@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.
@@ -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 { ensureTable, migrateAdditive } from "./schema";
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,