functionalscript 0.38.0 → 0.40.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.
Files changed (137) hide show
  1. package/README.md +1 -1
  2. package/fjs/bnf/descent/module.f.d.ts +43 -2
  3. package/fjs/bnf/descent/module.f.js +37 -12
  4. package/fjs/bnf/descent/proof.f.d.ts +1 -0
  5. package/fjs/bnf/descent/proof.f.js +73 -32
  6. package/fjs/bnf/ll1/module.f.js +2 -2
  7. package/fjs/bnf/token_symbol/module.f.d.ts +38 -0
  8. package/fjs/bnf/token_symbol/module.f.js +48 -0
  9. package/fjs/bnf/token_symbol/proof.f.d.ts +10 -0
  10. package/fjs/bnf/token_symbol/proof.f.js +37 -0
  11. package/fjs/cas/cli/module.f.js +6 -9
  12. package/fjs/cas/evo/module.f.d.ts +77 -13
  13. package/fjs/cas/evo/module.f.js +130 -18
  14. package/fjs/cas/evo/proof.f.d.ts +7 -0
  15. package/fjs/cas/evo/proof.f.js +120 -1
  16. package/fjs/cas/module.f.js +64 -37
  17. package/fjs/cas/proof.f.d.ts +3 -9
  18. package/fjs/cas/proof.f.js +94 -74
  19. package/fjs/ci/bun/module.f.js +3 -3
  20. package/fjs/ci/common/module.f.d.ts +1 -2
  21. package/fjs/ci/common/module.f.js +1 -5
  22. package/fjs/ci/config/module.f.d.ts +11 -8
  23. package/fjs/ci/config/module.f.js +24 -9
  24. package/fjs/ci/deno/module.f.js +3 -3
  25. package/fjs/ci/module.f.js +13 -7
  26. package/fjs/ci/nix/module.f.d.ts +61 -0
  27. package/fjs/ci/nix/module.f.js +92 -0
  28. package/fjs/ci/nix/proof.f.d.ts +23 -0
  29. package/fjs/ci/nix/proof.f.js +109 -0
  30. package/fjs/ci/node/module.f.d.ts +23 -1
  31. package/fjs/ci/node/module.f.js +58 -12
  32. package/fjs/ci/proof.f.d.ts +2 -0
  33. package/fjs/ci/proof.f.js +49 -9
  34. package/fjs/cli/module.f.js +2 -2
  35. package/fjs/common/monoid/module.f.d.ts +46 -2
  36. package/fjs/common/monoid/module.f.js +44 -0
  37. package/fjs/common/monoid/proof.f.d.ts +5 -0
  38. package/fjs/common/monoid/proof.f.js +27 -1
  39. package/fjs/dev/module.f.d.ts +1 -0
  40. package/fjs/dev/module.f.js +38 -27
  41. package/fjs/dev/update/module.f.d.ts +11 -0
  42. package/fjs/dev/update/module.f.js +20 -0
  43. package/fjs/dev/update/proof.f.d.ts +7 -0
  44. package/fjs/dev/update/proof.f.js +35 -0
  45. package/fjs/djs/ast/module.f.d.ts +47 -0
  46. package/fjs/djs/ast/module.f.js +9 -0
  47. package/fjs/djs/module.f.js +5 -8
  48. package/fjs/djs/parser/module.f.d.ts +3 -0
  49. package/fjs/djs/parser/module.f.js +17 -0
  50. package/fjs/djs/tokenizer/module.f.js +2 -2
  51. package/fjs/djs/tokenizer/proof.f.d.ts +1 -0
  52. package/fjs/djs/tokenizer/proof.f.js +70 -16
  53. package/fjs/djs/transpiler/module.f.js +6 -6
  54. package/fjs/effects/eff/module.f.d.ts +72 -0
  55. package/fjs/effects/eff/module.f.js +46 -0
  56. package/fjs/effects/eff/proof.f.d.ts +11 -0
  57. package/fjs/effects/eff/proof.f.js +58 -0
  58. package/fjs/effects/list/module.f.d.ts +11 -5
  59. package/fjs/effects/list/module.f.js +2 -1
  60. package/fjs/effects/memory/proof.f.js +7 -8
  61. package/fjs/effects/module.f.d.ts +334 -52
  62. package/fjs/effects/module.f.js +253 -45
  63. package/fjs/effects/node/memory/proof.js +3 -2
  64. package/fjs/effects/node/module.d.ts +3 -3
  65. package/fjs/effects/node/module.f.d.ts +18 -8
  66. package/fjs/effects/node/module.f.js +31 -11
  67. package/fjs/effects/node/module.js +12 -12
  68. package/fjs/effects/node/proof.f.d.ts +1 -0
  69. package/fjs/effects/node/proof.f.js +32 -11
  70. package/fjs/effects/node/virtual/module.f.js +1 -1
  71. package/fjs/effects/proof.f.d.ts +32 -6
  72. package/fjs/effects/proof.f.js +131 -39
  73. package/fjs/emergent_testing/all.test.js +2 -1
  74. package/fjs/emergent_testing/module.f.d.ts +3 -3
  75. package/fjs/emergent_testing/module.f.js +63 -49
  76. package/fjs/emergent_testing/proof.f.d.ts +4 -0
  77. package/fjs/emergent_testing/proof.f.js +54 -21
  78. package/fjs/emergent_testing/scenarios/thenable.pass.js +1 -1
  79. package/fjs/fsc/module.f.js +4 -4
  80. package/fjs/fsm/module.f.js +1 -1
  81. package/fjs/js/tokenizer/module.f.d.ts +1 -0
  82. package/fjs/js/tokenizer/module.f.js +13 -6
  83. package/fjs/{cas/mcp → mcp/cas}/module.f.d.ts +3 -25
  84. package/fjs/{cas/mcp → mcp/cas}/module.f.js +26 -64
  85. package/fjs/mcp/evo/module.f.d.ts +27 -0
  86. package/fjs/mcp/evo/module.f.js +94 -0
  87. package/fjs/{cas/evo/mcp → mcp/evo}/proof.f.d.ts +2 -0
  88. package/fjs/{cas/evo/mcp → mcp/evo}/proof.f.js +40 -9
  89. package/fjs/mcp/module.f.d.ts +54 -237
  90. package/fjs/mcp/module.f.js +55 -258
  91. package/fjs/mcp/proof.f.d.ts +43 -32
  92. package/fjs/mcp/proof.f.js +509 -200
  93. package/fjs/media/nix/module.f.d.ts +30 -0
  94. package/fjs/media/nix/module.f.js +166 -0
  95. package/fjs/media/nix/proof.f.d.ts +32 -0
  96. package/fjs/media/nix/proof.f.js +127 -0
  97. package/fjs/media/type/module.f.js +2 -2
  98. package/fjs/media/type/proof.f.js +7 -10
  99. package/fjs/module.f.js +5 -9
  100. package/fjs/protocol/json_rpc/module.f.d.ts +114 -0
  101. package/fjs/{media/json/rpc → protocol/json_rpc}/module.f.js +3 -3
  102. package/fjs/{media/json/rpc → protocol/json_rpc}/proof.f.js +3 -3
  103. package/fjs/protocol/mcp/module.f.d.ts +239 -0
  104. package/fjs/protocol/mcp/module.f.js +272 -0
  105. package/fjs/protocol/mcp/proof.f.d.ts +34 -0
  106. package/fjs/protocol/mcp/proof.f.js +208 -0
  107. package/fjs/{mcp → protocol/mcp}/stdio/module.f.d.ts +7 -7
  108. package/fjs/{mcp → protocol/mcp}/stdio/module.f.js +22 -22
  109. package/fjs/{mcp → protocol/mcp}/stdio/proof.f.js +9 -9
  110. package/fjs/types/bigint/module.f.js +4 -3
  111. package/fjs/types/bit_vec/module.f.js +5 -15
  112. package/fjs/types/btree/remove/module.f.d.ts +5 -0
  113. package/fjs/types/btree/remove/module.f.js +12 -0
  114. package/fjs/types/list/module.f.d.ts +21 -0
  115. package/fjs/types/list/module.f.js +17 -0
  116. package/fjs/types/list/proof.f.d.ts +1 -0
  117. package/fjs/types/list/proof.f.js +24 -1
  118. package/fjs/types/number/module.f.js +2 -1
  119. package/fjs/types/range_map/module.f.d.ts +14 -13
  120. package/fjs/types/range_map/module.f.js +18 -13
  121. package/fjs/types/range_map/proof.f.js +26 -39
  122. package/fjs/types/range_set/module.f.d.ts +5 -0
  123. package/fjs/types/range_set/module.f.js +16 -0
  124. package/fjs/types/range_set/proof.f.d.ts +1 -0
  125. package/fjs/types/range_set/proof.f.js +19 -0
  126. package/fjs/types/string/module.f.js +10 -2
  127. package/fjs/website/module.f.js +2 -3
  128. package/package.json +5 -5
  129. package/fjs/cas/evo/mcp/module.f.d.ts +0 -19
  130. package/fjs/cas/evo/mcp/module.f.js +0 -56
  131. package/fjs/cas/mcp/proof.f.d.ts +0 -45
  132. package/fjs/cas/mcp/proof.f.js +0 -545
  133. package/fjs/ci/playwright/module.f.d.ts +0 -2
  134. package/fjs/ci/playwright/module.f.js +0 -25
  135. package/fjs/media/json/rpc/module.f.d.ts +0 -114
  136. /package/fjs/{media/json/rpc → protocol/json_rpc}/proof.f.d.ts +0 -0
  137. /package/fjs/{mcp → protocol/mcp}/stdio/proof.f.d.ts +0 -0
@@ -30,6 +30,13 @@
30
30
  * both fold through the same {@link addRevisionToCache}, the former over the
31
31
  * whole store at once, the latter incrementally for one new revision.
32
32
  *
33
+ * Not everything here is cache-backed: {@link readRevision} answers "what is
34
+ * the revision at this hash" straight from the store, decoded, validated, and
35
+ * with every hash canonicalized ({@link toRevisionData}). It is the typed
36
+ * counterpart of a raw blob read — {@link addRevision} validates on the way
37
+ * in, {@link readRevision} on the way out — and both speak the same
38
+ * {@link RevisionData} vocabulary.
39
+ *
33
40
  * @module
34
41
  */
35
42
  import { type Effect, type Operation } from '../../effects/module.f.ts';
@@ -44,21 +51,50 @@ export type Hash = string;
44
51
  /** The identity of a mutable object whose revisions are being evolved. */
45
52
  export type Subject = string;
46
53
  /**
47
- * Input to {@link addRevision}: everything the caller supplies for a new
48
- * revision. Both `subject` and `snapshot` are input conveniences the write
49
- * boundary resolves, because the stored `vnd.fjs.revision` blob requires
50
- * them explicitly (see `fjs/media/revision`): `subject`, when omitted, is
51
- * inherited from the single parent's own `subject` (see
52
- * {@link resolveSubject}); `snapshot`, when omitted, is resolved from the
53
- * parents (see {@link resolveSnapshot}) the inference the format used to
54
- * carry, run once here with the parents already in hand. `generation` is
55
- * never an input: {@link computeGeneration} derives the authoritative value.
54
+ * The semantic content of a revision: the media-level `Revision`
55
+ * (`fjs/media/revision`) minus `dialect`, which is a serialization tag
56
+ * carrying no information once decoding has validated it. One structure is
57
+ * the vocabulary of both directions {@link addRevision} takes it,
58
+ * {@link readRevision} returns it so what you add is what you get back, and
59
+ * a value read back can be fed to `add` unchanged, without stripping fields.
60
+ * Every direction-specific field is therefore optional, with the guarantees
61
+ * documented per field rather than typed:
62
+ *
63
+ * - `parents` — required in both directions; index 0 is the mainline parent.
64
+ * On output the entries are canonical cBase32 spellings
65
+ * ({@link canonicalHash}), so they compare directly against {@link Evo.head}
66
+ * output.
67
+ * - `subject` — input: absent means "inherit from my single parent" (see
68
+ * {@link resolveSubject}); output: always present.
69
+ * - `snapshot` — input: absent is a write-boundary convenience resolved from
70
+ * the parents (see {@link resolveSnapshot}), because the stored blob
71
+ * requires it explicitly; output: always present, canonical, and exactly
72
+ * what the stored blob names.
73
+ * - `archived` — genuinely optional in both directions; the only field that
74
+ * can be absent from a read.
75
+ * - `generation` — input: **ignored**, {@link computeGeneration} derives the
76
+ * authoritative value from the parents; output: always present. It exists as
77
+ * an input field only so a read value round-trips into `add` as-is.
78
+ *
79
+ * Relaxing what the format requires is the point, not an oversight. The stored
80
+ * `vnd.fjs.revision` blob requires `subject`, `snapshot` and `generation`, and
81
+ * {@link readRevision} does return all three — but this is not that blob's
82
+ * type. It is the type of the API that *reads and constructs* revisions, and
83
+ * its purpose is to make both easy: `add` asks only for what a caller can
84
+ * actually know, resolving or computing the rest at the write boundary, and a
85
+ * read hands back a value that goes straight into `add` again. A separate
86
+ * output type with `subject`/`snapshot`/`generation` required would state that
87
+ * one direction more precisely, at the cost of splitting the one vocabulary
88
+ * into two — the trade this API declines, deliberately and not by omission.
89
+ * `fjs/media/revision`'s `Revision` remains the all-required type of the
90
+ * stored blob for anyone who wants it.
56
91
  */
57
- export type AddRevision = {
92
+ export type RevisionData = {
58
93
  readonly parents: readonly Hash[];
59
94
  readonly snapshot?: Hash | undefined;
60
95
  readonly subject?: Subject | undefined;
61
96
  readonly archived?: true | undefined;
97
+ readonly generation?: number | undefined;
62
98
  };
63
99
  /**
64
100
  * Per-subject bookkeeping: every revision hash seen for the subject, and
@@ -101,7 +137,7 @@ export declare const initEvo: <O extends Operation>(cas: Cas<O>) => Effect<O | M
101
137
  * Folds `value` — bytes already written to a `Cas` at `hash` by some other
102
138
  * caller — into the cache at `cacheKey` if it decodes as a `vnd.fjs.revision`
103
139
  * ({@link decodeRevisionVec}); a no-op otherwise. `cas_add`/`evo_add`
104
- * (`fjs/cas/mcp`) are two ways to reach the same store — a plain `cas_add`
140
+ * (`fjs/mcp`) are two ways to reach the same store — a plain `cas_add`
105
141
  * call can store a revision blob without going through {@link addRevision},
106
142
  * and this is what keeps the cache honest about it without rescanning the
107
143
  * whole store.
@@ -123,8 +159,27 @@ export declare const syncRevision: (cacheKey: Key<Cache>) => (hash: Vec) => (val
123
159
  * revision, a blob too large to encode, or a store write failure — is
124
160
  * reported as `error(message)` rather than thrown, so a caller (e.g. an MCP
125
161
  * tool handler) can surface it without a `throw`/`catch`.
162
+ *
163
+ * `input.generation` is ignored: it exists on {@link RevisionData} only so a
164
+ * value read back by {@link readRevision} round-trips into `add` unchanged,
165
+ * and {@link computeGeneration} always derives the stored value from the
166
+ * resolved parents.
167
+ */
168
+ export declare const addRevision: <O extends Operation>(cas: Cas<O>) => (cacheKey: Key<Cache>) => (input: RevisionData) => Effect<O | MemOp, Result<Hash, string>>;
169
+ /**
170
+ * Reads the revision at `hash`: decoded, validated, and canonicalized
171
+ * ({@link toRevisionData}). The typed counterpart of a raw `cas.read` — the
172
+ * store's generic "bytes by hash" read stays available for arbitrary content
173
+ * (snapshots and everything else that is not a revision), while this is the
174
+ * view for revisions specifically: `add` validates on the way in, this
175
+ * validates on the way out, so no caller re-implements JSON parsing, schema
176
+ * validation, the `dialect` check, or hash canonicalization.
177
+ *
178
+ * Every way to fail is its own message: `hash` is not cBase32, the store has
179
+ * nothing under it, the store has it but could not deliver it, or what it
180
+ * holds is not a `vnd.fjs.revision` (see {@link decodeReadRevision}).
126
181
  */
127
- export declare const addRevision: <O extends Operation>(cas: Cas<O>) => (cacheKey: Key<Cache>) => (input: AddRevision) => Effect<O | MemOp, Result<Hash, string>>;
182
+ export declare const readRevision: <O extends Operation>(cas: Cas<O>) => (hash: Hash) => Effect<O, Result<RevisionData, string>>;
128
183
  /** The Evo API described in `fjs/cas/evo/README.md`, bound to a `Cas<O>` and its cache slot. */
129
184
  export type Evo<O extends Operation> = {
130
185
  /** Returns every subject with at least one stored revision. */
@@ -132,7 +187,16 @@ export type Evo<O extends Operation> = {
132
187
  /** Returns the current head hashes of `subject` (empty if unknown). */
133
188
  readonly head: (subject: Subject) => Effect<MemOp, readonly Hash[]>;
134
189
  /** Adds a new head; see {@link addRevision}. */
135
- readonly add: (input: AddRevision) => Effect<O | MemOp, Result<Hash, string>>;
190
+ readonly add: (input: RevisionData) => Effect<O | MemOp, Result<Hash, string>>;
191
+ /**
192
+ * The revision at `hash`, decoded, validated, and canonicalized; see
193
+ * {@link readRevision}. Served from the store today, so the `MemOp` in the
194
+ * declared operation set is unused — it is there because a revision is
195
+ * immutable and therefore memoizable in the same cache slot the other
196
+ * operations read, which must not become a breaking change to this type
197
+ * when it happens.
198
+ */
199
+ readonly revision: (hash: Hash) => Effect<O | MemOp, Result<RevisionData, string>>;
136
200
  };
137
201
  /** Builds the {@link Evo} API over `cas`, backed by the cache at `cacheKey` (see {@link initEvo}). */
138
202
  export declare const evo: <O extends Operation>(cas: Cas<O>) => (cacheKey: Key<Cache>) => Evo<O>;
@@ -30,9 +30,17 @@
30
30
  * both fold through the same {@link addRevisionToCache}, the former over the
31
31
  * whole store at once, the latter incrementally for one new revision.
32
32
  *
33
+ * Not everything here is cache-backed: {@link readRevision} answers "what is
34
+ * the revision at this hash" straight from the store, decoded, validated, and
35
+ * with every hash canonicalized ({@link toRevisionData}). It is the typed
36
+ * counterpart of a raw blob read — {@link addRevision} validates on the way
37
+ * in, {@link readRevision} on the way out — and both speak the same
38
+ * {@link RevisionData} vocabulary.
39
+ *
33
40
  * @module
34
41
  */
35
42
  import { pure, foldStep } from '../../effects/module.f.js';
43
+ import { eff } from '../../effects/eff/module.f.js';
36
44
  import { create, read, write } from '../../effects/memory/module.f.js';
37
45
  import { collectRead } from '../module.f.js';
38
46
  import { cBase32ToVec, vecToCBase32 } from '../../basen/cbase32/module.f.js';
@@ -45,6 +53,7 @@ import { ok, error } from '../../types/result/module.f.js';
45
53
  import { nonEmpty, empty as elEmpty } from '../../effects/list/module.f.js';
46
54
  import { at, definedEntries } from '../../types/object/module.f.js';
47
55
  import { unwrap } from '../../types/nullable/module.f.js';
56
+ import { isNotFound } from '../../effects/node/module.f.js';
48
57
  /** A cache with no known subjects yet — the starting point for {@link buildCache}. */
49
58
  export const emptyCache = { bySubject: {} };
50
59
  /** Canonical JSON encoder for a `Revision` — key order carries no meaning for detection. */
@@ -76,7 +85,7 @@ const headsOf = (state) => state.hashes.filter(h => !state.parents.includes(h));
76
85
  *
77
86
  * Looks `revision.subject` up via {@link at} (own-property only), not plain
78
87
  * bracket indexing: a subject is an arbitrary caller-supplied string
79
- * (`AddRevision.subject`, or the MCP `evo_add`/`evo_head` argument), so it
88
+ * (`RevisionData.subject`, or the MCP `evo_add`/`evo_head` argument), so it
80
89
  * can collide with an inherited `Object.prototype` name (`toString`,
81
90
  * `constructor`, …) — bracket indexing would then return that inherited
82
91
  * value instead of "no entry yet" and crash on the (non-array) `.hashes`
@@ -110,21 +119,29 @@ export const decodeRevisionVec = (value) => {
110
119
  * parse/validate as the dialect — so a store containing arbitrary other
111
120
  * content can be scanned without failing the whole cache build.
112
121
  */
113
- export const decodeRevisionBlob = (cas) => (hash) => collectRead(cas.read(hash)).step(([tag, value]) => pure(tag === 'error' ? null : decodeRevisionVec(value)));
122
+ export const decodeRevisionBlob = (cas) => (hash) => eff(collectRead(cas.read(hash)))
123
+ .map(([tag, value]) => tag === 'error' ? null : decodeRevisionVec(value))
124
+ .value;
114
125
  /**
115
126
  * Scans every hash in `cas` and builds a fresh {@link Cache} from the
116
127
  * `vnd.fjs.revision` blobs found among them. Non-revision blobs are ignored.
117
128
  */
118
- export const buildCache = (cas) => cas.list().step(hashes => foldStep((hash) => (cache) => decodeRevisionBlob(cas)(hash).step(revision => pure(revision === null ? cache : addRevisionToCache(vecToCBase32(hash), revision)(cache))))(emptyCache)(hashes));
129
+ export const buildCache = (cas) => foldStep(cas.list(), emptyCache, hash => cache => eff(decodeRevisionBlob(cas)(hash))
130
+ .step(revision => pure(revision === null ? cache : addRevisionToCache(vecToCBase32(hash), revision)(cache)))
131
+ .value);
119
132
  /** Scans `cas` once and allocates a memory slot holding the resulting {@link Cache}. */
120
- export const initEvo = (cas) => buildCache(cas).step(cache => create(cache));
133
+ export const initEvo = (cas) => eff(buildCache(cas))
134
+ .step(cache => create(cache))
135
+ .value;
121
136
  /** Reads, then rewrites, the cache at `cacheKey` with `revision` folded in at `hash`. */
122
- const foldIntoCache = (cacheKey) => (hash) => (revision) => read(cacheKey).step(cache => write(cacheKey, addRevisionToCache(hash, revision)(cache)).step(() => pure(undefined)));
137
+ const foldIntoCache = (cacheKey) => (hash) => (revision) => eff(read(cacheKey))
138
+ .step(cache => write(cacheKey, addRevisionToCache(hash, revision)(cache)))
139
+ .value;
123
140
  /**
124
141
  * Folds `value` — bytes already written to a `Cas` at `hash` by some other
125
142
  * caller — into the cache at `cacheKey` if it decodes as a `vnd.fjs.revision`
126
143
  * ({@link decodeRevisionVec}); a no-op otherwise. `cas_add`/`evo_add`
127
- * (`fjs/cas/mcp`) are two ways to reach the same store — a plain `cas_add`
144
+ * (`fjs/mcp`) are two ways to reach the same store — a plain `cas_add`
128
145
  * call can store a revision blob without going through {@link addRevision},
129
146
  * and this is what keeps the cache honest about it without rescanning the
130
147
  * whole store.
@@ -145,17 +162,21 @@ const resolveParent = (cas) => (parentRef) => {
145
162
  if (parentHash === null) {
146
163
  return pure(error(`invalid parent hash: ${parentRef}`));
147
164
  }
148
- return decodeRevisionBlob(cas)(parentHash).step(parent => pure(parent === null ? error(`parent is not a revision blob: ${parentRef}`) : ok(parent)));
165
+ return eff(decodeRevisionBlob(cas)(parentHash))
166
+ .step(parent => pure(parent === null ? error(`parent is not a revision blob: ${parentRef}`) : ok(parent)))
167
+ .value;
149
168
  };
150
169
  /** Resolves and validates every entry of `parents`, in order, short-circuiting on the first failure. */
151
170
  const resolveParents = (cas) => (parents) => {
152
171
  const init = ok([]);
153
- return foldStep((parentRef) => (acc) => {
172
+ return foldStep(pure(parents), init, parentRef => (acc) => {
154
173
  if (acc[0] === 'error') {
155
174
  return pure(acc);
156
175
  }
157
- return resolveParent(cas)(parentRef).step((parentResult) => pure(parentResult[0] === 'error' ? parentResult : ok([...acc[1], parentResult[1]])));
158
- })(init)(parents);
176
+ return eff(resolveParent(cas)(parentRef))
177
+ .step((parentResult) => pure(parentResult[0] === 'error' ? parentResult : ok([...acc[1], parentResult[1]])))
178
+ .value;
179
+ });
159
180
  };
160
181
  /**
161
182
  * Resolves the `subject` of a new revision from its already-resolved
@@ -220,7 +241,8 @@ const resolveSnapshot = (input) => (subject) => (parents) => {
220
241
  * (`parents: []`), else `1 + max(parents' generations)`. Computed here from
221
242
  * the already-decoded parents, never taken from input — everything evo writes
222
243
  * follows the formula by construction (see the `generation` semantics in
223
- * [`fjs/cas/evo/todo/evo-revision.md`](todo/evo-revision.md)).
244
+ * [`fjs/media/revision/README.md`](../../media/revision/README.md), where a
245
+ * deviation is a readable epoch-reset signal rather than an invalid blob).
224
246
  *
225
247
  * The max is a `reduce`, not `Math.max(...parents.map(...))`: `parents` is
226
248
  * caller-sized (the direct API or the `evo_add` MCP tool), and argument-spread
@@ -244,8 +266,14 @@ const computeGeneration = (parents) => parents.length === 0 ? 0 : 1 + parents.re
244
266
  * revision, a blob too large to encode, or a store write failure — is
245
267
  * reported as `error(message)` rather than thrown, so a caller (e.g. an MCP
246
268
  * tool handler) can surface it without a `throw`/`catch`.
269
+ *
270
+ * `input.generation` is ignored: it exists on {@link RevisionData} only so a
271
+ * value read back by {@link readRevision} round-trips into `add` unchanged,
272
+ * and {@link computeGeneration} always derives the stored value from the
273
+ * resolved parents.
247
274
  */
248
- export const addRevision = (cas) => (cacheKey) => (input) => resolveParents(cas)(input.parents).step((parentsResult) => {
275
+ export const addRevision = (cas) => (cacheKey) => (input) => eff(resolveParents(cas)(input.parents))
276
+ .step((parentsResult) => {
249
277
  if (parentsResult[0] === 'error') {
250
278
  return pure(parentsResult);
251
279
  }
@@ -289,21 +317,105 @@ export const addRevision = (cas) => (cacheKey) => (input) => resolveParents(cas)
289
317
  if (bytes === null) {
290
318
  return pure(error('revision too large to encode'));
291
319
  }
292
- return cas.write(nonEmpty(ok(bytes), elEmpty()))
320
+ return eff(cas.write(nonEmpty(ok(bytes), elEmpty())))
293
321
  .step((writeResult) => {
294
322
  if (writeResult[0] === 'error') {
295
323
  return pure(error('failed to write revision to CAS'));
296
324
  }
297
325
  const hash = vecToCBase32(writeResult[1]);
298
- return foldIntoCache(cacheKey)(hash)(canonicalRevision).step(() => pure(ok(hash)));
299
- });
326
+ return eff(foldIntoCache(cacheKey)(hash)(canonicalRevision))
327
+ .step(() => pure(ok(hash)))
328
+ .value;
329
+ })
330
+ .value;
331
+ })
332
+ .value;
333
+ /**
334
+ * Projects a decoded `Revision` into the shared {@link RevisionData}
335
+ * vocabulary: `dialect` is dropped (a serialization tag with no information
336
+ * left once decoding has validated it), and every hash is re-spelled
337
+ * canonically ({@link canonicalHash}) so a read compares directly against
338
+ * {@link Evo.head}'s output instead of against whatever spelling the blob's
339
+ * writer happened to use. `checkReferences` ran as part of decoding, so every
340
+ * `parents` entry and the `snapshot` are known to decode and the `unwrap`
341
+ * inside `canonicalHash` is safe. Field order follows the stored blob's
342
+ * (minus `dialect`), which is what a JSON encoding of the result shows.
343
+ */
344
+ const toRevisionData = ({ subject, parents, snapshot, generation, archived }) => ({
345
+ subject,
346
+ parents: parents.map(canonicalHash),
347
+ snapshot: canonicalHash(snapshot),
348
+ generation,
349
+ archived,
300
350
  });
351
+ /**
352
+ * Second stage of {@link readRevision}: interprets an already-performed read
353
+ * of `hash`. Kept apart from the read itself so the failures stay
354
+ * distinguishable — "not present in the store" and "present but not a
355
+ * revision" are different answers to a client, and
356
+ * {@link decodeRevisionBlob}, which composes the same two stages internally,
357
+ * deliberately collapses both into `null` for store scanning.
358
+ *
359
+ * A failed read is only reported as *not found* when it is a genuine miss —
360
+ * `isNotFound`, the same ENOENT test `fjs/cas`'s `list` uses to tell an
361
+ * unwritten store from an unreadable one. A `Cas` read can also fail on a
362
+ * blob that exists: a permission or mid-stream I/O error, or content too
363
+ * large to buffer into one `Vec` (`collectRead`). Calling any of those "not
364
+ * found" would deny a stored revision exists, so they are their own message.
365
+ *
366
+ * A blob deleted *during* the read lands here as a miss too, and that is the
367
+ * honest answer rather than a gap in the split. `fileCas` streams in chunks,
368
+ * so a delete between two of them fails a later chunk with ENOENT after
369
+ * earlier ones succeeded — but by the time this result is produced the store
370
+ * genuinely no longer has the blob, and the very next read says "not found"
371
+ * with no race left to observe. `collectRead` reports how a read ended, not
372
+ * how far it got; recovering that difference would mean folding the chunk
373
+ * stream here instead of reusing `collectRead`, for a distinction no client
374
+ * can act on differently.
375
+ */
376
+ const decodeReadRevision = (hash) => ([tag, value]) => {
377
+ if (tag === 'error') {
378
+ return error(isNotFound(value)
379
+ ? `revision not found: ${hash}`
380
+ : `failed to read revision: ${hash}`);
381
+ }
382
+ const revision = decodeRevisionVec(value);
383
+ return revision === null
384
+ ? error(`not a revision blob: ${hash}`)
385
+ : ok(toRevisionData(revision));
386
+ };
387
+ /**
388
+ * Reads the revision at `hash`: decoded, validated, and canonicalized
389
+ * ({@link toRevisionData}). The typed counterpart of a raw `cas.read` — the
390
+ * store's generic "bytes by hash" read stays available for arbitrary content
391
+ * (snapshots and everything else that is not a revision), while this is the
392
+ * view for revisions specifically: `add` validates on the way in, this
393
+ * validates on the way out, so no caller re-implements JSON parsing, schema
394
+ * validation, the `dialect` check, or hash canonicalization.
395
+ *
396
+ * Every way to fail is its own message: `hash` is not cBase32, the store has
397
+ * nothing under it, the store has it but could not deliver it, or what it
398
+ * holds is not a `vnd.fjs.revision` (see {@link decodeReadRevision}).
399
+ */
400
+ export const readRevision = (cas) => (hash) => {
401
+ const hashVec = cBase32ToVec(hash);
402
+ return hashVec === null
403
+ ? pure(error(`invalid hash: ${hash}`))
404
+ : eff(collectRead(cas.read(hashVec)))
405
+ .map(decodeReadRevision(hash))
406
+ .value;
407
+ };
301
408
  /** Builds the {@link Evo} API over `cas`, backed by the cache at `cacheKey` (see {@link initEvo}). */
302
409
  export const evo = (cas) => (cacheKey) => ({
303
- list: () => read(cacheKey).step(cache => pure(definedEntries(cache.bySubject).map(([subject]) => subject))),
304
- head: subject => read(cacheKey).step(cache => {
410
+ list: () => eff(read(cacheKey))
411
+ .step(cache => pure(definedEntries(cache.bySubject).map(([subject]) => subject)))
412
+ .value,
413
+ head: subject => eff(read(cacheKey))
414
+ .step(cache => {
305
415
  const state = at(subject)(cache.bySubject);
306
416
  return pure(state === null ? [] : headsOf(state));
307
- }),
417
+ })
418
+ .value,
308
419
  add: input => addRevision(cas)(cacheKey)(input),
420
+ revision: readRevision(cas),
309
421
  });
@@ -14,6 +14,7 @@ export declare const proof: {
14
14
  addRevisionResolvesSubjectFromSingleParent: () => void;
15
15
  addComputesGenerationAndResolvesSnapshot: () => void;
16
16
  addComputesMergeGenerationFromMaxOfParents: () => void;
17
+ addIgnoresSuppliedGeneration: () => void;
17
18
  addRevisionSubjectRequiredForZeroParents: () => void;
18
19
  addRevisionNonHashSubjectWithoutSnapshotIsError: () => void;
19
20
  addRevisionInvalidParentHashIsError: () => void;
@@ -24,6 +25,12 @@ export declare const proof: {
24
25
  addRevisionInvalidReferencesIsError: () => void;
25
26
  addRevisionTooLargeToEncodeIsError: () => void;
26
27
  addRevisionWriteFailureIsError: () => void;
28
+ revisionInvalidHashIsError: () => void;
29
+ revisionMissingHashIsError: () => void;
30
+ revisionReadFailureIsNotReportedAsMissing: () => void;
31
+ revisionNonRevisionBlobIsError: () => void;
32
+ revisionCanonicalizesReferenceSpellings: () => void;
33
+ revisionRoundTripsThroughAdd: () => void;
27
34
  syncRevisionFoldsValidRevisionIntoCache: () => void;
28
35
  syncRevisionIgnoresNonRevisionContent: () => void;
29
36
  evoHeadUnknownSubjectIsEmpty: () => void;
@@ -19,6 +19,14 @@ const writeFailingCas = {
19
19
  write: () => pure(error('boom')),
20
20
  list: () => pure([]),
21
21
  };
22
+ // A `Cas<never>` whose `read` yields an error item that is *not* a missing
23
+ // shard — what a permission error, a mid-stream I/O failure, or a blob too
24
+ // large for `collectRead` to buffer looks like to a caller.
25
+ const readFailingCas = {
26
+ read: () => nonEmpty(error('boom'), elEmpty()),
27
+ write: () => pure(error('write not supported')),
28
+ list: () => pure([]),
29
+ };
22
30
  // A `Cas<never>` backed by a fixed set of (hash, content) entries, returned
23
31
  // from `list()` in exactly the given order — used to control the order
24
32
  // `buildCache` sees hashes in, independent of any real filesystem's
@@ -282,6 +290,24 @@ export const proof = {
282
290
  assert(mergeRev !== null, 'expected the stored merge to decode');
283
291
  assertEq(mergeRev?.generation, 3);
284
292
  },
293
+ // `generation` is an input field only so a value read back by
294
+ // `revision` round-trips into `add` unchanged: `add` never reads it, and
295
+ // supplying a wrong one must not reach the stored blob — the revision is
296
+ // byte-identical to the same `add` without it.
297
+ addIgnoresSuppliedGeneration: () => {
298
+ const c = fileCas(sha256)(home);
299
+ const [state0, cacheKey] = virtual(emptyState)(initEvo(c));
300
+ const e = evo(c)(cacheKey);
301
+ const subjectHash = vecToCBase32(vec8(0x73n));
302
+ const [state1, claimed] = virtual(state0)(e.add({ parents: [], subject: subjectHash, generation: 42 }));
303
+ assert(claimed[0] === 'ok', ['expected add ok', claimed]);
304
+ const [state2, plain] = virtual(state1)(e.add({ parents: [], subject: subjectHash }));
305
+ assert(plain[0] === 'ok', ['expected add ok', plain]);
306
+ assertEq(claimed[1], plain[1]);
307
+ const [, revision] = virtual(state2)(e.revision(claimed[1]));
308
+ assert(revision[0] === 'ok', ['expected revision ok', revision]);
309
+ assertEq(revision[1].generation, 0);
310
+ },
285
311
  addRevisionSubjectRequiredForZeroParents: () => {
286
312
  const c = fileCas(sha256)(home);
287
313
  const [state0, cacheKey] = virtual(emptyState)(initEvo(c));
@@ -396,10 +422,103 @@ export const proof = {
396
422
  assertEq(result[0], 'error');
397
423
  assert(result[0] === 'error' && result[1] === 'failed to write revision to CAS', ['unexpected message', result]);
398
424
  },
425
+ // Every way `revision` can fail is its own message, not one `null`: an
426
+ // undecodable hash, a hash the store has nothing under, a read that failed
427
+ // for any other reason, and a blob that is not a revision.
428
+ // `decodeRevisionBlob` collapses all but the first (it exists to scan
429
+ // stores of arbitrary content); this read keeps them apart.
430
+ revisionInvalidHashIsError: () => {
431
+ const c = fileCas(sha256)(home);
432
+ const [state0, cacheKey] = virtual(emptyState)(initEvo(c));
433
+ const e = evo(c)(cacheKey);
434
+ const [, result] = virtual(state0)(e.revision('not a valid cbase32!'));
435
+ assertEq(result[0], 'error');
436
+ assert(result[0] === 'error' && result[1].includes('invalid hash'), ['unexpected message', result]);
437
+ },
438
+ revisionMissingHashIsError: () => {
439
+ const c = fileCas(sha256)(home);
440
+ const [state0, cacheKey] = virtual(emptyState)(initEvo(c));
441
+ const e = evo(c)(cacheKey);
442
+ const [, result] = virtual(state0)(e.revision(vecToCBase32(vec8(0x9an))));
443
+ assertEq(result[0], 'error');
444
+ assert(result[0] === 'error' && result[1].includes('revision not found'), ['unexpected message', result]);
445
+ },
446
+ // Regression: a read failure that is not a missing shard — a permission or
447
+ // mid-stream I/O error, or a blob too large to buffer into one `Vec` —
448
+ // must not be reported as "not found". The blob may well be there; saying
449
+ // it is absent would be a false answer, not merely a vague one.
450
+ revisionReadFailureIsNotReportedAsMissing: () => {
451
+ const [state0, cacheKey] = virtual(emptyState)(initEvo(readFailingCas));
452
+ const e = evo(readFailingCas)(cacheKey);
453
+ const [, result] = virtual(state0)(e.revision(vecToCBase32(vec8(0x9bn))));
454
+ assertEq(result[0], 'error');
455
+ assert(result[0] === 'error' && result[1].includes('failed to read revision'), ['unexpected message', result]);
456
+ },
457
+ revisionNonRevisionBlobIsError: () => {
458
+ const c = fileCas(sha256)(home);
459
+ const [state0, cacheKey] = virtual(emptyState)(initEvo(c));
460
+ const e = evo(c)(cacheKey);
461
+ const content = vec8(0x41n); // 'A' — valid UTF-8, not revision JSON
462
+ const [state1, w] = virtual(state0)(c.write(nonEmpty(ok(content), elEmpty())));
463
+ assert(w[0] === 'ok', ['expected write ok', w]);
464
+ const [, result] = virtual(state1)(e.revision(vecToCBase32(w[1])));
465
+ assertEq(result[0], 'error');
466
+ assert(result[0] === 'error' && result[1].includes('not a revision blob'), ['unexpected message', result]);
467
+ },
468
+ // A blob written by somebody else can spell its references in any accepted
469
+ // cbase32 alias (case, `i`/`l`/`o`). What `revision` returns is
470
+ // canonicalized, so a client can compare it against `head` output
471
+ // directly instead of knowing about cbase32 aliasing.
472
+ revisionCanonicalizesReferenceSpellings: () => {
473
+ const c = fileCas(sha256)(home);
474
+ const [state0, cacheKey] = virtual(emptyState)(initEvo(c));
475
+ const e = evo(c)(cacheKey);
476
+ const parentCanonical = vecToCBase32(vec8(0xffn));
477
+ const snapshotCanonical = vecToCBase32(vec8(0xfen));
478
+ const parentAlias = parentCanonical.toUpperCase();
479
+ const snapshotAlias = snapshotCanonical.toUpperCase();
480
+ assert(parentAlias !== parentCanonical && snapshotAlias !== snapshotCanonical, 'expected the sample hashes to contain letters');
481
+ const text = `{"dialect":"${revisionDialect}","subject":"doc","parents":["${parentAlias}"],"snapshot":"${snapshotAlias}","generation":1}`;
482
+ const bytes = tryUtf8(text);
483
+ assert(bytes !== null, 'expected the sample revision text to encode as UTF-8');
484
+ const [state1, w] = virtual(state0)(c.write(nonEmpty(ok(bytes), elEmpty())));
485
+ assert(w[0] === 'ok', ['expected write ok', w]);
486
+ const [, result] = virtual(state1)(e.revision(vecToCBase32(w[1])));
487
+ assert(result[0] === 'ok', ['expected revision ok', result]);
488
+ assertEq(result[1].parents.length, 1);
489
+ assertEq(result[1].parents[0], parentCanonical);
490
+ assertEq(result[1].snapshot, snapshotCanonical);
491
+ assertEq(result[1].subject, 'doc');
492
+ assertEq(result[1].archived, undefined);
493
+ },
494
+ // What you add is what you get back: every field of a stored revision
495
+ // comes back in the same `RevisionData` shape `add` takes, so the value
496
+ // can be added again unchanged — same content, same hash.
497
+ revisionRoundTripsThroughAdd: () => {
498
+ const c = fileCas(sha256)(home);
499
+ const [state0, cacheKey] = virtual(emptyState)(initEvo(c));
500
+ const e = evo(c)(cacheKey);
501
+ const subjectHash = vecToCBase32(vec8(0x74n));
502
+ const [state1, root] = virtual(state0)(e.add({ parents: [], subject: subjectHash }));
503
+ assert(root[0] === 'ok', ['expected root ok', root]);
504
+ const [state2, child] = virtual(state1)(e.add({ parents: [root[1]], archived: true }));
505
+ assert(child[0] === 'ok', ['expected child ok', child]);
506
+ const [state3, result] = virtual(state2)(e.revision(child[1]));
507
+ assert(result[0] === 'ok', ['expected revision ok', result]);
508
+ assertEq(result[1].subject, subjectHash);
509
+ assertEq(result[1].snapshot, subjectHash);
510
+ assertEq(result[1].generation, 1);
511
+ assertEq(result[1].archived, true);
512
+ assertEq(result[1].parents.length, 1);
513
+ assertEq(result[1].parents[0], root[1]);
514
+ const [, readded] = virtual(state3)(e.add(result[1]));
515
+ assert(readded[0] === 'ok', ['expected re-add ok', readded]);
516
+ assertEq(readded[1], child[1]);
517
+ },
399
518
  // A raw CAS write (e.g. `cas_add`) of valid revision content is folded
400
519
  // into the cache exactly as `addRevision` would, without going through
401
520
  // `evo.add` — this is what keeps `cas_add` and `evo_add` writes to the
402
- // same store consistent (see `fjs/cas/mcp`).
521
+ // same store consistent (see `fjs/mcp`).
403
522
  syncRevisionFoldsValidRevisionIntoCache: () => {
404
523
  const c = fileCas(sha256)(home);
405
524
  const [state0, cacheKey] = virtual(emptyState)(initEvo(c));