tina4-nodejs 3.13.130 → 3.13.132
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 -3
- package/package.json +1 -1
- package/packages/cli/dist/bin.js +247 -185
- package/packages/core/dist/index.js +248 -185
- package/packages/core/public/js/tina4-dev-admin.min.js +148 -512
- package/packages/core/src/devAdmin.ts +0 -46
- package/packages/core/src/index.ts +1 -1
- package/packages/core/src/server.ts +129 -3
- package/packages/frond/dist/index.js +52 -104
- package/packages/frond/src/engine.ts +52 -100
- package/packages/orm/dist/index.js +248 -185
- package/packages/orm/src/baseModel.ts +64 -55
- package/packages/orm/src/index.ts +2 -0
- package/packages/orm/src/modelCollection.ts +149 -0
- package/types/core/src/index.d.ts +1 -1
- package/types/core/src/server.d.ts +15 -0
- package/types/frond/src/engine.d.ts +11 -0
- package/types/orm/src/baseModel.d.ts +20 -5
- package/types/orm/src/index.d.ts +2 -0
- package/types/orm/src/modelCollection.d.ts +101 -0
|
@@ -3,8 +3,9 @@ import {
|
|
|
3
3
|
adapterQuery, adapterFetch, adapterExecute, adapterFetchOne,
|
|
4
4
|
adapterStartTransaction, adapterCommit, adapterRollback,
|
|
5
5
|
adapterTableExists, adapterCreateTable, extractLastInsertId,
|
|
6
|
-
DEFAULT_ROW_CAP,
|
|
6
|
+
probeTotal, DEFAULT_ROW_CAP,
|
|
7
7
|
} from "./database.js";
|
|
8
|
+
import { ModelCollection } from "./modelCollection.js";
|
|
8
9
|
import { validate as validateFields } from "./validation.js";
|
|
9
10
|
import { QueryBuilder } from "./queryBuilder.js";
|
|
10
11
|
import { SQLiteAdapter } from "./adapters/sqlite.js";
|
|
@@ -451,6 +452,43 @@ export class BaseModel {
|
|
|
451
452
|
return this.getDbColumn(this.getPkField());
|
|
452
453
|
}
|
|
453
454
|
|
|
455
|
+
/**
|
|
456
|
+
* Shared read tail for the collection-returning finders (where / all / select
|
|
457
|
+
* / find filter-form / withTrashed). Runs the SAME two calls `db.fetch()`
|
|
458
|
+
* makes — the page fetch AND the COUNT probe over the SAME base SQL — hydrates
|
|
459
|
+
* the rows into model instances, and returns a ModelCollection carrying the
|
|
460
|
+
* total (ADR-0064).
|
|
461
|
+
*
|
|
462
|
+
* The total is FREE: `probeTotal` is the exact `COUNT(*)` probe `db.fetch()`
|
|
463
|
+
* already runs; the ORM used to discard it. ZERO extra queries beyond that one
|
|
464
|
+
* probe. `sql` MUST NOT carry its own LIMIT/OFFSET — `adapterFetch` applies
|
|
465
|
+
* limit/offset to the page, and the probe wraps the un-limited SQL so it counts
|
|
466
|
+
* the WHOLE filtered set, not the page.
|
|
467
|
+
*/
|
|
468
|
+
protected static async _collect<T extends BaseModel>(
|
|
469
|
+
this: typeof BaseModel & (new (data?: Record<string, unknown>) => T),
|
|
470
|
+
sql: string,
|
|
471
|
+
params: unknown[] | undefined,
|
|
472
|
+
limit: number,
|
|
473
|
+
offset: number,
|
|
474
|
+
include?: string[],
|
|
475
|
+
): Promise<ModelCollection<T>> {
|
|
476
|
+
const db = this.getDb();
|
|
477
|
+
const rows = await adapterFetch(db, sql, params, limit, offset);
|
|
478
|
+
const data: Record<string, unknown>[] = Array.isArray(rows)
|
|
479
|
+
? (rows as Record<string, unknown>[])
|
|
480
|
+
: ((rows as { data?: Record<string, unknown>[] })?.data ?? []);
|
|
481
|
+
// The total comes from the fetch COUNT probe — NOT data.length (that is only
|
|
482
|
+
// the page). probeTotal returns undefined only for an unbounded read
|
|
483
|
+
// (limit <= 0), where the page IS the whole set, so data.length is right.
|
|
484
|
+
const total = (await probeTotal(db, sql, params, limit)) ?? data.length;
|
|
485
|
+
const instances = data.map((row) => new this(row) as T);
|
|
486
|
+
if (include) {
|
|
487
|
+
await (this as typeof BaseModel)._eagerLoad(instances as BaseModel[], include);
|
|
488
|
+
}
|
|
489
|
+
return new ModelCollection<T>(instances, total, limit, offset);
|
|
490
|
+
}
|
|
491
|
+
|
|
454
492
|
/**
|
|
455
493
|
* Find a record by primary key.
|
|
456
494
|
* @param id Primary key value.
|
|
@@ -531,7 +569,7 @@ export class BaseModel {
|
|
|
531
569
|
offset?: number,
|
|
532
570
|
orderBy?: string,
|
|
533
571
|
include?: string[],
|
|
534
|
-
): Promise<T
|
|
572
|
+
): Promise<ModelCollection<T>>;
|
|
535
573
|
static async find<T extends BaseModel>(
|
|
536
574
|
this: new (data?: Record<string, unknown>) => T,
|
|
537
575
|
filter?: Record<string, unknown> | number | string,
|
|
@@ -539,7 +577,7 @@ export class BaseModel {
|
|
|
539
577
|
offset = 0,
|
|
540
578
|
orderBy?: string,
|
|
541
579
|
include?: string[],
|
|
542
|
-
): Promise<T
|
|
580
|
+
): Promise<ModelCollection<T> | T | null> {
|
|
543
581
|
const ModelClass = this as unknown as typeof BaseModel & (new (data?: Record<string, unknown>) => T);
|
|
544
582
|
|
|
545
583
|
// Scalar PK lookup routes to findById. A number or a string (but NOT a
|
|
@@ -555,7 +593,6 @@ export class BaseModel {
|
|
|
555
593
|
|
|
556
594
|
// Array form — coerce `limit` back to a number for the list path.
|
|
557
595
|
const lim = typeof limit === "number" ? limit : 100;
|
|
558
|
-
const db = ModelClass.getDb();
|
|
559
596
|
const conditions: string[] = [];
|
|
560
597
|
const params: unknown[] = [];
|
|
561
598
|
|
|
@@ -579,18 +616,7 @@ export class BaseModel {
|
|
|
579
616
|
sql += ` ORDER BY ${orderBy}`;
|
|
580
617
|
}
|
|
581
618
|
|
|
582
|
-
|
|
583
|
-
const data = (rows as any)?.data ?? rows;
|
|
584
|
-
const instances = (Array.isArray(data) ? data : []).map((row: Record<string, unknown>) => {
|
|
585
|
-
const inst = new this(row) as T;
|
|
586
|
-
return inst;
|
|
587
|
-
});
|
|
588
|
-
|
|
589
|
-
if (include) {
|
|
590
|
-
await ModelClass._eagerLoad(instances as BaseModel[], include);
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
return instances;
|
|
619
|
+
return ModelClass._collect<T>(sql, params, lim, offset, include);
|
|
594
620
|
}
|
|
595
621
|
|
|
596
622
|
/**
|
|
@@ -671,9 +697,8 @@ export class BaseModel {
|
|
|
671
697
|
offset: number = 0,
|
|
672
698
|
include?: string[],
|
|
673
699
|
orderBy?: string,
|
|
674
|
-
): Promise<T
|
|
700
|
+
): Promise<ModelCollection<T>> {
|
|
675
701
|
const ModelClass = this as unknown as typeof BaseModel & (new (data?: Record<string, unknown>) => T);
|
|
676
|
-
const db = ModelClass.getDb();
|
|
677
702
|
|
|
678
703
|
const conditions: string[] = [];
|
|
679
704
|
if (ModelClass.softDelete) {
|
|
@@ -685,18 +710,14 @@ export class BaseModel {
|
|
|
685
710
|
|
|
686
711
|
const whereClause = conditions.length > 0 ? ` WHERE ${conditions.join(" AND ")}` : "";
|
|
687
712
|
const orderClause = orderBy ? ` ORDER BY ${orderBy}` : "";
|
|
688
|
-
|
|
689
|
-
|
|
713
|
+
// No LIMIT/OFFSET embedded: _collect's adapterFetch applies them to the page
|
|
714
|
+
// and the COUNT probe wraps the un-limited SQL so the total is the whole set.
|
|
715
|
+
const sql = `SELECT * FROM "${ModelClass.tableName}"${whereClause}${orderClause}`;
|
|
690
716
|
|
|
691
717
|
// No bind parameters: the only conditions left are the framework's own
|
|
692
718
|
// softDelete / tableFilter literals. A caller-supplied filter belongs on
|
|
693
719
|
// where(), which binds its params properly.
|
|
694
|
-
|
|
695
|
-
const instances = rows.map((row) => new ModelClass(row as Record<string, unknown>) as T);
|
|
696
|
-
if (include) {
|
|
697
|
-
await ModelClass._eagerLoad(instances, include);
|
|
698
|
-
}
|
|
699
|
-
return instances;
|
|
720
|
+
return ModelClass._collect<T>(sql, [], limit, offset, include);
|
|
700
721
|
}
|
|
701
722
|
|
|
702
723
|
/**
|
|
@@ -718,9 +739,8 @@ export class BaseModel {
|
|
|
718
739
|
offset: number = 0,
|
|
719
740
|
include?: string[],
|
|
720
741
|
orderBy?: string,
|
|
721
|
-
): Promise<T
|
|
742
|
+
): Promise<ModelCollection<T>> {
|
|
722
743
|
const ModelClass = this as unknown as typeof BaseModel & (new (data?: Record<string, unknown>) => T);
|
|
723
|
-
const db = ModelClass.getDb();
|
|
724
744
|
|
|
725
745
|
const parts: string[] = [];
|
|
726
746
|
if (ModelClass.softDelete) {
|
|
@@ -732,14 +752,11 @@ export class BaseModel {
|
|
|
732
752
|
parts.push(`(${conditions})`);
|
|
733
753
|
|
|
734
754
|
const orderClause = orderBy ? ` ORDER BY ${orderBy}` : "";
|
|
735
|
-
|
|
755
|
+
// No LIMIT/OFFSET embedded — _collect applies them to the page and probes
|
|
756
|
+
// the total (ADR-0064). orderBy affects the page only; COUNT is order-free.
|
|
757
|
+
const sql = `SELECT * FROM "${ModelClass.tableName}" WHERE ${parts.join(" AND ")}${orderClause}`;
|
|
736
758
|
|
|
737
|
-
|
|
738
|
-
const instances = rows.map((row) => new ModelClass(row as Record<string, unknown>) as T);
|
|
739
|
-
if (include) {
|
|
740
|
-
await ModelClass._eagerLoad(instances, include);
|
|
741
|
-
}
|
|
742
|
-
return instances;
|
|
759
|
+
return ModelClass._collect<T>(sql, params, limit, offset, include);
|
|
743
760
|
}
|
|
744
761
|
|
|
745
762
|
/**
|
|
@@ -1418,17 +1435,14 @@ export class BaseModel {
|
|
|
1418
1435
|
params?: unknown[],
|
|
1419
1436
|
limit: number = DEFAULT_ROW_CAP,
|
|
1420
1437
|
offset: number = 0,
|
|
1421
|
-
): Promise<T
|
|
1438
|
+
): Promise<ModelCollection<T>> {
|
|
1422
1439
|
const ModelClass = this as unknown as typeof BaseModel & (new (data?: Record<string, unknown>) => T);
|
|
1423
|
-
|
|
1424
|
-
//
|
|
1425
|
-
// a second one is a syntax
|
|
1426
|
-
//
|
|
1427
|
-
//
|
|
1428
|
-
|
|
1429
|
-
const paged = SQLTranslator.appendLimit(sql, limit, offset);
|
|
1430
|
-
const rows = await adapterQuery(db, paged, params);
|
|
1431
|
-
return rows.map((row) => new ModelClass(row as Record<string, unknown>) as T);
|
|
1440
|
+
// _collect runs the page fetch — the adapter applies limit/offset via
|
|
1441
|
+
// SQLTranslator.appendLimit, which scrubs literals/comments and skips when
|
|
1442
|
+
// the caller's SQL already carries its own LIMIT (a second one is a syntax
|
|
1443
|
+
// error on every engine) — AND the COUNT probe over the raw SQL, so the
|
|
1444
|
+
// ModelCollection carries the total for the filter (ADR-0064).
|
|
1445
|
+
return ModelClass._collect<T>(sql, params, limit, offset);
|
|
1432
1446
|
}
|
|
1433
1447
|
|
|
1434
1448
|
static async selectOne<T extends BaseModel>(
|
|
@@ -1518,9 +1532,8 @@ export class BaseModel {
|
|
|
1518
1532
|
params?: unknown[],
|
|
1519
1533
|
limit: number = DEFAULT_ROW_CAP,
|
|
1520
1534
|
offset: number = 0,
|
|
1521
|
-
): Promise<T
|
|
1535
|
+
): Promise<ModelCollection<T>> {
|
|
1522
1536
|
const ModelClass = this as unknown as typeof BaseModel & (new (data?: Record<string, unknown>) => T);
|
|
1523
|
-
const db = ModelClass.getDb();
|
|
1524
1537
|
|
|
1525
1538
|
const parts: string[] = [];
|
|
1526
1539
|
if (ModelClass.tableFilter) {
|
|
@@ -1534,15 +1547,11 @@ export class BaseModel {
|
|
|
1534
1547
|
if (parts.length > 0) {
|
|
1535
1548
|
sql += ` WHERE ${parts.join(" AND ")}`;
|
|
1536
1549
|
}
|
|
1537
|
-
if (limit !== undefined) {
|
|
1538
|
-
sql += ` LIMIT ${limit}`;
|
|
1539
|
-
}
|
|
1540
|
-
if (offset !== undefined) {
|
|
1541
|
-
sql += ` OFFSET ${offset}`;
|
|
1542
|
-
}
|
|
1543
1550
|
|
|
1544
|
-
|
|
1545
|
-
|
|
1551
|
+
// No LIMIT/OFFSET embedded — _collect applies them to the page and probes
|
|
1552
|
+
// the total. No is_deleted filter here, so soft-deleted rows are INCLUDED in
|
|
1553
|
+
// both the page and the total (ADR-0064).
|
|
1554
|
+
return ModelClass._collect<T>(sql, params, limit, offset);
|
|
1546
1555
|
}
|
|
1547
1556
|
|
|
1548
1557
|
/**
|
|
@@ -13,6 +13,8 @@ export { REQUIRED_ADAPTER_CAPABILITIES, NOT_REQUIRED_ON_ADAPTER } from "./types.
|
|
|
13
13
|
|
|
14
14
|
export { DatabaseResult } from "./databaseResult.js";
|
|
15
15
|
export type { ColumnInfoResult } from "./databaseResult.js";
|
|
16
|
+
export { ModelCollection } from "./modelCollection.js";
|
|
17
|
+
export type { PaginateEnvelope } from "./modelCollection.js";
|
|
16
18
|
export { Database, initDatabase, getAdapter, setAdapter, bindDatabase, createAdapterFromUrl, closeDatabase, parseDatabaseUrl, setNamedAdapter, getNamedAdapter, resolveDbPool, stripTrailingSemicolons, wrapWithCache, resetRequestCaches } from "./database.js";
|
|
17
19
|
export {
|
|
18
20
|
adapterFetch, adapterQuery, adapterFetchOne, adapterExecute, adapterInsert,
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ModelCollection — a list of ORM models that also carries the query total.
|
|
3
|
+
*
|
|
4
|
+
* What the ORM read queries (`where` / `select` / `find` filter-form / `all` /
|
|
5
|
+
* `withTrashed`) return. It IS an Array — iterate it, index it, slice it, `.map`
|
|
6
|
+
* it, `.filter` it, read `.length`, `JSON.stringify` it — so every existing
|
|
7
|
+
* caller keeps working unchanged (`Array.isArray(coll) === true`). It adds one
|
|
8
|
+
* thing: the TOTAL number of rows matching the query's filter, independent of
|
|
9
|
+
* `limit` / `offset`.
|
|
10
|
+
*
|
|
11
|
+
* The total is free. Every one of those methods already runs the fetch COUNT
|
|
12
|
+
* probe (`db.fetch` / `probeTotal`) that computes `SELECT COUNT(*)` for the same
|
|
13
|
+
* filter; the ORM used to hydrate the page of models and throw that count away.
|
|
14
|
+
* This class carries it instead, so a caller with 20 models on the page can
|
|
15
|
+
* still learn there are 250 rows in the set. ZERO extra queries.
|
|
16
|
+
*
|
|
17
|
+
* Uniform across all four Tina4 frameworks (ADR-0064). Same concept, language-
|
|
18
|
+
* idiomatic accessor name:
|
|
19
|
+
*
|
|
20
|
+
* Python / Ruby : get_total_records() to_paginate()
|
|
21
|
+
* PHP / Node : getTotalRecords() toPaginate()
|
|
22
|
+
*
|
|
23
|
+
* The accessor is a METHOD, not a `.total` property, on purpose: `Array#count`
|
|
24
|
+
* exists in Ruby and `list.count()` in Python, so a `.count` would shadow a
|
|
25
|
+
* built-in. `DatabaseResult` keeps its `.count` property (it is not a list); both
|
|
26
|
+
* expose the identical seven-key `toPaginate()` envelope.
|
|
27
|
+
*
|
|
28
|
+
* ### Why a real Array subclass with `[Symbol.species] === Array`
|
|
29
|
+
*
|
|
30
|
+
* `class X extends Array` is the parity-faithful backing (the ADR-0064 table
|
|
31
|
+
* says Node is a "subclass of Array"), and it keeps `Array.isArray` true and
|
|
32
|
+
* `instanceof ModelCollection` true (mirroring Python's `isinstance`). BUT a bare
|
|
33
|
+
* Array subclass is a footgun: `map`/`filter`/`slice` build the result via
|
|
34
|
+
* `this.constructor[Symbol.species]`, calling the constructor with a single
|
|
35
|
+
* NUMBER (the length), and `new X(oneNumber)` means "length N", not "one
|
|
36
|
+
* element". Overriding `[Symbol.species]` to return `Array` makes every derived
|
|
37
|
+
* operation build a plain `Array` — so `.map`/`.filter`/`.slice`/spread never
|
|
38
|
+
* touch this constructor and can never explode. The constructor below is also
|
|
39
|
+
* defended against the numeric-length call directly, so even a path that ignores
|
|
40
|
+
* species is safe.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
/** The canonical pagination envelope — seven snake_case keys (ADR-0043/0064). */
|
|
44
|
+
export interface PaginateEnvelope {
|
|
45
|
+
records: unknown[];
|
|
46
|
+
total: number;
|
|
47
|
+
page: number;
|
|
48
|
+
per_page: number;
|
|
49
|
+
total_pages: number;
|
|
50
|
+
limit: number;
|
|
51
|
+
offset: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class ModelCollection<T = unknown> extends Array<T> {
|
|
55
|
+
/** Total rows matching the query's filter (ignores limit/offset). */
|
|
56
|
+
private _total = 0;
|
|
57
|
+
/** The SQL limit that produced this page. */
|
|
58
|
+
private _limit = 0;
|
|
59
|
+
/** The SQL offset that produced this page. */
|
|
60
|
+
private _offset = 0;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Derived array operations (`map`, `filter`, `slice`, spread, …) build a plain
|
|
64
|
+
* `Array`, never another `ModelCollection`. This is what defuses the Array-
|
|
65
|
+
* subclass constructor/species trap: the engine never calls this constructor
|
|
66
|
+
* with a length during those operations.
|
|
67
|
+
*/
|
|
68
|
+
static get [Symbol.species](): ArrayConstructor {
|
|
69
|
+
return Array;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @param items the page of hydrated model instances (or the length, when the
|
|
74
|
+
* engine constructs a derived array — defended against here).
|
|
75
|
+
* @param total total rows matching the query's filter (ignores limit/offset).
|
|
76
|
+
* @param limit the SQL limit that produced this page.
|
|
77
|
+
* @param offset the SQL offset that produced this page.
|
|
78
|
+
*/
|
|
79
|
+
constructor(items?: readonly T[] | number, total = 0, limit = 0, offset = 0) {
|
|
80
|
+
super();
|
|
81
|
+
// Defensive: a raw `new ModelCollection(5)` (or any engine path that ignores
|
|
82
|
+
// [Symbol.species]) must behave like `new Array(5)` — a length-5 array — not
|
|
83
|
+
// wedge a number into element 0. `[Symbol.species] = Array` means the built-
|
|
84
|
+
// in derived ops never reach here, but this keeps the constructor honest.
|
|
85
|
+
if (typeof items === "number") {
|
|
86
|
+
this.length = items;
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (items) {
|
|
90
|
+
for (let i = 0; i < items.length; i++) {
|
|
91
|
+
this[i] = items[i];
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
this._total = Math.trunc(total) || 0;
|
|
95
|
+
this._limit = Math.trunc(limit) || 0;
|
|
96
|
+
this._offset = Math.trunc(offset) || 0;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Total rows matching the query's filter, ignoring limit/offset.
|
|
101
|
+
*
|
|
102
|
+
* This is the whole point of the collection: the page slice you are iterating
|
|
103
|
+
* is capped by `limit`, but this number is the full count of matching rows —
|
|
104
|
+
* what a pager needs to render "page 3 of 13".
|
|
105
|
+
*/
|
|
106
|
+
getTotalRecords(): number {
|
|
107
|
+
return this._total;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The canonical pagination envelope — seven snake_case keys, identical to
|
|
112
|
+
* `DatabaseResult.toPaginate()` (ADR-0043) and to the other three frameworks'
|
|
113
|
+
* `toPaginate()` / `to_paginate()`.
|
|
114
|
+
*
|
|
115
|
+
* records the page's rows as plain objects (never re-sliced)
|
|
116
|
+
* total getTotalRecords() — the true total for the filter
|
|
117
|
+
* page floor(offset / per_page) + 1
|
|
118
|
+
* per_page the query's limit
|
|
119
|
+
* total_pages ceil(total / per_page)
|
|
120
|
+
* limit the SQL limit actually applied
|
|
121
|
+
* offset the SQL offset actually applied
|
|
122
|
+
*
|
|
123
|
+
* `records` are model dicts (via `toDict()`, the same serialisation the
|
|
124
|
+
* framework applies to a model in a JSON response), so the JSON a client sees
|
|
125
|
+
* matches `DatabaseResult` exactly — the result is uniform whether the route
|
|
126
|
+
* returned a raw `db.fetch()` or an ORM query.
|
|
127
|
+
*/
|
|
128
|
+
toPaginate(): PaginateEnvelope {
|
|
129
|
+
const perPage = this._limit > 0 ? this._limit : this.length;
|
|
130
|
+
const page = perPage > 0 ? Math.floor(this._offset / perPage) + 1 : 1;
|
|
131
|
+
const totalPages = perPage > 0 ? Math.max(1, Math.ceil(this._total / perPage)) : 1;
|
|
132
|
+
// `.map` returns a plain Array here (see [Symbol.species]). Each model is
|
|
133
|
+
// serialised via toDict() — the same shape the framework emits for a model
|
|
134
|
+
// in a JSON response — so the envelope matches DatabaseResult exactly.
|
|
135
|
+
const records = this.map((model) => {
|
|
136
|
+
const m = model as unknown as { toDict?: () => unknown };
|
|
137
|
+
return m && typeof m.toDict === "function" ? m.toDict() : model;
|
|
138
|
+
});
|
|
139
|
+
return {
|
|
140
|
+
records,
|
|
141
|
+
total: this._total,
|
|
142
|
+
page,
|
|
143
|
+
per_page: perPage,
|
|
144
|
+
total_pages: totalPages,
|
|
145
|
+
limit: perPage,
|
|
146
|
+
offset: this._offset,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export type { Tina4Request, Tina4Response, RouteHandler, RouteDefinition, RouteMeta, Tina4Config, Middleware, MiddlewareClass, MiddlewareSpec, UploadedFile, CookieOptions, WebSocketRouteHandler, WebSocketRouteDefinition, } from "./types.js";
|
|
2
|
-
export { startServer, resolvePortAndHost, handle, start, stop, httpReason, resolveTemplate, resetTemplateCache, templateAutoRoutingEnabled, isBannerSuppressed } from "./server.js";
|
|
2
|
+
export { startServer, resolvePortAndHost, loopbackBindHosts, handle, start, stop, httpReason, resolveTemplate, resetTemplateCache, templateAutoRoutingEnabled, isBannerSuppressed } from "./server.js";
|
|
3
3
|
export { background, stopAllBackgroundTasks, backgroundTaskCount } from "./background.js";
|
|
4
4
|
export { Router, RouteGroup, RouteRef, WsRouteRef, defaultRouter, runRouteMiddlewares, resolveStringMiddleware, isTrailingSlashRedirectEnabled } from "./router.js";
|
|
5
5
|
export { get, post, put, patch, del, any, websocket, del as delete } from "./router.js";
|
|
@@ -249,6 +249,21 @@ export declare function buildDispatchContext(router: Router, base?: string): Pro
|
|
|
249
249
|
* skipped for one caller and not the other (feature 131, TC-DEC-01).
|
|
250
250
|
*/
|
|
251
251
|
export declare function runDispatch(ctx: DispatchContext, rawReq: IncomingMessage, rawRes: ServerResponse): Promise<void>;
|
|
252
|
+
/**
|
|
253
|
+
* Sibling loopback addresses to ALSO listen on, so `localhost` reaches this
|
|
254
|
+
* server whether the OS resolves it to IPv4 (127.0.0.1) or IPv6 (::1).
|
|
255
|
+
*
|
|
256
|
+
* `localhost` resolves to `::1` (IPv6) FIRST on Windows, so a server bound only
|
|
257
|
+
* to `127.0.0.1` — or `0.0.0.0`, the IPv4 wildcard, which does NOT cover IPv6 —
|
|
258
|
+
* refuses the browser with ERR_CONNECTION_REFUSED even though it is serving,
|
|
259
|
+
* because nothing listens on `::1`. Binding the sibling family closes that gap.
|
|
260
|
+
*
|
|
261
|
+
* Returns only the families a direct bind of `host` does not already cover, as
|
|
262
|
+
* UNBRACKETED addresses (Node's net/http take a bare "::1"). A host that is
|
|
263
|
+
* neither loopback nor a wildcard yields an empty list — an explicit LAN
|
|
264
|
+
* address is bound exactly as asked. Mirrors PHP `Server::loopbackBindHosts`.
|
|
265
|
+
*/
|
|
266
|
+
export declare function loopbackBindHosts(host: string): string[];
|
|
252
267
|
export declare function startServer(config?: Tina4Config): Promise<{
|
|
253
268
|
close: () => void;
|
|
254
269
|
router: Router;
|
|
@@ -257,6 +257,17 @@ export declare class Frond {
|
|
|
257
257
|
*/
|
|
258
258
|
private handleImportAs;
|
|
259
259
|
private handleFromImport;
|
|
260
|
+
/**
|
|
261
|
+
* Collect the body tokens of a {% <openTag> %}...{% end<openTag> %} block,
|
|
262
|
+
* starting from the token after the opening tag (start + 1). Nested same-tag
|
|
263
|
+
* blocks are kept in the body and balanced by depth; the matching closing tag is
|
|
264
|
+
* consumed but NOT included. Returns [bodyTokens, indexAfterClosingTag].
|
|
265
|
+
*
|
|
266
|
+
* canNest guards the open-tag count: handleSetBlock passes it so the inline
|
|
267
|
+
* {% set x = 1 %} form (which has no {% endset %}) never opens a nested block —
|
|
268
|
+
* only the block form {% set x %} nests. Omitted, every openTag occurrence nests.
|
|
269
|
+
*/
|
|
270
|
+
private collectBlockBody;
|
|
260
271
|
private handleCache;
|
|
261
272
|
/**
|
|
262
273
|
* Handle {% live "name" poll N | sse | ws "path" [src "url"] %}...{% endlive %}.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ModelCollection } from "./modelCollection.js";
|
|
1
2
|
import { QueryBuilder } from "./queryBuilder.js";
|
|
2
3
|
import type { DatabaseAdapter, FieldDefinition, RelationshipDefinition } from "./types.js";
|
|
3
4
|
/**
|
|
@@ -135,6 +136,20 @@ export declare class BaseModel {
|
|
|
135
136
|
* Get the primary key database column name (applies fieldMapping).
|
|
136
137
|
*/
|
|
137
138
|
protected static getPkColumn(): string;
|
|
139
|
+
/**
|
|
140
|
+
* Shared read tail for the collection-returning finders (where / all / select
|
|
141
|
+
* / find filter-form / withTrashed). Runs the SAME two calls `db.fetch()`
|
|
142
|
+
* makes — the page fetch AND the COUNT probe over the SAME base SQL — hydrates
|
|
143
|
+
* the rows into model instances, and returns a ModelCollection carrying the
|
|
144
|
+
* total (ADR-0064).
|
|
145
|
+
*
|
|
146
|
+
* The total is FREE: `probeTotal` is the exact `COUNT(*)` probe `db.fetch()`
|
|
147
|
+
* already runs; the ORM used to discard it. ZERO extra queries beyond that one
|
|
148
|
+
* probe. `sql` MUST NOT carry its own LIMIT/OFFSET — `adapterFetch` applies
|
|
149
|
+
* limit/offset to the page, and the probe wraps the un-limited SQL so it counts
|
|
150
|
+
* the WHOLE filtered set, not the page.
|
|
151
|
+
*/
|
|
152
|
+
protected static _collect<T extends BaseModel>(this: typeof BaseModel & (new (data?: Record<string, unknown>) => T), sql: string, params: unknown[] | undefined, limit: number, offset: number, include?: string[]): Promise<ModelCollection<T>>;
|
|
138
153
|
/**
|
|
139
154
|
* Find a record by primary key.
|
|
140
155
|
* @param id Primary key value.
|
|
@@ -174,7 +189,7 @@ export declare class BaseModel {
|
|
|
174
189
|
* User.find() → all records
|
|
175
190
|
*/
|
|
176
191
|
static find<T extends BaseModel>(this: new (data?: Record<string, unknown>) => T, pk: number | string, include?: string[]): Promise<T | null>;
|
|
177
|
-
static find<T extends BaseModel>(this: new (data?: Record<string, unknown>) => T, filter?: Record<string, unknown>, limit?: number, offset?: number, orderBy?: string, include?: string[]): Promise<T
|
|
192
|
+
static find<T extends BaseModel>(this: new (data?: Record<string, unknown>) => T, filter?: Record<string, unknown>, limit?: number, offset?: number, orderBy?: string, include?: string[]): Promise<ModelCollection<T>>;
|
|
178
193
|
/**
|
|
179
194
|
* Load a record into this instance via selectOne.
|
|
180
195
|
* Returns true if found and loaded, false otherwise.
|
|
@@ -218,7 +233,7 @@ export declare class BaseModel {
|
|
|
218
233
|
* @param include Relationship names to eager-load.
|
|
219
234
|
* @param orderBy ORDER BY clause (e.g. "name ASC").
|
|
220
235
|
*/
|
|
221
|
-
static all<T extends BaseModel>(this: new (data?: Record<string, unknown>) => T, limit?: number, offset?: number, include?: string[], orderBy?: string): Promise<T
|
|
236
|
+
static all<T extends BaseModel>(this: new (data?: Record<string, unknown>) => T, limit?: number, offset?: number, include?: string[], orderBy?: string): Promise<ModelCollection<T>>;
|
|
222
237
|
/**
|
|
223
238
|
* Query records with a WHERE clause.
|
|
224
239
|
* Matches Python/PHP/Ruby where() API.
|
|
@@ -230,7 +245,7 @@ export declare class BaseModel {
|
|
|
230
245
|
* @param include Relationship names to eager-load
|
|
231
246
|
* @param orderBy ORDER BY clause (e.g. "name ASC")
|
|
232
247
|
*/
|
|
233
|
-
static where<T extends BaseModel>(this: new (data?: Record<string, unknown>) => T, conditions: string, params?: unknown[], limit?: number, offset?: number, include?: string[], orderBy?: string): Promise<T
|
|
248
|
+
static where<T extends BaseModel>(this: new (data?: Record<string, unknown>) => T, conditions: string, params?: unknown[], limit?: number, offset?: number, include?: string[], orderBy?: string): Promise<ModelCollection<T>>;
|
|
234
249
|
/**
|
|
235
250
|
* Save this instance (insert or update). Returns this on success (fluent
|
|
236
251
|
* self), false on failure.
|
|
@@ -354,7 +369,7 @@ export declare class BaseModel {
|
|
|
354
369
|
/**
|
|
355
370
|
* Execute a raw SQL SELECT and return results as model instances.
|
|
356
371
|
*/
|
|
357
|
-
static select<T extends BaseModel>(this: new (data?: Record<string, unknown>) => T, sql: string, params?: unknown[], limit?: number, offset?: number): Promise<T
|
|
372
|
+
static select<T extends BaseModel>(this: new (data?: Record<string, unknown>) => T, sql: string, params?: unknown[], limit?: number, offset?: number): Promise<ModelCollection<T>>;
|
|
358
373
|
static selectOne<T extends BaseModel>(this: new (data?: Record<string, unknown>) => T, sql: string, params?: unknown[], include?: string[]): Promise<T | null>;
|
|
359
374
|
/**
|
|
360
375
|
* Permanently delete this instance, bypassing soft delete.
|
|
@@ -367,7 +382,7 @@ export declare class BaseModel {
|
|
|
367
382
|
/**
|
|
368
383
|
* Find records including soft-deleted ones.
|
|
369
384
|
*/
|
|
370
|
-
static withTrashed<T extends BaseModel>(this: new (data?: Record<string, unknown>) => T, conditions?: string, params?: unknown[], limit?: number, offset?: number): Promise<T
|
|
385
|
+
static withTrashed<T extends BaseModel>(this: new (data?: Record<string, unknown>) => T, conditions?: string, params?: unknown[], limit?: number, offset?: number): Promise<ModelCollection<T>>;
|
|
371
386
|
/**
|
|
372
387
|
* Count records matching conditions (respects soft delete and table filter).
|
|
373
388
|
*/
|
package/types/orm/src/index.d.ts
CHANGED
|
@@ -2,6 +2,8 @@ export type { FieldType, FieldDefinition, ModelDefinition, DatabaseAdapter, Data
|
|
|
2
2
|
export { REQUIRED_ADAPTER_CAPABILITIES, NOT_REQUIRED_ON_ADAPTER } from "./types.js";
|
|
3
3
|
export { DatabaseResult } from "./databaseResult.js";
|
|
4
4
|
export type { ColumnInfoResult } from "./databaseResult.js";
|
|
5
|
+
export { ModelCollection } from "./modelCollection.js";
|
|
6
|
+
export type { PaginateEnvelope } from "./modelCollection.js";
|
|
5
7
|
export { Database, initDatabase, getAdapter, setAdapter, bindDatabase, createAdapterFromUrl, closeDatabase, parseDatabaseUrl, setNamedAdapter, getNamedAdapter, resolveDbPool, stripTrailingSemicolons, wrapWithCache, resetRequestCaches } from "./database.js";
|
|
6
8
|
export { adapterFetch, adapterQuery, adapterFetchOne, adapterExecute, adapterInsert, adapterStartTransaction, adapterCommit, adapterRollback, adapterTableExists, adapterTables, adapterColumns, adapterCreateTable, extractLastInsertId, } from "./database.js";
|
|
7
9
|
export type { DatabaseConfig } from "./database.js";
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ModelCollection — a list of ORM models that also carries the query total.
|
|
3
|
+
*
|
|
4
|
+
* What the ORM read queries (`where` / `select` / `find` filter-form / `all` /
|
|
5
|
+
* `withTrashed`) return. It IS an Array — iterate it, index it, slice it, `.map`
|
|
6
|
+
* it, `.filter` it, read `.length`, `JSON.stringify` it — so every existing
|
|
7
|
+
* caller keeps working unchanged (`Array.isArray(coll) === true`). It adds one
|
|
8
|
+
* thing: the TOTAL number of rows matching the query's filter, independent of
|
|
9
|
+
* `limit` / `offset`.
|
|
10
|
+
*
|
|
11
|
+
* The total is free. Every one of those methods already runs the fetch COUNT
|
|
12
|
+
* probe (`db.fetch` / `probeTotal`) that computes `SELECT COUNT(*)` for the same
|
|
13
|
+
* filter; the ORM used to hydrate the page of models and throw that count away.
|
|
14
|
+
* This class carries it instead, so a caller with 20 models on the page can
|
|
15
|
+
* still learn there are 250 rows in the set. ZERO extra queries.
|
|
16
|
+
*
|
|
17
|
+
* Uniform across all four Tina4 frameworks (ADR-0064). Same concept, language-
|
|
18
|
+
* idiomatic accessor name:
|
|
19
|
+
*
|
|
20
|
+
* Python / Ruby : get_total_records() to_paginate()
|
|
21
|
+
* PHP / Node : getTotalRecords() toPaginate()
|
|
22
|
+
*
|
|
23
|
+
* The accessor is a METHOD, not a `.total` property, on purpose: `Array#count`
|
|
24
|
+
* exists in Ruby and `list.count()` in Python, so a `.count` would shadow a
|
|
25
|
+
* built-in. `DatabaseResult` keeps its `.count` property (it is not a list); both
|
|
26
|
+
* expose the identical seven-key `toPaginate()` envelope.
|
|
27
|
+
*
|
|
28
|
+
* ### Why a real Array subclass with `[Symbol.species] === Array`
|
|
29
|
+
*
|
|
30
|
+
* `class X extends Array` is the parity-faithful backing (the ADR-0064 table
|
|
31
|
+
* says Node is a "subclass of Array"), and it keeps `Array.isArray` true and
|
|
32
|
+
* `instanceof ModelCollection` true (mirroring Python's `isinstance`). BUT a bare
|
|
33
|
+
* Array subclass is a footgun: `map`/`filter`/`slice` build the result via
|
|
34
|
+
* `this.constructor[Symbol.species]`, calling the constructor with a single
|
|
35
|
+
* NUMBER (the length), and `new X(oneNumber)` means "length N", not "one
|
|
36
|
+
* element". Overriding `[Symbol.species]` to return `Array` makes every derived
|
|
37
|
+
* operation build a plain `Array` — so `.map`/`.filter`/`.slice`/spread never
|
|
38
|
+
* touch this constructor and can never explode. The constructor below is also
|
|
39
|
+
* defended against the numeric-length call directly, so even a path that ignores
|
|
40
|
+
* species is safe.
|
|
41
|
+
*/
|
|
42
|
+
/** The canonical pagination envelope — seven snake_case keys (ADR-0043/0064). */
|
|
43
|
+
export interface PaginateEnvelope {
|
|
44
|
+
records: unknown[];
|
|
45
|
+
total: number;
|
|
46
|
+
page: number;
|
|
47
|
+
per_page: number;
|
|
48
|
+
total_pages: number;
|
|
49
|
+
limit: number;
|
|
50
|
+
offset: number;
|
|
51
|
+
}
|
|
52
|
+
export declare class ModelCollection<T = unknown> extends Array<T> {
|
|
53
|
+
/** Total rows matching the query's filter (ignores limit/offset). */
|
|
54
|
+
private _total;
|
|
55
|
+
/** The SQL limit that produced this page. */
|
|
56
|
+
private _limit;
|
|
57
|
+
/** The SQL offset that produced this page. */
|
|
58
|
+
private _offset;
|
|
59
|
+
/**
|
|
60
|
+
* Derived array operations (`map`, `filter`, `slice`, spread, …) build a plain
|
|
61
|
+
* `Array`, never another `ModelCollection`. This is what defuses the Array-
|
|
62
|
+
* subclass constructor/species trap: the engine never calls this constructor
|
|
63
|
+
* with a length during those operations.
|
|
64
|
+
*/
|
|
65
|
+
static get [Symbol.species](): ArrayConstructor;
|
|
66
|
+
/**
|
|
67
|
+
* @param items the page of hydrated model instances (or the length, when the
|
|
68
|
+
* engine constructs a derived array — defended against here).
|
|
69
|
+
* @param total total rows matching the query's filter (ignores limit/offset).
|
|
70
|
+
* @param limit the SQL limit that produced this page.
|
|
71
|
+
* @param offset the SQL offset that produced this page.
|
|
72
|
+
*/
|
|
73
|
+
constructor(items?: readonly T[] | number, total?: number, limit?: number, offset?: number);
|
|
74
|
+
/**
|
|
75
|
+
* Total rows matching the query's filter, ignoring limit/offset.
|
|
76
|
+
*
|
|
77
|
+
* This is the whole point of the collection: the page slice you are iterating
|
|
78
|
+
* is capped by `limit`, but this number is the full count of matching rows —
|
|
79
|
+
* what a pager needs to render "page 3 of 13".
|
|
80
|
+
*/
|
|
81
|
+
getTotalRecords(): number;
|
|
82
|
+
/**
|
|
83
|
+
* The canonical pagination envelope — seven snake_case keys, identical to
|
|
84
|
+
* `DatabaseResult.toPaginate()` (ADR-0043) and to the other three frameworks'
|
|
85
|
+
* `toPaginate()` / `to_paginate()`.
|
|
86
|
+
*
|
|
87
|
+
* records the page's rows as plain objects (never re-sliced)
|
|
88
|
+
* total getTotalRecords() — the true total for the filter
|
|
89
|
+
* page floor(offset / per_page) + 1
|
|
90
|
+
* per_page the query's limit
|
|
91
|
+
* total_pages ceil(total / per_page)
|
|
92
|
+
* limit the SQL limit actually applied
|
|
93
|
+
* offset the SQL offset actually applied
|
|
94
|
+
*
|
|
95
|
+
* `records` are model dicts (via `toDict()`, the same serialisation the
|
|
96
|
+
* framework applies to a model in a JSON response), so the JSON a client sees
|
|
97
|
+
* matches `DatabaseResult` exactly — the result is uniform whether the route
|
|
98
|
+
* returned a raw `db.fetch()` or an ORM query.
|
|
99
|
+
*/
|
|
100
|
+
toPaginate(): PaginateEnvelope;
|
|
101
|
+
}
|