@haikit/postgres 0.3.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 hai contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,64 @@
1
+ # @haikit/postgres
2
+
3
+ A durable `StoreAdapter` for haikit. Swap it in for `memoryStore()` before you
4
+ ship: a parked `elicit` turn is durable state, and a conversation that loses it
5
+ can never be sent again.
6
+
7
+ ```ts
8
+ import pg from "pg";
9
+ import { createHai } from "@haikit/server";
10
+ import { pgStore, migrate } from "@haikit/postgres";
11
+
12
+ const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
13
+ await migrate(pool);
14
+
15
+ const hai = createHai({ store: pgStore(pool), model, tools, surfaces, system });
16
+ ```
17
+
18
+ ## Bring your own driver
19
+
20
+ `pgStore` takes anything with `query(text, params) → Promise<{ rows }>`. A
21
+ `pg.Pool`, a `pg.Client` and PGlite all fit as they are, so this package depends
22
+ on `@haikit/core` and nothing else — pooling, TLS and connection lifecycle stay
23
+ yours.
24
+
25
+ ## Why it is safe to run on several instances
26
+
27
+ - **One statement per operation.** Every fenced write checks the turn's lease
28
+ token inside the same `UPDATE` or `INSERT … SELECT` that performs it. There is
29
+ no read-then-write anywhere, so there is nothing to race and no transaction to
30
+ forget.
31
+ - **The database's clock.** Lease expiry is computed with `now()`, never
32
+ `Date.now()`, so clock skew between app servers cannot hand one conversation
33
+ to two of them.
34
+ - **Write-once payloads.** Everything about a surface that changes over a
35
+ conversation lives on the fenced conversation row, so no payload write can
36
+ disagree with the history that references it.
37
+
38
+ The test suite runs twenty real connections at one released lease and requires
39
+ exactly one to win.
40
+
41
+ ## Schema
42
+
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`.
46
+
47
+ Handles are numbered per conversation, so every conversation's digests start at
48
+ `ui_01`.
49
+
50
+ ## Cleaning up
51
+
52
+ A turn that is overtaken mid-flight can leave payload rows that the surviving
53
+ history never references. They are inert — an interaction on one is refused —
54
+ but they are still rows:
55
+
56
+ ```ts
57
+ import { sweepOrphans } from "@haikit/postgres";
58
+
59
+ await sweepOrphans(pool, { olderThanMs: 24 * 60 * 60 * 1000 });
60
+ ```
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.
@@ -0,0 +1,65 @@
1
+ /**
2
+ * @haikit/postgres — a durable StoreAdapter.
3
+ *
4
+ * Every method is a single SQL statement, and every fenced write checks the
5
+ * lease token *inside* that statement. That is the whole trick: the contract in
6
+ * `@haikit/core` requires the check and the write to be atomic, and a
7
+ * conditional UPDATE is atomic without a transaction. There is no
8
+ * read-then-write anywhere in this file, and none should be added.
9
+ *
10
+ * Time comes from the database (`now()`), never from `Date.now()`, so lease
11
+ * expiry cannot be skewed by clocks on different app servers.
12
+ */
13
+ import { type StoreAdapter } from "@haikit/core";
14
+ /**
15
+ * Anything that runs a parameterised statement and returns rows. A `pg.Pool`,
16
+ * a `pg.Client` and PGlite all satisfy this as they are — the driver is yours,
17
+ * and so are pooling and connection lifecycle.
18
+ *
19
+ * Only `rows` is required. Every fenced statement here uses `RETURNING` and
20
+ * checks the rows it got back rather than a driver's affected-row count,
21
+ * because drivers disagree on what that field is called.
22
+ */
23
+ export interface Queryable {
24
+ query(text: string, params?: unknown[]): Promise<{
25
+ rows: any[];
26
+ }>;
27
+ }
28
+ export interface PgStoreOptions {
29
+ /**
30
+ * Turn lease TTL. A process that dies mid-turn strands its conversation for
31
+ * this long; too short and a slow turn is overtaken while still running —
32
+ * which is safe (its writes are fenced out) but wasteful.
33
+ */
34
+ leaseMs?: number;
35
+ }
36
+ /**
37
+ * The schema, one statement per entry. `migrate()` runs these; export them to
38
+ * your own migration tool instead if you have one.
39
+ */
40
+ export declare const schema: readonly string[];
41
+ /** Create the tables if they do not exist. Idempotent. */
42
+ export declare function migrate(db: Queryable): Promise<void>;
43
+ export declare function pgStore(db: Queryable, options?: PgStoreOptions): StoreAdapter;
44
+ export interface SweepOptions {
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.
49
+ */
50
+ olderThanMs: number;
51
+ }
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.
56
+ *
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.
61
+ */
62
+ export declare function sweepOrphans(db: Queryable, options: SweepOptions): Promise<{
63
+ deleted: number;
64
+ }>;
65
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +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"}
package/dist/index.js ADDED
@@ -0,0 +1,212 @@
1
+ /**
2
+ * @haikit/postgres — a durable StoreAdapter.
3
+ *
4
+ * Every method is a single SQL statement, and every fenced write checks the
5
+ * lease token *inside* that statement. That is the whole trick: the contract in
6
+ * `@haikit/core` requires the check and the write to be atomic, and a
7
+ * conditional UPDATE is atomic without a transaction. There is no
8
+ * read-then-write anywhere in this file, and none should be added.
9
+ *
10
+ * Time comes from the database (`now()`), never from `Date.now()`, so lease
11
+ * expiry cannot be skewed by clocks on different app servers.
12
+ */
13
+ import { ConversationBusy, StaleLease, } from "@haikit/core";
14
+ const LEASE_MS = 120_000;
15
+ /**
16
+ * The schema, one statement per entry. `migrate()` runs these; export them to
17
+ * your own migration tool instead if you have one.
18
+ */
19
+ export const schema = [
20
+ `CREATE TABLE IF NOT EXISTS haikit_conversations (
21
+ id text PRIMARY KEY,
22
+ status text NOT NULL,
23
+ messages jsonb NOT NULL DEFAULT '[]',
24
+ handles jsonb NOT NULL DEFAULT '[]',
25
+ frozen jsonb NOT NULL DEFAULT '[]',
26
+ pending jsonb,
27
+ lease_until timestamptz,
28
+ lease_token text,
29
+ handle_seq integer NOT NULL DEFAULT 0,
30
+ updated_at timestamptz NOT NULL DEFAULT now()
31
+ )`,
32
+ // Write-once. Nothing about a payload changes after insert — everything that
33
+ // does lives on the fenced conversation row (see Conversation.frozen).
34
+ `CREATE TABLE IF NOT EXISTS haikit_payloads (
35
+ conversation_id text NOT NULL REFERENCES haikit_conversations(id) ON DELETE CASCADE,
36
+ handle text NOT NULL,
37
+ component text NOT NULL,
38
+ version integer NOT NULL,
39
+ props jsonb NOT NULL,
40
+ mode text NOT NULL,
41
+ created_at timestamptz NOT NULL DEFAULT now(),
42
+ PRIMARY KEY (conversation_id, handle)
43
+ )`,
44
+ `CREATE INDEX IF NOT EXISTS haikit_payloads_created_at ON haikit_payloads (created_at)`,
45
+ ];
46
+ /** Create the tables if they do not exist. Idempotent. */
47
+ export async function migrate(db) {
48
+ // One statement per call: some drivers (PGlite among them) reject several
49
+ // statements in a single parameterised query.
50
+ for (const statement of schema)
51
+ await db.query(statement);
52
+ }
53
+ const CONVERSATION_COLUMNS = `
54
+ id, status, messages, handles, frozen, pending, lease_token,
55
+ (extract(epoch FROM lease_until) * 1000)::float8 AS lease_until_ms`;
56
+ 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);
61
+ const toConversation = (row) => ({
62
+ id: row.id,
63
+ status: row.status,
64
+ messages: json(row.messages),
65
+ handles: json(row.handles),
66
+ frozen: json(row.frozen),
67
+ pending: row.pending == null ? null : json(row.pending),
68
+ leaseUntil: row.lease_until_ms == null ? null : Number(row.lease_until_ms),
69
+ leaseToken: row.lease_token,
70
+ });
71
+ const toPayload = (row) => ({
72
+ handle: row.handle,
73
+ conversationId: row.conversation_id,
74
+ component: row.component,
75
+ version: Number(row.version),
76
+ props: json(row.props),
77
+ mode: row.mode,
78
+ createdAt: Number(row.created_at_ms),
79
+ });
80
+ const newToken = () => globalThis.crypto.randomUUID();
81
+ const newId = () => `conv_${globalThis.crypto.randomUUID().replaceAll("-", "")}`;
82
+ export function pgStore(db, options = {}) {
83
+ const leaseMs = options.leaseMs ?? LEASE_MS;
84
+ /** A fenced write matched nothing. Say which of the two reasons applies. */
85
+ async function stale(conversationId) {
86
+ const { rows } = await db.query(`SELECT 1 FROM haikit_conversations WHERE id = $1`, [conversationId]);
87
+ throw rows.length
88
+ ? new StaleLease(conversationId)
89
+ : new StaleLease(conversationId, "does not exist — no lease was ever issued for it");
90
+ }
91
+ return {
92
+ async loadConversation(id) {
93
+ if (id) {
94
+ // Acquisition in one statement: the expiry check and the new token are
95
+ // the same UPDATE, so two instances cannot both see "expired" and both
96
+ // acquire. The row lock serialises them; the loser re-evaluates the
97
+ // WHERE clause against the winner's committed lease and matches nothing.
98
+ const { rows } = await db.query(`UPDATE haikit_conversations
99
+ SET lease_until = now() + $2::float8 * interval '1 millisecond',
100
+ lease_token = $3
101
+ WHERE id = $1 AND (lease_until IS NULL OR lease_until <= now())
102
+ RETURNING ${CONVERSATION_COLUMNS}`, [id, leaseMs, newToken()]);
103
+ if (rows[0])
104
+ return toConversation(rows[0]);
105
+ // Nothing matched: either someone holds the lease, or there is no such
106
+ // conversation. This second read only picks the error — it decides
107
+ // nothing about who holds the lease.
108
+ const exists = await db.query(`SELECT 1 FROM haikit_conversations WHERE id = $1`, [id]);
109
+ if (exists.rows.length)
110
+ throw new ConversationBusy(id);
111
+ // An unknown id starts a fresh conversation, as memoryStore does.
112
+ }
113
+ const { rows } = await db.query(`INSERT INTO haikit_conversations (id, status, lease_until, lease_token)
114
+ VALUES ($1, 'idle', now() + $2::float8 * interval '1 millisecond', $3)
115
+ RETURNING ${CONVERSATION_COLUMNS}`, [newId(), leaseMs, newToken()]);
116
+ return toConversation(rows[0]);
117
+ },
118
+ async saveConversation(conversation) {
119
+ // Compare-and-set on the token. A null token or an unknown id matches no
120
+ // row (NULL = x is never true), which is exactly the contract: saving is
121
+ // not an upsert, and a superseded holder cannot overwrite the winner.
122
+ const { rows } = await db.query(`UPDATE haikit_conversations
123
+ SET status = $3,
124
+ messages = $4::jsonb,
125
+ handles = $5::jsonb,
126
+ frozen = $6::jsonb,
127
+ pending = $7::jsonb,
128
+ lease_until = to_timestamp($8::float8 / 1000),
129
+ updated_at = now()
130
+ WHERE id = $1 AND lease_token = $2
131
+ RETURNING id`, [
132
+ conversation.id,
133
+ conversation.leaseToken,
134
+ conversation.status,
135
+ JSON.stringify(conversation.messages),
136
+ JSON.stringify(conversation.handles),
137
+ JSON.stringify(conversation.frozen),
138
+ // SQL NULL, not the JSON value null — keep "no pending turn" queryable
139
+ conversation.pending == null ? null : JSON.stringify(conversation.pending),
140
+ conversation.leaseUntil,
141
+ ]);
142
+ if (!rows.length)
143
+ await stale(conversation.id);
144
+ },
145
+ async putPayload(record, leaseToken) {
146
+ // Fence, number and insert in one statement. The CTE's UPDATE both checks
147
+ // the token and takes the next handle number under the row lock, so
148
+ // concurrent renders in one conversation never share a handle. If the
149
+ // token does not match, `owner` is empty and nothing is inserted.
150
+ const { rows } = await db.query(`WITH owner AS (
151
+ UPDATE haikit_conversations
152
+ SET handle_seq = handle_seq + 1
153
+ WHERE id = $1::text AND lease_token = $2
154
+ RETURNING handle_seq
155
+ )
156
+ INSERT INTO haikit_payloads (conversation_id, handle, component, version, props, mode)
157
+ SELECT $1::text,
158
+ -- ui_01 … ui_09, ui_10 … ui_99, ui_100: pad, never truncate
159
+ 'ui_' || CASE WHEN handle_seq < 10 THEN '0' ELSE '' END || handle_seq,
160
+ $3::text, $4::integer, $5::jsonb, $6::text
161
+ FROM owner
162
+ RETURNING handle`, [
163
+ record.conversationId,
164
+ leaseToken,
165
+ record.component,
166
+ record.version,
167
+ JSON.stringify(record.props),
168
+ record.mode,
169
+ ]);
170
+ if (!rows.length)
171
+ await stale(record.conversationId);
172
+ return rows[0].handle;
173
+ },
174
+ /** Scoped: a handle is only meaningful inside its own conversation. */
175
+ async getPayload(handle, conversationId) {
176
+ const { rows } = await db.query(`SELECT ${PAYLOAD_COLUMNS} FROM haikit_payloads WHERE conversation_id = $1 AND handle = $2`, [conversationId, handle]);
177
+ return rows[0] ? toPayload(rows[0]) : null;
178
+ },
179
+ async getPayloads(handles, conversationId) {
180
+ if (!handles.length)
181
+ return [];
182
+ // The list travels as jsonb rather than a native array parameter, which
183
+ // not every driver encodes the same way.
184
+ const { rows } = await db.query(`SELECT ${PAYLOAD_COLUMNS} FROM haikit_payloads
185
+ WHERE conversation_id = $1
186
+ 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));
189
+ },
190
+ };
191
+ }
192
+ /**
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.
196
+ *
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.
201
+ */
202
+ export async function sweepOrphans(db, options) {
203
+ const { rows } = await db.query(`DELETE FROM haikit_payloads p
204
+ USING haikit_conversations c
205
+ WHERE p.conversation_id = c.id
206
+ AND p.created_at < now() - $1::float8 * interval '1 millisecond'
207
+ AND (c.lease_until IS NULL OR c.lease_until <= now())
208
+ AND NOT (c.handles @> jsonb_build_array(p.handle))
209
+ RETURNING p.handle`, [options.olderThanMs]);
210
+ return { deleted: rows.length };
211
+ }
212
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@haikit/postgres",
3
+ "version": "0.3.0",
4
+ "description": "Postgres store adapter for haikit: durable conversations, fenced turn leases, write-once payloads. Bring your own driver.",
5
+ "keywords": [
6
+ "haikit",
7
+ "postgres",
8
+ "postgresql",
9
+ "llm",
10
+ "agent",
11
+ "agentic-ui",
12
+ "store",
13
+ "durable"
14
+ ],
15
+ "type": "module",
16
+ "main": "./dist/index.js",
17
+ "types": "./dist/index.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "types": "./dist/index.d.ts",
21
+ "default": "./dist/index.js"
22
+ },
23
+ "./package.json": "./package.json"
24
+ },
25
+ "files": [
26
+ "dist",
27
+ "src"
28
+ ],
29
+ "sideEffects": false,
30
+ "engines": {
31
+ "node": ">=22"
32
+ },
33
+ "scripts": {
34
+ "prepack": "npm --prefix ../.. run build"
35
+ },
36
+ "dependencies": {
37
+ "@haikit/core": "0.2.0"
38
+ },
39
+ "publishConfig": {
40
+ "access": "public"
41
+ },
42
+ "license": "MIT",
43
+ "homepage": "https://github.com/wfoxd/haikit/tree/main/packages/postgres#readme",
44
+ "bugs": {
45
+ "url": "https://github.com/wfoxd/haikit/issues"
46
+ },
47
+ "repository": {
48
+ "type": "git",
49
+ "url": "git+https://github.com/wfoxd/haikit.git",
50
+ "directory": "packages/postgres"
51
+ }
52
+ }
package/src/index.ts ADDED
@@ -0,0 +1,283 @@
1
+ /**
2
+ * @haikit/postgres — a durable StoreAdapter.
3
+ *
4
+ * Every method is a single SQL statement, and every fenced write checks the
5
+ * lease token *inside* that statement. That is the whole trick: the contract in
6
+ * `@haikit/core` requires the check and the write to be atomic, and a
7
+ * conditional UPDATE is atomic without a transaction. There is no
8
+ * read-then-write anywhere in this file, and none should be added.
9
+ *
10
+ * Time comes from the database (`now()`), never from `Date.now()`, so lease
11
+ * expiry cannot be skewed by clocks on different app servers.
12
+ */
13
+
14
+ import {
15
+ ConversationBusy,
16
+ StaleLease,
17
+ type Conversation,
18
+ type PayloadRecord,
19
+ type StoreAdapter,
20
+ } from "@haikit/core";
21
+
22
+ /**
23
+ * Anything that runs a parameterised statement and returns rows. A `pg.Pool`,
24
+ * a `pg.Client` and PGlite all satisfy this as they are — the driver is yours,
25
+ * and so are pooling and connection lifecycle.
26
+ *
27
+ * Only `rows` is required. Every fenced statement here uses `RETURNING` and
28
+ * checks the rows it got back rather than a driver's affected-row count,
29
+ * because drivers disagree on what that field is called.
30
+ */
31
+ export interface Queryable {
32
+ query(text: string, params?: unknown[]): Promise<{ rows: any[] }>;
33
+ }
34
+
35
+ export interface PgStoreOptions {
36
+ /**
37
+ * Turn lease TTL. A process that dies mid-turn strands its conversation for
38
+ * this long; too short and a slow turn is overtaken while still running —
39
+ * which is safe (its writes are fenced out) but wasteful.
40
+ */
41
+ leaseMs?: number;
42
+ }
43
+
44
+ const LEASE_MS = 120_000;
45
+
46
+ /**
47
+ * The schema, one statement per entry. `migrate()` runs these; export them to
48
+ * your own migration tool instead if you have one.
49
+ */
50
+ export const schema: readonly string[] = [
51
+ `CREATE TABLE IF NOT EXISTS haikit_conversations (
52
+ id text PRIMARY KEY,
53
+ status text NOT NULL,
54
+ messages jsonb NOT NULL DEFAULT '[]',
55
+ handles jsonb NOT NULL DEFAULT '[]',
56
+ frozen jsonb NOT NULL DEFAULT '[]',
57
+ pending jsonb,
58
+ lease_until timestamptz,
59
+ lease_token text,
60
+ handle_seq integer NOT NULL DEFAULT 0,
61
+ updated_at timestamptz NOT NULL DEFAULT now()
62
+ )`,
63
+ // Write-once. Nothing about a payload changes after insert — everything that
64
+ // does lives on the fenced conversation row (see Conversation.frozen).
65
+ `CREATE TABLE IF NOT EXISTS haikit_payloads (
66
+ conversation_id text NOT NULL REFERENCES haikit_conversations(id) ON DELETE CASCADE,
67
+ handle text NOT NULL,
68
+ component text NOT NULL,
69
+ version integer NOT NULL,
70
+ props jsonb NOT NULL,
71
+ mode text NOT NULL,
72
+ created_at timestamptz NOT NULL DEFAULT now(),
73
+ PRIMARY KEY (conversation_id, handle)
74
+ )`,
75
+ `CREATE INDEX IF NOT EXISTS haikit_payloads_created_at ON haikit_payloads (created_at)`,
76
+ ];
77
+
78
+ /** Create the tables if they do not exist. Idempotent. */
79
+ export async function migrate(db: Queryable): Promise<void> {
80
+ // One statement per call: some drivers (PGlite among them) reject several
81
+ // statements in a single parameterised query.
82
+ for (const statement of schema) await db.query(statement);
83
+ }
84
+
85
+ const CONVERSATION_COLUMNS = `
86
+ id, status, messages, handles, frozen, pending, lease_token,
87
+ (extract(epoch FROM lease_until) * 1000)::float8 AS lease_until_ms`;
88
+
89
+ const PAYLOAD_COLUMNS = `
90
+ handle, conversation_id, component, version, props, mode,
91
+ (extract(epoch FROM created_at) * 1000)::float8 AS created_at_ms`;
92
+
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);
95
+
96
+ const toConversation = (row: any): Conversation => ({
97
+ id: row.id,
98
+ status: row.status,
99
+ messages: json(row.messages),
100
+ handles: json(row.handles),
101
+ frozen: json(row.frozen),
102
+ pending: row.pending == null ? null : json(row.pending),
103
+ leaseUntil: row.lease_until_ms == null ? null : Number(row.lease_until_ms),
104
+ leaseToken: row.lease_token,
105
+ });
106
+
107
+ const toPayload = (row: any): PayloadRecord => ({
108
+ handle: row.handle,
109
+ conversationId: row.conversation_id,
110
+ component: row.component,
111
+ version: Number(row.version),
112
+ props: json(row.props),
113
+ mode: row.mode,
114
+ createdAt: Number(row.created_at_ms),
115
+ });
116
+
117
+ const newToken = () => globalThis.crypto.randomUUID();
118
+ const newId = () => `conv_${globalThis.crypto.randomUUID().replaceAll("-", "")}`;
119
+
120
+ export function pgStore(db: Queryable, options: PgStoreOptions = {}): StoreAdapter {
121
+ const leaseMs = options.leaseMs ?? LEASE_MS;
122
+
123
+ /** A fenced write matched nothing. Say which of the two reasons applies. */
124
+ async function stale(conversationId: string): Promise<never> {
125
+ const { rows } = await db.query(`SELECT 1 FROM haikit_conversations WHERE id = $1`, [conversationId]);
126
+ throw rows.length
127
+ ? new StaleLease(conversationId)
128
+ : new StaleLease(conversationId, "does not exist — no lease was ever issued for it");
129
+ }
130
+
131
+ return {
132
+ async loadConversation(id) {
133
+ if (id) {
134
+ // Acquisition in one statement: the expiry check and the new token are
135
+ // the same UPDATE, so two instances cannot both see "expired" and both
136
+ // acquire. The row lock serialises them; the loser re-evaluates the
137
+ // WHERE clause against the winner's committed lease and matches nothing.
138
+ const { rows } = await db.query(
139
+ `UPDATE haikit_conversations
140
+ SET lease_until = now() + $2::float8 * interval '1 millisecond',
141
+ lease_token = $3
142
+ WHERE id = $1 AND (lease_until IS NULL OR lease_until <= now())
143
+ RETURNING ${CONVERSATION_COLUMNS}`,
144
+ [id, leaseMs, newToken()],
145
+ );
146
+ if (rows[0]) return toConversation(rows[0]);
147
+
148
+ // Nothing matched: either someone holds the lease, or there is no such
149
+ // conversation. This second read only picks the error — it decides
150
+ // nothing about who holds the lease.
151
+ const exists = await db.query(`SELECT 1 FROM haikit_conversations WHERE id = $1`, [id]);
152
+ if (exists.rows.length) throw new ConversationBusy(id);
153
+ // An unknown id starts a fresh conversation, as memoryStore does.
154
+ }
155
+
156
+ const { rows } = await db.query(
157
+ `INSERT INTO haikit_conversations (id, status, lease_until, lease_token)
158
+ VALUES ($1, 'idle', now() + $2::float8 * interval '1 millisecond', $3)
159
+ RETURNING ${CONVERSATION_COLUMNS}`,
160
+ [newId(), leaseMs, newToken()],
161
+ );
162
+ return toConversation(rows[0]);
163
+ },
164
+
165
+ async saveConversation(conversation) {
166
+ // Compare-and-set on the token. A null token or an unknown id matches no
167
+ // row (NULL = x is never true), which is exactly the contract: saving is
168
+ // not an upsert, and a superseded holder cannot overwrite the winner.
169
+ const { rows } = await db.query(
170
+ `UPDATE haikit_conversations
171
+ SET status = $3,
172
+ messages = $4::jsonb,
173
+ handles = $5::jsonb,
174
+ frozen = $6::jsonb,
175
+ pending = $7::jsonb,
176
+ lease_until = to_timestamp($8::float8 / 1000),
177
+ updated_at = now()
178
+ WHERE id = $1 AND lease_token = $2
179
+ RETURNING id`,
180
+ [
181
+ conversation.id,
182
+ conversation.leaseToken,
183
+ conversation.status,
184
+ JSON.stringify(conversation.messages),
185
+ JSON.stringify(conversation.handles),
186
+ JSON.stringify(conversation.frozen),
187
+ // SQL NULL, not the JSON value null — keep "no pending turn" queryable
188
+ conversation.pending == null ? null : JSON.stringify(conversation.pending),
189
+ conversation.leaseUntil,
190
+ ],
191
+ );
192
+ if (!rows.length) await stale(conversation.id);
193
+ },
194
+
195
+ async putPayload(record, leaseToken) {
196
+ // Fence, number and insert in one statement. The CTE's UPDATE both checks
197
+ // the token and takes the next handle number under the row lock, so
198
+ // concurrent renders in one conversation never share a handle. If the
199
+ // token does not match, `owner` is empty and nothing is inserted.
200
+ const { rows } = await db.query(
201
+ `WITH owner AS (
202
+ UPDATE haikit_conversations
203
+ SET handle_seq = handle_seq + 1
204
+ WHERE id = $1::text AND lease_token = $2
205
+ RETURNING handle_seq
206
+ )
207
+ INSERT INTO haikit_payloads (conversation_id, handle, component, version, props, mode)
208
+ SELECT $1::text,
209
+ -- ui_01 … ui_09, ui_10 … ui_99, ui_100: pad, never truncate
210
+ 'ui_' || CASE WHEN handle_seq < 10 THEN '0' ELSE '' END || handle_seq,
211
+ $3::text, $4::integer, $5::jsonb, $6::text
212
+ FROM owner
213
+ RETURNING handle`,
214
+ [
215
+ record.conversationId,
216
+ leaseToken,
217
+ record.component,
218
+ record.version,
219
+ JSON.stringify(record.props),
220
+ record.mode,
221
+ ],
222
+ );
223
+ if (!rows.length) await stale(record.conversationId);
224
+ return rows[0].handle;
225
+ },
226
+
227
+ /** Scoped: a handle is only meaningful inside its own conversation. */
228
+ async getPayload(handle, conversationId) {
229
+ const { rows } = await db.query(
230
+ `SELECT ${PAYLOAD_COLUMNS} FROM haikit_payloads WHERE conversation_id = $1 AND handle = $2`,
231
+ [conversationId, handle],
232
+ );
233
+ return rows[0] ? toPayload(rows[0]) : null;
234
+ },
235
+
236
+ async getPayloads(handles, conversationId) {
237
+ if (!handles.length) return [];
238
+ // The list travels as jsonb rather than a native array parameter, which
239
+ // not every driver encodes the same way.
240
+ const { rows } = await db.query(
241
+ `SELECT ${PAYLOAD_COLUMNS} FROM haikit_payloads
242
+ WHERE conversation_id = $1
243
+ AND handle IN (SELECT jsonb_array_elements_text($2::jsonb))`,
244
+ [conversationId, JSON.stringify(handles)],
245
+ );
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)!);
248
+ },
249
+ };
250
+ }
251
+
252
+ export interface SweepOptions {
253
+ /**
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.
257
+ */
258
+ olderThanMs: number;
259
+ }
260
+
261
+ /**
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.
265
+ *
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.
270
+ */
271
+ export async function sweepOrphans(db: Queryable, options: SweepOptions): Promise<{ deleted: number }> {
272
+ const { rows } = await db.query(
273
+ `DELETE FROM haikit_payloads p
274
+ USING haikit_conversations c
275
+ WHERE p.conversation_id = c.id
276
+ AND p.created_at < now() - $1::float8 * interval '1 millisecond'
277
+ AND (c.lease_until IS NULL OR c.lease_until <= now())
278
+ AND NOT (c.handles @> jsonb_build_array(p.handle))
279
+ RETURNING p.handle`,
280
+ [options.olderThanMs],
281
+ );
282
+ return { deleted: rows.length };
283
+ }