turbine-orm 0.21.0 → 0.22.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/dist/powdb.js ADDED
@@ -0,0 +1,802 @@
1
+ /**
2
+ * turbine-orm/powdb — Turbine's PowDB / PowQL backend.
3
+ *
4
+ * PowDB is a single-node embedded database with its own query language, **PowQL**
5
+ * (not SQL), reached over `@zvndev/powdb-client`'s binary TCP protocol. PowDB is a
6
+ * different shape than the SQL engines, so this module does NOT route through the
7
+ * SQL `Dialect` / `QueryInterface`: it ships a parallel {@link PowqlInterface} that
8
+ * generates PowQL, plugged into `TurbineClient` via the `queryInterfaceFactory`
9
+ * seam. The four SQL engines are untouched.
10
+ *
11
+ * PowDB realities shape the design (all verified firsthand against a live
12
+ * `powdb-server` / the embedded addon, see `docs/strategy/powdb-parity-matrix.md`):
13
+ * - **`RETURNING` (since 0.7.0)** — `create/createMany/update/delete` append the
14
+ * trailing `returning` keyword (`RETURNING *`, all columns) and read the
15
+ * affected rows back in one round-trip. `upsert` is the lone exception (its
16
+ * statement rejects `returning`) and reselects by primary key.
17
+ * - **No generated IDs** — the app must supply every value → Turbine generates a
18
+ * client-side UUID for the primary key when it has a default.
19
+ * - **`uuid`/`datetime`/`bytes` columns can't hold client-supplied values** (no
20
+ * literal, no working cast on the wire) → Turbine maps everything onto the four
21
+ * writable types (`str`/`int`/`float`/`bool`); `Date` → `int` epoch micros;
22
+ * `string` PKs hold UUID strings.
23
+ * - **No JSON aggregation / link navigation** — single-query nested `with` is
24
+ * impossible → it degrades to batched N+1 loaders (Phase B).
25
+ * - **Single global write lock; no savepoints/isolation/pipelining** — nested
26
+ * transactions / isolation / vector / LISTEN-NOTIFY / RLS throw.
27
+ *
28
+ * `@zvndev/powdb-client` is an **optional peer dependency** loaded by dynamic
29
+ * import; `npm i turbine-orm` still pulls only `pg`.
30
+ *
31
+ * @example
32
+ * ```ts
33
+ * import { turbinePowDB } from 'turbine-orm/powdb';
34
+ * import { SCHEMA } from './generated/turbine/metadata.js';
35
+ *
36
+ * const db = await turbinePowDB({ host: '127.0.0.1', port: 5433 }, SCHEMA);
37
+ * const user = await db.table('users').create({ data: { name: 'Ada' } }); // UUID id auto-generated
38
+ * const found = await db.table('users').findMany({ where: { name: 'Ada' }, limit: 10 });
39
+ * await db.disconnect();
40
+ * ```
41
+ *
42
+ * @module
43
+ */
44
+ import { TurbineClient, } from './client.js';
45
+ import { postgresDialect } from './dialect.js';
46
+ import { ConnectionError, NotNullViolationError, TimeoutError, UniqueConstraintError, UnsupportedFeatureError, ValidationError, } from './errors.js';
47
+ /**
48
+ * Capability descriptor for PowDB. PowQL generation is owned by
49
+ * {@link PowqlInterface} (not the SQL `Dialect`), so this dialect exists only to
50
+ * drive `TurbineClient`'s capability gating and transaction keywords:
51
+ * - `supports*` flags are all `false` for the Postgres-only features, so
52
+ * `$listen`/`$notify`, RLS `sessionContext`/`$withSession`, and pgvector
53
+ * throw a clear {@link UnsupportedFeatureError} (E017) at the client surface
54
+ * instead of emitting SQL that PowDB cannot parse.
55
+ * - `begin`/`commit`/`rollback` are lowercase PowQL keywords (verified on the
56
+ * wire) so a single-level `$transaction` works.
57
+ * - PowDB has a single global write lock and supports neither savepoints nor
58
+ * nested/concurrent transactions. The `savepoint*` keywords therefore throw
59
+ * {@link UnsupportedFeatureError} (E017): a nested `tx.$transaction` emits a
60
+ * savepoint synchronously (before any DB call) and so fails fast with a
61
+ * clear typed error instead of leaking PowDB's cryptic `Parse(... 'sp_1')`.
62
+ * The pool-level begin-while-active guard (see {@link PowdbPool}) catches the
63
+ * other re-entrant shape — a fresh top-level `db.$transaction` opened inside
64
+ * an already-open one — before it can deadlock on the write lock.
65
+ * Isolation levels remain Phase B.
66
+ */
67
+ export const powdbDialect = {
68
+ ...postgresDialect,
69
+ name: 'powdb',
70
+ // `resultStrategy` is decorative for PowDB — PowqlInterface owns its own write
71
+ // path and never reads it. Set to 'returning' for honesty: writes use PowDB
72
+ // 0.7.0's trailing `returning` keyword (upsert excepted — see PowqlInterface).
73
+ resultStrategy: 'returning',
74
+ supportsReturning: true,
75
+ supportsVector: false,
76
+ supportsListenNotify: false,
77
+ supportsRLS: false,
78
+ supportsAdvisoryLock: false,
79
+ supportsILike: false,
80
+ beginStatement: () => 'begin',
81
+ commitStatement: () => 'commit',
82
+ rollbackStatement: () => 'rollback',
83
+ // PowDB has no savepoints — a nested `tx.$transaction` would emit one and PowDB
84
+ // rejects it with a cryptic parse error. Throw a clear typed error instead.
85
+ // These run synchronously in TransactionClient.$transaction before any query,
86
+ // so the nested call fails fast with no partial DB state.
87
+ savepointStatement: throwNoNestedTransaction,
88
+ releaseSavepointStatement: throwNoNestedTransaction,
89
+ rollbackToSavepointStatement: throwNoNestedTransaction,
90
+ };
91
+ /** Reject any savepoint (nested-transaction) operation — PowDB is single-writer. */
92
+ function throwNoNestedTransaction() {
93
+ throw new UnsupportedFeatureError('nested transactions', 'powdb', 'PowDB is single-writer — it has one global write lock and no savepoints. ' +
94
+ 'Complete the open transaction before starting another; do not nest `$transaction` calls.');
95
+ }
96
+ /**
97
+ * Marker wrapper for a value bound to a `float` column. The networked driver
98
+ * unwraps it to the plain number (the wire param is unchanged), but the
99
+ * *embedded* literal encoder reads it to emit a float-form PowQL literal (`42`
100
+ * → `42.0`) so an integer-valued float column stays unambiguously a float.
101
+ * Constructed in {@link PowqlInterface.param}.
102
+ */
103
+ export class PowdbFloatParam {
104
+ value;
105
+ constructor(value) {
106
+ this.value = value;
107
+ }
108
+ }
109
+ /** Minimum PowDB server version the networked transport requires. */
110
+ export const MIN_POWDB_VERSION = '0.7.0';
111
+ /**
112
+ * Parse a `powdb://[user[:pass]@]host[:port][/db]` connection string into
113
+ * {@link PowdbConnOptions} (consistency with `turbineMysql`/`turbineMssql`,
114
+ * which accept a URL). Defaults: host `127.0.0.1`, port `5433`.
115
+ */
116
+ export function parsePowdbUrl(connectionString) {
117
+ let u;
118
+ try {
119
+ u = new URL(connectionString);
120
+ }
121
+ catch {
122
+ throw new ConnectionError(`[turbine] Invalid PowDB connection string: "${connectionString}"`);
123
+ }
124
+ if (u.protocol !== 'powdb:') {
125
+ throw new ConnectionError(`[turbine] PowDB connection string must use the powdb:// scheme (got "${u.protocol}//…").`);
126
+ }
127
+ const opts = {
128
+ host: u.hostname || '127.0.0.1',
129
+ port: u.port ? Number(u.port) : 5433,
130
+ };
131
+ if (u.username)
132
+ opts.user = decodeURIComponent(u.username);
133
+ if (u.password)
134
+ opts.password = decodeURIComponent(u.password);
135
+ const db = u.pathname.replace(/^\//, '');
136
+ if (db)
137
+ opts.dbName = decodeURIComponent(db);
138
+ return opts;
139
+ }
140
+ /**
141
+ * Fail fast if a networked PowDB server is older than {@link MIN_POWDB_VERSION}.
142
+ * Turbine's write path relies on the trailing `returning` keyword and the
143
+ * int→float coercion fix, both of which landed in 0.7.0. Embedded exposes no
144
+ * version method, so this is networked-only (the embedded peer is pinned ^0.7.0
145
+ * at install time). A non-semver / empty version string is tolerated (we cannot
146
+ * prove it is too old).
147
+ */
148
+ export function assertSupportedPowdbVersion(version) {
149
+ const m = /^(\d+)\.(\d+)\.(\d+)/.exec(String(version ?? '').trim());
150
+ if (!m)
151
+ return; // unknown / non-semver — don't block
152
+ const [major, minor] = [Number(m[1]), Number(m[2])];
153
+ // 0.7.0 is the floor; >= 0.7 (or any 1.x+) passes.
154
+ if (major > 0 || (major === 0 && minor >= 7))
155
+ return;
156
+ throw new ConnectionError(`[turbine] turbine-orm/powdb requires PowDB >= ${MIN_POWDB_VERSION}; the server reports "${version}". ` +
157
+ 'Upgrade the PowDB server (0.7.0 added the `returning` keyword and the int->float coercion fix Turbine relies on).');
158
+ }
159
+ /**
160
+ * Map a Turbine column to the PowQL DDL type used in `defineSchema` →
161
+ * `type T { … }`. Turbine never emits PowDB's `uuid`/`datetime`/`bytes` types,
162
+ * which cannot hold client-supplied values on the wire (no literal, no cast):
163
+ * - `Date` → `int` (epoch micros) - `boolean` → `bool`
164
+ * - integral `number`/`bigint` → `int` - fractional `number` → `float`
165
+ * - everything else (incl. UUID/PK strings) → `str`
166
+ * Array / JSON / bytes columns throw — they have no PowDB equivalent.
167
+ */
168
+ export function powqlColumnType(col) {
169
+ if (col.isArray) {
170
+ throw new ValidationError(`[turbine] Column "${col.name}" is an array — PowDB has no array type. Arrays are unsupported on the PowDB backend.`);
171
+ }
172
+ const ts = col.tsType.replace(/\s*\|\s*null$/i, '').trim();
173
+ if (ts === 'Date')
174
+ return 'int'; // epoch micros
175
+ if (ts === 'boolean')
176
+ return 'bool';
177
+ if (ts === 'number')
178
+ return isFloatColumn(col) ? 'float' : 'int';
179
+ if (ts === 'bigint')
180
+ return 'int';
181
+ if (ts === 'string')
182
+ return 'str';
183
+ if (ts === 'Buffer' || ts === 'Uint8Array') {
184
+ throw new ValidationError(`[turbine] Column "${col.name}" is binary — PowDB cannot store client-supplied bytes on the wire. Use a string (e.g. base64) instead.`);
185
+ }
186
+ if (/Record<|object|unknown|\[\]|\{/.test(ts)) {
187
+ throw new ValidationError(`[turbine] Column "${col.name}" (${col.tsType}) maps to JSON/object, which PowDB has no type for. Flatten it or store a JSON string.`);
188
+ }
189
+ return 'str';
190
+ }
191
+ /** Heuristic: does this numeric column hold fractional values (→ PowQL `float`)? */
192
+ function isFloatColumn(col) {
193
+ const t = (col.dialectType ?? col.pgType ?? '').toLowerCase();
194
+ return /float|double|real|numeric|decimal|money/.test(t);
195
+ }
196
+ /** Is a column stored as `int` epoch micros but surfaced as a JS `Date`? */
197
+ function isDateColumn(col) {
198
+ return col.tsType.replace(/\s*\|\s*null$/i, '').trim() === 'Date';
199
+ }
200
+ /**
201
+ * Generate PowQL DDL (`type T { … }`) for every table in a schema. Used to
202
+ * provision a PowDB database from a code-first `defineSchema`/`SchemaMetadata`
203
+ * (PowDB has no migration runner yet). The primary key column is declared
204
+ * `required unique`; non-nullable columns are `required`.
205
+ */
206
+ export function powqlSchemaDDL(schema) {
207
+ const stmts = [];
208
+ for (const meta of Object.values(schema.tables)) {
209
+ const pkSet = new Set(meta.primaryKey);
210
+ const fields = meta.columns.map((col) => {
211
+ const mods = [];
212
+ if (!col.nullable || pkSet.has(col.name))
213
+ mods.push('required');
214
+ if (pkSet.has(col.name))
215
+ mods.push('unique');
216
+ return ` ${mods.join(' ')}${mods.length ? ' ' : ''}${col.name}: ${powqlColumnType(col)}`;
217
+ });
218
+ stmts.push(`type ${meta.name} {\n${fields.join(',\n')}\n}`);
219
+ // Secondary unique constraints (beyond the PK) become unique indexes.
220
+ for (const uniq of meta.uniqueColumns) {
221
+ if (uniq.length === 1 && !pkSet.has(uniq[0])) {
222
+ stmts.push(`alter ${meta.name} add unique .${uniq[0]}`);
223
+ }
224
+ }
225
+ }
226
+ return stmts;
227
+ }
228
+ /** Coerce a JS value into a PowDB positional param (the write side). */
229
+ function toPowdbParam(value, col) {
230
+ if (value instanceof PowdbFloatParam)
231
+ return value.value; // wire-side: a float column takes the plain number
232
+ if (value === undefined || value === null)
233
+ return null;
234
+ if (value instanceof Date)
235
+ return BigInt(value.getTime()) * 1000n; // ms → micros (int column)
236
+ if (col && isDateColumn(col) && typeof value === 'number')
237
+ return BigInt(value) * 1000n;
238
+ if (typeof value === 'boolean' ||
239
+ typeof value === 'number' ||
240
+ typeof value === 'bigint' ||
241
+ typeof value === 'string') {
242
+ return value;
243
+ }
244
+ // Objects/arrays have no PowDB representation.
245
+ throw new ValidationError(`[turbine] Value of type ${typeof value} cannot be bound as a PowDB parameter.`);
246
+ }
247
+ /**
248
+ * Coerce a single PowDB wire string into the JS value its column type implies.
249
+ * Every PowDB value arrives as a string; NULL arrives as the bareword `"null"`.
250
+ * Metadata resolves the `"null"` ambiguity for nullable non-string columns.
251
+ */
252
+ export function coerceValue(raw, col) {
253
+ const ts = col.tsType.replace(/\s*\|\s*null$/i, '').trim();
254
+ // NULL bareword: unambiguous for non-string columns; for `str` we cannot tell a
255
+ // literal "null" from SQL NULL, so a nullable str of value "null" reads as null.
256
+ if (raw === 'null' && (ts !== 'string' || col.nullable))
257
+ return null;
258
+ if (ts === 'Date') {
259
+ const micros = Number(raw);
260
+ return Number.isFinite(micros) ? new Date(micros / 1000) : null;
261
+ }
262
+ if (ts === 'boolean')
263
+ return raw === 'true';
264
+ if (ts === 'number') {
265
+ const n = Number(raw);
266
+ // int8 policy: keep precision-losing big integers as strings.
267
+ return Number.isSafeInteger(n) || !Number.isInteger(n) ? n : raw;
268
+ }
269
+ if (ts === 'bigint')
270
+ return BigInt(raw);
271
+ return raw; // string / uuid-as-string
272
+ }
273
+ /**
274
+ * Map one raw PowDB row (snake-cased columns → raw wire strings, as produced by
275
+ * {@link PowdbPool}) into a typed entity (camelCase fields, coerced values).
276
+ * Only the columns present in `raw` are emitted, so partial `select` projections
277
+ * round-trip unchanged.
278
+ */
279
+ export function rowToEntity(raw, meta) {
280
+ const byName = new Map(meta.columns.map((c) => [c.name, c]));
281
+ const out = {};
282
+ for (const snake of Object.keys(raw)) {
283
+ const col = byName.get(snake);
284
+ const field = meta.reverseColumnMap[snake] ?? snake;
285
+ const value = raw[snake];
286
+ out[field] = col && typeof value === 'string' ? coerceValue(value, col) : value;
287
+ }
288
+ return out;
289
+ }
290
+ // ---------------------------------------------------------------------------
291
+ // Error translation
292
+ // ---------------------------------------------------------------------------
293
+ /**
294
+ * Translate a PowDB error into a typed Turbine error. Handles BOTH transports,
295
+ * whose error shapes differ:
296
+ * - **networked** (`@zvndev/powdb-client`) tags errors with a *semantic*
297
+ * `.code` (`connect_failed`, `timeout`, `query_failed`, …);
298
+ * - **embedded** (`@zvndev/powdb-embedded` napi addon) tags EVERY error
299
+ * `code:'GenericFailure'`, so the class can only be recovered from the
300
+ * message text (`Execution("column 'email' is required …")`,
301
+ * `Execution("type mismatch …")`, `Parse(…)`, `StorageError(…)`).
302
+ *
303
+ * So we always run the unique-constraint and message-shape checks first (they
304
+ * fire for both transports), then fall through to the networked `.code` switch.
305
+ */
306
+ export function wrapPowdbError(err) {
307
+ if (!err || typeof err !== 'object')
308
+ return new ConnectionError(`[turbine] PowDB error: ${String(err)}`);
309
+ const e = err;
310
+ const msg = e.message ?? 'unknown PowDB error';
311
+ // Unique-constraint — message-based on both transports.
312
+ if (/unique constraint violation/i.test(msg)) {
313
+ const m = /on\s+\S+\.(\w+)/i.exec(msg);
314
+ return new UniqueConstraintError({ constraint: m?.[1], cause: err });
315
+ }
316
+ // NOT NULL — "column 'x' is required but no value was provided". Map on BOTH
317
+ // transports (the networked path used to collapse this into E003).
318
+ if (/required|not[- ]?null|no value/i.test(msg)) {
319
+ const m = /column ['"]?(\w+)['"]?/i.exec(msg);
320
+ return new NotNullViolationError({ column: m?.[1], cause: err });
321
+ }
322
+ // Type mismatch / parse / execution / storage / unexpected → validation
323
+ // (E003). On the embedded transport these are the only signal we get
324
+ // (code is always 'GenericFailure'); on the networked path they are a
325
+ // safety net before the .code switch.
326
+ if (/type mismatch|\bParse\b|\bExecution\b|StorageError|unexpected|row too large/i.test(msg)) {
327
+ return new ValidationError(`[turbine] PowDB query rejected: ${msg}`);
328
+ }
329
+ switch (e.code) {
330
+ case 'connect_failed':
331
+ case 'closed':
332
+ return new ConnectionError(`[turbine] PowDB connection failed: ${msg}`);
333
+ case 'timeout':
334
+ case 'aborted':
335
+ return new TimeoutError(0, 'PowDB query');
336
+ case 'query_failed':
337
+ case 'type_coercion_failed':
338
+ case 'protocol_error':
339
+ case 'size_exceeded':
340
+ return new ValidationError(`[turbine] PowDB query rejected: ${msg}`);
341
+ default:
342
+ return err instanceof Error ? err : new ConnectionError(`[turbine] PowDB error: ${msg}`);
343
+ }
344
+ }
345
+ function normalizeQueryArgs(arg, values) {
346
+ if (typeof arg === 'string')
347
+ return { text: arg, params: values ?? [] };
348
+ return { text: arg.text, params: arg.values ?? values ?? [] };
349
+ }
350
+ /**
351
+ * Classify a transaction-control statement so the single-writer guard can track
352
+ * whether a transaction is open. Matches the lowercase keywords
353
+ * {@link powdbDialect} emits (`begin`/`commit`/`rollback`) plus the common SQL
354
+ * spellings, case-insensitively. Returns `null` for ordinary queries.
355
+ */
356
+ function txControl(powql) {
357
+ const head = powql.trim().toLowerCase();
358
+ if (head === 'begin' || head === 'begin transaction' || head === 'start transaction')
359
+ return 'begin';
360
+ if (head === 'commit' || head === 'commit transaction' || head === 'end')
361
+ return 'commit';
362
+ if (head === 'rollback' || head === 'rollback transaction')
363
+ return 'rollback';
364
+ return null;
365
+ }
366
+ /**
367
+ * The error a pool throws when a `begin` arrives while a transaction is already
368
+ * open. PowDB has ONE global write lock and supports neither concurrent nor
369
+ * nested transactions: on the networked transport a second `begin` checks out a
370
+ * fresh pooled connection and blocks forever on the lock the open transaction
371
+ * holds. This guard converts that hang into a fast, typed error.
372
+ */
373
+ function reentrantTransactionError() {
374
+ return new UnsupportedFeatureError('concurrent or nested transactions', 'powdb', 'PowDB is single-writer — it has one global write lock. A second transaction would block on it forever; ' +
375
+ 'complete the open transaction first.');
376
+ }
377
+ /** Adapt a PowDB result into the pg-compat `{ rows, rowCount, fields }` shape. */
378
+ function adaptResult(r) {
379
+ switch (r.kind) {
380
+ case 'rows': {
381
+ const rows = r.rows.map((row) => {
382
+ const o = {};
383
+ r.columns.forEach((c, i) => {
384
+ o[c] = row[i];
385
+ });
386
+ return o;
387
+ });
388
+ return { rows, rowCount: rows.length, fields: r.columns.map((name) => ({ name, dataTypeID: 0 })) };
389
+ }
390
+ case 'ok':
391
+ return { rows: [], rowCount: Number(r.affected), fields: [] };
392
+ case 'scalar':
393
+ return { rows: [{ value: r.value }], rowCount: 1, fields: [{ name: 'value', dataTypeID: 0 }] };
394
+ default:
395
+ return { rows: [], rowCount: 0, fields: [] };
396
+ }
397
+ }
398
+ /**
399
+ * A {@link PgCompatPool} backed by a `@zvndev/powdb-client` `Pool`. The query
400
+ * `text` is **PowQL**, not SQL — {@link PowqlInterface} generates it. Rows come
401
+ * back as raw strings here; per-column JS coercion happens in `PowqlInterface`
402
+ * (it owns the schema metadata).
403
+ */
404
+ export class PowdbPool {
405
+ pool;
406
+ toParam;
407
+ closed = false;
408
+ /**
409
+ * Pool-level single-writer guard. PowDB holds one global write lock, so at
410
+ * most one transaction may be open across the whole pool. A `begin` issued
411
+ * while this is `true` is rejected (it would otherwise check out a second
412
+ * connection and block on the lock forever — the networked re-entrant hang).
413
+ */
414
+ activeTransaction = false;
415
+ constructor(pool, toParam = (v) => toPowdbParam(v)) {
416
+ this.pool = pool;
417
+ this.toParam = toParam;
418
+ }
419
+ /**
420
+ * Enforce the single-writer model on a transaction-control statement. Throws
421
+ * (before any query runs) if a `begin` arrives while a transaction is open;
422
+ * otherwise flips the pool-level flag. Returns the control kind so the caller
423
+ * can decide whether it even needs to hit the engine.
424
+ */
425
+ guardTxControl(powql) {
426
+ const ctl = txControl(powql);
427
+ if (ctl === 'begin') {
428
+ if (this.activeTransaction)
429
+ throw reentrantTransactionError();
430
+ this.activeTransaction = true;
431
+ }
432
+ else if (ctl === 'commit' || ctl === 'rollback') {
433
+ this.activeTransaction = false;
434
+ }
435
+ return ctl;
436
+ }
437
+ // biome-ignore lint/suspicious/noExplicitAny: pg-compat query is generic over the row shape.
438
+ async query(text, values) {
439
+ const { text: powql, params } = normalizeQueryArgs(text, values);
440
+ this.guardTxControl(powql);
441
+ try {
442
+ const result = await this.pool.withClient((c) => c.query(powql, params.map(this.toParam)));
443
+ return adaptResult(result);
444
+ }
445
+ catch (err) {
446
+ throw wrapPowdbError(err);
447
+ }
448
+ }
449
+ async connect() {
450
+ const client = await this.pool.acquire();
451
+ let broken = false;
452
+ return {
453
+ // biome-ignore lint/suspicious/noExplicitAny: see query() above.
454
+ query: async (text, values) => {
455
+ const { text: powql, params } = normalizeQueryArgs(text, values);
456
+ // Guard BEFORE acquiring the engine — a re-entrant begin throws fast
457
+ // instead of blocking on the global write lock the open tx holds.
458
+ this.guardTxControl(powql);
459
+ try {
460
+ return adaptResult(await client.query(powql, params.map(this.toParam)));
461
+ }
462
+ catch (err) {
463
+ broken = true;
464
+ throw wrapPowdbError(err);
465
+ }
466
+ },
467
+ release: () => {
468
+ // Releasing this connection ends its transaction scope. Clear the flag
469
+ // as a safety net so a tx torn down without an explicit commit/rollback
470
+ // (e.g. a timeout that destroys the connection) never leaves the pool
471
+ // permanently believing a transaction is still open.
472
+ this.activeTransaction = false;
473
+ return broken ? this.pool.destroy(client) : this.pool.release(client);
474
+ },
475
+ };
476
+ }
477
+ async end() {
478
+ if (this.closed)
479
+ return;
480
+ this.closed = true;
481
+ await this.pool.close();
482
+ }
483
+ }
484
+ /** Normalize the embedded addon's loosely-typed result into a {@link PowdbResult}. */
485
+ function normalizeEmbeddedResult(r) {
486
+ switch (r.kind) {
487
+ case 'rows':
488
+ return { kind: 'rows', columns: r.columns ?? [], rows: r.rows ?? [] };
489
+ case 'scalar':
490
+ return { kind: 'scalar', value: r.value ?? 'null' };
491
+ case 'ok':
492
+ return { kind: 'ok', affected: r.affected ?? 0n };
493
+ default:
494
+ return { kind: 'message', message: r.message ?? '' };
495
+ }
496
+ }
497
+ /**
498
+ * Encode a JS value as a **PowQL literal** for the embedded driver, which takes
499
+ * no params array — `$N` placeholders must be materialized into the query text.
500
+ *
501
+ * This is the single place Turbine builds PowQL text from a value, so it is the
502
+ * security-critical surface. String encoding matches PowDB's lexer
503
+ * (`crates/query/src/lexer.rs`) exactly: a string literal is `"…"`, and inside
504
+ * it the lexer recognizes only the escapes `\"`, `\\`, `\n`, `\t` (any other
505
+ * `\x` drops the backslash and keeps `x`; every non-`\`/non-`"` char — raw
506
+ * newlines, CR, unicode — is taken literally). So we escape `\` → `\\` and
507
+ * `"` → `\"` (the only breakout vectors), render `\n`/`\t` as their recognized
508
+ * escapes, and leave everything else raw. Verified against the real engine:
509
+ * quotes, backslashes, `$N`, `"); drop … --`, raw CR, and emoji all round-trip
510
+ * as data and cannot break out of the literal or inject a second statement.
511
+ */
512
+ export function encodePowqlLiteral(value) {
513
+ if (value instanceof PowdbFloatParam) {
514
+ const n = value.value;
515
+ if (!Number.isFinite(n))
516
+ throw new ValidationError(`[turbine] Non-finite float cannot be encoded for PowDB.`);
517
+ // Force a float-form literal so an integer-valued float column stays a float.
518
+ return Number.isInteger(n) ? `${n}.0` : String(n);
519
+ }
520
+ if (value === undefined || value === null)
521
+ return 'null';
522
+ if (value instanceof Date)
523
+ return `${BigInt(value.getTime()) * 1000n}`; // epoch micros (int column)
524
+ if (typeof value === 'boolean')
525
+ return value ? 'true' : 'false';
526
+ if (typeof value === 'bigint')
527
+ return value.toString();
528
+ if (typeof value === 'number') {
529
+ if (!Number.isFinite(value))
530
+ throw new ValidationError(`[turbine] Non-finite number cannot be encoded for PowDB.`);
531
+ // `String(n)` renders an integer as an int literal (`42`) and a fractional
532
+ // number as a float literal (`4.2`) — PowQL distinguishes them by the dot.
533
+ return String(value);
534
+ }
535
+ if (typeof value === 'string')
536
+ return encodePowqlString(value);
537
+ throw new ValidationError(`[turbine] Value of type ${typeof value} cannot be encoded as a PowDB literal.`);
538
+ }
539
+ /** Escape a string into a PowQL `"…"` literal, matching the engine lexer's escape rules. */
540
+ function encodePowqlString(s) {
541
+ let out = '"';
542
+ for (const ch of s) {
543
+ if (ch === '\\')
544
+ out += '\\\\';
545
+ else if (ch === '"')
546
+ out += '\\"';
547
+ else if (ch === '\n')
548
+ out += '\\n';
549
+ else if (ch === '\t')
550
+ out += '\\t';
551
+ else
552
+ out += ch; // raw — the lexer takes any other char literally (incl. CR, unicode)
553
+ }
554
+ return `${out}"`;
555
+ }
556
+ /**
557
+ * Substitute every `$N` placeholder in a generator-produced PowQL template with
558
+ * the encoded literal of `params[N-1]`. Safe because the template is produced by
559
+ * {@link PowqlInterface} and contains **no** user string literals — the only
560
+ * `$<digits>` tokens are genuine positional placeholders, so a single scan
561
+ * cannot accidentally rewrite a `$N` that is itself part of a value (values are
562
+ * params, never inlined into the template by the generator).
563
+ */
564
+ export function materializePowql(powql, params) {
565
+ return powql.replace(/\$(\d+)/g, (_m, n) => {
566
+ const idx = Number(n) - 1;
567
+ if (idx < 0 || idx >= params.length) {
568
+ throw new ValidationError(`[turbine] PowQL placeholder $${n} has no bound parameter (have ${params.length}).`);
569
+ }
570
+ return encodePowqlLiteral(params[idx]);
571
+ });
572
+ }
573
+ /**
574
+ * A {@link PgCompatPool} backed by an in-process `@zvndev/powdb-embedded`
575
+ * `Database`. The embedded addon takes **no params array** — its `query(powql)`
576
+ * accepts only a string — so this pool materializes each positional `$N` into a
577
+ * PowQL literal via {@link materializePowql} before handing the text to the
578
+ * engine. One handle, single connection: transaction keywords (`begin`/`commit`/
579
+ * `rollback`) are issued serially as ordinary queries.
580
+ */
581
+ export class PowdbEmbeddedPool {
582
+ db;
583
+ closed = false;
584
+ /**
585
+ * Single-writer guard. The embedded engine is one handle with one global
586
+ * write lock — only one transaction may be open at a time. A re-entrant
587
+ * `begin` (a fresh top-level `db.$transaction` opened inside an open one)
588
+ * would otherwise hit PowDB's raw "already in a transaction" parse error;
589
+ * this surfaces a typed error instead. (Nested `tx.$transaction` is caught
590
+ * earlier still, by the savepoint override in {@link powdbDialect}.)
591
+ */
592
+ activeTransaction = false;
593
+ constructor(db) {
594
+ this.db = db;
595
+ }
596
+ /** Enforce the single-writer model on a transaction-control statement. */
597
+ guardTxControl(powql) {
598
+ const ctl = txControl(powql);
599
+ if (ctl === 'begin') {
600
+ if (this.activeTransaction)
601
+ throw reentrantTransactionError();
602
+ this.activeTransaction = true;
603
+ }
604
+ else if (ctl === 'commit' || ctl === 'rollback') {
605
+ this.activeTransaction = false;
606
+ }
607
+ }
608
+ run(powql, params) {
609
+ this.guardTxControl(powql);
610
+ try {
611
+ const materialized = materializePowql(powql, params);
612
+ return adaptResult(normalizeEmbeddedResult(this.db.query(materialized)));
613
+ }
614
+ catch (err) {
615
+ throw wrapPowdbError(err);
616
+ }
617
+ }
618
+ // biome-ignore lint/suspicious/noExplicitAny: pg-compat query is generic over the row shape.
619
+ async query(text, values) {
620
+ const { text: powql, params } = normalizeQueryArgs(text, values);
621
+ return this.run(powql, params);
622
+ }
623
+ async connect() {
624
+ // Single in-process handle — the "client" shares the one Database; tx
625
+ // keywords run serially on it.
626
+ return {
627
+ // biome-ignore lint/suspicious/noExplicitAny: see query() above.
628
+ query: async (text, values) => {
629
+ const { text: powql, params } = normalizeQueryArgs(text, values);
630
+ return this.run(powql, params);
631
+ },
632
+ release: () => {
633
+ // End-of-scope safety net (see PowdbPool.connect()): a tx torn down
634
+ // without an explicit commit/rollback must not wedge the handle.
635
+ this.activeTransaction = false;
636
+ },
637
+ };
638
+ }
639
+ async end() {
640
+ if (this.closed)
641
+ return;
642
+ // The addon exposes no explicit close — drop the reference and let GC /
643
+ // the engine's checkpoint flush. Caveat: durability is checkpoint-bound, so
644
+ // hold the process open long enough for the final WAL flush in short scripts.
645
+ this.closed = true;
646
+ }
647
+ }
648
+ // ---------------------------------------------------------------------------
649
+ // PowqlInterface — the PowQL query generator (Phase A: flat CRUD via returning)
650
+ // ---------------------------------------------------------------------------
651
+ export { PowqlInterface } from './powql.js';
652
+ /**
653
+ * Dynamically load `@zvndev/powdb-client`. Kept out of the static import graph so
654
+ * `import 'turbine-orm/powdb'` never throws when the optional peer is absent.
655
+ */
656
+ async function loadPowdb() {
657
+ try {
658
+ return (await import('@zvndev/powdb-client'));
659
+ }
660
+ catch (err) {
661
+ throw new ConnectionError("[turbine] turbine-orm/powdb requires the optional peer dependency '@zvndev/powdb-client'. Install it: npm i @zvndev/powdb-client. " +
662
+ `(${err.message})`);
663
+ }
664
+ }
665
+ /**
666
+ * Dynamically load `@zvndev/powdb-embedded` (the in-process napi addon). Kept out
667
+ * of the static import graph — `import 'turbine-orm/powdb'` never pulls it. A
668
+ * missing package or an unsupported platform (Intel-mac/musl/Windows ship no
669
+ * prebuilt binary) throws a clear {@link ConnectionError} pointing at the
670
+ * from-source `npm run build` fallback.
671
+ */
672
+ async function loadPowdbEmbedded() {
673
+ let mod;
674
+ try {
675
+ mod = (await import('@zvndev/powdb-embedded'));
676
+ }
677
+ catch (err) {
678
+ throw new ConnectionError("[turbine] turbine-orm/powdb embedded mode requires the optional peer '@zvndev/powdb-embedded'. " +
679
+ 'Install it: npm i @zvndev/powdb-embedded. If install succeeded but loading failed, your platform has no ' +
680
+ 'prebuilt binary (prebuilts ship for macOS arm64/x64 and Linux glibc x64/arm64; Intel-mac/musl/Windows ' +
681
+ 'build from source) — build it with `npm run build` in the addon, then retry. ' +
682
+ `(${err.message})`);
683
+ }
684
+ if (!mod || typeof mod.Database?.open !== 'function') {
685
+ throw new ConnectionError("[turbine] '@zvndev/powdb-embedded' loaded but did not export Database.open — the installed version is " +
686
+ 'likely incompatible (turbine-orm/powdb embedded requires @zvndev/powdb-embedded ^0.7.0).');
687
+ }
688
+ return mod;
689
+ }
690
+ /** Open an embedded database handle, wrapping engine open failures (corrupt dir, etc.). */
691
+ async function openEmbeddedPool(target) {
692
+ const mod = await loadPowdbEmbedded();
693
+ const { embedded: dir, syncMode, memoryLimit } = target;
694
+ let db;
695
+ try {
696
+ if (memoryLimit !== undefined) {
697
+ if (typeof mod.Database.openWithMemoryLimit !== 'function') {
698
+ throw new ConnectionError('[turbine] embedded `memoryLimit` requires @zvndev/powdb-embedded ≥ 0.7.1.');
699
+ }
700
+ db = mod.Database.openWithMemoryLimit(dir, memoryLimit);
701
+ }
702
+ else {
703
+ db = mod.Database.open(dir);
704
+ }
705
+ }
706
+ catch (err) {
707
+ if (err instanceof ConnectionError)
708
+ throw err;
709
+ throw new ConnectionError(`[turbine] PowDB embedded could not open data dir "${dir}": ${err.message}`);
710
+ }
711
+ if (syncMode !== undefined) {
712
+ if (typeof db.setSyncMode !== 'function') {
713
+ throw new ConnectionError('[turbine] embedded `syncMode` requires @zvndev/powdb-embedded ≥ 0.7.1 (the installed addon has no setSyncMode).');
714
+ }
715
+ db.setSyncMode(syncMode);
716
+ }
717
+ return new PowdbEmbeddedPool(db);
718
+ }
719
+ /**
720
+ * Bind Turbine to PowDB. `target` is one of:
721
+ * - a `powdb://[user[:pass]@]host[:port][/db]` connection string → a
722
+ * **networked** `@zvndev/powdb-client` pool (consistency with
723
+ * `turbineMysql`/`turbineMssql`);
724
+ * - a host/port options object → a **networked** `@zvndev/powdb-client` pool;
725
+ * - an `{ embedded: <data-dir> }` object → an in-process
726
+ * `@zvndev/powdb-embedded` database (no server);
727
+ * - an already-constructed `@zvndev/powdb-client` `Pool` or {@link PowdbPool}
728
+ * (injection — you own its lifecycle and `disconnect()` is a no-op).
729
+ *
730
+ * On the networked transport the server version is probed and a clear
731
+ * {@link ConnectionError} is thrown if it is older than {@link MIN_POWDB_VERSION}
732
+ * (the `returning` keyword / int->float coercion fix Turbine relies on).
733
+ *
734
+ * Resolves to a `TurbineClient` whose `table()` accessors generate **PowQL** via
735
+ * {@link PowqlInterface}. The SQL `Dialect` is not involved.
736
+ */
737
+ export async function turbinePowDB(target, schema, options = {}) {
738
+ let pool;
739
+ let owns = false;
740
+ if (typeof target === 'string') {
741
+ const mod = await loadPowdb();
742
+ const clientPool = new mod.Pool({ ...parsePowdbUrl(target), max: options.connectionLimit ?? 10 });
743
+ await assertNetworkedVersion(clientPool);
744
+ pool = new PowdbPool(clientPool);
745
+ owns = true;
746
+ }
747
+ else if (target instanceof PowdbPool) {
748
+ pool = target;
749
+ }
750
+ else if (isEmbeddedTarget(target)) {
751
+ pool = await openEmbeddedPool(target);
752
+ owns = true;
753
+ }
754
+ else if (isPowdbClientPool(target)) {
755
+ pool = new PowdbPool(target);
756
+ }
757
+ else {
758
+ const mod = await loadPowdb();
759
+ const clientPool = new mod.Pool({ ...target, max: options.connectionLimit ?? 10 });
760
+ await assertNetworkedVersion(clientPool);
761
+ pool = new PowdbPool(clientPool);
762
+ owns = true;
763
+ }
764
+ // The PowQL generator is loaded here to keep client.ts free of any PowDB import.
765
+ const { PowqlInterface } = await import('./powql.js');
766
+ const queryInterfaceFactory = (p, table, sch, middlewares, opts) => new PowqlInterface(p, table, sch, middlewares, opts);
767
+ const client = new TurbineClient({
768
+ pool,
769
+ preparedStatements: false,
770
+ dialect: powdbDialect,
771
+ logging: options.logging,
772
+ defaultLimit: options.defaultLimit,
773
+ warnOnUnlimited: options.warnOnUnlimited,
774
+ queryInterfaceFactory,
775
+ }, schema);
776
+ if (!owns) {
777
+ // Injected pool — the caller owns its lifecycle.
778
+ client.disconnect = async () => { };
779
+ }
780
+ return client;
781
+ }
782
+ /**
783
+ * Probe a networked pool's `serverVersion` (declared on {@link PowdbClient}) and
784
+ * fail fast if the server is older than {@link MIN_POWDB_VERSION}. Best-effort:
785
+ * a driver that does not surface a version is left untouched (we cannot prove it
786
+ * too old). Errors from the probe itself surface as the normal connect failure.
787
+ */
788
+ async function assertNetworkedVersion(clientPool) {
789
+ await clientPool.withClient(async (c) => {
790
+ assertSupportedPowdbVersion(c.serverVersion);
791
+ });
792
+ }
793
+ function isPowdbClientPool(x) {
794
+ return (!!x &&
795
+ typeof x === 'object' &&
796
+ typeof x.acquire === 'function' &&
797
+ typeof x.withClient === 'function');
798
+ }
799
+ function isEmbeddedTarget(x) {
800
+ return !!x && typeof x === 'object' && typeof x.embedded === 'string';
801
+ }
802
+ //# sourceMappingURL=powdb.js.map