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,70 @@
1
+ /**
2
+ * Coalesces concurrent on-demand index queries into a single server call.
3
+ * Used by `StoreManager.getOrLoadCollection` when an `onDemandIndexBatchFetcher`
4
+ * is configured; otherwise the per-triple `onDemandFetcher` runs directly.
5
+ */
6
+ function queryKey(q) {
7
+ return `${q.modelName}:${q.indexKey}:${q.value}`;
8
+ }
9
+ export class BatchModelLoader {
10
+ constructor(fetcher) {
11
+ this.pending = [];
12
+ this.flushScheduled = false;
13
+ this.disposed = false;
14
+ this.fetcher = fetcher;
15
+ }
16
+ /**
17
+ * Schedule `query` for the next batch. The returned promise resolves with
18
+ * the records matching this specific triple (filtered from the per-model
19
+ * bag the server returns).
20
+ */
21
+ load(query) {
22
+ return new Promise((resolve, reject) => {
23
+ if (this.disposed) {
24
+ reject(new Error("BatchModelLoader disposed"));
25
+ return;
26
+ }
27
+ this.pending.push({ query, resolve, reject });
28
+ if (!this.flushScheduled) {
29
+ this.flushScheduled = true;
30
+ queueMicrotask(() => this.flush());
31
+ }
32
+ });
33
+ }
34
+ /** Reject any unflushed waiters. Called from `StoreManager.teardown`. */
35
+ dispose() {
36
+ this.disposed = true;
37
+ const stale = this.pending;
38
+ this.pending = [];
39
+ for (const req of stale) {
40
+ req.reject(new Error("BatchModelLoader disposed"));
41
+ }
42
+ }
43
+ async flush() {
44
+ if (this.disposed) {
45
+ return;
46
+ }
47
+ const batch = this.pending;
48
+ this.pending = [];
49
+ this.flushScheduled = false;
50
+ // Dedupe identical triples — every waiter for the same triple shares one
51
+ // server call.
52
+ const uniqueByKey = new Map();
53
+ for (const req of batch) {
54
+ uniqueByKey.set(queryKey(req.query), req.query);
55
+ }
56
+ const unique = [...uniqueByKey.values()];
57
+ try {
58
+ const results = await this.fetcher(unique);
59
+ for (const req of batch) {
60
+ const bag = results[req.query.modelName] ?? [];
61
+ req.resolve(bag.filter((r) => r[req.query.indexKey] === req.query.value));
62
+ }
63
+ }
64
+ catch (err) {
65
+ for (const req of batch) {
66
+ req.reject(err);
67
+ }
68
+ }
69
+ }
70
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Compound index-key collapse — when an adopter has flagged
3
+ * `serverSupportsCompoundIndexKeys: true`, this wrapper inspects each
4
+ * batched fetch and replaces N per-parent queries with one server-side
5
+ * compound (joined) query whenever ≥ COMPOUND_FETCH_THRESHOLD requests
6
+ * share a parent FK value.
7
+ *
8
+ * Example: 50 concurrent `Comment[issueId=Ix]` requests where every Issue
9
+ * is in cycle X collapses to one `Comment[issueId.cycleId=X]` request.
10
+ * The server resolves the dotted path via a join and returns the union;
11
+ * `BatchModelLoader.flush` already filters each waiter's bag by direct FK
12
+ * match (`record["issueId"] === Ix`), so callers see exactly their slice.
13
+ *
14
+ * Adopters without server JOIN support leave the flag unset; the engine
15
+ * fans out per-parent (existing behavior).
16
+ */
17
+ import type { IndexBatchFetcher, IndexQuery } from "./BatchModelLoader";
18
+ import { type ObjectPool } from "./ObjectPool";
19
+ /** Switch to a compound fetch only when at least this many pending
20
+ * requests share a single parent FK value. Below this, the per-parent
21
+ * fan-out wins because the compound response would over-fetch. */
22
+ export declare const COMPOUND_FETCH_THRESHOLD = 5;
23
+ /**
24
+ * Wrap an `IndexBatchFetcher` so it transparently collapses sharable
25
+ * batches into compound queries before invoking `inner`. The returned
26
+ * fetcher has the same shape — `BatchModelLoader` doesn't know whether
27
+ * collapse happened.
28
+ *
29
+ * `onCompoundFetched` (optional) fires once per synthetic compound query
30
+ * after `inner` resolves successfully, with the per-model response bag.
31
+ * Used by `StoreManager` to (a) write the full bag to IDB so future
32
+ * direct lookups inside the compound's coverage area find their records,
33
+ * and (b) record the compound key in `partialIndexCoverage` so
34
+ * derive-on-read can short-circuit subsequent direct loads.
35
+ */
36
+ export declare function wrapCompoundFetcher(inner: IndexBatchFetcher, pool: ObjectPool, options?: {
37
+ threshold?: number;
38
+ onCompoundFetched?: (compound: IndexQuery, bagForModel: Record<string, unknown>[]) => void | Promise<void>;
39
+ }): IndexBatchFetcher;
40
+ /**
41
+ * Group `queries` by `(modelName, indexKey)` and within each group, look
42
+ * up the parent in the pool and find a parent FK whose value is shared by
43
+ * ≥ threshold members. Replace the sharing subset with one compound
44
+ * query; non-sharing members stay direct. Returns the rewritten list.
45
+ */
46
+ export declare function collapseQueries(queries: IndexQuery[], pool: ObjectPool, threshold?: number): IndexQuery[];
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Compound index-key collapse — when an adopter has flagged
3
+ * `serverSupportsCompoundIndexKeys: true`, this wrapper inspects each
4
+ * batched fetch and replaces N per-parent queries with one server-side
5
+ * compound (joined) query whenever ≥ COMPOUND_FETCH_THRESHOLD requests
6
+ * share a parent FK value.
7
+ *
8
+ * Example: 50 concurrent `Comment[issueId=Ix]` requests where every Issue
9
+ * is in cycle X collapses to one `Comment[issueId.cycleId=X]` request.
10
+ * The server resolves the dotted path via a join and returns the union;
11
+ * `BatchModelLoader.flush` already filters each waiter's bag by direct FK
12
+ * match (`record["issueId"] === Ix`), so callers see exactly their slice.
13
+ *
14
+ * Adopters without server JOIN support leave the flag unset; the engine
15
+ * fans out per-parent (existing behavior).
16
+ */
17
+ import { ModelRegistry } from "./ModelRegistry";
18
+ import { readFk } from "./ObjectPool";
19
+ import { PropertyType } from "./types";
20
+ /** Switch to a compound fetch only when at least this many pending
21
+ * requests share a single parent FK value. Below this, the per-parent
22
+ * fan-out wins because the compound response would over-fetch. */
23
+ export const COMPOUND_FETCH_THRESHOLD = 5;
24
+ /**
25
+ * Wrap an `IndexBatchFetcher` so it transparently collapses sharable
26
+ * batches into compound queries before invoking `inner`. The returned
27
+ * fetcher has the same shape — `BatchModelLoader` doesn't know whether
28
+ * collapse happened.
29
+ *
30
+ * `onCompoundFetched` (optional) fires once per synthetic compound query
31
+ * after `inner` resolves successfully, with the per-model response bag.
32
+ * Used by `StoreManager` to (a) write the full bag to IDB so future
33
+ * direct lookups inside the compound's coverage area find their records,
34
+ * and (b) record the compound key in `partialIndexCoverage` so
35
+ * derive-on-read can short-circuit subsequent direct loads.
36
+ */
37
+ export function wrapCompoundFetcher(inner, pool, options = {}) {
38
+ const threshold = options.threshold ?? COMPOUND_FETCH_THRESHOLD;
39
+ return async (queries) => {
40
+ const collapsed = collapseQueries(queries, pool, threshold);
41
+ const result = await inner(collapsed);
42
+ if (options.onCompoundFetched != null) {
43
+ // A compound query is the synthetic kind we added during collapse —
44
+ // it has a dotted `indexKey`. (Adopters never originate dotted-path
45
+ // queries themselves; only this collapser does.)
46
+ for (const q of collapsed) {
47
+ if (q.indexKey.includes(".")) {
48
+ await options.onCompoundFetched(q, result[q.modelName] ?? []);
49
+ }
50
+ }
51
+ }
52
+ return result;
53
+ };
54
+ }
55
+ /**
56
+ * Group `queries` by `(modelName, indexKey)` and within each group, look
57
+ * up the parent in the pool and find a parent FK whose value is shared by
58
+ * ≥ threshold members. Replace the sharing subset with one compound
59
+ * query; non-sharing members stay direct. Returns the rewritten list.
60
+ */
61
+ export function collapseQueries(queries, pool, threshold = COMPOUND_FETCH_THRESHOLD) {
62
+ // Early exit: no group can exceed the total query count, so when the
63
+ // whole batch is below threshold there's nothing collapsible. (A mixed
64
+ // batch like 4 + 6 still proceeds — total ≥ threshold; the per-bucket
65
+ // check below handles the small group.)
66
+ if (queries.length < threshold) {
67
+ return queries;
68
+ }
69
+ const out = [];
70
+ const groups = new Map();
71
+ for (const q of queries) {
72
+ const key = `${q.modelName}|${q.indexKey}`;
73
+ let bucket = groups.get(key);
74
+ if (bucket == null) {
75
+ bucket = [];
76
+ groups.set(key, bucket);
77
+ }
78
+ bucket.push(q);
79
+ }
80
+ for (const bucket of groups.values()) {
81
+ if (bucket.length < threshold) {
82
+ out.push(...bucket);
83
+ continue;
84
+ }
85
+ const rewritten = collapseGroup(bucket, pool, threshold);
86
+ out.push(...rewritten);
87
+ }
88
+ return out;
89
+ }
90
+ /** A bucket of `IndexQuery`s sharing `(modelName, indexKey)`. Find a
91
+ * parent FK whose value is shared by ≥ threshold; emit one compound
92
+ * query for that subset. Stragglers stay direct.
93
+ *
94
+ * NOTE: only walks one hop on the parent (e.g., Task → projectId). A
95
+ * future revision could recurse to match Phase A's depth-3 walk
96
+ * (Task → projectId → Project → workspaceId), enabling
97
+ * `Comment[taskId.projectId.workspaceId=W]`. The dotted-path API on the
98
+ * server already supports this; the rewrite logic here would need to
99
+ * compose the path. If you change the depth here, also update
100
+ * `StoreManager.isCoveredByCompound` — its derive-on-read walks the
101
+ * same depth and must stay in sync, otherwise reads silently miss
102
+ * coverage that the rewriter is now emitting. */
103
+ function collapseGroup(bucket, pool, threshold) {
104
+ const sample = bucket[0];
105
+ // Resolve the parent model for this FK: child[indexKey] → Reference to which model?
106
+ const childMeta = ModelRegistry.getModelMeta(sample.modelName);
107
+ const fkProp = childMeta?.properties.get(sample.indexKey);
108
+ if (fkProp?.type !== PropertyType.Reference || fkProp.referenceTo == null) {
109
+ return bucket;
110
+ }
111
+ const parentModelName = fkProp.referenceTo;
112
+ const parentMeta = ModelRegistry.getModelMeta(parentModelName);
113
+ if (parentMeta == null) {
114
+ return bucket;
115
+ }
116
+ // Collect each parent's outgoing FK values (one hop). For each
117
+ // (fkName, value) pair, count how many members of the bucket share it.
118
+ // Members whose parent is missing from the pool can't contribute.
119
+ const fkCandidates = [];
120
+ for (const prop of parentMeta.properties.values()) {
121
+ if (prop.type === PropertyType.Reference && prop.referenceTo != null) {
122
+ fkCandidates.push(prop.name);
123
+ }
124
+ }
125
+ if (fkCandidates.length === 0) {
126
+ return bucket;
127
+ }
128
+ const sharing = new Map();
129
+ for (const q of bucket) {
130
+ const parent = pool.getById(parentModelName, q.value);
131
+ if (parent == null) {
132
+ continue;
133
+ }
134
+ for (const fk of fkCandidates) {
135
+ const v = readFk(parent, fk);
136
+ if (v == null) {
137
+ continue;
138
+ }
139
+ const key = `${fk}=${v}`;
140
+ let entry = sharing.get(key);
141
+ if (entry == null) {
142
+ entry = { fk, value: v, members: [] };
143
+ sharing.set(key, entry);
144
+ }
145
+ entry.members.push(q);
146
+ }
147
+ }
148
+ // Pick the largest sharing set ≥ threshold. Single-pass max — ties go to
149
+ // first-seen, which is stable enough; no need to optimize.
150
+ let best = null;
151
+ for (const entry of sharing.values()) {
152
+ if (entry.members.length < threshold) {
153
+ continue;
154
+ }
155
+ if (best == null || entry.members.length > best.members.length) {
156
+ best = entry;
157
+ }
158
+ }
159
+ if (best == null) {
160
+ return bucket;
161
+ }
162
+ // Emit one compound query + every non-member as direct.
163
+ const collapsed = [
164
+ {
165
+ modelName: sample.modelName,
166
+ indexKey: `${sample.indexKey}.${best.fk}`,
167
+ value: best.value,
168
+ },
169
+ ];
170
+ const memberSet = new Set(best.members);
171
+ for (const q of bucket) {
172
+ if (!memberSet.has(q)) {
173
+ collapsed.push(q);
174
+ }
175
+ }
176
+ return collapsed;
177
+ }
@@ -0,0 +1,303 @@
1
+ /**
2
+ * Database — wraps IndexedDB for a single workspace.
3
+ *
4
+ * Schema Migration:
5
+ * Instead of falling back to full bootstrap on every schemaHash change,
6
+ * we run actual IDB migrations:
7
+ * 1. Open the DB at its current version to read meta
8
+ * 2. If schemaHash matches → use as-is
9
+ * 3. If schemaHash differs → close, reopen at version+1
10
+ * 4. In onupgradeneeded: add new stores, remove old stores, update indexes
11
+ * This preserves existing data for unchanged models.
12
+ *
13
+ * Determines bootstrap type:
14
+ * - Full: no DB or meta, or a critical migration that can't be handled
15
+ * - Partial: DB exists with valid data, just need delta since lastSyncId
16
+ * - Local: DB exists, no server contact needed (offline start)
17
+ */
18
+ export interface DatabaseMeta {
19
+ lastSyncId: number;
20
+ subscribedSyncGroups: string[];
21
+ schemaHash: string;
22
+ /** IDB version number. Incremented on each client-side schema migration. */
23
+ dbVersion: number;
24
+ /**
25
+ * Server-side schema version. The server sends this with every bootstrap response.
26
+ * If the server's version changes (e.g. renamed columns, restructured models),
27
+ * the client detects the mismatch and forces a full bootstrap to avoid
28
+ * interpreting data against the wrong schema.
29
+ */
30
+ backendDatabaseVersion: number;
31
+ /**
32
+ * Per-model `schemaVersion` snapshot at the time meta was last persisted.
33
+ * Adapters compare this map against the current ModelRegistry on connect
34
+ * and clear any model whose version bumped — stale rows in IDB serialized
35
+ * against the old shape get wiped so the next bootstrap re-fetches them.
36
+ * Filled in by the adapter; callers don't need to populate it.
37
+ */
38
+ modelSchemaVersions?: Record<string, number>;
39
+ }
40
+ export declare enum BootstrapType {
41
+ Full = "full",
42
+ Partial = "partial",
43
+ Local = "local"
44
+ }
45
+ /**
46
+ * One recorded `getOrLoadCollection(modelName, indexKey, value)` query, captured
47
+ * with the `lastSyncId` at the time of fetch. Adopters ship these to the
48
+ * server on partial fetches so it can return only deltas since `firstSyncId`.
49
+ */
50
+ export interface PartialIndexEntry {
51
+ modelName: string;
52
+ indexKey: string;
53
+ value: string;
54
+ firstSyncId: number;
55
+ }
56
+ /** A header for a server-confirmed sync action — persisted in the
57
+ * `__syncActions` store so crash-recovery can decide whether the awaited
58
+ * delta has already arrived (and whether a pending tx's target was
59
+ * deleted while the client was away). */
60
+ export interface SyncActionHeader {
61
+ syncId: number;
62
+ modelName: string;
63
+ modelId: string;
64
+ action: "I" | "U" | "D" | "A" | "V" | "C";
65
+ }
66
+ /** Snapshot of every registered model's current `schemaVersion`. */
67
+ export declare function currentModelVersions(): Record<string, number>;
68
+ /**
69
+ * Diff stored vs current per-model schemaVersions. `cleared` lists models
70
+ * whose version bumped (rows + partial-index coverage wiped) and models
71
+ * removed from the registry (coverage wiped). `newlyAdded` lists models
72
+ * present in the registry but missing from a non-empty `stored` snapshot —
73
+ * the caller targets these in a follow-up full-bootstrap call.
74
+ */
75
+ export declare function diffModelVersions(adapter: Pick<StorageAdapter, "clearModelStore" | "clearPartialIndexesForModel">, stored: Record<string, number> | undefined): Promise<{
76
+ cleared: string[];
77
+ newlyAdded: string[];
78
+ }>;
79
+ /**
80
+ * Tracks which models have at least one row in storage and notifies
81
+ * listeners on add/remove transitions. Composed by both `Database` and
82
+ * `MemoryAdapter` (the trio of mark/unmark/onChange + listener Set is
83
+ * adapter-agnostic, so the duplication doesn't have to live in each).
84
+ */
85
+ export declare class LoadedModelsTracker {
86
+ private set;
87
+ private listeners;
88
+ get loadedModels(): ReadonlySet<string>;
89
+ /** Mark a model as having data. Notifies listeners on the first add. */
90
+ markLoaded(modelName: string): void;
91
+ /** Mark a model as empty (e.g. after `clearModelStore`). */
92
+ markUnloaded(modelName: string): void;
93
+ /** Empty the tracker without firing listeners — used at the start of
94
+ * `connect()` before re-seeding. */
95
+ reset(): void;
96
+ /** Seed without notifying — used by `connect()` to populate from storage. */
97
+ seed(modelName: string): void;
98
+ onChange(cb: () => void): () => void;
99
+ private notify;
100
+ }
101
+ /**
102
+ * Pluggable storage backend for the sync engine.
103
+ *
104
+ * The default implementation (`Database`) uses IndexedDB and is suited for
105
+ * browsers. Implement this interface to use a different backend — e.g.
106
+ * `MemoryAdapter` for Node.js agents, or a custom SQLite/Redis adapter for
107
+ * server-side environments that need durable off-heap storage.
108
+ */
109
+ export interface StorageAdapter {
110
+ /** Open / initialise the storage backend. Called once before bootstrap. */
111
+ connect(): Promise<void>;
112
+ loadMeta(): Promise<DatabaseMeta | null>;
113
+ saveMeta(meta: DatabaseMeta): Promise<void>;
114
+ get currentMeta(): DatabaseMeta | null;
115
+ determineBootstrapType(): Promise<BootstrapType>;
116
+ /**
117
+ * Names of models present in the live registry but missing from the
118
+ * persisted `modelSchemaVersions` snapshot — i.e., new since the last
119
+ * connect. Populated during `connect()`. StoreManager runs a targeted
120
+ * full fetch for just these so adopters don't need to bump anything.
121
+ */
122
+ readonly newlyAddedModels: string[];
123
+ /**
124
+ * Names of models with at least one row in local storage. Seeded on
125
+ * `connect()` and grown as `writeModels` / `writeModelsIfAbsent` write
126
+ * records; shrinks when a store is fully cleared. The SSE catchup URL
127
+ * passes this set as `onlyModels` so the server skips deltas for models
128
+ * the client never touched.
129
+ */
130
+ readonly loadedModels: ReadonlySet<string>;
131
+ /** Subscribe to add/remove transitions on `loadedModels`. Returns an
132
+ * unsubscribe function. Per-row deletes don't fire — only first writes
133
+ * to a model and full clears do. */
134
+ onLoadedModelsChange(cb: () => void): () => void;
135
+ /** Mark a model as loaded even when no rows were written — e.g.
136
+ * `getOrLoadCollection` returned an empty server response, which still
137
+ * expresses "we want SSE deltas for this model". `writeModels` already
138
+ * covers the non-empty case; this is the path for empty-but-successful
139
+ * fetches. */
140
+ markModelLoaded(modelName: string): void;
141
+ writeModels(modelName: string, records: Record<string, unknown>[]): Promise<void>;
142
+ writeModelsIfAbsent(modelName: string, records: Record<string, unknown>[]): Promise<void>;
143
+ readAllModels(modelName: string): Promise<Record<string, unknown>[]>;
144
+ readModel(modelName: string, id: string): Promise<Record<string, unknown> | null>;
145
+ readModelsByIndex(modelName: string, indexName: string, value: string): Promise<Record<string, unknown>[]>;
146
+ deleteModel(modelName: string, id: string): Promise<void>;
147
+ deleteModels(modelName: string, ids: string[]): Promise<void>;
148
+ /** Delete all records matching indexName === value in a single IDB pass. */
149
+ deleteModelsByIndex(modelName: string, indexName: string, value: string): Promise<void>;
150
+ clearModelStore(modelName: string): Promise<void>;
151
+ cacheTransaction(data: unknown): Promise<number | null>;
152
+ /**
153
+ * Update an existing cached transaction by `idbKey`. Used to flag a
154
+ * transaction as awaiting a specific syncId (server-ack'd, waiting for the
155
+ * matching SSE delta). On crash, recovery checks the SyncAction store to
156
+ * decide whether the awaited delta already arrived.
157
+ */
158
+ updateCachedTransaction(idbKey: number, data: unknown): Promise<void>;
159
+ /** Returns `(idbKey, data)` pairs so recovery can selectively delete
160
+ * resolved entries without clearing the whole store. */
161
+ getCachedTransactions(): Promise<{
162
+ idbKey: number;
163
+ data: unknown;
164
+ }[]>;
165
+ deleteCachedTransactions(keys: number[]): Promise<void>;
166
+ clearCachedTransactions(): Promise<void>;
167
+ /**
168
+ * Persist headers for received SSE sync actions. Crash-recovery checks this
169
+ * store to (a) recognize transactions whose ack-syncId already arrived,
170
+ * (b) detect that a pending tx's target was deleted before the queue could
171
+ * flush. Headers only — `data` is not stored, since the model state is
172
+ * already durable in its own store.
173
+ */
174
+ recordSyncActions(actions: SyncActionHeader[]): Promise<void>;
175
+ hasSyncAction(syncId: number): Promise<boolean>;
176
+ findSyncActionsForModel(modelName: string, modelId: string): Promise<{
177
+ syncId: number;
178
+ action: string;
179
+ }[]>;
180
+ /** Drop sync actions older than `belowSyncId`. Called periodically to bound storage. */
181
+ pruneSyncActionsBelow(belowSyncId: number): Promise<void>;
182
+ /**
183
+ * Record that a `getOrLoadCollection(modelName, indexKey, value)` query has been
184
+ * fetched in full as of `firstSyncId`. Survives reload — on the next
185
+ * bootstrap the engine knows which scoped queries are already covered
186
+ * locally (and as of which point in the sync log) and can request a
187
+ * targeted delta instead of a full re-fetch.
188
+ */
189
+ recordPartialIndex(modelName: string, indexKey: string, value: string, firstSyncId: number): Promise<void>;
190
+ /** Clear coverage for a single (modelName, indexKey, value) tuple. */
191
+ clearPartialIndex(modelName: string, indexKey: string, value: string): Promise<void>;
192
+ /** Clear all coverage entries for a given model — used by schema migrations. */
193
+ clearPartialIndexesForModel(modelName: string): Promise<void>;
194
+ /** Read every recorded partial index. Called once at connect to populate the in-memory cache. */
195
+ loadPartialIndexes(): Promise<PartialIndexEntry[]>;
196
+ /**
197
+ * Close the storage connection without deleting any data.
198
+ * Called by StoreManager.teardown() during React unmount / cleanup.
199
+ * Data is preserved for the next page load (enables faster partial bootstrap).
200
+ */
201
+ close(): Promise<void>;
202
+ /**
203
+ * Close the connection AND permanently delete all persisted data.
204
+ * Use for explicit logout / factory-reset flows — NOT for routine teardown.
205
+ */
206
+ destroy(): Promise<void>;
207
+ get isConnected(): boolean;
208
+ }
209
+ export declare class Database implements StorageAdapter {
210
+ private db;
211
+ private workspaceId;
212
+ private meta;
213
+ newlyAddedModels: string[];
214
+ /** Set to true if connect() cleared rows for one or more models because
215
+ * their per-model `schemaVersion` bumped. Forces a Full bootstrap so the
216
+ * cleared rows refill from the server. */
217
+ migrationClearedModels: boolean;
218
+ private loadedTracker;
219
+ get loadedModels(): ReadonlySet<string>;
220
+ onLoadedModelsChange(cb: () => void): () => void;
221
+ markModelLoaded(modelName: string): void;
222
+ constructor(workspaceId: string);
223
+ connect(): Promise<void>;
224
+ /** One IDB count() per store to seed `loadedModels` with anything that
225
+ * survived from a prior session. Runs once per connect. */
226
+ private seedLoadedModels;
227
+ /**
228
+ * IDB blocks schema upgrades and deletions until all open connections close.
229
+ * onversionchange is the browser's signal to us: "another tab needs you to
230
+ * let go." Close immediately so the other tab's open/deleteDatabase call
231
+ * can proceed.
232
+ */
233
+ private attachVersionChangeHandler;
234
+ /** Open DB at its current version (no migration). */
235
+ private openDB;
236
+ /** Open DB at a specific version, triggering migration in onupgradeneeded. */
237
+ private openDBWithMigration;
238
+ /** Create the engine's reserved stores (`__`-prefixed) if they don't yet
239
+ * exist. Called from both first-time creation and incremental migration —
240
+ * adding a new system store means one entry here, not two. */
241
+ private ensureSystemStores;
242
+ /** Create all stores from scratch (first-time DB creation). */
243
+ private createAllStores;
244
+ /** Run an incremental migration: add/remove/update stores. */
245
+ private migrateSchema;
246
+ /** Create an object store for a model with its indexed properties. */
247
+ private createModelStore;
248
+ /** Add/remove indexes on an existing store to match current ModelRegistry. */
249
+ private migrateIndexes;
250
+ determineBootstrapType(): Promise<BootstrapType>;
251
+ loadMeta(): Promise<DatabaseMeta | null>;
252
+ saveMeta(meta: DatabaseMeta): Promise<void>;
253
+ get currentMeta(): DatabaseMeta | null;
254
+ writeModels(modelName: string, records: Record<string, unknown>[]): Promise<void>;
255
+ writeModelsIfAbsent(modelName: string, records: Record<string, unknown>[]): Promise<void>;
256
+ readAllModels(modelName: string): Promise<Record<string, unknown>[]>;
257
+ readModel(modelName: string, id: string): Promise<Record<string, unknown> | null>;
258
+ readModelsByIndex(modelName: string, indexName: string, value: string): Promise<Record<string, unknown>[]>;
259
+ deleteModel(modelName: string, id: string): Promise<void>;
260
+ /** Delete multiple records in a single IDB transaction. */
261
+ deleteModels(modelName: string, ids: string[]): Promise<void>;
262
+ deleteModelsByIndex(modelName: string, indexName: string, value: string): Promise<void>;
263
+ clearModelStore(modelName: string): Promise<void>;
264
+ /**
265
+ * Open a `__transactions` transaction, tolerating the brief window where
266
+ * the connection is closing but not yet nulled — a cross-tab `versionchange`
267
+ * upgrade, or teardown racing an SSE reconnect. In that window `this.db` is
268
+ * still non-null yet `.transaction()` throws `InvalidStateError` ("the
269
+ * database connection is closing"). Returns `null` so callers degrade
270
+ * gracefully: the transaction cache is a best-effort resend buffer that
271
+ * self-heals on the next clean connection.
272
+ */
273
+ private openTxCacheTx;
274
+ cacheTransaction(data: unknown): Promise<number | null>;
275
+ getCachedTransactions(): Promise<{
276
+ idbKey: number;
277
+ data: unknown;
278
+ }[]>;
279
+ deleteCachedTransactions(idbKeys: number[]): Promise<void>;
280
+ clearCachedTransactions(): Promise<void>;
281
+ updateCachedTransaction(idbKey: number, data: unknown): Promise<void>;
282
+ recordSyncActions(actions: SyncActionHeader[]): Promise<void>;
283
+ hasSyncAction(syncId: number): Promise<boolean>;
284
+ findSyncActionsForModel(modelName: string, modelId: string): Promise<{
285
+ syncId: number;
286
+ action: string;
287
+ }[]>;
288
+ pruneSyncActionsBelow(belowSyncId: number): Promise<void>;
289
+ recordPartialIndex(modelName: string, indexKey: string, value: string, firstSyncId: number): Promise<void>;
290
+ clearPartialIndex(modelName: string, indexKey: string, value: string): Promise<void>;
291
+ clearPartialIndexesForModel(modelName: string): Promise<void>;
292
+ loadPartialIndexes(): Promise<PartialIndexEntry[]>;
293
+ /** Close the IDB connection without deleting any data. */
294
+ close(): Promise<void>;
295
+ /** Close the connection AND delete all persisted data for this workspace. */
296
+ destroy(): Promise<void>;
297
+ get isConnected(): boolean;
298
+ private hasStore;
299
+ private idbGet;
300
+ private idbGetAll;
301
+ private idbPut;
302
+ private waitForTransaction;
303
+ }