@smartmemory/compose 0.3.7 → 0.4.0
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/.claude/skills/compose/SKILL.md +12 -3
- package/.compose-deps.json +51 -25
- package/README.md +79 -7
- package/bin/compose.js +495 -360
- package/bin/judgment-migrate.js +387 -0
- package/contracts/comp-obs-contract.schema.json +9 -3
- package/contracts/fluid-record.schema.json +209 -0
- package/contracts/lifecycle-backfill.schema.json +322 -0
- package/dist/assets/App-Z4MU-H_F.js +916 -0
- package/dist/assets/{_baseUniq-Bo837sRJ.js → _baseUniq-ClWoCPFl.js} +1 -1
- package/dist/assets/{arc-BafGpyqE.js → arc-DY26UIVo.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-BOBfUsqL.js → architectureDiagram-Q4EWVU46-6Ggq4DqJ.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-Dwodev1a.js → blockDiagram-DXYQGD6D-CH3Ked0l.js} +1 -1
- package/dist/assets/{browser-1ntj1-x_.js → browser-BWkrenen.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-CU_bhYag.js → c4Diagram-AHTNJAMY-Bk8dYilu.js} +1 -1
- package/dist/assets/channel-SnZzzh7k.js +1 -0
- package/dist/assets/{chunk-4BX2VUAB-p8WsDwnO.js → chunk-4BX2VUAB-BMR0XaAQ.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-B8h7-eR0.js → chunk-4TB4RGXK-JytR14a9.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-DxeEr98s.js → chunk-55IACEB6-B4Q97BCP.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-BYt8F151.js → chunk-EDXVE4YY-R_qarkSf.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-DGSOVeie.js → chunk-FMBD7UC4-C9s7KR9m.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-B-QdgYR2.js → chunk-OYMX7WX6-BySQzVxc.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-Du5UAZLs.js → chunk-QZHKN3VN-DdpSYZsW.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-C8JbNBSk.js → chunk-YZCP3GAM-iE_tzriw.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +1 -0
- package/dist/assets/clone-DgklGjHm.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-O1ESaqge.js → cose-bilkent-S5V4N54A-BdlU6ZX_.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-CPTmFPHw.js → dagre-KV5264BT-Cp3F5KTn.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-B3PNrWs5.js → diagram-5BDNPKRD-DiR6_2q_.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-Cscfr6vc.js → diagram-G4DWMVQ6-w0i-p5HX.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-CSfqZ-TM.js → diagram-MMDJMWI5-tIHhwUv3.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-Cg4aYS7W.js → diagram-TYMM5635-BAeY3B19.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-_ZqwG5pl.js → erDiagram-SMLLAGMA-Ckx_Knko.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-C83boxFT.js → flowDiagram-DWJPFMVM-DeoNka6J.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-CWnIjuEi.js → ganttDiagram-T4ZO3ILL-BmGnFbEg.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-DrMdxZfH.js → gitGraphDiagram-UUTBAWPF-Dk48IHsx.js} +1 -1
- package/dist/assets/{graph-RE4I7Ty7.js → graph-BNzKGvoy.js} +1 -1
- package/dist/assets/{graph-Bi99_6Yf.js → graph-CI_1htl0.js} +1 -1
- package/dist/assets/{index-Rm2RE-c0.js → index-BEfrNBp8.js} +3 -3
- package/dist/assets/index-yyrA5OZd.css +1 -0
- package/dist/assets/{infoDiagram-42DDH7IO-BLmP4Epr.js → infoDiagram-42DDH7IO-BRf827i0.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-yuWWshKN.js → ishikawaDiagram-UXIWVN3A-0kCZaeCM.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-BOfhaJov.js → journeyDiagram-VCZTEJTY-rvU7ayRt.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-Bbolde15.js → kanban-definition-6JOO6SKY-DpQwX1C5.js} +1 -1
- package/dist/assets/{layout-BSf33zm8.js → layout-BI8cXFPI.js} +1 -1
- package/dist/assets/{linear-AvSTWMqx.js → linear-a0glcDiw.js} +1 -1
- package/dist/assets/{min-QBM8H4xN.js → min-vPHfnXcC.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-BuvgtqIc.js → mindmap-definition-QFDTVHPH-D14eF-7C.js} +1 -1
- package/dist/assets/mobile-B7m9EO9D.js +17 -0
- package/dist/assets/{pieDiagram-DEJITSTG-DIzF16vh.js → pieDiagram-DEJITSTG-Cno-gETh.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-D-mbUIjS.js → quadrantDiagram-34T5L4WZ-BUQM1Hfm.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-CEs4kCLd.js → requirementDiagram-MS252O5E-pOXlN2-q.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-DFsnCr9n.js → sankeyDiagram-XADWPNL6-Crynd3_b.js} +1 -1
- package/dist/assets/{sequenceDiagram-FGHM5R23-BEJYdTjQ.js → sequenceDiagram-FGHM5R23-D9fZdCM8.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-BBXs57uY.js → stateDiagram-FHFEXIEX-CW9qVec8.js} +1 -1
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +1 -0
- package/dist/assets/{timeline-definition-GMOUNBTQ-BGvLoVAY.js → timeline-definition-GMOUNBTQ-BcHzhm_8.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-9LaBTMe0.js → vennDiagram-DHZGUBPP-BfytJcWk.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-P4MEqMTP.js → wardley-RL74JXVD-DLj-IjyB.js} +1 -1
- package/dist/assets/{wardleyDiagram-NUSXRM2D-o-tmxnlC.js → wardleyDiagram-NUSXRM2D-Ds0Ue68c.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-Dpn7V6qk.js → xychartDiagram-5P7HB3ND-vjWDXFL6.js} +1 -1
- package/dist/index.html +3 -3
- package/lib/agent-string.js +7 -5
- package/lib/append-integrity.js +81 -0
- package/lib/backfill-evidence.js +109 -0
- package/lib/bug-escalation.js +9 -0
- package/lib/build-stream-schema.js +3 -1
- package/lib/build-stream-writer.js +25 -0
- package/lib/build.js +874 -170
- package/lib/canon-guard.js +28 -6
- package/lib/canon-override.js +196 -0
- package/lib/canon-registry.js +104 -0
- package/lib/cli-commands.js +144 -0
- package/lib/codex-preflight.js +26 -13
- package/lib/colleague/context.js +215 -0
- package/lib/colleague/writeback.js +95 -0
- package/lib/completion-gate.js +1421 -0
- package/lib/completion-writer.js +47 -47
- package/lib/consumer-fanout.js +105 -11
- package/lib/coverage-gate.js +200 -0
- package/lib/deps.js +164 -7
- package/lib/dir-lock.js +170 -0
- package/lib/dispatch-ledger.js +3 -3
- package/lib/feature-json.js +1 -1
- package/lib/feature-reconciler.js +8 -0
- package/lib/feature-validator.js +64 -1
- package/lib/feature-writer.js +57 -2
- package/lib/fluid/factory.js +167 -0
- package/lib/fluid/ideabox-dates.js +73 -0
- package/lib/fluid/ideabox-migrate.js +154 -0
- package/lib/fluid/ideabox-ops.js +585 -0
- package/lib/fluid/ideabox-view.js +146 -0
- package/lib/fluid/import-ideabox.js +186 -0
- package/lib/fluid/local-provider.js +606 -0
- package/lib/fluid/provider.js +684 -0
- package/lib/fluid/record-shape.js +214 -0
- package/lib/fluid/record-store.js +328 -0
- package/lib/fluid/render-ideabox.js +261 -0
- package/lib/fluid/schema.js +40 -0
- package/lib/fluid/smartmemory-provider.js +1695 -0
- package/lib/gsd.js +63 -23
- package/lib/guard-cli.js +175 -0
- package/lib/guard-custody.js +141 -0
- package/lib/guard-descriptors.js +530 -0
- package/lib/guard-enrol.js +254 -0
- package/lib/health-score.js +1 -1
- package/lib/ideabox-cli.js +315 -0
- package/lib/ideabox.js +121 -21
- package/lib/judgment/store/index.js +9 -1
- package/lib/judgment/store/records.js +1 -1
- package/lib/judgment/trace.js +380 -0
- package/lib/judgment-decision-write.js +277 -0
- package/lib/judgment-decisions.js +466 -0
- package/lib/judgment-gen.js +5 -1
- package/lib/judgment-writer.js +56 -2
- package/lib/lifecycle-modes.js +4 -4
- package/lib/lineage.js +400 -0
- package/lib/local-claude-connector.js +52 -1
- package/lib/maya-client.js +302 -0
- package/lib/maya-config.js +53 -0
- package/lib/maya-identity.js +283 -0
- package/lib/migrate-anon.js +5 -0
- package/lib/migrate-roadmap.js +15 -0
- package/lib/new.js +13 -1
- package/lib/pipeline-compat.js +104 -0
- package/lib/policy-catalog.js +295 -0
- package/lib/policy-check.js +0 -0
- package/lib/process-termination.js +98 -0
- package/lib/resolve-workspace.js +5 -1
- package/lib/result-normalizer.js +396 -199
- package/lib/roadmap-errors.js +65 -0
- package/lib/roadmap-preservers.js +24 -4
- package/lib/roadmap-residue.js +299 -0
- package/lib/smartmemory-client.js +614 -78
- package/lib/smartmemory-config.js +54 -0
- package/lib/smartmemory-ingest.js +19 -2
- package/lib/step-prompt.js +7 -6
- package/lib/stratum-engine.js +53 -4
- package/lib/stratum-mcp-client.js +271 -36
- package/lib/test-bootstrap.js +31 -0
- package/lib/tool-inventory.js +122 -0
- package/lib/version-check.js +91 -19
- package/lib/vision-writer.js +88 -1
- package/package.json +7 -6
- package/pipelines/bug-fix.stratum.yaml +205 -211
- package/pipelines/build-quick.profiles.json +12 -0
- package/pipelines/build-quick.stratum.yaml +263 -350
- package/pipelines/content.stratum.yaml +81 -77
- package/pipelines/coverage-sweep.stratum.yaml +49 -30
- package/pipelines/plan.stratum.yaml +76 -86
- package/pipelines/refactor.stratum.yaml +125 -125
- package/pipelines/research.stratum.yaml +56 -58
- package/pipelines/review-fix.profiles.json +6 -0
- package/pipelines/review-fix.stratum.yaml +110 -83
- package/presets/team-feature.profiles.json +6 -0
- package/presets/team-feature.stratum.yaml +93 -66
- package/presets/team-research.profiles.json +6 -0
- package/presets/team-research.stratum.yaml +89 -80
- package/presets/team-review.profiles.json +8 -0
- package/presets/team-review.stratum.yaml +98 -80
- package/scripts/cost-census.mjs +70 -0
- package/scripts/guard-sign/compose-guard-sign.sh +62 -0
- package/server/agent-health.js +22 -0
- package/server/agent-hooks.js +14 -1
- package/server/agent-server.js +5 -248
- package/server/agent-spawn.js +3 -4
- package/server/agent-workspace.js +294 -0
- package/server/build-routes.js +6 -5
- package/server/build-stream-bridge.js +53 -0
- package/server/cc-session-watcher.js +4 -1
- package/server/coalescing-buffer.js +7 -1
- package/server/completion-projection.js +228 -0
- package/server/compose-mcp-tools.js +109 -23
- package/server/compose-mcp.js +88 -882
- package/server/decision-event-emit.js +41 -2
- package/server/decision-event-id.js +17 -0
- package/server/decision-events-snapshot.js +3 -0
- package/server/design-routes.js +14 -8
- package/server/feature-scan.js +76 -2
- package/server/file-watcher.js +170 -21
- package/server/ideabox-routes.js +166 -224
- package/server/index.js +70 -100
- package/server/lifecycle-guard.js +240 -10
- package/server/lifecycle-phase-history.js +276 -0
- package/server/maya-routes.js +507 -0
- package/server/mcp-tool-defs.js +940 -0
- package/server/mcp-tool-policy.js +34 -2
- package/server/model-tiers.js +22 -5
- package/server/pipeline-routes.js +21 -11
- package/server/project-root.js +58 -19
- package/server/remote-utils.js +3 -1
- package/server/schema-validator.js +7 -1
- package/server/session-manager.js +5 -6
- package/server/session-routes.js +3 -1
- package/server/stratum-client.js +57 -10
- package/server/stratum-sync.js +6 -3
- package/server/summarizer.js +3 -4
- package/server/supervisor.js +0 -1
- package/server/vision-routes.js +208 -98
- package/server/vision-server.js +86 -23
- package/server/vision-store.js +60 -6
- package/server/vision-utils.js +3 -4
- package/server/workspace-activity.js +18 -0
- package/server/workspace-middleware.js +2 -2
- package/server/workspace-runtime.js +243 -0
- package/server/worktree-gc.js +1 -0
- package/dist/assets/App-PkZzHeMj.js +0 -894
- package/dist/assets/channel-qVK_qn4E.js +0 -1
- package/dist/assets/classDiagram-6PBFFD2Q-B8UcfC1q.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-B8UcfC1q.js +0 -1
- package/dist/assets/clone-Pu3RyLUh.js +0 -1
- package/dist/assets/index-LIwREYgH.css +0 -1
- package/dist/assets/mobile-BnXEOE3U.js +0 -17
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-BqKuX4rj.js +0 -1
- package/lib/staleness.js +0 -87
- package/server/ideabox-cache.js +0 -77
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/fluid/local-provider.js — the zero-install floor provider.
|
|
3
|
+
*
|
|
4
|
+
* Implements the fluid-store seam over git-tracked record files
|
|
5
|
+
* (`lib/fluid/record-store.js`). There is exactly one record store.
|
|
6
|
+
*
|
|
7
|
+
* Storage layout:
|
|
8
|
+
* - a record → one tracked JSON file, `docs/product/fluid/records/<HANDLE>.json`
|
|
9
|
+
* - an event → one line in `docs/product/fluid/events.jsonl` (append-only)
|
|
10
|
+
*
|
|
11
|
+
* WHY NOT VISION ITEMS (S3 entry-gate ruling, 2026-08-04 — supersedes S1)
|
|
12
|
+
* -----------------------------------------------------------------------
|
|
13
|
+
* S1 hosted records on vision-store items in an additive `fluid_ext` namespace,
|
|
14
|
+
* reading `PROVIDER-SEAM` (§8k) as "the vision store's existing typed items are
|
|
15
|
+
* the expected implementation substrate of the local floor provider."
|
|
16
|
+
*
|
|
17
|
+
* That could not survive the durability question S3 opens with.
|
|
18
|
+
* `.compose/data/vision-state.json` is gitignored, so records hosted there are
|
|
19
|
+
* untracked, single-machine, and absent from CI — while `ideabox.md`, which
|
|
20
|
+
* this slice turns into a GENERATED projection of them, is tracked. Canon would
|
|
21
|
+
* have moved from a tracked file to an ignored one at the moment of cutover.
|
|
22
|
+
* Un-ignoring vision-state was not an option either: it is ~540KB of churning
|
|
23
|
+
* runtime state, unreadable as a diff and conflict-prone per parallel session.
|
|
24
|
+
*
|
|
25
|
+
* The owner ruled: track the records, split them out of vision-state. So the
|
|
26
|
+
* substrate clause of §8k no longer holds, and the direction of canon inverts —
|
|
27
|
+
* the record file is canon, and a vision item (if S4 wants one for the
|
|
28
|
+
* promotion edge) becomes a derived projection of it. The seam itself, its
|
|
29
|
+
* capability model and every handle invariant are untouched.
|
|
30
|
+
*
|
|
31
|
+
* What this bought beyond durability: the two-write compensation dance is gone
|
|
32
|
+
* (one file, one write), and so is the `_sync()` stale-snapshot hazard — there
|
|
33
|
+
* is no cached state for a second writer to invalidate.
|
|
34
|
+
*
|
|
35
|
+
* This provider declares STORAGE capabilities ONLY. It has no recall, no
|
|
36
|
+
* challenge, no conviction, no calibration, no contradiction — and per the
|
|
37
|
+
* ruling it must not pretend otherwise. Those calls inherit the base class's
|
|
38
|
+
* refusal and throw `FluidCapabilityUnavailable`. That is the intended,
|
|
39
|
+
* correct behavior of the floor, not a gap to be filled in later: the floor is
|
|
40
|
+
* the filing cabinet, and the intelligence is what a richer provider adds.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
44
|
+
import { join } from 'node:path';
|
|
45
|
+
|
|
46
|
+
import { withDirLock } from '../dir-lock.js';
|
|
47
|
+
|
|
48
|
+
import {
|
|
49
|
+
CAP,
|
|
50
|
+
FluidAmbiguousMatch,
|
|
51
|
+
FluidProvider,
|
|
52
|
+
FluidRecordNotFound,
|
|
53
|
+
KIND,
|
|
54
|
+
MUTATION_SCOPE,
|
|
55
|
+
STORAGE_CAP,
|
|
56
|
+
} from './provider.js';
|
|
57
|
+
import { FluidRecordStore } from './record-store.js';
|
|
58
|
+
// Seam-wide record rules, shared with every other provider so the two cannot
|
|
59
|
+
// drift apart (COMP-FOH C12/C16). Handle grammar, link and status vocabularies
|
|
60
|
+
// and the event-type derivation live there for the same reason: they define
|
|
61
|
+
// what a fluid record IS, which is a property of the seam, not of a store.
|
|
62
|
+
import {
|
|
63
|
+
HANDLE_PREFIX,
|
|
64
|
+
HANDLE_RE,
|
|
65
|
+
UNPATCHABLE,
|
|
66
|
+
assertHandle,
|
|
67
|
+
assertLink,
|
|
68
|
+
assertPatchable,
|
|
69
|
+
assertStatus,
|
|
70
|
+
eventTypeForUpdate,
|
|
71
|
+
normalizeRecord,
|
|
72
|
+
} from './record-shape.js';
|
|
73
|
+
import { assertValid } from './schema.js';
|
|
74
|
+
|
|
75
|
+
function nowIso() { return new Date().toISOString(); }
|
|
76
|
+
|
|
77
|
+
/** Validate at the seam so a bad status surfaces as a fluid-level error naming
|
|
78
|
+
* the legal values, rather than as a schema rejection quoting an enum the
|
|
79
|
+
* caller never sees. */
|
|
80
|
+
export class LocalFluidProvider extends FluidProvider {
|
|
81
|
+
name() { return 'local'; }
|
|
82
|
+
|
|
83
|
+
/** Storage only. Never a semantic capability — see the file header. */
|
|
84
|
+
capabilities() {
|
|
85
|
+
return new Set([STORAGE_CAP.RECORDS, STORAGE_CAP.EVENTS, STORAGE_CAP.LINKS]);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The kinds this provider owns.
|
|
90
|
+
*
|
|
91
|
+
* `position` and `joint` are still refused, but the S1 reason for it is now
|
|
92
|
+
* obsolete and the real one is stronger. S1 said they had no vision item type
|
|
93
|
+
* to live in; records are their own files now, so that constraint is gone.
|
|
94
|
+
* They stay out because THE JUDGMENT LAYER ALREADY OWNS THEM —
|
|
95
|
+
* `docs/judgment/records/positions/` and `.../joints/`, written by
|
|
96
|
+
* `judgment_position_create` and `judgment_joint_add`. Accepting them here
|
|
97
|
+
* would give one kind two stores and two canons, which is the exact
|
|
98
|
+
* fragmentation this epic exists to end.
|
|
99
|
+
*/
|
|
100
|
+
supportedKinds() {
|
|
101
|
+
return new Set([KIND.IDEA, KIND.DECISION, KIND.THREAD, KIND.QUESTION, KIND.CLUSTER]);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* MACHINE, and that is the complete answer for this provider rather than a
|
|
106
|
+
* shortfall. `lib/dir-lock.js` is a filesystem mutex: it serializes every
|
|
107
|
+
* process on this machine, which is every process that can reach a local
|
|
108
|
+
* directory. CLUSTER is unreachable here and also unnecessary.
|
|
109
|
+
*/
|
|
110
|
+
mutationScope() { return MUTATION_SCOPE.MACHINE; }
|
|
111
|
+
|
|
112
|
+
/** A directory on this machine. Two clones are two stores, not one shared one. */
|
|
113
|
+
isShared() { return false; }
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* @param {string} cwd project root
|
|
117
|
+
* @param {object} [config]
|
|
118
|
+
* @param {string} [config.recordsRoot] override (tests point this at a tmp dir)
|
|
119
|
+
*/
|
|
120
|
+
async init(cwd, config = {}) {
|
|
121
|
+
this.cwd = cwd;
|
|
122
|
+
this.store = new FluidRecordStore(cwd, config);
|
|
123
|
+
this.recordsDir = this.store.recordsDir;
|
|
124
|
+
this.eventsPath = this.store.eventsPath;
|
|
125
|
+
|
|
126
|
+
// Every mutating method serializes on this. It lives under `.compose/data/`
|
|
127
|
+
// because that path is gitignored: the lock writes an owner token INSIDE
|
|
128
|
+
// the lock dir, so siting it next to the records — which are tracked canon —
|
|
129
|
+
// would put untracked noise in tracked territory on every idea write.
|
|
130
|
+
// (`record-store.js` used to point at `.compose/locks/`, which is NOT
|
|
131
|
+
// ignored; that comment is corrected.)
|
|
132
|
+
//
|
|
133
|
+
// Keyed by the records dir rather than by cwd so two stores under one
|
|
134
|
+
// project — which in practice means two test fixtures — do not serialize
|
|
135
|
+
// against each other, while two writers to the SAME store always do.
|
|
136
|
+
const key = createHash('sha256').update(this.recordsDir).digest('hex').slice(0, 12);
|
|
137
|
+
this.lockPath = join(cwd, '.compose', 'data', `fluid-records-${key}.lock`);
|
|
138
|
+
return this;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// -------------------------------------------------------------------------
|
|
142
|
+
// Mapping
|
|
143
|
+
// -------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Normalize a record on the way out. Delegates to the shared seam rule
|
|
147
|
+
* (`record-shape.js`) so every provider fills contract defaults and clones
|
|
148
|
+
* identically — see that module for why this is not provider-private.
|
|
149
|
+
*/
|
|
150
|
+
_normalize(record) {
|
|
151
|
+
return normalizeRecord(record);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// -------------------------------------------------------------------------
|
|
155
|
+
// Handle allocation
|
|
156
|
+
// -------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Next handle for a kind: one past the highest number EVER issued for that
|
|
160
|
+
* prefix.
|
|
161
|
+
*
|
|
162
|
+
* The watermark is the max over live records AND the append-only event log —
|
|
163
|
+
* not over live records alone. Deriving it from live records only would reuse
|
|
164
|
+
* the handle of a deleted record, and handles are quoted in docs, commits and
|
|
165
|
+
* conversation, so reuse silently repoints an external citation at a different
|
|
166
|
+
* idea. The event log is never pruned, so a `created` event is a permanent
|
|
167
|
+
* tombstone for its handle.
|
|
168
|
+
*/
|
|
169
|
+
/** Every handle ever issued — live records UNION the append-only log. */
|
|
170
|
+
_issuedHandles() {
|
|
171
|
+
const issued = new Set(this.store.liveHandles());
|
|
172
|
+
for (const handle of this.store.issuedHandlesFromLog()) issued.add(handle);
|
|
173
|
+
return issued;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
_nextHandle(kind) {
|
|
177
|
+
const prefix = HANDLE_PREFIX[kind];
|
|
178
|
+
const issued = this._issuedHandles();
|
|
179
|
+
let max = 0;
|
|
180
|
+
for (const handle of issued) {
|
|
181
|
+
const m = HANDLE_RE.exec(handle);
|
|
182
|
+
if (m && m[1] === prefix) max = Math.max(max, Number(m[2]));
|
|
183
|
+
}
|
|
184
|
+
const candidate = `${prefix}-${max + 1}`;
|
|
185
|
+
// Belt and braces: the arithmetic above is only as trustworthy as the digit
|
|
186
|
+
// bound, so the automatic path makes the same membership check the
|
|
187
|
+
// caller-supplied path makes rather than trusting max + 1 to be fresh.
|
|
188
|
+
if (issued.has(candidate)) {
|
|
189
|
+
throw new Error(`fluid: handle allocation failed — ${candidate} is already issued`);
|
|
190
|
+
}
|
|
191
|
+
return candidate;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* True when this exact handle has EVER been issued — live or retired.
|
|
196
|
+
*
|
|
197
|
+
* Membership, deliberately not `n <= highest`. The import supplies its own
|
|
198
|
+
* handles to preserve existing IDEA-N citations, and it may encounter them in
|
|
199
|
+
* any order; a watermark comparison would reject IDEA-3 merely because IDEA-20
|
|
200
|
+
* had already been imported. Only a handle genuinely seen before is refused.
|
|
201
|
+
*/
|
|
202
|
+
/**
|
|
203
|
+
* Was this handle burned by a create that never finished?
|
|
204
|
+
*
|
|
205
|
+
* `createRecord` appends the tombstone BEFORE writing the record, so a crash
|
|
206
|
+
* between the two leaves a handle that is issued but has no record and never
|
|
207
|
+
* had one. The one-time import is exactly where that matters: it skips only
|
|
208
|
+
* LIVE records (`import-ideabox.js`), so a rerun retries the handle and is
|
|
209
|
+
* then refused by the issued-handle guard — leaving the migration permanently
|
|
210
|
+
* unrestartable, on the single operation that moves a project's whole idea
|
|
211
|
+
* corpus.
|
|
212
|
+
*
|
|
213
|
+
* WHAT THIS CAN AND CANNOT PROVE — stated precisely, because an earlier
|
|
214
|
+
* version of this comment claimed more than the code delivers.
|
|
215
|
+
*
|
|
216
|
+
* It establishes only: no record file, and no `deleted` event. It does NOT
|
|
217
|
+
* establish that the handle was never live. A record created normally whose
|
|
218
|
+
* file later disappeared out-of-band — a bad merge, a stray `rm`, a partial
|
|
219
|
+
* checkout — has exactly this shape, and no evidence in the log distinguishes
|
|
220
|
+
* it from a create that crashed. Do not read the name as a proof of absence.
|
|
221
|
+
*
|
|
222
|
+
* What makes reclaiming acceptable is the CALLER, not the check.
|
|
223
|
+
* `reclaimAborted` is opt-in and the one-time import is the only thing that
|
|
224
|
+
* passes it. The import supplies handles read out of the markdown, so the most
|
|
225
|
+
* a reclaim can do is restore a handle to the content the markdown already
|
|
226
|
+
* says belongs to it: a lost `IDEA-12.json` comes back as the IDEA-12 the file
|
|
227
|
+
* describes, never handed to an unrelated idea. Automatic allocation never
|
|
228
|
+
* reaches this path, and nothing else should pass the flag.
|
|
229
|
+
*
|
|
230
|
+
* Conservative where it can be. Structured events are read rather than
|
|
231
|
+
* `issuedHandlesFromLog`, whose regex matches a handle anywhere in the log
|
|
232
|
+
* including as another record's link target; and ANY `deleted` event
|
|
233
|
+
* disqualifies the handle, so a deliberately retired one stays retired.
|
|
234
|
+
*/
|
|
235
|
+
_isAbortedAllocation(handle) {
|
|
236
|
+
if (this.store.read(handle)) return false;
|
|
237
|
+
for (const event of this.store.readEvents()) {
|
|
238
|
+
if (event?.handle === handle && event.type === 'deleted') return false;
|
|
239
|
+
}
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
_handleWasIssued(handle) {
|
|
244
|
+
return this._issuedHandles().has(handle);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// -------------------------------------------------------------------------
|
|
248
|
+
// Records
|
|
249
|
+
// -------------------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
async getRecord(handle) {
|
|
252
|
+
// A malformed handle is a miss, not a crash: getRecord is the lookup a
|
|
253
|
+
// caller makes with untrusted input (a CLI argument, a URL segment), and
|
|
254
|
+
// the honest answer to "is there a record called ../../etc/passwd" is no.
|
|
255
|
+
// Paths that ALLOCATE a handle validate it strictly instead.
|
|
256
|
+
if (!HANDLE_RE.test(handle ?? '')) return null;
|
|
257
|
+
const record = this.store.read(handle);
|
|
258
|
+
return record ? this._normalize(record) : null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async listRecords(filter = {}) {
|
|
262
|
+
let records = this.store.list().map((r) => this._normalize(r));
|
|
263
|
+
if (filter.kind) records = records.filter((r) => r.kind === filter.kind);
|
|
264
|
+
if (filter.status) records = records.filter((r) => r.status === filter.status);
|
|
265
|
+
if (filter.cluster !== undefined) records = records.filter((r) => r.cluster === filter.cluster);
|
|
266
|
+
// Stable order: cluster order, then handle number. Presentation layers rely
|
|
267
|
+
// on this being deterministic so a regenerated projection does not churn.
|
|
268
|
+
return records.sort((a, b) => {
|
|
269
|
+
const ca = a.cluster_order ?? Number.MAX_SAFE_INTEGER;
|
|
270
|
+
const cb = b.cluster_order ?? Number.MAX_SAFE_INTEGER;
|
|
271
|
+
if (ca !== cb) return ca - cb;
|
|
272
|
+
return (Number(HANDLE_RE.exec(a.handle)?.[2] ?? 0)) - (Number(HANDLE_RE.exec(b.handle)?.[2] ?? 0));
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// -------------------------------------------------------------------------
|
|
277
|
+
// Mutation — every path below serializes on one lock
|
|
278
|
+
// -------------------------------------------------------------------------
|
|
279
|
+
//
|
|
280
|
+
// The lock covers TWO distinct races, and scoping it to only the first was
|
|
281
|
+
// the original mistake:
|
|
282
|
+
//
|
|
283
|
+
// 1. HANDLE ALLOCATION. `_nextHandle` reads the records directory AND the
|
|
284
|
+
// raw events log (`_issuedHandles`), so two creates can agree on the same
|
|
285
|
+
// next handle. The pre-write tombstone narrows that window; it does not
|
|
286
|
+
// close it. Note the lock must therefore cover the LOG, not just the
|
|
287
|
+
// record file — guarding the directory alone still permits a stale log
|
|
288
|
+
// read.
|
|
289
|
+
//
|
|
290
|
+
// 2. LOST UPDATES on every other mutation. `updateRecord`, `appendDiscussion`,
|
|
291
|
+
// `addLink` and `removeLink` are all read-modify-write against one record
|
|
292
|
+
// file: both writers read, both merge onto their own snapshot, and the
|
|
293
|
+
// later write erases the earlier one. S3a accepted this while nothing was
|
|
294
|
+
// wired ("it can only lose an update to the one contended record"). The
|
|
295
|
+
// CLI cutover is what makes it reachable, so it is fixed here rather than
|
|
296
|
+
// inherited.
|
|
297
|
+
//
|
|
298
|
+
// One lock rather than per-record locks: idea writes are human-scale, the
|
|
299
|
+
// critical sections are a few small-file operations, and a single lock also
|
|
300
|
+
// orders allocation against mutation. Two granularities would buy nothing and
|
|
301
|
+
// cost a deadlock ordering rule.
|
|
302
|
+
//
|
|
303
|
+
// Each public method is a thin wrapper over a `*Locked` body because
|
|
304
|
+
// `withDirLock` is NOT reentrant and these paths call one another's helpers.
|
|
305
|
+
// `appendEvent` deliberately stays unlocked: it is an O_APPEND write of a
|
|
306
|
+
// single line to an append-only log, it is called from inside locked bodies,
|
|
307
|
+
// and locking it would deadlock every one of them.
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* @param {object} input record fields; `handle` may be supplied by the
|
|
311
|
+
* one-time import to preserve existing IDEA-N citations, otherwise it is
|
|
312
|
+
* allocated.
|
|
313
|
+
*/
|
|
314
|
+
/**
|
|
315
|
+
* Genuinely atomic here (F6-1): the lookup and the create happen inside ONE
|
|
316
|
+
* hold of the mutation lock, so a second writer racing the same title waits,
|
|
317
|
+
* then finds the record the first one made instead of creating a twin.
|
|
318
|
+
*
|
|
319
|
+
* `_createRecordLocked` is the non-locking inner create this composes on —
|
|
320
|
+
* calling the public `createRecord` would deadlock, since `withDirLock` is
|
|
321
|
+
* deliberately not reentrant ("callers compose by locking once at the
|
|
322
|
+
* outermost mutating boundary").
|
|
323
|
+
*/
|
|
324
|
+
async findOrCreateRecord({ kind, title }, input = {}) {
|
|
325
|
+
return withDirLock(this.lockPath, async () => {
|
|
326
|
+
const matches = this.store.list()
|
|
327
|
+
.map((r) => this._normalize(r))
|
|
328
|
+
.filter((r) => r.kind === kind && r.title.toLowerCase() === String(title).toLowerCase());
|
|
329
|
+
if (matches.length > 1) {
|
|
330
|
+
throw new FluidAmbiguousMatch(kind, title, matches.map((m) => m.handle));
|
|
331
|
+
}
|
|
332
|
+
if (matches.length === 1) return { record: matches[0], created: false };
|
|
333
|
+
return { record: await this._createRecordLocked({ ...input, kind, title }), created: true };
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async createRecord(input) {
|
|
338
|
+
return withDirLock(this.lockPath, () => this._createRecordLocked(input));
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async updateRecord(handle, patch) {
|
|
342
|
+
return withDirLock(this.lockPath, () => this._updateRecordLocked(handle, patch));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async appendDiscussion(handle, entry) {
|
|
346
|
+
return withDirLock(this.lockPath, () => this._appendDiscussionLocked(handle, entry));
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async deleteRecord(handle) {
|
|
350
|
+
return withDirLock(this.lockPath, () => this._deleteRecordLocked(handle));
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async addLink(handle, link) {
|
|
354
|
+
return withDirLock(this.lockPath, () => this._addLinkLocked(handle, link));
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async removeLink(handle, link) {
|
|
358
|
+
return withDirLock(this.lockPath, () => this._removeLinkLocked(handle, link));
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async _createRecordLocked(input) {
|
|
362
|
+
const kind = input.kind ?? KIND.IDEA;
|
|
363
|
+
this.requireKind(kind);
|
|
364
|
+
if (!input.title) throw new Error('fluid: createRecord requires a title');
|
|
365
|
+
|
|
366
|
+
let handle;
|
|
367
|
+
if (input.handle === undefined) {
|
|
368
|
+
handle = this._nextHandle(kind);
|
|
369
|
+
} else {
|
|
370
|
+
handle = assertHandle(input.handle, kind);
|
|
371
|
+
// Checked against every handle EVER issued, not just the live ones. A
|
|
372
|
+
// retired handle is still spoken for: reissuing it would repoint existing
|
|
373
|
+
// citations at a different record, which is precisely the outcome the
|
|
374
|
+
// tombstones exist to prevent, and the caller-supplied path is the one
|
|
375
|
+
// that can request it explicitly.
|
|
376
|
+
if (this._handleWasIssued(handle) && !(input.reclaimAborted && this._isAbortedAllocation(handle))) {
|
|
377
|
+
throw new Error(
|
|
378
|
+
`fluid: handle ${handle} has already been issued and cannot be reused ` +
|
|
379
|
+
`(handles are external citations; retired ones stay retired)`
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
for (const link of input.links ?? []) assertLink(link);
|
|
385
|
+
const status = assertStatus(input.status ?? 'new');
|
|
386
|
+
const record = {
|
|
387
|
+
handle,
|
|
388
|
+
kind,
|
|
389
|
+
title: input.title,
|
|
390
|
+
body: input.body ?? '',
|
|
391
|
+
status,
|
|
392
|
+
status_label: input.status_label ?? null,
|
|
393
|
+
priority: input.priority ?? null,
|
|
394
|
+
effort: input.effort ?? null,
|
|
395
|
+
impact: input.impact ?? null,
|
|
396
|
+
cluster: input.cluster ?? null,
|
|
397
|
+
cluster_order: input.cluster_order ?? null,
|
|
398
|
+
tags: input.tags ?? [],
|
|
399
|
+
source: input.source ?? null,
|
|
400
|
+
links: input.links ?? [],
|
|
401
|
+
killed: input.killed ?? null,
|
|
402
|
+
discussion: input.discussion ?? [],
|
|
403
|
+
provenance: {
|
|
404
|
+
origin: input.provenance?.origin ?? 'cli:ideabox',
|
|
405
|
+
recorded_at: input.provenance?.recorded_at ?? nowIso(),
|
|
406
|
+
author: input.provenance?.author ?? null,
|
|
407
|
+
},
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
// Validate against the published contract, not a hand-rolled subset. A
|
|
411
|
+
// bespoke field check drifts from the schema silently, accepting what the
|
|
412
|
+
// contract forbids while the contract keeps claiming otherwise.
|
|
413
|
+
//
|
|
414
|
+
// Validated in its FINAL persisted form — id and timestamps included —
|
|
415
|
+
// rather than with an `id: 'pending'` stand-in, so what the contract
|
|
416
|
+
// approved is byte-for-byte what reaches disk.
|
|
417
|
+
const now = nowIso();
|
|
418
|
+
const persisted = this._normalize({
|
|
419
|
+
...record,
|
|
420
|
+
// Provider-assigned and provider-scoped, per the contract: `id` changes
|
|
421
|
+
// when a record is imported into a different provider, while `handle`
|
|
422
|
+
// survives the swap because it is quoted in docs and commits.
|
|
423
|
+
id: randomUUID(),
|
|
424
|
+
created_at: now,
|
|
425
|
+
updated_at: now,
|
|
426
|
+
});
|
|
427
|
+
assertValid('record', persisted, 'record');
|
|
428
|
+
|
|
429
|
+
// The tombstone is written BEFORE the record exists.
|
|
430
|
+
//
|
|
431
|
+
// Ordering matters and this is the only safe direction. Persisting the
|
|
432
|
+
// record first and appending the tombstone after means a failed append
|
|
433
|
+
// leaves a discoverable record whose handle was never burned — delete it
|
|
434
|
+
// and the handle is reissued, defeating the invariant. Burning first can
|
|
435
|
+
// at worst waste a handle if creation then fails, and a wasted handle is
|
|
436
|
+
// free while a reissued one is unrecoverable.
|
|
437
|
+
await this.appendEvent({
|
|
438
|
+
handle,
|
|
439
|
+
type: input.provenance?.origin === 'import:ideabox' ? 'imported' : 'created',
|
|
440
|
+
at: now,
|
|
441
|
+
detail: { kind },
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
// One file, one write. The S1 version wrote a vision item and then its
|
|
445
|
+
// namespace, and needed a compensating delete when the second failed or the
|
|
446
|
+
// record would exist as an inert half. A record is a single file now, and
|
|
447
|
+
// the atomic rename inside the store makes it appear whole or not at all.
|
|
448
|
+
this.store.write(persisted);
|
|
449
|
+
return this._normalize(persisted);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async _updateRecordLocked(handle, patch) {
|
|
453
|
+
const existing = this.store.read(handle);
|
|
454
|
+
if (!existing) throw new FluidRecordNotFound(handle, this.name());
|
|
455
|
+
|
|
456
|
+
// Refuse an unpatchable field rather than silently dropping it — shared with
|
|
457
|
+
// every provider, because a provider that allows one is not a simpler
|
|
458
|
+
// provider, it is one with a different contract.
|
|
459
|
+
assertPatchable(patch, this.name());
|
|
460
|
+
|
|
461
|
+
const current = this._normalize(existing);
|
|
462
|
+
const merged = {
|
|
463
|
+
...current,
|
|
464
|
+
...patch,
|
|
465
|
+
...Object.fromEntries(UNPATCHABLE.map((f) => [f, current[f]])),
|
|
466
|
+
updated_at: nowIso(),
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
// Validate the RESULT against the contract before anything reaches disk —
|
|
470
|
+
// the whole shape, not the two fields that were easy to check by hand.
|
|
471
|
+
//
|
|
472
|
+
// ORDER IS LOAD-BEARING: validate the RAW merge, then normalize. Normalizing
|
|
473
|
+
// first would hand the schema an already-sanitized object, and the schema
|
|
474
|
+
// would approve what the sanitizer had quietly repaired. Two silent
|
|
475
|
+
// failures live in that gap, both of which report success:
|
|
476
|
+
// - `{ links: null }` becomes `[]`, erasing every link
|
|
477
|
+
// - `{ titel: 'x' }` is dropped, so a misspelled field writes nothing
|
|
478
|
+
// The contract already rejects both (`links` is typed, and the record
|
|
479
|
+
// definition is `additionalProperties: false`) — but only if it sees them.
|
|
480
|
+
assertStatus(merged.status);
|
|
481
|
+
assertValid('record', merged, 'record');
|
|
482
|
+
for (const link of merged.links) assertLink(link);
|
|
483
|
+
|
|
484
|
+
const next = this._normalize(merged);
|
|
485
|
+
const eventType = eventTypeForUpdate(current, next, patch);
|
|
486
|
+
|
|
487
|
+
this.store.write(next);
|
|
488
|
+
|
|
489
|
+
await this.appendEvent({
|
|
490
|
+
handle,
|
|
491
|
+
type: eventType,
|
|
492
|
+
at: nowIso(),
|
|
493
|
+
detail: { fields: Object.keys(patch) },
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
return next;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** The only way discussion grows. Append-only in the API, not merely by
|
|
500
|
+
* convention in the contract — a deliberation trail that can be rewritten is
|
|
501
|
+
* not evidence. */
|
|
502
|
+
async _appendDiscussionLocked(handle, entry) {
|
|
503
|
+
const existing = this.store.read(handle);
|
|
504
|
+
if (!existing) throw new FluidRecordNotFound(handle, this.name());
|
|
505
|
+
if (!entry?.text) throw new Error('fluid: a discussion entry requires text');
|
|
506
|
+
|
|
507
|
+
const record = this._normalize(existing);
|
|
508
|
+
record.discussion.push({
|
|
509
|
+
at: entry.at ?? nowIso(),
|
|
510
|
+
text: entry.text,
|
|
511
|
+
author: entry.author ?? null,
|
|
512
|
+
});
|
|
513
|
+
record.updated_at = nowIso();
|
|
514
|
+
assertValid('record', record, 'record');
|
|
515
|
+
this.store.write(record);
|
|
516
|
+
await this.appendEvent({ handle, type: 'discussed', at: nowIso(), detail: {} });
|
|
517
|
+
return record;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Hard delete. NOT the lifecycle path — killing an idea is
|
|
522
|
+
* `updateRecord(handle, {status: 'killed'})`, which keeps the record and its
|
|
523
|
+
* reasoning. This removes the record entirely and exists for administrative
|
|
524
|
+
* correction.
|
|
525
|
+
*
|
|
526
|
+
* Because it destroys a record that may carry deliberation evidence, and the
|
|
527
|
+
* contract calls that evidence append-only, the discussion is carried into the
|
|
528
|
+
* append-only log before the record goes. Otherwise "append-only" would be
|
|
529
|
+
* true of every path except the one that actually erases it, and the surviving
|
|
530
|
+
* `discussed` events carry no text.
|
|
531
|
+
*/
|
|
532
|
+
async _deleteRecordLocked(handle) {
|
|
533
|
+
const existing = this.store.read(handle);
|
|
534
|
+
if (!existing) throw new FluidRecordNotFound(handle, this.name());
|
|
535
|
+
const record = this._normalize(existing);
|
|
536
|
+
|
|
537
|
+
await this.appendEvent({
|
|
538
|
+
handle,
|
|
539
|
+
type: 'deleted',
|
|
540
|
+
at: nowIso(),
|
|
541
|
+
detail: { kind: record.kind, title: record.title, discussion: record.discussion },
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
// rmSync throws on a failed unlink, so a delete that did not reach disk
|
|
545
|
+
// surfaces instead of returning ok while the record is still there to
|
|
546
|
+
// reappear on the next read.
|
|
547
|
+
this.store.remove(handle);
|
|
548
|
+
return { ok: true };
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// -------------------------------------------------------------------------
|
|
552
|
+
// Links
|
|
553
|
+
// -------------------------------------------------------------------------
|
|
554
|
+
|
|
555
|
+
async _addLinkLocked(handle, link) {
|
|
556
|
+
assertLink(link);
|
|
557
|
+
const existing = this.store.read(handle);
|
|
558
|
+
if (!existing) throw new FluidRecordNotFound(handle, this.name());
|
|
559
|
+
const record = this._normalize(existing);
|
|
560
|
+
const exists = record.links.some((l) => l.type === link.type && l.target === link.target);
|
|
561
|
+
// Idempotent: re-adding an existing link is a no-op, and deliberately emits
|
|
562
|
+
// no event. A `linked` event per repeat would inflate the lifecycle history
|
|
563
|
+
// that a conviction or calibration layer reads as signal.
|
|
564
|
+
if (!exists) {
|
|
565
|
+
record.links.push({ ...link });
|
|
566
|
+
record.updated_at = nowIso();
|
|
567
|
+
this.store.write(record);
|
|
568
|
+
await this.appendEvent({ handle, type: 'linked', at: nowIso(), detail: { ...link } });
|
|
569
|
+
}
|
|
570
|
+
return record;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
async _removeLinkLocked(handle, link) {
|
|
574
|
+
const existing = this.store.read(handle);
|
|
575
|
+
if (!existing) throw new FluidRecordNotFound(handle, this.name());
|
|
576
|
+
const record = this._normalize(existing);
|
|
577
|
+
const before = record.links.length;
|
|
578
|
+
record.links = record.links.filter((l) => !(l.type === link.type && l.target === link.target));
|
|
579
|
+
// Matching addLink: only a real change touches the record or the timestamp.
|
|
580
|
+
if (record.links.length !== before) {
|
|
581
|
+
record.updated_at = nowIso();
|
|
582
|
+
this.store.write(record);
|
|
583
|
+
}
|
|
584
|
+
return record;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// -------------------------------------------------------------------------
|
|
588
|
+
// Lifecycle events
|
|
589
|
+
// -------------------------------------------------------------------------
|
|
590
|
+
|
|
591
|
+
async appendEvent(event) {
|
|
592
|
+
const full = { at: nowIso(), ...event };
|
|
593
|
+
// The log is append-only, so a malformed entry is permanent. Validate before
|
|
594
|
+
// it lands rather than discovering it on a read that no longer has a caller
|
|
595
|
+
// to blame.
|
|
596
|
+
assertValid('lifecycle_event', full, 'lifecycle event');
|
|
597
|
+
return this.store.appendEvent(full);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
async readEvents(handle) {
|
|
601
|
+
const all = this.store.readEvents();
|
|
602
|
+
return handle ? all.filter((e) => e.handle === handle) : all;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
export { CAP };
|