@cleocode/core 2026.10.6 → 2026.10.7
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/build-provenance.json +2 -2
- package/dist/cloud/http.d.ts +8 -0
- package/dist/cloud/http.d.ts.map +1 -1
- package/dist/cloud/http.js +11 -0
- package/dist/cloud/http.js.map +1 -1
- package/dist/cloud/journal.d.ts +6 -0
- package/dist/cloud/journal.d.ts.map +1 -1
- package/dist/cloud/journal.js +1 -1
- package/dist/cloud/journal.js.map +1 -1
- package/dist/cloud/nexus-cloud-status.d.ts.map +1 -1
- package/dist/cloud/nexus-cloud-status.js +3 -1
- package/dist/cloud/nexus-cloud-status.js.map +1 -1
- package/dist/cloud/nexus-vault.d.ts +86 -1
- package/dist/cloud/nexus-vault.d.ts.map +1 -1
- package/dist/cloud/nexus-vault.js +875 -98
- package/dist/cloud/nexus-vault.js.map +1 -1
- package/dist/docs/doctor.d.ts +183 -0
- package/dist/docs/doctor.d.ts.map +1 -0
- package/dist/docs/doctor.js +537 -0
- package/dist/docs/doctor.js.map +1 -0
- package/dist/doctor/system-health.d.ts +189 -0
- package/dist/doctor/system-health.d.ts.map +1 -0
- package/dist/doctor/system-health.js +783 -0
- package/dist/doctor/system-health.js.map +1 -0
- package/dist/git/hooks-install.d.ts +17 -34
- package/dist/git/hooks-install.d.ts.map +1 -1
- package/dist/git/hooks-install.js +173 -70
- package/dist/git/hooks-install.js.map +1 -1
- package/dist/harness/ask-enforce.d.ts +43 -0
- package/dist/harness/ask-enforce.d.ts.map +1 -0
- package/dist/harness/ask-enforce.js +165 -0
- package/dist/harness/ask-enforce.js.map +1 -0
- package/dist/hooks.d.ts +8 -30
- package/dist/hooks.d.ts.map +1 -1
- package/dist/hooks.js +36 -116
- package/dist/hooks.js.map +1 -1
- package/dist/init.d.ts +2 -1
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +5 -14
- package/dist/init.js.map +1 -1
- package/dist/injection.d.ts +2 -2
- package/dist/injection.d.ts.map +1 -1
- package/dist/injection.js +108 -10
- package/dist/injection.js.map +1 -1
- package/dist/internal.d.ts +1 -1
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +1 -1
- package/dist/internal.js.map +1 -1
- package/dist/issue/create.d.ts +4 -0
- package/dist/issue/create.d.ts.map +1 -1
- package/dist/issue/create.js +12 -0
- package/dist/issue/create.js.map +1 -1
- package/dist/issue/index.d.ts +1 -1
- package/dist/issue/index.d.ts.map +1 -1
- package/dist/issue/index.js +1 -1
- package/dist/issue/index.js.map +1 -1
- package/dist/orchestrate/spawn-ops.d.ts.map +1 -1
- package/dist/orchestrate/spawn-ops.js +8 -2
- package/dist/orchestrate/spawn-ops.js.map +1 -1
- package/dist/orchestration/lean-change.d.ts +15 -0
- package/dist/orchestration/lean-change.d.ts.map +1 -0
- package/dist/orchestration/lean-change.js +17 -0
- package/dist/orchestration/lean-change.js.map +1 -0
- package/dist/orchestration/spawn-prompt.d.ts.map +1 -1
- package/dist/orchestration/spawn-prompt.js +6 -4
- package/dist/orchestration/spawn-prompt.js.map +1 -1
- package/dist/release/ci-evidence.d.ts +59 -0
- package/dist/release/ci-evidence.d.ts.map +1 -1
- package/dist/release/ci-evidence.js +150 -5
- package/dist/release/ci-evidence.js.map +1 -1
- package/dist/release/plan.d.ts.map +1 -1
- package/dist/release/plan.js +11 -2
- package/dist/release/plan.js.map +1 -1
- package/dist/release/pr-evidence.d.ts +15 -0
- package/dist/release/pr-evidence.d.ts.map +1 -1
- package/dist/release/pr-evidence.js +42 -1
- package/dist/release/pr-evidence.js.map +1 -1
- package/dist/release/reconcile.js +2 -2
- package/dist/release/reconcile.js.map +1 -1
- package/dist/release/verify-provenance.js +2 -2
- package/dist/release/verify-provenance.js.map +1 -1
- package/dist/resources/admission-ledger.d.ts +8 -0
- package/dist/resources/admission-ledger.d.ts.map +1 -1
- package/dist/resources/admission-ledger.js +5 -0
- package/dist/resources/admission-ledger.js.map +1 -1
- package/dist/resources/claim-keepalive.d.ts +34 -0
- package/dist/resources/claim-keepalive.d.ts.map +1 -0
- package/dist/resources/claim-keepalive.js +49 -0
- package/dist/resources/claim-keepalive.js.map +1 -0
- package/dist/resources/governor.d.ts.map +1 -1
- package/dist/resources/governor.js +3 -0
- package/dist/resources/governor.js.map +1 -1
- package/dist/scaffold/ensure-config.js +1 -1
- package/dist/scaffold/ensure-config.js.map +1 -1
- package/dist/store/attachment-store.d.ts.map +1 -1
- package/dist/store/attachment-store.js +2 -1
- package/dist/store/attachment-store.js.map +1 -1
- package/dist/store/restore-marker.d.ts +50 -7
- package/dist/store/restore-marker.d.ts.map +1 -1
- package/dist/store/restore-marker.js +77 -25
- package/dist/store/restore-marker.js.map +1 -1
- package/dist/store/row-identity-registry.d.ts +6 -4
- package/dist/store/row-identity-registry.d.ts.map +1 -1
- package/dist/store/row-identity-registry.js +156 -33
- package/dist/store/row-identity-registry.js.map +1 -1
- package/dist/store/schema/cleo-project/provenance-orphans.d.ts +16 -0
- package/dist/store/schema/cleo-project/provenance-orphans.d.ts.map +1 -1
- package/dist/store/schema/cleo-project/provenance-orphans.js +4 -1
- package/dist/store/schema/cleo-project/provenance-orphans.js.map +1 -1
- package/dist/store/schema/cleo-shared/brain.d.ts +240 -0
- package/dist/store/schema/cleo-shared/brain.d.ts.map +1 -1
- package/dist/store/schema/cleo-shared/brain.js +69 -0
- package/dist/store/schema/cleo-shared/brain.js.map +1 -1
- package/dist/store/schema/tasks.d.ts +1 -1
- package/dist/store/schema/tasks.d.ts.map +1 -1
- package/dist/store/schema/tasks.js +9 -1
- package/dist/store/schema/tasks.js.map +1 -1
- package/dist/store/sync/apply/applier.d.ts.map +1 -1
- package/dist/store/sync/apply/applier.js +85 -3
- package/dist/store/sync/apply/applier.js.map +1 -1
- package/dist/store/sync/apply/parent-delete.d.ts.map +1 -1
- package/dist/store/sync/apply/parent-delete.js +4 -0
- package/dist/store/sync/apply/parent-delete.js.map +1 -1
- package/dist/store/sync/capture.d.ts +8 -0
- package/dist/store/sync/capture.d.ts.map +1 -1
- package/dist/store/sync/capture.js +20 -0
- package/dist/store/sync/capture.js.map +1 -1
- package/dist/store/sync/clock-store.d.ts +21 -0
- package/dist/store/sync/clock-store.d.ts.map +1 -1
- package/dist/store/sync/clock-store.js +47 -0
- package/dist/store/sync/clock-store.js.map +1 -1
- package/dist/store/sync/conflicts.d.ts +13 -0
- package/dist/store/sync/conflicts.d.ts.map +1 -1
- package/dist/store/sync/conflicts.js +22 -0
- package/dist/store/sync/conflicts.js.map +1 -1
- package/dist/store/sync/genesis.d.ts +113 -12
- package/dist/store/sync/genesis.d.ts.map +1 -1
- package/dist/store/sync/genesis.js +325 -27
- package/dist/store/sync/genesis.js.map +1 -1
- package/dist/store/sync/merge/types.d.ts +8 -2
- package/dist/store/sync/merge/types.d.ts.map +1 -1
- package/dist/store/sync/pull.d.ts +192 -0
- package/dist/store/sync/pull.d.ts.map +1 -0
- package/dist/store/sync/pull.js +429 -0
- package/dist/store/sync/pull.js.map +1 -0
- package/dist/store/sync/push.d.ts +110 -0
- package/dist/store/sync/push.d.ts.map +1 -0
- package/dist/store/sync/push.js +172 -0
- package/dist/store/sync/push.js.map +1 -0
- package/dist/store/sync/replay-pin.d.ts +70 -0
- package/dist/store/sync/replay-pin.d.ts.map +1 -0
- package/dist/store/sync/replay-pin.js +172 -0
- package/dist/store/sync/replay-pin.js.map +1 -0
- package/dist/store/sync/sealer.d.ts.map +1 -1
- package/dist/store/sync/sealer.js +6 -2
- package/dist/store/sync/sealer.js.map +1 -1
- package/dist/store/sync/timestamp-canon.d.ts.map +1 -1
- package/dist/store/sync/timestamp-canon.js +15 -0
- package/dist/store/sync/timestamp-canon.js.map +1 -1
- package/dist/store/table-classification.d.ts.map +1 -1
- package/dist/store/table-classification.js +51 -15
- package/dist/store/table-classification.js.map +1 -1
- package/dist/store/tasks-schema.d.ts +1 -1
- package/dist/store/tasks-schema.d.ts.map +1 -1
- package/dist/store/tasks-schema.js +1 -1
- package/dist/store/tasks-schema.js.map +1 -1
- package/dist/store/twin-collapse.d.ts.map +1 -1
- package/dist/store/twin-collapse.js +5 -0
- package/dist/store/twin-collapse.js.map +1 -1
- package/dist/store/vault-manifest.d.ts +7 -0
- package/dist/store/vault-manifest.d.ts.map +1 -1
- package/dist/store/vault-manifest.js +4 -0
- package/dist/store/vault-manifest.js.map +1 -1
- package/dist/tasks/ac-table.d.ts +17 -0
- package/dist/tasks/ac-table.d.ts.map +1 -1
- package/dist/tasks/ac-table.js +49 -0
- package/dist/tasks/ac-table.js.map +1 -1
- package/dist/tasks/affected-scope.d.ts +52 -3
- package/dist/tasks/affected-scope.d.ts.map +1 -1
- package/dist/tasks/affected-scope.js +94 -3
- package/dist/tasks/affected-scope.js.map +1 -1
- package/dist/tasks/change-set.d.ts.map +1 -1
- package/dist/tasks/change-set.js +9 -5
- package/dist/tasks/change-set.js.map +1 -1
- package/dist/tasks/complete-ci.d.ts +2 -0
- package/dist/tasks/complete-ci.d.ts.map +1 -1
- package/dist/tasks/complete-ci.js +26 -7
- package/dist/tasks/complete-ci.js.map +1 -1
- package/dist/tasks/complete.d.ts.map +1 -1
- package/dist/tasks/complete.js +10 -0
- package/dist/tasks/complete.js.map +1 -1
- package/dist/tasks/done-plan.d.ts.map +1 -1
- package/dist/tasks/done-plan.js +63 -6
- package/dist/tasks/done-plan.js.map +1 -1
- package/dist/tasks/evidence.d.ts +3 -0
- package/dist/tasks/evidence.d.ts.map +1 -1
- package/dist/tasks/evidence.js +106 -2
- package/dist/tasks/evidence.js.map +1 -1
- package/dist/tasks/gate-preview.d.ts +19 -1
- package/dist/tasks/gate-preview.d.ts.map +1 -1
- package/dist/tasks/gate-preview.js +44 -2
- package/dist/tasks/gate-preview.js.map +1 -1
- package/dist/tasks/gate-runner.d.ts +16 -0
- package/dist/tasks/gate-runner.d.ts.map +1 -1
- package/dist/tasks/gate-runner.js +25 -2
- package/dist/tasks/gate-runner.js.map +1 -1
- package/dist/tasks/qa-run-binding.d.ts +88 -0
- package/dist/tasks/qa-run-binding.d.ts.map +1 -0
- package/dist/tasks/qa-run-binding.js +188 -0
- package/dist/tasks/qa-run-binding.js.map +1 -0
- package/dist/tasks/req.d.ts +50 -0
- package/dist/tasks/req.d.ts.map +1 -1
- package/dist/tasks/req.js +99 -1
- package/dist/tasks/req.js.map +1 -1
- package/dist/tasks/test-run-binding.d.ts +29 -0
- package/dist/tasks/test-run-binding.d.ts.map +1 -1
- package/dist/tasks/test-run-binding.js +47 -23
- package/dist/tasks/test-run-binding.js.map +1 -1
- package/dist/tasks/tool-resolver.d.ts +9 -0
- package/dist/tasks/tool-resolver.d.ts.map +1 -1
- package/dist/tasks/tool-resolver.js +14 -3
- package/dist/tasks/tool-resolver.js.map +1 -1
- package/dist/tasks/tool-semaphore.d.ts.map +1 -1
- package/dist/tasks/tool-semaphore.js +3 -0
- package/dist/tasks/tool-semaphore.js.map +1 -1
- package/dist/upgrade.d.ts.map +1 -1
- package/dist/upgrade.js +10 -1
- package/dist/upgrade.js.map +1 -1
- package/dist/validation/engine-ops.d.ts.map +1 -1
- package/dist/validation/engine-ops.js +14 -3
- package/dist/validation/engine-ops.js.map +1 -1
- package/migrations/drizzle-cleo-global/20261010130000_t12895-brain-natural-uids/migration.sql +28 -0
- package/migrations/drizzle-cleo-global/20261010140000_t12896-brain-autoinc-uids/migration.sql +54 -0
- package/migrations/drizzle-cleo-project/20261010130000_t12895-brain-natural-uids/migration.sql +36 -0
- package/migrations/drizzle-cleo-project/20261010140000_t12896-brain-autoinc-uids/migration.sql +58 -0
- package/migrations/sync-journal/20261008120000_t12343-pull-cursor/migration.sql +28 -0
- package/migrations/sync-journal/20261009120000_t13318-seen-floor/migration.sql +41 -0
- package/package.json +18 -18
- package/templates/CLEO-INJECTION.md +5 -3
- package/templates/CLEO-REFERENCE.md +1 -1
- package/templates/git-hooks/legacy-hashes.json +20 -0
- package/templates/git-hooks/pre-commit +1 -0
|
@@ -52,11 +52,12 @@
|
|
|
52
52
|
* @epic T12322
|
|
53
53
|
*/
|
|
54
54
|
import { execFileSync } from 'node:child_process';
|
|
55
|
+
import { createHash } from 'node:crypto';
|
|
55
56
|
import fs from 'node:fs';
|
|
56
57
|
import os from 'node:os';
|
|
57
58
|
import path from 'node:path';
|
|
58
|
-
import { ListCheckpointsResult, ListLeasesResult, } from '@cleocode/contracts/cloud';
|
|
59
|
-
import { nexusCloudDevicePageSchema } from '@cleocode/contracts/nexus-cloud.js';
|
|
59
|
+
import { ListCheckpointsResult, ListLeasesResult, ReplayPin, } from '@cleocode/contracts/cloud';
|
|
60
|
+
import { nexusCloudDevicePageSchema, nexusCloudStatusSchema, } from '@cleocode/contracts/nexus-cloud.js';
|
|
60
61
|
import { NEXUS_VAULT_LEASE_ROLE, nexusLeaseAcquireSchema, nexusStreamHeadSchema, } from '@cleocode/contracts/nexus-vault.js';
|
|
61
62
|
import { SYNC_SCHEMA_VERSION } from '@cleocode/contracts/sync-schema.js';
|
|
62
63
|
import { readDeclaredProjectIdentity } from '@cleocode/paths';
|
|
@@ -69,15 +70,25 @@ import { importPortableBundle } from '../store/portable-bundle-import.js';
|
|
|
69
70
|
import { integrityCheck, PROJECT_SECTION_RULES, scanSection, sha256File, } from '../store/portable-bundle-scan.js';
|
|
70
71
|
import { writeRestoreMarker } from '../store/restore-marker.js';
|
|
71
72
|
import { FIRST_OPEN_LOCK_SUFFIX } from '../store/sqlite.js';
|
|
72
|
-
import {
|
|
73
|
+
import { isSyncFlagOn, UNRELEASED_FLAGS } from '../store/sync/flags.js';
|
|
74
|
+
import { completeGenesis, cutGenesisWithSnapshot, GenesisRacedError, GenesisTakenOverError, genesisCutOf, genesisPending, joinStream, readGenesisCut, } from '../store/sync/genesis.js';
|
|
75
|
+
import { pullStream, readStreamCursor } from '../store/sync/pull.js';
|
|
76
|
+
import { pushStream } from '../store/sync/push.js';
|
|
77
|
+
import { replayPinOf } from '../store/sync/replay-pin.js';
|
|
78
|
+
import { activeReplica, fileIdentity, readActiveReplicaId, rebindAfterVaultRestore, } from '../store/sync/replica.js';
|
|
79
|
+
import { ReplicaRegistry } from '../store/sync/replica-registry.js';
|
|
80
|
+
import { sealPending } from '../store/sync/sealer.js';
|
|
81
|
+
import { firstBadTxnSignature, signTxn } from '../store/sync/txn-signing.js';
|
|
73
82
|
import { buildVaultManifest, carryMachineState, compareVaultManifests, emptyVaultTableHash, isVaultAnnotationKey, isVaultForkManifest, sameVaultManifest, VAULT_FILES_KEY, VAULT_FORK_KEY, VAULT_FORMAT_KEY, VAULT_GLOBAL_CONFIG_LOCAL_KEYS, VAULT_MANIFEST_FORMAT_VERSION, vaultDatabaseEntry, vaultDatabaseKey, vaultFileDigest, vaultFilesEntry, vaultForkEntry, vaultFormatEntry, vaultManifestFormat, vaultStripColumns, } from '../store/vault-manifest.js';
|
|
74
83
|
import { foreignWriterLeases, storeOpenElsewhere } from '../store/writer-lease.js';
|
|
75
|
-
import { deriveKey } from './crypto.js';
|
|
84
|
+
import { deriveKey, uuidv7 } from './crypto.js';
|
|
76
85
|
import { NexusError } from './http.js';
|
|
77
86
|
import { cursorFromCheckpoint, initialPullCursor, Journal } from './journal.js';
|
|
87
|
+
import { signerKeys } from './keys.js';
|
|
78
88
|
import { manifestVersion } from './manifest-check.js';
|
|
79
89
|
import { canonicalGlobalReplicaBinder } from './nexus-attach.js';
|
|
80
90
|
import { NexusAccountError } from './nexus-auth.js';
|
|
91
|
+
import { nexusQueryPath } from './nexus-cloud.js';
|
|
81
92
|
import { nexusApiErrorToAccountError } from './nexus-enrol.js';
|
|
82
93
|
import { readNexusProjectLink } from './nexus-link.js';
|
|
83
94
|
import { connectNexusVault, nexusHomeDataKey, nexusProjectDataKey, unlockNexusAccountKey, } from './nexus-vault-keys.js';
|
|
@@ -553,12 +564,29 @@ function compareWithSynced(local, snapshot, git, changes) {
|
|
|
553
564
|
* hold values the snapshot strips, and a restore takes these tables from the
|
|
554
565
|
* live store anyway.
|
|
555
566
|
*/
|
|
567
|
+
/**
|
|
568
|
+
* The merge state a journal checkpoint carries for the rows it holds (journal
|
|
569
|
+
* spec §2.10: row meta travels inside checkpoint bundles): a restore keeps
|
|
570
|
+
* the snapshot's, never this machine's, so the restored rows keep their HLCs
|
|
571
|
+
* and a JOIN can push and pull them (T13312).
|
|
572
|
+
*/
|
|
573
|
+
const JOURNAL_SNAPSHOT_CARRIED = new Set([
|
|
574
|
+
'_sync_row_meta',
|
|
575
|
+
'_sync_field_leave',
|
|
576
|
+
]);
|
|
556
577
|
const VAULT_CLEARED_JOURNAL_TABLES = [
|
|
557
578
|
'_sync_capture',
|
|
558
579
|
'_sync_undo',
|
|
559
580
|
'_sync_frame',
|
|
560
581
|
'_sync_quarantine',
|
|
561
582
|
'_sync_apply_intent',
|
|
583
|
+
// Where this device's pull stands (T12343 S5-1): a restored store resumes
|
|
584
|
+
// from the checkpoint it restored, never from another device's position.
|
|
585
|
+
'_sync_cursor',
|
|
586
|
+
'_sync_seen_txn',
|
|
587
|
+
// Its floor goes with it: a floor without its seen rows would refuse
|
|
588
|
+
// every transaction below it (T13318).
|
|
589
|
+
'_sync_seen_floor',
|
|
562
590
|
];
|
|
563
591
|
/**
|
|
564
592
|
* Export the store the way the vault snapshots it: no secrets, no `strip`
|
|
@@ -770,6 +798,120 @@ function tempDir(prefix) {
|
|
|
770
798
|
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
771
799
|
}
|
|
772
800
|
/** `cleo cloud push`. */
|
|
801
|
+
/**
|
|
802
|
+
* Register a checkpoint of `manifest` over `parent` (T12975): replay the
|
|
803
|
+
* window since the parent, append this replica's count-delta segment when the
|
|
804
|
+
* store's rows differ from the parent plus the window, then push the bundle
|
|
805
|
+
* as the checkpoint. A segment another device appends in between makes the
|
|
806
|
+
* server's count or replica-map check fail: replay once more and retry, once.
|
|
807
|
+
*
|
|
808
|
+
* @returns The stored checkpoint, the delta segment's seq (or null), and the
|
|
809
|
+
* cursor the checkpoint was signed with.
|
|
810
|
+
*/
|
|
811
|
+
async function appendCheckpoint(a) {
|
|
812
|
+
const { conn, t, journal, parent, manifest, bundlePath, replicaId } = a;
|
|
813
|
+
let deltaSegmentSeq = null;
|
|
814
|
+
let cp = null;
|
|
815
|
+
let pushedWith = null;
|
|
816
|
+
// A segment another device appends between our replay and our checkpoint
|
|
817
|
+
// makes the server's count or replica-map check fail: replay once more
|
|
818
|
+
// (our own delta is then part of the history) and retry (T12975). The
|
|
819
|
+
// retry seals the bundle under a new checkpoint id (the AAD covers it),
|
|
820
|
+
// so the first attempt's uploaded blob is left unreferenced: one orphan
|
|
821
|
+
// per retry, for the server's blob garbage collection.
|
|
822
|
+
for (let attempt = 0; cp === null; attempt++) {
|
|
823
|
+
const replay = await replaySegments(journal, parent ? cursorFromCheckpoint(parent) : initialPullCursor(), a.signers);
|
|
824
|
+
let cursor = replay.cursor;
|
|
825
|
+
// A segment of a newer sync schema appended since the head was read: refused
|
|
826
|
+
// before our delta segment (the push hands the lease back).
|
|
827
|
+
const windowAhead = aheadOfThisCleo(t, replay.maxSchemaVersion, null);
|
|
828
|
+
if (windowAhead)
|
|
829
|
+
throw windowAhead;
|
|
830
|
+
if (parent) {
|
|
831
|
+
const between = {};
|
|
832
|
+
for (const d of replay.deltas) {
|
|
833
|
+
for (const [table, v] of Object.entries(d)) {
|
|
834
|
+
const p = between[table] ?? { created: 0, deleted: 0 };
|
|
835
|
+
between[table] = { created: p.created + v.created, deleted: p.deleted + v.deleted };
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
const need = {};
|
|
839
|
+
const deltas = {};
|
|
840
|
+
for (const table of new Set([
|
|
841
|
+
...Object.keys(manifest.tables),
|
|
842
|
+
...Object.keys(parent.manifest.tables),
|
|
843
|
+
...Object.keys(between),
|
|
844
|
+
])) {
|
|
845
|
+
const expected = (parent.manifest.tables[table]?.rows ?? 0) +
|
|
846
|
+
(between[table]?.created ?? 0) -
|
|
847
|
+
(between[table]?.deleted ?? 0);
|
|
848
|
+
const diff = (manifest.tables[table]?.rows ?? 0) - expected;
|
|
849
|
+
if (diff !== 0) {
|
|
850
|
+
need[table] = diff;
|
|
851
|
+
deltas[table] = { created: Math.max(0, diff), deleted: Math.max(0, -diff) };
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
if (Object.keys(deltas).length > 0) {
|
|
855
|
+
// A replica's first segment is replicaSeq 0 (the server and every puller enforce it).
|
|
856
|
+
const mine = cursor.replicas[replicaId];
|
|
857
|
+
const replicaSeq = mine ? mine.replicaSeq + 1 : 0;
|
|
858
|
+
const hlc = `${String(Date.now()).padStart(13, '0')}-000000-${replicaId}`;
|
|
859
|
+
const appended = await journal.push(replicaSeq, Buffer.from(JSON.stringify({
|
|
860
|
+
kind: 'cleo-vault-delta/v1',
|
|
861
|
+
base: parent.checkpointId,
|
|
862
|
+
tables: need,
|
|
863
|
+
}), 'utf8'), {
|
|
864
|
+
opCount: 1,
|
|
865
|
+
hlcMin: hlc,
|
|
866
|
+
hlcMax: hlc,
|
|
867
|
+
deltas,
|
|
868
|
+
schemaVersion: SYNC_SCHEMA_VERSION,
|
|
869
|
+
});
|
|
870
|
+
deltaSegmentSeq = appended.seq;
|
|
871
|
+
cursor = {
|
|
872
|
+
after: appended.seq,
|
|
873
|
+
knowsAllReplicas: cursor.knowsAllReplicas,
|
|
874
|
+
replicas: {
|
|
875
|
+
...cursor.replicas,
|
|
876
|
+
[replicaId]: { deviceId: conn.deviceId, replicaSeq },
|
|
877
|
+
},
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
try {
|
|
882
|
+
cp = await journal.pushCheckpoint({
|
|
883
|
+
bundle: fs.readFileSync(bundlePath),
|
|
884
|
+
manifest,
|
|
885
|
+
cursor,
|
|
886
|
+
parentCheckpointId: parent?.checkpointId ?? null,
|
|
887
|
+
...(a.mintCheckpointId ? { checkpointId: a.mintCheckpointId() } : {}),
|
|
888
|
+
});
|
|
889
|
+
pushedWith = cursor;
|
|
890
|
+
}
|
|
891
|
+
catch (err) {
|
|
892
|
+
if (err instanceof NexusError && err.code === 'E_LINEAGE') {
|
|
893
|
+
throw vaultError('E_NEXUS_VAULT_BEHIND', 'another device pushed a snapshot while this one was uploading', 'run `cleo cloud pull`, then push again');
|
|
894
|
+
}
|
|
895
|
+
// The server's v3 ratchet (reason `stream-v3`). The check above refuses a v3 head before
|
|
896
|
+
// anything is written; this maps a server whose head this client did not see as v3.
|
|
897
|
+
if (err instanceof NexusError && err.code === 'E_STREAM_VERSION') {
|
|
898
|
+
throw streamUpgradedError(`the server refused the snapshot: ${t.streamId} takes only checkpoint/v3 snapshots (${err.message})`, JOURNAL_STREAM_REMEDY);
|
|
899
|
+
}
|
|
900
|
+
const countRefusal = err instanceof NexusError && (err.code === 'E_REGRESSION' || err.code === 'E_VALIDATION');
|
|
901
|
+
if (countRefusal && attempt === 0)
|
|
902
|
+
continue;
|
|
903
|
+
if (countRefusal) {
|
|
904
|
+
throw vaultError('E_NEXUS_VAULT_REFUSED', `the server refused the snapshot: ${err.message}`);
|
|
905
|
+
}
|
|
906
|
+
throw err;
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
if (cp === null || pushedWith === null) {
|
|
910
|
+
// @sync-invariant none:local-only unreachable: the loop exits only once a checkpoint is stored
|
|
911
|
+
throw new Error('appendCheckpoint: no checkpoint stored');
|
|
912
|
+
}
|
|
913
|
+
return { cp, deltaSegmentSeq, cursor: pushedWith };
|
|
914
|
+
}
|
|
773
915
|
async function pushNexusVaultImpl(opts = {}) {
|
|
774
916
|
const conn = await connectNexusVault(opts);
|
|
775
917
|
const key = await unlockNexusAccountKey(conn);
|
|
@@ -780,6 +922,12 @@ async function pushNexusVaultImpl(opts = {}) {
|
|
|
780
922
|
if (!replicaId) {
|
|
781
923
|
throw vaultError('E_NEXUS_VAULT_NOT_LINKED', 'this copy of the project is not attached from this device', 'run `cleo project link`');
|
|
782
924
|
}
|
|
925
|
+
// Past its genesis cut, this store's changes travel through the change
|
|
926
|
+
// journal: a vault delta would count them twice (T12343 S4-1b).
|
|
927
|
+
const cut = await readGenesisCut(t.dbPath, t.streamId);
|
|
928
|
+
if (cut !== undefined) {
|
|
929
|
+
throw streamUpgradedError(`this store started ${t.streamId}'s change journal (genesis cut at capture ${cut}); its changes are pushed as journal segments, not vault snapshots`, `nothing was written; ${JOURNAL_STREAM_REMEDY}`);
|
|
930
|
+
}
|
|
783
931
|
const force = opts.force === true;
|
|
784
932
|
const journal = journalFor(conn, t);
|
|
785
933
|
const head = await streamHead(conn, t.streamId);
|
|
@@ -874,100 +1022,16 @@ async function pushNexusVaultImpl(opts = {}) {
|
|
|
874
1022
|
try {
|
|
875
1023
|
// A crash before the snapshot is recorded must not leave this store behind its own push.
|
|
876
1024
|
conn.state.markPushInFlight(conn.apiUrl, conn.userId, t.streamId, t.storeRoot, parent?.checkpointId ?? null, { gitTracked: t.scope === 'project' ? gitTracked : [], files: syncFiles(files) });
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
let cursor = replay.cursor;
|
|
888
|
-
// A segment of a newer sync schema appended since the head was read: refused
|
|
889
|
-
// before our delta segment (the push hands the lease back).
|
|
890
|
-
const windowAhead = aheadOfThisCleo(t, replay.maxSchemaVersion, null);
|
|
891
|
-
if (windowAhead)
|
|
892
|
-
throw windowAhead;
|
|
893
|
-
if (parent) {
|
|
894
|
-
const between = {};
|
|
895
|
-
for (const d of replay.deltas) {
|
|
896
|
-
for (const [table, v] of Object.entries(d)) {
|
|
897
|
-
const p = between[table] ?? { created: 0, deleted: 0 };
|
|
898
|
-
between[table] = { created: p.created + v.created, deleted: p.deleted + v.deleted };
|
|
899
|
-
}
|
|
900
|
-
}
|
|
901
|
-
const need = {};
|
|
902
|
-
const deltas = {};
|
|
903
|
-
for (const table of new Set([
|
|
904
|
-
...Object.keys(manifest.tables),
|
|
905
|
-
...Object.keys(parent.manifest.tables),
|
|
906
|
-
...Object.keys(between),
|
|
907
|
-
])) {
|
|
908
|
-
const expected = (parent.manifest.tables[table]?.rows ?? 0) +
|
|
909
|
-
(between[table]?.created ?? 0) -
|
|
910
|
-
(between[table]?.deleted ?? 0);
|
|
911
|
-
const diff = (manifest.tables[table]?.rows ?? 0) - expected;
|
|
912
|
-
if (diff !== 0) {
|
|
913
|
-
need[table] = diff;
|
|
914
|
-
deltas[table] = { created: Math.max(0, diff), deleted: Math.max(0, -diff) };
|
|
915
|
-
}
|
|
916
|
-
}
|
|
917
|
-
if (Object.keys(deltas).length > 0) {
|
|
918
|
-
// A replica's first segment is replicaSeq 0 (the server and every puller enforce it).
|
|
919
|
-
const mine = cursor.replicas[replicaId];
|
|
920
|
-
const replicaSeq = mine ? mine.replicaSeq + 1 : 0;
|
|
921
|
-
const hlc = `${String(Date.now()).padStart(13, '0')}-000000-${replicaId}`;
|
|
922
|
-
const appended = await journal.push(replicaSeq, Buffer.from(JSON.stringify({
|
|
923
|
-
kind: 'cleo-vault-delta/v1',
|
|
924
|
-
base: parent.checkpointId,
|
|
925
|
-
tables: need,
|
|
926
|
-
}), 'utf8'), {
|
|
927
|
-
opCount: 1,
|
|
928
|
-
hlcMin: hlc,
|
|
929
|
-
hlcMax: hlc,
|
|
930
|
-
deltas,
|
|
931
|
-
schemaVersion: SYNC_SCHEMA_VERSION,
|
|
932
|
-
});
|
|
933
|
-
deltaSegmentSeq = appended.seq;
|
|
934
|
-
cursor = {
|
|
935
|
-
after: appended.seq,
|
|
936
|
-
knowsAllReplicas: cursor.knowsAllReplicas,
|
|
937
|
-
replicas: {
|
|
938
|
-
...cursor.replicas,
|
|
939
|
-
[replicaId]: { deviceId: conn.deviceId, replicaSeq },
|
|
940
|
-
},
|
|
941
|
-
};
|
|
942
|
-
}
|
|
943
|
-
}
|
|
944
|
-
try {
|
|
945
|
-
cp = await journal.pushCheckpoint({
|
|
946
|
-
bundle: fs.readFileSync(bundlePath),
|
|
947
|
-
manifest,
|
|
948
|
-
cursor,
|
|
949
|
-
parentCheckpointId: parent?.checkpointId ?? null,
|
|
950
|
-
});
|
|
951
|
-
}
|
|
952
|
-
catch (err) {
|
|
953
|
-
if (err instanceof NexusError && err.code === 'E_LINEAGE') {
|
|
954
|
-
throw vaultError('E_NEXUS_VAULT_BEHIND', 'another device pushed a snapshot while this one was uploading', 'run `cleo cloud pull`, then push again');
|
|
955
|
-
}
|
|
956
|
-
// The server's v3 ratchet (reason `stream-v3`). The check above refuses a v3 head before
|
|
957
|
-
// anything is written; this maps a server whose head this client did not see as v3.
|
|
958
|
-
if (err instanceof NexusError && err.code === 'E_STREAM_VERSION') {
|
|
959
|
-
throw streamUpgradedError(`the server refused the snapshot: ${t.streamId} takes only checkpoint/v3 snapshots (${err.message})`, JOURNAL_STREAM_REMEDY);
|
|
960
|
-
}
|
|
961
|
-
const countRefusal = err instanceof NexusError &&
|
|
962
|
-
(err.code === 'E_REGRESSION' || err.code === 'E_VALIDATION');
|
|
963
|
-
if (countRefusal && attempt === 0)
|
|
964
|
-
continue;
|
|
965
|
-
if (countRefusal) {
|
|
966
|
-
throw vaultError('E_NEXUS_VAULT_REFUSED', `the server refused the snapshot: ${err.message}`);
|
|
967
|
-
}
|
|
968
|
-
throw err;
|
|
969
|
-
}
|
|
970
|
-
}
|
|
1025
|
+
const { cp, deltaSegmentSeq } = await appendCheckpoint({
|
|
1026
|
+
conn,
|
|
1027
|
+
t,
|
|
1028
|
+
journal,
|
|
1029
|
+
signers: key.signers,
|
|
1030
|
+
parent,
|
|
1031
|
+
manifest,
|
|
1032
|
+
bundlePath,
|
|
1033
|
+
replicaId,
|
|
1034
|
+
});
|
|
971
1035
|
saveSynced(conn, t, cp, { gitTracked, files: syncFiles(files) });
|
|
972
1036
|
if (opts.hold !== true)
|
|
973
1037
|
await releaseLeaseQuietly(conn, t);
|
|
@@ -994,6 +1058,651 @@ async function pushNexusVaultImpl(opts = {}) {
|
|
|
994
1058
|
fs.rmSync(work, { recursive: true, force: true });
|
|
995
1059
|
}
|
|
996
1060
|
}
|
|
1061
|
+
/**
|
|
1062
|
+
* Replace `dest` atomically (tmp file, then rename): a crash leaves the old
|
|
1063
|
+
* file or the new one, never a truncated one.
|
|
1064
|
+
*/
|
|
1065
|
+
function replaceFile(dest, from) {
|
|
1066
|
+
const tmp = `${dest}.${process.pid}.tmp`;
|
|
1067
|
+
if ('text' in from)
|
|
1068
|
+
fs.writeFileSync(tmp, from.text);
|
|
1069
|
+
else
|
|
1070
|
+
fs.copyFileSync(from.copyFrom, tmp);
|
|
1071
|
+
fs.renameSync(tmp, dest);
|
|
1072
|
+
}
|
|
1073
|
+
/** Where a cut's genesis bundle waits until its checkpoint is stored: beside the store, per stream. */
|
|
1074
|
+
function genesisBundlePaths(t) {
|
|
1075
|
+
const dir = path.join(path.dirname(t.dbPath), 'sync-genesis');
|
|
1076
|
+
const name = createHash('sha256').update(t.streamId).digest('hex').slice(0, 16);
|
|
1077
|
+
return {
|
|
1078
|
+
dir,
|
|
1079
|
+
bundle: path.join(dir, `${name}.cleobundle.tar.gz`),
|
|
1080
|
+
meta: path.join(dir, `${name}.json`),
|
|
1081
|
+
};
|
|
1082
|
+
}
|
|
1083
|
+
/** What a cut saved for its checkpoint push: the vault manifest, replay pin and git marks. */
|
|
1084
|
+
const GenesisSaved = z.object({
|
|
1085
|
+
cut: z.number().int().nonnegative(),
|
|
1086
|
+
vault: z.object({
|
|
1087
|
+
schemaVersion: z.number().int().positive(),
|
|
1088
|
+
tables: z.record(z.string(), z.object({ rows: z.number().int().nonnegative(), hash: z.string() })),
|
|
1089
|
+
}),
|
|
1090
|
+
replayPin: ReplayPin,
|
|
1091
|
+
gitTracked: z.array(z.string()),
|
|
1092
|
+
files: z.record(z.string(), z.string()),
|
|
1093
|
+
/** Every checkpoint id an upload of this genesis was attempted under (T13302). */
|
|
1094
|
+
checkpointIds: z.array(z.string()).default([]),
|
|
1095
|
+
});
|
|
1096
|
+
async function enableSyncPushImpl(opts = {}) {
|
|
1097
|
+
if (UNRELEASED_FLAGS.has('sync.push') && opts.allowUnreleased !== true) {
|
|
1098
|
+
throw vaultError('E_NEXUS_SYNC_REFUSED', 'sync.push is unreleased: its slices are still landing (T12343)', 'nothing was cut; push stays off until the owner releases it');
|
|
1099
|
+
}
|
|
1100
|
+
const conn = await connectNexusVault(opts);
|
|
1101
|
+
const key = await unlockNexusAccountKey(conn);
|
|
1102
|
+
const t = await resolveTarget(conn, key.masterKey, opts, 'push');
|
|
1103
|
+
const warnings = [...conn.warnings];
|
|
1104
|
+
const replicaId = t.replicaId;
|
|
1105
|
+
if (!replicaId) {
|
|
1106
|
+
throw vaultError('E_NEXUS_VAULT_NOT_LINKED', 'this copy of the project is not attached from this device', 'run `cleo project link`');
|
|
1107
|
+
}
|
|
1108
|
+
const { openDualScopeDbAtPath, getDualScopeNativeDb } = await import('../store/dual-scope-db.js');
|
|
1109
|
+
const db = getDualScopeNativeDb(t.scope === 'global'
|
|
1110
|
+
? await openDualScopeDbAtPath('global', t.dbPath)
|
|
1111
|
+
: await openDualScopeDbAtPath('project', t.dbPath));
|
|
1112
|
+
// The cut seals as the store's bound replica; the stream knows it by the link's.
|
|
1113
|
+
const bound = activeReplica(db, tableScopeOf(t))?.replicaId ?? null;
|
|
1114
|
+
if (bound !== replicaId) {
|
|
1115
|
+
throw vaultError('E_NEXUS_SYNC_REFUSED', `this store is bound to replica ${bound ?? '(none)'}, but ${t.streamId} knows it as ${replicaId}`, 'nothing was cut; relink the project (`cleo project link`) so both name one replica');
|
|
1116
|
+
}
|
|
1117
|
+
const saved = genesisBundlePaths(t);
|
|
1118
|
+
const cutBefore = genesisCutOf(db, t.streamId);
|
|
1119
|
+
const pendingCut = cutBefore !== undefined && genesisPending(db, t.streamId);
|
|
1120
|
+
// A cut whose checkpoint push failed kept its bundle: push that. A cut that
|
|
1121
|
+
// crashed before its bundle was saved is resumed (or re-cut) by
|
|
1122
|
+
// cutGenesisWithSnapshot (T13301).
|
|
1123
|
+
const resuming = pendingCut && fs.existsSync(saved.bundle) && fs.existsSync(saved.meta);
|
|
1124
|
+
const base = { apiUrl: conn.apiUrl, scope: t.scope, streamId: t.streamId };
|
|
1125
|
+
if (cutBefore !== undefined && !pendingCut) {
|
|
1126
|
+
return {
|
|
1127
|
+
...base,
|
|
1128
|
+
status: 'already',
|
|
1129
|
+
cut: cutBefore,
|
|
1130
|
+
sealed: 0,
|
|
1131
|
+
folded: 0,
|
|
1132
|
+
baselined: {},
|
|
1133
|
+
snapshot: null,
|
|
1134
|
+
deltaSegmentSeq: null,
|
|
1135
|
+
replicaSeqFloor: null,
|
|
1136
|
+
warnings,
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
1139
|
+
const journal = journalFor(conn, t);
|
|
1140
|
+
const head = await streamHead(conn, t.streamId);
|
|
1141
|
+
const checkpoints = head.headCheckpointId ? await listCheckpoints(conn, t.streamId) : [];
|
|
1142
|
+
const parent = checkpoints.find((c) => c.checkpointId === head.headCheckpointId) ?? null;
|
|
1143
|
+
if (head.headCheckpointId && !parent) {
|
|
1144
|
+
throw vaultError('E_NEXUS_VAULT_REFUSED', `the head snapshot ${head.headCheckpointId} is not in the stream's list`);
|
|
1145
|
+
}
|
|
1146
|
+
if (parent)
|
|
1147
|
+
journal.verifyCheckpoint(parent, key.signers);
|
|
1148
|
+
const ahead = aheadOfThisCleo(t, Math.max(head.maxSchemaVersion ?? 0, parent?.manifest.schemaVersion ?? 0), parent);
|
|
1149
|
+
if (ahead)
|
|
1150
|
+
throw ahead;
|
|
1151
|
+
// T13302: `genesis_pending` means "not confirmed stored", not "never
|
|
1152
|
+
// pushed". A push the server accepted before this process died left the
|
|
1153
|
+
// genesis checkpoint on the stream: adopt it, never cut a second genesis.
|
|
1154
|
+
if (pendingCut) {
|
|
1155
|
+
const kept = fs.existsSync(saved.meta)
|
|
1156
|
+
? GenesisSaved.parse(JSON.parse(fs.readFileSync(saved.meta, 'utf8')))
|
|
1157
|
+
: null;
|
|
1158
|
+
// Only this cut's own upload, by the id recorded before it was sent.
|
|
1159
|
+
const stored = checkpoints.find((c) => isJournalSnapshot(c) &&
|
|
1160
|
+
c.replicaId === replicaId &&
|
|
1161
|
+
(kept?.checkpointIds.includes(c.checkpointId) ?? false));
|
|
1162
|
+
if (stored) {
|
|
1163
|
+
journal.verifyCheckpoint(stored, key.signers);
|
|
1164
|
+
const replicaSeqFloor = stored.replicas[replicaId]?.lastReplicaSeq ?? null;
|
|
1165
|
+
saveSynced(conn, t, stored, { gitTracked: kept?.gitTracked ?? [], files: kept?.files ?? {} });
|
|
1166
|
+
completeGenesis(db, {
|
|
1167
|
+
stream: t.streamId,
|
|
1168
|
+
replica: replicaId,
|
|
1169
|
+
replicaSeqFloor,
|
|
1170
|
+
nowIso: new Date().toISOString(),
|
|
1171
|
+
});
|
|
1172
|
+
fs.rmSync(saved.bundle, { force: true });
|
|
1173
|
+
fs.rmSync(saved.meta, { force: true });
|
|
1174
|
+
warnings.push(...conn.state.drainWarnings());
|
|
1175
|
+
return {
|
|
1176
|
+
...base,
|
|
1177
|
+
status: 'resumed',
|
|
1178
|
+
cut: cutBefore ?? 0,
|
|
1179
|
+
sealed: 0,
|
|
1180
|
+
folded: 0,
|
|
1181
|
+
baselined: {},
|
|
1182
|
+
snapshot: snapshotOf(stored, await deviceNames(conn)),
|
|
1183
|
+
deltaSegmentSeq: null,
|
|
1184
|
+
replicaSeqFloor,
|
|
1185
|
+
warnings,
|
|
1186
|
+
};
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
const { state: synced } = syncedState(conn, t, parent ? [parent] : [], head.headCheckpointId);
|
|
1190
|
+
// Another device already started this stream's journal: never a second
|
|
1191
|
+
// genesis. A store that holds exactly that journal checkpoint JOINS it
|
|
1192
|
+
// (T13312); any other store restores it first.
|
|
1193
|
+
if (parent !== null && isJournalSnapshot(parent)) {
|
|
1194
|
+
if (synced?.lastCheckpointId !== parent.checkpointId) {
|
|
1195
|
+
throw vaultError('E_NEXUS_SYNC_STREAM_JOURNALED', `${t.streamId} already carries the change journal: its head snapshot ${parent.checkpointId} is a journal checkpoint this store has not restored`, 'nothing was cut; restore the journal checkpoint (`cleo cloud restore`), then join the stream with `cleo sync enable push`');
|
|
1196
|
+
}
|
|
1197
|
+
// Unchanged since the restore: the rows match the checkpoint's manifest
|
|
1198
|
+
// (row data) here, and joinStream checks every row against its meta.
|
|
1199
|
+
const git = gitTracking(t);
|
|
1200
|
+
const local = await localManifest(t, git, untrackedComparison(t, git, synced));
|
|
1201
|
+
const changed = local === null
|
|
1202
|
+
? ['(no store)']
|
|
1203
|
+
: compareVaultManifests(local.manifest, comparable(parent.manifest, git))
|
|
1204
|
+
.filter((r) => r.table !== VAULT_FILES_KEY && !isVaultAnnotationKey(r.table) && !r.match)
|
|
1205
|
+
.map((r) => r.table);
|
|
1206
|
+
if (changed.length > 0) {
|
|
1207
|
+
throw vaultError('E_NEXUS_SYNC_REFUSED', `this store changed since it restored ${parent.checkpointId} (${changed.slice(0, 8).join(', ')}); joining would lose those changes`, 'nothing was joined; restore the journal checkpoint again (`cleo cloud restore --force`, a safety backup is taken first), then `cleo sync enable push`');
|
|
1208
|
+
}
|
|
1209
|
+
const joined = joinStream(db, {
|
|
1210
|
+
scope: tableScopeOf(t),
|
|
1211
|
+
stream: t.streamId,
|
|
1212
|
+
cursor: cursorFromCheckpoint(parent),
|
|
1213
|
+
...(opts.allowUnreleased ? { allowUnreleased: true } : {}),
|
|
1214
|
+
});
|
|
1215
|
+
if (joined.refused !== null) {
|
|
1216
|
+
throw vaultError('E_NEXUS_SYNC_REFUSED', `the join was refused: ${joined.refused}`);
|
|
1217
|
+
}
|
|
1218
|
+
warnings.push(...conn.state.drainWarnings());
|
|
1219
|
+
return {
|
|
1220
|
+
...base,
|
|
1221
|
+
status: 'joined',
|
|
1222
|
+
cut: joined.cut ?? 0,
|
|
1223
|
+
sealed: 0,
|
|
1224
|
+
folded: 0,
|
|
1225
|
+
baselined: {},
|
|
1226
|
+
snapshot: snapshotOf(parent, await deviceNames(conn)),
|
|
1227
|
+
deltaSegmentSeq: null,
|
|
1228
|
+
replicaSeqFloor: null,
|
|
1229
|
+
warnings,
|
|
1230
|
+
};
|
|
1231
|
+
}
|
|
1232
|
+
if (head.headCheckpointId !== null && head.headCheckpointId !== synced?.lastCheckpointId) {
|
|
1233
|
+
throw vaultError('E_NEXUS_VAULT_BEHIND', `another device pushed snapshot ${head.headCheckpointId} after this machine's last sync`, 'run `cleo cloud pull` first, then enable push again');
|
|
1234
|
+
}
|
|
1235
|
+
const git = gitTracking(t);
|
|
1236
|
+
if (git.mode === 'unknown')
|
|
1237
|
+
warnings.push(gitUnknownWarning(t));
|
|
1238
|
+
await acquireLease(conn, t, false, 'cleo sync enable push');
|
|
1239
|
+
try {
|
|
1240
|
+
let cut = cutBefore ?? 0;
|
|
1241
|
+
let sealed = 0;
|
|
1242
|
+
let folded = 0;
|
|
1243
|
+
let baselined = {};
|
|
1244
|
+
let resumedCut = false;
|
|
1245
|
+
if (!resuming) {
|
|
1246
|
+
fs.mkdirSync(saved.dir, { recursive: true });
|
|
1247
|
+
const work = tempDir('cleo-sync-genesis-');
|
|
1248
|
+
// Filled by the snapshot callback (a holder, so the assignment survives the closure).
|
|
1249
|
+
const snap = {
|
|
1250
|
+
taken: null,
|
|
1251
|
+
};
|
|
1252
|
+
try {
|
|
1253
|
+
const report = await cutGenesisWithSnapshot(db, {
|
|
1254
|
+
scope: tableScopeOf(t),
|
|
1255
|
+
stream: t.streamId,
|
|
1256
|
+
dbPath: t.dbPath,
|
|
1257
|
+
...(opts.allowUnreleased ? { allowUnreleased: true } : {}),
|
|
1258
|
+
}, async (at) => {
|
|
1259
|
+
// The store exactly as the committed cut left it: the genesis marker
|
|
1260
|
+
// holds every other writer off until this returns (T13296, T13297).
|
|
1261
|
+
const exported = await exportAndRead(t, work, `sync-genesis-${t.scope}`, trackedForPush(t, git, synced));
|
|
1262
|
+
snap.taken = {
|
|
1263
|
+
bundlePath: exported.bundlePath,
|
|
1264
|
+
record: {
|
|
1265
|
+
cut: at,
|
|
1266
|
+
vault: exported.vault,
|
|
1267
|
+
replayPin: replayPinOf(db),
|
|
1268
|
+
gitTracked: exported.gitTracked,
|
|
1269
|
+
files: syncFiles(exported.files),
|
|
1270
|
+
checkpointIds: [],
|
|
1271
|
+
},
|
|
1272
|
+
};
|
|
1273
|
+
});
|
|
1274
|
+
if (report.refused !== null) {
|
|
1275
|
+
fs.rmSync(saved.bundle, { force: true });
|
|
1276
|
+
fs.rmSync(saved.meta, { force: true });
|
|
1277
|
+
throw vaultError('E_NEXUS_SYNC_REFUSED', `the genesis cut was refused: ${report.refused}`);
|
|
1278
|
+
}
|
|
1279
|
+
// Saved only once the cut's race check passed: a bundle and record on
|
|
1280
|
+
// disk always describe a cut that stands. A crash before this is a
|
|
1281
|
+
// pending cut with nothing saved, which the next run resumes (T13301).
|
|
1282
|
+
const snapshotted = snap.taken;
|
|
1283
|
+
if (snapshotted === null) {
|
|
1284
|
+
// @sync-invariant none:local-only programming error: a committed cut always ran its snapshot
|
|
1285
|
+
throw new Error('the genesis cut returned without a snapshot');
|
|
1286
|
+
}
|
|
1287
|
+
replaceFile(saved.bundle, { copyFrom: snapshotted.bundlePath });
|
|
1288
|
+
replaceFile(saved.meta, { text: `${JSON.stringify(snapshotted.record)}\n` });
|
|
1289
|
+
cut = report.cut ?? 0;
|
|
1290
|
+
resumedCut = report.resumed;
|
|
1291
|
+
sealed = report.sealed;
|
|
1292
|
+
folded = report.folded;
|
|
1293
|
+
baselined = { ...report.baselined };
|
|
1294
|
+
}
|
|
1295
|
+
catch (err) {
|
|
1296
|
+
// The cut was undone (a failed export, or a write raced it): nothing is saved or pushed.
|
|
1297
|
+
// After a takeover the pending cut, and any bundle saved at these paths, belong
|
|
1298
|
+
// to the new holder: leave them (T13390).
|
|
1299
|
+
if (!(err instanceof GenesisTakenOverError)) {
|
|
1300
|
+
fs.rmSync(saved.bundle, { force: true });
|
|
1301
|
+
fs.rmSync(saved.meta, { force: true });
|
|
1302
|
+
}
|
|
1303
|
+
if (err instanceof GenesisRacedError) {
|
|
1304
|
+
throw vaultError('E_NEXUS_SYNC_REFUSED', err.message, 'nothing was pushed; run it again');
|
|
1305
|
+
}
|
|
1306
|
+
throw err;
|
|
1307
|
+
}
|
|
1308
|
+
finally {
|
|
1309
|
+
fs.rmSync(work, { recursive: true, force: true });
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
const record = GenesisSaved.parse(JSON.parse(fs.readFileSync(saved.meta, 'utf8')));
|
|
1313
|
+
// checkpoint/v3 with empty accounting: genesis snapshots the store (§2.11 §10).
|
|
1314
|
+
const manifest = {
|
|
1315
|
+
schemaVersion: SYNC_SCHEMA_VERSION,
|
|
1316
|
+
tables: {
|
|
1317
|
+
...record.vault.tables,
|
|
1318
|
+
[VAULT_FORMAT_KEY]: vaultFormatEntry(hashKeyOf(t.dataKey)),
|
|
1319
|
+
},
|
|
1320
|
+
pending: [],
|
|
1321
|
+
voided: [],
|
|
1322
|
+
revived: [],
|
|
1323
|
+
pruned: {},
|
|
1324
|
+
replayPin: record.replayPin,
|
|
1325
|
+
};
|
|
1326
|
+
if (parent) {
|
|
1327
|
+
for (const table of Object.keys(parent.manifest.tables)) {
|
|
1328
|
+
if (isVaultAnnotationKey(table))
|
|
1329
|
+
continue;
|
|
1330
|
+
manifest.tables[table] ??= { rows: 0, hash: buildEmptyHash(t, table) };
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
const parentVersion = parent?.manifest.schemaVersion ?? 0;
|
|
1334
|
+
const { cp, deltaSegmentSeq, cursor } = await appendCheckpoint({
|
|
1335
|
+
conn,
|
|
1336
|
+
t,
|
|
1337
|
+
journal,
|
|
1338
|
+
signers: key.signers,
|
|
1339
|
+
parent,
|
|
1340
|
+
manifest,
|
|
1341
|
+
bundlePath: saved.bundle,
|
|
1342
|
+
replicaId,
|
|
1343
|
+
// Recorded before each upload, so a run that dies after the server
|
|
1344
|
+
// stored it adopts this checkpoint instead of cutting again (T13302).
|
|
1345
|
+
mintCheckpointId: () => {
|
|
1346
|
+
const id = uuidv7();
|
|
1347
|
+
const now = GenesisSaved.parse(JSON.parse(fs.readFileSync(saved.meta, 'utf8')));
|
|
1348
|
+
replaceFile(saved.meta, {
|
|
1349
|
+
text: `${JSON.stringify({ ...now, checkpointIds: [...now.checkpointIds, id] })}\n`,
|
|
1350
|
+
});
|
|
1351
|
+
return id;
|
|
1352
|
+
},
|
|
1353
|
+
onReplay: (replay) => {
|
|
1354
|
+
// A schema rise inside the window would need its journal hash in the
|
|
1355
|
+
// pin's transitions; genesis is cut on a window without one.
|
|
1356
|
+
if (parent !== null && replay.maxSchemaVersion > parentVersion) {
|
|
1357
|
+
throw vaultError('E_NEXUS_SYNC_REFUSED', `${t.streamId} rose to sync schema ${replay.maxSchemaVersion} since its head snapshot (schema ${parentVersion})`, 'pull first (`cleo cloud pull`), then enable push again');
|
|
1358
|
+
}
|
|
1359
|
+
},
|
|
1360
|
+
});
|
|
1361
|
+
const replicaSeqFloor = cursor.replicas[replicaId]?.replicaSeq ?? null;
|
|
1362
|
+
// Synced first: a crash before completeGenesis leaves the cut pending,
|
|
1363
|
+
// and the next run adopts this checkpoint by its recorded id (T13302).
|
|
1364
|
+
saveSynced(conn, t, cp, { gitTracked: record.gitTracked, files: record.files });
|
|
1365
|
+
completeGenesis(db, {
|
|
1366
|
+
stream: t.streamId,
|
|
1367
|
+
replica: replicaId,
|
|
1368
|
+
replicaSeqFloor,
|
|
1369
|
+
nowIso: new Date().toISOString(),
|
|
1370
|
+
});
|
|
1371
|
+
fs.rmSync(saved.bundle, { force: true });
|
|
1372
|
+
fs.rmSync(saved.meta, { force: true });
|
|
1373
|
+
await releaseLeaseQuietly(conn, t);
|
|
1374
|
+
warnings.push(...conn.state.drainWarnings());
|
|
1375
|
+
return {
|
|
1376
|
+
...base,
|
|
1377
|
+
status: resuming || resumedCut ? 'resumed' : 'enabled',
|
|
1378
|
+
cut: resuming ? record.cut : cut,
|
|
1379
|
+
sealed,
|
|
1380
|
+
folded,
|
|
1381
|
+
baselined,
|
|
1382
|
+
snapshot: snapshotOf(cp, await deviceNames(conn)),
|
|
1383
|
+
deltaSegmentSeq,
|
|
1384
|
+
replicaSeqFloor,
|
|
1385
|
+
warnings,
|
|
1386
|
+
};
|
|
1387
|
+
}
|
|
1388
|
+
catch (err) {
|
|
1389
|
+
await releaseLeaseQuietly(conn, t);
|
|
1390
|
+
throw err;
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
/** Connect, unlock the account key and resolve the scope's store, once. */
|
|
1394
|
+
async function openStreamSession(opts) {
|
|
1395
|
+
const conn = await connectNexusVault(opts);
|
|
1396
|
+
const key = await unlockNexusAccountKey(conn);
|
|
1397
|
+
const t = await resolveTarget(conn, key.masterKey, opts, 'push');
|
|
1398
|
+
return { conn, key, t };
|
|
1399
|
+
}
|
|
1400
|
+
async function pushSyncStreamImpl(opts = {}) {
|
|
1401
|
+
return pushWithSession(await openStreamSession(opts), opts);
|
|
1402
|
+
}
|
|
1403
|
+
async function pushWithSession(session, opts) {
|
|
1404
|
+
const { conn, t } = session;
|
|
1405
|
+
const replicaId = t.replicaId;
|
|
1406
|
+
if (!replicaId) {
|
|
1407
|
+
throw vaultError('E_NEXUS_VAULT_NOT_LINKED', 'this copy of the project is not attached from this device', 'run `cleo project link`');
|
|
1408
|
+
}
|
|
1409
|
+
const journal = journalFor(conn, t);
|
|
1410
|
+
// A first request also gives the server's clock (Date header, §1.3).
|
|
1411
|
+
await streamHead(conn, t.streamId);
|
|
1412
|
+
// The server-authoritative rollback check (§1.5): E3 knows this replica's
|
|
1413
|
+
// last stored segment on a project stream.
|
|
1414
|
+
const status = t.projectId !== null
|
|
1415
|
+
? await conn.find(nexusQueryPath('/v1/status', { projectId: t.projectId, replicaId }), nexusCloudStatusSchema)
|
|
1416
|
+
: null;
|
|
1417
|
+
const { openDualScopeDbAtPath, getDualScopeNativeDb } = await import('../store/dual-scope-db.js');
|
|
1418
|
+
const db = getDualScopeNativeDb(t.scope === 'global'
|
|
1419
|
+
? await openDualScopeDbAtPath('global', t.dbPath)
|
|
1420
|
+
: await openDualScopeDbAtPath('project', t.dbPath));
|
|
1421
|
+
// T13304: push seals and packs as the replica the stream knows; the store
|
|
1422
|
+
// must be bound to that same one, or its own txns would never be selected.
|
|
1423
|
+
const bound = activeReplica(db, tableScopeOf(t))?.replicaId ?? null;
|
|
1424
|
+
if (bound !== replicaId) {
|
|
1425
|
+
throw vaultError('E_NEXUS_SYNC_REFUSED', `this store is bound to replica ${bound ?? '(none)'}, but ${t.streamId} knows it as ${replicaId}`, 'nothing was sealed or sent; relink the project (`cleo project link`) so both name one replica');
|
|
1426
|
+
}
|
|
1427
|
+
try {
|
|
1428
|
+
return await pushStream(db, {
|
|
1429
|
+
scope: tableScopeOf(t),
|
|
1430
|
+
stream: t.streamId,
|
|
1431
|
+
replica: replicaId,
|
|
1432
|
+
project: t.projectId,
|
|
1433
|
+
sealer: (replicaSeq, plaintext, meta) => journal.sealSegment(replicaSeq, plaintext, meta),
|
|
1434
|
+
signTxn: (stream, txn) => signTxn(conn.keys.signing, stream, txn),
|
|
1435
|
+
upload: async (seg) => journal.push(seg.replicaSeq, new Uint8Array(0), seg.meta, seg.sealed),
|
|
1436
|
+
serverOffsetMs: conn.http.lastServerOffsetMs,
|
|
1437
|
+
serverLastReplicaSeq: status?.replica?.lastReplicaSeq ?? null,
|
|
1438
|
+
registry: ReplicaRegistry.forDevice(conn.deviceId),
|
|
1439
|
+
...(opts.allowUnreleased ? { allowUnreleased: true } : {}),
|
|
1440
|
+
});
|
|
1441
|
+
}
|
|
1442
|
+
catch (err) {
|
|
1443
|
+
// The server holds a different segment at this replicaSeq, or a later one
|
|
1444
|
+
// than this store has: the store is behind (§1.5, §2.9).
|
|
1445
|
+
if (err instanceof NexusError && err.code === 'E_CONFLICT') {
|
|
1446
|
+
throw vaultError('E_NEXUS_SYNC_REFUSED', `the server refused a segment of replica ${replicaId} on ${t.streamId}: ${err.message}`, 'this store is behind the stream (restored or copied); it must rebind to a new replica (T12753)');
|
|
1447
|
+
}
|
|
1448
|
+
throw err;
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
async function pullSyncStreamImpl(opts = {}) {
|
|
1452
|
+
return pullWithSession(await openStreamSession(opts));
|
|
1453
|
+
}
|
|
1454
|
+
async function pullWithSession(session) {
|
|
1455
|
+
const { conn, key, t } = session;
|
|
1456
|
+
const replicaId = t.replicaId;
|
|
1457
|
+
if (!replicaId) {
|
|
1458
|
+
throw vaultError('E_NEXUS_VAULT_NOT_LINKED', 'this copy of the project is not attached from this device', 'run `cleo project link`');
|
|
1459
|
+
}
|
|
1460
|
+
const journal = journalFor(conn, t);
|
|
1461
|
+
const { openDualScopeDbAtPath, getDualScopeNativeDb } = await import('../store/dual-scope-db.js');
|
|
1462
|
+
const db = getDualScopeNativeDb(t.scope === 'global'
|
|
1463
|
+
? await openDualScopeDbAtPath('global', t.dbPath)
|
|
1464
|
+
: await openDualScopeDbAtPath('project', t.dbPath));
|
|
1465
|
+
// Own echoes are recognised by the bound replica: it must be the stream's (T13304).
|
|
1466
|
+
const bound = activeReplica(db, tableScopeOf(t))?.replicaId ?? null;
|
|
1467
|
+
if (bound !== replicaId) {
|
|
1468
|
+
throw vaultError('E_NEXUS_SYNC_REFUSED', `this store is bound to replica ${bound ?? '(none)'}, but ${t.streamId} knows it as ${replicaId}`, 'nothing was pulled; relink the project (`cleo project link`) so both name one replica');
|
|
1469
|
+
}
|
|
1470
|
+
// With sync.pull off, pullStream refuses at once: no network round trip.
|
|
1471
|
+
if (!isSyncFlagOn(db, 'sync.pull')) {
|
|
1472
|
+
return pullStream(db, {
|
|
1473
|
+
scope: tableScopeOf(t),
|
|
1474
|
+
stream: t.streamId,
|
|
1475
|
+
replica: replicaId,
|
|
1476
|
+
initialCursor: initialPullCursor(),
|
|
1477
|
+
pull: async () => ({ segments: [], cursor: initialPullCursor(), head: 0 }),
|
|
1478
|
+
verify: () => 0,
|
|
1479
|
+
seal: () => { },
|
|
1480
|
+
});
|
|
1481
|
+
}
|
|
1482
|
+
const head = await streamHead(conn, t.streamId);
|
|
1483
|
+
const checkpoints = head.headCheckpointId ? await listCheckpoints(conn, t.streamId) : [];
|
|
1484
|
+
// A store with no pull position starts after the checkpoint it last synced
|
|
1485
|
+
// (the genesis it cut, or the snapshot it restored), seeded from its signed
|
|
1486
|
+
// replica map.
|
|
1487
|
+
let initialCursor = readStreamCursor(db, t.streamId);
|
|
1488
|
+
if (initialCursor === null) {
|
|
1489
|
+
const { state: synced } = syncedState(conn, t, checkpoints, head.headCheckpointId);
|
|
1490
|
+
const from = checkpoints.find((c) => c.checkpointId === synced?.lastCheckpointId) ?? null;
|
|
1491
|
+
if (from === null) {
|
|
1492
|
+
throw vaultError('E_NEXUS_SYNC_REFUSED', `this store has synced no checkpoint of ${t.streamId}, so it has no position to pull from`, 'restore the stream (`cleo cloud restore`), or enable push (`cleo sync enable push`), first');
|
|
1493
|
+
}
|
|
1494
|
+
// T13306: a vault snapshot from before the stream's journal genesis. What
|
|
1495
|
+
// the genesis device changed between that snapshot and its cut was folded
|
|
1496
|
+
// into the genesis checkpoint, never sent as segments: pulling across it
|
|
1497
|
+
// would silently diverge.
|
|
1498
|
+
const genesis = isJournalSnapshot(from)
|
|
1499
|
+
? undefined
|
|
1500
|
+
: checkpoints.find((c) => isJournalSnapshot(c) && c.coversSeq >= from.coversSeq);
|
|
1501
|
+
if (genesis) {
|
|
1502
|
+
throw vaultError('E_NEXUS_SYNC_REFUSED', `${t.streamId} started its change journal at checkpoint ${genesis.checkpointId}, after the vault snapshot ${from.checkpointId} this store last synced: the changes folded into that journal checkpoint never travel as segments, so pulling from here would silently diverge`, 'nothing was pulled; restore the journal checkpoint first (`cleo cloud restore`), then join the stream with `cleo sync enable push` (T12999)');
|
|
1503
|
+
}
|
|
1504
|
+
journal.verifyCheckpoint(from, key.signers);
|
|
1505
|
+
initialCursor = cursorFromCheckpoint(from);
|
|
1506
|
+
}
|
|
1507
|
+
// Seen-txn rows below each origin's staged floor are pruned after every
|
|
1508
|
+
// pull (T13318): an origin's transactions are first delivered in local_seq
|
|
1509
|
+
// order, so one at or below the floor with no seen row is refused loudly
|
|
1510
|
+
// (`below-floor`), never applied twice.
|
|
1511
|
+
return pullStream(db, {
|
|
1512
|
+
scope: tableScopeOf(t),
|
|
1513
|
+
stream: t.streamId,
|
|
1514
|
+
replica: replicaId,
|
|
1515
|
+
initialCursor,
|
|
1516
|
+
pruneSeen: true,
|
|
1517
|
+
pull: async (cursor) => {
|
|
1518
|
+
const page = await journal.pull({
|
|
1519
|
+
after: cursor.after,
|
|
1520
|
+
knowsAllReplicas: cursor.knowsAllReplicas,
|
|
1521
|
+
replicas: { ...cursor.replicas },
|
|
1522
|
+
}, key.signers);
|
|
1523
|
+
return {
|
|
1524
|
+
segments: page.segments.map((s) => ({
|
|
1525
|
+
seq: s.seq,
|
|
1526
|
+
replicaId: s.replicaId,
|
|
1527
|
+
replicaSeq: s.replicaSeq,
|
|
1528
|
+
deviceId: s.deviceId,
|
|
1529
|
+
plaintext: s.plaintext,
|
|
1530
|
+
schemaVersion: s.meta.schemaVersion,
|
|
1531
|
+
})),
|
|
1532
|
+
cursor: page.cursor,
|
|
1533
|
+
head: page.head,
|
|
1534
|
+
};
|
|
1535
|
+
},
|
|
1536
|
+
verify: (deviceId, txns) => {
|
|
1537
|
+
const keys = signerKeys(key.signers, deviceId);
|
|
1538
|
+
const first = keys[0];
|
|
1539
|
+
if (first === undefined)
|
|
1540
|
+
return 0;
|
|
1541
|
+
for (const k of keys) {
|
|
1542
|
+
if (firstBadTxnSignature(k.publicKey, t.streamId, txns) === null)
|
|
1543
|
+
return null;
|
|
1544
|
+
}
|
|
1545
|
+
return firstBadTxnSignature(first.publicKey, t.streamId, txns);
|
|
1546
|
+
},
|
|
1547
|
+
seal: () => {
|
|
1548
|
+
sealPending(db, { scope: tableScopeOf(t), replica: replicaId });
|
|
1549
|
+
},
|
|
1550
|
+
});
|
|
1551
|
+
}
|
|
1552
|
+
/** A `cleo cloud sync` stream result with nothing done yet. */
|
|
1553
|
+
const syncStreamResult = (scope, fields) => ({
|
|
1554
|
+
scope,
|
|
1555
|
+
streamId: null,
|
|
1556
|
+
status: 'synced',
|
|
1557
|
+
refused: null,
|
|
1558
|
+
skipped: [],
|
|
1559
|
+
sealed: 0,
|
|
1560
|
+
built: 0,
|
|
1561
|
+
sent: 0,
|
|
1562
|
+
duplicates: 0,
|
|
1563
|
+
received: 0,
|
|
1564
|
+
staged: 0,
|
|
1565
|
+
redelivered: 0,
|
|
1566
|
+
applied: 0,
|
|
1567
|
+
held: 0,
|
|
1568
|
+
conflicts: 0,
|
|
1569
|
+
after: null,
|
|
1570
|
+
head: null,
|
|
1571
|
+
...fields,
|
|
1572
|
+
});
|
|
1573
|
+
/** Codes meaning this machine has no store on the stream of a scope. */
|
|
1574
|
+
const NOT_ATTACHED_CODES = new Set([
|
|
1575
|
+
'E_NEXUS_VAULT_NOT_LINKED',
|
|
1576
|
+
'E_NEXUS_NOT_A_PROJECT',
|
|
1577
|
+
]);
|
|
1578
|
+
/** Push refusals that mean "nothing to do here", not a failure (LOW-3 on #1962). */
|
|
1579
|
+
const PUSH_SKIPS = new Set(['push-off', 'no-genesis', 'genesis-pending']);
|
|
1580
|
+
/**
|
|
1581
|
+
* Classify a stream's push and pull results (T13315). Classification uses
|
|
1582
|
+
* each report's typed `refusedKind`, never its message, so rewording a
|
|
1583
|
+
* refusal never turns a skip into a refusal.
|
|
1584
|
+
*
|
|
1585
|
+
* @param push - The push leg's `refused` and `refusedKind`.
|
|
1586
|
+
* @param pull - The pull leg's `refused` and `refusedKind`.
|
|
1587
|
+
* @returns Skipped and refused messages, and whether the stream is disabled.
|
|
1588
|
+
*/
|
|
1589
|
+
export function classifySyncLegs(push, pull) {
|
|
1590
|
+
const pushSkipped = push.refusedKind !== null && PUSH_SKIPS.has(push.refusedKind);
|
|
1591
|
+
const pullSkipped = pull.refusedKind === 'pull-off';
|
|
1592
|
+
const skipped = [];
|
|
1593
|
+
const refused = [];
|
|
1594
|
+
for (const [msg, skip] of [
|
|
1595
|
+
[push.refused, pushSkipped],
|
|
1596
|
+
[pull.refused, pullSkipped],
|
|
1597
|
+
]) {
|
|
1598
|
+
if (msg === null)
|
|
1599
|
+
continue;
|
|
1600
|
+
(skip ? skipped : refused).push(msg);
|
|
1601
|
+
}
|
|
1602
|
+
return {
|
|
1603
|
+
skipped,
|
|
1604
|
+
refused,
|
|
1605
|
+
disabled: push.refusedKind === 'push-off' && pullSkipped,
|
|
1606
|
+
pullSkipped,
|
|
1607
|
+
};
|
|
1608
|
+
}
|
|
1609
|
+
/** The code and message of a failure, for a stream result. */
|
|
1610
|
+
function failureText(err) {
|
|
1611
|
+
if (err instanceof NexusAccountError || err instanceof NexusError)
|
|
1612
|
+
return `${err.code}: ${err.message}`;
|
|
1613
|
+
return err instanceof Error ? err.message : String(err);
|
|
1614
|
+
}
|
|
1615
|
+
/**
|
|
1616
|
+
* Seal, push, pull and apply one scope's stream (T12996). The run's one
|
|
1617
|
+
* connection and account key serve both legs. A failure is reported on this stream
|
|
1618
|
+
* (`failed`), never thrown, so the next stream still syncs.
|
|
1619
|
+
*/
|
|
1620
|
+
async function syncOneStream(shared, opts, scope) {
|
|
1621
|
+
let session;
|
|
1622
|
+
try {
|
|
1623
|
+
session = {
|
|
1624
|
+
...shared,
|
|
1625
|
+
t: await resolveTarget(shared.conn, shared.key.masterKey, { ...opts, scope }, 'push'),
|
|
1626
|
+
};
|
|
1627
|
+
}
|
|
1628
|
+
catch (err) {
|
|
1629
|
+
if (err instanceof NexusAccountError && NOT_ATTACHED_CODES.has(err.code)) {
|
|
1630
|
+
return syncStreamResult(scope, { status: 'not-attached', refused: err.message });
|
|
1631
|
+
}
|
|
1632
|
+
return syncStreamResult(scope, { status: 'failed', refused: failureText(err) });
|
|
1633
|
+
}
|
|
1634
|
+
const streamId = session.t.streamId;
|
|
1635
|
+
if (!session.t.replicaId) {
|
|
1636
|
+
return syncStreamResult(scope, {
|
|
1637
|
+
streamId,
|
|
1638
|
+
status: 'not-attached',
|
|
1639
|
+
refused: 'this copy of the project is not attached from this device',
|
|
1640
|
+
});
|
|
1641
|
+
}
|
|
1642
|
+
let push;
|
|
1643
|
+
try {
|
|
1644
|
+
push = await pushWithSession(session, opts);
|
|
1645
|
+
}
|
|
1646
|
+
catch (err) {
|
|
1647
|
+
return syncStreamResult(scope, { streamId, status: 'failed', refused: failureText(err) });
|
|
1648
|
+
}
|
|
1649
|
+
let pull;
|
|
1650
|
+
try {
|
|
1651
|
+
pull = await pullWithSession(session);
|
|
1652
|
+
}
|
|
1653
|
+
catch (err) {
|
|
1654
|
+
return syncStreamResult(scope, {
|
|
1655
|
+
streamId,
|
|
1656
|
+
status: 'failed',
|
|
1657
|
+
refused: failureText(err),
|
|
1658
|
+
sealed: push.sealed,
|
|
1659
|
+
built: push.built,
|
|
1660
|
+
sent: push.pushed,
|
|
1661
|
+
duplicates: push.duplicates,
|
|
1662
|
+
});
|
|
1663
|
+
}
|
|
1664
|
+
const legs = classifySyncLegs(push, pull);
|
|
1665
|
+
const { skipped, refused } = legs;
|
|
1666
|
+
const apply = pull.apply;
|
|
1667
|
+
return syncStreamResult(scope, {
|
|
1668
|
+
streamId,
|
|
1669
|
+
status: legs.disabled
|
|
1670
|
+
? 'disabled'
|
|
1671
|
+
: refused.length > 0
|
|
1672
|
+
? 'refused'
|
|
1673
|
+
: push.clockAhead
|
|
1674
|
+
? 'paused'
|
|
1675
|
+
: 'synced',
|
|
1676
|
+
refused: refused.length > 0 ? refused.join('; ') : null,
|
|
1677
|
+
skipped,
|
|
1678
|
+
sealed: push.sealed,
|
|
1679
|
+
built: push.built,
|
|
1680
|
+
sent: push.pushed,
|
|
1681
|
+
duplicates: push.duplicates,
|
|
1682
|
+
received: pull.segments,
|
|
1683
|
+
staged: pull.staged,
|
|
1684
|
+
redelivered: pull.redelivered,
|
|
1685
|
+
applied: apply?.applied ?? 0,
|
|
1686
|
+
held: (apply?.pending ?? 0) + (apply?.heldSkew ?? 0) + (apply?.refusedSchema ?? 0),
|
|
1687
|
+
conflicts: apply?.conflict ?? 0,
|
|
1688
|
+
after: legs.pullSkipped ? null : pull.after,
|
|
1689
|
+
head: legs.pullSkipped ? null : pull.head,
|
|
1690
|
+
});
|
|
1691
|
+
}
|
|
1692
|
+
async function cloudSyncImpl(opts = {}) {
|
|
1693
|
+
// One connection and one account-key unlock for every stream (LOW-2 on #1962).
|
|
1694
|
+
const conn = await connectNexusVault(opts);
|
|
1695
|
+
const key = await unlockNexusAccountKey(conn);
|
|
1696
|
+
const scopes = opts.scope ? [opts.scope] : ['project', 'global'];
|
|
1697
|
+
const streams = [];
|
|
1698
|
+
for (const scope of scopes)
|
|
1699
|
+
streams.push(await syncOneStream({ conn, key }, opts, scope));
|
|
1700
|
+
const worked = streams.filter((r) => r.status !== 'disabled' && r.status !== 'not-attached');
|
|
1701
|
+
if (worked.length === 0 && streams.some((r) => r.status === 'disabled')) {
|
|
1702
|
+
throw vaultError('E_SYNC_DISABLED', 'no attached stream has sync.push or sync.pull on', 'turn the change journal on first: `cleo sync enable push`');
|
|
1703
|
+
}
|
|
1704
|
+
return { apiUrl: conn.apiUrl, streams, warnings: [...conn.warnings] };
|
|
1705
|
+
}
|
|
997
1706
|
/** The keyed hash of an empty table (a table the parent lists that this store no longer has). */
|
|
998
1707
|
function buildEmptyHash(t, table) {
|
|
999
1708
|
return emptyVaultTableHash(hashKeyOf(t.dataKey), table);
|
|
@@ -1411,6 +2120,10 @@ async function restoreNexusVaultImpl(opts) {
|
|
|
1411
2120
|
// (T13007).
|
|
1412
2121
|
warnings.push(...carryWarnings(carryMachineState(staged.dbPath, hasLocal ? t.dbPath : null, tableScopeOf(t), {
|
|
1413
2122
|
snapshotRoot: t.scope === 'global' ? null : (manifest.projects[0]?.originalPath ?? null),
|
|
2123
|
+
// A journal checkpoint carries its rows' merge state (T13312).
|
|
2124
|
+
...(isJournalSnapshot(targetCp)
|
|
2125
|
+
? { snapshotCarried: JOURNAL_SNAPSHOT_CARRIED }
|
|
2126
|
+
: {}),
|
|
1414
2127
|
})));
|
|
1415
2128
|
},
|
|
1416
2129
|
});
|
|
@@ -1852,6 +2565,70 @@ async function mapped(run) {
|
|
|
1852
2565
|
export function pushNexusVault(opts = {}) {
|
|
1853
2566
|
return mapped(() => pushNexusVaultImpl(opts));
|
|
1854
2567
|
}
|
|
2568
|
+
/**
|
|
2569
|
+
* `cleo sync enable push`: record this store's genesis cut on its stream and
|
|
2570
|
+
* push the genesis checkpoint (journal spec §2.11 §10; T12343 S4-1b). The
|
|
2571
|
+
* store is snapshotted under its write lock, so other cleo processes pause
|
|
2572
|
+
* their writes (they wait, then refuse with `E_STORE_GENESIS`) while the
|
|
2573
|
+
* bundle is exported. A push that fails after the cut keeps the bundle beside
|
|
2574
|
+
* the store and resumes on the next run; a cut that crashed before its bundle
|
|
2575
|
+
* was saved is resumed or re-cut (T13301).
|
|
2576
|
+
*
|
|
2577
|
+
* @param opts - Scope and overrides; `allowUnreleased` for tests and staging only.
|
|
2578
|
+
* @returns What was cut and pushed.
|
|
2579
|
+
* @throws {NexusAccountError} `E_NEXUS_SYNC_REFUSED`, `E_NEXUS_SYNC_STREAM_JOURNALED`,
|
|
2580
|
+
* `E_NEXUS_VAULT_BEHIND`, `E_NEXUS_VAULT_LEASE_HELD`,
|
|
2581
|
+
* `E_NEXUS_VAULT_NOT_LINKED`, or a mapped API error.
|
|
2582
|
+
*/
|
|
2583
|
+
export function enableSyncPush(opts = {}) {
|
|
2584
|
+
return mapped(() => enableSyncPushImpl(opts));
|
|
2585
|
+
}
|
|
2586
|
+
/**
|
|
2587
|
+
* Push this store's change journal on its stream (journal spec §2.8, §2.9;
|
|
2588
|
+
* T12343 S4-2): seal, persist segments, upload the lowest unpushed first.
|
|
2589
|
+
* Pauses while this device's clock runs ahead of the server's, and refuses a
|
|
2590
|
+
* store behind the server (it must rebind). `cleo cloud sync` (T12996) runs
|
|
2591
|
+
* it with pull and apply.
|
|
2592
|
+
*
|
|
2593
|
+
* @param opts - Scope and overrides; `allowUnreleased` for tests and staging only.
|
|
2594
|
+
* @returns What was sealed, persisted and stored, or why nothing was sent.
|
|
2595
|
+
* @throws {NexusAccountError} `E_NEXUS_SYNC_REFUSED` (a segment conflict: the store is behind),
|
|
2596
|
+
* `E_NEXUS_VAULT_NOT_LINKED`, or a mapped API error.
|
|
2597
|
+
*/
|
|
2598
|
+
export function pushSyncStream(opts = {}) {
|
|
2599
|
+
return mapped(() => pushSyncStreamImpl(opts));
|
|
2600
|
+
}
|
|
2601
|
+
/**
|
|
2602
|
+
* Pull this store's stream journal (journal spec §3.1; T12343 S5-1): verified
|
|
2603
|
+
* segments after the store's cursor are decoded, signature-checked, staged
|
|
2604
|
+
* (a re-delivered transaction never twice, a vault delta passed over) and
|
|
2605
|
+
* applied. A store with no pull position starts after the checkpoint it last
|
|
2606
|
+
* synced. `cleo cloud sync` (T12996) runs it with push.
|
|
2607
|
+
*
|
|
2608
|
+
* @param opts - Scope and overrides.
|
|
2609
|
+
* @returns What was received, staged and applied.
|
|
2610
|
+
* @throws {NexusAccountError} `E_NEXUS_SYNC_REFUSED` (replica mismatch, no position),
|
|
2611
|
+
* `E_NEXUS_VAULT_NOT_LINKED`, or a mapped API error.
|
|
2612
|
+
*/
|
|
2613
|
+
export function pullSyncStream(opts = {}) {
|
|
2614
|
+
return mapped(() => pullSyncStreamImpl(opts));
|
|
2615
|
+
}
|
|
2616
|
+
/**
|
|
2617
|
+
* `cleo cloud sync`: seal, push, pull and apply the change journal of each
|
|
2618
|
+
* attached stream (the project's `project:<id>`, the account's `home:<user>`;
|
|
2619
|
+
* T12996). One result per stream: sealed, persisted, sent, received, staged,
|
|
2620
|
+
* applied, held and in conflict, and how far the store has staged against
|
|
2621
|
+
* the server's head. An interrupted run resumes on the next: a segment is
|
|
2622
|
+
* resent with the same bytes, and a transaction is never staged twice.
|
|
2623
|
+
*
|
|
2624
|
+
* @param opts - `scope` (one stream; default every attached one), overrides;
|
|
2625
|
+
* `allowUnreleased` for tests and staging only.
|
|
2626
|
+
* @returns Every stream's result.
|
|
2627
|
+
* @throws {NexusAccountError} `E_SYNC_DISABLED` when no attached stream has a journal flag on.
|
|
2628
|
+
*/
|
|
2629
|
+
export function cloudSync(opts = {}) {
|
|
2630
|
+
return mapped(() => cloudSyncImpl(opts));
|
|
2631
|
+
}
|
|
1855
2632
|
/**
|
|
1856
2633
|
* `cleo cloud pull` / `cleo cloud restore`: verify and activate a snapshot.
|
|
1857
2634
|
*
|