add basic group api

This commit is contained in:
tsukino
2024-01-02 14:52:45 -08:00
parent 655be2e716
commit 94fcc09440
12 changed files with 912 additions and 297 deletions

782
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -26,11 +26,13 @@
"async-mutex": "0.4.0",
"eventemitter2": "^6.4.9",
"node-cache": "^5.1.2",
"sequelize": "^6.35.2",
"pg": "^8.11.3",
"pg-native": "^3.0.1",
"yargs": "^17.7.2"
},
"devDependencies": {
"@types/yargs": "^17.0.32",
"@types/pg": "^8.10.9",
"nodemon": "^3.0.2"
}
}

View File

@@ -1,7 +1,16 @@
import GroupRegistry from '../src';
(async () => {
const registry = new GroupRegistry();
const registry = new GroupRegistry({
user: 'postgres',
password: 'password',
host: 'localhost',
pgPort: 5432,
database: 'group-dev',
max: 10,
idleTimeoutMillis: 30000,
});
await registry.start();
console.log(registry);
})();

View File

@@ -1,13 +1,53 @@
import HttpServer from './services/web.ts';
import IHttpServer, {
HttpServerOptions,
StatusCode,
} from './services/web/index.ts';
import IDatabase, { DatabaseOptions } from './services/db/index.ts';
const HttpServer = require('./services/web/worker.ts');
const Database = require('./services/db/worker.ts');
export default class GroupRegistry {
#http: HttpServer;
constructor() {
this.#http = new HttpServer();
}
#options?: HttpServerOptions & DatabaseOptions;
#http?: IHttpServer;
#db?: IDatabase;
constructor(options?: HttpServerOptions & DatabaseOptions) {
this.#options = options;
}
async start() {
await this.#http.start();
console.log('started');
if (Database) {
this.#db = new Database(this.#options) as IDatabase;
if (!Database.injected && this.#db.start) {
await this.#db.start();
}
}
if (HttpServer) {
this.#http = new IHttpServer(this.#options) as IHttpServer;
if (!HttpServer.injected && this.#http.start) {
await this.#http.start();
}
}
this.#http!.on('/health', (_, res) => {
res.writeHead(StatusCode.Ok, { 'Content-Type': 'text' });
res.end('ok');
});
this.#http!.on('/groups/:id', async (req, res) => {
const { id } = req.params!;
const result = await this.#db?.getGroupInfo(id);
res.writeHead(StatusCode.Ok, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(result));
});
this.#http!.on('/groups', async (req, res) => {
console.time('groups');
const result = await this.#db?.getGroups();
res.writeHead(StatusCode.Ok, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(result));
console.timeEnd('groups');
});
}
}

View File

@@ -0,0 +1,112 @@
import { Pool } from 'pg';
import groups from '../../../static/groups.json';
export type DatabaseOptions = {
host?: string;
pgPort?: number;
database?: string;
user?: string;
password?: string;
max?: number;
idleTimeoutMillis?: number;
};
export type GroupInfo = {
groupId: string;
title: string;
description: string;
iconUrl: string;
};
export default class Database {
#pool: Pool;
constructor(options?: DatabaseOptions) {
this.#pool = new Pool(options);
}
async start() {
await this.#pool.connect();
await this.#prepareTables();
}
async #prepareTables() {
await this.#pool.query(`
CREATE TABLE IF NOT EXISTS users (
commitment VARCHAR(66) PRIMARY KEY,
group_id VARCHAR(255) NOT NULL
);
CREATE INDEX IF NOT EXISTS users_group_idx ON users (group_id);
CREATE TABLE IF NOT EXISTS groups (
group_id VARCHAR(255) PRIMARY KEY,
title VARCHAR(255) NOT NULL,
description VARCHAR(4095) NOT NULL,
icon_url VARCHAR(255)
);
`);
for (const group of groups) {
await this.#pool.query(
`
INSERT INTO groups(group_id, title, description)
SELECT $1::text, $2, $3
WHERE
NOT EXISTS (
SELECT * FROM groups WHERE group_id = $1::text
);
`,
[group.id, group.title, group.description],
);
}
}
async insertCommitment(commitment: string, groupId: string) {
await this.#pool.query(
`
INSERT INTO users(commitment, group_id)
VALUES ($1, $2)
`,
[commitment, groupId],
);
}
async updateCommitment(
newCommitment: string,
groupId: string,
oldCommitment: string,
) {
await this.#pool.query(
`
UPDATE users(commitment, group_id)
SET commitment = $1
WHERE group_id = $2 AND commitment = $3
`,
[newCommitment, groupId, oldCommitment],
);
}
async getGroupInfo(groupId: string): Promise<GroupInfo | null> {
const res = await this.#pool.query(
`
SELECT * FROM groups
WHERE group_id = $1
`,
[groupId],
);
return res?.rows[0] || null;
}
async getGroups(): Promise<GroupInfo[]> {
const res = await this.#pool.query(
`
SELECT * FROM groups
`,
[],
);
return res.rows;
}
}

View File

@@ -0,0 +1,14 @@
import { Worker, isMainThread } from 'worker_threads';
import Database from './index.ts';
function initializeClass() {
new Worker(__filename);
}
initializeClass.injected = true;
if (isMainThread) {
module.exports = initializeClass;
} else {
module.exports = Database;
}

View File

@@ -1,32 +0,0 @@
import * as http from 'http';
import { parse } from 'url';
import logger from '../../../utilities/src/logger';
export type HttpServerOptions = {
port?: number;
};
export default class HttpServer {
#port: number;
constructor(options?: HttpServerOptions) {
const { port = 11014 } = options || {};
this.#port = port;
}
async start() {
return new Promise((resolve) => {
const server = http.createServer(this.#onServerStart);
server.listen(this.#port, () => {
logger.info(`Server is running on port ${this.#port}`);
resolve(server);
});
});
}
#onServerStart = (req: http.IncomingMessage, res: http.ServerResponse) => {
const url = parse(req.url || '', true);
console.log(url);
res.end('a');
};
}

View File

@@ -0,0 +1,70 @@
import { parse } from 'url';
import logger from '../../../../utilities/src/logger.ts';
import { testPath } from '../../../../utilities/src/encoding.ts';
import * as http from 'http';
export type HttpServerOptions = {
port?: number;
};
export enum StatusCode {
Ok = 200,
Created = 201,
BadRequest = 400,
Unauthorized = 401,
Forbidden = 403,
NotFound = 404,
InternalServerError = 500,
NotImplemented = 501,
BadGateway = 502,
ServiceUnavailable = 503,
}
export type Request = http.IncomingMessage & {
params?: { [key: string]: string };
};
export default class HttpServer {
#port: number;
#handlers: [string, (req: Request, res: http.ServerResponse) => void][] = [];
#server: http.Server;
constructor(options?: HttpServerOptions) {
const { port = 11014 } = options || {};
this.#port = port;
}
async start() {
if (this.#server) return;
new Promise((resolve) => {
this.#server = http.createServer(this.#onRequest);
this.#server.listen(this.#port, () => {
logger.info(`Server is running on port ${this.#port}`);
resolve(this.#server);
});
});
}
#onRequest = (req: Request, res: http.ServerResponse) => {
const url = parse(req.url || '', true);
for (const [pattern, handler] of this.#handlers) {
const params = testPath(pattern, url.pathname);
if (params) {
req.params = params;
handler(req, res);
return;
}
}
res.writeHead(StatusCode.NotImplemented, { 'Content-Type': 'text' });
res.end('not implememted');
};
on = (
path: string,
handler: (req: Request, res: http.ServerResponse) => void,
) => {
this.#handlers.push([path, handler]);
};
}

View File

@@ -0,0 +1,14 @@
import { Worker, isMainThread } from 'worker_threads';
import HttpServer from './index.ts';
function initializeClass() {
const worker = new Worker(__filename);
}
initializeClass.injected = true;
if (isMainThread) {
module.exports = initializeClass;
} else {
module.exports = HttpServer;
}

View File

@@ -0,0 +1,82 @@
[
{
"id": "twitter_common",
"title": "Common Twitter User",
"description": "Any Twitter users"
},
{
"id": "twitter_uncommon",
"title": "Uncommon Twitter User",
"description": "Any Twitter users with 100+ followers"
},
{
"id": "twitter_rare",
"title": "Rare Twitter User",
"description": "Any Twitter users with 1k+ followers"
},
{
"id": "twitter_epic",
"title": "Epic Twitter User",
"description": "Any Twitter users with 10k+ followers"
},
{
"id": "twitter_legendary",
"title": "Legendary Twitter User",
"description": "Any Twitter users with 100k+ followers"
},
{
"id": "twitter_mythic",
"title": "Mythic Twitter User",
"description": "Any Twitter users with 1M+ followers"
},
{
"id": "reddit_common",
"title": "Common Reddit User",
"description": "Any Reddit users"
},
{
"id": "reddit_uncommon",
"title": "Uncommon Reddit User",
"description": "Any Reddit users with 2k+ karma"
},
{
"id": "reddit_rare",
"title": "Rare Reddit User",
"description": "Any Reddit users with 20k+ karma"
},
{
"id": "reddit_epic",
"title": "Epic Reddit User",
"description": "Any Reddit users with 200k+ karma"
},
{
"id": "reddit_legendary",
"title": "Legendary Reddit User",
"description": "Any Reddit users with 500k+ karma"
},
{
"id": "github_common",
"title": "Common Github User",
"description": "Any Github users"
},
{
"id": "github_uncommon",
"title": "Uncommon Github User",
"description": "Any Github users with under 10+ stars"
},
{
"id": "github_rare",
"title": "Rare Github User",
"description": "Any Github users with 100+ stars"
},
{
"id": "github_epic",
"title": "Epic Github User",
"description": "Any Github users with 1000+ stars"
},
{
"id": "github_legendary",
"title": "Legendary Github User",
"description": "Any Github users with 10000+ stars"
}
]

View File

@@ -277,7 +277,7 @@ tape('Message Format', (t) => {
subtype: GroupSubtype.MemberRequest,
creator: '0xa1b77ccf93a2b14174c322d673a87bfa0031a2d2',
createdAt: new Date(0x018a01173656),
groupId: 'group-id',
groupId: 'db-id',
data: ['a', 'ab', 'abcdefg'],
});
@@ -288,7 +288,7 @@ tape('Message Format', (t) => {
subtype: GroupSubtype.Broadcast,
creator: '0xa1b77ccf93a2b14174c322d673a87bfa0031a2d2',
createdAt: new Date(0x018a01173656),
groupId: 'group-id',
groupId: 'db-id',
data: msgA.hex,
});
@@ -318,7 +318,7 @@ tape('Message Format', (t) => {
subtype: GroupSubtype.MemberRequest,
creator: '0xa1b77ccf93a2b14174c322d673a87bfa0031a2d2',
createdAt: new Date(0x018a01173656),
groupId: 'group-id',
groupId: 'db-id',
data: ['a', 'ab', 'abcdefg'],
proof: undefined,
},
@@ -333,7 +333,7 @@ tape('Message Format', (t) => {
subtype: GroupSubtype.Broadcast,
creator: '0xa1b77ccf93a2b14174c322d673a87bfa0031a2d2',
createdAt: new Date(0x018a01173656),
groupId: 'group-id',
groupId: 'db-id',
data: msgA.hex,
proof: undefined,
},

View File

@@ -0,0 +1,26 @@
export function testPath(pattern: string, path?: string | null) {
const tmpls = pattern.split("/");
const parts = (path || "").split("/");
const params: { [key: string]: string } = {};
let isWildcard = false;
for (let i = 1; i < tmpls.length; i++) {
const tmpl = tmpls[i];
const part = parts[i];
isWildcard = isWildcard || tmpl === "*";
const isParam = tmpl.slice(0, 1) === ":";
const isOptional = tmpl.slice(-1) === "?";
const paramName = tmpl.replace(/[:?]/g, "");
if (isWildcard) {
} else if (!isParam) {
if (part !== tmpl) return false;
} else if (isParam && !isOptional) {
if (!part) return;
params[paramName] = part;
} else if (isParam && isOptional) {
params[paramName] = part;
}
}
return params;
}