Skip to content

Commit

Permalink
implement a class to access the irclog couchdb api
Browse files Browse the repository at this point in the history
  • Loading branch information
gdamjan committed Dec 2, 2023
1 parent b1ff8fe commit 9701861
Show file tree
Hide file tree
Showing 7 changed files with 313 additions and 7 deletions.
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1 +1,22 @@
# `irclog-api`

Typescript API to access the IrcLog CouchDB database. Mostly typed wrappers over the http api of CouchDB.


## Quick start for users

```
TBD
```

## Quick start for developers

TBD
```
pnpm install
pnpm dev
```

## References

- https://docs.couchdb.org/en/stable/api/ddoc/views.html#db-design-design-doc-view-view-name
117 changes: 117 additions & 0 deletions src/couch-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/*
* Low level wrappers over fetch to work with couchdb
*
*/

import { Channel, Message, Query, ViewResponse, MessageViewResponse } from './types';
import { postQuery } from './post-query';

const commonQueryArgs = {
include_docs: true,
update_seq: true,
reduce: false,
};

// hard-coded based on the Couch Application deployed
const COUCH_DESIGN_DOC = '_design/log/_view/channel';
const COUCH_URL = 'https://db.softver.org.mk/irclog/';

/**
* FIXME: docs
*/
export class CouchDB {
#couchUrl: URL;
#designDoc: string;
#queryUrl: URL;

constructor(couchUrl: string, designDoc?: string) {
this.#couchUrl = new URL(couchUrl);
this.#designDoc = designDoc ?? COUCH_DESIGN_DOC;
this.#queryUrl = new URL(this.#designDoc, this.#couchUrl);
}

async fetchViewLatest(channel: string, limit = 100): Promise<MessageViewResponse> {
const query: Query = {
...commonQueryArgs,
limit: limit,
descending: true,
startkey: [channel, {}],
endkey: [channel, 0],
};

// FIXME: validation needed here
const page = await postQuery(this.#queryUrl, query);
page.rows.reverse();
page.rows = page.rows.map((row: { doc: Message }) => row.doc);
return page;
}

async fetchViewAtTimestamp(channel: string, timestamp: number, limit: number): Promise<MessageViewResponse> {
const query: Query = {
...commonQueryArgs,
limit: limit,
descending: false,
startkey: [channel, timestamp],
endkey: [channel, {}],
};

// FIXME: validation needed here
const page = await postQuery(this.#queryUrl, query);
page.rows = page.rows.map((row: { doc: Message }) => row.doc);
return page;
}

async fetchViewBefore(channel: string, firstRow: Message, limit: number): Promise<MessageViewResponse> {
const query: Query = {
...commonQueryArgs,
limit: limit,
descending: true,
skip: 1,
startkey: [channel, firstRow.timestamp],
startkey_docid: firstRow._id,
endkey: [channel, 0],
};

// FIXME: validation needed here
const view = await postQuery(this.#queryUrl, query);
view.rows.reverse();
view.rows = view.rows.map((row: { doc: Message }) => row.doc);
return view;
}

async fetchViewAfter(channel: string, lastRow: Message, limit: number): Promise<MessageViewResponse> {
const query: Query = {
...commonQueryArgs,
limit: limit,
descending: false,
skip: 1,
startkey: [channel, lastRow.timestamp],
startkey_docid: lastRow._id,
endkey: [channel, {}],
};

// FIXME: validation needed here
const view = await postQuery(this.#queryUrl, query);
view.rows = view.rows.map((row: { doc: Message }) => row.doc);
return view;
}

async fetchChannelList(): Promise<Channel[]> {
const query: Query = {
update_seq: true,
reduce: true,
group_level: 1,
include_docs: false,
};

// FIXME: validation needed here
const chanList = (await postQuery(this.#queryUrl, query)) as ViewResponse<GroupLevel1Row>;
return chanList.rows?.map(extractChannelData) ?? [];
}
}

type GroupLevel1Row = { key: [string]; value: number };

function extractChannelData(row: GroupLevel1Row): Channel {
return { channelName: row.key[0], totalMessages: row.value };
}
102 changes: 102 additions & 0 deletions src/fetch-feed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { Message } from './types';

export type ChangesResponse = {
results: { doc: Message }[];
last_seq: string;
};

export async function fetchFeed(
couchUrl: URL,
channel: string,
since: string,
signal?: AbortSignal,
): Promise<ChangesResponse> {
const feedUrl = new URL('_changes', couchUrl);
const query = {
feed: 'longpoll',
timeout: '90000',
include_docs: 'true',
filter: '_selector',
since: since,
};
feedUrl.search = new URLSearchParams(query).toString();

const options: RequestInit = {
signal,
mode: 'cors',
headers: {
accept: 'application/json',
'content-type': 'application/json',
},
body: JSON.stringify({ selector: { channel: channel } }),
method: 'POST',
};
const response = await fetch(`${feedUrl}`, options);
if (!response.ok) {
throw new Error(`Network response was not ok: ${response.statusText}`);
}
return await response.json();
}

/*
* Experimental Feed using the Server-Sent-Events stream from CouchDB
*/

function parseSseEvent(data: string) {
const tmp: Record<string, string> = {};
const lines = data.split(/\n/);
for (const line of lines) {
if (line) {
const [key, value] = line.split(/: /, 2);
tmp[key] = value;
}
}
return new MessageEvent<string>('message', tmp);
}

function asEventSourceStream() {
let buffer: string;
const transformer: Transformer<string, MessageEvent<string>> = {
start() {
buffer = '';
},
transform(chunk, controller) {
buffer += chunk;
while (true) {
const [event, sep, rest] = buffer.split(/(\n\n)/, 3);
if (sep !== '\n\n') break;
buffer = rest;
controller.enqueue(parseSseEvent(event));
if (rest === '') break;
}
},
};
return new TransformStream(transformer);
}

export async function fetchEventStream(url: URL, heartbeat?: number, init?: RequestInit) {
const heartbeat_ = heartbeat ?? 60000;

const headers = { ...init?.headers, Accept: 'text/event-stream' };
const u = new URL(url);
u.searchParams.set('feed', 'eventsource');
u.searchParams.set('heartbeat', `${heartbeat_}`);
u.searchParams.set('since', 'now');

const controller = new AbortController();
const signal = controller.signal;

const resp = await fetch(u, { ...init, headers, signal });
if (!resp.ok) return;

const reader = resp.body!.pipeThrough(new TextDecoderStream()).pipeThrough(asEventSourceStream()).getReader();

while (true) {
let timeoutID = setTimeout(() => controller.abort(), heartbeat_ * 2);
const { done, value } = await reader.read();
clearTimeout(timeoutID);
if (done) break;
console.log(value);
}
console.log('Stream done');
}
6 changes: 0 additions & 6 deletions src/hello-world.ts

This file was deleted.

2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { sayHello, sayGoodbye } from './hello-world';
export { CouchDB } from './couch-api';
32 changes: 32 additions & 0 deletions src/post-query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Query } from './types';

/**
* Helper function to send a query to the irc log CouchDB View using an http post request
*
* https://docs.couchdb.org/en/stable/api/ddoc/views.html#querying-views-and-indexes
* https://docs.couchdb.org/en/stable/ddocs/views/intro.html
*
* @param url
* @param query
* @returns A Fetch API Response - query results
*/
export async function postQuery(url: URL, query: Query) {
const options: RequestInit = {
mode: 'cors',
headers: {
accept: 'application/json',
'content-type': 'application/json',
},
method: 'POST',
};

const response = await fetch(url, {
...options,
body: JSON.stringify(query),
});

if (!response.ok) {
throw new Error(`Network response was not ok: ${response.statusText}`);
}
return response.json();
}
40 changes: 40 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
export type Channel = {
channelName: string;
totalMessages: number;
};

export type Message = {
timestamp: number;
sender: string;
channel: string;
message: string;
_id: string;
};

// TODO: map all from here: https://docs.couchdb.org/en/stable/api/ddoc/views.html#db-design-design-doc-view-view-name
export type Query = {
include_docs: boolean;
update_seq: boolean;
reduce: boolean;
group_level?: number;
limit?: number;
skip?: number;
descending?: boolean;
startkey?: any;
endkey?: any;
startkey_docid?: any;
endkey_docid?: any;
// [key: string]: any;
};

// CouchDB Result schema
export type ViewResponse<TRow> = {
rows: TRow[];
update_seq: string;
total_rows?: number;
offset?: number;
};

export type MessageViewResponse = {
channel: string;
} & ViewResponse<Message>;

0 comments on commit 9701861

Please sign in to comment.