turbine-orm 0.32.0 → 0.32.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -19
- package/dist/cjs/cli/config.js +76 -16
- package/dist/cjs/cli/index.js +174 -19
- package/dist/cjs/client.js +16 -0
- package/dist/cjs/errors.js +62 -4
- package/dist/cjs/generate.js +2 -1
- package/dist/cjs/powql.js +1 -1
- package/dist/cjs/query/builder.js +235 -41
- package/dist/cli/config.d.ts +53 -1
- package/dist/cli/config.js +73 -16
- package/dist/cli/index.d.ts +68 -0
- package/dist/cli/index.js +173 -21
- package/dist/client.js +16 -0
- package/dist/errors.d.ts +22 -2
- package/dist/errors.js +62 -4
- package/dist/generate.js +2 -1
- package/dist/powql.js +1 -1
- package/dist/query/builder.d.ts +43 -0
- package/dist/query/builder.js +235 -41
- package/dist/query/deferred.d.ts +10 -1
- package/package.json +3 -2
|
@@ -56,6 +56,105 @@ const filters_js_1 = require("./filters.js");
|
|
|
56
56
|
const utils_js_1 = require("./utils.js");
|
|
57
57
|
/** Relations already warned about missing FK indexes (once per process, dev only). */
|
|
58
58
|
const unindexedRelationWarned = new Set();
|
|
59
|
+
/**
|
|
60
|
+
* Dev-mode SQL-cache lockstep cross-check gate.
|
|
61
|
+
*
|
|
62
|
+
* The SQL template cache requires three code paths to enumerate where-clause
|
|
63
|
+
* keys identically: `fingerprintWhere` (builds the cache key),
|
|
64
|
+
* `buildWhereClause` (builds SQL + `$N` params on a MISS), and
|
|
65
|
+
* `collectWhereParams` (re-collects params on a HIT without rebuilding). They
|
|
66
|
+
* are synchronized only by convention, and drift has shipped silent
|
|
67
|
+
* wrong-results bugs before (permuted where-key order; an orderBy fingerprint
|
|
68
|
+
* collision). This check catches such drift loudly the moment a cache HIT
|
|
69
|
+
* happens by rebuilding the SQL + params fresh and comparing them against what
|
|
70
|
+
* the cache-hit path produced.
|
|
71
|
+
*
|
|
72
|
+
* Enabled only when `NODE_ENV !== 'production'` (same convention as the other
|
|
73
|
+
* dev-only guards in this file) AND `TURBINE_DISABLE_CACHE_CHECK !== '1'`. The
|
|
74
|
+
* env vars are read inline (not captured once) so tests and perf-sensitive dev
|
|
75
|
+
* traffic can toggle them per process. In production the check never runs, so
|
|
76
|
+
* the hot path is unchanged.
|
|
77
|
+
*/
|
|
78
|
+
function cacheCrossCheckEnabled() {
|
|
79
|
+
return process.env.NODE_ENV !== 'production' && process.env.TURBINE_DISABLE_CACHE_CHECK !== '1';
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Strict structural equality for a single SQL parameter value. Handles the
|
|
83
|
+
* value shapes Turbine binds: primitives (incl. `NaN` and `bigint`), `null`/
|
|
84
|
+
* `undefined`, `Date` (by time), `Buffer`/typed arrays (by bytes), arrays
|
|
85
|
+
* (`in` lists, pgvector arrays), and plain objects (JSON filter payloads).
|
|
86
|
+
*/
|
|
87
|
+
function cacheParamValueEqual(a, b) {
|
|
88
|
+
if (a === b)
|
|
89
|
+
return true; // identical ref or equal primitive (covers matching null/undefined)
|
|
90
|
+
if (a === null || b === null || a === undefined || b === undefined)
|
|
91
|
+
return false;
|
|
92
|
+
const ta = typeof a;
|
|
93
|
+
if (ta !== typeof b)
|
|
94
|
+
return false;
|
|
95
|
+
if (ta !== 'object') {
|
|
96
|
+
// Primitives that failed `===`: only NaN is legitimately "equal" to itself.
|
|
97
|
+
return typeof a === 'number' && Number.isNaN(a) && Number.isNaN(b);
|
|
98
|
+
}
|
|
99
|
+
if (a instanceof Date || b instanceof Date) {
|
|
100
|
+
return a instanceof Date && b instanceof Date && a.getTime() === b.getTime();
|
|
101
|
+
}
|
|
102
|
+
const aView = ArrayBuffer.isView(a);
|
|
103
|
+
const bView = ArrayBuffer.isView(b);
|
|
104
|
+
if (aView || bView) {
|
|
105
|
+
if (!aView || !bView)
|
|
106
|
+
return false;
|
|
107
|
+
const ua = a;
|
|
108
|
+
const ub = b;
|
|
109
|
+
if (ua.byteLength !== ub.byteLength)
|
|
110
|
+
return false;
|
|
111
|
+
const va = new Uint8Array(ua.buffer, ua.byteOffset, ua.byteLength);
|
|
112
|
+
const vb = new Uint8Array(ub.buffer, ub.byteOffset, ub.byteLength);
|
|
113
|
+
for (let i = 0; i < va.length; i++) {
|
|
114
|
+
if (va[i] !== vb[i])
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
const aArr = Array.isArray(a);
|
|
120
|
+
const bArr = Array.isArray(b);
|
|
121
|
+
if (aArr || bArr) {
|
|
122
|
+
if (!aArr || !bArr)
|
|
123
|
+
return false;
|
|
124
|
+
const arrA = a;
|
|
125
|
+
const arrB = b;
|
|
126
|
+
if (arrA.length !== arrB.length)
|
|
127
|
+
return false;
|
|
128
|
+
for (let i = 0; i < arrA.length; i++) {
|
|
129
|
+
if (!cacheParamValueEqual(arrA[i], arrB[i]))
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
const objA = a;
|
|
135
|
+
const objB = b;
|
|
136
|
+
const keysA = Object.keys(objA);
|
|
137
|
+
const keysB = Object.keys(objB);
|
|
138
|
+
if (keysA.length !== keysB.length)
|
|
139
|
+
return false;
|
|
140
|
+
for (const k of keysA) {
|
|
141
|
+
if (!Object.hasOwn(objB, k))
|
|
142
|
+
return false;
|
|
143
|
+
if (!cacheParamValueEqual(objA[k], objB[k]))
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
/** Element-wise strict equality of two SQL parameter arrays. */
|
|
149
|
+
function cacheParamsEqual(a, b) {
|
|
150
|
+
if (a.length !== b.length)
|
|
151
|
+
return false;
|
|
152
|
+
for (let i = 0; i < a.length; i++) {
|
|
153
|
+
if (!cacheParamValueEqual(a[i], b[i]))
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
59
158
|
// biome-ignore lint/complexity/noBannedTypes: {} means "no relations known" — intentional for untyped table access
|
|
60
159
|
class QueryInterface {
|
|
61
160
|
pool;
|
|
@@ -64,6 +163,15 @@ class QueryInterface {
|
|
|
64
163
|
tableMeta;
|
|
65
164
|
/** SQL template cache: cacheKey → SqlCacheEntry (sql + prepared statement name) */
|
|
66
165
|
sqlTemplateCache = new utils_js_1.LRUCache(1000);
|
|
166
|
+
/**
|
|
167
|
+
* Whether the most recent {@link acquireSql} call was a cache HIT. Read by
|
|
168
|
+
* {@link crossCheckCache} to decide whether to run the dev-mode lockstep
|
|
169
|
+
* cross-check. Safe as a single mutable flag: each `build*()` method calls
|
|
170
|
+
* `acquireSql` then `crossCheckCache` synchronously with no intervening
|
|
171
|
+
* `await` and no re-entrant `acquireSql` (relation subqueries are built
|
|
172
|
+
* inline, not through the top-level cache).
|
|
173
|
+
*/
|
|
174
|
+
lastCacheHit = false;
|
|
67
175
|
middlewares;
|
|
68
176
|
defaultLimit;
|
|
69
177
|
warnOnUnlimited;
|
|
@@ -382,24 +490,84 @@ class QueryInterface {
|
|
|
382
490
|
* On hit, increments counters and returns the cached entry.
|
|
383
491
|
*
|
|
384
492
|
* When `sqlCache` is disabled, always calls `build()` without caching.
|
|
493
|
+
*
|
|
494
|
+
* `build` receives a fresh `$N` param scratch array. On a miss those params
|
|
495
|
+
* are discarded (the returned params come from each call site's dedicated
|
|
496
|
+
* collect path); the array exists so the build path can number placeholders
|
|
497
|
+
* via `params.length` exactly as it does today. On a HIT, `build` is skipped
|
|
498
|
+
* here but re-run by {@link crossCheckCache} (dev only) with a fresh array to
|
|
499
|
+
* verify the collect path stayed in lockstep with the build path.
|
|
500
|
+
*
|
|
501
|
+
* Sets {@link lastCacheHit} so the caller's `crossCheckCache` knows whether a
|
|
502
|
+
* cross-check is warranted.
|
|
385
503
|
*/
|
|
386
504
|
acquireSql(cacheKey, build) {
|
|
387
505
|
if (!this.sqlCacheEnabled) {
|
|
388
|
-
|
|
506
|
+
this.lastCacheHit = false;
|
|
507
|
+
const sql = build([]);
|
|
389
508
|
this.cacheMisses++;
|
|
390
509
|
return { sql, name: (0, utils_js_1.sqlToPreparedName)(sql) };
|
|
391
510
|
}
|
|
392
511
|
const cached = this.sqlTemplateCache.get(cacheKey);
|
|
393
512
|
if (cached) {
|
|
394
513
|
this.cacheHits++;
|
|
514
|
+
this.lastCacheHit = true;
|
|
395
515
|
return cached;
|
|
396
516
|
}
|
|
397
|
-
|
|
517
|
+
this.lastCacheHit = false;
|
|
518
|
+
const sql = build([]);
|
|
398
519
|
const entry = { sql, name: (0, utils_js_1.sqlToPreparedName)(sql) };
|
|
399
520
|
this.sqlTemplateCache.set(cacheKey, entry);
|
|
400
521
|
this.cacheMisses++;
|
|
401
522
|
return entry;
|
|
402
523
|
}
|
|
524
|
+
/**
|
|
525
|
+
* Dev-mode SQL-cache lockstep cross-check (see {@link cacheCrossCheckEnabled}).
|
|
526
|
+
*
|
|
527
|
+
* Runs only when the most recent {@link acquireSql} was a cache HIT and the
|
|
528
|
+
* check is enabled. Rebuilds the SQL + `$N` params fresh via the same `build`
|
|
529
|
+
* closure the caller passed to `acquireSql`, then compares:
|
|
530
|
+
* (a) the cached SQL string byte-for-byte against the fresh SQL, and
|
|
531
|
+
* (b) the params the cache-hit collect path produced against the fresh
|
|
532
|
+
* build-path params (length and element-wise strict deep-equal).
|
|
533
|
+
*
|
|
534
|
+
* A mismatch means the fingerprint / build / collect paths have drifted out
|
|
535
|
+
* of lockstep (the exact class of bug that has silently corrupted results
|
|
536
|
+
* before), so it throws a {@link ValidationError} (E003) naming the
|
|
537
|
+
* fingerprint, the operation, and both SQL strings (truncated). Failing loud
|
|
538
|
+
* in dev/test is the point. Production never reaches the comparison.
|
|
539
|
+
*
|
|
540
|
+
* @param op human label of the calling build method (for the error message).
|
|
541
|
+
* @param cacheKey the cache fingerprint that HIT.
|
|
542
|
+
* @param entry the cached SQL entry that will be executed.
|
|
543
|
+
* @param build the same closure passed to `acquireSql`; re-run here to
|
|
544
|
+
* capture the fresh build-path SQL + params.
|
|
545
|
+
* @param collectedParams the params the caller's collect path produced.
|
|
546
|
+
*/
|
|
547
|
+
crossCheckCache(op, cacheKey, entry, build, collectedParams) {
|
|
548
|
+
if (!this.lastCacheHit)
|
|
549
|
+
return;
|
|
550
|
+
if (!cacheCrossCheckEnabled())
|
|
551
|
+
return;
|
|
552
|
+
const freshParams = [];
|
|
553
|
+
const freshSql = build(freshParams);
|
|
554
|
+
const sqlOk = freshSql === entry.sql;
|
|
555
|
+
const paramsOk = cacheParamsEqual(collectedParams, freshParams);
|
|
556
|
+
if (sqlOk && paramsOk)
|
|
557
|
+
return;
|
|
558
|
+
const truncate = (s) => (s.length > 300 ? `${s.slice(0, 300)}… (${s.length} chars total)` : s);
|
|
559
|
+
const details = [];
|
|
560
|
+
if (!sqlOk) {
|
|
561
|
+
details.push(`cached SQL and freshly-built SQL diverge:\n cached = <${truncate(entry.sql)}>\n fresh = <${truncate(freshSql)}>`);
|
|
562
|
+
}
|
|
563
|
+
if (!paramsOk) {
|
|
564
|
+
details.push(`cache-hit params and freshly-built params diverge (collected ${collectedParams.length}, built ${freshParams.length})`);
|
|
565
|
+
}
|
|
566
|
+
throw new errors_js_1.ValidationError(`[turbine] SQL cache lockstep violation on ${op} (fingerprint "${cacheKey}"). ` +
|
|
567
|
+
`This is a Turbine internal invariant violation, please report it at ` +
|
|
568
|
+
`https://github.com/zvndev/turbine-orm/issues. The fingerprint, SQL-build, and ` +
|
|
569
|
+
`param-collect paths must enumerate where-clause keys identically.\n${details.join('\n')}`);
|
|
570
|
+
}
|
|
403
571
|
/**
|
|
404
572
|
* Reset the per-instance unlimited-query warning dedupe set.
|
|
405
573
|
* Exposed for tests so a single test process can verify the warning fires
|
|
@@ -598,19 +766,22 @@ class QueryInterface {
|
|
|
598
766
|
});
|
|
599
767
|
// Simple path: plain equality, no operators/null/OR
|
|
600
768
|
if (!args.with && isSimpleWhere) {
|
|
601
|
-
const
|
|
769
|
+
const buildSql = (freshParams) => {
|
|
602
770
|
const qt = this.q(this.table);
|
|
603
|
-
const
|
|
604
|
-
|
|
771
|
+
const whereClauses = whereKeys.map((k, i) => {
|
|
772
|
+
freshParams.push(whereObj[k]);
|
|
773
|
+
return `${this.toSqlColumn(k)} = ${this.p(i + 1)}`;
|
|
774
|
+
});
|
|
605
775
|
const whereSql = whereClauses.length > 0 ? ` WHERE ${whereClauses.join(' AND ')}` : '';
|
|
606
776
|
const selectExpr = columnsList ? columnsList.map((c) => `${qt}.${this.q(c)}`).join(', ') : `${qt}.*`;
|
|
607
|
-
void tempParams; // params are positional, SQL is value-invariant
|
|
608
777
|
return `SELECT ${selectExpr} FROM ${qt}${whereSql}${this.limitOneClause()}`;
|
|
609
|
-
}
|
|
778
|
+
};
|
|
779
|
+
const entry = this.acquireSql(ck, buildSql);
|
|
610
780
|
// Collect params (same order as build)
|
|
611
781
|
for (const k of whereKeys) {
|
|
612
782
|
params.push(whereObj[k]);
|
|
613
783
|
}
|
|
784
|
+
this.crossCheckCache('findUnique', ck, entry, buildSql, params);
|
|
614
785
|
return {
|
|
615
786
|
sql: entry.sql,
|
|
616
787
|
params,
|
|
@@ -624,16 +795,17 @@ class QueryInterface {
|
|
|
624
795
|
}
|
|
625
796
|
// General path (with operators, null, OR, with clause)
|
|
626
797
|
if (!args.with) {
|
|
627
|
-
const
|
|
628
|
-
const freshParams = [];
|
|
798
|
+
const buildSql = (freshParams) => {
|
|
629
799
|
const clause = this.buildWhereClause(whereObj, freshParams);
|
|
630
800
|
const whereSql = clause ? ` WHERE ${clause}` : '';
|
|
631
801
|
const qt = this.q(this.table);
|
|
632
802
|
const selectExpr = columnsList ? columnsList.map((c) => `${qt}.${this.q(c)}`).join(', ') : `${qt}.*`;
|
|
633
803
|
return `SELECT ${selectExpr} FROM ${qt}${whereSql}${this.limitOneClause()}`;
|
|
634
|
-
}
|
|
804
|
+
};
|
|
805
|
+
const entry = this.acquireSql(ck, buildSql);
|
|
635
806
|
// Collect params
|
|
636
807
|
this.collectWhereParams(whereObj, params);
|
|
808
|
+
this.crossCheckCache('findUnique', ck, entry, buildSql, params);
|
|
637
809
|
return {
|
|
638
810
|
sql: entry.sql,
|
|
639
811
|
params,
|
|
@@ -650,16 +822,17 @@ class QueryInterface {
|
|
|
650
822
|
// 1. buildWhere pushes where params
|
|
651
823
|
// 2. buildSelectWithRelations pushes relation params to same array
|
|
652
824
|
// We must preserve this exact order.
|
|
653
|
-
const
|
|
654
|
-
const freshParams = [];
|
|
825
|
+
const buildSql = (freshParams) => {
|
|
655
826
|
const clause = this.buildWhereClause(whereObj, freshParams);
|
|
656
827
|
const whereSql = clause ? ` WHERE ${clause}` : '';
|
|
657
828
|
const selectClause = this.buildSelectWithRelations(this.table, args.with, freshParams, columnsList);
|
|
658
829
|
return `SELECT ${selectClause} FROM ${this.q(this.table)}${whereSql}${this.limitOneClause()}`;
|
|
659
|
-
}
|
|
830
|
+
};
|
|
831
|
+
const entry = this.acquireSql(ck, buildSql);
|
|
660
832
|
// Collect params in exact build order: where first, then with-clause relations
|
|
661
833
|
this.collectWhereParams(whereObj, params);
|
|
662
834
|
this.collectWithParams(args.with, params);
|
|
835
|
+
this.crossCheckCache('findUnique', ck, entry, buildSql, params);
|
|
663
836
|
const parseWith = this.makeNestedParser(args.with);
|
|
664
837
|
return {
|
|
665
838
|
sql: entry.sql,
|
|
@@ -724,7 +897,7 @@ class QueryInterface {
|
|
|
724
897
|
if (this.warnedTables.has(this.table))
|
|
725
898
|
return;
|
|
726
899
|
this.warnedTables.add(this.table);
|
|
727
|
-
console.warn(`[turbine] warning: findMany on "${this.table}" has no limit
|
|
900
|
+
console.warn(`[turbine] warning: findMany on "${this.table}" has no limit: this will fetch every row. ` +
|
|
728
901
|
'Pass `limit`, or silence with `warnOnUnlimited: false` (per call, per table, or in config).');
|
|
729
902
|
}
|
|
730
903
|
/**
|
|
@@ -781,15 +954,24 @@ class QueryInterface {
|
|
|
781
954
|
.sort()
|
|
782
955
|
.join(',')
|
|
783
956
|
: '';
|
|
784
|
-
|
|
957
|
+
// distinct must fingerprint in USER order: the SQL emits `DISTINCT ON` in
|
|
958
|
+
// the caller's column order, so a permuted array rebuilds different SQL and
|
|
959
|
+
// must not collapse onto the same cache entry (would trip the cross-check).
|
|
960
|
+
const distinctFp = args?.distinct ? args.distinct.join(',') : '';
|
|
785
961
|
const effectiveLimit = args?.take ?? args?.limit ?? this.defaultLimit;
|
|
786
|
-
|
|
787
|
-
|
|
962
|
+
// On engines that inline the literal LIMIT/OFFSET into the SQL text
|
|
963
|
+
// (dialect.inlineLimitOffset, MySQL), the value is part of the SQL, not the
|
|
964
|
+
// params, so it MUST be part of the fingerprint or two different limits share
|
|
965
|
+
// one cached statement (silent wrong row counts). Parameterized engines
|
|
966
|
+
// (PG/SQLite/SQL Server, whose buildLimitOffset uses placeholders) keep the
|
|
967
|
+
// presence-only fingerprint so the cache is not needlessly fragmented.
|
|
968
|
+
const inlinePagination = this.dialect.inlineLimitOffset === true;
|
|
969
|
+
const limitFp = effectiveLimit !== undefined ? (inlinePagination ? `v${effectiveLimit}` : '1') : '0';
|
|
970
|
+
const offsetFp = args?.offset !== undefined ? (inlinePagination ? `v${args.offset}` : '1') : '0';
|
|
788
971
|
const ck = `fm:${whereFp}|c=${colKey}|o=${orderFp}|l=${limitFp}|off=${offsetFp}|cur=${cursorFp}|d=${distinctFp}|w=${withFp}${this.globalFilterCacheSegment()}`;
|
|
789
972
|
const params = [];
|
|
790
|
-
const
|
|
791
|
-
// Fresh build
|
|
792
|
-
const freshParams = [];
|
|
973
|
+
const buildSql = (freshParams) => {
|
|
974
|
+
// Fresh build: generates SQL and populates freshParams
|
|
793
975
|
const { sql: freshWhereSql } = hasWhere
|
|
794
976
|
? (() => {
|
|
795
977
|
const clause = this.buildWhereClause(whereObj, freshParams);
|
|
@@ -869,7 +1051,8 @@ class QueryInterface {
|
|
|
869
1051
|
}
|
|
870
1052
|
sql += this.buildPagination(limitPh, offsetPh, !!args?.orderBy);
|
|
871
1053
|
return sql;
|
|
872
|
-
}
|
|
1054
|
+
};
|
|
1055
|
+
const entry = this.acquireSql(ck, buildSql);
|
|
873
1056
|
// Collect params in exact build order:
|
|
874
1057
|
// 1. WHERE params (includes the AND-merged global filter, if any)
|
|
875
1058
|
if (hasWhere) {
|
|
@@ -900,6 +1083,7 @@ class QueryInterface {
|
|
|
900
1083
|
if (args?.offset !== undefined && !this.dialect.inlineLimitOffset) {
|
|
901
1084
|
params.push(Number(args.offset));
|
|
902
1085
|
}
|
|
1086
|
+
this.crossCheckCache('findMany', ck, entry, buildSql, params);
|
|
903
1087
|
// Build the row parser once (positional shapes are computed here, not per row).
|
|
904
1088
|
const parseWith = args?.with ? this.makeNestedParser(args.with) : null;
|
|
905
1089
|
return {
|
|
@@ -1225,8 +1409,7 @@ class QueryInterface {
|
|
|
1225
1409
|
const whereFp = this.fingerprintWhere(whereObj);
|
|
1226
1410
|
const ck = lock ? null : `u:${setFp}|${whereFp}${this.globalFilterCacheSegment()}`;
|
|
1227
1411
|
const params = [];
|
|
1228
|
-
const buildSql = () => {
|
|
1229
|
-
const freshParams = [];
|
|
1412
|
+
const buildSql = (freshParams) => {
|
|
1230
1413
|
const setEntries = Object.entries(dataObj).filter(([, v]) => v !== undefined);
|
|
1231
1414
|
const setClauses = setEntries.map(([k, v]) => this.buildSetClause(k, v, freshParams));
|
|
1232
1415
|
if (lock) {
|
|
@@ -1250,13 +1433,15 @@ class QueryInterface {
|
|
|
1250
1433
|
};
|
|
1251
1434
|
let sql;
|
|
1252
1435
|
let preparedName;
|
|
1436
|
+
let cacheEntry;
|
|
1253
1437
|
if (ck) {
|
|
1254
|
-
|
|
1255
|
-
sql =
|
|
1256
|
-
preparedName =
|
|
1438
|
+
cacheEntry = this.acquireSql(ck, buildSql);
|
|
1439
|
+
sql = cacheEntry.sql;
|
|
1440
|
+
preparedName = cacheEntry.name;
|
|
1257
1441
|
}
|
|
1258
1442
|
else {
|
|
1259
|
-
|
|
1443
|
+
// optimisticLock path: value-variant version check → uncacheable, no cross-check.
|
|
1444
|
+
sql = buildSql([]);
|
|
1260
1445
|
}
|
|
1261
1446
|
// Collect params: SET first, then WHERE, then version check (same order as fresh build)
|
|
1262
1447
|
this.collectSetParams(dataObj, params);
|
|
@@ -1264,6 +1449,9 @@ class QueryInterface {
|
|
|
1264
1449
|
if (lock) {
|
|
1265
1450
|
params.push(lock.expected);
|
|
1266
1451
|
}
|
|
1452
|
+
if (ck && cacheEntry) {
|
|
1453
|
+
this.crossCheckCache('update', ck, cacheEntry, buildSql, params);
|
|
1454
|
+
}
|
|
1267
1455
|
return {
|
|
1268
1456
|
sql,
|
|
1269
1457
|
params,
|
|
@@ -1395,8 +1583,7 @@ class QueryInterface {
|
|
|
1395
1583
|
const whereFp = this.fingerprintWhere(whereObj);
|
|
1396
1584
|
const ck = `d:${whereFp}${this.globalFilterCacheSegment()}`;
|
|
1397
1585
|
const params = [];
|
|
1398
|
-
const
|
|
1399
|
-
const freshParams = [];
|
|
1586
|
+
const buildSql = (freshParams) => {
|
|
1400
1587
|
const clause = this.buildWhereClause(whereObj, freshParams);
|
|
1401
1588
|
const whereSql = clause ? ` WHERE ${clause}` : '';
|
|
1402
1589
|
// SQL Server injects `OUTPUT DELETED.*` between `DELETE FROM <t>` and WHERE;
|
|
@@ -1404,8 +1591,10 @@ class QueryInterface {
|
|
|
1404
1591
|
return this.dialect.buildDeleteStatement
|
|
1405
1592
|
? this.dialect.buildDeleteStatement({ table: this.q(this.table), whereSql, returning: '*' })
|
|
1406
1593
|
: `DELETE FROM ${this.q(this.table)}${whereSql}${this.dialect.buildReturningClause('*')}`;
|
|
1407
|
-
}
|
|
1594
|
+
};
|
|
1595
|
+
const entry = this.acquireSql(ck, buildSql);
|
|
1408
1596
|
this.collectWhereParams(whereObj, params);
|
|
1597
|
+
this.crossCheckCache('delete', ck, entry, buildSql, params);
|
|
1409
1598
|
return {
|
|
1410
1599
|
sql: entry.sql,
|
|
1411
1600
|
params,
|
|
@@ -1533,16 +1722,17 @@ class QueryInterface {
|
|
|
1533
1722
|
const whereFp = this.fingerprintWhere(whereObj);
|
|
1534
1723
|
const ck = `um:${setFp}|${whereFp}${this.globalFilterCacheSegment()}`;
|
|
1535
1724
|
const params = [];
|
|
1536
|
-
const
|
|
1537
|
-
const freshParams = [];
|
|
1725
|
+
const buildSql = (freshParams) => {
|
|
1538
1726
|
const setEntries = Object.entries(dataObj).filter(([, v]) => v !== undefined);
|
|
1539
1727
|
const setClauses = setEntries.map(([k, v]) => this.buildSetClause(k, v, freshParams));
|
|
1540
1728
|
const whereClause = this.buildWhereClause(whereObj, freshParams);
|
|
1541
1729
|
const whereSql = whereClause ? ` WHERE ${whereClause}` : '';
|
|
1542
1730
|
return `UPDATE ${this.q(this.table)} SET ${setClauses.join(', ')}${whereSql}`;
|
|
1543
|
-
}
|
|
1731
|
+
};
|
|
1732
|
+
const entry = this.acquireSql(ck, buildSql);
|
|
1544
1733
|
this.collectSetParams(dataObj, params);
|
|
1545
1734
|
this.collectWhereParams(whereObj, params);
|
|
1735
|
+
this.crossCheckCache('updateMany', ck, entry, buildSql, params);
|
|
1546
1736
|
return {
|
|
1547
1737
|
sql: entry.sql,
|
|
1548
1738
|
params,
|
|
@@ -1569,13 +1759,14 @@ class QueryInterface {
|
|
|
1569
1759
|
const whereFp = this.fingerprintWhere(whereObj);
|
|
1570
1760
|
const ck = `dm:${whereFp}${this.globalFilterCacheSegment()}`;
|
|
1571
1761
|
const params = [];
|
|
1572
|
-
const
|
|
1573
|
-
const freshParams = [];
|
|
1762
|
+
const buildSql = (freshParams) => {
|
|
1574
1763
|
const clause = this.buildWhereClause(whereObj, freshParams);
|
|
1575
1764
|
const whereSql = clause ? ` WHERE ${clause}` : '';
|
|
1576
1765
|
return `DELETE FROM ${this.q(this.table)}${whereSql}`;
|
|
1577
|
-
}
|
|
1766
|
+
};
|
|
1767
|
+
const entry = this.acquireSql(ck, buildSql);
|
|
1578
1768
|
this.collectWhereParams(whereObj, params);
|
|
1769
|
+
this.crossCheckCache('deleteMany', ck, entry, buildSql, params);
|
|
1579
1770
|
return {
|
|
1580
1771
|
sql: entry.sql,
|
|
1581
1772
|
params,
|
|
@@ -1602,15 +1793,16 @@ class QueryInterface {
|
|
|
1602
1793
|
const whereFp = hasWhere ? this.fingerprintWhere(whereObj) : '';
|
|
1603
1794
|
const ck = `cnt:${whereFp}${this.globalFilterCacheSegment()}`;
|
|
1604
1795
|
const params = [];
|
|
1605
|
-
const
|
|
1606
|
-
const freshParams = [];
|
|
1796
|
+
const buildSql = (freshParams) => {
|
|
1607
1797
|
const clause = hasWhere ? this.buildWhereClause(whereObj, freshParams) : null;
|
|
1608
1798
|
const whereSql = clause ? ` WHERE ${clause}` : '';
|
|
1609
1799
|
return `SELECT ${this.castAgg('COUNT(*)', 'int')} AS count FROM ${this.q(this.table)}${whereSql}`;
|
|
1610
|
-
}
|
|
1800
|
+
};
|
|
1801
|
+
const entry = this.acquireSql(ck, buildSql);
|
|
1611
1802
|
if (hasWhere) {
|
|
1612
1803
|
this.collectWhereParams(whereObj, params);
|
|
1613
1804
|
}
|
|
1805
|
+
this.crossCheckCache('count', ck, entry, buildSql, params);
|
|
1614
1806
|
return {
|
|
1615
1807
|
sql: entry.sql,
|
|
1616
1808
|
params,
|
|
@@ -2939,9 +3131,11 @@ class QueryInterface {
|
|
|
2939
3131
|
const oEntries = Object.entries(opts.orderBy).map(([k, d]) => `${k}:${this.orderByEntryFingerprint(d, targetRels?.[k]?.to)}`);
|
|
2940
3132
|
subParts.push(`o=${oEntries.join(',')}`);
|
|
2941
3133
|
}
|
|
2942
|
-
// limit presence
|
|
3134
|
+
// limit presence, but on inline-pagination engines (MySQL) the literal
|
|
3135
|
+
// value is baked into the subquery SQL, so fingerprint the value there or
|
|
3136
|
+
// `{limit:3}` and `{limit:5}` would share one cached statement.
|
|
2943
3137
|
if (opts.limit !== undefined) {
|
|
2944
|
-
subParts.push('l=1');
|
|
3138
|
+
subParts.push(this.dialect.inlineLimitOffset ? `l=${opts.limit}` : 'l=1');
|
|
2945
3139
|
}
|
|
2946
3140
|
// nested with (recurse)
|
|
2947
3141
|
if (opts.with) {
|
package/dist/cli/config.d.ts
CHANGED
|
@@ -49,9 +49,61 @@ export type TurbineConfig = TurbineCliConfig;
|
|
|
49
49
|
* silently matches zero tables. Used by `turbine generate` to fail loudly.
|
|
50
50
|
*/
|
|
51
51
|
export declare function looksLikeSchemaFilePath(schema: string): boolean;
|
|
52
|
+
/** A config-file load attempt that failed, kept so the CLI can surface it. */
|
|
53
|
+
export interface ConfigLoadError {
|
|
54
|
+
/** The config file whose import threw (e.g. `turbine.config.ts`). */
|
|
55
|
+
filename: string;
|
|
56
|
+
/** The underlying error thrown by the dynamic import. */
|
|
57
|
+
error: unknown;
|
|
58
|
+
}
|
|
59
|
+
/** Result of {@link loadConfigResult}: the resolved config plus any load failure. */
|
|
60
|
+
export interface ConfigLoadResult {
|
|
61
|
+
config: TurbineCliConfig;
|
|
62
|
+
/**
|
|
63
|
+
* Set when a config file existed but failed to import. The config is still
|
|
64
|
+
* returned as `{}` so resolution falls through to env vars and CLI flags, but
|
|
65
|
+
* the CLI should surface this rather than let it masquerade as a missing URL.
|
|
66
|
+
*/
|
|
67
|
+
loadError?: ConfigLoadError;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Unwrap the module object returned by `import(configFile)` down to the actual
|
|
71
|
+
* config value.
|
|
72
|
+
*
|
|
73
|
+
* With `"type": "commonjs"` in the consumer's package.json (the `npm init -y`
|
|
74
|
+
* default) plus the tsx loader, importing `turbine.config.ts` yields a
|
|
75
|
+
* CJS-interop DOUBLE-wrapped default: `mod.default` is itself `{ default: config }`.
|
|
76
|
+
* A naive `mod.default ?? mod` then reads every field as `undefined`, so every
|
|
77
|
+
* command fails with a misleading "No database URL provided".
|
|
78
|
+
*
|
|
79
|
+
* This prefers `default` when present (the historical behavior) and then keeps
|
|
80
|
+
* descending through any additional pure `{ default: … }` wrappers, so both the
|
|
81
|
+
* correct single-default shape and the double-wrapped shape resolve to the same
|
|
82
|
+
* config. A genuine config object (which has real fields, never a lone
|
|
83
|
+
* `default`) is returned untouched.
|
|
84
|
+
*/
|
|
85
|
+
export declare function unwrapModuleDefault(mod: unknown): unknown;
|
|
86
|
+
/**
|
|
87
|
+
* {@link unwrapModuleDefault} specialized for config files: a non-object export
|
|
88
|
+
* collapses to `{}` so downstream resolution falls through to env vars/flags.
|
|
89
|
+
*/
|
|
90
|
+
export declare function unwrapConfigModule(mod: unknown): TurbineCliConfig;
|
|
91
|
+
/**
|
|
92
|
+
* Attempt to load a turbine config file from the given directory, returning the
|
|
93
|
+
* resolved config together with any load failure so the caller can surface it.
|
|
94
|
+
*
|
|
95
|
+
* Candidates are tried in {@link CONFIG_FILES} priority order. The first one
|
|
96
|
+
* that imports successfully wins. If a candidate exists but throws (syntax
|
|
97
|
+
* error, ESM/CJS interop failure, etc.) we remember the first such error and
|
|
98
|
+
* keep trying lower-priority candidates; if none load, the remembered error is
|
|
99
|
+
* returned in `loadError` while `config` stays `{}` so env/flag resolution can
|
|
100
|
+
* still proceed.
|
|
101
|
+
*/
|
|
102
|
+
export declare function loadConfigResult(cwd?: string): Promise<ConfigLoadResult>;
|
|
52
103
|
/**
|
|
53
104
|
* Attempt to load a turbine config file from the current directory.
|
|
54
|
-
* Returns the config if found, or an empty object.
|
|
105
|
+
* Returns the config if found, or an empty object. Load failures are swallowed
|
|
106
|
+
* here; callers that need to surface them should use {@link loadConfigResult}.
|
|
55
107
|
*/
|
|
56
108
|
export declare function loadConfig(cwd?: string): Promise<TurbineCliConfig>;
|
|
57
109
|
/**
|
package/dist/cli/config.js
CHANGED
|
@@ -25,15 +25,66 @@ export function looksLikeSchemaFilePath(schema) {
|
|
|
25
25
|
// ---------------------------------------------------------------------------
|
|
26
26
|
const CONFIG_FILES = ['turbine.config.ts', 'turbine.config.mts', 'turbine.config.js', 'turbine.config.mjs'];
|
|
27
27
|
const DEFAULT_SEED_CANDIDATES = ['seed.ts', 'seed.js', 'seed.sql'];
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
function isPlainObject(value) {
|
|
29
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
30
|
+
}
|
|
31
31
|
/**
|
|
32
|
-
*
|
|
33
|
-
*
|
|
32
|
+
* True when `value` is a pure ESM/CJS-interop wrapper: an object whose only
|
|
33
|
+
* meaningful export is `default` (the `__esModule` marker is ignored). No
|
|
34
|
+
* Turbine config field is named `default`, so a real config never matches.
|
|
34
35
|
*/
|
|
35
|
-
|
|
36
|
+
function isPureDefaultWrapper(value) {
|
|
37
|
+
const keys = Object.keys(value).filter((k) => k !== '__esModule');
|
|
38
|
+
return keys.length === 1 && keys[0] === 'default';
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Unwrap the module object returned by `import(configFile)` down to the actual
|
|
42
|
+
* config value.
|
|
43
|
+
*
|
|
44
|
+
* With `"type": "commonjs"` in the consumer's package.json (the `npm init -y`
|
|
45
|
+
* default) plus the tsx loader, importing `turbine.config.ts` yields a
|
|
46
|
+
* CJS-interop DOUBLE-wrapped default: `mod.default` is itself `{ default: config }`.
|
|
47
|
+
* A naive `mod.default ?? mod` then reads every field as `undefined`, so every
|
|
48
|
+
* command fails with a misleading "No database URL provided".
|
|
49
|
+
*
|
|
50
|
+
* This prefers `default` when present (the historical behavior) and then keeps
|
|
51
|
+
* descending through any additional pure `{ default: … }` wrappers, so both the
|
|
52
|
+
* correct single-default shape and the double-wrapped shape resolve to the same
|
|
53
|
+
* config. A genuine config object (which has real fields, never a lone
|
|
54
|
+
* `default`) is returned untouched.
|
|
55
|
+
*/
|
|
56
|
+
export function unwrapModuleDefault(mod) {
|
|
57
|
+
// Step 1: prefer `default` at the top level (mirrors `mod.default ?? mod`).
|
|
58
|
+
let value = isPlainObject(mod) && mod.default != null ? mod.default : mod;
|
|
59
|
+
// Step 2: peel off any further pure interop wrappers, bounded to avoid a
|
|
60
|
+
// pathological self-referential object spinning forever.
|
|
61
|
+
for (let depth = 0; depth < 10 && isPlainObject(value) && isPureDefaultWrapper(value); depth++) {
|
|
62
|
+
value = value.default;
|
|
63
|
+
}
|
|
64
|
+
return value;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* {@link unwrapModuleDefault} specialized for config files: a non-object export
|
|
68
|
+
* collapses to `{}` so downstream resolution falls through to env vars/flags.
|
|
69
|
+
*/
|
|
70
|
+
export function unwrapConfigModule(mod) {
|
|
71
|
+
const value = unwrapModuleDefault(mod);
|
|
72
|
+
return isPlainObject(value) ? value : {};
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Attempt to load a turbine config file from the given directory, returning the
|
|
76
|
+
* resolved config together with any load failure so the caller can surface it.
|
|
77
|
+
*
|
|
78
|
+
* Candidates are tried in {@link CONFIG_FILES} priority order. The first one
|
|
79
|
+
* that imports successfully wins. If a candidate exists but throws (syntax
|
|
80
|
+
* error, ESM/CJS interop failure, etc.) we remember the first such error and
|
|
81
|
+
* keep trying lower-priority candidates; if none load, the remembered error is
|
|
82
|
+
* returned in `loadError` while `config` stays `{}` so env/flag resolution can
|
|
83
|
+
* still proceed.
|
|
84
|
+
*/
|
|
85
|
+
export async function loadConfigResult(cwd) {
|
|
36
86
|
const dir = cwd ?? process.cwd();
|
|
87
|
+
let loadError;
|
|
37
88
|
for (const filename of CONFIG_FILES) {
|
|
38
89
|
const filePath = join(dir, filename);
|
|
39
90
|
if (!existsSync(filePath))
|
|
@@ -41,21 +92,27 @@ export async function loadConfig(cwd) {
|
|
|
41
92
|
try {
|
|
42
93
|
const absPath = resolve(filePath);
|
|
43
94
|
const fileUrl = pathToFileURL(absPath).href;
|
|
44
|
-
// For .ts files, we
|
|
45
|
-
//
|
|
95
|
+
// For .ts files, we rely on the tsx loader being registered by the CLI
|
|
96
|
+
// before this runs. Dynamic import handles .js/.mjs natively.
|
|
46
97
|
const mod = await import(fileUrl);
|
|
47
|
-
|
|
48
|
-
return config;
|
|
98
|
+
return { config: unwrapConfigModule(mod) };
|
|
49
99
|
}
|
|
50
100
|
catch (err) {
|
|
51
|
-
//
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
throw new Error(`Failed to load config from ${filename}: ${err instanceof Error ? err.message : String(err)}`);
|
|
101
|
+
// Remember the first real load failure but keep trying lower-priority
|
|
102
|
+
// candidates (e.g. a working .js next to a broken .ts).
|
|
103
|
+
if (!loadError)
|
|
104
|
+
loadError = { filename, error: err };
|
|
56
105
|
}
|
|
57
106
|
}
|
|
58
|
-
return {};
|
|
107
|
+
return loadError ? { config: {}, loadError } : { config: {} };
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Attempt to load a turbine config file from the current directory.
|
|
111
|
+
* Returns the config if found, or an empty object. Load failures are swallowed
|
|
112
|
+
* here; callers that need to surface them should use {@link loadConfigResult}.
|
|
113
|
+
*/
|
|
114
|
+
export async function loadConfig(cwd) {
|
|
115
|
+
return (await loadConfigResult(cwd)).config;
|
|
59
116
|
}
|
|
60
117
|
/**
|
|
61
118
|
* Find the config file path (for display purposes).
|