tina4-nodejs 3.13.95 → 3.13.97
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 +3 -4
- package/package.json +2 -1
- package/packages/cli/dist/bin.js +708 -1012
- package/packages/core/dist/index.js +588 -893
- package/packages/core/public/css/tina4.min.css +1 -1
- package/packages/core/src/index.ts +1 -3
- package/packages/core/src/messenger.ts +288 -96
- package/packages/core/src/queueBackends/kafkaBackend.ts +23 -2
- package/packages/core/src/queueBackends/rabbitmqBackend.ts +29 -17
- package/packages/core/src/request.ts +28 -7
- package/packages/core/src/server.ts +135 -7
- package/packages/core/src/session.ts +8 -1
- package/packages/orm/dist/index.js +639 -944
- package/packages/orm/src/autoCrud.ts +12 -10
- package/packages/orm/src/database.ts +62 -58
- package/packages/orm/src/databaseResult.ts +44 -73
- package/packages/orm/src/index.ts +0 -3
- package/packages/orm/src/migration.ts +26 -8
- package/packages/orm/src/model.ts +4 -0
- package/packages/orm/src/queryBuilder.ts +12 -5
- package/packages/orm/src/types.ts +7 -74
- package/packages/swagger/dist/index.js +78 -20
- package/packages/swagger/src/generator.ts +172 -29
- package/types/core/src/index.d.ts +1 -3
- package/types/core/src/messenger.d.ts +45 -4
- package/types/core/src/queueBackends/kafkaBackend.d.ts +1 -0
- package/types/core/src/queueBackends/rabbitmqBackend.d.ts +2 -1
- package/types/core/src/server.d.ts +0 -4
- package/types/core/src/session.d.ts +7 -0
- package/types/orm/src/database.d.ts +34 -30
- package/types/orm/src/databaseResult.d.ts +26 -36
- package/types/orm/src/index.d.ts +1 -2
- package/types/orm/src/migration.d.ts +4 -3
- package/types/orm/src/types.d.ts +7 -34
- package/packages/core/src/scss.ts +0 -623
- package/types/core/src/scss.d.ts +0 -19
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { RouteDefinition, Tina4Request, Tina4Response } from "../../core/src/index.js";
|
|
2
2
|
import type { DiscoveredModel } from "./model.js";
|
|
3
3
|
import { getAdapter, adapterQuery, adapterExecute } from "./database.js";
|
|
4
|
+
import { DatabaseResult } from "./databaseResult.js";
|
|
4
5
|
import { buildQuery, parseQueryString } from "./query.js";
|
|
5
6
|
import { validate } from "./validation.js";
|
|
6
7
|
|
|
@@ -158,20 +159,21 @@ export function generateCrudRoutes(models: DiscoveredModel[], options: AutoCrudO
|
|
|
158
159
|
const countParams = params.slice(0, -2);
|
|
159
160
|
const rows = await adapterQuery(adapter, sql, params);
|
|
160
161
|
|
|
162
|
+
// total is the TRUE total for the filter (a COUNT probe), NEVER the number
|
|
163
|
+
// of rows this page returned (ADR-0043). limit/offset are exactly what the
|
|
164
|
+
// SQL applied — buildQuery derives offset as (page - 1) * limit.
|
|
161
165
|
const countRow = await adapterQuery(adapter, countSql, countParams);
|
|
162
166
|
const total = Number(countRow[0]?.total ?? 0);
|
|
163
167
|
const limit = qp.limit ?? 100;
|
|
164
168
|
const page = qp.page ?? 1;
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
},
|
|
174
|
-
});
|
|
169
|
+
const offset = (page - 1) * limit;
|
|
170
|
+
|
|
171
|
+
// The REST list envelope IS the canonical paginate envelope: exactly the
|
|
172
|
+
// seven snake_case keys DatabaseResult.toPaginate() builds — records, total,
|
|
173
|
+
// page, per_page, total_pages, limit, offset — so this endpoint and
|
|
174
|
+
// db.fetch(...).toPaginate() can never drift (ADR-0043). No `data` alias, no
|
|
175
|
+
// camelCase `totalPages`, no nested `meta`.
|
|
176
|
+
res.json(new DatabaseResult(rows, undefined, total, limit, offset).toPaginate());
|
|
175
177
|
},
|
|
176
178
|
});
|
|
177
179
|
|
|
@@ -113,6 +113,67 @@ export async function adapterCreateTable(
|
|
|
113
113
|
else adapter.createTable(name, columns);
|
|
114
114
|
}
|
|
115
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
|
+
|
|
116
177
|
/**
|
|
117
178
|
* Extract the engine-assigned auto-increment id from an `execute()` result.
|
|
118
179
|
*
|
|
@@ -575,7 +636,7 @@ export class Database {
|
|
|
575
636
|
// no store, run directly (mirrors the Python master's `no_cache`).
|
|
576
637
|
const rows = await adapterFetch(adapter, sql, params, limit, offset, opts?.noCache);
|
|
577
638
|
this.lastError = null;
|
|
578
|
-
const total = await
|
|
639
|
+
const total = await probeTotal(adapter, sql, params, limit);
|
|
579
640
|
return new DatabaseResult(rows, undefined, total, limit, offset, adapter, sql);
|
|
580
641
|
} catch (e: any) {
|
|
581
642
|
// v3.13.11 #49.2: fetch() records last_error like execute() does.
|
|
@@ -584,63 +645,6 @@ export class Database {
|
|
|
584
645
|
}
|
|
585
646
|
}
|
|
586
647
|
|
|
587
|
-
/**
|
|
588
|
-
* The true row count for `sql`, ignoring the pagination we appended.
|
|
589
|
-
*
|
|
590
|
-
* `count` is the TRUE TOTAL for the filter, not the number of rows this page
|
|
591
|
-
* returned. Node and Ruby used to populate it with `records.length` while
|
|
592
|
-
* Python and PHP populated it from a probe, so `db.fetch(sql).count` answered
|
|
593
|
-
* 20 here and 250 there for one query against one table, and every paginated
|
|
594
|
-
* response built on it under-reported. MEASURED 2026-08-05 on a 250-row table
|
|
595
|
-
* read with limit=20: Node reported total 20 over 2 pages against Python's
|
|
596
|
-
* 250 over 13.
|
|
597
|
-
*
|
|
598
|
-
* Only probed when a limit was actually applied. With no limit the rows
|
|
599
|
-
* returned ARE the whole answer for this SQL, so `records.length` is already
|
|
600
|
-
* the true total and a second round-trip would buy nothing — which is also
|
|
601
|
-
* what keeps `fetchAll()` at one query.
|
|
602
|
-
*
|
|
603
|
-
* BEST EFFORT, and it can never mask a real failure: it runs AFTER the main
|
|
604
|
-
* query (which has already thrown on bad SQL) and returns undefined on any
|
|
605
|
-
* error. `undefined` — not 0 — is the miss value, so DatabaseResult falls
|
|
606
|
-
* back to records.length, a true lower bound. Reporting 0 next to 100 real
|
|
607
|
-
* records would be the same "states a wrong number authoritatively" defect
|
|
608
|
-
* this change exists to remove.
|
|
609
|
-
*
|
|
610
|
-
* The closing paren goes on its OWN LINE: appended inline, a trailing
|
|
611
|
-
* `-- comment` in the caller's SQL comments it out and the probe dies with
|
|
612
|
-
* "incomplete input". Postgres, MySQL and MSSQL additionally require a name
|
|
613
|
-
* for the derived table; SQLite and Firebird do not, and Firebird rejects
|
|
614
|
-
* `AS` there — so the alias comes from the adapter, not an assumption.
|
|
615
|
-
*/
|
|
616
|
-
private async countProbe(
|
|
617
|
-
adapter: DatabaseAdapter,
|
|
618
|
-
sql: string,
|
|
619
|
-
params: unknown[] | undefined,
|
|
620
|
-
limit: number | undefined,
|
|
621
|
-
): Promise<number | undefined> {
|
|
622
|
-
if (limit === undefined || limit <= 0) return undefined;
|
|
623
|
-
try {
|
|
624
|
-
const alias = (adapter as any).countSubqueryAlias as string | undefined;
|
|
625
|
-
const suffix = alias ? ` AS ${alias}` : "";
|
|
626
|
-
const rows = await adapterFetch(
|
|
627
|
-
adapter,
|
|
628
|
-
`SELECT COUNT(*) AS tina4_total FROM (${sql}\n)${suffix}`,
|
|
629
|
-
params,
|
|
630
|
-
undefined,
|
|
631
|
-
undefined,
|
|
632
|
-
true,
|
|
633
|
-
);
|
|
634
|
-
const row = Array.isArray(rows) ? (rows[0] as Record<string, unknown> | undefined) : undefined;
|
|
635
|
-
if (!row) return undefined;
|
|
636
|
-
const value = row["tina4_total"] ?? row["TINA4_TOTAL"] ?? Object.values(row)[0];
|
|
637
|
-
const n = Number(value);
|
|
638
|
-
return Number.isFinite(n) ? n : undefined;
|
|
639
|
-
} catch {
|
|
640
|
-
return undefined;
|
|
641
|
-
}
|
|
642
|
-
}
|
|
643
|
-
|
|
644
648
|
/**
|
|
645
649
|
* Fetch a single row or null.
|
|
646
650
|
*
|
|
@@ -97,99 +97,70 @@ export class DatabaseResult implements Iterable<Record<string, unknown>> {
|
|
|
97
97
|
return this.records;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
/**
|
|
100
|
+
/**
|
|
101
|
+
* Describe the page this result IS — the canonical pagination envelope.
|
|
101
102
|
*
|
|
102
|
-
*
|
|
103
|
-
* (
|
|
104
|
-
*
|
|
105
|
-
*
|
|
103
|
+
* Takes NO arguments and derives every field from the query that produced this
|
|
104
|
+
* result (ADR-0043). Passing ANY argument RAISES: a DatabaseResult holds no
|
|
105
|
+
* connection, so an argument could only re-slice the rows already in memory and
|
|
106
|
+
* then report total_pages for pages it can never reach. To read page N, FETCH
|
|
107
|
+
* page N (limit + offset) and call this with no arguments.
|
|
106
108
|
*
|
|
107
|
-
*
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Describe the page this result actually IS. Takes no arguments.
|
|
109
|
+
* The envelope is EXACTLY seven snake_case keys, identical across all four
|
|
110
|
+
* frameworks: `records, total, page, per_page, total_pages, limit, offset`.
|
|
111
111
|
*
|
|
112
|
-
*
|
|
113
|
-
* (
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
112
|
+
* per_page = the query's limit
|
|
113
|
+
* page = floor(offset / limit) + 1
|
|
114
|
+
* total = the TRUE total for the filter — Database.fetch (and
|
|
115
|
+
* QueryBuilder.get) run a COUNT probe whenever a limit was
|
|
116
|
+
* applied — NEVER the number of rows returned
|
|
117
|
+
* total_pages = ceil(total / per_page)
|
|
118
|
+
* records = the rows the query returned, VERBATIM (never re-sliced)
|
|
119
|
+
* limit = the SQL limit actually applied
|
|
120
|
+
* offset = the SQL offset actually applied
|
|
119
121
|
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
* (
|
|
123
|
-
*
|
|
124
|
-
* pages 1-5 of 20 were right and every page from 6 onward came back EMPTY
|
|
125
|
-
* while totalPages reported 5,000.
|
|
122
|
+
* The JSON payload is snake_case even though the method name is camelCase — a
|
|
123
|
+
* JSON key is data, not a language surface (ADR-0043). The old duplicate and
|
|
124
|
+
* camelCase keys (`data`, `count`, `perPage`, `totalPages`, `has_next`,
|
|
125
|
+
* `has_prev`) are removed: Node emitted 13 keys, the worst offender of the four.
|
|
126
126
|
*
|
|
127
|
-
*
|
|
128
|
-
* all four frameworks - Database.fetch runs a COUNT probe whenever it applied
|
|
129
|
-
* a limit. It used to be ROWS RETURNED here and in Ruby while Python and PHP
|
|
130
|
-
* probed, so one query answered 20 in two frameworks and 250 in the other
|
|
131
|
-
* two.
|
|
127
|
+
* @throws {TypeError} if called with any argument.
|
|
132
128
|
*/
|
|
133
|
-
toPaginate(
|
|
129
|
+
toPaginate(): {
|
|
134
130
|
records: Record<string, unknown>[];
|
|
135
|
-
data: Record<string, unknown>[];
|
|
136
|
-
count: number;
|
|
137
131
|
total: number;
|
|
138
|
-
limit: number;
|
|
139
|
-
offset: number;
|
|
140
132
|
page: number;
|
|
141
133
|
per_page: number;
|
|
142
|
-
perPage: number;
|
|
143
|
-
totalPages: number;
|
|
144
134
|
total_pages: number;
|
|
145
|
-
|
|
146
|
-
|
|
135
|
+
limit: number;
|
|
136
|
+
offset: number;
|
|
147
137
|
} {
|
|
148
|
-
|
|
138
|
+
// No parameters (ADR-0043). `arguments` catches an argument passed anyway —
|
|
139
|
+
// including from plain JS, where the 0-arity signature is not enforced — so a
|
|
140
|
+
// caller porting the old two-argument form gets a hard error, never a silent
|
|
141
|
+
// in-memory re-slice that lies about total_pages.
|
|
142
|
+
if (arguments.length > 0) {
|
|
149
143
|
throw new TypeError(
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
`NOTHING. Fetch the page you want instead: fetch(sql, params, perPage, ` +
|
|
156
|
-
`(page - 1) * perPage), then call toPaginate() with no arguments.`,
|
|
144
|
+
"toPaginate() takes no arguments and derives the page from the query that " +
|
|
145
|
+
"ran (ADR-0043). A DatabaseResult holds no connection, so an argument could " +
|
|
146
|
+
"only re-slice the rows already in memory and report total_pages for pages " +
|
|
147
|
+
"it can never reach. To read a page, FETCH it: db.fetch(sql, params, perPage, " +
|
|
148
|
+
"(page - 1) * perPage), then call toPaginate() with no arguments.",
|
|
157
149
|
);
|
|
158
150
|
}
|
|
159
151
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
let rows: Record<string, unknown>[];
|
|
164
|
-
|
|
165
|
-
if (page === undefined && perPage === undefined) {
|
|
166
|
-
resolvedPerPage = this.limit > 0 ? this.limit : this.records.length;
|
|
167
|
-
resolvedPage = resolvedPerPage > 0 ? Math.floor(this.offset / resolvedPerPage) + 1 : 1;
|
|
168
|
-
offset = this.offset;
|
|
169
|
-
rows = this.records;
|
|
170
|
-
} else {
|
|
171
|
-
resolvedPage = page ?? 1;
|
|
172
|
-
resolvedPerPage = perPage ?? (this.limit > 0 ? this.limit : 10);
|
|
173
|
-
offset = (resolvedPage - 1) * resolvedPerPage;
|
|
174
|
-
rows = this.records.slice(offset, offset + resolvedPerPage);
|
|
175
|
-
}
|
|
176
|
-
const totalPages =
|
|
177
|
-
resolvedPerPage > 0 ? Math.max(1, Math.ceil(this.count / resolvedPerPage)) : 1;
|
|
152
|
+
const perPage = this.limit > 0 ? this.limit : this.records.length;
|
|
153
|
+
const page = perPage > 0 ? Math.floor(this.offset / perPage) + 1 : 1;
|
|
154
|
+
const totalPages = perPage > 0 ? Math.max(1, Math.ceil(this.count / perPage)) : 1;
|
|
178
155
|
|
|
179
156
|
return {
|
|
180
|
-
records:
|
|
181
|
-
data: rows,
|
|
182
|
-
count: this.count,
|
|
157
|
+
records: this.records,
|
|
183
158
|
total: this.count,
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
page: resolvedPage,
|
|
187
|
-
per_page: resolvedPerPage,
|
|
188
|
-
perPage: resolvedPerPage,
|
|
189
|
-
totalPages,
|
|
159
|
+
page,
|
|
160
|
+
per_page: perPage,
|
|
190
161
|
total_pages: totalPages,
|
|
191
|
-
|
|
192
|
-
|
|
162
|
+
limit: perPage,
|
|
163
|
+
offset: this.offset,
|
|
193
164
|
};
|
|
194
165
|
}
|
|
195
166
|
|
|
@@ -7,11 +7,8 @@ export type {
|
|
|
7
7
|
ColumnInfo,
|
|
8
8
|
QueryOptions,
|
|
9
9
|
RelationshipDefinition,
|
|
10
|
-
PaginatedResult,
|
|
11
10
|
} from "./types.js";
|
|
12
11
|
|
|
13
|
-
export { FetchResult } from "./types.js";
|
|
14
|
-
|
|
15
12
|
export { DatabaseResult } from "./databaseResult.js";
|
|
16
13
|
export type { ColumnInfoResult } from "./databaseResult.js";
|
|
17
14
|
export { Database, initDatabase, getAdapter, setAdapter, bindDatabase, createAdapterFromUrl, closeDatabase, parseDatabaseUrl, setNamedAdapter, getNamedAdapter, resolveDbPool, stripTrailingSemicolons, wrapWithCache, resetRequestCaches } from "./database.js";
|
|
@@ -1176,9 +1176,24 @@ export async function status(
|
|
|
1176
1176
|
*/
|
|
1177
1177
|
export async function createMigration(
|
|
1178
1178
|
description: string,
|
|
1179
|
-
options?: { migrationsDir?: string; kind?: "sql" | "class" },
|
|
1179
|
+
options?: { migrationsDir?: string; kind?: "sql" | "code" | "class" },
|
|
1180
1180
|
): Promise<string | { upPath: string; downPath: string }> {
|
|
1181
|
-
|
|
1181
|
+
// MEASURED 2026-08-06: the accepted kind differed in every framework -
|
|
1182
|
+
// python "python", php "php", ruby "ruby" OR "python", node "class" - and
|
|
1183
|
+
// NONE validated it, so create_migration(..., kind="python") produced a code
|
|
1184
|
+
// migration in Python and Ruby and a SILENT .sql file in PHP and Node.
|
|
1185
|
+
// "code" is now the canonical spelling in all four; each keeps its own
|
|
1186
|
+
// language name as a legacy alias; anything else raises.
|
|
1187
|
+
const kind = (options?.kind ?? "sql").trim().toLowerCase();
|
|
1188
|
+
if (!["sql", "code", "class"].includes(kind)) {
|
|
1189
|
+
throw new Error(
|
|
1190
|
+
`Unknown migration kind "${kind}". Use "sql" (default) or "code" ` +
|
|
1191
|
+
`(alias: "class"). An unrecognised kind used to produce a .sql file ` +
|
|
1192
|
+
`silently, which is why this now throws.`,
|
|
1193
|
+
);
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
if (kind === "code" || kind === "class") {
|
|
1182
1197
|
return createClassMigration(description, options);
|
|
1183
1198
|
}
|
|
1184
1199
|
const dir = resolve(options?.migrationsDir ?? "migrations");
|
|
@@ -1337,15 +1352,18 @@ export class Migration {
|
|
|
1337
1352
|
* Scaffold a new migration file.
|
|
1338
1353
|
*
|
|
1339
1354
|
* kind="sql" — creates {timestamp}_{description}.sql + .down.sql (default)
|
|
1340
|
-
* kind="
|
|
1355
|
+
* kind="code" — creates {timestamp}_{description}.ts with a TypeScript class
|
|
1356
|
+
* template. "class" is accepted as a legacy alias.
|
|
1341
1357
|
*
|
|
1342
1358
|
* Returns the path to the created up file (or class file).
|
|
1343
1359
|
*/
|
|
1344
|
-
async create(
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1360
|
+
async create(
|
|
1361
|
+
description: string,
|
|
1362
|
+
kind: "sql" | "code" | "class" = "sql",
|
|
1363
|
+
): Promise<string | { upPath: string; downPath: string }> {
|
|
1364
|
+
// Route through createMigration so the validation lives in ONE place - a
|
|
1365
|
+
// second copy of the accepted set is a second place for it to drift.
|
|
1366
|
+
return createMigration(description, { migrationsDir: this.dir, kind });
|
|
1349
1367
|
}
|
|
1350
1368
|
|
|
1351
1369
|
/** Return list of completed (applied) migration filenames. */
|
|
@@ -38,6 +38,10 @@ export async function discoverModels(modelsDir: string): Promise<DiscoveredModel
|
|
|
38
38
|
|
|
39
39
|
const definition: ModelDefinition = {
|
|
40
40
|
tableName: ModelClass.tableName,
|
|
41
|
+
// The class name is the type name a generated OpenAPI client wants
|
|
42
|
+
// (`Item`, not `items`). Carry it so Swagger keys components.schemas by
|
|
43
|
+
// it. A model exported as `default` keeps its declared class name here.
|
|
44
|
+
className: typeof ModelClass.name === "string" && ModelClass.name ? ModelClass.name : undefined,
|
|
41
45
|
fields: ModelClass.fields as Record<string, FieldDefinition>,
|
|
42
46
|
fieldMapping: ModelClass.fieldMapping as Record<string, string> | undefined,
|
|
43
47
|
softDelete: ModelClass.softDelete ?? false,
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import type { DatabaseAdapter } from "./types.js";
|
|
21
|
-
import { getAdapter, adapterFetch, adapterFetchOne } from "./database.js";
|
|
21
|
+
import { getAdapter, adapterFetch, adapterFetchOne, probeTotal } from "./database.js";
|
|
22
22
|
import { DatabaseResult } from "./databaseResult.js";
|
|
23
23
|
|
|
24
24
|
export class QueryBuilder {
|
|
@@ -227,22 +227,29 @@ export class QueryBuilder {
|
|
|
227
227
|
const sql = this.toSql();
|
|
228
228
|
const allParams = [...this.params, ...this.havingParams];
|
|
229
229
|
|
|
230
|
+
const queryParams = allParams.length > 0 ? allParams : undefined;
|
|
230
231
|
const rows = await adapterFetch(
|
|
231
232
|
this.db!,
|
|
232
233
|
sql,
|
|
233
|
-
|
|
234
|
+
queryParams,
|
|
234
235
|
this.limitVal,
|
|
235
236
|
this.offsetVal,
|
|
236
237
|
);
|
|
237
238
|
|
|
238
239
|
// Constructed exactly as Database._fetchWithLimit does, so a QueryBuilder
|
|
239
240
|
// result and a db.fetch() result are the same object in the same state --
|
|
240
|
-
//
|
|
241
|
-
//
|
|
241
|
+
// INCLUDING `count`, which is the TRUE total for the filter via the shared
|
|
242
|
+
// COUNT probe (ADR-0043), not rows-returned. Python's get() -> db.fetch()
|
|
243
|
+
// and Ruby's get -> @db.fetch() already carried the true total; Node used to
|
|
244
|
+
// leave it at the row count here, so `QueryBuilder.get().toPaginate()`
|
|
245
|
+
// under-reported `total` while `db.fetch().toPaginate()` did not. The probe
|
|
246
|
+
// is best-effort (undefined on any error -> falls back to rows.length) and
|
|
247
|
+
// only runs when a limit was applied, so an unlimited get() is one query.
|
|
248
|
+
const total = await probeTotal(this.db!, sql, queryParams, this.limitVal);
|
|
242
249
|
return new DatabaseResult(
|
|
243
250
|
rows as Record<string, unknown>[],
|
|
244
251
|
undefined,
|
|
245
|
-
|
|
252
|
+
total,
|
|
246
253
|
this.limitVal,
|
|
247
254
|
this.offsetVal,
|
|
248
255
|
this.db!,
|
|
@@ -31,6 +31,13 @@ export interface RelationshipDefinition {
|
|
|
31
31
|
|
|
32
32
|
export interface ModelDefinition {
|
|
33
33
|
tableName: string;
|
|
34
|
+
/**
|
|
35
|
+
* The model CLASS name (e.g. `Item` for tableName `items`), carried from
|
|
36
|
+
* `ModelClass.name` at discovery. Swagger keys `components.schemas` by this —
|
|
37
|
+
* the type name a generated client wants — falling back to a singular
|
|
38
|
+
* PascalCase derivation of tableName when a raw definition carries none.
|
|
39
|
+
*/
|
|
40
|
+
className?: string;
|
|
34
41
|
fields: Record<string, FieldDefinition>;
|
|
35
42
|
fieldMapping?: Record<string, string>;
|
|
36
43
|
softDelete?: boolean;
|
|
@@ -128,80 +135,6 @@ export interface DatabaseAdapter {
|
|
|
128
135
|
cacheIdentity?: string;
|
|
129
136
|
}
|
|
130
137
|
|
|
131
|
-
export interface PaginatedResult<T = Record<string, unknown>> {
|
|
132
|
-
data: T[];
|
|
133
|
-
page: number;
|
|
134
|
-
perPage: number;
|
|
135
|
-
total: number;
|
|
136
|
-
totalPages: number;
|
|
137
|
-
hasNext: boolean;
|
|
138
|
-
hasPrev: boolean;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Wraps an array of fetched rows with convenience methods.
|
|
143
|
-
*
|
|
144
|
-
* Mirrors Python's `DatabaseResult` and Ruby's `Tina4::DatabaseResult`.
|
|
145
|
-
*/
|
|
146
|
-
export class FetchResult<T = Record<string, unknown>> {
|
|
147
|
-
readonly records: T[];
|
|
148
|
-
readonly count: number;
|
|
149
|
-
readonly sql: string;
|
|
150
|
-
|
|
151
|
-
constructor(records: T[], sql = "") {
|
|
152
|
-
this.records = records;
|
|
153
|
-
this.count = records.length;
|
|
154
|
-
this.sql = sql;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/** Paginate the in-memory result set. */
|
|
158
|
-
toPaginate(page = 1, perPage = 20): PaginatedResult<T> {
|
|
159
|
-
const total = this.count;
|
|
160
|
-
const totalPages = Math.max(1, Math.ceil(total / perPage));
|
|
161
|
-
const offset = (page - 1) * perPage;
|
|
162
|
-
const data = this.records.slice(offset, offset + perPage);
|
|
163
|
-
return {
|
|
164
|
-
data,
|
|
165
|
-
page,
|
|
166
|
-
perPage,
|
|
167
|
-
total,
|
|
168
|
-
totalPages,
|
|
169
|
-
hasNext: page < totalPages,
|
|
170
|
-
hasPrev: page > 1,
|
|
171
|
-
};
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/** Return the first record or null. */
|
|
175
|
-
first(): T | null {
|
|
176
|
-
return this.records[0] ?? null;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/** Return the last record or null. */
|
|
180
|
-
last(): T | null {
|
|
181
|
-
return this.records[this.records.length - 1] ?? null;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/** Check if result is empty. */
|
|
185
|
-
isEmpty(): boolean {
|
|
186
|
-
return this.records.length === 0;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/** Convert to plain array. */
|
|
190
|
-
toArray(): T[] {
|
|
191
|
-
return [...this.records];
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
/** Convert to JSON string. */
|
|
195
|
-
toJSON(): string {
|
|
196
|
-
return JSON.stringify(this.records);
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/** Iterate over records. */
|
|
200
|
-
[Symbol.iterator](): Iterator<T> {
|
|
201
|
-
return this.records[Symbol.iterator]();
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
|
|
205
138
|
export interface QueryOptions {
|
|
206
139
|
filter?: Record<string, unknown>;
|
|
207
140
|
sort?: string;
|