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