zerodrift 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +358 -0
  3. package/dist/core/BaseModel.d.ts +76 -0
  4. package/dist/core/BaseModel.js +505 -0
  5. package/dist/core/BaseSSEConnection.d.ts +31 -0
  6. package/dist/core/BaseSSEConnection.js +91 -0
  7. package/dist/core/BatchModelLoader.d.ts +27 -0
  8. package/dist/core/BatchModelLoader.js +70 -0
  9. package/dist/core/CompoundIndexFetcher.d.ts +46 -0
  10. package/dist/core/CompoundIndexFetcher.js +177 -0
  11. package/dist/core/Database.d.ts +303 -0
  12. package/dist/core/Database.js +837 -0
  13. package/dist/core/LazyCollection.d.ts +168 -0
  14. package/dist/core/LazyCollection.js +403 -0
  15. package/dist/core/LazyOwnedCollection.d.ts +35 -0
  16. package/dist/core/LazyOwnedCollection.js +66 -0
  17. package/dist/core/MemoryAdapter.d.ts +67 -0
  18. package/dist/core/MemoryAdapter.js +243 -0
  19. package/dist/core/ModelRegistry.d.ts +64 -0
  20. package/dist/core/ModelRegistry.js +217 -0
  21. package/dist/core/ModelStream.d.ts +33 -0
  22. package/dist/core/ModelStream.js +68 -0
  23. package/dist/core/ObjectPool.d.ts +113 -0
  24. package/dist/core/ObjectPool.js +339 -0
  25. package/dist/core/Store.d.ts +40 -0
  26. package/dist/core/Store.js +73 -0
  27. package/dist/core/StoreManager.d.ts +839 -0
  28. package/dist/core/StoreManager.js +2034 -0
  29. package/dist/core/SyncConnection.d.ts +105 -0
  30. package/dist/core/SyncConnection.js +348 -0
  31. package/dist/core/Transaction.d.ts +114 -0
  32. package/dist/core/Transaction.js +147 -0
  33. package/dist/core/TransactionQueue.d.ts +110 -0
  34. package/dist/core/TransactionQueue.js +601 -0
  35. package/dist/core/decorators.d.ts +66 -0
  36. package/dist/core/decorators.js +278 -0
  37. package/dist/core/hash.d.ts +6 -0
  38. package/dist/core/hash.js +12 -0
  39. package/dist/core/index.d.ts +16 -0
  40. package/dist/core/index.js +18 -0
  41. package/dist/core/internal.d.ts +27 -0
  42. package/dist/core/internal.js +25 -0
  43. package/dist/core/observability.d.ts +21 -0
  44. package/dist/core/observability.js +66 -0
  45. package/dist/core/refAccessors.d.ts +43 -0
  46. package/dist/core/refAccessors.js +80 -0
  47. package/dist/core/serializers.d.ts +2 -0
  48. package/dist/core/serializers.js +2 -0
  49. package/dist/core/types.d.ts +320 -0
  50. package/dist/core/types.js +84 -0
  51. package/dist/react/index.d.ts +82 -0
  52. package/dist/react/index.js +373 -0
  53. package/dist/schema/builders.d.ts +29 -0
  54. package/dist/schema/builders.js +81 -0
  55. package/dist/schema/compile.d.ts +28 -0
  56. package/dist/schema/compile.js +334 -0
  57. package/dist/schema/createStore.d.ts +235 -0
  58. package/dist/schema/createStore.js +264 -0
  59. package/dist/schema/extend.d.ts +46 -0
  60. package/dist/schema/extend.js +6 -0
  61. package/dist/schema/index.d.ts +13 -0
  62. package/dist/schema/index.js +8 -0
  63. package/dist/schema/infer.d.ts +102 -0
  64. package/dist/schema/infer.js +1 -0
  65. package/dist/schema/types.d.ts +76 -0
  66. package/dist/schema/types.js +1 -0
  67. package/dist/schema/zod.d.ts +90 -0
  68. package/dist/schema/zod.js +101 -0
  69. package/package.json +99 -0
@@ -0,0 +1,839 @@
1
+ /**
2
+ * StoreManager — the top-level orchestrator.
3
+ *
4
+ * Owns: ObjectPool, Database, TransactionQueue, SyncConnection, all Stores.
5
+ *
6
+ * Bootstrap phases (for loading indicators):
7
+ * idle → creatingStores → connectingDatabase → determiningBootstrapType
8
+ * → fetching → writingToDatabase → hydrating → connectingSync → ready
9
+ *
10
+ * Batch API:
11
+ * storeManager.batch(() => {
12
+ * issue.title = "x"; issue.save();
13
+ * team.name = "y"; team.save();
14
+ * });
15
+ * storeManager.undo(); // reverts both
16
+ *
17
+ * Lazy loading:
18
+ * storeManager.getOrLoadCollection("Issue", "teamId", teamId)
19
+ * storeManager.getOrLoadById("DocumentContent", docId)
20
+ */
21
+ import { ObjectPool } from "./ObjectPool";
22
+ import { BootstrapType, type StorageAdapter, type DatabaseMeta, type PartialIndexEntry } from "./Database";
23
+ import { TransactionQueue, type TransactionSender, type UndoableActionHandlers } from "./TransactionQueue";
24
+ import { type DeltaPacket, type SSEClientFactory, type SyncMessageTransform } from "./SyncConnection";
25
+ import { type ModelStreamMessageTransform } from "./ModelStream";
26
+ import { type IndexBatchFetcher } from "./BatchModelLoader";
27
+ import { BaseModel } from "./BaseModel";
28
+ import { BootstrapPhase, type ModelMeta, type PropertyMeta, type PropertyChange, type FieldTransform, type EngineErrorContext, type EngineErrorHandler, type CommitRouteHandler, type OnModelTouchedHandler } from "./types";
29
+ /**
30
+ * Thrown when a delete/archive is blocked by an onDelete: "restrict" relationship.
31
+ *
32
+ * Example: if Label has @Reference("Team", { onDelete: "restrict" }) and you
33
+ * try to delete a Team that has Labels pointing to it, this error is thrown
34
+ * with details about which model and property blocked the deletion.
35
+ */
36
+ export declare class RestrictDeleteError extends Error {
37
+ deletedModelName: string;
38
+ deletedModelId: string;
39
+ restrictedByModel: string;
40
+ restrictedByProperty: string;
41
+ constructor(deletedModelName: string, deletedModelId: string, restrictedByModel: string, restrictedByProperty: string);
42
+ }
43
+ export interface BootstrapResponse {
44
+ lastSyncId: number;
45
+ subscribedSyncGroups: string[];
46
+ models: Record<string, Record<string, unknown>[]>;
47
+ /** Server-side schema version. Mismatch with stored value → full bootstrap. */
48
+ backendDatabaseVersion?: number;
49
+ /**
50
+ * Tombstones for records the client may already have but the server has
51
+ * since deleted, grouped by model name. Safe to omit when the client has
52
+ * no prior state (e.g. first-time full bootstrap).
53
+ */
54
+ deletedIds?: Record<string, string[]>;
55
+ }
56
+ export interface FetcherContext {
57
+ currentMeta?: DatabaseMeta | null;
58
+ }
59
+ export interface BootstrapFetcherOptions extends FetcherContext {
60
+ sinceSyncId?: number;
61
+ onlyModels?: string[];
62
+ /**
63
+ * Scope the response to records belonging to these sync groups. Set by the
64
+ * engine when activating a sync group at runtime or reacting to a delta that
65
+ * added the client to new groups. Server should ignore unrelated records.
66
+ */
67
+ syncGroups?: string[];
68
+ }
69
+ export type BootstrapFetcher = (type: BootstrapType.Full | BootstrapType.Partial, options?: BootstrapFetcherOptions) => Promise<BootstrapResponse>;
70
+ export interface ModelStreamConfig {
71
+ url: string;
72
+ onStatusChange?: (connected: boolean) => void;
73
+ /**
74
+ * Use when the backend sends a different envelope than the engine's
75
+ * canonical `{ modelName, modelId, data }`.
76
+ */
77
+ transform?: ModelStreamMessageTransform;
78
+ }
79
+ export type OnDemandFetcher = (modelName: string, indexKey: string, value: string) => Promise<Record<string, unknown>[]>;
80
+ export type OnDemandBatchFetcher = (modelName: string, ids: string[]) => Promise<Record<string, unknown>[]>;
81
+ /**
82
+ * On-demand (progressive) loading strategy for `Partial` / `Lazy` models.
83
+ * A discriminated union so an index-batch backend can't be half-configured
84
+ * — the old flat shape let `serverSupportsCompoundIndexKeys` be set without
85
+ * an `onDemandIndexBatchFetcher`, which silently did nothing.
86
+ *
87
+ * - `perKey`: `fetch(modelName, indexKey, value)` is called the first time a
88
+ * collection/index is accessed; `batchFetch` coalesces missing id lookups
89
+ * for `getByIds`. Supply either or both (they drive different reads —
90
+ * `getByIndex` vs `getByIds`). Results are written to IDB + hydrated.
91
+ * - `indexBatch`: concurrent `getByIndex` calls (incl. `coveringIndexes`
92
+ * fan-out) coalesce into one `fetch` per microtask. `compound` opts in to
93
+ * dotted server-side-join index keys; `threshold` overrides
94
+ * {@link COMPOUND_FETCH_THRESHOLD}.
95
+ */
96
+ export type OnDemandConfig = {
97
+ mode: "perKey";
98
+ fetch?: OnDemandFetcher;
99
+ batchFetch?: OnDemandBatchFetcher;
100
+ } | {
101
+ mode: "indexBatch";
102
+ fetch: IndexBatchFetcher;
103
+ compound?: {
104
+ threshold?: number;
105
+ };
106
+ };
107
+ export interface TransportConfig {
108
+ bootstrapFetcher: BootstrapFetcher;
109
+ transactionSender?: TransactionSender;
110
+ syncUrl?: string;
111
+ /**
112
+ * Optional async hook that returns the user's sync-group memberships
113
+ * before any bootstrap fetch runs. The returned groups are append-only
114
+ * unioned with `dbMeta.subscribedSyncGroups` (so a stale persisted set
115
+ * never shrinks the live one) and persisted, so every downstream
116
+ * `bootstrapFetcher` call can pass `syncGroups` from one canonical source
117
+ * rather than relying on the server inferring scope from auth/session.
118
+ * Fires after the storage adapter connects but before the bootstrap type
119
+ * is determined, so seeded groups can influence Full vs Partial. Failure
120
+ * is fatal. Return `[]` (or omit) if the server owns scope.
121
+ */
122
+ bootstrapSyncGroups?: () => Promise<string[]>;
123
+ /** Secondary model update streams (e.g. a calculation service). */
124
+ modelStreams?: ModelStreamConfig[];
125
+ /**
126
+ * Custom SSE client factory. Defaults to the browser's `EventSource`.
127
+ * Override to run outside the browser (Node/agent):
128
+ * `sseClientFactory: (url) => new EventSource(url)`. When set, `sseInit`
129
+ * is ignored — your factory owns any options.
130
+ */
131
+ sseClientFactory?: SSEClientFactory;
132
+ /**
133
+ * Init options forwarded to the default browser EventSource (e.g.
134
+ * `{ withCredentials: true }`). Applies to the main stream and every
135
+ * `modelStreams` entry. Ignored when `sseClientFactory` is set.
136
+ */
137
+ sseInit?: EventSourceInit;
138
+ /**
139
+ * Use when the backend sends a different envelope than the canonical
140
+ * `DeltaPacket`. Return null to drop a message.
141
+ */
142
+ syncTransform?: SyncMessageTransform;
143
+ }
144
+ export interface LoadingConfig {
145
+ /**
146
+ * How deep `RefCollection`s walk the parent's outgoing FK chain when
147
+ * auto-deriving covering axes. Defaults to 3 (matching Linear). 1 = only
148
+ * the parent's direct FKs; 0 = disable auto-derivation (manual
149
+ * `coveringIndexes` still applies). Higher values exponentially increase
150
+ * the registry-walk surface for diminishing returns.
151
+ */
152
+ transientIndexDepth?: number;
153
+ /**
154
+ * Two-phase full bootstrap. If provided, the first fetch loads only the
155
+ * critical models (everything NOT in this list); once interactive, a
156
+ * background fetch loads these. If omitted, all models load in one
157
+ * request.
158
+ */
159
+ deferredModels?: string[];
160
+ /**
161
+ * Progressive / on-demand loading for `Partial` / `Lazy` models — they're
162
+ * excluded from bootstrap and fetched on first access, written to IDB,
163
+ * and hydrated. See {@link OnDemandConfig}.
164
+ */
165
+ onDemand?: OnDemandConfig;
166
+ }
167
+ export interface PersistenceConfig {
168
+ /**
169
+ * Custom storage backend. Defaults to IndexedDB (`Database`). Override
170
+ * for environments without IndexedDB (`new MemoryAdapter()`), or
171
+ * implement `StorageAdapter` for SQLite/Redis/etc. If omitted, `Database`
172
+ * falls back to in-memory when IndexedDB is unavailable (no persistence
173
+ * across restarts, no crash).
174
+ */
175
+ storageAdapter?: StorageAdapter;
176
+ /**
177
+ * Maximum undo entries kept in memory. Defaults to 100. Lower it for
178
+ * long-running agents that make many writes and don't need deep history
179
+ * (each entry holds model snapshots).
180
+ */
181
+ undoLimit?: number;
182
+ }
183
+ export interface HooksConfig<TContext = unknown> {
184
+ onPhaseChange?: (phase: BootstrapPhase, detail?: string) => void;
185
+ onDeltaPacket?: (packet: DeltaPacket) => void;
186
+ onReady?: () => void;
187
+ /**
188
+ * Single hook for every async failure the engine catches internally
189
+ * (eager loads, SSE parse errors, transaction retries, deferred fetches).
190
+ * Receives the error and a tagged-union `EngineErrorContext`. Throwing
191
+ * from it is swallowed. Without it, internal failures are silently
192
+ * dropped.
193
+ */
194
+ onError?: EngineErrorHandler;
195
+ /**
196
+ * Called when a sync group is removed (`deactivateSyncGroup` or an SSE
197
+ * `removedSyncGroups`). `dbMeta.subscribedSyncGroups` is already updated;
198
+ * SSE reconnect waits for the returned promise. Use it to evict pool/IDB
199
+ * records — `sm` exposes `evictByIndex` / `evictWhere`, `objectPool`,
200
+ * `database`.
201
+ */
202
+ onSyncGroupDelete?: (groupId: string, sm: StoreManager<TContext>) => void | Promise<void>;
203
+ }
204
+ export interface AdvancedConfig<TContext = unknown> {
205
+ /**
206
+ * Mint the `id` for newly-constructed client-side models. Not invoked for
207
+ * server/IDB-hydrated records. Receives the live context from
208
+ * `setContext` (or `<SyncProvider context>`); `undefined` until set.
209
+ * Defaults to `crypto.randomUUID()`.
210
+ */
211
+ identifierFn?: (meta: ModelMeta, context: TContext | undefined) => string;
212
+ /**
213
+ * Stamp a field transform onto each `(model, property)` at engine init
214
+ * (walked once). The transform fires inside the property setter before
215
+ * the MobX box, receiving the value, instance, and live context — use it
216
+ * to canonicalize cross-cutting input (tenant-prefix FKs, normalize
217
+ * strings) without per-field decorators. Per-StoreManager storage.
218
+ */
219
+ applyFieldTransforms?: (meta: ModelMeta, prop: PropertyMeta) => FieldTransform<TContext> | undefined;
220
+ /**
221
+ * Route user-initiated commits before they hit the pool / queue. Fires
222
+ * from `commitCreate` (before pool insert + enqueue) and `commitUpdate`
223
+ * (before enqueue). `"skip"` suppresses; a redirect replays the intent
224
+ * against a different model id (optionally restoring the original's
225
+ * pre-edit boxes). Delta/SSE writes do NOT fire this. Pair with
226
+ * `materializePoolOnly` / `clonePoolOnly` for pool-only redirect targets.
227
+ */
228
+ routeCommit?: CommitRouteHandler;
229
+ /**
230
+ * Fired the instant a clean model becomes dirty (first pending change
231
+ * since last save/discard), synchronously inside the setter before any
232
+ * `save()`. For eager side-effects that must not wait for a commit (e.g.
233
+ * materializing a draft-layer scaffold). The write is still routed at
234
+ * `save()` via `routeCommit`. Runs on the setter hot path — keep it fast.
235
+ * NOT fired during redirect replay or delta/SSE hydrates.
236
+ */
237
+ onModelTouched?: OnModelTouchedHandler;
238
+ /**
239
+ * Hooks for undoing/redoing remote side-effects committed via non-model
240
+ * APIs (bulk endpoints, server workflows). Tracked on the same undo stack
241
+ * as model transactions; on undo the engine calls `undoableActions.undo`
242
+ * with the recorded `UndoableAction`. Each handler returns the
243
+ * compensating action (or `void` if symmetric). Wire
244
+ * `StoreManager.runUndoable(fn)` at the call site. Failures route to
245
+ * `onError` with `kind: "undoableAction"`.
246
+ */
247
+ undoableActions?: UndoableActionHandlers;
248
+ }
249
+ /**
250
+ * Public engine config, grouped by concern. `transport` is required
251
+ * (carries the required `bootstrapFetcher`); the rest are optional.
252
+ */
253
+ export interface StoreManagerConfig<TContext = unknown> {
254
+ workspaceId: string;
255
+ transport: TransportConfig;
256
+ loading?: LoadingConfig;
257
+ persistence?: PersistenceConfig;
258
+ hooks?: HooksConfig<TContext>;
259
+ advanced?: AdvancedConfig<TContext>;
260
+ }
261
+ /**
262
+ * @internal Flattened shape the engine reads internally. The public grouped
263
+ * `StoreManagerConfig` is normalized into this exactly once (constructor),
264
+ * so the rest of the engine collapses to one stable surface. Exported only
265
+ * for the test factory.
266
+ *
267
+ * Derived structurally from the grouped sub-interfaces (minus `onDemand`,
268
+ * which the discriminated union expands into the flat `onDemand*` fields
269
+ * below) so the "flat = projection of grouped" invariant is compiler-
270
+ * enforced — rename a field in one place and this follows automatically.
271
+ */
272
+ export type NormalizedConfig<TContext = unknown> = Omit<TransportConfig & LoadingConfig & PersistenceConfig & HooksConfig<TContext> & AdvancedConfig<TContext>, "onDemand"> & {
273
+ workspaceId: string;
274
+ onDemandFetcher?: OnDemandFetcher;
275
+ onDemandBatchFetcher?: OnDemandBatchFetcher;
276
+ onDemandIndexBatchFetcher?: IndexBatchFetcher;
277
+ serverSupportsCompoundIndexKeys?: boolean;
278
+ compoundIndexFetchThreshold?: number;
279
+ };
280
+ /** @internal Grouped public config → flat internal config. Single mapping
281
+ * point; the discriminated `onDemand` union expands to the legacy flat
282
+ * onDemand* fields here. */
283
+ export declare function normalizeConfig<TContext = unknown>(c: StoreManagerConfig<TContext>): NormalizedConfig<TContext>;
284
+ export declare class StoreManager<TContext = unknown> {
285
+ readonly objectPool: ObjectPool;
286
+ readonly database: StorageAdapter;
287
+ readonly transactionQueue: TransactionQueue;
288
+ get transientIndexDepth(): number;
289
+ private stores;
290
+ private syncConnection;
291
+ private modelStreams;
292
+ private config;
293
+ private context;
294
+ private fieldTransforms;
295
+ hasFieldTransforms: boolean;
296
+ hasModelTouchedHandler: boolean;
297
+ private _phase;
298
+ private _error;
299
+ private stopped;
300
+ private loadedModelsUnsub;
301
+ private syncReconnectTimer;
302
+ /**
303
+ * Hot cache of collection coverage, keyed by `"modelName:indexKey:value"`.
304
+ * Each value carries the structured tuple plus the `firstSyncId` (the
305
+ * `lastSyncId` at the time of fetch). Mirrored to the storage adapter's
306
+ * `__partialIndexes` store, so coverage survives reload.
307
+ */
308
+ private partialIndexCoverage;
309
+ private loadedIds;
310
+ /** Models whose IDB rows have been hydrated into the pool at least once
311
+ * this session. `getOrLoadAll`'s cache-hit path skips a full IDB scan
312
+ * when a model is in this set — pool stays current via SSE
313
+ * (`shouldHydrateInsert` honors `*`-coverage, see `isModelFullyLoaded`),
314
+ * so a fresh IDB read would just rediscover what pool already holds.
315
+ * Cleared whenever `objectPool.clear()` is called. */
316
+ private poolSyncedFromIDB;
317
+ /** Models that have at least one `*`-coverage entry in
318
+ * `partialIndexCoverage` (any scope). Mirror of those entries — kept so
319
+ * `isModelFullyLoaded` is O(1) on the SSE insert hot path. Updated
320
+ * wherever `partialIndexCoverage` mutates a `"*"` row. */
321
+ private fullyLoadedModels;
322
+ /** Models with an in-flight `getOrLoadAll` (or `fetchDeferredModels`)
323
+ * fetch. Refcounted because two fetches with different scopes can overlap.
324
+ * `isModelFullyLoaded` returns true while pending so `shouldHydrateInsert`
325
+ * starts admitting SSE deltas immediately — otherwise inserts arriving
326
+ * during the fetch window would land only in IDB and the snapshot's older
327
+ * `hydrateAndPut` pass would overwrite the pool with stale data. */
328
+ private pendingFullLoadRefcount;
329
+ /** Tombstone set populated by SSE `D`/`A` handlers while a model has a
330
+ * pending full-load. The merge step at the end of `getOrLoadAll` /
331
+ * `fetchDeferredModels` filters snapshot records through this set so a
332
+ * delete that arrived after the server's snapshot doesn't get resurrected.
333
+ * Cleared when the last in-flight fetch for the model completes. */
334
+ private inflightDeletes;
335
+ /** Per-(model, scope) in-flight `getOrLoadAll` promises so concurrent calls
336
+ * coalesce instead of double-fetching. Keyed by `coverageKey`. */
337
+ private inflightFullLoads;
338
+ /** Sync groups returned by `config.bootstrapSyncGroups`. Used as a
339
+ * pre-Phase-1 source for `subscribedSyncGroupsForFetch` (when no prior
340
+ * dbMeta exists, currentMeta is still null at fetch time) and unioned
341
+ * into the meta written by `saveMeta` after Phase 1/Partial completes. */
342
+ private seededSyncGroups;
343
+ /** Wired only when `onDemandIndexBatchFetcher` is configured. */
344
+ private indexBatchLoader;
345
+ /** Set of models touched inside the currently open `atomic()` scope.
346
+ * `null` when no scope is active. Mutations register themselves via
347
+ * `registerAtomicTouch` (called from `BaseModel.propertyChanged`). */
348
+ private activeAtomicScope;
349
+ /** True only while the engine replays a redirected commit onto its target.
350
+ * Gates every user-intent hook (`routeCommit`, `onModelTouched`) so the
351
+ * engine's own `assign()`/`save()` during replay isn't mistaken for a
352
+ * fresh user edit. */
353
+ private suppressUserIntentHooks;
354
+ constructor(config: StoreManagerConfig<TContext>);
355
+ /** Push the live context (e.g. user/tenant info) used by `identifierFn`.
356
+ * Read at id-mint time, not captured — call this whenever the relevant
357
+ * context changes. The React `<SyncProvider context={...}>` prop is a
358
+ * thin wrapper over this. */
359
+ setContext(ctx: TContext): void;
360
+ /** Apply any registered field transform for `(instance, propName)` against
361
+ * `value`, returning the result (or `value` unchanged when no rule applies).
362
+ * Setters short-circuit on `hasFieldTransforms` first — by the time this
363
+ * runs we know at least one rule exists somewhere in the engine. */
364
+ applyTransform(instance: BaseModel, propName: string, value: unknown): unknown;
365
+ private static fieldTransformKey;
366
+ /** Mint a fresh id, honoring `identifierFn` when configured. Folds the
367
+ * registry lookup in so callers can skip it entirely on the no-config
368
+ * path — `new Model()` is hot. */
369
+ mintId(instance: BaseModel): string;
370
+ get phase(): BootstrapPhase;
371
+ get error(): Error | null;
372
+ get isReady(): boolean;
373
+ private setPhase;
374
+ /**
375
+ * Route an internal error to `config.onError`. No-op when the hook isn't
376
+ * configured. Wrapped in try/catch so a buggy adopter handler can't crash
377
+ * the engine.
378
+ */
379
+ emitError(err: unknown, context: EngineErrorContext): void;
380
+ bootstrap(): Promise<void>;
381
+ /**
382
+ * Full bootstrap — two-phase fetch. Only Eager models are ever shipped;
383
+ * Lazy / Partial / LocalOnly / Ephemeral load on demand
384
+ * or via SSE.
385
+ *
386
+ * Phase 1: critical Eager models (everything NOT in deferredModels).
387
+ * Write to IDB, hydrate into ObjectPool. UI can render.
388
+ *
389
+ * Phase 2: deferred Eager models (Comment, Reaction, Attachment, etc.)
390
+ * in the background after the engine is marked ready.
391
+ *
392
+ * If deferredModels is not configured, every Eager model is fetched in
393
+ * a single request.
394
+ */
395
+ private fullBootstrap;
396
+ /**
397
+ * Background fetch for deferred models (phase 2).
398
+ * Runs after the engine is ready — the UI is already interactive.
399
+ * Uses Full bootstrap because the client has never fetched these models before.
400
+ * Any changes that occurred concurrently during phase 1 are covered by SSE,
401
+ * which connects before this method runs.
402
+ *
403
+ * Bypasses clearModelStore to avoid clobbering concurrent SSE writes to IDB.
404
+ * Uses writeModelsIfAbsent + tombstone filter to merge with SSE — see
405
+ * `agent-docs/04-lazy-loading.md` for the in-flight merge invariants.
406
+ */
407
+ private fetchDeferredModels;
408
+ /** Fold the result of `bootstrapSyncGroups` into `dbMeta`. When prior
409
+ * meta exists we persist immediately so `localBootstrap` (no `saveMeta`
410
+ * of its own) and a subsequent reload see the seeded groups; `saveMeta`
411
+ * is safe here because `lastSyncId` is preserved. With no prior meta,
412
+ * stash on the instance — calling `saveMeta` with `lastSyncId: 0` would
413
+ * coerce a fresh bootstrap into the `Local` path. Phase 1's `saveMeta`
414
+ * will fold the stashed set in. */
415
+ private applySeededSyncGroups;
416
+ /** Persist `dbMeta` after a Full bootstrap response, folding in any
417
+ * `seededSyncGroups` left over from `bootstrapSyncGroups`. Shared by the
418
+ * Phase-1 and single-phase branches of `fullBootstrap`. */
419
+ private persistFullBootstrapMeta;
420
+ /** Append-only merge: bootstrap responses never shrink the subscription set. */
421
+ private static mergeSubscribedGroups;
422
+ /** Canonical scope for bootstrap-style fetches: persisted set, then the
423
+ * pre-Phase-1 seeded fallback, else `undefined`. */
424
+ private subscribedSyncGroupsForFetch;
425
+ /**
426
+ * Evict tombstones from IDB (skipping Ephemeral models) and the pool.
427
+ * Run AFTER the upsert pass — if an id is in both `res.models` and
428
+ * `res.deletedIds` the tombstone wins (server's delete is authoritative).
429
+ * Cascade/invalidate are skipped; those flow via SSE D actions.
430
+ */
431
+ private applyDeletedIds;
432
+ private partialBootstrap;
433
+ private localBootstrap;
434
+ commitCreate(model: BaseModel): void;
435
+ commitUpdate(modelId: string, modelName: string, changes: Record<string, PropertyChange>): void;
436
+ private resolveCommitRoute;
437
+ private applyCreateRoute;
438
+ private applyUpdateRoute;
439
+ private previousDataFor;
440
+ /**
441
+ * Hydrate server-shaped records straight into the pool — no
442
+ * `CreateTransaction`, no server round-trip, no IDB write. Mirrors the insert
443
+ * path SSE uses (`ObjectPool.hydrateAndPut`), so inverse links,
444
+ * `@ReferenceCollection` membership, and `notifyModelChanged` reactivity all
445
+ * wire up automatically.
446
+ */
447
+ materializePoolOnly<T extends BaseModel = BaseModel>(modelName: string, records: Record<string, unknown>[], options?: {
448
+ onCollision?: "error" | "hydrate";
449
+ }): T[];
450
+ /**
451
+ * Convenience wrapper around `materializePoolOnly` for cloning existing
452
+ * sources into pool-only optimistic mirrors.
453
+ *
454
+ * `transform` receives each source's serialized data plus the source
455
+ * instance and must return a fully-formed record with a different `id`.
456
+ * Use it to rewrite ids and any FK fields that should point at the
457
+ * new scope.
458
+ *
459
+ * Intended for optimistic in-memory mirrors while the server fork-fetch
460
+ * is in flight. When the server's records eventually arrive via SSE on
461
+ * the same ids, `hydrate()` runs in place and the pendingChanges rebase
462
+ * keeps any user edits the user has stacked on top.
463
+ *
464
+ * Throws if `transform` returns the source id unchanged — that would
465
+ * silently overwrite the original instance.
466
+ */
467
+ clonePoolOnly<T extends BaseModel>(sources: T[], transform: (data: Record<string, unknown>, source: T) => Record<string, unknown>): T[];
468
+ /**
469
+ * Delete a model WITH client-side cascade and restrict validation.
470
+ *
471
+ * Pre-validation: checks for References with onDelete: "restrict".
472
+ * If any model instance references the one being deleted via a restrict
473
+ * relationship, the delete is refused with a RestrictDeleteError.
474
+ *
475
+ * Cascade: walks the ModelRegistry for:
476
+ * - BackReferences pointing at this model → delete those "owned" models
477
+ * - References with onDelete: "cascade" → delete those dependent models
478
+ * - References with onDelete: "nullify" → set the reference to null
479
+ *
480
+ * All operations are grouped in a batch so undo reverses everything.
481
+ */
482
+ deleteModel(model: BaseModel): Promise<void> | undefined;
483
+ /** Archive a model WITH client-side cascade and restrict validation. */
484
+ archiveModel(model: BaseModel): Promise<void> | undefined;
485
+ /**
486
+ * Check if any Reference with onDelete: "restrict" blocks this deletion.
487
+ *
488
+ * Walks all registered models. For each Reference property that points
489
+ * to the model type being deleted and has onDelete: "restrict", checks
490
+ * if any instance in the ObjectPool actually references the target ID.
491
+ *
492
+ * Returns the first restriction found, or null if deletion is allowed.
493
+ */
494
+ private checkDeleteRestriction;
495
+ /**
496
+ * Client-side cascade: find and delete/nullify models that reference the
497
+ * one being deleted. Mirrors SyncConnection.cascadeDelete but creates
498
+ * actual transactions (so undo works).
499
+ */
500
+ private cascadeDeleteClient;
501
+ /** Same cascade logic for archive. */
502
+ private cascadeArchiveClient;
503
+ /**
504
+ * Called by SyncConnection when new sync groups are added.
505
+ * Fetches all models scoped to those groups from the server,
506
+ * writes to IDB, and hydrates eager-load ones into the pool.
507
+ *
508
+ * Example: user joins team "t-design" → fetch all Issues, Comments,
509
+ * etc. that belong to that team.
510
+ */
511
+ private handleSyncGroupsAdded;
512
+ /**
513
+ * Called by SyncConnection when a delta packet's `removedSyncGroups` lists
514
+ * groups the client no longer has access to. SyncConnection has already
515
+ * updated `dbMeta.subscribedSyncGroups`.
516
+ */
517
+ private handleSyncGroupsRemoved;
518
+ /**
519
+ * Fire `onSyncGroupDelete` once per group, serially. Errors thrown by the
520
+ * adopter's callback are caught and routed to `onError` so one bad group
521
+ * doesn't abort cleanup for the rest.
522
+ */
523
+ private fireOnSyncGroupDelete;
524
+ /**
525
+ * Scoped bootstrap-fetcher call: same fetcher used by full/partial bootstrap,
526
+ * scoped to a subset of groups via `syncGroups` and to Eager models only.
527
+ *
528
+ * Returns `schemaMismatch: true` if the server reports a schema version that
529
+ * doesn't match what's stored — in that case a full re-bootstrap has already
530
+ * been triggered and the caller should bail.
531
+ */
532
+ private fetchSyncGroupModels;
533
+ /**
534
+ * Targeted full fetch for Eager models added to the registry since the
535
+ * last connect. Runs after partial bootstrap so existing models keep their
536
+ * delta-only path; new Eager models get a full snapshot. Non-Eager
537
+ * additions are silently dropped — they load on demand or not at all.
538
+ */
539
+ private fetchNewlyAddedModels;
540
+ /** Write a bootstrap response into IDB + the in-memory pool, then apply
541
+ * any tombstones it carries. Shared by `fetchSyncGroupModels` and
542
+ * `fetchNewlyAddedModels` — both are targeted full fetches. */
543
+ private applyBootstrapResponse;
544
+ /**
545
+ * Write records for Eager models into the pool. Updates existing instances
546
+ * in-place; creates new ones via hydrateAndPut for models not yet in memory.
547
+ */
548
+ private hydrateEagerModels;
549
+ /**
550
+ * Activate a sync group: subscribe to SSE deltas for the group and
551
+ * optionally fetch its models from the server.
552
+ *
553
+ * By default (fetch: true) models are fetched, written to IDB, and hydrated
554
+ * into the pool before reconnecting. Pass `{ fetch: false }` to subscribe
555
+ * without fetching — useful when you want SSE deltas to start flowing but
556
+ * will load models lazily later.
557
+ *
558
+ * Pass `{ ephemeral: true }` for session-scoped groups that should not
559
+ * survive page reloads. The group subscription is kept in memory only —
560
+ * models are still written to IDB as usual, but the group itself is not
561
+ * saved to meta.
562
+ *
563
+ * Idempotent — does nothing if the group is already active.
564
+ *
565
+ * Uses the same `bootstrapFetcher` as initial bootstrap, scoped via the
566
+ * `syncGroups` option. The server should return only records belonging to
567
+ * those groups.
568
+ */
569
+ activateSyncGroup(groupId: string | string[], { fetch, ephemeral, }?: {
570
+ fetch?: boolean;
571
+ ephemeral?: boolean;
572
+ }): Promise<void>;
573
+ /**
574
+ * Deactivate a sync group: drop it from the subscribed list, fire
575
+ * `onSyncGroupDelete` (if configured) so the app can evict pool/IDB records,
576
+ * and reconnect SSE so the server stops streaming deltas for it.
577
+ *
578
+ * If `onSyncGroupDelete` isn't configured the group's records remain in the
579
+ * pool/IDB. Use `sm.evictByIndex` / `sm.evictWhere` inside the callback, or
580
+ * walk `ModelRegistry.allModels()` for a generic sweeper.
581
+ *
582
+ * Idempotent — does nothing if the group is not currently active.
583
+ */
584
+ deactivateSyncGroup(groupId: string | string[]): Promise<void>;
585
+ /** Run a function inside a batch. All save() calls share a batchId.
586
+ * Accepts both sync and async functions — endBatch is always called
587
+ * after the function (or its returned Promise) completes.
588
+ */
589
+ batch(fn: () => void): string;
590
+ batch(fn: () => Promise<void>): Promise<string>;
591
+ beginBatch(): string;
592
+ endBatch(id: string): void;
593
+ /**
594
+ * Stage optimistic edits with all-or-nothing local commit semantics.
595
+ *
596
+ * storeManager.atomic(async () => {
597
+ * book.assign({ title: "X" });
598
+ * issue.assign({ status: "done" });
599
+ * await api.call();
600
+ * });
601
+ *
602
+ * Any model mutated inside `fn` registers with the active scope. On
603
+ * resolve, every touched model's `save()` is called once (wrapped in a
604
+ * single batch so undo collapses to one entry). On throw, every touched
605
+ * model's `discardUnsavedChanges()` runs and the error re-throws.
606
+ *
607
+ * SSE deltas that arrive on a touched field during an `await` rebase the
608
+ * model's `pendingChanges` baseline (see `BaseModel.hydrate`) — the
609
+ * optimistic value stays visible, and a discard lands on the server's
610
+ * latest known value rather than a stale pre-edit one.
611
+ *
612
+ * `runUndoable` side effects pass through unchanged: their server
613
+ * mutation is not rolled back when the atomic block throws. Compensate
614
+ * them yourself in the caller's catch if needed.
615
+ *
616
+ * Nested atomic scopes are not supported.
617
+ */
618
+ atomic<T>(fn: () => T): T;
619
+ atomic<T>(fn: () => Promise<T>): Promise<T>;
620
+ /** @internal */
621
+ registerAtomicTouch(model: BaseModel): void;
622
+ /** @internal Called from `BaseModel.propertyChanged` on the clean→dirty
623
+ * transition. Suppressed during the engine's own redirect replay so the
624
+ * draft target's `assign()` doesn't re-trigger a user-facing "first edit".
625
+ * BaseModel guards on `hasModelTouchedHandler` before calling. */
626
+ fireModelTouched(model: BaseModel, modelName: string): void;
627
+ undo(): Promise<import("./TransactionQueue").UndoResult | null>;
628
+ redo(): Promise<import("./TransactionQueue").UndoResult | null>;
629
+ /**
630
+ * Run a remote side-effect that returns a `changeLogId`, and record it on
631
+ * the undo stack so the next `undo()` invokes the consumer's
632
+ * `undoableActions.undo` handler with that id.
633
+ *
634
+ * The function may return either the `changeLogId` string directly, or any
635
+ * object with a `changeLogId` field — in which case the full object is
636
+ * returned to the caller. Inside an open `batch()`, the action joins the
637
+ * batch and undoes alongside the model transactions.
638
+ *
639
+ * If `fn` throws, nothing is recorded.
640
+ */
641
+ runUndoable<T extends string | {
642
+ changeLogId: string;
643
+ }>(fn: () => Promise<T> | T, opts?: {
644
+ actionType?: string;
645
+ metadata?: Record<string, unknown>;
646
+ }): Promise<T>;
647
+ /**
648
+ * Builds the `partialIndexCoverage` cache key. The `indexKey` segment is
649
+ * usually a real model field name, but the value `ALL_INDEX_KEY_SENTINEL`
650
+ * (`"*"`) is reserved for `getOrLoadAll` whole-table coverage and must
651
+ * not collide with any real field name.
652
+ */
653
+ private static collectionKey;
654
+ private static modelIdKey;
655
+ /** Pool-first collection lookup where indexKey === value (e.g. all Issues for a team). */
656
+ getOrLoadCollection<T extends BaseModel = BaseModel>(modelName: string, indexKey: string, value: string): Promise<T[]>;
657
+ isCollectionLoaded(modelName: string, indexKey: string, value: string): boolean;
658
+ /** True when the model has `*`-coverage (a completed `getOrLoadAll`) or
659
+ * a full-load fetch is currently in flight. Read on the SSE insert hot
660
+ * path via `shouldHydrateInsert` — the in-flight branch is what makes
661
+ * deltas land in the pool during the fetch window. */
662
+ isModelFullyLoaded(modelName: string): boolean;
663
+ /** Called by SSE D/A processing whenever a delete arrives. While a model
664
+ * has a pending full-load, the id is recorded so the in-flight fetch's
665
+ * merge step can drop a stale-snapshot resurrection. No-op when no fetch
666
+ * is pending — keeps the hot path cheap. */
667
+ recordInflightDelete(modelName: string, id: string): void;
668
+ /** Increment the in-flight refcount for `modelName`. Must be paired with
669
+ * `endPendingFullLoad`. While refcount > 0, `isModelFullyLoaded` returns
670
+ * true (admitting SSE inserts to the pool) and `recordInflightDelete`
671
+ * tracks tombstones. */
672
+ private beginPendingFullLoad;
673
+ /** Decrement the in-flight refcount. When it hits 0, drop the tombstone
674
+ * set — any snapshot that needed it has already merged. */
675
+ private endPendingFullLoad;
676
+ /** Strip records whose id was tombstoned by an SSE delete during the
677
+ * in-flight full-load window. Common case (no pending deletes) returns
678
+ * the input unchanged so the caller doesn't allocate. */
679
+ private filterTombstoned;
680
+ /**
681
+ * Derive-on-read for compound coverage: a direct triple
682
+ * `(modelName, indexKey, value)` is implicitly covered when a previously-
683
+ * fetched compound query `(modelName, "indexKey.fk", parent.fk)` exists
684
+ * AND the parent of `value` shares that FK value. Walks one hop on the
685
+ * parent — must stay in sync with `collapseGroup` in
686
+ * `CompoundIndexFetcher.ts`. If the rewriter ever recurses (e.g. to
687
+ * `taskId.projectId.workspaceId`), this loop needs the same recursion
688
+ * or covered reads will silently miss and re-fetch.
689
+ *
690
+ * Skipped (returns false) when:
691
+ * - `indexKey` is already a dotted path (we only check direct keys)
692
+ * - the FK's referent model isn't registered
693
+ * - `value`'s parent isn't in the pool
694
+ * - no outgoing FK on the parent has a matching compound coverage
695
+ */
696
+ private isCoveredByCompound;
697
+ /** Mark a `(modelName, indexKey, value)` query as fully covered locally as
698
+ * of `firstSyncId`. Updates the in-memory hot cache and the storage
699
+ * adapter's persistent store. */
700
+ private markPartialIndexLoaded;
701
+ /**
702
+ * Absorb the response from a synthetic compound query produced by
703
+ * `wrapCompoundFetcher`. The full response bag is written to IDB so
704
+ * future direct lookups within the compound's coverage area find their
705
+ * records — `BatchModelLoader.flush` only delivers per-waiter slices,
706
+ * which would otherwise drop records for parents that weren't in the
707
+ * original batch. The compound key itself is recorded in
708
+ * `partialIndexCoverage` so derive-on-read can short-circuit subsequent
709
+ * direct loads.
710
+ */
711
+ private absorbCompoundResponse;
712
+ /**
713
+ * Returns every recorded `(modelName, indexKey, value, firstSyncId)` tuple
714
+ * known to this client. Adopters can ship the result to the server alongside
715
+ * a partial fetch so it can return only deltas since each scope's
716
+ * `firstSyncId` instead of re-shipping the full snapshot.
717
+ */
718
+ getPartialIndexCoverage(): PartialIndexEntry[];
719
+ /** Walk the pool for `modelName`, removing instances matching `predicate`. */
720
+ private evictFromPool;
721
+ /**
722
+ * Remove every record of `modelName` matching `predicate` from pool and IDB.
723
+ * Predicate receives hydrated instances (pool) and raw records (IDB); write
724
+ * predicates that test plain property values so they work on both shapes.
725
+ * IDB side is a full cursor scan — prefer `evictByIndex` when the match is
726
+ * "indexed column equals value".
727
+ */
728
+ evictWhere(modelName: string, predicate: (m: Record<string, unknown>) => boolean): Promise<number>;
729
+ /**
730
+ * Remove every record where `record[indexKey] === value`, using the IDB
731
+ * index for the database side. Pool side is still a linear scan (no
732
+ * secondary in-memory index by field value). Also clears the matching
733
+ * `loadedCollections` cache key so a future `getOrLoadCollection(modelName,
734
+ * indexKey, value)` re-fetches from the server instead of trusting IDB.
735
+ */
736
+ evictByIndex(modelName: string, indexKey: string, value: string): Promise<void>;
737
+ /**
738
+ * Cascade `evictByIndex` across every model type that owns this FK. Use
739
+ * when an "owner" id (workspaceId, teamId, userId, …) goes away and the
740
+ * client should drop every related row in one call. Models that don't
741
+ * declare `indexKey` as `indexed: true` are skipped — `deleteModelsByIndex`
742
+ * falls back to a full-store cursor scan when the index is missing, and
743
+ * walking every store at every call is rarely what the caller wants.
744
+ */
745
+ evictAllByIndex(indexKey: string, value: string): Promise<void>;
746
+ /** Pool-first bulk lookup by ID (for OwnedCollection resolution). */
747
+ getOrLoadByIds<T extends BaseModel = BaseModel>(modelName: string, ids: string[]): Promise<T[]>;
748
+ /** Pool-first single-model lookup by ID. */
749
+ getOrLoadById<T extends BaseModel = BaseModel>(modelName: string, id: string): Promise<T | null>;
750
+ /**
751
+ * Load every instance of `modelName`, optionally scoped to a set of sync
752
+ * groups. Triggers a Full bootstrap fetch on first call, hydrates the
753
+ * results, and records coverage so subsequent same-scope calls short-circuit.
754
+ *
755
+ * Per-strategy behavior:
756
+ * - Eager / Ephemeral — already fully resident; returns pool snapshot.
757
+ * - Local — returns IDB contents (no server hit).
758
+ * - Lazy / Partial — fetches and hydrates.
759
+ *
760
+ * Coverage is tracked in `partialIndexCoverage` under the
761
+ * `ALL_INDEX_KEY_SENTINEL` reserved indexKey — adopters never see it but it
762
+ * coexists with real indexKeys, so callers must avoid using "*" themselves.
763
+ *
764
+ * Concurrent SSE deltas during the fetch are merged via a pending-flag +
765
+ * tombstone scheme — see `agent-docs/04-lazy-loading.md` for the full
766
+ * invariants. Concurrent calls with the same `(modelName, scope)` coalesce
767
+ * into one fetch via `inflightFullLoads`.
768
+ */
769
+ getOrLoadAll<T extends BaseModel = BaseModel>(modelName: string, opts?: {
770
+ syncGroups?: string[];
771
+ }): Promise<T[]>;
772
+ /** Hydrate every IDB row for a covered (or Local) model into the pool.
773
+ * Used the first time `getOrLoadAll` runs this session against a model
774
+ * whose `*`-coverage was already recorded (e.g. on a warm reload). */
775
+ private hydrateFullLoadFromIDB;
776
+ /** Fetch the snapshot and merge it with whatever the SSE pipeline wrote
777
+ * during the in-flight window. See the JSDoc on `getOrLoadAll` for the
778
+ * merge invariants (skip pool-present, drop tombstoned, IDB if-absent). */
779
+ private fetchAndMergeFullLoad;
780
+ /** Hydrate `records` into the pool as instances of `modelName` and
781
+ * return them. Skips any record whose model isn't registered.
782
+ * Intended for stories and tests, not production. */
783
+ seed<T extends BaseModel = BaseModel>(modelName: string, records: Record<string, unknown>[]): T[];
784
+ /** Bulk seed: takes the same shape as `BootstrapResponse.models`. Useful
785
+ * for one-shot story setup that hydrates a graph in one call. */
786
+ seedMany(modelsByName: Record<string, Record<string, unknown>[]>): void;
787
+ /**
788
+ * Sync filter over the pool for records of `modelName` whose `indexKey`
789
+ * field matches `value`. Used by the typed `store.<entity>.peekByIndex`
790
+ * surface and shared with the diff path inside `refreshCollection`.
791
+ */
792
+ peekByIndex<T extends BaseModel = BaseModel>(modelName: string, indexKey: string, value: string): T[];
793
+ /**
794
+ * Re-fetch a collection from the server, replacing stale pool data.
795
+ * Existing instances are updated in-place so references held by
796
+ * components/hooks remain valid. Models the server no longer returns
797
+ * are removed from the pool.
798
+ */
799
+ refreshCollection<T extends BaseModel = BaseModel>(modelName: string, indexKey: string, value: string): Promise<T[]>;
800
+ /**
801
+ * Re-fetch specific models by ID from the server.
802
+ * Existing instances are updated in-place so references remain valid.
803
+ */
804
+ refreshModels(modelName: string, ids: string[]): Promise<BaseModel[]>;
805
+ /**
806
+ * Re-fetch all previously loaded collections and models for a given model type.
807
+ * Existing instances are updated in-place so references remain valid.
808
+ * Models the server no longer returns are removed from the pool.
809
+ */
810
+ refreshAllOfModel(modelName: string): Promise<void>;
811
+ status(): {
812
+ phase: BootstrapPhase;
813
+ error: string | undefined;
814
+ workspaceId: string;
815
+ objectPoolSize: number;
816
+ objectPoolCounts: Record<string, number>;
817
+ pending: number;
818
+ undoDepth: number;
819
+ redoDepth: number;
820
+ syncConnected: boolean;
821
+ lastSyncId: number;
822
+ };
823
+ /** Drop in-memory pool + the per-session "we hydrated from IDB" mirror.
824
+ * Used by the schema-mismatch fallback in delta processing and sync-
825
+ * group fetches: when the server's data shape changes we throw away the
826
+ * pool and re-bootstrap. The two clears are an invariant pair —
827
+ * extracting the helper enforces it across both call sites instead of
828
+ * trusting future authors to remember. `partialIndexCoverage` is NOT
829
+ * cleared here because the schema-mismatch path keeps coverage entries;
830
+ * the `fullyLoadedModels` mirror stays consistent with that. */
831
+ private resetPoolState;
832
+ teardown(): Promise<void>;
833
+ /** Debounced reconnect for SSE when `loadedModels` mutates. A burst of
834
+ * transitions in the same tick (or across awaited writes in the same
835
+ * async chain) coalesces into a single reconnect. setTimeout — not
836
+ * queueMicrotask — so consecutive `await db.writeModels(A); await
837
+ * db.writeModels(B)` doesn't reconnect twice. */
838
+ private scheduleSyncReconnect;
839
+ }