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.
- package/CLAUDE.md +1 -1
- package/package.json +1 -1
- package/packages/core/src/auth.ts +0 -11
- package/packages/core/src/index.ts +4 -1
- package/packages/core/src/job.ts +86 -0
- package/packages/core/src/queue.ts +60 -463
- package/packages/core/src/queueBackends/liteBackend.ts +369 -0
- package/packages/core/src/websocket.ts +81 -0
- package/packages/orm/src/baseModel.ts +84 -6
|
@@ -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
|
-
/**
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
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 |
|
|
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
|
|
462
|
+
return false;
|
|
385
463
|
}
|
|
386
464
|
(this as any)._exists = true;
|
|
387
465
|
return this;
|