@haikit/postgres 0.12.0 → 0.14.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 +38 -1
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +144 -1
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +168 -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,46 @@ 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
|
+
|
|
59
|
+
**Upgrading to 0.13:** `migrate()` adds `kind` to `haikit_notices`, and
|
|
60
|
+
`wakes` and `woke_through` to `haikit_conversations`, for wake notices.
|
|
61
|
+
|
|
55
62
|
Handles are numbered per conversation, so every conversation's digests start at
|
|
56
63
|
`ui_01`.
|
|
57
64
|
|
|
65
|
+
## Notices
|
|
66
|
+
|
|
67
|
+
A notice is one `INSERT … SELECT`, numbered under the conversation's row lock,
|
|
68
|
+
so two notices in one conversation always commit in the order they were
|
|
69
|
+
numbered and a reader resuming after one can never miss an earlier. It takes no
|
|
70
|
+
lease token: a notice lands whoever holds the turn. The same statement sends a
|
|
71
|
+
`NOTIFY` on `haikit_notices` with the conversation id.
|
|
72
|
+
|
|
73
|
+
Give the store a connection that can `LISTEN`, and the events route hears of a
|
|
74
|
+
notice the moment it lands, rather than at its next two-second read. It needs a
|
|
75
|
+
connection of its own: a pooled one goes back to the pool and stops listening.
|
|
76
|
+
PGlite fits as it is (`pgStore(db, { listen: db })`). With `pg`:
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
const listener = new pg.Client({ connectionString: process.env.DATABASE_URL });
|
|
80
|
+
await listener.connect();
|
|
81
|
+
|
|
82
|
+
const store = pgStore(pool, {
|
|
83
|
+
listen: {
|
|
84
|
+
async listen(channel, onNotify) {
|
|
85
|
+
listener.on("notification", (msg) => msg.channel === channel && onNotify(msg.payload ?? ""));
|
|
86
|
+
await listener.query(`LISTEN ${channel}`);
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
A notice belongs to its conversation and goes when it does: deleting a
|
|
93
|
+
conversation deletes its notices. `sweepOrphans` never touches them.
|
|
94
|
+
|
|
58
95
|
## Cleaning up
|
|
59
96
|
|
|
60
97
|
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,EA8DnC,CAAC;AAEF,8FAA8F;AAC9F,wBAAsB,OAAO,CAAC,EAAE,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CAI1D;AAkED,wBAAgB,OAAO,CAAC,EAAE,EAAE,SAAS,EAAE,OAAO,GAAE,cAAmB,GAAG,YAAY,CAuPjF;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,8 +29,35 @@ 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,
|
|
35
|
+
-- when recent wake turns started, and the last notice that may no longer start one
|
|
36
|
+
wakes jsonb NOT NULL DEFAULT '[]',
|
|
37
|
+
woke_through integer NOT NULL DEFAULT 0,
|
|
30
38
|
updated_at timestamptz NOT NULL DEFAULT now()
|
|
31
39
|
)`,
|
|
40
|
+
// a table created before notices existed gains the columns
|
|
41
|
+
`ALTER TABLE haikit_conversations ADD COLUMN IF NOT EXISTS notice_seq integer NOT NULL DEFAULT 0`,
|
|
42
|
+
`ALTER TABLE haikit_conversations ADD COLUMN IF NOT EXISTS noticed_through integer NOT NULL DEFAULT 0`,
|
|
43
|
+
`ALTER TABLE haikit_conversations ADD COLUMN IF NOT EXISTS wakes jsonb NOT NULL DEFAULT '[]'`,
|
|
44
|
+
`ALTER TABLE haikit_conversations ADD COLUMN IF NOT EXISTS woke_through integer NOT NULL DEFAULT 0`,
|
|
45
|
+
// Append-only, and never an orphan: a notice belongs to a conversation that
|
|
46
|
+
// exists, and goes when it does.
|
|
47
|
+
`CREATE TABLE IF NOT EXISTS haikit_notices (
|
|
48
|
+
conversation_id text NOT NULL REFERENCES haikit_conversations(id) ON DELETE CASCADE,
|
|
49
|
+
seq integer NOT NULL,
|
|
50
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
51
|
+
name text NOT NULL,
|
|
52
|
+
version integer NOT NULL,
|
|
53
|
+
payload jsonb NOT NULL,
|
|
54
|
+
model text,
|
|
55
|
+
handle text,
|
|
56
|
+
-- 'wake', or NULL for a passive notice
|
|
57
|
+
kind text,
|
|
58
|
+
PRIMARY KEY (conversation_id, seq)
|
|
59
|
+
)`,
|
|
60
|
+
`ALTER TABLE haikit_notices ADD COLUMN IF NOT EXISTS kind text`,
|
|
32
61
|
// Write-once. Nothing about a payload changes after insert — everything that
|
|
33
62
|
// does lives on the fenced conversation row (see Conversation.frozen).
|
|
34
63
|
`CREATE TABLE IF NOT EXISTS haikit_payloads (
|
|
@@ -65,8 +94,12 @@ export async function migrate(db) {
|
|
|
65
94
|
// a second time throws. Taking text every time removes the guess.
|
|
66
95
|
const CONVERSATION_COLUMNS = `
|
|
67
96
|
id, status, messages::text AS messages, handles::text AS handles,
|
|
68
|
-
frozen::text AS frozen, pending::text AS pending, lease_token,
|
|
97
|
+
frozen::text AS frozen, pending::text AS pending, lease_token, noticed_through,
|
|
98
|
+
wakes::text AS wakes, woke_through,
|
|
69
99
|
(extract(epoch FROM lease_until) * 1000)::float8 AS lease_until_ms`;
|
|
100
|
+
const NOTICE_COLUMNS = `
|
|
101
|
+
conversation_id, seq, name, version, payload::text AS payload, model, handle, kind,
|
|
102
|
+
(extract(epoch FROM created_at) * 1000)::float8 AS created_at_ms`;
|
|
70
103
|
// The window is read as text too: 'Infinity' is how "never" is stored, and
|
|
71
104
|
// drivers need not agree on turning it into a number.
|
|
72
105
|
const PAYLOAD_COLUMNS = `
|
|
@@ -83,6 +116,20 @@ const toConversation = (row) => ({
|
|
|
83
116
|
pending: row.pending == null ? null : json(row.pending),
|
|
84
117
|
leaseUntil: row.lease_until_ms == null ? null : Number(row.lease_until_ms),
|
|
85
118
|
leaseToken: row.lease_token,
|
|
119
|
+
noticedThrough: Number(row.noticed_through ?? 0),
|
|
120
|
+
wakes: row.wakes == null ? [] : json(row.wakes),
|
|
121
|
+
wokeThrough: Number(row.woke_through ?? 0),
|
|
122
|
+
});
|
|
123
|
+
const toNotice = (row) => ({
|
|
124
|
+
conversationId: row.conversation_id,
|
|
125
|
+
seq: Number(row.seq),
|
|
126
|
+
createdAt: Number(row.created_at_ms),
|
|
127
|
+
name: row.name,
|
|
128
|
+
version: Number(row.version),
|
|
129
|
+
payload: json(row.payload),
|
|
130
|
+
model: row.model,
|
|
131
|
+
...(row.handle == null ? {} : { handle: row.handle }),
|
|
132
|
+
...(row.kind === "wake" ? { kind: "wake" } : {}),
|
|
86
133
|
});
|
|
87
134
|
const toPayload = (row) => ({
|
|
88
135
|
handle: row.handle,
|
|
@@ -105,6 +152,56 @@ export function pgStore(db, options = {}) {
|
|
|
105
152
|
if (!Number.isFinite(leaseMs) || leaseMs <= 0) {
|
|
106
153
|
throw new RangeError(`leaseMs must be a positive, finite number of milliseconds (got ${leaseMs})`);
|
|
107
154
|
}
|
|
155
|
+
// One LISTEN for the whole store, taken when something first watches, and
|
|
156
|
+
// each notification handed to whoever is watching that conversation.
|
|
157
|
+
const waiting = new Map();
|
|
158
|
+
let listening = null;
|
|
159
|
+
const listen = (listener) => (listening ??= listener
|
|
160
|
+
.listen(NOTICE_CHANNEL, (id) => {
|
|
161
|
+
for (const wake of waiting.get(id) ?? [])
|
|
162
|
+
wake();
|
|
163
|
+
})
|
|
164
|
+
.catch((err) => {
|
|
165
|
+
// try again on the next watch, rather than never
|
|
166
|
+
listening = null;
|
|
167
|
+
throw err;
|
|
168
|
+
}));
|
|
169
|
+
async function* watch(conversationId, signal) {
|
|
170
|
+
// One wake-up stands for any number of notices: a step resolves once
|
|
171
|
+
// something has landed since the last, and the caller reads them all.
|
|
172
|
+
let landed = false;
|
|
173
|
+
let wake = null;
|
|
174
|
+
const listener = () => {
|
|
175
|
+
landed = true;
|
|
176
|
+
wake?.();
|
|
177
|
+
};
|
|
178
|
+
const onAbort = () => wake?.();
|
|
179
|
+
const set = waiting.get(conversationId) ?? new Set();
|
|
180
|
+
set.add(listener);
|
|
181
|
+
waiting.set(conversationId, set);
|
|
182
|
+
signal.addEventListener("abort", onAbort);
|
|
183
|
+
try {
|
|
184
|
+
await listen(options.listen);
|
|
185
|
+
// A notice that landed before LISTEN took hold sent its NOTIFY to no
|
|
186
|
+
// one, so the first step is free: the caller reads once now.
|
|
187
|
+
landed = true;
|
|
188
|
+
while (!signal.aborted) {
|
|
189
|
+
if (!landed)
|
|
190
|
+
await new Promise((r) => (wake = r));
|
|
191
|
+
wake = null;
|
|
192
|
+
if (signal.aborted)
|
|
193
|
+
return;
|
|
194
|
+
landed = false;
|
|
195
|
+
yield;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
finally {
|
|
199
|
+
signal.removeEventListener("abort", onAbort);
|
|
200
|
+
set.delete(listener);
|
|
201
|
+
if (!set.size)
|
|
202
|
+
waiting.delete(conversationId);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
108
205
|
/** A fenced write matched nothing. Say which of the two reasons applies. */
|
|
109
206
|
async function stale(conversationId) {
|
|
110
207
|
const { rows } = await db.query(`SELECT 1 FROM haikit_conversations WHERE id = $1`, [conversationId]);
|
|
@@ -150,6 +247,9 @@ export function pgStore(db, options = {}) {
|
|
|
150
247
|
frozen = $6::jsonb,
|
|
151
248
|
pending = $7::jsonb,
|
|
152
249
|
lease_until = to_timestamp($8::float8 / 1000),
|
|
250
|
+
noticed_through = $9::integer,
|
|
251
|
+
wakes = $10::jsonb,
|
|
252
|
+
woke_through = $11::integer,
|
|
153
253
|
updated_at = now()
|
|
154
254
|
WHERE id = $1 AND lease_token = $2
|
|
155
255
|
RETURNING id`, [
|
|
@@ -162,6 +262,9 @@ export function pgStore(db, options = {}) {
|
|
|
162
262
|
// SQL NULL, not the JSON value null — keep "no pending turn" queryable
|
|
163
263
|
conversation.pending == null ? null : JSON.stringify(conversation.pending),
|
|
164
264
|
conversation.leaseUntil,
|
|
265
|
+
conversation.noticedThrough ?? 0,
|
|
266
|
+
JSON.stringify(conversation.wakes ?? []),
|
|
267
|
+
conversation.wokeThrough ?? 0,
|
|
165
268
|
]);
|
|
166
269
|
if (!rows.length)
|
|
167
270
|
await stale(conversation.id);
|
|
@@ -220,6 +323,46 @@ export function pgStore(db, options = {}) {
|
|
|
220
323
|
return record ? [structuredClone(record)] : [];
|
|
221
324
|
});
|
|
222
325
|
},
|
|
326
|
+
async putNotice(record) {
|
|
327
|
+
// Number, insert and notify in one statement. The UPDATE takes the next
|
|
328
|
+
// number under the conversation's row lock, so two notices in one
|
|
329
|
+
// conversation commit in the order they were numbered and a reader never
|
|
330
|
+
// sees a later one before an earlier. No conversation, no row from
|
|
331
|
+
// `next`, nothing inserted. Unfenced: no lease token is checked.
|
|
332
|
+
const { rows } = await db.query(`WITH next AS (
|
|
333
|
+
UPDATE haikit_conversations SET notice_seq = notice_seq + 1
|
|
334
|
+
WHERE id = $1::text
|
|
335
|
+
RETURNING notice_seq
|
|
336
|
+
), stored AS (
|
|
337
|
+
INSERT INTO haikit_notices (conversation_id, seq, name, version, payload, model, handle, kind)
|
|
338
|
+
SELECT $1::text, notice_seq, $2::text, $3::integer, $4::jsonb, $5::text, $6::text, $7::text FROM next
|
|
339
|
+
RETURNING ${NOTICE_COLUMNS}
|
|
340
|
+
)
|
|
341
|
+
SELECT stored.*, pg_notify('${NOTICE_CHANNEL}', $1::text) FROM stored`, [
|
|
342
|
+
record.conversationId,
|
|
343
|
+
record.name,
|
|
344
|
+
record.version,
|
|
345
|
+
JSON.stringify(record.payload ?? null),
|
|
346
|
+
record.model,
|
|
347
|
+
record.handle ?? null,
|
|
348
|
+
record.kind === "wake" ? "wake" : null,
|
|
349
|
+
]);
|
|
350
|
+
if (!rows.length)
|
|
351
|
+
throw new Error(`conversation ${record.conversationId} does not exist`);
|
|
352
|
+
return toNotice(rows[0]);
|
|
353
|
+
},
|
|
354
|
+
async getNotices(conversationId, after, limit) {
|
|
355
|
+
const { rows } = await db.query(`SELECT ${NOTICE_COLUMNS} FROM haikit_notices
|
|
356
|
+
WHERE conversation_id = $1 AND seq > $2::bigint
|
|
357
|
+
ORDER BY seq
|
|
358
|
+
LIMIT $3::integer`, [conversationId, after, limit ?? null]);
|
|
359
|
+
if (rows.length)
|
|
360
|
+
return rows.map(toNotice);
|
|
361
|
+
// nothing to return: say which of the two reasons applies
|
|
362
|
+
const exists = await db.query(`SELECT 1 FROM haikit_conversations WHERE id = $1`, [conversationId]);
|
|
363
|
+
return exists.rows.length ? [] : null;
|
|
364
|
+
},
|
|
365
|
+
...(options.listen ? { watch } : {}),
|
|
223
366
|
};
|
|
224
367
|
}
|
|
225
368
|
/**
|
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.14.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.14.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,35 @@ 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,
|
|
83
|
+
-- when recent wake turns started, and the last notice that may no longer start one
|
|
84
|
+
wakes jsonb NOT NULL DEFAULT '[]',
|
|
85
|
+
woke_through integer NOT NULL DEFAULT 0,
|
|
61
86
|
updated_at timestamptz NOT NULL DEFAULT now()
|
|
62
87
|
)`,
|
|
88
|
+
// a table created before notices existed gains the columns
|
|
89
|
+
`ALTER TABLE haikit_conversations ADD COLUMN IF NOT EXISTS notice_seq integer NOT NULL DEFAULT 0`,
|
|
90
|
+
`ALTER TABLE haikit_conversations ADD COLUMN IF NOT EXISTS noticed_through integer NOT NULL DEFAULT 0`,
|
|
91
|
+
`ALTER TABLE haikit_conversations ADD COLUMN IF NOT EXISTS wakes jsonb NOT NULL DEFAULT '[]'`,
|
|
92
|
+
`ALTER TABLE haikit_conversations ADD COLUMN IF NOT EXISTS woke_through integer NOT NULL DEFAULT 0`,
|
|
93
|
+
// Append-only, and never an orphan: a notice belongs to a conversation that
|
|
94
|
+
// exists, and goes when it does.
|
|
95
|
+
`CREATE TABLE IF NOT EXISTS haikit_notices (
|
|
96
|
+
conversation_id text NOT NULL REFERENCES haikit_conversations(id) ON DELETE CASCADE,
|
|
97
|
+
seq integer NOT NULL,
|
|
98
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
99
|
+
name text NOT NULL,
|
|
100
|
+
version integer NOT NULL,
|
|
101
|
+
payload jsonb NOT NULL,
|
|
102
|
+
model text,
|
|
103
|
+
handle text,
|
|
104
|
+
-- 'wake', or NULL for a passive notice
|
|
105
|
+
kind text,
|
|
106
|
+
PRIMARY KEY (conversation_id, seq)
|
|
107
|
+
)`,
|
|
108
|
+
`ALTER TABLE haikit_notices ADD COLUMN IF NOT EXISTS kind text`,
|
|
63
109
|
// Write-once. Nothing about a payload changes after insert — everything that
|
|
64
110
|
// does lives on the fenced conversation row (see Conversation.frozen).
|
|
65
111
|
`CREATE TABLE IF NOT EXISTS haikit_payloads (
|
|
@@ -97,9 +143,14 @@ export async function migrate(db: Queryable): Promise<void> {
|
|
|
97
143
|
// a second time throws. Taking text every time removes the guess.
|
|
98
144
|
const CONVERSATION_COLUMNS = `
|
|
99
145
|
id, status, messages::text AS messages, handles::text AS handles,
|
|
100
|
-
frozen::text AS frozen, pending::text AS pending, lease_token,
|
|
146
|
+
frozen::text AS frozen, pending::text AS pending, lease_token, noticed_through,
|
|
147
|
+
wakes::text AS wakes, woke_through,
|
|
101
148
|
(extract(epoch FROM lease_until) * 1000)::float8 AS lease_until_ms`;
|
|
102
149
|
|
|
150
|
+
const NOTICE_COLUMNS = `
|
|
151
|
+
conversation_id, seq, name, version, payload::text AS payload, model, handle, kind,
|
|
152
|
+
(extract(epoch FROM created_at) * 1000)::float8 AS created_at_ms`;
|
|
153
|
+
|
|
103
154
|
// The window is read as text too: 'Infinity' is how "never" is stored, and
|
|
104
155
|
// drivers need not agree on turning it into a number.
|
|
105
156
|
const PAYLOAD_COLUMNS = `
|
|
@@ -118,6 +169,21 @@ const toConversation = (row: any): Conversation => ({
|
|
|
118
169
|
pending: row.pending == null ? null : json(row.pending),
|
|
119
170
|
leaseUntil: row.lease_until_ms == null ? null : Number(row.lease_until_ms),
|
|
120
171
|
leaseToken: row.lease_token,
|
|
172
|
+
noticedThrough: Number(row.noticed_through ?? 0),
|
|
173
|
+
wakes: row.wakes == null ? [] : json(row.wakes),
|
|
174
|
+
wokeThrough: Number(row.woke_through ?? 0),
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const toNotice = (row: any): NoticeRecord => ({
|
|
178
|
+
conversationId: row.conversation_id,
|
|
179
|
+
seq: Number(row.seq),
|
|
180
|
+
createdAt: Number(row.created_at_ms),
|
|
181
|
+
name: row.name,
|
|
182
|
+
version: Number(row.version),
|
|
183
|
+
payload: json(row.payload),
|
|
184
|
+
model: row.model,
|
|
185
|
+
...(row.handle == null ? {} : { handle: row.handle }),
|
|
186
|
+
...(row.kind === "wake" ? { kind: "wake" as const } : {}),
|
|
121
187
|
});
|
|
122
188
|
|
|
123
189
|
const toPayload = (row: any): PayloadRecord => ({
|
|
@@ -145,6 +211,54 @@ export function pgStore(db: Queryable, options: PgStoreOptions = {}): StoreAdapt
|
|
|
145
211
|
throw new RangeError(`leaseMs must be a positive, finite number of milliseconds (got ${leaseMs})`);
|
|
146
212
|
}
|
|
147
213
|
|
|
214
|
+
// One LISTEN for the whole store, taken when something first watches, and
|
|
215
|
+
// each notification handed to whoever is watching that conversation.
|
|
216
|
+
const waiting = new Map<string, Set<() => void>>();
|
|
217
|
+
let listening: Promise<unknown> | null = null;
|
|
218
|
+
const listen = (listener: Listener) =>
|
|
219
|
+
(listening ??= listener
|
|
220
|
+
.listen(NOTICE_CHANNEL, (id) => {
|
|
221
|
+
for (const wake of waiting.get(id) ?? []) wake();
|
|
222
|
+
})
|
|
223
|
+
.catch((err) => {
|
|
224
|
+
// try again on the next watch, rather than never
|
|
225
|
+
listening = null;
|
|
226
|
+
throw err;
|
|
227
|
+
}));
|
|
228
|
+
|
|
229
|
+
async function* watch(conversationId: string, signal: AbortSignal): AsyncIterable<void> {
|
|
230
|
+
// One wake-up stands for any number of notices: a step resolves once
|
|
231
|
+
// something has landed since the last, and the caller reads them all.
|
|
232
|
+
let landed = false;
|
|
233
|
+
let wake: (() => void) | null = null;
|
|
234
|
+
const listener = () => {
|
|
235
|
+
landed = true;
|
|
236
|
+
wake?.();
|
|
237
|
+
};
|
|
238
|
+
const onAbort = () => wake?.();
|
|
239
|
+
const set = waiting.get(conversationId) ?? new Set();
|
|
240
|
+
set.add(listener);
|
|
241
|
+
waiting.set(conversationId, set);
|
|
242
|
+
signal.addEventListener("abort", onAbort);
|
|
243
|
+
try {
|
|
244
|
+
await listen(options.listen!);
|
|
245
|
+
// A notice that landed before LISTEN took hold sent its NOTIFY to no
|
|
246
|
+
// one, so the first step is free: the caller reads once now.
|
|
247
|
+
landed = true;
|
|
248
|
+
while (!signal.aborted) {
|
|
249
|
+
if (!landed) await new Promise<void>((r) => (wake = r));
|
|
250
|
+
wake = null;
|
|
251
|
+
if (signal.aborted) return;
|
|
252
|
+
landed = false;
|
|
253
|
+
yield;
|
|
254
|
+
}
|
|
255
|
+
} finally {
|
|
256
|
+
signal.removeEventListener("abort", onAbort);
|
|
257
|
+
set.delete(listener);
|
|
258
|
+
if (!set.size) waiting.delete(conversationId);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
148
262
|
/** A fenced write matched nothing. Say which of the two reasons applies. */
|
|
149
263
|
async function stale(conversationId: string): Promise<never> {
|
|
150
264
|
const { rows } = await db.query(`SELECT 1 FROM haikit_conversations WHERE id = $1`, [conversationId]);
|
|
@@ -199,6 +313,9 @@ export function pgStore(db: Queryable, options: PgStoreOptions = {}): StoreAdapt
|
|
|
199
313
|
frozen = $6::jsonb,
|
|
200
314
|
pending = $7::jsonb,
|
|
201
315
|
lease_until = to_timestamp($8::float8 / 1000),
|
|
316
|
+
noticed_through = $9::integer,
|
|
317
|
+
wakes = $10::jsonb,
|
|
318
|
+
woke_through = $11::integer,
|
|
202
319
|
updated_at = now()
|
|
203
320
|
WHERE id = $1 AND lease_token = $2
|
|
204
321
|
RETURNING id`,
|
|
@@ -212,6 +329,9 @@ export function pgStore(db: Queryable, options: PgStoreOptions = {}): StoreAdapt
|
|
|
212
329
|
// SQL NULL, not the JSON value null — keep "no pending turn" queryable
|
|
213
330
|
conversation.pending == null ? null : JSON.stringify(conversation.pending),
|
|
214
331
|
conversation.leaseUntil,
|
|
332
|
+
conversation.noticedThrough ?? 0,
|
|
333
|
+
JSON.stringify(conversation.wakes ?? []),
|
|
334
|
+
conversation.wokeThrough ?? 0,
|
|
215
335
|
],
|
|
216
336
|
);
|
|
217
337
|
if (!rows.length) await stale(conversation.id);
|
|
@@ -280,6 +400,53 @@ export function pgStore(db: Queryable, options: PgStoreOptions = {}): StoreAdapt
|
|
|
280
400
|
return record ? [structuredClone(record)] : [];
|
|
281
401
|
});
|
|
282
402
|
},
|
|
403
|
+
|
|
404
|
+
async putNotice(record) {
|
|
405
|
+
// Number, insert and notify in one statement. The UPDATE takes the next
|
|
406
|
+
// number under the conversation's row lock, so two notices in one
|
|
407
|
+
// conversation commit in the order they were numbered and a reader never
|
|
408
|
+
// sees a later one before an earlier. No conversation, no row from
|
|
409
|
+
// `next`, nothing inserted. Unfenced: no lease token is checked.
|
|
410
|
+
const { rows } = await db.query(
|
|
411
|
+
`WITH next AS (
|
|
412
|
+
UPDATE haikit_conversations SET notice_seq = notice_seq + 1
|
|
413
|
+
WHERE id = $1::text
|
|
414
|
+
RETURNING notice_seq
|
|
415
|
+
), stored AS (
|
|
416
|
+
INSERT INTO haikit_notices (conversation_id, seq, name, version, payload, model, handle, kind)
|
|
417
|
+
SELECT $1::text, notice_seq, $2::text, $3::integer, $4::jsonb, $5::text, $6::text, $7::text FROM next
|
|
418
|
+
RETURNING ${NOTICE_COLUMNS}
|
|
419
|
+
)
|
|
420
|
+
SELECT stored.*, pg_notify('${NOTICE_CHANNEL}', $1::text) FROM stored`,
|
|
421
|
+
[
|
|
422
|
+
record.conversationId,
|
|
423
|
+
record.name,
|
|
424
|
+
record.version,
|
|
425
|
+
JSON.stringify(record.payload ?? null),
|
|
426
|
+
record.model,
|
|
427
|
+
record.handle ?? null,
|
|
428
|
+
record.kind === "wake" ? "wake" : null,
|
|
429
|
+
],
|
|
430
|
+
);
|
|
431
|
+
if (!rows.length) throw new Error(`conversation ${record.conversationId} does not exist`);
|
|
432
|
+
return toNotice(rows[0]);
|
|
433
|
+
},
|
|
434
|
+
|
|
435
|
+
async getNotices(conversationId, after, limit) {
|
|
436
|
+
const { rows } = await db.query(
|
|
437
|
+
`SELECT ${NOTICE_COLUMNS} FROM haikit_notices
|
|
438
|
+
WHERE conversation_id = $1 AND seq > $2::bigint
|
|
439
|
+
ORDER BY seq
|
|
440
|
+
LIMIT $3::integer`,
|
|
441
|
+
[conversationId, after, limit ?? null],
|
|
442
|
+
);
|
|
443
|
+
if (rows.length) return rows.map(toNotice);
|
|
444
|
+
// nothing to return: say which of the two reasons applies
|
|
445
|
+
const exists = await db.query(`SELECT 1 FROM haikit_conversations WHERE id = $1`, [conversationId]);
|
|
446
|
+
return exists.rows.length ? [] : null;
|
|
447
|
+
},
|
|
448
|
+
|
|
449
|
+
...(options.listen ? { watch } : {}),
|
|
283
450
|
};
|
|
284
451
|
}
|
|
285
452
|
|