@indigoai-us/hq-cloud 6.18.95 → 6.18.96
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/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +3 -0
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +12 -3
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +8 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +8 -2
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/cli/conflict.d.ts +12 -1
- package/dist/cli/conflict.d.ts.map +1 -1
- package/dist/cli/conflict.js +6 -2
- package/dist/cli/conflict.js.map +1 -1
- package/dist/cli/rescue.d.ts +8 -0
- package/dist/cli/rescue.d.ts.map +1 -1
- package/dist/cli/rescue.js +43 -2
- package/dist/cli/rescue.js.map +1 -1
- package/dist/cli/share.d.ts +18 -0
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +46 -5
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/sync.d.ts +37 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +156 -14
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/tombstones.d.ts +8 -0
- package/dist/cli/tombstones.d.ts.map +1 -1
- package/dist/cli/tombstones.js +9 -0
- package/dist/cli/tombstones.js.map +1 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +61 -79
- package/dist/context.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/journal.d.ts +57 -0
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +242 -0
- package/dist/journal.js.map +1 -1
- package/dist/lib/net-errors.d.ts +14 -0
- package/dist/lib/net-errors.d.ts.map +1 -1
- package/dist/lib/net-errors.js +49 -0
- package/dist/lib/net-errors.js.map +1 -1
- package/dist/lib/server-owned-skips.d.ts +64 -0
- package/dist/lib/server-owned-skips.d.ts.map +1 -0
- package/dist/lib/server-owned-skips.js +182 -0
- package/dist/lib/server-owned-skips.js.map +1 -0
- package/dist/sync/state-store.d.ts +8 -1
- package/dist/sync/state-store.d.ts.map +1 -1
- package/dist/sync/state-store.js +96 -9
- package/dist/sync/state-store.js.map +1 -1
- package/package.json +1 -1
package/dist/journal.js
CHANGED
|
@@ -605,6 +605,242 @@ export function migratePersonalVaultJournal() {
|
|
|
605
605
|
const legacy = readJournal("personal");
|
|
606
606
|
writeJournal(PERSONAL_VAULT_JOURNAL_SLUG, legacy);
|
|
607
607
|
}
|
|
608
|
+
const PERSONAL_VAULT_OWNER_FORMAT = "hq-personal-vault-owner@2";
|
|
609
|
+
const PERSONAL_VAULT_STASH_FORMAT = "hq-personal-vault-stash@2";
|
|
610
|
+
/**
|
|
611
|
+
* Bind the personal-vault journal to the signed-in identity.
|
|
612
|
+
*
|
|
613
|
+
* feedback_447d143f / feedback_d1296824: the personal-vault journal was keyed
|
|
614
|
+
* only by `PERSONAL_VAULT_JOURNAL_SLUG`. After an identity switch on the same
|
|
615
|
+
* machine, person B's first personal sync read person A's rows, saw every one
|
|
616
|
+
* of A's files missing from B's bucket, treated them as remote deletes, and
|
|
617
|
+
* deleted them locally.
|
|
618
|
+
*
|
|
619
|
+
* Contract (hq-cloud#1028 review):
|
|
620
|
+
* - An owner record holds the person uid plus bucket, region and endpoint.
|
|
621
|
+
* Any mismatch with the caller's identity means the slug journal is not
|
|
622
|
+
* this caller's: its rows are stashed for the recorded owner and the caller
|
|
623
|
+
* gets its own stash back, or an empty journal. An empty journal tracks
|
|
624
|
+
* nothing, so it can never drive a local delete.
|
|
625
|
+
* - Fail closed: a journal with rows and no owner record is adopted only when
|
|
626
|
+
* its pull history names the caller and nobody else. Empty or mixed pull
|
|
627
|
+
* history, an unreadable owner record, or an owner record from an older
|
|
628
|
+
* format are "unproven": the rows are set aside in a recovery stash, a
|
|
629
|
+
* durable `recovery.json` is written, and the caller starts empty. A new
|
|
630
|
+
* install (no journal, or no rows) adopts silently.
|
|
631
|
+
* - Crash safety: the outgoing owner's stash is written first while the owner
|
|
632
|
+
* record still proves whose rows are in the slug journal, then the owner
|
|
633
|
+
* record is marked `pending`, then the slug journal is replaced, then the
|
|
634
|
+
* owner record names the new owner. A crash at any point leaves either the
|
|
635
|
+
* old proven state or a `pending` record, and a `pending` record is never
|
|
636
|
+
* used to stash the slug journal's rows under anyone.
|
|
637
|
+
*
|
|
638
|
+
* Stashes live outside the journal namespace (plain JSON under the state dir)
|
|
639
|
+
* so journal enumeration never mistakes them for a company shard.
|
|
640
|
+
*/
|
|
641
|
+
export function bindPersonalVaultJournalToIdentity(identity) {
|
|
642
|
+
const caller = typeof identity === "string" ? { uid: identity } : identity;
|
|
643
|
+
if (!caller.uid)
|
|
644
|
+
return {};
|
|
645
|
+
const ownerPath = personalVaultOwnerPath();
|
|
646
|
+
const read = readPersonalVaultOwner(ownerPath);
|
|
647
|
+
if (read.kind === "ok" && read.record.pending !== true && read.record.owner) {
|
|
648
|
+
const owner = read.record.owner;
|
|
649
|
+
if (identityMatches(owner, caller)) {
|
|
650
|
+
// Fill in vault coordinates the record was missing (uid-only binds).
|
|
651
|
+
const merged = mergeIdentity(owner, caller);
|
|
652
|
+
if (!sameIdentity(merged, owner))
|
|
653
|
+
writePersonalVaultOwner(ownerPath, { format: PERSONAL_VAULT_OWNER_FORMAT, owner: merged });
|
|
654
|
+
return {};
|
|
655
|
+
}
|
|
656
|
+
const current = readJournal(PERSONAL_VAULT_JOURNAL_SLUG);
|
|
657
|
+
const previousStash = personalVaultStashPath(owner.uid);
|
|
658
|
+
// Never let an empty journal overwrite the previous owner's real stash.
|
|
659
|
+
if (Object.keys(current.files).length > 0 || !fs.existsSync(previousStash)) {
|
|
660
|
+
writePersonalVaultStash(previousStash, owner, current);
|
|
661
|
+
}
|
|
662
|
+
installPersonalVaultJournal(ownerPath, caller);
|
|
663
|
+
return {};
|
|
664
|
+
}
|
|
665
|
+
if (read.kind === "ok" && read.record.pending === true) {
|
|
666
|
+
// Interrupted switch: the slug journal's rows are unproven. Every stash
|
|
667
|
+
// that mattered was written before `pending`; install the caller's own.
|
|
668
|
+
installPersonalVaultJournal(ownerPath, caller);
|
|
669
|
+
return {};
|
|
670
|
+
}
|
|
671
|
+
const current = readJournal(PERSONAL_VAULT_JOURNAL_SLUG);
|
|
672
|
+
const currentRows = Object.keys(current.files).length;
|
|
673
|
+
if (read.kind === "none") {
|
|
674
|
+
const pullOwners = new Set((current.pulls ?? [])
|
|
675
|
+
.map((pull) => pull.companyUid)
|
|
676
|
+
.filter((uid) => typeof uid === "string" && uid.length > 0));
|
|
677
|
+
if (currentRows === 0 || (pullOwners.size === 1 && pullOwners.has(caller.uid))) {
|
|
678
|
+
writePersonalVaultOwner(ownerPath, { format: PERSONAL_VAULT_OWNER_FORMAT, owner: caller });
|
|
679
|
+
return {};
|
|
680
|
+
}
|
|
681
|
+
if (pullOwners.size === 1) {
|
|
682
|
+
// Proven by pull history to belong to exactly one other person; their
|
|
683
|
+
// bucket is unknown, so the stash records the uid only.
|
|
684
|
+
const [otherUid] = [...pullOwners];
|
|
685
|
+
writePersonalVaultStash(personalVaultStashPath(otherUid), { uid: otherUid }, current);
|
|
686
|
+
installPersonalVaultJournal(ownerPath, caller);
|
|
687
|
+
return {};
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
// Unproven ownership (no pull history, mixed history, unreadable or
|
|
691
|
+
// old-format owner record): set the rows aside, never delete on them.
|
|
692
|
+
const reason = read.kind === "unreadable" ? "unreadable-owner-record" : "unproven-legacy-journal";
|
|
693
|
+
if (currentRows > 0) {
|
|
694
|
+
const stashName = `unproven-${Date.now()}-${process.pid}.json`;
|
|
695
|
+
writePersonalVaultStash(path.join(personalVaultIdentityDir(), stashName), undefined, current);
|
|
696
|
+
writeJsonAtomic(path.join(personalVaultIdentityDir(), "recovery.json"), {
|
|
697
|
+
format: "hq-personal-vault-recovery@1",
|
|
698
|
+
reason,
|
|
699
|
+
stash: stashName,
|
|
700
|
+
at: new Date().toISOString(),
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
installPersonalVaultJournal(ownerPath, caller);
|
|
704
|
+
return currentRows > 0 || read.kind === "unreadable" ? { fenced: reason } : {};
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* Explicit adoption: the caller asserts that the personal-vault journal now on
|
|
708
|
+
* disk belongs to `identity` (for example a recovery command run by the
|
|
709
|
+
* signed-in person, or a test seeding a journal). This is the only way an
|
|
710
|
+
* unmarked journal with rows is kept without proof from its pull history.
|
|
711
|
+
* It never deletes anything and clears a pending recovery record.
|
|
712
|
+
*/
|
|
713
|
+
export function adoptPersonalVaultJournalForIdentity(identity) {
|
|
714
|
+
if (!identity.uid)
|
|
715
|
+
throw new Error("journal: adoption needs a person uid");
|
|
716
|
+
writePersonalVaultOwner(personalVaultOwnerPath(), { format: PERSONAL_VAULT_OWNER_FORMAT, owner: identity });
|
|
717
|
+
fs.rmSync(path.join(personalVaultIdentityDir(), "recovery.json"), { force: true });
|
|
718
|
+
}
|
|
719
|
+
function installPersonalVaultJournal(ownerPath, caller) {
|
|
720
|
+
writePersonalVaultOwner(ownerPath, { format: PERSONAL_VAULT_OWNER_FORMAT, pending: true });
|
|
721
|
+
let next = emptyJournal();
|
|
722
|
+
const stashPath = personalVaultStashPath(caller.uid);
|
|
723
|
+
const stash = readPersonalVaultStash(stashPath);
|
|
724
|
+
// A stash recorded for a different bucket, region or endpoint is not
|
|
725
|
+
// restored; it stays on disk and the caller starts empty.
|
|
726
|
+
if (stash && (!stash.owner || identityMatches(stash.owner, caller)))
|
|
727
|
+
next = stash.journal;
|
|
728
|
+
writeJournal(PERSONAL_VAULT_JOURNAL_SLUG, next);
|
|
729
|
+
writePersonalVaultOwner(ownerPath, {
|
|
730
|
+
format: PERSONAL_VAULT_OWNER_FORMAT,
|
|
731
|
+
owner: stash?.owner ? mergeIdentity(stash.owner, caller) : caller,
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
function identityMatches(recorded, caller) {
|
|
735
|
+
if (recorded.uid !== caller.uid)
|
|
736
|
+
return false;
|
|
737
|
+
for (const field of ["bucket", "region", "endpoint"]) {
|
|
738
|
+
if (recorded[field] !== undefined && caller[field] !== undefined && recorded[field] !== caller[field])
|
|
739
|
+
return false;
|
|
740
|
+
}
|
|
741
|
+
return true;
|
|
742
|
+
}
|
|
743
|
+
function mergeIdentity(recorded, caller) {
|
|
744
|
+
const merged = { uid: recorded.uid };
|
|
745
|
+
for (const field of ["bucket", "region", "endpoint"]) {
|
|
746
|
+
const value = recorded[field] ?? caller[field];
|
|
747
|
+
if (value !== undefined)
|
|
748
|
+
merged[field] = value;
|
|
749
|
+
}
|
|
750
|
+
return merged;
|
|
751
|
+
}
|
|
752
|
+
function sameIdentity(a, b) {
|
|
753
|
+
return a.uid === b.uid && a.bucket === b.bucket && a.region === b.region && a.endpoint === b.endpoint;
|
|
754
|
+
}
|
|
755
|
+
function personalVaultIdentityDir() {
|
|
756
|
+
return path.join(getStateDir(), "personal-vault-identities");
|
|
757
|
+
}
|
|
758
|
+
function personalVaultOwnerPath() {
|
|
759
|
+
return path.join(personalVaultIdentityDir(), "owner.json");
|
|
760
|
+
}
|
|
761
|
+
function personalVaultStashPath(identityUid) {
|
|
762
|
+
const id = crypto.createHash("sha256").update(identityUid).digest("hex").slice(0, 32);
|
|
763
|
+
return path.join(personalVaultIdentityDir(), `journal-${id}.json`);
|
|
764
|
+
}
|
|
765
|
+
function readPersonalVaultOwner(ownerPath) {
|
|
766
|
+
let raw;
|
|
767
|
+
try {
|
|
768
|
+
raw = fs.readFileSync(ownerPath, "utf8");
|
|
769
|
+
}
|
|
770
|
+
catch (error) {
|
|
771
|
+
if (error.code === "ENOENT")
|
|
772
|
+
return { kind: "none" };
|
|
773
|
+
return { kind: "unreadable" };
|
|
774
|
+
}
|
|
775
|
+
try {
|
|
776
|
+
const parsed = JSON.parse(raw);
|
|
777
|
+
if (!parsed || parsed.format !== PERSONAL_VAULT_OWNER_FORMAT)
|
|
778
|
+
return { kind: "unreadable" };
|
|
779
|
+
if (parsed.pending === true)
|
|
780
|
+
return { kind: "ok", record: { format: PERSONAL_VAULT_OWNER_FORMAT, pending: true } };
|
|
781
|
+
const owner = parsed.owner;
|
|
782
|
+
if (!owner || typeof owner.uid !== "string" || !owner.uid)
|
|
783
|
+
return { kind: "unreadable" };
|
|
784
|
+
return { kind: "ok", record: { format: PERSONAL_VAULT_OWNER_FORMAT, owner } };
|
|
785
|
+
}
|
|
786
|
+
catch {
|
|
787
|
+
return { kind: "unreadable" };
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
function writePersonalVaultOwner(ownerPath, record) {
|
|
791
|
+
writeJsonAtomic(ownerPath, record);
|
|
792
|
+
}
|
|
793
|
+
function writePersonalVaultStash(stashPath, owner, journal) {
|
|
794
|
+
writeJsonAtomic(stashPath, { format: PERSONAL_VAULT_STASH_FORMAT, owner, journal: plainJournalCopy(journal) });
|
|
795
|
+
}
|
|
796
|
+
function readPersonalVaultStash(stashPath) {
|
|
797
|
+
let raw;
|
|
798
|
+
try {
|
|
799
|
+
raw = fs.readFileSync(stashPath, "utf8");
|
|
800
|
+
}
|
|
801
|
+
catch {
|
|
802
|
+
return undefined;
|
|
803
|
+
}
|
|
804
|
+
try {
|
|
805
|
+
const parsed = JSON.parse(raw);
|
|
806
|
+
// Stashes from an older format carry no owner coordinates; they are not
|
|
807
|
+
// trusted and the caller starts empty (no deletes).
|
|
808
|
+
if (parsed?.format !== PERSONAL_VAULT_STASH_FORMAT)
|
|
809
|
+
return undefined;
|
|
810
|
+
const journal = parsed.journal;
|
|
811
|
+
if (!journal || typeof journal !== "object" || !journal.files || typeof journal.files !== "object")
|
|
812
|
+
return undefined;
|
|
813
|
+
migrateToV2(journal);
|
|
814
|
+
return { owner: parsed.owner, journal };
|
|
815
|
+
}
|
|
816
|
+
catch {
|
|
817
|
+
return undefined;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
function writeJsonAtomic(filePath, value) {
|
|
821
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
822
|
+
const tmp = `${filePath}.${process.pid}.tmp`;
|
|
823
|
+
const fd = fs.openSync(tmp, "w");
|
|
824
|
+
try {
|
|
825
|
+
fs.writeSync(fd, JSON.stringify(value));
|
|
826
|
+
fs.fsyncSync(fd);
|
|
827
|
+
}
|
|
828
|
+
finally {
|
|
829
|
+
fs.closeSync(fd);
|
|
830
|
+
}
|
|
831
|
+
fs.renameSync(tmp, filePath);
|
|
832
|
+
}
|
|
833
|
+
function plainJournalCopy(journal) {
|
|
834
|
+
const files = {};
|
|
835
|
+
for (const [key, entry] of Object.entries(journal.files))
|
|
836
|
+
files[key] = { ...entry };
|
|
837
|
+
return {
|
|
838
|
+
version: journal.version,
|
|
839
|
+
lastSync: journal.lastSync,
|
|
840
|
+
files,
|
|
841
|
+
pulls: [...(journal.pulls ?? [])],
|
|
842
|
+
};
|
|
843
|
+
}
|
|
608
844
|
/**
|
|
609
845
|
* Read a per-company journal from disk.
|
|
610
846
|
*
|
|
@@ -619,6 +855,8 @@ export function migratePersonalVaultJournal() {
|
|
|
619
855
|
* installs never pass through v1 on disk.
|
|
620
856
|
*/
|
|
621
857
|
export function readJournal(slug, companyUid) {
|
|
858
|
+
if (slug === PERSONAL_VAULT_JOURNAL_SLUG && companyUid)
|
|
859
|
+
bindPersonalVaultJournalToIdentity(companyUid);
|
|
622
860
|
const areaMigration = openAreaMigrationIfAuthoritative(slug);
|
|
623
861
|
if (areaMigration) {
|
|
624
862
|
// Area rows stay in their shared packed store. This record-compatible
|
|
@@ -647,6 +885,10 @@ export function readJournal(slug, companyUid) {
|
|
|
647
885
|
* @internal Full-pull boundary; not a public journal record API.
|
|
648
886
|
*/
|
|
649
887
|
export function readJournalForPass(slug, companyUid) {
|
|
888
|
+
// feedback_447d143f: a pass over the personal-vault journal must only ever
|
|
889
|
+
// see the rows of the identity it runs as.
|
|
890
|
+
if (slug === PERSONAL_VAULT_JOURNAL_SLUG && companyUid)
|
|
891
|
+
bindPersonalVaultJournalToIdentity(companyUid);
|
|
650
892
|
const areaMigration = openAreaMigrationIfAuthoritative(slug);
|
|
651
893
|
if (!areaMigration)
|
|
652
894
|
return readJournal(slug, companyUid);
|