turbine-orm 0.30.0 → 0.31.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/cjs/client.js +14 -3
- package/dist/cjs/mssql.js +24 -3
- package/dist/cjs/powdb.js +197 -50
- package/dist/cjs/powql.js +7 -1
- package/dist/cjs/query/builder.js +311 -63
- package/dist/cjs/query/filters.js +49 -2
- package/dist/client.d.ts +13 -0
- package/dist/client.js +14 -3
- package/dist/index.d.ts +1 -1
- package/dist/mssql.js +24 -3
- package/dist/powdb.d.ts +33 -0
- package/dist/powdb.js +197 -50
- package/dist/powql.js +7 -1
- package/dist/query/builder.d.ts +85 -5
- package/dist/query/builder.js +312 -64
- package/dist/query/filters.d.ts +28 -1
- package/dist/query/filters.js +46 -1
- package/dist/query/index.d.ts +1 -1
- package/dist/query/types.d.ts +66 -11
- package/package.json +1 -1
|
@@ -7,9 +7,10 @@
|
|
|
7
7
|
* and execution rather than filter-shape bookkeeping.
|
|
8
8
|
*/
|
|
9
9
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
-
exports.VECTOR_DISTANCE_COMPARATORS = exports.VECTOR_METRIC_OPERATORS = exports.TEXT_SEARCH_KEYS = exports.ARRAY_UNIQUE_KEYS = exports.ARRAY_OPERATOR_KEYS = exports.JSONB_UNIQUE_KEYS = exports.JSON_RANGE_OPERATORS = exports.JSONB_OPERATOR_KEYS = exports.UPDATE_OPERATOR_KEYS = void 0;
|
|
10
|
+
exports.VECTOR_DISTANCE_COMPARATORS = exports.VECTOR_METRIC_OPERATORS = exports.TEXT_SEARCH_KEYS = exports.ARRAY_UNIQUE_KEYS = exports.ARRAY_OPERATOR_KEYS = exports.JSONB_UNIQUE_KEYS = exports.JSON_RANGE_OPERATORS = exports.JSONB_OPERATOR_KEYS = exports.UPDATE_OPERATOR_KEYS = exports.COLUMN_REF_OPERATORS = void 0;
|
|
11
11
|
exports.isWhereOperator = isWhereOperator;
|
|
12
12
|
exports.isUnmatchedPlainObject = isUnmatchedPlainObject;
|
|
13
|
+
exports.isColumnRef = isColumnRef;
|
|
13
14
|
exports.fingerprintOperatorShape = fingerprintOperatorShape;
|
|
14
15
|
exports.assertBindableEqualsOperand = assertBindableEqualsOperand;
|
|
15
16
|
exports.sortedKeys = sortedKeys;
|
|
@@ -24,6 +25,7 @@ exports.validateTextSearchConfig = validateTextSearchConfig;
|
|
|
24
25
|
exports.isVectorFilter = isVectorFilter;
|
|
25
26
|
exports.isVectorOrderBy = isVectorOrderBy;
|
|
26
27
|
exports.isOrderBySpec = isOrderBySpec;
|
|
28
|
+
exports.isJsonPathOrderBy = isJsonPathOrderBy;
|
|
27
29
|
exports.normalizeOrderBy = normalizeOrderBy;
|
|
28
30
|
const errors_js_1 = require("../errors.js");
|
|
29
31
|
const utils_js_1 = require("./utils.js");
|
|
@@ -56,17 +58,48 @@ function isUnmatchedPlainObject(value) {
|
|
|
56
58
|
const proto = Object.getPrototypeOf(value);
|
|
57
59
|
return proto === Object.prototype || proto === null;
|
|
58
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* Operator keys that accept a {@link ColumnRef} (`{ col: 'otherField' }`)
|
|
63
|
+
* value for column-to-column comparison. `in`/`notIn` and the LIKE operators
|
|
64
|
+
* take values only.
|
|
65
|
+
*/
|
|
66
|
+
exports.COLUMN_REF_OPERATORS = new Set(['equals', 'not', 'gt', 'gte', 'lt', 'lte']);
|
|
67
|
+
/**
|
|
68
|
+
* Check if an operator value is a column reference: a plain object whose ONLY
|
|
69
|
+
* key is `col` with a string value. Anything else (extra keys, non-string
|
|
70
|
+
* `col`) is treated as a plain value so JSON payloads that merely contain a
|
|
71
|
+
* `col` property keep their equality meaning.
|
|
72
|
+
*/
|
|
73
|
+
function isColumnRef(value) {
|
|
74
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value) || value instanceof Date)
|
|
75
|
+
return false;
|
|
76
|
+
const keys = Object.keys(value);
|
|
77
|
+
return keys.length === 1 && keys[0] === 'col' && typeof value.col === 'string';
|
|
78
|
+
}
|
|
59
79
|
/**
|
|
60
80
|
* Fingerprint the SHAPE of a where-operator object. Null-valued `equals` /
|
|
61
81
|
* `not` compile to parameterless `IS NULL` / `IS NOT NULL` (different SQL, no
|
|
62
82
|
* param pushed), so null-ness is part of the shape — without it a cache entry
|
|
63
83
|
* warmed by `{ not: 5 }` would serve `{ not: null }` with a desynced param list.
|
|
84
|
+
*
|
|
85
|
+
* Column references ({@link ColumnRef}) compile the referenced column into the
|
|
86
|
+
* SQL TEXT (no param bound), so the referenced field name is part of the shape
|
|
87
|
+
*: `{ equals: { col: 'a' } }` and `{ equals: { col: 'b' } }` must never share
|
|
88
|
+
* a cache entry. The name is JSON-encoded so exotic field names cannot collide
|
|
89
|
+
* with other fingerprint tokens.
|
|
64
90
|
*/
|
|
65
91
|
function fingerprintOperatorShape(value) {
|
|
66
92
|
const obj = value;
|
|
67
93
|
const opKeys = Object.keys(obj)
|
|
68
94
|
.filter((k) => k !== 'mode')
|
|
69
|
-
.map((k) =>
|
|
95
|
+
.map((k) => {
|
|
96
|
+
const v = obj[k];
|
|
97
|
+
if ((k === 'equals' || k === 'not') && v === null)
|
|
98
|
+
return `${k}:null`;
|
|
99
|
+
if (exports.COLUMN_REF_OPERATORS.has(k) && isColumnRef(v))
|
|
100
|
+
return `${k}:col(${JSON.stringify(v.col)})`;
|
|
101
|
+
return k;
|
|
102
|
+
})
|
|
70
103
|
.sort();
|
|
71
104
|
const modeStr = value.mode === 'insensitive' ? ':i' : '';
|
|
72
105
|
return `op(${opKeys.join(',')}${modeStr})`;
|
|
@@ -269,6 +302,20 @@ function isVectorOrderBy(value) {
|
|
|
269
302
|
function isOrderBySpec(value) {
|
|
270
303
|
return typeof value === 'object' && value !== null && !Array.isArray(value) && 'sort' in value;
|
|
271
304
|
}
|
|
305
|
+
/**
|
|
306
|
+
* Check if an orderBy value is a JSON-path ordering: `{ path: [...] }` with an
|
|
307
|
+
* ARRAY path. The array requirement disambiguates from relation orderBy values
|
|
308
|
+
* (whose entries are directions/specs keyed by target column: a target column
|
|
309
|
+
* literally named `path` maps to a string direction, never an array), and the
|
|
310
|
+
* `distance`/`sort` exclusions keep vector and spec shapes out.
|
|
311
|
+
*/
|
|
312
|
+
function isJsonPathOrderBy(value) {
|
|
313
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
314
|
+
return false;
|
|
315
|
+
if ('distance' in value || 'sort' in value)
|
|
316
|
+
return false;
|
|
317
|
+
return Array.isArray(value.path);
|
|
318
|
+
}
|
|
272
319
|
/**
|
|
273
320
|
* Normalize an orderBy value into `{ direction, nulls }`. Accepts a plain
|
|
274
321
|
* direction string or an {@link OrderBySpec}. Used by every ORDER BY compile
|
package/dist/client.d.ts
CHANGED
|
@@ -68,6 +68,19 @@ export interface PgCompatPoolClient {
|
|
|
68
68
|
* sequential.
|
|
69
69
|
*/
|
|
70
70
|
readonly supportsPipelining?: boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Optional engine seam: scope a transaction's user callback to its own
|
|
73
|
+
* async subtree. When present, `TurbineClient.transaction` / `$transaction`
|
|
74
|
+
* invoke the callback as `wrapTransactionCallback(() => fn(tx))` instead of
|
|
75
|
+
* `fn(tx)` directly. Single-writer engines (PowDB) implement it with
|
|
76
|
+
* `AsyncLocalStorage.run()` to plant their re-entrancy marker so that it
|
|
77
|
+
* exists ONLY inside the callback's async subtree: a transaction opened
|
|
78
|
+
* from inside the callback is detected as re-entrant (typed E017), while
|
|
79
|
+
* the CALLER's context stays unmarked, so same-tick sibling transactions
|
|
80
|
+
* queue FIFO instead of being falsely flagged. Absent on pg and every other
|
|
81
|
+
* engine, in which case the callback runs unwrapped (zero behavior change).
|
|
82
|
+
*/
|
|
83
|
+
wrapTransactionCallback?<R>(fn: () => Promise<R>): Promise<R>;
|
|
71
84
|
}
|
|
72
85
|
/**
|
|
73
86
|
* Minimal pg-compatible pool. Pass any driver that satisfies this interface
|
package/dist/client.js
CHANGED
|
@@ -775,7 +775,12 @@ export class TurbineClient {
|
|
|
775
775
|
try {
|
|
776
776
|
await client.query(this.dialect.beginStatement());
|
|
777
777
|
began = true;
|
|
778
|
-
|
|
778
|
+
// Engine seam: single-writer engines scope their transaction re-entrancy
|
|
779
|
+
// marker to the callback's async subtree (see
|
|
780
|
+
// PgCompatPoolClient.wrapTransactionCallback). Absent everywhere else.
|
|
781
|
+
const wrap = client.wrapTransactionCallback;
|
|
782
|
+
// `.call` erases the generic, so the callback's Promise<T> is re-asserted.
|
|
783
|
+
const result = wrap ? (await wrap.call(client, () => fn(client))) : await fn(client);
|
|
779
784
|
await client.query(this.dialect.commitStatement());
|
|
780
785
|
return result;
|
|
781
786
|
}
|
|
@@ -868,6 +873,12 @@ export class TurbineClient {
|
|
|
868
873
|
}
|
|
869
874
|
}
|
|
870
875
|
let result;
|
|
876
|
+
// Engine seam: when the checked-out connection exposes
|
|
877
|
+
// wrapTransactionCallback (single-writer engines such as PowDB), run the user
|
|
878
|
+
// callback through it so the engine can scope its re-entrancy marker to
|
|
879
|
+
// the callback's async subtree. All other drivers: plain fn(tx).
|
|
880
|
+
const wrap = client.wrapTransactionCallback;
|
|
881
|
+
const runCallback = () => (wrap ? wrap.call(client, () => fn(tx)) : fn(tx));
|
|
871
882
|
if (timeout) {
|
|
872
883
|
// Race between the function and a timeout. If the timeout fires we
|
|
873
884
|
// need to actually abort the in-flight query — otherwise the backend
|
|
@@ -889,14 +900,14 @@ export class TurbineClient {
|
|
|
889
900
|
}, timeout);
|
|
890
901
|
});
|
|
891
902
|
try {
|
|
892
|
-
result = await Promise.race([
|
|
903
|
+
result = await Promise.race([runCallback(), timeoutPromise]);
|
|
893
904
|
}
|
|
894
905
|
finally {
|
|
895
906
|
clearTimeout(timer);
|
|
896
907
|
}
|
|
897
908
|
}
|
|
898
909
|
else {
|
|
899
|
-
result = await
|
|
910
|
+
result = await runCallback();
|
|
900
911
|
}
|
|
901
912
|
await client.query(this.dialect.commitStatement());
|
|
902
913
|
if (this.logging) {
|
package/dist/index.d.ts
CHANGED
|
@@ -43,7 +43,7 @@ export { type IntrospectOptions, introspect } from './introspect.js';
|
|
|
43
43
|
export { executeNestedCreate, executeNestedUpdate, hasRelationFields, type NestedWriteContext, } from './nested-write.js';
|
|
44
44
|
export type { ObserveConfig, ObserveHandle } from './observe.js';
|
|
45
45
|
export { executePipeline, type PipelineOptions, type PipelineResults, pipelineSupported } from './pipeline.js';
|
|
46
|
-
export { type AggregateArgs, type AggregateResult, type ArrayFilter, type ConnectOrCreateOp, type CountArgs, type CreateArgs, type CreateDataInput, type CreateManyArgs, type DeferredQuery, type DeleteArgs, type DeleteManyArgs, type FieldResult, type FindManyArgs, type FindManyStreamArgs, type FindUniqueArgs, type GlobalFilters, type GroupByArgs, type HavingClause, type JsonFilter, type MiddlewareFn, type NestedCreateOp, type NestedUpdateOp, type NestedUpdateOpItem, type NestedUpsertOpItem, type OmitResult, type OrderByClause, type OrderDirection, type QueryEvent, type QueryEventListener, QueryInterface, type QueryResult, type RelationDescriptor, type RelationFilter, type SelectResult, type SkipGlobalFilters, type TextSearchFilter, type TypedWithClause, type UpdateArgs, type UpdateDataInput, type UpdateInput, type UpdateManyArgs, type UpdateOperatorInput, type UpsertArgs, type VectorDistanceFilter, type VectorFilter, type VectorMetric, type VectorOrderBy, type VectorOrderByDistance, type WhereClause, type WhereOperator, type WhereValue, type WithClause, type WithOptions, type WithResult, } from './query/index.js';
|
|
46
|
+
export { type AggregateArgs, type AggregateResult, type ArrayFilter, type ColumnRef, type ConnectOrCreateOp, type CountArgs, type CreateArgs, type CreateDataInput, type CreateManyArgs, type DeferredQuery, type DeleteArgs, type DeleteManyArgs, type FieldResult, type FindManyArgs, type FindManyStreamArgs, type FindUniqueArgs, type GlobalFilters, type GroupByArgs, type HavingClause, type JsonFilter, type JsonPathOrderBy, type MiddlewareFn, type NestedCreateOp, type NestedUpdateOp, type NestedUpdateOpItem, type NestedUpsertOpItem, type OmitResult, type OrderByClause, type OrderDirection, type QueryEvent, type QueryEventListener, QueryInterface, type QueryResult, type RelationDescriptor, type RelationFilter, type SelectResult, type SkipGlobalFilters, type TextSearchFilter, type TypedWithClause, type UpdateArgs, type UpdateDataInput, type UpdateInput, type UpdateManyArgs, type UpdateOperatorInput, type UpsertArgs, type VectorDistanceFilter, type VectorFilter, type VectorMetric, type VectorOrderBy, type VectorOrderByDistance, type WhereClause, type WhereOperator, type WhereValue, type WithClause, type WithOptions, type WithResult, } from './query/index.js';
|
|
47
47
|
export { type ActiveSubscription, type NotificationHandler, type Subscription, validateChannel } from './realtime.js';
|
|
48
48
|
export type { CheckMetadata, ColumnMetadata, IndexMetadata, ReferentialAction, RelationDef, SchemaMetadata, TableMetadata, } from './schema.js';
|
|
49
49
|
export { camelToSnake, isDateType, normalizeKeyColumns, pgArrayType, pgTypeToTs, singularize, snakeToCamel, snakeToPascal, } from './schema.js';
|
package/dist/mssql.js
CHANGED
|
@@ -745,11 +745,32 @@ function buildForJsonSubquery(dialect, ctx) {
|
|
|
745
745
|
if (hasOrder) {
|
|
746
746
|
const orderBy = orderEntries
|
|
747
747
|
.map(([k, dir]) => {
|
|
748
|
-
|
|
748
|
+
// FOR JSON nested ordering supports plain directions and { sort }
|
|
749
|
+
// specs only. Object shapes the core builder compiles with params
|
|
750
|
+
// (JSON-path / vector / relation ordering) must throw here: the
|
|
751
|
+
// shared param-collect mirror is gated on the native path, so a
|
|
752
|
+
// silently-ignored object would desync SQL text from params.
|
|
753
|
+
let rawDir = dir;
|
|
754
|
+
if (typeof dir === 'object' && dir !== null) {
|
|
755
|
+
const sortValue = dir.sort;
|
|
756
|
+
if (typeof sortValue !== 'string') {
|
|
757
|
+
throw new ValidationError(`[turbine] Nested orderBy on "${k}" (table "${targetTable}"): only plain directions and ` +
|
|
758
|
+
`{ sort } specs are supported inside a relation orderBy on SQL Server.`);
|
|
759
|
+
}
|
|
760
|
+
if (dir.nulls !== undefined) {
|
|
761
|
+
throw new UnsupportedFeatureError('NULLS FIRST/LAST ordering', 'sqlserver', 'Explicit nulls placement in orderBy is only available on PostgreSQL and SQLite.');
|
|
762
|
+
}
|
|
763
|
+
rawDir = sortValue;
|
|
764
|
+
}
|
|
765
|
+
// columnMap-first resolution (camelToSnake fallback): matches the
|
|
766
|
+
// core builder's nested orderBy path so camelCase-named DB columns
|
|
767
|
+
// resolve on SQL Server too.
|
|
768
|
+
const col = targetMeta.columnMap[k] ?? camelToSnake(k);
|
|
749
769
|
if (!targetMeta.allColumns.includes(col)) {
|
|
750
|
-
throw new ValidationError(`[turbine] Unknown
|
|
770
|
+
throw new ValidationError(`[turbine] Unknown field "${k}" in orderBy on table "${targetTable}". ` +
|
|
771
|
+
`Known fields: ${Object.keys(targetMeta.columnMap).join(', ') || '(none)'}.`);
|
|
751
772
|
}
|
|
752
|
-
const safeDir = String(
|
|
773
|
+
const safeDir = String(rawDir).toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
753
774
|
return `${a}.${q(col)} ${safeDir}`;
|
|
754
775
|
})
|
|
755
776
|
.join(', ');
|
package/dist/powdb.d.ts
CHANGED
|
@@ -120,6 +120,18 @@ interface PowdbClientPool {
|
|
|
120
120
|
withClient<T>(fn: (c: PowdbClient) => Promise<T>): Promise<T>;
|
|
121
121
|
close(): Promise<void>;
|
|
122
122
|
}
|
|
123
|
+
interface PowdbModule {
|
|
124
|
+
Client: {
|
|
125
|
+
connect(opts: PowdbConnOptions): Promise<PowdbClient>;
|
|
126
|
+
};
|
|
127
|
+
Pool: new (opts: PowdbConnOptions & {
|
|
128
|
+
max?: number;
|
|
129
|
+
}) => PowdbClientPool;
|
|
130
|
+
isPowDBError?(err: unknown): err is {
|
|
131
|
+
code: string;
|
|
132
|
+
message: string;
|
|
133
|
+
};
|
|
134
|
+
}
|
|
123
135
|
/** Connection options for {@link turbinePowDB} — host/port, not a connection string. */
|
|
124
136
|
export interface PowdbConnOptions {
|
|
125
137
|
host: string;
|
|
@@ -260,8 +272,22 @@ export declare class PowdbPool implements PgCompatPool {
|
|
|
260
272
|
private readonly txGate;
|
|
261
273
|
/** Hold taken by a `begin` issued via `query()` directly (no checked-out client). */
|
|
262
274
|
private poolHold;
|
|
275
|
+
/**
|
|
276
|
+
* Clients currently checked out via {@link connect}. The driver pool's
|
|
277
|
+
* `close()` only closes IDLE clients (checked-out ones are documented as the
|
|
278
|
+
* caller's responsibility), so {@link end} destroys these explicitly;
|
|
279
|
+
* otherwise a `disconnect()` racing an unreleased connection would leave a
|
|
280
|
+
* live socket holding the process open until the server's idle timeout.
|
|
281
|
+
*/
|
|
282
|
+
private readonly checkedOut;
|
|
263
283
|
constructor(pool: PowdbClientPool, toParam?: (v: unknown, i: number) => PowdbParam, options?: PowdbPoolOptions);
|
|
264
284
|
query(text: QueryArg, values?: unknown[]): Promise<any>;
|
|
285
|
+
/**
|
|
286
|
+
* Typed guard mirroring {@link PowdbEmbeddedPool}: after `end()` the driver
|
|
287
|
+
* pool throws a raw `Error('pool closed')` that {@link wrapPowdbError}
|
|
288
|
+
* cannot classify — surface the same ConnectionError on both transports.
|
|
289
|
+
*/
|
|
290
|
+
private assertOpen;
|
|
265
291
|
connect(): Promise<PgCompatPoolClient>;
|
|
266
292
|
end(): Promise<void>;
|
|
267
293
|
}
|
|
@@ -361,6 +387,13 @@ export interface TurbinePowdbOptions extends Pick<TurbineConfig, 'logging' | 'de
|
|
|
361
387
|
* E017 — queueing that shape would deadlock.
|
|
362
388
|
*/
|
|
363
389
|
transactionQueueTimeoutMs?: number;
|
|
390
|
+
/**
|
|
391
|
+
* Driver-module injection for the networked target forms (URL / host+port):
|
|
392
|
+
* bypasses the dynamic `import('@zvndev/powdb-client')` and uses this object
|
|
393
|
+
* as the driver module instead. Intended for tests (a fake pool that counts
|
|
394
|
+
* connections) and advanced embedding; everyday callers never set it.
|
|
395
|
+
*/
|
|
396
|
+
powdbClientModule?: PowdbModule;
|
|
364
397
|
}
|
|
365
398
|
/**
|
|
366
399
|
* Selects the **embedded** transport — an in-process `@zvndev/powdb-embedded`
|
package/dist/powdb.js
CHANGED
|
@@ -467,6 +467,11 @@ export function wrapPowdbError(err) {
|
|
|
467
467
|
const m = /column ['"]?(\w+)['"]?/i.exec(msg);
|
|
468
468
|
return new NotNullViolationError({ column: m?.[1], cause: err });
|
|
469
469
|
}
|
|
470
|
+
// Driver pool lifecycle errors (acquire after close, acquire timeout) carry
|
|
471
|
+
// no .code — classify by message so both transports surface E004.
|
|
472
|
+
if (/pool closed|pool acquire timeout/i.test(msg)) {
|
|
473
|
+
return new ConnectionError(`[turbine] PowDB connection unavailable: ${msg}`);
|
|
474
|
+
}
|
|
470
475
|
// Server-side transaction-gate wait bound (PowDB ≥ 0.10, default 5s): another
|
|
471
476
|
// connection held the single global write lock past the server's
|
|
472
477
|
// --tx-wait-timeout-ms. Retryable timeout, not a query defect.
|
|
@@ -544,6 +549,13 @@ function reentrantTransactionError() {
|
|
|
544
549
|
* waits without limit.
|
|
545
550
|
*/
|
|
546
551
|
export const DEFAULT_TX_QUEUE_TIMEOUT_MS = 30_000;
|
|
552
|
+
/**
|
|
553
|
+
* Upper bound on the best-effort `rollback` a release-with-open-hold fires
|
|
554
|
+
* before handing the gate to the next queued transaction. Keeps a dead socket
|
|
555
|
+
* from wedging the FIFO queue while still letting the engine drop its global
|
|
556
|
+
* write lock cleanly in the normal case.
|
|
557
|
+
*/
|
|
558
|
+
const RELEASE_ROLLBACK_TIMEOUT_MS = 2_000;
|
|
547
559
|
const powdbTxStorage = new AsyncLocalStorage();
|
|
548
560
|
/**
|
|
549
561
|
* FIFO gate serializing transactions across a whole pool. PowDB holds one
|
|
@@ -561,25 +573,35 @@ const powdbTxStorage = new AsyncLocalStorage();
|
|
|
561
573
|
* timeout, then returns a {@link PowdbTxHold} the caller finishes on
|
|
562
574
|
* commit / rollback / connection release.
|
|
563
575
|
*
|
|
564
|
-
* Context propagation
|
|
565
|
-
*
|
|
566
|
-
*
|
|
567
|
-
*
|
|
568
|
-
*
|
|
569
|
-
*
|
|
570
|
-
*
|
|
571
|
-
*
|
|
572
|
-
*
|
|
576
|
+
* Context propagation: `acquire()` only READS the async context (the
|
|
577
|
+
* chain-walking check in its prologue); it never writes it. The marker is
|
|
578
|
+
* planted by the pool's `wrapTransactionCallback` (`TurbineClient` invokes
|
|
579
|
+
* the user callback as `powdbTxStorage.run(hold.ctx, fn)`), so it exists
|
|
580
|
+
* exclusively inside the transaction CALLBACK's async subtree. Everything
|
|
581
|
+
* launched from inside the callback (table ops on the `tx` client, a
|
|
582
|
+
* fire-and-forget `db.$transaction`, nested-write implicit transactions)
|
|
583
|
+
* inherits it; the CALLER's context stays unmarked. This is load-bearing: the
|
|
584
|
+
* pre-0.31 implementation used `enterWith()` in acquire's prologue, which
|
|
585
|
+
* mutates the caller's shared context. On a cold client the FIRST same-tick
|
|
586
|
+
* burst of `db.$transaction` calls saw call #1's live marker from every
|
|
587
|
+
* sibling and falsely threw re-entrant E017 (9/10 rejected in production;
|
|
588
|
+
* one warm-up transaction masked it because its pruned `done` marker changed
|
|
589
|
+
* the propagation shape). With `run()` the sibling contexts are unmarked by
|
|
590
|
+
* construction, so they queue FIFO as intended. Markers form a chain
|
|
591
|
+
* ({@link PowdbTxContext.parent}) so transactions nested across DIFFERENT
|
|
592
|
+
* pools cannot shadow an outer marker on this gate; the re-entrancy check
|
|
593
|
+
* walks every live ancestor.
|
|
573
594
|
*
|
|
574
|
-
* **Residual limitation:** a
|
|
575
|
-
*
|
|
576
|
-
* continuations captured their
|
|
577
|
-
*
|
|
578
|
-
*
|
|
579
|
-
*
|
|
580
|
-
*
|
|
581
|
-
*
|
|
582
|
-
*
|
|
595
|
+
* **Residual limitation:** a transaction begun OUTSIDE a `$transaction`
|
|
596
|
+
* callback plants no marker (a manual raw `begin` span, or a worker loop
|
|
597
|
+
* whose continuations captured their context before the transaction opened).
|
|
598
|
+
* A deadlocking re-entrant begin from such a context cannot be told apart
|
|
599
|
+
* from a legitimate independent concurrent transaction: it queues FIFO and,
|
|
600
|
+
* because the open transaction is awaiting it, times out after
|
|
601
|
+
* `transactionQueueTimeoutMs` with a typed {@link TimeoutError} rather than
|
|
602
|
+
* throwing E017 instantly. The 30s default is the backstop for exactly this
|
|
603
|
+
* case: do not set `transactionQueueTimeoutMs: 0` (wait forever) in code
|
|
604
|
+
* paths that may start transactions from unmarked contexts.
|
|
583
605
|
*/
|
|
584
606
|
class PowdbTxGate {
|
|
585
607
|
queueTimeoutMs;
|
|
@@ -589,12 +611,14 @@ class PowdbTxGate {
|
|
|
589
611
|
this.queueTimeoutMs = queueTimeoutMs;
|
|
590
612
|
}
|
|
591
613
|
/**
|
|
592
|
-
* Take a place in the transaction queue.
|
|
593
|
-
*
|
|
594
|
-
*
|
|
614
|
+
* Take a place in the transaction queue. The prologue walks the caller's
|
|
615
|
+
* marker chain (planted around transaction callbacks by the pool's
|
|
616
|
+
* `wrapTransactionCallback`) and throws re-entrant E017 when any live
|
|
617
|
+
* ancestor holds THIS gate. It never marks the caller's context itself;
|
|
618
|
+
* the returned hold carries the fresh marker (`hold.ctx`) for
|
|
619
|
+
* `wrapTransactionCallback` to scope around the user callback.
|
|
595
620
|
*/
|
|
596
621
|
async acquire() {
|
|
597
|
-
// --- synchronous section (runs before the caller's first await) ---
|
|
598
622
|
// Walk the WHOLE marker chain, not just the innermost marker: with two
|
|
599
623
|
// pools, dbA-tx → dbB-tx → dbA-begin leaves dbB's marker innermost, but
|
|
600
624
|
// the dbA ancestor is still open — queueing the inner dbA begin behind it
|
|
@@ -611,7 +635,6 @@ class PowdbTxGate {
|
|
|
611
635
|
}
|
|
612
636
|
}
|
|
613
637
|
const ctx = { gate: this, done: false, parent };
|
|
614
|
-
powdbTxStorage.enterWith(ctx);
|
|
615
638
|
let handOff;
|
|
616
639
|
const finished = new Promise((resolve) => {
|
|
617
640
|
handOff = resolve;
|
|
@@ -619,6 +642,7 @@ class PowdbTxGate {
|
|
|
619
642
|
const ahead = this.tail;
|
|
620
643
|
this.tail = ahead.then(() => finished);
|
|
621
644
|
const hold = {
|
|
645
|
+
ctx,
|
|
622
646
|
finish: () => {
|
|
623
647
|
if (ctx.done)
|
|
624
648
|
return;
|
|
@@ -696,6 +720,14 @@ export class PowdbPool {
|
|
|
696
720
|
txGate;
|
|
697
721
|
/** Hold taken by a `begin` issued via `query()` directly (no checked-out client). */
|
|
698
722
|
poolHold = null;
|
|
723
|
+
/**
|
|
724
|
+
* Clients currently checked out via {@link connect}. The driver pool's
|
|
725
|
+
* `close()` only closes IDLE clients (checked-out ones are documented as the
|
|
726
|
+
* caller's responsibility), so {@link end} destroys these explicitly;
|
|
727
|
+
* otherwise a `disconnect()` racing an unreleased connection would leave a
|
|
728
|
+
* live socket holding the process open until the server's idle timeout.
|
|
729
|
+
*/
|
|
730
|
+
checkedOut = new Set();
|
|
699
731
|
constructor(pool, toParam = (v) => toPowdbParam(v), options = {}) {
|
|
700
732
|
this.pool = pool;
|
|
701
733
|
this.toParam = toParam;
|
|
@@ -703,12 +735,13 @@ export class PowdbPool {
|
|
|
703
735
|
}
|
|
704
736
|
// biome-ignore lint/suspicious/noExplicitAny: pg-compat query is generic over the row shape.
|
|
705
737
|
async query(text, values) {
|
|
738
|
+
this.assertOpen();
|
|
706
739
|
const { text: powql, params } = normalizeQueryArgs(text, values);
|
|
707
740
|
const ctl = txControl(powql);
|
|
708
741
|
if (ctl === 'begin') {
|
|
709
|
-
// Gate BEFORE touching the engine: a
|
|
710
|
-
//
|
|
711
|
-
//
|
|
742
|
+
// Gate BEFORE touching the engine: a begin from inside an active
|
|
743
|
+
// transaction callback throws re-entrant E017 fast; an independent
|
|
744
|
+
// concurrent one waits its FIFO turn.
|
|
712
745
|
this.poolHold = await this.txGate.acquire();
|
|
713
746
|
}
|
|
714
747
|
if ((ctl === 'commit' || ctl === 'rollback') && this.poolHold === null) {
|
|
@@ -736,8 +769,26 @@ export class PowdbPool {
|
|
|
736
769
|
}
|
|
737
770
|
}
|
|
738
771
|
}
|
|
772
|
+
/**
|
|
773
|
+
* Typed guard mirroring {@link PowdbEmbeddedPool}: after `end()` the driver
|
|
774
|
+
* pool throws a raw `Error('pool closed')` that {@link wrapPowdbError}
|
|
775
|
+
* cannot classify — surface the same ConnectionError on both transports.
|
|
776
|
+
*/
|
|
777
|
+
assertOpen() {
|
|
778
|
+
if (this.closed) {
|
|
779
|
+
throw new ConnectionError('[turbine] The PowDB pool is closed — disconnect() was already called on this client.');
|
|
780
|
+
}
|
|
781
|
+
}
|
|
739
782
|
async connect() {
|
|
740
|
-
|
|
783
|
+
this.assertOpen();
|
|
784
|
+
let client;
|
|
785
|
+
try {
|
|
786
|
+
client = await this.pool.acquire();
|
|
787
|
+
}
|
|
788
|
+
catch (err) {
|
|
789
|
+
throw wrapPowdbError(err);
|
|
790
|
+
}
|
|
791
|
+
this.checkedOut.add(client);
|
|
741
792
|
let broken = false;
|
|
742
793
|
/** The gate hold of the transaction begun through THIS connection (if any). */
|
|
743
794
|
let hold = null;
|
|
@@ -759,9 +810,10 @@ export class PowdbPool {
|
|
|
759
810
|
const { text: powql, params } = normalizeQueryArgs(text, values);
|
|
760
811
|
const ctl = txControl(powql);
|
|
761
812
|
if (ctl === 'begin') {
|
|
762
|
-
// Gate BEFORE hitting the engine
|
|
763
|
-
//
|
|
764
|
-
//
|
|
813
|
+
// Gate BEFORE hitting the engine: a begin from inside an active
|
|
814
|
+
// transaction callback throws re-entrant E017 fast instead of
|
|
815
|
+
// blocking on the global write lock the open tx holds; an
|
|
816
|
+
// independent concurrent begin queues FIFO.
|
|
765
817
|
hold = await this.txGate.acquire();
|
|
766
818
|
}
|
|
767
819
|
if ((ctl === 'commit' || ctl === 'rollback') && hold === null) {
|
|
@@ -789,16 +841,60 @@ export class PowdbPool {
|
|
|
789
841
|
}
|
|
790
842
|
}
|
|
791
843
|
},
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
hold?.
|
|
844
|
+
// Single-writer re-entrancy scoping: TurbineClient runs the user's
|
|
845
|
+
// transaction callback through this, so the gate's marker lives ONLY
|
|
846
|
+
// in the callback's async subtree (everything inside it, tx table ops
|
|
847
|
+
// and fire-and-forget db.$transaction alike, inherits it; the caller's
|
|
848
|
+
// context stays unmarked). `hold.ctx` is the same object `finish()` flips, so
|
|
849
|
+
// done-pruning keeps working for contexts that outlive the callback.
|
|
850
|
+
wrapTransactionCallback: (fn) => {
|
|
851
|
+
const ctx = hold?.ctx;
|
|
852
|
+
return ctx ? powdbTxStorage.run(ctx, fn) : fn();
|
|
853
|
+
},
|
|
854
|
+
release: (err) => {
|
|
855
|
+
// Releasing this connection ends its transaction scope. pg semantics:
|
|
856
|
+
// a truthy `err` means "destroy, don't re-idle" — client.ts's
|
|
857
|
+
// $transaction timeout path relies on that to keep an abandoned
|
|
858
|
+
// callback's connection out of the pool. Additionally, an OPEN hold
|
|
859
|
+
// here means the tx begun on this connection never saw commit/rollback
|
|
860
|
+
// (timeout teardown or caller bug): fire a best-effort bounded
|
|
861
|
+
// `rollback` FIRST so the engine drops its global write lock and the
|
|
862
|
+
// server-side transaction ends (destroying the socket alone leaves it
|
|
863
|
+
// open until the server's idle timeout), THEN hand the gate to the
|
|
864
|
+
// next queued transaction. If the rollback fails or times out the
|
|
865
|
+
// connection is treated as broken and destroyed. Never throws — a
|
|
866
|
+
// teardown error must not mask the transaction's real outcome.
|
|
867
|
+
const openHold = hold;
|
|
800
868
|
hold = null;
|
|
801
|
-
|
|
869
|
+
this.checkedOut.delete(client);
|
|
870
|
+
const teardown = async () => {
|
|
871
|
+
let rolledBack = false;
|
|
872
|
+
if (openHold) {
|
|
873
|
+
try {
|
|
874
|
+
await Promise.race([
|
|
875
|
+
client.query('rollback', []).then(() => {
|
|
876
|
+
rolledBack = true;
|
|
877
|
+
}),
|
|
878
|
+
new Promise((resolve) => {
|
|
879
|
+
const t = setTimeout(resolve, RELEASE_ROLLBACK_TIMEOUT_MS);
|
|
880
|
+
t.unref?.();
|
|
881
|
+
}),
|
|
882
|
+
]);
|
|
883
|
+
}
|
|
884
|
+
catch {
|
|
885
|
+
/* best-effort */
|
|
886
|
+
}
|
|
887
|
+
openHold.finish();
|
|
888
|
+
}
|
|
889
|
+
const mustDestroy = broken || Boolean(err) || (openHold !== null && !rolledBack);
|
|
890
|
+
try {
|
|
891
|
+
await (mustDestroy ? this.pool.destroy(client) : this.pool.release(client));
|
|
892
|
+
}
|
|
893
|
+
catch {
|
|
894
|
+
/* the pool may already be closed */
|
|
895
|
+
}
|
|
896
|
+
};
|
|
897
|
+
void teardown();
|
|
802
898
|
},
|
|
803
899
|
};
|
|
804
900
|
}
|
|
@@ -806,7 +902,16 @@ export class PowdbPool {
|
|
|
806
902
|
if (this.closed)
|
|
807
903
|
return;
|
|
808
904
|
this.closed = true;
|
|
905
|
+
// close() rejects pending waiters and closes every IDLE client…
|
|
809
906
|
await this.pool.close();
|
|
907
|
+
// …but NOT checked-out ones (documented in @zvndev/powdb-client: "callers
|
|
908
|
+
// that still hold one when close() is called are responsible for closing
|
|
909
|
+
// it themselves"). Destroy any stragglers so end() never leaves a live
|
|
910
|
+
// socket keeping the process alive until the server's idle timeout.
|
|
911
|
+
for (const client of this.checkedOut) {
|
|
912
|
+
this.pool.destroy(client);
|
|
913
|
+
}
|
|
914
|
+
this.checkedOut.clear();
|
|
810
915
|
}
|
|
811
916
|
}
|
|
812
917
|
/** Normalize the embedded addon's loosely-typed result into a {@link PowdbResult}. */
|
|
@@ -938,11 +1043,14 @@ export class PowdbEmbeddedPool {
|
|
|
938
1043
|
* transaction is holding.
|
|
939
1044
|
*/
|
|
940
1045
|
async run(powql, params, holdRef) {
|
|
1046
|
+
if (this.closed) {
|
|
1047
|
+
throw new ConnectionError('[turbine] The PowDB embedded pool is closed: disconnect() was already called on this client.');
|
|
1048
|
+
}
|
|
941
1049
|
const ctl = txControl(powql);
|
|
942
1050
|
if (ctl === 'begin') {
|
|
943
|
-
// Gate BEFORE hitting the engine
|
|
944
|
-
//
|
|
945
|
-
//
|
|
1051
|
+
// Gate BEFORE hitting the engine: a begin from inside an active
|
|
1052
|
+
// transaction callback throws re-entrant E017 fast; independent
|
|
1053
|
+
// concurrent ones wait their FIFO turn.
|
|
946
1054
|
holdRef.hold = await this.txGate.acquire();
|
|
947
1055
|
}
|
|
948
1056
|
if ((ctl === 'commit' || ctl === 'rollback') && holdRef.hold === null) {
|
|
@@ -988,11 +1096,31 @@ export class PowdbEmbeddedPool {
|
|
|
988
1096
|
const { text: powql, params } = normalizeQueryArgs(text, values);
|
|
989
1097
|
return this.run(powql, params, holdRef);
|
|
990
1098
|
},
|
|
1099
|
+
// Scope the gate's re-entrancy marker to the user callback's async
|
|
1100
|
+
// subtree (see PowdbPool.connect(), identical contract).
|
|
1101
|
+
wrapTransactionCallback: (fn) => {
|
|
1102
|
+
const ctx = holdRef.hold?.ctx;
|
|
1103
|
+
return ctx ? powdbTxStorage.run(ctx, fn) : fn();
|
|
1104
|
+
},
|
|
991
1105
|
release: () => {
|
|
992
1106
|
// End-of-scope safety net (see PowdbPool.connect()): a tx torn down
|
|
993
|
-
// without an explicit commit/rollback must not wedge the queue
|
|
994
|
-
|
|
995
|
-
|
|
1107
|
+
// without an explicit commit/rollback must not wedge the queue — and
|
|
1108
|
+
// on the ONE shared embedded handle its open engine transaction must
|
|
1109
|
+
// actually be rolled back before the gate moves on, or the next
|
|
1110
|
+
// transaction's work interleaves into it. run() owns the
|
|
1111
|
+
// finish/null-out in its commit/rollback finally; the .finally here
|
|
1112
|
+
// is the fallback when run() itself rejects.
|
|
1113
|
+
const h = holdRef.hold;
|
|
1114
|
+
if (!h)
|
|
1115
|
+
return;
|
|
1116
|
+
void this.run('rollback', [], holdRef)
|
|
1117
|
+
.catch(() => {
|
|
1118
|
+
/* best-effort */
|
|
1119
|
+
})
|
|
1120
|
+
.finally(() => {
|
|
1121
|
+
h.finish();
|
|
1122
|
+
holdRef.hold = null;
|
|
1123
|
+
});
|
|
996
1124
|
},
|
|
997
1125
|
};
|
|
998
1126
|
}
|
|
@@ -1000,8 +1128,11 @@ export class PowdbEmbeddedPool {
|
|
|
1000
1128
|
if (this.closed)
|
|
1001
1129
|
return;
|
|
1002
1130
|
// The addon exposes no explicit close — drop the reference and let GC /
|
|
1003
|
-
// the engine's checkpoint flush.
|
|
1004
|
-
//
|
|
1131
|
+
// the engine's checkpoint flush. Marking the pool closed makes later
|
|
1132
|
+
// queries fail with a typed ConnectionError instead of silently running
|
|
1133
|
+
// against a handle the caller believes is gone. Caveat: durability is
|
|
1134
|
+
// checkpoint-bound, so hold the process open long enough for the final
|
|
1135
|
+
// WAL flush in short scripts.
|
|
1005
1136
|
this.closed = true;
|
|
1006
1137
|
}
|
|
1007
1138
|
}
|
|
@@ -1106,7 +1237,7 @@ export async function turbinePowDB(target, schema, options = {}) {
|
|
|
1106
1237
|
let owns = false;
|
|
1107
1238
|
const poolOptions = { transactionQueueTimeoutMs: options.transactionQueueTimeoutMs };
|
|
1108
1239
|
if (typeof target === 'string') {
|
|
1109
|
-
const mod = await loadPowdb();
|
|
1240
|
+
const mod = options.powdbClientModule ?? (await loadPowdb());
|
|
1110
1241
|
const clientPool = new mod.Pool({ ...parsePowdbUrl(target), max: options.connectionLimit ?? 10 });
|
|
1111
1242
|
await assertNetworkedVersion(clientPool);
|
|
1112
1243
|
pool = new PowdbPool(clientPool, undefined, poolOptions);
|
|
@@ -1124,7 +1255,7 @@ export async function turbinePowDB(target, schema, options = {}) {
|
|
|
1124
1255
|
pool = new PowdbPool(target, undefined, poolOptions);
|
|
1125
1256
|
}
|
|
1126
1257
|
else {
|
|
1127
|
-
const mod = await loadPowdb();
|
|
1258
|
+
const mod = options.powdbClientModule ?? (await loadPowdb());
|
|
1128
1259
|
const clientPool = new mod.Pool({ ...target, max: options.connectionLimit ?? 10 });
|
|
1129
1260
|
await assertNetworkedVersion(clientPool);
|
|
1130
1261
|
pool = new PowdbPool(clientPool, undefined, poolOptions);
|
|
@@ -1142,7 +1273,23 @@ export async function turbinePowDB(target, schema, options = {}) {
|
|
|
1142
1273
|
warnOnUnlimited: options.warnOnUnlimited,
|
|
1143
1274
|
queryInterfaceFactory,
|
|
1144
1275
|
}, schema);
|
|
1145
|
-
if (
|
|
1276
|
+
if (owns) {
|
|
1277
|
+
// Turbine built this pool / embedded handle, so disconnect()/end() must
|
|
1278
|
+
// close it. client.ts sees TurbineConfig.pool as EXTERNAL (ownsPool =
|
|
1279
|
+
// false) and skips pool.end(); before this patch an owned networked
|
|
1280
|
+
// client leaked its live socket(s) on disconnect(), holding the process
|
|
1281
|
+
// open until powdb-server's idle timeout (~300s) closed them. Consistent
|
|
1282
|
+
// with turbineMssql's owned-pool patch.
|
|
1283
|
+
const baseDisconnect = client.disconnect.bind(client);
|
|
1284
|
+
const close = async () => {
|
|
1285
|
+
await baseDisconnect();
|
|
1286
|
+
await pool.end();
|
|
1287
|
+
};
|
|
1288
|
+
const patch = client;
|
|
1289
|
+
patch.disconnect = close;
|
|
1290
|
+
patch.end = close;
|
|
1291
|
+
}
|
|
1292
|
+
else {
|
|
1146
1293
|
// Injected pool — the caller owns its lifecycle.
|
|
1147
1294
|
client.disconnect = async () => { };
|
|
1148
1295
|
}
|