zerodrift 1.0.4 → 1.2.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/README.md +21 -3
- package/dist/core/BaseSSEConnection.d.ts +8 -2
- package/dist/core/BaseSSEConnection.js +22 -2
- package/dist/core/Database.d.ts +2 -1
- package/dist/core/ModelStream.d.ts +2 -2
- package/dist/core/StoreManager.d.ts +34 -7
- package/dist/core/StoreManager.js +31 -6
- package/dist/core/SyncConnection.d.ts +3 -3
- package/dist/core/SyncConnection.js +7 -6
- package/dist/core/index.d.ts +2 -2
- package/dist/react/index.d.ts +32 -11
- package/dist/react/index.js +49 -34
- package/dist/schema/index.d.ts +2 -2
- package/dist/schema/index.js +1 -1
- package/dist/schema/infer.d.ts +9 -0
- package/dist/schema/zod.d.ts +125 -12
- package/dist/schema/zod.js +23 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -157,15 +157,17 @@ Both authoring paths compile to the same registry, so schema entities and decora
|
|
|
157
157
|
|
|
158
158
|
## React quick start
|
|
159
159
|
|
|
160
|
-
Wrap your app in `<SyncProvider>` once.
|
|
160
|
+
Wrap your app in `<SyncProvider>` once. For the decorator path, import your model file as a side effect so decorators run before bootstrap; for the schema-first path, pass `schema={schema}` and the provider registers entities before fetching.
|
|
161
161
|
|
|
162
162
|
```tsx
|
|
163
163
|
import { SyncProvider } from "zerodrift/react";
|
|
164
|
-
import "./
|
|
164
|
+
import { schema } from "./schema"; // schema-first
|
|
165
|
+
// import "./models"; // or: decorator path — side-effect import
|
|
165
166
|
|
|
166
167
|
export default function Providers({ children }) {
|
|
167
168
|
return (
|
|
168
169
|
<SyncProvider
|
|
170
|
+
schema={schema}
|
|
169
171
|
config={{
|
|
170
172
|
workspaceId: "workspace-123",
|
|
171
173
|
transport: {
|
|
@@ -194,6 +196,16 @@ export default function Providers({ children }) {
|
|
|
194
196
|
}
|
|
195
197
|
```
|
|
196
198
|
|
|
199
|
+
In schema-first children, pull the typed store with `useStore<typeof schema>()` (add `typeof extensions` as the second generic if you also passed extensions):
|
|
200
|
+
|
|
201
|
+
```tsx
|
|
202
|
+
import { useStore } from "zerodrift/react";
|
|
203
|
+
import { schema } from "./schema";
|
|
204
|
+
|
|
205
|
+
const store = useStore<typeof schema>();
|
|
206
|
+
const { data: issue } = useRecord(store.issue, issueId);
|
|
207
|
+
```
|
|
208
|
+
|
|
197
209
|
Common reads and writes. The read hooks take a **handle** — a model class
|
|
198
210
|
(decorator path) or a `store.<entity>` namespace (schema path) — and infer
|
|
199
211
|
the record type from it. Every result has the same shape:
|
|
@@ -206,6 +218,12 @@ const { data: teamIssues } = useRecordsByIndex(Issue, "teamId", teamId);
|
|
|
206
218
|
const { data: comments } = useRelation(issue?.comments); // a relation
|
|
207
219
|
const { phase } = useBootstrapStatus();
|
|
208
220
|
|
|
221
|
+
// Every read hook takes an optional trailing `{ pause }` — while true it reads
|
|
222
|
+
// the pool but holds all fetching (auto-fire and reload) until flipped false:
|
|
223
|
+
const { data: c } = useRecordsByIndex(store.comment, "issueId", issueId, {
|
|
224
|
+
pause: !panelOpen,
|
|
225
|
+
});
|
|
226
|
+
|
|
209
227
|
issue.title = "New title";
|
|
210
228
|
issue.save();
|
|
211
229
|
|
|
@@ -227,7 +245,7 @@ const { data: teams } = useRecords(store.team);
|
|
|
227
245
|
const { data: teamIssues } = useRecordsByIndex(store.issue, "teamId", teamId);
|
|
228
246
|
```
|
|
229
247
|
|
|
230
|
-
See [agent-docs/08-react-integration.md](agent-docs/08-react-integration.md) for hook return shapes, context-driven id generation, Storybook seeding, and testing patterns.
|
|
248
|
+
See [agent-docs/08-react-integration.md](agent-docs/08-react-integration.md) for hook return shapes, context-driven id generation, Storybook seeding, and testing patterns. For the full `transport` field list (`bootstrapSyncGroups`, `modelStreams`, `sseClientFactory`, `syncTransform`, …) see [TransportConfig reference](agent-docs/07-realtime-sync.md#transportconfig-reference).
|
|
231
249
|
|
|
232
250
|
## Headless usage
|
|
233
251
|
|
|
@@ -8,19 +8,25 @@ export interface SSEClient {
|
|
|
8
8
|
}
|
|
9
9
|
export type SSEClientFactory = (url: string) => SSEClient;
|
|
10
10
|
export type SSEErrorReporter = (err: Error, context: EngineErrorContext) => void;
|
|
11
|
+
/** Either a fixed URL or a thunk re-evaluated on every (re)connect. */
|
|
12
|
+
export type SSEEndpoint = string | (() => string);
|
|
11
13
|
export declare const createBrowserSSEFactory: (init?: EventSourceInit) => SSEClientFactory;
|
|
12
14
|
export declare abstract class BaseSSEConnection {
|
|
13
|
-
protected url:
|
|
15
|
+
protected url: SSEEndpoint;
|
|
14
16
|
private sseClientFactory;
|
|
15
17
|
private reportError?;
|
|
16
18
|
private eventSource;
|
|
17
19
|
private reconnectTimer;
|
|
18
20
|
private stopped;
|
|
19
|
-
constructor(url:
|
|
21
|
+
constructor(url: SSEEndpoint, sseClientFactory?: SSEClientFactory, reportError?: SSEErrorReporter | undefined);
|
|
20
22
|
connect(): void;
|
|
21
23
|
disconnect(): void;
|
|
22
24
|
reconnect(): void;
|
|
23
25
|
get isConnected(): boolean;
|
|
26
|
+
/** Resolve the endpoint to a concrete string. Subclasses building dynamic
|
|
27
|
+
* URLs (e.g. appending query params) must read through this instead of
|
|
28
|
+
* `this.url` directly so a thunk endpoint is re-evaluated on every connect. */
|
|
29
|
+
protected resolveUrl(): string;
|
|
24
30
|
protected buildUrl(): string;
|
|
25
31
|
protected abstract onMessage(data: string): void;
|
|
26
32
|
protected onReconnect(): void;
|
|
@@ -36,8 +36,14 @@ export class BaseSSEConnection {
|
|
|
36
36
|
get isConnected() {
|
|
37
37
|
return this.eventSource != null;
|
|
38
38
|
}
|
|
39
|
+
/** Resolve the endpoint to a concrete string. Subclasses building dynamic
|
|
40
|
+
* URLs (e.g. appending query params) must read through this instead of
|
|
41
|
+
* `this.url` directly so a thunk endpoint is re-evaluated on every connect. */
|
|
42
|
+
resolveUrl() {
|
|
43
|
+
return typeof this.url === "function" ? this.url() : this.url;
|
|
44
|
+
}
|
|
39
45
|
buildUrl() {
|
|
40
|
-
return this.
|
|
46
|
+
return this.resolveUrl();
|
|
41
47
|
}
|
|
42
48
|
onReconnect() { }
|
|
43
49
|
onOpen() { }
|
|
@@ -51,7 +57,21 @@ export class BaseSSEConnection {
|
|
|
51
57
|
this.eventSource = null;
|
|
52
58
|
this.onClose();
|
|
53
59
|
}
|
|
54
|
-
|
|
60
|
+
// buildUrl() can throw when the endpoint is a thunk (e.g. cursor read
|
|
61
|
+
// crashes). Catch + schedule a reconnect so a transient failure doesn't
|
|
62
|
+
// permanently kill the stream.
|
|
63
|
+
let url;
|
|
64
|
+
try {
|
|
65
|
+
url = this.buildUrl();
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
this.reportError?.(toError(err), {
|
|
69
|
+
kind: "sseConstruction",
|
|
70
|
+
url: "<endpoint-thunk-threw>",
|
|
71
|
+
});
|
|
72
|
+
this.scheduleReconnect();
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
55
75
|
try {
|
|
56
76
|
this.eventSource = this.sseClientFactory(url);
|
|
57
77
|
this.eventSource.onmessage = (e) => {
|
package/dist/core/Database.d.ts
CHANGED
|
@@ -201,7 +201,8 @@ export interface StorageAdapter {
|
|
|
201
201
|
close(): Promise<void>;
|
|
202
202
|
/**
|
|
203
203
|
* Close the connection AND permanently delete all persisted data.
|
|
204
|
-
*
|
|
204
|
+
* Called by StoreManager.destroy() for explicit logout / factory-reset
|
|
205
|
+
* flows — NOT for routine teardown.
|
|
205
206
|
*/
|
|
206
207
|
destroy(): Promise<void>;
|
|
207
208
|
get isConnected(): boolean;
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import type { StorageAdapter } from "./Database.js";
|
|
7
7
|
import { ObjectPool } from "./ObjectPool.js";
|
|
8
|
-
import { BaseSSEConnection, type SSEClientFactory, type SSEErrorReporter } from "./BaseSSEConnection.js";
|
|
8
|
+
import { BaseSSEConnection, type SSEClientFactory, type SSEEndpoint, type SSEErrorReporter } from "./BaseSSEConnection.js";
|
|
9
9
|
export interface ModelUpdate {
|
|
10
10
|
modelName: string;
|
|
11
11
|
modelId: string;
|
|
@@ -23,7 +23,7 @@ export declare class ModelStream extends BaseSSEConnection {
|
|
|
23
23
|
private transform?;
|
|
24
24
|
private updateQueue;
|
|
25
25
|
private processing;
|
|
26
|
-
constructor(url:
|
|
26
|
+
constructor(url: SSEEndpoint, database: StorageAdapter, pool: ObjectPool, onStatusChange?: ((connected: boolean) => void) | undefined, sseClientFactory?: SSEClientFactory, transform?: ModelStreamMessageTransform | undefined, reportError?: SSEErrorReporter);
|
|
27
27
|
disconnect(): void;
|
|
28
28
|
protected onOpen(): void;
|
|
29
29
|
protected onClose(): void;
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
import { ObjectPool } from "./ObjectPool.js";
|
|
22
22
|
import { BootstrapType, type StorageAdapter, type DatabaseMeta, type PartialIndexEntry } from "./Database.js";
|
|
23
23
|
import { TransactionQueue, type TransactionSender, type UndoableActionHandlers } from "./TransactionQueue.js";
|
|
24
|
-
import { type DeltaPacket, type SSEClientFactory, type SyncMessageTransform } from "./SyncConnection.js";
|
|
24
|
+
import { type DeltaPacket, type SSEClientFactory, type SSEEndpoint, type SyncMessageTransform } from "./SyncConnection.js";
|
|
25
25
|
import { type ModelStreamMessageTransform } from "./ModelStream.js";
|
|
26
26
|
import { type IndexBatchFetcher } from "./BatchModelLoader.js";
|
|
27
27
|
import { BaseModel } from "./BaseModel.js";
|
|
@@ -40,6 +40,17 @@ export declare class RestrictDeleteError extends Error {
|
|
|
40
40
|
restrictedByProperty: string;
|
|
41
41
|
constructor(deletedModelName: string, deletedModelId: string, restrictedByModel: string, restrictedByProperty: string);
|
|
42
42
|
}
|
|
43
|
+
/** Per-call options for the `evict*` methods. */
|
|
44
|
+
export interface EvictOptions {
|
|
45
|
+
/**
|
|
46
|
+
* Drop matching instances from the in-memory pool only, leaving the rows in
|
|
47
|
+
* IndexedDB (and the collection-coverage cache) intact. Use it on a layer
|
|
48
|
+
* switch to free memory now and rehydrate fast from IDB on return — no server
|
|
49
|
+
* round-trip. Default false: both the pool and IDB are cleared, and a future
|
|
50
|
+
* load refetches from the server.
|
|
51
|
+
*/
|
|
52
|
+
keepInDb?: boolean;
|
|
53
|
+
}
|
|
43
54
|
export interface BootstrapResponse {
|
|
44
55
|
lastSyncId: number;
|
|
45
56
|
subscribedSyncGroups: string[];
|
|
@@ -68,7 +79,7 @@ export interface BootstrapFetcherOptions extends FetcherContext {
|
|
|
68
79
|
}
|
|
69
80
|
export type BootstrapFetcher = (type: BootstrapType.Full | BootstrapType.Partial, options?: BootstrapFetcherOptions) => Promise<BootstrapResponse>;
|
|
70
81
|
export interface ModelStreamConfig {
|
|
71
|
-
url:
|
|
82
|
+
url: SSEEndpoint;
|
|
72
83
|
onStatusChange?: (connected: boolean) => void;
|
|
73
84
|
/**
|
|
74
85
|
* Use when the backend sends a different envelope than the engine's
|
|
@@ -107,7 +118,10 @@ export type OnDemandConfig = {
|
|
|
107
118
|
export interface TransportConfig {
|
|
108
119
|
bootstrapFetcher: BootstrapFetcher;
|
|
109
120
|
transactionSender?: TransactionSender;
|
|
110
|
-
|
|
121
|
+
/** SSE endpoint for live deltas — a static string, or a thunk evaluated
|
|
122
|
+
* on every (re)connect (so callers can fold in cursors from localStorage,
|
|
123
|
+
* auth tokens in the path, etc., without rebuilding the engine). */
|
|
124
|
+
syncUrl?: SSEEndpoint;
|
|
111
125
|
/**
|
|
112
126
|
* Optional async hook that returns the user's sync-group memberships
|
|
113
127
|
* before any bootstrap fetch runs. The returned groups are append-only
|
|
@@ -723,17 +737,20 @@ export declare class StoreManager<TContext = unknown> {
|
|
|
723
737
|
* Predicate receives hydrated instances (pool) and raw records (IDB); write
|
|
724
738
|
* predicates that test plain property values so they work on both shapes.
|
|
725
739
|
* IDB side is a full cursor scan — prefer `evictByIndex` when the match is
|
|
726
|
-
* "indexed column equals value".
|
|
740
|
+
* "indexed column equals value". Pass `{ keepInDb: true }` to release only
|
|
741
|
+
* the pool and leave the IDB rows cached. Returns the number of rows dropped.
|
|
727
742
|
*/
|
|
728
|
-
evictWhere(modelName: string, predicate: (m: Record<string, unknown>) => boolean): Promise<number>;
|
|
743
|
+
evictWhere(modelName: string, predicate: (m: Record<string, unknown>) => boolean, opts?: EvictOptions): Promise<number>;
|
|
729
744
|
/**
|
|
730
745
|
* Remove every record where `record[indexKey] === value`, using the IDB
|
|
731
746
|
* index for the database side. Pool side is still a linear scan (no
|
|
732
747
|
* secondary in-memory index by field value). Also clears the matching
|
|
733
748
|
* `loadedCollections` cache key so a future `getOrLoadCollection(modelName,
|
|
734
749
|
* indexKey, value)` re-fetches from the server instead of trusting IDB.
|
|
750
|
+
* Pass `{ keepInDb: true }` to release only the pool — IDB rows and the
|
|
751
|
+
* coverage entry are left intact, so the next load rehydrates from IDB.
|
|
735
752
|
*/
|
|
736
|
-
evictByIndex(modelName: string, indexKey: string, value: string): Promise<void>;
|
|
753
|
+
evictByIndex(modelName: string, indexKey: string, value: string, opts?: EvictOptions): Promise<void>;
|
|
737
754
|
/**
|
|
738
755
|
* Cascade `evictByIndex` across every model type that owns this FK. Use
|
|
739
756
|
* when an "owner" id (workspaceId, teamId, userId, …) goes away and the
|
|
@@ -741,8 +758,10 @@ export declare class StoreManager<TContext = unknown> {
|
|
|
741
758
|
* declare `indexKey` as `indexed: true` are skipped — `deleteModelsByIndex`
|
|
742
759
|
* falls back to a full-store cursor scan when the index is missing, and
|
|
743
760
|
* walking every store at every call is rarely what the caller wants.
|
|
761
|
+
* `opts` is forwarded to each `evictByIndex` — pass `{ keepInDb: true }` to
|
|
762
|
+
* release the pool for the whole scope while keeping IDB warm (layer switch).
|
|
744
763
|
*/
|
|
745
|
-
evictAllByIndex(indexKey: string, value: string): Promise<void>;
|
|
764
|
+
evictAllByIndex(indexKey: string, value: string, opts?: EvictOptions): Promise<void>;
|
|
746
765
|
/** Pool-first bulk lookup by ID (for OwnedCollection resolution). */
|
|
747
766
|
getOrLoadByIds<T extends BaseModel = BaseModel>(modelName: string, ids: string[]): Promise<T[]>;
|
|
748
767
|
/** Pool-first single-model lookup by ID. */
|
|
@@ -829,7 +848,15 @@ export declare class StoreManager<TContext = unknown> {
|
|
|
829
848
|
* cleared here because the schema-mismatch path keeps coverage entries;
|
|
830
849
|
* the `fullyLoadedModels` mirror stays consistent with that. */
|
|
831
850
|
private resetPoolState;
|
|
851
|
+
/** Routine cleanup (e.g. React unmount): stop sync, clear the object pool,
|
|
852
|
+
* and `close()` the persistence layer — persisted data is preserved so the
|
|
853
|
+
* next load can do a fast partial/local bootstrap. Not a logout. */
|
|
832
854
|
teardown(): Promise<void>;
|
|
855
|
+
/** Logout / account switch: stop sync, clear the object pool, and `destroy()`
|
|
856
|
+
* the persistence layer — permanently wiping its data, whether persistence is
|
|
857
|
+
* IndexedDB or in-memory. Unlike {@link teardown}, nothing survives. */
|
|
858
|
+
destroy(): Promise<void>;
|
|
859
|
+
private shutdown;
|
|
833
860
|
/** Debounced reconnect for SSE when `loadedModels` mutates. A burst of
|
|
834
861
|
* transitions in the same tick (or across awaited writes in the same
|
|
835
862
|
* async chain) coalesces into a single reconnect. setTimeout — not
|
|
@@ -1551,10 +1551,14 @@ export class StoreManager {
|
|
|
1551
1551
|
* Predicate receives hydrated instances (pool) and raw records (IDB); write
|
|
1552
1552
|
* predicates that test plain property values so they work on both shapes.
|
|
1553
1553
|
* IDB side is a full cursor scan — prefer `evictByIndex` when the match is
|
|
1554
|
-
* "indexed column equals value".
|
|
1554
|
+
* "indexed column equals value". Pass `{ keepInDb: true }` to release only
|
|
1555
|
+
* the pool and leave the IDB rows cached. Returns the number of rows dropped.
|
|
1555
1556
|
*/
|
|
1556
|
-
async evictWhere(modelName, predicate) {
|
|
1557
|
+
async evictWhere(modelName, predicate, opts = {}) {
|
|
1557
1558
|
const poolCount = this.evictFromPool(modelName, predicate);
|
|
1559
|
+
if (opts.keepInDb === true) {
|
|
1560
|
+
return poolCount;
|
|
1561
|
+
}
|
|
1558
1562
|
const records = await this.database.readAllModels(modelName);
|
|
1559
1563
|
const ids = records.filter(predicate).map((r) => r.id);
|
|
1560
1564
|
if (ids.length > 0) {
|
|
@@ -1568,9 +1572,16 @@ export class StoreManager {
|
|
|
1568
1572
|
* secondary in-memory index by field value). Also clears the matching
|
|
1569
1573
|
* `loadedCollections` cache key so a future `getOrLoadCollection(modelName,
|
|
1570
1574
|
* indexKey, value)` re-fetches from the server instead of trusting IDB.
|
|
1575
|
+
* Pass `{ keepInDb: true }` to release only the pool — IDB rows and the
|
|
1576
|
+
* coverage entry are left intact, so the next load rehydrates from IDB.
|
|
1571
1577
|
*/
|
|
1572
|
-
async evictByIndex(modelName, indexKey, value) {
|
|
1578
|
+
async evictByIndex(modelName, indexKey, value, opts = {}) {
|
|
1573
1579
|
this.evictFromPool(modelName, (m) => m[indexKey] === value);
|
|
1580
|
+
if (opts.keepInDb === true) {
|
|
1581
|
+
// Pool-only release: IDB rows and the collection-coverage entry stay,
|
|
1582
|
+
// so a future getOrLoadCollection rehydrates from IDB without refetch.
|
|
1583
|
+
return;
|
|
1584
|
+
}
|
|
1574
1585
|
await this.database.deleteModelsByIndex(modelName, indexKey, value);
|
|
1575
1586
|
this.partialIndexCoverage.delete(StoreManager.collectionKey(modelName, indexKey, value));
|
|
1576
1587
|
if (indexKey === ALL_INDEX_KEY_SENTINEL) {
|
|
@@ -1597,10 +1608,12 @@ export class StoreManager {
|
|
|
1597
1608
|
* declare `indexKey` as `indexed: true` are skipped — `deleteModelsByIndex`
|
|
1598
1609
|
* falls back to a full-store cursor scan when the index is missing, and
|
|
1599
1610
|
* walking every store at every call is rarely what the caller wants.
|
|
1611
|
+
* `opts` is forwarded to each `evictByIndex` — pass `{ keepInDb: true }` to
|
|
1612
|
+
* release the pool for the whole scope while keeping IDB warm (layer switch).
|
|
1600
1613
|
*/
|
|
1601
|
-
async evictAllByIndex(indexKey, value) {
|
|
1614
|
+
async evictAllByIndex(indexKey, value, opts = {}) {
|
|
1602
1615
|
const models = ModelRegistry.allModels().filter((meta) => meta.properties.get(indexKey)?.indexed === true);
|
|
1603
|
-
await Promise.all(models.map((meta) => this.evictByIndex(meta.name, indexKey, value)));
|
|
1616
|
+
await Promise.all(models.map((meta) => this.evictByIndex(meta.name, indexKey, value, opts)));
|
|
1604
1617
|
}
|
|
1605
1618
|
/** Pool-first bulk lookup by ID (for OwnedCollection resolution). */
|
|
1606
1619
|
async getOrLoadByIds(modelName, ids) {
|
|
@@ -1980,7 +1993,19 @@ export class StoreManager {
|
|
|
1980
1993
|
this.inflightFullLoads.clear();
|
|
1981
1994
|
this.seededSyncGroups = [];
|
|
1982
1995
|
}
|
|
1996
|
+
/** Routine cleanup (e.g. React unmount): stop sync, clear the object pool,
|
|
1997
|
+
* and `close()` the persistence layer — persisted data is preserved so the
|
|
1998
|
+
* next load can do a fast partial/local bootstrap. Not a logout. */
|
|
1983
1999
|
async teardown() {
|
|
2000
|
+
await this.shutdown(false);
|
|
2001
|
+
}
|
|
2002
|
+
/** Logout / account switch: stop sync, clear the object pool, and `destroy()`
|
|
2003
|
+
* the persistence layer — permanently wiping its data, whether persistence is
|
|
2004
|
+
* IndexedDB or in-memory. Unlike {@link teardown}, nothing survives. */
|
|
2005
|
+
async destroy() {
|
|
2006
|
+
await this.shutdown(true);
|
|
2007
|
+
}
|
|
2008
|
+
async shutdown(destroyData) {
|
|
1984
2009
|
this.stopped = true;
|
|
1985
2010
|
BaseModel.storeManager = null;
|
|
1986
2011
|
this.loadedModelsUnsub?.();
|
|
@@ -1998,7 +2023,7 @@ export class StoreManager {
|
|
|
1998
2023
|
this.transactionQueue.destroy();
|
|
1999
2024
|
this.indexBatchLoader?.dispose();
|
|
2000
2025
|
this.indexBatchLoader = null;
|
|
2001
|
-
await this.database.close();
|
|
2026
|
+
await (destroyData ? this.database.destroy() : this.database.close());
|
|
2002
2027
|
this.objectPool.clear();
|
|
2003
2028
|
this.stores.clear();
|
|
2004
2029
|
this.partialIndexCoverage.clear();
|
|
@@ -24,8 +24,8 @@
|
|
|
24
24
|
import type { StorageAdapter } from "./Database.js";
|
|
25
25
|
import { ObjectPool } from "./ObjectPool.js";
|
|
26
26
|
import { TransactionQueue } from "./TransactionQueue.js";
|
|
27
|
-
import { BaseSSEConnection, type SSEClientFactory, type SSEErrorReporter } from "./BaseSSEConnection.js";
|
|
28
|
-
export { type SSEClient, type SSEClientFactory, type SSEErrorReporter, createBrowserSSEFactory, } from "./BaseSSEConnection.js";
|
|
27
|
+
import { BaseSSEConnection, type SSEClientFactory, type SSEEndpoint, type SSEErrorReporter } from "./BaseSSEConnection.js";
|
|
28
|
+
export { type SSEClient, type SSEClientFactory, type SSEEndpoint, type SSEErrorReporter, createBrowserSSEFactory, } from "./BaseSSEConnection.js";
|
|
29
29
|
/**
|
|
30
30
|
* Encode each element then comma-join — the right shape for a list-of-
|
|
31
31
|
* strings inside a URL query parameter or a stable cache key. Commas
|
|
@@ -92,7 +92,7 @@ export declare class SyncConnection extends BaseSSEConnection {
|
|
|
92
92
|
* gate, which doesn't see `getOrLoadAll`'s sentinel coverage. */
|
|
93
93
|
private isModelFullyLoaded?;
|
|
94
94
|
private recordInflightDelete?;
|
|
95
|
-
constructor(url:
|
|
95
|
+
constructor(url: SSEEndpoint, database: StorageAdapter, pool: ObjectPool, queue: TransactionQueue, opts?: SyncConnectionOptions);
|
|
96
96
|
protected buildUrl(): string;
|
|
97
97
|
protected onMessage(data: string): void;
|
|
98
98
|
protected onReconnect(): void;
|
|
@@ -67,11 +67,8 @@ export class SyncConnection extends BaseSSEConnection {
|
|
|
67
67
|
const meta = this.database.currentMeta;
|
|
68
68
|
const lastSyncId = meta?.lastSyncId ?? 0;
|
|
69
69
|
const syncGroups = encodeCsvList(meta?.subscribedSyncGroups ?? []);
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
// Ephemeral, see ModelRegistry) with adapter-tracked loadedModels.
|
|
73
|
-
// Sort for a stable URL — equivalent sets must produce identical URLs
|
|
74
|
-
// so the engine doesn't churn reconnects when iteration order shifts.
|
|
70
|
+
// Sort the union — equivalent sets must produce identical URLs so the
|
|
71
|
+
// engine doesn't churn reconnects when iteration order shifts.
|
|
75
72
|
const subscribed = [
|
|
76
73
|
...new Set([
|
|
77
74
|
...ModelRegistry.alwaysSubscribedModelNames(),
|
|
@@ -79,7 +76,11 @@ export class SyncConnection extends BaseSSEConnection {
|
|
|
79
76
|
]),
|
|
80
77
|
].sort();
|
|
81
78
|
const onlyModels = subscribed.length > 0 ? `&onlyModels=${encodeCsvList(subscribed)}` : "";
|
|
82
|
-
|
|
79
|
+
const base = this.resolveUrl();
|
|
80
|
+
// Thunk endpoints often already carry query params (tenant, cursor, …);
|
|
81
|
+
// pick `&` when the base is already in query-string mode.
|
|
82
|
+
const sep = base.includes("?") ? "&" : "?";
|
|
83
|
+
return `${base}${sep}lastSyncId=${lastSyncId}&syncGroups=${syncGroups}${onlyModels}`;
|
|
83
84
|
}
|
|
84
85
|
onMessage(data) {
|
|
85
86
|
const raw = JSON.parse(data);
|
package/dist/core/index.d.ts
CHANGED
|
@@ -9,8 +9,8 @@ export { MemoryAdapter } from "./MemoryAdapter.js";
|
|
|
9
9
|
export { BootstrapType } from "./Database.js";
|
|
10
10
|
export type { DatabaseMeta, StorageAdapter } from "./Database.js";
|
|
11
11
|
export { StoreManager, RestrictDeleteError } from "./StoreManager.js";
|
|
12
|
-
export type { BootstrapResponse, BootstrapFetcher, BootstrapFetcherOptions, FetcherContext, StoreManagerConfig, TransportConfig, LoadingConfig, PersistenceConfig, HooksConfig, AdvancedConfig, OnDemandConfig, OnDemandFetcher, OnDemandBatchFetcher, ModelStreamConfig, } from "./StoreManager.js";
|
|
12
|
+
export type { BootstrapResponse, EvictOptions, BootstrapFetcher, BootstrapFetcherOptions, FetcherContext, StoreManagerConfig, TransportConfig, LoadingConfig, PersistenceConfig, HooksConfig, AdvancedConfig, OnDemandConfig, OnDemandFetcher, OnDemandBatchFetcher, ModelStreamConfig, } from "./StoreManager.js";
|
|
13
13
|
export type { UndoableAction } from "./Transaction.js";
|
|
14
14
|
export type { TransactionSender, BatchResponse, UndoableActionHandlers, UndoResult, } from "./TransactionQueue.js";
|
|
15
|
-
export type { SyncAction, DeltaPacket, SyncMessageTransform, } from "./SyncConnection.js";
|
|
15
|
+
export type { SyncAction, DeltaPacket, SSEEndpoint, SyncMessageTransform, } from "./SyncConnection.js";
|
|
16
16
|
export type { ModelUpdate, ModelStreamMessageTransform } from "./ModelStream.js";
|
package/dist/react/index.d.ts
CHANGED
|
@@ -11,12 +11,16 @@ import { StoreManager, type StoreManagerConfig } from "../core/StoreManager.js";
|
|
|
11
11
|
import { BootstrapPhase } from "../core/types.js";
|
|
12
12
|
import { LazyCollectionBase, BackRef } from "../core/LazyCollection.js";
|
|
13
13
|
import type { BaseModel } from "../core/BaseModel.js";
|
|
14
|
+
import { type EntityNamespace, type EntityStore, type RecordWithExtensions } from "../schema/createStore.js";
|
|
15
|
+
import type { ExtensionDescriptor } from "../schema/extend.js";
|
|
16
|
+
import type { EntityKey, IndexedFieldKeys } from "../schema/infer.js";
|
|
17
|
+
import type { SchemaDef } from "../schema/types.js";
|
|
14
18
|
export interface SyncStatus {
|
|
15
19
|
phase: BootstrapPhase;
|
|
16
20
|
detail?: string;
|
|
17
21
|
error?: string;
|
|
18
22
|
}
|
|
19
|
-
export declare function SyncProvider<TContext = unknown>({ config, context, children, fallback, }: {
|
|
23
|
+
export declare function SyncProvider<TContext = unknown, S extends SchemaDef = SchemaDef>({ config, schema, extensions, context, children, fallback, }: {
|
|
20
24
|
config: StoreManagerConfig<TContext>;
|
|
21
25
|
/** Live context forwarded to `StoreManager.setContext` — consumed by
|
|
22
26
|
* `identifierFn` when minting ids for client-side models. Pushed
|
|
@@ -25,12 +29,22 @@ export declare function SyncProvider<TContext = unknown>({ config, context, chil
|
|
|
25
29
|
children: React.ReactNode;
|
|
26
30
|
/** Shown while bootstrap is in progress. */
|
|
27
31
|
fallback?: React.ReactNode;
|
|
28
|
-
}
|
|
32
|
+
} & ({
|
|
33
|
+
schema?: undefined;
|
|
34
|
+
extensions?: undefined;
|
|
35
|
+
} | {
|
|
36
|
+
/** Schema-first wiring. Read the store back with `useStore<typeof schema>()`. */
|
|
37
|
+
schema: S;
|
|
38
|
+
extensions?: readonly ExtensionDescriptor<S>[];
|
|
39
|
+
})): import("react/jsx-runtime").JSX.Element | null;
|
|
29
40
|
export declare function useSyncEngine(): {
|
|
30
41
|
sm: StoreManager<any>;
|
|
31
42
|
status: SyncStatus;
|
|
43
|
+
store?: EntityStore<any, any>;
|
|
32
44
|
};
|
|
33
45
|
export declare function useBootstrapStatus(): SyncStatus;
|
|
46
|
+
/** Read the schema-first store from context — `useStore<typeof schema>()`. */
|
|
47
|
+
export declare function useStore<S extends SchemaDef, const Exts extends readonly ExtensionDescriptor<S>[] = readonly []>(): EntityStore<S, Exts>;
|
|
34
48
|
/**
|
|
35
49
|
* Uniform async-resource shape for every load-aware hook (`useRecord`,
|
|
36
50
|
* `useRecords`, `useRecordsByIndex`, `useRelation`). `data` is the payload
|
|
@@ -45,6 +59,17 @@ export interface AsyncResource<T> {
|
|
|
45
59
|
error: Error | null;
|
|
46
60
|
reload: () => Promise<void>;
|
|
47
61
|
}
|
|
62
|
+
/** Per-call options shared by the load-aware read hooks. */
|
|
63
|
+
export interface UseQueryOptions {
|
|
64
|
+
/**
|
|
65
|
+
* When true, hold all fetching — auto-fire on mount/dependency change *and*
|
|
66
|
+
* `reload()` are suppressed until it flips false. The hook still reads the
|
|
67
|
+
* pool synchronously, so anything already resident renders immediately;
|
|
68
|
+
* only the async backfill waits. Use it to defer a fetch until a prerequisite
|
|
69
|
+
* is ready (auth resolved, a parent record loaded, a panel actually opened).
|
|
70
|
+
*/
|
|
71
|
+
pause?: boolean;
|
|
72
|
+
}
|
|
48
73
|
/** Returns `store.batch` — the sync overload yields the `batchId` string,
|
|
49
74
|
* the async overload a `Promise<string>`. */
|
|
50
75
|
export declare function useBatch(): StoreManager["batch"];
|
|
@@ -54,12 +79,8 @@ export declare function useUndoRedo(): {
|
|
|
54
79
|
canUndo: boolean;
|
|
55
80
|
canRedo: boolean;
|
|
56
81
|
};
|
|
57
|
-
export declare function useRelation<T extends BaseModel = BaseModel>(relation: LazyCollectionBase<T> | null | undefined): AsyncResource<T[]>;
|
|
58
|
-
export declare function useRelation<T extends BaseModel = BaseModel>(relation: BackRef<T> | null | undefined): AsyncResource<T | null>;
|
|
59
|
-
import { type EntityNamespace, type RecordWithExtensions } from "../schema/createStore.js";
|
|
60
|
-
import type { ExtensionDescriptor } from "../schema/extend.js";
|
|
61
|
-
import type { EntityKey, IndexedFieldKeys } from "../schema/infer.js";
|
|
62
|
-
import type { SchemaDef } from "../schema/types.js";
|
|
82
|
+
export declare function useRelation<T extends BaseModel = BaseModel>(relation: LazyCollectionBase<T> | null | undefined, opts?: UseQueryOptions): AsyncResource<T[]>;
|
|
83
|
+
export declare function useRelation<T extends BaseModel = BaseModel>(relation: BackRef<T> | null | undefined, opts?: UseQueryOptions): AsyncResource<T | null>;
|
|
63
84
|
type AnyNamespace = EntityNamespace<any, any, any>;
|
|
64
85
|
type ModelCtor<T extends BaseModel = BaseModel> = abstract new (...args: any[]) => T;
|
|
65
86
|
type Handle = AnyNamespace | ModelCtor;
|
|
@@ -73,10 +94,10 @@ type RecordOf<H> = H extends ModelCtor<infer T> ? T : RecordOfNamespace<H>;
|
|
|
73
94
|
* namespace, unconstrained `string` for a decorator class. */
|
|
74
95
|
type IndexKeyOf<H> = H extends ModelCtor ? string : IndexKeyOfNamespace<H>;
|
|
75
96
|
/** Reactive single record by id. Pool-first sync read; async backfill on miss. */
|
|
76
|
-
export declare function useRecord<H extends Handle>(handle: H, id: string | null | undefined): AsyncResource<RecordOf<H> | null>;
|
|
97
|
+
export declare function useRecord<H extends Handle>(handle: H, id: string | null | undefined, opts?: UseQueryOptions): AsyncResource<RecordOf<H> | null>;
|
|
77
98
|
/** Reactive list of records, optionally filtered to (and ordered by) `ids`. */
|
|
78
|
-
export declare function useRecords<H extends Handle>(handle: H, ids?: string[] | null): AsyncResource<RecordOf<H>[]>;
|
|
99
|
+
export declare function useRecords<H extends Handle>(handle: H, ids?: string[] | null, opts?: UseQueryOptions): AsyncResource<RecordOf<H>[]>;
|
|
79
100
|
/** Reactive list of records matching one value, or any of several, on a
|
|
80
101
|
* foreign-key index. */
|
|
81
|
-
export declare function useRecordsByIndex<H extends Handle>(handle: H, indexKey: IndexKeyOf<H>, value: string | readonly string[] | null | undefined): AsyncResource<RecordOf<H>[]>;
|
|
102
|
+
export declare function useRecordsByIndex<H extends Handle>(handle: H, indexKey: IndexKeyOf<H>, value: string | readonly string[] | null | undefined, opts?: UseQueryOptions): AsyncResource<RecordOf<H>[]>;
|
|
82
103
|
export {};
|
package/dist/react/index.js
CHANGED
|
@@ -12,18 +12,25 @@ import { StoreManager } from "../core/StoreManager.js";
|
|
|
12
12
|
import { BootstrapPhase } from "../core/types.js";
|
|
13
13
|
import { BackRef } from "../core/LazyCollection.js";
|
|
14
14
|
import { readFk } from "../core/ObjectPool.js";
|
|
15
|
+
import { createStore, entityNamespaceRegistryName, } from "../schema/createStore.js";
|
|
15
16
|
// `<any>` keeps the hooks free of TContext — none of them touch it.
|
|
16
17
|
const SyncContext = createContext(null);
|
|
17
18
|
// ---------------------------------------------------------------------------
|
|
18
19
|
// Provider
|
|
19
20
|
// ---------------------------------------------------------------------------
|
|
20
|
-
export function SyncProvider({ config, context, children, fallback, }) {
|
|
21
|
+
export function SyncProvider({ config, schema, extensions, context, children, fallback, }) {
|
|
21
22
|
const [status, setStatus] = useState({
|
|
22
23
|
phase: BootstrapPhase.Idle,
|
|
23
24
|
});
|
|
24
25
|
const smRef = useRef(null);
|
|
26
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
27
|
+
const storeRef = useRef(undefined);
|
|
25
28
|
const cfgRef = useRef(config);
|
|
26
29
|
cfgRef.current = config;
|
|
30
|
+
const schemaRef = useRef(schema);
|
|
31
|
+
schemaRef.current = schema;
|
|
32
|
+
const extensionsRef = useRef(extensions);
|
|
33
|
+
extensionsRef.current = extensions;
|
|
27
34
|
// Detect bfcache restores. When a tab is duplicated (or the user navigates
|
|
28
35
|
// back/forward) the browser may restore the page from its back/forward cache
|
|
29
36
|
// (bfcache). In that case the JS heap is frozen and thawed — React effects do
|
|
@@ -60,6 +67,15 @@ export function SyncProvider({ config, context, children, fallback, }) {
|
|
|
60
67
|
if (contextRef.current !== undefined) {
|
|
61
68
|
sm.setContext(contextRef.current);
|
|
62
69
|
}
|
|
70
|
+
// createStore registers schema entities into ModelRegistry — must
|
|
71
|
+
// run before bootstrap() so the first fetch sees them.
|
|
72
|
+
if (schemaRef.current != null) {
|
|
73
|
+
storeRef.current = createStore({
|
|
74
|
+
schema: schemaRef.current,
|
|
75
|
+
storeManager: sm,
|
|
76
|
+
extensions: extensionsRef.current,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
63
79
|
smRef.current = sm;
|
|
64
80
|
sm.bootstrap().catch((err) => {
|
|
65
81
|
if (active) {
|
|
@@ -70,6 +86,7 @@ export function SyncProvider({ config, context, children, fallback, }) {
|
|
|
70
86
|
active = false;
|
|
71
87
|
sm.teardown();
|
|
72
88
|
smRef.current = null;
|
|
89
|
+
storeRef.current = undefined;
|
|
73
90
|
};
|
|
74
91
|
}, [cfgRef.current.workspaceId]);
|
|
75
92
|
// Push context updates synchronously so an event handler dispatched in the
|
|
@@ -87,7 +104,7 @@ export function SyncProvider({ config, context, children, fallback, }) {
|
|
|
87
104
|
fallback != null) {
|
|
88
105
|
return _jsx(_Fragment, { children: fallback });
|
|
89
106
|
}
|
|
90
|
-
return (_jsx(SyncContext.Provider, { value: { sm: smRef.current, status }, children: children }));
|
|
107
|
+
return (_jsx(SyncContext.Provider, { value: { sm: smRef.current, status, store: storeRef.current }, children: children }));
|
|
91
108
|
}
|
|
92
109
|
// ---------------------------------------------------------------------------
|
|
93
110
|
// Core hook
|
|
@@ -102,6 +119,14 @@ export function useSyncEngine() {
|
|
|
102
119
|
export function useBootstrapStatus() {
|
|
103
120
|
return useSyncEngine().status;
|
|
104
121
|
}
|
|
122
|
+
/** Read the schema-first store from context — `useStore<typeof schema>()`. */
|
|
123
|
+
export function useStore() {
|
|
124
|
+
const ctx = useSyncEngine();
|
|
125
|
+
if (ctx.store == null) {
|
|
126
|
+
throw new Error("useStore() requires <SyncProvider schema={…}>.");
|
|
127
|
+
}
|
|
128
|
+
return ctx.store;
|
|
129
|
+
}
|
|
105
130
|
/** Subscribe to a model type's pool changes and read a snapshot synchronously.
|
|
106
131
|
*
|
|
107
132
|
* `getSnapshot` is intentionally NOT stabilized — useSyncExternalStore calls
|
|
@@ -159,12 +184,13 @@ const settled = (ready, isLoading, error) => ready && !isLoading && error == nul
|
|
|
159
184
|
// (schema namespace or model class) to a registry name and delegate here,
|
|
160
185
|
// so the pool-subscription / loader machinery lives in exactly one place.
|
|
161
186
|
/** Reactive single model by id. Pool-first sync read; async backfill on miss. */
|
|
162
|
-
function useRecordByName(modelName, id) {
|
|
187
|
+
function useRecordByName(modelName, id, opts) {
|
|
163
188
|
const { sm, status } = useSyncEngine();
|
|
164
189
|
const pool = sm.objectPool;
|
|
165
190
|
const ready = status.phase === BootstrapPhase.Ready;
|
|
191
|
+
const pause = opts?.pause ?? false;
|
|
166
192
|
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 ?? ""}`,
|
|
193
|
+
const { isLoading, error, reload } = useLoader(() => sm.getOrLoadById(modelName, id), ready && id != null && !pause, `${modelName}:${id ?? ""}`,
|
|
168
194
|
// Skip the load when the pool already has the entry — eager models
|
|
169
195
|
// render with isLoading: false from frame zero.
|
|
170
196
|
() => id != null && pool.getById(modelName, id) == null);
|
|
@@ -180,10 +206,11 @@ function useRecordByName(modelName, id) {
|
|
|
180
206
|
* set. Without `ids`: every instance in the pool. With `ids`: just those, in
|
|
181
207
|
* the order given, with async backfill for any missing from the pool. The
|
|
182
208
|
* ids array is compared by content so inline literals don't cause re-fetches. */
|
|
183
|
-
function useRecordsByName(modelName, ids) {
|
|
209
|
+
function useRecordsByName(modelName, ids, opts) {
|
|
184
210
|
const { sm, status } = useSyncEngine();
|
|
185
211
|
const pool = sm.objectPool;
|
|
186
212
|
const ready = status.phase === BootstrapPhase.Ready;
|
|
213
|
+
const pause = opts?.pause ?? false;
|
|
187
214
|
const idsKey = ids?.join(",") ?? "";
|
|
188
215
|
const all = usePoolSnapshot(modelName, () => pool.getAll(modelName));
|
|
189
216
|
const items = useMemo(() => {
|
|
@@ -195,7 +222,7 @@ function useRecordsByName(modelName, ids) {
|
|
|
195
222
|
.map((id) => byId.get(id))
|
|
196
223
|
.filter((m) => m != null);
|
|
197
224
|
}, [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));
|
|
225
|
+
const { isLoading, error, reload } = useLoader(() => sm.getOrLoadByIds(modelName, ids ?? []), ready && ids != null && ids.length > 0 && !pause, `${modelName}:${idsKey}`, () => ids != null && ids.some((id) => pool.getById(modelName, id) == null));
|
|
199
226
|
return {
|
|
200
227
|
data: ready ? items : [],
|
|
201
228
|
isLoading,
|
|
@@ -214,9 +241,10 @@ function useRecordsByName(modelName, ids) {
|
|
|
214
241
|
* For one-round-trip multi-value fetches, configure
|
|
215
242
|
* `onDemandIndexBatchFetcher` + `serverSupportsCompoundIndexKeys: true` —
|
|
216
243
|
* see `agent-docs/04-lazy-loading.md`. */
|
|
217
|
-
function useRecordsByIndexName(modelName, indexKey, value) {
|
|
244
|
+
function useRecordsByIndexName(modelName, indexKey, value, opts) {
|
|
218
245
|
const { sm, status } = useSyncEngine();
|
|
219
246
|
const ready = status.phase === BootstrapPhase.Ready;
|
|
247
|
+
const pause = opts?.pause ?? false;
|
|
220
248
|
const values = value == null
|
|
221
249
|
? []
|
|
222
250
|
: (Array.isArray(value) ? value : [value]).filter((v) => v != null && v !== "");
|
|
@@ -236,7 +264,7 @@ function useRecordsByIndexName(modelName, indexKey, value) {
|
|
|
236
264
|
}, [all, indexKey, valuesKey, hasValues]);
|
|
237
265
|
const { isLoading, error, reload } = useLoader(async () => {
|
|
238
266
|
await Promise.all(values.map((v) => sm.getOrLoadCollection(modelName, indexKey, v)));
|
|
239
|
-
}, ready && hasValues, `${modelName}:${indexKey}:${valuesKey}`, () => hasValues &&
|
|
267
|
+
}, ready && hasValues && !pause, `${modelName}:${indexKey}:${valuesKey}`, () => hasValues &&
|
|
240
268
|
values.some((v) => !sm.isCollectionLoaded(modelName, indexKey, v)));
|
|
241
269
|
return {
|
|
242
270
|
data: ready ? items : [],
|
|
@@ -276,9 +304,10 @@ export function useUndoRedo() {
|
|
|
276
304
|
canRedo: redoDepth > 0,
|
|
277
305
|
};
|
|
278
306
|
}
|
|
279
|
-
export function useRelation(relation) {
|
|
307
|
+
export function useRelation(relation, opts) {
|
|
280
308
|
const [tick, forceRender] = useState(0);
|
|
281
309
|
const isBackRef = relation instanceof BackRef;
|
|
310
|
+
const pause = opts?.pause ?? false;
|
|
282
311
|
// Collections expose watch() for invalidation; BackRef does not.
|
|
283
312
|
useEffect(() => {
|
|
284
313
|
if (relation == null || isBackRef) {
|
|
@@ -287,10 +316,10 @@ export function useRelation(relation) {
|
|
|
287
316
|
return relation.watch(() => forceRender((n) => n + 1));
|
|
288
317
|
}, [relation, isBackRef]);
|
|
289
318
|
useEffect(() => {
|
|
290
|
-
if (relation != null && !relation.isLoaded && !relation.isLoading) {
|
|
319
|
+
if (!pause && relation != null && !relation.isLoaded && !relation.isLoading) {
|
|
291
320
|
relation.load().then(() => forceRender((n) => n + 1));
|
|
292
321
|
}
|
|
293
|
-
}, [relation, tick]);
|
|
322
|
+
}, [relation, tick, pause]);
|
|
294
323
|
if (relation == null) {
|
|
295
324
|
// Can't tell collection from back-ref at null; `[]` is the map-safe
|
|
296
325
|
// default. A null back-ref reads `[]` rather than `null` — harmless
|
|
@@ -312,6 +341,9 @@ export function useRelation(relation) {
|
|
|
312
341
|
isLoaded: relation.isLoaded ?? false,
|
|
313
342
|
error: relation.error ?? null,
|
|
314
343
|
reload: async () => {
|
|
344
|
+
if (pause) {
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
315
347
|
await (isBackRef
|
|
316
348
|
? relation.load()
|
|
317
349
|
: relation.reload());
|
|
@@ -328,23 +360,6 @@ function useStableCallback(callback) {
|
|
|
328
360
|
}, [callback]);
|
|
329
361
|
return stableRef.current;
|
|
330
362
|
}
|
|
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.js";
|
|
348
363
|
function handleRegistryName(handle) {
|
|
349
364
|
if (typeof handle === "function") {
|
|
350
365
|
// Set by @ClientModel (explicit { name } or ctor.name fallback).
|
|
@@ -359,15 +374,15 @@ function handleRegistryName(handle) {
|
|
|
359
374
|
// has `__mobx`/`store`; RecordOf has schema fields + extensions). One cast
|
|
360
375
|
// per wrapper, contained.
|
|
361
376
|
/** 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);
|
|
377
|
+
export function useRecord(handle, id, opts) {
|
|
378
|
+
return useRecordByName(handleRegistryName(handle), id, opts);
|
|
364
379
|
}
|
|
365
380
|
/** Reactive list of records, optionally filtered to (and ordered by) `ids`. */
|
|
366
|
-
export function useRecords(handle, ids) {
|
|
367
|
-
return useRecordsByName(handleRegistryName(handle), ids);
|
|
381
|
+
export function useRecords(handle, ids, opts) {
|
|
382
|
+
return useRecordsByName(handleRegistryName(handle), ids, opts);
|
|
368
383
|
}
|
|
369
384
|
/** Reactive list of records matching one value, or any of several, on a
|
|
370
385
|
* foreign-key index. */
|
|
371
|
-
export function useRecordsByIndex(handle, indexKey, value) {
|
|
372
|
-
return useRecordsByIndexName(handleRegistryName(handle), indexKey, value);
|
|
386
|
+
export function useRecordsByIndex(handle, indexKey, value, opts) {
|
|
387
|
+
return useRecordsByIndexName(handleRegistryName(handle), indexKey, value, opts);
|
|
373
388
|
}
|
package/dist/schema/index.d.ts
CHANGED
|
@@ -7,7 +7,7 @@ export { createStore } from "./createStore.js";
|
|
|
7
7
|
export type { EntityStore, EntityNamespace, RecordWithExtensions, StoreApi, } from "./createStore.js";
|
|
8
8
|
export { extend } from "./extend.js";
|
|
9
9
|
export type { ActionFn, ComputedFn, ExtensionDef, ExtensionDescriptor, ExtensionMap, MergedExtensionMembers, } from "./extend.js";
|
|
10
|
-
export { fromZod, entityFromZod } from "./zod.js";
|
|
10
|
+
export { fromZod, entityFromZod, entitiesFromZod } from "./zod.js";
|
|
11
11
|
export type { EntityFromZodFieldOverride, EntityFromZodOpts } from "./zod.js";
|
|
12
12
|
export type { EntityDef, FieldBuilder, FieldKind, FieldMeta, LinkDef, LinkFromSpec, LinkToSpec, OnDelete, SchemaDef, } from "./types.js";
|
|
13
|
-
export type { EntityKey, IndexedFieldKeys, InferCreateInput, InferEntity, InferUpdateInput, RelationCollection, } from "./infer.js";
|
|
13
|
+
export type { EntityKey, IndexedFieldKeys, InferCreateInput, InferEntity, InferRecord, InferUpdateInput, RelationCollection, } from "./infer.js";
|
package/dist/schema/index.js
CHANGED
|
@@ -5,4 +5,4 @@ export { fields as s } from "./builders.js";
|
|
|
5
5
|
export { compileSchema } from "./compile.js";
|
|
6
6
|
export { createStore } from "./createStore.js";
|
|
7
7
|
export { extend } from "./extend.js";
|
|
8
|
-
export { fromZod, entityFromZod } from "./zod.js";
|
|
8
|
+
export { fromZod, entityFromZod, entitiesFromZod } from "./zod.js";
|
package/dist/schema/infer.d.ts
CHANGED
|
@@ -81,6 +81,15 @@ type ReverseCollections<S extends SchemaDef, K extends EntityKey<S>> = {
|
|
|
81
81
|
* with a one-line helper at the call site if they want.
|
|
82
82
|
*/
|
|
83
83
|
export type InferEntity<S extends SchemaDef, K extends EntityKey<S>> = EntityFieldTypes<S, K> & SingularRelations<S, K> & ReverseCollections<S, K>;
|
|
84
|
+
/**
|
|
85
|
+
* Alias for {@link InferEntity}. Provided so the three record-shape helpers
|
|
86
|
+
* read uniformly at call sites:
|
|
87
|
+
*
|
|
88
|
+
* type Need = InferRecord<typeof schema, "need">;
|
|
89
|
+
* type NeedCreate = InferCreateInput<typeof schema, "need">;
|
|
90
|
+
* type NeedUpdate = InferUpdateInput<typeof schema, "need">;
|
|
91
|
+
*/
|
|
92
|
+
export type InferRecord<S extends SchemaDef, K extends EntityKey<S>> = InferEntity<S, K>;
|
|
84
93
|
/**
|
|
85
94
|
* A create-input field is optional when the runtime can fill it without
|
|
86
95
|
* the caller: id-kind (BaseModel auto-assigns a UUID), defaulted fields,
|
package/dist/schema/zod.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { z } from "zod";
|
|
2
|
-
import type { AnyFieldBuilder, EntityDef, FieldBuilder } from "./types.js";
|
|
2
|
+
import type { AnyFieldBuilder, EntityDef, FieldBuilder, FieldKind, FieldMeta } from "./types.js";
|
|
3
3
|
/**
|
|
4
4
|
* Convert any Zod schema into the equivalent schema-first `FieldBuilder`.
|
|
5
5
|
* Handles the common nullable / optional / default modifiers; anything more
|
|
@@ -10,31 +10,111 @@ import type { AnyFieldBuilder, EntityDef, FieldBuilder } from "./types.js";
|
|
|
10
10
|
* caller to have installed `zod`.
|
|
11
11
|
*/
|
|
12
12
|
export declare function fromZod<Z extends z.ZodType>(zSchema: Z): FieldBuilder<z.infer<Z>>;
|
|
13
|
+
/**
|
|
14
|
+
* Maps Zod's `_zod.def.type` discriminator to a schema `FieldKind`. Mirrors
|
|
15
|
+
* the runtime `PRIMITIVE_KIND` map; anything not covered collapses to `"json"`,
|
|
16
|
+
* matching `fromZod`'s fallback.
|
|
17
|
+
*/
|
|
18
|
+
type ZodKindFromTypeName<T> = T extends "string" ? "string" : T extends "number" | "int" ? "number" : T extends "boolean" ? "boolean" : T extends "date" ? "date" : "json";
|
|
19
|
+
/**
|
|
20
|
+
* Type-level analogue of `fromZod`'s runtime walker: peels off `nullable` /
|
|
21
|
+
* `optional` / `default` wrappers, stamping each on the accumulator, then
|
|
22
|
+
* collapses the leaf to a kind via `ZodKindFromTypeName`. The result is
|
|
23
|
+
* intersected with `FieldMeta` so `IsOptionalCreateField` sees the same
|
|
24
|
+
* `{kind, optional, default}` flags the runtime produces — keeping
|
|
25
|
+
* create-input optionality aligned for `.optional()` and `.default(...)`
|
|
26
|
+
* Zod fields, not just `id`.
|
|
27
|
+
*/
|
|
28
|
+
type ZodToFieldMeta<Z, Accum = Record<never, never>> = Z extends {
|
|
29
|
+
_zod: {
|
|
30
|
+
def: {
|
|
31
|
+
type: "nullable";
|
|
32
|
+
innerType: infer I;
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
} ? ZodToFieldMeta<I, Accum & {
|
|
36
|
+
nullable: true;
|
|
37
|
+
}> : Z extends {
|
|
38
|
+
_zod: {
|
|
39
|
+
def: {
|
|
40
|
+
type: "optional";
|
|
41
|
+
innerType: infer I;
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
} ? ZodToFieldMeta<I, Accum & {
|
|
45
|
+
optional: true;
|
|
46
|
+
}> : Z extends {
|
|
47
|
+
_zod: {
|
|
48
|
+
def: {
|
|
49
|
+
type: "default";
|
|
50
|
+
innerType: infer I;
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
} ? ZodToFieldMeta<I, Accum & {
|
|
54
|
+
default: unknown;
|
|
55
|
+
}> : Z extends {
|
|
56
|
+
_zod: {
|
|
57
|
+
def: {
|
|
58
|
+
type: infer T extends string;
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
} ? Accum & {
|
|
62
|
+
kind: ZodKindFromTypeName<T>;
|
|
63
|
+
} : Accum;
|
|
64
|
+
/** Empty meta marker — convention-driven flags (autoIndex) layer on top.
|
|
65
|
+
* `id` is excluded because the storage layer treats the PK specially and
|
|
66
|
+
* never materializes a secondary index for it; the empty-string suffix is
|
|
67
|
+
* excluded so a defaulted/blank config value doesn't index every field. */
|
|
68
|
+
type AutoIndexMeta<K, AI extends string | undefined> = K extends "id" ? Record<never, never> : AI extends "" ? Record<never, never> : AI extends string ? K extends `${string}${AI}` ? {
|
|
69
|
+
indexed: true;
|
|
70
|
+
} : Record<never, never> : Record<never, never>;
|
|
71
|
+
/**
|
|
72
|
+
* Auto-derived `FieldBuilder` type for a Zod-object key. The `id` key is
|
|
73
|
+
* special-cased to carry `{kind: "id"}` (the runtime routes `id` through
|
|
74
|
+
* `fields.id()` regardless of the Zod-declared id type). Every other key
|
|
75
|
+
* walks its Zod schema via `ZodToFieldMeta` so PK, optional, and default
|
|
76
|
+
* flags all flow into the field's create-input optionality the same way.
|
|
77
|
+
* `AI` is the opts.autoIndex suffix — matching keys pick up `{indexed: true}`.
|
|
78
|
+
*/
|
|
79
|
+
type AutoFieldFromZod<K, ZS, AI extends string | undefined = undefined> = K extends "id" ? FieldBuilder<string, FieldMeta & {
|
|
80
|
+
kind: "id";
|
|
81
|
+
} & AutoIndexMeta<K, AI>> : ZS extends z.ZodType ? FieldBuilder<z.infer<ZS>, FieldMeta & ZodToFieldMeta<ZS> & AutoIndexMeta<K, AI>> : FieldBuilder<unknown, FieldMeta & {
|
|
82
|
+
kind: FieldKind;
|
|
83
|
+
}>;
|
|
13
84
|
/**
|
|
14
85
|
* Per-field override for `entityFromZod`. Either a chaining function
|
|
15
86
|
* (modifies the auto-derived `FieldBuilder`) or a full `FieldBuilder`
|
|
16
87
|
* (replaces it — useful for FKs and other shapes Zod can't model).
|
|
17
88
|
*/
|
|
18
89
|
export type EntityFromZodFieldOverride<AutoT = unknown> = AnyFieldBuilder | ((auto: FieldBuilder<AutoT>) => AnyFieldBuilder);
|
|
19
|
-
type EntityFromZodFieldOverrides<Z extends z.ZodObject> = {
|
|
20
|
-
[K in keyof
|
|
90
|
+
type EntityFromZodFieldOverrides<Z extends z.ZodObject, AI extends string | undefined = undefined> = {
|
|
91
|
+
[K in keyof Z["shape"] & string]?: AnyFieldBuilder | ((auto: AutoFieldFromZod<K, Z["shape"][K], AI>) => unknown);
|
|
92
|
+
};
|
|
93
|
+
/**
|
|
94
|
+
* Maps any `opts.fields` key that isn't declared on the Zod object to a
|
|
95
|
+
* branded error-string type. The intersection with the override map then
|
|
96
|
+
* forces TS to surface "is not assignable to type \"Error: 'foo' is not a
|
|
97
|
+
* field…\"" — naming the offender — instead of the bare "not assignable to
|
|
98
|
+
* type 'never'" the previous `Record<…, never>` form produced.
|
|
99
|
+
*/
|
|
100
|
+
type NoExtraZodFieldKeys<Z extends z.ZodObject, F> = {
|
|
101
|
+
[K in keyof F as K extends keyof Z["shape"] ? never : K]: `Error: '${K & string}' is not a field declared on the Zod object passed to entityFromZod`;
|
|
21
102
|
};
|
|
22
|
-
type NoExtraZodFieldKeys<Z extends z.ZodObject, F> = Record<Exclude<keyof F, keyof z.infer<Z> & string>, never>;
|
|
23
103
|
/**
|
|
24
104
|
* Resolve the field type contributed by an override entry. Functions are
|
|
25
105
|
* unwrapped via their inferred return type so chained modifiers like
|
|
26
106
|
* `.indexed()` carry their narrowed `M` into the entity's inferred fields;
|
|
27
107
|
* direct `FieldBuilder` overrides are used as-is. When no override is
|
|
28
|
-
* provided the auto-derived `
|
|
108
|
+
* provided the auto-derived `Auto` field stands.
|
|
29
109
|
*/
|
|
30
|
-
type FieldFromOverride<O,
|
|
110
|
+
type FieldFromOverride<O, Auto> = O extends (...args: never[]) => infer R ? R extends FieldBuilder<infer RT, infer RM> ? [unknown] extends [RT] ? Auto extends FieldBuilder<infer AT, FieldMeta> ? FieldBuilder<AT, RM> : never : R : Auto : O extends AnyFieldBuilder ? O : Auto;
|
|
31
111
|
/**
|
|
32
112
|
* Per-key merge of the Zod-inferred fields with `opts.fields` overrides.
|
|
33
113
|
* Override metadata (`.indexed()`, refId target, …) propagates into the
|
|
34
114
|
* entity's TS type so downstream helpers like `IndexedFieldKeys` see them.
|
|
35
115
|
*/
|
|
36
|
-
type MergedFieldsFromZodObject<Z extends z.ZodObject, F> = {
|
|
37
|
-
[K in keyof
|
|
116
|
+
type MergedFieldsFromZodObject<Z extends z.ZodObject, F, Om extends readonly string[] = readonly [], AI extends string | undefined = undefined> = {
|
|
117
|
+
[K in keyof Z["shape"] as K extends Om[number] ? never : K]: K extends keyof F ? FieldFromOverride<F[K], AutoFieldFromZod<K, Z["shape"][K], AI>> : AutoFieldFromZod<K, Z["shape"][K], AI>;
|
|
38
118
|
};
|
|
39
119
|
/** Non-`fields` portion of the opts — shared across the public type and
|
|
40
120
|
* the function's inferred-`F` signature. Tracks `EntityDef` so any new
|
|
@@ -56,8 +136,15 @@ export interface EntityFromZodOpts<Z extends z.ZodObject = z.ZodObject> extends
|
|
|
56
136
|
* });
|
|
57
137
|
*/
|
|
58
138
|
fields?: {
|
|
59
|
-
[K in keyof
|
|
139
|
+
[K in keyof Z["shape"] & string]?: AnyFieldBuilder | ((auto: AutoFieldFromZod<K, Z["shape"][K]>) => AnyFieldBuilder);
|
|
60
140
|
};
|
|
141
|
+
/** Fields whose Zod name ends with this suffix pick up `.indexed()` —
|
|
142
|
+
* intended for the common FK-naming convention (`/ID$/` → `autoIndex: "ID"`).
|
|
143
|
+
* Suppressed for any key that supplies a builder-form override. */
|
|
144
|
+
autoIndex?: string;
|
|
145
|
+
/** Field names to drop entirely from the produced entity. Common when the
|
|
146
|
+
* source Zod was generated from a DTO that carries transport-only keys. */
|
|
147
|
+
omit?: readonly string[];
|
|
61
148
|
}
|
|
62
149
|
/**
|
|
63
150
|
* Convert a `z.object({ ... })` into an `EntityDef`. Each key on the Zod
|
|
@@ -84,7 +171,33 @@ export interface EntityFromZodOpts<Z extends z.ZodObject = z.ZodObject> extends
|
|
|
84
171
|
* shape and validation; `link(...)` remains the source of truth for the
|
|
85
172
|
* graph.
|
|
86
173
|
*/
|
|
87
|
-
export declare function entityFromZod<Z extends z.ZodObject, const F = Record<never, never
|
|
88
|
-
fields?: F & EntityFromZodFieldOverrides<Z> & NoExtraZodFieldKeys<Z, F>;
|
|
89
|
-
|
|
174
|
+
export declare function entityFromZod<Z extends z.ZodObject, const F = Record<never, never>, const Om extends readonly (keyof Z["shape"] & string)[] = readonly [], const AI extends string | undefined = undefined>(zSchema: Z, opts: EntityFromZodOptsBase & {
|
|
175
|
+
fields?: F & EntityFromZodFieldOverrides<Z, AI> & NoExtraZodFieldKeys<Z, F>;
|
|
176
|
+
autoIndex?: AI;
|
|
177
|
+
omit?: Om;
|
|
178
|
+
}): EntityDef<MergedFieldsFromZodObject<Z, F, Om, AI>>;
|
|
179
|
+
/**
|
|
180
|
+
* Map a whole `{key: ZodObject}` module (e.g. an OpenAPI-generated barrel)
|
|
181
|
+
* into an entities record by calling `entityFromZod` per key with shared
|
|
182
|
+
* opts. The entity key in the returned record is the input key; the registry
|
|
183
|
+
* name is auto-derived (PascalCase of the key) by `compileSchema` — no need
|
|
184
|
+
* to spell `name` per entity.
|
|
185
|
+
*
|
|
186
|
+
* const entities = entitiesFromZod(generatedZods, {
|
|
187
|
+
* loadStrategy: LoadStrategy.Eager,
|
|
188
|
+
* autoIndex: "ID",
|
|
189
|
+
* omit: ["createdAt", "updatedAt"],
|
|
190
|
+
* });
|
|
191
|
+
* const schema = defineSchema({ entities, links: { ... } });
|
|
192
|
+
*
|
|
193
|
+
* Per-entity overrides are not threaded — drop down to `entityFromZod` for
|
|
194
|
+
* the handful that need a custom `fields` map or distinct `loadStrategy`.
|
|
195
|
+
*/
|
|
196
|
+
type EntitiesFromZodResult<Zods extends Record<string, z.ZodObject>, Om extends readonly string[], AI extends string | undefined> = {
|
|
197
|
+
[K in keyof Zods]: EntityDef<MergedFieldsFromZodObject<Zods[K], Record<never, never>, Om, AI>>;
|
|
198
|
+
};
|
|
199
|
+
export declare function entitiesFromZod<Zods extends Record<string, z.ZodObject>, const Om extends readonly string[] = readonly [], const AI extends string | undefined = undefined>(zods: Zods, opts: Pick<EntityFromZodOptsBase, "loadStrategy" | "usedForPartialIndexes"> & {
|
|
200
|
+
autoIndex?: AI;
|
|
201
|
+
omit?: Om;
|
|
202
|
+
}): EntitiesFromZodResult<Zods, Om, AI>;
|
|
90
203
|
export {};
|
package/dist/schema/zod.js
CHANGED
|
@@ -82,9 +82,19 @@ export function fromZod(zSchema) {
|
|
|
82
82
|
*/
|
|
83
83
|
export function entityFromZod(zSchema, opts) {
|
|
84
84
|
const overrides = (opts.fields ?? {});
|
|
85
|
+
const omitted = new Set(opts.omit ?? []);
|
|
86
|
+
const autoIndexSuffix = opts.autoIndex != null && opts.autoIndex !== "" ? opts.autoIndex : null;
|
|
85
87
|
const fieldsRecord = {};
|
|
86
88
|
for (const [key, fieldSchema] of Object.entries(zSchema.shape)) {
|
|
87
|
-
|
|
89
|
+
if (omitted.has(key)) {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
let auto = key === "id" ? fields.id() : fromZod(fieldSchema);
|
|
93
|
+
if (autoIndexSuffix != null &&
|
|
94
|
+
key !== "id" &&
|
|
95
|
+
key.endsWith(autoIndexSuffix)) {
|
|
96
|
+
auto = auto.indexed();
|
|
97
|
+
}
|
|
88
98
|
const override = overrides[key];
|
|
89
99
|
fieldsRecord[key] =
|
|
90
100
|
typeof override === "function"
|
|
@@ -99,3 +109,15 @@ export function entityFromZod(zSchema, opts) {
|
|
|
99
109
|
fields: fieldsRecord,
|
|
100
110
|
});
|
|
101
111
|
}
|
|
112
|
+
export function entitiesFromZod(zods, opts) {
|
|
113
|
+
const out = {};
|
|
114
|
+
for (const [key, zod] of Object.entries(zods)) {
|
|
115
|
+
out[key] = entityFromZod(zod, {
|
|
116
|
+
loadStrategy: opts.loadStrategy,
|
|
117
|
+
usedForPartialIndexes: opts.usedForPartialIndexes,
|
|
118
|
+
autoIndex: opts.autoIndex,
|
|
119
|
+
omit: opts.omit,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zerodrift",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "A TypeScript local-first sync engine: synchronous in-memory reads, optimistic writes, realtime SSE sync, offline IndexedDB persistence. Runs in the browser and in Node.",
|
|
6
6
|
"license": "MIT",
|