zerodrift 1.1.0 → 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 CHANGED
@@ -218,6 +218,12 @@ const { data: teamIssues } = useRecordsByIndex(Issue, "teamId", teamId);
218
218
  const { data: comments } = useRelation(issue?.comments); // a relation
219
219
  const { phase } = useBootstrapStatus();
220
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
+
221
227
  issue.title = "New title";
222
228
  issue.save();
223
229
 
@@ -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[];
@@ -726,17 +737,20 @@ export declare class StoreManager<TContext = unknown> {
726
737
  * Predicate receives hydrated instances (pool) and raw records (IDB); write
727
738
  * predicates that test plain property values so they work on both shapes.
728
739
  * IDB side is a full cursor scan — prefer `evictByIndex` when the match is
729
- * "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.
730
742
  */
731
- 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>;
732
744
  /**
733
745
  * Remove every record where `record[indexKey] === value`, using the IDB
734
746
  * index for the database side. Pool side is still a linear scan (no
735
747
  * secondary in-memory index by field value). Also clears the matching
736
748
  * `loadedCollections` cache key so a future `getOrLoadCollection(modelName,
737
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.
738
752
  */
739
- evictByIndex(modelName: string, indexKey: string, value: string): Promise<void>;
753
+ evictByIndex(modelName: string, indexKey: string, value: string, opts?: EvictOptions): Promise<void>;
740
754
  /**
741
755
  * Cascade `evictByIndex` across every model type that owns this FK. Use
742
756
  * when an "owner" id (workspaceId, teamId, userId, …) goes away and the
@@ -744,8 +758,10 @@ export declare class StoreManager<TContext = unknown> {
744
758
  * declare `indexKey` as `indexed: true` are skipped — `deleteModelsByIndex`
745
759
  * falls back to a full-store cursor scan when the index is missing, and
746
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).
747
763
  */
748
- evictAllByIndex(indexKey: string, value: string): Promise<void>;
764
+ evictAllByIndex(indexKey: string, value: string, opts?: EvictOptions): Promise<void>;
749
765
  /** Pool-first bulk lookup by ID (for OwnedCollection resolution). */
750
766
  getOrLoadByIds<T extends BaseModel = BaseModel>(modelName: string, ids: string[]): Promise<T[]>;
751
767
  /** Pool-first single-model lookup by ID. */
@@ -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) {
@@ -9,7 +9,7 @@ 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
15
  export type { SyncAction, DeltaPacket, SSEEndpoint, SyncMessageTransform, } from "./SyncConnection.js";
@@ -59,6 +59,17 @@ export interface AsyncResource<T> {
59
59
  error: Error | null;
60
60
  reload: () => Promise<void>;
61
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
+ }
62
73
  /** Returns `store.batch` — the sync overload yields the `batchId` string,
63
74
  * the async overload a `Promise<string>`. */
64
75
  export declare function useBatch(): StoreManager["batch"];
@@ -68,8 +79,8 @@ export declare function useUndoRedo(): {
68
79
  canUndo: boolean;
69
80
  canRedo: boolean;
70
81
  };
71
- export declare function useRelation<T extends BaseModel = BaseModel>(relation: LazyCollectionBase<T> | null | undefined): AsyncResource<T[]>;
72
- export declare function useRelation<T extends BaseModel = BaseModel>(relation: BackRef<T> | null | undefined): AsyncResource<T | null>;
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>;
73
84
  type AnyNamespace = EntityNamespace<any, any, any>;
74
85
  type ModelCtor<T extends BaseModel = BaseModel> = abstract new (...args: any[]) => T;
75
86
  type Handle = AnyNamespace | ModelCtor;
@@ -83,10 +94,10 @@ type RecordOf<H> = H extends ModelCtor<infer T> ? T : RecordOfNamespace<H>;
83
94
  * namespace, unconstrained `string` for a decorator class. */
84
95
  type IndexKeyOf<H> = H extends ModelCtor ? string : IndexKeyOfNamespace<H>;
85
96
  /** Reactive single record by id. Pool-first sync read; async backfill on miss. */
86
- 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>;
87
98
  /** Reactive list of records, optionally filtered to (and ordered by) `ids`. */
88
- 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>[]>;
89
100
  /** Reactive list of records matching one value, or any of several, on a
90
101
  * foreign-key index. */
91
- 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>[]>;
92
103
  export {};
@@ -184,12 +184,13 @@ const settled = (ready, isLoading, error) => ready && !isLoading && error == nul
184
184
  // (schema namespace or model class) to a registry name and delegate here,
185
185
  // so the pool-subscription / loader machinery lives in exactly one place.
186
186
  /** Reactive single model by id. Pool-first sync read; async backfill on miss. */
187
- function useRecordByName(modelName, id) {
187
+ function useRecordByName(modelName, id, opts) {
188
188
  const { sm, status } = useSyncEngine();
189
189
  const pool = sm.objectPool;
190
190
  const ready = status.phase === BootstrapPhase.Ready;
191
+ const pause = opts?.pause ?? false;
191
192
  const item = usePoolSnapshot(modelName, () => id != null ? (pool.getById(modelName, id) ?? null) : null);
192
- 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 ?? ""}`,
193
194
  // Skip the load when the pool already has the entry — eager models
194
195
  // render with isLoading: false from frame zero.
195
196
  () => id != null && pool.getById(modelName, id) == null);
@@ -205,10 +206,11 @@ function useRecordByName(modelName, id) {
205
206
  * set. Without `ids`: every instance in the pool. With `ids`: just those, in
206
207
  * the order given, with async backfill for any missing from the pool. The
207
208
  * ids array is compared by content so inline literals don't cause re-fetches. */
208
- function useRecordsByName(modelName, ids) {
209
+ function useRecordsByName(modelName, ids, opts) {
209
210
  const { sm, status } = useSyncEngine();
210
211
  const pool = sm.objectPool;
211
212
  const ready = status.phase === BootstrapPhase.Ready;
213
+ const pause = opts?.pause ?? false;
212
214
  const idsKey = ids?.join(",") ?? "";
213
215
  const all = usePoolSnapshot(modelName, () => pool.getAll(modelName));
214
216
  const items = useMemo(() => {
@@ -220,7 +222,7 @@ function useRecordsByName(modelName, ids) {
220
222
  .map((id) => byId.get(id))
221
223
  .filter((m) => m != null);
222
224
  }, [all, idsKey]);
223
- 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));
224
226
  return {
225
227
  data: ready ? items : [],
226
228
  isLoading,
@@ -239,9 +241,10 @@ function useRecordsByName(modelName, ids) {
239
241
  * For one-round-trip multi-value fetches, configure
240
242
  * `onDemandIndexBatchFetcher` + `serverSupportsCompoundIndexKeys: true` —
241
243
  * see `agent-docs/04-lazy-loading.md`. */
242
- function useRecordsByIndexName(modelName, indexKey, value) {
244
+ function useRecordsByIndexName(modelName, indexKey, value, opts) {
243
245
  const { sm, status } = useSyncEngine();
244
246
  const ready = status.phase === BootstrapPhase.Ready;
247
+ const pause = opts?.pause ?? false;
245
248
  const values = value == null
246
249
  ? []
247
250
  : (Array.isArray(value) ? value : [value]).filter((v) => v != null && v !== "");
@@ -261,7 +264,7 @@ function useRecordsByIndexName(modelName, indexKey, value) {
261
264
  }, [all, indexKey, valuesKey, hasValues]);
262
265
  const { isLoading, error, reload } = useLoader(async () => {
263
266
  await Promise.all(values.map((v) => sm.getOrLoadCollection(modelName, indexKey, v)));
264
- }, ready && hasValues, `${modelName}:${indexKey}:${valuesKey}`, () => hasValues &&
267
+ }, ready && hasValues && !pause, `${modelName}:${indexKey}:${valuesKey}`, () => hasValues &&
265
268
  values.some((v) => !sm.isCollectionLoaded(modelName, indexKey, v)));
266
269
  return {
267
270
  data: ready ? items : [],
@@ -301,9 +304,10 @@ export function useUndoRedo() {
301
304
  canRedo: redoDepth > 0,
302
305
  };
303
306
  }
304
- export function useRelation(relation) {
307
+ export function useRelation(relation, opts) {
305
308
  const [tick, forceRender] = useState(0);
306
309
  const isBackRef = relation instanceof BackRef;
310
+ const pause = opts?.pause ?? false;
307
311
  // Collections expose watch() for invalidation; BackRef does not.
308
312
  useEffect(() => {
309
313
  if (relation == null || isBackRef) {
@@ -312,10 +316,10 @@ export function useRelation(relation) {
312
316
  return relation.watch(() => forceRender((n) => n + 1));
313
317
  }, [relation, isBackRef]);
314
318
  useEffect(() => {
315
- if (relation != null && !relation.isLoaded && !relation.isLoading) {
319
+ if (!pause && relation != null && !relation.isLoaded && !relation.isLoading) {
316
320
  relation.load().then(() => forceRender((n) => n + 1));
317
321
  }
318
- }, [relation, tick]);
322
+ }, [relation, tick, pause]);
319
323
  if (relation == null) {
320
324
  // Can't tell collection from back-ref at null; `[]` is the map-safe
321
325
  // default. A null back-ref reads `[]` rather than `null` — harmless
@@ -337,6 +341,9 @@ export function useRelation(relation) {
337
341
  isLoaded: relation.isLoaded ?? false,
338
342
  error: relation.error ?? null,
339
343
  reload: async () => {
344
+ if (pause) {
345
+ return;
346
+ }
340
347
  await (isBackRef
341
348
  ? relation.load()
342
349
  : relation.reload());
@@ -367,15 +374,15 @@ function handleRegistryName(handle) {
367
374
  // has `__mobx`/`store`; RecordOf has schema fields + extensions). One cast
368
375
  // per wrapper, contained.
369
376
  /** Reactive single record by id. Pool-first sync read; async backfill on miss. */
370
- export function useRecord(handle, id) {
371
- return useRecordByName(handleRegistryName(handle), id);
377
+ export function useRecord(handle, id, opts) {
378
+ return useRecordByName(handleRegistryName(handle), id, opts);
372
379
  }
373
380
  /** Reactive list of records, optionally filtered to (and ordered by) `ids`. */
374
- export function useRecords(handle, ids) {
375
- return useRecordsByName(handleRegistryName(handle), ids);
381
+ export function useRecords(handle, ids, opts) {
382
+ return useRecordsByName(handleRegistryName(handle), ids, opts);
376
383
  }
377
384
  /** Reactive list of records matching one value, or any of several, on a
378
385
  * foreign-key index. */
379
- export function useRecordsByIndex(handle, indexKey, value) {
380
- return useRecordsByIndexName(handleRegistryName(handle), indexKey, value);
386
+ export function useRecordsByIndex(handle, indexKey, value, opts) {
387
+ return useRecordsByIndexName(handleRegistryName(handle), indexKey, value, opts);
381
388
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zerodrift",
3
- "version": "1.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",