@zubra/sync 5.9.1 → 5.9.2

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.9.1",
3
+ "version": "5.9.2",
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",
package/src/connect.js CHANGED
@@ -19,16 +19,31 @@ import { mesh } from './webrtc.js';
19
19
  const CLOCKS = '$clocks';
20
20
  const pad = (number, width) => String(number).padStart(width, '0');
21
21
 
22
- function clock(client) {
22
+ /** How far ahead of this device a clock it hears can be before it stops following it. */
23
+ const MAX_SKEW = 60_000;
24
+
25
+ /** A device's hybrid logical clock (exported for tests). */
26
+ export function deviceClock(client) {
23
27
  let last = 0;
24
28
  let counter = 0;
25
29
  const tick = (seen = 0) => {
26
- const now = Math.max(Date.now(), last, seen);
30
+ let now = Math.max(Date.now(), last, seen);
27
31
  counter = now === last ? counter + 1 : 0;
32
+ // Five digits of counter; past them, the next millisecond (a clock must compare as a string).
33
+ if (counter > 99_999) {
34
+ now += 1;
35
+ counter = 0;
36
+ }
28
37
  last = now;
29
38
  return `${pad(now, 15)}-${pad(counter, 5)}-${client}`;
30
39
  };
31
- return { tick, see: (stamp) => tick(Number(stamp.slice(0, 15))) };
40
+ // A stamp far in the future (a device with a wrong clock, or one sent to poison the log) isn't
41
+ // followed: this device's writes keep its own time, and the server refuses such stamps anyway.
42
+ const see = (stamp) => {
43
+ const time = Number(stamp.slice(0, 15));
44
+ return tick(time > Date.now() + MAX_SKEW ? 0 : time);
45
+ };
46
+ return { tick, see };
32
47
  }
33
48
 
34
49
  /**
@@ -86,7 +101,7 @@ export function connect({ url = '/_zb/sync', interval = 2000, live = true, webrt
86
101
  keep('zubra-sync:client', id);
87
102
  return id;
88
103
  });
89
- const clocks = clock(client);
104
+ const clocks = deviceClock(client);
90
105
  let cursor = remember(cursorName, () => '0');
91
106
  const outbox = outboxOf(JSON.parse(remember('zubra-sync:outbox', () => '[]')), (pending) =>
92
107
  keep('zubra-sync:outbox', JSON.stringify(pending)));
package/src/server.d.ts CHANGED
@@ -56,6 +56,11 @@ export function createSyncServer(options?: {
56
56
  * Without it, pages may read but every write from a page is refused (403, saying why). Pass
57
57
  * `allowAnyone` for a page everyone may edit. The server's own `write()` is never asked.
58
58
  */
59
+ /**
60
+ * How far ahead of the server's clock a pushed change's clock may be, in ms (default 60 000).
61
+ * Further ahead is refused (400): a clock far in the future would win every merge for ever.
62
+ */
63
+ maxSkew?: number;
59
64
  authorize?: (from: { request: Request; collection: string; key: string; field?: string; value?: unknown; write: boolean; kind: 'change' | 'presence' | 'read' }) => boolean | Promise<boolean>;
60
65
  /** Other origins allowed to push. */
61
66
  /**
package/src/server.js CHANGED
@@ -135,9 +135,19 @@ const clock = {
135
135
  },
136
136
  };
137
137
 
138
+ /**
139
+ * Field names a change can't have: `$clocks` is where a document keeps its fields' clocks, and
140
+ * `key` is the document's own key wherever documents are listed (`{ key, ...fields }`), so a
141
+ * field by that name would pass one document off as another.
142
+ */
143
+ const RESERVED = new Set(['$clocks', 'key']);
144
+
138
145
  const valid = (change) =>
139
146
  change && typeof change.collection === 'string' && typeof change.key === 'string' && typeof change.field === 'string' &&
140
- clock.parse(change.clock) !== null && change.field !== '$clocks';
147
+ clock.parse(change.clock) !== null && !RESERVED.has(change.field);
148
+
149
+ /** The largest counter a clock can carry; past it, the clock moves on a millisecond. */
150
+ const MAX_COUNTER = 99_999;
141
151
 
142
152
  /**
143
153
  * How documents are read, wherever they're kept: `find(collection, key)` → fields or undefined,
@@ -158,7 +168,8 @@ export function documentsView({ find, list, names }) {
158
168
  all: (collection, prefix = '') =>
159
169
  list(collection)
160
170
  .filter(({ key }) => key.startsWith(prefix))
161
- .map(({ key, fields }) => ({ key, ...structuredClone(fields) })),
171
+ // The key last: nothing in the fields can stand in for it.
172
+ .map(({ key, fields }) => ({ ...structuredClone(fields), key })),
162
173
  /** The collections that have documents, in the order they were first written to. */
163
174
  collections: () => names(),
164
175
  };
@@ -201,7 +212,7 @@ export const allowAnyone = () => true;
201
212
  const readsOnly = ({ write }) => !write;
202
213
  const NO_AUTHORIZE = 'This sync server takes no writes from pages until it is told who may make them: pass createSyncServer({ authorize }) (or authorize: allowAnyone, for a page anyone may edit). See docs/sync.md, "Who may write".';
203
214
 
204
- export function createSyncServer({ log = memoryLog(), mount = MOUNT, authorize: given, scope: serverScope = () => null, origins = [], heartbeat = 15_000, device = 'server' } = {}) {
215
+ export function createSyncServer({ log = memoryLog(), mount = MOUNT, authorize: given, scope: serverScope = () => null, origins = [], heartbeat = 15_000, device = 'server', maxSkew = 60_000 } = {}) {
205
216
  if (!/^[\w-]{1,40}$/.test(device)) throw new Error('createSyncServer: `device` names the server in its clocks: letters, digits, - and _');
206
217
  if (given !== undefined && typeof given !== 'function') throw new TypeError('createSyncServer: `authorize` is a function ({ request, collection, key, field, value, write, kind }) => boolean');
207
218
  const authorize = given ?? readsOnly;
@@ -215,13 +226,23 @@ export function createSyncServer({ log = memoryLog(), mount = MOUNT, authorize:
215
226
  let lastTime = 0;
216
227
  let counter = 0;
217
228
  const tick = () => {
218
- const now = Math.max(Date.now(), lastTime);
229
+ let now = Math.max(Date.now(), lastTime);
219
230
  counter = now === lastTime ? counter + 1 : 0;
231
+ // A counter can't outgrow its five digits (the clock would stop comparing as a string).
232
+ if (counter > MAX_COUNTER) {
233
+ now += 1;
234
+ counter = 0;
235
+ }
220
236
  lastTime = now;
221
237
  return clock.format(now, counter, device);
222
238
  };
239
+ /** Whether a clock is further ahead of this server's than `maxSkew`: a device can't push one. */
240
+ const ahead = (stamp) => clock.parse(stamp).time > Date.now() + maxSkew;
223
241
  const see = (change) => {
224
242
  const { time, counter: seen } = clock.parse(change.clock);
243
+ // One already in the log from before this check (or a log written elsewhere) doesn't drag
244
+ // this server's clock into the future with it.
245
+ if (time > Date.now() + maxSkew) return;
225
246
  if (time > lastTime) {
226
247
  lastTime = time;
227
248
  counter = seen;
@@ -332,7 +353,8 @@ export function createSyncServer({ log = memoryLog(), mount = MOUNT, authorize:
332
353
  return json(400, { error: 'Expected JSON' });
333
354
  }
334
355
  const changes = Array.isArray(body?.changes) ? body.changes : null;
335
- if (!changes || !changes.every(valid)) return json(400, { error: 'Expected { changes: [{ collection, key, field, value, clock }] }' });
356
+ if (!changes || !changes.every(valid)) return json(400, { error: 'Expected { changes: [{ collection, key, field, value, clock }] }; a field can\'t be named key or $clocks' });
357
+ if (changes.some((change) => ahead(change.clock))) return json(400, { error: "A change's clock is ahead of the server's by more than maxSkew; check the device's time" });
336
358
  for (const change of changes) {
337
359
  if (!(await mayWrite(request, change.collection, change.key))) return json(403, { error: 'Not yours to change' });
338
360
  if (!(await authorize({ request, collection: change.collection, key: change.key, field: change.field, value: change.value, write: true, kind: 'change' }))) return refused('Not yours to change');
@@ -490,7 +512,7 @@ export function createSyncServer({ log = memoryLog(), mount = MOUNT, authorize:
490
512
  if (typeof collection !== 'string' || typeof key !== 'string' || !fields || typeof fields !== 'object' || Array.isArray(fields)) {
491
513
  throw new TypeError('sync.write(collection, key, { field: value, … })');
492
514
  }
493
- if ('$clocks' in fields) throw new TypeError('sync.write: `$clocks` is not a field');
515
+ for (const name of RESERVED) if (name in fields) throw new TypeError(`sync.write: \`${name}\` can't be a field name`);
494
516
  // A server that hasn't read its log yet (it just started, or a Durable Object was evicted)
495
517
  // hasn't seen the clocks in it: read it first, so this write comes after all of them. Once
496
518
  // the documents exist this doesn't wait, so a check and a write with no await between stay