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.
- package/CLAUDE.md +33 -3
- package/COMMERCIAL-LICENSE.md +8 -0
- package/LICENSE +373 -0
- package/NOTICE +11 -0
- package/README.md +24 -2
- package/package.json +44 -6
- package/packages/cli/dist/bin.js +19466 -18048
- package/packages/cli/src/bin.ts +8 -0
- package/packages/cli/src/commands/build.ts +8 -0
- package/packages/cli/src/commands/generate.ts +8 -0
- package/packages/cli/src/commands/init.ts +8 -0
- package/packages/cli/src/commands/lint.ts +8 -0
- package/packages/cli/src/commands/migrate.ts +8 -0
- package/packages/cli/src/commands/migrateCreate.ts +8 -0
- package/packages/cli/src/commands/migrateRollback.ts +8 -0
- package/packages/cli/src/commands/migrateStatus.ts +8 -0
- package/packages/cli/src/commands/queue.ts +8 -0
- package/packages/cli/src/commands/routes.ts +8 -0
- package/packages/cli/src/commands/seed.ts +8 -0
- package/packages/cli/src/commands/serve.ts +11 -0
- package/packages/cli/src/commands/test.ts +8 -0
- package/packages/core/dist/index.js +19458 -18043
- package/packages/core/gallery/auth/src/routes/api/gallery/auth/login/post.ts +8 -0
- package/packages/core/gallery/auth/src/routes/api/gallery/auth/verify/get.ts +8 -0
- package/packages/core/gallery/auth/src/routes/gallery/auth/get.ts +8 -0
- package/packages/core/gallery/database/src/routes/api/gallery/db/notes/get.ts +8 -0
- package/packages/core/gallery/database/src/routes/api/gallery/db/notes/post.ts +8 -0
- package/packages/core/gallery/database/src/routes/api/gallery/db/tables/get.ts +8 -0
- package/packages/core/gallery/error-overlay/src/routes/api/gallery/crash/get.ts +8 -0
- package/packages/core/gallery/orm/src/routes/api/gallery/products/get.ts +8 -0
- package/packages/core/gallery/orm/src/routes/api/gallery/products/post.ts +8 -0
- package/packages/core/gallery/queue/src/lib/queueDb.ts +8 -0
- package/packages/core/gallery/queue/src/routes/api/gallery/queue/consume/post.ts +8 -0
- package/packages/core/gallery/queue/src/routes/api/gallery/queue/fail/post.ts +8 -0
- package/packages/core/gallery/queue/src/routes/api/gallery/queue/produce/post.ts +8 -0
- package/packages/core/gallery/queue/src/routes/api/gallery/queue/retry/post.ts +8 -0
- package/packages/core/gallery/queue/src/routes/api/gallery/queue/status/get.ts +8 -0
- package/packages/core/gallery/queue/src/routes/gallery/queue/get.ts +8 -0
- package/packages/core/gallery/rest-api/src/routes/api/gallery/hello/get.ts +8 -0
- package/packages/core/gallery/rest-api/src/routes/api/gallery/hello/post.ts +8 -0
- package/packages/core/gallery/templates/src/routes/gallery/page/get.ts +8 -0
- package/packages/core/gallery/templates/src/templates/gallery_page.twig +8 -0
- package/packages/core/public/THIRD-PARTY-NOTICES.md +55 -0
- package/packages/core/public/bundled-component-provenance.json +48 -0
- package/packages/core/public/js/tina4js.min.js +13 -3
- package/packages/core/public/licenses/swagger-ui-3.10.0-original-notice.txt +11 -0
- package/packages/core/public/licenses/swagger-ui-5.17.14-NOTICE.txt +2 -0
- package/packages/core/public/licenses/swagger-ui-Apache-2.0.txt +202 -0
- package/packages/core/public/licenses/tina4-css-MIT.txt +21 -0
- package/packages/core/public/licenses/tina4-dev-admin-components.json +240 -0
- package/packages/core/public/licenses/tina4-dev-admin-third-party.txt +919 -0
- package/packages/core/public/licenses/tina4-js-MIT.txt +20 -0
- package/packages/core/src/__feedback/widget.js +8 -0
- package/packages/core/src/_missing.ts +8 -0
- package/packages/core/src/ai.ts +8 -0
- package/packages/core/src/aiClient.ts +8 -0
- package/packages/core/src/api.ts +15 -3
- package/packages/core/src/auth.ts +21 -8
- package/packages/core/src/authGate.ts +8 -0
- package/packages/core/src/background.ts +8 -0
- package/packages/core/src/cache.ts +43 -4
- package/packages/core/src/constants.ts +8 -0
- package/packages/core/src/container.ts +8 -0
- package/packages/core/src/context/chunker.ts +8 -0
- package/packages/core/src/context/index.ts +8 -0
- package/packages/core/src/devAdmin.ts +185 -59
- package/packages/core/src/devMailbox.ts +8 -0
- package/packages/core/src/dispatchPipeline.ts +8 -0
- package/packages/core/src/docs.ts +8 -0
- package/packages/core/src/docsAutoDiscovery.ts +8 -0
- package/packages/core/src/docsParser.ts +8 -0
- package/packages/core/src/docsScanner.ts +44 -0
- package/packages/core/src/docsSignatures.ts +8 -0
- package/packages/core/src/dotenv.ts +8 -0
- package/packages/core/src/env.ts +8 -0
- package/packages/core/src/errorOverlay.ts +8 -0
- package/packages/core/src/events.ts +8 -0
- package/packages/core/src/fakeData.ts +8 -0
- package/packages/core/src/feedback.ts +8 -0
- package/packages/core/src/graphql.ts +16 -5
- package/packages/core/src/health.ts +8 -0
- package/packages/core/src/htmlElement.ts +8 -0
- package/packages/core/src/i18n.ts +8 -0
- package/packages/core/src/index.ts +8 -0
- package/packages/core/src/job.ts +8 -0
- package/packages/core/src/logger.ts +8 -0
- package/packages/core/src/mcp.test.ts +8 -0
- package/packages/core/src/mcp.ts +8 -0
- package/packages/core/src/messenger.ts +143 -86
- package/packages/core/src/metrics.ts +13 -0
- package/packages/core/src/middleware.ts +1178 -1096
- package/packages/core/src/mqtt.ts +8 -0
- package/packages/core/src/mqttMessage.ts +8 -0
- package/packages/core/src/optionalPackage.ts +48 -0
- package/packages/core/src/plan.ts +8 -0
- package/packages/core/src/portTakeover.ts +8 -0
- package/packages/core/src/projectIndex.ts +8 -0
- package/packages/core/src/projectIndexExtractors.ts +8 -0
- package/packages/core/src/projectIndexStorage.ts +8 -0
- package/packages/core/src/push.ts +8 -0
- package/packages/core/src/queue.ts +8 -0
- package/packages/core/src/queueBackends/kafkaBackend.ts +8 -0
- package/packages/core/src/queueBackends/liteBackend.ts +8 -0
- package/packages/core/src/queueBackends/mongoBackend.ts +21 -8
- package/packages/core/src/queueBackends/rabbitmqBackend.ts +8 -0
- package/packages/core/src/rateLimiter.ts +8 -0
- package/packages/core/src/request.ts +59 -10
- package/packages/core/src/response.ts +79 -5
- package/packages/core/src/routeDiscovery.ts +8 -0
- package/packages/core/src/router.ts +8 -0
- package/packages/core/src/server.ts +73 -24
- package/packages/core/src/service.ts +8 -0
- package/packages/core/src/session.ts +8 -0
- package/packages/core/src/sessionHandlers/childError.ts +8 -0
- package/packages/core/src/sessionHandlers/databaseHandler.ts +8 -0
- package/packages/core/src/sessionHandlers/memcachedHandler.ts +8 -0
- package/packages/core/src/sessionHandlers/mongoClient.ts +8 -0
- package/packages/core/src/sessionHandlers/mongoHandler.ts +8 -0
- package/packages/core/src/sessionHandlers/respClient.ts +8 -0
- package/packages/core/src/sessionHandlers/sqlClient.ts +8 -0
- package/packages/core/src/sessionHandlers/syncBridge.ts +8 -0
- package/packages/core/src/sessionHandlers/syncSocket.ts +8 -0
- package/packages/core/src/sessionHandlers/valkeyHandler.ts +8 -0
- package/packages/core/src/sso.ts +8 -0
- package/packages/core/src/static.ts +8 -0
- package/packages/core/src/test.ts +8 -0
- package/packages/core/src/testClient.ts +8 -0
- package/packages/core/src/testing.ts +8 -0
- package/packages/core/src/transport.ts +369 -0
- package/packages/core/src/trustedProxy.ts +8 -0
- package/packages/core/src/types.ts +13 -0
- package/packages/core/src/validator.ts +8 -0
- package/packages/core/src/version.ts +8 -0
- package/packages/core/src/websocket.ts +8 -0
- package/packages/core/src/websocketBackplane.ts +49 -32
- package/packages/core/src/websocketConnection.ts +8 -0
- package/packages/core/src/wsdl.ts +197 -115
- package/packages/core/templates/errors/302.twig +8 -0
- package/packages/core/templates/errors/401.twig +8 -0
- package/packages/core/templates/errors/403.twig +8 -0
- package/packages/core/templates/errors/404.twig +8 -0
- package/packages/core/templates/errors/500.twig +8 -0
- package/packages/core/templates/errors/502.twig +8 -0
- package/packages/core/templates/errors/503.twig +8 -0
- package/packages/core/templates/errors/base.twig +8 -0
- package/packages/frond/dist/index.js +151 -17
- package/packages/frond/src/engine.ts +177 -17
- package/packages/frond/src/index.ts +8 -0
- package/packages/orm/dist/index.js +8125 -6708
- package/packages/orm/src/adapters/firebird.ts +50 -3
- package/packages/orm/src/adapters/mongodb.ts +8 -0
- package/packages/orm/src/adapters/mssql.ts +29 -7
- package/packages/orm/src/adapters/mysql.ts +17 -1
- package/packages/orm/src/adapters/odbc.ts +11 -0
- package/packages/orm/src/adapters/pg-types.d.ts +8 -0
- package/packages/orm/src/adapters/postgres.ts +34 -8
- package/packages/orm/src/adapters/sqlDialect.ts +57 -0
- package/packages/orm/src/adapters/sqlite.ts +26 -0
- package/packages/orm/src/autoCrud.ts +78 -36
- package/packages/orm/src/baseModel.ts +61 -29
- package/packages/orm/src/cachedDatabase.ts +67 -1
- package/packages/orm/src/connectTimeout.ts +8 -0
- package/packages/orm/src/database.ts +649 -401
- package/packages/orm/src/databaseResult.ts +8 -0
- package/packages/orm/src/databaseUrl.ts +8 -0
- package/packages/orm/src/docstore.ts +23 -1
- package/packages/orm/src/fakeData.ts +8 -0
- package/packages/orm/src/graph/adapters/arango.ts +8 -0
- package/packages/orm/src/graph/adapters/bolt.ts +8 -0
- package/packages/orm/src/graph/adapters/ultipa.ts +8 -0
- package/packages/orm/src/graph/connectTimeout.ts +8 -0
- package/packages/orm/src/graph/errors.ts +8 -0
- package/packages/orm/src/graph/graphAdapter.ts +8 -0
- package/packages/orm/src/graph/graphDatabase.ts +8 -0
- package/packages/orm/src/graph/graphUrl.ts +10 -1
- package/packages/orm/src/graph/shapes.ts +8 -0
- package/packages/orm/src/index.ts +147 -139
- package/packages/orm/src/migration.ts +8 -0
- package/packages/orm/src/model.ts +8 -0
- package/packages/orm/src/modelCollection.ts +8 -0
- package/packages/orm/src/point.ts +8 -0
- package/packages/orm/src/query.ts +149 -33
- package/packages/orm/src/queryBuilder.ts +8 -0
- package/packages/orm/src/realtime/index.ts +8 -0
- package/packages/orm/src/realtime/models/attachment.ts +8 -0
- package/packages/orm/src/realtime/models/channel.ts +8 -0
- package/packages/orm/src/realtime/models/channelMember.ts +8 -0
- package/packages/orm/src/realtime/models/message.ts +8 -0
- package/packages/orm/src/realtime/models/workspace.ts +8 -0
- package/packages/orm/src/realtime/realtime.ts +8 -0
- package/packages/orm/src/realtime/storage.ts +43 -12
- package/packages/orm/src/seeder.ts +8 -0
- package/packages/orm/src/seederTable.ts +8 -0
- package/packages/orm/src/seederTypes.ts +8 -0
- package/packages/orm/src/sqlTranslator.ts +185 -8
- package/packages/orm/src/types.ts +15 -0
- package/packages/orm/src/validation.ts +8 -0
- package/packages/swagger/src/generator.ts +8 -0
- package/packages/swagger/src/index.ts +8 -0
- package/packages/swagger/src/ui.ts +8 -0
- package/types/core/src/background.d.ts +0 -16
- package/types/core/src/cache.d.ts +6 -0
- package/types/core/src/devAdmin.d.ts +18 -12
- package/types/core/src/docs.d.ts +0 -17
- package/types/core/src/errorOverlay.d.ts +0 -24
- package/types/core/src/events.d.ts +0 -12
- package/types/core/src/feedback.d.ts +0 -23
- package/types/core/src/graphql.d.ts +0 -21
- package/types/core/src/htmlElement.d.ts +0 -15
- package/types/core/src/messenger.d.ts +12 -1
- package/types/core/src/metrics.d.ts +2 -0
- package/types/core/src/middleware.d.ts +45 -1
- package/types/core/src/mqtt.d.ts +0 -33
- package/types/core/src/optionalPackage.d.ts +25 -0
- package/types/core/src/plan.d.ts +0 -10
- package/types/core/src/projectIndex.d.ts +0 -10
- package/types/core/src/queueBackends/mongoBackend.d.ts +2 -0
- package/types/core/src/request.d.ts +12 -0
- package/types/core/src/response.d.ts +13 -0
- package/types/core/src/server.d.ts +19 -0
- package/types/core/src/sessionHandlers/childError.d.ts +0 -15
- package/types/core/src/transport.d.ts +28 -0
- package/types/core/src/trustedProxy.d.ts +0 -16
- package/types/core/src/types.d.ts +5 -0
- package/types/core/src/websocketBackplane.d.ts +1 -0
- package/types/core/src/wsdl.d.ts +8 -22
- package/types/frond/src/engine.d.ts +7 -1
- package/types/orm/src/adapters/firebird.d.ts +12 -0
- package/types/orm/src/adapters/mssql.d.ts +11 -0
- package/types/orm/src/adapters/mysql.d.ts +2 -0
- package/types/orm/src/adapters/postgres.d.ts +11 -0
- package/types/orm/src/adapters/sqlDialect.d.ts +25 -0
- package/types/orm/src/adapters/sqlite.d.ts +7 -0
- package/types/orm/src/baseModel.d.ts +5 -0
- package/types/orm/src/cachedDatabase.d.ts +27 -0
- package/types/orm/src/database.d.ts +46 -7
- package/types/orm/src/graph/graphUrl.d.ts +0 -8
- package/types/orm/src/index.d.ts +1 -1
- package/types/orm/src/query.d.ts +48 -2
- package/types/orm/src/realtime/storage.d.ts +2 -1
- package/types/orm/src/sqlTranslator.d.ts +70 -18
- 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
|
-
|
|
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
|
|
594
|
-
|
|
595
|
-
|
|
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
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
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.
|
|
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
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
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
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
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
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
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
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
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
|
-
|
|
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
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
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
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
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.
|
|
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
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
const
|
|
904
|
-
const
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
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
|
|
910
|
-
`
|
|
911
|
-
`
|
|
912
|
-
`
|
|
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
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
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
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
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
|
-
|
|
943
|
-
}
|
|
1132
|
+
return Database.assertWrote(result, "update", table);
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
944
1135
|
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
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
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
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
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
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
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1049
|
-
|
|
1050
|
-
if (store) {
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
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
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
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
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
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
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
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
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1491
|
+
return this.withConnectionOperation(async () => {
|
|
1492
|
+
const adapter = this.getNextAdapter();
|
|
1250
1493
|
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
if (
|
|
1321
|
-
|
|
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
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
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
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
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
|
-
|
|
1699
|
+
return this.withConnectionOperation(async () => {
|
|
1700
|
+
const adapter = this.getNextAdapter();
|
|
1454
1701
|
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
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
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1714
|
+
// Firebird — use generators (atomic)
|
|
1715
|
+
if (this.dbType === "firebird") {
|
|
1716
|
+
const genName = generatorName ?? `GEN_${table.toUpperCase()}_ID`;
|
|
1470
1717
|
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
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
|
-
|
|
1479
|
-
|
|
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
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
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
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
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
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
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
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
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
|
|