zerodrift 1.0.0

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.
Files changed (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +358 -0
  3. package/dist/core/BaseModel.d.ts +76 -0
  4. package/dist/core/BaseModel.js +505 -0
  5. package/dist/core/BaseSSEConnection.d.ts +31 -0
  6. package/dist/core/BaseSSEConnection.js +91 -0
  7. package/dist/core/BatchModelLoader.d.ts +27 -0
  8. package/dist/core/BatchModelLoader.js +70 -0
  9. package/dist/core/CompoundIndexFetcher.d.ts +46 -0
  10. package/dist/core/CompoundIndexFetcher.js +177 -0
  11. package/dist/core/Database.d.ts +303 -0
  12. package/dist/core/Database.js +837 -0
  13. package/dist/core/LazyCollection.d.ts +168 -0
  14. package/dist/core/LazyCollection.js +403 -0
  15. package/dist/core/LazyOwnedCollection.d.ts +35 -0
  16. package/dist/core/LazyOwnedCollection.js +66 -0
  17. package/dist/core/MemoryAdapter.d.ts +67 -0
  18. package/dist/core/MemoryAdapter.js +243 -0
  19. package/dist/core/ModelRegistry.d.ts +64 -0
  20. package/dist/core/ModelRegistry.js +217 -0
  21. package/dist/core/ModelStream.d.ts +33 -0
  22. package/dist/core/ModelStream.js +68 -0
  23. package/dist/core/ObjectPool.d.ts +113 -0
  24. package/dist/core/ObjectPool.js +339 -0
  25. package/dist/core/Store.d.ts +40 -0
  26. package/dist/core/Store.js +73 -0
  27. package/dist/core/StoreManager.d.ts +839 -0
  28. package/dist/core/StoreManager.js +2034 -0
  29. package/dist/core/SyncConnection.d.ts +105 -0
  30. package/dist/core/SyncConnection.js +348 -0
  31. package/dist/core/Transaction.d.ts +114 -0
  32. package/dist/core/Transaction.js +147 -0
  33. package/dist/core/TransactionQueue.d.ts +110 -0
  34. package/dist/core/TransactionQueue.js +601 -0
  35. package/dist/core/decorators.d.ts +66 -0
  36. package/dist/core/decorators.js +278 -0
  37. package/dist/core/hash.d.ts +6 -0
  38. package/dist/core/hash.js +12 -0
  39. package/dist/core/index.d.ts +16 -0
  40. package/dist/core/index.js +18 -0
  41. package/dist/core/internal.d.ts +27 -0
  42. package/dist/core/internal.js +25 -0
  43. package/dist/core/observability.d.ts +21 -0
  44. package/dist/core/observability.js +66 -0
  45. package/dist/core/refAccessors.d.ts +43 -0
  46. package/dist/core/refAccessors.js +80 -0
  47. package/dist/core/serializers.d.ts +2 -0
  48. package/dist/core/serializers.js +2 -0
  49. package/dist/core/types.d.ts +320 -0
  50. package/dist/core/types.js +84 -0
  51. package/dist/react/index.d.ts +82 -0
  52. package/dist/react/index.js +373 -0
  53. package/dist/schema/builders.d.ts +29 -0
  54. package/dist/schema/builders.js +81 -0
  55. package/dist/schema/compile.d.ts +28 -0
  56. package/dist/schema/compile.js +334 -0
  57. package/dist/schema/createStore.d.ts +235 -0
  58. package/dist/schema/createStore.js +264 -0
  59. package/dist/schema/extend.d.ts +46 -0
  60. package/dist/schema/extend.js +6 -0
  61. package/dist/schema/index.d.ts +13 -0
  62. package/dist/schema/index.js +8 -0
  63. package/dist/schema/infer.d.ts +102 -0
  64. package/dist/schema/infer.js +1 -0
  65. package/dist/schema/types.d.ts +76 -0
  66. package/dist/schema/types.js +1 -0
  67. package/dist/schema/zod.d.ts +90 -0
  68. package/dist/schema/zod.js +101 -0
  69. package/package.json +99 -0
@@ -0,0 +1,601 @@
1
+ /**
2
+ * TransactionQueue — manages transaction lifecycle and batch undo.
3
+ *
4
+ * Three queues:
5
+ * pending → created, not yet sent
6
+ * executing → sent to server, awaiting response
7
+ * awaitingSync → server ACK'd, waiting for delta packet with syncId
8
+ *
9
+ * Batch undo:
10
+ * beginBatch() opens a batch. All save() calls inside share a batchId.
11
+ * endBatch() closes it. undo() pops the entire batch and reverts all.
12
+ *
13
+ * The undo stack stores "entries" — either a single tx or a batch of txs.
14
+ */
15
+ import { ModelRegistry } from "./ModelRegistry";
16
+ import { toError } from "./types";
17
+ import { BaseTransaction, UpdateTransaction, CreateTransaction, DeleteTransaction, ArchiveTransaction, } from "./Transaction";
18
+ import { TransactionState } from "./types";
19
+ const isAction = (e) => !(e instanceof BaseTransaction);
20
+ export class TransactionQueue {
21
+ constructor(database, pool, undoLimit = 100) {
22
+ this.sender = null;
23
+ // The three queues
24
+ this.pending = [];
25
+ this.executing = [];
26
+ this.awaitingSync = [];
27
+ // Undo/redo
28
+ this.undoStack = [];
29
+ this.redoStack = [];
30
+ // Active batch state. `activeBatchEntries` mixes BaseTransactions and
31
+ // UndoableActions so a single user action that combines model edits and
32
+ // remote API calls undoes as one unit, in reverse insertion order.
33
+ this.activeBatchId = null;
34
+ this.activeBatchEntries = [];
35
+ this.actionHandlers = null;
36
+ // When true, enqueue() and endBatch() skip undo stack mutations.
37
+ // Set during undo/redo so their inverse operations don't re-enter the stack.
38
+ this.suppressUndoStack = false;
39
+ // Flush timer
40
+ this.flushTimer = null;
41
+ this.flushDelay = 50; // ms — batches rapid saves
42
+ this.listeners = new Set();
43
+ this.reportError = null;
44
+ this.database = database;
45
+ this.pool = pool;
46
+ this.undoLimit = undoLimit;
47
+ }
48
+ setErrorReporter(reporter) {
49
+ this.reportError = reporter;
50
+ }
51
+ setSender(sender) {
52
+ this.sender = sender;
53
+ }
54
+ setActionHandlers(handlers) {
55
+ this.actionHandlers = handlers;
56
+ }
57
+ subscribe(listener) {
58
+ this.listeners.add(listener);
59
+ return () => {
60
+ this.listeners.delete(listener);
61
+ };
62
+ }
63
+ notify() {
64
+ this.listeners.forEach((listener) => listener());
65
+ }
66
+ // ── Batch API ─────────────────────────────────────────────────────────────
67
+ beginBatch() {
68
+ if (this.activeBatchId != null) {
69
+ throw new Error(`Nested batches are not supported. Active batch "${this.activeBatchId}" ` +
70
+ `must end before opening another batch.`);
71
+ }
72
+ const batchId = crypto.randomUUID();
73
+ this.activeBatchId = batchId;
74
+ this.activeBatchEntries = [];
75
+ return batchId;
76
+ }
77
+ endBatch(batchId) {
78
+ if (this.activeBatchId !== batchId) {
79
+ return;
80
+ }
81
+ if (this.activeBatchEntries.length > 0 && !this.suppressUndoStack) {
82
+ this.undoStack.push({
83
+ kind: "batch",
84
+ batchId,
85
+ entries: [...this.activeBatchEntries],
86
+ });
87
+ if (this.undoStack.length > this.undoLimit) {
88
+ this.undoStack.shift();
89
+ }
90
+ this.redoStack = [];
91
+ this.notify();
92
+ }
93
+ this.activeBatchId = null;
94
+ this.activeBatchEntries = [];
95
+ }
96
+ get hasActiveBatch() {
97
+ return this.activeBatchId != null;
98
+ }
99
+ // ── Enqueue methods (one per transaction type) ────────────────────────────
100
+ async enqueueUpdate(modelId, modelName, changes) {
101
+ const tx = new UpdateTransaction(modelId, modelName, changes);
102
+ await this.enqueue(tx);
103
+ return tx;
104
+ }
105
+ async enqueueCreate(modelId, modelName, data) {
106
+ await this.enqueue(new CreateTransaction(modelId, modelName, data));
107
+ }
108
+ async enqueueDelete(model) {
109
+ const meta = ModelRegistry.getMetaForInstance(model);
110
+ const tx = new DeleteTransaction(model.id, meta?.name ?? "Unknown", model.serialize());
111
+ if (meta != null) {
112
+ this.pool.remove(meta.name, model.id);
113
+ } // optimistic removal
114
+ await this.enqueue(tx);
115
+ }
116
+ /** Record an already-committed remote side-effect on the undo stack. No
117
+ * pending/executing/awaitingSync involvement and no IDB caching — the
118
+ * consumer's API call already happened, so there's nothing to resend. */
119
+ enqueueAction(action) {
120
+ if (this.activeBatchId != null) {
121
+ this.activeBatchEntries.push(action);
122
+ return;
123
+ }
124
+ if (this.suppressUndoStack) {
125
+ return;
126
+ }
127
+ this.undoStack.push({ kind: "single", item: action });
128
+ if (this.undoStack.length > this.undoLimit) {
129
+ this.undoStack.shift();
130
+ }
131
+ this.redoStack = [];
132
+ this.notify();
133
+ }
134
+ async enqueueArchive(model) {
135
+ const meta = ModelRegistry.getMetaForInstance(model);
136
+ const tx = new ArchiveTransaction(model.id, meta?.name ?? "Unknown", model.serialize());
137
+ if (meta != null) {
138
+ this.pool.remove(meta.name, model.id);
139
+ }
140
+ await this.enqueue(tx);
141
+ }
142
+ async enqueue(tx) {
143
+ tx.state = TransactionState.Pending;
144
+ // Tag with batch if one is active
145
+ if (this.activeBatchId != null) {
146
+ tx.batchId = this.activeBatchId;
147
+ this.activeBatchEntries.push(tx);
148
+ }
149
+ else if (!this.suppressUndoStack) {
150
+ this.undoStack.push({ kind: "single", item: tx });
151
+ if (this.undoStack.length > this.undoLimit) {
152
+ this.undoStack.shift();
153
+ }
154
+ this.redoStack = [];
155
+ this.notify();
156
+ }
157
+ // Add to pending and schedule flush synchronously so callers can immediately
158
+ // inspect pendingCount without waiting for the IDB cache write to complete.
159
+ this.pending.push(tx);
160
+ this.scheduleFlush();
161
+ // Cache in IDB for offline resilience (async — idbKey needed only for resendCached)
162
+ tx.idbKey = await this.database.cacheTransaction(tx.serialize());
163
+ }
164
+ // ── Flush — send batch to server ──────────────────────────────────────────
165
+ scheduleFlush() {
166
+ if (this.flushTimer != null) {
167
+ return;
168
+ }
169
+ this.flushTimer = setTimeout(() => this.flush(), this.flushDelay);
170
+ }
171
+ async flush() {
172
+ this.flushTimer = null;
173
+ if (this.pending.length === 0 || this.sender == null) {
174
+ return;
175
+ }
176
+ const batch = [...this.pending];
177
+ this.pending = [];
178
+ batch.forEach((tx) => (tx.state = TransactionState.Executing));
179
+ this.executing.push(...batch);
180
+ try {
181
+ const response = await this.sender(batch.map((tx) => tx.serialize()));
182
+ this.executing = this.executing.filter((tx) => !batch.includes(tx));
183
+ const batchKeys = batch
184
+ .map((tx) => tx.idbKey)
185
+ .filter((k) => k != null);
186
+ if (response.success) {
187
+ // Don't delete cached records on ACK — flag them as awaiting the
188
+ // server's syncId. If the client crashes here, recovery checks the
189
+ // SyncAction store; if the matching delta already arrived, the tx
190
+ // is dropped without resending. The cached record is removed on
191
+ // resolveBySync (when the matching SSE delta hits this tab).
192
+ for (const tx of batch) {
193
+ tx.markCompleted(response.lastSyncId);
194
+ this.awaitingSync.push(tx);
195
+ if (tx.idbKey != null) {
196
+ const cached = {
197
+ ...tx.serialize(),
198
+ syncIdNeededForCompletion: response.lastSyncId,
199
+ };
200
+ await this.database.updateCachedTransaction(tx.idbKey, cached);
201
+ }
202
+ }
203
+ }
204
+ else {
205
+ // Server rejected — revert first, then remove from IDB so failed
206
+ // transactions don't replay on next app start via resendCached()
207
+ for (let i = batch.length - 1; i >= 0; i--) {
208
+ batch[i].state = TransactionState.Failed;
209
+ this.revertOne(batch[i]);
210
+ }
211
+ await this.database.deleteCachedTransactions(batchKeys);
212
+ }
213
+ }
214
+ catch (err) {
215
+ // Network error — put back in pending for retry
216
+ this.executing = this.executing.filter((tx) => !batch.includes(tx));
217
+ batch.forEach((tx) => (tx.state = TransactionState.Pending));
218
+ this.pending = [...batch, ...this.pending];
219
+ setTimeout(() => this.scheduleFlush(), 2000);
220
+ this.reportError?.(toError(err), {
221
+ kind: "transactionSend",
222
+ batchSize: batch.length,
223
+ });
224
+ }
225
+ }
226
+ // ── Sync completion (called by SyncConnection on delta packet) ────────────
227
+ resolveBySync(receivedSyncId) {
228
+ if (this.awaitingSync.length === 0) {
229
+ return [];
230
+ }
231
+ const resolved = [];
232
+ const remaining = [];
233
+ for (const tx of this.awaitingSync) {
234
+ if (tx.isSyncedBy(receivedSyncId)) {
235
+ tx.state = TransactionState.Completed;
236
+ resolved.push(tx);
237
+ }
238
+ else {
239
+ remaining.push(tx);
240
+ }
241
+ }
242
+ this.awaitingSync = remaining;
243
+ // Drop the resolved txs' cached records — they're no longer needed for
244
+ // crash recovery (the awaited delta is now persisted in __syncActions).
245
+ const idbKeys = resolved
246
+ .map((tx) => tx.idbKey)
247
+ .filter((k) => k != null);
248
+ if (idbKeys.length > 0) {
249
+ void this.database.deleteCachedTransactions(idbKeys);
250
+ }
251
+ return resolved;
252
+ }
253
+ // ── Rebasing (called by SyncConnection for I/U/V/C actions) ───────────────
254
+ rebaseAll(modelId, modelName, serverData) {
255
+ const model = this.pool.getById(modelName, modelId);
256
+ if (model == null) {
257
+ return;
258
+ }
259
+ // Check all active queues for conflicting UpdateTransactions
260
+ const allActive = [
261
+ ...this.pending,
262
+ ...this.executing,
263
+ ...this.awaitingSync,
264
+ ];
265
+ for (const tx of allActive) {
266
+ if (tx instanceof UpdateTransaction &&
267
+ tx.modelId === modelId &&
268
+ tx.modelName === modelName &&
269
+ tx.conflictsWith(serverData)) {
270
+ tx.rebase(model, serverData);
271
+ }
272
+ }
273
+ }
274
+ // ── Revert a single transaction ───────────────────────────────────────────
275
+ revertOne(tx) {
276
+ if (tx instanceof UpdateTransaction) {
277
+ const model = this.pool.getById(tx.modelName, tx.modelId);
278
+ if (model != null) {
279
+ tx.revert(model);
280
+ this.pool.put(tx.modelName, model);
281
+ }
282
+ }
283
+ else if (tx instanceof CreateTransaction) {
284
+ this.pool.remove(tx.modelName, tx.modelId);
285
+ }
286
+ else if (tx instanceof DeleteTransaction ||
287
+ tx instanceof ArchiveTransaction) {
288
+ const meta = ModelRegistry.getModelMeta(tx.modelName);
289
+ if (meta != null) {
290
+ const inst = new meta.ctor();
291
+ tx.revert(inst);
292
+ this.pool.put(tx.modelName, inst);
293
+ }
294
+ }
295
+ }
296
+ // ── Undo/Redo — batch-aware, mixed tx + action entries ──────────────────
297
+ itemsOf(entry) {
298
+ return entry.kind === "single" ? [entry.item] : entry.entries;
299
+ }
300
+ /** Build the inverse-stack entry from a list of replaced items. Single-item
301
+ * entries collapse to `single`, otherwise reuse the original `batchId`. */
302
+ wrapEntry(original, items) {
303
+ if (original.kind === "single") {
304
+ return { kind: "single", item: items[0] };
305
+ }
306
+ return { kind: "batch", batchId: original.batchId, entries: items };
307
+ }
308
+ /** Run the consumer's action handler, surfacing failures through
309
+ * `reportError`. Returns the compensating action for the opposite stack —
310
+ * the handler's return value, or the original action on void/error. */
311
+ async invokeActionHandler(action, phase) {
312
+ const handler = this.actionHandlers?.[phase];
313
+ if (handler == null) {
314
+ this.reportError?.(new Error(`No ${phase} handler configured for undoable actions`), {
315
+ kind: "undoableAction",
316
+ phase,
317
+ changeLogId: action.changeLogId,
318
+ actionType: action.actionType,
319
+ });
320
+ return action;
321
+ }
322
+ try {
323
+ const result = await handler(action);
324
+ return result ?? action;
325
+ }
326
+ catch (err) {
327
+ this.reportError?.(toError(err), {
328
+ kind: "undoableAction",
329
+ phase,
330
+ changeLogId: action.changeLogId,
331
+ actionType: action.actionType,
332
+ });
333
+ return action;
334
+ }
335
+ }
336
+ /** Reverse a single transaction and enqueue the inverse server call. */
337
+ async revertTx(tx) {
338
+ if (tx instanceof UpdateTransaction) {
339
+ const model = this.pool.getById(tx.modelName, tx.modelId);
340
+ if (model == null) {
341
+ return;
342
+ }
343
+ tx.revert(model);
344
+ this.pool.put(tx.modelName, model);
345
+ const inverse = {};
346
+ for (const [p, c] of tx.changes) {
347
+ inverse[p] = { oldValue: c.newValue, newValue: c.oldValue };
348
+ }
349
+ await this.enqueueUpdate(tx.modelId, tx.modelName, inverse);
350
+ }
351
+ else if (tx instanceof CreateTransaction) {
352
+ const model = this.pool.getById(tx.modelName, tx.modelId);
353
+ if (model != null) {
354
+ await this.enqueueDelete(model);
355
+ }
356
+ }
357
+ else if (tx instanceof DeleteTransaction) {
358
+ const meta = ModelRegistry.getModelMeta(tx.modelName);
359
+ if (meta != null) {
360
+ this.pool.hydrateAndPut(tx.modelName, meta, tx.snapshot);
361
+ await this.enqueueCreate(tx.modelId, tx.modelName, tx.snapshot);
362
+ }
363
+ }
364
+ // ArchiveTransaction has no inverse enqueue today — pass through.
365
+ }
366
+ /** Re-apply a single transaction and enqueue the forward server call. */
367
+ async replayTx(tx) {
368
+ if (tx instanceof UpdateTransaction) {
369
+ const model = this.pool.getById(tx.modelName, tx.modelId);
370
+ if (model == null) {
371
+ return;
372
+ }
373
+ const changes = {};
374
+ for (const [p, c] of tx.changes) {
375
+ // Dynamic property assignment on BaseModel — no better type for runtime field access
376
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
377
+ model[p] = c.newValue;
378
+ changes[p] = { oldValue: c.oldValue, newValue: c.newValue };
379
+ }
380
+ this.pool.put(tx.modelName, model);
381
+ await this.enqueueUpdate(tx.modelId, tx.modelName, changes);
382
+ }
383
+ else if (tx instanceof DeleteTransaction) {
384
+ const model = this.pool.getById(tx.modelName, tx.modelId);
385
+ if (model != null) {
386
+ await this.enqueueDelete(model);
387
+ }
388
+ }
389
+ else if (tx instanceof CreateTransaction) {
390
+ const meta = ModelRegistry.getModelMeta(tx.modelName);
391
+ if (meta != null) {
392
+ this.pool.hydrateAndPut(tx.modelName, meta, tx.data);
393
+ await this.enqueueCreate(tx.modelId, tx.modelName, tx.data);
394
+ }
395
+ }
396
+ }
397
+ /** Walk an entry's items in `direction`, applying tx reverts/replays and
398
+ * delegating actions to the consumer's handler. Returns the compensating
399
+ * items for the opposite stack, in original insertion order. */
400
+ async processEntry(entry, direction) {
401
+ const items = this.itemsOf(entry);
402
+ const swaps = new Map();
403
+ // suppressUndoStack prevents inverse txs (and any handler-side re-entries)
404
+ // from polluting the active stack while we replay.
405
+ this.suppressUndoStack = true;
406
+ const batchId = this.beginBatch();
407
+ try {
408
+ const apply = direction === "undo" ? this.revertTx : this.replayTx;
409
+ const order = direction === "undo" ? -1 : 1;
410
+ for (let i = direction === "undo" ? items.length - 1 : 0; i >= 0 && i < items.length; i += order) {
411
+ const item = items[i];
412
+ if (isAction(item)) {
413
+ swaps.set(item, await this.invokeActionHandler(item, direction));
414
+ }
415
+ else {
416
+ await apply.call(this, item);
417
+ }
418
+ }
419
+ }
420
+ finally {
421
+ this.endBatch(batchId);
422
+ this.suppressUndoStack = false;
423
+ }
424
+ return items.map((x) => (isAction(x) ? swaps.get(x) ?? x : x));
425
+ }
426
+ partition(items) {
427
+ const txs = [];
428
+ const actions = [];
429
+ for (const x of items) {
430
+ if (isAction(x)) {
431
+ actions.push(x);
432
+ }
433
+ else {
434
+ txs.push(x);
435
+ }
436
+ }
437
+ return { txs, actions };
438
+ }
439
+ async undo() {
440
+ const entry = this.undoStack.pop();
441
+ if (entry == null) {
442
+ return null;
443
+ }
444
+ const replayed = await this.processEntry(entry, "undo");
445
+ this.redoStack.push(this.wrapEntry(entry, replayed));
446
+ this.notify();
447
+ return this.partition(this.itemsOf(entry));
448
+ }
449
+ async redo() {
450
+ const entry = this.redoStack.pop();
451
+ if (entry == null) {
452
+ return null;
453
+ }
454
+ const replayed = await this.processEntry(entry, "redo");
455
+ this.undoStack.push(this.wrapEntry(entry, replayed));
456
+ this.notify();
457
+ return this.partition(this.itemsOf(entry));
458
+ }
459
+ // ── Reconnection ──────────────────────────────────────────────────────────
460
+ async resendCached() {
461
+ const cached = await this.database.getCachedTransactions();
462
+ if (cached.length === 0) {
463
+ return 0;
464
+ }
465
+ // Build a signature set for transactions already in-flight, pending, or awaiting sync.
466
+ // If a transaction is currently being sent (executing) or already queued (pending),
467
+ // re-enqueueing from IDB would send a duplicate to the server.
468
+ const inFlight = new Set();
469
+ for (const tx of [
470
+ ...this.pending,
471
+ ...this.executing,
472
+ ...this.awaitingSync,
473
+ ]) {
474
+ inFlight.add(`${tx.action}:${tx.modelName}:${tx.modelId}`);
475
+ }
476
+ // Walk the cached records once. For each:
477
+ // - syncIdNeededForCompletion present + matching SSE delta already
478
+ // persisted → drop (server ack'd, delta arrived).
479
+ // - syncIdNeededForCompletion present + no matching delta yet →
480
+ // restore to awaitingSync (do NOT resend; just wait for the delta).
481
+ // - No syncId set → it's still pending. If the target model has been
482
+ // deleted/archived since the tx was queued, drop it and emit a
483
+ // transactionDiscarded error. Otherwise rebuild and re-enqueue.
484
+ const dropKeys = [];
485
+ let count = 0;
486
+ for (const entry of cached) {
487
+ const d = entry.data;
488
+ const idbKey = entry.idbKey;
489
+ const inFlightKey = `${d.action}:${d.modelName}:${d.modelId}`;
490
+ if (d.syncIdNeededForCompletion != null) {
491
+ if (await this.database.hasSyncAction(d.syncIdNeededForCompletion)) {
492
+ dropKeys.push(idbKey);
493
+ continue;
494
+ }
495
+ if (inFlight.has(inFlightKey)) {
496
+ dropKeys.push(idbKey);
497
+ continue;
498
+ }
499
+ const tx = this.rebuildTransaction(d);
500
+ if (tx == null) {
501
+ dropKeys.push(idbKey);
502
+ continue;
503
+ }
504
+ tx.idbKey = idbKey;
505
+ tx.markCompleted(d.syncIdNeededForCompletion);
506
+ this.awaitingSync.push(tx);
507
+ continue;
508
+ }
509
+ if (inFlight.has(inFlightKey)) {
510
+ dropKeys.push(idbKey);
511
+ continue;
512
+ }
513
+ // Pending tx — check whether the target was deleted/archived in our absence.
514
+ if (d.action === "U" || d.action === "D" || d.action === "A") {
515
+ const actions = await this.database.findSyncActionsForModel(d.modelName, d.modelId);
516
+ if (actions.some((a) => a.action === "D" || a.action === "A")) {
517
+ dropKeys.push(idbKey);
518
+ this.reportError?.(new Error(`Discarded persisted ${d.action} for ${d.modelName} ${d.modelId}: target was deleted`), {
519
+ kind: "transactionDiscarded",
520
+ modelName: d.modelName,
521
+ modelId: d.modelId,
522
+ action: d.action,
523
+ reason: "target-deleted",
524
+ });
525
+ continue;
526
+ }
527
+ }
528
+ const tx = this.rebuildTransaction(d);
529
+ if (tx == null) {
530
+ dropKeys.push(idbKey);
531
+ continue;
532
+ }
533
+ tx.idbKey = idbKey;
534
+ this.pending.push(tx);
535
+ count++;
536
+ }
537
+ if (dropKeys.length > 0) {
538
+ await this.database.deleteCachedTransactions(dropKeys);
539
+ }
540
+ if (count > 0) {
541
+ this.scheduleFlush();
542
+ }
543
+ return count;
544
+ }
545
+ rebuildTransaction(d) {
546
+ let tx;
547
+ switch (d.action) {
548
+ case "U":
549
+ if (d.changes == null) {
550
+ return null;
551
+ }
552
+ tx = new UpdateTransaction(d.modelId, d.modelName, d.changes);
553
+ break;
554
+ case "I":
555
+ if (d.data == null) {
556
+ return null;
557
+ }
558
+ tx = new CreateTransaction(d.modelId, d.modelName, d.data);
559
+ break;
560
+ case "D":
561
+ if (d.snapshot == null) {
562
+ return null;
563
+ }
564
+ tx = new DeleteTransaction(d.modelId, d.modelName, d.snapshot);
565
+ break;
566
+ case "A":
567
+ if (d.snapshot == null) {
568
+ return null;
569
+ }
570
+ tx = new ArchiveTransaction(d.modelId, d.modelName, d.snapshot);
571
+ break;
572
+ default:
573
+ return null;
574
+ }
575
+ tx.batchId = d.batchId ?? null;
576
+ return tx;
577
+ }
578
+ destroy() {
579
+ if (this.flushTimer != null) {
580
+ clearTimeout(this.flushTimer);
581
+ this.flushTimer = null;
582
+ }
583
+ this.listeners.clear();
584
+ }
585
+ // ── Introspection ─────────────────────────────────────────────────────────
586
+ get pendingCount() {
587
+ return this.pending.length;
588
+ }
589
+ get executingCount() {
590
+ return this.executing.length;
591
+ }
592
+ get awaitingSyncCount() {
593
+ return this.awaitingSync.length;
594
+ }
595
+ get undoDepth() {
596
+ return this.undoStack.length;
597
+ }
598
+ get redoDepth() {
599
+ return this.redoStack.length;
600
+ }
601
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Decorators for defining models and their properties.
3
+ *
4
+ * Usage looks like:
5
+ *
6
+ * @ClientModel({ loadStrategy: LoadStrategy.Eager })
7
+ * class Issue extends BaseModel {
8
+ * @Property() title = "";
9
+ * @Reference("User", { nullable: true }) assignee: any;
10
+ * @Action moveToTeam(id: string) { ... }
11
+ * @Computed get identifier() { ... }
12
+ * }
13
+ *
14
+ * Each decorator registers metadata in the ModelRegistry at class-definition
15
+ * time. The engine reads that metadata later for serialization, hydration,
16
+ * observability, indexing, and reference resolution.
17
+ */
18
+ import { LoadStrategy } from "./types";
19
+ export declare function ClientModel(opts?: {
20
+ /**
21
+ * The registry name — what `ModelMeta.name` becomes, what
22
+ * cross-references resolve against, and the typed handle for
23
+ * `useRecord(Model, …)`. Defaults to `ctor.name`, which minifiers
24
+ * mangle in production: pass an explicit `name` (or configure your
25
+ * bundler's `keep_classnames`) for any shipped build.
26
+ */
27
+ name?: string;
28
+ loadStrategy?: LoadStrategy;
29
+ usedForPartialIndexes?: boolean;
30
+ schemaVersion?: number;
31
+ }): <T extends new (...args: any[]) => any>(ctor: T) => T;
32
+ export declare function Property(opts?: {
33
+ indexed?: boolean;
34
+ serializer?: (v: any) => any;
35
+ deserializer?: (v: any) => any;
36
+ }): (target: any, key: string) => void;
37
+ export declare function EphemeralProperty(): (target: any, key: string) => void;
38
+ interface ReferenceOpts {
39
+ nullable?: boolean;
40
+ idField?: string;
41
+ onDelete?: "cascade" | "nullify" | "restrict";
42
+ }
43
+ export declare function Reference(referenceTo: string, opts?: ReferenceOpts): (target: any, key: string) => void;
44
+ export declare function LazyReference(referenceTo: string, opts?: ReferenceOpts): (target: any, key: string) => void;
45
+ interface ReferenceCollectionOpts {
46
+ inverseOf?: string;
47
+ /**
48
+ * Names of additional FK fields on the parent model that should each become
49
+ * an extra query when the collection loads. The loader unions the results.
50
+ * Use for multi-axis lazy queries (e.g. "all comments for this issue PLUS
51
+ * everything in the sync groups the user belongs to").
52
+ */
53
+ coveringIndexes?: string[];
54
+ }
55
+ export declare function ReferenceCollection(referenceTo: string, opts?: ReferenceCollectionOpts): (target: any, key: string) => void;
56
+ export declare function LazyReferenceCollection(referenceTo: string, opts?: ReferenceCollectionOpts): (target: any, key: string) => void;
57
+ export declare function BackReference(referenceTo: string, inverseOf: string): (target: any, key: string) => void;
58
+ export declare function ReferenceArray(referenceTo: string): (target: any, key: string) => void;
59
+ interface OwnedCollectionOpts {
60
+ idsField: string;
61
+ }
62
+ export declare function OwnedCollection(referenceTo: string, opts: OwnedCollectionOpts): (target: any, key: string) => void;
63
+ export declare function LazyOwnedCollection(referenceTo: string, opts: OwnedCollectionOpts): (target: any, key: string) => void;
64
+ export declare function Action(target: any, key: string, _d: PropertyDescriptor): void;
65
+ export declare function Computed(target: any, key: string, _d: PropertyDescriptor): void;
66
+ export {};