@fedify/postgres 1.8.1-dev.1233 → 1.8.1-dev.1238
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/kv.d.ts +1 -1
- package/dist/kv.js +1 -1
- package/dist/mq.d.ts +1 -1
- package/dist/mq.js +1 -1
- package/dist/utils.js +1 -1
- package/package.json +8 -3
- package/deno.json +0 -22
- package/kv.test.ts +0 -142
- package/kv.ts +0 -146
- package/mod.ts +0 -2
- package/mq.test.ts +0 -115
- package/mq.ts +0 -261
- package/tsdown.config.ts +0 -13
- package/utils.ts +0 -7
package/dist/kv.d.ts
CHANGED
package/dist/kv.js
CHANGED
package/dist/mq.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { Temporal } from "@js-temporal/polyfill";
|
|
|
2
2
|
import { Sql } from "postgres";
|
|
3
3
|
import { MessageQueue, MessageQueueEnqueueOptions, MessageQueueListenOptions } from "@fedify/fedify";
|
|
4
4
|
|
|
5
|
-
//#region mq.d.ts
|
|
5
|
+
//#region src/mq.d.ts
|
|
6
6
|
/**
|
|
7
7
|
* Options for the PostgreSQL message queue.
|
|
8
8
|
*/
|
package/dist/mq.js
CHANGED
package/dist/utils.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
|
|
2
2
|
import { Temporal } from "@js-temporal/polyfill";
|
|
3
3
|
|
|
4
|
-
//#region utils.ts
|
|
4
|
+
//#region src/utils.ts
|
|
5
5
|
async function driverSerializesJson(sql) {
|
|
6
6
|
const result = await sql`SELECT ${sql.json("{\"foo\":1}")}::jsonb AS test;`;
|
|
7
7
|
return result[0].test === "{\"foo\":1}";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fedify/postgres",
|
|
3
|
-
"version": "1.8.1-dev.
|
|
3
|
+
"version": "1.8.1-dev.1238+4e631e65",
|
|
4
4
|
"description": "PostgreSQL drivers for Fedify",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"fedify",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"repository": {
|
|
18
18
|
"type": "git",
|
|
19
19
|
"url": "git+https://github.com/fedify-dev/fedify.git",
|
|
20
|
-
"directory": "postgres"
|
|
20
|
+
"directory": "packages/postgres"
|
|
21
21
|
},
|
|
22
22
|
"bugs": {
|
|
23
23
|
"url": "https://github.com/fedify-dev/fedify/issues"
|
|
@@ -48,13 +48,18 @@
|
|
|
48
48
|
},
|
|
49
49
|
"./package.json": "./package.json"
|
|
50
50
|
},
|
|
51
|
+
"files": [
|
|
52
|
+
"dist",
|
|
53
|
+
"package.json",
|
|
54
|
+
"README.md"
|
|
55
|
+
],
|
|
51
56
|
"dependencies": {
|
|
52
57
|
"@js-temporal/polyfill": "^0.5.1",
|
|
53
58
|
"@logtape/logtape": "^1.0.0"
|
|
54
59
|
},
|
|
55
60
|
"peerDependencies": {
|
|
56
61
|
"postgres": "^3.4.7",
|
|
57
|
-
"@fedify/fedify": "1.8.1-dev.
|
|
62
|
+
"@fedify/fedify": "1.8.1-dev.1238+4e631e65"
|
|
58
63
|
},
|
|
59
64
|
"devDependencies": {
|
|
60
65
|
"@std/async": "npm:@jsr/std__async@^1.0.13",
|
package/deno.json
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@fedify/postgres",
|
|
3
|
-
"version": "1.8.1-dev.1233+f748fcc5",
|
|
4
|
-
"license": "MIT",
|
|
5
|
-
"exports": {
|
|
6
|
-
".": "./mod.ts",
|
|
7
|
-
"./kv": "./kv.ts",
|
|
8
|
-
"./mq": "./mq.ts"
|
|
9
|
-
},
|
|
10
|
-
"nodeModulesDir": "none",
|
|
11
|
-
"unstable": [
|
|
12
|
-
"temporal"
|
|
13
|
-
],
|
|
14
|
-
"exclude": [
|
|
15
|
-
"dist",
|
|
16
|
-
"node_modules"
|
|
17
|
-
],
|
|
18
|
-
"tasks": {
|
|
19
|
-
"check": "deno fmt --check && deno lint && deno check *.ts",
|
|
20
|
-
"test": "deno test --allow-net --allow-env"
|
|
21
|
-
}
|
|
22
|
-
}
|
package/kv.test.ts
DELETED
|
@@ -1,142 +0,0 @@
|
|
|
1
|
-
import { PostgresKvStore } from "@fedify/postgres/kv";
|
|
2
|
-
import * as temporal from "@js-temporal/polyfill";
|
|
3
|
-
import { delay } from "@std/async/delay";
|
|
4
|
-
import assert from "node:assert/strict";
|
|
5
|
-
import process from "node:process";
|
|
6
|
-
import { test } from "node:test";
|
|
7
|
-
import postgres from "postgres";
|
|
8
|
-
|
|
9
|
-
let Temporal: typeof temporal.Temporal;
|
|
10
|
-
if ("Temporal" in globalThis) {
|
|
11
|
-
Temporal = globalThis.Temporal;
|
|
12
|
-
} else {
|
|
13
|
-
Temporal = temporal.Temporal;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
const dbUrl = process.env.DATABASE_URL;
|
|
17
|
-
|
|
18
|
-
function getStore(): {
|
|
19
|
-
// deno-lint-ignore no-explicit-any
|
|
20
|
-
sql: postgres.Sql<any>;
|
|
21
|
-
tableName: string;
|
|
22
|
-
store: PostgresKvStore;
|
|
23
|
-
} {
|
|
24
|
-
const sql = postgres(dbUrl!);
|
|
25
|
-
const tableName = `fedify_kv_test_${Math.random().toString(36).slice(5)}`;
|
|
26
|
-
return {
|
|
27
|
-
sql,
|
|
28
|
-
tableName,
|
|
29
|
-
store: new PostgresKvStore(sql, { tableName }),
|
|
30
|
-
};
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
test("PostgresKvStore.initialize()", { skip: dbUrl == null }, async () => {
|
|
34
|
-
if (dbUrl == null) return; // Bun does not support skip option
|
|
35
|
-
const { sql, tableName, store } = getStore();
|
|
36
|
-
try {
|
|
37
|
-
await store.initialize();
|
|
38
|
-
const result = await sql`
|
|
39
|
-
SELECT to_regclass(${tableName}) IS NOT NULL AS exists;
|
|
40
|
-
`;
|
|
41
|
-
assert(result[0].exists);
|
|
42
|
-
} finally {
|
|
43
|
-
await store.drop();
|
|
44
|
-
await sql.end();
|
|
45
|
-
}
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
test("PostgresKvStore.get()", { skip: dbUrl == null }, async () => {
|
|
49
|
-
if (dbUrl == null) return; // Bun does not support skip option
|
|
50
|
-
const { sql, tableName, store } = getStore();
|
|
51
|
-
try {
|
|
52
|
-
await store.initialize();
|
|
53
|
-
await sql`
|
|
54
|
-
INSERT INTO ${sql(tableName)} (key, value)
|
|
55
|
-
VALUES (${["foo", "bar"]}, ${["foobar"]})
|
|
56
|
-
`;
|
|
57
|
-
assert.deepStrictEqual(await store.get(["foo", "bar"]), ["foobar"]);
|
|
58
|
-
|
|
59
|
-
await sql`
|
|
60
|
-
INSERT INTO ${sql(tableName)} (key, value, ttl)
|
|
61
|
-
VALUES (${["foo", "bar", "ttl"]}, ${["foobar"]}, ${"0 seconds"})
|
|
62
|
-
`;
|
|
63
|
-
await delay(500);
|
|
64
|
-
assert.strictEqual(await store.get(["foo", "bar", "ttl"]), undefined);
|
|
65
|
-
} finally {
|
|
66
|
-
await store.drop();
|
|
67
|
-
await sql.end();
|
|
68
|
-
}
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
test("PostgresKvStore.set()", { skip: dbUrl == null }, async () => {
|
|
72
|
-
if (dbUrl == null) return; // Bun does not support skip option
|
|
73
|
-
const { sql, tableName, store } = getStore();
|
|
74
|
-
try {
|
|
75
|
-
await store.set(["foo", "baz"], "baz");
|
|
76
|
-
const result = await sql`
|
|
77
|
-
SELECT * FROM ${sql(tableName)}
|
|
78
|
-
WHERE key = ${["foo", "baz"]}
|
|
79
|
-
`;
|
|
80
|
-
assert.strictEqual(result.length, 1);
|
|
81
|
-
assert.deepStrictEqual(result[0].key, ["foo", "baz"]);
|
|
82
|
-
assert.strictEqual(result[0].value, "baz");
|
|
83
|
-
assert.strictEqual(result[0].ttl, null);
|
|
84
|
-
|
|
85
|
-
await store.set(["foo", "qux"], "qux", {
|
|
86
|
-
ttl: Temporal.Duration.from({ days: 1 }),
|
|
87
|
-
});
|
|
88
|
-
const result2 = await sql`
|
|
89
|
-
SELECT * FROM ${sql(tableName)}
|
|
90
|
-
WHERE key = ${["foo", "qux"]}
|
|
91
|
-
`;
|
|
92
|
-
assert.strictEqual(result2.length, 1);
|
|
93
|
-
assert.deepStrictEqual(result2[0].key, ["foo", "qux"]);
|
|
94
|
-
assert.strictEqual(result2[0].value, "qux");
|
|
95
|
-
assert.strictEqual(result2[0].ttl, "1 day");
|
|
96
|
-
|
|
97
|
-
await store.set(["foo", "quux"], true);
|
|
98
|
-
const result3 = await sql`
|
|
99
|
-
SELECT * FROM ${sql(tableName)}
|
|
100
|
-
WHERE key = ${["foo", "quux"]}
|
|
101
|
-
`;
|
|
102
|
-
assert.strictEqual(result3.length, 1);
|
|
103
|
-
assert.deepStrictEqual(result3[0].key, ["foo", "quux"]);
|
|
104
|
-
assert.strictEqual(result3[0].value, true);
|
|
105
|
-
assert.strictEqual(result3[0].ttl, null);
|
|
106
|
-
} finally {
|
|
107
|
-
await store.drop();
|
|
108
|
-
await sql.end();
|
|
109
|
-
}
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
test("PostgresKvStore.delete()", { skip: dbUrl == null }, async () => {
|
|
113
|
-
if (dbUrl == null) return; // Bun does not support skip option
|
|
114
|
-
const { sql, tableName, store } = getStore();
|
|
115
|
-
try {
|
|
116
|
-
await store.delete(["foo", "bar"]);
|
|
117
|
-
const result = await sql`
|
|
118
|
-
SELECT * FROM ${sql(tableName)}
|
|
119
|
-
WHERE key = ${["foo", "bar"]}
|
|
120
|
-
`;
|
|
121
|
-
assert.strictEqual(result.length, 0);
|
|
122
|
-
} finally {
|
|
123
|
-
await store.drop();
|
|
124
|
-
await sql.end();
|
|
125
|
-
}
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
test("PostgresKvStore.drop()", { skip: dbUrl == null }, async () => {
|
|
129
|
-
if (dbUrl == null) return; // Bun does not support skip option
|
|
130
|
-
const { sql, tableName, store } = getStore();
|
|
131
|
-
try {
|
|
132
|
-
await store.drop();
|
|
133
|
-
const result2 = await sql`
|
|
134
|
-
SELECT to_regclass(${tableName}) IS NOT NULL AS exists;
|
|
135
|
-
`;
|
|
136
|
-
assert.ok(!result2[0].exists);
|
|
137
|
-
} finally {
|
|
138
|
-
await sql.end();
|
|
139
|
-
}
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
// cSpell: ignore regclass
|
package/kv.ts
DELETED
|
@@ -1,146 +0,0 @@
|
|
|
1
|
-
import type { KvKey, KvStore, KvStoreSetOptions } from "@fedify/fedify";
|
|
2
|
-
import { getLogger } from "@logtape/logtape";
|
|
3
|
-
import type { JSONValue, Parameter, Sql } from "postgres";
|
|
4
|
-
import { driverSerializesJson } from "./utils.ts";
|
|
5
|
-
|
|
6
|
-
const logger = getLogger(["fedify", "postgres", "kv"]);
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Options for the PostgreSQL key-value store.
|
|
10
|
-
*/
|
|
11
|
-
export interface PostgresKvStoreOptions {
|
|
12
|
-
/**
|
|
13
|
-
* The table name to use for the key-value store.
|
|
14
|
-
* `"fedify_kv_v2"` by default.
|
|
15
|
-
* @default `"fedify_kv_v2"`
|
|
16
|
-
*/
|
|
17
|
-
tableName?: string;
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Whether the table has been initialized. `false` by default.
|
|
21
|
-
* @default `false`
|
|
22
|
-
*/
|
|
23
|
-
initialized?: boolean;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* A key-value store that uses PostgreSQL as the underlying storage.
|
|
28
|
-
*
|
|
29
|
-
* @example
|
|
30
|
-
* ```ts
|
|
31
|
-
* import { createFederation } from "@fedify/fedify";
|
|
32
|
-
* import { PostgresKvStore } from "@fedify/postgres";
|
|
33
|
-
* import postgres from "postgres";
|
|
34
|
-
*
|
|
35
|
-
* const federation = createFederation({
|
|
36
|
-
* // ...
|
|
37
|
-
* kv: new PostgresKvStore(postgres("postgres://user:pass@localhost/db")),
|
|
38
|
-
* });
|
|
39
|
-
* ```
|
|
40
|
-
*/
|
|
41
|
-
export class PostgresKvStore implements KvStore {
|
|
42
|
-
// deno-lint-ignore ban-types
|
|
43
|
-
readonly #sql: Sql<{}>;
|
|
44
|
-
readonly #tableName: string;
|
|
45
|
-
#initialized: boolean;
|
|
46
|
-
#driverSerializesJson = false;
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Creates a new PostgreSQL key-value store.
|
|
50
|
-
* @param sql The PostgreSQL client to use.
|
|
51
|
-
* @param options The options for the key-value store.
|
|
52
|
-
*/
|
|
53
|
-
constructor(
|
|
54
|
-
// deno-lint-ignore ban-types
|
|
55
|
-
sql: Sql<{}>,
|
|
56
|
-
options: PostgresKvStoreOptions = {},
|
|
57
|
-
) {
|
|
58
|
-
this.#sql = sql;
|
|
59
|
-
this.#tableName = options.tableName ?? "fedify_kv_v2";
|
|
60
|
-
this.#initialized = options.initialized ?? false;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
async #expire(): Promise<void> {
|
|
64
|
-
await this.#sql`
|
|
65
|
-
DELETE FROM ${this.#sql(this.#tableName)}
|
|
66
|
-
WHERE ttl IS NOT NULL AND created + ttl < CURRENT_TIMESTAMP;
|
|
67
|
-
`;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
async get<T = unknown>(key: KvKey): Promise<T | undefined> {
|
|
71
|
-
await this.initialize();
|
|
72
|
-
const result = await this.#sql`
|
|
73
|
-
SELECT value
|
|
74
|
-
FROM ${this.#sql(this.#tableName)}
|
|
75
|
-
WHERE key = ${key} AND (ttl IS NULL OR created + ttl > CURRENT_TIMESTAMP);
|
|
76
|
-
`;
|
|
77
|
-
if (result.length < 1) return undefined;
|
|
78
|
-
return result[0].value as T;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
async set(
|
|
82
|
-
key: KvKey,
|
|
83
|
-
value: unknown,
|
|
84
|
-
options?: KvStoreSetOptions | undefined,
|
|
85
|
-
): Promise<void> {
|
|
86
|
-
await this.initialize();
|
|
87
|
-
const ttl = options?.ttl == null ? null : options.ttl.toString();
|
|
88
|
-
await this.#sql`
|
|
89
|
-
INSERT INTO ${this.#sql(this.#tableName)} (key, value, ttl)
|
|
90
|
-
VALUES (
|
|
91
|
-
${key},
|
|
92
|
-
${this.#json(value)},
|
|
93
|
-
${ttl}
|
|
94
|
-
)
|
|
95
|
-
ON CONFLICT (key)
|
|
96
|
-
DO UPDATE SET value = EXCLUDED.value, ttl = EXCLUDED.ttl;
|
|
97
|
-
`;
|
|
98
|
-
await this.#expire();
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
async delete(key: KvKey): Promise<void> {
|
|
102
|
-
await this.initialize();
|
|
103
|
-
await this.#sql`
|
|
104
|
-
DELETE FROM ${this.#sql(this.#tableName)}
|
|
105
|
-
WHERE key = ${key};
|
|
106
|
-
`;
|
|
107
|
-
await this.#expire();
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* Creates the table used by the key-value store if it does not already exist.
|
|
112
|
-
* Does nothing if the table already exists.
|
|
113
|
-
*/
|
|
114
|
-
async initialize(): Promise<void> {
|
|
115
|
-
if (this.#initialized) return;
|
|
116
|
-
logger.debug("Initializing the key-value store table {tableName}...", {
|
|
117
|
-
tableName: this.#tableName,
|
|
118
|
-
});
|
|
119
|
-
await this.#sql`
|
|
120
|
-
CREATE UNLOGGED TABLE IF NOT EXISTS ${this.#sql(this.#tableName)} (
|
|
121
|
-
key text[] PRIMARY KEY,
|
|
122
|
-
value jsonb NOT NULL,
|
|
123
|
-
created timestamp with time zone DEFAULT CURRENT_TIMESTAMP,
|
|
124
|
-
ttl interval
|
|
125
|
-
);
|
|
126
|
-
`;
|
|
127
|
-
this.#driverSerializesJson = await driverSerializesJson(this.#sql);
|
|
128
|
-
this.#initialized = true;
|
|
129
|
-
logger.debug("Initialized the key-value store table {tableName}.", {
|
|
130
|
-
tableName: this.#tableName,
|
|
131
|
-
});
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* Drops the table used by the key-value store. Does nothing if the table
|
|
136
|
-
* does not exist.
|
|
137
|
-
*/
|
|
138
|
-
async drop(): Promise<void> {
|
|
139
|
-
await this.#sql`DROP TABLE IF EXISTS ${this.#sql(this.#tableName)};`;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
#json(value: unknown): Parameter {
|
|
143
|
-
if (this.#driverSerializesJson) return this.#sql.json(value as JSONValue);
|
|
144
|
-
return this.#sql.json(JSON.stringify(value));
|
|
145
|
-
}
|
|
146
|
-
}
|
package/mod.ts
DELETED
package/mq.test.ts
DELETED
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
import { PostgresMessageQueue } from "@fedify/postgres/mq";
|
|
2
|
-
import * as temporal from "@js-temporal/polyfill";
|
|
3
|
-
import { delay } from "@std/async/delay";
|
|
4
|
-
import process from "node:process";
|
|
5
|
-
import assert from "node:assert/strict";
|
|
6
|
-
import { test } from "node:test";
|
|
7
|
-
import postgres from "postgres";
|
|
8
|
-
|
|
9
|
-
let Temporal: typeof temporal.Temporal;
|
|
10
|
-
if ("Temporal" in globalThis) {
|
|
11
|
-
Temporal = globalThis.Temporal;
|
|
12
|
-
} else {
|
|
13
|
-
Temporal = temporal.Temporal;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
const dbUrl = process.env.DATABASE_URL;
|
|
17
|
-
|
|
18
|
-
test("PostgresMessageQueue", { skip: dbUrl == null }, async () => {
|
|
19
|
-
if (dbUrl == null) return; // Bun does not support skip option
|
|
20
|
-
const sql = postgres(dbUrl!);
|
|
21
|
-
const sql2 = postgres(dbUrl!);
|
|
22
|
-
const tableName = `fedify_message_test_${
|
|
23
|
-
Math.random().toString(36).slice(5)
|
|
24
|
-
}`;
|
|
25
|
-
const channelName = `fedify_channel_test_${
|
|
26
|
-
Math.random().toString(36).slice(5)
|
|
27
|
-
}`;
|
|
28
|
-
const mq = new PostgresMessageQueue(sql, { tableName, channelName });
|
|
29
|
-
const mq2 = new PostgresMessageQueue(sql2, { tableName, channelName });
|
|
30
|
-
|
|
31
|
-
try {
|
|
32
|
-
const messages: string[] = [];
|
|
33
|
-
const controller = new AbortController();
|
|
34
|
-
const listening = mq.listen((message: string) => {
|
|
35
|
-
messages.push(message);
|
|
36
|
-
}, { signal: controller.signal });
|
|
37
|
-
const listening2 = mq2.listen((message: string) => {
|
|
38
|
-
messages.push(message);
|
|
39
|
-
}, { signal: controller.signal });
|
|
40
|
-
|
|
41
|
-
// enqueue()
|
|
42
|
-
await mq.enqueue("Hello, world!");
|
|
43
|
-
|
|
44
|
-
await waitFor(() => messages.length > 0, 15_000);
|
|
45
|
-
|
|
46
|
-
// listen()
|
|
47
|
-
assert.deepStrictEqual(messages, ["Hello, world!"]);
|
|
48
|
-
|
|
49
|
-
// enqueue() with delay
|
|
50
|
-
let started = 0;
|
|
51
|
-
started = Date.now();
|
|
52
|
-
await mq.enqueue(
|
|
53
|
-
{ msg: "Delayed message" },
|
|
54
|
-
{ delay: Temporal.Duration.from({ seconds: 3 }) },
|
|
55
|
-
);
|
|
56
|
-
|
|
57
|
-
await waitFor(() => messages.length > 1, 15_000);
|
|
58
|
-
|
|
59
|
-
// listen() with delay
|
|
60
|
-
assert.deepStrictEqual(messages, ["Hello, world!", {
|
|
61
|
-
msg: "Delayed message",
|
|
62
|
-
}]);
|
|
63
|
-
assert.ok(Date.now() - started > 3_000);
|
|
64
|
-
|
|
65
|
-
// enqueueMany()
|
|
66
|
-
while (messages.length > 0) messages.pop();
|
|
67
|
-
const batchMessages = [
|
|
68
|
-
"First batch message",
|
|
69
|
-
{ text: "Second batch message" },
|
|
70
|
-
{ text: "Third batch message", priority: "high" },
|
|
71
|
-
];
|
|
72
|
-
await mq.enqueueMany(batchMessages);
|
|
73
|
-
await waitFor(() => messages.length === batchMessages.length, 15_000);
|
|
74
|
-
assert.deepStrictEqual(messages, batchMessages);
|
|
75
|
-
|
|
76
|
-
// enqueueMany() with delay
|
|
77
|
-
while (messages.length > 0) messages.pop();
|
|
78
|
-
started = Date.now();
|
|
79
|
-
const delayedBatchMessages = [
|
|
80
|
-
"Delayed batch 1",
|
|
81
|
-
"Delayed batch 2",
|
|
82
|
-
];
|
|
83
|
-
await mq.enqueueMany(
|
|
84
|
-
delayedBatchMessages,
|
|
85
|
-
{ delay: Temporal.Duration.from({ seconds: 2 }) },
|
|
86
|
-
);
|
|
87
|
-
await waitFor(
|
|
88
|
-
() => messages.length === delayedBatchMessages.length,
|
|
89
|
-
15_000,
|
|
90
|
-
);
|
|
91
|
-
assert.deepStrictEqual(messages, delayedBatchMessages);
|
|
92
|
-
assert.ok(Date.now() - started > 2_000);
|
|
93
|
-
|
|
94
|
-
controller.abort();
|
|
95
|
-
await listening;
|
|
96
|
-
await listening2;
|
|
97
|
-
} finally {
|
|
98
|
-
await mq.drop();
|
|
99
|
-
await sql.end();
|
|
100
|
-
await sql2.end();
|
|
101
|
-
}
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
async function waitFor(
|
|
105
|
-
predicate: () => boolean,
|
|
106
|
-
timeoutMs: number,
|
|
107
|
-
): Promise<void> {
|
|
108
|
-
const started = Date.now();
|
|
109
|
-
while (!predicate()) {
|
|
110
|
-
await delay(500);
|
|
111
|
-
if (Date.now() - started > timeoutMs) {
|
|
112
|
-
throw new Error("Timeout");
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
}
|
package/mq.ts
DELETED
|
@@ -1,261 +0,0 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
MessageQueue,
|
|
3
|
-
MessageQueueEnqueueOptions,
|
|
4
|
-
MessageQueueListenOptions,
|
|
5
|
-
} from "@fedify/fedify";
|
|
6
|
-
import { getLogger } from "@logtape/logtape";
|
|
7
|
-
import type { JSONValue, Parameter, Sql } from "postgres";
|
|
8
|
-
import postgres from "postgres";
|
|
9
|
-
import { driverSerializesJson } from "./utils.ts";
|
|
10
|
-
|
|
11
|
-
const logger = getLogger(["fedify", "postgres", "mq"]);
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Options for the PostgreSQL message queue.
|
|
15
|
-
*/
|
|
16
|
-
export interface PostgresMessageQueueOptions {
|
|
17
|
-
/**
|
|
18
|
-
* The table name to use for the message queue.
|
|
19
|
-
* `"fedify_message_v2"` by default.
|
|
20
|
-
* @default `"fedify_message_v2"`
|
|
21
|
-
*/
|
|
22
|
-
tableName?: string;
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* The channel name to use for the message queue.
|
|
26
|
-
* `"fedify_channel"` by default.
|
|
27
|
-
* @default `"fedify_channel"`
|
|
28
|
-
*/
|
|
29
|
-
channelName?: string;
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Whether the table has been initialized. `false` by default.
|
|
33
|
-
* @default `false`
|
|
34
|
-
*/
|
|
35
|
-
initialized?: boolean;
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* The poll interval for the message queue. 5 seconds by default.
|
|
39
|
-
* @default `{ seconds: 5 }`
|
|
40
|
-
*/
|
|
41
|
-
pollInterval?: Temporal.Duration | Temporal.DurationLike;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* A message queue that uses PostgreSQL as the underlying storage.
|
|
46
|
-
*
|
|
47
|
-
* @example
|
|
48
|
-
* ```ts
|
|
49
|
-
* import { createFederation } from "@fedify/fedify";
|
|
50
|
-
* import { PostgresKvStore, PostgresMessageQueue } from "@fedify/postgres";
|
|
51
|
-
* import postgres from "postgres";
|
|
52
|
-
*
|
|
53
|
-
* const sql = postgres("postgres://user:pass@localhost/db");
|
|
54
|
-
*
|
|
55
|
-
* const federation = createFederation({
|
|
56
|
-
* kv: new PostgresKvStore(sql),
|
|
57
|
-
* queue: new PostgresMessageQueue(sql),
|
|
58
|
-
* });
|
|
59
|
-
* ```
|
|
60
|
-
*/
|
|
61
|
-
export class PostgresMessageQueue implements MessageQueue {
|
|
62
|
-
// deno-lint-ignore ban-types
|
|
63
|
-
readonly #sql: Sql<{}>;
|
|
64
|
-
readonly #tableName: string;
|
|
65
|
-
readonly #channelName: string;
|
|
66
|
-
readonly #pollIntervalMs: number;
|
|
67
|
-
#initialized: boolean;
|
|
68
|
-
#driverSerializesJson = false;
|
|
69
|
-
|
|
70
|
-
constructor(
|
|
71
|
-
// deno-lint-ignore ban-types
|
|
72
|
-
sql: Sql<{}>,
|
|
73
|
-
options: PostgresMessageQueueOptions = {},
|
|
74
|
-
) {
|
|
75
|
-
this.#sql = sql;
|
|
76
|
-
this.#tableName = options?.tableName ?? "fedify_message_v2";
|
|
77
|
-
this.#channelName = options?.channelName ?? "fedify_channel";
|
|
78
|
-
this.#pollIntervalMs = Temporal.Duration.from(
|
|
79
|
-
options?.pollInterval ?? { seconds: 5 },
|
|
80
|
-
).total("millisecond");
|
|
81
|
-
this.#initialized = options?.initialized ?? false;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
async enqueue(
|
|
85
|
-
// deno-lint-ignore no-explicit-any
|
|
86
|
-
message: any,
|
|
87
|
-
options?: MessageQueueEnqueueOptions,
|
|
88
|
-
): Promise<void> {
|
|
89
|
-
await this.initialize();
|
|
90
|
-
const delay = options?.delay ?? Temporal.Duration.from({ seconds: 0 });
|
|
91
|
-
if (options?.delay) {
|
|
92
|
-
logger.debug("Enqueuing a message with a delay of {delay}...", {
|
|
93
|
-
delay,
|
|
94
|
-
message,
|
|
95
|
-
});
|
|
96
|
-
} else {
|
|
97
|
-
logger.debug("Enqueuing a message...", { message });
|
|
98
|
-
}
|
|
99
|
-
await this.#sql`
|
|
100
|
-
INSERT INTO ${this.#sql(this.#tableName)} (message, delay)
|
|
101
|
-
VALUES (
|
|
102
|
-
${this.#json(message)},
|
|
103
|
-
${delay.toString()}
|
|
104
|
-
);
|
|
105
|
-
`;
|
|
106
|
-
logger.debug("Enqueued a message.", { message });
|
|
107
|
-
await this.#sql.notify(this.#channelName, delay.toString());
|
|
108
|
-
logger.debug("Notified the message queue channel {channelName}.", {
|
|
109
|
-
channelName: this.#channelName,
|
|
110
|
-
message,
|
|
111
|
-
});
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
async enqueueMany(
|
|
115
|
-
// deno-lint-ignore no-explicit-any
|
|
116
|
-
messages: any[],
|
|
117
|
-
options?: MessageQueueEnqueueOptions,
|
|
118
|
-
): Promise<void> {
|
|
119
|
-
if (messages.length === 0) return;
|
|
120
|
-
await this.initialize();
|
|
121
|
-
const delay = options?.delay ?? Temporal.Duration.from({ seconds: 0 });
|
|
122
|
-
if (options?.delay) {
|
|
123
|
-
logger.debug("Enqueuing messages with a delay of {delay}...", {
|
|
124
|
-
delay,
|
|
125
|
-
messages,
|
|
126
|
-
});
|
|
127
|
-
} else {
|
|
128
|
-
logger.debug("Enqueuing messages...", { messages });
|
|
129
|
-
}
|
|
130
|
-
for (const message of messages) {
|
|
131
|
-
await this.#sql`
|
|
132
|
-
INSERT INTO ${this.#sql(this.#tableName)} (message, delay)
|
|
133
|
-
VALUES (
|
|
134
|
-
${this.#json(message)},
|
|
135
|
-
${delay.toString()}
|
|
136
|
-
);
|
|
137
|
-
`;
|
|
138
|
-
}
|
|
139
|
-
logger.debug("Enqueued messages.", { messages });
|
|
140
|
-
await this.#sql.notify(this.#channelName, delay.toString());
|
|
141
|
-
logger.debug("Notified the message queue channel {channelName}.", {
|
|
142
|
-
channelName: this.#channelName,
|
|
143
|
-
messages,
|
|
144
|
-
});
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
async listen(
|
|
148
|
-
// deno-lint-ignore no-explicit-any
|
|
149
|
-
handler: (message: any) => void | Promise<void>,
|
|
150
|
-
options: MessageQueueListenOptions = {},
|
|
151
|
-
): Promise<void> {
|
|
152
|
-
await this.initialize();
|
|
153
|
-
const { signal } = options;
|
|
154
|
-
const poll = async () => {
|
|
155
|
-
while (!signal?.aborted) {
|
|
156
|
-
const query = this.#sql`
|
|
157
|
-
DELETE FROM ${this.#sql(this.#tableName)}
|
|
158
|
-
WHERE id = (
|
|
159
|
-
SELECT id
|
|
160
|
-
FROM ${this.#sql(this.#tableName)}
|
|
161
|
-
WHERE created + delay < CURRENT_TIMESTAMP
|
|
162
|
-
ORDER BY created
|
|
163
|
-
LIMIT 1
|
|
164
|
-
)
|
|
165
|
-
RETURNING message;
|
|
166
|
-
`.execute();
|
|
167
|
-
const cancel = query.cancel.bind(query);
|
|
168
|
-
signal?.addEventListener("abort", cancel);
|
|
169
|
-
let i = 0;
|
|
170
|
-
for (const message of await query) {
|
|
171
|
-
if (signal?.aborted) return;
|
|
172
|
-
await handler(message.message);
|
|
173
|
-
i++;
|
|
174
|
-
}
|
|
175
|
-
signal?.removeEventListener("abort", cancel);
|
|
176
|
-
if (i < 1) break;
|
|
177
|
-
}
|
|
178
|
-
};
|
|
179
|
-
const timeouts = new Set<ReturnType<typeof setTimeout>>();
|
|
180
|
-
const listen = await this.#sql.listen(
|
|
181
|
-
this.#channelName,
|
|
182
|
-
async (delay) => {
|
|
183
|
-
const duration = Temporal.Duration.from(delay);
|
|
184
|
-
const durationMs = duration.total("millisecond");
|
|
185
|
-
if (durationMs < 1) await poll();
|
|
186
|
-
else timeouts.add(setTimeout(poll, durationMs));
|
|
187
|
-
},
|
|
188
|
-
poll,
|
|
189
|
-
);
|
|
190
|
-
signal?.addEventListener("abort", () => {
|
|
191
|
-
listen.unlisten();
|
|
192
|
-
for (const timeout of timeouts) clearTimeout(timeout);
|
|
193
|
-
});
|
|
194
|
-
while (!signal?.aborted) {
|
|
195
|
-
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
196
|
-
await new Promise<unknown>((resolve) => {
|
|
197
|
-
signal?.addEventListener("abort", resolve);
|
|
198
|
-
timeout = setTimeout(() => {
|
|
199
|
-
signal?.removeEventListener("abort", resolve);
|
|
200
|
-
resolve(0);
|
|
201
|
-
}, this.#pollIntervalMs);
|
|
202
|
-
timeouts.add(timeout);
|
|
203
|
-
});
|
|
204
|
-
if (timeout != null) timeouts.delete(timeout);
|
|
205
|
-
await poll();
|
|
206
|
-
}
|
|
207
|
-
await new Promise<void>((resolve) => {
|
|
208
|
-
signal?.addEventListener("abort", () => resolve());
|
|
209
|
-
if (signal?.aborted) return resolve();
|
|
210
|
-
});
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/**
|
|
214
|
-
* Initializes the message queue table if it does not already exist.
|
|
215
|
-
*/
|
|
216
|
-
async initialize(): Promise<void> {
|
|
217
|
-
if (this.#initialized) return;
|
|
218
|
-
logger.debug("Initializing the message queue table {tableName}...", {
|
|
219
|
-
tableName: this.#tableName,
|
|
220
|
-
});
|
|
221
|
-
try {
|
|
222
|
-
await this.#sql`
|
|
223
|
-
CREATE TABLE IF NOT EXISTS ${this.#sql(this.#tableName)} (
|
|
224
|
-
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
225
|
-
message jsonb NOT NULL,
|
|
226
|
-
delay interval DEFAULT '0 seconds',
|
|
227
|
-
created timestamp with time zone DEFAULT CURRENT_TIMESTAMP
|
|
228
|
-
);
|
|
229
|
-
`;
|
|
230
|
-
} catch (error) {
|
|
231
|
-
if (
|
|
232
|
-
!(error instanceof postgres.PostgresError &&
|
|
233
|
-
error.constraint_name === "pg_type_typname_nsp_index")
|
|
234
|
-
) {
|
|
235
|
-
logger.error("Failed to initialize the message queue table: {error}", {
|
|
236
|
-
error,
|
|
237
|
-
});
|
|
238
|
-
throw error;
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
this.#driverSerializesJson = await driverSerializesJson(this.#sql);
|
|
242
|
-
this.#initialized = true;
|
|
243
|
-
logger.debug("Initialized the message queue table {tableName}.", {
|
|
244
|
-
tableName: this.#tableName,
|
|
245
|
-
});
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
/**
|
|
249
|
-
* Drops the message queue table if it exists.
|
|
250
|
-
*/
|
|
251
|
-
async drop(): Promise<void> {
|
|
252
|
-
await this.#sql`DROP TABLE IF EXISTS ${this.#sql(this.#tableName)};`;
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
#json(value: unknown): Parameter {
|
|
256
|
-
if (this.#driverSerializesJson) return this.#sql.json(value as JSONValue);
|
|
257
|
-
return this.#sql.json(JSON.stringify(value));
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
// cSpell: ignore typname
|
package/tsdown.config.ts
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
import { defineConfig } from "tsdown";
|
|
2
|
-
|
|
3
|
-
export default defineConfig({
|
|
4
|
-
entry: ["mod.ts", "kv.ts", "mq.ts"],
|
|
5
|
-
dts: true,
|
|
6
|
-
unbundle: true,
|
|
7
|
-
platform: "node",
|
|
8
|
-
outputOptions: {
|
|
9
|
-
intro: `
|
|
10
|
-
import { Temporal } from "@js-temporal/polyfill";
|
|
11
|
-
`,
|
|
12
|
-
},
|
|
13
|
-
});
|
package/utils.ts
DELETED
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
import type { Sql } from "postgres";
|
|
2
|
-
|
|
3
|
-
// deno-lint-ignore ban-types
|
|
4
|
-
export async function driverSerializesJson(sql: Sql<{}>): Promise<boolean> {
|
|
5
|
-
const result = await sql`SELECT ${sql.json('{"foo":1}')}::jsonb AS test;`;
|
|
6
|
-
return result[0].test === '{"foo":1}';
|
|
7
|
-
}
|