@haikit/postgres 0.3.0 → 0.4.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 +20 -5
- package/dist/index.d.ts +17 -11
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +58 -19
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +62 -22
package/README.md
CHANGED
|
@@ -41,8 +41,16 @@ exactly one to win.
|
|
|
41
41
|
## Schema
|
|
42
42
|
|
|
43
43
|
Two tables, `haikit_conversations` and `haikit_payloads`. `migrate()` creates
|
|
44
|
-
them if they do not exist; if you use your
|
|
45
|
-
are exported as `schema`.
|
|
44
|
+
them if they do not exist and brings existing ones up to date; if you use your
|
|
45
|
+
own migration tool, the statements are exported as `schema`.
|
|
46
|
+
|
|
47
|
+
**Upgrading to 0.4:** `migrate()` adds `stale_after_ms` to `haikit_payloads`:
|
|
48
|
+
the freshness window each payload was rendered under, so a later deploy that
|
|
49
|
+
renames a surface or relaxes its window cannot make data already shown last
|
|
50
|
+
longer. If you call `migrate()` at startup as above, there is nothing to do.
|
|
51
|
+
With your own migration tool, apply the new `ALTER TABLE` from `schema` before
|
|
52
|
+
deploying. Rows written before the column existed are held to the window your
|
|
53
|
+
code declares.
|
|
46
54
|
|
|
47
55
|
Handles are numbered per conversation, so every conversation's digests start at
|
|
48
56
|
`ui_01`.
|
|
@@ -59,6 +67,13 @@ import { sweepOrphans } from "@haikit/postgres";
|
|
|
59
67
|
await sweepOrphans(pool, { olderThanMs: 24 * 60 * 60 * 1000 });
|
|
60
68
|
```
|
|
61
69
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
70
|
+
A row is swept only when no history references it **and** the turn that wrote
|
|
71
|
+
it can never save again — which is proven by the conversation's lease having
|
|
72
|
+
been issued to someone else since. An expired lease is not enough: a turn slower
|
|
73
|
+
than its TTL keeps running, and if nobody took over, its save still succeeds.
|
|
74
|
+
So `olderThanMs` is a grace period, not a safety bound; no value of it can
|
|
75
|
+
delete a payload a turn is still going to reference.
|
|
76
|
+
|
|
77
|
+
A turn that crashed leaves its rows until the conversation is next used, since
|
|
78
|
+
only then is a new token issued. Deleting whole conversations is retention
|
|
79
|
+
policy, not garbage collection, and is left to you.
|
package/dist/index.d.ts
CHANGED
|
@@ -38,26 +38,32 @@ export interface PgStoreOptions {
|
|
|
38
38
|
* your own migration tool instead if you have one.
|
|
39
39
|
*/
|
|
40
40
|
export declare const schema: readonly string[];
|
|
41
|
-
/** Create the tables if they do not exist. Idempotent. */
|
|
41
|
+
/** Create the tables if they do not exist, and bring existing ones up to date. Idempotent. */
|
|
42
42
|
export declare function migrate(db: Queryable): Promise<void>;
|
|
43
43
|
export declare function pgStore(db: Queryable, options?: PgStoreOptions): StoreAdapter;
|
|
44
44
|
export interface SweepOptions {
|
|
45
45
|
/**
|
|
46
|
-
* Only sweep rows at least this old.
|
|
47
|
-
*
|
|
48
|
-
*
|
|
46
|
+
* Only sweep rows at least this old. A grace period for operators, not a
|
|
47
|
+
* safety bound: whether a row is an orphan is decided by lease tokens below,
|
|
48
|
+
* so no value here can delete a payload a turn is still going to reference.
|
|
49
49
|
*/
|
|
50
50
|
olderThanMs: number;
|
|
51
51
|
}
|
|
52
52
|
/**
|
|
53
|
-
* Delete orphaned payloads: rows written by a turn that
|
|
54
|
-
*
|
|
55
|
-
* interaction on one is refused — but they are still rows.
|
|
53
|
+
* Delete orphaned payloads: rows written by a turn that can never save the
|
|
54
|
+
* history that would reference them.
|
|
56
55
|
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
56
|
+
* A turn inserts its payloads before it saves, so an unreferenced row is not
|
|
57
|
+
* evidence of anything by itself — the turn may simply not have saved yet. Nor
|
|
58
|
+
* is an expired lease: a turn slower than its TTL keeps running, and if nobody
|
|
59
|
+
* took the conversation over, its save still succeeds. The only proof that a
|
|
60
|
+
* writer is finished is that the conversation's lease has since been issued to
|
|
61
|
+
* someone else. Every payload records the token its writer held, and a row is
|
|
62
|
+
* swept only when it is unreferenced *and* that token has been superseded.
|
|
63
|
+
*
|
|
64
|
+
* Consequence: a turn that crashed leaves its rows until the conversation is
|
|
65
|
+
* next used, since only then is a new token issued. That is the safe direction.
|
|
66
|
+
* Deleting conversations themselves is retention policy, and is left to you.
|
|
61
67
|
*/
|
|
62
68
|
export declare function sweepOrphans(db: Queryable, options: SweepOptions): Promise<{
|
|
63
69
|
deleted: number;
|
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,EAKL,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;CAClB;AAID;;;GAGG;AACH,eAAO,MAAM,MAAM,EAAE,SAAS,MAAM,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,EAKL,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;CAClB;AAID;;;GAGG;AACH,eAAO,MAAM,MAAM,EAAE,SAAS,MAAM,EAmCnC,CAAC;AAEF,8FAA8F;AAC9F,wBAAsB,OAAO,CAAC,EAAE,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CAI1D;AA8CD,wBAAgB,OAAO,CAAC,EAAE,EAAE,SAAS,EAAE,OAAO,GAAE,cAAmB,GAAG,YAAY,CAkJjF;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
|
@@ -38,26 +38,42 @@ export const schema = [
|
|
|
38
38
|
version integer NOT NULL,
|
|
39
39
|
props jsonb NOT NULL,
|
|
40
40
|
mode text NOT NULL,
|
|
41
|
+
-- the lease the writing turn held. Only a newer token proves that turn can
|
|
42
|
+
-- never save again, which is what makes an unreferenced row an orphan.
|
|
43
|
+
lease_token text NOT NULL,
|
|
41
44
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
45
|
+
-- the surface's freshness window when this rendered, in ms; 'Infinity' is
|
|
46
|
+
-- "never", NULL a payload written before windows existed
|
|
47
|
+
stale_after_ms double precision,
|
|
42
48
|
PRIMARY KEY (conversation_id, handle)
|
|
43
49
|
)`,
|
|
50
|
+
// a table created before freshness windows existed gains the column; its
|
|
51
|
+
// rows read as unknown and are held to whatever the code declares
|
|
52
|
+
`ALTER TABLE haikit_payloads ADD COLUMN IF NOT EXISTS stale_after_ms double precision`,
|
|
44
53
|
`CREATE INDEX IF NOT EXISTS haikit_payloads_created_at ON haikit_payloads (created_at)`,
|
|
45
54
|
];
|
|
46
|
-
/** Create the tables if they do not exist. Idempotent. */
|
|
55
|
+
/** Create the tables if they do not exist, and bring existing ones up to date. Idempotent. */
|
|
47
56
|
export async function migrate(db) {
|
|
48
57
|
// One statement per call: some drivers (PGlite among them) reject several
|
|
49
58
|
// statements in a single parameterised query.
|
|
50
59
|
for (const statement of schema)
|
|
51
60
|
await db.query(statement);
|
|
52
61
|
}
|
|
62
|
+
// jsonb is always read as text and parsed here. Drivers disagree on whether
|
|
63
|
+
// they parse jsonb themselves, and guessing from the result's type cannot work:
|
|
64
|
+
// a jsonb string scalar parsed by the driver is a JS string too, and parsing it
|
|
65
|
+
// a second time throws. Taking text every time removes the guess.
|
|
53
66
|
const CONVERSATION_COLUMNS = `
|
|
54
|
-
id, status, messages, handles
|
|
67
|
+
id, status, messages::text AS messages, handles::text AS handles,
|
|
68
|
+
frozen::text AS frozen, pending::text AS pending, lease_token,
|
|
55
69
|
(extract(epoch FROM lease_until) * 1000)::float8 AS lease_until_ms`;
|
|
70
|
+
// The window is read as text too: 'Infinity' is how "never" is stored, and
|
|
71
|
+
// drivers need not agree on turning it into a number.
|
|
56
72
|
const PAYLOAD_COLUMNS = `
|
|
57
|
-
handle, conversation_id, component, version, props, mode,
|
|
58
|
-
(extract(epoch FROM created_at) * 1000)::float8 AS created_at_ms
|
|
59
|
-
|
|
60
|
-
const json = (
|
|
73
|
+
handle, conversation_id, component, version, props::text AS props, mode,
|
|
74
|
+
(extract(epoch FROM created_at) * 1000)::float8 AS created_at_ms,
|
|
75
|
+
stale_after_ms::text AS stale_after_ms`;
|
|
76
|
+
const json = (text) => JSON.parse(text);
|
|
61
77
|
const toConversation = (row) => ({
|
|
62
78
|
id: row.id,
|
|
63
79
|
status: row.status,
|
|
@@ -76,11 +92,19 @@ const toPayload = (row) => ({
|
|
|
76
92
|
props: json(row.props),
|
|
77
93
|
mode: row.mode,
|
|
78
94
|
createdAt: Number(row.created_at_ms),
|
|
95
|
+
staleAfterMs: row.stale_after_ms == null ? null : row.stale_after_ms === "Infinity" ? "never" : Number(row.stale_after_ms),
|
|
79
96
|
});
|
|
80
97
|
const newToken = () => globalThis.crypto.randomUUID();
|
|
81
98
|
const newId = () => `conv_${globalThis.crypto.randomUUID().replaceAll("-", "")}`;
|
|
82
99
|
export function pgStore(db, options = {}) {
|
|
83
100
|
const leaseMs = options.leaseMs ?? LEASE_MS;
|
|
101
|
+
// Zero or negative makes every lease expire the moment it is taken, so every
|
|
102
|
+
// load acquires and the one-turn-in-flight guarantee silently disappears.
|
|
103
|
+
// Infinity would strand a crashed turn's conversation forever. Refuse both
|
|
104
|
+
// here, loudly, rather than run with no mutual exclusion at all.
|
|
105
|
+
if (!Number.isFinite(leaseMs) || leaseMs <= 0) {
|
|
106
|
+
throw new RangeError(`leaseMs must be a positive, finite number of milliseconds (got ${leaseMs})`);
|
|
107
|
+
}
|
|
84
108
|
/** A fenced write matched nothing. Say which of the two reasons applies. */
|
|
85
109
|
async function stale(conversationId) {
|
|
86
110
|
const { rows } = await db.query(`SELECT 1 FROM haikit_conversations WHERE id = $1`, [conversationId]);
|
|
@@ -150,14 +174,15 @@ export function pgStore(db, options = {}) {
|
|
|
150
174
|
const { rows } = await db.query(`WITH owner AS (
|
|
151
175
|
UPDATE haikit_conversations
|
|
152
176
|
SET handle_seq = handle_seq + 1
|
|
153
|
-
WHERE id = $1::text AND lease_token = $2
|
|
177
|
+
WHERE id = $1::text AND lease_token = $2::text
|
|
154
178
|
RETURNING handle_seq
|
|
155
179
|
)
|
|
156
|
-
INSERT INTO haikit_payloads
|
|
180
|
+
INSERT INTO haikit_payloads
|
|
181
|
+
(conversation_id, handle, component, version, props, mode, lease_token, stale_after_ms)
|
|
157
182
|
SELECT $1::text,
|
|
158
183
|
-- ui_01 … ui_09, ui_10 … ui_99, ui_100: pad, never truncate
|
|
159
184
|
'ui_' || CASE WHEN handle_seq < 10 THEN '0' ELSE '' END || handle_seq,
|
|
160
|
-
$3::text, $4::integer, $5::jsonb, $6::text
|
|
185
|
+
$3::text, $4::integer, $5::jsonb, $6::text, $2::text, $7::float8
|
|
161
186
|
FROM owner
|
|
162
187
|
RETURNING handle`, [
|
|
163
188
|
record.conversationId,
|
|
@@ -166,6 +191,7 @@ export function pgStore(db, options = {}) {
|
|
|
166
191
|
record.version,
|
|
167
192
|
JSON.stringify(record.props),
|
|
168
193
|
record.mode,
|
|
194
|
+
record.staleAfterMs === "never" ? "Infinity" : (record.staleAfterMs ?? null),
|
|
169
195
|
]);
|
|
170
196
|
if (!rows.length)
|
|
171
197
|
await stale(record.conversationId);
|
|
@@ -184,27 +210,40 @@ export function pgStore(db, options = {}) {
|
|
|
184
210
|
const { rows } = await db.query(`SELECT ${PAYLOAD_COLUMNS} FROM haikit_payloads
|
|
185
211
|
WHERE conversation_id = $1
|
|
186
212
|
AND handle IN (SELECT jsonb_array_elements_text($2::jsonb))`, [conversationId, JSON.stringify(handles)]);
|
|
187
|
-
|
|
188
|
-
|
|
213
|
+
// `IN` returns each row once; the contract is one result per input
|
|
214
|
+
// handle, duplicates and order included — map the input, not the rows.
|
|
215
|
+
// Each occurrence is a deep copy, as a separate getPayload would be: a
|
|
216
|
+
// shallow spread would hand two results one shared `props` object.
|
|
217
|
+
const byHandle = new Map(rows.map((row) => [row.handle, toPayload(row)]));
|
|
218
|
+
return handles.flatMap((h) => {
|
|
219
|
+
const record = byHandle.get(h);
|
|
220
|
+
return record ? [structuredClone(record)] : [];
|
|
221
|
+
});
|
|
189
222
|
},
|
|
190
223
|
};
|
|
191
224
|
}
|
|
192
225
|
/**
|
|
193
|
-
* Delete orphaned payloads: rows written by a turn that
|
|
194
|
-
*
|
|
195
|
-
*
|
|
226
|
+
* Delete orphaned payloads: rows written by a turn that can never save the
|
|
227
|
+
* history that would reference them.
|
|
228
|
+
*
|
|
229
|
+
* A turn inserts its payloads before it saves, so an unreferenced row is not
|
|
230
|
+
* evidence of anything by itself — the turn may simply not have saved yet. Nor
|
|
231
|
+
* is an expired lease: a turn slower than its TTL keeps running, and if nobody
|
|
232
|
+
* took the conversation over, its save still succeeds. The only proof that a
|
|
233
|
+
* writer is finished is that the conversation's lease has since been issued to
|
|
234
|
+
* someone else. Every payload records the token its writer held, and a row is
|
|
235
|
+
* swept only when it is unreferenced *and* that token has been superseded.
|
|
196
236
|
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
* conversations themselves is retention policy,
|
|
200
|
-
* left to you.
|
|
237
|
+
* Consequence: a turn that crashed leaves its rows until the conversation is
|
|
238
|
+
* next used, since only then is a new token issued. That is the safe direction.
|
|
239
|
+
* Deleting conversations themselves is retention policy, and is left to you.
|
|
201
240
|
*/
|
|
202
241
|
export async function sweepOrphans(db, options) {
|
|
203
242
|
const { rows } = await db.query(`DELETE FROM haikit_payloads p
|
|
204
243
|
USING haikit_conversations c
|
|
205
244
|
WHERE p.conversation_id = c.id
|
|
206
245
|
AND p.created_at < now() - $1::float8 * interval '1 millisecond'
|
|
207
|
-
AND
|
|
246
|
+
AND p.lease_token IS DISTINCT FROM c.lease_token
|
|
208
247
|
AND NOT (c.handles @> jsonb_build_array(p.handle))
|
|
209
248
|
RETURNING p.handle`, [options.olderThanMs]);
|
|
210
249
|
return { deleted: rows.length };
|
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,GAIX,MAAM,cAAc,CAAC;AAwBtB,MAAM,QAAQ,GAAG,OAAO,CAAC;AAEzB;;;GAGG;AACH,MAAM,CAAC,MAAM,MAAM,GAAsB;IACvC;;;;;;;;;;;KAWG;IACH,6EAA6E;IAC7E,uEAAuE;IACvE
|
|
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.4.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.4.0"
|
|
38
38
|
},
|
|
39
39
|
"publishConfig": {
|
|
40
40
|
"access": "public"
|
package/src/index.ts
CHANGED
|
@@ -69,29 +69,45 @@ export const schema: readonly string[] = [
|
|
|
69
69
|
version integer NOT NULL,
|
|
70
70
|
props jsonb NOT NULL,
|
|
71
71
|
mode text NOT NULL,
|
|
72
|
+
-- the lease the writing turn held. Only a newer token proves that turn can
|
|
73
|
+
-- never save again, which is what makes an unreferenced row an orphan.
|
|
74
|
+
lease_token text NOT NULL,
|
|
72
75
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
76
|
+
-- the surface's freshness window when this rendered, in ms; 'Infinity' is
|
|
77
|
+
-- "never", NULL a payload written before windows existed
|
|
78
|
+
stale_after_ms double precision,
|
|
73
79
|
PRIMARY KEY (conversation_id, handle)
|
|
74
80
|
)`,
|
|
81
|
+
// a table created before freshness windows existed gains the column; its
|
|
82
|
+
// rows read as unknown and are held to whatever the code declares
|
|
83
|
+
`ALTER TABLE haikit_payloads ADD COLUMN IF NOT EXISTS stale_after_ms double precision`,
|
|
75
84
|
`CREATE INDEX IF NOT EXISTS haikit_payloads_created_at ON haikit_payloads (created_at)`,
|
|
76
85
|
];
|
|
77
86
|
|
|
78
|
-
/** Create the tables if they do not exist. Idempotent. */
|
|
87
|
+
/** Create the tables if they do not exist, and bring existing ones up to date. Idempotent. */
|
|
79
88
|
export async function migrate(db: Queryable): Promise<void> {
|
|
80
89
|
// One statement per call: some drivers (PGlite among them) reject several
|
|
81
90
|
// statements in a single parameterised query.
|
|
82
91
|
for (const statement of schema) await db.query(statement);
|
|
83
92
|
}
|
|
84
93
|
|
|
94
|
+
// jsonb is always read as text and parsed here. Drivers disagree on whether
|
|
95
|
+
// they parse jsonb themselves, and guessing from the result's type cannot work:
|
|
96
|
+
// a jsonb string scalar parsed by the driver is a JS string too, and parsing it
|
|
97
|
+
// a second time throws. Taking text every time removes the guess.
|
|
85
98
|
const CONVERSATION_COLUMNS = `
|
|
86
|
-
id, status, messages, handles
|
|
99
|
+
id, status, messages::text AS messages, handles::text AS handles,
|
|
100
|
+
frozen::text AS frozen, pending::text AS pending, lease_token,
|
|
87
101
|
(extract(epoch FROM lease_until) * 1000)::float8 AS lease_until_ms`;
|
|
88
102
|
|
|
103
|
+
// The window is read as text too: 'Infinity' is how "never" is stored, and
|
|
104
|
+
// drivers need not agree on turning it into a number.
|
|
89
105
|
const PAYLOAD_COLUMNS = `
|
|
90
|
-
handle, conversation_id, component, version, props, mode,
|
|
91
|
-
(extract(epoch FROM created_at) * 1000)::float8 AS created_at_ms
|
|
106
|
+
handle, conversation_id, component, version, props::text AS props, mode,
|
|
107
|
+
(extract(epoch FROM created_at) * 1000)::float8 AS created_at_ms,
|
|
108
|
+
stale_after_ms::text AS stale_after_ms`;
|
|
92
109
|
|
|
93
|
-
|
|
94
|
-
const json = (value: unknown) => (typeof value === "string" ? JSON.parse(value) : value);
|
|
110
|
+
const json = (text: string) => JSON.parse(text);
|
|
95
111
|
|
|
96
112
|
const toConversation = (row: any): Conversation => ({
|
|
97
113
|
id: row.id,
|
|
@@ -112,6 +128,8 @@ const toPayload = (row: any): PayloadRecord => ({
|
|
|
112
128
|
props: json(row.props),
|
|
113
129
|
mode: row.mode,
|
|
114
130
|
createdAt: Number(row.created_at_ms),
|
|
131
|
+
staleAfterMs:
|
|
132
|
+
row.stale_after_ms == null ? null : row.stale_after_ms === "Infinity" ? "never" : Number(row.stale_after_ms),
|
|
115
133
|
});
|
|
116
134
|
|
|
117
135
|
const newToken = () => globalThis.crypto.randomUUID();
|
|
@@ -119,6 +137,13 @@ const newId = () => `conv_${globalThis.crypto.randomUUID().replaceAll("-", "")}`
|
|
|
119
137
|
|
|
120
138
|
export function pgStore(db: Queryable, options: PgStoreOptions = {}): StoreAdapter {
|
|
121
139
|
const leaseMs = options.leaseMs ?? LEASE_MS;
|
|
140
|
+
// Zero or negative makes every lease expire the moment it is taken, so every
|
|
141
|
+
// load acquires and the one-turn-in-flight guarantee silently disappears.
|
|
142
|
+
// Infinity would strand a crashed turn's conversation forever. Refuse both
|
|
143
|
+
// here, loudly, rather than run with no mutual exclusion at all.
|
|
144
|
+
if (!Number.isFinite(leaseMs) || leaseMs <= 0) {
|
|
145
|
+
throw new RangeError(`leaseMs must be a positive, finite number of milliseconds (got ${leaseMs})`);
|
|
146
|
+
}
|
|
122
147
|
|
|
123
148
|
/** A fenced write matched nothing. Say which of the two reasons applies. */
|
|
124
149
|
async function stale(conversationId: string): Promise<never> {
|
|
@@ -201,14 +226,15 @@ export function pgStore(db: Queryable, options: PgStoreOptions = {}): StoreAdapt
|
|
|
201
226
|
`WITH owner AS (
|
|
202
227
|
UPDATE haikit_conversations
|
|
203
228
|
SET handle_seq = handle_seq + 1
|
|
204
|
-
WHERE id = $1::text AND lease_token = $2
|
|
229
|
+
WHERE id = $1::text AND lease_token = $2::text
|
|
205
230
|
RETURNING handle_seq
|
|
206
231
|
)
|
|
207
|
-
INSERT INTO haikit_payloads
|
|
232
|
+
INSERT INTO haikit_payloads
|
|
233
|
+
(conversation_id, handle, component, version, props, mode, lease_token, stale_after_ms)
|
|
208
234
|
SELECT $1::text,
|
|
209
235
|
-- ui_01 … ui_09, ui_10 … ui_99, ui_100: pad, never truncate
|
|
210
236
|
'ui_' || CASE WHEN handle_seq < 10 THEN '0' ELSE '' END || handle_seq,
|
|
211
|
-
$3::text, $4::integer, $5::jsonb, $6::text
|
|
237
|
+
$3::text, $4::integer, $5::jsonb, $6::text, $2::text, $7::float8
|
|
212
238
|
FROM owner
|
|
213
239
|
RETURNING handle`,
|
|
214
240
|
[
|
|
@@ -218,6 +244,7 @@ export function pgStore(db: Queryable, options: PgStoreOptions = {}): StoreAdapt
|
|
|
218
244
|
record.version,
|
|
219
245
|
JSON.stringify(record.props),
|
|
220
246
|
record.mode,
|
|
247
|
+
record.staleAfterMs === "never" ? "Infinity" : (record.staleAfterMs ?? null),
|
|
221
248
|
],
|
|
222
249
|
);
|
|
223
250
|
if (!rows.length) await stale(record.conversationId);
|
|
@@ -243,30 +270,43 @@ export function pgStore(db: Queryable, options: PgStoreOptions = {}): StoreAdapt
|
|
|
243
270
|
AND handle IN (SELECT jsonb_array_elements_text($2::jsonb))`,
|
|
244
271
|
[conversationId, JSON.stringify(handles)],
|
|
245
272
|
);
|
|
246
|
-
|
|
247
|
-
|
|
273
|
+
// `IN` returns each row once; the contract is one result per input
|
|
274
|
+
// handle, duplicates and order included — map the input, not the rows.
|
|
275
|
+
// Each occurrence is a deep copy, as a separate getPayload would be: a
|
|
276
|
+
// shallow spread would hand two results one shared `props` object.
|
|
277
|
+
const byHandle = new Map(rows.map((row) => [row.handle, toPayload(row)] as const));
|
|
278
|
+
return handles.flatMap((h) => {
|
|
279
|
+
const record = byHandle.get(h);
|
|
280
|
+
return record ? [structuredClone(record)] : [];
|
|
281
|
+
});
|
|
248
282
|
},
|
|
249
283
|
};
|
|
250
284
|
}
|
|
251
285
|
|
|
252
286
|
export interface SweepOptions {
|
|
253
287
|
/**
|
|
254
|
-
* Only sweep rows at least this old.
|
|
255
|
-
*
|
|
256
|
-
*
|
|
288
|
+
* Only sweep rows at least this old. A grace period for operators, not a
|
|
289
|
+
* safety bound: whether a row is an orphan is decided by lease tokens below,
|
|
290
|
+
* so no value here can delete a payload a turn is still going to reference.
|
|
257
291
|
*/
|
|
258
292
|
olderThanMs: number;
|
|
259
293
|
}
|
|
260
294
|
|
|
261
295
|
/**
|
|
262
|
-
* Delete orphaned payloads: rows written by a turn that
|
|
263
|
-
*
|
|
264
|
-
*
|
|
296
|
+
* Delete orphaned payloads: rows written by a turn that can never save the
|
|
297
|
+
* history that would reference them.
|
|
298
|
+
*
|
|
299
|
+
* A turn inserts its payloads before it saves, so an unreferenced row is not
|
|
300
|
+
* evidence of anything by itself — the turn may simply not have saved yet. Nor
|
|
301
|
+
* is an expired lease: a turn slower than its TTL keeps running, and if nobody
|
|
302
|
+
* took the conversation over, its save still succeeds. The only proof that a
|
|
303
|
+
* writer is finished is that the conversation's lease has since been issued to
|
|
304
|
+
* someone else. Every payload records the token its writer held, and a row is
|
|
305
|
+
* swept only when it is unreferenced *and* that token has been superseded.
|
|
265
306
|
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
* conversations themselves is retention policy,
|
|
269
|
-
* left to you.
|
|
307
|
+
* Consequence: a turn that crashed leaves its rows until the conversation is
|
|
308
|
+
* next used, since only then is a new token issued. That is the safe direction.
|
|
309
|
+
* Deleting conversations themselves is retention policy, and is left to you.
|
|
270
310
|
*/
|
|
271
311
|
export async function sweepOrphans(db: Queryable, options: SweepOptions): Promise<{ deleted: number }> {
|
|
272
312
|
const { rows } = await db.query(
|
|
@@ -274,7 +314,7 @@ export async function sweepOrphans(db: Queryable, options: SweepOptions): Promis
|
|
|
274
314
|
USING haikit_conversations c
|
|
275
315
|
WHERE p.conversation_id = c.id
|
|
276
316
|
AND p.created_at < now() - $1::float8 * interval '1 millisecond'
|
|
277
|
-
AND
|
|
317
|
+
AND p.lease_token IS DISTINCT FROM c.lease_token
|
|
278
318
|
AND NOT (c.handles @> jsonb_build_array(p.handle))
|
|
279
319
|
RETURNING p.handle`,
|
|
280
320
|
[options.olderThanMs],
|