@indigoai-us/hq-cloud 6.18.52 → 6.18.54
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 +350 -95
- 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/reindex.d.ts.map +1 -1
- package/dist/cli/reindex.js +95 -5
- package/dist/cli/reindex.js.map +1 -1
- package/dist/cli/rescue-core.d.ts +25 -1
- package/dist/cli/rescue-core.d.ts.map +1 -1
- package/dist/cli/rescue-core.js +69 -4
- package/dist/cli/rescue-core.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 +151 -30
- 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 +90 -25
- 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-row-store.d.ts.map +1 -1
- package/dist/journal-row-store.js +6 -0
- package/dist/journal-row-store.js.map +1 -1
- package/dist/journal-snapshot-v4.d.ts +8 -0
- package/dist/journal-snapshot-v4.d.ts.map +1 -1
- package/dist/journal-snapshot-v4.js +208 -17
- package/dist/journal-snapshot-v4.js.map +1 -1
- package/dist/journal.d.ts +18 -2
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +248 -17
- 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/area-ledger-migration.d.ts +3 -1
- package/dist/sync/area-ledger-migration.d.ts.map +1 -1
- package/dist/sync/area-ledger-migration.js +3 -0
- package/dist/sync/area-ledger-migration.js.map +1 -1
- package/dist/sync/area-ledger.d.ts +3 -0
- package/dist/sync/area-ledger.d.ts.map +1 -1
- package/dist/sync/area-ledger.js +29 -6
- package/dist/sync/area-ledger.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/sync/state-store.d.ts +7 -0
- package/dist/sync/state-store.d.ts.map +1 -1
- package/dist/sync/state-store.js +113 -6
- package/dist/sync/state-store.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 +4 -3
package/dist/journal.js
CHANGED
|
@@ -20,11 +20,12 @@ 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";
|
|
26
27
|
import { journalCacheMetric } from "./journal-decode-window.js";
|
|
27
|
-
import { encodeV4JournalSnapshotChunks } from "./journal-snapshot-v4.js";
|
|
28
|
+
import { encodeV4JournalSnapshotChunks, encodeV4JournalSnapshotChunksAsync, } from "./journal-snapshot-v4.js";
|
|
28
29
|
import { toPosixKey } from "./posix-key.js";
|
|
29
30
|
import { isUnderAnyRoot } from "./path-ancestors.js";
|
|
30
31
|
export { isUnderAnyRoot } from "./path-ancestors.js";
|
|
@@ -60,6 +61,16 @@ const journalBaselines = new WeakMap();
|
|
|
60
61
|
const storeBackedJournals = new WeakMap();
|
|
61
62
|
/** Full-pass journals must not retain the post-cutover v3 rollback mirror between checkpoints. */
|
|
62
63
|
const fullPassAreaJournals = new WeakSet();
|
|
64
|
+
// Large asynchronous snapshot capture is enabled only by the long-lived watch
|
|
65
|
+
// runner. One-shot journal writers keep the synchronous writer's lower
|
|
66
|
+
// transient-memory profile.
|
|
67
|
+
let asyncJournalCompactionEnabled = false;
|
|
68
|
+
export function setAsyncJournalCompactionForWatchRunner(watchMode) {
|
|
69
|
+
asyncJournalCompactionEnabled = watchMode;
|
|
70
|
+
}
|
|
71
|
+
export function enableAsyncJournalCompactionForWatchRunner() {
|
|
72
|
+
setAsyncJournalCompactionForWatchRunner(true);
|
|
73
|
+
}
|
|
63
74
|
/**
|
|
64
75
|
* Record-shaped journals are a public compatibility boundary. Internal callers
|
|
65
76
|
* use this adapter so packed rows stay lazy through reads, scans and writes.
|
|
@@ -506,6 +517,42 @@ function sanitizeSlug(slug) {
|
|
|
506
517
|
export function getJournalPath(slug) {
|
|
507
518
|
return path.join(getStateDir(), `${JOURNAL_FILE_PREFIX}${sanitizeSlug(slug)}${JOURNAL_FILE_SUFFIX}`);
|
|
508
519
|
}
|
|
520
|
+
/**
|
|
521
|
+
* Paths retained in the durable quarantine sidecars for this journal scope.
|
|
522
|
+
* Ordinary quarantined delta paths remain blocked until repaired. An area row
|
|
523
|
+
* marked `freshCompare` has no trustworthy baseline, so sync may compare it as
|
|
524
|
+
* an unsynced path while still retaining its quarantine evidence.
|
|
525
|
+
*/
|
|
526
|
+
export function readQuarantinedJournalPaths(slug) {
|
|
527
|
+
const scope = crypto.createHash("sha256").update(journalScopeId(slug)).digest("hex");
|
|
528
|
+
const directory = path.join(getStateDir(), "journal-quarantine", scope);
|
|
529
|
+
let names;
|
|
530
|
+
try {
|
|
531
|
+
names = fs.readdirSync(directory);
|
|
532
|
+
}
|
|
533
|
+
catch (error) {
|
|
534
|
+
if (error.code === "ENOENT")
|
|
535
|
+
return new Set();
|
|
536
|
+
throw error;
|
|
537
|
+
}
|
|
538
|
+
const paths = new Set();
|
|
539
|
+
for (const name of names) {
|
|
540
|
+
if (!name.endsWith(".json"))
|
|
541
|
+
continue;
|
|
542
|
+
const sidecar = JSON.parse(fs.readFileSync(path.join(directory, name), "utf8"));
|
|
543
|
+
if (sidecar.format !== "hq-journal-quarantine@1" || !Array.isArray(sidecar.invalidUpserts)) {
|
|
544
|
+
throw new Error("journal: invalid quarantine sidecar");
|
|
545
|
+
}
|
|
546
|
+
if (sidecar.freshCompare === true)
|
|
547
|
+
continue;
|
|
548
|
+
for (const invalid of sidecar.invalidUpserts) {
|
|
549
|
+
if (typeof invalid.key !== "string")
|
|
550
|
+
throw new Error("journal: invalid quarantined path");
|
|
551
|
+
paths.add(invalid.key);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
return paths;
|
|
555
|
+
}
|
|
509
556
|
/**
|
|
510
557
|
* Reserved journal slug for the personal-vault fanout slot in the `--companies`
|
|
511
558
|
* runner. The vault slot uploads the whole HQ overlay (`.claude/`, `core/`,
|
|
@@ -571,7 +618,7 @@ export function migratePersonalVaultJournal() {
|
|
|
571
618
|
* When the file doesn't exist, we return a fresh v2 journal directly — new
|
|
572
619
|
* installs never pass through v1 on disk.
|
|
573
620
|
*/
|
|
574
|
-
export function readJournal(slug) {
|
|
621
|
+
export function readJournal(slug, companyUid) {
|
|
575
622
|
const areaMigration = openAreaMigrationIfAuthoritative(slug);
|
|
576
623
|
if (areaMigration) {
|
|
577
624
|
// Area rows stay in their shared packed store. This record-compatible
|
|
@@ -583,7 +630,7 @@ export function readJournal(slug) {
|
|
|
583
630
|
// same immutable packed rows. Each owns only its own overlay, so an
|
|
584
631
|
// uncommitted full pass is never visible to a scoped reader (and a scoped
|
|
585
632
|
// delta cannot dirty the full pass).
|
|
586
|
-
return readStoreBackedJournal(readJournalAggregateWith(slug, areaMigration, true), { privateOverlay: true });
|
|
633
|
+
return readStoreBackedJournal(readJournalAggregateWith(slug, areaMigration, true, companyUid), { privateOverlay: true });
|
|
587
634
|
}
|
|
588
635
|
/**
|
|
589
636
|
* Read the mutable outer shell required by a full pull without detaching every
|
|
@@ -599,10 +646,10 @@ export function readJournal(slug) {
|
|
|
599
646
|
*
|
|
600
647
|
* @internal Full-pull boundary; not a public journal record API.
|
|
601
648
|
*/
|
|
602
|
-
export function readJournalForPass(slug) {
|
|
649
|
+
export function readJournalForPass(slug, companyUid) {
|
|
603
650
|
const areaMigration = openAreaMigrationIfAuthoritative(slug);
|
|
604
651
|
if (!areaMigration)
|
|
605
|
-
return readJournal(slug);
|
|
652
|
+
return readJournal(slug, companyUid);
|
|
606
653
|
// The overlay owns only keys this pass changes; it borrows all untouched
|
|
607
654
|
// packed rows from the cached aggregate and keeps the public record facade.
|
|
608
655
|
const journal = readStoreBackedJournal(areaMigration.readAreaJournalCached().journal, { privateOverlay: true });
|
|
@@ -631,12 +678,12 @@ export function readJournalForPass(slug) {
|
|
|
631
678
|
function readJournalAggregate(slug) {
|
|
632
679
|
return readJournalAggregateWith(slug, openAreaMigrationIfAuthoritative(slug));
|
|
633
680
|
}
|
|
634
|
-
function readJournalAggregateWith(slug, areaMigration, retainScopedProjection = false) {
|
|
681
|
+
function readJournalAggregateWith(slug, areaMigration, retainScopedProjection = false, companyUid) {
|
|
635
682
|
if (areaMigration)
|
|
636
683
|
return areaMigration.readAreaJournal();
|
|
637
684
|
const store = StateStore.exists(getStateDir(), journalScopeId(slug))
|
|
638
|
-
? openJournalStore(slug, emptyJournal())
|
|
639
|
-
: openJournalStore(slug, readLegacyJournal(slug));
|
|
685
|
+
? openJournalStore(slug, emptyJournal(), companyUid)
|
|
686
|
+
: openJournalStore(slug, readLegacyJournal(slug), companyUid);
|
|
640
687
|
if (retainScopedProjection) {
|
|
641
688
|
// A full pass has already paid to recover this packed snapshot. Retain its
|
|
642
689
|
// projection for the next scoped pass instead of making that pass decode
|
|
@@ -805,7 +852,7 @@ export function setJournalSnapshotDecoderForTest(decoder) {
|
|
|
805
852
|
export function setJournalSnapshotEncoderForTest(encoder) {
|
|
806
853
|
journalSnapshotEncoderForTest = encoder;
|
|
807
854
|
}
|
|
808
|
-
function journalStoreOptions(slug, initialJournal) {
|
|
855
|
+
function journalStoreOptions(slug, initialJournal, companyUid) {
|
|
809
856
|
return {
|
|
810
857
|
rootDir: getStateDir(),
|
|
811
858
|
scopeId: journalScopeId(slug),
|
|
@@ -816,6 +863,9 @@ function journalStoreOptions(slug, initialJournal) {
|
|
|
816
863
|
snapshotEncoder: journalSnapshotEncoderForTest === undefined
|
|
817
864
|
? (generation, scopeDigest, payload) => encodeV4JournalSnapshotChunks(generation, scopeDigest, payload, (diagnostic) => journalSnapshotQuarantineListeners.get(slug)?.(diagnostic))
|
|
818
865
|
: journalSnapshotEncoderForTest ?? undefined,
|
|
866
|
+
snapshotEncoderAsync: asyncJournalCompactionEnabled && journalSnapshotEncoderForTest === undefined
|
|
867
|
+
? (generation, scopeDigest, payload) => encodeV4JournalSnapshotChunksAsync(generation, scopeDigest, payload, (diagnostic) => journalSnapshotQuarantineListeners.get(slug)?.(diagnostic))
|
|
868
|
+
: undefined,
|
|
819
869
|
snapshotMagics: [SNAPSHOT_V4_MAGIC],
|
|
820
870
|
validateRecord: validateJournalRecord,
|
|
821
871
|
reduce: (state, record) => {
|
|
@@ -828,6 +878,7 @@ function journalStoreOptions(slug, initialJournal) {
|
|
|
828
878
|
}
|
|
829
879
|
if (record.type === JOURNAL_DELTA_RECORD_TYPE) {
|
|
830
880
|
const delta = record.payload;
|
|
881
|
+
const replayDelta = quarantineIncompleteJournalUpserts(slug, companyUid, record, delta);
|
|
831
882
|
const existingRows = journalRecordRows(state.journal.files) ?? decodedJournalRows(state.journal);
|
|
832
883
|
if (existingRows) {
|
|
833
884
|
// StateStore's aggregate is also the immutable source for any
|
|
@@ -835,14 +886,14 @@ function journalStoreOptions(slug, initialJournal) {
|
|
|
835
886
|
// observed, put durable changes in a separate overlay rather than
|
|
836
887
|
// mutating that shared packed row store.
|
|
837
888
|
const rows = existingRows instanceof JournalRowOverlay
|
|
838
|
-
? existingRows.withCommittedDelta(
|
|
839
|
-
: new JournalRowOverlay(existingRows).withCommittedDelta(
|
|
889
|
+
? existingRows.withCommittedDelta(replayDelta.upserts, replayDelta.deletes)
|
|
890
|
+
: new JournalRowOverlay(existingRows).withCommittedDelta(replayDelta.upserts, replayDelta.deletes);
|
|
840
891
|
state.journal.files = createJournalRecordProxy(rows);
|
|
841
892
|
}
|
|
842
893
|
else {
|
|
843
|
-
for (const key of
|
|
894
|
+
for (const key of replayDelta.deletes)
|
|
844
895
|
delete state.journal.files[key];
|
|
845
|
-
Object.assign(state.journal.files,
|
|
896
|
+
Object.assign(state.journal.files, replayDelta.upserts);
|
|
846
897
|
}
|
|
847
898
|
state.journal.version = delta.version;
|
|
848
899
|
state.journal.lastSync = delta.lastSync ?? state.journal.lastSync;
|
|
@@ -861,12 +912,14 @@ function validateJournalRecord(record) {
|
|
|
861
912
|
}
|
|
862
913
|
return;
|
|
863
914
|
}
|
|
864
|
-
if (record.type === JOURNAL_DELTA_RECORD_TYPE && isJournalDelta(record.payload))
|
|
915
|
+
if (record.type === JOURNAL_DELTA_RECORD_TYPE && isJournalDelta(record.payload)) {
|
|
916
|
+
assertCompleteJournalUpserts(record.payload);
|
|
865
917
|
return;
|
|
918
|
+
}
|
|
866
919
|
throw new Error(`journal: unexpected v3 WAL record type ${record.type}`);
|
|
867
920
|
}
|
|
868
|
-
function openJournalStore(slug, initialJournal) {
|
|
869
|
-
return StateStore.open(journalStoreOptions(slug, initialJournal));
|
|
921
|
+
function openJournalStore(slug, initialJournal, companyUid) {
|
|
922
|
+
return StateStore.open(journalStoreOptions(slug, initialJournal, companyUid));
|
|
870
923
|
}
|
|
871
924
|
/**
|
|
872
925
|
* A committed area manifest is an authority decision, not a hint. If its
|
|
@@ -913,6 +966,7 @@ function openAreaMigrationIfAuthoritative(slug) {
|
|
|
913
966
|
rootDir: stateDir,
|
|
914
967
|
journalSlug: slug,
|
|
915
968
|
resolver,
|
|
969
|
+
onIncompleteJournalRow: (key, entry) => quarantineIncompleteAreaJournalRow(slug, key, entry),
|
|
916
970
|
// Area authority is already committed. Open the full reverse-shadow v3
|
|
917
971
|
// projection only during a mirror mutation instead of pinning it next to
|
|
918
972
|
// the authoritative area ledger for the runner's whole lifetime.
|
|
@@ -1245,6 +1299,7 @@ export class JournalStore {
|
|
|
1245
1299
|
upserts: structuredClone(delta.upserts ?? {}),
|
|
1246
1300
|
deletes: [...(delta.deletes ?? [])],
|
|
1247
1301
|
};
|
|
1302
|
+
assertCompleteJournalUpserts(payload);
|
|
1248
1303
|
if (delta.metadata?.lastSync !== undefined)
|
|
1249
1304
|
payload.lastSync = delta.metadata.lastSync;
|
|
1250
1305
|
if (delta.metadata?.pulls !== undefined)
|
|
@@ -1275,6 +1330,7 @@ export class JournalStore {
|
|
|
1275
1330
|
payload.lastSync = delta.metadata.lastSync;
|
|
1276
1331
|
if (delta.metadata?.pulls !== undefined)
|
|
1277
1332
|
payload.pulls = structuredClone(delta.metadata.pulls);
|
|
1333
|
+
assertCompleteJournalUpserts(payload);
|
|
1278
1334
|
return payload;
|
|
1279
1335
|
};
|
|
1280
1336
|
if (this.areaMigration) {
|
|
@@ -1312,6 +1368,7 @@ export class JournalStore {
|
|
|
1312
1368
|
upserts: structuredClone(delta.upserts ?? {}),
|
|
1313
1369
|
deletes: [...(delta.deletes ?? [])],
|
|
1314
1370
|
};
|
|
1371
|
+
assertCompleteJournalUpserts(payload);
|
|
1315
1372
|
if (delta.metadata?.lastSync !== undefined)
|
|
1316
1373
|
payload.lastSync = delta.metadata.lastSync;
|
|
1317
1374
|
if (delta.metadata?.pulls !== undefined)
|
|
@@ -1591,6 +1648,180 @@ function isJournalDelta(value) {
|
|
|
1591
1648
|
Array.isArray(delta.deletes) && delta.deletes.every((key) => typeof key === "string") &&
|
|
1592
1649
|
(delta.pulls === undefined || Array.isArray(delta.pulls));
|
|
1593
1650
|
}
|
|
1651
|
+
export class JournalIncompleteUpsertError extends Error {
|
|
1652
|
+
missingFields;
|
|
1653
|
+
errorClass = "JournalIncompleteUpsertError";
|
|
1654
|
+
constructor(missingFields) {
|
|
1655
|
+
super(`journal delta upsert is incomplete (${missingFields.join(",")})`);
|
|
1656
|
+
this.missingFields = missingFields;
|
|
1657
|
+
this.name = "JournalIncompleteUpsertError";
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
function incompleteJournalEntryFields(value) {
|
|
1661
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
1662
|
+
return ["hash", "size", "syncedAt", "direction"];
|
|
1663
|
+
}
|
|
1664
|
+
const entry = value;
|
|
1665
|
+
const missing = [];
|
|
1666
|
+
if (typeof entry.hash !== "string")
|
|
1667
|
+
missing.push("hash");
|
|
1668
|
+
if (typeof entry.size !== "number" || !Number.isFinite(entry.size))
|
|
1669
|
+
missing.push("size");
|
|
1670
|
+
if (typeof entry.syncedAt !== "string")
|
|
1671
|
+
missing.push("syncedAt");
|
|
1672
|
+
if (entry.direction !== "up" && entry.direction !== "down")
|
|
1673
|
+
missing.push("direction");
|
|
1674
|
+
return missing;
|
|
1675
|
+
}
|
|
1676
|
+
function assertCompleteJournalUpserts(delta) {
|
|
1677
|
+
for (const entry of Object.values(delta.upserts)) {
|
|
1678
|
+
const missing = incompleteJournalEntryFields(entry);
|
|
1679
|
+
if (missing.length > 0)
|
|
1680
|
+
throw new JournalIncompleteUpsertError(missing);
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
const loggedQuarantinedJournalRecords = new Set();
|
|
1684
|
+
function quarantineIncompleteJournalUpserts(slug, companyUid, record, delta, freshCompare = false) {
|
|
1685
|
+
const invalid = Object.entries(delta.upserts)
|
|
1686
|
+
.map(([key, value]) => ({ key, value, missingFields: incompleteJournalEntryFields(value) }))
|
|
1687
|
+
.filter((entry) => entry.missingFields.length > 0);
|
|
1688
|
+
if (invalid.length === 0)
|
|
1689
|
+
return delta;
|
|
1690
|
+
const content = JSON.stringify({
|
|
1691
|
+
format: "hq-journal-quarantine@1",
|
|
1692
|
+
slug,
|
|
1693
|
+
companyUid,
|
|
1694
|
+
sequence: record.sequence,
|
|
1695
|
+
recordType: record.type,
|
|
1696
|
+
...(freshCompare ? { freshCompare: true } : {}),
|
|
1697
|
+
invalidUpserts: invalid,
|
|
1698
|
+
});
|
|
1699
|
+
const digest = crypto.createHash("sha256").update(content).digest("hex");
|
|
1700
|
+
const quarantineDirectory = path.join(getStateDir(), "journal-quarantine", crypto.createHash("sha256").update(journalScopeId(slug)).digest("hex"));
|
|
1701
|
+
const finalPath = path.join(quarantineDirectory, `${record.sequence}-${digest}.json`);
|
|
1702
|
+
let linkAttempted = false;
|
|
1703
|
+
try {
|
|
1704
|
+
fs.mkdirSync(quarantineDirectory, { recursive: true, mode: 0o700 });
|
|
1705
|
+
const tempPath = path.join(quarantineDirectory, `${record.sequence}-${digest}-${crypto.randomBytes(8).toString("hex")}.json.tmp`);
|
|
1706
|
+
let descriptor;
|
|
1707
|
+
let operationError;
|
|
1708
|
+
let operationFailed = false;
|
|
1709
|
+
let cleanupError;
|
|
1710
|
+
let cleanupFailed = false;
|
|
1711
|
+
try {
|
|
1712
|
+
descriptor = fs.openSync(tempPath, "wx", 0o600);
|
|
1713
|
+
fs.writeFileSync(descriptor, `${content}\n`);
|
|
1714
|
+
fs.fsyncSync(descriptor);
|
|
1715
|
+
fs.closeSync(descriptor);
|
|
1716
|
+
descriptor = undefined;
|
|
1717
|
+
// A hard link publishes the fully synced inode atomically and fails if a
|
|
1718
|
+
// previous replay already installed this immutable record. Unlike rename,
|
|
1719
|
+
// link never overwrites an existing complete quarantine sidecar.
|
|
1720
|
+
linkAttempted = true;
|
|
1721
|
+
fs.linkSync(tempPath, finalPath);
|
|
1722
|
+
}
|
|
1723
|
+
catch (error) {
|
|
1724
|
+
operationError = error;
|
|
1725
|
+
operationFailed = true;
|
|
1726
|
+
}
|
|
1727
|
+
finally {
|
|
1728
|
+
if (descriptor !== undefined) {
|
|
1729
|
+
try {
|
|
1730
|
+
fs.closeSync(descriptor);
|
|
1731
|
+
}
|
|
1732
|
+
catch (error) {
|
|
1733
|
+
cleanupError = error;
|
|
1734
|
+
cleanupFailed = true;
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
try {
|
|
1738
|
+
fs.unlinkSync(tempPath);
|
|
1739
|
+
}
|
|
1740
|
+
catch (error) {
|
|
1741
|
+
if (error.code !== "ENOENT" && !cleanupFailed) {
|
|
1742
|
+
cleanupError = error;
|
|
1743
|
+
cleanupFailed = true;
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
// Keep the operation's failure authoritative if cleanup also fails. The
|
|
1748
|
+
// cleanup error is surfaced only when publishing itself succeeded.
|
|
1749
|
+
if (operationFailed)
|
|
1750
|
+
throw operationError;
|
|
1751
|
+
if (cleanupFailed)
|
|
1752
|
+
throw cleanupError;
|
|
1753
|
+
const directoryDescriptor = fs.openSync(quarantineDirectory, "r");
|
|
1754
|
+
try {
|
|
1755
|
+
fsyncWithCompatibility(directoryDescriptor, {
|
|
1756
|
+
site: "journal quarantine directory",
|
|
1757
|
+
handleKind: "directory",
|
|
1758
|
+
});
|
|
1759
|
+
}
|
|
1760
|
+
finally {
|
|
1761
|
+
fs.closeSync(directoryDescriptor);
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
catch (error) {
|
|
1765
|
+
// Replay may proceed without a write only when the same immutable sidecar
|
|
1766
|
+
// was already published. Other persistence failures must stop replay so a
|
|
1767
|
+
// later compaction cannot discard the only copy of this malformed record.
|
|
1768
|
+
if (linkAttempted &&
|
|
1769
|
+
isAlreadyExistsError(error) &&
|
|
1770
|
+
quarantineSidecarMatches(finalPath, `${content}\n`)) {
|
|
1771
|
+
// An earlier replay installed this exact record.
|
|
1772
|
+
}
|
|
1773
|
+
else {
|
|
1774
|
+
writeJournalRecoveryEvent("journal_quarantine_write_failed", "JournalQuarantineWriteError", companyUid);
|
|
1775
|
+
throw error;
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
const eventKey = `${slug}:${record.sequence}:${digest}:${companyUid ?? ""}`;
|
|
1779
|
+
if (!loggedQuarantinedJournalRecords.has(eventKey)) {
|
|
1780
|
+
loggedQuarantinedJournalRecords.add(eventKey);
|
|
1781
|
+
writeJournalRecoveryEvent("journal_record_quarantined", "JournalIncompleteUpsertError", companyUid);
|
|
1782
|
+
}
|
|
1783
|
+
const invalidPaths = new Set(invalid.map((entry) => entry.key));
|
|
1784
|
+
const upserts = Object.fromEntries(Object.entries(delta.upserts).filter(([key]) => !invalidPaths.has(key)));
|
|
1785
|
+
// A malformed upsert never authorizes deletion. Preserve any base row for
|
|
1786
|
+
// that path, even if the same WAL record also listed it in `deletes`.
|
|
1787
|
+
const deletes = delta.deletes.filter((key) => !invalidPaths.has(key));
|
|
1788
|
+
return { ...delta, upserts, deletes };
|
|
1789
|
+
}
|
|
1790
|
+
function quarantineIncompleteAreaJournalRow(slug, key, entry) {
|
|
1791
|
+
const delta = {
|
|
1792
|
+
version: JOURNAL_VERSION_CURRENT,
|
|
1793
|
+
upserts: { [key]: entry },
|
|
1794
|
+
deletes: [],
|
|
1795
|
+
};
|
|
1796
|
+
quarantineIncompleteJournalUpserts(slug, undefined, { sequence: 0, type: JOURNAL_DELTA_RECORD_TYPE, payload: { source: "area-ledger-row" } }, delta, true);
|
|
1797
|
+
}
|
|
1798
|
+
function isAlreadyExistsError(error) {
|
|
1799
|
+
return typeof error === "object" && error !== null && "code" in error &&
|
|
1800
|
+
error.code === "EEXIST";
|
|
1801
|
+
}
|
|
1802
|
+
function quarantineSidecarMatches(filePath, expectedContent) {
|
|
1803
|
+
try {
|
|
1804
|
+
return fs.readFileSync(filePath, "utf8") === expectedContent;
|
|
1805
|
+
}
|
|
1806
|
+
catch {
|
|
1807
|
+
return false;
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
function writeJournalRecoveryEvent(event, errorClass, companyUid) {
|
|
1811
|
+
try {
|
|
1812
|
+
process.stderr.write(`${JSON.stringify({
|
|
1813
|
+
type: "error",
|
|
1814
|
+
diagnostic: true,
|
|
1815
|
+
event,
|
|
1816
|
+
errorClass,
|
|
1817
|
+
...(companyUid === undefined ? {} : { companyUid }),
|
|
1818
|
+
})}\n`);
|
|
1819
|
+
}
|
|
1820
|
+
catch {
|
|
1821
|
+
// A diagnostic event must not break recovery or sync.
|
|
1822
|
+
process.emitWarning(`journal recovery diagnostic could not be emitted (${errorClass})`);
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1594
1825
|
function isJournal(value) {
|
|
1595
1826
|
return typeof value === "object" && value !== null &&
|
|
1596
1827
|
(value.version === "1" || value.version === "2") &&
|
|
@@ -2237,7 +2468,7 @@ function ensureJournalLocator(slug) {
|
|
|
2237
2468
|
*/
|
|
2238
2469
|
export function repairJournalStateIfNeeded(slug, options = {}) {
|
|
2239
2470
|
const safeSlug = sanitizeSlug(slug);
|
|
2240
|
-
const storeOptions = journalStoreOptions(safeSlug, emptyJournal());
|
|
2471
|
+
const storeOptions = journalStoreOptions(safeSlug, emptyJournal(), options.companyUid);
|
|
2241
2472
|
if (options.maxWalBytesBeforeCompaction !== undefined) {
|
|
2242
2473
|
storeOptions.maxWalBytesBeforeCompaction = options.maxWalBytesBeforeCompaction;
|
|
2243
2474
|
}
|