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.
- package/LICENSE +21 -0
- package/README.md +358 -0
- package/dist/core/BaseModel.d.ts +76 -0
- package/dist/core/BaseModel.js +505 -0
- package/dist/core/BaseSSEConnection.d.ts +31 -0
- package/dist/core/BaseSSEConnection.js +91 -0
- package/dist/core/BatchModelLoader.d.ts +27 -0
- package/dist/core/BatchModelLoader.js +70 -0
- package/dist/core/CompoundIndexFetcher.d.ts +46 -0
- package/dist/core/CompoundIndexFetcher.js +177 -0
- package/dist/core/Database.d.ts +303 -0
- package/dist/core/Database.js +837 -0
- package/dist/core/LazyCollection.d.ts +168 -0
- package/dist/core/LazyCollection.js +403 -0
- package/dist/core/LazyOwnedCollection.d.ts +35 -0
- package/dist/core/LazyOwnedCollection.js +66 -0
- package/dist/core/MemoryAdapter.d.ts +67 -0
- package/dist/core/MemoryAdapter.js +243 -0
- package/dist/core/ModelRegistry.d.ts +64 -0
- package/dist/core/ModelRegistry.js +217 -0
- package/dist/core/ModelStream.d.ts +33 -0
- package/dist/core/ModelStream.js +68 -0
- package/dist/core/ObjectPool.d.ts +113 -0
- package/dist/core/ObjectPool.js +339 -0
- package/dist/core/Store.d.ts +40 -0
- package/dist/core/Store.js +73 -0
- package/dist/core/StoreManager.d.ts +839 -0
- package/dist/core/StoreManager.js +2034 -0
- package/dist/core/SyncConnection.d.ts +105 -0
- package/dist/core/SyncConnection.js +348 -0
- package/dist/core/Transaction.d.ts +114 -0
- package/dist/core/Transaction.js +147 -0
- package/dist/core/TransactionQueue.d.ts +110 -0
- package/dist/core/TransactionQueue.js +601 -0
- package/dist/core/decorators.d.ts +66 -0
- package/dist/core/decorators.js +278 -0
- package/dist/core/hash.d.ts +6 -0
- package/dist/core/hash.js +12 -0
- package/dist/core/index.d.ts +16 -0
- package/dist/core/index.js +18 -0
- package/dist/core/internal.d.ts +27 -0
- package/dist/core/internal.js +25 -0
- package/dist/core/observability.d.ts +21 -0
- package/dist/core/observability.js +66 -0
- package/dist/core/refAccessors.d.ts +43 -0
- package/dist/core/refAccessors.js +80 -0
- package/dist/core/serializers.d.ts +2 -0
- package/dist/core/serializers.js +2 -0
- package/dist/core/types.d.ts +320 -0
- package/dist/core/types.js +84 -0
- package/dist/react/index.d.ts +82 -0
- package/dist/react/index.js +373 -0
- package/dist/schema/builders.d.ts +29 -0
- package/dist/schema/builders.js +81 -0
- package/dist/schema/compile.d.ts +28 -0
- package/dist/schema/compile.js +334 -0
- package/dist/schema/createStore.d.ts +235 -0
- package/dist/schema/createStore.js +264 -0
- package/dist/schema/extend.d.ts +46 -0
- package/dist/schema/extend.js +6 -0
- package/dist/schema/index.d.ts +13 -0
- package/dist/schema/index.js +8 -0
- package/dist/schema/infer.d.ts +102 -0
- package/dist/schema/infer.js +1 -0
- package/dist/schema/types.d.ts +76 -0
- package/dist/schema/types.js +1 -0
- package/dist/schema/zod.d.ts +90 -0
- package/dist/schema/zod.js +101 -0
- 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 {};
|