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 +2 -2
- package/package.json +1 -1
- package/packages/core/src/background.ts +30 -17
- package/packages/orm/src/migration.ts +40 -9
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.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.
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
|