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,168 @@
1
+ /**
2
+ * RefCollection and BackRef
3
+ *
4
+ * Runtime objects backing the collection / back-reference decorators. When a
5
+ * model is hydrated, each @ReferenceCollection / @LazyReferenceCollection
6
+ * property becomes a `RefCollection` instance, and each @BackReference becomes
7
+ * a `BackRef`. The runtime shape is identical regardless of whether the
8
+ * decorator is eager or lazy — eager just auto-fires `.load()` during
9
+ * makeModelObservable().
10
+ *
11
+ * Key behaviors:
12
+ *
13
+ * RefCollection:
14
+ * - Stores partial index values (e.g. "all Issues where teamId = team.id")
15
+ * - On first access, queries ObjectPool for already-loaded matches
16
+ * - If not fully loaded, queries IDB by index
17
+ * - Tracks loading state (idle → loading → loaded)
18
+ * - After a delta packet adds/removes items, can be invalidated and re-queried
19
+ *
20
+ * BackRef:
21
+ * - Resolves a single inverse model (e.g. Issue.favorite → Favorite)
22
+ * - Supports cascade delete: when the owning model is deleted,
23
+ * the back-referenced model is also removed from the pool
24
+ *
25
+ * Usage from React:
26
+ * const { data: team } = useRecord(Team, teamId);
27
+ * const { items, isLoading, load } = team.issues; // RefCollection
28
+ * // or via hook:
29
+ * const { data, isLoading } = useRelation(team?.issues);
30
+ */
31
+ import type { BaseModel } from "./BaseModel";
32
+ import type { CoveringPath } from "./types";
33
+ export declare enum CollectionState {
34
+ /** Never accessed — hydrate() computed the index values but no load yet. */
35
+ Idle = "idle",
36
+ /** Async load from IDB/server is in progress. */
37
+ Loading = "loading",
38
+ /** Load completed. Items are available. */
39
+ Loaded = "loaded",
40
+ /** Load failed. */
41
+ Error = "error"
42
+ }
43
+ export declare abstract class LazyCollectionBase<T extends BaseModel = BaseModel> {
44
+ items: T[];
45
+ state: CollectionState;
46
+ error: Error | null;
47
+ readonly referencedModelName: string;
48
+ private listeners;
49
+ private inFlight;
50
+ private onErrorHandler;
51
+ constructor(referencedModelName: string);
52
+ /** Wire a side-channel error reporter. Called by the loader's catch block in
53
+ * subclasses, in addition to setting `state = Error` and `.error`. Used by
54
+ * StoreManager to route into `config.onError` for telemetry. */
55
+ setOnError(handler: (err: Error) => void): void;
56
+ protected reportError(err: Error): void;
57
+ /** Subclass implementation. `load()` wraps this with concurrent-call dedup. */
58
+ protected abstract runLoad(): Promise<T[]>;
59
+ load(): Promise<T[]>;
60
+ invalidate(): void;
61
+ reload(): Promise<T[]>;
62
+ /**
63
+ * Splice an instance into items reactively. Idempotent — duplicates by id are
64
+ * skipped. Called by the ObjectPool when a child enters the pool with a
65
+ * matching foreign key, or moves into this parent.
66
+ */
67
+ attach(item: T): void;
68
+ /**
69
+ * Remove an instance from items reactively. No-op if missing. Called by the
70
+ * ObjectPool when a child is removed from the pool, or moves to a different
71
+ * parent.
72
+ */
73
+ detach(itemId: string): void;
74
+ /**
75
+ * Replace items wholesale. Used by the ObjectPool to backfill when a parent
76
+ * enters the pool after children were already present.
77
+ */
78
+ setItems(items: T[]): void;
79
+ get isLoaded(): boolean;
80
+ get isLoading(): boolean;
81
+ get length(): number;
82
+ /** Observe set-membership changes (items added / removed / replaced).
83
+ * Payload-less — re-read `items` inside the listener. Returns an
84
+ * unsubscribe function. The single subscription verb across the public
85
+ * surface (`record.watch`, `store.<entity>.watchAll`). */
86
+ watch(listener: () => void): () => void;
87
+ protected notifyListeners(): void;
88
+ }
89
+ export declare class RefCollection<T extends BaseModel = BaseModel> extends LazyCollectionBase<T> {
90
+ /** The foreign key on the child model (e.g. "teamId"). */
91
+ readonly inverseKey: string;
92
+ /** Additional FK axes on the parent that the loader should also query. */
93
+ readonly coveringIndexes: string[];
94
+ /** Auto-derived covering paths from the registry FK walk. Each path is
95
+ * resolved at hydrate time — depth 1 paths read directly from the parent;
96
+ * deeper paths walk the pool. Manual `coveringIndexes` and these paths
97
+ * are union'd into `partialIndexValues`, deduped by (axis, value). */
98
+ readonly derivedCoveringPaths: CoveringPath[];
99
+ /** The ID of the parent model (e.g. team.id). Set during hydrate(). */
100
+ parentId: string;
101
+ /**
102
+ * Cached covering values. Built in `hydrate()` from the parent's id +
103
+ * `coveringIndexes` axes; `runLoad` passes this to the loader, which
104
+ * turns each entry into a `getOrLoadCollection` call and unions the results.
105
+ */
106
+ private partialIndexValues;
107
+ private loader;
108
+ constructor(referencedModelName: string, inverseKey: string, coveringIndexes?: string[], derivedCoveringPaths?: CoveringPath[]);
109
+ /**
110
+ * Called by Model.hydrate() after the parent model is populated.
111
+ * Computes the covering partial-index values used for future loads.
112
+ */
113
+ hydrate(parent: BaseModel): void;
114
+ /**
115
+ * The set of (key, value) queries this collection's loader will run. One
116
+ * entry for the FK match; one per covering axis whose value is non-empty
117
+ * on the parent.
118
+ */
119
+ getCoveringPartialIndexValues(): ReadonlyArray<{
120
+ key: string;
121
+ value: string;
122
+ }>;
123
+ /** Wire the loader function. Called by StoreManager. */
124
+ setLoader(loader: (modelName: string, queries: Array<{
125
+ key: string;
126
+ value: string;
127
+ }>) => Promise<T[]>): void;
128
+ /**
129
+ * Resolve items already in the ObjectPool synchronously.
130
+ * Used for eager-load models where everything is in memory after bootstrap.
131
+ */
132
+ resolveFromPool(pool: {
133
+ getAll(name: string): BaseModel[];
134
+ }): T[];
135
+ protected runLoad(): Promise<T[]>;
136
+ }
137
+ export declare class BackRef<T extends BaseModel = BaseModel> {
138
+ value: T | null;
139
+ state: CollectionState;
140
+ error: Error | null;
141
+ readonly referencedModelName: string;
142
+ readonly inverseOf: string;
143
+ parentId: string;
144
+ private loader;
145
+ private onErrorHandler;
146
+ constructor(referencedModelName: string, inverseOf: string);
147
+ hydrate(parentId: string): void;
148
+ setLoader(loader: (modelName: string, key: string, value: string) => Promise<T | null>): void;
149
+ setOnError(handler: (err: Error) => void): void;
150
+ /** Resolve from pool synchronously. */
151
+ resolveFromPool(pool: {
152
+ getAll(name: string): BaseModel[];
153
+ }): T | null;
154
+ load(): Promise<T | null>;
155
+ invalidate(): void;
156
+ /**
157
+ * Set the resolved value reactively. Used by the ObjectPool when a model
158
+ * matching this back-reference enters the pool. Idempotent on identity.
159
+ */
160
+ attach(item: T): void;
161
+ /**
162
+ * Clear the resolved value reactively. Used by the ObjectPool when the
163
+ * referenced model leaves the pool or its inverse key changes.
164
+ */
165
+ detach(itemId: string): void;
166
+ get isLoaded(): boolean;
167
+ get isLoading(): boolean;
168
+ }
@@ -0,0 +1,403 @@
1
+ /**
2
+ * RefCollection and BackRef
3
+ *
4
+ * Runtime objects backing the collection / back-reference decorators. When a
5
+ * model is hydrated, each @ReferenceCollection / @LazyReferenceCollection
6
+ * property becomes a `RefCollection` instance, and each @BackReference becomes
7
+ * a `BackRef`. The runtime shape is identical regardless of whether the
8
+ * decorator is eager or lazy — eager just auto-fires `.load()` during
9
+ * makeModelObservable().
10
+ *
11
+ * Key behaviors:
12
+ *
13
+ * RefCollection:
14
+ * - Stores partial index values (e.g. "all Issues where teamId = team.id")
15
+ * - On first access, queries ObjectPool for already-loaded matches
16
+ * - If not fully loaded, queries IDB by index
17
+ * - Tracks loading state (idle → loading → loaded)
18
+ * - After a delta packet adds/removes items, can be invalidated and re-queried
19
+ *
20
+ * BackRef:
21
+ * - Resolves a single inverse model (e.g. Issue.favorite → Favorite)
22
+ * - Supports cascade delete: when the owning model is deleted,
23
+ * the back-referenced model is also removed from the pool
24
+ *
25
+ * Usage from React:
26
+ * const { data: team } = useRecord(Team, teamId);
27
+ * const { items, isLoading, load } = team.issues; // RefCollection
28
+ * // or via hook:
29
+ * const { data, isLoading } = useRelation(team?.issues);
30
+ */
31
+ import { observable, runInAction, makeObservable } from "mobx";
32
+ import { readFk } from "./ObjectPool";
33
+ /** Walk a `CoveringPath` from `parent` through the pool, returning the
34
+ * leaf FK value or null if any link is missing. Depth-1 paths are a single
35
+ * `readFk(parent, hops[0].fk)`. Deeper paths use intermediate
36
+ * `pool.getById(throughModel, id)` lookups; if the intermediate isn't in
37
+ * the pool, the path is silently skipped (its covering query will be
38
+ * issued only when the chain becomes resolvable on a later access). */
39
+ function resolveCoveringPath(parent, path) {
40
+ // Depth-1 fast path — no pool walk needed; just read the FK off `parent`.
41
+ if (path.hops.length === 1) {
42
+ return readFk(parent, path.hops[0].fk);
43
+ }
44
+ let current = parent;
45
+ // Deeper paths walk through `pool.getById`; bail silently if any link
46
+ // is missing (the covering query is just skipped for now).
47
+ for (let i = 0; i < path.hops.length - 1; i++) {
48
+ const id = readFk(current, path.hops[i].fk);
49
+ if (id == null) {
50
+ return null;
51
+ }
52
+ const pool = current.store;
53
+ if (pool == null) {
54
+ return null;
55
+ }
56
+ const next = pool.getById(path.hops[i].throughModel, id);
57
+ if (next == null) {
58
+ return null;
59
+ }
60
+ current = next;
61
+ }
62
+ return readFk(current, path.hops[path.hops.length - 1].fk);
63
+ }
64
+ // ---------------------------------------------------------------------------
65
+ // Loading state
66
+ // ---------------------------------------------------------------------------
67
+ export var CollectionState;
68
+ (function (CollectionState) {
69
+ /** Never accessed — hydrate() computed the index values but no load yet. */
70
+ CollectionState["Idle"] = "idle";
71
+ /** Async load from IDB/server is in progress. */
72
+ CollectionState["Loading"] = "loading";
73
+ /** Load completed. Items are available. */
74
+ CollectionState["Loaded"] = "loaded";
75
+ /** Load failed. */
76
+ CollectionState["Error"] = "error";
77
+ })(CollectionState || (CollectionState = {}));
78
+ // ---------------------------------------------------------------------------
79
+ // LazyCollectionBase — shared foundation for all lazy collection types
80
+ // ---------------------------------------------------------------------------
81
+ export class LazyCollectionBase {
82
+ constructor(referencedModelName) {
83
+ this.items = [];
84
+ this.state = CollectionState.Idle;
85
+ this.error = null;
86
+ this.listeners = new Set();
87
+ this.inFlight = null;
88
+ this.onErrorHandler = null;
89
+ this.referencedModelName = referencedModelName;
90
+ makeObservable(this, {
91
+ items: observable.shallow,
92
+ state: observable,
93
+ error: observable,
94
+ });
95
+ }
96
+ /** Wire a side-channel error reporter. Called by the loader's catch block in
97
+ * subclasses, in addition to setting `state = Error` and `.error`. Used by
98
+ * StoreManager to route into `config.onError` for telemetry. */
99
+ setOnError(handler) {
100
+ this.onErrorHandler = handler;
101
+ }
102
+ reportError(err) {
103
+ this.onErrorHandler?.(err);
104
+ }
105
+ load() {
106
+ if (this.inFlight != null) {
107
+ return this.inFlight;
108
+ }
109
+ this.inFlight = this.runLoad().finally(() => {
110
+ this.inFlight = null;
111
+ });
112
+ return this.inFlight;
113
+ }
114
+ invalidate() {
115
+ if (this.state === CollectionState.Loaded) {
116
+ runInAction(() => {
117
+ this.state = CollectionState.Idle;
118
+ });
119
+ this.notifyListeners();
120
+ }
121
+ }
122
+ async reload() {
123
+ runInAction(() => {
124
+ this.state = CollectionState.Idle;
125
+ });
126
+ return this.load();
127
+ }
128
+ /**
129
+ * Splice an instance into items reactively. Idempotent — duplicates by id are
130
+ * skipped. Called by the ObjectPool when a child enters the pool with a
131
+ * matching foreign key, or moves into this parent.
132
+ */
133
+ attach(item) {
134
+ if (this.items.some((existing) => existing.id === item.id)) {
135
+ return;
136
+ }
137
+ runInAction(() => {
138
+ this.items = [...this.items, item];
139
+ });
140
+ this.notifyListeners();
141
+ }
142
+ /**
143
+ * Remove an instance from items reactively. No-op if missing. Called by the
144
+ * ObjectPool when a child is removed from the pool, or moves to a different
145
+ * parent.
146
+ */
147
+ detach(itemId) {
148
+ if (!this.items.some((existing) => existing.id === itemId)) {
149
+ return;
150
+ }
151
+ runInAction(() => {
152
+ this.items = this.items.filter((existing) => existing.id !== itemId);
153
+ });
154
+ this.notifyListeners();
155
+ }
156
+ /**
157
+ * Replace items wholesale. Used by the ObjectPool to backfill when a parent
158
+ * enters the pool after children were already present.
159
+ */
160
+ setItems(items) {
161
+ runInAction(() => {
162
+ this.items = items;
163
+ });
164
+ this.notifyListeners();
165
+ }
166
+ get isLoaded() {
167
+ return this.state === CollectionState.Loaded;
168
+ }
169
+ get isLoading() {
170
+ return this.state === CollectionState.Loading;
171
+ }
172
+ get length() {
173
+ return this.items.length;
174
+ }
175
+ /** Observe set-membership changes (items added / removed / replaced).
176
+ * Payload-less — re-read `items` inside the listener. Returns an
177
+ * unsubscribe function. The single subscription verb across the public
178
+ * surface (`record.watch`, `store.<entity>.watchAll`). */
179
+ watch(listener) {
180
+ this.listeners.add(listener);
181
+ return () => {
182
+ this.listeners.delete(listener);
183
+ };
184
+ }
185
+ notifyListeners() {
186
+ this.listeners.forEach((fn) => fn());
187
+ }
188
+ }
189
+ // ---------------------------------------------------------------------------
190
+ // RefCollection — one-to-many queried by foreign key index. The runtime shape
191
+ // is identical for eager and lazy decorators; the decorator only chooses
192
+ // whether `.load()` fires automatically during makeModelObservable().
193
+ // ---------------------------------------------------------------------------
194
+ export class RefCollection extends LazyCollectionBase {
195
+ constructor(referencedModelName, inverseKey, coveringIndexes = [], derivedCoveringPaths = []) {
196
+ super(referencedModelName);
197
+ /** The ID of the parent model (e.g. team.id). Set during hydrate(). */
198
+ this.parentId = "";
199
+ /**
200
+ * Cached covering values. Built in `hydrate()` from the parent's id +
201
+ * `coveringIndexes` axes; `runLoad` passes this to the loader, which
202
+ * turns each entry into a `getOrLoadCollection` call and unions the results.
203
+ */
204
+ this.partialIndexValues = [];
205
+ this.loader = null;
206
+ this.inverseKey = inverseKey;
207
+ this.coveringIndexes = coveringIndexes;
208
+ this.derivedCoveringPaths = derivedCoveringPaths;
209
+ }
210
+ /**
211
+ * Called by Model.hydrate() after the parent model is populated.
212
+ * Computes the covering partial-index values used for future loads.
213
+ */
214
+ hydrate(parent) {
215
+ this.parentId = parent.id;
216
+ const values = [
217
+ { key: this.inverseKey, value: parent.id },
218
+ ];
219
+ // Note: Set's constructor takes an *iterable*. A bare string would
220
+ // iterate as characters, so seed with a one-element array.
221
+ const seen = new Set([`${this.inverseKey}=${parent.id}`]);
222
+ const push = (key, value) => {
223
+ const sig = `${key}=${value}`;
224
+ if (seen.has(sig)) {
225
+ return;
226
+ }
227
+ seen.add(sig);
228
+ values.push({ key, value });
229
+ };
230
+ for (const axis of this.coveringIndexes) {
231
+ const v = readFk(parent, axis);
232
+ if (v != null) {
233
+ push(axis, v);
234
+ }
235
+ }
236
+ for (const path of this.derivedCoveringPaths) {
237
+ const v = resolveCoveringPath(parent, path);
238
+ if (v != null) {
239
+ push(path.axis, v);
240
+ }
241
+ }
242
+ this.partialIndexValues = values;
243
+ }
244
+ /**
245
+ * The set of (key, value) queries this collection's loader will run. One
246
+ * entry for the FK match; one per covering axis whose value is non-empty
247
+ * on the parent.
248
+ */
249
+ getCoveringPartialIndexValues() {
250
+ return this.partialIndexValues;
251
+ }
252
+ /** Wire the loader function. Called by StoreManager. */
253
+ setLoader(loader) {
254
+ this.loader = loader;
255
+ }
256
+ /**
257
+ * Resolve items already in the ObjectPool synchronously.
258
+ * Used for eager-load models where everything is in memory after bootstrap.
259
+ */
260
+ resolveFromPool(pool) {
261
+ if (pool == null || this.parentId === "") {
262
+ return [];
263
+ }
264
+ const all = pool.getAll(this.referencedModelName);
265
+ return all.filter((m) => m[this.inverseKey] === this.parentId);
266
+ }
267
+ async runLoad() {
268
+ runInAction(() => {
269
+ this.state = CollectionState.Loading;
270
+ this.error = null;
271
+ });
272
+ try {
273
+ // The loader hydrates records into the ObjectPool, which synchronously
274
+ // dispatches attach() back into this collection. By the time the loader
275
+ // resolves, items already reflects every record the loader produced (plus
276
+ // anything else in the pool with a matching foreign key).
277
+ if (this.loader != null) {
278
+ await this.loader(this.referencedModelName, this.partialIndexValues);
279
+ }
280
+ runInAction(() => {
281
+ this.state = CollectionState.Loaded;
282
+ });
283
+ this.notifyListeners();
284
+ return [...this.items];
285
+ }
286
+ catch (err) {
287
+ runInAction(() => {
288
+ this.error = err;
289
+ this.state = CollectionState.Error;
290
+ });
291
+ this.notifyListeners();
292
+ this.reportError(err);
293
+ return [];
294
+ }
295
+ }
296
+ }
297
+ // ---------------------------------------------------------------------------
298
+ // BackRef — single inverse reference.
299
+ //
300
+ // When the owning model is deleted, the back-referenced model is cascade-removed.
301
+ //
302
+ // Example: Issue has @BackReference("Favorite", "issueId")
303
+ // → issue.favorite is a BackRef that resolves the Favorite where
304
+ // issueId === issue.id
305
+ // ---------------------------------------------------------------------------
306
+ export class BackRef {
307
+ constructor(referencedModelName, inverseOf) {
308
+ this.value = null;
309
+ this.state = CollectionState.Idle;
310
+ this.error = null;
311
+ this.parentId = "";
312
+ this.loader = null;
313
+ this.onErrorHandler = null;
314
+ this.referencedModelName = referencedModelName;
315
+ this.inverseOf = inverseOf;
316
+ makeObservable(this, {
317
+ value: observable.ref,
318
+ state: observable,
319
+ error: observable,
320
+ });
321
+ }
322
+ hydrate(parentId) {
323
+ this.parentId = parentId;
324
+ }
325
+ setLoader(loader) {
326
+ this.loader = loader;
327
+ }
328
+ setOnError(handler) {
329
+ this.onErrorHandler = handler;
330
+ }
331
+ /** Resolve from pool synchronously. */
332
+ resolveFromPool(pool) {
333
+ if (pool == null || this.parentId === "") {
334
+ return null;
335
+ }
336
+ const all = pool.getAll(this.referencedModelName);
337
+ return (all.find((m) => m[this.inverseOf] === this.parentId) ?? null);
338
+ }
339
+ async load() {
340
+ if (this.state === CollectionState.Loading) {
341
+ return this.value;
342
+ }
343
+ runInAction(() => {
344
+ this.state = CollectionState.Loading;
345
+ this.error = null;
346
+ });
347
+ try {
348
+ const result = this.loader != null
349
+ ? await this.loader(this.referencedModelName, this.inverseOf, this.parentId)
350
+ : null;
351
+ runInAction(() => {
352
+ this.value = result;
353
+ this.state = CollectionState.Loaded;
354
+ });
355
+ return result;
356
+ }
357
+ catch (err) {
358
+ runInAction(() => {
359
+ this.error = err;
360
+ this.state = CollectionState.Error;
361
+ });
362
+ this.onErrorHandler?.(err);
363
+ return null;
364
+ }
365
+ }
366
+ invalidate() {
367
+ if (this.state === CollectionState.Loaded) {
368
+ runInAction(() => {
369
+ this.state = CollectionState.Idle;
370
+ });
371
+ }
372
+ }
373
+ /**
374
+ * Set the resolved value reactively. Used by the ObjectPool when a model
375
+ * matching this back-reference enters the pool. Idempotent on identity.
376
+ */
377
+ attach(item) {
378
+ if (this.value === item) {
379
+ return;
380
+ }
381
+ runInAction(() => {
382
+ this.value = item;
383
+ });
384
+ }
385
+ /**
386
+ * Clear the resolved value reactively. Used by the ObjectPool when the
387
+ * referenced model leaves the pool or its inverse key changes.
388
+ */
389
+ detach(itemId) {
390
+ if (this.value == null || this.value.id !== itemId) {
391
+ return;
392
+ }
393
+ runInAction(() => {
394
+ this.value = null;
395
+ });
396
+ }
397
+ get isLoaded() {
398
+ return this.state === CollectionState.Loaded;
399
+ }
400
+ get isLoading() {
401
+ return this.state === CollectionState.Loading;
402
+ }
403
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * OwnedRefs — many-to-many where the parent owns the list of IDs.
3
+ *
4
+ * Contrast with RefCollection, where the *child* holds the foreign key (e.g.
5
+ * Issue has teamId). Here the parent holds the array.
6
+ *
7
+ * Resolution:
8
+ * - resolveFromPool: looks up each ID via pool.getById — synchronous
9
+ * - load: fetches missing IDs from IDB via the wired loader — async
10
+ *
11
+ * Usage:
12
+ * @Property()
13
+ * public issueIds: string[] = [];
14
+ *
15
+ * @OwnedCollection("Issue", { idsField: "issueIds" })
16
+ * public issues: OwnedRefs<Issue>;
17
+ */
18
+ import type { BaseModel } from "./BaseModel";
19
+ import { LazyCollectionBase } from "./LazyCollection";
20
+ export declare class OwnedRefs<T extends BaseModel = BaseModel> extends LazyCollectionBase<T> {
21
+ /** Live getter — reads the current IDs array from the parent model each call. */
22
+ private idsGetter;
23
+ private loader;
24
+ constructor(referencedModelName: string, idsGetter: () => string[]);
25
+ /** Wire the loader. Called by StoreManager during makeModelObservable(). */
26
+ setLoader(loader: (modelName: string, ids: string[]) => Promise<T[]>): void;
27
+ /**
28
+ * Resolve items already in the ObjectPool synchronously.
29
+ * Looks up each ID directly — no index query needed.
30
+ */
31
+ resolveFromPool(pool: {
32
+ getById(name: string, id: string): BaseModel | undefined;
33
+ }): T[];
34
+ protected runLoad(): Promise<T[]>;
35
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * OwnedRefs — many-to-many where the parent owns the list of IDs.
3
+ *
4
+ * Contrast with RefCollection, where the *child* holds the foreign key (e.g.
5
+ * Issue has teamId). Here the parent holds the array.
6
+ *
7
+ * Resolution:
8
+ * - resolveFromPool: looks up each ID via pool.getById — synchronous
9
+ * - load: fetches missing IDs from IDB via the wired loader — async
10
+ *
11
+ * Usage:
12
+ * @Property()
13
+ * public issueIds: string[] = [];
14
+ *
15
+ * @OwnedCollection("Issue", { idsField: "issueIds" })
16
+ * public issues: OwnedRefs<Issue>;
17
+ */
18
+ import { runInAction } from "mobx";
19
+ import { LazyCollectionBase, CollectionState } from "./LazyCollection";
20
+ export class OwnedRefs extends LazyCollectionBase {
21
+ constructor(referencedModelName, idsGetter) {
22
+ super(referencedModelName);
23
+ this.loader = null;
24
+ this.idsGetter = idsGetter;
25
+ }
26
+ /** Wire the loader. Called by StoreManager during makeModelObservable(). */
27
+ setLoader(loader) {
28
+ this.loader = loader;
29
+ }
30
+ /**
31
+ * Resolve items already in the ObjectPool synchronously.
32
+ * Looks up each ID directly — no index query needed.
33
+ */
34
+ resolveFromPool(pool) {
35
+ return this.idsGetter()
36
+ .map((id) => pool.getById(this.referencedModelName, id))
37
+ .filter((m) => m != null);
38
+ }
39
+ async runLoad() {
40
+ runInAction(() => {
41
+ this.state = CollectionState.Loading;
42
+ this.error = null;
43
+ });
44
+ try {
45
+ const ids = this.idsGetter();
46
+ const results = ids.length > 0 && this.loader != null
47
+ ? await this.loader(this.referencedModelName, ids)
48
+ : [];
49
+ runInAction(() => {
50
+ this.items = results;
51
+ this.state = CollectionState.Loaded;
52
+ });
53
+ this.notifyListeners();
54
+ return results;
55
+ }
56
+ catch (err) {
57
+ runInAction(() => {
58
+ this.error = err;
59
+ this.state = CollectionState.Error;
60
+ });
61
+ this.notifyListeners();
62
+ this.reportError(err);
63
+ return [];
64
+ }
65
+ }
66
+ }