@haikit/postgres 0.12.0 → 0.13.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 +35 -1
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +127 -1
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +151 -1
package/README.md
CHANGED
|
@@ -40,7 +40,7 @@ exactly one to win.
|
|
|
40
40
|
|
|
41
41
|
## Schema
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
Three tables, `haikit_conversations`, `haikit_payloads` and `haikit_notices`. `migrate()` creates
|
|
44
44
|
them if they do not exist and brings existing ones up to date; if you use your
|
|
45
45
|
own migration tool, the statements are exported as `schema`.
|
|
46
46
|
|
|
@@ -52,9 +52,43 @@ With your own migration tool, apply the new `ALTER TABLE` from `schema` before
|
|
|
52
52
|
deploying. Rows written before the column existed are held to the window your
|
|
53
53
|
code declares.
|
|
54
54
|
|
|
55
|
+
**Upgrading to 0.12:** `migrate()` adds `haikit_notices`, and `notice_seq` and
|
|
56
|
+
`noticed_through` to `haikit_conversations`, for `hai.notify`. As before, if
|
|
57
|
+
you call `migrate()` at startup there is nothing to do.
|
|
58
|
+
|
|
55
59
|
Handles are numbered per conversation, so every conversation's digests start at
|
|
56
60
|
`ui_01`.
|
|
57
61
|
|
|
62
|
+
## Notices
|
|
63
|
+
|
|
64
|
+
A notice is one `INSERT … SELECT`, numbered under the conversation's row lock,
|
|
65
|
+
so two notices in one conversation always commit in the order they were
|
|
66
|
+
numbered and a reader resuming after one can never miss an earlier. It takes no
|
|
67
|
+
lease token: a notice lands whoever holds the turn. The same statement sends a
|
|
68
|
+
`NOTIFY` on `haikit_notices` with the conversation id.
|
|
69
|
+
|
|
70
|
+
Give the store a connection that can `LISTEN`, and the events route hears of a
|
|
71
|
+
notice the moment it lands, rather than at its next two-second read. It needs a
|
|
72
|
+
connection of its own: a pooled one goes back to the pool and stops listening.
|
|
73
|
+
PGlite fits as it is (`pgStore(db, { listen: db })`). With `pg`:
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
const listener = new pg.Client({ connectionString: process.env.DATABASE_URL });
|
|
77
|
+
await listener.connect();
|
|
78
|
+
|
|
79
|
+
const store = pgStore(pool, {
|
|
80
|
+
listen: {
|
|
81
|
+
async listen(channel, onNotify) {
|
|
82
|
+
listener.on("notification", (msg) => msg.channel === channel && onNotify(msg.payload ?? ""));
|
|
83
|
+
await listener.query(`LISTEN ${channel}`);
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
A notice belongs to its conversation and goes when it does: deleting a
|
|
90
|
+
conversation deletes its notices. `sweepOrphans` never touches them.
|
|
91
|
+
|
|
58
92
|
## Cleaning up
|
|
59
93
|
|
|
60
94
|
A turn that is overtaken mid-flight can leave payload rows that the surviving
|
package/dist/index.d.ts
CHANGED
|
@@ -32,7 +32,23 @@ export interface PgStoreOptions {
|
|
|
32
32
|
* which is safe (its writes are fenced out) but wasteful.
|
|
33
33
|
*/
|
|
34
34
|
leaseMs?: number;
|
|
35
|
+
/**
|
|
36
|
+
* A connection that can `LISTEN`, so the events route hears of a notice the
|
|
37
|
+
* moment it lands, rather than at its next read a couple of seconds on. It needs a
|
|
38
|
+
* connection of its own: a pooled one goes back to the pool and stops
|
|
39
|
+
* listening. PGlite's `listen` fits as it is; see the README for `pg`.
|
|
40
|
+
*/
|
|
41
|
+
listen?: Listener;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Something that can `LISTEN` on a channel and report each `NOTIFY` payload.
|
|
45
|
+
* Resolves once listening, to a function that stops.
|
|
46
|
+
*/
|
|
47
|
+
export interface Listener {
|
|
48
|
+
listen(channel: string, onNotify: (payload: string) => void): Promise<unknown>;
|
|
35
49
|
}
|
|
50
|
+
/** The channel `putNotice` notifies on, with the conversation id as payload. */
|
|
51
|
+
export declare const NOTICE_CHANNEL = "haikit_notices";
|
|
36
52
|
/**
|
|
37
53
|
* The schema, one statement per entry. `migrate()` runs these; export them to
|
|
38
54
|
* your own migration tool instead if you have one.
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,EAML,KAAK,YAAY,EAClB,MAAM,cAAc,CAAC;AAEtB;;;;;;;;GAQG;AACH,MAAM,WAAW,SAAS;IACxB,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,EAAE,GAAG,OAAO,CAAC;QAAE,IAAI,EAAE,GAAG,EAAE,CAAA;KAAE,CAAC,CAAC;CACnE;AAED,MAAM,WAAW,cAAc;IAC7B;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;;OAKG;IACH,MAAM,CAAC,EAAE,QAAQ,CAAC;CACnB;AAED;;;GAGG;AACH,MAAM,WAAW,QAAQ;IACvB,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CAChF;AAID,gFAAgF;AAChF,eAAO,MAAM,cAAc,mBAAmB,CAAC;AAE/C;;;GAGG;AACH,eAAO,MAAM,MAAM,EAAE,SAAS,MAAM,EAsDnC,CAAC;AAEF,8FAA8F;AAC9F,wBAAsB,OAAO,CAAC,EAAE,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CAI1D;AA8DD,wBAAgB,OAAO,CAAC,EAAE,EAAE,SAAS,EAAE,OAAO,GAAE,cAAmB,GAAG,YAAY,CAkPjF;AAED,MAAM,WAAW,YAAY;IAC3B;;;;OAIG;IACH,WAAW,EAAE,MAAM,CAAC;CACrB;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,YAAY,CAAC,EAAE,EAAE,SAAS,EAAE,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC;IAAE,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC,CAYrG"}
|
package/dist/index.js
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { ConversationBusy, StaleLease, } from "@haikit/core";
|
|
14
14
|
const LEASE_MS = 120_000;
|
|
15
|
+
/** The channel `putNotice` notifies on, with the conversation id as payload. */
|
|
16
|
+
export const NOTICE_CHANNEL = "haikit_notices";
|
|
15
17
|
/**
|
|
16
18
|
* The schema, one statement per entry. `migrate()` runs these; export them to
|
|
17
19
|
* your own migration tool instead if you have one.
|
|
@@ -27,7 +29,26 @@ export const schema = [
|
|
|
27
29
|
lease_until timestamptz,
|
|
28
30
|
lease_token text,
|
|
29
31
|
handle_seq integer NOT NULL DEFAULT 0,
|
|
32
|
+
-- the last notice number given out, and the last one its history took in
|
|
33
|
+
notice_seq integer NOT NULL DEFAULT 0,
|
|
34
|
+
noticed_through integer NOT NULL DEFAULT 0,
|
|
30
35
|
updated_at timestamptz NOT NULL DEFAULT now()
|
|
36
|
+
)`,
|
|
37
|
+
// a table created before notices existed gains the columns
|
|
38
|
+
`ALTER TABLE haikit_conversations ADD COLUMN IF NOT EXISTS notice_seq integer NOT NULL DEFAULT 0`,
|
|
39
|
+
`ALTER TABLE haikit_conversations ADD COLUMN IF NOT EXISTS noticed_through integer NOT NULL DEFAULT 0`,
|
|
40
|
+
// Append-only, and never an orphan: a notice belongs to a conversation that
|
|
41
|
+
// exists, and goes when it does.
|
|
42
|
+
`CREATE TABLE IF NOT EXISTS haikit_notices (
|
|
43
|
+
conversation_id text NOT NULL REFERENCES haikit_conversations(id) ON DELETE CASCADE,
|
|
44
|
+
seq integer NOT NULL,
|
|
45
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
46
|
+
name text NOT NULL,
|
|
47
|
+
version integer NOT NULL,
|
|
48
|
+
payload jsonb NOT NULL,
|
|
49
|
+
model text,
|
|
50
|
+
handle text,
|
|
51
|
+
PRIMARY KEY (conversation_id, seq)
|
|
31
52
|
)`,
|
|
32
53
|
// Write-once. Nothing about a payload changes after insert — everything that
|
|
33
54
|
// does lives on the fenced conversation row (see Conversation.frozen).
|
|
@@ -65,8 +86,11 @@ export async function migrate(db) {
|
|
|
65
86
|
// a second time throws. Taking text every time removes the guess.
|
|
66
87
|
const CONVERSATION_COLUMNS = `
|
|
67
88
|
id, status, messages::text AS messages, handles::text AS handles,
|
|
68
|
-
frozen::text AS frozen, pending::text AS pending, lease_token,
|
|
89
|
+
frozen::text AS frozen, pending::text AS pending, lease_token, noticed_through,
|
|
69
90
|
(extract(epoch FROM lease_until) * 1000)::float8 AS lease_until_ms`;
|
|
91
|
+
const NOTICE_COLUMNS = `
|
|
92
|
+
conversation_id, seq, name, version, payload::text AS payload, model, handle,
|
|
93
|
+
(extract(epoch FROM created_at) * 1000)::float8 AS created_at_ms`;
|
|
70
94
|
// The window is read as text too: 'Infinity' is how "never" is stored, and
|
|
71
95
|
// drivers need not agree on turning it into a number.
|
|
72
96
|
const PAYLOAD_COLUMNS = `
|
|
@@ -83,6 +107,17 @@ const toConversation = (row) => ({
|
|
|
83
107
|
pending: row.pending == null ? null : json(row.pending),
|
|
84
108
|
leaseUntil: row.lease_until_ms == null ? null : Number(row.lease_until_ms),
|
|
85
109
|
leaseToken: row.lease_token,
|
|
110
|
+
noticedThrough: Number(row.noticed_through ?? 0),
|
|
111
|
+
});
|
|
112
|
+
const toNotice = (row) => ({
|
|
113
|
+
conversationId: row.conversation_id,
|
|
114
|
+
seq: Number(row.seq),
|
|
115
|
+
createdAt: Number(row.created_at_ms),
|
|
116
|
+
name: row.name,
|
|
117
|
+
version: Number(row.version),
|
|
118
|
+
payload: json(row.payload),
|
|
119
|
+
model: row.model,
|
|
120
|
+
...(row.handle == null ? {} : { handle: row.handle }),
|
|
86
121
|
});
|
|
87
122
|
const toPayload = (row) => ({
|
|
88
123
|
handle: row.handle,
|
|
@@ -105,6 +140,56 @@ export function pgStore(db, options = {}) {
|
|
|
105
140
|
if (!Number.isFinite(leaseMs) || leaseMs <= 0) {
|
|
106
141
|
throw new RangeError(`leaseMs must be a positive, finite number of milliseconds (got ${leaseMs})`);
|
|
107
142
|
}
|
|
143
|
+
// One LISTEN for the whole store, taken when something first watches, and
|
|
144
|
+
// each notification handed to whoever is watching that conversation.
|
|
145
|
+
const waiting = new Map();
|
|
146
|
+
let listening = null;
|
|
147
|
+
const listen = (listener) => (listening ??= listener
|
|
148
|
+
.listen(NOTICE_CHANNEL, (id) => {
|
|
149
|
+
for (const wake of waiting.get(id) ?? [])
|
|
150
|
+
wake();
|
|
151
|
+
})
|
|
152
|
+
.catch((err) => {
|
|
153
|
+
// try again on the next watch, rather than never
|
|
154
|
+
listening = null;
|
|
155
|
+
throw err;
|
|
156
|
+
}));
|
|
157
|
+
async function* watch(conversationId, signal) {
|
|
158
|
+
// One wake-up stands for any number of notices: a step resolves once
|
|
159
|
+
// something has landed since the last, and the caller reads them all.
|
|
160
|
+
let landed = false;
|
|
161
|
+
let wake = null;
|
|
162
|
+
const listener = () => {
|
|
163
|
+
landed = true;
|
|
164
|
+
wake?.();
|
|
165
|
+
};
|
|
166
|
+
const onAbort = () => wake?.();
|
|
167
|
+
const set = waiting.get(conversationId) ?? new Set();
|
|
168
|
+
set.add(listener);
|
|
169
|
+
waiting.set(conversationId, set);
|
|
170
|
+
signal.addEventListener("abort", onAbort);
|
|
171
|
+
try {
|
|
172
|
+
await listen(options.listen);
|
|
173
|
+
// A notice that landed before LISTEN took hold sent its NOTIFY to no
|
|
174
|
+
// one, so the first step is free: the caller reads once now.
|
|
175
|
+
landed = true;
|
|
176
|
+
while (!signal.aborted) {
|
|
177
|
+
if (!landed)
|
|
178
|
+
await new Promise((r) => (wake = r));
|
|
179
|
+
wake = null;
|
|
180
|
+
if (signal.aborted)
|
|
181
|
+
return;
|
|
182
|
+
landed = false;
|
|
183
|
+
yield;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
finally {
|
|
187
|
+
signal.removeEventListener("abort", onAbort);
|
|
188
|
+
set.delete(listener);
|
|
189
|
+
if (!set.size)
|
|
190
|
+
waiting.delete(conversationId);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
108
193
|
/** A fenced write matched nothing. Say which of the two reasons applies. */
|
|
109
194
|
async function stale(conversationId) {
|
|
110
195
|
const { rows } = await db.query(`SELECT 1 FROM haikit_conversations WHERE id = $1`, [conversationId]);
|
|
@@ -150,6 +235,7 @@ export function pgStore(db, options = {}) {
|
|
|
150
235
|
frozen = $6::jsonb,
|
|
151
236
|
pending = $7::jsonb,
|
|
152
237
|
lease_until = to_timestamp($8::float8 / 1000),
|
|
238
|
+
noticed_through = $9::integer,
|
|
153
239
|
updated_at = now()
|
|
154
240
|
WHERE id = $1 AND lease_token = $2
|
|
155
241
|
RETURNING id`, [
|
|
@@ -162,6 +248,7 @@ export function pgStore(db, options = {}) {
|
|
|
162
248
|
// SQL NULL, not the JSON value null — keep "no pending turn" queryable
|
|
163
249
|
conversation.pending == null ? null : JSON.stringify(conversation.pending),
|
|
164
250
|
conversation.leaseUntil,
|
|
251
|
+
conversation.noticedThrough ?? 0,
|
|
165
252
|
]);
|
|
166
253
|
if (!rows.length)
|
|
167
254
|
await stale(conversation.id);
|
|
@@ -220,6 +307,45 @@ export function pgStore(db, options = {}) {
|
|
|
220
307
|
return record ? [structuredClone(record)] : [];
|
|
221
308
|
});
|
|
222
309
|
},
|
|
310
|
+
async putNotice(record) {
|
|
311
|
+
// Number, insert and notify in one statement. The UPDATE takes the next
|
|
312
|
+
// number under the conversation's row lock, so two notices in one
|
|
313
|
+
// conversation commit in the order they were numbered and a reader never
|
|
314
|
+
// sees a later one before an earlier. No conversation, no row from
|
|
315
|
+
// `next`, nothing inserted. Unfenced: no lease token is checked.
|
|
316
|
+
const { rows } = await db.query(`WITH next AS (
|
|
317
|
+
UPDATE haikit_conversations SET notice_seq = notice_seq + 1
|
|
318
|
+
WHERE id = $1::text
|
|
319
|
+
RETURNING notice_seq
|
|
320
|
+
), stored AS (
|
|
321
|
+
INSERT INTO haikit_notices (conversation_id, seq, name, version, payload, model, handle)
|
|
322
|
+
SELECT $1::text, notice_seq, $2::text, $3::integer, $4::jsonb, $5::text, $6::text FROM next
|
|
323
|
+
RETURNING ${NOTICE_COLUMNS}
|
|
324
|
+
)
|
|
325
|
+
SELECT stored.*, pg_notify('${NOTICE_CHANNEL}', $1::text) FROM stored`, [
|
|
326
|
+
record.conversationId,
|
|
327
|
+
record.name,
|
|
328
|
+
record.version,
|
|
329
|
+
JSON.stringify(record.payload ?? null),
|
|
330
|
+
record.model,
|
|
331
|
+
record.handle ?? null,
|
|
332
|
+
]);
|
|
333
|
+
if (!rows.length)
|
|
334
|
+
throw new Error(`conversation ${record.conversationId} does not exist`);
|
|
335
|
+
return toNotice(rows[0]);
|
|
336
|
+
},
|
|
337
|
+
async getNotices(conversationId, after, limit) {
|
|
338
|
+
const { rows } = await db.query(`SELECT ${NOTICE_COLUMNS} FROM haikit_notices
|
|
339
|
+
WHERE conversation_id = $1 AND seq > $2::bigint
|
|
340
|
+
ORDER BY seq
|
|
341
|
+
LIMIT $3::integer`, [conversationId, after, limit ?? null]);
|
|
342
|
+
if (rows.length)
|
|
343
|
+
return rows.map(toNotice);
|
|
344
|
+
// nothing to return: say which of the two reasons applies
|
|
345
|
+
const exists = await db.query(`SELECT 1 FROM haikit_conversations WHERE id = $1`, [conversationId]);
|
|
346
|
+
return exists.rows.length ? [] : null;
|
|
347
|
+
},
|
|
348
|
+
...(options.listen ? { watch } : {}),
|
|
223
349
|
};
|
|
224
350
|
}
|
|
225
351
|
/**
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,EACL,gBAAgB,EAChB,UAAU,
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@haikit/postgres",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Postgres store adapter for haikit: durable conversations, fenced turn leases, write-once payloads. Bring your own driver.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"haikit",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"prepack": "npm --prefix ../.. run build"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@haikit/core": "0.
|
|
37
|
+
"@haikit/core": "0.13.0"
|
|
38
38
|
},
|
|
39
39
|
"publishConfig": {
|
|
40
40
|
"access": "public"
|
package/src/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
ConversationBusy,
|
|
16
16
|
StaleLease,
|
|
17
17
|
type Conversation,
|
|
18
|
+
type NoticeRecord,
|
|
18
19
|
type PayloadRecord,
|
|
19
20
|
type StoreAdapter,
|
|
20
21
|
} from "@haikit/core";
|
|
@@ -39,10 +40,28 @@ export interface PgStoreOptions {
|
|
|
39
40
|
* which is safe (its writes are fenced out) but wasteful.
|
|
40
41
|
*/
|
|
41
42
|
leaseMs?: number;
|
|
43
|
+
/**
|
|
44
|
+
* A connection that can `LISTEN`, so the events route hears of a notice the
|
|
45
|
+
* moment it lands, rather than at its next read a couple of seconds on. It needs a
|
|
46
|
+
* connection of its own: a pooled one goes back to the pool and stops
|
|
47
|
+
* listening. PGlite's `listen` fits as it is; see the README for `pg`.
|
|
48
|
+
*/
|
|
49
|
+
listen?: Listener;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Something that can `LISTEN` on a channel and report each `NOTIFY` payload.
|
|
54
|
+
* Resolves once listening, to a function that stops.
|
|
55
|
+
*/
|
|
56
|
+
export interface Listener {
|
|
57
|
+
listen(channel: string, onNotify: (payload: string) => void): Promise<unknown>;
|
|
42
58
|
}
|
|
43
59
|
|
|
44
60
|
const LEASE_MS = 120_000;
|
|
45
61
|
|
|
62
|
+
/** The channel `putNotice` notifies on, with the conversation id as payload. */
|
|
63
|
+
export const NOTICE_CHANNEL = "haikit_notices";
|
|
64
|
+
|
|
46
65
|
/**
|
|
47
66
|
* The schema, one statement per entry. `migrate()` runs these; export them to
|
|
48
67
|
* your own migration tool instead if you have one.
|
|
@@ -58,8 +77,27 @@ export const schema: readonly string[] = [
|
|
|
58
77
|
lease_until timestamptz,
|
|
59
78
|
lease_token text,
|
|
60
79
|
handle_seq integer NOT NULL DEFAULT 0,
|
|
80
|
+
-- the last notice number given out, and the last one its history took in
|
|
81
|
+
notice_seq integer NOT NULL DEFAULT 0,
|
|
82
|
+
noticed_through integer NOT NULL DEFAULT 0,
|
|
61
83
|
updated_at timestamptz NOT NULL DEFAULT now()
|
|
62
84
|
)`,
|
|
85
|
+
// a table created before notices existed gains the columns
|
|
86
|
+
`ALTER TABLE haikit_conversations ADD COLUMN IF NOT EXISTS notice_seq integer NOT NULL DEFAULT 0`,
|
|
87
|
+
`ALTER TABLE haikit_conversations ADD COLUMN IF NOT EXISTS noticed_through integer NOT NULL DEFAULT 0`,
|
|
88
|
+
// Append-only, and never an orphan: a notice belongs to a conversation that
|
|
89
|
+
// exists, and goes when it does.
|
|
90
|
+
`CREATE TABLE IF NOT EXISTS haikit_notices (
|
|
91
|
+
conversation_id text NOT NULL REFERENCES haikit_conversations(id) ON DELETE CASCADE,
|
|
92
|
+
seq integer NOT NULL,
|
|
93
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
94
|
+
name text NOT NULL,
|
|
95
|
+
version integer NOT NULL,
|
|
96
|
+
payload jsonb NOT NULL,
|
|
97
|
+
model text,
|
|
98
|
+
handle text,
|
|
99
|
+
PRIMARY KEY (conversation_id, seq)
|
|
100
|
+
)`,
|
|
63
101
|
// Write-once. Nothing about a payload changes after insert — everything that
|
|
64
102
|
// does lives on the fenced conversation row (see Conversation.frozen).
|
|
65
103
|
`CREATE TABLE IF NOT EXISTS haikit_payloads (
|
|
@@ -97,9 +135,13 @@ export async function migrate(db: Queryable): Promise<void> {
|
|
|
97
135
|
// a second time throws. Taking text every time removes the guess.
|
|
98
136
|
const CONVERSATION_COLUMNS = `
|
|
99
137
|
id, status, messages::text AS messages, handles::text AS handles,
|
|
100
|
-
frozen::text AS frozen, pending::text AS pending, lease_token,
|
|
138
|
+
frozen::text AS frozen, pending::text AS pending, lease_token, noticed_through,
|
|
101
139
|
(extract(epoch FROM lease_until) * 1000)::float8 AS lease_until_ms`;
|
|
102
140
|
|
|
141
|
+
const NOTICE_COLUMNS = `
|
|
142
|
+
conversation_id, seq, name, version, payload::text AS payload, model, handle,
|
|
143
|
+
(extract(epoch FROM created_at) * 1000)::float8 AS created_at_ms`;
|
|
144
|
+
|
|
103
145
|
// The window is read as text too: 'Infinity' is how "never" is stored, and
|
|
104
146
|
// drivers need not agree on turning it into a number.
|
|
105
147
|
const PAYLOAD_COLUMNS = `
|
|
@@ -118,6 +160,18 @@ const toConversation = (row: any): Conversation => ({
|
|
|
118
160
|
pending: row.pending == null ? null : json(row.pending),
|
|
119
161
|
leaseUntil: row.lease_until_ms == null ? null : Number(row.lease_until_ms),
|
|
120
162
|
leaseToken: row.lease_token,
|
|
163
|
+
noticedThrough: Number(row.noticed_through ?? 0),
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const toNotice = (row: any): NoticeRecord => ({
|
|
167
|
+
conversationId: row.conversation_id,
|
|
168
|
+
seq: Number(row.seq),
|
|
169
|
+
createdAt: Number(row.created_at_ms),
|
|
170
|
+
name: row.name,
|
|
171
|
+
version: Number(row.version),
|
|
172
|
+
payload: json(row.payload),
|
|
173
|
+
model: row.model,
|
|
174
|
+
...(row.handle == null ? {} : { handle: row.handle }),
|
|
121
175
|
});
|
|
122
176
|
|
|
123
177
|
const toPayload = (row: any): PayloadRecord => ({
|
|
@@ -145,6 +199,54 @@ export function pgStore(db: Queryable, options: PgStoreOptions = {}): StoreAdapt
|
|
|
145
199
|
throw new RangeError(`leaseMs must be a positive, finite number of milliseconds (got ${leaseMs})`);
|
|
146
200
|
}
|
|
147
201
|
|
|
202
|
+
// One LISTEN for the whole store, taken when something first watches, and
|
|
203
|
+
// each notification handed to whoever is watching that conversation.
|
|
204
|
+
const waiting = new Map<string, Set<() => void>>();
|
|
205
|
+
let listening: Promise<unknown> | null = null;
|
|
206
|
+
const listen = (listener: Listener) =>
|
|
207
|
+
(listening ??= listener
|
|
208
|
+
.listen(NOTICE_CHANNEL, (id) => {
|
|
209
|
+
for (const wake of waiting.get(id) ?? []) wake();
|
|
210
|
+
})
|
|
211
|
+
.catch((err) => {
|
|
212
|
+
// try again on the next watch, rather than never
|
|
213
|
+
listening = null;
|
|
214
|
+
throw err;
|
|
215
|
+
}));
|
|
216
|
+
|
|
217
|
+
async function* watch(conversationId: string, signal: AbortSignal): AsyncIterable<void> {
|
|
218
|
+
// One wake-up stands for any number of notices: a step resolves once
|
|
219
|
+
// something has landed since the last, and the caller reads them all.
|
|
220
|
+
let landed = false;
|
|
221
|
+
let wake: (() => void) | null = null;
|
|
222
|
+
const listener = () => {
|
|
223
|
+
landed = true;
|
|
224
|
+
wake?.();
|
|
225
|
+
};
|
|
226
|
+
const onAbort = () => wake?.();
|
|
227
|
+
const set = waiting.get(conversationId) ?? new Set();
|
|
228
|
+
set.add(listener);
|
|
229
|
+
waiting.set(conversationId, set);
|
|
230
|
+
signal.addEventListener("abort", onAbort);
|
|
231
|
+
try {
|
|
232
|
+
await listen(options.listen!);
|
|
233
|
+
// A notice that landed before LISTEN took hold sent its NOTIFY to no
|
|
234
|
+
// one, so the first step is free: the caller reads once now.
|
|
235
|
+
landed = true;
|
|
236
|
+
while (!signal.aborted) {
|
|
237
|
+
if (!landed) await new Promise<void>((r) => (wake = r));
|
|
238
|
+
wake = null;
|
|
239
|
+
if (signal.aborted) return;
|
|
240
|
+
landed = false;
|
|
241
|
+
yield;
|
|
242
|
+
}
|
|
243
|
+
} finally {
|
|
244
|
+
signal.removeEventListener("abort", onAbort);
|
|
245
|
+
set.delete(listener);
|
|
246
|
+
if (!set.size) waiting.delete(conversationId);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
148
250
|
/** A fenced write matched nothing. Say which of the two reasons applies. */
|
|
149
251
|
async function stale(conversationId: string): Promise<never> {
|
|
150
252
|
const { rows } = await db.query(`SELECT 1 FROM haikit_conversations WHERE id = $1`, [conversationId]);
|
|
@@ -199,6 +301,7 @@ export function pgStore(db: Queryable, options: PgStoreOptions = {}): StoreAdapt
|
|
|
199
301
|
frozen = $6::jsonb,
|
|
200
302
|
pending = $7::jsonb,
|
|
201
303
|
lease_until = to_timestamp($8::float8 / 1000),
|
|
304
|
+
noticed_through = $9::integer,
|
|
202
305
|
updated_at = now()
|
|
203
306
|
WHERE id = $1 AND lease_token = $2
|
|
204
307
|
RETURNING id`,
|
|
@@ -212,6 +315,7 @@ export function pgStore(db: Queryable, options: PgStoreOptions = {}): StoreAdapt
|
|
|
212
315
|
// SQL NULL, not the JSON value null — keep "no pending turn" queryable
|
|
213
316
|
conversation.pending == null ? null : JSON.stringify(conversation.pending),
|
|
214
317
|
conversation.leaseUntil,
|
|
318
|
+
conversation.noticedThrough ?? 0,
|
|
215
319
|
],
|
|
216
320
|
);
|
|
217
321
|
if (!rows.length) await stale(conversation.id);
|
|
@@ -280,6 +384,52 @@ export function pgStore(db: Queryable, options: PgStoreOptions = {}): StoreAdapt
|
|
|
280
384
|
return record ? [structuredClone(record)] : [];
|
|
281
385
|
});
|
|
282
386
|
},
|
|
387
|
+
|
|
388
|
+
async putNotice(record) {
|
|
389
|
+
// Number, insert and notify in one statement. The UPDATE takes the next
|
|
390
|
+
// number under the conversation's row lock, so two notices in one
|
|
391
|
+
// conversation commit in the order they were numbered and a reader never
|
|
392
|
+
// sees a later one before an earlier. No conversation, no row from
|
|
393
|
+
// `next`, nothing inserted. Unfenced: no lease token is checked.
|
|
394
|
+
const { rows } = await db.query(
|
|
395
|
+
`WITH next AS (
|
|
396
|
+
UPDATE haikit_conversations SET notice_seq = notice_seq + 1
|
|
397
|
+
WHERE id = $1::text
|
|
398
|
+
RETURNING notice_seq
|
|
399
|
+
), stored AS (
|
|
400
|
+
INSERT INTO haikit_notices (conversation_id, seq, name, version, payload, model, handle)
|
|
401
|
+
SELECT $1::text, notice_seq, $2::text, $3::integer, $4::jsonb, $5::text, $6::text FROM next
|
|
402
|
+
RETURNING ${NOTICE_COLUMNS}
|
|
403
|
+
)
|
|
404
|
+
SELECT stored.*, pg_notify('${NOTICE_CHANNEL}', $1::text) FROM stored`,
|
|
405
|
+
[
|
|
406
|
+
record.conversationId,
|
|
407
|
+
record.name,
|
|
408
|
+
record.version,
|
|
409
|
+
JSON.stringify(record.payload ?? null),
|
|
410
|
+
record.model,
|
|
411
|
+
record.handle ?? null,
|
|
412
|
+
],
|
|
413
|
+
);
|
|
414
|
+
if (!rows.length) throw new Error(`conversation ${record.conversationId} does not exist`);
|
|
415
|
+
return toNotice(rows[0]);
|
|
416
|
+
},
|
|
417
|
+
|
|
418
|
+
async getNotices(conversationId, after, limit) {
|
|
419
|
+
const { rows } = await db.query(
|
|
420
|
+
`SELECT ${NOTICE_COLUMNS} FROM haikit_notices
|
|
421
|
+
WHERE conversation_id = $1 AND seq > $2::bigint
|
|
422
|
+
ORDER BY seq
|
|
423
|
+
LIMIT $3::integer`,
|
|
424
|
+
[conversationId, after, limit ?? null],
|
|
425
|
+
);
|
|
426
|
+
if (rows.length) return rows.map(toNotice);
|
|
427
|
+
// nothing to return: say which of the two reasons applies
|
|
428
|
+
const exists = await db.query(`SELECT 1 FROM haikit_conversations WHERE id = $1`, [conversationId]);
|
|
429
|
+
return exists.rows.length ? [] : null;
|
|
430
|
+
},
|
|
431
|
+
|
|
432
|
+
...(options.listen ? { watch } : {}),
|
|
283
433
|
};
|
|
284
434
|
}
|
|
285
435
|
|