functionalscript 0.38.0 → 0.39.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 (75) hide show
  1. package/fjs/bnf/token_symbol/module.f.d.ts +38 -0
  2. package/fjs/bnf/token_symbol/module.f.js +48 -0
  3. package/fjs/bnf/token_symbol/proof.f.d.ts +10 -0
  4. package/fjs/bnf/token_symbol/proof.f.js +37 -0
  5. package/fjs/cas/cli/module.f.js +6 -9
  6. package/fjs/cas/evo/mcp/module.f.d.ts +9 -1
  7. package/fjs/cas/evo/mcp/module.f.js +52 -14
  8. package/fjs/cas/evo/mcp/proof.f.d.ts +2 -0
  9. package/fjs/cas/evo/mcp/proof.f.js +33 -2
  10. package/fjs/cas/evo/module.f.d.ts +76 -12
  11. package/fjs/cas/evo/module.f.js +129 -17
  12. package/fjs/cas/evo/proof.f.d.ts +7 -0
  13. package/fjs/cas/evo/proof.f.js +119 -0
  14. package/fjs/cas/mcp/module.f.js +22 -20
  15. package/fjs/cas/mcp/proof.f.d.ts +1 -1
  16. package/fjs/cas/mcp/proof.f.js +9 -9
  17. package/fjs/cas/module.f.js +64 -37
  18. package/fjs/cas/proof.f.d.ts +3 -9
  19. package/fjs/cas/proof.f.js +94 -74
  20. package/fjs/ci/bun/module.f.js +3 -3
  21. package/fjs/ci/common/module.f.d.ts +1 -2
  22. package/fjs/ci/common/module.f.js +1 -5
  23. package/fjs/ci/config/module.f.d.ts +5 -5
  24. package/fjs/ci/config/module.f.js +5 -5
  25. package/fjs/ci/deno/module.f.js +3 -3
  26. package/fjs/ci/module.f.js +3 -4
  27. package/fjs/ci/node/module.f.js +11 -9
  28. package/fjs/ci/proof.f.js +2 -0
  29. package/fjs/cli/module.f.js +2 -2
  30. package/fjs/common/monoid/module.f.d.ts +46 -2
  31. package/fjs/common/monoid/module.f.js +44 -0
  32. package/fjs/common/monoid/proof.f.d.ts +5 -0
  33. package/fjs/common/monoid/proof.f.js +27 -1
  34. package/fjs/dev/module.f.js +25 -25
  35. package/fjs/djs/module.f.js +5 -8
  36. package/fjs/djs/parser/module.f.d.ts +3 -0
  37. package/fjs/djs/parser/module.f.js +17 -0
  38. package/fjs/djs/transpiler/module.f.js +6 -6
  39. package/fjs/effects/eff/module.f.d.ts +72 -0
  40. package/fjs/effects/eff/module.f.js +46 -0
  41. package/fjs/effects/eff/proof.f.d.ts +11 -0
  42. package/fjs/effects/eff/proof.f.js +58 -0
  43. package/fjs/effects/list/module.f.d.ts +11 -5
  44. package/fjs/effects/list/module.f.js +2 -1
  45. package/fjs/effects/memory/proof.f.js +7 -8
  46. package/fjs/effects/module.f.d.ts +334 -52
  47. package/fjs/effects/module.f.js +253 -45
  48. package/fjs/effects/node/memory/proof.js +3 -2
  49. package/fjs/effects/node/module.f.d.ts +1 -1
  50. package/fjs/effects/node/module.f.js +9 -11
  51. package/fjs/effects/node/proof.f.js +18 -10
  52. package/fjs/effects/proof.f.d.ts +32 -6
  53. package/fjs/effects/proof.f.js +131 -39
  54. package/fjs/emergent_testing/module.f.js +55 -39
  55. package/fjs/emergent_testing/proof.f.d.ts +4 -0
  56. package/fjs/emergent_testing/proof.f.js +53 -20
  57. package/fjs/mcp/module.f.js +8 -8
  58. package/fjs/mcp/proof.f.js +5 -4
  59. package/fjs/mcp/stdio/module.f.d.ts +2 -2
  60. package/fjs/mcp/stdio/module.f.js +12 -12
  61. package/fjs/media/type/module.f.js +2 -2
  62. package/fjs/media/type/proof.f.js +7 -10
  63. package/fjs/module.f.js +4 -8
  64. package/fjs/types/bigint/module.f.js +4 -3
  65. package/fjs/types/bit_vec/module.f.js +5 -15
  66. package/fjs/types/btree/remove/module.f.d.ts +5 -0
  67. package/fjs/types/btree/remove/module.f.js +12 -0
  68. package/fjs/types/list/module.f.d.ts +21 -0
  69. package/fjs/types/list/module.f.js +17 -0
  70. package/fjs/types/list/proof.f.d.ts +1 -0
  71. package/fjs/types/list/proof.f.js +24 -1
  72. package/fjs/types/number/module.f.js +2 -1
  73. package/fjs/types/string/module.f.js +10 -2
  74. package/fjs/website/module.f.js +2 -3
  75. package/package.json +3 -3
@@ -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,16 +119,24 @@ 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`
@@ -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,6 +422,99 @@ 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
@@ -7,20 +7,22 @@
7
7
  * what is stored — without shelling out to the `cas` CLI. The store itself
8
8
  * (`fjs/cas/module.f.ts`) stays transport-agnostic; this is an additional
9
9
  * front end alongside the CLI `main`. The same server also exposes
10
- * `fjs/cas/evo`'s subject/head API (`evo_list`/`evo_head`/`evo_add`,
11
- * `fjs/cas/evo/mcp`) — one process, one `~/.cas/` store, one in-memory Evo
12
- * cache scanned once at startup (`initEvo`).
10
+ * `fjs/cas/evo`'s subject/head API
11
+ * (`evo_list`/`evo_head`/`evo_revision`/`evo_add`, `fjs/cas/evo/mcp`) — one
12
+ * process, one `~/.cas/` store, one in-memory Evo cache scanned once at
13
+ * startup (`initEvo`).
13
14
  *
14
15
  * ## Tools
15
16
  *
16
- * | Tool | args | action | result |
17
- * |-------------|-------------------------------------------------|---------------|--------------------------------------|
18
- * | `cas_add` | `{ content, type? }` | `c.write(...)`| hash (cBase32) |
19
- * | `cas_get` | `{ hash, content?: boolean }` | `c.read(key)` | JSON `{length,mimeType,type[,uri][,text\|blob]}` |
20
- * | `cas_list` | `{}` | `c.list()` | hashes, one per line |
21
- * | `evo_list` | `{}` | `e.list()` | subjects, one per line |
22
- * | `evo_head` | `{ subject }` | `e.head(...)` | head hashes, one per line |
23
- * | `evo_add` | `{ parents, snapshot?, subject?, archived? }` | `e.add(...)` | hash (cBase32) |
17
+ * | Tool | args | action | result |
18
+ * |----------------|----------------------------------------------|------------------|--------------------------------------|
19
+ * | `cas_add` | `{ content, type? }` | `c.write(...)` | hash (cBase32) |
20
+ * | `cas_get` | `{ hash, content?: boolean }` | `c.read(key)` | JSON `{length,mimeType,type[,uri][,text\|blob]}` |
21
+ * | `cas_list` | `{}` | `c.list()` | hashes, one per line |
22
+ * | `evo_list` | `{}` | `e.list()` | subjects, one per line |
23
+ * | `evo_head` | `{ subject }` | `e.head(...)` | head hashes, one per line |
24
+ * | `evo_revision` | `{ hash }` | `e.revision(...)`| the revision, as JSON |
25
+ * | `evo_add` | `{ parents, snapshot?, subject?, archived? }` | `e.add(...)` | hash (cBase32) |
24
26
  *
25
27
  * ## `cas_add` input encoding
26
28
  *
@@ -108,7 +110,7 @@
108
110
  */
109
111
  import { string, option, or, boolean } from '../../types/rtti/module.f.js';
110
112
  import { stringify } from '../../media/json/module.f.js';
111
- import { pure } from '../../effects/module.f.js';
113
+ import { pure, step } from '../../effects/module.f.js';
112
114
  import { create } from '../../effects/memory/module.f.js';
113
115
  import { cBase32ToVec, vecToCBase32 } from '../../basen/cbase32/module.f.js';
114
116
  import { decode as base64Decode, encode as base64Encode } from '../../basen/base64/module.f.js';
@@ -116,7 +118,7 @@ import { tryUtf8 } from '../../text/module.f.js';
116
118
  import { detectStream } from '../../media/type/module.f.js';
117
119
  import { detect as detectDialect } from '../../media/module.f.js';
118
120
  import { maxLengthBytes } from '../../types/bit_vec/module.f.js';
119
- import { ok, error } from '../../types/result/module.f.js';
121
+ import { ok } from '../../types/result/module.f.js';
120
122
  import {} from '../../effects/node/module.f.js';
121
123
  import { stdioTransport } from '../../mcp/stdio/module.f.js';
122
124
  import { mcpStep, uninitializedState, toolEntry, fromRegistry, errorResult, okResult, } from '../../mcp/module.f.js';
@@ -162,12 +164,12 @@ export const casToolRegistry = (home) => (cacheKey) => {
162
164
  return x === null
163
165
  ? pure(errorResult('too large or malformed — for large content, run `npx functionalscript cas add <path>` (or have the user run it) instead'))
164
166
  // The resolved content fits in one chunk; feed it as a single-item stream.
165
- : c.write(nonEmpty(ok(x), elEmpty())).step((writeResult) => {
167
+ : step(c.write(nonEmpty(ok(x), elEmpty())), (writeResult) => {
166
168
  if (writeResult[0] === 'error') {
167
169
  return pure(errorResult('write'));
168
170
  }
169
171
  const hash = writeResult[1];
170
- return syncRevision(cacheKey)(hash)(x).step(() => pure(okResult(vecToCBase32(hash))));
172
+ return step(syncRevision(cacheKey)(hash)(x), () => pure(okResult(vecToCBase32(hash))));
171
173
  });
172
174
  }),
173
175
  toolEntry('cas_get', 'Inspect a blob by hash. Always returns JSON {length,mimeType,type[,uri]} where type is "text" or "base64". Pass content:true to also include the inline payload as text (type:"text") or blob (type:"base64"), but content is capped at 128 KiB (131072 bytes) — a larger blob is rejected with an error. To download a blob, prefer the uri field returned in the result instead of requesting inline content.', casGetArgs, r => {
@@ -176,7 +178,7 @@ export const casToolRegistry = (home) => (cacheKey) => {
176
178
  return pure(errorResult(`invalid cBase32 hash: ${r.hash}`));
177
179
  }
178
180
  const uri = c.url(key);
179
- return detectStream(c.read(key)).step(([tag, detected]) => {
181
+ return step(detectStream(c.read(key)), ([tag, detected]) => {
180
182
  if (tag === 'error') {
181
183
  return pure(errorResult(`no such hash: ${r.hash}`));
182
184
  }
@@ -189,7 +191,7 @@ export const casToolRegistry = (home) => (cacheKey) => {
189
191
  if (type !== 'text' || length > maxLengthBytes) {
190
192
  return pure(okResult(toJson(meta)));
191
193
  }
192
- return collectRead(c.read(key)).step(([collectTag, value]) => {
194
+ return step(collectRead(c.read(key)), ([collectTag, value]) => {
193
195
  // Already known to fit from the streaming pass above, so an
194
196
  // error here means the hash vanished between reads; fall back
195
197
  // to the streaming verdict rather than fail the whole request.
@@ -207,7 +209,7 @@ export const casToolRegistry = (home) => (cacheKey) => {
207
209
  if (length > maxLengthBytes) {
208
210
  return pure(errorResult(`blob too large to fetch inline (${length} bytes, limit ${maxLengthBytes} bytes); use the uri field (${uri}) or omit content for metadata`));
209
211
  }
210
- return collectRead(c.read(key)).step(([collectTag, value]) => {
212
+ return step(collectRead(c.read(key)), ([collectTag, value]) => {
211
213
  if (collectTag === 'error') {
212
214
  return pure(errorResult(`no such hash: ${r.hash}`));
213
215
  }
@@ -232,7 +234,7 @@ export const casToolRegistry = (home) => (cacheKey) => {
232
234
  });
233
235
  });
234
236
  }),
235
- toolEntry('cas_list', 'List all stored content hashes (cBase32), one per line.', casListArgs, () => c.list().step(hashes => pure(okResult(hashes.map(vecToCBase32).join('\n'))))),
237
+ toolEntry('cas_list', 'List all stored content hashes (cBase32), one per line.', casListArgs, () => step(c.list(), hashes => pure(okResult(hashes.map(vecToCBase32).join('\n'))))),
236
238
  ];
237
239
  };
238
240
  // ── Handlers ────────────────────────────────────────────────────────────────────
@@ -258,7 +260,7 @@ export const casConfig = {
258
260
  * slot, builds the `mcpStep` for the merged tool registry, and drives the
259
261
  * read → parse → dispatch → write loop until stdin EOF.
260
262
  */
261
- export const casMcpServer = (home) => initEvo(fileCas(sha256)(home)).step(cacheKey => create(uninitializedState).step(sessionKey => stdioTransport(mcpStep(casConfig)(casMcpHandlers(home)(cacheKey))(sessionKey))));
263
+ export const casMcpServer = (home) => step(initEvo(fileCas(sha256)(home)), cacheKey => step(create(uninitializedState), sessionKey => stdioTransport(mcpStep(casConfig)(casMcpHandlers(home)(cacheKey))(sessionKey))));
262
264
  // ── Tests ────────────────────────────────────────────────────────────────────
263
265
  export const proof = {
264
266
  // casMcpServer is never called in integration tests because it drives a
@@ -7,7 +7,7 @@ export declare const proof: {
7
7
  getContentBase64InflationOverflowWritesInternalError: () => void;
8
8
  getContentBase64NearBoundarySucceeds: () => void;
9
9
  getContentDoubleEscapedOverflowWritesInternalError: () => void;
10
- toolsListAdvertisesSixTools: () => void;
10
+ toolsListAdvertisesSevenTools: () => void;
11
11
  evoAddListHeadRoundTripsThroughCasServer: () => void;
12
12
  evoAddDomainErrorIsErrorThroughCasServer: () => void;
13
13
  casAddSyncsEvoCacheForRevisionContent: () => void;
@@ -1,5 +1,5 @@
1
1
  import { assert, assertEq } from '../../asserts/module.f.js';
2
- import { pure } from '../../effects/module.f.js';
2
+ import { pure, step } from '../../effects/module.f.js';
3
3
  import { run } from '../../effects/mock/module.f.js';
4
4
  import { asBase, asNominal, create } from '../../effects/memory/module.f.js';
5
5
  import { msb, u8ListToVec, vec8, repeat, length, maxLengthBytes } from '../../types/bit_vec/module.f.js';
@@ -46,18 +46,18 @@ const mock = {
46
46
  };
47
47
  const runMem = (effect) => run(mock)(initialTestState)(effect)[1];
48
48
  // ── Session driver ──────────────────────────────────────────────────────────────
49
- // Feeds each message to `step` in order, collecting every response.
50
- const feed = (step) => (msgs) => {
49
+ // Feeds each message to `handler` in order, collecting every response.
50
+ const feed = (handler) => (msgs) => {
51
51
  const go = (i, acc) => i === msgs.length
52
52
  ? pure(acc)
53
- : step(msgs[i]).step(r => go(i + 1, [...acc, r]));
53
+ : step(handler(msgs[i]), r => go(i + 1, [...acc, r]));
54
54
  return go(0, []);
55
55
  };
56
56
  // Runs a session backed by the virtual node runner (for cas_upload which uses
57
57
  // Rename/ReadBytes/RandomInt/Mkdir). Uses fileKvStore so upload and get share
58
58
  // the same filesystem-backed CAS.
59
59
  const runSessionVirtual = (root, home = '/home/user') => (msgs) => {
60
- const effect = initEvo(fileCas(sha256)(home)).step(cacheKey => create(uninitializedState).step(sessionKey => {
60
+ const effect = step(initEvo(fileCas(sha256)(home)), cacheKey => step(create(uninitializedState), sessionKey => {
61
61
  const step = mcpStep(casConfig)(casMcpHandlers(home)(cacheKey))(sessionKey);
62
62
  return feed(step)(msgs);
63
63
  }));
@@ -95,7 +95,7 @@ const toBytes = (s) => [...fromVec(utf8(s))];
95
95
  // `fjs/mcp/stdio/module.f.ts` `writeResponse`).
96
96
  const runStdio = (root, home = '/home/user') => (msgs) => {
97
97
  const input = [init, initialized, ...msgs].map(m => JSON.stringify(m)).join('\n') + '\n';
98
- const effect = initEvo(fileCas(sha256)(home)).step(cacheKey => create(uninitializedState).step(sessionKey => stdioTransport(mcpStep(casConfig)(casMcpHandlers(home)(cacheKey))(sessionKey))));
98
+ const effect = step(initEvo(fileCas(sha256)(home)), cacheKey => step(create(uninitializedState), sessionKey => stdioTransport(mcpStep(casConfig)(casMcpHandlers(home)(cacheKey))(sessionKey))));
99
99
  const stdout = virtual({ ...emptyState, root, stdin: toBytes(input) })(effect)[0].stdout;
100
100
  // Only requests get a written line (notifications, like `initialized`,
101
101
  // write nothing) — drop the `init` response, keep one line per `msgs` entry.
@@ -257,11 +257,11 @@ export const proof = {
257
257
  assertEq(err.error?.code, -32603);
258
258
  assertEq(err.id, 2);
259
259
  },
260
- toolsListAdvertisesSixTools: () => {
260
+ toolsListAdvertisesSevenTools: () => {
261
261
  const [resp] = runSessionVirtual({})([init, initialized, list(2)]).slice(2);
262
262
  const tools = resp.result.tools;
263
- assertEq(tools.length, 6);
264
- assertEq(tools.map(t => t.name).join(','), 'cas_add,cas_get,cas_list,evo_list,evo_head,evo_add');
263
+ assertEq(tools.length, 7);
264
+ assertEq(tools.map(t => t.name).join(','), 'cas_add,cas_get,cas_list,evo_list,evo_head,evo_revision,evo_add');
265
265
  const add = resp.result.tools[0];
266
266
  assertEq(add.inputSchema.type, 'object');
267
267
  },