@makaio/storage-pg 1.0.0-dev-1781260968078
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 +21 -0
- package/README.md +95 -0
- package/dist/client.d.ts +52 -0
- package/dist/engine.d.ts +21 -0
- package/dist/errors.d.ts +27 -0
- package/dist/fts-strategy.d.ts +6 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.mjs +527 -0
- package/dist/migrations.d.ts +58 -0
- package/dist/raw-sql.d.ts +100 -0
- package/drizzle-postgres/0000_famous_ozymandias.sql +457 -0
- package/drizzle-postgres/0001_messages_content_tsv.sql +2 -0
- package/drizzle-postgres/0002_amused_doorman.sql +3 -0
- package/drizzle-postgres/0003_cute_tigra.sql +4 -0
- package/drizzle-postgres/0004_stormy_swarm.sql +2 -0
- package/drizzle-postgres/meta/0000_snapshot.json +3323 -0
- package/drizzle-postgres/meta/0001_snapshot.json +3348 -0
- package/drizzle-postgres/meta/0002_snapshot.json +3387 -0
- package/drizzle-postgres/meta/0003_snapshot.json +3407 -0
- package/drizzle-postgres/meta/0004_snapshot.json +3422 -0
- package/drizzle-postgres/meta/_journal.json +41 -0
- package/package.json +58 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { and, asc, count, eq, getTableColumns, sql } from "drizzle-orm";
|
|
3
|
+
import { brandDatabase, buildFirstUserMessagePreviewQuery, getRawSqlExecutor, quoteSqlIdentifier, readErrorCode, someInCauseChain } from "@makaio/framework/storage/drizzle";
|
|
4
|
+
import { PgDialect } from "drizzle-orm/pg-core";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
|
|
7
|
+
//#region src/raw-sql.ts
|
|
8
|
+
/**
|
|
9
|
+
* Attach the idle-connection error listener to a `pg` Pool.
|
|
10
|
+
*
|
|
11
|
+
* When the backend goes down or a network partition occurs, node-postgres
|
|
12
|
+
* emits an error for every idle pooled connection on the pool itself; without
|
|
13
|
+
* a listener that `'error'` event becomes an uncaught exception and kills the
|
|
14
|
+
* host process. A dropped idle connection is routine (server restart, network
|
|
15
|
+
* blip) and the pool replaces it on the next checkout, so the listener logs
|
|
16
|
+
* the failure and lets the process continue.
|
|
17
|
+
* @param pool - Pool to guard against unhandled idle-connection errors.
|
|
18
|
+
*/
|
|
19
|
+
function attachPgPoolErrorLogger(pool) {
|
|
20
|
+
pool.on("error", (error) => {
|
|
21
|
+
console.error("[makaio:storage-pg] Postgres pool: an idle pooled connection failed (backend restart or network drop); the pool replaces it on next use.", error);
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Create a {@link RawSqlExecutor} over a `pg` Pool.
|
|
26
|
+
*
|
|
27
|
+
* Standalone `run` and `all` calls go through the pool directly. `withSession`
|
|
28
|
+
* checks out a single `pool.connect()` client for the duration of the callback.
|
|
29
|
+
* The client is always released: returned to the pool on success, destroyed via
|
|
30
|
+
* `release(true)` when the callback rejects so a poisoned connection (open or
|
|
31
|
+
* aborted transaction) never re-enters the pool.
|
|
32
|
+
*
|
|
33
|
+
* INVARIANT: raw transaction control (BEGIN/COMMIT/ROLLBACK) may only ever be
|
|
34
|
+
* issued inside {@link RawSqlExecutor.withSession}. On Postgres, standalone
|
|
35
|
+
* `run` goes through the pool — raw BEGIN there would stripe statements across
|
|
36
|
+
* connections as the pool re-assigns them per call.
|
|
37
|
+
*
|
|
38
|
+
* SQL objects are serialized using `PgDialect.sqlToQuery`, which produces
|
|
39
|
+
* `$1`-style positional placeholders compatible with the `pg` driver.
|
|
40
|
+
* @param pool - Postgres pool to execute statements against.
|
|
41
|
+
* @returns Executor delegating to the pool.
|
|
42
|
+
*/
|
|
43
|
+
function createPostgresRawSqlExecutor(pool) {
|
|
44
|
+
const pgDialect = new PgDialect();
|
|
45
|
+
const sessionOver = (target) => ({
|
|
46
|
+
async run(query) {
|
|
47
|
+
const { sql: text, params } = pgDialect.sqlToQuery(query);
|
|
48
|
+
return { rowsAffected: (await target.query(text, params)).rowCount ?? 0 };
|
|
49
|
+
},
|
|
50
|
+
async all(query) {
|
|
51
|
+
const { sql: text, params } = pgDialect.sqlToQuery(query);
|
|
52
|
+
return (await target.query(text, params)).rows;
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
const standalone = sessionOver(pool);
|
|
56
|
+
return {
|
|
57
|
+
dialect: "postgres",
|
|
58
|
+
run: standalone.run,
|
|
59
|
+
all: standalone.all,
|
|
60
|
+
async withSession(fn) {
|
|
61
|
+
const client = await pool.connect();
|
|
62
|
+
try {
|
|
63
|
+
const result = await fn(sessionOver(client));
|
|
64
|
+
client.release();
|
|
65
|
+
return result;
|
|
66
|
+
} catch (error) {
|
|
67
|
+
client.release(true);
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
//#endregion
|
|
75
|
+
//#region src/client.ts
|
|
76
|
+
/**
|
|
77
|
+
* node-postgres driver glue.
|
|
78
|
+
*
|
|
79
|
+
* Delegation target of the engine's `createClient` — deliberately kept off
|
|
80
|
+
* the package barrel: consumers create Postgres clients through the engine
|
|
81
|
+
* registry (`createDatabaseClient` in `@makaio/storage-drizzle/client`),
|
|
82
|
+
* never by calling the driver glue directly.
|
|
83
|
+
* @packageDocumentation
|
|
84
|
+
*/
|
|
85
|
+
/**
|
|
86
|
+
* Production driver loaders: direct dynamic imports resolved relative to this
|
|
87
|
+
* module. Both specifiers are declared dependencies of this package, so the
|
|
88
|
+
* import resolves correctly even under strict installs (pnpm, Yarn PnP).
|
|
89
|
+
*/
|
|
90
|
+
const defaultDriverLoaders = {
|
|
91
|
+
loadPg: () => import("pg"),
|
|
92
|
+
loadDrizzlePg: () => import("drizzle-orm/node-postgres")
|
|
93
|
+
};
|
|
94
|
+
/**
|
|
95
|
+
* Default maximum pool size for Postgres connections.
|
|
96
|
+
*
|
|
97
|
+
* Sized for direct connections to a small managed Postgres tier without an
|
|
98
|
+
* external connection pooler. Callers that need a different limit pass
|
|
99
|
+
* `postgres.poolMax` in `DatabaseClientConfig`.
|
|
100
|
+
*/
|
|
101
|
+
const DEFAULT_PG_POOL_MAX = 4;
|
|
102
|
+
/**
|
|
103
|
+
* Creates a database client backed by the node-postgres (`pg`) driver.
|
|
104
|
+
*
|
|
105
|
+
* Delegation target of the Postgres engine's `createClient`: the engine
|
|
106
|
+
* package owns both the dialect-specific behavior registered through the
|
|
107
|
+
* engine seam and this driver glue.
|
|
108
|
+
*
|
|
109
|
+
* Both `'pg'` and `'drizzle-orm/node-postgres'` load through direct dynamic
|
|
110
|
+
* `import()` calls, so the drivers load on the first Postgres client rather
|
|
111
|
+
* than at module load (laziness is preserved). Resolution happens from this
|
|
112
|
+
* module, which declares both as regular dependencies, so it stays
|
|
113
|
+
* strict-install-safe (pnpm, Yarn PnP) where the drivers are only resolvable
|
|
114
|
+
* from this package. Bundle-time resolvability is acceptable here precisely
|
|
115
|
+
* because they are declared dependencies of this package — the build leaves
|
|
116
|
+
* them external (see `build.ts`); bundler-opacity is only required for
|
|
117
|
+
* specifiers the resolving package does not declare.
|
|
118
|
+
* @param url - Postgres connection URL (`postgres://` or `postgresql://`).
|
|
119
|
+
* @param options - Optional pool tuning options.
|
|
120
|
+
* @param loaders - Internal driver-loader seam; defaults to the literal
|
|
121
|
+
* dynamic imports. Tests inject a throwing loader to exercise the error path.
|
|
122
|
+
* @returns Database client with drizzle ORM instance and async close method.
|
|
123
|
+
*/
|
|
124
|
+
async function createNodePgClient(url, options, loaders = defaultDriverLoaders) {
|
|
125
|
+
let pg;
|
|
126
|
+
let drizzlePg;
|
|
127
|
+
try {
|
|
128
|
+
({default: pg} = await loaders.loadPg());
|
|
129
|
+
({drizzle: drizzlePg} = await loaders.loadDrizzlePg());
|
|
130
|
+
} catch (error) {
|
|
131
|
+
throw new Error("createNodePgClient: failed to load the Postgres driver modules ('pg' and 'drizzle-orm/node-postgres'). 'pg' is a dependency of @makaio/storage-pg — verify the host application's install is intact.", { cause: error });
|
|
132
|
+
}
|
|
133
|
+
const pool = new pg.Pool({
|
|
134
|
+
connectionString: url,
|
|
135
|
+
max: options?.poolMax ?? DEFAULT_PG_POOL_MAX
|
|
136
|
+
});
|
|
137
|
+
attachPgPoolErrorLogger(pool);
|
|
138
|
+
const db = drizzlePg(pool);
|
|
139
|
+
brandDatabase(db, "postgres", createPostgresRawSqlExecutor(pool));
|
|
140
|
+
let closed = false;
|
|
141
|
+
return {
|
|
142
|
+
db,
|
|
143
|
+
dialect: "postgres",
|
|
144
|
+
close: async () => {
|
|
145
|
+
if (closed) return;
|
|
146
|
+
closed = true;
|
|
147
|
+
await pool.end();
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
//#endregion
|
|
153
|
+
//#region src/errors.ts
|
|
154
|
+
/**
|
|
155
|
+
* Postgres-specific classification of database errors.
|
|
156
|
+
*
|
|
157
|
+
* node-postgres surfaces server failures as `DatabaseError` instances carrying
|
|
158
|
+
* the SQLSTATE in a non-standard `code` property (and the violated constraint
|
|
159
|
+
* name in `constraint` for constraint failures). These classifiers walk the
|
|
160
|
+
* cause chain via the shared helpers from `@makaio/storage-drizzle`, so
|
|
161
|
+
* wrapped driver errors classify the same as bare ones.
|
|
162
|
+
* @packageDocumentation
|
|
163
|
+
*/
|
|
164
|
+
/**
|
|
165
|
+
* SQLSTATE codes Postgres raises for an already-existing schema object:
|
|
166
|
+
* `42P07` (duplicate_table) and `42710` (duplicate_object — indexes,
|
|
167
|
+
* triggers, and other named objects).
|
|
168
|
+
*/
|
|
169
|
+
const POSTGRES_DUPLICATE_OBJECT_CODES = new Set(["42P07", "42710"]);
|
|
170
|
+
/**
|
|
171
|
+
* Returns `true` when the error (or any link in its cause chain) reports
|
|
172
|
+
* that a schema object already exists on Postgres.
|
|
173
|
+
*
|
|
174
|
+
* Matches the SQLSTATE codes `42P07`/`42710`. Used by the migration
|
|
175
|
+
* applicator to decide whether a failed first CREATE can be adopted into the
|
|
176
|
+
* ledger.
|
|
177
|
+
* @param error - Error thrown by a DDL statement.
|
|
178
|
+
* @returns Whether the failure is a duplicate-schema-object conflict.
|
|
179
|
+
*/
|
|
180
|
+
function isPostgresDuplicateObjectError(error) {
|
|
181
|
+
return someInCauseChain(error, (link) => {
|
|
182
|
+
const code = readErrorCode(link);
|
|
183
|
+
return code !== void 0 && POSTGRES_DUPLICATE_OBJECT_CODES.has(code);
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Returns `true` when the error (or any link in its cause chain) reports a
|
|
188
|
+
* Postgres unique-constraint violation.
|
|
189
|
+
*
|
|
190
|
+
* Matches SQLSTATE `23505` (unique_violation); when `constraint` is given,
|
|
191
|
+
* the driver error's `constraint` property must match it too, so callers can
|
|
192
|
+
* react to one specific index without swallowing unrelated violations.
|
|
193
|
+
*
|
|
194
|
+
* Used by write paths that resolve write-write races through a bounded retry
|
|
195
|
+
* (for example MAX-based counter assignment under `READ COMMITTED`, where two
|
|
196
|
+
* concurrent statements can compute the same next value).
|
|
197
|
+
* @param error - Error thrown by a DML statement.
|
|
198
|
+
* @param constraint - Optional constraint/index name to scope the match.
|
|
199
|
+
* @returns Whether the failure is a unique-constraint violation.
|
|
200
|
+
*/
|
|
201
|
+
function isPostgresUniqueViolationError(error, constraint) {
|
|
202
|
+
return someInCauseChain(error, (link) => {
|
|
203
|
+
if (readErrorCode(link) !== "23505") return false;
|
|
204
|
+
if (constraint === void 0) return true;
|
|
205
|
+
const { constraint: violated } = link;
|
|
206
|
+
return violated === constraint;
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
//#endregion
|
|
211
|
+
//#region src/fts-strategy.ts
|
|
212
|
+
/**
|
|
213
|
+
* Postgres full-text-search strategy.
|
|
214
|
+
*
|
|
215
|
+
* Postgres full-text search runs over the `messages.content_tsv` stored
|
|
216
|
+
* generated tsvector column (english regconfig) and its GIN index — both ship
|
|
217
|
+
* through the central Postgres migration chain, so provisioning here is a
|
|
218
|
+
* no-op. Message search operates on the messages table object passed in by
|
|
219
|
+
* the caller (the congruent twin resolved per handle); session search and the
|
|
220
|
+
* preview query are raw SQL against the fixed physical names.
|
|
221
|
+
* @packageDocumentation
|
|
222
|
+
*/
|
|
223
|
+
/**
|
|
224
|
+
* Resolve one required column from a table's column map.
|
|
225
|
+
* @param columns - Column map produced by `getTableColumns`.
|
|
226
|
+
* @param name - Property name of the required column.
|
|
227
|
+
* @returns The resolved column.
|
|
228
|
+
* @throws Error naming the missing column.
|
|
229
|
+
*/
|
|
230
|
+
function requireColumn(columns, name) {
|
|
231
|
+
const column = columns[name];
|
|
232
|
+
if (column === void 0) throw new Error(`postgresFtsSearchStrategy: the messages table passed in is missing the '${name}' column`);
|
|
233
|
+
return column;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Resolve the columns the message-search queries need, failing loudly when
|
|
237
|
+
* the passed table is not a messages table.
|
|
238
|
+
* @param messagesTable - Messages table object received from the caller.
|
|
239
|
+
* @returns The three columns referenced by the search queries.
|
|
240
|
+
* @throws Error naming the first missing column.
|
|
241
|
+
*/
|
|
242
|
+
function requireMessageSearchColumns(messagesTable) {
|
|
243
|
+
const columns = getTableColumns(messagesTable);
|
|
244
|
+
return {
|
|
245
|
+
messageId: requireColumn(columns, "messageId"),
|
|
246
|
+
sessionId: requireColumn(columns, "sessionId"),
|
|
247
|
+
timestamp: requireColumn(columns, "timestamp")
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Builds SQL predicates for optional session-search filters.
|
|
252
|
+
*
|
|
253
|
+
* Postgres uses native boolean literals for the import filter.
|
|
254
|
+
* @param input - Optional status/import filters from the search input.
|
|
255
|
+
* @returns SQL fragment appended to WHERE clauses.
|
|
256
|
+
*/
|
|
257
|
+
function buildSearchFilterSql(input) {
|
|
258
|
+
const conditions = [];
|
|
259
|
+
if (input.status !== void 0) conditions.push(sql`s.status = ${input.status}`);
|
|
260
|
+
if (input.isImported !== void 0) conditions.push(input.isImported ? sql`s.is_imported = true` : sql`COALESCE(s.is_imported, false) = false`);
|
|
261
|
+
if (conditions.length === 0) return sql``;
|
|
262
|
+
return sql` AND ${sql.join(conditions, sql` AND `)}`;
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Builds the shared session-match `WHERE` predicate for the session-search and
|
|
266
|
+
* session-count queries.
|
|
267
|
+
*
|
|
268
|
+
* Both queries match a session when any of its messages matches the FTS query
|
|
269
|
+
* OR its title matches the LIKE pattern, narrowed by the optional status/import
|
|
270
|
+
* filters. The predicate is authored once here so the two queries can never
|
|
271
|
+
* drift; the leading-whitespace layout and `query` → `likePattern` → filter
|
|
272
|
+
* parameter order are preserved verbatim from the inlined form, keeping the
|
|
273
|
+
* emitted SQL byte-identical to the previously-pinned behavior.
|
|
274
|
+
* @param input - Session-search/count input carrying the query, LIKE pattern,
|
|
275
|
+
* and optional filters.
|
|
276
|
+
* @returns The `WHERE (...)` predicate fragment including the filter clause.
|
|
277
|
+
*/
|
|
278
|
+
function buildSessionMatchWhere(input) {
|
|
279
|
+
const filterClause = buildSearchFilterSql(input);
|
|
280
|
+
return sql`WHERE (
|
|
281
|
+
EXISTS (
|
|
282
|
+
SELECT 1
|
|
283
|
+
FROM messages m
|
|
284
|
+
WHERE m.session_id = s.session_id
|
|
285
|
+
AND m.content_tsv @@ websearch_to_tsquery('english', ${input.query})
|
|
286
|
+
)
|
|
287
|
+
OR LOWER(s.title) LIKE ${input.likePattern}
|
|
288
|
+
)
|
|
289
|
+
${filterClause}`;
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* The Postgres FTS strategy: tsvector matching via `websearch_to_tsquery`,
|
|
293
|
+
* `ts_rank` ordering, and `ts_headline` excerpts.
|
|
294
|
+
*/
|
|
295
|
+
const postgresFtsSearchStrategy = {
|
|
296
|
+
dialect: "postgres",
|
|
297
|
+
async provisionSearchIndex() {},
|
|
298
|
+
async searchMessages(db, messagesTable, input) {
|
|
299
|
+
const { sessionId, limit } = input;
|
|
300
|
+
const columns = requireMessageSearchColumns(messagesTable);
|
|
301
|
+
const tsQuery = sql`websearch_to_tsquery('english', ${input.query})`;
|
|
302
|
+
const matches = sql`content_tsv @@ ${tsQuery}`;
|
|
303
|
+
const where = sessionId ? and(eq(columns.sessionId, sessionId), matches) : matches;
|
|
304
|
+
const rows = await db.select().from(messagesTable).where(where).orderBy(sql`ts_rank(content_tsv, ${tsQuery}) DESC`, asc(columns.timestamp), asc(columns.messageId)).limit(limit);
|
|
305
|
+
const [countRow] = await db.select({ total: count() }).from(messagesTable).where(where);
|
|
306
|
+
return {
|
|
307
|
+
rows,
|
|
308
|
+
total: countRow?.total ?? 0
|
|
309
|
+
};
|
|
310
|
+
},
|
|
311
|
+
async searchMessageExcerpts(db, messagesTable, input) {
|
|
312
|
+
const { sessionId, limit } = input;
|
|
313
|
+
const columns = requireMessageSearchColumns(messagesTable);
|
|
314
|
+
const tsQuery = sql`websearch_to_tsquery('english', ${input.query.trim()})`;
|
|
315
|
+
const matches = sql`content_tsv @@ ${tsQuery}`;
|
|
316
|
+
const where = sessionId !== void 0 ? and(eq(columns.sessionId, sessionId), matches) : matches;
|
|
317
|
+
const rows = await db.select({
|
|
318
|
+
messageId: columns.messageId,
|
|
319
|
+
sessionId: columns.sessionId,
|
|
320
|
+
score: sql`ts_rank(content_tsv, ${tsQuery})`,
|
|
321
|
+
excerpt: sql`ts_headline('english', content_text, ${tsQuery}, 'StartSel=<mark>, StopSel=</mark>, MaxWords=40, MinWords=15')`
|
|
322
|
+
}).from(messagesTable).where(where).orderBy(sql`ts_rank(content_tsv, ${tsQuery}) DESC`, asc(columns.timestamp), asc(columns.messageId)).limit(limit);
|
|
323
|
+
const [countRow] = await db.select({ total: count() }).from(messagesTable).where(where);
|
|
324
|
+
return {
|
|
325
|
+
results: rows,
|
|
326
|
+
total: countRow?.total ?? 0
|
|
327
|
+
};
|
|
328
|
+
},
|
|
329
|
+
async searchSessionRows(db, input) {
|
|
330
|
+
return getRawSqlExecutor(db).all(sql`
|
|
331
|
+
SELECT DISTINCT
|
|
332
|
+
s.session_id,
|
|
333
|
+
s.created_at::double precision AS created_at,
|
|
334
|
+
s.last_activity_at::double precision AS last_activity_at,
|
|
335
|
+
s.status,
|
|
336
|
+
s.title,
|
|
337
|
+
s.lead_agent_id,
|
|
338
|
+
s.parent_session_id,
|
|
339
|
+
s.root_session_id,
|
|
340
|
+
s.fork_point_message_id,
|
|
341
|
+
s.branch_kind,
|
|
342
|
+
s.adapter_name,
|
|
343
|
+
s.adapter_session_id,
|
|
344
|
+
s.adapter_id,
|
|
345
|
+
s.is_orchestrated::int AS is_orchestrated,
|
|
346
|
+
s.is_imported::int AS is_imported,
|
|
347
|
+
s.summary,
|
|
348
|
+
s.summary_updated_at::double precision AS summary_updated_at,
|
|
349
|
+
s.fork_transforms,
|
|
350
|
+
s.target_working_directory
|
|
351
|
+
FROM sessions s
|
|
352
|
+
${buildSessionMatchWhere(input)}
|
|
353
|
+
ORDER BY last_activity_at DESC
|
|
354
|
+
LIMIT ${input.limit}
|
|
355
|
+
`);
|
|
356
|
+
},
|
|
357
|
+
async countSessionMatches(db, input) {
|
|
358
|
+
const [totalRow] = await getRawSqlExecutor(db).all(sql`
|
|
359
|
+
SELECT COUNT(DISTINCT s.session_id)::int as total
|
|
360
|
+
FROM sessions s
|
|
361
|
+
${buildSessionMatchWhere(input)}
|
|
362
|
+
`);
|
|
363
|
+
return totalRow?.total ?? 0;
|
|
364
|
+
},
|
|
365
|
+
async fetchFirstUserMessagePreviews(db, sessionIds) {
|
|
366
|
+
if (sessionIds.length === 0) return /* @__PURE__ */ new Map();
|
|
367
|
+
const previewRows = await getRawSqlExecutor(db).all(buildFirstUserMessagePreviewQuery(sessionIds, sql`m2.message_id < m.message_id`));
|
|
368
|
+
const previewBySession = /* @__PURE__ */ new Map();
|
|
369
|
+
for (const row of previewRows) previewBySession.set(row.sessionId, row.preview);
|
|
370
|
+
return previewBySession;
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
//#endregion
|
|
375
|
+
//#region src/migrations.ts
|
|
376
|
+
/**
|
|
377
|
+
* Postgres migration behavior: ledger DDL, transaction pinning, and the
|
|
378
|
+
* cross-process advisory lock protocol.
|
|
379
|
+
*
|
|
380
|
+
* The statement texts and key derivations in this module are cross-version
|
|
381
|
+
* contracts — runners built from different framework versions must agree on
|
|
382
|
+
* them byte-for-byte, otherwise concurrent runs stop serializing against each
|
|
383
|
+
* other or stop recognizing each other's ledgers. Every value is pinned by
|
|
384
|
+
* unit tests and exercised by the live conformance coverage in
|
|
385
|
+
* `storage/conformance/src/suites/migration-runner-postgres.test.ts`.
|
|
386
|
+
* @packageDocumentation
|
|
387
|
+
*/
|
|
388
|
+
/**
|
|
389
|
+
* Derive the 64-bit advisory lock key for a migration ledger table.
|
|
390
|
+
*
|
|
391
|
+
* The key is the first 8 bytes (big-endian, signed) of
|
|
392
|
+
* `SHA-256("makaio:migrations:<tableName>")`. The derivation is a
|
|
393
|
+
* cross-version contract: concurrent runners built from different framework
|
|
394
|
+
* versions must compute the same key for the same ledger table, otherwise
|
|
395
|
+
* they stop serializing against each other.
|
|
396
|
+
* @param tableName - Ledger table name the run serializes on.
|
|
397
|
+
* @returns Signed 64-bit key for `pg_advisory_xact_lock`.
|
|
398
|
+
*/
|
|
399
|
+
function migrationAdvisoryLockKey(tableName) {
|
|
400
|
+
return createHash("sha256").update(`makaio:migrations:${tableName}`).digest().readBigInt64BE(0);
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Build the idempotent `CREATE TABLE IF NOT EXISTS` DDL for the Postgres
|
|
404
|
+
* migration ledger table.
|
|
405
|
+
*
|
|
406
|
+
* Uses an identity primary key and additionally enforces hash uniqueness at
|
|
407
|
+
* the schema level: the runner already treats the hash as a migration's
|
|
408
|
+
* identity, and the constraint backstops the advisory-lock serialization —
|
|
409
|
+
* should a cross-process double-record ever slip past it, the insert fails
|
|
410
|
+
* loudly instead of silently corrupting the ledger. The exact statement text
|
|
411
|
+
* is a cross-version contract pinned by tests.
|
|
412
|
+
* @param tableName - Ledger table name (engine default or caller-provided).
|
|
413
|
+
* @returns Complete DDL statement text.
|
|
414
|
+
*/
|
|
415
|
+
function buildPostgresLedgerDdl(tableName) {
|
|
416
|
+
return `CREATE TABLE IF NOT EXISTS ${quoteSqlIdentifier(tableName)} (
|
|
417
|
+
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
418
|
+
hash text NOT NULL UNIQUE,
|
|
419
|
+
created_at numeric
|
|
420
|
+
)`;
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* `BEGIN` statement that opens a Postgres migration transaction.
|
|
424
|
+
*
|
|
425
|
+
* Pins `READ COMMITTED` explicitly instead of inheriting
|
|
426
|
+
* `default_transaction_isolation` (a database/role-settable setting the
|
|
427
|
+
* framework does not control). The migration applicator's in-lock ledger
|
|
428
|
+
* recheck requires a snapshot taken after the advisory lock is acquired;
|
|
429
|
+
* under an ambient `REPEATABLE READ` or `SERIALIZABLE` default the
|
|
430
|
+
* transaction snapshot would be established by the lock SELECT itself —
|
|
431
|
+
* before the lock wait completes — so the recheck would miss a concurrent
|
|
432
|
+
* runner's committed ledger row and re-apply the migration. `READ COMMITTED`
|
|
433
|
+
* takes a fresh snapshot per statement, and commit visibility precedes lock
|
|
434
|
+
* release, so the recheck observes every ledger row committed before the
|
|
435
|
+
* lock was handed over.
|
|
436
|
+
*/
|
|
437
|
+
const POSTGRES_MIGRATION_BEGIN = "BEGIN ISOLATION LEVEL READ COMMITTED";
|
|
438
|
+
/**
|
|
439
|
+
* Acquire the transaction-scoped advisory lock that serializes concurrent
|
|
440
|
+
* migration runners on the same ledger table.
|
|
441
|
+
*
|
|
442
|
+
* Must be called inside an open transaction on the pinned session; the lock
|
|
443
|
+
* releases automatically at COMMIT/ROLLBACK. The key is bound as text and
|
|
444
|
+
* cast server-side: signed 64-bit keys exceed JS number precision and driver
|
|
445
|
+
* BigInt parameter support varies. The caller owns rollback-on-failure — a
|
|
446
|
+
* rejected lock acquisition (lock timeout, administrative cancel during a
|
|
447
|
+
* contended wait) leaves the transaction open for the caller to roll back.
|
|
448
|
+
* @param session - Pinned raw SQL session with the transaction open.
|
|
449
|
+
* @param ledgerTableName - Ledger table name the lock key is derived from.
|
|
450
|
+
* @returns Resolves once the lock is held for the transaction's lifetime.
|
|
451
|
+
*/
|
|
452
|
+
async function acquirePostgresMigrationLock(session, ledgerTableName) {
|
|
453
|
+
const lockKey = migrationAdvisoryLockKey(ledgerTableName).toString();
|
|
454
|
+
await session.run(sql`SELECT pg_advisory_xact_lock(CAST(${lockKey} AS bigint))`);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
//#endregion
|
|
458
|
+
//#region src/engine.ts
|
|
459
|
+
/**
|
|
460
|
+
* The Postgres storage engine definition.
|
|
461
|
+
*
|
|
462
|
+
* The `'postgres'` literals in this module denote the engine's own identity —
|
|
463
|
+
* they are declarations, not branches over dialects.
|
|
464
|
+
* @packageDocumentation
|
|
465
|
+
*/
|
|
466
|
+
/**
|
|
467
|
+
* The Postgres storage engine.
|
|
468
|
+
*
|
|
469
|
+
* Claims `postgres://` and `postgresql://` URLs (case-insensitively,
|
|
470
|
+
* mirroring the engine hint table in `@makaio/storage-drizzle`) and creates
|
|
471
|
+
* clients over the node-postgres driver glue owned by this package (`pg` is
|
|
472
|
+
* a regular dependency, loaded lazily when a client is created). Register it
|
|
473
|
+
* explicitly via `registerStorageEngine` or host boot options; Node runtime hosts
|
|
474
|
+
* additionally auto-register it for recognized database URLs through this
|
|
475
|
+
* package's well-known `storageEngine` export.
|
|
476
|
+
*
|
|
477
|
+
* Migration behavior preserves the cross-version Postgres contracts
|
|
478
|
+
* byte-for-byte (`__makaio_migrations` ledger name and DDL,
|
|
479
|
+
* `BEGIN ISOLATION LEVEL READ COMMITTED`, the advisory-lock key derivation,
|
|
480
|
+
* `__makaio_migrations_<hash>` extension ledgers). The chain directory name
|
|
481
|
+
* `drizzle-postgres` is deliberately distinct from the default `drizzle`
|
|
482
|
+
* directory so embedded-host chain discovery never picks up the Postgres
|
|
483
|
+
* chain.
|
|
484
|
+
*/
|
|
485
|
+
const postgresStorageEngine = {
|
|
486
|
+
dialect: "postgres",
|
|
487
|
+
matchesUrl: (url) => /^postgres(ql)?:\/\//i.test(url),
|
|
488
|
+
async createClient(config) {
|
|
489
|
+
if (config.url === void 0) throw new Error("postgresStorageEngine: a Postgres connection URL is required to create a client. Pass a postgres:// or postgresql:// URL in config.url — the engine never applies a default URL.");
|
|
490
|
+
return createNodePgClient(config.url, config.postgres);
|
|
491
|
+
},
|
|
492
|
+
errors: {
|
|
493
|
+
isDuplicateObjectError: isPostgresDuplicateObjectError,
|
|
494
|
+
isUniqueViolationError: isPostgresUniqueViolationError
|
|
495
|
+
},
|
|
496
|
+
capabilities: {
|
|
497
|
+
binaryColumnType: "bytea",
|
|
498
|
+
maxCounterAssignmentRaces: true,
|
|
499
|
+
async tableExists(executor, tableName) {
|
|
500
|
+
return (await executor.all(sql`SELECT table_name FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = ${tableName}`)).length > 0;
|
|
501
|
+
}
|
|
502
|
+
},
|
|
503
|
+
migrations: {
|
|
504
|
+
defaultLedgerTable: "__makaio_migrations",
|
|
505
|
+
journalDialect: "postgresql",
|
|
506
|
+
chainDirName: "drizzle-postgres",
|
|
507
|
+
resolveSourceChainDir: () => path.resolve(import.meta.dirname, "..", "drizzle-postgres"),
|
|
508
|
+
buildLedgerDdl: buildPostgresLedgerDdl,
|
|
509
|
+
beginTransactionStatement: POSTGRES_MIGRATION_BEGIN,
|
|
510
|
+
acquireTransactionLock: acquirePostgresMigrationLock,
|
|
511
|
+
extensionLedgerName: (sourceHash) => `__makaio_migrations_${sourceHash}`
|
|
512
|
+
},
|
|
513
|
+
fts: postgresFtsSearchStrategy
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
//#endregion
|
|
517
|
+
//#region src/index.ts
|
|
518
|
+
/**
|
|
519
|
+
* Well-known engine export consumed by host URL auto-resolve: runtime hosts
|
|
520
|
+
* that recognize a Postgres database URL import this package and register
|
|
521
|
+
* `storageEngine` with the engine registry. Same object as
|
|
522
|
+
* {@link postgresStorageEngine}.
|
|
523
|
+
*/
|
|
524
|
+
const storageEngine = postgresStorageEngine;
|
|
525
|
+
|
|
526
|
+
//#endregion
|
|
527
|
+
export { POSTGRES_MIGRATION_BEGIN, buildPostgresLedgerDdl, isPostgresDuplicateObjectError, isPostgresUniqueViolationError, migrationAdvisoryLockKey, postgresFtsSearchStrategy, postgresStorageEngine, storageEngine };
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { type RawSqlSession } from '@makaio/framework/storage/drizzle';
|
|
2
|
+
/**
|
|
3
|
+
* Derive the 64-bit advisory lock key for a migration ledger table.
|
|
4
|
+
*
|
|
5
|
+
* The key is the first 8 bytes (big-endian, signed) of
|
|
6
|
+
* `SHA-256("makaio:migrations:<tableName>")`. The derivation is a
|
|
7
|
+
* cross-version contract: concurrent runners built from different framework
|
|
8
|
+
* versions must compute the same key for the same ledger table, otherwise
|
|
9
|
+
* they stop serializing against each other.
|
|
10
|
+
* @param tableName - Ledger table name the run serializes on.
|
|
11
|
+
* @returns Signed 64-bit key for `pg_advisory_xact_lock`.
|
|
12
|
+
*/
|
|
13
|
+
export declare function migrationAdvisoryLockKey(tableName: string): bigint;
|
|
14
|
+
/**
|
|
15
|
+
* Build the idempotent `CREATE TABLE IF NOT EXISTS` DDL for the Postgres
|
|
16
|
+
* migration ledger table.
|
|
17
|
+
*
|
|
18
|
+
* Uses an identity primary key and additionally enforces hash uniqueness at
|
|
19
|
+
* the schema level: the runner already treats the hash as a migration's
|
|
20
|
+
* identity, and the constraint backstops the advisory-lock serialization —
|
|
21
|
+
* should a cross-process double-record ever slip past it, the insert fails
|
|
22
|
+
* loudly instead of silently corrupting the ledger. The exact statement text
|
|
23
|
+
* is a cross-version contract pinned by tests.
|
|
24
|
+
* @param tableName - Ledger table name (engine default or caller-provided).
|
|
25
|
+
* @returns Complete DDL statement text.
|
|
26
|
+
*/
|
|
27
|
+
export declare function buildPostgresLedgerDdl(tableName: string): string;
|
|
28
|
+
/**
|
|
29
|
+
* `BEGIN` statement that opens a Postgres migration transaction.
|
|
30
|
+
*
|
|
31
|
+
* Pins `READ COMMITTED` explicitly instead of inheriting
|
|
32
|
+
* `default_transaction_isolation` (a database/role-settable setting the
|
|
33
|
+
* framework does not control). The migration applicator's in-lock ledger
|
|
34
|
+
* recheck requires a snapshot taken after the advisory lock is acquired;
|
|
35
|
+
* under an ambient `REPEATABLE READ` or `SERIALIZABLE` default the
|
|
36
|
+
* transaction snapshot would be established by the lock SELECT itself —
|
|
37
|
+
* before the lock wait completes — so the recheck would miss a concurrent
|
|
38
|
+
* runner's committed ledger row and re-apply the migration. `READ COMMITTED`
|
|
39
|
+
* takes a fresh snapshot per statement, and commit visibility precedes lock
|
|
40
|
+
* release, so the recheck observes every ledger row committed before the
|
|
41
|
+
* lock was handed over.
|
|
42
|
+
*/
|
|
43
|
+
export declare const POSTGRES_MIGRATION_BEGIN = "BEGIN ISOLATION LEVEL READ COMMITTED";
|
|
44
|
+
/**
|
|
45
|
+
* Acquire the transaction-scoped advisory lock that serializes concurrent
|
|
46
|
+
* migration runners on the same ledger table.
|
|
47
|
+
*
|
|
48
|
+
* Must be called inside an open transaction on the pinned session; the lock
|
|
49
|
+
* releases automatically at COMMIT/ROLLBACK. The key is bound as text and
|
|
50
|
+
* cast server-side: signed 64-bit keys exceed JS number precision and driver
|
|
51
|
+
* BigInt parameter support varies. The caller owns rollback-on-failure — a
|
|
52
|
+
* rejected lock acquisition (lock timeout, administrative cancel during a
|
|
53
|
+
* contended wait) leaves the transaction open for the caller to roll back.
|
|
54
|
+
* @param session - Pinned raw SQL session with the transaction open.
|
|
55
|
+
* @param ledgerTableName - Ledger table name the lock key is derived from.
|
|
56
|
+
* @returns Resolves once the lock is held for the transaction's lifetime.
|
|
57
|
+
*/
|
|
58
|
+
export declare function acquirePostgresMigrationLock(session: RawSqlSession, ledgerTableName: string): Promise<void>;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { RawSqlExecutor } from '@makaio/framework/storage/drizzle';
|
|
2
|
+
/**
|
|
3
|
+
* Structural result shape of a node-postgres `query()` call.
|
|
4
|
+
*
|
|
5
|
+
* Self-owned so the published declaration file never references `import('pg')`.
|
|
6
|
+
*/
|
|
7
|
+
export interface PostgresQueryResultLike {
|
|
8
|
+
/** Result rows, each keyed by column name. */
|
|
9
|
+
rows: Array<Record<string, unknown>>;
|
|
10
|
+
/** Number of rows affected (INSERT/UPDATE/DELETE), or `null` when not applicable. */
|
|
11
|
+
rowCount: number | null;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Structural surface shared by a `pg` Pool and a checked-out pool client.
|
|
15
|
+
*
|
|
16
|
+
* Self-owned so the published declaration file never references `import('pg')`.
|
|
17
|
+
*/
|
|
18
|
+
export interface PostgresQueryableLike {
|
|
19
|
+
/**
|
|
20
|
+
* Execute a parameterized SQL statement.
|
|
21
|
+
* @param text - SQL text with `$1`-style positional placeholders.
|
|
22
|
+
* @param params - Positional parameter values corresponding to the placeholders.
|
|
23
|
+
* @returns Query result with rows and affected-row count.
|
|
24
|
+
*/
|
|
25
|
+
query(text: string, params: unknown[]): Promise<PostgresQueryResultLike>;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Structural surface of a checked-out `pg` pool client.
|
|
29
|
+
*
|
|
30
|
+
* Self-owned so the published declaration file never references `import('pg')`.
|
|
31
|
+
*/
|
|
32
|
+
export interface PostgresPoolClientLike extends PostgresQueryableLike {
|
|
33
|
+
/**
|
|
34
|
+
* Return the connection to the pool.
|
|
35
|
+
* @param destroy - When truthy, destroy the connection instead of returning it.
|
|
36
|
+
* Pass `true` after a failed callback to prevent a poisoned connection from
|
|
37
|
+
* re-entering the pool.
|
|
38
|
+
*/
|
|
39
|
+
release(destroy?: boolean): void;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Structural surface of a `pg` Pool.
|
|
43
|
+
*
|
|
44
|
+
* Self-owned so the published declaration file never references `import('pg')`.
|
|
45
|
+
*/
|
|
46
|
+
export interface PostgresPoolLike extends PostgresQueryableLike {
|
|
47
|
+
/**
|
|
48
|
+
* Check out a dedicated connection from the pool.
|
|
49
|
+
* @returns A pool client pinned to one server connection.
|
|
50
|
+
*/
|
|
51
|
+
connect(): Promise<PostgresPoolClientLike>;
|
|
52
|
+
/**
|
|
53
|
+
* Drain the pool and close all connections.
|
|
54
|
+
* Called once during client shutdown.
|
|
55
|
+
* @returns Promise that resolves when the pool has fully closed.
|
|
56
|
+
*/
|
|
57
|
+
end(): Promise<void>;
|
|
58
|
+
/**
|
|
59
|
+
* Subscribe to pool events. Only the `'error'` event is part of this
|
|
60
|
+
* structural surface: node-postgres re-emits failures of idle pooled
|
|
61
|
+
* connections on the pool's own event emitter, and an `'error'` event
|
|
62
|
+
* without a listener escalates to an uncaught exception that terminates
|
|
63
|
+
* the process.
|
|
64
|
+
* @param event - Pool event name; only `'error'` is consumed here.
|
|
65
|
+
* @param listener - Listener invoked with the emitted error.
|
|
66
|
+
*/
|
|
67
|
+
on(event: 'error', listener: (error: Error) => void): void;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Attach the idle-connection error listener to a `pg` Pool.
|
|
71
|
+
*
|
|
72
|
+
* When the backend goes down or a network partition occurs, node-postgres
|
|
73
|
+
* emits an error for every idle pooled connection on the pool itself; without
|
|
74
|
+
* a listener that `'error'` event becomes an uncaught exception and kills the
|
|
75
|
+
* host process. A dropped idle connection is routine (server restart, network
|
|
76
|
+
* blip) and the pool replaces it on the next checkout, so the listener logs
|
|
77
|
+
* the failure and lets the process continue.
|
|
78
|
+
* @param pool - Pool to guard against unhandled idle-connection errors.
|
|
79
|
+
*/
|
|
80
|
+
export declare function attachPgPoolErrorLogger(pool: Pick<PostgresPoolLike, 'on'>): void;
|
|
81
|
+
/**
|
|
82
|
+
* Create a {@link RawSqlExecutor} over a `pg` Pool.
|
|
83
|
+
*
|
|
84
|
+
* Standalone `run` and `all` calls go through the pool directly. `withSession`
|
|
85
|
+
* checks out a single `pool.connect()` client for the duration of the callback.
|
|
86
|
+
* The client is always released: returned to the pool on success, destroyed via
|
|
87
|
+
* `release(true)` when the callback rejects so a poisoned connection (open or
|
|
88
|
+
* aborted transaction) never re-enters the pool.
|
|
89
|
+
*
|
|
90
|
+
* INVARIANT: raw transaction control (BEGIN/COMMIT/ROLLBACK) may only ever be
|
|
91
|
+
* issued inside {@link RawSqlExecutor.withSession}. On Postgres, standalone
|
|
92
|
+
* `run` goes through the pool — raw BEGIN there would stripe statements across
|
|
93
|
+
* connections as the pool re-assigns them per call.
|
|
94
|
+
*
|
|
95
|
+
* SQL objects are serialized using `PgDialect.sqlToQuery`, which produces
|
|
96
|
+
* `$1`-style positional placeholders compatible with the `pg` driver.
|
|
97
|
+
* @param pool - Postgres pool to execute statements against.
|
|
98
|
+
* @returns Executor delegating to the pool.
|
|
99
|
+
*/
|
|
100
|
+
export declare function createPostgresRawSqlExecutor(pool: PostgresPoolLike): RawSqlExecutor;
|