tina4-nodejs 3.13.137 → 3.13.138

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 (242) hide show
  1. package/CLAUDE.md +33 -3
  2. package/COMMERCIAL-LICENSE.md +8 -0
  3. package/LICENSE +373 -0
  4. package/NOTICE +11 -0
  5. package/README.md +24 -2
  6. package/package.json +44 -6
  7. package/packages/cli/dist/bin.js +19466 -18048
  8. package/packages/cli/src/bin.ts +8 -0
  9. package/packages/cli/src/commands/build.ts +8 -0
  10. package/packages/cli/src/commands/generate.ts +8 -0
  11. package/packages/cli/src/commands/init.ts +8 -0
  12. package/packages/cli/src/commands/lint.ts +8 -0
  13. package/packages/cli/src/commands/migrate.ts +8 -0
  14. package/packages/cli/src/commands/migrateCreate.ts +8 -0
  15. package/packages/cli/src/commands/migrateRollback.ts +8 -0
  16. package/packages/cli/src/commands/migrateStatus.ts +8 -0
  17. package/packages/cli/src/commands/queue.ts +8 -0
  18. package/packages/cli/src/commands/routes.ts +8 -0
  19. package/packages/cli/src/commands/seed.ts +8 -0
  20. package/packages/cli/src/commands/serve.ts +11 -0
  21. package/packages/cli/src/commands/test.ts +8 -0
  22. package/packages/core/dist/index.js +19458 -18043
  23. package/packages/core/gallery/auth/src/routes/api/gallery/auth/login/post.ts +8 -0
  24. package/packages/core/gallery/auth/src/routes/api/gallery/auth/verify/get.ts +8 -0
  25. package/packages/core/gallery/auth/src/routes/gallery/auth/get.ts +8 -0
  26. package/packages/core/gallery/database/src/routes/api/gallery/db/notes/get.ts +8 -0
  27. package/packages/core/gallery/database/src/routes/api/gallery/db/notes/post.ts +8 -0
  28. package/packages/core/gallery/database/src/routes/api/gallery/db/tables/get.ts +8 -0
  29. package/packages/core/gallery/error-overlay/src/routes/api/gallery/crash/get.ts +8 -0
  30. package/packages/core/gallery/orm/src/routes/api/gallery/products/get.ts +8 -0
  31. package/packages/core/gallery/orm/src/routes/api/gallery/products/post.ts +8 -0
  32. package/packages/core/gallery/queue/src/lib/queueDb.ts +8 -0
  33. package/packages/core/gallery/queue/src/routes/api/gallery/queue/consume/post.ts +8 -0
  34. package/packages/core/gallery/queue/src/routes/api/gallery/queue/fail/post.ts +8 -0
  35. package/packages/core/gallery/queue/src/routes/api/gallery/queue/produce/post.ts +8 -0
  36. package/packages/core/gallery/queue/src/routes/api/gallery/queue/retry/post.ts +8 -0
  37. package/packages/core/gallery/queue/src/routes/api/gallery/queue/status/get.ts +8 -0
  38. package/packages/core/gallery/queue/src/routes/gallery/queue/get.ts +8 -0
  39. package/packages/core/gallery/rest-api/src/routes/api/gallery/hello/get.ts +8 -0
  40. package/packages/core/gallery/rest-api/src/routes/api/gallery/hello/post.ts +8 -0
  41. package/packages/core/gallery/templates/src/routes/gallery/page/get.ts +8 -0
  42. package/packages/core/gallery/templates/src/templates/gallery_page.twig +8 -0
  43. package/packages/core/public/THIRD-PARTY-NOTICES.md +55 -0
  44. package/packages/core/public/bundled-component-provenance.json +48 -0
  45. package/packages/core/public/js/tina4js.min.js +13 -3
  46. package/packages/core/public/licenses/swagger-ui-3.10.0-original-notice.txt +11 -0
  47. package/packages/core/public/licenses/swagger-ui-5.17.14-NOTICE.txt +2 -0
  48. package/packages/core/public/licenses/swagger-ui-Apache-2.0.txt +202 -0
  49. package/packages/core/public/licenses/tina4-css-MIT.txt +21 -0
  50. package/packages/core/public/licenses/tina4-dev-admin-components.json +240 -0
  51. package/packages/core/public/licenses/tina4-dev-admin-third-party.txt +919 -0
  52. package/packages/core/public/licenses/tina4-js-MIT.txt +20 -0
  53. package/packages/core/src/__feedback/widget.js +8 -0
  54. package/packages/core/src/_missing.ts +8 -0
  55. package/packages/core/src/ai.ts +8 -0
  56. package/packages/core/src/aiClient.ts +8 -0
  57. package/packages/core/src/api.ts +15 -3
  58. package/packages/core/src/auth.ts +21 -8
  59. package/packages/core/src/authGate.ts +8 -0
  60. package/packages/core/src/background.ts +8 -0
  61. package/packages/core/src/cache.ts +43 -4
  62. package/packages/core/src/constants.ts +8 -0
  63. package/packages/core/src/container.ts +8 -0
  64. package/packages/core/src/context/chunker.ts +8 -0
  65. package/packages/core/src/context/index.ts +8 -0
  66. package/packages/core/src/devAdmin.ts +185 -59
  67. package/packages/core/src/devMailbox.ts +8 -0
  68. package/packages/core/src/dispatchPipeline.ts +8 -0
  69. package/packages/core/src/docs.ts +8 -0
  70. package/packages/core/src/docsAutoDiscovery.ts +8 -0
  71. package/packages/core/src/docsParser.ts +8 -0
  72. package/packages/core/src/docsScanner.ts +44 -0
  73. package/packages/core/src/docsSignatures.ts +8 -0
  74. package/packages/core/src/dotenv.ts +8 -0
  75. package/packages/core/src/env.ts +8 -0
  76. package/packages/core/src/errorOverlay.ts +8 -0
  77. package/packages/core/src/events.ts +8 -0
  78. package/packages/core/src/fakeData.ts +8 -0
  79. package/packages/core/src/feedback.ts +8 -0
  80. package/packages/core/src/graphql.ts +16 -5
  81. package/packages/core/src/health.ts +8 -0
  82. package/packages/core/src/htmlElement.ts +8 -0
  83. package/packages/core/src/i18n.ts +8 -0
  84. package/packages/core/src/index.ts +8 -0
  85. package/packages/core/src/job.ts +8 -0
  86. package/packages/core/src/logger.ts +8 -0
  87. package/packages/core/src/mcp.test.ts +8 -0
  88. package/packages/core/src/mcp.ts +8 -0
  89. package/packages/core/src/messenger.ts +143 -86
  90. package/packages/core/src/metrics.ts +13 -0
  91. package/packages/core/src/middleware.ts +1178 -1096
  92. package/packages/core/src/mqtt.ts +8 -0
  93. package/packages/core/src/mqttMessage.ts +8 -0
  94. package/packages/core/src/optionalPackage.ts +48 -0
  95. package/packages/core/src/plan.ts +8 -0
  96. package/packages/core/src/portTakeover.ts +8 -0
  97. package/packages/core/src/projectIndex.ts +8 -0
  98. package/packages/core/src/projectIndexExtractors.ts +8 -0
  99. package/packages/core/src/projectIndexStorage.ts +8 -0
  100. package/packages/core/src/push.ts +8 -0
  101. package/packages/core/src/queue.ts +8 -0
  102. package/packages/core/src/queueBackends/kafkaBackend.ts +8 -0
  103. package/packages/core/src/queueBackends/liteBackend.ts +8 -0
  104. package/packages/core/src/queueBackends/mongoBackend.ts +21 -8
  105. package/packages/core/src/queueBackends/rabbitmqBackend.ts +8 -0
  106. package/packages/core/src/rateLimiter.ts +8 -0
  107. package/packages/core/src/request.ts +59 -10
  108. package/packages/core/src/response.ts +79 -5
  109. package/packages/core/src/routeDiscovery.ts +8 -0
  110. package/packages/core/src/router.ts +8 -0
  111. package/packages/core/src/server.ts +73 -24
  112. package/packages/core/src/service.ts +8 -0
  113. package/packages/core/src/session.ts +8 -0
  114. package/packages/core/src/sessionHandlers/childError.ts +8 -0
  115. package/packages/core/src/sessionHandlers/databaseHandler.ts +8 -0
  116. package/packages/core/src/sessionHandlers/memcachedHandler.ts +8 -0
  117. package/packages/core/src/sessionHandlers/mongoClient.ts +8 -0
  118. package/packages/core/src/sessionHandlers/mongoHandler.ts +8 -0
  119. package/packages/core/src/sessionHandlers/respClient.ts +8 -0
  120. package/packages/core/src/sessionHandlers/sqlClient.ts +8 -0
  121. package/packages/core/src/sessionHandlers/syncBridge.ts +8 -0
  122. package/packages/core/src/sessionHandlers/syncSocket.ts +8 -0
  123. package/packages/core/src/sessionHandlers/valkeyHandler.ts +8 -0
  124. package/packages/core/src/sso.ts +8 -0
  125. package/packages/core/src/static.ts +8 -0
  126. package/packages/core/src/test.ts +8 -0
  127. package/packages/core/src/testClient.ts +8 -0
  128. package/packages/core/src/testing.ts +8 -0
  129. package/packages/core/src/transport.ts +369 -0
  130. package/packages/core/src/trustedProxy.ts +8 -0
  131. package/packages/core/src/types.ts +13 -0
  132. package/packages/core/src/validator.ts +8 -0
  133. package/packages/core/src/version.ts +8 -0
  134. package/packages/core/src/websocket.ts +8 -0
  135. package/packages/core/src/websocketBackplane.ts +49 -32
  136. package/packages/core/src/websocketConnection.ts +8 -0
  137. package/packages/core/src/wsdl.ts +197 -115
  138. package/packages/core/templates/errors/302.twig +8 -0
  139. package/packages/core/templates/errors/401.twig +8 -0
  140. package/packages/core/templates/errors/403.twig +8 -0
  141. package/packages/core/templates/errors/404.twig +8 -0
  142. package/packages/core/templates/errors/500.twig +8 -0
  143. package/packages/core/templates/errors/502.twig +8 -0
  144. package/packages/core/templates/errors/503.twig +8 -0
  145. package/packages/core/templates/errors/base.twig +8 -0
  146. package/packages/frond/dist/index.js +151 -17
  147. package/packages/frond/src/engine.ts +177 -17
  148. package/packages/frond/src/index.ts +8 -0
  149. package/packages/orm/dist/index.js +8125 -6708
  150. package/packages/orm/src/adapters/firebird.ts +50 -3
  151. package/packages/orm/src/adapters/mongodb.ts +8 -0
  152. package/packages/orm/src/adapters/mssql.ts +29 -7
  153. package/packages/orm/src/adapters/mysql.ts +17 -1
  154. package/packages/orm/src/adapters/odbc.ts +11 -0
  155. package/packages/orm/src/adapters/pg-types.d.ts +8 -0
  156. package/packages/orm/src/adapters/postgres.ts +34 -8
  157. package/packages/orm/src/adapters/sqlDialect.ts +57 -0
  158. package/packages/orm/src/adapters/sqlite.ts +26 -0
  159. package/packages/orm/src/autoCrud.ts +78 -36
  160. package/packages/orm/src/baseModel.ts +61 -29
  161. package/packages/orm/src/cachedDatabase.ts +67 -1
  162. package/packages/orm/src/connectTimeout.ts +8 -0
  163. package/packages/orm/src/database.ts +649 -401
  164. package/packages/orm/src/databaseResult.ts +8 -0
  165. package/packages/orm/src/databaseUrl.ts +8 -0
  166. package/packages/orm/src/docstore.ts +23 -1
  167. package/packages/orm/src/fakeData.ts +8 -0
  168. package/packages/orm/src/graph/adapters/arango.ts +8 -0
  169. package/packages/orm/src/graph/adapters/bolt.ts +8 -0
  170. package/packages/orm/src/graph/adapters/ultipa.ts +8 -0
  171. package/packages/orm/src/graph/connectTimeout.ts +8 -0
  172. package/packages/orm/src/graph/errors.ts +8 -0
  173. package/packages/orm/src/graph/graphAdapter.ts +8 -0
  174. package/packages/orm/src/graph/graphDatabase.ts +8 -0
  175. package/packages/orm/src/graph/graphUrl.ts +10 -1
  176. package/packages/orm/src/graph/shapes.ts +8 -0
  177. package/packages/orm/src/index.ts +147 -139
  178. package/packages/orm/src/migration.ts +8 -0
  179. package/packages/orm/src/model.ts +8 -0
  180. package/packages/orm/src/modelCollection.ts +8 -0
  181. package/packages/orm/src/point.ts +8 -0
  182. package/packages/orm/src/query.ts +149 -33
  183. package/packages/orm/src/queryBuilder.ts +8 -0
  184. package/packages/orm/src/realtime/index.ts +8 -0
  185. package/packages/orm/src/realtime/models/attachment.ts +8 -0
  186. package/packages/orm/src/realtime/models/channel.ts +8 -0
  187. package/packages/orm/src/realtime/models/channelMember.ts +8 -0
  188. package/packages/orm/src/realtime/models/message.ts +8 -0
  189. package/packages/orm/src/realtime/models/workspace.ts +8 -0
  190. package/packages/orm/src/realtime/realtime.ts +8 -0
  191. package/packages/orm/src/realtime/storage.ts +43 -12
  192. package/packages/orm/src/seeder.ts +8 -0
  193. package/packages/orm/src/seederTable.ts +8 -0
  194. package/packages/orm/src/seederTypes.ts +8 -0
  195. package/packages/orm/src/sqlTranslator.ts +185 -8
  196. package/packages/orm/src/types.ts +15 -0
  197. package/packages/orm/src/validation.ts +8 -0
  198. package/packages/swagger/src/generator.ts +8 -0
  199. package/packages/swagger/src/index.ts +8 -0
  200. package/packages/swagger/src/ui.ts +8 -0
  201. package/types/core/src/background.d.ts +0 -16
  202. package/types/core/src/cache.d.ts +6 -0
  203. package/types/core/src/devAdmin.d.ts +18 -12
  204. package/types/core/src/docs.d.ts +0 -17
  205. package/types/core/src/errorOverlay.d.ts +0 -24
  206. package/types/core/src/events.d.ts +0 -12
  207. package/types/core/src/feedback.d.ts +0 -23
  208. package/types/core/src/graphql.d.ts +0 -21
  209. package/types/core/src/htmlElement.d.ts +0 -15
  210. package/types/core/src/messenger.d.ts +12 -1
  211. package/types/core/src/metrics.d.ts +2 -0
  212. package/types/core/src/middleware.d.ts +45 -1
  213. package/types/core/src/mqtt.d.ts +0 -33
  214. package/types/core/src/optionalPackage.d.ts +25 -0
  215. package/types/core/src/plan.d.ts +0 -10
  216. package/types/core/src/projectIndex.d.ts +0 -10
  217. package/types/core/src/queueBackends/mongoBackend.d.ts +2 -0
  218. package/types/core/src/request.d.ts +12 -0
  219. package/types/core/src/response.d.ts +13 -0
  220. package/types/core/src/server.d.ts +19 -0
  221. package/types/core/src/sessionHandlers/childError.d.ts +0 -15
  222. package/types/core/src/transport.d.ts +28 -0
  223. package/types/core/src/trustedProxy.d.ts +0 -16
  224. package/types/core/src/types.d.ts +5 -0
  225. package/types/core/src/websocketBackplane.d.ts +1 -0
  226. package/types/core/src/wsdl.d.ts +8 -22
  227. package/types/frond/src/engine.d.ts +7 -1
  228. package/types/orm/src/adapters/firebird.d.ts +12 -0
  229. package/types/orm/src/adapters/mssql.d.ts +11 -0
  230. package/types/orm/src/adapters/mysql.d.ts +2 -0
  231. package/types/orm/src/adapters/postgres.d.ts +11 -0
  232. package/types/orm/src/adapters/sqlDialect.d.ts +25 -0
  233. package/types/orm/src/adapters/sqlite.d.ts +7 -0
  234. package/types/orm/src/baseModel.d.ts +5 -0
  235. package/types/orm/src/cachedDatabase.d.ts +27 -0
  236. package/types/orm/src/database.d.ts +46 -7
  237. package/types/orm/src/graph/graphUrl.d.ts +0 -8
  238. package/types/orm/src/index.d.ts +1 -1
  239. package/types/orm/src/query.d.ts +48 -2
  240. package/types/orm/src/realtime/storage.d.ts +2 -1
  241. package/types/orm/src/sqlTranslator.d.ts +70 -18
  242. package/types/orm/src/types.d.ts +6 -0
@@ -1,9 +1,18 @@
1
+ /*
2
+ Copyright (c) 2026 Code Infinity
3
+ SPDX-License-Identifier: MPL-2.0
4
+ This Source Code Form is subject to the terms of the Mozilla Public
5
+ License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+ */
8
+
1
9
  import { AsyncLocalStorage } from "node:async_hooks";
2
10
  import type { DatabaseAdapter, DatabaseResult as DatabaseWriteResult, ColumnInfo, FieldDefinition } from "./types.js";
3
11
  import { DatabaseResult } from "./databaseResult.js";
4
12
  import { DatabaseUrl } from "./databaseUrl.js";
5
13
  import { CachedDatabaseAdapter, type CachedAdapterOptions } from "./cachedDatabase.js";
6
- import { QueryCache } from "./sqlTranslator.js";
14
+ import { QueryCache, SQLTranslator } from "./sqlTranslator.js";
15
+ import { quoteIdentifierAnsi, assertColumnNames } from "./adapters/sqlDialect.js";
7
16
 
8
17
  /**
9
18
  * v3.13.12 — strip trailing `;` and whitespace from user-supplied SQL
@@ -49,6 +58,16 @@ export async function adapterFetch<T = Record<string, unknown>>(
49
58
  : adapter.fetch<T>(sql, params, limit, skip);
50
59
  }
51
60
 
61
+ /**
62
+ * Quote a table/column name the ORM emits, in the bound adapter's dialect
63
+ * (MySQL backticks, Firebird upper-cased, `"name"` elsewhere). Python master:
64
+ * `Database.quote_identifier`. Only for framework-emitted identifiers - a
65
+ * user's raw SQL is never rewritten.
66
+ */
67
+ export function quoteIdentifier(adapter: DatabaseAdapter | null | undefined, name: string): string {
68
+ return adapter?.quoteIdentifier ? adapter.quoteIdentifier(name) : quoteIdentifierAnsi(name);
69
+ }
70
+
52
71
  export async function adapterQuery<T = Record<string, unknown>>(
53
72
  adapter: DatabaseAdapter, sql: string, params?: unknown[],
54
73
  ): Promise<T[]> {
@@ -65,6 +84,37 @@ export async function adapterFetchOne<T = Record<string, unknown>>(
65
84
  : adapter.fetchOne<T>(sql, params);
66
85
  }
67
86
 
87
+ /**
88
+ * Run a row-producing statement through an adapter's own row-returning execute
89
+ * (SQLite's executeRows, Firebird's executeRowsAsync, or the cache wrapper
90
+ * forwarding either). `undefined` means the adapter has none and NOTHING ran.
91
+ */
92
+ export async function adapterExecuteRows(
93
+ adapter: DatabaseAdapter, sql: string, params?: unknown[],
94
+ ): Promise<unknown[] | undefined> {
95
+ const a = adapter as unknown as {
96
+ executeRowsAsync?: (s: string, p?: unknown[]) => Promise<unknown[] | undefined>;
97
+ executeRows?: (s: string, p?: unknown[]) => unknown[] | undefined;
98
+ };
99
+ if (typeof a.executeRowsAsync === "function") return a.executeRowsAsync(sql, params);
100
+ if (typeof a.executeRows === "function") return a.executeRows(sql, params);
101
+ return undefined;
102
+ }
103
+
104
+ /**
105
+ * The rows inside a driver's execute() result: pg and tedious carry `.rows`,
106
+ * mysql2 and odbc return the row array itself, and a MySQL CALL returns
107
+ * `[[rows], OkPacket]`. `undefined` when the result carries no row list.
108
+ */
109
+ export function rowsFromExecuteResult(result: unknown): unknown[] | undefined {
110
+ if (Array.isArray(result)) {
111
+ if (result.length > 0 && Array.isArray(result[0])) return result[0] as unknown[];
112
+ return Array.from(result).filter((r) => r !== null && typeof r === "object");
113
+ }
114
+ const rows = (result as { rows?: unknown } | null | undefined)?.rows;
115
+ return Array.isArray(rows) ? rows : undefined;
116
+ }
117
+
68
118
  export async function adapterExecute(
69
119
  adapter: DatabaseAdapter, sql: string, params?: unknown[],
70
120
  ): Promise<unknown> {
@@ -214,7 +264,9 @@ export async function probeTotal(
214
264
  const suffix = alias ? ` AS ${alias}` : "";
215
265
  const rows = await adapterFetch(
216
266
  adapter,
217
- `SELECT COUNT(*) AS tina4_total FROM (${sql}\n)${suffix}`,
267
+ // ORDER BY cannot change a COUNT, and SQL Server rejects it inside a
268
+ // derived table, so the probe drops a trailing top-level one.
269
+ `SELECT COUNT(*) AS tina4_total FROM (${SQLTranslator.stripTrailingOrderBy(sql)}\n)${suffix}`,
218
270
  params,
219
271
  undefined,
220
272
  undefined,
@@ -500,6 +552,12 @@ export class Database {
500
552
  * the same Database don't clobber each other. startTransaction() sets the
501
553
  * pin via .enterWith(); commit()/rollback() clear it.
502
554
  */
555
+ private leases = new Map<DatabaseAdapter, object>();
556
+ private discardedSlots = new Set<number>();
557
+ private repairs = new Map<number, Promise<void>>();
558
+ private leaseOwner = new AsyncLocalStorage<object>();
559
+ private operationStore = new AsyncLocalStorage<{ adapter: DatabaseAdapter; active: boolean }>();
560
+
503
561
  private txStore: AsyncLocalStorage<{ adapter: DatabaseAdapter | null; depth?: number }> = new AsyncLocalStorage();
504
562
 
505
563
  /**
@@ -590,20 +648,79 @@ export class Database {
590
648
  * that adapter is returned for every call so the whole transaction is
591
649
  * atomic on one connection. Otherwise pooled mode round-robins.
592
650
  */
593
- private getNextAdapter(): DatabaseAdapter {
594
- const pinned = this.txStore.getStore()?.adapter;
595
- if (pinned) return pinned;
651
+ private availableAdapter(): DatabaseAdapter {
652
+ if (this._poolSize === 0) return this.adapter!;
653
+ for (let offset = 0; offset < this._poolSize; offset++) {
654
+ const idx = (this.poolIndex + offset) % this._poolSize;
655
+ const adapter = this.pool[idx];
656
+ if (adapter && !this.leases.has(adapter)) {
657
+ this.poolIndex = (idx + 1) % this._poolSize;
658
+ return adapter;
659
+ }
660
+ }
661
+ throw new Error("Database connection pool exhausted");
662
+ }
596
663
 
597
- if (this._poolSize > 0) {
598
- const idx = this.poolIndex;
599
- this.poolIndex = (this.poolIndex + 1) % this._poolSize;
600
- return this.pool[idx] as DatabaseAdapter;
664
+ private reserveAdapter(owner: object): DatabaseAdapter {
665
+ const adapter = this.availableAdapter();
666
+ if (this._poolSize > 0) this.leases.set(adapter, owner);
667
+ return adapter;
668
+ }
669
+
670
+ private async releaseAdapter(adapter: DatabaseAdapter, discard = false): Promise<void> {
671
+ if (this._poolSize === 0) return;
672
+ try {
673
+ if (discard) {
674
+ const idx = this.pool.indexOf(adapter);
675
+ if (idx >= 0) {
676
+ this.pool[idx] = null;
677
+ this.discardedSlots.add(idx);
678
+ adapter.close();
679
+ }
680
+ }
681
+ } finally { this.leases.delete(adapter); }
682
+ }
683
+
684
+ private async repairDiscardedConnections(): Promise<void> {
685
+ for (const idx of this.discardedSlots) {
686
+ let repair = this.repairs.get(idx);
687
+ if (!repair) {
688
+ repair = (async () => {
689
+ if (!this.adapterFactory) throw new Error("Database connection pool cannot reconnect");
690
+ const replacement = await this.adapterFactory();
691
+ this.pool[idx] = replacement;
692
+ this.discardedSlots.delete(idx);
693
+ })();
694
+ this.repairs.set(idx, repair);
695
+ }
696
+ try { await repair; }
697
+ finally { if (this.repairs.get(idx) === repair) this.repairs.delete(idx); }
601
698
  }
699
+ }
602
700
 
603
- return this.adapter!;
701
+ private async withConnectionOperation<T>(fn: () => Promise<T>): Promise<T> {
702
+ if (this._poolSize === 0 || this.txStore.getStore()?.adapter || this.operationStore.getStore()?.active) return fn();
703
+ if (this.discardedSlots.size) await this.repairDiscardedConnections();
704
+ const adapter = this.reserveAdapter({});
705
+ const operation = { adapter, active: true };
706
+ return this.operationStore.run(operation, async () => {
707
+ try { return await fn(); }
708
+ finally {
709
+ operation.active = false;
710
+ if (this.txStore.getStore()?.adapter !== adapter) await this.releaseAdapter(adapter);
711
+ }
712
+ });
604
713
  }
605
714
 
606
- /** Get the underlying adapter (for advanced / escape-hatch usage). */
715
+ private getNextAdapter(): DatabaseAdapter {
716
+ const pinned = this.txStore.getStore()?.adapter;
717
+ if (pinned) return pinned;
718
+ const operation = this.operationStore.getStore();
719
+ if (operation?.active) return operation.adapter;
720
+ return this.availableAdapter();
721
+ }
722
+
723
+ /** Non-owning peek excluding leased connections. Use checkout/checkin for concurrent raw driver work. */
607
724
  getAdapter(): DatabaseAdapter {
608
725
  return this.getNextAdapter();
609
726
  }
@@ -629,16 +746,18 @@ export class Database {
629
746
  * The caller is responsible for returning it via checkin().
630
747
  */
631
748
  checkout(): DatabaseAdapter {
632
- return this.getNextAdapter();
749
+ let owner = this.leaseOwner.getStore();
750
+ if (!owner) { owner = {}; this.leaseOwner.enterWith(owner); }
751
+ return this.reserveAdapter(owner);
633
752
  }
634
753
 
635
- /**
636
- * Return a borrowed connection to the pool.
637
- * For round-robin pools this is a no-op (connections stay in the pool array),
638
- * but the method exists for API parity and future pooling strategies.
639
- */
640
- checkin(_adapter: DatabaseAdapter): void {
641
- // No-op for round-robin pool — connections are not removed on checkout.
754
+ /** Only the borrowing async context can release an explicit lease. */
755
+ checkin(adapter: DatabaseAdapter): void {
756
+ if (this._poolSize === 0) return;
757
+ if (!this.leases.has(adapter) || this.leases.get(adapter) !== this.leaseOwner.getStore()) {
758
+ throw new Error("Database connection lease owner mismatch");
759
+ }
760
+ this.leases.delete(adapter);
642
761
  }
643
762
 
644
763
  /**
@@ -669,7 +788,9 @@ export class Database {
669
788
  * `fetchAll` deliberately does NOT inherit the cap — see below.
670
789
  */
671
790
  async fetch(sql: string, params?: unknown[], limit?: number, offset?: number, opts?: { noCache?: boolean }): Promise<DatabaseResult> {
672
- return this._fetchWithLimit(sql, params, limit ?? DEFAULT_ROW_CAP, offset, opts);
791
+ return this.withConnectionOperation(async () => {
792
+ return this._fetchWithLimit(sql, params, limit ?? DEFAULT_ROW_CAP, offset, opts);
793
+ });
673
794
  }
674
795
 
675
796
  /**
@@ -682,23 +803,35 @@ export class Database {
682
803
  * inherit it and stop returning every row.
683
804
  */
684
805
  private async _fetchWithLimit(sql: string, params?: unknown[], limit?: number, offset?: number, opts?: { noCache?: boolean }): Promise<DatabaseResult> {
685
- // v3.13.12: strip trailing `;` before the adapter wraps with COUNT(*)
686
- // or appends LIMIT/OFFSET. Without this, `"SELECT * FROM t;"` becomes
687
- // `"SELECT * FROM t; LIMIT 100 OFFSET 0"` — a syntax error.
688
- sql = stripTrailingSemicolons(sql);
689
- const adapter = this.getNextAdapter();
690
- try {
691
- // `opts.noCache` bypasses the query cache for this one call — no lookup,
692
- // no store, run directly (mirrors the Python master's `no_cache`).
693
- const rows = await adapterFetch(adapter, sql, params, limit, offset, opts?.noCache);
694
- this.lastError = null;
695
- const total = await probeTotal(adapter, sql, params, limit);
696
- return new DatabaseResult(rows, undefined, total, limit, offset, adapter, sql);
697
- } catch (e: any) {
698
- // v3.13.11 #49.2: fetch() records last_error like execute() does.
699
- this.lastError = e?.message ?? String(e);
700
- throw e;
701
- }
806
+ return this.withConnectionOperation(async () => {
807
+ // v3.13.12: strip trailing `;` before the adapter wraps with COUNT(*)
808
+ // or appends LIMIT/OFFSET. Without this, `"SELECT * FROM t;"` becomes
809
+ // `"SELECT * FROM t; LIMIT 100 OFFSET 0"` — a syntax error.
810
+ sql = stripTrailingSemicolons(sql);
811
+ const adapter = this.getNextAdapter();
812
+ try {
813
+ // A WRITE that returns rows (INSERT/UPDATE/DELETE ... RETURNING, a
814
+ // data-modifying CTE) runs exactly once: no LIMIT/OFFSET (a syntax error on
815
+ // DML), no COUNT probe (it would re-run the statement), never cached, and
816
+ // committed like execute() (tina4-python#133 contract).
817
+ if (SQLTranslator.isWriteStatement(sql)) {
818
+ const written = await adapterFetch(adapter, sql, params, undefined, undefined, true);
819
+ await this.commitFetchWrite(adapter);
820
+ this.lastError = null;
821
+ return new DatabaseResult(written, undefined, written.length, undefined, undefined, adapter, sql);
822
+ }
823
+ // `opts.noCache` bypasses the query cache for this one call — no lookup,
824
+ // no store, run directly (mirrors the Python master's `no_cache`).
825
+ const rows = await adapterFetch(adapter, sql, params, limit, offset, opts?.noCache);
826
+ this.lastError = null;
827
+ const total = await probeTotal(adapter, sql, params, limit);
828
+ return new DatabaseResult(rows, undefined, total, limit, offset, adapter, sql);
829
+ } catch (e: any) {
830
+ // v3.13.11 #49.2: fetch() records last_error like execute() does.
831
+ this.lastError = e?.message ?? String(e);
832
+ throw e;
833
+ }
834
+ });
702
835
  }
703
836
 
704
837
  /**
@@ -709,24 +842,30 @@ export class Database {
709
842
  * (mirrors the Python master's `no_cache`). Default preserves caching.
710
843
  */
711
844
  async fetchOne<T = Record<string, unknown>>(sql: string, params?: unknown[], opts?: { noCache?: boolean }): Promise<T | null> {
712
- sql = stripTrailingSemicolons(sql);
713
- const adapter = this.getNextAdapter();
714
- try {
715
- // DB-contract A: route fetchOne through the SAME error-capturing path as
716
- // fetch()/execute(). Pre-3.13.37 it called the adapter directly, so a SQL
717
- // error raised (good) but db.getError() stayed null — the public API
718
- // couldn't read the cause. Now it FAILS LOUD *and* populates lastError.
719
- // The throw happens before the CachedDatabaseAdapter ever reaches its
720
- // cache.set(), so a buried failure can never be cached either.
721
- const row = (adapter as any).fetchOneAsync
722
- ? await (adapter as any).fetchOneAsync(sql, params, opts?.noCache)
723
- : adapter.fetchOne<T>(sql, params);
724
- this.lastError = null;
725
- return row;
726
- } catch (e: any) {
727
- this.lastError = e?.message ?? String(e);
728
- throw e;
729
- }
845
+ return this.withConnectionOperation(async () => {
846
+ sql = stripTrailingSemicolons(sql);
847
+ const adapter = this.getNextAdapter();
848
+ try {
849
+ // DB-contract A: route fetchOne through the SAME error-capturing path as
850
+ // fetch()/execute(). Pre-3.13.37 it called the adapter directly, so a SQL
851
+ // error raised (good) but db.getError() stayed null — the public API
852
+ // couldn't read the cause. Now it FAILS LOUD *and* populates lastError.
853
+ // The throw happens before the CachedDatabaseAdapter ever reaches its
854
+ // cache.set(), so a buried failure can never be cached either.
855
+ // A write through fetchOne (the natural home of `INSERT ... RETURNING id`)
856
+ // is never cached and commits like execute() (tina4-python#133 contract).
857
+ const isWrite = SQLTranslator.isWriteStatement(sql);
858
+ const row = (adapter as any).fetchOneAsync
859
+ ? await (adapter as any).fetchOneAsync(sql, params, isWrite || opts?.noCache)
860
+ : adapter.fetchOne<T>(sql, params);
861
+ if (isWrite) await this.commitFetchWrite(adapter);
862
+ this.lastError = null;
863
+ return row;
864
+ } catch (e: any) {
865
+ this.lastError = e?.message ?? String(e);
866
+ throw e;
867
+ }
868
+ });
730
869
  }
731
870
 
732
871
  /**
@@ -748,10 +887,23 @@ export class Database {
748
887
  * SEPARATE trailing argument, never the params array.
749
888
  */
750
889
  async fetchAll<T = Record<string, unknown>>(sql: string, params?: unknown[], limit?: number, offset?: number, opts?: { noCache?: boolean }): Promise<T[]> {
751
- // Routes through _fetchWithLimit, NOT fetch(), so `limit` stays verbatim.
752
- // Going through fetch() would apply the 100-row cap and make a method
753
- // called "fetchAll" quietly stop returning them all.
754
- return (await this._fetchWithLimit(sql, params, limit, offset, opts)).records as T[];
890
+ return this.withConnectionOperation(async () => {
891
+ // Routes through _fetchWithLimit, NOT fetch(), so `limit` stays verbatim.
892
+ // Going through fetch() would apply the 100-row cap and make a method
893
+ // called "fetchAll" quietly stop returning them all.
894
+ return (await this._fetchWithLimit(sql, params, limit, offset, opts)).records as T[];
895
+ });
896
+ }
897
+
898
+ /**
899
+ * Close a write that ran through fetch()/fetchOne() the way execute() closes
900
+ * its own: auto-commit outside an explicit transaction, and leave it to the
901
+ * caller's commit()/rollback() inside one.
902
+ */
903
+ private async commitFetchWrite(adapter: DatabaseAdapter): Promise<void> {
904
+ if (this.autoCommit && !this.inExplicitTransaction()) {
905
+ try { await adapterCommit(adapter); } catch { /* no active transaction */ }
906
+ }
755
907
  }
756
908
 
757
909
  /**
@@ -769,23 +921,42 @@ export class Database {
769
921
  * `try/catch` and convert, rather than testing the return value.
770
922
  */
771
923
  async execute(sql: string, params?: unknown[]): Promise<boolean | unknown> {
772
- try {
773
- const adapter = this.getNextAdapter();
774
- const result = await adapterExecute(adapter, sql, params);
775
- if (this.autoCommit && !this.inExplicitTransaction()) {
776
- try { await adapterCommit(adapter); } catch { /* no active transaction */ }
777
- }
778
- this.lastError = null;
779
- const upper = sql.trim().toUpperCase();
780
- if (upper.includes("RETURNING") || upper.startsWith("CALL ") ||
781
- upper.startsWith("EXEC ") || upper.startsWith("SELECT ")) {
782
- return result;
924
+ return this.withConnectionOperation(async () => {
925
+ try {
926
+ const adapter = this.getNextAdapter();
927
+ // A statement that PRODUCES ROWS (SELECT, WITH ... SELECT, a write with
928
+ // RETURNING / SQL Server OUTPUT, a CALL/EXEC result set) returns them as
929
+ // the SAME DatabaseResult fetch() returns - run exactly once, with no
930
+ // COUNT probe and no LIMIT/OFFSET (cross-framework contract, matching
931
+ // PHP). It used to hand back the raw driver object (pg Result, tedious
932
+ // {rows}, a mysql2/odbc array), and on SQLite and Firebird the rows were
933
+ // lost entirely because their execute() returns none.
934
+ const kind = SQLTranslator.rowsKind(sql);
935
+ let rows: unknown[] | undefined;
936
+ let result: unknown;
937
+ if (kind) {
938
+ rows = await adapterExecuteRows(adapter, sql, params);
939
+ if (rows === undefined) {
940
+ result = await adapterExecute(adapter, sql, params);
941
+ rows = rowsFromExecuteResult(result);
942
+ }
943
+ } else {
944
+ result = await adapterExecute(adapter, sql, params);
945
+ }
946
+ if (this.autoCommit && !this.inExplicitTransaction()) {
947
+ try { await adapterCommit(adapter); } catch { /* no active transaction */ }
948
+ }
949
+ this.lastError = null;
950
+ if (kind && rows !== undefined && (kind !== "procedure" || rows.length > 0)) {
951
+ return new DatabaseResult(rows as Record<string, unknown>[], undefined, rows.length, undefined, undefined, adapter, sql);
952
+ }
953
+ if (kind === "procedure") return result ?? true;
954
+ return true;
955
+ } catch (e: any) {
956
+ this.lastError = e?.message ?? String(e);
957
+ throw e;
783
958
  }
784
- return true;
785
- } catch (e: any) {
786
- this.lastError = e?.message ?? String(e);
787
- throw e;
788
- }
959
+ });
789
960
  }
790
961
 
791
962
  /**
@@ -802,14 +973,19 @@ export class Database {
802
973
  * already produces, exactly as it already does for update/delete/truncate.
803
974
  */
804
975
  async insert(table: string, data: Record<string, unknown> | Record<string, unknown>[]): Promise<DatabaseWriteResult> {
805
- const adapter = this.getNextAdapter();
806
- const result = (adapter as any).insertAsync
807
- ? await (adapter as any).insertAsync(table, data)
808
- : adapter.insert(table, data);
809
- if (this.autoCommit && !this.inExplicitTransaction()) {
810
- try { await adapterCommit(adapter); } catch { /* no active transaction */ }
811
- }
812
- return Database.assertWrote(result, "insert", table);
976
+ return this.withConnectionOperation(async () => {
977
+ const adapter = this.getNextAdapter();
978
+ // ADR-0069 (G3): every row's keys, not only the first row's (the adapters
979
+ // build a batch from row 0).
980
+ for (const row of Array.isArray(data) ? data : [data]) this.assertWriteKeys(row);
981
+ const result = (adapter as any).insertAsync
982
+ ? await (adapter as any).insertAsync(table, data)
983
+ : adapter.insert(table, data);
984
+ if (this.autoCommit && !this.inExplicitTransaction()) {
985
+ try { await adapterCommit(adapter); } catch { /* no active transaction */ }
986
+ }
987
+ return Database.assertWrote(result, "insert", table);
988
+ });
813
989
  }
814
990
 
815
991
  /**
@@ -820,29 +996,31 @@ export class Database {
820
996
  * no primary key or cannot be introspected.
821
997
  */
822
998
  async primaryKey(table: string): Promise<string[]> {
823
- if (!this._pkCache.has(table)) {
824
- let pk: string[] = [];
825
- try {
826
- const columns = await this.getColumns(table);
827
- // ADR-0044 amendment: sort by primaryKeyPosition so a composite
828
- // PRIMARY KEY (b, a) returns ["b", "a"] (declared key order), not
829
- // table-column order. A column with no reported position sorts last.
830
- const pkColumns = columns.filter((c) => c.primaryKey);
831
- pkColumns.sort((a, b) => {
832
- const posA = a.primaryKeyPosition ?? null;
833
- const posB = b.primaryKeyPosition ?? null;
834
- if (posA === posB) return 0;
835
- if (posA === null) return 1;
836
- if (posB === null) return -1;
837
- return posA - posB;
838
- });
839
- pk = pkColumns.map((c) => c.name);
840
- } catch {
841
- pk = [];
999
+ return this.withConnectionOperation(async () => {
1000
+ if (!this._pkCache.has(table)) {
1001
+ let pk: string[] = [];
1002
+ try {
1003
+ const columns = await this.getColumns(table);
1004
+ // ADR-0044 amendment: sort by primaryKeyPosition so a composite
1005
+ // PRIMARY KEY (b, a) returns ["b", "a"] (declared key order), not
1006
+ // table-column order. A column with no reported position sorts last.
1007
+ const pkColumns = columns.filter((c) => c.primaryKey);
1008
+ pkColumns.sort((a, b) => {
1009
+ const posA = a.primaryKeyPosition ?? null;
1010
+ const posB = b.primaryKeyPosition ?? null;
1011
+ if (posA === posB) return 0;
1012
+ if (posA === null) return 1;
1013
+ if (posB === null) return -1;
1014
+ return posA - posB;
1015
+ });
1016
+ pk = pkColumns.map((c) => c.name);
1017
+ } catch {
1018
+ pk = [];
1019
+ }
1020
+ this._pkCache.set(table, pk);
842
1021
  }
843
- this._pkCache.set(table, pk);
844
- }
845
- return this._pkCache.get(table) ?? [];
1022
+ return this._pkCache.get(table) ?? [];
1023
+ });
846
1024
  }
847
1025
 
848
1026
  /**
@@ -870,132 +1048,176 @@ export class Database {
870
1048
  * throws rather than silently changing nothing (audit feature 4, P1).
871
1049
  */
872
1050
  async update(table: string, data: Record<string, unknown>, filter?: Record<string, unknown> | string, params?: unknown[]): Promise<DatabaseWriteResult> {
873
- let effectiveFilter: Record<string, unknown> | string = filter ?? {};
874
- let effectiveData = data;
875
-
876
- // A string filter is the OTHER documented form ("id = ?" + params), so it
877
- // must be tested as a string: Object.keys("id = ?") is ["0",..,"5"], which
878
- // is non-empty by accident rather than by meaning — and an EMPTY string
879
- // filter would then be treated as a real filter instead of falling through
880
- // to the primary key.
881
- const filterIsEmpty = typeof effectiveFilter === "string"
882
- ? effectiveFilter.trim() === ""
883
- : Object.keys(effectiveFilter).length === 0;
884
-
885
- if (filterIsEmpty) {
886
- const pkColumns = await this.primaryKey(table);
887
- // Resolve each key column to the caller's OWN key for it, matched
888
- // case-insensitively.
889
- //
890
- // The engines disagree about identifier case BY DESIGN and always will:
891
- // Firebird folds an unquoted identifier to UPPER, PostgreSQL folds it to
892
- // LOWER, MySQL and SQLite preserve what was typed. Introspection returns
893
- // the ENGINE's spelling while `data` carries the caller's, so `c in data`
894
- // failed on whichever engine folds the other way. A case-sensitivity bug,
895
- // not a Firebird quirk - Firebird just made it visible first.
896
- //
897
- // Deliberately does NOT lower-case introspection output: that would
898
- // special-case one engine and break a genuinely quoted mixed-case table.
899
- // The WHERE is built from the ENGINE's column name and the CALLER's value.
900
- const resolved: Record<string, string> = {};
901
- const missing: string[] = [];
902
- for (const col of pkColumns) {
903
- const folded = String(col).toLowerCase();
904
- const matches = Object.keys(data).filter((k) => k.toLowerCase() === folded);
905
- if (matches.length > 1) {
906
- // Ambiguity is refused, never guessed - choosing wrong here writes the
907
- // WHERE clause of an UPDATE.
1051
+ return this.withConnectionOperation(async () => {
1052
+ this.assertWriteKeys(data, filter);
1053
+ let effectiveFilter: Record<string, unknown> | string = filter ?? {};
1054
+ let effectiveData = data;
1055
+
1056
+ // A string filter is the OTHER documented form ("id = ?" + params), so it
1057
+ // must be tested as a string: Object.keys("id = ?") is ["0",..,"5"], which
1058
+ // is non-empty by accident rather than by meaning — and an EMPTY string
1059
+ // filter would then be treated as a real filter instead of falling through
1060
+ // to the primary key.
1061
+ const filterIsEmpty = typeof effectiveFilter === "string"
1062
+ ? effectiveFilter.trim() === ""
1063
+ : Object.keys(effectiveFilter).length === 0;
1064
+
1065
+ if (filterIsEmpty) {
1066
+ const pkColumns = await this.primaryKey(table);
1067
+ // Resolve each key column to the caller's OWN key for it, matched
1068
+ // case-insensitively.
1069
+ //
1070
+ // The engines disagree about identifier case BY DESIGN and always will:
1071
+ // Firebird folds an unquoted identifier to UPPER, PostgreSQL folds it to
1072
+ // LOWER, MySQL and SQLite preserve what was typed. Introspection returns
1073
+ // the ENGINE's spelling while `data` carries the caller's, so `c in data`
1074
+ // failed on whichever engine folds the other way. A case-sensitivity bug,
1075
+ // not a Firebird quirk - Firebird just made it visible first.
1076
+ //
1077
+ // Deliberately does NOT lower-case introspection output: that would
1078
+ // special-case one engine and break a genuinely quoted mixed-case table.
1079
+ // The WHERE is built from the ENGINE's column name and the CALLER's value.
1080
+ const resolved: Record<string, string> = {};
1081
+ const missing: string[] = [];
1082
+ for (const col of pkColumns) {
1083
+ const folded = String(col).toLowerCase();
1084
+ const matches = Object.keys(data).filter((k) => k.toLowerCase() === folded);
1085
+ if (matches.length > 1) {
1086
+ // Ambiguity is refused, never guessed - choosing wrong here writes the
1087
+ // WHERE clause of an UPDATE.
1088
+ throw new Error(
1089
+ `update was given more than one key for the primary-key column ${col}: ` +
1090
+ `[${matches.slice().sort().join(", ")}] (table=${table}). These differ ` +
1091
+ `only by case, so which one identifies the row is ambiguous - pass ` +
1092
+ `exactly one, or pass an explicit filter.`,
1093
+ );
1094
+ }
1095
+ if (matches.length === 1) resolved[col] = matches[0];
1096
+ else missing.push(col);
1097
+ }
1098
+ if (pkColumns.length === 0 || missing.length > 0) {
908
1099
  throw new Error(
909
- `update was given more than one key for the primary-key column ${col}: ` +
910
- `[${matches.slice().sort().join(", ")}] (table=${table}). These differ ` +
911
- `only by case, so which one identifies the row is ambiguous - pass ` +
912
- `exactly one, or pass an explicit filter.`,
1100
+ `update requires a filter or the complete primary key in the data; pass ` +
1101
+ `filter explicitly to update multiple rows (table=${table}, ` +
1102
+ `primary key=[${pkColumns.join(", ")}], missing from data=[${missing.join(", ")}]). ` +
1103
+ `To empty a table use truncate(${table}).`,
913
1104
  );
914
1105
  }
915
- if (matches.length === 1) resolved[col] = matches[0];
916
- else missing.push(col);
917
- }
918
- if (pkColumns.length === 0 || missing.length > 0) {
919
- throw new Error(
920
- `update requires a filter or the complete primary key in the data; pass ` +
921
- `filter explicitly to update multiple rows (table=${table}, ` +
922
- `primary key=[${pkColumns.join(", ")}], missing from data=[${missing.join(", ")}]). ` +
923
- `To empty a table use truncate(${table}).`,
924
- );
925
- }
926
- // EVERY key column goes into the WHERE. A composite key built from only its
927
- // first column would match every row sharing that value - the data-loss bug
928
- // this method exists to prevent, reintroduced.
929
- effectiveData = { ...data };
930
- const keyed: Record<string, unknown> = {};
931
- for (const col of pkColumns) {
932
- const callerKey = resolved[col];
933
- keyed[col] = effectiveData[callerKey];
934
- delete effectiveData[callerKey];
1106
+ // EVERY key column goes into the WHERE. A composite key built from only its
1107
+ // first column would match every row sharing that value - the data-loss bug
1108
+ // this method exists to prevent, reintroduced.
1109
+ effectiveData = { ...data };
1110
+ const keyed: Record<string, unknown> = {};
1111
+ for (const col of pkColumns) {
1112
+ const callerKey = resolved[col];
1113
+ keyed[col] = effectiveData[callerKey];
1114
+ delete effectiveData[callerKey];
1115
+ }
1116
+ if (Object.keys(effectiveData).length === 0) {
1117
+ throw new Error(
1118
+ `update was given only the primary key [${pkColumns.join(", ")}] and no ` +
1119
+ `columns to set (table=${table})`,
1120
+ );
1121
+ }
1122
+ effectiveFilter = keyed;
935
1123
  }
936
- if (Object.keys(effectiveData).length === 0) {
937
- throw new Error(
938
- `update was given only the primary key [${pkColumns.join(", ")}] and no ` +
939
- `columns to set (table=${table})`,
940
- );
1124
+
1125
+ const adapter = this.getNextAdapter();
1126
+ const result = (adapter as any).updateAsync
1127
+ ? await (adapter as any).updateAsync(table, effectiveData, effectiveFilter, params)
1128
+ : adapter.update(table, effectiveData, effectiveFilter, params);
1129
+ if (this.autoCommit && !this.inExplicitTransaction()) {
1130
+ try { await adapterCommit(adapter); } catch { /* no active transaction */ }
941
1131
  }
942
- effectiveFilter = keyed;
943
- }
1132
+ return Database.assertWrote(result, "update", table);
1133
+ });
1134
+ }
944
1135
 
945
- const adapter = this.getNextAdapter();
946
- const result = (adapter as any).updateAsync
947
- ? await (adapter as any).updateAsync(table, effectiveData, effectiveFilter, params)
948
- : adapter.update(table, effectiveData, effectiveFilter, params);
949
- if (this.autoCommit && !this.inExplicitTransaction()) {
950
- try { await adapterCommit(adapter); } catch { /* no active transaction */ }
1136
+ /**
1137
+ * ADR-0069 (G3): a data or filter-map key must be a plain identifier - it is
1138
+ * emitted as a column name. Checked here, before any adapter work, so every
1139
+ * engine raises the same error; the SQL builders (sqlDialect) check again for
1140
+ * callers that use an adapter directly. MongoDB builds no SQL and its keys are
1141
+ * document paths, so it is left alone.
1142
+ */
1143
+ private assertWriteKeys(data?: Record<string, unknown>, filter?: unknown): void {
1144
+ // The engine, read without advancing the pool's round-robin.
1145
+ const anyAdapter = this._poolSize > 0 ? this.pool[0] : this.adapter;
1146
+ if (anyAdapter?.getDatabaseType() === "mongodb") return;
1147
+ if (data) assertColumnNames(Object.keys(data));
1148
+ if (filter && typeof filter === "object" && !Array.isArray(filter)) {
1149
+ assertColumnNames(Object.keys(filter as Record<string, unknown>));
951
1150
  }
952
- return Database.assertWrote(result, "update", table);
953
1151
  }
954
1152
 
955
1153
  /** Delete rows. A filterless delete throws; use truncate() to empty a table. */
956
1154
  async delete(table: string, filter?: Record<string, unknown> | string | Record<string, unknown>[], params?: unknown[]): Promise<DatabaseWriteResult> {
957
- const effectiveFilter = filter ?? {};
958
- // A BLANK string counts as no filter. The old guard skipped the emptiness
959
- // test for anything typed string, so `delete(t, "")` fell through to the
960
- // adapter, which renders an empty WHERE as `DELETE FROM "t"` — a silent
961
- // whole-table delete through the very method that exists to make that
962
- // impossible. truncate() is the explicit spelling.
963
- const filterIsEmpty = Array.isArray(effectiveFilter)
964
- ? effectiveFilter.length === 0
965
- : typeof effectiveFilter === "string"
966
- ? effectiveFilter.trim() === ""
967
- : Object.keys(effectiveFilter).length === 0;
968
- if (filterIsEmpty) {
969
- throw new Error(
970
- `delete requires a filter (table=${table}). To remove every row use truncate(${table}).`,
971
- );
972
- }
1155
+ return this.withConnectionOperation(async () => {
1156
+ const effectiveFilter = filter ?? {};
1157
+ // A BLANK string counts as no filter. The old guard skipped the emptiness
1158
+ // test for anything typed string, so `delete(t, "")` fell through to the
1159
+ // adapter, which renders an empty WHERE as `DELETE FROM "t"` — a silent
1160
+ // whole-table delete through the very method that exists to make that
1161
+ // impossible. truncate() is the explicit spelling.
1162
+ const filterIsEmpty = Array.isArray(effectiveFilter)
1163
+ ? effectiveFilter.length === 0
1164
+ : typeof effectiveFilter === "string"
1165
+ ? effectiveFilter.trim() === ""
1166
+ : Object.keys(effectiveFilter).length === 0;
1167
+ if (filterIsEmpty) {
1168
+ throw new Error(
1169
+ `delete requires a filter (table=${table}). To remove every row use truncate(${table}).`,
1170
+ );
1171
+ }
973
1172
 
974
- const adapter = this.getNextAdapter();
975
- const result = (adapter as any).deleteAsync
976
- ? await (adapter as any).deleteAsync(table, effectiveFilter, params)
977
- : adapter.delete(table, effectiveFilter, params);
978
- if (this.autoCommit && !this.inExplicitTransaction()) {
979
- try { await adapterCommit(adapter); } catch { /* no active transaction */ }
980
- }
981
- return Database.assertWrote(result, "delete", table);
1173
+ const adapter = this.getNextAdapter();
1174
+ for (const each of Array.isArray(effectiveFilter) ? effectiveFilter : [effectiveFilter]) {
1175
+ this.assertWriteKeys(undefined, each);
1176
+ }
1177
+ // A LIST of filter maps deletes each one. Only the SQLite adapter handled
1178
+ // the list itself; the others read the array's indices ("0", "1", ...) as
1179
+ // column names. Deleting one map at a time gives every engine the same form.
1180
+ if (Array.isArray(effectiveFilter)) {
1181
+ let affectedRows = 0;
1182
+ for (const each of effectiveFilter) {
1183
+ const one = (adapter as any).deleteAsync
1184
+ ? await (adapter as any).deleteAsync(table, each, params)
1185
+ : adapter.delete(table, each, params);
1186
+ affectedRows += Number(Database.assertWrote(one, "delete", table)?.affectedRows ?? 0);
1187
+ }
1188
+ if (this.autoCommit && !this.inExplicitTransaction()) {
1189
+ try { await adapterCommit(adapter); } catch { /* no active transaction */ }
1190
+ }
1191
+ return { success: true, affectedRows };
1192
+ }
1193
+ const result = (adapter as any).deleteAsync
1194
+ ? await (adapter as any).deleteAsync(table, effectiveFilter, params)
1195
+ : adapter.delete(table, effectiveFilter, params);
1196
+ if (this.autoCommit && !this.inExplicitTransaction()) {
1197
+ try { await adapterCommit(adapter); } catch { /* no active transaction */ }
1198
+ }
1199
+ return Database.assertWrote(result, "delete", table);
1200
+ });
982
1201
  }
983
1202
 
984
1203
  /** Remove every row. The explicit spelling of a whole-table delete. */
985
1204
  async truncate(table: string): Promise<DatabaseWriteResult> {
986
- const adapter = this.getNextAdapter();
987
- // The adapters' delete() already accepts a raw string WHERE clause.
988
- const result = (adapter as any).deleteAsync
989
- ? await (adapter as any).deleteAsync(table, "1 = 1", [])
990
- : adapter.delete(table, "1 = 1" as any, []);
991
- if (this.autoCommit && !this.inExplicitTransaction()) {
992
- try { await adapterCommit(adapter); } catch { /* no active transaction */ }
993
- }
994
- return Database.assertWrote(result, "truncate", table);
1205
+ return this.withConnectionOperation(async () => {
1206
+ const adapter = this.getNextAdapter();
1207
+ // The adapters' delete() already accepts a raw string WHERE clause.
1208
+ const result = (adapter as any).deleteAsync
1209
+ ? await (adapter as any).deleteAsync(table, "1 = 1", [])
1210
+ : adapter.delete(table, "1 = 1" as any, []);
1211
+ if (this.autoCommit && !this.inExplicitTransaction()) {
1212
+ try { await adapterCommit(adapter); } catch { /* no active transaction */ }
1213
+ }
1214
+ return Database.assertWrote(result, "truncate", table);
1215
+ });
995
1216
  }
996
1217
 
997
1218
  /** Close all database connections (pool or single). */
998
1219
  close(): void {
1220
+ this.discardedSlots.clear();
999
1221
  if (this._poolSize > 0) {
1000
1222
  for (let i = 0; i < this.pool.length; i++) {
1001
1223
  if (this.pool[i] !== null) {
@@ -1032,7 +1254,7 @@ export class Database {
1032
1254
  * connection, and the matching inner commit just unwinds the depth.
1033
1255
  */
1034
1256
  async startTransaction(): Promise<void> {
1035
- const store = this.txStore.getStore();
1257
+ let store = this.txStore.getStore();
1036
1258
  if (store?.adapter) {
1037
1259
  const depth = store.depth ?? 1;
1038
1260
  console.warn(
@@ -1045,15 +1267,20 @@ export class Database {
1045
1267
  store.depth = depth + 1;
1046
1268
  return;
1047
1269
  }
1048
- // Pick an adapter using the normal selection logic, then pin it.
1049
- const adapter = this.getNextAdapter();
1050
- if (store) {
1051
- store.adapter = adapter;
1052
- store.depth = 1;
1053
- } else {
1054
- this.txStore.enterWith({ adapter, depth: 1 });
1270
+ // Establish the mutable context before awaiting a reconnect/BEGIN so the
1271
+ // caller's continuation retains the pin, including after a discarded slot.
1272
+ if (!store) { store = { adapter: null, depth: 0 }; this.txStore.enterWith(store); }
1273
+ if (this.discardedSlots.size) await this.repairDiscardedConnections();
1274
+ const adapter = this.reserveAdapter({});
1275
+ store.adapter = adapter;
1276
+ store.depth = 1;
1277
+ try { await adapterStartTransaction(adapter); }
1278
+ catch (error) {
1279
+ const current = this.txStore.getStore();
1280
+ if (current) { current.adapter = null; current.depth = 0; }
1281
+ await this.releaseAdapter(adapter, true);
1282
+ throw error;
1055
1283
  }
1056
- await adapterStartTransaction(adapter);
1057
1284
  }
1058
1285
 
1059
1286
  /**
@@ -1068,24 +1295,27 @@ export class Database {
1068
1295
  * the depth — the outer commit is the real one.
1069
1296
  */
1070
1297
  async commit(): Promise<void> {
1071
- const store = this.txStore.getStore();
1072
- const depth = store?.depth ?? 0;
1073
- if (depth > 1) {
1074
- // Inner commit of an ignored nested begin — just unwind the depth.
1075
- if (store) store.depth = depth - 1;
1076
- return;
1077
- }
1078
- const adapter = this.getNextAdapter();
1079
- try {
1080
- await adapterCommit(adapter);
1081
- this.lastError = null;
1082
- } catch (e: any) {
1083
- // Keep the pin so rollback() reaches this same connection.
1084
- this.lastError = e?.message ?? String(e);
1085
- throw e;
1086
- }
1087
- // Success — release the pin.
1088
- if (store) { store.adapter = null; store.depth = 0; }
1298
+ return this.withConnectionOperation(async () => {
1299
+ const store = this.txStore.getStore();
1300
+ const depth = store?.depth ?? 0;
1301
+ if (depth > 1) {
1302
+ // Inner commit of an ignored nested begin — just unwind the depth.
1303
+ if (store) store.depth = depth - 1;
1304
+ return;
1305
+ }
1306
+ const adapter = this.getNextAdapter();
1307
+ try {
1308
+ await adapterCommit(adapter);
1309
+ this.lastError = null;
1310
+ } catch (e: any) {
1311
+ // Keep the pin so rollback() reaches this same connection.
1312
+ this.lastError = e?.message ?? String(e);
1313
+ throw e;
1314
+ }
1315
+ // Success — release the transaction lease (an ordinary operation owns its own lease).
1316
+ if (store) { store.adapter = null; store.depth = 0; }
1317
+ if (this.operationStore.getStore()?.adapter !== adapter) await this.releaseAdapter(adapter);
1318
+ });
1089
1319
  }
1090
1320
 
1091
1321
  /**
@@ -1097,28 +1327,36 @@ export class Database {
1097
1327
  * stay pinned to this context forever.
1098
1328
  */
1099
1329
  async rollback(): Promise<void> {
1100
- const adapter = this.getNextAdapter();
1101
- const store = this.txStore.getStore();
1102
- try {
1103
- await adapterRollback(adapter);
1104
- this.lastError = null;
1105
- } catch (e: any) {
1106
- this.lastError = e?.message ?? String(e);
1107
- throw e;
1108
- } finally {
1109
- // Terminal cleanup — always release the pin.
1110
- if (store) { store.adapter = null; store.depth = 0; }
1111
- }
1330
+ return this.withConnectionOperation(async () => {
1331
+ const adapter = this.getNextAdapter();
1332
+ const store = this.txStore.getStore();
1333
+ let failed = false;
1334
+ try {
1335
+ await adapterRollback(adapter);
1336
+ this.lastError = null;
1337
+ } catch (e: any) {
1338
+ this.lastError = e?.message ?? String(e);
1339
+ failed = true;
1340
+ throw e;
1341
+ } finally {
1342
+ if (store) { store.adapter = null; store.depth = 0; }
1343
+ if (failed || this.operationStore.getStore()?.adapter !== adapter) await this.releaseAdapter(adapter, failed);
1344
+ }
1345
+ });
1112
1346
  }
1113
1347
 
1114
1348
  /** Check if a table exists. */
1115
1349
  async tableExists(name: string): Promise<boolean> {
1116
- return adapterTableExists(this.getNextAdapter(), name);
1350
+ return this.withConnectionOperation(async () => {
1351
+ return adapterTableExists(this.getNextAdapter(), name);
1352
+ });
1117
1353
  }
1118
1354
 
1119
1355
  /** List all tables in the database. */
1120
1356
  async getTables(): Promise<string[]> {
1121
- return adapterTables(this.getNextAdapter());
1357
+ return this.withConnectionOperation(async () => {
1358
+ return adapterTables(this.getNextAdapter());
1359
+ });
1122
1360
  }
1123
1361
 
1124
1362
  /**
@@ -1130,7 +1368,9 @@ export class Database {
1130
1368
  * @returns Array of column info objects: { name, type, nullable, default, primaryKey }.
1131
1369
  */
1132
1370
  async getColumns(tableName: string): Promise<{ name: string; type: string; nullable?: boolean; default?: unknown; primaryKey?: boolean; primaryKeyPosition?: number | null }[]> {
1133
- return adapterColumns(this.getNextAdapter(), tableName);
1371
+ return this.withConnectionOperation(async () => {
1372
+ return adapterColumns(this.getNextAdapter(), tableName);
1373
+ });
1134
1374
  }
1135
1375
 
1136
1376
  /**
@@ -1153,41 +1393,43 @@ export class Database {
1153
1393
  * @returns The aggregate DatabaseResult for the whole batch.
1154
1394
  */
1155
1395
  async executeMany(sql: string, paramSets: unknown[][] = []): Promise<DatabaseWriteResult> {
1156
- // ADR-0044 (DBA-B01): empty input is a successful no-op — it opens no
1157
- // transaction and calls no adapter.
1158
- if (paramSets.length === 0) {
1159
- return { success: true, affectedRows: 0 };
1160
- }
1396
+ return this.withConnectionOperation(async () => {
1397
+ // ADR-0044 (DBA-B01): empty input is a successful no-op — it opens no
1398
+ // transaction and calls no adapter.
1399
+ if (paramSets.length === 0) {
1400
+ return { success: true, affectedRows: 0 };
1401
+ }
1161
1402
 
1162
- // Own the batch transaction ONLY when not already inside a caller's explicit
1163
- // transaction. inExplicitTransaction() is true when startTransaction() has
1164
- // pinned an adapter to this async context (getNextAdapter() returns that same
1165
- // pinned connection). If we owned a BEGIN/COMMIT here regardless, the inner
1166
- // COMMIT would commit the caller's OUTER transaction early and their later
1167
- // rollback() would undo nothing (the batch rows survive). So: standalone
1168
- // batch -> own BEGIN/COMMIT (atomic, all-or-nothing); nested batch -> join
1169
- // the caller's transaction and let their commit/rollback decide. Mirrors the
1170
- // sibling execute()/insert()/update()/delete() owns-guard and the Python
1171
- // master (Database.execute_many delegating to adapter.execute_many's
1172
- // owns_txn guard).
1173
- const owns = !this.inExplicitTransaction();
1174
- const adapter = this.getNextAdapter();
1175
- if (owns) await adapterStartTransaction(adapter);
1403
+ // Own the batch transaction ONLY when not already inside a caller's explicit
1404
+ // transaction. inExplicitTransaction() is true when startTransaction() has
1405
+ // pinned an adapter to this async context (getNextAdapter() returns that same
1406
+ // pinned connection). If we owned a BEGIN/COMMIT here regardless, the inner
1407
+ // COMMIT would commit the caller's OUTER transaction early and their later
1408
+ // rollback() would undo nothing (the batch rows survive). So: standalone
1409
+ // batch -> own BEGIN/COMMIT (atomic, all-or-nothing); nested batch -> join
1410
+ // the caller's transaction and let their commit/rollback decide. Mirrors the
1411
+ // sibling execute()/insert()/update()/delete() owns-guard and the Python
1412
+ // master (Database.execute_many delegating to adapter.execute_many's
1413
+ // owns_txn guard).
1414
+ const owns = !this.inExplicitTransaction();
1415
+ const adapter = this.getNextAdapter();
1416
+ if (owns) await adapterStartTransaction(adapter);
1176
1417
 
1177
- let result: DatabaseWriteResult;
1178
- try {
1179
- // ONE delegated call — native batching (one multi-row round-trip instead
1180
- // of one per row: 500 rows measured 9848ms on PostgreSQL row-at-a-time
1181
- // against 15.8ms batched — 625x, MySQL 216x, MSSQL 121x) is the
1182
- // ADAPTER's job, not a facade loop.
1183
- result = await adapterExecuteMany(adapter, sql, paramSets);
1184
- if (owns) await adapterCommit(adapter);
1185
- } catch (e) {
1186
- if (owns) await adapterRollback(adapter);
1187
- throw e;
1188
- }
1418
+ let result: DatabaseWriteResult;
1419
+ try {
1420
+ // ONE delegated call — native batching (one multi-row round-trip instead
1421
+ // of one per row: 500 rows measured 9848ms on PostgreSQL row-at-a-time
1422
+ // against 15.8ms batched — 625x, MySQL 216x, MSSQL 121x) is the
1423
+ // ADAPTER's job, not a facade loop.
1424
+ result = await adapterExecuteMany(adapter, sql, paramSets);
1425
+ if (owns) await adapterCommit(adapter);
1426
+ } catch (e) {
1427
+ if (owns) await adapterRollback(adapter);
1428
+ throw e;
1429
+ }
1189
1430
 
1190
- return result;
1431
+ return result;
1432
+ });
1191
1433
  }
1192
1434
 
1193
1435
  /** Return the last execute() error message, or null. */
@@ -1246,24 +1488,26 @@ export class Database {
1246
1488
  * SQLite, MySQL, MSSQL, and as a PostgreSQL fallback.
1247
1489
  */
1248
1490
  private async ensureSequenceTable(): Promise<void> {
1249
- const adapter = this.getNextAdapter();
1491
+ return this.withConnectionOperation(async () => {
1492
+ const adapter = this.getNextAdapter();
1250
1493
 
1251
- if (!(await adapterTableExists(adapter, "tina4_sequences"))) {
1252
- if (this.dbType === "mssql") {
1253
- await adapterExecute(adapter,
1254
- "CREATE TABLE tina4_sequences (" +
1255
- "seq_name VARCHAR(200) NOT NULL PRIMARY KEY, " +
1256
- "current_value INTEGER NOT NULL DEFAULT 0)"
1257
- );
1258
- } else {
1259
- await adapterExecute(adapter,
1260
- "CREATE TABLE IF NOT EXISTS tina4_sequences (" +
1261
- "seq_name VARCHAR(200) NOT NULL PRIMARY KEY, " +
1262
- "current_value INTEGER NOT NULL DEFAULT 0)"
1263
- );
1494
+ if (!(await adapterTableExists(adapter, "tina4_sequences"))) {
1495
+ if (this.dbType === "mssql") {
1496
+ await adapterExecute(adapter,
1497
+ "CREATE TABLE tina4_sequences (" +
1498
+ "seq_name VARCHAR(200) NOT NULL PRIMARY KEY, " +
1499
+ "current_value INTEGER NOT NULL DEFAULT 0)"
1500
+ );
1501
+ } else {
1502
+ await adapterExecute(adapter,
1503
+ "CREATE TABLE IF NOT EXISTS tina4_sequences (" +
1504
+ "seq_name VARCHAR(200) NOT NULL PRIMARY KEY, " +
1505
+ "current_value INTEGER NOT NULL DEFAULT 0)"
1506
+ );
1507
+ }
1508
+ try { await adapterCommit(adapter); } catch { /* no active transaction */ }
1264
1509
  }
1265
- try { await adapterCommit(adapter); } catch { /* no active transaction */ }
1266
- }
1510
+ });
1267
1511
  }
1268
1512
 
1269
1513
  /**
@@ -1309,49 +1553,51 @@ export class Database {
1309
1553
  * (never silently fall back to 1).
1310
1554
  */
1311
1555
  private async sequenceNext(seqName: string, table?: string, pkColumn = "id"): Promise<number> {
1312
- // Pin a single adapter for the whole sequence operation so seed +
1313
- // increment + read all hit the SAME connection. Inside an active
1314
- // transaction the adapter is already pinned; otherwise pin here and
1315
- // release in the finally so the pool can rotate afterwards.
1316
- const store = this.txStore.getStore();
1317
- const alreadyPinned = !!store?.adapter;
1318
- const adapter = this.getNextAdapter();
1319
- if (!alreadyPinned) {
1320
- if (store) store.adapter = adapter;
1321
- else this.txStore.enterWith({ adapter });
1322
- }
1556
+ return this.withConnectionOperation(async () => {
1557
+ // Pin a single adapter for the whole sequence operation so seed +
1558
+ // increment + read all hit the SAME connection. Inside an active
1559
+ // transaction the adapter is already pinned; otherwise pin here and
1560
+ // release in the finally so the pool can rotate afterwards.
1561
+ const store = this.txStore.getStore();
1562
+ const alreadyPinned = !!store?.adapter;
1563
+ const adapter = this.getNextAdapter();
1564
+ if (!alreadyPinned) {
1565
+ if (store) store.adapter = adapter;
1566
+ else this.txStore.enterWith({ adapter });
1567
+ }
1323
1568
 
1324
- try {
1325
- if (this.dbType === "sqlite") {
1326
- // SQLite: the adapter does ensure-table + seed + atomic increment as one
1327
- // synchronous burst. We compute the seed first (its own read can yield,
1328
- // but that's fine — INSERT OR IGNORE makes the seed idempotent and the
1329
- // increment itself is the atomic step).
1330
- const seed = await this.sequenceSeedValue(adapter, table, pkColumn);
1331
- const raw = (adapter as any).getAdapter ? (adapter as any).getAdapter() : adapter;
1332
- if (typeof raw.sequenceNextSqlite === "function") {
1333
- return raw.sequenceNextSqlite(seqName, seed);
1569
+ try {
1570
+ if (this.dbType === "sqlite") {
1571
+ // SQLite: the adapter does ensure-table + seed + atomic increment as one
1572
+ // synchronous burst. We compute the seed first (its own read can yield,
1573
+ // but that's fine — INSERT OR IGNORE makes the seed idempotent and the
1574
+ // increment itself is the atomic step).
1575
+ const seed = await this.sequenceSeedValue(adapter, table, pkColumn);
1576
+ const raw = (adapter as any).getAdapter ? (adapter as any).getAdapter() : adapter;
1577
+ if (typeof raw.sequenceNextSqlite === "function") {
1578
+ return raw.sequenceNextSqlite(seqName, seed);
1579
+ }
1580
+ // Defensive fallback if the underlying adapter lacks the atomic helper.
1581
+ return this.sequenceNextGeneric(adapter, seqName, seed);
1334
1582
  }
1335
- // Defensive fallback if the underlying adapter lacks the atomic helper.
1336
- return this.sequenceNextGeneric(adapter, seqName, seed);
1337
- }
1338
1583
 
1339
- await this.ensureSequenceTable();
1340
- if (this.dbType === "mysql") {
1341
- return this.sequenceNextMysql(adapter, seqName, table, pkColumn);
1342
- }
1343
- if (this.dbType === "mssql") {
1344
- return this.sequenceNextMssql(adapter, seqName, table, pkColumn);
1345
- }
1346
- // Any other engine routed here (defensive) — generic atomic-ish path.
1347
- const seed = await this.sequenceSeedValue(adapter, table, pkColumn);
1348
- return this.sequenceNextGeneric(adapter, seqName, seed);
1349
- } finally {
1350
- if (!alreadyPinned) {
1351
- const s = this.txStore.getStore();
1352
- if (s) s.adapter = null;
1584
+ await this.ensureSequenceTable();
1585
+ if (this.dbType === "mysql") {
1586
+ return this.sequenceNextMysql(adapter, seqName, table, pkColumn);
1587
+ }
1588
+ if (this.dbType === "mssql") {
1589
+ return this.sequenceNextMssql(adapter, seqName, table, pkColumn);
1590
+ }
1591
+ // Any other engine routed here (defensive) — generic atomic-ish path.
1592
+ const seed = await this.sequenceSeedValue(adapter, table, pkColumn);
1593
+ return this.sequenceNextGeneric(adapter, seqName, seed);
1594
+ } finally {
1595
+ if (!alreadyPinned) {
1596
+ const s = this.txStore.getStore();
1597
+ if (s) s.adapter = null;
1598
+ }
1353
1599
  }
1354
- }
1600
+ });
1355
1601
  }
1356
1602
 
1357
1603
  /**
@@ -1450,79 +1696,81 @@ export class Database {
1450
1696
  * - Returns 1 if the table is empty or does not exist.
1451
1697
  */
1452
1698
  async getNextId(table: string, pkColumn = "id", generatorName?: string): Promise<number> {
1453
- const adapter = this.getNextAdapter();
1699
+ return this.withConnectionOperation(async () => {
1700
+ const adapter = this.getNextAdapter();
1454
1701
 
1455
- // MongoDB — a DEDICATED atomic counter (findOneAndUpdate($inc) keyed by _id),
1456
- // monotonic and concurrency-safe. NEVER routed through the relational
1457
- // tina4_sequences path: the Mongo SET-clause parser matches only `col = ?`
1458
- // and DROPS the arithmetic `current_value + 1`, so the increment vanished
1459
- // (empty $set) and every call returned the same id — a duplicate generator.
1460
- if (this.dbType === "mongodb") {
1461
- const mongo = adapter as unknown as { getNextId?: (t: string, p: string) => Promise<number> };
1462
- if (typeof mongo.getNextId === "function") {
1463
- return mongo.getNextId(table, pkColumn);
1702
+ // MongoDB — a DEDICATED atomic counter (findOneAndUpdate($inc) keyed by _id),
1703
+ // monotonic and concurrency-safe. NEVER routed through the relational
1704
+ // tina4_sequences path: the Mongo SET-clause parser matches only `col = ?`
1705
+ // and DROPS the arithmetic `current_value + 1`, so the increment vanished
1706
+ // (empty $set) and every call returned the same id — a duplicate generator.
1707
+ if (this.dbType === "mongodb") {
1708
+ const mongo = adapter as unknown as { getNextId?: (t: string, p: string) => Promise<number> };
1709
+ if (typeof mongo.getNextId === "function") {
1710
+ return mongo.getNextId(table, pkColumn);
1711
+ }
1464
1712
  }
1465
- }
1466
1713
 
1467
- // Firebird — use generators (atomic)
1468
- if (this.dbType === "firebird") {
1469
- const genName = generatorName ?? `GEN_${table.toUpperCase()}_ID`;
1714
+ // Firebird — use generators (atomic)
1715
+ if (this.dbType === "firebird") {
1716
+ const genName = generatorName ?? `GEN_${table.toUpperCase()}_ID`;
1470
1717
 
1471
- // Auto-create the generator if it does not exist
1472
- try {
1473
- await adapterExecute(adapter, `CREATE GENERATOR ${genName}`);
1474
- } catch {
1475
- // Generator already exists — ignore
1476
- }
1718
+ // Auto-create the generator if it does not exist
1719
+ try {
1720
+ await adapterExecute(adapter, `CREATE GENERATOR ${genName}`);
1721
+ } catch {
1722
+ // Generator already exists — ignore
1723
+ }
1477
1724
 
1478
- const row = await adapterFetchOne<Record<string, unknown>>(adapter, `SELECT GEN_ID(${genName}, 1) AS NEXT_ID FROM RDB$DATABASE`);
1479
- return Number(row?.NEXT_ID ?? row?.next_id ?? 1);
1480
- }
1725
+ const row = await adapterFetchOne<Record<string, unknown>>(adapter, `SELECT GEN_ID(${genName}, 1) AS NEXT_ID FROM RDB$DATABASE`);
1726
+ return Number(row?.NEXT_ID ?? row?.next_id ?? 1);
1727
+ }
1481
1728
 
1482
- // PostgreSQL — try sequence first, auto-create if missing, fall through to sequence table
1483
- if (this.dbType === "postgres") {
1484
- const seqName = generatorName ?? `${table.toLowerCase()}_${pkColumn.toLowerCase()}_seq`;
1485
- // Fast path: the sequence already exists — nextval() is atomic.
1486
- try {
1487
- const row = await adapterFetchOne<Record<string, unknown>>(adapter, `SELECT nextval('${seqName}') AS next_id`);
1488
- if (row?.next_id != null) {
1489
- return Number(row.next_id);
1729
+ // PostgreSQL — try sequence first, auto-create if missing, fall through to sequence table
1730
+ if (this.dbType === "postgres") {
1731
+ const seqName = generatorName ?? `${table.toLowerCase()}_${pkColumn.toLowerCase()}_seq`;
1732
+ // Fast path: the sequence already exists — nextval() is atomic.
1733
+ try {
1734
+ const row = await adapterFetchOne<Record<string, unknown>>(adapter, `SELECT nextval('${seqName}') AS next_id`);
1735
+ if (row?.next_id != null) {
1736
+ return Number(row.next_id);
1737
+ }
1738
+ } catch {
1739
+ // Sequence missing — create it idempotently below.
1490
1740
  }
1491
- } catch {
1492
- // Sequence missing — create it idempotently below.
1493
- }
1494
1741
 
1495
- // First use: create the sequence IDEMPOTENTLY (CREATE SEQUENCE IF NOT
1496
- // EXISTS), seeded from MAX(pk). Two concurrent first-callers therefore
1497
- // share ONE counter — the loser's create is a no-op, not an error, so it
1498
- // never falls to the tina4_sequences table and draws a DUPLICATE id from a
1499
- // second, independent counter (the first-use race).
1500
- try {
1501
- const maxRow = await adapterFetchOne<Record<string, unknown>>(adapter,
1502
- `SELECT COALESCE(MAX(${pkColumn}), 0) AS max_id FROM ${table}`
1503
- );
1504
- const start = maxRow?.max_id != null ? Number(maxRow.max_id) + 1 : 1;
1505
- await adapterExecute(adapter, `CREATE SEQUENCE IF NOT EXISTS ${seqName} START WITH ${start}`);
1506
- try { await adapterCommit(adapter); } catch { /* no active transaction */ }
1507
- } catch {
1508
- // A concurrent creator won the catalog race — the sequence exists now.
1509
- }
1742
+ // First use: create the sequence IDEMPOTENTLY (CREATE SEQUENCE IF NOT
1743
+ // EXISTS), seeded from MAX(pk). Two concurrent first-callers therefore
1744
+ // share ONE counter — the loser's create is a no-op, not an error, so it
1745
+ // never falls to the tina4_sequences table and draws a DUPLICATE id from a
1746
+ // second, independent counter (the first-use race).
1747
+ try {
1748
+ const maxRow = await adapterFetchOne<Record<string, unknown>>(adapter,
1749
+ `SELECT COALESCE(MAX(${pkColumn}), 0) AS max_id FROM ${table}`
1750
+ );
1751
+ const start = maxRow?.max_id != null ? Number(maxRow.max_id) + 1 : 1;
1752
+ await adapterExecute(adapter, `CREATE SEQUENCE IF NOT EXISTS ${seqName} START WITH ${start}`);
1753
+ try { await adapterCommit(adapter); } catch { /* no active transaction */ }
1754
+ } catch {
1755
+ // A concurrent creator won the catalog race — the sequence exists now.
1756
+ }
1510
1757
 
1511
- // ALWAYS draw from the sequence now that it exists. Never fall to the
1512
- // sequence table just because our own CREATE lost the race.
1513
- try {
1514
- const row = await adapterFetchOne<Record<string, unknown>>(adapter, `SELECT nextval('${seqName}') AS next_id`);
1515
- if (row?.next_id != null) {
1516
- return Number(row.next_id);
1758
+ // ALWAYS draw from the sequence now that it exists. Never fall to the
1759
+ // sequence table just because our own CREATE lost the race.
1760
+ try {
1761
+ const row = await adapterFetchOne<Record<string, unknown>>(adapter, `SELECT nextval('${seqName}') AS next_id`);
1762
+ if (row?.next_id != null) {
1763
+ return Number(row.next_id);
1764
+ }
1765
+ } catch {
1766
+ // Truly cannot use a sequence — last-resort table below.
1517
1767
  }
1518
- } catch {
1519
- // Truly cannot use a sequence — last-resort table below.
1520
1768
  }
1521
- }
1522
1769
 
1523
- // SQLite / MySQL / MSSQL / PostgreSQL fallback — atomic sequence table
1524
- const seqKey = generatorName ?? `${table}.${pkColumn}`;
1525
- return this.sequenceNext(seqKey, table, pkColumn);
1770
+ // SQLite / MySQL / MSSQL / PostgreSQL fallback — atomic sequence table
1771
+ const seqKey = generatorName ?? `${table}.${pkColumn}`;
1772
+ return this.sequenceNext(seqKey, table, pkColumn);
1773
+ });
1526
1774
  }
1527
1775
  }
1528
1776