@smart-data-engines/sde 0.1.0-dev.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.
Files changed (131) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +13 -0
  3. package/README.md +153 -0
  4. package/bin/weather.mjs +32 -0
  5. package/dist/_usage.d.ts +30 -0
  6. package/dist/_usage.js +194 -0
  7. package/dist/_usage.js.map +1 -0
  8. package/dist/bulk.d.ts +9 -0
  9. package/dist/bulk.js +83 -0
  10. package/dist/bulk.js.map +1 -0
  11. package/dist/canonical.d.ts +46 -0
  12. package/dist/canonical.js +150 -0
  13. package/dist/canonical.js.map +1 -0
  14. package/dist/capabilities.d.ts +48 -0
  15. package/dist/capabilities.js +62 -0
  16. package/dist/capabilities.js.map +1 -0
  17. package/dist/cutover.d.ts +36 -0
  18. package/dist/cutover.js +219 -0
  19. package/dist/cutover.js.map +1 -0
  20. package/dist/demo/model.d.ts +28 -0
  21. package/dist/demo/model.js +40 -0
  22. package/dist/demo/model.js.map +1 -0
  23. package/dist/demo/project.d.ts +19 -0
  24. package/dist/demo/project.js +128 -0
  25. package/dist/demo/project.js.map +1 -0
  26. package/dist/demo/weather.d.ts +73 -0
  27. package/dist/demo/weather.js +334 -0
  28. package/dist/demo/weather.js.map +1 -0
  29. package/dist/engines/_clickhouse-connection.d.ts +17 -0
  30. package/dist/engines/_clickhouse-connection.js +182 -0
  31. package/dist/engines/_clickhouse-connection.js.map +1 -0
  32. package/dist/engines/_tls-peer-identity.d.ts +2 -0
  33. package/dist/engines/_tls-peer-identity.js +23 -0
  34. package/dist/engines/_tls-peer-identity.js.map +1 -0
  35. package/dist/engines/_write-fences.d.ts +51 -0
  36. package/dist/engines/_write-fences.js +189 -0
  37. package/dist/engines/_write-fences.js.map +1 -0
  38. package/dist/engines/clickhouse.d.ts +193 -0
  39. package/dist/engines/clickhouse.js +899 -0
  40. package/dist/engines/clickhouse.js.map +1 -0
  41. package/dist/engines/postgres.d.ts +293 -0
  42. package/dist/engines/postgres.js +981 -0
  43. package/dist/engines/postgres.js.map +1 -0
  44. package/dist/errors.d.ts +89 -0
  45. package/dist/errors.js +90 -0
  46. package/dist/errors.js.map +1 -0
  47. package/dist/frozen-verification.d.ts +26 -0
  48. package/dist/frozen-verification.js +67 -0
  49. package/dist/frozen-verification.js.map +1 -0
  50. package/dist/generation.d.ts +34 -0
  51. package/dist/generation.js +81 -0
  52. package/dist/generation.js.map +1 -0
  53. package/dist/groups.d.ts +17 -0
  54. package/dist/groups.js +66 -0
  55. package/dist/groups.js.map +1 -0
  56. package/dist/hashing.d.ts +68 -0
  57. package/dist/hashing.js +146 -0
  58. package/dist/hashing.js.map +1 -0
  59. package/dist/in-place-index.d.ts +43 -0
  60. package/dist/in-place-index.js +272 -0
  61. package/dist/in-place-index.js.map +1 -0
  62. package/dist/index.d.ts +79 -0
  63. package/dist/index.js +64 -0
  64. package/dist/index.js.map +1 -0
  65. package/dist/inspection.d.ts +19 -0
  66. package/dist/inspection.js +31 -0
  67. package/dist/inspection.js.map +1 -0
  68. package/dist/internal.d.ts +42 -0
  69. package/dist/internal.js +56 -0
  70. package/dist/internal.js.map +1 -0
  71. package/dist/layout.d.ts +36 -0
  72. package/dist/layout.js +62 -0
  73. package/dist/layout.js.map +1 -0
  74. package/dist/migration.d.ts +197 -0
  75. package/dist/migration.js +592 -0
  76. package/dist/migration.js.map +1 -0
  77. package/dist/model.d.ts +93 -0
  78. package/dist/model.js +313 -0
  79. package/dist/model.js.map +1 -0
  80. package/dist/physical.d.ts +128 -0
  81. package/dist/physical.js +421 -0
  82. package/dist/physical.js.map +1 -0
  83. package/dist/placement.d.ts +157 -0
  84. package/dist/placement.js +651 -0
  85. package/dist/placement.js.map +1 -0
  86. package/dist/provisioning.d.ts +6 -0
  87. package/dist/provisioning.js +45 -0
  88. package/dist/provisioning.js.map +1 -0
  89. package/dist/query.d.ts +68 -0
  90. package/dist/query.js +340 -0
  91. package/dist/query.js.map +1 -0
  92. package/dist/routing.d.ts +25 -0
  93. package/dist/routing.js +35 -0
  94. package/dist/routing.js.map +1 -0
  95. package/dist/schema.d.ts +110 -0
  96. package/dist/schema.js +337 -0
  97. package/dist/schema.js.map +1 -0
  98. package/dist/session.d.ts +195 -0
  99. package/dist/session.js +870 -0
  100. package/dist/session.js.map +1 -0
  101. package/dist/shapes.d.ts +30 -0
  102. package/dist/shapes.js +112 -0
  103. package/dist/shapes.js.map +1 -0
  104. package/dist/staging.d.ts +29 -0
  105. package/dist/staging.js +214 -0
  106. package/dist/staging.js.map +1 -0
  107. package/dist/telemetry.d.ts +468 -0
  108. package/dist/telemetry.js +872 -0
  109. package/dist/telemetry.js.map +1 -0
  110. package/dist/testing/loader.d.ts +38 -0
  111. package/dist/testing/loader.js +86 -0
  112. package/dist/testing/loader.js.map +1 -0
  113. package/dist/testing/memory.d.ts +131 -0
  114. package/dist/testing/memory.js +311 -0
  115. package/dist/testing/memory.js.map +1 -0
  116. package/dist/timestamp.d.ts +20 -0
  117. package/dist/timestamp.js +89 -0
  118. package/dist/timestamp.js.map +1 -0
  119. package/dist/types.d.ts +79 -0
  120. package/dist/types.js +100 -0
  121. package/dist/types.js.map +1 -0
  122. package/dist/verification.d.ts +41 -0
  123. package/dist/verification.js +169 -0
  124. package/dist/verification.js.map +1 -0
  125. package/dist/watermark.d.ts +103 -0
  126. package/dist/watermark.js +170 -0
  127. package/dist/watermark.js.map +1 -0
  128. package/dist/write-fence.d.ts +58 -0
  129. package/dist/write-fence.js +225 -0
  130. package/dist/write-fence.js.map +1 -0
  131. package/package.json +86 -0
@@ -0,0 +1,981 @@
1
+ /**
2
+ * PostgreSQL adapter.
3
+ *
4
+ * Three rules run through everything here, and all three come from the requirements rather than
5
+ * from taste.
6
+ *
7
+ * **A failed write is reported, never worked around.** No retry into another engine, no swallowing,
8
+ * no "eventually consistent" story invented on the spot. If the source engine for a group will not
9
+ * take the write, the client's code finds out. The library swallows its *own* internal problems -
10
+ * routing, telemetry - because a bug of ours must not take down someone's application, but a write
11
+ * that did not happen is not our internal problem and reporting success for it would be the worst
12
+ * thing this library could do.
13
+ *
14
+ * **Identifiers are quoted, always.** Not for injection - identifiers come from the placement map,
15
+ * not from user input - but because entity names may contain non-ASCII characters, and an unquoted
16
+ * identifier in PostgreSQL is folded to lower case in a way that is lossy for some of them. The
17
+ * quoting function is bound from `schema.ts` rather than written again, so DDL and DML cannot
18
+ * disagree about how an identifier is escaped.
19
+ *
20
+ * **Values are converted per row, from the field types the server sent.** `pg` offers a global type
21
+ * parser registry, and using it would be the shortest path and the wrong one: this library goes
22
+ * into somebody else's application, so registering a parser would change how *their* other queries
23
+ * come back. The reference implementation has no equivalent temptation, which is why the rule is
24
+ * written down here.
25
+ *
26
+ * The driver is `pg`, and it is an **optional peer dependency**. A client who never places a group
27
+ * in PostgreSQL should not have it in their tree, and a client who has it already should keep the
28
+ * version they chose - the core of this library has no runtime dependencies at all, because every
29
+ * one of them would be a dependency they inherit and a version conflict they may have to resolve.
30
+ */
31
+ import { isIP } from 'node:net';
32
+ import { UsageGate } from '../_usage.js';
33
+ import { batchColumns } from '../bulk.js';
34
+ import { readRow, readSql, summarySql } from '../query.js';
35
+ import { EngineError } from '../errors.js';
36
+ import { exactBytes } from '../telemetry.js';
37
+ import { declaredTables } from '../physical.js';
38
+ import { BACKFILL_TABLE, WATERMARK_TABLE } from '../placement.js';
39
+ import { QUOTE, schemaStatements } from '../schema.js';
40
+ import { keyColumns, sameWidth } from '../migration.js';
41
+ import { Timestamp } from '../timestamp.js';
42
+ import { WriteFence } from '../write-fence.js';
43
+ import { PostgresFences, fenceIO } from './_write-fences.js';
44
+ import { verifyPeerIdentity } from './_tls-peer-identity.js';
45
+ // Bound from the one definition in schema.ts, so that DDL and DML cannot disagree about how an
46
+ // identifier is escaped.
47
+ const quote = QUOTE['postgres'];
48
+ /**
49
+ * Milliseconds. Not a guess about networks: this bounds *opening* a connection, which either
50
+ * completes in milliseconds on a healthy link or is not going to complete.
51
+ *
52
+ * Ten seconds leaves room for a saturated cross-region hop and still fails long before a request
53
+ * timeout a caller sets. It is a **default** rather than a rule - a `connect_timeout` in the DSN
54
+ * wins - and it exists because the alternative, measured against a socket that accepts TCP and says
55
+ * nothing, is a call that never returns. Queries are deliberately not bounded: an analytical query
56
+ * legitimately takes minutes and a library that timed it out would be deciding something about the
57
+ * caller's workload.
58
+ */
59
+ export const CONNECT_TIMEOUT_MS = 10_000;
60
+ /** Type OIDs this adapter converts, and what it converts them to. See the module docstring. */
61
+ const OID = {
62
+ bool: 16,
63
+ bytea: 17,
64
+ int8: 20,
65
+ int2: 21,
66
+ int4: 23,
67
+ json: 114,
68
+ float4: 700,
69
+ float8: 701,
70
+ date: 1082,
71
+ timestamp: 1114,
72
+ timestamptz: 1184,
73
+ numeric: 1700,
74
+ uuid: 2950,
75
+ jsonb: 3802,
76
+ };
77
+ /**
78
+ * One value, converted from what `pg` hands back into what this library promises.
79
+ *
80
+ * Four of these are decisions rather than plumbing, and each is a place where two engines would
81
+ * otherwise disagree about the same column.
82
+ *
83
+ * **`int8` becomes a `bigint`.** `pg` returns it as a string, and a `number` would silently lose
84
+ * precision above 2^53 - which is not exotic for an identifier column. A `bigint` is the only
85
+ * JavaScript type that holds an int64, so it is the one used, and the ClickHouse adapter agrees.
86
+ *
87
+ * **`numeric` stays a string.** There is no exact decimal in this runtime, and turning `12.34` into
88
+ * a float is the one conversion this library must never do quietly: the value comes back changed and
89
+ * nothing raises. The reference returns `Decimal`; a string is this language's nearest honest
90
+ * equivalent, and it round-trips.
91
+ *
92
+ * **`date` stays a string.** `pg` parses it into a `Date`, which is midnight in *this process's*
93
+ * timezone - so the same stored date reads as a different day depending on where the reader runs.
94
+ * A calendar date has no time and no zone; `YYYY-MM-DD` says exactly that.
95
+ *
96
+ * **Both timestamp types become `Timestamp`.** Client-local parsers retain their text before pg
97
+ * can discard microseconds. Converting a Date after the driver parsed it is already too late.
98
+ */
99
+ function convert(value, oid) {
100
+ if (value === null || value === undefined)
101
+ return null;
102
+ switch (oid) {
103
+ case OID.int8:
104
+ return typeof value === 'bigint' ? value : BigInt(String(value));
105
+ case OID.numeric:
106
+ return String(value);
107
+ case OID.date:
108
+ // `pg` has already parsed it into a local-midnight Date; render the calendar date back out of
109
+ // the parts that do not depend on a zone.
110
+ return value instanceof Date ? isoDate(value) : String(value);
111
+ case OID.timestamp:
112
+ case OID.timestamptz:
113
+ return Timestamp.from(value instanceof Date ? value : String(value));
114
+ default:
115
+ return value;
116
+ }
117
+ }
118
+ function isoDate(value) {
119
+ const year = String(value.getFullYear()).padStart(4, '0');
120
+ const month = String(value.getMonth() + 1).padStart(2, '0');
121
+ const day = String(value.getDate()).padStart(2, '0');
122
+ return `${year}-${month}-${day}`;
123
+ }
124
+ /**
125
+ * One value on the way *in*.
126
+ *
127
+ * A `bigint` is handed to `pg` as a string, because the driver has no encoder for it and would
128
+ * otherwise throw. Both `Timestamp` and `Date` are serialized explicitly as UTC text, including
129
+ * for timezone-free columns; everything else goes through untouched. Passing Date to pg unchanged
130
+ * would serialize local clock parts, whose offset a timezone-free SQL column discards.
131
+ */
132
+ function outbound(value) {
133
+ if (value instanceof Timestamp || value instanceof Date)
134
+ return Timestamp.from(value).toISOString();
135
+ if (typeof value === 'bigint')
136
+ return value.toString();
137
+ return value;
138
+ }
139
+ /** Pin verified TLS defaults and the native connection's literal-IP certificate identity.
140
+ *
141
+ * The native parser remains authoritative for SSL mode, CA and any verification callback.
142
+ * An absent rejection flag must not inherit NODE_TLS_REJECT_UNAUTHORIZED=0. For IPs, pg also
143
+ * needs host passed to tls.connect: a supplied socket has no DNS name and can fall back to localhost.
144
+ */
145
+ function configureTlsVerification(client) {
146
+ const native = client;
147
+ const selected = native.connection?.ssl ?? native.ssl ?? native.connectionParameters?.ssl;
148
+ if (selected !== true && (selected === null || typeof selected !== 'object'))
149
+ return;
150
+ // pg.defaults.ssl may be shared. Clone own descriptors so neither that object nor another
151
+ // client's IP changes, and non-enumerable private keys/getters retain their original behavior.
152
+ const descriptors = selected === true ? {} : Object.getOwnPropertyDescriptors(selected);
153
+ if (selected === true || selected.rejectUnauthorized !== false) {
154
+ descriptors.rejectUnauthorized = { value: true, enumerable: true, configurable: true, writable: true };
155
+ }
156
+ const configured = native.host ?? native.connectionParameters?.host;
157
+ let ip;
158
+ if (typeof configured === 'string') {
159
+ const candidate = configured.startsWith('[') && configured.endsWith(']')
160
+ ? configured.slice(1, -1) : configured;
161
+ if (isIP(candidate) !== 0) {
162
+ ip = candidate;
163
+ descriptors.host = { value: ip, enumerable: true, configurable: true, writable: true };
164
+ const options = selected;
165
+ if (options.rejectUnauthorized !== false && options.checkServerIdentity === undefined) {
166
+ descriptors.checkServerIdentity = {
167
+ value: (_hostname, certificate) => verifyPeerIdentity(candidate, certificate),
168
+ enumerable: true, configurable: true, writable: true,
169
+ };
170
+ }
171
+ }
172
+ }
173
+ const ssl = Object.create(selected === true ? Object.prototype : Object.getPrototypeOf(selected), descriptors);
174
+ native.ssl = ssl;
175
+ if (native.connectionParameters)
176
+ native.connectionParameters.ssl = ssl;
177
+ if (native.connection)
178
+ native.connection.ssl = ssl;
179
+ // pg-connection-string retains URI IPv6 brackets; net.connect needs the unbracketed address.
180
+ // This is only normalization of a proven literal IP, after the native DSN parser has finished.
181
+ if (ip !== undefined) {
182
+ native.host = ip;
183
+ if (native.connectionParameters)
184
+ native.connectionParameters.host = ip;
185
+ }
186
+ }
187
+ export class PostgresEngine {
188
+ dsn;
189
+ options;
190
+ dialect = 'postgres';
191
+ client = null;
192
+ transactions = 0;
193
+ savepoint = 0;
194
+ usage = new UsageGate();
195
+ unusable = false;
196
+ /**
197
+ * The asynchronous failure the driver reported, if any, kept for the next call to explain.
198
+ *
199
+ * **This exists because an unhandled `'error'` event kills the client's process.** `pg.Client` is
200
+ * an `EventEmitter`, and it emits `'error'` when the server terminates the connection between
201
+ * queries - a restart, a failover, an administrator. Node's rule for an `'error'` event with no
202
+ * listener is to throw it, so a library that did not listen would turn a database restart into
203
+ * `Error: Connection terminated unexpectedly` from inside somebody's event loop, with no call of
204
+ * theirs on the stack and nothing to catch it.
205
+ *
206
+ * Found by the test that measures the *message* after a cut connection: the assertion failed and
207
+ * took eight unrelated tests with it, which is a mild version of what a client would have seen.
208
+ * The reference implementation has no equivalent hazard - psycopg raises at the next call and
209
+ * emits nothing - so this is the one place where "same contract, different runtime" means extra
210
+ * code rather than a translation.
211
+ */
212
+ lost = null;
213
+ constructor(dsn, options = {}) {
214
+ this.dsn = dsn;
215
+ this.options = options;
216
+ }
217
+ /**
218
+ * Open the connection, with a bound on how long that may take.
219
+ *
220
+ * Measured in the reference implementation before this bound existed: a host that accepts the TCP
221
+ * connection and never answers hung the call **for as long as the test was willing to wait**.
222
+ * That is not an exotic case - it is a firewall that accepts, a load balancer with no healthy
223
+ * backend, a server mid-restart - and without a bound it happens inside the caller's request path
224
+ * with nothing to time out.
225
+ *
226
+ * The default is only applied when the caller has not chosen one. A `connect_timeout` in the DSN
227
+ * is their decision about their own network and this must not override it - see
228
+ * {@link connectBound}, where honouring it costs more work here than it does in the reference.
229
+ */
230
+ async connect() {
231
+ return this.usage.operation(async () => {
232
+ if (this.client !== null && this.unusable)
233
+ throw new EngineError('transaction completion was uncertain; close() then connect() before reuse');
234
+ if (this.client !== null)
235
+ return;
236
+ const driver = (this.options.driver ?? (await loadDriver()));
237
+ const config = {
238
+ connectionString: this.dsn,
239
+ connectionTimeoutMillis: connectBound(this.dsn),
240
+ };
241
+ const client = new driver.Client(config);
242
+ // Preserve text before pg's Date parser loses precision, only on our own client.
243
+ client.setTypeParser(OID.timestamp, (value) => value);
244
+ client.setTypeParser(OID.timestamptz, (value) => value);
245
+ // Attached before `connect`, because the window between them is one a server can fail in.
246
+ client.on('error', (error) => {
247
+ this.lost = error;
248
+ });
249
+ try {
250
+ configureTlsVerification(client);
251
+ await client.connect();
252
+ }
253
+ catch (error) {
254
+ try {
255
+ await client.end();
256
+ }
257
+ catch { /* Preserve the connect failure. */ }
258
+ throw new EngineError(`could not connect to PostgreSQL: ${message(error)}`);
259
+ }
260
+ this.unusable = false;
261
+ this.lost = null;
262
+ this.client = client;
263
+ });
264
+ }
265
+ async close() {
266
+ return this.usage.operation(async () => {
267
+ if (this.client === null)
268
+ return;
269
+ const client = this.client;
270
+ try {
271
+ await client.end();
272
+ this.client = null;
273
+ }
274
+ catch (error) {
275
+ this.unusable = true;
276
+ throw error;
277
+ }
278
+ });
279
+ }
280
+ get cx() {
281
+ if (this.unusable)
282
+ throw new EngineError('transaction completion was uncertain; close() then connect() before reuse');
283
+ if (this.client === null)
284
+ throw new EngineError('not connected; call connect() first');
285
+ return this.client;
286
+ }
287
+ /**
288
+ * The driver's message, plus the one sentence it cannot know to add.
289
+ *
290
+ * Measured in the reference: cut the connection under a live session and the first failing call
291
+ * reports what the server said, which is right. **Every call after it reports only that the
292
+ * connection is closed** - true, unhelpful, and the point at which a reader needs to be told that
293
+ * this library holds the connection it was handed and does not reopen it. Reconnecting is one line
294
+ * and it is the caller's, because a library that silently reconnected would also silently retry.
295
+ */
296
+ explain(error) {
297
+ const text = message(error);
298
+ const gone = this.lost !== null || /connection.*(closed|terminated|ended)|not queryable/i.test(text);
299
+ if (gone) {
300
+ return (`${text}. The connection is gone and this library does not reopen one it was handed: ` +
301
+ `call close() then connect() on the engine, or hand the session a new one. Nothing was ` +
302
+ `retried, so no write reached the engine twice.`);
303
+ }
304
+ return text;
305
+ }
306
+ async run(sql, values = []) {
307
+ return this.usage.operation(async () => {
308
+ return this.cx.query(sql, values.map(outbound));
309
+ });
310
+ }
311
+ rowsOf(result) {
312
+ const oids = new Map(result.fields.map((field) => [field.name, field.dataTypeID]));
313
+ return result.rows.map((row) => {
314
+ const out = {};
315
+ for (const [name, value] of Object.entries(row)) {
316
+ out[name] = convert(value, oids.get(name) ?? -1);
317
+ }
318
+ return out;
319
+ });
320
+ }
321
+ /** DDL capability; use a dedicated connection separate from application traffic. */
322
+ writeFence(table, options) {
323
+ return new WriteFence(new PostgresFences({
324
+ query: (sql, values) => fenceIO(async () => (await this.run(sql, values)).rows),
325
+ transaction: (body) => fenceIO(async () => { await this.transaction(body); }),
326
+ busy: () => this.transactions > 0,
327
+ }), table, options);
328
+ }
329
+ // --- schema ------------------------------------------------------------------------------
330
+ /**
331
+ * Create what is missing, change nothing that exists.
332
+ *
333
+ * Idempotent on purpose: an application restarting must not reapply DDL, and two instances
334
+ * starting at once must not race. Anything beyond creation - altering a column, dropping an
335
+ * index - is a migration, which is the orchestrator's job and carries a safety classification.
336
+ */
337
+ async ensureSchema(layout, options) {
338
+ return this.usage.operation(async () => {
339
+ const tables = schemaStatements(layout, { keys: options.keys, dialect: this.dialect })
340
+ .filter((statement) => statement.startsWith('CREATE TABLE '));
341
+ await this.execute(tables);
342
+ await this.verifySchema(layout);
343
+ // Indexes only on tables whose key is the declared one. A table with another primary key
344
+ // belongs to another design and this map's refusal is coming; `CREATE INDEX` without
345
+ // CONCURRENTLY blocks that table's writes while it builds, so running it first would be a
346
+ // refused operation that still stopped the client's writes.
347
+ const blocked = new Set((await this.physicalFindings(layout, options.keys))
348
+ .filter((finding) => finding.aspect === 'primary key')
349
+ .map((finding) => finding.table));
350
+ const applicable = {
351
+ ...layout,
352
+ indexes: layout.indexes.filter((index) => {
353
+ const table = layout.tables[String(index['entity'])];
354
+ return table === undefined || !blocked.has(table);
355
+ }),
356
+ };
357
+ await this.execute(schemaStatements(applicable, { keys: options.keys, dialect: this.dialect })
358
+ .filter((statement) => statement.startsWith('CREATE INDEX ')));
359
+ // Returned, not refused: whether a physical difference is a refusal (a person provisioning)
360
+ // or a report (a running session) is the caller's decision. Columns and types refuse above.
361
+ return this.physicalFindings(layout, options.keys);
362
+ });
363
+ }
364
+ async execute(statements) {
365
+ for (const statement of statements) {
366
+ try {
367
+ await this.run(statement);
368
+ }
369
+ catch (error) {
370
+ throw new EngineError(`schema statement failed: ${statement}: ${message(error)}`);
371
+ }
372
+ }
373
+ }
374
+ /**
375
+ * Check that what exists is what the map describes, because `IF NOT EXISTS` does not.
376
+ *
377
+ * `CREATE TABLE IF NOT EXISTS` accepts a table of that name whatever shape it is in, so a table
378
+ * left over from something else - an older map, another application, a migration run by hand - is
379
+ * silently kept and the first insert fails with `column "at" does not exist`. That error names a
380
+ * column and not the cause, and it arrives in the client's request path rather than at startup.
381
+ *
382
+ * A **missing** column is refused: writes through this map cannot work. An **extra** column is
383
+ * allowed - a client may have added one outside SDE, the map does not name it, writes are
384
+ * unaffected, and refusing would make this library an obstacle to work it has no opinion about.
385
+ *
386
+ * **Types as well as names, since 7 September 2026.** The earlier version checked names only,
387
+ * on the true observation that `information_schema.data_type` reports `numeric` for a
388
+ * `numeric(8,2)` column - a correct statement about the wrong catalogue.
389
+ * `pg_catalog.format_type(atttypid, atttypmod)` reports the canonical type *with* its modifier,
390
+ * and measured against every type this library renders, eleven of thirteen come back as the
391
+ * exact string we wrote. What that cost while it was names-only, measured: a table whose `at`
392
+ * column is **`text`** where the map says `timestamptz` passed and was called a good schema.
393
+ */
394
+ /** Check existing physical columns without issuing DDL; report the physical design. */
395
+ async validateSchema(layout, options = {}) {
396
+ return this.usage.operation(async () => {
397
+ await this.verifySchema(layout);
398
+ return options.keys === undefined ? [] : this.physicalFindings(layout, options.keys);
399
+ });
400
+ }
401
+ /**
402
+ * Primary key order and each declared index's method and columns, from `pg_index`.
403
+ *
404
+ * `CREATE INDEX IF NOT EXISTS ... USING brin` keeps an existing B-tree of that name - measured -
405
+ * so the method is read back rather than assumed from the statement that ran. An index with a
406
+ * predicate, an expression or INCLUDE columns is not the index a layout declares, however its
407
+ * name reads. The catalogues are readable by any login, so this needs no grant.
408
+ */
409
+ async physicalFindings(layout, keys) {
410
+ const declared = declaredTables(layout, keys);
411
+ if (declared.length === 0)
412
+ return [];
413
+ const result = await this.run('SELECT t.relname AS table_name, ic.relname AS index_name, i.indisprimary AS is_primary, ' +
414
+ 'am.amname AS method, ' +
415
+ 'ARRAY(SELECT a.attname::text FROM unnest(i.indkey) WITH ORDINALITY k(num, pos) ' +
416
+ 'JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = k.num ORDER BY k.pos) AS columns, ' +
417
+ 'i.indpred IS NULL AND i.indexprs IS NULL AND i.indnkeyatts = i.indnatts AS simple ' +
418
+ 'FROM pg_index i JOIN pg_class ic ON ic.oid = i.indexrelid ' +
419
+ 'JOIN pg_class t ON t.oid = i.indrelid JOIN pg_am am ON am.oid = ic.relam ' +
420
+ 'JOIN pg_namespace n ON n.oid = t.relnamespace ' +
421
+ 'WHERE n.nspname = current_schema() AND t.relname = ANY($1)', [declared.map((entry) => entry.table)]);
422
+ const primary = new Map();
423
+ const indexes = new Map();
424
+ for (const row of result.rows) {
425
+ const table = String(row['table_name']);
426
+ const columns = row['columns'].map(String);
427
+ if (row['is_primary'] === true) {
428
+ primary.set(table, columns);
429
+ }
430
+ else {
431
+ const byName = indexes.get(table) ?? new Map();
432
+ byName.set(String(row['index_name']), {
433
+ method: String(row['method']),
434
+ columns,
435
+ simple: row['simple'] === true,
436
+ });
437
+ indexes.set(table, byName);
438
+ }
439
+ }
440
+ const same = (a, b) => a.length === b.length && a.every((value, position) => value === b[position]);
441
+ const findings = [];
442
+ for (const entry of declared) {
443
+ const foundKey = primary.get(entry.table);
444
+ if (foundKey === undefined || !same(foundKey, entry.key)) {
445
+ findings.push({
446
+ table: entry.table,
447
+ aspect: 'primary key',
448
+ declared: JSON.stringify(entry.key),
449
+ found: foundKey === undefined ? 'absent' : JSON.stringify(foundKey),
450
+ });
451
+ }
452
+ for (const index of entry.indexes) {
453
+ const wanted = `${index.method} on ${JSON.stringify(index.columns)}`;
454
+ const got = indexes.get(entry.table)?.get(index.name);
455
+ if (got === undefined) {
456
+ findings.push({ table: entry.table, aspect: `index ${index.name}`, declared: wanted, found: 'absent' });
457
+ continue;
458
+ }
459
+ if (got.method !== index.method || !same(got.columns, index.columns) || !got.simple) {
460
+ const shape = got.simple ? '' : ' with a predicate, expression or INCLUDE';
461
+ findings.push({
462
+ table: entry.table,
463
+ aspect: `index ${index.name}`,
464
+ declared: wanted,
465
+ found: `${got.method} on ${JSON.stringify(got.columns)}${shape}`,
466
+ });
467
+ }
468
+ }
469
+ }
470
+ return findings;
471
+ }
472
+ async verifySchema(layout) {
473
+ const expected = new Map();
474
+ for (const [entity, table] of Object.entries(layout.tables)) {
475
+ expected.set(table, layout.columns[entity] ?? {});
476
+ }
477
+ if (expected.size === 0)
478
+ return;
479
+ // `pg_attribute` rather than `information_schema`, for `format_type`: the canonical spelling
480
+ // *including* the modifier, which is the whole reason this can compare types at all.
481
+ const result = await this.run('SELECT c.relname, a.attname, format_type(a.atttypid, a.atttypmod) AS coltype ' +
482
+ 'FROM pg_attribute a ' +
483
+ 'JOIN pg_class c ON c.oid = a.attrelid ' +
484
+ 'JOIN pg_namespace n ON n.oid = c.relnamespace ' +
485
+ 'WHERE n.nspname = current_schema() AND c.relname = ANY($1) ' +
486
+ 'AND a.attnum > 0 AND NOT a.attisdropped', [[...expected.keys()].sort()]);
487
+ const found = new Map();
488
+ for (const row of result.rows) {
489
+ const table = String(row['relname']);
490
+ const columns = found.get(table) ?? new Map();
491
+ columns.set(String(row['attname']), String(row['coltype']));
492
+ found.set(table, columns);
493
+ }
494
+ for (const [table, columns] of [...expected.entries()].sort()) {
495
+ const actual = found.get(table);
496
+ if (actual === undefined) {
497
+ throw new EngineError(`'${table}' does not exist after applying the schema. The statement reported success, ` +
498
+ `so this is a permissions or search_path problem rather than a bad map.`);
499
+ }
500
+ const missing = Object.keys(columns)
501
+ .filter((column) => !actual.has(column))
502
+ .sort();
503
+ if (missing.length > 0) {
504
+ throw new EngineError(`'${table}' already existed with a different shape: the map needs ` +
505
+ `[${missing.join(', ')}] and the table has [${[...actual.keys()].sort().join(', ')}]. ` +
506
+ `CREATE TABLE IF NOT EXISTS keeps whatever is there, so this table came from ` +
507
+ `somewhere else - an older map, another application, a migration run by hand. ` +
508
+ `Refusing here rather than at the first insert, which would fail in your request path ` +
509
+ `with an error naming a column and not the cause.`);
510
+ }
511
+ for (const column of Object.keys(columns).sort()) {
512
+ const declared = columns[column];
513
+ const reported = actual.get(column);
514
+ if (await this.sameType(declared, reported))
515
+ continue;
516
+ throw new EngineError(`${table}.${column} is '${reported}' and this map declares it '${declared}'. ` +
517
+ `CREATE TABLE IF NOT EXISTS keeps a table of that name whatever shape it is in, and ` +
518
+ `this library never alters a column's type - so the table came from somewhere else, ` +
519
+ `or from a map that rendered this column differently. Refusing rather than writing ` +
520
+ `into it: a type that differs is either a write that fails in your request path or, ` +
521
+ `worse, one that succeeds and hands the value back as something else.`);
522
+ }
523
+ }
524
+ }
525
+ /**
526
+ * Whether two PostgreSQL type spellings denote the same type. Asked of the server.
527
+ *
528
+ * Literal first, which is the answer for every type this library renders except the two
529
+ * timestamps. When that fails the server is asked: `to_regtype` resolves an alias to the type it
530
+ * names, so `timestamptz` and `timestamp with time zone` come back equal without this file
531
+ * holding an alias table that could fall behind the renderer.
532
+ *
533
+ * **A modifier makes a literal mismatch a real one.** `to_regtype` discards modifiers, so
534
+ * `numeric(12,2)` and `numeric(8,2)` would both resolve to `numeric` and a precision change
535
+ * would read as agreement - which is the one difference this check exists to catch.
536
+ */
537
+ async sameType(declared, reported) {
538
+ if (declared === reported)
539
+ return true;
540
+ if (declared.includes('(') || reported.includes('('))
541
+ return false;
542
+ const result = await this.run('SELECT to_regtype($1)::text AS a, to_regtype($2)::text AS b', [
543
+ declared,
544
+ reported,
545
+ ]);
546
+ const row = result.rows[0];
547
+ if (row === undefined || row['a'] === null)
548
+ return false;
549
+ return row['a'] === row['b'];
550
+ }
551
+ // --- data --------------------------------------------------------------------------------
552
+ async insert(table, values) {
553
+ return this.usage.operation(async () => {
554
+ const columns = Object.keys(values).sort();
555
+ if (columns.length === 0)
556
+ throw new EngineError('nothing to insert');
557
+ const placeholders = columns.map((_, index) => `$${index + 1}`).join(', ');
558
+ const sql = `INSERT INTO ${quote(table)} (${columns.map(quote).join(', ')}) VALUES (${placeholders})`;
559
+ try {
560
+ await this.run(sql, columns.map((column) => values[column]));
561
+ }
562
+ catch (error) {
563
+ // Surfaced, not swallowed and not rerouted. See the module docstring.
564
+ throw new EngineError(`insert into ${table} failed: ${this.explain(error)}`);
565
+ }
566
+ });
567
+ }
568
+ /** Ordinary application INSERT. Do not reuse copyIn's conflict suppression. */
569
+ async insertMany(table, rows) {
570
+ return this.usage.operation(async () => {
571
+ const columns = batchColumns(rows);
572
+ if (columns.length === 0)
573
+ return;
574
+ const values = [];
575
+ const tuples = rows.map((row) => `(${columns.map((column) => {
576
+ values.push(row[column]);
577
+ return `$${values.length}`;
578
+ }).join(', ')})`);
579
+ try {
580
+ await this.run(`INSERT INTO ${quote(table)} (${columns.map(quote).join(', ')}) VALUES ${tuples.join(', ')}`, values);
581
+ }
582
+ catch (error) {
583
+ throw new EngineError(`batch insert into ${table} failed: ${this.explain(error)}`);
584
+ }
585
+ });
586
+ }
587
+ async get(table, key) {
588
+ return this.usage.operation(async () => {
589
+ const columns = Object.keys(key).sort();
590
+ const where = columns.map((column, index) => `${quote(column)} = $${index + 1}`).join(' AND ');
591
+ try {
592
+ const result = await this.run(`SELECT * FROM ${quote(table)} WHERE ${where}`, columns.map((column) => key[column]));
593
+ const rows = this.rowsOf(result);
594
+ return rows[0] ?? null;
595
+ }
596
+ catch (error) {
597
+ throw new EngineError(`select from ${table} failed: ${this.explain(error)}`);
598
+ }
599
+ });
600
+ }
601
+ async selectRows(table, plan) {
602
+ return this.usage.operation(async () => {
603
+ const params = [];
604
+ const parameter = (value) => { params.push(value); return '$' + params.length; };
605
+ const statement = readSql(table, plan, this.dialect, parameter);
606
+ try {
607
+ return this.rowsOf(await this.run(statement, params)).map(row => readRow(plan.columns, row));
608
+ }
609
+ catch (error) {
610
+ throw new EngineError('logical scan of ' + table + ' failed: ' + this.explain(error));
611
+ }
612
+ });
613
+ }
614
+ async countRows(table, plan) {
615
+ return this.usage.operation(async () => {
616
+ const params = [];
617
+ const parameter = (value) => { params.push(value); return '$' + params.length; };
618
+ const statement = readSql(table, plan, this.dialect, parameter, true);
619
+ try {
620
+ const row = (await this.run(statement, params)).rows[0];
621
+ if (row === undefined || typeof row['sde_count'] !== 'string')
622
+ throw new EngineError('count query returned no exact result');
623
+ return BigInt(row['sde_count']);
624
+ }
625
+ catch (error) {
626
+ throw new EngineError('logical count of ' + table + ' failed: ' + this.explain(error));
627
+ }
628
+ });
629
+ }
630
+ async summarizeRows(table, plan, column) {
631
+ return this.usage.operation(async () => {
632
+ const params = [];
633
+ const parameter = (value) => { params.push(value); return '$' + params.length; };
634
+ const statement = summarySql(table, plan, column, this.dialect, parameter);
635
+ try {
636
+ const row = (await this.run(statement, params)).rows[0];
637
+ if (row === undefined)
638
+ throw new EngineError('summary query returned no result');
639
+ return row;
640
+ }
641
+ catch (error) {
642
+ throw new EngineError('logical summary of ' + table + ' failed: ' + this.explain(error));
643
+ }
644
+ });
645
+ }
646
+ /**
647
+ * Each table's bytes and its secondary index bytes, from the catalogue. Numbers only.
648
+ *
649
+ * `pg_total_relation_size` - the table, its TOAST and every index - and the indexes other than
650
+ * the primary key, one statement for every table named. A name the connection does not resolve is
651
+ * absent rather than a zero: a missing table is not an empty one. A login with only SELECT and
652
+ * INSERT on the table reads the same numbers as the administrator (measured on PostgreSQL 15).
653
+ */
654
+ async storageSizes(tables) {
655
+ return this.usage.operation(async () => {
656
+ if (tables.length === 0)
657
+ return new Map();
658
+ const sql = 'SELECT t.name AS name, pg_total_relation_size(r.oid)::text AS total, ' +
659
+ 'COALESCE((SELECT sum(pg_relation_size(i.indexrelid)) FROM pg_index i ' +
660
+ 'WHERE i.indrelid = r.oid AND NOT i.indisprimary), 0)::text AS secondary ' +
661
+ 'FROM unnest($1::text[]) AS t(name) ' +
662
+ 'JOIN pg_class r ON r.oid = to_regclass(quote_ident(t.name))';
663
+ try {
664
+ const result = await this.run(sql, [[...tables]]);
665
+ return new Map(result.rows.map((row) => [
666
+ String(row['name']),
667
+ [exactBytes(row['total']), exactBytes(row['secondary'])],
668
+ ]));
669
+ }
670
+ catch (error) {
671
+ throw new EngineError('storage sizes could not be read: ' + this.explain(error), { cause: error });
672
+ }
673
+ });
674
+ }
675
+ async count(table) {
676
+ return this.usage.operation(async () => {
677
+ try {
678
+ const result = await this.run(`SELECT count(*) AS n FROM ${quote(table)}`);
679
+ return Number(result.rows[0]?.['n'] ?? 0);
680
+ }
681
+ catch (error) {
682
+ throw new EngineError(`count on ${table} failed: ${message(error)}`);
683
+ }
684
+ });
685
+ }
686
+ // --- rollback protection ------------------------------------------------------------------
687
+ /**
688
+ * The highest map version applied against this engine, creating the table if missing.
689
+ *
690
+ * An existing table needs only read privileges. CREATE IF NOT EXISTS still requires schema
691
+ * CREATE even when the table exists; lazy creation is retained only for an absent table.
692
+ */
693
+ async mapWatermark() {
694
+ return this.usage.operation(async () => {
695
+ try {
696
+ const existing = await this.run('SELECT to_regclass($1) AS relation', [WATERMARK_TABLE]);
697
+ if (existing.rows.length !== 1 || !('relation' in existing.rows[0])) {
698
+ throw new EngineError('watermark catalog lookup returned no result');
699
+ }
700
+ if (existing.rows[0]['relation'] === null) {
701
+ await this.run(`CREATE TABLE IF NOT EXISTS ${quote(WATERMARK_TABLE)} (` +
702
+ `${quote('map_version')} bigint NOT NULL, ` +
703
+ `${quote('model_version')} text NOT NULL, ` +
704
+ `${quote('seen_at')} timestamptz NOT NULL DEFAULT now())`);
705
+ }
706
+ const result = await this.run(`SELECT max(${quote('map_version')}) AS high FROM ${quote(WATERMARK_TABLE)}`);
707
+ const high = result.rows[0]?.['high'];
708
+ return high === null || high === undefined ? null : Number(high);
709
+ }
710
+ catch (error) {
711
+ throw new EngineError(`reading ${WATERMARK_TABLE} failed: ${this.explain(error)}`);
712
+ }
713
+ });
714
+ }
715
+ /**
716
+ * Append. Never update, so there is nothing to contend over and nothing to lose.
717
+ *
718
+ * The timestamp comes from the engine's own `now()` rather than from this process: an audit column
719
+ * wants the clock of the thing being audited, and this library reading a clock is a thing its
720
+ * tests would then have to work around.
721
+ */
722
+ async recordMapVersion(version, options) {
723
+ return this.usage.operation(async () => {
724
+ try {
725
+ await this.run(`INSERT INTO ${quote(WATERMARK_TABLE)} (${quote('map_version')}, ` +
726
+ `${quote('model_version')}) VALUES ($1, $2)`, [version, options.modelVersion]);
727
+ }
728
+ catch (error) {
729
+ throw new EngineError(`recording a map version in ${WATERMARK_TABLE} failed: ${message(error)}`);
730
+ }
731
+ });
732
+ }
733
+ // --- migration ---------------------------------------------------------------------------
734
+ /**
735
+ * Rows in key order, strictly after one key and up to another inclusive.
736
+ *
737
+ * Row-value comparison - `(a, b) > ($1, $2)` - rather than a hand-rolled disjunction over the
738
+ * key's columns. The disjunction is where composite-key pagination goes wrong, and it goes wrong
739
+ * by skipping rows.
740
+ *
741
+ * The bounds are asymmetric on purpose. `after` is exclusive because it is a resume point: the row
742
+ * it names has been dealt with. `upto` is inclusive because it names the last row of a chunk read
743
+ * from somewhere else, and that row is one this range has to include.
744
+ */
745
+ async keyRange(table, order, options = {}) {
746
+ return this.usage.operation(async () => {
747
+ const cols = keyColumns(order, table);
748
+ const clauses = [];
749
+ const values = [];
750
+ const tuple = `(${cols.map(quote).join(', ')})`;
751
+ if (options.after !== undefined) {
752
+ sameWidth(options.after, cols, 'after');
753
+ clauses.push(`${tuple} > (${cols.map((_, i) => `$${values.length + i + 1}`).join(', ')})`);
754
+ values.push(...options.after);
755
+ }
756
+ if (options.upto !== undefined) {
757
+ sameWidth(options.upto, cols, 'upto');
758
+ clauses.push(`${tuple} <= (${cols.map((_, i) => `$${values.length + i + 1}`).join(', ')})`);
759
+ values.push(...options.upto);
760
+ }
761
+ const where = clauses.length > 0 ? ` WHERE ${clauses.join(' AND ')}` : '';
762
+ let cap = '';
763
+ if (options.limit !== undefined) {
764
+ values.push(options.limit);
765
+ cap = ` LIMIT $${values.length}`;
766
+ }
767
+ try {
768
+ const result = await this.run(`SELECT * FROM ${quote(table)}${where} ORDER BY ${tuple}${cap}`, values);
769
+ return this.rowsOf(result);
770
+ }
771
+ catch (error) {
772
+ throw new EngineError(`key range select from ${table} failed: ${message(error)}`);
773
+ }
774
+ });
775
+ }
776
+ /**
777
+ * The key of the `position`-th row in key order, one-based, or null if there is no such row.
778
+ *
779
+ * An `OFFSET` scan, which is the expensive kind of query, and it is here because it is paid **once
780
+ * per resume** rather than once per chunk. See `migration.ts` for why the marker is a row count
781
+ * and not a key.
782
+ */
783
+ async nthKey(table, order, options) {
784
+ return this.usage.operation(async () => {
785
+ const cols = keyColumns(order, table);
786
+ if (options.position < 1) {
787
+ throw new EngineError(`position is one-based; ${options.position} is not a row`);
788
+ }
789
+ const projection = cols.map(quote).join(', ');
790
+ try {
791
+ const result = await this.run(`SELECT ${projection} FROM ${quote(table)} ORDER BY (${projection}) OFFSET $1 LIMIT 1`, [options.position - 1]);
792
+ const rows = this.rowsOf(result);
793
+ const row = rows[0];
794
+ if (row === undefined)
795
+ return null;
796
+ return cols.map((column) => row[column]);
797
+ }
798
+ catch (error) {
799
+ throw new EngineError(`reading row ${options.position} of ${table} failed: ${message(error)}`);
800
+ }
801
+ });
802
+ }
803
+ /**
804
+ * Insert rows, skipping any whose key is already there.
805
+ *
806
+ * `ON CONFLICT DO NOTHING` is what makes a backfill chunk **idempotent**, and idempotence is what
807
+ * makes it resumable: the marker is written after the chunk, so a crash in between costs a recopy
808
+ * and never a lost row. Without it the recopy would be a primary-key violation and the safe
809
+ * failure mode would become the loud one.
810
+ *
811
+ * The bare form, with no conflict target, so it covers the primary key and any unique index the
812
+ * layout asked for. Naming the key here would mean deriving it a second time.
813
+ */
814
+ async copyIn(table, rows) {
815
+ return this.usage.operation(async () => {
816
+ if (rows.length === 0)
817
+ return;
818
+ const columns = Object.keys(rows[0]).sort();
819
+ for (const row of rows) {
820
+ const here = Object.keys(row).sort();
821
+ if (here.join(' ') !== columns.join(' ')) {
822
+ throw new EngineError(`copyIn into ${table} was given rows with different columns ([${columns.join(', ')}] ` +
823
+ `and [${here.join(', ')}]). A chunk comes from one table, so this is a caller ` +
824
+ `assembling it from two.`);
825
+ }
826
+ }
827
+ const values = [];
828
+ const tuples = rows.map((row) => {
829
+ const placeholders = columns.map((column) => {
830
+ values.push(row[column]);
831
+ return `$${values.length}`;
832
+ });
833
+ return `(${placeholders.join(', ')})`;
834
+ });
835
+ try {
836
+ await this.run(`INSERT INTO ${quote(table)} (${columns.map(quote).join(', ')}) ` +
837
+ `VALUES ${tuples.join(', ')} ON CONFLICT DO NOTHING`, values);
838
+ }
839
+ catch (error) {
840
+ throw new EngineError(`copying ${rows.length} rows into ${table} failed: ${message(error)}`);
841
+ }
842
+ });
843
+ }
844
+ /**
845
+ * How many rows of this entity have been copied into this engine. Zero if none.
846
+ *
847
+ * `max()` over an append-only table, exactly like the map watermark, and for the same reason: no
848
+ * row to update, nothing to contend over, and identical semantics in an engine with no unique
849
+ * constraint. A stale row can never lower the marker.
850
+ */
851
+ async backfillMarker(options) {
852
+ return this.usage.operation(async () => {
853
+ try {
854
+ await this.run(`CREATE TABLE IF NOT EXISTS ${quote(BACKFILL_TABLE)} (` +
855
+ `${quote('materialization')} text NOT NULL, ` +
856
+ `${quote('entity')} text NOT NULL, ` +
857
+ `${quote('rows_copied')} bigint NOT NULL, ` +
858
+ `${quote('at')} timestamptz NOT NULL DEFAULT now())`);
859
+ const result = await this.run(`SELECT max(${quote('rows_copied')}) AS high FROM ${quote(BACKFILL_TABLE)} ` +
860
+ `WHERE ${quote('materialization')} = $1 AND ${quote('entity')} = $2`, [options.materialization, options.entity]);
861
+ const high = result.rows[0]?.['high'];
862
+ return high === null || high === undefined ? 0 : Number(high);
863
+ }
864
+ catch (error) {
865
+ throw new EngineError(`reading ${BACKFILL_TABLE} failed: ${message(error)}`);
866
+ }
867
+ });
868
+ }
869
+ /** Append the new marker. Never update, so an interrupted run leaves a readable trail. */
870
+ async recordBackfillMarker(options) {
871
+ return this.usage.operation(async () => {
872
+ try {
873
+ await this.run(`INSERT INTO ${quote(BACKFILL_TABLE)} (${quote('materialization')}, ${quote('entity')}, ` +
874
+ `${quote('rows_copied')}) VALUES ($1, $2, $3)`, [options.materialization, options.entity, options.rows]);
875
+ }
876
+ catch (error) {
877
+ throw new EngineError(`recording backfill progress in ${BACKFILL_TABLE} failed: ${message(error)}`);
878
+ }
879
+ });
880
+ }
881
+ // --- transactions ------------------------------------------------------------------------
882
+ /**
883
+ * One engine, one transaction, that engine's semantics.
884
+ *
885
+ * There is no distributed transaction here and there will not be one. A client needing two
886
+ * entities to commit together declares that, and the planner puts them in the same group and
887
+ * therefore the same engine - so the requirement turns into a placement constraint instead of a
888
+ * two-phase commit. That is the trade this product makes, and it is why this method is a dozen
889
+ * lines rather than a subsystem.
890
+ */
891
+ async transaction(body) {
892
+ return this.usage.transaction(async () => {
893
+ const outer = this.transactions === 0;
894
+ const savepoint = `sde_scope_${++this.savepoint}`;
895
+ const client = this.cx;
896
+ this.transactions++;
897
+ let phase = 'begin';
898
+ try {
899
+ await client.query(outer ? 'BEGIN' : `SAVEPOINT ${savepoint}`);
900
+ phase = 'body';
901
+ const result = await body();
902
+ this.usage.idle();
903
+ this.usage.seal();
904
+ try {
905
+ // pg has no public transaction-status API; COMMIT can succeed with a ROLLBACK tag.
906
+ await client.query('SELECT 1');
907
+ }
908
+ catch {
909
+ throw new EngineError('the transaction is aborted or unavailable and cannot be reported as committed');
910
+ }
911
+ phase = 'commit';
912
+ await client.query(outer ? 'COMMIT' : `RELEASE SAVEPOINT ${savepoint}`);
913
+ return result;
914
+ }
915
+ catch (error) {
916
+ this.usage.seal();
917
+ if (phase !== 'body')
918
+ this.unusable = true;
919
+ try {
920
+ await client.query(outer ? 'ROLLBACK' : `ROLLBACK TO SAVEPOINT ${savepoint}`);
921
+ if (!outer)
922
+ await client.query(`RELEASE SAVEPOINT ${savepoint}`);
923
+ }
924
+ catch {
925
+ this.unusable = true;
926
+ }
927
+ if (phase !== 'body') {
928
+ const refused = new EngineError('transaction completion was uncertain; close() then connect() before reuse');
929
+ refused.cause = error;
930
+ throw refused;
931
+ }
932
+ throw error;
933
+ }
934
+ finally {
935
+ this.transactions--;
936
+ }
937
+ });
938
+ }
939
+ }
940
+ /**
941
+ * The bound to open a connection with: the caller's, in milliseconds, or this adapter's default.
942
+ *
943
+ * **`pg` does not read `connect_timeout` from a connection string, and finding that out is the
944
+ * whole reason this function exists.** `pg-connection-string` parses the parameter into a
945
+ * `connect_timeout` property, and `pg` then *overwrites* that property from
946
+ * `connectionTimeoutMillis` - so the value a caller wrote into their DSN reaches the JavaScript
947
+ * client and is discarded. Measured, because it is not the kind of thing a signature admits.
948
+ *
949
+ * The first version of this adapter did what the reference does: apply a default unless the DSN
950
+ * already says `connect_timeout`. In psycopg that is right, because the parameter goes through to
951
+ * libpq and libpq honours it. Here it produced the worst of both - the caller's value ignored by
952
+ * the driver and this adapter's default suppressed by the caller's value - so a DSN that asked for
953
+ * a two-second bound got **no bound at all**, which is the case the bound exists for. Found by the
954
+ * test that asserts the caller's value wins, which failed by hanging until its own deadline fired.
955
+ *
956
+ * So the parameter is translated rather than deferred to. The caller's decision is honoured and a
957
+ * bound always exists, which are the two properties that were meant to hold in the first place.
958
+ */
959
+ export function connectBound(dsn) {
960
+ const declared = /[?&]connect_timeout=(\d+)/.exec(dsn);
961
+ if (declared === null)
962
+ return CONNECT_TIMEOUT_MS;
963
+ const seconds = Number(declared[1]);
964
+ // Zero means "wait forever" in libpq, and a caller who wrote that has said something explicit
965
+ // about their own network. Passed through as `pg`'s own spelling of no timeout.
966
+ return seconds === 0 ? 0 : seconds * 1000;
967
+ }
968
+ async function loadDriver() {
969
+ try {
970
+ return await import('pg');
971
+ }
972
+ catch {
973
+ throw new EngineError("the PostgreSQL adapter needs the 'pg' package: npm install pg. The core library has no " +
974
+ 'dependencies, because it goes into your application and every dependency here would be ' +
975
+ 'one you inherit.');
976
+ }
977
+ }
978
+ function message(error) {
979
+ return error instanceof Error ? error.message : String(error);
980
+ }
981
+ //# sourceMappingURL=postgres.js.map