tina4-nodejs 3.13.36 → 3.13.38

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.
Files changed (50) hide show
  1. package/CLAUDE.md +51 -19
  2. package/package.json +5 -3
  3. package/packages/cli/src/bin.ts +7 -0
  4. package/packages/cli/src/commands/init.ts +1 -0
  5. package/packages/cli/src/commands/metrics.ts +154 -0
  6. package/packages/cli/src/commands/routes.ts +3 -3
  7. package/packages/core/public/js/tina4-dev-admin.js +212 -212
  8. package/packages/core/public/js/tina4-dev-admin.min.js +212 -212
  9. package/packages/core/src/auth.ts +112 -2
  10. package/packages/core/src/cache.ts +2 -2
  11. package/packages/core/src/devAdmin.ts +75 -26
  12. package/packages/core/src/devMailbox.ts +4 -0
  13. package/packages/core/src/dotenv.ts +13 -4
  14. package/packages/core/src/events.ts +86 -4
  15. package/packages/core/src/graphql.ts +182 -128
  16. package/packages/core/src/htmlElement.ts +62 -3
  17. package/packages/core/src/index.ts +14 -8
  18. package/packages/core/src/logger.ts +1 -1
  19. package/packages/core/src/mcp.test.ts +1 -1
  20. package/packages/core/src/messenger.ts +111 -11
  21. package/packages/core/src/metrics.ts +232 -33
  22. package/packages/core/src/middleware.ts +129 -39
  23. package/packages/core/src/plan.ts +1 -1
  24. package/packages/core/src/queue.ts +1 -1
  25. package/packages/core/src/queueBackends/kafkaBackend.ts +1 -1
  26. package/packages/core/src/queueBackends/mongoBackend.ts +1 -1
  27. package/packages/core/src/queueBackends/rabbitmqBackend.ts +1 -1
  28. package/packages/core/src/rateLimiter.ts +1 -1
  29. package/packages/core/src/response.ts +90 -6
  30. package/packages/core/src/router.ts +2 -2
  31. package/packages/core/src/server.ts +26 -4
  32. package/packages/core/src/session.ts +130 -18
  33. package/packages/core/src/sessionHandlers/databaseHandler.ts +10 -0
  34. package/packages/core/src/sessionHandlers/mongoHandler.ts +21 -4
  35. package/packages/core/src/sessionHandlers/redisHandler.ts +28 -7
  36. package/packages/core/src/sessionHandlers/valkeyHandler.ts +27 -8
  37. package/packages/core/src/testClient.ts +1 -1
  38. package/packages/core/src/websocket.ts +247 -33
  39. package/packages/core/src/websocketBackplane.ts +210 -10
  40. package/packages/core/src/wsdl.ts +55 -21
  41. package/packages/orm/src/adapters/pg-types.d.ts +60 -0
  42. package/packages/orm/src/adapters/postgres.ts +26 -4
  43. package/packages/orm/src/adapters/sqlite.ts +112 -13
  44. package/packages/orm/src/baseModel.ts +8 -3
  45. package/packages/orm/src/cachedDatabase.ts +15 -6
  46. package/packages/orm/src/database.ts +257 -55
  47. package/packages/orm/src/index.ts +2 -1
  48. package/packages/orm/src/migration.ts +2 -2
  49. package/packages/orm/src/seeder.ts +443 -65
  50. package/packages/swagger/src/ui.ts +1 -1
@@ -41,10 +41,11 @@ export async function adapterFetch<T = Record<string, unknown>>(
41
41
  adapter: DatabaseAdapter, sql: string, params?: unknown[], limit?: number, skip?: number, noCache?: boolean,
42
42
  ): Promise<T[]> {
43
43
  // `noCache` is forwarded to the CachedDatabaseAdapter so a single read can
44
- // bypass the query cache; raw adapters ignore the extra trailing arg.
44
+ // bypass the query cache; raw adapters have no query cache and no `noCache`
45
+ // parameter, so it is simply not passed to the raw `fetch` path.
45
46
  return (adapter as any).fetchAsync
46
47
  ? await (adapter as any).fetchAsync(sql, params, limit, skip, noCache)
47
- : adapter.fetch<T>(sql, params, limit, skip, noCache);
48
+ : adapter.fetch<T>(sql, params, limit, skip);
48
49
  }
49
50
 
50
51
  export async function adapterQuery<T = Record<string, unknown>>(
@@ -142,9 +143,10 @@ const namedAdapters: Map<string, DatabaseAdapter> = new Map();
142
143
  * double-wraps. `options.sharedCache` backs all pooled connections with one
143
144
  * store so a write on any connection invalidates reads cached by all of them.
144
145
  *
145
- * Caching is ON by default (request-scoped, TINA4_AUTO_CACHING) and additionally
146
- * persistent when TINA4_DB_CACHE=true. Off-switch: TINA4_AUTO_CACHING=false (and
147
- * TINA4_DB_CACHE unset) then the wrapper passes everything straight through.
146
+ * Caching is OFF by default — both layers are opt-in. Turn the request-scoped
147
+ * layer on with TINA4_AUTO_CACHING=true (for read-heavy endpoints) and/or the
148
+ * persistent cross-request layer with TINA4_DB_CACHE=true. With both unset the
149
+ * wrapper passes everything straight through (no cached read-after-write footgun).
148
150
  */
149
151
  export function wrapWithCache(adapter: DatabaseAdapter, options?: CachedAdapterOptions): DatabaseAdapter {
150
152
  if (adapter instanceof CachedDatabaseAdapter) return adapter;
@@ -506,7 +508,7 @@ export class Database {
506
508
  * the same Database don't clobber each other. startTransaction() sets the
507
509
  * pin via .enterWith(); commit()/rollback() clear it.
508
510
  */
509
- private txStore: AsyncLocalStorage<{ adapter: DatabaseAdapter | null }> = new AsyncLocalStorage();
511
+ private txStore: AsyncLocalStorage<{ adapter: DatabaseAdapter | null; depth?: number }> = new AsyncLocalStorage();
510
512
 
511
513
  /**
512
514
  * Create a Database wrapping an existing adapter.
@@ -679,9 +681,22 @@ export class Database {
679
681
  async fetchOne<T = Record<string, unknown>>(sql: string, params?: unknown[], opts?: { noCache?: boolean }): Promise<T | null> {
680
682
  sql = stripTrailingSemicolons(sql);
681
683
  const adapter = this.getNextAdapter();
682
- return (adapter as any).fetchOneAsync
683
- ? await (adapter as any).fetchOneAsync<T>(sql, params, opts?.noCache)
684
- : adapter.fetchOne<T>(sql, params, opts?.noCache);
684
+ try {
685
+ // DB-contract A: route fetchOne through the SAME error-capturing path as
686
+ // fetch()/execute(). Pre-3.13.37 it called the adapter directly, so a SQL
687
+ // error raised (good) but db.getError() stayed null — the public API
688
+ // couldn't read the cause. Now it FAILS LOUD *and* populates lastError.
689
+ // The throw happens before the CachedDatabaseAdapter ever reaches its
690
+ // cache.set(), so a buried failure can never be cached either.
691
+ const row = (adapter as any).fetchOneAsync
692
+ ? await (adapter as any).fetchOneAsync(sql, params, opts?.noCache)
693
+ : adapter.fetchOne<T>(sql, params);
694
+ this.lastError = null;
695
+ return row;
696
+ } catch (e: any) {
697
+ this.lastError = e?.message ?? String(e);
698
+ throw e;
699
+ }
685
700
  }
686
701
 
687
702
  /**
@@ -707,8 +722,18 @@ export class Database {
707
722
  }
708
723
 
709
724
  /**
710
- * Execute a write statement. Returns true/false for simple writes.
711
- * If SQL contains RETURNING, CALL, EXEC, or SELECT, returns the result set.
725
+ * Execute a write statement.
726
+ *
727
+ * On SUCCESS returns `true` for simple writes, or the result set when the
728
+ * SQL contains RETURNING / CALL / EXEC / SELECT.
729
+ *
730
+ * On a SQL error (bad SQL, constraint violation, dead/aborted connection,
731
+ * missing driver) it FAILS LOUD: it records the cause on `lastError`
732
+ * (readable via `getError()`) and then RE-THROWS — it never swallows the
733
+ * error and returns `false`. This mirrors `fetch()`/`fetchOne()`, which
734
+ * already raise. Callers that need a boolean (e.g. ORM `save()`,
735
+ * `createTable()`, the migration runner, dev-admin/MCP DB tools) must
736
+ * `try/catch` and convert, rather than testing the return value.
712
737
  */
713
738
  async execute(sql: string, params?: unknown[]): Promise<boolean | unknown> {
714
739
  try {
@@ -726,7 +751,7 @@ export class Database {
726
751
  return true;
727
752
  } catch (e: any) {
728
753
  this.lastError = e?.message ?? String(e);
729
- return false;
754
+ throw e;
730
755
  }
731
756
  }
732
757
 
@@ -794,33 +819,93 @@ export class Database {
794
819
  * Start a transaction. Pins the adapter to the current async context for
795
820
  * the whole transaction so executes and the final commit/rollback all run
796
821
  * on the same connection (critical when pool > 0).
822
+ *
823
+ * Nested-begin guard (DB-contract C): a second startTransaction() on a
824
+ * context that already has a pinned adapter is a double-begin — the inner
825
+ * BEGIN silently commits or no-ops on most engines, leaving the connection
826
+ * mid-transaction with the caller none the wiser. We keep a depth counter and
827
+ * log a clear warning instead of silently re-beginning; the pin stays on the
828
+ * original adapter so the eventual commit/rollback still land on the right
829
+ * connection, and the matching inner commit just unwinds the depth.
797
830
  */
798
831
  async startTransaction(): Promise<void> {
832
+ const store = this.txStore.getStore();
833
+ if (store?.adapter) {
834
+ const depth = store.depth ?? 1;
835
+ console.warn(
836
+ "[tina4] startTransaction() called while a transaction is already open " +
837
+ `on this context (depth would become ${depth + 1}). Nested transactions ` +
838
+ "are not supported — the existing transaction stays open on its pinned " +
839
+ "connection and this nested begin is ignored. Commit or rollback the " +
840
+ "outer transaction first.",
841
+ );
842
+ store.depth = depth + 1;
843
+ return;
844
+ }
799
845
  // Pick an adapter using the normal selection logic, then pin it.
800
846
  const adapter = this.getNextAdapter();
801
- let store = this.txStore.getStore();
802
847
  if (store) {
803
848
  store.adapter = adapter;
849
+ store.depth = 1;
804
850
  } else {
805
- this.txStore.enterWith({ adapter });
851
+ this.txStore.enterWith({ adapter, depth: 1 });
806
852
  }
807
853
  await adapterStartTransaction(adapter);
808
854
  }
809
855
 
810
- /** Commit the current transaction and release the adapter pin. */
856
+ /**
857
+ * Commit the current transaction.
858
+ *
859
+ * FAIL LOUD (DB-contract C): if the underlying commit raises, capture
860
+ * lastError and RE-THROW — never swallow. On failure the transaction pin is
861
+ * RETAINED so the caller's follow-up rollback() lands on the SAME connection
862
+ * (clearing it would leak a dirty connection back into the pool and route the
863
+ * rollback to a different one). The pin is cleared ONLY on a successful
864
+ * commit. An inner commit of an ignored nested begin (depth > 1) just unwinds
865
+ * the depth — the outer commit is the real one.
866
+ */
811
867
  async commit(): Promise<void> {
812
- const adapter = this.getNextAdapter();
813
- await adapterCommit(adapter);
814
868
  const store = this.txStore.getStore();
815
- if (store) store.adapter = null;
869
+ const depth = store?.depth ?? 0;
870
+ if (depth > 1) {
871
+ // Inner commit of an ignored nested begin — just unwind the depth.
872
+ if (store) store.depth = depth - 1;
873
+ return;
874
+ }
875
+ const adapter = this.getNextAdapter();
876
+ try {
877
+ await adapterCommit(adapter);
878
+ this.lastError = null;
879
+ } catch (e: any) {
880
+ // Keep the pin so rollback() reaches this same connection.
881
+ this.lastError = e?.message ?? String(e);
882
+ throw e;
883
+ }
884
+ // Success — release the pin.
885
+ if (store) { store.adapter = null; store.depth = 0; }
816
886
  }
817
887
 
818
- /** Rollback the current transaction and release the adapter pin. */
888
+ /**
889
+ * Rollback the current transaction — the terminal cleanup of a transaction,
890
+ * so it ALWAYS clears the pin (and the depth counter), even after a failed
891
+ * commit (it routes to the retained pinned connection and cleans it up). If
892
+ * the underlying rollback itself raises, lastError is captured and the error
893
+ * re-thrown, but the pin is still released so a poisoned connection doesn't
894
+ * stay pinned to this context forever.
895
+ */
819
896
  async rollback(): Promise<void> {
820
897
  const adapter = this.getNextAdapter();
821
- await adapterRollback(adapter);
822
898
  const store = this.txStore.getStore();
823
- if (store) store.adapter = null;
899
+ try {
900
+ await adapterRollback(adapter);
901
+ this.lastError = null;
902
+ } catch (e: any) {
903
+ this.lastError = e?.message ?? String(e);
904
+ throw e;
905
+ } finally {
906
+ // Terminal cleanup — always release the pin.
907
+ if (store) { store.adapter = null; store.depth = 0; }
908
+ }
824
909
  }
825
910
 
826
911
  /** Check if a table exists. */
@@ -879,10 +964,10 @@ export class Database {
879
964
  /**
880
965
  * Return query cache statistics from the REAL cache backing this connection.
881
966
  *
882
- * The bound adapter is a CachedDatabaseAdapter (caching is ON by default —
883
- * request-scoped and additionally persistent when TINA4_DB_CACHE=true), so
884
- * we read the live counters + size + mode from it. Mirrors Python's
885
- * `Database.cache_stats()`: `{ enabled, mode, hits, misses, size, ttl }`.
967
+ * The bound adapter is a CachedDatabaseAdapter (caching is OFF by default —
968
+ * both layers opt-in: request-scoped via TINA4_AUTO_CACHING=true, persistent
969
+ * via TINA4_DB_CACHE=true), so we read the live counters + size + mode from it.
970
+ * Mirrors Python's `Database.cache_stats()`: `{ enabled, mode, hits, misses, size, ttl }`.
886
971
  */
887
972
  cacheStats(): { enabled: boolean; mode: "persistent" | "request" | "off"; hits: number; misses: number; size: number; ttl: number; backend?: string } {
888
973
  const adapter = this.getNextAdapter();
@@ -947,58 +1032,175 @@ export class Database {
947
1032
  }
948
1033
  }
949
1034
 
1035
+ /**
1036
+ * Best-effort MAX(pk) seed for a new sequence row. 0 if the table is
1037
+ * missing/empty. Mirrors Python's `_sequence_seed_value`.
1038
+ */
1039
+ private async sequenceSeedValue(adapter: DatabaseAdapter, table: string | undefined, pkColumn: string): Promise<number> {
1040
+ if (!table) return 0;
1041
+ try {
1042
+ const maxRow = await adapterFetchOne<Record<string, unknown>>(adapter,
1043
+ `SELECT MAX(${pkColumn}) AS max_id FROM ${table}`,
1044
+ );
1045
+ if (maxRow?.max_id != null) return Number(maxRow.max_id);
1046
+ } catch {
1047
+ // Table doesn't exist — start at 0.
1048
+ }
1049
+ return 0;
1050
+ }
1051
+
950
1052
  /**
951
1053
  * Atomically increment and return the next value from the sequence table.
952
1054
  *
953
- * If the sequence row doesn't exist yet, seeds it from MAX(pkColumn)
954
- * of the given table (or 0 if the table is empty/missing).
1055
+ * DB-contract B (no duplicate primary keys under concurrency): the old path
1056
+ * was read-increment-read across several `await` points, so two concurrent
1057
+ * async callers could read the same `current_value` and return the same id.
1058
+ * This now uses a single atomic increment-and-return per engine, pinned to
1059
+ * ONE adapter so the two statements (where two are needed) land on the same
1060
+ * connection:
1061
+ *
1062
+ * * SQLite: the SQLiteAdapter does ensure-table + seed + the atomic
1063
+ * `UPDATE ... RETURNING current_value` (>= 3.35; else `+1` then `SELECT`)
1064
+ * as ONE synchronous burst — no `await` between read and write, so no
1065
+ * other async task can interleave (Node analog of Python's _write_lock).
1066
+ * * MySQL: `UPDATE ... SET current_value = LAST_INSERT_ID(current_value + 1)`
1067
+ * then `SELECT LAST_INSERT_ID()` on the SAME pinned connection
1068
+ * (LAST_INSERT_ID is per-connection → race-safe).
1069
+ * * MSSQL: `UPDATE ... SET current_value = current_value + 1 OUTPUT
1070
+ * inserted.current_value ...` — one atomic statement.
1071
+ *
1072
+ * Seeding is always a race-safe insert-if-absent (INSERT OR IGNORE /
1073
+ * INSERT IGNORE / INSERT ... WHERE NOT EXISTS) seeded from MAX(pk), run
1074
+ * BEFORE the increment — never a read-then-insert gap. On error we RAISE
1075
+ * (never silently fall back to 1).
955
1076
  */
956
1077
  private async sequenceNext(seqName: string, table?: string, pkColumn = "id"): Promise<number> {
957
- await this.ensureSequenceTable();
1078
+ // Pin a single adapter for the whole sequence operation so seed +
1079
+ // increment + read all hit the SAME connection. Inside an active
1080
+ // transaction the adapter is already pinned; otherwise pin here and
1081
+ // release in the finally so the pool can rotate afterwards.
1082
+ const store = this.txStore.getStore();
1083
+ const alreadyPinned = !!store?.adapter;
958
1084
  const adapter = this.getNextAdapter();
1085
+ if (!alreadyPinned) {
1086
+ if (store) store.adapter = adapter;
1087
+ else this.txStore.enterWith({ adapter });
1088
+ }
959
1089
 
960
- // Check if the sequence row exists
961
- const existing = await adapterFetchOne<Record<string, unknown>>(adapter,
962
- "SELECT current_value FROM tina4_sequences WHERE seq_name = ?",
963
- [seqName]
964
- );
965
-
966
- if (existing == null) {
967
- // Seed from current MAX
968
- let seedValue = 0;
969
- if (table) {
970
- try {
971
- const maxRow = await adapterFetchOne<Record<string, unknown>>(adapter,
972
- `SELECT MAX(${pkColumn}) AS max_id FROM ${table}`
973
- );
974
- if (maxRow?.max_id != null) {
975
- seedValue = Number(maxRow.max_id);
976
- }
977
- } catch {
978
- // Table doesn't exist — start at 0
1090
+ try {
1091
+ if (this.dbType === "sqlite") {
1092
+ // SQLite: the adapter does ensure-table + seed + atomic increment as one
1093
+ // synchronous burst. We compute the seed first (its own read can yield,
1094
+ // but that's fine — INSERT OR IGNORE makes the seed idempotent and the
1095
+ // increment itself is the atomic step).
1096
+ const seed = await this.sequenceSeedValue(adapter, table, pkColumn);
1097
+ const raw = (adapter as any).getAdapter ? (adapter as any).getAdapter() : adapter;
1098
+ if (typeof raw.sequenceNextSqlite === "function") {
1099
+ return raw.sequenceNextSqlite(seqName, seed);
979
1100
  }
1101
+ // Defensive fallback if the underlying adapter lacks the atomic helper.
1102
+ return this.sequenceNextGeneric(adapter, seqName, seed);
1103
+ }
1104
+
1105
+ await this.ensureSequenceTable();
1106
+ if (this.dbType === "mysql") {
1107
+ return this.sequenceNextMysql(adapter, seqName, table, pkColumn);
980
1108
  }
1109
+ if (this.dbType === "mssql") {
1110
+ return this.sequenceNextMssql(adapter, seqName, table, pkColumn);
1111
+ }
1112
+ // Any other engine routed here (defensive) — generic atomic-ish path.
1113
+ const seed = await this.sequenceSeedValue(adapter, table, pkColumn);
1114
+ return this.sequenceNextGeneric(adapter, seqName, seed);
1115
+ } finally {
1116
+ if (!alreadyPinned) {
1117
+ const s = this.txStore.getStore();
1118
+ if (s) s.adapter = null;
1119
+ }
1120
+ }
1121
+ }
1122
+
1123
+ /**
1124
+ * MySQL atomic sequence step. LAST_INSERT_ID(expr) stashes `expr` in this
1125
+ * CONNECTION's session var and returns it, so the read-back is per-connection
1126
+ * and race-safe. Runs on the pinned adapter.
1127
+ */
1128
+ private async sequenceNextMysql(adapter: DatabaseAdapter, seqName: string, table: string | undefined, pkColumn: string): Promise<number> {
1129
+ const seed = await this.sequenceSeedValue(adapter, table, pkColumn);
1130
+ // Race-safe seed: INSERT IGNORE is a no-op if the row exists.
1131
+ await adapterExecute(adapter,
1132
+ "INSERT IGNORE INTO tina4_sequences (seq_name, current_value) VALUES (?, ?)",
1133
+ [seqName, seed],
1134
+ );
1135
+ try { await adapterCommit(adapter); } catch { /* no active transaction */ }
1136
+ await adapterExecute(adapter,
1137
+ "UPDATE tina4_sequences SET current_value = LAST_INSERT_ID(current_value + 1) WHERE seq_name = ?",
1138
+ [seqName],
1139
+ );
1140
+ try { await adapterCommit(adapter); } catch { /* no active transaction */ }
1141
+ const row = await adapterFetchOne<Record<string, unknown>>(adapter, "SELECT LAST_INSERT_ID() AS next_id");
1142
+ if (!row) {
1143
+ throw new Error(`getNextId: LAST_INSERT_ID() returned nothing for '${seqName}'`);
1144
+ }
1145
+ return Number(Object.values(row)[0]);
1146
+ }
981
1147
 
1148
+ /**
1149
+ * MSSQL atomic sequence step. A single `UPDATE ... OUTPUT
1150
+ * inserted.current_value` increments and returns the new value in one
1151
+ * statement. Runs on the pinned adapter.
1152
+ */
1153
+ private async sequenceNextMssql(adapter: DatabaseAdapter, seqName: string, table: string | undefined, pkColumn: string): Promise<number> {
1154
+ const seed = await this.sequenceSeedValue(adapter, table, pkColumn);
1155
+ // Race-safe seed: INSERT only when absent (single statement).
1156
+ await adapterExecute(adapter,
1157
+ "INSERT INTO tina4_sequences (seq_name, current_value) " +
1158
+ "SELECT ?, ? WHERE NOT EXISTS (SELECT 1 FROM tina4_sequences WHERE seq_name = ?)",
1159
+ [seqName, seed, seqName],
1160
+ );
1161
+ try { await adapterCommit(adapter); } catch { /* no active transaction */ }
1162
+ // Single atomic statement: increment + return the new value via OUTPUT.
1163
+ const result = await adapterExecute(adapter,
1164
+ "UPDATE tina4_sequences SET current_value = current_value + 1 " +
1165
+ "OUTPUT inserted.current_value AS next_id WHERE seq_name = ?",
1166
+ [seqName],
1167
+ ) as any;
1168
+ const rows = result?.rows ?? result?.records ?? null;
1169
+ if (rows && rows.length > 0 && rows[0].next_id != null) {
1170
+ return Number(rows[0].next_id);
1171
+ }
1172
+ throw new Error(`getNextId: OUTPUT produced no row for sequence '${seqName}'`);
1173
+ }
1174
+
1175
+ /**
1176
+ * Defensive generic atomic-ish path for any engine not otherwise special-cased
1177
+ * (and the SQLite fallback if the adapter lacks the synchronous helper). Seeds
1178
+ * if absent, then increments and reads on the pinned connection.
1179
+ */
1180
+ private async sequenceNextGeneric(adapter: DatabaseAdapter, seqName: string, seed: number): Promise<number> {
1181
+ try {
982
1182
  await adapterExecute(adapter,
983
1183
  "INSERT INTO tina4_sequences (seq_name, current_value) VALUES (?, ?)",
984
- [seqName, seedValue]
1184
+ [seqName, seed],
985
1185
  );
986
1186
  try { await adapterCommit(adapter); } catch { /* no active transaction */ }
1187
+ } catch {
1188
+ // Row likely already exists (PK conflict) — fine, keep going.
1189
+ try { await adapterRollback(adapter); } catch { /* nothing to roll back */ }
987
1190
  }
988
-
989
- // Atomic increment
990
1191
  await adapterExecute(adapter,
991
1192
  "UPDATE tina4_sequences SET current_value = current_value + 1 WHERE seq_name = ?",
992
- [seqName]
1193
+ [seqName],
993
1194
  );
994
1195
  try { await adapterCommit(adapter); } catch { /* no active transaction */ }
995
-
996
- // Read the new value
997
1196
  const row = await adapterFetchOne<Record<string, unknown>>(adapter,
998
1197
  "SELECT current_value FROM tina4_sequences WHERE seq_name = ?",
999
- [seqName]
1198
+ [seqName],
1000
1199
  );
1001
- return row?.current_value != null ? Number(row.current_value) : 1;
1200
+ if (!row || row.current_value == null) {
1201
+ throw new Error(`getNextId: sequence row '${seqName}' missing`);
1202
+ }
1203
+ return Number(row.current_value);
1002
1204
  }
1003
1205
 
1004
1206
  /**
@@ -51,7 +51,8 @@ export { SQLTranslator, QueryCache } from "./sqlTranslation.js";
51
51
  export { CachedDatabaseAdapter } from "./cachedDatabase.js";
52
52
  export type { CachedAdapterOptions } from "./cachedDatabase.js";
53
53
  export { FakeData } from "./fakeData.js";
54
- export { seedTable, seedOrm } from "./seeder.js";
54
+ export { seedTable, seedOrm, seedModels } from "./seeder.js";
55
+ export type { SeedSummary, SeedOptions } from "./seeder.js";
55
56
 
56
57
  // Database adapters
57
58
  export { SQLiteAdapter } from "./adapters/sqlite.js";
@@ -46,10 +46,10 @@ async function firebirdColumnExists(
46
46
  table: string,
47
47
  column: string,
48
48
  ): Promise<boolean> {
49
- const rows = await (db as any).queryAsync<Record<string, unknown>>(
49
+ const rows = (await (db as any).queryAsync(
50
50
  "SELECT 1 FROM RDB$RELATION_FIELDS WHERE RDB$RELATION_NAME = ? AND TRIM(RDB$FIELD_NAME) = ?",
51
51
  [table.toUpperCase(), column.toUpperCase()],
52
- );
52
+ )) as unknown[];
53
53
  return rows.length > 0;
54
54
  }
55
55