@parlor/sync 0.0.0-stage → 0.2.0
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/LICENSE +21 -0
- package/README.md +10 -2
- package/dist/channel.d.ts +21 -0
- package/dist/channel.d.ts.map +1 -0
- package/dist/channel.js +2 -0
- package/dist/channel.js.map +1 -0
- package/dist/client/index.d.ts +3 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +3 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/sse.d.ts +10 -0
- package/dist/client/sse.d.ts.map +1 -0
- package/dist/client/sse.js +48 -0
- package/dist/client/sse.js.map +1 -0
- package/dist/client/sync-client.d.ts +84 -0
- package/dist/client/sync-client.d.ts.map +1 -0
- package/dist/client/sync-client.js +459 -0
- package/dist/client/sync-client.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/protocol.d.ts +39 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +30 -0
- package/dist/protocol.js.map +1 -0
- package/dist/server/hub.d.ts +15 -0
- package/dist/server/hub.d.ts.map +1 -0
- package/dist/server/hub.js +75 -0
- package/dist/server/hub.js.map +1 -0
- package/dist/server/index.d.ts +5 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +5 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/pruner.d.ts +11 -0
- package/dist/server/pruner.d.ts.map +1 -0
- package/dist/server/pruner.js +23 -0
- package/dist/server/pruner.js.map +1 -0
- package/dist/server/routes.d.ts +36 -0
- package/dist/server/routes.d.ts.map +1 -0
- package/dist/server/routes.js +127 -0
- package/dist/server/routes.js.map +1 -0
- package/dist/server/store.d.ts +42 -0
- package/dist/server/store.d.ts.map +1 -0
- package/dist/server/store.js +155 -0
- package/dist/server/store.js.map +1 -0
- package/package.json +43 -3
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { SYNC_CHANNEL } from "../protocol.js";
|
|
2
|
+
import { parseNotifyPayload } from "./store.js";
|
|
3
|
+
export class SyncHub {
|
|
4
|
+
data;
|
|
5
|
+
#subscribers = new Map();
|
|
6
|
+
#unlisten;
|
|
7
|
+
#starting;
|
|
8
|
+
constructor(data) {
|
|
9
|
+
this.data = data;
|
|
10
|
+
}
|
|
11
|
+
get canPush() {
|
|
12
|
+
return typeof this.data.listen === "function";
|
|
13
|
+
}
|
|
14
|
+
async start() {
|
|
15
|
+
if (!this.data.listen || this.#unlisten)
|
|
16
|
+
return;
|
|
17
|
+
const starting = (this.#starting ??= this.data
|
|
18
|
+
.listen(SYNC_CHANNEL, (payload) => {
|
|
19
|
+
const parsed = parseNotifyPayload(payload);
|
|
20
|
+
if (parsed)
|
|
21
|
+
this.publish(parsed.scope, parsed.event);
|
|
22
|
+
}, { onLost: () => this.#reconnectEveryStream() })
|
|
23
|
+
.then((unlisten) => {
|
|
24
|
+
if (this.#starting === starting)
|
|
25
|
+
this.#unlisten = unlisten;
|
|
26
|
+
else
|
|
27
|
+
void unlisten();
|
|
28
|
+
}, (error) => {
|
|
29
|
+
if (this.#starting === starting)
|
|
30
|
+
this.#starting = undefined;
|
|
31
|
+
throw error;
|
|
32
|
+
}));
|
|
33
|
+
await starting;
|
|
34
|
+
}
|
|
35
|
+
#reconnectEveryStream() {
|
|
36
|
+
this.#unlisten = undefined;
|
|
37
|
+
this.#starting = undefined;
|
|
38
|
+
const subscriptions = [...this.#subscribers.values()].flatMap((set) => [...set]);
|
|
39
|
+
for (const { onStop } of subscriptions)
|
|
40
|
+
onStop?.();
|
|
41
|
+
}
|
|
42
|
+
publish(scope, event) {
|
|
43
|
+
for (const { notify } of this.#subscribers.get(scope) ?? [])
|
|
44
|
+
notify(event);
|
|
45
|
+
}
|
|
46
|
+
subscribe(scope, notify, onStop) {
|
|
47
|
+
const subscription = { notify, onStop };
|
|
48
|
+
const set = this.#subscribers.get(scope) ?? new Set();
|
|
49
|
+
set.add(subscription);
|
|
50
|
+
this.#subscribers.set(scope, set);
|
|
51
|
+
return () => {
|
|
52
|
+
set.delete(subscription);
|
|
53
|
+
if (set.size === 0)
|
|
54
|
+
this.#subscribers.delete(scope);
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
subscriberCount(scope) {
|
|
58
|
+
if (scope)
|
|
59
|
+
return this.#subscribers.get(scope)?.size ?? 0;
|
|
60
|
+
let total = 0;
|
|
61
|
+
for (const set of this.#subscribers.values())
|
|
62
|
+
total += set.size;
|
|
63
|
+
return total;
|
|
64
|
+
}
|
|
65
|
+
async stop() {
|
|
66
|
+
const subscriptions = [...this.#subscribers.values()].flatMap((set) => [...set]);
|
|
67
|
+
for (const { onStop } of subscriptions)
|
|
68
|
+
onStop?.();
|
|
69
|
+
await this.#unlisten?.();
|
|
70
|
+
this.#unlisten = undefined;
|
|
71
|
+
this.#starting = undefined;
|
|
72
|
+
this.#subscribers.clear();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
//# sourceMappingURL=hub.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hub.js","sourceRoot":"","sources":["../../src/server/hub.ts"],"names":[],"mappings":"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","sourcesContent":["import type { DataProvider, Unlisten } from \"@parlor/core\";\n\nimport { SYNC_CHANNEL, type SyncEvent } from \"../protocol.js\";\nimport { parseNotifyPayload } from \"./store.js\";\n\nexport type SyncSubscriber = (event: SyncEvent) => void;\n\ninterface Subscription {\n readonly notify: SyncSubscriber;\n readonly onStop: (() => void) | undefined;\n}\n\nexport class SyncHub {\n readonly #subscribers = new Map<string, Set<Subscription>>();\n #unlisten: Unlisten | undefined;\n #starting: Promise<void> | undefined;\n\n constructor(private readonly data: DataProvider) {}\n\n get canPush(): boolean {\n return typeof this.data.listen === \"function\";\n }\n\n async start(): Promise<void> {\n if (!this.data.listen || this.#unlisten) return;\n const starting = (this.#starting ??= this.data\n .listen(\n SYNC_CHANNEL,\n (payload) => {\n const parsed = parseNotifyPayload(payload);\n if (parsed) this.publish(parsed.scope, parsed.event);\n },\n { onLost: () => this.#reconnectEveryStream() },\n )\n .then(\n (unlisten) => {\n if (this.#starting === starting) this.#unlisten = unlisten;\n else void unlisten();\n },\n (error: unknown) => {\n if (this.#starting === starting) this.#starting = undefined;\n throw error;\n },\n ));\n await starting;\n }\n\n #reconnectEveryStream(): void {\n this.#unlisten = undefined;\n this.#starting = undefined;\n const subscriptions = [...this.#subscribers.values()].flatMap((set) => [...set]);\n for (const { onStop } of subscriptions) onStop?.();\n }\n\n publish(scope: string, event: SyncEvent): void {\n for (const { notify } of this.#subscribers.get(scope) ?? []) notify(event);\n }\n\n subscribe(scope: string, notify: SyncSubscriber, onStop?: () => void): () => void {\n const subscription: Subscription = { notify, onStop };\n const set = this.#subscribers.get(scope) ?? new Set();\n set.add(subscription);\n this.#subscribers.set(scope, set);\n return () => {\n set.delete(subscription);\n if (set.size === 0) this.#subscribers.delete(scope);\n };\n }\n\n subscriberCount(scope?: string): number {\n if (scope) return this.#subscribers.get(scope)?.size ?? 0;\n let total = 0;\n for (const set of this.#subscribers.values()) total += set.size;\n return total;\n }\n\n async stop(): Promise<void> {\n const subscriptions = [...this.#subscribers.values()].flatMap((set) => [...set]);\n for (const { onStop } of subscriptions) onStop?.();\n await this.#unlisten?.();\n this.#unlisten = undefined;\n this.#starting = undefined;\n this.#subscribers.clear();\n }\n}\n"]}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { SyncHub, type SyncSubscriber } from "./hub.js";
|
|
2
|
+
export { createPruner, DEFAULT_PRUNE_INTERVAL_MS, type Pruner } from "./pruner.js";
|
|
3
|
+
export { DEFAULT_POLL_INTERVAL_MS, registerSyncRoutes, type StreamAuthority, type SyncRouteOptions, type SyncTransport, transportFromEnv, } from "./routes.js";
|
|
4
|
+
export { ensureSyncTables, type NotifyPayload, parseNotifyPayload, PRUNE_LOCK_KEY, pruneSyncEvents, readSyncCursor, readSyncEvents, recordSyncEvent, type RecordSyncEventInput, RETENTION_MS, type SyncDatabase, } from "./store.js";
|
|
5
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/server/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,KAAK,cAAc,EAAE,MAAM,UAAU,CAAC;AACxD,OAAO,EAAE,YAAY,EAAE,yBAAyB,EAAE,KAAK,MAAM,EAAE,MAAM,aAAa,CAAC;AACnF,OAAO,EACL,wBAAwB,EACxB,kBAAkB,EAClB,KAAK,eAAe,EACpB,KAAK,gBAAgB,EACrB,KAAK,aAAa,EAClB,gBAAgB,GACjB,MAAM,aAAa,CAAC;AACrB,OAAO,EACL,gBAAgB,EAChB,KAAK,aAAa,EAClB,kBAAkB,EAClB,cAAc,EACd,eAAe,EACf,cAAc,EACd,cAAc,EACd,eAAe,EACf,KAAK,oBAAoB,EACzB,YAAY,EACZ,KAAK,YAAY,GAClB,MAAM,YAAY,CAAC"}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { SyncHub } from "./hub.js";
|
|
2
|
+
export { createPruner, DEFAULT_PRUNE_INTERVAL_MS } from "./pruner.js";
|
|
3
|
+
export { DEFAULT_POLL_INTERVAL_MS, registerSyncRoutes, transportFromEnv, } from "./routes.js";
|
|
4
|
+
export { ensureSyncTables, parseNotifyPayload, PRUNE_LOCK_KEY, pruneSyncEvents, readSyncCursor, readSyncEvents, recordSyncEvent, RETENTION_MS, } from "./store.js";
|
|
5
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/server/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAuB,MAAM,UAAU,CAAC;AACxD,OAAO,EAAE,YAAY,EAAE,yBAAyB,EAAe,MAAM,aAAa,CAAC;AACnF,OAAO,EACL,wBAAwB,EACxB,kBAAkB,EAIlB,gBAAgB,GACjB,MAAM,aAAa,CAAC;AACrB,OAAO,EACL,gBAAgB,EAEhB,kBAAkB,EAClB,cAAc,EACd,eAAe,EACf,cAAc,EACd,cAAc,EACd,eAAe,EAEf,YAAY,GAEb,MAAM,YAAY,CAAC","sourcesContent":["export { SyncHub, type SyncSubscriber } from \"./hub.js\";\nexport { createPruner, DEFAULT_PRUNE_INTERVAL_MS, type Pruner } from \"./pruner.js\";\nexport {\n DEFAULT_POLL_INTERVAL_MS,\n registerSyncRoutes,\n type StreamAuthority,\n type SyncRouteOptions,\n type SyncTransport,\n transportFromEnv,\n} from \"./routes.js\";\nexport {\n ensureSyncTables,\n type NotifyPayload,\n parseNotifyPayload,\n PRUNE_LOCK_KEY,\n pruneSyncEvents,\n readSyncCursor,\n readSyncEvents,\n recordSyncEvent,\n type RecordSyncEventInput,\n RETENTION_MS,\n type SyncDatabase,\n} from \"./store.js\";\n"]}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { DataProvider } from "@parlor/core";
|
|
2
|
+
export declare const DEFAULT_PRUNE_INTERVAL_MS: number;
|
|
3
|
+
export interface Pruner {
|
|
4
|
+
touch(): void;
|
|
5
|
+
}
|
|
6
|
+
export declare function createPruner(data: DataProvider, options?: {
|
|
7
|
+
intervalMs?: number | undefined;
|
|
8
|
+
now?: () => number;
|
|
9
|
+
onError?: (error: unknown) => void;
|
|
10
|
+
}): Pruner;
|
|
11
|
+
//# sourceMappingURL=pruner.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pruner.d.ts","sourceRoot":"","sources":["../../src/server/pruner.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAIjD,eAAO,MAAM,yBAAyB,QAAgB,CAAC;AAEvD,MAAM,WAAW,MAAM;IACrB,KAAK,IAAI,IAAI,CAAC;CACf;AAED,wBAAgB,YAAY,CAC1B,IAAI,EAAE,YAAY,EAClB,OAAO,GAAE;IACP,UAAU,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAChC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;CAC/B,GACL,MAAM,CAmBR"}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { pruneSyncEvents } from "./store.js";
|
|
2
|
+
export const DEFAULT_PRUNE_INTERVAL_MS = 5 * 60 * 1000;
|
|
3
|
+
export function createPruner(data, options = {}) {
|
|
4
|
+
const intervalMs = options.intervalMs ?? DEFAULT_PRUNE_INTERVAL_MS;
|
|
5
|
+
const now = options.now ?? Date.now;
|
|
6
|
+
const onError = options.onError ?? ((error) => console.error("[parlor] sync prune failed:", error));
|
|
7
|
+
let last = now();
|
|
8
|
+
let running = false;
|
|
9
|
+
return {
|
|
10
|
+
touch() {
|
|
11
|
+
if (running || now() - last < intervalMs)
|
|
12
|
+
return;
|
|
13
|
+
running = true;
|
|
14
|
+
last = now();
|
|
15
|
+
void pruneSyncEvents(data.read())
|
|
16
|
+
.catch(onError)
|
|
17
|
+
.finally(() => {
|
|
18
|
+
running = false;
|
|
19
|
+
});
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
//# sourceMappingURL=pruner.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pruner.js","sourceRoot":"","sources":["../../src/server/pruner.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,eAAe,EAAqB,MAAM,YAAY,CAAC;AAEhE,MAAM,CAAC,MAAM,yBAAyB,GAAG,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC;AAMvD,MAAM,UAAU,YAAY,CAC1B,IAAkB,EAClB,OAAO,GAIH,EAAE;IAEN,MAAM,UAAU,GAAG,OAAO,CAAC,UAAU,IAAI,yBAAyB,CAAC;IACnE,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,IAAI,IAAI,CAAC,GAAG,CAAC;IACpC,MAAM,OAAO,GACX,OAAO,CAAC,OAAO,IAAI,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,OAAO,CAAC,KAAK,CAAC,6BAA6B,EAAE,KAAK,CAAC,CAAC,CAAC;IACtF,IAAI,IAAI,GAAG,GAAG,EAAE,CAAC;IACjB,IAAI,OAAO,GAAG,KAAK,CAAC;IACpB,OAAO;QACL,KAAK;YACH,IAAI,OAAO,IAAI,GAAG,EAAE,GAAG,IAAI,GAAG,UAAU;gBAAE,OAAO;YACjD,OAAO,GAAG,IAAI,CAAC;YACf,IAAI,GAAG,GAAG,EAAE,CAAC;YACb,KAAK,eAAe,CAAC,IAAI,CAAC,IAAI,EAAkB,CAAC;iBAC9C,KAAK,CAAC,OAAO,CAAC;iBACd,OAAO,CAAC,GAAG,EAAE;gBACZ,OAAO,GAAG,KAAK,CAAC;YAClB,CAAC,CAAC,CAAC;QACP,CAAC;KACF,CAAC;AACJ,CAAC","sourcesContent":["import type { DataProvider } from \"@parlor/core\";\n\nimport { pruneSyncEvents, type SyncDatabase } from \"./store.js\";\n\nexport const DEFAULT_PRUNE_INTERVAL_MS = 5 * 60 * 1000;\n\nexport interface Pruner {\n touch(): void;\n}\n\nexport function createPruner(\n data: DataProvider,\n options: {\n intervalMs?: number | undefined;\n now?: () => number;\n onError?: (error: unknown) => void;\n } = {},\n): Pruner {\n const intervalMs = options.intervalMs ?? DEFAULT_PRUNE_INTERVAL_MS;\n const now = options.now ?? Date.now;\n const onError =\n options.onError ?? ((error) => console.error(\"[parlor] sync prune failed:\", error));\n let last = now();\n let running = false;\n return {\n touch() {\n if (running || now() - last < intervalMs) return;\n running = true;\n last = now();\n void pruneSyncEvents(data.read() as SyncDatabase)\n .catch(onError)\n .finally(() => {\n running = false;\n });\n },\n };\n}\n"]}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { DataProvider, Principal } from "@parlor/core";
|
|
2
|
+
import { type H3 } from "h3";
|
|
3
|
+
import type { ServerChannel } from "../channel.js";
|
|
4
|
+
import type { SyncHub } from "./hub.js";
|
|
5
|
+
export type SyncTransport = "sse" | "poll";
|
|
6
|
+
export interface SyncRouteOptions {
|
|
7
|
+
readonly basePath: string;
|
|
8
|
+
readonly data: DataProvider;
|
|
9
|
+
readonly hub: SyncHub;
|
|
10
|
+
readonly scope: (request: Request) => string | Promise<string>;
|
|
11
|
+
/**
|
|
12
|
+
* The caller's own stream inside its scope, for events only its user should hear (an
|
|
13
|
+
* action with `notifies: "user"`); read and pushed alongside the scope's.
|
|
14
|
+
*/
|
|
15
|
+
readonly audience?: ((request: Request) => string | Promise<string>) | undefined;
|
|
16
|
+
readonly transport?: SyncTransport | undefined;
|
|
17
|
+
readonly pollIntervalMs?: number | undefined;
|
|
18
|
+
readonly keepAliveMs?: number | undefined;
|
|
19
|
+
readonly channels?: readonly ServerChannel[] | undefined;
|
|
20
|
+
readonly clientId?: ((request: Request) => string | undefined) | undefined;
|
|
21
|
+
/**
|
|
22
|
+
* Who holds a stream and until when. A stream closes when `revoked` aborts, and a
|
|
23
|
+
* stream without `channels` carries sync events only. Refusing a caller happens before
|
|
24
|
+
* these routes run.
|
|
25
|
+
*/
|
|
26
|
+
readonly authority?: ((request: Request) => Promise<StreamAuthority>) | undefined;
|
|
27
|
+
}
|
|
28
|
+
export interface StreamAuthority {
|
|
29
|
+
readonly principal: Principal;
|
|
30
|
+
readonly revoked?: AbortSignal | undefined;
|
|
31
|
+
readonly channels: boolean;
|
|
32
|
+
}
|
|
33
|
+
export declare const DEFAULT_POLL_INTERVAL_MS = 2000;
|
|
34
|
+
export declare function transportFromEnv(value?: string | undefined): SyncTransport | undefined;
|
|
35
|
+
export declare function registerSyncRoutes(app: H3, options: SyncRouteOptions): void;
|
|
36
|
+
//# sourceMappingURL=routes.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../../src/server/routes.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAC5D,OAAO,EAA+B,KAAK,EAAE,EAAgB,MAAM,IAAI,CAAC;AAExE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAYnD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,UAAU,CAAC;AAGxC,MAAM,MAAM,aAAa,GAAG,KAAK,GAAG,MAAM,CAAC;AAE3C,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,IAAI,EAAE,YAAY,CAAC;IAC5B,QAAQ,CAAC,GAAG,EAAE,OAAO,CAAC;IACtB,QAAQ,CAAC,KAAK,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAC/D;;;OAGG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,OAAO,EAAE,OAAO,KAAK,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,GAAG,SAAS,CAAC;IACjF,QAAQ,CAAC,SAAS,CAAC,EAAE,aAAa,GAAG,SAAS,CAAC;IAC/C,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC7C,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC1C,QAAQ,CAAC,QAAQ,CAAC,EAAE,SAAS,aAAa,EAAE,GAAG,SAAS,CAAC;IACzD,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,OAAO,EAAE,OAAO,KAAK,MAAM,GAAG,SAAS,CAAC,GAAG,SAAS,CAAC;IAC3E;;;;OAIG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,CAAC,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,eAAe,CAAC,CAAC,GAAG,SAAS,CAAC;CACnF;AAED,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,SAAS,EAAE,SAAS,CAAC;IAC9B,QAAQ,CAAC,OAAO,CAAC,EAAE,WAAW,GAAG,SAAS,CAAC;IAC3C,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;CAC5B;AAED,eAAO,MAAM,wBAAwB,OAAO,CAAC;AAE7C,wBAAgB,gBAAgB,CAC9B,KAAK,GAAE,MAAM,GAAG,SAA6C,GAC5D,aAAa,GAAG,SAAS,CAE3B;AAwBD,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,EAAE,EAAE,OAAO,EAAE,gBAAgB,GAAG,IAAI,CA8G3E"}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { createEventStream, getQuery } from "h3";
|
|
2
|
+
import { compareCursors, cursorOf, decodeCursor, DEFAULT_KEEP_ALIVE_MS, encodeCursor, SYNC_PROTOCOL_VERSION, } from "../protocol.js";
|
|
3
|
+
import { readSyncBacklog, readSyncEvents } from "./store.js";
|
|
4
|
+
export const DEFAULT_POLL_INTERVAL_MS = 2000;
|
|
5
|
+
export function transportFromEnv(value = process.env.PARLOR_SYNC_TRANSPORT) {
|
|
6
|
+
return value === "sse" || value === "poll" ? value : undefined;
|
|
7
|
+
}
|
|
8
|
+
function batch(events, since) {
|
|
9
|
+
const last = events.at(-1);
|
|
10
|
+
return { events, cursor: last ? encodeCursor(cursorOf(last)) : since };
|
|
11
|
+
}
|
|
12
|
+
async function missedSince(db, streams, event) {
|
|
13
|
+
const query = getQuery(event);
|
|
14
|
+
const since = String(query.since ?? "");
|
|
15
|
+
if (query.reset !== "1") {
|
|
16
|
+
return batch(await readSyncEvents(db, streams, decodeCursor(since)), since);
|
|
17
|
+
}
|
|
18
|
+
const backlog = await readSyncBacklog(db, streams, decodeCursor(since));
|
|
19
|
+
if (backlog.resetTo) {
|
|
20
|
+
return { events: [], cursor: encodeCursor(backlog.resetTo), reset: true };
|
|
21
|
+
}
|
|
22
|
+
return batch(backlog.events, since);
|
|
23
|
+
}
|
|
24
|
+
export function registerSyncRoutes(app, options) {
|
|
25
|
+
const { basePath, data, hub, scope } = options;
|
|
26
|
+
const streamsOf = async (request) => {
|
|
27
|
+
const own = await scope(request);
|
|
28
|
+
const audience = await options.audience?.(request);
|
|
29
|
+
return audience === undefined || audience === own ? [own] : [own, audience];
|
|
30
|
+
};
|
|
31
|
+
const transport = options.transport ?? transportFromEnv() ?? (hub.canPush ? "sse" : "poll");
|
|
32
|
+
const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
33
|
+
const keepAliveMs = options.keepAliveMs ?? DEFAULT_KEEP_ALIVE_MS;
|
|
34
|
+
const db = () => data.read();
|
|
35
|
+
const channels = options.channels ?? [];
|
|
36
|
+
const capabilities = channels.map((channel) => channel.event);
|
|
37
|
+
app.post(`${basePath}/sync/token`, () => ({
|
|
38
|
+
protocol: SYNC_PROTOCOL_VERSION,
|
|
39
|
+
transport,
|
|
40
|
+
endpoint: `${basePath}/sync/stream`,
|
|
41
|
+
pollIntervalMs,
|
|
42
|
+
}));
|
|
43
|
+
app.get(`${basePath}/sync`, async (event) => {
|
|
44
|
+
const missed = await missedSince(db(), await streamsOf(event.req), event);
|
|
45
|
+
event.res.headers.set("cache-control", "no-store");
|
|
46
|
+
return missed;
|
|
47
|
+
});
|
|
48
|
+
app.get(`${basePath}/sync/stream`, async (event) => {
|
|
49
|
+
const streams = await streamsOf(event.req);
|
|
50
|
+
const currentScope = streams[0];
|
|
51
|
+
const authority = await options.authority?.(event.req);
|
|
52
|
+
await hub.start();
|
|
53
|
+
const stream = createEventStream(event);
|
|
54
|
+
const send = (payload) => void stream.push(JSON.stringify(payload));
|
|
55
|
+
const sendLive = (syncEvent) => send({ events: [syncEvent], cursor: encodeCursor(cursorOf(syncEvent)) });
|
|
56
|
+
let arrivedDuringCatchUp = [];
|
|
57
|
+
const unsubscribes = streams.map((name) => hub.subscribe(name, (syncEvent) => {
|
|
58
|
+
if (arrivedDuringCatchUp)
|
|
59
|
+
arrivedDuringCatchUp.push(syncEvent);
|
|
60
|
+
else
|
|
61
|
+
sendLive(syncEvent);
|
|
62
|
+
}, () => void stream.close()));
|
|
63
|
+
const keepAlive = setInterval(() => void stream.pushComment("keep-alive"), keepAliveMs);
|
|
64
|
+
const disconnects = [];
|
|
65
|
+
const ended = new AbortController();
|
|
66
|
+
let detached = false;
|
|
67
|
+
const revoked = authority?.revoked;
|
|
68
|
+
const closeRevoked = () => void stream.close();
|
|
69
|
+
const detach = () => {
|
|
70
|
+
if (detached)
|
|
71
|
+
return;
|
|
72
|
+
detached = true;
|
|
73
|
+
ended.abort();
|
|
74
|
+
clearInterval(keepAlive);
|
|
75
|
+
revoked?.removeEventListener("abort", closeRevoked);
|
|
76
|
+
for (const unsubscribe of unsubscribes)
|
|
77
|
+
unsubscribe();
|
|
78
|
+
for (const disconnect of disconnects)
|
|
79
|
+
disconnect();
|
|
80
|
+
};
|
|
81
|
+
stream.onClosed(detach);
|
|
82
|
+
if (revoked?.aborted)
|
|
83
|
+
closeRevoked();
|
|
84
|
+
else
|
|
85
|
+
revoked?.addEventListener("abort", closeRevoked, { once: true });
|
|
86
|
+
void stream.push({
|
|
87
|
+
event: "handshake",
|
|
88
|
+
data: JSON.stringify({
|
|
89
|
+
protocol: SYNC_PROTOCOL_VERSION,
|
|
90
|
+
capabilities: authority?.channels === false ? [] : capabilities,
|
|
91
|
+
keepAliveMs,
|
|
92
|
+
}),
|
|
93
|
+
});
|
|
94
|
+
const clientId = options.clientId?.(event.req);
|
|
95
|
+
for (const channel of authority?.channels === false ? [] : channels) {
|
|
96
|
+
disconnects.push(channel.connect({
|
|
97
|
+
clientId,
|
|
98
|
+
scope: currentScope,
|
|
99
|
+
...(authority ? { principal: authority.principal } : {}),
|
|
100
|
+
closed: ended.signal,
|
|
101
|
+
send: (frame) => {
|
|
102
|
+
if (!detached)
|
|
103
|
+
void stream.push({ event: channel.event, data: frame });
|
|
104
|
+
},
|
|
105
|
+
}));
|
|
106
|
+
}
|
|
107
|
+
const missed = await missedSince(db(), streams, event).catch((error) => {
|
|
108
|
+
detach();
|
|
109
|
+
throw error;
|
|
110
|
+
});
|
|
111
|
+
if (missed.events.length > 0 || missed.reset)
|
|
112
|
+
send(missed);
|
|
113
|
+
const caughtUpTo = decodeCursor(missed.cursor);
|
|
114
|
+
const pending = arrivedDuringCatchUp
|
|
115
|
+
.filter((syncEvent) => compareCursors(cursorOf(syncEvent), caughtUpTo) > 0)
|
|
116
|
+
.sort((a, b) => compareCursors(cursorOf(a), cursorOf(b)));
|
|
117
|
+
arrivedDuringCatchUp = undefined;
|
|
118
|
+
for (const syncEvent of pending)
|
|
119
|
+
sendLive(syncEvent);
|
|
120
|
+
const body = await stream.send();
|
|
121
|
+
if (event.req.headers.get("connection") === "keep-alive") {
|
|
122
|
+
event.res.headers.set("connection", "close");
|
|
123
|
+
}
|
|
124
|
+
return body;
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
//# sourceMappingURL=routes.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"routes.js","sourceRoot":"","sources":["../../src/server/routes.ts"],"names":[],"mappings":"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","sourcesContent":["import type { DataProvider, Principal } from \"@parlor/core\";\nimport { createEventStream, getQuery, type H3, type H3Event } from \"h3\";\n\nimport type { ServerChannel } from \"../channel.js\";\nimport {\n compareCursors,\n cursorOf,\n decodeCursor,\n DEFAULT_KEEP_ALIVE_MS,\n encodeCursor,\n type SyncBatch,\n type SyncEvent,\n type SyncNegotiation,\n SYNC_PROTOCOL_VERSION,\n} from \"../protocol.js\";\nimport type { SyncHub } from \"./hub.js\";\nimport { readSyncBacklog, readSyncEvents, type SyncDatabase } from \"./store.js\";\n\nexport type SyncTransport = \"sse\" | \"poll\";\n\nexport interface SyncRouteOptions {\n readonly basePath: string;\n readonly data: DataProvider;\n readonly hub: SyncHub;\n readonly scope: (request: Request) => string | Promise<string>;\n /**\n * The caller's own stream inside its scope, for events only its user should hear (an\n * action with `notifies: \"user\"`); read and pushed alongside the scope's.\n */\n readonly audience?: ((request: Request) => string | Promise<string>) | undefined;\n readonly transport?: SyncTransport | undefined;\n readonly pollIntervalMs?: number | undefined;\n readonly keepAliveMs?: number | undefined;\n readonly channels?: readonly ServerChannel[] | undefined;\n readonly clientId?: ((request: Request) => string | undefined) | undefined;\n /**\n * Who holds a stream and until when. A stream closes when `revoked` aborts, and a\n * stream without `channels` carries sync events only. Refusing a caller happens before\n * these routes run.\n */\n readonly authority?: ((request: Request) => Promise<StreamAuthority>) | undefined;\n}\n\nexport interface StreamAuthority {\n readonly principal: Principal;\n readonly revoked?: AbortSignal | undefined;\n readonly channels: boolean;\n}\n\nexport const DEFAULT_POLL_INTERVAL_MS = 2000;\n\nexport function transportFromEnv(\n value: string | undefined = process.env.PARLOR_SYNC_TRANSPORT,\n): SyncTransport | undefined {\n return value === \"sse\" || value === \"poll\" ? value : undefined;\n}\n\nfunction batch(events: SyncEvent[], since: string): SyncBatch {\n const last = events.at(-1);\n return { events, cursor: last ? encodeCursor(cursorOf(last)) : since };\n}\n\nasync function missedSince(\n db: SyncDatabase,\n streams: readonly string[],\n event: H3Event,\n): Promise<SyncBatch> {\n const query = getQuery(event);\n const since = String(query.since ?? \"\");\n if (query.reset !== \"1\") {\n return batch(await readSyncEvents(db, streams, decodeCursor(since)), since);\n }\n const backlog = await readSyncBacklog(db, streams, decodeCursor(since));\n if (backlog.resetTo) {\n return { events: [], cursor: encodeCursor(backlog.resetTo), reset: true };\n }\n return batch(backlog.events, since);\n}\n\nexport function registerSyncRoutes(app: H3, options: SyncRouteOptions): void {\n const { basePath, data, hub, scope } = options;\n const streamsOf = async (request: Request): Promise<readonly string[]> => {\n const own = await scope(request);\n const audience = await options.audience?.(request);\n return audience === undefined || audience === own ? [own] : [own, audience];\n };\n const transport: SyncTransport =\n options.transport ?? transportFromEnv() ?? (hub.canPush ? \"sse\" : \"poll\");\n const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;\n const keepAliveMs = options.keepAliveMs ?? DEFAULT_KEEP_ALIVE_MS;\n const db = () => data.read() as SyncDatabase;\n const channels = options.channels ?? [];\n const capabilities = channels.map((channel) => channel.event);\n\n app.post(`${basePath}/sync/token`, (): SyncNegotiation => ({\n protocol: SYNC_PROTOCOL_VERSION,\n transport,\n endpoint: `${basePath}/sync/stream`,\n pollIntervalMs,\n }));\n\n app.get(`${basePath}/sync`, async (event: H3Event) => {\n const missed = await missedSince(db(), await streamsOf(event.req), event);\n event.res.headers.set(\"cache-control\", \"no-store\");\n return missed;\n });\n\n app.get(`${basePath}/sync/stream`, async (event: H3Event) => {\n const streams = await streamsOf(event.req);\n const currentScope = streams[0]!;\n const authority = await options.authority?.(event.req);\n await hub.start();\n const stream = createEventStream(event);\n const send = (payload: SyncBatch) => void stream.push(JSON.stringify(payload));\n const sendLive = (syncEvent: SyncEvent) =>\n send({ events: [syncEvent], cursor: encodeCursor(cursorOf(syncEvent)) });\n\n let arrivedDuringCatchUp: SyncEvent[] | undefined = [];\n const unsubscribes = streams.map((name) =>\n hub.subscribe(\n name,\n (syncEvent) => {\n if (arrivedDuringCatchUp) arrivedDuringCatchUp.push(syncEvent);\n else sendLive(syncEvent);\n },\n () => void stream.close(),\n ),\n );\n const keepAlive = setInterval(\n () => void stream.pushComment(\"keep-alive\"),\n keepAliveMs,\n );\n const disconnects: (() => void)[] = [];\n const ended = new AbortController();\n let detached = false;\n const revoked = authority?.revoked;\n const closeRevoked = () => void stream.close();\n const detach = () => {\n if (detached) return;\n detached = true;\n ended.abort();\n clearInterval(keepAlive);\n revoked?.removeEventListener(\"abort\", closeRevoked);\n for (const unsubscribe of unsubscribes) unsubscribe();\n for (const disconnect of disconnects) disconnect();\n };\n stream.onClosed(detach);\n if (revoked?.aborted) closeRevoked();\n else revoked?.addEventListener(\"abort\", closeRevoked, { once: true });\n\n void stream.push({\n event: \"handshake\",\n data: JSON.stringify({\n protocol: SYNC_PROTOCOL_VERSION,\n capabilities: authority?.channels === false ? [] : capabilities,\n keepAliveMs,\n }),\n });\n const clientId = options.clientId?.(event.req);\n for (const channel of authority?.channels === false ? [] : channels) {\n disconnects.push(\n channel.connect({\n clientId,\n scope: currentScope,\n ...(authority ? { principal: authority.principal } : {}),\n closed: ended.signal,\n send: (frame) => {\n if (!detached) void stream.push({ event: channel.event, data: frame });\n },\n }),\n );\n }\n const missed = await missedSince(db(), streams, event).catch((error: unknown) => {\n detach();\n throw error;\n });\n if (missed.events.length > 0 || missed.reset) send(missed);\n const caughtUpTo = decodeCursor(missed.cursor);\n const pending = arrivedDuringCatchUp\n .filter((syncEvent) => compareCursors(cursorOf(syncEvent), caughtUpTo) > 0)\n .sort((a, b) => compareCursors(cursorOf(a), cursorOf(b)));\n arrivedDuringCatchUp = undefined;\n for (const syncEvent of pending) sendLive(syncEvent);\n const body = await stream.send();\n if (event.req.headers.get(\"connection\") === \"keep-alive\") {\n event.res.headers.set(\"connection\", \"close\");\n }\n return body;\n });\n}\n"]}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Tag } from "@parlor/core";
|
|
2
|
+
import type { PgDatabase, PgQueryResultHKT } from "drizzle-orm/pg-core";
|
|
3
|
+
import { type SyncCursor, type SyncEvent } from "../protocol.js";
|
|
4
|
+
export type SyncDatabase = PgDatabase<PgQueryResultHKT>;
|
|
5
|
+
export declare const RETENTION_MS: number;
|
|
6
|
+
export declare const PRUNE_LOCK_KEY = 7311207;
|
|
7
|
+
export declare const NOTIFY_PAYLOAD_LIMIT_BYTES = 8000;
|
|
8
|
+
export declare function ensureSyncTables(db: SyncDatabase): Promise<void>;
|
|
9
|
+
export interface RecordSyncEventInput {
|
|
10
|
+
readonly scope: string;
|
|
11
|
+
readonly tags: readonly Tag[];
|
|
12
|
+
readonly source?: string | undefined;
|
|
13
|
+
}
|
|
14
|
+
export declare function recordSyncEvent(tx: SyncDatabase, input: RecordSyncEventInput): Promise<SyncEvent>;
|
|
15
|
+
export declare function notifyPayload(scope: string, event: SyncEvent): string;
|
|
16
|
+
/** One scope's events, or several streams' merged on the shared version order. */
|
|
17
|
+
export declare function readSyncEvents(db: SyncDatabase, scope: string | readonly string[], since: SyncCursor, limit?: number): Promise<SyncEvent[]>;
|
|
18
|
+
export type SyncBacklog = {
|
|
19
|
+
readonly events: SyncEvent[];
|
|
20
|
+
readonly resetTo?: undefined;
|
|
21
|
+
} | {
|
|
22
|
+
readonly resetTo: SyncCursor;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Every event of these streams after `since`, or, when they cannot all be replayed, the
|
|
26
|
+
* cursor to reset to: there are more than one batch of them, or pruning removed events
|
|
27
|
+
* after `since`. The initial cursor asks for the retained log, so pruning never resets it.
|
|
28
|
+
*/
|
|
29
|
+
export declare function readSyncBacklog(db: SyncDatabase, scope: string | readonly string[], since: SyncCursor): Promise<SyncBacklog>;
|
|
30
|
+
/**
|
|
31
|
+
* The last event of these streams, or the initial cursor when they have none. Read before
|
|
32
|
+
* a query, it marks what the query's answer already reflects: versions are allocated under
|
|
33
|
+
* one row lock, so any event committed later in these streams sorts after it.
|
|
34
|
+
*/
|
|
35
|
+
export declare function readSyncCursor(db: SyncDatabase, scope: string | readonly string[]): Promise<SyncCursor>;
|
|
36
|
+
export declare function pruneSyncEvents(db: SyncDatabase, retentionMs?: number): Promise<number>;
|
|
37
|
+
export interface NotifyPayload {
|
|
38
|
+
readonly scope: string;
|
|
39
|
+
readonly event: SyncEvent;
|
|
40
|
+
}
|
|
41
|
+
export declare function parseNotifyPayload(payload: string): NotifyPayload | undefined;
|
|
42
|
+
//# sourceMappingURL=store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../src/server/store.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,cAAc,CAAC;AAExC,OAAO,KAAK,EAAE,UAAU,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAExE,OAAO,EAGL,KAAK,UAAU,EACf,KAAK,SAAS,EAEf,MAAM,gBAAgB,CAAC;AAExB,MAAM,MAAM,YAAY,GAAG,UAAU,CAAC,gBAAgB,CAAC,CAAC;AAExD,eAAO,MAAM,YAAY,QAAsB,CAAC;AAChD,eAAO,MAAM,cAAc,UAAY,CAAC;AACxC,eAAO,MAAM,0BAA0B,OAAO,CAAC;AAQ/C,wBAAsB,gBAAgB,CAAC,EAAE,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CA2BtE;AAED,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,IAAI,EAAE,SAAS,GAAG,EAAE,CAAC;IAC9B,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CACtC;AAED,wBAAsB,eAAe,CACnC,EAAE,EAAE,YAAY,EAChB,KAAK,EAAE,oBAAoB,GAC1B,OAAO,CAAC,SAAS,CAAC,CA6BpB;AAGD,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,GAAG,MAAM,CAIrE;AAeD,kFAAkF;AAClF,wBAAsB,cAAc,CAClC,EAAE,EAAE,YAAY,EAChB,KAAK,EAAE,MAAM,GAAG,SAAS,MAAM,EAAE,EACjC,KAAK,EAAE,UAAU,EACjB,KAAK,GAAE,MAAkB,GACxB,OAAO,CAAC,SAAS,EAAE,CAAC,CAsBtB;AAED,MAAM,MAAM,WAAW,GACnB;IAAE,QAAQ,CAAC,MAAM,EAAE,SAAS,EAAE,CAAC;IAAC,QAAQ,CAAC,OAAO,CAAC,EAAE,SAAS,CAAA;CAAE,GAC9D;IAAE,QAAQ,CAAC,OAAO,EAAE,UAAU,CAAA;CAAE,CAAC;AAErC;;;;GAIG;AACH,wBAAsB,eAAe,CACnC,EAAE,EAAE,YAAY,EAChB,KAAK,EAAE,MAAM,GAAG,SAAS,MAAM,EAAE,EACjC,KAAK,EAAE,UAAU,GAChB,OAAO,CAAC,WAAW,CAAC,CAUtB;AASD;;;;GAIG;AACH,wBAAsB,cAAc,CAClC,EAAE,EAAE,YAAY,EAChB,KAAK,EAAE,MAAM,GAAG,SAAS,MAAM,EAAE,GAChC,OAAO,CAAC,UAAU,CAAC,CAerB;AAED,wBAAsB,eAAe,CACnC,EAAE,EAAE,YAAY,EAChB,WAAW,GAAE,MAAqB,GACjC,OAAO,CAAC,MAAM,CAAC,CAmBjB;AAED,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAC;CAC3B;AAED,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,GAAG,aAAa,GAAG,SAAS,CAQ7E"}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { sql } from "drizzle-orm";
|
|
2
|
+
import { compareCursors, INITIAL_CURSOR, SYNC_CHANNEL, } from "../protocol.js";
|
|
3
|
+
export const RETENTION_MS = 24 * 60 * 60 * 1000;
|
|
4
|
+
export const PRUNE_LOCK_KEY = 7_311_207;
|
|
5
|
+
export const NOTIFY_PAYLOAD_LIMIT_BYTES = 8000;
|
|
6
|
+
const MAX_BATCH = 500;
|
|
7
|
+
async function rows(query) {
|
|
8
|
+
const result = (await query);
|
|
9
|
+
return Array.isArray(result) ? result : (result.rows ?? []);
|
|
10
|
+
}
|
|
11
|
+
export async function ensureSyncTables(db) {
|
|
12
|
+
await db.execute(sql `
|
|
13
|
+
create table if not exists sync_events (
|
|
14
|
+
id text primary key,
|
|
15
|
+
version bigint not null,
|
|
16
|
+
scope text not null,
|
|
17
|
+
tags jsonb not null,
|
|
18
|
+
source text,
|
|
19
|
+
created_at timestamptz not null default clock_timestamp()
|
|
20
|
+
)`);
|
|
21
|
+
await db.execute(sql `create index if not exists sync_events_scope_version on sync_events (scope, version, id)`);
|
|
22
|
+
await db.execute(sql `create table if not exists sync_version (id int primary key, v bigint not null)`);
|
|
23
|
+
await db.execute(sql `
|
|
24
|
+
insert into sync_version (id, v)
|
|
25
|
+
select 1, greatest(coalesce((select max(version) from sync_events), 0), 0)
|
|
26
|
+
on conflict (id) do nothing`);
|
|
27
|
+
await db.execute(sql `create table if not exists sync_pruned (id int primary key, version bigint not null)`);
|
|
28
|
+
await db.execute(sql `
|
|
29
|
+
insert into sync_pruned (id, version)
|
|
30
|
+
select 1, coalesce((select min(version) - 1 from sync_events), (select v from sync_version where id = 1))
|
|
31
|
+
on conflict (id) do nothing`);
|
|
32
|
+
}
|
|
33
|
+
export async function recordSyncEvent(tx, input) {
|
|
34
|
+
if (input.tags.length === 0)
|
|
35
|
+
throw new Error("A sync event needs at least one tag");
|
|
36
|
+
const id = crypto.randomUUID();
|
|
37
|
+
const tags = JSON.stringify(input.tags);
|
|
38
|
+
const inserted = await rows(tx.execute(sql `
|
|
39
|
+
with alloc as (
|
|
40
|
+
update sync_version
|
|
41
|
+
set v = greatest(v + 1, (extract(epoch from clock_timestamp()) * 1000)::bigint)
|
|
42
|
+
where id = 1
|
|
43
|
+
returning v
|
|
44
|
+
)
|
|
45
|
+
insert into sync_events (id, version, scope, tags, source)
|
|
46
|
+
select ${id}, alloc.v, ${input.scope}, ${tags}::jsonb, ${input.source ?? null}
|
|
47
|
+
from alloc
|
|
48
|
+
returning version`));
|
|
49
|
+
const row = inserted[0];
|
|
50
|
+
if (!row)
|
|
51
|
+
throw new Error("sync_version row is missing; run ensureSyncTables first");
|
|
52
|
+
const event = {
|
|
53
|
+
version: Number(row.version),
|
|
54
|
+
id,
|
|
55
|
+
tags: input.tags,
|
|
56
|
+
source: input.source,
|
|
57
|
+
};
|
|
58
|
+
await tx.execute(sql `select pg_notify(${SYNC_CHANNEL}, ${notifyPayload(input.scope, event)})`);
|
|
59
|
+
return event;
|
|
60
|
+
}
|
|
61
|
+
// pg_notify rejects payloads of 8000 bytes or more; the row keeps the full tags.
|
|
62
|
+
export function notifyPayload(scope, event) {
|
|
63
|
+
const full = JSON.stringify({ scope, event });
|
|
64
|
+
if (new TextEncoder().encode(full).length < NOTIFY_PAYLOAD_LIMIT_BYTES)
|
|
65
|
+
return full;
|
|
66
|
+
return JSON.stringify({ scope, event: { ...event, tags: tableTags(event.tags) } });
|
|
67
|
+
}
|
|
68
|
+
function tableTags(tags) {
|
|
69
|
+
const tables = new Set();
|
|
70
|
+
for (const tag of tags)
|
|
71
|
+
if (tag[0] !== undefined)
|
|
72
|
+
tables.add(tag[0]);
|
|
73
|
+
return [...tables].map((table) => [table]);
|
|
74
|
+
}
|
|
75
|
+
/** One scope's events, or several streams' merged on the shared version order. */
|
|
76
|
+
export async function readSyncEvents(db, scope, since, limit = MAX_BATCH) {
|
|
77
|
+
const scopes = typeof scope === "string" ? [scope] : scope;
|
|
78
|
+
const found = await rows(db.execute(sql `
|
|
79
|
+
select id, version, tags, source
|
|
80
|
+
from sync_events
|
|
81
|
+
where scope in (${sql.join(scopes.map((one) => sql `${one}`), sql `, `)})
|
|
82
|
+
and (version > ${since.version} or (version = ${since.version} and id > ${since.id}))
|
|
83
|
+
order by version asc, id asc
|
|
84
|
+
limit ${limit}`));
|
|
85
|
+
return found
|
|
86
|
+
.map((row) => ({
|
|
87
|
+
id: row.id,
|
|
88
|
+
version: Number(row.version),
|
|
89
|
+
tags: row.tags,
|
|
90
|
+
source: row.source ?? undefined,
|
|
91
|
+
}))
|
|
92
|
+
.sort((a, b) => compareCursors(a, b));
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Every event of these streams after `since`, or, when they cannot all be replayed, the
|
|
96
|
+
* cursor to reset to: there are more than one batch of them, or pruning removed events
|
|
97
|
+
* after `since`. The initial cursor asks for the retained log, so pruning never resets it.
|
|
98
|
+
*/
|
|
99
|
+
export async function readSyncBacklog(db, scope, since) {
|
|
100
|
+
const events = await readSyncEvents(db, scope, since, MAX_BATCH + 1);
|
|
101
|
+
const pruned = await readPrunedVersion(db);
|
|
102
|
+
const fromStart = compareCursors(since, INITIAL_CURSOR) === 0;
|
|
103
|
+
if (events.length <= MAX_BATCH && (fromStart || since.version >= pruned)) {
|
|
104
|
+
return { events };
|
|
105
|
+
}
|
|
106
|
+
const head = await readSyncCursor(db, scope);
|
|
107
|
+
const floor = { version: pruned, id: "" };
|
|
108
|
+
return { resetTo: compareCursors(head, floor) > 0 ? head : floor };
|
|
109
|
+
}
|
|
110
|
+
async function readPrunedVersion(db) {
|
|
111
|
+
const [row] = await rows(db.execute(sql `select version from sync_pruned where id = 1`));
|
|
112
|
+
return Number(row?.version ?? 0);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* The last event of these streams, or the initial cursor when they have none. Read before
|
|
116
|
+
* a query, it marks what the query's answer already reflects: versions are allocated under
|
|
117
|
+
* one row lock, so any event committed later in these streams sorts after it.
|
|
118
|
+
*/
|
|
119
|
+
export async function readSyncCursor(db, scope) {
|
|
120
|
+
const scopes = typeof scope === "string" ? [scope] : scope;
|
|
121
|
+
const lasts = scopes.map((one) => sql `(select version, id from sync_events where scope = ${one} order by version desc, id desc limit 1)`);
|
|
122
|
+
const found = await rows(db.execute(sql.join(lasts, sql ` union all `)));
|
|
123
|
+
return found
|
|
124
|
+
.map((row) => ({ version: Number(row.version), id: row.id }))
|
|
125
|
+
.reduce((latest, cursor) => (compareCursors(cursor, latest) > 0 ? cursor : latest), INITIAL_CURSOR);
|
|
126
|
+
}
|
|
127
|
+
export async function pruneSyncEvents(db, retentionMs = RETENTION_MS) {
|
|
128
|
+
return db.transaction(async (tx) => {
|
|
129
|
+
const [lock] = await rows(tx.execute(sql `select pg_try_advisory_xact_lock(${PRUNE_LOCK_KEY}) as held`));
|
|
130
|
+
if (!lock?.held)
|
|
131
|
+
return 0;
|
|
132
|
+
const cutoff = new Date(Date.now() - retentionMs);
|
|
133
|
+
const [deleted] = await rows(tx.execute(sql `
|
|
134
|
+
with deleted as (
|
|
135
|
+
delete from sync_events where created_at < ${cutoff} returning version
|
|
136
|
+
), marked as (
|
|
137
|
+
update sync_pruned set version = greatest(version, (select max(version) from deleted))
|
|
138
|
+
where id = 1
|
|
139
|
+
)
|
|
140
|
+
select count(*)::int as count from deleted`));
|
|
141
|
+
return deleted?.count ?? 0;
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
export function parseNotifyPayload(payload) {
|
|
145
|
+
try {
|
|
146
|
+
const parsed = JSON.parse(payload);
|
|
147
|
+
if (typeof parsed.scope !== "string" || !parsed.event)
|
|
148
|
+
return undefined;
|
|
149
|
+
return { scope: parsed.scope, event: parsed.event };
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
//# sourceMappingURL=store.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"store.js","sourceRoot":"","sources":["../../src/server/store.ts"],"names":[],"mappings":"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","sourcesContent":["import type { Tag } from \"@parlor/core\";\nimport { sql } from \"drizzle-orm\";\nimport type { PgDatabase, PgQueryResultHKT } from \"drizzle-orm/pg-core\";\n\nimport {\n compareCursors,\n INITIAL_CURSOR,\n type SyncCursor,\n type SyncEvent,\n SYNC_CHANNEL,\n} from \"../protocol.js\";\n\nexport type SyncDatabase = PgDatabase<PgQueryResultHKT>;\n\nexport const RETENTION_MS = 24 * 60 * 60 * 1000;\nexport const PRUNE_LOCK_KEY = 7_311_207;\nexport const NOTIFY_PAYLOAD_LIMIT_BYTES = 8000;\nconst MAX_BATCH = 500;\n\nasync function rows<T>(query: Promise<unknown>): Promise<T[]> {\n const result = (await query) as { rows?: T[] } | T[];\n return Array.isArray(result) ? result : (result.rows ?? []);\n}\n\nexport async function ensureSyncTables(db: SyncDatabase): Promise<void> {\n await db.execute(sql`\n create table if not exists sync_events (\n id text primary key,\n version bigint not null,\n scope text not null,\n tags jsonb not null,\n source text,\n created_at timestamptz not null default clock_timestamp()\n )`);\n await db.execute(\n sql`create index if not exists sync_events_scope_version on sync_events (scope, version, id)`,\n );\n await db.execute(\n sql`create table if not exists sync_version (id int primary key, v bigint not null)`,\n );\n await db.execute(sql`\n insert into sync_version (id, v)\n select 1, greatest(coalesce((select max(version) from sync_events), 0), 0)\n on conflict (id) do nothing`);\n await db.execute(\n sql`create table if not exists sync_pruned (id int primary key, version bigint not null)`,\n );\n await db.execute(sql`\n insert into sync_pruned (id, version)\n select 1, coalesce((select min(version) - 1 from sync_events), (select v from sync_version where id = 1))\n on conflict (id) do nothing`);\n}\n\nexport interface RecordSyncEventInput {\n readonly scope: string;\n readonly tags: readonly Tag[];\n readonly source?: string | undefined;\n}\n\nexport async function recordSyncEvent(\n tx: SyncDatabase,\n input: RecordSyncEventInput,\n): Promise<SyncEvent> {\n if (input.tags.length === 0) throw new Error(\"A sync event needs at least one tag\");\n const id = crypto.randomUUID();\n const tags = JSON.stringify(input.tags);\n const inserted = await rows<{ version: number | string }>(\n tx.execute(sql`\n with alloc as (\n update sync_version\n set v = greatest(v + 1, (extract(epoch from clock_timestamp()) * 1000)::bigint)\n where id = 1\n returning v\n )\n insert into sync_events (id, version, scope, tags, source)\n select ${id}, alloc.v, ${input.scope}, ${tags}::jsonb, ${input.source ?? null}\n from alloc\n returning version`),\n );\n const row = inserted[0];\n if (!row) throw new Error(\"sync_version row is missing; run ensureSyncTables first\");\n const event: SyncEvent = {\n version: Number(row.version),\n id,\n tags: input.tags,\n source: input.source,\n };\n await tx.execute(\n sql`select pg_notify(${SYNC_CHANNEL}, ${notifyPayload(input.scope, event)})`,\n );\n return event;\n}\n\n// pg_notify rejects payloads of 8000 bytes or more; the row keeps the full tags.\nexport function notifyPayload(scope: string, event: SyncEvent): string {\n const full = JSON.stringify({ scope, event });\n if (new TextEncoder().encode(full).length < NOTIFY_PAYLOAD_LIMIT_BYTES) return full;\n return JSON.stringify({ scope, event: { ...event, tags: tableTags(event.tags) } });\n}\n\nfunction tableTags(tags: readonly Tag[]): Tag[] {\n const tables = new Set<string | number>();\n for (const tag of tags) if (tag[0] !== undefined) tables.add(tag[0]);\n return [...tables].map((table) => [table]);\n}\n\ninterface EventRow {\n id: string;\n version: number | string;\n tags: Tag[];\n source: string | null;\n}\n\n/** One scope's events, or several streams' merged on the shared version order. */\nexport async function readSyncEvents(\n db: SyncDatabase,\n scope: string | readonly string[],\n since: SyncCursor,\n limit: number = MAX_BATCH,\n): Promise<SyncEvent[]> {\n const scopes = typeof scope === \"string\" ? [scope] : scope;\n const found = await rows<EventRow>(\n db.execute(sql`\n select id, version, tags, source\n from sync_events\n where scope in (${sql.join(\n scopes.map((one) => sql`${one}`),\n sql`, `,\n )})\n and (version > ${since.version} or (version = ${since.version} and id > ${since.id}))\n order by version asc, id asc\n limit ${limit}`),\n );\n return found\n .map((row) => ({\n id: row.id,\n version: Number(row.version),\n tags: row.tags,\n source: row.source ?? undefined,\n }))\n .sort((a, b) => compareCursors(a, b));\n}\n\nexport type SyncBacklog =\n | { readonly events: SyncEvent[]; readonly resetTo?: undefined }\n | { readonly resetTo: SyncCursor };\n\n/**\n * Every event of these streams after `since`, or, when they cannot all be replayed, the\n * cursor to reset to: there are more than one batch of them, or pruning removed events\n * after `since`. The initial cursor asks for the retained log, so pruning never resets it.\n */\nexport async function readSyncBacklog(\n db: SyncDatabase,\n scope: string | readonly string[],\n since: SyncCursor,\n): Promise<SyncBacklog> {\n const events = await readSyncEvents(db, scope, since, MAX_BATCH + 1);\n const pruned = await readPrunedVersion(db);\n const fromStart = compareCursors(since, INITIAL_CURSOR) === 0;\n if (events.length <= MAX_BATCH && (fromStart || since.version >= pruned)) {\n return { events };\n }\n const head = await readSyncCursor(db, scope);\n const floor: SyncCursor = { version: pruned, id: \"\" };\n return { resetTo: compareCursors(head, floor) > 0 ? head : floor };\n}\n\nasync function readPrunedVersion(db: SyncDatabase): Promise<number> {\n const [row] = await rows<{ version: number | string }>(\n db.execute(sql`select version from sync_pruned where id = 1`),\n );\n return Number(row?.version ?? 0);\n}\n\n/**\n * The last event of these streams, or the initial cursor when they have none. Read before\n * a query, it marks what the query's answer already reflects: versions are allocated under\n * one row lock, so any event committed later in these streams sorts after it.\n */\nexport async function readSyncCursor(\n db: SyncDatabase,\n scope: string | readonly string[],\n): Promise<SyncCursor> {\n const scopes = typeof scope === \"string\" ? [scope] : scope;\n const lasts = scopes.map(\n (one) =>\n sql`(select version, id from sync_events where scope = ${one} order by version desc, id desc limit 1)`,\n );\n const found = await rows<{ id: string; version: number | string }>(\n db.execute(sql.join(lasts, sql` union all `)),\n );\n return found\n .map((row) => ({ version: Number(row.version), id: row.id }))\n .reduce<SyncCursor>(\n (latest, cursor) => (compareCursors(cursor, latest) > 0 ? cursor : latest),\n INITIAL_CURSOR,\n );\n}\n\nexport async function pruneSyncEvents(\n db: SyncDatabase,\n retentionMs: number = RETENTION_MS,\n): Promise<number> {\n return db.transaction(async (tx) => {\n const [lock] = await rows<{ held: boolean }>(\n tx.execute(sql`select pg_try_advisory_xact_lock(${PRUNE_LOCK_KEY}) as held`),\n );\n if (!lock?.held) return 0;\n const cutoff = new Date(Date.now() - retentionMs);\n const [deleted] = await rows<{ count: number }>(\n tx.execute(sql`\n with deleted as (\n delete from sync_events where created_at < ${cutoff} returning version\n ), marked as (\n update sync_pruned set version = greatest(version, (select max(version) from deleted))\n where id = 1\n )\n select count(*)::int as count from deleted`),\n );\n return deleted?.count ?? 0;\n });\n}\n\nexport interface NotifyPayload {\n readonly scope: string;\n readonly event: SyncEvent;\n}\n\nexport function parseNotifyPayload(payload: string): NotifyPayload | undefined {\n try {\n const parsed = JSON.parse(payload) as Partial<NotifyPayload>;\n if (typeof parsed.scope !== \"string\" || !parsed.event) return undefined;\n return { scope: parsed.scope, event: parsed.event };\n } catch {\n return undefined;\n }\n}\n"]}
|