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,505 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BaseModel — the base class for all sync engine models.
|
|
3
|
+
*
|
|
4
|
+
* Lifecycle:
|
|
5
|
+
* 1. `new Issue()` — raw construction, observability OFF
|
|
6
|
+
* 2. `issue.hydrate(data)` — populate flat values, recursive for embedded objects
|
|
7
|
+
* 3. `issue.makeModelObservable()` — create MobX boxes + RefCollections
|
|
8
|
+
* 4. `issue.title = "..."` — setter fires, tracked in pendingChanges
|
|
9
|
+
* 5. `issue.save()` — builds transaction, auto-commits to server
|
|
10
|
+
*
|
|
11
|
+
* makeModelObservable() creates the runtime relationship objects:
|
|
12
|
+
* - RefCollection for @ReferenceCollection properties
|
|
13
|
+
* - BackRef for @BackReference properties
|
|
14
|
+
* These are stored on __collections and __backRefs, read by the decorator getters.
|
|
15
|
+
*/
|
|
16
|
+
import { ModelRegistry } from "./ModelRegistry";
|
|
17
|
+
import { PropertyType, DEFAULT_TRANSIENT_INDEX_DEPTH, } from "./types";
|
|
18
|
+
import { RefCollection, BackRef } from "./LazyCollection";
|
|
19
|
+
import { OwnedRefs } from "./LazyOwnedCollection";
|
|
20
|
+
import { action, computed, observable, reaction, runInAction, } from "mobx";
|
|
21
|
+
// The four PropertyTypes that have MobX boxes and direct setters — the "flat scalar" fields.
|
|
22
|
+
// Used by makeModelObservable (to create boxes) and assign() (to filter writable keys).
|
|
23
|
+
const FLAT_PROPERTY_TYPES = new Set([
|
|
24
|
+
PropertyType.Property,
|
|
25
|
+
PropertyType.EphemeralProperty,
|
|
26
|
+
PropertyType.Reference,
|
|
27
|
+
PropertyType.ReferenceArray,
|
|
28
|
+
]);
|
|
29
|
+
export class BaseModel {
|
|
30
|
+
constructor() {
|
|
31
|
+
this.id = BaseModel.storeManager?.mintId(this) ?? crypto.randomUUID();
|
|
32
|
+
this.__mobx = {};
|
|
33
|
+
this.__observabilityEnabled = false;
|
|
34
|
+
this.store = null;
|
|
35
|
+
/** Runtime lazy collections, keyed by property name. */
|
|
36
|
+
this.__collections = {};
|
|
37
|
+
/** Runtime BackRefs, keyed by property name. Read by @BackReference getters. */
|
|
38
|
+
this.__backRefs = {};
|
|
39
|
+
this.pendingChanges = new Map();
|
|
40
|
+
}
|
|
41
|
+
// Backed by globalThis so it survives HMR module reloads in dev mode.
|
|
42
|
+
// Bundlers (webpack, Vite, etc.) re-execute modules on hot reload, which
|
|
43
|
+
// resets static field initializers — but globalThis is outside the module
|
|
44
|
+
// system and is never reset, keeping the live StoreManager reachable for
|
|
45
|
+
// new model instances created after a reload.
|
|
46
|
+
static get storeManager() {
|
|
47
|
+
return (globalThis
|
|
48
|
+
.__syncEngineStore ?? null);
|
|
49
|
+
}
|
|
50
|
+
static set storeManager(sm) {
|
|
51
|
+
globalThis.__syncEngineStore = sm ?? null;
|
|
52
|
+
}
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Change tracking
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
/**
|
|
57
|
+
* Set a property value without triggering change tracking or pendingChanges.
|
|
58
|
+
* Used by revert paths so that rolling back an optimistic update doesn't
|
|
59
|
+
* leave the model in a dirty state.
|
|
60
|
+
*/
|
|
61
|
+
setQuiet(propName, value) {
|
|
62
|
+
const box = this.__mobx[propName];
|
|
63
|
+
if (box != null) {
|
|
64
|
+
box.set(value);
|
|
65
|
+
}
|
|
66
|
+
this[`__raw_${propName}`] = value;
|
|
67
|
+
this.pendingChanges.delete(propName);
|
|
68
|
+
}
|
|
69
|
+
propertyChanged(propName, oldValue, newValue) {
|
|
70
|
+
if (oldValue === newValue) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const sm = BaseModel.storeManager;
|
|
74
|
+
sm?.registerAtomicTouch(this);
|
|
75
|
+
if (!this.pendingChanges.has(propName)) {
|
|
76
|
+
const wasClean = this.pendingChanges.size === 0;
|
|
77
|
+
const meta = ModelRegistry.getMetaForInstance(this);
|
|
78
|
+
const propMeta = meta?.properties.get(propName);
|
|
79
|
+
const serialized = propMeta?.serializer != null ? propMeta.serializer(oldValue) : oldValue;
|
|
80
|
+
this.pendingChanges.set(propName, serialized);
|
|
81
|
+
// Invalidate any OwnedCollections backed by this property so they
|
|
82
|
+
// re-resolve against the updated IDs array on next access.
|
|
83
|
+
if (meta != null) {
|
|
84
|
+
for (const [collectionName, ownedPropMeta] of meta.properties) {
|
|
85
|
+
if (ownedPropMeta.type === PropertyType.OwnedCollection &&
|
|
86
|
+
ownedPropMeta.idsField === propName) {
|
|
87
|
+
this.__collections[collectionName]?.invalidate();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
this.maintainParentLinks(meta.name, propName, oldValue, newValue);
|
|
91
|
+
}
|
|
92
|
+
// Clean→dirty transition: fire after parent links are consistent so an
|
|
93
|
+
// adopter materializing a draft scaffold sees correct inverse links.
|
|
94
|
+
if (wasClean && sm != null && sm.hasModelTouchedHandler && meta != null) {
|
|
95
|
+
sm.fireModelTouched(this, meta.name);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** Forward an FK change to the pool so it can re-route inverse links. No-op
|
|
100
|
+
* before the model has entered a pool. */
|
|
101
|
+
maintainParentLinks(modelName, propName, oldValue, newValue) {
|
|
102
|
+
const pool = this.store;
|
|
103
|
+
if (pool == null) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const oldId = typeof oldValue === "string" ? oldValue : null;
|
|
107
|
+
const newId = typeof newValue === "string" ? newValue : null;
|
|
108
|
+
pool.notifyReferenceChange(this, modelName, propName, oldId, newId);
|
|
109
|
+
}
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// makeModelObservable — create MobX boxes + relationship runtime objects
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
makeModelObservable() {
|
|
114
|
+
// Idempotent: re-running would replace the RefCollection /
|
|
115
|
+
// BackRef / OwnedRefs runtime objects, dropping
|
|
116
|
+
// their loaded items, and re-fire any non-lazy eager loads.
|
|
117
|
+
if (this.__observabilityEnabled) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
this.__observabilityEnabled = true;
|
|
121
|
+
const meta = ModelRegistry.getMetaForInstance(this);
|
|
122
|
+
if (meta == null) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
for (const [name, prop] of meta.properties) {
|
|
126
|
+
switch (prop.type) {
|
|
127
|
+
// ── Flat observable properties: create MobX boxes ──
|
|
128
|
+
case PropertyType.Property:
|
|
129
|
+
case PropertyType.EphemeralProperty:
|
|
130
|
+
case PropertyType.Reference:
|
|
131
|
+
case PropertyType.ReferenceArray: {
|
|
132
|
+
const rawValue = this[`__raw_${name}`];
|
|
133
|
+
let currentValue = rawValue;
|
|
134
|
+
// SWC (Next.js) compiles class fields using "define" semantics
|
|
135
|
+
// (Object.defineProperty), creating own data properties that shadow
|
|
136
|
+
// the prototype getter/setter installed by @Property. Delete them so
|
|
137
|
+
// the prototype accessor is reachable for all future reads and writes.
|
|
138
|
+
const ownDesc = Object.getOwnPropertyDescriptor(this, name);
|
|
139
|
+
if (ownDesc != null && "value" in ownDesc) {
|
|
140
|
+
if (currentValue === undefined) {
|
|
141
|
+
// hydrate() hasn't run yet (new model) — preserve the class field value.
|
|
142
|
+
currentValue = ownDesc.value;
|
|
143
|
+
}
|
|
144
|
+
delete this[name];
|
|
145
|
+
}
|
|
146
|
+
if (currentValue !== undefined) {
|
|
147
|
+
if (this.__mobx[name] != null) {
|
|
148
|
+
this.__mobx[name].set(currentValue);
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
// Create the box directly to avoid triggering propertyChanged.
|
|
152
|
+
this.__mobx[name] = observable.box(currentValue, { deep: false });
|
|
153
|
+
this[`__raw_${name}`] = currentValue;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (prop.type === PropertyType.Reference &&
|
|
157
|
+
prop.lazy === false &&
|
|
158
|
+
BaseModel.storeManager != null &&
|
|
159
|
+
typeof currentValue === "string" &&
|
|
160
|
+
currentValue !== "") {
|
|
161
|
+
const sm = BaseModel.storeManager;
|
|
162
|
+
const refTo = prop.referenceTo;
|
|
163
|
+
const id = currentValue;
|
|
164
|
+
sm.getOrLoadById(refTo, id).catch((err) => {
|
|
165
|
+
sm.emitError(err, {
|
|
166
|
+
kind: "eagerReferenceLoad",
|
|
167
|
+
modelName: refTo,
|
|
168
|
+
id,
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
// ── ReferenceCollection → create RefCollection ──
|
|
175
|
+
// e.g. Team.issues → RefCollection("Issue", "teamId")
|
|
176
|
+
// The collection's hydrate() stores the parent ID and computes
|
|
177
|
+
// the partial index values for future IDB queries — manual
|
|
178
|
+
// coveringIndexes plus auto-derived paths from the FK graph walk.
|
|
179
|
+
case PropertyType.ReferenceCollection: {
|
|
180
|
+
const depth = BaseModel.storeManager?.transientIndexDepth ??
|
|
181
|
+
DEFAULT_TRANSIENT_INDEX_DEPTH;
|
|
182
|
+
const derivedPaths = depth > 0
|
|
183
|
+
? ModelRegistry.getDerivedCoveringPaths(meta.name, prop.referenceTo, depth)
|
|
184
|
+
: [];
|
|
185
|
+
const collection = new RefCollection(prop.referenceTo, prop.inverseOf, prop.coveringIndexes ?? [], derivedPaths);
|
|
186
|
+
collection.hydrate(this);
|
|
187
|
+
// Wire loader from StoreManager (for async IDB/server loading)
|
|
188
|
+
if (BaseModel.storeManager != null) {
|
|
189
|
+
const sm = BaseModel.storeManager;
|
|
190
|
+
collection.setLoader(async (modelName, queries) => {
|
|
191
|
+
// Each axis is an independent IDB read; fire in parallel.
|
|
192
|
+
const batches = await Promise.all(queries.map((q) => sm.getOrLoadCollection(modelName, q.key, q.value)));
|
|
193
|
+
return batches.flat();
|
|
194
|
+
});
|
|
195
|
+
const parentModelName = meta.name;
|
|
196
|
+
const parentId = this.id;
|
|
197
|
+
const refTo = prop.referenceTo;
|
|
198
|
+
const isEager = prop.lazy === false;
|
|
199
|
+
collection.setOnError((err) => {
|
|
200
|
+
sm.emitError(err, {
|
|
201
|
+
kind: isEager ? "eagerCollectionLoad" : "lazyCollectionLoad",
|
|
202
|
+
modelName: refTo,
|
|
203
|
+
parentModelName,
|
|
204
|
+
parentId,
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
this.__collections[name] = collection;
|
|
209
|
+
if (prop.lazy === false && BaseModel.storeManager != null) {
|
|
210
|
+
void collection.load();
|
|
211
|
+
}
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
// ── OwnedCollection → create OwnedRefs ──
|
|
215
|
+
// e.g. Team.issues where Team has issueIds: string[]
|
|
216
|
+
// The idsGetter is a live function — reads the current array each time,
|
|
217
|
+
// so additions/removals to issueIds are always reflected.
|
|
218
|
+
case PropertyType.OwnedCollection: {
|
|
219
|
+
const idsField = prop.idsField;
|
|
220
|
+
const collection = new OwnedRefs(prop.referenceTo,
|
|
221
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
222
|
+
() => this[idsField] ?? []);
|
|
223
|
+
if (BaseModel.storeManager != null) {
|
|
224
|
+
const sm = BaseModel.storeManager;
|
|
225
|
+
collection.setLoader(async (modelName, ids) => {
|
|
226
|
+
return sm.getOrLoadByIds(modelName, ids);
|
|
227
|
+
});
|
|
228
|
+
const refTo = prop.referenceTo;
|
|
229
|
+
collection.setOnError((err) => {
|
|
230
|
+
sm.emitError(err, {
|
|
231
|
+
kind: "lazyOwnedCollectionLoad",
|
|
232
|
+
modelName: refTo,
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
this.__collections[name] = collection;
|
|
237
|
+
if (prop.lazy === false && BaseModel.storeManager != null) {
|
|
238
|
+
void collection.load();
|
|
239
|
+
}
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
// ── BackReference → create BackRef ──
|
|
243
|
+
// e.g. Issue.favorite → BackRef("Favorite", "issueId")
|
|
244
|
+
case PropertyType.BackReference: {
|
|
245
|
+
const backRef = new BackRef(prop.referenceTo, prop.inverseOf);
|
|
246
|
+
backRef.hydrate(this.id);
|
|
247
|
+
if (BaseModel.storeManager != null) {
|
|
248
|
+
const sm = BaseModel.storeManager;
|
|
249
|
+
backRef.setLoader(async (modelName, key, value) => {
|
|
250
|
+
const items = await sm.getOrLoadCollection(modelName, key, value);
|
|
251
|
+
return items[0] ?? null;
|
|
252
|
+
});
|
|
253
|
+
const refTo = prop.referenceTo;
|
|
254
|
+
const parentId = this.id;
|
|
255
|
+
backRef.setOnError((err) => {
|
|
256
|
+
sm.emitError(err, {
|
|
257
|
+
kind: "lazyBackRefLoad",
|
|
258
|
+
modelName: refTo,
|
|
259
|
+
parentId,
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
this.__backRefs[name] = backRef;
|
|
264
|
+
break;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
// ── Wire @Action methods with MobX action() ──
|
|
269
|
+
// Wraps the method so multiple property changes inside it are batched
|
|
270
|
+
// into a single MobX transaction (one re-render, not N).
|
|
271
|
+
for (const actionName of meta.actions) {
|
|
272
|
+
const original = this[actionName];
|
|
273
|
+
if (typeof original === "function") {
|
|
274
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
275
|
+
this[actionName] = action(original.bind(this));
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
// ── Wire @Computed getters with MobX computed() ──
|
|
279
|
+
// Memoizes the getter — re-evaluates only when its observed dependencies change.
|
|
280
|
+
for (const compName of meta.computedProps) {
|
|
281
|
+
const descriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(this), compName);
|
|
282
|
+
if (descriptor?.get != null) {
|
|
283
|
+
const fn = descriptor.get.bind(this);
|
|
284
|
+
const memo = computed(fn);
|
|
285
|
+
Object.defineProperty(this, compName, {
|
|
286
|
+
get: () => memo.get(),
|
|
287
|
+
configurable: true,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
// Save
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
save() {
|
|
296
|
+
const meta = ModelRegistry.getMetaForInstance(this);
|
|
297
|
+
if (this.store === null) {
|
|
298
|
+
BaseModel.storeManager?.commitCreate(this);
|
|
299
|
+
return {};
|
|
300
|
+
}
|
|
301
|
+
// Stamp updatedAt when there are actual changes, if the model declares it as a @Property.
|
|
302
|
+
if (this.pendingChanges.size > 0 &&
|
|
303
|
+
meta?.properties.has("updatedAt") === true) {
|
|
304
|
+
this["updatedAt"] = new Date();
|
|
305
|
+
}
|
|
306
|
+
const changes = {};
|
|
307
|
+
for (const [propName, oldSerialized] of this.pendingChanges) {
|
|
308
|
+
const propMeta = meta?.properties.get(propName);
|
|
309
|
+
const currentValue = this[propName];
|
|
310
|
+
const newSerialized = propMeta?.serializer != null
|
|
311
|
+
? propMeta.serializer(currentValue)
|
|
312
|
+
: currentValue;
|
|
313
|
+
changes[propName] = { oldValue: oldSerialized, newValue: newSerialized };
|
|
314
|
+
}
|
|
315
|
+
this.pendingChanges.clear();
|
|
316
|
+
if (BaseModel.storeManager != null && Object.keys(changes).length > 0) {
|
|
317
|
+
BaseModel.storeManager.commitUpdate(this.id, meta?.name ?? "Unknown", changes);
|
|
318
|
+
}
|
|
319
|
+
return changes;
|
|
320
|
+
}
|
|
321
|
+
get hasUnsavedChanges() {
|
|
322
|
+
return this.pendingChanges.size > 0;
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Revert all unsaved property changes to their last-saved values.
|
|
326
|
+
* Mirror of save() — where save() commits forward, this rolls back.
|
|
327
|
+
*/
|
|
328
|
+
discardUnsavedChanges() {
|
|
329
|
+
if (this.pendingChanges.size === 0) {
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
const meta = ModelRegistry.getMetaForInstance(this);
|
|
333
|
+
const entries = Array.from(this.pendingChanges);
|
|
334
|
+
runInAction(() => {
|
|
335
|
+
for (const [propName, serializedOldValue] of entries) {
|
|
336
|
+
const propMeta = meta?.properties.get(propName);
|
|
337
|
+
const deserialized = propMeta?.deserializer != null
|
|
338
|
+
? propMeta.deserializer(serializedOldValue)
|
|
339
|
+
: serializedOldValue;
|
|
340
|
+
this.setQuiet(propName, deserialized);
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
this.pendingChanges.clear();
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* React to property changes on this model without importing MobX.
|
|
347
|
+
* Use on models obtained from the pool — `objectPool.getById` / `objectPool.getAll`.
|
|
348
|
+
*
|
|
349
|
+
* @param selector - reads the property (or derived value) to observe
|
|
350
|
+
* @param callback - fires whenever the selector result changes; receives new and previous value
|
|
351
|
+
* @returns unwatch function — call it to stop observing
|
|
352
|
+
*/
|
|
353
|
+
watch(selector, callback) {
|
|
354
|
+
return reaction(() => selector(this), callback);
|
|
355
|
+
}
|
|
356
|
+
// ---------------------------------------------------------------------------
|
|
357
|
+
// Field assignment
|
|
358
|
+
// ---------------------------------------------------------------------------
|
|
359
|
+
/**
|
|
360
|
+
* Stage a bulk field assignment without committing. Changes land in
|
|
361
|
+
* `pendingChanges` and stay local until `save()` (or an enclosing
|
|
362
|
+
* `StoreManager.atomic()` / `store.batch()`) flushes them, or
|
|
363
|
+
* `discardUnsavedChanges()` rolls them back. This is the staging
|
|
364
|
+
* primitive behind `store.<entity>.draft(...)`.
|
|
365
|
+
*
|
|
366
|
+
* Only `@Property`, `@EphemeralProperty`, `@Reference` (ID fields), and
|
|
367
|
+
* `@ReferenceArray` fields are written — relationship objects and internals
|
|
368
|
+
* are ignored.
|
|
369
|
+
*/
|
|
370
|
+
assign(data) {
|
|
371
|
+
const meta = ModelRegistry.getMetaForInstance(this);
|
|
372
|
+
runInAction(() => {
|
|
373
|
+
for (const [key, value] of Object.entries(data)) {
|
|
374
|
+
if (key === "id") {
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
const propMeta = meta?.properties.get(key);
|
|
378
|
+
if (propMeta == null || !FLAT_PROPERTY_TYPES.has(propMeta.type)) {
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
this[key] = value;
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
// ---------------------------------------------------------------------------
|
|
386
|
+
// Hydration — flat values + recursive for embedded objects
|
|
387
|
+
// ---------------------------------------------------------------------------
|
|
388
|
+
/** @internal */
|
|
389
|
+
hydrate(data) {
|
|
390
|
+
const meta = ModelRegistry.getMetaForInstance(this);
|
|
391
|
+
// Wrap multi-field updates in a single MobX action so observers see one
|
|
392
|
+
// coherent transition for SSE deltas that touch many fields at once.
|
|
393
|
+
runInAction(() => {
|
|
394
|
+
for (const [key, value] of Object.entries(data)) {
|
|
395
|
+
if (key === "id") {
|
|
396
|
+
this.id = value;
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
const propMeta = meta?.properties.get(key);
|
|
400
|
+
// Recursive hydration: if a ReferenceModel property has an embedded object,
|
|
401
|
+
// create a model instance from it and put it in the pool.
|
|
402
|
+
if (propMeta?.type === PropertyType.ReferenceModel &&
|
|
403
|
+
value &&
|
|
404
|
+
typeof value === "object" &&
|
|
405
|
+
"id" in value) {
|
|
406
|
+
const nested = value;
|
|
407
|
+
this.hydrateNestedModel(propMeta.referenceTo, nested);
|
|
408
|
+
const idKey = propMeta.idField ?? key + "Id";
|
|
409
|
+
// Rebase: if the FK is being optimistically edited, keep the
|
|
410
|
+
// optimistic value visible but update its stored baseline to the
|
|
411
|
+
// server's value, so a later `discardUnsavedChanges()` lands on
|
|
412
|
+
// the rebased server truth rather than the stale pre-edit value.
|
|
413
|
+
if (this.pendingChanges.has(idKey)) {
|
|
414
|
+
this.pendingChanges.set(idKey, nested.id);
|
|
415
|
+
}
|
|
416
|
+
else {
|
|
417
|
+
this[`__raw_${idKey}`] = nested.id;
|
|
418
|
+
}
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
const deserialized = propMeta?.deserializer != null ? propMeta.deserializer(value) : value;
|
|
422
|
+
// Rebase path mirrors UpdateTransaction.rebase: pendingChanges holds
|
|
423
|
+
// the serialized baseline; an incoming server value that differs
|
|
424
|
+
// from our optimistic newValue overwrites it. Echo of our own change
|
|
425
|
+
// (server === optimistic) is a no-op.
|
|
426
|
+
if (this.pendingChanges.has(key)) {
|
|
427
|
+
const currentValue = this[key];
|
|
428
|
+
const optimisticSerialized = propMeta?.serializer != null
|
|
429
|
+
? propMeta.serializer(currentValue)
|
|
430
|
+
: currentValue;
|
|
431
|
+
const serverSerialized = propMeta?.serializer != null
|
|
432
|
+
? propMeta.serializer(deserialized)
|
|
433
|
+
: value;
|
|
434
|
+
if (serverSerialized !== optimisticSerialized) {
|
|
435
|
+
this.pendingChanges.set(key, serverSerialized);
|
|
436
|
+
}
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
const oldRawValue = this[`__raw_${key}`];
|
|
440
|
+
this[`__raw_${key}`] = deserialized;
|
|
441
|
+
const box = this.__mobx[key];
|
|
442
|
+
if (box != null) {
|
|
443
|
+
box.set(deserialized);
|
|
444
|
+
}
|
|
445
|
+
// box.set bypasses the prototype setter, so propertyChanged never fires
|
|
446
|
+
// for delta-driven hydrates. Dispatch parent-link maintenance directly
|
|
447
|
+
// so SSE-driven FK changes still wake the inverse RefCollection / BackRef.
|
|
448
|
+
if (this.store != null && meta != null) {
|
|
449
|
+
this.maintainParentLinks(meta.name, key, oldRawValue, deserialized);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
hydrateNestedModel(modelName, data) {
|
|
455
|
+
const pool = this.store ?? BaseModel.storeManager?.objectPool;
|
|
456
|
+
if (pool == null) {
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
const existing = pool.getById(modelName, data.id);
|
|
460
|
+
if (existing != null) {
|
|
461
|
+
existing.hydrate(data);
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
const refMeta = ModelRegistry.getModelMeta(modelName);
|
|
465
|
+
if (refMeta == null) {
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
const instance = new refMeta.ctor();
|
|
469
|
+
instance.hydrate(data); // recursive
|
|
470
|
+
instance.makeModelObservable();
|
|
471
|
+
pool.put(modelName, instance);
|
|
472
|
+
}
|
|
473
|
+
// ---------------------------------------------------------------------------
|
|
474
|
+
// Serialization
|
|
475
|
+
// ---------------------------------------------------------------------------
|
|
476
|
+
serialize() {
|
|
477
|
+
const meta = ModelRegistry.getMetaForInstance(this);
|
|
478
|
+
const out = {
|
|
479
|
+
id: this.id,
|
|
480
|
+
};
|
|
481
|
+
if (meta == null) {
|
|
482
|
+
return out;
|
|
483
|
+
}
|
|
484
|
+
for (const [name, prop] of meta.properties) {
|
|
485
|
+
if (prop.type === PropertyType.EphemeralProperty) {
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
if (prop.type === PropertyType.ReferenceModel) {
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
if (prop.type === PropertyType.ReferenceCollection) {
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
if (prop.type === PropertyType.BackReference) {
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
if (prop.type === PropertyType.OwnedCollection) {
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
const value = this[name];
|
|
501
|
+
out[name] = prop.serializer != null ? prop.serializer(value) : value;
|
|
502
|
+
}
|
|
503
|
+
return out;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type EngineErrorContext } from "./types";
|
|
2
|
+
export interface SSEClient {
|
|
3
|
+
onmessage: ((event: {
|
|
4
|
+
data: string;
|
|
5
|
+
}) => void) | null;
|
|
6
|
+
onerror: ((event?: any) => void) | null;
|
|
7
|
+
close(): void;
|
|
8
|
+
}
|
|
9
|
+
export type SSEClientFactory = (url: string) => SSEClient;
|
|
10
|
+
export type SSEErrorReporter = (err: Error, context: EngineErrorContext) => void;
|
|
11
|
+
export declare const createBrowserSSEFactory: (init?: EventSourceInit) => SSEClientFactory;
|
|
12
|
+
export declare abstract class BaseSSEConnection {
|
|
13
|
+
protected url: string;
|
|
14
|
+
private sseClientFactory;
|
|
15
|
+
private reportError?;
|
|
16
|
+
private eventSource;
|
|
17
|
+
private reconnectTimer;
|
|
18
|
+
private stopped;
|
|
19
|
+
constructor(url: string, sseClientFactory?: SSEClientFactory, reportError?: SSEErrorReporter | undefined);
|
|
20
|
+
connect(): void;
|
|
21
|
+
disconnect(): void;
|
|
22
|
+
reconnect(): void;
|
|
23
|
+
get isConnected(): boolean;
|
|
24
|
+
protected buildUrl(): string;
|
|
25
|
+
protected abstract onMessage(data: string): void;
|
|
26
|
+
protected onReconnect(): void;
|
|
27
|
+
protected onOpen(): void;
|
|
28
|
+
protected onClose(): void;
|
|
29
|
+
private openEventSource;
|
|
30
|
+
private scheduleReconnect;
|
|
31
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { toError } from "./types";
|
|
2
|
+
export const createBrowserSSEFactory = (init) => (url) => new EventSource(url, init);
|
|
3
|
+
export class BaseSSEConnection {
|
|
4
|
+
constructor(url, sseClientFactory = createBrowserSSEFactory(), reportError) {
|
|
5
|
+
this.url = url;
|
|
6
|
+
this.sseClientFactory = sseClientFactory;
|
|
7
|
+
this.reportError = reportError;
|
|
8
|
+
this.eventSource = null;
|
|
9
|
+
this.reconnectTimer = null;
|
|
10
|
+
this.stopped = false;
|
|
11
|
+
}
|
|
12
|
+
connect() {
|
|
13
|
+
this.openEventSource();
|
|
14
|
+
}
|
|
15
|
+
disconnect() {
|
|
16
|
+
// Permanent teardown. Block any in-flight or future reconnect: a pending
|
|
17
|
+
// `onerror` triggered by the close() below — or a timer already scheduled
|
|
18
|
+
// before this call — must not re-open the source after the owning
|
|
19
|
+
// StoreManager has torn down its Database. That post-teardown reopen is
|
|
20
|
+
// what surfaces as "the database connection is closing".
|
|
21
|
+
this.stopped = true;
|
|
22
|
+
if (this.reconnectTimer != null) {
|
|
23
|
+
clearTimeout(this.reconnectTimer);
|
|
24
|
+
this.reconnectTimer = null;
|
|
25
|
+
}
|
|
26
|
+
if (this.eventSource != null) {
|
|
27
|
+
this.eventSource.onerror = null;
|
|
28
|
+
this.eventSource.close();
|
|
29
|
+
this.eventSource = null;
|
|
30
|
+
this.onClose();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
reconnect() {
|
|
34
|
+
this.openEventSource();
|
|
35
|
+
}
|
|
36
|
+
get isConnected() {
|
|
37
|
+
return this.eventSource != null;
|
|
38
|
+
}
|
|
39
|
+
buildUrl() {
|
|
40
|
+
return this.url;
|
|
41
|
+
}
|
|
42
|
+
onReconnect() { }
|
|
43
|
+
onOpen() { }
|
|
44
|
+
onClose() { }
|
|
45
|
+
openEventSource() {
|
|
46
|
+
if (this.stopped) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (this.eventSource != null) {
|
|
50
|
+
this.eventSource.close();
|
|
51
|
+
this.eventSource = null;
|
|
52
|
+
this.onClose();
|
|
53
|
+
}
|
|
54
|
+
const url = this.buildUrl();
|
|
55
|
+
try {
|
|
56
|
+
this.eventSource = this.sseClientFactory(url);
|
|
57
|
+
this.eventSource.onmessage = (e) => {
|
|
58
|
+
try {
|
|
59
|
+
this.onMessage(e.data);
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
this.reportError?.(toError(err), {
|
|
63
|
+
kind: "ssePacketParse",
|
|
64
|
+
url,
|
|
65
|
+
raw: e.data,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
this.eventSource.onerror = () => {
|
|
70
|
+
this.eventSource?.close();
|
|
71
|
+
this.eventSource = null;
|
|
72
|
+
this.onClose();
|
|
73
|
+
this.scheduleReconnect();
|
|
74
|
+
};
|
|
75
|
+
this.onOpen();
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
this.reportError?.(toError(err), { kind: "sseConstruction", url });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
scheduleReconnect() {
|
|
82
|
+
if (this.stopped || this.reconnectTimer != null) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
this.reconnectTimer = setTimeout(() => {
|
|
86
|
+
this.reconnectTimer = null;
|
|
87
|
+
this.openEventSource();
|
|
88
|
+
this.onReconnect();
|
|
89
|
+
}, 3000);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coalesces concurrent on-demand index queries into a single server call.
|
|
3
|
+
* Used by `StoreManager.getOrLoadCollection` when an `onDemandIndexBatchFetcher`
|
|
4
|
+
* is configured; otherwise the per-triple `onDemandFetcher` runs directly.
|
|
5
|
+
*/
|
|
6
|
+
export interface IndexQuery {
|
|
7
|
+
modelName: string;
|
|
8
|
+
indexKey: string;
|
|
9
|
+
value: string;
|
|
10
|
+
}
|
|
11
|
+
export type IndexBatchFetcher = (queries: IndexQuery[]) => Promise<Record<string, Record<string, unknown>[]>>;
|
|
12
|
+
export declare class BatchModelLoader {
|
|
13
|
+
private fetcher;
|
|
14
|
+
private pending;
|
|
15
|
+
private flushScheduled;
|
|
16
|
+
private disposed;
|
|
17
|
+
constructor(fetcher: IndexBatchFetcher);
|
|
18
|
+
/**
|
|
19
|
+
* Schedule `query` for the next batch. The returned promise resolves with
|
|
20
|
+
* the records matching this specific triple (filtered from the per-model
|
|
21
|
+
* bag the server returns).
|
|
22
|
+
*/
|
|
23
|
+
load(query: IndexQuery): Promise<Record<string, unknown>[]>;
|
|
24
|
+
/** Reject any unflushed waiters. Called from `StoreManager.teardown`. */
|
|
25
|
+
dispose(): void;
|
|
26
|
+
private flush;
|
|
27
|
+
}
|