@tensorix/metahub 0.5.1 → 0.5.3
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 +8 -4
- package/README.zh-CN.md +9 -5
- package/dist/cli.js +5539 -4193
- package/dist/cli.js.map +68 -61
- package/dist/core/audit.d.ts.map +1 -1
- package/dist/core/blobs-core.d.ts.map +1 -1
- package/dist/core/compact.d.ts.map +1 -1
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/crdt.d.ts +12 -4
- package/dist/core/crdt.d.ts.map +1 -1
- package/dist/core/databases.d.ts +3 -2
- package/dist/core/databases.d.ts.map +1 -1
- 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/driver.d.ts +5 -1
- package/dist/core/driver.d.ts.map +1 -1
- package/dist/core/errors.d.ts +19 -1
- package/dist/core/errors.d.ts.map +1 -1
- package/dist/core/guest-intent.d.ts +1 -1
- package/dist/core/guest-intent.d.ts.map +1 -1
- package/dist/core/history-cache.d.ts +13 -0
- package/dist/core/history-cache.d.ts.map +1 -0
- package/dist/core/history.d.ts.map +1 -1
- package/dist/core/hlc.d.ts +13 -0
- package/dist/core/hlc.d.ts.map +1 -1
- package/dist/core/integrity.d.ts +19 -2
- package/dist/core/integrity.d.ts.map +1 -1
- package/dist/core/node.d.ts +52 -6
- package/dist/core/node.d.ts.map +1 -1
- package/dist/core/resolve.d.ts +8 -0
- package/dist/core/resolve.d.ts.map +1 -1
- package/dist/core/schema-init.d.ts +4 -1
- 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/search.d.ts +1 -1
- package/dist/core/search.d.ts.map +1 -1
- package/dist/core/site-channel-store.d.ts +1 -1
- package/dist/core/site-channel-store.d.ts.map +1 -1
- package/dist/core/snapshot.d.ts.map +1 -1
- package/dist/core/sync/auth.d.ts +2 -4
- package/dist/core/sync/auth.d.ts.map +1 -1
- package/dist/core/sync/cf-oauth.d.ts.map +1 -1
- package/dist/core/sync/client.d.ts +4 -0
- package/dist/core/sync/client.d.ts.map +1 -1
- package/dist/core/sync/devices.d.ts +6 -0
- package/dist/core/sync/devices.d.ts.map +1 -1
- package/dist/core/sync/drop-protocol.d.ts.map +1 -1
- package/dist/core/sync/drop-pull.d.ts +1 -1
- package/dist/core/sync/drop-pull.d.ts.map +1 -1
- package/dist/core/sync/http-status.d.ts.map +1 -1
- package/dist/core/sync/locale.d.ts +8 -0
- package/dist/core/sync/locale.d.ts.map +1 -0
- package/dist/core/sync/page-messages.d.ts +106 -0
- package/dist/core/sync/page-messages.d.ts.map +1 -0
- package/dist/core/sync/pairing.d.ts +4 -1
- package/dist/core/sync/pairing.d.ts.map +1 -1
- package/dist/core/sync/peers-routes.d.ts.map +1 -1
- package/dist/core/sync/peers.d.ts +3 -1
- package/dist/core/sync/peers.d.ts.map +1 -1
- package/dist/core/sync/protocol.d.ts +1 -0
- package/dist/core/sync/protocol.d.ts.map +1 -1
- package/dist/core/sync/remote-shares-cache.d.ts +32 -0
- package/dist/core/sync/remote-shares-cache.d.ts.map +1 -0
- package/dist/core/sync/room-client.d.ts +1 -1
- package/dist/core/sync/room-client.d.ts.map +1 -1
- package/dist/core/sync/room-protocol.d.ts +1 -1
- package/dist/core/sync/room-protocol.d.ts.map +1 -1
- package/dist/core/sync/room-serve.d.ts.map +1 -1
- package/dist/core/sync/routes.d.ts +1 -1
- package/dist/core/sync/routes.d.ts.map +1 -1
- package/dist/core/sync/server.d.ts +2 -0
- package/dist/core/sync/server.d.ts.map +1 -1
- package/dist/core/sync/share-actions.d.ts +7 -2
- package/dist/core/sync/share-actions.d.ts.map +1 -1
- package/dist/core/sync/share-copy.d.ts +7 -0
- package/dist/core/sync/share-copy.d.ts.map +1 -0
- package/dist/core/sync/share-export.d.ts +3 -1
- package/dist/core/sync/share-export.d.ts.map +1 -1
- package/dist/core/sync/share-serve.d.ts.map +1 -1
- package/dist/core/sync/sites-serve.d.ts.map +1 -1
- package/dist/core/sync/storage.d.ts +5 -1
- package/dist/core/sync/storage.d.ts.map +1 -1
- package/dist/core/tables.d.ts +16 -0
- package/dist/core/tables.d.ts.map +1 -0
- package/dist/db-worker.js +131 -88
- package/dist/edge-worker.js +353 -90
- package/dist/index.js +2051 -471
- package/dist/index.js.map +57 -49
- package/dist/metahub-sdk.js +5 -5
- package/dist/mh-runtime.js +9 -1
- package/dist/sw.js +15 -7
- package/dist/webui/server/assets.d.ts.map +1 -1
- package/dist/webui/server/changes-route.d.ts +2 -0
- package/dist/webui/server/changes-route.d.ts.map +1 -1
- package/dist/webui/server/edge-routes.d.ts.map +1 -1
- package/dist/webui/server/routes.d.ts +2 -0
- package/dist/webui/server/routes.d.ts.map +1 -1
- package/dist/webui-fmt.js +1 -1
- package/dist/webui.js +173 -214
- package/package.json +1 -1
package/dist/edge-worker.js
CHANGED
|
@@ -69,14 +69,38 @@ function normalizeRow(row) {
|
|
|
69
69
|
// src/core/errors.ts
|
|
70
70
|
class MhError extends Error {
|
|
71
71
|
code;
|
|
72
|
-
|
|
72
|
+
i18n;
|
|
73
|
+
constructor(code, message, i18n) {
|
|
73
74
|
super(message);
|
|
74
75
|
this.code = code;
|
|
76
|
+
this.i18n = i18n;
|
|
75
77
|
this.name = "MhError";
|
|
76
78
|
}
|
|
77
79
|
}
|
|
80
|
+
function interpolateMsg(s, params) {
|
|
81
|
+
return params ? s.replace(/\{(\w+)\}/g, (m, k) => (k in params) ? String(params[k]) : m) : s;
|
|
82
|
+
}
|
|
83
|
+
function mhError(code, key, params) {
|
|
84
|
+
return new MhError(code, interpolateMsg(key, params), { key, params });
|
|
85
|
+
}
|
|
86
|
+
var SQLITE_BUSY_RE = /^SQLITE_(BUSY|LOCKED)/;
|
|
87
|
+
function isSqliteBusy(e) {
|
|
88
|
+
if (!(e instanceof Error))
|
|
89
|
+
return false;
|
|
90
|
+
const code = e.code;
|
|
91
|
+
if (typeof code === "string" && SQLITE_BUSY_RE.test(code))
|
|
92
|
+
return true;
|
|
93
|
+
return e.message === "database is locked";
|
|
94
|
+
}
|
|
95
|
+
function asMhError(e) {
|
|
96
|
+
if (e instanceof MhError)
|
|
97
|
+
return e;
|
|
98
|
+
if (isSqliteBusy(e))
|
|
99
|
+
return mhError("busy", "数据库正被另一个进程写入,请稍后重试");
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
78
102
|
function errorCode(e) {
|
|
79
|
-
return e
|
|
103
|
+
return asMhError(e)?.code;
|
|
80
104
|
}
|
|
81
105
|
|
|
82
106
|
// src/core/sync/http-status.ts
|
|
@@ -87,7 +111,9 @@ var STATUS = {
|
|
|
87
111
|
stale: 409,
|
|
88
112
|
conflict: 409,
|
|
89
113
|
auth: 401,
|
|
90
|
-
rate_limited: 429
|
|
114
|
+
rate_limited: 429,
|
|
115
|
+
busy: 503,
|
|
116
|
+
clock_skew: 409
|
|
91
117
|
};
|
|
92
118
|
function httpStatusForCode(code) {
|
|
93
119
|
return code ? STATUS[code] ?? 400 : 400;
|
|
@@ -132,6 +158,12 @@ function idKind(id) {
|
|
|
132
158
|
return KINDS.has(prefix) ? prefix : null;
|
|
133
159
|
}
|
|
134
160
|
|
|
161
|
+
// src/core/driver.ts
|
|
162
|
+
function writeTx(db, fn) {
|
|
163
|
+
const tx = db.transaction(fn);
|
|
164
|
+
return (tx.immediate ?? tx)();
|
|
165
|
+
}
|
|
166
|
+
|
|
135
167
|
// src/core/node.ts
|
|
136
168
|
function getNodeId(db) {
|
|
137
169
|
const row = db.query("SELECT value FROM meta WHERE key = 'node_id'").get();
|
|
@@ -144,6 +176,13 @@ function getNodeId(db) {
|
|
|
144
176
|
|
|
145
177
|
// src/core/hlc.ts
|
|
146
178
|
var MAX_HLC_COUNTER = 65535;
|
|
179
|
+
var HLC_MAX_SKEW_MS = 5 * 60000;
|
|
180
|
+
function hlcSkewBound(now) {
|
|
181
|
+
return formatHlc({ millis: now + HLC_MAX_SKEW_MS, counter: MAX_HLC_COUNTER, node: "~~~~~~~~" });
|
|
182
|
+
}
|
|
183
|
+
function isSkewedHlc(hlc, now) {
|
|
184
|
+
return hlc > hlcSkewBound(now);
|
|
185
|
+
}
|
|
147
186
|
function carryCounter(millis, counter) {
|
|
148
187
|
if (counter <= MAX_HLC_COUNTER)
|
|
149
188
|
return { millis, counter };
|
|
@@ -174,6 +213,8 @@ function writeLast(db, h) {
|
|
|
174
213
|
}
|
|
175
214
|
function nextHlc(db, node, now = Date.now()) {
|
|
176
215
|
const last = readLast(db, node);
|
|
216
|
+
if (last.millis > now + HLC_MAX_SKEW_MS)
|
|
217
|
+
throw mhError("clock_skew", "本机逻辑时钟比系统时间超前 {min} 分钟(系统时钟曾被调错,或收到过时钟错误设备的数据),运行 mh repair --clock 修复", { min: Math.ceil((last.millis - now) / 60000) });
|
|
177
218
|
const millis = Math.max(last.millis, now);
|
|
178
219
|
const counter = millis === last.millis ? last.counter + 1 : 0;
|
|
179
220
|
const h = { ...carryCounter(millis, counter), node };
|
|
@@ -185,6 +226,8 @@ function observeHlc(db, node, remote, now = Date.now()) {
|
|
|
185
226
|
const r = parseHlc(remote);
|
|
186
227
|
if (!Number.isFinite(r.millis) || !Number.isFinite(r.counter))
|
|
187
228
|
return;
|
|
229
|
+
if (r.millis > now + HLC_MAX_SKEW_MS)
|
|
230
|
+
return;
|
|
188
231
|
const millis = Math.max(last.millis, r.millis, now);
|
|
189
232
|
let counter;
|
|
190
233
|
if (millis === last.millis && millis === r.millis)
|
|
@@ -299,6 +342,39 @@ function lcs(a, b) {
|
|
|
299
342
|
return pairs;
|
|
300
343
|
}
|
|
301
344
|
|
|
345
|
+
// src/core/history-cache.ts
|
|
346
|
+
var caches = new WeakMap;
|
|
347
|
+
function dropDoc(c, docId) {
|
|
348
|
+
const e = c.docs.get(docId);
|
|
349
|
+
if (!e)
|
|
350
|
+
return;
|
|
351
|
+
c.docs.delete(docId);
|
|
352
|
+
for (const b of e.blockIds)
|
|
353
|
+
if (c.blockToDoc.get(b) === docId)
|
|
354
|
+
c.blockToDoc.delete(b);
|
|
355
|
+
}
|
|
356
|
+
function noteChange(db, dataset, rowId, col, value) {
|
|
357
|
+
const c = caches.get(db);
|
|
358
|
+
if (!c)
|
|
359
|
+
return;
|
|
360
|
+
if (dataset === "documents") {
|
|
361
|
+
dropDoc(c, rowId);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
if (dataset !== "doc_blocks")
|
|
365
|
+
return;
|
|
366
|
+
const owner = c.blockToDoc.get(rowId);
|
|
367
|
+
if (owner)
|
|
368
|
+
dropDoc(c, owner);
|
|
369
|
+
if (col === "doc_id" && value) {
|
|
370
|
+
try {
|
|
371
|
+
const target = JSON.parse(value);
|
|
372
|
+
if (typeof target === "string")
|
|
373
|
+
dropDoc(c, target);
|
|
374
|
+
} catch {}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
302
378
|
// src/core/intent-retention.ts
|
|
303
379
|
var DAY_MS = 24 * 60 * 60 * 1000;
|
|
304
380
|
var DROP_ENVELOPE_RETENTION_MS = 30 * DAY_MS;
|
|
@@ -338,27 +414,27 @@ function withNodeId(node, fn) {
|
|
|
338
414
|
currentNodeOverride = prev;
|
|
339
415
|
}
|
|
340
416
|
}
|
|
341
|
-
function withChangeGroup(label, fn) {
|
|
417
|
+
function withChangeGroup(db, label, fn) {
|
|
342
418
|
if (currentTxn !== null)
|
|
343
419
|
return fn();
|
|
344
420
|
currentTxn = (currentActor ? currentActor + "/" : "") + (label ? label + ":" : "") + randomSuffix(8);
|
|
345
421
|
try {
|
|
346
|
-
return fn
|
|
422
|
+
return writeTx(db, fn);
|
|
347
423
|
} finally {
|
|
348
424
|
currentTxn = null;
|
|
349
425
|
}
|
|
350
426
|
}
|
|
351
|
-
function withTxnId(txn, fn) {
|
|
427
|
+
function withTxnId(db, txn, fn) {
|
|
352
428
|
const prev = currentTxn;
|
|
353
429
|
currentTxn = txn;
|
|
354
430
|
try {
|
|
355
|
-
return fn
|
|
431
|
+
return writeTx(db, fn);
|
|
356
432
|
} finally {
|
|
357
433
|
currentTxn = prev;
|
|
358
434
|
}
|
|
359
435
|
}
|
|
360
436
|
function grouped(fn, label = null) {
|
|
361
|
-
return (...args) => withChangeGroup(label, () => fn(...args));
|
|
437
|
+
return (db, ...args) => withChangeGroup(db, label, () => fn(db, ...args));
|
|
362
438
|
}
|
|
363
439
|
var DOMAIN = {
|
|
364
440
|
databases: {
|
|
@@ -411,6 +487,10 @@ var DOMAIN = {
|
|
|
411
487
|
blob_policy: {
|
|
412
488
|
table: "blob_policy",
|
|
413
489
|
cols: new Set(["full_nodes", "redundancy", "__deleted"])
|
|
490
|
+
},
|
|
491
|
+
nodes: {
|
|
492
|
+
table: "nodes",
|
|
493
|
+
cols: new Set(["label", "platform", "form", "app", "first_seen", "__deleted"])
|
|
414
494
|
}
|
|
415
495
|
};
|
|
416
496
|
var RECORD_META = new Set(["database_id", "created_hlc", "order_key", "__deleted"]);
|
|
@@ -428,7 +508,8 @@ var NOT_NULL_ZERO_COLS = new Set([
|
|
|
428
508
|
"sites:spa",
|
|
429
509
|
"site_channels:__deleted",
|
|
430
510
|
"site_files:__deleted",
|
|
431
|
-
"blob_policy:__deleted"
|
|
511
|
+
"blob_policy:__deleted",
|
|
512
|
+
"nodes:__deleted"
|
|
432
513
|
]);
|
|
433
514
|
function decodeMaterializeValue(dataset, col, valueJson) {
|
|
434
515
|
if (valueJson !== null)
|
|
@@ -475,6 +556,16 @@ function materialize(db, dataset, rowId, col, valueJson) {
|
|
|
475
556
|
}
|
|
476
557
|
return;
|
|
477
558
|
}
|
|
559
|
+
if (dataset === "databases" && col.startsWith("meta.")) {
|
|
560
|
+
ensureRow(db, "databases", rowId);
|
|
561
|
+
const key = col.slice(5);
|
|
562
|
+
if (valueJson === null) {
|
|
563
|
+
db.query(`UPDATE databases SET meta = json_remove(coalesce(meta, '{}'), '$."' || ? || '"') WHERE id = ?`).run(key, rowId);
|
|
564
|
+
} else {
|
|
565
|
+
db.query(`UPDATE databases SET meta = json_set(coalesce(meta, '{}'), '$."' || ? || '"', json(?)) WHERE id = ?`).run(key, valueJson, rowId);
|
|
566
|
+
}
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
478
569
|
const d = DOMAIN[dataset];
|
|
479
570
|
if (!d || !d.cols.has(col))
|
|
480
571
|
return;
|
|
@@ -492,6 +583,8 @@ function materialize(db, dataset, rowId, col, valueJson) {
|
|
|
492
583
|
function applyChange(db, c) {
|
|
493
584
|
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;
|
|
494
585
|
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);
|
|
586
|
+
if (inserted)
|
|
587
|
+
noteChange(db, c.dataset, c.row_id, c.col, c.value);
|
|
495
588
|
if (cur.h !== c.hlc)
|
|
496
589
|
return { inserted, winner: false };
|
|
497
590
|
try {
|
|
@@ -505,30 +598,41 @@ function applyChange(db, c) {
|
|
|
505
598
|
return { inserted, winner: true };
|
|
506
599
|
}
|
|
507
600
|
function emit(db, dataset, rowId, col, value) {
|
|
508
|
-
const
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
601
|
+
const mint = () => {
|
|
602
|
+
const node = currentNodeOverride ?? getNodeId(db);
|
|
603
|
+
const change = {
|
|
604
|
+
hlc: nextHlc(db, node),
|
|
605
|
+
node_id: node,
|
|
606
|
+
dataset,
|
|
607
|
+
row_id: rowId,
|
|
608
|
+
col,
|
|
609
|
+
value: value === undefined ? null : JSON.stringify(value),
|
|
610
|
+
txn: currentTxn
|
|
611
|
+
};
|
|
612
|
+
applyChange(db, change);
|
|
613
|
+
return change;
|
|
517
614
|
};
|
|
518
|
-
|
|
519
|
-
return change;
|
|
615
|
+
return currentTxn !== null ? mint() : writeTx(db, mint);
|
|
520
616
|
}
|
|
521
617
|
function ingest(db, changes, opts = {}) {
|
|
618
|
+
return ingestDetailed(db, changes, opts).received;
|
|
619
|
+
}
|
|
620
|
+
function ingestDetailed(db, changes, opts = {}) {
|
|
522
621
|
const now = opts.now ?? Date.now();
|
|
523
622
|
pruneExpiredIntentReceipts(db, now);
|
|
524
623
|
const node = getNodeId(db);
|
|
525
624
|
let received = 0;
|
|
526
625
|
let poisoned = 0;
|
|
527
|
-
const
|
|
528
|
-
|
|
626
|
+
const skewed = {};
|
|
627
|
+
const tx = () => {
|
|
628
|
+
for (const c of changes) {
|
|
529
629
|
if (isExpiredIntentReceipt(c, now))
|
|
530
630
|
continue;
|
|
531
|
-
|
|
631
|
+
if (isSkewedHlc(c.hlc, now)) {
|
|
632
|
+
skewed[c.node_id] = (skewed[c.node_id] ?? 0) + 1;
|
|
633
|
+
continue;
|
|
634
|
+
}
|
|
635
|
+
observeHlc(db, node, c.hlc, now);
|
|
532
636
|
try {
|
|
533
637
|
if (applyChange(db, c).inserted)
|
|
534
638
|
received++;
|
|
@@ -537,11 +641,13 @@ function ingest(db, changes, opts = {}) {
|
|
|
537
641
|
console.error(`[ingest] skipped a change that failed to apply (dataset=${c.dataset} col=${c.col} row=${c.row_id}): ${e.message}`);
|
|
538
642
|
}
|
|
539
643
|
}
|
|
540
|
-
}
|
|
541
|
-
tx
|
|
644
|
+
};
|
|
645
|
+
writeTx(db, tx);
|
|
542
646
|
if (poisoned)
|
|
543
647
|
console.error(`[ingest] ${poisoned} change(s) skipped this batch (see above)`);
|
|
544
|
-
|
|
648
|
+
for (const [n, k] of Object.entries(skewed))
|
|
649
|
+
console.error(`[ingest] refused ${k} change(s) from node ${n}: stamped more than 5 min in the future (that device should run mh repair --clock)`);
|
|
650
|
+
return { received, skewed };
|
|
545
651
|
}
|
|
546
652
|
|
|
547
653
|
// src/core/indexing.ts
|
|
@@ -1199,7 +1305,19 @@ var DATABASE_SELECT = DATABASE_COLS.join(", ");
|
|
|
1199
1305
|
function rowOut(r) {
|
|
1200
1306
|
if (!r)
|
|
1201
1307
|
return null;
|
|
1202
|
-
|
|
1308
|
+
const meta = r.meta === null ? null : JSON.parse(r.meta);
|
|
1309
|
+
return { ...r, meta: meta && Object.keys(meta).length ? meta : null };
|
|
1310
|
+
}
|
|
1311
|
+
function emitMetaPatch(db, id, cur, patch) {
|
|
1312
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
1313
|
+
if (k.includes("."))
|
|
1314
|
+
throw new MhError("invalid_input", `meta key must not contain ".": ${k}`);
|
|
1315
|
+
const next = v === undefined ? null : v;
|
|
1316
|
+
const prev = cur && k in cur ? cur[k] : null;
|
|
1317
|
+
if (JSON.stringify(prev ?? null) === JSON.stringify(next ?? null))
|
|
1318
|
+
continue;
|
|
1319
|
+
emit(db, "databases", id, `meta.${k}`, next === null ? undefined : next);
|
|
1320
|
+
}
|
|
1203
1321
|
}
|
|
1204
1322
|
var createDatabase = grouped(function createDatabase(db, opts) {
|
|
1205
1323
|
const id = newId("db", opts.name);
|
|
@@ -1219,7 +1337,9 @@ var updateDatabase = grouped(function updateDatabase(db, id, fields) {
|
|
|
1219
1337
|
if (fields.meta !== undefined) {
|
|
1220
1338
|
if (fields.meta !== null && (typeof fields.meta !== "object" || Array.isArray(fields.meta)))
|
|
1221
1339
|
throw new MhError("invalid_input", "meta must be a JSON object or null");
|
|
1222
|
-
|
|
1340
|
+
const cur = getDatabase(db, id).meta;
|
|
1341
|
+
const patch = fields.meta ?? Object.fromEntries(Object.keys(cur ?? {}).map((k) => [k, null]));
|
|
1342
|
+
emitMetaPatch(db, id, cur, patch);
|
|
1223
1343
|
}
|
|
1224
1344
|
return getDatabase(db, id);
|
|
1225
1345
|
});
|
|
@@ -1233,8 +1353,7 @@ var duplicateDatabase = grouped(function duplicateDatabase(db, id, opts = {}) {
|
|
|
1233
1353
|
});
|
|
1234
1354
|
if (src.meta !== null) {
|
|
1235
1355
|
const { collapsed: _hidden, ...meta } = src.meta;
|
|
1236
|
-
|
|
1237
|
-
emit(db, "databases", dup.id, "meta", meta);
|
|
1356
|
+
emitMetaPatch(db, dup.id, null, meta);
|
|
1238
1357
|
}
|
|
1239
1358
|
const propIdMap = new Map;
|
|
1240
1359
|
const selfRelNew = new Set;
|
|
@@ -1558,8 +1677,151 @@ function escapeHtml(s) {
|
|
|
1558
1677
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
1559
1678
|
}
|
|
1560
1679
|
|
|
1680
|
+
// src/core/sync/locale.ts
|
|
1681
|
+
var LANG_COOKIE = "mh-lang";
|
|
1682
|
+
var isLocale = (v) => v === "zh-CN" || v === "en";
|
|
1683
|
+
function localeFromTags(tags) {
|
|
1684
|
+
if (!tags || tags.length === 0)
|
|
1685
|
+
return "zh-CN";
|
|
1686
|
+
for (const tag of tags) {
|
|
1687
|
+
if (!tag)
|
|
1688
|
+
continue;
|
|
1689
|
+
if (/^zh\b/i.test(tag))
|
|
1690
|
+
return "zh-CN";
|
|
1691
|
+
if (/^en\b/i.test(tag))
|
|
1692
|
+
return "en";
|
|
1693
|
+
}
|
|
1694
|
+
return "en";
|
|
1695
|
+
}
|
|
1696
|
+
function localeFromAcceptLanguage(header) {
|
|
1697
|
+
if (!header)
|
|
1698
|
+
return "zh-CN";
|
|
1699
|
+
return localeFromTags(header.split(",").map((s) => s.split(";")[0].trim()));
|
|
1700
|
+
}
|
|
1701
|
+
function localeFromCookie(header) {
|
|
1702
|
+
if (!header)
|
|
1703
|
+
return null;
|
|
1704
|
+
for (const part of header.split(";")) {
|
|
1705
|
+
const eq = part.indexOf("=");
|
|
1706
|
+
if (eq < 0)
|
|
1707
|
+
continue;
|
|
1708
|
+
if (part.slice(0, eq).trim() !== LANG_COOKIE)
|
|
1709
|
+
continue;
|
|
1710
|
+
const v = decodeURIComponent(part.slice(eq + 1).trim());
|
|
1711
|
+
return isLocale(v) ? v : null;
|
|
1712
|
+
}
|
|
1713
|
+
return null;
|
|
1714
|
+
}
|
|
1715
|
+
function pickLocale(req) {
|
|
1716
|
+
return localeFromCookie(req.headers.get("cookie")) ?? localeFromAcceptLanguage(req.headers.get("accept-language"));
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
// src/core/sync/page-messages.ts
|
|
1720
|
+
var EN = {
|
|
1721
|
+
"本机逻辑时钟比系统时间超前 {min} 分钟(系统时钟曾被调错,或收到过时钟错误设备的数据),运行 mh repair --clock 修复": "This device's logical clock is {min} min ahead of wall time (the system clock was wrong, or data arrived from a device with a wrong clock); run mh repair --clock",
|
|
1722
|
+
"数据库正被另一个进程写入,请稍后重试": "The database is being written by another process; retry shortly",
|
|
1723
|
+
"通过 metahub 分享": "Shared via metahub",
|
|
1724
|
+
"由 metahub 托管": "Hosted by metahub",
|
|
1725
|
+
"(空文档)": "(empty document)",
|
|
1726
|
+
文档: "Document",
|
|
1727
|
+
表格: "Table",
|
|
1728
|
+
编辑: "Edit",
|
|
1729
|
+
编辑文档: "Edit document",
|
|
1730
|
+
保存: "Save",
|
|
1731
|
+
"保存中…": "Saving…",
|
|
1732
|
+
保存失败: "Save failed",
|
|
1733
|
+
"已保存 ✓": "Saved ✓",
|
|
1734
|
+
"可编辑文本/数字/URL/日期单元格,失焦自动保存": "Text, number, URL and date cells are editable; changes save on blur",
|
|
1735
|
+
"编辑站点文件(文本文件可在线编辑;保存即对所有访问者生效)。": "Edit site files (text files can be edited here; saving applies to every visitor).",
|
|
1736
|
+
"(暂无文件)": "(no files yet)",
|
|
1737
|
+
"预览 ↗": "Preview ↗",
|
|
1738
|
+
"(二进制文件,不可在线编辑)": "(binary file, cannot be edited here)",
|
|
1739
|
+
"{name} · 文件": "{name} · Files",
|
|
1740
|
+
"\uD83D\uDD12 此分享受口令保护": "\uD83D\uDD12 This share is protected by a passcode",
|
|
1741
|
+
"口令错误,请重试。": "Wrong passcode, try again.",
|
|
1742
|
+
口令: "Passcode",
|
|
1743
|
+
解锁: "Unlock",
|
|
1744
|
+
受保护的分享: "Protected share",
|
|
1745
|
+
复制全文: "Copy all",
|
|
1746
|
+
已复制: "Copied",
|
|
1747
|
+
复制失败: "Copy failed",
|
|
1748
|
+
站点尚未同步: "Site not synced yet",
|
|
1749
|
+
"分享方设备上线同步后即可访问。": "Available once the sharing device comes online and syncs.",
|
|
1750
|
+
页面不存在: "Page not found",
|
|
1751
|
+
"这个站点里没有这个页面或文件。": "This site has no such page or file.",
|
|
1752
|
+
资源暂不可用: "Resource temporarily unavailable",
|
|
1753
|
+
"文件内容尚未同步到房间,请稍后重试。": "The file content has not synced to the room yet. Try again later.",
|
|
1754
|
+
"文件的内容暂时无法取得,请稍后重试。": "The file content cannot be retrieved right now. Try again later.",
|
|
1755
|
+
站点不存在: "Site not found",
|
|
1756
|
+
"没有名为 “{name}” 的站点。": "There is no site named “{name}”.",
|
|
1757
|
+
返回站点首页: "Back to site home",
|
|
1758
|
+
"验证访问令牌 · metahub": "Verify access token · metahub",
|
|
1759
|
+
输入访问令牌: "Enter the access token",
|
|
1760
|
+
"此服务器已开启访问保护。在已登录设备的「设置 → 设备与授权」获取登录链接或二维码,或在服务器上运行 {cmd}。": "This server requires an access token. Get a login link or QR code from Settings → Devices on a signed-in device, or run {cmd} on the server.",
|
|
1761
|
+
粘贴访问令牌或登录链接: "Paste the access token or login link",
|
|
1762
|
+
显示令牌: "Show token",
|
|
1763
|
+
隐藏令牌: "Hide token",
|
|
1764
|
+
解锁并进入: "Unlock and enter",
|
|
1765
|
+
"请输入访问令牌。": "Enter the access token.",
|
|
1766
|
+
"验证中…": "Verifying…",
|
|
1767
|
+
"令牌无效,请检查后重试。": "Invalid token. Check it and try again.",
|
|
1768
|
+
"登录已过期,请重新输入令牌。": "Session expired. Enter the token again.",
|
|
1769
|
+
附件已失效: "Attachment unavailable",
|
|
1770
|
+
链接缺少分享数据: "The link is missing share data",
|
|
1771
|
+
请输入分享口令: "Enter the share passcode",
|
|
1772
|
+
链接缺少密钥: "The link is missing its key",
|
|
1773
|
+
口令错误或链接已失效: "Wrong passcode or the link has expired",
|
|
1774
|
+
链接已失效或密钥不正确: "The link has expired or the key is wrong",
|
|
1775
|
+
分享: "Share",
|
|
1776
|
+
正在加载: "Loading",
|
|
1777
|
+
"✅ 已授权": "✅ Authorized",
|
|
1778
|
+
"可以关闭此标签页,回到 MetaHub 继续部署。": "You can close this tab and return to MetaHub to continue deploying.",
|
|
1779
|
+
站点入口健康响应过大: "Site entry health response is too large",
|
|
1780
|
+
站点入口不能指向云平台元数据服务: "Site entry must not point at a cloud metadata service",
|
|
1781
|
+
"Desktop sidecar 仅用于本机预览;请启动带鉴权的 mh --server,或使用 Edge": "The desktop sidecar is for local preview only; start an authenticated mh --server, or use Edge",
|
|
1782
|
+
"站点入口健康检查失败(HTTP {status})": "Site entry health check failed (HTTP {status})",
|
|
1783
|
+
站点入口健康响应无效: "Site entry health response is invalid",
|
|
1784
|
+
"站点入口节点不匹配(期望 {expected},实际 {actual});这是防误配检查,不是身份认证": "Site entry node mismatch (expected {expected}, got {actual}); this is a misconfiguration guard, not authentication",
|
|
1785
|
+
"目标设备版本不支持定向发布渠道;请先升级该设备,再重新验证入口": "The target device does not support targeted publishing channels; upgrade it, then verify the entry again",
|
|
1786
|
+
"无法验证站点入口:连接超时": "Could not verify the site entry: connection timed out",
|
|
1787
|
+
"无法验证站点入口:{msg}": "Could not verify the site entry: {msg}",
|
|
1788
|
+
"请先在“设置 → 站点托管”配置并验证当前设备的公网或局域网入口": "Configure and verify this device's public or LAN entry under Settings → Site hosting first",
|
|
1789
|
+
"配对设备缺少节点身份,重新配对后再试": "The paired device has no node identity; pair again and retry",
|
|
1790
|
+
"该站点仍在等待目标设备确认回滚,请先重试回滚": "This site is still waiting for the target device to confirm the rollback; retry the rollback first",
|
|
1791
|
+
配对设备同步失败: "Sync with the paired device failed",
|
|
1792
|
+
没有待确认的站点回滚: "No site rollback is awaiting confirmation",
|
|
1793
|
+
"目标配对设备不存在,请重新配对后重试": "The target paired device does not exist; pair again and retry",
|
|
1794
|
+
站点渠道已不存在: "The site channel no longer exists",
|
|
1795
|
+
"OAuth 流程不存在或已过期,请重新登录": "The OAuth flow does not exist or has expired; sign in again",
|
|
1796
|
+
"OAuth 流程不存在或已过期,请重试": "The OAuth flow does not exist or has expired; try again",
|
|
1797
|
+
"Cloudflare 授权失败": "Cloudflare authorization failed",
|
|
1798
|
+
"Cloudflare 授权尚未完成,请稍候": "Cloudflare authorization is not finished yet; please wait",
|
|
1799
|
+
"缺少 Cloudflare 账号 id": "Missing Cloudflare account id",
|
|
1800
|
+
"缺少 Cloudflare 凭据(请先登录或提供 API Token)": "Missing Cloudflare credentials (sign in first or provide an API token)",
|
|
1801
|
+
"Cloudflare 授权失败:{detail}": "Cloudflare authorization failed: {detail}",
|
|
1802
|
+
"未返回 access token": "no access token returned",
|
|
1803
|
+
"无法连接 Cloudflare 授权服务:{msg}": "Could not reach the Cloudflare authorization service: {msg}",
|
|
1804
|
+
"Cloudflare 令牌交换失败(HTTP {status})": "Cloudflare token exchange failed (HTTP {status})",
|
|
1805
|
+
"无法读取 Cloudflare 账号列表:{msg}": "Could not read the Cloudflare account list: {msg}",
|
|
1806
|
+
"读取 Cloudflare 账号失败(HTTP {status})": "Reading Cloudflare accounts failed (HTTP {status})",
|
|
1807
|
+
"未配置 Cloudflare OAuth 客户端,请改用 API Token 部署": "No Cloudflare OAuth client is configured; deploy with an API token instead",
|
|
1808
|
+
"Cloudflare 授权超时,请重试": "Cloudflare authorization timed out; try again",
|
|
1809
|
+
"Cloudflare 授权被拒绝:{detail}": "Cloudflare authorization was denied: {detail}",
|
|
1810
|
+
"OAuth state 不匹配,疑似伪造回调,已中止": "OAuth state mismatch; the callback looks forged, aborted",
|
|
1811
|
+
"Cloudflare 未返回授权码": "Cloudflare did not return an authorization code",
|
|
1812
|
+
"请输入完整的 http(s) 地址": "Enter a complete http(s) address",
|
|
1813
|
+
"站点入口只支持 HTTP 或 HTTPS": "Site entry supports HTTP or HTTPS only",
|
|
1814
|
+
"站点入口不能包含凭据、查询参数或片段": "Site entry must not contain credentials, query parameters or fragments",
|
|
1815
|
+
"站点入口必须是域名根地址,不能包含路径": "Site entry must be a domain root without a path",
|
|
1816
|
+
站点入口必须是可访问的主机地址: "Site entry must be a reachable host address",
|
|
1817
|
+
"公网入口必须使用 HTTPS;HTTP 仅允许本机或局域网地址": "A public entry must use HTTPS; HTTP is only allowed for local or LAN addresses"
|
|
1818
|
+
};
|
|
1819
|
+
function pm(locale, key, params) {
|
|
1820
|
+
const s = locale === "en" ? EN[key] : key;
|
|
1821
|
+
return params ? s.replace(/\{(\w+)\}/g, (m, k) => (k in params) ? String(params[k]) : m) : s;
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1561
1824
|
// src/core/guest-intent.ts
|
|
1562
|
-
var HLC_SKEW_MS = 5 * 60000;
|
|
1563
1825
|
var INTENT_ID_RE = /^[A-Za-z0-9._-]{1,64}$/;
|
|
1564
1826
|
var RECEIPT_COL = "result";
|
|
1565
1827
|
function applyGuestIntent(db, policy, session, intent, opts) {
|
|
@@ -1571,7 +1833,7 @@ function applyGuestIntent(db, policy, session, intent, opts) {
|
|
|
1571
1833
|
kind: policy.audience,
|
|
1572
1834
|
guestNode: session.guestNode
|
|
1573
1835
|
};
|
|
1574
|
-
|
|
1836
|
+
return writeTx(db, () => {
|
|
1575
1837
|
const prepared = prepareIntent(db, policy, principal, intent, opts.clock);
|
|
1576
1838
|
const txn = intentTxnPrefix(session.guestNode, intent.intentId) + prepared.fingerprint;
|
|
1577
1839
|
const receipt = readReceipt(db, session.guestNode, intent.intentId, acceptedNow);
|
|
@@ -1591,7 +1853,6 @@ function applyGuestIntent(db, policy, session, intent, opts) {
|
|
|
1591
1853
|
return applySubmitted(db, policy, principal, intent, prepared, txn, acceptedNow);
|
|
1592
1854
|
return applyAuthority(db, policy, principal, intent, prepared, txn, acceptedNow);
|
|
1593
1855
|
});
|
|
1594
|
-
return tx();
|
|
1595
1856
|
}
|
|
1596
1857
|
function prepareIntent(db, policy, principal, intent, clock) {
|
|
1597
1858
|
if (typeof intent.payload !== "object" || intent.payload === null || Array.isArray(intent.payload))
|
|
@@ -1629,7 +1890,7 @@ function prepareIntent(db, policy, principal, intent, clock) {
|
|
|
1629
1890
|
};
|
|
1630
1891
|
}
|
|
1631
1892
|
function applyAuthority(db, policy, principal, intent, prepared, txn, acceptedNow) {
|
|
1632
|
-
return withTxnId(txn, () => withNodeId(principal.guestNode, () => {
|
|
1893
|
+
return withTxnId(db, txn, () => withNodeId(principal.guestNode, () => {
|
|
1633
1894
|
if (intent.action === "createRecord")
|
|
1634
1895
|
assertGuestCreateCapacity(db, prepared.database, policy.limits);
|
|
1635
1896
|
const current = intent.action === "createRecord" ? createRecordPrepared(db, prepared.database, prepared.cells) : updateRecordPrepared(db, intent.recordId, prepared.cells);
|
|
@@ -1753,7 +2014,7 @@ function isRecordRow(value) {
|
|
|
1753
2014
|
}
|
|
1754
2015
|
function clampMillis(submittedAt, now) {
|
|
1755
2016
|
const safeNow = Math.max(0, Math.trunc(Number.isFinite(now) ? now : Date.now()));
|
|
1756
|
-
const ceil = safeNow +
|
|
2017
|
+
const ceil = safeNow + HLC_MAX_SKEW_MS;
|
|
1757
2018
|
if (!Number.isFinite(submittedAt))
|
|
1758
2019
|
return safeNow;
|
|
1759
2020
|
return Math.max(0, Math.min(Math.trunc(submittedAt), ceil));
|
|
@@ -2509,6 +2770,22 @@ CREATE TABLE IF NOT EXISTS blob_policy (
|
|
|
2509
2770
|
redundancy TEXT, -- 'all' | 'any'
|
|
2510
2771
|
__deleted INTEGER NOT NULL DEFAULT 0
|
|
2511
2772
|
);
|
|
2773
|
+
|
|
2774
|
+
-- Synced device roster (system tier, see tables.ts): one row per node id, written
|
|
2775
|
+
-- by the device itself on open (describeSelf) and by anyone who renames it.
|
|
2776
|
+
-- Replicates so every device shows the same name and icon for a peer — the only
|
|
2777
|
+
-- record a purely bucket-joined device leaves besides its oplog stream. Each
|
|
2778
|
+
-- column is its own LWW register, so a rename and a platform refresh never
|
|
2779
|
+
-- clobber each other. meta.node_label survives as a node-local read fallback.
|
|
2780
|
+
CREATE TABLE IF NOT EXISTS nodes (
|
|
2781
|
+
id TEXT PRIMARY KEY, -- node_id
|
|
2782
|
+
label TEXT, -- shared display name; any device may set it
|
|
2783
|
+
platform TEXT, -- macos | windows | linux | ios | android | web
|
|
2784
|
+
form TEXT, -- laptop | desktop | phone | server | browser
|
|
2785
|
+
app TEXT, -- cli | server | desktop | web
|
|
2786
|
+
first_seen INTEGER, -- epoch ms of the device's first self-description
|
|
2787
|
+
__deleted INTEGER NOT NULL DEFAULT 0
|
|
2788
|
+
);
|
|
2512
2789
|
`;
|
|
2513
2790
|
var FTS_SCHEMA = `
|
|
2514
2791
|
CREATE VIRTUAL TABLE IF NOT EXISTS search_fts USING fts5(
|
|
@@ -2769,25 +3046,6 @@ var editDocument = grouped(function editDocument(db, id, opts) {
|
|
|
2769
3046
|
throw new MhError("invalid_input", `anchor not found: no match for --old`);
|
|
2770
3047
|
if (count > 1 && !opts.replaceAll)
|
|
2771
3048
|
throw new MhError("ambiguous", `${count} matches for --old; add surrounding context or use --replace-all`);
|
|
2772
|
-
if (!opts.old.includes(`
|
|
2773
|
-
|
|
2774
|
-
`)) {
|
|
2775
|
-
const affected = blocks.filter((b) => b.text.includes(opts.old));
|
|
2776
|
-
const next = affected.map((b) => opts.replaceAll ? b.text.replaceAll(opts.old, () => opts.new) : b.text.replace(opts.old, () => opts.new));
|
|
2777
|
-
if (next.every((t) => t.length > 0 && !t.includes(`
|
|
2778
|
-
|
|
2779
|
-
`))) {
|
|
2780
|
-
const targets = opts.replaceAll ? affected : affected.slice(0, 1);
|
|
2781
|
-
let wrote = false;
|
|
2782
|
-
targets.forEach((b, i) => {
|
|
2783
|
-
if (next[i] !== b.text) {
|
|
2784
|
-
emit(db, "doc_blocks", b.id, "text", next[i]);
|
|
2785
|
-
wrote = true;
|
|
2786
|
-
}
|
|
2787
|
-
});
|
|
2788
|
-
return { id, changed: wrote, replaced: opts.replaceAll ? count : 1, version: documentVersion(db, id) };
|
|
2789
|
-
}
|
|
2790
|
-
}
|
|
2791
3049
|
const newBody = opts.replaceAll ? body.replaceAll(opts.old, () => opts.new) : body.replace(opts.old, () => opts.new);
|
|
2792
3050
|
reconcileBody(db, id, newBody);
|
|
2793
3051
|
return { id, changed: newBody !== body, replaced: opts.replaceAll ? count : 1, version: documentVersion(db, id) };
|
|
@@ -2884,7 +3142,7 @@ function readPolicy(db) {
|
|
|
2884
3142
|
return { fullNodes, redundancy };
|
|
2885
3143
|
}
|
|
2886
3144
|
var setFullNodes = (db, nodeIds) => {
|
|
2887
|
-
withChangeGroup(null, () => {
|
|
3145
|
+
withChangeGroup(db, null, () => {
|
|
2888
3146
|
const seen = new Set;
|
|
2889
3147
|
const list = nodeIds.filter((n) => n && !seen.has(n) && seen.add(n) != null);
|
|
2890
3148
|
emit(db, "blob_policy", POLICY_ID, "full_nodes", list);
|
|
@@ -2938,7 +3196,7 @@ function migrateRecords(db) {
|
|
|
2938
3196
|
db.exec("ALTER TABLE records ADD COLUMN order_key TEXT");
|
|
2939
3197
|
if (!tableExists3(db, "record_values"))
|
|
2940
3198
|
return;
|
|
2941
|
-
|
|
3199
|
+
writeTx(db, () => {
|
|
2942
3200
|
db.exec(`
|
|
2943
3201
|
UPDATE records SET data = coalesce((
|
|
2944
3202
|
SELECT json_group_object(rv.property_id, json(rv.value))
|
|
@@ -2949,7 +3207,6 @@ function migrateRecords(db) {
|
|
|
2949
3207
|
db.exec("DROP TABLE record_values");
|
|
2950
3208
|
backfillRecordOrderKeys(db);
|
|
2951
3209
|
});
|
|
2952
|
-
tx();
|
|
2953
3210
|
backfillRecordOrderKeys(db);
|
|
2954
3211
|
}
|
|
2955
3212
|
function migrateShares(db) {
|
|
@@ -3007,7 +3264,7 @@ function migrateOplog(db) {
|
|
|
3007
3264
|
function migrateCrdtChangesSeq(db) {
|
|
3008
3265
|
if (hasColumn(db, "crdt_changes", "seq"))
|
|
3009
3266
|
return;
|
|
3010
|
-
|
|
3267
|
+
writeTx(db, () => {
|
|
3011
3268
|
db.exec(`
|
|
3012
3269
|
DROP TABLE IF EXISTS crdt_changes_new;
|
|
3013
3270
|
CREATE TABLE crdt_changes_new (
|
|
@@ -3037,7 +3294,6 @@ function migrateCrdtChangesSeq(db) {
|
|
|
3037
3294
|
if (tableExists3(db, "peers"))
|
|
3038
3295
|
db.exec("UPDATE peers SET push_cursor = 0, pull_cursor = 0");
|
|
3039
3296
|
});
|
|
3040
|
-
tx();
|
|
3041
3297
|
}
|
|
3042
3298
|
function migrateStoragePeerUrls(db) {
|
|
3043
3299
|
if (!tableExists3(db, "peers"))
|
|
@@ -3070,7 +3326,7 @@ function migrateStoragePeerUrls(db) {
|
|
|
3070
3326
|
if (renames.length === 0)
|
|
3071
3327
|
return;
|
|
3072
3328
|
const map = new Map(renames.map(({ oldUrl, newUrl }) => [oldUrl, newUrl]));
|
|
3073
|
-
|
|
3329
|
+
writeTx(db, () => {
|
|
3074
3330
|
for (const { oldUrl, newUrl } of renames) {
|
|
3075
3331
|
if (tableExists3(db, "storage_cursors"))
|
|
3076
3332
|
db.query("UPDATE storage_cursors SET peer_url = ? WHERE peer_url = ?").run(newUrl, oldUrl);
|
|
@@ -3082,7 +3338,6 @@ function migrateStoragePeerUrls(db) {
|
|
|
3082
3338
|
setFullNodes(db, fullNodes.map((n) => map.get(n) ?? n));
|
|
3083
3339
|
}
|
|
3084
3340
|
});
|
|
3085
|
-
tx();
|
|
3086
3341
|
}
|
|
3087
3342
|
function migrateDatabases(db) {
|
|
3088
3343
|
if (hasColumn(db, "databases", "meta"))
|
|
@@ -3125,21 +3380,27 @@ function migrateSitesAccess(db) {
|
|
|
3125
3380
|
backfill("public_grants", "NULL");
|
|
3126
3381
|
}
|
|
3127
3382
|
}
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
if (!tableExists3(db, "site_channels"))
|
|
3383
|
+
function replayDataset(db, dataset) {
|
|
3384
|
+
if (!tableExists3(db, dataset))
|
|
3131
3385
|
return;
|
|
3132
|
-
const
|
|
3386
|
+
const key = `${dataset}_replay_seq`;
|
|
3387
|
+
const wm = db.query("SELECT value FROM meta WHERE key = ?").get(key);
|
|
3133
3388
|
const since = wm ? Number(wm.value) : 0;
|
|
3134
3389
|
const changes = db.query(`SELECT seq, ${CHANGE_SELECT} FROM crdt_changes
|
|
3135
|
-
WHERE dataset =
|
|
3390
|
+
WHERE dataset = ? AND seq > ? ORDER BY seq`).all(dataset, since);
|
|
3136
3391
|
if (changes.length === 0)
|
|
3137
3392
|
return;
|
|
3138
|
-
db
|
|
3139
|
-
for (const change of
|
|
3393
|
+
writeTx(db, () => {
|
|
3394
|
+
for (const change of changes)
|
|
3140
3395
|
applyChange(db, change);
|
|
3141
|
-
db.query("INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(
|
|
3142
|
-
})
|
|
3396
|
+
db.query("INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(key, String(changes[changes.length - 1].seq));
|
|
3397
|
+
});
|
|
3398
|
+
}
|
|
3399
|
+
function migrateSiteChannels(db) {
|
|
3400
|
+
replayDataset(db, "site_channels");
|
|
3401
|
+
}
|
|
3402
|
+
function migrateNodes(db) {
|
|
3403
|
+
replayDataset(db, "nodes");
|
|
3143
3404
|
}
|
|
3144
3405
|
function initSchema(db) {
|
|
3145
3406
|
runSchema(db);
|
|
@@ -3155,6 +3416,7 @@ function initSchema(db) {
|
|
|
3155
3416
|
migrateShares(db);
|
|
3156
3417
|
migrateSitesAccess(db);
|
|
3157
3418
|
migrateSiteChannels(db);
|
|
3419
|
+
migrateNodes(db);
|
|
3158
3420
|
}
|
|
3159
3421
|
|
|
3160
3422
|
// src/core/sync/partition.ts
|
|
@@ -3240,12 +3502,11 @@ function roomMissingBlobs(db, limit = NEED_BLOBS_LIMIT) {
|
|
|
3240
3502
|
return rows.map((r) => r.hash);
|
|
3241
3503
|
}
|
|
3242
3504
|
function roomPutBlobChunk(db, hash, idx, total, bytes) {
|
|
3243
|
-
|
|
3505
|
+
writeTx(db, () => {
|
|
3244
3506
|
if (idx === 0)
|
|
3245
3507
|
db.query("DELETE FROM room_blobs WHERE hash = ?").run(hash);
|
|
3246
3508
|
db.query("INSERT OR REPLACE INTO room_blobs (hash, idx, total, bytes) VALUES (?, ?, ?, ?)").run(hash, idx, total, bytes);
|
|
3247
3509
|
});
|
|
3248
|
-
tx();
|
|
3249
3510
|
}
|
|
3250
3511
|
function roomBlobBytes(db, hash) {
|
|
3251
3512
|
const rows = db.query("SELECT idx, total, bytes FROM room_blobs WHERE hash = ? ORDER BY idx").all(hash);
|
|
@@ -3275,6 +3536,7 @@ function evictRow(db, key, ackSeq, guestBase) {
|
|
|
3275
3536
|
if (!table)
|
|
3276
3537
|
return;
|
|
3277
3538
|
db.query(`DELETE FROM ${table} WHERE id = ?`).run(key.row_id);
|
|
3539
|
+
noteChange(db, key.dataset, key.row_id, "", null);
|
|
3278
3540
|
db.query(`DELETE FROM crdt_changes WHERE dataset = ? AND row_id = ?
|
|
3279
3541
|
AND NOT (${GUEST_NODE_SQL} AND seq > ?)`).run(key.dataset, key.row_id, guestBase, guestBase, ackSeq);
|
|
3280
3542
|
const remaining = db.query("SELECT 1 FROM crdt_changes WHERE dataset = ? AND row_id = ? LIMIT 1").get(key.dataset, key.row_id) != null;
|
|
@@ -3291,6 +3553,7 @@ function sweepEvictPending(db, ackSeq, guestBase) {
|
|
|
3291
3553
|
db.query("DELETE FROM evict_pending WHERE dataset = ? AND row_id = ?").run(k.dataset, k.row_id);
|
|
3292
3554
|
continue;
|
|
3293
3555
|
}
|
|
3556
|
+
noteChange(db, k.dataset, k.row_id, "", null);
|
|
3294
3557
|
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);
|
|
3295
3558
|
const remaining = db.query("SELECT 1 FROM crdt_changes WHERE dataset = ? AND row_id = ? LIMIT 1").get(k.dataset, k.row_id) != null;
|
|
3296
3559
|
if (!remaining)
|
|
@@ -3624,9 +3887,9 @@ function createRoomFetch(deps) {
|
|
|
3624
3887
|
if (req.method !== "GET")
|
|
3625
3888
|
return plain404();
|
|
3626
3889
|
if (locked) {
|
|
3627
|
-
return wantsHtml(req) ? new Response(unlockPage(cfg, false), { headers: HTML }) : json({ error: "unauthorized" }, 401);
|
|
3890
|
+
return wantsHtml(req) ? new Response(unlockPage(pickLocale(req), cfg, false), { headers: HTML }) : json({ error: "unauthorized" }, 401);
|
|
3628
3891
|
}
|
|
3629
|
-
return serveRoomFile(db, cfg, sub);
|
|
3892
|
+
return serveRoomFile(db, cfg, sub, pickLocale(req));
|
|
3630
3893
|
};
|
|
3631
3894
|
}
|
|
3632
3895
|
async function handleUnlock(db, cfg, req, url) {
|
|
@@ -3640,20 +3903,20 @@ async function handleUnlock(db, cfg, req, url) {
|
|
|
3640
3903
|
}
|
|
3641
3904
|
const ok = !cfg.pwHash || !cfg.pwSalt ? true : await verifyPasswordVerifier(cfg.pwHash, cfg.pwSalt, pw);
|
|
3642
3905
|
if (!ok)
|
|
3643
|
-
return new Response(unlockPage(cfg, true), { headers: HTML, status: 401 });
|
|
3906
|
+
return new Response(unlockPage(pickLocale(req), cfg, true), { headers: HTML, status: 401 });
|
|
3644
3907
|
const minted = await mintRoomSession(db, cfg, url);
|
|
3645
3908
|
return new Response(null, {
|
|
3646
3909
|
status: 303,
|
|
3647
3910
|
headers: { location: `/r/${cfg.slug}/`, "set-cookie": minted.cookie }
|
|
3648
3911
|
});
|
|
3649
3912
|
}
|
|
3650
|
-
function serveRoomFile(db, cfg, path) {
|
|
3913
|
+
function serveRoomFile(db, cfg, path, locale) {
|
|
3651
3914
|
const site = db.query("SELECT id, spa FROM sites WHERE __deleted = 0 ORDER BY created_hlc LIMIT 1").get();
|
|
3652
3915
|
if (!site)
|
|
3653
|
-
return roomMessagePage("站点尚未同步", "分享方设备上线同步后即可访问。", 404);
|
|
3916
|
+
return roomMessagePage(locale, pm(locale, "站点尚未同步"), pm(locale, "分享方设备上线同步后即可访问。"), 404);
|
|
3654
3917
|
const resolved = resolveSiteFileRow(db, site.id, path, { spa: site.spa === 1 });
|
|
3655
3918
|
if (!resolved)
|
|
3656
|
-
return roomMessagePage("页面不存在", "这个站点里没有这个页面或文件。", 404);
|
|
3919
|
+
return roomMessagePage(locale, pm(locale, "页面不存在"), pm(locale, "这个站点里没有这个页面或文件。"), 404);
|
|
3657
3920
|
const { row, status } = resolved;
|
|
3658
3921
|
const headers = {
|
|
3659
3922
|
"content-type": row.content_type,
|
|
@@ -3669,7 +3932,7 @@ function serveRoomFile(db, cfg, path) {
|
|
|
3669
3932
|
}
|
|
3670
3933
|
const bytes = row.content ? roomBlobBytes(db, row.content) : null;
|
|
3671
3934
|
if (!bytes)
|
|
3672
|
-
return roomMessagePage("资源暂不可用", "文件内容尚未同步到房间,请稍后重试。", 404);
|
|
3935
|
+
return roomMessagePage(locale, pm(locale, "资源暂不可用"), pm(locale, "文件内容尚未同步到房间,请稍后重试。"), 404);
|
|
3673
3936
|
const body = new Uint8Array(bytes.byteLength);
|
|
3674
3937
|
body.set(bytes);
|
|
3675
3938
|
return new Response(body.buffer, { status, headers });
|
|
@@ -3688,28 +3951,28 @@ var PAGE_CSS = `
|
|
|
3688
3951
|
.muted{color:var(--muted);font-size:14px}
|
|
3689
3952
|
h1{font-size:20px;margin:0 0 10px;text-align:center}
|
|
3690
3953
|
footer{margin-top:34px;color:var(--muted);font-size:12px;text-align:center}`;
|
|
3691
|
-
function unlockPage(cfg, error) {
|
|
3692
|
-
return `<!doctype html><html lang="
|
|
3954
|
+
function unlockPage(locale, cfg, error) {
|
|
3955
|
+
return `<!doctype html><html lang="${locale}"><head>
|
|
3693
3956
|
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
3694
|
-
<meta name="robots" content="noindex"><title
|
|
3957
|
+
<meta name="robots" content="noindex"><title>${escapeHtml(pm(locale, "受保护的分享"))}</title>
|
|
3695
3958
|
<style>${PAGE_CSS}</style></head><body><main>
|
|
3696
3959
|
<form method="post" action="/r/${escapeHtml(cfg.slug)}/unlock" class="pw">
|
|
3697
|
-
<h1
|
|
3698
|
-
${error ?
|
|
3699
|
-
<input type="password" name="password" placeholder="口令" autofocus autocomplete="current-password">
|
|
3700
|
-
<button type="submit"
|
|
3960
|
+
<h1>${escapeHtml(pm(locale, "\uD83D\uDD12 此分享受口令保护"))}</h1>
|
|
3961
|
+
${error ? `<p class="err">${escapeHtml(pm(locale, "口令错误,请重试。"))}</p>` : ""}
|
|
3962
|
+
<input type="password" name="password" placeholder="${escapeHtml(pm(locale, "口令"))}" autofocus autocomplete="current-password">
|
|
3963
|
+
<button type="submit">${escapeHtml(pm(locale, "解锁"))}</button>
|
|
3701
3964
|
</form>
|
|
3702
|
-
<footer
|
|
3965
|
+
<footer>${escapeHtml(pm(locale, "通过 metahub 分享"))}</footer>
|
|
3703
3966
|
</main></body></html>`;
|
|
3704
3967
|
}
|
|
3705
|
-
function roomMessagePage(title, detail, status) {
|
|
3706
|
-
const html = `<!doctype html><html lang="
|
|
3968
|
+
function roomMessagePage(locale, title, detail, status) {
|
|
3969
|
+
const html = `<!doctype html><html lang="${locale}"><head>
|
|
3707
3970
|
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
3708
3971
|
<meta name="robots" content="noindex"><title>${escapeHtml(title)}</title>
|
|
3709
3972
|
<style>${PAGE_CSS}</style></head><body><main>
|
|
3710
3973
|
<h1>${escapeHtml(title)}</h1>
|
|
3711
3974
|
<p class="muted" style="text-align:center">${escapeHtml(detail)}</p>
|
|
3712
|
-
<footer
|
|
3975
|
+
<footer>${escapeHtml(pm(locale, "由 metahub 托管"))}</footer>
|
|
3713
3976
|
</main></body></html>`;
|
|
3714
3977
|
return new Response(html, { status, headers: { ...HTML, "cache-control": "no-store" } });
|
|
3715
3978
|
}
|