tempest-db-js 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -0
- package/dist/bin.cjs +1921 -12
- package/dist/bin.cjs.map +1 -1
- package/dist/bin.js +31 -2
- package/dist/bin.js.map +1 -1
- package/dist/chunk-HXK6WIBP.js +5897 -0
- package/dist/chunk-HXK6WIBP.js.map +1 -0
- package/dist/{chunk-SI4CLSF7.js → chunk-ZBIHRVUS.js} +255 -11
- package/dist/chunk-ZBIHRVUS.js.map +1 -0
- package/dist/index.cjs +3156 -280
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +3058 -549
- package/dist/index.d.ts +3058 -549
- package/dist/index.js +1 -1
- package/dist/migrations/index.cjs +1654 -11
- package/dist/migrations/index.cjs.map +1 -1
- package/dist/migrations/index.d.cts +30 -1
- package/dist/migrations/index.d.ts +30 -1
- package/dist/migrations/index.js +2 -2
- package/package.json +1 -1
- package/dist/chunk-4AWUP7BM.js +0 -3076
- package/dist/chunk-4AWUP7BM.js.map +0 -1
- package/dist/chunk-SI4CLSF7.js.map +0 -1
package/dist/index.cjs
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
var child_process = require('child_process');
|
|
4
|
+
var promises = require('fs/promises');
|
|
5
|
+
var util = require('util');
|
|
3
6
|
var module$1 = require('module');
|
|
4
7
|
|
|
5
8
|
var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
|
|
@@ -113,12 +116,34 @@ var Expression = class {
|
|
|
113
116
|
return this.compare("isNull", value);
|
|
114
117
|
}
|
|
115
118
|
};
|
|
119
|
+
function conditionFromNode(node) {
|
|
120
|
+
return wrap(node);
|
|
121
|
+
}
|
|
122
|
+
function expressionFromNode(node) {
|
|
123
|
+
return new Expression(node);
|
|
124
|
+
}
|
|
116
125
|
function col(name) {
|
|
117
126
|
return new Expression({ kind: "column", name });
|
|
118
127
|
}
|
|
119
128
|
function val(value) {
|
|
120
129
|
return new Expression({ kind: "value", value });
|
|
121
130
|
}
|
|
131
|
+
function caseWhen(branches, fallback) {
|
|
132
|
+
if (branches.length === 0) {
|
|
133
|
+
throw new Error("caseWhen() needs at least one [condition, result] branch.");
|
|
134
|
+
}
|
|
135
|
+
return new Expression({
|
|
136
|
+
kind: "case",
|
|
137
|
+
branches: branches.map(([when, result]) => ({
|
|
138
|
+
when: toCondNode(when),
|
|
139
|
+
result: toExprNode(result)
|
|
140
|
+
})),
|
|
141
|
+
fallback: fallback === void 0 ? null : toExprNode(fallback)
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
function cast(operand, to) {
|
|
145
|
+
return new Expression({ kind: "cast", operand: toArg(operand), to });
|
|
146
|
+
}
|
|
122
147
|
var FUNCTION_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
123
148
|
function toArg(arg) {
|
|
124
149
|
return typeof arg === "string" ? { kind: "column", name: arg } : arg.node;
|
|
@@ -166,7 +191,39 @@ function not(input) {
|
|
|
166
191
|
return wrap({ kind: "not", part: toCondNode(input) });
|
|
167
192
|
}
|
|
168
193
|
|
|
194
|
+
// src/aliased.ts
|
|
195
|
+
function aliased(model, alias) {
|
|
196
|
+
const aliasedModel = class extends model {
|
|
197
|
+
};
|
|
198
|
+
Object.defineProperty(aliasedModel, "tablename", { value: alias, writable: true });
|
|
199
|
+
Object.defineProperty(aliasedModel, "naming", {
|
|
200
|
+
value: model.naming,
|
|
201
|
+
writable: true
|
|
202
|
+
});
|
|
203
|
+
Object.defineProperty(aliasedModel, "tableArgs", {
|
|
204
|
+
value: void 0,
|
|
205
|
+
writable: true
|
|
206
|
+
});
|
|
207
|
+
Object.defineProperty(aliasedModel, "aliasOf", { value: model, writable: true });
|
|
208
|
+
return aliasedModel;
|
|
209
|
+
}
|
|
210
|
+
function aliasOf(model) {
|
|
211
|
+
return model.aliasOf ?? null;
|
|
212
|
+
}
|
|
213
|
+
|
|
169
214
|
// src/query.ts
|
|
215
|
+
function exists(subquery) {
|
|
216
|
+
return conditionFromNode({ kind: "exists", select: nodeOf(subquery), negate: false });
|
|
217
|
+
}
|
|
218
|
+
function notExists(subquery) {
|
|
219
|
+
return conditionFromNode({ kind: "exists", select: nodeOf(subquery), negate: true });
|
|
220
|
+
}
|
|
221
|
+
function scalar(subquery) {
|
|
222
|
+
return expressionFromNode({ kind: "scalar", select: subquery.node });
|
|
223
|
+
}
|
|
224
|
+
function nodeOf(subquery) {
|
|
225
|
+
return subquery.node;
|
|
226
|
+
}
|
|
170
227
|
function isSubquery(value) {
|
|
171
228
|
return typeof value === "object" && value !== null && value.node?.kind === "select";
|
|
172
229
|
}
|
|
@@ -180,6 +237,7 @@ var OPERATORS = [
|
|
|
180
237
|
"like",
|
|
181
238
|
"ilike",
|
|
182
239
|
"ieq",
|
|
240
|
+
"iContains",
|
|
183
241
|
"in",
|
|
184
242
|
"notIn",
|
|
185
243
|
"between",
|
|
@@ -200,16 +258,16 @@ function count() {
|
|
|
200
258
|
return new Agg("count", "*");
|
|
201
259
|
}
|
|
202
260
|
function sum(column2) {
|
|
203
|
-
return new Agg("sum", column2);
|
|
261
|
+
return new Agg("sum", typeof column2 === "string" ? column2 : column2.node);
|
|
204
262
|
}
|
|
205
263
|
function avg(column2) {
|
|
206
|
-
return new Agg("avg", column2);
|
|
264
|
+
return new Agg("avg", typeof column2 === "string" ? column2 : column2.node);
|
|
207
265
|
}
|
|
208
266
|
function min(column2) {
|
|
209
|
-
return new Agg("min", column2);
|
|
267
|
+
return new Agg("min", typeof column2 === "string" ? column2 : column2.node);
|
|
210
268
|
}
|
|
211
269
|
function max(column2) {
|
|
212
|
-
return new Agg("max", column2);
|
|
270
|
+
return new Agg("max", typeof column2 === "string" ? column2 : column2.node);
|
|
213
271
|
}
|
|
214
272
|
var SelectBuilder = class _SelectBuilder {
|
|
215
273
|
constructor(node, source) {
|
|
@@ -294,9 +352,35 @@ var SelectBuilder = class _SelectBuilder {
|
|
|
294
352
|
* @param direction `"asc"` (default) or `"desc"`.
|
|
295
353
|
* @returns A builder carrying the ordering term.
|
|
296
354
|
*/
|
|
355
|
+
/**
|
|
356
|
+
* Project extra expressions alongside the columns, by alias.
|
|
357
|
+
*
|
|
358
|
+
* This is where a window function lands: unlike `aggregate()`, it does **not**
|
|
359
|
+
* group — every row stays, with the computed value attached.
|
|
360
|
+
*
|
|
361
|
+
* @param map Alias → expression.
|
|
362
|
+
* @returns A builder whose row type carries the aliases.
|
|
363
|
+
*
|
|
364
|
+
* @example
|
|
365
|
+
* ```ts
|
|
366
|
+
* select(Sale).compute({
|
|
367
|
+
* rank: over(rowNumber(), { partitionBy: ["region"], orderBy: [["total", "desc"]] }),
|
|
368
|
+
* });
|
|
369
|
+
* ```
|
|
370
|
+
*/
|
|
371
|
+
compute(map) {
|
|
372
|
+
const computed = { ...this.node.computed ?? {} };
|
|
373
|
+
for (const [alias, expression2] of Object.entries(map)) {
|
|
374
|
+
computed[alias] = expression2.node;
|
|
375
|
+
}
|
|
376
|
+
return this.with({ computed });
|
|
377
|
+
}
|
|
297
378
|
orderBy(column2, direction = "asc") {
|
|
298
379
|
return this.with({
|
|
299
|
-
orderBy: [
|
|
380
|
+
orderBy: [
|
|
381
|
+
...this.node.orderBy,
|
|
382
|
+
{ column: typeof column2 === "string" ? column2 : column2.node, direction }
|
|
383
|
+
]
|
|
300
384
|
});
|
|
301
385
|
}
|
|
302
386
|
/** Limit the number of rows. */
|
|
@@ -389,10 +473,12 @@ function buildLock(strength, options) {
|
|
|
389
473
|
return { strength, wait, of: options?.of ?? [] };
|
|
390
474
|
}
|
|
391
475
|
function select(model, columns) {
|
|
476
|
+
const base = aliasOf(model);
|
|
392
477
|
return new SelectBuilder(
|
|
393
478
|
{
|
|
394
479
|
kind: "select",
|
|
395
|
-
table: model.tablename,
|
|
480
|
+
table: base ? base.tablename : model.tablename,
|
|
481
|
+
alias: base ? model.tablename : void 0,
|
|
396
482
|
columns: columns ?? "*",
|
|
397
483
|
distinct: false,
|
|
398
484
|
aggregates: [],
|
|
@@ -401,12 +487,67 @@ function select(model, columns) {
|
|
|
401
487
|
orderBy: [],
|
|
402
488
|
limit: void 0,
|
|
403
489
|
offset: void 0,
|
|
404
|
-
names: columnNamesOf(model) ?? void 0
|
|
490
|
+
names: columnNamesOf(model) ?? void 0,
|
|
491
|
+
codecs: codecsOf(model) ?? void 0
|
|
405
492
|
},
|
|
406
493
|
model
|
|
407
494
|
);
|
|
408
495
|
}
|
|
409
496
|
|
|
497
|
+
// src/window.ts
|
|
498
|
+
function functionNode(fn2) {
|
|
499
|
+
if ("call" in fn2) return fn2.call;
|
|
500
|
+
if (fn2 instanceof Agg) {
|
|
501
|
+
const arg = fn2.column === "*" ? { kind: "star" } : typeof fn2.column === "string" ? { kind: "column", name: fn2.column } : fn2.column;
|
|
502
|
+
return { kind: "fn", name: fn2.fn, args: [arg] };
|
|
503
|
+
}
|
|
504
|
+
return fn2.node;
|
|
505
|
+
}
|
|
506
|
+
function over(fn2, spec = {}) {
|
|
507
|
+
return expressionFromNode({
|
|
508
|
+
kind: "window",
|
|
509
|
+
fn: functionNode(fn2),
|
|
510
|
+
partitionBy: [...spec.partitionBy ?? []],
|
|
511
|
+
orderBy: (spec.orderBy ?? []).map(
|
|
512
|
+
(term) => typeof term === "string" ? { column: term, direction: "asc" } : { column: term[0], direction: term[1] }
|
|
513
|
+
),
|
|
514
|
+
frame: spec.frame ?? null
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
function windowFn(name, args = []) {
|
|
518
|
+
return { call: { kind: "fn", name, args } };
|
|
519
|
+
}
|
|
520
|
+
function rowNumber() {
|
|
521
|
+
return windowFn("row_number");
|
|
522
|
+
}
|
|
523
|
+
function rank() {
|
|
524
|
+
return windowFn("rank");
|
|
525
|
+
}
|
|
526
|
+
function denseRank() {
|
|
527
|
+
return windowFn("dense_rank");
|
|
528
|
+
}
|
|
529
|
+
function percentRank() {
|
|
530
|
+
return windowFn("percent_rank");
|
|
531
|
+
}
|
|
532
|
+
function lag(column2, offset = 1) {
|
|
533
|
+
return windowFn("lag", [
|
|
534
|
+
{ kind: "column", name: column2 },
|
|
535
|
+
{ kind: "value", value: offset }
|
|
536
|
+
]);
|
|
537
|
+
}
|
|
538
|
+
function lead(column2, offset = 1) {
|
|
539
|
+
return windowFn("lead", [
|
|
540
|
+
{ kind: "column", name: column2 },
|
|
541
|
+
{ kind: "value", value: offset }
|
|
542
|
+
]);
|
|
543
|
+
}
|
|
544
|
+
function firstValue(column2) {
|
|
545
|
+
return windowFn("first_value", [{ kind: "column", name: column2 }]);
|
|
546
|
+
}
|
|
547
|
+
function lastValue(column2) {
|
|
548
|
+
return windowFn("last_value", [{ kind: "column", name: column2 }]);
|
|
549
|
+
}
|
|
550
|
+
|
|
410
551
|
// src/serialize.ts
|
|
411
552
|
var ValidationError = class extends Error {
|
|
412
553
|
constructor(table, issues) {
|
|
@@ -432,8 +573,20 @@ function fromBase64(value) {
|
|
|
432
573
|
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
433
574
|
return bytes;
|
|
434
575
|
}
|
|
576
|
+
function baseOf(column2) {
|
|
577
|
+
return new Column(
|
|
578
|
+
column2.type,
|
|
579
|
+
column2.flags,
|
|
580
|
+
column2.defaultValue,
|
|
581
|
+
column2.onUpdateValue,
|
|
582
|
+
column2.reference,
|
|
583
|
+
column2.dbName,
|
|
584
|
+
null
|
|
585
|
+
);
|
|
586
|
+
}
|
|
435
587
|
function encodeValue(column2, value) {
|
|
436
588
|
if (value === null || value === void 0) return null;
|
|
589
|
+
if (column2.codec) return encodeValue(baseOf(column2), column2.codec.toDb(value));
|
|
437
590
|
switch (column2.type.kind) {
|
|
438
591
|
case "bigint":
|
|
439
592
|
return typeof value === "bigint" ? value.toString() : value;
|
|
@@ -449,6 +602,7 @@ function encodeValue(column2, value) {
|
|
|
449
602
|
}
|
|
450
603
|
function decodeValue(column2, value) {
|
|
451
604
|
if (value === null || value === void 0) return null;
|
|
605
|
+
if (column2.codec) return column2.codec.fromDb(decodeValue(baseOf(column2), value));
|
|
452
606
|
switch (column2.type.kind) {
|
|
453
607
|
case "bigint":
|
|
454
608
|
return typeof value === "bigint" ? value : BigInt(value);
|
|
@@ -568,6 +722,12 @@ function mapperFor(model) {
|
|
|
568
722
|
const decoders = /* @__PURE__ */ new Map();
|
|
569
723
|
for (const [prop, col2] of Object.entries(columnsOf(model))) {
|
|
570
724
|
const decoder = decoderFor(col2.type);
|
|
725
|
+
const codec = col2.codec;
|
|
726
|
+
if (codec) {
|
|
727
|
+
const decode2 = decoder ? (value) => value === null || value === void 0 ? value : codec.fromDb(decoder(value)) : (value) => value === null || value === void 0 ? value : codec.fromDb(value);
|
|
728
|
+
decoders.set(names?.[prop] ?? prop, decode2);
|
|
729
|
+
continue;
|
|
730
|
+
}
|
|
571
731
|
if (decoder) decoders.set(names?.[prop] ?? prop, decoder);
|
|
572
732
|
}
|
|
573
733
|
const mapper = { props, decoders };
|
|
@@ -603,7 +763,9 @@ function assertWritableValues(model, values, clause) {
|
|
|
603
763
|
issues.push(`${clause}: "${key}" is not a column of ${model.tablename}`);
|
|
604
764
|
continue;
|
|
605
765
|
}
|
|
606
|
-
if (isSqlExpression(value) || isBindableScalar(value))
|
|
766
|
+
if (isExpression(value) || isSqlExpression(value) || isBindableScalar(value)) {
|
|
767
|
+
continue;
|
|
768
|
+
}
|
|
607
769
|
if (typeof value === "object" && STRUCTURED_KINDS.has(col2.type.kind)) continue;
|
|
608
770
|
issues.push(
|
|
609
771
|
`${clause}: "${key}" got ${describeValue(value)}, which cannot be bound to a ${col2.type.kind} column \u2014 use sql.raw()/sql.expr\`...\` for a SQL expression`
|
|
@@ -611,11 +773,23 @@ function assertWritableValues(model, values, clause) {
|
|
|
611
773
|
}
|
|
612
774
|
if (issues.length > 0) throw new ValidationError(model.tablename, issues);
|
|
613
775
|
}
|
|
776
|
+
function encodeWriteValues(model, values) {
|
|
777
|
+
const codecs = codecsOf(model);
|
|
778
|
+
if (!codecs) return values;
|
|
779
|
+
const out = { ...values };
|
|
780
|
+
for (const [key, codec] of Object.entries(codecs)) {
|
|
781
|
+
if (!(key in out)) continue;
|
|
782
|
+
const value = out[key];
|
|
783
|
+
if (isSqlExpression(value) || isExpression(value)) continue;
|
|
784
|
+
out[key] = codec.toDb(value);
|
|
785
|
+
}
|
|
786
|
+
return out;
|
|
787
|
+
}
|
|
614
788
|
function assertConsistentRows(model, rows) {
|
|
615
789
|
if (rows.length < 2) return;
|
|
616
|
-
const
|
|
617
|
-
for (const row of rows) for (const key of Object.keys(row))
|
|
618
|
-
const inconsistent = [...
|
|
790
|
+
const union2 = /* @__PURE__ */ new Set();
|
|
791
|
+
for (const row of rows) for (const key of Object.keys(row)) union2.add(key);
|
|
792
|
+
const inconsistent = [...union2].filter((key) => rows.some((row) => !(key in row)));
|
|
619
793
|
if (inconsistent.length === 0) return;
|
|
620
794
|
const columns = columnsOf(model);
|
|
621
795
|
const defaulted = inconsistent.filter((key) => columns[key]?.flags.hasDefault);
|
|
@@ -653,9 +827,10 @@ var InsertBuilder = class _InsertBuilder {
|
|
|
653
827
|
*/
|
|
654
828
|
values(rows) {
|
|
655
829
|
const list = Array.isArray(rows) ? rows : [rows];
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
830
|
+
const encoded = list.map((row) => encodeWriteValues(this.source, row));
|
|
831
|
+
for (const row of encoded) assertWritableValues(this.source, row, "values");
|
|
832
|
+
assertConsistentRows(this.source, encoded);
|
|
833
|
+
return this.with({ values: encoded });
|
|
659
834
|
}
|
|
660
835
|
/**
|
|
661
836
|
* On a unique/PK conflict on `target`, do nothing (skip the row).
|
|
@@ -706,6 +881,34 @@ var InsertBuilder = class _InsertBuilder {
|
|
|
706
881
|
}
|
|
707
882
|
});
|
|
708
883
|
}
|
|
884
|
+
/**
|
|
885
|
+
* Fill the table from another query — `INSERT INTO t (a, b) SELECT …`.
|
|
886
|
+
*
|
|
887
|
+
* The rows never leave the database, which is the point: archiving or copying a
|
|
888
|
+
* million rows should not become a million round trips through this process.
|
|
889
|
+
*
|
|
890
|
+
* @param columns The target columns, in the order the query projects them.
|
|
891
|
+
* @param query The query producing the rows.
|
|
892
|
+
* @returns A builder ready to execute.
|
|
893
|
+
* @throws Error When no target column is given.
|
|
894
|
+
*
|
|
895
|
+
* @example
|
|
896
|
+
* ```ts
|
|
897
|
+
* insert(ArchivedOrder).fromSelect(
|
|
898
|
+
* ["id", "total"],
|
|
899
|
+
* select(Order, ["id", "total"]).where({ createdAt: { lt: cutoff } }),
|
|
900
|
+
* );
|
|
901
|
+
* ```
|
|
902
|
+
*/
|
|
903
|
+
fromSelect(columns, query) {
|
|
904
|
+
if (columns.length === 0) {
|
|
905
|
+
throw new Error("fromSelect() needs at least one target column.");
|
|
906
|
+
}
|
|
907
|
+
return this.with({
|
|
908
|
+
values: [],
|
|
909
|
+
fromSelect: { columns: [...columns], select: query.node }
|
|
910
|
+
});
|
|
911
|
+
}
|
|
709
912
|
returning(columns) {
|
|
710
913
|
return this.with({ returning: columns ?? "*" });
|
|
711
914
|
}
|
|
@@ -717,11 +920,21 @@ function insert(model) {
|
|
|
717
920
|
table: model.tablename,
|
|
718
921
|
values: [],
|
|
719
922
|
returning: null,
|
|
720
|
-
names: columnNamesOf(model) ?? void 0
|
|
923
|
+
names: columnNamesOf(model) ?? void 0,
|
|
924
|
+
codecs: codecsOf(model) ?? void 0
|
|
721
925
|
},
|
|
722
926
|
model
|
|
723
927
|
);
|
|
724
928
|
}
|
|
929
|
+
function withOnUpdateValues(model, values) {
|
|
930
|
+
const merged = { ...values };
|
|
931
|
+
for (const [name, col2] of Object.entries(columnsOf(model))) {
|
|
932
|
+
if (col2.onUpdateValue !== null && !(name in merged)) {
|
|
933
|
+
merged[name] = defaultAsWriteValue(col2.onUpdateValue);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
return merged;
|
|
937
|
+
}
|
|
725
938
|
var UpdateBuilder = class _UpdateBuilder {
|
|
726
939
|
constructor(node, source) {
|
|
727
940
|
this.node = node;
|
|
@@ -752,8 +965,30 @@ var UpdateBuilder = class _UpdateBuilder {
|
|
|
752
965
|
* ```
|
|
753
966
|
*/
|
|
754
967
|
set(values) {
|
|
755
|
-
|
|
756
|
-
|
|
968
|
+
const encoded = encodeWriteValues(this.source, values);
|
|
969
|
+
assertWritableValues(this.source, encoded, "set");
|
|
970
|
+
return this.with({
|
|
971
|
+
set: withOnUpdateValues(this.source, encoded)
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
/**
|
|
975
|
+
* Read from another table while updating — `UPDATE t SET … FROM other WHERE …`.
|
|
976
|
+
*
|
|
977
|
+
* The join condition goes in `where`, where SQL wants it:
|
|
978
|
+
* `.where({ customerId: col("c.id") })`.
|
|
979
|
+
*
|
|
980
|
+
* PostgreSQL and SQLite (3.33+) only. MySQL spells this as a multi-table
|
|
981
|
+
* `UPDATE a JOIN b`, which is out of this project's active scope, so it throws
|
|
982
|
+
* there rather than emitting something the server rejects.
|
|
983
|
+
*
|
|
984
|
+
* @param model The extra source.
|
|
985
|
+
* @param alias The name to reference it by.
|
|
986
|
+
* @returns A builder carrying the extra source.
|
|
987
|
+
*/
|
|
988
|
+
from(model, alias) {
|
|
989
|
+
return this.with({
|
|
990
|
+
from: [...this.node.from ?? [], { table: model.tablename, alias }]
|
|
991
|
+
});
|
|
757
992
|
}
|
|
758
993
|
/** Restrict the rows to update. Marks the builder safe to execute. */
|
|
759
994
|
where(input) {
|
|
@@ -779,7 +1014,8 @@ function update(model) {
|
|
|
779
1014
|
where: void 0,
|
|
780
1015
|
guarded: false,
|
|
781
1016
|
returning: null,
|
|
782
|
-
names: columnNamesOf(model) ?? void 0
|
|
1017
|
+
names: columnNamesOf(model) ?? void 0,
|
|
1018
|
+
codecs: codecsOf(model) ?? void 0
|
|
783
1019
|
},
|
|
784
1020
|
model
|
|
785
1021
|
);
|
|
@@ -794,6 +1030,22 @@ var DeleteBuilder = class _DeleteBuilder {
|
|
|
794
1030
|
with(patch) {
|
|
795
1031
|
return new _DeleteBuilder({ ...this.node, ...patch }, this.source);
|
|
796
1032
|
}
|
|
1033
|
+
/**
|
|
1034
|
+
* Delete by matching another table — `DELETE FROM t USING other WHERE …`.
|
|
1035
|
+
*
|
|
1036
|
+
* PostgreSQL only. SQLite and MySQL have no `USING` here; there the portable
|
|
1037
|
+
* form is `where({ id: { in: select(Other, ["id"]).where(...).asSubquery("id") } })`,
|
|
1038
|
+
* and this throws rather than pretending.
|
|
1039
|
+
*
|
|
1040
|
+
* @param model The extra source.
|
|
1041
|
+
* @param alias The name to reference it by.
|
|
1042
|
+
* @returns A builder carrying the extra source.
|
|
1043
|
+
*/
|
|
1044
|
+
using(model, alias) {
|
|
1045
|
+
return this.with({
|
|
1046
|
+
using: [...this.node.using ?? [], { table: model.tablename, alias }]
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
797
1049
|
/** Restrict the rows to delete. Marks the builder safe to execute. */
|
|
798
1050
|
where(input) {
|
|
799
1051
|
return this.with({
|
|
@@ -817,7 +1069,8 @@ function del(model) {
|
|
|
817
1069
|
where: void 0,
|
|
818
1070
|
guarded: false,
|
|
819
1071
|
returning: null,
|
|
820
|
-
names: columnNamesOf(model) ?? void 0
|
|
1072
|
+
names: columnNamesOf(model) ?? void 0,
|
|
1073
|
+
codecs: codecsOf(model) ?? void 0
|
|
821
1074
|
},
|
|
822
1075
|
model
|
|
823
1076
|
);
|
|
@@ -921,10 +1174,15 @@ function detectDialect(url) {
|
|
|
921
1174
|
}
|
|
922
1175
|
|
|
923
1176
|
// src/expressions.ts
|
|
1177
|
+
function renderExcluded(column2, bare, dialect) {
|
|
1178
|
+
return dialect === "mysql" ? `VALUES(${bare})` : `excluded.${column2}`;
|
|
1179
|
+
}
|
|
924
1180
|
function renderPortableToken(token, dialect) {
|
|
925
1181
|
switch (token) {
|
|
926
1182
|
case "now":
|
|
927
|
-
|
|
1183
|
+
if (dialect === "postgresql") return "now()";
|
|
1184
|
+
if (dialect === "sqlite") return "strftime('%Y-%m-%dT%H:%M:%fZ', 'now')";
|
|
1185
|
+
return "CURRENT_TIMESTAMP";
|
|
928
1186
|
case "current_date":
|
|
929
1187
|
return "CURRENT_DATE";
|
|
930
1188
|
case "current_time":
|
|
@@ -936,8 +1194,76 @@ function renderPortableToken(token, dialect) {
|
|
|
936
1194
|
}
|
|
937
1195
|
}
|
|
938
1196
|
|
|
1197
|
+
// src/search.ts
|
|
1198
|
+
function escapeLike(value) {
|
|
1199
|
+
return value.replace(/[\\%_]/g, (char) => `\\${char}`);
|
|
1200
|
+
}
|
|
1201
|
+
function tokenize(term) {
|
|
1202
|
+
return term.split(/\s+/).filter((token) => token.length > 0);
|
|
1203
|
+
}
|
|
1204
|
+
function contains(columns, term, options) {
|
|
1205
|
+
if (columns.length === 0) {
|
|
1206
|
+
throw new Error("contains() needs at least one column to search.");
|
|
1207
|
+
}
|
|
1208
|
+
const tokens = tokenize(term);
|
|
1209
|
+
if (tokens.length === 0) return alwaysFalse();
|
|
1210
|
+
const perToken = tokens.map(
|
|
1211
|
+
(token) => or(
|
|
1212
|
+
...columns.map((column2) => ({ [column2]: { iContains: token } }))
|
|
1213
|
+
)
|
|
1214
|
+
);
|
|
1215
|
+
if (options?.match === "any") return or(...perToken);
|
|
1216
|
+
return perToken.length === 1 ? perToken[0] : and(...perToken);
|
|
1217
|
+
}
|
|
1218
|
+
function alwaysFalse() {
|
|
1219
|
+
return conditionFromNode({
|
|
1220
|
+
kind: "compare",
|
|
1221
|
+
left: { kind: "value", value: 1 },
|
|
1222
|
+
op: "eq",
|
|
1223
|
+
right: { kind: "value", value: 0 }
|
|
1224
|
+
});
|
|
1225
|
+
}
|
|
1226
|
+
function fullText(columns, term, options) {
|
|
1227
|
+
if (columns.length === 0) {
|
|
1228
|
+
throw new Error("fullText() needs at least one column to search.");
|
|
1229
|
+
}
|
|
1230
|
+
return conditionFromNode({
|
|
1231
|
+
kind: "fullText",
|
|
1232
|
+
columns: [...columns],
|
|
1233
|
+
term,
|
|
1234
|
+
language: options?.language ?? "english",
|
|
1235
|
+
fallback: contains(columns, term).node
|
|
1236
|
+
});
|
|
1237
|
+
}
|
|
1238
|
+
function fullTextRank(columns, term, options) {
|
|
1239
|
+
if (columns.length === 0) {
|
|
1240
|
+
throw new Error("fullTextRank() needs at least one column to rank on.");
|
|
1241
|
+
}
|
|
1242
|
+
return expressionFromNode({
|
|
1243
|
+
kind: "rank",
|
|
1244
|
+
columns: [...columns],
|
|
1245
|
+
term,
|
|
1246
|
+
language: options?.language ?? "english"
|
|
1247
|
+
});
|
|
1248
|
+
}
|
|
1249
|
+
|
|
939
1250
|
// src/dialect.ts
|
|
940
1251
|
var OPERATOR_SET = new Set(OPERATORS);
|
|
1252
|
+
var MULTI_VALUE_OPERATORS = /* @__PURE__ */ new Set(["in", "notIn", "between"]);
|
|
1253
|
+
function encodeOperand(op, operand, encode) {
|
|
1254
|
+
if (op === "isNull") return operand;
|
|
1255
|
+
if (MULTI_VALUE_OPERATORS.has(op)) {
|
|
1256
|
+
return Array.isArray(operand) ? operand.map(encode) : operand;
|
|
1257
|
+
}
|
|
1258
|
+
return encode(operand);
|
|
1259
|
+
}
|
|
1260
|
+
function codecEncoder(codecs) {
|
|
1261
|
+
if (!codecs) return (_key, value) => value;
|
|
1262
|
+
return (key, value) => {
|
|
1263
|
+
const codec = codecs[key];
|
|
1264
|
+
return codec ? codec.toDb(value) : value;
|
|
1265
|
+
};
|
|
1266
|
+
}
|
|
941
1267
|
function isOperatorObject(value) {
|
|
942
1268
|
if (typeof value !== "object" || value === null || Array.isArray(value) || value instanceof Date || value instanceof Uint8Array) {
|
|
943
1269
|
return false;
|
|
@@ -991,6 +1317,20 @@ var BaseDialect = class _BaseDialect {
|
|
|
991
1317
|
static insertTemplates = /* @__PURE__ */ new Map();
|
|
992
1318
|
/** Quoted-identifier cache (see {@link quoteId}). Shared across dialects. */
|
|
993
1319
|
static quotedIds = /* @__PURE__ */ new Map();
|
|
1320
|
+
/**
|
|
1321
|
+
* Render a case-insensitive LIKE whose pattern carries escaped wildcards.
|
|
1322
|
+
*
|
|
1323
|
+
* The `ESCAPE` clause is not decoration: PostgreSQL treats `\` as the escape
|
|
1324
|
+
* character by default, **SQLite has none at all** until one is declared, so
|
|
1325
|
+
* without this the escaping done on our side would be meaningless there.
|
|
1326
|
+
*
|
|
1327
|
+
* @param column The rendered column.
|
|
1328
|
+
* @param param The bound pattern.
|
|
1329
|
+
* @returns The rendered comparison.
|
|
1330
|
+
*/
|
|
1331
|
+
ilikeEscaped(column2, param) {
|
|
1332
|
+
return `${this.ilike(column2, param)} ESCAPE '\\'`;
|
|
1333
|
+
}
|
|
994
1334
|
/**
|
|
995
1335
|
* Validate a subquery operand before it is rendered, for dialects that restrict
|
|
996
1336
|
* what an `IN (SELECT ...)` may contain. The default accepts everything.
|
|
@@ -1050,9 +1390,105 @@ var BaseDialect = class _BaseDialect {
|
|
|
1050
1390
|
case "join_select":
|
|
1051
1391
|
sql2 = this.compileJoin(node, params);
|
|
1052
1392
|
break;
|
|
1393
|
+
case "set_op":
|
|
1394
|
+
sql2 = this.compileSetOp(node, params);
|
|
1395
|
+
break;
|
|
1053
1396
|
}
|
|
1054
1397
|
return { sql: sql2, params: params.values };
|
|
1055
1398
|
}
|
|
1399
|
+
/**
|
|
1400
|
+
* Render a condition as schema SQL, with values inlined.
|
|
1401
|
+
*
|
|
1402
|
+
* Same compiler as a `WHERE`, different parameter strategy — so a `CHECK` and
|
|
1403
|
+
* the query language cannot drift apart in what they mean.
|
|
1404
|
+
*
|
|
1405
|
+
* @param node The condition.
|
|
1406
|
+
* @param params A {@link LiteralParams}.
|
|
1407
|
+
* @returns The rendered predicate.
|
|
1408
|
+
*/
|
|
1409
|
+
renderConditionLiteral(node, params) {
|
|
1410
|
+
return this.compileCondition(node, params, (key) => this.columnId(key, void 0));
|
|
1411
|
+
}
|
|
1412
|
+
/**
|
|
1413
|
+
* Render the `WITH` clause of a statement.
|
|
1414
|
+
*
|
|
1415
|
+
* `RECURSIVE` is a property of the **clause**, not of an entry: one recursive
|
|
1416
|
+
* entry makes the whole `WITH` recursive, which is what the SQL standard says
|
|
1417
|
+
* and what PostgreSQL and SQLite both implement.
|
|
1418
|
+
*
|
|
1419
|
+
* @param entries The `WITH` entries, if any.
|
|
1420
|
+
* @param params The parameter collector.
|
|
1421
|
+
* @returns The clause with a trailing space, or an empty string.
|
|
1422
|
+
*/
|
|
1423
|
+
compileWith(entries, params) {
|
|
1424
|
+
if (!entries || entries.length === 0) return "";
|
|
1425
|
+
const recursive = entries.some((entry) => entry.recursive) ? "RECURSIVE " : "";
|
|
1426
|
+
const rendered = entries.map((entry) => {
|
|
1427
|
+
const body = entry.body.kind === "set_op" ? this.compileSetOp(entry.body, params) : this.compileSelect(entry.body, params);
|
|
1428
|
+
const hint = entry.materialized === null ? "" : entry.materialized ? " MATERIALIZED" : " NOT MATERIALIZED";
|
|
1429
|
+
return `${this.quoteId(entry.name)} AS${hint} (${body})`;
|
|
1430
|
+
});
|
|
1431
|
+
return `WITH ${recursive}${rendered.join(", ")} `;
|
|
1432
|
+
}
|
|
1433
|
+
/**
|
|
1434
|
+
* The `EXPLAIN` prefix for this dialect.
|
|
1435
|
+
*
|
|
1436
|
+
* @param analyze Whether to measure by actually running the statement.
|
|
1437
|
+
* @returns The prefix to put in front of the statement.
|
|
1438
|
+
* @throws Error When the dialect cannot do what was asked.
|
|
1439
|
+
*/
|
|
1440
|
+
explainPrefix(analyze) {
|
|
1441
|
+
return analyze ? "EXPLAIN (FORMAT JSON, ANALYZE)" : "EXPLAIN (FORMAT JSON)";
|
|
1442
|
+
}
|
|
1443
|
+
/**
|
|
1444
|
+
* The SQL keyword for a set operation.
|
|
1445
|
+
*
|
|
1446
|
+
* @param op The operator.
|
|
1447
|
+
* @returns The keyword.
|
|
1448
|
+
* @throws Error On a dialect that does not implement it.
|
|
1449
|
+
*/
|
|
1450
|
+
setOperator(op) {
|
|
1451
|
+
switch (op) {
|
|
1452
|
+
case "union":
|
|
1453
|
+
return "UNION";
|
|
1454
|
+
case "unionAll":
|
|
1455
|
+
return "UNION ALL";
|
|
1456
|
+
case "intersect":
|
|
1457
|
+
return "INTERSECT";
|
|
1458
|
+
case "except":
|
|
1459
|
+
return "EXCEPT";
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
/**
|
|
1463
|
+
* Compile a set operation.
|
|
1464
|
+
*
|
|
1465
|
+
* A branch carrying its own `ORDER BY`/`LIMIT` is parenthesized: without the
|
|
1466
|
+
* parentheses those clauses bind to the **combined** result, which is a
|
|
1467
|
+
* different query and a classic source of silently wrong output.
|
|
1468
|
+
*
|
|
1469
|
+
* @param node The set-operation node.
|
|
1470
|
+
* @param params The parameter collector.
|
|
1471
|
+
* @returns The rendered statement.
|
|
1472
|
+
*/
|
|
1473
|
+
compileSetOp(node, params) {
|
|
1474
|
+
const keyword = ` ${this.setOperator(node.op)} `;
|
|
1475
|
+
const branches = node.branches.map((branch) => {
|
|
1476
|
+
const sql3 = branch.kind === "join_select" ? this.compileJoin(branch, params) : this.compileSelect(branch, params);
|
|
1477
|
+
const scoped = branch.orderBy.length > 0 || branch.limit !== void 0;
|
|
1478
|
+
return scoped ? `(${sql3})` : sql3;
|
|
1479
|
+
});
|
|
1480
|
+
let sql2 = branches.join(keyword);
|
|
1481
|
+
if (node.orderBy.length > 0) {
|
|
1482
|
+
const terms = node.orderBy.map((t) => {
|
|
1483
|
+
const id = typeof t.column === "string" ? this.quoteId(t.column) : this.renderExpr(t.column, params, (k) => this.quoteId(k));
|
|
1484
|
+
return `${id} ${t.direction === "desc" ? "DESC" : "ASC"}`;
|
|
1485
|
+
}).join(", ");
|
|
1486
|
+
sql2 += ` ORDER BY ${terms}`;
|
|
1487
|
+
}
|
|
1488
|
+
if (node.limit !== void 0) sql2 += ` LIMIT ${params.bind(node.limit)}`;
|
|
1489
|
+
if (node.offset !== void 0) sql2 += ` OFFSET ${params.bind(node.offset)}`;
|
|
1490
|
+
return sql2;
|
|
1491
|
+
}
|
|
1056
1492
|
/**
|
|
1057
1493
|
* Render a qualified `alias.column` ref as `"alias"."column"`, translating the
|
|
1058
1494
|
* property name to the real column name for that alias's model.
|
|
@@ -1080,7 +1516,13 @@ var BaseDialect = class _BaseDialect {
|
|
|
1080
1516
|
* @returns The quoted database identifier.
|
|
1081
1517
|
*/
|
|
1082
1518
|
columnId(prop, names) {
|
|
1083
|
-
|
|
1519
|
+
const mapped = names?.[prop];
|
|
1520
|
+
if (mapped !== void 0) return this.quoteId(mapped);
|
|
1521
|
+
const dot = prop.indexOf(".");
|
|
1522
|
+
if (dot > 0 && dot < prop.length - 1) {
|
|
1523
|
+
return `${this.quoteId(prop.slice(0, dot))}.${this.quoteId(prop.slice(dot + 1))}`;
|
|
1524
|
+
}
|
|
1525
|
+
return this.quoteId(prop);
|
|
1084
1526
|
}
|
|
1085
1527
|
/**
|
|
1086
1528
|
* Render a {@link SqlExpression} inline, binding the parameters it carries.
|
|
@@ -1097,6 +1539,9 @@ var BaseDialect = class _BaseDialect {
|
|
|
1097
1539
|
const token = expr.expression;
|
|
1098
1540
|
if (typeof token === "string") return renderPortableToken(token, this.name);
|
|
1099
1541
|
if ("raw" in token) return token.raw;
|
|
1542
|
+
if ("excluded" in token) {
|
|
1543
|
+
return renderExcluded(this.quoteId(token.excluded), token.excluded, this.name);
|
|
1544
|
+
}
|
|
1100
1545
|
const parts = token.parts;
|
|
1101
1546
|
let sql2 = parts[0] ?? "";
|
|
1102
1547
|
for (let i = 1; i < parts.length; i++) {
|
|
@@ -1106,7 +1551,29 @@ var BaseDialect = class _BaseDialect {
|
|
|
1106
1551
|
}
|
|
1107
1552
|
/** Render one write value: a SQL expression inline, anything else as a parameter. */
|
|
1108
1553
|
renderValue(value, params) {
|
|
1109
|
-
|
|
1554
|
+
if (isSqlExpression(value)) return this.renderExpression(value, params);
|
|
1555
|
+
if (isExpression(value)) {
|
|
1556
|
+
return this.renderExpr(value.node, params, (k) => this.columnId(k, void 0));
|
|
1557
|
+
}
|
|
1558
|
+
return params.bind(value);
|
|
1559
|
+
}
|
|
1560
|
+
/**
|
|
1561
|
+
* The statements that open a transaction with the requested characteristics.
|
|
1562
|
+
*
|
|
1563
|
+
* Returned as a list because the dialects disagree on shape: PostgreSQL takes
|
|
1564
|
+
* everything on the `BEGIN` itself, MySQL needs a separate `SET TRANSACTION`
|
|
1565
|
+
* before it, and SQLite has no syntax at all.
|
|
1566
|
+
*
|
|
1567
|
+
* @param options The requested isolation level and read-only flag.
|
|
1568
|
+
* @returns The statements to run, in order.
|
|
1569
|
+
* @throws Error When the dialect cannot honor what was asked.
|
|
1570
|
+
*/
|
|
1571
|
+
beginStatements(options) {
|
|
1572
|
+
const parts = ["BEGIN"];
|
|
1573
|
+
if (options?.isolation)
|
|
1574
|
+
parts.push(`ISOLATION LEVEL ${options.isolation.toUpperCase()}`);
|
|
1575
|
+
if (options?.readOnly) parts.push("READ ONLY");
|
|
1576
|
+
return [parts.join(" ")];
|
|
1110
1577
|
}
|
|
1111
1578
|
/**
|
|
1112
1579
|
* Render a row-level locking clause (`FOR UPDATE ...`).
|
|
@@ -1142,18 +1609,24 @@ var BaseDialect = class _BaseDialect {
|
|
|
1142
1609
|
if (node.aggregates.length > 0) {
|
|
1143
1610
|
const groupSel = node.groupBy.map((c) => this.columnId(c, names));
|
|
1144
1611
|
const aggSel = node.aggregates.map((a) => {
|
|
1145
|
-
const inner =
|
|
1612
|
+
const inner = this.aggregateOperand(a, params, names);
|
|
1146
1613
|
return `${a.fn.toUpperCase()}(${inner}) AS ${this.quoteId(a.alias)}`;
|
|
1147
1614
|
});
|
|
1148
1615
|
cols = [...groupSel, ...aggSel].join(", ");
|
|
1149
1616
|
} else {
|
|
1150
1617
|
cols = node.columns === "*" ? "*" : node.columns.map((c) => this.columnId(c, names)).join(", ");
|
|
1151
1618
|
}
|
|
1152
|
-
|
|
1619
|
+
const computed = Object.entries(node.computed ?? {}).map(
|
|
1620
|
+
([alias, expr]) => `${this.renderExpr(expr, params, (k) => this.columnId(k, names))} AS ${this.quoteId(alias)}`
|
|
1621
|
+
);
|
|
1622
|
+
if (computed.length > 0) cols = [cols, ...computed].join(", ");
|
|
1623
|
+
const from = node.alias ? `${this.quoteId(node.table)} AS ${this.quoteId(node.alias)}` : this.quoteId(node.table);
|
|
1624
|
+
let sql2 = `${this.compileWith(node.with, params)}SELECT ${node.distinct ? "DISTINCT " : ""}${cols} FROM ${from}`;
|
|
1153
1625
|
const where = this.compileCondition(
|
|
1154
1626
|
node.where,
|
|
1155
1627
|
params,
|
|
1156
|
-
(k) => this.columnId(k, names)
|
|
1628
|
+
(k) => this.columnId(k, names),
|
|
1629
|
+
codecEncoder(node.codecs)
|
|
1157
1630
|
);
|
|
1158
1631
|
if (where) sql2 += ` WHERE ${where}`;
|
|
1159
1632
|
if (node.groupBy.length > 0) {
|
|
@@ -1164,14 +1637,14 @@ var BaseDialect = class _BaseDialect {
|
|
|
1164
1637
|
const having = this.compileCondition(node.having, params, (key) => {
|
|
1165
1638
|
const agg = aggByAlias.get(key);
|
|
1166
1639
|
if (!agg) return this.columnId(key, names);
|
|
1167
|
-
const inner =
|
|
1640
|
+
const inner = this.aggregateOperand(agg, params, names);
|
|
1168
1641
|
return `${agg.fn.toUpperCase()}(${inner})`;
|
|
1169
1642
|
});
|
|
1170
1643
|
if (having) sql2 += ` HAVING ${having}`;
|
|
1171
1644
|
}
|
|
1172
1645
|
if (node.orderBy.length > 0) {
|
|
1173
1646
|
const terms = node.orderBy.map((t) => {
|
|
1174
|
-
const id = aggByAlias.has(t.column) ? this.quoteId(t.column) : this.columnId(t.column, names);
|
|
1647
|
+
const id = typeof t.column !== "string" ? this.renderExpr(t.column, params, (k) => this.columnId(k, names)) : aggByAlias.has(t.column) ? this.quoteId(t.column) : this.columnId(t.column, names);
|
|
1175
1648
|
return `${id} ${t.direction === "desc" ? "DESC" : "ASC"}`;
|
|
1176
1649
|
}).join(", ");
|
|
1177
1650
|
sql2 += ` ORDER BY ${terms}`;
|
|
@@ -1197,6 +1670,13 @@ var BaseDialect = class _BaseDialect {
|
|
|
1197
1670
|
* SQL order, so placeholder positions stay correct.
|
|
1198
1671
|
*/
|
|
1199
1672
|
compileInsert(node, params) {
|
|
1673
|
+
if (node.fromSelect) {
|
|
1674
|
+
const target = node.fromSelect.columns.map((c) => this.columnId(c, node.names)).join(", ");
|
|
1675
|
+
const source = node.fromSelect.select;
|
|
1676
|
+
const query = source.kind === "join_select" ? this.compileJoin(source, params) : source.kind === "set_op" ? this.compileSetOp(source, params) : this.compileSelect(source, params);
|
|
1677
|
+
const returning = this.compileReturning(node.returning, node.names);
|
|
1678
|
+
return `INSERT INTO ${this.quoteId(node.table)} (${target}) ${query}${returning}`;
|
|
1679
|
+
}
|
|
1200
1680
|
const columns = insertColumns(node.values);
|
|
1201
1681
|
const conflict = node.onConflict;
|
|
1202
1682
|
const cacheable = conflict?.targetWhere === void 0 && conflict?.updateWhere === void 0 && !insertHasExpression(node);
|
|
@@ -1312,10 +1792,12 @@ var BaseDialect = class _BaseDialect {
|
|
|
1312
1792
|
([col2, value]) => `${this.columnId(col2, names)} = ${this.renderValue(value, params)}`
|
|
1313
1793
|
).join(", ");
|
|
1314
1794
|
let sql2 = `UPDATE ${this.quoteId(node.table)} SET ${sets}`;
|
|
1795
|
+
sql2 += this.compileExtraSources("FROM", node.from);
|
|
1315
1796
|
const where = this.compileCondition(
|
|
1316
1797
|
node.where,
|
|
1317
1798
|
params,
|
|
1318
|
-
(k) => this.columnId(k, names)
|
|
1799
|
+
(k) => this.columnId(k, names),
|
|
1800
|
+
codecEncoder(node.codecs)
|
|
1319
1801
|
);
|
|
1320
1802
|
if (where) sql2 += ` WHERE ${where}`;
|
|
1321
1803
|
sql2 += this.compileReturning(node.returning, names);
|
|
@@ -1324,10 +1806,12 @@ var BaseDialect = class _BaseDialect {
|
|
|
1324
1806
|
compileDelete(node, params) {
|
|
1325
1807
|
const names = node.names;
|
|
1326
1808
|
let sql2 = `DELETE FROM ${this.quoteId(node.table)}`;
|
|
1809
|
+
sql2 += this.compileExtraSources("USING", node.using);
|
|
1327
1810
|
const where = this.compileCondition(
|
|
1328
1811
|
node.where,
|
|
1329
1812
|
params,
|
|
1330
|
-
(k) => this.columnId(k, names)
|
|
1813
|
+
(k) => this.columnId(k, names),
|
|
1814
|
+
codecEncoder(node.codecs)
|
|
1331
1815
|
);
|
|
1332
1816
|
if (where) sql2 += ` WHERE ${where}`;
|
|
1333
1817
|
sql2 += this.compileReturning(node.returning, names);
|
|
@@ -1337,9 +1821,10 @@ var BaseDialect = class _BaseDialect {
|
|
|
1337
1821
|
const names = node.names;
|
|
1338
1822
|
const cols = node.selections.map((s) => {
|
|
1339
1823
|
const ref = `${s.alias}.${s.column}`;
|
|
1340
|
-
|
|
1824
|
+
const label = node.pick ? s.column : ref;
|
|
1825
|
+
return `${this.qualify(ref, names)} AS ${this.quoteId(label)}`;
|
|
1341
1826
|
}).join(", ");
|
|
1342
|
-
let sql2 =
|
|
1827
|
+
let sql2 = `${this.compileWith(node.with, params)}SELECT ${cols} FROM ${this.quoteId(node.base.table)} AS ${this.quoteId(node.base.alias)}`;
|
|
1343
1828
|
for (const j of node.joins) {
|
|
1344
1829
|
const kw = j.kind === "left" ? "LEFT JOIN" : "INNER JOIN";
|
|
1345
1830
|
const on = j.on.map(([l, r]) => `${this.qualify(l, names)} = ${this.qualify(r, names)}`).join(" AND ");
|
|
@@ -1362,6 +1847,22 @@ var BaseDialect = class _BaseDialect {
|
|
|
1362
1847
|
return sql2;
|
|
1363
1848
|
}
|
|
1364
1849
|
// ---- clauses ----------------------------------------------------------
|
|
1850
|
+
/**
|
|
1851
|
+
* Render the extra sources of an `UPDATE ... FROM` / `DELETE ... USING`.
|
|
1852
|
+
*
|
|
1853
|
+
* The dialects that do not have the clause override this and throw: emitting it
|
|
1854
|
+
* anyway would produce a statement the server rejects, and quietly dropping it
|
|
1855
|
+
* would change which rows are written.
|
|
1856
|
+
*
|
|
1857
|
+
* @param keyword `FROM` or `USING`.
|
|
1858
|
+
* @param sources The extra tables, if any.
|
|
1859
|
+
* @returns The clause with a leading space, or an empty string.
|
|
1860
|
+
*/
|
|
1861
|
+
compileExtraSources(keyword, sources) {
|
|
1862
|
+
if (!sources || sources.length === 0) return "";
|
|
1863
|
+
const list = sources.map((s) => `${this.quoteId(s.table)} AS ${this.quoteId(s.alias)}`).join(", ");
|
|
1864
|
+
return ` ${keyword} ${list}`;
|
|
1865
|
+
}
|
|
1365
1866
|
compileReturning(returning, names) {
|
|
1366
1867
|
if (returning === null) return "";
|
|
1367
1868
|
if (returning === "*") return " RETURNING *";
|
|
@@ -1372,20 +1873,40 @@ var BaseDialect = class _BaseDialect {
|
|
|
1372
1873
|
* key to a quoted identifier — `quoteId` for single-table, `qualify` for joins —
|
|
1373
1874
|
* so select/update/delete/join all share this one compiler.
|
|
1374
1875
|
*/
|
|
1375
|
-
compileCondition(node, params, idFor) {
|
|
1876
|
+
compileCondition(node, params, idFor, encode = (_key, value) => value) {
|
|
1376
1877
|
if (!node) return "";
|
|
1377
1878
|
switch (node.kind) {
|
|
1378
1879
|
case "fields": {
|
|
1379
1880
|
const conditions = [];
|
|
1380
1881
|
for (const [key, value] of Object.entries(node.fields)) {
|
|
1381
1882
|
const id = idFor(key);
|
|
1382
|
-
if (
|
|
1883
|
+
if (isExpression(value)) {
|
|
1884
|
+
conditions.push(
|
|
1885
|
+
this.compileExprOperator(
|
|
1886
|
+
id,
|
|
1887
|
+
"eq",
|
|
1888
|
+
this.renderExpr(value.node, params, idFor)
|
|
1889
|
+
)
|
|
1890
|
+
);
|
|
1891
|
+
} else if (isOperatorObject(value)) {
|
|
1383
1892
|
for (const [op, operand] of Object.entries(value)) {
|
|
1384
|
-
conditions.push(
|
|
1893
|
+
conditions.push(
|
|
1894
|
+
isExpression(operand) ? this.compileExprOperator(
|
|
1895
|
+
id,
|
|
1896
|
+
op,
|
|
1897
|
+
this.renderExpr(operand.node, params, idFor)
|
|
1898
|
+
) : this.compileOperator(
|
|
1899
|
+
id,
|
|
1900
|
+
op,
|
|
1901
|
+
encodeOperand(op, operand, (v) => encode(key, v)),
|
|
1902
|
+
params
|
|
1903
|
+
)
|
|
1904
|
+
);
|
|
1385
1905
|
}
|
|
1386
1906
|
} else {
|
|
1907
|
+
const operand = encode(key, value);
|
|
1387
1908
|
conditions.push(
|
|
1388
|
-
|
|
1909
|
+
operand === null ? `${id} IS NULL` : `${id} = ${params.bind(operand)}`
|
|
1389
1910
|
);
|
|
1390
1911
|
}
|
|
1391
1912
|
}
|
|
@@ -1393,15 +1914,23 @@ var BaseDialect = class _BaseDialect {
|
|
|
1393
1914
|
}
|
|
1394
1915
|
case "and":
|
|
1395
1916
|
case "or": {
|
|
1396
|
-
const parts = node.parts.map((p) => this.compileCondition(p, params, idFor)).filter((s) => s.length > 0);
|
|
1917
|
+
const parts = node.parts.map((p) => this.compileCondition(p, params, idFor, encode)).filter((s) => s.length > 0);
|
|
1397
1918
|
if (parts.length === 0) return "";
|
|
1398
1919
|
const sep = node.kind === "and" ? " AND " : " OR ";
|
|
1399
1920
|
return parts.map((p) => `(${p})`).join(sep);
|
|
1400
1921
|
}
|
|
1401
1922
|
case "not": {
|
|
1402
|
-
const inner = this.compileCondition(node.part, params, idFor);
|
|
1923
|
+
const inner = this.compileCondition(node.part, params, idFor, encode);
|
|
1403
1924
|
return inner ? `NOT (${inner})` : "";
|
|
1404
1925
|
}
|
|
1926
|
+
case "exists": {
|
|
1927
|
+
const select2 = node.select;
|
|
1928
|
+
this.checkSubquery(select2);
|
|
1929
|
+
const keyword = node.negate ? "NOT EXISTS" : "EXISTS";
|
|
1930
|
+
return `${keyword} (${this.compileSelect(select2, params)})`;
|
|
1931
|
+
}
|
|
1932
|
+
case "fullText":
|
|
1933
|
+
return this.compileFullText(node, params, idFor);
|
|
1405
1934
|
case "compare": {
|
|
1406
1935
|
const left = this.renderExpr(node.left, params, idFor);
|
|
1407
1936
|
if (node.right.kind === "value") {
|
|
@@ -1415,6 +1944,22 @@ var BaseDialect = class _BaseDialect {
|
|
|
1415
1944
|
}
|
|
1416
1945
|
}
|
|
1417
1946
|
}
|
|
1947
|
+
/**
|
|
1948
|
+
* Compile a full-text condition.
|
|
1949
|
+
*
|
|
1950
|
+
* PostgreSQL gets the real thing (`@@ websearch_to_tsquery`); the dialects with
|
|
1951
|
+
* no text-search engine override this and compile the node's prebuilt substring
|
|
1952
|
+
* fallback instead, so the query still returns the right rows.
|
|
1953
|
+
*
|
|
1954
|
+
* @param node The full-text condition node.
|
|
1955
|
+
* @param params The parameter collector.
|
|
1956
|
+
* @param idFor Column-name resolver.
|
|
1957
|
+
* @returns The rendered condition.
|
|
1958
|
+
*/
|
|
1959
|
+
compileFullText(node, params, idFor) {
|
|
1960
|
+
const config = params.bind(node.language);
|
|
1961
|
+
return `${this.tsVector(node.columns, config, idFor)} @@ websearch_to_tsquery(${config}::regconfig, ${params.bind(node.term)})`;
|
|
1962
|
+
}
|
|
1418
1963
|
/**
|
|
1419
1964
|
* Render one side of a comparison.
|
|
1420
1965
|
*
|
|
@@ -1427,6 +1972,23 @@ var BaseDialect = class _BaseDialect {
|
|
|
1427
1972
|
* @param idFor The identifier resolver for the enclosing statement.
|
|
1428
1973
|
* @returns The SQL text of the expression.
|
|
1429
1974
|
*/
|
|
1975
|
+
/**
|
|
1976
|
+
* Render what an aggregate is applied to: `*`, a column, or an expression.
|
|
1977
|
+
*
|
|
1978
|
+
* An expression operand is what makes a conditional aggregate
|
|
1979
|
+
* (`SUM(CASE WHEN ... END)`) expressible — one pass over the table instead of a
|
|
1980
|
+
* query per bucket.
|
|
1981
|
+
*
|
|
1982
|
+
* @param agg The aggregate term.
|
|
1983
|
+
* @param params The parameter collector.
|
|
1984
|
+
* @param names The node's column-name map.
|
|
1985
|
+
* @returns The rendered operand.
|
|
1986
|
+
*/
|
|
1987
|
+
aggregateOperand(agg, params, names) {
|
|
1988
|
+
if (agg.column === "*") return "*";
|
|
1989
|
+
if (typeof agg.column === "string") return this.columnId(agg.column, names);
|
|
1990
|
+
return this.renderExpr(agg.column, params, (k) => this.columnId(k, names));
|
|
1991
|
+
}
|
|
1430
1992
|
renderExpr(node, params, idFor) {
|
|
1431
1993
|
switch (node.kind) {
|
|
1432
1994
|
case "column":
|
|
@@ -1437,6 +1999,109 @@ var BaseDialect = class _BaseDialect {
|
|
|
1437
1999
|
const args = node.args.map((a) => this.renderExpr(a, params, idFor)).join(", ");
|
|
1438
2000
|
return `${node.name}(${args})`;
|
|
1439
2001
|
}
|
|
2002
|
+
case "case": {
|
|
2003
|
+
const branches = node.branches.map(
|
|
2004
|
+
(b) => `WHEN ${this.compileCondition(b.when, params, idFor)} THEN ${this.renderExpr(b.result, params, idFor)}`
|
|
2005
|
+
).join(" ");
|
|
2006
|
+
const fallback = node.fallback === null ? "" : ` ELSE ${this.renderExpr(node.fallback, params, idFor)}`;
|
|
2007
|
+
return `CASE ${branches}${fallback} END`;
|
|
2008
|
+
}
|
|
2009
|
+
case "cast":
|
|
2010
|
+
return `CAST(${this.renderExpr(node.operand, params, idFor)} AS ${this.castTypeName(node.to)})`;
|
|
2011
|
+
case "scalar": {
|
|
2012
|
+
const select2 = node.select;
|
|
2013
|
+
this.checkSubquery(select2);
|
|
2014
|
+
return `(${this.compileSelect(select2, params)})`;
|
|
2015
|
+
}
|
|
2016
|
+
case "rank":
|
|
2017
|
+
return this.renderRank(node.columns, node.term, node.language, params, idFor);
|
|
2018
|
+
case "star":
|
|
2019
|
+
return "*";
|
|
2020
|
+
case "window": {
|
|
2021
|
+
const call2 = this.renderExpr(node.fn, params, idFor);
|
|
2022
|
+
const parts = [];
|
|
2023
|
+
if (node.partitionBy.length > 0) {
|
|
2024
|
+
parts.push(`PARTITION BY ${node.partitionBy.map(idFor).join(", ")}`);
|
|
2025
|
+
}
|
|
2026
|
+
if (node.orderBy.length > 0) {
|
|
2027
|
+
const terms = node.orderBy.map((t) => `${idFor(t.column)} ${t.direction === "desc" ? "DESC" : "ASC"}`).join(", ");
|
|
2028
|
+
parts.push(`ORDER BY ${terms}`);
|
|
2029
|
+
}
|
|
2030
|
+
if (node.frame) parts.push(node.frame);
|
|
2031
|
+
return `${call2} OVER (${parts.join(" ")})`;
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
/**
|
|
2036
|
+
* Render a full-text relevance score.
|
|
2037
|
+
*
|
|
2038
|
+
* PostgreSQL has `ts_rank`; the others have nothing equivalent, and they
|
|
2039
|
+
* override this to a constant so that ordering by it is a no-op rather than a
|
|
2040
|
+
* compile error — the fallback keeps returning the right rows, only unranked.
|
|
2041
|
+
*
|
|
2042
|
+
* @param columns The columns making up the document.
|
|
2043
|
+
* @param term The search term.
|
|
2044
|
+
* @param language The text-search configuration.
|
|
2045
|
+
* @param params The parameter collector.
|
|
2046
|
+
* @param idFor Column-name resolver.
|
|
2047
|
+
* @returns The rendered score expression.
|
|
2048
|
+
*/
|
|
2049
|
+
renderRank(columns, term, language, params, idFor) {
|
|
2050
|
+
const config = params.bind(language);
|
|
2051
|
+
return `ts_rank(${this.tsVector(columns, config, idFor)}, websearch_to_tsquery(${config}::regconfig, ${params.bind(term)}))`;
|
|
2052
|
+
}
|
|
2053
|
+
/**
|
|
2054
|
+
* Build the `to_tsvector(...)` document out of the searched columns.
|
|
2055
|
+
*
|
|
2056
|
+
* `coalesce(col, '')` matters: in SQL a `NULL` anywhere in a concatenation makes
|
|
2057
|
+
* the whole document `NULL`, so one empty column would silently exclude the row.
|
|
2058
|
+
*
|
|
2059
|
+
* @param columns The columns making up the document.
|
|
2060
|
+
* @param config The already-bound placeholder for the text-search config.
|
|
2061
|
+
* @param idFor Column-name resolver.
|
|
2062
|
+
* @returns The rendered `to_tsvector(...)` call.
|
|
2063
|
+
*/
|
|
2064
|
+
tsVector(columns, config, idFor) {
|
|
2065
|
+
const document = columns.map((c) => `coalesce(${idFor(c)}, '')`).join(" || ' ' || ");
|
|
2066
|
+
return `to_tsvector(${config}::regconfig, ${document})`;
|
|
2067
|
+
}
|
|
2068
|
+
/**
|
|
2069
|
+
* The SQL type name this dialect accepts in a `CAST`.
|
|
2070
|
+
*
|
|
2071
|
+
* The base mapping is the standard one PostgreSQL takes; SQLite and MySQL
|
|
2072
|
+
* override it, because the names genuinely differ (MySQL's `CAST(x AS SIGNED)`
|
|
2073
|
+
* is not `INTEGER`, and SQLite only has five storage classes to aim at).
|
|
2074
|
+
*
|
|
2075
|
+
* @param to The portable cast target.
|
|
2076
|
+
* @returns The dialect's own type name.
|
|
2077
|
+
*/
|
|
2078
|
+
castTypeName(to) {
|
|
2079
|
+
switch (to) {
|
|
2080
|
+
case "integer":
|
|
2081
|
+
return "INTEGER";
|
|
2082
|
+
case "bigint":
|
|
2083
|
+
return "BIGINT";
|
|
2084
|
+
case "real":
|
|
2085
|
+
return "DOUBLE PRECISION";
|
|
2086
|
+
case "numeric":
|
|
2087
|
+
return "NUMERIC";
|
|
2088
|
+
case "text":
|
|
2089
|
+
return "TEXT";
|
|
2090
|
+
case "boolean":
|
|
2091
|
+
return "BOOLEAN";
|
|
2092
|
+
case "date":
|
|
2093
|
+
return "DATE";
|
|
2094
|
+
case "datetime":
|
|
2095
|
+
case "timestamp":
|
|
2096
|
+
return "TIMESTAMP";
|
|
2097
|
+
case "uuid":
|
|
2098
|
+
return "UUID";
|
|
2099
|
+
case "json":
|
|
2100
|
+
return "JSON";
|
|
2101
|
+
case "jsonb":
|
|
2102
|
+
return "JSONB";
|
|
2103
|
+
case "blob":
|
|
2104
|
+
return "BYTEA";
|
|
1440
2105
|
}
|
|
1441
2106
|
}
|
|
1442
2107
|
/**
|
|
@@ -1473,6 +2138,10 @@ var BaseDialect = class _BaseDialect {
|
|
|
1473
2138
|
return this.ilike(left, right);
|
|
1474
2139
|
case "ieq":
|
|
1475
2140
|
return `lower(${left}) = lower(${right})`;
|
|
2141
|
+
case "iContains":
|
|
2142
|
+
throw new Error(
|
|
2143
|
+
'The "iContains" operator matches a literal, so it takes a value, not an expression.'
|
|
2144
|
+
);
|
|
1476
2145
|
case "contains":
|
|
1477
2146
|
case "containedBy":
|
|
1478
2147
|
case "overlaps":
|
|
@@ -1501,6 +2170,8 @@ var BaseDialect = class _BaseDialect {
|
|
|
1501
2170
|
return this.ilike(id, params.bind(operand));
|
|
1502
2171
|
case "ieq":
|
|
1503
2172
|
return operand === null ? `${id} IS NULL` : `lower(${id}) = lower(${params.bind(operand)})`;
|
|
2173
|
+
case "iContains":
|
|
2174
|
+
return this.ilikeEscaped(id, params.bind(`%${escapeLike(String(operand))}%`));
|
|
1504
2175
|
case "contains":
|
|
1505
2176
|
return `${id} ${this.arrayOperator("contains")} ${params.bind(operand)}`;
|
|
1506
2177
|
case "containedBy":
|
|
@@ -1552,12 +2223,93 @@ var BaseDialect = class _BaseDialect {
|
|
|
1552
2223
|
};
|
|
1553
2224
|
var SqliteDialect = class extends BaseDialect {
|
|
1554
2225
|
name = "sqlite";
|
|
2226
|
+
/**
|
|
2227
|
+
* SQLite explains with `EXPLAIN QUERY PLAN`, and has no `ANALYZE` — the plain
|
|
2228
|
+
* `EXPLAIN` there dumps bytecode, which answers a different question.
|
|
2229
|
+
*/
|
|
2230
|
+
explainPrefix(analyze) {
|
|
2231
|
+
if (analyze) {
|
|
2232
|
+
throw new Error(
|
|
2233
|
+
"SQLite has no EXPLAIN ANALYZE; use EXPLAIN QUERY PLAN (analyze: false)."
|
|
2234
|
+
);
|
|
2235
|
+
}
|
|
2236
|
+
return "EXPLAIN QUERY PLAN";
|
|
2237
|
+
}
|
|
2238
|
+
/**
|
|
2239
|
+
* SQLite has `UPDATE ... FROM` (3.33+) but no `DELETE ... USING`.
|
|
2240
|
+
*
|
|
2241
|
+
* The portable shape there is a subquery — `where({ id: { in: … } })` — so this
|
|
2242
|
+
* throws and says so, rather than emitting a clause SQLite does not parse.
|
|
2243
|
+
*/
|
|
2244
|
+
compileExtraSources(keyword, sources) {
|
|
2245
|
+
if (keyword === "USING" && sources && sources.length > 0) {
|
|
2246
|
+
throw new Error(
|
|
2247
|
+
'SQLite has no DELETE ... USING; filter with a subquery instead: where({ id: { in: select(Other, ["id"]).asSubquery("id") } }).'
|
|
2248
|
+
);
|
|
2249
|
+
}
|
|
2250
|
+
return super.compileExtraSources(keyword, sources);
|
|
2251
|
+
}
|
|
2252
|
+
/**
|
|
2253
|
+
* SQLite has no text-search engine, so the prebuilt substring fallback is
|
|
2254
|
+
* compiled instead. The rows are right; the ranking is what is missing.
|
|
2255
|
+
*/
|
|
2256
|
+
compileFullText(node, params, idFor) {
|
|
2257
|
+
return this.compileCondition(node.fallback, params, idFor);
|
|
2258
|
+
}
|
|
2259
|
+
/** No text-search engine means no score: a constant, so ordering by it is inert. */
|
|
2260
|
+
renderRank() {
|
|
2261
|
+
return "0";
|
|
2262
|
+
}
|
|
2263
|
+
/**
|
|
2264
|
+
* SQLite has five storage classes, so most targets collapse onto `TEXT` or
|
|
2265
|
+
* `INTEGER`. Naming a type it does not know would not fail — SQLite applies the
|
|
2266
|
+
* closest affinity — but it would make the cast mean something different here
|
|
2267
|
+
* than on the other databases, which is what this mapping avoids.
|
|
2268
|
+
*/
|
|
2269
|
+
castTypeName(to) {
|
|
2270
|
+
switch (to) {
|
|
2271
|
+
case "integer":
|
|
2272
|
+
case "bigint":
|
|
2273
|
+
case "boolean":
|
|
2274
|
+
return "INTEGER";
|
|
2275
|
+
case "real":
|
|
2276
|
+
return "REAL";
|
|
2277
|
+
case "numeric":
|
|
2278
|
+
return "NUMERIC";
|
|
2279
|
+
case "blob":
|
|
2280
|
+
return "BLOB";
|
|
2281
|
+
default:
|
|
2282
|
+
return "TEXT";
|
|
2283
|
+
}
|
|
2284
|
+
}
|
|
1555
2285
|
placeholder() {
|
|
1556
2286
|
return "?";
|
|
1557
2287
|
}
|
|
1558
2288
|
ilike(column2, param) {
|
|
1559
2289
|
return `${column2} LIKE ${param}`;
|
|
1560
2290
|
}
|
|
2291
|
+
/**
|
|
2292
|
+
* SQLite runs one writer at a time, so its only isolation level **is**
|
|
2293
|
+
* serializable — there is no syntax to ask for another, and no weaker level to
|
|
2294
|
+
* fall back to. Asking for one is an error rather than a silent no-op, since a
|
|
2295
|
+
* caller who wrote `repeatable read` was reasoning about a guarantee.
|
|
2296
|
+
*
|
|
2297
|
+
* `readOnly` likewise has no per-transaction form here (`PRAGMA query_only` is
|
|
2298
|
+
* per connection), so it is refused instead of quietly ignored.
|
|
2299
|
+
*/
|
|
2300
|
+
beginStatements(options) {
|
|
2301
|
+
if (options?.isolation && options.isolation !== "serializable") {
|
|
2302
|
+
throw new Error(
|
|
2303
|
+
`SQLite only implements the "serializable" isolation level; ${JSON.stringify(options.isolation)} has no equivalent here.`
|
|
2304
|
+
);
|
|
2305
|
+
}
|
|
2306
|
+
if (options?.readOnly) {
|
|
2307
|
+
throw new Error(
|
|
2308
|
+
"SQLite has no read-only transaction; open the engine with { sqlite: { ... } } on a read-only connection instead."
|
|
2309
|
+
);
|
|
2310
|
+
}
|
|
2311
|
+
return ["BEGIN"];
|
|
2312
|
+
}
|
|
1561
2313
|
/**
|
|
1562
2314
|
* SQLite has no row-level locking, so a lock request is an error rather than a
|
|
1563
2315
|
* silently unlocked `SELECT` — a lock that does not exist only shows up as
|
|
@@ -1571,8 +2323,8 @@ var SqliteDialect = class extends BaseDialect {
|
|
|
1571
2323
|
};
|
|
1572
2324
|
var PostgresDialect = class extends BaseDialect {
|
|
1573
2325
|
name = "postgresql";
|
|
1574
|
-
placeholder(
|
|
1575
|
-
return `$${
|
|
2326
|
+
placeholder(index2) {
|
|
2327
|
+
return `$${index2}`;
|
|
1576
2328
|
}
|
|
1577
2329
|
ilike(column2, param) {
|
|
1578
2330
|
return `${column2} ILIKE ${param}`;
|
|
@@ -1592,36 +2344,120 @@ var MysqlDialect = class extends BaseDialect {
|
|
|
1592
2344
|
ilike(column2, param) {
|
|
1593
2345
|
return `${column2} LIKE ${param}`;
|
|
1594
2346
|
}
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
const quoted = `\`${name.replace(/`/g, "``")}\``;
|
|
1599
|
-
mysqlQuotedIds.set(name, quoted);
|
|
1600
|
-
return quoted;
|
|
2347
|
+
/** MySQL's `EXPLAIN FORMAT=JSON` spells the option differently. */
|
|
2348
|
+
explainPrefix(analyze) {
|
|
2349
|
+
return analyze ? "EXPLAIN ANALYZE" : "EXPLAIN FORMAT=JSON";
|
|
1601
2350
|
}
|
|
1602
2351
|
/**
|
|
1603
|
-
* MySQL
|
|
1604
|
-
*
|
|
1605
|
-
*
|
|
1606
|
-
* instead of surfacing that error from the driver at runtime.
|
|
2352
|
+
* MySQL only gained `INTERSECT`/`EXCEPT` in 8.0.31, and this project does not
|
|
2353
|
+
* invest in MySQL beyond what already works — so they are refused here rather
|
|
2354
|
+
* than emitted against a server that may reject them.
|
|
1607
2355
|
*/
|
|
1608
|
-
|
|
1609
|
-
if (
|
|
2356
|
+
setOperator(op) {
|
|
2357
|
+
if (op === "intersect" || op === "except") {
|
|
1610
2358
|
throw new Error(
|
|
1611
|
-
|
|
2359
|
+
`MySQL support for ${op.toUpperCase()} is out of scope for tempest-db-js; express it with a join or NOT EXISTS.`
|
|
1612
2360
|
);
|
|
1613
2361
|
}
|
|
2362
|
+
return super.setOperator(op);
|
|
1614
2363
|
}
|
|
1615
|
-
|
|
1616
|
-
|
|
2364
|
+
/**
|
|
2365
|
+
* MySQL writes multi-table updates as `UPDATE a JOIN b`, and has no
|
|
2366
|
+
* `DELETE ... USING` in this shape. Both are out of the project's active scope,
|
|
2367
|
+
* so they are refused rather than emitted against a server that rejects them.
|
|
2368
|
+
*/
|
|
2369
|
+
compileExtraSources(keyword, sources) {
|
|
2370
|
+
if (sources && sources.length > 0) {
|
|
1617
2371
|
throw new Error(
|
|
1618
|
-
|
|
2372
|
+
`MySQL does not take ${keyword} on a write in this form; it spells multi-table writes as UPDATE a JOIN b, which is out of scope for tempest-db-js.`
|
|
1619
2373
|
);
|
|
1620
2374
|
}
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
2375
|
+
return "";
|
|
2376
|
+
}
|
|
2377
|
+
/**
|
|
2378
|
+
* MySQL's full-text search needs a `FULLTEXT` index and different syntax, and it
|
|
2379
|
+
* is outside this project's active scope — the substring fallback is compiled,
|
|
2380
|
+
* like on SQLite.
|
|
2381
|
+
*/
|
|
2382
|
+
compileFullText(node, params, idFor) {
|
|
2383
|
+
return this.compileCondition(node.fallback, params, idFor);
|
|
2384
|
+
}
|
|
2385
|
+
/** No `ts_rank` equivalent in scope: a constant, so ordering by it is inert. */
|
|
2386
|
+
renderRank() {
|
|
2387
|
+
return "0";
|
|
2388
|
+
}
|
|
2389
|
+
/**
|
|
2390
|
+
* MySQL sets the level with a statement **before** the transaction opens, and
|
|
2391
|
+
* spells the read-only flag on `START TRANSACTION` rather than on `BEGIN`.
|
|
2392
|
+
*/
|
|
2393
|
+
beginStatements(options) {
|
|
2394
|
+
const statements = [];
|
|
2395
|
+
if (options?.isolation) {
|
|
2396
|
+
statements.push(
|
|
2397
|
+
`SET TRANSACTION ISOLATION LEVEL ${options.isolation.toUpperCase()}`
|
|
2398
|
+
);
|
|
2399
|
+
}
|
|
2400
|
+
statements.push(options?.readOnly ? "START TRANSACTION READ ONLY" : "BEGIN");
|
|
2401
|
+
return statements;
|
|
2402
|
+
}
|
|
2403
|
+
/**
|
|
2404
|
+
* MySQL's `CAST` takes its own vocabulary — `SIGNED`, not `INTEGER`; `CHAR`,
|
|
2405
|
+
* not `TEXT` — and rejects the standard names outright.
|
|
2406
|
+
*/
|
|
2407
|
+
castTypeName(to) {
|
|
2408
|
+
switch (to) {
|
|
2409
|
+
case "integer":
|
|
2410
|
+
case "bigint":
|
|
2411
|
+
case "boolean":
|
|
2412
|
+
return "SIGNED";
|
|
2413
|
+
case "real":
|
|
2414
|
+
case "numeric":
|
|
2415
|
+
return "DECIMAL";
|
|
2416
|
+
case "text":
|
|
2417
|
+
case "uuid":
|
|
2418
|
+
return "CHAR";
|
|
2419
|
+
case "date":
|
|
2420
|
+
return "DATE";
|
|
2421
|
+
case "datetime":
|
|
2422
|
+
case "timestamp":
|
|
2423
|
+
return "DATETIME";
|
|
2424
|
+
case "json":
|
|
2425
|
+
case "jsonb":
|
|
2426
|
+
return "JSON";
|
|
2427
|
+
case "blob":
|
|
2428
|
+
return "BINARY";
|
|
2429
|
+
}
|
|
2430
|
+
}
|
|
2431
|
+
quoteId(name) {
|
|
2432
|
+
const cached = mysqlQuotedIds.get(name);
|
|
2433
|
+
if (cached !== void 0) return cached;
|
|
2434
|
+
const quoted = `\`${name.replace(/`/g, "``")}\``;
|
|
2435
|
+
mysqlQuotedIds.set(name, quoted);
|
|
2436
|
+
return quoted;
|
|
2437
|
+
}
|
|
2438
|
+
/**
|
|
2439
|
+
* MySQL rejects `LIMIT` inside an `IN` subquery with
|
|
2440
|
+
* `ER_NOT_SUPPORTED_YET: This version of MySQL doesn't yet support
|
|
2441
|
+
* 'LIMIT & IN/ALL/ANY/SOME subquery'`. Failing at compile time names the fix
|
|
2442
|
+
* instead of surfacing that error from the driver at runtime.
|
|
2443
|
+
*/
|
|
2444
|
+
checkSubquery(node) {
|
|
2445
|
+
if (node.limit !== void 0 || node.offset !== void 0) {
|
|
2446
|
+
throw new Error(
|
|
2447
|
+
"MySQL does not support LIMIT/OFFSET inside an IN subquery. Select the ids first and pass them as a list, or wrap the subquery in a derived table."
|
|
2448
|
+
);
|
|
2449
|
+
}
|
|
2450
|
+
}
|
|
2451
|
+
renderConflict(onConflict, conflictCols, nextValue, names) {
|
|
2452
|
+
if (onConflict.targetWhere || onConflict.updateWhere) {
|
|
2453
|
+
throw new Error(
|
|
2454
|
+
"MySQL's ON DUPLICATE KEY UPDATE has no conflict-target predicate \u2014 a partial unique index is PostgreSQL/SQLite only."
|
|
2455
|
+
);
|
|
2456
|
+
}
|
|
2457
|
+
if (onConflict.update === "nothing") {
|
|
2458
|
+
const col2 = this.columnId(onConflict.target[0] ?? "id", names);
|
|
2459
|
+
return ` ON DUPLICATE KEY UPDATE ${col2} = ${col2}`;
|
|
2460
|
+
}
|
|
1625
2461
|
const assignments = conflictCols.map((c) => `${this.columnId(c, names)} = ${nextValue()}`).join(", ");
|
|
1626
2462
|
return ` ON DUPLICATE KEY UPDATE ${assignments}`;
|
|
1627
2463
|
}
|
|
@@ -1682,7 +2518,7 @@ var JoinBuilder = class _JoinBuilder {
|
|
|
1682
2518
|
clause(kind, model, alias, on) {
|
|
1683
2519
|
return {
|
|
1684
2520
|
kind,
|
|
1685
|
-
table: model.tablename,
|
|
2521
|
+
table: aliasOf(model)?.tablename ?? model.tablename,
|
|
1686
2522
|
alias,
|
|
1687
2523
|
on: Object.entries(on)
|
|
1688
2524
|
};
|
|
@@ -1701,6 +2537,38 @@ var JoinBuilder = class _JoinBuilder {
|
|
|
1701
2537
|
model
|
|
1702
2538
|
);
|
|
1703
2539
|
}
|
|
2540
|
+
/**
|
|
2541
|
+
* Project **one** source, flat, instead of the composite row.
|
|
2542
|
+
*
|
|
2543
|
+
* The join still happens — it just stops being what comes back. Needed wherever
|
|
2544
|
+
* the result has to match a single table's shape: the recursive branch of a
|
|
2545
|
+
* CTE, an `INSERT ... SELECT`, a `UNION` branch.
|
|
2546
|
+
*
|
|
2547
|
+
* @param alias The source to project.
|
|
2548
|
+
* @returns A builder whose rows are that source's rows.
|
|
2549
|
+
*
|
|
2550
|
+
* @example
|
|
2551
|
+
* ```ts
|
|
2552
|
+
* join(Category, "c").innerJoin(subtree, "s", { "c.parentId": "s.id" }).pick("c");
|
|
2553
|
+
* // SELECT "c"."id" AS "id", "c"."name" AS "name" ... — not "c.id"
|
|
2554
|
+
* ```
|
|
2555
|
+
*/
|
|
2556
|
+
pick(alias) {
|
|
2557
|
+
const model = this.sources[alias];
|
|
2558
|
+
if (!model) {
|
|
2559
|
+
throw new Error(
|
|
2560
|
+
`pick(${JSON.stringify(alias)}): no source is joined under that alias.`
|
|
2561
|
+
);
|
|
2562
|
+
}
|
|
2563
|
+
return new _JoinBuilder(
|
|
2564
|
+
{
|
|
2565
|
+
...this.node,
|
|
2566
|
+
selections: selectionsFor(alias, model),
|
|
2567
|
+
pick: alias
|
|
2568
|
+
},
|
|
2569
|
+
this.sources
|
|
2570
|
+
);
|
|
2571
|
+
}
|
|
1704
2572
|
/** Filter by `alias.column` references (object form) or an `and`/`or`/`not`. */
|
|
1705
2573
|
where(input) {
|
|
1706
2574
|
return new _JoinBuilder(
|
|
@@ -1729,7 +2597,7 @@ function join(model, alias) {
|
|
|
1729
2597
|
return new JoinBuilder(
|
|
1730
2598
|
{
|
|
1731
2599
|
kind: "join_select",
|
|
1732
|
-
base: { table: model.tablename, alias },
|
|
2600
|
+
base: { table: aliasOf(model)?.tablename ?? model.tablename, alias },
|
|
1733
2601
|
joins: [],
|
|
1734
2602
|
selections: selectionsFor(alias, model),
|
|
1735
2603
|
where: void 0,
|
|
@@ -1742,46 +2610,388 @@ function join(model, alias) {
|
|
|
1742
2610
|
);
|
|
1743
2611
|
}
|
|
1744
2612
|
|
|
2613
|
+
// src/mixins.ts
|
|
2614
|
+
function withTimestamps(Base) {
|
|
2615
|
+
class WithTimestamps extends Base {
|
|
2616
|
+
/** When the row was inserted. */
|
|
2617
|
+
createdAt = column.datetime().notNull().default(sql.now());
|
|
2618
|
+
/** When the row was last updated — refreshed by every `UPDATE`. */
|
|
2619
|
+
updatedAt = column.datetime().notNull().default(sql.now()).onUpdate(sql.now());
|
|
2620
|
+
}
|
|
2621
|
+
return WithTimestamps;
|
|
2622
|
+
}
|
|
2623
|
+
function withSoftDelete(Base) {
|
|
2624
|
+
class WithSoftDelete extends Base {
|
|
2625
|
+
/** When the row was soft-deleted, or `null` while it is alive. */
|
|
2626
|
+
deletedAt = column.datetime();
|
|
2627
|
+
}
|
|
2628
|
+
return WithSoftDelete;
|
|
2629
|
+
}
|
|
2630
|
+
function notDeleted() {
|
|
2631
|
+
return { deletedAt: { isNull: true } };
|
|
2632
|
+
}
|
|
2633
|
+
function onlyDeleted() {
|
|
2634
|
+
return { deletedAt: { isNull: false } };
|
|
2635
|
+
}
|
|
2636
|
+
function withAudit(Base, actor = () => column.uuid()) {
|
|
2637
|
+
class WithAudit extends Base {
|
|
2638
|
+
/** Who created the row, or `null` when nobody was attributed. */
|
|
2639
|
+
createdBy = actor();
|
|
2640
|
+
/** Who last updated the row, or `null`. */
|
|
2641
|
+
updatedBy = actor();
|
|
2642
|
+
}
|
|
2643
|
+
return WithAudit;
|
|
2644
|
+
}
|
|
2645
|
+
|
|
2646
|
+
// src/integrity.ts
|
|
2647
|
+
var POSTGRES_VIOLATIONS = {
|
|
2648
|
+
"23505": "unique",
|
|
2649
|
+
"23503": "foreignKey",
|
|
2650
|
+
"23502": "notNull",
|
|
2651
|
+
"23514": "check",
|
|
2652
|
+
"23P01": "exclusion"
|
|
2653
|
+
};
|
|
2654
|
+
var SQLITE_ERRCODES = {
|
|
2655
|
+
1555: "unique",
|
|
2656
|
+
// SQLITE_CONSTRAINT_PRIMARYKEY
|
|
2657
|
+
2067: "unique",
|
|
2658
|
+
// SQLITE_CONSTRAINT_UNIQUE
|
|
2659
|
+
787: "foreignKey",
|
|
2660
|
+
// SQLITE_CONSTRAINT_FOREIGNKEY
|
|
2661
|
+
1299: "notNull",
|
|
2662
|
+
// SQLITE_CONSTRAINT_NOTNULL
|
|
2663
|
+
275: "check"
|
|
2664
|
+
// SQLITE_CONSTRAINT_CHECK
|
|
2665
|
+
};
|
|
2666
|
+
var SQLITE_CODES = {
|
|
2667
|
+
SQLITE_CONSTRAINT_PRIMARYKEY: "unique",
|
|
2668
|
+
SQLITE_CONSTRAINT_UNIQUE: "unique",
|
|
2669
|
+
SQLITE_CONSTRAINT_FOREIGNKEY: "foreignKey",
|
|
2670
|
+
SQLITE_CONSTRAINT_NOTNULL: "notNull",
|
|
2671
|
+
SQLITE_CONSTRAINT_CHECK: "check"
|
|
2672
|
+
};
|
|
2673
|
+
function str(error, key) {
|
|
2674
|
+
const value = error[key];
|
|
2675
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
2676
|
+
}
|
|
2677
|
+
function postgresDetailColumns(detail) {
|
|
2678
|
+
if (!detail) return [];
|
|
2679
|
+
const match = /Key \(([^)]+)\)=/.exec(detail);
|
|
2680
|
+
if (!match?.[1]) return [];
|
|
2681
|
+
return match[1].split(",").map((c) => c.trim().replace(/^"|"$/g, ""));
|
|
2682
|
+
}
|
|
2683
|
+
function sqliteTargets(list) {
|
|
2684
|
+
const columns = [];
|
|
2685
|
+
let table = null;
|
|
2686
|
+
for (const entry of list.split(",")) {
|
|
2687
|
+
const [left, right] = entry.trim().split(".");
|
|
2688
|
+
if (right === void 0) {
|
|
2689
|
+
if (left) columns.push(left);
|
|
2690
|
+
continue;
|
|
2691
|
+
}
|
|
2692
|
+
table = left ?? null;
|
|
2693
|
+
columns.push(right);
|
|
2694
|
+
}
|
|
2695
|
+
return { table, columns };
|
|
2696
|
+
}
|
|
2697
|
+
function fromPostgres(error) {
|
|
2698
|
+
const code = str(error, "code");
|
|
2699
|
+
const violation = code ? POSTGRES_VIOLATIONS[code] : void 0;
|
|
2700
|
+
if (!violation) return null;
|
|
2701
|
+
const detail = str(error, "detail");
|
|
2702
|
+
const columns = postgresDetailColumns(detail);
|
|
2703
|
+
const column2 = str(error, "column_name");
|
|
2704
|
+
return {
|
|
2705
|
+
violation,
|
|
2706
|
+
constraint: str(error, "constraint_name"),
|
|
2707
|
+
table: str(error, "table_name"),
|
|
2708
|
+
columns: columns.length > 0 ? columns : column2 ? [column2] : [],
|
|
2709
|
+
detail: str(error, "message") ?? detail ?? ""
|
|
2710
|
+
};
|
|
2711
|
+
}
|
|
2712
|
+
function fromSqlite(error) {
|
|
2713
|
+
const code = str(error, "code");
|
|
2714
|
+
const errcode = error.errcode;
|
|
2715
|
+
const violation = (code ? SQLITE_CODES[code] : void 0) ?? (typeof errcode === "number" ? SQLITE_ERRCODES[errcode] : void 0);
|
|
2716
|
+
if (!violation) return null;
|
|
2717
|
+
const message = str(error, "message") ?? "";
|
|
2718
|
+
if (violation === "check") {
|
|
2719
|
+
const named = /CHECK constraint failed: (.+)$/.exec(message);
|
|
2720
|
+
return {
|
|
2721
|
+
violation,
|
|
2722
|
+
constraint: named?.[1] ?? null,
|
|
2723
|
+
table: null,
|
|
2724
|
+
columns: [],
|
|
2725
|
+
detail: message
|
|
2726
|
+
};
|
|
2727
|
+
}
|
|
2728
|
+
const targets = /constraint failed: (.+)$/.exec(message);
|
|
2729
|
+
if (!targets?.[1]) {
|
|
2730
|
+
return { violation, constraint: null, table: null, columns: [], detail: message };
|
|
2731
|
+
}
|
|
2732
|
+
const { table, columns } = sqliteTargets(targets[1]);
|
|
2733
|
+
return { violation, constraint: null, table, columns, detail: message };
|
|
2734
|
+
}
|
|
2735
|
+
function parseIntegrityError(error, model) {
|
|
2736
|
+
let current = error;
|
|
2737
|
+
for (let depth = 0; depth < 5 && current !== null && current !== void 0; depth++) {
|
|
2738
|
+
if (typeof current === "object") {
|
|
2739
|
+
const record = current;
|
|
2740
|
+
const failure = fromPostgres(record) ?? fromSqlite(record);
|
|
2741
|
+
if (failure) return model ? withModelNames(failure, model) : failure;
|
|
2742
|
+
current = record.cause;
|
|
2743
|
+
continue;
|
|
2744
|
+
}
|
|
2745
|
+
return null;
|
|
2746
|
+
}
|
|
2747
|
+
return null;
|
|
2748
|
+
}
|
|
2749
|
+
function withModelNames(failure, model) {
|
|
2750
|
+
const props = columnPropsOf(model);
|
|
2751
|
+
if (!props || failure.columns.length === 0) return failure;
|
|
2752
|
+
return { ...failure, columns: failure.columns.map((c) => props[c] ?? c) };
|
|
2753
|
+
}
|
|
2754
|
+
|
|
2755
|
+
// src/signals.ts
|
|
2756
|
+
var registry = /* @__PURE__ */ new WeakMap();
|
|
2757
|
+
var registered = /* @__PURE__ */ new Set();
|
|
2758
|
+
function onSignal(model, signal, handler) {
|
|
2759
|
+
let bySignal = registry.get(model);
|
|
2760
|
+
if (!bySignal) {
|
|
2761
|
+
bySignal = /* @__PURE__ */ new Map();
|
|
2762
|
+
registry.set(model, bySignal);
|
|
2763
|
+
registered.add(model);
|
|
2764
|
+
}
|
|
2765
|
+
let handlers = bySignal.get(signal);
|
|
2766
|
+
if (!handlers) {
|
|
2767
|
+
handlers = /* @__PURE__ */ new Set();
|
|
2768
|
+
bySignal.set(signal, handlers);
|
|
2769
|
+
}
|
|
2770
|
+
handlers.add(handler);
|
|
2771
|
+
return () => {
|
|
2772
|
+
handlers?.delete(handler);
|
|
2773
|
+
};
|
|
2774
|
+
}
|
|
2775
|
+
function hasHandlers(model, signal) {
|
|
2776
|
+
return (registry.get(model)?.get(signal)?.size ?? 0) > 0;
|
|
2777
|
+
}
|
|
2778
|
+
async function emitSignal(signal, payload) {
|
|
2779
|
+
const handlers = registry.get(payload.model)?.get(signal);
|
|
2780
|
+
if (!handlers || handlers.size === 0) return;
|
|
2781
|
+
for (const handler of [...handlers]) {
|
|
2782
|
+
await handler(payload);
|
|
2783
|
+
}
|
|
2784
|
+
}
|
|
2785
|
+
function clearSignals(model) {
|
|
2786
|
+
if (model) {
|
|
2787
|
+
registry.delete(model);
|
|
2788
|
+
registered.delete(model);
|
|
2789
|
+
return;
|
|
2790
|
+
}
|
|
2791
|
+
for (const registeredModel of registered) registry.delete(registeredModel);
|
|
2792
|
+
registered.clear();
|
|
2793
|
+
}
|
|
2794
|
+
|
|
2795
|
+
// src/setops.ts
|
|
2796
|
+
var SetBuilder = class _SetBuilder {
|
|
2797
|
+
constructor(node, source) {
|
|
2798
|
+
this.node = node;
|
|
2799
|
+
this.source = source;
|
|
2800
|
+
}
|
|
2801
|
+
node;
|
|
2802
|
+
source;
|
|
2803
|
+
with(patch) {
|
|
2804
|
+
return new _SetBuilder({ ...this.node, ...patch }, this.source);
|
|
2805
|
+
}
|
|
2806
|
+
/**
|
|
2807
|
+
* Order the combined result.
|
|
2808
|
+
*
|
|
2809
|
+
* @param column A column of the projected row.
|
|
2810
|
+
* @param direction Sort direction (default ascending).
|
|
2811
|
+
* @returns A builder carrying the ordering.
|
|
2812
|
+
*/
|
|
2813
|
+
orderBy(column2, direction = "asc") {
|
|
2814
|
+
return this.with({ orderBy: [...this.node.orderBy, { column: column2, direction }] });
|
|
2815
|
+
}
|
|
2816
|
+
/** Limit the combined result. */
|
|
2817
|
+
limit(count2) {
|
|
2818
|
+
return this.with({ limit: count2 });
|
|
2819
|
+
}
|
|
2820
|
+
/** Skip rows of the combined result. */
|
|
2821
|
+
offset(count2) {
|
|
2822
|
+
return this.with({ offset: count2 });
|
|
2823
|
+
}
|
|
2824
|
+
};
|
|
2825
|
+
function branchSource(branch) {
|
|
2826
|
+
if (branch.source) return branch.source;
|
|
2827
|
+
const pick = branch.node.pick;
|
|
2828
|
+
return pick ? branch.sources?.[pick] : void 0;
|
|
2829
|
+
}
|
|
2830
|
+
function combine(op, branches) {
|
|
2831
|
+
if (branches.length < 2) {
|
|
2832
|
+
throw new Error(`${op}() combines at least two queries.`);
|
|
2833
|
+
}
|
|
2834
|
+
const first = branches[0];
|
|
2835
|
+
const source = branchSource(first);
|
|
2836
|
+
if (!source) {
|
|
2837
|
+
throw new Error(
|
|
2838
|
+
`${op}(): the first branch must project a single source \u2014 narrow a join with .pick(alias).`
|
|
2839
|
+
);
|
|
2840
|
+
}
|
|
2841
|
+
return new SetBuilder(
|
|
2842
|
+
{
|
|
2843
|
+
kind: "set_op",
|
|
2844
|
+
op,
|
|
2845
|
+
branches: branches.map((b) => b.node),
|
|
2846
|
+
orderBy: [],
|
|
2847
|
+
limit: void 0,
|
|
2848
|
+
offset: void 0
|
|
2849
|
+
},
|
|
2850
|
+
source
|
|
2851
|
+
);
|
|
2852
|
+
}
|
|
2853
|
+
function union(...branches) {
|
|
2854
|
+
return combine("union", branches);
|
|
2855
|
+
}
|
|
2856
|
+
function unionAll(...branches) {
|
|
2857
|
+
return combine("unionAll", branches);
|
|
2858
|
+
}
|
|
2859
|
+
function intersect(...branches) {
|
|
2860
|
+
return combine("intersect", branches);
|
|
2861
|
+
}
|
|
2862
|
+
function except(...branches) {
|
|
2863
|
+
return combine("except", branches);
|
|
2864
|
+
}
|
|
2865
|
+
|
|
1745
2866
|
// src/repository.ts
|
|
1746
|
-
var
|
|
1747
|
-
constructor(
|
|
1748
|
-
super(
|
|
1749
|
-
this.name = "
|
|
2867
|
+
var InvalidCursor = class extends Error {
|
|
2868
|
+
constructor(reason) {
|
|
2869
|
+
super(`Invalid pagination cursor: ${reason}`);
|
|
2870
|
+
this.name = "InvalidCursor";
|
|
1750
2871
|
}
|
|
1751
2872
|
};
|
|
1752
|
-
function
|
|
1753
|
-
|
|
1754
|
-
|
|
2873
|
+
function encodeCursor(columns, keys, row) {
|
|
2874
|
+
const k = {};
|
|
2875
|
+
for (const key of keys) {
|
|
2876
|
+
k[key] = encodeValue(columns[key], row[key]);
|
|
1755
2877
|
}
|
|
1756
|
-
|
|
2878
|
+
const payload = { v: 1, k };
|
|
2879
|
+
return Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
|
|
1757
2880
|
}
|
|
2881
|
+
function decodeCursor(columns, keys, cursor) {
|
|
2882
|
+
let payload;
|
|
2883
|
+
try {
|
|
2884
|
+
payload = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
|
|
2885
|
+
} catch {
|
|
2886
|
+
throw new InvalidCursor("not a cursor this repository produced");
|
|
2887
|
+
}
|
|
2888
|
+
if (payload === null || typeof payload !== "object" || payload.v !== 1) {
|
|
2889
|
+
throw new InvalidCursor("unknown cursor version");
|
|
2890
|
+
}
|
|
2891
|
+
const values = {};
|
|
2892
|
+
for (const key of keys) {
|
|
2893
|
+
if (!(key in payload.k)) {
|
|
2894
|
+
throw new InvalidCursor(
|
|
2895
|
+
`it does not carry ${JSON.stringify(key)} \u2014 the ordering changed between pages`
|
|
2896
|
+
);
|
|
2897
|
+
}
|
|
2898
|
+
values[key] = decodeValue(columns[key], payload.k[key]);
|
|
2899
|
+
}
|
|
2900
|
+
return values;
|
|
2901
|
+
}
|
|
2902
|
+
function afterCursor(keys, values, ascending) {
|
|
2903
|
+
const op = ascending ? "gt" : "lt";
|
|
2904
|
+
const build = (index2) => {
|
|
2905
|
+
const key = keys[index2];
|
|
2906
|
+
const strict = { [key]: { [op]: values[key] } };
|
|
2907
|
+
if (index2 === keys.length - 1) return strict;
|
|
2908
|
+
return or(
|
|
2909
|
+
strict,
|
|
2910
|
+
and({ [key]: values[key] }, build(index2 + 1))
|
|
2911
|
+
);
|
|
2912
|
+
};
|
|
2913
|
+
return build(0);
|
|
2914
|
+
}
|
|
2915
|
+
var RecordNotFound = class extends Error {
|
|
2916
|
+
constructor(table, key) {
|
|
2917
|
+
super(`${table} not found for key ${JSON.stringify(key)}`);
|
|
2918
|
+
this.name = "RecordNotFound";
|
|
2919
|
+
}
|
|
2920
|
+
};
|
|
1758
2921
|
var BaseRepository = class {
|
|
1759
2922
|
constructor(model, session) {
|
|
1760
2923
|
this.model = model;
|
|
1761
2924
|
this.session = session;
|
|
1762
|
-
this.
|
|
2925
|
+
this.pks = primaryKeysOf(model);
|
|
1763
2926
|
}
|
|
1764
2927
|
model;
|
|
1765
2928
|
session;
|
|
1766
|
-
|
|
2929
|
+
pks;
|
|
2930
|
+
/**
|
|
2931
|
+
* Narrow every read this repository performs.
|
|
2932
|
+
*
|
|
2933
|
+
* The extension point a scoped repository overrides: returning a filter that
|
|
2934
|
+
* always carries the scope's predicate is what makes it impossible for one
|
|
2935
|
+
* query site to forget it. The base implementation adds nothing.
|
|
2936
|
+
*
|
|
2937
|
+
* @param filters The caller's filters.
|
|
2938
|
+
* @returns The filters actually sent to the database.
|
|
2939
|
+
*/
|
|
2940
|
+
scopeFilters(filters) {
|
|
2941
|
+
return filters;
|
|
2942
|
+
}
|
|
2943
|
+
/**
|
|
2944
|
+
* Stamp every row this repository writes.
|
|
2945
|
+
*
|
|
2946
|
+
* The write-side counterpart of {@link scopeFilters}. The base implementation
|
|
2947
|
+
* writes the row unchanged.
|
|
2948
|
+
*
|
|
2949
|
+
* @param data The row being written.
|
|
2950
|
+
* @returns The row actually written.
|
|
2951
|
+
*/
|
|
2952
|
+
scopeWrite(data) {
|
|
2953
|
+
return data;
|
|
2954
|
+
}
|
|
1767
2955
|
/** All rows matching `filters` (or everything). Empty list when none match. */
|
|
1768
|
-
async list(
|
|
2956
|
+
async list(input) {
|
|
2957
|
+
const filters = this.scopeFilters(input);
|
|
1769
2958
|
const query = filters ? select(this.model).where(filters) : select(this.model);
|
|
1770
2959
|
return this.session.execute(query).all();
|
|
1771
2960
|
}
|
|
1772
2961
|
/** The first row matching `filters`, or `null`. */
|
|
1773
|
-
async first(
|
|
2962
|
+
async first(input) {
|
|
2963
|
+
const filters = this.scopeFilters(input);
|
|
1774
2964
|
const query = filters ? select(this.model).where(filters) : select(this.model);
|
|
1775
2965
|
return this.session.execute(query).first();
|
|
1776
2966
|
}
|
|
1777
|
-
/**
|
|
2967
|
+
/**
|
|
2968
|
+
* A single row by primary key, or `null`.
|
|
2969
|
+
*
|
|
2970
|
+
* @param id The key — a bare value for a single-column key, an object
|
|
2971
|
+
* (`{ orderId, lineNumber }`) for a composite one.
|
|
2972
|
+
* @returns The row, or `null` when nothing matches.
|
|
2973
|
+
* @throws Error When a scalar is given for a composite key, or the key is
|
|
2974
|
+
* incomplete.
|
|
2975
|
+
*/
|
|
1778
2976
|
async getByIdOrNull(id) {
|
|
1779
|
-
|
|
2977
|
+
const filter = this.scopeFilters(
|
|
2978
|
+
primaryKeyFilter(this.model, id)
|
|
2979
|
+
);
|
|
2980
|
+
return this.session.execute(select(this.model).where(filter)).first();
|
|
1780
2981
|
}
|
|
1781
|
-
/**
|
|
2982
|
+
/**
|
|
2983
|
+
* A single row by primary key; throws `RecordNotFound` when absent.
|
|
2984
|
+
*
|
|
2985
|
+
* @param id The key — see {@link getByIdOrNull}.
|
|
2986
|
+
* @returns The row.
|
|
2987
|
+
* @throws RecordNotFound When no row carries that key.
|
|
2988
|
+
*/
|
|
1782
2989
|
async getById(id) {
|
|
1783
|
-
const
|
|
1784
|
-
|
|
2990
|
+
const filter = this.scopeFilters(
|
|
2991
|
+
primaryKeyFilter(this.model, id)
|
|
2992
|
+
);
|
|
2993
|
+
const row = await this.session.execute(select(this.model).where(filter)).first();
|
|
2994
|
+
if (row === null) throw new RecordNotFound(this.model.tablename, filter);
|
|
1785
2995
|
return row;
|
|
1786
2996
|
}
|
|
1787
2997
|
/** Whether any row matches `filters`. */
|
|
@@ -1789,26 +2999,106 @@ var BaseRepository = class {
|
|
|
1789
2999
|
return await this.first(filters) !== null;
|
|
1790
3000
|
}
|
|
1791
3001
|
/** How many rows match `filters` (or the whole table). */
|
|
1792
|
-
async count(
|
|
1793
|
-
const
|
|
3002
|
+
async count(input) {
|
|
3003
|
+
const filters = this.scopeFilters(input);
|
|
3004
|
+
const query = filters ? select(this.model, [this.pks[0]]).where(filters) : select(this.model, [this.pks[0]]);
|
|
1794
3005
|
return (await this.session.execute(query).all()).length;
|
|
1795
3006
|
}
|
|
1796
|
-
/**
|
|
3007
|
+
/**
|
|
3008
|
+
* Insert one row, returning the created row.
|
|
3009
|
+
*
|
|
3010
|
+
* Fires `preSave` (which can veto by throwing) and then `postSave`.
|
|
3011
|
+
*
|
|
3012
|
+
* @param data The row to insert.
|
|
3013
|
+
* @returns The stored row.
|
|
3014
|
+
*/
|
|
1797
3015
|
async create(data) {
|
|
1798
|
-
|
|
3016
|
+
const scoped = this.scopeWrite(data);
|
|
3017
|
+
await this.fire("preSave", scoped, true);
|
|
3018
|
+
const row = await this.session.execute(
|
|
3019
|
+
insert(this.model).values(scoped).returning()
|
|
3020
|
+
).one();
|
|
3021
|
+
await this.fire("postSave", row, true);
|
|
3022
|
+
return row;
|
|
1799
3023
|
}
|
|
1800
|
-
/**
|
|
3024
|
+
/**
|
|
3025
|
+
* Insert many rows, returning the created rows.
|
|
3026
|
+
*
|
|
3027
|
+
* Signals fire per row, so a handler sees one row at a time whether it was
|
|
3028
|
+
* inserted alone or in a batch.
|
|
3029
|
+
*
|
|
3030
|
+
* @param data The rows to insert.
|
|
3031
|
+
* @returns The stored rows.
|
|
3032
|
+
*/
|
|
1801
3033
|
async createMany(data) {
|
|
1802
3034
|
if (data.length === 0) return [];
|
|
1803
|
-
|
|
3035
|
+
const scoped = data.map(
|
|
3036
|
+
(row) => this.scopeWrite(row)
|
|
3037
|
+
);
|
|
3038
|
+
for (const row of scoped) {
|
|
3039
|
+
await this.fire("preSave", row, true);
|
|
3040
|
+
}
|
|
3041
|
+
const rows = await this.session.execute(
|
|
3042
|
+
insert(this.model).values(scoped).returning()
|
|
3043
|
+
).all();
|
|
3044
|
+
for (const row of rows) await this.fire("postSave", row, true);
|
|
3045
|
+
return rows;
|
|
1804
3046
|
}
|
|
1805
|
-
/**
|
|
1806
|
-
|
|
1807
|
-
|
|
3047
|
+
/**
|
|
3048
|
+
* Update rows matching `filters`; returns the number of rows affected.
|
|
3049
|
+
*
|
|
3050
|
+
* With a `preSave`/`postSave` handler registered, the matching rows are read
|
|
3051
|
+
* first so the handler can see them — that extra `SELECT` is skipped entirely
|
|
3052
|
+
* when nothing is listening.
|
|
3053
|
+
*
|
|
3054
|
+
* @param filters Which rows to update.
|
|
3055
|
+
* @param set The columns to change.
|
|
3056
|
+
* @returns The number of rows affected.
|
|
3057
|
+
*/
|
|
3058
|
+
async update(input, set) {
|
|
3059
|
+
const filters = this.scopeFilters(input);
|
|
3060
|
+
const listening = hasHandlers(this.model, "preSave") || hasHandlers(this.model, "postSave");
|
|
3061
|
+
const before = listening ? await this.list(filters) : [];
|
|
3062
|
+
for (const row of before) await this.fire("preSave", { ...row, ...set }, false);
|
|
3063
|
+
const affected = await this.session.execute(update(this.model).set(set).where(filters)).rowsAffected();
|
|
3064
|
+
if (hasHandlers(this.model, "postSave")) {
|
|
3065
|
+
for (const row of await this.list(filters)) await this.fire("postSave", row, false);
|
|
3066
|
+
}
|
|
3067
|
+
return affected;
|
|
1808
3068
|
}
|
|
1809
|
-
/**
|
|
1810
|
-
|
|
1811
|
-
|
|
3069
|
+
/**
|
|
3070
|
+
* Delete rows matching `filters`; returns the number of rows affected.
|
|
3071
|
+
*
|
|
3072
|
+
* `preDelete` and `postDelete` receive the row as it was **before** the delete —
|
|
3073
|
+
* the only chance to see it. Reading it costs a `SELECT`, which is skipped when
|
|
3074
|
+
* no handler is registered.
|
|
3075
|
+
*
|
|
3076
|
+
* @param filters Which rows to delete.
|
|
3077
|
+
* @returns The number of rows affected.
|
|
3078
|
+
*/
|
|
3079
|
+
async delete(input) {
|
|
3080
|
+
const filters = this.scopeFilters(input);
|
|
3081
|
+
const listening = hasHandlers(this.model, "preDelete") || hasHandlers(this.model, "postDelete");
|
|
3082
|
+
const doomed = listening ? await this.list(filters) : [];
|
|
3083
|
+
for (const row of doomed) await this.fire("preDelete", row, false);
|
|
3084
|
+
const affected = await this.session.execute(del(this.model).where(filters)).rowsAffected();
|
|
3085
|
+
for (const row of doomed) await this.fire("postDelete", row, false);
|
|
3086
|
+
return affected;
|
|
3087
|
+
}
|
|
3088
|
+
/**
|
|
3089
|
+
* Fire one signal for one row on this repository's model and session.
|
|
3090
|
+
*
|
|
3091
|
+
* @param signal Which signal.
|
|
3092
|
+
* @param row The row it is about.
|
|
3093
|
+
* @param isInsert Whether the write was an insert.
|
|
3094
|
+
*/
|
|
3095
|
+
async fire(signal, row, isInsert) {
|
|
3096
|
+
await emitSignal(signal, {
|
|
3097
|
+
row,
|
|
3098
|
+
model: this.model,
|
|
3099
|
+
session: this.session,
|
|
3100
|
+
isInsert
|
|
3101
|
+
});
|
|
1812
3102
|
}
|
|
1813
3103
|
/**
|
|
1814
3104
|
* A page of rows plus metadata. `total` counts all matching rows.
|
|
@@ -1819,7 +3109,7 @@ var BaseRepository = class {
|
|
|
1819
3109
|
async paginate(filter = {}) {
|
|
1820
3110
|
const page = Math.max(1, filter.page ?? 1);
|
|
1821
3111
|
const pageSize = Math.max(1, filter.pageSize ?? 20);
|
|
1822
|
-
const where = filter.filters;
|
|
3112
|
+
const where = this.scopeFilters(filter.filters);
|
|
1823
3113
|
let query = where ? select(this.model).where(where) : select(this.model);
|
|
1824
3114
|
if (filter.orderBy) {
|
|
1825
3115
|
query = query.orderBy(filter.orderBy, filter.ascending === false ? "desc" : "asc");
|
|
@@ -1835,153 +3125,779 @@ var BaseRepository = class {
|
|
|
1835
3125
|
pages: Math.max(1, Math.ceil(total / pageSize))
|
|
1836
3126
|
};
|
|
1837
3127
|
}
|
|
3128
|
+
/**
|
|
3129
|
+
* A cursor-paginated page: the rows after `cursor`, plus the cursor for the
|
|
3130
|
+
* next page.
|
|
3131
|
+
*
|
|
3132
|
+
* No `COUNT(*)` runs, and the page boundary is stable under concurrent inserts —
|
|
3133
|
+
* the two reasons to reach for this instead of {@link paginate} on a large table.
|
|
3134
|
+
* The trade-off is losing random access: there is no "page 7".
|
|
3135
|
+
*
|
|
3136
|
+
* The primary key is always appended as a tie-break, so rows sharing an
|
|
3137
|
+
* `orderBy` value cannot be skipped or repeated across pages.
|
|
3138
|
+
*
|
|
3139
|
+
* @param filter Cursor, page size, ordering and filters.
|
|
3140
|
+
* @returns The page and the next cursor (`null` on the last page).
|
|
3141
|
+
* @throws InvalidCursor When the cursor is malformed or was built for a
|
|
3142
|
+
* different ordering.
|
|
3143
|
+
*/
|
|
3144
|
+
async cursorPaginate(filter = {}) {
|
|
3145
|
+
const limit = Math.max(1, Math.trunc(filter.limit ?? 20));
|
|
3146
|
+
const ascending = filter.ascending === true;
|
|
3147
|
+
const columns = columnsOf(this.model);
|
|
3148
|
+
const primary = filter.orderBy ?? this.pks[0];
|
|
3149
|
+
const keys = [primary, ...this.pks.filter((k) => k !== primary)];
|
|
3150
|
+
const parts = [];
|
|
3151
|
+
const scoped = this.scopeFilters(filter.filters);
|
|
3152
|
+
if (scoped) parts.push(scoped);
|
|
3153
|
+
if (filter.cursor) {
|
|
3154
|
+
parts.push(
|
|
3155
|
+
afterCursor(
|
|
3156
|
+
keys,
|
|
3157
|
+
decodeCursor(columns, keys, filter.cursor),
|
|
3158
|
+
ascending
|
|
3159
|
+
)
|
|
3160
|
+
);
|
|
3161
|
+
}
|
|
3162
|
+
let query = parts.length === 0 ? select(this.model) : select(this.model).where(
|
|
3163
|
+
parts.length === 1 ? parts[0] : and(...parts)
|
|
3164
|
+
);
|
|
3165
|
+
for (const key of keys) {
|
|
3166
|
+
query = query.orderBy(
|
|
3167
|
+
key,
|
|
3168
|
+
ascending ? "asc" : "desc"
|
|
3169
|
+
);
|
|
3170
|
+
}
|
|
3171
|
+
const rows = await this.session.execute(query.limit(limit + 1)).all();
|
|
3172
|
+
const items = rows.slice(0, limit);
|
|
3173
|
+
const last = items[items.length - 1];
|
|
3174
|
+
return {
|
|
3175
|
+
items,
|
|
3176
|
+
nextCursor: rows.length > limit && last !== void 0 ? encodeCursor(columns, keys, last) : null
|
|
3177
|
+
};
|
|
3178
|
+
}
|
|
3179
|
+
/**
|
|
3180
|
+
* Whether any **other** row matches `filters`.
|
|
3181
|
+
*
|
|
3182
|
+
* The uniqueness check for an update: "is this e-mail taken by somebody else?"
|
|
3183
|
+
* A plain `exists` would find the row being edited and report a false conflict.
|
|
3184
|
+
*
|
|
3185
|
+
* @param filters What to look for.
|
|
3186
|
+
* @param key The primary key to exclude — the row being updated.
|
|
3187
|
+
* @returns True when a different row matches.
|
|
3188
|
+
*/
|
|
3189
|
+
async existsExcluding(filters, key) {
|
|
3190
|
+
const excluded = primaryKeyFilter(this.model, key);
|
|
3191
|
+
const condition = and(filters, not(excluded));
|
|
3192
|
+
return await this.first(condition) !== null;
|
|
3193
|
+
}
|
|
3194
|
+
/**
|
|
3195
|
+
* The rows that changed since a high-water mark — the delta-sync read.
|
|
3196
|
+
*
|
|
3197
|
+
* Rows come back oldest change first, tie-broken by primary key, so a client
|
|
3198
|
+
* can advance its watermark monotonically and resume mid-stream with the
|
|
3199
|
+
* cursor. The filter is **strict** (`updatedAt > since`).
|
|
3200
|
+
*
|
|
3201
|
+
* Soft-deleted rows are included on purpose: they are the tombstones that tell
|
|
3202
|
+
* the client to delete its local copy. Filtering them out would strand deleted
|
|
3203
|
+
* rows on the device forever.
|
|
3204
|
+
*
|
|
3205
|
+
* @param filter The watermark, cursor, page size and domain filters.
|
|
3206
|
+
* @returns The page, plus the `serverTime` to persist as the next watermark.
|
|
3207
|
+
* @throws Error When the model has no `updatedAt` column (see `withTimestamps`).
|
|
3208
|
+
*/
|
|
3209
|
+
async changesSince(filter = {}) {
|
|
3210
|
+
this.requireColumn("updatedAt", "changesSince");
|
|
3211
|
+
const serverTime = /* @__PURE__ */ new Date();
|
|
3212
|
+
const clauses = [];
|
|
3213
|
+
if (filter.filters) clauses.push(filter.filters);
|
|
3214
|
+
if (filter.since) {
|
|
3215
|
+
clauses.push({
|
|
3216
|
+
updatedAt: { gt: filter.since }
|
|
3217
|
+
});
|
|
3218
|
+
}
|
|
3219
|
+
const page = await this.cursorPaginate({
|
|
3220
|
+
cursor: filter.cursor ?? null,
|
|
3221
|
+
limit: filter.limit ?? 50,
|
|
3222
|
+
orderBy: "updatedAt",
|
|
3223
|
+
ascending: true,
|
|
3224
|
+
...clauses.length === 0 ? {} : {
|
|
3225
|
+
filters: clauses.length === 1 ? clauses[0] : and(...clauses)
|
|
3226
|
+
}
|
|
3227
|
+
});
|
|
3228
|
+
return { ...page, serverTime };
|
|
3229
|
+
}
|
|
3230
|
+
/**
|
|
3231
|
+
* Insert many rows, overwriting the ones that conflict — one statement.
|
|
3232
|
+
*
|
|
3233
|
+
* @param rows The rows to write.
|
|
3234
|
+
* @param options The conflict target, and optionally which columns to overwrite.
|
|
3235
|
+
* @returns The stored rows.
|
|
3236
|
+
* @throws Error When no conflict column is given.
|
|
3237
|
+
*/
|
|
3238
|
+
async bulkUpsert(rows, options) {
|
|
3239
|
+
if (rows.length === 0) return [];
|
|
3240
|
+
if (options.conflictColumns.length === 0) {
|
|
3241
|
+
throw new Error("bulkUpsert needs at least one conflict column.");
|
|
3242
|
+
}
|
|
3243
|
+
const target = new Set(options.conflictColumns);
|
|
3244
|
+
const columns = options.update ?? [
|
|
3245
|
+
...new Set(rows.flatMap((row) => Object.keys(row)))
|
|
3246
|
+
].filter((name) => !target.has(name));
|
|
3247
|
+
const patch = {};
|
|
3248
|
+
for (const name of columns) patch[name] = sql.excluded(name);
|
|
3249
|
+
return this.session.execute(
|
|
3250
|
+
insert(this.model).values(rows).onConflictDoUpdate(options.conflictColumns, patch).returning()
|
|
3251
|
+
).all();
|
|
3252
|
+
}
|
|
3253
|
+
/**
|
|
3254
|
+
* Mark a row deleted without removing it (`deletedAt = now()`).
|
|
3255
|
+
*
|
|
3256
|
+
* @param key The primary key.
|
|
3257
|
+
* @returns The updated row.
|
|
3258
|
+
* @throws Error When the model has no `deletedAt` column (see `withSoftDelete`).
|
|
3259
|
+
* @throws RecordNotFound When no row carries that key.
|
|
3260
|
+
*/
|
|
3261
|
+
async softDelete(key) {
|
|
3262
|
+
this.requireColumn("deletedAt", "softDelete");
|
|
3263
|
+
const filter = primaryKeyFilter(this.model, key);
|
|
3264
|
+
const affected = await this.update(filter, {
|
|
3265
|
+
deletedAt: sql.now()
|
|
3266
|
+
});
|
|
3267
|
+
if (affected === 0) throw new RecordNotFound(this.model.tablename, filter);
|
|
3268
|
+
return this.getById(key);
|
|
3269
|
+
}
|
|
3270
|
+
/**
|
|
3271
|
+
* Bring a soft-deleted row back (`deletedAt = null`).
|
|
3272
|
+
*
|
|
3273
|
+
* @param key The primary key.
|
|
3274
|
+
* @returns The updated row.
|
|
3275
|
+
* @throws Error When the model has no `deletedAt` column.
|
|
3276
|
+
* @throws RecordNotFound When no row carries that key.
|
|
3277
|
+
*/
|
|
3278
|
+
async restore(key) {
|
|
3279
|
+
this.requireColumn("deletedAt", "restore");
|
|
3280
|
+
const filter = primaryKeyFilter(this.model, key);
|
|
3281
|
+
const affected = await this.update(filter, {
|
|
3282
|
+
deletedAt: null
|
|
3283
|
+
});
|
|
3284
|
+
if (affected === 0) throw new RecordNotFound(this.model.tablename, filter);
|
|
3285
|
+
return this.getById(key);
|
|
3286
|
+
}
|
|
3287
|
+
/**
|
|
3288
|
+
* Delete many rows by primary key, in one statement.
|
|
3289
|
+
*
|
|
3290
|
+
* @param keys The primary keys.
|
|
3291
|
+
* @returns The number of rows actually deleted (keys that matched nothing are
|
|
3292
|
+
* not an error — deleting what is already gone is the desired end state).
|
|
3293
|
+
* @throws Error When the model has a composite primary key: `IN` over a tuple is
|
|
3294
|
+
* not portable, and the caller should loop or build the condition explicitly.
|
|
3295
|
+
*/
|
|
3296
|
+
async deleteBatch(keys) {
|
|
3297
|
+
if (keys.length === 0) return 0;
|
|
3298
|
+
if (this.pks.length > 1) {
|
|
3299
|
+
throw new Error(
|
|
3300
|
+
`${this.model.tablename} has a composite primary key (${this.pks.join(", ")}); deleteBatch takes single-column keys only.`
|
|
3301
|
+
);
|
|
3302
|
+
}
|
|
3303
|
+
const pk = this.pks[0];
|
|
3304
|
+
const values = keys.map(
|
|
3305
|
+
(key) => primaryKeyFilter(this.model, key)[pk]
|
|
3306
|
+
);
|
|
3307
|
+
return this.delete({ [pk]: { in: values } });
|
|
3308
|
+
}
|
|
3309
|
+
/**
|
|
3310
|
+
* Fail loudly when a method needs a column the model does not declare.
|
|
3311
|
+
*
|
|
3312
|
+
* @param column The property name required.
|
|
3313
|
+
* @param method The method asking, for the message.
|
|
3314
|
+
* @throws Error When the column is absent.
|
|
3315
|
+
*/
|
|
3316
|
+
requireColumn(column2, method) {
|
|
3317
|
+
if (column2 in columnsOf(this.model)) return;
|
|
3318
|
+
throw new Error(
|
|
3319
|
+
`${this.model.tablename} has no "${column2}" column, which ${method}() requires \u2014 add it with the matching mixin.`
|
|
3320
|
+
);
|
|
3321
|
+
}
|
|
1838
3322
|
};
|
|
1839
3323
|
|
|
1840
|
-
// src/
|
|
1841
|
-
function
|
|
1842
|
-
|
|
1843
|
-
|
|
3324
|
+
// src/outbox.ts
|
|
3325
|
+
function outboxModel(name) {
|
|
3326
|
+
class OutboxBase extends Model {
|
|
3327
|
+
static tablename = name;
|
|
3328
|
+
/** Monotonic id; also the claim order, so events publish in the order written. */
|
|
3329
|
+
id = column.bigInteger().primaryKey();
|
|
3330
|
+
/** The routing key the relay publishes under. */
|
|
3331
|
+
topic = column.varchar(200).notNull();
|
|
3332
|
+
/** The event body. */
|
|
3333
|
+
payload = column.json().notNull();
|
|
3334
|
+
/** Lifecycle state. */
|
|
3335
|
+
status = column.enum("pending", "sending", "sent", "failed").notNull().default("pending");
|
|
3336
|
+
/** How many publish attempts have been made. */
|
|
3337
|
+
attempts = column.integer().notNull().default(0);
|
|
3338
|
+
/** Epoch milliseconds before which the row must not be claimed (backoff). */
|
|
3339
|
+
availableAt = column.bigInteger().notNull().default(0n);
|
|
3340
|
+
/** When the row was written. */
|
|
3341
|
+
createdAt = column.datetime().notNull().default(sql.now());
|
|
3342
|
+
/** When the relay confirmed the publish. */
|
|
3343
|
+
sentAt = column.datetime();
|
|
3344
|
+
/** The last failure's message, kept for triage. */
|
|
3345
|
+
lastError = column.text();
|
|
3346
|
+
}
|
|
3347
|
+
return OutboxBase;
|
|
3348
|
+
}
|
|
3349
|
+
var OutboxRepository = class extends BaseRepository {
|
|
3350
|
+
/**
|
|
3351
|
+
* Write events to the outbox.
|
|
3352
|
+
*
|
|
3353
|
+
* Call it inside the same `transaction()` as the business write — that is the
|
|
3354
|
+
* whole point, and it is why this does not open a transaction of its own.
|
|
3355
|
+
*
|
|
3356
|
+
* @param events One event, or many.
|
|
3357
|
+
* @returns The stored rows.
|
|
3358
|
+
*/
|
|
3359
|
+
async publish(events) {
|
|
3360
|
+
const list = Array.isArray(events) ? events : [events];
|
|
3361
|
+
if (list.length === 0) return [];
|
|
3362
|
+
const now2 = Date.now();
|
|
3363
|
+
return this.createMany(
|
|
3364
|
+
list.map(
|
|
3365
|
+
(event) => ({
|
|
3366
|
+
topic: event.topic,
|
|
3367
|
+
payload: event.payload,
|
|
3368
|
+
status: "pending",
|
|
3369
|
+
attempts: 0,
|
|
3370
|
+
availableAt: BigInt(now2 + (event.delayMs ?? 0))
|
|
3371
|
+
})
|
|
3372
|
+
)
|
|
3373
|
+
);
|
|
3374
|
+
}
|
|
3375
|
+
/**
|
|
3376
|
+
* Claim a batch of due events for this relay, in one statement.
|
|
3377
|
+
*
|
|
3378
|
+
* Uses `FOR UPDATE SKIP LOCKED` over a subquery, so two relays running at once
|
|
3379
|
+
* take **disjoint** batches instead of fighting over the same rows. SQLite has
|
|
3380
|
+
* no row locking and throws — a single-process relay there can claim with
|
|
3381
|
+
* `pending()` plus an update.
|
|
3382
|
+
*
|
|
3383
|
+
* @param limit How many events to take.
|
|
3384
|
+
* @param options Topic filter, and a clock override for tests.
|
|
3385
|
+
* @returns The claimed rows, oldest first.
|
|
3386
|
+
*/
|
|
3387
|
+
async claim(limit, options) {
|
|
3388
|
+
const now2 = options?.now ?? Date.now();
|
|
3389
|
+
const due = this.dueQuery(now2, options?.topics).orderBy("id").limit(limit).forUpdate({ skipLocked: true }).asSubquery("id");
|
|
3390
|
+
return this.session.execute(
|
|
3391
|
+
update(this.model).set({
|
|
3392
|
+
status: "sending",
|
|
3393
|
+
attempts: sql.raw("attempts + 1")
|
|
3394
|
+
}).where({ id: { in: due } }).returning()
|
|
3395
|
+
).all();
|
|
3396
|
+
}
|
|
3397
|
+
/**
|
|
3398
|
+
* The events that are due, without claiming them.
|
|
3399
|
+
*
|
|
3400
|
+
* @param limit How many to read.
|
|
3401
|
+
* @param options Topic filter and clock override.
|
|
3402
|
+
* @returns The due rows, oldest first.
|
|
3403
|
+
*/
|
|
3404
|
+
async pending(limit, options) {
|
|
3405
|
+
const now2 = options?.now ?? Date.now();
|
|
3406
|
+
return this.session.execute(
|
|
3407
|
+
this.dueQuery(now2, options?.topics).orderBy("id").limit(limit)
|
|
3408
|
+
).all();
|
|
1844
3409
|
}
|
|
1845
|
-
|
|
3410
|
+
/**
|
|
3411
|
+
* Confirm that events were published.
|
|
3412
|
+
*
|
|
3413
|
+
* @param ids The claimed ids.
|
|
3414
|
+
* @returns How many rows were marked sent.
|
|
3415
|
+
*/
|
|
3416
|
+
async markSent(ids) {
|
|
3417
|
+
if (ids.length === 0) return 0;
|
|
3418
|
+
return this.update(
|
|
3419
|
+
{ id: { in: ids } },
|
|
3420
|
+
{
|
|
3421
|
+
status: "sent",
|
|
3422
|
+
sentAt: sql.now()
|
|
3423
|
+
}
|
|
3424
|
+
);
|
|
3425
|
+
}
|
|
3426
|
+
/**
|
|
3427
|
+
* Record a failed publish, scheduling a retry unless it is permanent.
|
|
3428
|
+
*
|
|
3429
|
+
* The attempt counter was already incremented by {@link claim}, so a row that
|
|
3430
|
+
* keeps failing carries its own history — which is what a dead-letter policy
|
|
3431
|
+
* reads.
|
|
3432
|
+
*
|
|
3433
|
+
* @param id The event's id.
|
|
3434
|
+
* @param error The failure, for triage.
|
|
3435
|
+
* @param options Backoff delay, or `permanent` to stop retrying.
|
|
3436
|
+
* @returns How many rows were updated (0 or 1).
|
|
3437
|
+
*/
|
|
3438
|
+
async markFailed(id, error, options) {
|
|
3439
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3440
|
+
return this.update(
|
|
3441
|
+
{ id },
|
|
3442
|
+
{
|
|
3443
|
+
status: options?.permanent ? "failed" : "pending",
|
|
3444
|
+
availableAt: BigInt(Date.now() + (options?.retryInMs ?? 0)),
|
|
3445
|
+
lastError: message.slice(0, 1e3)
|
|
3446
|
+
}
|
|
3447
|
+
);
|
|
3448
|
+
}
|
|
3449
|
+
/**
|
|
3450
|
+
* The SELECT of events that may be claimed now.
|
|
3451
|
+
*
|
|
3452
|
+
* @param now The current epoch milliseconds.
|
|
3453
|
+
* @param topics Optional topic filter.
|
|
3454
|
+
* @returns The builder, unordered.
|
|
3455
|
+
*/
|
|
3456
|
+
dueQuery(now2, topics) {
|
|
3457
|
+
const where = {
|
|
3458
|
+
status: "pending",
|
|
3459
|
+
availableAt: { lte: BigInt(now2) }
|
|
3460
|
+
};
|
|
3461
|
+
if (topics && topics.length > 0) where.topic = { in: topics };
|
|
3462
|
+
return select(this.model).where(where);
|
|
3463
|
+
}
|
|
3464
|
+
};
|
|
3465
|
+
|
|
3466
|
+
// src/explain.ts
|
|
3467
|
+
function isReadOnlyStatement(sql2) {
|
|
3468
|
+
return /^\s*(select|with)\b/i.test(sql2);
|
|
1846
3469
|
}
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
3470
|
+
function summarizePlan(plan) {
|
|
3471
|
+
if (Array.isArray(plan)) {
|
|
3472
|
+
const rows = plan;
|
|
3473
|
+
const details = rows.map((row) => row.detail ?? row["QUERY PLAN"]).filter((detail) => typeof detail === "string");
|
|
3474
|
+
if (details.length > 0) return details.join(" | ");
|
|
3475
|
+
}
|
|
3476
|
+
const node = plan?.Plan;
|
|
3477
|
+
if (node) return describePostgresNode(node);
|
|
3478
|
+
return JSON.stringify(plan);
|
|
3479
|
+
}
|
|
3480
|
+
function describePostgresNode(node) {
|
|
3481
|
+
const parts = [];
|
|
3482
|
+
const type = node["Node Type"];
|
|
3483
|
+
const relation = node["Relation Name"];
|
|
3484
|
+
const index2 = node["Index Name"];
|
|
3485
|
+
parts.push(
|
|
3486
|
+
[type, relation ? `on ${relation}` : "", index2 ? `using ${index2}` : ""].filter((piece) => piece !== "").join(" ")
|
|
3487
|
+
);
|
|
3488
|
+
const children = node.Plans;
|
|
3489
|
+
if (Array.isArray(children)) {
|
|
3490
|
+
for (const child of children) {
|
|
3491
|
+
parts.push(describePostgresNode(child));
|
|
3492
|
+
}
|
|
3493
|
+
}
|
|
3494
|
+
return parts.join(" -> ");
|
|
3495
|
+
}
|
|
3496
|
+
async function buildReport(session, statements, prefix, options) {
|
|
3497
|
+
const analyze = options?.analyze === true;
|
|
3498
|
+
const plans = [];
|
|
3499
|
+
for (const statement of statements) {
|
|
3500
|
+
if (options?.filter && !options.filter(statement.sql)) continue;
|
|
3501
|
+
if (analyze && !isReadOnlyStatement(statement.sql)) {
|
|
3502
|
+
throw new Error(
|
|
3503
|
+
`EXPLAIN ANALYZE executes the statement, so it is refused for a write: ${statement.sql.slice(0, 80)}`
|
|
3504
|
+
);
|
|
3505
|
+
}
|
|
3506
|
+
const rows = await session.raw(`${prefix(analyze)} ${statement.sql}`, statement.params).all();
|
|
3507
|
+
const plan = extractPlan(rows);
|
|
3508
|
+
plans.push({
|
|
3509
|
+
sql: statement.sql,
|
|
3510
|
+
params: statement.params,
|
|
3511
|
+
plan,
|
|
3512
|
+
summary: () => summarizePlan(plan)
|
|
3513
|
+
});
|
|
3514
|
+
}
|
|
3515
|
+
return {
|
|
3516
|
+
plans,
|
|
3517
|
+
summary: () => plans.map((p) => `${p.summary()}
|
|
3518
|
+
${p.sql}`).join("\n")
|
|
3519
|
+
};
|
|
3520
|
+
}
|
|
3521
|
+
function extractPlan(rows) {
|
|
3522
|
+
const first = rows[0];
|
|
3523
|
+
if (rows.length === 1 && first) {
|
|
3524
|
+
const value = first["QUERY PLAN"] ?? Object.values(first)[0];
|
|
3525
|
+
const parsed = typeof value === "string" ? tryParseJson(value) : value;
|
|
3526
|
+
if (Array.isArray(parsed) && parsed.length === 1) return parsed[0];
|
|
3527
|
+
if (parsed !== void 0 && typeof parsed === "object") return parsed;
|
|
3528
|
+
}
|
|
3529
|
+
return rows;
|
|
3530
|
+
}
|
|
3531
|
+
function tryParseJson(value) {
|
|
3532
|
+
try {
|
|
3533
|
+
return JSON.parse(value);
|
|
3534
|
+
} catch {
|
|
3535
|
+
return value;
|
|
3536
|
+
}
|
|
3537
|
+
}
|
|
3538
|
+
|
|
3539
|
+
// src/tenant.ts
|
|
3540
|
+
var TenantScopedRepository = class extends BaseRepository {
|
|
3541
|
+
scope;
|
|
3542
|
+
/**
|
|
3543
|
+
* Bind a repository to one tenant.
|
|
3544
|
+
*
|
|
3545
|
+
* @param model The model class.
|
|
3546
|
+
* @param session The session to run on.
|
|
3547
|
+
* @param scope The tenant column and id.
|
|
3548
|
+
* @throws Error When the model has no such column — a scope that silently
|
|
3549
|
+
* matches nothing is worse than no scope at all.
|
|
3550
|
+
*/
|
|
3551
|
+
constructor(model, session, scope) {
|
|
3552
|
+
super(model, session);
|
|
3553
|
+
if (!(scope.column in columnsOf(model))) {
|
|
3554
|
+
throw new Error(
|
|
3555
|
+
`${model.tablename} has no "${scope.column}" column to scope by tenant.`
|
|
3556
|
+
);
|
|
3557
|
+
}
|
|
3558
|
+
this.scope = scope;
|
|
3559
|
+
}
|
|
3560
|
+
/** The tenant this repository is bound to. */
|
|
3561
|
+
get tenantId() {
|
|
3562
|
+
return this.scope.id;
|
|
3563
|
+
}
|
|
3564
|
+
/**
|
|
3565
|
+
* Merge the tenant predicate into every read.
|
|
3566
|
+
*
|
|
3567
|
+
* The caller's filters are **added to**, never replaced: passing another
|
|
3568
|
+
* tenant's id produces a contradiction that matches nothing, which is the safe
|
|
3569
|
+
* outcome.
|
|
3570
|
+
*
|
|
3571
|
+
* @param filters The caller's filters.
|
|
3572
|
+
* @returns The filters plus the tenant predicate.
|
|
3573
|
+
*/
|
|
3574
|
+
scopeFilters(filters) {
|
|
3575
|
+
const tenant = { [this.scope.column]: this.scope.id };
|
|
3576
|
+
if (!filters) return tenant;
|
|
3577
|
+
return and(filters, tenant);
|
|
3578
|
+
}
|
|
3579
|
+
/**
|
|
3580
|
+
* Stamp the tenant onto every row written.
|
|
3581
|
+
*
|
|
3582
|
+
* A row that arrives carrying a **different** tenant is refused rather than
|
|
3583
|
+
* overwritten: silently rewriting it would turn a caller's bug into data that
|
|
3584
|
+
* looks deliberate.
|
|
3585
|
+
*
|
|
3586
|
+
* @param data The row being written.
|
|
3587
|
+
* @returns The row with the tenant column set.
|
|
3588
|
+
* @throws Error When the row names another tenant.
|
|
3589
|
+
*/
|
|
3590
|
+
scopeWrite(data) {
|
|
3591
|
+
const given = data[this.scope.column];
|
|
3592
|
+
if (given !== void 0 && given !== null && given !== this.scope.id) {
|
|
3593
|
+
throw new Error(
|
|
3594
|
+
`Refusing to write ${this.model.tablename}.${this.scope.column} = ${JSON.stringify(given)} from a repository scoped to ${JSON.stringify(this.scope.id)}.`
|
|
3595
|
+
);
|
|
3596
|
+
}
|
|
3597
|
+
return { ...data, [this.scope.column]: this.scope.id };
|
|
3598
|
+
}
|
|
3599
|
+
};
|
|
3600
|
+
|
|
3601
|
+
// src/audit.ts
|
|
3602
|
+
function auditLogModel(name) {
|
|
3603
|
+
class AuditBase extends Model {
|
|
3604
|
+
static tablename = name;
|
|
3605
|
+
/** Monotonic id — also the order the changes happened in. */
|
|
3606
|
+
id = column.bigInteger().primaryKey();
|
|
3607
|
+
/** The audited table. */
|
|
3608
|
+
tableName = column.varchar(200).notNull();
|
|
3609
|
+
/** The audited row's primary key, as an object. */
|
|
3610
|
+
rowKey = column.json().notNull();
|
|
3611
|
+
/** What happened. */
|
|
3612
|
+
action = column.enum("insert", "update", "delete").notNull();
|
|
3613
|
+
/** Who did it, when the caller could say. */
|
|
3614
|
+
actor = column.varchar(200);
|
|
3615
|
+
/** The changed columns as `[before, after]`; the whole row on insert/delete. */
|
|
3616
|
+
changes = column.json().notNull();
|
|
3617
|
+
/** When the entry was written. */
|
|
3618
|
+
at = column.datetime().notNull().default(sql.now());
|
|
3619
|
+
}
|
|
3620
|
+
return AuditBase;
|
|
3621
|
+
}
|
|
3622
|
+
function snapshot(model, row, exclude = []) {
|
|
3623
|
+
const columns = columnsOf(model);
|
|
3624
|
+
const out = {};
|
|
3625
|
+
for (const [name, col2] of Object.entries(columns)) {
|
|
3626
|
+
if (exclude.includes(name)) continue;
|
|
3627
|
+
if (!(name in row)) continue;
|
|
3628
|
+
out[name] = encodeValue(col2, row[name]);
|
|
3629
|
+
}
|
|
3630
|
+
return out;
|
|
3631
|
+
}
|
|
3632
|
+
function diffSnapshots(before, after) {
|
|
3633
|
+
const diff = {};
|
|
3634
|
+
for (const key of /* @__PURE__ */ new Set([...Object.keys(before), ...Object.keys(after)])) {
|
|
3635
|
+
const from = before[key] ?? null;
|
|
3636
|
+
const to = after[key] ?? null;
|
|
3637
|
+
if (JSON.stringify(from) !== JSON.stringify(to)) diff[key] = [from, to];
|
|
3638
|
+
}
|
|
3639
|
+
return diff;
|
|
3640
|
+
}
|
|
3641
|
+
var pending = /* @__PURE__ */ new WeakMap();
|
|
3642
|
+
function pendingKey(model, key) {
|
|
3643
|
+
return `${model.tablename}:${JSON.stringify(key)}`;
|
|
3644
|
+
}
|
|
3645
|
+
function enableAudit(model, options) {
|
|
3646
|
+
const exclude = options.exclude ?? [];
|
|
3647
|
+
const write = async (session, action, row, changes) => {
|
|
3648
|
+
await new BaseRepository(options.log, session).create({
|
|
3649
|
+
tableName: model.tablename,
|
|
3650
|
+
rowKey: primaryKeyFilter(model, row),
|
|
3651
|
+
action,
|
|
3652
|
+
actor: options.actor?.() ?? null,
|
|
3653
|
+
changes
|
|
3654
|
+
});
|
|
3655
|
+
};
|
|
3656
|
+
const offs = [
|
|
3657
|
+
onSignal(model, "preSave", async ({ row, session, isInsert }) => {
|
|
3658
|
+
if (isInsert) return;
|
|
3659
|
+
const key = primaryKeyFilter(model, row);
|
|
3660
|
+
const before = await new BaseRepository(model, session).first(key);
|
|
3661
|
+
if (!before) return;
|
|
3662
|
+
let bySession = pending.get(session);
|
|
3663
|
+
if (!bySession) {
|
|
3664
|
+
bySession = /* @__PURE__ */ new Map();
|
|
3665
|
+
pending.set(session, bySession);
|
|
3666
|
+
}
|
|
3667
|
+
bySession.set(
|
|
3668
|
+
pendingKey(model, key),
|
|
3669
|
+
snapshot(model, before, exclude)
|
|
3670
|
+
);
|
|
3671
|
+
}),
|
|
3672
|
+
onSignal(model, "postSave", async ({ row, session, isInsert }) => {
|
|
3673
|
+
const record = row;
|
|
3674
|
+
const after = snapshot(model, record, exclude);
|
|
3675
|
+
if (isInsert) {
|
|
3676
|
+
await write(session, "insert", record, toInsertDiff(after));
|
|
3677
|
+
return;
|
|
3678
|
+
}
|
|
3679
|
+
const key = pendingKey(model, primaryKeyFilter(model, record));
|
|
3680
|
+
const before = pending.get(session)?.get(key) ?? {};
|
|
3681
|
+
pending.get(session)?.delete(key);
|
|
3682
|
+
const changes = diffSnapshots(before, after);
|
|
3683
|
+
if (Object.keys(changes).length === 0) return;
|
|
3684
|
+
await write(session, "update", record, changes);
|
|
3685
|
+
}),
|
|
3686
|
+
onSignal(model, "preDelete", async ({ row, session }) => {
|
|
3687
|
+
const record = row;
|
|
3688
|
+
await write(
|
|
3689
|
+
session,
|
|
3690
|
+
"delete",
|
|
3691
|
+
record,
|
|
3692
|
+
toDeleteDiff(snapshot(model, record, exclude))
|
|
3693
|
+
);
|
|
3694
|
+
})
|
|
3695
|
+
];
|
|
3696
|
+
return () => {
|
|
3697
|
+
for (const off of offs) off();
|
|
3698
|
+
};
|
|
3699
|
+
}
|
|
3700
|
+
function toInsertDiff(after) {
|
|
3701
|
+
const diff = {};
|
|
3702
|
+
for (const [key, value] of Object.entries(after)) diff[key] = [null, value];
|
|
3703
|
+
return diff;
|
|
3704
|
+
}
|
|
3705
|
+
function toDeleteDiff(before) {
|
|
3706
|
+
const diff = {};
|
|
3707
|
+
for (const [key, value] of Object.entries(before)) diff[key] = [value, null];
|
|
3708
|
+
return diff;
|
|
3709
|
+
}
|
|
3710
|
+
|
|
3711
|
+
// src/unit-of-work.ts
|
|
3712
|
+
var UnitOfWork = class {
|
|
3713
|
+
constructor(session) {
|
|
1850
3714
|
this.session = session;
|
|
1851
|
-
this.data = data;
|
|
1852
|
-
this.pk = primaryKeyOf2(model);
|
|
1853
3715
|
}
|
|
1854
|
-
model;
|
|
1855
3716
|
session;
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
return this.data[this.pk];
|
|
3717
|
+
entries = /* @__PURE__ */ new Map();
|
|
3718
|
+
/** How many rows are being tracked. */
|
|
3719
|
+
get size() {
|
|
3720
|
+
return this.entries.size;
|
|
1861
3721
|
}
|
|
1862
|
-
|
|
1863
|
-
|
|
3722
|
+
/**
|
|
3723
|
+
* Load a row by primary key, or return the one already loaded.
|
|
3724
|
+
*
|
|
3725
|
+
* The second call for the same key does **not** hit the database and returns
|
|
3726
|
+
* the **same object** as the first.
|
|
3727
|
+
*
|
|
3728
|
+
* @param model The model class.
|
|
3729
|
+
* @param key The primary key — a value, or an object for a composite key.
|
|
3730
|
+
* @returns The tracked row, or `null` when there is none.
|
|
3731
|
+
*/
|
|
3732
|
+
async get(model, key) {
|
|
3733
|
+
const id = this.identity(model, key);
|
|
3734
|
+
const known = this.entries.get(id);
|
|
3735
|
+
if (known)
|
|
3736
|
+
return known.state === "removed" ? null : known.row;
|
|
3737
|
+
const row = await new BaseRepository(model, this.session).getByIdOrNull(key);
|
|
3738
|
+
if (row === null) return null;
|
|
3739
|
+
const tracked = { ...row };
|
|
3740
|
+
this.entries.set(id, {
|
|
3741
|
+
model,
|
|
3742
|
+
row: tracked,
|
|
3743
|
+
snapshot: { ...tracked },
|
|
3744
|
+
state: "clean"
|
|
3745
|
+
});
|
|
3746
|
+
return tracked;
|
|
1864
3747
|
}
|
|
1865
3748
|
/**
|
|
1866
|
-
*
|
|
1867
|
-
* row (upsert on the primary key). Refreshes `data` from the returned row.
|
|
3749
|
+
* Track an existing row that was loaded elsewhere.
|
|
1868
3750
|
*
|
|
1869
|
-
* @
|
|
3751
|
+
* @param model The model class.
|
|
3752
|
+
* @param row The row, as loaded.
|
|
3753
|
+
* @returns The tracked object — the same one, if this row is already known.
|
|
1870
3754
|
*/
|
|
1871
|
-
|
|
1872
|
-
const
|
|
1873
|
-
const
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
).one();
|
|
1884
|
-
this.data = saved;
|
|
1885
|
-
return this;
|
|
3755
|
+
track(model, row) {
|
|
3756
|
+
const id = this.identity(model, row);
|
|
3757
|
+
const known = this.entries.get(id);
|
|
3758
|
+
if (known) return known.row;
|
|
3759
|
+
const tracked = { ...row };
|
|
3760
|
+
this.entries.set(id, {
|
|
3761
|
+
model,
|
|
3762
|
+
row: tracked,
|
|
3763
|
+
snapshot: { ...tracked },
|
|
3764
|
+
state: "clean"
|
|
3765
|
+
});
|
|
3766
|
+
return tracked;
|
|
1886
3767
|
}
|
|
1887
3768
|
/**
|
|
1888
|
-
*
|
|
3769
|
+
* Schedule an insert.
|
|
1889
3770
|
*
|
|
1890
|
-
* @param
|
|
1891
|
-
* @
|
|
3771
|
+
* @param model The model class.
|
|
3772
|
+
* @param data The row to insert; it must carry the primary key, since the
|
|
3773
|
+
* identity map is keyed by it and a database-generated id is not known yet.
|
|
3774
|
+
* @returns The tracked row.
|
|
3775
|
+
* @throws Error When the key is incomplete.
|
|
1892
3776
|
*/
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
3777
|
+
add(model, data) {
|
|
3778
|
+
const row = { ...data };
|
|
3779
|
+
const id = this.identity(model, row);
|
|
3780
|
+
this.entries.set(id, { model, row, snapshot: null, state: "new" });
|
|
3781
|
+
return row;
|
|
1897
3782
|
}
|
|
1898
3783
|
/**
|
|
1899
|
-
*
|
|
3784
|
+
* Schedule a delete.
|
|
3785
|
+
*
|
|
3786
|
+
* A row added and then removed before the flush simply disappears — no
|
|
3787
|
+
* statement is emitted for it.
|
|
3788
|
+
*
|
|
3789
|
+
* @param model The model class.
|
|
3790
|
+
* @param row The row to delete.
|
|
3791
|
+
*/
|
|
3792
|
+
remove(model, row) {
|
|
3793
|
+
const id = this.identity(model, row);
|
|
3794
|
+
const known = this.entries.get(id);
|
|
3795
|
+
if (known?.state === "new") {
|
|
3796
|
+
this.entries.delete(id);
|
|
3797
|
+
return;
|
|
3798
|
+
}
|
|
3799
|
+
if (known) {
|
|
3800
|
+
known.state = "removed";
|
|
3801
|
+
return;
|
|
3802
|
+
}
|
|
3803
|
+
this.entries.set(id, {
|
|
3804
|
+
model,
|
|
3805
|
+
row: { ...row },
|
|
3806
|
+
snapshot: { ...row },
|
|
3807
|
+
state: "removed"
|
|
3808
|
+
});
|
|
3809
|
+
}
|
|
3810
|
+
/**
|
|
3811
|
+
* Write every pending change, in one transaction.
|
|
3812
|
+
*
|
|
3813
|
+
* Order is inserts, then updates, then deletes — the order that keeps a
|
|
3814
|
+
* foreign key satisfied when a new parent and its children are flushed
|
|
3815
|
+
* together. It is **not** a topological sort: a graph that needs one should be
|
|
3816
|
+
* flushed in stages.
|
|
3817
|
+
*
|
|
3818
|
+
* A statement that fails takes the whole flush with it, since it all runs in
|
|
3819
|
+
* one transaction. The tracked state is left untouched in that case, so the
|
|
3820
|
+
* caller can fix and flush again.
|
|
1900
3821
|
*
|
|
1901
|
-
* @returns
|
|
3822
|
+
* @returns How many rows were inserted, updated and deleted.
|
|
1902
3823
|
*/
|
|
1903
|
-
async
|
|
1904
|
-
|
|
3824
|
+
async flush() {
|
|
3825
|
+
const inserts = [...this.entries.values()].filter((e) => e.state === "new");
|
|
3826
|
+
const removals = [...this.entries.values()].filter((e) => e.state === "removed");
|
|
3827
|
+
const updates = [...this.entries.values()].filter((e) => e.state === "clean" && e.snapshot !== null).map((entry) => ({ entry, patch: diffRow(entry) })).filter(({ patch }) => Object.keys(patch).length > 0);
|
|
3828
|
+
if (inserts.length === 0 && updates.length === 0 && removals.length === 0) {
|
|
3829
|
+
return { inserted: 0, updated: 0, deleted: 0 };
|
|
3830
|
+
}
|
|
3831
|
+
const result = await this.session.transaction(async (tx) => {
|
|
3832
|
+
let inserted = 0;
|
|
3833
|
+
let updated = 0;
|
|
3834
|
+
let deleted = 0;
|
|
3835
|
+
for (const entry of inserts) {
|
|
3836
|
+
await new BaseRepository(entry.model, tx).create(
|
|
3837
|
+
entry.row
|
|
3838
|
+
);
|
|
3839
|
+
inserted += 1;
|
|
3840
|
+
}
|
|
3841
|
+
for (const { entry, patch } of updates) {
|
|
3842
|
+
updated += await new BaseRepository(entry.model, tx).update(
|
|
3843
|
+
primaryKeyFilter(entry.model, entry.row),
|
|
3844
|
+
patch
|
|
3845
|
+
);
|
|
3846
|
+
}
|
|
3847
|
+
for (const entry of removals) {
|
|
3848
|
+
deleted += await new BaseRepository(entry.model, tx).delete(
|
|
3849
|
+
primaryKeyFilter(entry.model, entry.row)
|
|
3850
|
+
);
|
|
3851
|
+
}
|
|
3852
|
+
return { inserted, updated, deleted };
|
|
3853
|
+
});
|
|
3854
|
+
for (const entry of inserts) entry.state = "clean";
|
|
3855
|
+
for (const { entry } of updates) entry.snapshot = { ...entry.row };
|
|
3856
|
+
for (const entry of inserts) entry.snapshot = { ...entry.row };
|
|
3857
|
+
for (const entry of removals) {
|
|
3858
|
+
this.entries.delete(this.identity(entry.model, entry.row));
|
|
3859
|
+
}
|
|
3860
|
+
return result;
|
|
3861
|
+
}
|
|
3862
|
+
/** Forget everything tracked, without writing. */
|
|
3863
|
+
clear() {
|
|
3864
|
+
this.entries.clear();
|
|
1905
3865
|
}
|
|
1906
3866
|
/**
|
|
1907
|
-
*
|
|
3867
|
+
* The identity-map key for a row or a primary key.
|
|
1908
3868
|
*
|
|
1909
|
-
* @
|
|
1910
|
-
* @
|
|
3869
|
+
* @param model The model class.
|
|
3870
|
+
* @param key The row, or the key.
|
|
3871
|
+
* @returns A stable string key.
|
|
1911
3872
|
*/
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
if (fresh === null) {
|
|
1915
|
-
throw new Error(
|
|
1916
|
-
`${this.model.tablename} row ${JSON.stringify(this.pkValue())} not found on reload`
|
|
1917
|
-
);
|
|
1918
|
-
}
|
|
1919
|
-
this.data = fresh;
|
|
1920
|
-
return this;
|
|
3873
|
+
identity(model, key) {
|
|
3874
|
+
return `${model.tablename}:${JSON.stringify(primaryKeyFilter(model, key))}`;
|
|
1921
3875
|
}
|
|
1922
3876
|
};
|
|
1923
|
-
function
|
|
1924
|
-
const
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
}
|
|
1934
|
-
|
|
1935
|
-
// src/relations.ts
|
|
1936
|
-
function hasMany(target, keys) {
|
|
1937
|
-
return {
|
|
1938
|
-
kind: "hasMany",
|
|
1939
|
-
target,
|
|
1940
|
-
localKey: keys.localKey,
|
|
1941
|
-
foreignKey: keys.foreignKey
|
|
1942
|
-
};
|
|
1943
|
-
}
|
|
1944
|
-
function belongsTo(target, keys) {
|
|
1945
|
-
return {
|
|
1946
|
-
kind: "belongsTo",
|
|
1947
|
-
target,
|
|
1948
|
-
localKey: keys.localKey,
|
|
1949
|
-
foreignKey: keys.foreignKey
|
|
1950
|
-
};
|
|
3877
|
+
function diffRow(entry) {
|
|
3878
|
+
const patch = {};
|
|
3879
|
+
const snapshot2 = entry.snapshot ?? {};
|
|
3880
|
+
for (const name of Object.keys(columnsOf(entry.model))) {
|
|
3881
|
+
if (!(name in entry.row)) continue;
|
|
3882
|
+
const before = snapshot2[name] ?? null;
|
|
3883
|
+
const after = entry.row[name] ?? null;
|
|
3884
|
+
if (!sameValue(before, after)) patch[name] = entry.row[name];
|
|
3885
|
+
}
|
|
3886
|
+
return patch;
|
|
1951
3887
|
}
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
const related = localValues.length > 0 ? await session.execute(
|
|
1958
|
-
select(target).where({
|
|
1959
|
-
[rel.foreignKey]: { in: localValues }
|
|
1960
|
-
})
|
|
1961
|
-
).all() : [];
|
|
1962
|
-
if (rel.kind === "hasMany") {
|
|
1963
|
-
const grouped = /* @__PURE__ */ new Map();
|
|
1964
|
-
for (const row of related) {
|
|
1965
|
-
const key = row[rel.foreignKey];
|
|
1966
|
-
const list = grouped.get(key) ?? [];
|
|
1967
|
-
list.push(row);
|
|
1968
|
-
grouped.set(key, list);
|
|
1969
|
-
}
|
|
1970
|
-
out.forEach((r, i) => {
|
|
1971
|
-
r[name] = grouped.get(rows[i]?.[rel.localKey]) ?? [];
|
|
1972
|
-
});
|
|
1973
|
-
} else {
|
|
1974
|
-
const byKey = /* @__PURE__ */ new Map();
|
|
1975
|
-
for (const row of related) {
|
|
1976
|
-
byKey.set(row[rel.foreignKey], row);
|
|
1977
|
-
}
|
|
1978
|
-
out.forEach((r, i) => {
|
|
1979
|
-
r[name] = byKey.get(rows[i]?.[rel.localKey]) ?? null;
|
|
1980
|
-
});
|
|
1981
|
-
}
|
|
3888
|
+
function sameValue(a, b) {
|
|
3889
|
+
if (a === b) return true;
|
|
3890
|
+
if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
|
|
3891
|
+
if (a instanceof Uint8Array && b instanceof Uint8Array) {
|
|
3892
|
+
return a.length === b.length && a.every((byte, i) => byte === b[i]);
|
|
1982
3893
|
}
|
|
1983
|
-
|
|
3894
|
+
if (typeof a === "object" && typeof b === "object" && a !== null && b !== null) {
|
|
3895
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
3896
|
+
}
|
|
3897
|
+
return false;
|
|
1984
3898
|
}
|
|
3899
|
+
|
|
3900
|
+
// src/engine.ts
|
|
1985
3901
|
var nodeRequire = module$1.createRequire((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.cjs', document.baseURI).href)));
|
|
1986
3902
|
function encodeSqliteParam(value) {
|
|
1987
3903
|
if (value === void 0 || value === null) return null;
|
|
@@ -2047,8 +3963,70 @@ var NodeSqliteDriver = class _NodeSqliteDriver {
|
|
|
2047
3963
|
this.db.close();
|
|
2048
3964
|
}
|
|
2049
3965
|
};
|
|
3966
|
+
var BetterSqliteDriver = class _BetterSqliteDriver {
|
|
3967
|
+
// biome-ignore lint/suspicious/noExplicitAny: the peer dep's types are optional here.
|
|
3968
|
+
db;
|
|
3969
|
+
/** Prepared-statement cache keyed by SQL text — see {@link NodeSqliteDriver}. */
|
|
3970
|
+
// biome-ignore lint/suspicious/noExplicitAny: see above.
|
|
3971
|
+
statements = /* @__PURE__ */ new Map();
|
|
3972
|
+
// biome-ignore lint/suspicious/noExplicitAny: accept an already-open Database handle.
|
|
3973
|
+
constructor(database) {
|
|
3974
|
+
this.db = database;
|
|
3975
|
+
}
|
|
3976
|
+
/**
|
|
3977
|
+
* Open a `better-sqlite3` database at the given path (or `":memory:"`).
|
|
3978
|
+
*
|
|
3979
|
+
* @param path The database file, or `":memory:"`.
|
|
3980
|
+
* @param options Passed straight to `new Database()` (`readonly`, `timeout`, …).
|
|
3981
|
+
* @returns A driver over the open handle.
|
|
3982
|
+
* @throws If `better-sqlite3` is not installed — it is an optional peer
|
|
3983
|
+
* dependency, so the error names the package to install.
|
|
3984
|
+
*/
|
|
3985
|
+
static open(path, options) {
|
|
3986
|
+
let Database;
|
|
3987
|
+
try {
|
|
3988
|
+
const mod = nodeRequire("better-sqlite3");
|
|
3989
|
+
Database = mod.default ?? mod;
|
|
3990
|
+
} catch (cause) {
|
|
3991
|
+
throw new Error(
|
|
3992
|
+
'The "better-sqlite3" driver requires the better-sqlite3 package: npm install better-sqlite3',
|
|
3993
|
+
{ cause }
|
|
3994
|
+
);
|
|
3995
|
+
}
|
|
3996
|
+
return new _BetterSqliteDriver(
|
|
3997
|
+
options ? new Database(path, { ...options }) : new Database(path)
|
|
3998
|
+
);
|
|
3999
|
+
}
|
|
4000
|
+
/** Return the cached prepared statement for `sql`, preparing it on first use. */
|
|
4001
|
+
// biome-ignore lint/suspicious/noExplicitAny: statement type is optional here.
|
|
4002
|
+
prepare(sql2) {
|
|
4003
|
+
const cached = this.statements.get(sql2);
|
|
4004
|
+
if (cached) return cached;
|
|
4005
|
+
const stmt = this.db.prepare(sql2);
|
|
4006
|
+
this.statements.set(sql2, stmt);
|
|
4007
|
+
return stmt;
|
|
4008
|
+
}
|
|
4009
|
+
execute(sql2, params) {
|
|
4010
|
+
const stmt = this.prepare(sql2);
|
|
4011
|
+
const bound = params.map(encodeSqliteParam);
|
|
4012
|
+
if (stmt.reader) {
|
|
4013
|
+
return { rows: stmt.all(...bound), changes: 0 };
|
|
4014
|
+
}
|
|
4015
|
+
const info = stmt.run(...bound);
|
|
4016
|
+
return { rows: [], changes: Number(info.changes ?? 0) };
|
|
4017
|
+
}
|
|
4018
|
+
*iterate(sql2, params) {
|
|
4019
|
+
const stmt = this.prepare(sql2);
|
|
4020
|
+
const bound = params.map(encodeSqliteParam);
|
|
4021
|
+
yield* stmt.iterate(...bound);
|
|
4022
|
+
}
|
|
4023
|
+
close() {
|
|
4024
|
+
this.statements.clear();
|
|
4025
|
+
this.db.close();
|
|
4026
|
+
}
|
|
4027
|
+
};
|
|
2050
4028
|
function returnsRows(sql2) {
|
|
2051
|
-
return /^\s*(select|pragma)/i.test(sql2) || /\breturning\b/i.test(sql2);
|
|
4029
|
+
return /^\s*(select|with|values|table|pragma|explain)\b/i.test(sql2) || /\breturning\b/i.test(sql2);
|
|
2052
4030
|
}
|
|
2053
4031
|
function splitJoinRow(node, sources, raw) {
|
|
2054
4032
|
const leftAliases = new Set(
|
|
@@ -2072,6 +4050,10 @@ function coerceOne(builder, raw) {
|
|
|
2072
4050
|
const node = builder.node;
|
|
2073
4051
|
if (node.kind === "join_select") {
|
|
2074
4052
|
const b2 = builder;
|
|
4053
|
+
if (node.pick) {
|
|
4054
|
+
const model = b2.sources[node.pick];
|
|
4055
|
+
if (model) return coerceRow(model, raw);
|
|
4056
|
+
}
|
|
2075
4057
|
return splitJoinRow(b2.node, b2.sources, raw);
|
|
2076
4058
|
}
|
|
2077
4059
|
const b = builder;
|
|
@@ -2109,13 +4091,30 @@ var QueryExecutionError = class extends Error {
|
|
|
2109
4091
|
sql;
|
|
2110
4092
|
params;
|
|
2111
4093
|
};
|
|
2112
|
-
function emitLog(
|
|
4094
|
+
function emitLog(hooks, sql2, params) {
|
|
4095
|
+
const logger = hooks?.onQuery;
|
|
2113
4096
|
if (!logger) return;
|
|
2114
4097
|
try {
|
|
2115
4098
|
logger({ sql: sql2, params });
|
|
2116
4099
|
} catch {
|
|
2117
4100
|
}
|
|
2118
4101
|
}
|
|
4102
|
+
function now() {
|
|
4103
|
+
return performance.now();
|
|
4104
|
+
}
|
|
4105
|
+
function emitQueryEnd(hooks, event) {
|
|
4106
|
+
const logger = hooks?.onQueryEnd;
|
|
4107
|
+
if (!logger) return;
|
|
4108
|
+
const threshold = hooks?.slowQueryMs;
|
|
4109
|
+
if (threshold !== void 0 && event.durationMs < threshold) return;
|
|
4110
|
+
try {
|
|
4111
|
+
logger(event);
|
|
4112
|
+
} catch {
|
|
4113
|
+
}
|
|
4114
|
+
}
|
|
4115
|
+
function resultRowCount(result) {
|
|
4116
|
+
return result.rows.length > 0 ? result.rows.length : result.changes;
|
|
4117
|
+
}
|
|
2119
4118
|
function firstScalar(row) {
|
|
2120
4119
|
if (!row) return null;
|
|
2121
4120
|
const keys = Object.keys(row);
|
|
@@ -2203,21 +4202,45 @@ function assertRawParams(params) {
|
|
|
2203
4202
|
);
|
|
2204
4203
|
}
|
|
2205
4204
|
}
|
|
4205
|
+
function assertNoNestedOptions(options) {
|
|
4206
|
+
if (options?.isolation || options?.readOnly) {
|
|
4207
|
+
throw new Error(
|
|
4208
|
+
"Transaction characteristics can only be set on the outermost transaction() \u2014 a nested block joins the one already open."
|
|
4209
|
+
);
|
|
4210
|
+
}
|
|
4211
|
+
}
|
|
2206
4212
|
var SyncSession = class {
|
|
2207
|
-
constructor(driver, dialect,
|
|
4213
|
+
constructor(driver, dialect, hooks) {
|
|
2208
4214
|
this.driver = driver;
|
|
2209
4215
|
this.dialect = dialect;
|
|
2210
|
-
this.
|
|
4216
|
+
this.hooks = hooks;
|
|
2211
4217
|
}
|
|
2212
4218
|
driver;
|
|
2213
4219
|
dialect;
|
|
2214
|
-
|
|
2215
|
-
/**
|
|
4220
|
+
hooks;
|
|
4221
|
+
/** Open `transaction()` blocks on this session; only the outermost commits. */
|
|
4222
|
+
depth = 0;
|
|
4223
|
+
/** Log, run, time, and error-wrap one raw statement. */
|
|
2216
4224
|
exec(sql2, params) {
|
|
2217
|
-
emitLog(this.
|
|
4225
|
+
emitLog(this.hooks, sql2, params);
|
|
4226
|
+
const startedAt = now();
|
|
2218
4227
|
try {
|
|
2219
|
-
|
|
4228
|
+
const result = this.driver.execute(sql2, params);
|
|
4229
|
+
emitQueryEnd(this.hooks, {
|
|
4230
|
+
sql: sql2,
|
|
4231
|
+
params,
|
|
4232
|
+
durationMs: now() - startedAt,
|
|
4233
|
+
rowCount: resultRowCount(result)
|
|
4234
|
+
});
|
|
4235
|
+
return result;
|
|
2220
4236
|
} catch (error) {
|
|
4237
|
+
emitQueryEnd(this.hooks, {
|
|
4238
|
+
sql: sql2,
|
|
4239
|
+
params,
|
|
4240
|
+
durationMs: now() - startedAt,
|
|
4241
|
+
rowCount: 0,
|
|
4242
|
+
error
|
|
4243
|
+
});
|
|
2221
4244
|
throw new QueryExecutionError(error, sql2, params);
|
|
2222
4245
|
}
|
|
2223
4246
|
}
|
|
@@ -2268,8 +4291,44 @@ var SyncSession = class {
|
|
|
2268
4291
|
return new SyncResult(rows, result.changes);
|
|
2269
4292
|
}
|
|
2270
4293
|
/** Run `fn` inside a transaction: commit on success, rollback on throw. */
|
|
2271
|
-
|
|
2272
|
-
|
|
4294
|
+
/**
|
|
4295
|
+
* How many `transaction()` blocks are open on this session.
|
|
4296
|
+
*
|
|
4297
|
+
* The counter is what makes a service that orchestrates two repositories work:
|
|
4298
|
+
* both hold the same session, so an inner block **joins** the outer one instead
|
|
4299
|
+
* of emitting a second `BEGIN`, and only the outermost exit commits.
|
|
4300
|
+
*/
|
|
4301
|
+
get transactionDepth() {
|
|
4302
|
+
return this.depth;
|
|
4303
|
+
}
|
|
4304
|
+
/** Whether a `transaction()` block is currently open on this session. */
|
|
4305
|
+
get inTransaction() {
|
|
4306
|
+
return this.depth > 0;
|
|
4307
|
+
}
|
|
4308
|
+
/**
|
|
4309
|
+
* Run `fn` inside a transaction, committing on a clean exit and rolling back on
|
|
4310
|
+
* a throw.
|
|
4311
|
+
*
|
|
4312
|
+
* **Re-entrant:** a nested call joins the block already open on this session —
|
|
4313
|
+
* one `BEGIN`, one `COMMIT`, and an inner failure rolls the whole thing back.
|
|
4314
|
+
* To recover from an inner failure without discarding the outer work, use
|
|
4315
|
+
* {@link beginNested}, which is a real savepoint.
|
|
4316
|
+
*
|
|
4317
|
+
* @param fn The body; receives the session to work through.
|
|
4318
|
+
* @returns Whatever `fn` returned.
|
|
4319
|
+
*/
|
|
4320
|
+
transaction(fn2, options) {
|
|
4321
|
+
if (this.depth > 0) {
|
|
4322
|
+
assertNoNestedOptions(options);
|
|
4323
|
+
this.depth += 1;
|
|
4324
|
+
try {
|
|
4325
|
+
return fn2(this);
|
|
4326
|
+
} finally {
|
|
4327
|
+
this.depth -= 1;
|
|
4328
|
+
}
|
|
4329
|
+
}
|
|
4330
|
+
for (const stmt of this.dialect.beginStatements(options)) this.exec(stmt, []);
|
|
4331
|
+
this.depth = 1;
|
|
2273
4332
|
try {
|
|
2274
4333
|
const out = fn2(this);
|
|
2275
4334
|
this.exec("COMMIT", []);
|
|
@@ -2277,6 +4336,8 @@ var SyncSession = class {
|
|
|
2277
4336
|
} catch (error) {
|
|
2278
4337
|
this.exec("ROLLBACK", []);
|
|
2279
4338
|
throw error;
|
|
4339
|
+
} finally {
|
|
4340
|
+
this.depth = 0;
|
|
2280
4341
|
}
|
|
2281
4342
|
}
|
|
2282
4343
|
/** Run `fn` inside a SAVEPOINT (nested transaction). */
|
|
@@ -2300,15 +4361,31 @@ var SyncSession = class {
|
|
|
2300
4361
|
*stream(builder) {
|
|
2301
4362
|
const node = builder.node;
|
|
2302
4363
|
const { sql: sql2, params } = this.dialect.compile(node);
|
|
2303
|
-
emitLog(this.
|
|
4364
|
+
emitLog(this.hooks, sql2, params);
|
|
2304
4365
|
if (this.driver.iterate) {
|
|
4366
|
+
const startedAt = now();
|
|
4367
|
+
let rowCount = 0;
|
|
2305
4368
|
try {
|
|
2306
4369
|
for (const raw of this.driver.iterate(sql2, params)) {
|
|
4370
|
+
rowCount++;
|
|
2307
4371
|
yield coerceOne(builder, raw);
|
|
2308
4372
|
}
|
|
2309
4373
|
} catch (error) {
|
|
4374
|
+
emitQueryEnd(this.hooks, {
|
|
4375
|
+
sql: sql2,
|
|
4376
|
+
params,
|
|
4377
|
+
durationMs: now() - startedAt,
|
|
4378
|
+
rowCount,
|
|
4379
|
+
error
|
|
4380
|
+
});
|
|
2310
4381
|
throw new QueryExecutionError(error, sql2, params);
|
|
2311
4382
|
}
|
|
4383
|
+
emitQueryEnd(this.hooks, {
|
|
4384
|
+
sql: sql2,
|
|
4385
|
+
params,
|
|
4386
|
+
durationMs: now() - startedAt,
|
|
4387
|
+
rowCount
|
|
4388
|
+
});
|
|
2312
4389
|
return;
|
|
2313
4390
|
}
|
|
2314
4391
|
for (const raw of this.exec(sql2, params).rows) {
|
|
@@ -2324,20 +4401,37 @@ var SyncSession = class {
|
|
|
2324
4401
|
}
|
|
2325
4402
|
};
|
|
2326
4403
|
var AsyncSession = class _AsyncSession {
|
|
2327
|
-
constructor(driver, dialect,
|
|
4404
|
+
constructor(driver, dialect, hooks) {
|
|
2328
4405
|
this.driver = driver;
|
|
2329
4406
|
this.dialect = dialect;
|
|
2330
|
-
this.
|
|
4407
|
+
this.hooks = hooks;
|
|
2331
4408
|
}
|
|
2332
4409
|
driver;
|
|
2333
4410
|
dialect;
|
|
2334
|
-
|
|
2335
|
-
/**
|
|
4411
|
+
hooks;
|
|
4412
|
+
/** Open `transaction()` blocks on this session; only the outermost commits. */
|
|
4413
|
+
depth = 0;
|
|
4414
|
+
/** Log, run, time, and error-wrap one raw statement. */
|
|
2336
4415
|
async exec(sql2, params) {
|
|
2337
|
-
emitLog(this.
|
|
4416
|
+
emitLog(this.hooks, sql2, params);
|
|
4417
|
+
const startedAt = now();
|
|
2338
4418
|
try {
|
|
2339
|
-
|
|
4419
|
+
const result = await this.driver.execute(sql2, params);
|
|
4420
|
+
emitQueryEnd(this.hooks, {
|
|
4421
|
+
sql: sql2,
|
|
4422
|
+
params,
|
|
4423
|
+
durationMs: now() - startedAt,
|
|
4424
|
+
rowCount: resultRowCount(result)
|
|
4425
|
+
});
|
|
4426
|
+
return result;
|
|
2340
4427
|
} catch (error) {
|
|
4428
|
+
emitQueryEnd(this.hooks, {
|
|
4429
|
+
sql: sql2,
|
|
4430
|
+
params,
|
|
4431
|
+
durationMs: now() - startedAt,
|
|
4432
|
+
rowCount: 0,
|
|
4433
|
+
error
|
|
4434
|
+
});
|
|
2341
4435
|
throw new QueryExecutionError(error, sql2, params);
|
|
2342
4436
|
}
|
|
2343
4437
|
}
|
|
@@ -2427,17 +4521,17 @@ var AsyncSession = class _AsyncSession {
|
|
|
2427
4521
|
const selectSql = this.dialect.compile(
|
|
2428
4522
|
node.returning === "*" || node.returning === null ? readBack.node : { ...readBack.node, columns: node.returning }
|
|
2429
4523
|
);
|
|
2430
|
-
const
|
|
2431
|
-
const scoped = new _AsyncSession(driver, this.dialect, this.
|
|
4524
|
+
const run2 = async (driver) => {
|
|
4525
|
+
const scoped = new _AsyncSession(driver, this.dialect, this.hooks);
|
|
2432
4526
|
const written = await scoped.exec(insertSql.sql, insertSql.params);
|
|
2433
4527
|
const read = await scoped.exec(selectSql.sql, selectSql.params);
|
|
2434
4528
|
const rows = read.rows.map((row) => coerceRow(model, row));
|
|
2435
4529
|
return new SyncResult(rows, written.changes);
|
|
2436
4530
|
};
|
|
2437
|
-
if (!this.driver.reserve) return
|
|
4531
|
+
if (!this.driver.reserve) return run2(this.driver);
|
|
2438
4532
|
const reserved = await this.driver.reserve();
|
|
2439
4533
|
try {
|
|
2440
|
-
return await
|
|
4534
|
+
return await run2(reserved);
|
|
2441
4535
|
} finally {
|
|
2442
4536
|
await reserved.release();
|
|
2443
4537
|
}
|
|
@@ -2446,15 +4540,31 @@ var AsyncSession = class _AsyncSession {
|
|
|
2446
4540
|
async *stream(builder) {
|
|
2447
4541
|
const node = builder.node;
|
|
2448
4542
|
const { sql: sql2, params } = this.dialect.compile(node);
|
|
2449
|
-
emitLog(this.
|
|
4543
|
+
emitLog(this.hooks, sql2, params);
|
|
2450
4544
|
if (this.driver.iterate) {
|
|
4545
|
+
const startedAt = now();
|
|
4546
|
+
let rowCount = 0;
|
|
2451
4547
|
try {
|
|
2452
4548
|
for await (const raw of this.driver.iterate(sql2, params)) {
|
|
4549
|
+
rowCount++;
|
|
2453
4550
|
yield coerceOne(builder, raw);
|
|
2454
4551
|
}
|
|
2455
4552
|
} catch (error) {
|
|
4553
|
+
emitQueryEnd(this.hooks, {
|
|
4554
|
+
sql: sql2,
|
|
4555
|
+
params,
|
|
4556
|
+
durationMs: now() - startedAt,
|
|
4557
|
+
rowCount,
|
|
4558
|
+
error
|
|
4559
|
+
});
|
|
2456
4560
|
throw new QueryExecutionError(error, sql2, params);
|
|
2457
4561
|
}
|
|
4562
|
+
emitQueryEnd(this.hooks, {
|
|
4563
|
+
sql: sql2,
|
|
4564
|
+
params,
|
|
4565
|
+
durationMs: now() - startedAt,
|
|
4566
|
+
rowCount
|
|
4567
|
+
});
|
|
2458
4568
|
return;
|
|
2459
4569
|
}
|
|
2460
4570
|
const result = await this.exec(sql2, params);
|
|
@@ -2462,12 +4572,56 @@ var AsyncSession = class _AsyncSession {
|
|
|
2462
4572
|
yield coerceOne(builder, raw);
|
|
2463
4573
|
}
|
|
2464
4574
|
}
|
|
2465
|
-
|
|
4575
|
+
/**
|
|
4576
|
+
* How many `transaction()` blocks are open on this session.
|
|
4577
|
+
*
|
|
4578
|
+
* The counter is what makes a service that orchestrates two repositories work:
|
|
4579
|
+
* both hold the same session, so an inner block **joins** the outer one instead
|
|
4580
|
+
* of emitting a second `BEGIN`, and only the outermost exit commits.
|
|
4581
|
+
*/
|
|
4582
|
+
get transactionDepth() {
|
|
4583
|
+
return this.depth;
|
|
4584
|
+
}
|
|
4585
|
+
/** Whether a `transaction()` block is currently open on this session. */
|
|
4586
|
+
get inTransaction() {
|
|
4587
|
+
return this.depth > 0;
|
|
4588
|
+
}
|
|
4589
|
+
/**
|
|
4590
|
+
* Run `fn` inside a transaction, committing on a clean exit and rolling back on
|
|
4591
|
+
* a throw.
|
|
4592
|
+
*
|
|
4593
|
+
* **Re-entrant:** a nested call joins the block already open on this session,
|
|
4594
|
+
* so a service orchestrating several repositories bound to the same session
|
|
4595
|
+
* gets one `BEGIN` and one `COMMIT`, not two of each. An inner failure rolls the
|
|
4596
|
+
* whole block back; use {@link beginNested} for a savepoint that can be
|
|
4597
|
+
* recovered from.
|
|
4598
|
+
*
|
|
4599
|
+
* Pooled drivers (PostgreSQL) pin one connection for the block: `BEGIN`/`COMMIT`
|
|
4600
|
+
* and every statement between them have to run on the same connection, or
|
|
4601
|
+
* postgres.js rejects the raw transaction. Single-connection drivers (SQLite)
|
|
4602
|
+
* skip the reservation.
|
|
4603
|
+
*
|
|
4604
|
+
* @param fn The body; receives the session to work through (the pinned one, on a
|
|
4605
|
+
* pooled driver).
|
|
4606
|
+
* @returns Whatever `fn` returned.
|
|
4607
|
+
*/
|
|
4608
|
+
async transaction(fn2, options) {
|
|
4609
|
+
if (this.depth > 0) {
|
|
4610
|
+
assertNoNestedOptions(options);
|
|
4611
|
+
this.depth += 1;
|
|
4612
|
+
try {
|
|
4613
|
+
return await fn2(this);
|
|
4614
|
+
} finally {
|
|
4615
|
+
this.depth -= 1;
|
|
4616
|
+
}
|
|
4617
|
+
}
|
|
4618
|
+
const begin = this.dialect.beginStatements(options);
|
|
2466
4619
|
if (this.driver.reserve) {
|
|
2467
4620
|
const reserved = await this.driver.reserve();
|
|
2468
|
-
const scoped = new _AsyncSession(reserved, this.dialect, this.
|
|
4621
|
+
const scoped = new _AsyncSession(reserved, this.dialect, this.hooks);
|
|
2469
4622
|
try {
|
|
2470
|
-
await scoped.exec(
|
|
4623
|
+
for (const stmt of begin) await scoped.exec(stmt, []);
|
|
4624
|
+
scoped.depth = 1;
|
|
2471
4625
|
const out = await fn2(scoped);
|
|
2472
4626
|
await scoped.exec("COMMIT", []);
|
|
2473
4627
|
return out;
|
|
@@ -2475,10 +4629,12 @@ var AsyncSession = class _AsyncSession {
|
|
|
2475
4629
|
await scoped.exec("ROLLBACK", []);
|
|
2476
4630
|
throw error;
|
|
2477
4631
|
} finally {
|
|
4632
|
+
scoped.depth = 0;
|
|
2478
4633
|
await reserved.release();
|
|
2479
4634
|
}
|
|
2480
4635
|
}
|
|
2481
|
-
await this.exec(
|
|
4636
|
+
for (const stmt of begin) await this.exec(stmt, []);
|
|
4637
|
+
this.depth = 1;
|
|
2482
4638
|
try {
|
|
2483
4639
|
const out = await fn2(this);
|
|
2484
4640
|
await this.exec("COMMIT", []);
|
|
@@ -2486,8 +4642,49 @@ var AsyncSession = class _AsyncSession {
|
|
|
2486
4642
|
} catch (error) {
|
|
2487
4643
|
await this.exec("ROLLBACK", []);
|
|
2488
4644
|
throw error;
|
|
4645
|
+
} finally {
|
|
4646
|
+
this.depth = 0;
|
|
4647
|
+
}
|
|
4648
|
+
}
|
|
4649
|
+
/**
|
|
4650
|
+
* Run `fn` inside a `SAVEPOINT` (a nested transaction that can be rolled back on
|
|
4651
|
+
* its own).
|
|
4652
|
+
*
|
|
4653
|
+
* This is the difference from a nested {@link transaction}: a savepoint that
|
|
4654
|
+
* fails discards **only** its own work, so the enclosing block can catch the
|
|
4655
|
+
* error and carry on. A nested `transaction()` joins the outer block, and its
|
|
4656
|
+
* failure takes the whole block down.
|
|
4657
|
+
*
|
|
4658
|
+
* Must run inside an open transaction — PostgreSQL rejects a savepoint outside a
|
|
4659
|
+
* transaction block.
|
|
4660
|
+
*
|
|
4661
|
+
* @param fn The body; receives the same session.
|
|
4662
|
+
* @returns Whatever `fn` returned.
|
|
4663
|
+
*/
|
|
4664
|
+
async beginNested(fn2) {
|
|
4665
|
+
savepointCounter += 1;
|
|
4666
|
+
const name = `qsp_${savepointCounter}`;
|
|
4667
|
+
await this.exec(`SAVEPOINT ${name}`, []);
|
|
4668
|
+
try {
|
|
4669
|
+
const out = await fn2(this);
|
|
4670
|
+
await this.exec(`RELEASE ${name}`, []);
|
|
4671
|
+
return out;
|
|
4672
|
+
} catch (error) {
|
|
4673
|
+
await this.exec(`ROLLBACK TO ${name}`, []);
|
|
4674
|
+
throw error;
|
|
2489
4675
|
}
|
|
2490
4676
|
}
|
|
4677
|
+
/**
|
|
4678
|
+
* Open an opt-in {@link UnitOfWork} over this session.
|
|
4679
|
+
*
|
|
4680
|
+
* The default stays a plain object written when you ask; this is the other
|
|
4681
|
+
* model, for code that prefers to mutate and flush once.
|
|
4682
|
+
*
|
|
4683
|
+
* @returns A new, empty unit of work.
|
|
4684
|
+
*/
|
|
4685
|
+
unitOfWork() {
|
|
4686
|
+
return new UnitOfWork(this);
|
|
4687
|
+
}
|
|
2491
4688
|
async close() {
|
|
2492
4689
|
await this.driver.close();
|
|
2493
4690
|
}
|
|
@@ -2496,6 +4693,14 @@ var AsyncSession = class _AsyncSession {
|
|
|
2496
4693
|
await this.close();
|
|
2497
4694
|
}
|
|
2498
4695
|
};
|
|
4696
|
+
function queryHooks(options) {
|
|
4697
|
+
if (!options?.onQuery && !options?.onQueryEnd) return void 0;
|
|
4698
|
+
return {
|
|
4699
|
+
onQuery: options.onQuery,
|
|
4700
|
+
onQueryEnd: options.onQueryEnd,
|
|
4701
|
+
slowQueryMs: options.slowQueryMs
|
|
4702
|
+
};
|
|
4703
|
+
}
|
|
2499
4704
|
function emitNotice(logger, notice) {
|
|
2500
4705
|
if (!logger) return;
|
|
2501
4706
|
try {
|
|
@@ -2504,18 +4709,18 @@ function emitNotice(logger, notice) {
|
|
|
2504
4709
|
}
|
|
2505
4710
|
}
|
|
2506
4711
|
var SyncEngine = class {
|
|
2507
|
-
constructor(driver,
|
|
4712
|
+
constructor(driver, hooks) {
|
|
2508
4713
|
this.driver = driver;
|
|
2509
|
-
this.
|
|
4714
|
+
this.hooks = hooks;
|
|
2510
4715
|
}
|
|
2511
4716
|
driver;
|
|
2512
|
-
|
|
4717
|
+
hooks;
|
|
2513
4718
|
dialect = "sqlite";
|
|
2514
4719
|
session() {
|
|
2515
|
-
return new SyncSession(this.driver, getDialect("sqlite"), this.
|
|
4720
|
+
return new SyncSession(this.driver, getDialect("sqlite"), this.hooks);
|
|
2516
4721
|
}
|
|
2517
|
-
transaction(fn2) {
|
|
2518
|
-
return this.session().transaction(fn2);
|
|
4722
|
+
transaction(fn2, options) {
|
|
4723
|
+
return this.session().transaction(fn2, options);
|
|
2519
4724
|
}
|
|
2520
4725
|
close() {
|
|
2521
4726
|
this.driver.close();
|
|
@@ -2526,19 +4731,68 @@ var SyncEngine = class {
|
|
|
2526
4731
|
}
|
|
2527
4732
|
};
|
|
2528
4733
|
var AsyncEngine = class {
|
|
2529
|
-
constructor(driver, dialect,
|
|
4734
|
+
constructor(driver, dialect, hooks) {
|
|
2530
4735
|
this.driver = driver;
|
|
2531
4736
|
this.dialect = dialect;
|
|
2532
|
-
this.
|
|
4737
|
+
this.hooks = hooks;
|
|
2533
4738
|
}
|
|
2534
4739
|
driver;
|
|
2535
4740
|
dialect;
|
|
2536
|
-
|
|
4741
|
+
hooks;
|
|
2537
4742
|
session() {
|
|
2538
|
-
return new AsyncSession(this.driver, getDialect(this.dialect), this.
|
|
4743
|
+
return new AsyncSession(this.driver, getDialect(this.dialect), this.hooks);
|
|
4744
|
+
}
|
|
4745
|
+
transaction(fn2, options) {
|
|
4746
|
+
return this.session().transaction(fn2, options);
|
|
2539
4747
|
}
|
|
2540
|
-
|
|
2541
|
-
|
|
4748
|
+
/**
|
|
4749
|
+
* Run `fn` and return the query plan of every statement it ran.
|
|
4750
|
+
*
|
|
4751
|
+
* The block gets its own session over a **recording** driver, so the plans are
|
|
4752
|
+
* built from the statements and the parameters the code really used — not from
|
|
4753
|
+
* SQL copied out of a log by hand. A development tool: it runs the block once
|
|
4754
|
+
* and then one `EXPLAIN` per statement, so keep it out of the hot path.
|
|
4755
|
+
*
|
|
4756
|
+
* @param fn The code to observe; receives the recording session.
|
|
4757
|
+
* @param options `analyze: true` to measure (refused for writes), `filter` to
|
|
4758
|
+
* explain only some statements.
|
|
4759
|
+
* @returns The report, one plan per statement in execution order.
|
|
4760
|
+
* @throws Error When `analyze` is requested for a statement that writes, or on
|
|
4761
|
+
* a dialect that has no `EXPLAIN ANALYZE`.
|
|
4762
|
+
*
|
|
4763
|
+
* @example
|
|
4764
|
+
* ```ts
|
|
4765
|
+
* const report = await engine.explain(async (session) => {
|
|
4766
|
+
* await new BaseRepository(Order, session).paginate({ page: 3 });
|
|
4767
|
+
* });
|
|
4768
|
+
* console.log(report.summary());
|
|
4769
|
+
* ```
|
|
4770
|
+
*/
|
|
4771
|
+
async explain(fn2, options) {
|
|
4772
|
+
const statements = [];
|
|
4773
|
+
const recorder = {
|
|
4774
|
+
execute: async (sql2, params) => {
|
|
4775
|
+
statements.push({ sql: sql2, params });
|
|
4776
|
+
return this.driver.execute(sql2, params);
|
|
4777
|
+
},
|
|
4778
|
+
close: () => Promise.resolve(),
|
|
4779
|
+
...this.driver.iterate ? {
|
|
4780
|
+
iterate: (sql2, params) => {
|
|
4781
|
+
statements.push({ sql: sql2, params });
|
|
4782
|
+
return this.driver.iterate(
|
|
4783
|
+
sql2,
|
|
4784
|
+
params
|
|
4785
|
+
);
|
|
4786
|
+
}
|
|
4787
|
+
} : {}
|
|
4788
|
+
};
|
|
4789
|
+
await fn2(new AsyncSession(recorder, getDialect(this.dialect), this.hooks));
|
|
4790
|
+
return buildReport(
|
|
4791
|
+
this.session(),
|
|
4792
|
+
statements,
|
|
4793
|
+
(analyze) => getDialect(this.dialect).explainPrefix(analyze),
|
|
4794
|
+
options
|
|
4795
|
+
);
|
|
2542
4796
|
}
|
|
2543
4797
|
async close() {
|
|
2544
4798
|
await this.driver.close();
|
|
@@ -2547,31 +4801,133 @@ var AsyncEngine = class {
|
|
|
2547
4801
|
async [Symbol.asyncDispose]() {
|
|
2548
4802
|
await this.close();
|
|
2549
4803
|
}
|
|
2550
|
-
};
|
|
2551
|
-
function toAsyncDriver(driver) {
|
|
2552
|
-
return {
|
|
2553
|
-
async execute(sql2, params) {
|
|
2554
|
-
return await driver.execute(sql2, params);
|
|
2555
|
-
},
|
|
2556
|
-
async close() {
|
|
2557
|
-
await driver.close();
|
|
4804
|
+
};
|
|
4805
|
+
function toAsyncDriver(driver) {
|
|
4806
|
+
return {
|
|
4807
|
+
async execute(sql2, params) {
|
|
4808
|
+
return await driver.execute(sql2, params);
|
|
4809
|
+
},
|
|
4810
|
+
async close() {
|
|
4811
|
+
await driver.close();
|
|
4812
|
+
}
|
|
4813
|
+
};
|
|
4814
|
+
}
|
|
4815
|
+
function asAsync(driver) {
|
|
4816
|
+
const syncIterate = driver.iterate?.bind(driver);
|
|
4817
|
+
return {
|
|
4818
|
+
execute: (sql2, params) => Promise.resolve(driver.execute(sql2, params)),
|
|
4819
|
+
close: () => Promise.resolve(driver.close()),
|
|
4820
|
+
...syncIterate ? {
|
|
4821
|
+
iterate: async function* (sql2, params) {
|
|
4822
|
+
yield* syncIterate(sql2, params);
|
|
4823
|
+
}
|
|
4824
|
+
} : {}
|
|
4825
|
+
};
|
|
4826
|
+
}
|
|
4827
|
+
var SQLITE_DRIVER_ALIASES = {
|
|
4828
|
+
"node:sqlite": "node:sqlite",
|
|
4829
|
+
"node-sqlite": "node:sqlite",
|
|
4830
|
+
node: "node:sqlite",
|
|
4831
|
+
"better-sqlite3": "better-sqlite3",
|
|
4832
|
+
better_sqlite3: "better-sqlite3",
|
|
4833
|
+
bettersqlite3: "better-sqlite3"
|
|
4834
|
+
};
|
|
4835
|
+
var SERVER_DRIVER_ALIASES = {
|
|
4836
|
+
postgresql: ["postgres", "postgres.js", "postgresjs", "pg"],
|
|
4837
|
+
mysql: ["mysql", "mysql2"]
|
|
4838
|
+
};
|
|
4839
|
+
function checkServerDriver(dialect, driver) {
|
|
4840
|
+
if (!driver) return;
|
|
4841
|
+
const accepted = SERVER_DRIVER_ALIASES[dialect] ?? [];
|
|
4842
|
+
if (accepted.includes(driver.toLowerCase())) return;
|
|
4843
|
+
throw new Error(
|
|
4844
|
+
`Unknown ${dialect} driver ${JSON.stringify(driver)}; tempest-db-js runs ${dialect} on ${JSON.stringify(accepted[0])}.`
|
|
4845
|
+
);
|
|
4846
|
+
}
|
|
4847
|
+
function resolveSqliteDriver(parsed, options) {
|
|
4848
|
+
const explicit = options?.driver;
|
|
4849
|
+
if (explicit) {
|
|
4850
|
+
const resolved = SQLITE_DRIVER_ALIASES[explicit.toLowerCase()];
|
|
4851
|
+
if (!resolved) {
|
|
4852
|
+
throw new Error(
|
|
4853
|
+
`Unknown SQLite driver ${JSON.stringify(explicit)}; supported: "node:sqlite" (built-in, default) and "better-sqlite3".`
|
|
4854
|
+
);
|
|
4855
|
+
}
|
|
4856
|
+
return resolved;
|
|
4857
|
+
}
|
|
4858
|
+
const fromUrl = parsed.driver ? SQLITE_DRIVER_ALIASES[parsed.driver.toLowerCase()] : void 0;
|
|
4859
|
+
return fromUrl ?? "node:sqlite";
|
|
4860
|
+
}
|
|
4861
|
+
var SQLITE_SYNCHRONOUS_LEVELS = [
|
|
4862
|
+
"off",
|
|
4863
|
+
"normal",
|
|
4864
|
+
"full",
|
|
4865
|
+
"extra"
|
|
4866
|
+
];
|
|
4867
|
+
function readPragma(driver, name) {
|
|
4868
|
+
const { rows } = driver.execute(`PRAGMA ${name}`, []);
|
|
4869
|
+
const first = rows[0];
|
|
4870
|
+
if (!first) return null;
|
|
4871
|
+
return Object.values(first)[0] ?? null;
|
|
4872
|
+
}
|
|
4873
|
+
function applySqlitePragmas(driver, options, path) {
|
|
4874
|
+
const foreignKeys = options?.foreignKeys ?? true;
|
|
4875
|
+
driver.execute(`PRAGMA foreign_keys = ${foreignKeys ? "ON" : "OFF"}`, []);
|
|
4876
|
+
if (Number(readPragma(driver, "foreign_keys")) !== (foreignKeys ? 1 : 0)) {
|
|
4877
|
+
throw new Error(
|
|
4878
|
+
`SQLite refused PRAGMA foreign_keys = ${foreignKeys ? "ON" : "OFF"} \u2014 the build may lack foreign-key support.`
|
|
4879
|
+
);
|
|
4880
|
+
}
|
|
4881
|
+
const journalMode = options?.journalMode;
|
|
4882
|
+
if (journalMode) {
|
|
4883
|
+
driver.execute(`PRAGMA journal_mode = ${journalMode}`, []);
|
|
4884
|
+
const actual = String(readPragma(driver, "journal_mode") ?? "").toLowerCase();
|
|
4885
|
+
if (actual !== journalMode) {
|
|
4886
|
+
const hint = journalMode === "wal" && actual === "memory" ? " \u2014 an in-memory database cannot use WAL." : ".";
|
|
4887
|
+
throw new Error(
|
|
4888
|
+
`SQLite refused PRAGMA journal_mode = ${journalMode} for ${JSON.stringify(path)} and stayed on ${JSON.stringify(actual)}${hint}`
|
|
4889
|
+
);
|
|
4890
|
+
}
|
|
4891
|
+
}
|
|
4892
|
+
const busyTimeoutMs = options?.busyTimeoutMs;
|
|
4893
|
+
if (busyTimeoutMs !== void 0) {
|
|
4894
|
+
if (!Number.isInteger(busyTimeoutMs) || busyTimeoutMs < 0) {
|
|
4895
|
+
throw new Error(
|
|
4896
|
+
`busyTimeoutMs must be a non-negative integer, got ${JSON.stringify(busyTimeoutMs)}.`
|
|
4897
|
+
);
|
|
4898
|
+
}
|
|
4899
|
+
driver.execute(`PRAGMA busy_timeout = ${busyTimeoutMs}`, []);
|
|
4900
|
+
if (Number(readPragma(driver, "busy_timeout")) !== busyTimeoutMs) {
|
|
4901
|
+
throw new Error(`SQLite refused PRAGMA busy_timeout = ${busyTimeoutMs}.`);
|
|
4902
|
+
}
|
|
4903
|
+
}
|
|
4904
|
+
const synchronous = options?.synchronous;
|
|
4905
|
+
if (synchronous) {
|
|
4906
|
+
driver.execute(`PRAGMA synchronous = ${synchronous}`, []);
|
|
4907
|
+
const actual = SQLITE_SYNCHRONOUS_LEVELS[Number(readPragma(driver, "synchronous"))];
|
|
4908
|
+
if (actual !== synchronous) {
|
|
4909
|
+
throw new Error(
|
|
4910
|
+
`SQLite refused PRAGMA synchronous = ${synchronous} and stayed on ${JSON.stringify(actual ?? "unknown")}.`
|
|
4911
|
+
);
|
|
2558
4912
|
}
|
|
2559
|
-
}
|
|
4913
|
+
}
|
|
2560
4914
|
}
|
|
2561
|
-
function
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
...syncIterate ? {
|
|
2567
|
-
iterate: async function* (sql2, params) {
|
|
2568
|
-
yield* syncIterate(sql2, params);
|
|
2569
|
-
}
|
|
2570
|
-
} : {}
|
|
2571
|
-
};
|
|
4915
|
+
function checkSqliteOptions(dialect, options) {
|
|
4916
|
+
if (!options?.sqlite) return;
|
|
4917
|
+
throw new Error(
|
|
4918
|
+
`The "sqlite" engine options are SQLite-only; ${dialect} has no per-connection pragmas.`
|
|
4919
|
+
);
|
|
2572
4920
|
}
|
|
2573
|
-
function openSqliteDriver(
|
|
2574
|
-
|
|
4921
|
+
function openSqliteDriver(parsed, options) {
|
|
4922
|
+
const path = parsed.database ?? ":memory:";
|
|
4923
|
+
const driver = resolveSqliteDriver(parsed, options) === "better-sqlite3" ? BetterSqliteDriver.open(path, options?.driverOptions) : NodeSqliteDriver.open(path, options?.driverOptions);
|
|
4924
|
+
try {
|
|
4925
|
+
applySqlitePragmas(driver, options?.sqlite, path);
|
|
4926
|
+
} catch (error) {
|
|
4927
|
+
driver.close();
|
|
4928
|
+
throw error;
|
|
4929
|
+
}
|
|
4930
|
+
return driver;
|
|
2575
4931
|
}
|
|
2576
4932
|
function createSyncEngine(url, options) {
|
|
2577
4933
|
const parsed = parseDatabaseUrl(url);
|
|
@@ -2580,31 +4936,32 @@ function createSyncEngine(url, options) {
|
|
|
2580
4936
|
`createSyncEngine supports only SQLite; ${parsed.dialect} is async-only \u2014 use createEngine.`
|
|
2581
4937
|
);
|
|
2582
4938
|
}
|
|
2583
|
-
return new SyncEngine(
|
|
2584
|
-
openSqliteDriver(parsed.database ?? ":memory:", options),
|
|
2585
|
-
options?.onQuery
|
|
2586
|
-
);
|
|
4939
|
+
return new SyncEngine(openSqliteDriver(parsed, options), queryHooks(options));
|
|
2587
4940
|
}
|
|
2588
4941
|
function createEngine(url, options) {
|
|
2589
4942
|
const parsed = parseDatabaseUrl(url);
|
|
2590
4943
|
if (parsed.dialect === "sqlite") {
|
|
2591
4944
|
return new AsyncEngine(
|
|
2592
|
-
asAsync(openSqliteDriver(parsed
|
|
4945
|
+
asAsync(openSqliteDriver(parsed, options)),
|
|
2593
4946
|
"sqlite",
|
|
2594
|
-
options
|
|
4947
|
+
queryHooks(options)
|
|
2595
4948
|
);
|
|
2596
4949
|
}
|
|
2597
4950
|
if (parsed.dialect === "mysql") {
|
|
4951
|
+
checkServerDriver("mysql", options?.driver);
|
|
4952
|
+
checkSqliteOptions("mysql", options);
|
|
2598
4953
|
return new AsyncEngine(
|
|
2599
4954
|
createMysqlDriver(parsed.raw, options),
|
|
2600
4955
|
"mysql",
|
|
2601
|
-
options
|
|
4956
|
+
queryHooks(options)
|
|
2602
4957
|
);
|
|
2603
4958
|
}
|
|
4959
|
+
checkServerDriver("postgresql", options?.driver);
|
|
4960
|
+
checkSqliteOptions("postgresql", options);
|
|
2604
4961
|
return new AsyncEngine(
|
|
2605
4962
|
createPostgresDriver(parsed.raw, options),
|
|
2606
4963
|
"postgresql",
|
|
2607
|
-
options
|
|
4964
|
+
queryHooks(options)
|
|
2608
4965
|
);
|
|
2609
4966
|
}
|
|
2610
4967
|
function encodeMysqlParam(value) {
|
|
@@ -2633,6 +4990,11 @@ function createMysqlDriver(url, options) {
|
|
|
2633
4990
|
moduleName
|
|
2634
4991
|
);
|
|
2635
4992
|
const opts = { uri: url };
|
|
4993
|
+
if (pool?.prePing || pool?.recycleMs !== void 0) {
|
|
4994
|
+
throw new Error(
|
|
4995
|
+
"pool.prePing and pool.recycleMs are PostgreSQL-only; mysql2 has no equivalent knob."
|
|
4996
|
+
);
|
|
4997
|
+
}
|
|
2636
4998
|
if (pool?.size !== void 0) opts.connectionLimit = pool.size;
|
|
2637
4999
|
if (pool?.idleTimeoutMs !== void 0) opts.idleTimeout = pool.idleTimeoutMs;
|
|
2638
5000
|
if (pool?.connectTimeoutMs !== void 0) opts.connectTimeout = pool.connectTimeoutMs;
|
|
@@ -2687,6 +5049,9 @@ function createPostgresDriver(url, options) {
|
|
|
2687
5049
|
if (pool?.connectTimeoutMs !== void 0) {
|
|
2688
5050
|
opts.connect_timeout = Math.ceil(pool.connectTimeoutMs / 1e3);
|
|
2689
5051
|
}
|
|
5052
|
+
if (pool?.recycleMs !== void 0) {
|
|
5053
|
+
opts.max_lifetime = Math.ceil(pool.recycleMs / 1e3);
|
|
5054
|
+
}
|
|
2690
5055
|
opts.onnotice = (notice) => emitNotice(options?.onNotice, notice);
|
|
2691
5056
|
Object.assign(opts, options?.driverOptions ?? {});
|
|
2692
5057
|
client = (mod.default ?? mod)(url, opts);
|
|
@@ -2698,7 +5063,16 @@ function createPostgresDriver(url, options) {
|
|
|
2698
5063
|
},
|
|
2699
5064
|
async reserve() {
|
|
2700
5065
|
await ensure();
|
|
2701
|
-
|
|
5066
|
+
let conn = await client.reserve();
|
|
5067
|
+
if (pool?.prePing) {
|
|
5068
|
+
try {
|
|
5069
|
+
await conn.unsafe("SELECT 1", []);
|
|
5070
|
+
} catch {
|
|
5071
|
+
conn.release();
|
|
5072
|
+
conn = await client.reserve();
|
|
5073
|
+
await conn.unsafe("SELECT 1", []);
|
|
5074
|
+
}
|
|
5075
|
+
}
|
|
2702
5076
|
return {
|
|
2703
5077
|
async execute(sql2, params) {
|
|
2704
5078
|
return toPostgresResult(await conn.unsafe(sql2, params));
|
|
@@ -2717,6 +5091,302 @@ function createPostgresDriver(url, options) {
|
|
|
2717
5091
|
};
|
|
2718
5092
|
}
|
|
2719
5093
|
|
|
5094
|
+
// src/backup.ts
|
|
5095
|
+
var run = util.promisify(child_process.execFile);
|
|
5096
|
+
var BackupToolMissing = class extends Error {
|
|
5097
|
+
constructor(tool) {
|
|
5098
|
+
super(`${tool} is not on the PATH; install the PostgreSQL client tools to back up.`);
|
|
5099
|
+
this.name = "BackupToolMissing";
|
|
5100
|
+
}
|
|
5101
|
+
};
|
|
5102
|
+
var UnsupportedBackupBackend = class extends Error {
|
|
5103
|
+
constructor(dialect) {
|
|
5104
|
+
super(
|
|
5105
|
+
`Backups are implemented for PostgreSQL and SQLite; ${dialect} is not covered.`
|
|
5106
|
+
);
|
|
5107
|
+
this.name = "UnsupportedBackupBackend";
|
|
5108
|
+
}
|
|
5109
|
+
};
|
|
5110
|
+
function backupFormat(file) {
|
|
5111
|
+
return file.endsWith(".sql") ? "plain" : "custom";
|
|
5112
|
+
}
|
|
5113
|
+
function postgresEnv(password) {
|
|
5114
|
+
return password ? { ...process.env, PGPASSWORD: password } : { ...process.env };
|
|
5115
|
+
}
|
|
5116
|
+
function toolUrl(url) {
|
|
5117
|
+
return url.replace(/^([a-z0-9]+)\+[a-z0-9_-]+:/i, "$1:");
|
|
5118
|
+
}
|
|
5119
|
+
async function spawn(tool, args, env) {
|
|
5120
|
+
try {
|
|
5121
|
+
await run(tool, [...args], { env, maxBuffer: 1024 * 1024 * 64 });
|
|
5122
|
+
} catch (error) {
|
|
5123
|
+
if (error.code === "ENOENT") {
|
|
5124
|
+
throw new BackupToolMissing(tool);
|
|
5125
|
+
}
|
|
5126
|
+
throw error;
|
|
5127
|
+
}
|
|
5128
|
+
}
|
|
5129
|
+
async function backupDatabase(url, file, options) {
|
|
5130
|
+
const parsed = parseDatabaseUrl(url);
|
|
5131
|
+
if (parsed.dialect === "sqlite") {
|
|
5132
|
+
const source = parsed.database ?? ":memory:";
|
|
5133
|
+
const driver = NodeSqliteDriver.open(source);
|
|
5134
|
+
try {
|
|
5135
|
+
driver.execute("VACUUM INTO ?", [file]);
|
|
5136
|
+
} finally {
|
|
5137
|
+
driver.close();
|
|
5138
|
+
}
|
|
5139
|
+
return { file, dialect: "sqlite", via: "VACUUM INTO" };
|
|
5140
|
+
}
|
|
5141
|
+
if (parsed.dialect !== "postgresql") {
|
|
5142
|
+
throw new UnsupportedBackupBackend(parsed.dialect);
|
|
5143
|
+
}
|
|
5144
|
+
const format = backupFormat(file);
|
|
5145
|
+
await spawn(
|
|
5146
|
+
"pg_dump",
|
|
5147
|
+
[
|
|
5148
|
+
"--dbname",
|
|
5149
|
+
toolUrl(url),
|
|
5150
|
+
...format === "custom" ? ["--format=custom"] : [],
|
|
5151
|
+
"--file",
|
|
5152
|
+
file,
|
|
5153
|
+
...options?.extraArgs ?? []
|
|
5154
|
+
],
|
|
5155
|
+
postgresEnv(parsed.password)
|
|
5156
|
+
);
|
|
5157
|
+
return { file, dialect: "postgresql", via: "pg_dump" };
|
|
5158
|
+
}
|
|
5159
|
+
async function restoreDatabase(url, file, options) {
|
|
5160
|
+
const parsed = parseDatabaseUrl(url);
|
|
5161
|
+
if (parsed.dialect === "sqlite") {
|
|
5162
|
+
const target = parsed.database ?? ":memory:";
|
|
5163
|
+
await promises.copyFile(file, target);
|
|
5164
|
+
return { file, dialect: "sqlite", via: "copy" };
|
|
5165
|
+
}
|
|
5166
|
+
if (parsed.dialect !== "postgresql") {
|
|
5167
|
+
throw new UnsupportedBackupBackend(parsed.dialect);
|
|
5168
|
+
}
|
|
5169
|
+
const env = postgresEnv(parsed.password);
|
|
5170
|
+
if (backupFormat(file) === "plain") {
|
|
5171
|
+
await spawn(
|
|
5172
|
+
"psql",
|
|
5173
|
+
["--dbname", toolUrl(url), "--file", file, ...options?.extraArgs ?? []],
|
|
5174
|
+
env
|
|
5175
|
+
);
|
|
5176
|
+
return { file, dialect: "postgresql", via: "psql" };
|
|
5177
|
+
}
|
|
5178
|
+
await spawn(
|
|
5179
|
+
"pg_restore",
|
|
5180
|
+
[
|
|
5181
|
+
"--dbname",
|
|
5182
|
+
toolUrl(url),
|
|
5183
|
+
...options?.force ? ["--clean", "--if-exists"] : [],
|
|
5184
|
+
file,
|
|
5185
|
+
...options?.extraArgs ?? []
|
|
5186
|
+
],
|
|
5187
|
+
env
|
|
5188
|
+
);
|
|
5189
|
+
return { file, dialect: "postgresql", via: "pg_restore" };
|
|
5190
|
+
}
|
|
5191
|
+
|
|
5192
|
+
// src/cte.ts
|
|
5193
|
+
function cteModel(model, name) {
|
|
5194
|
+
const relation = class extends model {
|
|
5195
|
+
};
|
|
5196
|
+
Object.defineProperty(relation, "tablename", { value: name, writable: true });
|
|
5197
|
+
Object.defineProperty(relation, "naming", {
|
|
5198
|
+
value: model.naming,
|
|
5199
|
+
writable: true
|
|
5200
|
+
});
|
|
5201
|
+
Object.defineProperty(relation, "tableArgs", { value: void 0, writable: true });
|
|
5202
|
+
return relation;
|
|
5203
|
+
}
|
|
5204
|
+
var Cte = class {
|
|
5205
|
+
constructor(model, node) {
|
|
5206
|
+
this.model = model;
|
|
5207
|
+
this.node = node;
|
|
5208
|
+
}
|
|
5209
|
+
model;
|
|
5210
|
+
node;
|
|
5211
|
+
select(columns) {
|
|
5212
|
+
const builder = columns ? select(this.model, columns) : select(this.model);
|
|
5213
|
+
return attach(builder, this.node);
|
|
5214
|
+
}
|
|
5215
|
+
};
|
|
5216
|
+
function attach(builder, node) {
|
|
5217
|
+
const current = builder.node.with ?? [];
|
|
5218
|
+
const next = { ...builder.node, with: [...current, node] };
|
|
5219
|
+
return Object.assign(Object.create(Object.getPrototypeOf(builder)), builder, {
|
|
5220
|
+
node: next
|
|
5221
|
+
});
|
|
5222
|
+
}
|
|
5223
|
+
function cte(name, model, body, options) {
|
|
5224
|
+
return new Cte(cteModel(model, name), {
|
|
5225
|
+
name,
|
|
5226
|
+
recursive: false,
|
|
5227
|
+
body: body.node,
|
|
5228
|
+
materialized: options?.materialized ?? null
|
|
5229
|
+
});
|
|
5230
|
+
}
|
|
5231
|
+
function cteRecursive(name, model, build, options) {
|
|
5232
|
+
const self = cteModel(model, name);
|
|
5233
|
+
return new Cte(self, {
|
|
5234
|
+
name,
|
|
5235
|
+
recursive: true,
|
|
5236
|
+
body: build(self).node,
|
|
5237
|
+
materialized: options?.materialized ?? null
|
|
5238
|
+
});
|
|
5239
|
+
}
|
|
5240
|
+
|
|
5241
|
+
// src/active-record.ts
|
|
5242
|
+
var ActiveRecord = class {
|
|
5243
|
+
constructor(model, session, data) {
|
|
5244
|
+
this.model = model;
|
|
5245
|
+
this.session = session;
|
|
5246
|
+
this.data = data;
|
|
5247
|
+
this.pks = primaryKeysOf(model);
|
|
5248
|
+
}
|
|
5249
|
+
model;
|
|
5250
|
+
session;
|
|
5251
|
+
data;
|
|
5252
|
+
pks;
|
|
5253
|
+
/**
|
|
5254
|
+
* The primary-key values of the wrapped row, as an object.
|
|
5255
|
+
*
|
|
5256
|
+
* Composite keys are the reason this is an object and not a value: identifying
|
|
5257
|
+
* the row by one column of a two-column key hits the wrong row.
|
|
5258
|
+
*/
|
|
5259
|
+
pkValue() {
|
|
5260
|
+
const row = this.data;
|
|
5261
|
+
const key = {};
|
|
5262
|
+
for (const name of this.pks) key[name] = row[name];
|
|
5263
|
+
return key;
|
|
5264
|
+
}
|
|
5265
|
+
pkFilter() {
|
|
5266
|
+
return this.pkValue();
|
|
5267
|
+
}
|
|
5268
|
+
/**
|
|
5269
|
+
* Persist the current `data` — insert if new, otherwise overwrite the existing
|
|
5270
|
+
* row (upsert on the primary key). Refreshes `data` from the returned row.
|
|
5271
|
+
*
|
|
5272
|
+
* @returns This wrapper, for chaining.
|
|
5273
|
+
*/
|
|
5274
|
+
async save() {
|
|
5275
|
+
const cols = columnsOf(this.model);
|
|
5276
|
+
const rowData = this.data;
|
|
5277
|
+
const setPatch = {};
|
|
5278
|
+
for (const c of Object.keys(rowData)) {
|
|
5279
|
+
if (!this.pks.includes(c) && c in cols) setPatch[c] = rowData[c];
|
|
5280
|
+
}
|
|
5281
|
+
const saved = await this.session.execute(
|
|
5282
|
+
insert(this.model).values(this.data).onConflictDoUpdate(
|
|
5283
|
+
this.pks,
|
|
5284
|
+
setPatch
|
|
5285
|
+
).returning()
|
|
5286
|
+
).one();
|
|
5287
|
+
this.data = saved;
|
|
5288
|
+
return this;
|
|
5289
|
+
}
|
|
5290
|
+
/**
|
|
5291
|
+
* Update the given columns for this row and merge them into `data`.
|
|
5292
|
+
*
|
|
5293
|
+
* @param patch The columns to change.
|
|
5294
|
+
* @returns This wrapper, for chaining.
|
|
5295
|
+
*/
|
|
5296
|
+
async update(patch) {
|
|
5297
|
+
await this.session.execute(update(this.model).set(patch).where(this.pkFilter()));
|
|
5298
|
+
this.data = { ...this.data, ...patch };
|
|
5299
|
+
return this;
|
|
5300
|
+
}
|
|
5301
|
+
/**
|
|
5302
|
+
* Delete this row.
|
|
5303
|
+
*
|
|
5304
|
+
* @returns The number of rows affected (0 or 1).
|
|
5305
|
+
*/
|
|
5306
|
+
async delete() {
|
|
5307
|
+
return this.session.execute(del(this.model).where(this.pkFilter())).rowsAffected();
|
|
5308
|
+
}
|
|
5309
|
+
/**
|
|
5310
|
+
* Re-fetch this row by primary key and refresh `data`.
|
|
5311
|
+
*
|
|
5312
|
+
* @returns This wrapper, for chaining.
|
|
5313
|
+
* @throws When the row no longer exists.
|
|
5314
|
+
*/
|
|
5315
|
+
async reload() {
|
|
5316
|
+
const fresh = await this.session.execute(select(this.model).where(this.pkFilter())).first();
|
|
5317
|
+
if (fresh === null) {
|
|
5318
|
+
throw new Error(
|
|
5319
|
+
`${this.model.tablename} row ${JSON.stringify(this.pkValue())} not found on reload`
|
|
5320
|
+
);
|
|
5321
|
+
}
|
|
5322
|
+
this.data = fresh;
|
|
5323
|
+
return this;
|
|
5324
|
+
}
|
|
5325
|
+
};
|
|
5326
|
+
function activeRecord(model, session) {
|
|
5327
|
+
primaryKeysOf(model);
|
|
5328
|
+
return {
|
|
5329
|
+
wrap: (row) => new ActiveRecord(model, session, row),
|
|
5330
|
+
create: (data) => new ActiveRecord(model, session, data),
|
|
5331
|
+
async get(id) {
|
|
5332
|
+
const filter = primaryKeyFilter(model, id);
|
|
5333
|
+
const row = await session.execute(select(model).where(filter)).first();
|
|
5334
|
+
return row === null ? null : new ActiveRecord(model, session, row);
|
|
5335
|
+
}
|
|
5336
|
+
};
|
|
5337
|
+
}
|
|
5338
|
+
|
|
5339
|
+
// src/relations.ts
|
|
5340
|
+
function hasMany(target, keys) {
|
|
5341
|
+
return {
|
|
5342
|
+
kind: "hasMany",
|
|
5343
|
+
target,
|
|
5344
|
+
localKey: keys.localKey,
|
|
5345
|
+
foreignKey: keys.foreignKey
|
|
5346
|
+
};
|
|
5347
|
+
}
|
|
5348
|
+
function belongsTo(target, keys) {
|
|
5349
|
+
return {
|
|
5350
|
+
kind: "belongsTo",
|
|
5351
|
+
target,
|
|
5352
|
+
localKey: keys.localKey,
|
|
5353
|
+
foreignKey: keys.foreignKey
|
|
5354
|
+
};
|
|
5355
|
+
}
|
|
5356
|
+
async function loadRelations(session, rows, spec) {
|
|
5357
|
+
const out = rows.map((r) => ({ ...r }));
|
|
5358
|
+
for (const [name, rel] of Object.entries(spec)) {
|
|
5359
|
+
const target = rel.target();
|
|
5360
|
+
const localValues = [...new Set(rows.map((r) => r[rel.localKey]))];
|
|
5361
|
+
const related = localValues.length > 0 ? await session.execute(
|
|
5362
|
+
select(target).where({
|
|
5363
|
+
[rel.foreignKey]: { in: localValues }
|
|
5364
|
+
})
|
|
5365
|
+
).all() : [];
|
|
5366
|
+
if (rel.kind === "hasMany") {
|
|
5367
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
5368
|
+
for (const row of related) {
|
|
5369
|
+
const key = row[rel.foreignKey];
|
|
5370
|
+
const list = grouped.get(key) ?? [];
|
|
5371
|
+
list.push(row);
|
|
5372
|
+
grouped.set(key, list);
|
|
5373
|
+
}
|
|
5374
|
+
out.forEach((r, i) => {
|
|
5375
|
+
r[name] = grouped.get(rows[i]?.[rel.localKey]) ?? [];
|
|
5376
|
+
});
|
|
5377
|
+
} else {
|
|
5378
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
5379
|
+
for (const row of related) {
|
|
5380
|
+
byKey.set(row[rel.foreignKey], row);
|
|
5381
|
+
}
|
|
5382
|
+
out.forEach((r, i) => {
|
|
5383
|
+
r[name] = byKey.get(rows[i]?.[rel.localKey]) ?? null;
|
|
5384
|
+
});
|
|
5385
|
+
}
|
|
5386
|
+
}
|
|
5387
|
+
return out;
|
|
5388
|
+
}
|
|
5389
|
+
|
|
2720
5390
|
// src/index.ts
|
|
2721
5391
|
var DEFAULT_FLAGS = {
|
|
2722
5392
|
primaryKey: false,
|
|
@@ -2728,6 +5398,9 @@ var EXPRESSION = /* @__PURE__ */ Symbol.for("tempest-db-js.expression");
|
|
|
2728
5398
|
function expression(token, params = []) {
|
|
2729
5399
|
return { [EXPRESSION]: true, kind: "expression", expression: token, params };
|
|
2730
5400
|
}
|
|
5401
|
+
function defaultAsWriteValue(value) {
|
|
5402
|
+
return value.kind === "literal" ? value.value : expression(value.expression);
|
|
5403
|
+
}
|
|
2731
5404
|
function isSqlExpression(value) {
|
|
2732
5405
|
return typeof value === "object" && value !== null && value[EXPRESSION] === true;
|
|
2733
5406
|
}
|
|
@@ -2740,6 +5413,18 @@ var sql = {
|
|
|
2740
5413
|
currentTime: () => expression("current_time"),
|
|
2741
5414
|
/** A freshly generated UUID v4 (`gen_random_uuid()` / portable fallback). */
|
|
2742
5415
|
uuidv4: () => expression("uuidv4"),
|
|
5416
|
+
/**
|
|
5417
|
+
* The **incoming** value of a column, valid only inside an upsert's
|
|
5418
|
+
* `onConflictDoUpdate` patch.
|
|
5419
|
+
*
|
|
5420
|
+
* A multi-row upsert cannot spell the new value as a literal — each row has its
|
|
5421
|
+
* own — so the assignment has to name the row being inserted:
|
|
5422
|
+
* `EXCLUDED."total"` on PostgreSQL and SQLite, `VALUES(total)` on MySQL.
|
|
5423
|
+
*
|
|
5424
|
+
* @param column The column's property name.
|
|
5425
|
+
* @returns The expression, for use as a write value.
|
|
5426
|
+
*/
|
|
5427
|
+
excluded: (column2) => expression({ excluded: column2 }),
|
|
2743
5428
|
/**
|
|
2744
5429
|
* Escape hatch: a verbatim SQL expression rendered as-is, with no parameters.
|
|
2745
5430
|
*
|
|
@@ -2789,13 +5474,14 @@ function parseReference(ref, options) {
|
|
|
2789
5474
|
};
|
|
2790
5475
|
}
|
|
2791
5476
|
var Column = class _Column {
|
|
2792
|
-
constructor(type, flags, defaultValue = null, onUpdateValue = null, reference = null, dbName = null) {
|
|
5477
|
+
constructor(type, flags, defaultValue = null, onUpdateValue = null, reference = null, dbName = null, codec = null) {
|
|
2793
5478
|
this.type = type;
|
|
2794
5479
|
this.flags = flags;
|
|
2795
5480
|
this.defaultValue = defaultValue;
|
|
2796
5481
|
this.onUpdateValue = onUpdateValue;
|
|
2797
5482
|
this.reference = reference;
|
|
2798
5483
|
this.dbName = dbName;
|
|
5484
|
+
this.codec = codec;
|
|
2799
5485
|
}
|
|
2800
5486
|
type;
|
|
2801
5487
|
flags;
|
|
@@ -2803,6 +5489,7 @@ var Column = class _Column {
|
|
|
2803
5489
|
onUpdateValue;
|
|
2804
5490
|
reference;
|
|
2805
5491
|
dbName;
|
|
5492
|
+
codec;
|
|
2806
5493
|
/** Clone this column with one facet replaced, carrying every other over. */
|
|
2807
5494
|
derive(patch) {
|
|
2808
5495
|
return new _Column(
|
|
@@ -2811,7 +5498,8 @@ var Column = class _Column {
|
|
|
2811
5498
|
patch.defaultValue !== void 0 ? patch.defaultValue : this.defaultValue,
|
|
2812
5499
|
patch.onUpdateValue !== void 0 ? patch.onUpdateValue : this.onUpdateValue,
|
|
2813
5500
|
patch.reference !== void 0 ? patch.reference : this.reference,
|
|
2814
|
-
patch.dbName !== void 0 ? patch.dbName : this.dbName
|
|
5501
|
+
patch.dbName !== void 0 ? patch.dbName : this.dbName,
|
|
5502
|
+
patch.codec !== void 0 ? patch.codec : this.codec
|
|
2815
5503
|
);
|
|
2816
5504
|
}
|
|
2817
5505
|
primaryKey() {
|
|
@@ -2907,6 +5595,40 @@ var Column = class _Column {
|
|
|
2907
5595
|
return this.derive({ onUpdateValue: resolved });
|
|
2908
5596
|
}
|
|
2909
5597
|
};
|
|
5598
|
+
function customType(spec) {
|
|
5599
|
+
const codec = {
|
|
5600
|
+
toDb: (value) => value === null || value === void 0 ? value : spec.toDb(value),
|
|
5601
|
+
fromDb: (value) => value === null || value === void 0 ? value : spec.fromDb(value)
|
|
5602
|
+
};
|
|
5603
|
+
return () => {
|
|
5604
|
+
const base = spec.base();
|
|
5605
|
+
return new Column(
|
|
5606
|
+
base.type,
|
|
5607
|
+
base.flags,
|
|
5608
|
+
base.defaultValue,
|
|
5609
|
+
base.onUpdateValue,
|
|
5610
|
+
base.reference,
|
|
5611
|
+
base.dbName,
|
|
5612
|
+
codec
|
|
5613
|
+
);
|
|
5614
|
+
};
|
|
5615
|
+
}
|
|
5616
|
+
var codecCache = /* @__PURE__ */ new WeakMap();
|
|
5617
|
+
function codecsOf(model) {
|
|
5618
|
+
const cached = codecCache.get(model);
|
|
5619
|
+
if (cached !== void 0) return cached;
|
|
5620
|
+
const codecs = {};
|
|
5621
|
+
let found = false;
|
|
5622
|
+
for (const [prop, col2] of Object.entries(columnsOf(model))) {
|
|
5623
|
+
if (col2.codec) {
|
|
5624
|
+
codecs[prop] = col2.codec;
|
|
5625
|
+
found = true;
|
|
5626
|
+
}
|
|
5627
|
+
}
|
|
5628
|
+
const result = found ? codecs : null;
|
|
5629
|
+
codecCache.set(model, result);
|
|
5630
|
+
return result;
|
|
5631
|
+
}
|
|
2910
5632
|
function makeColumn(kind, meta = {}) {
|
|
2911
5633
|
return new Column({ kind, meta }, DEFAULT_FLAGS);
|
|
2912
5634
|
}
|
|
@@ -2984,6 +5706,62 @@ function unique(...columns) {
|
|
|
2984
5706
|
}
|
|
2985
5707
|
return { kind: "unique", columns };
|
|
2986
5708
|
}
|
|
5709
|
+
function check(expression2, options) {
|
|
5710
|
+
const node = toCondNode(expression2);
|
|
5711
|
+
return {
|
|
5712
|
+
kind: "check",
|
|
5713
|
+
name: options?.name,
|
|
5714
|
+
expression: node,
|
|
5715
|
+
columns: options?.columns ?? conditionColumns(node)
|
|
5716
|
+
};
|
|
5717
|
+
}
|
|
5718
|
+
function index(columns, options) {
|
|
5719
|
+
if (columns.length === 0) {
|
|
5720
|
+
throw new Error("index() requires at least one column.");
|
|
5721
|
+
}
|
|
5722
|
+
return {
|
|
5723
|
+
kind: "index",
|
|
5724
|
+
name: options?.name,
|
|
5725
|
+
columns: [...columns],
|
|
5726
|
+
unique: options?.unique,
|
|
5727
|
+
where: options?.where === void 0 ? void 0 : toCondNode(options.where)
|
|
5728
|
+
};
|
|
5729
|
+
}
|
|
5730
|
+
function conditionColumns(node) {
|
|
5731
|
+
const found = [];
|
|
5732
|
+
const visitExpr = (expr) => {
|
|
5733
|
+
if (expr.kind === "column") {
|
|
5734
|
+
if (!found.includes(expr.name)) found.push(expr.name);
|
|
5735
|
+
return;
|
|
5736
|
+
}
|
|
5737
|
+
if (expr.kind === "fn") for (const arg of expr.args) visitExpr(arg);
|
|
5738
|
+
if (expr.kind === "cast") visitExpr(expr.operand);
|
|
5739
|
+
};
|
|
5740
|
+
const visit = (current) => {
|
|
5741
|
+
switch (current.kind) {
|
|
5742
|
+
case "fields":
|
|
5743
|
+
for (const key of Object.keys(current.fields)) {
|
|
5744
|
+
if (!found.includes(key)) found.push(key);
|
|
5745
|
+
}
|
|
5746
|
+
return;
|
|
5747
|
+
case "and":
|
|
5748
|
+
case "or":
|
|
5749
|
+
for (const part of current.parts) visit(part);
|
|
5750
|
+
return;
|
|
5751
|
+
case "not":
|
|
5752
|
+
visit(current.part);
|
|
5753
|
+
return;
|
|
5754
|
+
case "compare":
|
|
5755
|
+
visitExpr(current.left);
|
|
5756
|
+
visitExpr(current.right);
|
|
5757
|
+
return;
|
|
5758
|
+
default:
|
|
5759
|
+
return;
|
|
5760
|
+
}
|
|
5761
|
+
};
|
|
5762
|
+
visit(node);
|
|
5763
|
+
return found;
|
|
5764
|
+
}
|
|
2987
5765
|
function foreignKey(columns, refTable, refColumns, options) {
|
|
2988
5766
|
if (columns.length === 0 || columns.length !== refColumns.length) {
|
|
2989
5767
|
throw new Error(
|
|
@@ -3073,18 +5851,53 @@ function columnPropsOf(model) {
|
|
|
3073
5851
|
function dbColumn(names, prop) {
|
|
3074
5852
|
return names?.[prop] ?? prop;
|
|
3075
5853
|
}
|
|
5854
|
+
function primaryKeysOf(model) {
|
|
5855
|
+
const keys = [];
|
|
5856
|
+
for (const [name, col2] of Object.entries(columnsOf(model))) {
|
|
5857
|
+
if (col2.flags.primaryKey) keys.push(name);
|
|
5858
|
+
}
|
|
5859
|
+
if (keys.length === 0) throw new Error(`${model.tablename} has no primary key`);
|
|
5860
|
+
return keys;
|
|
5861
|
+
}
|
|
5862
|
+
function primaryKeyFilter(model, id) {
|
|
5863
|
+
const keys = primaryKeysOf(model);
|
|
5864
|
+
const asObject = typeof id === "object" && id !== null && !Array.isArray(id) && !(id instanceof Date) ? id : null;
|
|
5865
|
+
const first = keys[0];
|
|
5866
|
+
if (keys.length === 1) {
|
|
5867
|
+
return { [first]: asObject && first in asObject ? asObject[first] : id };
|
|
5868
|
+
}
|
|
5869
|
+
if (asObject === null) {
|
|
5870
|
+
throw new Error(
|
|
5871
|
+
`${model.tablename} has a composite primary key (${keys.join(", ")}); pass an object like { ${keys.join(", ")} } instead of a scalar.`
|
|
5872
|
+
);
|
|
5873
|
+
}
|
|
5874
|
+
const filter = {};
|
|
5875
|
+
for (const key of keys) {
|
|
5876
|
+
if (!(key in asObject)) {
|
|
5877
|
+
throw new Error(
|
|
5878
|
+
`${model.tablename} has a composite primary key (${keys.join(", ")}); the key is missing ${JSON.stringify(key)}.`
|
|
5879
|
+
);
|
|
5880
|
+
}
|
|
5881
|
+
filter[key] = asObject[key];
|
|
5882
|
+
}
|
|
5883
|
+
return filter;
|
|
5884
|
+
}
|
|
3076
5885
|
|
|
3077
5886
|
exports.ActiveRecord = ActiveRecord;
|
|
3078
5887
|
exports.Agg = Agg;
|
|
3079
5888
|
exports.AsyncEngine = AsyncEngine;
|
|
3080
5889
|
exports.AsyncResult = AsyncResult;
|
|
3081
5890
|
exports.AsyncSession = AsyncSession;
|
|
5891
|
+
exports.BackupToolMissing = BackupToolMissing;
|
|
3082
5892
|
exports.BaseDialect = BaseDialect;
|
|
3083
5893
|
exports.BaseRepository = BaseRepository;
|
|
5894
|
+
exports.BetterSqliteDriver = BetterSqliteDriver;
|
|
3084
5895
|
exports.Column = Column;
|
|
5896
|
+
exports.Cte = Cte;
|
|
3085
5897
|
exports.DeleteBuilder = DeleteBuilder;
|
|
3086
5898
|
exports.Expression = Expression;
|
|
3087
5899
|
exports.InsertBuilder = InsertBuilder;
|
|
5900
|
+
exports.InvalidCursor = InvalidCursor;
|
|
3088
5901
|
exports.InvalidDatabaseUrl = InvalidDatabaseUrl;
|
|
3089
5902
|
exports.JoinBuilder = JoinBuilder;
|
|
3090
5903
|
exports.Model = Model;
|
|
@@ -3092,61 +5905,124 @@ exports.MysqlDialect = MysqlDialect;
|
|
|
3092
5905
|
exports.NoResultError = NoResultError;
|
|
3093
5906
|
exports.NodeSqliteDriver = NodeSqliteDriver;
|
|
3094
5907
|
exports.OPERATORS = OPERATORS;
|
|
5908
|
+
exports.OutboxRepository = OutboxRepository;
|
|
3095
5909
|
exports.Params = Params;
|
|
3096
5910
|
exports.PostgresDialect = PostgresDialect;
|
|
3097
5911
|
exports.QueryExecutionError = QueryExecutionError;
|
|
3098
5912
|
exports.RecordNotFound = RecordNotFound;
|
|
3099
5913
|
exports.SelectBuilder = SelectBuilder;
|
|
5914
|
+
exports.SetBuilder = SetBuilder;
|
|
3100
5915
|
exports.SqliteDialect = SqliteDialect;
|
|
3101
5916
|
exports.SyncEngine = SyncEngine;
|
|
3102
5917
|
exports.SyncResult = SyncResult;
|
|
3103
5918
|
exports.SyncSession = SyncSession;
|
|
5919
|
+
exports.TenantScopedRepository = TenantScopedRepository;
|
|
5920
|
+
exports.UnitOfWork = UnitOfWork;
|
|
5921
|
+
exports.UnsupportedBackupBackend = UnsupportedBackupBackend;
|
|
3104
5922
|
exports.UpdateBuilder = UpdateBuilder;
|
|
3105
5923
|
exports.ValidationError = ValidationError;
|
|
3106
5924
|
exports.activeRecord = activeRecord;
|
|
5925
|
+
exports.aliasOf = aliasOf;
|
|
5926
|
+
exports.aliased = aliased;
|
|
3107
5927
|
exports.and = and;
|
|
5928
|
+
exports.attachCte = attach;
|
|
5929
|
+
exports.auditLogModel = auditLogModel;
|
|
3108
5930
|
exports.avg = avg;
|
|
5931
|
+
exports.backupDatabase = backupDatabase;
|
|
5932
|
+
exports.backupFormat = backupFormat;
|
|
3109
5933
|
exports.belongsTo = belongsTo;
|
|
5934
|
+
exports.caseWhen = caseWhen;
|
|
5935
|
+
exports.cast = cast;
|
|
5936
|
+
exports.check = check;
|
|
5937
|
+
exports.clearSignals = clearSignals;
|
|
5938
|
+
exports.codecsOf = codecsOf;
|
|
3110
5939
|
exports.col = col;
|
|
3111
5940
|
exports.column = column;
|
|
3112
5941
|
exports.columnNamesOf = columnNamesOf;
|
|
3113
5942
|
exports.columnPropsOf = columnPropsOf;
|
|
3114
5943
|
exports.columnsOf = columnsOf;
|
|
5944
|
+
exports.contains = contains;
|
|
3115
5945
|
exports.count = count;
|
|
3116
5946
|
exports.createEngine = createEngine;
|
|
3117
5947
|
exports.createSyncEngine = createSyncEngine;
|
|
5948
|
+
exports.cte = cte;
|
|
5949
|
+
exports.cteRecursive = cteRecursive;
|
|
5950
|
+
exports.customType = customType;
|
|
3118
5951
|
exports.dbColumn = dbColumn;
|
|
5952
|
+
exports.decodeColumnValue = decodeValue;
|
|
5953
|
+
exports.defaultAsWriteValue = defaultAsWriteValue;
|
|
3119
5954
|
exports.del = del;
|
|
5955
|
+
exports.denseRank = denseRank;
|
|
3120
5956
|
exports.detectDialect = detectDialect;
|
|
5957
|
+
exports.diffSnapshots = diffSnapshots;
|
|
5958
|
+
exports.emitSignal = emitSignal;
|
|
5959
|
+
exports.enableAudit = enableAudit;
|
|
5960
|
+
exports.encodeColumnValue = encodeValue;
|
|
5961
|
+
exports.escapeLike = escapeLike;
|
|
5962
|
+
exports.except = except;
|
|
5963
|
+
exports.exists = exists;
|
|
5964
|
+
exports.firstValue = firstValue;
|
|
3121
5965
|
exports.fn = fn;
|
|
3122
5966
|
exports.foreignKey = foreignKey;
|
|
3123
5967
|
exports.fromDict = fromDict;
|
|
5968
|
+
exports.fullText = fullText;
|
|
5969
|
+
exports.fullTextRank = fullTextRank;
|
|
3124
5970
|
exports.getDialect = getDialect;
|
|
5971
|
+
exports.hasHandlers = hasHandlers;
|
|
3125
5972
|
exports.hasMany = hasMany;
|
|
5973
|
+
exports.index = index;
|
|
3126
5974
|
exports.insert = insert;
|
|
5975
|
+
exports.intersect = intersect;
|
|
3127
5976
|
exports.isCondition = isCondition;
|
|
3128
5977
|
exports.isExpression = isExpression;
|
|
5978
|
+
exports.isReadOnlyStatement = isReadOnlyStatement;
|
|
3129
5979
|
exports.isSqlExpression = isSqlExpression;
|
|
3130
5980
|
exports.isSubquery = isSubquery;
|
|
3131
5981
|
exports.join = join;
|
|
5982
|
+
exports.lag = lag;
|
|
5983
|
+
exports.lastValue = lastValue;
|
|
5984
|
+
exports.lead = lead;
|
|
3132
5985
|
exports.loadRelations = loadRelations;
|
|
3133
5986
|
exports.max = max;
|
|
3134
5987
|
exports.min = min;
|
|
3135
5988
|
exports.not = not;
|
|
5989
|
+
exports.notDeleted = notDeleted;
|
|
5990
|
+
exports.notExists = notExists;
|
|
5991
|
+
exports.onSignal = onSignal;
|
|
5992
|
+
exports.onlyDeleted = onlyDeleted;
|
|
3136
5993
|
exports.or = or;
|
|
5994
|
+
exports.outboxModel = outboxModel;
|
|
5995
|
+
exports.over = over;
|
|
3137
5996
|
exports.parse = parse;
|
|
3138
5997
|
exports.parseDatabaseUrl = parseDatabaseUrl;
|
|
5998
|
+
exports.parseIntegrityError = parseIntegrityError;
|
|
5999
|
+
exports.percentRank = percentRank;
|
|
6000
|
+
exports.primaryKeyFilter = primaryKeyFilter;
|
|
6001
|
+
exports.primaryKeysOf = primaryKeysOf;
|
|
6002
|
+
exports.rank = rank;
|
|
6003
|
+
exports.restoreDatabase = restoreDatabase;
|
|
6004
|
+
exports.rowNumber = rowNumber;
|
|
6005
|
+
exports.scalar = scalar;
|
|
3139
6006
|
exports.select = select;
|
|
6007
|
+
exports.snapshot = snapshot;
|
|
3140
6008
|
exports.sql = sql;
|
|
3141
6009
|
exports.stringify = stringify;
|
|
3142
6010
|
exports.sum = sum;
|
|
6011
|
+
exports.summarizePlan = summarizePlan;
|
|
3143
6012
|
exports.toAsyncDriver = toAsyncDriver;
|
|
3144
6013
|
exports.toCondNode = toCondNode;
|
|
3145
6014
|
exports.toDict = toDict;
|
|
3146
6015
|
exports.toJSON = toJSON;
|
|
3147
6016
|
exports.toSnakeCase = toSnakeCase;
|
|
6017
|
+
exports.tokenize = tokenize;
|
|
6018
|
+
exports.toolUrl = toolUrl;
|
|
6019
|
+
exports.union = union;
|
|
6020
|
+
exports.unionAll = unionAll;
|
|
3148
6021
|
exports.unique = unique;
|
|
3149
6022
|
exports.update = update;
|
|
3150
6023
|
exports.val = val;
|
|
6024
|
+
exports.withAudit = withAudit;
|
|
6025
|
+
exports.withSoftDelete = withSoftDelete;
|
|
6026
|
+
exports.withTimestamps = withTimestamps;
|
|
3151
6027
|
//# sourceMappingURL=index.cjs.map
|
|
3152
6028
|
//# sourceMappingURL=index.cjs.map
|