oakbun 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -335,6 +335,14 @@ var init_types = __esm({
335
335
  });
336
336
 
337
337
  // src/db/sql.ts
338
+ function buildSubquery(sql, params, col) {
339
+ if (!sql) throw new Error("buildSubquery: sql must not be empty");
340
+ return {
341
+ _sql: `(${sql})`,
342
+ _params: params,
343
+ _phantom: { col, type: void 0 }
344
+ };
345
+ }
338
346
  function validateAndQuoteOnClause(on) {
339
347
  const trimmed = on.trim();
340
348
  const match = ON_CLAUSE_PATTERN.exec(trimmed);
@@ -356,6 +364,9 @@ function filterDefined(data) {
356
364
  function isWhereOp(val) {
357
365
  return typeof val === "object" && val !== null && "op" in val && typeof val["op"] === "string";
358
366
  }
367
+ function isSubqueryResult(val) {
368
+ return typeof val === "object" && val !== null && "_sql" in val && "_params" in val;
369
+ }
359
370
  function buildFieldCondition(key, condition, dialect) {
360
371
  if (!isWhereOp(condition)) {
361
372
  return { sql: `"${key}" = ?`, params: [condition] };
@@ -375,7 +386,11 @@ function buildFieldCondition(key, condition, dialect) {
375
386
  case "<=":
376
387
  return { sql: `"${key}" <= ?`, params: [op.value] };
377
388
  case "IN": {
378
- const vals = op.value;
389
+ const inVal = op.value;
390
+ if (isSubqueryResult(inVal)) {
391
+ return { sql: `"${key}" IN ${inVal._sql}`, params: inVal._params };
392
+ }
393
+ const vals = inVal;
379
394
  if (vals.length === 0) {
380
395
  return { sql: "1 = 0", params: [] };
381
396
  }
@@ -383,7 +398,11 @@ function buildFieldCondition(key, condition, dialect) {
383
398
  return { sql: `"${key}" IN (${placeholders})`, params: vals };
384
399
  }
385
400
  case "NOT IN": {
386
- const vals = op.value;
401
+ const inVal = op.value;
402
+ if (isSubqueryResult(inVal)) {
403
+ return { sql: `"${key}" NOT IN ${inVal._sql}`, params: inVal._params };
404
+ }
405
+ const vals = inVal;
387
406
  if (vals.length === 0) {
388
407
  return { sql: "1 = 1", params: [] };
389
408
  }
@@ -454,6 +473,37 @@ function buildInsert(tableName, data, returning = true) {
454
473
  const sql = `INSERT INTO "${tableName}" (${cols}) VALUES (${placeholders})${returning_clause}`;
455
474
  return { sql, params };
456
475
  }
476
+ function buildInsertMany(tableName, rows, returning = true) {
477
+ if (rows.length === 0) {
478
+ throw new Error("insertMany: rows array must not be empty");
479
+ }
480
+ const columns = Object.keys(rows[0]);
481
+ const quotedCols = columns.map((c) => `"${c}"`).join(", ");
482
+ const params = [];
483
+ const valueClauses = [];
484
+ for (const row of rows) {
485
+ const placeholders = [];
486
+ for (const col of columns) {
487
+ const val = row[col];
488
+ if (val === void 0) {
489
+ throw new Error(`insertMany: column "${col}" has undefined value \u2014 apply defaults before calling buildInsertMany`);
490
+ }
491
+ params.push(val);
492
+ placeholders.push("?");
493
+ }
494
+ valueClauses.push(`(${placeholders.join(", ")})`);
495
+ }
496
+ const returning_clause = returning ? " RETURNING *" : "";
497
+ const sql = `INSERT INTO "${tableName}" (${quotedCols}) VALUES ${valueClauses.join(", ")}${returning_clause}`;
498
+ return { sql, params };
499
+ }
500
+ function buildSoftDeleteUpdate(tableName, col, value, where, dialect = "sqlite") {
501
+ const serialized = value instanceof Date ? value.toISOString() : null;
502
+ const { sql: whereSql, params: whereParams } = buildWhere(where, dialect);
503
+ const setSql = `"${col}" = ?`;
504
+ const sql = whereSql ? `UPDATE "${tableName}" SET ${setSql} WHERE ${whereSql}` : `UPDATE "${tableName}" SET ${setSql}`;
505
+ return { sql, params: [serialized, ...whereParams] };
506
+ }
457
507
  function buildUpdate(tableName, patch, pk, pkValue) {
458
508
  const entries = filterDefined(patch);
459
509
  const sets = entries.map(([key]) => `"${key}" = ?`).join(", ");
@@ -509,11 +559,27 @@ function appendPaginationAndOrder(parts, options) {
509
559
  }
510
560
  }
511
561
  }
562
+ function buildUnion(parts, kind, options = {}) {
563
+ if (parts.length < 2) {
564
+ throw new Error("buildUnion: at least 2 parts required");
565
+ }
566
+ const sqls = parts.map((p) => p.sql);
567
+ const params = parts.flatMap((p) => p.params);
568
+ let sql = sqls.join(` ${kind} `);
569
+ if (options.orderBy) {
570
+ sql += ` ORDER BY "${options.orderBy.col}" ${options.orderBy.dir ?? "ASC"}`;
571
+ }
572
+ if (options.limit !== void 0) {
573
+ sql += ` LIMIT ${Math.trunc(Math.max(0, options.limit))}`;
574
+ }
575
+ return { sql, params };
576
+ }
512
577
  function buildSelect(tableName, conditions, options, dialect = "sqlite") {
513
578
  const selectList = buildSelectListFromOptions(options);
514
579
  const { sql: whereSql, params } = buildWhere(conditions, dialect);
580
+ const selectKeyword = options?.distinct ? "SELECT DISTINCT" : "SELECT";
515
581
  const parts = [
516
- whereSql ? `SELECT ${selectList} FROM "${tableName}" WHERE ${whereSql}` : `SELECT ${selectList} FROM "${tableName}"`
582
+ whereSql ? `${selectKeyword} ${selectList} FROM "${tableName}" WHERE ${whereSql}` : `${selectKeyword} ${selectList} FROM "${tableName}"`
517
583
  ];
518
584
  if (options?.groupBy && options.groupBy.length > 0) {
519
585
  parts.push(`GROUP BY ${options.groupBy.map((c) => `"${c}"`).join(", ")}`);
@@ -564,9 +630,12 @@ var init_sql = __esm({
564
630
  var db_exports = {};
565
631
  __export(db_exports, {
566
632
  BoundVelnDB: () => BoundVelnDB,
633
+ ColumnRestrictedBuilder: () => ColumnRestrictedBuilder,
567
634
  InsertBuilder: () => InsertBuilder,
568
635
  JoinBuilder: () => JoinBuilder,
569
636
  SelectBuilder: () => SelectBuilder,
637
+ SoftDeleteBuilder: () => SoftDeleteBuilder,
638
+ UnionBuilder: () => UnionBuilder,
570
639
  VelnDB: () => VelnDB
571
640
  });
572
641
  function mergeWhereAnd(a, b) {
@@ -577,7 +646,7 @@ function mergeWhereAnd(a, b) {
577
646
  }
578
647
  return { AND: [a, b] };
579
648
  }
580
- var VelnDB, BoundVelnDB, SelectBuilder, InsertBuilder, JoinBuilder;
649
+ var VelnDB, BoundVelnDB, SelectBuilder, ColumnRestrictedBuilder, SoftDeleteBuilder, UnionBuilder, InsertBuilder, JoinBuilder;
581
650
  var init_db = __esm({
582
651
  "src/db/index.ts"() {
583
652
  "use strict";
@@ -597,10 +666,11 @@ var init_db = __esm({
597
666
  }
598
667
  };
599
668
  BoundVelnDB = class _BoundVelnDB {
600
- constructor(adapter, hooks, ctx, queue, queryLog) {
669
+ constructor(adapter, hooks, ctx, queue, queryLog, dialect = "sqlite") {
601
670
  this.hooks = hooks;
602
671
  this.ctx = ctx;
603
672
  this.queue = queue;
673
+ this.dialect = dialect;
604
674
  if (queryLog) {
605
675
  const log = queryLog;
606
676
  this.adapter = {
@@ -635,9 +705,11 @@ var init_db = __esm({
635
705
  hooks;
636
706
  ctx;
637
707
  queue;
708
+ dialect;
638
709
  /** Per-request query counter — incremented for every query() and execute() call on this instance. */
639
710
  _queryCount = 0;
640
711
  adapter;
712
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
641
713
  from(table) {
642
714
  return new SelectBuilder(this.adapter, this.hooks, this.ctx, this.queue, table, {});
643
715
  }
@@ -657,20 +729,20 @@ var init_db = __esm({
657
729
  return new JoinBuilder(this.adapter, tableName, [], [], "", []);
658
730
  }
659
731
  into(table) {
660
- return new InsertBuilder(this.adapter, this.hooks, this.ctx, this.queue, table);
661
- }
662
- /**
663
- * DataLoader-pattern relation fetch — single IN-query, no N+1.
664
- * Returns a Map keyed by the foreign-key value; each entry is an array of
665
- * matching child rows (for one-to-many relations).
666
- *
667
- * @example
668
- * const posts = await db.from(postsTable).select()
669
- * const authorMap = await db.loadRelation(posts, 'authorId', usersTable, 'id')
670
- * // → SELECT * FROM "users" WHERE "id" IN (1, 2, 3)
671
- * const withAuthors = posts.map(p => ({ ...p, author: authorMap.get(p.authorId)?.[0] ?? null }))
672
- */
673
- async loadRelation(parents, foreignKey, childTable, primaryKey) {
732
+ return new InsertBuilder(this.adapter, this.hooks, this.ctx, this.queue, table, this.dialect);
733
+ }
734
+ // Implementation
735
+ async loadRelation(parents, keyOrRelationName, tableOrSource, primaryKey) {
736
+ if (primaryKey === void 0) {
737
+ return this._loadRelationByName(
738
+ parents,
739
+ keyOrRelationName,
740
+ tableOrSource,
741
+ "many"
742
+ );
743
+ }
744
+ const foreignKey = keyOrRelationName;
745
+ const childTable = tableOrSource;
674
746
  const result = /* @__PURE__ */ new Map();
675
747
  if (parents.length === 0) return result;
676
748
  const ids = [...new Set(parents.map((p) => p[foreignKey]))];
@@ -686,15 +758,18 @@ var init_db = __esm({
686
758
  }
687
759
  return result;
688
760
  }
689
- /**
690
- * Convenience variant of loadRelation for belongs-to (many-to-one) relations.
691
- * Returns Map<fkValue, TChild> single child per key instead of an array.
692
- *
693
- * @example
694
- * const authorMap = await db.loadRelationOne(posts, 'authorId', usersTable, 'id')
695
- * const author = authorMap.get(post.authorId) ?? null
696
- */
697
- async loadRelationOne(parents, foreignKey, childTable, primaryKey) {
761
+ // Implementation
762
+ async loadRelationOne(parents, keyOrRelationName, tableOrSource, primaryKey) {
763
+ if (primaryKey === void 0) {
764
+ return this._loadRelationByName(
765
+ parents,
766
+ keyOrRelationName,
767
+ tableOrSource,
768
+ "one"
769
+ );
770
+ }
771
+ const foreignKey = keyOrRelationName;
772
+ const childTable = tableOrSource;
698
773
  const result = /* @__PURE__ */ new Map();
699
774
  if (parents.length === 0) return result;
700
775
  const ids = [...new Set(parents.map((p) => p[foreignKey]))];
@@ -704,6 +779,55 @@ var init_db = __esm({
704
779
  }
705
780
  return result;
706
781
  }
782
+ /**
783
+ * Shared implementation for name-based loadRelation / loadRelationOne.
784
+ * Reads RelationMeta from sourceTable.relations, validates the kind,
785
+ * and delegates to the explicit overload.
786
+ */
787
+ async _loadRelationByName(parents, relationName, sourceTable, mode) {
788
+ const rel = sourceTable.relations[relationName];
789
+ if (rel === void 0) {
790
+ const available = Object.keys(sourceTable.relations).join(", ") || "(none)";
791
+ throw new Error(
792
+ `Relation '${relationName}' is not defined on table '${sourceTable.name}'. Available relations: ${available}`
793
+ );
794
+ }
795
+ if (rel.kind === "manyToMany") {
796
+ throw new Error(
797
+ `manyToMany relations are not yet supported in loadRelation. Use a manual JOIN for relation '${relationName}' on table '${sourceTable.name}'.`
798
+ );
799
+ }
800
+ if (mode === "one" && rel.kind === "hasMany") {
801
+ throw new Error(
802
+ `loadRelationOne cannot be used with hasMany relation '${relationName}' on table '${sourceTable.name}'. Use loadRelation to get an array of results.`
803
+ );
804
+ }
805
+ const foreignTable = rel.getTable();
806
+ const pk = foreignTable.primaryKey;
807
+ if (rel.kind === "belongsTo") {
808
+ const ft = foreignTable;
809
+ const fk = rel.foreignKey;
810
+ if (mode === "one") {
811
+ return this.loadRelationOne(parents, fk, ft, pk);
812
+ }
813
+ return this.loadRelation(parents, fk, ft, pk);
814
+ }
815
+ const parentPk = sourceTable.primaryKey;
816
+ const result = /* @__PURE__ */ new Map();
817
+ if (parents.length === 0) return result;
818
+ const ids = [...new Set(parents.map((p) => p[parentPk]))];
819
+ const children = await this.from(foreignTable).where({ [rel.foreignKey]: { op: "IN", value: ids } }).select();
820
+ for (const child of children) {
821
+ const key = child[rel.foreignKey];
822
+ const group = result.get(key);
823
+ if (group) {
824
+ group.push(child);
825
+ } else {
826
+ result.set(key, [child]);
827
+ }
828
+ }
829
+ return result;
830
+ }
707
831
  async transaction(fn) {
708
832
  const txQueue = new RequestEventQueue();
709
833
  const result = await this.adapter.transaction(async (txAdapter) => {
@@ -740,7 +864,7 @@ var init_db = __esm({
740
864
  }
741
865
  };
742
866
  SelectBuilder = class _SelectBuilder {
743
- constructor(adapter, hooks, ctx, queue, table, conditions, _options = {}, _rawWhere = [], _dialect = "sqlite") {
867
+ constructor(adapter, hooks, ctx, queue, table, conditions, _options = {}, _rawWhere = [], _dialect = "sqlite", _withRelations = [], _includeDeleted = false) {
744
868
  this.adapter = adapter;
745
869
  this.hooks = hooks;
746
870
  this.ctx = ctx;
@@ -750,6 +874,8 @@ var init_db = __esm({
750
874
  this._options = _options;
751
875
  this._rawWhere = _rawWhere;
752
876
  this._dialect = _dialect;
877
+ this._withRelations = _withRelations;
878
+ this._includeDeleted = _includeDeleted;
753
879
  }
754
880
  adapter;
755
881
  hooks;
@@ -760,6 +886,8 @@ var init_db = __esm({
760
886
  _options;
761
887
  _rawWhere;
762
888
  _dialect;
889
+ _withRelations;
890
+ _includeDeleted;
763
891
  _cloneWith(conditions, rawWhere) {
764
892
  return new _SelectBuilder(
765
893
  this.adapter,
@@ -770,7 +898,9 @@ var init_db = __esm({
770
898
  conditions,
771
899
  this._options,
772
900
  rawWhere ?? this._rawWhere,
773
- this._dialect
901
+ this._dialect,
902
+ this._withRelations,
903
+ this._includeDeleted
774
904
  );
775
905
  }
776
906
  _clone(patch) {
@@ -783,7 +913,60 @@ var init_db = __esm({
783
913
  this.conditions,
784
914
  { ...this._options, ...patch },
785
915
  this._rawWhere,
786
- this._dialect
916
+ this._dialect,
917
+ this._withRelations,
918
+ this._includeDeleted
919
+ );
920
+ }
921
+ /**
922
+ * Eager-load relations alongside the main query.
923
+ * One additional IN-query per relation — never N+1 regardless of row count.
924
+ *
925
+ * @example
926
+ * const posts = await db.from(postsTable).with({ author: true }).select()
927
+ * posts[0].author // → User | null (fully typed)
928
+ * posts[0].title // → string (original fields preserved)
929
+ */
930
+ with(relations) {
931
+ const keys = Object.keys(relations);
932
+ return new _SelectBuilder(
933
+ this.adapter,
934
+ this.hooks,
935
+ this.ctx,
936
+ this.queue,
937
+ // table type cast: the schema/relations are unchanged; only T changes in the generic
938
+ this.table,
939
+ this.conditions,
940
+ this._options,
941
+ this._rawWhere,
942
+ this._dialect,
943
+ [...this._withRelations, ...keys],
944
+ this._includeDeleted
945
+ );
946
+ }
947
+ /**
948
+ * Include soft-deleted rows in the query result.
949
+ * By default, tables with `.withSoftDelete()` automatically exclude rows
950
+ * where the soft-delete column is not null.
951
+ *
952
+ * Has no effect on tables without soft delete configured.
953
+ *
954
+ * @example
955
+ * const allUsers = await db.from(usersTable).withDeleted().select()
956
+ */
957
+ withDeleted() {
958
+ return new _SelectBuilder(
959
+ this.adapter,
960
+ this.hooks,
961
+ this.ctx,
962
+ this.queue,
963
+ this.table,
964
+ this.conditions,
965
+ this._options,
966
+ this._rawWhere,
967
+ this._dialect,
968
+ this._withRelations,
969
+ true
787
970
  );
788
971
  }
789
972
  /**
@@ -810,6 +993,17 @@ var init_db = __esm({
810
993
  whereRaw(sql, params) {
811
994
  return this._cloneWith(this.conditions, [...this._rawWhere, { sql, params }]);
812
995
  }
996
+ /**
997
+ * Apply SELECT DISTINCT — deduplicate rows in the result set.
998
+ * Combine with `.columns()` to deduplicate on specific columns.
999
+ *
1000
+ * @example
1001
+ * await db.from(usersTable).columns('name').distinct().select()
1002
+ * // → SELECT DISTINCT "name" FROM "users"
1003
+ */
1004
+ distinct() {
1005
+ return this._clone({ distinct: true });
1006
+ }
813
1007
  /** Limit the number of rows returned. Bound as a parameter — never interpolated. */
814
1008
  limit(n) {
815
1009
  return this._clone({ limit: n });
@@ -831,14 +1025,74 @@ var init_db = __esm({
831
1025
  page(page, size) {
832
1026
  return this._clone({ limit: size, offset: (page - 1) * size });
833
1027
  }
1028
+ columns(...cols) {
1029
+ const cloned = this._clone({ columns: cols });
1030
+ if (cols.length === 1) {
1031
+ return new ColumnRestrictedBuilder(cloned, cols[0]);
1032
+ }
1033
+ return cloned;
1034
+ }
834
1035
  /**
835
- * Restrict which columns are returned.
836
- * SELECT "id", "name" FROM "table" — instead of SELECT *
837
- *
838
- * Return type is narrowed to Pick<T, K> for full type safety.
1036
+ * Build SELECT SQL + params without executing the query.
1037
+ * Used internally by ColumnRestrictedBuilder.subquery().
839
1038
  */
840
- columns(...cols) {
841
- return this._clone({ columns: cols });
1039
+ /** Internal accessor for ColumnRestrictedBuilder / UnionBuilder — returns the adapter. */
1040
+ _getAdapter() {
1041
+ return this.adapter;
1042
+ }
1043
+ /** Internal accessor for ColumnRestrictedBuilder / UnionBuilder — returns the SQL dialect. */
1044
+ _getDialect() {
1045
+ return this._dialect;
1046
+ }
1047
+ /**
1048
+ * Returns the effective WHERE conditions, injecting the soft-delete IS NULL
1049
+ * filter when the table has a soft-delete column and .withDeleted() was not called.
1050
+ */
1051
+ _effectiveConditions() {
1052
+ const col = this.table.softDeleteColumn;
1053
+ if (col === null || this._includeDeleted) return this.conditions;
1054
+ const softFilter = { [col]: { op: "IS NULL" } };
1055
+ return mergeWhereAnd(this.conditions, softFilter);
1056
+ }
1057
+ _buildSelectSQL() {
1058
+ const conditions = this._effectiveConditions();
1059
+ if (this._rawWhere.length === 0) {
1060
+ return buildSelect(
1061
+ this.table.name,
1062
+ conditions,
1063
+ this._options,
1064
+ this._dialect
1065
+ );
1066
+ }
1067
+ const { sql: whereSql, params: whereParams } = buildWhere(
1068
+ conditions,
1069
+ this._dialect
1070
+ );
1071
+ const allWhereParts = [];
1072
+ const allParams = [...whereParams];
1073
+ if (whereSql) allWhereParts.push(whereSql);
1074
+ for (const raw of this._rawWhere) {
1075
+ allWhereParts.push(raw.sql);
1076
+ allParams.push(...raw.params);
1077
+ }
1078
+ const combinedWhere = allWhereParts.join(" AND ");
1079
+ const selectList = buildSelectListFromOptions(this._options);
1080
+ const selectKeyword = this._options.distinct ? "SELECT DISTINCT" : "SELECT";
1081
+ const parts = [
1082
+ combinedWhere ? `${selectKeyword} ${selectList} FROM "${this.table.name}" WHERE ${combinedWhere}` : `${selectKeyword} ${selectList} FROM "${this.table.name}"`
1083
+ ];
1084
+ if (this._options.orderBy && this._options.orderBy.length > 0) {
1085
+ const clause = this._options.orderBy.map(({ col, dir }) => `"${col}" ${dir}`).join(", ");
1086
+ parts.push(`ORDER BY ${clause}`);
1087
+ }
1088
+ if (this._options.limit !== void 0 || this._options.offset !== void 0) {
1089
+ const limitVal = this._options.limit !== void 0 ? Math.trunc(Math.max(0, this._options.limit)) : -1;
1090
+ parts.push(`LIMIT ${limitVal}`);
1091
+ if (this._options.offset !== void 0) {
1092
+ parts.push(`OFFSET ${Math.trunc(Math.max(0, this._options.offset))}`);
1093
+ }
1094
+ }
1095
+ return { sql: parts.join(" "), params: allParams };
842
1096
  }
843
1097
  /**
844
1098
  * Add a GROUP BY clause. Multiple columns are comma-separated.
@@ -875,7 +1129,7 @@ var init_db = __esm({
875
1129
  }));
876
1130
  const { sql, params } = buildSelect(
877
1131
  this.table.name,
878
- this.conditions,
1132
+ this._effectiveConditions(),
879
1133
  { ...this._options, aggregates: aggClauses },
880
1134
  this._dialect
881
1135
  );
@@ -913,7 +1167,7 @@ var init_db = __esm({
913
1167
  const alias = "_agg";
914
1168
  const colExpr = col ? `"${col}"` : "*";
915
1169
  const { sql: whereSql, params } = buildWhere(
916
- this.conditions,
1170
+ this._effectiveConditions(),
917
1171
  this._dialect
918
1172
  );
919
1173
  let sqlStr;
@@ -939,10 +1193,11 @@ var init_db = __esm({
939
1193
  async select() {
940
1194
  let finalSql;
941
1195
  let finalParams;
1196
+ const effectiveConditions = this._effectiveConditions();
942
1197
  if (this._rawWhere.length === 0) {
943
1198
  const { sql, params } = buildSelect(
944
1199
  this.table.name,
945
- this.conditions,
1200
+ effectiveConditions,
946
1201
  this._options,
947
1202
  this._dialect
948
1203
  );
@@ -950,7 +1205,7 @@ var init_db = __esm({
950
1205
  finalParams = params;
951
1206
  } else {
952
1207
  const { sql: whereSql, params: whereParams } = buildWhere(
953
- this.conditions,
1208
+ effectiveConditions,
954
1209
  this._dialect
955
1210
  );
956
1211
  const allWhereParts = [];
@@ -989,16 +1244,103 @@ var init_db = __esm({
989
1244
  finalSql = parts.join(" ");
990
1245
  finalParams = allParams;
991
1246
  }
992
- const rows = await this.adapter.query(finalSql, finalParams);
1247
+ const rawRows = await this.adapter.query(finalSql, finalParams);
1248
+ let rows;
993
1249
  if (this._options.columns && this._options.columns.length > 0) {
994
- return rows;
1250
+ rows = rawRows;
1251
+ } else {
1252
+ rows = rawRows.map((row) => deserializeRow(this.table, row));
995
1253
  }
996
- return rows.map((row) => deserializeRow(this.table, row));
1254
+ if (this._withRelations.length === 0) return rows;
1255
+ return this._executeWith(rows);
997
1256
  }
998
1257
  async first() {
999
1258
  const rows = await this.select();
1000
1259
  return rows[0] ?? null;
1001
1260
  }
1261
+ // ── Eager loading — _executeWith ────────────────────────────────────────
1262
+ async _executeWith(rows) {
1263
+ if (rows.length === 0) return rows;
1264
+ const mutableRows = rows.map((r) => ({ ...r }));
1265
+ for (const relationName of this._withRelations) {
1266
+ const meta = this.table.relations[relationName];
1267
+ if (!meta) continue;
1268
+ if (meta.kind === "manyToMany") {
1269
+ throw new Error(
1270
+ `manyToMany eager loading is not yet supported. Use loadRelation manually for relation '${relationName}'.`
1271
+ );
1272
+ }
1273
+ if (meta.kind === "belongsTo") {
1274
+ await this._attachBelongsTo(mutableRows, relationName, meta);
1275
+ } else if (meta.kind === "hasMany") {
1276
+ await this._attachHasMany(mutableRows, relationName, meta);
1277
+ }
1278
+ }
1279
+ return mutableRows;
1280
+ }
1281
+ async _attachBelongsTo(rows, relationName, meta) {
1282
+ const foreignTable = meta.getTable();
1283
+ const fkValues = rows.map((r) => r[meta.foreignKey]).filter((v) => v !== null && v !== void 0);
1284
+ if (fkValues.length === 0) {
1285
+ for (const r of rows) r[relationName] = null;
1286
+ return;
1287
+ }
1288
+ const uniqueFkValues = [...new Set(fkValues)];
1289
+ const pk = foreignTable.primaryKey;
1290
+ const baseConditions = { [pk]: { op: "IN", value: uniqueFkValues } };
1291
+ if (foreignTable.softDeleteColumn !== null) {
1292
+ baseConditions[foreignTable.softDeleteColumn] = { op: "IS NULL" };
1293
+ }
1294
+ const { sql, params } = buildSelect(
1295
+ foreignTable.name,
1296
+ baseConditions,
1297
+ {},
1298
+ this._dialect
1299
+ );
1300
+ const related = await this.adapter.query(sql, params);
1301
+ const relatedMap = /* @__PURE__ */ new Map();
1302
+ for (const r of related) {
1303
+ relatedMap.set(r[pk], deserializeRow(foreignTable, r));
1304
+ }
1305
+ for (const r of rows) {
1306
+ r[relationName] = relatedMap.get(r[meta.foreignKey]) ?? null;
1307
+ }
1308
+ }
1309
+ async _attachHasMany(rows, relationName, meta) {
1310
+ const foreignTable = meta.getTable();
1311
+ const pk = this.table.primaryKey;
1312
+ const pkValues = rows.map((r) => r[pk]).filter((v) => v !== null && v !== void 0);
1313
+ if (pkValues.length === 0) {
1314
+ for (const r of rows) r[relationName] = [];
1315
+ return;
1316
+ }
1317
+ const hasManyConditions = { [meta.foreignKey]: { op: "IN", value: pkValues } };
1318
+ if (foreignTable.softDeleteColumn !== null) {
1319
+ hasManyConditions[foreignTable.softDeleteColumn] = { op: "IS NULL" };
1320
+ }
1321
+ const { sql, params } = buildSelect(
1322
+ foreignTable.name,
1323
+ hasManyConditions,
1324
+ {},
1325
+ this._dialect
1326
+ );
1327
+ const related = await this.adapter.query(sql, params);
1328
+ const grouped = /* @__PURE__ */ new Map();
1329
+ for (const pkVal of pkValues) grouped.set(pkVal, []);
1330
+ for (const r of related) {
1331
+ const fkVal = r[meta.foreignKey];
1332
+ const deserialized = deserializeRow(foreignTable, r);
1333
+ const group = grouped.get(fkVal);
1334
+ if (group) {
1335
+ group.push(deserialized);
1336
+ } else {
1337
+ grouped.set(fkVal, [deserialized]);
1338
+ }
1339
+ }
1340
+ for (const r of rows) {
1341
+ r[relationName] = grouped.get(r[pk]) ?? [];
1342
+ }
1343
+ }
1002
1344
  async update(patch) {
1003
1345
  const hasConditions = !(Object.keys(this.conditions).length === 0 && this._rawWhere.length === 0);
1004
1346
  if (!hasConditions) {
@@ -1026,6 +1368,58 @@ var init_db = __esm({
1026
1368
  await this.hooks.runAfterUpdate(this.table, this.ctx, result, current, this.queue);
1027
1369
  return result;
1028
1370
  }
1371
+ /**
1372
+ * Update multiple rows atomically inside a single transaction.
1373
+ * Each row must include the primary key. beforeUpdate and afterUpdate hooks
1374
+ * run per row. If any row fails, the entire transaction rolls back.
1375
+ *
1376
+ * @example
1377
+ * const updated = await db.from(usersTable).updateMany([
1378
+ * { id: 1, name: 'Alice Updated' },
1379
+ * { id: 2, role: 'admin' },
1380
+ * ])
1381
+ */
1382
+ async updateMany(rows) {
1383
+ if (rows.length === 0) return [];
1384
+ const pk = this.table.primaryKey;
1385
+ const results = await this.adapter.transaction(async (txAdapter) => {
1386
+ const txQueue = new RequestEventQueue();
1387
+ const inner = [];
1388
+ for (const row of rows) {
1389
+ const pkValue = row[pk];
1390
+ if (pkValue === void 0 || pkValue === null) {
1391
+ throw new Error(
1392
+ `updateMany: row is missing primary key "${pk}" \u2014 every row must include the PK`
1393
+ );
1394
+ }
1395
+ const selectSql = `SELECT * FROM "${this.table.name}" WHERE "${pk}" = ?`;
1396
+ const currentRows = await txAdapter.query(selectSql, [pkValue]);
1397
+ if (currentRows.length === 0) {
1398
+ throw new Error(`updateMany: record with ${pk}=${String(pkValue)} not found`);
1399
+ }
1400
+ const current = deserializeRow(this.table, currentRows[0]);
1401
+ const { [pk]: _pk, ...patchWithoutPk } = row;
1402
+ const patch = patchWithoutPk;
1403
+ const finalPatch = await this.hooks.runBeforeUpdate(this.table, this.ctx, current, patch);
1404
+ const { sql, params } = buildUpdate(
1405
+ this.table.name,
1406
+ finalPatch,
1407
+ pk,
1408
+ pkValue
1409
+ );
1410
+ await txAdapter.execute(sql, params);
1411
+ const updatedRow = {
1412
+ ...current,
1413
+ ...finalPatch
1414
+ };
1415
+ const result = deserializeRow(this.table, updatedRow);
1416
+ await this.hooks.runAfterUpdate(this.table, this.ctx, result, current, txQueue);
1417
+ inner.push(result);
1418
+ }
1419
+ return inner;
1420
+ });
1421
+ return results;
1422
+ }
1029
1423
  async delete() {
1030
1424
  const hasConditions = !(Object.keys(this.conditions).length === 0 && this._rawWhere.length === 0);
1031
1425
  if (!hasConditions) {
@@ -1043,20 +1437,235 @@ var init_db = __esm({
1043
1437
  await this.hooks.runAfterDelete(this.table, this.ctx, current, this.queue);
1044
1438
  return current;
1045
1439
  }
1440
+ /**
1441
+ * Soft-delete rows by setting the soft-delete column to the current timestamp.
1442
+ * The table must have `.withSoftDelete()` configured — throws otherwise (at execute() time).
1443
+ *
1444
+ * Does NOT call beforeUpdate/afterUpdate hooks.
1445
+ * Without .where(), all rows in the table are soft-deleted.
1446
+ *
1447
+ * @example
1448
+ * await db.from(usersTable).softDelete().where({ id: 1 }).execute()
1449
+ */
1450
+ softDelete() {
1451
+ return new SoftDeleteBuilder(this.adapter, this.table, /* @__PURE__ */ new Date(), this._dialect);
1452
+ }
1453
+ /**
1454
+ * Restore soft-deleted rows by setting the soft-delete column back to null.
1455
+ * The table must have `.withSoftDelete()` configured — throws otherwise (at execute() time).
1456
+ *
1457
+ * @example
1458
+ * await db.from(usersTable).restore().where({ id: 1 }).execute()
1459
+ */
1460
+ restore() {
1461
+ return new SoftDeleteBuilder(this.adapter, this.table, null, this._dialect);
1462
+ }
1463
+ };
1464
+ ColumnRestrictedBuilder = class _ColumnRestrictedBuilder {
1465
+ // _builder is typed as SelectBuilder<unknown, ...> to avoid variance issues
1466
+ // when constructing from SelectBuilder<Pick<T,K>, ...> — the runtime shape is identical.
1467
+ constructor(_builder, _col) {
1468
+ this._builder = _builder;
1469
+ this._col = _col;
1470
+ }
1471
+ _builder;
1472
+ _col;
1473
+ where(conditions) {
1474
+ return new _ColumnRestrictedBuilder(
1475
+ this._builder.where(conditions),
1476
+ this._col
1477
+ );
1478
+ }
1479
+ limit(n) {
1480
+ return new _ColumnRestrictedBuilder(
1481
+ this._builder.limit(n),
1482
+ this._col
1483
+ );
1484
+ }
1485
+ orderBy(col, dir = "ASC") {
1486
+ return new _ColumnRestrictedBuilder(
1487
+ // _builder is SelectBuilder<unknown,...> so keyof unknown = never;
1488
+ // col is a valid schema key at runtime — cast is safe.
1489
+ this._builder.orderBy(col, dir),
1490
+ this._col
1491
+ );
1492
+ }
1493
+ /**
1494
+ * Build the SQL for this query as a subquery fragment.
1495
+ * The result can be used directly in WHERE IN / NOT IN conditions.
1496
+ *
1497
+ * @example
1498
+ * const activeIds = db.from(usersTable).columns('id').where({ active: true }).subquery()
1499
+ * // → SubqueryResult<'id', number>
1500
+ *
1501
+ * const posts = await db.from(postsTable)
1502
+ * .where({ authorId: { op: 'IN', value: activeIds } })
1503
+ * .select()
1504
+ */
1505
+ subquery() {
1506
+ const { sql, params } = this._builder._buildSelectSQL();
1507
+ return buildSubquery(sql, params, this._col);
1508
+ }
1509
+ /** Build raw SELECT SQL + params without parentheses (for UNION). */
1510
+ _buildRawSQL() {
1511
+ return this._builder._buildSelectSQL();
1512
+ }
1513
+ /**
1514
+ * Combine this query with another same-type column query via UNION (deduplicates).
1515
+ * Both sides must produce the same column type — enforced at compile time.
1516
+ *
1517
+ * @example
1518
+ * db.from(usersTable).columns('id')
1519
+ * .union(db.from(adminsTable).columns('id'))
1520
+ * .select()
1521
+ */
1522
+ union(other) {
1523
+ return new UnionBuilder(
1524
+ [this._buildRawSQL(), other._buildRawSQL()],
1525
+ "UNION",
1526
+ this._builder._getAdapter(),
1527
+ this._builder._getDialect()
1528
+ );
1529
+ }
1530
+ /**
1531
+ * Combine via UNION ALL — keeps duplicate rows.
1532
+ */
1533
+ unionAll(other) {
1534
+ return new UnionBuilder(
1535
+ [this._buildRawSQL(), other._buildRawSQL()],
1536
+ "UNION ALL",
1537
+ this._builder._getAdapter(),
1538
+ this._builder._getDialect()
1539
+ );
1540
+ }
1541
+ };
1542
+ SoftDeleteBuilder = class {
1543
+ constructor(adapter, table, _value, _dialect = "sqlite") {
1544
+ this.adapter = adapter;
1545
+ this.table = table;
1546
+ this._value = _value;
1547
+ this._dialect = _dialect;
1548
+ }
1549
+ adapter;
1550
+ table;
1551
+ _value;
1552
+ _dialect;
1553
+ _conditions = {};
1554
+ /**
1555
+ * Add WHERE conditions to scope which rows are soft-deleted / restored.
1556
+ * Multiple calls accumulate with AND.
1557
+ * Without .where(), all rows in the table are affected.
1558
+ */
1559
+ where(conditions) {
1560
+ this._conditions = mergeWhereAnd(this._conditions, conditions);
1561
+ return this;
1562
+ }
1563
+ /**
1564
+ * Execute the soft-delete or restore UPDATE.
1565
+ * Throws if the table has no softDeleteColumn configured.
1566
+ */
1567
+ async execute() {
1568
+ const col = this.table.softDeleteColumn;
1569
+ if (col === null) {
1570
+ throw new Error(
1571
+ `softDelete() called on table '${this.table.name}' which has no soft delete column. Add .withSoftDelete('deletedAt') to the table definition.`
1572
+ );
1573
+ }
1574
+ const { sql, params } = buildSoftDeleteUpdate(
1575
+ this.table.name,
1576
+ col,
1577
+ this._value,
1578
+ this._conditions,
1579
+ this._dialect
1580
+ );
1581
+ await this.adapter.execute(sql, params);
1582
+ }
1583
+ };
1584
+ UnionBuilder = class _UnionBuilder {
1585
+ constructor(_parts, _kind, _adapter, _dialect) {
1586
+ this._parts = _parts;
1587
+ this._kind = _kind;
1588
+ this._adapter = _adapter;
1589
+ this._dialect = _dialect;
1590
+ }
1591
+ _parts;
1592
+ _kind;
1593
+ _adapter;
1594
+ _dialect;
1595
+ _orderBy;
1596
+ _limit;
1597
+ /** Append another UNION (deduplicating) leg. */
1598
+ union(other) {
1599
+ return new _UnionBuilder(
1600
+ [...this._parts, other._buildRawSQL()],
1601
+ "UNION",
1602
+ this._adapter,
1603
+ this._dialect
1604
+ );
1605
+ }
1606
+ /** Append another UNION ALL (keep duplicates) leg. */
1607
+ unionAll(other) {
1608
+ return new _UnionBuilder(
1609
+ [...this._parts, other._buildRawSQL()],
1610
+ "UNION ALL",
1611
+ this._adapter,
1612
+ this._dialect
1613
+ );
1614
+ }
1615
+ /** Add ORDER BY to the entire UNION result. */
1616
+ orderBy(col, dir = "ASC") {
1617
+ const next = new _UnionBuilder(this._parts, this._kind, this._adapter, this._dialect);
1618
+ next._orderBy = { col, dir };
1619
+ next._limit = this._limit;
1620
+ return next;
1621
+ }
1622
+ /** Add LIMIT to the entire UNION result. */
1623
+ limit(n) {
1624
+ const next = new _UnionBuilder(this._parts, this._kind, this._adapter, this._dialect);
1625
+ next._orderBy = this._orderBy;
1626
+ next._limit = n;
1627
+ return next;
1628
+ }
1629
+ /** Execute the UNION query and return typed rows. */
1630
+ async select() {
1631
+ const { sql, params } = buildUnion(this._parts, this._kind, {
1632
+ orderBy: this._orderBy,
1633
+ limit: this._limit
1634
+ });
1635
+ return this._adapter.query(sql, params);
1636
+ }
1637
+ /**
1638
+ * Build the UNION as a subquery — wrapped in parentheses.
1639
+ * Usable in WHERE IN / NOT IN conditions.
1640
+ *
1641
+ * @example
1642
+ * const adminOrModIds = db.from(usersTable).columns('id').where({ role: 'admin' })
1643
+ * .union(db.from(usersTable).columns('id').where({ role: 'mod' }))
1644
+ * .subquery()
1645
+ */
1646
+ subquery() {
1647
+ const { sql, params } = buildUnion(this._parts, this._kind, {
1648
+ orderBy: this._orderBy,
1649
+ limit: this._limit
1650
+ });
1651
+ return buildSubquery(sql, params, "");
1652
+ }
1046
1653
  };
1047
1654
  InsertBuilder = class {
1048
- constructor(adapter, hooks, ctx, queue, table) {
1655
+ constructor(adapter, hooks, ctx, queue, table, dialect = "sqlite") {
1049
1656
  this.adapter = adapter;
1050
1657
  this.hooks = hooks;
1051
1658
  this.ctx = ctx;
1052
1659
  this.queue = queue;
1053
1660
  this.table = table;
1661
+ this.dialect = dialect;
1054
1662
  }
1055
1663
  adapter;
1056
1664
  hooks;
1057
1665
  ctx;
1058
1666
  queue;
1059
1667
  table;
1668
+ dialect;
1060
1669
  async insert(data) {
1061
1670
  const originalInput = { ...data };
1062
1671
  let current = { ...data };
@@ -1080,7 +1689,59 @@ var init_db = __esm({
1080
1689
  await this.hooks.runAfterInsert(this.table, this.ctx, result, originalInput, this.queue);
1081
1690
  return result;
1082
1691
  }
1083
- /** Serialize values for SQLite storage. Date → ISO string. */
1692
+ /**
1693
+ * Insert multiple rows in a single SQL statement.
1694
+ * beforeInsert and afterInsert hooks run per row.
1695
+ * Defaults (defaultFn / defaultValue) are applied per row.
1696
+ *
1697
+ * MySQL is not yet supported (no RETURNING *) — throws an informative error.
1698
+ *
1699
+ * @example
1700
+ * const users = await db.into(usersTable).insertMany([
1701
+ * { name: 'Alice', email: 'alice@example.com' },
1702
+ * { name: 'Bob', email: 'bob@example.com' },
1703
+ * ])
1704
+ */
1705
+ async insertMany(data) {
1706
+ if (data.length === 0) return [];
1707
+ if (this.dialect === "mysql") {
1708
+ throw new Error(
1709
+ "insertMany is not yet supported for MySQL \u2014 MySQL does not support RETURNING *. Use individual insert() calls inside a transaction() instead."
1710
+ );
1711
+ }
1712
+ const serializedRows = [];
1713
+ for (const row of data) {
1714
+ let processed = await this.hooks.runBeforeInsert(
1715
+ this.table,
1716
+ this.ctx,
1717
+ { ...row }
1718
+ );
1719
+ for (const [field, col] of Object.entries(this.table.schema)) {
1720
+ if (col.def.primaryKey && col.def.autoIncrement) continue;
1721
+ if (processed[field] === void 0) {
1722
+ if (col.def.defaultFn !== void 0) {
1723
+ ;
1724
+ processed[field] = col.def.defaultFn();
1725
+ } else if (col.def.defaultValue !== void 0) {
1726
+ ;
1727
+ processed[field] = col.def.defaultValue;
1728
+ }
1729
+ }
1730
+ }
1731
+ const serialized = this._serializeForInsert(processed);
1732
+ serializedRows.push(serialized);
1733
+ }
1734
+ const { sql, params } = buildInsertMany(this.table.name, serializedRows, true);
1735
+ const rawRows = await this.adapter.query(sql, params);
1736
+ const results = [];
1737
+ for (const rawRow of rawRows) {
1738
+ const deserialized = deserializeRow(this.table, rawRow);
1739
+ await this.hooks.runAfterInsert(this.table, this.ctx, deserialized, {}, this.queue);
1740
+ results.push(deserialized);
1741
+ }
1742
+ return results;
1743
+ }
1744
+ /** Serialize values for storage. Date → ISO string. Drops undefined values. */
1084
1745
  _serializeForInsert(data) {
1085
1746
  const result = {};
1086
1747
  for (const [key, val] of Object.entries(data)) {
@@ -1289,18 +1950,103 @@ var TableBuilder = class _TableBuilder {
1289
1950
  _schema;
1290
1951
  _hooks = [];
1291
1952
  _events = {};
1953
+ _relations = {};
1954
+ _softDeleteColumn = null;
1292
1955
  // Register table-level hook (no ctx)
1293
1956
  hook(handlers) {
1294
1957
  this._hooks.push(handlers);
1295
1958
  return this;
1296
1959
  }
1960
+ /**
1961
+ * Designate a column as the soft-delete timestamp.
1962
+ * Once set, all SELECTs automatically add `WHERE "col" IS NULL`.
1963
+ * Use `.withDeleted()` on the query to opt out.
1964
+ *
1965
+ * The column must exist in the schema (validated in `build()`).
1966
+ *
1967
+ * @example
1968
+ * const usersTable = defineTable('users', {
1969
+ * id: column.integer().primaryKey(),
1970
+ * deletedAt: column.timestamp().nullable(),
1971
+ * }).withSoftDelete('deletedAt').build()
1972
+ */
1973
+ withSoftDelete(col) {
1974
+ this._softDeleteColumn = col;
1975
+ return this;
1976
+ }
1297
1977
  emits(map) {
1298
1978
  const next = new _TableBuilder(this._name, this._schema);
1299
1979
  for (const h of this._hooks) next._hooks.push(h);
1300
1980
  next._events = map;
1981
+ for (const [k, v] of Object.entries(this._relations)) {
1982
+ ;
1983
+ next._relations[k] = v;
1984
+ }
1985
+ ;
1986
+ next._softDeleteColumn = this._softDeleteColumn;
1301
1987
  return next;
1302
1988
  }
1989
+ /**
1990
+ * Declare a belongs-to relation — FK lives on this table.
1991
+ * Returns a new builder with the relation type added to TRelations.
1992
+ *
1993
+ * @example
1994
+ * const postsTable = defineTable('posts', { authorId: column.integer() })
1995
+ * .belongsTo('author', () => usersTable, 'authorId')
1996
+ * .build()
1997
+ */
1998
+ belongsTo(name, getTable, foreignKey) {
1999
+ if (name in this._relations) {
2000
+ throw new Error(`Relation '${name}' is already defined on table '${this._name}'`);
2001
+ }
2002
+ const rel = { kind: "belongsTo", name, getTable, foreignKey };
2003
+ this._relations[name] = rel;
2004
+ return this;
2005
+ }
2006
+ /**
2007
+ * Declare a has-many relation — FK lives on the foreign table.
2008
+ * Returns a new builder with the relation type added to TRelations.
2009
+ *
2010
+ * @example
2011
+ * const usersTable = defineTable('users', { id: column.integer().primaryKey() })
2012
+ * .hasMany('posts', () => postsTable, 'authorId')
2013
+ * .build()
2014
+ */
2015
+ hasMany(name, getTable, foreignKey) {
2016
+ if (name in this._relations) {
2017
+ throw new Error(`Relation '${name}' is already defined on table '${this._name}'`);
2018
+ }
2019
+ const rel = { kind: "hasMany", name, getTable, foreignKey };
2020
+ this._relations[name] = rel;
2021
+ return this;
2022
+ }
2023
+ /**
2024
+ * Declare a many-to-many relation via a pivot table.
2025
+ *
2026
+ * @example
2027
+ * const postsTable = defineTable('posts', { ... })
2028
+ * .manyToMany('tags', () => tagsTable, postTagsTable, 'postId', 'tagId')
2029
+ * .build()
2030
+ */
2031
+ manyToMany(name, getTable, pivotTable, localKey, foreignKey) {
2032
+ if (name in this._relations) {
2033
+ throw new Error(`Relation '${name}' is already defined on table '${this._name}'`);
2034
+ }
2035
+ this._relations[name] = {
2036
+ kind: "manyToMany",
2037
+ name,
2038
+ getTable,
2039
+ foreignKey,
2040
+ pivot: { table: pivotTable, localKey, foreignKey }
2041
+ };
2042
+ return this;
2043
+ }
1303
2044
  build() {
2045
+ if (this._softDeleteColumn !== null && !(this._softDeleteColumn in this._schema)) {
2046
+ throw new Error(
2047
+ `withSoftDelete: column '${this._softDeleteColumn}' is not defined in table '${this._name}'. Add it to the schema: column.timestamp().nullable()`
2048
+ );
2049
+ }
1304
2050
  return {
1305
2051
  name: this._name,
1306
2052
  schema: this._schema,
@@ -1312,7 +2058,9 @@ var TableBuilder = class _TableBuilder {
1312
2058
  // At runtime it's an empty object (events hold only the string names, not payloads).
1313
2059
  // The field exists solely so TypeScript can infer TMap in onEvent() without
1314
2060
  // recomputing the conditional InferTableEvents each time.
1315
- _eventMap: {}
2061
+ _eventMap: {},
2062
+ relations: { ...this._relations },
2063
+ softDeleteColumn: this._softDeleteColumn
1316
2064
  };
1317
2065
  }
1318
2066
  _findPrimaryKey() {
@@ -1390,6 +2138,105 @@ function applyRedact(row, fields) {
1390
2138
  return copy;
1391
2139
  }
1392
2140
 
2141
+ // src/hooks/executor.ts
2142
+ var HookExecutor = class {
2143
+ // tableName → ordered array of module-level handlers
2144
+ registry = /* @__PURE__ */ new Map();
2145
+ // Set by dbPlugin at install() — used by app.register() to build audit closures.
2146
+ // undefined until dbPlugin installs (no-DB apps never set this).
2147
+ _adapter;
2148
+ // No EventBus held globally — queue is passed per runAfterX call
2149
+ constructor() {
2150
+ }
2151
+ setAdapter(adapter) {
2152
+ this._adapter = adapter;
2153
+ }
2154
+ getAdapter() {
2155
+ return this._adapter;
2156
+ }
2157
+ // Called by defineModule when a .hook() is registered
2158
+ registerModuleHook(tableName, handlers) {
2159
+ if (!this.registry.has(tableName)) this.registry.set(tableName, []);
2160
+ this.registry.get(tableName).push(handlers);
2161
+ }
2162
+ // ── Before operations — can transform data, can throw to cancel ──
2163
+ async runBeforeInsert(table, ctx, data) {
2164
+ let current = { ...data };
2165
+ for (const h of table.hooks) {
2166
+ if (!h.beforeInsert) continue;
2167
+ const result = await h.beforeInsert(current);
2168
+ if (result != null) current = result;
2169
+ }
2170
+ for (const h of this._moduleHandlers(table.name)) {
2171
+ if (!h.beforeInsert) continue;
2172
+ const result = await h.beforeInsert(ctx, current);
2173
+ if (result != null) current = result;
2174
+ }
2175
+ return current;
2176
+ }
2177
+ async runBeforeUpdate(table, ctx, current, patch) {
2178
+ let currentPatch = { ...patch };
2179
+ for (const h of table.hooks) {
2180
+ if (!h.beforeUpdate) continue;
2181
+ const result = await h.beforeUpdate(current, currentPatch);
2182
+ if (result != null) currentPatch = result;
2183
+ }
2184
+ for (const h of this._moduleHandlers(table.name)) {
2185
+ if (!h.beforeUpdate) continue;
2186
+ const result = await h.beforeUpdate(ctx, current, currentPatch);
2187
+ if (result != null) currentPatch = result;
2188
+ }
2189
+ return currentPatch;
2190
+ }
2191
+ async runBeforeDelete(table, ctx, current) {
2192
+ for (const h of table.hooks) {
2193
+ if (h.beforeDelete) await h.beforeDelete(current);
2194
+ }
2195
+ for (const h of this._moduleHandlers(table.name)) {
2196
+ if (h.beforeDelete) await h.beforeDelete(ctx, current);
2197
+ }
2198
+ }
2199
+ // ── After operations — side effects only, cannot cancel ──
2200
+ // queue: per-request RequestEventQueue, or undefined when called outside HTTP context.
2201
+ // When undefined (e.g. background jobs, tests, Phase 2 direct usage), events are dropped.
2202
+ async runAfterInsert(table, ctx, result, input, queue) {
2203
+ for (const h of table.hooks) {
2204
+ if (h.afterInsert) await h.afterInsert(result, input);
2205
+ }
2206
+ for (const h of this._moduleHandlers(table.name)) {
2207
+ if (h.afterInsert) await h.afterInsert(ctx, result, input);
2208
+ }
2209
+ if (table.events.afterInsert) {
2210
+ queue?.collect(table.events.afterInsert, result);
2211
+ }
2212
+ }
2213
+ async runAfterUpdate(table, ctx, result, before, queue) {
2214
+ for (const h of table.hooks) {
2215
+ if (h.afterUpdate) await h.afterUpdate(result, before);
2216
+ }
2217
+ for (const h of this._moduleHandlers(table.name)) {
2218
+ if (h.afterUpdate) await h.afterUpdate(ctx, result, before);
2219
+ }
2220
+ if (table.events.afterUpdate) {
2221
+ queue?.collect(table.events.afterUpdate, { before, after: result });
2222
+ }
2223
+ }
2224
+ async runAfterDelete(table, ctx, deleted, queue) {
2225
+ for (const h of table.hooks) {
2226
+ if (h.afterDelete) await h.afterDelete(deleted);
2227
+ }
2228
+ for (const h of this._moduleHandlers(table.name)) {
2229
+ if (h.afterDelete) await h.afterDelete(ctx, deleted);
2230
+ }
2231
+ if (table.events.afterDelete) {
2232
+ queue?.collect(table.events.afterDelete, deleted);
2233
+ }
2234
+ }
2235
+ _moduleHandlers(tableName) {
2236
+ return this.registry.get(tableName) ?? [];
2237
+ }
2238
+ };
2239
+
1393
2240
  // src/db/migrations/runner.ts
1394
2241
  import { readdir, readFile } from "fs/promises";
1395
2242
  import { join } from "path";
@@ -1977,6 +2824,7 @@ async function generateMigration(options) {
1977
2824
 
1978
2825
  // src/index.ts
1979
2826
  init_db();
2827
+ init_sql();
1980
2828
  init_events();
1981
2829
 
1982
2830
  // src/events/handler.ts
@@ -2671,105 +3519,6 @@ function createSystemCtx(extra) {
2671
3519
  init_db();
2672
3520
  init_events();
2673
3521
 
2674
- // src/hooks/executor.ts
2675
- var HookExecutor = class {
2676
- // tableName → ordered array of module-level handlers
2677
- registry = /* @__PURE__ */ new Map();
2678
- // Set by dbPlugin at install() — used by app.register() to build audit closures.
2679
- // undefined until dbPlugin installs (no-DB apps never set this).
2680
- _adapter;
2681
- // No EventBus held globally — queue is passed per runAfterX call
2682
- constructor() {
2683
- }
2684
- setAdapter(adapter) {
2685
- this._adapter = adapter;
2686
- }
2687
- getAdapter() {
2688
- return this._adapter;
2689
- }
2690
- // Called by defineModule when a .hook() is registered
2691
- registerModuleHook(tableName, handlers) {
2692
- if (!this.registry.has(tableName)) this.registry.set(tableName, []);
2693
- this.registry.get(tableName).push(handlers);
2694
- }
2695
- // ── Before operations — can transform data, can throw to cancel ──
2696
- async runBeforeInsert(table, ctx, data) {
2697
- let current = { ...data };
2698
- for (const h of table.hooks) {
2699
- if (!h.beforeInsert) continue;
2700
- const result = await h.beforeInsert(current);
2701
- if (result != null) current = result;
2702
- }
2703
- for (const h of this._moduleHandlers(table.name)) {
2704
- if (!h.beforeInsert) continue;
2705
- const result = await h.beforeInsert(ctx, current);
2706
- if (result != null) current = result;
2707
- }
2708
- return current;
2709
- }
2710
- async runBeforeUpdate(table, ctx, current, patch) {
2711
- let currentPatch = { ...patch };
2712
- for (const h of table.hooks) {
2713
- if (!h.beforeUpdate) continue;
2714
- const result = await h.beforeUpdate(current, currentPatch);
2715
- if (result != null) currentPatch = result;
2716
- }
2717
- for (const h of this._moduleHandlers(table.name)) {
2718
- if (!h.beforeUpdate) continue;
2719
- const result = await h.beforeUpdate(ctx, current, currentPatch);
2720
- if (result != null) currentPatch = result;
2721
- }
2722
- return currentPatch;
2723
- }
2724
- async runBeforeDelete(table, ctx, current) {
2725
- for (const h of table.hooks) {
2726
- if (h.beforeDelete) await h.beforeDelete(current);
2727
- }
2728
- for (const h of this._moduleHandlers(table.name)) {
2729
- if (h.beforeDelete) await h.beforeDelete(ctx, current);
2730
- }
2731
- }
2732
- // ── After operations — side effects only, cannot cancel ──
2733
- // queue: per-request RequestEventQueue, or undefined when called outside HTTP context.
2734
- // When undefined (e.g. background jobs, tests, Phase 2 direct usage), events are dropped.
2735
- async runAfterInsert(table, ctx, result, input, queue) {
2736
- for (const h of table.hooks) {
2737
- if (h.afterInsert) await h.afterInsert(result, input);
2738
- }
2739
- for (const h of this._moduleHandlers(table.name)) {
2740
- if (h.afterInsert) await h.afterInsert(ctx, result, input);
2741
- }
2742
- if (table.events.afterInsert) {
2743
- queue?.collect(table.events.afterInsert, result);
2744
- }
2745
- }
2746
- async runAfterUpdate(table, ctx, result, before, queue) {
2747
- for (const h of table.hooks) {
2748
- if (h.afterUpdate) await h.afterUpdate(result, before);
2749
- }
2750
- for (const h of this._moduleHandlers(table.name)) {
2751
- if (h.afterUpdate) await h.afterUpdate(ctx, result, before);
2752
- }
2753
- if (table.events.afterUpdate) {
2754
- queue?.collect(table.events.afterUpdate, { before, after: result });
2755
- }
2756
- }
2757
- async runAfterDelete(table, ctx, deleted, queue) {
2758
- for (const h of table.hooks) {
2759
- if (h.afterDelete) await h.afterDelete(deleted);
2760
- }
2761
- for (const h of this._moduleHandlers(table.name)) {
2762
- if (h.afterDelete) await h.afterDelete(ctx, deleted);
2763
- }
2764
- if (table.events.afterDelete) {
2765
- queue?.collect(table.events.afterDelete, deleted);
2766
- }
2767
- }
2768
- _moduleHandlers(tableName) {
2769
- return this.registry.get(tableName) ?? [];
2770
- }
2771
- };
2772
-
2773
3522
  // src/app/router.ts
2774
3523
  function matchPath(pattern, pathname) {
2775
3524
  const normPattern = pattern.length > 1 ? pattern.replace(/\/$/, "") : pattern;
@@ -5500,6 +6249,7 @@ export {
5500
6249
  EventBus,
5501
6250
  EventHandlerBuilder,
5502
6251
  ForbiddenError,
6252
+ HookExecutor,
5503
6253
  InMemoryEventBus,
5504
6254
  InMemoryStore,
5505
6255
  InsertBuilder,
@@ -5517,8 +6267,10 @@ export {
5517
6267
  ResourceBuilder,
5518
6268
  SQLiteAdapter,
5519
6269
  SelectBuilder,
6270
+ SoftDeleteBuilder,
5520
6271
  TooManyRequestsError,
5521
6272
  UnauthorizedError,
6273
+ UnionBuilder,
5522
6274
  UnprocessableError,
5523
6275
  ValidationError,
5524
6276
  Veln,
@@ -5526,6 +6278,8 @@ export {
5526
6278
  VelnDB,
5527
6279
  VelnError,
5528
6280
  bodySizeLimitPlugin,
6281
+ buildSubquery,
6282
+ buildUnion,
5529
6283
  column,
5530
6284
  compareSchemas,
5531
6285
  compressionPlugin,