turbine-orm 0.33.0 → 0.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/cjs/client.js +26 -4
- package/dist/cjs/dialect.js +1 -0
- package/dist/cjs/errors.js +41 -1
- package/dist/cjs/index-advisor.js +0 -0
- package/dist/cjs/index.js +4 -2
- package/dist/cjs/mssql.js +5 -0
- package/dist/cjs/mysql.js +4 -0
- package/dist/cjs/optional-peer-import.cjs +28 -0
- package/dist/cjs/powdb-introspect.js +222 -0
- package/dist/cjs/powdb.js +592 -72
- package/dist/cjs/powql.js +998 -134
- package/dist/cjs/query/builder.js +72 -1
- package/dist/cjs/schema-builder.js +16 -0
- package/dist/cjs/schema-metadata.js +81 -10
- package/dist/cjs/sqlite.js +3 -0
- package/dist/client.d.ts +32 -5
- package/dist/client.js +26 -4
- package/dist/dialect.d.ts +13 -0
- package/dist/dialect.js +1 -0
- package/dist/errors.d.ts +36 -0
- package/dist/errors.js +39 -0
- package/dist/index-advisor.d.ts +15 -1
- package/dist/index-advisor.js +0 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/mssql.js +5 -0
- package/dist/mysql.js +4 -0
- package/dist/optional-peer-import.cjs +28 -0
- package/dist/optional-peer-import.d.cts +19 -0
- package/dist/powdb-introspect.d.ts +84 -0
- package/dist/powdb-introspect.js +219 -0
- package/dist/powdb.d.ts +361 -19
- package/dist/powdb.js +585 -72
- package/dist/powql.d.ts +245 -8
- package/dist/powql.js +1001 -137
- package/dist/query/builder.d.ts +36 -1
- package/dist/query/builder.js +72 -1
- package/dist/query/deferred.d.ts +6 -2
- package/dist/query/types.d.ts +49 -12
- package/dist/schema-builder.d.ts +46 -1
- package/dist/schema-builder.js +15 -0
- package/dist/schema-metadata.d.ts +13 -7
- package/dist/schema-metadata.js +82 -11
- package/dist/schema.d.ts +25 -0
- package/dist/sqlite.js +3 -0
- package/package.json +3 -3
package/dist/powdb.js
CHANGED
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
56
56
|
import { TurbineClient, } from './client.js';
|
|
57
57
|
import { postgresDialect } from './dialect.js';
|
|
58
|
-
import { ConnectionError, NotNullViolationError, TimeoutError, UniqueConstraintError, UnsupportedFeatureError, ValidationError, } from './errors.js';
|
|
58
|
+
import { ConnectionError, NotNullViolationError, ReadOnlyError, TimeoutError, UniqueConstraintError, UnsupportedFeatureError, ValidationError, } from './errors.js';
|
|
59
59
|
import importOptionalPeer from './optional-peer-import.cjs';
|
|
60
60
|
/**
|
|
61
61
|
* Capability descriptor for PowDB. PowQL generation is owned by
|
|
@@ -124,6 +124,22 @@ export class PowdbFloatParam {
|
|
|
124
124
|
this.value = value;
|
|
125
125
|
}
|
|
126
126
|
}
|
|
127
|
+
/**
|
|
128
|
+
* Marker wrapper for a JS object/array bound to a `json` document column. Both
|
|
129
|
+
* transports serialize `value` with `JSON.stringify` and send the text as a
|
|
130
|
+
* `str` param / string literal, exactly how the PowDB docs insert a json
|
|
131
|
+
* document (the engine validates it as JSON text and stores the canonical
|
|
132
|
+
* binary form). Constructed in {@link PowqlInterface.param} when the target
|
|
133
|
+
* column is `json` and the value is a non-null object/array; a JS string
|
|
134
|
+
* written to a json column passes through RAW (same contract as pg jsonb,
|
|
135
|
+
* pass `'"x"'` to store the JSON string `"x"`), and `null` stays `null`.
|
|
136
|
+
*/
|
|
137
|
+
export class PowdbJsonParam {
|
|
138
|
+
value;
|
|
139
|
+
constructor(value) {
|
|
140
|
+
this.value = value;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
127
143
|
/** Minimum PowDB server version the networked transport requires. */
|
|
128
144
|
export const MIN_POWDB_VERSION = '0.7.0';
|
|
129
145
|
/**
|
|
@@ -174,19 +190,119 @@ export function assertSupportedPowdbVersion(version) {
|
|
|
174
190
|
throw new ConnectionError(`[turbine] turbine-orm/powdb requires PowDB >= ${MIN_POWDB_VERSION}; the server reports "${version}". ` +
|
|
175
191
|
'Upgrade the PowDB server (0.7.0 added the `returning` keyword and the int->float coercion fix Turbine relies on).');
|
|
176
192
|
}
|
|
193
|
+
/** Minimum engine version each gated feature needs, for the E017 hint text. */
|
|
194
|
+
const POWDB_FEATURE_MIN_VERSION = {
|
|
195
|
+
introspection: '0.10',
|
|
196
|
+
jsonDocs: '0.12',
|
|
197
|
+
docFieldIndexes: '0.13',
|
|
198
|
+
serverJoins: '0.13',
|
|
199
|
+
};
|
|
200
|
+
/**
|
|
201
|
+
* Trusted-caller default: every FEATURE gate on, engine version unknown. Used
|
|
202
|
+
* for a directly-constructed {@link PowdbPool} / {@link PowdbEmbeddedPool} that
|
|
203
|
+
* did not go through {@link turbinePowDB}'s version probe (e.g. an injected
|
|
204
|
+
* pool, or a unit-test pool). `nativeRaw` stays OFF here because it flips the
|
|
205
|
+
* actual wire path and must only be enabled after a real server-version probe,
|
|
206
|
+
* never inferred from a bare construction.
|
|
207
|
+
*/
|
|
208
|
+
export const ALL_POWDB_CAPABILITIES = {
|
|
209
|
+
engineVersion: null,
|
|
210
|
+
jsonDocs: true,
|
|
211
|
+
docFieldIndexes: true,
|
|
212
|
+
introspection: true,
|
|
213
|
+
serverJoins: true,
|
|
214
|
+
nativeRaw: false,
|
|
215
|
+
};
|
|
216
|
+
/** Parse a PowDB semver prefix (`0.13.0`, `0.13`, `1.2.3-rc`) into components, or `null`. */
|
|
217
|
+
function parsePowdbSemver(version) {
|
|
218
|
+
const m = /^(\d+)\.(\d+)(?:\.(\d+))?/.exec(String(version ?? '').trim());
|
|
219
|
+
if (!m)
|
|
220
|
+
return null;
|
|
221
|
+
return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3] ?? 0) };
|
|
222
|
+
}
|
|
223
|
+
/** Is `sem` at least `major.minor`? */
|
|
224
|
+
function atLeastVersion(sem, major, minor) {
|
|
225
|
+
return sem.major > major || (sem.major === major && sem.minor >= minor);
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Derive {@link PowdbCapabilities} from an engine version string. A non-semver /
|
|
229
|
+
* unknown version turns every gate OFF (the E017 hint then tells the caller to
|
|
230
|
+
* upgrade or pass `assumeEngineVersion`). `nativeRaw` requires BOTH the client
|
|
231
|
+
* to expose `queryNativeRaw` (passed in) AND server ≥ 0.13.
|
|
232
|
+
*/
|
|
233
|
+
export function capabilitiesFromVersion(version, opts = {}) {
|
|
234
|
+
const sem = parsePowdbSemver(version);
|
|
235
|
+
if (!sem) {
|
|
236
|
+
return {
|
|
237
|
+
engineVersion: version ?? null,
|
|
238
|
+
jsonDocs: false,
|
|
239
|
+
docFieldIndexes: false,
|
|
240
|
+
introspection: false,
|
|
241
|
+
serverJoins: false,
|
|
242
|
+
nativeRaw: false,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
engineVersion: `${sem.major}.${sem.minor}.${sem.patch}`,
|
|
247
|
+
introspection: atLeastVersion(sem, 0, 10),
|
|
248
|
+
jsonDocs: atLeastVersion(sem, 0, 12),
|
|
249
|
+
docFieldIndexes: atLeastVersion(sem, 0, 13),
|
|
250
|
+
serverJoins: atLeastVersion(sem, 0, 13),
|
|
251
|
+
nativeRaw: Boolean(opts.hasNativeRaw) && atLeastVersion(sem, 0, 13),
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Throw a version-hinting {@link UnsupportedFeatureError} (E017) when a gated
|
|
256
|
+
* PowQL feature is used on an engine that does not support it. Keeps old engines
|
|
257
|
+
* getting clean typed errors instead of raw PowQL parse failures.
|
|
258
|
+
*/
|
|
259
|
+
export function requireCapability(caps, key, feature) {
|
|
260
|
+
if (caps[key])
|
|
261
|
+
return;
|
|
262
|
+
const min = POWDB_FEATURE_MIN_VERSION[key];
|
|
263
|
+
const reported = caps.engineVersion
|
|
264
|
+
? `this connection reports ${caps.engineVersion}`
|
|
265
|
+
: 'this connection could not report a version';
|
|
266
|
+
throw new UnsupportedFeatureError(feature, 'PowDB', `${feature} requires PowDB >= ${min}; ${reported}. Upgrade powdb-server / @zvndev/powdb-embedded ` +
|
|
267
|
+
'(or pass `assumeEngineVersion` if the version cannot be detected).');
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Does this column map to PowDB's native `json` document type? A Postgres
|
|
271
|
+
* `json`/`jsonb` type (via `dialectType`/`pgType`) is authoritative; otherwise
|
|
272
|
+
* the tsType heuristic (`Record<…>`, `object`, `unknown`, an object/array
|
|
273
|
+
* literal) that the four scalar branches do not claim. Array columns never map
|
|
274
|
+
* to json, a PowDB array only exists INSIDE a json document, so a Postgres
|
|
275
|
+
* array column has no PowDB shape and still throws in {@link powqlColumnType}.
|
|
276
|
+
*/
|
|
277
|
+
export function isJsonColumn(col) {
|
|
278
|
+
if (col.isArray)
|
|
279
|
+
return false;
|
|
280
|
+
const dbType = (col.dialectType ?? col.pgType ?? '').toLowerCase();
|
|
281
|
+
if (dbType === 'json' || dbType === 'jsonb')
|
|
282
|
+
return true;
|
|
283
|
+
const ts = col.tsType.replace(/\s*\|\s*null$/i, '').trim();
|
|
284
|
+
if (ts === 'Date' || ts === 'boolean' || ts === 'number' || ts === 'bigint' || ts === 'string')
|
|
285
|
+
return false;
|
|
286
|
+
if (ts === 'Buffer' || ts === 'Uint8Array')
|
|
287
|
+
return false;
|
|
288
|
+
return /Record<|object|unknown|\[\]|\{/.test(ts);
|
|
289
|
+
}
|
|
177
290
|
/**
|
|
178
291
|
* Map a Turbine column to the PowQL DDL type used in `defineSchema` →
|
|
179
292
|
* `type T { … }`. Turbine never emits PowDB's `uuid`/`datetime`/`bytes` types,
|
|
180
293
|
* which cannot hold client-supplied values on the wire (no literal, no cast):
|
|
181
294
|
* - `Date` → `int` (epoch micros) - `boolean` → `bool`
|
|
182
295
|
* - integral `number`/`bigint` → `int` - fractional `number` → `float`
|
|
296
|
+
* - JSON / object columns → `json` (native PowDB document type, ≥ 0.12)
|
|
183
297
|
* - everything else (incl. UUID/PK strings) → `str`
|
|
184
|
-
* Array
|
|
298
|
+
* Array (non-json) and bytes columns throw, they have no PowDB equivalent.
|
|
185
299
|
*/
|
|
186
300
|
export function powqlColumnType(col) {
|
|
187
301
|
if (col.isArray) {
|
|
188
302
|
throw new ValidationError(`[turbine] Column "${col.name}" is an array — PowDB has no array type. Arrays are unsupported on the PowDB backend.`);
|
|
189
303
|
}
|
|
304
|
+
if (isJsonColumn(col))
|
|
305
|
+
return 'json';
|
|
190
306
|
const ts = col.tsType.replace(/\s*\|\s*null$/i, '').trim();
|
|
191
307
|
if (ts === 'Date')
|
|
192
308
|
return 'int'; // epoch micros
|
|
@@ -201,9 +317,6 @@ export function powqlColumnType(col) {
|
|
|
201
317
|
if (ts === 'Buffer' || ts === 'Uint8Array') {
|
|
202
318
|
throw new ValidationError(`[turbine] Column "${col.name}" is binary — PowDB cannot store client-supplied bytes on the wire. Use a string (e.g. base64) instead.`);
|
|
203
319
|
}
|
|
204
|
-
if (/Record<|object|unknown|\[\]|\{/.test(ts)) {
|
|
205
|
-
throw new ValidationError(`[turbine] Column "${col.name}" (${col.tsType}) maps to JSON/object, which PowDB has no type for. Flatten it or store a JSON string.`);
|
|
206
|
-
}
|
|
207
320
|
return 'str';
|
|
208
321
|
}
|
|
209
322
|
/** Heuristic: does this numeric column hold fractional values (→ PowQL `float`)? */
|
|
@@ -343,7 +456,8 @@ export function quotePowqlIdent(name) {
|
|
|
343
456
|
}
|
|
344
457
|
return POWQL_KEYWORDS.has(name) || !POWQL_BARE_IDENT.test(name) ? `\`${name}\`` : name;
|
|
345
458
|
}
|
|
346
|
-
export function powqlSchemaDDL(schema) {
|
|
459
|
+
export function powqlSchemaDDL(schema, opts = {}) {
|
|
460
|
+
const caps = opts.capabilities;
|
|
347
461
|
const stmts = [];
|
|
348
462
|
for (const meta of Object.values(schema.tables)) {
|
|
349
463
|
const pkSet = new Set(meta.primaryKey);
|
|
@@ -354,6 +468,12 @@ export function powqlSchemaDDL(schema) {
|
|
|
354
468
|
// cannot enforce the tuple's uniqueness at the engine level.
|
|
355
469
|
const pkIsSingle = meta.primaryKey.length === 1;
|
|
356
470
|
const fields = meta.columns.map((col) => {
|
|
471
|
+
const powqlType = powqlColumnType(col);
|
|
472
|
+
// Gate `json` columns behind the engine's jsonDocs capability when a
|
|
473
|
+
// caller supplied one, an old engine has no `json` type and would reject
|
|
474
|
+
// the DDL. Pure-function callers (no opts) emit unconditionally.
|
|
475
|
+
if (powqlType === 'json' && caps)
|
|
476
|
+
requireCapability(caps, 'jsonDocs', 'JSON document columns');
|
|
357
477
|
const mods = [];
|
|
358
478
|
if (!col.nullable || pkSet.has(col.name))
|
|
359
479
|
mods.push('required');
|
|
@@ -362,15 +482,57 @@ export function powqlSchemaDDL(schema) {
|
|
|
362
482
|
// `auto` = server-generated monotonic int. PowDB requires it be `int` and
|
|
363
483
|
// rejects it alongside a `default`; non-int generated columns fall back to
|
|
364
484
|
// a plain typed column (Turbine assigns the value client-side instead).
|
|
365
|
-
if (col.isGenerated &&
|
|
485
|
+
if (col.isGenerated && powqlType === 'int')
|
|
366
486
|
mods.push('auto');
|
|
367
|
-
return ` ${mods.join(' ')}${mods.length ? ' ' : ''}${quotePowqlIdent(col.name)}: ${
|
|
487
|
+
return ` ${mods.join(' ')}${mods.length ? ' ' : ''}${quotePowqlIdent(col.name)}: ${powqlType}`;
|
|
368
488
|
});
|
|
369
489
|
stmts.push(`type ${quotePowqlIdent(meta.name)} {\n${fields.join(',\n')}\n}`);
|
|
490
|
+
// Track which single columns already carry a unique constraint (the
|
|
491
|
+
// single-column PK is inlined `required unique` in the type body above) so
|
|
492
|
+
// a redundant `add unique .col` is never emitted twice.
|
|
493
|
+
const emittedUnique = new Set();
|
|
494
|
+
if (pkIsSingle && meta.primaryKey[0] !== undefined)
|
|
495
|
+
emittedUnique.add(meta.primaryKey[0]);
|
|
370
496
|
// Secondary unique constraints (beyond the PK) become unique indexes.
|
|
371
497
|
for (const uniq of meta.uniqueColumns) {
|
|
372
498
|
if (uniq.length === 1 && !pkSet.has(uniq[0])) {
|
|
373
499
|
stmts.push(`alter ${quotePowqlIdent(meta.name)} add unique .${quotePowqlIdent(uniq[0])}`);
|
|
500
|
+
emittedUnique.add(uniq[0]);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
// Declared indexes: PowDB doc-field expression indexes (docPath) and plain
|
|
504
|
+
// single-column indexes. A doc-field index MUST be parenthesized (the engine
|
|
505
|
+
// rejects a bare JSON path); string path segments emit lexer-exact via the
|
|
506
|
+
// shared `encodePowqlString`, integer array indexes emit bare. A json
|
|
507
|
+
// document column reference stays dotted-bare (`.col`), which bypasses
|
|
508
|
+
// keyword lookup on every engine version exactly like a filter path.
|
|
509
|
+
for (const idx of meta.indexes) {
|
|
510
|
+
const kind = idx.unique ? 'unique' : 'index';
|
|
511
|
+
if (idx.docPath) {
|
|
512
|
+
if (caps)
|
|
513
|
+
requireCapability(caps, 'docFieldIndexes', 'JSON doc-field expression indexes');
|
|
514
|
+
const column = idx.columns[0];
|
|
515
|
+
if (column === undefined) {
|
|
516
|
+
throw new ValidationError(`[turbine] Doc-field index "${idx.name}" on ${meta.name} has no target json column.`);
|
|
517
|
+
}
|
|
518
|
+
const segs = idx.docPath.map((s) => (typeof s === 'number' ? `->${s}` : `->${encodePowqlString(s)}`)).join('');
|
|
519
|
+
stmts.push(`alter ${quotePowqlIdent(meta.name)} add ${kind} (.${column}${segs})`);
|
|
520
|
+
}
|
|
521
|
+
else {
|
|
522
|
+
// Plain column index. PowDB has no composite index (`add index` takes a
|
|
523
|
+
// single `.column`), so a multi-column entry is a typed E017.
|
|
524
|
+
if (idx.columns.length !== 1) {
|
|
525
|
+
throw new UnsupportedFeatureError('composite indexes', 'PowDB', `PowDB has no composite index. Index "${idx.name}" on ${meta.name} lists ` +
|
|
526
|
+
`${idx.columns.length} columns; declare a single-column index (or a doc-field index) instead.`);
|
|
527
|
+
}
|
|
528
|
+
const column = idx.columns[0];
|
|
529
|
+
// A unique index whose column already carries a unique constraint (the
|
|
530
|
+
// PK, or a column-level unique) would be a redundant duplicate, so skip it.
|
|
531
|
+
if (idx.unique && emittedUnique.has(column))
|
|
532
|
+
continue;
|
|
533
|
+
stmts.push(`alter ${quotePowqlIdent(meta.name)} add ${kind} .${quotePowqlIdent(column)}`);
|
|
534
|
+
if (idx.unique)
|
|
535
|
+
emittedUnique.add(column);
|
|
374
536
|
}
|
|
375
537
|
}
|
|
376
538
|
}
|
|
@@ -380,6 +542,10 @@ export function powqlSchemaDDL(schema) {
|
|
|
380
542
|
function toPowdbParam(value, col) {
|
|
381
543
|
if (value instanceof PowdbFloatParam)
|
|
382
544
|
return value.value; // wire-side: a float column takes the plain number
|
|
545
|
+
// json document: serialize to canonical JSON text and bind as a str param,
|
|
546
|
+
// the engine validates it as JSON and stores the canonical binary form.
|
|
547
|
+
if (value instanceof PowdbJsonParam)
|
|
548
|
+
return JSON.stringify(value.value);
|
|
383
549
|
if (value === undefined || value === null)
|
|
384
550
|
return null;
|
|
385
551
|
if (value instanceof Date)
|
|
@@ -402,10 +568,23 @@ function toPowdbParam(value, col) {
|
|
|
402
568
|
*/
|
|
403
569
|
export function coerceValue(raw, col) {
|
|
404
570
|
const ts = col.tsType.replace(/\s*\|\s*null$/i, '').trim();
|
|
571
|
+
const json = isJsonColumn(col);
|
|
405
572
|
// NULL bareword: unambiguous for non-string columns; for `str` we cannot tell a
|
|
406
573
|
// literal "null" from SQL NULL, so a nullable str of value "null" reads as null.
|
|
407
|
-
|
|
574
|
+
// For a `json` column the bareword `null` (a legacy-wire rendering shared by an
|
|
575
|
+
// absent value AND a top-level JSON-null document, documented residual,
|
|
576
|
+
// resolved on the native transport by the WireValue path) maps to null; a JSON
|
|
577
|
+
// string document "null" renders WITH quotes (`"null"`) and parses distinctly.
|
|
578
|
+
if (raw === 'null' && (json || ts !== 'string' || col.nullable))
|
|
408
579
|
return null;
|
|
580
|
+
if (json) {
|
|
581
|
+
try {
|
|
582
|
+
return JSON.parse(raw);
|
|
583
|
+
}
|
|
584
|
+
catch {
|
|
585
|
+
return raw; // defensive: canonical JSON text always parses
|
|
586
|
+
}
|
|
587
|
+
}
|
|
409
588
|
if (ts === 'Date') {
|
|
410
589
|
const micros = Number(raw);
|
|
411
590
|
return Number.isFinite(micros) ? new Date(micros / 1000) : null;
|
|
@@ -422,19 +601,62 @@ export function coerceValue(raw, col) {
|
|
|
422
601
|
return raw; // string / uuid-as-string
|
|
423
602
|
}
|
|
424
603
|
/**
|
|
425
|
-
*
|
|
426
|
-
* {@link
|
|
427
|
-
*
|
|
428
|
-
*
|
|
604
|
+
* Coerce a single cell that arrived over the NATIVE typed wire (decoded from a
|
|
605
|
+
* {@link PowdbWireValue}, so already a JS `bigint`/`number`/`boolean`/`string`/
|
|
606
|
+
* `NativeJson`/`Uint8Array`/`null`, never a bare `"null"` string). Unlike
|
|
607
|
+
* {@link coerceValue} this NEVER collapses the string `"null"` to `null`: an
|
|
608
|
+
* absent value already decoded to `null` (from the `empty` cell), so a genuine
|
|
609
|
+
* str `"null"` stays the string `"null"` (fixes the legacy-wire wart on the
|
|
610
|
+
* native transport). `datetime`-shaped cells (int micros) become `Date`; a
|
|
611
|
+
* bigint on a `number` column follows the int8 safe-integer policy.
|
|
612
|
+
*/
|
|
613
|
+
export function coerceNativeValue(value, col) {
|
|
614
|
+
if (value === undefined || value === null)
|
|
615
|
+
return null;
|
|
616
|
+
if (isDateColumn(col)) {
|
|
617
|
+
if (typeof value === 'bigint')
|
|
618
|
+
return new Date(Number(value) / 1000);
|
|
619
|
+
if (typeof value === 'number')
|
|
620
|
+
return new Date(value / 1000);
|
|
621
|
+
return value;
|
|
622
|
+
}
|
|
623
|
+
const ts = col.tsType.replace(/\s*\|\s*null$/i, '').trim();
|
|
624
|
+
if (typeof value === 'bigint') {
|
|
625
|
+
if (ts === 'bigint')
|
|
626
|
+
return value;
|
|
627
|
+
if (ts === 'number') {
|
|
628
|
+
const n = Number(value);
|
|
629
|
+
return Number.isSafeInteger(n) ? n : value.toString(); // int8 policy: keep big ints as strings
|
|
630
|
+
}
|
|
631
|
+
return value;
|
|
632
|
+
}
|
|
633
|
+
return value; // number / boolean / string / NativeJson document / Uint8Array
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* Map one raw PowDB row into a typed entity (camelCase fields, coerced values).
|
|
637
|
+
* Only the columns present in `raw` are emitted, so partial `select`
|
|
638
|
+
* projections round-trip unchanged. `native` selects the coercion policy: the
|
|
639
|
+
* default `false` handles the legacy string wire (every cell is a string, via
|
|
640
|
+
* {@link coerceValue}); `true` handles the native typed wire, where non-string
|
|
641
|
+
* cells arrive pre-typed and go through {@link coerceNativeValue} (see F3).
|
|
642
|
+
* Callers on the native transport pass `this.pool.capabilities.nativeRaw`.
|
|
429
643
|
*/
|
|
430
|
-
export function rowToEntity(raw, meta) {
|
|
644
|
+
export function rowToEntity(raw, meta, native = false) {
|
|
431
645
|
const byName = new Map(meta.columns.map((c) => [c.name, c]));
|
|
432
646
|
const out = {};
|
|
433
647
|
for (const snake of Object.keys(raw)) {
|
|
434
648
|
const col = byName.get(snake);
|
|
435
649
|
const field = meta.reverseColumnMap[snake] ?? snake;
|
|
436
650
|
const value = raw[snake];
|
|
437
|
-
|
|
651
|
+
if (!col) {
|
|
652
|
+
out[field] = value;
|
|
653
|
+
}
|
|
654
|
+
else if (native) {
|
|
655
|
+
out[field] = coerceNativeValue(value, col);
|
|
656
|
+
}
|
|
657
|
+
else {
|
|
658
|
+
out[field] = typeof value === 'string' ? coerceValue(value, col) : value;
|
|
659
|
+
}
|
|
438
660
|
}
|
|
439
661
|
return out;
|
|
440
662
|
}
|
|
@@ -460,7 +682,7 @@ export function wrapPowdbError(err) {
|
|
|
460
682
|
const e = err;
|
|
461
683
|
const msg = e.message ?? 'unknown PowDB error';
|
|
462
684
|
// Unique-constraint — message-based on both transports.
|
|
463
|
-
if (/unique constraint violation/i.test(msg)) {
|
|
685
|
+
if (/unique (constraint|expression index) violation/i.test(msg)) {
|
|
464
686
|
const m = /on\s+\S+\.(\w+)/i.exec(msg);
|
|
465
687
|
return new UniqueConstraintError({ constraint: m?.[1], cause: err });
|
|
466
688
|
}
|
|
@@ -470,40 +692,123 @@ export function wrapPowdbError(err) {
|
|
|
470
692
|
const m = /column ['"]?(\w+)['"]?/i.exec(msg);
|
|
471
693
|
return new NotNullViolationError({ column: m?.[1], cause: err });
|
|
472
694
|
}
|
|
473
|
-
// Driver pool lifecycle errors (acquire after close, acquire timeout
|
|
474
|
-
//
|
|
475
|
-
|
|
476
|
-
|
|
695
|
+
// Driver pool lifecycle errors (acquire after close, acquire timeout, or a
|
|
696
|
+
// statement reaching an already-closed embedded handle) carry no .code:
|
|
697
|
+
// classify by message so both transports surface E004.
|
|
698
|
+
if (/pool closed|pool acquire timeout|database is closed/i.test(msg)) {
|
|
699
|
+
return new ConnectionError(`[turbine] PowDB connection unavailable: ${msg}`, { cause: err });
|
|
477
700
|
}
|
|
478
701
|
// Server-side transaction-gate wait bound (PowDB ≥ 0.10, default 5s): another
|
|
479
702
|
// connection held the single global write lock past the server's
|
|
480
703
|
// --tx-wait-timeout-ms. Retryable timeout, not a query defect.
|
|
481
704
|
if (/transaction gate timeout/i.test(msg)) {
|
|
482
|
-
return new TimeoutError(0, 'PowDB transaction gate');
|
|
705
|
+
return new TimeoutError(0, 'PowDB transaction gate', { cause: err });
|
|
706
|
+
}
|
|
707
|
+
// Stale / violated WIRE state → ConnectionError (E004), NOT a query defect.
|
|
708
|
+
// A `protocol_error`-class failure means the socket's framing state is gone
|
|
709
|
+
// (the client cannot safely reuse it and the pool must destroy it). The
|
|
710
|
+
// canonical trigger is the "received unexpected frame from server" that a
|
|
711
|
+
// fresh request hits after a multi-minute idle gap; sibling shapes are an
|
|
712
|
+
// unknown message type, a truncated payload, or bad framing. Runs BEFORE the
|
|
713
|
+
// validation regex below, whose `unexpected` token would otherwise misclass
|
|
714
|
+
// "received unexpected frame" as an E003 query defect. `.cause` preserved so
|
|
715
|
+
// callers (and the opt-in stale-read retry) can inspect the driver code.
|
|
716
|
+
if (e.code === 'protocol_error' ||
|
|
717
|
+
/received unexpected frame|unknown message type|truncated payload|bad framing/i.test(msg)) {
|
|
718
|
+
return new ConnectionError(`[turbine] PowDB connection is in an invalid state: ${msg}`, { cause: err });
|
|
483
719
|
}
|
|
484
|
-
//
|
|
485
|
-
// (
|
|
486
|
-
//
|
|
487
|
-
//
|
|
720
|
+
// Read-only refusal → ReadOnlyError (E018). Two engine shapes, both mapped by
|
|
721
|
+
// substring (the networked transport prefixes the message with `query failed:
|
|
722
|
+
// `, so never anchor on the start): an embedded database opened read-only for
|
|
723
|
+
// snapshot serving (`readonly mode: statement requires a writer …`), and a
|
|
724
|
+
// networked read-only role (`permission denied: role '<role>' cannot execute
|
|
725
|
+
// write statements`). These run BEFORE the generic validation regex below so a
|
|
726
|
+
// read-only write is surfaced as the routing signal E018, not a query defect.
|
|
727
|
+
// The driver spec (0.15) distinguishes them via `reason`: snapshot mode
|
|
728
|
+
// means "nothing can write here; route writes to the primary", RBAC means
|
|
729
|
+
// "this connection's role may not write here".
|
|
730
|
+
if (/readonly mode: statement requires a writer/i.test(msg)) {
|
|
731
|
+
return new ReadOnlyError(`PowDB refused a write on a read-only database: ${msg}.`, {
|
|
732
|
+
cause: err,
|
|
733
|
+
reason: 'snapshot',
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
if (/permission denied: role/i.test(msg)) {
|
|
737
|
+
return new ReadOnlyError(`PowDB refused a write for a read-only role: ${msg}.`, { cause: err, reason: 'rbac' });
|
|
738
|
+
}
|
|
739
|
+
// Open-time read-only failure: a read-only handle over a directory whose WAL
|
|
740
|
+
// still has uncommitted frames is refused (`cannot open read-only: the WAL is
|
|
741
|
+
// not empty …`). It is a connection failure (E004), not a query defect, the
|
|
742
|
+
// fix is to recover the directory with a writable open first.
|
|
743
|
+
if (/cannot open read-only: the WAL is not empty/i.test(msg)) {
|
|
744
|
+
return new ConnectionError(`[turbine] PowDB could not open the directory read-only: ${msg}. Open it once with a writable handle to ` +
|
|
745
|
+
'flush the WAL (recover the directory), then reopen it read-only for snapshot serving.', { cause: err });
|
|
746
|
+
}
|
|
747
|
+
// Per-query deadline → TimeoutError (E002). Message-path so it fires on the
|
|
748
|
+
// embedded transport too (code is always 'GenericFailure' there); retryable.
|
|
749
|
+
// Pass the engine prose through the message override (same pattern as the
|
|
750
|
+
// transaction-gate timeout below) so the real "query timeout after <n>ms"
|
|
751
|
+
// survives instead of rendering the placeholder "timed out after 0ms".
|
|
752
|
+
if (/query timeout after/i.test(msg)) {
|
|
753
|
+
return new TimeoutError(0, 'PowDB query', { message: `[turbine] PowDB ${msg}`, cause: err });
|
|
754
|
+
}
|
|
755
|
+
// Client-initiated cancellation → ConnectionError (E004). This is FINAL: the
|
|
756
|
+
// issuing client disconnected, so the query was a clean early return, never
|
|
757
|
+
// auto-retry it (the opt-in stale-read retry only replays stale-FRAME reads).
|
|
758
|
+
if (/query cancelled by client disconnect/i.test(msg)) {
|
|
759
|
+
return new ConnectionError(`[turbine] PowDB query cancelled by client disconnect: ${msg}`, { cause: err });
|
|
760
|
+
}
|
|
761
|
+
// Bounded join rejection → ValidationError (E003). The engine rejects a pure
|
|
762
|
+
// nested-loop join whose candidate-pair count (or result row count) exceeds
|
|
763
|
+
// the safety bound BEFORE executing, and names the fix in the message, keep
|
|
764
|
+
// that fix-hint intact so the caller knows how to make the join eligible.
|
|
765
|
+
if (/nested-loop join would evaluate|join result exceeds row limit/i.test(msg)) {
|
|
766
|
+
return new ValidationError(`[turbine] PowDB join rejected: ${msg}`);
|
|
767
|
+
}
|
|
768
|
+
// Type mismatch / parse / execution / storage / unexpected(token) / row too
|
|
769
|
+
// large → validation (E003). On the embedded transport these are the only
|
|
770
|
+
// signal we get (code is always 'GenericFailure'); on the networked path they
|
|
771
|
+
// are a safety net before the .code switch.
|
|
488
772
|
if (/type mismatch|\bParse\b|\bExecution\b|StorageError|unexpected|row too large/i.test(msg)) {
|
|
489
773
|
return new ValidationError(`[turbine] PowDB query rejected: ${msg}`);
|
|
490
774
|
}
|
|
491
775
|
switch (e.code) {
|
|
492
776
|
case 'connect_failed':
|
|
493
777
|
case 'closed':
|
|
494
|
-
return new ConnectionError(`[turbine] PowDB connection failed: ${msg}
|
|
778
|
+
return new ConnectionError(`[turbine] PowDB connection failed: ${msg}`, { cause: err });
|
|
779
|
+
case 'auth_failed':
|
|
780
|
+
// Connection-establishment class, non-retryable: the handshake was
|
|
781
|
+
// rejected. Surface E004 with a concrete remediation hint instead of
|
|
782
|
+
// letting it fall through to the raw error.
|
|
783
|
+
return new ConnectionError(`[turbine] PowDB authentication failed: ${msg} (check the user / password / dbName for this connection).`, { cause: err });
|
|
495
784
|
case 'timeout':
|
|
496
785
|
case 'aborted':
|
|
497
|
-
return new TimeoutError(0, 'PowDB query');
|
|
786
|
+
return new TimeoutError(0, 'PowDB query', { cause: err });
|
|
498
787
|
case 'query_failed':
|
|
499
788
|
case 'type_coercion_failed':
|
|
500
|
-
case 'protocol_error':
|
|
501
789
|
case 'size_exceeded':
|
|
502
790
|
return new ValidationError(`[turbine] PowDB query rejected: ${msg}`);
|
|
503
791
|
default:
|
|
504
|
-
return err instanceof Error ? err : new ConnectionError(`[turbine] PowDB error: ${msg}
|
|
792
|
+
return err instanceof Error ? err : new ConnectionError(`[turbine] PowDB error: ${msg}`, { cause: err });
|
|
505
793
|
}
|
|
506
794
|
}
|
|
795
|
+
/**
|
|
796
|
+
* True when `err` is the stale-wire-frame {@link ConnectionError} produced by
|
|
797
|
+
* {@link wrapPowdbError} (its `.cause` is a `protocol_error` PowDBError, or the
|
|
798
|
+
* message carries the invalid-state signature). The opt-in read retry
|
|
799
|
+
* (`retryStaleReads`, evaluated in {@link PowqlInterface}'s exec seam) uses this
|
|
800
|
+
* to decide whether a first-statement READ may be replayed once on a fresh
|
|
801
|
+
* connection; writes are NEVER retried (an ambiguous mutation reply is unsafe
|
|
802
|
+
* to replay, matching the client's own native-path policy).
|
|
803
|
+
*/
|
|
804
|
+
export function isStaleFramePowdbError(err) {
|
|
805
|
+
if (!(err instanceof ConnectionError))
|
|
806
|
+
return false;
|
|
807
|
+
const cause = err.cause;
|
|
808
|
+
if (cause && typeof cause === 'object' && cause.code === 'protocol_error')
|
|
809
|
+
return true;
|
|
810
|
+
return /PowDB connection is in an invalid state/.test(err.message);
|
|
811
|
+
}
|
|
507
812
|
function normalizeQueryArgs(arg, values) {
|
|
508
813
|
if (typeof arg === 'string')
|
|
509
814
|
return { text: arg, params: values ?? [] };
|
|
@@ -682,7 +987,7 @@ class PowdbTxGate {
|
|
|
682
987
|
return hold;
|
|
683
988
|
}
|
|
684
989
|
}
|
|
685
|
-
/** Adapt a PowDB result into the pg-compat `{ rows, rowCount, fields }` shape. */
|
|
990
|
+
/** Adapt a PowDB (legacy string wire) result into the pg-compat `{ rows, rowCount, fields }` shape. */
|
|
686
991
|
function adaptResult(r) {
|
|
687
992
|
switch (r.kind) {
|
|
688
993
|
case 'rows': {
|
|
@@ -693,21 +998,86 @@ function adaptResult(r) {
|
|
|
693
998
|
});
|
|
694
999
|
return o;
|
|
695
1000
|
});
|
|
696
|
-
return { rows, rowCount: rows.length, fields: r.columns.map((name) => ({ name, dataTypeID: 0 })) };
|
|
1001
|
+
return { rows, rowCount: rows.length, fields: r.columns.map((name) => ({ name, dataTypeID: 0 })), native: false };
|
|
697
1002
|
}
|
|
698
1003
|
case 'ok':
|
|
699
|
-
return { rows: [], rowCount: Number(r.affected), fields: [] };
|
|
1004
|
+
return { rows: [], rowCount: Number(r.affected), fields: [], native: false };
|
|
700
1005
|
case 'scalar':
|
|
701
|
-
return { rows: [{ value: r.value }], rowCount: 1, fields: [{ name: 'value', dataTypeID: 0 }] };
|
|
1006
|
+
return { rows: [{ value: r.value }], rowCount: 1, fields: [{ name: 'value', dataTypeID: 0 }], native: false };
|
|
702
1007
|
default:
|
|
703
|
-
return { rows: [], rowCount: 0, fields: [] };
|
|
1008
|
+
return { rows: [], rowCount: 0, fields: [], native: false };
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
/** Format 16 raw UUID bytes as a canonical `8-4-4-4-12` hex string. */
|
|
1012
|
+
function uuidBytesToHex(bytes) {
|
|
1013
|
+
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
1014
|
+
if (hex.length !== 32)
|
|
1015
|
+
return hex; // defensive: non-16B payloads pass through as raw hex
|
|
1016
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
1017
|
+
}
|
|
1018
|
+
/**
|
|
1019
|
+
* Decode one native {@link PowdbWireValue} cell into a JS value. `empty` →
|
|
1020
|
+
* `null` (an unset value; for a json column this cleanly distinguishes absent
|
|
1021
|
+
* from a JSON-null document, which arrives as `{ type: 'json', value: null }`).
|
|
1022
|
+
* `int`/`datetime` stay `bigint` so the row layer ({@link coerceNativeValue})
|
|
1023
|
+
* applies the int8 policy / Date conversion by column; `uuid` becomes canonical
|
|
1024
|
+
* hex; `bytes` stay `Uint8Array`; `json` passes the decoded document through
|
|
1025
|
+
* with no re-parse (its `pj1` raw bytes are dropped).
|
|
1026
|
+
*/
|
|
1027
|
+
function decodeWireValue(cell) {
|
|
1028
|
+
switch (cell.type) {
|
|
1029
|
+
case 'empty':
|
|
1030
|
+
return null;
|
|
1031
|
+
case 'int':
|
|
1032
|
+
case 'datetime':
|
|
1033
|
+
return cell.value; // bigint; row layer decides Date vs number vs bigint per column
|
|
1034
|
+
case 'float':
|
|
1035
|
+
case 'bool':
|
|
1036
|
+
case 'str':
|
|
1037
|
+
return cell.value;
|
|
1038
|
+
case 'uuid':
|
|
1039
|
+
return uuidBytesToHex(cell.value);
|
|
1040
|
+
case 'bytes':
|
|
1041
|
+
return cell.value; // Uint8Array
|
|
1042
|
+
case 'json':
|
|
1043
|
+
return cell.value; // NativeJson document, already recursive data
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
/** Adapt a native (typed-wire) PowDB result into the pg-compat shape, decoding every {@link PowdbWireValue} cell. */
|
|
1047
|
+
function adaptNativeResult(r) {
|
|
1048
|
+
switch (r.kind) {
|
|
1049
|
+
case 'rows': {
|
|
1050
|
+
const rows = r.rows.map((row) => {
|
|
1051
|
+
const o = {};
|
|
1052
|
+
r.columns.forEach((c, i) => {
|
|
1053
|
+
o[c] = decodeWireValue(row[i] ?? { type: 'empty' });
|
|
1054
|
+
});
|
|
1055
|
+
return o;
|
|
1056
|
+
});
|
|
1057
|
+
return { rows, rowCount: rows.length, fields: r.columns.map((name) => ({ name, dataTypeID: 0 })), native: true };
|
|
1058
|
+
}
|
|
1059
|
+
case 'ok':
|
|
1060
|
+
return { rows: [], rowCount: Number(r.affected), fields: [], native: true };
|
|
1061
|
+
case 'scalar':
|
|
1062
|
+
return {
|
|
1063
|
+
rows: [{ value: decodeWireValue(r.value) }],
|
|
1064
|
+
rowCount: 1,
|
|
1065
|
+
fields: [{ name: 'value', dataTypeID: 0 }],
|
|
1066
|
+
native: true,
|
|
1067
|
+
};
|
|
1068
|
+
default:
|
|
1069
|
+
return { rows: [], rowCount: 0, fields: [], native: true };
|
|
704
1070
|
}
|
|
705
1071
|
}
|
|
706
1072
|
/**
|
|
707
1073
|
* A {@link PgCompatPool} backed by a `@zvndev/powdb-client` `Pool`. The query
|
|
708
|
-
* `text` is **PowQL**, not SQL
|
|
709
|
-
*
|
|
710
|
-
* (
|
|
1074
|
+
* `text` is **PowQL**, not SQL ({@link PowqlInterface} generates it). On the
|
|
1075
|
+
* legacy string wire cells come back as strings; when `capabilities.nativeRaw`
|
|
1076
|
+
* is set (server ≥ 0.13 + a client exposing `queryNativeRaw`) this pool routes
|
|
1077
|
+
* through the typed native wire instead, so cells arrive pre-typed (a json int
|
|
1078
|
+
* as `bigint`, etc.) and each result is tagged with the wire that served it
|
|
1079
|
+
* ({@link PowdbTaggedResult}). Per-column JS coercion still happens in
|
|
1080
|
+
* `PowqlInterface` (it owns the schema metadata), keyed on that per-result tag.
|
|
711
1081
|
*/
|
|
712
1082
|
export class PowdbPool {
|
|
713
1083
|
pool;
|
|
@@ -731,10 +1101,37 @@ export class PowdbPool {
|
|
|
731
1101
|
* live socket holding the process open until the server's idle timeout.
|
|
732
1102
|
*/
|
|
733
1103
|
checkedOut = new Set();
|
|
1104
|
+
/** Feature capabilities of the bound server (probed version + native-wire feature-detect). */
|
|
1105
|
+
capabilities;
|
|
1106
|
+
/** Opt-in first-statement-read replay on a stale wire frame (read by {@link PowqlInterface}). */
|
|
1107
|
+
retryStaleReads;
|
|
1108
|
+
/**
|
|
1109
|
+
* True when the caller marked this pool read-only (`readonly: true`). Read by
|
|
1110
|
+
* {@link PowqlInterface}'s exec seam to fail writes fast with E018 before the
|
|
1111
|
+
* wire; the engine's own read-only-role refusal (mapped by
|
|
1112
|
+
* {@link wrapPowdbError}) is the backstop for raw / injected paths.
|
|
1113
|
+
*/
|
|
1114
|
+
readonly;
|
|
734
1115
|
constructor(pool, toParam = (v) => toPowdbParam(v), options = {}) {
|
|
735
1116
|
this.pool = pool;
|
|
736
1117
|
this.toParam = toParam;
|
|
737
1118
|
this.txGate = new PowdbTxGate(options.transactionQueueTimeoutMs ?? DEFAULT_TX_QUEUE_TIMEOUT_MS);
|
|
1119
|
+
this.capabilities = options.capabilities ?? ALL_POWDB_CAPABILITIES;
|
|
1120
|
+
this.retryStaleReads = options.retryStaleReads ?? false;
|
|
1121
|
+
this.readonly = options.readonly ?? false;
|
|
1122
|
+
}
|
|
1123
|
+
/**
|
|
1124
|
+
* Run one statement on `c`, choosing the lossless native typed wire when the
|
|
1125
|
+
* server supports it (`capabilities.nativeRaw`) AND this client exposes
|
|
1126
|
+
* `queryNativeRaw` (a defensive per-call feature-detect, so a heterogeneous
|
|
1127
|
+
* injected pool cannot crash). Otherwise the legacy string wire, unchanged.
|
|
1128
|
+
*/
|
|
1129
|
+
async runOnClient(c, powql, params) {
|
|
1130
|
+
const bound = params.map(this.toParam);
|
|
1131
|
+
if (this.capabilities.nativeRaw && typeof c.queryNativeRaw === 'function') {
|
|
1132
|
+
return adaptNativeResult(await c.queryNativeRaw(powql, bound));
|
|
1133
|
+
}
|
|
1134
|
+
return adaptResult(await c.query(powql, bound));
|
|
738
1135
|
}
|
|
739
1136
|
// biome-ignore lint/suspicious/noExplicitAny: pg-compat query is generic over the row shape.
|
|
740
1137
|
async query(text, values) {
|
|
@@ -755,8 +1152,7 @@ export class PowdbPool {
|
|
|
755
1152
|
return { rows: [], rowCount: 0, fields: [] };
|
|
756
1153
|
}
|
|
757
1154
|
try {
|
|
758
|
-
|
|
759
|
-
return adaptResult(result);
|
|
1155
|
+
return await this.pool.withClient((c) => this.runOnClient(c, powql, params));
|
|
760
1156
|
}
|
|
761
1157
|
catch (err) {
|
|
762
1158
|
if (ctl === 'begin') {
|
|
@@ -827,7 +1223,7 @@ export class PowdbPool {
|
|
|
827
1223
|
return { rows: [], rowCount: 0, fields: [] };
|
|
828
1224
|
}
|
|
829
1225
|
try {
|
|
830
|
-
return
|
|
1226
|
+
return await this.runOnClient(client, powql, params);
|
|
831
1227
|
}
|
|
832
1228
|
catch (err) {
|
|
833
1229
|
broken = true;
|
|
@@ -953,6 +1349,10 @@ export function encodePowqlLiteral(value) {
|
|
|
953
1349
|
// Force a float-form literal so an integer-valued float column stays a float.
|
|
954
1350
|
return Number.isInteger(n) ? `${n}.0` : String(n);
|
|
955
1351
|
}
|
|
1352
|
+
// json document: emit the canonical JSON text as a PowQL string literal (the
|
|
1353
|
+
// embedded engine validates and stores it as a json document).
|
|
1354
|
+
if (value instanceof PowdbJsonParam)
|
|
1355
|
+
return encodePowqlString(JSON.stringify(value.value));
|
|
956
1356
|
if (value === undefined || value === null)
|
|
957
1357
|
return 'null';
|
|
958
1358
|
if (value instanceof Date)
|
|
@@ -1008,10 +1408,14 @@ export function materializePowql(powql, params) {
|
|
|
1008
1408
|
}
|
|
1009
1409
|
/**
|
|
1010
1410
|
* A {@link PgCompatPool} backed by an in-process `@zvndev/powdb-embedded`
|
|
1011
|
-
* `Database`.
|
|
1012
|
-
*
|
|
1013
|
-
*
|
|
1014
|
-
*
|
|
1411
|
+
* `Database`. On the addon's typed native wire (≥ 0.14, when
|
|
1412
|
+
* `capabilities.nativeRaw` is set) this pool binds positional `$N` params via
|
|
1413
|
+
* `queryWithParams` and decodes the typed cells, exactly like the networked
|
|
1414
|
+
* transport. On an older addon (no `queryWithParams`) it falls back to the
|
|
1415
|
+
* legacy string wire, which takes **no params array** (its `query(powql)`
|
|
1416
|
+
* accepts only a string), so each positional `$N` is materialized into a PowQL
|
|
1417
|
+
* literal via {@link materializePowql} before the text is handed to the engine.
|
|
1418
|
+
* One handle, single connection: transaction keywords (`begin`/`commit`/
|
|
1015
1419
|
* `rollback`) are issued serially as ordinary queries.
|
|
1016
1420
|
*/
|
|
1017
1421
|
export class PowdbEmbeddedPool {
|
|
@@ -1030,12 +1434,46 @@ export class PowdbEmbeddedPool {
|
|
|
1030
1434
|
txGate;
|
|
1031
1435
|
/** Hold taken by a `begin` issued via `query()` directly (no checked-out client). */
|
|
1032
1436
|
poolHoldRef = { hold: null };
|
|
1437
|
+
/**
|
|
1438
|
+
* Feature capabilities of the embedded engine (resolved from the addon
|
|
1439
|
+
* package version). `nativeRaw` is true when the addon is ≥ 0.14 and the
|
|
1440
|
+
* opened handle exposes `queryWithParams` (the typed native wire); an older
|
|
1441
|
+
* addon has no such method, so it stays false and the legacy string wire is
|
|
1442
|
+
* used.
|
|
1443
|
+
*/
|
|
1444
|
+
capabilities;
|
|
1445
|
+
/** Carried for surface uniformity with {@link PowdbPool}; inert on embedded (no protocol_error frames). */
|
|
1446
|
+
retryStaleReads;
|
|
1447
|
+
/**
|
|
1448
|
+
* True when this pool was opened read-only (an `{ embedded, readonly: true }`
|
|
1449
|
+
* target, or a directly-constructed pool passed `readonly: true`). Read by
|
|
1450
|
+
* {@link PowqlInterface}'s exec seam to fail writes fast with E018 before the
|
|
1451
|
+
* wire; the engine's own refusal (mapped by {@link wrapPowdbError}) is the
|
|
1452
|
+
* backstop for raw / injected paths.
|
|
1453
|
+
*/
|
|
1454
|
+
readonly;
|
|
1033
1455
|
constructor(db, options = {}) {
|
|
1034
1456
|
this.db = db;
|
|
1035
1457
|
this.txGate = new PowdbTxGate(options.transactionQueueTimeoutMs ?? DEFAULT_TX_QUEUE_TIMEOUT_MS);
|
|
1458
|
+
this.capabilities = options.capabilities ?? ALL_POWDB_CAPABILITIES;
|
|
1459
|
+
this.retryStaleReads = options.retryStaleReads ?? false;
|
|
1460
|
+
this.readonly = options.readonly ?? false;
|
|
1036
1461
|
}
|
|
1037
|
-
/**
|
|
1462
|
+
/** Run the PowQL on the in-process engine, choosing the native or legacy wire. */
|
|
1038
1463
|
exec(powql, params) {
|
|
1464
|
+
// Native typed wire (addon ≥ 0.14): bind positional params with the SAME
|
|
1465
|
+
// binder the networked transport uses ({@link toPowdbParam} yields exactly
|
|
1466
|
+
// the NativeParam union null|bigint|number|boolean|string) and decode the
|
|
1467
|
+
// typed cells: a genuine str "null" survives, a json-null document stays
|
|
1468
|
+
// distinct from an absent value. Gated on the resolved capability AND a
|
|
1469
|
+
// per-call feature-detect so a heterogeneous injected handle cannot crash.
|
|
1470
|
+
if (this.capabilities.nativeRaw && typeof this.db.queryWithParams === 'function') {
|
|
1471
|
+
const bound = params.map((v) => toPowdbParam(v));
|
|
1472
|
+
return adaptNativeResult(this.db.queryWithParams(powql, bound));
|
|
1473
|
+
}
|
|
1474
|
+
// Legacy string wire (addon < 0.14): the engine takes no params array, so
|
|
1475
|
+
// materialize each `$N` into a PowQL literal. Byte-for-byte unchanged, kept
|
|
1476
|
+
// live and tested as the pre-0.14 fallback.
|
|
1039
1477
|
const materialized = materializePowql(powql, params);
|
|
1040
1478
|
return adaptResult(normalizeEmbeddedResult(this.db.query(materialized)));
|
|
1041
1479
|
}
|
|
@@ -1055,6 +1493,15 @@ export class PowdbEmbeddedPool {
|
|
|
1055
1493
|
// transaction callback throws re-entrant E017 fast; independent
|
|
1056
1494
|
// concurrent ones wait their FIFO turn.
|
|
1057
1495
|
holdRef.hold = await this.txGate.acquire();
|
|
1496
|
+
// The gate may have handed us the slot AFTER disconnect() closed the
|
|
1497
|
+
// handle (a transaction queued behind an in-flight one, released as the
|
|
1498
|
+
// pool shut down). Re-check before touching the now-closed engine, and
|
|
1499
|
+
// release the slot we just took so the queue keeps draining.
|
|
1500
|
+
if (this.closed) {
|
|
1501
|
+
holdRef.hold.finish();
|
|
1502
|
+
holdRef.hold = null;
|
|
1503
|
+
throw new ConnectionError('[turbine] The PowDB embedded pool is closed: disconnect() was already called on this client.');
|
|
1504
|
+
}
|
|
1058
1505
|
}
|
|
1059
1506
|
if ((ctl === 'commit' || ctl === 'rollback') && holdRef.hold === null) {
|
|
1060
1507
|
// This context never acquired the gate — its `begin` never ran (the
|
|
@@ -1130,18 +1577,22 @@ export class PowdbEmbeddedPool {
|
|
|
1130
1577
|
async end() {
|
|
1131
1578
|
if (this.closed)
|
|
1132
1579
|
return;
|
|
1133
|
-
// The addon exposes no explicit close — drop the reference and let GC /
|
|
1134
|
-
// the engine's checkpoint flush. Marking the pool closed makes later
|
|
1135
|
-
// queries fail with a typed ConnectionError instead of silently running
|
|
1136
|
-
// against a handle the caller believes is gone. Caveat: durability is
|
|
1137
|
-
// checkpoint-bound, so hold the process open long enough for the final
|
|
1138
|
-
// WAL flush in short scripts.
|
|
1139
1580
|
this.closed = true;
|
|
1581
|
+
// Addon ≥ 0.14 exposes an explicit checkpoint-flushing close(): call it so
|
|
1582
|
+
// the final WAL flush completes deterministically before the handle is
|
|
1583
|
+
// dropped. An older addon has no close, dropping the reference and letting
|
|
1584
|
+
// GC / the engine's checkpoint flush is the fallback (durability is then
|
|
1585
|
+
// checkpoint-bound, so a short script must hold the process open long enough
|
|
1586
|
+
// for the final flush). Marking the pool closed makes later queries fail
|
|
1587
|
+
// with a typed ConnectionError instead of running against a gone handle.
|
|
1588
|
+
this.db.close?.();
|
|
1140
1589
|
}
|
|
1141
1590
|
}
|
|
1142
1591
|
// ---------------------------------------------------------------------------
|
|
1143
1592
|
// PowqlInterface — the PowQL query generator (Phase A: flat CRUD via returning)
|
|
1144
1593
|
// ---------------------------------------------------------------------------
|
|
1594
|
+
// `describe`-based introspection (programmatic API; see powdb-introspect.ts).
|
|
1595
|
+
export { introspectPowdbDatabase, } from './powdb-introspect.js';
|
|
1145
1596
|
export { PowqlInterface } from './powql.js';
|
|
1146
1597
|
/**
|
|
1147
1598
|
* Dynamically load `@zvndev/powdb-client`. Kept out of the static import graph so
|
|
@@ -1188,13 +1639,47 @@ async function loadPowdbEmbedded() {
|
|
|
1188
1639
|
}
|
|
1189
1640
|
return mod;
|
|
1190
1641
|
}
|
|
1642
|
+
/**
|
|
1643
|
+
* Resolve the embedded addon's engine version. The addon vendors the engine and
|
|
1644
|
+
* exports no version, but `@zvndev/powdb-embedded/package.json` has no `exports`
|
|
1645
|
+
* map, so a bare `require` of it resolves: the package version IS the engine
|
|
1646
|
+
* version. Delegated to the `.cts` optional-peer helper so the resolution uses a
|
|
1647
|
+
* real CommonJS `require` in BOTH build outputs; `import.meta.url` here would
|
|
1648
|
+
* fail `tsc` under `tsconfig.cjs.json` (module: CommonJS) and crash any CJS
|
|
1649
|
+
* consumer of `turbine-orm/powdb`. Returns `null` when it cannot be resolved.
|
|
1650
|
+
*/
|
|
1651
|
+
function resolveEmbeddedVersion() {
|
|
1652
|
+
return importOptionalPeer.peerPackageVersion('@zvndev/powdb-embedded');
|
|
1653
|
+
}
|
|
1191
1654
|
/** Open an embedded database handle, wrapping engine open failures (corrupt dir, etc.). */
|
|
1192
|
-
async function openEmbeddedPool(target, poolOptions = {}) {
|
|
1193
|
-
const mod = await loadPowdbEmbedded();
|
|
1194
|
-
const { embedded: dir, syncMode, memoryLimit } = target;
|
|
1655
|
+
async function openEmbeddedPool(target, poolOptions = {}, assumeEngineVersion, injectedModule) {
|
|
1656
|
+
const mod = injectedModule ?? (await loadPowdbEmbedded());
|
|
1657
|
+
const { embedded: dir, syncMode, memoryLimit, readonly } = target;
|
|
1658
|
+
// A read-only engine never writes, so a durability selector is meaningless
|
|
1659
|
+
// there, reject the combination loudly rather than silently ignoring one.
|
|
1660
|
+
if (readonly && syncMode !== undefined) {
|
|
1661
|
+
throw new ValidationError('[turbine] embedded `syncMode` is meaningless with `readonly: true` (a read-only database never writes). Remove one.');
|
|
1662
|
+
}
|
|
1195
1663
|
let db;
|
|
1196
1664
|
try {
|
|
1197
|
-
if (
|
|
1665
|
+
if (readonly) {
|
|
1666
|
+
// Read-only snapshot serving (addon ≥ 0.14): route to the openReadOnly*
|
|
1667
|
+
// constructors; feature-detect and fail with a clear version hint if the
|
|
1668
|
+
// installed addon predates them.
|
|
1669
|
+
if (memoryLimit !== undefined) {
|
|
1670
|
+
if (typeof mod.Database.openReadOnlyWithMemoryLimit !== 'function') {
|
|
1671
|
+
throw new ConnectionError('[turbine] embedded `readonly` + `memoryLimit` requires @zvndev/powdb-embedded >= 0.14 (openReadOnlyWithMemoryLimit).');
|
|
1672
|
+
}
|
|
1673
|
+
db = mod.Database.openReadOnlyWithMemoryLimit(dir, memoryLimit);
|
|
1674
|
+
}
|
|
1675
|
+
else {
|
|
1676
|
+
if (typeof mod.Database.openReadOnly !== 'function') {
|
|
1677
|
+
throw new ConnectionError('[turbine] embedded `readonly: true` requires @zvndev/powdb-embedded >= 0.14 (the installed addon has no openReadOnly).');
|
|
1678
|
+
}
|
|
1679
|
+
db = mod.Database.openReadOnly(dir);
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
else if (memoryLimit !== undefined) {
|
|
1198
1683
|
if (typeof mod.Database.openWithMemoryLimit !== 'function') {
|
|
1199
1684
|
throw new ConnectionError('[turbine] embedded `memoryLimit` requires @zvndev/powdb-embedded ≥ 0.7.1.');
|
|
1200
1685
|
}
|
|
@@ -1215,7 +1700,19 @@ async function openEmbeddedPool(target, poolOptions = {}) {
|
|
|
1215
1700
|
}
|
|
1216
1701
|
db.setSyncMode(syncMode);
|
|
1217
1702
|
}
|
|
1218
|
-
|
|
1703
|
+
// Native typed wire is feature-detected on the OPENED handle: an addon ≥ 0.14
|
|
1704
|
+
// exposes `queryWithParams`, so nativeRaw turns on (server-gate ≥ 0.13 still
|
|
1705
|
+
// applies via the version); an older addon has no such method → false.
|
|
1706
|
+
const capabilities = capabilitiesFromVersion(assumeEngineVersion ?? resolveEmbeddedVersion(), {
|
|
1707
|
+
hasNativeRaw: typeof db.queryWithParams === 'function',
|
|
1708
|
+
});
|
|
1709
|
+
// A read-only target forces the pool's readonly flag; otherwise honor whatever
|
|
1710
|
+
// `poolOptions` (threaded from `options.readonly`) carried.
|
|
1711
|
+
return new PowdbEmbeddedPool(db, {
|
|
1712
|
+
...poolOptions,
|
|
1713
|
+
capabilities,
|
|
1714
|
+
readonly: Boolean(readonly) || Boolean(poolOptions.readonly),
|
|
1715
|
+
});
|
|
1219
1716
|
}
|
|
1220
1717
|
/**
|
|
1221
1718
|
* Bind Turbine to PowDB. `target` is one of:
|
|
@@ -1238,30 +1735,39 @@ async function openEmbeddedPool(target, poolOptions = {}) {
|
|
|
1238
1735
|
export async function turbinePowDB(target, schema, options = {}) {
|
|
1239
1736
|
let pool;
|
|
1240
1737
|
let owns = false;
|
|
1241
|
-
const poolOptions = {
|
|
1738
|
+
const poolOptions = {
|
|
1739
|
+
transactionQueueTimeoutMs: options.transactionQueueTimeoutMs,
|
|
1740
|
+
retryStaleReads: options.retryStaleReads,
|
|
1741
|
+
readonly: options.readonly,
|
|
1742
|
+
};
|
|
1743
|
+
const max = options.connectionLimit ?? 10;
|
|
1242
1744
|
if (typeof target === 'string') {
|
|
1243
1745
|
const mod = options.powdbClientModule ?? (await loadPowdb());
|
|
1244
|
-
const clientPool = new mod.Pool({ ...parsePowdbUrl(target), max
|
|
1245
|
-
await assertNetworkedVersion(clientPool);
|
|
1246
|
-
pool = new PowdbPool(clientPool, undefined, poolOptions);
|
|
1746
|
+
const clientPool = new mod.Pool({ ...parsePowdbUrl(target), max });
|
|
1747
|
+
const capabilities = await assertNetworkedVersion(clientPool, options.assumeEngineVersion);
|
|
1748
|
+
pool = new PowdbPool(clientPool, undefined, { ...poolOptions, capabilities });
|
|
1247
1749
|
owns = true;
|
|
1248
1750
|
}
|
|
1249
1751
|
else if (target instanceof PowdbPool) {
|
|
1250
|
-
// An injected PowdbPool carries its own PowdbPoolOptions.
|
|
1752
|
+
// An injected PowdbPool carries its own PowdbPoolOptions (incl. capabilities).
|
|
1251
1753
|
pool = target;
|
|
1252
1754
|
}
|
|
1253
1755
|
else if (isEmbeddedTarget(target)) {
|
|
1254
|
-
pool = await openEmbeddedPool(target, poolOptions);
|
|
1756
|
+
pool = await openEmbeddedPool(target, poolOptions, options.assumeEngineVersion, options.powdbEmbeddedModule);
|
|
1255
1757
|
owns = true;
|
|
1256
1758
|
}
|
|
1257
1759
|
else if (isPowdbClientPool(target)) {
|
|
1258
|
-
pool
|
|
1760
|
+
// Injected client pool: run the SAME probe as the URL / host+port paths so
|
|
1761
|
+
// it gets real capabilities AND the version-floor check (this branch used
|
|
1762
|
+
// to skip the probe entirely (an injected pool silently bypassed both).
|
|
1763
|
+
const capabilities = await assertNetworkedVersion(target, options.assumeEngineVersion);
|
|
1764
|
+
pool = new PowdbPool(target, undefined, { ...poolOptions, capabilities });
|
|
1259
1765
|
}
|
|
1260
1766
|
else {
|
|
1261
1767
|
const mod = options.powdbClientModule ?? (await loadPowdb());
|
|
1262
|
-
const clientPool = new mod.Pool({ ...target, max
|
|
1263
|
-
await assertNetworkedVersion(clientPool);
|
|
1264
|
-
pool = new PowdbPool(clientPool, undefined, poolOptions);
|
|
1768
|
+
const clientPool = new mod.Pool({ ...target, max });
|
|
1769
|
+
const capabilities = await assertNetworkedVersion(clientPool, options.assumeEngineVersion);
|
|
1770
|
+
pool = new PowdbPool(clientPool, undefined, { ...poolOptions, capabilities });
|
|
1265
1771
|
owns = true;
|
|
1266
1772
|
}
|
|
1267
1773
|
// The PowQL generator is loaded here to keep client.ts free of any PowDB import.
|
|
@@ -1274,6 +1780,7 @@ export async function turbinePowDB(target, schema, options = {}) {
|
|
|
1274
1780
|
logging: options.logging,
|
|
1275
1781
|
defaultLimit: options.defaultLimit,
|
|
1276
1782
|
warnOnUnlimited: options.warnOnUnlimited,
|
|
1783
|
+
relationLoadStrategy: options.relationLoadStrategy,
|
|
1277
1784
|
queryInterfaceFactory,
|
|
1278
1785
|
}, schema);
|
|
1279
1786
|
if (owns) {
|
|
@@ -1299,14 +1806,20 @@ export async function turbinePowDB(target, schema, options = {}) {
|
|
|
1299
1806
|
return client;
|
|
1300
1807
|
}
|
|
1301
1808
|
/**
|
|
1302
|
-
* Probe a networked pool's `serverVersion` (declared on {@link PowdbClient})
|
|
1303
|
-
* fail fast if the server is older than {@link MIN_POWDB_VERSION}
|
|
1304
|
-
*
|
|
1305
|
-
*
|
|
1809
|
+
* Probe a networked pool's `serverVersion` (declared on {@link PowdbClient}),
|
|
1810
|
+
* fail fast if the server is older than {@link MIN_POWDB_VERSION}, and derive
|
|
1811
|
+
* the {@link PowdbCapabilities} for it: the version gates PLUS `nativeRaw`
|
|
1812
|
+
* (server ≥ 0.13 AND the client exposes `queryNativeRaw`, feature-detected
|
|
1813
|
+
* here). `assumeEngineVersion` overrides the version used for capability
|
|
1814
|
+
* derivation (the floor check still runs against the real reported version).
|
|
1815
|
+
* Errors from the probe itself surface as the normal connect failure.
|
|
1306
1816
|
*/
|
|
1307
|
-
async function assertNetworkedVersion(clientPool) {
|
|
1308
|
-
|
|
1817
|
+
async function assertNetworkedVersion(clientPool, assumeEngineVersion) {
|
|
1818
|
+
return clientPool.withClient(async (c) => {
|
|
1309
1819
|
assertSupportedPowdbVersion(c.serverVersion);
|
|
1820
|
+
const version = assumeEngineVersion ?? c.serverVersion;
|
|
1821
|
+
const hasNativeRaw = typeof c.queryNativeRaw === 'function';
|
|
1822
|
+
return capabilitiesFromVersion(version, { hasNativeRaw });
|
|
1310
1823
|
});
|
|
1311
1824
|
}
|
|
1312
1825
|
function isPowdbClientPool(x) {
|