@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 +10 -3
- package/dist/index.d.ts +16 -10
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +44 -17
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +47 -20
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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.
|
|
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,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
|
|
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
|
-
|
|
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
|
-
|
|
188
|
-
|
|
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
|
|
194
|
-
*
|
|
195
|
-
*
|
|
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
|
-
*
|
|
198
|
-
*
|
|
199
|
-
* conversations themselves is retention policy,
|
|
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
|
|
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":"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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
247
|
-
|
|
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.
|
|
255
|
-
*
|
|
256
|
-
*
|
|
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
|
|
263
|
-
*
|
|
264
|
-
*
|
|
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
|
-
*
|
|
267
|
-
*
|
|
268
|
-
* conversations themselves is retention policy,
|
|
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
|
|
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],
|