create-evda 0.1.8 → 0.1.10
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/CHANGELOG.md +109 -1
- package/README.md +20 -7
- package/bin/evda.mjs +277 -7
- package/create-evda.mjs +17 -4
- package/dist/pairing.mjs +2932 -1684
- package/hooks/evda-day-writer-guard.mjs +7 -0
- package/lib/acknowledge.mjs +187 -0
- package/lib/adopt.mjs +488 -0
- package/lib/agents.mjs +1 -1
- package/lib/attested-host.mjs +269 -0
- package/lib/browse.mjs +354 -0
- package/lib/circles.mjs +5 -1
- package/lib/clients.mjs +29 -2
- package/lib/connect.mjs +9 -2
- package/lib/connecting-apps.mjs +56 -0
- package/lib/connector-tools.mjs +85 -0
- package/lib/corrections.mjs +34 -0
- package/lib/credentials-migrate.mjs +120 -0
- package/lib/day-file.mjs +173 -15
- package/lib/day-writer.mjs +110 -0
- package/lib/doctor.mjs +45 -3
- package/lib/every-chat.mjs +128 -0
- package/lib/filechanges.mjs +203 -68
- package/lib/gather-apply.mjs +176 -26
- package/lib/gather.mjs +119 -17
- package/lib/html-notes.mjs +124 -0
- package/lib/keep-hook.mjs +61 -8
- package/lib/keychain.mjs +181 -0
- package/lib/manifest.mjs +5 -1
- package/lib/mcp-auth.mjs +41 -1
- package/lib/mcp-http.mjs +42 -4
- package/lib/mcp.mjs +1198 -76
- package/lib/measurement-log.mjs +249 -0
- package/lib/messages.mjs +5 -2
- package/lib/migrate-people.mjs +54 -20
- package/lib/pairing.mjs +243 -5
- package/lib/parity.mjs +47 -13
- package/lib/person-folders.mjs +89 -0
- package/lib/plugin-hook.mjs +109 -0
- package/lib/plugin.mjs +208 -0
- package/lib/presets.mjs +26 -5
- package/lib/publish.mjs +55 -10
- package/lib/qr.mjs +317 -0
- package/lib/record.mjs +74 -0
- package/lib/routines.mjs +54 -4
- package/lib/sigstore-trust.json +20 -0
- package/lib/skills.mjs +1 -1
- package/lib/sync-lock.mjs +143 -0
- package/lib/sync-rules.mjs +154 -1
- package/lib/takeout.mjs +169 -0
- package/lib/tdx-attestation.mjs +412 -0
- package/lib/tdx-trust.json +10 -0
- package/lib/tools.mjs +1040 -31
- package/lib/touches.mjs +427 -0
- package/lib/update-check.mjs +30 -0
- package/lib/watch.mjs +160 -0
- package/lib/yaml.mjs +8 -4
- package/package.json +2 -1
- package/skills/evda/SKILL.md +14 -0
- package/templates/AGENTS.md +81 -10
- package/templates/evda-digest.md +5 -1
package/lib/filechanges.mjs
CHANGED
|
@@ -36,6 +36,10 @@ import { admitDevice, devicesOf, readDevices, recordOf, retireDevice } from "./d
|
|
|
36
36
|
import { resolveRole } from "./roles.mjs";
|
|
37
37
|
import { boundaries, insideAny } from "./records.mjs";
|
|
38
38
|
import { EvdaError } from "./errors.mjs";
|
|
39
|
+
import { carryList, datalessAmong, excluder, fileClass, walk as walkRules } from "./sync-rules.mjs";
|
|
40
|
+
// The day role, re-exported so pairing reads it through the four person libs the phone bundles.
|
|
41
|
+
export { dayRole } from "./day-file.mjs";
|
|
42
|
+
import { dayRole, localDate } from "./day-file.mjs";
|
|
39
43
|
|
|
40
44
|
export const FILE_SET = "file.set";
|
|
41
45
|
export const FILE_REMOVE = "file.remove";
|
|
@@ -365,6 +369,49 @@ export function grantView(evda) {
|
|
|
365
369
|
};
|
|
366
370
|
}
|
|
367
371
|
|
|
372
|
+
// The id this record's computer speaks as (HQ-2005). A record create-evda made grants its own id
|
|
373
|
+
// owner, and the computer is the record id. A record whose parts were declared by hand may grant
|
|
374
|
+
// owner to a named device instead (his: `laptop.atlas` owner of every part, `hestia.atlas` an
|
|
375
|
+
// editor, the record id nothing); then the computer is the one device that owns every private part
|
|
376
|
+
// this record homes. None, or more than one, is refused in one line naming what was found, so a
|
|
377
|
+
// caller can refuse before it writes anything. A record with no private parts is its own id.
|
|
378
|
+
//
|
|
379
|
+
// A computer that pins a device as a PEER in its own state is not that device (HQ-2040): Hestia
|
|
380
|
+
// holds the laptop as a peer, so it never speaks as laptop.atlas. `notMe` lists those pins. With
|
|
381
|
+
// them set aside, the one owner left is this computer even when it owns only some parts (his
|
|
382
|
+
// Hestia owns core, where a phone pairs); none left is the same refusal, so a computer with no
|
|
383
|
+
// grant of its own writes nothing rather than minting a second identity for another device's id.
|
|
384
|
+
export const NOT_AN_OWNER_LEAD = "This record has private parts";
|
|
385
|
+
export async function ownerDevice(evda, { notMe = [] } = {}) {
|
|
386
|
+
const own = typeof evda.manifest?.id === "string" ? evda.manifest.id : "";
|
|
387
|
+
const view = grantView(evda);
|
|
388
|
+
const parts = view.index.filter((c) => !c.public && !c.mount);
|
|
389
|
+
if (!own || parts.length === 0) return own;
|
|
390
|
+
const owns = async (id) => {
|
|
391
|
+
const out = [];
|
|
392
|
+
for (const c of parts) if (await view.mayShareKey(id, c.id)) out.push(c.name);
|
|
393
|
+
return out;
|
|
394
|
+
};
|
|
395
|
+
if ((await owns(own)).length > 0) return own;
|
|
396
|
+
const grants = (readGrants(evda) ?? []).filter((g) => g.role === "owner" && g.principalId && g.problems.every((p) => p.level !== "fail"));
|
|
397
|
+
const devices = readDevices(evda) ?? { lists: new Map(), problems: [] };
|
|
398
|
+
const names = new Map();
|
|
399
|
+
for (const g of grants) for (const d of devicesOf(devices, g.principalId)) if (!names.has(d)) names.set(d, g.holder?.name ?? d);
|
|
400
|
+
const found = [];
|
|
401
|
+
const peers = new Set(notMe);
|
|
402
|
+
for (const id of [...names.keys()].sort()) if (id !== own && !peers.has(id)) found.push({ id, name: names.get(id), parts: await owns(id) });
|
|
403
|
+
const full = found.filter((f) => f.parts.length === parts.length);
|
|
404
|
+
if (full.length === 1) return full[0].id;
|
|
405
|
+
const some = found.filter((f) => f.parts.length > 0);
|
|
406
|
+
if (peers.size > 0 && full.length === 0 && some.length === 1) return some[0].id;
|
|
407
|
+
const list = parts.map((c) => c.name).join(", ");
|
|
408
|
+
if (full.length > 1) {
|
|
409
|
+
throw new EvdaError(`${NOT_AN_OWNER_LEAD} (${list}), and ${full.length} devices own every one of them (${full.map((f) => f.name).join(", ")}), so this computer cannot tell which of them it is. Nothing was written.`, { code: "NOT_AN_OWNER", exit: 2 });
|
|
410
|
+
}
|
|
411
|
+
const who = found.filter((f) => f.parts.length > 0).map((f) => `${f.name} owns ${f.parts.join(", ")}`);
|
|
412
|
+
throw new EvdaError(`${NOT_AN_OWNER_LEAD} (${list}), but this computer is not an owner of any of them, and no one device owns them all${who.length ? `: ${who.join("; ")}` : ": no grant names an owner"}. Nothing was written.`, { code: "NOT_AN_OWNER", exit: 2 });
|
|
413
|
+
}
|
|
414
|
+
|
|
368
415
|
// Paths under an append-only role, where two devices' concurrent appends union instead of
|
|
369
416
|
// conflicting: the daily notes and the ledger (Spec 08, section 3).
|
|
370
417
|
export function unionPaths(evda) {
|
|
@@ -415,10 +462,16 @@ export function circleCrypto(crypto) {
|
|
|
415
462
|
// has no mapping for the home record, still unions where the home does.
|
|
416
463
|
// onMinted optional ({ change, circle, payload }) -> after the Change is appended, once per
|
|
417
464
|
// circle the file reaches
|
|
418
|
-
// blobs optional { seal(bytes) -> { hash, contentKey }, link(changeId, hash) }: a file
|
|
419
|
-
// BLOB_THRESHOLD bytes
|
|
420
|
-
// bytes are sealed once under a fresh content key and kept
|
|
421
|
-
// carries { hash, contentKey } in place of the text. Absent,
|
|
465
|
+
// blobs optional { seal(bytes) -> { hash, contentKey }, link(changeId, hash) }: a text file
|
|
466
|
+
// above BLOB_THRESHOLD bytes or not UTF-8, and a file the manifest carries, rides the
|
|
467
|
+
// blob lane (Spec 08 §1). Its bytes are sealed once under a fresh content key and kept
|
|
468
|
+
// by the device; the payload carries { hash, contentKey } in place of the text. Absent,
|
|
469
|
+
// such a file is minted as text.
|
|
470
|
+
// carry optional, the manifest's `sync.carry` list (sync-rules `carryList`). Every file that
|
|
471
|
+
// is neither text nor carried is a REFERENCE (HQ-2017): its payload is { path, sha256,
|
|
472
|
+
// baseSha256, ref: { name, size } }, with no text and no blob, and nothing is sealed.
|
|
473
|
+
// The scan passes `how.reference` for such a file and no bytes; a file that reaches
|
|
474
|
+
// mint with bytes is classified here again, so no caller can seal media by mistake.
|
|
422
475
|
//
|
|
423
476
|
// Throwing is how a callback says "not recorded": the scan leaves the path for the next run.
|
|
424
477
|
export const BLOB_THRESHOLD = 256 * 1024;
|
|
@@ -431,8 +484,17 @@ const utf8OrNull = (bytes) => {
|
|
|
431
484
|
}
|
|
432
485
|
};
|
|
433
486
|
|
|
434
|
-
|
|
487
|
+
// The reference payload for a file whose bytes do not travel (HQ-2017).
|
|
488
|
+
const refPayload = (address, sha256, baseSha256, size) => ({
|
|
489
|
+
path: address,
|
|
490
|
+
sha256,
|
|
491
|
+
baseSha256,
|
|
492
|
+
ref: { name: address.slice(address.lastIndexOf("/") + 1), size },
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
export function fileMinter({ log, index, seal, notifyFor, previousSha, changesetId, originUser = null, onMinted, blobs, unionPath = null, readText = null, carry = [] }) {
|
|
435
496
|
const at = addressing(index);
|
|
497
|
+
const classOf = fileClass(carry);
|
|
436
498
|
const circleOf = (path) => {
|
|
437
499
|
const c = circleForPath(index, path);
|
|
438
500
|
if (!c) throw new EvdaError(`${path} is in no circle, so it has no one to sync to.`, { code: "NO_CIRCLE" });
|
|
@@ -461,10 +523,11 @@ export function fileMinter({ log, index, seal, notifyFor, previousSha, changeset
|
|
|
461
523
|
return first;
|
|
462
524
|
};
|
|
463
525
|
return {
|
|
464
|
-
async mint(item, bytes) {
|
|
526
|
+
async mint(item, bytes, how = {}) {
|
|
465
527
|
const home = circleOf(item.path);
|
|
466
528
|
const address = at.address(home, item.path);
|
|
467
|
-
const
|
|
529
|
+
const kind = how.reference ? "reference" : classOf(item.path);
|
|
530
|
+
const text = kind === "reference" ? null : utf8OrNull(bytes);
|
|
468
531
|
const circles = home.mount ? [home] : circlesFor(index, item.path, text, readText);
|
|
469
532
|
const union = unionPath?.(item.path) === true ? { union: true } : {};
|
|
470
533
|
// A circle the file reached before and reaches no longer (its mark was taken off, or its home
|
|
@@ -474,7 +537,13 @@ export function fileMinter({ log, index, seal, notifyFor, previousSha, changeset
|
|
|
474
537
|
const withdraw = async () => {
|
|
475
538
|
if (withdrawn.length > 0) await append(FILE_REMOVE, item.path, { path: address, sha256: null, baseSha256: await previousSha(item.path) }, withdrawn);
|
|
476
539
|
};
|
|
477
|
-
if (
|
|
540
|
+
if (kind === "reference") {
|
|
541
|
+
const size = how.reference?.size ?? bytes.length;
|
|
542
|
+
await append(FILE_SET, item.path, refPayload(address, item.sha256, await previousSha(item.path), size), circles);
|
|
543
|
+
await withdraw();
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
if (blobs && (kind === "carried" || text === null || bytes.length > BLOB_THRESHOLD)) {
|
|
478
547
|
const sealed = await blobs.seal(bytes);
|
|
479
548
|
const blob = { hash: sealed.hash, contentKey: Buffer.from(sealed.contentKey).toString("base64") };
|
|
480
549
|
await append(FILE_SET, item.path, { path: address, sha256: item.sha256, baseSha256: await previousSha(item.path), blob }, circles, sealed.hash);
|
|
@@ -528,28 +597,35 @@ export function fileMinter({ log, index, seal, notifyFor, previousSha, changeset
|
|
|
528
597
|
// `ancestors`: every sha this device logged for the address before the current one, from any circle
|
|
529
598
|
// it can open. A receiver holding any of them fast-forwards; a receiver holding a version nobody
|
|
530
599
|
// here has seen keeps it and gets the current text beside it, by the ordinary merge table.
|
|
531
|
-
|
|
600
|
+
//
|
|
601
|
+
// `stream(circleId, { skip })` yields the same Changes one at a time, minting each as it is asked
|
|
602
|
+
// for, today's day file first and then newest first (HQ-2068); `snapshot` is the stream collected.
|
|
603
|
+
//
|
|
604
|
+
// `onFileSet(rel, sha256)`, when given, is awaited once per file.set this mints (HQ-2109): the
|
|
605
|
+
// catch-up reads and hashes the file itself, so it is the one place that can tell the scan index the
|
|
606
|
+
// file is already accounted for, at no extra read. Without it, a device's next ordinary scan finds no
|
|
607
|
+
// row for a file the catch-up just sent, reads it as never-before-seen, and mints a second Change for
|
|
608
|
+
// bytes the peer already has.
|
|
609
|
+
export function catchUp({ evda, view, log, seal, open, changesetId, originUser = null, unionPath = null, blobs = null, exclude = null, onFileSet = null }) {
|
|
610
|
+
// What travels, by the same rule as the scan (sync-rules, HQ-2017): a reference is hashed from a
|
|
611
|
+
// local file and carries no bytes; a dataless file is not opened at all, and is left out of the
|
|
612
|
+
// catch-up (it is neither sent nor withdrawn) until its bytes are on this disk.
|
|
613
|
+
const classOf = fileClass(carryList(evda.manifest));
|
|
532
614
|
const readText = (rel) => {
|
|
533
615
|
const abs = join(evda.dir, rel);
|
|
534
616
|
return existsSync(abs) && statSync(abs).isFile() ? readFileSync(abs, "utf8") : null;
|
|
535
617
|
};
|
|
618
|
+
// The files a circle member holds, by the sync rules' own walk (HQ-2017): the same exclusions the
|
|
619
|
+
// scan lists by (node_modules, .git, .evda, the plan's paths), no symlink, no iCloud placeholder,
|
|
620
|
+
// nothing over the blob lane. The catch-up once walked by its own rule (every dot name skipped,
|
|
621
|
+
// node_modules kept), so what it sent and withdrew disagreed with what the scan minted.
|
|
622
|
+
const skip = exclude ?? excluder();
|
|
536
623
|
const walk = (prefix) => {
|
|
537
|
-
const out = [];
|
|
538
624
|
const start = join(evda.dir, prefix);
|
|
539
|
-
if (!existsSync(start)) return
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
const dir = stack.pop();
|
|
544
|
-
for (const name of readdirSync(join(evda.dir, dir)).sort()) {
|
|
545
|
-
if (name.startsWith(".")) continue;
|
|
546
|
-
const rel = dir === "" ? name : `${dir}/${name}`;
|
|
547
|
-
const st = statSync(join(evda.dir, rel));
|
|
548
|
-
if (st.isDirectory()) stack.push(rel);
|
|
549
|
-
else if (st.isFile()) out.push(rel);
|
|
550
|
-
}
|
|
551
|
-
}
|
|
552
|
-
return out;
|
|
625
|
+
if (!existsSync(start)) return [];
|
|
626
|
+
const from = prefix.replace(/\/+$/, "");
|
|
627
|
+
if (statSync(start).isFile()) return skip(from) ? [] : [from];
|
|
628
|
+
return [...walkRules(evda.dir, { exclude: skip, from })].filter((e) => e.kind === "file").map((e) => e.rel);
|
|
553
629
|
};
|
|
554
630
|
const shaOf = async (bytes) => Buffer.from(await crypto.subtle.digest("SHA-256", bytes)).toString("hex");
|
|
555
631
|
|
|
@@ -568,55 +644,114 @@ export function catchUp({ evda, view, log, seal, open, changesetId, originUser =
|
|
|
568
644
|
return out;
|
|
569
645
|
};
|
|
570
646
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
647
|
+
const dayDir = dayFolderOf(evda.manifest);
|
|
648
|
+
|
|
649
|
+
// One Change per file the circle reaches now, newest first (HQ-2068), then one removal per address
|
|
650
|
+
// it no longer reaches. Each is minted only when the caller asks for the next, so a caller that
|
|
651
|
+
// posts as it goes has the first batch on the relay before the rest is read. `skip` holds
|
|
652
|
+
// addresses already sent by an earlier, interrupted run: neither minted again nor withdrawn.
|
|
653
|
+
async function* stream(circleId, { skip = new Set(), now = new Date() } = {}) {
|
|
654
|
+
const index = view().index;
|
|
655
|
+
const target = circleById(index, circleId);
|
|
656
|
+
if (!target) throw new EvdaError(`no circle ${circleId} in this record.`, { code: "NO_CIRCLE" });
|
|
657
|
+
if (target.mount) throw new EvdaError(`circle "${target.name}" is held from ${target.mount.holder}; only its home catches a device up.`, { code: "NOT_OWNER" });
|
|
658
|
+
const at = addressing(index);
|
|
659
|
+
const listed = new Set();
|
|
660
|
+
const skipped = new Set();
|
|
661
|
+
const files = [];
|
|
662
|
+
for (const { prefix, match } of scanPrefixes(index, (c) => !c.mount && !c.frozen)) {
|
|
663
|
+
const rels = walk(prefix).filter((rel) => match(rel) && !listed.has(rel));
|
|
664
|
+
const dataless = datalessAmong(evda.dir, rels);
|
|
665
|
+
for (const rel of rels) {
|
|
666
|
+
if (listed.has(rel)) continue;
|
|
667
|
+
listed.add(rel);
|
|
668
|
+
if (dataless.has(rel)) { skipped.add(rel); continue; }
|
|
669
|
+
let mtimeMs = 0;
|
|
670
|
+
try { mtimeMs = statSync(join(evda.dir, rel)).mtimeMs; } catch { continue; }
|
|
671
|
+
files.push({ rel, mtimeMs });
|
|
587
672
|
}
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
673
|
+
}
|
|
674
|
+
files.sort(catchUpOrder(dayDir, localDate(now)));
|
|
675
|
+
const mint = async (kind, payload, blobHash = null) => {
|
|
676
|
+
const id = await changesetId();
|
|
677
|
+
const change = await log.append({ changesetId: id, eco: target.id, originUser, kind, target: payload.path, payload: await seal(target, payload), notify: [] });
|
|
678
|
+
if (blobHash !== null) await blobs.link(change.id, blobHash);
|
|
679
|
+
return change;
|
|
680
|
+
};
|
|
681
|
+
const addresses = new Set();
|
|
682
|
+
for (const { rel } of files) {
|
|
683
|
+
const kind = classOf(rel);
|
|
684
|
+
let bytes = null;
|
|
685
|
+
let text = null;
|
|
686
|
+
let size = 0;
|
|
687
|
+
let known = null;
|
|
688
|
+
if (kind === "reference") {
|
|
689
|
+
// Only its home circle: a mark to release is frontmatter, and a reference has no text.
|
|
690
|
+
if (circleForPath(index, rel) !== target) continue;
|
|
691
|
+
const raw = readFileSync(join(evda.dir, rel));
|
|
692
|
+
size = raw.length;
|
|
693
|
+
known = await shaOf(raw);
|
|
694
|
+
} else {
|
|
695
|
+
bytes = readFileSync(join(evda.dir, rel));
|
|
696
|
+
text = utf8OrNull(bytes);
|
|
697
|
+
if (!circlesFor(index, rel, text, readText).includes(target)) continue;
|
|
608
698
|
}
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
699
|
+
const address = at.address(circleForPath(index, rel), rel);
|
|
700
|
+
addresses.add(address);
|
|
701
|
+
if (skip.has(address)) continue;
|
|
702
|
+
const sha256 = known ?? (await shaOf(bytes));
|
|
703
|
+
// Told as soon as the hash is known, whichever branch below mints: a file.set is coming for
|
|
704
|
+
// this path either way, and the scan index should already agree with it by the time it lands.
|
|
705
|
+
if (onFileSet) await onFileSet(rel, sha256);
|
|
706
|
+
const ancestors = await ancestorsOf(address, sha256);
|
|
707
|
+
const base = { path: address, sha256, baseSha256: ancestors.at(-1) ?? null, ancestors };
|
|
708
|
+
if (kind === "reference") {
|
|
709
|
+
yield await mint(FILE_SET, { ...refPayload(address, sha256, base.baseSha256, size), ancestors });
|
|
710
|
+
continue;
|
|
613
711
|
}
|
|
614
|
-
|
|
615
|
-
const
|
|
616
|
-
|
|
617
|
-
|
|
712
|
+
if (blobs && (kind === "carried" || text === null || bytes.length > BLOB_THRESHOLD)) {
|
|
713
|
+
const sealed = await blobs.seal(bytes);
|
|
714
|
+
yield await mint(FILE_SET, { ...base, blob: { hash: sealed.hash, contentKey: Buffer.from(sealed.contentKey).toString("base64") } }, sealed.hash);
|
|
715
|
+
continue;
|
|
618
716
|
}
|
|
717
|
+
yield await mint(FILE_SET, { ...base, text: text ?? new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes), ...(unionPath?.(rel) === true ? { union: true } : {}) });
|
|
718
|
+
}
|
|
719
|
+
// Addresses the circle held before and does not now: removed, or no longer released into it.
|
|
720
|
+
const gone = new Set();
|
|
721
|
+
const skippedAddresses = new Set([...skipped].map((rel) => { try { return at.address(circleForPath(index, rel), rel); } catch { return rel; } }));
|
|
722
|
+
for (const change of await log.forEco(target.id)) {
|
|
723
|
+
if (change.kind === FILE_SET && !addresses.has(change.target) && !skippedAddresses.has(change.target) && !skip.has(change.target)) gone.add(change.target);
|
|
724
|
+
}
|
|
725
|
+
for (const address of [...gone].sort()) {
|
|
726
|
+
const ancestors = await ancestorsOf(address, null);
|
|
727
|
+
if (ancestors.length === 0) continue;
|
|
728
|
+
yield await mint(FILE_REMOVE, { path: address, sha256: null, baseSha256: ancestors.at(-1), ancestors });
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
return {
|
|
733
|
+
stream,
|
|
734
|
+
async snapshot(circleId, o = {}) {
|
|
735
|
+
const minted = [];
|
|
736
|
+
for await (const change of stream(circleId, o)) minted.push(change);
|
|
619
737
|
return minted;
|
|
620
738
|
},
|
|
621
739
|
};
|
|
622
740
|
}
|
|
741
|
+
|
|
742
|
+
// The order a catch-up sends a circle's files in (HQ-2068): today's day file first, then the most
|
|
743
|
+
// recently modified, then by path, so a phone joining a large record shows what was written last
|
|
744
|
+
// within seconds instead of after the alphabet.
|
|
745
|
+
export function catchUpOrder(dayDir, today) {
|
|
746
|
+
const dayPrefix = `${String(dayDir ?? "").replace(/\/+$/, "")}/`;
|
|
747
|
+
const rank = (rel) => (rel.startsWith(dayPrefix) && rel.slice(dayPrefix.length).startsWith(today) ? 0 : 1);
|
|
748
|
+
return (a, b) => rank(a.rel) - rank(b.rel) || b.mtimeMs - a.mtimeMs || (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// The day folder a record's mapping names, else create-evda's default (as pairing.ts reads it).
|
|
752
|
+
function dayFolderOf(manifest) {
|
|
753
|
+
const mapping = manifest?.mapping;
|
|
754
|
+
const role = dayRole(mapping);
|
|
755
|
+
const rel = role ? mapping?.[role] : undefined;
|
|
756
|
+
return typeof rel === "string" && rel.trim() !== "" ? rel.replace(/\/+$/, "") : "record/daily";
|
|
757
|
+
}
|