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 +6 -0
- package/dist/core/StoreManager.d.ts +20 -4
- package/dist/core/StoreManager.js +18 -5
- package/dist/core/index.d.ts +1 -1
- package/dist/react/index.d.ts +16 -5
- package/dist/react/index.js +22 -15
- package/package.json +1 -1
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) {
|
package/dist/core/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/react/index.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/react/index.js
CHANGED
|
@@ -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.
|
|
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",
|