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,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
|
+
}
|