turbine-orm 0.34.0 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +18 -16
  2. package/dist/cjs/cli/index.js +109 -16
  3. package/dist/cjs/cli/migrate.js +78 -3
  4. package/dist/cjs/cli/studio-ui.generated.js +1 -1
  5. package/dist/cjs/cli/studio.js +333 -22
  6. package/dist/cjs/cli/ui.js +7 -1
  7. package/dist/cjs/client.js +26 -4
  8. package/dist/cjs/dialect.js +2 -1
  9. package/dist/cjs/errors.js +41 -1
  10. package/dist/cjs/generate.js +23 -2
  11. package/dist/cjs/index.js +4 -2
  12. package/dist/cjs/mssql.js +27 -5
  13. package/dist/cjs/mysql.js +4 -0
  14. package/dist/cjs/powdb.js +197 -25
  15. package/dist/cjs/powql.js +515 -51
  16. package/dist/cjs/query/aggregates.js +683 -0
  17. package/dist/cjs/query/batched-loader.js +2 -0
  18. package/dist/cjs/query/builder.js +361 -4508
  19. package/dist/cjs/query/filters.js +12 -0
  20. package/dist/cjs/query/relations.js +1698 -0
  21. package/dist/cjs/query/where-compile.js +180 -0
  22. package/dist/cjs/query/where.js +1491 -0
  23. package/dist/cjs/query/writes.js +680 -0
  24. package/dist/cjs/schema-builder.js +6 -0
  25. package/dist/cjs/schema-metadata.js +4 -0
  26. package/dist/cjs/schema-sql.js +265 -3
  27. package/dist/cjs/sqlite.js +4 -1
  28. package/dist/cli/index.d.ts +8 -2
  29. package/dist/cli/index.js +111 -18
  30. package/dist/cli/migrate.d.ts +24 -1
  31. package/dist/cli/migrate.js +77 -3
  32. package/dist/cli/studio-ui.generated.js +1 -1
  33. package/dist/cli/studio.d.ts +46 -13
  34. package/dist/cli/studio.js +331 -23
  35. package/dist/cli/ui.js +7 -1
  36. package/dist/client.d.ts +32 -5
  37. package/dist/client.js +26 -4
  38. package/dist/dialect.d.ts +28 -6
  39. package/dist/dialect.js +2 -1
  40. package/dist/errors.d.ts +36 -0
  41. package/dist/errors.js +39 -0
  42. package/dist/generate.js +23 -2
  43. package/dist/index.d.ts +3 -3
  44. package/dist/index.js +2 -2
  45. package/dist/mssql.js +27 -5
  46. package/dist/mysql.js +4 -0
  47. package/dist/powdb.d.ts +135 -9
  48. package/dist/powdb.js +197 -25
  49. package/dist/powql.d.ts +166 -4
  50. package/dist/powql.js +516 -52
  51. package/dist/query/aggregates.d.ts +74 -0
  52. package/dist/query/aggregates.js +641 -0
  53. package/dist/query/batched-loader.d.ts +6 -0
  54. package/dist/query/batched-loader.js +2 -0
  55. package/dist/query/builder.d.ts +98 -830
  56. package/dist/query/builder.js +366 -4513
  57. package/dist/query/deferred.d.ts +13 -2
  58. package/dist/query/filters.d.ts +7 -0
  59. package/dist/query/filters.js +11 -0
  60. package/dist/query/relations.d.ts +441 -0
  61. package/dist/query/relations.js +1627 -0
  62. package/dist/query/types.d.ts +25 -6
  63. package/dist/query/where-compile.d.ts +139 -0
  64. package/dist/query/where-compile.js +175 -0
  65. package/dist/query/where.d.ts +494 -0
  66. package/dist/query/where.js +1431 -0
  67. package/dist/query/writes.d.ts +131 -0
  68. package/dist/query/writes.js +626 -0
  69. package/dist/schema-builder.d.ts +18 -3
  70. package/dist/schema-builder.js +6 -0
  71. package/dist/schema-metadata.js +4 -0
  72. package/dist/schema-sql.d.ts +60 -3
  73. package/dist/schema-sql.js +261 -4
  74. package/dist/schema.d.ts +10 -0
  75. package/dist/sqlite.js +4 -1
  76. package/package.json +4 -4
@@ -168,8 +168,13 @@ function generateTypes(schema, options) {
168
168
  for (const col of table.columns) {
169
169
  const pkNote = table.primaryKey.includes(col.name) ? ' (primary key)' : '';
170
170
  const nullNote = col.nullable ? ' (nullable)' : '';
171
- lines.push(` /** Column: ${col.name} ${col.pgType}${pkNote}${nullNote} */`);
172
- lines.push(` ${col.field}: ${columnTsType(col, schema.enums)};`);
171
+ // PII columns are excluded from default projections, so the field is
172
+ // absent unless the query names it in `select` or passes `includePii`.
173
+ // The emitted type marks it optional so it tells the truth about absence.
174
+ const piiNote = col.pii ? ' (PII: absent unless selected or includePii)' : '';
175
+ const optional = col.pii ? '?' : '';
176
+ lines.push(` /** Column: ${col.name}, ${col.pgType}${pkNote}${nullNote}${piiNote} */`);
177
+ lines.push(` ${col.field}${optional}: ${columnTsType(col, schema.enums)};`);
173
178
  }
174
179
  lines.push('}');
175
180
  lines.push('');
@@ -531,6 +536,17 @@ function generateMetadata(schema, options) {
531
536
  lines.push(` { name: '${escSQ(idx.name)}', columns: [${idx.columns.map((c) => `'${escSQ(c)}'`).join(', ')}], unique: ${idx.unique}, definition: ${JSON.stringify(idx.definition)} },`);
532
537
  }
533
538
  lines.push(' ],');
539
+ // checks: introspected named CHECK constraints. Emitted only when present
540
+ // (byte-stable for check-less tables) and sorted by name so `--no-timestamp`
541
+ // output is deterministic regardless of catalog row order.
542
+ if (table.checks && table.checks.length > 0) {
543
+ const sortedChecks = [...table.checks].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
544
+ lines.push(' checks: [');
545
+ for (const chk of sortedChecks) {
546
+ lines.push(` { name: '${escSQ(chk.name)}', expression: ${JSON.stringify(chk.expression)} },`);
547
+ }
548
+ lines.push(' ],');
549
+ }
534
550
  // isView — read-only marker; the runtime write guard reads it.
535
551
  if (table.isView)
536
552
  lines.push(' isView: true,');
@@ -734,6 +750,11 @@ function serializeColumn(col) {
734
750
  if (col.generationExpression !== undefined) {
735
751
  parts.push(`generationExpression: '${escSQ(col.generationExpression)}'`);
736
752
  }
753
+ // PII marker: emitted only when set, so untagged schemas stay byte-identical.
754
+ // Introspection never sets this (code-first declaration), but a metadata
755
+ // object built from `defineSchema` (pii: true) carries it through codegen.
756
+ if (col.pii)
757
+ parts.push(`pii: true`);
737
758
  if (col.maxLength !== undefined)
738
759
  parts.push(`maxLength: ${col.maxLength}`);
739
760
  return `{ ${parts.join(', ')} }`;
package/dist/cjs/index.js CHANGED
@@ -34,8 +34,8 @@
34
34
  * ```
35
35
  */
36
36
  Object.defineProperty(exports, "__esModule", { value: true });
37
- exports.ColumnBuilder = exports.applyManyToManyRelations = exports.snakeToPascal = exports.snakeToCamel = exports.singularize = exports.pgTypeToTs = exports.pgArrayType = exports.normalizeKeyColumns = exports.isDateType = exports.camelToSnake = exports.validateChannel = exports.QueryInterface = exports.pipelineSupported = exports.executePipeline = exports.hasRelationFields = exports.executeNestedUpdate = exports.executeNestedCreate = exports.introspect = exports.generate = exports.wrapPgError = exports.ValidationError = exports.UnsupportedFeatureError = exports.UniqueConstraintError = exports.TurbineErrorCode = exports.TurbineError = exports.TimeoutError = exports.setErrorMessageMode = exports.SerializationFailureError = exports.RelationError = exports.PipelineError = exports.OptimisticLockError = exports.NotNullViolationError = exports.NotFoundError = exports.MigrationError = exports.getErrorMessageMode = exports.ForeignKeyError = exports.ExclusionConstraintError = exports.DeadlockError = exports.ConnectionError = exports.CircularRelationError = exports.CheckConstraintError = exports.postgresDialect = exports.withRetry = exports.TurbineClient = exports.TransactionClient = exports.yugabytedb = exports.timescale = exports.postgresql = exports.cockroachdb = exports.alloydb = void 0;
38
- exports.TypedSqlQuery = exports.buildTypedSql = exports.turbineHttp = exports.defineSeed = exports.schemaToSQLString = exports.schemaToSQL = exports.schemaPush = exports.schemaDiff = exports.schemaDefToMetadata = exports.table = exports.isDocFieldIndexDef = exports.defineSchema = exports.column = void 0;
37
+ exports.applyManyToManyRelations = exports.snakeToPascal = exports.snakeToCamel = exports.singularize = exports.pgTypeToTs = exports.pgArrayType = exports.normalizeKeyColumns = exports.isDateType = exports.camelToSnake = exports.validateChannel = exports.QueryInterface = exports.pipelineSupported = exports.executePipeline = exports.hasRelationFields = exports.executeNestedUpdate = exports.executeNestedCreate = exports.introspect = exports.generate = exports.wrapPgError = exports.ValidationError = exports.UnsupportedFeatureError = exports.UniqueConstraintError = exports.TurbineErrorCode = exports.TurbineError = exports.TimeoutError = exports.setErrorMessageMode = exports.SerializationFailureError = exports.RelationError = exports.ReadOnlyError = exports.PipelineError = exports.OptimisticLockError = exports.NotNullViolationError = exports.NotFoundError = exports.MigrationError = exports.getErrorMessageMode = exports.ForeignKeyError = exports.ExclusionConstraintError = exports.DeadlockError = exports.ConnectionError = exports.CircularRelationError = exports.CheckConstraintError = exports.postgresDialect = exports.withRetry = exports.TurbineClient = exports.TransactionClient = exports.yugabytedb = exports.timescale = exports.postgresql = exports.cockroachdb = exports.alloydb = void 0;
38
+ exports.TypedSqlQuery = exports.buildTypedSql = exports.turbineHttp = exports.defineSeed = exports.schemaToSQLString = exports.schemaToSQL = exports.schemaPush = exports.schemaDiff = exports.DestructivePushRefusal = exports.schemaDefToMetadata = exports.table = exports.isDocFieldIndexDef = exports.defineSchema = exports.column = exports.ColumnBuilder = void 0;
39
39
  var index_js_1 = require("./adapters/index.js");
40
40
  Object.defineProperty(exports, "alloydb", { enumerable: true, get: function () { return index_js_1.alloydb; } });
41
41
  Object.defineProperty(exports, "cockroachdb", { enumerable: true, get: function () { return index_js_1.cockroachdb; } });
@@ -63,6 +63,7 @@ Object.defineProperty(exports, "NotFoundError", { enumerable: true, get: functio
63
63
  Object.defineProperty(exports, "NotNullViolationError", { enumerable: true, get: function () { return errors_js_1.NotNullViolationError; } });
64
64
  Object.defineProperty(exports, "OptimisticLockError", { enumerable: true, get: function () { return errors_js_1.OptimisticLockError; } });
65
65
  Object.defineProperty(exports, "PipelineError", { enumerable: true, get: function () { return errors_js_1.PipelineError; } });
66
+ Object.defineProperty(exports, "ReadOnlyError", { enumerable: true, get: function () { return errors_js_1.ReadOnlyError; } });
66
67
  Object.defineProperty(exports, "RelationError", { enumerable: true, get: function () { return errors_js_1.RelationError; } });
67
68
  Object.defineProperty(exports, "SerializationFailureError", { enumerable: true, get: function () { return errors_js_1.SerializationFailureError; } });
68
69
  Object.defineProperty(exports, "setErrorMessageMode", { enumerable: true, get: function () { return errors_js_1.setErrorMessageMode; } });
@@ -118,6 +119,7 @@ var schema_metadata_js_1 = require("./schema-metadata.js");
118
119
  Object.defineProperty(exports, "schemaDefToMetadata", { enumerable: true, get: function () { return schema_metadata_js_1.schemaDefToMetadata; } });
119
120
  // Schema SQL — generate DDL, diff, and push
120
121
  var schema_sql_js_1 = require("./schema-sql.js");
122
+ Object.defineProperty(exports, "DestructivePushRefusal", { enumerable: true, get: function () { return schema_sql_js_1.DestructivePushRefusal; } });
121
123
  Object.defineProperty(exports, "schemaDiff", { enumerable: true, get: function () { return schema_sql_js_1.schemaDiff; } });
122
124
  Object.defineProperty(exports, "schemaPush", { enumerable: true, get: function () { return schema_sql_js_1.schemaPush; } });
123
125
  Object.defineProperty(exports, "schemaToSQL", { enumerable: true, get: function () { return schema_sql_js_1.schemaToSQL; } });
package/dist/cjs/mssql.js CHANGED
@@ -459,6 +459,23 @@ function mssqlColumnType(type, maxLength) {
459
459
  // ---------------------------------------------------------------------------
460
460
  // mssqlDialect — the full Dialect contract for SQL Server 2016+
461
461
  // ---------------------------------------------------------------------------
462
+ /**
463
+ * Render the SQL Server `OUTPUT` clause for a write's returning selection.
464
+ * `'*'` → ` OUTPUT INSERTED.*` (byte-identical to the historical default); a
465
+ * quoted column list → ` OUTPUT INSERTED.[c1], INSERTED.[c2]`; each column
466
+ * carries its own `INSERTED.`/`DELETED.` prefix (a bare comma list is invalid
467
+ * T-SQL). Used to exclude PII columns from a write's returned row. Empty
468
+ * selection → no clause.
469
+ */
470
+ function mssqlOutput(returning, alias) {
471
+ if (!returning)
472
+ return '';
473
+ if (returning === '*')
474
+ return ` OUTPUT ${alias}.*`;
475
+ if (returning.length === 0)
476
+ return '';
477
+ return ` OUTPUT ${returning.map((col) => `${alias}.${col}`).join(', ')}`;
478
+ }
462
479
  /**
463
480
  * SQL Server 2016+ implementation of the {@link Dialect} contract. Bracket
464
481
  * identifier quoting (`[…]`), named `@pN` placeholders, the `FOR JSON PATH`
@@ -483,6 +500,11 @@ exports.mssqlDialect = {
483
500
  supportsLateralJoin: false,
484
501
  // sp_getapplock / sp_releaseapplock exist (used by a future migrate adapter).
485
502
  supportsAdvisoryLock: true,
503
+ // No in-band EXPLAIN: SQL Server's SHOWPLAN is a session toggle
504
+ // (SET SHOWPLAN_ALL ON), not a statement prefix, so a compiled query cannot
505
+ // be explained in one round-trip. Override the inherited Postgres `EXPLAIN`
506
+ // to absent → QueryInterface.explain() throws E017.
507
+ explainQuery: undefined,
486
508
  // FOR JSON over zero rows is NULL → coalesced in the relation override.
487
509
  aggSupportsInlineOrderBy: false,
488
510
  jsonPathSupport: 'limited',
@@ -517,7 +539,7 @@ exports.mssqlDialect = {
517
539
  return '';
518
540
  },
519
541
  buildInsertStatement(input) {
520
- const out = input.returning ? ` OUTPUT INSERTED.${input.returning}` : '';
542
+ const out = mssqlOutput(input.returning, 'INSERTED');
521
543
  return `INSERT INTO ${input.table} (${input.columns.join(', ')})${out} VALUES (${input.valuePlaceholders.join(', ')})`;
522
544
  },
523
545
  buildBulkInsertStatement(input) {
@@ -537,7 +559,7 @@ exports.mssqlDialect = {
537
559
  const placeholders = input.rowValues
538
560
  .map((row) => `(${row.map(() => this.paramPlaceholder(++n)).join(', ')})`)
539
561
  .join(', ');
540
- const out = input.returning ? ` OUTPUT INSERTED.${input.returning}` : '';
562
+ const out = mssqlOutput(input.returning, 'INSERTED');
541
563
  // skipDuplicates has no single-statement equivalent here; ignored (documented).
542
564
  return {
543
565
  sql: `INSERT INTO ${input.table} (${input.columns.join(', ')})${out} VALUES ${placeholders}`,
@@ -552,7 +574,7 @@ exports.mssqlDialect = {
552
574
  const on = input.conflictColumns.map((c) => `T.${c} = S.${c}`).join(' AND ');
553
575
  const insertCols = input.insertColumns.join(', ');
554
576
  const sourceVals = input.insertColumns.map((c) => `S.${c}`).join(', ');
555
- const out = input.returning ? ` OUTPUT INSERTED.${input.returning}` : '';
577
+ const out = mssqlOutput(input.returning, 'INSERTED');
556
578
  return (`MERGE INTO ${input.table} AS T ` +
557
579
  `USING (VALUES (${input.valuePlaceholders.join(', ')})) AS S (${insertCols}) ` +
558
580
  `ON (${on}) ` +
@@ -563,11 +585,11 @@ exports.mssqlDialect = {
563
585
  // UPDATE/DELETE inject OUTPUT mid-statement (between SET and WHERE / FROM and
564
586
  // WHERE) — a trailing clause would be invalid T-SQL.
565
587
  buildUpdateStatement(input) {
566
- const out = input.returning ? ` OUTPUT INSERTED.${input.returning}` : '';
588
+ const out = mssqlOutput(input.returning, 'INSERTED');
567
589
  return `UPDATE ${input.table} SET ${input.setClauses.join(', ')}${out}${input.whereSql}`;
568
590
  },
569
591
  buildDeleteStatement(input) {
570
- const out = input.returning ? ` OUTPUT DELETED.${input.returning}` : '';
592
+ const out = mssqlOutput(input.returning, 'DELETED');
571
593
  return `DELETE FROM ${input.table}${out}${input.whereSql}`;
572
594
  },
573
595
  // SQL Server has no LIMIT — emit OFFSET/FETCH, injecting a stable ORDER BY when
package/dist/cjs/mysql.js CHANGED
@@ -384,6 +384,10 @@ exports.mysqlDialect = {
384
384
  supportsLateralJoin: false,
385
385
  // GET_LOCK / RELEASE_LOCK exist (used by a future migrate adapter).
386
386
  supportsAdvisoryLock: true,
387
+ // Plain `EXPLAIN` (one row of tabular plan columns) works on every supported
388
+ // MySQL 8.0.x; the readable `FORMAT=TREE` variant only exists from 8.0.16 and
389
+ // the engine floor here is 8.0.0. Plan text is a diagnostic, not a contract.
390
+ explainQuery: { prefix: 'EXPLAIN' },
387
391
  // JSON_ARRAYAGG has no inline ORDER BY argument → force the inner-subquery
388
392
  // rewrite for every ordered to-many relation.
389
393
  aggSupportsInlineOrderBy: false,
package/dist/cjs/powdb.js CHANGED
@@ -90,7 +90,7 @@ 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.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;
93
+ exports.PowqlInterface = exports.introspectPowdbDatabase = exports.PowdbEmbeddedPool = exports.POWQL_LEXER_TESTED_CEILING = 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
96
  exports.capabilitiesFromVersion = capabilitiesFromVersion;
@@ -252,6 +252,7 @@ const POWDB_FEATURE_MIN_VERSION = {
252
252
  introspection: '0.10',
253
253
  jsonDocs: '0.12',
254
254
  docFieldIndexes: '0.13',
255
+ serverJoins: '0.13',
255
256
  };
256
257
  /**
257
258
  * Trusted-caller default: every FEATURE gate on, engine version unknown. Used
@@ -266,6 +267,7 @@ exports.ALL_POWDB_CAPABILITIES = {
266
267
  jsonDocs: true,
267
268
  docFieldIndexes: true,
268
269
  introspection: true,
270
+ serverJoins: true,
269
271
  nativeRaw: false,
270
272
  };
271
273
  /** Parse a PowDB semver prefix (`0.13.0`, `0.13`, `1.2.3-rc`) into components, or `null`. */
@@ -293,6 +295,7 @@ function capabilitiesFromVersion(version, opts = {}) {
293
295
  jsonDocs: false,
294
296
  docFieldIndexes: false,
295
297
  introspection: false,
298
+ serverJoins: false,
296
299
  nativeRaw: false,
297
300
  };
298
301
  }
@@ -301,6 +304,7 @@ function capabilitiesFromVersion(version, opts = {}) {
301
304
  introspection: atLeastVersion(sem, 0, 10),
302
305
  jsonDocs: atLeastVersion(sem, 0, 12),
303
306
  docFieldIndexes: atLeastVersion(sem, 0, 13),
307
+ serverJoins: atLeastVersion(sem, 0, 13),
304
308
  nativeRaw: Boolean(opts.hasNativeRaw) && atLeastVersion(sem, 0, 13),
305
309
  };
306
310
  }
@@ -745,9 +749,10 @@ function wrapPowdbError(err) {
745
749
  const m = /column ['"]?(\w+)['"]?/i.exec(msg);
746
750
  return new errors_js_1.NotNullViolationError({ column: m?.[1], cause: err });
747
751
  }
748
- // Driver pool lifecycle errors (acquire after close, acquire timeout) carry
749
- // no .code classify by message so both transports surface E004.
750
- if (/pool closed|pool acquire timeout/i.test(msg)) {
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)) {
751
756
  return new errors_js_1.ConnectionError(`[turbine] PowDB connection unavailable: ${msg}`, { cause: err });
752
757
  }
753
758
  // Server-side transaction-gate wait bound (PowDB ≥ 0.10, default 5s): another
@@ -769,6 +774,54 @@ function wrapPowdbError(err) {
769
774
  /received unexpected frame|unknown message type|truncated payload|bad framing/i.test(msg)) {
770
775
  return new errors_js_1.ConnectionError(`[turbine] PowDB connection is in an invalid state: ${msg}`, { cause: err });
771
776
  }
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
+ }
772
825
  // Type mismatch / parse / execution / storage / unexpected(token) / row too
773
826
  // large → validation (E003). On the embedded transport these are the only
774
827
  // signal we get (code is always 'GenericFailure'); on the networked path they
@@ -1109,12 +1162,20 @@ class PowdbPool {
1109
1162
  capabilities;
1110
1163
  /** Opt-in first-statement-read replay on a stale wire frame (read by {@link PowqlInterface}). */
1111
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;
1112
1172
  constructor(pool, toParam = (v) => toPowdbParam(v), options = {}) {
1113
1173
  this.pool = pool;
1114
1174
  this.toParam = toParam;
1115
1175
  this.txGate = new PowdbTxGate(options.transactionQueueTimeoutMs ?? exports.DEFAULT_TX_QUEUE_TIMEOUT_MS);
1116
1176
  this.capabilities = options.capabilities ?? exports.ALL_POWDB_CAPABILITIES;
1117
1177
  this.retryStaleReads = options.retryStaleReads ?? false;
1178
+ this.readonly = options.readonly ?? false;
1118
1179
  }
1119
1180
  /**
1120
1181
  * Run one statement on `c`, choosing the lossless native typed wire when the
@@ -1369,6 +1430,26 @@ function encodePowqlLiteral(value) {
1369
1430
  return encodePowqlString(value);
1370
1431
  throw new errors_js_1.ValidationError(`[turbine] Value of type ${typeof value} cannot be encoded as a PowDB literal.`);
1371
1432
  }
1433
+ /**
1434
+ * The newest PowDB engine LINE (major.minor) whose lexer escape handling
1435
+ * {@link encodePowqlString} is VERIFIED against by reading
1436
+ * `crates/query/src/lexer.rs`. The legacy materialize path
1437
+ * ({@link materializePowql}) inlines encoded string literals directly into query
1438
+ * text, so it is only injection-safe while the lexer recognizes exactly the
1439
+ * escape set the escaper emits (`\"`, `\\`, `\n`, `\t`, everything else raw). If
1440
+ * a future engine line teaches the lexer new escapes (e.g. `\u`, `\x`), a string
1441
+ * that the escaper leaves raw could be re-interpreted by the lexer and break out
1442
+ * of the literal, turning the fallback into an injection primitive.
1443
+ *
1444
+ * CONTRACT: bump this ceiling ONLY after re-verifying the escape handling in
1445
+ * `crates/query/src/lexer.rs` for the newer line AND confirming
1446
+ * {@link encodePowqlString} still escapes every breakout vector the lexer
1447
+ * recognizes. The legacy path guards on this value (see
1448
+ * {@link PowdbEmbeddedPool.exec}): an embedded addon whose engine line exceeds
1449
+ * the ceiling yet still routes through the string wire is refused rather than
1450
+ * materialized.
1451
+ */
1452
+ exports.POWQL_LEXER_TESTED_CEILING = '0.15';
1372
1453
  /** Escape a string into a PowQL `"…"` literal, matching the engine lexer's escape rules. */
1373
1454
  function encodePowqlString(s) {
1374
1455
  let out = '"';
@@ -1405,10 +1486,14 @@ function materializePowql(powql, params) {
1405
1486
  }
1406
1487
  /**
1407
1488
  * A {@link PgCompatPool} backed by an in-process `@zvndev/powdb-embedded`
1408
- * `Database`. The embedded addon takes **no params array** its `query(powql)`
1409
- * accepts only a string — so this pool materializes each positional `$N` into a
1410
- * PowQL literal via {@link materializePowql} before handing the text to the
1411
- * engine. One handle, single connection: transaction keywords (`begin`/`commit`/
1489
+ * `Database`. On the addon's typed native wire (≥ 0.14, when
1490
+ * `capabilities.nativeRaw` is set) this pool binds positional `$N` params via
1491
+ * `queryWithParams` and decodes the typed cells, exactly like the networked
1492
+ * transport. On an older addon (no `queryWithParams`) it falls back to the
1493
+ * legacy string wire, which takes **no params array** (its `query(powql)`
1494
+ * accepts only a string), so each positional `$N` is materialized into a PowQL
1495
+ * literal via {@link materializePowql} before the text is handed to the engine.
1496
+ * One handle, single connection: transaction keywords (`begin`/`commit`/
1412
1497
  * `rollback`) are issued serially as ordinary queries.
1413
1498
  */
1414
1499
  class PowdbEmbeddedPool {
@@ -1429,20 +1514,64 @@ class PowdbEmbeddedPool {
1429
1514
  poolHoldRef = { hold: null };
1430
1515
  /**
1431
1516
  * Feature capabilities of the embedded engine (resolved from the addon
1432
- * package version). `nativeRaw` is always false: the embedded addon exposes
1433
- * no native typed-wire surface (its rows are `string[][]`, the legacy wire).
1517
+ * package version). `nativeRaw` is true when the addon is ≥ 0.14 and the
1518
+ * opened handle exposes `queryWithParams` (the typed native wire); an older
1519
+ * addon has no such method, so it stays false and the legacy string wire is
1520
+ * used.
1434
1521
  */
1435
1522
  capabilities;
1436
1523
  /** Carried for surface uniformity with {@link PowdbPool}; inert on embedded (no protocol_error frames). */
1437
1524
  retryStaleReads;
1525
+ /**
1526
+ * True when this pool was opened read-only (an `{ embedded, readonly: true }`
1527
+ * target, or a directly-constructed pool passed `readonly: true`). Read by
1528
+ * {@link PowqlInterface}'s exec seam to fail writes fast with E018 before the
1529
+ * wire; the engine's own refusal (mapped by {@link wrapPowdbError}) is the
1530
+ * backstop for raw / injected paths.
1531
+ */
1532
+ readonly;
1438
1533
  constructor(db, options = {}) {
1439
1534
  this.db = db;
1440
1535
  this.txGate = new PowdbTxGate(options.transactionQueueTimeoutMs ?? exports.DEFAULT_TX_QUEUE_TIMEOUT_MS);
1441
1536
  this.capabilities = options.capabilities ?? exports.ALL_POWDB_CAPABILITIES;
1442
1537
  this.retryStaleReads = options.retryStaleReads ?? false;
1538
+ this.readonly = options.readonly ?? false;
1443
1539
  }
1444
- /** Materialize `$N` params and hand the PowQL to the in-process engine. */
1540
+ /** Run the PowQL on the in-process engine, choosing the native or legacy wire. */
1445
1541
  exec(powql, params) {
1542
+ // Native typed wire (addon ≥ 0.14): bind positional params with the SAME
1543
+ // binder the networked transport uses ({@link toPowdbParam} yields exactly
1544
+ // the NativeParam union null|bigint|number|boolean|string) and decode the
1545
+ // typed cells: a genuine str "null" survives, a json-null document stays
1546
+ // distinct from an absent value. Gated on the resolved capability AND a
1547
+ // per-call feature-detect so a heterogeneous injected handle cannot crash.
1548
+ if (this.capabilities.nativeRaw && typeof this.db.queryWithParams === 'function') {
1549
+ const bound = params.map((v) => toPowdbParam(v));
1550
+ return adaptNativeResult(this.db.queryWithParams(powql, bound));
1551
+ }
1552
+ // Legacy string wire (addon < 0.14): the engine takes no params array, so
1553
+ // materialize each `$N` into a PowQL literal. Byte-for-byte unchanged, kept
1554
+ // live and tested as the pre-0.14 fallback.
1555
+ //
1556
+ // Safety assertion (see {@link POWQL_LEXER_TESTED_CEILING}): an addon whose
1557
+ // engine line EXCEEDS the escaper's verified lexer ceiling must never be
1558
+ // materialized: a newer lexer may recognize escapes the escaper leaves raw,
1559
+ // making the inline path an injection primitive. Reachability: addons >= 0.14
1560
+ // expose `queryWithParams` and take the native path above, so the ONLY way to
1561
+ // reach this branch with a newer-than-ceiling engine is the anomalous
1562
+ // newer-addon-without-native case (feature-detect failed). Refuse it rather
1563
+ // than inline-encode against an unverified lexer.
1564
+ const engineSem = parsePowdbSemver(this.capabilities.engineVersion);
1565
+ const ceiling = parsePowdbSemver(exports.POWQL_LEXER_TESTED_CEILING);
1566
+ if (engineSem &&
1567
+ ceiling &&
1568
+ (engineSem.major > ceiling.major || (engineSem.major === ceiling.major && engineSem.minor > ceiling.minor))) {
1569
+ throw new errors_js_1.ValidationError(`[turbine] Refusing the PowDB legacy string wire: this embedded addon reports engine ` +
1570
+ `${this.capabilities.engineVersion}, which is newer than the escaper's verified lexer range ` +
1571
+ `(<= ${exports.POWQL_LEXER_TESTED_CEILING}) AND such an addon exposes the parameterized native API, so ` +
1572
+ `reaching the legacy materialize path means the queryWithParams feature-detect failed. Upgrade ` +
1573
+ `turbine-orm so its PowQL escaper is verified against this engine line before running on it.`);
1574
+ }
1446
1575
  const materialized = materializePowql(powql, params);
1447
1576
  return adaptResult(normalizeEmbeddedResult(this.db.query(materialized)));
1448
1577
  }
@@ -1462,6 +1591,15 @@ class PowdbEmbeddedPool {
1462
1591
  // transaction callback throws re-entrant E017 fast; independent
1463
1592
  // concurrent ones wait their FIFO turn.
1464
1593
  holdRef.hold = await this.txGate.acquire();
1594
+ // The gate may have handed us the slot AFTER disconnect() closed the
1595
+ // handle (a transaction queued behind an in-flight one, released as the
1596
+ // pool shut down). Re-check before touching the now-closed engine, and
1597
+ // release the slot we just took so the queue keeps draining.
1598
+ if (this.closed) {
1599
+ holdRef.hold.finish();
1600
+ holdRef.hold = null;
1601
+ throw new errors_js_1.ConnectionError('[turbine] The PowDB embedded pool is closed: disconnect() was already called on this client.');
1602
+ }
1465
1603
  }
1466
1604
  if ((ctl === 'commit' || ctl === 'rollback') && holdRef.hold === null) {
1467
1605
  // This context never acquired the gate — its `begin` never ran (the
@@ -1537,13 +1675,15 @@ class PowdbEmbeddedPool {
1537
1675
  async end() {
1538
1676
  if (this.closed)
1539
1677
  return;
1540
- // The addon exposes no explicit close — drop the reference and let GC /
1541
- // the engine's checkpoint flush. Marking the pool closed makes later
1542
- // queries fail with a typed ConnectionError instead of silently running
1543
- // against a handle the caller believes is gone. Caveat: durability is
1544
- // checkpoint-bound, so hold the process open long enough for the final
1545
- // WAL flush in short scripts.
1546
1678
  this.closed = true;
1679
+ // Addon ≥ 0.14 exposes an explicit checkpoint-flushing close(): call it so
1680
+ // the final WAL flush completes deterministically before the handle is
1681
+ // dropped. An older addon has no close, dropping the reference and letting
1682
+ // GC / the engine's checkpoint flush is the fallback (durability is then
1683
+ // checkpoint-bound, so a short script must hold the process open long enough
1684
+ // for the final flush). Marking the pool closed makes later queries fail
1685
+ // with a typed ConnectionError instead of running against a gone handle.
1686
+ this.db.close?.();
1547
1687
  }
1548
1688
  }
1549
1689
  exports.PowdbEmbeddedPool = PowdbEmbeddedPool;
@@ -1613,12 +1753,34 @@ function resolveEmbeddedVersion() {
1613
1753
  return optional_peer_import_cjs_1.default.peerPackageVersion('@zvndev/powdb-embedded');
1614
1754
  }
1615
1755
  /** Open an embedded database handle, wrapping engine open failures (corrupt dir, etc.). */
1616
- async function openEmbeddedPool(target, poolOptions = {}, assumeEngineVersion) {
1617
- const mod = await loadPowdbEmbedded();
1618
- const { embedded: dir, syncMode, memoryLimit } = target;
1756
+ async function openEmbeddedPool(target, poolOptions = {}, assumeEngineVersion, injectedModule) {
1757
+ const mod = injectedModule ?? (await loadPowdbEmbedded());
1758
+ const { embedded: dir, syncMode, memoryLimit, readonly } = target;
1759
+ // A read-only engine never writes, so a durability selector is meaningless
1760
+ // there, reject the combination loudly rather than silently ignoring one.
1761
+ if (readonly && syncMode !== undefined) {
1762
+ throw new errors_js_1.ValidationError('[turbine] embedded `syncMode` is meaningless with `readonly: true` (a read-only database never writes). Remove one.');
1763
+ }
1619
1764
  let db;
1620
1765
  try {
1621
- if (memoryLimit !== undefined) {
1766
+ if (readonly) {
1767
+ // Read-only snapshot serving (addon ≥ 0.14): route to the openReadOnly*
1768
+ // constructors; feature-detect and fail with a clear version hint if the
1769
+ // installed addon predates them.
1770
+ if (memoryLimit !== undefined) {
1771
+ if (typeof mod.Database.openReadOnlyWithMemoryLimit !== 'function') {
1772
+ throw new errors_js_1.ConnectionError('[turbine] embedded `readonly` + `memoryLimit` requires @zvndev/powdb-embedded >= 0.14 (openReadOnlyWithMemoryLimit).');
1773
+ }
1774
+ db = mod.Database.openReadOnlyWithMemoryLimit(dir, memoryLimit);
1775
+ }
1776
+ else {
1777
+ if (typeof mod.Database.openReadOnly !== 'function') {
1778
+ throw new errors_js_1.ConnectionError('[turbine] embedded `readonly: true` requires @zvndev/powdb-embedded >= 0.14 (the installed addon has no openReadOnly).');
1779
+ }
1780
+ db = mod.Database.openReadOnly(dir);
1781
+ }
1782
+ }
1783
+ else if (memoryLimit !== undefined) {
1622
1784
  if (typeof mod.Database.openWithMemoryLimit !== 'function') {
1623
1785
  throw new errors_js_1.ConnectionError('[turbine] embedded `memoryLimit` requires @zvndev/powdb-embedded ≥ 0.7.1.');
1624
1786
  }
@@ -1639,11 +1801,19 @@ async function openEmbeddedPool(target, poolOptions = {}, assumeEngineVersion) {
1639
1801
  }
1640
1802
  db.setSyncMode(syncMode);
1641
1803
  }
1642
- // Embedded exposes no native typed-wire surface, so nativeRaw is always false.
1804
+ // Native typed wire is feature-detected on the OPENED handle: an addon ≥ 0.14
1805
+ // exposes `queryWithParams`, so nativeRaw turns on (server-gate ≥ 0.13 still
1806
+ // applies via the version); an older addon has no such method → false.
1643
1807
  const capabilities = capabilitiesFromVersion(assumeEngineVersion ?? resolveEmbeddedVersion(), {
1644
- hasNativeRaw: false,
1808
+ hasNativeRaw: typeof db.queryWithParams === 'function',
1809
+ });
1810
+ // A read-only target forces the pool's readonly flag; otherwise honor whatever
1811
+ // `poolOptions` (threaded from `options.readonly`) carried.
1812
+ return new PowdbEmbeddedPool(db, {
1813
+ ...poolOptions,
1814
+ capabilities,
1815
+ readonly: Boolean(readonly) || Boolean(poolOptions.readonly),
1645
1816
  });
1646
- return new PowdbEmbeddedPool(db, { ...poolOptions, capabilities });
1647
1817
  }
1648
1818
  /**
1649
1819
  * Bind Turbine to PowDB. `target` is one of:
@@ -1669,6 +1839,7 @@ async function turbinePowDB(target, schema, options = {}) {
1669
1839
  const poolOptions = {
1670
1840
  transactionQueueTimeoutMs: options.transactionQueueTimeoutMs,
1671
1841
  retryStaleReads: options.retryStaleReads,
1842
+ readonly: options.readonly,
1672
1843
  };
1673
1844
  const max = options.connectionLimit ?? 10;
1674
1845
  if (typeof target === 'string') {
@@ -1683,7 +1854,7 @@ async function turbinePowDB(target, schema, options = {}) {
1683
1854
  pool = target;
1684
1855
  }
1685
1856
  else if (isEmbeddedTarget(target)) {
1686
- pool = await openEmbeddedPool(target, poolOptions, options.assumeEngineVersion);
1857
+ pool = await openEmbeddedPool(target, poolOptions, options.assumeEngineVersion, options.powdbEmbeddedModule);
1687
1858
  owns = true;
1688
1859
  }
1689
1860
  else if (isPowdbClientPool(target)) {
@@ -1710,6 +1881,7 @@ async function turbinePowDB(target, schema, options = {}) {
1710
1881
  logging: options.logging,
1711
1882
  defaultLimit: options.defaultLimit,
1712
1883
  warnOnUnlimited: options.warnOnUnlimited,
1884
+ relationLoadStrategy: options.relationLoadStrategy,
1713
1885
  queryInterfaceFactory,
1714
1886
  }, schema);
1715
1887
  if (owns) {