@lunora/replica 1.0.0-alpha.4 → 1.0.0-alpha.5
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/dist/adapters/sqlite-wasm.d.mts +18 -8
- package/dist/adapters/sqlite-wasm.d.ts +18 -8
- package/dist/adapters/sqlite-wasm.mjs +12 -24
- package/dist/index.d.mts +79 -18
- package/dist/index.d.ts +79 -18
- package/dist/index.mjs +9 -9
- package/dist/packem_shared/EventLog-CnK-3Wge.mjs +264 -0
- package/dist/packem_shared/{EventLogDO-CZYUvvSr.mjs → EventLogDO-DqlsVx0H.mjs} +155 -9
- package/dist/packem_shared/{EventLogDOClient-DGiEdi96.mjs → EventLogDOClient-F4FO8Si4.mjs} +8 -2
- package/dist/packem_shared/{EventSource-DfV4VoRD.mjs → EventSource-D5yO9_aI.mjs} +48 -22
- package/dist/packem_shared/{EventsSync-DkVbU0WV.mjs → EventsSync-BP36tC9O.mjs} +49 -17
- package/dist/packem_shared/{LocalMirror-GeJ26eNe.mjs → LocalMirror-a-5jEqFN.mjs} +34 -3
- package/dist/packem_shared/{MaterializerRuntime-HqNXqJxp.mjs → MaterializerRuntime-BoIrsMYB.mjs} +65 -45
- package/dist/packem_shared/applyDiff-98tKzmiW.mjs +67 -0
- package/dist/packem_shared/{classifyChanges-aZmkxgVI.mjs → classifyChanges-RcqLBpLs.mjs} +6 -3
- package/dist/packem_shared/{local-mirror.d-Cd8tAg-W.d.ts → local-mirror.d-ByIjd7sW.d.ts} +94 -3
- package/dist/packem_shared/{local-mirror.d-BUeOe5KC.d.mts → local-mirror.d-DL1XJBB3.d.mts} +94 -3
- package/dist/react.d.mts +1 -1
- package/dist/react.d.ts +1 -1
- package/dist/react.mjs +2 -2
- package/package.json +1 -1
- package/dist/packem_shared/EventLog-zMy7AYP4.mjs +0 -162
- package/dist/packem_shared/applyDiff-BtbIl1D3.mjs +0 -40
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createSqlJsAdapter } from '../adapters/sqljs.mjs';
|
|
2
2
|
import { applyDiffToDb as applyDiffToDatabase, escapeIdentifier } from './applyDiffToDb-DQ1xZp5J.mjs';
|
|
3
|
-
import { EventLog } from './EventLog-
|
|
3
|
+
import { EventLog } from './EventLog-CnK-3Wge.mjs';
|
|
4
4
|
|
|
5
5
|
const MIRROR_META_TABLE = "__lunora_mirror_meta";
|
|
6
6
|
const ensureMetaTable = (database) => {
|
|
@@ -14,8 +14,16 @@ const ensureMetaTable = (database) => {
|
|
|
14
14
|
class LocalMirror {
|
|
15
15
|
#db;
|
|
16
16
|
#tables;
|
|
17
|
-
#eventLog
|
|
17
|
+
#eventLog;
|
|
18
18
|
#changeListeners = /* @__PURE__ */ new Set();
|
|
19
|
+
/**
|
|
20
|
+
* Monotonically increasing counter bumped on every state-changing
|
|
21
|
+
* operation (`applyDiff`, `clearData`) — independent of `eventLog.size`.
|
|
22
|
+
* `clearData` doesn't grow the log (REPLICA-09), so a consumer that used
|
|
23
|
+
* `eventLog.size` as its `useSyncExternalStore` snapshot would never
|
|
24
|
+
* re-render after a clear; `version` changes on both operations.
|
|
25
|
+
*/
|
|
26
|
+
#version = 0;
|
|
19
27
|
/**
|
|
20
28
|
* Convenience factory that creates a {@link LocalMirror} backed by a
|
|
21
29
|
* {@link createSqlJsAdapter sql.js adapter} without needing to import
|
|
@@ -40,6 +48,7 @@ class LocalMirror {
|
|
|
40
48
|
constructor(options) {
|
|
41
49
|
this.#db = options.db;
|
|
42
50
|
this.#tables = { ...options.tables };
|
|
51
|
+
this.#eventLog = new EventLog({ maxEntries: options.maxEventLogEntries });
|
|
43
52
|
ensureMetaTable(this.#db);
|
|
44
53
|
}
|
|
45
54
|
/**
|
|
@@ -68,6 +77,15 @@ class LocalMirror {
|
|
|
68
77
|
get db() {
|
|
69
78
|
return this.#db;
|
|
70
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* Monotonically increasing version counter, bumped on every operation
|
|
82
|
+
* that changes mirrored data (`applyDiff`, `clearData`). Use this — not
|
|
83
|
+
* `eventLog.size` — as a `useSyncExternalStore` snapshot so operations
|
|
84
|
+
* that don't append to the log still trigger a re-render.
|
|
85
|
+
*/
|
|
86
|
+
get version() {
|
|
87
|
+
return this.#version;
|
|
88
|
+
}
|
|
71
89
|
/**
|
|
72
90
|
* Apply a server-side diff to the local SQLite mirror.
|
|
73
91
|
*
|
|
@@ -82,6 +100,7 @@ class LocalMirror {
|
|
|
82
100
|
this.#ensureTableSchema(diff);
|
|
83
101
|
applyDiffToDatabase(this.#db, diff, pkColumn);
|
|
84
102
|
this.#eventLog.append("table-diff", diff, [diff]);
|
|
103
|
+
this.#version += 1;
|
|
85
104
|
for (const listener of this.#changeListeners) {
|
|
86
105
|
try {
|
|
87
106
|
listener();
|
|
@@ -106,16 +125,28 @@ class LocalMirror {
|
|
|
106
125
|
/**
|
|
107
126
|
* Delete every row from all known tables (preserves the event log
|
|
108
127
|
* and schema). Useful when re-syncing from scratch.
|
|
128
|
+
*
|
|
129
|
+
* Notifies `onChange` subscribers and bumps {@link LocalMirror.version}
|
|
130
|
+
* (REPLICA-09) even though nothing is appended to the event log — a
|
|
131
|
+
* consumer keyed only on `eventLog.size` would otherwise never learn the
|
|
132
|
+
* mirror was cleared and keep rendering deleted rows.
|
|
109
133
|
*/
|
|
110
134
|
clearData() {
|
|
111
135
|
const tables = this.#db.query(
|
|
112
|
-
`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE '
|
|
136
|
+
String.raw`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE '\_\_lunora\_%' ESCAPE '\' AND name NOT LIKE 'sqlite\_%' ESCAPE '\'`
|
|
113
137
|
);
|
|
114
138
|
this.#db.transaction(() => {
|
|
115
139
|
for (const { name } of tables) {
|
|
116
140
|
this.#db.exec(`DELETE FROM ${escapeIdentifier(name)}`);
|
|
117
141
|
}
|
|
118
142
|
});
|
|
143
|
+
this.#version += 1;
|
|
144
|
+
for (const listener of this.#changeListeners) {
|
|
145
|
+
try {
|
|
146
|
+
listener();
|
|
147
|
+
} catch {
|
|
148
|
+
}
|
|
149
|
+
}
|
|
119
150
|
}
|
|
120
151
|
/**
|
|
121
152
|
* Dispose the mirror and close the database connection.
|
package/dist/packem_shared/{MaterializerRuntime-HqNXqJxp.mjs → MaterializerRuntime-BoIrsMYB.mjs}
RENAMED
|
@@ -22,50 +22,60 @@ class MaterializerRuntime {
|
|
|
22
22
|
#doClient;
|
|
23
23
|
#unknownEventHandling;
|
|
24
24
|
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
25
|
+
* Per-materializer watermark: the seq of the next event each materializer
|
|
26
|
+
* (by index, parallel to `#materializers`) has NOT yet applied. Starts at
|
|
27
|
+
* `0` for every materializer and advances independently — a materializer
|
|
28
|
+
* with no snapshot stays at `0` even when a sibling has recovered to a
|
|
29
|
+
* much higher watermark, so catch-up never skips events for it (REPLICA-04).
|
|
27
30
|
*/
|
|
28
|
-
#
|
|
31
|
+
#watermarks;
|
|
29
32
|
constructor(materializers, options = {}) {
|
|
30
33
|
this.#materializers = [...materializers];
|
|
34
|
+
this.#watermarks = this.#materializers.map(() => 0);
|
|
31
35
|
this.#snapshotStore = options.snapshotStore;
|
|
32
36
|
this.#doClient = options.doClient;
|
|
33
37
|
this.#unknownEventHandling = options.unknownEventHandling ?? "warn";
|
|
34
38
|
}
|
|
35
39
|
// ── Public API ──────────────────────────────────────────────────────
|
|
36
40
|
/**
|
|
37
|
-
* The
|
|
41
|
+
* The lowest per-materializer watermark — the seq of the next event that
|
|
42
|
+
* at least one materializer has not yet applied. `0` when there are no
|
|
43
|
+
* materializers.
|
|
38
44
|
*/
|
|
39
45
|
get appliedSeq() {
|
|
40
|
-
return this.#
|
|
46
|
+
return this.#watermarks.length > 0 ? Math.min(...this.#watermarks) : 0;
|
|
41
47
|
}
|
|
42
48
|
/**
|
|
43
|
-
* Replay a batch of entries
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
49
|
+
* Replay a batch of entries, applying each entry only to the
|
|
50
|
+
* materializers whose own watermark is behind it — a materializer at or
|
|
51
|
+
* past an entry's seq (e.g. recovered from a snapshot, or already caught
|
|
52
|
+
* up) skips it, so no materializer ever double-applies an event.
|
|
53
|
+
* @returns The number of entries applied to at least one materializer.
|
|
47
54
|
*/
|
|
48
55
|
applyEntries(entries) {
|
|
49
56
|
let count = 0;
|
|
50
57
|
for (const entry of entries) {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
m.apply(entry);
|
|
58
|
-
}
|
|
59
|
-
for (let i = 0; i < this.#materializers.length; i += 1) {
|
|
60
|
-
if (this.#materializers[i]?.state !== statesBefore[i]) {
|
|
61
|
-
stateChanged = true;
|
|
62
|
-
break;
|
|
58
|
+
let appliedToAny = false;
|
|
59
|
+
let anyChanged = false;
|
|
60
|
+
for (const [i, materializer] of this.#materializers.entries()) {
|
|
61
|
+
const watermark = this.#watermarks[i] ?? 0;
|
|
62
|
+
if (entry.seq < watermark) {
|
|
63
|
+
continue;
|
|
63
64
|
}
|
|
65
|
+
const stateBefore = materializer.state;
|
|
66
|
+
materializer.apply(entry);
|
|
67
|
+
appliedToAny = true;
|
|
68
|
+
if (materializer.state !== stateBefore) {
|
|
69
|
+
anyChanged = true;
|
|
70
|
+
}
|
|
71
|
+
this.#watermarks[i] = entry.seq + 1;
|
|
72
|
+
}
|
|
73
|
+
if (!appliedToAny) {
|
|
74
|
+
continue;
|
|
64
75
|
}
|
|
65
|
-
if (!
|
|
76
|
+
if (!anyChanged) {
|
|
66
77
|
this.#handleUnknownEvent(entry);
|
|
67
78
|
}
|
|
68
|
-
this.#appliedSeq = entry.seq + 1;
|
|
69
79
|
count += 1;
|
|
70
80
|
}
|
|
71
81
|
return count;
|
|
@@ -99,44 +109,50 @@ class MaterializerRuntime {
|
|
|
99
109
|
/**
|
|
100
110
|
* Attempt to recover materialized state from a snapshot store.
|
|
101
111
|
*
|
|
102
|
-
* When a snapshot is found for a materializer, its state
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
112
|
+
* When a snapshot is found for a materializer, its state AND its own
|
|
113
|
+
* watermark are restored from that snapshot. A materializer with no
|
|
114
|
+
* snapshot keeps its current watermark (`0` for a fresh runtime) — it
|
|
115
|
+
* does NOT inherit another materializer's watermark, so it still catches
|
|
116
|
+
* up from the very beginning (REPLICA-04: previously a shared watermark
|
|
117
|
+
* was bumped to the MAX across snapshots, permanently skipping events 0..N
|
|
118
|
+
* for any un-snapshotted or lagging materializer).
|
|
119
|
+
* @returns The highest snapshot `appliedSeq` across all materializers, or
|
|
120
|
+
* `0` — kept for backward compatibility; callers that need the fetch
|
|
121
|
+
* watermark for catch-up should use the per-materializer minimum instead
|
|
122
|
+
* (see `initialize`).
|
|
106
123
|
*/
|
|
107
124
|
async recoverFromSnapshots() {
|
|
108
125
|
if (!this.#snapshotStore) {
|
|
109
126
|
return 0;
|
|
110
127
|
}
|
|
111
128
|
let maxSeq = 0;
|
|
112
|
-
for (const
|
|
113
|
-
const raw = await this.#snapshotStore.load(
|
|
129
|
+
for (const [i, materializer] of this.#materializers.entries()) {
|
|
130
|
+
const raw = await this.#snapshotStore.load(materializer.def.name);
|
|
114
131
|
if (raw !== null && typeof raw === "object") {
|
|
115
132
|
const snapshot = raw;
|
|
116
133
|
if (snapshot.state !== void 0) {
|
|
117
|
-
|
|
134
|
+
materializer.setState(snapshot.state);
|
|
118
135
|
}
|
|
136
|
+
this.#watermarks[i] = snapshot.appliedSeq;
|
|
119
137
|
if (snapshot.appliedSeq > maxSeq) {
|
|
120
138
|
maxSeq = snapshot.appliedSeq;
|
|
121
139
|
}
|
|
122
140
|
}
|
|
123
141
|
}
|
|
124
|
-
if (maxSeq > this.#appliedSeq) {
|
|
125
|
-
this.#appliedSeq = maxSeq;
|
|
126
|
-
}
|
|
127
142
|
return maxSeq;
|
|
128
143
|
}
|
|
129
144
|
/**
|
|
130
|
-
* Persist the current state of all materializers as snapshots
|
|
145
|
+
* Persist the current state of all materializers as snapshots, each
|
|
146
|
+
* tagged with ITS OWN watermark (not a shared one).
|
|
131
147
|
*/
|
|
132
148
|
async persistSnapshots() {
|
|
133
149
|
if (!this.#snapshotStore) {
|
|
134
150
|
return;
|
|
135
151
|
}
|
|
136
|
-
for (const
|
|
137
|
-
await this.#snapshotStore.save(
|
|
138
|
-
appliedSeq: this.#
|
|
139
|
-
state:
|
|
152
|
+
for (const [i, materializer] of this.#materializers.entries()) {
|
|
153
|
+
await this.#snapshotStore.save(materializer.def.name, {
|
|
154
|
+
appliedSeq: this.#watermarks[i] ?? 0,
|
|
155
|
+
state: materializer.state
|
|
140
156
|
});
|
|
141
157
|
}
|
|
142
158
|
}
|
|
@@ -146,8 +162,11 @@ class MaterializerRuntime {
|
|
|
146
162
|
*
|
|
147
163
|
* 1. Recover materialized state from snapshots (if a snapshotStore is
|
|
148
164
|
* configured).
|
|
149
|
-
* 2. Fetch all entries since the
|
|
150
|
-
*
|
|
165
|
+
* 2. Fetch all entries since the MINIMUM per-materializer watermark from
|
|
166
|
+
* the DO — not the maximum — so a materializer with no snapshot (or a
|
|
167
|
+
* lower one) still receives every event it hasn't seen (REPLICA-04).
|
|
168
|
+
* 3. Apply them through the materializers; `applyEntries` skips each
|
|
169
|
+
* entry for any materializer already past it, so nothing is double-applied.
|
|
151
170
|
*
|
|
152
171
|
* Call this once on startup / after the DO binding is available.
|
|
153
172
|
* @returns The number of entries applied during catch-up.
|
|
@@ -156,8 +175,9 @@ class MaterializerRuntime {
|
|
|
156
175
|
if (!this.#doClient) {
|
|
157
176
|
return 0;
|
|
158
177
|
}
|
|
159
|
-
|
|
160
|
-
const
|
|
178
|
+
await this.recoverFromSnapshots();
|
|
179
|
+
const minWatermark = this.#watermarks.length > 0 ? Math.min(...this.#watermarks) : 0;
|
|
180
|
+
const entries = await this.#doClient.getSince(minWatermark);
|
|
161
181
|
if (entries.length === 0) {
|
|
162
182
|
return 0;
|
|
163
183
|
}
|
|
@@ -188,9 +208,9 @@ class MaterializerRuntime {
|
|
|
188
208
|
* Reset all materializers to their initial state and clear snapshots.
|
|
189
209
|
*/
|
|
190
210
|
reset() {
|
|
191
|
-
this.#
|
|
192
|
-
|
|
193
|
-
|
|
211
|
+
for (const [i, materializer] of this.#materializers.entries()) {
|
|
212
|
+
this.#watermarks[i] = 0;
|
|
213
|
+
materializer.reset();
|
|
194
214
|
}
|
|
195
215
|
}
|
|
196
216
|
/**
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
const canonicalizeForHash = (value) => {
|
|
2
|
+
if (Array.isArray(value)) {
|
|
3
|
+
return value.map((item) => canonicalizeForHash(item));
|
|
4
|
+
}
|
|
5
|
+
if (value !== null && typeof value === "object") {
|
|
6
|
+
const record = value;
|
|
7
|
+
const sortedKeys = Object.keys(record).toSorted((a, b) => a.localeCompare(b));
|
|
8
|
+
const result = {};
|
|
9
|
+
for (const key of sortedKeys) {
|
|
10
|
+
result[key] = canonicalizeForHash(record[key]);
|
|
11
|
+
}
|
|
12
|
+
return result;
|
|
13
|
+
}
|
|
14
|
+
return value;
|
|
15
|
+
};
|
|
16
|
+
const deriveInsertId = (diff, changeIndex, data) => {
|
|
17
|
+
const diffIdentity = diff.id ?? String(diff.timestamp);
|
|
18
|
+
const input = `${diff.table}::${diffIdentity}::${String(changeIndex)}::${JSON.stringify(canonicalizeForHash(data))}`;
|
|
19
|
+
let hash = 0xcbf29ce484222325n;
|
|
20
|
+
const prime = 0x00000100000001b3n;
|
|
21
|
+
const mask64 = 0xffffffffffffffffn;
|
|
22
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
23
|
+
hash ^= BigInt(input.codePointAt(index) ?? 0);
|
|
24
|
+
hash = hash * prime & mask64;
|
|
25
|
+
}
|
|
26
|
+
return `row-${hash.toString(16).padStart(16, "0")}`;
|
|
27
|
+
};
|
|
28
|
+
const applyDiff = (current, diff) => {
|
|
29
|
+
const next = new Map(current);
|
|
30
|
+
for (const [changeIndex, change] of diff.changes.entries()) {
|
|
31
|
+
switch (change.type) {
|
|
32
|
+
case "delete": {
|
|
33
|
+
next.delete(change.id);
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
case "insert": {
|
|
37
|
+
const rawId = change.data.id;
|
|
38
|
+
const id = typeof rawId === "string" || typeof rawId === "number" ? String(rawId) : deriveInsertId(diff, changeIndex, change.data);
|
|
39
|
+
next.set(id, { ...change.data, id });
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
case "update": {
|
|
43
|
+
const existing = next.get(change.id);
|
|
44
|
+
if (existing) {
|
|
45
|
+
next.set(change.id, { ...existing, ...change.data });
|
|
46
|
+
}
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return next;
|
|
52
|
+
};
|
|
53
|
+
const applyDiffs = (current, diffs) => {
|
|
54
|
+
let result = new Map(current);
|
|
55
|
+
for (const diff of diffs) {
|
|
56
|
+
result = applyDiff(result, diff);
|
|
57
|
+
}
|
|
58
|
+
return result;
|
|
59
|
+
};
|
|
60
|
+
const applyDiffToSnapshot = (snapshot, diff) => {
|
|
61
|
+
const next = new Map(snapshot);
|
|
62
|
+
const tableMap = next.get(diff.table) ?? /* @__PURE__ */ new Map();
|
|
63
|
+
next.set(diff.table, applyDiff(tableMap, diff));
|
|
64
|
+
return next;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export { applyDiff, applyDiffToSnapshot, applyDiffs };
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
const createTableDiff = (table, changes, timestamp) => {
|
|
1
|
+
const createTableDiff = (table, changes, timestamp, id) => {
|
|
2
2
|
return {
|
|
3
3
|
table,
|
|
4
4
|
changes,
|
|
5
|
-
timestamp: timestamp ?? Date.now()
|
|
5
|
+
timestamp: timestamp ?? Date.now(),
|
|
6
|
+
id: id ?? crypto.randomUUID()
|
|
6
7
|
};
|
|
7
8
|
};
|
|
8
9
|
const isDiffEmpty = (diff) => diff.changes.length === 0;
|
|
@@ -28,10 +29,12 @@ const mergeDiffs = (diffs) => {
|
|
|
28
29
|
}
|
|
29
30
|
const first = diffs[0];
|
|
30
31
|
const last = diffs[diffs.length - 1];
|
|
32
|
+
const mergedId = `merge:${diffs.map((d) => d.id ?? String(d.timestamp)).join("|")}`;
|
|
31
33
|
return createTableDiff(
|
|
32
34
|
first.table,
|
|
33
35
|
diffs.flatMap((d) => d.changes),
|
|
34
|
-
last.timestamp
|
|
36
|
+
last.timestamp,
|
|
37
|
+
mergedId
|
|
35
38
|
);
|
|
36
39
|
};
|
|
37
40
|
|
|
@@ -28,16 +28,29 @@ type RowChange = {
|
|
|
28
28
|
interface TableDiff {
|
|
29
29
|
/** Ordered row changes — insert/update/delete, earliest first. */
|
|
30
30
|
readonly changes: ReadonlyArray<RowChange>;
|
|
31
|
+
/**
|
|
32
|
+
* Optional stable identity for this diff, distinct from `timestamp`
|
|
33
|
+
* (multiple diffs can legitimately share a millisecond, so `timestamp`
|
|
34
|
+
* alone is not a unique diff identity). Used by `deriveInsertId` in
|
|
35
|
+
* `apply-diff.ts` to derive deterministic row ids for id-less inserts:
|
|
36
|
+
* replaying the SAME diff (same `id`) must always mint the SAME id,
|
|
37
|
+
* while two DIFFERENT diffs emitted in the same millisecond must not
|
|
38
|
+
* alias onto the same one. `createTableDiff` auto-generates one when
|
|
39
|
+
* omitted; diffs built as plain object literals (bypassing the helper)
|
|
40
|
+
* simply fall back to `timestamp` for that derivation.
|
|
41
|
+
*/
|
|
42
|
+
readonly id?: string;
|
|
31
43
|
/** Logical table name (matches the schema table name). */
|
|
32
44
|
readonly table: string;
|
|
33
45
|
/** Monotonic server timestamp (ms since epoch) when this diff was emitted. */
|
|
34
46
|
readonly timestamp: number;
|
|
35
47
|
}
|
|
36
48
|
/**
|
|
37
|
-
* Create a {@link TableDiff} with a snapshot of the current time
|
|
49
|
+
* Create a {@link TableDiff} with a snapshot of the current time and a
|
|
50
|
+
* fresh stable `id` (unless one is explicitly provided).
|
|
38
51
|
* @experimental
|
|
39
52
|
*/
|
|
40
|
-
declare const createTableDiff: (table: string, changes: ReadonlyArray<RowChange>, timestamp?: number) => TableDiff;
|
|
53
|
+
declare const createTableDiff: (table: string, changes: ReadonlyArray<RowChange>, timestamp?: number, id?: string) => TableDiff;
|
|
41
54
|
/**
|
|
42
55
|
* Return `true` when the diff contains no row changes.
|
|
43
56
|
* @experimental
|
|
@@ -212,6 +225,37 @@ interface AppendOptions {
|
|
|
212
225
|
readonly parentSeqNum?: Seq;
|
|
213
226
|
/** Session identifier within the client. */
|
|
214
227
|
readonly sessionId?: string;
|
|
228
|
+
/**
|
|
229
|
+
* Override the entry's `timestamp` instead of stamping `Date.now()` at
|
|
230
|
+
* append time.
|
|
231
|
+
*
|
|
232
|
+
* Used by callers (e.g. {@link import("./event-source").EventSource | EventSource})
|
|
233
|
+
* that must commit the EXACT entry a reducer already observed — a
|
|
234
|
+
* second, independently-drawn `Date.now()` at append time could produce
|
|
235
|
+
* a persisted entry a timestamp-dependent reducer cannot reproduce on
|
|
236
|
+
* replay.
|
|
237
|
+
*/
|
|
238
|
+
readonly timestamp?: number;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Options for constructing an {@link EventLog}.
|
|
242
|
+
* @experimental
|
|
243
|
+
*/
|
|
244
|
+
interface EventLogOptions {
|
|
245
|
+
/**
|
|
246
|
+
* Cap the number of entries retained in memory (REPLICA-06). When an
|
|
247
|
+
* append would exceed the cap, the OLDEST entries are evicted (ring
|
|
248
|
+
* buffer) — a `getSince`/`getFrom` call for a watermark below the oldest
|
|
249
|
+
* retained `seq` then returns only what's left, silently missing
|
|
250
|
+
* anything evicted.
|
|
251
|
+
*
|
|
252
|
+
* `undefined` (the default) preserves the original unbounded behavior.
|
|
253
|
+
* Set this only when you have another durable source of truth for
|
|
254
|
+
* anything older than the cap (a snapshot, a server-side `EventLogDO`) —
|
|
255
|
+
* see {@link EventLog#truncateBelow} for the caller-driven equivalent
|
|
256
|
+
* tied to snapshot persistence.
|
|
257
|
+
*/
|
|
258
|
+
readonly maxEntries?: number;
|
|
215
259
|
}
|
|
216
260
|
/**
|
|
217
261
|
* An append-only, in-memory event log for local event sourcing.
|
|
@@ -226,6 +270,7 @@ interface AppendOptions {
|
|
|
226
270
|
*/
|
|
227
271
|
declare class EventLog {
|
|
228
272
|
#private;
|
|
273
|
+
constructor(options?: EventLogOptions);
|
|
229
274
|
/**
|
|
230
275
|
* Append a new entry to the log.
|
|
231
276
|
*
|
|
@@ -254,6 +299,10 @@ declare class EventLog {
|
|
|
254
299
|
* This is the restore counterpart of {@link EventLog#snapshot}.
|
|
255
300
|
* Restores `headSeq` from the snapshot so auto-parenting continues
|
|
256
301
|
* after restore.
|
|
302
|
+
*
|
|
303
|
+
* Runs `#enforceCap()` after restoring so a snapshot captured under a
|
|
304
|
+
* different (or no) `maxEntries` can never leave this log over its
|
|
305
|
+
* configured capacity.
|
|
257
306
|
*/
|
|
258
307
|
load(snapshot: EventLogSnapshot): void;
|
|
259
308
|
/**
|
|
@@ -289,6 +338,24 @@ declare class EventLog {
|
|
|
289
338
|
/** Remove all entries (primarily for testing). */
|
|
290
339
|
clear(): void;
|
|
291
340
|
/**
|
|
341
|
+
* Discard all entries with `seq < floorSeq` (REPLICA-06).
|
|
342
|
+
*
|
|
343
|
+
* `headSeq`/`nextSeq` are untouched (they're independent counters), so
|
|
344
|
+
* appends after a truncation continue the same sequence uninterrupted.
|
|
345
|
+
*
|
|
346
|
+
* **Caller-driven, not automatic**: only call this after the truncated
|
|
347
|
+
* range has already been durably captured elsewhere (a snapshot, a
|
|
348
|
+
* server-side `EventLogDO`) — truncating without such a floor makes any
|
|
349
|
+
* future `getSince`/`getFrom`/`EventSource.replayFromLog` call for a
|
|
350
|
+
* watermark below `floorSeq` silently miss the discarded entries. This is
|
|
351
|
+
* the hook the caller ties to snapshot persistence; the log itself has no
|
|
352
|
+
* concept of "already durably persisted".
|
|
353
|
+
*
|
|
354
|
+
* `floorSeq` must be a non-negative safe integer — `NaN` would make
|
|
355
|
+
* every comparison false and silently clear the entire log.
|
|
356
|
+
*/
|
|
357
|
+
truncateBelow(floorSeq: number): void;
|
|
358
|
+
/**
|
|
292
359
|
* Return an async generator that yields every entry starting from
|
|
293
360
|
* `fromSeq` (default `0` = all entries).
|
|
294
361
|
*
|
|
@@ -314,6 +381,18 @@ interface LocalMirrorOptions {
|
|
|
314
381
|
/** Platform-specific SQLite adapter. */
|
|
315
382
|
readonly db: SqliteAdapter;
|
|
316
383
|
/**
|
|
384
|
+
* Cap the mirror's internal {@link EventLog} to this many entries
|
|
385
|
+
* (REPLICA-06). Every applied diff is recorded in the log — with no cap,
|
|
386
|
+
* a long-running client accumulates one entry per diff forever.
|
|
387
|
+
*
|
|
388
|
+
* `undefined` (the default) preserves unbounded retention. Set this when
|
|
389
|
+
* catch-up replication only ever needs a bounded recent window; older
|
|
390
|
+
* entries are silently evicted (oldest-first) once the cap is exceeded.
|
|
391
|
+
* See {@link EventLog#truncateBelow} for caller-driven truncation tied to
|
|
392
|
+
* a snapshot instead.
|
|
393
|
+
*/
|
|
394
|
+
readonly maxEventLogEntries?: number;
|
|
395
|
+
/**
|
|
317
396
|
* Table schemas the mirror should manage.
|
|
318
397
|
*
|
|
319
398
|
* On first use the mirror creates any missing tables automatically
|
|
@@ -399,6 +478,13 @@ declare class LocalMirror {
|
|
|
399
478
|
*/
|
|
400
479
|
get db(): SqliteAdapter;
|
|
401
480
|
/**
|
|
481
|
+
* Monotonically increasing version counter, bumped on every operation
|
|
482
|
+
* that changes mirrored data (`applyDiff`, `clearData`). Use this — not
|
|
483
|
+
* `eventLog.size` — as a `useSyncExternalStore` snapshot so operations
|
|
484
|
+
* that don't append to the log still trigger a re-render.
|
|
485
|
+
*/
|
|
486
|
+
get version(): number;
|
|
487
|
+
/**
|
|
402
488
|
* Apply a server-side diff to the local SQLite mirror.
|
|
403
489
|
*
|
|
404
490
|
* The diff is applied in a transaction and recorded in the event log
|
|
@@ -420,6 +506,11 @@ declare class LocalMirror {
|
|
|
420
506
|
/**
|
|
421
507
|
* Delete every row from all known tables (preserves the event log
|
|
422
508
|
* and schema). Useful when re-syncing from scratch.
|
|
509
|
+
*
|
|
510
|
+
* Notifies `onChange` subscribers and bumps {@link LocalMirror.version}
|
|
511
|
+
* (REPLICA-09) even though nothing is appended to the event log — a
|
|
512
|
+
* consumer keyed only on `eventLog.size` would otherwise never learn the
|
|
513
|
+
* mirror was cleared and keep rendering deleted rows.
|
|
423
514
|
*/
|
|
424
515
|
clearData(): void;
|
|
425
516
|
/**
|
|
@@ -436,4 +527,4 @@ declare class LocalMirror {
|
|
|
436
527
|
*/
|
|
437
528
|
get mirroredTables(): ReadonlyArray<string>;
|
|
438
529
|
}
|
|
439
|
-
export { AppendOptions as A, ClientSeq as C, EventLogEntry as E, GlobalSeq as G, InputEvent as I, LocalMirror as L, MirrorTableDef as M, RowChange as R, Seq as S, TableDiff as T, EventLog as a,
|
|
530
|
+
export { AppendOptions as A, ClientSeq as C, EventLogEntry as E, GlobalSeq as G, InputEvent as I, LocalMirror as L, MirrorTableDef as M, RowChange as R, Seq as S, TableDiff as T, EventLog as a, EventLogOptions as b, EventLogSnapshot as c, LocalMirrorOptions as d, classifyChanges as e, createTableDiff as f, diffSize as g, isDiffEmpty as h, isClientSeq as i, isGlobalSeq as j, isInputEvent as k, mergeDiffs as m };
|