@cleocode/core 2026.9.23 → 2026.9.25
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/admin/import-tasks.d.ts.map +1 -1
- package/dist/admin/import-tasks.js +35 -6
- package/dist/admin/import-tasks.js.map +1 -1
- package/dist/admin/import.d.ts.map +1 -1
- package/dist/admin/import.js +25 -6
- package/dist/admin/import.js.map +1 -1
- package/dist/audit-prune.d.ts +32 -0
- package/dist/audit-prune.d.ts.map +1 -1
- package/dist/audit-prune.js +37 -3
- package/dist/audit-prune.js.map +1 -1
- package/dist/build-provenance.json +2 -2
- package/dist/cloud/crypto.d.ts +1 -2
- package/dist/cloud/crypto.d.ts.map +1 -1
- package/dist/cloud/crypto.js +1 -11
- package/dist/cloud/crypto.js.map +1 -1
- package/dist/cloud/http.d.ts +8 -0
- package/dist/cloud/http.d.ts.map +1 -1
- package/dist/cloud/http.js +8 -1
- package/dist/cloud/http.js.map +1 -1
- package/dist/cloud/index.d.ts +6 -1
- package/dist/cloud/index.d.ts.map +1 -1
- package/dist/cloud/index.js +6 -1
- package/dist/cloud/index.js.map +1 -1
- package/dist/cloud/nexus-auth.d.ts +169 -0
- package/dist/cloud/nexus-auth.d.ts.map +1 -0
- package/dist/cloud/nexus-auth.js +372 -0
- package/dist/cloud/nexus-auth.js.map +1 -0
- package/dist/cloud/nexus-credentials.d.ts +188 -0
- package/dist/cloud/nexus-credentials.d.ts.map +1 -0
- package/dist/cloud/nexus-credentials.js +323 -0
- package/dist/cloud/nexus-credentials.js.map +1 -0
- package/dist/cloud/nexus-link.d.ts +85 -0
- package/dist/cloud/nexus-link.d.ts.map +1 -0
- package/dist/cloud/nexus-link.js +226 -0
- package/dist/cloud/nexus-link.js.map +1 -0
- package/dist/cloud/presence.d.ts +88 -0
- package/dist/cloud/presence.d.ts.map +1 -0
- package/dist/cloud/presence.js +137 -0
- package/dist/cloud/presence.js.map +1 -0
- package/dist/cloud/projects.d.ts +3 -1
- package/dist/cloud/projects.d.ts.map +1 -1
- package/dist/cloud/projects.js +4 -2
- package/dist/cloud/projects.js.map +1 -1
- package/dist/cloud/uuidv7.d.ts +9 -0
- package/dist/cloud/uuidv7.d.ts.map +1 -0
- package/dist/cloud/uuidv7.js +19 -0
- package/dist/cloud/uuidv7.js.map +1 -0
- package/dist/crypto/credentials.d.ts +14 -4
- package/dist/crypto/credentials.d.ts.map +1 -1
- package/dist/crypto/credentials.js +15 -1
- package/dist/crypto/credentials.js.map +1 -1
- package/dist/decide/bench/dataset.d.ts +125 -0
- package/dist/decide/bench/dataset.d.ts.map +1 -0
- package/dist/decide/bench/dataset.js +457 -0
- package/dist/decide/bench/dataset.js.map +1 -0
- package/dist/decide/bench/index.d.ts +23 -0
- package/dist/decide/bench/index.d.ts.map +1 -0
- package/dist/decide/bench/index.js +23 -0
- package/dist/decide/bench/index.js.map +1 -0
- package/dist/decide/bench/metrics.d.ts +55 -0
- package/dist/decide/bench/metrics.d.ts.map +1 -0
- package/dist/decide/bench/metrics.js +100 -0
- package/dist/decide/bench/metrics.js.map +1 -0
- package/dist/decide/bench/operation.d.ts +119 -0
- package/dist/decide/bench/operation.d.ts.map +1 -0
- package/dist/decide/bench/operation.js +171 -0
- package/dist/decide/bench/operation.js.map +1 -0
- package/dist/decide/bench/profiles.d.ts +102 -0
- package/dist/decide/bench/profiles.d.ts.map +1 -0
- package/dist/decide/bench/profiles.js +175 -0
- package/dist/decide/bench/profiles.js.map +1 -0
- package/dist/decide/bench/questions.d.ts +40 -0
- package/dist/decide/bench/questions.d.ts.map +1 -0
- package/dist/decide/bench/questions.js +107 -0
- package/dist/decide/bench/questions.js.map +1 -0
- package/dist/decide/bench/report.d.ts +21 -0
- package/dist/decide/bench/report.d.ts.map +1 -0
- package/dist/decide/bench/report.js +125 -0
- package/dist/decide/bench/report.js.map +1 -0
- package/dist/decide/bench/runner.d.ts +208 -0
- package/dist/decide/bench/runner.d.ts.map +1 -0
- package/dist/decide/bench/runner.js +336 -0
- package/dist/decide/bench/runner.js.map +1 -0
- package/dist/decide/bench/source.d.ts +21 -0
- package/dist/decide/bench/source.d.ts.map +1 -0
- package/dist/decide/bench/source.js +67 -0
- package/dist/decide/bench/source.js.map +1 -0
- package/dist/decide/bench/spot-check.d.ts +117 -0
- package/dist/decide/bench/spot-check.d.ts.map +1 -0
- package/dist/decide/bench/spot-check.js +221 -0
- package/dist/decide/bench/spot-check.js.map +1 -0
- package/dist/decide/bench/stats.d.ts +57 -0
- package/dist/decide/bench/stats.d.ts.map +1 -0
- package/dist/decide/bench/stats.js +82 -0
- package/dist/decide/bench/stats.js.map +1 -0
- package/dist/decide/bench/types.d.ts +216 -0
- package/dist/decide/bench/types.d.ts.map +1 -0
- package/dist/decide/bench/types.js +104 -0
- package/dist/decide/bench/types.js.map +1 -0
- package/dist/decide/client.d.ts +35 -1
- package/dist/decide/client.d.ts.map +1 -1
- package/dist/decide/client.js +135 -35
- package/dist/decide/client.js.map +1 -1
- package/dist/decide/credentials.d.ts +177 -17
- package/dist/decide/credentials.d.ts.map +1 -1
- package/dist/decide/credentials.js +505 -41
- package/dist/decide/credentials.js.map +1 -1
- package/dist/decide/index.d.ts +8 -8
- package/dist/decide/index.d.ts.map +1 -1
- package/dist/decide/index.js +8 -8
- package/dist/decide/index.js.map +1 -1
- package/dist/decide/jev-wire.d.ts +55 -19
- package/dist/decide/jev-wire.d.ts.map +1 -1
- package/dist/decide/jev-wire.js +116 -37
- package/dist/decide/jev-wire.js.map +1 -1
- package/dist/decide/operations.d.ts +63 -12
- package/dist/decide/operations.d.ts.map +1 -1
- package/dist/decide/operations.js +116 -58
- package/dist/decide/operations.js.map +1 -1
- package/dist/decide/provider-state.d.ts +85 -12
- package/dist/decide/provider-state.d.ts.map +1 -1
- package/dist/decide/provider-state.js +154 -21
- package/dist/decide/provider-state.js.map +1 -1
- package/dist/decide/provider.d.ts +5 -0
- package/dist/decide/provider.d.ts.map +1 -1
- package/dist/decide/provider.js.map +1 -1
- package/dist/decide/providers.d.ts +15 -2
- package/dist/decide/providers.d.ts.map +1 -1
- package/dist/decide/providers.js +22 -6
- package/dist/decide/providers.js.map +1 -1
- package/dist/decide/sites/registry.d.ts +22 -0
- package/dist/decide/sites/registry.d.ts.map +1 -1
- package/dist/decide/sites/registry.js +23 -0
- package/dist/decide/sites/registry.js.map +1 -1
- package/dist/decide/wizard.d.ts +12 -4
- package/dist/decide/wizard.d.ts.map +1 -1
- package/dist/decide/wizard.js +79 -26
- package/dist/decide/wizard.js.map +1 -1
- package/dist/dispatch/mvi-projection.d.ts +15 -2
- package/dist/dispatch/mvi-projection.d.ts.map +1 -1
- package/dist/dispatch/mvi-projection.js +73 -12
- package/dist/dispatch/mvi-projection.js.map +1 -1
- package/dist/docs/adr-allocator.d.ts +2 -2
- package/dist/docs/adr-allocator.d.ts.map +1 -1
- package/dist/docs/adr-allocator.js +17 -4
- package/dist/docs/adr-allocator.js.map +1 -1
- package/dist/docs/blob-consolidation.js +1 -1
- package/dist/docs/blob-consolidation.js.map +1 -1
- package/dist/docs/display-alias.d.ts +1 -1
- package/dist/docs/display-alias.d.ts.map +1 -1
- package/dist/docs/display-alias.js +7 -4
- package/dist/docs/display-alias.js.map +1 -1
- package/dist/docs/docs-inconsistency-detector.js +1 -1
- package/dist/docs/docs-inconsistency-detector.js.map +1 -1
- package/dist/docs/docs-update.d.ts.map +1 -1
- package/dist/docs/docs-update.js +6 -1
- package/dist/docs/docs-update.js.map +1 -1
- package/dist/docs/export-document.js +18058 -14538
- package/dist/docs/export-document.js.map +3 -3
- package/dist/docs/numbering.js +1 -1
- package/dist/docs/numbering.js.map +1 -1
- package/dist/docs/supersede.d.ts.map +1 -1
- package/dist/docs/supersede.js +6 -3
- package/dist/docs/supersede.js.map +1 -1
- package/dist/docs/version-ssot.js +1 -1
- package/dist/docs/version-ssot.js.map +1 -1
- package/dist/doctor/db-substrate.d.ts.map +1 -1
- package/dist/doctor/db-substrate.js +39 -24
- package/dist/doctor/db-substrate.js.map +1 -1
- package/dist/doctor/knowledge-summary.d.ts +10 -3
- package/dist/doctor/knowledge-summary.d.ts.map +1 -1
- package/dist/doctor/knowledge-summary.js +10 -3
- package/dist/doctor/knowledge-summary.js.map +1 -1
- package/dist/doctor/orphan-ac-bindings.d.ts +64 -0
- package/dist/doctor/orphan-ac-bindings.d.ts.map +1 -0
- package/dist/doctor/orphan-ac-bindings.js +59 -0
- package/dist/doctor/orphan-ac-bindings.js.map +1 -0
- package/dist/doctor/project-identity.d.ts +128 -37
- package/dist/doctor/project-identity.d.ts.map +1 -1
- package/dist/doctor/project-identity.js +476 -63
- package/dist/doctor/project-identity.js.map +1 -1
- package/dist/doctor/projects.d.ts.map +1 -1
- package/dist/doctor/projects.js +43 -29
- package/dist/doctor/projects.js.map +1 -1
- package/dist/doctor/row-identity.d.ts +31 -0
- package/dist/doctor/row-identity.d.ts.map +1 -0
- package/dist/doctor/row-identity.js +93 -0
- package/dist/doctor/row-identity.js.map +1 -0
- package/dist/doctor/twin-collapse.d.ts +108 -2
- package/dist/doctor/twin-collapse.d.ts.map +1 -1
- package/dist/doctor/twin-collapse.js +309 -7
- package/dist/doctor/twin-collapse.js.map +1 -1
- package/dist/error-catalog.js +2 -2
- package/dist/error-catalog.js.map +1 -1
- package/dist/error-registry.js +1 -1
- package/dist/error-registry.js.map +1 -1
- package/dist/gateway-client/generated/types.gen.d.ts +22 -2
- package/dist/gateway-client/generated/types.gen.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +20 -11
- package/dist/init.js.map +1 -1
- package/dist/inject/index.d.ts.map +1 -1
- package/dist/inject/index.js +2 -3
- package/dist/inject/index.js.map +1 -1
- package/dist/internal.d.ts +5 -4
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +6 -4
- package/dist/internal.js.map +1 -1
- package/dist/lifecycle/verification/satisfies-validator.d.ts.map +1 -1
- package/dist/lifecycle/verification/satisfies-validator.js +10 -0
- package/dist/lifecycle/verification/satisfies-validator.js.map +1 -1
- package/dist/llm/credential-remove-entry.d.ts +59 -0
- package/dist/llm/credential-remove-entry.d.ts.map +1 -0
- package/dist/llm/credential-remove-entry.js +101 -0
- package/dist/llm/credential-remove-entry.js.map +1 -0
- package/dist/llm/oauth/device-code.d.ts +26 -6
- package/dist/llm/oauth/device-code.d.ts.map +1 -1
- package/dist/llm/oauth/device-code.js +44 -12
- package/dist/llm/oauth/device-code.js.map +1 -1
- package/dist/llm/plugin-facade.js +18202 -14682
- package/dist/llm/plugin-facade.js.map +3 -3
- package/dist/memory/attention.d.ts.map +1 -1
- package/dist/memory/attention.js +5 -3
- package/dist/memory/attention.js.map +1 -1
- package/dist/memory/decisions.d.ts +48 -3
- package/dist/memory/decisions.d.ts.map +1 -1
- package/dist/memory/decisions.js +98 -14
- package/dist/memory/decisions.js.map +1 -1
- package/dist/memory/index.d.ts.map +1 -1
- package/dist/memory/index.js +19 -10
- package/dist/memory/index.js.map +1 -1
- package/dist/memory/learnings.d.ts +6 -6
- package/dist/memory/manifest-identity.d.ts +171 -0
- package/dist/memory/manifest-identity.d.ts.map +1 -0
- package/dist/memory/manifest-identity.js +345 -0
- package/dist/memory/manifest-identity.js.map +1 -0
- package/dist/memory/mental-model-queue.d.ts +14 -4
- package/dist/memory/mental-model-queue.d.ts.map +1 -1
- package/dist/memory/mental-model-queue.js +64 -35
- package/dist/memory/mental-model-queue.js.map +1 -1
- package/dist/memory/patterns.d.ts +4 -4
- package/dist/memory/pipeline-manifest-sqlite.d.ts +47 -5
- package/dist/memory/pipeline-manifest-sqlite.d.ts.map +1 -1
- package/dist/memory/pipeline-manifest-sqlite.js +130 -14
- package/dist/memory/pipeline-manifest-sqlite.js.map +1 -1
- package/dist/memory/retrieval/observe.d.ts.map +1 -1
- package/dist/memory/retrieval/observe.js +2 -0
- package/dist/memory/retrieval/observe.js.map +1 -1
- package/dist/memory/transcript-scanner.js +1 -1
- package/dist/migration/manual/T10165-backfill-adr-index.d.ts.map +1 -1
- package/dist/migration/manual/T10165-backfill-adr-index.js +5 -1
- package/dist/migration/manual/T10165-backfill-adr-index.js.map +1 -1
- package/dist/nexus/deps.d.ts.map +1 -1
- package/dist/nexus/deps.js +7 -14
- package/dist/nexus/deps.js.map +1 -1
- package/dist/nexus/discover.d.ts.map +1 -1
- package/dist/nexus/discover.js +6 -9
- package/dist/nexus/discover.js.map +1 -1
- package/dist/nexus/fleet-status.d.ts +83 -0
- package/dist/nexus/fleet-status.d.ts.map +1 -0
- package/dist/nexus/fleet-status.js +414 -0
- package/dist/nexus/fleet-status.js.map +1 -0
- package/dist/nexus/git-state.d.ts +15 -0
- package/dist/nexus/git-state.d.ts.map +1 -1
- package/dist/nexus/git-state.js +77 -3
- package/dist/nexus/git-state.js.map +1 -1
- package/dist/nexus/identity.d.ts.map +1 -1
- package/dist/nexus/identity.js +4 -0
- package/dist/nexus/identity.js.map +1 -1
- package/dist/nexus/index.d.ts +4 -1
- package/dist/nexus/index.d.ts.map +1 -1
- package/dist/nexus/index.js +7 -1
- package/dist/nexus/index.js.map +1 -1
- package/dist/nexus/migrate-json-to-sqlite.d.ts +3 -2
- package/dist/nexus/migrate-json-to-sqlite.d.ts.map +1 -1
- package/dist/nexus/migrate-json-to-sqlite.js +9 -17
- package/dist/nexus/migrate-json-to-sqlite.js.map +1 -1
- package/dist/nexus/ops.d.ts +1 -0
- package/dist/nexus/ops.d.ts.map +1 -1
- package/dist/nexus/project-activity.d.ts +104 -0
- package/dist/nexus/project-activity.d.ts.map +1 -0
- package/dist/nexus/project-activity.js +150 -0
- package/dist/nexus/project-activity.js.map +1 -0
- package/dist/nexus/projects-clean.d.ts.map +1 -1
- package/dist/nexus/projects-clean.js +22 -11
- package/dist/nexus/projects-clean.js.map +1 -1
- package/dist/nexus/projects-scan.d.ts.map +1 -1
- package/dist/nexus/projects-scan.js +8 -13
- package/dist/nexus/projects-scan.js.map +1 -1
- package/dist/nexus/query.d.ts +2 -2
- package/dist/nexus/query.d.ts.map +1 -1
- package/dist/nexus/query.js +8 -31
- package/dist/nexus/query.js.map +1 -1
- package/dist/nexus/registry-errors.d.ts +61 -0
- package/dist/nexus/registry-errors.d.ts.map +1 -0
- package/dist/nexus/registry-errors.js +85 -0
- package/dist/nexus/registry-errors.js.map +1 -0
- package/dist/nexus/registry-roots.d.ts +5 -2
- package/dist/nexus/registry-roots.d.ts.map +1 -1
- package/dist/nexus/registry-roots.js +8 -10
- package/dist/nexus/registry-roots.js.map +1 -1
- package/dist/nexus/registry.d.ts +48 -5
- package/dist/nexus/registry.d.ts.map +1 -1
- package/dist/nexus/registry.js +151 -85
- package/dist/nexus/registry.js.map +1 -1
- package/dist/orchestrate/pivot.d.ts +4 -0
- package/dist/orchestrate/pivot.d.ts.map +1 -1
- package/dist/orchestrate/pivot.js +75 -12
- package/dist/orchestrate/pivot.js.map +1 -1
- package/dist/orchestrate/query-ops.d.ts.map +1 -1
- package/dist/orchestrate/query-ops.js +29 -27
- package/dist/orchestrate/query-ops.js.map +1 -1
- package/dist/orchestrate/spawn-ops.d.ts.map +1 -1
- package/dist/orchestrate/spawn-ops.js +29 -3
- package/dist/orchestrate/spawn-ops.js.map +1 -1
- package/dist/orchestrate/worktree-complete.d.ts.map +1 -1
- package/dist/orchestrate/worktree-complete.js +40 -2
- package/dist/orchestrate/worktree-complete.js.map +1 -1
- package/dist/orchestration/bootstrap.d.ts.map +1 -1
- package/dist/orchestration/bootstrap.js +20 -14
- package/dist/orchestration/bootstrap.js.map +1 -1
- package/dist/orchestration/index.d.ts +15 -1
- package/dist/orchestration/index.d.ts.map +1 -1
- package/dist/orchestration/index.js +25 -6
- package/dist/orchestration/index.js.map +1 -1
- package/dist/orchestration/lead-rollup.d.ts +7 -1
- package/dist/orchestration/lead-rollup.d.ts.map +1 -1
- package/dist/orchestration/lead-rollup.js +6 -3
- package/dist/orchestration/lead-rollup.js.map +1 -1
- package/dist/orchestration/spawn-prompt.d.ts +26 -0
- package/dist/orchestration/spawn-prompt.d.ts.map +1 -1
- package/dist/orchestration/spawn-prompt.js +63 -92
- package/dist/orchestration/spawn-prompt.js.map +1 -1
- package/dist/orchestration/waves.d.ts +21 -10
- package/dist/orchestration/waves.d.ts.map +1 -1
- package/dist/orchestration/waves.js +38 -35
- package/dist/orchestration/waves.js.map +1 -1
- package/dist/paths.d.ts +9 -2
- package/dist/paths.d.ts.map +1 -1
- package/dist/paths.js +30 -13
- package/dist/paths.js.map +1 -1
- package/dist/phases/index.d.ts +1 -1
- package/dist/phases/index.d.ts.map +1 -1
- package/dist/phases/index.js +3 -4
- package/dist/phases/index.js.map +1 -1
- package/dist/project-info.d.ts +40 -3
- package/dist/project-info.d.ts.map +1 -1
- package/dist/project-info.js +74 -6
- package/dist/project-info.js.map +1 -1
- package/dist/project-lifecycle.d.ts +28 -7
- package/dist/project-lifecycle.d.ts.map +1 -1
- package/dist/project-lifecycle.js +114 -47
- package/dist/project-lifecycle.js.map +1 -1
- package/dist/project-scope.d.ts +70 -3
- package/dist/project-scope.d.ts.map +1 -1
- package/dist/project-scope.js +154 -15
- package/dist/project-scope.js.map +1 -1
- package/dist/release/ci-evidence.d.ts +190 -0
- package/dist/release/ci-evidence.d.ts.map +1 -1
- package/dist/release/ci-evidence.js +436 -4
- package/dist/release/ci-evidence.js.map +1 -1
- package/dist/release/open.d.ts +12 -2
- package/dist/release/open.d.ts.map +1 -1
- package/dist/release/open.js +32 -3
- package/dist/release/open.js.map +1 -1
- package/dist/release/preflight-skip.d.ts +73 -0
- package/dist/release/preflight-skip.d.ts.map +1 -0
- package/dist/release/preflight-skip.js +254 -0
- package/dist/release/preflight-skip.js.map +1 -0
- package/dist/render/decide/index.d.ts +51 -0
- package/dist/render/decide/index.d.ts.map +1 -0
- package/dist/render/decide/index.js +289 -0
- package/dist/render/decide/index.js.map +1 -0
- package/dist/render/index.d.ts +1 -0
- package/dist/render/index.d.ts.map +1 -1
- package/dist/render/index.js +1 -0
- package/dist/render/index.js.map +1 -1
- package/dist/render/nexus/graph/index.d.ts +5 -0
- package/dist/render/nexus/graph/index.d.ts.map +1 -1
- package/dist/render/nexus/graph/index.js +53 -0
- package/dist/render/nexus/graph/index.js.map +1 -1
- package/dist/scaffold/ensure-config.d.ts +14 -6
- package/dist/scaffold/ensure-config.d.ts.map +1 -1
- package/dist/scaffold/ensure-config.js +39 -17
- package/dist/scaffold/ensure-config.js.map +1 -1
- package/dist/scaffold/project-identity.d.ts +115 -23
- package/dist/scaffold/project-identity.d.ts.map +1 -1
- package/dist/scaffold/project-identity.js +196 -44
- package/dist/scaffold/project-identity.js.map +1 -1
- package/dist/sdk/validator-ac-pull.d.ts.map +1 -1
- package/dist/sdk/validator-ac-pull.js +2 -1
- package/dist/sdk/validator-ac-pull.js.map +1 -1
- package/dist/selfimprove/fix-gen.js +18221 -14701
- package/dist/selfimprove/fix-gen.js.map +3 -3
- package/dist/sentient/cross-project-hygiene.d.ts +17 -6
- package/dist/sentient/cross-project-hygiene.d.ts.map +1 -1
- package/dist/sentient/cross-project-hygiene.js +22 -11
- package/dist/sentient/cross-project-hygiene.js.map +1 -1
- package/dist/sentient/propose-tick.d.ts.map +1 -1
- package/dist/sentient/propose-tick.js +2 -3
- package/dist/sentient/propose-tick.js.map +1 -1
- package/dist/sentient/stage-drift-tick.d.ts.map +1 -1
- package/dist/sentient/stage-drift-tick.js +2 -3
- package/dist/sentient/stage-drift-tick.js.map +1 -1
- package/dist/session/engine-ops.d.ts +13 -4
- package/dist/session/engine-ops.d.ts.map +1 -1
- package/dist/session/engine-ops.js +38 -45
- package/dist/session/engine-ops.js.map +1 -1
- package/dist/sessions/briefing.d.ts +7 -3
- package/dist/sessions/briefing.d.ts.map +1 -1
- package/dist/sessions/briefing.js +10 -10
- package/dist/sessions/briefing.js.map +1 -1
- package/dist/sessions/drift-watchdog.d.ts.map +1 -1
- package/dist/sessions/drift-watchdog.js +11 -19
- package/dist/sessions/drift-watchdog.js.map +1 -1
- package/dist/sessions/focus-state-store.d.ts +103 -26
- package/dist/sessions/focus-state-store.d.ts.map +1 -1
- package/dist/sessions/focus-state-store.js +166 -37
- package/dist/sessions/focus-state-store.js.map +1 -1
- package/dist/sessions/handoff.d.ts.map +1 -1
- package/dist/sessions/handoff.js +9 -18
- package/dist/sessions/handoff.js.map +1 -1
- package/dist/sessions/index.d.ts +4 -2
- package/dist/sessions/index.d.ts.map +1 -1
- package/dist/sessions/index.js +35 -6
- package/dist/sessions/index.js.map +1 -1
- package/dist/sessions/session-drift.d.ts.map +1 -1
- package/dist/sessions/session-drift.js +4 -2
- package/dist/sessions/session-drift.js.map +1 -1
- package/dist/sessions/session-view.d.ts +8 -1
- package/dist/sessions/session-view.d.ts.map +1 -1
- package/dist/sessions/session-view.js +9 -2
- package/dist/sessions/session-view.js.map +1 -1
- package/dist/setup/config-json-merge.d.ts.map +1 -1
- package/dist/setup/config-json-merge.js +1 -0
- package/dist/setup/config-json-merge.js.map +1 -1
- package/dist/setup/index.d.ts +4 -2
- package/dist/setup/index.d.ts.map +1 -1
- package/dist/setup/index.js +6 -2
- package/dist/setup/index.js.map +1 -1
- package/dist/setup/sections/nexus-account.d.ts +32 -0
- package/dist/setup/sections/nexus-account.d.ts.map +1 -0
- package/dist/setup/sections/nexus-account.js +64 -0
- package/dist/setup/sections/nexus-account.js.map +1 -0
- package/dist/setup/wizard.d.ts +1 -1
- package/dist/setup/wizard.d.ts.map +1 -1
- package/dist/setup/wizard.js.map +1 -1
- package/dist/skills/manifests/research.js +2 -0
- package/dist/skills/manifests/research.js.map +1 -1
- package/dist/skills/orchestrator/startup.d.ts.map +1 -1
- package/dist/skills/orchestrator/startup.js +18 -31
- package/dist/skills/orchestrator/startup.js.map +1 -1
- package/dist/skills/orchestrator/validator.d.ts.map +1 -1
- package/dist/skills/orchestrator/validator.js +9 -2
- package/dist/skills/orchestrator/validator.js.map +1 -1
- package/dist/skills/validation.d.ts +4 -1
- package/dist/skills/validation.d.ts.map +1 -1
- package/dist/skills/validation.js +11 -3
- package/dist/skills/validation.js.map +1 -1
- package/dist/snapshot/index.d.ts.map +1 -1
- package/dist/snapshot/index.js +60 -53
- package/dist/snapshot/index.js.map +1 -1
- package/dist/spawn/agent-identity.d.ts +33 -1
- package/dist/spawn/agent-identity.d.ts.map +1 -1
- package/dist/spawn/agent-identity.js +53 -1
- package/dist/spawn/agent-identity.js.map +1 -1
- package/dist/spawn/branch-lock.d.ts +111 -7
- package/dist/spawn/branch-lock.d.ts.map +1 -1
- package/dist/spawn/branch-lock.js +296 -8
- package/dist/spawn/branch-lock.js.map +1 -1
- package/dist/spawn/index.d.ts +1 -1
- package/dist/spawn/index.d.ts.map +1 -1
- package/dist/spawn/index.js +1 -1
- package/dist/spawn/index.js.map +1 -1
- package/dist/stats/index.d.ts.map +1 -1
- package/dist/stats/index.js +3 -4
- package/dist/stats/index.js.map +1 -1
- package/dist/status/index.d.ts +12 -3
- package/dist/status/index.d.ts.map +1 -1
- package/dist/status/index.js +47 -5
- package/dist/status/index.js.map +1 -1
- package/dist/store/ac-binding-prune.d.ts +144 -0
- package/dist/store/ac-binding-prune.d.ts.map +1 -0
- package/dist/store/ac-binding-prune.js +293 -0
- package/dist/store/ac-binding-prune.js.map +1 -0
- package/dist/store/agent-registry-store.d.ts.map +1 -1
- package/dist/store/agent-registry-store.js +3 -2
- package/dist/store/agent-registry-store.js.map +1 -1
- package/dist/store/attachment-repair.d.ts.map +1 -1
- package/dist/store/attachment-repair.js +42 -3
- package/dist/store/attachment-repair.js.map +1 -1
- package/dist/store/attachment-store.d.ts.map +1 -1
- package/dist/store/attachment-store.js +30 -46
- package/dist/store/attachment-store.js.map +1 -1
- package/dist/store/backup-recover-brain.d.ts +4 -0
- package/dist/store/backup-recover-brain.d.ts.map +1 -1
- package/dist/store/backup-recover-brain.js +2 -0
- package/dist/store/backup-recover-brain.js.map +1 -1
- package/dist/store/backup-recover.d.ts +9 -0
- package/dist/store/backup-recover.d.ts.map +1 -1
- package/dist/store/backup-recover.js +28 -1
- package/dist/store/backup-recover.js.map +1 -1
- package/dist/store/backup-sidecar.d.ts +115 -2
- package/dist/store/backup-sidecar.d.ts.map +1 -1
- package/dist/store/backup-sidecar.js +263 -3
- package/dist/store/backup-sidecar.js.map +1 -1
- package/dist/store/blob-keep.d.ts +135 -0
- package/dist/store/blob-keep.d.ts.map +1 -0
- package/dist/store/blob-keep.js +283 -0
- package/dist/store/blob-keep.js.map +1 -0
- package/dist/store/conduit-sqlite.d.ts.map +1 -1
- package/dist/store/conduit-sqlite.js +3 -2
- package/dist/store/conduit-sqlite.js.map +1 -1
- package/dist/store/converters.d.ts.map +1 -1
- package/dist/store/converters.js +11 -0
- package/dist/store/converters.js.map +1 -1
- package/dist/store/credential-transfer.d.ts +37 -5
- package/dist/store/credential-transfer.d.ts.map +1 -1
- package/dist/store/credential-transfer.js +76 -18
- package/dist/store/credential-transfer.js.map +1 -1
- package/dist/store/data-accessor.d.ts +1 -1
- package/dist/store/data-accessor.d.ts.map +1 -1
- package/dist/store/data-accessor.js.map +1 -1
- package/dist/store/db-helpers.d.ts +35 -11
- package/dist/store/db-helpers.d.ts.map +1 -1
- package/dist/store/db-helpers.js +107 -38
- package/dist/store/db-helpers.js.map +1 -1
- package/dist/store/display-id-alias.d.ts +529 -0
- package/dist/store/display-id-alias.d.ts.map +1 -0
- package/dist/store/display-id-alias.js +1063 -0
- package/dist/store/display-id-alias.js.map +1 -0
- package/dist/store/dual-scope-db.d.ts.map +1 -1
- package/dist/store/dual-scope-db.js +21 -0
- package/dist/store/dual-scope-db.js.map +1 -1
- package/dist/store/exodus/on-open.d.ts.map +1 -1
- package/dist/store/exodus/on-open.js +35 -0
- package/dist/store/exodus/on-open.js.map +1 -1
- package/dist/store/exodus/reconcile.d.ts.map +1 -1
- package/dist/store/exodus/reconcile.js +7 -2
- package/dist/store/exodus/reconcile.js.map +1 -1
- package/dist/store/exodus/recovery.d.ts.map +1 -1
- package/dist/store/exodus/recovery.js +20 -1
- package/dist/store/exodus/recovery.js.map +1 -1
- package/dist/store/exodus/runtime-targets.d.ts +3 -3
- package/dist/store/exodus/runtime-targets.js +3 -3
- package/dist/store/import-remap.d.ts +12 -1
- package/dist/store/import-remap.d.ts.map +1 -1
- package/dist/store/import-remap.js +14 -0
- package/dist/store/import-remap.js.map +1 -1
- package/dist/store/legacy-tasks-lineage.d.ts +3 -1
- package/dist/store/legacy-tasks-lineage.d.ts.map +1 -1
- package/dist/store/legacy-tasks-lineage.js +22 -3
- package/dist/store/legacy-tasks-lineage.js.map +1 -1
- package/dist/store/memory-accessor.js +1 -1
- package/dist/store/memory-accessor.js.map +1 -1
- package/dist/store/migration-manager.d.ts +61 -0
- package/dist/store/migration-manager.d.ts.map +1 -1
- package/dist/store/migration-manager.js +102 -4
- package/dist/store/migration-manager.js.map +1 -1
- package/dist/store/nexus-validation-schemas.d.ts +64 -0
- package/dist/store/nexus-validation-schemas.d.ts.map +1 -1
- package/dist/store/portable-bundle-import.d.ts +8 -0
- package/dist/store/portable-bundle-import.d.ts.map +1 -1
- package/dist/store/portable-bundle-import.js +10 -0
- package/dist/store/portable-bundle-import.js.map +1 -1
- package/dist/store/portable-bundle-scan.d.ts.map +1 -1
- package/dist/store/portable-bundle-scan.js +1 -0
- package/dist/store/portable-bundle-scan.js.map +1 -1
- package/dist/store/portable-bundle.d.ts.map +1 -1
- package/dist/store/portable-bundle.js +15 -14
- package/dist/store/portable-bundle.js.map +1 -1
- package/dist/store/pre-repair-snapshot.d.ts +4 -1
- package/dist/store/pre-repair-snapshot.d.ts.map +1 -1
- package/dist/store/pre-repair-snapshot.js +5 -1
- package/dist/store/pre-repair-snapshot.js.map +1 -1
- package/dist/store/regenerators.d.ts +4 -2
- package/dist/store/regenerators.d.ts.map +1 -1
- package/dist/store/regenerators.js +7 -3
- package/dist/store/regenerators.js.map +1 -1
- package/dist/store/repair-malformed-dbs.d.ts +4 -0
- package/dist/store/repair-malformed-dbs.d.ts.map +1 -1
- package/dist/store/repair-malformed-dbs.js +3 -0
- package/dist/store/repair-malformed-dbs.js.map +1 -1
- package/dist/store/row-identity-flag.d.ts +26 -0
- package/dist/store/row-identity-flag.d.ts.map +1 -0
- package/dist/store/row-identity-flag.js +31 -0
- package/dist/store/row-identity-flag.js.map +1 -0
- package/dist/store/row-identity-registry.d.ts +61 -0
- package/dist/store/row-identity-registry.d.ts.map +1 -0
- package/dist/store/row-identity-registry.js +198 -0
- package/dist/store/row-identity-registry.js.map +1 -0
- package/dist/store/row-identity.d.ts +390 -0
- package/dist/store/row-identity.d.ts.map +1 -0
- package/dist/store/row-identity.js +1240 -0
- package/dist/store/row-identity.js.map +1 -0
- package/dist/store/safety-data-accessor.d.ts +10 -3
- package/dist/store/safety-data-accessor.d.ts.map +1 -1
- package/dist/store/safety-data-accessor.js +15 -4
- package/dist/store/safety-data-accessor.js.map +1 -1
- package/dist/store/schema/attachments.d.ts +1 -1
- package/dist/store/schema/audit.d.ts +4 -4
- package/dist/store/schema/background-jobs.d.ts +1 -1
- package/dist/store/schema/cleo-global/nexus.d.ts +49 -1
- package/dist/store/schema/cleo-global/nexus.d.ts.map +1 -1
- package/dist/store/schema/cleo-global/nexus.js +17 -0
- package/dist/store/schema/cleo-global/nexus.js.map +1 -1
- package/dist/store/schema/cleo-global/skills.d.ts +2 -2
- package/dist/store/schema/cleo-project/audit.d.ts +4 -4
- package/dist/store/schema/cleo-project/docs.d.ts +17 -1
- package/dist/store/schema/cleo-project/docs.d.ts.map +1 -1
- package/dist/store/schema/cleo-project/docs.js +10 -0
- package/dist/store/schema/cleo-project/docs.js.map +1 -1
- package/dist/store/schema/cleo-project/lifecycle.d.ts +4 -4
- package/dist/store/schema/cleo-project/nexus-graph.d.ts +3 -3
- package/dist/store/schema/cleo-project/provenance-commits.d.ts +2 -2
- package/dist/store/schema/cleo-project/provenance-rest.d.ts +3 -3
- package/dist/store/schema/cleo-project/runtime.d.ts +3 -3
- package/dist/store/schema/cleo-project/tasks-core-batch2.d.ts +87 -1
- package/dist/store/schema/cleo-project/tasks-core-batch2.d.ts.map +1 -1
- package/dist/store/schema/cleo-project/tasks-core-batch2.js +35 -0
- package/dist/store/schema/cleo-project/tasks-core-batch2.js.map +1 -1
- package/dist/store/schema/cleo-project/tasks-core.d.ts +263 -7
- package/dist/store/schema/cleo-project/tasks-core.d.ts.map +1 -1
- package/dist/store/schema/cleo-project/tasks-core.js +77 -2
- package/dist/store/schema/cleo-project/tasks-core.js.map +1 -1
- package/dist/store/schema/cleo-shared/brain.d.ts +7 -7
- package/dist/store/schema/lifecycle.d.ts +4 -4
- package/dist/store/schema/manifest.d.ts +1 -1
- package/dist/store/schema/memory-schema.d.ts +6 -6
- package/dist/store/schema/nexus-schema.d.ts +51 -3
- package/dist/store/schema/nexus-schema.d.ts.map +1 -1
- package/dist/store/schema/nexus-schema.js +28 -7
- package/dist/store/schema/nexus-schema.js.map +1 -1
- package/dist/store/schema/provenance/releases.d.ts +1 -1
- package/dist/store/schema/skills-schema.d.ts +2 -2
- package/dist/store/schema/tasks.d.ts +6 -6
- package/dist/store/session-store.d.ts +65 -0
- package/dist/store/session-store.d.ts.map +1 -1
- package/dist/store/session-store.js +108 -7
- package/dist/store/session-store.js.map +1 -1
- package/dist/store/split-brain-import.d.ts +2 -14
- package/dist/store/split-brain-import.d.ts.map +1 -1
- package/dist/store/split-brain-import.js +4 -19
- package/dist/store/split-brain-import.js.map +1 -1
- package/dist/store/sqlite-data-accessor.d.ts +279 -2
- package/dist/store/sqlite-data-accessor.d.ts.map +1 -1
- package/dist/store/sqlite-data-accessor.js +789 -91
- package/dist/store/sqlite-data-accessor.js.map +1 -1
- package/dist/store/sqlite.d.ts +15 -3
- package/dist/store/sqlite.d.ts.map +1 -1
- package/dist/store/sqlite.js +52 -20
- package/dist/store/sqlite.js.map +1 -1
- package/dist/store/store-write-guard.d.ts +9 -3
- package/dist/store/store-write-guard.d.ts.map +1 -1
- package/dist/store/store-write-guard.js +25 -4
- package/dist/store/store-write-guard.js.map +1 -1
- package/dist/store/sync/clock-store.d.ts +67 -0
- package/dist/store/sync/clock-store.d.ts.map +1 -0
- package/dist/store/sync/clock-store.js +117 -0
- package/dist/store/sync/clock-store.js.map +1 -0
- package/dist/store/sync/flags.d.ts +51 -0
- package/dist/store/sync/flags.d.ts.map +1 -0
- package/dist/store/sync/flags.js +81 -0
- package/dist/store/sync/flags.js.map +1 -0
- package/dist/store/sync/hlc.d.ts +115 -0
- package/dist/store/sync/hlc.d.ts.map +1 -0
- package/dist/store/sync/hlc.js +172 -0
- package/dist/store/sync/hlc.js.map +1 -0
- package/dist/store/sync/index.d.ts +20 -0
- package/dist/store/sync/index.d.ts.map +1 -0
- package/dist/store/sync/index.js +20 -0
- package/dist/store/sync/index.js.map +1 -0
- package/dist/store/sync/replica-registry.d.ts +85 -0
- package/dist/store/sync/replica-registry.d.ts.map +1 -0
- package/dist/store/sync/replica-registry.js +147 -0
- package/dist/store/sync/replica-registry.js.map +1 -0
- package/dist/store/sync/replica.d.ts +181 -0
- package/dist/store/sync/replica.d.ts.map +1 -0
- package/dist/store/sync/replica.js +305 -0
- package/dist/store/sync/replica.js.map +1 -0
- package/dist/store/sync/schema.d.ts +68 -0
- package/dist/store/sync/schema.d.ts.map +1 -0
- package/dist/store/sync/schema.js +130 -0
- package/dist/store/sync/schema.js.map +1 -0
- package/dist/store/sync/skew-hold.d.ts +71 -0
- package/dist/store/sync/skew-hold.d.ts.map +1 -0
- package/dist/store/sync/skew-hold.js +89 -0
- package/dist/store/sync/skew-hold.js.map +1 -0
- package/dist/store/table-classification.d.ts +11 -9
- package/dist/store/table-classification.d.ts.map +1 -1
- package/dist/store/table-classification.js +127 -33
- package/dist/store/table-classification.js.map +1 -1
- package/dist/store/task-claim.d.ts +144 -0
- package/dist/store/task-claim.d.ts.map +1 -0
- package/dist/store/task-claim.js +195 -0
- package/dist/store/task-claim.js.map +1 -0
- package/dist/store/task-version.d.ts +43 -5
- package/dist/store/task-version.d.ts.map +1 -1
- package/dist/store/task-version.js +113 -17
- package/dist/store/task-version.js.map +1 -1
- package/dist/store/tasks-schema.d.ts +2 -0
- package/dist/store/tasks-schema.d.ts.map +1 -1
- package/dist/store/tasks-schema.js +1 -0
- package/dist/store/tasks-schema.js.map +1 -1
- package/dist/store/tasks-sqlite.d.ts.map +1 -1
- package/dist/store/tasks-sqlite.js +10 -2
- package/dist/store/tasks-sqlite.js.map +1 -1
- package/dist/store/twin-collapse.d.ts +450 -22
- package/dist/store/twin-collapse.d.ts.map +1 -1
- package/dist/store/twin-collapse.js +1552 -49
- package/dist/store/twin-collapse.js.map +1 -1
- package/dist/store/umbrella-data-accessor.d.ts +10 -3
- package/dist/store/umbrella-data-accessor.d.ts.map +1 -1
- package/dist/store/umbrella-data-accessor.js +14 -4
- package/dist/store/umbrella-data-accessor.js.map +1 -1
- package/dist/store/validation-schemas.d.ts +402 -50
- package/dist/store/validation-schemas.d.ts.map +1 -1
- package/dist/store/with-retry.d.ts +36 -0
- package/dist/store/with-retry.d.ts.map +1 -1
- package/dist/store/with-retry.js +51 -0
- package/dist/store/with-retry.js.map +1 -1
- package/dist/store/worktree-build-guard.d.ts +13 -4
- package/dist/store/worktree-build-guard.d.ts.map +1 -1
- package/dist/store/worktree-build-guard.js +21 -10
- package/dist/store/worktree-build-guard.js.map +1 -1
- package/dist/store/worktree-isolation-guard.d.ts +121 -10
- package/dist/store/worktree-isolation-guard.d.ts.map +1 -1
- package/dist/store/worktree-isolation-guard.js +155 -34
- package/dist/store/worktree-isolation-guard.js.map +1 -1
- package/dist/system/backup.d.ts +7 -0
- package/dist/system/backup.d.ts.map +1 -1
- package/dist/system/backup.js +5 -0
- package/dist/system/backup.js.map +1 -1
- package/dist/system/health.d.ts.map +1 -1
- package/dist/system/health.js +6 -1
- package/dist/system/health.js.map +1 -1
- package/dist/system/inject-generate.d.ts.map +1 -1
- package/dist/system/inject-generate.js +23 -10
- package/dist/system/inject-generate.js.map +1 -1
- package/dist/system/project-health.d.ts +17 -2
- package/dist/system/project-health.d.ts.map +1 -1
- package/dist/system/project-health.js +19 -6
- package/dist/system/project-health.js.map +1 -1
- package/dist/task-tools/index.d.ts +1 -1
- package/dist/task-tools/index.d.ts.map +1 -1
- package/dist/task-tools/index.js +1 -1
- package/dist/task-tools/index.js.map +1 -1
- package/dist/task-tools/score-task-priority.d.ts +38 -0
- package/dist/task-tools/score-task-priority.d.ts.map +1 -1
- package/dist/task-tools/score-task-priority.js +51 -0
- package/dist/task-tools/score-task-priority.js.map +1 -1
- package/dist/task-work/claims.d.ts +215 -0
- package/dist/task-work/claims.d.ts.map +1 -0
- package/dist/task-work/claims.js +272 -0
- package/dist/task-work/claims.js.map +1 -0
- package/dist/task-work/index.d.ts +47 -3
- package/dist/task-work/index.d.ts.map +1 -1
- package/dist/task-work/index.js +74 -41
- package/dist/task-work/index.js.map +1 -1
- package/dist/tasks/ac-coverage-gate.d.ts.map +1 -1
- package/dist/tasks/ac-coverage-gate.js +3 -0
- package/dist/tasks/ac-coverage-gate.js.map +1 -1
- package/dist/tasks/ac-table.d.ts +35 -7
- package/dist/tasks/ac-table.d.ts.map +1 -1
- package/dist/tasks/ac-table.js +119 -14
- package/dist/tasks/ac-table.js.map +1 -1
- package/dist/tasks/add.d.ts.map +1 -1
- package/dist/tasks/add.js +51 -4
- package/dist/tasks/add.js.map +1 -1
- package/dist/tasks/analyze.d.ts.map +1 -1
- package/dist/tasks/analyze.js +3 -4
- package/dist/tasks/analyze.js.map +1 -1
- package/dist/tasks/backfill-child-projections.js +47 -6
- package/dist/tasks/backfill-child-projections.js.map +1 -1
- package/dist/tasks/change-set.d.ts +5 -0
- package/dist/tasks/change-set.d.ts.map +1 -1
- package/dist/tasks/change-set.js +59 -3
- package/dist/tasks/change-set.js.map +1 -1
- package/dist/tasks/complete.d.ts +16 -0
- package/dist/tasks/complete.d.ts.map +1 -1
- package/dist/tasks/complete.js +33 -8
- package/dist/tasks/complete.js.map +1 -1
- package/dist/tasks/completion-evaluation.d.ts.map +1 -1
- package/dist/tasks/completion-evaluation.js +3 -0
- package/dist/tasks/completion-evaluation.js.map +1 -1
- package/dist/tasks/component-pr.d.ts +162 -1
- package/dist/tasks/component-pr.d.ts.map +1 -1
- package/dist/tasks/component-pr.js +560 -59
- package/dist/tasks/component-pr.js.map +1 -1
- package/dist/tasks/done-record.d.ts +14 -1
- package/dist/tasks/done-record.d.ts.map +1 -1
- package/dist/tasks/done-record.js +37 -0
- package/dist/tasks/done-record.js.map +1 -1
- package/dist/tasks/duplicate-detector.d.ts +20 -1
- package/dist/tasks/duplicate-detector.d.ts.map +1 -1
- package/dist/tasks/duplicate-detector.js +10 -2
- package/dist/tasks/duplicate-detector.js.map +1 -1
- package/dist/tasks/engine-wrap-ops.d.ts +19 -10
- package/dist/tasks/engine-wrap-ops.d.ts.map +1 -1
- package/dist/tasks/engine-wrap-ops.js +38 -10
- package/dist/tasks/engine-wrap-ops.js.map +1 -1
- package/dist/tasks/evidence.d.ts.map +1 -1
- package/dist/tasks/evidence.js +14 -3
- package/dist/tasks/evidence.js.map +1 -1
- package/dist/tasks/graph-ops.d.ts +9 -2
- package/dist/tasks/graph-ops.d.ts.map +1 -1
- package/dist/tasks/graph-ops.js +24 -32
- package/dist/tasks/graph-ops.js.map +1 -1
- package/dist/tasks/plan.d.ts.map +1 -1
- package/dist/tasks/plan.js +16 -60
- package/dist/tasks/plan.js.map +1 -1
- package/dist/tasks/task-context.d.ts.map +1 -1
- package/dist/tasks/task-context.js +13 -0
- package/dist/tasks/task-context.js.map +1 -1
- package/dist/tasks/task-import.d.ts.map +1 -1
- package/dist/tasks/task-import.js +21 -12
- package/dist/tasks/task-import.js.map +1 -1
- package/dist/tasks/task-next.d.ts +25 -2
- package/dist/tasks/task-next.d.ts.map +1 -1
- package/dist/tasks/task-next.js +32 -13
- package/dist/tasks/task-next.js.map +1 -1
- package/dist/tasks/update.d.ts.map +1 -1
- package/dist/tasks/update.js +7 -5
- package/dist/tasks/update.js.map +1 -1
- package/dist/upgrade.d.ts +1 -1
- package/dist/upgrade.d.ts.map +1 -1
- package/dist/upgrade.js +48 -9
- package/dist/upgrade.js.map +1 -1
- package/dist/validation/compliance.d.ts +5 -1
- package/dist/validation/compliance.d.ts.map +1 -1
- package/dist/validation/compliance.js +24 -3
- package/dist/validation/compliance.js.map +1 -1
- package/dist/validation/doctor/checks.d.ts +20 -6
- package/dist/validation/doctor/checks.d.ts.map +1 -1
- package/dist/validation/doctor/checks.js +51 -8
- package/dist/validation/doctor/checks.js.map +1 -1
- package/dist/validation/index.d.ts +1 -1
- package/dist/validation/index.d.ts.map +1 -1
- package/dist/validation/index.js +1 -1
- package/dist/validation/index.js.map +1 -1
- package/dist/validation/protocol-common.d.ts +47 -2
- package/dist/validation/protocol-common.d.ts.map +1 -1
- package/dist/validation/protocol-common.js +86 -21
- package/dist/validation/protocol-common.js.map +1 -1
- package/dist/validation/validate-ops.d.ts.map +1 -1
- package/dist/validation/validate-ops.js +2 -3
- package/dist/validation/validate-ops.js.map +1 -1
- package/migrations/README.md +12 -0
- package/migrations/drizzle-cleo-global/20260929010000_t12512-registry-probed-opened/migration.sql +35 -0
- package/migrations/drizzle-cleo-global/20260929020000_t12721-git-state-head-committed-at/migration.sql +25 -0
- package/migrations/drizzle-cleo-project/20260928120000_t12341-row-uids/migration.sql +169 -0
- package/migrations/drizzle-cleo-project/20260929000000_t12535-docs-attachments-union-shape/migration.sql +21 -0
- package/migrations/drizzle-cleo-project/20260929120000_t12502-task-claim-leases/migration.sql +77 -0
- package/migrations/drizzle-cleo-project/20260929130000_t12736-claim-lease-iso-repair/migration.sql +37 -0
- package/migrations/sync-journal/20260929140000_t12342-sync-clock/migration.sql +42 -0
- package/package.json +23 -13
- package/schemas/project-info.schema.json +10 -4
- package/templates/CLEO-INJECTION.md +1 -1
- package/templates/CLEO-REFERENCE.md +7 -3
- package/templates/cleo-gitignore +11 -0
- package/templates/workflows/release-prepare.yml.tmpl +156 -10
|
@@ -45,13 +45,17 @@
|
|
|
45
45
|
* | every other key (`schemaVersion`, `version`, `focus_state`, `focus_state:<session>`, `project_meta`, `project`, `parallel_state`, `activeSession`, `reconcile.<task>.release`, any unknown key) | none: the bare value |
|
|
46
46
|
*
|
|
47
47
|
* - **Initial collapse**, when the bare table holds at least one carried key:
|
|
48
|
-
* every bare key is carried under the rules
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
48
|
+
* every bare key is carried under the rules (the bare value wins every key
|
|
49
|
+
* both hold). A key only the twin holds is KEPT, never dropped (the union
|
|
50
|
+
* rule, T12535 audit 2026-09-29): it is listed as `kept` in the receipt and
|
|
51
|
+
* marker. When a key's twin value would be replaced by a different result,
|
|
52
|
+
* the twin value is copied to `twin_collapse_archive:<key>` in
|
|
53
|
+
* `tasks_schema_meta` (listed as `archived`; doctor shows it), so nothing
|
|
54
|
+
* unique is left only in the snapshot. `focus_state` merges instead: the
|
|
55
|
+
* bare value's current fields win and `sessionNotes` is the union of both,
|
|
56
|
+
* deduplicated by `(timestamp, note)` ({@link mergeFocusState}); its twin
|
|
57
|
+
* value is archived as well. The pre-collapse snapshot holds the store
|
|
58
|
+
* before any change and is pinned (never rotated).
|
|
55
59
|
* - **Incremental re-merge**, per bare key whose hash changed since the last
|
|
56
60
|
* merge:
|
|
57
61
|
* - twin unchanged since the last merge → the bare value (counter fields
|
|
@@ -68,9 +72,13 @@
|
|
|
68
72
|
* `sticky_tags` → `brain_sticky_tags` (rows are `(sticky_id, tag)`, both the
|
|
69
73
|
* key, so a collision is two identical rows and one is kept):
|
|
70
74
|
*
|
|
71
|
-
* - Initial collapse
|
|
72
|
-
*
|
|
73
|
-
*
|
|
75
|
+
* - Initial collapse: bare tags are added. A twin-only tag the note's
|
|
76
|
+
* `tags_json` names is kept (listed as `kept`). One it does not name leaves
|
|
77
|
+
* the junction (which follows `tags_json`, the value every reader displays)
|
|
78
|
+
* and is recorded under `twin_collapse_archive:sticky_tags` in
|
|
79
|
+
* `brain_schema_meta` (listed as `archived`); adding it to `tags_json` would
|
|
80
|
+
* change the tags the user sees. A bare tag whose note no longer exists is
|
|
81
|
+
* not carried (`skipped`; it stays in the bare table).
|
|
74
82
|
* - Incremental re-merge, per sticky id whose bare tag-set hash changed: if
|
|
75
83
|
* the twin's set for that id is unchanged since the last merge, the twin
|
|
76
84
|
* set is made equal to the bare set (additions AND removals propagate);
|
|
@@ -82,12 +90,58 @@
|
|
|
82
90
|
* tag filter can never disagree with the displayed tags. A conflict is still
|
|
83
91
|
* recorded for `cleo doctor`.
|
|
84
92
|
*
|
|
93
|
+
* `attachments` + `attachment_refs` → `docs_attachments` + `docs_attachment_refs`
|
|
94
|
+
* merge as ONE pair, in one transaction (a doc and its refs never diverge).
|
|
95
|
+
* The bare rows are authoritative over the columns both tables share, except
|
|
96
|
+
* `ref_count`, which is derived: after every merge it is recomputed as the
|
|
97
|
+
* number of refs in `docs_attachment_refs`, whoever wrote them (never carried,
|
|
98
|
+
* never max'd). Rows are keyed `d:<id>` and `r:<attachment_id, owner_type,
|
|
99
|
+
* owner_id>` in the marker hashes.
|
|
100
|
+
*
|
|
101
|
+
* - Initial collapse: the twins are made equal to the bare tables, even when
|
|
102
|
+
* the bare tables are empty (the older build never showed a twin-only row);
|
|
103
|
+
* frozen twin-only rows are dropped and listed.
|
|
104
|
+
* - Incremental re-merge, per bare doc whose hash moved (see {@link planDocs}):
|
|
105
|
+
* twin changed too → conflict, the twin wins; bare row gone → the twin row
|
|
106
|
+
* goes unless a ref or a supersedes link still names it (`kept`); the
|
|
107
|
+
* content (sha256) already belongs to another twin row → merged into that
|
|
108
|
+
* row: the bare id is aliased to it (`twin_collapse_alias:attachments`) and
|
|
109
|
+
* its refs follow; the slug already belongs to another twin row → carried
|
|
110
|
+
* under `<slug>-<n>` (`renamed`). Natural keys are checked against the state
|
|
111
|
+
* after the merge, so a merge never fails on them. Per bare ref whose hash
|
|
112
|
+
* moved: added → added (aliased), removed → removed unless this build
|
|
113
|
+
* changed it since.
|
|
114
|
+
* - Change detection: AFTER triggers on the bare tables (`t12535_track_*`)
|
|
115
|
+
* bump `twin_collapse_seq:attachments` on every write, the older build's
|
|
116
|
+
* included. An open compares that counter with the marker's instead of
|
|
117
|
+
* re-hashing every doc; a missing trigger falls back to the full compare.
|
|
118
|
+
* - Union shape: migration `20260929000000_t12535-docs-attachments-union-shape`
|
|
119
|
+
* adds the bare-only `display_alias` column and indexes; the UNIQUE slug and
|
|
120
|
+
* sha256 indexes are dropped and re-created by the merge itself around the
|
|
121
|
+
* row changes. Every merge ends with them in place, or rolls back.
|
|
122
|
+
* - The `supersedes` / `superseded_by` self-FKs are checked at commit
|
|
123
|
+
* (`PRAGMA defer_foreign_keys`), so a chain merges in any row order.
|
|
124
|
+
* - A bare row that violates a twin CHECK (e.g. a non-ISO `created_at`) fails
|
|
125
|
+
* the merge; the docs pair opens degraded (docs writes refused, everything
|
|
126
|
+
* else writable) and `cleo doctor twin-collapse` shows the cause.
|
|
127
|
+
* - Blobs: after each merge every twin doc's blob gets a keep-link (and is
|
|
128
|
+
* restored from it if the older build unlinked it), see `blob-keep.ts`.
|
|
129
|
+
* - Freeze (option (a), D11160): every merge installs BEFORE
|
|
130
|
+
* INSERT/UPDATE/DELETE triggers on the bare docs tables that abort an
|
|
131
|
+
* older build's write with the upgrade message ({@link docsFrozenMessage}).
|
|
132
|
+
* This build never writes them, so there is no bypass. Every open checks the freeze and change
|
|
133
|
+
* triggers and re-installs missing ones (a rebuild or restore drops them);
|
|
134
|
+
* `cleo doctor` reports them missing. The (b) defences above cover the time
|
|
135
|
+
* a freeze is missing.
|
|
136
|
+
*
|
|
85
137
|
* ## Contract
|
|
86
138
|
*
|
|
87
139
|
* 1. **Snapshot first** (initial collapse only, only when the merge changes
|
|
88
140
|
* the twin). The free space is checked before the `VACUUM INTO`. One
|
|
89
141
|
* snapshot covers every pair collapsing in this open, registered as a
|
|
90
|
-
* `migration` backup
|
|
142
|
+
* PINNED `migration` backup: `cleo backup list` shows it, rotation never
|
|
143
|
+
* deletes it, and a snapshot an earlier build wrote unpinned is pinned at
|
|
144
|
+
* the next open (`cleo doctor` lists an unpinned one).
|
|
91
145
|
* 2. **Atomic.** Each pair merges, verifies and writes its marker in one
|
|
92
146
|
* `BEGIN IMMEDIATE` transaction, the marker re-read under the write lock.
|
|
93
147
|
* Any failure rolls back: both tables byte-identical.
|
|
@@ -125,9 +179,16 @@
|
|
|
125
179
|
* @task T12535
|
|
126
180
|
*/
|
|
127
181
|
import { createHash } from 'node:crypto';
|
|
182
|
+
import { existsSync, statSync } from 'node:fs';
|
|
183
|
+
import { basename, dirname } from 'node:path';
|
|
184
|
+
import { fileURLToPath } from 'node:url';
|
|
128
185
|
import { ExitCode } from '@cleocode/contracts';
|
|
186
|
+
import { z } from 'zod';
|
|
129
187
|
import { CleoError } from '../errors.js';
|
|
130
188
|
import { getLogger } from '../logger.js';
|
|
189
|
+
import { getCleoVersion } from '../scaffold/ensure-config.js';
|
|
190
|
+
import { isPinnedBackup, pinBackup, readBackupSidecar, restoreBackupSidecar, unpinBackup, } from './backup-sidecar.js';
|
|
191
|
+
import { blobFileForRow, pinBlob, restoreBlob } from './blob-keep.js';
|
|
131
192
|
import { planMigrationSnapshot, writeMigrationSnapshot } from './pre-repair-snapshot.js';
|
|
132
193
|
const log = getLogger('twin-collapse');
|
|
133
194
|
/** Prefix of the marker key holding a pair's collapse state. */
|
|
@@ -151,6 +212,8 @@ export const SCHEMA_META_NEVER_DELETED = new Set([
|
|
|
151
212
|
'task_id_sequence',
|
|
152
213
|
SNAPSHOT_GATE_KEY,
|
|
153
214
|
]);
|
|
215
|
+
/** Why a twin-collapse snapshot is pinned (never rotated). */
|
|
216
|
+
const SNAPSHOT_PIN_REASON = 'T12535 twin collapse: the store before the merge (only copy of any row it replaced)';
|
|
154
217
|
/** Most conflicts kept in the marker (the doctor warning lists them). */
|
|
155
218
|
const MAX_CONFLICTS = 50;
|
|
156
219
|
const sha = (value) => createHash('sha256').update(value).digest('hex');
|
|
@@ -194,11 +257,23 @@ function readState(db, pair) {
|
|
|
194
257
|
dropped: Array.isArray(parsed.dropped) ? parsed.dropped : [],
|
|
195
258
|
conflicts: Array.isArray(parsed.conflicts) ? parsed.conflicts : [],
|
|
196
259
|
conflictsAt: typeof parsed.conflictsAt === 'string' ? parsed.conflictsAt : null,
|
|
260
|
+
seq: typeof parsed.seq === 'string' ? parsed.seq : null,
|
|
261
|
+
merged: Array.isArray(parsed.merged) ? parsed.merged : [],
|
|
262
|
+
renamed: Array.isArray(parsed.renamed) ? parsed.renamed : [],
|
|
263
|
+
kept: Array.isArray(parsed.kept) ? parsed.kept : [],
|
|
264
|
+
archived: Array.isArray(parsed.archived) ? parsed.archived : [],
|
|
197
265
|
};
|
|
198
266
|
}
|
|
199
267
|
/** Whether the bare side moved since the stored hashes (or no usable state exists). */
|
|
200
268
|
function bareChanged(db, pair, state) {
|
|
201
|
-
|
|
269
|
+
if (!state?.hashes)
|
|
270
|
+
return true;
|
|
271
|
+
if (state.seq !== null && pair.changeSeq !== undefined) {
|
|
272
|
+
const seq = pair.changeSeq(db);
|
|
273
|
+
if (seq !== undefined)
|
|
274
|
+
return seq !== state.seq;
|
|
275
|
+
}
|
|
276
|
+
return !sameHashes(state.hashes.bare, pair.bareHashes(db));
|
|
202
277
|
}
|
|
203
278
|
/** Hashes of the last merge; a pre-release marker treats the current twin as unchanged. */
|
|
204
279
|
function lastHashes(db, pair, state) {
|
|
@@ -315,6 +390,52 @@ function kvRows(db, table, schema = 'main') {
|
|
|
315
390
|
const rows = db.prepare(`SELECT key, value FROM ${schema}.${table}`).all();
|
|
316
391
|
return new Map(rows.map((r) => [r.key, r.value]));
|
|
317
392
|
}
|
|
393
|
+
/** Prefix of the key holding a twin value the initial collapse replaced. */
|
|
394
|
+
export const TWIN_COLLAPSE_ARCHIVE_PREFIX = 'twin_collapse_archive:';
|
|
395
|
+
/**
|
|
396
|
+
* `focus_state` when both tables hold it: the bare value's current fields
|
|
397
|
+
* win, and the session-note history is the union of both
|
|
398
|
+
* (`sessionNotes`, deduplicated by `(timestamp, note)`, sorted by timestamp).
|
|
399
|
+
* The twin may hold months of notes the bare copy never saw (178 KB on the
|
|
400
|
+
* cleocode Linux store). A value that is not a JSON object stays the bare one
|
|
401
|
+
* (the replaced twin value is archived either way).
|
|
402
|
+
*
|
|
403
|
+
* @param bare - The bare value.
|
|
404
|
+
* @param twin - The twin value.
|
|
405
|
+
* @returns The merged value.
|
|
406
|
+
* @task T12535
|
|
407
|
+
*/
|
|
408
|
+
export function mergeFocusState(bare, twin) {
|
|
409
|
+
const b = parseJson(bare);
|
|
410
|
+
const t = parseJson(twin);
|
|
411
|
+
if (b === null || typeof b !== 'object' || Array.isArray(b))
|
|
412
|
+
return bare;
|
|
413
|
+
const notesOf = (v) => v !== null &&
|
|
414
|
+
typeof v === 'object' &&
|
|
415
|
+
Array.isArray(v.sessionNotes)
|
|
416
|
+
? v.sessionNotes
|
|
417
|
+
: [];
|
|
418
|
+
const bareNotes = notesOf(b);
|
|
419
|
+
const twinNotes = notesOf(t);
|
|
420
|
+
if (twinNotes.length === 0)
|
|
421
|
+
return bare;
|
|
422
|
+
const keyOf = (n) => {
|
|
423
|
+
const r = (n ?? {});
|
|
424
|
+
return `${String(r.timestamp ?? '')}\u0000${String(r.note ?? JSON.stringify(n))}`;
|
|
425
|
+
};
|
|
426
|
+
const seen = new Set();
|
|
427
|
+
const union = [];
|
|
428
|
+
for (const n of [...twinNotes, ...bareNotes]) {
|
|
429
|
+
const k = keyOf(n);
|
|
430
|
+
if (seen.has(k))
|
|
431
|
+
continue;
|
|
432
|
+
seen.add(k);
|
|
433
|
+
union.push(n);
|
|
434
|
+
}
|
|
435
|
+
const ts = (n) => String((n ?? {}).timestamp ?? '');
|
|
436
|
+
union.sort((x, y) => ts(x).localeCompare(ts(y)));
|
|
437
|
+
return JSON.stringify({ ...b, sessionNotes: union });
|
|
438
|
+
}
|
|
318
439
|
/** Carried rows of a `schema_meta` table. */
|
|
319
440
|
function carriedRows(db, table) {
|
|
320
441
|
const all = kvRows(db, table);
|
|
@@ -324,7 +445,17 @@ function carriedRows(db, table) {
|
|
|
324
445
|
return all;
|
|
325
446
|
}
|
|
326
447
|
const hashMap = (m) => Object.fromEntries([...m].map(([k, v]) => [k, sha(v)]));
|
|
327
|
-
const emptyPlan = () => ({
|
|
448
|
+
const emptyPlan = () => ({
|
|
449
|
+
set: new Map(),
|
|
450
|
+
del: [],
|
|
451
|
+
dropped: [],
|
|
452
|
+
conflicts: [],
|
|
453
|
+
skipped: 0,
|
|
454
|
+
merged: [],
|
|
455
|
+
renamed: [],
|
|
456
|
+
kept: [],
|
|
457
|
+
archived: [],
|
|
458
|
+
});
|
|
328
459
|
function planSchemaMeta(db, state) {
|
|
329
460
|
const plan = emptyPlan();
|
|
330
461
|
const bare = carriedRows(db, 'schema_meta');
|
|
@@ -336,14 +467,24 @@ function planSchemaMeta(db, state) {
|
|
|
336
467
|
if (state === undefined) {
|
|
337
468
|
if (bare.size === 0)
|
|
338
469
|
return plan;
|
|
339
|
-
for (const [key, value] of bare)
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
470
|
+
for (const [key, value] of [...bare].sort(([a], [b]) => a.localeCompare(b))) {
|
|
471
|
+
const current = twin.get(key);
|
|
472
|
+
const merged = key === 'focus_state' && current !== undefined
|
|
473
|
+
? mergeFocusState(value, current)
|
|
474
|
+
: mergeSchemaMetaValue(key, value, current);
|
|
475
|
+
want(key, merged);
|
|
476
|
+
// The twin's own value would be replaced: keep a copy of it, so nothing
|
|
477
|
+
// unique is left only in the snapshot.
|
|
478
|
+
if (current !== undefined && merged !== null && merged !== current) {
|
|
479
|
+
plan.set.set(`${TWIN_COLLAPSE_ARCHIVE_PREFIX}${key}`, current);
|
|
480
|
+
plan.archived.push(key);
|
|
345
481
|
}
|
|
346
482
|
}
|
|
483
|
+
// The union rule: a key only the twin holds is carried over, never
|
|
484
|
+
// dropped (listed as kept). The bare value wins every key both hold.
|
|
485
|
+
for (const key of [...twin.keys()].sort())
|
|
486
|
+
if (!bare.has(key))
|
|
487
|
+
plan.kept.push(key);
|
|
347
488
|
return plan;
|
|
348
489
|
}
|
|
349
490
|
const last = lastHashes(db, SCHEMA_META, state);
|
|
@@ -467,10 +608,32 @@ function planSticky(db, state) {
|
|
|
467
608
|
}
|
|
468
609
|
};
|
|
469
610
|
if (state === undefined) {
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
611
|
+
// The union rule: bare tags are added. A twin-only tag the note's
|
|
612
|
+
// tags_json names is kept (listed as kept). One tags_json does not name is
|
|
613
|
+
// listed as archived: the junction follows tags_json (the value both
|
|
614
|
+
// builds write and every reader displays), so it leaves the junction, and
|
|
615
|
+
// its row is recorded under `twin_collapse_archive:sticky_tags`.
|
|
616
|
+
const tagsJson = db.prepare('SELECT tags_json FROM main.brain_sticky_notes WHERE id = ?');
|
|
617
|
+
for (const id of [...new Set([...bare.keys(), ...twin.keys()])].sort()) {
|
|
618
|
+
const b = new Set(bare.get(id) ?? []);
|
|
619
|
+
const t = new Set(twin.get(id) ?? []);
|
|
620
|
+
const alive = b.size === 0 || noteExists.get(id) !== undefined;
|
|
621
|
+
for (const tag of b) {
|
|
622
|
+
if (!alive)
|
|
623
|
+
plan.skipped++;
|
|
624
|
+
else if (!t.has(tag))
|
|
625
|
+
plan.set.set(`${id}\t${tag}`, '');
|
|
626
|
+
}
|
|
627
|
+
const named = new Set(tagsOf(tagsJson.get(id)?.tags_json ?? null));
|
|
628
|
+
for (const tag of t) {
|
|
629
|
+
if (b.has(tag))
|
|
630
|
+
continue;
|
|
631
|
+
if (named.has(tag))
|
|
632
|
+
plan.kept.push(`${id}\t${tag}`);
|
|
633
|
+
else
|
|
634
|
+
plan.archived.push(`${id}\t${tag}`);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
474
637
|
return plan;
|
|
475
638
|
}
|
|
476
639
|
const last = lastHashes(db, STICKY_TAGS, state);
|
|
@@ -572,6 +735,12 @@ const STICKY_TAGS = {
|
|
|
572
735
|
const lingering = plan.del.filter(has).length;
|
|
573
736
|
if (missing > 0 || lingering > 0)
|
|
574
737
|
throw new Error(`sticky_tags collapse did not verify: ${missing} missing, ${lingering} not removed`);
|
|
738
|
+
if (plan.archived.length > 0) {
|
|
739
|
+
const key = `${TWIN_COLLAPSE_ARCHIVE_PREFIX}sticky_tags`;
|
|
740
|
+
const previous = parseJson(readKv(db, 'brain_schema_meta', key) ?? '[]');
|
|
741
|
+
const rows = new Set([...(Array.isArray(previous) ? previous : []), ...plan.archived]);
|
|
742
|
+
writeKv(db, 'brain_schema_meta', key, JSON.stringify([...rows].sort()));
|
|
743
|
+
}
|
|
575
744
|
const synced = syncStickyJunction(db, 'main');
|
|
576
745
|
return {
|
|
577
746
|
inserted: counts.inserted + synced.inserted,
|
|
@@ -587,8 +756,669 @@ const STICKY_TAGS = {
|
|
|
587
756
|
syncStickyJunction(db, 'temp');
|
|
588
757
|
},
|
|
589
758
|
};
|
|
759
|
+
// ── attachments + attachment_refs (docs) ─────────────────────────────────────
|
|
760
|
+
/** Key of the bare→twin id aliases (bare rows merged into a twin row by sha256). */
|
|
761
|
+
export const DOCS_ALIAS_KEY = 'twin_collapse_alias:attachments';
|
|
762
|
+
/** Key the bare-side change triggers bump on every write to the bare docs tables. */
|
|
763
|
+
export const DOCS_CHANGE_SEQ_KEY = 'twin_collapse_seq:attachments';
|
|
764
|
+
/** The bare docs tables (the change triggers watch both). */
|
|
765
|
+
const DOCS_BARE = ['attachments', 'attachment_refs'];
|
|
766
|
+
/** The docs twins (both shadowed in read-only-for-users mode). */
|
|
767
|
+
const DOCS_TWINS = ['docs_attachments', 'docs_attachment_refs'];
|
|
768
|
+
/** Columns recomputed after every merge, never carried (a count of the refs). */
|
|
769
|
+
const DOCS_DERIVED = new Set(['ref_count']);
|
|
770
|
+
/**
|
|
771
|
+
* The UNIQUE indexes the docs code relies on (union shape with the bare table).
|
|
772
|
+
* Dropped and re-created by every merge, inside its transaction.
|
|
773
|
+
*/
|
|
774
|
+
const DOCS_UNIQUE_INDEXES = [
|
|
775
|
+
{ name: 'uniq_docs_attachments_slug', on: '(slug) WHERE slug IS NOT NULL' },
|
|
776
|
+
{ name: 'uniq_docs_attachments_sha256', on: '(sha256)' },
|
|
777
|
+
];
|
|
778
|
+
/** Column names of a table, in declaration order. */
|
|
779
|
+
function columnsOf(db, schema, table) {
|
|
780
|
+
return db.prepare(`SELECT name FROM pragma_table_info(?, ?)`).all(table, schema).map((c) => c.name);
|
|
781
|
+
}
|
|
782
|
+
const quoteIdent = (name) => `"${name.replace(/"/g, '""')}"`;
|
|
783
|
+
/** Columns both tables hold, in the bare table's order, minus `skip`. */
|
|
784
|
+
function sharedColumns(db, bare, twin, skip = new Set()) {
|
|
785
|
+
const twinCols = new Set(columnsOf(db, 'main', twin));
|
|
786
|
+
return columnsOf(db, 'main', bare).filter((c) => twinCols.has(c) && !skip.has(c));
|
|
787
|
+
}
|
|
788
|
+
/** The carried doc columns (`ref_count` is derived). */
|
|
789
|
+
const docColumns = (db) => sharedColumns(db, 'attachments', 'docs_attachments', DOCS_DERIVED);
|
|
790
|
+
/** The carried ref columns. */
|
|
791
|
+
const refColumns = (db) => sharedColumns(db, 'attachment_refs', 'docs_attachment_refs');
|
|
792
|
+
/** Rows of a table keyed by the JSON of `key` columns (or the bare id for docs). */
|
|
793
|
+
function carriedRowsOf(db, schema, table, columns, key) {
|
|
794
|
+
const rows = db
|
|
795
|
+
.prepare(`SELECT ${columns.map(quoteIdent).join(', ')} FROM ${schema}.${quoteIdent(table)}`)
|
|
796
|
+
.all();
|
|
797
|
+
return new Map(rows.map((r) => [
|
|
798
|
+
key.length === 1 ? String(r[key[0]]) : JSON.stringify(key.map((k) => r[k])),
|
|
799
|
+
{ json: JSON.stringify(columns.map((c) => r[c] ?? null)), values: r },
|
|
800
|
+
]));
|
|
801
|
+
}
|
|
802
|
+
const REF_KEY = ['attachment_id', 'owner_type', 'owner_id'];
|
|
803
|
+
/** Docs (`d:<id>`) and refs (`r:<key>`) of one side. */
|
|
804
|
+
function docsSide(db, side) {
|
|
805
|
+
const [docTable, refTable] = side === 'bare' ? DOCS_BARE : DOCS_TWINS;
|
|
806
|
+
return {
|
|
807
|
+
docs: carriedRowsOf(db, 'main', docTable, docColumns(db), ['id']),
|
|
808
|
+
refs: carriedRowsOf(db, 'main', refTable, refColumns(db), REF_KEY),
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
function docsHashes(db, side) {
|
|
812
|
+
const { docs, refs } = docsSide(db, side);
|
|
813
|
+
return Object.fromEntries([
|
|
814
|
+
...[...docs].map(([k, v]) => [`d:${k}`, sha(v.json)]),
|
|
815
|
+
...[...refs].map(([k, v]) => [`r:${k}`, sha(v.json)]),
|
|
816
|
+
]);
|
|
817
|
+
}
|
|
818
|
+
function readAliases(db) {
|
|
819
|
+
const raw = readKv(db, 'tasks_schema_meta', DOCS_ALIAS_KEY);
|
|
820
|
+
if (raw === undefined)
|
|
821
|
+
return {};
|
|
822
|
+
const parsed = parseJson(raw);
|
|
823
|
+
return parsed !== null && typeof parsed === 'object' ? parsed : {};
|
|
824
|
+
}
|
|
825
|
+
/** The change-trigger name for one bare table and operation. */
|
|
826
|
+
const trackTrigger = (table, op) => `t12535_track_${table}_${op.toLowerCase()}`;
|
|
827
|
+
/**
|
|
828
|
+
* AFTER triggers on the bare docs tables that bump {@link DOCS_CHANGE_SEQ_KEY}.
|
|
829
|
+
* They fire for every writer, the older build included, so an open detects an
|
|
830
|
+
* unchanged bare side by reading one row instead of re-hashing every doc.
|
|
831
|
+
*/
|
|
832
|
+
function ensureDocsTracking(db) {
|
|
833
|
+
for (const table of DOCS_BARE) {
|
|
834
|
+
for (const op of SHADOW_WRITE_OPS) {
|
|
835
|
+
db.exec(`CREATE TRIGGER IF NOT EXISTS main.${trackTrigger(table, op)} AFTER ${op} ON ${table} BEGIN ` +
|
|
836
|
+
`INSERT OR IGNORE INTO tasks_schema_meta (key, value) VALUES ('${DOCS_CHANGE_SEQ_KEY}', '0'); ` +
|
|
837
|
+
`UPDATE tasks_schema_meta SET value = CAST(value AS INTEGER) + 1 WHERE key = '${DOCS_CHANGE_SEQ_KEY}'; END`);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
/** The freeze-trigger name for one bare table and operation. */
|
|
842
|
+
const freezeTrigger = (table, op) => `t12535_freeze_${table}_${op.toLowerCase()}`;
|
|
843
|
+
/** Every freeze and change trigger the docs pair keeps on the bare tables. */
|
|
844
|
+
const docsGuardTriggers = () => DOCS_BARE.flatMap((t) => SHADOW_WRITE_OPS.flatMap((op) => [freezeTrigger(t, op), trackTrigger(t, op)]));
|
|
845
|
+
/**
|
|
846
|
+
* The message an older build's write to a frozen bare docs table aborts with.
|
|
847
|
+
* It names the release that moved the docs, what fails (docs, changesets, and
|
|
848
|
+
* the IVTR playbook provenance an older build attaches when a playbook run
|
|
849
|
+
* finalises), and the upgrade command.
|
|
850
|
+
*
|
|
851
|
+
* It must never read as contention or corruption to an older build: those
|
|
852
|
+
* retry on `sqlite_busy` / `database is locked` and restore backups on
|
|
853
|
+
* `database disk image is malformed`, so none of those words appear (pinned by
|
|
854
|
+
* a test, see {@link DOCS_FROZEN_FORBIDDEN}).
|
|
855
|
+
*/
|
|
856
|
+
export function docsFrozenMessage(version) {
|
|
857
|
+
const needs = version === null ? 'the latest cleo' : `cleo ${version} or newer`;
|
|
858
|
+
return (`CLEO: docs moved to docs_attachments (T12535); this project needs ${needs} ` +
|
|
859
|
+
'to write docs, changesets and IVTR playbook provenance. ' +
|
|
860
|
+
'Run: npm i -g @cleocode/cleo@latest');
|
|
861
|
+
}
|
|
862
|
+
/** Key recording the version of the build that first froze this store's bare docs tables. */
|
|
863
|
+
export const DOCS_FROZEN_BY_KEY = 'twin_collapse_frozen_by:attachments';
|
|
864
|
+
/**
|
|
865
|
+
* The version the freeze message names: the first RELEASE build that froze
|
|
866
|
+
* (or later opened) this store, recorded under {@link DOCS_FROZEN_BY_KEY}.
|
|
867
|
+
*
|
|
868
|
+
* Why recorded, not a literal: the release that ships the freeze was not
|
|
869
|
+
* known when the code was written (2026.9.23 shipped without it). Why not the
|
|
870
|
+
* running build's version: a trigger outlives the build that wrote it, and a
|
|
871
|
+
* development build (a worktree, or the main checkout) reports the PREVIOUS
|
|
872
|
+
* release's version, which cannot read the store. So only a published build
|
|
873
|
+
* (running from an installed package, i.e. under `node_modules`) records its
|
|
874
|
+
* version, and never the unreadable `0.0.0` fallback. Until one does, the
|
|
875
|
+
* message names no version and points at `@latest`; the first release build
|
|
876
|
+
* to open the store records itself and the triggers are re-written.
|
|
877
|
+
*
|
|
878
|
+
* @param db - The project `cleo.db` connection.
|
|
879
|
+
* @returns The version to name, or `null` (no release build has frozen or
|
|
880
|
+
* opened this store yet).
|
|
881
|
+
* @task T12535
|
|
882
|
+
*/
|
|
883
|
+
export function docsFrozenVersion(db) {
|
|
884
|
+
const recorded = readKv(db, 'tasks_schema_meta', DOCS_FROZEN_BY_KEY);
|
|
885
|
+
if (recorded !== undefined)
|
|
886
|
+
return recorded.replace(/[^0-9A-Za-z.+-]/g, '');
|
|
887
|
+
return releaseBuildVersion();
|
|
888
|
+
}
|
|
889
|
+
/** Test hook: what this process counts as (a published build, and its version). */
|
|
890
|
+
let freezeBuildOverride;
|
|
891
|
+
/**
|
|
892
|
+
* Test only: make this process count as a published build with `version`
|
|
893
|
+
* (or restore the real detection with `undefined`).
|
|
894
|
+
*
|
|
895
|
+
* @param override - The build to pretend to be.
|
|
896
|
+
* @task T12535
|
|
897
|
+
*/
|
|
898
|
+
export function setFreezeBuildForTests(override) {
|
|
899
|
+
freezeBuildOverride = override;
|
|
900
|
+
}
|
|
901
|
+
/**
|
|
902
|
+
* This build's version when it is a published release, else `null`: running
|
|
903
|
+
* from an installed package (under `node_modules`) with a real version.
|
|
904
|
+
*/
|
|
905
|
+
function releaseBuildVersion() {
|
|
906
|
+
const published = freezeBuildOverride?.published ??
|
|
907
|
+
fileURLToPath(import.meta.url)
|
|
908
|
+
.split(/[\\/]/)
|
|
909
|
+
.includes('node_modules');
|
|
910
|
+
if (!published)
|
|
911
|
+
return null;
|
|
912
|
+
const version = (freezeBuildOverride?.version ?? getCleoVersion()).replace(/[^0-9A-Za-z.+-]/g, '');
|
|
913
|
+
return /^\d+\.\d+\.\d+/.test(version) && version !== '0.0.0' ? version : null;
|
|
914
|
+
}
|
|
915
|
+
/** Phrases the freeze message must never contain (older builds' retry and recovery matchers). */
|
|
916
|
+
export const DOCS_FROZEN_FORBIDDEN = [
|
|
917
|
+
'sqlite_busy',
|
|
918
|
+
'database is locked',
|
|
919
|
+
'database disk image is malformed',
|
|
920
|
+
'malformed',
|
|
921
|
+
'corrupt',
|
|
922
|
+
'not a database',
|
|
923
|
+
];
|
|
924
|
+
/**
|
|
925
|
+
* BEFORE INSERT/UPDATE/DELETE triggers on the bare docs tables that abort
|
|
926
|
+
* every write with {@link docsFrozenMessage} (option (a), owner decision
|
|
927
|
+
* D11160). SQLite enforces them whatever build writes, so the 2026.9.20 build
|
|
928
|
+
* can no longer write rows this build treats as frozen: no duplicate content
|
|
929
|
+
* or slug, no split ref counts, no stale-read deletes, no ADR number reuse.
|
|
930
|
+
* Its reads keep working (they show the frozen docs).
|
|
931
|
+
*
|
|
932
|
+
* There is no bypass: this build never writes the bare tables. A migration
|
|
933
|
+
* that rebuilds or drops them is not affected (DDL does not fire DML triggers;
|
|
934
|
+
* the triggers go with a renamed or dropped table and are re-installed at the
|
|
935
|
+
* next open, see {@link repairGuards}).
|
|
936
|
+
*/
|
|
937
|
+
function ensureDocsFreeze(db) {
|
|
938
|
+
if (readKv(db, 'tasks_schema_meta', DOCS_FROZEN_BY_KEY) === undefined) {
|
|
939
|
+
const version = releaseBuildVersion();
|
|
940
|
+
if (version !== null)
|
|
941
|
+
writeKv(db, 'tasks_schema_meta', DOCS_FROZEN_BY_KEY, version);
|
|
942
|
+
}
|
|
943
|
+
for (const table of DOCS_BARE) {
|
|
944
|
+
for (const op of SHADOW_WRITE_OPS) {
|
|
945
|
+
const name = freezeTrigger(table, op);
|
|
946
|
+
// A trigger with another text (an older message or version) is replaced.
|
|
947
|
+
if (freezeSql(db, name) !== undefined && !freezeUpToDate(db, table, op))
|
|
948
|
+
db.exec(`DROP TRIGGER main.${name}`);
|
|
949
|
+
db.exec(`CREATE TRIGGER IF NOT EXISTS main.${name} BEFORE ${op} ON ${table} ` +
|
|
950
|
+
`BEGIN ${freezeBody(db)} END`);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
/** The freeze trigger's body (it carries the message). */
|
|
955
|
+
const freezeBody = (db) => `SELECT RAISE(ABORT, '${docsFrozenMessage(docsFrozenVersion(db)).replace(/'/g, "''")}');`;
|
|
956
|
+
/** The stored SQL of a trigger, or `undefined` when it does not exist. */
|
|
957
|
+
function freezeSql(db, name) {
|
|
958
|
+
return db
|
|
959
|
+
.prepare("SELECT sql FROM main.sqlite_master WHERE type = 'trigger' AND name = ?")
|
|
960
|
+
.get(name)?.sql;
|
|
961
|
+
}
|
|
962
|
+
/** Whether a freeze trigger exists with the current table, event and message. */
|
|
963
|
+
function freezeUpToDate(db, table, op) {
|
|
964
|
+
const sql = freezeSql(db, freezeTrigger(table, op));
|
|
965
|
+
return sql?.includes(`BEFORE ${op} ON ${table} `) === true && sql.includes(freezeBody(db));
|
|
966
|
+
}
|
|
967
|
+
/** Whether every freeze and change trigger is in place. */
|
|
968
|
+
function docsGuardsIntact(db) {
|
|
969
|
+
const names = docsGuardTriggers();
|
|
970
|
+
const present = db
|
|
971
|
+
.prepare(`SELECT COUNT(*) AS n FROM main.sqlite_master WHERE type = 'trigger' AND name IN (${names.map(() => '?').join(', ')})`)
|
|
972
|
+
.get(...names).n;
|
|
973
|
+
if (present !== names.length)
|
|
974
|
+
return false;
|
|
975
|
+
return DOCS_BARE.every((t) => SHADOW_WRITE_OPS.every((op) => freezeUpToDate(db, t, op)));
|
|
976
|
+
}
|
|
977
|
+
/** Install the freeze and change triggers (idempotent). */
|
|
978
|
+
function ensureDocsGuards(db) {
|
|
979
|
+
ensureDocsFreeze(db);
|
|
980
|
+
ensureDocsTracking(db);
|
|
981
|
+
}
|
|
982
|
+
/** The bare side's change counter, or `undefined` when a change trigger is missing. */
|
|
983
|
+
function docsChangeSeq(db) {
|
|
984
|
+
const names = DOCS_BARE.flatMap((t) => SHADOW_WRITE_OPS.map((op) => trackTrigger(t, op)));
|
|
985
|
+
const present = db
|
|
986
|
+
.prepare(`SELECT COUNT(*) AS n FROM main.sqlite_master WHERE type = 'trigger' AND name IN (${names.map(() => '?').join(', ')})`)
|
|
987
|
+
.get(...names).n;
|
|
988
|
+
if (present !== names.length)
|
|
989
|
+
return undefined;
|
|
990
|
+
return readKv(db, 'tasks_schema_meta', DOCS_CHANGE_SEQ_KEY) ?? '0';
|
|
991
|
+
}
|
|
992
|
+
/** The first `<slug>-<n>` (n ≥ 2) no other twin row holds. */
|
|
993
|
+
function freeSlug(slug, owners, id) {
|
|
994
|
+
for (let n = 2;; n++) {
|
|
995
|
+
const candidate = `${slug}-${n}`;
|
|
996
|
+
const holder = owners.get(candidate);
|
|
997
|
+
if (holder === undefined || holder === id)
|
|
998
|
+
return candidate;
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
/**
|
|
1002
|
+
* The initial docs collapse: a union that never drops content.
|
|
1003
|
+
*
|
|
1004
|
+
* - A row whose id the bare table has: the bare row is authoritative. When the
|
|
1005
|
+
* twin's row under that id holds DIFFERENT bytes, the twin's version is
|
|
1006
|
+
* kept as its own row `<id>~twin-<sha8>` (no slug, superseded by the bare
|
|
1007
|
+
* row) and listed as merged, so no content leaves the twins (unless those
|
|
1008
|
+
* bytes are already another row's). A metadata-only difference (same sha256)
|
|
1009
|
+
* goes to the bare row.
|
|
1010
|
+
* - A twin-only row whose sha256 a bare row (or an earlier kept twin-only
|
|
1011
|
+
* row) holds, i.e. the same content under another id: merged into that row
|
|
1012
|
+
* (listed as merged), its refs and any supersedes link moved onto it.
|
|
1013
|
+
* - Any other twin-only row: kept under its own id. When a bare row holds its
|
|
1014
|
+
* slug, the bare (live) doc keeps the slug and this one is carried as
|
|
1015
|
+
* `<slug>-<n>` (listed as renamed).
|
|
1016
|
+
* - Refs: every bare ref and every twin ref (remapped onto merged ids) is kept.
|
|
1017
|
+
*/
|
|
1018
|
+
function planInitialDocs(db, plan, bare, twin) {
|
|
1019
|
+
const columns = docColumns(db);
|
|
1020
|
+
const refCols = refColumns(db);
|
|
1021
|
+
const idAt = refCols.indexOf('attachment_id');
|
|
1022
|
+
const bareBySha = new Map();
|
|
1023
|
+
const slugOwner = new Map();
|
|
1024
|
+
for (const [id, row] of bare.docs) {
|
|
1025
|
+
bareBySha.set(String(row.values.sha256), id);
|
|
1026
|
+
if (typeof row.values.slug === 'string')
|
|
1027
|
+
slugOwner.set(row.values.slug, id);
|
|
1028
|
+
}
|
|
1029
|
+
for (const [id, row] of bare.docs)
|
|
1030
|
+
if (twin.docs.get(id)?.json !== row.json)
|
|
1031
|
+
plan.set.set(`d:${id}`, row.json);
|
|
1032
|
+
// An id both tables hold with DIFFERENT content: the bare row wins the id,
|
|
1033
|
+
// and the twin's version is kept as its own row (`<id>~twin-<sha8>`,
|
|
1034
|
+
// superseded by the bare row, no slug), so its bytes stay referenced and
|
|
1035
|
+
// visible. Same content (only metadata differs): the bare row wins.
|
|
1036
|
+
for (const [id, row] of bare.docs) {
|
|
1037
|
+
const twinRow = twin.docs.get(id);
|
|
1038
|
+
if (twinRow === undefined)
|
|
1039
|
+
continue;
|
|
1040
|
+
const content = String(twinRow.values.sha256);
|
|
1041
|
+
if (content === String(row.values.sha256))
|
|
1042
|
+
continue;
|
|
1043
|
+
const owner = bareBySha.get(content);
|
|
1044
|
+
if (owner !== undefined) {
|
|
1045
|
+
plan.merged.push(`${id} (twin version) -> ${owner}`);
|
|
1046
|
+
continue;
|
|
1047
|
+
}
|
|
1048
|
+
const archiveId = `${id}~twin-${content.slice(0, 8)}`;
|
|
1049
|
+
const values = {
|
|
1050
|
+
...twinRow.values,
|
|
1051
|
+
id: archiveId,
|
|
1052
|
+
slug: null,
|
|
1053
|
+
lifecycle_status: 'superseded',
|
|
1054
|
+
superseded_by: id,
|
|
1055
|
+
};
|
|
1056
|
+
plan.set.set(`d:${archiveId}`, JSON.stringify(columns.map((c) => values[c] ?? null)));
|
|
1057
|
+
plan.merged.push(`${id} (twin version) -> ${archiveId}`);
|
|
1058
|
+
bareBySha.set(content, archiveId);
|
|
1059
|
+
}
|
|
1060
|
+
// Twin-only rows: merge same-content duplicates into the bare row, keep the rest.
|
|
1061
|
+
const mergedInto = new Map();
|
|
1062
|
+
const kept = [];
|
|
1063
|
+
for (const id of [...twin.docs.keys()].sort()) {
|
|
1064
|
+
if (bare.docs.has(id))
|
|
1065
|
+
continue;
|
|
1066
|
+
const row = twin.docs.get(id);
|
|
1067
|
+
const content = String(row.values.sha256);
|
|
1068
|
+
// The same content in a bare row, or in a twin-only row kept already
|
|
1069
|
+
// (a frozen twin had no UNIQUE sha256 index).
|
|
1070
|
+
const owner = bareBySha.get(content);
|
|
1071
|
+
if (owner !== undefined) {
|
|
1072
|
+
mergedInto.set(id, owner);
|
|
1073
|
+
plan.del.push(`d:${id}`);
|
|
1074
|
+
plan.merged.push(`${id} -> ${owner}`);
|
|
1075
|
+
}
|
|
1076
|
+
else {
|
|
1077
|
+
bareBySha.set(content, id);
|
|
1078
|
+
kept.push(id);
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
for (const id of kept) {
|
|
1082
|
+
const row = twin.docs.get(id);
|
|
1083
|
+
const values = { ...row.values };
|
|
1084
|
+
let changed = false;
|
|
1085
|
+
if (typeof values.slug === 'string') {
|
|
1086
|
+
const holder = slugOwner.get(values.slug);
|
|
1087
|
+
if (holder !== undefined && holder !== id) {
|
|
1088
|
+
const next = freeSlug(values.slug, slugOwner, id);
|
|
1089
|
+
plan.renamed.push(`${id}: ${values.slug} -> ${next}`);
|
|
1090
|
+
values.slug = next;
|
|
1091
|
+
changed = true;
|
|
1092
|
+
}
|
|
1093
|
+
slugOwner.set(values.slug, id);
|
|
1094
|
+
}
|
|
1095
|
+
for (const col of ['supersedes', 'superseded_by']) {
|
|
1096
|
+
const target = values[col];
|
|
1097
|
+
if (typeof target === 'string' && mergedInto.has(target)) {
|
|
1098
|
+
values[col] = mergedInto.get(target);
|
|
1099
|
+
changed = true;
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
if (changed)
|
|
1103
|
+
plan.set.set(`d:${id}`, JSON.stringify(columns.map((c) => values[c] ?? null)));
|
|
1104
|
+
}
|
|
1105
|
+
// Refs: the union, twin refs remapped onto merged ids.
|
|
1106
|
+
for (const [key, row] of bare.refs)
|
|
1107
|
+
if (twin.refs.get(key)?.json !== row.json)
|
|
1108
|
+
plan.set.set(`r:${key}`, row.json);
|
|
1109
|
+
for (const [key, row] of twin.refs) {
|
|
1110
|
+
const [attachmentId, ownerType, ownerId] = JSON.parse(key);
|
|
1111
|
+
const target = mergedInto.get(attachmentId);
|
|
1112
|
+
if (target === undefined)
|
|
1113
|
+
continue; // kept as it is
|
|
1114
|
+
plan.del.push(`r:${key}`);
|
|
1115
|
+
const moved = JSON.stringify([target, ownerType, ownerId]);
|
|
1116
|
+
if (bare.refs.has(moved) || plan.set.has(`r:${moved}`))
|
|
1117
|
+
continue;
|
|
1118
|
+
const values = JSON.parse(row.json);
|
|
1119
|
+
values[idAt] = target;
|
|
1120
|
+
plan.set.set(`r:${moved}`, JSON.stringify(values));
|
|
1121
|
+
}
|
|
1122
|
+
return plan;
|
|
1123
|
+
}
|
|
1124
|
+
/**
|
|
1125
|
+
* Plan the docs merge. Keys: `d:<twin id>` (doc rows) and `r:<JSON key>` (refs).
|
|
1126
|
+
*
|
|
1127
|
+
* Initial collapse, a union that never drops content ({@link planInitialDocs}):
|
|
1128
|
+
* bare rows are authoritative for their ids; twin-only rows are kept (on the
|
|
1129
|
+
* live cleocode store they are the whole docs history before 2026-06-03: ADRs,
|
|
1130
|
+
* specs, research, with live task refs), except a twin-only row whose content
|
|
1131
|
+
* a bare row holds, which is merged into that bare row; refs are united.
|
|
1132
|
+
*
|
|
1133
|
+
* Incremental re-merge, per bare doc whose row hash moved since the last merge
|
|
1134
|
+
* (its id translated through the aliases):
|
|
1135
|
+
* - twin row changed too → conflict, the twin wins;
|
|
1136
|
+
* - bare row gone → the twin row is deleted unless a ref (from either build)
|
|
1137
|
+
* or a supersedes link still names it;
|
|
1138
|
+
* - the content (sha256) is another twin row's → no new row: the bare id is
|
|
1139
|
+
* aliased to that row, and its refs follow it;
|
|
1140
|
+
* - the slug is another twin row's → the doc is carried under `<slug>-<n>`
|
|
1141
|
+
* (listed as renamed);
|
|
1142
|
+
* - otherwise the bare row is upserted.
|
|
1143
|
+
* Per bare ref whose hash moved: an added ref is added (under the aliased id)
|
|
1144
|
+
* unless the twin has it or its doc is gone; a removed ref is removed unless
|
|
1145
|
+
* this build changed it since.
|
|
1146
|
+
*/
|
|
1147
|
+
function planDocs(db, state) {
|
|
1148
|
+
const plan = emptyPlan();
|
|
1149
|
+
const bare = docsSide(db, 'bare');
|
|
1150
|
+
const twin = docsSide(db, 'twin');
|
|
1151
|
+
if (state === undefined)
|
|
1152
|
+
return planInitialDocs(db, plan, bare, twin);
|
|
1153
|
+
const last = lastHashes(db, DOCS, state);
|
|
1154
|
+
const aliases = { ...readAliases(db) };
|
|
1155
|
+
plan.aliases = aliases;
|
|
1156
|
+
const hashOf = (row) => (row ? sha(row.json) : ABSENT);
|
|
1157
|
+
const columns = docColumns(db);
|
|
1158
|
+
const deletions = [];
|
|
1159
|
+
const candidates = [];
|
|
1160
|
+
const lastBareDocs = Object.keys(last.bare)
|
|
1161
|
+
.filter((k) => k.startsWith('d:'))
|
|
1162
|
+
.map((k) => k.slice(2));
|
|
1163
|
+
for (const b of [...new Set([...bare.docs.keys(), ...lastBareDocs])].sort()) {
|
|
1164
|
+
const bareRow = bare.docs.get(b);
|
|
1165
|
+
if (hashOf(bareRow) === (last.bare[`d:${b}`] ?? ABSENT))
|
|
1166
|
+
continue;
|
|
1167
|
+
const x = aliases[b] ?? b;
|
|
1168
|
+
const twinRow = twin.docs.get(x);
|
|
1169
|
+
if (hashOf(twinRow) !== (last.twin[`d:${x}`] ?? ABSENT)) {
|
|
1170
|
+
plan.conflicts.push(JSON.stringify([b]));
|
|
1171
|
+
continue;
|
|
1172
|
+
}
|
|
1173
|
+
if (bareRow === undefined) {
|
|
1174
|
+
if (twinRow !== undefined)
|
|
1175
|
+
deletions.push(x);
|
|
1176
|
+
}
|
|
1177
|
+
else {
|
|
1178
|
+
candidates.push({ b, x, twinRow });
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
// Natural keys are checked against the state AFTER the merge: twin rows the
|
|
1182
|
+
// merge does not touch, plus the carried rows (so two docs swapping a slug
|
|
1183
|
+
// do not collide with each other's old values).
|
|
1184
|
+
const moving = new Set([...deletions, ...candidates.map((c) => c.x)]);
|
|
1185
|
+
const shaOwner = new Map();
|
|
1186
|
+
const slugOwner = new Map();
|
|
1187
|
+
for (const [id, row] of twin.docs) {
|
|
1188
|
+
if (moving.has(id))
|
|
1189
|
+
continue;
|
|
1190
|
+
shaOwner.set(String(row.values.sha256), id);
|
|
1191
|
+
if (typeof row.values.slug === 'string')
|
|
1192
|
+
slugOwner.set(row.values.slug, id);
|
|
1193
|
+
}
|
|
1194
|
+
for (const { b, x, twinRow } of candidates) {
|
|
1195
|
+
const values = { ...(bare.docs.get(b)?.values ?? {}), id: x };
|
|
1196
|
+
const content = String(values.sha256);
|
|
1197
|
+
const holder = shaOwner.get(content);
|
|
1198
|
+
if (holder !== undefined && holder !== x) {
|
|
1199
|
+
if (twinRow === undefined) {
|
|
1200
|
+
aliases[b] = holder;
|
|
1201
|
+
plan.merged.push(`${b} -> ${holder}`);
|
|
1202
|
+
}
|
|
1203
|
+
else {
|
|
1204
|
+
plan.conflicts.push(JSON.stringify([b]));
|
|
1205
|
+
// The twin row stays as it is: keep its keys owned.
|
|
1206
|
+
shaOwner.set(String(twinRow.values.sha256), x);
|
|
1207
|
+
if (typeof twinRow.values.slug === 'string')
|
|
1208
|
+
slugOwner.set(twinRow.values.slug, x);
|
|
1209
|
+
}
|
|
1210
|
+
continue;
|
|
1211
|
+
}
|
|
1212
|
+
if (typeof values.slug === 'string') {
|
|
1213
|
+
const slugHolder = slugOwner.get(values.slug);
|
|
1214
|
+
if (slugHolder !== undefined && slugHolder !== x) {
|
|
1215
|
+
const next = freeSlug(values.slug, slugOwner, x);
|
|
1216
|
+
plan.renamed.push(`${b}: ${values.slug} -> ${next}`);
|
|
1217
|
+
values.slug = next;
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
shaOwner.set(content, x);
|
|
1221
|
+
if (typeof values.slug === 'string')
|
|
1222
|
+
slugOwner.set(values.slug, x);
|
|
1223
|
+
const json = JSON.stringify(columns.map((c) => values[c] ?? null));
|
|
1224
|
+
if (twinRow?.json !== json)
|
|
1225
|
+
plan.set.set(`d:${x}`, json);
|
|
1226
|
+
}
|
|
1227
|
+
const refCols = refColumns(db);
|
|
1228
|
+
const idAt = refCols.indexOf('attachment_id');
|
|
1229
|
+
const lastBareRefs = Object.keys(last.bare)
|
|
1230
|
+
.filter((k) => k.startsWith('r:'))
|
|
1231
|
+
.map((k) => k.slice(2));
|
|
1232
|
+
const docGoing = new Set(deletions);
|
|
1233
|
+
const docExists = (id) => (twin.docs.has(id) && !docGoing.has(id)) || plan.set.has(`d:${id}`);
|
|
1234
|
+
for (const key of [...new Set([...bare.refs.keys(), ...lastBareRefs])].sort()) {
|
|
1235
|
+
const bareRef = bare.refs.get(key);
|
|
1236
|
+
if (hashOf(bareRef) === (last.bare[`r:${key}`] ?? ABSENT))
|
|
1237
|
+
continue;
|
|
1238
|
+
const [attachmentId, ownerType, ownerId] = JSON.parse(key);
|
|
1239
|
+
const target = aliases[attachmentId] ?? attachmentId;
|
|
1240
|
+
const twinKey = JSON.stringify([target, ownerType, ownerId]);
|
|
1241
|
+
const twinRef = twin.refs.get(twinKey);
|
|
1242
|
+
if (bareRef !== undefined) {
|
|
1243
|
+
if (twinRef !== undefined)
|
|
1244
|
+
continue; // the twin already holds this ref
|
|
1245
|
+
if (!docExists(target)) {
|
|
1246
|
+
plan.skipped++;
|
|
1247
|
+
continue;
|
|
1248
|
+
}
|
|
1249
|
+
const values = JSON.parse(bareRef.json);
|
|
1250
|
+
values[idAt] = target;
|
|
1251
|
+
plan.set.set(`r:${twinKey}`, JSON.stringify(values));
|
|
1252
|
+
}
|
|
1253
|
+
else if (twinRef !== undefined && hashOf(twinRef) === (last.twin[`r:${twinKey}`] ?? ABSENT)) {
|
|
1254
|
+
plan.del.push(`r:${twinKey}`);
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
// A doc row goes only when nothing names it any more.
|
|
1258
|
+
const refsAfter = new Map();
|
|
1259
|
+
for (const [key] of twin.refs) {
|
|
1260
|
+
if (plan.del.includes(`r:${key}`))
|
|
1261
|
+
continue;
|
|
1262
|
+
const id = JSON.parse(key)[0];
|
|
1263
|
+
refsAfter.set(id, (refsAfter.get(id) ?? 0) + 1);
|
|
1264
|
+
}
|
|
1265
|
+
for (const key of plan.set.keys()) {
|
|
1266
|
+
if (!key.startsWith('r:'))
|
|
1267
|
+
continue;
|
|
1268
|
+
const id = JSON.parse(key.slice(2))[0];
|
|
1269
|
+
refsAfter.set(id, (refsAfter.get(id) ?? 0) + 1);
|
|
1270
|
+
}
|
|
1271
|
+
const linked = new Set();
|
|
1272
|
+
for (const [id, row] of twin.docs) {
|
|
1273
|
+
if (docGoing.has(id))
|
|
1274
|
+
continue;
|
|
1275
|
+
for (const col of ['supersedes', 'superseded_by']) {
|
|
1276
|
+
const v = row.values[col];
|
|
1277
|
+
if (typeof v === 'string')
|
|
1278
|
+
linked.add(v);
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
for (const id of deletions) {
|
|
1282
|
+
if ((refsAfter.get(id) ?? 0) > 0 || linked.has(id))
|
|
1283
|
+
plan.kept.push(id);
|
|
1284
|
+
else
|
|
1285
|
+
plan.del.push(`d:${id}`);
|
|
1286
|
+
}
|
|
1287
|
+
return plan;
|
|
1288
|
+
}
|
|
1289
|
+
/** Apply a docs plan to `schema` (`main`, or `temp` for the shadows). */
|
|
1290
|
+
function applyDocs(db, schema, plan) {
|
|
1291
|
+
const docCols = docColumns(db);
|
|
1292
|
+
const refCols = refColumns(db);
|
|
1293
|
+
const [docs, refs] = DOCS_TWINS;
|
|
1294
|
+
const before = new Set(db.prepare(`SELECT id FROM ${schema}.${docs}`).all().map((r) => r.id));
|
|
1295
|
+
const refsBefore = new Set(db
|
|
1296
|
+
.prepare(`SELECT attachment_id, owner_type, owner_id FROM ${schema}.${refs}`)
|
|
1297
|
+
.all().map((r) => JSON.stringify(REF_KEY.map((k) => r[k]))));
|
|
1298
|
+
const upsert = (table, cols, key) => db.prepare(`INSERT INTO ${schema}.${table} (${cols.map(quoteIdent).join(', ')}) ` +
|
|
1299
|
+
`VALUES (${cols.map(() => '?').join(', ')}) ` +
|
|
1300
|
+
`ON CONFLICT(${key.map(quoteIdent).join(', ')}) DO UPDATE SET ` +
|
|
1301
|
+
cols
|
|
1302
|
+
.filter((c) => !key.includes(c))
|
|
1303
|
+
.map((c) => `${quoteIdent(c)} = excluded.${quoteIdent(c)}`)
|
|
1304
|
+
.join(', '));
|
|
1305
|
+
const upsertDoc = upsert(docs, docCols, ['id']);
|
|
1306
|
+
const upsertRef = upsert(refs, refCols, REF_KEY);
|
|
1307
|
+
const delDoc = db.prepare(`DELETE FROM ${schema}.${docs} WHERE id = ?`);
|
|
1308
|
+
const delRef = db.prepare(`DELETE FROM ${schema}.${refs} WHERE attachment_id = ? AND owner_type = ? AND owner_id = ?`);
|
|
1309
|
+
let inserted = 0;
|
|
1310
|
+
let replaced = 0;
|
|
1311
|
+
let deleted = 0;
|
|
1312
|
+
const params = (json) => JSON.parse(json);
|
|
1313
|
+
for (const key of plan.del)
|
|
1314
|
+
if (key.startsWith('r:'))
|
|
1315
|
+
deleted += Number(delRef.run(...params(key.slice(2))).changes);
|
|
1316
|
+
for (const key of plan.del)
|
|
1317
|
+
if (key.startsWith('d:'))
|
|
1318
|
+
deleted += Number(delDoc.run(key.slice(2)).changes);
|
|
1319
|
+
for (const [key, json] of plan.set) {
|
|
1320
|
+
if (!key.startsWith('d:'))
|
|
1321
|
+
continue;
|
|
1322
|
+
upsertDoc.run(...params(json));
|
|
1323
|
+
if (before.has(key.slice(2)))
|
|
1324
|
+
replaced++;
|
|
1325
|
+
else
|
|
1326
|
+
inserted++;
|
|
1327
|
+
}
|
|
1328
|
+
for (const [key, json] of plan.set) {
|
|
1329
|
+
if (!key.startsWith('r:'))
|
|
1330
|
+
continue;
|
|
1331
|
+
upsertRef.run(...params(json));
|
|
1332
|
+
if (refsBefore.has(key.slice(2)))
|
|
1333
|
+
replaced++;
|
|
1334
|
+
else
|
|
1335
|
+
inserted++;
|
|
1336
|
+
}
|
|
1337
|
+
// `ref_count` is derived: the number of refs, whoever wrote them.
|
|
1338
|
+
db.exec(`UPDATE ${schema}.${docs} SET ref_count = ` +
|
|
1339
|
+
`(SELECT COUNT(*) FROM ${schema}.${refs} r WHERE r.attachment_id = ${docs}.id) ` +
|
|
1340
|
+
`WHERE ref_count IS NOT (SELECT COUNT(*) FROM ${schema}.${refs} r WHERE r.attachment_id = ${docs}.id)`);
|
|
1341
|
+
return { inserted, replaced, deleted };
|
|
1342
|
+
}
|
|
1343
|
+
/**
|
|
1344
|
+
* `attachments` + `attachment_refs` → `docs_attachments` + `docs_attachment_refs`,
|
|
1345
|
+
* merged as ONE pair in one transaction: a doc and its refs never diverge, and
|
|
1346
|
+
* `ref_count` is recomputed from the merged refs (never carried). See
|
|
1347
|
+
* {@link planDocs} for the rules. Every merge ends with the UNIQUE `slug` and
|
|
1348
|
+
* `sha256` indexes in place and the bare-side change triggers installed; after
|
|
1349
|
+
* the commit, every twin doc's blob is pinned (and restored if an older build
|
|
1350
|
+
* deleted it, see `blob-keep.ts`).
|
|
1351
|
+
*/
|
|
1352
|
+
const DOCS = {
|
|
1353
|
+
table: 'attachments',
|
|
1354
|
+
twin: 'docs_attachments',
|
|
1355
|
+
kvTable: 'tasks_schema_meta',
|
|
1356
|
+
tables: [...DOCS_BARE, ...DOCS_TWINS, 'tasks_schema_meta'],
|
|
1357
|
+
shadows: DOCS_TWINS,
|
|
1358
|
+
bareHashes: (db) => docsHashes(db, 'bare'),
|
|
1359
|
+
twinHashes: (db) => docsHashes(db, 'twin'),
|
|
1360
|
+
changeSeq: docsChangeSeq,
|
|
1361
|
+
guardsIntact: docsGuardsIntact,
|
|
1362
|
+
installGuards: ensureDocsGuards,
|
|
1363
|
+
plan: planDocs,
|
|
1364
|
+
apply(db, plan) {
|
|
1365
|
+
// Rows may reference each other (self foreign keys); check at COMMIT.
|
|
1366
|
+
db.exec('PRAGMA defer_foreign_keys = ON');
|
|
1367
|
+
for (const index of DOCS_UNIQUE_INDEXES)
|
|
1368
|
+
db.exec(`DROP INDEX IF EXISTS main.${quoteIdent(index.name)}`);
|
|
1369
|
+
const counts = applyDocs(db, 'main', plan);
|
|
1370
|
+
for (const index of DOCS_UNIQUE_INDEXES)
|
|
1371
|
+
db.exec(`CREATE UNIQUE INDEX main.${quoteIdent(index.name)} ON docs_attachments ${index.on}`);
|
|
1372
|
+
if (plan.aliases !== undefined)
|
|
1373
|
+
writeKv(db, 'tasks_schema_meta', DOCS_ALIAS_KEY, JSON.stringify(plan.aliases));
|
|
1374
|
+
ensureDocsGuards(db);
|
|
1375
|
+
const after = docsSide(db, 'twin');
|
|
1376
|
+
for (const [key, json] of plan.set) {
|
|
1377
|
+
const got = key.startsWith('d:')
|
|
1378
|
+
? after.docs.get(key.slice(2))?.json
|
|
1379
|
+
: after.refs.get(key.slice(2))?.json;
|
|
1380
|
+
if (got !== json)
|
|
1381
|
+
throw new Error(`attachments collapse did not verify for ${key}`);
|
|
1382
|
+
}
|
|
1383
|
+
for (const key of plan.del) {
|
|
1384
|
+
const present = key.startsWith('d:')
|
|
1385
|
+
? after.docs.has(key.slice(2))
|
|
1386
|
+
: after.refs.has(key.slice(2));
|
|
1387
|
+
if (present)
|
|
1388
|
+
throw new Error(`attachments collapse did not verify the removal of ${key}`);
|
|
1389
|
+
}
|
|
1390
|
+
return counts;
|
|
1391
|
+
},
|
|
1392
|
+
shadow(db, plan) {
|
|
1393
|
+
const keys = {
|
|
1394
|
+
docs_attachments: ['id'],
|
|
1395
|
+
docs_attachment_refs: REF_KEY,
|
|
1396
|
+
};
|
|
1397
|
+
for (const twin of DOCS_TWINS) {
|
|
1398
|
+
db.exec(`CREATE TEMP TABLE IF NOT EXISTS ${twin} AS SELECT * FROM main.${twin} WHERE 0`);
|
|
1399
|
+
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS temp.${quoteIdent(`${twin}_shadow_key`)} ` +
|
|
1400
|
+
`ON ${twin} (${(keys[twin] ?? []).map(quoteIdent).join(', ')})`);
|
|
1401
|
+
db.exec(`DELETE FROM temp.${twin}`);
|
|
1402
|
+
db.exec(`INSERT INTO temp.${twin} SELECT * FROM main.${twin}`);
|
|
1403
|
+
}
|
|
1404
|
+
applyDocs(db, 'temp', plan);
|
|
1405
|
+
},
|
|
1406
|
+
afterCommit(db, _plan, dbPath) {
|
|
1407
|
+
const cleoDir = dirname(dbPath);
|
|
1408
|
+
const rows = db
|
|
1409
|
+
.prepare('SELECT sha256, attachment_json FROM main.docs_attachments')
|
|
1410
|
+
.all();
|
|
1411
|
+
for (const row of rows) {
|
|
1412
|
+
const primary = blobFileForRow(cleoDir, row.sha256, row.attachment_json);
|
|
1413
|
+
if (primary === null)
|
|
1414
|
+
continue;
|
|
1415
|
+
restoreBlob(cleoDir, row.sha256, primary);
|
|
1416
|
+
pinBlob(cleoDir, row.sha256, primary);
|
|
1417
|
+
}
|
|
1418
|
+
},
|
|
1419
|
+
};
|
|
590
1420
|
/** The pairs this build collapses, in order. */
|
|
591
|
-
const PAIRS = [SCHEMA_META, STICKY_TAGS];
|
|
1421
|
+
const PAIRS = [SCHEMA_META, STICKY_TAGS, DOCS];
|
|
592
1422
|
/** Connections serving TEMP shadows because their collapse failed. */
|
|
593
1423
|
const degraded = new WeakMap();
|
|
594
1424
|
/**
|
|
@@ -603,22 +1433,29 @@ export function twinCollapseFailureOf(db) {
|
|
|
603
1433
|
return degraded.get(db);
|
|
604
1434
|
}
|
|
605
1435
|
/**
|
|
606
|
-
* Refuse a write before its first statement when the
|
|
607
|
-
* failed
|
|
608
|
-
* (`tasks_schema_meta`,
|
|
609
|
-
* degraded store never takes a partial write (e.g. a sticky's
|
|
610
|
-
* without its tag rows).
|
|
611
|
-
*
|
|
612
|
-
*
|
|
613
|
-
*
|
|
614
|
-
* @
|
|
1436
|
+
* Refuse a write before its first statement when the collapse of the table it
|
|
1437
|
+
* writes failed. The write accessors of the collapsed tables
|
|
1438
|
+
* (`tasks_schema_meta`, sticky notes and tags, the docs tables) call it at
|
|
1439
|
+
* entry, so a degraded store never takes a partial write (e.g. a sticky's
|
|
1440
|
+
* `tags_json` without its tag rows). Only the failed pairs are blocked: a
|
|
1441
|
+
* docs merge failure leaves task and sticky writes alone. The shadows' TEMP
|
|
1442
|
+
* triggers stay as the backstop.
|
|
1443
|
+
*
|
|
1444
|
+
* @param handle - The connection the write would use: the native
|
|
1445
|
+
* `DatabaseSync`, or a drizzle instance (its `$client` is checked).
|
|
1446
|
+
* `null`/`undefined` (no bound handle) is not checked.
|
|
1447
|
+
* @param table - The bare table whose pair the write belongs to.
|
|
1448
|
+
* @throws {CleoError} `E_TWIN_COLLAPSE_FAILED` when that pair's collapse failed.
|
|
615
1449
|
* @task T12535
|
|
616
1450
|
*/
|
|
617
|
-
export function assertTwinCollapseWritable(handle) {
|
|
618
|
-
if (
|
|
1451
|
+
export function assertTwinCollapseWritable(handle, table) {
|
|
1452
|
+
if (!handle)
|
|
619
1453
|
return;
|
|
620
|
-
const
|
|
621
|
-
if (
|
|
1454
|
+
const native = '$client' in handle ? handle.$client : handle;
|
|
1455
|
+
if (typeof native !== 'object' || native === null)
|
|
1456
|
+
return;
|
|
1457
|
+
const failure = degraded.get(native);
|
|
1458
|
+
if (failure?.tables.includes(table))
|
|
622
1459
|
throw twinCollapseError(failure);
|
|
623
1460
|
}
|
|
624
1461
|
/**
|
|
@@ -639,10 +1476,16 @@ export function twinCollapseError(failure, cause) {
|
|
|
639
1476
|
? ` The snapshot needs ${failure.requiredBytes} bytes free` +
|
|
640
1477
|
(failure.availableBytes === null ? '.' : ` (${failure.availableBytes} free).`)
|
|
641
1478
|
: '';
|
|
1479
|
+
const snapshotProblem = failure.cause.startsWith(SNAPSHOT_FAILURE_PREFIX);
|
|
642
1480
|
return new CleoError(ExitCode.TWIN_COLLAPSE_FAILED, `Twin collapse of ${failure.tables.join(', ')} failed (both tables unchanged; reads still ` +
|
|
643
|
-
`work, writes are refused): ${failure.cause}.${where}${space}
|
|
644
|
-
|
|
645
|
-
|
|
1481
|
+
`work, writes to them are refused): ${failure.cause}.${where}${space} ` +
|
|
1482
|
+
"Run 'cleo doctor twin-collapse'.", {
|
|
1483
|
+
fix: snapshotProblem
|
|
1484
|
+
? 'The snapshot could not be written: free the space or make .cleo/backups/sqlite ' +
|
|
1485
|
+
"writable, then run 'cleo doctor twin-collapse --retry'."
|
|
1486
|
+
: 'The merge rejected a row of the bare table (the cause names the constraint, e.g. a ' +
|
|
1487
|
+
"CHECK or UNIQUE). Fix or remove that row, then run 'cleo doctor twin-collapse --retry'; " +
|
|
1488
|
+
"'cleo doctor twin-collapse' shows the details.",
|
|
646
1489
|
details: { field: 'twinCollapse', ...failure },
|
|
647
1490
|
cause,
|
|
648
1491
|
});
|
|
@@ -659,6 +1502,8 @@ function recordFailure(db, pairs, failure) {
|
|
|
659
1502
|
}
|
|
660
1503
|
}
|
|
661
1504
|
}
|
|
1505
|
+
/** Cause prefix of a failure to write the snapshot (before any merge). */
|
|
1506
|
+
const SNAPSHOT_FAILURE_PREFIX = 'snapshot not written: ';
|
|
662
1507
|
/** Message a write into a sealed shadow aborts with. */
|
|
663
1508
|
export const SHADOW_WRITE_REFUSED = 'E_TWIN_COLLAPSE_FAILED: store is read-only until the twin collapse succeeds; run cleo doctor twin-collapse';
|
|
664
1509
|
const SHADOW_WRITE_OPS = ['INSERT', 'UPDATE', 'DELETE'];
|
|
@@ -684,7 +1529,8 @@ function sealShadow(db, twin) {
|
|
|
684
1529
|
/** Drop the TEMP shadows (and their triggers) of a connection that is no longer degraded. */
|
|
685
1530
|
function clearShadows(db) {
|
|
686
1531
|
for (const pair of PAIRS)
|
|
687
|
-
|
|
1532
|
+
for (const shadow of pair.shadows ?? [pair.twin])
|
|
1533
|
+
db.exec(`DROP TABLE IF EXISTS temp.${shadow}`);
|
|
688
1534
|
degraded.delete(db);
|
|
689
1535
|
}
|
|
690
1536
|
function receipt(pair, status, snapshotPath) {
|
|
@@ -699,10 +1545,14 @@ function receipt(pair, status, snapshotPath) {
|
|
|
699
1545
|
skipped: 0,
|
|
700
1546
|
dropped: [],
|
|
701
1547
|
conflicts: [],
|
|
1548
|
+
merged: [],
|
|
1549
|
+
renamed: [],
|
|
1550
|
+
kept: [],
|
|
1551
|
+
archived: [],
|
|
702
1552
|
};
|
|
703
1553
|
}
|
|
704
1554
|
/** Run one pair's merge in its own transaction. */
|
|
705
|
-
function collapsePair(db, pair, snapshotPath) {
|
|
1555
|
+
function collapsePair(db, pair, snapshotPath, dbPath) {
|
|
706
1556
|
db.exec('BEGIN IMMEDIATE');
|
|
707
1557
|
try {
|
|
708
1558
|
// Re-read under the write lock: another process may have merged since.
|
|
@@ -721,21 +1571,40 @@ function collapsePair(db, pair, snapshotPath) {
|
|
|
721
1571
|
task: 'T12535',
|
|
722
1572
|
collapsedAt: state?.collapsedAt || now,
|
|
723
1573
|
lastMergedAt: now,
|
|
724
|
-
|
|
1574
|
+
// Only an initial collapse records the snapshot it took. A later one
|
|
1575
|
+
// keeps what the marker holds, including `null` after an owner
|
|
1576
|
+
// released it (T12767): the snapshot another pair's initial collapse
|
|
1577
|
+
// took is not this pair's pre-collapse store.
|
|
1578
|
+
snapshot: state === undefined ? snapshotPath : state.snapshot,
|
|
725
1579
|
hashes: { bare: pair.bareHashes(db), twin: pair.twinHashes(db) },
|
|
726
1580
|
dropped: state === undefined ? plan.dropped : state.dropped,
|
|
1581
|
+
kept: state === undefined ? plan.kept.slice(0, MAX_CONFLICTS) : state.kept,
|
|
1582
|
+
archived: state === undefined ? plan.archived : state.archived,
|
|
727
1583
|
conflicts: plan.conflicts.slice(0, MAX_CONFLICTS),
|
|
728
1584
|
conflictsAt: plan.conflicts.length > 0 ? now : null,
|
|
1585
|
+
seq: pair.changeSeq?.(db) ?? null,
|
|
1586
|
+
merged: plan.merged.slice(0, MAX_CONFLICTS),
|
|
1587
|
+
renamed: plan.renamed.slice(0, MAX_CONFLICTS),
|
|
729
1588
|
};
|
|
730
1589
|
writeKv(db, pair.kvTable, `${TWIN_COLLAPSE_MARKER_PREFIX}${pair.table}`, JSON.stringify(next));
|
|
731
1590
|
db.prepare(`DELETE FROM main.${pair.kvTable} WHERE key = ?`).run(`${TWIN_COLLAPSE_FAILURE_PREFIX}${pair.table}`);
|
|
732
1591
|
db.exec('COMMIT');
|
|
1592
|
+
try {
|
|
1593
|
+
pair.afterCommit?.(db, plan, dbPath);
|
|
1594
|
+
}
|
|
1595
|
+
catch (error) {
|
|
1596
|
+
log.warn({ err: error }, `after-merge work for ${pair.table} failed (T12535)`);
|
|
1597
|
+
}
|
|
733
1598
|
const done = {
|
|
734
1599
|
...receipt(pair, state === undefined ? 'initial' : 'incremental', next.snapshot),
|
|
735
1600
|
...counts,
|
|
736
1601
|
skipped: plan.skipped,
|
|
737
1602
|
dropped: plan.dropped,
|
|
738
1603
|
conflicts: plan.conflicts,
|
|
1604
|
+
merged: plan.merged,
|
|
1605
|
+
renamed: plan.renamed,
|
|
1606
|
+
kept: plan.kept,
|
|
1607
|
+
archived: plan.archived,
|
|
739
1608
|
};
|
|
740
1609
|
if (counts.inserted + counts.replaced + counts.deleted > 0 || plan.conflicts.length > 0)
|
|
741
1610
|
log.warn(done, `carried bare ${pair.table} into ${pair.twin} (${done.status}, T12535)`);
|
|
@@ -747,6 +1616,30 @@ function collapsePair(db, pair, snapshotPath) {
|
|
|
747
1616
|
throw error;
|
|
748
1617
|
}
|
|
749
1618
|
}
|
|
1619
|
+
/**
|
|
1620
|
+
* Re-install a collapsed pair's bare-table triggers when something removed
|
|
1621
|
+
* them (a table rebuild, a restore, a journal probe that skipped a migration:
|
|
1622
|
+
* the T12541 class). One `sqlite_master` read per open; a write only when a
|
|
1623
|
+
* trigger is missing. Best effort: a failure is logged and `cleo doctor`
|
|
1624
|
+
* reports the missing triggers.
|
|
1625
|
+
*/
|
|
1626
|
+
function repairGuards(db, pair) {
|
|
1627
|
+
if (!pair.guardsIntact || !pair.installGuards || pair.guardsIntact(db))
|
|
1628
|
+
return;
|
|
1629
|
+
if (db.isTransaction || degraded.has(db))
|
|
1630
|
+
return;
|
|
1631
|
+
try {
|
|
1632
|
+
db.exec('BEGIN IMMEDIATE');
|
|
1633
|
+
pair.installGuards(db);
|
|
1634
|
+
db.exec('COMMIT');
|
|
1635
|
+
log.warn({ table: pair.table }, `re-installed the bare ${pair.table} triggers (T12535)`);
|
|
1636
|
+
}
|
|
1637
|
+
catch (error) {
|
|
1638
|
+
if (db.isTransaction)
|
|
1639
|
+
db.exec('ROLLBACK');
|
|
1640
|
+
log.warn({ err: error, table: pair.table }, `could not re-install the bare triggers (T12535)`);
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
750
1643
|
/**
|
|
751
1644
|
* Collapse every bare/twin pair present in the store: the initial collapse
|
|
752
1645
|
* (snapshot first) where no marker exists yet, an incremental re-merge of
|
|
@@ -774,10 +1667,17 @@ export function collapseTwinTables(nativeDb, dbPath, options = {}) {
|
|
|
774
1667
|
continue;
|
|
775
1668
|
}
|
|
776
1669
|
const state = readState(nativeDb, pair);
|
|
1670
|
+
// A snapshot an earlier collapse took (a build before pinning) is pinned
|
|
1671
|
+
// now, so rotation never deletes it. Best effort; one sidecar read.
|
|
1672
|
+
if (state?.snapshot)
|
|
1673
|
+
pinBackup(state.snapshot, 'migration', SNAPSHOT_PIN_REASON);
|
|
777
1674
|
if (bareChanged(nativeDb, pair, state))
|
|
778
1675
|
pending.push(pair);
|
|
779
|
-
else
|
|
1676
|
+
else {
|
|
1677
|
+
if (state !== undefined)
|
|
1678
|
+
repairGuards(nativeDb, pair);
|
|
780
1679
|
byTable.set(pair.table, receipt(pair, 'unchanged', state?.snapshot ?? null));
|
|
1680
|
+
}
|
|
781
1681
|
}
|
|
782
1682
|
if (pending.length === 0) {
|
|
783
1683
|
if (degraded.has(nativeDb))
|
|
@@ -795,9 +1695,11 @@ export function collapseTwinTables(nativeDb, dbPath, options = {}) {
|
|
|
795
1695
|
// even that cannot be built, reads cannot be served correctly either.
|
|
796
1696
|
try {
|
|
797
1697
|
for (const pair of pairs) {
|
|
798
|
-
|
|
1698
|
+
for (const shadow of pair.shadows ?? [pair.twin])
|
|
1699
|
+
unsealShadow(nativeDb, shadow);
|
|
799
1700
|
pair.shadow(nativeDb, pair.plan(nativeDb, readState(nativeDb, pair)));
|
|
800
|
-
|
|
1701
|
+
for (const shadow of pair.shadows ?? [pair.twin])
|
|
1702
|
+
sealShadow(nativeDb, shadow);
|
|
801
1703
|
}
|
|
802
1704
|
}
|
|
803
1705
|
catch (shadowError) {
|
|
@@ -818,12 +1720,12 @@ export function collapseTwinTables(nativeDb, dbPath, options = {}) {
|
|
|
818
1720
|
if (needSnapshot.length > 0) {
|
|
819
1721
|
const plan = planMigrationSnapshot(nativeDb, dbPath);
|
|
820
1722
|
try {
|
|
821
|
-
snapshotPath = writeMigrationSnapshot(nativeDb, plan, `T12535 twin collapse of ${needSnapshot.map((p) => p.table).join(', ')} (store before the merge)
|
|
1723
|
+
snapshotPath = writeMigrationSnapshot(nativeDb, plan, `T12535 twin collapse of ${needSnapshot.map((p) => p.table).join(', ')} (store before the merge)`, { pinnedReason: SNAPSHOT_PIN_REASON });
|
|
822
1724
|
}
|
|
823
1725
|
catch (error) {
|
|
824
1726
|
fail(needSnapshot, {
|
|
825
1727
|
tables: needSnapshot.map((p) => p.table),
|
|
826
|
-
cause:
|
|
1728
|
+
cause: `${SNAPSHOT_FAILURE_PREFIX}${error instanceof Error ? error.message : String(error)}`,
|
|
827
1729
|
snapshotPath: plan.snapshotPath,
|
|
828
1730
|
snapshotWritten: false,
|
|
829
1731
|
requiredBytes: plan.requiredBytes,
|
|
@@ -840,7 +1742,7 @@ export function collapseTwinTables(nativeDb, dbPath, options = {}) {
|
|
|
840
1742
|
let firstError;
|
|
841
1743
|
for (const pair of pending) {
|
|
842
1744
|
try {
|
|
843
|
-
byTable.set(pair.table, collapsePair(nativeDb, pair, snapshotPath));
|
|
1745
|
+
byTable.set(pair.table, collapsePair(nativeDb, pair, snapshotPath, dbPath));
|
|
844
1746
|
}
|
|
845
1747
|
catch (error) {
|
|
846
1748
|
failed.push(pair);
|
|
@@ -864,6 +1766,12 @@ export function collapseTwinTables(nativeDb, dbPath, options = {}) {
|
|
|
864
1766
|
}
|
|
865
1767
|
return inOrder();
|
|
866
1768
|
}
|
|
1769
|
+
/** Whether a collapse snapshot on disk is pinned, or `null` when there is none. */
|
|
1770
|
+
function snapshotPinnedOf(snapshot) {
|
|
1771
|
+
if (!snapshot || !existsSync(snapshot))
|
|
1772
|
+
return null;
|
|
1773
|
+
return isPinnedBackup(dirname(snapshot), basename(snapshot), 'migration');
|
|
1774
|
+
}
|
|
867
1775
|
/**
|
|
868
1776
|
* Report every pair's collapse state without writing (for `cleo doctor`).
|
|
869
1777
|
*
|
|
@@ -883,6 +1791,13 @@ export function inspectTwinCollapse(db) {
|
|
|
883
1791
|
changedSinceMerge: 0,
|
|
884
1792
|
conflicts: [],
|
|
885
1793
|
conflictsAt: null,
|
|
1794
|
+
merged: [],
|
|
1795
|
+
renamed: [],
|
|
1796
|
+
kept: [],
|
|
1797
|
+
archived: [],
|
|
1798
|
+
guardsIntact: null,
|
|
1799
|
+
snapshotPinned: null,
|
|
1800
|
+
snapshotMissing: false,
|
|
886
1801
|
failure: null,
|
|
887
1802
|
};
|
|
888
1803
|
if (!pair.tables.every((t) => hasMainTable(db, t)))
|
|
@@ -924,8 +1839,596 @@ export function inspectTwinCollapse(db) {
|
|
|
924
1839
|
changedSinceMerge: state === undefined ? 0 : changed,
|
|
925
1840
|
conflicts: state?.conflicts ?? [],
|
|
926
1841
|
conflictsAt: state?.conflictsAt ?? null,
|
|
1842
|
+
merged: state?.merged ?? [],
|
|
1843
|
+
renamed: state?.renamed ?? [],
|
|
1844
|
+
kept: state?.kept ?? [],
|
|
1845
|
+
archived: state?.archived ?? [],
|
|
1846
|
+
guardsIntact: state === undefined || pair.guardsIntact === undefined ? null : pair.guardsIntact(db),
|
|
1847
|
+
snapshotPinned: snapshotPinnedOf(state?.snapshot ?? null),
|
|
1848
|
+
snapshotMissing: typeof state?.snapshot === 'string' && !existsSync(state.snapshot),
|
|
927
1849
|
failure,
|
|
928
1850
|
};
|
|
929
1851
|
});
|
|
930
1852
|
}
|
|
1853
|
+
// ── recovery of what 2026.9.21 dropped or replaced (T12727) ─────────────────
|
|
1854
|
+
/** Prefix of a recovery receipt key: `twin_collapse_recovery:<recoveredAt>`. */
|
|
1855
|
+
export const TWIN_COLLAPSE_RECOVERY_PREFIX = 'twin_collapse_recovery:';
|
|
1856
|
+
/** Error code of a recovery or rollback that cannot proceed; nothing was written. */
|
|
1857
|
+
export const E_TWIN_COLLAPSE_RECOVER = 'E_TWIN_COLLAPSE_RECOVER';
|
|
1858
|
+
const STICKY_ARCHIVE_KEY = `${TWIN_COLLAPSE_ARCHIVE_PREFIX}sticky_tags`;
|
|
1859
|
+
/** A JSON object whose `sessionNotes` is an array (other fields pass through). */
|
|
1860
|
+
const FocusNotesShape = z.object({ sessionNotes: z.array(z.unknown()) }).passthrough();
|
|
1861
|
+
/** The sticky archive: `sticky_id\ttag` rows. */
|
|
1862
|
+
const StickyArchiveShape = z.array(z.string());
|
|
1863
|
+
/** Session notes of a `focus_state` value; `[]` when it has none or is not JSON. */
|
|
1864
|
+
function sessionNotesOf(value) {
|
|
1865
|
+
if (value === undefined)
|
|
1866
|
+
return [];
|
|
1867
|
+
const parsed = FocusNotesShape.safeParse(parseJson(value));
|
|
1868
|
+
return parsed.success ? parsed.data.sessionNotes : [];
|
|
1869
|
+
}
|
|
1870
|
+
/** The `(timestamp, note)` identity {@link mergeFocusState} deduplicates on. */
|
|
1871
|
+
function noteKey(n) {
|
|
1872
|
+
const r = (n !== null && typeof n === 'object' ? n : {});
|
|
1873
|
+
return `${String(r.timestamp ?? '')}\u0000${String(r.note ?? JSON.stringify(n))}`;
|
|
1874
|
+
}
|
|
1875
|
+
const recoveryWrites = new WeakMap();
|
|
1876
|
+
const RecoveryReceiptShape = z.object({
|
|
1877
|
+
task: z.literal('T12727'),
|
|
1878
|
+
id: z.string(),
|
|
1879
|
+
recoveredAt: z.string(),
|
|
1880
|
+
snapshot: z.string().nullable(),
|
|
1881
|
+
archived: z.array(z.object({ key: z.string(), archiveKey: z.string(), sha: z.string() })),
|
|
1882
|
+
focusNotesAdded: z.number(),
|
|
1883
|
+
stickyArchived: z.array(z.string()),
|
|
1884
|
+
stickyArchiveKey: z.string(),
|
|
1885
|
+
before: z.object({ focusState: z.string().nullable(), stickyArchive: z.string().nullable() }),
|
|
1886
|
+
after: z.object({ focusState: z.string().nullable(), stickyArchive: z.string().nullable() }),
|
|
1887
|
+
rolledBackAt: z.string().nullable(),
|
|
1888
|
+
});
|
|
1889
|
+
/** Parsed receipt rows, by key. */
|
|
1890
|
+
function recoveryReceipts(live) {
|
|
1891
|
+
const out = new Map();
|
|
1892
|
+
for (const [key, value] of kvRows(live, 'tasks_schema_meta')) {
|
|
1893
|
+
if (!key.startsWith(TWIN_COLLAPSE_RECOVERY_PREFIX))
|
|
1894
|
+
continue;
|
|
1895
|
+
const parsed = RecoveryReceiptShape.safeParse(parseJson(value));
|
|
1896
|
+
if (parsed.success)
|
|
1897
|
+
out.set(key, parsed.data);
|
|
1898
|
+
}
|
|
1899
|
+
return out;
|
|
1900
|
+
}
|
|
1901
|
+
/** Tag sets of a sticky junction; empty when the table is absent. */
|
|
1902
|
+
function stickySetsIfPresent(db, table) {
|
|
1903
|
+
return hasMainTable(db, table) ? stickySets(db, table) : new Map();
|
|
1904
|
+
}
|
|
1905
|
+
/**
|
|
1906
|
+
* Plan the recovery of the twin values a 2026.9.21 collapse dropped or
|
|
1907
|
+
* replaced, from the pre-collapse snapshot its marker records (T12727).
|
|
1908
|
+
*
|
|
1909
|
+
* The 9.21 initial collapse was bare-authoritative: it deleted keys and
|
|
1910
|
+
* sticky tags only the twin held and overwrote twin values with the bare
|
|
1911
|
+
* ones, keeping them only in that snapshot. This compares the snapshot's
|
|
1912
|
+
* twin and bare tables (read-only; a pair whose table the snapshot lacks is
|
|
1913
|
+
* skipped) and plans, for every twin value that is not live and not archived:
|
|
1914
|
+
* - `schema_meta`: the twin value is written to `twin_collapse_archive:<key>`
|
|
1915
|
+
* (a suffixed key when that name already holds another value, so nothing is
|
|
1916
|
+
* overwritten); for `focus_state` the twin's session notes are also merged
|
|
1917
|
+
* into the live value ({@link mergeFocusState}: live current fields win,
|
|
1918
|
+
* notes deduplicated by `(timestamp, note)` and sorted);
|
|
1919
|
+
* - `sticky_tags`: twin-only tags are added to
|
|
1920
|
+
* `twin_collapse_archive:sticky_tags` in `brain_schema_meta` (a suffixed key
|
|
1921
|
+
* when that row is not a list of tags).
|
|
1922
|
+
* The notes are merged once: not again after an apply that was not rolled
|
|
1923
|
+
* back, nor when the twin value is already archived, so notes pruned after a
|
|
1924
|
+
* recovery stay pruned. A second apply plans nothing.
|
|
1925
|
+
*
|
|
1926
|
+
* @param live - The project `cleo.db` connection (read-only is enough).
|
|
1927
|
+
* @param snapshot - The snapshot, opened read-only, or `null` when missing.
|
|
1928
|
+
* @returns The plan.
|
|
1929
|
+
* @task T12727
|
|
1930
|
+
*/
|
|
1931
|
+
export function planTwinCollapseRecovery(live, snapshot, options = {}) {
|
|
1932
|
+
const state = readState(live, SCHEMA_META);
|
|
1933
|
+
const recorded = state?.snapshot ?? null;
|
|
1934
|
+
const snapshotPath = recorded === null ? null : (options.snapshotPath ?? recorded);
|
|
1935
|
+
const liveKv = kvRows(live, 'tasks_schema_meta');
|
|
1936
|
+
const receipts = [...recoveryReceipts(live)]
|
|
1937
|
+
.filter(([, r]) => r.rolledBackAt === null)
|
|
1938
|
+
.map(([k]) => k)
|
|
1939
|
+
.sort();
|
|
1940
|
+
const empty = {
|
|
1941
|
+
snapshot: snapshotPath,
|
|
1942
|
+
snapshotExists: snapshot !== null,
|
|
1943
|
+
snapshotPinned: recoverySnapshotPinned(snapshotPath),
|
|
1944
|
+
archive: [],
|
|
1945
|
+
focusNotesAdded: 0,
|
|
1946
|
+
stickyArchived: [],
|
|
1947
|
+
stickyArchiveKey: STICKY_ARCHIVE_KEY,
|
|
1948
|
+
receipts,
|
|
1949
|
+
changes: false,
|
|
1950
|
+
movedFrom: recorded !== null && snapshotPath !== recorded ? recorded : null,
|
|
1951
|
+
};
|
|
1952
|
+
if (snapshot === null)
|
|
1953
|
+
return empty;
|
|
1954
|
+
// A pair whose bare or twin table the snapshot lacks had nothing to collapse.
|
|
1955
|
+
const kvPair = hasMainTable(snapshot, 'schema_meta') && hasMainTable(snapshot, 'tasks_schema_meta');
|
|
1956
|
+
const sBare = kvPair ? carriedRows(snapshot, 'schema_meta') : new Map();
|
|
1957
|
+
const sTwin = kvPair ? carriedRows(snapshot, 'tasks_schema_meta') : new Map();
|
|
1958
|
+
const kv = new Map();
|
|
1959
|
+
const archive = [];
|
|
1960
|
+
const archivedValues = new Set([...liveKv].filter(([k]) => k.startsWith(TWIN_COLLAPSE_ARCHIVE_PREFIX)).map(([, v]) => v));
|
|
1961
|
+
const focusBefore = liveKv.get('focus_state') ?? null;
|
|
1962
|
+
let focusState = null;
|
|
1963
|
+
let focusNotesAdded = 0;
|
|
1964
|
+
for (const [key, twinValue] of [...sTwin].sort(([a], [b]) => a.localeCompare(b))) {
|
|
1965
|
+
if (sBare.get(key) === twinValue)
|
|
1966
|
+
continue; // not replaced
|
|
1967
|
+
const reason = sBare.has(key) ? 'replaced' : 'dropped';
|
|
1968
|
+
const done = liveKv.get(key) === twinValue || archivedValues.has(twinValue);
|
|
1969
|
+
if (key === 'focus_state' && !done && receipts.length === 0) {
|
|
1970
|
+
if (focusBefore !== null) {
|
|
1971
|
+
const merged = mergeFocusState(focusBefore, twinValue);
|
|
1972
|
+
if (merged !== focusBefore) {
|
|
1973
|
+
focusNotesAdded = sessionNotesOf(merged).length - sessionNotesOf(focusBefore).length;
|
|
1974
|
+
focusState = merged;
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
else if (reason === 'dropped') {
|
|
1978
|
+
// Only the twin held it (the kodomeet shape) and nothing replaced the
|
|
1979
|
+
// legacy key since: its notes are live again. Its pointer is not:
|
|
1980
|
+
// bound sessions keep their focus under `focus_state:<sessionId>`
|
|
1981
|
+
// (T11345, T12501), and the next keyless one would adopt a live legacy
|
|
1982
|
+
// pointer months stale. The full value stays in the archive (T12771).
|
|
1983
|
+
const parsed = FocusNotesShape.safeParse(parseJson(twinValue));
|
|
1984
|
+
if (parsed.success) {
|
|
1985
|
+
focusState = JSON.stringify({ ...parsed.data, currentTask: null });
|
|
1986
|
+
focusNotesAdded = parsed.data.sessionNotes.length;
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
}
|
|
1990
|
+
if (done)
|
|
1991
|
+
continue;
|
|
1992
|
+
let archiveKey = `${TWIN_COLLAPSE_ARCHIVE_PREFIX}${key}`;
|
|
1993
|
+
if (liveKv.has(archiveKey))
|
|
1994
|
+
archiveKey = `${archiveKey}:${sha(twinValue).slice(0, 8)}`;
|
|
1995
|
+
kv.set(archiveKey, twinValue);
|
|
1996
|
+
archive.push({ key, archiveKey, reason, length: twinValue.length });
|
|
1997
|
+
}
|
|
1998
|
+
// Sticky tags only the twin junction held in the snapshot.
|
|
1999
|
+
const stickyPair = hasMainTable(snapshot, 'sticky_tags') && hasMainTable(snapshot, 'brain_sticky_tags');
|
|
2000
|
+
const sb = stickyPair ? stickySets(snapshot, 'sticky_tags') : new Map();
|
|
2001
|
+
const st = stickyPair ? stickySets(snapshot, 'brain_sticky_tags') : new Map();
|
|
2002
|
+
const liveTags = stickySetsIfPresent(live, 'brain_sticky_tags');
|
|
2003
|
+
const stickyRaw = hasMainTable(live, 'brain_schema_meta')
|
|
2004
|
+
? readKv(live, 'brain_schema_meta', STICKY_ARCHIVE_KEY)
|
|
2005
|
+
: undefined;
|
|
2006
|
+
const stickyParsed = StickyArchiveShape.safeParse(parseJson(stickyRaw ?? '[]'));
|
|
2007
|
+
// A row that is not a list of tags is someone's data: never overwrite it.
|
|
2008
|
+
const stickyArchiveKey = stickyParsed.success
|
|
2009
|
+
? STICKY_ARCHIVE_KEY
|
|
2010
|
+
: `${STICKY_ARCHIVE_KEY}:${sha(stickyRaw ?? '').slice(0, 8)}`;
|
|
2011
|
+
const previousRaw = stickyParsed.success
|
|
2012
|
+
? stickyRaw
|
|
2013
|
+
: readKv(live, 'brain_schema_meta', stickyArchiveKey);
|
|
2014
|
+
const previous = StickyArchiveShape.safeParse(parseJson(previousRaw ?? '[]'));
|
|
2015
|
+
const archivedTags = new Set(previous.success ? previous.data : []);
|
|
2016
|
+
const stickyArchived = [];
|
|
2017
|
+
for (const [id, tags] of st) {
|
|
2018
|
+
const bare = new Set(sb.get(id) ?? []);
|
|
2019
|
+
const now = new Set(liveTags.get(id) ?? []);
|
|
2020
|
+
for (const tag of tags) {
|
|
2021
|
+
const row = `${id}\t${tag}`;
|
|
2022
|
+
if (bare.has(tag) || now.has(tag) || archivedTags.has(row))
|
|
2023
|
+
continue;
|
|
2024
|
+
stickyArchived.push(row);
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
stickyArchived.sort();
|
|
2028
|
+
const stickyArchive = stickyArchived.length > 0
|
|
2029
|
+
? JSON.stringify([...new Set([...archivedTags, ...stickyArchived])].sort())
|
|
2030
|
+
: null;
|
|
2031
|
+
const plan = {
|
|
2032
|
+
...empty,
|
|
2033
|
+
archive,
|
|
2034
|
+
focusNotesAdded,
|
|
2035
|
+
stickyArchived,
|
|
2036
|
+
stickyArchiveKey,
|
|
2037
|
+
changes: kv.size > 0 || focusState !== null || stickyArchive !== null,
|
|
2038
|
+
};
|
|
2039
|
+
recoveryWrites.set(plan, {
|
|
2040
|
+
snapshot,
|
|
2041
|
+
options,
|
|
2042
|
+
kv,
|
|
2043
|
+
focusBefore,
|
|
2044
|
+
focusState,
|
|
2045
|
+
stickyBefore: previousRaw ?? null,
|
|
2046
|
+
stickyArchive,
|
|
2047
|
+
});
|
|
2048
|
+
return plan;
|
|
2049
|
+
}
|
|
2050
|
+
/**
|
|
2051
|
+
* Apply a recovery in one `BEGIN IMMEDIATE` transaction on the live store.
|
|
2052
|
+
*
|
|
2053
|
+
* The plan is recomputed INSIDE the transaction from the live values read
|
|
2054
|
+
* under the write lock, so a note written after `plan` was built is merged,
|
|
2055
|
+
* never lost; `plan` is the preview the caller showed. Writes the archive
|
|
2056
|
+
* keys, the merged `focus_state`, the sticky archive, and a new receipt.
|
|
2057
|
+
* Nothing is written when there is nothing to recover. Never touches the
|
|
2058
|
+
* snapshot.
|
|
2059
|
+
*
|
|
2060
|
+
* @param live - The project `cleo.db` connection, outside a transaction.
|
|
2061
|
+
* @param plan - From {@link planTwinCollapseRecovery} on the same connection.
|
|
2062
|
+
* @returns The plan applied and its receipt (`null` when nothing was written).
|
|
2063
|
+
* @throws {CleoError} {@link E_TWIN_COLLAPSE_RECOVER} when the recorded
|
|
2064
|
+
* snapshot is missing (nothing is written).
|
|
2065
|
+
* @task T12727
|
|
2066
|
+
*/
|
|
2067
|
+
export function applyTwinCollapseRecovery(live, plan, onRepoint) {
|
|
2068
|
+
if (plan.snapshot === null)
|
|
2069
|
+
return { plan, receipt: null, repointed: null };
|
|
2070
|
+
const preview = recoveryWrites.get(plan);
|
|
2071
|
+
if (!plan.snapshotExists || preview === undefined)
|
|
2072
|
+
throw missingSnapshot(plan.snapshot);
|
|
2073
|
+
live.exec('BEGIN IMMEDIATE');
|
|
2074
|
+
try {
|
|
2075
|
+
const fresh = planTwinCollapseRecovery(live, preview.snapshot, preview.options);
|
|
2076
|
+
const writes = recoveryWrites.get(fresh);
|
|
2077
|
+
// Only when there is something to write (T12772): a re-run on a
|
|
2078
|
+
// recovered store is a no-op whatever the pin. Checked before anything
|
|
2079
|
+
// (the re-point, its audit row) is written.
|
|
2080
|
+
if (fresh.changes && writes !== undefined && !fresh.snapshotPinned)
|
|
2081
|
+
throw unpinnedSnapshot(fresh.snapshot);
|
|
2082
|
+
// A moved store (T12788): the markers name the file where it lives now,
|
|
2083
|
+
// whether or not anything is left to recover. `onRepoint` audits it just
|
|
2084
|
+
// before COMMIT, so a failure that rolls the re-point back leaves no
|
|
2085
|
+
// audit row claiming it.
|
|
2086
|
+
let repointed = null;
|
|
2087
|
+
if (fresh.movedFrom !== null && fresh.snapshot !== null)
|
|
2088
|
+
repointed = repointSnapshotMarkers(live, fresh.movedFrom, fresh.snapshot);
|
|
2089
|
+
if (!fresh.changes || writes === undefined) {
|
|
2090
|
+
if (repointed === null)
|
|
2091
|
+
live.exec('ROLLBACK');
|
|
2092
|
+
else {
|
|
2093
|
+
onRepoint?.(repointed);
|
|
2094
|
+
live.exec('COMMIT');
|
|
2095
|
+
}
|
|
2096
|
+
return { plan: fresh, receipt: null, repointed };
|
|
2097
|
+
}
|
|
2098
|
+
const recoveredAt = new Date().toISOString();
|
|
2099
|
+
let id = `${TWIN_COLLAPSE_RECOVERY_PREFIX}${recoveredAt}`;
|
|
2100
|
+
for (let n = 2; readKv(live, 'tasks_schema_meta', id) !== undefined; n++)
|
|
2101
|
+
id = `${TWIN_COLLAPSE_RECOVERY_PREFIX}${recoveredAt}:${n}`;
|
|
2102
|
+
const receipt = {
|
|
2103
|
+
task: 'T12727',
|
|
2104
|
+
id,
|
|
2105
|
+
recoveredAt,
|
|
2106
|
+
snapshot: fresh.snapshot,
|
|
2107
|
+
archived: fresh.archive.map((a) => ({
|
|
2108
|
+
key: a.key,
|
|
2109
|
+
archiveKey: a.archiveKey,
|
|
2110
|
+
sha: sha(writes.kv.get(a.archiveKey) ?? ''),
|
|
2111
|
+
})),
|
|
2112
|
+
focusNotesAdded: fresh.focusNotesAdded,
|
|
2113
|
+
stickyArchived: [...fresh.stickyArchived],
|
|
2114
|
+
stickyArchiveKey: fresh.stickyArchiveKey,
|
|
2115
|
+
before: {
|
|
2116
|
+
focusState: writes.focusState === null ? null : writes.focusBefore,
|
|
2117
|
+
stickyArchive: writes.stickyArchive === null ? null : writes.stickyBefore,
|
|
2118
|
+
},
|
|
2119
|
+
after: {
|
|
2120
|
+
focusState: writes.focusState === null ? null : sha(writes.focusState),
|
|
2121
|
+
stickyArchive: writes.stickyArchive === null ? null : sha(writes.stickyArchive),
|
|
2122
|
+
},
|
|
2123
|
+
rolledBackAt: null,
|
|
2124
|
+
};
|
|
2125
|
+
for (const [key, value] of writes.kv)
|
|
2126
|
+
writeKv(live, 'tasks_schema_meta', key, value);
|
|
2127
|
+
if (writes.focusState !== null)
|
|
2128
|
+
writeKv(live, 'tasks_schema_meta', 'focus_state', writes.focusState);
|
|
2129
|
+
if (writes.stickyArchive !== null)
|
|
2130
|
+
writeKv(live, 'brain_schema_meta', fresh.stickyArchiveKey, writes.stickyArchive);
|
|
2131
|
+
writeKv(live, 'tasks_schema_meta', id, JSON.stringify(receipt));
|
|
2132
|
+
if (repointed !== null)
|
|
2133
|
+
onRepoint?.(repointed);
|
|
2134
|
+
live.exec('COMMIT');
|
|
2135
|
+
return { plan: fresh, receipt, repointed };
|
|
2136
|
+
}
|
|
2137
|
+
catch (error) {
|
|
2138
|
+
if (live.isTransaction)
|
|
2139
|
+
live.exec('ROLLBACK');
|
|
2140
|
+
throw error;
|
|
2141
|
+
}
|
|
2142
|
+
}
|
|
2143
|
+
/** Point every marker that records `from` at `to` (inside the caller's transaction). */
|
|
2144
|
+
function repointSnapshotMarkers(live, from, to) {
|
|
2145
|
+
const markers = [];
|
|
2146
|
+
for (const pair of PAIRS) {
|
|
2147
|
+
if (!hasMainTable(live, pair.kvTable))
|
|
2148
|
+
continue;
|
|
2149
|
+
const key = `${TWIN_COLLAPSE_MARKER_PREFIX}${pair.table}`;
|
|
2150
|
+
const raw = readKv(live, pair.kvTable, key);
|
|
2151
|
+
const parsed = raw === undefined ? undefined : parseJson(raw);
|
|
2152
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
2153
|
+
continue;
|
|
2154
|
+
if (parsed.snapshot !== from)
|
|
2155
|
+
continue;
|
|
2156
|
+
writeKv(live, pair.kvTable, key, JSON.stringify({ ...parsed, snapshot: to }));
|
|
2157
|
+
markers.push(`${pair.kvTable}:${key}`);
|
|
2158
|
+
}
|
|
2159
|
+
return { from, to, markers };
|
|
2160
|
+
}
|
|
2161
|
+
/** The error for a recorded snapshot that is not on disk. */
|
|
2162
|
+
export function missingSnapshot(path) {
|
|
2163
|
+
return new CleoError(ExitCode.NOT_FOUND, `${E_TWIN_COLLAPSE_RECOVER}: the pre-collapse snapshot ${path ?? '(none recorded)'} is missing; nothing was recovered`, {
|
|
2164
|
+
fix: 'Restore that file from a backup of .cleo/backups/sqlite/ and re-run; without it there is nothing to recover from.',
|
|
2165
|
+
details: { field: 'snapshot', actual: path },
|
|
2166
|
+
});
|
|
2167
|
+
}
|
|
2168
|
+
/** The error for a recorded snapshot rotation could still delete. */
|
|
2169
|
+
export function unpinnedSnapshot(path) {
|
|
2170
|
+
return new CleoError(ExitCode.VALIDATION_ERROR, `${E_TWIN_COLLAPSE_RECOVER}: the pre-collapse snapshot ${path ?? '(none recorded)'} is not pinned, so backup rotation may delete it; nothing was recovered`, {
|
|
2171
|
+
fix: 'cleo doctor twin-collapse --recover --pin-snapshot # pins it (writes only its .meta.json sidecar), then recovers',
|
|
2172
|
+
details: { field: 'snapshotPinned', expected: true, actual: false },
|
|
2173
|
+
});
|
|
2174
|
+
}
|
|
2175
|
+
/** Whether the recorded snapshot is on disk and pinned. */
|
|
2176
|
+
function recoverySnapshotPinned(path) {
|
|
2177
|
+
return (path !== null && existsSync(path) && isPinnedBackup(dirname(path), basename(path), 'migration'));
|
|
2178
|
+
}
|
|
2179
|
+
/**
|
|
2180
|
+
* Pin the recorded pre-collapse snapshot before a recovery
|
|
2181
|
+
* (`--pin-snapshot`): writes its `.meta.json` sidecar only, never the snapshot.
|
|
2182
|
+
*
|
|
2183
|
+
* @param path - The snapshot the marker records.
|
|
2184
|
+
* @returns Whether it is pinned now.
|
|
2185
|
+
* @task T12727
|
|
2186
|
+
*/
|
|
2187
|
+
export function pinRecoverySnapshot(path) {
|
|
2188
|
+
return pinBackup(path, 'migration', SNAPSHOT_PIN_REASON);
|
|
2189
|
+
}
|
|
2190
|
+
/**
|
|
2191
|
+
* Undo one recovery apply (`cleo doctor twin-collapse --rollback <id>`), in
|
|
2192
|
+
* one `BEGIN IMMEDIATE` transaction:
|
|
2193
|
+
* - archive keys it wrote are deleted when they still hold that value;
|
|
2194
|
+
* - `focus_state` is restored to its pre-merge value when unchanged since,
|
|
2195
|
+
* else only the notes the recovery added are removed (notes written since
|
|
2196
|
+
* stay);
|
|
2197
|
+
* - the sticky archive is restored when unchanged since, else only the rows
|
|
2198
|
+
* the recovery added are removed;
|
|
2199
|
+
* - the receipt is kept, stamped `rolledBackAt`.
|
|
2200
|
+
*
|
|
2201
|
+
* @param live - The project `cleo.db` connection, outside a transaction.
|
|
2202
|
+
* @param id - The receipt key (`twin_collapse_recovery:<recoveredAt>`).
|
|
2203
|
+
* @returns What was undone.
|
|
2204
|
+
* @throws {CleoError} {@link E_TWIN_COLLAPSE_RECOVER} when the receipt is
|
|
2205
|
+
* unknown or already rolled back (nothing is written).
|
|
2206
|
+
* @task T12727
|
|
2207
|
+
*/
|
|
2208
|
+
export function rollbackTwinCollapseRecovery(live, id) {
|
|
2209
|
+
live.exec('BEGIN IMMEDIATE');
|
|
2210
|
+
try {
|
|
2211
|
+
const receipt = recoveryReceipts(live).get(id);
|
|
2212
|
+
if (receipt === undefined || receipt.rolledBackAt !== null)
|
|
2213
|
+
throw new CleoError(ExitCode.NOT_FOUND, `${E_TWIN_COLLAPSE_RECOVER}: ${receipt === undefined ? 'no recovery receipt' : 'already rolled back:'} ${id}; nothing was changed`, {
|
|
2214
|
+
fix: 'cleo doctor twin-collapse --recover --dry-run # lists the receipts not rolled back',
|
|
2215
|
+
});
|
|
2216
|
+
const archiveDeleted = [];
|
|
2217
|
+
const archiveKept = [];
|
|
2218
|
+
let addedNotes = new Set();
|
|
2219
|
+
for (const a of receipt.archived) {
|
|
2220
|
+
const value = readKv(live, 'tasks_schema_meta', a.archiveKey);
|
|
2221
|
+
if (value === undefined || sha(value) !== a.sha) {
|
|
2222
|
+
archiveKept.push(a.archiveKey);
|
|
2223
|
+
continue;
|
|
2224
|
+
}
|
|
2225
|
+
if (a.key === 'focus_state') {
|
|
2226
|
+
const before = new Set(sessionNotesOf(receipt.before.focusState ?? undefined).map(noteKey));
|
|
2227
|
+
addedNotes = new Set(sessionNotesOf(value)
|
|
2228
|
+
.map(noteKey)
|
|
2229
|
+
.filter((k) => !before.has(k)));
|
|
2230
|
+
}
|
|
2231
|
+
live.prepare('DELETE FROM main.tasks_schema_meta WHERE key = ?').run(a.archiveKey);
|
|
2232
|
+
archiveDeleted.push(a.archiveKey);
|
|
2233
|
+
}
|
|
2234
|
+
let focusState = 'none';
|
|
2235
|
+
let notesRemoved = 0;
|
|
2236
|
+
if (receipt.after.focusState !== null) {
|
|
2237
|
+
const current = readKv(live, 'tasks_schema_meta', 'focus_state');
|
|
2238
|
+
if (current !== undefined && sha(current) === receipt.after.focusState) {
|
|
2239
|
+
notesRemoved =
|
|
2240
|
+
sessionNotesOf(current).length -
|
|
2241
|
+
sessionNotesOf(receipt.before.focusState ?? undefined).length;
|
|
2242
|
+
if (receipt.before.focusState === null)
|
|
2243
|
+
live.prepare("DELETE FROM main.tasks_schema_meta WHERE key = 'focus_state'").run();
|
|
2244
|
+
else
|
|
2245
|
+
writeKv(live, 'tasks_schema_meta', 'focus_state', receipt.before.focusState);
|
|
2246
|
+
focusState = 'restored';
|
|
2247
|
+
}
|
|
2248
|
+
else if (current !== undefined) {
|
|
2249
|
+
const parsed = FocusNotesShape.safeParse(parseJson(current));
|
|
2250
|
+
if (parsed.success) {
|
|
2251
|
+
const kept = parsed.data.sessionNotes.filter((n) => !addedNotes.has(noteKey(n)));
|
|
2252
|
+
notesRemoved = parsed.data.sessionNotes.length - kept.length;
|
|
2253
|
+
if (notesRemoved > 0)
|
|
2254
|
+
writeKv(live, 'tasks_schema_meta', 'focus_state', JSON.stringify({ ...parsed.data, sessionNotes: kept }));
|
|
2255
|
+
focusState = 'notes-removed';
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
let stickyRemoved = 0;
|
|
2260
|
+
if (receipt.after.stickyArchive !== null) {
|
|
2261
|
+
const current = readKv(live, 'brain_schema_meta', receipt.stickyArchiveKey);
|
|
2262
|
+
if (current !== undefined && sha(current) === receipt.after.stickyArchive) {
|
|
2263
|
+
stickyRemoved = receipt.stickyArchived.length;
|
|
2264
|
+
if (receipt.before.stickyArchive === null)
|
|
2265
|
+
live
|
|
2266
|
+
.prepare('DELETE FROM main.brain_schema_meta WHERE key = ?')
|
|
2267
|
+
.run(receipt.stickyArchiveKey);
|
|
2268
|
+
else
|
|
2269
|
+
writeKv(live, 'brain_schema_meta', receipt.stickyArchiveKey, receipt.before.stickyArchive);
|
|
2270
|
+
}
|
|
2271
|
+
else if (current !== undefined) {
|
|
2272
|
+
const parsed = StickyArchiveShape.safeParse(parseJson(current));
|
|
2273
|
+
if (parsed.success) {
|
|
2274
|
+
const added = new Set(receipt.stickyArchived);
|
|
2275
|
+
const kept = parsed.data.filter((r) => !added.has(r));
|
|
2276
|
+
stickyRemoved = parsed.data.length - kept.length;
|
|
2277
|
+
writeKv(live, 'brain_schema_meta', receipt.stickyArchiveKey, JSON.stringify(kept));
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
}
|
|
2281
|
+
writeKv(live, 'tasks_schema_meta', id, JSON.stringify({ ...receipt, rolledBackAt: new Date().toISOString() }));
|
|
2282
|
+
live.exec('COMMIT');
|
|
2283
|
+
return { id, archiveDeleted, archiveKept, focusState, notesRemoved, stickyRemoved };
|
|
2284
|
+
}
|
|
2285
|
+
catch (error) {
|
|
2286
|
+
if (live.isTransaction)
|
|
2287
|
+
live.exec('ROLLBACK');
|
|
2288
|
+
throw error;
|
|
2289
|
+
}
|
|
2290
|
+
}
|
|
2291
|
+
// ── releasing a pre-collapse snapshot (T12767) ──────────────────────────────
|
|
2292
|
+
/** Error code of a snapshot release that cannot proceed; nothing was written. */
|
|
2293
|
+
export const E_TWIN_COLLAPSE_RELEASE = 'E_TWIN_COLLAPSE_RELEASE';
|
|
2294
|
+
/** Pairs whose loss the recovery (T12727) verifies; a release covers only these. */
|
|
2295
|
+
const RECOVERABLE_PAIRS = [SCHEMA_META, STICKY_TAGS];
|
|
2296
|
+
/** Whether a recorded snapshot path is the one `id` names (backup id, file name or path). */
|
|
2297
|
+
function namesSnapshot(path, id) {
|
|
2298
|
+
const want = basename(id);
|
|
2299
|
+
const have = basename(path);
|
|
2300
|
+
return have === want || have.endsWith(`.${want}`);
|
|
2301
|
+
}
|
|
2302
|
+
/**
|
|
2303
|
+
* Plan releasing a pre-collapse snapshot (T12767): which markers reference
|
|
2304
|
+
* it, how many bytes it holds, and whether it may be released. It may only
|
|
2305
|
+
* after a verified recovery or a no-recovery-needed check: the recovery plan
|
|
2306
|
+
* against it ({@link planTwinCollapseRecovery}) must find nothing left to
|
|
2307
|
+
* recover, and only the `schema_meta` / `sticky_tags` markers (the pairs the
|
|
2308
|
+
* recovery covers) may reference it.
|
|
2309
|
+
*
|
|
2310
|
+
* @param live - The project `cleo.db` connection (read-only is enough).
|
|
2311
|
+
* @param id - The snapshot: its backup id (`migration-<timestamp>`), file name or path.
|
|
2312
|
+
* @param snapshot - The snapshot opened read-only, or `null` when it is not on disk.
|
|
2313
|
+
* @returns The plan.
|
|
2314
|
+
* @task T12767
|
|
2315
|
+
*/
|
|
2316
|
+
export function planSnapshotRelease(live, id, snapshot, options = {}) {
|
|
2317
|
+
let path = null;
|
|
2318
|
+
const markers = [];
|
|
2319
|
+
const blockers = [];
|
|
2320
|
+
for (const pair of PAIRS) {
|
|
2321
|
+
if (!hasMainTable(live, pair.kvTable))
|
|
2322
|
+
continue;
|
|
2323
|
+
const recorded = readState(live, pair)?.snapshot;
|
|
2324
|
+
if (typeof recorded !== 'string' || !namesSnapshot(recorded, id))
|
|
2325
|
+
continue;
|
|
2326
|
+
path ??= recorded;
|
|
2327
|
+
markers.push(`${pair.kvTable}:${TWIN_COLLAPSE_MARKER_PREFIX}${pair.table}`);
|
|
2328
|
+
if (!RECOVERABLE_PAIRS.includes(pair))
|
|
2329
|
+
blockers.push(`the ${pair.table} marker references it, and the recovery does not cover ${pair.table}`);
|
|
2330
|
+
}
|
|
2331
|
+
if (path === null)
|
|
2332
|
+
return {
|
|
2333
|
+
snapshot: null,
|
|
2334
|
+
markers,
|
|
2335
|
+
exists: false,
|
|
2336
|
+
bytes: 0,
|
|
2337
|
+
basis: null,
|
|
2338
|
+
receipts: [],
|
|
2339
|
+
blockers: [`no twin-collapse marker references ${id}`],
|
|
2340
|
+
movedFrom: null,
|
|
2341
|
+
};
|
|
2342
|
+
// A moved store (T12788): the markers name the old path; the file is local.
|
|
2343
|
+
const recorded = path;
|
|
2344
|
+
path = options.snapshotPath ?? recorded;
|
|
2345
|
+
const exists = existsSync(path);
|
|
2346
|
+
const bytes = [path, `${path}-wal`, `${path}-shm`].reduce((sum, f) => sum + (existsSync(f) ? statSync(f).size : 0), 0);
|
|
2347
|
+
const receipts = [...recoveryReceipts(live)]
|
|
2348
|
+
.filter(([, r]) => r.rolledBackAt === null && (r.snapshot === path || r.snapshot === recorded))
|
|
2349
|
+
.map(([k]) => k)
|
|
2350
|
+
.sort();
|
|
2351
|
+
if (!exists || snapshot === null)
|
|
2352
|
+
blockers.push('the snapshot is not on disk, so no recovery check can run against it');
|
|
2353
|
+
else {
|
|
2354
|
+
const recovery = planTwinCollapseRecovery(live, snapshot, { snapshotPath: path });
|
|
2355
|
+
if (recovery.changes)
|
|
2356
|
+
blockers.push(`it still holds ${recovery.archive.length} value(s), ${recovery.focusNotesAdded} session note(s) and ` +
|
|
2357
|
+
`${recovery.stickyArchived.length} sticky tag(s) the live store lacks: run --recover first`);
|
|
2358
|
+
}
|
|
2359
|
+
return {
|
|
2360
|
+
snapshot: path,
|
|
2361
|
+
markers,
|
|
2362
|
+
exists,
|
|
2363
|
+
bytes,
|
|
2364
|
+
basis: blockers.length > 0 ? null : receipts.length > 0 ? 'recovered' : 'no-recovery-needed',
|
|
2365
|
+
receipts,
|
|
2366
|
+
blockers,
|
|
2367
|
+
movedFrom: recorded !== path ? recorded : null,
|
|
2368
|
+
};
|
|
2369
|
+
}
|
|
2370
|
+
/** The release refusal for a plan with blockers. */
|
|
2371
|
+
export function releaseRefused(id, plan) {
|
|
2372
|
+
return new CleoError(ExitCode.VALIDATION_ERROR, `${E_TWIN_COLLAPSE_RELEASE}: ${id} cannot be released: ${plan.blockers.join('; ')}; nothing was changed`, {
|
|
2373
|
+
fix: `cleo doctor twin-collapse --recover --dry-run # then --recover, and --release-snapshot ${id} --dry-run`,
|
|
2374
|
+
details: { field: 'snapshot', actual: plan.snapshot, blockers: plan.blockers },
|
|
2375
|
+
});
|
|
2376
|
+
}
|
|
2377
|
+
/**
|
|
2378
|
+
* Release a pre-collapse snapshot, in one `BEGIN IMMEDIATE` transaction: the
|
|
2379
|
+
* plan is re-checked under the write lock, each referencing marker's
|
|
2380
|
+
* `snapshot` is cleared (so an open no longer pins it and rotation no longer
|
|
2381
|
+
* keeps it), its sidecar pin is released, and `audit` records the release
|
|
2382
|
+
* before the commit (a failed audit write refuses it). The snapshot file is
|
|
2383
|
+
* never touched; it rotates normally from then on.
|
|
2384
|
+
*
|
|
2385
|
+
* @param live - The project `cleo.db` connection, outside a transaction.
|
|
2386
|
+
* @param id - The snapshot: backup id, file name or path.
|
|
2387
|
+
* @param snapshot - The snapshot opened read-only, or `null`.
|
|
2388
|
+
* @param audit - Appends the audit row; throws to refuse.
|
|
2389
|
+
* @returns What was released.
|
|
2390
|
+
* @throws {CleoError} {@link E_TWIN_COLLAPSE_RELEASE} when it may not be released.
|
|
2391
|
+
* @task T12767
|
|
2392
|
+
*/
|
|
2393
|
+
export function releaseTwinCollapseSnapshot(live, id, snapshot, audit, options = {}) {
|
|
2394
|
+
live.exec('BEGIN IMMEDIATE');
|
|
2395
|
+
let sidecarBefore;
|
|
2396
|
+
let path = null;
|
|
2397
|
+
try {
|
|
2398
|
+
const plan = planSnapshotRelease(live, id, snapshot, options);
|
|
2399
|
+
if (plan.basis === null || plan.snapshot === null)
|
|
2400
|
+
throw releaseRefused(id, plan);
|
|
2401
|
+
path = plan.snapshot;
|
|
2402
|
+
for (const pair of PAIRS) {
|
|
2403
|
+
const key = `${TWIN_COLLAPSE_MARKER_PREFIX}${pair.table}`;
|
|
2404
|
+
if (!plan.markers.includes(`${pair.kvTable}:${key}`))
|
|
2405
|
+
continue;
|
|
2406
|
+
const raw = readKv(live, pair.kvTable, key);
|
|
2407
|
+
const parsed = raw === undefined ? undefined : parseJson(raw);
|
|
2408
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
2409
|
+
throw releaseRefused(id, { ...plan, blockers: [`the ${pair.table} marker is unreadable`] });
|
|
2410
|
+
writeKv(live, pair.kvTable, key, JSON.stringify({ ...parsed, snapshot: null }));
|
|
2411
|
+
}
|
|
2412
|
+
sidecarBefore = readBackupSidecar(path, 'migration');
|
|
2413
|
+
const sidecar = unpinBackup(path, 'migration', `T12767: released by an owner after a ${plan.basis} check`);
|
|
2414
|
+
const release = {
|
|
2415
|
+
snapshot: path,
|
|
2416
|
+
markers: plan.markers,
|
|
2417
|
+
basis: plan.basis,
|
|
2418
|
+
bytes: plan.bytes,
|
|
2419
|
+
releasedAt: sidecar.releasedAt ?? new Date().toISOString(),
|
|
2420
|
+
movedFrom: plan.movedFrom,
|
|
2421
|
+
};
|
|
2422
|
+
audit({ ...release, receipts: plan.receipts });
|
|
2423
|
+
live.exec('COMMIT');
|
|
2424
|
+
return release;
|
|
2425
|
+
}
|
|
2426
|
+
catch (error) {
|
|
2427
|
+
if (live.isTransaction)
|
|
2428
|
+
live.exec('ROLLBACK');
|
|
2429
|
+
if (sidecarBefore !== undefined && path !== null)
|
|
2430
|
+
restoreBackupSidecar(path, 'migration', sidecarBefore);
|
|
2431
|
+
throw error;
|
|
2432
|
+
}
|
|
2433
|
+
}
|
|
931
2434
|
//# sourceMappingURL=twin-collapse.js.map
|