tina4-nodejs 3.13.37 → 3.13.39
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/CLAUDE.md +65 -20
- package/README.md +6 -6
- package/package.json +5 -3
- package/packages/cli/src/bin.ts +7 -0
- package/packages/cli/src/commands/init.ts +1 -0
- package/packages/cli/src/commands/metrics.ts +154 -0
- package/packages/cli/src/commands/routes.ts +3 -3
- package/packages/core/src/api.ts +64 -1
- package/packages/core/src/auth.ts +112 -2
- package/packages/core/src/cache.ts +2 -2
- package/packages/core/src/devAdmin.ts +66 -44
- package/packages/core/src/devMailbox.ts +4 -0
- package/packages/core/src/dotenv.ts +13 -4
- package/packages/core/src/events.ts +86 -4
- package/packages/core/src/graphql.ts +182 -128
- package/packages/core/src/htmlElement.ts +62 -3
- package/packages/core/src/index.ts +21 -10
- package/packages/core/src/logger.ts +85 -28
- package/packages/core/src/mcp.test.ts +1 -1
- package/packages/core/src/mcp.ts +25 -8
- package/packages/core/src/messenger.ts +111 -11
- package/packages/core/src/metrics.ts +557 -98
- package/packages/core/src/middleware.ts +130 -40
- package/packages/core/src/plan.ts +1 -1
- package/packages/core/src/queue.ts +1 -1
- package/packages/core/src/queueBackends/kafkaBackend.ts +98 -1
- package/packages/core/src/queueBackends/mongoBackend.ts +1 -1
- package/packages/core/src/queueBackends/rabbitmqBackend.ts +1 -1
- package/packages/core/src/rateLimiter.ts +1 -1
- package/packages/core/src/response.ts +90 -6
- package/packages/core/src/router.ts +56 -8
- package/packages/core/src/server.ts +138 -23
- package/packages/core/src/session.ts +130 -18
- package/packages/core/src/sessionHandlers/databaseHandler.ts +10 -0
- package/packages/core/src/sessionHandlers/mongoHandler.ts +21 -4
- package/packages/core/src/sessionHandlers/redisHandler.ts +28 -7
- package/packages/core/src/sessionHandlers/valkeyHandler.ts +27 -8
- package/packages/core/src/testClient.ts +1 -1
- package/packages/core/src/types.ts +17 -2
- package/packages/core/src/websocket.ts +666 -42
- package/packages/core/src/websocketBackplane.ts +210 -10
- package/packages/core/src/websocketConnection.ts +6 -0
- package/packages/core/src/wsdl.ts +55 -21
- package/packages/orm/src/adapters/pg-types.d.ts +60 -0
- package/packages/orm/src/adapters/postgres.ts +26 -4
- package/packages/orm/src/adapters/sqlite.ts +112 -13
- package/packages/orm/src/baseModel.ts +175 -25
- package/packages/orm/src/cachedDatabase.ts +15 -6
- package/packages/orm/src/database.ts +257 -55
- package/packages/orm/src/index.ts +6 -1
- package/packages/orm/src/migration.ts +151 -24
- package/packages/orm/src/queryBuilder.ts +14 -2
- package/packages/orm/src/seeder.ts +443 -65
- package/packages/orm/src/types.ts +7 -0
- package/packages/orm/src/validation.ts +14 -0
- 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
|
|
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
|
|
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
|
|
146
|
-
*
|
|
147
|
-
*
|
|
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
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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.
|
|
711
|
-
*
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
883
|
-
* request-scoped
|
|
884
|
-
* we read the live counters + size + mode from it.
|
|
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
|
-
*
|
|
954
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
/**
|
|
@@ -39,6 +39,10 @@ export {
|
|
|
39
39
|
createMigration,
|
|
40
40
|
status,
|
|
41
41
|
Migration,
|
|
42
|
+
splitStatements,
|
|
43
|
+
normalizeQuotes,
|
|
44
|
+
sortMigrationFiles,
|
|
45
|
+
shouldSkipCreateTable,
|
|
42
46
|
} from "./migration.js";
|
|
43
47
|
export type { MigrationResult, MigrationStatus } from "./migration.js";
|
|
44
48
|
export { AutoCrud, generateCrudRoutes } from "./autoCrud.js";
|
|
@@ -51,7 +55,8 @@ export { SQLTranslator, QueryCache } from "./sqlTranslation.js";
|
|
|
51
55
|
export { CachedDatabaseAdapter } from "./cachedDatabase.js";
|
|
52
56
|
export type { CachedAdapterOptions } from "./cachedDatabase.js";
|
|
53
57
|
export { FakeData } from "./fakeData.js";
|
|
54
|
-
export { seedTable, seedOrm } from "./seeder.js";
|
|
58
|
+
export { seedTable, seedOrm, seedModels } from "./seeder.js";
|
|
59
|
+
export type { SeedSummary, SeedOptions } from "./seeder.js";
|
|
55
60
|
|
|
56
61
|
// Database adapters
|
|
57
62
|
export { SQLiteAdapter } from "./adapters/sqlite.js";
|