@shetty4l/core 0.1.34 → 0.1.36
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/package.json +1 -1
- package/src/state/collection/decorators.ts +252 -0
- package/src/state/collection/query.ts +259 -0
- package/src/state/collection/types.ts +160 -0
- package/src/state/decorators.ts +4 -1
- package/src/state/index.ts +103 -5
- package/src/state/loader.ts +682 -2
- package/src/state/schema.ts +104 -1
- package/src/state/types.ts +3 -0
package/src/state/loader.ts
CHANGED
|
@@ -2,11 +2,26 @@
|
|
|
2
2
|
* StateLoader: Load and auto-persist state objects.
|
|
3
3
|
*
|
|
4
4
|
* Provides a proxy-based approach to automatically save state changes
|
|
5
|
-
* to SQLite with debounced writes.
|
|
5
|
+
* to SQLite with debounced writes. Supports both singleton @Persisted
|
|
6
|
+
* classes and multi-row @PersistedCollection classes.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
import type { Database, SQLQueryBindings } from "bun:sqlite";
|
|
9
|
-
import {
|
|
10
|
+
import { buildOrderBy, buildWhere } from "./collection/query";
|
|
11
|
+
import {
|
|
12
|
+
CollectionEntity,
|
|
13
|
+
type CollectionMeta,
|
|
14
|
+
collectionMeta,
|
|
15
|
+
type FieldType,
|
|
16
|
+
type FindOptions,
|
|
17
|
+
} from "./collection/types";
|
|
18
|
+
import {
|
|
19
|
+
ensureCollectionTable,
|
|
20
|
+
ensureIndices,
|
|
21
|
+
ensureTable,
|
|
22
|
+
migrateAdditive,
|
|
23
|
+
migrateCollectionAdditive,
|
|
24
|
+
} from "./schema";
|
|
10
25
|
import { deserializeValue, serializeValue } from "./serialization";
|
|
11
26
|
import type { ClassMeta } from "./types";
|
|
12
27
|
import { classMeta } from "./types";
|
|
@@ -34,6 +49,47 @@ export class StateLoader {
|
|
|
34
49
|
this.db = db;
|
|
35
50
|
}
|
|
36
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Check if a state row exists for the given key.
|
|
54
|
+
*
|
|
55
|
+
* Unlike `load()`, this does NOT create a row if it doesn't exist.
|
|
56
|
+
* Ensures table exists and migrates if needed (consistent with load()).
|
|
57
|
+
*
|
|
58
|
+
* @param Cls - The @Persisted class constructor
|
|
59
|
+
* @param key - Unique key to check
|
|
60
|
+
* @returns `true` if row exists, `false` otherwise
|
|
61
|
+
* @throws Error if class is not decorated with @Persisted
|
|
62
|
+
* @throws Error (compile-time) if class extends CollectionEntity
|
|
63
|
+
*/
|
|
64
|
+
exists<T extends CollectionEntity>(Cls: new () => T, key: string): never;
|
|
65
|
+
exists<T extends object>(Cls: new () => T, key: string): boolean;
|
|
66
|
+
exists<T extends object>(Cls: new () => T, key: string): boolean {
|
|
67
|
+
// Runtime check: CollectionEntity classes should use get() instead
|
|
68
|
+
if (collectionMeta.has(Cls)) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`Class "${Cls.name}" is a @PersistedCollection. ` +
|
|
71
|
+
`Use get() or find() instead of exists().`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Get metadata
|
|
76
|
+
const meta = classMeta.get(Cls);
|
|
77
|
+
if (!meta || !meta.table) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`Class "${Cls.name}" is not decorated with @Persisted. ` +
|
|
80
|
+
`Add @Persisted('table_name') to the class.`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Ensure table exists and migrate if needed (consistent with load())
|
|
85
|
+
ensureTable(this.db, meta);
|
|
86
|
+
migrateAdditive(this.db, meta);
|
|
87
|
+
|
|
88
|
+
// Check if row exists
|
|
89
|
+
const row = this.selectRow(meta, key);
|
|
90
|
+
return row !== null;
|
|
91
|
+
}
|
|
92
|
+
|
|
37
93
|
/**
|
|
38
94
|
* Load a state object by key.
|
|
39
95
|
*
|
|
@@ -44,8 +100,19 @@ export class StateLoader {
|
|
|
44
100
|
* @param key - Unique key for this state instance
|
|
45
101
|
* @returns Proxied instance that auto-saves changes
|
|
46
102
|
* @throws Error if class is not decorated with @Persisted
|
|
103
|
+
* @throws Error (compile-time) if class extends CollectionEntity
|
|
47
104
|
*/
|
|
105
|
+
load<T extends CollectionEntity>(Cls: new () => T, key: string): never;
|
|
106
|
+
load<T extends object>(Cls: new () => T, key: string): T;
|
|
48
107
|
load<T extends object>(Cls: new () => T, key: string): T {
|
|
108
|
+
// Runtime check: CollectionEntity classes should use get() instead
|
|
109
|
+
if (collectionMeta.has(Cls)) {
|
|
110
|
+
throw new Error(
|
|
111
|
+
`Class "${Cls.name}" is a @PersistedCollection. ` +
|
|
112
|
+
`Use get() or find() instead of load().`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
49
116
|
// Get metadata
|
|
50
117
|
const meta = classMeta.get(Cls);
|
|
51
118
|
if (!meta || !meta.table) {
|
|
@@ -103,6 +170,619 @@ export class StateLoader {
|
|
|
103
170
|
this.pendingSaves.clear();
|
|
104
171
|
}
|
|
105
172
|
|
|
173
|
+
// --------------------------------------------------------------------------
|
|
174
|
+
// Collection methods (for @PersistedCollection classes)
|
|
175
|
+
// --------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Create a new collection entity and persist it.
|
|
179
|
+
*
|
|
180
|
+
* Inserts a new row into the collection table. The entity's @Id field
|
|
181
|
+
* must be set before calling create(). Returns a bound entity with
|
|
182
|
+
* working save() and delete() methods.
|
|
183
|
+
*
|
|
184
|
+
* @param Cls - The @PersistedCollection class constructor
|
|
185
|
+
* @param data - Partial entity data to initialize with
|
|
186
|
+
* @returns Bound entity instance with save() and delete() methods
|
|
187
|
+
* @throws Error if class is not decorated with @PersistedCollection
|
|
188
|
+
* @throws Error if INSERT fails (e.g., duplicate primary key)
|
|
189
|
+
*
|
|
190
|
+
* @example
|
|
191
|
+
* ```ts
|
|
192
|
+
* const user = await loader.create(User, { id: 'abc123', name: 'Alice' });
|
|
193
|
+
* user.name = 'Alicia';
|
|
194
|
+
* await user.save();
|
|
195
|
+
* ```
|
|
196
|
+
*/
|
|
197
|
+
create<T extends CollectionEntity>(
|
|
198
|
+
Cls: new () => T,
|
|
199
|
+
data: Partial<Omit<T, "save" | "delete">>,
|
|
200
|
+
): T {
|
|
201
|
+
const meta = this.getCollectionMeta(Cls);
|
|
202
|
+
|
|
203
|
+
// Ensure table and indices exist
|
|
204
|
+
ensureCollectionTable(this.db, meta);
|
|
205
|
+
migrateCollectionAdditive(this.db, meta);
|
|
206
|
+
ensureIndices(this.db, meta);
|
|
207
|
+
|
|
208
|
+
// Create instance and populate with data
|
|
209
|
+
const instance = new Cls();
|
|
210
|
+
Object.assign(instance, data);
|
|
211
|
+
|
|
212
|
+
// Insert row
|
|
213
|
+
this.insertCollectionRow(meta, instance);
|
|
214
|
+
|
|
215
|
+
// Return bound entity
|
|
216
|
+
return this.bindCollectionEntity(instance, meta);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Get a single entity by its primary key.
|
|
221
|
+
*
|
|
222
|
+
* @param Cls - The @PersistedCollection class constructor
|
|
223
|
+
* @param id - Primary key value
|
|
224
|
+
* @returns Bound entity instance or null if not found
|
|
225
|
+
* @throws Error if class is not decorated with @PersistedCollection
|
|
226
|
+
*
|
|
227
|
+
* @example
|
|
228
|
+
* ```ts
|
|
229
|
+
* const user = loader.get(User, 'abc123');
|
|
230
|
+
* if (user) {
|
|
231
|
+
* console.log(user.name);
|
|
232
|
+
* }
|
|
233
|
+
* ```
|
|
234
|
+
*/
|
|
235
|
+
get<T extends CollectionEntity>(
|
|
236
|
+
Cls: new () => T,
|
|
237
|
+
id: string | number,
|
|
238
|
+
): T | null {
|
|
239
|
+
const meta = this.getCollectionMeta(Cls);
|
|
240
|
+
|
|
241
|
+
// Ensure table and indices exist
|
|
242
|
+
ensureCollectionTable(this.db, meta);
|
|
243
|
+
migrateCollectionAdditive(this.db, meta);
|
|
244
|
+
ensureIndices(this.db, meta);
|
|
245
|
+
|
|
246
|
+
// Query by primary key
|
|
247
|
+
const sql = `SELECT * FROM ${meta.table} WHERE ${meta.idColumn} = ?`;
|
|
248
|
+
const row = this.db.prepare(sql).get(id) as Record<string, unknown> | null;
|
|
249
|
+
|
|
250
|
+
if (!row) {
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Create instance and populate from row
|
|
255
|
+
const instance = new Cls();
|
|
256
|
+
this.populateCollectionInstance(instance, meta, row);
|
|
257
|
+
|
|
258
|
+
return this.bindCollectionEntity(instance, meta);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Find entities matching the given criteria.
|
|
263
|
+
*
|
|
264
|
+
* @param Cls - The @PersistedCollection class constructor
|
|
265
|
+
* @param options - Query options (where, orderBy, limit, offset)
|
|
266
|
+
* @returns Array of bound entity instances
|
|
267
|
+
* @throws Error if class is not decorated with @PersistedCollection
|
|
268
|
+
*
|
|
269
|
+
* @example
|
|
270
|
+
* ```ts
|
|
271
|
+
* const users = loader.find(User, {
|
|
272
|
+
* where: { status: 'active', age: { op: 'gte', value: 18 } },
|
|
273
|
+
* orderBy: { createdAt: 'desc' },
|
|
274
|
+
* limit: 10,
|
|
275
|
+
* });
|
|
276
|
+
* ```
|
|
277
|
+
*/
|
|
278
|
+
find<T extends CollectionEntity>(
|
|
279
|
+
Cls: new () => T,
|
|
280
|
+
options?: FindOptions<T>,
|
|
281
|
+
): T[] {
|
|
282
|
+
const meta = this.getCollectionMeta(Cls);
|
|
283
|
+
|
|
284
|
+
// Ensure table and indices exist
|
|
285
|
+
ensureCollectionTable(this.db, meta);
|
|
286
|
+
migrateCollectionAdditive(this.db, meta);
|
|
287
|
+
ensureIndices(this.db, meta);
|
|
288
|
+
|
|
289
|
+
// Build query
|
|
290
|
+
let sql = `SELECT * FROM ${meta.table}`;
|
|
291
|
+
const params: SQLQueryBindings[] = [];
|
|
292
|
+
|
|
293
|
+
// WHERE clause
|
|
294
|
+
const whereResult = buildWhere(meta, options?.where);
|
|
295
|
+
if (whereResult.sql) {
|
|
296
|
+
sql += ` WHERE ${whereResult.sql}`;
|
|
297
|
+
params.push(...whereResult.params);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ORDER BY clause
|
|
301
|
+
const orderBySql = buildOrderBy(meta, options?.orderBy);
|
|
302
|
+
if (orderBySql) {
|
|
303
|
+
sql += ` ORDER BY ${orderBySql}`;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// LIMIT and OFFSET
|
|
307
|
+
if (options?.limit !== undefined) {
|
|
308
|
+
sql += ` LIMIT ?`;
|
|
309
|
+
params.push(options.limit);
|
|
310
|
+
}
|
|
311
|
+
if (options?.offset !== undefined) {
|
|
312
|
+
sql += ` OFFSET ?`;
|
|
313
|
+
params.push(options.offset);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Execute query
|
|
317
|
+
const rows = this.db.prepare(sql).all(...params) as Record<
|
|
318
|
+
string,
|
|
319
|
+
unknown
|
|
320
|
+
>[];
|
|
321
|
+
|
|
322
|
+
// Map rows to bound entities
|
|
323
|
+
return rows.map((row) => {
|
|
324
|
+
const instance = new Cls();
|
|
325
|
+
this.populateCollectionInstance(instance, meta, row);
|
|
326
|
+
return this.bindCollectionEntity(instance, meta);
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Count entities matching the given criteria.
|
|
332
|
+
*
|
|
333
|
+
* @param Cls - The @PersistedCollection class constructor
|
|
334
|
+
* @param where - Optional filter conditions
|
|
335
|
+
* @returns Number of matching entities
|
|
336
|
+
* @throws Error if class is not decorated with @PersistedCollection
|
|
337
|
+
*
|
|
338
|
+
* @example
|
|
339
|
+
* ```ts
|
|
340
|
+
* const activeCount = loader.count(User, { status: 'active' });
|
|
341
|
+
* ```
|
|
342
|
+
*/
|
|
343
|
+
count<T extends CollectionEntity>(
|
|
344
|
+
Cls: new () => T,
|
|
345
|
+
where?: FindOptions<T>["where"],
|
|
346
|
+
): number {
|
|
347
|
+
const meta = this.getCollectionMeta(Cls);
|
|
348
|
+
|
|
349
|
+
// Ensure table and indices exist
|
|
350
|
+
ensureCollectionTable(this.db, meta);
|
|
351
|
+
migrateCollectionAdditive(this.db, meta);
|
|
352
|
+
ensureIndices(this.db, meta);
|
|
353
|
+
|
|
354
|
+
// Build query
|
|
355
|
+
let sql = `SELECT COUNT(*) as count FROM ${meta.table}`;
|
|
356
|
+
const params: SQLQueryBindings[] = [];
|
|
357
|
+
|
|
358
|
+
// WHERE clause
|
|
359
|
+
const whereResult = buildWhere(meta, where);
|
|
360
|
+
if (whereResult.sql) {
|
|
361
|
+
sql += ` WHERE ${whereResult.sql}`;
|
|
362
|
+
params.push(...whereResult.params);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Execute query
|
|
366
|
+
const result = this.db.prepare(sql).get(...params) as { count: number };
|
|
367
|
+
return result.count;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// --------------------------------------------------------------------------
|
|
371
|
+
// Bulk operations (for @PersistedCollection classes)
|
|
372
|
+
// --------------------------------------------------------------------------
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Insert or replace an entity (upsert).
|
|
376
|
+
*
|
|
377
|
+
* If an entity with the same primary key exists, it will be replaced.
|
|
378
|
+
* Otherwise, a new row is inserted. Returns a bound entity with
|
|
379
|
+
* working save() and delete() methods.
|
|
380
|
+
*
|
|
381
|
+
* @param Cls - The @PersistedCollection class constructor
|
|
382
|
+
* @param data - Entity data including the @Id field
|
|
383
|
+
* @returns Bound entity instance with save() and delete() methods
|
|
384
|
+
* @throws Error if class is not decorated with @PersistedCollection
|
|
385
|
+
*
|
|
386
|
+
* @example
|
|
387
|
+
* ```ts
|
|
388
|
+
* const user = loader.upsert(User, { id: 'abc123', name: 'Alice' });
|
|
389
|
+
* // If user exists, it's updated; otherwise created
|
|
390
|
+
* ```
|
|
391
|
+
*/
|
|
392
|
+
upsert<T extends CollectionEntity>(
|
|
393
|
+
Cls: new () => T,
|
|
394
|
+
data: Partial<Omit<T, "save" | "delete">>,
|
|
395
|
+
): T {
|
|
396
|
+
const meta = this.getCollectionMeta(Cls);
|
|
397
|
+
|
|
398
|
+
// Ensure table and indices exist
|
|
399
|
+
ensureCollectionTable(this.db, meta);
|
|
400
|
+
migrateCollectionAdditive(this.db, meta);
|
|
401
|
+
ensureIndices(this.db, meta);
|
|
402
|
+
|
|
403
|
+
// Create instance and populate with data
|
|
404
|
+
const instance = new Cls();
|
|
405
|
+
Object.assign(instance, data);
|
|
406
|
+
|
|
407
|
+
// Upsert row
|
|
408
|
+
this.upsertCollectionRow(meta, instance);
|
|
409
|
+
|
|
410
|
+
// Return bound entity
|
|
411
|
+
return this.bindCollectionEntity(instance, meta);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Update multiple entities matching the given criteria.
|
|
416
|
+
*
|
|
417
|
+
* @param Cls - The @PersistedCollection class constructor
|
|
418
|
+
* @param where - Filter conditions to select rows to update
|
|
419
|
+
* @param updates - Partial data to apply to matching rows
|
|
420
|
+
* @returns Number of rows updated
|
|
421
|
+
* @throws Error if class is not decorated with @PersistedCollection
|
|
422
|
+
*
|
|
423
|
+
* @example
|
|
424
|
+
* ```ts
|
|
425
|
+
* const count = loader.updateWhere(User,
|
|
426
|
+
* { status: 'pending' },
|
|
427
|
+
* { status: 'active' }
|
|
428
|
+
* );
|
|
429
|
+
* console.log(`Updated ${count} users`);
|
|
430
|
+
* ```
|
|
431
|
+
*/
|
|
432
|
+
updateWhere<T extends CollectionEntity>(
|
|
433
|
+
Cls: new () => T,
|
|
434
|
+
where: FindOptions<T>["where"],
|
|
435
|
+
updates: Partial<Omit<T, "save" | "delete">>,
|
|
436
|
+
): number {
|
|
437
|
+
const meta = this.getCollectionMeta(Cls);
|
|
438
|
+
|
|
439
|
+
// Ensure table and indices exist
|
|
440
|
+
ensureCollectionTable(this.db, meta);
|
|
441
|
+
migrateCollectionAdditive(this.db, meta);
|
|
442
|
+
ensureIndices(this.db, meta);
|
|
443
|
+
|
|
444
|
+
// Build SET clause from updates
|
|
445
|
+
const setClauses: string[] = ["updated_at = datetime('now')"];
|
|
446
|
+
const setParams: SQLQueryBindings[] = [];
|
|
447
|
+
|
|
448
|
+
for (const [property, value] of Object.entries(updates)) {
|
|
449
|
+
if (value === undefined) {
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Get column name - check if it's the id field or a regular field
|
|
454
|
+
let column: string;
|
|
455
|
+
let fieldType: FieldType;
|
|
456
|
+
|
|
457
|
+
if (property === meta.idProperty) {
|
|
458
|
+
column = meta.idColumn;
|
|
459
|
+
fieldType = meta.idType;
|
|
460
|
+
} else {
|
|
461
|
+
const field = meta.fields.get(property);
|
|
462
|
+
if (!field) {
|
|
463
|
+
continue; // Skip unknown properties
|
|
464
|
+
}
|
|
465
|
+
column = field.column;
|
|
466
|
+
fieldType = field.type;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
setClauses.push(`${column} = ?`);
|
|
470
|
+
setParams.push(serializeValue(value, fieldType));
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Build WHERE clause
|
|
474
|
+
const whereResult = buildWhere(meta, where);
|
|
475
|
+
|
|
476
|
+
// Require at least one WHERE condition to prevent accidental bulk updates
|
|
477
|
+
if (!whereResult.sql) {
|
|
478
|
+
throw new Error(
|
|
479
|
+
`updateWhere() requires at least one WHERE condition. ` +
|
|
480
|
+
`Pass a non-empty filter to prevent accidental bulk updates.`,
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Build full SQL
|
|
485
|
+
const sql = `UPDATE ${meta.table} SET ${setClauses.join(", ")} WHERE ${whereResult.sql}`;
|
|
486
|
+
const params: SQLQueryBindings[] = [...setParams, ...whereResult.params];
|
|
487
|
+
|
|
488
|
+
// Execute query
|
|
489
|
+
const result = this.db.prepare(sql).run(...params);
|
|
490
|
+
return result.changes;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Delete multiple entities matching the given criteria.
|
|
495
|
+
*
|
|
496
|
+
* @param Cls - The @PersistedCollection class constructor
|
|
497
|
+
* @param where - Filter conditions to select rows to delete
|
|
498
|
+
* @returns Number of rows deleted
|
|
499
|
+
* @throws Error if class is not decorated with @PersistedCollection
|
|
500
|
+
*
|
|
501
|
+
* @example
|
|
502
|
+
* ```ts
|
|
503
|
+
* const count = loader.deleteWhere(User, { status: 'inactive' });
|
|
504
|
+
* console.log(`Deleted ${count} inactive users`);
|
|
505
|
+
* ```
|
|
506
|
+
*/
|
|
507
|
+
deleteWhere<T extends CollectionEntity>(
|
|
508
|
+
Cls: new () => T,
|
|
509
|
+
where: FindOptions<T>["where"],
|
|
510
|
+
): number {
|
|
511
|
+
const meta = this.getCollectionMeta(Cls);
|
|
512
|
+
|
|
513
|
+
// Ensure table and indices exist
|
|
514
|
+
ensureCollectionTable(this.db, meta);
|
|
515
|
+
migrateCollectionAdditive(this.db, meta);
|
|
516
|
+
ensureIndices(this.db, meta);
|
|
517
|
+
|
|
518
|
+
// Build WHERE clause
|
|
519
|
+
const whereResult = buildWhere(meta, where);
|
|
520
|
+
|
|
521
|
+
// Require at least one WHERE condition to prevent accidental bulk deletes
|
|
522
|
+
if (!whereResult.sql) {
|
|
523
|
+
throw new Error(
|
|
524
|
+
`deleteWhere() requires at least one WHERE condition. ` +
|
|
525
|
+
`Pass a non-empty filter to prevent accidental bulk deletes.`,
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Build full SQL
|
|
530
|
+
const sql = `DELETE FROM ${meta.table} WHERE ${whereResult.sql}`;
|
|
531
|
+
const params: SQLQueryBindings[] = [...whereResult.params];
|
|
532
|
+
|
|
533
|
+
// Execute query
|
|
534
|
+
const result = this.db.prepare(sql).run(...params);
|
|
535
|
+
return result.changes;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Execute a function within a database transaction.
|
|
540
|
+
*
|
|
541
|
+
* Uses BEGIN IMMEDIATE to acquire a write lock immediately, preventing
|
|
542
|
+
* other writers. If the function throws, the transaction is rolled back.
|
|
543
|
+
* Otherwise, it is committed.
|
|
544
|
+
*
|
|
545
|
+
* @param fn - The function to execute within the transaction
|
|
546
|
+
* @returns The return value of the function
|
|
547
|
+
* @throws Error if the function throws (transaction is rolled back)
|
|
548
|
+
*
|
|
549
|
+
* @example
|
|
550
|
+
* ```ts
|
|
551
|
+
* await loader.transaction(async () => {
|
|
552
|
+
* const user = loader.get(User, 'abc123');
|
|
553
|
+
* if (user) {
|
|
554
|
+
* user.balance -= 100;
|
|
555
|
+
* await user.save();
|
|
556
|
+
* }
|
|
557
|
+
* const order = loader.create(Order, { userId: 'abc123', amount: 100 });
|
|
558
|
+
* });
|
|
559
|
+
* ```
|
|
560
|
+
*/
|
|
561
|
+
async transaction<T>(fn: () => T | Promise<T>): Promise<T> {
|
|
562
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
563
|
+
try {
|
|
564
|
+
const result = await fn();
|
|
565
|
+
this.db.exec("COMMIT");
|
|
566
|
+
return result;
|
|
567
|
+
} catch (error) {
|
|
568
|
+
this.db.exec("ROLLBACK");
|
|
569
|
+
throw error;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Convenience method for single-entity sync updates with auto-save.
|
|
575
|
+
*
|
|
576
|
+
* Fetches an entity by ID, applies a synchronous update function,
|
|
577
|
+
* and automatically saves the entity. Throws if the entity is not found.
|
|
578
|
+
*
|
|
579
|
+
* @param Cls - The @PersistedCollection class constructor
|
|
580
|
+
* @param id - Primary key value
|
|
581
|
+
* @param fn - Synchronous function to modify the entity
|
|
582
|
+
* @returns The modified and saved entity
|
|
583
|
+
* @throws Error if entity is not found
|
|
584
|
+
*
|
|
585
|
+
* @example
|
|
586
|
+
* ```ts
|
|
587
|
+
* const user = await loader.modify(User, 'abc123', (user) => {
|
|
588
|
+
* user.lastLogin = new Date();
|
|
589
|
+
* user.loginCount += 1;
|
|
590
|
+
* });
|
|
591
|
+
* ```
|
|
592
|
+
*/
|
|
593
|
+
async modify<T extends CollectionEntity>(
|
|
594
|
+
Cls: new () => T,
|
|
595
|
+
id: string | number,
|
|
596
|
+
fn: (entity: T) => void,
|
|
597
|
+
): Promise<T> {
|
|
598
|
+
const entity = this.get(Cls, id);
|
|
599
|
+
if (!entity) {
|
|
600
|
+
throw new Error(
|
|
601
|
+
`Entity "${Cls.name}" with id "${id}" not found. ` +
|
|
602
|
+
`Use get() to check existence before calling modify().`,
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
fn(entity);
|
|
607
|
+
await entity.save();
|
|
608
|
+
return entity;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// --------------------------------------------------------------------------
|
|
612
|
+
// Collection private helpers
|
|
613
|
+
// --------------------------------------------------------------------------
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Get collection metadata for a class, ensuring it's properly decorated.
|
|
617
|
+
*/
|
|
618
|
+
private getCollectionMeta(Cls: new () => CollectionEntity): CollectionMeta {
|
|
619
|
+
const meta = collectionMeta.get(Cls);
|
|
620
|
+
if (!meta) {
|
|
621
|
+
throw new Error(
|
|
622
|
+
`Class "${Cls.name}" is not decorated with @PersistedCollection. ` +
|
|
623
|
+
`Add @PersistedCollection('table_name') to the class.`,
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
return meta;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Insert a new collection entity row.
|
|
631
|
+
*/
|
|
632
|
+
private insertCollectionRow<T extends CollectionEntity>(
|
|
633
|
+
meta: CollectionMeta,
|
|
634
|
+
instance: T,
|
|
635
|
+
): void {
|
|
636
|
+
const columns: string[] = [meta.idColumn, "created_at", "updated_at"];
|
|
637
|
+
const placeholders: string[] = ["?", "datetime('now')", "datetime('now')"];
|
|
638
|
+
const values: SQLQueryBindings[] = [
|
|
639
|
+
(instance as Record<string, unknown>)[
|
|
640
|
+
meta.idProperty
|
|
641
|
+
] as SQLQueryBindings,
|
|
642
|
+
];
|
|
643
|
+
|
|
644
|
+
// Add all field columns
|
|
645
|
+
for (const field of meta.fields.values()) {
|
|
646
|
+
columns.push(field.column);
|
|
647
|
+
placeholders.push("?");
|
|
648
|
+
const value = (instance as Record<string, unknown>)[field.property];
|
|
649
|
+
values.push(serializeValue(value, field.type));
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const sql = `INSERT INTO ${meta.table} (${columns.join(", ")}) VALUES (${placeholders.join(", ")})`;
|
|
653
|
+
this.db.prepare(sql).run(...values);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Insert or replace a collection entity row (upsert).
|
|
658
|
+
* Uses ON CONFLICT to preserve created_at on updates.
|
|
659
|
+
*/
|
|
660
|
+
private upsertCollectionRow<T extends CollectionEntity>(
|
|
661
|
+
meta: CollectionMeta,
|
|
662
|
+
instance: T,
|
|
663
|
+
): void {
|
|
664
|
+
const columns: string[] = [meta.idColumn, "created_at", "updated_at"];
|
|
665
|
+
const placeholders: string[] = ["?", "datetime('now')", "datetime('now')"];
|
|
666
|
+
const values: SQLQueryBindings[] = [
|
|
667
|
+
(instance as Record<string, unknown>)[
|
|
668
|
+
meta.idProperty
|
|
669
|
+
] as SQLQueryBindings,
|
|
670
|
+
];
|
|
671
|
+
|
|
672
|
+
// Build update clauses for ON CONFLICT (excludes id and created_at)
|
|
673
|
+
const updateClauses: string[] = ["updated_at = datetime('now')"];
|
|
674
|
+
|
|
675
|
+
// Add all field columns
|
|
676
|
+
for (const field of meta.fields.values()) {
|
|
677
|
+
columns.push(field.column);
|
|
678
|
+
placeholders.push("?");
|
|
679
|
+
const value = (instance as Record<string, unknown>)[field.property];
|
|
680
|
+
values.push(serializeValue(value, field.type));
|
|
681
|
+
updateClauses.push(`${field.column} = excluded.${field.column}`);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// Use INSERT ... ON CONFLICT to preserve created_at on update
|
|
685
|
+
const sql =
|
|
686
|
+
`INSERT INTO ${meta.table} (${columns.join(", ")}) ` +
|
|
687
|
+
`VALUES (${placeholders.join(", ")}) ` +
|
|
688
|
+
`ON CONFLICT(${meta.idColumn}) DO UPDATE SET ${updateClauses.join(", ")}`;
|
|
689
|
+
this.db.prepare(sql).run(...values);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* Save (update) an existing collection entity row.
|
|
694
|
+
*/
|
|
695
|
+
private saveCollectionRow<T extends CollectionEntity>(
|
|
696
|
+
meta: CollectionMeta,
|
|
697
|
+
instance: T,
|
|
698
|
+
): void {
|
|
699
|
+
const setClauses: string[] = ["updated_at = datetime('now')"];
|
|
700
|
+
const values: SQLQueryBindings[] = [];
|
|
701
|
+
|
|
702
|
+
// Add all field columns
|
|
703
|
+
for (const field of meta.fields.values()) {
|
|
704
|
+
setClauses.push(`${field.column} = ?`);
|
|
705
|
+
const value = (instance as Record<string, unknown>)[field.property];
|
|
706
|
+
values.push(serializeValue(value, field.type));
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// Add id value for WHERE clause
|
|
710
|
+
const idValue = (instance as Record<string, unknown>)[
|
|
711
|
+
meta.idProperty
|
|
712
|
+
] as SQLQueryBindings;
|
|
713
|
+
values.push(idValue);
|
|
714
|
+
|
|
715
|
+
const sql = `UPDATE ${meta.table} SET ${setClauses.join(", ")} WHERE ${meta.idColumn} = ?`;
|
|
716
|
+
this.db.prepare(sql).run(...values);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Delete a collection entity row.
|
|
721
|
+
*/
|
|
722
|
+
private deleteCollectionRow<T extends CollectionEntity>(
|
|
723
|
+
meta: CollectionMeta,
|
|
724
|
+
instance: T,
|
|
725
|
+
): void {
|
|
726
|
+
const idValue = (instance as Record<string, unknown>)[
|
|
727
|
+
meta.idProperty
|
|
728
|
+
] as SQLQueryBindings;
|
|
729
|
+
const sql = `DELETE FROM ${meta.table} WHERE ${meta.idColumn} = ?`;
|
|
730
|
+
this.db.prepare(sql).run(idValue);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* Populate a collection instance from a database row.
|
|
735
|
+
*/
|
|
736
|
+
private populateCollectionInstance<T extends CollectionEntity>(
|
|
737
|
+
instance: T,
|
|
738
|
+
meta: CollectionMeta,
|
|
739
|
+
row: Record<string, unknown>,
|
|
740
|
+
): void {
|
|
741
|
+
// Set id field
|
|
742
|
+
const rawId = row[meta.idColumn];
|
|
743
|
+
if (rawId !== null && rawId !== undefined) {
|
|
744
|
+
const idValue = deserializeValue(rawId, meta.idType);
|
|
745
|
+
(instance as Record<string, unknown>)[meta.idProperty] = idValue;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// Set all other fields
|
|
749
|
+
for (const field of meta.fields.values()) {
|
|
750
|
+
const rawValue = row[field.column];
|
|
751
|
+
if (rawValue !== null && rawValue !== undefined) {
|
|
752
|
+
const value = deserializeValue(rawValue, field.type);
|
|
753
|
+
(instance as Record<string, unknown>)[field.property] = value;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* Bind save() and delete() methods to a collection entity.
|
|
760
|
+
*/
|
|
761
|
+
private bindCollectionEntity<T extends CollectionEntity>(
|
|
762
|
+
instance: T,
|
|
763
|
+
meta: CollectionMeta,
|
|
764
|
+
): T {
|
|
765
|
+
const saveRow = this.saveCollectionRow.bind(this);
|
|
766
|
+
const deleteRow = this.deleteCollectionRow.bind(this);
|
|
767
|
+
|
|
768
|
+
// Override abstract methods with concrete implementations
|
|
769
|
+
(instance as unknown as { save: () => Promise<void> }).save =
|
|
770
|
+
async function (): Promise<void> {
|
|
771
|
+
saveRow(meta, instance);
|
|
772
|
+
};
|
|
773
|
+
|
|
774
|
+
(instance as unknown as { delete: () => Promise<void> }).delete =
|
|
775
|
+
async function (): Promise<void> {
|
|
776
|
+
deleteRow(meta, instance);
|
|
777
|
+
};
|
|
778
|
+
|
|
779
|
+
return instance;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// --------------------------------------------------------------------------
|
|
783
|
+
// Singleton private helpers
|
|
784
|
+
// --------------------------------------------------------------------------
|
|
785
|
+
|
|
106
786
|
private selectRow(
|
|
107
787
|
meta: ClassMeta,
|
|
108
788
|
key: string,
|