tina4-nodejs 3.13.14 → 3.13.18
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 +42 -36
- package/package.json +1 -1
- package/packages/cli/src/commands/migrate.ts +7 -5
- package/packages/cli/src/commands/migrateRollback.ts +3 -3
- package/packages/core/src/server.ts +1 -1
- package/packages/orm/src/adapters/postgres.ts +29 -0
- package/packages/orm/src/autoCrud.ts +32 -16
- package/packages/orm/src/baseModel.ts +233 -197
- package/packages/orm/src/database.ts +189 -70
- package/packages/orm/src/databaseResult.ts +24 -0
- package/packages/orm/src/index.ts +6 -0
- package/packages/orm/src/migration.ts +128 -75
- package/packages/orm/src/queryBuilder.ts +12 -9
- package/packages/orm/src/seeder.ts +2 -1
package/CLAUDE.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
# CLAUDE.md — AI Developer Guide for tina4-nodejs (v3.13.
|
|
1
|
+
# CLAUDE.md — AI Developer Guide for tina4-nodejs (v3.13.18)
|
|
2
2
|
|
|
3
3
|
> This file helps AI assistants (Claude, Copilot, Cursor, etc.) understand and work on this codebase effectively.
|
|
4
4
|
|
|
5
5
|
## What This Project Is
|
|
6
6
|
|
|
7
|
-
Tina4 for Node.js/TypeScript v3.13.
|
|
7
|
+
Tina4 for Node.js/TypeScript v3.13.18 — The Intelligent Native Application 4ramework. A convention-over-configuration structural paradigm. The developer writes TypeScript; Tina4 is invisible infrastructure.
|
|
8
8
|
|
|
9
9
|
The philosophy: zero ceremony, batteries included, file system as source of truth.
|
|
10
10
|
|
|
@@ -53,7 +53,7 @@ tina4-nodejs/
|
|
|
53
53
|
seeder.ts # Database seeding (seedTable, seedOrm)
|
|
54
54
|
sqlTranslation.ts # Cross-engine SQL translator + query cache
|
|
55
55
|
swagger/ # OpenAPI spec generator, Swagger UI
|
|
56
|
-
|
|
56
|
+
frond/ # Zero-dependency Twig-compatible template engine
|
|
57
57
|
test/
|
|
58
58
|
run-all.ts # Test runner — executes all 43 test files
|
|
59
59
|
integration.ts # Full integration test
|
|
@@ -70,7 +70,7 @@ This is an **npm workspaces monorepo**. All packages are in `packages/*`.
|
|
|
70
70
|
- **Runtime:** Node.js 20+ (ESM only, `"type": "module"` everywhere)
|
|
71
71
|
- **HTTP:** Native `node:http` — no Express, no Fastify
|
|
72
72
|
- **Database:** SQLite via `node:sqlite` (default), with adapters for Postgres, MySQL, MSSQL/SQL Server, and Firebird
|
|
73
|
-
- **Templates:**
|
|
73
|
+
- **Templates:** Frond — built-in zero-dependency Twig-compatible engine (`@tina4/frond`)
|
|
74
74
|
- **Dev tooling:** `tsx` for runtime TS execution, `esbuild` for builds
|
|
75
75
|
- **Testing:** 43 test files via `tsx test/run-all.ts`
|
|
76
76
|
|
|
@@ -110,10 +110,10 @@ The HTTP foundation. Handles request/response lifecycle, route matching, middlew
|
|
|
110
110
|
- `static.ts` — Serves files from `public/` with MIME type detection
|
|
111
111
|
- `types.ts` — All shared type definitions (`Tina4Request`, `Tina4Response`, `RouteHandler`, etc.)
|
|
112
112
|
- `events.ts` — Observer-pattern event system (`Events.on`, `emit`, `once`, `off`, `clear`)
|
|
113
|
-
- `ai.ts` — AI coding tool
|
|
113
|
+
- `ai.ts` — AI coding tool context installer (`AI_TOOLS`, `isInstalled`, `showMenu`, `installSelected`, `installAll`, `generateContext`)
|
|
114
114
|
- `errorOverlay.ts` — Rich debug error page for dev mode (`renderErrorOverlay`, `renderProductionError`, `isDebugMode`)
|
|
115
115
|
- `htmlElement.ts` — Programmatic HTML builder (`HtmlElement`, `htmlElement`, `addHtmlHelpers`)
|
|
116
|
-
- `testing.ts` — Inline testing framework (`tests`, `assertEqual`, `
|
|
116
|
+
- `testing.ts` — Inline testing framework (`tests`, `assertEqual`, `assertRaises`, `runAll`)
|
|
117
117
|
- `fakeData.ts` — Core fake data generator (names, emails, addresses, UUIDs, etc.)
|
|
118
118
|
- `constants.ts` — HTTP status codes (`HTTP_OK`, `HTTP_NOT_FOUND`, etc.) and content types (`APPLICATION_JSON`, `TEXT_HTML`, etc.)
|
|
119
119
|
- `devAdmin.ts` — Dev toolbar (fixed bottom bar injected into HTML pages) and admin dashboard at `/_dev/`
|
|
@@ -152,7 +152,7 @@ Database layer with auto-CRUD generation, seeding, fake data, and SQL translatio
|
|
|
152
152
|
- `fakeData.ts` — ORM-aware fake data extending core (adds `forField()` with column-name heuristics)
|
|
153
153
|
- `seeder.ts` — Database seeding (`seedTable` for raw SQL, `seedOrm` for model-based)
|
|
154
154
|
- `sqlTranslation.ts` — Cross-engine SQL translator (`SQLTranslator`) and TTL query cache (`QueryCache`)
|
|
155
|
-
- **Instance methods:** `save(): this|
|
|
155
|
+
- **Instance methods:** `save(): this|false` (fluent, false on failure), `delete()`, `forceDelete()`, `restore()`, `load(sql, params?, include?): boolean`, `validate(): string[]`, `toDict(include?)`, `toAssoc(include?)`, `toObject()`, `toArray(): unknown[]`, `toList()`, `toJson(include?)`, `hasOne(class, fk)`, `hasMany(class, fk, limit?, offset?)`, `belongsTo(class, fk)`
|
|
156
156
|
- **Static methods:** `find(id, include?)`, `findById(id, include?)`, `findOrFail(id)`, `create(data)`, `all(where?, params?, include?)`, `select(sql, params?)`, `selectOne(sql, params?, include?)`, `where(conditions, params?, limit?, offset?, include?)`, `count(conditions?, params?)`, `withTrashed(conditions?, params?, limit?, offset?)`, `scope(name, filterSql, params?)` (registers reusable method), `createTable()`, `query()`, `_processForeignKeys()`, `_applyFkRegistry()`
|
|
157
157
|
- **Foreign key auto-wire:** Declare a field with `type: "foreignKey"` and `references: "ModelName"` to auto-wire both `belongsTo` on the declaring model and `hasMany` on the referenced model. Optional `relatedName` overrides the has-many key. Models must be registered via `BaseModel.registerModel(name, class)` for name-based resolution. Example: `user_id: { type: "foreignKey", references: "User" }` → `post.belongsTo(User, "user_id")` and `user.hasMany(Post, "user_id")` both resolve without extra wiring.
|
|
158
158
|
- QueryBuilder supports `toMongo()` for generating MongoDB query documents from the same fluent API
|
|
@@ -240,7 +240,7 @@ req.cookies: Record<string, string> // parsed from Cookie header
|
|
|
240
240
|
req.contentType: string // from content-type header
|
|
241
241
|
req.query: Record<string, string> // query string params
|
|
242
242
|
response.xml(content, status?): Tina4Response
|
|
243
|
-
response.stream(
|
|
243
|
+
response.stream(source: AsyncIterable<string | Buffer>, contentType?: string): Promise<Tina4Response> // SSE/streaming
|
|
244
244
|
```
|
|
245
245
|
|
|
246
246
|
### Queue
|
|
@@ -258,12 +258,11 @@ Auto-generates OpenAPI 3.0 docs.
|
|
|
258
258
|
- `generator.ts` — Produces OpenAPI spec from route table + model definitions
|
|
259
259
|
- `ui.ts` — Serves Swagger UI HTML (CDN-based) at `/swagger` and spec at `/swagger/openapi.json`
|
|
260
260
|
|
|
261
|
-
### @tina4/
|
|
262
|
-
|
|
261
|
+
### @tina4/frond (`packages/frond/`)
|
|
262
|
+
Built-in zero-dependency Twig-compatible template engine (the only template engine; there is no `twig` npm dependency).
|
|
263
263
|
|
|
264
264
|
**Key files:**
|
|
265
|
-
- `engine.ts` —
|
|
266
|
-
- `middleware.ts` — Adds `res.render(template, data)` to response objects
|
|
265
|
+
- `engine.ts` — The `Frond` class: `render(path, data)`, `renderString(template, data)`, filters/globals/tests, sandbox mode
|
|
267
266
|
|
|
268
267
|
### tina4 CLI (`packages/cli/`)
|
|
269
268
|
Developer-facing CLI commands.
|
|
@@ -305,26 +304,29 @@ Events.clear();
|
|
|
305
304
|
|
|
306
305
|
## Module: AI (`packages/core/src/ai.ts`)
|
|
307
306
|
|
|
308
|
-
|
|
307
|
+
Installs Tina4 context files for AI coding tools (Claude Code, Cursor, Copilot, Windsurf, Aider, Cline, Codex). `AI_TOOLS` is the ordered list of known tools; the installer writes a marker-bracketed Tina4 skill block into each tool's context file, preserving existing content.
|
|
309
308
|
|
|
310
309
|
```typescript
|
|
311
|
-
import {
|
|
310
|
+
import { AI_TOOLS, isInstalled, showMenu, installSelected, installAll, generateContext } from "@tina4/core";
|
|
312
311
|
|
|
313
|
-
//
|
|
314
|
-
|
|
315
|
-
// → [{ name: "claude-code", description: "Claude Code (Anthropic CLI)",
|
|
316
|
-
// configFile: "CLAUDE.md", status: "detected" }, ...]
|
|
312
|
+
// The known tools (name, description, contextFile, configDir)
|
|
313
|
+
AI_TOOLS; // → [{ name: "claude-code", description: "Claude Code", contextFile: "CLAUDE.md", configDir: ".claude" }, ...]
|
|
317
314
|
|
|
318
|
-
//
|
|
319
|
-
|
|
320
|
-
// → ["CLAUDE.md", ".cursorules"]
|
|
315
|
+
// Check whether a tool's context file already exists in a project directory
|
|
316
|
+
isInstalled(".", AI_TOOLS[0]); // → boolean
|
|
321
317
|
|
|
322
|
-
//
|
|
323
|
-
|
|
324
|
-
installAllAiContext(".", true); // force overwrite
|
|
318
|
+
// Show the interactive numbered menu and read the user's selection (returns a Promise)
|
|
319
|
+
const selection = await showMenu(".");
|
|
325
320
|
|
|
326
|
-
//
|
|
327
|
-
|
|
321
|
+
// Install context files for a selection ("1,2,3" or "all") — returns created/updated paths
|
|
322
|
+
const created = installSelected(".", selection);
|
|
323
|
+
// → ["CLAUDE.md", ".cursorules", ...]
|
|
324
|
+
|
|
325
|
+
// Install for ALL known tools, non-interactive
|
|
326
|
+
installAll(".");
|
|
327
|
+
|
|
328
|
+
// Generate the context document string for a specific tool (defaults to "claude-code")
|
|
329
|
+
const doc = generateContext("cursor");
|
|
328
330
|
```
|
|
329
331
|
|
|
330
332
|
## Module: Error Overlay (`packages/core/src/errorOverlay.ts`)
|
|
@@ -384,16 +386,16 @@ Void tags (`br`, `hr`, `img`, `input`, `meta`, etc.) render without closing tags
|
|
|
384
386
|
|
|
385
387
|
## Module: Inline Testing (`packages/core/src/testing.ts`)
|
|
386
388
|
|
|
387
|
-
Attach test assertions directly to functions. Tests are registered globally and run with `
|
|
389
|
+
Attach test assertions directly to functions. Tests are registered globally and run with `runAll()`. No external test runner needed.
|
|
388
390
|
|
|
389
391
|
```typescript
|
|
390
|
-
import { tests, assertEqual,
|
|
392
|
+
import { tests, assertEqual, assertRaises, assertTrue, assertFalse, runAll, reset } from "@tina4/core";
|
|
391
393
|
|
|
392
394
|
// Decorate a function with inline tests
|
|
393
395
|
const add = tests(
|
|
394
396
|
assertEqual([5, 3], 8), // add(5, 3) === 8
|
|
395
397
|
assertEqual([0, 0], 0), // add(0, 0) === 0
|
|
396
|
-
|
|
398
|
+
assertRaises(Error, [null]), // add(null) throws Error
|
|
397
399
|
)(function add(a: number, b: number | null = null): number {
|
|
398
400
|
if (b === null) throw new Error("b required");
|
|
399
401
|
return a + b;
|
|
@@ -403,7 +405,7 @@ const add = tests(
|
|
|
403
405
|
add(2, 3); // 5
|
|
404
406
|
|
|
405
407
|
// Run all registered tests
|
|
406
|
-
const results =
|
|
408
|
+
const results = runAll({ quiet: false, failfast: false });
|
|
407
409
|
// → { passed: 3, failed: 0, errors: 0, details: [...] }
|
|
408
410
|
|
|
409
411
|
// Additional assertion types
|
|
@@ -411,7 +413,7 @@ assertTrue([someArgs]); // result is truthy
|
|
|
411
413
|
assertFalse([someArgs]); // result is falsy
|
|
412
414
|
|
|
413
415
|
// Reset registry between test runs
|
|
414
|
-
|
|
416
|
+
reset();
|
|
415
417
|
```
|
|
416
418
|
|
|
417
419
|
## Module: Seeder / FakeData (`packages/orm/src/seeder.ts`, `packages/orm/src/fakeData.ts`)
|
|
@@ -600,7 +602,7 @@ db.pool
|
|
|
600
602
|
Active-Record base class. Models live in `src/models/` and are auto-discovered. Use `static fields` (not decorators) — same convention across all four frameworks.
|
|
601
603
|
|
|
602
604
|
```typescript
|
|
603
|
-
import { BaseModel,
|
|
605
|
+
import { BaseModel, initDatabase, setAdapter } from "@tina4/orm";
|
|
604
606
|
|
|
605
607
|
export default class User extends BaseModel {
|
|
606
608
|
static tableName = "users";
|
|
@@ -614,7 +616,7 @@ export default class User extends BaseModel {
|
|
|
614
616
|
|
|
615
617
|
// Instance methods (chainable where it makes sense)
|
|
616
618
|
const user = new User({ email: "alice@example.com" });
|
|
617
|
-
user.save(); // returns this on success,
|
|
619
|
+
user.save(); // returns this on success, false on failure
|
|
618
620
|
user.delete(); // soft-delete if enabled, otherwise hard
|
|
619
621
|
user.forceDelete(); // bypasses soft-delete
|
|
620
622
|
user.restore(); // clears soft-delete marker
|
|
@@ -643,7 +645,11 @@ User.createTable();
|
|
|
643
645
|
User.query(): QueryBuilder;
|
|
644
646
|
BaseModel.registerModel(name, class); // for foreignKey name resolution
|
|
645
647
|
|
|
646
|
-
|
|
648
|
+
// Models bind to the active adapter, not a Database wrapper. initDatabase() sets it
|
|
649
|
+
// automatically; setAdapter() lets you bind one explicitly. Models read it via getAdapter().
|
|
650
|
+
await initDatabase({ url: "sqlite:///app.db" }); // sets the active adapter for all models
|
|
651
|
+
// or, with an adapter you constructed yourself:
|
|
652
|
+
setAdapter(adapter);
|
|
647
653
|
```
|
|
648
654
|
|
|
649
655
|
**Soft delete:** set `static softDelete = true`. Adds an `is_deleted` INTEGER column (0/1). `delete()` flips the flag, `forceDelete()` removes the row, `restore()` clears it.
|
|
@@ -726,7 +732,7 @@ frond.sandbox(["upper"], ["if"], ["x"]); // allowed: filters, tags, vars
|
|
|
726
732
|
frond.unsandbox();
|
|
727
733
|
```
|
|
728
734
|
|
|
729
|
-
- **
|
|
735
|
+
- **Safe output** — Frond's built-in `raw`/`safe`-style filters (and the `{% autoescape %}` controls) mark output as already-escaped so it bypasses auto-escaping. The internal `SafeString` wrapper backing this is not exported from `@tina4/frond` (only `Frond`, `FilterFn`, `TestFn` are public).
|
|
730
736
|
- **Fragment caching** — `{% cache "key" 300 %}...{% endcache %}` caches block output for TTL seconds.
|
|
731
737
|
- **Raw blocks** — `{% raw %}...{% endraw %}` outputs literal template syntax.
|
|
732
738
|
- **Pre-compiled regexes** + token caching (cleared on file mtime change in dev mode) for ~2.8x render improvement over the naive path.
|
|
@@ -1056,7 +1062,7 @@ When adding new features, add a corresponding `test/<feature>.test.ts` file.
|
|
|
1056
1062
|
## v3 Features Summary
|
|
1057
1063
|
|
|
1058
1064
|
- **45 built-in features**, zero third-party dependencies
|
|
1059
|
-
- **
|
|
1065
|
+
- **3,653 tests** passing across all modules
|
|
1060
1066
|
- **Race-safe `getNextId()`** with atomic sequence table (`tina4_sequences`) for SQLite/MySQL/MSSQL; PostgreSQL auto-creates sequences
|
|
1061
1067
|
- **Frond template engine optimizations**: pre-compiled regexes, lazy loop context (copy-on-write), filter chain caching, path split caching, inline common filters (11-15% speedup)
|
|
1062
1068
|
- **Production server auto-detect**: `npx tina4nodejs serve --production` auto-uses cluster mode
|
package/package.json
CHANGED
|
@@ -32,6 +32,7 @@ export async function runMigrations(migrationDir?: string): Promise<void> {
|
|
|
32
32
|
let recordMigration: typeof import("../../../orm/src/index.js").recordMigration;
|
|
33
33
|
let getNextBatch: typeof import("../../../orm/src/index.js").getNextBatch;
|
|
34
34
|
let getAdapter: typeof import("../../../orm/src/index.js").getAdapter;
|
|
35
|
+
let adapterExecute: typeof import("../../../orm/src/index.js").adapterExecute;
|
|
35
36
|
|
|
36
37
|
try {
|
|
37
38
|
const orm = await import("../../../orm/src/index.js");
|
|
@@ -41,6 +42,7 @@ export async function runMigrations(migrationDir?: string): Promise<void> {
|
|
|
41
42
|
recordMigration = orm.recordMigration;
|
|
42
43
|
getNextBatch = orm.getNextBatch;
|
|
43
44
|
getAdapter = orm.getAdapter;
|
|
45
|
+
adapterExecute = orm.adapterExecute;
|
|
44
46
|
} catch {
|
|
45
47
|
console.error(" Error: @tina4/orm is required to run migrations.");
|
|
46
48
|
process.exit(1);
|
|
@@ -57,7 +59,7 @@ export async function runMigrations(migrationDir?: string): Promise<void> {
|
|
|
57
59
|
process.exit(1);
|
|
58
60
|
}
|
|
59
61
|
|
|
60
|
-
ensureMigrationTable();
|
|
62
|
+
await ensureMigrationTable();
|
|
61
63
|
|
|
62
64
|
// Collect .sql files, excluding .down.sql, sorted by numeric prefix
|
|
63
65
|
const files = readdirSync(dir)
|
|
@@ -79,13 +81,13 @@ export async function runMigrations(migrationDir?: string): Promise<void> {
|
|
|
79
81
|
return;
|
|
80
82
|
}
|
|
81
83
|
|
|
82
|
-
const batch = getNextBatch();
|
|
84
|
+
const batch = await getNextBatch();
|
|
83
85
|
let applied = 0;
|
|
84
86
|
|
|
85
87
|
for (const file of files) {
|
|
86
88
|
const name = file.replace(/\.sql$/, "");
|
|
87
89
|
|
|
88
|
-
if (isMigrationApplied(name)) {
|
|
90
|
+
if (await isMigrationApplied(name)) {
|
|
89
91
|
continue;
|
|
90
92
|
}
|
|
91
93
|
|
|
@@ -100,7 +102,7 @@ export async function runMigrations(migrationDir?: string): Promise<void> {
|
|
|
100
102
|
|
|
101
103
|
for (const stmt of statements) {
|
|
102
104
|
try {
|
|
103
|
-
adapter
|
|
105
|
+
await adapterExecute(adapter, stmt);
|
|
104
106
|
} catch (err) {
|
|
105
107
|
const msg = err instanceof Error ? err.message : String(err);
|
|
106
108
|
console.error(` Error in ${file}: ${msg}`);
|
|
@@ -108,7 +110,7 @@ export async function runMigrations(migrationDir?: string): Promise<void> {
|
|
|
108
110
|
}
|
|
109
111
|
}
|
|
110
112
|
|
|
111
|
-
recordMigration(name, batch);
|
|
113
|
+
await recordMigration(name, batch);
|
|
112
114
|
applied++;
|
|
113
115
|
}
|
|
114
116
|
|
|
@@ -44,9 +44,9 @@ export async function migrateRollback(migrationDir?: string): Promise<void> {
|
|
|
44
44
|
process.exit(1);
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
ensureMigrationTable();
|
|
47
|
+
await ensureMigrationTable();
|
|
48
48
|
|
|
49
|
-
const lastBatch = getLastBatchMigrations();
|
|
49
|
+
const lastBatch = await getLastBatchMigrations();
|
|
50
50
|
if (lastBatch.length === 0) {
|
|
51
51
|
console.log(" Nothing to rollback — no migrations have been applied.");
|
|
52
52
|
return;
|
|
@@ -54,7 +54,7 @@ export async function migrateRollback(migrationDir?: string): Promise<void> {
|
|
|
54
54
|
|
|
55
55
|
console.log(` Rolling back batch ${lastBatch[0].batch} (${lastBatch.length} migration(s))...`);
|
|
56
56
|
|
|
57
|
-
const rolledBack = rollbackFn(dir);
|
|
57
|
+
const rolledBack = await rollbackFn(dir);
|
|
58
58
|
|
|
59
59
|
if (rolledBack.length === 0) {
|
|
60
60
|
console.log(" Nothing was rolled back.");
|
|
@@ -832,7 +832,7 @@ ${reset}
|
|
|
832
832
|
}
|
|
833
833
|
if (models.length > 0) {
|
|
834
834
|
console.log(`\n Models discovered:`);
|
|
835
|
-
orm.syncModels(models);
|
|
835
|
+
await orm.syncModels(models);
|
|
836
836
|
for (const { definition } of models) {
|
|
837
837
|
console.log(` \x1b[35m${definition.tableName}\x1b[0m (${Object.keys(definition.fields).length} fields)`);
|
|
838
838
|
}
|
|
@@ -10,12 +10,14 @@ import { SQLTranslator } from "../sqlTranslation.js";
|
|
|
10
10
|
import { createRequire } from "node:module";
|
|
11
11
|
|
|
12
12
|
let pg: typeof import("pg") | null = null;
|
|
13
|
+
let typeParsersRegistered = false;
|
|
13
14
|
|
|
14
15
|
function requirePg(): typeof import("pg") {
|
|
15
16
|
if (pg) return pg;
|
|
16
17
|
try {
|
|
17
18
|
const req = createRequire(import.meta.url);
|
|
18
19
|
pg = req("pg");
|
|
20
|
+
registerTypeParsers(pg!);
|
|
19
21
|
return pg!;
|
|
20
22
|
} catch {
|
|
21
23
|
throw new Error(
|
|
@@ -28,6 +30,33 @@ function requirePg(): typeof import("pg") {
|
|
|
28
30
|
}
|
|
29
31
|
}
|
|
30
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Register global pg type parsers so int8 and numeric/decimal columns decode to
|
|
35
|
+
* JS numbers instead of strings. node-postgres returns int8 (OID 20) and
|
|
36
|
+
* numeric (OID 1700) as strings by default to preserve full precision; Python,
|
|
37
|
+
* Ruby and PHP all return native numerics for aggregates (SUM, AVG, COUNT, …),
|
|
38
|
+
* so this brings Node to cross-framework parity. count() and getNextId() already
|
|
39
|
+
* coerce via Number(), so this is purely additive for them.
|
|
40
|
+
*
|
|
41
|
+
* PRECISION CAVEAT: values beyond Number.MAX_SAFE_INTEGER (2^53 - 1) lose
|
|
42
|
+
* precision when coerced to a JS double. That is the accepted trade-off for
|
|
43
|
+
* parity — Python and Ruby return native numerics (and lose precision the same
|
|
44
|
+
* way for floats) too. Applications that need exact 64-bit/arbitrary-precision
|
|
45
|
+
* values should select the column with an explicit ::text cast.
|
|
46
|
+
*
|
|
47
|
+
* Idempotent — registration runs once per process.
|
|
48
|
+
*/
|
|
49
|
+
function registerTypeParsers(pgModule: typeof import("pg")): void {
|
|
50
|
+
if (typeParsersRegistered) return;
|
|
51
|
+
const types = pgModule.types ?? (pgModule as any).default?.types;
|
|
52
|
+
if (!types?.setTypeParser) return;
|
|
53
|
+
// OID 20 = int8 (bigint) → Number (NULL passes through untouched by pg)
|
|
54
|
+
types.setTypeParser(20, (v: string) => Number(v));
|
|
55
|
+
// OID 1700 = numeric / decimal → parseFloat
|
|
56
|
+
types.setTypeParser(1700, (v: string) => parseFloat(v));
|
|
57
|
+
typeParsersRegistered = true;
|
|
58
|
+
}
|
|
59
|
+
|
|
31
60
|
export interface PostgresConfig {
|
|
32
61
|
host?: string;
|
|
33
62
|
port?: number;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { RouteDefinition, Tina4Request, Tina4Response } from "@tina4/core";
|
|
2
2
|
import type { DiscoveredModel } from "./model.js";
|
|
3
|
-
import { getAdapter } from "./database.js";
|
|
3
|
+
import { getAdapter, adapterQuery, adapterExecute } from "./database.js";
|
|
4
4
|
import { buildQuery, parseQueryString } from "./query.js";
|
|
5
5
|
import { validate } from "./validation.js";
|
|
6
6
|
|
|
@@ -109,9 +109,9 @@ export function generateCrudRoutes(models: DiscoveredModel[]): RouteDefinition[]
|
|
|
109
109
|
|
|
110
110
|
// params includes limit and offset at the end; countSql doesn't need them
|
|
111
111
|
const countParams = params.slice(0, -2);
|
|
112
|
-
const rows = adapter
|
|
112
|
+
const rows = await adapterQuery(adapter, sql, params);
|
|
113
113
|
|
|
114
|
-
const countRow = adapter
|
|
114
|
+
const countRow = await adapterQuery(adapter, countSql, countParams);
|
|
115
115
|
const total = Number(countRow[0]?.total ?? 0);
|
|
116
116
|
const limit = qp.limit ?? 100;
|
|
117
117
|
const page = qp.page ?? 1;
|
|
@@ -140,7 +140,7 @@ export function generateCrudRoutes(models: DiscoveredModel[]): RouteDefinition[]
|
|
|
140
140
|
const adapter = getAdapter();
|
|
141
141
|
|
|
142
142
|
const conditions = [`"${pkColumn}" = ?`, ...extraConditions];
|
|
143
|
-
const rows = adapter
|
|
143
|
+
const rows = await adapterQuery(adapter,
|
|
144
144
|
`SELECT * FROM "${tableName}" WHERE ${conditions.join(" AND ")}`,
|
|
145
145
|
[req.params.id],
|
|
146
146
|
);
|
|
@@ -183,14 +183,30 @@ export function generateCrudRoutes(models: DiscoveredModel[]): RouteDefinition[]
|
|
|
183
183
|
const values = Object.values(dbBody);
|
|
184
184
|
const placeholders = columns.map(() => "?").join(", ");
|
|
185
185
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
186
|
+
// Non-SQLite engines can't read a plain INSERT's auto-id back via
|
|
187
|
+
// lastInsertId(); RETURNING the PK column lets us recover it. SQLite
|
|
188
|
+
// tolerates RETURNING but we still prefer its lastInsertId below.
|
|
189
|
+
const isSqlite = adapter.constructor.name === "SQLiteAdapter";
|
|
190
|
+
const insertSql =
|
|
191
|
+
`INSERT INTO "${tableName}" (${columns.map((c) => `"${c}"`).join(", ")}) VALUES (${placeholders})` +
|
|
192
|
+
(isSqlite ? "" : ` RETURNING "${pkColumn}"`);
|
|
193
|
+
|
|
194
|
+
const insertResult = await adapterExecute(adapter, insertSql, values);
|
|
195
|
+
|
|
196
|
+
// Recover the new PK: SQLite via lastInsertId(); others via RETURNING.
|
|
197
|
+
let lastId: unknown = isSqlite ? adapter.lastInsertId() : null;
|
|
198
|
+
if (lastId === null && insertResult && typeof insertResult === "object") {
|
|
199
|
+
const rrows = (insertResult as any).rows;
|
|
200
|
+
if (Array.isArray(rrows) && rrows[0]) {
|
|
201
|
+
lastId = rrows[0][pkColumn] ?? rrows[0].id ?? null;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (lastId === null || lastId === undefined) {
|
|
205
|
+
lastId = adapter.lastInsertId();
|
|
206
|
+
}
|
|
190
207
|
|
|
191
208
|
// Fetch the created record to include auto-generated fields (e.g. id)
|
|
192
|
-
const
|
|
193
|
-
const created = adapter.query(
|
|
209
|
+
const created = await adapterQuery(adapter,
|
|
194
210
|
`SELECT * FROM "${tableName}" WHERE "${pkColumn}" = ?`,
|
|
195
211
|
[lastId],
|
|
196
212
|
);
|
|
@@ -212,7 +228,7 @@ export function generateCrudRoutes(models: DiscoveredModel[]): RouteDefinition[]
|
|
|
212
228
|
const body = req.body as Record<string, unknown>;
|
|
213
229
|
|
|
214
230
|
const conditions = [`"${pkColumn}" = ?`, ...extraConditions];
|
|
215
|
-
const existing = adapter
|
|
231
|
+
const existing = await adapterQuery(adapter,
|
|
216
232
|
`SELECT * FROM "${tableName}" WHERE ${conditions.join(" AND ")}`,
|
|
217
233
|
[req.params.id],
|
|
218
234
|
);
|
|
@@ -232,12 +248,12 @@ export function generateCrudRoutes(models: DiscoveredModel[]): RouteDefinition[]
|
|
|
232
248
|
.join(", ");
|
|
233
249
|
const values = [...Object.values(dbBody), req.params.id];
|
|
234
250
|
|
|
235
|
-
adapter
|
|
251
|
+
await adapterExecute(adapter,
|
|
236
252
|
`UPDATE "${tableName}" SET ${setClauses} WHERE "${pkColumn}" = ?`,
|
|
237
253
|
values,
|
|
238
254
|
);
|
|
239
255
|
|
|
240
|
-
const updated = adapter
|
|
256
|
+
const updated = await adapterQuery(adapter,
|
|
241
257
|
`SELECT * FROM "${tableName}" WHERE "${pkColumn}" = ?`,
|
|
242
258
|
[req.params.id],
|
|
243
259
|
);
|
|
@@ -258,7 +274,7 @@ export function generateCrudRoutes(models: DiscoveredModel[]): RouteDefinition[]
|
|
|
258
274
|
const adapter = getAdapter();
|
|
259
275
|
|
|
260
276
|
const conditions = [`"${pkColumn}" = ?`, ...extraConditions];
|
|
261
|
-
const existing = adapter
|
|
277
|
+
const existing = await adapterQuery(adapter,
|
|
262
278
|
`SELECT * FROM "${tableName}" WHERE ${conditions.join(" AND ")}`,
|
|
263
279
|
[req.params.id],
|
|
264
280
|
);
|
|
@@ -268,13 +284,13 @@ export function generateCrudRoutes(models: DiscoveredModel[]): RouteDefinition[]
|
|
|
268
284
|
}
|
|
269
285
|
|
|
270
286
|
if (softDelete) {
|
|
271
|
-
adapter
|
|
287
|
+
await adapterExecute(adapter,
|
|
272
288
|
`UPDATE "${tableName}" SET is_deleted = 1 WHERE "${pkColumn}" = ?`,
|
|
273
289
|
[req.params.id],
|
|
274
290
|
);
|
|
275
291
|
res.json({ message: "Deleted (soft)", data: existing[0] });
|
|
276
292
|
} else {
|
|
277
|
-
adapter
|
|
293
|
+
await adapterExecute(adapter,
|
|
278
294
|
`DELETE FROM "${tableName}" WHERE "${pkColumn}" = ?`,
|
|
279
295
|
[req.params.id],
|
|
280
296
|
);
|