tina4-nodejs 3.2.1 → 3.5.0

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 (34) hide show
  1. package/CLAUDE.md +1 -1
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/packages/cli/src/bin.ts +13 -1
  5. package/packages/cli/src/commands/migrate.ts +19 -5
  6. package/packages/cli/src/commands/migrateCreate.ts +29 -28
  7. package/packages/cli/src/commands/migrateRollback.ts +59 -0
  8. package/packages/cli/src/commands/migrateStatus.ts +62 -0
  9. package/packages/core/public/js/tina4-dev-admin.min.js +1 -1
  10. package/packages/core/public/js/tina4js.min.js +47 -0
  11. package/packages/core/src/auth.ts +44 -10
  12. package/packages/core/src/devAdmin.ts +14 -16
  13. package/packages/core/src/index.ts +10 -3
  14. package/packages/core/src/middleware.ts +232 -2
  15. package/packages/core/src/queue.ts +127 -25
  16. package/packages/core/src/queueBackends/mongoBackend.ts +223 -0
  17. package/packages/core/src/request.ts +3 -3
  18. package/packages/core/src/router.ts +115 -51
  19. package/packages/core/src/server.ts +47 -3
  20. package/packages/core/src/session.ts +29 -1
  21. package/packages/core/src/sessionHandlers/databaseHandler.ts +134 -0
  22. package/packages/core/src/sessionHandlers/redisHandler.ts +230 -0
  23. package/packages/core/src/types.ts +12 -6
  24. package/packages/core/src/websocket.ts +11 -2
  25. package/packages/core/src/websocketConnection.ts +4 -2
  26. package/packages/frond/src/engine.ts +66 -1
  27. package/packages/orm/src/autoCrud.ts +17 -12
  28. package/packages/orm/src/baseModel.ts +99 -21
  29. package/packages/orm/src/database.ts +197 -69
  30. package/packages/orm/src/databaseResult.ts +207 -0
  31. package/packages/orm/src/index.ts +6 -3
  32. package/packages/orm/src/migration.ts +296 -71
  33. package/packages/orm/src/model.ts +1 -0
  34. package/packages/orm/src/types.ts +1 -0
package/CLAUDE.md CHANGED
@@ -587,7 +587,7 @@ When adding new features, add a corresponding `test/<feature>.test.ts` file.
587
587
  - **`npx tina4nodejs generate`**: model, route, migration, middleware scaffolding
588
588
  - **Database**: 5 engines (SQLite, PostgreSQL, MySQL, MSSQL, Firebird), query caching (`TINA4_DB_CACHE=true`)
589
589
  - **Sessions**: file backend (default)
590
- - **Queue**: SQLite/RabbitMQ/Kafka backends, configured via env vars
590
+ - **Queue**: SQLite/RabbitMQ/Kafka/MongoDB backends, configured via env vars
591
591
  - **Cache**: memory/Redis/file backends
592
592
  - **Messenger**: .env driven SMTP/IMAP
593
593
  - **ORM relationships**: `hasMany`, `hasOne`, `belongsTo` with eager loading (`include`)
package/README.md CHANGED
@@ -86,7 +86,7 @@ Every feature is built from scratch -- no npm install, no node_modules bloat, no
86
86
  | **Database** | SQLite, PostgreSQL, MySQL, MSSQL/SQL Server, Firebird -- unified adapter interface, query caching (TINA4_DB_CACHE=true for 4x speedup) |
87
87
  | **Auth** | Zero-dep JWT (HS256 + RS256), sessions (file backend), PBKDF2 password hashing, form tokens |
88
88
  | **API** | Swagger/OpenAPI auto-generation, GraphQL with schema builder and GraphiQL IDE |
89
- | **Background** | Queue (SQLite/RabbitMQ/Kafka) with priority, delayed jobs, retry, batch processing |
89
+ | **Background** | Queue (SQLite/RabbitMQ/Kafka/MongoDB) with priority, delayed jobs, retry, batch processing |
90
90
  | **Real-time** | Native WebSocket (RFC 6455), per-path routing, connection manager, broadcast |
91
91
  | **Frontend** | tina4-css (~24 KB), frond.js helper, SCSS compiler, live reload, CSS hot-reload |
92
92
  | **DX** | Dev admin dashboard, error overlay, request inspector, hot-reload, Carbonah green benchmarks |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tina4-nodejs",
3
- "version": "3.2.1",
3
+ "version": "3.5.0",
4
4
  "type": "module",
5
5
  "description": "This is not a framework. Tina4 for Node.js/TypeScript — zero deps, 38 built-in features.",
6
6
  "keywords": ["tina4", "framework", "web", "api", "orm", "graphql", "websocket", "typescript"],
@@ -2,6 +2,8 @@ import { initProject } from "./commands/init.js";
2
2
  import { serveProject } from "./commands/serve.js";
3
3
  import { runMigrations } from "./commands/migrate.js";
4
4
  import { createMigration } from "./commands/migrateCreate.js";
5
+ import { migrateStatus } from "./commands/migrateStatus.js";
6
+ import { migrateRollback } from "./commands/migrateRollback.js";
5
7
  import { listRoutes } from "./commands/routes.js";
6
8
  import { runTests } from "./commands/test.js";
7
9
  import { generate } from "./commands/generate.js";
@@ -16,7 +18,9 @@ const HELP = `
16
18
  tina4nodejs init [dir] Create a new Tina4 project (default: current directory)
17
19
  tina4nodejs serve Start the dev server with hot-reload
18
20
  tina4nodejs migrate Run pending SQL migrations
19
- tina4nodejs migrate:create <desc> Create a new migration file
21
+ tina4nodejs migrate:create <desc> Create a new migration file pair (.sql + .down.sql)
22
+ tina4nodejs migrate:status Show completed and pending migrations
23
+ tina4nodejs migrate:rollback Roll back the last batch of migrations
20
24
  tina4nodejs routes List all registered routes
21
25
  tina4nodejs test [file] Run project tests
22
26
  tina4nodejs generate <what> <name> Generate scaffolding (model, route, migration, middleware)
@@ -52,6 +56,14 @@ async function main(): Promise<void> {
52
56
  await createMigration(description || undefined);
53
57
  break;
54
58
  }
59
+ case "migrate:status": {
60
+ await migrateStatus(args[1]);
61
+ break;
62
+ }
63
+ case "migrate:rollback": {
64
+ await migrateRollback(args[1]);
65
+ break;
66
+ }
55
67
  case "routes": {
56
68
  await listRoutes();
57
69
  break;
@@ -1,8 +1,12 @@
1
1
  /**
2
2
  * CLI command: migrate — Run pending SQL migration files.
3
3
  *
4
- * Scans the migrations/ directory for .sql files, executes them in order,
5
- * and records each as applied via the ORM migration tracker.
4
+ * Scans the migrations/ directory for .sql files (excluding .down.sql),
5
+ * executes them in order, and records each as applied with a batch number.
6
+ *
7
+ * Supports both naming patterns:
8
+ * - Sequential: 000001_name.sql
9
+ * - Timestamp: YYYYMMDDHHMMSS_name.sql
6
10
  */
7
11
  import { existsSync, readdirSync, readFileSync } from "node:fs";
8
12
  import { join, resolve } from "node:path";
@@ -45,10 +49,20 @@ export async function runMigrations(migrationDir?: string): Promise<void> {
45
49
 
46
50
  ensureMigrationTable();
47
51
 
48
- // Collect .sql files sorted alphabetically
52
+ // Collect .sql files, excluding .down.sql, sorted by numeric prefix
49
53
  const files = readdirSync(dir)
50
- .filter((f) => f.endsWith(".sql"))
51
- .sort();
54
+ .filter((f) => f.endsWith(".sql") && !f.endsWith(".down.sql"))
55
+ .sort((a, b) => {
56
+ const aMatch = a.match(/^(\d+)/);
57
+ const bMatch = b.match(/^(\d+)/);
58
+ if (aMatch && bMatch) {
59
+ const aNum = BigInt(aMatch[1]);
60
+ const bNum = BigInt(bMatch[1]);
61
+ if (aNum < bNum) return -1;
62
+ if (aNum > bNum) return 1;
63
+ }
64
+ return a.localeCompare(b);
65
+ });
52
66
 
53
67
  if (files.length === 0) {
54
68
  console.log(" No .sql migration files found.");
@@ -1,11 +1,13 @@
1
1
  /**
2
- * CLI command: migrate:create — Create a new SQL migration file.
2
+ * CLI command: migrate:create — Create a new SQL migration file pair.
3
3
  *
4
4
  * Usage:
5
5
  * tina4 migrate:create "create users table"
6
6
  * tina4 migrate:create add_email_to_users
7
7
  *
8
- * Creates migrations/000001_description.sql (next sequence number).
8
+ * Creates both:
9
+ * migrations/YYYYMMDDHHMMSS_description.sql (up migration)
10
+ * migrations/YYYYMMDDHHMMSS_description.down.sql (rollback)
9
11
  */
10
12
  import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
11
13
  import { join, resolve } from "node:path";
@@ -24,36 +26,35 @@ export async function createMigration(description?: string): Promise<void> {
24
26
  mkdirSync(dir, { recursive: true });
25
27
  }
26
28
 
27
- // Determine the next sequence number
28
- const existing = existsSync(dir)
29
- ? readdirSync(dir).filter((f) => f.endsWith(".sql")).sort()
30
- : [];
31
-
32
- let nextSeq = 1;
33
- if (existing.length > 0) {
34
- const last = existing[existing.length - 1];
35
- const match = last.match(/^(\d+)/);
36
- if (match) {
37
- nextSeq = parseInt(match[1], 10) + 1;
38
- }
39
- }
40
-
41
29
  // Sanitise description for filename
42
30
  const safeName = description
43
31
  .toLowerCase()
44
32
  .replace(/[^a-z0-9]+/g, "_")
45
33
  .replace(/^_|_$/g, "");
46
34
 
47
- const seqStr = String(nextSeq).padStart(6, "0");
48
- const fileName = `${seqStr}_${safeName}.sql`;
49
- const filePath = join(dir, fileName);
50
-
51
- const template = `-- Migration: ${description}
52
- -- Created: ${new Date().toISOString()}
53
-
54
- `;
55
-
56
- writeFileSync(filePath, template, "utf-8");
57
- console.log(` Created migration: ${fileName}`);
58
- console.log(` Path: ${filePath}`);
35
+ // Use YYYYMMDDHHMMSS timestamp prefix
36
+ const now = new Date();
37
+ const timestamp = [
38
+ now.getFullYear(),
39
+ String(now.getMonth() + 1).padStart(2, "0"),
40
+ String(now.getDate()).padStart(2, "0"),
41
+ String(now.getHours()).padStart(2, "0"),
42
+ String(now.getMinutes()).padStart(2, "0"),
43
+ String(now.getSeconds()).padStart(2, "0"),
44
+ ].join("");
45
+
46
+ const upFileName = `${timestamp}_${safeName}.sql`;
47
+ const downFileName = `${timestamp}_${safeName}.down.sql`;
48
+ const upPath = join(dir, upFileName);
49
+ const downPath = join(dir, downFileName);
50
+
51
+ const upTemplate = `-- Migration: ${description}\n-- Created: ${now.toISOString()}\n\n`;
52
+ const downTemplate = `-- Rollback: ${description}\n-- Created: ${now.toISOString()}\n\n`;
53
+
54
+ writeFileSync(upPath, upTemplate, "utf-8");
55
+ writeFileSync(downPath, downTemplate, "utf-8");
56
+
57
+ console.log(` Created migration: ${upFileName}`);
58
+ console.log(` Created rollback: ${downFileName}`);
59
+ console.log(` Path: ${dir}`);
59
60
  }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * CLI command: migrate:rollback — Roll back the last batch of migrations.
3
+ *
4
+ * Looks for .down.sql files corresponding to each migration in the last batch,
5
+ * executes the SQL, and removes the tracking records.
6
+ *
7
+ * Usage:
8
+ * tina4 migrate:rollback
9
+ * tina4 migrate:rollback ./path/to/migrations
10
+ */
11
+ import { resolve } from "node:path";
12
+
13
+ export async function migrateRollback(migrationDir?: string): Promise<void> {
14
+ const dir = resolve(migrationDir ?? "migrations");
15
+
16
+ let initDatabase: typeof import("@tina4/orm").initDatabase;
17
+ let ensureMigrationTable: typeof import("@tina4/orm").ensureMigrationTable;
18
+ let rollbackFn: typeof import("@tina4/orm").rollback;
19
+ let getLastBatchMigrations: typeof import("@tina4/orm").getLastBatchMigrations;
20
+
21
+ try {
22
+ const orm = await import("@tina4/orm");
23
+ initDatabase = orm.initDatabase;
24
+ ensureMigrationTable = orm.ensureMigrationTable;
25
+ rollbackFn = orm.rollback;
26
+ getLastBatchMigrations = orm.getLastBatchMigrations;
27
+ } catch {
28
+ console.error(" Error: @tina4/orm is required to rollback migrations.");
29
+ process.exit(1);
30
+ }
31
+
32
+ // Ensure database is initialised
33
+ try {
34
+ initDatabase();
35
+ } catch {
36
+ // Adapter may already be set — ignore
37
+ }
38
+
39
+ ensureMigrationTable();
40
+
41
+ const lastBatch = getLastBatchMigrations();
42
+ if (lastBatch.length === 0) {
43
+ console.log(" Nothing to rollback — no migrations have been applied.");
44
+ return;
45
+ }
46
+
47
+ console.log(` Rolling back batch ${lastBatch[0].batch} (${lastBatch.length} migration(s))...`);
48
+
49
+ const rolledBack = rollbackFn(dir);
50
+
51
+ if (rolledBack.length === 0) {
52
+ console.log(" Nothing was rolled back.");
53
+ } else {
54
+ for (const name of rolledBack) {
55
+ console.log(` Rolled back: ${name}`);
56
+ }
57
+ console.log(` Rolled back ${rolledBack.length} migration(s).`);
58
+ }
59
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * CLI command: migrate:status — Show which migrations are applied and which are pending.
3
+ *
4
+ * Usage:
5
+ * tina4 migrate:status
6
+ * tina4 migrate:status ./path/to/migrations
7
+ */
8
+ import { resolve } from "node:path";
9
+
10
+ export async function migrateStatus(migrationDir?: string): Promise<void> {
11
+ const dir = resolve(migrationDir ?? "migrations");
12
+
13
+ let initDatabase: typeof import("@tina4/orm").initDatabase;
14
+ let ensureMigrationTable: typeof import("@tina4/orm").ensureMigrationTable;
15
+ let statusFn: typeof import("@tina4/orm").status;
16
+
17
+ try {
18
+ const orm = await import("@tina4/orm");
19
+ initDatabase = orm.initDatabase;
20
+ ensureMigrationTable = orm.ensureMigrationTable;
21
+ statusFn = orm.status;
22
+ } catch {
23
+ console.error(" Error: @tina4/orm is required to check migration status.");
24
+ process.exit(1);
25
+ }
26
+
27
+ // Ensure database is initialised
28
+ try {
29
+ initDatabase();
30
+ } catch {
31
+ // Adapter may already be set — ignore
32
+ }
33
+
34
+ ensureMigrationTable();
35
+
36
+ const result = await statusFn(undefined, { migrationsDir: dir });
37
+
38
+ console.log("");
39
+ console.log(" Migration Status");
40
+ console.log(" ─────────────────────────────────────");
41
+
42
+ if (result.completed.length === 0 && result.pending.length === 0) {
43
+ console.log(" No migration files found.");
44
+ return;
45
+ }
46
+
47
+ if (result.completed.length > 0) {
48
+ console.log(` Completed (${result.completed.length}):`);
49
+ for (const file of result.completed) {
50
+ console.log(` ✓ ${file}`);
51
+ }
52
+ }
53
+
54
+ if (result.pending.length > 0) {
55
+ console.log(` Pending (${result.pending.length}):`);
56
+ for (const file of result.pending) {
57
+ console.log(` ○ ${file}`);
58
+ }
59
+ }
60
+
61
+ console.log("");
62
+ }
@@ -21,7 +21,7 @@ document.getElementById('routes-count').textContent = d.count;
21
21
  document.getElementById('routes-body').innerHTML = d.routes.map(r => `
22
22
  <tr>
23
23
  <td><span class="method method-${r.method.toLowerCase()}">${r.method}</span></td>
24
- <td class="path">${r.path}</td>
24
+ <td class="path"><a href="${r.path}" target="_blank" title="${r.method !== 'GET' ? r.method + ' route \u2014 may not respond to browser GET' : 'Open in new tab'}" style="color:inherit;text-decoration:underline dotted;${r.method !== 'GET' ? 'opacity:0.7' : ''}">${r.path}</a></td>
25
25
  <td>${r.auth_required ? '<span class="badge-pill bg-reserved">auth</span>' : '<span class="badge-pill bg-success">open</span>'}</td>
26
26
  <td class="text-sm text-muted">${r.handler} <small>(${r.module})</small></td>
27
27
  </tr>`).join('');
@@ -0,0 +1,47 @@
1
+ "use strict";var Tina4=(()=>{var H=Object.defineProperty;var pe=Object.getOwnPropertyDescriptor;var ge=Object.getOwnPropertyNames;var he=Object.prototype.hasOwnProperty;var me=(e,n)=>{for(var t in n)H(e,t,{get:n[t],enumerable:!0})},ye=(e,n,t,o)=>{if(n&&typeof n=="object"||typeof n=="function")for(let r of ge(n))!he.call(e,r)&&r!==t&&H(e,r,{get:()=>n[r],enumerable:!(o=pe(n,r))||o.enumerable});return e};var ve=e=>ye(H({},"__esModule",{value:!0}),e);var Ae={};me(Ae,{Tina4Element:()=>I,api:()=>ie,batch:()=>q,computed:()=>z,effect:()=>m,html:()=>X,isSignal:()=>w,navigate:()=>L,pwa:()=>le,route:()=>oe,router:()=>re,signal:()=>h,ws:()=>ue});var C=null,_=null,M=null;function b(e){M=e}function J(){return M}var B=null,V=null;var N=0,P=new Set;function h(e,n){let t=e,o=new Set,r={_t4:!0,get value(){if(C&&(o.add(C),_)){let a=C;_.push(()=>o.delete(a))}return t},set value(a){if(Object.is(a,t))return;let i=t;if(t=a,r._debugInfo&&r._debugInfo.updateCount++,V&&V(r,i,a),N>0)for(let s of o)P.add(s);else{let s;for(let c of[...o])try{c()}catch(l){s===void 0&&(s=l)}if(s!==void 0)throw s}},_subscribe(a){return o.add(a),()=>{o.delete(a)}},peek(){return t}};return B&&(r._debugInfo={label:n,createdAt:Date.now(),updateCount:0,subs:o},B(r,n)),r}function z(e){let n=h(void 0);return m(()=>{n.value=e()}),{_t4:!0,get value(){return n.value},set value(t){throw new Error("[tina4] computed signals are read-only")},_subscribe(t){return n._subscribe(t)},peek(){return n.peek()}}}function m(e){let n=!1,t=[],o=()=>{if(n)return;for(let s of t)s();t=[];let a=C,i=_;C=o,_=t;try{e()}finally{C=a,_=i}};o();let r=()=>{n=!0;for(let a of t)a();t=[]};return M&&M.push(r),r}function q(e){N++;try{e()}finally{if(N--,N===0){let n=[...P];P.clear();let t;for(let o of n)try{o()}catch(r){t===void 0&&(t=r)}if(t!==void 0)throw t}}}function w(e){return e!==null&&typeof e=="object"&&e._t4===!0}var Q=new WeakMap,D="t4:";function X(e,...n){let t=Q.get(e);if(!t){t=document.createElement("template");let i="";for(let s=0;s<e.length;s++)i+=e[s],s<n.length&&(Te(i)?i+=`__t4_${s}__`:i+=`<!--${D}${s}-->`);t.innerHTML=i,Q.set(e,t)}let o=t.content.cloneNode(!0),r=be(o);for(let{marker:i,index:s}of r)ke(i,n[s]);let a=we(o);for(let i of a)Ce(i,n);return o}function be(e){let n=[];return W(e,t=>{if(t.nodeType===8){let o=t.data;if(o&&o.startsWith(D)){let r=parseInt(o.slice(D.length),10);n.push({marker:t,index:r})}}}),n}function we(e){let n=[];return W(e,t=>{t.nodeType===1&&n.push(t)}),n}function W(e,n){let t=e.childNodes;for(let o=0;o<t.length;o++){let r=t[o];n(r),W(r,n)}}function ke(e,n){let t=e.parentNode;if(t)if(w(n)){let o=document.createTextNode("");t.replaceChild(o,e),m(()=>{o.data=String(n.value??"")})}else if(typeof n=="function"){let o=document.createComment("");t.replaceChild(o,e);let r=[],a=[];m(()=>{for(let d of a)d();a=[];let i=[],s=J();b(i);let c=n();b(s),a=i;for(let d of r)d.parentNode?.removeChild(d);r=[];let l=F(c),f=o.parentNode;if(f)for(let d of l)f.insertBefore(d,o),r.push(d)})}else if(Y(n))t.replaceChild(n,e);else if(n instanceof Node)t.replaceChild(n,e);else if(Array.isArray(n)){let o=document.createDocumentFragment();for(let r of n){let a=F(r);for(let i of a)o.appendChild(i)}t.replaceChild(o,e)}else{let o=document.createTextNode(String(n??""));t.replaceChild(o,e)}}function Ce(e,n){let t=[];for(let o of Array.from(e.attributes)){let r=o.name,a=o.value;if(r.startsWith("@")){let s=r.slice(1),c=a.match(/__t4_(\d+)__/);if(c){let l=n[parseInt(c[1],10)];typeof l=="function"&&e.addEventListener(s,f=>q(()=>l(f)))}t.push(r);continue}if(r.startsWith("?")){let s=r.slice(1),c=a.match(/__t4_(\d+)__/);if(c){let l=n[parseInt(c[1],10)];if(w(l)){let f=l;m(()=>{f.value?e.setAttribute(s,""):e.removeAttribute(s)})}else typeof l=="function"?m(()=>{l()?e.setAttribute(s,""):e.removeAttribute(s)}):l&&e.setAttribute(s,"")}t.push(r);continue}if(r.startsWith(".")){let s=r.slice(1),c=a.match(/__t4_(\d+)__/);if(c){let l=n[parseInt(c[1],10)];w(l)?m(()=>{e[s]=l.value}):e[s]=l}t.push(r);continue}let i=a.match(/__t4_(\d+)__/);if(i){let s=n[parseInt(i[1],10)];if(w(s)){let c=s;m(()=>{e.setAttribute(r,String(c.value??""))})}else typeof s=="function"?m(()=>{e.setAttribute(r,String(s()??""))}):e.setAttribute(r,String(s??""))}}for(let o of t)e.removeAttribute(o)}function F(e){if(e==null||e===!1)return[];if(Y(e))return Array.from(e.childNodes);if(e instanceof Node)return[e];if(Array.isArray(e)){let n=[];for(let t of e)n.push(...F(t));return n}return[document.createTextNode(String(e))]}function Y(e){return e!=null&&typeof e=="object"&&e.nodeType===11}function Te(e){let n=!1,t=!1,o=!1;for(let r=0;r<e.length;r++){let a=e[r];a==="<"&&!n&&!t&&(o=!0),a===">"&&!n&&!t&&(o=!1),o&&(a==='"'&&!n&&(t=!t),a==="'"&&!t&&(n=!n))}return o}var Z=null,ee=null;var I=class extends HTMLElement{constructor(){super();this._props={};this._rendered=!1;let t=this.constructor;this._root=t.shadow?this.attachShadow({mode:"open"}):this;for(let[o,r]of Object.entries(t.props))this._props[o]=h(this._coerce(this.getAttribute(o),r))}static{this.props={}}static{this.styles=""}static{this.shadow=!0}static get observedAttributes(){return Object.keys(this.props)}connectedCallback(){if(this._rendered)return;this._rendered=!0;let t=this.constructor;if(t.styles&&t.shadow&&this._root instanceof ShadowRoot){let r=document.createElement("style");r.textContent=t.styles,this._root.appendChild(r)}let o=this.render();o&&this._root.appendChild(o),this.onMount(),Z&&Z(this)}disconnectedCallback(){this.onUnmount(),ee&&ee(this)}attributeChangedCallback(t,o,r){let i=this.constructor.props[t];i&&this._props[t]&&(this._props[t].value=this._coerce(r,i))}prop(t){if(!this._props[t])throw new Error(`[tina4] Prop '${t}' not declared in static props of <${this.tagName.toLowerCase()}>`);return this._props[t]}emit(t,o){this.dispatchEvent(new CustomEvent(t,{bubbles:!0,composed:!0,...o}))}onMount(){}onUnmount(){}_coerce(t,o){return o===Boolean?t!==null:o===Number?t!==null?Number(t):0:t??""}};var U=[],T=null,S="history",_e=!1,E=[],O=[],te=0;function oe(e,n){let t=[],o;e==="*"?o=".*":o=e.replace(/\{(\w+)\}/g,(a,i)=>(t.push(i),"([^/]+)"));let r=new RegExp(`^${o}$`);typeof n=="function"?U.push({pattern:e,regex:r,paramNames:t,handler:n}):U.push({pattern:e,regex:r,paramNames:t,handler:n.handler,guard:n.guard})}function L(e,n){if(S==="hash")if(n?.replace){let t=new URL(location.href);t.hash="#"+e,history.replaceState(null,"",t.toString()),R()}else location.hash="#"+e;else n?.replace?history.replaceState(null,"",e):history.pushState(null,"",e),R()}function R(){if(!T)return;let e=performance.now(),n=++te,t=S==="hash"?location.hash.slice(1)||"/":location.pathname;for(let o of U){let r=t.match(o.regex);if(!r)continue;let a={};if(o.paramNames.forEach((c,l)=>{a[c]=decodeURIComponent(r[l+1])}),o.guard){let c=o.guard();if(c===!1)return;if(typeof c=="string"){L(c,{replace:!0});return}}for(let c of O)c();O=[],T.innerHTML="";let i=[];b(i);let s=o.handler(a);if(s instanceof Promise)s.then(c=>{if(b(null),n!==te){for(let f of i)f();return}ne(T,c),O=i;let l=performance.now()-e;for(let f of E)f({path:t,params:a,pattern:o.pattern,durationMs:l})});else{b(null),ne(T,s),O=i;let c=performance.now()-e;for(let l of E)l({path:t,params:a,pattern:o.pattern,durationMs:c})}return}}function ne(e,n){n instanceof DocumentFragment||n instanceof Node?e.replaceChildren(n):typeof n=="string"?e.innerHTML=n:n!=null&&e.replaceChildren(document.createTextNode(String(n)))}var re={start(e){if(T=document.querySelector(e.target),!T)throw new Error(`[tina4] Router target '${e.target}' not found in DOM`);S=e.mode??"history",_e=!0,window.addEventListener("popstate",R),S==="hash"&&window.addEventListener("hashchange",R),document.addEventListener("click",n=>{if(n.metaKey||n.ctrlKey||n.shiftKey||n.altKey)return;let t=n.target.closest("a[href]");if(!t||t.origin!==location.origin||t.hasAttribute("target")||t.hasAttribute("download")||t.getAttribute("rel")?.includes("external"))return;n.preventDefault();let o=S==="hash"?t.getAttribute("href"):t.pathname;L(o)}),R()},on(e,n){return E.push(n),()=>{let t=E.indexOf(n);t>=0&&E.splice(t,1)}}};var y={baseUrl:"",auth:!1,tokenKey:"tina4_token",headers:{}},j=[],K=[],Se=0;function se(){try{return localStorage.getItem(y.tokenKey)}catch{return null}}function Ee(e){try{localStorage.setItem(y.tokenKey,e)}catch{}}async function x(e,n,t,o){let r={method:e,headers:{"Content-Type":"application/json",...y.headers}};if(y.auth){let d=se();d&&(r.headers.Authorization=`Bearer ${d}`)}if(t!==void 0&&e!=="GET"){let d=typeof t=="object"&&t!==null?{...t}:t;if(y.auth&&typeof d=="object"&&d!==null){let p=se();p&&(d.formToken=p)}r.body=JSON.stringify(d)}if(o?.headers&&Object.assign(r.headers,o.headers),o?.params){let d=Object.entries(o.params).map(([p,v])=>`${encodeURIComponent(p)}=${encodeURIComponent(String(v))}`).join("&");n+=(n.includes("?")?"&":"?")+d}let a=y.baseUrl+n;r._url=a,r._requestId=++Se;for(let d of j){let p=d(r);p&&(r=p)}let i=await fetch(a,r),s=i.headers.get("FreshToken");s&&Ee(s);let c=i.headers.get("Content-Type")??"",l;c.includes("json")?l=await i.json():l=await i.text();let f={status:i.status,data:l,ok:i.ok,headers:i.headers,_requestId:r._requestId};for(let d of K){let p=d(f);p&&(f=p)}if(!i.ok)throw f;return f.data}var ie={configure(e){Object.assign(y,e)},get(e,n){return x("GET",e,void 0,n)},post(e,n,t){return x("POST",e,n,t)},put(e,n,t){return x("PUT",e,n,t)},patch(e,n,t){return x("PATCH",e,n,t)},delete(e,n){return x("DELETE",e,void 0,n)},intercept(e,n){e==="request"?j.push(n):K.push(n)},_reset(){y.baseUrl="",y.auth=!1,y.tokenKey="tina4_token",y.headers={},j.length=0,K.length=0}};function ae(e){let n=e.cacheStrategy??"network-first",t=JSON.stringify(e.precache??[]),o=e.offlineRoute?`'${e.offlineRoute}'`:"null";return`
2
+ const CACHE = 'tina4-v1';
3
+ const PRECACHE = ${t};
4
+ const OFFLINE = ${o};
5
+
6
+ self.addEventListener('install', (e) => {
7
+ e.waitUntil(
8
+ caches.open(CACHE).then((c) => c.addAll(PRECACHE)).then(() => self.skipWaiting())
9
+ );
10
+ });
11
+
12
+ self.addEventListener('activate', (e) => {
13
+ e.waitUntil(self.clients.claim());
14
+ });
15
+
16
+ self.addEventListener('fetch', (e) => {
17
+ const req = e.request;
18
+ if (req.method !== 'GET') return;
19
+
20
+ ${n==="cache-first"?`
21
+ e.respondWith(
22
+ caches.match(req).then((cached) => cached || fetch(req).then((res) => {
23
+ const clone = res.clone();
24
+ caches.open(CACHE).then((c) => c.put(req, clone));
25
+ return res;
26
+ })).catch(() => OFFLINE ? caches.match(OFFLINE) : new Response('Offline', { status: 503 }))
27
+ );`:n==="stale-while-revalidate"?`
28
+ e.respondWith(
29
+ caches.match(req).then((cached) => {
30
+ const fetched = fetch(req).then((res) => {
31
+ caches.open(CACHE).then((c) => c.put(req, res.clone()));
32
+ return res;
33
+ });
34
+ return cached || fetched;
35
+ }).catch(() => OFFLINE ? caches.match(OFFLINE) : new Response('Offline', { status: 503 }))
36
+ );`:`
37
+ e.respondWith(
38
+ fetch(req).then((res) => {
39
+ const clone = res.clone();
40
+ caches.open(CACHE).then((c) => c.put(req, clone));
41
+ return res;
42
+ }).catch(() => caches.match(req).then((cached) =>
43
+ cached || (OFFLINE ? caches.match(OFFLINE) : new Response('Offline', { status: 503 }))
44
+ ))
45
+ );`}
46
+ });
47
+ `.trim()}function ce(e){let n={name:e.name,short_name:e.shortName??e.name,start_url:"/",display:e.display??"standalone",background_color:e.backgroundColor??"#ffffff",theme_color:e.themeColor??"#000000"};return e.icon&&(n.icons=[{src:e.icon,sizes:"192x192",type:"image/png"},{src:e.icon,sizes:"512x512",type:"image/png"}]),n}var le={register(e){let n=ce(e),t=new Blob([JSON.stringify(n)],{type:"application/json"}),o=document.createElement("link");o.rel="manifest",o.href=URL.createObjectURL(t),document.head.appendChild(o);let r=document.querySelector('meta[name="theme-color"]');if(r||(r=document.createElement("meta"),r.name="theme-color",document.head.appendChild(r)),r.content=e.themeColor??"#000000","serviceWorker"in navigator){let a=ae(e),i=new Blob([a],{type:"text/javascript"}),s=URL.createObjectURL(i);navigator.serviceWorker.register(s).catch(c=>{console.warn("[tina4] Service worker registration failed:",c)})}},generateServiceWorker(e){return ae(e)},generateManifest(e){return ce(e)}};var Re={reconnect:!0,reconnectDelay:1e3,reconnectMaxDelay:3e4,reconnectAttempts:1/0,protocols:[]};function xe(e,n={}){let t={...Re,...n},o=h("connecting"),r=h(!1),a=h(null),i=h(null),s=h(0),c={message:[],open:[],close:[],error:[]},l=null,f=!1,d=t.reconnectDelay,p=null,v=0;function de(u){if(typeof u!="string")return u;try{return JSON.parse(u)}catch{return u}}function $(){o.value=v>0?"reconnecting":"connecting";try{l=new WebSocket(e,t.protocols)}catch{o.value="closed",r.value=!1;return}l.onopen=()=>{o.value="open",r.value=!0,i.value=null,v=0,d=t.reconnectDelay,s.value=0;for(let u of c.open)u()},l.onmessage=u=>{let g=de(u.data);a.value=g;for(let k of c.message)k(g)},l.onclose=u=>{o.value="closed",r.value=!1;for(let g of c.close)g(u.code,u.reason);!f&&t.reconnect&&v<t.reconnectAttempts&&fe()},l.onerror=u=>{i.value=u;for(let g of c.error)g(u)}}function fe(){v++,s.value=v,o.value="reconnecting",p=setTimeout(()=>{p=null,$()},d),d=Math.min(d*2,t.reconnectMaxDelay)}let G={status:o,connected:r,lastMessage:a,error:i,reconnectCount:s,send(u){if(!l||l.readyState!==WebSocket.OPEN)throw new Error("[tina4] WebSocket is not connected");let g=typeof u=="string"?u:JSON.stringify(u);l.send(g)},on(u,g){return c[u].push(g),()=>{let k=c[u],A=k.indexOf(g);A>=0&&k.splice(A,1)}},pipe(u,g){let k=A=>{u.value=g(A,u.value)};return G.on("message",k)},close(u,g){f=!0,p&&(clearTimeout(p),p=null),l&&l.close(u??1e3,g??""),o.value="closed",r.value=!1}};return $(),G}var ue={connect:xe};return ve(Ae);})();
@@ -3,10 +3,10 @@
3
3
  *
4
4
  * Uses only Node.js built-in `crypto` module. No external dependencies.
5
5
  *
6
- * import { createToken, validateToken, hashPassword, checkPassword } from "./auth.js";
6
+ * import { getToken, validToken, hashPassword, checkPassword } from "./auth.js";
7
7
  *
8
- * const token = createToken({ userId: 1 }, "my-secret");
9
- * const payload = validateToken(token, "my-secret");
8
+ * const token = getToken({ userId: 1 }, "my-secret");
9
+ * const payload = validToken(token, "my-secret");
10
10
  *
11
11
  * const hash = hashPassword("secret123");
12
12
  * checkPassword("secret123", hash); // true
@@ -38,7 +38,7 @@ function base64urlDecode(str: string): Buffer {
38
38
  * @param algorithm - "HS256" or "RS256" (default "HS256")
39
39
  * @returns Signed JWT string: header.payload.signature
40
40
  */
41
- export function createToken(
41
+ export function getToken(
42
42
  payload: Record<string, unknown>,
43
43
  secret: string,
44
44
  expiresIn: number = 3600,
@@ -63,7 +63,7 @@ export function createToken(
63
63
  /**
64
64
  * Validate a JWT token and return the decoded payload, or null if invalid/expired.
65
65
  */
66
- export function validateToken(
66
+ export function validToken(
67
67
  token: string,
68
68
  secret: string,
69
69
  algorithm: string = "HS256",
@@ -199,7 +199,7 @@ export function authMiddleware(secret: string, algorithm: string = "HS256"): Mid
199
199
  }
200
200
 
201
201
  const token = authHeader.slice(7);
202
- const payload = validateToken(token, secret, algorithm);
202
+ const payload = validToken(token, secret, algorithm);
203
203
 
204
204
  if (payload === null) {
205
205
  res({ error: "Unauthorized" }, 401);
@@ -229,12 +229,12 @@ export function refreshToken(
229
229
  expiresIn: number = 3600,
230
230
  algorithm: string = "HS256",
231
231
  ): string | null {
232
- const payload = validateToken(token, secret, algorithm);
232
+ const payload = validToken(token, secret, algorithm);
233
233
  if (payload === null) return null;
234
234
 
235
- // Strip standard timing claims so createToken sets fresh ones
235
+ // Strip standard timing claims so getToken sets fresh ones
236
236
  const { iat: _iat, exp: _exp, ...claims } = payload;
237
- return createToken(claims, secret, expiresIn, algorithm);
237
+ return getToken(claims, secret, expiresIn, algorithm);
238
238
  }
239
239
 
240
240
  // ── Request Authentication ───────────────────────────────────────
@@ -258,9 +258,17 @@ export function authenticateRequest(
258
258
  if (!authHeader.startsWith("Bearer ")) return null;
259
259
 
260
260
  const token = authHeader.slice(7);
261
- return validateToken(token, secret, algorithm);
261
+ return validToken(token, secret, algorithm);
262
262
  }
263
263
 
264
+ // ── Backward-Compatible Aliases ──────────────────────────────────
265
+
266
+ /** Alias for getToken() — kept for backward compatibility. */
267
+ export const createToken = getToken;
268
+
269
+ /** Alias for validToken() — kept for backward compatibility. */
270
+ export const validateToken = validToken;
271
+
264
272
  // ── API Key Validation ───────────────────────────────────────────
265
273
 
266
274
  /**
@@ -285,3 +293,29 @@ export function validateApiKey(
285
293
  if (a.length !== b.length) return false;
286
294
  return timingSafeEqual(a, b);
287
295
  }
296
+
297
+ // ── Auth Class Wrapper ──────────────────────────────────────────
298
+
299
+ /**
300
+ * Auth class that wraps the standalone auth functions so both patterns work:
301
+ *
302
+ * import { Auth } from "tina4-nodejs";
303
+ * const token = Auth.getToken(payload, secret);
304
+ *
305
+ * import { getToken } from "tina4-nodejs";
306
+ * const token = getToken(payload, secret);
307
+ */
308
+ export class Auth {
309
+ static getToken = getToken;
310
+ static validToken = validToken;
311
+ static getPayload = getPayload;
312
+ static hashPassword = hashPassword;
313
+ static checkPassword = checkPassword;
314
+ static authMiddleware = authMiddleware;
315
+ static refreshToken = refreshToken;
316
+ static authenticateRequest = authenticateRequest;
317
+ static validateApiKey = validateApiKey;
318
+ // Legacy aliases
319
+ static createToken = getToken;
320
+ static validateToken = validToken;
321
+ }
@@ -862,31 +862,29 @@ const handleConnectionsTest: RouteHandler = async (req, res) => {
862
862
  let version = "Connected";
863
863
  let tableCount = 0;
864
864
  try {
865
- if (db.tables) {
866
- const tables = await db.tables();
867
- tableCount = Array.isArray(tables) ? tables.length : 0;
868
- }
865
+ const tables = db.getTables();
866
+ tableCount = Array.isArray(tables) ? tables.length : 0;
869
867
  } catch { tableCount = 0; }
870
868
  try {
871
869
  const urlLower = url.toLowerCase();
872
870
  if (urlLower.includes("sqlite")) {
873
- const row = await db.execute("SELECT sqlite_version() as v");
874
- version = `SQLite ${row?.[0]?.v ?? ""}`;
871
+ const row = db.execute("SELECT sqlite_version() as v") as Record<string, unknown>[] | undefined;
872
+ version = `SQLite ${(row as any)?.[0]?.v ?? ""}`;
875
873
  } else if (urlLower.includes("postgres")) {
876
- const row = await db.execute("SELECT version() as v");
877
- version = (row?.[0]?.v ?? "PostgreSQL").toString().split(",")[0];
874
+ const row = db.execute("SELECT version() as v") as Record<string, unknown>[] | undefined;
875
+ version = ((row as any)?.[0]?.v ?? "PostgreSQL").toString().split(",")[0];
878
876
  } else if (urlLower.includes("mysql")) {
879
- const row = await db.execute("SELECT version() as v");
880
- version = `MySQL ${row?.[0]?.v ?? ""}`;
877
+ const row = db.execute("SELECT version() as v") as Record<string, unknown>[] | undefined;
878
+ version = `MySQL ${(row as any)?.[0]?.v ?? ""}`;
881
879
  } else if (urlLower.includes("mssql")) {
882
- const row = await db.execute("SELECT @@VERSION as v");
883
- version = (row?.[0]?.v ?? "MSSQL").toString().split("\n")[0];
880
+ const row = db.execute("SELECT @@VERSION as v") as Record<string, unknown>[] | undefined;
881
+ version = ((row as any)?.[0]?.v ?? "MSSQL").toString().split("\n")[0];
884
882
  } else if (urlLower.includes("firebird")) {
885
- const row = await db.execute("SELECT rdb$get_context('SYSTEM', 'ENGINE_VERSION') as v FROM rdb$database");
886
- version = `Firebird ${row?.[0]?.v ?? ""}`;
883
+ const row = db.execute("SELECT rdb$get_context('SYSTEM', 'ENGINE_VERSION') as v FROM rdb$database") as Record<string, unknown>[] | undefined;
884
+ version = `Firebird ${(row as any)?.[0]?.v ?? ""}`;
887
885
  }
888
886
  } catch { /* keep version as Connected */ }
889
- if (db.close) await db.close();
887
+ db.close();
890
888
  res.json({ success: true, version, tables: tableCount });
891
889
  } catch (e: unknown) {
892
890
  const msg = e instanceof Error ? e.message : String(e);
@@ -1085,7 +1083,7 @@ function renderDevAdminJs(): string {
1085
1083
  " document.getElementById('routes-body').innerHTML = d.routes.map(function(r) {",
1086
1084
  " return '<tr>' +",
1087
1085
  " '<td><span class=\"method method-' + r.method.toLowerCase() + '\">' + r.method + '</span></td>' +",
1088
- " '<td class=\"path\">' + (r.path || r.pattern || '') + '</td>' +",
1086
+ " '<td class=\"path\"><a href=\"' + (r.path || r.pattern || '') + '\" target=\"_blank\" title=\"' + (r.method !== 'GET' ? r.method + ' route \\u2014 may not respond to browser GET' : 'Open in new tab') + '\" style=\"color:inherit;text-decoration:underline dotted;' + (r.method !== 'GET' ? 'opacity:0.7' : '') + '\">' + (r.path || r.pattern || '') + '</a></td>' +",
1089
1087
  " '<td>' + (r.auth_required || r.secure ? '<span class=\"badge-pill bg-reserved\">auth</span>' : '<span class=\"badge-pill bg-success\">open</span>') + '</td>' +",
1090
1088
  " '<td class=\"text-sm text-muted\">' + (r.handler || '') + (r.module ? ' <small>(' + r.module + ')</small>' : '') + '</td>' +",
1091
1089
  " '</tr>';",
@@ -13,11 +13,11 @@ export type {
13
13
  } from "./types.js";
14
14
 
15
15
  export { startServer, resolvePortAndHost } from "./server.js";
16
- export { Router, RouteGroup, defaultRouter, runRouteMiddlewares } from "./router.js";
16
+ export { Router, RouteGroup, RouteRef, defaultRouter, runRouteMiddlewares } from "./router.js";
17
17
  export { get, post, put, patch, del, any, websocket, del as delete } from "./router.js";
18
18
  export type { RouteInfo } from "./router.js";
19
19
  export { discoverRoutes } from "./routeDiscovery.js";
20
- export { MiddlewareChain, cors, requestLogger } from "./middleware.js";
20
+ export { MiddlewareChain, MiddlewareRunner, cors, requestLogger, CorsMiddleware, RateLimiterMiddleware, RequestLogger } from "./middleware.js";
21
21
  export type { CorsConfig } from "./middleware.js";
22
22
  export { createRequest, parseBody } from "./request.js";
23
23
  export { createResponse } from "./response.js";
@@ -38,10 +38,11 @@ export {
38
38
  APPLICATION_OCTET, TEXT_HTML, TEXT_PLAIN, TEXT_CSV, TEXT_XML,
39
39
  } from "./constants.js";
40
40
  export {
41
- createToken, validateToken, getPayload,
41
+ getToken, validToken, createToken, validateToken, getPayload,
42
42
  hashPassword, checkPassword,
43
43
  authMiddleware,
44
44
  refreshToken, authenticateRequest, validateApiKey,
45
+ Auth,
45
46
  } from "./auth.js";
46
47
  export { Session, FileSessionHandler, RedisSessionHandler } from "./session.js";
47
48
  export type { SessionConfig, SessionHandler } from "./session.js";
@@ -82,10 +83,16 @@ export { RabbitMQBackend } from "./queueBackends/rabbitmqBackend.js";
82
83
  export type { RabbitMQConfig } from "./queueBackends/rabbitmqBackend.js";
83
84
  export { KafkaBackend } from "./queueBackends/kafkaBackend.js";
84
85
  export type { KafkaConfig } from "./queueBackends/kafkaBackend.js";
86
+ export { MongoBackend } from "./queueBackends/mongoBackend.js";
87
+ export type { MongoConfig as MongoQueueConfig } from "./queueBackends/mongoBackend.js";
88
+ export { DatabaseSessionHandler } from "./sessionHandlers/databaseHandler.js";
89
+ export type { DatabaseSessionConfig } from "./sessionHandlers/databaseHandler.js";
85
90
  export { MongoSessionHandler } from "./sessionHandlers/mongoHandler.js";
86
91
  export type { MongoSessionConfig } from "./sessionHandlers/mongoHandler.js";
87
92
  export { ValkeySessionHandler } from "./sessionHandlers/valkeyHandler.js";
88
93
  export type { ValkeySessionConfig } from "./sessionHandlers/valkeyHandler.js";
94
+ export { RedisNpmSessionHandler } from "./sessionHandlers/redisHandler.js";
95
+ export type { RedisNpmSessionConfig } from "./sessionHandlers/redisHandler.js";
89
96
  export { tests, assertEqual, assertThrows, assertTrue, assertFalse, runAllTests, resetTests } from "./testing.js";
90
97
  export { Container, container } from "./container.js";
91
98
  export type { WebSocketConnection } from "./websocketConnection.js";