@haikit/postgres 0.3.0 → 0.3.1

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 CHANGED
@@ -59,6 +59,13 @@ import { sweepOrphans } from "@haikit/postgres";
59
59
  await sweepOrphans(pool, { olderThanMs: 24 * 60 * 60 * 1000 });
60
60
  ```
61
61
 
62
- Keep `olderThanMs` above your lease TTL. Conversations with a turn in flight are
63
- skipped regardless. Deleting whole conversations is retention policy, not
64
- garbage collection, and is left to you.
62
+ A row is swept only when no history references it **and** the turn that wrote
63
+ it can never save again — which is proven by the conversation's lease having
64
+ been issued to someone else since. An expired lease is not enough: a turn slower
65
+ than its TTL keeps running, and if nobody took over, its save still succeeds.
66
+ So `olderThanMs` is a grace period, not a safety bound; no value of it can
67
+ delete a payload a turn is still going to reference.
68
+
69
+ A turn that crashed leaves its rows until the conversation is next used, since
70
+ only then is a new token issued. Deleting whole conversations is retention
71
+ policy, not garbage collection, and is left to you.
package/dist/index.d.ts CHANGED
@@ -43,21 +43,27 @@ 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. Keep it above your lease TTL: a turn
47
- * inserts its payloads before it saves the history that references them, so
48
- * a young row can look orphaned while its turn is still running.
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 was then overtaken, so
54
- * the surviving history never recorded their handles. They are inert — an
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
- * Conversations with a live lease are skipped regardless of age, so an
58
- * in-flight turn's not-yet-recorded payloads are never touched. Deleting
59
- * conversations themselves is retention policy, not garbage collection, and is
60
- * left to you.
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;
@@ -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,EA0BnC,CAAC;AAEF,0DAA0D;AAC1D,wBAAsB,OAAO,CAAC,EAAE,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CAI1D;AAqCD,wBAAgB,OAAO,CAAC,EAAE,EAAE,SAAS,EAAE,OAAO,GAAE,cAAmB,GAAG,YAAY,CAkIjF;AAED,MAAM,WAAW,YAAY;IAC3B;;;;OAIG;IACH,WAAW,EAAE,MAAM,CAAC;CACrB;AAED;;;;;;;;;GASG;AACH,wBAAsB,YAAY,CAAC,EAAE,EAAE,SAAS,EAAE,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC;IAAE,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC,CAYrG"}
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,EA6BnC,CAAC;AAEF,0DAA0D;AAC1D,wBAAsB,OAAO,CAAC,EAAE,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CAI1D;AAyCD,wBAAgB,OAAO,CAAC,EAAE,EAAE,SAAS,EAAE,OAAO,GAAE,cAAmB,GAAG,YAAY,CAgJjF;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,6 +38,9 @@ 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(),
42
45
  PRIMARY KEY (conversation_id, handle)
43
46
  )`,
@@ -50,14 +53,18 @@ export async function migrate(db) {
50
53
  for (const statement of schema)
51
54
  await db.query(statement);
52
55
  }
56
+ // jsonb is always read as text and parsed here. Drivers disagree on whether
57
+ // they parse jsonb themselves, and guessing from the result's type cannot work:
58
+ // a jsonb string scalar parsed by the driver is a JS string too, and parsing it
59
+ // a second time throws. Taking text every time removes the guess.
53
60
  const CONVERSATION_COLUMNS = `
54
- id, status, messages, handles, frozen, pending, lease_token,
61
+ id, status, messages::text AS messages, handles::text AS handles,
62
+ frozen::text AS frozen, pending::text AS pending, lease_token,
55
63
  (extract(epoch FROM lease_until) * 1000)::float8 AS lease_until_ms`;
56
64
  const PAYLOAD_COLUMNS = `
57
- handle, conversation_id, component, version, props, mode,
65
+ handle, conversation_id, component, version, props::text AS props, mode,
58
66
  (extract(epoch FROM created_at) * 1000)::float8 AS created_at_ms`;
59
- /** Most drivers parse jsonb into values; a few hand back text. Accept both. */
60
- const json = (value) => (typeof value === "string" ? JSON.parse(value) : value);
67
+ const json = (text) => JSON.parse(text);
61
68
  const toConversation = (row) => ({
62
69
  id: row.id,
63
70
  status: row.status,
@@ -81,6 +88,13 @@ const newToken = () => globalThis.crypto.randomUUID();
81
88
  const newId = () => `conv_${globalThis.crypto.randomUUID().replaceAll("-", "")}`;
82
89
  export function pgStore(db, options = {}) {
83
90
  const leaseMs = options.leaseMs ?? LEASE_MS;
91
+ // Zero or negative makes every lease expire the moment it is taken, so every
92
+ // load acquires and the one-turn-in-flight guarantee silently disappears.
93
+ // Infinity would strand a crashed turn's conversation forever. Refuse both
94
+ // here, loudly, rather than run with no mutual exclusion at all.
95
+ if (!Number.isFinite(leaseMs) || leaseMs <= 0) {
96
+ throw new RangeError(`leaseMs must be a positive, finite number of milliseconds (got ${leaseMs})`);
97
+ }
84
98
  /** A fenced write matched nothing. Say which of the two reasons applies. */
85
99
  async function stale(conversationId) {
86
100
  const { rows } = await db.query(`SELECT 1 FROM haikit_conversations WHERE id = $1`, [conversationId]);
@@ -150,14 +164,14 @@ export function pgStore(db, options = {}) {
150
164
  const { rows } = await db.query(`WITH owner AS (
151
165
  UPDATE haikit_conversations
152
166
  SET handle_seq = handle_seq + 1
153
- WHERE id = $1::text AND lease_token = $2
167
+ WHERE id = $1::text AND lease_token = $2::text
154
168
  RETURNING handle_seq
155
169
  )
156
- INSERT INTO haikit_payloads (conversation_id, handle, component, version, props, mode)
170
+ INSERT INTO haikit_payloads (conversation_id, handle, component, version, props, mode, lease_token)
157
171
  SELECT $1::text,
158
172
  -- ui_01 … ui_09, ui_10 … ui_99, ui_100: pad, never truncate
159
173
  'ui_' || CASE WHEN handle_seq < 10 THEN '0' ELSE '' END || handle_seq,
160
- $3::text, $4::integer, $5::jsonb, $6::text
174
+ $3::text, $4::integer, $5::jsonb, $6::text, $2::text
161
175
  FROM owner
162
176
  RETURNING handle`, [
163
177
  record.conversationId,
@@ -184,27 +198,40 @@ export function pgStore(db, options = {}) {
184
198
  const { rows } = await db.query(`SELECT ${PAYLOAD_COLUMNS} FROM haikit_payloads
185
199
  WHERE conversation_id = $1
186
200
  AND handle IN (SELECT jsonb_array_elements_text($2::jsonb))`, [conversationId, JSON.stringify(handles)]);
187
- const order = new Map(handles.map((h, i) => [h, i]));
188
- return rows.map(toPayload).sort((a, b) => order.get(a.handle) - order.get(b.handle));
201
+ // `IN` returns each row once; the contract is one result per input
202
+ // handle, duplicates and order included — map the input, not the rows.
203
+ // Each occurrence is a deep copy, as a separate getPayload would be: a
204
+ // shallow spread would hand two results one shared `props` object.
205
+ const byHandle = new Map(rows.map((row) => [row.handle, toPayload(row)]));
206
+ return handles.flatMap((h) => {
207
+ const record = byHandle.get(h);
208
+ return record ? [structuredClone(record)] : [];
209
+ });
189
210
  },
190
211
  };
191
212
  }
192
213
  /**
193
- * Delete orphaned payloads: rows written by a turn that was then overtaken, so
194
- * the surviving history never recorded their handles. They are inert — an
195
- * interaction on one is refused — but they are still rows.
214
+ * Delete orphaned payloads: rows written by a turn that can never save the
215
+ * history that would reference them.
216
+ *
217
+ * A turn inserts its payloads before it saves, so an unreferenced row is not
218
+ * evidence of anything by itself — the turn may simply not have saved yet. Nor
219
+ * is an expired lease: a turn slower than its TTL keeps running, and if nobody
220
+ * took the conversation over, its save still succeeds. The only proof that a
221
+ * writer is finished is that the conversation's lease has since been issued to
222
+ * someone else. Every payload records the token its writer held, and a row is
223
+ * swept only when it is unreferenced *and* that token has been superseded.
196
224
  *
197
- * Conversations with a live lease are skipped regardless of age, so an
198
- * in-flight turn's not-yet-recorded payloads are never touched. Deleting
199
- * conversations themselves is retention policy, not garbage collection, and is
200
- * left to you.
225
+ * Consequence: a turn that crashed leaves its rows until the conversation is
226
+ * next used, since only then is a new token issued. That is the safe direction.
227
+ * Deleting conversations themselves is retention policy, and is left to you.
201
228
  */
202
229
  export async function sweepOrphans(db, options) {
203
230
  const { rows } = await db.query(`DELETE FROM haikit_payloads p
204
231
  USING haikit_conversations c
205
232
  WHERE p.conversation_id = c.id
206
233
  AND p.created_at < now() - $1::float8 * interval '1 millisecond'
207
- AND (c.lease_until IS NULL OR c.lease_until <= now())
234
+ AND p.lease_token IS DISTINCT FROM c.lease_token
208
235
  AND NOT (c.handles @> jsonb_build_array(p.handle))
209
236
  RETURNING p.handle`, [options.olderThanMs]);
210
237
  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":"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"}
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.0",
3
+ "version": "0.3.1",
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.2.0"
37
+ "@haikit/core": "0.3.1"
38
38
  },
39
39
  "publishConfig": {
40
40
  "access": "public"
package/src/index.ts CHANGED
@@ -69,6 +69,9 @@ 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(),
73
76
  PRIMARY KEY (conversation_id, handle)
74
77
  )`,
@@ -82,16 +85,20 @@ export async function migrate(db: Queryable): Promise<void> {
82
85
  for (const statement of schema) await db.query(statement);
83
86
  }
84
87
 
88
+ // jsonb is always read as text and parsed here. Drivers disagree on whether
89
+ // they parse jsonb themselves, and guessing from the result's type cannot work:
90
+ // a jsonb string scalar parsed by the driver is a JS string too, and parsing it
91
+ // a second time throws. Taking text every time removes the guess.
85
92
  const CONVERSATION_COLUMNS = `
86
- id, status, messages, handles, frozen, pending, lease_token,
93
+ id, status, messages::text AS messages, handles::text AS handles,
94
+ frozen::text AS frozen, pending::text AS pending, lease_token,
87
95
  (extract(epoch FROM lease_until) * 1000)::float8 AS lease_until_ms`;
88
96
 
89
97
  const PAYLOAD_COLUMNS = `
90
- handle, conversation_id, component, version, props, mode,
98
+ handle, conversation_id, component, version, props::text AS props, mode,
91
99
  (extract(epoch FROM created_at) * 1000)::float8 AS created_at_ms`;
92
100
 
93
- /** Most drivers parse jsonb into values; a few hand back text. Accept both. */
94
- const json = (value: unknown) => (typeof value === "string" ? JSON.parse(value) : value);
101
+ const json = (text: string) => JSON.parse(text);
95
102
 
96
103
  const toConversation = (row: any): Conversation => ({
97
104
  id: row.id,
@@ -119,6 +126,13 @@ const newId = () => `conv_${globalThis.crypto.randomUUID().replaceAll("-", "")}`
119
126
 
120
127
  export function pgStore(db: Queryable, options: PgStoreOptions = {}): StoreAdapter {
121
128
  const leaseMs = options.leaseMs ?? LEASE_MS;
129
+ // Zero or negative makes every lease expire the moment it is taken, so every
130
+ // load acquires and the one-turn-in-flight guarantee silently disappears.
131
+ // Infinity would strand a crashed turn's conversation forever. Refuse both
132
+ // here, loudly, rather than run with no mutual exclusion at all.
133
+ if (!Number.isFinite(leaseMs) || leaseMs <= 0) {
134
+ throw new RangeError(`leaseMs must be a positive, finite number of milliseconds (got ${leaseMs})`);
135
+ }
122
136
 
123
137
  /** A fenced write matched nothing. Say which of the two reasons applies. */
124
138
  async function stale(conversationId: string): Promise<never> {
@@ -201,14 +215,14 @@ export function pgStore(db: Queryable, options: PgStoreOptions = {}): StoreAdapt
201
215
  `WITH owner AS (
202
216
  UPDATE haikit_conversations
203
217
  SET handle_seq = handle_seq + 1
204
- WHERE id = $1::text AND lease_token = $2
218
+ WHERE id = $1::text AND lease_token = $2::text
205
219
  RETURNING handle_seq
206
220
  )
207
- INSERT INTO haikit_payloads (conversation_id, handle, component, version, props, mode)
221
+ INSERT INTO haikit_payloads (conversation_id, handle, component, version, props, mode, lease_token)
208
222
  SELECT $1::text,
209
223
  -- ui_01 … ui_09, ui_10 … ui_99, ui_100: pad, never truncate
210
224
  'ui_' || CASE WHEN handle_seq < 10 THEN '0' ELSE '' END || handle_seq,
211
- $3::text, $4::integer, $5::jsonb, $6::text
225
+ $3::text, $4::integer, $5::jsonb, $6::text, $2::text
212
226
  FROM owner
213
227
  RETURNING handle`,
214
228
  [
@@ -243,30 +257,43 @@ export function pgStore(db: Queryable, options: PgStoreOptions = {}): StoreAdapt
243
257
  AND handle IN (SELECT jsonb_array_elements_text($2::jsonb))`,
244
258
  [conversationId, JSON.stringify(handles)],
245
259
  );
246
- const order = new Map(handles.map((h, i) => [h, i] as const));
247
- return rows.map(toPayload).sort((a, b) => order.get(a.handle)! - order.get(b.handle)!);
260
+ // `IN` returns each row once; the contract is one result per input
261
+ // handle, duplicates and order included — map the input, not the rows.
262
+ // Each occurrence is a deep copy, as a separate getPayload would be: a
263
+ // shallow spread would hand two results one shared `props` object.
264
+ const byHandle = new Map(rows.map((row) => [row.handle, toPayload(row)] as const));
265
+ return handles.flatMap((h) => {
266
+ const record = byHandle.get(h);
267
+ return record ? [structuredClone(record)] : [];
268
+ });
248
269
  },
249
270
  };
250
271
  }
251
272
 
252
273
  export interface SweepOptions {
253
274
  /**
254
- * Only sweep rows at least this old. Keep it above your lease TTL: a turn
255
- * inserts its payloads before it saves the history that references them, so
256
- * a young row can look orphaned while its turn is still running.
275
+ * Only sweep rows at least this old. A grace period for operators, not a
276
+ * safety bound: whether a row is an orphan is decided by lease tokens below,
277
+ * so no value here can delete a payload a turn is still going to reference.
257
278
  */
258
279
  olderThanMs: number;
259
280
  }
260
281
 
261
282
  /**
262
- * Delete orphaned payloads: rows written by a turn that was then overtaken, so
263
- * the surviving history never recorded their handles. They are inert — an
264
- * interaction on one is refused — but they are still rows.
283
+ * Delete orphaned payloads: rows written by a turn that can never save the
284
+ * history that would reference them.
285
+ *
286
+ * A turn inserts its payloads before it saves, so an unreferenced row is not
287
+ * evidence of anything by itself — the turn may simply not have saved yet. Nor
288
+ * is an expired lease: a turn slower than its TTL keeps running, and if nobody
289
+ * took the conversation over, its save still succeeds. The only proof that a
290
+ * writer is finished is that the conversation's lease has since been issued to
291
+ * someone else. Every payload records the token its writer held, and a row is
292
+ * swept only when it is unreferenced *and* that token has been superseded.
265
293
  *
266
- * Conversations with a live lease are skipped regardless of age, so an
267
- * in-flight turn's not-yet-recorded payloads are never touched. Deleting
268
- * conversations themselves is retention policy, not garbage collection, and is
269
- * left to you.
294
+ * Consequence: a turn that crashed leaves its rows until the conversation is
295
+ * next used, since only then is a new token issued. That is the safe direction.
296
+ * Deleting conversations themselves is retention policy, and is left to you.
270
297
  */
271
298
  export async function sweepOrphans(db: Queryable, options: SweepOptions): Promise<{ deleted: number }> {
272
299
  const { rows } = await db.query(
@@ -274,7 +301,7 @@ export async function sweepOrphans(db: Queryable, options: SweepOptions): Promis
274
301
  USING haikit_conversations c
275
302
  WHERE p.conversation_id = c.id
276
303
  AND p.created_at < now() - $1::float8 * interval '1 millisecond'
277
- AND (c.lease_until IS NULL OR c.lease_until <= now())
304
+ AND p.lease_token IS DISTINCT FROM c.lease_token
278
305
  AND NOT (c.handles @> jsonb_build_array(p.handle))
279
306
  RETURNING p.handle`,
280
307
  [options.olderThanMs],