@indigoai-us/hq-cloud 6.18.52 → 6.18.55-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/sync-runner-audit.d.ts +26 -0
- package/dist/bin/sync-runner-audit.d.ts.map +1 -0
- package/dist/bin/sync-runner-audit.js +51 -0
- package/dist/bin/sync-runner-audit.js.map +1 -0
- package/dist/bin/sync-runner-company.d.ts +18 -0
- package/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +186 -9
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-watch-loop.d.ts +107 -5
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +1382 -183
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +63 -11
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +348 -94
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/watcher-catchup-skip-store.d.ts +60 -0
- package/dist/bin/watcher-catchup-skip-store.d.ts.map +1 -0
- package/dist/bin/watcher-catchup-skip-store.js +282 -0
- package/dist/bin/watcher-catchup-skip-store.js.map +1 -0
- package/dist/cli/conflict.d.ts +6 -0
- package/dist/cli/conflict.d.ts.map +1 -1
- package/dist/cli/conflict.js +3 -1
- package/dist/cli/conflict.js.map +1 -1
- package/dist/cli/share.d.ts +27 -0
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +119 -22
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/sync.d.ts +18 -2
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +74 -14
- package/dist/cli/sync.js.map +1 -1
- package/dist/ignore.d.ts +1 -1
- package/dist/ignore.d.ts.map +1 -1
- package/dist/ignore.js +2 -2
- package/dist/ignore.js.map +1 -1
- package/dist/journal-snapshot-v4.d.ts.map +1 -1
- package/dist/journal-snapshot-v4.js +44 -38
- package/dist/journal-snapshot-v4.js.map +1 -1
- package/dist/journal.d.ts +15 -2
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +221 -16
- package/dist/journal.js.map +1 -1
- package/dist/lib/cloud-authoritative.d.ts +9 -2
- package/dist/lib/cloud-authoritative.d.ts.map +1 -1
- package/dist/lib/cloud-authoritative.js +34 -2
- package/dist/lib/cloud-authoritative.js.map +1 -1
- package/dist/lib/exit-codes.d.ts +2 -0
- package/dist/lib/exit-codes.d.ts.map +1 -1
- package/dist/lib/exit-codes.js +2 -0
- package/dist/lib/exit-codes.js.map +1 -1
- package/dist/personal-vault-exclusions.d.ts +2 -2
- package/dist/personal-vault-exclusions.d.ts.map +1 -1
- package/dist/personal-vault-exclusions.js +5 -3
- package/dist/personal-vault-exclusions.js.map +1 -1
- package/dist/personal-vault.d.ts +7 -0
- package/dist/personal-vault.d.ts.map +1 -1
- package/dist/personal-vault.js +10 -0
- package/dist/personal-vault.js.map +1 -1
- package/dist/qmd-runner-admission.d.ts +22 -0
- package/dist/qmd-runner-admission.d.ts.map +1 -0
- package/dist/qmd-runner-admission.js +107 -0
- package/dist/qmd-runner-admission.js.map +1 -0
- package/dist/s3.d.ts +2 -0
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +2 -0
- package/dist/s3.js.map +1 -1
- package/dist/symlink-target-policy.d.ts +11 -0
- package/dist/symlink-target-policy.d.ts.map +1 -1
- package/dist/symlink-target-policy.js +110 -0
- package/dist/symlink-target-policy.js.map +1 -1
- package/dist/sync/mutation-session.d.ts.map +1 -1
- package/dist/sync/mutation-session.js +4 -0
- package/dist/sync/mutation-session.js.map +1 -1
- package/dist/sync/parallel-sync-coordinator.d.ts +29 -0
- package/dist/sync/parallel-sync-coordinator.d.ts.map +1 -1
- package/dist/sync/parallel-sync-coordinator.js +229 -16
- package/dist/sync/parallel-sync-coordinator.js.map +1 -1
- package/dist/sync/push-receiver.d.ts +5 -0
- package/dist/sync/push-receiver.d.ts.map +1 -1
- package/dist/sync/push-receiver.js +32 -10
- package/dist/sync/push-receiver.js.map +1 -1
- package/dist/watch-owner-lease.d.ts +4 -0
- package/dist/watch-owner-lease.d.ts.map +1 -1
- package/dist/watch-owner-lease.js +40 -6
- package/dist/watch-owner-lease.js.map +1 -1
- package/dist/watcher.d.ts +16 -3
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +114 -21
- package/dist/watcher.js.map +1 -1
- package/package.json +1 -1
package/dist/journal.js
CHANGED
|
@@ -20,6 +20,7 @@ import { spawn } from "node:child_process";
|
|
|
20
20
|
import { fileURLToPath } from "node:url";
|
|
21
21
|
import { JournalRowOverlay, JournalRowStore, journalEntryFingerprint, } from "./journal-row-store.js";
|
|
22
22
|
import { createJournalRecordProxy, journalRecordRows } from "./journal-record-proxy.js";
|
|
23
|
+
import { fsyncWithCompatibility } from "./sync/fsync-compat.js";
|
|
23
24
|
import { decodeJournalSnapshot, decodedJournalRows } from "./journal-snapshot-decoder.js";
|
|
24
25
|
import { forgetSharedAreaJournalRows, } from "./journal-snapshot-rows.js";
|
|
25
26
|
export { JOURNAL_CACHE_MAX_ROWS, JOURNAL_DECODE_WINDOW, dropJournalDecodeWindow, emitJournalCacheMetric, journalCacheMetric, journalFingerprintMatchesStat, resetJournalDecodeWindowForTest, } from "./journal-decode-window.js";
|
|
@@ -506,6 +507,39 @@ function sanitizeSlug(slug) {
|
|
|
506
507
|
export function getJournalPath(slug) {
|
|
507
508
|
return path.join(getStateDir(), `${JOURNAL_FILE_PREFIX}${sanitizeSlug(slug)}${JOURNAL_FILE_SUFFIX}`);
|
|
508
509
|
}
|
|
510
|
+
/**
|
|
511
|
+
* Paths retained in the durable quarantine sidecars for this journal scope.
|
|
512
|
+
* A quarantined path remains unresolved until an operator removes or repairs
|
|
513
|
+
* its sidecar; sync must not infer a create, download, or delete for it.
|
|
514
|
+
*/
|
|
515
|
+
export function readQuarantinedJournalPaths(slug) {
|
|
516
|
+
const scope = crypto.createHash("sha256").update(journalScopeId(slug)).digest("hex");
|
|
517
|
+
const directory = path.join(getStateDir(), "journal-quarantine", scope);
|
|
518
|
+
let names;
|
|
519
|
+
try {
|
|
520
|
+
names = fs.readdirSync(directory);
|
|
521
|
+
}
|
|
522
|
+
catch (error) {
|
|
523
|
+
if (error.code === "ENOENT")
|
|
524
|
+
return new Set();
|
|
525
|
+
throw error;
|
|
526
|
+
}
|
|
527
|
+
const paths = new Set();
|
|
528
|
+
for (const name of names) {
|
|
529
|
+
if (!name.endsWith(".json"))
|
|
530
|
+
continue;
|
|
531
|
+
const sidecar = JSON.parse(fs.readFileSync(path.join(directory, name), "utf8"));
|
|
532
|
+
if (sidecar.format !== "hq-journal-quarantine@1" || !Array.isArray(sidecar.invalidUpserts)) {
|
|
533
|
+
throw new Error("journal: invalid quarantine sidecar");
|
|
534
|
+
}
|
|
535
|
+
for (const invalid of sidecar.invalidUpserts) {
|
|
536
|
+
if (typeof invalid.key !== "string")
|
|
537
|
+
throw new Error("journal: invalid quarantined path");
|
|
538
|
+
paths.add(invalid.key);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
return paths;
|
|
542
|
+
}
|
|
509
543
|
/**
|
|
510
544
|
* Reserved journal slug for the personal-vault fanout slot in the `--companies`
|
|
511
545
|
* runner. The vault slot uploads the whole HQ overlay (`.claude/`, `core/`,
|
|
@@ -571,7 +605,7 @@ export function migratePersonalVaultJournal() {
|
|
|
571
605
|
* When the file doesn't exist, we return a fresh v2 journal directly — new
|
|
572
606
|
* installs never pass through v1 on disk.
|
|
573
607
|
*/
|
|
574
|
-
export function readJournal(slug) {
|
|
608
|
+
export function readJournal(slug, companyUid) {
|
|
575
609
|
const areaMigration = openAreaMigrationIfAuthoritative(slug);
|
|
576
610
|
if (areaMigration) {
|
|
577
611
|
// Area rows stay in their shared packed store. This record-compatible
|
|
@@ -583,7 +617,7 @@ export function readJournal(slug) {
|
|
|
583
617
|
// same immutable packed rows. Each owns only its own overlay, so an
|
|
584
618
|
// uncommitted full pass is never visible to a scoped reader (and a scoped
|
|
585
619
|
// delta cannot dirty the full pass).
|
|
586
|
-
return readStoreBackedJournal(readJournalAggregateWith(slug, areaMigration, true), { privateOverlay: true });
|
|
620
|
+
return readStoreBackedJournal(readJournalAggregateWith(slug, areaMigration, true, companyUid), { privateOverlay: true });
|
|
587
621
|
}
|
|
588
622
|
/**
|
|
589
623
|
* Read the mutable outer shell required by a full pull without detaching every
|
|
@@ -599,10 +633,10 @@ export function readJournal(slug) {
|
|
|
599
633
|
*
|
|
600
634
|
* @internal Full-pull boundary; not a public journal record API.
|
|
601
635
|
*/
|
|
602
|
-
export function readJournalForPass(slug) {
|
|
636
|
+
export function readJournalForPass(slug, companyUid) {
|
|
603
637
|
const areaMigration = openAreaMigrationIfAuthoritative(slug);
|
|
604
638
|
if (!areaMigration)
|
|
605
|
-
return readJournal(slug);
|
|
639
|
+
return readJournal(slug, companyUid);
|
|
606
640
|
// The overlay owns only keys this pass changes; it borrows all untouched
|
|
607
641
|
// packed rows from the cached aggregate and keeps the public record facade.
|
|
608
642
|
const journal = readStoreBackedJournal(areaMigration.readAreaJournalCached().journal, { privateOverlay: true });
|
|
@@ -631,12 +665,12 @@ export function readJournalForPass(slug) {
|
|
|
631
665
|
function readJournalAggregate(slug) {
|
|
632
666
|
return readJournalAggregateWith(slug, openAreaMigrationIfAuthoritative(slug));
|
|
633
667
|
}
|
|
634
|
-
function readJournalAggregateWith(slug, areaMigration, retainScopedProjection = false) {
|
|
668
|
+
function readJournalAggregateWith(slug, areaMigration, retainScopedProjection = false, companyUid) {
|
|
635
669
|
if (areaMigration)
|
|
636
670
|
return areaMigration.readAreaJournal();
|
|
637
671
|
const store = StateStore.exists(getStateDir(), journalScopeId(slug))
|
|
638
|
-
? openJournalStore(slug, emptyJournal())
|
|
639
|
-
: openJournalStore(slug, readLegacyJournal(slug));
|
|
672
|
+
? openJournalStore(slug, emptyJournal(), companyUid)
|
|
673
|
+
: openJournalStore(slug, readLegacyJournal(slug), companyUid);
|
|
640
674
|
if (retainScopedProjection) {
|
|
641
675
|
// A full pass has already paid to recover this packed snapshot. Retain its
|
|
642
676
|
// projection for the next scoped pass instead of making that pass decode
|
|
@@ -805,7 +839,7 @@ export function setJournalSnapshotDecoderForTest(decoder) {
|
|
|
805
839
|
export function setJournalSnapshotEncoderForTest(encoder) {
|
|
806
840
|
journalSnapshotEncoderForTest = encoder;
|
|
807
841
|
}
|
|
808
|
-
function journalStoreOptions(slug, initialJournal) {
|
|
842
|
+
function journalStoreOptions(slug, initialJournal, companyUid) {
|
|
809
843
|
return {
|
|
810
844
|
rootDir: getStateDir(),
|
|
811
845
|
scopeId: journalScopeId(slug),
|
|
@@ -828,6 +862,7 @@ function journalStoreOptions(slug, initialJournal) {
|
|
|
828
862
|
}
|
|
829
863
|
if (record.type === JOURNAL_DELTA_RECORD_TYPE) {
|
|
830
864
|
const delta = record.payload;
|
|
865
|
+
const replayDelta = quarantineIncompleteJournalUpserts(slug, companyUid, record, delta);
|
|
831
866
|
const existingRows = journalRecordRows(state.journal.files) ?? decodedJournalRows(state.journal);
|
|
832
867
|
if (existingRows) {
|
|
833
868
|
// StateStore's aggregate is also the immutable source for any
|
|
@@ -835,14 +870,14 @@ function journalStoreOptions(slug, initialJournal) {
|
|
|
835
870
|
// observed, put durable changes in a separate overlay rather than
|
|
836
871
|
// mutating that shared packed row store.
|
|
837
872
|
const rows = existingRows instanceof JournalRowOverlay
|
|
838
|
-
? existingRows.withCommittedDelta(
|
|
839
|
-
: new JournalRowOverlay(existingRows).withCommittedDelta(
|
|
873
|
+
? existingRows.withCommittedDelta(replayDelta.upserts, replayDelta.deletes)
|
|
874
|
+
: new JournalRowOverlay(existingRows).withCommittedDelta(replayDelta.upserts, replayDelta.deletes);
|
|
840
875
|
state.journal.files = createJournalRecordProxy(rows);
|
|
841
876
|
}
|
|
842
877
|
else {
|
|
843
|
-
for (const key of
|
|
878
|
+
for (const key of replayDelta.deletes)
|
|
844
879
|
delete state.journal.files[key];
|
|
845
|
-
Object.assign(state.journal.files,
|
|
880
|
+
Object.assign(state.journal.files, replayDelta.upserts);
|
|
846
881
|
}
|
|
847
882
|
state.journal.version = delta.version;
|
|
848
883
|
state.journal.lastSync = delta.lastSync ?? state.journal.lastSync;
|
|
@@ -861,12 +896,14 @@ function validateJournalRecord(record) {
|
|
|
861
896
|
}
|
|
862
897
|
return;
|
|
863
898
|
}
|
|
864
|
-
if (record.type === JOURNAL_DELTA_RECORD_TYPE && isJournalDelta(record.payload))
|
|
899
|
+
if (record.type === JOURNAL_DELTA_RECORD_TYPE && isJournalDelta(record.payload)) {
|
|
900
|
+
assertCompleteJournalUpserts(record.payload);
|
|
865
901
|
return;
|
|
902
|
+
}
|
|
866
903
|
throw new Error(`journal: unexpected v3 WAL record type ${record.type}`);
|
|
867
904
|
}
|
|
868
|
-
function openJournalStore(slug, initialJournal) {
|
|
869
|
-
return StateStore.open(journalStoreOptions(slug, initialJournal));
|
|
905
|
+
function openJournalStore(slug, initialJournal, companyUid) {
|
|
906
|
+
return StateStore.open(journalStoreOptions(slug, initialJournal, companyUid));
|
|
870
907
|
}
|
|
871
908
|
/**
|
|
872
909
|
* A committed area manifest is an authority decision, not a hint. If its
|
|
@@ -1245,6 +1282,7 @@ export class JournalStore {
|
|
|
1245
1282
|
upserts: structuredClone(delta.upserts ?? {}),
|
|
1246
1283
|
deletes: [...(delta.deletes ?? [])],
|
|
1247
1284
|
};
|
|
1285
|
+
assertCompleteJournalUpserts(payload);
|
|
1248
1286
|
if (delta.metadata?.lastSync !== undefined)
|
|
1249
1287
|
payload.lastSync = delta.metadata.lastSync;
|
|
1250
1288
|
if (delta.metadata?.pulls !== undefined)
|
|
@@ -1275,6 +1313,7 @@ export class JournalStore {
|
|
|
1275
1313
|
payload.lastSync = delta.metadata.lastSync;
|
|
1276
1314
|
if (delta.metadata?.pulls !== undefined)
|
|
1277
1315
|
payload.pulls = structuredClone(delta.metadata.pulls);
|
|
1316
|
+
assertCompleteJournalUpserts(payload);
|
|
1278
1317
|
return payload;
|
|
1279
1318
|
};
|
|
1280
1319
|
if (this.areaMigration) {
|
|
@@ -1312,6 +1351,7 @@ export class JournalStore {
|
|
|
1312
1351
|
upserts: structuredClone(delta.upserts ?? {}),
|
|
1313
1352
|
deletes: [...(delta.deletes ?? [])],
|
|
1314
1353
|
};
|
|
1354
|
+
assertCompleteJournalUpserts(payload);
|
|
1315
1355
|
if (delta.metadata?.lastSync !== undefined)
|
|
1316
1356
|
payload.lastSync = delta.metadata.lastSync;
|
|
1317
1357
|
if (delta.metadata?.pulls !== undefined)
|
|
@@ -1591,6 +1631,171 @@ function isJournalDelta(value) {
|
|
|
1591
1631
|
Array.isArray(delta.deletes) && delta.deletes.every((key) => typeof key === "string") &&
|
|
1592
1632
|
(delta.pulls === undefined || Array.isArray(delta.pulls));
|
|
1593
1633
|
}
|
|
1634
|
+
export class JournalIncompleteUpsertError extends Error {
|
|
1635
|
+
missingFields;
|
|
1636
|
+
errorClass = "JournalIncompleteUpsertError";
|
|
1637
|
+
constructor(missingFields) {
|
|
1638
|
+
super(`journal delta upsert is incomplete (${missingFields.join(",")})`);
|
|
1639
|
+
this.missingFields = missingFields;
|
|
1640
|
+
this.name = "JournalIncompleteUpsertError";
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
function incompleteJournalEntryFields(value) {
|
|
1644
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
1645
|
+
return ["hash", "size", "syncedAt", "direction"];
|
|
1646
|
+
}
|
|
1647
|
+
const entry = value;
|
|
1648
|
+
const missing = [];
|
|
1649
|
+
if (typeof entry.hash !== "string")
|
|
1650
|
+
missing.push("hash");
|
|
1651
|
+
if (typeof entry.size !== "number" || !Number.isFinite(entry.size))
|
|
1652
|
+
missing.push("size");
|
|
1653
|
+
if (typeof entry.syncedAt !== "string")
|
|
1654
|
+
missing.push("syncedAt");
|
|
1655
|
+
if (entry.direction !== "up" && entry.direction !== "down")
|
|
1656
|
+
missing.push("direction");
|
|
1657
|
+
return missing;
|
|
1658
|
+
}
|
|
1659
|
+
function assertCompleteJournalUpserts(delta) {
|
|
1660
|
+
for (const entry of Object.values(delta.upserts)) {
|
|
1661
|
+
const missing = incompleteJournalEntryFields(entry);
|
|
1662
|
+
if (missing.length > 0)
|
|
1663
|
+
throw new JournalIncompleteUpsertError(missing);
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
const loggedQuarantinedJournalRecords = new Set();
|
|
1667
|
+
function quarantineIncompleteJournalUpserts(slug, companyUid, record, delta) {
|
|
1668
|
+
const invalid = Object.entries(delta.upserts)
|
|
1669
|
+
.map(([key, value]) => ({ key, value, missingFields: incompleteJournalEntryFields(value) }))
|
|
1670
|
+
.filter((entry) => entry.missingFields.length > 0);
|
|
1671
|
+
if (invalid.length === 0)
|
|
1672
|
+
return delta;
|
|
1673
|
+
const content = JSON.stringify({
|
|
1674
|
+
format: "hq-journal-quarantine@1",
|
|
1675
|
+
slug,
|
|
1676
|
+
companyUid,
|
|
1677
|
+
sequence: record.sequence,
|
|
1678
|
+
recordType: record.type,
|
|
1679
|
+
invalidUpserts: invalid,
|
|
1680
|
+
});
|
|
1681
|
+
const digest = crypto.createHash("sha256").update(content).digest("hex");
|
|
1682
|
+
const quarantineDirectory = path.join(getStateDir(), "journal-quarantine", crypto.createHash("sha256").update(journalScopeId(slug)).digest("hex"));
|
|
1683
|
+
const finalPath = path.join(quarantineDirectory, `${record.sequence}-${digest}.json`);
|
|
1684
|
+
let linkAttempted = false;
|
|
1685
|
+
try {
|
|
1686
|
+
fs.mkdirSync(quarantineDirectory, { recursive: true, mode: 0o700 });
|
|
1687
|
+
const tempPath = path.join(quarantineDirectory, `${record.sequence}-${digest}-${crypto.randomBytes(8).toString("hex")}.json.tmp`);
|
|
1688
|
+
let descriptor;
|
|
1689
|
+
let operationError;
|
|
1690
|
+
let operationFailed = false;
|
|
1691
|
+
let cleanupError;
|
|
1692
|
+
let cleanupFailed = false;
|
|
1693
|
+
try {
|
|
1694
|
+
descriptor = fs.openSync(tempPath, "wx", 0o600);
|
|
1695
|
+
fs.writeFileSync(descriptor, `${content}\n`);
|
|
1696
|
+
fs.fsyncSync(descriptor);
|
|
1697
|
+
fs.closeSync(descriptor);
|
|
1698
|
+
descriptor = undefined;
|
|
1699
|
+
// A hard link publishes the fully synced inode atomically and fails if a
|
|
1700
|
+
// previous replay already installed this immutable record. Unlike rename,
|
|
1701
|
+
// link never overwrites an existing complete quarantine sidecar.
|
|
1702
|
+
linkAttempted = true;
|
|
1703
|
+
fs.linkSync(tempPath, finalPath);
|
|
1704
|
+
}
|
|
1705
|
+
catch (error) {
|
|
1706
|
+
operationError = error;
|
|
1707
|
+
operationFailed = true;
|
|
1708
|
+
}
|
|
1709
|
+
finally {
|
|
1710
|
+
if (descriptor !== undefined) {
|
|
1711
|
+
try {
|
|
1712
|
+
fs.closeSync(descriptor);
|
|
1713
|
+
}
|
|
1714
|
+
catch (error) {
|
|
1715
|
+
cleanupError = error;
|
|
1716
|
+
cleanupFailed = true;
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
try {
|
|
1720
|
+
fs.unlinkSync(tempPath);
|
|
1721
|
+
}
|
|
1722
|
+
catch (error) {
|
|
1723
|
+
if (error.code !== "ENOENT" && !cleanupFailed) {
|
|
1724
|
+
cleanupError = error;
|
|
1725
|
+
cleanupFailed = true;
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
// Keep the operation's failure authoritative if cleanup also fails. The
|
|
1730
|
+
// cleanup error is surfaced only when publishing itself succeeded.
|
|
1731
|
+
if (operationFailed)
|
|
1732
|
+
throw operationError;
|
|
1733
|
+
if (cleanupFailed)
|
|
1734
|
+
throw cleanupError;
|
|
1735
|
+
const directoryDescriptor = fs.openSync(quarantineDirectory, "r");
|
|
1736
|
+
try {
|
|
1737
|
+
fsyncWithCompatibility(directoryDescriptor, {
|
|
1738
|
+
site: "journal quarantine directory",
|
|
1739
|
+
handleKind: "directory",
|
|
1740
|
+
});
|
|
1741
|
+
}
|
|
1742
|
+
finally {
|
|
1743
|
+
fs.closeSync(directoryDescriptor);
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
catch (error) {
|
|
1747
|
+
// Replay may proceed without a write only when the same immutable sidecar
|
|
1748
|
+
// was already published. Other persistence failures must stop replay so a
|
|
1749
|
+
// later compaction cannot discard the only copy of this malformed record.
|
|
1750
|
+
if (linkAttempted &&
|
|
1751
|
+
isAlreadyExistsError(error) &&
|
|
1752
|
+
quarantineSidecarMatches(finalPath, `${content}\n`)) {
|
|
1753
|
+
// An earlier replay installed this exact record.
|
|
1754
|
+
}
|
|
1755
|
+
else {
|
|
1756
|
+
writeJournalRecoveryEvent("journal_quarantine_write_failed", "JournalQuarantineWriteError", companyUid);
|
|
1757
|
+
throw error;
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
const eventKey = `${slug}:${record.sequence}:${digest}:${companyUid ?? ""}`;
|
|
1761
|
+
if (!loggedQuarantinedJournalRecords.has(eventKey)) {
|
|
1762
|
+
loggedQuarantinedJournalRecords.add(eventKey);
|
|
1763
|
+
writeJournalRecoveryEvent("journal_record_quarantined", "JournalIncompleteUpsertError", companyUid);
|
|
1764
|
+
}
|
|
1765
|
+
const invalidPaths = new Set(invalid.map((entry) => entry.key));
|
|
1766
|
+
const upserts = Object.fromEntries(Object.entries(delta.upserts).filter(([key]) => !invalidPaths.has(key)));
|
|
1767
|
+
// A malformed upsert never authorizes deletion. Preserve any base row for
|
|
1768
|
+
// that path, even if the same WAL record also listed it in `deletes`.
|
|
1769
|
+
const deletes = delta.deletes.filter((key) => !invalidPaths.has(key));
|
|
1770
|
+
return { ...delta, upserts, deletes };
|
|
1771
|
+
}
|
|
1772
|
+
function isAlreadyExistsError(error) {
|
|
1773
|
+
return typeof error === "object" && error !== null && "code" in error &&
|
|
1774
|
+
error.code === "EEXIST";
|
|
1775
|
+
}
|
|
1776
|
+
function quarantineSidecarMatches(filePath, expectedContent) {
|
|
1777
|
+
try {
|
|
1778
|
+
return fs.readFileSync(filePath, "utf8") === expectedContent;
|
|
1779
|
+
}
|
|
1780
|
+
catch {
|
|
1781
|
+
return false;
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
function writeJournalRecoveryEvent(event, errorClass, companyUid) {
|
|
1785
|
+
try {
|
|
1786
|
+
process.stderr.write(`${JSON.stringify({
|
|
1787
|
+
type: "error",
|
|
1788
|
+
diagnostic: true,
|
|
1789
|
+
event,
|
|
1790
|
+
errorClass,
|
|
1791
|
+
...(companyUid === undefined ? {} : { companyUid }),
|
|
1792
|
+
})}\n`);
|
|
1793
|
+
}
|
|
1794
|
+
catch {
|
|
1795
|
+
// A diagnostic event must not break recovery or sync.
|
|
1796
|
+
process.emitWarning(`journal recovery diagnostic could not be emitted (${errorClass})`);
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1594
1799
|
function isJournal(value) {
|
|
1595
1800
|
return typeof value === "object" && value !== null &&
|
|
1596
1801
|
(value.version === "1" || value.version === "2") &&
|
|
@@ -2237,7 +2442,7 @@ function ensureJournalLocator(slug) {
|
|
|
2237
2442
|
*/
|
|
2238
2443
|
export function repairJournalStateIfNeeded(slug, options = {}) {
|
|
2239
2444
|
const safeSlug = sanitizeSlug(slug);
|
|
2240
|
-
const storeOptions = journalStoreOptions(safeSlug, emptyJournal());
|
|
2445
|
+
const storeOptions = journalStoreOptions(safeSlug, emptyJournal(), options.companyUid);
|
|
2241
2446
|
if (options.maxWalBytesBeforeCompaction !== undefined) {
|
|
2242
2447
|
storeOptions.maxWalBytesBeforeCompaction = options.maxWalBytesBeforeCompaction;
|
|
2243
2448
|
}
|