@zubra/sync 5.5.0 → 5.6.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.5.0",
3
+ "version": "5.6.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",
@@ -21,14 +21,18 @@
21
21
  "./sqlite": {
22
22
  "types": "./src/sqlite.d.ts",
23
23
  "default": "./src/sqlite.js"
24
+ },
25
+ "./durable": {
26
+ "types": "./src/durable.d.ts",
27
+ "default": "./src/durable.js"
24
28
  }
25
29
  },
26
30
  "scripts": {
27
- "test": "node --test --test-force-exit \"test/*.test.js\"",
28
- "test:binary": "ZUBRA_SYNC_BACKEND=binary node --test --test-force-exit test/browser.test.js"
31
+ "test": "node --test \"test/*.test.js\"",
32
+ "test:binary": "ZUBRA_SYNC_BACKEND=binary node --test test/browser.test.js"
29
33
  },
30
34
  "peerDependencies": {
31
- "@zubra/runtime": "^5.5.0"
35
+ "@zubra/runtime": "^5.6.0"
32
36
  },
33
37
  "devDependencies": {
34
38
  "@zubra/compiler": "file:../compiler",
@@ -0,0 +1,38 @@
1
+ // What a SyncObject is, without Workers' modules: a sync server on the object's storage, its
2
+ // `authorize` asked of the object, and a snapshot of the documents for the Worker to render from.
3
+ // durable.js wraps this in a DurableObject; tests run it on Node against a stand-in storage.
4
+ import { createSyncServer, MOUNT } from './server.js';
5
+ import { durableObjectLog } from './durable-log.js';
6
+
7
+ export { MOUNT };
8
+
9
+ /**
10
+ * `owner` is the object: its `authorize(…)`, if it has one, decides as `createSyncServer`'s does.
11
+ * `heartbeat` is shorter than on Node (2 s, not 15): a Durable Object isn't told when a page's
12
+ * connection is cut, only that a keep-alive couldn't be written, which on workerd is the third
13
+ * after the cut. So a crashed page's presence lingers about three keep-alives (6 s). A page that
14
+ * closes normally says it's leaving and is gone at once.
15
+ */
16
+ export function syncObject(ctx, owner, { mount = MOUNT, heartbeat = 2_000, device = 'server', table = 'changes', origins = [] } = {}) {
17
+ const sync = createSyncServer({
18
+ log: durableObjectLog(ctx.storage, { table }),
19
+ mount,
20
+ heartbeat,
21
+ device,
22
+ origins,
23
+ authorize: (asked) => (typeof owner.authorize === 'function' ? owner.authorize(asked) : true),
24
+ });
25
+ return {
26
+ async fetch(request) {
27
+ return (await sync.fetch(request)) ?? new Response(JSON.stringify({ error: 'Not found' }), { status: 404, headers: { 'content-type': 'application/json' } });
28
+ },
29
+ documents: sync.documents,
30
+ write: sync.write,
31
+ /** `{ collection: [{ key, ...fields }] }` for the collections named, or every one: plain data, for RPC. */
32
+ async snapshot(collections = []) {
33
+ const docs = await sync.documents();
34
+ const names = collections.length ? collections : docs.collections();
35
+ return Object.fromEntries(names.map((name) => [name, docs.all(name)]));
36
+ },
37
+ };
38
+ }
@@ -0,0 +1,13 @@
1
+ import type { Log } from './server.js';
2
+
3
+ /** The parts of a Durable Object's `ctx.storage` the log uses. */
4
+ export interface DurableObjectStorageLike {
5
+ sql: { exec(query: string, ...bindings: unknown[]): { toArray(): any[]; one(): any } };
6
+ transactionSync<T>(fn: () => T): T;
7
+ }
8
+
9
+ /**
10
+ * A sync log in a Durable Object's SQLite: the table and cursors `sqliteLog` uses. `table` names
11
+ * the table (default `changes`), if the object keeps tables of its own.
12
+ */
13
+ export function durableObjectLog(storage: DurableObjectStorageLike, options?: { table?: string }): Log;
@@ -0,0 +1,44 @@
1
+ // A sync log in a Cloudflare Durable Object's own SQLite (`ctx.storage.sql`): the same table and
2
+ // the same cursors as `sqliteLog`, so a log can move between Node and a Durable Object. It runs
3
+ // anywhere a Durable Object's storage is passed in; nothing here imports Workers' modules.
4
+ //
5
+ // import { createSyncServer } from '@zubra/sync/server';
6
+ // import { durableObjectLog } from '@zubra/sync/durable';
7
+ // const sync = createSyncServer({ log: durableObjectLog(ctx.storage) });
8
+ //
9
+ // `table` names the table, if the object keeps tables of its own (default `changes`). Appends are
10
+ // one transaction (`transactionSync`): a batch is written whole or not at all.
11
+ const NAME = /^[A-Za-z_][\w]{0,62}$/;
12
+
13
+ export function durableObjectLog(storage, { table = 'changes' } = {}) {
14
+ if (!storage?.sql || typeof storage.transactionSync !== 'function') {
15
+ throw new TypeError("durableObjectLog(storage): pass a Durable Object's `ctx.storage` (SQLite-backed)");
16
+ }
17
+ if (!NAME.test(table)) throw new TypeError('durableObjectLog: `table` is a plain SQL name (letters, digits, _)');
18
+ const { sql } = storage;
19
+ sql.exec(`CREATE TABLE IF NOT EXISTS ${table} (
20
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
21
+ collection TEXT NOT NULL,
22
+ key TEXT NOT NULL,
23
+ field TEXT NOT NULL,
24
+ value TEXT,
25
+ clock TEXT NOT NULL
26
+ )`);
27
+ const last = () => Number(sql.exec(`SELECT COALESCE(MAX(seq), 0) AS seq FROM ${table}`).one().seq);
28
+ return {
29
+ async append(changes) {
30
+ return storage.transactionSync(() => {
31
+ for (const { collection, key, field, value, clock } of changes) {
32
+ sql.exec(`INSERT INTO ${table} (collection, key, field, value, clock) VALUES (?, ?, ?, ?, ?)`, collection, key, field, JSON.stringify(value ?? null), clock);
33
+ }
34
+ return last();
35
+ });
36
+ },
37
+ async after(cursor, limit = 1000) {
38
+ const from = Number(cursor) || 0;
39
+ const rows = sql.exec(`SELECT seq, collection, key, field, value, clock FROM ${table} WHERE seq > ? ORDER BY seq LIMIT ?`, from, limit).toArray();
40
+ const changes = rows.map(({ collection, key, field, value, clock }) => ({ collection, key, field, value: JSON.parse(value), clock }));
41
+ return { changes, cursor: rows.length ? Number(rows[rows.length - 1].seq) : from };
42
+ },
43
+ };
44
+ }
@@ -0,0 +1,47 @@
1
+ // The Worker's side of a SyncObject: forward sync requests to it, read its documents, write as
2
+ // the server. Plain JavaScript (the namespace is a binding the Worker is given), so it runs on
3
+ // Node in tests too.
4
+ import { MOUNT, documentsView } from './server.js';
5
+
6
+ /** A snapshot (`{ collection: [{ key, ...fields }] }`) read like `sync.documents()`. */
7
+ export function viewOf(snapshot) {
8
+ const split = ({ key, ...fields }) => ({ key, fields });
9
+ return documentsView({
10
+ find: (collection, key) => {
11
+ const doc = (snapshot[collection] ?? []).find((each) => each.key === key);
12
+ return doc && split(doc).fields;
13
+ },
14
+ list: (collection) => (snapshot[collection] ?? []).map(split),
15
+ names: () => Object.keys(snapshot),
16
+ });
17
+ }
18
+
19
+ /**
20
+ * `namespace` is the Durable Object binding (`env.SYNC`); `name` picks the object (one per app:
21
+ * the log's cursor is one order). The stub's own methods (`stub.placeOrder(…)`) are the object's,
22
+ * over RPC: put a read-then-write that mustn't interleave there, not in the Worker.
23
+ */
24
+ export function syncStub(namespace, { name = 'sync', mount = MOUNT } = {}) {
25
+ if (!namespace || typeof namespace.idFromName !== 'function') {
26
+ throw new TypeError('syncStub(env.SYNC): pass the Durable Object namespace binding');
27
+ }
28
+ const stub = namespace.get(namespace.idFromName(name));
29
+ const base = mount.replace(/\/$/, '');
30
+ return {
31
+ stub,
32
+ /** The object's answer to a sync request, or null when the request isn't one. */
33
+ async fetch(request) {
34
+ return new URL(request.url).pathname.startsWith(`${base}/`) ? stub.fetch(request) : null;
35
+ },
36
+ /**
37
+ * The documents as they are now, for rendering: a copy, not kept current, and reading it then
38
+ * writing from the Worker can interleave with other requests. Name the collections you need
39
+ * to send less.
40
+ */
41
+ async documents(...collections) {
42
+ return viewOf(await stub.snapshot(collections));
43
+ },
44
+ /** Write as the server, through the object: to its log and every live page. */
45
+ write: (collection, key, fields) => stub.write(collection, key, fields),
46
+ };
47
+ }
@@ -0,0 +1,47 @@
1
+ import type { Change, Documents } from './server.js';
2
+ export { durableObjectLog, type DurableObjectStorageLike } from './durable-log.js';
3
+
4
+ /** `{ collection: [{ key, ...fields }] }` */
5
+ export type Snapshot = Record<string, Array<{ key: string } & Record<string, unknown>>>;
6
+
7
+ export interface SyncObjectOptions {
8
+ /** Default `/_zb/sync`; the Worker's `syncStub` must use the same. */
9
+ mount?: string;
10
+ /** Keep-alive interval (ms, default 2000). A page whose connection is cut is noticed about three keep-alives later. */
11
+ heartbeat?: number;
12
+ /** The server's name in its clocks (default `server`). */
13
+ device?: string;
14
+ /** The log's table (default `changes`). */
15
+ table?: string;
16
+ /** Other origins allowed to push. */
17
+ origins?: string[];
18
+ }
19
+
20
+ /** Extend it with your Durable Object class. Methods you add are the stub's, over RPC. */
21
+ export class SyncObject<Env = unknown> {
22
+ static options?: SyncObjectOptions;
23
+ constructor(ctx: any, env: Env);
24
+ protected ctx: any;
25
+ protected env: Env;
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>;
28
+ fetch(request: Request): Promise<Response>;
29
+ documents(): Promise<Documents>;
30
+ write(collection: string, key: string, fields: Record<string, unknown>): Promise<Change[]>;
31
+ snapshot(collections?: string[]): Promise<Snapshot>;
32
+ }
33
+
34
+ export function viewOf(snapshot: Snapshot): Documents;
35
+
36
+ export function syncStub<Stub = any>(
37
+ namespace: { idFromName(name: string): unknown; get(id: unknown): Stub },
38
+ options?: { name?: string; mount?: string },
39
+ ): {
40
+ /** The object's stub: your SyncObject's own methods, over RPC. */
41
+ stub: Stub;
42
+ /** The object's answer to a sync request, or null when it isn't one. */
43
+ fetch(request: Request): Promise<Response | null>;
44
+ /** A copy of the documents (the collections named, or all), for rendering. Not kept current. */
45
+ documents(...collections: string[]): Promise<Documents>;
46
+ write(collection: string, key: string, fields: Record<string, unknown>): Promise<Change[]>;
47
+ };
package/src/durable.js ADDED
@@ -0,0 +1,55 @@
1
+ // @zubra/sync on Cloudflare Workers. A Durable Object holds the sync server (one per app), with
2
+ // the log in its own SQLite; the Worker forwards sync requests to it and reads and writes through
3
+ // it. Extend SyncObject, and put what must not interleave (check stock, then take it) in its
4
+ // methods: the object runs one call at a time.
5
+ //
6
+ // import { SyncObject, syncStub } from '@zubra/sync/durable';
7
+ //
8
+ // export class Market extends SyncObject {
9
+ // static options = { device: 'market' }; // createSyncServer's, and `table`
10
+ // authorize({ request, collection, key, kind }) { … } // as on Node; secrets are this.env's
11
+ // async take(key, qty) {
12
+ // const left = (await this.documents()).get('stock', key)?.qty ?? 0;
13
+ // if (left < qty) return { error: 'Sold out' };
14
+ // await this.write('stock', key, { qty: left - qty });
15
+ // return { ok: true };
16
+ // }
17
+ // }
18
+ //
19
+ // export default {
20
+ // async fetch(request, env) {
21
+ // const sync = syncStub(env.MARKET);
22
+ // return (await sync.fetch(request)) ?? render(request, await sync.documents('stock'));
23
+ // },
24
+ // };
25
+ // // wrangler.jsonc: durable_objects.bindings [{ name: 'MARKET', class_name: 'Market' }],
26
+ // // migrations [{ tag: 'v1', new_sqlite_classes: ['Market'] }]
27
+ import { DurableObject } from 'cloudflare:workers';
28
+ import { syncObject } from './durable-core.js';
29
+
30
+ export { durableObjectLog } from './durable-log.js';
31
+ export { syncStub, viewOf } from './durable-stub.js';
32
+
33
+ export class SyncObject extends DurableObject {
34
+ #sync;
35
+ constructor(ctx, env) {
36
+ super(ctx, env);
37
+ this.#sync = syncObject(ctx, this, this.constructor.options);
38
+ }
39
+ /** Sync's requests (`/_zb/sync/*`), forwarded by the Worker's `syncStub(…).fetch`. */
40
+ fetch(request) {
41
+ return this.#sync.fetch(request);
42
+ }
43
+ /** Every document, kept current (in the object's own methods; the Worker uses the stub's). */
44
+ documents() {
45
+ return this.#sync.documents();
46
+ }
47
+ /** Write as the server: to the log and every live page. */
48
+ write(collection, key, fields) {
49
+ return this.#sync.write(collection, key, fields);
50
+ }
51
+ /** Plain data for the stub's `documents()`. */
52
+ snapshot(collections) {
53
+ return this.#sync.snapshot(collections);
54
+ }
55
+ }
package/src/index.js CHANGED
@@ -52,6 +52,8 @@ export const currentStore = () => theStore();
52
52
  const bound = new Map();
53
53
  const docId = (collection, key) => `${collection}\u0000${key}`;
54
54
  const channel = typeof BroadcastChannel === 'undefined' ? null : new BroadcastChannel('zubra-sync');
55
+ // Outside a browser (Node, a test), an open channel would keep the process from exiting.
56
+ channel?.unref?.();
55
57
  channel?.addEventListener('message', ({ data }) =>
56
58
  data.kind === 'presence' ? receivePresence(data) : deliver(data.collection, data.key, data.fields, null));
57
59
 
package/src/server.d.ts CHANGED
@@ -14,18 +14,43 @@ export interface Log {
14
14
 
15
15
  export function memoryLog(): Log;
16
16
 
17
+ export interface Documents {
18
+ /** A document's fields (a copy), or undefined when nothing has been written to it. */
19
+ get(collection: string, key: string): Record<string, unknown> | undefined;
20
+ /** Every document in a collection, each with its key. */
21
+ all(collection: string): Array<{ key: string } & Record<string, unknown>>;
22
+ /** The collections that have documents, in the order they were first written to. */
23
+ collections(): string[];
24
+ }
25
+
17
26
  export function createSyncServer(options?: {
18
27
  log?: Log;
19
28
  /** Default `/_zb/sync`. */
20
29
  mount?: string;
21
- /** Refuse a write, or hide a change from a reader, by returning false. */
22
- authorize?: (from: { request: Request; collection: string; key: string; write: boolean }) => boolean | Promise<boolean>;
30
+ /**
31
+ * Refuse a write, or hide a change from a reader, by returning false. `kind` is what's asked:
32
+ * 'change' (a push), 'presence' (joining or signaling in a document's room; nothing is stored)
33
+ * or 'read' (a change about to be sent to this request). `write` is true for the first two.
34
+ */
35
+ authorize?: (from: { request: Request; collection: string; key: string; write: boolean; kind: 'change' | 'presence' | 'read' }) => boolean | Promise<boolean>;
23
36
  /** Other origins allowed to push. */
24
37
  origins?: string[];
25
38
  /** How often the live stream sends a keep-alive comment (ms, default 15000). */
26
39
  heartbeat?: number;
40
+ /** The server's name in the clocks of its own writes (default `server`). */
41
+ device?: string;
27
42
  }): {
28
43
  fetch(request: Request): Promise<Response | null>;
44
+ /**
45
+ * Every document as the devices merge it (last writer wins, per field), read from the log the
46
+ * first time and kept current after. Its reads are synchronous.
47
+ */
48
+ documents(): Promise<Documents>;
49
+ /**
50
+ * Write fields to a document as the server, with a clock later than any it has seen: to the
51
+ * documents view at once, then to the log and every live page. Resolves to the changes.
52
+ */
53
+ write(collection: string, key: string, fields: Record<string, unknown>): Promise<Change[]>;
29
54
  /** Node's http: answers sync requests (streaming the live ones) and resolves true, else false. */
30
55
  handle(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse): Promise<boolean>;
31
56
  };
package/src/server.js CHANGED
@@ -7,14 +7,25 @@
7
7
  // const sync = createSyncServer();
8
8
  // const response = await sync.fetch(request); // a Response, or null if not ours
9
9
  //
10
- // `authorize({ request, collection, key, write })` may refuse a change or hide one (return false).
10
+ // `authorize({ request, collection, key, write, kind })` may refuse a change or hide one (return
11
+ // false). `kind` says what's asked: 'read' (a change being sent to this request), 'change' (one
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.
14
+ //
15
+ // The server can take part too. `await sync.documents()` is every document as the devices see
16
+ // it (last writer wins, per field), kept current; `sync.write(collection, key, fields)` writes as
17
+ // the server, to the log and to every live page:
18
+ //
19
+ // const docs = await sync.documents();
20
+ // const left = docs.get('stock', 'eggs')?.qty ?? 0; // read and write with no await between,
21
+ // if (left > 0) sync.write('stock', 'eggs', { qty: left - 1 }); // and no request interleaves
11
22
  //
12
23
  // Live: `GET <mount>/live?since=<cursor>&peer=<id>` is a server-sent event stream: `changes` as
13
24
  // they're pushed, and `presence` from the other viewers (POST <mount>/presence). Subscribers are
14
25
  // this process's; several server processes need a shared log and a pub/sub between them.
15
26
  //
16
27
  // On Node, `handle(req, res)` answers sync requests (streaming the live ones) and returns true.
17
- const MOUNT = '/_zb/sync';
28
+ export const MOUNT = '/_zb/sync';
18
29
 
19
30
  /** A log in memory: `append(changes)` and `after(cursor)`. */
20
31
  export function memoryLog() {
@@ -33,12 +44,130 @@ export function memoryLog() {
33
44
 
34
45
  const json = (status, value) => new Response(JSON.stringify(value), { status, headers: { 'content-type': 'application/json' } });
35
46
 
47
+ // A clock is `<15-digit ms>-<5-digit counter>-<device>`, so clocks compare as strings.
48
+ const CLOCK = /^(\d{15})-(\d{5})-[\w-]+$/;
49
+ const clock = {
50
+ format: (time, counter, device) => `${String(time).padStart(15, '0')}-${String(counter).padStart(5, '0')}-${device}`,
51
+ /** `{ time, counter }`, or null when it isn't a clock. */
52
+ parse(text) {
53
+ const match = typeof text === 'string' && CLOCK.exec(text);
54
+ return match ? { time: Number(match[1]), counter: Number(match[2]) } : null;
55
+ },
56
+ };
57
+
36
58
  const valid = (change) =>
37
59
  change && typeof change.collection === 'string' && typeof change.key === 'string' && typeof change.field === 'string' &&
38
- typeof change.clock === 'string' && /^\d{15}-\d{5}-[\w-]+$/.test(change.clock) && change.field !== '$clocks';
60
+ clock.parse(change.clock) !== null && change.field !== '$clocks';
61
+
62
+ /**
63
+ * How documents are read, wherever they're kept: `find(collection, key)` → fields or undefined,
64
+ * `list(collection)` → `[{ key, fields }]`, `names()` → the collections. What a reader gets is a
65
+ * copy, so changing it changes nothing.
66
+ */
67
+ export function documentsView({ find, list, names }) {
68
+ return {
69
+ /** A document's fields (a copy), or undefined when nothing has been written to it. */
70
+ get(collection, key) {
71
+ const fields = find(collection, key);
72
+ return fields && structuredClone(fields);
73
+ },
74
+ /** Every document in a collection: `[{ key, ...fields }]`. */
75
+ all: (collection) => list(collection).map(({ key, fields }) => ({ key, ...structuredClone(fields) })),
76
+ /** The collections that have documents, in the order they were first written to. */
77
+ collections: () => names(),
78
+ };
79
+ }
80
+
81
+ /** The documents a log describes, merged by each field's latest clock, as every device merges them. */
82
+ function documentsOf() {
83
+ const docs = new Map();
84
+ const id = (collection, key) => `${collection}\u0000${key}`;
85
+ return {
86
+ /** Applies a change if its clock is the field's latest; returns what undoes it, if it did. */
87
+ apply(change) {
88
+ let doc = docs.get(id(change.collection, change.key));
89
+ if (!doc) docs.set(id(change.collection, change.key), (doc = { collection: change.collection, key: change.key, fields: {}, clocks: {} }));
90
+ const known = doc.clocks[change.field];
91
+ if (known && known >= change.clock) return null;
92
+ const had = change.field in doc.fields;
93
+ const before = doc.fields[change.field];
94
+ doc.clocks[change.field] = change.clock;
95
+ doc.fields[change.field] = change.value;
96
+ return () => {
97
+ if (doc.clocks[change.field] !== change.clock) return; // something newer came since: it stands
98
+ if (had) [doc.clocks[change.field], doc.fields[change.field]] = [known, before];
99
+ else delete doc.clocks[change.field], delete doc.fields[change.field];
100
+ if (!Object.keys(doc.fields).length) docs.delete(id(change.collection, change.key));
101
+ };
102
+ },
103
+ view: documentsView({
104
+ find: (collection, key) => docs.get(id(collection, key))?.fields,
105
+ list: (collection) => [...docs.values()].filter((doc) => doc.collection === collection),
106
+ names: () => [...new Set([...docs.values()].map((doc) => doc.collection))],
107
+ }),
108
+ };
109
+ }
39
110
 
40
- export function createSyncServer({ log = memoryLog(), mount = MOUNT, authorize = () => true, origins = [], heartbeat = 15_000 } = {}) {
111
+ export function createSyncServer({ log = memoryLog(), mount = MOUNT, authorize = () => true, origins = [], heartbeat = 15_000, device = 'server' } = {}) {
112
+ if (!/^[\w-]{1,40}$/.test(device)) throw new Error('createSyncServer: `device` names the server in its clocks: letters, digits, - and _');
41
113
  const base = mount.replace(/\/$/, '');
114
+ // Appends, one after another, so the log's order is the order they were made in.
115
+ let appending = Promise.resolve();
116
+ // The server's clock: later than any clock it has seen, so its writes win over what came
117
+ // before them (and lose to what devices write after, as any device's would).
118
+ let lastTime = 0;
119
+ let counter = 0;
120
+ const tick = () => {
121
+ const now = Math.max(Date.now(), lastTime);
122
+ counter = now === lastTime ? counter + 1 : 0;
123
+ lastTime = now;
124
+ return clock.format(now, counter, device);
125
+ };
126
+ const see = (change) => {
127
+ const { time, counter: seen } = clock.parse(change.clock);
128
+ if (time > lastTime) {
129
+ lastTime = time;
130
+ counter = seen;
131
+ } else if (time === lastTime) {
132
+ counter = Math.max(counter, seen);
133
+ }
134
+ };
135
+ // The documents, built from the log the first time they're asked for, then kept current.
136
+ // While they're being built, what's recorded goes into the view being built too (applying a
137
+ // change twice changes nothing), so nothing that arrives during the read is missed.
138
+ let docs = null;
139
+ let built = null;
140
+ let building = null;
141
+ /** Puts changes in the documents; returns what takes them back out (for a write the log refused). */
142
+ const record = (changes) => {
143
+ const undo = [];
144
+ for (const change of changes) {
145
+ see(change);
146
+ const taken = (docs ?? built)?.apply(change);
147
+ if (taken) undo.push(taken);
148
+ }
149
+ return () => undo.reverse().forEach((take) => take());
150
+ };
151
+ async function documents() {
152
+ if (docs) return docs.view;
153
+ building ??= (async () => {
154
+ built = documentsOf();
155
+ for (let cursor = 0; ; ) {
156
+ const page = await log.after(cursor, 5000);
157
+ for (const change of page.changes) {
158
+ see(change);
159
+ built.apply(change);
160
+ }
161
+ if (!page.changes.length || String(page.cursor) === String(cursor)) break;
162
+ cursor = page.cursor;
163
+ }
164
+ docs = built;
165
+ return built.view;
166
+ })();
167
+ return building;
168
+ }
169
+ // One that fails rejects for its own caller only; the next still runs.
170
+ const append = (changes) => (appending = appending.catch(() => {}).then(() => log.append(changes)));
42
171
  // Live subscribers: { peer, request, send(event, data), rooms }.
43
172
  const listeners = new Set();
44
173
  const encoder = new TextEncoder();
@@ -50,7 +179,7 @@ export function createSyncServer({ log = memoryLog(), mount = MOUNT, authorize =
50
179
  for (const listener of listeners) {
51
180
  const visible = [];
52
181
  for (const change of changes) {
53
- if (await authorize({ request: listener.request, collection: change.collection, key: change.key, write: false })) visible.push(change);
182
+ if (await authorize({ request: listener.request, collection: change.collection, key: change.key, write: false, kind: 'read' })) visible.push(change);
54
183
  }
55
184
  listener.send('changes', { changes: visible, cursor });
56
185
  }
@@ -59,7 +188,7 @@ export function createSyncServer({ log = memoryLog(), mount = MOUNT, authorize =
59
188
  const { collection, key } = roomOf(message.room);
60
189
  for (const listener of listeners) {
61
190
  if (listener.peer === message.peer) continue;
62
- if (await authorize({ request: listener.request, collection, key, write: false })) listener.send('presence', message);
191
+ if (await authorize({ request: listener.request, collection, key, write: false, kind: 'read' })) listener.send('presence', message);
63
192
  }
64
193
  }
65
194
  const sameOrigin = (request) => {
@@ -82,10 +211,11 @@ export function createSyncServer({ log = memoryLog(), mount = MOUNT, authorize =
82
211
  const changes = Array.isArray(body?.changes) ? body.changes : null;
83
212
  if (!changes || !changes.every(valid)) return json(400, { error: 'Expected { changes: [{ collection, key, field, value, clock }] }' });
84
213
  for (const change of changes) {
85
- if (!(await authorize({ request, collection: change.collection, key: change.key, write: true }))) return json(403, { error: 'Not yours to change' });
214
+ if (!(await authorize({ request, collection: change.collection, key: change.key, write: true, kind: 'change' }))) return json(403, { error: 'Not yours to change' });
86
215
  }
87
216
  const appended = changes.map(({ collection, key, field, value, clock }) => ({ collection, key, field, value, clock }));
88
- const cursor = await log.append(appended);
217
+ const cursor = await append(appended);
218
+ record(appended); // once it's logged: the documents never hold what the log doesn't
89
219
  await broadcastChanges(appended, cursor);
90
220
  return json(200, { ok: true });
91
221
  }
@@ -103,7 +233,7 @@ export function createSyncServer({ log = memoryLog(), mount = MOUNT, authorize =
103
233
  if (!room || !okPeer || !okState || JSON.stringify(message.state).length > 4096) {
104
234
  return json(400, { error: 'Expected { room: "<collection>/<key>", peer, state: {…} | null }' });
105
235
  }
106
- if (!(await authorize({ request, ...room, write: true }))) return json(403, { error: 'Not yours to join' });
236
+ if (!(await authorize({ request, ...room, write: true, kind: 'presence' }))) return json(403, { error: 'Not yours to join' });
107
237
  const clean = { room: message.room, peer: message.peer, state: message.state };
108
238
  for (const listener of listeners) if (listener.peer === clean.peer) {
109
239
  if (clean.state === null) listener.rooms.delete(clean.room);
@@ -128,7 +258,7 @@ export function createSyncServer({ log = memoryLog(), mount = MOUNT, authorize =
128
258
  if (!room || !peerOk(message.from) || !peerOk(message.to) || !dataOk) {
129
259
  return json(400, { error: 'Expected { room, from, to, data: {…} }' });
130
260
  }
131
- if (!(await authorize({ request, ...room, write: true }))) return json(403, { error: 'Not yours to join' });
261
+ if (!(await authorize({ request, ...room, write: true, kind: 'presence' }))) return json(403, { error: 'Not yours to join' });
132
262
  const clean = { room: message.room, from: message.from, to: message.to, data: message.data };
133
263
  for (const listener of listeners) if (listener.peer === clean.to) listener.send('signal', clean);
134
264
  return json(200, { ok: true });
@@ -170,7 +300,7 @@ export function createSyncServer({ log = memoryLog(), mount = MOUNT, authorize =
170
300
  if (!backlog.changes.length && !first) break;
171
301
  const visible = [];
172
302
  for (const change of backlog.changes) {
173
- if (await authorize({ request, collection: change.collection, key: change.key, write: false })) visible.push(change);
303
+ if (await authorize({ request, collection: change.collection, key: change.key, write: false, kind: 'read' })) visible.push(change);
174
304
  }
175
305
  listener.send('changes', { changes: visible, cursor: backlog.cursor });
176
306
  if (!backlog.changes.length || String(backlog.cursor) === String(from)) break;
@@ -189,7 +319,7 @@ export function createSyncServer({ log = memoryLog(), mount = MOUNT, authorize =
189
319
  const { changes, cursor } = await log.after(url.searchParams.get('since') ?? '0');
190
320
  const visible = [];
191
321
  for (const change of changes) {
192
- if (await authorize({ request, collection: change.collection, key: change.key, write: false })) visible.push(change);
322
+ if (await authorize({ request, collection: change.collection, key: change.key, write: false, kind: 'read' })) visible.push(change);
193
323
  }
194
324
  return json(200, { changes: visible, cursor });
195
325
  }
@@ -228,5 +358,36 @@ export function createSyncServer({ log = memoryLog(), mount = MOUNT, authorize =
228
358
  return true;
229
359
  }
230
360
 
231
- return { fetch, handle };
361
+ /**
362
+ * Write `fields` to a document as the server: to the documents view at once (so a check
363
+ * before it, with no await between, can't be interleaved), then to the log and every live
364
+ * page. Resolves to the changes written.
365
+ */
366
+ async function write(collection, key, fields) {
367
+ if (typeof collection !== 'string' || typeof key !== 'string' || !fields || typeof fields !== 'object' || Array.isArray(fields)) {
368
+ throw new TypeError('sync.write(collection, key, { field: value, … })');
369
+ }
370
+ if ('$clocks' in fields) throw new TypeError('sync.write: `$clocks` is not a field');
371
+ // A server that hasn't read its log yet (it just started, or a Durable Object was evicted)
372
+ // hasn't seen the clocks in it: read it first, so this write comes after all of them. Once
373
+ // the documents exist this doesn't wait, so a check and a write with no await between stay
374
+ // one step.
375
+ if (!docs) await documents();
376
+ const changes = Object.entries(fields).map(([field, value]) => ({ collection, key, field, value, clock: tick() }));
377
+ if (!changes.length) return [];
378
+ // In the documents at once, so a check before this call and the write are one step; taken
379
+ // back out if the log refuses it.
380
+ const undo = record(changes);
381
+ let cursor;
382
+ try {
383
+ cursor = await append(changes);
384
+ } catch (error) {
385
+ undo();
386
+ throw error;
387
+ }
388
+ await broadcastChanges(changes, cursor);
389
+ return changes;
390
+ }
391
+
392
+ return { fetch, handle, documents, write };
232
393
  }