tina4-nodejs 3.13.72 → 3.13.73

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 (22) hide show
  1. package/CLAUDE.md +3 -3
  2. package/package.json +1 -1
  3. package/packages/core/gallery/auth/src/routes/api/gallery/auth/login/post.ts +2 -2
  4. package/packages/core/gallery/auth/src/routes/api/gallery/auth/verify/get.ts +2 -2
  5. package/packages/core/gallery/auth/src/routes/gallery/auth/get.ts +2 -2
  6. package/packages/core/gallery/database/src/routes/api/gallery/db/notes/get.ts +3 -3
  7. package/packages/core/gallery/database/src/routes/api/gallery/db/notes/post.ts +3 -3
  8. package/packages/core/gallery/database/src/routes/api/gallery/db/tables/get.ts +3 -3
  9. package/packages/core/gallery/error-overlay/src/routes/api/gallery/crash/get.ts +1 -1
  10. package/packages/core/gallery/orm/src/routes/api/gallery/products/get.ts +1 -1
  11. package/packages/core/gallery/orm/src/routes/api/gallery/products/post.ts +1 -1
  12. package/packages/core/gallery/queue/src/lib/queueDb.ts +2 -2
  13. package/packages/core/gallery/queue/src/routes/api/gallery/queue/consume/post.ts +1 -1
  14. package/packages/core/gallery/queue/src/routes/api/gallery/queue/fail/post.ts +1 -1
  15. package/packages/core/gallery/queue/src/routes/api/gallery/queue/produce/post.ts +1 -1
  16. package/packages/core/gallery/queue/src/routes/api/gallery/queue/retry/post.ts +1 -1
  17. package/packages/core/gallery/queue/src/routes/api/gallery/queue/status/get.ts +1 -1
  18. package/packages/core/gallery/queue/src/routes/gallery/queue/get.ts +1 -1
  19. package/packages/core/gallery/rest-api/src/routes/api/gallery/hello/get.ts +1 -1
  20. package/packages/core/gallery/rest-api/src/routes/api/gallery/hello/post.ts +1 -1
  21. package/packages/core/gallery/templates/src/routes/gallery/page/get.ts +1 -1
  22. package/packages/orm/src/migration.ts +50 -30
package/CLAUDE.md CHANGED
@@ -1,10 +1,10 @@
1
- # CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.71)
1
+ # CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.73)
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.71 - The Intelligent Native Application 4ramework. A convention-over-configuration structural paradigm. The developer writes TypeScript; Tina4 is invisible infrastructure.
7
+ Tina4 for Node.js/TypeScript v3.13.73 - 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
 
@@ -839,7 +839,7 @@ syncModels(discoveredModels); // auto-create tables / add columns
839
839
 
840
840
  - SQL files live in `migrations/`, named `NNNNNN_description.sql` (sequential) or `YYYYMMDDHHMMSS_description.sql` (timestamp), and are split on the `;` delimiter.
841
841
  - Files are applied in **numeric-prefix order** (`9_` before `10_` — a plain lexical sort misorders unpadded prefixes because `"10" < "9"`). A file with no numeric/timestamp prefix sorts **after** the numbered ones (lexically) and logs a `Log.warning` — its order is undefined.
842
- - State is tracked by **row existence** in `tina4_migration` (id, name, batch, applied_at), auto-created per engine: a migration runs once if a row with its `name` exists it is skipped. A FAILED migration is **never** recorded (no row is written, nothing is deleted) fix the bad file and re-run; the `migrate()` summary's `failed[]` carries the failure.
842
+ - State is tracked in the `tina4_migration` table (auto-created per engine, canonical columns `id, migration_name VARCHAR(500) NOT NULL UNIQUE, description VARCHAR(500), batch INTEGER NOT NULL DEFAULT 1, executed_at VARCHAR(50) NOT NULL, passed INTEGER NOT NULL DEFAULT 1` - identical across all four frameworks). A migration is **applied** when a row exists for it with `passed = 1` (the applied-read is `WHERE passed = 1`). `migrate()` writes **only `passed = 1` rows**, and it does so **delete-before-insert**: on success it DELETEs any existing row for that `migration_name` and then INSERTs the fresh `passed = 1` row (the shared `recordApplied()` helper, mirroring the Python master's `_record_applied()`), so the table holds **at most one row per `migration_name`** - latest state wins. A FAILED migration file is rolled back and **no row is written** for it (it is NOT recorded as `passed = 0`; the record step is never reached), and the run STOPS (the `migrate()` summary's `failed[]` carries the failure). The public `recordMigration(name, batch, passed)` API can write a `passed = 0` row (and one may be carried over from an older table); any `passed = 0` row is treated as **not applied**. Because the success path deletes any existing row for the `migration_name` before the `passed = 1` INSERT, a leftover `passed = 0` row **re-applies cleanly** on the next `migrate()` - the stale row is superseded rather than colliding on the UNIQUE `migration_name` (that collision previously wedged a re-run). Fix the bad file and re-run.
843
843
  - **Each migration FILE is wrapped in its own transaction.** On a failure the file rolls back and `migrate()` **STOPS** — later files are never applied on top of a missing earlier one (parity with Python/PHP/Ruby). Already-applied files stay applied. The explicit `tina4 migrate` CLI surfaces a non-empty `failed[]` as a non-zero exit; startup auto-migration logs it and the service still boots (see `TINA4_AUTO_MIGRATE` above).
844
844
  - **Atomicity caveat:** per-file transactions are truly atomic only on engines with **transactional DDL (PostgreSQL)**. MySQL, Firebird, and SQLite auto-commit DDL, so a multi-statement migration that fails midway on those engines leaves earlier statements applied — keep one logical change per file. `CREATE TABLE` and `ALTER TABLE ... ADD` are made idempotent on Firebird/MSSQL (existence-checked via `RDB$RELATION_FIELDS` / `tableExists`) so a re-run with a raw `CREATE`/`ADD` does not error "object already exists"; SQLite/MySQL/PostgreSQL support `IF NOT EXISTS` and are left to the engine. Only a genuine already-exists is skipped — every other error still raises.
845
845
  - The stored-proc block delimiters (`$$ … $$` / `// … //`) are extracted before splitting, but a `//` preceded by a colon is **not** treated as a delimiter, so a URL (`https://…`) or any `://` literal inside a migration is never swallowed as an opaque block.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tina4-nodejs",
3
- "version": "3.13.72",
3
+ "version": "3.13.73",
4
4
  "type": "module",
5
5
  "description": "Tina4 for Node.js/TypeScript - 54 built-in features, zero dependencies",
6
6
  "keywords": [
@@ -1,5 +1,5 @@
1
1
  /** Gallery: Auth — login endpoint, returns a JWT token. */
2
- import type { Tina4Request, Tina4Response } from "@tina4/core";
2
+ import type { Tina4Request, Tina4Response } from "tina4-nodejs";
3
3
 
4
4
  export default async function (req: Tina4Request, res: Tina4Response) {
5
5
  const body = (req.body as Record<string, unknown>) ?? {};
@@ -7,7 +7,7 @@ export default async function (req: Tina4Request, res: Tina4Response) {
7
7
  const password = (body.password as string) ?? "";
8
8
 
9
9
  if (username && password) {
10
- // In a real app: import { Auth } from "@tina4/core";
10
+ // In a real app: import { Auth } from "tina4-nodejs";
11
11
  // const auth = new Auth();
12
12
  // const token = auth.createToken({ username, role: "user" });
13
13
  // For the gallery demo, generate a simple base64 token
@@ -1,11 +1,11 @@
1
1
  /** Gallery: Auth — verify a JWT token. */
2
- import type { Tina4Request, Tina4Response } from "@tina4/core";
2
+ import type { Tina4Request, Tina4Response } from "tina4-nodejs";
3
3
 
4
4
  export default async function (req: Tina4Request, res: Tina4Response) {
5
5
  const url = new URL(req.url ?? "/", "http://localhost");
6
6
  const token = url.searchParams.get("token") ?? "";
7
7
 
8
- // In a real app: import { Auth } from "@tina4/core";
8
+ // In a real app: import { Auth } from "tina4-nodejs";
9
9
  // const auth = new Auth();
10
10
  // const isValid = auth.validateToken(token);
11
11
  // For the gallery demo, just check the token has 3 parts
@@ -1,5 +1,5 @@
1
1
  /** Gallery: Auth — JWT login with a visual demo page. */
2
- import type { Tina4Request, Tina4Response } from "@tina4/core";
2
+ import type { Tina4Request, Tina4Response } from "tina4-nodejs";
3
3
 
4
4
  export default async function (_req: Tina4Request, res: Tina4Response) {
5
5
  const html = `<!DOCTYPE html>
@@ -44,7 +44,7 @@ export default async function (_req: Tina4Request, res: Tina4Response) {
44
44
  <div class="card bg-dark mt-4" style="border:1px solid #334155;">
45
45
  <div class="card-body">
46
46
  <h6 style="color:#e2e8f0;">How it works</h6>
47
- <pre style="background:#0f172a;color:#4ade80;padding:1rem;border-radius:0.5rem;font-size:0.8rem;"><code>import { Auth } from "@tina4/core";
47
+ <pre style="background:#0f172a;color:#4ade80;padding:1rem;border-radius:0.5rem;font-size:0.8rem;"><code>import { Auth } from "tina4-nodejs";
48
48
  const auth = new Auth();
49
49
  const token = auth.createToken({ username: "admin" });
50
50
  const payload = auth.getPayload(token);
@@ -1,10 +1,10 @@
1
1
  /** Gallery: Database — list notes from the gallery database. */
2
- import type { Tina4Request, Tina4Response } from "@tina4/core";
2
+ import type { Tina4Request, Tina4Response } from "tina4-nodejs";
3
3
 
4
4
  export default async function (_req: Tina4Request, res: Tina4Response) {
5
5
  try {
6
- const orm = await import("@tina4/orm");
7
- const db = orm.initDatabase({ type: "sqlite", path: "./data/gallery.db" });
6
+ const orm = await import("tina4-nodejs/orm");
7
+ const db = await orm.initDatabase({ type: "sqlite", path: "./data/gallery.db" });
8
8
  const result = await db.fetch("SELECT * FROM gallery_notes ORDER BY id DESC", undefined, 50);
9
9
  return res.json(result.records ?? []);
10
10
  } catch (e: unknown) {
@@ -1,11 +1,11 @@
1
1
  /** Gallery: Database — create a note in the gallery database. */
2
- import type { Tina4Request, Tina4Response } from "@tina4/core";
2
+ import type { Tina4Request, Tina4Response } from "tina4-nodejs";
3
3
 
4
4
  export default async function (req: Tina4Request, res: Tina4Response) {
5
5
  try {
6
6
  const body = (req.body as Record<string, unknown>) ?? {};
7
- const orm = await import("@tina4/orm");
8
- const db = orm.initDatabase({ type: "sqlite", path: "./data/gallery.db" });
7
+ const orm = await import("tina4-nodejs/orm");
8
+ const db = await orm.initDatabase({ type: "sqlite", path: "./data/gallery.db" });
9
9
  await db.insert("gallery_notes", {
10
10
  title: (body.title as string) ?? "Untitled",
11
11
  body: (body.body as string) ?? "",
@@ -1,10 +1,10 @@
1
1
  /** Gallery: Database — list tables in the gallery database. */
2
- import type { Tina4Request, Tina4Response } from "@tina4/core";
2
+ import type { Tina4Request, Tina4Response } from "tina4-nodejs";
3
3
 
4
4
  export default async function (_req: Tina4Request, res: Tina4Response) {
5
5
  try {
6
- const orm = await import("@tina4/orm");
7
- const db = orm.initDatabase({ type: "sqlite", path: "./data/gallery.db" });
6
+ const orm = await import("tina4-nodejs/orm");
7
+ const db = await orm.initDatabase({ type: "sqlite", path: "./data/gallery.db" });
8
8
 
9
9
  await db.execute(`
10
10
  CREATE TABLE IF NOT EXISTS gallery_notes (
@@ -7,7 +7,7 @@
7
7
  * - Request details (method, path, headers)
8
8
  * - Environment info (framework version, Node.js version)
9
9
  */
10
- import type { Tina4Request, Tina4Response } from "@tina4/core";
10
+ import type { Tina4Request, Tina4Response } from "tina4-nodejs";
11
11
 
12
12
  export default async function (_req: Tina4Request, _res: Tina4Response) {
13
13
  // Simulate a realistic error — accessing a missing property
@@ -1,5 +1,5 @@
1
1
  /** Gallery: ORM — list products (demo data). */
2
- import type { Tina4Request, Tina4Response } from "@tina4/core";
2
+ import type { Tina4Request, Tina4Response } from "tina4-nodejs";
3
3
 
4
4
  export default async function (_req: Tina4Request, res: Tina4Response) {
5
5
  return res.json({
@@ -1,5 +1,5 @@
1
1
  /** Gallery: ORM — create a product (demo). */
2
- import type { Tina4Request, Tina4Response } from "@tina4/core";
2
+ import type { Tina4Request, Tina4Response } from "tina4-nodejs";
3
3
 
4
4
  export default async function (req: Tina4Request, res: Tina4Response) {
5
5
  const data = (req.body as Record<string, unknown>) ?? {};
@@ -1,5 +1,5 @@
1
1
  /** Shared SQLite database helper for the queue gallery demo. */
2
- import type { DatabaseAdapter } from "@tina4/orm";
2
+ import type { DatabaseAdapter } from "tina4-nodejs/orm";
3
3
 
4
4
  let _db: DatabaseAdapter | null = null;
5
5
 
@@ -7,7 +7,7 @@ export const MAX_RETRIES = 3;
7
7
 
8
8
  export async function getQueueDb(): Promise<DatabaseAdapter> {
9
9
  if (_db) return _db;
10
- const orm = await import("@tina4/orm");
10
+ const orm = await import("tina4-nodejs/orm");
11
11
  _db = await orm.initDatabase({ type: "sqlite", path: "./data/gallery_queue.db" });
12
12
  try {
13
13
  _db.execute(`CREATE TABLE IF NOT EXISTS tina4_queue (
@@ -1,5 +1,5 @@
1
1
  /** Gallery: Queue — consume (complete) the next pending message. */
2
- import type { Tina4Request, Tina4Response } from "@tina4/core";
2
+ import type { Tina4Request, Tina4Response } from "tina4-nodejs";
3
3
  import { getQueueDb, now } from "../../../../lib/queueDb.js";
4
4
 
5
5
  export default async function (_req: Tina4Request, res: Tina4Response) {
@@ -1,5 +1,5 @@
1
1
  /** Gallery: Queue — deliberately fail the next pending message. */
2
- import type { Tina4Request, Tina4Response } from "@tina4/core";
2
+ import type { Tina4Request, Tina4Response } from "tina4-nodejs";
3
3
  import { getQueueDb, now } from "../../../../lib/queueDb.js";
4
4
 
5
5
  export default async function (_req: Tina4Request, res: Tina4Response) {
@@ -1,5 +1,5 @@
1
1
  /** Gallery: Queue — produce a message into the queue. */
2
- import type { Tina4Request, Tina4Response } from "@tina4/core";
2
+ import type { Tina4Request, Tina4Response } from "tina4-nodejs";
3
3
  import { getQueueDb, now } from "../../../../lib/queueDb.js";
4
4
 
5
5
  export default async function (req: Tina4Request, res: Tina4Response) {
@@ -1,5 +1,5 @@
1
1
  /** Gallery: Queue — retry failed messages (re-queue under max retries). */
2
- import type { Tina4Request, Tina4Response } from "@tina4/core";
2
+ import type { Tina4Request, Tina4Response } from "tina4-nodejs";
3
3
  import { getQueueDb, now, MAX_RETRIES } from "../../../../lib/queueDb.js";
4
4
 
5
5
  export default async function (_req: Tina4Request, res: Tina4Response) {
@@ -1,5 +1,5 @@
1
1
  /** Gallery: Queue — list all messages with statuses. */
2
- import type { Tina4Request, Tina4Response } from "@tina4/core";
2
+ import type { Tina4Request, Tina4Response } from "tina4-nodejs";
3
3
  import { getQueueDb, MAX_RETRIES } from "../../../../lib/queueDb.js";
4
4
 
5
5
  export default async function (_req: Tina4Request, res: Tina4Response) {
@@ -1,5 +1,5 @@
1
1
  /** Gallery: Queue — interactive queue demo with visual web UI. */
2
- import type { Tina4Request, Tina4Response } from "@tina4/core";
2
+ import type { Tina4Request, Tina4Response } from "tina4-nodejs";
3
3
 
4
4
  export default async function (_req: Tina4Request, res: Tina4Response) {
5
5
  const html = `<!DOCTYPE html>
@@ -1,5 +1,5 @@
1
1
  /** Gallery: REST API — simple JSON GET endpoint. */
2
- import type { Tina4Request, Tina4Response } from "@tina4/core";
2
+ import type { Tina4Request, Tina4Response } from "tina4-nodejs";
3
3
 
4
4
  export default async function (req: Tina4Request, res: Tina4Response) {
5
5
  return res.json({ message: "Hello from Tina4!", method: "GET" });
@@ -1,5 +1,5 @@
1
1
  /** Gallery: REST API — simple JSON POST endpoint. */
2
- import type { Tina4Request, Tina4Response } from "@tina4/core";
2
+ import type { Tina4Request, Tina4Response } from "tina4-nodejs";
3
3
 
4
4
  export default async function (req: Tina4Request, res: Tina4Response) {
5
5
  const data = (req.body as Record<string, unknown>) ?? {};
@@ -1,5 +1,5 @@
1
1
  /** Gallery: Templates — render an HTML page with dynamic data via template export. */
2
- import type { Tina4Request, Tina4Response } from "@tina4/core";
2
+ import type { Tina4Request, Tina4Response } from "tina4-nodejs";
3
3
 
4
4
  export const template = "gallery_page.twig";
5
5
 
@@ -437,32 +437,66 @@ export async function isMigrationApplied(name: string): Promise<boolean> {
437
437
  }
438
438
 
439
439
  /**
440
- * Record a migration as applied. Writes the canonical columns: migration_name,
441
- * a derived description, batch, an explicit ISO-8601 executed_at timestamp, and
442
- * passed (default 1). Preserves the Firebird generator-id branch.
440
+ * Write the canonical "applied" bookkeeping row for a migration.
441
+ *
442
+ * DELETEs any existing row for this migration_name FIRST, then INSERTs the
443
+ * fresh row. A leftover passed=0 row (a prior failure recorded via the public
444
+ * recordMigration() API, or one carried over from a <= 3.13.54 upgrade) is
445
+ * therefore superseded, so the migration re-applies cleanly instead of colliding
446
+ * on the UNIQUE migration_name constraint when the fresh passed=1 row is
447
+ * INSERTed (that collision used to wedge a previously-failed migration).
448
+ * DELETE+INSERT is portable across every engine (no UPSERT dialect variance) and
449
+ * holds the invariant that the table carries AT MOST ONE row per migration_name -
450
+ * latest state wins.
451
+ *
452
+ * Operates on the passed adapter so both migrate() (its own adapter) and
453
+ * recordMigration() (the global adapter) share ONE implementation - mirrors the
454
+ * Python master's single _record_applied(). Preserves the Firebird generator-id
455
+ * branch (no AUTOINCREMENT on Firebird). Writes the canonical columns:
456
+ * migration_name, a derived description, batch, an explicit ISO-8601 executed_at
457
+ * timestamp, and passed (default 1).
443
458
  */
444
- export async function recordMigration(name: string, batch: number, passed: number = 1): Promise<void> {
445
- const adapter = getAdapter();
459
+ async function recordApplied(
460
+ db: DatabaseAdapter,
461
+ name: string,
462
+ batch: number,
463
+ passed: number = 1,
464
+ ): Promise<void> {
446
465
  const now = new Date().toISOString();
447
466
  const description = deriveDescription(name);
448
- if (isFirebirdAdapter(adapter)) {
449
- // Firebird: generate ID from sequence
450
- const rows = await adapterQuery<{ NEXT_ID: number }>(adapter,
467
+ // Delete-before-insert: supersede any leftover row for this migration_name so
468
+ // the INSERT below never collides on the UNIQUE migration_name.
469
+ await adapterExecute(db,
470
+ `DELETE FROM "${MIGRATION_TABLE}" WHERE migration_name = ?`,
471
+ [name],
472
+ );
473
+ if (isFirebirdAdapter(db)) {
474
+ // Firebird: generate the id from the sequence.
475
+ const rows = await adapterQuery<{ NEXT_ID: number }>(db,
451
476
  "SELECT GEN_ID(GEN_TINA4_MIGRATION_ID, 1) AS NEXT_ID FROM RDB$DATABASE",
452
477
  );
453
478
  const nextId = rows[0]?.NEXT_ID ?? 1;
454
- await adapterExecute(adapter,
479
+ await adapterExecute(db,
455
480
  `INSERT INTO "${MIGRATION_TABLE}" (id, migration_name, description, batch, executed_at, passed) VALUES (?, ?, ?, ?, ?, ?)`,
456
481
  [nextId, name, description, batch, now, passed],
457
482
  );
458
483
  } else {
459
- await adapterExecute(adapter,
484
+ await adapterExecute(db,
460
485
  `INSERT INTO "${MIGRATION_TABLE}" (migration_name, description, batch, executed_at, passed) VALUES (?, ?, ?, ?, ?)`,
461
486
  [name, description, batch, now, passed],
462
487
  );
463
488
  }
464
489
  }
465
490
 
491
+ /**
492
+ * Record a migration as applied (public API). Routes through recordApplied() so
493
+ * a leftover passed=0 row for the same migration_name is deleted before the
494
+ * fresh row is written (at most one row per migration_name).
495
+ */
496
+ export async function recordMigration(name: string, batch: number, passed: number = 1): Promise<void> {
497
+ await recordApplied(getAdapter(), name, batch, passed);
498
+ }
499
+
466
500
  /**
467
501
  * Apply a migration (run its up function and record it).
468
502
  */
@@ -981,26 +1015,12 @@ export async function migrate(
981
1015
  await adapterExecute(db, stmt);
982
1016
  }
983
1017
 
984
- // Record as applied (passed = 1) with the canonical columns and an
985
- // explicit ISO-8601 executed_at timestamp.
986
- const now = new Date().toISOString();
987
- const description = deriveDescription(migrationId);
988
- if (isFirebirdAdapter(db)) {
989
- // Firebird: generate ID from sequence
990
- const idRows = await adapterQuery<{ NEXT_ID: number }>(db,
991
- "SELECT GEN_ID(GEN_TINA4_MIGRATION_ID, 1) AS NEXT_ID FROM RDB$DATABASE",
992
- );
993
- const nextId = idRows[0]?.NEXT_ID ?? 1;
994
- await adapterExecute(db,
995
- `INSERT INTO "${MIGRATION_TABLE}" (id, migration_name, description, batch, executed_at, passed) VALUES (?, ?, ?, ?, ?, 1)`,
996
- [nextId, migrationId, description, currentBatch, now],
997
- );
998
- } else {
999
- await adapterExecute(db,
1000
- `INSERT INTO "${MIGRATION_TABLE}" (migration_name, description, batch, executed_at, passed) VALUES (?, ?, ?, ?, 1)`,
1001
- [migrationId, description, currentBatch, now],
1002
- );
1003
- }
1018
+ // Record as applied (passed = 1) via recordApplied(), which DELETEs any
1019
+ // leftover row for this migration_name before the fresh INSERT - so a
1020
+ // previously-failed migration (a leftover passed=0 row) re-applies
1021
+ // cleanly instead of colliding on the UNIQUE migration_name. Both the
1022
+ // DELETE and INSERT run inside this file's open transaction.
1023
+ await recordApplied(db, migrationId, currentBatch);
1004
1024
 
1005
1025
  await adapterCommit(db);
1006
1026
  result.applied.push(file);