@zubra/sync 5.7.0 → 5.9.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zubra/sync",
3
- "version": "5.7.0",
3
+ "version": "5.9.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.7.0"
35
+ "@zubra/runtime": "^5.9.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
- let pending = changes;
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
- outbox.add({ collection, key, field, value, clock: stamp });
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.values()) {
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
- if (Object.keys(fields).length) await receive(collection, key, { ...fields, [CLOCKS]: known });
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 = flush().then(pull);
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
- /** @internal A change from elsewhere: store it, and show it in this tab and the others. */
43
- export async function receive(collection, key, fields) {
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
- deliver(collection, key, fields, null);
46
- channel?.postMessage({ collection, key, fields });
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
- /** Every document in a collection, each with its key. */
27
- all(collection: string): Array<{ key: string } & Record<string, unknown>>;
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
- /** Every document in a collection: `[{ key, ...fields }]`. */
149
- all: (collection) => list(collection).map(({ key, fields }) => ({ key, ...structuredClone(fields) })),
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
  };