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/cjs/powdb.js
CHANGED
|
@@ -90,15 +90,20 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
90
90
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
91
91
|
};
|
|
92
92
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
93
|
-
exports.PowqlInterface = exports.PowdbEmbeddedPool = exports.PowdbPool = exports.DEFAULT_TX_QUEUE_TIMEOUT_MS = exports.POWQL_KEYWORDS = exports.MIN_POWDB_VERSION = exports.PowdbFloatParam = exports.powdbDialect = void 0;
|
|
93
|
+
exports.PowqlInterface = exports.introspectPowdbDatabase = exports.PowdbEmbeddedPool = exports.PowdbPool = exports.DEFAULT_TX_QUEUE_TIMEOUT_MS = exports.POWQL_KEYWORDS = exports.ALL_POWDB_CAPABILITIES = exports.MIN_POWDB_VERSION = exports.PowdbJsonParam = exports.PowdbFloatParam = exports.powdbDialect = void 0;
|
|
94
94
|
exports.parsePowdbUrl = parsePowdbUrl;
|
|
95
95
|
exports.assertSupportedPowdbVersion = assertSupportedPowdbVersion;
|
|
96
|
+
exports.capabilitiesFromVersion = capabilitiesFromVersion;
|
|
97
|
+
exports.requireCapability = requireCapability;
|
|
98
|
+
exports.isJsonColumn = isJsonColumn;
|
|
96
99
|
exports.powqlColumnType = powqlColumnType;
|
|
97
100
|
exports.quotePowqlIdent = quotePowqlIdent;
|
|
98
101
|
exports.powqlSchemaDDL = powqlSchemaDDL;
|
|
99
102
|
exports.coerceValue = coerceValue;
|
|
103
|
+
exports.coerceNativeValue = coerceNativeValue;
|
|
100
104
|
exports.rowToEntity = rowToEntity;
|
|
101
105
|
exports.wrapPowdbError = wrapPowdbError;
|
|
106
|
+
exports.isStaleFramePowdbError = isStaleFramePowdbError;
|
|
102
107
|
exports.encodePowqlLiteral = encodePowqlLiteral;
|
|
103
108
|
exports.materializePowql = materializePowql;
|
|
104
109
|
exports.turbinePowDB = turbinePowDB;
|
|
@@ -175,6 +180,23 @@ class PowdbFloatParam {
|
|
|
175
180
|
}
|
|
176
181
|
}
|
|
177
182
|
exports.PowdbFloatParam = PowdbFloatParam;
|
|
183
|
+
/**
|
|
184
|
+
* Marker wrapper for a JS object/array bound to a `json` document column. Both
|
|
185
|
+
* transports serialize `value` with `JSON.stringify` and send the text as a
|
|
186
|
+
* `str` param / string literal, exactly how the PowDB docs insert a json
|
|
187
|
+
* document (the engine validates it as JSON text and stores the canonical
|
|
188
|
+
* binary form). Constructed in {@link PowqlInterface.param} when the target
|
|
189
|
+
* column is `json` and the value is a non-null object/array; a JS string
|
|
190
|
+
* written to a json column passes through RAW (same contract as pg jsonb,
|
|
191
|
+
* pass `'"x"'` to store the JSON string `"x"`), and `null` stays `null`.
|
|
192
|
+
*/
|
|
193
|
+
class PowdbJsonParam {
|
|
194
|
+
value;
|
|
195
|
+
constructor(value) {
|
|
196
|
+
this.value = value;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
exports.PowdbJsonParam = PowdbJsonParam;
|
|
178
200
|
/** Minimum PowDB server version the networked transport requires. */
|
|
179
201
|
exports.MIN_POWDB_VERSION = '0.7.0';
|
|
180
202
|
/**
|
|
@@ -225,19 +247,119 @@ function assertSupportedPowdbVersion(version) {
|
|
|
225
247
|
throw new errors_js_1.ConnectionError(`[turbine] turbine-orm/powdb requires PowDB >= ${exports.MIN_POWDB_VERSION}; the server reports "${version}". ` +
|
|
226
248
|
'Upgrade the PowDB server (0.7.0 added the `returning` keyword and the int->float coercion fix Turbine relies on).');
|
|
227
249
|
}
|
|
250
|
+
/** Minimum engine version each gated feature needs, for the E017 hint text. */
|
|
251
|
+
const POWDB_FEATURE_MIN_VERSION = {
|
|
252
|
+
introspection: '0.10',
|
|
253
|
+
jsonDocs: '0.12',
|
|
254
|
+
docFieldIndexes: '0.13',
|
|
255
|
+
serverJoins: '0.13',
|
|
256
|
+
};
|
|
257
|
+
/**
|
|
258
|
+
* Trusted-caller default: every FEATURE gate on, engine version unknown. Used
|
|
259
|
+
* for a directly-constructed {@link PowdbPool} / {@link PowdbEmbeddedPool} that
|
|
260
|
+
* did not go through {@link turbinePowDB}'s version probe (e.g. an injected
|
|
261
|
+
* pool, or a unit-test pool). `nativeRaw` stays OFF here because it flips the
|
|
262
|
+
* actual wire path and must only be enabled after a real server-version probe,
|
|
263
|
+
* never inferred from a bare construction.
|
|
264
|
+
*/
|
|
265
|
+
exports.ALL_POWDB_CAPABILITIES = {
|
|
266
|
+
engineVersion: null,
|
|
267
|
+
jsonDocs: true,
|
|
268
|
+
docFieldIndexes: true,
|
|
269
|
+
introspection: true,
|
|
270
|
+
serverJoins: true,
|
|
271
|
+
nativeRaw: false,
|
|
272
|
+
};
|
|
273
|
+
/** Parse a PowDB semver prefix (`0.13.0`, `0.13`, `1.2.3-rc`) into components, or `null`. */
|
|
274
|
+
function parsePowdbSemver(version) {
|
|
275
|
+
const m = /^(\d+)\.(\d+)(?:\.(\d+))?/.exec(String(version ?? '').trim());
|
|
276
|
+
if (!m)
|
|
277
|
+
return null;
|
|
278
|
+
return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3] ?? 0) };
|
|
279
|
+
}
|
|
280
|
+
/** Is `sem` at least `major.minor`? */
|
|
281
|
+
function atLeastVersion(sem, major, minor) {
|
|
282
|
+
return sem.major > major || (sem.major === major && sem.minor >= minor);
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Derive {@link PowdbCapabilities} from an engine version string. A non-semver /
|
|
286
|
+
* unknown version turns every gate OFF (the E017 hint then tells the caller to
|
|
287
|
+
* upgrade or pass `assumeEngineVersion`). `nativeRaw` requires BOTH the client
|
|
288
|
+
* to expose `queryNativeRaw` (passed in) AND server ≥ 0.13.
|
|
289
|
+
*/
|
|
290
|
+
function capabilitiesFromVersion(version, opts = {}) {
|
|
291
|
+
const sem = parsePowdbSemver(version);
|
|
292
|
+
if (!sem) {
|
|
293
|
+
return {
|
|
294
|
+
engineVersion: version ?? null,
|
|
295
|
+
jsonDocs: false,
|
|
296
|
+
docFieldIndexes: false,
|
|
297
|
+
introspection: false,
|
|
298
|
+
serverJoins: false,
|
|
299
|
+
nativeRaw: false,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
return {
|
|
303
|
+
engineVersion: `${sem.major}.${sem.minor}.${sem.patch}`,
|
|
304
|
+
introspection: atLeastVersion(sem, 0, 10),
|
|
305
|
+
jsonDocs: atLeastVersion(sem, 0, 12),
|
|
306
|
+
docFieldIndexes: atLeastVersion(sem, 0, 13),
|
|
307
|
+
serverJoins: atLeastVersion(sem, 0, 13),
|
|
308
|
+
nativeRaw: Boolean(opts.hasNativeRaw) && atLeastVersion(sem, 0, 13),
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Throw a version-hinting {@link UnsupportedFeatureError} (E017) when a gated
|
|
313
|
+
* PowQL feature is used on an engine that does not support it. Keeps old engines
|
|
314
|
+
* getting clean typed errors instead of raw PowQL parse failures.
|
|
315
|
+
*/
|
|
316
|
+
function requireCapability(caps, key, feature) {
|
|
317
|
+
if (caps[key])
|
|
318
|
+
return;
|
|
319
|
+
const min = POWDB_FEATURE_MIN_VERSION[key];
|
|
320
|
+
const reported = caps.engineVersion
|
|
321
|
+
? `this connection reports ${caps.engineVersion}`
|
|
322
|
+
: 'this connection could not report a version';
|
|
323
|
+
throw new errors_js_1.UnsupportedFeatureError(feature, 'PowDB', `${feature} requires PowDB >= ${min}; ${reported}. Upgrade powdb-server / @zvndev/powdb-embedded ` +
|
|
324
|
+
'(or pass `assumeEngineVersion` if the version cannot be detected).');
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Does this column map to PowDB's native `json` document type? A Postgres
|
|
328
|
+
* `json`/`jsonb` type (via `dialectType`/`pgType`) is authoritative; otherwise
|
|
329
|
+
* the tsType heuristic (`Record<…>`, `object`, `unknown`, an object/array
|
|
330
|
+
* literal) that the four scalar branches do not claim. Array columns never map
|
|
331
|
+
* to json, a PowDB array only exists INSIDE a json document, so a Postgres
|
|
332
|
+
* array column has no PowDB shape and still throws in {@link powqlColumnType}.
|
|
333
|
+
*/
|
|
334
|
+
function isJsonColumn(col) {
|
|
335
|
+
if (col.isArray)
|
|
336
|
+
return false;
|
|
337
|
+
const dbType = (col.dialectType ?? col.pgType ?? '').toLowerCase();
|
|
338
|
+
if (dbType === 'json' || dbType === 'jsonb')
|
|
339
|
+
return true;
|
|
340
|
+
const ts = col.tsType.replace(/\s*\|\s*null$/i, '').trim();
|
|
341
|
+
if (ts === 'Date' || ts === 'boolean' || ts === 'number' || ts === 'bigint' || ts === 'string')
|
|
342
|
+
return false;
|
|
343
|
+
if (ts === 'Buffer' || ts === 'Uint8Array')
|
|
344
|
+
return false;
|
|
345
|
+
return /Record<|object|unknown|\[\]|\{/.test(ts);
|
|
346
|
+
}
|
|
228
347
|
/**
|
|
229
348
|
* Map a Turbine column to the PowQL DDL type used in `defineSchema` →
|
|
230
349
|
* `type T { … }`. Turbine never emits PowDB's `uuid`/`datetime`/`bytes` types,
|
|
231
350
|
* which cannot hold client-supplied values on the wire (no literal, no cast):
|
|
232
351
|
* - `Date` → `int` (epoch micros) - `boolean` → `bool`
|
|
233
352
|
* - integral `number`/`bigint` → `int` - fractional `number` → `float`
|
|
353
|
+
* - JSON / object columns → `json` (native PowDB document type, ≥ 0.12)
|
|
234
354
|
* - everything else (incl. UUID/PK strings) → `str`
|
|
235
|
-
* Array
|
|
355
|
+
* Array (non-json) and bytes columns throw, they have no PowDB equivalent.
|
|
236
356
|
*/
|
|
237
357
|
function powqlColumnType(col) {
|
|
238
358
|
if (col.isArray) {
|
|
239
359
|
throw new errors_js_1.ValidationError(`[turbine] Column "${col.name}" is an array — PowDB has no array type. Arrays are unsupported on the PowDB backend.`);
|
|
240
360
|
}
|
|
361
|
+
if (isJsonColumn(col))
|
|
362
|
+
return 'json';
|
|
241
363
|
const ts = col.tsType.replace(/\s*\|\s*null$/i, '').trim();
|
|
242
364
|
if (ts === 'Date')
|
|
243
365
|
return 'int'; // epoch micros
|
|
@@ -252,9 +374,6 @@ function powqlColumnType(col) {
|
|
|
252
374
|
if (ts === 'Buffer' || ts === 'Uint8Array') {
|
|
253
375
|
throw new errors_js_1.ValidationError(`[turbine] Column "${col.name}" is binary — PowDB cannot store client-supplied bytes on the wire. Use a string (e.g. base64) instead.`);
|
|
254
376
|
}
|
|
255
|
-
if (/Record<|object|unknown|\[\]|\{/.test(ts)) {
|
|
256
|
-
throw new errors_js_1.ValidationError(`[turbine] Column "${col.name}" (${col.tsType}) maps to JSON/object, which PowDB has no type for. Flatten it or store a JSON string.`);
|
|
257
|
-
}
|
|
258
377
|
return 'str';
|
|
259
378
|
}
|
|
260
379
|
/** Heuristic: does this numeric column hold fractional values (→ PowQL `float`)? */
|
|
@@ -394,7 +513,8 @@ function quotePowqlIdent(name) {
|
|
|
394
513
|
}
|
|
395
514
|
return exports.POWQL_KEYWORDS.has(name) || !POWQL_BARE_IDENT.test(name) ? `\`${name}\`` : name;
|
|
396
515
|
}
|
|
397
|
-
function powqlSchemaDDL(schema) {
|
|
516
|
+
function powqlSchemaDDL(schema, opts = {}) {
|
|
517
|
+
const caps = opts.capabilities;
|
|
398
518
|
const stmts = [];
|
|
399
519
|
for (const meta of Object.values(schema.tables)) {
|
|
400
520
|
const pkSet = new Set(meta.primaryKey);
|
|
@@ -405,6 +525,12 @@ function powqlSchemaDDL(schema) {
|
|
|
405
525
|
// cannot enforce the tuple's uniqueness at the engine level.
|
|
406
526
|
const pkIsSingle = meta.primaryKey.length === 1;
|
|
407
527
|
const fields = meta.columns.map((col) => {
|
|
528
|
+
const powqlType = powqlColumnType(col);
|
|
529
|
+
// Gate `json` columns behind the engine's jsonDocs capability when a
|
|
530
|
+
// caller supplied one, an old engine has no `json` type and would reject
|
|
531
|
+
// the DDL. Pure-function callers (no opts) emit unconditionally.
|
|
532
|
+
if (powqlType === 'json' && caps)
|
|
533
|
+
requireCapability(caps, 'jsonDocs', 'JSON document columns');
|
|
408
534
|
const mods = [];
|
|
409
535
|
if (!col.nullable || pkSet.has(col.name))
|
|
410
536
|
mods.push('required');
|
|
@@ -413,15 +539,57 @@ function powqlSchemaDDL(schema) {
|
|
|
413
539
|
// `auto` = server-generated monotonic int. PowDB requires it be `int` and
|
|
414
540
|
// rejects it alongside a `default`; non-int generated columns fall back to
|
|
415
541
|
// a plain typed column (Turbine assigns the value client-side instead).
|
|
416
|
-
if (col.isGenerated &&
|
|
542
|
+
if (col.isGenerated && powqlType === 'int')
|
|
417
543
|
mods.push('auto');
|
|
418
|
-
return ` ${mods.join(' ')}${mods.length ? ' ' : ''}${quotePowqlIdent(col.name)}: ${
|
|
544
|
+
return ` ${mods.join(' ')}${mods.length ? ' ' : ''}${quotePowqlIdent(col.name)}: ${powqlType}`;
|
|
419
545
|
});
|
|
420
546
|
stmts.push(`type ${quotePowqlIdent(meta.name)} {\n${fields.join(',\n')}\n}`);
|
|
547
|
+
// Track which single columns already carry a unique constraint (the
|
|
548
|
+
// single-column PK is inlined `required unique` in the type body above) so
|
|
549
|
+
// a redundant `add unique .col` is never emitted twice.
|
|
550
|
+
const emittedUnique = new Set();
|
|
551
|
+
if (pkIsSingle && meta.primaryKey[0] !== undefined)
|
|
552
|
+
emittedUnique.add(meta.primaryKey[0]);
|
|
421
553
|
// Secondary unique constraints (beyond the PK) become unique indexes.
|
|
422
554
|
for (const uniq of meta.uniqueColumns) {
|
|
423
555
|
if (uniq.length === 1 && !pkSet.has(uniq[0])) {
|
|
424
556
|
stmts.push(`alter ${quotePowqlIdent(meta.name)} add unique .${quotePowqlIdent(uniq[0])}`);
|
|
557
|
+
emittedUnique.add(uniq[0]);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
// Declared indexes: PowDB doc-field expression indexes (docPath) and plain
|
|
561
|
+
// single-column indexes. A doc-field index MUST be parenthesized (the engine
|
|
562
|
+
// rejects a bare JSON path); string path segments emit lexer-exact via the
|
|
563
|
+
// shared `encodePowqlString`, integer array indexes emit bare. A json
|
|
564
|
+
// document column reference stays dotted-bare (`.col`), which bypasses
|
|
565
|
+
// keyword lookup on every engine version exactly like a filter path.
|
|
566
|
+
for (const idx of meta.indexes) {
|
|
567
|
+
const kind = idx.unique ? 'unique' : 'index';
|
|
568
|
+
if (idx.docPath) {
|
|
569
|
+
if (caps)
|
|
570
|
+
requireCapability(caps, 'docFieldIndexes', 'JSON doc-field expression indexes');
|
|
571
|
+
const column = idx.columns[0];
|
|
572
|
+
if (column === undefined) {
|
|
573
|
+
throw new errors_js_1.ValidationError(`[turbine] Doc-field index "${idx.name}" on ${meta.name} has no target json column.`);
|
|
574
|
+
}
|
|
575
|
+
const segs = idx.docPath.map((s) => (typeof s === 'number' ? `->${s}` : `->${encodePowqlString(s)}`)).join('');
|
|
576
|
+
stmts.push(`alter ${quotePowqlIdent(meta.name)} add ${kind} (.${column}${segs})`);
|
|
577
|
+
}
|
|
578
|
+
else {
|
|
579
|
+
// Plain column index. PowDB has no composite index (`add index` takes a
|
|
580
|
+
// single `.column`), so a multi-column entry is a typed E017.
|
|
581
|
+
if (idx.columns.length !== 1) {
|
|
582
|
+
throw new errors_js_1.UnsupportedFeatureError('composite indexes', 'PowDB', `PowDB has no composite index. Index "${idx.name}" on ${meta.name} lists ` +
|
|
583
|
+
`${idx.columns.length} columns; declare a single-column index (or a doc-field index) instead.`);
|
|
584
|
+
}
|
|
585
|
+
const column = idx.columns[0];
|
|
586
|
+
// A unique index whose column already carries a unique constraint (the
|
|
587
|
+
// PK, or a column-level unique) would be a redundant duplicate, so skip it.
|
|
588
|
+
if (idx.unique && emittedUnique.has(column))
|
|
589
|
+
continue;
|
|
590
|
+
stmts.push(`alter ${quotePowqlIdent(meta.name)} add ${kind} .${quotePowqlIdent(column)}`);
|
|
591
|
+
if (idx.unique)
|
|
592
|
+
emittedUnique.add(column);
|
|
425
593
|
}
|
|
426
594
|
}
|
|
427
595
|
}
|
|
@@ -431,6 +599,10 @@ function powqlSchemaDDL(schema) {
|
|
|
431
599
|
function toPowdbParam(value, col) {
|
|
432
600
|
if (value instanceof PowdbFloatParam)
|
|
433
601
|
return value.value; // wire-side: a float column takes the plain number
|
|
602
|
+
// json document: serialize to canonical JSON text and bind as a str param,
|
|
603
|
+
// the engine validates it as JSON and stores the canonical binary form.
|
|
604
|
+
if (value instanceof PowdbJsonParam)
|
|
605
|
+
return JSON.stringify(value.value);
|
|
434
606
|
if (value === undefined || value === null)
|
|
435
607
|
return null;
|
|
436
608
|
if (value instanceof Date)
|
|
@@ -453,10 +625,23 @@ function toPowdbParam(value, col) {
|
|
|
453
625
|
*/
|
|
454
626
|
function coerceValue(raw, col) {
|
|
455
627
|
const ts = col.tsType.replace(/\s*\|\s*null$/i, '').trim();
|
|
628
|
+
const json = isJsonColumn(col);
|
|
456
629
|
// NULL bareword: unambiguous for non-string columns; for `str` we cannot tell a
|
|
457
630
|
// literal "null" from SQL NULL, so a nullable str of value "null" reads as null.
|
|
458
|
-
|
|
631
|
+
// For a `json` column the bareword `null` (a legacy-wire rendering shared by an
|
|
632
|
+
// absent value AND a top-level JSON-null document, documented residual,
|
|
633
|
+
// resolved on the native transport by the WireValue path) maps to null; a JSON
|
|
634
|
+
// string document "null" renders WITH quotes (`"null"`) and parses distinctly.
|
|
635
|
+
if (raw === 'null' && (json || ts !== 'string' || col.nullable))
|
|
459
636
|
return null;
|
|
637
|
+
if (json) {
|
|
638
|
+
try {
|
|
639
|
+
return JSON.parse(raw);
|
|
640
|
+
}
|
|
641
|
+
catch {
|
|
642
|
+
return raw; // defensive: canonical JSON text always parses
|
|
643
|
+
}
|
|
644
|
+
}
|
|
460
645
|
if (ts === 'Date') {
|
|
461
646
|
const micros = Number(raw);
|
|
462
647
|
return Number.isFinite(micros) ? new Date(micros / 1000) : null;
|
|
@@ -473,19 +658,62 @@ function coerceValue(raw, col) {
|
|
|
473
658
|
return raw; // string / uuid-as-string
|
|
474
659
|
}
|
|
475
660
|
/**
|
|
476
|
-
*
|
|
477
|
-
* {@link
|
|
478
|
-
*
|
|
479
|
-
*
|
|
661
|
+
* Coerce a single cell that arrived over the NATIVE typed wire (decoded from a
|
|
662
|
+
* {@link PowdbWireValue}, so already a JS `bigint`/`number`/`boolean`/`string`/
|
|
663
|
+
* `NativeJson`/`Uint8Array`/`null`, never a bare `"null"` string). Unlike
|
|
664
|
+
* {@link coerceValue} this NEVER collapses the string `"null"` to `null`: an
|
|
665
|
+
* absent value already decoded to `null` (from the `empty` cell), so a genuine
|
|
666
|
+
* str `"null"` stays the string `"null"` (fixes the legacy-wire wart on the
|
|
667
|
+
* native transport). `datetime`-shaped cells (int micros) become `Date`; a
|
|
668
|
+
* bigint on a `number` column follows the int8 safe-integer policy.
|
|
669
|
+
*/
|
|
670
|
+
function coerceNativeValue(value, col) {
|
|
671
|
+
if (value === undefined || value === null)
|
|
672
|
+
return null;
|
|
673
|
+
if (isDateColumn(col)) {
|
|
674
|
+
if (typeof value === 'bigint')
|
|
675
|
+
return new Date(Number(value) / 1000);
|
|
676
|
+
if (typeof value === 'number')
|
|
677
|
+
return new Date(value / 1000);
|
|
678
|
+
return value;
|
|
679
|
+
}
|
|
680
|
+
const ts = col.tsType.replace(/\s*\|\s*null$/i, '').trim();
|
|
681
|
+
if (typeof value === 'bigint') {
|
|
682
|
+
if (ts === 'bigint')
|
|
683
|
+
return value;
|
|
684
|
+
if (ts === 'number') {
|
|
685
|
+
const n = Number(value);
|
|
686
|
+
return Number.isSafeInteger(n) ? n : value.toString(); // int8 policy: keep big ints as strings
|
|
687
|
+
}
|
|
688
|
+
return value;
|
|
689
|
+
}
|
|
690
|
+
return value; // number / boolean / string / NativeJson document / Uint8Array
|
|
691
|
+
}
|
|
692
|
+
/**
|
|
693
|
+
* Map one raw PowDB row into a typed entity (camelCase fields, coerced values).
|
|
694
|
+
* Only the columns present in `raw` are emitted, so partial `select`
|
|
695
|
+
* projections round-trip unchanged. `native` selects the coercion policy: the
|
|
696
|
+
* default `false` handles the legacy string wire (every cell is a string, via
|
|
697
|
+
* {@link coerceValue}); `true` handles the native typed wire, where non-string
|
|
698
|
+
* cells arrive pre-typed and go through {@link coerceNativeValue} (see F3).
|
|
699
|
+
* Callers on the native transport pass `this.pool.capabilities.nativeRaw`.
|
|
480
700
|
*/
|
|
481
|
-
function rowToEntity(raw, meta) {
|
|
701
|
+
function rowToEntity(raw, meta, native = false) {
|
|
482
702
|
const byName = new Map(meta.columns.map((c) => [c.name, c]));
|
|
483
703
|
const out = {};
|
|
484
704
|
for (const snake of Object.keys(raw)) {
|
|
485
705
|
const col = byName.get(snake);
|
|
486
706
|
const field = meta.reverseColumnMap[snake] ?? snake;
|
|
487
707
|
const value = raw[snake];
|
|
488
|
-
|
|
708
|
+
if (!col) {
|
|
709
|
+
out[field] = value;
|
|
710
|
+
}
|
|
711
|
+
else if (native) {
|
|
712
|
+
out[field] = coerceNativeValue(value, col);
|
|
713
|
+
}
|
|
714
|
+
else {
|
|
715
|
+
out[field] = typeof value === 'string' ? coerceValue(value, col) : value;
|
|
716
|
+
}
|
|
489
717
|
}
|
|
490
718
|
return out;
|
|
491
719
|
}
|
|
@@ -511,7 +739,7 @@ function wrapPowdbError(err) {
|
|
|
511
739
|
const e = err;
|
|
512
740
|
const msg = e.message ?? 'unknown PowDB error';
|
|
513
741
|
// Unique-constraint — message-based on both transports.
|
|
514
|
-
if (/unique constraint violation/i.test(msg)) {
|
|
742
|
+
if (/unique (constraint|expression index) violation/i.test(msg)) {
|
|
515
743
|
const m = /on\s+\S+\.(\w+)/i.exec(msg);
|
|
516
744
|
return new errors_js_1.UniqueConstraintError({ constraint: m?.[1], cause: err });
|
|
517
745
|
}
|
|
@@ -521,40 +749,123 @@ function wrapPowdbError(err) {
|
|
|
521
749
|
const m = /column ['"]?(\w+)['"]?/i.exec(msg);
|
|
522
750
|
return new errors_js_1.NotNullViolationError({ column: m?.[1], cause: err });
|
|
523
751
|
}
|
|
524
|
-
// Driver pool lifecycle errors (acquire after close, acquire timeout
|
|
525
|
-
//
|
|
526
|
-
|
|
527
|
-
|
|
752
|
+
// Driver pool lifecycle errors (acquire after close, acquire timeout, or a
|
|
753
|
+
// statement reaching an already-closed embedded handle) carry no .code:
|
|
754
|
+
// classify by message so both transports surface E004.
|
|
755
|
+
if (/pool closed|pool acquire timeout|database is closed/i.test(msg)) {
|
|
756
|
+
return new errors_js_1.ConnectionError(`[turbine] PowDB connection unavailable: ${msg}`, { cause: err });
|
|
528
757
|
}
|
|
529
758
|
// Server-side transaction-gate wait bound (PowDB ≥ 0.10, default 5s): another
|
|
530
759
|
// connection held the single global write lock past the server's
|
|
531
760
|
// --tx-wait-timeout-ms. Retryable timeout, not a query defect.
|
|
532
761
|
if (/transaction gate timeout/i.test(msg)) {
|
|
533
|
-
return new errors_js_1.TimeoutError(0, 'PowDB transaction gate');
|
|
762
|
+
return new errors_js_1.TimeoutError(0, 'PowDB transaction gate', { cause: err });
|
|
763
|
+
}
|
|
764
|
+
// Stale / violated WIRE state → ConnectionError (E004), NOT a query defect.
|
|
765
|
+
// A `protocol_error`-class failure means the socket's framing state is gone
|
|
766
|
+
// (the client cannot safely reuse it and the pool must destroy it). The
|
|
767
|
+
// canonical trigger is the "received unexpected frame from server" that a
|
|
768
|
+
// fresh request hits after a multi-minute idle gap; sibling shapes are an
|
|
769
|
+
// unknown message type, a truncated payload, or bad framing. Runs BEFORE the
|
|
770
|
+
// validation regex below, whose `unexpected` token would otherwise misclass
|
|
771
|
+
// "received unexpected frame" as an E003 query defect. `.cause` preserved so
|
|
772
|
+
// callers (and the opt-in stale-read retry) can inspect the driver code.
|
|
773
|
+
if (e.code === 'protocol_error' ||
|
|
774
|
+
/received unexpected frame|unknown message type|truncated payload|bad framing/i.test(msg)) {
|
|
775
|
+
return new errors_js_1.ConnectionError(`[turbine] PowDB connection is in an invalid state: ${msg}`, { cause: err });
|
|
534
776
|
}
|
|
535
|
-
//
|
|
536
|
-
// (
|
|
537
|
-
//
|
|
538
|
-
//
|
|
777
|
+
// Read-only refusal → ReadOnlyError (E018). Two engine shapes, both mapped by
|
|
778
|
+
// substring (the networked transport prefixes the message with `query failed:
|
|
779
|
+
// `, so never anchor on the start): an embedded database opened read-only for
|
|
780
|
+
// snapshot serving (`readonly mode: statement requires a writer …`), and a
|
|
781
|
+
// networked read-only role (`permission denied: role '<role>' cannot execute
|
|
782
|
+
// write statements`). These run BEFORE the generic validation regex below so a
|
|
783
|
+
// read-only write is surfaced as the routing signal E018, not a query defect.
|
|
784
|
+
// The driver spec (0.15) distinguishes them via `reason`: snapshot mode
|
|
785
|
+
// means "nothing can write here; route writes to the primary", RBAC means
|
|
786
|
+
// "this connection's role may not write here".
|
|
787
|
+
if (/readonly mode: statement requires a writer/i.test(msg)) {
|
|
788
|
+
return new errors_js_1.ReadOnlyError(`PowDB refused a write on a read-only database: ${msg}.`, {
|
|
789
|
+
cause: err,
|
|
790
|
+
reason: 'snapshot',
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
if (/permission denied: role/i.test(msg)) {
|
|
794
|
+
return new errors_js_1.ReadOnlyError(`PowDB refused a write for a read-only role: ${msg}.`, { cause: err, reason: 'rbac' });
|
|
795
|
+
}
|
|
796
|
+
// Open-time read-only failure: a read-only handle over a directory whose WAL
|
|
797
|
+
// still has uncommitted frames is refused (`cannot open read-only: the WAL is
|
|
798
|
+
// not empty …`). It is a connection failure (E004), not a query defect, the
|
|
799
|
+
// fix is to recover the directory with a writable open first.
|
|
800
|
+
if (/cannot open read-only: the WAL is not empty/i.test(msg)) {
|
|
801
|
+
return new errors_js_1.ConnectionError(`[turbine] PowDB could not open the directory read-only: ${msg}. Open it once with a writable handle to ` +
|
|
802
|
+
'flush the WAL (recover the directory), then reopen it read-only for snapshot serving.', { cause: err });
|
|
803
|
+
}
|
|
804
|
+
// Per-query deadline → TimeoutError (E002). Message-path so it fires on the
|
|
805
|
+
// embedded transport too (code is always 'GenericFailure' there); retryable.
|
|
806
|
+
// Pass the engine prose through the message override (same pattern as the
|
|
807
|
+
// transaction-gate timeout below) so the real "query timeout after <n>ms"
|
|
808
|
+
// survives instead of rendering the placeholder "timed out after 0ms".
|
|
809
|
+
if (/query timeout after/i.test(msg)) {
|
|
810
|
+
return new errors_js_1.TimeoutError(0, 'PowDB query', { message: `[turbine] PowDB ${msg}`, cause: err });
|
|
811
|
+
}
|
|
812
|
+
// Client-initiated cancellation → ConnectionError (E004). This is FINAL: the
|
|
813
|
+
// issuing client disconnected, so the query was a clean early return, never
|
|
814
|
+
// auto-retry it (the opt-in stale-read retry only replays stale-FRAME reads).
|
|
815
|
+
if (/query cancelled by client disconnect/i.test(msg)) {
|
|
816
|
+
return new errors_js_1.ConnectionError(`[turbine] PowDB query cancelled by client disconnect: ${msg}`, { cause: err });
|
|
817
|
+
}
|
|
818
|
+
// Bounded join rejection → ValidationError (E003). The engine rejects a pure
|
|
819
|
+
// nested-loop join whose candidate-pair count (or result row count) exceeds
|
|
820
|
+
// the safety bound BEFORE executing, and names the fix in the message, keep
|
|
821
|
+
// that fix-hint intact so the caller knows how to make the join eligible.
|
|
822
|
+
if (/nested-loop join would evaluate|join result exceeds row limit/i.test(msg)) {
|
|
823
|
+
return new errors_js_1.ValidationError(`[turbine] PowDB join rejected: ${msg}`);
|
|
824
|
+
}
|
|
825
|
+
// Type mismatch / parse / execution / storage / unexpected(token) / row too
|
|
826
|
+
// large → validation (E003). On the embedded transport these are the only
|
|
827
|
+
// signal we get (code is always 'GenericFailure'); on the networked path they
|
|
828
|
+
// are a safety net before the .code switch.
|
|
539
829
|
if (/type mismatch|\bParse\b|\bExecution\b|StorageError|unexpected|row too large/i.test(msg)) {
|
|
540
830
|
return new errors_js_1.ValidationError(`[turbine] PowDB query rejected: ${msg}`);
|
|
541
831
|
}
|
|
542
832
|
switch (e.code) {
|
|
543
833
|
case 'connect_failed':
|
|
544
834
|
case 'closed':
|
|
545
|
-
return new errors_js_1.ConnectionError(`[turbine] PowDB connection failed: ${msg}
|
|
835
|
+
return new errors_js_1.ConnectionError(`[turbine] PowDB connection failed: ${msg}`, { cause: err });
|
|
836
|
+
case 'auth_failed':
|
|
837
|
+
// Connection-establishment class, non-retryable: the handshake was
|
|
838
|
+
// rejected. Surface E004 with a concrete remediation hint instead of
|
|
839
|
+
// letting it fall through to the raw error.
|
|
840
|
+
return new errors_js_1.ConnectionError(`[turbine] PowDB authentication failed: ${msg} (check the user / password / dbName for this connection).`, { cause: err });
|
|
546
841
|
case 'timeout':
|
|
547
842
|
case 'aborted':
|
|
548
|
-
return new errors_js_1.TimeoutError(0, 'PowDB query');
|
|
843
|
+
return new errors_js_1.TimeoutError(0, 'PowDB query', { cause: err });
|
|
549
844
|
case 'query_failed':
|
|
550
845
|
case 'type_coercion_failed':
|
|
551
|
-
case 'protocol_error':
|
|
552
846
|
case 'size_exceeded':
|
|
553
847
|
return new errors_js_1.ValidationError(`[turbine] PowDB query rejected: ${msg}`);
|
|
554
848
|
default:
|
|
555
|
-
return err instanceof Error ? err : new errors_js_1.ConnectionError(`[turbine] PowDB error: ${msg}
|
|
849
|
+
return err instanceof Error ? err : new errors_js_1.ConnectionError(`[turbine] PowDB error: ${msg}`, { cause: err });
|
|
556
850
|
}
|
|
557
851
|
}
|
|
852
|
+
/**
|
|
853
|
+
* True when `err` is the stale-wire-frame {@link ConnectionError} produced by
|
|
854
|
+
* {@link wrapPowdbError} (its `.cause` is a `protocol_error` PowDBError, or the
|
|
855
|
+
* message carries the invalid-state signature). The opt-in read retry
|
|
856
|
+
* (`retryStaleReads`, evaluated in {@link PowqlInterface}'s exec seam) uses this
|
|
857
|
+
* to decide whether a first-statement READ may be replayed once on a fresh
|
|
858
|
+
* connection; writes are NEVER retried (an ambiguous mutation reply is unsafe
|
|
859
|
+
* to replay, matching the client's own native-path policy).
|
|
860
|
+
*/
|
|
861
|
+
function isStaleFramePowdbError(err) {
|
|
862
|
+
if (!(err instanceof errors_js_1.ConnectionError))
|
|
863
|
+
return false;
|
|
864
|
+
const cause = err.cause;
|
|
865
|
+
if (cause && typeof cause === 'object' && cause.code === 'protocol_error')
|
|
866
|
+
return true;
|
|
867
|
+
return /PowDB connection is in an invalid state/.test(err.message);
|
|
868
|
+
}
|
|
558
869
|
function normalizeQueryArgs(arg, values) {
|
|
559
870
|
if (typeof arg === 'string')
|
|
560
871
|
return { text: arg, params: values ?? [] };
|
|
@@ -733,7 +1044,7 @@ class PowdbTxGate {
|
|
|
733
1044
|
return hold;
|
|
734
1045
|
}
|
|
735
1046
|
}
|
|
736
|
-
/** Adapt a PowDB result into the pg-compat `{ rows, rowCount, fields }` shape. */
|
|
1047
|
+
/** Adapt a PowDB (legacy string wire) result into the pg-compat `{ rows, rowCount, fields }` shape. */
|
|
737
1048
|
function adaptResult(r) {
|
|
738
1049
|
switch (r.kind) {
|
|
739
1050
|
case 'rows': {
|
|
@@ -744,21 +1055,86 @@ function adaptResult(r) {
|
|
|
744
1055
|
});
|
|
745
1056
|
return o;
|
|
746
1057
|
});
|
|
747
|
-
return { rows, rowCount: rows.length, fields: r.columns.map((name) => ({ name, dataTypeID: 0 })) };
|
|
1058
|
+
return { rows, rowCount: rows.length, fields: r.columns.map((name) => ({ name, dataTypeID: 0 })), native: false };
|
|
748
1059
|
}
|
|
749
1060
|
case 'ok':
|
|
750
|
-
return { rows: [], rowCount: Number(r.affected), fields: [] };
|
|
1061
|
+
return { rows: [], rowCount: Number(r.affected), fields: [], native: false };
|
|
751
1062
|
case 'scalar':
|
|
752
|
-
return { rows: [{ value: r.value }], rowCount: 1, fields: [{ name: 'value', dataTypeID: 0 }] };
|
|
1063
|
+
return { rows: [{ value: r.value }], rowCount: 1, fields: [{ name: 'value', dataTypeID: 0 }], native: false };
|
|
753
1064
|
default:
|
|
754
|
-
return { rows: [], rowCount: 0, fields: [] };
|
|
1065
|
+
return { rows: [], rowCount: 0, fields: [], native: false };
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
/** Format 16 raw UUID bytes as a canonical `8-4-4-4-12` hex string. */
|
|
1069
|
+
function uuidBytesToHex(bytes) {
|
|
1070
|
+
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
1071
|
+
if (hex.length !== 32)
|
|
1072
|
+
return hex; // defensive: non-16B payloads pass through as raw hex
|
|
1073
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
1074
|
+
}
|
|
1075
|
+
/**
|
|
1076
|
+
* Decode one native {@link PowdbWireValue} cell into a JS value. `empty` →
|
|
1077
|
+
* `null` (an unset value; for a json column this cleanly distinguishes absent
|
|
1078
|
+
* from a JSON-null document, which arrives as `{ type: 'json', value: null }`).
|
|
1079
|
+
* `int`/`datetime` stay `bigint` so the row layer ({@link coerceNativeValue})
|
|
1080
|
+
* applies the int8 policy / Date conversion by column; `uuid` becomes canonical
|
|
1081
|
+
* hex; `bytes` stay `Uint8Array`; `json` passes the decoded document through
|
|
1082
|
+
* with no re-parse (its `pj1` raw bytes are dropped).
|
|
1083
|
+
*/
|
|
1084
|
+
function decodeWireValue(cell) {
|
|
1085
|
+
switch (cell.type) {
|
|
1086
|
+
case 'empty':
|
|
1087
|
+
return null;
|
|
1088
|
+
case 'int':
|
|
1089
|
+
case 'datetime':
|
|
1090
|
+
return cell.value; // bigint; row layer decides Date vs number vs bigint per column
|
|
1091
|
+
case 'float':
|
|
1092
|
+
case 'bool':
|
|
1093
|
+
case 'str':
|
|
1094
|
+
return cell.value;
|
|
1095
|
+
case 'uuid':
|
|
1096
|
+
return uuidBytesToHex(cell.value);
|
|
1097
|
+
case 'bytes':
|
|
1098
|
+
return cell.value; // Uint8Array
|
|
1099
|
+
case 'json':
|
|
1100
|
+
return cell.value; // NativeJson document, already recursive data
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
/** Adapt a native (typed-wire) PowDB result into the pg-compat shape, decoding every {@link PowdbWireValue} cell. */
|
|
1104
|
+
function adaptNativeResult(r) {
|
|
1105
|
+
switch (r.kind) {
|
|
1106
|
+
case 'rows': {
|
|
1107
|
+
const rows = r.rows.map((row) => {
|
|
1108
|
+
const o = {};
|
|
1109
|
+
r.columns.forEach((c, i) => {
|
|
1110
|
+
o[c] = decodeWireValue(row[i] ?? { type: 'empty' });
|
|
1111
|
+
});
|
|
1112
|
+
return o;
|
|
1113
|
+
});
|
|
1114
|
+
return { rows, rowCount: rows.length, fields: r.columns.map((name) => ({ name, dataTypeID: 0 })), native: true };
|
|
1115
|
+
}
|
|
1116
|
+
case 'ok':
|
|
1117
|
+
return { rows: [], rowCount: Number(r.affected), fields: [], native: true };
|
|
1118
|
+
case 'scalar':
|
|
1119
|
+
return {
|
|
1120
|
+
rows: [{ value: decodeWireValue(r.value) }],
|
|
1121
|
+
rowCount: 1,
|
|
1122
|
+
fields: [{ name: 'value', dataTypeID: 0 }],
|
|
1123
|
+
native: true,
|
|
1124
|
+
};
|
|
1125
|
+
default:
|
|
1126
|
+
return { rows: [], rowCount: 0, fields: [], native: true };
|
|
755
1127
|
}
|
|
756
1128
|
}
|
|
757
1129
|
/**
|
|
758
1130
|
* A {@link PgCompatPool} backed by a `@zvndev/powdb-client` `Pool`. The query
|
|
759
|
-
* `text` is **PowQL**, not SQL
|
|
760
|
-
*
|
|
761
|
-
* (
|
|
1131
|
+
* `text` is **PowQL**, not SQL ({@link PowqlInterface} generates it). On the
|
|
1132
|
+
* legacy string wire cells come back as strings; when `capabilities.nativeRaw`
|
|
1133
|
+
* is set (server ≥ 0.13 + a client exposing `queryNativeRaw`) this pool routes
|
|
1134
|
+
* through the typed native wire instead, so cells arrive pre-typed (a json int
|
|
1135
|
+
* as `bigint`, etc.) and each result is tagged with the wire that served it
|
|
1136
|
+
* ({@link PowdbTaggedResult}). Per-column JS coercion still happens in
|
|
1137
|
+
* `PowqlInterface` (it owns the schema metadata), keyed on that per-result tag.
|
|
762
1138
|
*/
|
|
763
1139
|
class PowdbPool {
|
|
764
1140
|
pool;
|
|
@@ -782,10 +1158,37 @@ class PowdbPool {
|
|
|
782
1158
|
* live socket holding the process open until the server's idle timeout.
|
|
783
1159
|
*/
|
|
784
1160
|
checkedOut = new Set();
|
|
1161
|
+
/** Feature capabilities of the bound server (probed version + native-wire feature-detect). */
|
|
1162
|
+
capabilities;
|
|
1163
|
+
/** Opt-in first-statement-read replay on a stale wire frame (read by {@link PowqlInterface}). */
|
|
1164
|
+
retryStaleReads;
|
|
1165
|
+
/**
|
|
1166
|
+
* True when the caller marked this pool read-only (`readonly: true`). Read by
|
|
1167
|
+
* {@link PowqlInterface}'s exec seam to fail writes fast with E018 before the
|
|
1168
|
+
* wire; the engine's own read-only-role refusal (mapped by
|
|
1169
|
+
* {@link wrapPowdbError}) is the backstop for raw / injected paths.
|
|
1170
|
+
*/
|
|
1171
|
+
readonly;
|
|
785
1172
|
constructor(pool, toParam = (v) => toPowdbParam(v), options = {}) {
|
|
786
1173
|
this.pool = pool;
|
|
787
1174
|
this.toParam = toParam;
|
|
788
1175
|
this.txGate = new PowdbTxGate(options.transactionQueueTimeoutMs ?? exports.DEFAULT_TX_QUEUE_TIMEOUT_MS);
|
|
1176
|
+
this.capabilities = options.capabilities ?? exports.ALL_POWDB_CAPABILITIES;
|
|
1177
|
+
this.retryStaleReads = options.retryStaleReads ?? false;
|
|
1178
|
+
this.readonly = options.readonly ?? false;
|
|
1179
|
+
}
|
|
1180
|
+
/**
|
|
1181
|
+
* Run one statement on `c`, choosing the lossless native typed wire when the
|
|
1182
|
+
* server supports it (`capabilities.nativeRaw`) AND this client exposes
|
|
1183
|
+
* `queryNativeRaw` (a defensive per-call feature-detect, so a heterogeneous
|
|
1184
|
+
* injected pool cannot crash). Otherwise the legacy string wire, unchanged.
|
|
1185
|
+
*/
|
|
1186
|
+
async runOnClient(c, powql, params) {
|
|
1187
|
+
const bound = params.map(this.toParam);
|
|
1188
|
+
if (this.capabilities.nativeRaw && typeof c.queryNativeRaw === 'function') {
|
|
1189
|
+
return adaptNativeResult(await c.queryNativeRaw(powql, bound));
|
|
1190
|
+
}
|
|
1191
|
+
return adaptResult(await c.query(powql, bound));
|
|
789
1192
|
}
|
|
790
1193
|
// biome-ignore lint/suspicious/noExplicitAny: pg-compat query is generic over the row shape.
|
|
791
1194
|
async query(text, values) {
|
|
@@ -806,8 +1209,7 @@ class PowdbPool {
|
|
|
806
1209
|
return { rows: [], rowCount: 0, fields: [] };
|
|
807
1210
|
}
|
|
808
1211
|
try {
|
|
809
|
-
|
|
810
|
-
return adaptResult(result);
|
|
1212
|
+
return await this.pool.withClient((c) => this.runOnClient(c, powql, params));
|
|
811
1213
|
}
|
|
812
1214
|
catch (err) {
|
|
813
1215
|
if (ctl === 'begin') {
|
|
@@ -878,7 +1280,7 @@ class PowdbPool {
|
|
|
878
1280
|
return { rows: [], rowCount: 0, fields: [] };
|
|
879
1281
|
}
|
|
880
1282
|
try {
|
|
881
|
-
return
|
|
1283
|
+
return await this.runOnClient(client, powql, params);
|
|
882
1284
|
}
|
|
883
1285
|
catch (err) {
|
|
884
1286
|
broken = true;
|
|
@@ -1005,6 +1407,10 @@ function encodePowqlLiteral(value) {
|
|
|
1005
1407
|
// Force a float-form literal so an integer-valued float column stays a float.
|
|
1006
1408
|
return Number.isInteger(n) ? `${n}.0` : String(n);
|
|
1007
1409
|
}
|
|
1410
|
+
// json document: emit the canonical JSON text as a PowQL string literal (the
|
|
1411
|
+
// embedded engine validates and stores it as a json document).
|
|
1412
|
+
if (value instanceof PowdbJsonParam)
|
|
1413
|
+
return encodePowqlString(JSON.stringify(value.value));
|
|
1008
1414
|
if (value === undefined || value === null)
|
|
1009
1415
|
return 'null';
|
|
1010
1416
|
if (value instanceof Date)
|
|
@@ -1060,10 +1466,14 @@ function materializePowql(powql, params) {
|
|
|
1060
1466
|
}
|
|
1061
1467
|
/**
|
|
1062
1468
|
* A {@link PgCompatPool} backed by an in-process `@zvndev/powdb-embedded`
|
|
1063
|
-
* `Database`.
|
|
1064
|
-
*
|
|
1065
|
-
*
|
|
1066
|
-
*
|
|
1469
|
+
* `Database`. On the addon's typed native wire (≥ 0.14, when
|
|
1470
|
+
* `capabilities.nativeRaw` is set) this pool binds positional `$N` params via
|
|
1471
|
+
* `queryWithParams` and decodes the typed cells, exactly like the networked
|
|
1472
|
+
* transport. On an older addon (no `queryWithParams`) it falls back to the
|
|
1473
|
+
* legacy string wire, which takes **no params array** (its `query(powql)`
|
|
1474
|
+
* accepts only a string), so each positional `$N` is materialized into a PowQL
|
|
1475
|
+
* literal via {@link materializePowql} before the text is handed to the engine.
|
|
1476
|
+
* One handle, single connection: transaction keywords (`begin`/`commit`/
|
|
1067
1477
|
* `rollback`) are issued serially as ordinary queries.
|
|
1068
1478
|
*/
|
|
1069
1479
|
class PowdbEmbeddedPool {
|
|
@@ -1082,12 +1492,46 @@ class PowdbEmbeddedPool {
|
|
|
1082
1492
|
txGate;
|
|
1083
1493
|
/** Hold taken by a `begin` issued via `query()` directly (no checked-out client). */
|
|
1084
1494
|
poolHoldRef = { hold: null };
|
|
1495
|
+
/**
|
|
1496
|
+
* Feature capabilities of the embedded engine (resolved from the addon
|
|
1497
|
+
* package version). `nativeRaw` is true when the addon is ≥ 0.14 and the
|
|
1498
|
+
* opened handle exposes `queryWithParams` (the typed native wire); an older
|
|
1499
|
+
* addon has no such method, so it stays false and the legacy string wire is
|
|
1500
|
+
* used.
|
|
1501
|
+
*/
|
|
1502
|
+
capabilities;
|
|
1503
|
+
/** Carried for surface uniformity with {@link PowdbPool}; inert on embedded (no protocol_error frames). */
|
|
1504
|
+
retryStaleReads;
|
|
1505
|
+
/**
|
|
1506
|
+
* True when this pool was opened read-only (an `{ embedded, readonly: true }`
|
|
1507
|
+
* target, or a directly-constructed pool passed `readonly: true`). Read by
|
|
1508
|
+
* {@link PowqlInterface}'s exec seam to fail writes fast with E018 before the
|
|
1509
|
+
* wire; the engine's own refusal (mapped by {@link wrapPowdbError}) is the
|
|
1510
|
+
* backstop for raw / injected paths.
|
|
1511
|
+
*/
|
|
1512
|
+
readonly;
|
|
1085
1513
|
constructor(db, options = {}) {
|
|
1086
1514
|
this.db = db;
|
|
1087
1515
|
this.txGate = new PowdbTxGate(options.transactionQueueTimeoutMs ?? exports.DEFAULT_TX_QUEUE_TIMEOUT_MS);
|
|
1516
|
+
this.capabilities = options.capabilities ?? exports.ALL_POWDB_CAPABILITIES;
|
|
1517
|
+
this.retryStaleReads = options.retryStaleReads ?? false;
|
|
1518
|
+
this.readonly = options.readonly ?? false;
|
|
1088
1519
|
}
|
|
1089
|
-
/**
|
|
1520
|
+
/** Run the PowQL on the in-process engine, choosing the native or legacy wire. */
|
|
1090
1521
|
exec(powql, params) {
|
|
1522
|
+
// Native typed wire (addon ≥ 0.14): bind positional params with the SAME
|
|
1523
|
+
// binder the networked transport uses ({@link toPowdbParam} yields exactly
|
|
1524
|
+
// the NativeParam union null|bigint|number|boolean|string) and decode the
|
|
1525
|
+
// typed cells: a genuine str "null" survives, a json-null document stays
|
|
1526
|
+
// distinct from an absent value. Gated on the resolved capability AND a
|
|
1527
|
+
// per-call feature-detect so a heterogeneous injected handle cannot crash.
|
|
1528
|
+
if (this.capabilities.nativeRaw && typeof this.db.queryWithParams === 'function') {
|
|
1529
|
+
const bound = params.map((v) => toPowdbParam(v));
|
|
1530
|
+
return adaptNativeResult(this.db.queryWithParams(powql, bound));
|
|
1531
|
+
}
|
|
1532
|
+
// Legacy string wire (addon < 0.14): the engine takes no params array, so
|
|
1533
|
+
// materialize each `$N` into a PowQL literal. Byte-for-byte unchanged, kept
|
|
1534
|
+
// live and tested as the pre-0.14 fallback.
|
|
1091
1535
|
const materialized = materializePowql(powql, params);
|
|
1092
1536
|
return adaptResult(normalizeEmbeddedResult(this.db.query(materialized)));
|
|
1093
1537
|
}
|
|
@@ -1107,6 +1551,15 @@ class PowdbEmbeddedPool {
|
|
|
1107
1551
|
// transaction callback throws re-entrant E017 fast; independent
|
|
1108
1552
|
// concurrent ones wait their FIFO turn.
|
|
1109
1553
|
holdRef.hold = await this.txGate.acquire();
|
|
1554
|
+
// The gate may have handed us the slot AFTER disconnect() closed the
|
|
1555
|
+
// handle (a transaction queued behind an in-flight one, released as the
|
|
1556
|
+
// pool shut down). Re-check before touching the now-closed engine, and
|
|
1557
|
+
// release the slot we just took so the queue keeps draining.
|
|
1558
|
+
if (this.closed) {
|
|
1559
|
+
holdRef.hold.finish();
|
|
1560
|
+
holdRef.hold = null;
|
|
1561
|
+
throw new errors_js_1.ConnectionError('[turbine] The PowDB embedded pool is closed: disconnect() was already called on this client.');
|
|
1562
|
+
}
|
|
1110
1563
|
}
|
|
1111
1564
|
if ((ctl === 'commit' || ctl === 'rollback') && holdRef.hold === null) {
|
|
1112
1565
|
// This context never acquired the gate — its `begin` never ran (the
|
|
@@ -1182,19 +1635,24 @@ class PowdbEmbeddedPool {
|
|
|
1182
1635
|
async end() {
|
|
1183
1636
|
if (this.closed)
|
|
1184
1637
|
return;
|
|
1185
|
-
// The addon exposes no explicit close — drop the reference and let GC /
|
|
1186
|
-
// the engine's checkpoint flush. Marking the pool closed makes later
|
|
1187
|
-
// queries fail with a typed ConnectionError instead of silently running
|
|
1188
|
-
// against a handle the caller believes is gone. Caveat: durability is
|
|
1189
|
-
// checkpoint-bound, so hold the process open long enough for the final
|
|
1190
|
-
// WAL flush in short scripts.
|
|
1191
1638
|
this.closed = true;
|
|
1639
|
+
// Addon ≥ 0.14 exposes an explicit checkpoint-flushing close(): call it so
|
|
1640
|
+
// the final WAL flush completes deterministically before the handle is
|
|
1641
|
+
// dropped. An older addon has no close, dropping the reference and letting
|
|
1642
|
+
// GC / the engine's checkpoint flush is the fallback (durability is then
|
|
1643
|
+
// checkpoint-bound, so a short script must hold the process open long enough
|
|
1644
|
+
// for the final flush). Marking the pool closed makes later queries fail
|
|
1645
|
+
// with a typed ConnectionError instead of running against a gone handle.
|
|
1646
|
+
this.db.close?.();
|
|
1192
1647
|
}
|
|
1193
1648
|
}
|
|
1194
1649
|
exports.PowdbEmbeddedPool = PowdbEmbeddedPool;
|
|
1195
1650
|
// ---------------------------------------------------------------------------
|
|
1196
1651
|
// PowqlInterface — the PowQL query generator (Phase A: flat CRUD via returning)
|
|
1197
1652
|
// ---------------------------------------------------------------------------
|
|
1653
|
+
// `describe`-based introspection (programmatic API; see powdb-introspect.ts).
|
|
1654
|
+
var powdb_introspect_js_1 = require("./powdb-introspect.js");
|
|
1655
|
+
Object.defineProperty(exports, "introspectPowdbDatabase", { enumerable: true, get: function () { return powdb_introspect_js_1.introspectPowdbDatabase; } });
|
|
1198
1656
|
var powql_js_1 = require("./powql.js");
|
|
1199
1657
|
Object.defineProperty(exports, "PowqlInterface", { enumerable: true, get: function () { return powql_js_1.PowqlInterface; } });
|
|
1200
1658
|
/**
|
|
@@ -1242,13 +1700,47 @@ async function loadPowdbEmbedded() {
|
|
|
1242
1700
|
}
|
|
1243
1701
|
return mod;
|
|
1244
1702
|
}
|
|
1703
|
+
/**
|
|
1704
|
+
* Resolve the embedded addon's engine version. The addon vendors the engine and
|
|
1705
|
+
* exports no version, but `@zvndev/powdb-embedded/package.json` has no `exports`
|
|
1706
|
+
* map, so a bare `require` of it resolves: the package version IS the engine
|
|
1707
|
+
* version. Delegated to the `.cts` optional-peer helper so the resolution uses a
|
|
1708
|
+
* real CommonJS `require` in BOTH build outputs; `import.meta.url` here would
|
|
1709
|
+
* fail `tsc` under `tsconfig.cjs.json` (module: CommonJS) and crash any CJS
|
|
1710
|
+
* consumer of `turbine-orm/powdb`. Returns `null` when it cannot be resolved.
|
|
1711
|
+
*/
|
|
1712
|
+
function resolveEmbeddedVersion() {
|
|
1713
|
+
return optional_peer_import_cjs_1.default.peerPackageVersion('@zvndev/powdb-embedded');
|
|
1714
|
+
}
|
|
1245
1715
|
/** Open an embedded database handle, wrapping engine open failures (corrupt dir, etc.). */
|
|
1246
|
-
async function openEmbeddedPool(target, poolOptions = {}) {
|
|
1247
|
-
const mod = await loadPowdbEmbedded();
|
|
1248
|
-
const { embedded: dir, syncMode, memoryLimit } = target;
|
|
1716
|
+
async function openEmbeddedPool(target, poolOptions = {}, assumeEngineVersion, injectedModule) {
|
|
1717
|
+
const mod = injectedModule ?? (await loadPowdbEmbedded());
|
|
1718
|
+
const { embedded: dir, syncMode, memoryLimit, readonly } = target;
|
|
1719
|
+
// A read-only engine never writes, so a durability selector is meaningless
|
|
1720
|
+
// there, reject the combination loudly rather than silently ignoring one.
|
|
1721
|
+
if (readonly && syncMode !== undefined) {
|
|
1722
|
+
throw new errors_js_1.ValidationError('[turbine] embedded `syncMode` is meaningless with `readonly: true` (a read-only database never writes). Remove one.');
|
|
1723
|
+
}
|
|
1249
1724
|
let db;
|
|
1250
1725
|
try {
|
|
1251
|
-
if (
|
|
1726
|
+
if (readonly) {
|
|
1727
|
+
// Read-only snapshot serving (addon ≥ 0.14): route to the openReadOnly*
|
|
1728
|
+
// constructors; feature-detect and fail with a clear version hint if the
|
|
1729
|
+
// installed addon predates them.
|
|
1730
|
+
if (memoryLimit !== undefined) {
|
|
1731
|
+
if (typeof mod.Database.openReadOnlyWithMemoryLimit !== 'function') {
|
|
1732
|
+
throw new errors_js_1.ConnectionError('[turbine] embedded `readonly` + `memoryLimit` requires @zvndev/powdb-embedded >= 0.14 (openReadOnlyWithMemoryLimit).');
|
|
1733
|
+
}
|
|
1734
|
+
db = mod.Database.openReadOnlyWithMemoryLimit(dir, memoryLimit);
|
|
1735
|
+
}
|
|
1736
|
+
else {
|
|
1737
|
+
if (typeof mod.Database.openReadOnly !== 'function') {
|
|
1738
|
+
throw new errors_js_1.ConnectionError('[turbine] embedded `readonly: true` requires @zvndev/powdb-embedded >= 0.14 (the installed addon has no openReadOnly).');
|
|
1739
|
+
}
|
|
1740
|
+
db = mod.Database.openReadOnly(dir);
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
else if (memoryLimit !== undefined) {
|
|
1252
1744
|
if (typeof mod.Database.openWithMemoryLimit !== 'function') {
|
|
1253
1745
|
throw new errors_js_1.ConnectionError('[turbine] embedded `memoryLimit` requires @zvndev/powdb-embedded ≥ 0.7.1.');
|
|
1254
1746
|
}
|
|
@@ -1269,7 +1761,19 @@ async function openEmbeddedPool(target, poolOptions = {}) {
|
|
|
1269
1761
|
}
|
|
1270
1762
|
db.setSyncMode(syncMode);
|
|
1271
1763
|
}
|
|
1272
|
-
|
|
1764
|
+
// Native typed wire is feature-detected on the OPENED handle: an addon ≥ 0.14
|
|
1765
|
+
// exposes `queryWithParams`, so nativeRaw turns on (server-gate ≥ 0.13 still
|
|
1766
|
+
// applies via the version); an older addon has no such method → false.
|
|
1767
|
+
const capabilities = capabilitiesFromVersion(assumeEngineVersion ?? resolveEmbeddedVersion(), {
|
|
1768
|
+
hasNativeRaw: typeof db.queryWithParams === 'function',
|
|
1769
|
+
});
|
|
1770
|
+
// A read-only target forces the pool's readonly flag; otherwise honor whatever
|
|
1771
|
+
// `poolOptions` (threaded from `options.readonly`) carried.
|
|
1772
|
+
return new PowdbEmbeddedPool(db, {
|
|
1773
|
+
...poolOptions,
|
|
1774
|
+
capabilities,
|
|
1775
|
+
readonly: Boolean(readonly) || Boolean(poolOptions.readonly),
|
|
1776
|
+
});
|
|
1273
1777
|
}
|
|
1274
1778
|
/**
|
|
1275
1779
|
* Bind Turbine to PowDB. `target` is one of:
|
|
@@ -1292,30 +1796,39 @@ async function openEmbeddedPool(target, poolOptions = {}) {
|
|
|
1292
1796
|
async function turbinePowDB(target, schema, options = {}) {
|
|
1293
1797
|
let pool;
|
|
1294
1798
|
let owns = false;
|
|
1295
|
-
const poolOptions = {
|
|
1799
|
+
const poolOptions = {
|
|
1800
|
+
transactionQueueTimeoutMs: options.transactionQueueTimeoutMs,
|
|
1801
|
+
retryStaleReads: options.retryStaleReads,
|
|
1802
|
+
readonly: options.readonly,
|
|
1803
|
+
};
|
|
1804
|
+
const max = options.connectionLimit ?? 10;
|
|
1296
1805
|
if (typeof target === 'string') {
|
|
1297
1806
|
const mod = options.powdbClientModule ?? (await loadPowdb());
|
|
1298
|
-
const clientPool = new mod.Pool({ ...parsePowdbUrl(target), max
|
|
1299
|
-
await assertNetworkedVersion(clientPool);
|
|
1300
|
-
pool = new PowdbPool(clientPool, undefined, poolOptions);
|
|
1807
|
+
const clientPool = new mod.Pool({ ...parsePowdbUrl(target), max });
|
|
1808
|
+
const capabilities = await assertNetworkedVersion(clientPool, options.assumeEngineVersion);
|
|
1809
|
+
pool = new PowdbPool(clientPool, undefined, { ...poolOptions, capabilities });
|
|
1301
1810
|
owns = true;
|
|
1302
1811
|
}
|
|
1303
1812
|
else if (target instanceof PowdbPool) {
|
|
1304
|
-
// An injected PowdbPool carries its own PowdbPoolOptions.
|
|
1813
|
+
// An injected PowdbPool carries its own PowdbPoolOptions (incl. capabilities).
|
|
1305
1814
|
pool = target;
|
|
1306
1815
|
}
|
|
1307
1816
|
else if (isEmbeddedTarget(target)) {
|
|
1308
|
-
pool = await openEmbeddedPool(target, poolOptions);
|
|
1817
|
+
pool = await openEmbeddedPool(target, poolOptions, options.assumeEngineVersion, options.powdbEmbeddedModule);
|
|
1309
1818
|
owns = true;
|
|
1310
1819
|
}
|
|
1311
1820
|
else if (isPowdbClientPool(target)) {
|
|
1312
|
-
pool
|
|
1821
|
+
// Injected client pool: run the SAME probe as the URL / host+port paths so
|
|
1822
|
+
// it gets real capabilities AND the version-floor check (this branch used
|
|
1823
|
+
// to skip the probe entirely (an injected pool silently bypassed both).
|
|
1824
|
+
const capabilities = await assertNetworkedVersion(target, options.assumeEngineVersion);
|
|
1825
|
+
pool = new PowdbPool(target, undefined, { ...poolOptions, capabilities });
|
|
1313
1826
|
}
|
|
1314
1827
|
else {
|
|
1315
1828
|
const mod = options.powdbClientModule ?? (await loadPowdb());
|
|
1316
|
-
const clientPool = new mod.Pool({ ...target, max
|
|
1317
|
-
await assertNetworkedVersion(clientPool);
|
|
1318
|
-
pool = new PowdbPool(clientPool, undefined, poolOptions);
|
|
1829
|
+
const clientPool = new mod.Pool({ ...target, max });
|
|
1830
|
+
const capabilities = await assertNetworkedVersion(clientPool, options.assumeEngineVersion);
|
|
1831
|
+
pool = new PowdbPool(clientPool, undefined, { ...poolOptions, capabilities });
|
|
1319
1832
|
owns = true;
|
|
1320
1833
|
}
|
|
1321
1834
|
// The PowQL generator is loaded here to keep client.ts free of any PowDB import.
|
|
@@ -1328,6 +1841,7 @@ async function turbinePowDB(target, schema, options = {}) {
|
|
|
1328
1841
|
logging: options.logging,
|
|
1329
1842
|
defaultLimit: options.defaultLimit,
|
|
1330
1843
|
warnOnUnlimited: options.warnOnUnlimited,
|
|
1844
|
+
relationLoadStrategy: options.relationLoadStrategy,
|
|
1331
1845
|
queryInterfaceFactory,
|
|
1332
1846
|
}, schema);
|
|
1333
1847
|
if (owns) {
|
|
@@ -1353,14 +1867,20 @@ async function turbinePowDB(target, schema, options = {}) {
|
|
|
1353
1867
|
return client;
|
|
1354
1868
|
}
|
|
1355
1869
|
/**
|
|
1356
|
-
* Probe a networked pool's `serverVersion` (declared on {@link PowdbClient})
|
|
1357
|
-
* fail fast if the server is older than {@link MIN_POWDB_VERSION}
|
|
1358
|
-
*
|
|
1359
|
-
*
|
|
1870
|
+
* Probe a networked pool's `serverVersion` (declared on {@link PowdbClient}),
|
|
1871
|
+
* fail fast if the server is older than {@link MIN_POWDB_VERSION}, and derive
|
|
1872
|
+
* the {@link PowdbCapabilities} for it: the version gates PLUS `nativeRaw`
|
|
1873
|
+
* (server ≥ 0.13 AND the client exposes `queryNativeRaw`, feature-detected
|
|
1874
|
+
* here). `assumeEngineVersion` overrides the version used for capability
|
|
1875
|
+
* derivation (the floor check still runs against the real reported version).
|
|
1876
|
+
* Errors from the probe itself surface as the normal connect failure.
|
|
1360
1877
|
*/
|
|
1361
|
-
async function assertNetworkedVersion(clientPool) {
|
|
1362
|
-
|
|
1878
|
+
async function assertNetworkedVersion(clientPool, assumeEngineVersion) {
|
|
1879
|
+
return clientPool.withClient(async (c) => {
|
|
1363
1880
|
assertSupportedPowdbVersion(c.serverVersion);
|
|
1881
|
+
const version = assumeEngineVersion ?? c.serverVersion;
|
|
1882
|
+
const hasNativeRaw = typeof c.queryNativeRaw === 'function';
|
|
1883
|
+
return capabilitiesFromVersion(version, { hasNativeRaw });
|
|
1364
1884
|
});
|
|
1365
1885
|
}
|
|
1366
1886
|
function isPowdbClientPool(x) {
|