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.
|
|
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.
|
|
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**:
|
|
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.
|
|
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
|
-
|
|
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
|
*/
|