@tensorix/metahub 0.3.2 → 0.4.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/README.md +23 -10
- package/README.zh-CN.md +23 -10
- package/dist/cli/edge-worker-script.d.ts +4 -0
- package/dist/cli/edge-worker-script.d.ts.map +1 -0
- package/dist/cli.js +16882 -8691
- package/dist/cli.js.map +109 -59
- package/dist/core/access-policy.d.ts +82 -0
- package/dist/core/access-policy.d.ts.map +1 -0
- package/dist/core/blobs-core.d.ts +19 -0
- package/dist/core/blobs-core.d.ts.map +1 -1
- package/dist/core/blobs.d.ts.map +1 -1
- package/dist/core/compact.d.ts.map +1 -1
- package/dist/core/config.d.ts +13 -0
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/crdt.d.ts +29 -2
- package/dist/core/crdt.d.ts.map +1 -1
- package/dist/core/data-map.d.ts +105 -0
- package/dist/core/data-map.d.ts.map +1 -0
- package/dist/core/db.d.ts +1 -1
- package/dist/core/db.d.ts.map +1 -1
- package/dist/core/documents.d.ts.map +1 -1
- package/dist/core/errors.d.ts +2 -0
- package/dist/core/errors.d.ts.map +1 -1
- package/dist/core/grants-core.d.ts +132 -0
- package/dist/core/grants-core.d.ts.map +1 -0
- package/dist/core/guest-intent.d.ts +33 -0
- package/dist/core/guest-intent.d.ts.map +1 -0
- package/dist/core/hash.d.ts +4 -0
- package/dist/core/hash.d.ts.map +1 -0
- package/dist/core/history.d.ts.map +1 -1
- package/dist/core/hlc.d.ts +5 -0
- package/dist/core/hlc.d.ts.map +1 -1
- package/dist/core/ids.d.ts +1 -1
- package/dist/core/ids.d.ts.map +1 -1
- package/dist/core/inject-runtime.d.ts +8 -0
- package/dist/core/inject-runtime.d.ts.map +1 -0
- package/dist/core/intent-retention.d.ts +23 -0
- package/dist/core/intent-retention.d.ts.map +1 -0
- package/dist/core/md/inline.d.ts +6 -2
- package/dist/core/md/inline.d.ts.map +1 -1
- package/dist/core/node.d.ts +13 -0
- package/dist/core/node.d.ts.map +1 -1
- package/dist/core/records.d.ts +31 -0
- package/dist/core/records.d.ts.map +1 -1
- package/dist/core/schema-init.d.ts +19 -2
- package/dist/core/schema-init.d.ts.map +1 -1
- package/dist/core/schema.d.ts +1 -1
- package/dist/core/schema.d.ts.map +1 -1
- package/dist/core/shares.d.ts +15 -0
- package/dist/core/shares.d.ts.map +1 -1
- package/dist/core/site-channel-lifecycle.d.ts +10 -0
- package/dist/core/site-channel-lifecycle.d.ts.map +1 -0
- package/dist/core/site-channel-store.d.ts +120 -0
- package/dist/core/site-channel-store.d.ts.map +1 -0
- package/dist/core/site-channels.d.ts +65 -0
- package/dist/core/site-channels.d.ts.map +1 -0
- package/dist/core/sites-core.d.ts +100 -7
- package/dist/core/sites-core.d.ts.map +1 -1
- package/dist/core/sites.d.ts +65 -2
- package/dist/core/sites.d.ts.map +1 -1
- package/dist/core/sync/anti-abuse.d.ts +26 -0
- package/dist/core/sync/anti-abuse.d.ts.map +1 -0
- package/dist/core/sync/auth.d.ts +30 -2
- package/dist/core/sync/auth.d.ts.map +1 -1
- package/dist/core/sync/blob-routes.d.ts.map +1 -1
- package/dist/core/sync/cf-api.d.ts +54 -0
- package/dist/core/sync/cf-api.d.ts.map +1 -0
- package/dist/core/sync/cf-oauth.d.ts +91 -0
- package/dist/core/sync/cf-oauth.d.ts.map +1 -0
- package/dist/core/sync/client.d.ts +12 -0
- package/dist/core/sync/client.d.ts.map +1 -1
- package/dist/core/sync/data-map-db.d.ts +8 -0
- package/dist/core/sync/data-map-db.d.ts.map +1 -0
- package/dist/core/sync/devices.d.ts +65 -0
- package/dist/core/sync/devices.d.ts.map +1 -0
- package/dist/core/sync/drop-host.d.ts +40 -0
- package/dist/core/sync/drop-host.d.ts.map +1 -0
- package/dist/core/sync/drop-keys.d.ts +64 -0
- package/dist/core/sync/drop-keys.d.ts.map +1 -0
- package/dist/core/sync/drop-protocol.d.ts +94 -0
- package/dist/core/sync/drop-protocol.d.ts.map +1 -0
- package/dist/core/sync/drop-pull.d.ts +67 -0
- package/dist/core/sync/drop-pull.d.ts.map +1 -0
- package/dist/core/sync/drop-wire.d.ts +39 -0
- package/dist/core/sync/drop-wire.d.ts.map +1 -0
- package/dist/core/sync/e2ee.d.ts +3 -0
- package/dist/core/sync/e2ee.d.ts.map +1 -1
- package/dist/core/sync/edge-config.d.ts +77 -0
- package/dist/core/sync/edge-config.d.ts.map +1 -0
- package/dist/core/sync/edge-connect.d.ts +13 -0
- package/dist/core/sync/edge-connect.d.ts.map +1 -0
- package/dist/core/sync/edge-service.d.ts +100 -0
- package/dist/core/sync/edge-service.d.ts.map +1 -0
- package/dist/core/sync/grants-routes.d.ts +46 -0
- package/dist/core/sync/grants-routes.d.ts.map +1 -0
- package/dist/core/sync/guest-session.d.ts +29 -0
- package/dist/core/sync/guest-session.d.ts.map +1 -0
- package/dist/core/sync/html-escape.d.ts +2 -0
- package/dist/core/sync/html-escape.d.ts.map +1 -0
- package/dist/core/sync/http-status.d.ts +14 -0
- package/dist/core/sync/http-status.d.ts.map +1 -0
- package/dist/core/sync/http-util.d.ts +6 -0
- package/dist/core/sync/http-util.d.ts.map +1 -0
- package/dist/core/sync/pairing.d.ts +9 -9
- package/dist/core/sync/pairing.d.ts.map +1 -1
- package/dist/core/sync/partition.d.ts +77 -0
- package/dist/core/sync/partition.d.ts.map +1 -0
- package/dist/core/sync/peers-routes.d.ts.map +1 -1
- package/dist/core/sync/peers.d.ts +77 -0
- package/dist/core/sync/peers.d.ts.map +1 -1
- package/dist/core/sync/protocol.d.ts +2 -0
- package/dist/core/sync/protocol.d.ts.map +1 -1
- package/dist/core/sync/rate-limit.d.ts +20 -0
- package/dist/core/sync/rate-limit.d.ts.map +1 -0
- package/dist/core/sync/recovery.d.ts +9 -0
- package/dist/core/sync/recovery.d.ts.map +1 -0
- package/dist/core/sync/room-client.d.ts +50 -0
- package/dist/core/sync/room-client.d.ts.map +1 -0
- package/dist/core/sync/room-peer.d.ts +62 -0
- package/dist/core/sync/room-peer.d.ts.map +1 -0
- package/dist/core/sync/room-protocol.d.ts +130 -0
- package/dist/core/sync/room-protocol.d.ts.map +1 -0
- package/dist/core/sync/room-serve.d.ts +38 -0
- package/dist/core/sync/room-serve.d.ts.map +1 -0
- package/dist/core/sync/room-url.d.ts +4 -0
- package/dist/core/sync/room-url.d.ts.map +1 -0
- package/dist/core/sync/routes.d.ts +4 -1
- package/dist/core/sync/routes.d.ts.map +1 -1
- package/dist/core/sync/seal.d.ts +22 -0
- package/dist/core/sync/seal.d.ts.map +1 -0
- package/dist/core/sync/server.d.ts +7 -0
- package/dist/core/sync/server.d.ts.map +1 -1
- package/dist/core/sync/share-actions.d.ts +55 -6
- package/dist/core/sync/share-actions.d.ts.map +1 -1
- package/dist/core/sync/share-export.d.ts +14 -0
- package/dist/core/sync/share-export.d.ts.map +1 -1
- package/dist/core/sync/share-render.d.ts +9 -1
- package/dist/core/sync/share-render.d.ts.map +1 -1
- package/dist/core/sync/share-serve.d.ts +5 -1
- package/dist/core/sync/share-serve.d.ts.map +1 -1
- package/dist/core/sync/site-channel-reconcile.d.ts +26 -0
- package/dist/core/sync/site-channel-reconcile.d.ts.map +1 -0
- package/dist/core/sync/site-publish-recovery.d.ts +32 -0
- package/dist/core/sync/site-publish-recovery.d.ts.map +1 -0
- package/dist/core/sync/site-reachability.d.ts +13 -0
- package/dist/core/sync/site-reachability.d.ts.map +1 -0
- package/dist/core/sync/sites-routes.d.ts.map +1 -1
- package/dist/core/sync/sites-serve.d.ts +24 -1
- package/dist/core/sync/sites-serve.d.ts.map +1 -1
- package/dist/core/sync/storage-s3-bun.d.ts +1 -24
- package/dist/core/sync/storage-s3-bun.d.ts.map +1 -1
- package/dist/core/sync/storage-s3-sign.d.ts +43 -0
- package/dist/core/sync/storage-s3-sign.d.ts.map +1 -0
- package/dist/core/sync/storage.d.ts +59 -0
- package/dist/core/sync/storage.d.ts.map +1 -1
- package/dist/db-worker.js +206 -76
- package/dist/edge-worker.js +3960 -0
- package/dist/index.js +13175 -6670
- package/dist/index.js.map +87 -41
- package/dist/metahub-sdk.js +10 -1
- package/dist/mh-runtime.js +1 -1
- package/dist/room/do-driver.d.ts +30 -0
- package/dist/room/do-driver.d.ts.map +1 -0
- package/dist/sw.js +10 -1
- package/dist/webui/server/edge-routes.d.ts +3 -0
- package/dist/webui/server/edge-routes.d.ts.map +1 -0
- package/dist/webui/server/json-handler.d.ts +4 -0
- package/dist/webui/server/json-handler.d.ts.map +1 -0
- package/dist/webui/server/routes.d.ts.map +1 -1
- package/dist/webui/server/share-routes.d.ts.map +1 -1
- package/dist/webui/server/site-hosting-routes.d.ts +7 -0
- package/dist/webui/server/site-hosting-routes.d.ts.map +1 -0
- package/dist/webui.js +192 -137
- package/dist/workers/edge-worker.d.ts +66 -0
- package/dist/workers/edge-worker.d.ts.map +1 -0
- package/dist/workers/room.d.ts +34 -0
- package/dist/workers/room.d.ts.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,3960 @@
|
|
|
1
|
+
// src/room/do-driver.ts
|
|
2
|
+
class DoSqlDriver {
|
|
3
|
+
storage;
|
|
4
|
+
changesFnOk = null;
|
|
5
|
+
constructor(storage) {
|
|
6
|
+
this.storage = storage;
|
|
7
|
+
}
|
|
8
|
+
query(sql) {
|
|
9
|
+
const self = this;
|
|
10
|
+
return {
|
|
11
|
+
get(...params) {
|
|
12
|
+
const rows = self.storage.sql.exec(sql, ...normalizeBinds(params)).toArray();
|
|
13
|
+
return rows.length > 0 ? normalizeRow(rows[0]) : null;
|
|
14
|
+
},
|
|
15
|
+
all(...params) {
|
|
16
|
+
return self.storage.sql.exec(sql, ...normalizeBinds(params)).toArray().map(normalizeRow);
|
|
17
|
+
},
|
|
18
|
+
run(...params) {
|
|
19
|
+
const cur = self.storage.sql.exec(sql, ...normalizeBinds(params));
|
|
20
|
+
cur.toArray();
|
|
21
|
+
return { changes: self.changes(cur) };
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
exec(sql) {
|
|
26
|
+
this.storage.sql.exec(sql).toArray();
|
|
27
|
+
}
|
|
28
|
+
transaction(fn) {
|
|
29
|
+
return (...args) => this.storage.transactionSync(() => fn(...args));
|
|
30
|
+
}
|
|
31
|
+
changes(cur) {
|
|
32
|
+
if (this.changesFnOk !== false) {
|
|
33
|
+
try {
|
|
34
|
+
const rows = this.storage.sql.exec("SELECT changes() AS c").toArray();
|
|
35
|
+
this.changesFnOk = true;
|
|
36
|
+
return Number(rows[0]?.c ?? 0);
|
|
37
|
+
} catch {
|
|
38
|
+
this.changesFnOk = false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return cur.rowsWritten;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function normalizeBinds(params) {
|
|
45
|
+
return params.map((p) => {
|
|
46
|
+
if (p === undefined || p === null)
|
|
47
|
+
return null;
|
|
48
|
+
if (typeof p === "boolean")
|
|
49
|
+
return p ? 1 : 0;
|
|
50
|
+
if (typeof p === "bigint")
|
|
51
|
+
return Number(p);
|
|
52
|
+
if (p instanceof Uint8Array) {
|
|
53
|
+
const buf = new ArrayBuffer(p.byteLength);
|
|
54
|
+
new Uint8Array(buf).set(p);
|
|
55
|
+
return buf;
|
|
56
|
+
}
|
|
57
|
+
return p;
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
function normalizeRow(row) {
|
|
61
|
+
for (const k of Object.keys(row)) {
|
|
62
|
+
const v = row[k];
|
|
63
|
+
if (v instanceof ArrayBuffer)
|
|
64
|
+
row[k] = new Uint8Array(v);
|
|
65
|
+
}
|
|
66
|
+
return row;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// src/core/errors.ts
|
|
70
|
+
class MhError extends Error {
|
|
71
|
+
code;
|
|
72
|
+
constructor(code, message) {
|
|
73
|
+
super(message);
|
|
74
|
+
this.code = code;
|
|
75
|
+
this.name = "MhError";
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function errorCode(e) {
|
|
79
|
+
return e instanceof MhError ? e.code : undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// src/core/sync/http-status.ts
|
|
83
|
+
var STATUS = {
|
|
84
|
+
invalid_input: 400,
|
|
85
|
+
not_found: 404,
|
|
86
|
+
ambiguous: 400,
|
|
87
|
+
stale: 409,
|
|
88
|
+
conflict: 409,
|
|
89
|
+
auth: 401,
|
|
90
|
+
rate_limited: 429
|
|
91
|
+
};
|
|
92
|
+
function httpStatusForCode(code) {
|
|
93
|
+
return code ? STATUS[code] ?? 400 : 400;
|
|
94
|
+
}
|
|
95
|
+
function errorBody(e) {
|
|
96
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
97
|
+
const code = errorCode(e);
|
|
98
|
+
return { body: code ? { error: message, code } : { error: message }, status: httpStatusForCode(code) };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// src/core/ids.ts
|
|
102
|
+
var ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz";
|
|
103
|
+
function randomSuffix(len = 6) {
|
|
104
|
+
const bytes = crypto.getRandomValues(new Uint8Array(len));
|
|
105
|
+
let out = "";
|
|
106
|
+
for (const b of bytes)
|
|
107
|
+
out += ALPHABET.charAt(b % ALPHABET.length);
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
function slugify(name, fallback = "item") {
|
|
111
|
+
const slug = name.normalize("NFKD").replace(/[^\x20-\x7E]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 32).replace(/-+$/g, "");
|
|
112
|
+
return slug || fallback;
|
|
113
|
+
}
|
|
114
|
+
var KINDS = new Set([
|
|
115
|
+
"db",
|
|
116
|
+
"prop",
|
|
117
|
+
"rec",
|
|
118
|
+
"doc",
|
|
119
|
+
"blk",
|
|
120
|
+
"site",
|
|
121
|
+
"sf",
|
|
122
|
+
"chan"
|
|
123
|
+
]);
|
|
124
|
+
function newId(kind, name, fallback = kind) {
|
|
125
|
+
return `${kind}_${slugify(name, fallback)}-${randomSuffix()}`;
|
|
126
|
+
}
|
|
127
|
+
function idKind(id) {
|
|
128
|
+
const i = id.indexOf("_");
|
|
129
|
+
if (i <= 0)
|
|
130
|
+
return null;
|
|
131
|
+
const prefix = id.slice(0, i);
|
|
132
|
+
return KINDS.has(prefix) ? prefix : null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// src/core/node.ts
|
|
136
|
+
function getNodeId(db) {
|
|
137
|
+
const row = db.query("SELECT value FROM meta WHERE key = 'node_id'").get();
|
|
138
|
+
if (row)
|
|
139
|
+
return row.value;
|
|
140
|
+
const id = randomSuffix(8);
|
|
141
|
+
db.query("INSERT INTO meta (key, value) VALUES ('node_id', ?)").run(id);
|
|
142
|
+
return id;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// src/core/hlc.ts
|
|
146
|
+
var MAX_HLC_COUNTER = 65535;
|
|
147
|
+
function carryCounter(millis, counter) {
|
|
148
|
+
if (counter <= MAX_HLC_COUNTER)
|
|
149
|
+
return { millis, counter };
|
|
150
|
+
return {
|
|
151
|
+
millis: millis + Math.floor(counter / (MAX_HLC_COUNTER + 1)),
|
|
152
|
+
counter: counter % (MAX_HLC_COUNTER + 1)
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
function formatHlc(h) {
|
|
156
|
+
const { millis, counter } = carryCounter(h.millis, h.counter);
|
|
157
|
+
return `${String(millis).padStart(15, "0")}-${counter.toString(16).padStart(4, "0")}-${h.node}`;
|
|
158
|
+
}
|
|
159
|
+
function parseHlc(s) {
|
|
160
|
+
const i1 = s.indexOf("-");
|
|
161
|
+
const i2 = s.indexOf("-", i1 + 1);
|
|
162
|
+
return {
|
|
163
|
+
millis: Number(s.slice(0, i1)),
|
|
164
|
+
counter: parseInt(s.slice(i1 + 1, i2), 16),
|
|
165
|
+
node: s.slice(i2 + 1)
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
function readLast(db, node) {
|
|
169
|
+
const row = db.query("SELECT value FROM meta WHERE key = 'hlc'").get();
|
|
170
|
+
return row ? parseHlc(row.value) : { millis: 0, counter: 0, node };
|
|
171
|
+
}
|
|
172
|
+
function writeLast(db, h) {
|
|
173
|
+
db.query("INSERT INTO meta (key, value) VALUES ('hlc', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(formatHlc(h));
|
|
174
|
+
}
|
|
175
|
+
function nextHlc(db, node, now = Date.now()) {
|
|
176
|
+
const last = readLast(db, node);
|
|
177
|
+
const millis = Math.max(last.millis, now);
|
|
178
|
+
const counter = millis === last.millis ? last.counter + 1 : 0;
|
|
179
|
+
const h = { ...carryCounter(millis, counter), node };
|
|
180
|
+
writeLast(db, h);
|
|
181
|
+
return formatHlc(h);
|
|
182
|
+
}
|
|
183
|
+
function observeHlc(db, node, remote, now = Date.now()) {
|
|
184
|
+
const last = readLast(db, node);
|
|
185
|
+
const r = parseHlc(remote);
|
|
186
|
+
if (!Number.isFinite(r.millis) || !Number.isFinite(r.counter))
|
|
187
|
+
return;
|
|
188
|
+
const millis = Math.max(last.millis, r.millis, now);
|
|
189
|
+
let counter;
|
|
190
|
+
if (millis === last.millis && millis === r.millis)
|
|
191
|
+
counter = Math.max(last.counter, r.counter) + 1;
|
|
192
|
+
else if (millis === last.millis)
|
|
193
|
+
counter = last.counter + 1;
|
|
194
|
+
else if (millis === r.millis)
|
|
195
|
+
counter = r.counter + 1;
|
|
196
|
+
else
|
|
197
|
+
counter = 0;
|
|
198
|
+
writeLast(db, { ...carryCounter(millis, counter), node });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// src/core/blocks.ts
|
|
202
|
+
function parseDocBlocks(md) {
|
|
203
|
+
if (!md)
|
|
204
|
+
return [];
|
|
205
|
+
const lines = md.replace(/\r\n?/g, `
|
|
206
|
+
`).split(`
|
|
207
|
+
`);
|
|
208
|
+
const blocks = [];
|
|
209
|
+
let cur = [];
|
|
210
|
+
let fenceChar = null;
|
|
211
|
+
let fenceLen = 0;
|
|
212
|
+
const closeBlock = () => {
|
|
213
|
+
if (cur.length) {
|
|
214
|
+
blocks.push({ text: cur.join(`
|
|
215
|
+
`), blankAfter: 0 });
|
|
216
|
+
cur = [];
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
const onBlank = () => {
|
|
220
|
+
closeBlock();
|
|
221
|
+
if (blocks.length)
|
|
222
|
+
blocks[blocks.length - 1].blankAfter++;
|
|
223
|
+
};
|
|
224
|
+
for (const raw of lines) {
|
|
225
|
+
if (fenceChar) {
|
|
226
|
+
cur.push(raw);
|
|
227
|
+
const m = raw.match(/^\s*(`{3,}|~{3,})\s*$/);
|
|
228
|
+
if (m && m[1][0] === fenceChar && m[1].length >= fenceLen) {
|
|
229
|
+
closeBlock();
|
|
230
|
+
fenceChar = null;
|
|
231
|
+
fenceLen = 0;
|
|
232
|
+
}
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
const open = raw.match(/^\s*(`{3,}|~{3,})/);
|
|
236
|
+
if (open) {
|
|
237
|
+
closeBlock();
|
|
238
|
+
fenceChar = open[1][0];
|
|
239
|
+
fenceLen = open[1].length;
|
|
240
|
+
cur.push(raw);
|
|
241
|
+
} else if (raw.trim() === "") {
|
|
242
|
+
onBlank();
|
|
243
|
+
} else {
|
|
244
|
+
cur.push(raw);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
closeBlock();
|
|
248
|
+
for (let i = 0;i < blocks.length - 1; i++)
|
|
249
|
+
blocks[i].blankAfter = Math.max(0, blocks[i].blankAfter - 1);
|
|
250
|
+
return blocks;
|
|
251
|
+
}
|
|
252
|
+
function serializeDocBlocks(blocks) {
|
|
253
|
+
const live = blocks.filter((b) => !!b.text && b.text.length > 0);
|
|
254
|
+
const out = [];
|
|
255
|
+
live.forEach((b, i) => {
|
|
256
|
+
out.push(...b.text.split(`
|
|
257
|
+
`));
|
|
258
|
+
const extra = Math.max(0, b.blankAfter ?? 0);
|
|
259
|
+
if (i < live.length - 1)
|
|
260
|
+
out.push("");
|
|
261
|
+
for (let k = 0;k < extra; k++)
|
|
262
|
+
out.push("");
|
|
263
|
+
});
|
|
264
|
+
return out.join(`
|
|
265
|
+
`);
|
|
266
|
+
}
|
|
267
|
+
function reconcile(oldTexts, newTexts) {
|
|
268
|
+
const keep = lcs(oldTexts, newTexts);
|
|
269
|
+
const keptOld = new Set(keep.map((p) => p[0]));
|
|
270
|
+
const newToOld = new Map(keep.map((p) => [p[1], p[0]]));
|
|
271
|
+
const items = newTexts.map((text, ni) => newToOld.has(ni) ? { keep: newToOld.get(ni) } : { insert: text });
|
|
272
|
+
const deleted = [];
|
|
273
|
+
for (let oi = 0;oi < oldTexts.length; oi++)
|
|
274
|
+
if (!keptOld.has(oi))
|
|
275
|
+
deleted.push(oi);
|
|
276
|
+
return { items, deleted };
|
|
277
|
+
}
|
|
278
|
+
function lcs(a, b) {
|
|
279
|
+
const m = a.length;
|
|
280
|
+
const n = b.length;
|
|
281
|
+
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
|
282
|
+
for (let i2 = m - 1;i2 >= 0; i2--)
|
|
283
|
+
for (let j2 = n - 1;j2 >= 0; j2--)
|
|
284
|
+
dp[i2][j2] = a[i2] === b[j2] ? dp[i2 + 1][j2 + 1] + 1 : Math.max(dp[i2 + 1][j2], dp[i2][j2 + 1]);
|
|
285
|
+
const pairs = [];
|
|
286
|
+
let i = 0;
|
|
287
|
+
let j = 0;
|
|
288
|
+
while (i < m && j < n) {
|
|
289
|
+
if (a[i] === b[j]) {
|
|
290
|
+
pairs.push([i, j]);
|
|
291
|
+
i++;
|
|
292
|
+
j++;
|
|
293
|
+
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
|
294
|
+
i++;
|
|
295
|
+
} else {
|
|
296
|
+
j++;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return pairs;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// src/core/intent-retention.ts
|
|
303
|
+
var DAY_MS = 24 * 60 * 60 * 1000;
|
|
304
|
+
var DROP_ENVELOPE_RETENTION_MS = 30 * DAY_MS;
|
|
305
|
+
var INTENT_REPLAY_WINDOW_MS = DROP_ENVELOPE_RETENTION_MS + DAY_MS;
|
|
306
|
+
var INTENT_RECEIPT_DATASET = "intent_receipts";
|
|
307
|
+
function safeNow(now) {
|
|
308
|
+
return Math.max(0, Math.trunc(Number.isFinite(now) ? now : Date.now()));
|
|
309
|
+
}
|
|
310
|
+
function intentReceiptCutoffHlc(now = Date.now()) {
|
|
311
|
+
const cutoff = Math.max(0, safeNow(now) - INTENT_REPLAY_WINDOW_MS);
|
|
312
|
+
return String(cutoff).padStart(15, "0") + "-";
|
|
313
|
+
}
|
|
314
|
+
function isExpiredIntentReceipt(change, now = Date.now()) {
|
|
315
|
+
return change.dataset === INTENT_RECEIPT_DATASET && change.hlc < intentReceiptCutoffHlc(now);
|
|
316
|
+
}
|
|
317
|
+
function pruneExpiredIntentReceipts(db, now = Date.now()) {
|
|
318
|
+
return db.query("DELETE FROM crdt_changes WHERE dataset = 'intent_receipts' AND hlc < ?").run(intentReceiptCutoffHlc(now)).changes;
|
|
319
|
+
}
|
|
320
|
+
function intentSubmissionExpired(submittedAt, now = Date.now()) {
|
|
321
|
+
if (!Number.isFinite(submittedAt))
|
|
322
|
+
return false;
|
|
323
|
+
return Math.trunc(submittedAt) < safeNow(now) - INTENT_REPLAY_WINDOW_MS;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// src/core/crdt.ts
|
|
327
|
+
var CHANGE_COLS = ["hlc", "node_id", "dataset", "row_id", "col", "value", "txn"];
|
|
328
|
+
var CHANGE_SELECT = CHANGE_COLS.join(", ");
|
|
329
|
+
var currentTxn = null;
|
|
330
|
+
var currentNodeOverride = null;
|
|
331
|
+
function withNodeId(node, fn) {
|
|
332
|
+
const prev = currentNodeOverride;
|
|
333
|
+
currentNodeOverride = node;
|
|
334
|
+
try {
|
|
335
|
+
return fn();
|
|
336
|
+
} finally {
|
|
337
|
+
currentNodeOverride = prev;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
function withChangeGroup(label, fn) {
|
|
341
|
+
if (currentTxn !== null)
|
|
342
|
+
return fn();
|
|
343
|
+
currentTxn = (label ? label + ":" : "") + randomSuffix(8);
|
|
344
|
+
try {
|
|
345
|
+
return fn();
|
|
346
|
+
} finally {
|
|
347
|
+
currentTxn = null;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
function withTxnId(txn, fn) {
|
|
351
|
+
const prev = currentTxn;
|
|
352
|
+
currentTxn = txn;
|
|
353
|
+
try {
|
|
354
|
+
return fn();
|
|
355
|
+
} finally {
|
|
356
|
+
currentTxn = prev;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
function grouped(fn, label = null) {
|
|
360
|
+
return (...args) => withChangeGroup(label, () => fn(...args));
|
|
361
|
+
}
|
|
362
|
+
var DOMAIN = {
|
|
363
|
+
databases: {
|
|
364
|
+
table: "databases",
|
|
365
|
+
cols: new Set(["name", "icon", "meta", "created_hlc", "__deleted"])
|
|
366
|
+
},
|
|
367
|
+
properties: {
|
|
368
|
+
table: "properties",
|
|
369
|
+
cols: new Set(["database_id", "name", "type", "config", "position", "__deleted"])
|
|
370
|
+
},
|
|
371
|
+
documents: {
|
|
372
|
+
table: "documents",
|
|
373
|
+
cols: new Set(["title", "body", "database_id", "parent_id", "created_hlc", "order_key", "__deleted"])
|
|
374
|
+
},
|
|
375
|
+
doc_blocks: {
|
|
376
|
+
table: "doc_blocks",
|
|
377
|
+
cols: new Set(["doc_id", "text", "order_key", "blank_after", "__deleted"])
|
|
378
|
+
},
|
|
379
|
+
sites: {
|
|
380
|
+
table: "sites",
|
|
381
|
+
cols: new Set(["name", "title", "created_hlc", "visibility", "spa", "public_grants", "__deleted"])
|
|
382
|
+
},
|
|
383
|
+
site_channels: {
|
|
384
|
+
table: "site_channels",
|
|
385
|
+
cols: new Set([
|
|
386
|
+
"site_id",
|
|
387
|
+
"audience",
|
|
388
|
+
"hosting",
|
|
389
|
+
"controller_node_id",
|
|
390
|
+
"target_ref",
|
|
391
|
+
"canonical_url",
|
|
392
|
+
"policy_json",
|
|
393
|
+
"desired_state",
|
|
394
|
+
"created_hlc",
|
|
395
|
+
"__deleted"
|
|
396
|
+
])
|
|
397
|
+
},
|
|
398
|
+
site_files: {
|
|
399
|
+
table: "site_files",
|
|
400
|
+
cols: new Set([
|
|
401
|
+
"site_id",
|
|
402
|
+
"path",
|
|
403
|
+
"content_type",
|
|
404
|
+
"encoding",
|
|
405
|
+
"content",
|
|
406
|
+
"created_hlc",
|
|
407
|
+
"__deleted"
|
|
408
|
+
])
|
|
409
|
+
},
|
|
410
|
+
blob_policy: {
|
|
411
|
+
table: "blob_policy",
|
|
412
|
+
cols: new Set(["full_nodes", "redundancy", "__deleted"])
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
var RECORD_META = new Set(["database_id", "created_hlc", "order_key", "__deleted"]);
|
|
416
|
+
var OPLOG_ONLY_DATASETS = new Set([
|
|
417
|
+
INTENT_RECEIPT_DATASET
|
|
418
|
+
]);
|
|
419
|
+
var NOT_NULL_ZERO_COLS = new Set([
|
|
420
|
+
"records:__deleted",
|
|
421
|
+
"databases:__deleted",
|
|
422
|
+
"properties:__deleted",
|
|
423
|
+
"documents:__deleted",
|
|
424
|
+
"doc_blocks:__deleted",
|
|
425
|
+
"doc_blocks:blank_after",
|
|
426
|
+
"sites:__deleted",
|
|
427
|
+
"sites:spa",
|
|
428
|
+
"site_channels:__deleted",
|
|
429
|
+
"site_files:__deleted",
|
|
430
|
+
"blob_policy:__deleted"
|
|
431
|
+
]);
|
|
432
|
+
function decodeMaterializeValue(dataset, col, valueJson) {
|
|
433
|
+
if (valueJson !== null)
|
|
434
|
+
return JSON.parse(valueJson);
|
|
435
|
+
return NOT_NULL_ZERO_COLS.has(`${dataset}:${col}`) ? 0 : null;
|
|
436
|
+
}
|
|
437
|
+
function encodeScalar(val) {
|
|
438
|
+
if (val === null || val === undefined)
|
|
439
|
+
return null;
|
|
440
|
+
if (typeof val === "boolean")
|
|
441
|
+
return val ? 1 : 0;
|
|
442
|
+
if (typeof val === "number")
|
|
443
|
+
return val;
|
|
444
|
+
if (typeof val === "string")
|
|
445
|
+
return val;
|
|
446
|
+
return JSON.stringify(val);
|
|
447
|
+
}
|
|
448
|
+
function ensureRow(db, table, id) {
|
|
449
|
+
db.query(`INSERT OR IGNORE INTO ${table} (id) VALUES (?)`).run(id);
|
|
450
|
+
}
|
|
451
|
+
function isBlockManaged(db, docId) {
|
|
452
|
+
return db.query("SELECT 1 AS x FROM doc_blocks WHERE doc_id = ? LIMIT 1").get(docId) != null;
|
|
453
|
+
}
|
|
454
|
+
function recomputeDocBody(db, docId) {
|
|
455
|
+
const rows = db.query("SELECT text, blank_after FROM doc_blocks WHERE doc_id = ? AND __deleted = 0 ORDER BY order_key, id").all(docId);
|
|
456
|
+
ensureRow(db, "documents", docId);
|
|
457
|
+
db.query("UPDATE documents SET body = ? WHERE id = ?").run(serializeDocBlocks(rows.map((r) => ({ text: r.text, blankAfter: r.blank_after ?? 0 }))), docId);
|
|
458
|
+
}
|
|
459
|
+
function materialize(db, dataset, rowId, col, valueJson) {
|
|
460
|
+
if (OPLOG_ONLY_DATASETS.has(dataset))
|
|
461
|
+
return;
|
|
462
|
+
if (dataset === "records") {
|
|
463
|
+
if (RECORD_META.has(col)) {
|
|
464
|
+
ensureRow(db, "records", rowId);
|
|
465
|
+
const v2 = decodeMaterializeValue("records", col, valueJson);
|
|
466
|
+
db.query(`UPDATE records SET "${col}" = ? WHERE id = ?`).run(encodeScalar(v2), rowId);
|
|
467
|
+
} else {
|
|
468
|
+
ensureRow(db, "records", rowId);
|
|
469
|
+
if (valueJson === null) {
|
|
470
|
+
db.query(`UPDATE records SET data = json_remove(data, '$."' || ? || '"') WHERE id = ?`).run(col, rowId);
|
|
471
|
+
} else {
|
|
472
|
+
db.query(`UPDATE records SET data = json_set(coalesce(data, '{}'), '$."' || ? || '"', json(?)) WHERE id = ?`).run(col, valueJson, rowId);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
const d = DOMAIN[dataset];
|
|
478
|
+
if (!d || !d.cols.has(col))
|
|
479
|
+
return;
|
|
480
|
+
if (dataset === "documents" && col === "body" && isBlockManaged(db, rowId))
|
|
481
|
+
return;
|
|
482
|
+
ensureRow(db, d.table, rowId);
|
|
483
|
+
const v = decodeMaterializeValue(dataset, col, valueJson);
|
|
484
|
+
db.query(`UPDATE ${d.table} SET "${col}" = ? WHERE id = ?`).run(encodeScalar(v), rowId);
|
|
485
|
+
if (dataset === "doc_blocks") {
|
|
486
|
+
const blk = db.query("SELECT doc_id FROM doc_blocks WHERE id = ?").get(rowId);
|
|
487
|
+
if (blk?.doc_id)
|
|
488
|
+
recomputeDocBody(db, blk.doc_id);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
function applyChange(db, c) {
|
|
492
|
+
const inserted = db.query("INSERT OR IGNORE INTO crdt_changes (hlc, node_id, dataset, row_id, col, value, txn) VALUES (?, ?, ?, ?, ?, ?, ?)").run(c.hlc, c.node_id, c.dataset, c.row_id, c.col, c.value, c.txn ?? null).changes > 0;
|
|
493
|
+
const cur = db.query("SELECT MAX(hlc) AS h FROM crdt_changes WHERE dataset = ? AND row_id = ? AND col = ?").get(c.dataset, c.row_id, c.col);
|
|
494
|
+
if (cur.h !== c.hlc)
|
|
495
|
+
return { inserted, winner: false };
|
|
496
|
+
try {
|
|
497
|
+
materialize(db, c.dataset, c.row_id, c.col, c.value);
|
|
498
|
+
} catch (e) {
|
|
499
|
+
if (inserted) {
|
|
500
|
+
db.query("DELETE FROM crdt_changes WHERE dataset = ? AND row_id = ? AND col = ? AND hlc = ?").run(c.dataset, c.row_id, c.col, c.hlc);
|
|
501
|
+
}
|
|
502
|
+
throw e;
|
|
503
|
+
}
|
|
504
|
+
return { inserted, winner: true };
|
|
505
|
+
}
|
|
506
|
+
function emit(db, dataset, rowId, col, value) {
|
|
507
|
+
const node = currentNodeOverride ?? getNodeId(db);
|
|
508
|
+
const change = {
|
|
509
|
+
hlc: nextHlc(db, node),
|
|
510
|
+
node_id: node,
|
|
511
|
+
dataset,
|
|
512
|
+
row_id: rowId,
|
|
513
|
+
col,
|
|
514
|
+
value: value === undefined ? null : JSON.stringify(value),
|
|
515
|
+
txn: currentTxn
|
|
516
|
+
};
|
|
517
|
+
applyChange(db, change);
|
|
518
|
+
return change;
|
|
519
|
+
}
|
|
520
|
+
function ingest(db, changes, opts = {}) {
|
|
521
|
+
const now = opts.now ?? Date.now();
|
|
522
|
+
pruneExpiredIntentReceipts(db, now);
|
|
523
|
+
const node = getNodeId(db);
|
|
524
|
+
let received = 0;
|
|
525
|
+
let poisoned = 0;
|
|
526
|
+
const tx = db.transaction((cs) => {
|
|
527
|
+
for (const c of cs) {
|
|
528
|
+
if (isExpiredIntentReceipt(c, now))
|
|
529
|
+
continue;
|
|
530
|
+
observeHlc(db, node, c.hlc);
|
|
531
|
+
try {
|
|
532
|
+
if (applyChange(db, c).inserted)
|
|
533
|
+
received++;
|
|
534
|
+
} catch (e) {
|
|
535
|
+
poisoned++;
|
|
536
|
+
console.error(`[ingest] skipped a change that failed to apply (dataset=${c.dataset} col=${c.col} row=${c.row_id}): ${e.message}`);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
});
|
|
540
|
+
tx(changes);
|
|
541
|
+
if (poisoned)
|
|
542
|
+
console.error(`[ingest] ${poisoned} change(s) skipped this batch (see above)`);
|
|
543
|
+
return received;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// src/core/indexing.ts
|
|
547
|
+
var AUTO_INDEX_ROW_THRESHOLD = 2000;
|
|
548
|
+
function sanitize(s) {
|
|
549
|
+
return s.replace(/[^a-zA-Z0-9_]/g, "_");
|
|
550
|
+
}
|
|
551
|
+
function lit(s) {
|
|
552
|
+
return s.replace(/'/g, "''");
|
|
553
|
+
}
|
|
554
|
+
function indexName(databaseId, propId) {
|
|
555
|
+
return `idx_rec_${sanitize(databaseId)}_${sanitize(propId)}`;
|
|
556
|
+
}
|
|
557
|
+
function hasIndex(db, databaseId, propId) {
|
|
558
|
+
const row = db.query("SELECT 1 AS ok FROM sqlite_master WHERE type = 'index' AND name = ?").get(indexName(databaseId, propId));
|
|
559
|
+
return row != null;
|
|
560
|
+
}
|
|
561
|
+
function ensurePropIndex(db, databaseId, propId) {
|
|
562
|
+
const name = indexName(databaseId, propId);
|
|
563
|
+
db.exec(`CREATE INDEX IF NOT EXISTS "${name}" ON records (data ->> '${lit(propId)}', created_hlc) ` + `WHERE database_id = '${lit(databaseId)}' AND __deleted = 0`);
|
|
564
|
+
}
|
|
565
|
+
function maybeAutoIndex(db, databaseId, prop) {
|
|
566
|
+
if (hasIndex(db, databaseId, prop.id))
|
|
567
|
+
return;
|
|
568
|
+
if (prop.type !== "relation") {
|
|
569
|
+
const row = db.query("SELECT COUNT(*) AS c FROM records WHERE database_id = ? AND __deleted = 0").get(databaseId);
|
|
570
|
+
if (row.c < AUTO_INDEX_ROW_THRESHOLD)
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
ensurePropIndex(db, databaseId, prop.id);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// src/core/properties.ts
|
|
577
|
+
var PROP_TYPES = new Set([
|
|
578
|
+
"text",
|
|
579
|
+
"number",
|
|
580
|
+
"checkbox",
|
|
581
|
+
"select",
|
|
582
|
+
"multi_select",
|
|
583
|
+
"date",
|
|
584
|
+
"relation",
|
|
585
|
+
"url"
|
|
586
|
+
]);
|
|
587
|
+
var PROPERTY_COLS = ["id", "database_id", "name", "type", "config", "position"];
|
|
588
|
+
var PROPERTY_SELECT = PROPERTY_COLS.join(", ");
|
|
589
|
+
function validateConfig(type, config) {
|
|
590
|
+
if (type === "select" || type === "multi_select") {
|
|
591
|
+
const opts = config?.options;
|
|
592
|
+
if (!Array.isArray(opts) || opts.length === 0 || !opts.every((o) => typeof o === "string"))
|
|
593
|
+
throw new MhError("invalid_input", `${type} requires config.options: string[]`);
|
|
594
|
+
}
|
|
595
|
+
if (type === "relation") {
|
|
596
|
+
if (typeof config?.database !== "string")
|
|
597
|
+
throw new MhError("invalid_input", "relation requires config.database (target database id)");
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
function nextPosition(db, databaseId) {
|
|
601
|
+
const row = db.query("SELECT MAX(position) AS m FROM properties WHERE database_id = ? AND __deleted = 0").get(databaseId);
|
|
602
|
+
return (row.m ?? 0) + 1;
|
|
603
|
+
}
|
|
604
|
+
var addProperty = grouped(function addProperty2(db, databaseId, opts) {
|
|
605
|
+
if (!getDatabase(db, databaseId))
|
|
606
|
+
throw new MhError("not_found", `no such database: ${databaseId}`);
|
|
607
|
+
if (!PROP_TYPES.has(opts.type))
|
|
608
|
+
throw new MhError("invalid_input", `unknown property type: ${opts.type}`);
|
|
609
|
+
validateConfig(opts.type, opts.config);
|
|
610
|
+
const config = opts.indexed ? { ...opts.config ?? {}, indexed: true } : opts.config ?? null;
|
|
611
|
+
const id = newId("prop", opts.name);
|
|
612
|
+
emit(db, "properties", id, "database_id", databaseId);
|
|
613
|
+
emit(db, "properties", id, "name", opts.name);
|
|
614
|
+
emit(db, "properties", id, "type", opts.type);
|
|
615
|
+
emit(db, "properties", id, "config", config);
|
|
616
|
+
emit(db, "properties", id, "position", opts.position ?? nextPosition(db, databaseId));
|
|
617
|
+
if (opts.type === "relation" || opts.indexed === true)
|
|
618
|
+
ensurePropIndex(db, databaseId, id);
|
|
619
|
+
return getProperty(db, id);
|
|
620
|
+
});
|
|
621
|
+
function getProperty(db, id) {
|
|
622
|
+
const row = db.query(`SELECT ${PROPERTY_SELECT} FROM properties WHERE id = ? AND __deleted = 0`).get(id);
|
|
623
|
+
if (!row)
|
|
624
|
+
return null;
|
|
625
|
+
return { ...row, config: row.config ? JSON.parse(row.config) : null };
|
|
626
|
+
}
|
|
627
|
+
function listProperties(db, databaseId) {
|
|
628
|
+
const rows = db.query(`SELECT ${PROPERTY_SELECT} FROM properties WHERE database_id = ? AND __deleted = 0 ORDER BY position`).all(databaseId);
|
|
629
|
+
return rows.map((r) => ({
|
|
630
|
+
...r,
|
|
631
|
+
config: r.config ? JSON.parse(r.config) : null
|
|
632
|
+
}));
|
|
633
|
+
}
|
|
634
|
+
var updateProperty = grouped(function updateProperty2(db, id, fields) {
|
|
635
|
+
const cur = getProperty(db, id);
|
|
636
|
+
if (!cur)
|
|
637
|
+
throw new MhError("not_found", `no such property: ${id}`);
|
|
638
|
+
const typeChanged = fields.type !== undefined && fields.type !== cur.type;
|
|
639
|
+
if (fields.type !== undefined && !PROP_TYPES.has(fields.type))
|
|
640
|
+
throw new MhError("invalid_input", `unknown property type: ${fields.type}`);
|
|
641
|
+
const effectiveType = fields.type ?? cur.type;
|
|
642
|
+
if (fields.config !== undefined)
|
|
643
|
+
validateConfig(effectiveType, fields.config);
|
|
644
|
+
else if (typeChanged)
|
|
645
|
+
validateConfig(fields.type, undefined);
|
|
646
|
+
if (fields.name !== undefined)
|
|
647
|
+
emit(db, "properties", id, "name", fields.name);
|
|
648
|
+
if (fields.type !== undefined)
|
|
649
|
+
emit(db, "properties", id, "type", fields.type);
|
|
650
|
+
if (fields.config !== undefined)
|
|
651
|
+
emit(db, "properties", id, "config", fields.config);
|
|
652
|
+
if (fields.position !== undefined)
|
|
653
|
+
emit(db, "properties", id, "position", fields.position);
|
|
654
|
+
if (typeChanged) {
|
|
655
|
+
const rows = db.query("SELECT id FROM records WHERE database_id = ? AND __deleted = 0 AND data ->> ? IS NOT NULL").all(cur.database_id, id);
|
|
656
|
+
for (const r of rows)
|
|
657
|
+
emit(db, "records", r.id, id, null);
|
|
658
|
+
}
|
|
659
|
+
return getProperty(db, id);
|
|
660
|
+
});
|
|
661
|
+
var removeProperty = grouped(function removeProperty2(db, id) {
|
|
662
|
+
const prop = getProperty(db, id);
|
|
663
|
+
if (!prop)
|
|
664
|
+
return false;
|
|
665
|
+
emit(db, "properties", id, "__deleted", 1);
|
|
666
|
+
const rows = db.query("SELECT id FROM records WHERE database_id = ? AND __deleted = 0 AND data ->> ? IS NOT NULL").all(prop.database_id, id);
|
|
667
|
+
for (const r of rows)
|
|
668
|
+
emit(db, "records", r.id, id, undefined);
|
|
669
|
+
return true;
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
// src/core/resolve.ts
|
|
673
|
+
var SPECS = {
|
|
674
|
+
db: { table: "databases", nameCol: "name", scoped: false, noun: "database" },
|
|
675
|
+
prop: { table: "properties", nameCol: "name", scoped: true, noun: "property" },
|
|
676
|
+
rec: { table: "records", nameCol: null, scoped: true, noun: "record" },
|
|
677
|
+
doc: { table: "documents", nameCol: "title", scoped: true, noun: "document" }
|
|
678
|
+
};
|
|
679
|
+
var PUBLIC_KINDS = ["db", "prop", "rec", "doc"];
|
|
680
|
+
function isPublic(k) {
|
|
681
|
+
return k !== null && k !== "blk";
|
|
682
|
+
}
|
|
683
|
+
var HI = "{";
|
|
684
|
+
function titlePropId(db, databaseId) {
|
|
685
|
+
const row = db.query("SELECT id FROM properties WHERE database_id = ? AND type = 'text' AND __deleted = 0 ORDER BY position LIMIT 1").get(databaseId);
|
|
686
|
+
return row?.id ?? null;
|
|
687
|
+
}
|
|
688
|
+
function queryKind(db, kind, ref, databaseId) {
|
|
689
|
+
const spec = SPECS[kind];
|
|
690
|
+
let nameCol = spec.nameCol;
|
|
691
|
+
if (kind === "rec" && databaseId) {
|
|
692
|
+
const tp = titlePropId(db, databaseId);
|
|
693
|
+
if (tp)
|
|
694
|
+
nameCol = `data ->> '${tp.replace(/'/g, "''")}'`;
|
|
695
|
+
}
|
|
696
|
+
const labelExpr = nameCol ?? "''";
|
|
697
|
+
const conds = [];
|
|
698
|
+
const args = [];
|
|
699
|
+
conds.push("(id >= ? AND id < ?)");
|
|
700
|
+
args.push(ref, ref + HI);
|
|
701
|
+
const typed = `${kind}_${ref}`;
|
|
702
|
+
conds.push("(id >= ? AND id < ?)");
|
|
703
|
+
args.push(typed, typed + HI);
|
|
704
|
+
if (nameCol) {
|
|
705
|
+
conds.push(`lower(${nameCol}) = lower(?)`);
|
|
706
|
+
args.push(ref);
|
|
707
|
+
}
|
|
708
|
+
let sql = `SELECT id, ${labelExpr} AS label FROM ${spec.table} WHERE __deleted = 0 AND (${conds.join(" OR ")})`;
|
|
709
|
+
if (spec.scoped && databaseId) {
|
|
710
|
+
sql += " AND database_id = ?";
|
|
711
|
+
args.push(databaseId);
|
|
712
|
+
}
|
|
713
|
+
const rows = db.query(sql).all(...args);
|
|
714
|
+
return rows.map((r) => ({ kind, id: r.id, label: r.label ?? "" }));
|
|
715
|
+
}
|
|
716
|
+
function resolveCandidates(db, ref, opts = {}) {
|
|
717
|
+
const kinds = opts.kind ? [opts.kind] : isPublic(idKind(ref)) ? [idKind(ref)] : PUBLIC_KINDS;
|
|
718
|
+
const out = [];
|
|
719
|
+
const seen = new Set;
|
|
720
|
+
for (const k of kinds) {
|
|
721
|
+
for (const c of queryKind(db, k, ref, opts.databaseId)) {
|
|
722
|
+
if (seen.has(c.id))
|
|
723
|
+
continue;
|
|
724
|
+
seen.add(c.id);
|
|
725
|
+
out.push(c);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
return out;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// src/core/fracdex.ts
|
|
732
|
+
var DIGITS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
733
|
+
function midpoint(a, b) {
|
|
734
|
+
const zero = DIGITS[0];
|
|
735
|
+
if (b !== null && a >= b)
|
|
736
|
+
throw new Error(`fracdex: ${a} >= ${b}`);
|
|
737
|
+
if (a.endsWith(zero) || b !== null && b.endsWith(zero))
|
|
738
|
+
throw new Error(`fracdex: trailing zero in ${a}/${b}`);
|
|
739
|
+
if (b !== null) {
|
|
740
|
+
let n = 0;
|
|
741
|
+
while ((a[n] ?? zero) === b[n])
|
|
742
|
+
n++;
|
|
743
|
+
if (n > 0)
|
|
744
|
+
return b.slice(0, n) + midpoint(a.slice(n), b.slice(n));
|
|
745
|
+
}
|
|
746
|
+
const da = a === "" ? 0 : DIGITS.indexOf(a[0]);
|
|
747
|
+
const db = b === null ? DIGITS.length : DIGITS.indexOf(b[0]);
|
|
748
|
+
if (db - da > 1) {
|
|
749
|
+
const mid = Math.round(0.5 * (da + db));
|
|
750
|
+
return DIGITS[mid];
|
|
751
|
+
}
|
|
752
|
+
if (b !== null && b.length > 1)
|
|
753
|
+
return b.slice(0, 1);
|
|
754
|
+
return DIGITS[da] + midpoint(a.slice(1), null);
|
|
755
|
+
}
|
|
756
|
+
function keyBetween(a, b) {
|
|
757
|
+
return midpoint(a ?? "", b);
|
|
758
|
+
}
|
|
759
|
+
function keysBetween(a, b, n) {
|
|
760
|
+
if (n <= 0)
|
|
761
|
+
return [];
|
|
762
|
+
if (n === 1)
|
|
763
|
+
return [keyBetween(a, b)];
|
|
764
|
+
if (b === null) {
|
|
765
|
+
let prev = keyBetween(a, null);
|
|
766
|
+
const out = [prev];
|
|
767
|
+
for (let i = 1;i < n; i++) {
|
|
768
|
+
prev = keyBetween(prev, null);
|
|
769
|
+
out.push(prev);
|
|
770
|
+
}
|
|
771
|
+
return out;
|
|
772
|
+
}
|
|
773
|
+
if (a === null) {
|
|
774
|
+
let next = keyBetween(null, b);
|
|
775
|
+
const out = [next];
|
|
776
|
+
for (let i = 1;i < n; i++) {
|
|
777
|
+
next = keyBetween(null, next);
|
|
778
|
+
out.unshift(next);
|
|
779
|
+
}
|
|
780
|
+
return out;
|
|
781
|
+
}
|
|
782
|
+
const mid = Math.floor(n / 2);
|
|
783
|
+
const c = keyBetween(a, b);
|
|
784
|
+
return [...keysBetween(a, c, mid), c, ...keysBetween(c, b, n - mid - 1)];
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// src/core/records.ts
|
|
788
|
+
function isSqlScalar(v) {
|
|
789
|
+
return typeof v === "string" || typeof v === "number" || typeof v === "boolean";
|
|
790
|
+
}
|
|
791
|
+
function sqlFilterValue(v) {
|
|
792
|
+
return typeof v === "boolean" ? v ? 1 : 0 : v;
|
|
793
|
+
}
|
|
794
|
+
function resolveRelation(db, prop, value) {
|
|
795
|
+
const target = prop.config?.database;
|
|
796
|
+
if (!target)
|
|
797
|
+
return value;
|
|
798
|
+
const cands = resolveCandidates(db, value, { kind: "rec", databaseId: target });
|
|
799
|
+
const exact = cands.find((c) => c.id === value);
|
|
800
|
+
if (exact)
|
|
801
|
+
return exact.id;
|
|
802
|
+
if (cands.length === 1)
|
|
803
|
+
return cands[0].id;
|
|
804
|
+
if (cands.length === 0) {
|
|
805
|
+
if (idKind(value) === "rec")
|
|
806
|
+
return value;
|
|
807
|
+
throw new MhError("not_found", `${prop.name}: no such record in target database: ${value}`);
|
|
808
|
+
}
|
|
809
|
+
throw new MhError("ambiguous", `${prop.name}: ambiguous relation "${value}" (${cands.length} matches); use a full id`);
|
|
810
|
+
}
|
|
811
|
+
function coerce(db, prop, value) {
|
|
812
|
+
switch (prop.type) {
|
|
813
|
+
case "text":
|
|
814
|
+
case "url":
|
|
815
|
+
case "date": {
|
|
816
|
+
if (value === null)
|
|
817
|
+
return null;
|
|
818
|
+
if (typeof value !== "string")
|
|
819
|
+
throw new MhError("invalid_input", `${prop.name} expects a string`);
|
|
820
|
+
return value;
|
|
821
|
+
}
|
|
822
|
+
case "number": {
|
|
823
|
+
if (value === null)
|
|
824
|
+
return null;
|
|
825
|
+
const n = typeof value === "number" ? value : Number(value);
|
|
826
|
+
if (!Number.isFinite(n))
|
|
827
|
+
throw new MhError("invalid_input", `${prop.name} expects a number`);
|
|
828
|
+
return n;
|
|
829
|
+
}
|
|
830
|
+
case "checkbox": {
|
|
831
|
+
if (typeof value === "boolean")
|
|
832
|
+
return value;
|
|
833
|
+
if (value === "true" || value === 1)
|
|
834
|
+
return true;
|
|
835
|
+
if (value === "false" || value === 0 || value === null)
|
|
836
|
+
return false;
|
|
837
|
+
throw new MhError("invalid_input", `${prop.name} expects a boolean`);
|
|
838
|
+
}
|
|
839
|
+
case "select": {
|
|
840
|
+
if (value === null)
|
|
841
|
+
return null;
|
|
842
|
+
const v = String(value);
|
|
843
|
+
if (!prop.config?.options?.includes(v))
|
|
844
|
+
throw new MhError("invalid_input", `${prop.name}: '${v}' is not an allowed option`);
|
|
845
|
+
return v;
|
|
846
|
+
}
|
|
847
|
+
case "multi_select": {
|
|
848
|
+
const arr = value === null ? [] : Array.isArray(value) ? value : [value];
|
|
849
|
+
const opts = prop.config?.options ?? [];
|
|
850
|
+
for (const item of arr)
|
|
851
|
+
if (!opts.includes(String(item)))
|
|
852
|
+
throw new MhError("invalid_input", `${prop.name}: '${item}' is not an allowed option`);
|
|
853
|
+
return arr.map(String);
|
|
854
|
+
}
|
|
855
|
+
case "relation": {
|
|
856
|
+
const arr = value === null ? [] : Array.isArray(value) ? value : [value];
|
|
857
|
+
return arr.map((v) => resolveRelation(db, prop, String(v)));
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
function propsByName(props) {
|
|
862
|
+
const byName = new Map;
|
|
863
|
+
for (const p of props) {
|
|
864
|
+
const k = p.name.toLowerCase();
|
|
865
|
+
const list = byName.get(k);
|
|
866
|
+
if (list)
|
|
867
|
+
list.push(p);
|
|
868
|
+
else
|
|
869
|
+
byName.set(k, [p]);
|
|
870
|
+
}
|
|
871
|
+
return byName;
|
|
872
|
+
}
|
|
873
|
+
function resolveData(props, data) {
|
|
874
|
+
const byId = new Map(props.map((p) => [p.id, p]));
|
|
875
|
+
const byName = propsByName(props);
|
|
876
|
+
const out = [];
|
|
877
|
+
for (const [key, value] of Object.entries(data)) {
|
|
878
|
+
let prop = byId.get(key);
|
|
879
|
+
if (!prop) {
|
|
880
|
+
const matches = byName.get(key.toLowerCase()) ?? [];
|
|
881
|
+
if (matches.length > 1)
|
|
882
|
+
throw new MhError("ambiguous", `property name "${key}" matches ${matches.length} properties; use a property id`);
|
|
883
|
+
prop = matches[0];
|
|
884
|
+
}
|
|
885
|
+
if (!prop)
|
|
886
|
+
throw new MhError("not_found", `unknown property: ${key}`);
|
|
887
|
+
out.push({ prop, value });
|
|
888
|
+
}
|
|
889
|
+
return out;
|
|
890
|
+
}
|
|
891
|
+
function prepareRecordCells(db, databaseId, data) {
|
|
892
|
+
return resolveData(listProperties(db, databaseId), data).map(({ prop, value }) => ({
|
|
893
|
+
prop,
|
|
894
|
+
value: coerce(db, prop, value)
|
|
895
|
+
}));
|
|
896
|
+
}
|
|
897
|
+
function rowToRecord(row, props) {
|
|
898
|
+
const raw = JSON.parse(row.data || "{}");
|
|
899
|
+
const byId = new Map(props.map((p) => [p.id, p]));
|
|
900
|
+
const values = {};
|
|
901
|
+
const cells = {};
|
|
902
|
+
for (const [propId, v] of Object.entries(raw)) {
|
|
903
|
+
const p = byId.get(propId);
|
|
904
|
+
if (!p)
|
|
905
|
+
continue;
|
|
906
|
+
values[p.name] = v;
|
|
907
|
+
cells[p.id] = v;
|
|
908
|
+
}
|
|
909
|
+
return { id: row.id, database_id: row.database_id, values, cells };
|
|
910
|
+
}
|
|
911
|
+
function deriveTitle(resolved, fallbackBase) {
|
|
912
|
+
const text = resolved.find((r) => r.prop.type === "text" && typeof r.value === "string" && r.value);
|
|
913
|
+
return newId("rec", text ? String(text.value) : "", fallbackBase);
|
|
914
|
+
}
|
|
915
|
+
function tableExists(db, table) {
|
|
916
|
+
return db.query("SELECT 1 AS ok FROM sqlite_master WHERE type = 'table' AND name = ?").get(table) != null;
|
|
917
|
+
}
|
|
918
|
+
function canEmitOrderKeys(db) {
|
|
919
|
+
return tableExists(db, "meta") && tableExists(db, "crdt_changes");
|
|
920
|
+
}
|
|
921
|
+
function writeRecordOrderKey(db, recordId, orderKey, emitChange) {
|
|
922
|
+
if (emitChange)
|
|
923
|
+
emit(db, "records", recordId, "order_key", orderKey);
|
|
924
|
+
else
|
|
925
|
+
db.query("UPDATE records SET order_key = ? WHERE id = ?").run(orderKey, recordId);
|
|
926
|
+
}
|
|
927
|
+
function lastRecordOrderKey(db, databaseId) {
|
|
928
|
+
const row = db.query(`SELECT order_key FROM records
|
|
929
|
+
WHERE database_id = ? AND __deleted = 0 AND order_key IS NOT NULL
|
|
930
|
+
ORDER BY order_key DESC, id DESC LIMIT 1`).get(databaseId);
|
|
931
|
+
return row?.order_key ?? null;
|
|
932
|
+
}
|
|
933
|
+
function orderedRecordRows(db, databaseId) {
|
|
934
|
+
return db.query(`SELECT id, database_id, created_hlc, order_key FROM records
|
|
935
|
+
WHERE database_id = ? AND __deleted = 0
|
|
936
|
+
ORDER BY order_key IS NULL, order_key, created_hlc, id`).all(databaseId);
|
|
937
|
+
}
|
|
938
|
+
function rebalanceRecordOrderKeys(db, databaseId) {
|
|
939
|
+
const rows = orderedRecordRows(db, databaseId);
|
|
940
|
+
const keys = keysBetween(null, null, rows.length);
|
|
941
|
+
rows.forEach((row, i) => {
|
|
942
|
+
if (row.order_key !== keys[i])
|
|
943
|
+
emit(db, "records", row.id, "order_key", keys[i]);
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
var backfillRecordOrderKeys = grouped(function backfillRecordOrderKeys2(db, databaseId) {
|
|
947
|
+
const emitChange = canEmitOrderKeys(db);
|
|
948
|
+
const dbRows = databaseId ? [{ database_id: databaseId }] : db.query(`SELECT DISTINCT database_id FROM records
|
|
949
|
+
WHERE database_id IS NOT NULL AND __deleted = 0 AND order_key IS NULL`).all();
|
|
950
|
+
for (const d of dbRows) {
|
|
951
|
+
const missing = db.query(`SELECT id FROM records
|
|
952
|
+
WHERE database_id = ? AND __deleted = 0 AND order_key IS NULL
|
|
953
|
+
ORDER BY created_hlc, id`).all(d.database_id);
|
|
954
|
+
if (!missing.length)
|
|
955
|
+
continue;
|
|
956
|
+
const start = lastRecordOrderKey(db, d.database_id);
|
|
957
|
+
const keys = keysBetween(start, null, missing.length);
|
|
958
|
+
missing.forEach((row, i) => writeRecordOrderKey(db, row.id, keys[i], emitChange));
|
|
959
|
+
}
|
|
960
|
+
});
|
|
961
|
+
var createRecordPrepared = grouped(function createRecordPrepared2(db, database, cells) {
|
|
962
|
+
const id = deriveTitle(cells, slugify(database.name, "rec"));
|
|
963
|
+
const orderKey = keyBetween(lastRecordOrderKey(db, database.id), null);
|
|
964
|
+
const first = emit(db, "records", id, "database_id", database.id);
|
|
965
|
+
emit(db, "records", id, "created_hlc", first.hlc);
|
|
966
|
+
emit(db, "records", id, "order_key", orderKey);
|
|
967
|
+
for (const { prop, value } of cells)
|
|
968
|
+
emit(db, "records", id, prop.id, value);
|
|
969
|
+
return getRecord(db, id);
|
|
970
|
+
});
|
|
971
|
+
var createRecord = grouped(function createRecord2(db, databaseId, data) {
|
|
972
|
+
const database = getDatabase(db, databaseId);
|
|
973
|
+
if (!database)
|
|
974
|
+
throw new MhError("not_found", `no such database: ${databaseId}`);
|
|
975
|
+
return createRecordPrepared(db, database, prepareRecordCells(db, database.id, data));
|
|
976
|
+
});
|
|
977
|
+
function getRecord(db, id) {
|
|
978
|
+
const row = db.query("SELECT id, database_id, data FROM records WHERE id = ? AND __deleted = 0").get(id);
|
|
979
|
+
if (!row)
|
|
980
|
+
return null;
|
|
981
|
+
return rowToRecord(row, listProperties(db, row.database_id));
|
|
982
|
+
}
|
|
983
|
+
function jsonKeyLit(propId) {
|
|
984
|
+
return propId.replace(/'/g, "''");
|
|
985
|
+
}
|
|
986
|
+
function listRecords(db, databaseId, opts = {}) {
|
|
987
|
+
const props = listProperties(db, databaseId);
|
|
988
|
+
const resolved = opts.filter && Object.keys(opts.filter).length ? resolveData(props, opts.filter).map(({ prop, value }) => ({
|
|
989
|
+
prop,
|
|
990
|
+
value: coerce(db, prop, value)
|
|
991
|
+
})) : [];
|
|
992
|
+
const sqlFilters = resolved.filter((f) => isSqlScalar(f.value));
|
|
993
|
+
const jsFilters = resolved.filter((f) => !isSqlScalar(f.value));
|
|
994
|
+
let sortProp;
|
|
995
|
+
let sortDesc = false;
|
|
996
|
+
if (opts.sort) {
|
|
997
|
+
let key = opts.sort;
|
|
998
|
+
if (key.startsWith("-")) {
|
|
999
|
+
sortDesc = true;
|
|
1000
|
+
key = key.slice(1);
|
|
1001
|
+
}
|
|
1002
|
+
if (key !== "created" && key !== "created_hlc") {
|
|
1003
|
+
let p = props.find((p2) => p2.id === key);
|
|
1004
|
+
if (!p) {
|
|
1005
|
+
const matches = props.filter((q) => q.name.toLowerCase() === key.toLowerCase());
|
|
1006
|
+
if (matches.length > 1)
|
|
1007
|
+
throw new MhError("ambiguous", `sort field "${key}" matches ${matches.length} properties; use a property id`);
|
|
1008
|
+
p = matches[0];
|
|
1009
|
+
}
|
|
1010
|
+
if (!p)
|
|
1011
|
+
throw new MhError("not_found", `unknown sort field: ${key}`);
|
|
1012
|
+
sortProp = p;
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
for (const f of sqlFilters)
|
|
1016
|
+
maybeAutoIndex(db, databaseId, f.prop);
|
|
1017
|
+
if (sortProp)
|
|
1018
|
+
maybeAutoIndex(db, databaseId, sortProp);
|
|
1019
|
+
const where = ["database_id = ?", "__deleted = 0"];
|
|
1020
|
+
const args = [databaseId];
|
|
1021
|
+
for (const f of sqlFilters) {
|
|
1022
|
+
where.push(`data ->> '${jsonKeyLit(f.prop.id)}' = ?`);
|
|
1023
|
+
args.push(sqlFilterValue(f.value));
|
|
1024
|
+
}
|
|
1025
|
+
const manualOrder = opts.sort == null;
|
|
1026
|
+
const orderExpr = sortProp ? `data ->> '${jsonKeyLit(sortProp.id)}'` : "created_hlc";
|
|
1027
|
+
let sql = `SELECT id, database_id, data FROM records WHERE ${where.join(" AND ")} ` + (manualOrder ? "ORDER BY order_key IS NULL, order_key, id" : `ORDER BY ${orderExpr} ${sortDesc ? "DESC" : "ASC"}, id`);
|
|
1028
|
+
const pushLimit = opts.limit != null && jsFilters.length === 0;
|
|
1029
|
+
if (pushLimit) {
|
|
1030
|
+
sql += " LIMIT ?";
|
|
1031
|
+
args.push(opts.limit);
|
|
1032
|
+
}
|
|
1033
|
+
let rows = db.query(sql).all(...args).map((r) => rowToRecord(r, props));
|
|
1034
|
+
if (jsFilters.length) {
|
|
1035
|
+
rows = rows.filter((r) => jsFilters.every((f) => JSON.stringify(r.cells[f.prop.id] ?? null) === JSON.stringify(f.value)));
|
|
1036
|
+
if (opts.limit != null)
|
|
1037
|
+
rows = rows.slice(0, opts.limit);
|
|
1038
|
+
}
|
|
1039
|
+
return rows;
|
|
1040
|
+
}
|
|
1041
|
+
var moveRecord = grouped(function moveRecord2(db, id, targetId, where) {
|
|
1042
|
+
if (where !== "before" && where !== "after")
|
|
1043
|
+
throw new MhError("invalid_input", `unknown move position: ${where}`);
|
|
1044
|
+
const src = db.query("SELECT id, database_id FROM records WHERE id = ? AND __deleted = 0").get(id);
|
|
1045
|
+
if (!src)
|
|
1046
|
+
throw new MhError("not_found", `no such record: ${id}`);
|
|
1047
|
+
const target = db.query("SELECT id, database_id FROM records WHERE id = ? AND __deleted = 0").get(targetId);
|
|
1048
|
+
if (!target)
|
|
1049
|
+
throw new MhError("not_found", `no such record: ${targetId}`);
|
|
1050
|
+
if (src.database_id !== target.database_id)
|
|
1051
|
+
throw new MhError("invalid_input", "cannot move a record across databases");
|
|
1052
|
+
if (id === targetId)
|
|
1053
|
+
return getRecord(db, id);
|
|
1054
|
+
backfillRecordOrderKeys(db, src.database_id);
|
|
1055
|
+
let rows = orderedRecordRows(db, src.database_id).filter((r) => r.id !== id);
|
|
1056
|
+
let to = rows.findIndex((r) => r.id === targetId);
|
|
1057
|
+
if (to < 0)
|
|
1058
|
+
throw new MhError("not_found", `no such target record: ${targetId}`);
|
|
1059
|
+
let left = where === "before" ? rows[to - 1]?.order_key ?? null : rows[to].order_key;
|
|
1060
|
+
let right = where === "before" ? rows[to].order_key : rows[to + 1]?.order_key ?? null;
|
|
1061
|
+
if (left !== null && right !== null && left >= right) {
|
|
1062
|
+
rebalanceRecordOrderKeys(db, src.database_id);
|
|
1063
|
+
rows = orderedRecordRows(db, src.database_id).filter((r) => r.id !== id);
|
|
1064
|
+
to = rows.findIndex((r) => r.id === targetId);
|
|
1065
|
+
left = where === "before" ? rows[to - 1]?.order_key ?? null : rows[to].order_key;
|
|
1066
|
+
right = where === "before" ? rows[to].order_key : rows[to + 1]?.order_key ?? null;
|
|
1067
|
+
}
|
|
1068
|
+
emit(db, "records", id, "order_key", keyBetween(left, right));
|
|
1069
|
+
return getRecord(db, id);
|
|
1070
|
+
});
|
|
1071
|
+
var updateRecordPrepared = grouped(function updateRecordPrepared2(db, id, cells) {
|
|
1072
|
+
const rec = db.query("SELECT id, database_id FROM records WHERE id = ? AND __deleted = 0").get(id);
|
|
1073
|
+
if (!rec)
|
|
1074
|
+
throw new MhError("not_found", `no such record: ${id}`);
|
|
1075
|
+
for (const { prop, value } of cells)
|
|
1076
|
+
emit(db, "records", id, prop.id, value);
|
|
1077
|
+
return getRecord(db, id);
|
|
1078
|
+
});
|
|
1079
|
+
var updateRecord = grouped(function updateRecord2(db, id, data) {
|
|
1080
|
+
const rec = db.query("SELECT id, database_id FROM records WHERE id = ? AND __deleted = 0").get(id);
|
|
1081
|
+
if (!rec)
|
|
1082
|
+
throw new MhError("not_found", `no such record: ${id}`);
|
|
1083
|
+
return updateRecordPrepared(db, id, prepareRecordCells(db, rec.database_id, data));
|
|
1084
|
+
});
|
|
1085
|
+
var deleteRecord = grouped(function deleteRecord2(db, id) {
|
|
1086
|
+
const rec = db.query("SELECT id FROM records WHERE id = ? AND __deleted = 0").get(id);
|
|
1087
|
+
if (!rec)
|
|
1088
|
+
return false;
|
|
1089
|
+
emit(db, "records", id, "__deleted", 1);
|
|
1090
|
+
return true;
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
// src/core/databases.ts
|
|
1094
|
+
var DATABASE_COLS = ["id", "name", "icon", "meta", "created_hlc"];
|
|
1095
|
+
var DATABASE_SELECT = DATABASE_COLS.join(", ");
|
|
1096
|
+
function rowOut(r) {
|
|
1097
|
+
if (!r)
|
|
1098
|
+
return null;
|
|
1099
|
+
return { ...r, meta: r.meta === null ? null : JSON.parse(r.meta) };
|
|
1100
|
+
}
|
|
1101
|
+
var createDatabase = grouped(function createDatabase2(db, opts) {
|
|
1102
|
+
const id = newId("db", opts.name);
|
|
1103
|
+
const first = emit(db, "databases", id, "name", opts.name);
|
|
1104
|
+
emit(db, "databases", id, "created_hlc", first.hlc);
|
|
1105
|
+
if (opts.icon !== undefined)
|
|
1106
|
+
emit(db, "databases", id, "icon", opts.icon);
|
|
1107
|
+
return getDatabase(db, id);
|
|
1108
|
+
});
|
|
1109
|
+
var updateDatabase = grouped(function updateDatabase2(db, id, fields) {
|
|
1110
|
+
if (!getDatabase(db, id))
|
|
1111
|
+
throw new MhError("not_found", `no such database: ${id}`);
|
|
1112
|
+
if (fields.name !== undefined)
|
|
1113
|
+
emit(db, "databases", id, "name", fields.name);
|
|
1114
|
+
if (fields.icon !== undefined)
|
|
1115
|
+
emit(db, "databases", id, "icon", fields.icon);
|
|
1116
|
+
if (fields.meta !== undefined) {
|
|
1117
|
+
if (fields.meta !== null && (typeof fields.meta !== "object" || Array.isArray(fields.meta)))
|
|
1118
|
+
throw new MhError("invalid_input", "meta must be a JSON object or null");
|
|
1119
|
+
emit(db, "databases", id, "meta", fields.meta);
|
|
1120
|
+
}
|
|
1121
|
+
return getDatabase(db, id);
|
|
1122
|
+
});
|
|
1123
|
+
var duplicateDatabase = grouped(function duplicateDatabase2(db, id, opts = {}) {
|
|
1124
|
+
const src = getDatabase(db, id);
|
|
1125
|
+
if (!src)
|
|
1126
|
+
throw new MhError("not_found", `no such database: ${id}`);
|
|
1127
|
+
const dup = createDatabase(db, {
|
|
1128
|
+
name: opts.name ?? src.name,
|
|
1129
|
+
icon: opts.icon ?? src.icon ?? undefined
|
|
1130
|
+
});
|
|
1131
|
+
if (src.meta !== null) {
|
|
1132
|
+
const { collapsed: _hidden, ...meta } = src.meta;
|
|
1133
|
+
if (Object.keys(meta).length)
|
|
1134
|
+
emit(db, "databases", dup.id, "meta", meta);
|
|
1135
|
+
}
|
|
1136
|
+
const propIdMap = new Map;
|
|
1137
|
+
for (const p of listProperties(db, id)) {
|
|
1138
|
+
const config = p.config?.database === id ? { ...p.config, database: dup.id } : p.config ?? undefined;
|
|
1139
|
+
const np = addProperty(db, dup.id, { name: p.name, type: p.type, config, position: p.position });
|
|
1140
|
+
propIdMap.set(p.id, np.id);
|
|
1141
|
+
}
|
|
1142
|
+
for (const r of listRecords(db, id)) {
|
|
1143
|
+
const data = {};
|
|
1144
|
+
for (const [pid, v] of Object.entries(r.cells)) {
|
|
1145
|
+
const nid = propIdMap.get(pid);
|
|
1146
|
+
if (nid)
|
|
1147
|
+
data[nid] = v;
|
|
1148
|
+
}
|
|
1149
|
+
createRecord(db, dup.id, data);
|
|
1150
|
+
}
|
|
1151
|
+
return getDatabase(db, dup.id);
|
|
1152
|
+
});
|
|
1153
|
+
function getDatabase(db, id) {
|
|
1154
|
+
return rowOut(db.query(`SELECT ${DATABASE_SELECT} FROM databases WHERE id = ? AND __deleted = 0`).get(id));
|
|
1155
|
+
}
|
|
1156
|
+
var deleteDatabase = grouped(function deleteDatabase2(db, id) {
|
|
1157
|
+
if (!getDatabase(db, id))
|
|
1158
|
+
return false;
|
|
1159
|
+
emit(db, "databases", id, "__deleted", 1);
|
|
1160
|
+
const tombstone = (table) => {
|
|
1161
|
+
for (const r of liveChildren(db, table, "database_id", id))
|
|
1162
|
+
emit(db, table, r, "__deleted", 1);
|
|
1163
|
+
};
|
|
1164
|
+
tombstone("properties");
|
|
1165
|
+
tombstone("records");
|
|
1166
|
+
for (const d of liveChildren(db, "documents", "database_id", id))
|
|
1167
|
+
emit(db, "documents", d, "database_id", null);
|
|
1168
|
+
db.query("DELETE FROM meta WHERE key = 'current_db' AND value = ?").run(id);
|
|
1169
|
+
return true;
|
|
1170
|
+
});
|
|
1171
|
+
function liveChildren(db, table, col, parentId) {
|
|
1172
|
+
return db.query(`SELECT id FROM ${table} WHERE ${col} = ? AND __deleted = 0`).all(parentId).map((r) => r.id);
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
// src/core/grants-core.ts
|
|
1176
|
+
var GRANT_OPS = ["read", "create", "update"];
|
|
1177
|
+
function emptySet() {
|
|
1178
|
+
return { v: 1, tables: [] };
|
|
1179
|
+
}
|
|
1180
|
+
function isGrantOp(x) {
|
|
1181
|
+
return typeof x === "string" && GRANT_OPS.includes(x);
|
|
1182
|
+
}
|
|
1183
|
+
function normalizeSet(tables) {
|
|
1184
|
+
const byDb = new Map;
|
|
1185
|
+
for (const t of tables) {
|
|
1186
|
+
const ops = byDb.get(t.db) ?? new Set;
|
|
1187
|
+
for (const op of t.ops)
|
|
1188
|
+
ops.add(op);
|
|
1189
|
+
byDb.set(t.db, ops);
|
|
1190
|
+
}
|
|
1191
|
+
const out = [];
|
|
1192
|
+
for (const [dbId, ops] of byDb) {
|
|
1193
|
+
const ordered = GRANT_OPS.filter((op) => ops.has(op));
|
|
1194
|
+
if (ordered.length)
|
|
1195
|
+
out.push({ db: dbId, ops: ordered });
|
|
1196
|
+
}
|
|
1197
|
+
out.sort((a, b) => a.db < b.db ? -1 : a.db > b.db ? 1 : 0);
|
|
1198
|
+
return { v: 1, tables: out };
|
|
1199
|
+
}
|
|
1200
|
+
function parseGrantSet(raw) {
|
|
1201
|
+
if (!raw)
|
|
1202
|
+
return emptySet();
|
|
1203
|
+
let parsed;
|
|
1204
|
+
try {
|
|
1205
|
+
parsed = JSON.parse(raw);
|
|
1206
|
+
} catch {
|
|
1207
|
+
return emptySet();
|
|
1208
|
+
}
|
|
1209
|
+
try {
|
|
1210
|
+
return validateGrantSetInput(parsed);
|
|
1211
|
+
} catch {
|
|
1212
|
+
return emptySet();
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
function validateGrantSetInput(input) {
|
|
1216
|
+
if (typeof input !== "object" || input === null || Array.isArray(input))
|
|
1217
|
+
throw new MhError("invalid_input", "grants must be an object {v:1, tables:[...]}");
|
|
1218
|
+
const obj = input;
|
|
1219
|
+
if (obj.v !== 1)
|
|
1220
|
+
throw new MhError("invalid_input", "grants version must be 1");
|
|
1221
|
+
if (!Array.isArray(obj.tables))
|
|
1222
|
+
throw new MhError("invalid_input", "grants.tables must be an array");
|
|
1223
|
+
const tables = [];
|
|
1224
|
+
for (const t of obj.tables) {
|
|
1225
|
+
if (typeof t !== "object" || t === null)
|
|
1226
|
+
throw new MhError("invalid_input", "each grant table must be an object {db, ops}");
|
|
1227
|
+
const { db, ops } = t;
|
|
1228
|
+
if (typeof db !== "string" || !db)
|
|
1229
|
+
throw new MhError("invalid_input", "grant table `db` must be a database id");
|
|
1230
|
+
if (!Array.isArray(ops) || !ops.every(isGrantOp))
|
|
1231
|
+
throw new MhError("invalid_input", `grant ops must be a subset of ${GRANT_OPS.join("|")} (delete is never grantable)`);
|
|
1232
|
+
tables.push({ db, ops });
|
|
1233
|
+
}
|
|
1234
|
+
return normalizeSet(tables);
|
|
1235
|
+
}
|
|
1236
|
+
function serializeGrantSet(set) {
|
|
1237
|
+
return JSON.stringify(normalizeSet(set.tables));
|
|
1238
|
+
}
|
|
1239
|
+
function grantFor(set, dbId) {
|
|
1240
|
+
return set.tables.find((t) => t.db === dbId);
|
|
1241
|
+
}
|
|
1242
|
+
function grantAllows(set, dbId, op) {
|
|
1243
|
+
return grantFor(set, dbId)?.ops.includes(op) ?? false;
|
|
1244
|
+
}
|
|
1245
|
+
function deny() {
|
|
1246
|
+
return new MhError("auth", "unauthorized");
|
|
1247
|
+
}
|
|
1248
|
+
function authorizeDbRef(db, set, dbRef, op) {
|
|
1249
|
+
const matches = [];
|
|
1250
|
+
for (const table of set.tables) {
|
|
1251
|
+
const row = getDatabase(db, table.db);
|
|
1252
|
+
if (!row)
|
|
1253
|
+
continue;
|
|
1254
|
+
if (row.id === dbRef || row.name.toLowerCase() === dbRef.toLowerCase())
|
|
1255
|
+
matches.push({ row, table });
|
|
1256
|
+
}
|
|
1257
|
+
const exact = matches.find((m) => m.row.id === dbRef);
|
|
1258
|
+
const hit = exact ?? (matches.length === 1 ? matches[0] : undefined);
|
|
1259
|
+
if (!hit) {
|
|
1260
|
+
if (matches.length > 1)
|
|
1261
|
+
throw new MhError("ambiguous", `"${dbRef}" matches ${matches.length} granted databases; use a database id`);
|
|
1262
|
+
throw deny();
|
|
1263
|
+
}
|
|
1264
|
+
if (!hit.table.ops.includes(op))
|
|
1265
|
+
throw deny();
|
|
1266
|
+
return hit.row;
|
|
1267
|
+
}
|
|
1268
|
+
function authorizeRecord(db, set, recordId, op) {
|
|
1269
|
+
const rec = getRecord(db, recordId);
|
|
1270
|
+
if (!rec || !grantAllows(set, rec.database_id, op))
|
|
1271
|
+
throw deny();
|
|
1272
|
+
return rec;
|
|
1273
|
+
}
|
|
1274
|
+
var GUEST_LIMITS = {
|
|
1275
|
+
maxBodyBytes: 64 * 1024,
|
|
1276
|
+
maxValueBytes: 8 * 1024,
|
|
1277
|
+
maxCells: 64,
|
|
1278
|
+
maxRows: 1e4
|
|
1279
|
+
};
|
|
1280
|
+
function utf8Len(s) {
|
|
1281
|
+
return new TextEncoder().encode(s).byteLength;
|
|
1282
|
+
}
|
|
1283
|
+
function liveRowCount(db, databaseId) {
|
|
1284
|
+
return db.query("SELECT COUNT(*) AS n FROM records WHERE database_id = ? AND __deleted = 0").get(databaseId).n;
|
|
1285
|
+
}
|
|
1286
|
+
function assertRelationAllowed(set, principalKind, prop) {
|
|
1287
|
+
if (principalKind === "public")
|
|
1288
|
+
throw new MhError("invalid_input", `${prop.name}: relation properties are not writable anonymously`);
|
|
1289
|
+
const target = prop.config?.database;
|
|
1290
|
+
if (!target || !grantFor(set, target))
|
|
1291
|
+
throw new MhError("invalid_input", `${prop.name}: relation target database is not in this share's grants`);
|
|
1292
|
+
}
|
|
1293
|
+
function assertGuestPayload(db, set, principal, database, values, opts = {}) {
|
|
1294
|
+
const limits = opts.limits ?? GUEST_LIMITS;
|
|
1295
|
+
const entries = Object.entries(values);
|
|
1296
|
+
if (entries.length > limits.maxCells)
|
|
1297
|
+
throw new MhError("invalid_input", `too many cells (max ${limits.maxCells})`);
|
|
1298
|
+
const props = listProperties(db, database.id);
|
|
1299
|
+
const resolved = resolveData(props, values).map(({ prop, value }) => ({
|
|
1300
|
+
prop,
|
|
1301
|
+
value: coerce(db, prop, value)
|
|
1302
|
+
}));
|
|
1303
|
+
let total = 0;
|
|
1304
|
+
for (const { prop, value } of resolved) {
|
|
1305
|
+
const size = utf8Len(JSON.stringify(value ?? null));
|
|
1306
|
+
if (size > limits.maxValueBytes)
|
|
1307
|
+
throw new MhError("invalid_input", `${prop.name}: value too large (max ${limits.maxValueBytes} bytes)`);
|
|
1308
|
+
total += size;
|
|
1309
|
+
if (prop.type === "relation" && value != null && !(Array.isArray(value) && value.length === 0))
|
|
1310
|
+
assertRelationAllowed(set, principal.kind, prop);
|
|
1311
|
+
}
|
|
1312
|
+
if (total > limits.maxBodyBytes)
|
|
1313
|
+
throw new MhError("invalid_input", `payload too large (max ${limits.maxBodyBytes} bytes)`);
|
|
1314
|
+
return resolved;
|
|
1315
|
+
}
|
|
1316
|
+
function assertGuestCreateCapacity(db, database, limits = GUEST_LIMITS) {
|
|
1317
|
+
if (liveRowCount(db, database.id) >= limits.maxRows)
|
|
1318
|
+
throw new MhError("invalid_input", `table is full (guest writes cap at ${limits.maxRows} rows)`);
|
|
1319
|
+
}
|
|
1320
|
+
var GUEST_META_COLS = new Set(["database_id", "created_hlc", "order_key"]);
|
|
1321
|
+
var GUEST_COERCIBLE_TYPES = new Set([
|
|
1322
|
+
"text",
|
|
1323
|
+
"url",
|
|
1324
|
+
"date",
|
|
1325
|
+
"number",
|
|
1326
|
+
"checkbox",
|
|
1327
|
+
"select",
|
|
1328
|
+
"multi_select",
|
|
1329
|
+
"relation"
|
|
1330
|
+
]);
|
|
1331
|
+
|
|
1332
|
+
// src/core/sync/e2ee.ts
|
|
1333
|
+
function fromB64(b64) {
|
|
1334
|
+
const s = atob(b64);
|
|
1335
|
+
const out = new Uint8Array(s.length);
|
|
1336
|
+
for (let i = 0;i < s.length; i++)
|
|
1337
|
+
out[i] = s.charCodeAt(i);
|
|
1338
|
+
return out;
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
// src/core/shares.ts
|
|
1342
|
+
var SHARE_KINDS = new Set(["doc", "database", "site"]);
|
|
1343
|
+
var PW_ITER = 600000;
|
|
1344
|
+
var PW_HASH_BITS = 256;
|
|
1345
|
+
function ab(u) {
|
|
1346
|
+
const out = new Uint8Array(u.byteLength);
|
|
1347
|
+
out.set(u);
|
|
1348
|
+
return out.buffer;
|
|
1349
|
+
}
|
|
1350
|
+
async function pbkdf2(password, salt) {
|
|
1351
|
+
const base = await crypto.subtle.importKey("raw", ab(new TextEncoder().encode(password)), "PBKDF2", false, ["deriveBits"]);
|
|
1352
|
+
const bits = await crypto.subtle.deriveBits({ name: "PBKDF2", salt: ab(salt), iterations: PW_ITER, hash: "SHA-256" }, base, PW_HASH_BITS);
|
|
1353
|
+
return new Uint8Array(bits);
|
|
1354
|
+
}
|
|
1355
|
+
async function verifyPasswordVerifier(hashB64, saltB64, password) {
|
|
1356
|
+
const expect = fromB64(hashB64);
|
|
1357
|
+
const got = await pbkdf2(password, fromB64(saltB64));
|
|
1358
|
+
return timingSafeEqual(expect, got);
|
|
1359
|
+
}
|
|
1360
|
+
function timingSafeEqual(a, b) {
|
|
1361
|
+
if (a.length !== b.length)
|
|
1362
|
+
return false;
|
|
1363
|
+
let diff = 0;
|
|
1364
|
+
for (let i = 0;i < a.length; i++)
|
|
1365
|
+
diff |= a[i] ^ b[i];
|
|
1366
|
+
return diff === 0;
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
// src/core/hash.ts
|
|
1370
|
+
var FNV32_OFFSET = 2166136261;
|
|
1371
|
+
var FNV64_OFFSET = 0xcbf29ce484222325n;
|
|
1372
|
+
var FNV64_PRIME = 0x100000001b3n;
|
|
1373
|
+
var U64_MASK = 0xffffffffffffffffn;
|
|
1374
|
+
function fnv1a32(text) {
|
|
1375
|
+
let h = FNV32_OFFSET;
|
|
1376
|
+
for (let i = 0;i < text.length; i++) {
|
|
1377
|
+
h ^= text.charCodeAt(i);
|
|
1378
|
+
h = h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24)) >>> 0;
|
|
1379
|
+
}
|
|
1380
|
+
return h >>> 0;
|
|
1381
|
+
}
|
|
1382
|
+
function fnv1a64Hex(text) {
|
|
1383
|
+
let h = FNV64_OFFSET;
|
|
1384
|
+
for (let i = 0;i < text.length; i++) {
|
|
1385
|
+
h ^= BigInt(text.charCodeAt(i));
|
|
1386
|
+
h = h * FNV64_PRIME & U64_MASK;
|
|
1387
|
+
}
|
|
1388
|
+
return h.toString(16).padStart(16, "0");
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
// src/core/access-policy.ts
|
|
1392
|
+
function computeRevision(policy) {
|
|
1393
|
+
const canon = JSON.stringify({
|
|
1394
|
+
a: policy.audience,
|
|
1395
|
+
t: policy.grants.tables,
|
|
1396
|
+
ts: policy.writeGate.turnstile ? { enabled: true, sitekey: policy.writeGate.turnstile.sitekey ?? null } : null,
|
|
1397
|
+
pw: policy.writeGate.password ? { enabled: true, salt: policy.writeGate.password.saltB64 } : null,
|
|
1398
|
+
x: policy.expiresAt ?? null
|
|
1399
|
+
});
|
|
1400
|
+
return fnv1a32(canon);
|
|
1401
|
+
}
|
|
1402
|
+
function policyForRoom(src) {
|
|
1403
|
+
return withRevision({
|
|
1404
|
+
audience: "share",
|
|
1405
|
+
grants: parseGrantSet(src.grants),
|
|
1406
|
+
writeGate: passwordGate(src.pwSalt ?? null, src.pwHash ?? null),
|
|
1407
|
+
limits: GUEST_LIMITS,
|
|
1408
|
+
revision: 0,
|
|
1409
|
+
expiresAt: src.expiresAt ?? null,
|
|
1410
|
+
guestBase: src.guestBase
|
|
1411
|
+
});
|
|
1412
|
+
}
|
|
1413
|
+
function withRevision(p) {
|
|
1414
|
+
p.revision = computeRevision(p);
|
|
1415
|
+
return p;
|
|
1416
|
+
}
|
|
1417
|
+
function passwordGate(saltB64, verifierB64) {
|
|
1418
|
+
return saltB64 && verifierB64 ? { password: { saltB64, verifierB64 } } : {};
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
// src/core/sync/http-util.ts
|
|
1422
|
+
function safeDecode(s) {
|
|
1423
|
+
try {
|
|
1424
|
+
return decodeURIComponent(s);
|
|
1425
|
+
} catch {
|
|
1426
|
+
return null;
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
// src/core/sync/html-escape.ts
|
|
1431
|
+
function escapeHtml(s) {
|
|
1432
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
// src/core/guest-intent.ts
|
|
1436
|
+
var HLC_SKEW_MS = 5 * 60000;
|
|
1437
|
+
var INTENT_ID_RE = /^[A-Za-z0-9._-]{1,64}$/;
|
|
1438
|
+
var RECEIPT_COL = "result";
|
|
1439
|
+
function applyGuestIntent(db, policy, session, intent, opts) {
|
|
1440
|
+
if (!INTENT_ID_RE.test(intent.intentId))
|
|
1441
|
+
throw new MhError("invalid_input", "intentId must be 1-64 safe characters");
|
|
1442
|
+
const acceptedNow = opts.now ?? Date.now();
|
|
1443
|
+
pruneExpiredIntentReceipts(db, acceptedNow);
|
|
1444
|
+
const principal = {
|
|
1445
|
+
kind: policy.audience,
|
|
1446
|
+
guestNode: session.guestNode
|
|
1447
|
+
};
|
|
1448
|
+
const tx = db.transaction(() => {
|
|
1449
|
+
const prepared = prepareIntent(db, policy, principal, intent, opts.clock);
|
|
1450
|
+
const txn = intentTxnPrefix(session.guestNode, intent.intentId) + prepared.fingerprint;
|
|
1451
|
+
const receipt = readReceipt(db, session.guestNode, intent.intentId, acceptedNow);
|
|
1452
|
+
if (receipt) {
|
|
1453
|
+
if (receipt.fingerprint !== prepared.fingerprint || receipt.action !== prepared.action || receipt.databaseId !== prepared.database.id)
|
|
1454
|
+
throw new MhError("conflict", "intentId was already used for a different request");
|
|
1455
|
+
const current = getRecord(db, receipt.recordId);
|
|
1456
|
+
if (!current || current.database_id !== receipt.databaseId)
|
|
1457
|
+
throw new MhError("conflict", "intent result no longer exists");
|
|
1458
|
+
return responseForPolicy(policy, current, receipt.result);
|
|
1459
|
+
}
|
|
1460
|
+
if (opts.clock === "submitted" && intentSubmissionExpired(intent.submittedAt, acceptedNow))
|
|
1461
|
+
throw new MhError("conflict", "intent replay window expired");
|
|
1462
|
+
if (!prepared.targetLive)
|
|
1463
|
+
throw new MhError("auth", "unauthorized");
|
|
1464
|
+
if (opts.clock === "submitted")
|
|
1465
|
+
return applySubmitted(db, policy, principal, intent, prepared, txn, acceptedNow);
|
|
1466
|
+
return applyAuthority(db, policy, principal, intent, prepared, txn, acceptedNow);
|
|
1467
|
+
});
|
|
1468
|
+
return tx();
|
|
1469
|
+
}
|
|
1470
|
+
function prepareIntent(db, policy, principal, intent, clock) {
|
|
1471
|
+
if (typeof intent.payload !== "object" || intent.payload === null || Array.isArray(intent.payload))
|
|
1472
|
+
throw new MhError("invalid_input", "intent payload must be an object");
|
|
1473
|
+
if (intent.action === "createRecord") {
|
|
1474
|
+
if (!intent.table || typeof intent.table !== "string")
|
|
1475
|
+
throw new MhError("invalid_input", "createRecord intent needs a table");
|
|
1476
|
+
const database2 = authorizeDbRef(db, policy.grants, intent.table, "create");
|
|
1477
|
+
const cells2 = assertGuestPayload(db, policy.grants, principal, database2, intent.payload, { limits: policy.limits });
|
|
1478
|
+
return {
|
|
1479
|
+
action: intent.action,
|
|
1480
|
+
database: database2,
|
|
1481
|
+
cells: cells2,
|
|
1482
|
+
targetLive: true,
|
|
1483
|
+
fingerprint: fingerprintIntent(intent.action, database2.id, clock === "submitted" ? intent.recordId ?? null : null, cells2)
|
|
1484
|
+
};
|
|
1485
|
+
}
|
|
1486
|
+
if (intent.action !== "updateRecord")
|
|
1487
|
+
throw new MhError("invalid_input", "unknown guest intent action");
|
|
1488
|
+
if (!intent.recordId || typeof intent.recordId !== "string")
|
|
1489
|
+
throw new MhError("invalid_input", "updateRecord intent needs a record id");
|
|
1490
|
+
if (Object.keys(intent.payload).length === 0)
|
|
1491
|
+
throw new MhError("invalid_input", "updateRecord intent payload must not be empty");
|
|
1492
|
+
const rec = db.query("SELECT database_id, __deleted FROM records WHERE id = ?").get(intent.recordId);
|
|
1493
|
+
if (!rec?.database_id)
|
|
1494
|
+
throw new MhError("auth", "unauthorized");
|
|
1495
|
+
const database = authorizeDbRef(db, policy.grants, rec.database_id, "update");
|
|
1496
|
+
const cells = assertGuestPayload(db, policy.grants, principal, database, intent.payload, { limits: policy.limits });
|
|
1497
|
+
return {
|
|
1498
|
+
action: intent.action,
|
|
1499
|
+
database,
|
|
1500
|
+
cells,
|
|
1501
|
+
targetLive: rec.__deleted === 0,
|
|
1502
|
+
fingerprint: fingerprintIntent(intent.action, database.id, intent.recordId, cells)
|
|
1503
|
+
};
|
|
1504
|
+
}
|
|
1505
|
+
function applyAuthority(db, policy, principal, intent, prepared, txn, acceptedNow) {
|
|
1506
|
+
return withTxnId(txn, () => withNodeId(principal.guestNode, () => {
|
|
1507
|
+
if (intent.action === "createRecord")
|
|
1508
|
+
assertGuestCreateCapacity(db, prepared.database, policy.limits);
|
|
1509
|
+
const current = intent.action === "createRecord" ? createRecordPrepared(db, prepared.database, prepared.cells) : updateRecordPrepared(db, intent.recordId, prepared.cells);
|
|
1510
|
+
const projection = submittedProjection(current.id, prepared.database.id, prepared.cells);
|
|
1511
|
+
writeReceiptAt(db, principal.guestNode, intent.intentId, makeReceipt(intent.action, prepared, current.id, projection), txn, acceptedNow);
|
|
1512
|
+
return responseForPolicy(policy, current, projection);
|
|
1513
|
+
}));
|
|
1514
|
+
}
|
|
1515
|
+
function applySubmitted(db, policy, principal, intent, prepared, txn, now) {
|
|
1516
|
+
const node = principal.guestNode;
|
|
1517
|
+
const millis = clampMillis(intent.submittedAt, now);
|
|
1518
|
+
const cells = [...prepared.cells].sort((a, b) => a.prop.id < b.prop.id ? -1 : a.prop.id > b.prop.id ? 1 : 0);
|
|
1519
|
+
const rowId = intent.action === "createRecord" ? intent.recordId ?? "rec_" + randomSuffix(10) : intent.recordId;
|
|
1520
|
+
if (intent.action === "createRecord" && recordIdEverUsed(db, rowId))
|
|
1521
|
+
throw new MhError("conflict", "record id already exists");
|
|
1522
|
+
if (intent.action === "createRecord")
|
|
1523
|
+
assertGuestCreateCapacity(db, prepared.database, policy.limits);
|
|
1524
|
+
let counter = seedCounter(db, node, millis);
|
|
1525
|
+
const changes = [];
|
|
1526
|
+
const mk = (dataset, targetId, col, value) => {
|
|
1527
|
+
const hlc = formatHlc({ millis, counter, node });
|
|
1528
|
+
counter++;
|
|
1529
|
+
changes.push({
|
|
1530
|
+
hlc,
|
|
1531
|
+
node_id: node,
|
|
1532
|
+
dataset,
|
|
1533
|
+
row_id: targetId,
|
|
1534
|
+
col,
|
|
1535
|
+
value: value == null ? null : JSON.stringify(value),
|
|
1536
|
+
txn
|
|
1537
|
+
});
|
|
1538
|
+
return hlc;
|
|
1539
|
+
};
|
|
1540
|
+
if (intent.action === "createRecord") {
|
|
1541
|
+
const firstHlc = formatHlc({ millis, counter, node });
|
|
1542
|
+
mk("records", rowId, "database_id", prepared.database.id);
|
|
1543
|
+
mk("records", rowId, "created_hlc", firstHlc);
|
|
1544
|
+
}
|
|
1545
|
+
for (const cell of cells)
|
|
1546
|
+
mk("records", rowId, cell.prop.id, cell.value);
|
|
1547
|
+
const projection = submittedProjection(rowId, prepared.database.id, cells);
|
|
1548
|
+
const receipt = makeReceipt(intent.action, prepared, rowId, projection);
|
|
1549
|
+
const receiptMillis = clampMillis(now, now);
|
|
1550
|
+
const receiptCounter = receiptMillis === millis ? counter : seedCounter(db, node, receiptMillis);
|
|
1551
|
+
changes.push({
|
|
1552
|
+
hlc: formatHlc({ millis: receiptMillis, counter: receiptCounter, node }),
|
|
1553
|
+
node_id: node,
|
|
1554
|
+
dataset: INTENT_RECEIPT_DATASET,
|
|
1555
|
+
row_id: receiptRowId(node, intent.intentId),
|
|
1556
|
+
col: RECEIPT_COL,
|
|
1557
|
+
value: JSON.stringify(receipt),
|
|
1558
|
+
txn
|
|
1559
|
+
});
|
|
1560
|
+
ingest(db, changes, { now: receiptMillis });
|
|
1561
|
+
const current = getRecord(db, rowId);
|
|
1562
|
+
if (!current)
|
|
1563
|
+
throw new MhError("conflict", "intent result no longer exists");
|
|
1564
|
+
return responseForPolicy(policy, current, projection);
|
|
1565
|
+
}
|
|
1566
|
+
function responseForPolicy(policy, current, projection) {
|
|
1567
|
+
return grantAllows(policy.grants, current.database_id, "read") ? current : projection;
|
|
1568
|
+
}
|
|
1569
|
+
function submittedProjection(recordId, databaseId, cells) {
|
|
1570
|
+
const values = {};
|
|
1571
|
+
const byId = {};
|
|
1572
|
+
for (const { prop, value } of cells) {
|
|
1573
|
+
values[prop.name] = value;
|
|
1574
|
+
byId[prop.id] = value;
|
|
1575
|
+
}
|
|
1576
|
+
return { id: recordId, database_id: databaseId, values, cells: byId };
|
|
1577
|
+
}
|
|
1578
|
+
function makeReceipt(action, prepared, recordId, result) {
|
|
1579
|
+
return {
|
|
1580
|
+
v: 1,
|
|
1581
|
+
action,
|
|
1582
|
+
fingerprint: prepared.fingerprint,
|
|
1583
|
+
databaseId: prepared.database.id,
|
|
1584
|
+
recordId,
|
|
1585
|
+
result
|
|
1586
|
+
};
|
|
1587
|
+
}
|
|
1588
|
+
function writeReceiptAt(db, guestNode, intentId, receipt, txn, acceptedNow) {
|
|
1589
|
+
const millis = clampMillis(acceptedNow, acceptedNow);
|
|
1590
|
+
const change = {
|
|
1591
|
+
hlc: formatHlc({
|
|
1592
|
+
millis,
|
|
1593
|
+
counter: seedCounter(db, guestNode, millis),
|
|
1594
|
+
node: guestNode
|
|
1595
|
+
}),
|
|
1596
|
+
node_id: guestNode,
|
|
1597
|
+
dataset: INTENT_RECEIPT_DATASET,
|
|
1598
|
+
row_id: receiptRowId(guestNode, intentId),
|
|
1599
|
+
col: RECEIPT_COL,
|
|
1600
|
+
value: JSON.stringify(receipt),
|
|
1601
|
+
txn
|
|
1602
|
+
};
|
|
1603
|
+
ingest(db, [change], { now: millis });
|
|
1604
|
+
}
|
|
1605
|
+
function readReceipt(db, guestNode, intentId, now) {
|
|
1606
|
+
const row = db.query(`SELECT hlc, value, node_id, txn FROM crdt_changes
|
|
1607
|
+
WHERE dataset = ? AND row_id = ? AND col = ?
|
|
1608
|
+
ORDER BY hlc DESC LIMIT 1`).get(INTENT_RECEIPT_DATASET, receiptRowId(guestNode, intentId), RECEIPT_COL);
|
|
1609
|
+
if (!row)
|
|
1610
|
+
return null;
|
|
1611
|
+
if (isExpiredIntentReceipt({ dataset: INTENT_RECEIPT_DATASET, hlc: row.hlc }, now))
|
|
1612
|
+
return null;
|
|
1613
|
+
try {
|
|
1614
|
+
const receipt = JSON.parse(row.value ?? "");
|
|
1615
|
+
if (row.node_id !== guestNode || receipt.v !== 1 || receipt.action !== "createRecord" && receipt.action !== "updateRecord" || typeof receipt.fingerprint !== "string" || !/^[0-9a-f]{16}$/.test(receipt.fingerprint) || row.txn !== intentTxnPrefix(guestNode, intentId) + receipt.fingerprint || typeof receipt.databaseId !== "string" || typeof receipt.recordId !== "string" || !isRecordRow(receipt.result) || receipt.result.id !== receipt.recordId || receipt.result.database_id !== receipt.databaseId)
|
|
1616
|
+
throw new Error("invalid receipt");
|
|
1617
|
+
return receipt;
|
|
1618
|
+
} catch {
|
|
1619
|
+
throw new MhError("conflict", "intent receipt is invalid");
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
function isRecordRow(value) {
|
|
1623
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
1624
|
+
return false;
|
|
1625
|
+
const row = value;
|
|
1626
|
+
return typeof row.id === "string" && typeof row.database_id === "string" && !!row.values && typeof row.values === "object" && !Array.isArray(row.values) && !!row.cells && typeof row.cells === "object" && !Array.isArray(row.cells);
|
|
1627
|
+
}
|
|
1628
|
+
function clampMillis(submittedAt, now) {
|
|
1629
|
+
const safeNow2 = Math.max(0, Math.trunc(Number.isFinite(now) ? now : Date.now()));
|
|
1630
|
+
const ceil = safeNow2 + HLC_SKEW_MS;
|
|
1631
|
+
if (!Number.isFinite(submittedAt))
|
|
1632
|
+
return safeNow2;
|
|
1633
|
+
return Math.max(0, Math.min(Math.trunc(submittedAt), ceil));
|
|
1634
|
+
}
|
|
1635
|
+
function seedCounter(db, node, millis) {
|
|
1636
|
+
const prefix = String(millis).padStart(15, "0") + "-";
|
|
1637
|
+
const row = db.query("SELECT MAX(hlc) AS h FROM crdt_changes WHERE node_id = ? AND hlc LIKE ? || '%'").get(node, prefix);
|
|
1638
|
+
return row.h ? parseHlc(row.h).counter + 1 : 0;
|
|
1639
|
+
}
|
|
1640
|
+
function intentTxnPrefix(guestNode, intentId) {
|
|
1641
|
+
return `intent:${encodeURIComponent(guestNode)}:${intentId}:`;
|
|
1642
|
+
}
|
|
1643
|
+
function receiptRowId(guestNode, intentId) {
|
|
1644
|
+
return `${encodeURIComponent(guestNode)}:${intentId}`;
|
|
1645
|
+
}
|
|
1646
|
+
function recordIdEverUsed(db, rowId) {
|
|
1647
|
+
if (db.query("SELECT 1 AS ok FROM records WHERE id = ? LIMIT 1").get(rowId) != null)
|
|
1648
|
+
return true;
|
|
1649
|
+
return db.query("SELECT 1 AS ok FROM crdt_changes WHERE dataset = 'records' AND row_id = ? LIMIT 1").get(rowId) != null;
|
|
1650
|
+
}
|
|
1651
|
+
function fingerprintIntent(action, databaseId, recordId, cells) {
|
|
1652
|
+
const canonical = stableStringify({
|
|
1653
|
+
action,
|
|
1654
|
+
databaseId,
|
|
1655
|
+
recordId,
|
|
1656
|
+
cells: [...cells].sort((a, b) => a.prop.id < b.prop.id ? -1 : a.prop.id > b.prop.id ? 1 : 0).map((cell) => [cell.prop.id, cell.value])
|
|
1657
|
+
});
|
|
1658
|
+
return fnv1a64Hex(canonical);
|
|
1659
|
+
}
|
|
1660
|
+
function stableStringify(value) {
|
|
1661
|
+
if (Array.isArray(value))
|
|
1662
|
+
return "[" + value.map(stableStringify).join(",") + "]";
|
|
1663
|
+
if (value && typeof value === "object") {
|
|
1664
|
+
const obj = value;
|
|
1665
|
+
return "{" + Object.keys(obj).sort().map((key) => JSON.stringify(key) + ":" + stableStringify(obj[key])).join(",") + "}";
|
|
1666
|
+
}
|
|
1667
|
+
return JSON.stringify(value) ?? "null";
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
// src/core/sync/anti-abuse.ts
|
|
1671
|
+
function timingSafeEq(a, b) {
|
|
1672
|
+
const ab2 = new TextEncoder().encode(a);
|
|
1673
|
+
const bb = new TextEncoder().encode(b);
|
|
1674
|
+
let diff = ab2.length ^ bb.length;
|
|
1675
|
+
const n = Math.max(ab2.length, bb.length);
|
|
1676
|
+
for (let i = 0;i < n; i++)
|
|
1677
|
+
diff |= (ab2[i] ?? 0) ^ (bb[i] ?? 0);
|
|
1678
|
+
return diff === 0;
|
|
1679
|
+
}
|
|
1680
|
+
var siteverify = async (secret, token, ip) => {
|
|
1681
|
+
try {
|
|
1682
|
+
const res = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
|
|
1683
|
+
method: "POST",
|
|
1684
|
+
headers: { "content-type": "application/json" },
|
|
1685
|
+
body: JSON.stringify({ secret, response: token, ...ip ? { remoteip: ip } : {} }),
|
|
1686
|
+
signal: AbortSignal.timeout(5000)
|
|
1687
|
+
});
|
|
1688
|
+
const data = await res.json();
|
|
1689
|
+
return data?.success === true;
|
|
1690
|
+
} catch {
|
|
1691
|
+
return false;
|
|
1692
|
+
}
|
|
1693
|
+
};
|
|
1694
|
+
async function assertAntiAbuse(knobs, req, opts = {}) {
|
|
1695
|
+
if (!knobs)
|
|
1696
|
+
return;
|
|
1697
|
+
if (knobs.turnstileSecret) {
|
|
1698
|
+
const verify = opts.verifyTurnstile ?? siteverify;
|
|
1699
|
+
const token = req.headers.get("x-turnstile-token") ?? "";
|
|
1700
|
+
if (!token || !await verify(knobs.turnstileSecret, token, opts.ip ?? null))
|
|
1701
|
+
throw new MhError("auth", "turnstile verification failed");
|
|
1702
|
+
}
|
|
1703
|
+
if (knobs.passwordVerifier) {
|
|
1704
|
+
const pass = req.headers.get("x-drop-pass") ?? "";
|
|
1705
|
+
if (!pass || !timingSafeEq(pass, knobs.passwordVerifier))
|
|
1706
|
+
throw new MhError("auth", "password required");
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
// src/core/sync/grants-routes.ts
|
|
1711
|
+
var LIST_LIMIT_DEFAULT = 100;
|
|
1712
|
+
var LIST_LIMIT_MAX = 500;
|
|
1713
|
+
function grantedDepsFromPolicy(policy, host) {
|
|
1714
|
+
const gate = policy.writeGate;
|
|
1715
|
+
const gated = policy.audience === "public" && !!(gate.turnstile?.secret || gate.password);
|
|
1716
|
+
return {
|
|
1717
|
+
db: host.db,
|
|
1718
|
+
set: policy.grants,
|
|
1719
|
+
principal: host.principal,
|
|
1720
|
+
allow: host.allow,
|
|
1721
|
+
beforeWrite: gated ? () => assertAntiAbuse({ turnstileSecret: gate.turnstile?.secret, passwordVerifier: gate.password?.verifierB64 }, host.req, { verifyTurnstile: host.verifyTurnstile, ip: host.ip ?? null }) : undefined
|
|
1722
|
+
};
|
|
1723
|
+
}
|
|
1724
|
+
function errJson(e) {
|
|
1725
|
+
const { body, status } = errorBody(e);
|
|
1726
|
+
return Response.json(body, { status });
|
|
1727
|
+
}
|
|
1728
|
+
function tooMany() {
|
|
1729
|
+
return Response.json({ error: "too many requests — slow down", code: "rate_limited" }, { status: 429 });
|
|
1730
|
+
}
|
|
1731
|
+
function needParam(url, key) {
|
|
1732
|
+
const v = url.searchParams.get(key);
|
|
1733
|
+
if (!v)
|
|
1734
|
+
throw new MhError("invalid_input", `missing query param: ${key}`);
|
|
1735
|
+
return v;
|
|
1736
|
+
}
|
|
1737
|
+
async function guestJsonBody(req) {
|
|
1738
|
+
const text = await req.text();
|
|
1739
|
+
if (new TextEncoder().encode(text).byteLength > GUEST_LIMITS.maxBodyBytes)
|
|
1740
|
+
throw new MhError("invalid_input", `payload too large (max ${GUEST_LIMITS.maxBodyBytes} bytes)`);
|
|
1741
|
+
let parsed;
|
|
1742
|
+
try {
|
|
1743
|
+
parsed = JSON.parse(text || "{}");
|
|
1744
|
+
} catch {
|
|
1745
|
+
throw new MhError("invalid_input", "body must be JSON");
|
|
1746
|
+
}
|
|
1747
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
|
1748
|
+
throw new MhError("invalid_input", "body must be a JSON object of {column: value}");
|
|
1749
|
+
return parsed;
|
|
1750
|
+
}
|
|
1751
|
+
function readWriteBody(body) {
|
|
1752
|
+
const wrap = body["$intent"];
|
|
1753
|
+
if (wrap && typeof wrap === "object" && !Array.isArray(wrap) && "values" in body) {
|
|
1754
|
+
const meta = wrap;
|
|
1755
|
+
const values = body["values"];
|
|
1756
|
+
if (typeof values !== "object" || values === null || Array.isArray(values))
|
|
1757
|
+
throw new MhError("invalid_input", "intent values must be a JSON object");
|
|
1758
|
+
if (typeof meta.id !== "string")
|
|
1759
|
+
throw new MhError("invalid_input", "intent id must be a string");
|
|
1760
|
+
return {
|
|
1761
|
+
intentId: meta.id,
|
|
1762
|
+
submittedAt: Number.isFinite(meta.submittedAt) ? meta.submittedAt : Date.now(),
|
|
1763
|
+
values
|
|
1764
|
+
};
|
|
1765
|
+
}
|
|
1766
|
+
return { intentId: "srv_" + randomSuffix(16), submittedAt: Date.now(), values: body };
|
|
1767
|
+
}
|
|
1768
|
+
function authorityPolicy(deps) {
|
|
1769
|
+
return {
|
|
1770
|
+
audience: deps.principal.kind,
|
|
1771
|
+
grants: deps.set,
|
|
1772
|
+
writeGate: {},
|
|
1773
|
+
limits: GUEST_LIMITS,
|
|
1774
|
+
revision: 0,
|
|
1775
|
+
expiresAt: null,
|
|
1776
|
+
guestBase: deps.principal.guestNode
|
|
1777
|
+
};
|
|
1778
|
+
}
|
|
1779
|
+
async function serveGrantedApi(req, sub, deps) {
|
|
1780
|
+
const { db, set, principal } = deps;
|
|
1781
|
+
const url = new URL(req.url);
|
|
1782
|
+
try {
|
|
1783
|
+
if (req.method === "GET") {
|
|
1784
|
+
if (!deps.allow("read"))
|
|
1785
|
+
return tooMany();
|
|
1786
|
+
if (sub === "records") {
|
|
1787
|
+
const dbRow = authorizeDbRef(db, set, needParam(url, "db"), "read");
|
|
1788
|
+
const rawLimit = Number(url.searchParams.get("limit") ?? NaN);
|
|
1789
|
+
const limit = Number.isFinite(rawLimit) ? Math.max(1, Math.min(Math.floor(rawLimit), LIST_LIMIT_MAX)) : LIST_LIMIT_DEFAULT;
|
|
1790
|
+
const sort = url.searchParams.get("sort") ?? undefined;
|
|
1791
|
+
return Response.json(listRecords(db, dbRow.id, { sort, limit }));
|
|
1792
|
+
}
|
|
1793
|
+
if (sub === "record") {
|
|
1794
|
+
return Response.json(authorizeRecord(db, set, needParam(url, "id"), "read"));
|
|
1795
|
+
}
|
|
1796
|
+
if (sub === "properties") {
|
|
1797
|
+
const dbRow = authorizeDbRef(db, set, needParam(url, "db"), "read");
|
|
1798
|
+
return Response.json(listProperties(db, dbRow.id));
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
if (req.method === "POST" && sub === "records") {
|
|
1802
|
+
if (!deps.allow("write"))
|
|
1803
|
+
return tooMany();
|
|
1804
|
+
if (deps.beforeWrite)
|
|
1805
|
+
await deps.beforeWrite();
|
|
1806
|
+
const { intentId, submittedAt, values } = readWriteBody(await guestJsonBody(req));
|
|
1807
|
+
const intent = {
|
|
1808
|
+
intentId,
|
|
1809
|
+
action: "createRecord",
|
|
1810
|
+
table: needParam(url, "db"),
|
|
1811
|
+
payload: values,
|
|
1812
|
+
submittedAt
|
|
1813
|
+
};
|
|
1814
|
+
return Response.json(applyGuestIntent(db, authorityPolicy(deps), { guestNode: principal.guestNode }, intent, {
|
|
1815
|
+
clock: "authority"
|
|
1816
|
+
}));
|
|
1817
|
+
}
|
|
1818
|
+
if (req.method === "PATCH" && sub === "record") {
|
|
1819
|
+
if (!deps.allow("write"))
|
|
1820
|
+
return tooMany();
|
|
1821
|
+
if (deps.beforeWrite)
|
|
1822
|
+
await deps.beforeWrite();
|
|
1823
|
+
const { intentId, submittedAt, values } = readWriteBody(await guestJsonBody(req));
|
|
1824
|
+
const intent = {
|
|
1825
|
+
intentId,
|
|
1826
|
+
action: "updateRecord",
|
|
1827
|
+
recordId: needParam(url, "id"),
|
|
1828
|
+
payload: values,
|
|
1829
|
+
submittedAt
|
|
1830
|
+
};
|
|
1831
|
+
return Response.json(applyGuestIntent(db, authorityPolicy(deps), { guestNode: principal.guestNode }, intent, {
|
|
1832
|
+
clock: "authority"
|
|
1833
|
+
}));
|
|
1834
|
+
}
|
|
1835
|
+
return Response.json({ error: "not found" }, { status: 404 });
|
|
1836
|
+
} catch (e) {
|
|
1837
|
+
return errJson(e);
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
// src/core/sync/rate-limit.ts
|
|
1842
|
+
class FixedWindowLimiter {
|
|
1843
|
+
maxEntries;
|
|
1844
|
+
hits = new Map;
|
|
1845
|
+
constructor(maxEntries = 1e4) {
|
|
1846
|
+
this.maxEntries = maxEntries;
|
|
1847
|
+
}
|
|
1848
|
+
allow(bucket, key, limit, windowMs = 60000, now = Date.now()) {
|
|
1849
|
+
const k = `${bucket}\x00${key}`;
|
|
1850
|
+
const cur = this.hits.get(k);
|
|
1851
|
+
if (!cur || now >= cur.resetAt) {
|
|
1852
|
+
if (!cur && this.hits.size >= this.maxEntries)
|
|
1853
|
+
this.evict(now);
|
|
1854
|
+
this.hits.delete(k);
|
|
1855
|
+
this.hits.set(k, { count: 1, resetAt: now + windowMs });
|
|
1856
|
+
return limit >= 1;
|
|
1857
|
+
}
|
|
1858
|
+
cur.count++;
|
|
1859
|
+
return cur.count <= limit;
|
|
1860
|
+
}
|
|
1861
|
+
evict(now) {
|
|
1862
|
+
for (const [k, v] of this.hits) {
|
|
1863
|
+
if (now >= v.resetAt)
|
|
1864
|
+
this.hits.delete(k);
|
|
1865
|
+
}
|
|
1866
|
+
while (this.hits.size >= this.maxEntries) {
|
|
1867
|
+
const oldest = this.hits.keys().next();
|
|
1868
|
+
if (oldest.done)
|
|
1869
|
+
break;
|
|
1870
|
+
this.hits.delete(oldest.value);
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
get size() {
|
|
1874
|
+
return this.hits.size;
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
var rateLimiter = new FixedWindowLimiter;
|
|
1878
|
+
var SHARE_LIMIT = 240;
|
|
1879
|
+
|
|
1880
|
+
// src/core/sites-core.ts
|
|
1881
|
+
var SITE_COLS = ["id", "name", "title", "created_hlc", "visibility", "spa", "public_grants"];
|
|
1882
|
+
var SITE_SELECT = SITE_COLS.join(", ");
|
|
1883
|
+
var SITE_FILE_COLS = [
|
|
1884
|
+
"id",
|
|
1885
|
+
"site_id",
|
|
1886
|
+
"path",
|
|
1887
|
+
"content_type",
|
|
1888
|
+
"encoding",
|
|
1889
|
+
"content"
|
|
1890
|
+
];
|
|
1891
|
+
var SITE_FILE_SELECT = SITE_FILE_COLS.join(", ");
|
|
1892
|
+
function normalizeSiteName(raw) {
|
|
1893
|
+
const slug = raw.trim().toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
1894
|
+
if (!slug)
|
|
1895
|
+
throw new MhError("invalid_input", `invalid site name: ${JSON.stringify(raw)}`);
|
|
1896
|
+
return slug;
|
|
1897
|
+
}
|
|
1898
|
+
function normalizeSitePath(raw) {
|
|
1899
|
+
const segs = [];
|
|
1900
|
+
for (const seg of raw.split("/")) {
|
|
1901
|
+
if (seg === "" || seg === ".")
|
|
1902
|
+
continue;
|
|
1903
|
+
if (seg === "..")
|
|
1904
|
+
segs.pop();
|
|
1905
|
+
else
|
|
1906
|
+
segs.push(seg);
|
|
1907
|
+
}
|
|
1908
|
+
return segs.join("/");
|
|
1909
|
+
}
|
|
1910
|
+
function getSite(db, id) {
|
|
1911
|
+
return db.query(`SELECT ${SITE_SELECT} FROM sites WHERE id = ? AND __deleted = 0`).get(id);
|
|
1912
|
+
}
|
|
1913
|
+
function getSiteByName(db, name) {
|
|
1914
|
+
let slug;
|
|
1915
|
+
try {
|
|
1916
|
+
slug = normalizeSiteName(name);
|
|
1917
|
+
} catch {
|
|
1918
|
+
return null;
|
|
1919
|
+
}
|
|
1920
|
+
return db.query(`SELECT ${SITE_SELECT} FROM sites WHERE name = ? AND __deleted = 0 ORDER BY created_hlc DESC LIMIT 1`).get(slug);
|
|
1921
|
+
}
|
|
1922
|
+
function fileRowAtPath(db, siteId, p) {
|
|
1923
|
+
return db.query("SELECT content_type, encoding, content FROM site_files WHERE site_id = ? AND path = ? AND __deleted = 0 ORDER BY created_hlc LIMIT 1").get(siteId, p);
|
|
1924
|
+
}
|
|
1925
|
+
function resolveSiteFileRow(db, siteId, path, opts = {}) {
|
|
1926
|
+
const withIndex = path === "" || path.endsWith("/") ? `${path}index.html` : path;
|
|
1927
|
+
const p = normalizeSitePath(withIndex);
|
|
1928
|
+
if (p) {
|
|
1929
|
+
const row = fileRowAtPath(db, siteId, p);
|
|
1930
|
+
if (row)
|
|
1931
|
+
return { row, status: 200 };
|
|
1932
|
+
}
|
|
1933
|
+
if (opts.spa) {
|
|
1934
|
+
const last = path.split("/").pop() ?? "";
|
|
1935
|
+
if (!/\.[a-z0-9]+$/i.test(last)) {
|
|
1936
|
+
const idx = fileRowAtPath(db, siteId, "index.html");
|
|
1937
|
+
if (idx)
|
|
1938
|
+
return { row: idx, status: 200 };
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
const nf = fileRowAtPath(db, siteId, "404.html");
|
|
1942
|
+
if (nf)
|
|
1943
|
+
return { row: nf, status: 404 };
|
|
1944
|
+
return null;
|
|
1945
|
+
}
|
|
1946
|
+
var INLINE_LIMIT = 256 * 1024;
|
|
1947
|
+
function base64ToBytes(b64) {
|
|
1948
|
+
const bin = atob(b64);
|
|
1949
|
+
const bytes = new Uint8Array(bin.length);
|
|
1950
|
+
for (let i = 0;i < bin.length; i++)
|
|
1951
|
+
bytes[i] = bin.charCodeAt(i);
|
|
1952
|
+
return bytes;
|
|
1953
|
+
}
|
|
1954
|
+
function assertVisibility(v) {
|
|
1955
|
+
if (v !== "public" && v !== "private")
|
|
1956
|
+
throw new MhError("invalid_input", `visibility must be "public" or "private", got ${JSON.stringify(v)}`);
|
|
1957
|
+
}
|
|
1958
|
+
var createSite = grouped(function createSite2(db, opts) {
|
|
1959
|
+
const name = normalizeSiteName(opts.name);
|
|
1960
|
+
if (getSiteByName(db, name))
|
|
1961
|
+
throw new MhError("conflict", `site name already exists: ${name}`);
|
|
1962
|
+
if (opts.visibility !== undefined)
|
|
1963
|
+
assertVisibility(opts.visibility);
|
|
1964
|
+
const id = newId("site", name);
|
|
1965
|
+
const first = emit(db, "sites", id, "name", name);
|
|
1966
|
+
emit(db, "sites", id, "created_hlc", first.hlc);
|
|
1967
|
+
if (opts.title !== undefined)
|
|
1968
|
+
emit(db, "sites", id, "title", opts.title);
|
|
1969
|
+
if (opts.visibility !== undefined)
|
|
1970
|
+
emit(db, "sites", id, "visibility", opts.visibility);
|
|
1971
|
+
return getSite(db, id);
|
|
1972
|
+
});
|
|
1973
|
+
var updateSite = grouped(function updateSite2(db, id, opts) {
|
|
1974
|
+
if (!getSite(db, id))
|
|
1975
|
+
throw new MhError("not_found", `no such site: ${id}`);
|
|
1976
|
+
if (opts.visibility !== undefined)
|
|
1977
|
+
assertVisibility(opts.visibility);
|
|
1978
|
+
if (opts.name !== undefined) {
|
|
1979
|
+
const name = normalizeSiteName(opts.name);
|
|
1980
|
+
const dup = getSiteByName(db, name);
|
|
1981
|
+
if (dup && dup.id !== id)
|
|
1982
|
+
throw new MhError("conflict", `site name already exists: ${name}`);
|
|
1983
|
+
emit(db, "sites", id, "name", name);
|
|
1984
|
+
}
|
|
1985
|
+
if (opts.title !== undefined)
|
|
1986
|
+
emit(db, "sites", id, "title", opts.title);
|
|
1987
|
+
if (opts.visibility !== undefined)
|
|
1988
|
+
emit(db, "sites", id, "visibility", opts.visibility);
|
|
1989
|
+
if (opts.spa !== undefined)
|
|
1990
|
+
emit(db, "sites", id, "spa", opts.spa ? 1 : 0);
|
|
1991
|
+
return getSite(db, id);
|
|
1992
|
+
});
|
|
1993
|
+
var setSitePublicGrants = grouped(function setSitePublicGrants2(db, siteId, grants) {
|
|
1994
|
+
if (!getSite(db, siteId))
|
|
1995
|
+
throw new MhError("not_found", `no such site: ${siteId}`);
|
|
1996
|
+
const value = grants == null ? null : serializeGrantSet(validateGrantSetInput(grants));
|
|
1997
|
+
emit(db, "sites", siteId, "public_grants", value);
|
|
1998
|
+
return getSite(db, siteId);
|
|
1999
|
+
});
|
|
2000
|
+
var deleteSite = grouped(function deleteSite2(db, id) {
|
|
2001
|
+
if (!getSite(db, id))
|
|
2002
|
+
return false;
|
|
2003
|
+
const files = db.query("SELECT id FROM site_files WHERE site_id = ? AND __deleted = 0").all(id);
|
|
2004
|
+
for (const f of files)
|
|
2005
|
+
emit(db, "site_files", f.id, "__deleted", 1);
|
|
2006
|
+
emit(db, "sites", id, "__deleted", 1);
|
|
2007
|
+
return true;
|
|
2008
|
+
});
|
|
2009
|
+
function fileIdFor(db, siteId, path) {
|
|
2010
|
+
const row = db.query("SELECT id FROM site_files WHERE site_id = ? AND path = ? ORDER BY created_hlc LIMIT 1").get(siteId, path);
|
|
2011
|
+
return row?.id ?? null;
|
|
2012
|
+
}
|
|
2013
|
+
var deleteFile = grouped(function deleteFile2(db, siteId, path) {
|
|
2014
|
+
const id = fileIdFor(db, siteId, normalizeSitePath(path));
|
|
2015
|
+
if (!id)
|
|
2016
|
+
return false;
|
|
2017
|
+
const live = db.query("SELECT __deleted AS d FROM site_files WHERE id = ?").get(id);
|
|
2018
|
+
if (!live || live.d)
|
|
2019
|
+
return false;
|
|
2020
|
+
emit(db, "site_files", id, "__deleted", 1);
|
|
2021
|
+
return true;
|
|
2022
|
+
});
|
|
2023
|
+
|
|
2024
|
+
// src/core/sync/guest-session.ts
|
|
2025
|
+
function toHex(b) {
|
|
2026
|
+
let s = "";
|
|
2027
|
+
for (const x of b)
|
|
2028
|
+
s += x.toString(16).padStart(2, "0");
|
|
2029
|
+
return s;
|
|
2030
|
+
}
|
|
2031
|
+
async function hmacHex(secret, msg) {
|
|
2032
|
+
const enc = new TextEncoder;
|
|
2033
|
+
const key = await crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
2034
|
+
const sig = await crypto.subtle.sign("HMAC", key, enc.encode(msg));
|
|
2035
|
+
return toHex(new Uint8Array(sig));
|
|
2036
|
+
}
|
|
2037
|
+
function timingSafeStr(a, b) {
|
|
2038
|
+
if (a.length !== b.length)
|
|
2039
|
+
return false;
|
|
2040
|
+
let diff = 0;
|
|
2041
|
+
for (let i = 0;i < a.length; i++)
|
|
2042
|
+
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
2043
|
+
return diff === 0;
|
|
2044
|
+
}
|
|
2045
|
+
function readCookie(header, name) {
|
|
2046
|
+
if (!header)
|
|
2047
|
+
return null;
|
|
2048
|
+
for (const part of header.split(";")) {
|
|
2049
|
+
const [k, ...v] = part.trim().split("=");
|
|
2050
|
+
if (k === name)
|
|
2051
|
+
return decodeURIComponent(v.join("="));
|
|
2052
|
+
}
|
|
2053
|
+
return null;
|
|
2054
|
+
}
|
|
2055
|
+
async function readGuestSession(scope, cookieHeader, now = Date.now()) {
|
|
2056
|
+
const raw = readCookie(cookieHeader, scope.cookieName);
|
|
2057
|
+
if (!raw)
|
|
2058
|
+
return null;
|
|
2059
|
+
const [expStr, sub, mac] = raw.split(".");
|
|
2060
|
+
if (expStr === undefined || sub === undefined || mac === undefined)
|
|
2061
|
+
return null;
|
|
2062
|
+
const exp = Number(expStr);
|
|
2063
|
+
if (!Number.isFinite(exp) || now > exp)
|
|
2064
|
+
return null;
|
|
2065
|
+
const expect = await hmacHex(scope.secret, `${scope.scopeKey}:${exp}:${sub}`);
|
|
2066
|
+
return timingSafeStr(expect, mac) ? { exp, sub } : null;
|
|
2067
|
+
}
|
|
2068
|
+
async function mintGuestSession(scope, opts) {
|
|
2069
|
+
const exp = Date.now() + Math.max(0, opts.ttlMs);
|
|
2070
|
+
const mac = await hmacHex(scope.secret, `${scope.scopeKey}:${exp}:${opts.sub}`);
|
|
2071
|
+
const cookie = `${scope.cookieName}=${exp}.${opts.sub}.${mac}; Path=${opts.path}; HttpOnly; SameSite=Strict; Max-Age=${Math.floor((exp - Date.now()) / 1000)}` + (opts.secure ? "; Secure" : "");
|
|
2072
|
+
return { sub: opts.sub, exp, cookie };
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
// src/core/schema.ts
|
|
2076
|
+
var CORE_SCHEMA = `
|
|
2077
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
2078
|
+
key TEXT PRIMARY KEY,
|
|
2079
|
+
value TEXT
|
|
2080
|
+
);
|
|
2081
|
+
|
|
2082
|
+
-- CRDT oplog: one row per field assignment. Source of truth for sync.
|
|
2083
|
+
-- txn groups the changes of one logical mutation for history rendering
|
|
2084
|
+
-- (nullable: legacy rows and changes from pre-txn peers have none).
|
|
2085
|
+
--
|
|
2086
|
+
-- seq is an explicit AUTOINCREMENT id used as the replication cursor. It must
|
|
2087
|
+
-- be a declared INTEGER PRIMARY KEY (not the implicit rowid): VACUUM renumbers
|
|
2088
|
+
-- the rowids of tables that lack one, which would shift them below a peer's
|
|
2089
|
+
-- stored push/pull cursor and silently strand every write in the gap.
|
|
2090
|
+
-- AUTOINCREMENT additionally guarantees ids are never reused. The
|
|
2091
|
+
-- (dataset,row_id,col,hlc) tuple stays UNIQUE so applyChange's INSERT OR IGNORE
|
|
2092
|
+
-- dedup is unchanged. Legacy databases are rebuilt into this shape by
|
|
2093
|
+
-- migrateCrdtChangesSeq (schema-init.ts).
|
|
2094
|
+
CREATE TABLE IF NOT EXISTS crdt_changes (
|
|
2095
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
2096
|
+
hlc TEXT NOT NULL,
|
|
2097
|
+
node_id TEXT NOT NULL,
|
|
2098
|
+
dataset TEXT NOT NULL,
|
|
2099
|
+
row_id TEXT NOT NULL,
|
|
2100
|
+
col TEXT NOT NULL,
|
|
2101
|
+
value TEXT,
|
|
2102
|
+
txn TEXT,
|
|
2103
|
+
UNIQUE (dataset, row_id, col, hlc)
|
|
2104
|
+
);
|
|
2105
|
+
CREATE INDEX IF NOT EXISTS idx_changes_hlc ON crdt_changes(hlc);
|
|
2106
|
+
-- GuestIntent idempotency probes by txn prefix on every guest write. Keep the
|
|
2107
|
+
-- sparse legacy/null majority out of the index.
|
|
2108
|
+
CREATE INDEX IF NOT EXISTS idx_changes_txn ON crdt_changes(txn)
|
|
2109
|
+
WHERE txn IS NOT NULL;
|
|
2110
|
+
-- Receipt GC is age-based and runs on every intent/sync maintenance path.
|
|
2111
|
+
CREATE INDEX IF NOT EXISTS idx_changes_intent_receipt_hlc ON crdt_changes(hlc)
|
|
2112
|
+
WHERE dataset = 'intent_receipts';
|
|
2113
|
+
-- migrateSiteChannels replays only the tail past its watermark on every open;
|
|
2114
|
+
-- partial index keeps that probe off a full-table scan (site_channels rows are
|
|
2115
|
+
-- a tiny minority of the oplog).
|
|
2116
|
+
CREATE INDEX IF NOT EXISTS idx_changes_site_channels_seq ON crdt_changes(seq)
|
|
2117
|
+
WHERE dataset = 'site_channels';
|
|
2118
|
+
-- Serves history's "every block ever attached to this doc" lookup. Partial so
|
|
2119
|
+
-- it never indexes the large values other datasets carry (e.g. site file bodies).
|
|
2120
|
+
CREATE INDEX IF NOT EXISTS idx_changes_docref ON crdt_changes(value)
|
|
2121
|
+
WHERE dataset = 'doc_blocks' AND col = 'doc_id';
|
|
2122
|
+
|
|
2123
|
+
-- Per-peer replication cursors (crdt_changes.seq: AUTOINCREMENT, monotonic in
|
|
2124
|
+
-- insertion order and stable across VACUUM, so no change is ever skipped
|
|
2125
|
+
-- regardless of HLC/clock skew). Outbound side of
|
|
2126
|
+
-- a pairing: "token" is the credential the *remote* issued to us (sent as a
|
|
2127
|
+
-- Bearer header when we sync to them). See migratePeers in db.ts for the columns
|
|
2128
|
+
-- added to legacy databases.
|
|
2129
|
+
--
|
|
2130
|
+
-- kind selects the transport: 'http' (POST /sync to a peer server, the classic
|
|
2131
|
+
-- path) or 's3' (an S3-compatible bucket used as dumb store-and-forward — see
|
|
2132
|
+
-- sync/storage.ts). For 's3' peers url is a synthetic key (s3://<bucket>/<prefix>)
|
|
2133
|
+
-- and config holds the bucket settings + credentials + master key as JSON.
|
|
2134
|
+
-- Like the rest of peers, config is local-only and never enters the CRDT oplog.
|
|
2135
|
+
CREATE TABLE IF NOT EXISTS peers (
|
|
2136
|
+
url TEXT PRIMARY KEY,
|
|
2137
|
+
pull_cursor INTEGER NOT NULL DEFAULT 0,
|
|
2138
|
+
push_cursor INTEGER NOT NULL DEFAULT 0,
|
|
2139
|
+
token TEXT,
|
|
2140
|
+
label TEXT,
|
|
2141
|
+
node_id TEXT,
|
|
2142
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
2143
|
+
last_sync_at INTEGER,
|
|
2144
|
+
last_success_at INTEGER,
|
|
2145
|
+
last_status TEXT,
|
|
2146
|
+
last_error TEXT,
|
|
2147
|
+
kind TEXT NOT NULL DEFAULT 'http',
|
|
2148
|
+
config TEXT
|
|
2149
|
+
);
|
|
2150
|
+
|
|
2151
|
+
-- Per-(storage-peer, remote-node) pull progress: the key of the last oplog
|
|
2152
|
+
-- segment we consumed from that node's bucket prefix. HTTP peers use the single
|
|
2153
|
+
-- pull_cursor above; storage peers need one cursor per remote node because each
|
|
2154
|
+
-- node publishes its own segment stream under its own prefix. last_key is a
|
|
2155
|
+
-- bucket object key (lexicographically ordered = chronological), so LIST
|
|
2156
|
+
-- start-after resumes exactly where we left off.
|
|
2157
|
+
CREATE TABLE IF NOT EXISTS storage_cursors (
|
|
2158
|
+
peer_url TEXT NOT NULL,
|
|
2159
|
+
node_id TEXT NOT NULL,
|
|
2160
|
+
last_key TEXT,
|
|
2161
|
+
PRIMARY KEY (peer_url, node_id)
|
|
2162
|
+
);
|
|
2163
|
+
|
|
2164
|
+
-- Owner-side shadow of a room partition (sync/partition.ts): which (dataset,
|
|
2165
|
+
-- row_id) pairs THIS node last told a given room peer about. Node-local like
|
|
2166
|
+
-- peers/storage_cursors — deliberately absent from crdt.ts's DOMAIN map, never
|
|
2167
|
+
-- in the oplog, never synced (each device keeps its own shadow; cross-device
|
|
2168
|
+
-- shadow splits are healed by the room protocol's need_baseline/digest layers,
|
|
2169
|
+
-- not by syncing shadows). peer_key is the peers.url of a kind='room' row.
|
|
2170
|
+
CREATE TABLE IF NOT EXISTS room_rows (
|
|
2171
|
+
peer_key TEXT NOT NULL,
|
|
2172
|
+
dataset TEXT NOT NULL,
|
|
2173
|
+
row_id TEXT NOT NULL,
|
|
2174
|
+
PRIMARY KEY (peer_key, dataset, row_id)
|
|
2175
|
+
);
|
|
2176
|
+
|
|
2177
|
+
-- Inbound side of a pairing: credentials *we* issued to remote devices. A peer
|
|
2178
|
+
-- presents one of these on /sync; acceptsSyncToken() checks it alongside the
|
|
2179
|
+
-- managed master token. Revoke by deleting the row.
|
|
2180
|
+
CREATE TABLE IF NOT EXISTS peer_grants (
|
|
2181
|
+
token TEXT PRIMARY KEY,
|
|
2182
|
+
peer_url TEXT,
|
|
2183
|
+
node_id TEXT,
|
|
2184
|
+
created_at INTEGER
|
|
2185
|
+
);
|
|
2186
|
+
|
|
2187
|
+
-- One-time pairing codes minted by "mh config peer code" / POST /api/pair/new.
|
|
2188
|
+
-- Short-lived and single-use: redeemed during the pairing handshake, then the
|
|
2189
|
+
-- two devices exchange durable peer_grants. used/exp gate redemption.
|
|
2190
|
+
CREATE TABLE IF NOT EXISTS pairing_codes (
|
|
2191
|
+
code TEXT PRIMARY KEY,
|
|
2192
|
+
exp INTEGER NOT NULL,
|
|
2193
|
+
used INTEGER NOT NULL DEFAULT 0,
|
|
2194
|
+
created_at INTEGER
|
|
2195
|
+
);
|
|
2196
|
+
|
|
2197
|
+
-- Public capability links to a doc / database / site. Like peers and blob_cache
|
|
2198
|
+
-- this is node-local and deliberately ABSENT from crdt.ts's DOMAIN map, so it
|
|
2199
|
+
-- never enters the oplog and never syncs — a share is a property of the access
|
|
2200
|
+
-- point that minted it, not of the workspace. slug is the unguessable capability
|
|
2201
|
+
-- in the URL (/share/<slug>). transport selects how it's served: 'server' (the
|
|
2202
|
+
-- token-exempt /share endpoint on this node) or 's3' (a presigned static export
|
|
2203
|
+
-- under <prefix>/shares/<slug> in a bucket). permission 'edit' is server-only:
|
|
2204
|
+
-- a presigned static object can't accept writes. guest_node_id attributes every
|
|
2205
|
+
-- edit made through an edit-share to one synthetic node (see crdt.ts withNodeId).
|
|
2206
|
+
-- pw_hash/pw_salt: server path stores a PBKDF2 verifier; the s3 path is E2EE so
|
|
2207
|
+
-- only pw_salt travels (in the link) and decryption happens in the viewer.
|
|
2208
|
+
CREATE TABLE IF NOT EXISTS shares (
|
|
2209
|
+
slug TEXT PRIMARY KEY,
|
|
2210
|
+
kind TEXT NOT NULL, -- 'doc' | 'database' | 'site'
|
|
2211
|
+
target_id TEXT NOT NULL, -- documents.id / databases.id / sites.id
|
|
2212
|
+
permission TEXT NOT NULL, -- 'view' | 'edit'
|
|
2213
|
+
transport TEXT NOT NULL, -- 'server' | 's3'
|
|
2214
|
+
pw_salt TEXT, -- base64; null = no password
|
|
2215
|
+
pw_hash TEXT, -- base64 PBKDF2 verifier (server path only)
|
|
2216
|
+
expires_at INTEGER, -- epoch ms; null = never
|
|
2217
|
+
guest_node_id TEXT, -- synthetic node id for edit shares (null for view)
|
|
2218
|
+
served_base TEXT, -- reachable base URL chosen at creation (link / source label)
|
|
2219
|
+
s3_peer_url TEXT, -- vestigial (s3 shares live in the bucket, not here)
|
|
2220
|
+
s3_object_prefix TEXT, -- vestigial
|
|
2221
|
+
s3_presign_exp INTEGER, -- vestigial
|
|
2222
|
+
s3_key_b64 TEXT, -- vestigial
|
|
2223
|
+
created_at INTEGER NOT NULL,
|
|
2224
|
+
request_id TEXT, -- idempotency key for remote share creation
|
|
2225
|
+
grants TEXT -- serialized GrantSet for /share/<slug>/api/* (node-local like the
|
|
2226
|
+
-- rest of the row: revoking the share kills the grants with it)
|
|
2227
|
+
);
|
|
2228
|
+
CREATE INDEX IF NOT EXISTS idx_shares_target ON shares(target_id);
|
|
2229
|
+
|
|
2230
|
+
CREATE TABLE IF NOT EXISTS databases (
|
|
2231
|
+
id TEXT PRIMARY KEY,
|
|
2232
|
+
name TEXT,
|
|
2233
|
+
icon TEXT,
|
|
2234
|
+
meta TEXT, -- JSON: generic replicated metadata (e.g. UI flags), one LWW register
|
|
2235
|
+
created_hlc TEXT,
|
|
2236
|
+
__deleted INTEGER NOT NULL DEFAULT 0
|
|
2237
|
+
);
|
|
2238
|
+
|
|
2239
|
+
CREATE TABLE IF NOT EXISTS properties (
|
|
2240
|
+
id TEXT PRIMARY KEY,
|
|
2241
|
+
database_id TEXT,
|
|
2242
|
+
name TEXT,
|
|
2243
|
+
type TEXT,
|
|
2244
|
+
config TEXT,
|
|
2245
|
+
position REAL,
|
|
2246
|
+
__deleted INTEGER NOT NULL DEFAULT 0
|
|
2247
|
+
);
|
|
2248
|
+
CREATE INDEX IF NOT EXISTS idx_properties_db ON properties(database_id);
|
|
2249
|
+
|
|
2250
|
+
-- One row per record. Field cells live in the data JSON object, keyed by
|
|
2251
|
+
-- property id (rename-safe). Each (record,property) is still an independent
|
|
2252
|
+
-- CRDT register in the oplog; materialize() folds the winner into data via
|
|
2253
|
+
-- json_set/json_remove. Hot fields get expression indexes on data->>'propid'
|
|
2254
|
+
-- (see indexing.ts) so filter/sort/limit push down to SQL.
|
|
2255
|
+
CREATE TABLE IF NOT EXISTS records (
|
|
2256
|
+
id TEXT PRIMARY KEY,
|
|
2257
|
+
database_id TEXT,
|
|
2258
|
+
created_hlc TEXT,
|
|
2259
|
+
order_key TEXT,
|
|
2260
|
+
data TEXT NOT NULL DEFAULT '{}',
|
|
2261
|
+
__deleted INTEGER NOT NULL DEFAULT 0
|
|
2262
|
+
);
|
|
2263
|
+
CREATE INDEX IF NOT EXISTS idx_records_db_hlc ON records(database_id, created_hlc);
|
|
2264
|
+
|
|
2265
|
+
CREATE TABLE IF NOT EXISTS documents (
|
|
2266
|
+
id TEXT PRIMARY KEY,
|
|
2267
|
+
title TEXT,
|
|
2268
|
+
body TEXT, -- materialized cache: serialized from doc_blocks
|
|
2269
|
+
database_id TEXT,
|
|
2270
|
+
parent_id TEXT,
|
|
2271
|
+
created_hlc TEXT,
|
|
2272
|
+
order_key TEXT, -- fractional index among siblings (same parent_id); ties broken by created_hlc,id
|
|
2273
|
+
__deleted INTEGER NOT NULL DEFAULT 0
|
|
2274
|
+
);
|
|
2275
|
+
|
|
2276
|
+
-- Document body as an ordered list of blocks; each (block,field) is an
|
|
2277
|
+
-- independent CRDT register so concurrent edits to different blocks merge.
|
|
2278
|
+
-- order_key is a fractional index (ties broken by id) giving display order.
|
|
2279
|
+
CREATE TABLE IF NOT EXISTS doc_blocks (
|
|
2280
|
+
id TEXT PRIMARY KEY,
|
|
2281
|
+
doc_id TEXT,
|
|
2282
|
+
text TEXT,
|
|
2283
|
+
order_key TEXT,
|
|
2284
|
+
blank_after INTEGER NOT NULL DEFAULT 0, -- extra blank lines kept after this block (user spacing)
|
|
2285
|
+
__deleted INTEGER NOT NULL DEFAULT 0
|
|
2286
|
+
);
|
|
2287
|
+
CREATE INDEX IF NOT EXISTS idx_doc_blocks_doc ON doc_blocks(doc_id);
|
|
2288
|
+
|
|
2289
|
+
-- A named static site (Supabase-Storage-style bucket). Served at /sites/<name>/.
|
|
2290
|
+
CREATE TABLE IF NOT EXISTS sites (
|
|
2291
|
+
id TEXT PRIMARY KEY,
|
|
2292
|
+
name TEXT, -- URL slug; resolved by getSiteByName
|
|
2293
|
+
title TEXT,
|
|
2294
|
+
created_hlc TEXT,
|
|
2295
|
+
visibility TEXT, -- exactly 'public' = token-free serving; anything else = private (isSitePublic)
|
|
2296
|
+
spa INTEGER NOT NULL DEFAULT 0, -- 1 = extension-less misses fall back to index.html
|
|
2297
|
+
public_grants TEXT, -- serialized GrantSet for anonymous /sites/<name>/api/*; synced register,
|
|
2298
|
+
-- readers MUST go through parseGrantSet (default-deny on any junk)
|
|
2299
|
+
__deleted INTEGER NOT NULL DEFAULT 0
|
|
2300
|
+
);
|
|
2301
|
+
|
|
2302
|
+
-- Synced desired-state control plane for each way a site is reachable. Access
|
|
2303
|
+
-- policy and hosting are separate axes; controller_node_id names the node that
|
|
2304
|
+
-- owns any node-local secret/cleanup work. Runtime success is NOT synced here
|
|
2305
|
+
-- (see node-local site_channel_observations below).
|
|
2306
|
+
CREATE TABLE IF NOT EXISTS site_channels (
|
|
2307
|
+
id TEXT PRIMARY KEY,
|
|
2308
|
+
site_id TEXT,
|
|
2309
|
+
audience TEXT, -- 'public' | 'link'
|
|
2310
|
+
hosting TEXT, -- 'device' | 'edge'
|
|
2311
|
+
controller_node_id TEXT,
|
|
2312
|
+
target_ref TEXT, -- serving node id (device) / capability slug (edge)
|
|
2313
|
+
canonical_url TEXT,
|
|
2314
|
+
policy_json TEXT,
|
|
2315
|
+
desired_state TEXT, -- 'active' | 'revoked'
|
|
2316
|
+
created_hlc TEXT,
|
|
2317
|
+
__deleted INTEGER NOT NULL DEFAULT 0
|
|
2318
|
+
);
|
|
2319
|
+
CREATE INDEX IF NOT EXISTS idx_site_channels_site ON site_channels(site_id);
|
|
2320
|
+
|
|
2321
|
+
-- What THIS node has actually observed while applying a channel's desired
|
|
2322
|
+
-- state. Never replicated: readiness/errors are node-relative facts.
|
|
2323
|
+
CREATE TABLE IF NOT EXISTS site_channel_observations (
|
|
2324
|
+
channel_id TEXT PRIMARY KEY,
|
|
2325
|
+
status TEXT NOT NULL,
|
|
2326
|
+
last_verified_at INTEGER,
|
|
2327
|
+
last_error TEXT
|
|
2328
|
+
);
|
|
2329
|
+
|
|
2330
|
+
-- One row per file in a site. content holds inline utf8/base64 text, or a blob
|
|
2331
|
+
-- hash (see cache.ts) when encoding = 'blob'. (site_id, path) maps to a stable
|
|
2332
|
+
-- id so re-uploads merge as a CRDT register instead of duplicating.
|
|
2333
|
+
CREATE TABLE IF NOT EXISTS site_files (
|
|
2334
|
+
id TEXT PRIMARY KEY,
|
|
2335
|
+
site_id TEXT,
|
|
2336
|
+
path TEXT,
|
|
2337
|
+
content_type TEXT,
|
|
2338
|
+
encoding TEXT, -- 'utf8' | 'base64' | 'blob'
|
|
2339
|
+
content TEXT,
|
|
2340
|
+
created_hlc TEXT,
|
|
2341
|
+
__deleted INTEGER NOT NULL DEFAULT 0
|
|
2342
|
+
);
|
|
2343
|
+
CREATE INDEX IF NOT EXISTS idx_site_files_site ON site_files(site_id);
|
|
2344
|
+
|
|
2345
|
+
-- Node-local blob cache ledger (NOT synced, like peers/storage_cursors): one row
|
|
2346
|
+
-- per blob byte-file held in cache.ts's content-addressed store (~/.metahub/cache).
|
|
2347
|
+
-- Gives the bare hash-named files the metadata clearing/stats need. hash is the
|
|
2348
|
+
-- canonical content key (sha256 truncated to 32 hex; legacy 64-hex coexist).
|
|
2349
|
+
-- pending = bytes produced HERE not yet confirmed flushed to a durable anchor
|
|
2350
|
+
-- (bucket) — the only blobs a device must protect; never auto-evicted/cleared.
|
|
2351
|
+
-- pending=0 means a flushed production OR an acquired cache, both safely
|
|
2352
|
+
-- clearable (re-fetchable). This local fact replaces the old SYNCED blob_presence
|
|
2353
|
+
-- table — clearing is now purely local/offline. See blobs.ts / 22-blob-sync.
|
|
2354
|
+
CREATE TABLE IF NOT EXISTS blob_cache (
|
|
2355
|
+
hash TEXT PRIMARY KEY,
|
|
2356
|
+
size INTEGER NOT NULL,
|
|
2357
|
+
content_type TEXT,
|
|
2358
|
+
last_access INTEGER,
|
|
2359
|
+
pinned INTEGER NOT NULL DEFAULT 0, -- node-local: never auto-evicted / cleared
|
|
2360
|
+
pending INTEGER NOT NULL DEFAULT 1, -- produced here, not yet flushed to anchor
|
|
2361
|
+
anchored INTEGER NOT NULL DEFAULT 0 -- node-local: last verify confirmed a designated anchor holds it (per redundancy). Drives isClearable; reset on policy change
|
|
2362
|
+
);
|
|
2363
|
+
|
|
2364
|
+
-- Node-local ledger of write-inbox envelopes the ingest isolation layer refused
|
|
2365
|
+
-- (drop-pull.ts): the reason is recorded here, then the envelope is deleted from
|
|
2366
|
+
-- the edge host (invalid mail never occupies inbox capacity). NOT synced — like
|
|
2367
|
+
-- peers/blob_cache, this is a fact about what THIS node saw, not workspace data.
|
|
2368
|
+
CREATE TABLE IF NOT EXISTS drop_rejects (
|
|
2369
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
2370
|
+
drop_id TEXT NOT NULL,
|
|
2371
|
+
envelope_id TEXT,
|
|
2372
|
+
reason TEXT NOT NULL,
|
|
2373
|
+
created_at INTEGER NOT NULL
|
|
2374
|
+
);
|
|
2375
|
+
|
|
2376
|
+
-- Synced workspace policy (single row "default"): which nodes are designated
|
|
2377
|
+
-- full-blob libraries — the durable anchor in no-bucket topologies (they pull
|
|
2378
|
+
-- everything and never clear). full_nodes is a JSON array of node_id; redundancy
|
|
2379
|
+
-- is retained but unused since clearing moved to the local pending model.
|
|
2380
|
+
CREATE TABLE IF NOT EXISTS blob_policy (
|
|
2381
|
+
id TEXT PRIMARY KEY, -- always "default"
|
|
2382
|
+
full_nodes TEXT, -- JSON array of node_id
|
|
2383
|
+
redundancy TEXT, -- 'all' | 'any'
|
|
2384
|
+
__deleted INTEGER NOT NULL DEFAULT 0
|
|
2385
|
+
);
|
|
2386
|
+
`;
|
|
2387
|
+
var FTS_SCHEMA = `
|
|
2388
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS search_fts USING fts5(
|
|
2389
|
+
kind UNINDEXED,
|
|
2390
|
+
id UNINDEXED,
|
|
2391
|
+
database_id UNINDEXED,
|
|
2392
|
+
title,
|
|
2393
|
+
body
|
|
2394
|
+
);
|
|
2395
|
+
`;
|
|
2396
|
+
|
|
2397
|
+
// src/core/documents.ts
|
|
2398
|
+
var DOCUMENT_COLS = [
|
|
2399
|
+
"id",
|
|
2400
|
+
"title",
|
|
2401
|
+
"body",
|
|
2402
|
+
"database_id",
|
|
2403
|
+
"parent_id",
|
|
2404
|
+
"created_hlc",
|
|
2405
|
+
"order_key"
|
|
2406
|
+
];
|
|
2407
|
+
var DOCUMENT_SELECT = DOCUMENT_COLS.join(", ");
|
|
2408
|
+
var DOC_SUMMARY_COLS = [
|
|
2409
|
+
"id",
|
|
2410
|
+
"title",
|
|
2411
|
+
"database_id",
|
|
2412
|
+
"parent_id",
|
|
2413
|
+
"created_hlc",
|
|
2414
|
+
"order_key"
|
|
2415
|
+
];
|
|
2416
|
+
var DOC_SUMMARY_SELECT = DOC_SUMMARY_COLS.join(", ");
|
|
2417
|
+
function liveBlocks(db, docId) {
|
|
2418
|
+
return db.query("SELECT id, text, order_key, blank_after FROM doc_blocks WHERE doc_id = ? AND __deleted = 0 ORDER BY order_key, id").all(docId);
|
|
2419
|
+
}
|
|
2420
|
+
function makeBlockId(text) {
|
|
2421
|
+
return newId("blk", text.split(`
|
|
2422
|
+
`, 1)[0] ?? "");
|
|
2423
|
+
}
|
|
2424
|
+
function emitBlock(db, docId, fields) {
|
|
2425
|
+
const id = makeBlockId(fields.text);
|
|
2426
|
+
emit(db, "doc_blocks", id, "doc_id", docId);
|
|
2427
|
+
emit(db, "doc_blocks", id, "order_key", fields.order_key);
|
|
2428
|
+
if (fields.blankAfter)
|
|
2429
|
+
emit(db, "doc_blocks", id, "blank_after", fields.blankAfter);
|
|
2430
|
+
emit(db, "doc_blocks", id, "text", fields.text);
|
|
2431
|
+
}
|
|
2432
|
+
function insertBlocks(db, docId, blocks, after, before) {
|
|
2433
|
+
const keys = keysBetween(after, before, blocks.length);
|
|
2434
|
+
blocks.forEach((b, i) => emitBlock(db, docId, { text: b.text, order_key: keys[i], blankAfter: b.blankAfter }));
|
|
2435
|
+
}
|
|
2436
|
+
function ensureBlocks(db, docId) {
|
|
2437
|
+
if (db.query("SELECT 1 AS x FROM doc_blocks WHERE doc_id = ? LIMIT 1").get(docId))
|
|
2438
|
+
return;
|
|
2439
|
+
const row = db.query("SELECT body FROM documents WHERE id = ?").get(docId);
|
|
2440
|
+
const blocks = parseDocBlocks(row?.body ?? "");
|
|
2441
|
+
if (blocks.length)
|
|
2442
|
+
insertBlocks(db, docId, blocks, null, null);
|
|
2443
|
+
}
|
|
2444
|
+
function reconcileBody(db, docId, body) {
|
|
2445
|
+
const old = liveBlocks(db, docId);
|
|
2446
|
+
const next = parseDocBlocks(body);
|
|
2447
|
+
const plan = reconcile(old.map((b) => b.text), next.map((b) => b.text));
|
|
2448
|
+
for (const oi of plan.deleted)
|
|
2449
|
+
emit(db, "doc_blocks", old[oi].id, "__deleted", 1);
|
|
2450
|
+
const { items } = plan;
|
|
2451
|
+
let i = 0;
|
|
2452
|
+
while (i < items.length) {
|
|
2453
|
+
const it = items[i];
|
|
2454
|
+
if ("keep" in it) {
|
|
2455
|
+
const oldB = old[it.keep];
|
|
2456
|
+
const want = next[i].blankAfter;
|
|
2457
|
+
if ((oldB.blank_after ?? 0) !== want)
|
|
2458
|
+
emit(db, "doc_blocks", oldB.id, "blank_after", want);
|
|
2459
|
+
i++;
|
|
2460
|
+
continue;
|
|
2461
|
+
}
|
|
2462
|
+
let j = i;
|
|
2463
|
+
while (j < items.length && "insert" in items[j])
|
|
2464
|
+
j++;
|
|
2465
|
+
const left = i > 0 ? old[items[i - 1].keep].order_key : null;
|
|
2466
|
+
const right = j < items.length ? old[items[j].keep].order_key : null;
|
|
2467
|
+
insertBlocks(db, docId, next.slice(i, j), left, right);
|
|
2468
|
+
i = j;
|
|
2469
|
+
}
|
|
2470
|
+
}
|
|
2471
|
+
function documentVersion(db, id) {
|
|
2472
|
+
const row = db.query(`SELECT MAX(hlc) AS h FROM crdt_changes
|
|
2473
|
+
WHERE (dataset = 'documents' AND row_id = ?)
|
|
2474
|
+
OR (dataset = 'doc_blocks' AND row_id IN (SELECT id FROM doc_blocks WHERE doc_id = ?))`).get(id, id);
|
|
2475
|
+
return row.h ?? "";
|
|
2476
|
+
}
|
|
2477
|
+
var createDocument = grouped(function createDocument2(db, opts) {
|
|
2478
|
+
const id = newId("doc", opts.title);
|
|
2479
|
+
const first = emit(db, "documents", id, "title", opts.title);
|
|
2480
|
+
emit(db, "documents", id, "created_hlc", first.hlc);
|
|
2481
|
+
if (opts.database_id !== undefined)
|
|
2482
|
+
emit(db, "documents", id, "database_id", opts.database_id);
|
|
2483
|
+
if (opts.parent_id !== undefined)
|
|
2484
|
+
emit(db, "documents", id, "parent_id", opts.parent_id);
|
|
2485
|
+
placeInSiblings(db, id, opts.parent_id ?? null);
|
|
2486
|
+
if (opts.body !== undefined) {
|
|
2487
|
+
const blocks = parseDocBlocks(opts.body);
|
|
2488
|
+
if (blocks.length)
|
|
2489
|
+
insertBlocks(db, id, blocks, null, null);
|
|
2490
|
+
}
|
|
2491
|
+
return getDocument(db, id);
|
|
2492
|
+
});
|
|
2493
|
+
function getDocument(db, id) {
|
|
2494
|
+
return db.query(`SELECT ${DOCUMENT_SELECT} FROM documents WHERE id = ? AND __deleted = 0`).get(id);
|
|
2495
|
+
}
|
|
2496
|
+
var ORDER_BY = "ORDER BY order_key IS NULL, order_key, created_hlc, id";
|
|
2497
|
+
function siblingWhere(parentId) {
|
|
2498
|
+
return parentId === null ? { clause: "parent_id IS NULL", args: [] } : { clause: "parent_id = ?", args: [parentId] };
|
|
2499
|
+
}
|
|
2500
|
+
function canEmitOrderKeys2(db) {
|
|
2501
|
+
return tableExists2(db, "meta") && tableExists2(db, "crdt_changes");
|
|
2502
|
+
}
|
|
2503
|
+
function tableExists2(db, table) {
|
|
2504
|
+
return db.query("SELECT 1 AS ok FROM sqlite_master WHERE type = 'table' AND name = ?").get(table) != null;
|
|
2505
|
+
}
|
|
2506
|
+
function lastSiblingOrderKey(db, parentId, excludeId) {
|
|
2507
|
+
const w = siblingWhere(parentId);
|
|
2508
|
+
const exclude = excludeId ? " AND id <> ?" : "";
|
|
2509
|
+
const row = db.query(`SELECT order_key FROM documents WHERE ${w.clause}${exclude}
|
|
2510
|
+
AND __deleted = 0 AND order_key IS NOT NULL
|
|
2511
|
+
ORDER BY order_key DESC, id DESC LIMIT 1`).get(...w.args, ...excludeId ? [excludeId] : []);
|
|
2512
|
+
return row?.order_key ?? null;
|
|
2513
|
+
}
|
|
2514
|
+
function orderedSiblings(db, parentId) {
|
|
2515
|
+
const w = siblingWhere(parentId);
|
|
2516
|
+
return db.query(`SELECT id, order_key FROM documents WHERE ${w.clause} AND __deleted = 0 ${ORDER_BY}`).all(...w.args);
|
|
2517
|
+
}
|
|
2518
|
+
function rebalanceSiblings(db, parentId) {
|
|
2519
|
+
const rows = orderedSiblings(db, parentId);
|
|
2520
|
+
const keys = keysBetween(null, null, rows.length);
|
|
2521
|
+
rows.forEach((row, i) => {
|
|
2522
|
+
if (row.order_key !== keys[i])
|
|
2523
|
+
emit(db, "documents", row.id, "order_key", keys[i]);
|
|
2524
|
+
});
|
|
2525
|
+
}
|
|
2526
|
+
function backfillSiblings(db, parentId) {
|
|
2527
|
+
const emitChange = canEmitOrderKeys2(db);
|
|
2528
|
+
const w = siblingWhere(parentId);
|
|
2529
|
+
const missing = db.query(`SELECT id FROM documents WHERE ${w.clause} AND __deleted = 0 AND order_key IS NULL
|
|
2530
|
+
ORDER BY created_hlc, id`).all(...w.args);
|
|
2531
|
+
if (!missing.length)
|
|
2532
|
+
return;
|
|
2533
|
+
const keys = keysBetween(lastSiblingOrderKey(db, parentId), null, missing.length);
|
|
2534
|
+
missing.forEach((row, i) => {
|
|
2535
|
+
if (emitChange)
|
|
2536
|
+
emit(db, "documents", row.id, "order_key", keys[i]);
|
|
2537
|
+
else
|
|
2538
|
+
db.query("UPDATE documents SET order_key = ? WHERE id = ?").run(keys[i], row.id);
|
|
2539
|
+
});
|
|
2540
|
+
}
|
|
2541
|
+
var backfillDocumentOrderKeys = grouped(function backfillDocumentOrderKeys2(db) {
|
|
2542
|
+
const parents = db.query("SELECT DISTINCT parent_id FROM documents WHERE __deleted = 0 AND order_key IS NULL").all();
|
|
2543
|
+
for (const p of parents)
|
|
2544
|
+
backfillSiblings(db, p.parent_id);
|
|
2545
|
+
});
|
|
2546
|
+
function placeInSiblings(db, id, parentId, anchor) {
|
|
2547
|
+
let cur = parentId;
|
|
2548
|
+
while (cur) {
|
|
2549
|
+
if (cur === id)
|
|
2550
|
+
throw new MhError("invalid_input", `cannot set parent_id: would create a cycle (${id})`);
|
|
2551
|
+
cur = getDocument(db, cur)?.parent_id ?? null;
|
|
2552
|
+
}
|
|
2553
|
+
const current = getDocument(db, id);
|
|
2554
|
+
if (current && (current.parent_id ?? null) !== parentId)
|
|
2555
|
+
emit(db, "documents", id, "parent_id", parentId);
|
|
2556
|
+
let key;
|
|
2557
|
+
if (!anchor) {
|
|
2558
|
+
key = keyBetween(lastSiblingOrderKey(db, parentId, id), null);
|
|
2559
|
+
} else {
|
|
2560
|
+
backfillSiblings(db, parentId);
|
|
2561
|
+
const neighbors = () => {
|
|
2562
|
+
const rows = orderedSiblings(db, parentId).filter((r) => r.id !== id);
|
|
2563
|
+
const to = rows.findIndex((r) => r.id === anchor.targetId);
|
|
2564
|
+
if (to < 0)
|
|
2565
|
+
throw new MhError("not_found", `no such target document: ${anchor.targetId}`);
|
|
2566
|
+
return anchor.where === "before" ? [rows[to - 1]?.order_key ?? null, rows[to].order_key] : [rows[to].order_key, rows[to + 1]?.order_key ?? null];
|
|
2567
|
+
};
|
|
2568
|
+
let [left, right] = neighbors();
|
|
2569
|
+
if (left !== null && right !== null && left >= right) {
|
|
2570
|
+
rebalanceSiblings(db, parentId);
|
|
2571
|
+
[left, right] = neighbors();
|
|
2572
|
+
}
|
|
2573
|
+
key = keyBetween(left, right);
|
|
2574
|
+
}
|
|
2575
|
+
emit(db, "documents", id, "order_key", key);
|
|
2576
|
+
}
|
|
2577
|
+
var moveDocument = grouped(function moveDocument2(db, id, targetId, where) {
|
|
2578
|
+
const src = getDocument(db, id);
|
|
2579
|
+
if (!src)
|
|
2580
|
+
throw new MhError("not_found", `no such document: ${id}`);
|
|
2581
|
+
if (id === targetId)
|
|
2582
|
+
return src;
|
|
2583
|
+
const target = getDocument(db, targetId);
|
|
2584
|
+
if (!target)
|
|
2585
|
+
throw new MhError("not_found", `no such target document: ${targetId}`);
|
|
2586
|
+
if (where === "into")
|
|
2587
|
+
placeInSiblings(db, id, targetId);
|
|
2588
|
+
else
|
|
2589
|
+
placeInSiblings(db, id, target.parent_id, { targetId, where });
|
|
2590
|
+
return getDocument(db, id);
|
|
2591
|
+
});
|
|
2592
|
+
var duplicateDocument = grouped(function duplicateDocument2(db, id, opts = {}) {
|
|
2593
|
+
const src = getDocument(db, id);
|
|
2594
|
+
if (!src)
|
|
2595
|
+
throw new MhError("not_found", `no such document: ${id}`);
|
|
2596
|
+
ensureBlocks(db, id);
|
|
2597
|
+
const blocks = liveBlocks(db, id);
|
|
2598
|
+
const parentId = opts.parentId !== undefined ? opts.parentId : src.parent_id;
|
|
2599
|
+
const dup = createDocument(db, {
|
|
2600
|
+
title: opts.title ?? src.title,
|
|
2601
|
+
database_id: src.database_id ?? undefined,
|
|
2602
|
+
parent_id: parentId ?? undefined
|
|
2603
|
+
});
|
|
2604
|
+
if (blocks.length)
|
|
2605
|
+
insertBlocks(db, dup.id, blocks.map((b) => ({ text: b.text, blankAfter: b.blank_after })), null, null);
|
|
2606
|
+
if (parentId === src.parent_id)
|
|
2607
|
+
moveDocument(db, dup.id, src.id, "after");
|
|
2608
|
+
return getDocument(db, dup.id);
|
|
2609
|
+
});
|
|
2610
|
+
var updateDocument = grouped(function updateDocument2(db, id, fields, opts = {}) {
|
|
2611
|
+
const doc = getDocument(db, id);
|
|
2612
|
+
if (!doc)
|
|
2613
|
+
throw new MhError("not_found", `no such document: ${id}`);
|
|
2614
|
+
if (opts.ifMatch !== undefined && documentVersion(db, id) !== opts.ifMatch)
|
|
2615
|
+
throw new MhError("stale", `stale: document changed since ${opts.ifMatch}; re-read first`);
|
|
2616
|
+
if (fields.title !== undefined && fields.title !== (doc.title ?? ""))
|
|
2617
|
+
emit(db, "documents", id, "title", fields.title);
|
|
2618
|
+
if (fields.database_id !== undefined && fields.database_id !== (doc.database_id ?? null))
|
|
2619
|
+
emit(db, "documents", id, "database_id", fields.database_id);
|
|
2620
|
+
if (fields.parent_id !== undefined) {
|
|
2621
|
+
const next = fields.parent_id ?? null;
|
|
2622
|
+
if ((getDocument(db, id).parent_id ?? null) !== next)
|
|
2623
|
+
placeInSiblings(db, id, next);
|
|
2624
|
+
}
|
|
2625
|
+
if (fields.body !== undefined) {
|
|
2626
|
+
ensureBlocks(db, id);
|
|
2627
|
+
reconcileBody(db, id, fields.body);
|
|
2628
|
+
}
|
|
2629
|
+
return getDocument(db, id);
|
|
2630
|
+
});
|
|
2631
|
+
var editDocument = grouped(function editDocument2(db, id, opts) {
|
|
2632
|
+
if (!getDocument(db, id))
|
|
2633
|
+
throw new MhError("not_found", `no such document: ${id}`);
|
|
2634
|
+
if (opts.ifMatch !== undefined && documentVersion(db, id) !== opts.ifMatch)
|
|
2635
|
+
throw new MhError("stale", `stale: document changed since ${opts.ifMatch}; re-read first`);
|
|
2636
|
+
if (opts.old === "")
|
|
2637
|
+
throw new MhError("invalid_input", "--old must not be empty");
|
|
2638
|
+
ensureBlocks(db, id);
|
|
2639
|
+
const blocks = liveBlocks(db, id);
|
|
2640
|
+
const body = serializeDocBlocks(blocks.map((b) => ({ text: b.text, blankAfter: b.blank_after })));
|
|
2641
|
+
const count = countOccurrences(body, opts.old);
|
|
2642
|
+
if (count === 0)
|
|
2643
|
+
throw new MhError("invalid_input", `anchor not found: no match for --old`);
|
|
2644
|
+
if (count > 1 && !opts.replaceAll)
|
|
2645
|
+
throw new MhError("ambiguous", `${count} matches for --old; add surrounding context or use --replace-all`);
|
|
2646
|
+
if (!opts.old.includes(`
|
|
2647
|
+
|
|
2648
|
+
`)) {
|
|
2649
|
+
const affected = blocks.filter((b) => b.text.includes(opts.old));
|
|
2650
|
+
const next = affected.map((b) => opts.replaceAll ? b.text.replaceAll(opts.old, () => opts.new) : b.text.replace(opts.old, () => opts.new));
|
|
2651
|
+
if (next.every((t) => t.length > 0 && !t.includes(`
|
|
2652
|
+
|
|
2653
|
+
`))) {
|
|
2654
|
+
const targets = opts.replaceAll ? affected : affected.slice(0, 1);
|
|
2655
|
+
let wrote = false;
|
|
2656
|
+
targets.forEach((b, i) => {
|
|
2657
|
+
if (next[i] !== b.text) {
|
|
2658
|
+
emit(db, "doc_blocks", b.id, "text", next[i]);
|
|
2659
|
+
wrote = true;
|
|
2660
|
+
}
|
|
2661
|
+
});
|
|
2662
|
+
return { id, changed: wrote, replaced: opts.replaceAll ? count : 1, version: documentVersion(db, id) };
|
|
2663
|
+
}
|
|
2664
|
+
}
|
|
2665
|
+
const newBody = opts.replaceAll ? body.replaceAll(opts.old, () => opts.new) : body.replace(opts.old, () => opts.new);
|
|
2666
|
+
reconcileBody(db, id, newBody);
|
|
2667
|
+
return { id, changed: newBody !== body, replaced: opts.replaceAll ? count : 1, version: documentVersion(db, id) };
|
|
2668
|
+
});
|
|
2669
|
+
var editDocumentBatch = grouped(function editDocumentBatch2(db, id, opts) {
|
|
2670
|
+
if (!getDocument(db, id))
|
|
2671
|
+
throw new MhError("not_found", `no such document: ${id}`);
|
|
2672
|
+
if (opts.edits.length === 0)
|
|
2673
|
+
throw new MhError("invalid_input", "no edits given");
|
|
2674
|
+
if (opts.ifMatch !== undefined && documentVersion(db, id) !== opts.ifMatch)
|
|
2675
|
+
throw new MhError("stale", `stale: document changed since ${opts.ifMatch}; re-read first`);
|
|
2676
|
+
ensureBlocks(db, id);
|
|
2677
|
+
const orig = serializeDocBlocks(liveBlocks(db, id).map((b) => ({ text: b.text, blankAfter: b.blank_after })));
|
|
2678
|
+
let body = orig;
|
|
2679
|
+
let total = 0;
|
|
2680
|
+
opts.edits.forEach((e, i) => {
|
|
2681
|
+
if (e.old === "")
|
|
2682
|
+
throw new MhError("invalid_input", `edit[${i}]: old must not be empty`);
|
|
2683
|
+
const count = countOccurrences(body, e.old);
|
|
2684
|
+
if (count === 0)
|
|
2685
|
+
throw new MhError("invalid_input", `edit[${i}]: anchor not found: no match for old`);
|
|
2686
|
+
if (count > 1 && !e.replaceAll)
|
|
2687
|
+
throw new MhError("ambiguous", `edit[${i}]: ${count} matches for old; add surrounding context or set replaceAll`);
|
|
2688
|
+
const repl = e.new ?? "";
|
|
2689
|
+
body = e.replaceAll ? body.replaceAll(e.old, () => repl) : body.replace(e.old, () => repl);
|
|
2690
|
+
total += e.replaceAll ? count : 1;
|
|
2691
|
+
});
|
|
2692
|
+
if (body !== orig)
|
|
2693
|
+
reconcileBody(db, id, body);
|
|
2694
|
+
return { id, changed: body !== orig, replaced: total, version: documentVersion(db, id) };
|
|
2695
|
+
});
|
|
2696
|
+
var appendDocument = grouped(function appendDocument2(db, id, body) {
|
|
2697
|
+
if (!getDocument(db, id))
|
|
2698
|
+
throw new MhError("not_found", `no such document: ${id}`);
|
|
2699
|
+
ensureBlocks(db, id);
|
|
2700
|
+
const next = parseDocBlocks(body);
|
|
2701
|
+
if (next.length) {
|
|
2702
|
+
const blocks = liveBlocks(db, id);
|
|
2703
|
+
const last = blocks.length ? blocks[blocks.length - 1].order_key : null;
|
|
2704
|
+
insertBlocks(db, id, next, last, null);
|
|
2705
|
+
}
|
|
2706
|
+
return { id, changed: next.length > 0, replaced: next.length, version: documentVersion(db, id) };
|
|
2707
|
+
});
|
|
2708
|
+
var prependDocument = grouped(function prependDocument2(db, id, body) {
|
|
2709
|
+
if (!getDocument(db, id))
|
|
2710
|
+
throw new MhError("not_found", `no such document: ${id}`);
|
|
2711
|
+
ensureBlocks(db, id);
|
|
2712
|
+
const next = parseDocBlocks(body);
|
|
2713
|
+
if (next.length) {
|
|
2714
|
+
const blocks = liveBlocks(db, id);
|
|
2715
|
+
const first = blocks.length ? blocks[0].order_key : null;
|
|
2716
|
+
insertBlocks(db, id, next, null, first);
|
|
2717
|
+
}
|
|
2718
|
+
return { id, changed: next.length > 0, replaced: next.length, version: documentVersion(db, id) };
|
|
2719
|
+
});
|
|
2720
|
+
var deleteDocument = grouped(function deleteDocument2(db, id) {
|
|
2721
|
+
if (!getDocument(db, id))
|
|
2722
|
+
return false;
|
|
2723
|
+
emit(db, "documents", id, "__deleted", 1);
|
|
2724
|
+
for (const b of liveChildIds(db, "doc_blocks", "doc_id", id))
|
|
2725
|
+
emit(db, "doc_blocks", b, "__deleted", 1);
|
|
2726
|
+
for (const c of liveChildIds(db, "documents", "parent_id", id))
|
|
2727
|
+
emit(db, "documents", c, "parent_id", null);
|
|
2728
|
+
return true;
|
|
2729
|
+
});
|
|
2730
|
+
function liveChildIds(db, table, col, parentId) {
|
|
2731
|
+
return db.query(`SELECT id FROM ${table} WHERE ${col} = ? AND __deleted = 0`).all(parentId).map((r) => r.id);
|
|
2732
|
+
}
|
|
2733
|
+
function countOccurrences(haystack, needle) {
|
|
2734
|
+
if (needle === "")
|
|
2735
|
+
return 0;
|
|
2736
|
+
let n = 0;
|
|
2737
|
+
let i = haystack.indexOf(needle);
|
|
2738
|
+
while (i !== -1) {
|
|
2739
|
+
n++;
|
|
2740
|
+
i = haystack.indexOf(needle, i + needle.length);
|
|
2741
|
+
}
|
|
2742
|
+
return n;
|
|
2743
|
+
}
|
|
2744
|
+
|
|
2745
|
+
// src/core/blobs-core.ts
|
|
2746
|
+
var POLICY_ID = "default";
|
|
2747
|
+
function readPolicy(db) {
|
|
2748
|
+
const row = db.query("SELECT full_nodes, redundancy FROM blob_policy WHERE id = ? AND __deleted = 0").get(POLICY_ID);
|
|
2749
|
+
let fullNodes = [];
|
|
2750
|
+
if (row?.full_nodes) {
|
|
2751
|
+
try {
|
|
2752
|
+
const parsed = JSON.parse(row.full_nodes);
|
|
2753
|
+
if (Array.isArray(parsed))
|
|
2754
|
+
fullNodes = parsed.filter((n) => typeof n === "string");
|
|
2755
|
+
} catch {}
|
|
2756
|
+
}
|
|
2757
|
+
const redundancy = row?.redundancy === "any" ? "any" : "all";
|
|
2758
|
+
return { fullNodes, redundancy };
|
|
2759
|
+
}
|
|
2760
|
+
var setFullNodes = (db, nodeIds) => {
|
|
2761
|
+
withChangeGroup(null, () => {
|
|
2762
|
+
const seen = new Set;
|
|
2763
|
+
const list = nodeIds.filter((n) => n && !seen.has(n) && seen.add(n) != null);
|
|
2764
|
+
emit(db, "blob_policy", POLICY_ID, "full_nodes", list);
|
|
2765
|
+
const cur = readPolicy(db);
|
|
2766
|
+
if (cur.redundancy == null)
|
|
2767
|
+
emit(db, "blob_policy", POLICY_ID, "redundancy", "all");
|
|
2768
|
+
});
|
|
2769
|
+
invalidateAnchored(db);
|
|
2770
|
+
};
|
|
2771
|
+
function invalidateAnchored(db) {
|
|
2772
|
+
db.query("UPDATE blob_cache SET anchored = 0").run();
|
|
2773
|
+
db.query("DELETE FROM meta WHERE key = ?").run(BLOB_VERIFIED_AT_KEY);
|
|
2774
|
+
}
|
|
2775
|
+
var BLOB_VERIFIED_AT_KEY = "blob_verified_at";
|
|
2776
|
+
|
|
2777
|
+
// src/core/sync/storage-url.ts
|
|
2778
|
+
var DEFAULT_STORAGE_PREFIX = "metahub";
|
|
2779
|
+
var DEFAULT_PORT = { "https:": "443", "http:": "80" };
|
|
2780
|
+
function storageEndpointHost(endpoint) {
|
|
2781
|
+
try {
|
|
2782
|
+
const u = new URL(endpoint);
|
|
2783
|
+
const port = u.port && u.port !== DEFAULT_PORT[u.protocol] ? `:${u.port}` : "";
|
|
2784
|
+
return u.hostname + port;
|
|
2785
|
+
} catch {
|
|
2786
|
+
return endpoint.trim().replace(/^[a-z]+:\/\//i, "").replace(/\/.*$/, "").replace(/:(80|443)$/, "");
|
|
2787
|
+
}
|
|
2788
|
+
}
|
|
2789
|
+
function storageUrl(endpoint, bucket, prefix) {
|
|
2790
|
+
const p = prefix?.trim() || DEFAULT_STORAGE_PREFIX;
|
|
2791
|
+
return `s3://${storageEndpointHost(endpoint)}/${bucket}/${p}`;
|
|
2792
|
+
}
|
|
2793
|
+
|
|
2794
|
+
// src/core/schema-init.ts
|
|
2795
|
+
function runSchema(db) {
|
|
2796
|
+
db.exec(CORE_SCHEMA);
|
|
2797
|
+
try {
|
|
2798
|
+
db.exec(FTS_SCHEMA);
|
|
2799
|
+
} catch {}
|
|
2800
|
+
}
|
|
2801
|
+
function hasColumn(db, table, column) {
|
|
2802
|
+
const cols = db.query(`PRAGMA table_info(${table})`).all();
|
|
2803
|
+
return cols.some((c) => c.name === column);
|
|
2804
|
+
}
|
|
2805
|
+
function tableExists3(db, table) {
|
|
2806
|
+
return db.query("SELECT 1 AS ok FROM sqlite_master WHERE type = 'table' AND name = ?").get(table) != null;
|
|
2807
|
+
}
|
|
2808
|
+
function migrateRecords(db) {
|
|
2809
|
+
if (!hasColumn(db, "records", "data"))
|
|
2810
|
+
db.exec("ALTER TABLE records ADD COLUMN data TEXT NOT NULL DEFAULT '{}'");
|
|
2811
|
+
if (!hasColumn(db, "records", "order_key"))
|
|
2812
|
+
db.exec("ALTER TABLE records ADD COLUMN order_key TEXT");
|
|
2813
|
+
if (!tableExists3(db, "record_values"))
|
|
2814
|
+
return;
|
|
2815
|
+
const tx = db.transaction(() => {
|
|
2816
|
+
db.exec(`
|
|
2817
|
+
UPDATE records SET data = coalesce((
|
|
2818
|
+
SELECT json_group_object(rv.property_id, json(rv.value))
|
|
2819
|
+
FROM record_values rv WHERE rv.record_id = records.id
|
|
2820
|
+
), '{}')
|
|
2821
|
+
WHERE id IN (SELECT DISTINCT record_id FROM record_values);
|
|
2822
|
+
`);
|
|
2823
|
+
db.exec("DROP TABLE record_values");
|
|
2824
|
+
backfillRecordOrderKeys(db);
|
|
2825
|
+
});
|
|
2826
|
+
tx();
|
|
2827
|
+
backfillRecordOrderKeys(db);
|
|
2828
|
+
}
|
|
2829
|
+
function migrateShares(db) {
|
|
2830
|
+
if (!hasColumn(db, "shares", "served_base"))
|
|
2831
|
+
db.exec("ALTER TABLE shares ADD COLUMN served_base TEXT");
|
|
2832
|
+
if (!hasColumn(db, "shares", "grants"))
|
|
2833
|
+
db.exec("ALTER TABLE shares ADD COLUMN grants TEXT");
|
|
2834
|
+
if (!hasColumn(db, "shares", "request_id"))
|
|
2835
|
+
db.exec("ALTER TABLE shares ADD COLUMN request_id TEXT");
|
|
2836
|
+
db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_shares_request_id ON shares(request_id) WHERE request_id IS NOT NULL");
|
|
2837
|
+
}
|
|
2838
|
+
function migratePeers(db) {
|
|
2839
|
+
const add = [
|
|
2840
|
+
["token", "TEXT"],
|
|
2841
|
+
["label", "TEXT"],
|
|
2842
|
+
["node_id", "TEXT"],
|
|
2843
|
+
["enabled", "INTEGER NOT NULL DEFAULT 1"],
|
|
2844
|
+
["last_sync_at", "INTEGER"],
|
|
2845
|
+
["last_success_at", "INTEGER"],
|
|
2846
|
+
["last_status", "TEXT"],
|
|
2847
|
+
["last_error", "TEXT"],
|
|
2848
|
+
["kind", "TEXT NOT NULL DEFAULT 'http'"],
|
|
2849
|
+
["config", "TEXT"]
|
|
2850
|
+
];
|
|
2851
|
+
for (const [col, decl] of add) {
|
|
2852
|
+
if (!hasColumn(db, "peers", col))
|
|
2853
|
+
db.exec(`ALTER TABLE peers ADD COLUMN ${col} ${decl}`);
|
|
2854
|
+
}
|
|
2855
|
+
db.exec("UPDATE peers SET last_success_at = last_sync_at " + "WHERE last_success_at IS NULL AND last_status = 'ok' AND last_sync_at IS NOT NULL");
|
|
2856
|
+
}
|
|
2857
|
+
function migrateDocBlocks(db) {
|
|
2858
|
+
if (!hasColumn(db, "doc_blocks", "blank_after"))
|
|
2859
|
+
db.exec("ALTER TABLE doc_blocks ADD COLUMN blank_after INTEGER NOT NULL DEFAULT 0");
|
|
2860
|
+
}
|
|
2861
|
+
function migrateBlobCache(db) {
|
|
2862
|
+
if (!hasColumn(db, "blob_cache", "pinned"))
|
|
2863
|
+
db.exec("ALTER TABLE blob_cache ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0");
|
|
2864
|
+
if (!hasColumn(db, "blob_cache", "pending"))
|
|
2865
|
+
db.exec("ALTER TABLE blob_cache ADD COLUMN pending INTEGER NOT NULL DEFAULT 1");
|
|
2866
|
+
if (!hasColumn(db, "blob_cache", "anchored"))
|
|
2867
|
+
db.exec("ALTER TABLE blob_cache ADD COLUMN anchored INTEGER NOT NULL DEFAULT 0");
|
|
2868
|
+
db.exec("DROP TABLE IF EXISTS blob_presence");
|
|
2869
|
+
}
|
|
2870
|
+
function migrateDocuments(db) {
|
|
2871
|
+
if (!hasColumn(db, "documents", "order_key"))
|
|
2872
|
+
db.exec("ALTER TABLE documents ADD COLUMN order_key TEXT");
|
|
2873
|
+
backfillDocumentOrderKeys(db);
|
|
2874
|
+
}
|
|
2875
|
+
function migrateOplog(db) {
|
|
2876
|
+
if (!hasColumn(db, "crdt_changes", "txn"))
|
|
2877
|
+
db.exec("ALTER TABLE crdt_changes ADD COLUMN txn TEXT");
|
|
2878
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_changes_txn ON crdt_changes(txn) WHERE txn IS NOT NULL");
|
|
2879
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_changes_intent_receipt_hlc ON crdt_changes(hlc) WHERE dataset = 'intent_receipts'");
|
|
2880
|
+
}
|
|
2881
|
+
function migrateCrdtChangesSeq(db) {
|
|
2882
|
+
if (hasColumn(db, "crdt_changes", "seq"))
|
|
2883
|
+
return;
|
|
2884
|
+
const tx = db.transaction(() => {
|
|
2885
|
+
db.exec(`
|
|
2886
|
+
DROP TABLE IF EXISTS crdt_changes_new;
|
|
2887
|
+
CREATE TABLE crdt_changes_new (
|
|
2888
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
2889
|
+
hlc TEXT NOT NULL,
|
|
2890
|
+
node_id TEXT NOT NULL,
|
|
2891
|
+
dataset TEXT NOT NULL,
|
|
2892
|
+
row_id TEXT NOT NULL,
|
|
2893
|
+
col TEXT NOT NULL,
|
|
2894
|
+
value TEXT,
|
|
2895
|
+
txn TEXT,
|
|
2896
|
+
UNIQUE (dataset, row_id, col, hlc)
|
|
2897
|
+
);
|
|
2898
|
+
INSERT INTO crdt_changes_new (seq, hlc, node_id, dataset, row_id, col, value, txn)
|
|
2899
|
+
SELECT rowid, hlc, node_id, dataset, row_id, col, value, txn
|
|
2900
|
+
FROM crdt_changes ORDER BY rowid;
|
|
2901
|
+
DROP TABLE crdt_changes;
|
|
2902
|
+
ALTER TABLE crdt_changes_new RENAME TO crdt_changes;
|
|
2903
|
+
CREATE INDEX IF NOT EXISTS idx_changes_hlc ON crdt_changes(hlc);
|
|
2904
|
+
CREATE INDEX IF NOT EXISTS idx_changes_txn ON crdt_changes(txn)
|
|
2905
|
+
WHERE txn IS NOT NULL;
|
|
2906
|
+
CREATE INDEX IF NOT EXISTS idx_changes_intent_receipt_hlc ON crdt_changes(hlc)
|
|
2907
|
+
WHERE dataset = 'intent_receipts';
|
|
2908
|
+
CREATE INDEX IF NOT EXISTS idx_changes_docref ON crdt_changes(value)
|
|
2909
|
+
WHERE dataset = 'doc_blocks' AND col = 'doc_id';
|
|
2910
|
+
`);
|
|
2911
|
+
if (tableExists3(db, "peers"))
|
|
2912
|
+
db.exec("UPDATE peers SET push_cursor = 0, pull_cursor = 0");
|
|
2913
|
+
});
|
|
2914
|
+
tx();
|
|
2915
|
+
}
|
|
2916
|
+
function migrateStoragePeerUrls(db) {
|
|
2917
|
+
if (!tableExists3(db, "peers"))
|
|
2918
|
+
return;
|
|
2919
|
+
const rows = db.query("SELECT url, config FROM peers WHERE kind = 's3' AND config IS NOT NULL").all();
|
|
2920
|
+
if (rows.length === 0)
|
|
2921
|
+
return;
|
|
2922
|
+
const renames = [];
|
|
2923
|
+
const claimed = new Set;
|
|
2924
|
+
for (const r of rows) {
|
|
2925
|
+
let cfg;
|
|
2926
|
+
try {
|
|
2927
|
+
cfg = JSON.parse(r.config);
|
|
2928
|
+
} catch {
|
|
2929
|
+
continue;
|
|
2930
|
+
}
|
|
2931
|
+
if (!cfg.endpoint || !cfg.bucket || !cfg.prefix)
|
|
2932
|
+
continue;
|
|
2933
|
+
const newUrl = storageUrl(cfg.endpoint, cfg.bucket, cfg.prefix);
|
|
2934
|
+
if (newUrl === r.url) {
|
|
2935
|
+
claimed.add(newUrl);
|
|
2936
|
+
continue;
|
|
2937
|
+
}
|
|
2938
|
+
if (claimed.has(newUrl) || db.query("SELECT 1 FROM peers WHERE url = ?").get(newUrl) || tableExists3(db, "storage_cursors") && db.query("SELECT 1 FROM storage_cursors WHERE peer_url = ?").get(newUrl)) {
|
|
2939
|
+
continue;
|
|
2940
|
+
}
|
|
2941
|
+
claimed.add(newUrl);
|
|
2942
|
+
renames.push({ oldUrl: r.url, newUrl });
|
|
2943
|
+
}
|
|
2944
|
+
if (renames.length === 0)
|
|
2945
|
+
return;
|
|
2946
|
+
const map = new Map(renames.map(({ oldUrl, newUrl }) => [oldUrl, newUrl]));
|
|
2947
|
+
const tx = db.transaction(() => {
|
|
2948
|
+
for (const { oldUrl, newUrl } of renames) {
|
|
2949
|
+
if (tableExists3(db, "storage_cursors"))
|
|
2950
|
+
db.query("UPDATE storage_cursors SET peer_url = ? WHERE peer_url = ?").run(newUrl, oldUrl);
|
|
2951
|
+
db.query("UPDATE peers SET url = ? WHERE url = ?").run(newUrl, oldUrl);
|
|
2952
|
+
}
|
|
2953
|
+
if (tableExists3(db, "blob_policy")) {
|
|
2954
|
+
const fullNodes = readPolicy(db).fullNodes;
|
|
2955
|
+
if (fullNodes.some((n) => map.has(n)))
|
|
2956
|
+
setFullNodes(db, fullNodes.map((n) => map.get(n) ?? n));
|
|
2957
|
+
}
|
|
2958
|
+
});
|
|
2959
|
+
tx();
|
|
2960
|
+
}
|
|
2961
|
+
function migrateDatabases(db) {
|
|
2962
|
+
if (hasColumn(db, "databases", "meta"))
|
|
2963
|
+
return;
|
|
2964
|
+
db.exec("ALTER TABLE databases ADD COLUMN meta TEXT");
|
|
2965
|
+
db.exec(`
|
|
2966
|
+
UPDATE databases SET meta = (
|
|
2967
|
+
SELECT c.value FROM crdt_changes c
|
|
2968
|
+
WHERE c.dataset = 'databases' AND c.col = 'meta' AND c.row_id = databases.id
|
|
2969
|
+
ORDER BY c.hlc DESC LIMIT 1
|
|
2970
|
+
)
|
|
2971
|
+
WHERE id IN (
|
|
2972
|
+
SELECT DISTINCT row_id FROM crdt_changes WHERE dataset = 'databases' AND col = 'meta'
|
|
2973
|
+
)
|
|
2974
|
+
`);
|
|
2975
|
+
}
|
|
2976
|
+
function migrateSitesAccess(db) {
|
|
2977
|
+
const backfill = (col, fallback) => {
|
|
2978
|
+
db.exec(`
|
|
2979
|
+
UPDATE sites SET ${col} = COALESCE((
|
|
2980
|
+
SELECT json_extract(c.value, '$') FROM crdt_changes c
|
|
2981
|
+
WHERE c.dataset = 'sites' AND c.col = '${col}' AND c.row_id = sites.id
|
|
2982
|
+
ORDER BY c.hlc DESC LIMIT 1
|
|
2983
|
+
), ${fallback})
|
|
2984
|
+
WHERE id IN (
|
|
2985
|
+
SELECT DISTINCT row_id FROM crdt_changes WHERE dataset = 'sites' AND col = '${col}'
|
|
2986
|
+
)
|
|
2987
|
+
`);
|
|
2988
|
+
};
|
|
2989
|
+
if (!hasColumn(db, "sites", "visibility")) {
|
|
2990
|
+
db.exec("ALTER TABLE sites ADD COLUMN visibility TEXT");
|
|
2991
|
+
backfill("visibility", "NULL");
|
|
2992
|
+
}
|
|
2993
|
+
if (!hasColumn(db, "sites", "spa")) {
|
|
2994
|
+
db.exec("ALTER TABLE sites ADD COLUMN spa INTEGER NOT NULL DEFAULT 0");
|
|
2995
|
+
backfill("spa", "0");
|
|
2996
|
+
}
|
|
2997
|
+
if (!hasColumn(db, "sites", "public_grants")) {
|
|
2998
|
+
db.exec("ALTER TABLE sites ADD COLUMN public_grants TEXT");
|
|
2999
|
+
backfill("public_grants", "NULL");
|
|
3000
|
+
}
|
|
3001
|
+
}
|
|
3002
|
+
var SITE_CHANNELS_REPLAY_KEY = "site_channels_replay_seq";
|
|
3003
|
+
function migrateSiteChannels(db) {
|
|
3004
|
+
if (!tableExists3(db, "site_channels"))
|
|
3005
|
+
return;
|
|
3006
|
+
const wm = db.query("SELECT value FROM meta WHERE key = ?").get(SITE_CHANNELS_REPLAY_KEY);
|
|
3007
|
+
const since = wm ? Number(wm.value) : 0;
|
|
3008
|
+
const changes = db.query(`SELECT seq, ${CHANGE_SELECT} FROM crdt_changes
|
|
3009
|
+
WHERE dataset = 'site_channels' AND seq > ? ORDER BY seq`).all(since);
|
|
3010
|
+
if (changes.length === 0)
|
|
3011
|
+
return;
|
|
3012
|
+
db.transaction((rows) => {
|
|
3013
|
+
for (const change of rows)
|
|
3014
|
+
applyChange(db, change);
|
|
3015
|
+
db.query("INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(SITE_CHANNELS_REPLAY_KEY, String(rows[rows.length - 1].seq));
|
|
3016
|
+
})(changes);
|
|
3017
|
+
}
|
|
3018
|
+
function initSchema(db) {
|
|
3019
|
+
runSchema(db);
|
|
3020
|
+
migrateOplog(db);
|
|
3021
|
+
migrateDatabases(db);
|
|
3022
|
+
migrateCrdtChangesSeq(db);
|
|
3023
|
+
migrateRecords(db);
|
|
3024
|
+
migratePeers(db);
|
|
3025
|
+
migrateStoragePeerUrls(db);
|
|
3026
|
+
migrateDocBlocks(db);
|
|
3027
|
+
migrateDocuments(db);
|
|
3028
|
+
migrateBlobCache(db);
|
|
3029
|
+
migrateShares(db);
|
|
3030
|
+
migrateSitesAccess(db);
|
|
3031
|
+
migrateSiteChannels(db);
|
|
3032
|
+
}
|
|
3033
|
+
|
|
3034
|
+
// src/core/sync/partition.ts
|
|
3035
|
+
var PARTITION_DATASETS = [
|
|
3036
|
+
"databases",
|
|
3037
|
+
"properties",
|
|
3038
|
+
"records",
|
|
3039
|
+
"sites",
|
|
3040
|
+
"site_files"
|
|
3041
|
+
];
|
|
3042
|
+
var CHANGE_SELECT_Q = CHANGE_COLS.map((c) => "c." + c).join(", ");
|
|
3043
|
+
function allWinners(db) {
|
|
3044
|
+
const excluded = [...OPLOG_ONLY_DATASETS];
|
|
3045
|
+
const exclusion = excluded.length ? `c.dataset NOT IN (${excluded.map(() => "?").join(", ")}) AND` : "";
|
|
3046
|
+
return db.query(`SELECT ${CHANGE_SELECT} FROM crdt_changes c
|
|
3047
|
+
WHERE ${exclusion} NOT EXISTS (
|
|
3048
|
+
SELECT 1 FROM crdt_changes k
|
|
3049
|
+
WHERE k.dataset = c.dataset AND k.row_id = c.row_id AND k.col = c.col AND k.hlc > c.hlc
|
|
3050
|
+
)`).all(...excluded);
|
|
3051
|
+
}
|
|
3052
|
+
function winnersDigest(rows) {
|
|
3053
|
+
const keys = rows.map((r) => `${r.dataset}\x00${r.row_id}\x00${r.col}\x00${r.hlc}`).sort();
|
|
3054
|
+
return fnv1a64Hex(keys.map((key) => key + "\x1E").join(""));
|
|
3055
|
+
}
|
|
3056
|
+
|
|
3057
|
+
// src/core/sync/room-protocol.ts
|
|
3058
|
+
var ROOM_PROTOCOL_VERSION = 1;
|
|
3059
|
+
var ROOM_SCHEMA = `
|
|
3060
|
+
CREATE TABLE IF NOT EXISTS room_config (
|
|
3061
|
+
key TEXT PRIMARY KEY,
|
|
3062
|
+
value TEXT
|
|
3063
|
+
);
|
|
3064
|
+
CREATE TABLE IF NOT EXISTS evict_pending (
|
|
3065
|
+
dataset TEXT NOT NULL,
|
|
3066
|
+
row_id TEXT NOT NULL,
|
|
3067
|
+
PRIMARY KEY (dataset, row_id)
|
|
3068
|
+
);
|
|
3069
|
+
CREATE TABLE IF NOT EXISTS room_blobs (
|
|
3070
|
+
hash TEXT NOT NULL,
|
|
3071
|
+
idx INTEGER NOT NULL,
|
|
3072
|
+
total INTEGER NOT NULL,
|
|
3073
|
+
bytes BLOB NOT NULL,
|
|
3074
|
+
PRIMARY KEY (hash, idx)
|
|
3075
|
+
);
|
|
3076
|
+
`;
|
|
3077
|
+
function initRoomDb(db, prov) {
|
|
3078
|
+
initSchema(db);
|
|
3079
|
+
db.exec(ROOM_SCHEMA);
|
|
3080
|
+
const roomNode = prov.roomNode ?? `room-${prov.slug}`;
|
|
3081
|
+
db.query("INSERT INTO meta (key, value) VALUES ('node_id', ?) ON CONFLICT(key) DO NOTHING").run(roomNode);
|
|
3082
|
+
const cfg = {
|
|
3083
|
+
slug: prov.slug,
|
|
3084
|
+
roomNode: db.query("SELECT value FROM meta WHERE key = 'node_id'").get().value,
|
|
3085
|
+
guestBase: prov.guestBase,
|
|
3086
|
+
grants: prov.grants,
|
|
3087
|
+
pwHash: prov.pwHash ?? null,
|
|
3088
|
+
pwSalt: prov.pwSalt ?? null,
|
|
3089
|
+
expiresAt: prov.expiresAt ?? null,
|
|
3090
|
+
state: "active"
|
|
3091
|
+
};
|
|
3092
|
+
db.query("INSERT INTO room_config (key, value) VALUES ('config', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(JSON.stringify(cfg));
|
|
3093
|
+
return cfg;
|
|
3094
|
+
}
|
|
3095
|
+
function readRoomConfig(db) {
|
|
3096
|
+
const row = db.query("SELECT value FROM room_config WHERE key = 'config'").get();
|
|
3097
|
+
if (!row)
|
|
3098
|
+
throw new MhError("not_found", "room is not provisioned");
|
|
3099
|
+
return JSON.parse(row.value);
|
|
3100
|
+
}
|
|
3101
|
+
function roomExpired(cfg, now = Date.now()) {
|
|
3102
|
+
return cfg.state === "expired" || cfg.expiresAt != null && now > cfg.expiresAt;
|
|
3103
|
+
}
|
|
3104
|
+
var NEED_BLOBS_LIMIT = 32;
|
|
3105
|
+
var ROOM_BLOB_CHUNK_LIMIT = 1024 * 1024;
|
|
3106
|
+
function roomMissingBlobs(db, limit = NEED_BLOBS_LIMIT) {
|
|
3107
|
+
const rows = db.query(`SELECT DISTINCT content AS hash FROM site_files
|
|
3108
|
+
WHERE encoding = 'blob' AND __deleted = 0 AND content IS NOT NULL
|
|
3109
|
+
AND NOT EXISTS (
|
|
3110
|
+
SELECT 1 FROM room_blobs b WHERE b.hash = site_files.content AND b.idx = 0
|
|
3111
|
+
AND (SELECT COUNT(*) FROM room_blobs c WHERE c.hash = b.hash) = b.total
|
|
3112
|
+
)
|
|
3113
|
+
ORDER BY hash LIMIT ?`).all(limit);
|
|
3114
|
+
return rows.map((r) => r.hash);
|
|
3115
|
+
}
|
|
3116
|
+
function roomPutBlobChunk(db, hash, idx, total, bytes) {
|
|
3117
|
+
const tx = db.transaction(() => {
|
|
3118
|
+
if (idx === 0)
|
|
3119
|
+
db.query("DELETE FROM room_blobs WHERE hash = ?").run(hash);
|
|
3120
|
+
db.query("INSERT OR REPLACE INTO room_blobs (hash, idx, total, bytes) VALUES (?, ?, ?, ?)").run(hash, idx, total, bytes);
|
|
3121
|
+
});
|
|
3122
|
+
tx();
|
|
3123
|
+
}
|
|
3124
|
+
function roomBlobBytes(db, hash) {
|
|
3125
|
+
const rows = db.query("SELECT idx, total, bytes FROM room_blobs WHERE hash = ? ORDER BY idx").all(hash);
|
|
3126
|
+
if (rows.length === 0 || rows.length !== rows[0].total)
|
|
3127
|
+
return null;
|
|
3128
|
+
const parts = rows.map((r) => r.bytes instanceof Uint8Array ? r.bytes : new Uint8Array(r.bytes));
|
|
3129
|
+
const out = new Uint8Array(parts.reduce((n, p) => n + p.byteLength, 0));
|
|
3130
|
+
let off = 0;
|
|
3131
|
+
for (const p of parts) {
|
|
3132
|
+
out.set(p, off);
|
|
3133
|
+
off += p.byteLength;
|
|
3134
|
+
}
|
|
3135
|
+
return out;
|
|
3136
|
+
}
|
|
3137
|
+
function sweepRoomBlobs(db) {
|
|
3138
|
+
db.query(`DELETE FROM room_blobs WHERE hash NOT IN (
|
|
3139
|
+
SELECT content FROM site_files WHERE encoding = 'blob' AND __deleted = 0 AND content IS NOT NULL
|
|
3140
|
+
)`).run();
|
|
3141
|
+
}
|
|
3142
|
+
var GUEST_NODE_SQL = "(node_id = ? OR node_id LIKE ? || '-%')";
|
|
3143
|
+
function isGuestNode(base, node) {
|
|
3144
|
+
return node === base || node.startsWith(base + "-");
|
|
3145
|
+
}
|
|
3146
|
+
var STATE_TABLE = Object.fromEntries(PARTITION_DATASETS.map((d) => [d, d]));
|
|
3147
|
+
function evictRow(db, key, ackSeq, guestBase) {
|
|
3148
|
+
const table = STATE_TABLE[key.dataset];
|
|
3149
|
+
if (!table)
|
|
3150
|
+
return;
|
|
3151
|
+
db.query(`DELETE FROM ${table} WHERE id = ?`).run(key.row_id);
|
|
3152
|
+
db.query(`DELETE FROM crdt_changes WHERE dataset = ? AND row_id = ?
|
|
3153
|
+
AND NOT (${GUEST_NODE_SQL} AND seq > ?)`).run(key.dataset, key.row_id, guestBase, guestBase, ackSeq);
|
|
3154
|
+
const remaining = db.query("SELECT 1 FROM crdt_changes WHERE dataset = ? AND row_id = ? LIMIT 1").get(key.dataset, key.row_id) != null;
|
|
3155
|
+
if (remaining)
|
|
3156
|
+
db.query("INSERT OR IGNORE INTO evict_pending (dataset, row_id) VALUES (?, ?)").run(key.dataset, key.row_id);
|
|
3157
|
+
else
|
|
3158
|
+
db.query("DELETE FROM evict_pending WHERE dataset = ? AND row_id = ?").run(key.dataset, key.row_id);
|
|
3159
|
+
}
|
|
3160
|
+
function sweepEvictPending(db, ackSeq, guestBase) {
|
|
3161
|
+
const pending = db.query("SELECT dataset, row_id FROM evict_pending").all();
|
|
3162
|
+
for (const k of pending) {
|
|
3163
|
+
const table = STATE_TABLE[k.dataset];
|
|
3164
|
+
if (table && db.query(`SELECT 1 FROM ${table} WHERE id = ? LIMIT 1`).get(k.row_id) != null) {
|
|
3165
|
+
db.query("DELETE FROM evict_pending WHERE dataset = ? AND row_id = ?").run(k.dataset, k.row_id);
|
|
3166
|
+
continue;
|
|
3167
|
+
}
|
|
3168
|
+
db.query(`DELETE FROM crdt_changes WHERE dataset = ? AND row_id = ? AND seq <= ? AND (${GUEST_NODE_SQL})`).run(k.dataset, k.row_id, ackSeq, guestBase, guestBase);
|
|
3169
|
+
const remaining = db.query("SELECT 1 FROM crdt_changes WHERE dataset = ? AND row_id = ? LIMIT 1").get(k.dataset, k.row_id) != null;
|
|
3170
|
+
if (!remaining)
|
|
3171
|
+
db.query("DELETE FROM evict_pending WHERE dataset = ? AND row_id = ?").run(k.dataset, k.row_id);
|
|
3172
|
+
}
|
|
3173
|
+
}
|
|
3174
|
+
var GUEST_PULL_LIMIT = 1000;
|
|
3175
|
+
function guestChangesAfterSeq(db, seq, guestBase, limit = GUEST_PULL_LIMIT) {
|
|
3176
|
+
const rows = db.query(`SELECT seq AS __seq, ${CHANGE_COLS.join(", ")} FROM crdt_changes
|
|
3177
|
+
WHERE seq > ? AND ${GUEST_NODE_SQL}
|
|
3178
|
+
AND (dataset != ? OR hlc >= ?)
|
|
3179
|
+
ORDER BY seq LIMIT ?`).all(seq, guestBase, guestBase, INTENT_RECEIPT_DATASET, intentReceiptCutoffHlc(), limit);
|
|
3180
|
+
const more = rows.length >= limit;
|
|
3181
|
+
let cursor;
|
|
3182
|
+
if (more) {
|
|
3183
|
+
cursor = rows[rows.length - 1].__seq;
|
|
3184
|
+
} else {
|
|
3185
|
+
const top = db.query("SELECT MAX(seq) AS m FROM crdt_changes").get();
|
|
3186
|
+
cursor = Math.max(seq, top.m ?? seq);
|
|
3187
|
+
}
|
|
3188
|
+
return { changes: rows.map(({ __seq: _s, ...c }) => c), cursor, more };
|
|
3189
|
+
}
|
|
3190
|
+
function orphanRows(db) {
|
|
3191
|
+
return db.query(`SELECT 'records' AS dataset, id AS row_id FROM records WHERE database_id IS NULL
|
|
3192
|
+
UNION ALL SELECT 'properties', id FROM properties WHERE database_id IS NULL
|
|
3193
|
+
UNION ALL SELECT 'site_files', id FROM site_files WHERE site_id IS NULL
|
|
3194
|
+
ORDER BY dataset, row_id`).all();
|
|
3195
|
+
}
|
|
3196
|
+
function handleOwnerSync(roomDb, req, cfg = readRoomConfig(roomDb)) {
|
|
3197
|
+
if (req.protocol !== ROOM_PROTOCOL_VERSION)
|
|
3198
|
+
throw new MhError("conflict", `room speaks protocol ${ROOM_PROTOCOL_VERSION}, owner sent ${req.protocol} — upgrade required`);
|
|
3199
|
+
if (roomExpired(cfg)) {
|
|
3200
|
+
return {
|
|
3201
|
+
protocol: ROOM_PROTOCOL_VERSION,
|
|
3202
|
+
node_id: cfg.roomNode,
|
|
3203
|
+
changes: [],
|
|
3204
|
+
cursor: req.since,
|
|
3205
|
+
need_baseline: [],
|
|
3206
|
+
need_blobs: [],
|
|
3207
|
+
share_state: "expired"
|
|
3208
|
+
};
|
|
3209
|
+
}
|
|
3210
|
+
const tx = roomDb.transaction(() => {
|
|
3211
|
+
ingest(roomDb, req.changes);
|
|
3212
|
+
const pull = guestChangesAfterSeq(roomDb, req.since, cfg.guestBase);
|
|
3213
|
+
if (req.members) {
|
|
3214
|
+
const keep = new Set(req.members.map((k) => `${k.dataset}\x00${k.row_id}`));
|
|
3215
|
+
const held = roomDb.query(`SELECT DISTINCT dataset, row_id FROM crdt_changes
|
|
3216
|
+
WHERE dataset IN (${PARTITION_DATASETS.map(() => "?").join(", ")})`).all(...PARTITION_DATASETS);
|
|
3217
|
+
for (const k of held) {
|
|
3218
|
+
if (!keep.has(`${k.dataset}\x00${k.row_id}`))
|
|
3219
|
+
evictRow(roomDb, k, req.since, cfg.guestBase);
|
|
3220
|
+
}
|
|
3221
|
+
}
|
|
3222
|
+
for (const k of req.evict)
|
|
3223
|
+
evictRow(roomDb, k, req.since, cfg.guestBase);
|
|
3224
|
+
sweepEvictPending(roomDb, req.since, cfg.guestBase);
|
|
3225
|
+
sweepRoomBlobs(roomDb);
|
|
3226
|
+
return {
|
|
3227
|
+
protocol: ROOM_PROTOCOL_VERSION,
|
|
3228
|
+
node_id: cfg.roomNode,
|
|
3229
|
+
changes: pull.changes,
|
|
3230
|
+
cursor: pull.cursor,
|
|
3231
|
+
more: pull.more || undefined,
|
|
3232
|
+
need_baseline: orphanRows(roomDb),
|
|
3233
|
+
need_blobs: roomMissingBlobs(roomDb),
|
|
3234
|
+
digest: req.digest !== undefined ? winnersDigest(allWinners(roomDb)) : undefined,
|
|
3235
|
+
share_state: "active"
|
|
3236
|
+
};
|
|
3237
|
+
});
|
|
3238
|
+
return tx();
|
|
3239
|
+
}
|
|
3240
|
+
function mintRoomGuestSub(cfg) {
|
|
3241
|
+
return `${cfg.guestBase}-${randomSuffix(6)}`;
|
|
3242
|
+
}
|
|
3243
|
+
function handleGuestWrite(roomDb, cfg, session, intent) {
|
|
3244
|
+
if (roomExpired(cfg))
|
|
3245
|
+
throw new MhError("auth", "unauthorized");
|
|
3246
|
+
if (!session.sub || !isGuestNode(cfg.guestBase, session.sub))
|
|
3247
|
+
throw new MhError("auth", "unauthorized");
|
|
3248
|
+
if (intent.op !== "createRecord" && intent.op !== "updateRecord")
|
|
3249
|
+
throw new MhError("invalid_input", `unknown intent op ${JSON.stringify(intent.op)}`);
|
|
3250
|
+
const gi = {
|
|
3251
|
+
intentId: intent.intentId || randomSuffix(16),
|
|
3252
|
+
action: intent.op,
|
|
3253
|
+
table: intent.db,
|
|
3254
|
+
recordId: intent.record,
|
|
3255
|
+
payload: intent.values,
|
|
3256
|
+
submittedAt: Number.isFinite(intent.submittedAt) ? intent.submittedAt : Date.now()
|
|
3257
|
+
};
|
|
3258
|
+
return applyGuestIntent(roomDb, policyForRoom(cfg), { guestNode: session.sub }, gi, { clock: "authority" });
|
|
3259
|
+
}
|
|
3260
|
+
|
|
3261
|
+
// src/core/sync/room-serve.ts
|
|
3262
|
+
var HTML = { "content-type": "text/html; charset=utf-8" };
|
|
3263
|
+
var HASH_RE = /^[0-9a-f]{16,64}$/;
|
|
3264
|
+
var ROOM_SESSION_TTL_MS = 12 * 60 * 60 * 1000;
|
|
3265
|
+
function json(data, status = 200) {
|
|
3266
|
+
return Response.json(data, { status });
|
|
3267
|
+
}
|
|
3268
|
+
function errJson2(e) {
|
|
3269
|
+
const { body, status } = errorBody(e);
|
|
3270
|
+
return json(body, status);
|
|
3271
|
+
}
|
|
3272
|
+
function plain404() {
|
|
3273
|
+
return new Response("not found", { status: 404 });
|
|
3274
|
+
}
|
|
3275
|
+
function wantsHtml(req) {
|
|
3276
|
+
return (req.headers.get("accept") ?? "").includes("text/html");
|
|
3277
|
+
}
|
|
3278
|
+
function isOwner(req, ownerToken) {
|
|
3279
|
+
if (!ownerToken)
|
|
3280
|
+
return false;
|
|
3281
|
+
const h = req.headers.get("authorization") ?? "";
|
|
3282
|
+
return h.startsWith("Bearer ") && timingSafeStr(h.slice(7), ownerToken);
|
|
3283
|
+
}
|
|
3284
|
+
function tryConfig(db) {
|
|
3285
|
+
try {
|
|
3286
|
+
return readRoomConfig(db);
|
|
3287
|
+
} catch {
|
|
3288
|
+
return null;
|
|
3289
|
+
}
|
|
3290
|
+
}
|
|
3291
|
+
function maxSeq(db) {
|
|
3292
|
+
try {
|
|
3293
|
+
const row = db.query("SELECT MAX(seq) AS m FROM crdt_changes").get();
|
|
3294
|
+
return row?.m ?? 0;
|
|
3295
|
+
} catch {
|
|
3296
|
+
return 0;
|
|
3297
|
+
}
|
|
3298
|
+
}
|
|
3299
|
+
async function hlcHeadroom(db, deps) {
|
|
3300
|
+
if (!deps.yieldClock)
|
|
3301
|
+
return;
|
|
3302
|
+
const row = db.query("SELECT value FROM meta WHERE key = 'hlc'").get();
|
|
3303
|
+
if (!row)
|
|
3304
|
+
return;
|
|
3305
|
+
const counter = parseInt(row.value.slice(16, 20), 16);
|
|
3306
|
+
if (Number.isFinite(counter) && counter > 61440)
|
|
3307
|
+
await deps.yieldClock();
|
|
3308
|
+
}
|
|
3309
|
+
function roomSecret(db) {
|
|
3310
|
+
const read = () => db.query("SELECT value FROM room_config WHERE key = 'cookie_secret'").get();
|
|
3311
|
+
const row = read();
|
|
3312
|
+
if (row)
|
|
3313
|
+
return row.value;
|
|
3314
|
+
db.query("INSERT INTO room_config (key, value) VALUES ('cookie_secret', ?) ON CONFLICT(key) DO NOTHING").run(randomSuffix(32));
|
|
3315
|
+
return read().value;
|
|
3316
|
+
}
|
|
3317
|
+
function roomSessionScope(db, cfg) {
|
|
3318
|
+
return { secret: roomSecret(db), cookieName: "mh_room_" + cfg.slug, scopeKey: cfg.slug };
|
|
3319
|
+
}
|
|
3320
|
+
function sessionTtl(cfg) {
|
|
3321
|
+
return cfg.expiresAt == null ? ROOM_SESSION_TTL_MS : Math.min(ROOM_SESSION_TTL_MS, Math.max(0, cfg.expiresAt - Date.now()));
|
|
3322
|
+
}
|
|
3323
|
+
async function readRoomSession(db, cfg, req) {
|
|
3324
|
+
return readGuestSession(roomSessionScope(db, cfg), req.headers.get("cookie"));
|
|
3325
|
+
}
|
|
3326
|
+
async function mintRoomSession(db, cfg, url) {
|
|
3327
|
+
const minted = await mintGuestSession(roomSessionScope(db, cfg), {
|
|
3328
|
+
sub: mintRoomGuestSub(cfg),
|
|
3329
|
+
ttlMs: sessionTtl(cfg),
|
|
3330
|
+
path: `/r/${cfg.slug}`,
|
|
3331
|
+
secure: url.protocol === "https:"
|
|
3332
|
+
});
|
|
3333
|
+
return { sub: minted.sub, cookie: minted.cookie };
|
|
3334
|
+
}
|
|
3335
|
+
function withSetCookie(res, setCookie) {
|
|
3336
|
+
if (!setCookie)
|
|
3337
|
+
return res;
|
|
3338
|
+
const headers = new Headers(res.headers);
|
|
3339
|
+
headers.append("set-cookie", setCookie);
|
|
3340
|
+
return new Response(res.body, { status: res.status, headers });
|
|
3341
|
+
}
|
|
3342
|
+
async function roomWsSession(db, req) {
|
|
3343
|
+
const cfg = tryConfig(db);
|
|
3344
|
+
if (!cfg || roomExpired(cfg))
|
|
3345
|
+
return null;
|
|
3346
|
+
const sess = await readRoomSession(db, cfg, req);
|
|
3347
|
+
if (cfg.pwHash && !sess)
|
|
3348
|
+
return null;
|
|
3349
|
+
return { cfg, sub: sess?.sub || mintRoomGuestSub(cfg) };
|
|
3350
|
+
}
|
|
3351
|
+
async function roomWsMessage(db, sub, raw) {
|
|
3352
|
+
let msg;
|
|
3353
|
+
try {
|
|
3354
|
+
msg = JSON.parse(raw);
|
|
3355
|
+
} catch {
|
|
3356
|
+
return {
|
|
3357
|
+
reply: JSON.stringify({ type: "error", error: "malformed message", code: "invalid_input" }),
|
|
3358
|
+
poke: false,
|
|
3359
|
+
seq: 0
|
|
3360
|
+
};
|
|
3361
|
+
}
|
|
3362
|
+
const id = msg?.id;
|
|
3363
|
+
if (msg?.type !== "write" || typeof msg.intent !== "object" || msg.intent === null) {
|
|
3364
|
+
return {
|
|
3365
|
+
reply: JSON.stringify({ type: "error", id, error: "unknown message type", code: "invalid_input" }),
|
|
3366
|
+
poke: false,
|
|
3367
|
+
seq: 0
|
|
3368
|
+
};
|
|
3369
|
+
}
|
|
3370
|
+
const cfg = tryConfig(db);
|
|
3371
|
+
if (!cfg) {
|
|
3372
|
+
return {
|
|
3373
|
+
reply: JSON.stringify({ type: "error", id, error: "unauthorized", code: "auth" }),
|
|
3374
|
+
poke: false,
|
|
3375
|
+
seq: 0
|
|
3376
|
+
};
|
|
3377
|
+
}
|
|
3378
|
+
try {
|
|
3379
|
+
const record = db.transaction(() => handleGuestWrite(db, cfg, { sub }, msg.intent))();
|
|
3380
|
+
return { reply: JSON.stringify({ type: "result", id, record }), poke: true, seq: maxSeq(db) };
|
|
3381
|
+
} catch (e) {
|
|
3382
|
+
return {
|
|
3383
|
+
reply: JSON.stringify({
|
|
3384
|
+
type: "error",
|
|
3385
|
+
id,
|
|
3386
|
+
error: e instanceof Error ? e.message : String(e),
|
|
3387
|
+
code: errorCode(e)
|
|
3388
|
+
}),
|
|
3389
|
+
poke: false,
|
|
3390
|
+
seq: 0
|
|
3391
|
+
};
|
|
3392
|
+
}
|
|
3393
|
+
}
|
|
3394
|
+
function createRoomFetch(deps) {
|
|
3395
|
+
const { db } = deps;
|
|
3396
|
+
return async (req) => {
|
|
3397
|
+
const url = new URL(req.url);
|
|
3398
|
+
const m = /^\/r\/([^/]+)(\/.*)?$/.exec(url.pathname);
|
|
3399
|
+
if (!m)
|
|
3400
|
+
return plain404();
|
|
3401
|
+
const pathSlug = safeDecode(m[1]);
|
|
3402
|
+
if (pathSlug === null)
|
|
3403
|
+
return plain404();
|
|
3404
|
+
if (m[2] === undefined) {
|
|
3405
|
+
return new Response(null, { status: 301, headers: { location: `/r/${m[1]}/` } });
|
|
3406
|
+
}
|
|
3407
|
+
const sub = safeDecode(m[2].slice(1));
|
|
3408
|
+
if (sub === null)
|
|
3409
|
+
return plain404();
|
|
3410
|
+
if (sub === "owner" || sub.startsWith("owner/")) {
|
|
3411
|
+
if (!isOwner(req, deps.ownerToken))
|
|
3412
|
+
return json({ error: "unauthorized", code: "auth" }, 401);
|
|
3413
|
+
if (req.method !== "POST")
|
|
3414
|
+
return json({ error: "not found", code: "not_found" }, 404);
|
|
3415
|
+
if (sub === "owner/provision") {
|
|
3416
|
+
const body = await req.json().catch(() => null);
|
|
3417
|
+
if (!body || typeof body.guestBase !== "string" || body.guestBase === "")
|
|
3418
|
+
return json({ error: "provision needs a guestBase", code: "invalid_input" }, 400);
|
|
3419
|
+
const str = (v) => typeof v === "string" && v !== "" ? v : null;
|
|
3420
|
+
const cfg3 = initRoomDb(db, {
|
|
3421
|
+
slug: typeof body.slug === "string" && body.slug !== "" ? body.slug : pathSlug,
|
|
3422
|
+
guestBase: body.guestBase,
|
|
3423
|
+
grants: str(body.grants) ?? "",
|
|
3424
|
+
pwHash: str(body.pwHash),
|
|
3425
|
+
pwSalt: str(body.pwSalt),
|
|
3426
|
+
expiresAt: typeof body.expiresAt === "number" ? body.expiresAt : null
|
|
3427
|
+
});
|
|
3428
|
+
await deps.setAlarm?.(cfg3.expiresAt ?? null);
|
|
3429
|
+
return json({ slug: cfg3.slug, room_node: cfg3.roomNode, provisioned: true });
|
|
3430
|
+
}
|
|
3431
|
+
const cfg2 = tryConfig(db);
|
|
3432
|
+
if (!cfg2)
|
|
3433
|
+
return json({ error: "room is not provisioned", code: "not_found" }, 404);
|
|
3434
|
+
if (sub === "owner/sync") {
|
|
3435
|
+
const body = await req.json().catch(() => null);
|
|
3436
|
+
if (!body || typeof body.protocol !== "number" || !Array.isArray(body.changes))
|
|
3437
|
+
return json({ error: "malformed sync request", code: "invalid_input" }, 400);
|
|
3438
|
+
await hlcHeadroom(db, deps);
|
|
3439
|
+
try {
|
|
3440
|
+
const resp = handleOwnerSync(db, body, cfg2);
|
|
3441
|
+
if (resp.share_state === "active" && body.changes.length > 0)
|
|
3442
|
+
deps.poke?.(maxSeq(db));
|
|
3443
|
+
return json(resp);
|
|
3444
|
+
} catch (e) {
|
|
3445
|
+
return errJson2(e);
|
|
3446
|
+
}
|
|
3447
|
+
}
|
|
3448
|
+
const blob = /^owner\/blob\/([^/]+)$/.exec(sub);
|
|
3449
|
+
if (blob) {
|
|
3450
|
+
const hash = blob[1].toLowerCase();
|
|
3451
|
+
if (!HASH_RE.test(hash))
|
|
3452
|
+
return json({ error: "bad blob hash", code: "invalid_input" }, 400);
|
|
3453
|
+
const idx = Math.floor(Number(url.searchParams.get("idx") ?? 0));
|
|
3454
|
+
const total = Math.floor(Number(url.searchParams.get("total") ?? 1));
|
|
3455
|
+
if (!Number.isFinite(idx) || !Number.isFinite(total) || idx < 0 || total < 1 || idx >= total || total > 4096)
|
|
3456
|
+
return json({ error: "bad chunk indices", code: "invalid_input" }, 400);
|
|
3457
|
+
const bytes = new Uint8Array(await req.arrayBuffer());
|
|
3458
|
+
if (bytes.byteLength === 0 || bytes.byteLength > ROOM_BLOB_CHUNK_LIMIT)
|
|
3459
|
+
return json({ error: `chunk must be 1..${ROOM_BLOB_CHUNK_LIMIT} bytes`, code: "invalid_input" }, 400);
|
|
3460
|
+
roomPutBlobChunk(db, hash, idx, total, bytes);
|
|
3461
|
+
return json({ hash, idx, total, bytes: bytes.byteLength });
|
|
3462
|
+
}
|
|
3463
|
+
if (sub === "owner/destroy") {
|
|
3464
|
+
await deps.setAlarm?.(null);
|
|
3465
|
+
await deps.destroy();
|
|
3466
|
+
return json({ destroyed: true });
|
|
3467
|
+
}
|
|
3468
|
+
return json({ error: "not found", code: "not_found" }, 404);
|
|
3469
|
+
}
|
|
3470
|
+
const cfg = tryConfig(db);
|
|
3471
|
+
if (!cfg || roomExpired(cfg))
|
|
3472
|
+
return plain404();
|
|
3473
|
+
if (sub === "unlock" && req.method === "POST")
|
|
3474
|
+
return handleUnlock(db, cfg, req, url);
|
|
3475
|
+
if (sub === "ws")
|
|
3476
|
+
return new Response("upgrade required", { status: 426 });
|
|
3477
|
+
const session = await readRoomSession(db, cfg, req);
|
|
3478
|
+
const locked = !!cfg.pwHash && !session;
|
|
3479
|
+
if (sub === "api" || sub.startsWith("api/")) {
|
|
3480
|
+
if (locked)
|
|
3481
|
+
return json({ error: "unauthorized" }, 401);
|
|
3482
|
+
const gs = session?.sub ? { sub: session.sub, setCookie: null } : await (async () => {
|
|
3483
|
+
const minted = await mintRoomSession(db, cfg, url);
|
|
3484
|
+
return { sub: minted.sub, setCookie: minted.cookie };
|
|
3485
|
+
})();
|
|
3486
|
+
const key = gs.setCookie ? req.headers.get("cf-connecting-ip") ?? "?" : gs.sub;
|
|
3487
|
+
const res = await serveGrantedApi(req, sub === "api" ? "" : sub.slice("api/".length), grantedDepsFromPolicy(policyForRoom(cfg), {
|
|
3488
|
+
db,
|
|
3489
|
+
principal: { kind: "share", guestNode: gs.sub || cfg.guestBase },
|
|
3490
|
+
allow: () => rateLimiter.allow("room-api", `${cfg.slug}:${key}`, SHARE_LIMIT),
|
|
3491
|
+
req,
|
|
3492
|
+
ip: req.headers.get("cf-connecting-ip")
|
|
3493
|
+
}));
|
|
3494
|
+
if (req.method !== "GET" && res.ok)
|
|
3495
|
+
deps.poke?.(maxSeq(db));
|
|
3496
|
+
return withSetCookie(res, gs.setCookie);
|
|
3497
|
+
}
|
|
3498
|
+
if (req.method !== "GET")
|
|
3499
|
+
return plain404();
|
|
3500
|
+
if (locked) {
|
|
3501
|
+
return wantsHtml(req) ? new Response(unlockPage(cfg, false), { headers: HTML }) : json({ error: "unauthorized" }, 401);
|
|
3502
|
+
}
|
|
3503
|
+
return serveRoomFile(db, cfg, sub);
|
|
3504
|
+
};
|
|
3505
|
+
}
|
|
3506
|
+
async function handleUnlock(db, cfg, req, url) {
|
|
3507
|
+
let pw = "";
|
|
3508
|
+
const ct = req.headers.get("content-type") ?? "";
|
|
3509
|
+
if (ct.includes("application/json")) {
|
|
3510
|
+
pw = String((await req.json().catch(() => ({}))).password ?? "");
|
|
3511
|
+
} else {
|
|
3512
|
+
const form = await req.formData().catch(() => null);
|
|
3513
|
+
pw = form ? String(form.get("password") ?? "") : "";
|
|
3514
|
+
}
|
|
3515
|
+
const ok = !cfg.pwHash || !cfg.pwSalt ? true : await verifyPasswordVerifier(cfg.pwHash, cfg.pwSalt, pw);
|
|
3516
|
+
if (!ok)
|
|
3517
|
+
return new Response(unlockPage(cfg, true), { headers: HTML, status: 401 });
|
|
3518
|
+
const minted = await mintRoomSession(db, cfg, url);
|
|
3519
|
+
return new Response(null, {
|
|
3520
|
+
status: 303,
|
|
3521
|
+
headers: { location: `/r/${cfg.slug}/`, "set-cookie": minted.cookie }
|
|
3522
|
+
});
|
|
3523
|
+
}
|
|
3524
|
+
function serveRoomFile(db, cfg, path) {
|
|
3525
|
+
const site = db.query("SELECT id, spa FROM sites WHERE __deleted = 0 ORDER BY created_hlc LIMIT 1").get();
|
|
3526
|
+
if (!site)
|
|
3527
|
+
return roomMessagePage("站点尚未同步", "分享方设备上线同步后即可访问。", 404);
|
|
3528
|
+
const resolved = resolveSiteFileRow(db, site.id, path, { spa: site.spa === 1 });
|
|
3529
|
+
if (!resolved)
|
|
3530
|
+
return roomMessagePage("页面不存在", "这个站点里没有这个页面或文件。", 404);
|
|
3531
|
+
const { row, status } = resolved;
|
|
3532
|
+
const headers = {
|
|
3533
|
+
"content-type": row.content_type,
|
|
3534
|
+
"cache-control": row.content_type.startsWith("text/html") ? "private, no-cache" : "private, max-age=300"
|
|
3535
|
+
};
|
|
3536
|
+
if (row.encoding === "utf8")
|
|
3537
|
+
return new Response(row.content ?? "", { status, headers });
|
|
3538
|
+
if (row.encoding === "base64") {
|
|
3539
|
+
const bytes2 = base64ToBytes(row.content ?? "");
|
|
3540
|
+
const body2 = new Uint8Array(bytes2.byteLength);
|
|
3541
|
+
body2.set(bytes2);
|
|
3542
|
+
return new Response(body2.buffer, { status, headers });
|
|
3543
|
+
}
|
|
3544
|
+
const bytes = row.content ? roomBlobBytes(db, row.content) : null;
|
|
3545
|
+
if (!bytes)
|
|
3546
|
+
return roomMessagePage("资源暂不可用", "文件内容尚未同步到房间,请稍后重试。", 404);
|
|
3547
|
+
const body = new Uint8Array(bytes.byteLength);
|
|
3548
|
+
body.set(bytes);
|
|
3549
|
+
return new Response(body.buffer, { status, headers });
|
|
3550
|
+
}
|
|
3551
|
+
var PAGE_CSS = `
|
|
3552
|
+
:root{--bg:#ffffff;--fg:#1f2328;--muted:#6e7781;--line:#d0d7de;--accent:#0969da;--card:#f6f8fa}
|
|
3553
|
+
@media (prefers-color-scheme: dark){:root{--bg:#0d1117;--fg:#e6edf3;--muted:#8b949e;--line:#30363d;--accent:#4493f8;--card:#161b22}}
|
|
3554
|
+
*{box-sizing:border-box}
|
|
3555
|
+
body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.65 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"PingFang SC","Microsoft YaHei",sans-serif;
|
|
3556
|
+
min-height:100dvh;display:flex;align-items:center;justify-content:center;padding:24px}
|
|
3557
|
+
main{max-width:420px;width:100%}
|
|
3558
|
+
form.pw{display:flex;flex-direction:column;gap:12px}
|
|
3559
|
+
form.pw input{padding:9px;border:1px solid var(--line);border-radius:7px;background:var(--card);color:var(--fg)}
|
|
3560
|
+
form.pw button{padding:9px;border:0;border-radius:7px;background:var(--accent);color:#fff;cursor:pointer}
|
|
3561
|
+
.err{color:#f85149;font-size:13px;margin:0}
|
|
3562
|
+
.muted{color:var(--muted);font-size:14px}
|
|
3563
|
+
h1{font-size:20px;margin:0 0 10px;text-align:center}
|
|
3564
|
+
footer{margin-top:34px;color:var(--muted);font-size:12px;text-align:center}`;
|
|
3565
|
+
function unlockPage(cfg, error) {
|
|
3566
|
+
return `<!doctype html><html lang="zh"><head>
|
|
3567
|
+
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
3568
|
+
<meta name="robots" content="noindex"><title>受保护的分享</title>
|
|
3569
|
+
<style>${PAGE_CSS}</style></head><body><main>
|
|
3570
|
+
<form method="post" action="/r/${escapeHtml(cfg.slug)}/unlock" class="pw">
|
|
3571
|
+
<h1>\uD83D\uDD12 此分享受口令保护</h1>
|
|
3572
|
+
${error ? '<p class="err">口令错误,请重试。</p>' : ""}
|
|
3573
|
+
<input type="password" name="password" placeholder="口令" autofocus autocomplete="current-password">
|
|
3574
|
+
<button type="submit">解锁</button>
|
|
3575
|
+
</form>
|
|
3576
|
+
<footer>通过 metahub 分享</footer>
|
|
3577
|
+
</main></body></html>`;
|
|
3578
|
+
}
|
|
3579
|
+
function roomMessagePage(title, detail, status) {
|
|
3580
|
+
const html = `<!doctype html><html lang="zh"><head>
|
|
3581
|
+
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
3582
|
+
<meta name="robots" content="noindex"><title>${escapeHtml(title)}</title>
|
|
3583
|
+
<style>${PAGE_CSS}</style></head><body><main>
|
|
3584
|
+
<h1>${escapeHtml(title)}</h1>
|
|
3585
|
+
<p class="muted" style="text-align:center">${escapeHtml(detail)}</p>
|
|
3586
|
+
<footer>由 metahub 托管</footer>
|
|
3587
|
+
</main></body></html>`;
|
|
3588
|
+
return new Response(html, { status, headers: { ...HTML, "cache-control": "no-store" } });
|
|
3589
|
+
}
|
|
3590
|
+
|
|
3591
|
+
// src/workers/room.ts
|
|
3592
|
+
var g = globalThis;
|
|
3593
|
+
|
|
3594
|
+
class MhRoom {
|
|
3595
|
+
state;
|
|
3596
|
+
db;
|
|
3597
|
+
handler;
|
|
3598
|
+
constructor(state, env) {
|
|
3599
|
+
this.state = state;
|
|
3600
|
+
this.db = new DoSqlDriver(state.storage);
|
|
3601
|
+
this.handler = createRoomFetch({
|
|
3602
|
+
db: this.db,
|
|
3603
|
+
ownerToken: env.OWNER_TOKEN,
|
|
3604
|
+
poke: (seq) => this.broadcast(seq),
|
|
3605
|
+
destroy: async () => {
|
|
3606
|
+
for (const ws of this.state.getWebSockets()) {
|
|
3607
|
+
try {
|
|
3608
|
+
ws.close(1001, "room destroyed");
|
|
3609
|
+
} catch {}
|
|
3610
|
+
}
|
|
3611
|
+
await this.state.storage.deleteAlarm?.();
|
|
3612
|
+
await this.state.storage.deleteAll();
|
|
3613
|
+
},
|
|
3614
|
+
setAlarm: async (at) => {
|
|
3615
|
+
if (at == null)
|
|
3616
|
+
await this.state.storage.deleteAlarm?.();
|
|
3617
|
+
else
|
|
3618
|
+
await this.state.storage.setAlarm(at);
|
|
3619
|
+
},
|
|
3620
|
+
yieldClock: async () => {
|
|
3621
|
+
const scheduler = g.scheduler;
|
|
3622
|
+
if (scheduler?.wait)
|
|
3623
|
+
await scheduler.wait(1);
|
|
3624
|
+
}
|
|
3625
|
+
});
|
|
3626
|
+
state.blockConcurrencyWhile?.(async () => {});
|
|
3627
|
+
const Pair = g.WebSocketRequestResponsePair;
|
|
3628
|
+
if (Pair && state.setWebSocketAutoResponse) {
|
|
3629
|
+
state.setWebSocketAutoResponse(new Pair("ping", "pong"));
|
|
3630
|
+
}
|
|
3631
|
+
}
|
|
3632
|
+
async fetch(req) {
|
|
3633
|
+
const url = new URL(req.url);
|
|
3634
|
+
if (/^\/r\/[^/]+\/ws$/.test(url.pathname) && (req.headers.get("upgrade") ?? "").toLowerCase() === "websocket")
|
|
3635
|
+
return this.upgrade(req);
|
|
3636
|
+
return this.handler(req);
|
|
3637
|
+
}
|
|
3638
|
+
async upgrade(req) {
|
|
3639
|
+
const sess = await roomWsSession(this.db, req);
|
|
3640
|
+
if (!sess)
|
|
3641
|
+
return new Response("not found", { status: 404 });
|
|
3642
|
+
const PairCtor = g.WebSocketPair;
|
|
3643
|
+
if (!PairCtor)
|
|
3644
|
+
return new Response("websockets unavailable", { status: 501 });
|
|
3645
|
+
const pair = new PairCtor;
|
|
3646
|
+
const client = pair[0];
|
|
3647
|
+
const server = pair[1];
|
|
3648
|
+
this.state.acceptWebSocket(server);
|
|
3649
|
+
server.serializeAttachment?.({ sub: sess.sub });
|
|
3650
|
+
return new Response(null, {
|
|
3651
|
+
status: 101,
|
|
3652
|
+
webSocket: client
|
|
3653
|
+
});
|
|
3654
|
+
}
|
|
3655
|
+
async webSocketMessage(ws, message) {
|
|
3656
|
+
if (typeof message !== "string")
|
|
3657
|
+
return;
|
|
3658
|
+
const att = ws.deserializeAttachment?.() ?? {};
|
|
3659
|
+
const out = await roomWsMessage(this.db, att.sub ?? "", message);
|
|
3660
|
+
if (out.reply) {
|
|
3661
|
+
try {
|
|
3662
|
+
ws.send(out.reply);
|
|
3663
|
+
} catch {}
|
|
3664
|
+
}
|
|
3665
|
+
if (out.poke)
|
|
3666
|
+
this.broadcast(out.seq);
|
|
3667
|
+
}
|
|
3668
|
+
async alarm() {
|
|
3669
|
+
let cfg;
|
|
3670
|
+
try {
|
|
3671
|
+
cfg = readRoomConfig(this.db);
|
|
3672
|
+
} catch {
|
|
3673
|
+
await this.state.storage.deleteAll();
|
|
3674
|
+
return;
|
|
3675
|
+
}
|
|
3676
|
+
if (!roomExpired(cfg)) {
|
|
3677
|
+
const next = cfg.expiresAt != null ? Math.max(Date.now() + 1000, cfg.expiresAt) : Date.now() + 60000;
|
|
3678
|
+
await this.state.storage.setAlarm(next);
|
|
3679
|
+
return;
|
|
3680
|
+
}
|
|
3681
|
+
for (const ws of this.state.getWebSockets()) {
|
|
3682
|
+
try {
|
|
3683
|
+
ws.close(1001, "room expired");
|
|
3684
|
+
} catch {}
|
|
3685
|
+
}
|
|
3686
|
+
await this.state.storage.deleteAll();
|
|
3687
|
+
}
|
|
3688
|
+
broadcast(seq) {
|
|
3689
|
+
const msg = JSON.stringify({ type: "poke", seq });
|
|
3690
|
+
for (const ws of this.state.getWebSockets()) {
|
|
3691
|
+
try {
|
|
3692
|
+
ws.send(msg);
|
|
3693
|
+
} catch {}
|
|
3694
|
+
}
|
|
3695
|
+
}
|
|
3696
|
+
}
|
|
3697
|
+
// src/core/sync/edge-config.ts
|
|
3698
|
+
var EXPECTED_EDGE_WORKER_VERSION = "3";
|
|
3699
|
+
|
|
3700
|
+
// src/workers/edge-worker.ts
|
|
3701
|
+
var EDGE_WORKER_VERSION = EXPECTED_EDGE_WORKER_VERSION;
|
|
3702
|
+
var EDGE_WORKER_MARKER = "mh-edge-worker";
|
|
3703
|
+
var DROP_DEFAULT_MAX_ENVELOPES = 2000;
|
|
3704
|
+
var DROP_DEFAULT_MAX_BYTES = 20 * 1024 * 1024;
|
|
3705
|
+
var DROP_ENVELOPE_LIMIT_BYTES = 64 * 1024;
|
|
3706
|
+
var LIST_LIMIT_DEFAULT2 = 100;
|
|
3707
|
+
var LIST_LIMIT_MAX2 = 500;
|
|
3708
|
+
var EDGE_SCHEMA_SQL = `
|
|
3709
|
+
CREATE TABLE IF NOT EXISTS drops (
|
|
3710
|
+
drop_id TEXT PRIMARY KEY,
|
|
3711
|
+
turnstile_sitekey TEXT,
|
|
3712
|
+
turnstile_secret TEXT,
|
|
3713
|
+
password_salt TEXT,
|
|
3714
|
+
password_verifier TEXT,
|
|
3715
|
+
max_envelopes INTEGER NOT NULL DEFAULT ${DROP_DEFAULT_MAX_ENVELOPES},
|
|
3716
|
+
max_bytes INTEGER NOT NULL DEFAULT ${DROP_DEFAULT_MAX_BYTES},
|
|
3717
|
+
created_at INTEGER NOT NULL
|
|
3718
|
+
);
|
|
3719
|
+
CREATE TABLE IF NOT EXISTS envelopes (
|
|
3720
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
3721
|
+
drop_id TEXT NOT NULL,
|
|
3722
|
+
envelope_id TEXT NOT NULL,
|
|
3723
|
+
body TEXT NOT NULL,
|
|
3724
|
+
bytes INTEGER NOT NULL,
|
|
3725
|
+
created_at INTEGER NOT NULL,
|
|
3726
|
+
UNIQUE (drop_id, envelope_id)
|
|
3727
|
+
);
|
|
3728
|
+
CREATE INDEX IF NOT EXISTS idx_envelopes_drop ON envelopes(drop_id, id);
|
|
3729
|
+
CREATE INDEX IF NOT EXISTS idx_envelopes_drop_created ON envelopes(drop_id, created_at);
|
|
3730
|
+
`;
|
|
3731
|
+
function json2(data, status = 200) {
|
|
3732
|
+
return new Response(JSON.stringify(data), {
|
|
3733
|
+
status,
|
|
3734
|
+
headers: { "content-type": "application/json" }
|
|
3735
|
+
});
|
|
3736
|
+
}
|
|
3737
|
+
function err(status, code, message) {
|
|
3738
|
+
return json2({ error: message, code }, status);
|
|
3739
|
+
}
|
|
3740
|
+
function isOwner2(req, ownerToken) {
|
|
3741
|
+
if (!ownerToken)
|
|
3742
|
+
return false;
|
|
3743
|
+
const h = req.headers.get("authorization") ?? "";
|
|
3744
|
+
return h.startsWith("Bearer ") && timingSafeEq(h.slice(7), ownerToken);
|
|
3745
|
+
}
|
|
3746
|
+
function createInboxFetch(deps) {
|
|
3747
|
+
const { sql } = deps;
|
|
3748
|
+
const now = deps.now ?? (() => Date.now());
|
|
3749
|
+
const getDrop = async (dropId) => {
|
|
3750
|
+
const rows = await sql.all("SELECT * FROM drops WHERE drop_id = ?", [dropId]);
|
|
3751
|
+
return rows[0] ?? null;
|
|
3752
|
+
};
|
|
3753
|
+
const pruneExpired = async (dropId, currentTime) => {
|
|
3754
|
+
await sql.run("DELETE FROM envelopes WHERE drop_id = ? AND created_at < ?", [dropId, Math.max(0, currentTime - DROP_ENVELOPE_RETENTION_MS)]);
|
|
3755
|
+
};
|
|
3756
|
+
return async (req) => {
|
|
3757
|
+
const url = new URL(req.url);
|
|
3758
|
+
if (req.method === "GET" && url.pathname === "/health") {
|
|
3759
|
+
return json2({ ok: true, service: EDGE_WORKER_MARKER, version: EDGE_WORKER_VERSION });
|
|
3760
|
+
}
|
|
3761
|
+
if (req.method === "GET" && url.pathname === "/owner/health") {
|
|
3762
|
+
if (!isOwner2(req, deps.ownerToken))
|
|
3763
|
+
return err(401, "auth", "unauthorized");
|
|
3764
|
+
return json2({ ok: true, service: EDGE_WORKER_MARKER, version: EDGE_WORKER_VERSION });
|
|
3765
|
+
}
|
|
3766
|
+
const m = /^\/v1\/inbox\/([^/]+)(?:\/(envelopes|stats))?$/.exec(url.pathname);
|
|
3767
|
+
if (!m)
|
|
3768
|
+
return err(404, "not_found", "not found");
|
|
3769
|
+
const dropId = safeDecode(m[1]);
|
|
3770
|
+
if (dropId === null)
|
|
3771
|
+
return err(400, "invalid_input", "malformed drop id");
|
|
3772
|
+
const sub = m[2] ?? null;
|
|
3773
|
+
if (req.method === "POST" && sub === "envelopes") {
|
|
3774
|
+
const drop = await getDrop(dropId);
|
|
3775
|
+
if (!drop)
|
|
3776
|
+
return err(404, "not_found", "no such drop");
|
|
3777
|
+
const t = now();
|
|
3778
|
+
await pruneExpired(dropId, t);
|
|
3779
|
+
const text = await req.text();
|
|
3780
|
+
const bytes = new TextEncoder().encode(text).byteLength;
|
|
3781
|
+
if (bytes > DROP_ENVELOPE_LIMIT_BYTES)
|
|
3782
|
+
return err(413, "invalid_input", `envelope too large (max ${DROP_ENVELOPE_LIMIT_BYTES} bytes)`);
|
|
3783
|
+
let env;
|
|
3784
|
+
try {
|
|
3785
|
+
env = JSON.parse(text);
|
|
3786
|
+
} catch {
|
|
3787
|
+
return err(400, "invalid_input", "body must be a JSON envelope");
|
|
3788
|
+
}
|
|
3789
|
+
if (!env || env.v !== 1 || typeof env.envelope_id !== "string" || env.envelope_id === "" || env.envelope_id.length > 64 || typeof env.sealed !== "string" || env.drop_id !== dropId)
|
|
3790
|
+
return err(400, "invalid_input", "malformed envelope");
|
|
3791
|
+
try {
|
|
3792
|
+
await assertAntiAbuse({ turnstileSecret: drop.turnstile_secret, passwordVerifier: drop.password_verifier }, req, { verifyTurnstile: deps.verifyTurnstile, ip: req.headers.get("cf-connecting-ip") });
|
|
3793
|
+
} catch (e) {
|
|
3794
|
+
return err(401, "auth", e.message);
|
|
3795
|
+
}
|
|
3796
|
+
const ins = await sql.run(`INSERT OR IGNORE INTO envelopes (drop_id, envelope_id, body, bytes, created_at)
|
|
3797
|
+
SELECT ?, ?, ?, ?, ?
|
|
3798
|
+
WHERE (SELECT COUNT(*) FROM envelopes WHERE drop_id = ?) < ?
|
|
3799
|
+
AND (SELECT COALESCE(SUM(bytes), 0) FROM envelopes WHERE drop_id = ?) + ? <= ?`, [dropId, env.envelope_id, text, bytes, t, dropId, drop.max_envelopes, dropId, bytes, drop.max_bytes]);
|
|
3800
|
+
if (ins.changes === 0) {
|
|
3801
|
+
const dup = await sql.all("SELECT 1 AS x FROM envelopes WHERE drop_id = ? AND envelope_id = ?", [
|
|
3802
|
+
dropId,
|
|
3803
|
+
env.envelope_id
|
|
3804
|
+
]);
|
|
3805
|
+
if (dup.length > 0)
|
|
3806
|
+
return json2({ envelope_id: env.envelope_id, server_time: t, duplicate: true });
|
|
3807
|
+
return err(429, "drop_full", "drop is full — try again later");
|
|
3808
|
+
}
|
|
3809
|
+
return json2({ envelope_id: env.envelope_id, server_time: t });
|
|
3810
|
+
}
|
|
3811
|
+
if (!isOwner2(req, deps.ownerToken))
|
|
3812
|
+
return err(401, "auth", "unauthorized");
|
|
3813
|
+
if (sub === null && req.method === "PUT") {
|
|
3814
|
+
let body;
|
|
3815
|
+
try {
|
|
3816
|
+
body = await req.json();
|
|
3817
|
+
} catch {
|
|
3818
|
+
body = {};
|
|
3819
|
+
}
|
|
3820
|
+
const s = (k) => typeof body[k] === "string" && body[k] !== "" ? body[k] : null;
|
|
3821
|
+
const n = (k, dflt) => typeof body[k] === "number" && body[k] > 0 ? Math.floor(body[k]) : dflt;
|
|
3822
|
+
await sql.run(`INSERT INTO drops (drop_id, turnstile_sitekey, turnstile_secret, password_salt, password_verifier, max_envelopes, max_bytes, created_at)
|
|
3823
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
3824
|
+
ON CONFLICT(drop_id) DO UPDATE SET
|
|
3825
|
+
turnstile_sitekey = excluded.turnstile_sitekey,
|
|
3826
|
+
turnstile_secret = excluded.turnstile_secret,
|
|
3827
|
+
password_salt = excluded.password_salt,
|
|
3828
|
+
password_verifier = excluded.password_verifier,
|
|
3829
|
+
max_envelopes = excluded.max_envelopes,
|
|
3830
|
+
max_bytes = excluded.max_bytes`, [
|
|
3831
|
+
dropId,
|
|
3832
|
+
s("turnstile_sitekey"),
|
|
3833
|
+
s("turnstile_secret"),
|
|
3834
|
+
s("password_salt"),
|
|
3835
|
+
s("password_verifier"),
|
|
3836
|
+
n("max_envelopes", DROP_DEFAULT_MAX_ENVELOPES),
|
|
3837
|
+
n("max_bytes", DROP_DEFAULT_MAX_BYTES),
|
|
3838
|
+
now()
|
|
3839
|
+
]);
|
|
3840
|
+
return json2({ drop_id: dropId, registered: true });
|
|
3841
|
+
}
|
|
3842
|
+
if (sub === null && req.method === "DELETE") {
|
|
3843
|
+
await sql.run("DELETE FROM envelopes WHERE drop_id = ?", [dropId]);
|
|
3844
|
+
const r = await sql.run("DELETE FROM drops WHERE drop_id = ?", [dropId]);
|
|
3845
|
+
return json2({ drop_id: dropId, deleted: r.changes > 0 });
|
|
3846
|
+
}
|
|
3847
|
+
if (sub === "envelopes" && req.method === "GET") {
|
|
3848
|
+
if (!await getDrop(dropId))
|
|
3849
|
+
return err(404, "not_found", "no such drop");
|
|
3850
|
+
await pruneExpired(dropId, now());
|
|
3851
|
+
const afterId = Math.max(0, Number(url.searchParams.get("after_id") ?? 0) || 0);
|
|
3852
|
+
const rawLimit = Number(url.searchParams.get("limit") ?? NaN);
|
|
3853
|
+
const limit = Number.isFinite(rawLimit) ? Math.max(1, Math.min(Math.floor(rawLimit), LIST_LIMIT_MAX2)) : LIST_LIMIT_DEFAULT2;
|
|
3854
|
+
const rows = await sql.all("SELECT id, body FROM envelopes WHERE drop_id = ? AND id > ? ORDER BY id LIMIT ?", [dropId, afterId, limit]);
|
|
3855
|
+
return json2({
|
|
3856
|
+
rows: rows.map((r) => {
|
|
3857
|
+
let envelope;
|
|
3858
|
+
try {
|
|
3859
|
+
envelope = JSON.parse(r.body);
|
|
3860
|
+
} catch {
|
|
3861
|
+
envelope = null;
|
|
3862
|
+
}
|
|
3863
|
+
return { id: r.id, envelope };
|
|
3864
|
+
})
|
|
3865
|
+
});
|
|
3866
|
+
}
|
|
3867
|
+
if (sub === "envelopes" && req.method === "DELETE") {
|
|
3868
|
+
let ids;
|
|
3869
|
+
try {
|
|
3870
|
+
ids = (await req.json())?.ids;
|
|
3871
|
+
} catch {
|
|
3872
|
+
ids = null;
|
|
3873
|
+
}
|
|
3874
|
+
if (!Array.isArray(ids) || !ids.every((x) => typeof x === "number"))
|
|
3875
|
+
return err(400, "invalid_input", "body must be {ids: number[]}");
|
|
3876
|
+
if (ids.length === 0)
|
|
3877
|
+
return json2({ deleted: 0 });
|
|
3878
|
+
const marks = ids.map(() => "?").join(", ");
|
|
3879
|
+
const r = await sql.run(`DELETE FROM envelopes WHERE drop_id = ? AND id IN (${marks})`, [
|
|
3880
|
+
dropId,
|
|
3881
|
+
...ids
|
|
3882
|
+
]);
|
|
3883
|
+
return json2({ deleted: r.changes });
|
|
3884
|
+
}
|
|
3885
|
+
if (sub === "stats" && req.method === "GET") {
|
|
3886
|
+
const drop = await getDrop(dropId);
|
|
3887
|
+
if (!drop)
|
|
3888
|
+
return err(404, "not_found", "no such drop");
|
|
3889
|
+
await pruneExpired(dropId, now());
|
|
3890
|
+
const rows = await sql.all("SELECT COUNT(*) AS n, COALESCE(SUM(bytes), 0) AS b FROM envelopes WHERE drop_id = ?", [dropId]);
|
|
3891
|
+
return json2({
|
|
3892
|
+
drop_id: dropId,
|
|
3893
|
+
envelopes: rows[0]?.n ?? 0,
|
|
3894
|
+
bytes: rows[0]?.b ?? 0,
|
|
3895
|
+
max_envelopes: drop.max_envelopes,
|
|
3896
|
+
max_bytes: drop.max_bytes
|
|
3897
|
+
});
|
|
3898
|
+
}
|
|
3899
|
+
return err(404, "not_found", "not found");
|
|
3900
|
+
};
|
|
3901
|
+
}
|
|
3902
|
+
var OWNER_CORS = {
|
|
3903
|
+
"access-control-allow-origin": "*",
|
|
3904
|
+
"access-control-allow-methods": "GET, POST, PUT, DELETE, OPTIONS",
|
|
3905
|
+
"access-control-allow-headers": "authorization, content-type",
|
|
3906
|
+
"access-control-max-age": "86400"
|
|
3907
|
+
};
|
|
3908
|
+
function isOwnerCorsPath(pathname) {
|
|
3909
|
+
return pathname === "/health" || pathname === "/owner/health" || /^\/v1\/inbox\/[^/]+(?:\/(?:stats|envelopes))?$/.test(pathname) || /^\/r\/[^/]+\/owner(?:\/|$)/.test(pathname);
|
|
3910
|
+
}
|
|
3911
|
+
function corsResponse(res) {
|
|
3912
|
+
if (res.status === 101)
|
|
3913
|
+
return res;
|
|
3914
|
+
const headers = new Headers(res.headers);
|
|
3915
|
+
for (const [k, v] of Object.entries(OWNER_CORS))
|
|
3916
|
+
headers.set(k, v);
|
|
3917
|
+
return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
|
|
3918
|
+
}
|
|
3919
|
+
function d1Sql(db) {
|
|
3920
|
+
return {
|
|
3921
|
+
async all(sql, params = []) {
|
|
3922
|
+
const r = await db.prepare(sql).bind(...params).all();
|
|
3923
|
+
return r.results ?? [];
|
|
3924
|
+
},
|
|
3925
|
+
async run(sql, params = []) {
|
|
3926
|
+
const r = await db.prepare(sql).bind(...params).run();
|
|
3927
|
+
return { changes: r.meta?.changes ?? 0 };
|
|
3928
|
+
}
|
|
3929
|
+
};
|
|
3930
|
+
}
|
|
3931
|
+
var edge_worker_default = {
|
|
3932
|
+
async fetch(req, env) {
|
|
3933
|
+
const path = new URL(req.url).pathname;
|
|
3934
|
+
const cors = isOwnerCorsPath(path);
|
|
3935
|
+
if (cors && req.method === "OPTIONS")
|
|
3936
|
+
return new Response(null, { status: 204, headers: OWNER_CORS });
|
|
3937
|
+
const room = /^\/r\/([^/]+)/.exec(new URL(req.url).pathname);
|
|
3938
|
+
if (room && env.ROOM) {
|
|
3939
|
+
const slug = safeDecode(room[1]);
|
|
3940
|
+
if (slug === null)
|
|
3941
|
+
return new Response("bad request", { status: 400 });
|
|
3942
|
+
const id = env.ROOM.idFromName(slug);
|
|
3943
|
+
const res2 = await env.ROOM.get(id).fetch(req);
|
|
3944
|
+
return cors ? corsResponse(res2) : res2;
|
|
3945
|
+
}
|
|
3946
|
+
const res = await createInboxFetch({ sql: d1Sql(env.DB), ownerToken: env.OWNER_TOKEN })(req);
|
|
3947
|
+
return cors ? corsResponse(res) : res;
|
|
3948
|
+
}
|
|
3949
|
+
};
|
|
3950
|
+
export {
|
|
3951
|
+
edge_worker_default as default,
|
|
3952
|
+
createInboxFetch,
|
|
3953
|
+
MhRoom,
|
|
3954
|
+
EDGE_WORKER_VERSION,
|
|
3955
|
+
EDGE_WORKER_MARKER,
|
|
3956
|
+
EDGE_SCHEMA_SQL,
|
|
3957
|
+
DROP_ENVELOPE_LIMIT_BYTES,
|
|
3958
|
+
DROP_DEFAULT_MAX_ENVELOPES,
|
|
3959
|
+
DROP_DEFAULT_MAX_BYTES
|
|
3960
|
+
};
|