@zubra/sync 5.6.0 → 5.8.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 +2 -2
- package/src/connect.d.ts +8 -0
- package/src/connect.js +70 -15
- package/src/durable-log.d.ts +4 -3
- package/src/durable-log.js +17 -33
- package/src/index.js +39 -6
- package/src/server.d.ts +26 -3
- package/src/server.js +132 -28
- package/src/sql-log.js +86 -0
- package/src/sqlite.d.ts +7 -2
- package/src/sqlite.js +28 -35
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zubra/sync",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.8.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.
|
|
35
|
+
"@zubra/runtime": "^5.8.0"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
38
|
"@zubra/compiler": "file:../compiler",
|
package/src/connect.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { Scope } from './server.js';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Share 'use collection' documents through a log at `url` (default `/_zb/sync`), pulling every
|
|
3
5
|
* `interval` ms (default 2000). Writes stay on the device first and go out when the network allows.
|
|
@@ -13,6 +15,12 @@ export function connect(options?: {
|
|
|
13
15
|
* server carries introductions, goodbyes, signaling, and presence to anyone else.
|
|
14
16
|
*/
|
|
15
17
|
webrtc?: boolean | { iceServers?: RTCIceServer[] };
|
|
18
|
+
/**
|
|
19
|
+
* Only what this page shows: per collection, `true` or a key prefix (or a list of them), e.g.
|
|
20
|
+
* `{ seats: 'showing-12/', brands: true }`. Changes and presence outside it aren't sent. Each
|
|
21
|
+
* scope keeps its own cursor. Leave it out for everything.
|
|
22
|
+
*/
|
|
23
|
+
scope?: Scope;
|
|
16
24
|
}): {
|
|
17
25
|
flush(): Promise<void>;
|
|
18
26
|
pull(): Promise<void>;
|
package/src/connect.js
CHANGED
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
//
|
|
8
8
|
// The protocol (plan/sync-design.md), plain HTTP so any server can keep the log:
|
|
9
9
|
// POST <url>/push { changes: [Change] } → { ok: true }
|
|
10
|
-
// GET <url>/pull?since=<cursor>
|
|
10
|
+
// GET <url>/pull?since=<cursor>[&scope=<json>] → { changes: [Change], cursor }
|
|
11
11
|
// Change = { collection, key, field, value, clock } (clock: a hybrid logical clock, a string
|
|
12
12
|
// that sorts by time, then counter, then client id)
|
|
13
13
|
//
|
|
14
14
|
// Merging is last-writer-wins per field, by clock. A device that was offline pushes what it
|
|
15
15
|
// wrote when it's back; whichever write has the later clock wins, on every device alike.
|
|
16
|
-
import { onWrite, receive, currentStore, onPresence, receivePresence, peerId } from './index.js';
|
|
16
|
+
import { onWrite, receive, currentStore, onPresence, receivePresence, peerId, startingPull } from './index.js';
|
|
17
17
|
import { mesh } from './webrtc.js';
|
|
18
18
|
|
|
19
19
|
const CLOCKS = '$clocks';
|
|
@@ -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
|
-
|
|
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
|
},
|
|
@@ -64,27 +71,45 @@ const keep = (name, value) => { try { localStorage.setItem(name, value); } catch
|
|
|
64
71
|
|
|
65
72
|
/**
|
|
66
73
|
* Start sharing. `url`: where the log is (`/_zb/sync`). `interval`: how often to pull, in ms
|
|
67
|
-
* (default 2000).
|
|
74
|
+
* (default 2000). `scope`: only what this page shows, per collection `true` or a key prefix (or a
|
|
75
|
+
* list of them), e.g. `{ seats: 'showing-12/', brands: true }`; leave it out for everything.
|
|
76
|
+
* Returns `{ flush, pull, stop }`.
|
|
68
77
|
*/
|
|
69
|
-
export function connect({ url = '/_zb/sync', interval = 2000, live = true, webrtc = false } = {}) {
|
|
78
|
+
export function connect({ url = '/_zb/sync', interval = 2000, live = true, webrtc = false, scope = null } = {}) {
|
|
70
79
|
const base = url.replace(/\/$/, '');
|
|
80
|
+
// The scope goes with every pull and live request. Each scope keeps its own cursor: a page that
|
|
81
|
+
// moves to another showing reads that showing from the start, not from where the last one was.
|
|
82
|
+
const scoped = scope ? `&scope=${encodeURIComponent(JSON.stringify(scope))}` : '';
|
|
83
|
+
const cursorName = scope ? `zubra-sync:cursor:${JSON.stringify(scope)}` : 'zubra-sync:cursor';
|
|
71
84
|
const client = remember('zubra-sync:client', () => {
|
|
72
85
|
const id = Math.random().toString(36).slice(2, 10);
|
|
73
86
|
keep('zubra-sync:client', id);
|
|
74
87
|
return id;
|
|
75
88
|
});
|
|
76
89
|
const clocks = clock(client);
|
|
77
|
-
let cursor = remember(
|
|
90
|
+
let cursor = remember(cursorName, () => '0');
|
|
78
91
|
const outbox = outboxOf(JSON.parse(remember('zubra-sync:outbox', () => '[]')), (pending) =>
|
|
79
92
|
keep('zubra-sync:outbox', JSON.stringify(pending)));
|
|
80
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
|
+
|
|
81
104
|
// Local writes: stamp each field, remember the stamp beside the document, queue it.
|
|
82
105
|
const off = onWrite(async (collection, key, fields) => {
|
|
83
106
|
const stamped = {};
|
|
84
107
|
for (const [field, value] of Object.entries(fields)) {
|
|
85
108
|
const stamp = clocks.tick();
|
|
86
109
|
stamped[field] = stamp;
|
|
87
|
-
|
|
110
|
+
const change = { collection, key, field, value, clock: stamp };
|
|
111
|
+
own(change);
|
|
112
|
+
outbox.add(change);
|
|
88
113
|
}
|
|
89
114
|
const doc = (await currentStore().get(collection, key)) ?? {};
|
|
90
115
|
await currentStore().set(collection, key, { [CLOCKS]: { ...doc[CLOCKS], ...stamped } });
|
|
@@ -116,7 +141,7 @@ export function connect({ url = '/_zb/sync', interval = 2000, live = true, webrt
|
|
|
116
141
|
async function pull() {
|
|
117
142
|
let body;
|
|
118
143
|
try {
|
|
119
|
-
const response = await fetch(`${base}/pull?since=${encodeURIComponent(cursor)}`);
|
|
144
|
+
const response = await fetch(`${base}/pull?since=${encodeURIComponent(cursor)}${scoped}`);
|
|
120
145
|
if (!response.ok) return;
|
|
121
146
|
body = await response.json();
|
|
122
147
|
} catch {
|
|
@@ -139,22 +164,27 @@ export function connect({ url = '/_zb/sync', interval = 2000, live = true, webrt
|
|
|
139
164
|
if (!byDoc.has(id)) byDoc.set(id, []);
|
|
140
165
|
byDoc.get(id).push(change);
|
|
141
166
|
}
|
|
142
|
-
for (const changes of byDoc
|
|
167
|
+
for (const [id, changes] of byDoc) {
|
|
143
168
|
const { collection, key } = changes[0];
|
|
144
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.
|
|
145
171
|
const known = { ...doc[CLOCKS] };
|
|
172
|
+
for (const [field, stamp] of Object.entries(mine.get(id) ?? {})) known[field] = newest(stamp, known[field]);
|
|
146
173
|
const fields = {};
|
|
147
174
|
for (const change of changes) {
|
|
148
175
|
if (known[change.field] && known[change.field] >= change.clock) continue;
|
|
149
176
|
known[change.field] = change.clock;
|
|
150
177
|
fields[change.field] = change.value;
|
|
151
178
|
}
|
|
152
|
-
|
|
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);
|
|
153
182
|
}
|
|
154
|
-
// Never move the cursor back (a live backlog can arrive after a newer push)
|
|
183
|
+
// Never move the cursor back (a live backlog can arrive after a newer push), unless the server
|
|
184
|
+
// says this device's cursor was past the end of its log: then it starts over from what it sent.
|
|
155
185
|
const next = String(body.cursor);
|
|
156
|
-
if (!(/^\d+$/.test(next) && /^\d+$/.test(cursor) && Number(next) < Number(cursor))) cursor = next;
|
|
157
|
-
keep(
|
|
186
|
+
if (body.reset || !(/^\d+$/.test(next) && /^\d+$/.test(cursor) && Number(next) < Number(cursor))) cursor = next;
|
|
187
|
+
keep(cursorName, cursor);
|
|
158
188
|
}
|
|
159
189
|
|
|
160
190
|
// Live: a server-sent event stream brings changes as they're pushed, and other viewers'
|
|
@@ -164,7 +194,7 @@ export function connect({ url = '/_zb/sync', interval = 2000, live = true, webrt
|
|
|
164
194
|
let retry = null;
|
|
165
195
|
const listen = () => {
|
|
166
196
|
if (!live || typeof EventSource === 'undefined' || stopped) return;
|
|
167
|
-
source = new EventSource(`${base}/live?since=${encodeURIComponent(cursor)}&peer=${encodeURIComponent(peerId())}`);
|
|
197
|
+
source = new EventSource(`${base}/live?since=${encodeURIComponent(cursor)}&peer=${encodeURIComponent(peerId())}${scoped}`);
|
|
168
198
|
source.addEventListener('open', () => (open = true));
|
|
169
199
|
source.addEventListener('changes', (event) => apply(JSON.parse(event.data)));
|
|
170
200
|
source.addEventListener('presence', (event) => {
|
|
@@ -215,7 +245,32 @@ export function connect({ url = '/_zb/sync', interval = 2000, live = true, webrt
|
|
|
215
245
|
const loop = setInterval(() => flush().then(() => (open ? undefined : pull())), interval);
|
|
216
246
|
const online = () => flush().then(pull);
|
|
217
247
|
addEventListener('online', online);
|
|
218
|
-
|
|
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
|
+
|
|
270
|
+
// Components wait for this first pull before applying what's stored (index.js: fresh first).
|
|
271
|
+
const first = (applying = applying.then(replay).catch(() => {})).then(flush).then(pull);
|
|
272
|
+
startingPull(first);
|
|
273
|
+
first.then(listen);
|
|
219
274
|
return {
|
|
220
275
|
flush,
|
|
221
276
|
pull,
|
package/src/durable-log.d.ts
CHANGED
|
@@ -7,7 +7,8 @@ export interface DurableObjectStorageLike {
|
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
* A sync log in a Durable Object's SQLite: the
|
|
11
|
-
*
|
|
10
|
+
* A sync log in a Durable Object's SQLite: the tables and cursors `sqliteLog` uses. `keep:
|
|
11
|
+
* 'latest'` keeps each field's latest value only. `table` names the table (default `changes`, or
|
|
12
|
+
* `latest`), if the object keeps tables of its own.
|
|
12
13
|
*/
|
|
13
|
-
export function durableObjectLog(storage: DurableObjectStorageLike, options?: { table?: string }): Log;
|
|
14
|
+
export function durableObjectLog(storage: DurableObjectStorageLike, options?: { table?: string; keep?: 'all' | 'latest' }): Log;
|
package/src/durable-log.js
CHANGED
|
@@ -1,44 +1,28 @@
|
|
|
1
|
-
// A sync log in a Cloudflare Durable Object's own SQLite (`ctx.storage.sql`): the same
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// A sync log in a Cloudflare Durable Object's own SQLite (`ctx.storage.sql`): the same tables and
|
|
2
|
+
// cursors as `sqliteLog`, so a log can move between Node and a Durable Object. It runs anywhere a
|
|
3
|
+
// Durable Object's storage is passed in; nothing here imports Workers' modules.
|
|
4
4
|
//
|
|
5
5
|
// import { createSyncServer } from '@zubra/sync/server';
|
|
6
6
|
// import { durableObjectLog } from '@zubra/sync/durable';
|
|
7
|
-
// const sync = createSyncServer({ log: durableObjectLog(ctx.storage) });
|
|
7
|
+
// const sync = createSyncServer({ log: durableObjectLog(ctx.storage, { keep: 'latest' }) });
|
|
8
8
|
//
|
|
9
|
-
// `
|
|
10
|
-
//
|
|
11
|
-
|
|
9
|
+
// `keep: 'latest'` keeps each field's latest value only (see sql-log.js); by default every change
|
|
10
|
+
// is kept. `table` names the table, if the object keeps tables of its own (default `changes`, or
|
|
11
|
+
// `latest`). Appends are one transaction (`transactionSync`): a batch is written whole or not at all.
|
|
12
|
+
import { sqlLog } from './sql-log.js';
|
|
12
13
|
|
|
13
|
-
export function durableObjectLog(storage, { table = '
|
|
14
|
+
export function durableObjectLog(storage, { table, keep = 'all' } = {}) {
|
|
14
15
|
if (!storage?.sql || typeof storage.transactionSync !== 'function') {
|
|
15
16
|
throw new TypeError("durableObjectLog(storage): pass a Durable Object's `ctx.storage` (SQLite-backed)");
|
|
16
17
|
}
|
|
17
|
-
if (!NAME.test(table)) throw new TypeError('durableObjectLog: `table` is a plain SQL name (letters, digits, _)');
|
|
18
18
|
const { sql } = storage;
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
});
|
|
19
|
+
return sqlLog(
|
|
20
|
+
{
|
|
21
|
+
exec: (query) => sql.exec(query),
|
|
22
|
+
run: (query, ...args) => sql.exec(query, ...args),
|
|
23
|
+
all: (query, ...args) => sql.exec(query, ...args).toArray(),
|
|
24
|
+
transaction: (fn) => storage.transactionSync(fn),
|
|
36
25
|
},
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
};
|
|
26
|
+
{ keep, table, label: 'durableObjectLog' },
|
|
27
|
+
);
|
|
44
28
|
}
|
package/src/index.js
CHANGED
|
@@ -39,15 +39,48 @@ 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
|
-
/**
|
|
43
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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();
|
|
50
54
|
|
|
55
|
+
// Fresh first: a page connected to a log reads it before a component applies what this device
|
|
56
|
+
// stored last time, so a returning visitor never sees last visit's state over the server's (or
|
|
57
|
+
// over a page the server just rendered). Offline, the pull fails at once and stored values show.
|
|
58
|
+
const FRESH_WAIT = 1500; // and never longer than this
|
|
59
|
+
let firstPull = null;
|
|
60
|
+
/** @internal connect() hands over its first pull, as it starts. */
|
|
61
|
+
export const startingPull = (pull) => {
|
|
62
|
+
firstPull = Promise.resolve(pull).catch(() => {});
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* Before reading a stored document: wait until the page's own scripts have run (they finish before
|
|
66
|
+
* DOMContentLoaded, and connect() is one of them), then for the first pull if there is a connection.
|
|
67
|
+
*/
|
|
68
|
+
const fresh = () =>
|
|
69
|
+
scriptsDone().then(() => (firstPull ? Promise.race([firstPull, new Promise((done) => setTimeout(done, FRESH_WAIT))]) : undefined));
|
|
70
|
+
let scripts = null;
|
|
71
|
+
const scriptsDone = () =>
|
|
72
|
+
(scripts ??= (() => {
|
|
73
|
+
if (typeof document === 'undefined' || document.readyState === 'complete') return Promise.resolve();
|
|
74
|
+
// Navigation timing says whether DOMContentLoaded has already started (readyState is
|
|
75
|
+
// 'interactive' both while module scripts run and after).
|
|
76
|
+
const navigation = globalThis.performance?.getEntriesByType?.('navigation')?.[0];
|
|
77
|
+
if (navigation && navigation.domContentLoadedEventStart > 0) return Promise.resolve();
|
|
78
|
+
return new Promise((done) => {
|
|
79
|
+
document.addEventListener('DOMContentLoaded', done, { once: true });
|
|
80
|
+
setTimeout(done, FRESH_WAIT);
|
|
81
|
+
});
|
|
82
|
+
})());
|
|
83
|
+
|
|
51
84
|
// Every bound element, by document, so a write reaches the others (this tab and, below, others).
|
|
52
85
|
const bound = new Map();
|
|
53
86
|
const docId = (collection, key) => `${collection}\u0000${key}`;
|
|
@@ -142,8 +175,8 @@ export function collection(name, keys, props, fields, presenceFields = null, pee
|
|
|
142
175
|
let cancelled = false;
|
|
143
176
|
// What the fields hold as loading starts, to tell what someone changed while it loaded.
|
|
144
177
|
const before = Signal.subtle.untrack(values);
|
|
145
|
-
|
|
146
|
-
.get(name, key)
|
|
178
|
+
fresh()
|
|
179
|
+
.then(() => theStore().get(name, key))
|
|
147
180
|
.then((doc = {}) => {
|
|
148
181
|
if (cancelled) return;
|
|
149
182
|
// An edit made before the document arrived is newer than it: keep the edit (and save
|
package/src/server.d.ts
CHANGED
|
@@ -10,19 +10,36 @@ export interface Change {
|
|
|
10
10
|
export interface Log {
|
|
11
11
|
append(changes: Change[]): Promise<unknown>;
|
|
12
12
|
after(cursor: string, limit?: number): Promise<{ changes: Change[]; cursor: number | string }>;
|
|
13
|
+
/**
|
|
14
|
+
* The cursor after the last change. Optional: with it, a device whose cursor is past the end
|
|
15
|
+
* (the log was replaced) is answered from the start, with `reset: true`.
|
|
16
|
+
*/
|
|
17
|
+
head?(): Promise<number>;
|
|
13
18
|
}
|
|
14
19
|
|
|
15
|
-
|
|
20
|
+
/** A log in memory. `keep: 'latest'` keeps each field's latest value only. */
|
|
21
|
+
export function memoryLog(options?: { keep?: 'all' | 'latest' }): Log;
|
|
16
22
|
|
|
17
23
|
export interface Documents {
|
|
18
24
|
/** A document's fields (a copy), or undefined when nothing has been written to it. */
|
|
19
25
|
get(collection: string, key: string): Record<string, unknown> | undefined;
|
|
20
|
-
/**
|
|
21
|
-
|
|
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>>;
|
|
22
31
|
/** The collections that have documents, in the order they were first written to. */
|
|
23
32
|
collections(): string[];
|
|
24
33
|
}
|
|
25
34
|
|
|
35
|
+
/** Per collection, `true` (every document) or a key prefix, or a list of them. */
|
|
36
|
+
export type Scope = Record<string, true | string | string[]>;
|
|
37
|
+
|
|
38
|
+
/** A scope from a request's `?scope=` (JSON), or null for everything. Throws on anything else. */
|
|
39
|
+
export function parseScope(text: string | null | undefined): Scope | null;
|
|
40
|
+
/** Whether a document is in a scope (null is everything). */
|
|
41
|
+
export function inScope(scope: Scope | null, collection: string, key: string): boolean;
|
|
42
|
+
|
|
26
43
|
export function createSyncServer(options?: {
|
|
27
44
|
log?: Log;
|
|
28
45
|
/** Default `/_zb/sync`. */
|
|
@@ -34,6 +51,12 @@ export function createSyncServer(options?: {
|
|
|
34
51
|
*/
|
|
35
52
|
authorize?: (from: { request: Request; collection: string; key: string; write: boolean; kind: 'change' | 'presence' | 'read' }) => boolean | Promise<boolean>;
|
|
36
53
|
/** Other origins allowed to push. */
|
|
54
|
+
/**
|
|
55
|
+
* The scope a request may see and write (tenancy: a business's keys under its own prefix), or
|
|
56
|
+
* null for everything. A page's own `scope` can only narrow it; pushes and joins outside it are
|
|
57
|
+
* refused.
|
|
58
|
+
*/
|
|
59
|
+
scope?: (from: { request: Request }) => Scope | null | Promise<Scope | null>;
|
|
37
60
|
origins?: string[];
|
|
38
61
|
/** How often the live stream sends a keep-alive comment (ms, default 15000). */
|
|
39
62
|
heartbeat?: number;
|
package/src/server.js
CHANGED
|
@@ -20,6 +20,16 @@
|
|
|
20
20
|
// const left = docs.get('stock', 'eggs')?.qty ?? 0; // read and write with no await between,
|
|
21
21
|
// if (left > 0) sync.write('stock', 'eggs', { qty: left - 1 }); // and no request interleaves
|
|
22
22
|
//
|
|
23
|
+
// Scope: a page asks only for what it shows. `?scope=` on pull and live is JSON, per collection
|
|
24
|
+
// `true` or a key prefix (or a list of them): `{ "seats": "showing-12/", "brands": true }`.
|
|
25
|
+
// Changes and presence outside it aren't sent. `scope({ request })` imposes one from the server
|
|
26
|
+
// (tenancy: each business's keys under its own prefix), which the page's can only narrow; writes
|
|
27
|
+
// and joins outside it are refused.
|
|
28
|
+
//
|
|
29
|
+
// A cursor past the end of the log (the log was replaced, or compacted, or this device's cursor
|
|
30
|
+
// is from another log) starts over: pull and live answer from the beginning, with `reset: true`,
|
|
31
|
+
// and the device takes the new cursor. Logs that can say where they end have `head()`.
|
|
32
|
+
//
|
|
23
33
|
// Live: `GET <mount>/live?since=<cursor>&peer=<id>` is a server-sent event stream: `changes` as
|
|
24
34
|
// they're pushed, and `presence` from the other viewers (POST <mount>/presence). Subscribers are
|
|
25
35
|
// this process's; several server processes need a shared log and a pub/sub between them.
|
|
@@ -27,8 +37,14 @@
|
|
|
27
37
|
// On Node, `handle(req, res)` answers sync requests (streaming the live ones) and returns true.
|
|
28
38
|
export const MOUNT = '/_zb/sync';
|
|
29
39
|
|
|
30
|
-
/**
|
|
31
|
-
|
|
40
|
+
/**
|
|
41
|
+
* A log in memory: `append(changes)` and `after(cursor)`. `keep: 'latest'` keeps each field's
|
|
42
|
+
* latest value only (a newer clock replaces it and takes the next cursor, an older one is dropped),
|
|
43
|
+
* as `sqliteLog(path, { keep: 'latest' })` does.
|
|
44
|
+
*/
|
|
45
|
+
export function memoryLog({ keep = 'all' } = {}) {
|
|
46
|
+
if (keep === 'latest') return latestInMemory();
|
|
47
|
+
if (keep !== 'all') throw new TypeError("memoryLog: `keep` is 'all' or 'latest'");
|
|
32
48
|
const changes = [];
|
|
33
49
|
return {
|
|
34
50
|
async append(more) {
|
|
@@ -39,9 +55,67 @@ export function memoryLog() {
|
|
|
39
55
|
const from = Number(cursor) || 0;
|
|
40
56
|
return { changes: changes.slice(from, from + limit), cursor: Math.min(changes.length, from + limit) };
|
|
41
57
|
},
|
|
58
|
+
head: async () => changes.length,
|
|
42
59
|
};
|
|
43
60
|
}
|
|
44
61
|
|
|
62
|
+
function latestInMemory() {
|
|
63
|
+
const rows = new Map(); // collection, key, field → { change, seq }
|
|
64
|
+
let seq = 0;
|
|
65
|
+
return {
|
|
66
|
+
async append(more) {
|
|
67
|
+
for (const change of more) {
|
|
68
|
+
const id = JSON.stringify([change.collection, change.key, change.field]);
|
|
69
|
+
const had = rows.get(id);
|
|
70
|
+
if (had && had.change.clock >= change.clock) continue;
|
|
71
|
+
rows.set(id, { change: structuredClone(change), seq: ++seq });
|
|
72
|
+
}
|
|
73
|
+
return seq;
|
|
74
|
+
},
|
|
75
|
+
async after(cursor, limit = 1000) {
|
|
76
|
+
const from = Number(cursor) || 0;
|
|
77
|
+
const page = [...rows.values()].filter((row) => row.seq > from).sort((a, b) => a.seq - b.seq).slice(0, limit);
|
|
78
|
+
return { changes: page.map((row) => structuredClone(row.change)), cursor: page.length ? page[page.length - 1].seq : from };
|
|
79
|
+
},
|
|
80
|
+
head: async () => seq,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A scope from a request's `?scope=`: `{ collection: true | prefix | [prefixes] }`, or null for
|
|
86
|
+
* everything. Throws on anything else.
|
|
87
|
+
*/
|
|
88
|
+
export function parseScope(text) {
|
|
89
|
+
if (text === null || text === undefined || text === '') return null;
|
|
90
|
+
let scope;
|
|
91
|
+
try {
|
|
92
|
+
scope = JSON.parse(text);
|
|
93
|
+
} catch {
|
|
94
|
+
throw new TypeError('scope is JSON');
|
|
95
|
+
}
|
|
96
|
+
if (scope === null) return null;
|
|
97
|
+
checkScope(scope);
|
|
98
|
+
return scope;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function checkScope(scope) {
|
|
102
|
+
const prefix = (each) => typeof each === 'string' && each.length <= 200;
|
|
103
|
+
const ok =
|
|
104
|
+
scope !== null && typeof scope === 'object' && !Array.isArray(scope) && Object.keys(scope).length <= 100 &&
|
|
105
|
+
Object.values(scope).every((each) => each === true || prefix(each) || (Array.isArray(each) && each.length <= 50 && each.every(prefix)));
|
|
106
|
+
if (!ok) throw new TypeError('scope is { collection: true | "key prefix" | ["prefixes"] }');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Whether a document is in a scope (null is everything). */
|
|
110
|
+
export function inScope(scope, collection, key) {
|
|
111
|
+
if (!scope) return true;
|
|
112
|
+
const rule = scope[collection]; // inherited properties (constructor…) are none of these
|
|
113
|
+
if (rule === true) return true;
|
|
114
|
+
if (typeof rule === 'string') return key.startsWith(rule);
|
|
115
|
+
if (Array.isArray(rule)) return rule.some((prefix) => key.startsWith(prefix));
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
|
|
45
119
|
const json = (status, value) => new Response(JSON.stringify(value), { status, headers: { 'content-type': 'application/json' } });
|
|
46
120
|
|
|
47
121
|
// A clock is `<15-digit ms>-<5-digit counter>-<device>`, so clocks compare as strings.
|
|
@@ -71,8 +145,14 @@ export function documentsView({ find, list, names }) {
|
|
|
71
145
|
const fields = find(collection, key);
|
|
72
146
|
return fields && structuredClone(fields);
|
|
73
147
|
},
|
|
74
|
-
/**
|
|
75
|
-
|
|
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) })),
|
|
76
156
|
/** The collections that have documents, in the order they were first written to. */
|
|
77
157
|
collections: () => names(),
|
|
78
158
|
};
|
|
@@ -108,7 +188,7 @@ function documentsOf() {
|
|
|
108
188
|
};
|
|
109
189
|
}
|
|
110
190
|
|
|
111
|
-
export function createSyncServer({ log = memoryLog(), mount = MOUNT, authorize = () => true, origins = [], heartbeat = 15_000, device = 'server' } = {}) {
|
|
191
|
+
export function createSyncServer({ log = memoryLog(), mount = MOUNT, authorize = () => true, scope: serverScope = () => null, origins = [], heartbeat = 15_000, device = 'server' } = {}) {
|
|
112
192
|
if (!/^[\w-]{1,40}$/.test(device)) throw new Error('createSyncServer: `device` names the server in its clocks: letters, digits, - and _');
|
|
113
193
|
const base = mount.replace(/\/$/, '');
|
|
114
194
|
// Appends, one after another, so the log's order is the order they were made in.
|
|
@@ -175,22 +255,48 @@ export function createSyncServer({ log = memoryLog(), mount = MOUNT, authorize =
|
|
|
175
255
|
const at = room.indexOf('/');
|
|
176
256
|
return at > 0 ? { collection: room.slice(0, at), key: room.slice(at + 1) } : null;
|
|
177
257
|
};
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
258
|
+
/**
|
|
259
|
+
* What a request may see: inside the server's scope for it and the page's own, and allowed by
|
|
260
|
+
* authorize. `where` is `{ request, scopes: [server, page] }`.
|
|
261
|
+
*/
|
|
262
|
+
const sees = async ({ request, scopes }, collection, key) =>
|
|
263
|
+
scopes.every((scope) => inScope(scope, collection, key)) && (await authorize({ request, collection, key, write: false, kind: 'read' }));
|
|
264
|
+
async function visible(where, changes) {
|
|
265
|
+
const shown = [];
|
|
266
|
+
for (const change of changes) if (await sees(where, change.collection, change.key)) shown.push(change);
|
|
267
|
+
return shown;
|
|
268
|
+
}
|
|
269
|
+
/** The server's scope for a request and the page's (`?scope=`), or a Response refusing it. */
|
|
270
|
+
async function scopesOf(request, url) {
|
|
271
|
+
let page;
|
|
272
|
+
try {
|
|
273
|
+
page = parseScope(url.searchParams.get('scope'));
|
|
274
|
+
} catch (error) {
|
|
275
|
+
return json(400, { error: error.message });
|
|
185
276
|
}
|
|
277
|
+
const server = (await serverScope({ request })) ?? null;
|
|
278
|
+
if (server) checkScope(server);
|
|
279
|
+
return [server, page];
|
|
280
|
+
}
|
|
281
|
+
/** Where to read from for a device at `since`: from the start if it's past the end of the log. */
|
|
282
|
+
async function startFrom(since) {
|
|
283
|
+
const head = await log.head?.();
|
|
284
|
+
const at = Number(since);
|
|
285
|
+
if (head === undefined || !/^\d+$/.test(String(since)) || at <= Number(head)) return { since, reset: false };
|
|
286
|
+
return { since: '0', reset: true };
|
|
287
|
+
}
|
|
288
|
+
async function broadcastChanges(changes, cursor) {
|
|
289
|
+
for (const listener of listeners) listener.send('changes', { changes: await visible(listener, changes), cursor });
|
|
186
290
|
}
|
|
187
291
|
async function broadcastPresence(message) {
|
|
188
292
|
const { collection, key } = roomOf(message.room);
|
|
189
293
|
for (const listener of listeners) {
|
|
190
294
|
if (listener.peer === message.peer) continue;
|
|
191
|
-
if (await
|
|
295
|
+
if (await sees(listener, collection, key)) listener.send('presence', message);
|
|
192
296
|
}
|
|
193
297
|
}
|
|
298
|
+
/** Whether the server's scope lets this request write to (or join) a document. */
|
|
299
|
+
const mayWrite = async (request, collection, key) => inScope((await serverScope({ request })) ?? null, collection, key);
|
|
194
300
|
const sameOrigin = (request) => {
|
|
195
301
|
const site = request.headers.get('sec-fetch-site');
|
|
196
302
|
if (site) return site === 'same-origin' || site === 'none';
|
|
@@ -211,6 +317,7 @@ export function createSyncServer({ log = memoryLog(), mount = MOUNT, authorize =
|
|
|
211
317
|
const changes = Array.isArray(body?.changes) ? body.changes : null;
|
|
212
318
|
if (!changes || !changes.every(valid)) return json(400, { error: 'Expected { changes: [{ collection, key, field, value, clock }] }' });
|
|
213
319
|
for (const change of changes) {
|
|
320
|
+
if (!(await mayWrite(request, change.collection, change.key))) return json(403, { error: 'Not yours to change' });
|
|
214
321
|
if (!(await authorize({ request, collection: change.collection, key: change.key, write: true, kind: 'change' }))) return json(403, { error: 'Not yours to change' });
|
|
215
322
|
}
|
|
216
323
|
const appended = changes.map(({ collection, key, field, value, clock }) => ({ collection, key, field, value, clock }));
|
|
@@ -233,7 +340,7 @@ export function createSyncServer({ log = memoryLog(), mount = MOUNT, authorize =
|
|
|
233
340
|
if (!room || !okPeer || !okState || JSON.stringify(message.state).length > 4096) {
|
|
234
341
|
return json(400, { error: 'Expected { room: "<collection>/<key>", peer, state: {…} | null }' });
|
|
235
342
|
}
|
|
236
|
-
if (!(await authorize({ request, ...room, write: true, kind: 'presence' }))) return json(403, { error: 'Not yours to join' });
|
|
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' });
|
|
237
344
|
const clean = { room: message.room, peer: message.peer, state: message.state };
|
|
238
345
|
for (const listener of listeners) if (listener.peer === clean.peer) {
|
|
239
346
|
if (clean.state === null) listener.rooms.delete(clean.room);
|
|
@@ -258,7 +365,7 @@ export function createSyncServer({ log = memoryLog(), mount = MOUNT, authorize =
|
|
|
258
365
|
if (!room || !peerOk(message.from) || !peerOk(message.to) || !dataOk) {
|
|
259
366
|
return json(400, { error: 'Expected { room, from, to, data: {…} }' });
|
|
260
367
|
}
|
|
261
|
-
if (!(await authorize({ request, ...room, write: true, kind: 'presence' }))) return json(403, { error: 'Not yours to join' });
|
|
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' });
|
|
262
369
|
const clean = { room: message.room, from: message.from, to: message.to, data: message.data };
|
|
263
370
|
for (const listener of listeners) if (listener.peer === clean.to) listener.send('signal', clean);
|
|
264
371
|
return json(200, { ok: true });
|
|
@@ -266,7 +373,9 @@ export function createSyncServer({ log = memoryLog(), mount = MOUNT, authorize =
|
|
|
266
373
|
if (url.pathname === `${base}/live` && request.method === 'GET') {
|
|
267
374
|
const peer = url.searchParams.get('peer') ?? '';
|
|
268
375
|
if (!/^[\w-]{0,40}$/.test(peer)) return json(400, { error: 'Bad peer id' });
|
|
269
|
-
const
|
|
376
|
+
const scopes = await scopesOf(request, url);
|
|
377
|
+
if (scopes instanceof Response) return scopes;
|
|
378
|
+
const { since, reset } = await startFrom(url.searchParams.get('since') ?? '0');
|
|
270
379
|
let listener;
|
|
271
380
|
let timer;
|
|
272
381
|
const stream = new ReadableStream({
|
|
@@ -285,7 +394,7 @@ export function createSyncServer({ log = memoryLog(), mount = MOUNT, authorize =
|
|
|
285
394
|
for (const room of listener.rooms) broadcastPresence({ room, peer, state: null });
|
|
286
395
|
try { controller.close(); } catch {}
|
|
287
396
|
};
|
|
288
|
-
listener = { peer, request, rooms: new Set(), send: (event, data) => write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`), close };
|
|
397
|
+
listener = { peer, request, scopes, rooms: new Set(), send: (event, data) => write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`), close };
|
|
289
398
|
listeners.add(listener);
|
|
290
399
|
request.signal?.addEventListener('abort', close);
|
|
291
400
|
write('retry: 1000\n\n');
|
|
@@ -298,11 +407,7 @@ export function createSyncServer({ log = memoryLog(), mount = MOUNT, authorize =
|
|
|
298
407
|
for (let first = true; ; first = false) {
|
|
299
408
|
const backlog = await log.after(from);
|
|
300
409
|
if (!backlog.changes.length && !first) break;
|
|
301
|
-
|
|
302
|
-
for (const change of backlog.changes) {
|
|
303
|
-
if (await authorize({ request, collection: change.collection, key: change.key, write: false, kind: 'read' })) visible.push(change);
|
|
304
|
-
}
|
|
305
|
-
listener.send('changes', { changes: visible, cursor: backlog.cursor });
|
|
410
|
+
listener.send('changes', { changes: await visible(listener, backlog.changes), cursor: backlog.cursor, ...(reset && first ? { reset: true } : {}) });
|
|
306
411
|
if (!backlog.changes.length || String(backlog.cursor) === String(from)) break;
|
|
307
412
|
from = backlog.cursor;
|
|
308
413
|
}
|
|
@@ -316,12 +421,11 @@ export function createSyncServer({ log = memoryLog(), mount = MOUNT, authorize =
|
|
|
316
421
|
});
|
|
317
422
|
}
|
|
318
423
|
if (url.pathname === `${base}/pull` && request.method === 'GET') {
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
}
|
|
324
|
-
return json(200, { changes: visible, cursor });
|
|
424
|
+
const scopes = await scopesOf(request, url);
|
|
425
|
+
if (scopes instanceof Response) return scopes;
|
|
426
|
+
const { since, reset } = await startFrom(url.searchParams.get('since') ?? '0');
|
|
427
|
+
const { changes, cursor } = await log.after(since);
|
|
428
|
+
return json(200, { changes: await visible({ request, scopes }, changes), cursor, ...(reset ? { reset: true } : {}) });
|
|
325
429
|
}
|
|
326
430
|
if (url.pathname.startsWith(`${base}/`)) return json(404, { error: 'Not found' });
|
|
327
431
|
return null;
|
package/src/sql-log.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// A sync log in SQL, over any SQLite: Node's and Bun's (sqliteLog) and a Durable Object's
|
|
2
|
+
// (durableObjectLog) hand it the same three calls. Two ways to keep it:
|
|
3
|
+
//
|
|
4
|
+
// - `keep: 'all'` (the default): every change, in the order it came. A table `changes`, cursor =
|
|
5
|
+
// row id. What `sqliteLog()` has always written; the zubra-sync binary reads the same table.
|
|
6
|
+
// - `keep: 'latest'`: each field's latest value only. A table `latest`, one row per field: a
|
|
7
|
+
// change with a newer clock replaces the row and takes the next cursor, an older one is dropped.
|
|
8
|
+
// `after(cursor)` hands out each field's latest value once, so a new device reads what's there
|
|
9
|
+
// now, not the whole evening's history. Opening it over a database that has a `changes` table
|
|
10
|
+
// and no `latest` one carries each field's latest value across, numbered after the old log's
|
|
11
|
+
// last cursor, so a device that had read the old log reads the carried values again and misses
|
|
12
|
+
// nothing.
|
|
13
|
+
//
|
|
14
|
+
// `db` is `{ exec(sql), run(sql, ...args), all(sql, ...args), transaction(fn) }`.
|
|
15
|
+
const NAME = /^[A-Za-z_][\w]{0,62}$/;
|
|
16
|
+
|
|
17
|
+
export function sqlLog(db, { keep = 'all', table, from = 'changes', label = 'log' } = {}) {
|
|
18
|
+
if (keep !== 'all' && keep !== 'latest') throw new TypeError(`${label}: \`keep\` is 'all' or 'latest'`);
|
|
19
|
+
table ??= keep === 'all' ? 'changes' : 'latest';
|
|
20
|
+
for (const name of [table, from]) if (!NAME.test(name)) throw new TypeError(`${label}: a table is a plain SQL name (letters, digits, _)`);
|
|
21
|
+
const json = (value) => JSON.stringify(value ?? null);
|
|
22
|
+
const read = (rows) => rows.map(({ collection, key, field, value, clock }) => ({ collection, key, field, value: JSON.parse(value), clock }));
|
|
23
|
+
const last = () => Number(db.all(`SELECT COALESCE(MAX(seq), 0) AS seq FROM ${table}`)[0].seq);
|
|
24
|
+
|
|
25
|
+
if (keep === 'all') {
|
|
26
|
+
db.exec(`CREATE TABLE IF NOT EXISTS ${table} (
|
|
27
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
28
|
+
collection TEXT NOT NULL,
|
|
29
|
+
key TEXT NOT NULL,
|
|
30
|
+
field TEXT NOT NULL,
|
|
31
|
+
value TEXT,
|
|
32
|
+
clock TEXT NOT NULL
|
|
33
|
+
)`);
|
|
34
|
+
} else {
|
|
35
|
+
const exists = (name) => db.all(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`, name).length > 0;
|
|
36
|
+
const fresh = !exists(table);
|
|
37
|
+
db.exec(`CREATE TABLE IF NOT EXISTS ${table} (
|
|
38
|
+
collection TEXT NOT NULL,
|
|
39
|
+
key TEXT NOT NULL,
|
|
40
|
+
field TEXT NOT NULL,
|
|
41
|
+
value TEXT,
|
|
42
|
+
clock TEXT NOT NULL,
|
|
43
|
+
seq INTEGER NOT NULL UNIQUE,
|
|
44
|
+
PRIMARY KEY (collection, key, field)
|
|
45
|
+
)`);
|
|
46
|
+
if (fresh && from !== table && exists(from)) {
|
|
47
|
+
// Each field's latest change (the later clock; the later row if two share one), in log
|
|
48
|
+
// order, numbered after the old log's last cursor.
|
|
49
|
+
db.transaction(() =>
|
|
50
|
+
db.run(`INSERT INTO ${table} (collection, key, field, value, clock, seq)
|
|
51
|
+
SELECT collection, key, field, value, clock,
|
|
52
|
+
(SELECT COALESCE(MAX(seq), 0) FROM ${from}) + ROW_NUMBER() OVER (ORDER BY seq)
|
|
53
|
+
FROM (SELECT *, ROW_NUMBER() OVER (PARTITION BY collection, key, field ORDER BY clock DESC, seq DESC) AS rank FROM ${from})
|
|
54
|
+
WHERE rank = 1`),
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
async append(changes) {
|
|
61
|
+
return db.transaction(() => {
|
|
62
|
+
for (const { collection, key, field, value, clock } of changes) {
|
|
63
|
+
if (keep === 'all') {
|
|
64
|
+
db.run(`INSERT INTO ${table} (collection, key, field, value, clock) VALUES (?, ?, ?, ?, ?)`, collection, key, field, json(value), clock);
|
|
65
|
+
} else {
|
|
66
|
+
// A newer clock takes the row and the next cursor; an older or equal one changes nothing.
|
|
67
|
+
db.run(
|
|
68
|
+
`INSERT INTO ${table} (collection, key, field, value, clock, seq) VALUES (?, ?, ?, ?, ?, (SELECT COALESCE(MAX(seq), 0) + 1 FROM ${table}))
|
|
69
|
+
ON CONFLICT (collection, key, field) DO UPDATE SET value = excluded.value, clock = excluded.clock, seq = excluded.seq
|
|
70
|
+
WHERE excluded.clock > ${table}.clock`,
|
|
71
|
+
collection, key, field, json(value), clock,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return last();
|
|
76
|
+
});
|
|
77
|
+
},
|
|
78
|
+
async after(cursor, limit = 1000) {
|
|
79
|
+
const from = Number(cursor) || 0;
|
|
80
|
+
const rows = db.all(`SELECT seq, collection, key, field, value, clock FROM ${table} WHERE seq > ? ORDER BY seq LIMIT ?`, from, limit);
|
|
81
|
+
return { changes: read(rows), cursor: rows.length ? Number(rows[rows.length - 1].seq) : from };
|
|
82
|
+
},
|
|
83
|
+
/** The cursor after the last change. */
|
|
84
|
+
head: async () => last(),
|
|
85
|
+
};
|
|
86
|
+
}
|
package/src/sqlite.d.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import type { Log } from './server.js';
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
|
|
3
|
+
/**
|
|
4
|
+
* A sync log in SQLite (`node:sqlite` on Node 22.5+, `bun:sqlite` on Bun). `:memory:` by default.
|
|
5
|
+
* `keep: 'latest'` keeps each field's latest value only: a newer clock replaces it and takes the
|
|
6
|
+
* next cursor, an older one is dropped. Switching an existing file over carries each field's
|
|
7
|
+
* latest value across. By default every change is kept.
|
|
8
|
+
*/
|
|
9
|
+
export function sqliteLog(path?: string, options?: { keep?: 'all' | 'latest' }): Promise<Log & { close(): void }>;
|
package/src/sqlite.js
CHANGED
|
@@ -1,46 +1,39 @@
|
|
|
1
|
-
// A sync log that survives a restart:
|
|
2
|
-
//
|
|
1
|
+
// A sync log that survives a restart: SQLite, through the runtime's own (Node's `node:sqlite`,
|
|
2
|
+
// Bun's `bun:sqlite`), so there's nothing to install.
|
|
3
3
|
//
|
|
4
4
|
// import { createSyncServer } from '@zubra/sync/server';
|
|
5
5
|
// import { sqliteLog } from '@zubra/sync/sqlite';
|
|
6
6
|
// const sync = createSyncServer({ log: await sqliteLog('sync.db') });
|
|
7
7
|
//
|
|
8
|
-
//
|
|
9
|
-
|
|
8
|
+
// `keep: 'latest'` keeps each field's latest value only, so the log doesn't grow with every
|
|
9
|
+
// change and a new device reads what's there now (see sql-log.js). By default it keeps every
|
|
10
|
+
// change, in a table `changes` that the zubra-sync binary reads too.
|
|
11
|
+
import { sqlLog } from './sql-log.js';
|
|
12
|
+
|
|
13
|
+
export async function sqliteLog(path = ':memory:', { keep = 'all' } = {}) {
|
|
10
14
|
const db = await open(path);
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
insert.run(collection, key, field, JSON.stringify(value ?? null), clock);
|
|
15
|
+
const statements = new Map();
|
|
16
|
+
const statement = (sql) => statements.get(sql) ?? statements.set(sql, db.prepare(sql)).get(sql);
|
|
17
|
+
const log = sqlLog(
|
|
18
|
+
{
|
|
19
|
+
exec: (sql) => db.exec(sql),
|
|
20
|
+
run: (sql, ...args) => statement(sql).run(...args),
|
|
21
|
+
all: (sql, ...args) => statement(sql).all(...args),
|
|
22
|
+
transaction(fn) {
|
|
23
|
+
db.exec('BEGIN');
|
|
24
|
+
try {
|
|
25
|
+
const result = fn();
|
|
26
|
+
db.exec('COMMIT');
|
|
27
|
+
return result;
|
|
28
|
+
} catch (error) {
|
|
29
|
+
db.exec('ROLLBACK');
|
|
30
|
+
throw error;
|
|
28
31
|
}
|
|
29
|
-
|
|
30
|
-
} catch (error) {
|
|
31
|
-
db.exec('ROLLBACK');
|
|
32
|
-
throw error;
|
|
33
|
-
}
|
|
34
|
-
return Number(last.get().seq);
|
|
35
|
-
},
|
|
36
|
-
async after(cursor, limit = 1000) {
|
|
37
|
-
const rows = select.all(Number(cursor) || 0, limit);
|
|
38
|
-
const changes = rows.map(({ collection, key, field, value, clock }) => ({ collection, key, field, value: JSON.parse(value), clock }));
|
|
39
|
-
const cursorNow = rows.length ? Number(rows[rows.length - 1].seq) : Number(cursor) || 0;
|
|
40
|
-
return { changes, cursor: cursorNow };
|
|
32
|
+
},
|
|
41
33
|
},
|
|
42
|
-
|
|
43
|
-
|
|
34
|
+
{ keep, label: 'sqliteLog' },
|
|
35
|
+
);
|
|
36
|
+
return { ...log, close: () => db.close() };
|
|
44
37
|
}
|
|
45
38
|
|
|
46
39
|
async function open(path) {
|