tina4-nodejs 3.9.1 → 3.9.3

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.8.0)
1
+ # CLAUDE.md — AI Developer Guide for tina4-nodejs (v3.9.2)
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.8.0 — a convention-over-configuration structural paradigm. **Not a framework.** The developer writes TypeScript; Tina4 is invisible infrastructure.
7
+ Tina4 for Node.js/TypeScript v3.9.2 — a convention-over-configuration structural paradigm. **Not a framework.** 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
 
@@ -38,12 +38,12 @@ tina4-nodejs/
38
38
  service.ts # Service layer helpers
39
39
  session.ts # Session management
40
40
  testing.ts # Inline testing framework (attach tests to functions)
41
- websocket.ts # WebSocket support
41
+ websocket.ts # WebSocket support (with backplane)
42
42
  wsdl.ts # WSDL / SOAP support
43
43
  orm/ # Database adapters, models, auto-CRUD, query builder, seeding
44
44
  src/
45
45
  adapters/
46
- sqlite.ts # SQLite via better-sqlite3 (default)
46
+ sqlite.ts # SQLite via node:sqlite (default)
47
47
  postgres.ts # PostgreSQL adapter
48
48
  mysql.ts # MySQL adapter
49
49
  mssql.ts # MSSQL / SQL Server adapter
@@ -69,7 +69,7 @@ This is an **npm workspaces monorepo**. All packages are in `packages/*`.
69
69
  - **Language:** TypeScript (strict mode, ES2022 target, Node16 module resolution)
70
70
  - **Runtime:** Node.js 20+ (ESM only, `"type": "module"` everywhere)
71
71
  - **HTTP:** Native `node:http` — no Express, no Fastify
72
- - **Database:** SQLite via `better-sqlite3` (default), with adapters for Postgres, MySQL, MSSQL/SQL Server, and Firebird
72
+ - **Database:** SQLite via `node:sqlite` (default), with adapters for Postgres, MySQL, MSSQL/SQL Server, and Firebird
73
73
  - **Templates:** Twig via `twig` npm package (optional)
74
74
  - **Dev tooling:** `tsx` for runtime TS execution, `esbuild` for builds
75
75
  - **Testing:** 43 test files via `tsx test/run-all.ts`
@@ -120,8 +120,8 @@ The HTTP foundation. Handles request/response lifecycle, route matching, middlew
120
120
  - `devAdmin.ts` — Dev toolbar (fixed bottom bar injected into HTML pages) and admin dashboard at `/_dev/`
121
121
  - `auth.ts` — Authentication helpers
122
122
  - `cache.ts` — In-memory caching
123
- - `session.ts` — Session management with pluggable handlers
124
- - `websocket.ts` — WebSocket support
123
+ - `session.ts` — Session management with pluggable handlers. `TINA4_SESSION_SAMESITE` env var (default: Lax)
124
+ - `websocket.ts` — WebSocket support with backplane for scaling via Redis pub/sub (`TINA4_WS_BACKPLANE`, `TINA4_WS_BACKPLANE_URL`)
125
125
  - `queue.ts` — Queue system with pluggable backends
126
126
  - `graphql.ts` — GraphQL engine
127
127
  - `i18n.ts` — Internationalization / localization
@@ -139,7 +139,7 @@ Database layer with auto-CRUD generation, seeding, fake data, and SQL translatio
139
139
 
140
140
  **Key files:**
141
141
  - `database.ts` — Adapter manager, `initDatabase()` factory
142
- - `adapters/sqlite.ts` — `better-sqlite3` implementation of `DatabaseAdapter` interface
142
+ - `adapters/sqlite.ts` — `node:sqlite` implementation of `DatabaseAdapter` interface
143
143
  - `adapters/postgres.ts` — PostgreSQL adapter
144
144
  - `adapters/mysql.ts` — MySQL adapter
145
145
  - `adapters/mssql.ts` — MSSQL / SQL Server adapter (`mssql` or `sqlserver` scheme)
@@ -153,6 +153,7 @@ Database layer with auto-CRUD generation, seeding, fake data, and SQL translatio
153
153
  - `fakeData.ts` — ORM-aware fake data extending core (adds `forField()` with column-name heuristics)
154
154
  - `seeder.ts` — Database seeding (`seedTable` for raw SQL, `seedOrm` for model-based)
155
155
  - `sqlTranslation.ts` — Cross-engine SQL translator (`SQLTranslator`) and TTL query cache (`QueryCache`)
156
+ - QueryBuilder supports `toMongo()` for generating MongoDB query documents from the same fluent API
156
157
 
157
158
  ### @tina4/swagger (`packages/swagger/`)
158
159
  Auto-generates OpenAPI 3.0 docs.
@@ -173,7 +174,7 @@ Developer-facing CLI commands.
173
174
 
174
175
  **Key files:**
175
176
  - `bin.ts` — Entry point, command dispatch (`init`, `serve`, `--help`)
176
- - `commands/init.ts` — Scaffolds a new project directory with sample files
177
+ - `commands/init.ts` — Scaffolds a new project directory with sample files, Dockerfile, and .dockerignore
177
178
  - `commands/serve.ts` — Starts dev server with hot-reload via `@tina4/core`
178
179
 
179
180
  ## Module: Events (`packages/core/src/events.ts`)
@@ -469,7 +470,7 @@ import { Router } from "./router.js"; // .js even though the file is .ts
469
470
  3. **Convention-based models** — `static fields = {}` over decorators. No special TypeScript config needed.
470
471
  4. **CDN for Swagger UI** — Keeps install under 8MB. Single HTML file loads from unpkg.com.
471
472
  5. **Process restart for hot-reload** — Simpler and more reliable than HMR with ESM.
472
- 6. **SQLite default** — `better-sqlite3` is synchronous and fast. Full adapters for Postgres, MySQL, MSSQL/SQL Server, and Firebird.
473
+ 6. **SQLite default** — `node:sqlite` is synchronous and fast. Full adapters for Postgres, MySQL, MSSQL/SQL Server, and Firebird.
473
474
  7. **CLI named `tina4nodejs`** (primary) with `tina4` as alias — So `npx tina4nodejs init` or `npx tina4 init` both work.
474
475
  8. **Event system** — Static `Events` class, synchronous dispatch, priority ordering, zero deps.
475
476
  9. **Inline testing** — Tests as decorators on functions, no external test runner for unit-level checks.
@@ -531,7 +532,7 @@ await initDatabase({ type: "postgres", host: "localhost", port: 5432, database:
531
532
  ### Available adapters
532
533
  | Adapter | Scheme(s) | Package |
533
534
  |---------|-----------|---------|
534
- | SQLite | `sqlite://` | `better-sqlite3` |
535
+ | SQLite | `sqlite://` | `node:sqlite` |
535
536
  | PostgreSQL | `postgres://`, `postgresql://` | `pg` |
536
537
  | MySQL | `mysql://` | `mysql2` |
537
538
  | MSSQL | `mssql://`, `sqlserver://` | `tedious` |
@@ -586,12 +587,16 @@ When adding new features, add a corresponding `test/<feature>.test.ts` file.
586
587
  - **Production server auto-detect**: `npx tina4nodejs serve --production` auto-uses cluster mode
587
588
  - **`npx tina4nodejs generate`**: model, route, migration, middleware scaffolding
588
589
  - **Database**: 5 engines (SQLite, PostgreSQL, MySQL, MSSQL, Firebird), query caching (`TINA4_DB_CACHE=true`)
589
- - **Sessions**: file backend (default)
590
- - **Queue**: SQLite/RabbitMQ/Kafka/MongoDB backends, configured via env vars
590
+ - **Sessions**: file backend (default). `TINA4_SESSION_SAMESITE` env var (default: Lax)
591
+ - **Queue**: file/RabbitMQ/Kafka/MongoDB backends, configured via env vars
591
592
  - **Cache**: memory/Redis/file backends
592
593
  - **Messenger**: .env driven SMTP/IMAP
593
594
  - **ORM relationships**: `hasMany`, `hasOne`, `belongsTo` with eager loading (`include`)
594
595
  - **Frond pre-compilation**: 2.8x template render improvement
596
+ - **QueryBuilder** with NoSQL/MongoDB support (`toMongo()`)
597
+ - **WebSocket backplane** (Redis pub/sub) for horizontal scaling
598
+ - **SameSite=Lax** default on session cookies (`TINA4_SESSION_SAMESITE`)
599
+ - **`tina4 init`** generates Dockerfile and .dockerignore
595
600
  - **Gallery**: 7 interactive examples with Try It deploy at `/_dev/`
596
601
 
597
602
  ## Don'ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tina4-nodejs",
3
- "version": "3.9.1",
3
+ "version": "3.9.3",
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"],
@@ -585,7 +585,8 @@ ${reset}
585
585
  const newSid = (sess as any).sessionId ?? (sess as any).getSessionId?.();
586
586
  if (newSid && newSid !== existingSid && !rawRes.headersSent) {
587
587
  const ttl = parseInt(process.env.TINA4_SESSION_TTL ?? "3600", 10);
588
- rawRes.setHeader("Set-Cookie", `tina4_session=${newSid}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${ttl}`);
588
+ const sameSite = process.env.TINA4_SESSION_SAMESITE ?? "Lax";
589
+ rawRes.setHeader("Set-Cookie", `tina4_session=${newSid}; Path=/; HttpOnly; SameSite=${sameSite}; Max-Age=${ttl}`);
589
590
  }
590
591
  return origEnd(...args);
591
592
  } as typeof rawRes.end;
@@ -0,0 +1,120 @@
1
+ /**
2
+ * WebSocket Backplane Abstraction for Tina4 Node.js.
3
+ *
4
+ * Enables broadcasting WebSocket messages across multiple server instances
5
+ * using a shared pub/sub channel (e.g. Redis). Without a backplane configured,
6
+ * broadcast() only reaches connections on the local process.
7
+ *
8
+ * Configuration via environment variables:
9
+ * TINA4_WS_BACKPLANE — Backend type: "redis", "nats", or "" (default: none)
10
+ * TINA4_WS_BACKPLANE_URL — Connection string (default: redis://localhost:6379)
11
+ *
12
+ * Usage:
13
+ * const backplane = createBackplane();
14
+ * if (backplane) {
15
+ * backplane.subscribe("chat", (msg) => relayToLocal(msg));
16
+ * backplane.publish("chat", '{"user":"A","text":"hello"}');
17
+ * }
18
+ */
19
+
20
+ /**
21
+ * Base interface for scaling WebSocket broadcast across instances.
22
+ *
23
+ * Implementations relay messages over a shared bus so every server instance
24
+ * receives every broadcast, not just the originator.
25
+ */
26
+ export interface WebSocketBackplane {
27
+ /** Publish a message to all instances listening on `channel`. */
28
+ publish(channel: string, message: string): Promise<void>;
29
+
30
+ /** Subscribe to `channel`. `callback` is invoked with each incoming message. */
31
+ subscribe(channel: string, callback: (message: string) => void): Promise<void>;
32
+
33
+ /** Stop listening on `channel`. */
34
+ unsubscribe(channel: string): Promise<void>;
35
+
36
+ /** Tear down connections. */
37
+ close(): Promise<void>;
38
+ }
39
+
40
+ /**
41
+ * Redis pub/sub backplane.
42
+ *
43
+ * Requires the `redis` package (`npm install redis`). The import is deferred
44
+ * so the rest of Tina4 works fine without it installed — an error is thrown
45
+ * only when this class is actually instantiated.
46
+ */
47
+ export class RedisBackplane implements WebSocketBackplane {
48
+ private publisher: any;
49
+ private subscriber: any;
50
+ private url: string;
51
+ private ready: Promise<void>;
52
+
53
+ constructor(url?: string) {
54
+ this.url = url ?? process.env.TINA4_WS_BACKPLANE_URL ?? "redis://localhost:6379";
55
+
56
+ let redis: any;
57
+ try {
58
+ redis = require("redis");
59
+ } catch {
60
+ throw new Error(
61
+ "The 'redis' package is required for RedisBackplane. " +
62
+ "Install it with: npm install redis"
63
+ );
64
+ }
65
+
66
+ this.publisher = redis.createClient({ url: this.url });
67
+ this.subscriber = this.publisher.duplicate();
68
+
69
+ this.ready = Promise.all([
70
+ this.publisher.connect(),
71
+ this.subscriber.connect(),
72
+ ]).then(() => {
73
+ console.log(`[Tina4] RedisBackplane connected to ${this.url}`);
74
+ });
75
+ }
76
+
77
+ async publish(channel: string, message: string): Promise<void> {
78
+ await this.ready;
79
+ await this.publisher.publish(channel, message);
80
+ }
81
+
82
+ async subscribe(channel: string, callback: (message: string) => void): Promise<void> {
83
+ await this.ready;
84
+ await this.subscriber.subscribe(channel, (message: string) => {
85
+ callback(message);
86
+ });
87
+ }
88
+
89
+ async unsubscribe(channel: string): Promise<void> {
90
+ await this.ready;
91
+ await this.subscriber.unsubscribe(channel);
92
+ }
93
+
94
+ async close(): Promise<void> {
95
+ await this.publisher.quit();
96
+ await this.subscriber.quit();
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Factory that reads TINA4_WS_BACKPLANE and returns the appropriate
102
+ * backplane instance, or `null` if no backplane is configured.
103
+ *
104
+ * This keeps backplane usage entirely optional — callers simply check
105
+ * `if (backplane)` before publishing.
106
+ */
107
+ export function createBackplane(url?: string): WebSocketBackplane | null {
108
+ const backend = (process.env.TINA4_WS_BACKPLANE ?? "").trim().toLowerCase();
109
+
110
+ switch (backend) {
111
+ case "redis":
112
+ return new RedisBackplane(url);
113
+ case "nats":
114
+ throw new Error("NATS backplane is on the roadmap but not yet implemented.");
115
+ case "":
116
+ return null;
117
+ default:
118
+ throw new Error(`Unknown TINA4_WS_BACKPLANE value: '${backend}'`);
119
+ }
120
+ }
@@ -268,6 +268,198 @@ export class QueryBuilder {
268
268
  return this.count() > 0;
269
269
  }
270
270
 
271
+ /**
272
+ * Convert the fluent builder state into a MongoDB-compatible query document.
273
+ *
274
+ * @returns An object with filter, projection, sort, limit, skip (only non-empty keys).
275
+ */
276
+ toMongo(): {
277
+ filter?: Record<string, unknown>;
278
+ projection?: Record<string, number>;
279
+ sort?: Record<string, 1 | -1>;
280
+ limit?: number;
281
+ skip?: number;
282
+ } {
283
+ const result: Record<string, unknown> = {};
284
+
285
+ // -- projection --
286
+ if (
287
+ this.columns.length !== 1 ||
288
+ this.columns[0] !== "*"
289
+ ) {
290
+ const projection: Record<string, number> = {};
291
+ for (const col of this.columns) {
292
+ projection[col.trim()] = 1;
293
+ }
294
+ result.projection = projection;
295
+ }
296
+
297
+ // -- filter --
298
+ if (this.wheres.length > 0) {
299
+ let paramIndex = 0;
300
+ const andConditions: Record<string, unknown>[] = [];
301
+ const orConditions: Record<string, unknown>[] = [];
302
+
303
+ for (let i = 0; i < this.wheres.length; i++) {
304
+ const [connector, condition] = this.wheres[i];
305
+ const [mongoCond, newIndex] = this.parseConditionToMongo(
306
+ condition,
307
+ paramIndex,
308
+ );
309
+ paramIndex = newIndex;
310
+ if (i === 0 || connector === "AND") {
311
+ andConditions.push(mongoCond);
312
+ } else {
313
+ orConditions.push(mongoCond);
314
+ }
315
+ }
316
+
317
+ if (orConditions.length > 0) {
318
+ const andMerged = this.mergeMongoConditions(andConditions);
319
+ const allBranches = [andMerged, ...orConditions];
320
+ result.filter = { $or: allBranches };
321
+ } else {
322
+ result.filter = this.mergeMongoConditions(andConditions);
323
+ }
324
+ }
325
+
326
+ // -- sort --
327
+ if (this.orderByCols.length > 0) {
328
+ const sort: Record<string, 1 | -1> = {};
329
+ for (const expr of this.orderByCols) {
330
+ const parts = expr.trim().split(/\s+/);
331
+ const field = parts[0];
332
+ const direction: 1 | -1 =
333
+ parts.length > 1 && parts[1].toUpperCase() === "DESC" ? -1 : 1;
334
+ sort[field] = direction;
335
+ }
336
+ result.sort = sort;
337
+ }
338
+
339
+ // -- limit / skip --
340
+ if (this.limitVal !== undefined) {
341
+ result.limit = this.limitVal;
342
+ }
343
+ if (this.offsetVal !== undefined) {
344
+ result.skip = this.offsetVal;
345
+ }
346
+
347
+ return result as {
348
+ filter?: Record<string, unknown>;
349
+ projection?: Record<string, number>;
350
+ sort?: Record<string, 1 | -1>;
351
+ limit?: number;
352
+ skip?: number;
353
+ };
354
+ }
355
+
356
+ /**
357
+ * Parse a single SQL condition string into a MongoDB filter object.
358
+ */
359
+ private parseConditionToMongo(
360
+ condition: string,
361
+ paramIndex: number,
362
+ ): [Record<string, unknown>, number] {
363
+ const cond = condition.trim();
364
+
365
+ // IS NOT NULL
366
+ let match = cond.match(/^(\w+)\s+IS\s+NOT\s+NULL$/i);
367
+ if (match) {
368
+ return [{ [match[1]]: { $exists: true, $ne: null } }, paramIndex];
369
+ }
370
+
371
+ // IS NULL
372
+ match = cond.match(/^(\w+)\s+IS\s+NULL$/i);
373
+ if (match) {
374
+ return [{ [match[1]]: { $exists: false } }, paramIndex];
375
+ }
376
+
377
+ // NOT IN
378
+ match = cond.match(/^(\w+)\s+NOT\s+IN\s*\(\s*\?\s*\)$/i);
379
+ if (match) {
380
+ const val = this.params[paramIndex] ?? [];
381
+ const values = Array.isArray(val) ? val : [val];
382
+ return [{ [match[1]]: { $nin: values } }, paramIndex + 1];
383
+ }
384
+
385
+ // IN
386
+ match = cond.match(/^(\w+)\s+IN\s*\(\s*\?\s*\)$/i);
387
+ if (match) {
388
+ const val = this.params[paramIndex] ?? [];
389
+ const values = Array.isArray(val) ? val : [val];
390
+ return [{ [match[1]]: { $in: values } }, paramIndex + 1];
391
+ }
392
+
393
+ // LIKE
394
+ match = cond.match(/^(\w+)\s+LIKE\s+\?$/i);
395
+ if (match) {
396
+ const val = String(this.params[paramIndex] ?? "");
397
+ const pattern = val.replace(/%/g, ".*").replace(/_/g, ".");
398
+ return [
399
+ { [match[1]]: { $regex: pattern, $options: "i" } },
400
+ paramIndex + 1,
401
+ ];
402
+ }
403
+
404
+ // Comparison operators: >=, <=, <>, !=, >, <, =
405
+ match = cond.match(/^(\w+)\s*(>=|<=|<>|!=|>|<|=)\s*\?$/);
406
+ if (match) {
407
+ const field = match[1];
408
+ const op = match[2];
409
+ const val = this.params[paramIndex] ?? null;
410
+
411
+ const opMap: Record<string, string | null> = {
412
+ "=": null,
413
+ "!=": "$ne",
414
+ "<>": "$ne",
415
+ ">": "$gt",
416
+ ">=": "$gte",
417
+ "<": "$lt",
418
+ "<=": "$lte",
419
+ };
420
+
421
+ const mongoOp = opMap[op];
422
+ if (mongoOp === null || mongoOp === undefined) {
423
+ return [{ [field]: val }, paramIndex + 1];
424
+ }
425
+ return [{ [field]: { [mongoOp]: val } }, paramIndex + 1];
426
+ }
427
+
428
+ // Fallback
429
+ return [{ $where: cond }, paramIndex];
430
+ }
431
+
432
+ /**
433
+ * Merge multiple single-field mongo condition objects into one.
434
+ * Uses $and if field keys conflict.
435
+ */
436
+ private mergeMongoConditions(
437
+ conditions: Record<string, unknown>[],
438
+ ): Record<string, unknown> {
439
+ if (conditions.length === 1) {
440
+ return conditions[0];
441
+ }
442
+
443
+ const merged: Record<string, unknown> = {};
444
+ let hasConflict = false;
445
+
446
+ outer: for (const cond of conditions) {
447
+ for (const key of Object.keys(cond)) {
448
+ if (key in merged) {
449
+ hasConflict = true;
450
+ break outer;
451
+ }
452
+ merged[key] = cond[key];
453
+ }
454
+ }
455
+
456
+ if (hasConflict) {
457
+ return { $and: conditions };
458
+ }
459
+
460
+ return merged;
461
+ }
462
+
271
463
  /**
272
464
  * Build the WHERE clause from accumulated conditions.
273
465
  */