tina4-nodejs 3.13.94 → 3.13.96
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 +158 -30
- package/README.md +1 -1
- package/package.json +3 -1
- package/packages/cli/dist/bin.js +30911 -28444
- package/packages/cli/src/commands/metrics.ts +17 -11
- package/packages/cli/src/commands/serve.ts +10 -9
- package/packages/core/dist/index.js +30810 -28261
- package/packages/core/public/css/tina4.min.css +1 -1
- package/packages/core/src/ai.ts +7 -1
- package/packages/core/src/auth.ts +191 -39
- package/packages/core/src/background.ts +19 -19
- package/packages/core/src/cache.ts +492 -49
- package/packages/core/src/devAdmin.ts +79 -32
- package/packages/core/src/dispatchPipeline.ts +285 -0
- package/packages/core/src/dotenv.ts +185 -40
- package/packages/core/src/index.ts +6 -7
- package/packages/core/src/logger.ts +257 -36
- package/packages/core/src/mcp.ts +1 -1
- package/packages/core/src/messenger.ts +294 -106
- package/packages/core/src/metrics.ts +199 -961
- package/packages/core/src/middleware.ts +390 -123
- package/packages/core/src/queue.ts +188 -32
- package/packages/core/src/queueBackends/kafkaBackend.ts +1 -1
- package/packages/core/src/queueBackends/liteBackend.ts +13 -0
- package/packages/core/src/queueBackends/mongoBackend.ts +101 -9
- package/packages/core/src/queueBackends/rabbitmqBackend.ts +22 -4
- package/packages/core/src/rateLimiter.ts +10 -5
- package/packages/core/src/request.ts +34 -16
- package/packages/core/src/response.ts +46 -1
- package/packages/core/src/router.ts +29 -4
- package/packages/core/src/server.ts +886 -421
- package/packages/core/src/session.ts +244 -27
- package/packages/core/src/sessionHandlers/databaseHandler.ts +338 -48
- package/packages/core/src/sessionHandlers/memcachedHandler.ts +181 -0
- package/packages/core/src/sessionHandlers/mongoClient.ts +293 -208
- package/packages/core/src/sessionHandlers/mongoHandler.ts +88 -8
- package/packages/core/src/sessionHandlers/respClient.ts +16 -147
- package/packages/core/src/sessionHandlers/sqlClient.ts +290 -0
- package/packages/core/src/sessionHandlers/syncBridge.ts +190 -0
- package/packages/core/src/sessionHandlers/syncSocket.ts +236 -0
- package/packages/core/src/testClient.ts +18 -5
- package/packages/core/src/trustedProxy.ts +249 -0
- package/packages/core/src/types.ts +29 -5
- package/packages/core/src/websocket.ts +66 -0
- package/packages/orm/dist/index.js +22717 -20168
- package/packages/orm/src/adapters/firebird.ts +183 -56
- package/packages/orm/src/adapters/mongodb.ts +25 -4
- package/packages/orm/src/adapters/mssql.ts +114 -29
- package/packages/orm/src/adapters/mysql.ts +103 -40
- package/packages/orm/src/adapters/odbc.ts +44 -21
- package/packages/orm/src/adapters/postgres.ts +118 -26
- package/packages/orm/src/adapters/sqlDialect.ts +120 -0
- package/packages/orm/src/adapters/sqlite.ts +60 -24
- package/packages/orm/src/autoCrud.ts +12 -10
- package/packages/orm/src/baseModel.ts +135 -40
- package/packages/orm/src/cachedDatabase.ts +43 -19
- package/packages/orm/src/connectTimeout.ts +265 -0
- package/packages/orm/src/database.ts +241 -197
- package/packages/orm/src/databaseResult.ts +51 -28
- package/packages/orm/src/databaseUrl.ts +484 -0
- package/packages/orm/src/docstore.ts +386 -145
- package/packages/orm/src/index.ts +13 -6
- package/packages/orm/src/migration.ts +44 -11
- package/packages/orm/src/model.ts +4 -0
- package/packages/orm/src/queryBuilder.ts +47 -6
- package/packages/orm/src/sqlTranslator.ts +310 -4
- package/packages/orm/src/types.ts +21 -77
- package/packages/swagger/dist/index.js +78 -20
- package/packages/swagger/src/generator.ts +172 -29
- package/types/core/src/ai.d.ts +1 -1
- package/types/core/src/auth.d.ts +28 -5
- package/types/core/src/background.d.ts +3 -3
- package/types/core/src/cache.d.ts +15 -12
- package/types/core/src/dispatchPipeline.d.ts +117 -0
- package/types/core/src/dotenv.d.ts +38 -16
- package/types/core/src/index.d.ts +6 -9
- package/types/core/src/logger.d.ts +93 -16
- package/types/core/src/messenger.d.ts +47 -6
- package/types/core/src/metrics.d.ts +25 -61
- package/types/core/src/middleware.d.ts +134 -11
- package/types/core/src/queue.d.ts +54 -5
- package/types/core/src/queueBackends/kafkaBackend.d.ts +1 -1
- package/types/core/src/queueBackends/liteBackend.d.ts +9 -0
- package/types/core/src/queueBackends/mongoBackend.d.ts +24 -2
- package/types/core/src/queueBackends/rabbitmqBackend.d.ts +3 -3
- package/types/core/src/router.d.ts +14 -3
- package/types/core/src/server.d.ts +15 -4
- package/types/core/src/session.d.ts +87 -2
- package/types/core/src/sessionHandlers/databaseHandler.d.ts +60 -5
- package/types/core/src/sessionHandlers/memcachedHandler.d.ts +60 -0
- package/types/core/src/sessionHandlers/mongoClient.d.ts +16 -5
- package/types/core/src/sessionHandlers/mongoHandler.d.ts +51 -3
- package/types/core/src/sessionHandlers/respClient.d.ts +2 -2
- package/types/core/src/sessionHandlers/sqlClient.d.ts +39 -0
- package/types/core/src/sessionHandlers/syncBridge.d.ts +91 -0
- package/types/core/src/sessionHandlers/syncSocket.d.ts +49 -0
- package/types/core/src/trustedProxy.d.ts +44 -0
- package/types/core/src/types.d.ts +28 -5
- package/types/core/src/websocket.d.ts +26 -0
- package/types/orm/src/adapters/firebird.d.ts +55 -10
- package/types/orm/src/adapters/mongodb.d.ts +2 -2
- package/types/orm/src/adapters/mssql.d.ts +18 -11
- package/types/orm/src/adapters/mysql.d.ts +11 -10
- package/types/orm/src/adapters/odbc.d.ts +9 -12
- package/types/orm/src/adapters/postgres.d.ts +11 -10
- package/types/orm/src/adapters/sqlDialect.d.ts +71 -0
- package/types/orm/src/adapters/sqlite.d.ts +15 -3
- package/types/orm/src/baseModel.d.ts +45 -9
- package/types/orm/src/cachedDatabase.d.ts +18 -5
- package/types/orm/src/connectTimeout.d.ts +100 -0
- package/types/orm/src/database.d.ts +78 -28
- package/types/orm/src/databaseResult.d.ts +29 -15
- package/types/orm/src/databaseUrl.d.ts +125 -0
- package/types/orm/src/docstore.d.ts +102 -43
- package/types/orm/src/index.d.ts +6 -4
- package/types/orm/src/migration.d.ts +4 -3
- package/types/orm/src/queryBuilder.d.ts +23 -3
- package/types/orm/src/sqlTranslator.d.ts +126 -2
- package/types/orm/src/types.d.ts +21 -38
- package/packages/core/src/scss.ts +0 -623
- package/packages/core/src/sessionHandlers/redisHandler.ts +0 -219
- package/types/core/src/scss.d.ts +0 -19
- package/types/core/src/sessionHandlers/redisHandler.d.ts +0 -60
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
2
|
import type { DatabaseAdapter, DatabaseResult as DatabaseWriteResult, ColumnInfo, FieldDefinition } from "./types.js";
|
|
3
3
|
import { DatabaseResult } from "./databaseResult.js";
|
|
4
|
+
import { DatabaseUrl } from "./databaseUrl.js";
|
|
4
5
|
import { CachedDatabaseAdapter, type CachedAdapterOptions } from "./cachedDatabase.js";
|
|
5
|
-
import { QueryCache } from "./sqlTranslator.js";
|
|
6
|
+
import { QueryCache, SQLTranslator } from "./sqlTranslator.js";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* v3.13.12 — strip trailing `;` and whitespace from user-supplied SQL
|
|
@@ -96,13 +97,13 @@ export async function adapterTableExists(adapter: DatabaseAdapter, name: string)
|
|
|
96
97
|
export async function adapterTables(adapter: DatabaseAdapter): Promise<string[]> {
|
|
97
98
|
return (adapter as any).tablesAsync
|
|
98
99
|
? await (adapter as any).tablesAsync()
|
|
99
|
-
: adapter.
|
|
100
|
+
: adapter.getTables();
|
|
100
101
|
}
|
|
101
102
|
|
|
102
103
|
export async function adapterColumns(adapter: DatabaseAdapter, table: string): Promise<ColumnInfo[]> {
|
|
103
104
|
return (adapter as any).columnsAsync
|
|
104
105
|
? await (adapter as any).columnsAsync(table)
|
|
105
|
-
: adapter.
|
|
106
|
+
: adapter.getColumns(table);
|
|
106
107
|
}
|
|
107
108
|
|
|
108
109
|
export async function adapterCreateTable(
|
|
@@ -112,6 +113,67 @@ export async function adapterCreateTable(
|
|
|
112
113
|
else adapter.createTable(name, columns);
|
|
113
114
|
}
|
|
114
115
|
|
|
116
|
+
/**
|
|
117
|
+
* The true row count for `sql`, ignoring the pagination the caller applied.
|
|
118
|
+
*
|
|
119
|
+
* `count` on a DatabaseResult is the TRUE TOTAL for the filter, not the number
|
|
120
|
+
* of rows the page returned. Node and Ruby used to populate it with
|
|
121
|
+
* `records.length` while Python and PHP populated it from a probe, so
|
|
122
|
+
* `db.fetch(sql).count` answered 20 here and 250 there for one query against one
|
|
123
|
+
* table, and every `toPaginate()` envelope built on it under-reported (ADR-0043).
|
|
124
|
+
* MEASURED 2026-08-05 on a 250-row table read with limit=20: Node reported total
|
|
125
|
+
* 20 over 2 pages against Python's 250 over 13.
|
|
126
|
+
*
|
|
127
|
+
* This is the single source of truth for that probe. Both read paths that build a
|
|
128
|
+
* DatabaseResult — `Database.fetch()` and `QueryBuilder.get()` — call it, so the
|
|
129
|
+
* two can never drift (QueryBuilder.get used to leave `count` at rows-returned,
|
|
130
|
+
* diverging from db.fetch AND from Python/Ruby, whose get() routes through fetch).
|
|
131
|
+
*
|
|
132
|
+
* Only probed when a limit was actually applied. With no limit the rows returned
|
|
133
|
+
* ARE the whole answer for this SQL, so `records.length` is already the true total
|
|
134
|
+
* and a second round-trip would buy nothing — which is also what keeps
|
|
135
|
+
* `fetchAll()` at one query.
|
|
136
|
+
*
|
|
137
|
+
* BEST EFFORT, and it can never mask a real failure: it runs AFTER the main query
|
|
138
|
+
* (which has already thrown on bad SQL) and returns `undefined` on any error.
|
|
139
|
+
* `undefined` — not 0 — is the miss value, so DatabaseResult falls back to
|
|
140
|
+
* records.length, a true lower bound. Reporting 0 next to 100 real records would
|
|
141
|
+
* be the same "states a wrong number authoritatively" defect this exists to remove.
|
|
142
|
+
*
|
|
143
|
+
* The closing paren goes on its OWN LINE: appended inline, a trailing
|
|
144
|
+
* `-- comment` in the caller's SQL comments it out and the probe dies with
|
|
145
|
+
* "incomplete input". Postgres, MySQL and MSSQL additionally require a name for
|
|
146
|
+
* the derived table; SQLite and Firebird do not, and Firebird rejects `AS` there —
|
|
147
|
+
* so the alias comes from the adapter, not an assumption.
|
|
148
|
+
*/
|
|
149
|
+
export async function probeTotal(
|
|
150
|
+
adapter: DatabaseAdapter,
|
|
151
|
+
sql: string,
|
|
152
|
+
params: unknown[] | undefined,
|
|
153
|
+
limit: number | undefined,
|
|
154
|
+
): Promise<number | undefined> {
|
|
155
|
+
if (limit === undefined || limit <= 0) return undefined;
|
|
156
|
+
try {
|
|
157
|
+
const alias = (adapter as any).countSubqueryAlias as string | undefined;
|
|
158
|
+
const suffix = alias ? ` AS ${alias}` : "";
|
|
159
|
+
const rows = await adapterFetch(
|
|
160
|
+
adapter,
|
|
161
|
+
`SELECT COUNT(*) AS tina4_total FROM (${sql}\n)${suffix}`,
|
|
162
|
+
params,
|
|
163
|
+
undefined,
|
|
164
|
+
undefined,
|
|
165
|
+
true,
|
|
166
|
+
);
|
|
167
|
+
const row = Array.isArray(rows) ? (rows[0] as Record<string, unknown> | undefined) : undefined;
|
|
168
|
+
if (!row) return undefined;
|
|
169
|
+
const value = row["tina4_total"] ?? row["TINA4_TOTAL"] ?? Object.values(row)[0];
|
|
170
|
+
const n = Number(value);
|
|
171
|
+
return Number.isFinite(n) ? n : undefined;
|
|
172
|
+
} catch {
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
115
177
|
/**
|
|
116
178
|
* Extract the engine-assigned auto-increment id from an `execute()` result.
|
|
117
179
|
*
|
|
@@ -131,6 +193,16 @@ export function extractLastInsertId(result: unknown): number | bigint | null {
|
|
|
131
193
|
}
|
|
132
194
|
|
|
133
195
|
let activeAdapter: DatabaseAdapter | null = null;
|
|
196
|
+
/**
|
|
197
|
+
* The default row cap on every read path that advertises a `limit`.
|
|
198
|
+
*
|
|
199
|
+
* One number for the whole family (Python, PHP, Ruby and Node all default to
|
|
200
|
+
* this). Pagination is a default principle: an un-paginated read of a table
|
|
201
|
+
* that grew to a million rows is a production incident waiting to happen. A
|
|
202
|
+
* caller who wants more passes a bigger limit.
|
|
203
|
+
*/
|
|
204
|
+
export const DEFAULT_ROW_CAP = 100;
|
|
205
|
+
|
|
134
206
|
const namedAdapters: Map<string, DatabaseAdapter> = new Map();
|
|
135
207
|
|
|
136
208
|
/**
|
|
@@ -289,166 +361,20 @@ export interface DatabaseConfig {
|
|
|
289
361
|
/**
|
|
290
362
|
* Parsed result from a TINA4_DATABASE_URL connection string.
|
|
291
363
|
*/
|
|
292
|
-
export interface ParsedDatabaseUrl {
|
|
293
|
-
type: "sqlite" | "postgres" | "mysql" | "mssql" | "firebird" | "mongodb" | "odbc";
|
|
294
|
-
path?: string;
|
|
295
|
-
host?: string;
|
|
296
|
-
port?: number;
|
|
297
|
-
user?: string;
|
|
298
|
-
password?: string;
|
|
299
|
-
database?: string;
|
|
300
|
-
/** ODBC-specific: raw connection string passed to odbc.connect() */
|
|
301
|
-
connectionString?: string;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
364
|
/**
|
|
305
|
-
* Parse a
|
|
365
|
+
* Parse a connection URL into a `DatabaseUrl` value.
|
|
306
366
|
*
|
|
307
|
-
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
310
|
-
*
|
|
311
|
-
*
|
|
312
|
-
* mysql://user:pass@host:port/dbname
|
|
367
|
+
* Breaking (feature 5): this returned a `ParsedDatabaseUrl` struct whose fields
|
|
368
|
+
* were `type`, `user` and `path`. It now returns a `DatabaseUrl`, whose fields
|
|
369
|
+
* are `engine`, `username` and `database` - the same names PHP, Python and Ruby
|
|
370
|
+
* use, and the same names as the TINA4_DATABASE_USERNAME env var they come from.
|
|
371
|
+
* `ParsedDatabaseUrl` is gone rather than kept as an alias.
|
|
313
372
|
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
* @param password - Optional password to merge when the URL has no credentials.
|
|
317
|
-
* @returns Parsed database configuration.
|
|
318
|
-
* @throws Error if the URL scheme is not supported.
|
|
373
|
+
* The 43-CC body that used to live here - the worst function measured anywhere
|
|
374
|
+
* in the audit - is now one small parser per engine inside the value type.
|
|
319
375
|
*/
|
|
320
|
-
export function parseDatabaseUrl(url: string, username?: string, password?: string):
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
// Handle sqlite:// separately because URL class mangles the path.
|
|
324
|
-
//
|
|
325
|
-
// Convention (matches tina4-python, tina4-php, and the docs):
|
|
326
|
-
// sqlite::memory: → in-memory
|
|
327
|
-
// sqlite:///:memory: → in-memory (URL form)
|
|
328
|
-
// sqlite:///app.db → ./app.db (relative to cwd)
|
|
329
|
-
// sqlite:///data/app.db → ./data/app.db (relative)
|
|
330
|
-
// sqlite:////absolute/app.db → /absolute/app.db (absolute)
|
|
331
|
-
// sqlite:///C:/Users/app.db → C:/Users/app.db (Windows absolute)
|
|
332
|
-
if (url === "sqlite::memory:" || url === "sqlite:///:memory:") {
|
|
333
|
-
result = { type: "sqlite", path: ":memory:" };
|
|
334
|
-
} else if (url.startsWith("sqlite:///")) {
|
|
335
|
-
// Strip the "sqlite://" prefix (leaving one "/" + path)
|
|
336
|
-
let rest = url.slice("sqlite://".length); // e.g. "/data/app.db" or "//abs/app.db" or "/C:/Users/..."
|
|
337
|
-
// Drop exactly one leading "/"
|
|
338
|
-
if (rest.startsWith("/")) rest = rest.slice(1);
|
|
339
|
-
// Windows absolute: C:/Users/app.db or C:\...
|
|
340
|
-
const isWindowsAbs = /^[A-Za-z]:[\/\\]/.test(rest);
|
|
341
|
-
// Unix absolute: still starts with "/" after the strip (four-slash URL form)
|
|
342
|
-
const isUnixAbs = rest.startsWith("/");
|
|
343
|
-
result = { type: "sqlite", path: isWindowsAbs || isUnixAbs ? rest : rest };
|
|
344
|
-
// Relative paths are resolved against cwd by the SQLite adapter at connect time;
|
|
345
|
-
// keep the string as-is here so tests can inspect the raw form.
|
|
346
|
-
} else if (url.startsWith("sqlite://")) {
|
|
347
|
-
// sqlite://./relative or sqlite://relative — legacy two-slash form
|
|
348
|
-
const path = url.slice("sqlite://".length);
|
|
349
|
-
result = { type: "sqlite", path };
|
|
350
|
-
} else if (url.startsWith("sqlite:")) {
|
|
351
|
-
// sqlite:/abs/app.db (one slash = a real absolute path) or sqlite:app.db (relative).
|
|
352
|
-
// Keep the leading slash so resolveSqlitePath's isAbsolute() sees the absolute path —
|
|
353
|
-
// this form used to fall through and throw "unsupported scheme" (the naive-abs footgun).
|
|
354
|
-
const path = url.slice("sqlite:".length);
|
|
355
|
-
result = { type: "sqlite", path };
|
|
356
|
-
} else if (url.startsWith("mssql://") || url.startsWith("sqlserver://")) {
|
|
357
|
-
// Handle mssql:// and sqlserver:// with custom parsing (URL class doesn't know these schemes)
|
|
358
|
-
const match = url.match(/(?:mssql|sqlserver):\/\/(?:([^:]+):([^@]+)@)?([^:/]+)(?::(\d+))?\/(.*)/);
|
|
359
|
-
if (!match) throw new Error(`Invalid MSSQL URL: ${url}`);
|
|
360
|
-
result = {
|
|
361
|
-
type: "mssql",
|
|
362
|
-
user: match[1] ? decodeURIComponent(match[1]) : undefined,
|
|
363
|
-
password: match[2] ? decodeURIComponent(match[2]) : undefined,
|
|
364
|
-
host: match[3],
|
|
365
|
-
port: match[4] ? parseInt(match[4], 10) : undefined,
|
|
366
|
-
database: match[5],
|
|
367
|
-
};
|
|
368
|
-
} else if (url.startsWith("firebird://")) {
|
|
369
|
-
const match = url.match(/firebird:\/\/(?:([^:]+):([^@]+)@)?([^:/]+)(?::(\d+))?\/(.*)/);
|
|
370
|
-
if (!match) throw new Error(`Invalid Firebird URL: ${url}`);
|
|
371
|
-
result = {
|
|
372
|
-
type: "firebird",
|
|
373
|
-
user: match[1] ? decodeURIComponent(match[1]) : undefined,
|
|
374
|
-
password: match[2] ? decodeURIComponent(match[2]) : undefined,
|
|
375
|
-
host: match[3],
|
|
376
|
-
port: match[4] ? parseInt(match[4], 10) : undefined,
|
|
377
|
-
database: "/" + match[5],
|
|
378
|
-
};
|
|
379
|
-
} else if (url.startsWith("odbc:///")) {
|
|
380
|
-
// odbc:///DSN=MyDSN or odbc:///DRIVER={driver};SERVER=host;DATABASE=db
|
|
381
|
-
// Strip the "odbc:///" prefix and pass the rest directly as the connection string
|
|
382
|
-
const connectionString = url.slice("odbc:///".length);
|
|
383
|
-
result = { type: "odbc", connectionString };
|
|
384
|
-
} else if (url.startsWith("mongodb://") || url.startsWith("mongodb+srv://")) {
|
|
385
|
-
// Pass through as-is; MongodbAdapter handles the full connection string
|
|
386
|
-
let parsed: URL;
|
|
387
|
-
try {
|
|
388
|
-
parsed = new URL(url);
|
|
389
|
-
} catch {
|
|
390
|
-
throw new Error(`Invalid MongoDB URL: ${url}`);
|
|
391
|
-
}
|
|
392
|
-
const database = parsed.pathname.replace(/^\//, "") || "tina4";
|
|
393
|
-
result = {
|
|
394
|
-
type: "mongodb",
|
|
395
|
-
host: parsed.hostname || undefined,
|
|
396
|
-
port: parsed.port ? parseInt(parsed.port, 10) : undefined,
|
|
397
|
-
user: parsed.username ? decodeURIComponent(parsed.username) : undefined,
|
|
398
|
-
password: parsed.password ? decodeURIComponent(parsed.password) : undefined,
|
|
399
|
-
database,
|
|
400
|
-
};
|
|
401
|
-
} else {
|
|
402
|
-
// Normalize postgres:// and pgsql:// (the PDO/Laravel/Doctrine scheme
|
|
403
|
-
// name, issue #58) to postgresql:// for URL parsing.
|
|
404
|
-
const normalizedUrl = /^(postgres|pgsql):\/\//.test(url)
|
|
405
|
-
? url.replace(/^(postgres|pgsql):\/\//, "postgresql://")
|
|
406
|
-
: url;
|
|
407
|
-
|
|
408
|
-
let parsed: URL;
|
|
409
|
-
try {
|
|
410
|
-
parsed = new URL(normalizedUrl);
|
|
411
|
-
} catch {
|
|
412
|
-
throw new Error(`Invalid database URL: ${url}`);
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
const scheme = parsed.protocol.replace(/:$/, "");
|
|
416
|
-
let type: "sqlite" | "postgres" | "mysql" | "mssql" | "firebird";
|
|
417
|
-
|
|
418
|
-
switch (scheme) {
|
|
419
|
-
case "postgresql":
|
|
420
|
-
type = "postgres";
|
|
421
|
-
break;
|
|
422
|
-
case "mysql":
|
|
423
|
-
type = "mysql";
|
|
424
|
-
break;
|
|
425
|
-
default:
|
|
426
|
-
throw new Error(`Unsupported database URL scheme: "${scheme}". Supported: sqlite, postgres/postgresql, mysql, mssql/sqlserver, firebird.`);
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
const database = parsed.pathname.startsWith("/")
|
|
430
|
-
? parsed.pathname.slice(1)
|
|
431
|
-
: parsed.pathname;
|
|
432
|
-
|
|
433
|
-
result = {
|
|
434
|
-
type,
|
|
435
|
-
host: parsed.hostname || undefined,
|
|
436
|
-
port: parsed.port ? parseInt(parsed.port, 10) : undefined,
|
|
437
|
-
user: parsed.username ? decodeURIComponent(parsed.username) : undefined,
|
|
438
|
-
password: parsed.password ? decodeURIComponent(parsed.password) : undefined,
|
|
439
|
-
database: database || undefined,
|
|
440
|
-
};
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
// Merge separate username/password when the URL contained no credentials
|
|
444
|
-
if (!result.user && username) {
|
|
445
|
-
result.user = username;
|
|
446
|
-
}
|
|
447
|
-
if (!result.password && password) {
|
|
448
|
-
result.password = password;
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
return result;
|
|
376
|
+
export function parseDatabaseUrl(url: string, username?: string, password?: string): DatabaseUrl {
|
|
377
|
+
return new DatabaseUrl(url, username, password);
|
|
452
378
|
}
|
|
453
379
|
|
|
454
380
|
/**
|
|
@@ -574,7 +500,7 @@ export class Database {
|
|
|
574
500
|
db.poolIndex = 0;
|
|
575
501
|
db.adapter = null; // Don't use single-adapter path
|
|
576
502
|
db.adapterFactory = async () => wrapWithCache(await createAdapterFromUrl(url, username, password), { sharedCache });
|
|
577
|
-
db.dbType = parsed.
|
|
503
|
+
db.dbType = parsed.engine;
|
|
578
504
|
return exposeDb(db);
|
|
579
505
|
}
|
|
580
506
|
|
|
@@ -584,7 +510,7 @@ export class Database {
|
|
|
584
510
|
const adapter = await createAdapterFromUrl(url, username, password);
|
|
585
511
|
const wrapped = setAdapter(adapter);
|
|
586
512
|
const db = new Database(wrapped);
|
|
587
|
-
db.dbType = parsed.
|
|
513
|
+
db.dbType = parsed.engine;
|
|
588
514
|
return exposeDb(db);
|
|
589
515
|
}
|
|
590
516
|
|
|
@@ -675,7 +601,31 @@ export class Database {
|
|
|
675
601
|
* the fallback resolves instantly). This is the breaking change that makes
|
|
676
602
|
* the wrapper work uniformly across every engine.
|
|
677
603
|
*/
|
|
604
|
+
/**
|
|
605
|
+
* Fetch rows with pagination, capped at DEFAULT_ROW_CAP (100) when the
|
|
606
|
+
* caller does not pass a limit.
|
|
607
|
+
*
|
|
608
|
+
* The cap is the one row-cap number the whole family shares (Python, PHP and
|
|
609
|
+
* Ruby all default `fetch` to 100). Node was the outlier: `limit` was
|
|
610
|
+
* optional with NO default, so a bare `db.fetch("select * from big_table")`
|
|
611
|
+
* returned every row.
|
|
612
|
+
*
|
|
613
|
+
* `fetchAll` deliberately does NOT inherit the cap — see below.
|
|
614
|
+
*/
|
|
678
615
|
async fetch(sql: string, params?: unknown[], limit?: number, offset?: number, opts?: { noCache?: boolean }): Promise<DatabaseResult> {
|
|
616
|
+
return this._fetchWithLimit(sql, params, limit ?? DEFAULT_ROW_CAP, offset, opts);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* The shared read body. `limit` is passed through VERBATIM: `undefined`
|
|
621
|
+
* means "no LIMIT clause at all", which is how `fetchAll` stays uncapped.
|
|
622
|
+
*
|
|
623
|
+
* This exists because Node's adapters treat `limit: 0` as `LIMIT 0` (zero
|
|
624
|
+
* rows), not as the "no truncation" sentinel Python and PHP use — so the cap
|
|
625
|
+
* cannot live on the parameter default, or `fetchAll()` would silently
|
|
626
|
+
* inherit it and stop returning every row.
|
|
627
|
+
*/
|
|
628
|
+
private async _fetchWithLimit(sql: string, params?: unknown[], limit?: number, offset?: number, opts?: { noCache?: boolean }): Promise<DatabaseResult> {
|
|
679
629
|
// v3.13.12: strip trailing `;` before the adapter wraps with COUNT(*)
|
|
680
630
|
// or appends LIMIT/OFFSET. Without this, `"SELECT * FROM t;"` becomes
|
|
681
631
|
// `"SELECT * FROM t; LIMIT 100 OFFSET 0"` — a syntax error.
|
|
@@ -686,7 +636,8 @@ export class Database {
|
|
|
686
636
|
// no store, run directly (mirrors the Python master's `no_cache`).
|
|
687
637
|
const rows = await adapterFetch(adapter, sql, params, limit, offset, opts?.noCache);
|
|
688
638
|
this.lastError = null;
|
|
689
|
-
|
|
639
|
+
const total = await probeTotal(adapter, sql, params, limit);
|
|
640
|
+
return new DatabaseResult(rows, undefined, total, limit, offset, adapter, sql);
|
|
690
641
|
} catch (e: any) {
|
|
691
642
|
// v3.13.11 #49.2: fetch() records last_error like execute() does.
|
|
692
643
|
this.lastError = e?.message ?? String(e);
|
|
@@ -741,7 +692,10 @@ export class Database {
|
|
|
741
692
|
* SEPARATE trailing argument, never the params array.
|
|
742
693
|
*/
|
|
743
694
|
async fetchAll<T = Record<string, unknown>>(sql: string, params?: unknown[], limit?: number, offset?: number, opts?: { noCache?: boolean }): Promise<T[]> {
|
|
744
|
-
|
|
695
|
+
// Routes through _fetchWithLimit, NOT fetch(), so `limit` stays verbatim.
|
|
696
|
+
// Going through fetch() would apply the 100-row cap and make a method
|
|
697
|
+
// called "fetchAll" quietly stop returning them all.
|
|
698
|
+
return (await this._fetchWithLimit(sql, params, limit, offset, opts)).records as T[];
|
|
745
699
|
}
|
|
746
700
|
|
|
747
701
|
/**
|
|
@@ -835,13 +789,52 @@ export class Database {
|
|
|
835
789
|
* the WHERE clause. With neither a filter nor a primary key in `data` this
|
|
836
790
|
* throws rather than silently changing nothing (audit feature 4, P1).
|
|
837
791
|
*/
|
|
838
|
-
async update(table: string, data: Record<string, unknown>, filter?: Record<string, unknown
|
|
839
|
-
let effectiveFilter = filter ?? {};
|
|
792
|
+
async update(table: string, data: Record<string, unknown>, filter?: Record<string, unknown> | string, params?: unknown[]): Promise<DatabaseWriteResult> {
|
|
793
|
+
let effectiveFilter: Record<string, unknown> | string = filter ?? {};
|
|
840
794
|
let effectiveData = data;
|
|
841
795
|
|
|
842
|
-
|
|
796
|
+
// A string filter is the OTHER documented form ("id = ?" + params), so it
|
|
797
|
+
// must be tested as a string: Object.keys("id = ?") is ["0",..,"5"], which
|
|
798
|
+
// is non-empty by accident rather than by meaning — and an EMPTY string
|
|
799
|
+
// filter would then be treated as a real filter instead of falling through
|
|
800
|
+
// to the primary key.
|
|
801
|
+
const filterIsEmpty = typeof effectiveFilter === "string"
|
|
802
|
+
? effectiveFilter.trim() === ""
|
|
803
|
+
: Object.keys(effectiveFilter).length === 0;
|
|
804
|
+
|
|
805
|
+
if (filterIsEmpty) {
|
|
843
806
|
const pkColumns = await this.primaryKey(table);
|
|
844
|
-
|
|
807
|
+
// Resolve each key column to the caller's OWN key for it, matched
|
|
808
|
+
// case-insensitively.
|
|
809
|
+
//
|
|
810
|
+
// The engines disagree about identifier case BY DESIGN and always will:
|
|
811
|
+
// Firebird folds an unquoted identifier to UPPER, PostgreSQL folds it to
|
|
812
|
+
// LOWER, MySQL and SQLite preserve what was typed. Introspection returns
|
|
813
|
+
// the ENGINE's spelling while `data` carries the caller's, so `c in data`
|
|
814
|
+
// failed on whichever engine folds the other way. A case-sensitivity bug,
|
|
815
|
+
// not a Firebird quirk - Firebird just made it visible first.
|
|
816
|
+
//
|
|
817
|
+
// Deliberately does NOT lower-case introspection output: that would
|
|
818
|
+
// special-case one engine and break a genuinely quoted mixed-case table.
|
|
819
|
+
// The WHERE is built from the ENGINE's column name and the CALLER's value.
|
|
820
|
+
const resolved: Record<string, string> = {};
|
|
821
|
+
const missing: string[] = [];
|
|
822
|
+
for (const col of pkColumns) {
|
|
823
|
+
const folded = String(col).toLowerCase();
|
|
824
|
+
const matches = Object.keys(data).filter((k) => k.toLowerCase() === folded);
|
|
825
|
+
if (matches.length > 1) {
|
|
826
|
+
// Ambiguity is refused, never guessed - choosing wrong here writes the
|
|
827
|
+
// WHERE clause of an UPDATE.
|
|
828
|
+
throw new Error(
|
|
829
|
+
`update was given more than one key for the primary-key column ${col}: ` +
|
|
830
|
+
`[${matches.slice().sort().join(", ")}] (table=${table}). These differ ` +
|
|
831
|
+
`only by case, so which one identifies the row is ambiguous - pass ` +
|
|
832
|
+
`exactly one, or pass an explicit filter.`,
|
|
833
|
+
);
|
|
834
|
+
}
|
|
835
|
+
if (matches.length === 1) resolved[col] = matches[0];
|
|
836
|
+
else missing.push(col);
|
|
837
|
+
}
|
|
845
838
|
if (pkColumns.length === 0 || missing.length > 0) {
|
|
846
839
|
throw new Error(
|
|
847
840
|
`update requires a filter or the complete primary key in the data; pass ` +
|
|
@@ -856,8 +849,9 @@ export class Database {
|
|
|
856
849
|
effectiveData = { ...data };
|
|
857
850
|
const keyed: Record<string, unknown> = {};
|
|
858
851
|
for (const col of pkColumns) {
|
|
859
|
-
|
|
860
|
-
|
|
852
|
+
const callerKey = resolved[col];
|
|
853
|
+
keyed[col] = effectiveData[callerKey];
|
|
854
|
+
delete effectiveData[callerKey];
|
|
861
855
|
}
|
|
862
856
|
if (Object.keys(effectiveData).length === 0) {
|
|
863
857
|
throw new Error(
|
|
@@ -879,10 +873,19 @@ export class Database {
|
|
|
879
873
|
}
|
|
880
874
|
|
|
881
875
|
/** Delete rows. A filterless delete throws; use truncate() to empty a table. */
|
|
882
|
-
async delete(table: string, filter?: Record<string, unknown
|
|
876
|
+
async delete(table: string, filter?: Record<string, unknown> | string | Record<string, unknown>[], params?: unknown[]): Promise<DatabaseWriteResult> {
|
|
883
877
|
const effectiveFilter = filter ?? {};
|
|
884
|
-
|
|
885
|
-
|
|
878
|
+
// A BLANK string counts as no filter. The old guard skipped the emptiness
|
|
879
|
+
// test for anything typed string, so `delete(t, "")` fell through to the
|
|
880
|
+
// adapter, which renders an empty WHERE as `DELETE FROM "t"` — a silent
|
|
881
|
+
// whole-table delete through the very method that exists to make that
|
|
882
|
+
// impossible. truncate() is the explicit spelling.
|
|
883
|
+
const filterIsEmpty = Array.isArray(effectiveFilter)
|
|
884
|
+
? effectiveFilter.length === 0
|
|
885
|
+
: typeof effectiveFilter === "string"
|
|
886
|
+
? effectiveFilter.trim() === ""
|
|
887
|
+
: Object.keys(effectiveFilter).length === 0;
|
|
888
|
+
if (filterIsEmpty) {
|
|
886
889
|
throw new Error(
|
|
887
890
|
`delete requires a filter (table=${table}). To remove every row use truncate(${table}).`,
|
|
888
891
|
);
|
|
@@ -1075,9 +1078,35 @@ export class Database {
|
|
|
1075
1078
|
// (Database.execute_many delegating to adapter.execute_many's owns_txn guard).
|
|
1076
1079
|
const owns = !this.inExplicitTransaction();
|
|
1077
1080
|
if (owns) await adapterStartTransaction(adapter);
|
|
1081
|
+
|
|
1082
|
+
// ONE round-trip per CHUNK instead of one per ROW. Looping execute() here
|
|
1083
|
+
// pays a full network round-trip for every row: 500 rows took 9848ms on
|
|
1084
|
+
// PostgreSQL against 15.8ms as a single multi-row VALUES (625x), MySQL 216x,
|
|
1085
|
+
// MSSQL 121x. buildBatchInserts returns an empty array for anything it
|
|
1086
|
+
// cannot collapse safely — RETURNING, upserts, non-INSERT statements, ragged
|
|
1087
|
+
// rows, Firebird — and the row-at-a-time loop then runs unchanged.
|
|
1088
|
+
const batched = SQLTranslator.buildBatchInserts(sql, paramSets, this.dbType ?? "");
|
|
1089
|
+
|
|
1078
1090
|
try {
|
|
1079
|
-
|
|
1080
|
-
|
|
1091
|
+
if (batched.length > 0) {
|
|
1092
|
+
let row = 0;
|
|
1093
|
+
for (const [chunkSql, chunkParams] of batched) {
|
|
1094
|
+
const result = await adapterExecute(adapter, chunkSql, chunkParams);
|
|
1095
|
+
// executeMany's contract is ONE RESULT PER ROW, and callers index into
|
|
1096
|
+
// it. Collapsing rows into chunks must not shorten the array, so each
|
|
1097
|
+
// row reports the result of the statement that actually wrote it.
|
|
1098
|
+
// Node is the only one of the four returning per-row results — Python,
|
|
1099
|
+
// PHP and Ruby return a count or a single DatabaseResult — so this is
|
|
1100
|
+
// the one place the collapse could have been observable.
|
|
1101
|
+
const rowsInChunk = chunkParams.length / (paramSets[0]?.length || 1);
|
|
1102
|
+
for (let i = 0; i < rowsInChunk && row < paramSets.length; i++, row++) {
|
|
1103
|
+
results.push(result);
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
} else {
|
|
1107
|
+
for (const params of paramSets) {
|
|
1108
|
+
results.push(await adapterExecute(adapter, sql, params));
|
|
1109
|
+
}
|
|
1081
1110
|
}
|
|
1082
1111
|
if (owns) await adapterCommit(adapter);
|
|
1083
1112
|
} catch (e) {
|
|
@@ -1416,21 +1445,31 @@ export class Database {
|
|
|
1416
1445
|
* connected; SQLite connects lazily.
|
|
1417
1446
|
*/
|
|
1418
1447
|
export async function createAdapterFromUrl(url: string, username?: string, password?: string): Promise<DatabaseAdapter> {
|
|
1448
|
+
const adapter = await buildAdapterFromUrl(url, username, password);
|
|
1449
|
+
// Tag the adapter with WHICH DATABASE it is connected to. The query cache
|
|
1450
|
+
// folds this into every key, so two databases sharing one cache backend
|
|
1451
|
+
// cannot serve each other's rows. Set here because this is the single funnel
|
|
1452
|
+
// where a URL becomes an adapter.
|
|
1453
|
+
adapter.cacheIdentity = QueryCache.cacheIdentity(url);
|
|
1454
|
+
return adapter;
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
async function buildAdapterFromUrl(url: string, username?: string, password?: string): Promise<DatabaseAdapter> {
|
|
1419
1458
|
const parsed = parseDatabaseUrl(url, username, password);
|
|
1420
1459
|
|
|
1421
|
-
switch (parsed.
|
|
1460
|
+
switch (parsed.engine) {
|
|
1422
1461
|
case "sqlite": {
|
|
1423
1462
|
const { SQLiteAdapter } = await import("./adapters/sqlite.js");
|
|
1424
|
-
return new SQLiteAdapter(parsed.
|
|
1463
|
+
return new SQLiteAdapter(parsed.database || "./data/tina4.db");
|
|
1425
1464
|
}
|
|
1426
1465
|
case "postgres": {
|
|
1427
1466
|
const { PostgresAdapter } = await import("./adapters/postgres.js");
|
|
1428
1467
|
const adapter = new PostgresAdapter({
|
|
1429
|
-
host: parsed.host,
|
|
1430
|
-
port: parsed.port,
|
|
1431
|
-
user: parsed.
|
|
1432
|
-
password: parsed.password,
|
|
1433
|
-
database: parsed.database,
|
|
1468
|
+
host: parsed.host ?? undefined,
|
|
1469
|
+
port: parsed.port ?? undefined,
|
|
1470
|
+
user: parsed.username ?? undefined,
|
|
1471
|
+
password: parsed.password ?? undefined,
|
|
1472
|
+
database: parsed.database || undefined,
|
|
1434
1473
|
});
|
|
1435
1474
|
await adapter.connect();
|
|
1436
1475
|
return adapter;
|
|
@@ -1438,11 +1477,11 @@ export async function createAdapterFromUrl(url: string, username?: string, passw
|
|
|
1438
1477
|
case "mysql": {
|
|
1439
1478
|
const { MysqlAdapter } = await import("./adapters/mysql.js");
|
|
1440
1479
|
const adapter = new MysqlAdapter({
|
|
1441
|
-
host: parsed.host,
|
|
1442
|
-
port: parsed.port,
|
|
1443
|
-
user: parsed.
|
|
1444
|
-
password: parsed.password,
|
|
1445
|
-
database: parsed.database,
|
|
1480
|
+
host: parsed.host ?? undefined,
|
|
1481
|
+
port: parsed.port ?? undefined,
|
|
1482
|
+
user: parsed.username ?? undefined,
|
|
1483
|
+
password: parsed.password ?? undefined,
|
|
1484
|
+
database: parsed.database || undefined,
|
|
1446
1485
|
});
|
|
1447
1486
|
await adapter.connect();
|
|
1448
1487
|
return adapter;
|
|
@@ -1450,11 +1489,11 @@ export async function createAdapterFromUrl(url: string, username?: string, passw
|
|
|
1450
1489
|
case "mssql": {
|
|
1451
1490
|
const { MssqlAdapter } = await import("./adapters/mssql.js");
|
|
1452
1491
|
const adapter = new MssqlAdapter({
|
|
1453
|
-
host: parsed.host,
|
|
1454
|
-
port: parsed.port,
|
|
1455
|
-
user: parsed.
|
|
1456
|
-
password: parsed.password,
|
|
1457
|
-
database: parsed.database,
|
|
1492
|
+
host: parsed.host ?? undefined,
|
|
1493
|
+
port: parsed.port ?? undefined,
|
|
1494
|
+
user: parsed.username ?? undefined,
|
|
1495
|
+
password: parsed.password ?? undefined,
|
|
1496
|
+
database: parsed.database || undefined,
|
|
1458
1497
|
});
|
|
1459
1498
|
await adapter.connect();
|
|
1460
1499
|
return adapter;
|
|
@@ -1462,11 +1501,11 @@ export async function createAdapterFromUrl(url: string, username?: string, passw
|
|
|
1462
1501
|
case "firebird": {
|
|
1463
1502
|
const { FirebirdAdapter } = await import("./adapters/firebird.js");
|
|
1464
1503
|
const adapter = new FirebirdAdapter({
|
|
1465
|
-
host: parsed.host,
|
|
1466
|
-
port: parsed.port,
|
|
1467
|
-
user: parsed.
|
|
1468
|
-
password: parsed.password,
|
|
1469
|
-
database: parsed.database,
|
|
1504
|
+
host: parsed.host ?? undefined,
|
|
1505
|
+
port: parsed.port ?? undefined,
|
|
1506
|
+
user: parsed.username ?? undefined,
|
|
1507
|
+
password: parsed.password ?? undefined,
|
|
1508
|
+
database: parsed.database || undefined,
|
|
1470
1509
|
});
|
|
1471
1510
|
await adapter.connect();
|
|
1472
1511
|
return adapter;
|
|
@@ -1577,7 +1616,7 @@ export async function initDatabase(config?: DatabaseConfig): Promise<Database> {
|
|
|
1577
1616
|
const parsed = parseDatabaseUrl(url, resolvedUser, resolvedPassword);
|
|
1578
1617
|
const adapter = await createAdapterFromUrl(url, resolvedUser, resolvedPassword);
|
|
1579
1618
|
const db = new Database(setAdapter(adapter));
|
|
1580
|
-
db.setDbType(parsed.
|
|
1619
|
+
db.setDbType(parsed.engine);
|
|
1581
1620
|
return exposeDb(db);
|
|
1582
1621
|
}
|
|
1583
1622
|
|
|
@@ -1604,6 +1643,11 @@ export async function initDatabase(config?: DatabaseConfig): Promise<Database> {
|
|
|
1604
1643
|
// default and a `{ type: "postgres" }` connection takes the SQLite getNextId
|
|
1605
1644
|
// branch and crashes on the missing tina4_sequences table (#255).
|
|
1606
1645
|
const finished = (adapter: DatabaseAdapter): Database => {
|
|
1646
|
+
// Same identity tag as the URL path above - a config-object connection is
|
|
1647
|
+
// just as capable of sharing a cache backend with another database.
|
|
1648
|
+
adapter.cacheIdentity = QueryCache.cacheIdentity(
|
|
1649
|
+
`${type}://${config?.host ?? ""}:${config?.port ?? ""}/${config?.database ?? config?.path ?? ""}`,
|
|
1650
|
+
);
|
|
1607
1651
|
const db = new Database(setAdapter(adapter));
|
|
1608
1652
|
db.setDbType(type);
|
|
1609
1653
|
return exposeDb(db);
|