@cleocode/core 2026.9.24 → 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/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/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/decide/bench/index.d.ts +1 -1
- package/dist/decide/bench/index.d.ts.map +1 -1
- package/dist/decide/bench/index.js +1 -1
- package/dist/decide/bench/index.js.map +1 -1
- package/dist/decide/bench/operation.d.ts +1 -1
- package/dist/decide/bench/operation.d.ts.map +1 -1
- package/dist/decide/bench/operation.js +1 -1
- package/dist/decide/bench/operation.js.map +1 -1
- package/dist/decide/bench/profiles.d.ts +50 -22
- package/dist/decide/bench/profiles.d.ts.map +1 -1
- package/dist/decide/bench/profiles.js +103 -59
- package/dist/decide/bench/profiles.js.map +1 -1
- package/dist/decide/bench/report.d.ts.map +1 -1
- package/dist/decide/bench/report.js +6 -1
- package/dist/decide/bench/report.js.map +1 -1
- package/dist/decide/bench/runner.d.ts +6 -1
- package/dist/decide/bench/runner.d.ts.map +1 -1
- package/dist/decide/bench/runner.js +1 -0
- package/dist/decide/bench/runner.js.map +1 -1
- package/dist/decide/client.d.ts.map +1 -1
- package/dist/decide/client.js +11 -5
- package/dist/decide/client.js.map +1 -1
- package/dist/decide/jev-wire.d.ts +31 -1
- package/dist/decide/jev-wire.d.ts.map +1 -1
- package/dist/decide/jev-wire.js +61 -7
- package/dist/decide/jev-wire.js.map +1 -1
- package/dist/docs/export-document.js +17181 -15536
- package/dist/docs/export-document.js.map +3 -3
- 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/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 +58 -2
- package/dist/doctor/twin-collapse.d.ts.map +1 -1
- package/dist/doctor/twin-collapse.js +212 -14
- package/dist/doctor/twin-collapse.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/plugin-facade.js +17045 -15400
- package/dist/llm/plugin-facade.js.map +3 -3
- 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/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/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/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/git-state.d.ts.map +1 -1
- package/dist/nexus/git-state.js +10 -1
- package/dist/nexus/git-state.js.map +1 -1
- 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.map +1 -1
- package/dist/nexus/query.js +4 -12
- package/dist/nexus/query.js.map +1 -1
- 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 +18 -0
- package/dist/nexus/registry.d.ts.map +1 -1
- package/dist/nexus/registry.js +37 -16
- package/dist/nexus/registry.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/spawn-prompt.d.ts +26 -0
- package/dist/orchestration/spawn-prompt.d.ts.map +1 -1
- package/dist/orchestration/spawn-prompt.js +62 -91
- package/dist/orchestration/spawn-prompt.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/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 +17049 -15404
- package/dist/selfimprove/fix-gen.js.map +3 -3
- package/dist/sentient/cross-project-hygiene.d.ts +5 -0
- package/dist/sentient/cross-project-hygiene.d.ts.map +1 -1
- package/dist/sentient/cross-project-hygiene.js +7 -1
- 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/skills/manifests/research.js +2 -0
- package/dist/skills/manifests/research.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/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/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/backup-sidecar.d.ts +85 -2
- package/dist/store/backup-sidecar.d.ts.map +1 -1
- package/dist/store/backup-sidecar.js +173 -1
- package/dist/store/backup-sidecar.js.map +1 -1
- 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/credential-transfer.d.ts.map +1 -1
- package/dist/store/credential-transfer.js +3 -0
- package/dist/store/credential-transfer.js.map +1 -1
- package/dist/store/db-helpers.d.ts +17 -11
- package/dist/store/db-helpers.d.ts.map +1 -1
- package/dist/store/db-helpers.js +61 -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/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/legacy-tasks-lineage.d.ts.map +1 -1
- package/dist/store/legacy-tasks-lineage.js +19 -2
- package/dist/store/legacy-tasks-lineage.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 +2 -0
- package/dist/store/safety-data-accessor.d.ts.map +1 -1
- package/dist/store/safety-data-accessor.js +4 -0
- 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 +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 +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 +183 -7
- package/dist/store/schema/cleo-project/tasks-core.d.ts.map +1 -1
- package/dist/store/schema/cleo-project/tasks-core.js +52 -1
- package/dist/store/schema/cleo-project/tasks-core.js.map +1 -1
- package/dist/store/schema/cleo-shared/brain.d.ts +6 -6
- 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 +5 -5
- package/dist/store/schema/nexus-schema.d.ts +3 -3
- 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/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 +544 -44
- package/dist/store/sqlite-data-accessor.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 +10 -8
- package/dist/store/table-classification.d.ts.map +1 -1
- package/dist/store/table-classification.js +93 -25
- package/dist/store/table-classification.js.map +1 -1
- package/dist/store/tasks-sqlite.d.ts.map +1 -1
- package/dist/store/tasks-sqlite.js +9 -1
- package/dist/store/tasks-sqlite.js.map +1 -1
- package/dist/store/twin-collapse.d.ts +113 -2
- package/dist/store/twin-collapse.d.ts.map +1 -1
- package/dist/store/twin-collapse.js +247 -19
- package/dist/store/twin-collapse.js.map +1 -1
- package/dist/store/umbrella-data-accessor.d.ts +2 -0
- package/dist/store/umbrella-data-accessor.d.ts.map +1 -1
- package/dist/store/umbrella-data-accessor.js +4 -0
- package/dist/store/umbrella-data-accessor.js.map +1 -1
- package/dist/store/validation-schemas.d.ts +241 -49
- package/dist/store/validation-schemas.d.ts.map +1 -1
- package/dist/system/health.d.ts.map +1 -1
- package/dist/system/health.js +3 -0
- package/dist/system/health.js.map +1 -1
- package/dist/system/project-health.d.ts +9 -2
- package/dist/system/project-health.d.ts.map +1 -1
- package/dist/system/project-health.js +8 -3
- package/dist/system/project-health.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 +48 -2
- package/dist/tasks/add.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/complete.d.ts.map +1 -1
- package/dist/tasks/complete.js +6 -1
- 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/evidence.d.ts.map +1 -1
- package/dist/tasks/evidence.js +14 -3
- package/dist/tasks/evidence.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/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/migrations/README.md +12 -0
- package/migrations/drizzle-cleo-project/20260928120000_t12341-row-uids/migration.sql +169 -0
- package/migrations/sync-journal/20260929140000_t12342-sync-clock/migration.sql +42 -0
- package/package.json +13 -13
- package/templates/CLEO-REFERENCE.md +4 -0
|
@@ -0,0 +1,1240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Row uids (T12341): the merge key every syncing row carries.
|
|
3
|
+
*
|
|
4
|
+
* A table whose Gate A class syncs gets a `uid TEXT` column. The local primary
|
|
5
|
+
* key (`T####`, an AC id, a session id, a composite edge key) stays the local
|
|
6
|
+
* key, and local foreign keys keep pointing at it; the uid is what a merge
|
|
7
|
+
* compares, as (project, uid). Spec: `cleo docs fetch t12341-uid-scheme`.
|
|
8
|
+
*
|
|
9
|
+
* ## How a uid is made
|
|
10
|
+
*
|
|
11
|
+
* - **Minted, new row** (this build writes it): a random UUIDv7
|
|
12
|
+
* ({@link mintRowUid}), set by the Drizzle `$defaultFn` or by the writer.
|
|
13
|
+
* - **Minted, no uid yet** (an existing row, or one an older build or a raw-SQL
|
|
14
|
+
* writer inserted): a DETERMINISTIC v7-layout uid. Its timestamp is the
|
|
15
|
+
* row's birth; its other 74 bits are SHA-256 over (scope, table, key, birth,
|
|
16
|
+
* the uids of its owning rows, and, for append-only tables, a frozen content
|
|
17
|
+
* list) ({@link mintedRowUid}). A `randomOnly` table (portable-secret) gets a
|
|
18
|
+
* random uid instead.
|
|
19
|
+
* - **Natural** (edges, labels): a UUIDv8 over the key with every reference
|
|
20
|
+
* replaced by the referenced row's uid ({@link naturalRowUid}); a pure
|
|
21
|
+
* function of the row. Symmetric relation types hash their endpoints in
|
|
22
|
+
* sorted order; the reversed twin of a symmetric edge stored both ways takes
|
|
23
|
+
* the `natural-mirror` recipe. A reference whose row does not exist hashes
|
|
24
|
+
* `dangling:<raw key>` and is reported.
|
|
25
|
+
*
|
|
26
|
+
* Deterministic identity can coincide for different rows (the same `T100`
|
|
27
|
+
* with the same `created_at` in two stores). Every minted row therefore also
|
|
28
|
+
* carries a birth fingerprint ({@link birthFingerprint}, `birth_fp`), hashed
|
|
29
|
+
* once from creation facts no edit changes. Same uid and same `birth_fp` is
|
|
30
|
+
* the same row; same uid and a different `birth_fp` is a COLLISION
|
|
31
|
+
* ({@link classifyUidMatch}), which the merge never merges.
|
|
32
|
+
*
|
|
33
|
+
* ## When uids are filled
|
|
34
|
+
*
|
|
35
|
+
* {@link prepareRowIdentity} runs on every open of a `cleo.db`, inside the
|
|
36
|
+
* cold-open lease right after the migrations:
|
|
37
|
+
*
|
|
38
|
+
* 1. re-asserts the identity columns and indexes (idempotent; heals a
|
|
39
|
+
* migration the journal probe stamped without running its index DDL);
|
|
40
|
+
* 2. registers the deterministic SQL functions on the connection, re-links
|
|
41
|
+
* acceptance criteria an older build recreated without their uid (the AC
|
|
42
|
+
* uid graveyard, spec §6.5), then fills every NULL uid (minted tables,
|
|
43
|
+
* owners first; then natural tables), the stored reference facts (`ac_uid`,
|
|
44
|
+
* `ac_text_hash`) and the birth fingerprints, in one savepoint;
|
|
45
|
+
* 3. installs one TEMP `AFTER INSERT` trigger per declared table on this
|
|
46
|
+
* connection, so a row inserted here without them gets the same values the
|
|
47
|
+
* open pass would give it, in the same statement. A trigger never fails the
|
|
48
|
+
* insert: a value that would clash is left NULL and reported at the next
|
|
49
|
+
* open.
|
|
50
|
+
*
|
|
51
|
+
* A row an older build inserts keeps NULL values until the next open by this
|
|
52
|
+
* build: there is no persistent trigger that computes them (see the
|
|
53
|
+
* migration's header). Row uids are OPT-IN: nothing here runs unless
|
|
54
|
+
* `CLEO_ROW_UID_FILL=1` ({@link rowUidFillEnabled}).
|
|
55
|
+
*
|
|
56
|
+
* A failure never fails the open: it is logged, and the rows keep NULL values
|
|
57
|
+
* until a later open succeeds. A uid's timestamp is ordering sugar only: HLC
|
|
58
|
+
* (T12342) never reads time from a uid.
|
|
59
|
+
*
|
|
60
|
+
* @module
|
|
61
|
+
* @task T12341
|
|
62
|
+
* @epic T12323
|
|
63
|
+
*/
|
|
64
|
+
import { createHash } from 'node:crypto';
|
|
65
|
+
import { uuidv7 } from '../cloud/uuidv7.js';
|
|
66
|
+
import { getLogger } from '../logger.js';
|
|
67
|
+
import { acTextHash } from '../tasks/ac-table.js';
|
|
68
|
+
import { rowUidFillEnabled } from './row-identity-flag.js';
|
|
69
|
+
import { BIRTH_FP_COLUMN, ROW_IDENTITY, rowIdentityColumns, rowIdentitySpec, UID_COLUMN, } from './row-identity-registry.js';
|
|
70
|
+
export { BIRTH_FP_COLUMN, ROW_IDENTITY, rowIdentityColumns, rowIdentitySpec, UID_COLUMN };
|
|
71
|
+
/** Domain separator hashed into every deterministic uid. Changing it re-keys every row. */
|
|
72
|
+
export const ROW_UID_DOMAIN = 'cleo/row-uid/v1';
|
|
73
|
+
/** Domain separator hashed into every birth fingerprint. */
|
|
74
|
+
export const ROW_BIRTH_DOMAIN = 'cleo/row-birth/v1';
|
|
75
|
+
/** Per-connection SQL function computing a deterministic uid. */
|
|
76
|
+
export const ROW_UID_SQL_FUNCTION = 'cleo_row_uid';
|
|
77
|
+
/** Per-connection SQL function computing a birth fingerprint. */
|
|
78
|
+
export const ROW_BIRTH_FP_SQL_FUNCTION = 'cleo_row_birth_fp';
|
|
79
|
+
/** Per-connection SQL function returning a fresh random UUIDv7 (`randomOnly` tables). */
|
|
80
|
+
export const ROW_RANDOM_UID_SQL_FUNCTION = 'cleo_row_random_uid';
|
|
81
|
+
/** Per-connection SQL function hashing AC text like `acTextHash`. */
|
|
82
|
+
export const AC_TEXT_HASH_SQL_FUNCTION = 'cleo_ac_text_hash';
|
|
83
|
+
/** Physical name of the AC uid graveyard (spec §6.5). */
|
|
84
|
+
export const AC_UID_GRAVEYARD = 'tasks_ac_uid_graveyard';
|
|
85
|
+
let registeredWriters;
|
|
86
|
+
/**
|
|
87
|
+
* Register the chokepoint's row-identity writers (called once by
|
|
88
|
+
* `store/sqlite-data-accessor.ts` at load).
|
|
89
|
+
*
|
|
90
|
+
* @param writers - The writers.
|
|
91
|
+
*/
|
|
92
|
+
export function registerRowIdentityWriters(writers) {
|
|
93
|
+
registeredWriters = writers;
|
|
94
|
+
}
|
|
95
|
+
/** The registered writers, or an error naming the module that registers them. */
|
|
96
|
+
function requireWriters() {
|
|
97
|
+
if (!registeredWriters) {
|
|
98
|
+
throw new Error('row identity writers are not registered: load store/sqlite-data-accessor.js');
|
|
99
|
+
}
|
|
100
|
+
return registeredWriters;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Unambiguous encoding of uid inputs: per value a type tag (0 null, 1 text,
|
|
104
|
+
* 2 integer, 3 real, 4 blob), a big-endian u32 byte length, and the bytes. An
|
|
105
|
+
* integer-valued number is tag 2 (SQLite `3` and `3.0` encode alike); any other
|
|
106
|
+
* number is tag 3 with its ECMAScript shortest round-trip text. Frozen for
|
|
107
|
+
* recipe v1.
|
|
108
|
+
*
|
|
109
|
+
* @param values - Inputs in hash order.
|
|
110
|
+
* @returns The encoded bytes.
|
|
111
|
+
*/
|
|
112
|
+
export function encodeUidInputs(values) {
|
|
113
|
+
const parts = [];
|
|
114
|
+
for (const value of values) {
|
|
115
|
+
let tag;
|
|
116
|
+
let bytes;
|
|
117
|
+
if (value === null) {
|
|
118
|
+
tag = 0;
|
|
119
|
+
bytes = Buffer.alloc(0);
|
|
120
|
+
}
|
|
121
|
+
else if (typeof value === 'string') {
|
|
122
|
+
tag = 1;
|
|
123
|
+
bytes = Buffer.from(value, 'utf8');
|
|
124
|
+
}
|
|
125
|
+
else if (typeof value === 'bigint' || Number.isInteger(value)) {
|
|
126
|
+
tag = 2;
|
|
127
|
+
bytes = Buffer.from(String(value), 'utf8');
|
|
128
|
+
}
|
|
129
|
+
else if (typeof value === 'number') {
|
|
130
|
+
tag = 3;
|
|
131
|
+
bytes = Buffer.from(value.toString(), 'utf8');
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
tag = 4;
|
|
135
|
+
bytes = Buffer.from(value);
|
|
136
|
+
}
|
|
137
|
+
const head = Buffer.alloc(5);
|
|
138
|
+
head.writeUInt8(tag, 0);
|
|
139
|
+
head.writeUInt32BE(bytes.length, 1);
|
|
140
|
+
parts.push(head, bytes);
|
|
141
|
+
}
|
|
142
|
+
return Buffer.concat(parts);
|
|
143
|
+
}
|
|
144
|
+
/** Strict ISO-8601 / SQLite timestamp: date, optional time, optional zone. */
|
|
145
|
+
const STORE_TIMESTAMP = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,9}))?)?)?(Z|[+-]\d{2}:?\d{2})?$/;
|
|
146
|
+
/**
|
|
147
|
+
* Epoch milliseconds of a stored timestamp, or `null` when absent or not a
|
|
148
|
+
* strict ISO-8601 / SQLite timestamp. A value WITHOUT a zone is UTC:
|
|
149
|
+
* `datetime('now')` writes `YYYY-MM-DD HH:MM:SS` meaning UTC, and a zoneless
|
|
150
|
+
* `YYYY-MM-DDTHH:MM:SS` is read the same way, never as the device's local
|
|
151
|
+
* time (`Date.parse` would, and a uid would then depend on the device's
|
|
152
|
+
* timezone). Anything else (`Date.parse` extensions such as `Sep 24 2026`,
|
|
153
|
+
* out-of-range fields) is `null`, i.e. an unparseable birth. Comparing that
|
|
154
|
+
* text against ISO text is wrong within a day (`' '` sorts before `'T'`), so
|
|
155
|
+
* every comparison and every uid birth goes through this function (T12329).
|
|
156
|
+
*
|
|
157
|
+
* @param value - Stored timestamp.
|
|
158
|
+
* @returns Epoch milliseconds, or `null`.
|
|
159
|
+
*/
|
|
160
|
+
export function parseStoreTimestamp(value) {
|
|
161
|
+
if (typeof value !== 'string')
|
|
162
|
+
return null;
|
|
163
|
+
const m = STORE_TIMESTAMP.exec(value);
|
|
164
|
+
if (!m)
|
|
165
|
+
return null;
|
|
166
|
+
const [, y, mo, d, h = '0', mi = '0', sec = '0', frac = '', zone] = m;
|
|
167
|
+
const year = Number(y);
|
|
168
|
+
const month = Number(mo);
|
|
169
|
+
const day = Number(d);
|
|
170
|
+
const hour = Number(h);
|
|
171
|
+
const minute = Number(mi);
|
|
172
|
+
const second = Number(sec);
|
|
173
|
+
if (month < 1 || month > 12 || day < 1 || hour > 23 || minute > 59 || second > 59)
|
|
174
|
+
return null;
|
|
175
|
+
const ms = Number(frac.padEnd(3, '0').slice(0, 3) || '0');
|
|
176
|
+
const utc = Date.UTC(year, month - 1, day, hour, minute, second, ms);
|
|
177
|
+
const check = new Date(utc);
|
|
178
|
+
if (check.getUTCMonth() !== month - 1 || check.getUTCDate() !== day)
|
|
179
|
+
return null;
|
|
180
|
+
if (!zone || zone === 'Z')
|
|
181
|
+
return utc;
|
|
182
|
+
const sign = zone.startsWith('-') ? -1 : 1;
|
|
183
|
+
const digits = zone.slice(1).replace(':', '');
|
|
184
|
+
const offset = (Number(digits.slice(0, 2)) * 60 + Number(digits.slice(2, 4))) * 60_000;
|
|
185
|
+
if (Number(digits.slice(0, 2)) > 23 || Number(digits.slice(2, 4)) > 59)
|
|
186
|
+
return null;
|
|
187
|
+
return utc - sign * offset;
|
|
188
|
+
}
|
|
189
|
+
/** Largest value a UUIDv7 timestamp field holds. */
|
|
190
|
+
const MAX_UUID_MS = 2 ** 48 - 1;
|
|
191
|
+
/** Format 16 bytes as a lowercase canonical UUID string. */
|
|
192
|
+
function formatUuid(bytes) {
|
|
193
|
+
const h = bytes.toString('hex');
|
|
194
|
+
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* A new uid for a row this build creates: a random UUIDv7.
|
|
198
|
+
*
|
|
199
|
+
* @param birthMs - The row's own birth, for a row imported with one (an
|
|
200
|
+
* importer must never stamp import time into an old row's uid).
|
|
201
|
+
* @returns Canonical lowercase UUID string.
|
|
202
|
+
*/
|
|
203
|
+
export function mintRowUid(birthMs) {
|
|
204
|
+
return birthMs === undefined ? uuidv7() : uuidv7(birthMs);
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* The deterministic uid of a minted row that has none: a v7 layout whose
|
|
208
|
+
* timestamp is the row's birth and whose other 74 bits come from SHA-256 over
|
|
209
|
+
* the row's identity. An unknown birth gives timestamp 0; the row's birth
|
|
210
|
+
* fingerprint flags it (`birth:unknown`), and {@link rowIdentityFindings}
|
|
211
|
+
* lists it.
|
|
212
|
+
*
|
|
213
|
+
* @param scope - Store scope.
|
|
214
|
+
* @param table - Physical table name.
|
|
215
|
+
* @param key - Local key values.
|
|
216
|
+
* @param birth - Stored birth value (any format, or null).
|
|
217
|
+
* @param ownerUids - Uids of the owning rows.
|
|
218
|
+
* @param content - Frozen content values of an append-only row.
|
|
219
|
+
* @returns Canonical lowercase UUID string.
|
|
220
|
+
*/
|
|
221
|
+
export function mintedRowUid(scope, table, key, birth, ownerUids = [], content = []) {
|
|
222
|
+
const birthMs = parseStoreTimestamp(birth);
|
|
223
|
+
const canonicalBirth = birthMs ?? birth;
|
|
224
|
+
const h = createHash('sha256')
|
|
225
|
+
.update(encodeUidInputs([
|
|
226
|
+
ROW_UID_DOMAIN,
|
|
227
|
+
'minted',
|
|
228
|
+
scope,
|
|
229
|
+
table,
|
|
230
|
+
...key,
|
|
231
|
+
canonicalBirth,
|
|
232
|
+
...ownerUids,
|
|
233
|
+
...content,
|
|
234
|
+
]))
|
|
235
|
+
.digest();
|
|
236
|
+
const ms = birthMs !== null && birthMs >= 0 && birthMs <= MAX_UUID_MS ? birthMs : 0;
|
|
237
|
+
const b = Buffer.alloc(16);
|
|
238
|
+
b.writeUIntBE(ms, 0, 6);
|
|
239
|
+
b[6] = 0x70 | ((h[0] ?? 0) & 0x0f);
|
|
240
|
+
b[7] = h[1] ?? 0;
|
|
241
|
+
b[8] = 0x80 | ((h[2] ?? 0) & 0x3f);
|
|
242
|
+
h.copy(b, 9, 3, 10);
|
|
243
|
+
return formatUuid(b);
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* The new uid of a minted child row when its owner is re-keyed (spec §6.4):
|
|
247
|
+
* derived from STORED identity only (the child's old uid and the owner's new
|
|
248
|
+
* uid), never from a local key or content, so every replica that computes it
|
|
249
|
+
* gets the same value whatever display ids it holds. The child's timestamp
|
|
250
|
+
* bits are kept; the other 74 bits come from SHA-256.
|
|
251
|
+
*
|
|
252
|
+
* @param scope - Store scope.
|
|
253
|
+
* @param table - Physical table of the child.
|
|
254
|
+
* @param oldChildUid - The child's uid before the re-key.
|
|
255
|
+
* @param newOwnerUid - The owner's new uid.
|
|
256
|
+
* @returns Canonical lowercase UUID string.
|
|
257
|
+
*/
|
|
258
|
+
export function rekeyedChildUid(scope, table, oldChildUid, newOwnerUid) {
|
|
259
|
+
const h = createHash('sha256')
|
|
260
|
+
.update(encodeUidInputs([ROW_UID_DOMAIN, 'rekey-child', scope, table, oldChildUid, newOwnerUid]))
|
|
261
|
+
.digest();
|
|
262
|
+
const b = Buffer.alloc(16);
|
|
263
|
+
Buffer.from(oldChildUid.replaceAll('-', '').slice(0, 12), 'hex').copy(b, 0);
|
|
264
|
+
b[6] = 0x70 | ((h[0] ?? 0) & 0x0f);
|
|
265
|
+
b[7] = h[1] ?? 0;
|
|
266
|
+
b[8] = 0x80 | ((h[2] ?? 0) & 0x3f);
|
|
267
|
+
h.copy(b, 9, 3, 10);
|
|
268
|
+
return formatUuid(b);
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* The uid of a natural row: a UUIDv8 over (scope, table, key), with every
|
|
272
|
+
* reference in the key already replaced by the referenced row's uid.
|
|
273
|
+
*
|
|
274
|
+
* @param scope - Store scope.
|
|
275
|
+
* @param table - Physical table name.
|
|
276
|
+
* @param parts - Key values, references as uids.
|
|
277
|
+
* @param recipe - `natural-mirror` for the reversed twin of a symmetric edge.
|
|
278
|
+
* @returns Canonical lowercase UUID string.
|
|
279
|
+
*/
|
|
280
|
+
export function naturalRowUid(scope, table, parts, recipe = 'natural') {
|
|
281
|
+
const h = createHash('sha256')
|
|
282
|
+
.update(encodeUidInputs([ROW_UID_DOMAIN, recipe, scope, table, ...parts]))
|
|
283
|
+
.digest();
|
|
284
|
+
const b = Buffer.from(h.subarray(0, 16));
|
|
285
|
+
b[6] = 0x80 | ((b[6] ?? 0) & 0x0f);
|
|
286
|
+
b[8] = 0x80 | ((b[8] ?? 0) & 0x3f);
|
|
287
|
+
return formatUuid(b);
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* The birth-token part of a birth fingerprint: the stored birth at full
|
|
291
|
+
* precision, or a flag when it is missing or unparseable.
|
|
292
|
+
*/
|
|
293
|
+
function birthToken(birth) {
|
|
294
|
+
if (birth === null)
|
|
295
|
+
return 'birth:unknown';
|
|
296
|
+
const ms = parseStoreTimestamp(birth);
|
|
297
|
+
if (ms !== null)
|
|
298
|
+
return `ms:${ms}`;
|
|
299
|
+
return `birth:unparseable:${canonicalText(String(birth))}`;
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Canonical form of a text input to a birth fingerprint: Unicode NFC, line
|
|
303
|
+
* endings as LF, surrounding whitespace trimmed. Two stores that hold the same
|
|
304
|
+
* text in different normal forms fingerprint it identically.
|
|
305
|
+
*
|
|
306
|
+
* @param text - Stored text.
|
|
307
|
+
* @returns The canonical text.
|
|
308
|
+
*/
|
|
309
|
+
export function canonicalText(text) {
|
|
310
|
+
return text.normalize('NFC').replace(/\r\n?/g, '\n').trim();
|
|
311
|
+
}
|
|
312
|
+
/** Canonicalise one birth-fact value (text as {@link canonicalText}; others as stored). */
|
|
313
|
+
function canonicalFact(value) {
|
|
314
|
+
return typeof value === 'string' ? canonicalText(value) : value;
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* A minted row's birth fingerprint: 128 bits of SHA-256 over the canonical
|
|
318
|
+
* birth (epoch ms when it parses, so a format-only difference fingerprints
|
|
319
|
+
* alike; a flagged token otherwise) and the table's frozen birth facts, each
|
|
320
|
+
* canonicalised ({@link canonicalText}). Computed ONCE, when the row first
|
|
321
|
+
* gets its identity, from the row as it is then; never recomputed. It is
|
|
322
|
+
* write-once and syncs with the row: a receiver stores the value it receives.
|
|
323
|
+
*
|
|
324
|
+
* @param table - Physical table name.
|
|
325
|
+
* @param birth - Stored birth value.
|
|
326
|
+
* @param facts - Birth fact values, in declared order.
|
|
327
|
+
* @returns 32 lowercase hex characters.
|
|
328
|
+
*/
|
|
329
|
+
export function birthFingerprint(table, birth, facts) {
|
|
330
|
+
return createHash('sha256')
|
|
331
|
+
.update(encodeUidInputs([ROW_BIRTH_DOMAIN, table, birthToken(birth), ...facts.map(canonicalFact)]))
|
|
332
|
+
.digest('hex')
|
|
333
|
+
.slice(0, 32);
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Classify two rows of one table that carry the same uid (merge contract,
|
|
337
|
+
* spec §6.4): equal birth fingerprints are one row; different ones are a
|
|
338
|
+
* COLLISION that must never be merged; a missing fingerprint must be filled
|
|
339
|
+
* before the row is emitted.
|
|
340
|
+
*
|
|
341
|
+
* @param a - Birth fingerprint of one row.
|
|
342
|
+
* @param b - Birth fingerprint of the other.
|
|
343
|
+
* @returns The relation.
|
|
344
|
+
*/
|
|
345
|
+
export function classifyUidMatch(a, b) {
|
|
346
|
+
if (a === null || b === null)
|
|
347
|
+
return 'unknown';
|
|
348
|
+
return a === b ? 'same-row' : 'collision';
|
|
349
|
+
}
|
|
350
|
+
/** Quote an identifier for SQL. */
|
|
351
|
+
function q(name) {
|
|
352
|
+
return `"${name.replaceAll('"', '""')}"`;
|
|
353
|
+
}
|
|
354
|
+
/** The local key column of a referenced table (its declared single-column key). */
|
|
355
|
+
function targetKey(scope, table) {
|
|
356
|
+
const target = rowIdentitySpec(scope, table);
|
|
357
|
+
if (!target || target.key.length !== 1) {
|
|
358
|
+
throw new Error(`row identity: ${table} is not a declared single-key table`);
|
|
359
|
+
}
|
|
360
|
+
return target.key[0];
|
|
361
|
+
}
|
|
362
|
+
/** SQL for the uid of the row `ref` points at, read from `row`. */
|
|
363
|
+
function refUidSql(scope, ref, row) {
|
|
364
|
+
return (`(SELECT _r.${q(UID_COLUMN)} FROM main.${q(ref.table)} AS _r ` +
|
|
365
|
+
`WHERE _r.${q(targetKey(scope, ref.table))} = ${row}.${q(ref.column)})`);
|
|
366
|
+
}
|
|
367
|
+
/** SQL for "the row `ref` points at exists". */
|
|
368
|
+
function refExistsSql(scope, ref, row) {
|
|
369
|
+
return (`EXISTS(SELECT 1 FROM main.${q(ref.table)} AS _e ` +
|
|
370
|
+
`WHERE _e.${q(targetKey(scope, ref.table))} = ${row}.${q(ref.column)})`);
|
|
371
|
+
}
|
|
372
|
+
/** A reference passed to the uid function as (uid, raw key, target exists). */
|
|
373
|
+
function refArgs(scope, ref, row) {
|
|
374
|
+
return [refUidSql(scope, ref, row), `${row}.${q(ref.column)}`, refExistsSql(scope, ref, row)];
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Resolve one reference argument triple: the target's uid; `dangling:<key>`
|
|
378
|
+
* when the target does not exist; `undefined` when it exists but has no uid
|
|
379
|
+
* yet (fill later).
|
|
380
|
+
*/
|
|
381
|
+
function resolveRef(uid, raw, exists) {
|
|
382
|
+
if (uid !== null)
|
|
383
|
+
return uid;
|
|
384
|
+
if (raw === null)
|
|
385
|
+
return null;
|
|
386
|
+
if (Number(exists) === 1)
|
|
387
|
+
return undefined;
|
|
388
|
+
return `dangling:${String(raw)}`;
|
|
389
|
+
}
|
|
390
|
+
/** Whether a table's symmetric-type twin check applies (two key refs). */
|
|
391
|
+
function symmetricTwinSql(spec, row) {
|
|
392
|
+
const [a, b] = spec.keyRefs ?? [];
|
|
393
|
+
const sym = spec.symmetric;
|
|
394
|
+
if (!a || !b || !sym)
|
|
395
|
+
throw new Error(`row identity: ${spec.table} symmetric needs two key refs`);
|
|
396
|
+
return (`EXISTS(SELECT 1 FROM main.${q(spec.table)} AS _t WHERE _t.${q(a.column)} = ${row}.${q(b.column)} ` +
|
|
397
|
+
`AND _t.${q(b.column)} = ${row}.${q(a.column)} AND _t.${q(sym.column)} = ${row}.${q(sym.column)})`);
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* The uid-function arguments of one table, as SQL over `row` (`NEW` inside a
|
|
401
|
+
* trigger, the qualified table name inside an UPDATE).
|
|
402
|
+
*/
|
|
403
|
+
function uidArgSql(scope, spec, row) {
|
|
404
|
+
const col = (name) => `${row}.${q(name)}`;
|
|
405
|
+
if (spec.kind === 'minted') {
|
|
406
|
+
return [
|
|
407
|
+
...spec.key.map(col),
|
|
408
|
+
spec.birth ? col(spec.birth) : 'NULL',
|
|
409
|
+
...(spec.owners ?? []).flatMap((ref) => refArgs(scope, ref, row)),
|
|
410
|
+
...(spec.content ?? []).map(col),
|
|
411
|
+
];
|
|
412
|
+
}
|
|
413
|
+
return [
|
|
414
|
+
...spec.key.flatMap((column) => {
|
|
415
|
+
const ref = spec.keyRefs?.find((r) => r.column === column);
|
|
416
|
+
return ref ? refArgs(scope, ref, row) : [col(column)];
|
|
417
|
+
}),
|
|
418
|
+
...(spec.symmetric ? [symmetricTwinSql(spec, row)] : []),
|
|
419
|
+
];
|
|
420
|
+
}
|
|
421
|
+
/** SQL call of the uid function for one table; `variant` 1 forces the mirror recipe. */
|
|
422
|
+
function uidCallSql(scope, spec, row, variant = 0) {
|
|
423
|
+
if (spec.randomOnly)
|
|
424
|
+
return `${ROW_RANDOM_UID_SQL_FUNCTION}()`;
|
|
425
|
+
const args = uidArgSql(scope, spec, row);
|
|
426
|
+
return `${ROW_UID_SQL_FUNCTION}(${[`'${spec.table}'`, String(variant), ...args].join(', ')})`;
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Compute a deterministic uid from the arguments the SQL function receives,
|
|
430
|
+
* in {@link uidArgSql} order. Returns `null` while a referenced row exists but
|
|
431
|
+
* has no uid yet.
|
|
432
|
+
*/
|
|
433
|
+
function uidFromArgs(scope, table, variant, args) {
|
|
434
|
+
const spec = rowIdentitySpec(scope, table);
|
|
435
|
+
if (!spec)
|
|
436
|
+
throw new Error(`row identity: ${table} is not declared in scope ${scope}`);
|
|
437
|
+
let i = 0;
|
|
438
|
+
const take = () => {
|
|
439
|
+
if (i >= args.length)
|
|
440
|
+
throw new Error(`row identity: ${table} got too few arguments`);
|
|
441
|
+
return args[i++] ?? null;
|
|
442
|
+
};
|
|
443
|
+
const takeRef = () => resolveRef(take(), take(), take());
|
|
444
|
+
if (spec.kind === 'minted') {
|
|
445
|
+
const key = spec.key.map(() => take());
|
|
446
|
+
const birth = take();
|
|
447
|
+
const owners = [];
|
|
448
|
+
for (const _ of spec.owners ?? []) {
|
|
449
|
+
const owner = takeRef();
|
|
450
|
+
if (owner === undefined)
|
|
451
|
+
return null;
|
|
452
|
+
owners.push(owner);
|
|
453
|
+
}
|
|
454
|
+
const content = (spec.content ?? []).map(() => take());
|
|
455
|
+
return mintedRowUid(scope, table, key, birth, owners, content);
|
|
456
|
+
}
|
|
457
|
+
const parts = [];
|
|
458
|
+
for (const column of spec.key) {
|
|
459
|
+
if (spec.keyRefs?.some((r) => r.column === column)) {
|
|
460
|
+
const value = takeRef();
|
|
461
|
+
if (value === undefined)
|
|
462
|
+
return null;
|
|
463
|
+
parts.push(value);
|
|
464
|
+
}
|
|
465
|
+
else
|
|
466
|
+
parts.push(take());
|
|
467
|
+
}
|
|
468
|
+
if (!spec.symmetric)
|
|
469
|
+
return naturalRowUid(scope, table, parts);
|
|
470
|
+
const twinExists = Number(take()) === 1;
|
|
471
|
+
const typeIndex = spec.key.indexOf(spec.symmetric.column);
|
|
472
|
+
const type = parts[typeIndex];
|
|
473
|
+
if (typeof type !== 'string' || !spec.symmetric.values.includes(type)) {
|
|
474
|
+
return naturalRowUid(scope, table, parts);
|
|
475
|
+
}
|
|
476
|
+
const [a, b] = [parts[0], parts[1]].map((p) => String(p));
|
|
477
|
+
const sorted = a <= b ? [a, b] : [b, a];
|
|
478
|
+
const rest = parts.filter((_, k) => k > 1);
|
|
479
|
+
const mirror = variant === 1 || (a > b && twinExists);
|
|
480
|
+
return naturalRowUid(scope, table, [...sorted, ...rest], mirror ? 'natural-mirror' : 'natural');
|
|
481
|
+
}
|
|
482
|
+
/** SQL for one birth fact of a minted table. */
|
|
483
|
+
function birthFactSql(scope, spec, fact, row) {
|
|
484
|
+
const target = fingerprintRef(scope, spec, fact);
|
|
485
|
+
if (target) {
|
|
486
|
+
// The referenced row's fingerprint; a flag when there is no reference or
|
|
487
|
+
// the row is gone; NULL (wait) while it exists without a fingerprint.
|
|
488
|
+
const match = `_r.${q(target.key)} = ${row}.${q(target.column)}`;
|
|
489
|
+
return (`(CASE WHEN ${row}.${q(target.column)} IS NULL THEN 'ref:none' ` +
|
|
490
|
+
`WHEN NOT EXISTS (SELECT 1 FROM main.${q(target.table)} AS _r WHERE ${match}) THEN 'ref:missing' ` +
|
|
491
|
+
`ELSE (SELECT _r.${q(BIRTH_FP_COLUMN)} FROM main.${q(target.table)} AS _r WHERE ${match}) END)`);
|
|
492
|
+
}
|
|
493
|
+
return `${row}.${q(fact)}`;
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* The row a fingerprint fact reads the fingerprint of: `@ownerFp:<column>` is
|
|
497
|
+
* the owner that column references (matched on the owner's key);
|
|
498
|
+
* `@refFp:<column>` is a stored reference uid column (matched on the uid).
|
|
499
|
+
*/
|
|
500
|
+
function fingerprintRef(scope, spec, fact) {
|
|
501
|
+
if (fact.startsWith('@ownerFp:')) {
|
|
502
|
+
const column = fact.slice('@ownerFp:'.length);
|
|
503
|
+
const ref = spec.owners?.find((r) => r.column === column);
|
|
504
|
+
if (!ref)
|
|
505
|
+
throw new Error(`row identity: ${spec.table} birth fact ${fact} names no owner`);
|
|
506
|
+
return { table: ref.table, column, key: targetKey(scope, ref.table) };
|
|
507
|
+
}
|
|
508
|
+
if (fact.startsWith('@refFp:')) {
|
|
509
|
+
const column = fact.slice('@refFp:'.length);
|
|
510
|
+
const ref = spec.storedRefUids?.find((r) => r.column === column && (r.source ?? 'uid') === 'uid');
|
|
511
|
+
if (!ref)
|
|
512
|
+
throw new Error(`row identity: ${spec.table} birth fact ${fact} names no stored uid`);
|
|
513
|
+
return { table: ref.table, column, key: UID_COLUMN };
|
|
514
|
+
}
|
|
515
|
+
return null;
|
|
516
|
+
}
|
|
517
|
+
/** Minted tables ordered so a table comes after every table its fingerprint facts read. */
|
|
518
|
+
function fingerprintOrder(scope, specs) {
|
|
519
|
+
const minted = specs.filter((s) => s.kind === 'minted');
|
|
520
|
+
const deps = (spec) => (spec.birthFacts ?? [])
|
|
521
|
+
.map((f) => fingerprintRef(scope, spec, f)?.table)
|
|
522
|
+
.filter((t) => t !== undefined && t !== spec.table);
|
|
523
|
+
const done = new Set();
|
|
524
|
+
const out = [];
|
|
525
|
+
let pending = [...minted];
|
|
526
|
+
while (pending.length > 0) {
|
|
527
|
+
const ready = pending.filter((s) => deps(s).every((t) => done.has(t) || !minted.some((m) => m.table === t)));
|
|
528
|
+
if (ready.length === 0)
|
|
529
|
+
throw new Error('row identity: cyclic fingerprint facts');
|
|
530
|
+
for (const s of ready) {
|
|
531
|
+
out.push(s);
|
|
532
|
+
done.add(s.table);
|
|
533
|
+
}
|
|
534
|
+
pending = pending.filter((s) => !ready.includes(s));
|
|
535
|
+
}
|
|
536
|
+
return out;
|
|
537
|
+
}
|
|
538
|
+
/** SQL call of the birth-fingerprint function for one minted table. */
|
|
539
|
+
function birthFpCallSql(scope, spec, row) {
|
|
540
|
+
const birth = spec.birth ? `${row}.${q(spec.birth)}` : 'NULL';
|
|
541
|
+
const facts = (spec.birthFacts ?? []).map((f) => birthFactSql(scope, spec, f, row));
|
|
542
|
+
return `${ROW_BIRTH_FP_SQL_FUNCTION}(${[`'${spec.table}'`, birth, ...facts].join(', ')})`;
|
|
543
|
+
}
|
|
544
|
+
/** SQL for a stored reference fact (`ac_uid`, `ac_text_hash`) read from `row`. */
|
|
545
|
+
function storedRefSql(scope, ref, row) {
|
|
546
|
+
if (ref.source === 'text_hash') {
|
|
547
|
+
return (`${AC_TEXT_HASH_SQL_FUNCTION}((SELECT _r.${q('text')} FROM main.${q(ref.table)} AS _r ` +
|
|
548
|
+
`WHERE _r.${q(targetKey(scope, ref.table))} = ${row}.${q(ref.from)}))`);
|
|
549
|
+
}
|
|
550
|
+
return refUidSql(scope, { column: ref.from, table: ref.table }, row);
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Register the identity SQL functions on a connection: the deterministic uid
|
|
554
|
+
* and birth fingerprint, the AC text hash, and a random v7 for `randomOnly`
|
|
555
|
+
* tables.
|
|
556
|
+
*
|
|
557
|
+
* @param db - Connection on a `cleo.db`.
|
|
558
|
+
* @param scope - The store's scope.
|
|
559
|
+
*/
|
|
560
|
+
export function registerRowUidFunction(db, scope) {
|
|
561
|
+
db.function(ROW_UID_SQL_FUNCTION, { varargs: true, deterministic: true }, (table, variant, ...args) => {
|
|
562
|
+
if (typeof table !== 'string')
|
|
563
|
+
throw new Error('row identity: table name must be text');
|
|
564
|
+
return uidFromArgs(scope, table, Number(variant), args);
|
|
565
|
+
});
|
|
566
|
+
db.function(ROW_BIRTH_FP_SQL_FUNCTION, { varargs: true, deterministic: true }, (table, birth, ...facts) => {
|
|
567
|
+
if (typeof table !== 'string')
|
|
568
|
+
throw new Error('row identity: table name must be text');
|
|
569
|
+
const spec = rowIdentitySpec(scope, table);
|
|
570
|
+
const ownerFacts = (spec?.birthFacts ?? []).map((f) => f.startsWith('@ownerFp:') || f.startsWith('@refFp:'));
|
|
571
|
+
if (facts.some((v, k) => ownerFacts[k] && v === null))
|
|
572
|
+
return null;
|
|
573
|
+
return birthFingerprint(table, birth, facts);
|
|
574
|
+
});
|
|
575
|
+
db.function(AC_TEXT_HASH_SQL_FUNCTION, { deterministic: true }, (text) => typeof text === 'string' ? acTextHash(text) : null);
|
|
576
|
+
db.function(ROW_RANDOM_UID_SQL_FUNCTION, { deterministic: false }, () => mintRowUid());
|
|
577
|
+
}
|
|
578
|
+
/** Whether a table exists in `main`. */
|
|
579
|
+
function hasTable(db, table) {
|
|
580
|
+
return (db.prepare("SELECT 1 FROM main.sqlite_master WHERE type = 'table' AND name = ?").get(table) !==
|
|
581
|
+
undefined);
|
|
582
|
+
}
|
|
583
|
+
/** Column names of a `main` table. */
|
|
584
|
+
function columnsOf(db, table) {
|
|
585
|
+
const rows = db.prepare('SELECT name FROM pragma_table_info(?)').all(table);
|
|
586
|
+
return new Set(rows.map((r) => r.name));
|
|
587
|
+
}
|
|
588
|
+
/** Declared tables present in this store, minted before natural, owners before children. */
|
|
589
|
+
function fillOrder(db, scope) {
|
|
590
|
+
const present = ROW_IDENTITY[scope].filter((spec) => hasTable(db, spec.table));
|
|
591
|
+
const done = new Set();
|
|
592
|
+
const ordered = [];
|
|
593
|
+
const deps = (spec) => [...(spec.owners ?? []), ...(spec.keyRefs ?? [])]
|
|
594
|
+
.map((ref) => ref.table)
|
|
595
|
+
.filter((t) => t !== spec.table);
|
|
596
|
+
for (const kind of ['minted', 'natural']) {
|
|
597
|
+
let pending = present.filter((spec) => spec.kind === kind);
|
|
598
|
+
while (pending.length > 0) {
|
|
599
|
+
const ready = pending.filter((spec) => deps(spec).every((t) => done.has(t) || !present.some((p) => p.table === t)));
|
|
600
|
+
if (ready.length === 0) {
|
|
601
|
+
throw new Error(`row identity: cyclic owners among ${pending.map((s) => s.table).join(', ')}`);
|
|
602
|
+
}
|
|
603
|
+
for (const spec of ready) {
|
|
604
|
+
ordered.push(spec);
|
|
605
|
+
done.add(spec.table);
|
|
606
|
+
}
|
|
607
|
+
pending = pending.filter((spec) => !ready.includes(spec));
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
return ordered;
|
|
611
|
+
}
|
|
612
|
+
/** Whether the uid column is the table's primary key (no separate index needed). */
|
|
613
|
+
function uidIsPrimaryKey(db, table) {
|
|
614
|
+
const rows = db.prepare('SELECT name, pk FROM pragma_table_info(?)').all(table);
|
|
615
|
+
return rows.some((r) => r.name === UID_COLUMN && r.pk > 0);
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* Re-assert the identity columns and indexes of every declared table present
|
|
619
|
+
* in the store. Idempotent; the migration normally created them already.
|
|
620
|
+
*
|
|
621
|
+
* @param db - Connection on a `cleo.db`.
|
|
622
|
+
* @param scope - The store's scope.
|
|
623
|
+
* @returns Statements that had to be run (empty on a healthy store).
|
|
624
|
+
*/
|
|
625
|
+
export function ensureRowIdentitySchema(db, scope) {
|
|
626
|
+
const healed = [];
|
|
627
|
+
for (const spec of ROW_IDENTITY[scope]) {
|
|
628
|
+
if (!hasTable(db, spec.table))
|
|
629
|
+
continue;
|
|
630
|
+
const cols = columnsOf(db, spec.table);
|
|
631
|
+
for (const column of rowIdentityColumns(scope, spec.table)) {
|
|
632
|
+
if (cols.has(column))
|
|
633
|
+
continue;
|
|
634
|
+
const stmt = `ALTER TABLE main.${q(spec.table)} ADD COLUMN ${q(column)} TEXT`;
|
|
635
|
+
db.exec(stmt);
|
|
636
|
+
healed.push(stmt);
|
|
637
|
+
}
|
|
638
|
+
const indexes = [];
|
|
639
|
+
if (!uidIsPrimaryKey(db, spec.table)) {
|
|
640
|
+
indexes.push(`CREATE UNIQUE INDEX IF NOT EXISTS main.${q(`uq_${spec.table}_uid`)} ON ${q(spec.table)} (${q(UID_COLUMN)})`);
|
|
641
|
+
}
|
|
642
|
+
for (const ref of spec.storedRefUids ?? []) {
|
|
643
|
+
indexes.push(`CREATE INDEX IF NOT EXISTS main.${q(`idx_${spec.table}_${ref.column}`)} ON ${q(spec.table)} (${q(ref.column)})`);
|
|
644
|
+
}
|
|
645
|
+
for (const stmt of indexes) {
|
|
646
|
+
const name = /INDEX IF NOT EXISTS main\."([^"]+)"/.exec(stmt)?.[1];
|
|
647
|
+
const exists = db
|
|
648
|
+
.prepare("SELECT 1 FROM main.sqlite_master WHERE type = 'index' AND name = ?")
|
|
649
|
+
.get(name ?? '') !== undefined;
|
|
650
|
+
if (exists)
|
|
651
|
+
continue;
|
|
652
|
+
db.exec(stmt);
|
|
653
|
+
healed.push(stmt);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
return healed;
|
|
657
|
+
}
|
|
658
|
+
/** Count of rows with a NULL value in `column`. */
|
|
659
|
+
function nullCount(db, table, column) {
|
|
660
|
+
const row = db
|
|
661
|
+
.prepare(`SELECT count(*) AS n FROM main.${q(table)} WHERE ${q(column)} IS NULL`)
|
|
662
|
+
.get();
|
|
663
|
+
return row.n;
|
|
664
|
+
}
|
|
665
|
+
/** Whether an error is a SQLite UNIQUE constraint failure. */
|
|
666
|
+
function isUniqueViolation(error) {
|
|
667
|
+
return error instanceof Error && /UNIQUE constraint failed/i.test(error.message);
|
|
668
|
+
}
|
|
669
|
+
/**
|
|
670
|
+
* Fill the NULL uids of one table. A bulk UPDATE normally does it; if a
|
|
671
|
+
* computed uid clashes with an existing one, the rows are filled one by one
|
|
672
|
+
* (a symmetric edge then tries its mirror recipe) and a row that still clashes
|
|
673
|
+
* keeps its NULL (reported, never fatal).
|
|
674
|
+
*/
|
|
675
|
+
function fillTable(db, scope, spec, writers) {
|
|
676
|
+
const before = nullCount(db, spec.table, UID_COLUMN);
|
|
677
|
+
if (before === 0)
|
|
678
|
+
return 0;
|
|
679
|
+
const table = `main.${q(spec.table)}`;
|
|
680
|
+
const fill = (variant, rowid) => writers.fillIdentityColumnNative(db, spec.table, UID_COLUMN, uidCallSql(scope, spec, table, variant), rowid);
|
|
681
|
+
db.exec('SAVEPOINT row_uid_fill_table');
|
|
682
|
+
try {
|
|
683
|
+
fill(0);
|
|
684
|
+
db.exec('RELEASE SAVEPOINT row_uid_fill_table');
|
|
685
|
+
}
|
|
686
|
+
catch (error) {
|
|
687
|
+
db.exec('ROLLBACK TO SAVEPOINT row_uid_fill_table');
|
|
688
|
+
db.exec('RELEASE SAVEPOINT row_uid_fill_table');
|
|
689
|
+
if (!isUniqueViolation(error))
|
|
690
|
+
throw error;
|
|
691
|
+
const rowids = db
|
|
692
|
+
.prepare(`SELECT rowid AS r FROM ${table} WHERE ${q(UID_COLUMN)} IS NULL ORDER BY rowid`)
|
|
693
|
+
.all();
|
|
694
|
+
const variants = spec.symmetric ? [0, 1] : [0];
|
|
695
|
+
for (const { r } of rowids) {
|
|
696
|
+
for (const variant of variants) {
|
|
697
|
+
try {
|
|
698
|
+
if (fill(variant, r) > 0)
|
|
699
|
+
break;
|
|
700
|
+
}
|
|
701
|
+
catch (rowError) {
|
|
702
|
+
if (!isUniqueViolation(rowError))
|
|
703
|
+
throw rowError;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
return before - nullCount(db, spec.table, UID_COLUMN);
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* Fill the NULL uids of one declared table (the open pass's recipe). Requires
|
|
712
|
+
* {@link registerRowUidFunction} on the connection. Used after a uid re-key,
|
|
713
|
+
* which clears the uids of the natural rows keyed by the re-keyed row.
|
|
714
|
+
*
|
|
715
|
+
* @param db - Connection on a `cleo.db`.
|
|
716
|
+
* @param scope - The store's scope.
|
|
717
|
+
* @param table - Declared table.
|
|
718
|
+
* @returns Uids written.
|
|
719
|
+
*/
|
|
720
|
+
export function fillTableUids(db, scope, table, writers = requireWriters()) {
|
|
721
|
+
const spec = rowIdentitySpec(scope, table);
|
|
722
|
+
if (!spec)
|
|
723
|
+
throw new Error(`row identity: ${table} is not declared in scope ${scope}`);
|
|
724
|
+
return fillTable(db, scope, spec, writers);
|
|
725
|
+
}
|
|
726
|
+
/** Fill the stored reference facts (`ac_uid`, `ac_text_hash`) that resolve. */
|
|
727
|
+
function fillStoredRefs(db, scope, spec, out, writers) {
|
|
728
|
+
const table = `main.${q(spec.table)}`;
|
|
729
|
+
for (const ref of spec.storedRefUids ?? []) {
|
|
730
|
+
const before = nullCount(db, spec.table, ref.column);
|
|
731
|
+
if (before === 0)
|
|
732
|
+
continue;
|
|
733
|
+
writers.fillIdentityColumnNative(db, spec.table, ref.column, storedRefSql(scope, ref, table));
|
|
734
|
+
const written = before - nullCount(db, spec.table, ref.column);
|
|
735
|
+
if (written > 0)
|
|
736
|
+
out[`${spec.table}.${ref.column}`] = written;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
/** Fill the NULL birth fingerprints of one minted table. */
|
|
740
|
+
function fillBirthFp(db, scope, spec, writers) {
|
|
741
|
+
if (spec.kind !== 'minted')
|
|
742
|
+
return 0;
|
|
743
|
+
const before = nullCount(db, spec.table, BIRTH_FP_COLUMN);
|
|
744
|
+
if (before === 0)
|
|
745
|
+
return 0;
|
|
746
|
+
const table = `main.${q(spec.table)}`;
|
|
747
|
+
writers.fillIdentityColumnNative(db, spec.table, BIRTH_FP_COLUMN, birthFpCallSql(scope, spec, table));
|
|
748
|
+
return before - nullCount(db, spec.table, BIRTH_FP_COLUMN);
|
|
749
|
+
}
|
|
750
|
+
/**
|
|
751
|
+
* Re-link acceptance criteria an older build deleted and recreated without
|
|
752
|
+
* their uid (spec §6.5). For each task with criteria lacking a uid, the uids
|
|
753
|
+
* its deleted criteria carried (recorded by the graveyard trigger) are handed
|
|
754
|
+
* back by the carry rule of `planAcUpdate`: same text first, then same
|
|
755
|
+
* ordinal. Returns how many criteria got their uid back. The graveyard is
|
|
756
|
+
* emptied afterwards: an entry whose criterion was not recreated is a real
|
|
757
|
+
* deletion.
|
|
758
|
+
*/
|
|
759
|
+
function relinkAcUids(db, writers) {
|
|
760
|
+
if (!hasTable(db, AC_UID_GRAVEYARD) || !hasTable(db, 'tasks_task_acceptance_criteria'))
|
|
761
|
+
return 0;
|
|
762
|
+
let relinked = 0;
|
|
763
|
+
const orphans = db
|
|
764
|
+
.prepare(`SELECT id, task_id AS taskId, ordinal, text FROM main.tasks_task_acceptance_criteria
|
|
765
|
+
WHERE uid IS NULL AND task_id IN (SELECT task_id FROM main.${AC_UID_GRAVEYARD})
|
|
766
|
+
ORDER BY task_id, ordinal`)
|
|
767
|
+
.all();
|
|
768
|
+
const dead = db.prepare(`SELECT uid, ordinal, text, birth_fp AS birthFp FROM main.${AC_UID_GRAVEYARD} g
|
|
769
|
+
WHERE task_id = ? AND NOT EXISTS (SELECT 1 FROM main.tasks_task_acceptance_criteria a WHERE a.uid = g.uid)
|
|
770
|
+
ORDER BY seq DESC`);
|
|
771
|
+
const assign = (dead, acId) => writers.relinkAcUidNative(db, acId, dead.uid, dead.birthFp);
|
|
772
|
+
const byTask = new Map();
|
|
773
|
+
for (const row of orphans)
|
|
774
|
+
byTask.set(row.taskId, [...(byTask.get(row.taskId) ?? []), row]);
|
|
775
|
+
for (const [taskId, rows] of byTask) {
|
|
776
|
+
const seen = new Set();
|
|
777
|
+
const pool = dead.all(taskId).filter((d) => !seen.has(d.uid) && seen.add(d.uid));
|
|
778
|
+
const claimed = new Set();
|
|
779
|
+
const pick = (match) => pool.find((d) => !claimed.has(d.uid) && match(d));
|
|
780
|
+
const pending = rows.filter((row) => {
|
|
781
|
+
const same = pick((d) => d.text === row.text);
|
|
782
|
+
if (!same)
|
|
783
|
+
return true;
|
|
784
|
+
claimed.add(same.uid);
|
|
785
|
+
relinked += assign(same, row.id);
|
|
786
|
+
return false;
|
|
787
|
+
});
|
|
788
|
+
for (const row of pending) {
|
|
789
|
+
const same = pick((d) => d.ordinal === row.ordinal);
|
|
790
|
+
if (!same)
|
|
791
|
+
continue;
|
|
792
|
+
claimed.add(same.uid);
|
|
793
|
+
relinked += assign(same, row.id);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
writers.clearAcUidGraveyardNative(db);
|
|
797
|
+
return relinked;
|
|
798
|
+
}
|
|
799
|
+
/** Count rows per table matching `where`, keeping non-zero counts. */
|
|
800
|
+
function countWhere(db, table, where, out) {
|
|
801
|
+
const n = db.prepare(`SELECT count(*) AS n FROM main.${q(table)} AS x WHERE ${where}`).get().n;
|
|
802
|
+
if (n > 0)
|
|
803
|
+
out[table] = n;
|
|
804
|
+
}
|
|
805
|
+
/**
|
|
806
|
+
* Rows the recipes flag: minted rows with an unknown birth (uid timestamp 0),
|
|
807
|
+
* natural rows with a dangling key reference, and reversed twins of symmetric
|
|
808
|
+
* edges stored both ways.
|
|
809
|
+
*
|
|
810
|
+
* @param db - Connection on a `cleo.db`.
|
|
811
|
+
* @param scope - The store's scope.
|
|
812
|
+
* @returns Counts per table (tables without findings are absent).
|
|
813
|
+
*/
|
|
814
|
+
export function rowIdentityFindings(db, scope) {
|
|
815
|
+
const unknownBirth = {};
|
|
816
|
+
const danglingRefs = {};
|
|
817
|
+
const mirrorEdges = {};
|
|
818
|
+
for (const spec of ROW_IDENTITY[scope]) {
|
|
819
|
+
if (!hasTable(db, spec.table))
|
|
820
|
+
continue;
|
|
821
|
+
if (spec.kind === 'minted' && spec.birth) {
|
|
822
|
+
const b = `x.${q(spec.birth)}`;
|
|
823
|
+
countWhere(db, spec.table, `${b} IS NULL OR (julianday(replace(${b}, 'T', ' ')) IS NULL AND julianday(${b}) IS NULL)`, unknownBirth);
|
|
824
|
+
}
|
|
825
|
+
const refs = spec.kind === 'natural' ? (spec.keyRefs ?? []) : (spec.owners ?? []);
|
|
826
|
+
if (refs.length > 0) {
|
|
827
|
+
countWhere(db, spec.table, refs
|
|
828
|
+
.map((ref) => `(x.${q(ref.column)} IS NOT NULL AND NOT ${refExistsSql(scope, ref, 'x')})`)
|
|
829
|
+
.join(' OR '), danglingRefs);
|
|
830
|
+
}
|
|
831
|
+
if (spec.symmetric) {
|
|
832
|
+
const [a, b] = spec.keyRefs ?? [];
|
|
833
|
+
if (a && b) {
|
|
834
|
+
const values = spec.symmetric.values.map((v) => `'${v.replaceAll("'", "''")}'`).join(', ');
|
|
835
|
+
countWhere(db, spec.table, `x.${q(spec.symmetric.column)} IN (${values}) AND ${symmetricTwinSql(spec, 'x')} ` +
|
|
836
|
+
`AND ${refUidSql(scope, a, 'x')} > ${refUidSql(scope, b, 'x')}`, mirrorEdges);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
return { unknownBirth, danglingRefs, mirrorEdges };
|
|
841
|
+
}
|
|
842
|
+
/**
|
|
843
|
+
* Fill every NULL uid, stored reference fact and birth fingerprint of the
|
|
844
|
+
* declared tables, in one savepoint, after re-linking graveyard AC uids.
|
|
845
|
+
* Requires {@link registerRowUidFunction} on the connection. Idempotent: a
|
|
846
|
+
* filled store writes nothing.
|
|
847
|
+
*
|
|
848
|
+
* @param db - Connection on a `cleo.db`.
|
|
849
|
+
* @param scope - The store's scope.
|
|
850
|
+
* @returns What was written and what could not be.
|
|
851
|
+
*/
|
|
852
|
+
export function fillRowUids(db, scope, writers = requireWriters()) {
|
|
853
|
+
const filled = {};
|
|
854
|
+
const refsFilled = {};
|
|
855
|
+
const fingerprinted = {};
|
|
856
|
+
const unfilled = {};
|
|
857
|
+
const order = fillOrder(db, scope);
|
|
858
|
+
db.exec('SAVEPOINT row_uid_fill');
|
|
859
|
+
try {
|
|
860
|
+
const relinked = scope === 'project' ? relinkAcUids(db, writers) : 0;
|
|
861
|
+
for (const spec of order) {
|
|
862
|
+
const n = fillTable(db, scope, spec, writers);
|
|
863
|
+
if (n > 0)
|
|
864
|
+
filled[spec.table] = n;
|
|
865
|
+
}
|
|
866
|
+
for (const spec of order)
|
|
867
|
+
fillStoredRefs(db, scope, spec, refsFilled, writers);
|
|
868
|
+
for (const spec of fingerprintOrder(scope, order)) {
|
|
869
|
+
const n = fillBirthFp(db, scope, spec, writers);
|
|
870
|
+
if (n > 0)
|
|
871
|
+
fingerprinted[spec.table] = n;
|
|
872
|
+
}
|
|
873
|
+
for (const spec of order) {
|
|
874
|
+
const left = nullCount(db, spec.table, UID_COLUMN) +
|
|
875
|
+
(spec.kind === 'minted' ? nullCount(db, spec.table, BIRTH_FP_COLUMN) : 0);
|
|
876
|
+
if (left > 0)
|
|
877
|
+
unfilled[spec.table] = left;
|
|
878
|
+
}
|
|
879
|
+
db.exec('RELEASE SAVEPOINT row_uid_fill');
|
|
880
|
+
return { filled, refsFilled, fingerprinted, relinked, unfilled };
|
|
881
|
+
}
|
|
882
|
+
catch (error) {
|
|
883
|
+
db.exec('ROLLBACK TO SAVEPOINT row_uid_fill');
|
|
884
|
+
db.exec('RELEASE SAVEPOINT row_uid_fill');
|
|
885
|
+
throw error;
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
/**
|
|
889
|
+
* Install the per-connection TEMP triggers that fill a uid, the stored
|
|
890
|
+
* reference facts and the birth fingerprint on insert, in that order. Each
|
|
891
|
+
* step is guarded so the trigger never fails the insert: a uid that would
|
|
892
|
+
* clash is left NULL (a symmetric edge first tries its mirror recipe).
|
|
893
|
+
* Requires {@link registerRowUidFunction}.
|
|
894
|
+
*
|
|
895
|
+
* @param db - Connection on a `cleo.db`.
|
|
896
|
+
* @param scope - The store's scope.
|
|
897
|
+
*/
|
|
898
|
+
export function installRowUidTriggers(db, scope) {
|
|
899
|
+
for (const spec of ROW_IDENTITY[scope]) {
|
|
900
|
+
if (!hasTable(db, spec.table) || uidIsPrimaryKey(db, spec.table))
|
|
901
|
+
continue;
|
|
902
|
+
const table = `main.${q(spec.table)}`;
|
|
903
|
+
const uid = q(UID_COLUMN);
|
|
904
|
+
const steps = [];
|
|
905
|
+
for (const variant of spec.symmetric ? [0, 1] : [0]) {
|
|
906
|
+
const value = uidCallSql(scope, spec, 'NEW', variant);
|
|
907
|
+
steps.push(`UPDATE ${table} SET ${uid} = ${value} WHERE rowid = NEW.rowid AND ${uid} IS NULL ` +
|
|
908
|
+
`AND NOT EXISTS (SELECT 1 FROM ${table} AS _u WHERE _u.${uid} = ${value});`);
|
|
909
|
+
}
|
|
910
|
+
for (const ref of spec.storedRefUids ?? []) {
|
|
911
|
+
steps.push(`UPDATE ${table} SET ${q(ref.column)} = ${storedRefSql(scope, ref, 'NEW')} ` +
|
|
912
|
+
`WHERE rowid = NEW.rowid AND ${q(ref.column)} IS NULL;`);
|
|
913
|
+
}
|
|
914
|
+
if (spec.kind === 'minted') {
|
|
915
|
+
// Reads the row as stored, so the facts filled above (ac_text_hash) count.
|
|
916
|
+
steps.push(`UPDATE ${table} SET ${q(BIRTH_FP_COLUMN)} = ${birthFpCallSql(scope, spec, table)} ` +
|
|
917
|
+
`WHERE rowid = NEW.rowid AND ${q(BIRTH_FP_COLUMN)} IS NULL;`);
|
|
918
|
+
}
|
|
919
|
+
db.exec(`CREATE TEMP TRIGGER IF NOT EXISTS ${q(`trg_row_uid_${spec.table}`)} ` +
|
|
920
|
+
`AFTER INSERT ON ${table} BEGIN ${steps.join(' ')} END`);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
/**
|
|
924
|
+
* Bring a freshly opened `cleo.db` connection's row identity up to date:
|
|
925
|
+
* schema, graveyard re-link, fill, per-connection triggers. Never throws: a
|
|
926
|
+
* failure is logged and the store opens with the values it has.
|
|
927
|
+
*
|
|
928
|
+
* @param db - Connection on a `cleo.db`, after its migrations ran.
|
|
929
|
+
* @param scope - The store's scope.
|
|
930
|
+
* @param options - `triggers: false` skips the per-connection triggers (a
|
|
931
|
+
* dedicated migration connection: exodus refuses copies whose triggers call
|
|
932
|
+
* an opaque function, so its rows are filled at the next open instead).
|
|
933
|
+
* @returns The fill report, or `null` when skipped or failed.
|
|
934
|
+
*/
|
|
935
|
+
/**
|
|
936
|
+
* Version of every identity recipe this build derives (uid, natural uid,
|
|
937
|
+
* birth fingerprint, encoding). Written to {@link ROW_IDENTITY_META_TABLE} under
|
|
938
|
+
* {@link ROW_IDENTITY_RECIPE_KEY} by the first fill; bumped by any change to a
|
|
939
|
+
* recipe, a frozen content or birth-fact list, or the encoding.
|
|
940
|
+
*/
|
|
941
|
+
export const ROW_IDENTITY_RECIPE = 'cleo/row-identity/v1';
|
|
942
|
+
/**
|
|
943
|
+
* Local-only key/value table of the row-identity layer. Not
|
|
944
|
+
* `tasks_schema_meta`: the twin collapse drops twin-only keys of that table on
|
|
945
|
+
* its first run (T12535), which would lose the marker.
|
|
946
|
+
*/
|
|
947
|
+
export const ROW_IDENTITY_META_TABLE = 'tasks_row_identity_meta';
|
|
948
|
+
/** Key holding the recipe version the store's identity values were derived with. */
|
|
949
|
+
export const ROW_IDENTITY_RECIPE_KEY = 'row_identity_recipe';
|
|
950
|
+
/**
|
|
951
|
+
* Key the sync layer (T12342/T12343) writes BEFORE the
|
|
952
|
+
* first uid leaves the device. Once present, identity values are shared and
|
|
953
|
+
* are never cleared locally.
|
|
954
|
+
*/
|
|
955
|
+
export const ROW_IDENTITY_SYNCED_KEY = 'row_identity_synced';
|
|
956
|
+
/** Tables the uid migration creates, as `CREATE … IF NOT EXISTS` (kept equal to its SQL). */
|
|
957
|
+
const IDENTITY_TABLE_DDL = {
|
|
958
|
+
[ROW_IDENTITY_META_TABLE]: [
|
|
959
|
+
`CREATE TABLE IF NOT EXISTS main.${ROW_IDENTITY_META_TABLE} (
|
|
960
|
+
key TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL)`,
|
|
961
|
+
],
|
|
962
|
+
tasks_display_id_aliases: [
|
|
963
|
+
`CREATE TABLE IF NOT EXISTS main.tasks_display_id_aliases (
|
|
964
|
+
uid TEXT PRIMARY KEY NOT NULL, entity_table TEXT NOT NULL, display_id TEXT NOT NULL,
|
|
965
|
+
entity_uid TEXT NOT NULL, reason TEXT NOT NULL, origin TEXT, displaced_hlc TEXT,
|
|
966
|
+
created_at TEXT NOT NULL, entity_birth_fp TEXT)`,
|
|
967
|
+
'CREATE INDEX IF NOT EXISTS main.idx_tasks_display_id_aliases_lookup ON tasks_display_id_aliases (entity_table, display_id)',
|
|
968
|
+
'CREATE INDEX IF NOT EXISTS main.idx_tasks_display_id_aliases_entity ON tasks_display_id_aliases (entity_uid)',
|
|
969
|
+
],
|
|
970
|
+
tasks_uid_aliases: [
|
|
971
|
+
`CREATE TABLE IF NOT EXISTS main.tasks_uid_aliases (
|
|
972
|
+
uid TEXT PRIMARY KEY NOT NULL, entity_table TEXT NOT NULL, old_uid TEXT NOT NULL,
|
|
973
|
+
old_birth_fp TEXT NOT NULL, new_uid TEXT NOT NULL, origin TEXT, displaced_hlc TEXT,
|
|
974
|
+
created_at TEXT NOT NULL)`,
|
|
975
|
+
'CREATE INDEX IF NOT EXISTS main.idx_tasks_uid_aliases_old ON tasks_uid_aliases (entity_table, old_uid)',
|
|
976
|
+
],
|
|
977
|
+
tasks_identity_quarantine: [
|
|
978
|
+
`CREATE TABLE IF NOT EXISTS main.tasks_identity_quarantine (
|
|
979
|
+
entity_table TEXT NOT NULL, uid TEXT NOT NULL, birth_fp TEXT NOT NULL, reason TEXT NOT NULL,
|
|
980
|
+
contested_id TEXT, row_json TEXT NOT NULL, received_hlc TEXT, created_at TEXT NOT NULL,
|
|
981
|
+
PRIMARY KEY (entity_table, uid, birth_fp))`,
|
|
982
|
+
],
|
|
983
|
+
[AC_UID_GRAVEYARD]: [
|
|
984
|
+
`CREATE TABLE IF NOT EXISTS main.${AC_UID_GRAVEYARD} (
|
|
985
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT, ac_id TEXT NOT NULL, uid TEXT NOT NULL,
|
|
986
|
+
task_id TEXT NOT NULL, ordinal INTEGER NOT NULL, text TEXT NOT NULL, birth_fp TEXT,
|
|
987
|
+
deleted_at TEXT NOT NULL)`,
|
|
988
|
+
`CREATE INDEX IF NOT EXISTS main.idx_${AC_UID_GRAVEYARD}_task ON ${AC_UID_GRAVEYARD} (task_id)`,
|
|
989
|
+
],
|
|
990
|
+
};
|
|
991
|
+
/** The graveyard's pure-SQL delete trigger (kept equal to the migration's). */
|
|
992
|
+
const AC_UID_GRAVEYARD_TRIGGER = `CREATE TRIGGER IF NOT EXISTS main.trg_tasks_ac_uid_graveyard
|
|
993
|
+
AFTER DELETE ON tasks_task_acceptance_criteria
|
|
994
|
+
WHEN OLD.uid IS NOT NULL
|
|
995
|
+
BEGIN
|
|
996
|
+
INSERT INTO ${AC_UID_GRAVEYARD} (ac_id, uid, task_id, ordinal, text, birth_fp, deleted_at)
|
|
997
|
+
VALUES (OLD.id, OLD.uid, OLD.task_id, OLD.ordinal, OLD.text, OLD.birth_fp, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
|
|
998
|
+
END`;
|
|
999
|
+
/** Whether a schema object of `type` named `name` exists in `main`. */
|
|
1000
|
+
function hasObject(db, type, name) {
|
|
1001
|
+
return (db.prepare('SELECT 1 FROM main.sqlite_master WHERE type = ? AND name = ?').get(type, name) !==
|
|
1002
|
+
undefined);
|
|
1003
|
+
}
|
|
1004
|
+
/**
|
|
1005
|
+
* Re-create the tables, trigger and columns the uid migration adds when they
|
|
1006
|
+
* are missing: a store the journal probe stamped as migrated after only its
|
|
1007
|
+
* ADD COLUMNs had run (Scenario 3, Case A) otherwise never gets them.
|
|
1008
|
+
* Idempotent.
|
|
1009
|
+
*
|
|
1010
|
+
* @param db - Connection on the project `cleo.db`.
|
|
1011
|
+
* @returns Statements that had to be run.
|
|
1012
|
+
*/
|
|
1013
|
+
export function ensureIdentityTables(db) {
|
|
1014
|
+
if (!hasTable(db, 'tasks_task_acceptance_criteria'))
|
|
1015
|
+
return [];
|
|
1016
|
+
const healed = [];
|
|
1017
|
+
for (const [table, ddl] of Object.entries(IDENTITY_TABLE_DDL)) {
|
|
1018
|
+
const tableExisted = hasTable(db, table);
|
|
1019
|
+
for (const stmt of ddl) {
|
|
1020
|
+
const index = /INDEX IF NOT EXISTS main\.(\w+)/.exec(stmt)?.[1];
|
|
1021
|
+
if (index ? hasObject(db, 'index', index) : tableExisted)
|
|
1022
|
+
continue;
|
|
1023
|
+
db.exec(stmt);
|
|
1024
|
+
healed.push(stmt);
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
// Columns an early pre-release table lacks (the live-cleocode state, §12.1).
|
|
1028
|
+
for (const [table, column] of [
|
|
1029
|
+
['tasks_display_id_aliases', 'displaced_hlc'],
|
|
1030
|
+
['tasks_display_id_aliases', 'entity_birth_fp'],
|
|
1031
|
+
[AC_UID_GRAVEYARD, 'birth_fp'],
|
|
1032
|
+
]) {
|
|
1033
|
+
if (columnsOf(db, table).has(column))
|
|
1034
|
+
continue;
|
|
1035
|
+
const stmt = `ALTER TABLE main.${table} ADD COLUMN ${column} TEXT`;
|
|
1036
|
+
db.exec(stmt);
|
|
1037
|
+
healed.push(stmt);
|
|
1038
|
+
}
|
|
1039
|
+
if (!hasObject(db, 'trigger', 'trg_tasks_ac_uid_graveyard')) {
|
|
1040
|
+
db.exec(AC_UID_GRAVEYARD_TRIGGER);
|
|
1041
|
+
healed.push(AC_UID_GRAVEYARD_TRIGGER);
|
|
1042
|
+
}
|
|
1043
|
+
return healed;
|
|
1044
|
+
}
|
|
1045
|
+
/** A row-identity meta value, or `undefined` (also when the table is absent). */
|
|
1046
|
+
function readMeta(db, key) {
|
|
1047
|
+
if (!hasTable(db, ROW_IDENTITY_META_TABLE))
|
|
1048
|
+
return undefined;
|
|
1049
|
+
const row = db
|
|
1050
|
+
.prepare(`SELECT value FROM main.${ROW_IDENTITY_META_TABLE} WHERE key = ?`)
|
|
1051
|
+
.get(key);
|
|
1052
|
+
return row?.value;
|
|
1053
|
+
}
|
|
1054
|
+
/** The pre-release (v4/v5 build) parse of a birth: a zoneless SQLite value as UTC, else `Date.parse`. */
|
|
1055
|
+
function preReleaseParses(value) {
|
|
1056
|
+
if (typeof value !== 'string' || value.length === 0)
|
|
1057
|
+
return false;
|
|
1058
|
+
const iso = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}(:\d{2}(\.\d+)?)?$/.test(value)
|
|
1059
|
+
? `${value.replace(' ', 'T')}Z`
|
|
1060
|
+
: value;
|
|
1061
|
+
return !Number.isNaN(Date.parse(iso));
|
|
1062
|
+
}
|
|
1063
|
+
/**
|
|
1064
|
+
* The birth fingerprint the pre-release v4/v5 build derived (the recipe that
|
|
1065
|
+
* reached live cleocode on 2026-09-28): raw birth text, raw facts, the task
|
|
1066
|
+
* title from the earliest `task_created` audit event, and an AC's OWNER UID
|
|
1067
|
+
* instead of its owner's fingerprint. Used only to recognise those values.
|
|
1068
|
+
*
|
|
1069
|
+
* @param db - Connection on the project `cleo.db`.
|
|
1070
|
+
* @param table - Minted table.
|
|
1071
|
+
* @param row - The stored row.
|
|
1072
|
+
* @returns The pre-release fingerprint, or `null` when the table had none.
|
|
1073
|
+
*/
|
|
1074
|
+
export function preReleaseBirthFp(db, table, row) {
|
|
1075
|
+
const facts = [];
|
|
1076
|
+
const col = (c) => row[c] ?? null;
|
|
1077
|
+
switch (table) {
|
|
1078
|
+
case 'tasks_tasks': {
|
|
1079
|
+
let title = col('title');
|
|
1080
|
+
if (hasTable(db, 'tasks_audit_log')) {
|
|
1081
|
+
const ev = db
|
|
1082
|
+
.prepare(`SELECT json_extract(details_json, '$.title') AS t FROM main.tasks_audit_log
|
|
1083
|
+
WHERE task_id = ? AND +action = 'task_created' AND json_valid(details_json)
|
|
1084
|
+
ORDER BY +timestamp, +id LIMIT 1`)
|
|
1085
|
+
.get(col('id'));
|
|
1086
|
+
if (ev && ev.t !== null && ev.t !== undefined)
|
|
1087
|
+
title = ev.t;
|
|
1088
|
+
}
|
|
1089
|
+
facts.push(title, col('type'));
|
|
1090
|
+
break;
|
|
1091
|
+
}
|
|
1092
|
+
case 'tasks_sessions':
|
|
1093
|
+
facts.push(col('name'));
|
|
1094
|
+
break;
|
|
1095
|
+
case 'tasks_task_acceptance_criteria': {
|
|
1096
|
+
const owner = db
|
|
1097
|
+
.prepare('SELECT uid FROM main.tasks_tasks WHERE id = ?')
|
|
1098
|
+
.get(col('task_id'));
|
|
1099
|
+
facts.push(col('text'), owner?.uid ?? null);
|
|
1100
|
+
break;
|
|
1101
|
+
}
|
|
1102
|
+
case 'tasks_task_acceptance_criteria_history':
|
|
1103
|
+
facts.push(col('ac_id'), col('previous_text'), col('reason'));
|
|
1104
|
+
break;
|
|
1105
|
+
case 'tasks_evidence_ac_bindings':
|
|
1106
|
+
facts.push(col('evidence_atom_id'), col('binding_type'), col('ac_text_hash'));
|
|
1107
|
+
break;
|
|
1108
|
+
default:
|
|
1109
|
+
return null;
|
|
1110
|
+
}
|
|
1111
|
+
const spec = rowIdentitySpec('project', table);
|
|
1112
|
+
const birth = spec?.birth ? col(spec.birth) : null;
|
|
1113
|
+
const token = birth === null
|
|
1114
|
+
? 'birth:unknown'
|
|
1115
|
+
: preReleaseParses(birth)
|
|
1116
|
+
? String(birth)
|
|
1117
|
+
: `birth:unparseable:${String(birth)}`;
|
|
1118
|
+
return createHash('sha256')
|
|
1119
|
+
.update(encodeUidInputs([ROW_BIRTH_DOMAIN, table, token, ...facts]))
|
|
1120
|
+
.digest('hex')
|
|
1121
|
+
.slice(0, 32);
|
|
1122
|
+
}
|
|
1123
|
+
/**
|
|
1124
|
+
* Birth fingerprints a pre-release build derived (e.g. a worktree CLI that
|
|
1125
|
+
* opened live cleocode, 2026-09-28) are cleared, so the fill re-derives them
|
|
1126
|
+
* with the release recipe. Targeted: a value is cleared ONLY when it equals
|
|
1127
|
+
* {@link preReleaseBirthFp} of its row; values from any other source (a
|
|
1128
|
+
* device that received them by sync, a store whose meta table was lost) are
|
|
1129
|
+
* kept, and alias rows are never touched. The uid recipe did not change, so
|
|
1130
|
+
* uids are kept. Runs only while the recipe marker is missing or stale, and
|
|
1131
|
+
* never once uids have synced ({@link ROW_IDENTITY_SYNCED_KEY}): `refused`.
|
|
1132
|
+
*/
|
|
1133
|
+
function resetStaleIdentity(db, scope, writers) {
|
|
1134
|
+
if (scope !== 'project' || !hasTable(db, ROW_IDENTITY_META_TABLE))
|
|
1135
|
+
return 'none';
|
|
1136
|
+
if (readMeta(db, ROW_IDENTITY_RECIPE_KEY) === ROW_IDENTITY_RECIPE)
|
|
1137
|
+
return 'none';
|
|
1138
|
+
const minted = ROW_IDENTITY.project.filter((spec) => spec.kind === 'minted' &&
|
|
1139
|
+
hasTable(db, spec.table) &&
|
|
1140
|
+
columnsOf(db, spec.table).has(BIRTH_FP_COLUMN));
|
|
1141
|
+
const stale = [];
|
|
1142
|
+
for (const spec of minted) {
|
|
1143
|
+
const rows = db
|
|
1144
|
+
.prepare(`SELECT rowid AS _rowid, * FROM main.${q(spec.table)} WHERE ${q(BIRTH_FP_COLUMN)} IS NOT NULL`)
|
|
1145
|
+
.all();
|
|
1146
|
+
for (const row of rows) {
|
|
1147
|
+
if (row[BIRTH_FP_COLUMN] === preReleaseBirthFp(db, spec.table, row)) {
|
|
1148
|
+
stale.push({ table: spec.table, rowid: row._rowid });
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
if (stale.length === 0)
|
|
1153
|
+
return 'none';
|
|
1154
|
+
if (readMeta(db, ROW_IDENTITY_SYNCED_KEY) !== undefined)
|
|
1155
|
+
return 'refused';
|
|
1156
|
+
db.exec('SAVEPOINT row_identity_reset');
|
|
1157
|
+
try {
|
|
1158
|
+
for (const { table, rowid } of stale) {
|
|
1159
|
+
writers.clearBirthFpNative(db, table, rowid);
|
|
1160
|
+
}
|
|
1161
|
+
db.exec('RELEASE SAVEPOINT row_identity_reset');
|
|
1162
|
+
}
|
|
1163
|
+
catch (error) {
|
|
1164
|
+
db.exec('ROLLBACK TO SAVEPOINT row_identity_reset');
|
|
1165
|
+
db.exec('RELEASE SAVEPOINT row_identity_reset');
|
|
1166
|
+
throw error;
|
|
1167
|
+
}
|
|
1168
|
+
return 'cleared';
|
|
1169
|
+
}
|
|
1170
|
+
/**
|
|
1171
|
+
* Record that the store's identity values follow {@link ROW_IDENTITY_RECIPE}.
|
|
1172
|
+
* The fill writes it; {@link markRowIdentityShared} writes it too on the
|
|
1173
|
+
* first receive (a device that pulls before it ever fills).
|
|
1174
|
+
*
|
|
1175
|
+
* @param db - Connection on the project `cleo.db`.
|
|
1176
|
+
*/
|
|
1177
|
+
export function writeRecipeMarker(db, writers = requireWriters()) {
|
|
1178
|
+
if (!hasTable(db, ROW_IDENTITY_META_TABLE))
|
|
1179
|
+
return;
|
|
1180
|
+
writers.writeRowIdentityMetaNative(db, ROW_IDENTITY_RECIPE_KEY, ROW_IDENTITY_RECIPE);
|
|
1181
|
+
}
|
|
1182
|
+
/**
|
|
1183
|
+
* Record that identity values are now SHARED with other devices: the recipe
|
|
1184
|
+
* marker, and {@link ROW_IDENTITY_SYNCED_KEY} (first write wins, with the
|
|
1185
|
+
* direction). Called on the first receive (a pull-first clone that never
|
|
1186
|
+
* filled) as well as before the first send; once present, the open pass
|
|
1187
|
+
* never clears an identity value (spec §12.1).
|
|
1188
|
+
*
|
|
1189
|
+
* @param db - Connection on the project `cleo.db`.
|
|
1190
|
+
* @param direction - `receive` (the merge applied a row or op) or `send`.
|
|
1191
|
+
* @param writers - The chokepoint writers.
|
|
1192
|
+
* @task T12746
|
|
1193
|
+
*/
|
|
1194
|
+
export function markRowIdentityShared(db, direction, writers = requireWriters()) {
|
|
1195
|
+
if (!hasTable(db, ROW_IDENTITY_META_TABLE))
|
|
1196
|
+
return;
|
|
1197
|
+
if (readMeta(db, ROW_IDENTITY_RECIPE_KEY) === undefined)
|
|
1198
|
+
writeRecipeMarker(db, writers);
|
|
1199
|
+
if (readMeta(db, ROW_IDENTITY_SYNCED_KEY) !== undefined)
|
|
1200
|
+
return;
|
|
1201
|
+
writers.writeRowIdentityMetaNative(db, ROW_IDENTITY_SYNCED_KEY, JSON.stringify({ first: direction, at: new Date().toISOString() }));
|
|
1202
|
+
}
|
|
1203
|
+
export function prepareRowIdentity(db, scope, options = {}) {
|
|
1204
|
+
if (ROW_IDENTITY[scope].length === 0)
|
|
1205
|
+
return null;
|
|
1206
|
+
if (!rowUidFillEnabled())
|
|
1207
|
+
return null;
|
|
1208
|
+
const log = getLogger('row-identity');
|
|
1209
|
+
try {
|
|
1210
|
+
const healed = [
|
|
1211
|
+
...(scope === 'project' ? ensureIdentityTables(db) : []),
|
|
1212
|
+
...ensureRowIdentitySchema(db, scope),
|
|
1213
|
+
];
|
|
1214
|
+
registerRowUidFunction(db, scope);
|
|
1215
|
+
const writers = options.writers ?? requireWriters();
|
|
1216
|
+
const refill = resetStaleIdentity(db, scope, writers);
|
|
1217
|
+
if (refill === 'refused') {
|
|
1218
|
+
log.error({ scope, marker: readMeta(db, ROW_IDENTITY_RECIPE_KEY) }, 'identity values predate the current recipe but uids have synced; kept as they are');
|
|
1219
|
+
}
|
|
1220
|
+
const filled = fillRowUids(db, scope, writers);
|
|
1221
|
+
if (refill !== 'refused')
|
|
1222
|
+
writeRecipeMarker(db, writers);
|
|
1223
|
+
const findings = rowIdentityFindings(db, scope);
|
|
1224
|
+
const report = { ...filled, findings, healed, refill };
|
|
1225
|
+
if (options.triggers !== false)
|
|
1226
|
+
installRowUidTriggers(db, scope);
|
|
1227
|
+
if (Object.keys(report.unfilled).length > 0) {
|
|
1228
|
+
log.warn({ scope, unfilled: report.unfilled }, 'rows left without a uid (filled on a later open)');
|
|
1229
|
+
}
|
|
1230
|
+
if (healed.length > 0 || Object.keys(report.filled).length > 0 || report.relinked > 0) {
|
|
1231
|
+
log.debug({ scope, ...report }, 'row uids filled');
|
|
1232
|
+
}
|
|
1233
|
+
return report;
|
|
1234
|
+
}
|
|
1235
|
+
catch (error) {
|
|
1236
|
+
log.error({ scope, error }, 'row uid fill failed; rows keep a NULL uid until a later open');
|
|
1237
|
+
return null;
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
//# sourceMappingURL=row-identity.js.map
|