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,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SyncConnection — WebSocket for receiving delta packets from the server.
|
|
3
|
+
*
|
|
4
|
+
* Delta packet processing:
|
|
5
|
+
* 1. Handle sync group changes (idempotent — triggers authoritative refetch)
|
|
6
|
+
* 2. Apply sync actions → IndexedDB (the ONLY way model tables get updated)
|
|
7
|
+
* 3. Apply sync actions → in-memory ObjectPool + rebase + cascade + invalidate
|
|
8
|
+
* 4. Advance lastSyncId
|
|
9
|
+
* 5. Resolve transactions waiting for this syncId
|
|
10
|
+
*
|
|
11
|
+
* Stale packets (syncId <= lastSyncId) skip steps 2-4: re-applying would
|
|
12
|
+
* clobber any newer state already in the pool. Group changes still run.
|
|
13
|
+
*
|
|
14
|
+
* Cascade delete (from BackReference metadata):
|
|
15
|
+
* When a model is deleted, find all BackReferences pointing to it and
|
|
16
|
+
* remove those "owned" models too. Also handle onDelete: "cascade" on References.
|
|
17
|
+
*
|
|
18
|
+
* Inverse-link maintenance:
|
|
19
|
+
* The ObjectPool keeps parent RefCollections / BackRefs in sync with the
|
|
20
|
+
* pool automatically — `pool.put` attaches and `pool.remove` detaches, and
|
|
21
|
+
* `BaseModel.hydrate` dispatches FK changes for in-pool models. SyncConnection
|
|
22
|
+
* only has to mutate the pool; parent collections track changes themselves.
|
|
23
|
+
*/
|
|
24
|
+
import type { StorageAdapter } from "./Database";
|
|
25
|
+
import { ObjectPool } from "./ObjectPool";
|
|
26
|
+
import { TransactionQueue } from "./TransactionQueue";
|
|
27
|
+
import { BaseSSEConnection, type SSEClientFactory, type SSEErrorReporter } from "./BaseSSEConnection";
|
|
28
|
+
export { type SSEClient, type SSEClientFactory, type SSEErrorReporter, createBrowserSSEFactory, } from "./BaseSSEConnection";
|
|
29
|
+
/**
|
|
30
|
+
* Encode each element then comma-join — the right shape for a list-of-
|
|
31
|
+
* strings inside a URL query parameter or a stable cache key. Commas
|
|
32
|
+
* inside an element become `%2C`, leaving the join-comma unambiguous.
|
|
33
|
+
* Encode-after-join would silently collapse `["a,b"]` and `["a", "b"]`
|
|
34
|
+
* into the same string.
|
|
35
|
+
*/
|
|
36
|
+
export declare function encodeCsvList(parts: ReadonlyArray<string>): string;
|
|
37
|
+
export interface SyncAction {
|
|
38
|
+
modelName: string;
|
|
39
|
+
modelId: string;
|
|
40
|
+
action: "I" | "U" | "D" | "A" | "V" | "C";
|
|
41
|
+
data?: Record<string, unknown>;
|
|
42
|
+
}
|
|
43
|
+
export interface DeltaPacket {
|
|
44
|
+
syncId: number;
|
|
45
|
+
syncActions: SyncAction[];
|
|
46
|
+
addedSyncGroups?: string[];
|
|
47
|
+
removedSyncGroups?: string[];
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Callback when new sync groups are added. StoreManager uses this to
|
|
51
|
+
* fetch all models scoped to the new groups from the server.
|
|
52
|
+
*/
|
|
53
|
+
export type SyncGroupChangeHandler = (addedGroups: string[], removedGroups: string[]) => Promise<void>;
|
|
54
|
+
/**
|
|
55
|
+
* Return null to drop the message. When not provided, raw payloads are
|
|
56
|
+
* assumed to already match `DeltaPacket`.
|
|
57
|
+
*/
|
|
58
|
+
export type SyncMessageTransform = (raw: unknown) => DeltaPacket | null | undefined;
|
|
59
|
+
/** Optional construction args for `SyncConnection`. The four required
|
|
60
|
+
* collaborators (url, database, pool, queue) stay positional. */
|
|
61
|
+
export interface SyncConnectionOptions {
|
|
62
|
+
onPacket?: (p: DeltaPacket) => void;
|
|
63
|
+
onSyncGroupsChanged?: SyncGroupChangeHandler;
|
|
64
|
+
isCollectionLoaded?: (modelName: string, indexKey: string, value: string) => boolean;
|
|
65
|
+
sseClientFactory?: SSEClientFactory;
|
|
66
|
+
transform?: SyncMessageTransform;
|
|
67
|
+
reportError?: SSEErrorReporter;
|
|
68
|
+
isModelFullyLoaded?: (modelName: string) => boolean;
|
|
69
|
+
/** Notified for every D/A action so StoreManager can tombstone deletes
|
|
70
|
+
* that arrive while a `getOrLoadAll` / `fetchDeferredModels` snapshot
|
|
71
|
+
* fetch is in flight. The implementation is expected to be a cheap no-op
|
|
72
|
+
* when no fetch is pending. */
|
|
73
|
+
recordInflightDelete?: (modelName: string, id: string) => void;
|
|
74
|
+
}
|
|
75
|
+
export declare class SyncConnection extends BaseSSEConnection {
|
|
76
|
+
private database;
|
|
77
|
+
private pool;
|
|
78
|
+
private queue;
|
|
79
|
+
private packetQueue;
|
|
80
|
+
private processing;
|
|
81
|
+
/** SyncId at which we last pruned `__syncActions`. Pruning fires every
|
|
82
|
+
* `SYNC_ACTION_PRUNE_STRIDE` syncIds rather than per-packet — opening a
|
|
83
|
+
* readwrite transaction is wasteful when nothing matches. */
|
|
84
|
+
private lastPrunedSyncId;
|
|
85
|
+
private onPacket?;
|
|
86
|
+
private onSyncGroupsChanged?;
|
|
87
|
+
private isCollectionLoaded?;
|
|
88
|
+
private transform?;
|
|
89
|
+
/** True when the adopter called `getOrLoadAll(modelName, ...)` (any
|
|
90
|
+
* scope) since the last bootstrap. SSE inserts for fully-loaded models
|
|
91
|
+
* always land in the pool — bypassing the per-FK `isCollectionLoaded`
|
|
92
|
+
* gate, which doesn't see `getOrLoadAll`'s sentinel coverage. */
|
|
93
|
+
private isModelFullyLoaded?;
|
|
94
|
+
private recordInflightDelete?;
|
|
95
|
+
constructor(url: string, database: StorageAdapter, pool: ObjectPool, queue: TransactionQueue, opts?: SyncConnectionOptions);
|
|
96
|
+
protected buildUrl(): string;
|
|
97
|
+
protected onMessage(data: string): void;
|
|
98
|
+
protected onReconnect(): void;
|
|
99
|
+
/** Queue a packet and drain sequentially. */
|
|
100
|
+
private enqueuePacket;
|
|
101
|
+
private processDeltaPacket;
|
|
102
|
+
private applySyncAction;
|
|
103
|
+
private shouldHydrateInsert;
|
|
104
|
+
private cascadeDelete;
|
|
105
|
+
}
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SyncConnection — WebSocket for receiving delta packets from the server.
|
|
3
|
+
*
|
|
4
|
+
* Delta packet processing:
|
|
5
|
+
* 1. Handle sync group changes (idempotent — triggers authoritative refetch)
|
|
6
|
+
* 2. Apply sync actions → IndexedDB (the ONLY way model tables get updated)
|
|
7
|
+
* 3. Apply sync actions → in-memory ObjectPool + rebase + cascade + invalidate
|
|
8
|
+
* 4. Advance lastSyncId
|
|
9
|
+
* 5. Resolve transactions waiting for this syncId
|
|
10
|
+
*
|
|
11
|
+
* Stale packets (syncId <= lastSyncId) skip steps 2-4: re-applying would
|
|
12
|
+
* clobber any newer state already in the pool. Group changes still run.
|
|
13
|
+
*
|
|
14
|
+
* Cascade delete (from BackReference metadata):
|
|
15
|
+
* When a model is deleted, find all BackReferences pointing to it and
|
|
16
|
+
* remove those "owned" models too. Also handle onDelete: "cascade" on References.
|
|
17
|
+
*
|
|
18
|
+
* Inverse-link maintenance:
|
|
19
|
+
* The ObjectPool keeps parent RefCollections / BackRefs in sync with the
|
|
20
|
+
* pool automatically — `pool.put` attaches and `pool.remove` detaches, and
|
|
21
|
+
* `BaseModel.hydrate` dispatches FK changes for in-pool models. SyncConnection
|
|
22
|
+
* only has to mutate the pool; parent collections track changes themselves.
|
|
23
|
+
*/
|
|
24
|
+
import { ModelRegistry } from "./ModelRegistry";
|
|
25
|
+
import { LoadStrategy, PropertyType } from "./types";
|
|
26
|
+
import { BaseSSEConnection, } from "./BaseSSEConnection";
|
|
27
|
+
// Re-export so existing imports from "@zerodrift/SyncConnection" keep working.
|
|
28
|
+
export { createBrowserSSEFactory, } from "./BaseSSEConnection";
|
|
29
|
+
/** How many syncIds back to retain in the SyncAction store before pruning.
|
|
30
|
+
* Covers short offline gaps where a persisted pending tx asks "was my
|
|
31
|
+
* target deleted while I was away?" on next reconnect. */
|
|
32
|
+
const SYNC_ACTION_PRUNE_MARGIN = 10000;
|
|
33
|
+
/** Run a prune sweep at most every Nth syncId of advancement — opening a
|
|
34
|
+
* readwrite transaction per packet is wasteful when nothing matches. */
|
|
35
|
+
const SYNC_ACTION_PRUNE_STRIDE = 1000;
|
|
36
|
+
/**
|
|
37
|
+
* Encode each element then comma-join — the right shape for a list-of-
|
|
38
|
+
* strings inside a URL query parameter or a stable cache key. Commas
|
|
39
|
+
* inside an element become `%2C`, leaving the join-comma unambiguous.
|
|
40
|
+
* Encode-after-join would silently collapse `["a,b"]` and `["a", "b"]`
|
|
41
|
+
* into the same string.
|
|
42
|
+
*/
|
|
43
|
+
export function encodeCsvList(parts) {
|
|
44
|
+
return parts.map(encodeURIComponent).join(",");
|
|
45
|
+
}
|
|
46
|
+
export class SyncConnection extends BaseSSEConnection {
|
|
47
|
+
constructor(url, database, pool, queue, opts = {}) {
|
|
48
|
+
super(url, opts.sseClientFactory, opts.reportError);
|
|
49
|
+
this.database = database;
|
|
50
|
+
this.pool = pool;
|
|
51
|
+
this.queue = queue;
|
|
52
|
+
// Serializes packet processing to prevent interleaved async mutations.
|
|
53
|
+
this.packetQueue = [];
|
|
54
|
+
this.processing = false;
|
|
55
|
+
/** SyncId at which we last pruned `__syncActions`. Pruning fires every
|
|
56
|
+
* `SYNC_ACTION_PRUNE_STRIDE` syncIds rather than per-packet — opening a
|
|
57
|
+
* readwrite transaction is wasteful when nothing matches. */
|
|
58
|
+
this.lastPrunedSyncId = 0;
|
|
59
|
+
this.onPacket = opts.onPacket;
|
|
60
|
+
this.onSyncGroupsChanged = opts.onSyncGroupsChanged;
|
|
61
|
+
this.isCollectionLoaded = opts.isCollectionLoaded;
|
|
62
|
+
this.transform = opts.transform;
|
|
63
|
+
this.isModelFullyLoaded = opts.isModelFullyLoaded;
|
|
64
|
+
this.recordInflightDelete = opts.recordInflightDelete;
|
|
65
|
+
}
|
|
66
|
+
buildUrl() {
|
|
67
|
+
const meta = this.database.currentMeta;
|
|
68
|
+
const lastSyncId = meta?.lastSyncId ?? 0;
|
|
69
|
+
const syncGroups = encodeCsvList(meta?.subscribedSyncGroups ?? []);
|
|
70
|
+
// Tell the server which models we're subscribed to (catchup + live
|
|
71
|
+
// stream; absent → no filter). Union of always-subscribed (Eager +
|
|
72
|
+
// Ephemeral, see ModelRegistry) with adapter-tracked loadedModels.
|
|
73
|
+
// Sort for a stable URL — equivalent sets must produce identical URLs
|
|
74
|
+
// so the engine doesn't churn reconnects when iteration order shifts.
|
|
75
|
+
const subscribed = [
|
|
76
|
+
...new Set([
|
|
77
|
+
...ModelRegistry.alwaysSubscribedModelNames(),
|
|
78
|
+
...this.database.loadedModels,
|
|
79
|
+
]),
|
|
80
|
+
].sort();
|
|
81
|
+
const onlyModels = subscribed.length > 0 ? `&onlyModels=${encodeCsvList(subscribed)}` : "";
|
|
82
|
+
return `${this.url}?lastSyncId=${lastSyncId}&syncGroups=${syncGroups}${onlyModels}`;
|
|
83
|
+
}
|
|
84
|
+
onMessage(data) {
|
|
85
|
+
const raw = JSON.parse(data);
|
|
86
|
+
const packet = this.transform != null ? this.transform(raw) : raw;
|
|
87
|
+
if (packet == null) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
this.enqueuePacket(packet);
|
|
91
|
+
}
|
|
92
|
+
onReconnect() {
|
|
93
|
+
// Fire-and-forget: resend is best-effort and self-heals on the next
|
|
94
|
+
// reconnect (the cache is durable), so a rejection here — typically the
|
|
95
|
+
// teardown/closing-DB race — must not become an unhandledRejection.
|
|
96
|
+
// resendCached() already reports its own domain failures via the queue's
|
|
97
|
+
// error reporter; this catch only absorbs the unexpected throw.
|
|
98
|
+
void this.queue.resendCached().catch(() => { });
|
|
99
|
+
}
|
|
100
|
+
// =========================================================================
|
|
101
|
+
// Sequential packet processing
|
|
102
|
+
// =========================================================================
|
|
103
|
+
/** Queue a packet and drain sequentially. */
|
|
104
|
+
async enqueuePacket(packet) {
|
|
105
|
+
this.packetQueue.push(packet);
|
|
106
|
+
if (this.processing) {
|
|
107
|
+
return;
|
|
108
|
+
} // already draining
|
|
109
|
+
this.processing = true;
|
|
110
|
+
while (this.packetQueue.length > 0) {
|
|
111
|
+
const next = this.packetQueue.shift();
|
|
112
|
+
await this.processDeltaPacket(next);
|
|
113
|
+
}
|
|
114
|
+
this.processing = false;
|
|
115
|
+
}
|
|
116
|
+
// =========================================================================
|
|
117
|
+
// 7-step delta packet processing
|
|
118
|
+
// =========================================================================
|
|
119
|
+
async processDeltaPacket(packet) {
|
|
120
|
+
const meta = this.database.currentMeta;
|
|
121
|
+
if (meta == null) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
// Step 1: sync group changes → trigger scoped loading
|
|
125
|
+
let groupsChanged = false;
|
|
126
|
+
if ((packet.addedSyncGroups?.length ?? 0) > 0 ||
|
|
127
|
+
(packet.removedSyncGroups?.length ?? 0) > 0) {
|
|
128
|
+
groupsChanged = true;
|
|
129
|
+
const groups = new Set(meta.subscribedSyncGroups);
|
|
130
|
+
for (const g of packet.addedSyncGroups ?? []) {
|
|
131
|
+
groups.add(g);
|
|
132
|
+
}
|
|
133
|
+
for (const g of packet.removedSyncGroups ?? []) {
|
|
134
|
+
groups.delete(g);
|
|
135
|
+
}
|
|
136
|
+
meta.subscribedSyncGroups = [...groups];
|
|
137
|
+
// Fetch models scoped to the new sync groups.
|
|
138
|
+
// e.g. user joined a new team → fetch all Issues/Comments for that team.
|
|
139
|
+
if (this.onSyncGroupsChanged != null) {
|
|
140
|
+
await this.onSyncGroupsChanged(packet.addedSyncGroups ?? [], packet.removedSyncGroups ?? []);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// Stale packets (syncId <= lastSyncId): these actions would clobber newer pool state.
|
|
144
|
+
const advanced = packet.syncId > meta.lastSyncId;
|
|
145
|
+
if (advanced) {
|
|
146
|
+
// Step 2: apply to IndexedDB (server is SSOT — IDB mirrors it)
|
|
147
|
+
for (const action of packet.syncActions) {
|
|
148
|
+
const actionMeta = ModelRegistry.getModelMeta(action.modelName);
|
|
149
|
+
if (actionMeta?.loadStrategy === LoadStrategy.Ephemeral) {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (["I", "U", "V", "C"].includes(action.action) &&
|
|
153
|
+
action.data != null) {
|
|
154
|
+
await this.database.writeModels(action.modelName, [
|
|
155
|
+
{ id: action.modelId, ...action.data },
|
|
156
|
+
]);
|
|
157
|
+
}
|
|
158
|
+
else if (action.action === "D" || action.action === "A") {
|
|
159
|
+
await this.database.deleteModel(action.modelName, action.modelId);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// Step 2b: persist sync-action headers for crash recovery — lets the
|
|
163
|
+
// queue (a) recognize an ack-syncId already arrived, (b) detect that
|
|
164
|
+
// a pending tx's target was deleted before flush. All actions in a
|
|
165
|
+
// packet share `packet.syncId`.
|
|
166
|
+
await this.database.recordSyncActions(packet.syncActions.map((a) => ({
|
|
167
|
+
syncId: packet.syncId,
|
|
168
|
+
modelName: a.modelName,
|
|
169
|
+
modelId: a.modelId,
|
|
170
|
+
action: a.action,
|
|
171
|
+
})));
|
|
172
|
+
// Step 3: apply to in-memory + rebase + cascade. Each action may need to
|
|
173
|
+
// read from IDB to decide whether to hydrate a not-yet-pooled model
|
|
174
|
+
// whose update brings it into a loaded scope, so this is async.
|
|
175
|
+
for (const action of packet.syncActions) {
|
|
176
|
+
await this.applySyncAction(action);
|
|
177
|
+
}
|
|
178
|
+
// Step 4: advance lastSyncId
|
|
179
|
+
meta.lastSyncId = packet.syncId;
|
|
180
|
+
}
|
|
181
|
+
if (advanced || groupsChanged) {
|
|
182
|
+
await this.database.saveMeta(meta);
|
|
183
|
+
}
|
|
184
|
+
// Step 5: resolve transactions
|
|
185
|
+
this.queue.resolveBySync(packet.syncId);
|
|
186
|
+
// Step 6: prune the SyncAction store. Recovery only needs recent
|
|
187
|
+
// history — anything well below `lastSyncId` is safe to drop. The
|
|
188
|
+
// 10k-syncId margin covers short offline gaps where a persisted-but-
|
|
189
|
+
// unsent tx checks the log for a delete of its target. We prune every
|
|
190
|
+
// ~1000 syncIds rather than per-packet to avoid opening a readwrite
|
|
191
|
+
// transaction when nothing matches.
|
|
192
|
+
if (packet.syncId > SYNC_ACTION_PRUNE_MARGIN &&
|
|
193
|
+
packet.syncId - this.lastPrunedSyncId >= SYNC_ACTION_PRUNE_STRIDE) {
|
|
194
|
+
this.lastPrunedSyncId = packet.syncId;
|
|
195
|
+
void this.database.pruneSyncActionsBelow(packet.syncId - SYNC_ACTION_PRUNE_MARGIN);
|
|
196
|
+
}
|
|
197
|
+
this.onPacket?.(packet);
|
|
198
|
+
}
|
|
199
|
+
// =========================================================================
|
|
200
|
+
// Apply a single sync action to the in-memory ObjectPool
|
|
201
|
+
// =========================================================================
|
|
202
|
+
async applySyncAction(action) {
|
|
203
|
+
const modelMeta = ModelRegistry.getModelMeta(action.modelName);
|
|
204
|
+
if (modelMeta == null) {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
switch (action.action) {
|
|
208
|
+
case "I": {
|
|
209
|
+
if (action.data == null) {
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
const existing = this.pool.getById(action.modelName, action.modelId);
|
|
213
|
+
if (existing != null) {
|
|
214
|
+
existing.hydrate(action.data);
|
|
215
|
+
this.pool.put(action.modelName, existing);
|
|
216
|
+
}
|
|
217
|
+
else if (this.shouldHydrateInsert(modelMeta, action.data)) {
|
|
218
|
+
this.pool.hydrateAndPut(action.modelName, modelMeta, {
|
|
219
|
+
id: action.modelId,
|
|
220
|
+
...action.data,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
this.queue.rebaseAll(action.modelId, action.modelName, action.data);
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
case "U":
|
|
227
|
+
case "V":
|
|
228
|
+
case "C": {
|
|
229
|
+
if (action.data == null) {
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
const model = this.pool.getById(action.modelName, action.modelId);
|
|
233
|
+
if (model != null) {
|
|
234
|
+
model.hydrate(action.data);
|
|
235
|
+
this.pool.put(action.modelName, model);
|
|
236
|
+
}
|
|
237
|
+
else if (modelMeta.loadStrategy !== LoadStrategy.Ephemeral) {
|
|
238
|
+
// Dependents loader: the model isn't in the pool, but the update
|
|
239
|
+
// may have moved it into a scope we already track. Step 2 wrote the
|
|
240
|
+
// merged record to IDB; read it back and let `shouldHydrateInsert`
|
|
241
|
+
// decide whether to hydrate based on the post-update FK values.
|
|
242
|
+
// (Ephemeral models skip IDB entirely in step 2, so there's never
|
|
243
|
+
// anything to read.)
|
|
244
|
+
const idbRecord = await this.database.readModel(action.modelName, action.modelId);
|
|
245
|
+
if (idbRecord != null &&
|
|
246
|
+
this.shouldHydrateInsert(modelMeta, idbRecord)) {
|
|
247
|
+
this.pool.hydrateAndPut(action.modelName, modelMeta, idbRecord);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
this.queue.rebaseAll(action.modelId, action.modelName, action.data);
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
case "D":
|
|
254
|
+
case "A": {
|
|
255
|
+
// Tombstone the id so any in-flight `getOrLoadAll` /
|
|
256
|
+
// `fetchDeferredModels` snapshot fetch drops a stale resurrection
|
|
257
|
+
// when its older snapshot still includes the now-deleted record.
|
|
258
|
+
this.recordInflightDelete?.(action.modelName, action.modelId);
|
|
259
|
+
// Cascade delete: remove BackReference-owned models
|
|
260
|
+
this.cascadeDelete(action.modelName, action.modelId);
|
|
261
|
+
// Pool.remove detaches the model from any parent RefCollections / BackRefs
|
|
262
|
+
this.pool.remove(action.modelName, action.modelId);
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
// =========================================================================
|
|
268
|
+
// On-demand hydration guard
|
|
269
|
+
//
|
|
270
|
+
// For non-Eager models, SSE inserts should only enter the pool if the
|
|
271
|
+
// relevant collection has already been loaded this session. Otherwise the
|
|
272
|
+
// insert is written to IDB (step 4) and will be picked up the next time
|
|
273
|
+
// getOrLoadCollection is called for that parent.
|
|
274
|
+
// =========================================================================
|
|
275
|
+
shouldHydrateInsert(modelMeta, data) {
|
|
276
|
+
// No checker registered → behave as before (hydrate everything)
|
|
277
|
+
if (this.isCollectionLoaded == null) {
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
// Eager models always go into the pool — they were bootstrapped in full
|
|
281
|
+
if (modelMeta.loadStrategy === LoadStrategy.Eager) {
|
|
282
|
+
return true;
|
|
283
|
+
}
|
|
284
|
+
// `getOrLoadAll` recorded "we want every instance of this model" via a
|
|
285
|
+
// sentinel coverage entry. SSE inserts must land in the pool too,
|
|
286
|
+
// otherwise observers reading via `useRecords(Model)` miss the row
|
|
287
|
+
// until the next explicit `getOrLoadAll` call refreshes from IDB.
|
|
288
|
+
if (this.isModelFullyLoaded?.(modelMeta.name) === true) {
|
|
289
|
+
return true;
|
|
290
|
+
}
|
|
291
|
+
// For on-demand models, hydrate only if the parent collection has been loaded
|
|
292
|
+
for (const [propName, propMeta] of modelMeta.properties) {
|
|
293
|
+
if (propMeta.type !== PropertyType.Reference ||
|
|
294
|
+
propMeta.referenceTo == null) {
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
const parentId = data[propName];
|
|
298
|
+
if (parentId != null &&
|
|
299
|
+
this.isCollectionLoaded(modelMeta.name, propName, parentId)) {
|
|
300
|
+
return true;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
// =========================================================================
|
|
306
|
+
// Cascade delete
|
|
307
|
+
//
|
|
308
|
+
// Walk all registered models. For each BackReference that points to the
|
|
309
|
+
// deleted model's type, remove instances where the inverse key matches.
|
|
310
|
+
// Also cascade for References with onDelete: "cascade".
|
|
311
|
+
// =========================================================================
|
|
312
|
+
cascadeDelete(deletedModelName, deletedModelId) {
|
|
313
|
+
for (const meta of ModelRegistry.allModels()) {
|
|
314
|
+
for (const [, propMeta] of meta.properties) {
|
|
315
|
+
// BackReference cascade: "owned by" the deleted model
|
|
316
|
+
if (propMeta.type === PropertyType.BackReference &&
|
|
317
|
+
propMeta.referenceTo === deletedModelName) {
|
|
318
|
+
const inverseKey = propMeta.inverseOf;
|
|
319
|
+
const toDelete = this.pool
|
|
320
|
+
.getAll(meta.name)
|
|
321
|
+
.filter((m) => m[inverseKey] ===
|
|
322
|
+
deletedModelId);
|
|
323
|
+
for (const m of toDelete) {
|
|
324
|
+
this.pool.remove(meta.name, m.id);
|
|
325
|
+
}
|
|
326
|
+
if (meta.loadStrategy !== LoadStrategy.Ephemeral) {
|
|
327
|
+
this.database.deleteModels(meta.name, toDelete.map((m) => m.id)); // fire and forget
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
// Reference with onDelete: "cascade"
|
|
331
|
+
if (propMeta.type === PropertyType.Reference &&
|
|
332
|
+
propMeta.referenceTo === deletedModelName &&
|
|
333
|
+
propMeta.onDelete === "cascade") {
|
|
334
|
+
const toDelete = this.pool
|
|
335
|
+
.getAll(meta.name)
|
|
336
|
+
.filter((m) => m[propMeta.name] ===
|
|
337
|
+
deletedModelId);
|
|
338
|
+
for (const m of toDelete) {
|
|
339
|
+
this.pool.remove(meta.name, m.id);
|
|
340
|
+
}
|
|
341
|
+
if (meta.loadStrategy !== LoadStrategy.Ephemeral) {
|
|
342
|
+
this.database.deleteModels(meta.name, toDelete.map((m) => m.id)); // fire and forget
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transaction types with batchId for multi-model grouped undo and rebase support.
|
|
3
|
+
*
|
|
4
|
+
* batchId groups transactions from one user action (e.g. moveIssueToTeam
|
|
5
|
+
* updates Issue.teamId AND Team.issueCount). undo() reverts the entire batch.
|
|
6
|
+
*
|
|
7
|
+
* Rebasing (UpdateTransaction): when a delta packet conflicts with our local
|
|
8
|
+
* change, the server value becomes our new baseline and our value is re-applied.
|
|
9
|
+
*/
|
|
10
|
+
import type { BaseModel } from "./BaseModel";
|
|
11
|
+
import { TransactionState, type PropertyChange } from "./types";
|
|
12
|
+
export declare abstract class BaseTransaction {
|
|
13
|
+
readonly id: `${string}-${string}-${string}-${string}-${string}`;
|
|
14
|
+
readonly modelId: string;
|
|
15
|
+
readonly modelName: string;
|
|
16
|
+
readonly timestamp: number;
|
|
17
|
+
abstract readonly action: "I" | "U" | "D" | "A";
|
|
18
|
+
state: TransactionState;
|
|
19
|
+
batchId: string | null;
|
|
20
|
+
syncIdNeededForCompletion: number | null;
|
|
21
|
+
idbKey: number | null;
|
|
22
|
+
constructor(modelId: string, modelName: string);
|
|
23
|
+
markCompleted(syncId: number): void;
|
|
24
|
+
isSyncedBy(syncId: number): boolean;
|
|
25
|
+
abstract revert(model: BaseModel): void;
|
|
26
|
+
abstract serialize(): Record<string, unknown>;
|
|
27
|
+
}
|
|
28
|
+
export declare class UpdateTransaction extends BaseTransaction {
|
|
29
|
+
readonly action: "U";
|
|
30
|
+
readonly changes: Map<string, PropertyChange>;
|
|
31
|
+
constructor(modelId: string, modelName: string, changes: Record<string, PropertyChange>);
|
|
32
|
+
revert(model: BaseModel): void;
|
|
33
|
+
/**
|
|
34
|
+
* Last-writer-wins rebase: update baseline to server value, re-apply ours.
|
|
35
|
+
* Only rebases fields where the server value differs from our intended newValue —
|
|
36
|
+
* an echo of our own change (serverValue === newValue) must not overwrite oldValue,
|
|
37
|
+
* as that would corrupt the undo baseline.
|
|
38
|
+
*/
|
|
39
|
+
rebase(model: BaseModel, serverData: Record<string, unknown>): void;
|
|
40
|
+
/** Returns true only when the server data has a field we're changing AND the
|
|
41
|
+
* server's value differs from our intended newValue (i.e. a real conflict,
|
|
42
|
+
* not just an echo of our own change). */
|
|
43
|
+
conflictsWith(data: Record<string, unknown>): boolean;
|
|
44
|
+
serialize(): {
|
|
45
|
+
id: `${string}-${string}-${string}-${string}-${string}`;
|
|
46
|
+
action: "U";
|
|
47
|
+
batchId: string | null;
|
|
48
|
+
modelId: string;
|
|
49
|
+
modelName: string;
|
|
50
|
+
timestamp: number;
|
|
51
|
+
changes: {
|
|
52
|
+
[k: string]: PropertyChange;
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
export declare class CreateTransaction extends BaseTransaction {
|
|
57
|
+
readonly action: "I";
|
|
58
|
+
readonly data: Record<string, unknown>;
|
|
59
|
+
constructor(modelId: string, modelName: string, data: Record<string, unknown>);
|
|
60
|
+
revert(): void;
|
|
61
|
+
serialize(): {
|
|
62
|
+
id: `${string}-${string}-${string}-${string}-${string}`;
|
|
63
|
+
action: "I";
|
|
64
|
+
batchId: string | null;
|
|
65
|
+
modelId: string;
|
|
66
|
+
modelName: string;
|
|
67
|
+
timestamp: number;
|
|
68
|
+
data: Record<string, unknown>;
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
export declare class DeleteTransaction extends BaseTransaction {
|
|
72
|
+
readonly action: "D";
|
|
73
|
+
readonly snapshot: Record<string, unknown>;
|
|
74
|
+
constructor(modelId: string, modelName: string, snapshot: Record<string, unknown>);
|
|
75
|
+
revert(model: BaseModel): void;
|
|
76
|
+
serialize(): {
|
|
77
|
+
id: `${string}-${string}-${string}-${string}-${string}`;
|
|
78
|
+
action: "D";
|
|
79
|
+
batchId: string | null;
|
|
80
|
+
modelId: string;
|
|
81
|
+
modelName: string;
|
|
82
|
+
timestamp: number;
|
|
83
|
+
snapshot: Record<string, unknown>;
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* A side-effect that's already been committed via a non-model API call (e.g. a
|
|
88
|
+
* server bulk-mutation endpoint that returns a `changeLogId`). Lives on the
|
|
89
|
+
* undo stack alongside `BaseTransaction`s so user actions that mix model edits
|
|
90
|
+
* and remote calls can be undone atomically. Reverted via the consumer's
|
|
91
|
+
* `undoableActions.undo` handler — the engine itself doesn't know how.
|
|
92
|
+
*/
|
|
93
|
+
export interface UndoableAction {
|
|
94
|
+
id: string;
|
|
95
|
+
changeLogId: string;
|
|
96
|
+
actionType?: string;
|
|
97
|
+
metadata?: Record<string, unknown>;
|
|
98
|
+
timestamp: number;
|
|
99
|
+
}
|
|
100
|
+
export declare class ArchiveTransaction extends BaseTransaction {
|
|
101
|
+
readonly action: "A";
|
|
102
|
+
readonly snapshot: Record<string, unknown>;
|
|
103
|
+
constructor(modelId: string, modelName: string, snapshot: Record<string, unknown>);
|
|
104
|
+
revert(model: BaseModel): void;
|
|
105
|
+
serialize(): {
|
|
106
|
+
id: `${string}-${string}-${string}-${string}-${string}`;
|
|
107
|
+
action: "A";
|
|
108
|
+
batchId: string | null;
|
|
109
|
+
modelId: string;
|
|
110
|
+
modelName: string;
|
|
111
|
+
timestamp: number;
|
|
112
|
+
snapshot: Record<string, unknown>;
|
|
113
|
+
};
|
|
114
|
+
}
|