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/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: [...this.node.orderBy, { column: column2, direction }]
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)) continue;
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 union = /* @__PURE__ */ new Set();
617
- for (const row of rows) for (const key of Object.keys(row)) union.add(key);
618
- const inconsistent = [...union].filter((key) => rows.some((row) => !(key in row)));
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
- for (const row of list) assertWritableValues(this.source, row, "values");
657
- assertConsistentRows(this.source, list);
658
- return this.with({ values: list });
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
- assertWritableValues(this.source, values, "set");
756
- return this.with({ set: values });
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
- return dialect === "postgresql" ? "now()" : "CURRENT_TIMESTAMP";
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
- return this.quoteId(names?.[prop] ?? prop);
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
- return isSqlExpression(value) ? this.renderExpression(value, params) : params.bind(value);
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 = a.column === "*" ? "*" : this.columnId(a.column, names);
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
- let sql2 = `SELECT ${node.distinct ? "DISTINCT " : ""}${cols} FROM ${this.quoteId(node.table)}`;
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 = agg.column === "*" ? "*" : this.columnId(agg.column, names);
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
- return `${this.qualify(ref, names)} AS ${this.quoteId(ref)}`;
1824
+ const label = node.pick ? s.column : ref;
1825
+ return `${this.qualify(ref, names)} AS ${this.quoteId(label)}`;
1341
1826
  }).join(", ");
1342
- let sql2 = `SELECT ${cols} FROM ${this.quoteId(node.base.table)} AS ${this.quoteId(node.base.alias)}`;
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 (isOperatorObject(value)) {
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(this.compileOperator(id, op, operand, params));
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
- value === null ? `${id} IS NULL` : `${id} = ${params.bind(value)}`
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(index) {
1575
- return `$${index}`;
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
- quoteId(name) {
1596
- const cached = mysqlQuotedIds.get(name);
1597
- if (cached !== void 0) return cached;
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 rejects `LIMIT` inside an `IN` subquery with
1604
- * `ER_NOT_SUPPORTED_YET: This version of MySQL doesn't yet support
1605
- * 'LIMIT & IN/ALL/ANY/SOME subquery'`. Failing at compile time names the fix
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
- checkSubquery(node) {
1609
- if (node.limit !== void 0 || node.offset !== void 0) {
2356
+ setOperator(op) {
2357
+ if (op === "intersect" || op === "except") {
1610
2358
  throw new Error(
1611
- "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."
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
- renderConflict(onConflict, conflictCols, nextValue, names) {
1616
- if (onConflict.targetWhere || onConflict.updateWhere) {
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
- "MySQL's ON DUPLICATE KEY UPDATE has no conflict-target predicate \u2014 a partial unique index is PostgreSQL/SQLite only."
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
- if (onConflict.update === "nothing") {
1622
- const col2 = this.columnId(onConflict.target[0] ?? "id", names);
1623
- return ` ON DUPLICATE KEY UPDATE ${col2} = ${col2}`;
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 RecordNotFound = class extends Error {
1747
- constructor(table, id) {
1748
- super(`${table} not found for id ${JSON.stringify(id)}`);
1749
- this.name = "RecordNotFound";
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 primaryKeyOf(model) {
1753
- for (const [name, col2] of Object.entries(columnsOf(model))) {
1754
- if (col2.flags.primaryKey) return name;
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
- throw new Error(`${model.tablename} has no primary key`);
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.pk = primaryKeyOf(model);
2925
+ this.pks = primaryKeysOf(model);
1763
2926
  }
1764
2927
  model;
1765
2928
  session;
1766
- pk;
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(filters) {
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(filters) {
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
- /** A single row by primary key, or `null`. */
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
- return this.session.execute(select(this.model).where({ [this.pk]: id })).first();
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
- /** A single row by primary key; throws `RecordNotFound` when absent. */
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 row = await this.getByIdOrNull(id);
1784
- if (row === null) throw new RecordNotFound(this.model.tablename, id);
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(filters) {
1793
- const query = filters ? select(this.model, [this.pk]).where(filters) : select(this.model, [this.pk]);
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
- /** Insert one row, returning the created row. */
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
- return this.session.execute(insert(this.model).values(data).returning()).one();
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
- /** Insert many rows, returning the created rows. */
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
- return this.session.execute(insert(this.model).values(data).returning()).all();
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
- /** Update rows matching `filters`; returns the number of rows affected. */
1806
- async update(filters, set) {
1807
- return this.session.execute(update(this.model).set(set).where(filters)).rowsAffected();
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
- /** Delete rows matching `filters`; returns the number of rows affected. */
1810
- async delete(filters) {
1811
- return this.session.execute(del(this.model).where(filters)).rowsAffected();
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/active-record.ts
1841
- function primaryKeyOf2(model) {
1842
- for (const [name, col2] of Object.entries(columnsOf(model))) {
1843
- if (col2.flags.primaryKey) return name;
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
- throw new Error(`${model.tablename} has no primary key`);
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
- var ActiveRecord = class {
1848
- constructor(model, session, data) {
1849
- this.model = model;
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
- data;
1857
- pk;
1858
- /** The primary-key value of the wrapped row. */
1859
- pkValue() {
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
- pkFilter() {
1863
- return { [this.pk]: this.pkValue() };
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
- * Persist the current `data` insert if new, otherwise overwrite the existing
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
- * @returns This wrapper, for chaining.
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
- async save() {
1872
- const cols = columnsOf(this.model);
1873
- const rowData = this.data;
1874
- const setPatch = {};
1875
- for (const c of Object.keys(rowData)) {
1876
- if (c !== this.pk && c in cols) setPatch[c] = rowData[c];
1877
- }
1878
- const saved = await this.session.execute(
1879
- insert(this.model).values(this.data).onConflictDoUpdate(
1880
- [this.pk],
1881
- setPatch
1882
- ).returning()
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
- * Update the given columns for this row and merge them into `data`.
3769
+ * Schedule an insert.
1889
3770
  *
1890
- * @param patch The columns to change.
1891
- * @returns This wrapper, for chaining.
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
- async update(patch) {
1894
- await this.session.execute(update(this.model).set(patch).where(this.pkFilter()));
1895
- this.data = { ...this.data, ...patch };
1896
- return this;
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
- * Delete this row.
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 The number of rows affected (0 or 1).
3822
+ * @returns How many rows were inserted, updated and deleted.
1902
3823
  */
1903
- async delete() {
1904
- return this.session.execute(del(this.model).where(this.pkFilter())).rowsAffected();
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
- * Re-fetch this row by primary key and refresh `data`.
3867
+ * The identity-map key for a row or a primary key.
1908
3868
  *
1909
- * @returns This wrapper, for chaining.
1910
- * @throws When the row no longer exists.
3869
+ * @param model The model class.
3870
+ * @param key The row, or the key.
3871
+ * @returns A stable string key.
1911
3872
  */
1912
- async reload() {
1913
- const fresh = await this.session.execute(select(this.model).where(this.pkFilter())).first();
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 activeRecord(model, session) {
1924
- const pk = primaryKeyOf2(model);
1925
- return {
1926
- wrap: (row) => new ActiveRecord(model, session, row),
1927
- create: (data) => new ActiveRecord(model, session, data),
1928
- async get(id) {
1929
- const row = await session.execute(select(model).where({ [pk]: id })).first();
1930
- return row === null ? null : new ActiveRecord(model, session, row);
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
- async function loadRelations(session, rows, spec) {
1953
- const out = rows.map((r) => ({ ...r }));
1954
- for (const [name, rel] of Object.entries(spec)) {
1955
- const target = rel.target();
1956
- const localValues = [...new Set(rows.map((r) => r[rel.localKey]))];
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
- return out;
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(logger, sql2, params) {
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, logger) {
4213
+ constructor(driver, dialect, hooks) {
2208
4214
  this.driver = driver;
2209
4215
  this.dialect = dialect;
2210
- this.logger = logger;
4216
+ this.hooks = hooks;
2211
4217
  }
2212
4218
  driver;
2213
4219
  dialect;
2214
- logger;
2215
- /** Log, run, and error-wrap one raw statement. */
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.logger, sql2, params);
4225
+ emitLog(this.hooks, sql2, params);
4226
+ const startedAt = now();
2218
4227
  try {
2219
- return this.driver.execute(sql2, params);
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
- transaction(fn2) {
2272
- this.exec("BEGIN", []);
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.logger, sql2, params);
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, logger) {
4404
+ constructor(driver, dialect, hooks) {
2328
4405
  this.driver = driver;
2329
4406
  this.dialect = dialect;
2330
- this.logger = logger;
4407
+ this.hooks = hooks;
2331
4408
  }
2332
4409
  driver;
2333
4410
  dialect;
2334
- logger;
2335
- /** Log, run, and error-wrap one raw statement. */
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.logger, sql2, params);
4416
+ emitLog(this.hooks, sql2, params);
4417
+ const startedAt = now();
2338
4418
  try {
2339
- return await this.driver.execute(sql2, params);
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 run = async (driver) => {
2431
- const scoped = new _AsyncSession(driver, this.dialect, this.logger);
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 run(this.driver);
4531
+ if (!this.driver.reserve) return run2(this.driver);
2438
4532
  const reserved = await this.driver.reserve();
2439
4533
  try {
2440
- return await run(reserved);
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.logger, sql2, params);
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
- async transaction(fn2) {
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.logger);
4621
+ const scoped = new _AsyncSession(reserved, this.dialect, this.hooks);
2469
4622
  try {
2470
- await scoped.exec("BEGIN", []);
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("BEGIN", []);
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, logger) {
4712
+ constructor(driver, hooks) {
2508
4713
  this.driver = driver;
2509
- this.logger = logger;
4714
+ this.hooks = hooks;
2510
4715
  }
2511
4716
  driver;
2512
- logger;
4717
+ hooks;
2513
4718
  dialect = "sqlite";
2514
4719
  session() {
2515
- return new SyncSession(this.driver, getDialect("sqlite"), this.logger);
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, logger) {
4734
+ constructor(driver, dialect, hooks) {
2530
4735
  this.driver = driver;
2531
4736
  this.dialect = dialect;
2532
- this.logger = logger;
4737
+ this.hooks = hooks;
2533
4738
  }
2534
4739
  driver;
2535
4740
  dialect;
2536
- logger;
4741
+ hooks;
2537
4742
  session() {
2538
- return new AsyncSession(this.driver, getDialect(this.dialect), this.logger);
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
- transaction(fn2) {
2541
- return this.session().transaction(fn2);
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 asAsync(driver) {
2562
- const syncIterate = driver.iterate?.bind(driver);
2563
- return {
2564
- execute: (sql2, params) => Promise.resolve(driver.execute(sql2, params)),
2565
- close: () => Promise.resolve(driver.close()),
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(path, options) {
2574
- return NodeSqliteDriver.open(path, options?.driverOptions);
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.database ?? ":memory:", options)),
4945
+ asAsync(openSqliteDriver(parsed, options)),
2593
4946
  "sqlite",
2594
- options?.onQuery
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?.onQuery
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?.onQuery
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
- const conn = await client.reserve();
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