@zubra/sync 5.9.0 → 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/README.md CHANGED
@@ -12,4 +12,4 @@ Local-first state for Zubra components. `'use collection tasks'` at the top of a
12
12
  <task-item doc="">…</task-item>
13
13
  ```
14
14
 
15
- `<task-item doc="t42">` is the document `tasks/t42`. To share documents between devices, `connect()` from `@zubra/sync/connect` and serve the log with `createSyncServer()` from `@zubra/sync/server`; components don't change. see [docs/sync.md](https://github.com/journey-to-code-org/zubra/blob/main/docs/sync.md).
15
+ `<task-item doc="t42">` is the document `tasks/t42`. To share documents between devices, `connect()` from `@zubra/sync/connect` and serve the log with `createSyncServer({ authorize })` from `@zubra/sync/server` (without `authorize`, pages may read but not write); components don't change. see [docs/sync.md](https://github.com/journey-to-code-org/zubra/blob/main/docs/sync.md).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zubra/sync",
3
- "version": "5.9.0",
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)));
@@ -117,6 +132,7 @@ export function connect({ url = '/_zb/sync', interval = 2000, live = true, webrt
117
132
  });
118
133
 
119
134
  let timer = null;
135
+ let warned = false;
120
136
  const schedule = () => {
121
137
  clearTimeout(timer);
122
138
  timer = setTimeout(flush, 30);
@@ -131,7 +147,16 @@ export function connect({ url = '/_zb/sync', interval = 2000, live = true, webrt
131
147
  headers: { 'content-type': 'application/json' },
132
148
  body: JSON.stringify({ changes: sending }),
133
149
  });
134
- if (!response.ok) return;
150
+ if (!response.ok) {
151
+ // A refused write is kept (it may be allowed later), but say why once: a server with no
152
+ // `authorize` refuses every write from pages, and a silent outbox looks like a bug.
153
+ if (response.status === 403 && !warned) {
154
+ warned = true;
155
+ const why = await response.json().then((body) => body?.error, () => '');
156
+ console.warn(`@zubra/sync: the server refused this page's changes${why ? `: ${why}` : ''}`);
157
+ }
158
+ return;
159
+ }
135
160
  outbox.done(sending);
136
161
  } catch {
137
162
  // Offline: the outbox stays (in localStorage too), and goes with the next flush.
@@ -7,7 +7,8 @@ import { durableObjectLog } from './durable-log.js';
7
7
  export { MOUNT };
8
8
 
9
9
  /**
10
- * `owner` is the object: its `authorize(…)`, if it has one, decides as `createSyncServer`'s does.
10
+ * `owner` is the object: its `authorize(…)`, if it has one, decides as `createSyncServer`'s does;
11
+ * without one, pages may read and not write.
11
12
  * `heartbeat` is shorter than on Node (2 s, not 15): a Durable Object isn't told when a page's
12
13
  * connection is cut, only that a keep-alive couldn't be written, which on workerd is the third
13
14
  * after the cut. So a crashed page's presence lingers about three keep-alives (6 s). A page that
@@ -20,7 +21,8 @@ export function syncObject(ctx, owner, { mount = MOUNT, heartbeat = 2_000, devic
20
21
  heartbeat,
21
22
  device,
22
23
  origins,
23
- authorize: (asked) => (typeof owner.authorize === 'function' ? owner.authorize(asked) : true),
24
+ // Without an authorize of its own, the object takes reads only, as createSyncServer does.
25
+ authorize: typeof owner.authorize === 'function' ? (asked) => owner.authorize(asked) : undefined,
24
26
  });
25
27
  return {
26
28
  async fetch(request) {
package/src/durable.d.ts CHANGED
@@ -24,7 +24,7 @@ export class SyncObject<Env = unknown> {
24
24
  protected ctx: any;
25
25
  protected env: Env;
26
26
  /** Refuse a write or hide a change; as `createSyncServer`'s. Allows everything if not defined. */
27
- authorize?(asked: { request: Request; collection: string; key: string; write: boolean; kind: 'change' | 'presence' | 'read' }): boolean | Promise<boolean>;
27
+ authorize?(asked: { request: Request; collection: string; key: string; field?: string; value?: unknown; write: boolean; kind: 'change' | 'presence' | 'read' }): boolean | Promise<boolean>;
28
28
  fetch(request: Request): Promise<Response>;
29
29
  documents(): Promise<Documents>;
30
30
  write(collection: string, key: string, fields: Record<string, unknown>): Promise<Change[]>;
package/src/server.d.ts CHANGED
@@ -40,6 +40,9 @@ export function parseScope(text: string | null | undefined): Scope | null;
40
40
  /** Whether a document is in a scope (null is everything). */
41
41
  export function inScope(scope: Scope | null, collection: string, key: string): boolean;
42
42
 
43
+ /** For `createSyncServer({ authorize: allowAnyone })`: every page may read and write everything. */
44
+ export const allowAnyone: () => true;
45
+
43
46
  export function createSyncServer(options?: {
44
47
  log?: Log;
45
48
  /** Default `/_zb/sync`. */
@@ -47,9 +50,18 @@ export function createSyncServer(options?: {
47
50
  /**
48
51
  * Refuse a write, or hide a change from a reader, by returning false. `kind` is what's asked:
49
52
  * 'change' (a push), 'presence' (joining or signaling in a document's room; nothing is stored)
50
- * or 'read' (a change about to be sent to this request). `write` is true for the first two.
53
+ * or 'read' (a change about to be sent to this request). `write` is true for the first two. A
54
+ * 'change' has the `field` and `value` it sets.
55
+ *
56
+ * Without it, pages may read but every write from a page is refused (403, saying why). Pass
57
+ * `allowAnyone` for a page everyone may edit. The server's own `write()` is never asked.
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.
51
62
  */
52
- authorize?: (from: { request: Request; collection: string; key: string; write: boolean; kind: 'change' | 'presence' | 'read' }) => boolean | Promise<boolean>;
63
+ maxSkew?: number;
64
+ authorize?: (from: { request: Request; collection: string; key: string; field?: string; value?: unknown; write: boolean; kind: 'change' | 'presence' | 'read' }) => boolean | Promise<boolean>;
53
65
  /** Other origins allowed to push. */
54
66
  /**
55
67
  * The scope a request may see and write (tenancy: a business's keys under its own prefix), or
package/src/server.js CHANGED
@@ -10,7 +10,13 @@
10
10
  // `authorize({ request, collection, key, write, kind })` may refuse a change or hide one (return
11
11
  // false). `kind` says what's asked: 'read' (a change being sent to this request), 'change' (one
12
12
  // it pushed) or 'presence' (joining a document's room, or signaling in it: nothing is stored).
13
- // `write` is true for the last two.
13
+ // `write` is true for the last two. A 'change' also has the `field` and `value` it sets, so an
14
+ // app can keep fields (or whole collections) for the server alone.
15
+ //
16
+ // Without `authorize`, pages may read but not write: every push, presence and signal is refused
17
+ // with a 403 saying so. Say who may change what, or, for a page everyone may edit (a shared
18
+ // board on a trusted network), `createSyncServer({ authorize: allowAnyone })`. The server's own
19
+ // `write()` is never asked.
14
20
  //
15
21
  // The server can take part too. `await sync.documents()` is every document as the devices see
16
22
  // it (last writer wins, per field), kept current; `sync.write(collection, key, fields)` writes as
@@ -129,9 +135,19 @@ const clock = {
129
135
  },
130
136
  };
131
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
+
132
145
  const valid = (change) =>
133
146
  change && typeof change.collection === 'string' && typeof change.key === 'string' && typeof change.field === 'string' &&
134
- 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;
135
151
 
136
152
  /**
137
153
  * How documents are read, wherever they're kept: `find(collection, key)` → fields or undefined,
@@ -152,7 +168,8 @@ export function documentsView({ find, list, names }) {
152
168
  all: (collection, prefix = '') =>
153
169
  list(collection)
154
170
  .filter(({ key }) => key.startsWith(prefix))
155
- .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 })),
156
173
  /** The collections that have documents, in the order they were first written to. */
157
174
  collections: () => names(),
158
175
  };
@@ -188,8 +205,19 @@ function documentsOf() {
188
205
  };
189
206
  }
190
207
 
191
- export function createSyncServer({ log = memoryLog(), mount = MOUNT, authorize = () => true, scope: serverScope = () => null, origins = [], heartbeat = 15_000, device = 'server' } = {}) {
208
+ /** For `createSyncServer({ authorize: allowAnyone })`: every page may read and write everything. */
209
+ export const allowAnyone = () => true;
210
+
211
+ /** The default: pages may read; writes from pages need an `authorize` that says who may make them. */
212
+ const readsOnly = ({ write }) => !write;
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".';
214
+
215
+ export function createSyncServer({ log = memoryLog(), mount = MOUNT, authorize: given, scope: serverScope = () => null, origins = [], heartbeat = 15_000, device = 'server', maxSkew = 60_000 } = {}) {
192
216
  if (!/^[\w-]{1,40}$/.test(device)) throw new Error('createSyncServer: `device` names the server in its clocks: letters, digits, - and _');
217
+ if (given !== undefined && typeof given !== 'function') throw new TypeError('createSyncServer: `authorize` is a function ({ request, collection, key, field, value, write, kind }) => boolean');
218
+ const authorize = given ?? readsOnly;
219
+ /** What a refused write says: why, when no authorize was given; the app's own refusal otherwise. */
220
+ const refused = (fallback) => json(403, { error: given ? fallback : NO_AUTHORIZE });
193
221
  const base = mount.replace(/\/$/, '');
194
222
  // Appends, one after another, so the log's order is the order they were made in.
195
223
  let appending = Promise.resolve();
@@ -198,13 +226,23 @@ export function createSyncServer({ log = memoryLog(), mount = MOUNT, authorize =
198
226
  let lastTime = 0;
199
227
  let counter = 0;
200
228
  const tick = () => {
201
- const now = Math.max(Date.now(), lastTime);
229
+ let now = Math.max(Date.now(), lastTime);
202
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
+ }
203
236
  lastTime = now;
204
237
  return clock.format(now, counter, device);
205
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;
206
241
  const see = (change) => {
207
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;
208
246
  if (time > lastTime) {
209
247
  lastTime = time;
210
248
  counter = seen;
@@ -315,10 +353,11 @@ export function createSyncServer({ log = memoryLog(), mount = MOUNT, authorize =
315
353
  return json(400, { error: 'Expected JSON' });
316
354
  }
317
355
  const changes = Array.isArray(body?.changes) ? body.changes : null;
318
- 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" });
319
358
  for (const change of changes) {
320
359
  if (!(await mayWrite(request, change.collection, change.key))) return json(403, { error: 'Not yours to change' });
321
- if (!(await authorize({ request, collection: change.collection, key: change.key, write: true, kind: 'change' }))) return json(403, { error: 'Not yours to change' });
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');
322
361
  }
323
362
  const appended = changes.map(({ collection, key, field, value, clock }) => ({ collection, key, field, value, clock }));
324
363
  const cursor = await append(appended);
@@ -340,7 +379,8 @@ export function createSyncServer({ log = memoryLog(), mount = MOUNT, authorize =
340
379
  if (!room || !okPeer || !okState || JSON.stringify(message.state).length > 4096) {
341
380
  return json(400, { error: 'Expected { room: "<collection>/<key>", peer, state: {…} | null }' });
342
381
  }
343
- if (!(await mayWrite(request, room.collection, room.key)) || !(await authorize({ request, ...room, write: true, kind: 'presence' }))) return json(403, { error: 'Not yours to join' });
382
+ if (!(await mayWrite(request, room.collection, room.key))) return json(403, { error: 'Not yours to join' });
383
+ if (!(await authorize({ request, ...room, write: true, kind: 'presence' }))) return refused('Not yours to join');
344
384
  const clean = { room: message.room, peer: message.peer, state: message.state };
345
385
  for (const listener of listeners) if (listener.peer === clean.peer) {
346
386
  if (clean.state === null) listener.rooms.delete(clean.room);
@@ -365,7 +405,8 @@ export function createSyncServer({ log = memoryLog(), mount = MOUNT, authorize =
365
405
  if (!room || !peerOk(message.from) || !peerOk(message.to) || !dataOk) {
366
406
  return json(400, { error: 'Expected { room, from, to, data: {…} }' });
367
407
  }
368
- if (!(await mayWrite(request, room.collection, room.key)) || !(await authorize({ request, ...room, write: true, kind: 'presence' }))) return json(403, { error: 'Not yours to join' });
408
+ if (!(await mayWrite(request, room.collection, room.key))) return json(403, { error: 'Not yours to join' });
409
+ if (!(await authorize({ request, ...room, write: true, kind: 'presence' }))) return refused('Not yours to join');
369
410
  const clean = { room: message.room, from: message.from, to: message.to, data: message.data };
370
411
  for (const listener of listeners) if (listener.peer === clean.to) listener.send('signal', clean);
371
412
  return json(200, { ok: true });
@@ -471,7 +512,7 @@ export function createSyncServer({ log = memoryLog(), mount = MOUNT, authorize =
471
512
  if (typeof collection !== 'string' || typeof key !== 'string' || !fields || typeof fields !== 'object' || Array.isArray(fields)) {
472
513
  throw new TypeError('sync.write(collection, key, { field: value, … })');
473
514
  }
474
- 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`);
475
516
  // A server that hasn't read its log yet (it just started, or a Durable Object was evicted)
476
517
  // hasn't seen the clocks in it: read it first, so this write comes after all of them. Once
477
518
  // the documents exist this doesn't wait, so a check and a write with no await between stay