dzql 0.6.37 → 0.8.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/README.md +16 -0
- package/docs/README.md +1 -1
- package/docs/for_ai.md +39 -9
- package/package.json +12 -5
- package/src/cli/codegen/notification.ts +3 -3
- package/src/cli/codegen/sql/core.ts +21 -5
- package/src/cli/codegen/sql/delete.ts +5 -7
- package/src/cli/codegen/sql/get.ts +9 -11
- package/src/cli/codegen/sql/index.ts +7 -4
- package/src/cli/codegen/sql/lookup.ts +4 -5
- package/src/cli/codegen/sql/save.ts +58 -39
- package/src/cli/codegen/sql/search.ts +44 -10
- package/src/cli/codegen/sql/utils.ts +16 -1
- package/src/cli/codegen/subscribable_sql.ts +116 -124
- package/src/cli/compile.ts +263 -0
- package/src/cli/compiler/path-validator.ts +115 -83
- package/src/cli/compiler/paths.ts +299 -379
- package/src/cli/compiler/permissions.ts +26 -26
- package/src/cli/index.ts +17 -219
- package/src/client/ws.ts +20 -10
- package/src/postgres/auth-jwt-sql.ts +32 -0
- package/src/postgres/auth-jwt.ts +209 -0
- package/src/postgres/auth.ts +50 -0
- package/src/postgres/bootstrap.ts +55 -0
- package/src/postgres/carry.ts +40 -0
- package/src/postgres/codegen.ts +172 -0
- package/src/postgres/index.ts +58 -0
- package/src/postgres/listener.ts +706 -0
- package/src/postgres/migrate.ts +143 -0
- package/src/postgres/pglite.ts +142 -0
- package/src/postgres/registry.ts +253 -0
- package/src/postgres/schema.ts +171 -0
- package/src/postgres/sql.ts +42 -0
- package/src/runtime/auth.ts +33 -2
- package/src/runtime/index.ts +47 -6
- package/src/runtime/manifest_loader.ts +10 -0
- package/src/runtime/projection.ts +19 -0
- package/src/sql/001a-delta-meta.sql +204 -0
- package/src/sql/001b-delta-scope.sql +619 -0
- package/src/sql/001c-delta-read.sql +506 -0
- package/src/sql/001d-delta-write.sql +1088 -0
- package/src/sql/001e-delta-ops.sql +142 -0
- package/src/sql/001f-delta-as.sql +41 -0
- package/src/sql/001g-delta-ledger.sql +564 -0
- package/src/sql/auth-jwt.sql +62 -0
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DocListener — one server, one LISTEN, many doc types.
|
|
3
|
+
*
|
|
4
|
+
* A listener answers for the doc types it is given (`types`, and `register`
|
|
5
|
+
* later); two servers in one process each have their own.
|
|
6
|
+
*
|
|
7
|
+
* Auth is pluggable via `opts.auth`. When provided, every doc message passes
|
|
8
|
+
* through `auth.gate(client)` and the resulting identity is threaded into
|
|
9
|
+
* `type.open / apply / openAt` so backends can scope queries (e.g. with
|
|
10
|
+
* `withAppAuth` + RLS), and a type's `owns(identity, docName)` decides whether
|
|
11
|
+
* that identity may have the document at all (404 when not). When auth is
|
|
12
|
+
* omitted, no gate runs and identity is undefined — delta itself has no
|
|
13
|
+
* opinion on authentication.
|
|
14
|
+
*/
|
|
15
|
+
import type { WsServer } from "@blueshed/delta/backend";
|
|
16
|
+
import { trackSubscribe, trackUnsubscribe, onClientDrop } from "@blueshed/delta/backend";
|
|
17
|
+
import { createLogger } from "@blueshed/delta/logger";
|
|
18
|
+
import type { Pool, PoolClient } from "pg";
|
|
19
|
+
import { callAs, docTypes, showAnswer } from "./registry";
|
|
20
|
+
import { showDoc, showOps, type DocDef, type Schema } from "@blueshed/delta/backend";
|
|
21
|
+
import { resolveScope } from "@blueshed/delta/backend";
|
|
22
|
+
import type { DocType } from "./registry";
|
|
23
|
+
import type { DeltaAuth } from "@blueshed/delta/auth";
|
|
24
|
+
import { isAuthError } from "@blueshed/delta/auth";
|
|
25
|
+
import type { DeltaOp } from "@blueshed/delta/core";
|
|
26
|
+
import { isEntryId, ledgerWho, socketCursor } from "@blueshed/delta/backend";
|
|
27
|
+
import { assertOwned, createCustomDocs, NOT_FOUND, sendTo, type CustomDoc, type Owns, type Refusal } from "@blueshed/delta/backend";
|
|
28
|
+
|
|
29
|
+
const log = createLogger("[doc]");
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Custom doc — predicate-based membership view over watched collections.
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A custom doc on Postgres (`../custom`): its reads take the pool and answer
|
|
37
|
+
* with a promise. A membership `query` runs once per doc name and identity
|
|
38
|
+
* (the gated identity, undefined without auth), and its result is cached and
|
|
39
|
+
* shared by the subscribers of that name who are that identity -- never by
|
|
40
|
+
* another: bind the identity yourself (`withAppAuth(pool, id, …)`) so RLS
|
|
41
|
+
* scopes what it reads, as recompute does. A `recompute` reads the WHOLE doc,
|
|
42
|
+
* on open and on any write to a watched collection, and republishes it to each
|
|
43
|
+
* subscriber as a root-replace op -- for nested/joined reads a per-row
|
|
44
|
+
* `matches` can't express: re-evaluated PER SUBSCRIBER under that client's
|
|
45
|
+
* identity, so RLS applies, hence it binds `app.user_id` itself.
|
|
46
|
+
*/
|
|
47
|
+
export interface CustomDocDef<C = unknown, I = any> extends CustomDoc<C, I, Pool> {
|
|
48
|
+
query?: (pool: Pool, criteria: C, identity?: I) => Promise<Record<string, any[]>>;
|
|
49
|
+
recompute?: (pool: Pool, criteria: C, identity?: I) => Promise<any>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function defineCustomDoc<C, I = any>(
|
|
53
|
+
prefix: string,
|
|
54
|
+
opts: Omit<CustomDocDef<C, I>, "prefix">,
|
|
55
|
+
): CustomDocDef<C, I> {
|
|
56
|
+
return { prefix, ...opts };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The schema the database declares (`_delta_collections`, the last
|
|
61
|
+
* `generateSql` applied), as a listener given none shows it: read once, as
|
|
62
|
+
* it starts.
|
|
63
|
+
*/
|
|
64
|
+
async function declaredSchema(pool: Pool): Promise<Schema> {
|
|
65
|
+
try {
|
|
66
|
+
const { rows } = await pool.query("SELECT collection_key, table_name, columns_def, parent_collection, parent_fk, temporal FROM _delta_collections");
|
|
67
|
+
return {
|
|
68
|
+
tables: Object.fromEntries(rows.map((r: any) => [r.collection_key, {
|
|
69
|
+
name: r.table_name, docKey: r.collection_key, columns: r.columns_def ?? {}, temporal: !!r.temporal, children: [], referencedBy: [],
|
|
70
|
+
...(r.parent_fk ? { parent: { collection: r.parent_collection, fkColumn: r.parent_fk } } : {}),
|
|
71
|
+
}])),
|
|
72
|
+
};
|
|
73
|
+
} catch {
|
|
74
|
+
return { tables: {} }; // no framework yet: nothing declared, nothing left out
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function errMsg(err: unknown): string {
|
|
79
|
+
return err instanceof Error ? err.message : String(err);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The wire code for an error the database raised -- the same table as the
|
|
84
|
+
* other backends. The framework raises SQLSTATE 22023 for a malformed path or
|
|
85
|
+
* op, 22P02 for a row id it could not mint, 22007 and 22008 for a time it
|
|
86
|
+
* cannot read, and 23502 for an add that leaves out a required field (a
|
|
87
|
+
* client's mistake, 400); P0002 for a row that is not
|
|
88
|
+
* there (404); 23505 for an add of a row that is, and 40001 for an undo of an
|
|
89
|
+
* entry that is not the cursor's next (409). And 40P01, a write that met another
|
|
90
|
+
* at once and gave way to it (two adds of temporal ids in opposite orders wait
|
|
91
|
+
* on each other's row locks, todo #16): nothing was written, and the same write
|
|
92
|
+
* sent again goes through, or meets the rows the other made (409). Anything
|
|
93
|
+
* else is the server's (500).
|
|
94
|
+
*/
|
|
95
|
+
const CODE_OF_SQLSTATE: Record<string, number> = { "22023": 400, "22P02": 400, "22007": 400, "22008": 400, "23502": 400, "25006": 403, P0002: 404, "23505": 409, "40001": 409, "40P01": 409 };
|
|
96
|
+
function wireCode(err: unknown): number {
|
|
97
|
+
const state = (err as { code?: unknown } | null)?.code;
|
|
98
|
+
return (typeof state === "string" && CODE_OF_SQLSTATE[state]) || 500;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// State — per-doc subscriber tracking
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
interface DocState {
|
|
106
|
+
version: number;
|
|
107
|
+
/**
|
|
108
|
+
* The last version a notification has announced (or an open read): a drain
|
|
109
|
+
* reads no further (todo #55). A later entry is heard when its own
|
|
110
|
+
* notification comes, so each notification maps to entries not yet heard,
|
|
111
|
+
* and a document let go of and tracked again by a late notification does not
|
|
112
|
+
* read an entry twice. A resync after a reconnect, which missed notifications,
|
|
113
|
+
* reads to the end.
|
|
114
|
+
*/
|
|
115
|
+
until: number;
|
|
116
|
+
subscribers: Set<any>;
|
|
117
|
+
notifying: boolean;
|
|
118
|
+
/** A notification arrived while a fetch was in flight — re-drain when it ends. */
|
|
119
|
+
pending: boolean;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** _delta_fetch_log (delta_fetch_ops' rows) caps each call at this many; a full page means there may be more. */
|
|
123
|
+
const FETCH_PAGE = 1000;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* How the listener reads a document's log. `_delta_fetch_log` (001e) gives each
|
|
127
|
+
* entry with the write as applied, for custom documents (todo #44). Framework
|
|
128
|
+
* SQL older than this listener -- vendored with `delta init` into an initdb
|
|
129
|
+
* directory and not re-applied -- has no such function, and every fetch would
|
|
130
|
+
* fail, so no broadcast would reach anyone: there the listener reads
|
|
131
|
+
* `delta_fetch_ops`, each entry heard as told, as before, and says so once.
|
|
132
|
+
*/
|
|
133
|
+
const FETCH_LOG = "SELECT version, ops, applied FROM _delta_fetch_log($1, $2)";
|
|
134
|
+
const FETCH_TOLD = "SELECT version, ops, NULL::jsonb AS applied FROM delta_fetch_ops($1, $2)";
|
|
135
|
+
async function fetchQuery(pool: Pool): Promise<string> {
|
|
136
|
+
const { rows } = await pool.query("SELECT to_regprocedure('_delta_fetch_log(text, bigint)') IS NOT NULL AS ok");
|
|
137
|
+
if (rows[0]?.ok) return FETCH_LOG;
|
|
138
|
+
log.warn(
|
|
139
|
+
"the framework SQL is older than this listener (no _delta_fetch_log, 001e): changes are read with delta_fetch_ops, " +
|
|
140
|
+
"and a custom document hears a write once for each document told of it. Re-apply it (applyFramework, or " +
|
|
141
|
+
"bunx @blueshed/delta init) and restart.",
|
|
142
|
+
);
|
|
143
|
+
return FETCH_TOLD;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function createDocListener<I = unknown>(
|
|
147
|
+
ws: WsServer,
|
|
148
|
+
pool: Pool,
|
|
149
|
+
opts?: {
|
|
150
|
+
/** The documents this listener answers for: `docTypeFromDef(def, pool)`, or a `DocType` written by hand. */
|
|
151
|
+
types?: DocType<any, I>[];
|
|
152
|
+
auth?: DeltaAuth<I>;
|
|
153
|
+
custom?: CustomDocDef<any>[];
|
|
154
|
+
/** Keep the ledger (001g): every write with its inverse, who made it and the cursor undo walks; and `undo`, `redo`, `history`. */
|
|
155
|
+
ledger?: boolean;
|
|
156
|
+
/** How an identity is written in the ledger. Default: a string or number as it is, anything else as JSON. */
|
|
157
|
+
who?: (identity: I) => string;
|
|
158
|
+
/**
|
|
159
|
+
* This process's schema: the schema is the contract (reference/migrations.md). It writes
|
|
160
|
+
* by the columns it declares, and sends only those -- two versions on one database each
|
|
161
|
+
* see and write their own. Without it, the columns the database declares as the
|
|
162
|
+
* listener starts (`_delta_collections`, the last `generateSql` applied).
|
|
163
|
+
*/
|
|
164
|
+
schema?: Schema;
|
|
165
|
+
},
|
|
166
|
+
) {
|
|
167
|
+
const auth = opts?.auth;
|
|
168
|
+
// Default-deny, as docTypeFromDef is: with auth, every document -- a custom
|
|
169
|
+
// one, a registered type -- says who owns it, or that it is shared. Asked
|
|
170
|
+
// before the pool is touched, so a refusal leaves nothing to clean up.
|
|
171
|
+
assertOwned(opts?.custom ?? [], auth);
|
|
172
|
+
const registry = docTypes((opts?.types ?? []) as DocType[], (opts?.custom ?? []).map((d) => d.prefix), !!auth);
|
|
173
|
+
// what this process declares: it writes by it (`declared`, set for each write's transaction) and sends only it (`shown`)
|
|
174
|
+
const declared = opts?.schema ? JSON.stringify(Object.fromEntries(Object.entries(opts.schema.tables).map(([key, t]) => [key, t.columns]))) : undefined;
|
|
175
|
+
const shownBy: Schema = opts?.schema ?? (await declaredSchema(pool));
|
|
176
|
+
/** A document as this process shows it: each row its declared columns (`showDoc`). */
|
|
177
|
+
const shownDoc = (type: DocType<any, I>, docName: string, doc: any) =>
|
|
178
|
+
type.def && doc ? showDoc(type.def, shownBy, doc, resolveScope(type.def, docName.slice(type.def.prefix.length)).mode === "single") : doc;
|
|
179
|
+
/** Ops, and an answer's ops and inverse, as this process shows them (`showOps`). */
|
|
180
|
+
const shownOps = (def: DocDef | undefined, ops: any) => (def && Array.isArray(ops) ? showOps(def, shownBy, ops) : ops);
|
|
181
|
+
const shownAnswer = <R extends { ops?: unknown; inverse?: unknown } | null>(def: DocDef | undefined, r: R): R =>
|
|
182
|
+
r && def ? { ...r, ...(r.ops ? { ops: shownOps(def, r.ops) } : {}), ...(r.inverse ? { inverse: shownOps(def, r.inverse) } : {}) } : r;
|
|
183
|
+
const resolveDoc = registry.resolve;
|
|
184
|
+
const ledger = !!opts?.ledger;
|
|
185
|
+
const whoOf = (identity: I | undefined): string | null => ledgerWho(identity, opts?.who);
|
|
186
|
+
// The cursor undo walks: named by a caller in this process, and over the
|
|
187
|
+
// socket the connection itself -- signed in, the person and the connection
|
|
188
|
+
// together, so another person holding the same connection id (a client may
|
|
189
|
+
// choose it, to keep its cursor across a reconnect) cannot walk it.
|
|
190
|
+
const cursorOf = (msg: any, client: any, identity: I | undefined): string | null =>
|
|
191
|
+
client?.data?.local ? (typeof msg.cursor === "string" ? msg.cursor : null) : socketCursor(whoOf(identity), client);
|
|
192
|
+
// Who is writing, for the ledger: the identity the gate gives when there is
|
|
193
|
+
// an auth module; without one, the identity the client carries
|
|
194
|
+
// (`createLocal().as(identity)`, or one set at upgrade) -- as the SQLite backend reads it.
|
|
195
|
+
const writerOf = (identity: I | undefined, client: any): I | undefined =>
|
|
196
|
+
auth ? identity : (client?.data?.identity as I | undefined);
|
|
197
|
+
/** The gate's identity (undefined without auth), or the 401 for a socket without one. */
|
|
198
|
+
const gate = (client: any): { identity: I | undefined } | { error: Refusal } => {
|
|
199
|
+
if (!auth) return { identity: undefined };
|
|
200
|
+
const gated = auth.gate(client);
|
|
201
|
+
return isAuthError(gated) ? { error: { code: 401, message: gated.error } } : { identity: gated };
|
|
202
|
+
};
|
|
203
|
+
const tracked = new Map<string, DocState>();
|
|
204
|
+
/**
|
|
205
|
+
* The names this listener holds open in the database (`_delta_held`, todo
|
|
206
|
+
* #73), each its hold's query: a write's fan-out asks only the names held
|
|
207
|
+
* somewhere. A name is held from before its first open here reads it, until
|
|
208
|
+
* nobody here has it open or is opening it (`opening`). Its holds and lets
|
|
209
|
+
* go run on the pool in the order asked, one name's beside another's: a hold
|
|
210
|
+
* that waits for a write holds up no other name. The listener's connection
|
|
211
|
+
* keeps the lock that says this holder is alive.
|
|
212
|
+
*/
|
|
213
|
+
const holds = new Map<string, Promise<unknown>>();
|
|
214
|
+
const opening = new Map<string, number>();
|
|
215
|
+
const queued = new Map<string, Promise<unknown>>();
|
|
216
|
+
let holder: number | null = null;
|
|
217
|
+
/** `sql` for `docName`, after what was asked for it before. */
|
|
218
|
+
function inOrder(docName: string, sql: string): Promise<unknown> {
|
|
219
|
+
const run = (queued.get(docName) ?? Promise.resolve()).then(() => pool.query(sql, [holder, docName]));
|
|
220
|
+
const done = run.catch(() => {});
|
|
221
|
+
queued.set(docName, done);
|
|
222
|
+
void done.then(() => { if (queued.get(docName) === done) queued.delete(docName); });
|
|
223
|
+
return run;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// The custom documents (../custom): who holds each, who may hear it, what a
|
|
227
|
+
// write tells it -- and who may still hear any document (todo #17): a change
|
|
228
|
+
// told asks the gate and `owns` again of each socket it is told to (`owns`
|
|
229
|
+
// once per identity per change), so what a change costs is the identities a
|
|
230
|
+
// document has open, not its sockets. Without auth nothing is asked. The
|
|
231
|
+
// longest prefix answers a name: a type's longer than a custom doc's wins.
|
|
232
|
+
const custom = createCustomDocs<I, Pool>({
|
|
233
|
+
defs: opts?.custom ?? [], handle: pool, auth, publish: (channel, data) => ws.publish(channel, data), log,
|
|
234
|
+
answers: (docName, length) => (resolveDoc(docName)?.type.prefix.length ?? 0) <= length,
|
|
235
|
+
});
|
|
236
|
+
const ownsOf = (docName: string): Owns<I> | undefined => {
|
|
237
|
+
const type = resolveDoc(docName)?.type;
|
|
238
|
+
return type?.owns ? (identity, name) => type.owns!(identity, name) : undefined;
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
// Single LISTEN connection with auto-reconnect
|
|
242
|
+
const fetchSql = await fetchQuery(pool);
|
|
243
|
+
let listener: PoolClient = await pool.connect();
|
|
244
|
+
let destroyed = false;
|
|
245
|
+
let reconnecting = false;
|
|
246
|
+
|
|
247
|
+
let errorHandler: ((err: Error) => void) | null = null;
|
|
248
|
+
|
|
249
|
+
// Detach our handlers before releasing a connection — released connections
|
|
250
|
+
// are recycled by the pool, and a stale listener would fire against this
|
|
251
|
+
// closure's state (and spuriously trigger reconnect) on a future checkout.
|
|
252
|
+
function detachListener(client: any) {
|
|
253
|
+
try { client.removeListener?.("notification", onNotification); } catch {}
|
|
254
|
+
if (errorHandler) {
|
|
255
|
+
try { client.removeListener?.("error", errorHandler); } catch {}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** LISTEN on `client`, and keep this holder's lock on it. */
|
|
260
|
+
async function setupListener(client: any): Promise<void> {
|
|
261
|
+
await client.query("LISTEN delta_changes");
|
|
262
|
+
client.on("notification", onNotification);
|
|
263
|
+
errorHandler = (err: Error) => {
|
|
264
|
+
log.error(`listener error: ${err.message}`);
|
|
265
|
+
if (!destroyed) reconnect();
|
|
266
|
+
};
|
|
267
|
+
client.on("error", errorHandler);
|
|
268
|
+
holder = (await client.query("SELECT _delta_holder_start($1) AS holder", [holder])).rows[0].holder;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function reconnect() {
|
|
272
|
+
if (reconnecting || destroyed) return; // a single error event runs the loop
|
|
273
|
+
reconnecting = true;
|
|
274
|
+
// `reconnecting` stays true for the WHOLE operation, including the resync,
|
|
275
|
+
// so an error fired by the freshly-connected listener during resync can't
|
|
276
|
+
// spawn a second concurrent reconnect that releases the connection resync
|
|
277
|
+
// is using. The finally guarantees the flag is always cleared.
|
|
278
|
+
try {
|
|
279
|
+
log.info("listener reconnecting...");
|
|
280
|
+
detachListener(listener);
|
|
281
|
+
try { listener.release(); } catch {}
|
|
282
|
+
const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
283
|
+
for (let attempt = 1; !destroyed; attempt++) {
|
|
284
|
+
try {
|
|
285
|
+
await delay(Math.min(attempt * 1000, 10000));
|
|
286
|
+
listener = await pool.connect();
|
|
287
|
+
await setupListener(listener);
|
|
288
|
+
log.info("listener reconnected");
|
|
289
|
+
await keepHolds();
|
|
290
|
+
// Catch up: ops committed while we were disconnected were NOTIFY'd to
|
|
291
|
+
// nobody, so re-drain every tracked doc from its last seen version.
|
|
292
|
+
await resyncTracked();
|
|
293
|
+
return;
|
|
294
|
+
} catch (err) {
|
|
295
|
+
log.error(`reconnect attempt ${attempt} failed: ${errMsg(err)}`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
} finally {
|
|
299
|
+
reconnecting = false;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Re-drain all tracked docs (used after a reconnect to recover missed ops).
|
|
304
|
+
async function resyncTracked() {
|
|
305
|
+
for (const [docName, state] of tracked) {
|
|
306
|
+
state.until = Number.MAX_SAFE_INTEGER; // the notifications while away are gone: read to the end
|
|
307
|
+
if (state.notifying) { state.pending = true; continue; }
|
|
308
|
+
state.notifying = true;
|
|
309
|
+
try { await drainDoc(docName, state); }
|
|
310
|
+
catch (err) { log.error(`resync ${docName}: ${errMsg(err)}`); }
|
|
311
|
+
finally { state.notifying = false; }
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
try {
|
|
316
|
+
await setupListener(listener);
|
|
317
|
+
} catch (err) {
|
|
318
|
+
detachListener(listener);
|
|
319
|
+
listener.release();
|
|
320
|
+
throw err;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Notification handler — fans out LISTEN/NOTIFY to WS subscribers, plus
|
|
324
|
+
// custom-doc cross-pollination when any predicate-based doc is registered.
|
|
325
|
+
async function onNotification(msg: any) {
|
|
326
|
+
if (destroyed) return;
|
|
327
|
+
if (msg.channel !== "delta_changes" || !msg.payload) return;
|
|
328
|
+
try {
|
|
329
|
+
const { doc: docName, v } = JSON.parse(msg.payload);
|
|
330
|
+
let state = tracked.get(docName);
|
|
331
|
+
|
|
332
|
+
// Even without direct subscribers, we still process notifications if any
|
|
333
|
+
// custom doc is registered — a watched collection's rows may need fan-out.
|
|
334
|
+
if (!state && custom.size === 0) return;
|
|
335
|
+
if (!state) {
|
|
336
|
+
// Initialize at v-1 so we fetch only the current notification's row.
|
|
337
|
+
// Custom docs loaded their initial state via `def.query` on open, so
|
|
338
|
+
// pre-existing ops are already reflected — replaying history would
|
|
339
|
+
// duplicate them. Same logic protects close-then-reopen.
|
|
340
|
+
state = { version: Math.max(0, v - 1), until: v, subscribers: new Set(), notifying: false, pending: false };
|
|
341
|
+
tracked.set(docName, state);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (v <= state.version) return;
|
|
345
|
+
state.until = Math.max(state.until, v);
|
|
346
|
+
if (state.notifying) {
|
|
347
|
+
// A fetch is already running for this doc. Coalesce instead of dropping:
|
|
348
|
+
// dropping here lost ops when a concurrent write's NOTIFY landed mid-fetch.
|
|
349
|
+
state.pending = true;
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
state.notifying = true;
|
|
354
|
+
try {
|
|
355
|
+
await drainDoc(docName, state);
|
|
356
|
+
} finally {
|
|
357
|
+
state.notifying = false;
|
|
358
|
+
}
|
|
359
|
+
} catch (err) {
|
|
360
|
+
log.error(`notify error: ${errMsg(err)}`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Drain a doc's ops to its subscribers. Loops while a full page comes back
|
|
365
|
+
// (a backlog larger than the fetch's LIMIT) and re-runs if another
|
|
366
|
+
// notification arrived mid-drain (the coalesced `pending` flag). Caller owns
|
|
367
|
+
// the `notifying` guard.
|
|
368
|
+
async function drainDoc(docName: string, state: DocState) {
|
|
369
|
+
do {
|
|
370
|
+
state.pending = false;
|
|
371
|
+
let more = true;
|
|
372
|
+
while (more && state.version < state.until) {
|
|
373
|
+
const page = (await pool.query(fetchSql, [docName, state.version])).rows;
|
|
374
|
+
// only what a notification has announced (`until`): the rest is heard with its own
|
|
375
|
+
const rows = page.filter((row: any) => Number(row.version) <= state.until);
|
|
376
|
+
more = page.length >= FETCH_PAGE && rows.length === page.length;
|
|
377
|
+
// Told to those who may still hear it (todo #17): the document's own
|
|
378
|
+
// subscribers, and the custom documents' that watch what it changed.
|
|
379
|
+
if (auth && rows.length > 0) {
|
|
380
|
+
await custom.letGo(docName, state.subscribers, ownsOf(docName));
|
|
381
|
+
await custom.letGoWatching(rows.flatMap((row: any) => (row.applied ?? row.ops) as DeltaOp[]));
|
|
382
|
+
}
|
|
383
|
+
for (const row of rows) {
|
|
384
|
+
if (state.subscribers.size > 0) {
|
|
385
|
+
// `v` lets the client validate the per-doc sequence and re-open on
|
|
386
|
+
// a gap. delta_fetch_ops rows are contiguous and ascending per doc.
|
|
387
|
+
// Number(): pg returns a top-level BIGINT column as a string, but
|
|
388
|
+
// the open snapshot's `_v` (from JSONB) is a number — the client
|
|
389
|
+
// compares them strictly, so both must be numbers.
|
|
390
|
+
ws.publish(docName, { doc: docName, ops: shownOps(resolveDoc(docName)?.type.def, row.ops), v: Number(row.version) });
|
|
391
|
+
}
|
|
392
|
+
state.version = Number(row.version);
|
|
393
|
+
// A custom document hears each write once, as it was applied: the
|
|
394
|
+
// writer's entry carries it (`applied`) where the writer was told
|
|
395
|
+
// otherwise, and null where it was told the write as applied; each
|
|
396
|
+
// other told document's entry of the write carries none ([]). Told,
|
|
397
|
+
// a row that left a document is a remove, which is not a delete, and
|
|
398
|
+
// a row told to two documents would be heard twice (todo #44). An
|
|
399
|
+
// entry logged some other way carries null too: heard as told.
|
|
400
|
+
custom.tell((row.applied ?? row.ops) as DeltaOp[]);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
} while (state.pending);
|
|
404
|
+
if (state.until > state.version) state.until = state.version; // after a resync: from here, as announced
|
|
405
|
+
|
|
406
|
+
pruneDoc(docName);
|
|
407
|
+
log.debug(`notify ${docName} v${state.version}`);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function pruneDoc(docName: string) {
|
|
411
|
+
const state = tracked.get(docName);
|
|
412
|
+
if (!state) return;
|
|
413
|
+
for (const sub of state.subscribers) {
|
|
414
|
+
if (sub.readyState !== undefined && sub.readyState !== 1) {
|
|
415
|
+
state.subscribers.delete(sub);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
if (state.subscribers.size === 0) { tracked.delete(docName); release(docName); }
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function hold(docName: string): Promise<unknown> {
|
|
422
|
+
let held = holds.get(docName);
|
|
423
|
+
if (!held) {
|
|
424
|
+
held = inOrder(docName, "SELECT _delta_hold($1, $2)");
|
|
425
|
+
held.catch(() => { if (holds.get(docName) === held) holds.delete(docName); });
|
|
426
|
+
holds.set(docName, held);
|
|
427
|
+
}
|
|
428
|
+
return held;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** Let go of a name nobody here has open or is opening. */
|
|
432
|
+
function release(docName: string): void {
|
|
433
|
+
if (tracked.get(docName)?.subscribers.size || opening.has(docName) || !holds.delete(docName)) return;
|
|
434
|
+
inOrder(docName, "SELECT _delta_let_go($1, $2)")
|
|
435
|
+
.catch((err) => log.error(`let go of ${docName}: ${errMsg(err)}`));
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Back from a lost connection: a name cleared while the listener was away
|
|
440
|
+
* (another took it for gone) may have missed a write. Each socket is let go
|
|
441
|
+
* of such a document, and opens it again; an open reading it reads again.
|
|
442
|
+
*/
|
|
443
|
+
async function keepHolds(): Promise<void> {
|
|
444
|
+
await Promise.allSettled(holds.values());
|
|
445
|
+
const { rows } = await pool.query("SELECT doc_name FROM _delta_held WHERE holder = $1", [holder]);
|
|
446
|
+
const still = new Set(rows.map((r: any) => r.doc_name as string));
|
|
447
|
+
for (const docName of [...holds.keys()]) {
|
|
448
|
+
if (still.has(docName)) continue;
|
|
449
|
+
holds.delete(docName);
|
|
450
|
+
for (const client of tracked.get(docName)?.subscribers ?? []) {
|
|
451
|
+
try { trackUnsubscribe(client, docName); } catch { /* the socket may be closing */ }
|
|
452
|
+
sendTo(client, { doc: docName, error: { code: 500, message: "the server lost the database for a while: open it again" } });
|
|
453
|
+
}
|
|
454
|
+
tracked.delete(docName);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Transport-level teardown (socket drop / logout via dropClientSubscriptions):
|
|
459
|
+
// pruneDoc only runs when a doc drains, so a doc whose subscribers all
|
|
460
|
+
// vanished without further writes stayed tracked, and custom-doc caches
|
|
461
|
+
// (which pruneDoc never touches) lived until an explicit `close` action
|
|
462
|
+
// that a dropped socket never sends. Mirrors the sqlite backend's hook.
|
|
463
|
+
function releaseClient(client: any): void {
|
|
464
|
+
for (const [docName, state] of tracked) {
|
|
465
|
+
if (!state.subscribers.delete(client)) continue;
|
|
466
|
+
if (state.subscribers.size === 0) { tracked.delete(docName); release(docName); }
|
|
467
|
+
}
|
|
468
|
+
custom.leave(client);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// ---------------------------------------------------------------------------
|
|
472
|
+
// withDoc — authenticate (if configured), validate doc name, resolve handler,
|
|
473
|
+
// wrap errors. Each WS branch supplies only its own body.
|
|
474
|
+
// ---------------------------------------------------------------------------
|
|
475
|
+
|
|
476
|
+
type DocCtx = {
|
|
477
|
+
docName: string;
|
|
478
|
+
type: DocType<any, I>;
|
|
479
|
+
ctx: any;
|
|
480
|
+
msg: any;
|
|
481
|
+
client: any;
|
|
482
|
+
respond: (r: any) => void;
|
|
483
|
+
identity: I | undefined;
|
|
484
|
+
};
|
|
485
|
+
|
|
486
|
+
// `owned: false` for close alone: letting go of a document never needs the owner's say.
|
|
487
|
+
function withDoc(label: string, fn: (dc: DocCtx) => Promise<void> | void, { owned = true } = {}) {
|
|
488
|
+
return async (msg: any, client: any, respond: (r: any) => void) => {
|
|
489
|
+
const g = gate(client);
|
|
490
|
+
if ("error" in g) return respond(g);
|
|
491
|
+
const { identity } = g;
|
|
492
|
+
const docName = msg.doc as string;
|
|
493
|
+
if (!docName) {
|
|
494
|
+
return respond({ error: { code: 400, message: "doc is required" } });
|
|
495
|
+
}
|
|
496
|
+
const found = resolveDoc(docName);
|
|
497
|
+
if (!found) {
|
|
498
|
+
return respond({ error: { code: 404, message: `No handler for ${docName}` } });
|
|
499
|
+
}
|
|
500
|
+
try {
|
|
501
|
+
// The document's name is its channel: one this identity does not own is
|
|
502
|
+
// not there for it (404, as a missing one), so it never subscribes.
|
|
503
|
+
if (owned && auth && found.type.owns && !(await found.type.owns(identity as I, docName))) {
|
|
504
|
+
return respond({ error: NOT_FOUND });
|
|
505
|
+
}
|
|
506
|
+
await fn({ docName, type: found.type, ctx: found.ctx, msg, client, respond, identity });
|
|
507
|
+
} catch (err) {
|
|
508
|
+
const m = errMsg(err);
|
|
509
|
+
log.error(`${label} failed: ${m}`);
|
|
510
|
+
respond({ error: { code: wireCode(err), message: m } });
|
|
511
|
+
}
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// ---------------------------------------------------------------------------
|
|
516
|
+
// WS branches — custom docs first (short-circuit before standard withDoc)
|
|
517
|
+
// ---------------------------------------------------------------------------
|
|
518
|
+
|
|
519
|
+
if (custom.size > 0) {
|
|
520
|
+
ws.on("open", async (msg: any, client: any, respond: (r: any) => void) => {
|
|
521
|
+
const docName = msg.doc as string;
|
|
522
|
+
if (!docName) return;
|
|
523
|
+
const match = custom.find(docName);
|
|
524
|
+
if (!match) return; // fall through to standard
|
|
525
|
+
|
|
526
|
+
// Gate custom docs exactly like standard docs (withDoc). Without this the
|
|
527
|
+
// custom open handler — which registers before and short-circuits the
|
|
528
|
+
// standard gated handler — let an unauthenticated client read scoped data.
|
|
529
|
+
const g = gate(client);
|
|
530
|
+
if ("error" in g) return respond(g);
|
|
531
|
+
const { identity } = g;
|
|
532
|
+
|
|
533
|
+
try {
|
|
534
|
+
// As withDoc asks a type's owns: a name this identity does not own is not there for it.
|
|
535
|
+
if (auth && match.def.owns && !(await match.def.owns(identity as I, docName))) return respond({ error: NOT_FOUND });
|
|
536
|
+
const doc = await custom.open(client, docName, match, identity);
|
|
537
|
+
if (doc == null) return respond({ error: NOT_FOUND });
|
|
538
|
+
trackSubscribe(client, docName);
|
|
539
|
+
onClientDrop(client, releaseClient);
|
|
540
|
+
respond({ result: doc });
|
|
541
|
+
log.info(`opened ${docName} (custom${match.def.recompute ? ", recompute" : ""})`);
|
|
542
|
+
} catch (err) {
|
|
543
|
+
log.error(`open custom ${docName} failed: ${errMsg(err)}`);
|
|
544
|
+
respond({ error: { code: 500, message: errMsg(err) } });
|
|
545
|
+
}
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
ws.on("delta", (msg: any, _client: any, respond: (r: any) => void) => {
|
|
549
|
+
const docName = msg.doc as string;
|
|
550
|
+
if (!docName) return;
|
|
551
|
+
if (!custom.find(docName)) return; // fall through to standard
|
|
552
|
+
respond({ error: { code: 403, message: "Custom docs are read-only; write through the source doc." } });
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
ws.on("close", (msg: any, client: any, respond: (r: any) => void) => {
|
|
556
|
+
const docName = msg.doc as string;
|
|
557
|
+
if (!docName) return;
|
|
558
|
+
if (!custom.find(docName)) return; // fall through to standard
|
|
559
|
+
trackUnsubscribe(client, docName);
|
|
560
|
+
custom.leave(client, docName);
|
|
561
|
+
respond({ result: { ack: true } });
|
|
562
|
+
log.debug(`closed ${docName} (custom)`);
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
ws.on("open", withDoc("open", async ({ docName, type, ctx, msg, client, respond, identity }) => {
|
|
567
|
+
opening.set(docName, (opening.get(docName) ?? 0) + 1);
|
|
568
|
+
try {
|
|
569
|
+
// held before the read: a write from now is told to this name; one lost
|
|
570
|
+
// while it read (keepHolds) is held and read again
|
|
571
|
+
let result;
|
|
572
|
+
for (let held = hold(docName); ; held = hold(docName)) {
|
|
573
|
+
await held;
|
|
574
|
+
result = await type.open(ctx, docName, msg, identity);
|
|
575
|
+
if (holds.get(docName) === held) break;
|
|
576
|
+
}
|
|
577
|
+
if (!result) return respond({ error: NOT_FOUND });
|
|
578
|
+
|
|
579
|
+
pruneDoc(docName);
|
|
580
|
+
let state = tracked.get(docName);
|
|
581
|
+
if (!state) {
|
|
582
|
+
state = { version: Number(result.version), until: Number(result.version), subscribers: new Set(), notifying: false, pending: false };
|
|
583
|
+
tracked.set(docName, state);
|
|
584
|
+
} else {
|
|
585
|
+
state.version = Math.max(state.version, Number(result.version));
|
|
586
|
+
state.until = Math.max(state.until, state.version);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
state.subscribers.add(client);
|
|
590
|
+
trackSubscribe(client, docName);
|
|
591
|
+
onClientDrop(client, releaseClient);
|
|
592
|
+
respond({ result: shownDoc(type, docName, result.result) });
|
|
593
|
+
log.info(`opened ${docName} v${state.version}`);
|
|
594
|
+
} finally {
|
|
595
|
+
const left = opening.get(docName)! - 1;
|
|
596
|
+
if (left > 0) opening.set(docName, left); else opening.delete(docName);
|
|
597
|
+
release(docName);
|
|
598
|
+
}
|
|
599
|
+
}));
|
|
600
|
+
|
|
601
|
+
ws.on("delta", withDoc("delta", async ({ docName, type, ctx, msg, client, respond, identity }) => {
|
|
602
|
+
const writer = writerOf(identity, client);
|
|
603
|
+
const by = ledger ? { who: whoOf(writer), cursor: cursorOf(msg, client, writer), undoable: msg.undoable !== false } : undefined;
|
|
604
|
+
const result = shownAnswer(type.def, await type.apply(ctx, docName, msg.ops, identity, by, declared));
|
|
605
|
+
respond({ result: ledger ? { ack: true, version: Number(result.version), ops: result.ops, inverse: result.inverse, entry: result.entry == null ? undefined : Number(result.entry) } : { ack: true, version: Number(result.version), ...(msg.inverse ? { ops: result.ops } : {}) } });
|
|
606
|
+
log.info(`delta ${docName} v${result.version}`);
|
|
607
|
+
}));
|
|
608
|
+
|
|
609
|
+
if (ledger) {
|
|
610
|
+
/**
|
|
611
|
+
* Undo or redo: the cursor's next entry, walked in the database (001g
|
|
612
|
+
* `delta_walk`) by its guarded plan and recorded as walking it; a conflict
|
|
613
|
+
* changes nothing, answers `conflict`, and moves the cursor on. `dry: true`
|
|
614
|
+
* answers the plan; `entry: id` walks only that entry. `change: id` walks
|
|
615
|
+
* one change of the cursor's instead (`_delta_change_tip`), and a conflict
|
|
616
|
+
* then records nothing.
|
|
617
|
+
*/
|
|
618
|
+
const walk = (way: "undo" | "redo") => async (msg: any, client: any, respond: (r: any) => void) => {
|
|
619
|
+
const change = msg.change ?? null;
|
|
620
|
+
if (change !== null && !isEntryId(change)) return respond({ error: { code: 400, message: "change must be an entry's id: a whole number from 1" } });
|
|
621
|
+
const g = gate(client);
|
|
622
|
+
if ("error" in g) return respond(g);
|
|
623
|
+
const { identity } = g;
|
|
624
|
+
const db = await pool.connect();
|
|
625
|
+
try {
|
|
626
|
+
const writer = writerOf(identity, client);
|
|
627
|
+
const cursor = cursorOf(msg, client, writer);
|
|
628
|
+
await db.query("BEGIN");
|
|
629
|
+
// A walk writes to its entry's document, so it asks owns as delta does:
|
|
630
|
+
// under the cursor's lock (delta_walk takes it again), so the entry
|
|
631
|
+
// asked about is the one walked.
|
|
632
|
+
if (auth && cursor !== null) {
|
|
633
|
+
await db.query("SELECT pg_advisory_xact_lock(hashtext('delta-cursor:' || $1::text))", [cursor]);
|
|
634
|
+
const tip = (change === null
|
|
635
|
+
? await db.query("SELECT doc_name FROM _delta_ledger_tip($1, $2)", [cursor, way === "undo"])
|
|
636
|
+
: await db.query("SELECT doc_name FROM _delta_change_tip($1, $2, $3)", [cursor, change, way === "undo"])).rows[0]?.doc_name;
|
|
637
|
+
const found = tip ? resolveDoc(tip) : null;
|
|
638
|
+
if (found?.type.owns && !(await found.type.owns(identity as I, tip))) {
|
|
639
|
+
await db.query("ROLLBACK");
|
|
640
|
+
return respond({ error: NOT_FOUND });
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
// a change named takes 001g's newer forms; none, the ones a database on 0.10.0's SQL has too
|
|
644
|
+
const args = [cursor, whoOf(writer), way === "undo", msg.dry === true, msg.entry ?? null, ...(change === null ? [] : [change])];
|
|
645
|
+
const walked = (await callAs(db, "delta_walk", args, auth, identity, declared)) ?? null;
|
|
646
|
+
const def = walked?.doc ? resolveDoc(walked.doc)?.type.def : undefined;
|
|
647
|
+
const result = walked?.doc ? shownAnswer(def, await showAnswer(db, walked.doc, walked, def)) : walked; // as its document shows them
|
|
648
|
+
await db.query("COMMIT");
|
|
649
|
+
respond({ result: result && { ...result, ...(result.version != null ? { version: Number(result.version) } : {}), entry: result.entry == null ? undefined : Number(result.entry) } });
|
|
650
|
+
} catch (err) {
|
|
651
|
+
try { await db.query("ROLLBACK"); } catch { /* the connection may be gone */ }
|
|
652
|
+
log.error(`${way} failed: ${errMsg(err)}`);
|
|
653
|
+
respond({ error: { code: wireCode(err), message: errMsg(err) } });
|
|
654
|
+
} finally {
|
|
655
|
+
db.release();
|
|
656
|
+
}
|
|
657
|
+
};
|
|
658
|
+
ws.on("undo", walk("undo"));
|
|
659
|
+
ws.on("redo", walk("redo"));
|
|
660
|
+
// A document's recent history, to whoever may open it: each entry says `mine`, never who wrote it.
|
|
661
|
+
ws.on("history", withDoc("history", async ({ docName, type, ctx, msg, client, respond, identity }) => {
|
|
662
|
+
if (!(await type.open(ctx, docName, msg, identity))) return respond({ error: NOT_FOUND });
|
|
663
|
+
const { rows } = await pool.query("SELECT delta_history($1, $2, $3) AS entries", [docName, cursorOf(msg, client, writerOf(identity, client)), msg.limit ?? 50]);
|
|
664
|
+
respond({ result: await Promise.all((rows[0]?.entries ?? []).map(async (e: any) => shownAnswer(type.def, await showAnswer(pool, docName, e, type.def)))) });
|
|
665
|
+
}));
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
ws.on("open_at", withDoc("open_at", async ({ docName, type, ctx, msg, respond, identity }) => {
|
|
669
|
+
const at = msg.at as string;
|
|
670
|
+
if (!at) return respond({ error: { code: 400, message: "at is required" } });
|
|
671
|
+
if (!type.openAt) {
|
|
672
|
+
return respond({ error: { code: 400, message: `open_at not supported for ${docName}` } });
|
|
673
|
+
}
|
|
674
|
+
const doc = await type.openAt(ctx, docName, at, identity);
|
|
675
|
+
if (!doc) return respond({ error: NOT_FOUND });
|
|
676
|
+
respond({ result: shownDoc(type, docName, doc) });
|
|
677
|
+
log.info(`open_at ${docName} @ ${at}`);
|
|
678
|
+
}));
|
|
679
|
+
|
|
680
|
+
ws.on("close", withDoc("close", ({ docName, client, respond }) => {
|
|
681
|
+
trackUnsubscribe(client, docName);
|
|
682
|
+
const state = tracked.get(docName);
|
|
683
|
+
if (state) {
|
|
684
|
+
state.subscribers.delete(client);
|
|
685
|
+
if (state.subscribers.size === 0) { tracked.delete(docName); release(docName); }
|
|
686
|
+
}
|
|
687
|
+
respond({ result: { ack: true } });
|
|
688
|
+
log.debug(`closed ${docName}`);
|
|
689
|
+
}, { owned: false }));
|
|
690
|
+
|
|
691
|
+
return {
|
|
692
|
+
/** Answer for one more type; one of a prefix it answers for already is replaced (`bun --hot`). */
|
|
693
|
+
register: registry.register as (type: DocType<any, I>) => void,
|
|
694
|
+
async destroy() {
|
|
695
|
+
destroyed = true;
|
|
696
|
+
try {
|
|
697
|
+
await listener.query("UNLISTEN *");
|
|
698
|
+
await listener.query("SELECT _delta_holder_stop($1)", [holder]);
|
|
699
|
+
} catch {}
|
|
700
|
+
// Released connections are recycled by the pool; stale listeners would
|
|
701
|
+
// fire against this closure's state on future pooled connections.
|
|
702
|
+
detachListener(listener);
|
|
703
|
+
try { listener.release(); } catch {}
|
|
704
|
+
},
|
|
705
|
+
};
|
|
706
|
+
}
|