tina4-nodejs 3.9.1 → 3.9.2

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.1)
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.1 — 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
 
@@ -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/MongoDB backends, configured via env vars
590
+ - **Queue**: file/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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tina4-nodejs",
3
- "version": "3.9.1",
3
+ "version": "3.9.2",
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
  */