@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 +1 -1
- package/src/connect.js +19 -4
- package/src/server.d.ts +5 -0
- package/src/server.js +28 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zubra/sync",
|
|
3
|
-
"version": "5.9.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|