tina4-nodejs 3.13.73 → 3.13.75

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.73)
1
+ # CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.75)
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.73 - 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.75 - 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.73",
3
+ "version": "3.13.75",
4
4
  "type": "module",
5
5
  "description": "Tina4 for Node.js/TypeScript - 54 built-in features, zero dependencies",
6
6
  "keywords": [
@@ -1753,30 +1753,33 @@ const handleConnectionsTest: RouteHandler = async (req, res) => {
1753
1753
  let version = "Connected";
1754
1754
  let tableCount = 0;
1755
1755
  try {
1756
- const tables = db.getTables();
1756
+ // getTables() is async (Promise<string[]>) and MUST be awaited — the
1757
+ // un-awaited call made tableCount always 0 (Array.isArray(Promise) is
1758
+ // false). Python (db.get_tables()) and Ruby (db.tables) were correct.
1759
+ const tables = await db.getTables();
1757
1760
  tableCount = Array.isArray(tables) ? tables.length : 0;
1758
1761
  } catch { tableCount = 0; }
1759
1762
  try {
1760
1763
  const urlLower = url.toLowerCase();
1761
- // NOTE: db.execute() is async; these calls are intentionally left
1762
- // un-awaited to preserve the exact existing runtime behaviour during
1763
- // this type-only cleanup. `row` is therefore a Promise and the `as any`
1764
- // access below evaluates to the fallback string. See report open question.
1764
+ // fetchOne() is async and returns a single row object ({ v: ... }); it
1765
+ // must be awaited. Previously db.execute() was left un-awaited, so `row`
1766
+ // was a Promise and the version always fell back to the default string.
1767
+ // Mirrors Python/Ruby which read row["v"] from a fetch_one().
1765
1768
  if (urlLower.includes("sqlite")) {
1766
- const row = db.execute("SELECT sqlite_version() as v");
1767
- version = `SQLite ${(row as any)?.[0]?.v ?? ""}`;
1769
+ const row = await db.fetchOne<{ v?: string }>("SELECT sqlite_version() as v");
1770
+ version = `SQLite ${row?.v ?? ""}`;
1768
1771
  } else if (urlLower.includes("postgres")) {
1769
- const row = db.execute("SELECT version() as v");
1770
- version = ((row as any)?.[0]?.v ?? "PostgreSQL").toString().split(",")[0];
1772
+ const row = await db.fetchOne<{ v?: string }>("SELECT version() as v");
1773
+ version = (row?.v ?? "PostgreSQL").toString().split(",")[0];
1771
1774
  } else if (urlLower.includes("mysql")) {
1772
- const row = db.execute("SELECT version() as v");
1773
- version = `MySQL ${(row as any)?.[0]?.v ?? ""}`;
1775
+ const row = await db.fetchOne<{ v?: string }>("SELECT version() as v");
1776
+ version = `MySQL ${row?.v ?? ""}`;
1774
1777
  } else if (urlLower.includes("mssql")) {
1775
- const row = db.execute("SELECT @@VERSION as v");
1776
- version = ((row as any)?.[0]?.v ?? "MSSQL").toString().split("\n")[0];
1778
+ const row = await db.fetchOne<{ v?: string }>("SELECT @@VERSION as v");
1779
+ version = (row?.v ?? "MSSQL").toString().split("\n")[0];
1777
1780
  } else if (urlLower.includes("firebird")) {
1778
- const row = db.execute("SELECT rdb$get_context('SYSTEM', 'ENGINE_VERSION') as v FROM rdb$database");
1779
- version = `Firebird ${(row as any)?.[0]?.v ?? ""}`;
1781
+ const row = await db.fetchOne<{ v?: string }>("SELECT rdb$get_context('SYSTEM', 'ENGINE_VERSION') as v FROM rdb$database");
1782
+ version = `Firebird ${row?.v ?? ""}`;
1780
1783
  }
1781
1784
  } catch { /* keep version as Connected */ }
1782
1785
  db.close();
@@ -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
+ }