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,373 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* React integration for the Sync Engine.
|
|
4
|
+
*
|
|
5
|
+
* Hooks subscribe to ObjectPool change notifications via useSyncExternalStore,
|
|
6
|
+
* so a delta packet that adds, updates, or removes a model automatically
|
|
7
|
+
* re-renders any component reading it through `useRecord` / `useRecords` /
|
|
8
|
+
* `useRecordsByIndex` (or a relation via `useRelation`).
|
|
9
|
+
*/
|
|
10
|
+
import { createContext, useContext, useState, useEffect, useCallback, useMemo, useRef, useSyncExternalStore, useLayoutEffect, } from "react";
|
|
11
|
+
import { StoreManager } from "../core/StoreManager";
|
|
12
|
+
import { BootstrapPhase } from "../core/types";
|
|
13
|
+
import { BackRef } from "../core/LazyCollection";
|
|
14
|
+
import { readFk } from "../core/ObjectPool";
|
|
15
|
+
// `<any>` keeps the hooks free of TContext — none of them touch it.
|
|
16
|
+
const SyncContext = createContext(null);
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Provider
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
export function SyncProvider({ config, context, children, fallback, }) {
|
|
21
|
+
const [status, setStatus] = useState({
|
|
22
|
+
phase: BootstrapPhase.Idle,
|
|
23
|
+
});
|
|
24
|
+
const smRef = useRef(null);
|
|
25
|
+
const cfgRef = useRef(config);
|
|
26
|
+
cfgRef.current = config;
|
|
27
|
+
// Detect bfcache restores. When a tab is duplicated (or the user navigates
|
|
28
|
+
// back/forward) the browser may restore the page from its back/forward cache
|
|
29
|
+
// (bfcache). In that case the JS heap is frozen and thawed — React effects do
|
|
30
|
+
// NOT re-run, so the StoreManager never bootstraps and the fallback stays
|
|
31
|
+
// visible forever. Reloading on persisted pageshow breaks out of that state.
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
const handlePageShow = (e) => {
|
|
34
|
+
if (e.persisted) {
|
|
35
|
+
window.location.reload();
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
window.addEventListener("pageshow", handlePageShow);
|
|
39
|
+
return () => window.removeEventListener("pageshow", handlePageShow);
|
|
40
|
+
}, []);
|
|
41
|
+
// Latest context, sampled at render time. Captured into a ref so the
|
|
42
|
+
// construction effect can seed the StoreManager without re-running when
|
|
43
|
+
// the context changes (the dedicated effect below pushes updates).
|
|
44
|
+
const contextRef = useRef(context);
|
|
45
|
+
contextRef.current = context;
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
let active = true;
|
|
48
|
+
const sm = new StoreManager({
|
|
49
|
+
...cfgRef.current,
|
|
50
|
+
hooks: {
|
|
51
|
+
...cfgRef.current.hooks,
|
|
52
|
+
onPhaseChange: (phase, detail) => {
|
|
53
|
+
cfgRef.current.hooks?.onPhaseChange?.(phase, detail);
|
|
54
|
+
if (active) {
|
|
55
|
+
setStatus({ phase, detail });
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
if (contextRef.current !== undefined) {
|
|
61
|
+
sm.setContext(contextRef.current);
|
|
62
|
+
}
|
|
63
|
+
smRef.current = sm;
|
|
64
|
+
sm.bootstrap().catch((err) => {
|
|
65
|
+
if (active) {
|
|
66
|
+
setStatus({ phase: BootstrapPhase.Error, error: String(err) });
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
return () => {
|
|
70
|
+
active = false;
|
|
71
|
+
sm.teardown();
|
|
72
|
+
smRef.current = null;
|
|
73
|
+
};
|
|
74
|
+
}, [cfgRef.current.workspaceId]);
|
|
75
|
+
// Push context updates synchronously so an event handler dispatched in the
|
|
76
|
+
// same commit as a context change sees the fresh value when minting ids.
|
|
77
|
+
useLayoutEffect(() => {
|
|
78
|
+
if (smRef.current != null && context !== undefined) {
|
|
79
|
+
smRef.current.setContext(context);
|
|
80
|
+
}
|
|
81
|
+
}, [context]);
|
|
82
|
+
if (smRef.current == null) {
|
|
83
|
+
return fallback != null ? _jsx(_Fragment, { children: fallback }) : null;
|
|
84
|
+
}
|
|
85
|
+
if (status.phase !== BootstrapPhase.Ready &&
|
|
86
|
+
status.phase !== BootstrapPhase.Error &&
|
|
87
|
+
fallback != null) {
|
|
88
|
+
return _jsx(_Fragment, { children: fallback });
|
|
89
|
+
}
|
|
90
|
+
return (_jsx(SyncContext.Provider, { value: { sm: smRef.current, status }, children: children }));
|
|
91
|
+
}
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// Core hook
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
export function useSyncEngine() {
|
|
96
|
+
const ctx = useContext(SyncContext);
|
|
97
|
+
if (ctx == null) {
|
|
98
|
+
throw new Error("useSyncEngine() must be inside <SyncProvider>");
|
|
99
|
+
}
|
|
100
|
+
return ctx;
|
|
101
|
+
}
|
|
102
|
+
export function useBootstrapStatus() {
|
|
103
|
+
return useSyncEngine().status;
|
|
104
|
+
}
|
|
105
|
+
/** Subscribe to a model type's pool changes and read a snapshot synchronously.
|
|
106
|
+
*
|
|
107
|
+
* `getSnapshot` is intentionally NOT stabilized — useSyncExternalStore calls
|
|
108
|
+
* it during render and compares the returned value, not the function identity.
|
|
109
|
+
* Stabilizing via useStableCallback would defer ref-updates to useLayoutEffect
|
|
110
|
+
* and silently return stale values on the render where its inputs change. */
|
|
111
|
+
function usePoolSnapshot(modelName, getSnapshot) {
|
|
112
|
+
const { sm } = useSyncEngine();
|
|
113
|
+
const subscribe = useCallback((onStoreChange) => sm.objectPool.subscribe(modelName, onStoreChange), [sm, modelName]);
|
|
114
|
+
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
115
|
+
}
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// useLoader — internal helper carrying the loading/error/reload + race-guard
|
|
118
|
+
// shape shared by every pool-aware hook below. Auto-fires on mount and on
|
|
119
|
+
// `triggerKey` change when `shouldAutoFire` returns true; `reload()` always
|
|
120
|
+
// fires regardless of the gate.
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
function useLoader(load, enabled, triggerKey, shouldAutoFire) {
|
|
123
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
124
|
+
const [error, setError] = useState(null);
|
|
125
|
+
const gen = useRef(0);
|
|
126
|
+
const stableLoad = useStableCallback(load);
|
|
127
|
+
const stableShouldAutoFire = useStableCallback(shouldAutoFire);
|
|
128
|
+
const reload = useCallback(async () => {
|
|
129
|
+
if (!enabled) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const g = ++gen.current;
|
|
133
|
+
setIsLoading(true);
|
|
134
|
+
setError(null);
|
|
135
|
+
try {
|
|
136
|
+
await stableLoad();
|
|
137
|
+
if (g === gen.current) {
|
|
138
|
+
setIsLoading(false);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
catch (e) {
|
|
142
|
+
if (g === gen.current) {
|
|
143
|
+
setError(e);
|
|
144
|
+
setIsLoading(false);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}, [enabled, triggerKey, stableLoad]);
|
|
148
|
+
useEffect(() => {
|
|
149
|
+
if (enabled && stableShouldAutoFire()) {
|
|
150
|
+
void reload();
|
|
151
|
+
}
|
|
152
|
+
}, [reload, enabled, triggerKey, stableShouldAutoFire]);
|
|
153
|
+
return { isLoading, error, reload };
|
|
154
|
+
}
|
|
155
|
+
/** `isLoaded` for the pool-keyed hooks: ready, not loading, no error — true
|
|
156
|
+
* from frame zero on a pool hit (the loader's auto-fire is gated). */
|
|
157
|
+
const settled = (ready, isLoading, error) => ready && !isLoading && error == null;
|
|
158
|
+
// Model-name-keyed implementations. The public hooks resolve a handle
|
|
159
|
+
// (schema namespace or model class) to a registry name and delegate here,
|
|
160
|
+
// so the pool-subscription / loader machinery lives in exactly one place.
|
|
161
|
+
/** Reactive single model by id. Pool-first sync read; async backfill on miss. */
|
|
162
|
+
function useRecordByName(modelName, id) {
|
|
163
|
+
const { sm, status } = useSyncEngine();
|
|
164
|
+
const pool = sm.objectPool;
|
|
165
|
+
const ready = status.phase === BootstrapPhase.Ready;
|
|
166
|
+
const item = usePoolSnapshot(modelName, () => id != null ? (pool.getById(modelName, id) ?? null) : null);
|
|
167
|
+
const { isLoading, error, reload } = useLoader(() => sm.getOrLoadById(modelName, id), ready && id != null, `${modelName}:${id ?? ""}`,
|
|
168
|
+
// Skip the load when the pool already has the entry — eager models
|
|
169
|
+
// render with isLoading: false from frame zero.
|
|
170
|
+
() => id != null && pool.getById(modelName, id) == null);
|
|
171
|
+
return {
|
|
172
|
+
data: ready ? item : null,
|
|
173
|
+
isLoading,
|
|
174
|
+
isLoaded: settled(ready, isLoading, error),
|
|
175
|
+
error,
|
|
176
|
+
reload,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
/** Reactive list of models of a type, optionally filtered to a specific id
|
|
180
|
+
* set. Without `ids`: every instance in the pool. With `ids`: just those, in
|
|
181
|
+
* the order given, with async backfill for any missing from the pool. The
|
|
182
|
+
* ids array is compared by content so inline literals don't cause re-fetches. */
|
|
183
|
+
function useRecordsByName(modelName, ids) {
|
|
184
|
+
const { sm, status } = useSyncEngine();
|
|
185
|
+
const pool = sm.objectPool;
|
|
186
|
+
const ready = status.phase === BootstrapPhase.Ready;
|
|
187
|
+
const idsKey = ids?.join(",") ?? "";
|
|
188
|
+
const all = usePoolSnapshot(modelName, () => pool.getAll(modelName));
|
|
189
|
+
const items = useMemo(() => {
|
|
190
|
+
if (ids == null) {
|
|
191
|
+
return all;
|
|
192
|
+
}
|
|
193
|
+
const byId = new Map(all.map((m) => [m.id, m]));
|
|
194
|
+
return ids
|
|
195
|
+
.map((id) => byId.get(id))
|
|
196
|
+
.filter((m) => m != null);
|
|
197
|
+
}, [all, idsKey]);
|
|
198
|
+
const { isLoading, error, reload } = useLoader(() => sm.getOrLoadByIds(modelName, ids ?? []), ready && ids != null && ids.length > 0, `${modelName}:${idsKey}`, () => ids != null && ids.some((id) => pool.getById(modelName, id) == null));
|
|
199
|
+
return {
|
|
200
|
+
data: ready ? items : [],
|
|
201
|
+
isLoading,
|
|
202
|
+
isLoaded: settled(ready, isLoading, error),
|
|
203
|
+
error,
|
|
204
|
+
reload,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
/** Reactive list of models matching one OR many values on a foreign-key
|
|
208
|
+
* index. A single string and a `string[]` take the same path (the single
|
|
209
|
+
* value is a one-element set), so semantics are identical. Coverage is
|
|
210
|
+
* tracked per `(name, indexKey, value)` so re-renders don't re-fetch
|
|
211
|
+
* already-covered buckets; values are compared by content so inline literals
|
|
212
|
+
* don't trigger re-fetches.
|
|
213
|
+
*
|
|
214
|
+
* For one-round-trip multi-value fetches, configure
|
|
215
|
+
* `onDemandIndexBatchFetcher` + `serverSupportsCompoundIndexKeys: true` —
|
|
216
|
+
* see `agent-docs/04-lazy-loading.md`. */
|
|
217
|
+
function useRecordsByIndexName(modelName, indexKey, value) {
|
|
218
|
+
const { sm, status } = useSyncEngine();
|
|
219
|
+
const ready = status.phase === BootstrapPhase.Ready;
|
|
220
|
+
const values = value == null
|
|
221
|
+
? []
|
|
222
|
+
: (Array.isArray(value) ? value : [value]).filter((v) => v != null && v !== "");
|
|
223
|
+
const valuesKey = values.join(",");
|
|
224
|
+
const hasValues = values.length > 0;
|
|
225
|
+
const all = usePoolSnapshot(modelName, () => sm.objectPool.getAll(modelName));
|
|
226
|
+
const items = useMemo(() => {
|
|
227
|
+
if (!hasValues) {
|
|
228
|
+
return [];
|
|
229
|
+
}
|
|
230
|
+
const set = new Set(values);
|
|
231
|
+
return all.filter((m) => {
|
|
232
|
+
const v = readFk(m, indexKey);
|
|
233
|
+
return v != null && set.has(v);
|
|
234
|
+
});
|
|
235
|
+
// valuesKey: content equality; array identity is unstable for inline literals.
|
|
236
|
+
}, [all, indexKey, valuesKey, hasValues]);
|
|
237
|
+
const { isLoading, error, reload } = useLoader(async () => {
|
|
238
|
+
await Promise.all(values.map((v) => sm.getOrLoadCollection(modelName, indexKey, v)));
|
|
239
|
+
}, ready && hasValues, `${modelName}:${indexKey}:${valuesKey}`, () => hasValues &&
|
|
240
|
+
values.some((v) => !sm.isCollectionLoaded(modelName, indexKey, v)));
|
|
241
|
+
return {
|
|
242
|
+
data: ready ? items : [],
|
|
243
|
+
isLoading,
|
|
244
|
+
isLoaded: settled(ready, isLoading, error),
|
|
245
|
+
error,
|
|
246
|
+
reload,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
// Batch and undo/redo
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
/** Returns `store.batch` — the sync overload yields the `batchId` string,
|
|
253
|
+
* the async overload a `Promise<string>`. */
|
|
254
|
+
export function useBatch() {
|
|
255
|
+
const { sm } = useSyncEngine();
|
|
256
|
+
return useCallback(((fn) => sm.batch(fn)), [sm]);
|
|
257
|
+
}
|
|
258
|
+
export function useUndoRedo() {
|
|
259
|
+
const { sm } = useSyncEngine();
|
|
260
|
+
const snapshotRef = useRef({ undoDepth: 0, redoDepth: 0 });
|
|
261
|
+
const subscribe = useCallback((onStoreChange) => sm.transactionQueue.subscribe(onStoreChange), [sm]);
|
|
262
|
+
const getSnapshot = useCallback(() => {
|
|
263
|
+
const undoDepth = sm.transactionQueue.undoDepth;
|
|
264
|
+
const redoDepth = sm.transactionQueue.redoDepth;
|
|
265
|
+
if (snapshotRef.current.undoDepth !== undoDepth ||
|
|
266
|
+
snapshotRef.current.redoDepth !== redoDepth) {
|
|
267
|
+
snapshotRef.current = { undoDepth, redoDepth };
|
|
268
|
+
}
|
|
269
|
+
return snapshotRef.current;
|
|
270
|
+
}, [sm]);
|
|
271
|
+
const { undoDepth, redoDepth } = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
272
|
+
return {
|
|
273
|
+
undo: useCallback(() => sm.undo(), [sm]),
|
|
274
|
+
redo: useCallback(() => sm.redo(), [sm]),
|
|
275
|
+
canUndo: undoDepth > 0,
|
|
276
|
+
canRedo: redoDepth > 0,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
export function useRelation(relation) {
|
|
280
|
+
const [tick, forceRender] = useState(0);
|
|
281
|
+
const isBackRef = relation instanceof BackRef;
|
|
282
|
+
// Collections expose watch() for invalidation; BackRef does not.
|
|
283
|
+
useEffect(() => {
|
|
284
|
+
if (relation == null || isBackRef) {
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
return relation.watch(() => forceRender((n) => n + 1));
|
|
288
|
+
}, [relation, isBackRef]);
|
|
289
|
+
useEffect(() => {
|
|
290
|
+
if (relation != null && !relation.isLoaded && !relation.isLoading) {
|
|
291
|
+
relation.load().then(() => forceRender((n) => n + 1));
|
|
292
|
+
}
|
|
293
|
+
}, [relation, tick]);
|
|
294
|
+
if (relation == null) {
|
|
295
|
+
// Can't tell collection from back-ref at null; `[]` is the map-safe
|
|
296
|
+
// default. A null back-ref reads `[]` rather than `null` — harmless
|
|
297
|
+
// (consumers use `data?.x`), and the relation object is non-null
|
|
298
|
+
// whenever its holding record exists.
|
|
299
|
+
return {
|
|
300
|
+
data: [],
|
|
301
|
+
isLoading: false,
|
|
302
|
+
isLoaded: false,
|
|
303
|
+
error: null,
|
|
304
|
+
reload: async () => { },
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
return {
|
|
308
|
+
data: isBackRef
|
|
309
|
+
? (relation.value ?? null)
|
|
310
|
+
: (relation.items ?? []),
|
|
311
|
+
isLoading: relation.isLoading ?? false,
|
|
312
|
+
isLoaded: relation.isLoaded ?? false,
|
|
313
|
+
error: relation.error ?? null,
|
|
314
|
+
reload: async () => {
|
|
315
|
+
await (isBackRef
|
|
316
|
+
? relation.load()
|
|
317
|
+
: relation.reload());
|
|
318
|
+
},
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
function useStableCallback(callback) {
|
|
322
|
+
const computedRef = useRef(callback);
|
|
323
|
+
const stableRef = useRef((...args) => {
|
|
324
|
+
return computedRef.current(...args);
|
|
325
|
+
});
|
|
326
|
+
useLayoutEffect(function updateStableCallbackRef() {
|
|
327
|
+
computedRef.current = callback;
|
|
328
|
+
}, [callback]);
|
|
329
|
+
return stableRef.current;
|
|
330
|
+
}
|
|
331
|
+
// ---------------------------------------------------------------------------
|
|
332
|
+
// Public read hooks — keyed by a "handle"
|
|
333
|
+
//
|
|
334
|
+
// A handle is either a schema namespace (`store.issue`) or a decorator
|
|
335
|
+
// model class (`Issue`). Both resolve to a registry name; the record type
|
|
336
|
+
// is inferred from whichever form was passed, so the same four hooks serve
|
|
337
|
+
// both authoring paths with one vocabulary:
|
|
338
|
+
//
|
|
339
|
+
// useRecord(handle, id) → AsyncResource<T | null>
|
|
340
|
+
// useRecords(handle, ids?) → AsyncResource<T[]>
|
|
341
|
+
// useRecordsByIndex(handle, key, v|v[]) → AsyncResource<T[]>
|
|
342
|
+
// useRelation(record.relation) → AsyncResource<T[] | T | null>
|
|
343
|
+
//
|
|
344
|
+
// For namespace handles the index key is constrained to the schema's
|
|
345
|
+
// `.indexed()` fields; for class handles it's `string`.
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
347
|
+
import { entityNamespaceRegistryName, } from "../schema/createStore";
|
|
348
|
+
function handleRegistryName(handle) {
|
|
349
|
+
if (typeof handle === "function") {
|
|
350
|
+
// Set by @ClientModel (explicit { name } or ctor.name fallback).
|
|
351
|
+
return (handle._modelName ??
|
|
352
|
+
handle.name);
|
|
353
|
+
}
|
|
354
|
+
return entityNamespaceRegistryName(handle);
|
|
355
|
+
}
|
|
356
|
+
// `as unknown as` bridges `BaseModel` (what the internal name-keyed hooks
|
|
357
|
+
// return) and `RecordOf<H>` (the typed view of the same pooled instance).
|
|
358
|
+
// Same object at runtime; neither type is assignable to the other (BaseModel
|
|
359
|
+
// has `__mobx`/`store`; RecordOf has schema fields + extensions). One cast
|
|
360
|
+
// per wrapper, contained.
|
|
361
|
+
/** Reactive single record by id. Pool-first sync read; async backfill on miss. */
|
|
362
|
+
export function useRecord(handle, id) {
|
|
363
|
+
return useRecordByName(handleRegistryName(handle), id);
|
|
364
|
+
}
|
|
365
|
+
/** Reactive list of records, optionally filtered to (and ordered by) `ids`. */
|
|
366
|
+
export function useRecords(handle, ids) {
|
|
367
|
+
return useRecordsByName(handleRegistryName(handle), ids);
|
|
368
|
+
}
|
|
369
|
+
/** Reactive list of records matching one value, or any of several, on a
|
|
370
|
+
* foreign-key index. */
|
|
371
|
+
export function useRecordsByIndex(handle, indexKey, value) {
|
|
372
|
+
return useRecordsByIndexName(handleRegistryName(handle), indexKey, value);
|
|
373
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { AnyFieldBuilder, AnyLinkDef, EntityDef, FieldBuilder, FieldMeta, LinkDef, SchemaDef } from "./types";
|
|
2
|
+
export declare function rebuildFieldBuilder<T, M extends FieldMeta>(meta: M): FieldBuilder<T, M>;
|
|
3
|
+
export declare const fields: {
|
|
4
|
+
id: () => FieldBuilder<string, FieldMeta & {
|
|
5
|
+
kind: "id";
|
|
6
|
+
}>;
|
|
7
|
+
string: () => FieldBuilder<string, FieldMeta & {
|
|
8
|
+
kind: "string";
|
|
9
|
+
}>;
|
|
10
|
+
number: () => FieldBuilder<number, FieldMeta & {
|
|
11
|
+
kind: "number";
|
|
12
|
+
}>;
|
|
13
|
+
boolean: () => FieldBuilder<boolean, FieldMeta & {
|
|
14
|
+
kind: "boolean";
|
|
15
|
+
}>;
|
|
16
|
+
date: () => FieldBuilder<Date, FieldMeta & {
|
|
17
|
+
kind: "date";
|
|
18
|
+
}>;
|
|
19
|
+
json: <T = unknown>() => FieldBuilder<T, FieldMeta & {
|
|
20
|
+
kind: "json";
|
|
21
|
+
}>;
|
|
22
|
+
refId: <Target extends string>(target: Target) => FieldBuilder<string, FieldMeta & {
|
|
23
|
+
kind: "refId";
|
|
24
|
+
refTarget: Target;
|
|
25
|
+
}>;
|
|
26
|
+
};
|
|
27
|
+
export declare function entity<const F extends Record<string, AnyFieldBuilder>>(def: EntityDef<F>): EntityDef<F>;
|
|
28
|
+
export declare function link<const FromEntity extends string, const FromField extends string, const As extends string, const ToEntity extends string, const Many extends string>(def: LinkDef<FromEntity, FromField, As, ToEntity, Many>): LinkDef<FromEntity, FromField, As, ToEntity, Many>;
|
|
29
|
+
export declare function defineSchema<const E extends Record<string, EntityDef>, const L extends Record<string, AnyLinkDef>>(schema: SchemaDef<E, L>): SchemaDef<E, L>;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { dateDeserializer, dateSerializer } from "../core/serializers";
|
|
2
|
+
function makeBuilder(meta) {
|
|
3
|
+
return {
|
|
4
|
+
meta,
|
|
5
|
+
nullable() {
|
|
6
|
+
return makeBuilder({ ...meta, nullable: true });
|
|
7
|
+
},
|
|
8
|
+
indexed() {
|
|
9
|
+
return makeBuilder({
|
|
10
|
+
...meta,
|
|
11
|
+
indexed: true,
|
|
12
|
+
});
|
|
13
|
+
},
|
|
14
|
+
default(value) {
|
|
15
|
+
return makeBuilder({
|
|
16
|
+
...meta,
|
|
17
|
+
default: value,
|
|
18
|
+
});
|
|
19
|
+
},
|
|
20
|
+
ephemeral() {
|
|
21
|
+
return makeBuilder({ ...meta, ephemeral: true });
|
|
22
|
+
},
|
|
23
|
+
serialize(fn) {
|
|
24
|
+
return makeBuilder({
|
|
25
|
+
...meta,
|
|
26
|
+
serializer: fn,
|
|
27
|
+
});
|
|
28
|
+
},
|
|
29
|
+
deserialize(fn) {
|
|
30
|
+
return makeBuilder({ ...meta, deserializer: fn });
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export function rebuildFieldBuilder(meta) {
|
|
35
|
+
return makeBuilder(meta);
|
|
36
|
+
}
|
|
37
|
+
const baseFlags = {
|
|
38
|
+
nullable: false,
|
|
39
|
+
optional: false,
|
|
40
|
+
indexed: false,
|
|
41
|
+
ephemeral: false,
|
|
42
|
+
};
|
|
43
|
+
function field(kind, extras) {
|
|
44
|
+
return makeBuilder({
|
|
45
|
+
kind,
|
|
46
|
+
...baseFlags,
|
|
47
|
+
...extras,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
const id = () => field("id");
|
|
51
|
+
const stringField = () => field("string");
|
|
52
|
+
const numberField = () => field("number");
|
|
53
|
+
const booleanField = () => field("boolean");
|
|
54
|
+
const date = () => field("date", {
|
|
55
|
+
serializer: dateSerializer,
|
|
56
|
+
deserializer: dateDeserializer,
|
|
57
|
+
});
|
|
58
|
+
const json = () => field("json");
|
|
59
|
+
const refId = (target) => makeBuilder({
|
|
60
|
+
kind: "refId",
|
|
61
|
+
refTarget: target,
|
|
62
|
+
...baseFlags,
|
|
63
|
+
});
|
|
64
|
+
export const fields = {
|
|
65
|
+
id,
|
|
66
|
+
string: stringField,
|
|
67
|
+
number: numberField,
|
|
68
|
+
boolean: booleanField,
|
|
69
|
+
date,
|
|
70
|
+
json,
|
|
71
|
+
refId,
|
|
72
|
+
};
|
|
73
|
+
export function entity(def) {
|
|
74
|
+
return def;
|
|
75
|
+
}
|
|
76
|
+
export function link(def) {
|
|
77
|
+
return def;
|
|
78
|
+
}
|
|
79
|
+
export function defineSchema(schema) {
|
|
80
|
+
return schema;
|
|
81
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { SchemaDef } from "./types";
|
|
2
|
+
export interface CompiledSchema {
|
|
3
|
+
/** Registry names of every entity that was compiled. */
|
|
4
|
+
modelNames: readonly string[];
|
|
5
|
+
/** Map from schema-entity key to registry name. */
|
|
6
|
+
nameByKey: ReadonlyMap<string, string>;
|
|
7
|
+
/** Snapshot of the global registry hash after compilation. */
|
|
8
|
+
schemaHash: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Compile a `SchemaDef` produced by `defineSchema(...)` into the existing
|
|
12
|
+
* `ModelRegistry`. Each schema entity becomes a synthetic `BaseModel`
|
|
13
|
+
* subclass, registered under its PascalCased key (or `entity({ name })`
|
|
14
|
+
* override). After this returns, `ModelRegistry`, `StoreManager`, and the
|
|
15
|
+
* sync runtime see schema-defined models exactly the way they see
|
|
16
|
+
* decorator-defined ones.
|
|
17
|
+
*
|
|
18
|
+
* The function is pure with respect to the input `schema` object, but
|
|
19
|
+
* registers globally as a side effect — same contract as `@ClientModel`.
|
|
20
|
+
* Validation runs before any registry mutation; on failure the registry
|
|
21
|
+
* is untouched.
|
|
22
|
+
*
|
|
23
|
+
* The four passes below have an ordering dependency: ctors must be created
|
|
24
|
+
* before their fields can carry resolved `referenceTo` registry names; fields
|
|
25
|
+
* must exist before `registerLink` can `updateProperty` the FK; and the
|
|
26
|
+
* per-entity hash must run last so it captures every link side-effect.
|
|
27
|
+
*/
|
|
28
|
+
export declare function compileSchema(schema: SchemaDef): CompiledSchema;
|