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,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
|
+
}
|