functionalscript 0.40.0 → 0.41.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/cas/evo/module.f.d.ts +27 -7
- package/fjs/cas/evo/module.f.js +42 -9
- package/fjs/cas/evo/proof.f.d.ts +4 -0
- package/fjs/cas/evo/proof.f.js +81 -0
- package/fjs/ci/config/module.f.d.ts +4 -4
- package/fjs/ci/config/module.f.js +4 -4
- package/fjs/ci/node/proof.f.d.ts +3 -0
- package/fjs/ci/node/proof.f.js +17 -0
- package/fjs/effects/module.f.d.ts +20 -0
- package/fjs/effects/module.f.js +25 -1
- package/fjs/effects/proof.f.d.ts +5 -0
- package/fjs/effects/proof.f.js +22 -0
- package/fjs/mcp/evo/module.f.d.ts +7 -2
- package/fjs/mcp/evo/module.f.js +10 -5
- package/fjs/mcp/evo/proof.f.d.ts +1 -0
- package/fjs/mcp/evo/proof.f.js +16 -0
- package/fjs/mcp/module.f.d.ts +1 -1
- package/fjs/mcp/module.f.js +1 -1
- package/package.json +1 -1
|
@@ -17,7 +17,10 @@
|
|
|
17
17
|
* subject (see [`fjs/media/revision/README.md`](../../media/revision/README.md)).
|
|
18
18
|
* `Cache` therefore tracks, per subject, every revision hash seen and every
|
|
19
19
|
* hash referenced as somebody's parent; heads are the set difference between
|
|
20
|
-
* the two, computed at read time ({@link headsOf}).
|
|
20
|
+
* the two, computed at read time ({@link headsOf}). Alongside them it records
|
|
21
|
+
* which of the seen revisions are `archived`, so {@link Evo.list} can classify
|
|
22
|
+
* a subject as active or archived from its heads' flags
|
|
23
|
+
* ({@link subjectListed}) without touching the store. Storing both sets rather
|
|
21
24
|
* than a running head list is what makes folding revisions truly order
|
|
22
25
|
* independent: `cas.list()` (used by {@link buildCache} to scan an existing
|
|
23
26
|
* store) returns hashes in hash order, not revision ancestry, so a child can
|
|
@@ -97,14 +100,22 @@ export type RevisionData = {
|
|
|
97
100
|
readonly generation?: number | undefined;
|
|
98
101
|
};
|
|
99
102
|
/**
|
|
100
|
-
* Per-subject bookkeeping: every revision hash seen for the subject,
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
* {@link headsOf} for how heads are
|
|
103
|
+
* Per-subject bookkeeping: every revision hash seen for the subject, every
|
|
104
|
+
* hash any of those revisions names as a parent, and which of the seen
|
|
105
|
+
* revisions are `archived`. See the module doc for why the first two sets are
|
|
106
|
+
* kept (rather than a running head list), {@link headsOf} for how heads are
|
|
107
|
+
* derived from them, and {@link subjectListed} for how `archived` classifies a
|
|
108
|
+
* subject once its heads are known.
|
|
109
|
+
*
|
|
110
|
+
* `archived` is keyed by revision hash, not by subject, for the same reason
|
|
111
|
+
* heads are computed at read time: which revisions are heads is only known
|
|
112
|
+
* once the whole store has been folded in, so a per-subject archived flag
|
|
113
|
+
* would have to be revised every time a later fold changes the head set.
|
|
104
114
|
*/
|
|
105
115
|
export type SubjectState = {
|
|
106
116
|
readonly hashes: readonly Hash[];
|
|
107
117
|
readonly parents: readonly Hash[];
|
|
118
|
+
readonly archived: readonly Hash[];
|
|
108
119
|
};
|
|
109
120
|
/** In-memory index: subject → its {@link SubjectState}. */
|
|
110
121
|
export type Cache = {
|
|
@@ -182,8 +193,17 @@ export declare const addRevision: <O extends Operation>(cas: Cas<O>) => (cacheKe
|
|
|
182
193
|
export declare const readRevision: <O extends Operation>(cas: Cas<O>) => (hash: Hash) => Effect<O, Result<RevisionData, string>>;
|
|
183
194
|
/** The Evo API described in `fjs/cas/evo/README.md`, bound to a `Cas<O>` and its cache slot. */
|
|
184
195
|
export type Evo<O extends Operation> = {
|
|
185
|
-
/**
|
|
186
|
-
|
|
196
|
+
/**
|
|
197
|
+
* Returns the subjects matching a status filter: the active ones by
|
|
198
|
+
* default, the archived ones when `archived` is `true`. A subject's status
|
|
199
|
+
* is derived from its current heads — see {@link subjectListed}, which
|
|
200
|
+
* also explains why a subject with no current heads is in neither result.
|
|
201
|
+
*
|
|
202
|
+
* There is deliberately no all-subjects mode: nothing needs one yet, and
|
|
203
|
+
* adding it later is a compatible extension of this parameter, while
|
|
204
|
+
* removing it would not be.
|
|
205
|
+
*/
|
|
206
|
+
readonly list: (archived?: true) => Effect<MemOp, readonly Subject[]>;
|
|
187
207
|
/** Returns the current head hashes of `subject` (empty if unknown). */
|
|
188
208
|
readonly head: (subject: Subject) => Effect<MemOp, readonly Hash[]>;
|
|
189
209
|
/** Adds a new head; see {@link addRevision}. */
|
package/fjs/cas/evo/module.f.js
CHANGED
|
@@ -17,7 +17,10 @@
|
|
|
17
17
|
* subject (see [`fjs/media/revision/README.md`](../../media/revision/README.md)).
|
|
18
18
|
* `Cache` therefore tracks, per subject, every revision hash seen and every
|
|
19
19
|
* hash referenced as somebody's parent; heads are the set difference between
|
|
20
|
-
* the two, computed at read time ({@link headsOf}).
|
|
20
|
+
* the two, computed at read time ({@link headsOf}). Alongside them it records
|
|
21
|
+
* which of the seen revisions are `archived`, so {@link Evo.list} can classify
|
|
22
|
+
* a subject as active or archived from its heads' flags
|
|
23
|
+
* ({@link subjectListed}) without touching the store. Storing both sets rather
|
|
21
24
|
* than a running head list is what makes folding revisions truly order
|
|
22
25
|
* independent: `cas.list()` (used by {@link buildCache} to scan an existing
|
|
23
26
|
* store) returns hashes in hash order, not revision ancestry, so a child can
|
|
@@ -58,7 +61,7 @@ import { isNotFound } from '../../effects/node/module.f.js';
|
|
|
58
61
|
export const emptyCache = { bySubject: {} };
|
|
59
62
|
/** Canonical JSON encoder for a `Revision` — key order carries no meaning for detection. */
|
|
60
63
|
const toJson = stringify(identity);
|
|
61
|
-
const emptySubjectState = { hashes: [], parents: [] };
|
|
64
|
+
const emptySubjectState = { hashes: [], parents: [], archived: [] };
|
|
62
65
|
/** Adds every item of `items` to `set` that isn't already there, preserving `set`'s existing order. */
|
|
63
66
|
const union = (set) => (items) => items.reduce((acc, h) => acc.includes(h) ? acc : [...acc, h], set);
|
|
64
67
|
/**
|
|
@@ -76,12 +79,37 @@ const union = (set) => (items) => items.reduce((acc, h) => acc.includes(h) ? acc
|
|
|
76
79
|
const canonicalHash = (h) => vecToCBase32(unwrap(cBase32ToVec(h)));
|
|
77
80
|
/** A subject's current heads: revision hashes seen that no other revision of the same subject names as a parent. */
|
|
78
81
|
const headsOf = (state) => state.hashes.filter(h => !state.parents.includes(h));
|
|
82
|
+
/**
|
|
83
|
+
* Whether a subject in `state` belongs in {@link Evo.list}'s result for the
|
|
84
|
+
* given `archived` filter — the subject-level status derived from its
|
|
85
|
+
* revision-level `archived` flags:
|
|
86
|
+
*
|
|
87
|
+
* - **active** — at least one current head is not archived. This is the
|
|
88
|
+
* default result set (`archived` omitted).
|
|
89
|
+
* - **archived** — the subject has at least one current head and every one of
|
|
90
|
+
* them is archived (`archived: true`).
|
|
91
|
+
*
|
|
92
|
+
* Concurrent heads can disagree, and the two rules resolve that the same way:
|
|
93
|
+
* one unarchived head keeps the whole subject active, because a subject is
|
|
94
|
+
* only done evolving when nothing left to build on remains. A subject with no
|
|
95
|
+
* current heads is neither active nor archived and appears in no result — the
|
|
96
|
+
* status is a statement about heads, and there is nothing to state. That case
|
|
97
|
+
* needs the explicit `heads.length` test only in the archived branch, since
|
|
98
|
+
* "every head is archived" is vacuously true of no heads at all.
|
|
99
|
+
*/
|
|
100
|
+
const subjectListed = (archived) => (state) => {
|
|
101
|
+
const heads = headsOf(state);
|
|
102
|
+
const unarchived = heads.filter(h => !state.archived.includes(h));
|
|
103
|
+
return archived === undefined
|
|
104
|
+
? unarchived.length !== 0
|
|
105
|
+
: heads.length !== 0 && unarchived.length === 0;
|
|
106
|
+
};
|
|
79
107
|
/**
|
|
80
108
|
* Folds one more stored revision into `cache`: `hash` joins its subject's
|
|
81
|
-
* `hashes` set,
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
* incremental `add`.
|
|
109
|
+
* `hashes` set, `revision.parents` (canonicalized, see {@link canonicalHash})
|
|
110
|
+
* join its `parents` set, and `hash` also joins the `archived` set when the
|
|
111
|
+
* revision carries `archived: true`. Order independent (see the module doc) —
|
|
112
|
+
* used both for a full-store scan and for a single incremental `add`.
|
|
85
113
|
*
|
|
86
114
|
* Looks `revision.subject` up via {@link at} (own-property only), not plain
|
|
87
115
|
* bracket indexing: a subject is an arbitrary caller-supplied string
|
|
@@ -96,6 +124,7 @@ const addRevisionToCache = (hash, revision) => (cache) => {
|
|
|
96
124
|
const state = {
|
|
97
125
|
hashes: union(existing.hashes)([hash]),
|
|
98
126
|
parents: union(existing.parents)(revision.parents.map(canonicalHash)),
|
|
127
|
+
archived: union(existing.archived)(revision.archived === undefined ? [] : [hash]),
|
|
99
128
|
};
|
|
100
129
|
return { bySubject: { ...cache.bySubject, [revision.subject]: state } };
|
|
101
130
|
};
|
|
@@ -407,9 +436,13 @@ export const readRevision = (cas) => (hash) => {
|
|
|
407
436
|
};
|
|
408
437
|
/** Builds the {@link Evo} API over `cas`, backed by the cache at `cacheKey` (see {@link initEvo}). */
|
|
409
438
|
export const evo = (cas) => (cacheKey) => ({
|
|
410
|
-
list:
|
|
411
|
-
|
|
412
|
-
|
|
439
|
+
list: archived => {
|
|
440
|
+
const listed = subjectListed(archived);
|
|
441
|
+
return eff(read(cacheKey))
|
|
442
|
+
.step(cache => pure(definedEntries(cache.bySubject)
|
|
443
|
+
.flatMap(([subject, state]) => listed(state) ? [subject] : [])))
|
|
444
|
+
.value;
|
|
445
|
+
},
|
|
413
446
|
head: subject => eff(read(cacheKey))
|
|
414
447
|
.step(cache => {
|
|
415
448
|
const state = at(subject)(cache.bySubject);
|
package/fjs/cas/evo/proof.f.d.ts
CHANGED
|
@@ -9,6 +9,10 @@ export declare const proof: {
|
|
|
9
9
|
buildCacheOrderIndependentWhenChildScannedBeforeParent: () => void;
|
|
10
10
|
buildCacheCanonicalizesNonCanonicalParentHashes: () => void;
|
|
11
11
|
addRevisionBuildsHeadsAcrossChainAndFork: () => void;
|
|
12
|
+
listPartitionsSubjectsByHeadArchivedFlag: () => void;
|
|
13
|
+
listTreatsDisagreeingHeadsAsActive: () => void;
|
|
14
|
+
listIgnoresArchivedRevisionsThatAreNoLongerHeads: () => void;
|
|
15
|
+
listExcludesSubjectWithNoCurrentHeads: () => void;
|
|
12
16
|
addRevisionIdempotentOnDuplicateContent: () => void;
|
|
13
17
|
addRevisionCanonicalizesParentSpellingBeforeSerializing: () => void;
|
|
14
18
|
addRevisionResolvesSubjectFromSingleParent: () => void;
|
package/fjs/cas/evo/proof.f.js
CHANGED
|
@@ -183,6 +183,87 @@ export const proof = {
|
|
|
183
183
|
assertEq(subjects.length, 1);
|
|
184
184
|
assertEq(subjects[0], 'doc');
|
|
185
185
|
},
|
|
186
|
+
// `list()` and `list(true)` partition subjects by the archived flags of
|
|
187
|
+
// their current heads: a lone root revision carrying `archived: true`
|
|
188
|
+
// makes its subject archived, one without it leaves the subject active,
|
|
189
|
+
// and neither subject appears in the other's result.
|
|
190
|
+
listPartitionsSubjectsByHeadArchivedFlag: () => {
|
|
191
|
+
const c = fileCas(sha256)(home);
|
|
192
|
+
const [state0, cacheKey] = virtual(emptyState)(initEvo(c));
|
|
193
|
+
const e = evo(c)(cacheKey);
|
|
194
|
+
const [state1, live] = virtual(state0)(e.add({ parents: [], subject: 'live', snapshot: vecToCBase32(vec8(0x40n)) }));
|
|
195
|
+
assert(live[0] === 'ok', ['expected the active add to succeed', live]);
|
|
196
|
+
const [state2, gone] = virtual(state1)(e.add({ parents: [], subject: 'gone', snapshot: vecToCBase32(vec8(0x41n)), archived: true }));
|
|
197
|
+
assert(gone[0] === 'ok', ['expected the archived add to succeed', gone]);
|
|
198
|
+
const [state3, active] = virtual(state2)(e.list());
|
|
199
|
+
assertEq(active.length, 1);
|
|
200
|
+
assertEq(active[0], 'live');
|
|
201
|
+
const [, archived] = virtual(state3)(e.list(true));
|
|
202
|
+
assertEq(archived.length, 1);
|
|
203
|
+
assertEq(archived[0], 'gone');
|
|
204
|
+
},
|
|
205
|
+
// Concurrent heads can disagree about `archived`. One unarchived head
|
|
206
|
+
// keeps the whole subject active — there is still a head left to build on
|
|
207
|
+
// — so the subject stays out of the archived-only result.
|
|
208
|
+
listTreatsDisagreeingHeadsAsActive: () => {
|
|
209
|
+
const c = fileCas(sha256)(home);
|
|
210
|
+
const [state0, cacheKey] = virtual(emptyState)(initEvo(c));
|
|
211
|
+
const e = evo(c)(cacheKey);
|
|
212
|
+
const [state1, root] = virtual(state0)(e.add({ parents: [], subject: 'doc', snapshot: vecToCBase32(vec8(0x42n)) }));
|
|
213
|
+
assert(root[0] === 'ok', ['expected root ok', root]);
|
|
214
|
+
// The two children differ only in `archived`, which is enough to make
|
|
215
|
+
// them distinct blobs, hence two concurrent heads of one root.
|
|
216
|
+
const [state2, kept] = virtual(state1)(e.add({ parents: [root[1]], subject: 'doc' }));
|
|
217
|
+
assert(kept[0] === 'ok', ['expected the unarchived child ok', kept]);
|
|
218
|
+
const [state3, dropped] = virtual(state2)(e.add({ parents: [root[1]], subject: 'doc', archived: true }));
|
|
219
|
+
assert(dropped[0] === 'ok', ['expected the archived child ok', dropped]);
|
|
220
|
+
const [state4, heads] = virtual(state3)(e.head('doc'));
|
|
221
|
+
assertEq(heads.length, 2);
|
|
222
|
+
const [state5, active] = virtual(state4)(e.list());
|
|
223
|
+
assertEq(active.length, 1);
|
|
224
|
+
assertEq(active[0], 'doc');
|
|
225
|
+
const [, archived] = virtual(state5)(e.list(true));
|
|
226
|
+
assertEq(archived.length, 0);
|
|
227
|
+
},
|
|
228
|
+
// `archived` is a property of a revision, not of a subject: a subject
|
|
229
|
+
// archived at one revision is active again as soon as an unarchived child
|
|
230
|
+
// demotes that revision out of the head set.
|
|
231
|
+
listIgnoresArchivedRevisionsThatAreNoLongerHeads: () => {
|
|
232
|
+
const c = fileCas(sha256)(home);
|
|
233
|
+
const [state0, cacheKey] = virtual(emptyState)(initEvo(c));
|
|
234
|
+
const e = evo(c)(cacheKey);
|
|
235
|
+
const [state1, root] = virtual(state0)(e.add({ parents: [], subject: 'doc', snapshot: vecToCBase32(vec8(0x45n)), archived: true }));
|
|
236
|
+
assert(root[0] === 'ok', ['expected the archived root ok', root]);
|
|
237
|
+
const [state2, revived] = virtual(state1)(e.add({ parents: [root[1]], subject: 'doc' }));
|
|
238
|
+
assert(revived[0] === 'ok', ['expected the unarchived child ok', revived]);
|
|
239
|
+
const [state3, active] = virtual(state2)(e.list());
|
|
240
|
+
assertEq(active.length, 1);
|
|
241
|
+
assertEq(active[0], 'doc');
|
|
242
|
+
const [, archived] = virtual(state3)(e.list(true));
|
|
243
|
+
assertEq(archived.length, 0);
|
|
244
|
+
},
|
|
245
|
+
// A subject can end up with no current head at all, and a status is a
|
|
246
|
+
// statement about heads — so such a subject is neither active nor
|
|
247
|
+
// archived and belongs to neither result. Nothing verifies that a stored
|
|
248
|
+
// blob actually hashes to the key it sits under, so a hand-crafted or
|
|
249
|
+
// corrupt store can present a revision naming its own hash as its parent;
|
|
250
|
+
// `fixedCas` reproduces exactly that, which a real `fileCas` cannot.
|
|
251
|
+
listExcludesSubjectWithNoCurrentHeads: () => {
|
|
252
|
+
const selfHash = vec8(0x43n);
|
|
253
|
+
const snapshotHash = vecToCBase32(vec8(0x44n));
|
|
254
|
+
const text = `{"dialect":"${revisionDialect}","subject":"doc","parents":["${vecToCBase32(selfHash)}"],"snapshot":"${snapshotHash}","generation":1}`;
|
|
255
|
+
const bytes = tryUtf8(text);
|
|
256
|
+
assert(bytes !== null, 'expected the sample revision text to encode as UTF-8');
|
|
257
|
+
const cas = fixedCas([[selfHash, bytes]]);
|
|
258
|
+
const [state0, cacheKey] = virtual(emptyState)(initEvo(cas));
|
|
259
|
+
const e = evo(cas)(cacheKey);
|
|
260
|
+
const [state1, heads] = virtual(state0)(e.head('doc'));
|
|
261
|
+
assertEq(heads.length, 0);
|
|
262
|
+
const [state2, active] = virtual(state1)(e.list());
|
|
263
|
+
assertEq(active.length, 0);
|
|
264
|
+
const [, archived] = virtual(state2)(e.list(true));
|
|
265
|
+
assertEq(archived.length, 0);
|
|
266
|
+
},
|
|
186
267
|
// Adding the exact same revision twice yields the same (deduplicated)
|
|
187
268
|
// content hash and must not duplicate the head entry.
|
|
188
269
|
addRevisionIdempotentOnDuplicateContent: () => {
|
|
@@ -18,19 +18,19 @@ export declare const images: {
|
|
|
18
18
|
readonly arm: 'windows-11-arm';
|
|
19
19
|
};
|
|
20
20
|
};
|
|
21
|
-
export declare const functionalscript: '0.
|
|
21
|
+
export declare const functionalscript: '0.39.0';
|
|
22
22
|
export declare const bun = "1.3.14";
|
|
23
23
|
export declare const deno = "2.9.4";
|
|
24
24
|
export declare const node: {
|
|
25
25
|
readonly default: '26.5.1';
|
|
26
|
-
readonly node22: '22.23.
|
|
26
|
+
readonly node22: '22.23.2';
|
|
27
27
|
readonly node24: '24.18.0';
|
|
28
28
|
};
|
|
29
29
|
export declare const nixpkgs: {
|
|
30
30
|
readonly ref: 'nixos-26.05';
|
|
31
|
-
readonly commit: '
|
|
31
|
+
readonly commit: '6d65bfc1bcef2ef39a239d38e577e92a89fb0f07';
|
|
32
32
|
};
|
|
33
|
-
export declare const wasmtime = "47.0.
|
|
33
|
+
export declare const wasmtime = "47.0.3";
|
|
34
34
|
export declare const wasmer = "7.2.1";
|
|
35
35
|
export declare const actions: {
|
|
36
36
|
readonly 'actions/checkout': 'v7.0.1';
|
|
@@ -23,7 +23,7 @@ export const images = {
|
|
|
23
23
|
// published FunctionalScript release; do not tie it to package.json's current
|
|
24
24
|
// in-repo version.
|
|
25
25
|
// https://www.npmjs.com/package/functionalscript
|
|
26
|
-
export const functionalscript = '0.
|
|
26
|
+
export const functionalscript = '0.39.0';
|
|
27
27
|
// https://bun.sh/
|
|
28
28
|
export const bun = '1.3.14';
|
|
29
29
|
// https://deno.com/
|
|
@@ -37,7 +37,7 @@ export const deno = '2.9.4';
|
|
|
37
37
|
// https://nodejs.org/en/download
|
|
38
38
|
export const node = {
|
|
39
39
|
default: '26.5.1',
|
|
40
|
-
node22: '22.23.
|
|
40
|
+
node22: '22.23.2',
|
|
41
41
|
node24: '24.18.0',
|
|
42
42
|
};
|
|
43
43
|
// Official Nixpkgs snapshot used by the generated CI flakes. `ref` is the
|
|
@@ -47,10 +47,10 @@ export const node = {
|
|
|
47
47
|
// https://channels.nixos.org/nixos-26.05/git-revision
|
|
48
48
|
export const nixpkgs = {
|
|
49
49
|
ref: 'nixos-26.05',
|
|
50
|
-
commit: '
|
|
50
|
+
commit: '6d65bfc1bcef2ef39a239d38e577e92a89fb0f07',
|
|
51
51
|
};
|
|
52
52
|
// https://github.com/bytecodealliance/wasmtime/releases
|
|
53
|
-
export const wasmtime = '47.0.
|
|
53
|
+
export const wasmtime = '47.0.3';
|
|
54
54
|
// https://github.com/wasmerio/wasmer/releases
|
|
55
55
|
export const wasmer = '7.2.1';
|
|
56
56
|
// GitHub Action versions used by CI step builders. The key is the action
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { basicNode } from './module.f.js';
|
|
2
|
+
import { test } from '../common/module.f.js';
|
|
3
|
+
import { assertEq } from '../../asserts/module.f.js';
|
|
4
|
+
export const proof = {
|
|
5
|
+
basicNode: () => {
|
|
6
|
+
const extra = [test({ run: 'echo extra' })];
|
|
7
|
+
const steps = basicNode('22.0.0')(extra);
|
|
8
|
+
assertEq(steps.length, 3);
|
|
9
|
+
const [setupNode, npmCi, extraStep] = steps;
|
|
10
|
+
assertEq(setupNode.type, 'install');
|
|
11
|
+
assertEq(setupNode.type === 'install' ? setupNode.step.uses : undefined, 'actions/setup-node@v7.0.0');
|
|
12
|
+
assertEq(setupNode.type === 'install' ? setupNode.step.with?.['node-version'] : undefined, '22.0.0');
|
|
13
|
+
assertEq(npmCi.type, 'test');
|
|
14
|
+
assertEq(npmCi.type === 'test' ? npmCi.step.run : undefined, 'npm ci');
|
|
15
|
+
assertEq(extraStep, extra[0]);
|
|
16
|
+
},
|
|
17
|
+
};
|
|
@@ -379,6 +379,26 @@ export type MatchResult<O extends Operation, T, R> = readonly ['done', T] | read
|
|
|
379
379
|
* with the continuation. The one world-specific step — `await` for async
|
|
380
380
|
* runners, state threading for sync ones — is left to the caller, so every
|
|
381
381
|
* interpreter loop is this skeleton plus a single eliminator line.
|
|
382
|
+
*
|
|
383
|
+
* **The handler is looked up with `at`, never with `map[command]`.**
|
|
384
|
+
* `OperationMap<O, R>` pins `command` to `O[0]` at the type level, but a `Do`
|
|
385
|
+
* node's `command` is runtime data — it can reach an interpreter from a decoded
|
|
386
|
+
* payload or a deserialized continuation, where no type ever constrained it.
|
|
387
|
+
* `map` is an ordinary object, so a plain index read resolves an inherited name
|
|
388
|
+
* (`'constructor'`, `'toString'`, `'hasOwnProperty'`) to the `Object.prototype`
|
|
389
|
+
* member instead of `undefined`, and the line below would then call it with the
|
|
390
|
+
* node's payload: a value the type system promised was `(...payload) => R` turns
|
|
391
|
+
* out to be an arbitrary inherited function, chosen by the same input that
|
|
392
|
+
* supplies its arguments. `at` reads through `getOwnPropertyDescriptor`, which
|
|
393
|
+
* only ever sees own properties, so such a command yields `null` and never a
|
|
394
|
+
* callable.
|
|
395
|
+
*
|
|
396
|
+
* A `null` handler is an invariant violation, not an outcome: every `O1 extends
|
|
397
|
+
* O` the signature admits has its command in `map`, so reaching it means the
|
|
398
|
+
* node's `command` was never the `O1[0]` it claimed to be. It therefore throws
|
|
399
|
+
* (`assert`) rather than widening {@link MatchResult} with a variant no
|
|
400
|
+
* type-correct caller could ever observe — a runner cannot resume a command it
|
|
401
|
+
* has no handler for, so there is nothing for a recovery branch to do.
|
|
382
402
|
*/
|
|
383
403
|
export declare const match: <O extends Operation, R>(map: OperationMap<O, R>) => <O1 extends O, T>(e: Effect<O1, T>) => MatchResult<O1, T, R>;
|
|
384
404
|
export type ToAsyncOperationMap<O extends Operation> = {
|
package/fjs/effects/module.f.js
CHANGED
|
@@ -94,7 +94,9 @@
|
|
|
94
94
|
*
|
|
95
95
|
* @module
|
|
96
96
|
*/
|
|
97
|
+
import { assert } from '../asserts/module.f.js';
|
|
97
98
|
import { fold } from '../types/list/module.f.js';
|
|
99
|
+
import { at } from '../types/object/module.f.js';
|
|
98
100
|
export const pure = (v) => () => v;
|
|
99
101
|
/**
|
|
100
102
|
* Composes effects: run `e`, then continue with `f` applied to its result.
|
|
@@ -278,11 +280,33 @@ export const runPure = (e) => typeof e === 'function' ? [e()] : [];
|
|
|
278
280
|
* with the continuation. The one world-specific step — `await` for async
|
|
279
281
|
* runners, state threading for sync ones — is left to the caller, so every
|
|
280
282
|
* interpreter loop is this skeleton plus a single eliminator line.
|
|
283
|
+
*
|
|
284
|
+
* **The handler is looked up with `at`, never with `map[command]`.**
|
|
285
|
+
* `OperationMap<O, R>` pins `command` to `O[0]` at the type level, but a `Do`
|
|
286
|
+
* node's `command` is runtime data — it can reach an interpreter from a decoded
|
|
287
|
+
* payload or a deserialized continuation, where no type ever constrained it.
|
|
288
|
+
* `map` is an ordinary object, so a plain index read resolves an inherited name
|
|
289
|
+
* (`'constructor'`, `'toString'`, `'hasOwnProperty'`) to the `Object.prototype`
|
|
290
|
+
* member instead of `undefined`, and the line below would then call it with the
|
|
291
|
+
* node's payload: a value the type system promised was `(...payload) => R` turns
|
|
292
|
+
* out to be an arbitrary inherited function, chosen by the same input that
|
|
293
|
+
* supplies its arguments. `at` reads through `getOwnPropertyDescriptor`, which
|
|
294
|
+
* only ever sees own properties, so such a command yields `null` and never a
|
|
295
|
+
* callable.
|
|
296
|
+
*
|
|
297
|
+
* A `null` handler is an invariant violation, not an outcome: every `O1 extends
|
|
298
|
+
* O` the signature admits has its command in `map`, so reaching it means the
|
|
299
|
+
* node's `command` was never the `O1[0]` it claimed to be. It therefore throws
|
|
300
|
+
* (`assert`) rather than widening {@link MatchResult} with a variant no
|
|
301
|
+
* type-correct caller could ever observe — a runner cannot resume a command it
|
|
302
|
+
* has no handler for, so there is nothing for a recovery branch to do.
|
|
281
303
|
*/
|
|
282
304
|
export const match = (map) => (e) => {
|
|
283
305
|
if (typeof e === 'function') {
|
|
284
306
|
return ['done', e()];
|
|
285
307
|
}
|
|
286
308
|
const { command, payload, continuation } = e;
|
|
287
|
-
|
|
309
|
+
const handler = at(command)(map);
|
|
310
|
+
assert(handler !== null, command);
|
|
311
|
+
return ['cont', handler(...payload), continuation];
|
|
288
312
|
};
|
package/fjs/effects/proof.f.d.ts
CHANGED
package/fjs/effects/proof.f.js
CHANGED
|
@@ -15,6 +15,7 @@ export const assertPure = (e, expected) => {
|
|
|
15
15
|
assertEq(o[0], expected);
|
|
16
16
|
};
|
|
17
17
|
const next = match({ add: (a, b) => a + b });
|
|
18
|
+
const anyNext = match({ add: a => a + 1 });
|
|
18
19
|
export const proof = {
|
|
19
20
|
foldStep: {
|
|
20
21
|
empty: () => {
|
|
@@ -103,6 +104,27 @@ export const proof = {
|
|
|
103
104
|
assert(r2[0] === 'done', r2);
|
|
104
105
|
assertEq(r2[1], 5);
|
|
105
106
|
},
|
|
107
|
+
ownCommand: () => {
|
|
108
|
+
// The same map the two cases below dispatch against: an own
|
|
109
|
+
// property still resolves, so what they prove is refusal of
|
|
110
|
+
// inherited names, not a map that dispatches nothing.
|
|
111
|
+
const r = anyNext(do_('add')(41));
|
|
112
|
+
assert(r[0] === 'cont', r);
|
|
113
|
+
assertEq(r[1], 42);
|
|
114
|
+
},
|
|
115
|
+
// A `command` naming an `Object.prototype` member must not dispatch to
|
|
116
|
+
// the inherited value. `map['constructor']` is `Object` and
|
|
117
|
+
// `map['toString']` is `Function.prototype.toString` — both callable,
|
|
118
|
+
// neither a handler — so a plain index read would call one of them with
|
|
119
|
+
// the node's payload instead of throwing.
|
|
120
|
+
throw: {
|
|
121
|
+
constructorCommand: () => {
|
|
122
|
+
anyNext(do_('constructor')(1));
|
|
123
|
+
},
|
|
124
|
+
toStringCommand: () => {
|
|
125
|
+
anyNext(do_('toString')(1));
|
|
126
|
+
},
|
|
127
|
+
},
|
|
106
128
|
},
|
|
107
129
|
step: {
|
|
108
130
|
pure: () => {
|
|
@@ -2,8 +2,13 @@ import { type Operation } from '../../effects/module.f.ts';
|
|
|
2
2
|
import { type MemOp } from '../../effects/memory/module.f.ts';
|
|
3
3
|
import { type ToolEntry } from '../../protocol/mcp/module.f.ts';
|
|
4
4
|
import { type Evo } from '../../cas/evo/module.f.ts';
|
|
5
|
-
/**
|
|
6
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Arguments for `evo_list`: an optional status filter, forwarded unchanged to
|
|
7
|
+
* `Evo.list` — omitted lists the active subjects, `true` the archived ones.
|
|
8
|
+
*/
|
|
9
|
+
export declare const evoListArgs: {
|
|
10
|
+
readonly archived: import("../../types/rtti/module.f.ts").Or<readonly [true, undefined]>;
|
|
11
|
+
};
|
|
7
12
|
/** Arguments for `evo_head`: the subject whose current heads are requested. */
|
|
8
13
|
export declare const evoHeadArgs: {
|
|
9
14
|
readonly subject: import("../../types/rtti/module.f.ts").String;
|
package/fjs/mcp/evo/module.f.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*
|
|
9
9
|
* | Tool | args | action | result |
|
|
10
10
|
* |----------------|-----------------------------------------------|-------------------|--------------------------------------|
|
|
11
|
-
* | `evo_list` | `{}`
|
|
11
|
+
* | `evo_list` | `{ archived? }` | `e.list(...)` | subjects, as a JSON array of strings |
|
|
12
12
|
* | `evo_head` | `{ subject }` | `e.head(...)` | head hashes, one per line |
|
|
13
13
|
* | `evo_revision` | `{ hash }` | `e.revision(...)` | the revision, as JSON `RevisionData` |
|
|
14
14
|
* | `evo_add` | `{ parents, snapshot?, subject?, archived? }` | `e.add(...)` | hash (cBase32) |
|
|
@@ -51,8 +51,13 @@ import { stringify } from '../../media/json/module.f.js';
|
|
|
51
51
|
import { identity } from '../../types/function/module.f.js';
|
|
52
52
|
import {} from '../../cas/evo/module.f.js';
|
|
53
53
|
// ── Argument schemas (declared once, used for both inputSchema and validate) ─────
|
|
54
|
-
/**
|
|
55
|
-
|
|
54
|
+
/**
|
|
55
|
+
* Arguments for `evo_list`: an optional status filter, forwarded unchanged to
|
|
56
|
+
* `Evo.list` — omitted lists the active subjects, `true` the archived ones.
|
|
57
|
+
*/
|
|
58
|
+
export const evoListArgs = {
|
|
59
|
+
archived: option(true),
|
|
60
|
+
};
|
|
56
61
|
/** Arguments for `evo_head`: the subject whose current heads are requested. */
|
|
57
62
|
export const evoHeadArgs = {
|
|
58
63
|
subject: string,
|
|
@@ -77,12 +82,12 @@ export const evoAddArgs = {
|
|
|
77
82
|
const toJson = stringify(identity);
|
|
78
83
|
/** Registry of all Evo tools, bound to an `Evo<O>`. */
|
|
79
84
|
export const evoToolRegistry = (e) => [
|
|
80
|
-
toolEntry('evo_list', 'List
|
|
85
|
+
toolEntry('evo_list', 'List subjects, as a JSON array of strings. By default only the active ones: a subject is active while at least one of its current heads is not archived. Pass `archived: true` to list the archived subjects instead — those with at least one current head, all of them archived. A subject with no current heads is in neither list.', evoListArgs,
|
|
81
86
|
// Subjects are arbitrary caller-supplied strings (unlike hashes, not
|
|
82
87
|
// constrained to a newline-free alphabet), so a `join('\n')` line
|
|
83
88
|
// format could not represent an empty subject or one containing a
|
|
84
89
|
// newline without ambiguity — JSON encoding can.
|
|
85
|
-
() => step(e.list(), subjects => pure(okResult(toJson(subjects))))),
|
|
90
|
+
({ archived }) => step(e.list(archived), subjects => pure(okResult(toJson(subjects))))),
|
|
86
91
|
toolEntry('evo_head', 'List the current head hashes (cBase32) of a subject, one per line. Empty when the subject is unknown.', evoHeadArgs, ({ subject }) => step(e.head(subject), heads => pure(okResult(heads.join('\n'))))),
|
|
87
92
|
toolEntry('evo_revision', 'Read one revision by hash, as JSON: `{ subject, parents, snapshot, generation, archived? }`. `parents[0]` is the mainline parent and every further entry is a merged-in branch; `parents` and `snapshot` come back in their canonical cBase32 spelling, so they compare directly against `evo_head` output. Errors when the hash is not cBase32, is not present in the store, could not be read, or does not hold a `vnd.fjs.revision` blob — use `cas_get` for raw bytes of non-revision content.', evoRevisionArgs,
|
|
88
93
|
// The revision goes out as JSON in a text content item, like
|
package/fjs/mcp/evo/proof.f.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ export declare const proof: {
|
|
|
2
2
|
toolNamesMatchTheDesign: () => void;
|
|
3
3
|
evoListReflectsTheCache: () => void;
|
|
4
4
|
evoListEncodesArbitrarySubjectsAsJson: () => void;
|
|
5
|
+
evoListForwardsTheArchivedFilter: () => void;
|
|
5
6
|
evoHeadReflectsTheCache: () => void;
|
|
6
7
|
evoHeadMissingSubjectIsInvalidArguments: () => void;
|
|
7
8
|
evoRevisionReturnsRevisionJson: () => void;
|
package/fjs/mcp/evo/proof.f.js
CHANGED
|
@@ -57,6 +57,22 @@ export const proof = {
|
|
|
57
57
|
assert(subjects.includes('line one\nline two'), ['unexpected subjects', subjects]);
|
|
58
58
|
assert(subjects.includes(''), ['unexpected subjects', subjects]);
|
|
59
59
|
},
|
|
60
|
+
// The `archived` argument is a pass-through to `Evo.list`'s status filter:
|
|
61
|
+
// omitted lists the active subjects, `true` the archived ones.
|
|
62
|
+
evoListForwardsTheArchivedFilter: () => {
|
|
63
|
+
const c = fileCas(sha256)(home);
|
|
64
|
+
const [state0, cacheKey] = virtual(emptyState)(initEvo(c));
|
|
65
|
+
const e = evo(c)(cacheKey);
|
|
66
|
+
const [state1, add] = virtual(state0)(e.add({ parents: [], subject: 'gone', snapshot: vecToCBase32(vec8(0x2cn)), archived: true }));
|
|
67
|
+
assert(add[0] === 'ok', ['expected add ok', add]);
|
|
68
|
+
const entry = findEntry(evoToolRegistry(e), 'evo_list');
|
|
69
|
+
const [state2, active] = virtual(state1)(entry.handle({}));
|
|
70
|
+
assert(!active.isError);
|
|
71
|
+
assertEq(textOf(active), '[]');
|
|
72
|
+
const [, archived] = virtual(state2)(entry.handle({ archived: true }));
|
|
73
|
+
assert(!archived.isError);
|
|
74
|
+
assertEq(textOf(archived), '["gone"]');
|
|
75
|
+
},
|
|
60
76
|
evoHeadReflectsTheCache: () => {
|
|
61
77
|
const c = fileCas(sha256)(home);
|
|
62
78
|
const [state0, cacheKey] = virtual(emptyState)(initEvo(c));
|
package/fjs/mcp/module.f.d.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* | `cas_add` | `{ content, type? }` | `c.write(...)` | hash (cBase32) |
|
|
15
15
|
* | `cas_get` | `{ hash, content?: boolean }` | `c.read(key)` | JSON `{length,mimeType,type[,uri][,text\|blob]}` |
|
|
16
16
|
* | `cas_list` | `{}` | `c.list()` | hashes, one per line |
|
|
17
|
-
* | `evo_list` | `{}`
|
|
17
|
+
* | `evo_list` | `{ archived? }` | `e.list(...)` | subjects, as a JSON array of strings |
|
|
18
18
|
* | `evo_head` | `{ subject }` | `e.head(...)` | head hashes, one per line |
|
|
19
19
|
* | `evo_revision` | `{ hash }` | `e.revision(...)`| the revision, as JSON |
|
|
20
20
|
* | `evo_add` | `{ parents, snapshot?, subject?, archived? }` | `e.add(...)` | hash (cBase32) |
|
package/fjs/mcp/module.f.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* | `cas_add` | `{ content, type? }` | `c.write(...)` | hash (cBase32) |
|
|
15
15
|
* | `cas_get` | `{ hash, content?: boolean }` | `c.read(key)` | JSON `{length,mimeType,type[,uri][,text\|blob]}` |
|
|
16
16
|
* | `cas_list` | `{}` | `c.list()` | hashes, one per line |
|
|
17
|
-
* | `evo_list` | `{}`
|
|
17
|
+
* | `evo_list` | `{ archived? }` | `e.list(...)` | subjects, as a JSON array of strings |
|
|
18
18
|
* | `evo_head` | `{ subject }` | `e.head(...)` | head hashes, one per line |
|
|
19
19
|
* | `evo_revision` | `{ hash }` | `e.revision(...)`| the revision, as JSON |
|
|
20
20
|
* | `evo_add` | `{ parents, snapshot?, subject?, archived? }` | `e.add(...)` | hash (cBase32) |
|