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.
@@ -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}). Storing both sets rather
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, and
101
- * every hash any of those revisions names as a parent. See the module doc
102
- * for why both sets are kept (rather than a running head list) and
103
- * {@link headsOf} for how heads are derived from them.
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
- /** Returns every subject with at least one stored revision. */
186
- readonly list: () => Effect<MemOp, readonly Subject[]>;
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}. */
@@ -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}). Storing both sets rather
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, and `revision.parents` (canonicalized, see
82
- * {@link canonicalHash}) join its `parents` set. Order independent (see the
83
- * module doc) used both for a full-store scan and for a single
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: () => eff(read(cacheKey))
411
- .step(cache => pure(definedEntries(cache.bySubject).map(([subject]) => subject)))
412
- .value,
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);
@@ -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;
@@ -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.38.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.1';
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: '21ea275a7c46aef9d4d6ddc962e6d562e9d94183';
31
+ readonly commit: '6d65bfc1bcef2ef39a239d38e577e92a89fb0f07';
32
32
  };
33
- export declare const wasmtime = "47.0.2";
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.38.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.1',
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: '21ea275a7c46aef9d4d6ddc962e6d562e9d94183',
50
+ commit: '6d65bfc1bcef2ef39a239d38e577e92a89fb0f07',
51
51
  };
52
52
  // https://github.com/bytecodealliance/wasmtime/releases
53
- export const wasmtime = '47.0.2';
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,3 @@
1
+ export declare const proof: {
2
+ basicNode: () => void;
3
+ };
@@ -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> = {
@@ -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
- return ['cont', map[command](...payload), continuation];
309
+ const handler = at(command)(map);
310
+ assert(handler !== null, command);
311
+ return ['cont', handler(...payload), continuation];
288
312
  };
@@ -31,6 +31,11 @@ export declare const proof: {
31
31
  match: {
32
32
  done: () => void;
33
33
  cont: () => void;
34
+ ownCommand: () => void;
35
+ throw: {
36
+ constructorCommand: () => void;
37
+ toStringCommand: () => void;
38
+ };
34
39
  };
35
40
  step: {
36
41
  pure: () => void;
@@ -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
- /** Arguments for `evo_list`: none. */
6
- export declare const evoListArgs: {};
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;
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * | Tool | args | action | result |
10
10
  * |----------------|-----------------------------------------------|-------------------|--------------------------------------|
11
- * | `evo_list` | `{}` | `e.list()` | subjects, as a JSON array of strings |
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
- /** Arguments for `evo_list`: none. */
55
- export const evoListArgs = {};
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 all subjects with at least one stored revision, as a JSON array of strings.', evoListArgs,
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
@@ -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;
@@ -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));
@@ -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` | `{}` | `e.list()` | subjects, one per line |
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) |
@@ -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` | `{}` | `e.list()` | subjects, one per line |
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "functionalscript",
3
- "version": "0.40.0",
3
+ "version": "0.41.0",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "**/*.js",