zerodrift 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +358 -0
  3. package/dist/core/BaseModel.d.ts +76 -0
  4. package/dist/core/BaseModel.js +505 -0
  5. package/dist/core/BaseSSEConnection.d.ts +31 -0
  6. package/dist/core/BaseSSEConnection.js +91 -0
  7. package/dist/core/BatchModelLoader.d.ts +27 -0
  8. package/dist/core/BatchModelLoader.js +70 -0
  9. package/dist/core/CompoundIndexFetcher.d.ts +46 -0
  10. package/dist/core/CompoundIndexFetcher.js +177 -0
  11. package/dist/core/Database.d.ts +303 -0
  12. package/dist/core/Database.js +837 -0
  13. package/dist/core/LazyCollection.d.ts +168 -0
  14. package/dist/core/LazyCollection.js +403 -0
  15. package/dist/core/LazyOwnedCollection.d.ts +35 -0
  16. package/dist/core/LazyOwnedCollection.js +66 -0
  17. package/dist/core/MemoryAdapter.d.ts +67 -0
  18. package/dist/core/MemoryAdapter.js +243 -0
  19. package/dist/core/ModelRegistry.d.ts +64 -0
  20. package/dist/core/ModelRegistry.js +217 -0
  21. package/dist/core/ModelStream.d.ts +33 -0
  22. package/dist/core/ModelStream.js +68 -0
  23. package/dist/core/ObjectPool.d.ts +113 -0
  24. package/dist/core/ObjectPool.js +339 -0
  25. package/dist/core/Store.d.ts +40 -0
  26. package/dist/core/Store.js +73 -0
  27. package/dist/core/StoreManager.d.ts +839 -0
  28. package/dist/core/StoreManager.js +2034 -0
  29. package/dist/core/SyncConnection.d.ts +105 -0
  30. package/dist/core/SyncConnection.js +348 -0
  31. package/dist/core/Transaction.d.ts +114 -0
  32. package/dist/core/Transaction.js +147 -0
  33. package/dist/core/TransactionQueue.d.ts +110 -0
  34. package/dist/core/TransactionQueue.js +601 -0
  35. package/dist/core/decorators.d.ts +66 -0
  36. package/dist/core/decorators.js +278 -0
  37. package/dist/core/hash.d.ts +6 -0
  38. package/dist/core/hash.js +12 -0
  39. package/dist/core/index.d.ts +16 -0
  40. package/dist/core/index.js +18 -0
  41. package/dist/core/internal.d.ts +27 -0
  42. package/dist/core/internal.js +25 -0
  43. package/dist/core/observability.d.ts +21 -0
  44. package/dist/core/observability.js +66 -0
  45. package/dist/core/refAccessors.d.ts +43 -0
  46. package/dist/core/refAccessors.js +80 -0
  47. package/dist/core/serializers.d.ts +2 -0
  48. package/dist/core/serializers.js +2 -0
  49. package/dist/core/types.d.ts +320 -0
  50. package/dist/core/types.js +84 -0
  51. package/dist/react/index.d.ts +82 -0
  52. package/dist/react/index.js +373 -0
  53. package/dist/schema/builders.d.ts +29 -0
  54. package/dist/schema/builders.js +81 -0
  55. package/dist/schema/compile.d.ts +28 -0
  56. package/dist/schema/compile.js +334 -0
  57. package/dist/schema/createStore.d.ts +235 -0
  58. package/dist/schema/createStore.js +264 -0
  59. package/dist/schema/extend.d.ts +46 -0
  60. package/dist/schema/extend.js +6 -0
  61. package/dist/schema/index.d.ts +13 -0
  62. package/dist/schema/index.js +8 -0
  63. package/dist/schema/infer.d.ts +102 -0
  64. package/dist/schema/infer.js +1 -0
  65. package/dist/schema/types.d.ts +76 -0
  66. package/dist/schema/types.js +1 -0
  67. package/dist/schema/zod.d.ts +90 -0
  68. package/dist/schema/zod.js +101 -0
  69. package/package.json +99 -0
@@ -0,0 +1,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
+ }