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.
Files changed (36) hide show
  1. package/CLAUDE.md +3 -4
  2. package/package.json +2 -1
  3. package/packages/cli/dist/bin.js +708 -1012
  4. package/packages/core/dist/index.js +588 -893
  5. package/packages/core/public/css/tina4.min.css +1 -1
  6. package/packages/core/src/index.ts +1 -3
  7. package/packages/core/src/messenger.ts +288 -96
  8. package/packages/core/src/queueBackends/kafkaBackend.ts +23 -2
  9. package/packages/core/src/queueBackends/rabbitmqBackend.ts +29 -17
  10. package/packages/core/src/request.ts +28 -7
  11. package/packages/core/src/server.ts +135 -7
  12. package/packages/core/src/session.ts +8 -1
  13. package/packages/orm/dist/index.js +639 -944
  14. package/packages/orm/src/autoCrud.ts +12 -10
  15. package/packages/orm/src/database.ts +62 -58
  16. package/packages/orm/src/databaseResult.ts +44 -73
  17. package/packages/orm/src/index.ts +0 -3
  18. package/packages/orm/src/migration.ts +26 -8
  19. package/packages/orm/src/model.ts +4 -0
  20. package/packages/orm/src/queryBuilder.ts +12 -5
  21. package/packages/orm/src/types.ts +7 -74
  22. package/packages/swagger/dist/index.js +78 -20
  23. package/packages/swagger/src/generator.ts +172 -29
  24. package/types/core/src/index.d.ts +1 -3
  25. package/types/core/src/messenger.d.ts +45 -4
  26. package/types/core/src/queueBackends/kafkaBackend.d.ts +1 -0
  27. package/types/core/src/queueBackends/rabbitmqBackend.d.ts +2 -1
  28. package/types/core/src/server.d.ts +0 -4
  29. package/types/core/src/session.d.ts +7 -0
  30. package/types/orm/src/database.d.ts +34 -30
  31. package/types/orm/src/databaseResult.d.ts +26 -36
  32. package/types/orm/src/index.d.ts +1 -2
  33. package/types/orm/src/migration.d.ts +4 -3
  34. package/types/orm/src/types.d.ts +7 -34
  35. package/packages/core/src/scss.ts +0 -623
  36. 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
- res.json({
167
- data: rows,
168
- meta: {
169
- total,
170
- page,
171
- limit,
172
- totalPages: Math.ceil(total / limit),
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 this.countProbe(adapter, sql, params, limit);
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
- /** Pagination envelope — accepts either (page, perPage) or (offset, limit) style.
100
+ /**
101
+ * Describe the page this result IS — the canonical pagination envelope.
101
102
  *
102
- * When called with two arguments both >= 0 and the first >= the second
103
- * (i.e. offset-style), pass `{ offset, limit }` as the first argument.
104
- * The simplest way is to always use the default (page, perPage) form and
105
- * let the autoCRUD layer supply offset/limit from the query string.
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
- * Returns a superset of keys for backwards-compatibility across all clients.
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
- * MEASURED 2026-08-05 on a real 250-row table read with limit=20 offset=40
113
- * (page 3 of 13): this reported page 1 of 2 and returned 10 of the 20 rows.
114
- * It ignored the query entirely - defaulting page to 1 and perPage to 10 -
115
- * then re-sliced the rows it was handed, which were already just that page.
116
- * So a caller who paginated correctly at the SQL level had the answer
117
- * silently re-paginated underneath them, with a page number that was simply
118
- * wrong.
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
- * WITH page/perPage it slices this result in memory, the behaviour GitHub
121
- * issue #106 asked for. Valid ONLY when the result holds the WHOLE set
122
- * (records.length >= count). A PARTIAL result cannot be sliced by page number
123
- * without lying: MEASURED on 100,000 rows read under the default cap of 100,
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
- * `total` is `count`, and `count` is now the TRUE total for the filter in
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(page?: number, perPage?: number): {
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
- has_next: boolean;
146
- has_prev: boolean;
135
+ limit: number;
136
+ offset: number;
147
137
  } {
148
- if ((page !== undefined || perPage !== undefined) && this.records.length < this.count) {
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
- `toPaginate(page, perPage) slices the rows this result holds, but this ` +
151
- `result holds only ${this.records.length} of ${this.count} rows - it is a ` +
152
- `PARTIAL result, so any page past the rows it holds comes back empty while ` +
153
- `totalPages claims it exists. MEASURED on 100,000 rows read under the ` +
154
- `default cap of 100: pages 1-5 of 20 were right and pages 6 onward returned ` +
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
- let resolvedPerPage: number;
161
- let resolvedPage: number;
162
- let offset: number;
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: rows,
181
- data: rows,
182
- count: this.count,
157
+ records: this.records,
183
158
  total: this.count,
184
- limit: resolvedPerPage,
185
- offset,
186
- page: resolvedPage,
187
- per_page: resolvedPerPage,
188
- perPage: resolvedPerPage,
189
- totalPages,
159
+ page,
160
+ per_page: perPage,
190
161
  total_pages: totalPages,
191
- has_next: resolvedPage < totalPages,
192
- has_prev: resolvedPage > 1,
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
- if (options?.kind === "class") {
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="class" — creates {timestamp}_{description}.ts with a TypeScript class template
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(description: string, kind: "sql" | "class" = "sql"): Promise<string | { upPath: string; downPath: string }> {
1345
- if (kind === "class") {
1346
- return createClassMigration(description, { migrationsDir: this.dir });
1347
- }
1348
- return createMigration(description, { migrationsDir: this.dir });
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
- allParams.length > 0 ? allParams : undefined,
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
- // including leaving `count` to default to the row count and handing the
241
- // adapter + sql through.
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
- undefined,
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;