@zubra/sync 5.7.0 → 5.8.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/package.json +2 -2
- package/src/connect.js +50 -5
- package/src/index.js +8 -4
- package/src/server.d.ts +5 -2
- package/src/server.js +8 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zubra/sync",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.8.0",
|
|
4
4
|
"description": "Local-first state for Zubra components: 'use collection' keeps a component's state in IndexedDB, across reloads, offline, and in step across tabs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"test:binary": "ZUBRA_SYNC_BACKEND=binary node --test test/browser.test.js"
|
|
33
33
|
},
|
|
34
34
|
"peerDependencies": {
|
|
35
|
-
"@zubra/runtime": "^5.
|
|
35
|
+
"@zubra/runtime": "^5.8.0"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
38
|
"@zubra/compiler": "file:../compiler",
|
package/src/connect.js
CHANGED
|
@@ -36,15 +36,22 @@ function clock(client) {
|
|
|
36
36
|
* server has it, exactly those changes leave (`done`): by identity, not by count, so a change
|
|
37
37
|
* added while an earlier push was out is never dropped by it. Two pushes can overlap (the debounce
|
|
38
38
|
* and the poll loop); this is why that's safe.
|
|
39
|
+
*
|
|
40
|
+
* It keeps one change per field: a newer one replaces what's waiting for the same field (this
|
|
41
|
+
* device's later write wins anyway), so hours of typing offline wait as one change per field,
|
|
42
|
+
* not one per keystroke, and still fit in localStorage.
|
|
39
43
|
*/
|
|
40
44
|
export function outboxOf(changes = [], persist = () => {}) {
|
|
41
|
-
|
|
45
|
+
const same = (a, b) => a.collection === b.collection && a.key === b.key && a.field === b.field;
|
|
46
|
+
let pending = [];
|
|
47
|
+
for (const change of changes) pending = [...pending.filter((other) => !same(other, change)), change];
|
|
42
48
|
return {
|
|
43
49
|
get size() {
|
|
44
50
|
return pending.length;
|
|
45
51
|
},
|
|
46
52
|
pending: () => pending.slice(),
|
|
47
53
|
add(change) {
|
|
54
|
+
pending = pending.filter((other) => !same(other, change));
|
|
48
55
|
pending.push(change);
|
|
49
56
|
persist(pending);
|
|
50
57
|
},
|
|
@@ -84,13 +91,25 @@ export function connect({ url = '/_zb/sync', interval = 2000, live = true, webrt
|
|
|
84
91
|
const outbox = outboxOf(JSON.parse(remember('zubra-sync:outbox', () => '[]')), (pending) =>
|
|
85
92
|
keep('zubra-sync:outbox', JSON.stringify(pending)));
|
|
86
93
|
|
|
94
|
+
// This device's stamps, kept here the moment a field is edited. Storing them beside the document
|
|
95
|
+
// takes an await, and a change from elsewhere merged in that time must still lose to them.
|
|
96
|
+
const mine = new Map(); // JSON [collection, key] → { field: clock }
|
|
97
|
+
const newest = (a, b) => (a && (!b || a > b) ? a : b);
|
|
98
|
+
const own = ({ collection, key, field, clock: stamp }) => {
|
|
99
|
+
const id = JSON.stringify([collection, key]);
|
|
100
|
+
if (!mine.has(id)) mine.set(id, {});
|
|
101
|
+
mine.get(id)[field] = newest(stamp, mine.get(id)[field]);
|
|
102
|
+
};
|
|
103
|
+
|
|
87
104
|
// Local writes: stamp each field, remember the stamp beside the document, queue it.
|
|
88
105
|
const off = onWrite(async (collection, key, fields) => {
|
|
89
106
|
const stamped = {};
|
|
90
107
|
for (const [field, value] of Object.entries(fields)) {
|
|
91
108
|
const stamp = clocks.tick();
|
|
92
109
|
stamped[field] = stamp;
|
|
93
|
-
|
|
110
|
+
const change = { collection, key, field, value, clock: stamp };
|
|
111
|
+
own(change);
|
|
112
|
+
outbox.add(change);
|
|
94
113
|
}
|
|
95
114
|
const doc = (await currentStore().get(collection, key)) ?? {};
|
|
96
115
|
await currentStore().set(collection, key, { [CLOCKS]: { ...doc[CLOCKS], ...stamped } });
|
|
@@ -145,17 +164,21 @@ export function connect({ url = '/_zb/sync', interval = 2000, live = true, webrt
|
|
|
145
164
|
if (!byDoc.has(id)) byDoc.set(id, []);
|
|
146
165
|
byDoc.get(id).push(change);
|
|
147
166
|
}
|
|
148
|
-
for (const changes of byDoc
|
|
167
|
+
for (const [id, changes] of byDoc) {
|
|
149
168
|
const { collection, key } = changes[0];
|
|
150
169
|
const doc = (await currentStore().get(collection, key)) ?? {};
|
|
170
|
+
// What's stored, and what this device has stamped since that may not be stored yet.
|
|
151
171
|
const known = { ...doc[CLOCKS] };
|
|
172
|
+
for (const [field, stamp] of Object.entries(mine.get(id) ?? {})) known[field] = newest(stamp, known[field]);
|
|
152
173
|
const fields = {};
|
|
153
174
|
for (const change of changes) {
|
|
154
175
|
if (known[change.field] && known[change.field] >= change.clock) continue;
|
|
155
176
|
known[change.field] = change.clock;
|
|
156
177
|
fields[change.field] = change.value;
|
|
157
178
|
}
|
|
158
|
-
|
|
179
|
+
// An edit made here while the change was being stored still wins on screen.
|
|
180
|
+
const stillNewer = (field) => field === CLOCKS || !(mine.get(id)?.[field] > known[field]);
|
|
181
|
+
if (Object.keys(fields).length) await receive(collection, key, { ...fields, [CLOCKS]: known }, stillNewer);
|
|
159
182
|
}
|
|
160
183
|
// Never move the cursor back (a live backlog can arrive after a newer push), unless the server
|
|
161
184
|
// says this device's cursor was past the end of its log: then it starts over from what it sent.
|
|
@@ -222,8 +245,30 @@ export function connect({ url = '/_zb/sync', interval = 2000, live = true, webrt
|
|
|
222
245
|
const loop = setInterval(() => flush().then(() => (open ? undefined : pull())), interval);
|
|
223
246
|
const online = () => flush().then(pull);
|
|
224
247
|
addEventListener('online', online);
|
|
248
|
+
// What's still waiting to be sent is newer than what's stored wherever the store says otherwise:
|
|
249
|
+
// a tab that closed with writes queued can leave the stored value behind the outbox.
|
|
250
|
+
async function replay() {
|
|
251
|
+
const byDoc = new Map();
|
|
252
|
+
for (const change of outbox.pending()) {
|
|
253
|
+
const id = JSON.stringify([change.collection, change.key]);
|
|
254
|
+
byDoc.set(id, [...(byDoc.get(id) ?? []), change]);
|
|
255
|
+
}
|
|
256
|
+
for (const changes of byDoc.values()) {
|
|
257
|
+
const { collection, key } = changes[0];
|
|
258
|
+
const doc = (await currentStore().get(collection, key)) ?? {};
|
|
259
|
+
const known = { ...doc[CLOCKS] };
|
|
260
|
+
const fields = {};
|
|
261
|
+
for (const change of changes) {
|
|
262
|
+
if (known[change.field] && known[change.field] >= change.clock) continue;
|
|
263
|
+
known[change.field] = change.clock;
|
|
264
|
+
fields[change.field] = change.value;
|
|
265
|
+
}
|
|
266
|
+
if (Object.keys(fields).length) await receive(collection, key, { ...fields, [CLOCKS]: known });
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
225
270
|
// Components wait for this first pull before applying what's stored (index.js: fresh first).
|
|
226
|
-
const first =
|
|
271
|
+
const first = (applying = applying.then(replay).catch(() => {})).then(flush).then(pull);
|
|
227
272
|
startingPull(first);
|
|
228
273
|
first.then(listen);
|
|
229
274
|
return {
|
package/src/index.js
CHANGED
|
@@ -39,11 +39,15 @@ const theStore = () => (store ??= typeof indexedDB === 'undefined' ? memoryStore
|
|
|
39
39
|
const writers = new Set();
|
|
40
40
|
/** @internal Called with `(collection, key, fields)` on each local save. */
|
|
41
41
|
export const onWrite = (fn) => (writers.add(fn), () => writers.delete(fn));
|
|
42
|
-
/**
|
|
43
|
-
|
|
42
|
+
/**
|
|
43
|
+
* @internal A change from elsewhere: store it, and show it in this tab and the others. `keep(field)`
|
|
44
|
+
* is asked again before showing it: a field edited here while it was being stored isn't shown.
|
|
45
|
+
*/
|
|
46
|
+
export async function receive(collection, key, fields, keep = () => true) {
|
|
44
47
|
await theStore().set(collection, key, fields);
|
|
45
|
-
|
|
46
|
-
|
|
48
|
+
const shown = Object.fromEntries(Object.entries(fields).filter(([field]) => keep(field)));
|
|
49
|
+
deliver(collection, key, shown, null);
|
|
50
|
+
channel?.postMessage({ collection, key, fields: shown });
|
|
47
51
|
}
|
|
48
52
|
/** @internal The store in use. */
|
|
49
53
|
export const currentStore = () => theStore();
|
package/src/server.d.ts
CHANGED
|
@@ -23,8 +23,11 @@ export function memoryLog(options?: { keep?: 'all' | 'latest' }): Log;
|
|
|
23
23
|
export interface Documents {
|
|
24
24
|
/** A document's fields (a copy), or undefined when nothing has been written to it. */
|
|
25
25
|
get(collection: string, key: string): Record<string, unknown> | undefined;
|
|
26
|
-
/**
|
|
27
|
-
|
|
26
|
+
/**
|
|
27
|
+
* Every document in a collection whose key starts with `prefix` (all of them by default), each
|
|
28
|
+
* with its key, in the order they were first written to.
|
|
29
|
+
*/
|
|
30
|
+
all(collection: string, prefix?: string): Array<{ key: string } & Record<string, unknown>>;
|
|
28
31
|
/** The collections that have documents, in the order they were first written to. */
|
|
29
32
|
collections(): string[];
|
|
30
33
|
}
|
package/src/server.js
CHANGED
|
@@ -145,8 +145,14 @@ export function documentsView({ find, list, names }) {
|
|
|
145
145
|
const fields = find(collection, key);
|
|
146
146
|
return fields && structuredClone(fields);
|
|
147
147
|
},
|
|
148
|
-
/**
|
|
149
|
-
|
|
148
|
+
/**
|
|
149
|
+
* Every document in a collection whose key starts with `prefix` (all of them by default):
|
|
150
|
+
* `[{ key, ...fields }]`, in the order they were first written to.
|
|
151
|
+
*/
|
|
152
|
+
all: (collection, prefix = '') =>
|
|
153
|
+
list(collection)
|
|
154
|
+
.filter(({ key }) => key.startsWith(prefix))
|
|
155
|
+
.map(({ key, fields }) => ({ key, ...structuredClone(fields) })),
|
|
150
156
|
/** The collections that have documents, in the order they were first written to. */
|
|
151
157
|
collections: () => names(),
|
|
152
158
|
};
|