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.
- package/LICENSE +21 -0
- package/README.md +358 -0
- package/dist/core/BaseModel.d.ts +76 -0
- package/dist/core/BaseModel.js +505 -0
- package/dist/core/BaseSSEConnection.d.ts +31 -0
- package/dist/core/BaseSSEConnection.js +91 -0
- package/dist/core/BatchModelLoader.d.ts +27 -0
- package/dist/core/BatchModelLoader.js +70 -0
- package/dist/core/CompoundIndexFetcher.d.ts +46 -0
- package/dist/core/CompoundIndexFetcher.js +177 -0
- package/dist/core/Database.d.ts +303 -0
- package/dist/core/Database.js +837 -0
- package/dist/core/LazyCollection.d.ts +168 -0
- package/dist/core/LazyCollection.js +403 -0
- package/dist/core/LazyOwnedCollection.d.ts +35 -0
- package/dist/core/LazyOwnedCollection.js +66 -0
- package/dist/core/MemoryAdapter.d.ts +67 -0
- package/dist/core/MemoryAdapter.js +243 -0
- package/dist/core/ModelRegistry.d.ts +64 -0
- package/dist/core/ModelRegistry.js +217 -0
- package/dist/core/ModelStream.d.ts +33 -0
- package/dist/core/ModelStream.js +68 -0
- package/dist/core/ObjectPool.d.ts +113 -0
- package/dist/core/ObjectPool.js +339 -0
- package/dist/core/Store.d.ts +40 -0
- package/dist/core/Store.js +73 -0
- package/dist/core/StoreManager.d.ts +839 -0
- package/dist/core/StoreManager.js +2034 -0
- package/dist/core/SyncConnection.d.ts +105 -0
- package/dist/core/SyncConnection.js +348 -0
- package/dist/core/Transaction.d.ts +114 -0
- package/dist/core/Transaction.js +147 -0
- package/dist/core/TransactionQueue.d.ts +110 -0
- package/dist/core/TransactionQueue.js +601 -0
- package/dist/core/decorators.d.ts +66 -0
- package/dist/core/decorators.js +278 -0
- package/dist/core/hash.d.ts +6 -0
- package/dist/core/hash.js +12 -0
- package/dist/core/index.d.ts +16 -0
- package/dist/core/index.js +18 -0
- package/dist/core/internal.d.ts +27 -0
- package/dist/core/internal.js +25 -0
- package/dist/core/observability.d.ts +21 -0
- package/dist/core/observability.js +66 -0
- package/dist/core/refAccessors.d.ts +43 -0
- package/dist/core/refAccessors.js +80 -0
- package/dist/core/serializers.d.ts +2 -0
- package/dist/core/serializers.js +2 -0
- package/dist/core/types.d.ts +320 -0
- package/dist/core/types.js +84 -0
- package/dist/react/index.d.ts +82 -0
- package/dist/react/index.js +373 -0
- package/dist/schema/builders.d.ts +29 -0
- package/dist/schema/builders.js +81 -0
- package/dist/schema/compile.d.ts +28 -0
- package/dist/schema/compile.js +334 -0
- package/dist/schema/createStore.d.ts +235 -0
- package/dist/schema/createStore.js +264 -0
- package/dist/schema/extend.d.ts +46 -0
- package/dist/schema/extend.js +6 -0
- package/dist/schema/index.d.ts +13 -0
- package/dist/schema/index.js +8 -0
- package/dist/schema/infer.d.ts +102 -0
- package/dist/schema/infer.js +1 -0
- package/dist/schema/types.d.ts +76 -0
- package/dist/schema/types.js +1 -0
- package/dist/schema/zod.d.ts +90 -0
- package/dist/schema/zod.js +101 -0
- 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
|
+
}
|