@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 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 own migration tool, the statements
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
- 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.
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. 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,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, frozen, pending, lease_token,
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
- /** Most drivers parse jsonb into values; a few hand back text. Accept both. */
60
- const json = (value) => (typeof value === "string" ? JSON.parse(value) : value);
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 (conversation_id, handle, component, version, props, mode)
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
- 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));
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 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.
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
- * 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.
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 (c.lease_until IS NULL OR c.lease_until <= now())
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":"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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.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.2.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, frozen, pending, lease_token,
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
- /** 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);
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 (conversation_id, handle, component, version, props, mode)
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
- 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)!);
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. 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.
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 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.
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
- * 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.
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 (c.lease_until IS NULL OR c.lease_until <= now())
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],