@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.
Files changed (242) hide show
  1. package/dist/build-provenance.json +2 -2
  2. package/dist/cloud/http.d.ts +8 -0
  3. package/dist/cloud/http.d.ts.map +1 -1
  4. package/dist/cloud/http.js +11 -0
  5. package/dist/cloud/http.js.map +1 -1
  6. package/dist/cloud/journal.d.ts +6 -0
  7. package/dist/cloud/journal.d.ts.map +1 -1
  8. package/dist/cloud/journal.js +1 -1
  9. package/dist/cloud/journal.js.map +1 -1
  10. package/dist/cloud/nexus-cloud-status.d.ts.map +1 -1
  11. package/dist/cloud/nexus-cloud-status.js +3 -1
  12. package/dist/cloud/nexus-cloud-status.js.map +1 -1
  13. package/dist/cloud/nexus-vault.d.ts +86 -1
  14. package/dist/cloud/nexus-vault.d.ts.map +1 -1
  15. package/dist/cloud/nexus-vault.js +875 -98
  16. package/dist/cloud/nexus-vault.js.map +1 -1
  17. package/dist/docs/doctor.d.ts +183 -0
  18. package/dist/docs/doctor.d.ts.map +1 -0
  19. package/dist/docs/doctor.js +537 -0
  20. package/dist/docs/doctor.js.map +1 -0
  21. package/dist/doctor/system-health.d.ts +189 -0
  22. package/dist/doctor/system-health.d.ts.map +1 -0
  23. package/dist/doctor/system-health.js +783 -0
  24. package/dist/doctor/system-health.js.map +1 -0
  25. package/dist/git/hooks-install.d.ts +17 -34
  26. package/dist/git/hooks-install.d.ts.map +1 -1
  27. package/dist/git/hooks-install.js +173 -70
  28. package/dist/git/hooks-install.js.map +1 -1
  29. package/dist/harness/ask-enforce.d.ts +43 -0
  30. package/dist/harness/ask-enforce.d.ts.map +1 -0
  31. package/dist/harness/ask-enforce.js +165 -0
  32. package/dist/harness/ask-enforce.js.map +1 -0
  33. package/dist/hooks.d.ts +8 -30
  34. package/dist/hooks.d.ts.map +1 -1
  35. package/dist/hooks.js +36 -116
  36. package/dist/hooks.js.map +1 -1
  37. package/dist/init.d.ts +2 -1
  38. package/dist/init.d.ts.map +1 -1
  39. package/dist/init.js +5 -14
  40. package/dist/init.js.map +1 -1
  41. package/dist/injection.d.ts +2 -2
  42. package/dist/injection.d.ts.map +1 -1
  43. package/dist/injection.js +108 -10
  44. package/dist/injection.js.map +1 -1
  45. package/dist/internal.d.ts +1 -1
  46. package/dist/internal.d.ts.map +1 -1
  47. package/dist/internal.js +1 -1
  48. package/dist/internal.js.map +1 -1
  49. package/dist/issue/create.d.ts +4 -0
  50. package/dist/issue/create.d.ts.map +1 -1
  51. package/dist/issue/create.js +12 -0
  52. package/dist/issue/create.js.map +1 -1
  53. package/dist/issue/index.d.ts +1 -1
  54. package/dist/issue/index.d.ts.map +1 -1
  55. package/dist/issue/index.js +1 -1
  56. package/dist/issue/index.js.map +1 -1
  57. package/dist/orchestrate/spawn-ops.d.ts.map +1 -1
  58. package/dist/orchestrate/spawn-ops.js +8 -2
  59. package/dist/orchestrate/spawn-ops.js.map +1 -1
  60. package/dist/orchestration/lean-change.d.ts +15 -0
  61. package/dist/orchestration/lean-change.d.ts.map +1 -0
  62. package/dist/orchestration/lean-change.js +17 -0
  63. package/dist/orchestration/lean-change.js.map +1 -0
  64. package/dist/orchestration/spawn-prompt.d.ts.map +1 -1
  65. package/dist/orchestration/spawn-prompt.js +6 -4
  66. package/dist/orchestration/spawn-prompt.js.map +1 -1
  67. package/dist/release/ci-evidence.d.ts +59 -0
  68. package/dist/release/ci-evidence.d.ts.map +1 -1
  69. package/dist/release/ci-evidence.js +150 -5
  70. package/dist/release/ci-evidence.js.map +1 -1
  71. package/dist/release/plan.d.ts.map +1 -1
  72. package/dist/release/plan.js +11 -2
  73. package/dist/release/plan.js.map +1 -1
  74. package/dist/release/pr-evidence.d.ts +15 -0
  75. package/dist/release/pr-evidence.d.ts.map +1 -1
  76. package/dist/release/pr-evidence.js +42 -1
  77. package/dist/release/pr-evidence.js.map +1 -1
  78. package/dist/release/reconcile.js +2 -2
  79. package/dist/release/reconcile.js.map +1 -1
  80. package/dist/release/verify-provenance.js +2 -2
  81. package/dist/release/verify-provenance.js.map +1 -1
  82. package/dist/resources/admission-ledger.d.ts +8 -0
  83. package/dist/resources/admission-ledger.d.ts.map +1 -1
  84. package/dist/resources/admission-ledger.js +5 -0
  85. package/dist/resources/admission-ledger.js.map +1 -1
  86. package/dist/resources/claim-keepalive.d.ts +34 -0
  87. package/dist/resources/claim-keepalive.d.ts.map +1 -0
  88. package/dist/resources/claim-keepalive.js +49 -0
  89. package/dist/resources/claim-keepalive.js.map +1 -0
  90. package/dist/resources/governor.d.ts.map +1 -1
  91. package/dist/resources/governor.js +3 -0
  92. package/dist/resources/governor.js.map +1 -1
  93. package/dist/scaffold/ensure-config.js +1 -1
  94. package/dist/scaffold/ensure-config.js.map +1 -1
  95. package/dist/store/attachment-store.d.ts.map +1 -1
  96. package/dist/store/attachment-store.js +2 -1
  97. package/dist/store/attachment-store.js.map +1 -1
  98. package/dist/store/restore-marker.d.ts +50 -7
  99. package/dist/store/restore-marker.d.ts.map +1 -1
  100. package/dist/store/restore-marker.js +77 -25
  101. package/dist/store/restore-marker.js.map +1 -1
  102. package/dist/store/row-identity-registry.d.ts +6 -4
  103. package/dist/store/row-identity-registry.d.ts.map +1 -1
  104. package/dist/store/row-identity-registry.js +156 -33
  105. package/dist/store/row-identity-registry.js.map +1 -1
  106. package/dist/store/schema/cleo-project/provenance-orphans.d.ts +16 -0
  107. package/dist/store/schema/cleo-project/provenance-orphans.d.ts.map +1 -1
  108. package/dist/store/schema/cleo-project/provenance-orphans.js +4 -1
  109. package/dist/store/schema/cleo-project/provenance-orphans.js.map +1 -1
  110. package/dist/store/schema/cleo-shared/brain.d.ts +240 -0
  111. package/dist/store/schema/cleo-shared/brain.d.ts.map +1 -1
  112. package/dist/store/schema/cleo-shared/brain.js +69 -0
  113. package/dist/store/schema/cleo-shared/brain.js.map +1 -1
  114. package/dist/store/schema/tasks.d.ts +1 -1
  115. package/dist/store/schema/tasks.d.ts.map +1 -1
  116. package/dist/store/schema/tasks.js +9 -1
  117. package/dist/store/schema/tasks.js.map +1 -1
  118. package/dist/store/sync/apply/applier.d.ts.map +1 -1
  119. package/dist/store/sync/apply/applier.js +85 -3
  120. package/dist/store/sync/apply/applier.js.map +1 -1
  121. package/dist/store/sync/apply/parent-delete.d.ts.map +1 -1
  122. package/dist/store/sync/apply/parent-delete.js +4 -0
  123. package/dist/store/sync/apply/parent-delete.js.map +1 -1
  124. package/dist/store/sync/capture.d.ts +8 -0
  125. package/dist/store/sync/capture.d.ts.map +1 -1
  126. package/dist/store/sync/capture.js +20 -0
  127. package/dist/store/sync/capture.js.map +1 -1
  128. package/dist/store/sync/clock-store.d.ts +21 -0
  129. package/dist/store/sync/clock-store.d.ts.map +1 -1
  130. package/dist/store/sync/clock-store.js +47 -0
  131. package/dist/store/sync/clock-store.js.map +1 -1
  132. package/dist/store/sync/conflicts.d.ts +13 -0
  133. package/dist/store/sync/conflicts.d.ts.map +1 -1
  134. package/dist/store/sync/conflicts.js +22 -0
  135. package/dist/store/sync/conflicts.js.map +1 -1
  136. package/dist/store/sync/genesis.d.ts +113 -12
  137. package/dist/store/sync/genesis.d.ts.map +1 -1
  138. package/dist/store/sync/genesis.js +325 -27
  139. package/dist/store/sync/genesis.js.map +1 -1
  140. package/dist/store/sync/merge/types.d.ts +8 -2
  141. package/dist/store/sync/merge/types.d.ts.map +1 -1
  142. package/dist/store/sync/pull.d.ts +192 -0
  143. package/dist/store/sync/pull.d.ts.map +1 -0
  144. package/dist/store/sync/pull.js +429 -0
  145. package/dist/store/sync/pull.js.map +1 -0
  146. package/dist/store/sync/push.d.ts +110 -0
  147. package/dist/store/sync/push.d.ts.map +1 -0
  148. package/dist/store/sync/push.js +172 -0
  149. package/dist/store/sync/push.js.map +1 -0
  150. package/dist/store/sync/replay-pin.d.ts +70 -0
  151. package/dist/store/sync/replay-pin.d.ts.map +1 -0
  152. package/dist/store/sync/replay-pin.js +172 -0
  153. package/dist/store/sync/replay-pin.js.map +1 -0
  154. package/dist/store/sync/sealer.d.ts.map +1 -1
  155. package/dist/store/sync/sealer.js +6 -2
  156. package/dist/store/sync/sealer.js.map +1 -1
  157. package/dist/store/sync/timestamp-canon.d.ts.map +1 -1
  158. package/dist/store/sync/timestamp-canon.js +15 -0
  159. package/dist/store/sync/timestamp-canon.js.map +1 -1
  160. package/dist/store/table-classification.d.ts.map +1 -1
  161. package/dist/store/table-classification.js +51 -15
  162. package/dist/store/table-classification.js.map +1 -1
  163. package/dist/store/tasks-schema.d.ts +1 -1
  164. package/dist/store/tasks-schema.d.ts.map +1 -1
  165. package/dist/store/tasks-schema.js +1 -1
  166. package/dist/store/tasks-schema.js.map +1 -1
  167. package/dist/store/twin-collapse.d.ts.map +1 -1
  168. package/dist/store/twin-collapse.js +5 -0
  169. package/dist/store/twin-collapse.js.map +1 -1
  170. package/dist/store/vault-manifest.d.ts +7 -0
  171. package/dist/store/vault-manifest.d.ts.map +1 -1
  172. package/dist/store/vault-manifest.js +4 -0
  173. package/dist/store/vault-manifest.js.map +1 -1
  174. package/dist/tasks/ac-table.d.ts +17 -0
  175. package/dist/tasks/ac-table.d.ts.map +1 -1
  176. package/dist/tasks/ac-table.js +49 -0
  177. package/dist/tasks/ac-table.js.map +1 -1
  178. package/dist/tasks/affected-scope.d.ts +52 -3
  179. package/dist/tasks/affected-scope.d.ts.map +1 -1
  180. package/dist/tasks/affected-scope.js +94 -3
  181. package/dist/tasks/affected-scope.js.map +1 -1
  182. package/dist/tasks/change-set.d.ts.map +1 -1
  183. package/dist/tasks/change-set.js +9 -5
  184. package/dist/tasks/change-set.js.map +1 -1
  185. package/dist/tasks/complete-ci.d.ts +2 -0
  186. package/dist/tasks/complete-ci.d.ts.map +1 -1
  187. package/dist/tasks/complete-ci.js +26 -7
  188. package/dist/tasks/complete-ci.js.map +1 -1
  189. package/dist/tasks/complete.d.ts.map +1 -1
  190. package/dist/tasks/complete.js +10 -0
  191. package/dist/tasks/complete.js.map +1 -1
  192. package/dist/tasks/done-plan.d.ts.map +1 -1
  193. package/dist/tasks/done-plan.js +63 -6
  194. package/dist/tasks/done-plan.js.map +1 -1
  195. package/dist/tasks/evidence.d.ts +3 -0
  196. package/dist/tasks/evidence.d.ts.map +1 -1
  197. package/dist/tasks/evidence.js +106 -2
  198. package/dist/tasks/evidence.js.map +1 -1
  199. package/dist/tasks/gate-preview.d.ts +19 -1
  200. package/dist/tasks/gate-preview.d.ts.map +1 -1
  201. package/dist/tasks/gate-preview.js +44 -2
  202. package/dist/tasks/gate-preview.js.map +1 -1
  203. package/dist/tasks/gate-runner.d.ts +16 -0
  204. package/dist/tasks/gate-runner.d.ts.map +1 -1
  205. package/dist/tasks/gate-runner.js +25 -2
  206. package/dist/tasks/gate-runner.js.map +1 -1
  207. package/dist/tasks/qa-run-binding.d.ts +88 -0
  208. package/dist/tasks/qa-run-binding.d.ts.map +1 -0
  209. package/dist/tasks/qa-run-binding.js +188 -0
  210. package/dist/tasks/qa-run-binding.js.map +1 -0
  211. package/dist/tasks/req.d.ts +50 -0
  212. package/dist/tasks/req.d.ts.map +1 -1
  213. package/dist/tasks/req.js +99 -1
  214. package/dist/tasks/req.js.map +1 -1
  215. package/dist/tasks/test-run-binding.d.ts +29 -0
  216. package/dist/tasks/test-run-binding.d.ts.map +1 -1
  217. package/dist/tasks/test-run-binding.js +47 -23
  218. package/dist/tasks/test-run-binding.js.map +1 -1
  219. package/dist/tasks/tool-resolver.d.ts +9 -0
  220. package/dist/tasks/tool-resolver.d.ts.map +1 -1
  221. package/dist/tasks/tool-resolver.js +14 -3
  222. package/dist/tasks/tool-resolver.js.map +1 -1
  223. package/dist/tasks/tool-semaphore.d.ts.map +1 -1
  224. package/dist/tasks/tool-semaphore.js +3 -0
  225. package/dist/tasks/tool-semaphore.js.map +1 -1
  226. package/dist/upgrade.d.ts.map +1 -1
  227. package/dist/upgrade.js +10 -1
  228. package/dist/upgrade.js.map +1 -1
  229. package/dist/validation/engine-ops.d.ts.map +1 -1
  230. package/dist/validation/engine-ops.js +14 -3
  231. package/dist/validation/engine-ops.js.map +1 -1
  232. package/migrations/drizzle-cleo-global/20261010130000_t12895-brain-natural-uids/migration.sql +28 -0
  233. package/migrations/drizzle-cleo-global/20261010140000_t12896-brain-autoinc-uids/migration.sql +54 -0
  234. package/migrations/drizzle-cleo-project/20261010130000_t12895-brain-natural-uids/migration.sql +36 -0
  235. package/migrations/drizzle-cleo-project/20261010140000_t12896-brain-autoinc-uids/migration.sql +58 -0
  236. package/migrations/sync-journal/20261008120000_t12343-pull-cursor/migration.sql +28 -0
  237. package/migrations/sync-journal/20261009120000_t13318-seen-floor/migration.sql +41 -0
  238. package/package.json +18 -18
  239. package/templates/CLEO-INJECTION.md +5 -3
  240. package/templates/CLEO-REFERENCE.md +1 -1
  241. package/templates/git-hooks/legacy-hashes.json +20 -0
  242. 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 { fileIdentity, readActiveReplicaId, rebindAfterVaultRestore, } from '../store/sync/replica.js';
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
- let deltaSegmentSeq = null;
878
- let cp = null;
879
- // A segment another device appends between our replay and our checkpoint
880
- // makes the server's count or replica-map check fail: replay once more
881
- // (our own delta is then part of the history) and retry (T12975). The
882
- // retry seals the bundle under a new checkpoint id (the AAD covers it),
883
- // so the first attempt's uploaded blob is left unreferenced: one orphan
884
- // per retry, for the server's blob garbage collection.
885
- for (let attempt = 0; cp === null; attempt++) {
886
- const replay = await replaySegments(journal, parent ? cursorFromCheckpoint(parent) : initialPullCursor(), key.signers);
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
  *