tina4-nodejs 3.13.75 → 3.13.77

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 CHANGED
@@ -1,10 +1,10 @@
1
- # CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.75)
1
+ # CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.77)
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.75 - 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.77 - 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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tina4-nodejs",
3
- "version": "3.13.75",
3
+ "version": "3.13.77",
4
4
  "type": "module",
5
5
  "description": "Tina4 for Node.js/TypeScript - 54 built-in features, zero dependencies",
6
6
  "keywords": [
@@ -22,6 +22,8 @@ interface BackgroundTask {
22
22
  callback: () => unknown | Promise<unknown>;
23
23
  intervalSeconds: number;
24
24
  timer: NodeJS.Timeout;
25
+ /** Set by any stop path. Checked around the await so a run in flight never re-arms. */
26
+ stopped: boolean;
25
27
  }
26
28
 
27
29
  const _tasks: BackgroundTask[] = [];
@@ -64,32 +66,42 @@ export function background(
64
66
  _bindSignalsOnce();
65
67
 
66
68
  const ms = Math.max(1, Math.round(intervalSeconds * 1000));
67
- const timer = setInterval(() => {
69
+
70
+ // A task must NEVER overlap itself. setInterval fires on a fixed schedule and
71
+ // does not wait for an async callback, so a run slower than the interval would
72
+ // have a second copy start alongside it (silent double-execution of a slow
73
+ // sweep, with every later tick piling on another). Re-arm a one-shot timer only
74
+ // AFTER the run settles — the interval is then the gap BETWEEN runs. Mirrors the
75
+ // Python master (background_tick_loop) and matches PHP/Ruby, which never overlap.
76
+ const tick = async () => {
77
+ if (task.stopped) return;
68
78
  try {
69
- const result = callback();
70
- if (result && typeof (result as Promise<unknown>).then === "function") {
71
- (result as Promise<unknown>).catch((err) => {
72
- Log.error?.(`background task error: ${err instanceof Error ? err.message : String(err)}`);
73
- });
74
- }
79
+ await callback();
75
80
  } catch (err) {
76
81
  Log.error?.(`background task error: ${err instanceof Error ? err.message : String(err)}`);
77
82
  }
78
- }, ms);
83
+ // Re-check AFTER the await: a stop during the run must not re-arm.
84
+ if (task.stopped) return;
85
+ task.timer = _arm();
86
+ };
79
87
 
80
- // Don't keep the event loop alive solely for background tasks — this matches
81
- // Python's behaviour, where background tasks live in the server's loop and
82
- // exit with it rather than blocking shutdown.
83
- if (typeof timer.unref === "function") {
84
- timer.unref();
85
- }
88
+ const _arm = () => {
89
+ const t = setTimeout(() => { void tick(); }, ms);
90
+ // Don't keep the event loop alive solely for background tasks — this matches
91
+ // Python's behaviour, where background tasks live in the server's loop and
92
+ // exit with it rather than blocking shutdown.
93
+ if (typeof t.unref === "function") t.unref();
94
+ return t;
95
+ };
86
96
 
87
- const task: BackgroundTask = { callback, intervalSeconds, timer };
97
+ const task: BackgroundTask = { callback, intervalSeconds, timer: undefined as unknown as NodeJS.Timeout, stopped: false };
98
+ task.timer = _arm();
88
99
  _tasks.push(task);
89
100
 
90
101
  return {
91
102
  stop: () => {
92
- clearInterval(task.timer);
103
+ task.stopped = true;
104
+ clearTimeout(task.timer);
93
105
  const idx = _tasks.indexOf(task);
94
106
  if (idx !== -1) _tasks.splice(idx, 1);
95
107
  },
@@ -104,7 +116,8 @@ export function background(
104
116
  export function stopAllBackgroundTasks(): void {
105
117
  while (_tasks.length > 0) {
106
118
  const task = _tasks.pop()!;
107
- clearInterval(task.timer);
119
+ task.stopped = true;
120
+ clearTimeout(task.timer);
108
121
  }
109
122
  }
110
123
 
@@ -470,21 +470,52 @@ async function recordApplied(
470
470
  `DELETE FROM "${MIGRATION_TABLE}" WHERE migration_name = ?`,
471
471
  [name],
472
472
  );
473
+ // Build the column list from the columns that ACTUALLY exist on the table, so
474
+ // a legacy column left behind by an in-place upgrade is populated rather than
475
+ // defaulted to NULL. Node never created a `migration_id` column, but a Node app
476
+ // can be pointed at a database whose tina4_migration table was created by
477
+ // tina4-python v3 <= 3.13.54 - there `migration_id` is NOT NULL, and an insert
478
+ // that omits it fails, wedging every migration on that database
479
+ // (tina4-python#93). Mirrors the PHP + Python masters.
480
+ const cols = await trackingColumns(db);
481
+ const insertCols = ["migration_name", "description", "batch", "executed_at", "passed"];
482
+ const values: unknown[] = [name, description, batch, now, passed];
483
+
484
+ if (cols.has("migration_id")) {
485
+ insertCols.push("migration_id");
486
+ values.push(name);
487
+ }
488
+
473
489
  if (isFirebirdAdapter(db)) {
474
490
  // Firebird: generate the id from the sequence.
475
491
  const rows = await adapterQuery<{ NEXT_ID: number }>(db,
476
492
  "SELECT GEN_ID(GEN_TINA4_MIGRATION_ID, 1) AS NEXT_ID FROM RDB$DATABASE",
477
493
  );
478
- const nextId = rows[0]?.NEXT_ID ?? 1;
479
- await adapterExecute(db,
480
- `INSERT INTO "${MIGRATION_TABLE}" (id, migration_name, description, batch, executed_at, passed) VALUES (?, ?, ?, ?, ?, ?)`,
481
- [nextId, name, description, batch, now, passed],
482
- );
483
- } else {
484
- await adapterExecute(db,
485
- `INSERT INTO "${MIGRATION_TABLE}" (migration_name, description, batch, executed_at, passed) VALUES (?, ?, ?, ?, ?)`,
486
- [name, description, batch, now, passed],
494
+ insertCols.unshift("id");
495
+ values.unshift(rows[0]?.NEXT_ID ?? 1);
496
+ }
497
+
498
+ const placeholders = insertCols.map(() => "?").join(", ");
499
+ await adapterExecute(db,
500
+ `INSERT INTO "${MIGRATION_TABLE}" (${insertCols.join(", ")}) VALUES (${placeholders})`,
501
+ values,
502
+ );
503
+ }
504
+
505
+ /**
506
+ * Lowercased column names on the tracking table. Returns an empty set on any
507
+ * failure so a missing/unreadable table falls back to the canonical columns.
508
+ */
509
+ async function trackingColumns(db: DatabaseAdapter): Promise<Set<string>> {
510
+ try {
511
+ // The DatabaseAdapter contract exposes columns(), not getColumns().
512
+ const cols = await (db as any).columns?.(MIGRATION_TABLE);
513
+ if (!Array.isArray(cols)) return new Set();
514
+ return new Set(
515
+ cols.map((c: any) => String(c?.name ?? c ?? "").toLowerCase()).filter(Boolean),
487
516
  );
517
+ } catch {
518
+ return new Set();
488
519
  }
489
520
  }
490
521