Skip to content

Commit

Permalink
Add a simple postgresql storage provider (#350)
Browse files Browse the repository at this point in the history
* Add a simple postgresql storage provider

* Fix queries

* Add unit test

* Run postgres containers concurrently

* Log postgres for debugging workflow

* Adjust timeouts

* Upgrade dependencies?

* Adjust timeouts again
  • Loading branch information
turt2live authored Oct 30, 2023
1 parent cbbd9f8 commit 4acd80b
Show file tree
Hide file tree
Showing 6 changed files with 1,455 additions and 845 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/static_analysis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,6 @@ jobs:
- run: yarn install
- uses: nick-invision/retry@v2
with:
max_attempts: 5
timeout_minutes: 5
max_attempts: 3
timeout_minutes: 15
command: yarn test
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"docs": "jsdoc -c jsdoc.json -P package.json -u docs/tutorials",
"build": "tsc --listEmittedFiles -p tsconfig-release.json",
"lint": "eslint \"{src,test,examples}/**/*.ts\"",
"lint:fix": "eslint \"{src,test,examples}/**/*.ts\" --fix",
"test": "jest",
"build:examples": "tsc -p tsconfig-examples.json",
"example:bot": "yarn build:examples && node lib/examples/bot.js",
Expand Down Expand Up @@ -65,6 +66,7 @@
"lru-cache": "^10.0.1",
"mkdirp": "^3.0.1",
"morgan": "^1.10.0",
"postgres": "^3.4.1",
"request": "^2.88.2",
"request-promise": "^4.2.6",
"sanitize-html": "^2.11.0"
Expand All @@ -73,6 +75,7 @@
"@babel/core": "^7.23.2",
"@babel/eslint-parser": "^7.22.15",
"@babel/eslint-plugin": "^7.22.10",
"@testcontainers/postgresql": "^10.2.2",
"@types/async-lock": "^1.4.1",
"@types/jest": "^29.5.6",
"@types/lowdb": "^1.0.14",
Expand All @@ -93,6 +96,7 @@
"matrix-mock-request": "^2.6.0",
"simple-mock": "^0.8.0",
"taffydb": "^2.7.3",
"testcontainers": "^10.2.2",
"tmp": "^0.2.1",
"ts-jest": "^29.1.1",
"typescript": "^5.2.2"
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ export * from "./storage/MemoryStorageProvider";
export * from "./storage/SimpleFsStorageProvider";
export * from "./storage/ICryptoStorageProvider";
export * from "./storage/RustSdkCryptoStorageProvider";
export * from "./storage/SimplePostgresStorageProvider";

// Strategies
export * from "./strategies/AppserviceJoinRoomStrategy";
Expand Down
168 changes: 168 additions & 0 deletions src/storage/SimplePostgresStorageProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import * as postgres from "postgres";

import { IStorageProvider } from "./IStorageProvider";
import { IAppserviceStorageProvider } from "./IAppserviceStorageProvider";
import { IFilterInfo } from "../IFilter";

/**
* A barebones postgresql storage provider. It is not efficient, but it does work.
* @category Storage providers
*/
export class SimplePostgresStorageProvider implements IStorageProvider, IAppserviceStorageProvider {
private readonly db: postgres.Sql;
private readonly waitPromise: Promise<void>;
private completedTransactions = [];

/**
* Creates a new simple postgresql storage provider.
* @param connectionString The `postgres://` connection string to use.
* @param trackTransactionsInMemory True (default) to track all received appservice transactions rather than on disk.
* @param maxInMemoryTransactions The maximum number of transactions to hold in memory before rotating the oldest out. Defaults to 20.
*/
constructor(connectionString: string, private trackTransactionsInMemory = true, private maxInMemoryTransactions = 20) {
this.db = postgres(connectionString);

this.waitPromise = Promise.all([
this.db`
CREATE TABLE IF NOT EXISTS bot_metadata (key TEXT NOT NULL PRIMARY KEY, value TEXT);
`,
this.db`
CREATE TABLE IF NOT EXISTS bot_kv (key TEXT NOT NULL PRIMARY KEY, value TEXT);
`,
this.db`
CREATE TABLE IF NOT EXISTS appservice_users (user_id TEXT NOT NULL PRIMARY KEY, registered BOOLEAN NOT NULL);
`,
this.db`
CREATE TABLE IF NOT EXISTS appservice_transactions (txn_id TEXT NOT NULL PRIMARY KEY, completed BOOLEAN NOT NULL);
`,
]).then();
}

public async setSyncToken(token: string | null): Promise<any> {
await this.waitPromise;
return this.db`
INSERT INTO bot_metadata (key, value) VALUES ('syncToken', ${token})
ON CONFLICT (key) DO UPDATE SET value = ${token};
`;
}

public async getSyncToken(): Promise<string | null> {
await this.waitPromise;
return (await this.db`
SELECT value FROM bot_metadata WHERE key = 'syncToken';
`)[0]?.value;
}

public async setFilter(filter: IFilterInfo): Promise<any> {
await this.waitPromise;
const filterStr = filter ? JSON.stringify(filter) : null;
return this.db`
INSERT INTO bot_metadata (key, value) VALUES ('filter', ${filterStr})
ON CONFLICT (key) DO UPDATE SET value = ${filterStr};
`;
}

public async getFilter(): Promise<IFilterInfo> {
await this.waitPromise;
const value = (await this.db`
SELECT value FROM bot_metadata WHERE key = 'filter';
`)[0]?.value;
return typeof value === "string" ? JSON.parse(value) : value;
}

public async addRegisteredUser(userId: string): Promise<any> {
await this.waitPromise;
return this.db`
INSERT INTO appservice_users (user_id, registered) VALUES (${userId}, TRUE)
ON CONFLICT (user_id) DO UPDATE SET registered = TRUE;
`;
}

public async isUserRegistered(userId: string): Promise<boolean> {
await this.waitPromise;
return !!(await this.db`
SELECT registered FROM appservice_users WHERE user_id = ${userId};
`)[0]?.registered;
}

public async setTransactionCompleted(transactionId: string): Promise<any> {
await this.waitPromise;
if (this.trackTransactionsInMemory) {
if (this.completedTransactions.indexOf(transactionId) === -1) {
this.completedTransactions.push(transactionId);
}
if (this.completedTransactions.length > this.maxInMemoryTransactions) {
this.completedTransactions = this.completedTransactions.reverse().slice(0, this.maxInMemoryTransactions).reverse();
}
return;
}

return this.db`
INSERT INTO appservice_transactions (txn_id, completed) VALUES (${transactionId}, TRUE)
ON CONFLICT (txn_id) DO UPDATE SET completed = TRUE;
`;
}

public async isTransactionCompleted(transactionId: string): Promise<boolean> {
await this.waitPromise;
if (this.trackTransactionsInMemory) {
return this.completedTransactions.includes(transactionId);
}

return (await this.db`
SELECT completed FROM appservice_transactions WHERE txn_id = ${transactionId};
`)[0]?.completed;
}

public async readValue(key: string): Promise<string | null | undefined> {
await this.waitPromise;
return (await this.db`
SELECT value FROM bot_kv WHERE key = ${key};
`)[0]?.value;
}

public async storeValue(key: string, value: string): Promise<void> {
await this.waitPromise;
return this.db`
INSERT INTO bot_kv (key, value) VALUES (${key}, ${value})
ON CONFLICT (key) DO UPDATE SET value = ${value};
`.then();
}

public storageForUser(userId: string): IStorageProvider {
return new NamespacedPostgresProvider(userId, this);
}
}

/**
* A namespaced storage provider that uses postgres to store information.
* @category Storage providers
*/
class NamespacedPostgresProvider implements IStorageProvider {
constructor(private prefix: string, private parent: SimplePostgresStorageProvider) {
}

public setFilter(filter: IFilterInfo): Promise<any> | void {
return this.parent.storeValue(`${this.prefix}_internal_filter`, JSON.stringify(filter));
}

public async getFilter(): Promise<IFilterInfo> {
return this.parent.readValue(`${this.prefix}_internal_filter`).then(r => r ? JSON.parse(r) : r);
}

public setSyncToken(token: string | null): Promise<any> | void {
return this.parent.storeValue(`${this.prefix}_internal_syncToken`, token ?? "");
}

public async getSyncToken(): Promise<string> {
return this.parent.readValue(`${this.prefix}_internal_syncToken`).then(r => r === "" ? null : r);
}

public storeValue(key: string, value: string): Promise<any> | void {
return this.parent.storeValue(`${this.prefix}_internal_kv_${key}`, value);
}

public readValue(key: string): string | Promise<string | null | undefined> | null | undefined {
return this.parent.readValue(`${this.prefix}_internal_kv_${key}`);
}
}
Loading

0 comments on commit 4acd80b

Please sign in to comment.