@jarenjs/db 0.56.0 → 0.67.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 (79) hide show
  1. package/ARCHITECTURE.md +412 -56
  2. package/README.md +600 -57
  3. package/docs/HOSTS.md +269 -0
  4. package/docs/JOBS-FORMAT.md +293 -45
  5. package/docs/LIVE-FORMAT.md +169 -20
  6. package/docs/MIGRATION-FORMAT.md +142 -17
  7. package/docs/MODEL-FORMAT.md +752 -64
  8. package/docs/REPLICATION-FORMAT.md +208 -0
  9. package/package.json +21 -7
  10. package/schemas/jaren-model.draft-07.schema.json +224 -162
  11. package/schemas/jaren-model.schema.json +224 -162
  12. package/schemas/jaren-replication-snapshot.draft-07.schema.json +83 -0
  13. package/schemas/jaren-replication-snapshot.schema.json +83 -0
  14. package/schemas/jaren-replication.draft-07.schema.json +82 -0
  15. package/schemas/jaren-replication.schema.json +82 -0
  16. package/src/algebra.js +227 -9
  17. package/src/backup.js +161 -0
  18. package/src/cancellation.js +48 -0
  19. package/src/capture.js +230 -47
  20. package/src/cli.js +165 -59
  21. package/src/cursor.js +417 -0
  22. package/src/dag-job.js +154 -21
  23. package/src/ddl.js +102 -8
  24. package/src/dialect.js +268 -113
  25. package/src/dialects/expression-read.js +158 -0
  26. package/src/dialects/postgres.js +618 -0
  27. package/src/dialects/rtree-ddl.js +129 -0
  28. package/src/dialects/sqlite.js +244 -11
  29. package/src/document-files.js +311 -0
  30. package/src/document-steps.js +422 -0
  31. package/src/documents.js +335 -0
  32. package/src/driver.js +448 -61
  33. package/src/drivers/bun.js +37 -1
  34. package/src/drivers/indexeddb-snapshot.js +149 -0
  35. package/src/drivers/node-pool.js +11 -0
  36. package/src/drivers/node-worker-endpoint.js +105 -0
  37. package/src/drivers/node-worker.js +204 -0
  38. package/src/drivers/node.js +41 -7
  39. package/src/drivers/postgres.js +331 -0
  40. package/src/drivers/wasm-oo1.js +97 -0
  41. package/src/drivers/wasm-session.js +67 -0
  42. package/src/drivers/wasm.js +17 -83
  43. package/src/drivers/worker-pool.js +183 -0
  44. package/src/drivers/worker-protocol.js +79 -0
  45. package/src/drivers/worker-queue.js +60 -0
  46. package/src/emit.js +339 -48
  47. package/src/entity.js +20 -22
  48. package/src/errors.js +430 -19
  49. package/src/expression.js +284 -0
  50. package/src/graph.js +64 -8
  51. package/src/index.js +48 -17
  52. package/src/introspect.js +583 -0
  53. package/src/jobs.js +843 -107
  54. package/src/json-bytes.js +58 -0
  55. package/src/live-join.js +250 -0
  56. package/src/live-nested.js +120 -0
  57. package/src/live.js +18 -4
  58. package/src/logical-rows.js +90 -0
  59. package/src/maintenance.js +175 -0
  60. package/src/migrate.js +248 -181
  61. package/src/model.js +68 -0
  62. package/src/plan.js +1119 -138
  63. package/src/pragmas.js +314 -0
  64. package/src/profile.js +151 -3
  65. package/src/query.js +1634 -323
  66. package/src/replication-format.js +115 -0
  67. package/src/replication.js +332 -0
  68. package/src/residual.js +17 -0
  69. package/src/series.js +12 -4
  70. package/src/store.js +1567 -273
  71. package/src/tracker.js +203 -29
  72. package/src/udf.js +88 -7
  73. package/types/index.d.ts +1158 -27
  74. package/types/node-pool.d.ts +28 -0
  75. package/types/node-worker.d.ts +54 -0
  76. package/types/node.d.ts +69 -2
  77. package/types/postgres.d.ts +46 -0
  78. package/types/typed.d.ts +27 -4
  79. package/types/wasm.d.ts +14 -0
@@ -0,0 +1,618 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The PostgreSQL dialect — the second spelling of the dialect
4
+ * contract, and the reason the contract exists.
5
+ *
6
+ * It imports no PostgreSQL client and no runtime builtin: it is pure
7
+ * text, exactly like the SQLite dialect beside it, so
8
+ * `@jarenjs/db/postgres` resolves in a browser bundle and type-checks
9
+ * with nothing installed. Transport is the driver's business
10
+ * (`src/drivers/postgres.js`), and the driver is INJECTED — a
11
+ * standards-shaped client or pool the host supplies.
12
+ *
13
+ * The mapping, in one paragraph. Documents are stored `jsonb`. An
14
+ * indexed path becomes a STORED generated column, because PostgreSQL
15
+ * has no indexable virtual one; its type is the schema's, and the
16
+ * expression is guarded by `jsonb_typeof` inside a `CASE` so a document
17
+ * whose member is the wrong JSON type stores `NULL` rather than failing
18
+ * the INSERT — a cast that can raise is not a legal generated-column
19
+ * expression. Text columns and text comparisons carry `COLLATE "C"`,
20
+ * which is byte order, which is what SQLite's default `BINARY`
21
+ * collation is: without it the same prefix range would hold different
22
+ * rows on a database initialised in another locale. Every table this
23
+ * dialect creates carries `rid bigserial`, because PostgreSQL has no
24
+ * per-row identity that survives an UPDATE (`ctid` moves) and a
25
+ * collection is a SEQUENCE — its order is insertion order, and the
26
+ * store orders by `rowIdentity()` wherever the model says so.
27
+ *
28
+ * What it does NOT do, declared rather than approximated: no
29
+ * configuration vocabulary (`pragmas: false` — a PostgreSQL server is
30
+ * configured by its operator, not by a store at open), no stored CREATE
31
+ * text (`declaredSqlText: false` — the drift check is the structural
32
+ * one), no virtual tables and no triggers (so a `physical: 'rtree'`
33
+ * column set maps back onto the B-tree over its four edge columns), no
34
+ * up-front write lock (`BEGIN IMMEDIATE` has no analogue), and no
35
+ * column without a scalar type — which is why a comparison against a
36
+ * member the schema does not type reads the document rather than the
37
+ * column.
38
+ */
39
+
40
+ import { createDialect } from '../dialect.js';
41
+ import { readExpression } from './expression-read.js';
42
+
43
+ /** PostgreSQL truncates an identifier past this many BYTES, silently
44
+ * and with only a notice — so two long generated names would collide
45
+ * and one index would quietly serve another column's path. */
46
+ export const IDENTIFIER_BYTES = 63;
47
+
48
+ const UTF8 = new TextEncoder();
49
+
50
+ /**
51
+ * @param {string} name
52
+ * @returns {string}
53
+ */
54
+ function quoteIdentifier(name) {
55
+ const text = String(name);
56
+ if (UTF8.encode(text).length > IDENTIFIER_BYTES) {
57
+ throw new TypeError(
58
+ `postgres dialect: the identifier '${text}' is longer than ${IDENTIFIER_BYTES} bytes, `
59
+ + 'which PostgreSQL truncates silently — two names that share a prefix would become one');
60
+ }
61
+ return `"${text.replace(/"/g, '""')}"`;
62
+ }
63
+
64
+ /**
65
+ * @param {string} value
66
+ * @returns {string}
67
+ */
68
+ function stringLiteral(value) {
69
+ return `'${String(value).replace(/'/g, "''")}'`;
70
+ }
71
+
72
+ /**
73
+ * A member path as the text of a PostgreSQL `text[]`, which is what
74
+ * `#>` and `jsonb_set` navigate by. Every element is quoted and its
75
+ * backslashes and quotes escaped, so a member named `a,b` or `}` is one
76
+ * element rather than two.
77
+ *
78
+ * Two shapes return `null`, and the caller then falls back to a
79
+ * whole-document strategy rather than addressing the wrong member: a
80
+ * NEGATIVE array index (`#>` counts from the front only — `jsonb_set`
81
+ * would accept one, and a path that meant different members to the read
82
+ * and the write would be worse than no path), and a name carrying a NUL,
83
+ * which no PostgreSQL text value can hold.
84
+ * @param {import('../dialect.js').JsonPathSegment[]} segments
85
+ * @returns {string | null}
86
+ */
87
+ function jsonPathText(segments) {
88
+ const parts = [];
89
+ for (const segment of segments) {
90
+ if ('index' in segment) {
91
+ if (segment.index < 0) return null;
92
+ parts.push(`"${segment.index}"`);
93
+ continue;
94
+ }
95
+ // eslint-disable-next-line no-control-regex
96
+ if (/[\u0000]/.test(segment.name)) return null;
97
+ parts.push(`"${segment.name.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`);
98
+ }
99
+ return `{${parts.join(',')}}`;
100
+ }
101
+
102
+ /**
103
+ * The same path with one more element on the end — how an append
104
+ * addresses the position after an array's last.
105
+ * @param {string} pathText
106
+ * @param {string} element
107
+ * @returns {string}
108
+ */
109
+ function pathWith(pathText, element) {
110
+ const body = pathText.slice(1, -1);
111
+ return `{${body.length === 0 ? '' : `${body},`}${element}}`;
112
+ }
113
+
114
+ /**
115
+ * The declared column type for a schema-declared value type.
116
+ *
117
+ * `numeric` carries BOTH JSON number types. PostgreSQL would take
118
+ * `bigint` for an integer member, and it would be the better index —
119
+ * but a document whose `integer` member holds `3.5` (a schema this
120
+ * store validates against only when a validator is injected) would then
121
+ * fail its INSERT inside a generated column's cast, and a storage
122
+ * decision that can reject a document the model accepts is not a
123
+ * storage decision. The cost is stated: `integer` and `number` are one
124
+ * column type here, and introspection cannot tell them apart.
125
+ *
126
+ * A path the schema does not type has no honest scalar type at all, so
127
+ * it is `jsonb` — indexable, comparable with another `jsonb`, and
128
+ * comparable with nothing else, which is exactly what
129
+ * `capabilities.untypedColumns: false` says.
130
+ * @param {string | undefined} schemaType
131
+ * @param {string} hint - `'key'` or `'generated'`
132
+ * @returns {string}
133
+ */
134
+ function typeFor(schemaType, hint) {
135
+ switch (schemaType) {
136
+ case 'string': return 'text COLLATE "C"';
137
+ case 'integer': return 'numeric';
138
+ case 'number': return 'numeric';
139
+ // 1 and 0, not TRUE and FALSE. A boolean MEMBER is 1 or 0 in the
140
+ // SQLite mapping, every shared form that touches one compares it
141
+ // against those integers (a type test, a projected pair's own type
142
+ // name), and a column that answered `true` would make each of those
143
+ // an operator-resolution error rather than a row. The loss is
144
+ // stated: introspection reads this column back as a number.
145
+ case 'boolean': return 'smallint';
146
+ default: return hint === 'key' ? 'text COLLATE "C"' : 'jsonb';
147
+ }
148
+ }
149
+
150
+ /** The catalog's own spelling of a type this dialect declares, so the
151
+ * drift check compares like with like: a collation, an allocation
152
+ * clause and a serial's expansion are all how a type is WRITTEN, not
153
+ * what the catalog reports it as. */
154
+ const TYPE_SYNONYMS = Object.freeze({
155
+ BIGSERIAL: 'BIGINT', SERIAL: 'INTEGER', SMALLSERIAL: 'SMALLINT',
156
+ INT8: 'BIGINT', INT4: 'INTEGER', INT2: 'SMALLINT', INT: 'INTEGER',
157
+ FLOAT8: 'DOUBLE PRECISION', FLOAT4: 'REAL', BOOL: 'BOOLEAN',
158
+ 'CHARACTER VARYING': 'VARCHAR', DECIMAL: 'NUMERIC',
159
+ });
160
+
161
+ /**
162
+ * @param {string} declaredType
163
+ * @returns {string}
164
+ */
165
+ function comparableColumnType(declaredType) {
166
+ const bare = String(declaredType)
167
+ .replace(/\s+COLLATE\s+("[^"]*"|\S+)/i, '')
168
+ .replace(/\s+GENERATED\s+.*$/i, '')
169
+ .replace(/\s+NOT\s+NULL$/i, '')
170
+ .trim()
171
+ .toUpperCase();
172
+ return TYPE_SYNONYMS[bare] ?? bare;
173
+ }
174
+
175
+ /**
176
+ * Whether a column of `declaredType` can be compared with a value of
177
+ * `kind` at all. PostgreSQL resolves an operator by type, so a text
178
+ * column against a numeric parameter is a parse error rather than a row
179
+ * that fails its guard — the emitter reads the member out of the
180
+ * document instead, which answers the same and merely does not seek.
181
+ * @param {string | undefined} declaredType
182
+ * @param {string} kind
183
+ * @returns {boolean}
184
+ */
185
+ function columnUsableFor(declaredType, kind) {
186
+ if (kind === 'any') return true;
187
+ switch (declaredType) {
188
+ case 'string': return kind === 'text';
189
+ case 'integer': case 'number': return kind === 'number';
190
+ case 'boolean': return kind === 'boolean';
191
+ default: return false;
192
+ }
193
+ }
194
+
195
+ /**
196
+ * The type discriminator, in the vocabulary the whole store shares —
197
+ * SQLite's, because the row decoder reads these names in JavaScript.
198
+ * `jsonb_typeof` answers six names and this maps the two that differ:
199
+ * a JSON string is `text`, and a JSON boolean is `true` or `false`
200
+ * (which is how a type test spells an equality against one). A JSON
201
+ * number stays `number`, and `numericTypeNames` is what tells the
202
+ * emitter so.
203
+ * @param {string} atSql - SQL for the member, as `jsonb`
204
+ * @returns {string}
205
+ */
206
+ function typeOfJsonb(atSql) {
207
+ return `(CASE WHEN jsonb_typeof(${atSql}) = 'string' THEN 'text' `
208
+ + `WHEN jsonb_typeof(${atSql}) = 'boolean' `
209
+ + `THEN (CASE WHEN (${atSql})::boolean THEN 'true' ELSE 'false' END) `
210
+ + `ELSE jsonb_typeof(${atSql}) END)`;
211
+ }
212
+
213
+ /**
214
+ * The member at a path, read as the SQL type a comparison of the given
215
+ * KIND needs. Every typed form is a `CASE` over `jsonb_typeof`, and the
216
+ * guard is INSIDE it for two reasons: a cast that can raise is not a
217
+ * legal generated-column expression, and `AND` does not short-circuit,
218
+ * so a guard beside the cast would not stop it either.
219
+ * @param {string} columnSql
220
+ * @param {string} pathText
221
+ * @param {string} [kind]
222
+ * @returns {string}
223
+ */
224
+ function jsonExtract(columnSql, pathText, kind) {
225
+ const at = `(${columnSql} #> ${stringLiteral(pathText)})`;
226
+ switch (kind) {
227
+ case 'text':
228
+ return `((CASE WHEN jsonb_typeof(${at}) = 'string' `
229
+ + `THEN ${columnSql} #>> ${stringLiteral(pathText)} END) COLLATE "C")`;
230
+ case 'number':
231
+ return `(CASE WHEN jsonb_typeof(${at}) = 'number' THEN (${at})::numeric END)`;
232
+ case 'boolean':
233
+ return `(CASE WHEN jsonb_typeof(${at}) = 'boolean' `
234
+ + `THEN (CASE WHEN (${at})::boolean THEN 1 ELSE 0 END) END)`;
235
+ // a projected SCALAR leaf: its text, whatever JSON type it is. The
236
+ // decoder reads the type name beside it and rebuilds the value, so
237
+ // one column that can carry every scalar is what it needs
238
+ case 'scalar':
239
+ return `(${columnSql} #>> ${stringLiteral(pathText)})`;
240
+ default:
241
+ return at;
242
+ }
243
+ }
244
+
245
+ /**
246
+ * The schema type a declared column type came from. Two mappings here
247
+ * are LOSSY on the way back and the introspector's report says so:
248
+ * `numeric` carries both `integer` and `number`, and `smallint` carries
249
+ * a boolean member's 1 and 0. `jsonb` carries no scalar type at all.
250
+ * @param {string} declaredType
251
+ * @returns {string | undefined}
252
+ */
253
+ function schemaTypeOf(declaredType) {
254
+ switch (comparableColumnType(declaredType)) {
255
+ case 'TEXT': case 'VARCHAR': case 'CHARACTER': return 'string';
256
+ case 'NUMERIC': case 'DOUBLE PRECISION': case 'REAL': return 'number';
257
+ case 'BIGINT': case 'INTEGER': case 'SMALLINT': return 'integer';
258
+ case 'BOOLEAN': return 'boolean';
259
+ default: return undefined;
260
+ }
261
+ }
262
+
263
+ /** The words a re-rendered expression puts before a parenthesis that
264
+ * are not function calls: `WHEN (`, `AND (`, and their kin. */
265
+ const SQL_WORDS = new Set(['CASE', 'WHEN', 'THEN', 'ELSE', 'END', 'AND', 'OR', 'NOT',
266
+ 'IN', 'IS', 'BETWEEN', 'LIKE', 'COLLATE', 'SELECT', 'WHERE']);
267
+
268
+ /**
269
+ * One PostgreSQL array literal's elements. The server RE-RENDERS the
270
+ * literal it stored — dropping the quotes a member did not need — so
271
+ * the parse has to take both forms, and a member named `a,b` comes back
272
+ * quoted for exactly that reason.
273
+ * @param {string} body - between the braces
274
+ * @returns {string[] | null}
275
+ */
276
+ function parseArrayLiteral(body) {
277
+ const out = [];
278
+ let i = 0;
279
+ if (body.length === 0) return out;
280
+ while (i <= body.length) {
281
+ if (body[i] === '"') {
282
+ let text = '';
283
+ i += 1;
284
+ while (i < body.length && body[i] !== '"') {
285
+ if (body[i] === '\\') { text += body[i + 1] ?? ''; i += 2; continue; }
286
+ text += body[i];
287
+ i += 1;
288
+ }
289
+ if (body[i] !== '"') return null;
290
+ out.push(text);
291
+ i += 1;
292
+ }
293
+ else {
294
+ const comma = body.indexOf(',', i);
295
+ const end = comma < 0 ? body.length : comma;
296
+ out.push(body.slice(i, end));
297
+ i = end;
298
+ }
299
+ if (i >= body.length) return out;
300
+ if (body[i] !== ',') return null;
301
+ i += 1;
302
+ }
303
+ return out;
304
+ }
305
+
306
+ /**
307
+ * The member path a generated column's expression reads, recovered from
308
+ * this dialect's own spelling. Every form it writes navigates with
309
+ * `#>` or `#>>` over an array literal, and the FIRST one is the member
310
+ * — the guard reads the same path, and a `CASE` cannot come before it.
311
+ * Anything else answers `null`, and the caller reports the column
312
+ * rather than inventing a path for it.
313
+ * @param {string} expression
314
+ * @returns {import('../dialect.js').JsonPathSegment[] | null}
315
+ */
316
+ function memberPathOf(expression) {
317
+ const text = String(expression);
318
+ // and NOTHING else calls: every form `jsonExtract` writes navigates
319
+ // with `#>` under at most a `jsonb_typeof` guard, so an expression
320
+ // that calls anything else is a DECLARED index expression rather than
321
+ // a member read — and reading it as a member would lose the index
322
+ for (const call of text.matchAll(/\b([A-Za-z_][A-Za-z0-9_]*)\s*\(/g)) {
323
+ if (call[1] === 'jsonb_typeof' || SQL_WORDS.has(call[1].toUpperCase())) continue;
324
+ return null;
325
+ }
326
+ const match = /#>>?\s*'\{((?:[^']|'')*)\}'/.exec(text);
327
+ if (match === null) return null;
328
+ const elements = parseArrayLiteral(match[1].replace(/''/g, "'"));
329
+ if (elements === null || elements.length === 0) return null;
330
+ // an element that spells a whole non-negative integer is an ARRAY
331
+ // INDEX here, exactly as it was on the way in
332
+ return elements.map((element) => (/^(?:0|[1-9][0-9]*)$/.test(element)
333
+ ? { index: Number(element) }
334
+ : { name: element }));
335
+ }
336
+
337
+ /**
338
+ * The declared index EXPRESSION a generated column computes, out of the
339
+ * SQL this dialect wrote for it. Here the engine calls its OWN
340
+ * immutable function, so `byName` maps the host's `sql` name back to the
341
+ * model's — a mapping only the host's declarations carry.
342
+ * @param {string} expression
343
+ * @param {Record<string, string>} byName
344
+ * @returns {any | null}
345
+ */
346
+ function expressionOf(expression, byName) {
347
+ return readExpression(expression, {
348
+ memberOf: (text) => {
349
+ // a member read here is `doc #>> '{…}'` and nothing else: the
350
+ // typed forms belong to a plain path index, not to an expression
351
+ if (!/#>>/.test(text)) return null;
352
+ const segments = memberPathOf(text);
353
+ return segments === null ? null : { member: pathOf(segments) };
354
+ },
355
+ nameOf: (name) => byName[name] ?? null,
356
+ stringOf: (text) => (/^'(?:[^']|'')*'(?:::text)?$/.test(text)
357
+ ? text.replace(/::text$/, '').slice(1, -1).replace(/''/g, "'") : null),
358
+ });
359
+ }
360
+
361
+ /** A recovered segment list, in the spelling a model's path takes. */
362
+ function pathOf(segments) {
363
+ let text = '$';
364
+ for (const segment of segments) {
365
+ if ('index' in segment) { text += `[${segment.index}]`; continue; }
366
+ text += /^[A-Za-z_][A-Za-z0-9_]*$/.test(segment.name)
367
+ ? `.${segment.name}` : `[${JSON.stringify(segment.name)}]`;
368
+ }
369
+ return text;
370
+ }
371
+
372
+ /**
373
+ * Build the dialect. A function rather than a constant because a host
374
+ * may narrow it — today only the schema search path is worth naming,
375
+ * and the default is the connection's own, which is what a disposable
376
+ * per-run schema needs.
377
+ * @param {{ searchPath?: string }} [options]
378
+ * @returns {any}
379
+ */
380
+ export function postgresDialect(options = undefined) {
381
+ // the namespaces a catalog query looks in: the connection's own
382
+ // search path unless the host names one, so a store opened against a
383
+ // disposable schema introspects that schema and not `public`
384
+ const namespaces = options?.searchPath === undefined
385
+ ? 'ANY(current_schemas(false))'
386
+ : stringLiteral(options.searchPath);
387
+ const inNamespace = `n.nspname = ${namespaces}`;
388
+
389
+ return createDialect({
390
+ name: 'postgres',
391
+ capabilities: {
392
+ jsonb: true,
393
+ generatedColumns: true,
394
+ // STORED ones are; PostgreSQL 18's VIRTUAL ones are not, which is
395
+ // why this dialect declares STORED and this capability true
396
+ indexableGeneratedColumns: true,
397
+ untypedColumns: false,
398
+ returning: true,
399
+ upsert: true,
400
+ savepoints: true,
401
+ // no `BEGIN IMMEDIATE`: a PostgreSQL transaction takes its locks
402
+ // as it needs them, and a read-then-write body meets a
403
+ // serialization failure rather than a busy database
404
+ immediateTransactions: false,
405
+ // a GROUP BY / ORDER BY term may name an output column
406
+ groupByAlias: true,
407
+ alterTableFull: true,
408
+ virtualTables: false,
409
+ triggers: false,
410
+ pragmas: false,
411
+ declaredSqlText: false,
412
+ foreignKeysAlwaysOn: true,
413
+ // through the declared `rid` column, not through `ctid`
414
+ rowIdentity: true,
415
+ },
416
+ tableSuffix: '',
417
+ // PostgreSQL has no indexable virtual generated column, so an
418
+ // indexed path is materialised
419
+ generatedStorage: 'STORED',
420
+ // the identity every table this dialect creates carries. NOT
421
+ // `GENERATED ALWAYS AS IDENTITY`: PostgreSQL allows one identity
422
+ // column per table, and a collection with a database-allocated
423
+ // integer key needs that slot for its key
424
+ identityColumn: { name: 'rid', type: 'bigserial' },
425
+ autoKeyType: 'bigserial',
426
+ epochFromRfc3339: (valueSql) =>
427
+ `round(EXTRACT(EPOCH FROM (${valueSql})::timestamptz) * 1000)::bigint`,
428
+ docColumnType: 'jsonb',
429
+ // the packed little-endian binary32 form of a `derive: 'vector'`
430
+ // column. Deliberately NOT `vector` from pgvector: a model may not
431
+ // name a vendor type, and the k-nearest cut is the engine's
432
+ packedVectorType: 'bytea',
433
+ quoteIdentifier,
434
+ parameterRef: (i) => `$${i}`,
435
+ stringLiteral,
436
+ booleanLiteral: (b) => (b ? 'TRUE' : 'FALSE'),
437
+ typeFor,
438
+ comparableColumnType,
439
+ columnUsableFor,
440
+ limitClause: (limit, offset) => (offset !== undefined && offset > 0
441
+ ? `LIMIT ${limit === null ? 'ALL' : limit} OFFSET ${offset}`
442
+ : `LIMIT ${limit === null ? 'ALL' : limit}`),
443
+ jsonPathText,
444
+ jsonExtract,
445
+ // a DERIVED column's expression names a function the HOST supplies;
446
+ // this dialect creates none and assumes none. Every driver this
447
+ // package ships for PostgreSQL declares
448
+ // `deterministicIndexableFunctions: false`, so the store computes
449
+ // these values and writes them into ordinary columns — which is why
450
+ // asking for the expression is a planner defect, exactly as it is
451
+ // for a vector column on every driver
452
+ derivedExpression: (memberSql, column) => {
453
+ throw new TypeError(
454
+ `postgres dialect: no generated-column expression for derive kind '${column.derive}' `
455
+ + '— a derived column is STORED here, and the store writes its value');
456
+ },
457
+ jsonSet: (exprSql, pathText, valueSql) =>
458
+ `jsonb_set(${exprSql}, ${stringLiteral(pathText)}, ${valueSql}, true)`,
459
+ jsonRemove: (exprSql, pathText) =>
460
+ `(${exprSql} #- ${stringLiteral(pathText)})`,
461
+ // `-1` is the position AFTER the array's last element when
462
+ // `insert_after` is true, which is the append this translates
463
+ jsonAppend: (exprSql, arrayPathText, valueSql) =>
464
+ `jsonb_insert(${exprSql}, ${stringLiteral(pathWith(arrayPathText, '-1'))}, ${valueSql}, true)`,
465
+ jsonEncode: (paramSql) => `(${paramSql})::jsonb`,
466
+ jsonText: (columnSql) => `(${columnSql})::text`,
467
+ jsonEmbed: (columnSql) => `(${columnSql})`,
468
+ jsonAgg: (exprSql) => `jsonb_agg(${exprSql})`,
469
+ jsonObject: (pairsSql) => `jsonb_build_object(${pairsSql})`,
470
+ jsonTypeOf: (columnSql, pathText) =>
471
+ typeOfJsonb(`(${columnSql} #> ${stringLiteral(pathText)})`),
472
+ // one JSON number type, so one name
473
+ numericTypeNames: ['number'],
474
+ // An external's value is bound as its JSON TEXT. A PostgreSQL
475
+ // parameter's type is resolved where it is used, once, for the
476
+ // whole statement — so one placeholder cannot be a text member's
477
+ // operand in one branch and a numeric member's in another, and the
478
+ // guard that keeps a row out of the wrong branch does not keep the
479
+ // COERCION out. Bound as JSON, both branches compare in a space
480
+ // that holds every scalar: text against text under `C`, and number
481
+ // against number in `jsonb`'s own numeric order.
482
+ externalEncoding: 'json',
483
+ externalCompare: (valueSql, kind) =>
484
+ (kind === 'number' ? `to_jsonb(${valueSql})` : valueSql),
485
+ externalRef: (paramSql, kind) => (kind === 'text'
486
+ ? `((((${paramSql})::jsonb) #>> '{}') COLLATE "C")`
487
+ : `((${paramSql})::jsonb)`),
488
+ valueTypeOf: (paramSql) => typeOfJsonb(`((${paramSql})::jsonb)`),
489
+ // the half-open range over the prefix, seekable through a B-tree on
490
+ // a `C`-collated column. Both operands are byte-ordered — the column
491
+ // by its declaration, the extracted member by the `COLLATE` in
492
+ // `jsonExtract` — which is what makes the range hold exactly the
493
+ // values that begin with the prefix
494
+ strStartsWith: (valueSql, lowerParamSql, upperParamSql) =>
495
+ `(${valueSql} >= ${lowerParamSql} AND ${valueSql} < ${upperParamSql})`,
496
+ strStartsWithExact: (valueSql, patternA, patternB) =>
497
+ `substr(${valueSql}, 1, length(${patternA}::text)) = ${patternB}`,
498
+ strEndsWith: (valueSql, patternA, patternB, patternC) =>
499
+ `(length(${patternA}::text) = 0 OR right(${valueSql}, length(${patternB}::text)) = ${patternC})`,
500
+ strContains: (valueSql, patternSql) => `strpos(${valueSql}, ${patternSql}::text) > 0`,
501
+ orderNulls: (nullsFirst) => (nullsFirst ? ' NULLS FIRST' : ' NULLS LAST'),
502
+ // the same fixed ladder, in exact arithmetic: `%` truncates towards
503
+ // zero here as it does in C, so `((x % m) + m) % m` is the
504
+ // non-negative remainder and an instant before 1970 lands in its own
505
+ // bucket rather than the one after it
506
+ timeBucket: (instantSql, originSql, everyA, everyB, everyC) =>
507
+ `(${instantSql} - (((${instantSql} - ${originSql}) % ${everyA} + ${everyB}) % ${everyC}))`,
508
+ groupAggregate: (fn, valueSql) => (valueSql === null
509
+ ? 'COUNT(*)'
510
+ : `${{ sum: 'SUM', avg: 'AVG', min: 'MIN', max: 'MAX' }[fn]}(${valueSql})`),
511
+ rowIdentity: () => '"rid"',
512
+ identityIn: (identitySql, paramSqls) => `${identitySql} IN (${paramSqls.join(', ')})`,
513
+ schemaTypeOf,
514
+ memberPathOf,
515
+ expressionOf,
516
+ // the catalog already answers one row per generated column
517
+ readGenerated: (rows) => rows.map((row) => ({
518
+ name: String(row.name), expression: String(row.expression ?? ''),
519
+ })),
520
+ explainQuery: (sql) => `EXPLAIN ${sql}`,
521
+ // PostgreSQL's plan is prose too, under a column whose name has a
522
+ // space in it
523
+ explainLines: (rows) => rows.map((row) => String(row['QUERY PLAN'] ?? row.plan ?? '')),
524
+ isFullScan: (line, tables) => {
525
+ const match = /\bSeq Scan on (?:\w+\.)?"?([^\s"]+)"?/.exec(line);
526
+ if (match === null) return false;
527
+ // a join statement aliases its tables `t0`, `t1`, …, and the plan
528
+ // names the relation with the alias after it
529
+ return tables.includes(match[1]) || /\bSeq Scan on \S+ t\d+\b/.test(line);
530
+ },
531
+ usesIndex: (line, index) =>
532
+ line.includes(`Index Scan using ${index}`)
533
+ || line.includes(`Index Only Scan using ${index}`)
534
+ || line.includes(`Bitmap Index Scan on ${index}`),
535
+ excludedRef: (columnSql) => `excluded.${columnSql}`,
536
+ tx: {
537
+ begin: 'BEGIN',
538
+ // no up-front write lock exists; the capability says so and the
539
+ // store's `mode: 'immediate'` is the same transaction here
540
+ beginImmediate: 'BEGIN',
541
+ commit: 'COMMIT',
542
+ rollback: 'ROLLBACK',
543
+ savepoint: (n) => `SAVEPOINT ${quoteIdentifier(n)}`,
544
+ release: (n) => `RELEASE SAVEPOINT ${quoteIdentifier(n)}`,
545
+ rollbackTo: (n) => `ROLLBACK TO SAVEPOINT ${quoteIdentifier(n)}`,
546
+ },
547
+ introspect: {
548
+ version: () => "SELECT current_setting('server_version') AS version",
549
+ // the table probe binds its name, so a hostile collection name is
550
+ // a value and never syntax
551
+ tableExists: () =>
552
+ 'SELECT c.relname AS name FROM pg_class c '
553
+ + 'JOIN pg_namespace n ON n.oid = c.relnamespace '
554
+ + `WHERE c.relkind IN ('r', 'p') AND ${inNamespace} AND c.relname = $1`,
555
+ // `hidden` is non-zero for a GENERATED column, which is the one
556
+ // fact the shape check reads beside the name and the type
557
+ columns: (table) =>
558
+ 'SELECT a.attname AS name, format_type(a.atttypid, a.atttypmod) AS type, '
559
+ + "CASE WHEN a.attgenerated <> '' THEN 1 ELSE 0 END AS hidden "
560
+ + 'FROM pg_attribute a JOIN pg_class c ON c.oid = a.attrelid '
561
+ + 'JOIN pg_namespace n ON n.oid = c.relnamespace '
562
+ + `WHERE c.relname = ${stringLiteral(table)} AND ${inNamespace} `
563
+ + 'AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum',
564
+ // `origin` mirrors the vocabulary the shape check filters on:
565
+ // only an index the model DECLARED is compared, and the primary
566
+ // key's is the engine's own
567
+ indexes: (table) =>
568
+ 'SELECT ci.relname AS name, CASE WHEN i.indisunique THEN 1 ELSE 0 END AS uniq, '
569
+ + "CASE WHEN i.indisprimary THEN 'pk' ELSE 'c' END AS origin "
570
+ + 'FROM pg_index i JOIN pg_class ci ON ci.oid = i.indexrelid '
571
+ + 'JOIN pg_class ct ON ct.oid = i.indrelid '
572
+ + 'JOIN pg_namespace n ON n.oid = ct.relnamespace '
573
+ + `WHERE ct.relname = ${stringLiteral(table)} AND ${inNamespace} `
574
+ + 'ORDER BY ci.relname',
575
+ // in the index's OWN column order: `(a, b)` and `(b, a)` are
576
+ // different indexes, and only one of them serves an `a` prefix
577
+ indexColumns: (index) =>
578
+ 'SELECT a.attname AS name FROM pg_index i '
579
+ + 'JOIN pg_class ci ON ci.oid = i.indexrelid '
580
+ + 'JOIN pg_namespace n ON n.oid = ci.relnamespace '
581
+ + 'CROSS JOIN LATERAL unnest(i.indkey::int2[]) WITH ORDINALITY AS k(attnum, ord) '
582
+ + 'JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = k.attnum '
583
+ + `WHERE ci.relname = ${stringLiteral(index)} AND ${inNamespace} ORDER BY k.ord`,
584
+ // every table this store might own; a view is reported rather
585
+ // than derived, and the engine's own schemas are never in scope
586
+ tables: () =>
587
+ "SELECT c.relname AS name, CASE WHEN c.relkind IN ('v', 'm') THEN 'view' "
588
+ + "ELSE 'table' END AS type FROM pg_class c "
589
+ + 'JOIN pg_namespace n ON n.oid = c.relnamespace '
590
+ + `WHERE c.relkind IN ('r', 'p', 'v', 'm') AND ${inNamespace} `
591
+ + 'ORDER BY type, c.relname',
592
+ generated: (table) =>
593
+ 'SELECT a.attname AS name, pg_get_expr(d.adbin, d.adrelid) AS expression '
594
+ + 'FROM pg_attrdef d '
595
+ + 'JOIN pg_attribute a ON a.attrelid = d.adrelid AND a.attnum = d.adnum '
596
+ + 'JOIN pg_class c ON c.oid = d.adrelid '
597
+ + 'JOIN pg_namespace n ON n.oid = c.relnamespace '
598
+ + `WHERE c.relname = ${stringLiteral(table)} AND ${inNamespace} `
599
+ + "AND a.attgenerated <> '' ORDER BY a.attnum",
600
+ foreignKeyList: (table) => {
601
+ const action = (column) => `CASE ${column} `
602
+ + "WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' WHEN 'c' THEN 'CASCADE' "
603
+ + "WHEN 'n' THEN 'SET NULL' WHEN 'd' THEN 'SET DEFAULT' ELSE 'NO ACTION' END";
604
+ return 'SELECT tt.relname AS target, sa.attname AS source_column, '
605
+ + `ta.attname AS target_column, ${action('c.confdeltype')} AS on_delete, `
606
+ + `${action('c.confupdtype')} AS on_update, k.ord - 1 AS seq `
607
+ + 'FROM pg_constraint c JOIN pg_class ct ON ct.oid = c.conrelid '
608
+ + 'JOIN pg_namespace n ON n.oid = ct.relnamespace '
609
+ + 'JOIN pg_class tt ON tt.oid = c.confrelid '
610
+ + 'CROSS JOIN LATERAL unnest(c.conkey, c.confkey) WITH ORDINALITY AS k(src, tgt, ord) '
611
+ + 'JOIN pg_attribute sa ON sa.attrelid = c.conrelid AND sa.attnum = k.src '
612
+ + 'JOIN pg_attribute ta ON ta.attrelid = c.confrelid AND ta.attnum = k.tgt '
613
+ + `WHERE c.contype = 'f' AND ct.relname = ${stringLiteral(table)} AND ${inNamespace} `
614
+ + 'ORDER BY c.conname, k.ord';
615
+ },
616
+ },
617
+ });
618
+ }