@tensorix/metahub 0.5.1 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -801,7 +801,7 @@ var package_default;
801
801
  var init_package = __esm(() => {
802
802
  package_default = {
803
803
  name: "@tensorix/metahub",
804
- version: "0.5.1",
804
+ version: "0.5.2",
805
805
  description: "Metahub core library and CLI",
806
806
  license: "AGPL-3.0-only",
807
807
  type: "module",
@@ -922,38 +922,6 @@ var init_ids = __esm(() => {
922
922
  ]);
923
923
  });
924
924
 
925
- // src/core/node.ts
926
- function getNodeId(db) {
927
- const row = db.query("SELECT value FROM meta WHERE key = 'node_id'").get();
928
- if (row)
929
- return row.value;
930
- const id = randomSuffix(8);
931
- db.query("INSERT INTO meta (key, value) VALUES ('node_id', ?)").run(id);
932
- return id;
933
- }
934
- function getNodeLabel(db) {
935
- const row = db.query("SELECT value FROM meta WHERE key = 'node_label'").get();
936
- return row?.value ?? null;
937
- }
938
- function setNodeLabel(db, label) {
939
- if (label == null || label.trim() === "") {
940
- db.query("DELETE FROM meta WHERE key = 'node_label'").run();
941
- return;
942
- }
943
- db.query("INSERT INTO meta (key, value) VALUES ('node_label', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(label.trim());
944
- }
945
- function displayNodes(db) {
946
- const self = getNodeId(db);
947
- const peers = db.query("SELECT node_id, label FROM peers WHERE node_id IS NOT NULL AND node_id <> '' GROUP BY node_id").all();
948
- return [
949
- { node_id: self, label: getNodeLabel(db), self: true },
950
- ...peers.filter((p) => p.node_id !== self).map((p) => ({ ...p, self: false }))
951
- ];
952
- }
953
- var init_node = __esm(() => {
954
- init_ids();
955
- });
956
-
957
925
  // src/core/hlc.ts
958
926
  function carryCounter(millis, counter) {
959
927
  if (counter <= MAX_HLC_COUNTER)
@@ -1380,6 +1348,10 @@ var init_crdt = __esm(() => {
1380
1348
  blob_policy: {
1381
1349
  table: "blob_policy",
1382
1350
  cols: new Set(["full_nodes", "redundancy", "__deleted"])
1351
+ },
1352
+ nodes: {
1353
+ table: "nodes",
1354
+ cols: new Set(["label", "platform", "form", "app", "first_seen", "__deleted"])
1383
1355
  }
1384
1356
  };
1385
1357
  RECORD_META = new Set(["database_id", "created_hlc", "order_key", "__deleted"]);
@@ -1397,10 +1369,149 @@ var init_crdt = __esm(() => {
1397
1369
  "sites:spa",
1398
1370
  "site_channels:__deleted",
1399
1371
  "site_files:__deleted",
1400
- "blob_policy:__deleted"
1372
+ "blob_policy:__deleted",
1373
+ "nodes:__deleted"
1401
1374
  ]);
1402
1375
  });
1403
1376
 
1377
+ // src/core/node.ts
1378
+ function getNodeId(db) {
1379
+ const row = db.query("SELECT value FROM meta WHERE key = 'node_id'").get();
1380
+ if (row)
1381
+ return row.value;
1382
+ const id = randomSuffix(8);
1383
+ db.query("INSERT INTO meta (key, value) VALUES ('node_id', ?)").run(id);
1384
+ return id;
1385
+ }
1386
+ function readNodeMeta(db, nodeId) {
1387
+ const r = db.query(`SELECT ${NODE_COLS} FROM nodes WHERE id = ? AND __deleted = 0`).get(nodeId);
1388
+ return r ? rowToMeta(r) : null;
1389
+ }
1390
+ function listNodeMeta(db) {
1391
+ return db.query(`SELECT ${NODE_COLS} FROM nodes WHERE __deleted = 0`).all().map(rowToMeta);
1392
+ }
1393
+ function legacySelfLabel(db) {
1394
+ const row = db.query("SELECT value FROM meta WHERE key = 'node_label'").get();
1395
+ return row?.value ?? null;
1396
+ }
1397
+ function mirrorLegacySelfLabel(db, label) {
1398
+ if (label == null) {
1399
+ db.query("DELETE FROM meta WHERE key = 'node_label'").run();
1400
+ return;
1401
+ }
1402
+ db.query("INSERT INTO meta (key, value) VALUES ('node_label', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(label);
1403
+ }
1404
+ function nodeLabelOf(db, nodeId) {
1405
+ const synced = readNodeMeta(db, nodeId)?.label;
1406
+ if (synced)
1407
+ return synced;
1408
+ const peer = db.query("SELECT label FROM peers WHERE node_id = ? AND label IS NOT NULL AND label <> '' LIMIT 1").get(nodeId);
1409
+ if (peer?.label)
1410
+ return peer.label;
1411
+ return nodeId === getNodeId(db) ? legacySelfLabel(db) : null;
1412
+ }
1413
+ function getNodeLabel(db) {
1414
+ return nodeLabelOf(db, getNodeId(db));
1415
+ }
1416
+ function setNodeLabel(db, label, nodeId) {
1417
+ const id = nodeId ?? getNodeId(db);
1418
+ const next = label == null || label.trim() === "" ? null : label.trim();
1419
+ const cur = readNodeMeta(db, id);
1420
+ if (id === getNodeId(db))
1421
+ mirrorLegacySelfLabel(db, next);
1422
+ if (cur && cur.label === next)
1423
+ return;
1424
+ if (!cur && next == null)
1425
+ return;
1426
+ withChangeGroup("node", () => {
1427
+ emit(db, "nodes", id, "label", next);
1428
+ });
1429
+ }
1430
+ function describeSelf(db, info = {}) {
1431
+ const id = getNodeId(db);
1432
+ const cur = readNodeMeta(db, id);
1433
+ const fields = {};
1434
+ if (info.platform !== undefined && (cur?.platform ?? null) !== info.platform)
1435
+ fields.platform = info.platform;
1436
+ if (info.form !== undefined && (cur?.form ?? null) !== info.form)
1437
+ fields.form = info.form;
1438
+ if (info.app !== undefined && (cur?.app ?? null) !== info.app)
1439
+ fields.app = info.app;
1440
+ if (cur?.first_seen == null)
1441
+ fields.first_seen = info.now ?? Date.now();
1442
+ if (!cur?.label) {
1443
+ const seed = legacySelfLabel(db) ?? info.defaultLabel ?? null;
1444
+ if (seed)
1445
+ fields.label = seed;
1446
+ }
1447
+ if (Object.keys(fields).length > 0) {
1448
+ withChangeGroup("node", () => {
1449
+ for (const [col, v] of Object.entries(fields))
1450
+ emit(db, "nodes", id, col, v);
1451
+ });
1452
+ }
1453
+ return readNodeMeta(db, id);
1454
+ }
1455
+ function platformFromProcess(p) {
1456
+ switch (p) {
1457
+ case "darwin":
1458
+ return "macos";
1459
+ case "win32":
1460
+ return "windows";
1461
+ case "linux":
1462
+ return "linux";
1463
+ case "android":
1464
+ return "android";
1465
+ default:
1466
+ return null;
1467
+ }
1468
+ }
1469
+ function defaultForm(platform, app) {
1470
+ if (app === "web")
1471
+ return "browser";
1472
+ if (platform === "ios" || platform === "android")
1473
+ return "phone";
1474
+ if (platform === "linux")
1475
+ return "server";
1476
+ if (platform === "macos" || platform === "windows")
1477
+ return "laptop";
1478
+ return null;
1479
+ }
1480
+ function displayNodes(db) {
1481
+ const self = getNodeId(db);
1482
+ const ids = new Set([self]);
1483
+ for (const r of listNodeMeta(db))
1484
+ ids.add(r.node_id);
1485
+ const peers = db.query("SELECT node_id FROM peers WHERE node_id IS NOT NULL AND node_id <> '' GROUP BY node_id").all();
1486
+ for (const p of peers)
1487
+ ids.add(p.node_id);
1488
+ const out = [...ids].map((node_id) => {
1489
+ const m = readNodeMeta(db, node_id);
1490
+ return {
1491
+ node_id,
1492
+ label: nodeLabelOf(db, node_id),
1493
+ self: node_id === self,
1494
+ platform: m?.platform ?? null,
1495
+ form: m?.form ?? null,
1496
+ app: m?.app ?? null
1497
+ };
1498
+ });
1499
+ out.sort((a, b) => Number(b.self) - Number(a.self));
1500
+ return out;
1501
+ }
1502
+ var NODE_COLS = "id, label, platform, form, app, first_seen", rowToMeta = (r) => ({
1503
+ node_id: r.id,
1504
+ label: r.label,
1505
+ platform: r.platform ?? null,
1506
+ form: r.form ?? null,
1507
+ app: r.app ?? null,
1508
+ first_seen: r.first_seen
1509
+ });
1510
+ var init_node = __esm(() => {
1511
+ init_ids();
1512
+ init_crdt();
1513
+ });
1514
+
1404
1515
  // src/core/sync/publisher-lease.ts
1405
1516
  async function isElectedPublisher(client, base, node, priority, now = Date.now()) {
1406
1517
  const beatId = `${base}\x00${node}`;
@@ -9510,6 +9621,22 @@ CREATE TABLE IF NOT EXISTS blob_policy (
9510
9621
  redundancy TEXT, -- 'all' | 'any'
9511
9622
  __deleted INTEGER NOT NULL DEFAULT 0
9512
9623
  );
9624
+
9625
+ -- Synced device roster (system tier, see tables.ts): one row per node id, written
9626
+ -- by the device itself on open (describeSelf) and by anyone who renames it.
9627
+ -- Replicates so every device shows the same name and icon for a peer \u2014 the only
9628
+ -- record a purely bucket-joined device leaves besides its oplog stream. Each
9629
+ -- column is its own LWW register, so a rename and a platform refresh never
9630
+ -- clobber each other. meta.node_label survives as a node-local read fallback.
9631
+ CREATE TABLE IF NOT EXISTS nodes (
9632
+ id TEXT PRIMARY KEY, -- node_id
9633
+ label TEXT, -- shared display name; any device may set it
9634
+ platform TEXT, -- macos | windows | linux | ios | android | web
9635
+ form TEXT, -- laptop | desktop | phone | server | browser
9636
+ app TEXT, -- cli | server | desktop | web
9637
+ first_seen INTEGER, -- epoch ms of the device's first self-description
9638
+ __deleted INTEGER NOT NULL DEFAULT 0
9639
+ );
9513
9640
  `, FTS_SCHEMA = `
9514
9641
  CREATE VIRTUAL TABLE IF NOT EXISTS search_fts USING fts5(
9515
9642
  kind UNINDEXED,
@@ -10009,17 +10136,14 @@ function cacheStats(db) {
10009
10136
  }
10010
10137
  function knownNodes(db) {
10011
10138
  const self = getNodeId(db);
10012
- const byId = new Map;
10013
- byId.set(self, getNodeLabel(db));
10014
- const peers = db.query("SELECT node_id, label FROM peers WHERE node_id IS NOT NULL").all();
10015
- for (const p of peers)
10016
- if (!byId.get(p.node_id))
10017
- byId.set(p.node_id, p.label ?? byId.get(p.node_id) ?? null);
10018
- const grants = db.query("SELECT DISTINCT node_id FROM peer_grants WHERE node_id IS NOT NULL").all();
10019
- for (const g of grants)
10020
- if (!byId.has(g.node_id))
10021
- byId.set(g.node_id, null);
10022
- return [...byId.entries()].map(([nodeId, label]) => ({ nodeId, label, self: nodeId === self }));
10139
+ const ids = new Set([self]);
10140
+ for (const r of db.query("SELECT id FROM nodes WHERE __deleted = 0").all())
10141
+ ids.add(r.id);
10142
+ for (const p of db.query("SELECT DISTINCT node_id FROM peers WHERE node_id IS NOT NULL").all())
10143
+ ids.add(p.node_id);
10144
+ for (const g of db.query("SELECT DISTINCT node_id FROM peer_grants WHERE node_id IS NOT NULL").all())
10145
+ ids.add(g.node_id);
10146
+ return [...ids].map((nodeId) => ({ nodeId, label: nodeLabelOf(db, nodeId), self: nodeId === self }));
10023
10147
  }
10024
10148
  var POLICY_ID = "default", blobBytesResolver = null, setFullNodes = (db, nodeIds) => {
10025
10149
  withChangeGroup(null, () => {
@@ -10253,21 +10377,28 @@ function migrateSitesAccess(db) {
10253
10377
  backfill("public_grants", "NULL");
10254
10378
  }
10255
10379
  }
10256
- function migrateSiteChannels(db) {
10257
- if (!tableExists3(db, "site_channels"))
10380
+ function replayDataset(db, dataset) {
10381
+ if (!tableExists3(db, dataset))
10258
10382
  return;
10259
- const wm = db.query("SELECT value FROM meta WHERE key = ?").get(SITE_CHANNELS_REPLAY_KEY);
10383
+ const key = `${dataset}_replay_seq`;
10384
+ const wm = db.query("SELECT value FROM meta WHERE key = ?").get(key);
10260
10385
  const since = wm ? Number(wm.value) : 0;
10261
10386
  const changes = db.query(`SELECT seq, ${CHANGE_SELECT} FROM crdt_changes
10262
- WHERE dataset = 'site_channels' AND seq > ? ORDER BY seq`).all(since);
10387
+ WHERE dataset = ? AND seq > ? ORDER BY seq`).all(dataset, since);
10263
10388
  if (changes.length === 0)
10264
10389
  return;
10265
10390
  db.transaction((rows) => {
10266
10391
  for (const change of rows)
10267
10392
  applyChange(db, change);
10268
- 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));
10393
+ db.query("INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(key, String(rows[rows.length - 1].seq));
10269
10394
  })(changes);
10270
10395
  }
10396
+ function migrateSiteChannels(db) {
10397
+ replayDataset(db, "site_channels");
10398
+ }
10399
+ function migrateNodes(db) {
10400
+ replayDataset(db, "nodes");
10401
+ }
10271
10402
  function initSchema(db) {
10272
10403
  runSchema(db);
10273
10404
  migrateOplog(db);
@@ -10282,8 +10413,8 @@ function initSchema(db) {
10282
10413
  migrateShares(db);
10283
10414
  migrateSitesAccess(db);
10284
10415
  migrateSiteChannels(db);
10416
+ migrateNodes(db);
10285
10417
  }
10286
- var SITE_CHANNELS_REPLAY_KEY = "site_channels_replay_seq";
10287
10418
  var init_schema_init = __esm(() => {
10288
10419
  init_records();
10289
10420
  init_documents();
@@ -11802,6 +11933,8 @@ async function addAndSyncStoragePeer(db, spec) {
11802
11933
  publish: spec.publish,
11803
11934
  priority: spec.priority
11804
11935
  };
11936
+ if (spec.provider)
11937
+ config.provider = spec.provider;
11805
11938
  if (encrypt) {
11806
11939
  if (spec.recoveryCode) {
11807
11940
  const rawKey = await decodeRecoveryCode(spec.recoveryCode);
@@ -13297,10 +13430,14 @@ function listDevices(db) {
13297
13430
  const ensure = (nodeId) => {
13298
13431
  let v = byNode.get(nodeId);
13299
13432
  if (!v) {
13433
+ const meta = readNodeMeta(db, nodeId);
13300
13434
  v = {
13301
13435
  nodeId,
13302
- label: null,
13436
+ label: nodeLabelOf(db, nodeId),
13303
13437
  self: nodeId === self,
13438
+ platform: meta?.platform ?? null,
13439
+ form: meta?.form ?? null,
13440
+ app: meta?.app ?? null,
13304
13441
  channels: [],
13305
13442
  lastActivityAt: null,
13306
13443
  revocable: "none",
@@ -13311,7 +13448,9 @@ function listDevices(db) {
13311
13448
  }
13312
13449
  return v;
13313
13450
  };
13314
- ensure(self).label = getNodeLabel(db);
13451
+ ensure(self);
13452
+ for (const r of db.query("SELECT id FROM nodes WHERE __deleted = 0").all())
13453
+ ensure(r.id);
13315
13454
  const oplog = db.query("SELECT node_id, MAX(hlc) AS h FROM crdt_changes GROUP BY node_id").all();
13316
13455
  for (const r of oplog)
13317
13456
  ensure(r.node_id).channels.push({ kind: "oplog", ref: "", lastSeenAt: parseHlc(r.h).millis });
@@ -13320,8 +13459,6 @@ function listDevices(db) {
13320
13459
  if (!p.node_id)
13321
13460
  continue;
13322
13461
  const v = ensure(p.node_id);
13323
- if (!v.label)
13324
- v.label = p.label;
13325
13462
  v.channels.push({
13326
13463
  kind: "paired_out",
13327
13464
  ref: p.url,
@@ -13339,6 +13476,9 @@ function listDevices(db) {
13339
13476
  nodeId: null,
13340
13477
  label: null,
13341
13478
  self: false,
13479
+ platform: null,
13480
+ form: null,
13481
+ app: null,
13342
13482
  channels: [ch],
13343
13483
  lastActivityAt: g.created_at,
13344
13484
  revocable: "yes",
@@ -14265,7 +14405,8 @@ function s3PeerViews(db) {
14265
14405
  prefix: c.prefix ?? null,
14266
14406
  accessKeyId: c.accessKeyId ?? null,
14267
14407
  encrypt: c.encrypt !== false,
14268
- virtualHostedStyle: c.virtualHostedStyle ?? null
14408
+ virtualHostedStyle: c.virtualHostedStyle ?? null,
14409
+ provider: c.provider ?? null
14269
14410
  };
14270
14411
  });
14271
14412
  }
@@ -14342,7 +14483,8 @@ var init_peers_routes = __esm(() => {
14342
14483
  prefix: string2().optional(),
14343
14484
  encrypt: boolean2().optional(),
14344
14485
  passphrase: string2().optional(),
14345
- corsOrigins: array(string2()).optional()
14486
+ corsOrigins: array(string2()).optional(),
14487
+ provider: string2().optional()
14346
14488
  });
14347
14489
  S3PeerSchema = object({
14348
14490
  url: string2(),
@@ -14359,7 +14501,8 @@ var init_peers_routes = __esm(() => {
14359
14501
  prefix: string2().nullable(),
14360
14502
  accessKeyId: string2().nullable(),
14361
14503
  encrypt: boolean2(),
14362
- virtualHostedStyle: boolean2().nullable()
14504
+ virtualHostedStyle: boolean2().nullable(),
14505
+ provider: string2().nullable()
14363
14506
  });
14364
14507
  DataPlaceSchema = object({
14365
14508
  kind: _enum(["self", "device", "bucket"]),
@@ -17706,20 +17849,42 @@ init_blobs();
17706
17849
  // src/core/db.ts
17707
17850
  init_paths();
17708
17851
  init_schema_init();
17852
+ init_node();
17709
17853
  init_schema_init();
17710
17854
  import { Database } from "bun:sqlite";
17711
17855
  import { mkdirSync } from "fs";
17856
+ import { hostname } from "os";
17712
17857
  function ensureDirs() {
17713
17858
  mkdirSync(metahubHome(), { recursive: true });
17714
17859
  mkdirSync(cacheDir(), { recursive: true });
17715
17860
  }
17861
+ function appKind() {
17862
+ const v = process.env.METAHUB_APP;
17863
+ return v === "server" || v === "desktop" || v === "web" ? v : "cli";
17864
+ }
17716
17865
  function openMetahub() {
17717
17866
  ensureDirs();
17718
17867
  const db = new Database(dbPath(), { create: true });
17719
17868
  db.exec("PRAGMA journal_mode = WAL;");
17720
17869
  initSchema(db);
17870
+ const app = appKind();
17871
+ const platform = platformFromProcess(process.platform);
17872
+ describeSelf(db, {
17873
+ app,
17874
+ platform,
17875
+ form: defaultForm(platform, app),
17876
+ defaultLabel: safeHostname()
17877
+ });
17721
17878
  return db;
17722
17879
  }
17880
+ function safeHostname() {
17881
+ try {
17882
+ const h = hostname();
17883
+ return h ? h.replace(/\.local$/i, "") : null;
17884
+ } catch {
17885
+ return null;
17886
+ }
17887
+ }
17723
17888
 
17724
17889
  // src/cli/commands/init.ts
17725
17890
  init_node();
@@ -22302,6 +22467,24 @@ import { join as join8 } from "path";
22302
22467
  // src/core/integrity.ts
22303
22468
  init_crdt();
22304
22469
  init_properties();
22470
+ function rematerializeDataset(db, dataset) {
22471
+ const d = DOMAIN[dataset];
22472
+ if (!d)
22473
+ throw new Error(`not a replicated dataset: ${dataset}`);
22474
+ const changes = db.query(`SELECT ${CHANGE_SELECT} FROM crdt_changes WHERE dataset = ? ORDER BY seq`).all(dataset);
22475
+ db.transaction((rows) => {
22476
+ db.query(`DELETE FROM ${d.table}`).run();
22477
+ for (const c of rows)
22478
+ applyChange(db, c);
22479
+ })(changes);
22480
+ return changes.length;
22481
+ }
22482
+ function rematerializeAll(db) {
22483
+ const out = {};
22484
+ for (const ds of Object.keys(DOMAIN))
22485
+ out[ds] = rematerializeDataset(db, ds);
22486
+ return out;
22487
+ }
22305
22488
  var REF_RULES = [
22306
22489
  { srcTable: "properties", fk: "database_id", tgtTable: "databases", fix: "tombstone-src" },
22307
22490
  { srcTable: "records", fk: "database_id", tgtTable: "databases", fix: "tombstone-src" },
@@ -22601,6 +22784,39 @@ var repairHub = grouped(function repairHub(db) {
22601
22784
  init_paths();
22602
22785
  init_cache();
22603
22786
  init_errors();
22787
+
22788
+ // src/core/tables.ts
22789
+ var TABLES = {
22790
+ databases: { tier: "content", synced: true },
22791
+ properties: { tier: "content", synced: true },
22792
+ records: { tier: "content", synced: true },
22793
+ documents: { tier: "content", synced: true },
22794
+ doc_blocks: { tier: "content", synced: true },
22795
+ sites: { tier: "content", synced: true },
22796
+ site_files: { tier: "content", synced: true },
22797
+ site_channels: { tier: "system", synced: true },
22798
+ blob_policy: { tier: "system", synced: true },
22799
+ nodes: { tier: "system", synced: true },
22800
+ meta: { tier: "local", synced: false },
22801
+ crdt_changes: { tier: "local", synced: false },
22802
+ peers: { tier: "local", synced: false },
22803
+ storage_cursors: { tier: "local", synced: false },
22804
+ room_rows: { tier: "local", synced: false },
22805
+ peer_grants: { tier: "local", synced: false },
22806
+ pairing_codes: { tier: "local", synced: false },
22807
+ shares: { tier: "local", synced: false },
22808
+ site_channel_observations: { tier: "local", synced: false },
22809
+ blob_cache: { tier: "local", synced: false },
22810
+ drop_rejects: { tier: "local", synced: false },
22811
+ search_fts: { tier: "local", synced: false }
22812
+ };
22813
+ var names = (pred) => Object.entries(TABLES).filter(([, s]) => pred(s)).map(([n]) => n);
22814
+ var SYNCED_TABLES = names((s) => s.synced);
22815
+ var CONTENT_TABLES = names((s) => s.tier === "content");
22816
+ var SYSTEM_DATASETS = names((s) => s.tier === "system");
22817
+ var LOCAL_TABLES = names((s) => s.tier === "local");
22818
+
22819
+ // src/core/snapshot.ts
22604
22820
  var SNAPSHOT_FORMAT = "metahub-snapshot";
22605
22821
  var SNAPSHOT_VERSION = 1;
22606
22822
  function readMeta3(db, key) {
@@ -22692,16 +22908,9 @@ async function restoreSnapshot(db, pkg, opts = {}) {
22692
22908
  await writeSnapshot(await createSnapshot(db), safetyPath);
22693
22909
  let applied = 0;
22694
22910
  const tx = db.transaction(() => {
22695
- for (const t of [
22696
- "crdt_changes",
22697
- "databases",
22698
- "properties",
22699
- "records",
22700
- "documents",
22701
- "doc_blocks",
22702
- "peers"
22703
- ])
22911
+ for (const t of ["crdt_changes", ...SYNCED_TABLES, "peers"])
22704
22912
  db.query(`DELETE FROM ${t}`).run();
22913
+ db.query("DELETE FROM meta WHERE key LIKE '%_replay_seq'").run();
22705
22914
  db.query("DELETE FROM meta WHERE key IN ('search_seq', 'search_index_version')").run();
22706
22915
  if (ftsAvailable(db))
22707
22916
  db.query("DELETE FROM search_fts").run();
@@ -22932,10 +23141,19 @@ var repair_default = defineCommand({
22932
23141
  "dry-run": {
22933
23142
  type: "boolean",
22934
23143
  description: "Report what would be fixed without changing anything (same as `mh doctor`)"
23144
+ },
23145
+ rematerialize: {
23146
+ type: "boolean",
23147
+ description: "First rebuild every replicated table from the oplog (fixes a dropped/emptied/corrupted table; local-only, emits nothing)"
22935
23148
  }
22936
23149
  },
22937
23150
  run: ({ args }) => {
22938
23151
  const db = openMetahub();
23152
+ if (args.rematerialize && !args["dry-run"]) {
23153
+ const replayed = rematerializeAll(db);
23154
+ const total = Object.values(replayed).reduce((a, b) => a + b, 0);
23155
+ console.error(`rematerialized ${Object.keys(replayed).length} table(s) from ${total} oplog change(s)`);
23156
+ }
22939
23157
  if (args["dry-run"]) {
22940
23158
  const report = validateHub(db);
22941
23159
  const fixable = report.issues.filter((i) => i.fixable);
@@ -26415,9 +26633,10 @@ function isMetaCol(dataset, col) {
26415
26633
  return col === "created_hlc" || col === "__deleted";
26416
26634
  }
26417
26635
  function oplogExclude() {
26636
+ const excluded = [...OPLOG_ONLY_DATASETS, ...SYSTEM_DATASETS];
26418
26637
  return {
26419
- sql: [...OPLOG_ONLY_DATASETS].map(() => "dataset != ?"),
26420
- params: [...OPLOG_ONLY_DATASETS]
26638
+ sql: excluded.map(() => "dataset != ?"),
26639
+ params: excluded
26421
26640
  };
26422
26641
  }
26423
26642
  function changesOfTxn(db, txn, beforeHlc) {
@@ -26881,6 +27100,7 @@ function scalarHtml(specUrl) {
26881
27100
  }
26882
27101
  function startServer(opts = {}) {
26883
27102
  registerRoomBlobResolver(resolveBlob);
27103
+ process.env.METAHUB_APP ??= "server";
26884
27104
  const tDb = Date.now();
26885
27105
  const db = openMetahub();
26886
27106
  const dbMs = Date.now() - tDb;
@@ -27379,9 +27599,34 @@ var styles_default = ` :root {
27379
27599
  .set-row-line { display:flex; align-items:center; gap:14px; min-height:44px; padding:5px 0; }
27380
27600
  .set-row.clickable > .set-row-line { cursor:pointer; }
27381
27601
  .set-row-text { flex:1 1 auto; min-width:0; }
27382
- .set-row-title { font-size:14px; font-weight:550; color:var(--fg); }
27383
- .set-row-caption { font-size:12px; color:var(--muted); margin-top:1px;
27602
+ .set-row-title { font-size:14px; font-weight:550; color:var(--fg); display:flex; align-items:center; gap:8px; min-width:0; }
27603
+ .set-row-title > .set-row-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
27604
+ .set-row-caption { font-size:12px; color:var(--muted); margin-top:1px; display:flex; align-items:center; gap:6px;
27384
27605
  white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
27606
+ /* leading glyph (device form / bucket) \u2014 a soft tile so rows scan by shape */
27607
+ .set-row-lead { flex:none; width:28px; height:28px; display:grid; place-items:center; color:var(--muted);
27608
+ border-radius:7px; background:var(--surface-2); }
27609
+ .set-row-lead .ico { width:15px; height:15px; }
27610
+ .set-row.dim > .set-row-line { opacity:.62; }
27611
+ /* status dot in a caption: the only colour a row carries */
27612
+ .cap-dot { display:inline-block; flex:none; width:7px; height:7px; border-radius:50%; background:var(--muted); }
27613
+ .cap-dot.ok { background:var(--success); } .cap-dot.warn { background:var(--warning,#9a6700); } .cap-dot.err { background:var(--danger); }
27614
+ .cap-err { color:var(--danger); }
27615
+ /* tag variants next to a title: neutral outline (default), accent (roles), self (soft fill) */
27616
+ .set-row-title .set-row-tag { margin-left:0; vertical-align:0; flex:none; }
27617
+ .set-row-tag.accent { color:var(--accent); background:var(--accent-soft); border-color:transparent; }
27618
+ .set-row-tag.self { color:var(--fg-soft); background:var(--surface-2); border-color:transparent; }
27619
+ .set-row-tag.mono { font-family:var(--mono); letter-spacing:-.01em; }
27620
+ /* quiet "+ add" row closing a list, and a "N more\u2026" fold */
27621
+ .add-row { display:flex; align-items:center; gap:8px; width:100%; min-height:40px; margin-top:2px; padding:0 2px;
27622
+ color:var(--muted); font-size:var(--fs-ui); border-radius:var(--radius-sm); text-align:left; }
27623
+ .add-row:hover { color:var(--fg); background:var(--hover); }
27624
+ .add-row .ico { width:15px; height:15px; }
27625
+ .more-row { display:inline-flex; align-items:center; gap:6px; padding:8px 2px; font-size:var(--fs-sm); color:var(--muted); }
27626
+ .more-row:hover { color:var(--fg); }
27627
+ .set-section-head { display:flex; align-items:baseline; gap:10px; margin:0 0 4px 2px; }
27628
+ .set-section-head .set-section-label { margin:0; }
27629
+ .set-section-count { font-size:12px; color:var(--muted); font-variant-numeric:tabular-nums; }
27385
27630
  .set-row-control { flex:none; display:flex; align-items:center; gap:8px; }
27386
27631
  .set-row-detail { padding:2px 0 12px; }
27387
27632
  .set-row.disabled > .set-row-line { opacity:.55; pointer-events:none; }
@@ -27446,8 +27691,12 @@ var styles_default = ` :root {
27446
27691
  .audit-detail { padding:0 4px 10px 86px; }
27447
27692
  .audit-detail-ent-name { font-size:var(--fs-sm); font-weight:600; margin-top:6px; }
27448
27693
  .audit-blocks { font-size:var(--fs-sm); margin-top:2px; }
27449
- .audit-more { display:block; margin:14px auto 0; }
27450
- .audit-foot { font-size:var(--fs-xs); margin-top:18px; line-height:1.6; }
27694
+ /* quiet tail: left-aligned text button while more exists; a rule once exhausted */
27695
+ .audit-more { display:inline-flex; align-items:center; gap:6px; margin:14px 0 0 2px; font-size:var(--fs-sm); color:var(--muted); }
27696
+ .audit-more:hover { color:var(--fg); }
27697
+ .audit-more.loading { opacity:.6; pointer-events:none; }
27698
+ .audit-end { display:flex; align-items:center; gap:12px; margin:18px 0 0; color:var(--muted); font-size:var(--fs-xs); }
27699
+ .audit-end::before, .audit-end::after { content:""; flex:1; height:1px; background:var(--line); }
27451
27700
  @media (hover:none) { .audit-revert { opacity:1; } }
27452
27701
  .set-callout.warn { border-color:color-mix(in srgb,var(--warning,#9a6700) 32%,var(--line)); }
27453
27702
  /* two-fact meta line under a block title (who publishes \xB7 where creds live) \u2014
@@ -27562,28 +27811,31 @@ var styles_default = ` :root {
27562
27811
  /* concurrent problems under the headline (only when >1, else redundant) */
27563
27812
  .sync-health-issues { padding:2px 0 4px; }
27564
27813
  .sync-health-issue { font-size:12px; color:var(--muted); padding:2px 0 2px 18px; }
27565
- .sync-health-places { padding:4px 0 2px; }
27566
- .sync-health-place { display:flex; align-items:baseline; gap:8px; padding:8px 0; min-width:0; }
27814
+ /* \u67E5\u770B\u5404\u5904: a bordered place list (kind \xB7 name \xB7 freshness \xB7 inline retry) */
27815
+ .sync-health-places { margin-top:6px; border:1px solid var(--line); border-radius:var(--radius); overflow:hidden; }
27816
+ .sync-health-place { display:flex; align-items:center; gap:10px; padding:9px 12px; min-width:0; font-size:var(--fs-sm); }
27567
27817
  .sync-health-place + .sync-health-place { border-top:1px solid var(--line); }
27568
- .sync-health-place-kind { flex:none; font-size:11px; color:var(--muted); border:1px solid var(--line);
27569
- border-radius:999px; padding:1px 7px; }
27570
- .sync-health-place-label { font-size:var(--fs-body); font-weight:550; overflow:hidden;
27571
- text-overflow:ellipsis; white-space:nowrap; }
27572
- .sync-health-place-cap { flex:1; min-width:0; font-size:12px; color:var(--muted);
27573
- overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
27818
+ .sync-health-place-kind { flex:none; width:64px; font-size:var(--fs-xs); color:var(--muted); }
27819
+ .sync-health-place-label { flex:none; font-weight:550; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:40%; }
27820
+ .sync-health-place-cap { flex:1; min-width:0; color:var(--muted); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
27821
+ .sync-health-place .btn { margin-left:auto; }
27574
27822
  .sync-health-role { flex:none; font-size:11px; color:var(--accent); background:var(--accent-soft);
27575
27823
  border-radius:999px; padding:2px 7px; }
27576
27824
 
27577
- /* unified device roster (DevicesPanel) */
27578
- .device-detail { padding:2px 12px 10px 28px; display:flex; flex-direction:column; gap:4px; }
27579
- .device-detail-line { display:flex; align-items:center; gap:8px; font-size:12px; min-width:0; }
27580
- .device-ch-label { flex:none; color:var(--muted); border:1px solid var(--line); border-radius:999px; padding:1px 7px; font-size:11px; }
27581
- .device-ch-ref { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--fg); }
27582
- .device-ch-actions { margin-left:auto; display:inline-flex; gap:2px; flex:none; }
27583
- .device-ch-actions .btn { padding:2px 8px; font-size:12px; }
27584
- .device-danger { color:var(--danger); }
27585
- .device-detail-note { font-size:12px; color:var(--muted); background:var(--surface-2);
27586
- border-radius:8px; padding:8px 10px; margin-top:4px; line-height:1.6; }
27825
+ /* device roster (settings/devices-page.tsx): expanded detail = key/value
27826
+ table + one action row + one plain sentence \u2014 no gray callout box. */
27827
+ .device-kv { display:grid; grid-template-columns:max-content 1fr; gap:6px 18px; padding:4px 0 10px 42px; font-size:var(--fs-sm); margin:0; }
27828
+ .device-kv dt { color:var(--muted); margin:0; }
27829
+ .device-kv dd { margin:0; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
27830
+ .device-kv .mono { font-family:var(--mono); font-size:var(--fs-xs); letter-spacing:-.01em; }
27831
+ .device-actions { display:flex; align-items:center; gap:4px; padding-left:42px; flex-wrap:wrap; }
27832
+ .device-actions .btn { padding:3px 8px; font-size:12px; }
27833
+ .device-actions .btn.danger { color:var(--danger); }
27834
+ .device-actions .btn.danger:hover { background:var(--danger-soft); color:var(--danger); }
27835
+ .device-note { font-size:12px; color:var(--muted); line-height:1.6; padding:4px 0 6px 42px; max-width:60ch; }
27836
+ .device-note b { color:var(--fg-soft); font-weight:600; }
27837
+ .device-note a { color:var(--accent); text-decoration:none; }
27838
+ .device-note a:hover { text-decoration:underline; }
27587
27839
 
27588
27840
  /* cloud accessories panel (CloudPanel: bucket + edge in one glance) */
27589
27841
  .cloud-cta { border:1px solid var(--line); border-radius:10px; padding:14px 16px; margin:4px 0 12px;
@@ -27658,6 +27910,7 @@ var styles_default = ` :root {
27658
27910
  .btn-primary { background:var(--accent); color:var(--accent-fg); box-shadow:var(--shadow-sm); }
27659
27911
  .btn-primary:hover { filter:brightness(1.07); }
27660
27912
  .btn-danger { background:var(--danger); color:#fff; } .btn-danger:hover { filter:brightness(1.05); }
27913
+ .btn.sm { padding:3px 8px; font-size:12px; }
27661
27914
  /* Notion-style ambient sync indicator: a quiet ghost control living in the
27662
27915
  topbar icon row. State reads through the *text/icon colour* \u2014 no filled pill
27663
27916
  or drop shadow \u2014 and only the error state escalates to a visible danger tone.
@@ -27687,6 +27940,26 @@ var styles_default = ` :root {
27687
27940
  @keyframes save-pop { 0% { transform:scale(.7); } 100% { transform:scale(1); } }
27688
27941
  @keyframes save-shake { 10%,90% { transform:translateX(-1px); } 20%,80% { transform:translateX(1.5px); } 30%,50%,70% { transform:translateX(-2px); } 40%,60% { transform:translateX(2px); } }
27689
27942
  @keyframes save-kbd-pulse { 0% { transform:scale(1); } 45% { transform:scale(1.05); } 100% { transform:scale(1); } }
27943
+ /* Ambient replica-sync indicator (sync-indicator.tsx): same ghost-control
27944
+ idiom as .share-save \u2014 zero footprint when idle, colour carries state,
27945
+ only error escalates to danger. Lives in .sb-footer (desktop), .sb-head
27946
+ (mobile home) and the topbar (sidebar collapsed). */
27947
+ .sync-ind { display:inline-flex; align-items:center; gap:6px; height:28px; padding:0 8px; border-radius:var(--radius-sm);
27948
+ font-size:var(--fs-xs); color:var(--fg-soft); white-space:nowrap; font-variant-numeric:tabular-nums; flex:none;
27949
+ animation:save-rise .22s ease both; }
27950
+ .sync-ind:hover { background:var(--hover-2); color:var(--fg); }
27951
+ .sync-ind-ico { display:inline-flex; align-items:center; }
27952
+ .sync-ring { width:12px; height:12px; border-radius:50%; border:1.5px solid var(--line-strong);
27953
+ border-top-color:var(--accent); animation:mh-spin .8s linear infinite; }
27954
+ .sync-ind-done { color:var(--success); }
27955
+ .sync-ind-done .sync-ind-ico { animation:save-pop .24s cubic-bezier(.18,.9,.24,1.22) both; }
27956
+ .sync-ind-error { color:var(--danger); }
27957
+ .sync-ind-error:hover { background:var(--danger-soft); color:var(--danger); }
27958
+ /* footer: push right, version stays flush right after it */
27959
+ .sb-footer .sync-ind { margin-left:auto; }
27960
+ .sb-footer .sync-ind + .sbf-ver { margin-left:8px; }
27961
+ /* head slot is mobile-only (mirrors the .sb-head .iconbtn gating) */
27962
+ .sb-head .sync-ind { display:none; }
27690
27963
 
27691
27964
  /* macOS desktop (hiddenInset frameless): drop the sidebar logo and reserve a
27692
27965
  draggable strip so the inset traffic lights never overlap our controls.
@@ -28376,55 +28649,148 @@ var styles_default = ` :root {
28376
28649
  .site-empty .et { font-size:15px; color:var(--fg); margin-bottom:5px; font-weight:650; letter-spacing:-.01em; }
28377
28650
  .site-empty .ed { font-size:var(--fs-sm); margin-bottom:18px; max-width:42ch; line-height:1.55; }
28378
28651
 
28379
- /* \u2500\u2500 \u5206\u4EAB (global ShareView) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
28380
- .shares-page .db-head { align-items:flex-start; gap:16px; margin-bottom:18px; }
28381
- .shv-tools { margin-left:auto; display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
28652
+ /* \u2500\u2500 \u5206\u4EAB (global ShareView) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
28653
+ One row = object \xB7 status \xB7 one primary action \xB7 overflow. State is the
28654
+ organizing idea: colour lives only in the status dot and the \u53EF\u7F16\u8F91 word;
28655
+ expiry is a share's normal ending and reads muted, never danger. */
28656
+ .shares-page .db-head { align-items:flex-start; gap:16px; margin-bottom:6px; }
28657
+ .shares-page .db-desc { max-width:56ch; }
28658
+ .shv-toolbar { display:flex; align-items:center; gap:10px; flex-wrap:nowrap; padding:10px 0; margin-bottom:8px; }
28659
+ .shv-tools-right { display:flex; align-items:center; gap:10px; flex:none; margin-left:auto; }
28382
28660
  .shv-seg { display:inline-flex; background:var(--surface-2); border:1px solid var(--line); border-radius:var(--radius-sm); padding:2px; }
28383
- .shv-seg-btn { padding:5px 11px; border-radius:calc(var(--radius-sm) - 2px); font-size:13px; color:var(--fg-soft); }
28661
+ .shv-seg-btn { display:inline-flex; align-items:baseline; padding:5px 11px; border-radius:calc(var(--radius-sm) - 2px); font-size:13px; color:var(--fg-soft); }
28384
28662
  .shv-seg-btn.active { background:var(--bg); color:var(--fg); box-shadow:var(--shadow-sm); }
28385
- .shv-search { display:flex; align-items:center; gap:6px; background:var(--bg); border:1px solid var(--line-strong); border-radius:var(--radius-sm); padding:5px 10px; color:var(--muted); }
28386
- .shv-search input { border:0; background:none; outline:none; color:var(--fg); font-size:13px; width:180px; }
28663
+ .shv-seg-btn .n { display:inline-flex; justify-content:flex-end; align-items:center; min-width:2ch; margin-left:5px; font-family:var(--mono); font-size:var(--fs-micro); color:var(--muted); font-variant-numeric:tabular-nums; }
28664
+ .shv-seg-btn .skel-n { width:2ch; height:.85em; border-radius:3px; animation:skel-in .25s ease .12s both; }
28665
+ .shv-seg-btn.active .n { color:var(--fg-soft); }
28666
+ .shv-src { gap:5px; color:var(--fg-soft); }
28667
+ .shv-src.on { color:var(--fg); }
28668
+ .shv-src .ico { width:14px; height:14px; opacity:.7; }
28669
+ .shv-search { display:flex; align-items:center; gap:6px; flex:1 1 220px; min-width:140px; max-width:360px; background:var(--bg); border:1px solid var(--line-strong); border-radius:var(--radius-sm); padding:5px 10px; color:var(--muted); }
28670
+ .shv-search:focus-within { border-color:var(--accent); }
28671
+ .shv-search input { border:0; background:none; outline:none; color:var(--fg); font-size:13px; width:100%; min-width:0; }
28387
28672
 
28388
28673
  .shv-list { display:flex; flex-direction:column; gap:8px; list-style:none; padding:0; margin:0; }
28389
- .shv-row { display:flex; align-items:center; gap:13px; padding:13px 15px; background:var(--bg); border:1px solid var(--line);
28674
+ .shv-row { display:grid; grid-template-columns:38px minmax(0,1fr) auto auto; gap:6px 13px; align-items:center;
28675
+ padding:12px 15px; background:var(--bg); border:1px solid var(--line);
28390
28676
  border-radius:var(--radius-lg); transition:border-color .12s, box-shadow .12s; }
28391
28677
  .shv-row:hover { border-color:var(--line-strong); box-shadow:var(--shadow-sm); }
28392
28678
  .shv-ico { width:38px; height:38px; flex:none; display:grid; place-items:center; border-radius:var(--radius-md, 10px);
28393
28679
  background:var(--surface-2); color:var(--fg-soft); }
28394
28680
  .shv-ico svg { width:19px; height:19px; }
28395
28681
  .shv-ico.k-doc { color:var(--accent); background:var(--accent-soft); }
28682
+ .shv-ico.k-database { color:var(--fg-soft); }
28396
28683
  .shv-ico.k-site { color:#1a7f5a; }
28397
- .shv-main { flex:1; min-width:0; }
28398
- .shv-titleline { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
28399
- .shv-title { font-weight:600; color:var(--fg); letter-spacing:-.01em; max-width:34ch; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
28684
+ .shv-main { min-width:0; }
28685
+ .shv-title { display:block; max-width:100%; font-weight:600; color:var(--fg); letter-spacing:-.01em;
28686
+ overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:left; }
28400
28687
  .shv-title:hover { color:var(--accent); }
28401
- .shv-pill { font-size:11px; line-height:1.6; padding:0 8px; border-radius:999px; border:1px solid var(--line-strong); color:var(--fg-soft); }
28402
- .shv-pill.t-s3 { background:var(--accent-soft); border-color:transparent; color:var(--accent); }
28403
- .shv-pill.p-edit { background:var(--danger-soft); border-color:transparent; color:var(--danger); }
28404
- .shv-lock { font-size:12px; }
28405
- .shv-meta { margin-top:3px; font-size:var(--fs-sm); color:var(--muted); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
28406
- .shv-exp.warn { color:var(--danger); }
28407
- .shv-exp.dead { text-decoration:line-through; }
28688
+ .shv-sub { display:flex; align-items:center; gap:6px; margin-top:3px; min-width:0; font-size:var(--fs-sm); color:var(--muted);
28689
+ white-space:nowrap; overflow:hidden; }
28690
+ .shv-sub > * { flex:none; }
28691
+ .shv-sub > .shv-link { flex:0 1 auto; }
28692
+ .shv-link-text { min-width:0; overflow:hidden; text-overflow:ellipsis; }
28693
+ .shv-sep { color:var(--line-strong); }
28694
+ /* the link itself: mono, copy on click, copy glyph slides in on hover */
28695
+ .shv-link { display:inline-flex; align-items:center; gap:4px; min-width:0; max-width:46ch; font-family:var(--mono); font-size:var(--fs-xs);
28696
+ color:var(--fg-soft); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; border-radius:4px; }
28697
+ .shv-link .ico { width:12px; height:12px; opacity:0; transition:opacity .12s; flex:none; }
28698
+ .shv-link:not(:disabled):hover { color:var(--accent); }
28699
+ .shv-link:not(:disabled):hover .ico { opacity:1; }
28700
+ .shv-link:disabled { cursor:default; text-decoration:line-through; text-decoration-color:var(--line-strong); }
28701
+ .shv-snap { font-variant-numeric:tabular-nums; }
28702
+ .shv-perm.edit { color:var(--danger); font-weight:500; }
28703
+ .shv-lock { display:inline-flex; color:var(--fg-soft); }
28704
+ .shv-lock .ico { width:12px; height:12px; }
28705
+ /* status column: dot + short text, fixed floor so rows never jitter */
28706
+ .shv-status { display:inline-flex; align-items:center; gap:7px; min-width:9ch; font-size:var(--fs-sm); color:var(--fg-soft);
28707
+ font-variant-numeric:tabular-nums; white-space:nowrap; }
28708
+ .shv-dot { width:8px; height:8px; border-radius:50%; background:var(--muted); flex:none; }
28709
+ .shv-status.tone-ok .shv-dot { background:var(--success); }
28710
+ .shv-status.tone-warn { color:var(--warning,#9a6700); }
28711
+ .shv-status.tone-warn .shv-dot { background:var(--warning,#9a6700); }
28712
+ .shv-status.tone-muted { color:var(--muted); }
28713
+ .shv-status.tone-muted .shv-dot { background:transparent; border:1.5px solid var(--line-strong); }
28714
+ .shv-status .sync-ring { width:10px; height:10px; }
28408
28715
  .shv-actions { display:flex; align-items:center; gap:6px; flex:none; }
28409
- .shv-actions .btn { padding:5px 10px; font-size:12px; }
28716
+ .shv-actions .btn { padding:5px 10px; font-size:12px; min-width:76px; justify-content:center; }
28717
+ .shv-actions .btn.txt-danger { color:var(--danger); }
28718
+ .shv-row.st-expired .shv-title { color:var(--fg-soft); }
28719
+ /* expired group sinks below the live list */
28720
+ .shv-group-head { display:flex; align-items:center; gap:8px; margin:22px 2px 8px; font-size:var(--fs-sm); font-weight:600; color:var(--muted); }
28721
+ .shv-group-head .n { font-family:var(--mono); font-size:var(--fs-micro); font-weight:400; }
28722
+ .shv-group-head .btn { margin-left:auto; padding:4px 10px; font-size:12px; }
28723
+ /* list swap: data arriving over the skeleton fades in rather than hard-cutting */
28724
+ .shv-list { animation:skel-in .18s ease both; }
28725
+
28726
+ /* \u2500\u2500 skeletons (skeleton.tsx, primitives.tsx SetRowSkeleton, shares-view
28727
+ ShareListSkeleton). Rows are the REAL row markup; only content is swapped
28728
+ for sheen blocks. .skel-list fades in after 120ms so an instant load
28729
+ never flashes it; --i offsets each row's sheen into one rolling wave. */
28730
+ .skel-list { animation:skel-in .25s ease .12s both; }
28731
+ .skel-lines { display:flex; flex-direction:column; gap:8px; }
28732
+ .skel-lines .skel-s { width:calc(58% + var(--i, 0) * 9%); max-width:100%; }
28733
+ .page-header-sub .skel-s { display:block; width:22ch; }
28734
+ .set-row.skel { pointer-events:none; }
28735
+ .set-row.skel .set-row-text { display:flex; flex-direction:column; gap:5px; padding:3px 0; }
28736
+ .set-row.skel .skel-t { height:14px; width:calc(30% + var(--i, 0) * 8%); border-radius:5px; }
28737
+ .set-row.skel .skel-s { height:11px; width:calc(44% + var(--i, 0) * 7%); border-radius:4px; }
28738
+ .set-row.skel .set-row-lead.skel-b { border-radius:7px; }
28739
+ .skel-switch { width:38px; height:22px; border-radius:999px; }
28740
+ .skel-chevron { width:14px; height:14px; border-radius:4px; }
28741
+ .skel-menu { width:28px; height:28px; border-radius:var(--radius-sm); }
28742
+ .skel-button { width:76px; height:29px; border-radius:var(--radius-sm); }
28743
+ .audit-item.skel .audit-item-line { cursor:default; pointer-events:none; }
28744
+ .audit-item.skel .skel-when { width:56px; height:12px; border-radius:4px; flex:none; }
28745
+ .audit-item.skel .skel-t { height:14px; width:calc(34% + var(--i, 0) * 9%); border-radius:5px; }
28746
+ .site-card.skel { pointer-events:none; }
28747
+ .site-card.skel:hover { transform:none; box-shadow:none; border-color:var(--line); }
28748
+ .site-card.skel .si.skel-b { background:var(--surface-2); }
28749
+ .site-card.skel .skel-t { height:14px; width:calc(40% + var(--i, 0) * 8%); border-radius:5px; margin-bottom:6px; }
28750
+ .site-card.skel .skel-s { height:11px; width:calc(55% + var(--i, 0) * 6%); border-radius:4px; }
28751
+ .site-card.skel .skel-addr { height:30px; width:80%; border-radius:var(--radius-sm); }
28752
+ .filerow.skel { pointer-events:none; }
28753
+ .filerow.skel .fi.skel-b { background:var(--surface-2); }
28754
+ .filerow.skel .skel-t { height:13px; width:calc(36% + var(--i, 0) * 10%); border-radius:4px; }
28755
+ .blob-hero.skel .blob-legend .skel-s { height:12px; width:calc(70% + var(--i, 0) * 8%); }
28756
+ .blob-hero.skel .blob-total .skel-s { display:block; height:11px; width:14ch; }
28757
+ .skel-b { display:block; position:relative; overflow:hidden; background:var(--surface-2); border-radius:6px; }
28758
+ .skel-b::after { content:""; position:absolute; inset:0;
28759
+ background:linear-gradient(90deg, transparent 20%, color-mix(in srgb, var(--fg) 5%, transparent) 50%, transparent 80%);
28760
+ background-size:200% 100%; animation:skel-sweep 1.6s ease-in-out infinite;
28761
+ animation-delay:calc(var(--i, 0) * 120ms); }
28762
+ .shv-row.skel { pointer-events:none; }
28763
+ .shv-row.skel:hover { border-color:var(--line); box-shadow:none; }
28764
+ .skel-t { height:1.55em; height:1lh; width:calc(52% + var(--i, 0) * 9%); border-radius:5px; }
28765
+ .skel-s { height:1.55em; height:1lh; width:min(44ch, 60%); border-radius:4px; }
28766
+ .shv-status .skel-st { height:1.55em; height:1lh; width:7ch; border-radius:4px; }
28767
+ .skel-btn { width:76px; height:29px; border-radius:var(--radius-sm); }
28768
+ .skel-ib { width:30px; height:30px; border-radius:var(--radius-sm); }
28769
+ @keyframes skel-in { from { opacity:0; } to { opacity:1; } }
28770
+ @keyframes skel-sweep { from { background-position:100% 0; } to { background-position:-100% 0; } }
28410
28771
  @media (max-width:640px){
28411
- /* head stacks: title/desc full row, then segmented tabs, then search */
28772
+ .shv-actions .skel-btn { flex:1; width:auto; height:36px; }
28773
+ .shv-actions .skel-ib { width:40px; height:36px; }
28774
+ /* head stacks: title/desc full row, then segmented tabs, then [source][search] */
28412
28775
  .shares-page .db-head { flex-direction:column; align-items:stretch; gap:12px; }
28413
- .shv-tools { margin-left:0; width:100%; }
28414
- .shv-seg { display:flex; width:100%; }
28415
- .shv-seg-btn { flex:1; text-align:center; }
28416
- .shv-search { flex:1; }
28417
- .shv-search input { width:100%; flex:1; }
28418
-
28419
- /* cards: title clamps to two lines, meta wraps naturally */
28420
- .shv-row { flex-wrap:wrap; }
28421
- .shv-title { max-width:100%; white-space:normal; display:-webkit-box;
28422
- -webkit-line-clamp:2; -webkit-box-orient:vertical; text-align:left; }
28423
- .shv-meta { white-space:normal; overflow:visible; text-overflow:clip; }
28424
-
28425
- /* actions: full row of evenly stretched touch targets */
28426
- .shv-actions { width:100%; }
28427
- .shv-actions .btn { flex:1; justify-content:center; padding:7px 10px; }
28776
+ .shv-toolbar { flex-wrap:wrap; }
28777
+ .shv-tools-right { display:contents; }
28778
+ .shv-seg { display:flex; width:100%; order:-1; }
28779
+ .shv-seg-btn { flex:1; justify-content:center; padding:6px 4px; }
28780
+ .shv-src { flex:1; justify-content:center; }
28781
+ .shv-search { flex:1; min-width:0; max-width:none; }
28782
+
28783
+ /* rows: ico + main on top; status, then the action row, each full width */
28784
+ .shv-row { grid-template-columns:38px minmax(0,1fr); gap:8px 13px; align-items:start; }
28785
+ .shv-title { white-space:normal; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; }
28786
+ .shv-sub { white-space:normal; flex-wrap:wrap; row-gap:2px; }
28787
+ .shv-link { max-width:100%; }
28788
+ .shv-status { grid-column:1 / -1; min-width:0; }
28789
+ .shv-actions { grid-column:1 / -1; }
28790
+ .shv-actions .btn { flex:1; padding:8px 10px; }
28791
+ .shv-actions .iconbtn { width:40px; height:36px; flex:none; }
28792
+ .shv-group-head { flex-wrap:wrap; }
28793
+ .shv-group-head .btn { margin-left:0; width:100%; justify-content:center; padding:7px 10px; }
28428
28794
  }
28429
28795
 
28430
28796
  /* in-context "\u5DF2\u5206\u4EAB" badge on docs / tables / site cards */
@@ -29109,6 +29475,7 @@ var styles_default = ` :root {
29109
29475
  html:has(body.mobile), body.mobile { background:var(--sidebar); }
29110
29476
  html:has(body.mobile-content), body.mobile-content { background:var(--bg); }
29111
29477
  .sb-head .iconbtn { display:none; } /* no collapse on mobile */
29478
+ .sb-head .sync-ind { display:inline-flex; height:32px; } /* .brand's flex:1 already pushes it right */
29112
29479
  .sb-resizer { display:none; }
29113
29480
  .topbar .hamburger { display:grid; } /* serves as the back button */
29114
29481
  /* site config topbar: icon-only publish button so the crumb keeps its last segment */
@@ -29566,6 +29933,12 @@ var styles_default = ` :root {
29566
29933
  .blob-dot.keep { background:var(--line-strong); }
29567
29934
  .blob-dot.pin { background:var(--muted); opacity:.6; }
29568
29935
  .blob-total { margin:13px 0 14px; font-size:var(--fs-xs); color:var(--muted); }
29936
+ /* headline number over the legend (\u6570\u636E\u4E0E\u5907\u4EFD \xB7 \u9644\u4EF6) */
29937
+ .blob-stat { font-size:20px; font-weight:700; letter-spacing:-.02em; font-variant-numeric:tabular-nums; margin-bottom:10px; }
29938
+ .blob-stat small { font-size:var(--fs-sm); font-weight:500; color:var(--muted); margin-left:8px; letter-spacing:0; }
29939
+ /* a status sentence in the action row (never a disabled button) */
29940
+ .blob-actions .blob-status { display:inline-flex; align-items:center; gap:6px; font-size:var(--fs-sm); color:var(--muted); }
29941
+ .blob-actions .blob-status .ico { width:14px; height:14px; color:var(--success); }
29569
29942
 
29570
29943
  .blob-ring { position:relative; width:152px; height:152px; flex:none; }
29571
29944
  .blob-ring-svg { width:100%; height:100%; display:block; }
@@ -29600,7 +29973,7 @@ var styles_default = ` :root {
29600
29973
  }
29601
29974
 
29602
29975
  @media (prefers-reduced-motion: reduce) {
29603
- .switch, .switch-knob, .ico.spin, .blob-seg, .blob-ring-track { transition:none; animation:none; }
29976
+ .switch, .switch-knob, .ico.spin, .sync-ring, .blob-seg, .blob-ring-track { transition:none; animation:none; }
29604
29977
  }
29605
29978
 
29606
29979
  /* Blob manager popup (Settings storage \u2192 Blob \u7BA1\u7406): a sortable/filterable list
@@ -31296,8 +31669,11 @@ var FieldHistoryEntrySchema = object({
31296
31669
  });
31297
31670
  var NodeInfoSchema = object({
31298
31671
  node_id: string2(),
31299
- label: string2().nullable().describe("Peer label from pairing; null when unnamed"),
31300
- self: boolean2().describe("True for the node serving this request")
31672
+ label: string2().nullable().describe("Synced roster name \u2192 pairing label \u2192 null when unnamed"),
31673
+ self: boolean2().describe("True for the node serving this request"),
31674
+ platform: _enum(["macos", "windows", "linux", "ios", "android", "web"]).nullable(),
31675
+ form: _enum(["laptop", "desktop", "phone", "server", "browser"]).nullable(),
31676
+ app: _enum(["cli", "server", "desktop", "web"]).nullable()
31301
31677
  });
31302
31678
  var CacheStatsSchema = object({
31303
31679
  totalBytes: number2(),
@@ -31807,13 +32183,17 @@ var webuiRoutes = [
31807
32183
  {
31808
32184
  method: "PATCH",
31809
32185
  path: "/api/node",
31810
- summary: "Set this device's display label (empty/null clears it)",
31811
- request: object({ label: string2().nullable().optional() }),
32186
+ summary: "Rename a device (default: this one). Writes the synced roster; empty/null clears",
32187
+ request: object({
32188
+ label: string2().nullable().optional(),
32189
+ node_id: string2().optional().describe("Target node; omitted = this device")
32190
+ }),
31812
32191
  response: NodeInfoSchema,
31813
32192
  handler: handle3(async (req, { db }) => {
31814
32193
  const body = await req.json();
31815
- setNodeLabel(db, body.label ?? null);
31816
- return { node_id: getNodeId(db), label: getNodeLabel(db), self: true };
32194
+ const id = body.node_id ?? getNodeId(db);
32195
+ setNodeLabel(db, body.label ?? null, id);
32196
+ return displayNodes(db).find((n) => n.node_id === id);
31817
32197
  })
31818
32198
  },
31819
32199
  {
@@ -32535,4 +32915,4 @@ if (scopedConfigHelp) {
32535
32915
  runMain(main, { showUsage: showUsage2 });
32536
32916
  }
32537
32917
 
32538
- //# debugId=BFA341FBF37A009064756E2164756E21
32918
+ //# debugId=A6FF3D00837917E364756E2164756E21