tina4-nodejs 3.13.74 → 3.13.76

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.74)
1
+ # CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.76)
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.74 - 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.76 - 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.74",
3
+ "version": "3.13.76",
4
4
  "type": "module",
5
5
  "description": "Tina4 for Node.js/TypeScript - 54 built-in features, zero dependencies",
6
6
  "keywords": [
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync, statSync } from "node:fs";
1
+ import { existsSync, readFileSync, statSync, type Stats } from "node:fs";
2
2
  import { join, extname } from "node:path";
3
3
  import type { Tina4Request, Tina4Response } from "./types.js";
4
4
 
@@ -55,6 +55,29 @@ export function tryServeStatic(
55
55
  const ext = extname(filePath);
56
56
  const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
57
57
 
58
+ // Cheap validators from the file's size + mtime — no hashing needed. A weak
59
+ // ETag (W/) is correct here: two representations with the same size+mtime
60
+ // are treated as equivalent for caching. Last-Modified is second-resolution
61
+ // per HTTP.
62
+ const etag = `W/"${stat.size}-${stat.mtimeMs}"`;
63
+ const lastModified = stat.mtime.toUTCString();
64
+
65
+ // A static asset MAY be cached but MUST be revalidated before use, so a
66
+ // redeployed file reaches the browser on the next load without a manual hard
67
+ // refresh; an unchanged asset costs a cheap 304, not a re-download. These
68
+ // ride on both the 200 and the 304 so the client always has fresh
69
+ // validators. Parity with the Python master static handler.
70
+ res.raw.setHeader("Cache-Control", "no-cache, must-revalidate");
71
+ res.raw.setHeader("ETag", etag);
72
+ res.raw.setHeader("Last-Modified", lastModified);
73
+
74
+ // Conditional request → 304 Not Modified with no body.
75
+ if (isNotModified(req, etag, stat)) {
76
+ res.raw.statusCode = 304;
77
+ res.raw.end();
78
+ return true;
79
+ }
80
+
58
81
  res.raw.setHeader("Content-Type", contentType);
59
82
  res.raw.setHeader("Content-Length", stat.size);
60
83
  res.raw.end(readFileSync(filePath));
@@ -63,3 +86,53 @@ export function tryServeStatic(
63
86
 
64
87
  return false;
65
88
  }
89
+
90
+ /**
91
+ * Read a conditional-request header off the incoming request. Works with the
92
+ * real `IncomingMessage` (`req.headers`, already lower-cased by Node) AND with
93
+ * the hand-rolled request objects used in unit tests. Header names must be
94
+ * lower-case. Returns "" when absent.
95
+ */
96
+ function conditionalHeader(req: Tina4Request, name: string): string {
97
+ const headers = (req as { headers?: Record<string, string | string[] | undefined> }).headers;
98
+ const value = headers?.[name];
99
+ if (Array.isArray(value)) return value[0] ?? "";
100
+ return value ?? "";
101
+ }
102
+
103
+ /**
104
+ * Answer whether the cached representation the client already holds is still
105
+ * current. `If-None-Match` wins over `If-Modified-Since` (RFC 7232 §3.3): when
106
+ * the client sends an entity tag we compare against that alone.
107
+ */
108
+ function isNotModified(req: Tina4Request, etag: string, stat: Stats): boolean {
109
+ const ifNoneMatch = conditionalHeader(req, "if-none-match");
110
+ if (ifNoneMatch) {
111
+ return etagMatches(ifNoneMatch, etag);
112
+ }
113
+ const ifModifiedSince = conditionalHeader(req, "if-modified-since");
114
+ if (ifModifiedSince) {
115
+ const since = Date.parse(ifModifiedSince);
116
+ if (!Number.isNaN(since)) {
117
+ // Last-Modified is second-resolution; floor the mtime so sub-second
118
+ // precision never reports a spurious "modified".
119
+ const modified = Math.floor(stat.mtimeMs / 1000) * 1000;
120
+ return modified <= since;
121
+ }
122
+ }
123
+ return false;
124
+ }
125
+
126
+ /**
127
+ * Weak comparison of an `If-None-Match` value against our ETag. The header may
128
+ * be `*`, a single tag, or a comma-separated list; the `W/` weak prefix is
129
+ * stripped on both sides before comparing (RFC 7232 §2.3.2 weak comparison).
130
+ */
131
+ function etagMatches(ifNoneMatch: string, etag: string): boolean {
132
+ const strip = (tag: string) => tag.trim().replace(/^W\//, "");
133
+ const target = strip(etag);
134
+ return ifNoneMatch.split(",").some((candidate) => {
135
+ const trimmed = candidate.trim();
136
+ return trimmed === "*" || strip(trimmed) === target;
137
+ });
138
+ }
@@ -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