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.
- package/fjs/bnf/token_symbol/module.f.d.ts +38 -0
- package/fjs/bnf/token_symbol/module.f.js +48 -0
- package/fjs/bnf/token_symbol/proof.f.d.ts +10 -0
- package/fjs/bnf/token_symbol/proof.f.js +37 -0
- package/fjs/cas/cli/module.f.js +6 -9
- package/fjs/cas/evo/mcp/module.f.d.ts +9 -1
- package/fjs/cas/evo/mcp/module.f.js +52 -14
- package/fjs/cas/evo/mcp/proof.f.d.ts +2 -0
- package/fjs/cas/evo/mcp/proof.f.js +33 -2
- package/fjs/cas/evo/module.f.d.ts +76 -12
- package/fjs/cas/evo/module.f.js +129 -17
- package/fjs/cas/evo/proof.f.d.ts +7 -0
- package/fjs/cas/evo/proof.f.js +119 -0
- package/fjs/cas/mcp/module.f.js +22 -20
- package/fjs/cas/mcp/proof.f.d.ts +1 -1
- package/fjs/cas/mcp/proof.f.js +9 -9
- package/fjs/cas/module.f.js +64 -37
- package/fjs/cas/proof.f.d.ts +3 -9
- package/fjs/cas/proof.f.js +94 -74
- package/fjs/ci/bun/module.f.js +3 -3
- package/fjs/ci/common/module.f.d.ts +1 -2
- package/fjs/ci/common/module.f.js +1 -5
- package/fjs/ci/config/module.f.d.ts +5 -5
- package/fjs/ci/config/module.f.js +5 -5
- package/fjs/ci/deno/module.f.js +3 -3
- package/fjs/ci/module.f.js +3 -4
- package/fjs/ci/node/module.f.js +11 -9
- package/fjs/ci/proof.f.js +2 -0
- package/fjs/cli/module.f.js +2 -2
- package/fjs/common/monoid/module.f.d.ts +46 -2
- package/fjs/common/monoid/module.f.js +44 -0
- package/fjs/common/monoid/proof.f.d.ts +5 -0
- package/fjs/common/monoid/proof.f.js +27 -1
- package/fjs/dev/module.f.js +25 -25
- package/fjs/djs/module.f.js +5 -8
- package/fjs/djs/parser/module.f.d.ts +3 -0
- package/fjs/djs/parser/module.f.js +17 -0
- package/fjs/djs/transpiler/module.f.js +6 -6
- package/fjs/effects/eff/module.f.d.ts +72 -0
- package/fjs/effects/eff/module.f.js +46 -0
- package/fjs/effects/eff/proof.f.d.ts +11 -0
- package/fjs/effects/eff/proof.f.js +58 -0
- package/fjs/effects/list/module.f.d.ts +11 -5
- package/fjs/effects/list/module.f.js +2 -1
- package/fjs/effects/memory/proof.f.js +7 -8
- package/fjs/effects/module.f.d.ts +334 -52
- package/fjs/effects/module.f.js +253 -45
- package/fjs/effects/node/memory/proof.js +3 -2
- package/fjs/effects/node/module.f.d.ts +1 -1
- package/fjs/effects/node/module.f.js +9 -11
- package/fjs/effects/node/proof.f.js +18 -10
- package/fjs/effects/proof.f.d.ts +32 -6
- package/fjs/effects/proof.f.js +131 -39
- package/fjs/emergent_testing/module.f.js +55 -39
- package/fjs/emergent_testing/proof.f.d.ts +4 -0
- package/fjs/emergent_testing/proof.f.js +53 -20
- package/fjs/mcp/module.f.js +8 -8
- package/fjs/mcp/proof.f.js +5 -4
- package/fjs/mcp/stdio/module.f.d.ts +2 -2
- package/fjs/mcp/stdio/module.f.js +12 -12
- package/fjs/media/type/module.f.js +2 -2
- package/fjs/media/type/proof.f.js +7 -10
- package/fjs/module.f.js +4 -8
- package/fjs/types/bigint/module.f.js +4 -3
- package/fjs/types/bit_vec/module.f.js +5 -15
- package/fjs/types/btree/remove/module.f.d.ts +5 -0
- package/fjs/types/btree/remove/module.f.js +12 -0
- package/fjs/types/list/module.f.d.ts +21 -0
- package/fjs/types/list/module.f.js +17 -0
- package/fjs/types/list/proof.f.d.ts +1 -0
- package/fjs/types/list/proof.f.js +24 -1
- package/fjs/types/number/module.f.js +2 -1
- package/fjs/types/string/module.f.js +10 -2
- package/fjs/website/module.f.js +2 -3
- package/package.json +3 -3
package/fjs/cas/evo/module.f.js
CHANGED
|
@@ -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
|
-
* (`
|
|
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))
|
|
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()
|
|
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)
|
|
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)
|
|
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)
|
|
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((
|
|
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)
|
|
158
|
-
|
|
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/
|
|
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)
|
|
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)
|
|
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)
|
|
304
|
-
|
|
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
|
});
|
package/fjs/cas/evo/proof.f.d.ts
CHANGED
|
@@ -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;
|
package/fjs/cas/evo/proof.f.js
CHANGED
|
@@ -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
|
package/fjs/cas/mcp/module.f.js
CHANGED
|
@@ -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
|
|
11
|
-
* `fjs/cas/evo/mcp`) — one
|
|
12
|
-
* cache scanned once at
|
|
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
|
|
17
|
-
*
|
|
18
|
-
* | `cas_add`
|
|
19
|
-
* | `cas_get`
|
|
20
|
-
* | `cas_list`
|
|
21
|
-
* | `evo_list`
|
|
22
|
-
* | `evo_head`
|
|
23
|
-
* | `
|
|
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
|
|
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()))
|
|
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)
|
|
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))
|
|
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))
|
|
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))
|
|
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()
|
|
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))
|
|
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
|
package/fjs/cas/mcp/proof.f.d.ts
CHANGED
|
@@ -7,7 +7,7 @@ export declare const proof: {
|
|
|
7
7
|
getContentBase64InflationOverflowWritesInternalError: () => void;
|
|
8
8
|
getContentBase64NearBoundarySucceeds: () => void;
|
|
9
9
|
getContentDoubleEscapedOverflowWritesInternalError: () => void;
|
|
10
|
-
|
|
10
|
+
toolsListAdvertisesSevenTools: () => void;
|
|
11
11
|
evoAddListHeadRoundTripsThroughCasServer: () => void;
|
|
12
12
|
evoAddDomainErrorIsErrorThroughCasServer: () => void;
|
|
13
13
|
casAddSyncsEvoCacheForRevisionContent: () => void;
|
package/fjs/cas/mcp/proof.f.js
CHANGED
|
@@ -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 `
|
|
50
|
-
const feed = (
|
|
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])
|
|
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))
|
|
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))
|
|
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
|
-
|
|
260
|
+
toolsListAdvertisesSevenTools: () => {
|
|
261
261
|
const [resp] = runSessionVirtual({})([init, initialized, list(2)]).slice(2);
|
|
262
262
|
const tools = resp.result.tools;
|
|
263
|
-
assertEq(tools.length,
|
|
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
|
},
|