tina4-nodejs 3.10.76 → 3.10.83

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.
@@ -0,0 +1,369 @@
1
+ /**
2
+ * LiteBackend — file-based queue backend for Tina4 Queue.
3
+ * Stores jobs as JSON files on disk. Zero dependencies.
4
+ */
5
+ import { mkdirSync, readdirSync, readFileSync, writeFileSync, unlinkSync, existsSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { randomUUID } from "node:crypto";
8
+ import { type QueueJob } from "../job.js";
9
+ import { createJob, type JobQueueBridge } from "../job.js";
10
+
11
+ export class LiteBackend {
12
+ private basePath: string;
13
+ private seq: number = 0;
14
+
15
+ constructor(basePath: string = "data/queue") {
16
+ this.basePath = basePath;
17
+ }
18
+
19
+ private ensureDir(queue: string): string {
20
+ const dir = join(this.basePath, queue);
21
+ mkdirSync(dir, { recursive: true });
22
+ return dir;
23
+ }
24
+
25
+ private ensureFailedDir(queue: string): string {
26
+ const dir = join(this.basePath, queue, "failed");
27
+ mkdirSync(dir, { recursive: true });
28
+ return dir;
29
+ }
30
+
31
+ push(queue: string, payload: unknown, delay?: number, priority?: number): string {
32
+ const dir = this.ensureDir(queue);
33
+ const id = randomUUID();
34
+ const now = new Date().toISOString();
35
+ this.seq++;
36
+
37
+ const job = {
38
+ id,
39
+ payload,
40
+ status: "pending" as const,
41
+ createdAt: now,
42
+ attempts: 0,
43
+ delayUntil: delay ? new Date(Date.now() + delay * 1000).toISOString() : null,
44
+ priority: priority ?? 0,
45
+ };
46
+
47
+ const prefix = `${Date.now()}-${String(this.seq).padStart(6, "0")}`;
48
+ writeFileSync(join(dir, `${prefix}_${id}.queue-data`), JSON.stringify(job, null, 2));
49
+ return id;
50
+ }
51
+
52
+ pop(queue: string, bridge: JobQueueBridge): QueueJob | null {
53
+ const dir = this.ensureDir(queue);
54
+
55
+ let files: string[];
56
+ try {
57
+ files = readdirSync(dir).filter(f => f.endsWith(".queue-data")).sort();
58
+ } catch {
59
+ return null;
60
+ }
61
+
62
+ const now = new Date().toISOString();
63
+
64
+ for (const file of files) {
65
+ const filePath = join(dir, file);
66
+ let job: QueueJob;
67
+ try {
68
+ job = JSON.parse(readFileSync(filePath, "utf-8"));
69
+ } catch {
70
+ continue;
71
+ }
72
+
73
+ if (job.status !== "pending") continue;
74
+ if (job.delayUntil && job.delayUntil > now) continue;
75
+
76
+ job.status = "reserved";
77
+ job.topic = queue;
78
+ job.priority = job.priority ?? 0;
79
+ writeFileSync(filePath, JSON.stringify(job, null, 2));
80
+
81
+ try {
82
+ unlinkSync(filePath);
83
+ } catch {
84
+ // Already consumed by another worker
85
+ }
86
+
87
+ return createJob(job as any, bridge);
88
+ }
89
+
90
+ return null;
91
+ }
92
+
93
+ size(queue: string, status: string = "pending"): number {
94
+ if (status === "failed") {
95
+ const failedDir = this.ensureFailedDir(queue);
96
+ let files: string[];
97
+ try {
98
+ files = readdirSync(failedDir).filter(f => f.endsWith(".queue-data"));
99
+ } catch {
100
+ return 0;
101
+ }
102
+ return files.length;
103
+ }
104
+
105
+ const dir = this.ensureDir(queue);
106
+ let files: string[];
107
+ try {
108
+ files = readdirSync(dir).filter(f => f.endsWith(".queue-data"));
109
+ } catch {
110
+ return 0;
111
+ }
112
+
113
+ let count = 0;
114
+ for (const file of files) {
115
+ try {
116
+ const job = JSON.parse(readFileSync(join(dir, file), "utf-8"));
117
+ if (job.status === status) count++;
118
+ } catch {
119
+ // skip corrupt files
120
+ }
121
+ }
122
+ return count;
123
+ }
124
+
125
+ clear(queue: string): void {
126
+ const dir = this.ensureDir(queue);
127
+ try {
128
+ const files = readdirSync(dir).filter(f => f.endsWith(".queue-data"));
129
+ for (const file of files) {
130
+ unlinkSync(join(dir, file));
131
+ }
132
+ } catch {
133
+ // directory might not exist
134
+ }
135
+
136
+ // Also clear failed jobs
137
+ const failedDir = join(dir, "failed");
138
+ try {
139
+ if (existsSync(failedDir)) {
140
+ const files = readdirSync(failedDir).filter(f => f.endsWith(".queue-data"));
141
+ for (const file of files) {
142
+ unlinkSync(join(failedDir, file));
143
+ }
144
+ }
145
+ } catch {
146
+ // ignore
147
+ }
148
+ }
149
+
150
+ failed(queue: string): QueueJob[] {
151
+ const failedDir = this.ensureFailedDir(queue);
152
+ const results: QueueJob[] = [];
153
+
154
+ try {
155
+ const files = readdirSync(failedDir).filter(f => f.endsWith(".queue-data")).sort();
156
+ for (const file of files) {
157
+ try {
158
+ const job: QueueJob = JSON.parse(readFileSync(join(failedDir, file), "utf-8"));
159
+ results.push(job);
160
+ } catch {
161
+ // skip corrupt files
162
+ }
163
+ }
164
+ } catch {
165
+ // directory might not exist
166
+ }
167
+
168
+ return results;
169
+ }
170
+
171
+ retry(queue: string, jobId: string): boolean {
172
+ try {
173
+ const queues = readdirSync(this.basePath);
174
+ for (const q of queues) {
175
+ const failedDir = join(this.basePath, q, "failed");
176
+ const filePath = join(failedDir, `${jobId}.queue-data`);
177
+
178
+ if (existsSync(filePath)) {
179
+ const job: QueueJob = JSON.parse(readFileSync(filePath, "utf-8"));
180
+ job.status = "pending";
181
+ job.attempts = (job.attempts || 0) + 1;
182
+ job.error = undefined;
183
+
184
+ this.seq++;
185
+ const prefix = `${Date.now()}-${String(this.seq).padStart(6, "0")}`;
186
+ const queueDir = join(this.basePath, q);
187
+ writeFileSync(join(queueDir, `${prefix}_${jobId}.queue-data`), JSON.stringify(job, null, 2));
188
+ unlinkSync(filePath);
189
+ return true;
190
+ }
191
+ }
192
+ } catch {
193
+ // ignore
194
+ }
195
+
196
+ return false;
197
+ }
198
+
199
+ deadLetters(queue: string, maxRetries: number = 3): QueueJob[] {
200
+ const failedDir = this.ensureFailedDir(queue);
201
+ const results: QueueJob[] = [];
202
+
203
+ try {
204
+ const files = readdirSync(failedDir).filter(f => f.endsWith(".queue-data")).sort();
205
+ for (const file of files) {
206
+ try {
207
+ const job: QueueJob = JSON.parse(readFileSync(join(failedDir, file), "utf-8"));
208
+ if ((job.attempts || 0) >= maxRetries) {
209
+ job.status = "dead";
210
+ results.push(job);
211
+ }
212
+ } catch {
213
+ // skip corrupt files
214
+ }
215
+ }
216
+ } catch {
217
+ // directory might not exist
218
+ }
219
+
220
+ return results;
221
+ }
222
+
223
+ purge(queue: string, status: string, maxRetries: number = 3): number {
224
+ let count = 0;
225
+
226
+ if (status === "dead") {
227
+ const failedDir = this.ensureFailedDir(queue);
228
+ try {
229
+ const files = readdirSync(failedDir).filter(f => f.endsWith(".queue-data"));
230
+ for (const file of files) {
231
+ try {
232
+ const job: QueueJob = JSON.parse(readFileSync(join(failedDir, file), "utf-8"));
233
+ if ((job.attempts || 0) >= maxRetries) {
234
+ unlinkSync(join(failedDir, file));
235
+ count++;
236
+ }
237
+ } catch {
238
+ // skip corrupt files
239
+ }
240
+ }
241
+ } catch {
242
+ // directory might not exist
243
+ }
244
+ } else if (status === "failed") {
245
+ const failedDir = this.ensureFailedDir(queue);
246
+ try {
247
+ const files = readdirSync(failedDir).filter(f => f.endsWith(".queue-data"));
248
+ for (const file of files) {
249
+ try {
250
+ const job: QueueJob = JSON.parse(readFileSync(join(failedDir, file), "utf-8"));
251
+ if ((job.attempts || 0) < maxRetries) {
252
+ unlinkSync(join(failedDir, file));
253
+ count++;
254
+ }
255
+ } catch {
256
+ // skip corrupt files
257
+ }
258
+ }
259
+ } catch {
260
+ // directory might not exist
261
+ }
262
+ } else {
263
+ const dir = this.ensureDir(queue);
264
+ try {
265
+ const files = readdirSync(dir).filter(f => f.endsWith(".queue-data"));
266
+ for (const file of files) {
267
+ try {
268
+ const job: QueueJob = JSON.parse(readFileSync(join(dir, file), "utf-8"));
269
+ if (job.status === status) {
270
+ unlinkSync(join(dir, file));
271
+ count++;
272
+ }
273
+ } catch {
274
+ // skip corrupt files
275
+ }
276
+ }
277
+ } catch {
278
+ // directory might not exist
279
+ }
280
+ }
281
+
282
+ return count;
283
+ }
284
+
285
+ retryFailed(queue: string, maxRetries: number = 3): number {
286
+ const failedDir = this.ensureFailedDir(queue);
287
+ const queueDir = this.ensureDir(queue);
288
+ let count = 0;
289
+
290
+ try {
291
+ const files = readdirSync(failedDir).filter(f => f.endsWith(".queue-data"));
292
+ for (const file of files) {
293
+ try {
294
+ const filePath = join(failedDir, file);
295
+ const job: QueueJob = JSON.parse(readFileSync(filePath, "utf-8"));
296
+
297
+ if ((job.attempts || 0) >= maxRetries) {
298
+ continue;
299
+ }
300
+
301
+ job.status = "pending";
302
+ job.error = undefined;
303
+
304
+ this.seq++;
305
+ const prefix = `${Date.now()}-${String(this.seq).padStart(6, "0")}`;
306
+ writeFileSync(join(queueDir, `${prefix}_${job.id}.queue-data`), JSON.stringify(job, null, 2));
307
+ unlinkSync(filePath);
308
+ count++;
309
+ } catch {
310
+ // skip corrupt files
311
+ }
312
+ }
313
+ } catch {
314
+ // directory might not exist
315
+ }
316
+
317
+ return count;
318
+ }
319
+
320
+ popById(queue: string, id: string): QueueJob | null {
321
+ const dir = this.ensureDir(queue);
322
+
323
+ let files: string[];
324
+ try {
325
+ files = readdirSync(dir).filter(f => f.endsWith(".queue-data"));
326
+ } catch {
327
+ return null;
328
+ }
329
+
330
+ for (const file of files) {
331
+ const filePath = join(dir, file);
332
+ let job: QueueJob;
333
+ try {
334
+ job = JSON.parse(readFileSync(filePath, "utf-8"));
335
+ } catch {
336
+ continue;
337
+ }
338
+
339
+ if (job.status !== "pending") continue;
340
+ if (job.id === id) {
341
+ try { unlinkSync(filePath); } catch { /* already consumed */ }
342
+ return job;
343
+ }
344
+ }
345
+
346
+ return null;
347
+ }
348
+
349
+ failJob(queue: string, job: QueueJob, error: string, maxRetries: number): void {
350
+ const failedDir = this.ensureFailedDir(queue);
351
+ job.status = "failed";
352
+ job.attempts = (job.attempts || 0) + 1;
353
+ job.error = error;
354
+
355
+ writeFileSync(join(failedDir, `${job.id}.queue-data`), JSON.stringify(job, null, 2));
356
+ }
357
+
358
+ retryJob(queue: string, job: QueueJob, delaySeconds?: number): void {
359
+ const dir = this.ensureDir(queue);
360
+ job.status = "pending";
361
+ job.attempts = (job.attempts || 0) + 1;
362
+ job.error = undefined;
363
+ job.delayUntil = delaySeconds ? new Date(Date.now() + delaySeconds * 1000).toISOString() : null;
364
+
365
+ this.seq++;
366
+ const prefix = `${Date.now()}-${String(this.seq).padStart(6, "0")}`;
367
+ writeFileSync(join(dir, `${prefix}_${job.id}.queue-data`), JSON.stringify(job, null, 2));
368
+ }
369
+ }
@@ -170,6 +170,10 @@ export class WebSocketServer {
170
170
  private server: Server | null = null;
171
171
  private clients: Map<string, WebSocketClient> = new Map();
172
172
  private handlers: Map<string, EventHandler[]> = new Map();
173
+ /** rooms[roomName] = Set of clientIds */
174
+ private rooms: Map<string, Set<string>> = new Map();
175
+ /** clientRooms[clientId] = Set of roomNames */
176
+ private clientRooms: Map<string, Set<string>> = new Map();
173
177
 
174
178
  constructor(options?: { port?: number }) {
175
179
  this.port = options?.port ?? parseInt(process.env.TINA4_WS_PORT ?? "8080", 10);
@@ -281,6 +285,70 @@ export class WebSocketServer {
281
285
  return this.clients;
282
286
  }
283
287
 
288
+ // ── Rooms ──────────────────────────────────────────────────
289
+
290
+ /**
291
+ * Add a client to a named room.
292
+ */
293
+ joinRoom(clientId: string, roomName: string): void {
294
+ if (!this.rooms.has(roomName)) this.rooms.set(roomName, new Set());
295
+ this.rooms.get(roomName)!.add(clientId);
296
+
297
+ if (!this.clientRooms.has(clientId)) this.clientRooms.set(clientId, new Set());
298
+ this.clientRooms.get(clientId)!.add(roomName);
299
+ }
300
+
301
+ /**
302
+ * Remove a client from a named room.
303
+ */
304
+ leaveRoom(clientId: string, roomName: string): void {
305
+ this.rooms.get(roomName)?.delete(clientId);
306
+ this.clientRooms.get(clientId)?.delete(roomName);
307
+ }
308
+
309
+ /**
310
+ * Return the list of client IDs in a room.
311
+ */
312
+ getRoomConnections(roomName: string): string[] {
313
+ return Array.from(this.rooms.get(roomName) ?? []);
314
+ }
315
+
316
+ /**
317
+ * Return the number of clients in a room.
318
+ */
319
+ roomCount(roomName: string): number {
320
+ return this.rooms.get(roomName)?.size ?? 0;
321
+ }
322
+
323
+ /**
324
+ * Return the names of all rooms a client has joined.
325
+ */
326
+ getClientRooms(clientId: string): string[] {
327
+ return Array.from(this.clientRooms.get(clientId) ?? []);
328
+ }
329
+
330
+ /**
331
+ * Broadcast a message to all clients in a room.
332
+ */
333
+ broadcastToRoom(roomName: string, message: string, excludeIds?: string[]): void {
334
+ const members = this.rooms.get(roomName);
335
+ if (!members) return;
336
+
337
+ const frame = buildFrame(OP_TEXT, Buffer.from(message, "utf-8"));
338
+ const exclude = new Set(excludeIds ?? []);
339
+
340
+ for (const clientId of members) {
341
+ if (exclude.has(clientId)) continue;
342
+ const client = this.clients.get(clientId);
343
+ if (!client || client.closed) continue;
344
+ try {
345
+ client.socket.write(frame);
346
+ } catch {
347
+ // client disconnected
348
+ }
349
+ }
350
+ }
351
+
284
352
  // ── Private ────────────────────────────────────────────────
285
353
 
286
354
  private emit(event: string, ...args: unknown[]): void {
@@ -348,12 +416,14 @@ export class WebSocketServer {
348
416
  socket.on("close", () => {
349
417
  client.closed = true;
350
418
  this.clients.delete(clientId);
419
+ this.removeClientFromAllRooms(clientId);
351
420
  this.emit("close", client);
352
421
  });
353
422
 
354
423
  socket.on("error", (err) => {
355
424
  client.closed = true;
356
425
  this.clients.delete(clientId);
426
+ this.removeClientFromAllRooms(clientId);
357
427
  this.emit("error", err, client);
358
428
  });
359
429
  }
@@ -405,6 +475,7 @@ export class WebSocketServer {
405
475
  // already closed
406
476
  }
407
477
  this.clients.delete(client.id);
478
+ this.removeClientFromAllRooms(client.id);
408
479
  this.emit("close", client);
409
480
  }
410
481
  break;
@@ -414,4 +485,14 @@ export class WebSocketServer {
414
485
 
415
486
  setBuffer(remaining);
416
487
  }
488
+
489
+ private removeClientFromAllRooms(clientId: string): void {
490
+ const rooms = this.clientRooms.get(clientId);
491
+ if (rooms) {
492
+ for (const roomName of rooms) {
493
+ this.rooms.get(roomName)?.delete(clientId);
494
+ }
495
+ }
496
+ this.clientRooms.delete(clientId);
497
+ }
417
498
  }
@@ -235,17 +235,95 @@ export class BaseModel {
235
235
  return instance;
236
236
  }
237
237
 
238
- /** Alias for findById(). */
239
- static find<T extends BaseModel>(this: new (data?: Record<string, unknown>) => T, id: unknown, include?: string[]): T | null {
240
- return (this as unknown as typeof BaseModel).findById.call(this, id, include) as T | null;
238
+ /**
239
+ * Find records by filter dict. Always returns an array.
240
+ *
241
+ * Usage:
242
+ * User.find({ name: "Alice" }) → [User, ...]
243
+ * User.find({ age: 18 }, 10) → [User, ...] (limit 10)
244
+ * User.find({}, 100, 0, "name ASC") → [User, ...] (with orderBy)
245
+ * User.find() → all records
246
+ *
247
+ * Use findById(id) for single-record primary key lookup.
248
+ */
249
+ static find<T extends BaseModel>(
250
+ this: new (data?: Record<string, unknown>) => T,
251
+ filter?: Record<string, unknown>,
252
+ limit = 100,
253
+ offset = 0,
254
+ orderBy?: string,
255
+ include?: string[],
256
+ ): T[] {
257
+ const ModelClass = this as unknown as typeof BaseModel & (new (data?: Record<string, unknown>) => T);
258
+ const db = ModelClass.getDb();
259
+ const conditions: string[] = [];
260
+ const params: unknown[] = [];
261
+
262
+ if (filter) {
263
+ for (const [key, value] of Object.entries(filter)) {
264
+ const col = ModelClass.getDbColumn(key) ?? key;
265
+ conditions.push(`"${col}" = ?`);
266
+ params.push(value);
267
+ }
268
+ }
269
+
270
+ if (ModelClass.softDelete) {
271
+ conditions.push("is_deleted = 0");
272
+ }
273
+
274
+ let sql = `SELECT * FROM "${ModelClass.tableName}"`;
275
+ if (conditions.length > 0) {
276
+ sql += ` WHERE ${conditions.join(" AND ")}`;
277
+ }
278
+ if (orderBy) {
279
+ sql += ` ORDER BY ${orderBy}`;
280
+ }
281
+
282
+ const rows = db.fetch(sql, params, limit, offset);
283
+ const data = (rows as any)?.data ?? rows;
284
+ const instances = (Array.isArray(data) ? data : []).map((row: Record<string, unknown>) => {
285
+ const inst = new this(row) as T;
286
+ (inst as any)._exists = true;
287
+ return inst;
288
+ });
289
+
290
+ if (include) {
291
+ ModelClass._eagerLoad(instances as BaseModel[], include);
292
+ }
293
+
294
+ return instances;
241
295
  }
242
296
 
243
297
  /**
244
298
  * Load a record into this instance via selectOne.
245
299
  * Returns true if found and loaded, false otherwise.
246
300
  */
247
- load(sql: string, params?: unknown[], include?: string[]): boolean {
301
+ /**
302
+ * Load a record into this instance.
303
+ *
304
+ * Usage:
305
+ * orm.id = 1; orm.load() — uses PK already set
306
+ * orm.load("id = ?", [1]) — filter with params
307
+ * orm.load("id = 1") — filter string
308
+ *
309
+ * Returns true if found, false otherwise.
310
+ */
311
+ load(filter?: string, params?: unknown[], include?: string[]): boolean {
248
312
  const ModelClass = this.constructor as typeof BaseModel & (new (data?: Record<string, unknown>) => BaseModel);
313
+ const table = (ModelClass as any).tableName ?? (this as any).tableName;
314
+
315
+ let sql: string;
316
+ if (filter === undefined || filter === null) {
317
+ // No args — use PK already set
318
+ const pk = (ModelClass as any).primaryKey ?? (this as any).primaryKey ?? "id";
319
+ const pkValue = (this as any)[pk];
320
+ if (pkValue === undefined || pkValue === null) return false;
321
+ sql = `SELECT * FROM ${table} WHERE ${pk} = ?`;
322
+ params = [pkValue];
323
+ } else {
324
+ sql = `SELECT * FROM ${table} WHERE ${filter}`;
325
+ }
326
+
249
327
  const result = ModelClass.selectOne(sql, params, include);
250
328
  if (!result) return false;
251
329
  const data = (result as any).toJSON ? (result as any).toJSON() : result;
@@ -338,7 +416,7 @@ export class BaseModel {
338
416
  * Save this instance (insert or update).
339
417
  * Returns this on success (fluent), null on failure.
340
418
  */
341
- save(): this | null {
419
+ save(): this | false {
342
420
  const ModelClass = this.constructor as typeof BaseModel;
343
421
  const db = ModelClass.getDb();
344
422
  const pk = ModelClass.getPkField();
@@ -381,7 +459,7 @@ export class BaseModel {
381
459
  db.commit();
382
460
  } catch (e) {
383
461
  db.rollback();
384
- return null;
462
+ return false;
385
463
  }
386
464
  (this as any)._exists = true;
387
465
  return this;