@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/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;