@lunora/replica 1.0.0-alpha.4 → 1.0.0-alpha.6
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/better-sqlite3.d.mts +14 -14
- package/dist/adapters/better-sqlite3.d.ts +14 -14
- package/dist/adapters/sqlite-wasm.d.mts +29 -19
- package/dist/adapters/sqlite-wasm.d.ts +29 -19
- package/dist/adapters/sqlite-wasm.mjs +12 -24
- package/dist/adapters/sqljs.d.mts +10 -10
- package/dist/adapters/sqljs.d.ts +10 -10
- package/dist/index.d.mts +634 -574
- package/dist/index.d.ts +634 -574
- 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-CyGOpUES.d.ts +530 -0
- package/dist/packem_shared/local-mirror.d-DTavX_y0.d.mts +530 -0
- package/dist/packem_shared/{types.d-BuAWjEY5.d.mts → types.d-CkMkSwLJ.d.mts} +10 -10
- package/dist/packem_shared/{types.d-BuAWjEY5.d.ts → types.d-CkMkSwLJ.d.ts} +10 -10
- package/dist/react.d.mts +59 -59
- package/dist/react.d.ts +59 -59
- 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
- package/dist/packem_shared/local-mirror.d-BUeOe5KC.d.mts +0 -439
- package/dist/packem_shared/local-mirror.d-Cd8tAg-W.d.ts +0 -439
|
@@ -3,8 +3,13 @@ class EventsSync {
|
|
|
3
3
|
/** The highest `seq + 1` that has been applied. Starts at `0`. */
|
|
4
4
|
#watermark = 0;
|
|
5
5
|
#timer;
|
|
6
|
-
/**
|
|
7
|
-
|
|
6
|
+
/**
|
|
7
|
+
* The in-flight poll cycle, or `undefined` when idle. A concurrent
|
|
8
|
+
* `sync()`/timer tick AWAITS this instead of no-op'ing (REPLICA-08) —
|
|
9
|
+
* previously a concurrent call returned `0` immediately without waiting
|
|
10
|
+
* for the in-progress cycle to actually finish.
|
|
11
|
+
*/
|
|
12
|
+
#inFlight;
|
|
8
13
|
// ── Constructor ─────────────────────────────────────────────────────
|
|
9
14
|
constructor(options) {
|
|
10
15
|
this.#options = options;
|
|
@@ -56,34 +61,61 @@ class EventsSync {
|
|
|
56
61
|
}
|
|
57
62
|
// ── Internal ────────────────────────────────────────────────────────
|
|
58
63
|
/**
|
|
59
|
-
*
|
|
64
|
+
* Entry point for a poll cycle. A cycle already in flight is AWAITED
|
|
65
|
+
* (not restarted, not no-op'd) so a `sync()` racing a timer tick — or two
|
|
66
|
+
* concurrent `sync()` calls — observes the real outcome of the one cycle
|
|
67
|
+
* that actually runs (REPLICA-08).
|
|
60
68
|
*/
|
|
61
69
|
async #poll() {
|
|
62
|
-
if (this.#
|
|
63
|
-
return
|
|
70
|
+
if (this.#inFlight) {
|
|
71
|
+
return this.#inFlight;
|
|
64
72
|
}
|
|
65
|
-
this.#
|
|
73
|
+
const promise = this.#pollOnce().finally(() => {
|
|
74
|
+
this.#inFlight = void 0;
|
|
75
|
+
});
|
|
76
|
+
this.#inFlight = promise;
|
|
77
|
+
return promise;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* One poll cycle: fetch → (apply → diff → mirror) per event.
|
|
81
|
+
*
|
|
82
|
+
* Each event is driven through the FULL pipeline — `applyEvents`,
|
|
83
|
+
* `getTableDiffs`, and `mirror.applyDiff` — atomically before the
|
|
84
|
+
* watermark advances past it (REPLICA-08). Advancing the watermark any
|
|
85
|
+
* earlier (e.g. right after `applyEvents`) would let a later throw from
|
|
86
|
+
* `getTableDiffs`/`mirror.applyDiff` skip mirror delivery for that event
|
|
87
|
+
* PERMANENTLY, since the next poll would never re-fetch it. Keeping the
|
|
88
|
+
* watermark pinned to the last event whose entire pipeline succeeded
|
|
89
|
+
* means the next poll re-fetches exactly the unapplied remainder — never
|
|
90
|
+
* re-applying a fully-succeeded event, never silently dropping one that
|
|
91
|
+
* partially failed.
|
|
92
|
+
*/
|
|
93
|
+
async #pollOnce() {
|
|
66
94
|
try {
|
|
67
95
|
const events = await this.#options.fetchEventsSince(this.#watermark);
|
|
68
96
|
if (events.length === 0) {
|
|
69
97
|
return 0;
|
|
70
98
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
99
|
+
let appliedCount = 0;
|
|
100
|
+
try {
|
|
101
|
+
for (const event of events) {
|
|
102
|
+
this.#options.applyEvents([event]);
|
|
103
|
+
const diffs = this.#options.getTableDiffs();
|
|
104
|
+
for (const diff of diffs) {
|
|
105
|
+
this.#options.mirror.applyDiff(diff);
|
|
106
|
+
}
|
|
107
|
+
this.#watermark = event.seq + 1;
|
|
108
|
+
appliedCount += 1;
|
|
109
|
+
}
|
|
110
|
+
} catch (error) {
|
|
111
|
+
const onError = this.#options.onError ?? console.error;
|
|
112
|
+
onError(error);
|
|
79
113
|
}
|
|
80
|
-
return
|
|
114
|
+
return appliedCount;
|
|
81
115
|
} catch (error) {
|
|
82
116
|
const onError = this.#options.onError ?? console.error;
|
|
83
117
|
onError(error);
|
|
84
118
|
return 0;
|
|
85
|
-
} finally {
|
|
86
|
-
this.#running = false;
|
|
87
119
|
}
|
|
88
120
|
}
|
|
89
121
|
}
|
|
@@ -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
|
|