zerodrift 1.0.4 → 1.1.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 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. Import your model file as a side effect so decorators run before bootstrap.
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 "./models";
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:
@@ -227,7 +239,7 @@ const { data: teams } = useRecords(store.team);
227
239
  const { data: teamIssues } = useRecordsByIndex(store.issue, "teamId", teamId);
228
240
  ```
229
241
 
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.
242
+ 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
243
 
232
244
  ## Headless usage
233
245
 
@@ -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: string;
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: string, sseClientFactory?: SSEClientFactory, reportError?: SSEErrorReporter | undefined);
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.url;
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
- const url = this.buildUrl();
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) => {
@@ -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
- * Use for explicit logout / factory-reset flows — NOT for routine teardown.
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: string, database: StorageAdapter, pool: ObjectPool, onStatusChange?: ((connected: boolean) => void) | undefined, sseClientFactory?: SSEClientFactory, transform?: ModelStreamMessageTransform | undefined, reportError?: SSEErrorReporter);
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";
@@ -68,7 +68,7 @@ export interface BootstrapFetcherOptions extends FetcherContext {
68
68
  }
69
69
  export type BootstrapFetcher = (type: BootstrapType.Full | BootstrapType.Partial, options?: BootstrapFetcherOptions) => Promise<BootstrapResponse>;
70
70
  export interface ModelStreamConfig {
71
- url: string;
71
+ url: SSEEndpoint;
72
72
  onStatusChange?: (connected: boolean) => void;
73
73
  /**
74
74
  * Use when the backend sends a different envelope than the engine's
@@ -107,7 +107,10 @@ export type OnDemandConfig = {
107
107
  export interface TransportConfig {
108
108
  bootstrapFetcher: BootstrapFetcher;
109
109
  transactionSender?: TransactionSender;
110
- syncUrl?: string;
110
+ /** SSE endpoint for live deltas — a static string, or a thunk evaluated
111
+ * on every (re)connect (so callers can fold in cursors from localStorage,
112
+ * auth tokens in the path, etc., without rebuilding the engine). */
113
+ syncUrl?: SSEEndpoint;
111
114
  /**
112
115
  * Optional async hook that returns the user's sync-group memberships
113
116
  * before any bootstrap fetch runs. The returned groups are append-only
@@ -829,7 +832,15 @@ export declare class StoreManager<TContext = unknown> {
829
832
  * cleared here because the schema-mismatch path keeps coverage entries;
830
833
  * the `fullyLoadedModels` mirror stays consistent with that. */
831
834
  private resetPoolState;
835
+ /** Routine cleanup (e.g. React unmount): stop sync, clear the object pool,
836
+ * and `close()` the persistence layer — persisted data is preserved so the
837
+ * next load can do a fast partial/local bootstrap. Not a logout. */
832
838
  teardown(): Promise<void>;
839
+ /** Logout / account switch: stop sync, clear the object pool, and `destroy()`
840
+ * the persistence layer — permanently wiping its data, whether persistence is
841
+ * IndexedDB or in-memory. Unlike {@link teardown}, nothing survives. */
842
+ destroy(): Promise<void>;
843
+ private shutdown;
833
844
  /** Debounced reconnect for SSE when `loadedModels` mutates. A burst of
834
845
  * transitions in the same tick (or across awaited writes in the same
835
846
  * async chain) coalesces into a single reconnect. setTimeout — not
@@ -1980,7 +1980,19 @@ export class StoreManager {
1980
1980
  this.inflightFullLoads.clear();
1981
1981
  this.seededSyncGroups = [];
1982
1982
  }
1983
+ /** Routine cleanup (e.g. React unmount): stop sync, clear the object pool,
1984
+ * and `close()` the persistence layer — persisted data is preserved so the
1985
+ * next load can do a fast partial/local bootstrap. Not a logout. */
1983
1986
  async teardown() {
1987
+ await this.shutdown(false);
1988
+ }
1989
+ /** Logout / account switch: stop sync, clear the object pool, and `destroy()`
1990
+ * the persistence layer — permanently wiping its data, whether persistence is
1991
+ * IndexedDB or in-memory. Unlike {@link teardown}, nothing survives. */
1992
+ async destroy() {
1993
+ await this.shutdown(true);
1994
+ }
1995
+ async shutdown(destroyData) {
1984
1996
  this.stopped = true;
1985
1997
  BaseModel.storeManager = null;
1986
1998
  this.loadedModelsUnsub?.();
@@ -1998,7 +2010,7 @@ export class StoreManager {
1998
2010
  this.transactionQueue.destroy();
1999
2011
  this.indexBatchLoader?.dispose();
2000
2012
  this.indexBatchLoader = null;
2001
- await this.database.close();
2013
+ await (destroyData ? this.database.destroy() : this.database.close());
2002
2014
  this.objectPool.clear();
2003
2015
  this.stores.clear();
2004
2016
  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: string, database: StorageAdapter, pool: ObjectPool, queue: TransactionQueue, opts?: SyncConnectionOptions);
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
- // Tell the server which models we're subscribed to (catchup + live
71
- // stream; absent no filter). Union of always-subscribed (Eager +
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
- return `${this.url}?lastSyncId=${lastSyncId}&syncGroups=${syncGroups}${onlyModels}`;
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);
@@ -12,5 +12,5 @@ export { StoreManager, RestrictDeleteError } from "./StoreManager.js";
12
12
  export type { BootstrapResponse, 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";
@@ -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
- }): import("react/jsx-runtime").JSX.Element | null;
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
@@ -56,10 +70,6 @@ export declare function useUndoRedo(): {
56
70
  };
57
71
  export declare function useRelation<T extends BaseModel = BaseModel>(relation: LazyCollectionBase<T> | null | undefined): AsyncResource<T[]>;
58
72
  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";
63
73
  type AnyNamespace = EntityNamespace<any, any, any>;
64
74
  type ModelCtor<T extends BaseModel = BaseModel> = abstract new (...args: any[]) => T;
65
75
  type Handle = AnyNamespace | ModelCtor;
@@ -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
@@ -328,23 +353,6 @@ function useStableCallback(callback) {
328
353
  }, [callback]);
329
354
  return stableRef.current;
330
355
  }
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
356
  function handleRegistryName(handle) {
349
357
  if (typeof handle === "function") {
350
358
  // Set by @ClientModel (explicit { name } or ctor.name fallback).
@@ -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";
@@ -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";
@@ -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,
@@ -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 z.infer<Z> & string]?: AnyFieldBuilder | ((auto: FieldBuilder<z.infer<Z>[K]>) => unknown);
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 `FieldBuilder<AutoT>` from Zod stands.
108
+ * provided the auto-derived `Auto` field stands.
29
109
  */
30
- type FieldFromOverride<O, AutoT> = O extends (...args: never[]) => infer R ? R extends FieldBuilder<infer RT, infer RM> ? [unknown] extends [RT] ? FieldBuilder<AutoT, RM> : R : FieldBuilder<AutoT> : O extends AnyFieldBuilder ? O : FieldBuilder<AutoT>;
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 z.infer<Z>]: K extends keyof F ? FieldFromOverride<F[K], z.infer<Z>[K]> : FieldBuilder<z.infer<Z>[K]>;
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 z.infer<Z> & string]?: EntityFromZodFieldOverride<z.infer<Z>[K]>;
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>>(zSchema: Z, opts: EntityFromZodOptsBase & {
88
- fields?: F & EntityFromZodFieldOverrides<Z> & NoExtraZodFieldKeys<Z, F>;
89
- }): EntityDef<MergedFieldsFromZodObject<Z, F>>;
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 {};
@@ -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
- const auto = key === "id" ? fields.id() : fromZod(fieldSchema);
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.4",
3
+ "version": "1.1.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",