functionalscript 0.38.0 → 0.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/fjs/bnf/descent/module.f.d.ts +43 -2
- package/fjs/bnf/descent/module.f.js +37 -12
- package/fjs/bnf/descent/proof.f.d.ts +1 -0
- package/fjs/bnf/descent/proof.f.js +73 -32
- package/fjs/bnf/ll1/module.f.js +2 -2
- 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/module.f.d.ts +77 -13
- package/fjs/cas/evo/module.f.js +130 -18
- package/fjs/cas/evo/proof.f.d.ts +7 -0
- package/fjs/cas/evo/proof.f.js +120 -1
- 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 +11 -8
- package/fjs/ci/config/module.f.js +24 -9
- package/fjs/ci/deno/module.f.js +3 -3
- package/fjs/ci/module.f.js +13 -7
- package/fjs/ci/nix/module.f.d.ts +61 -0
- package/fjs/ci/nix/module.f.js +92 -0
- package/fjs/ci/nix/proof.f.d.ts +23 -0
- package/fjs/ci/nix/proof.f.js +109 -0
- package/fjs/ci/node/module.f.d.ts +23 -1
- package/fjs/ci/node/module.f.js +58 -12
- package/fjs/ci/proof.f.d.ts +2 -0
- package/fjs/ci/proof.f.js +49 -9
- 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.d.ts +1 -0
- package/fjs/dev/module.f.js +38 -27
- package/fjs/dev/update/module.f.d.ts +11 -0
- package/fjs/dev/update/module.f.js +20 -0
- package/fjs/dev/update/proof.f.d.ts +7 -0
- package/fjs/dev/update/proof.f.js +35 -0
- package/fjs/djs/ast/module.f.d.ts +47 -0
- package/fjs/djs/ast/module.f.js +9 -0
- 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/tokenizer/module.f.js +2 -2
- package/fjs/djs/tokenizer/proof.f.d.ts +1 -0
- package/fjs/djs/tokenizer/proof.f.js +70 -16
- 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.d.ts +3 -3
- package/fjs/effects/node/module.f.d.ts +18 -8
- package/fjs/effects/node/module.f.js +31 -11
- package/fjs/effects/node/module.js +12 -12
- package/fjs/effects/node/proof.f.d.ts +1 -0
- package/fjs/effects/node/proof.f.js +32 -11
- package/fjs/effects/node/virtual/module.f.js +1 -1
- package/fjs/effects/proof.f.d.ts +32 -6
- package/fjs/effects/proof.f.js +131 -39
- package/fjs/emergent_testing/all.test.js +2 -1
- package/fjs/emergent_testing/module.f.d.ts +3 -3
- package/fjs/emergent_testing/module.f.js +63 -49
- package/fjs/emergent_testing/proof.f.d.ts +4 -0
- package/fjs/emergent_testing/proof.f.js +54 -21
- package/fjs/emergent_testing/scenarios/thenable.pass.js +1 -1
- package/fjs/fsc/module.f.js +4 -4
- package/fjs/fsm/module.f.js +1 -1
- package/fjs/js/tokenizer/module.f.d.ts +1 -0
- package/fjs/js/tokenizer/module.f.js +13 -6
- package/fjs/{cas/mcp → mcp/cas}/module.f.d.ts +3 -25
- package/fjs/{cas/mcp → mcp/cas}/module.f.js +26 -64
- package/fjs/mcp/evo/module.f.d.ts +27 -0
- package/fjs/mcp/evo/module.f.js +94 -0
- package/fjs/{cas/evo/mcp → mcp/evo}/proof.f.d.ts +2 -0
- package/fjs/{cas/evo/mcp → mcp/evo}/proof.f.js +40 -9
- package/fjs/mcp/module.f.d.ts +54 -237
- package/fjs/mcp/module.f.js +55 -258
- package/fjs/mcp/proof.f.d.ts +43 -32
- package/fjs/mcp/proof.f.js +509 -200
- package/fjs/media/nix/module.f.d.ts +30 -0
- package/fjs/media/nix/module.f.js +166 -0
- package/fjs/media/nix/proof.f.d.ts +32 -0
- package/fjs/media/nix/proof.f.js +127 -0
- package/fjs/media/type/module.f.js +2 -2
- package/fjs/media/type/proof.f.js +7 -10
- package/fjs/module.f.js +5 -9
- package/fjs/protocol/json_rpc/module.f.d.ts +114 -0
- package/fjs/{media/json/rpc → protocol/json_rpc}/module.f.js +3 -3
- package/fjs/{media/json/rpc → protocol/json_rpc}/proof.f.js +3 -3
- package/fjs/protocol/mcp/module.f.d.ts +239 -0
- package/fjs/protocol/mcp/module.f.js +272 -0
- package/fjs/protocol/mcp/proof.f.d.ts +34 -0
- package/fjs/protocol/mcp/proof.f.js +208 -0
- package/fjs/{mcp → protocol/mcp}/stdio/module.f.d.ts +7 -7
- package/fjs/{mcp → protocol/mcp}/stdio/module.f.js +22 -22
- package/fjs/{mcp → protocol/mcp}/stdio/proof.f.js +9 -9
- 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/range_map/module.f.d.ts +14 -13
- package/fjs/types/range_map/module.f.js +18 -13
- package/fjs/types/range_map/proof.f.js +26 -39
- package/fjs/types/range_set/module.f.d.ts +5 -0
- package/fjs/types/range_set/module.f.js +16 -0
- package/fjs/types/range_set/proof.f.d.ts +1 -0
- package/fjs/types/range_set/proof.f.js +19 -0
- package/fjs/types/string/module.f.js +10 -2
- package/fjs/website/module.f.js +2 -3
- package/package.json +5 -5
- package/fjs/cas/evo/mcp/module.f.d.ts +0 -19
- package/fjs/cas/evo/mcp/module.f.js +0 -56
- package/fjs/cas/mcp/proof.f.d.ts +0 -45
- package/fjs/cas/mcp/proof.f.js +0 -545
- package/fjs/ci/playwright/module.f.d.ts +0 -2
- package/fjs/ci/playwright/module.f.js +0 -25
- package/fjs/media/json/rpc/module.f.d.ts +0 -114
- /package/fjs/{media/json/rpc → protocol/json_rpc}/proof.f.d.ts +0 -0
- /package/fjs/{mcp → protocol/mcp}/stdio/proof.f.d.ts +0 -0
|
@@ -30,6 +30,13 @@
|
|
|
30
30
|
* both fold through the same {@link addRevisionToCache}, the former over the
|
|
31
31
|
* whole store at once, the latter incrementally for one new revision.
|
|
32
32
|
*
|
|
33
|
+
* Not everything here is cache-backed: {@link readRevision} answers "what is
|
|
34
|
+
* the revision at this hash" straight from the store, decoded, validated, and
|
|
35
|
+
* with every hash canonicalized ({@link toRevisionData}). It is the typed
|
|
36
|
+
* counterpart of a raw blob read — {@link addRevision} validates on the way
|
|
37
|
+
* in, {@link readRevision} on the way out — and both speak the same
|
|
38
|
+
* {@link RevisionData} vocabulary.
|
|
39
|
+
*
|
|
33
40
|
* @module
|
|
34
41
|
*/
|
|
35
42
|
import { type Effect, type Operation } from '../../effects/module.f.ts';
|
|
@@ -44,21 +51,50 @@ export type Hash = string;
|
|
|
44
51
|
/** The identity of a mutable object whose revisions are being evolved. */
|
|
45
52
|
export type Subject = string;
|
|
46
53
|
/**
|
|
47
|
-
*
|
|
48
|
-
* revision
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
54
|
+
* The semantic content of a revision: the media-level `Revision`
|
|
55
|
+
* (`fjs/media/revision`) minus `dialect`, which is a serialization tag
|
|
56
|
+
* carrying no information once decoding has validated it. One structure is
|
|
57
|
+
* the vocabulary of both directions — {@link addRevision} takes it,
|
|
58
|
+
* {@link readRevision} returns it — so what you add is what you get back, and
|
|
59
|
+
* a value read back can be fed to `add` unchanged, without stripping fields.
|
|
60
|
+
* Every direction-specific field is therefore optional, with the guarantees
|
|
61
|
+
* documented per field rather than typed:
|
|
62
|
+
*
|
|
63
|
+
* - `parents` — required in both directions; index 0 is the mainline parent.
|
|
64
|
+
* On output the entries are canonical cBase32 spellings
|
|
65
|
+
* ({@link canonicalHash}), so they compare directly against {@link Evo.head}
|
|
66
|
+
* output.
|
|
67
|
+
* - `subject` — input: absent means "inherit from my single parent" (see
|
|
68
|
+
* {@link resolveSubject}); output: always present.
|
|
69
|
+
* - `snapshot` — input: absent is a write-boundary convenience resolved from
|
|
70
|
+
* the parents (see {@link resolveSnapshot}), because the stored blob
|
|
71
|
+
* requires it explicitly; output: always present, canonical, and exactly
|
|
72
|
+
* what the stored blob names.
|
|
73
|
+
* - `archived` — genuinely optional in both directions; the only field that
|
|
74
|
+
* can be absent from a read.
|
|
75
|
+
* - `generation` — input: **ignored**, {@link computeGeneration} derives the
|
|
76
|
+
* authoritative value from the parents; output: always present. It exists as
|
|
77
|
+
* an input field only so a read value round-trips into `add` as-is.
|
|
78
|
+
*
|
|
79
|
+
* Relaxing what the format requires is the point, not an oversight. The stored
|
|
80
|
+
* `vnd.fjs.revision` blob requires `subject`, `snapshot` and `generation`, and
|
|
81
|
+
* {@link readRevision} does return all three — but this is not that blob's
|
|
82
|
+
* type. It is the type of the API that *reads and constructs* revisions, and
|
|
83
|
+
* its purpose is to make both easy: `add` asks only for what a caller can
|
|
84
|
+
* actually know, resolving or computing the rest at the write boundary, and a
|
|
85
|
+
* read hands back a value that goes straight into `add` again. A separate
|
|
86
|
+
* output type with `subject`/`snapshot`/`generation` required would state that
|
|
87
|
+
* one direction more precisely, at the cost of splitting the one vocabulary
|
|
88
|
+
* into two — the trade this API declines, deliberately and not by omission.
|
|
89
|
+
* `fjs/media/revision`'s `Revision` remains the all-required type of the
|
|
90
|
+
* stored blob for anyone who wants it.
|
|
56
91
|
*/
|
|
57
|
-
export type
|
|
92
|
+
export type RevisionData = {
|
|
58
93
|
readonly parents: readonly Hash[];
|
|
59
94
|
readonly snapshot?: Hash | undefined;
|
|
60
95
|
readonly subject?: Subject | undefined;
|
|
61
96
|
readonly archived?: true | undefined;
|
|
97
|
+
readonly generation?: number | undefined;
|
|
62
98
|
};
|
|
63
99
|
/**
|
|
64
100
|
* Per-subject bookkeeping: every revision hash seen for the subject, and
|
|
@@ -101,7 +137,7 @@ export declare const initEvo: <O extends Operation>(cas: Cas<O>) => Effect<O | M
|
|
|
101
137
|
* Folds `value` — bytes already written to a `Cas` at `hash` by some other
|
|
102
138
|
* caller — into the cache at `cacheKey` if it decodes as a `vnd.fjs.revision`
|
|
103
139
|
* ({@link decodeRevisionVec}); a no-op otherwise. `cas_add`/`evo_add`
|
|
104
|
-
* (`fjs/
|
|
140
|
+
* (`fjs/mcp`) are two ways to reach the same store — a plain `cas_add`
|
|
105
141
|
* call can store a revision blob without going through {@link addRevision},
|
|
106
142
|
* and this is what keeps the cache honest about it without rescanning the
|
|
107
143
|
* whole store.
|
|
@@ -123,8 +159,27 @@ export declare const syncRevision: (cacheKey: Key<Cache>) => (hash: Vec) => (val
|
|
|
123
159
|
* revision, a blob too large to encode, or a store write failure — is
|
|
124
160
|
* reported as `error(message)` rather than thrown, so a caller (e.g. an MCP
|
|
125
161
|
* tool handler) can surface it without a `throw`/`catch`.
|
|
162
|
+
*
|
|
163
|
+
* `input.generation` is ignored: it exists on {@link RevisionData} only so a
|
|
164
|
+
* value read back by {@link readRevision} round-trips into `add` unchanged,
|
|
165
|
+
* and {@link computeGeneration} always derives the stored value from the
|
|
166
|
+
* resolved parents.
|
|
167
|
+
*/
|
|
168
|
+
export declare const addRevision: <O extends Operation>(cas: Cas<O>) => (cacheKey: Key<Cache>) => (input: RevisionData) => Effect<O | MemOp, Result<Hash, string>>;
|
|
169
|
+
/**
|
|
170
|
+
* Reads the revision at `hash`: decoded, validated, and canonicalized
|
|
171
|
+
* ({@link toRevisionData}). The typed counterpart of a raw `cas.read` — the
|
|
172
|
+
* store's generic "bytes by hash" read stays available for arbitrary content
|
|
173
|
+
* (snapshots and everything else that is not a revision), while this is the
|
|
174
|
+
* view for revisions specifically: `add` validates on the way in, this
|
|
175
|
+
* validates on the way out, so no caller re-implements JSON parsing, schema
|
|
176
|
+
* validation, the `dialect` check, or hash canonicalization.
|
|
177
|
+
*
|
|
178
|
+
* Every way to fail is its own message: `hash` is not cBase32, the store has
|
|
179
|
+
* nothing under it, the store has it but could not deliver it, or what it
|
|
180
|
+
* holds is not a `vnd.fjs.revision` (see {@link decodeReadRevision}).
|
|
126
181
|
*/
|
|
127
|
-
export declare const
|
|
182
|
+
export declare const readRevision: <O extends Operation>(cas: Cas<O>) => (hash: Hash) => Effect<O, Result<RevisionData, string>>;
|
|
128
183
|
/** The Evo API described in `fjs/cas/evo/README.md`, bound to a `Cas<O>` and its cache slot. */
|
|
129
184
|
export type Evo<O extends Operation> = {
|
|
130
185
|
/** Returns every subject with at least one stored revision. */
|
|
@@ -132,7 +187,16 @@ export type Evo<O extends Operation> = {
|
|
|
132
187
|
/** Returns the current head hashes of `subject` (empty if unknown). */
|
|
133
188
|
readonly head: (subject: Subject) => Effect<MemOp, readonly Hash[]>;
|
|
134
189
|
/** Adds a new head; see {@link addRevision}. */
|
|
135
|
-
readonly add: (input:
|
|
190
|
+
readonly add: (input: RevisionData) => Effect<O | MemOp, Result<Hash, string>>;
|
|
191
|
+
/**
|
|
192
|
+
* The revision at `hash`, decoded, validated, and canonicalized; see
|
|
193
|
+
* {@link readRevision}. Served from the store today, so the `MemOp` in the
|
|
194
|
+
* declared operation set is unused — it is there because a revision is
|
|
195
|
+
* immutable and therefore memoizable in the same cache slot the other
|
|
196
|
+
* operations read, which must not become a breaking change to this type
|
|
197
|
+
* when it happens.
|
|
198
|
+
*/
|
|
199
|
+
readonly revision: (hash: Hash) => Effect<O | MemOp, Result<RevisionData, string>>;
|
|
136
200
|
};
|
|
137
201
|
/** Builds the {@link Evo} API over `cas`, backed by the cache at `cacheKey` (see {@link initEvo}). */
|
|
138
202
|
export declare const evo: <O extends Operation>(cas: Cas<O>) => (cacheKey: Key<Cache>) => Evo<O>;
|
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,21 +119,29 @@ export const decodeRevisionVec = (value) => {
|
|
|
110
119
|
* parse/validate as the dialect — so a store containing arbitrary other
|
|
111
120
|
* content can be scanned without failing the whole cache build.
|
|
112
121
|
*/
|
|
113
|
-
export const decodeRevisionBlob = (cas) => (hash) => collectRead(cas.read(hash))
|
|
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`
|
|
126
143
|
* ({@link decodeRevisionVec}); a no-op otherwise. `cas_add`/`evo_add`
|
|
127
|
-
* (`fjs/
|
|
144
|
+
* (`fjs/mcp`) are two ways to reach the same store — a plain `cas_add`
|
|
128
145
|
* call can store a revision blob without going through {@link addRevision},
|
|
129
146
|
* and this is what keeps the cache honest about it without rescanning the
|
|
130
147
|
* whole store.
|
|
@@ -145,17 +162,21 @@ const resolveParent = (cas) => (parentRef) => {
|
|
|
145
162
|
if (parentHash === null) {
|
|
146
163
|
return pure(error(`invalid parent hash: ${parentRef}`));
|
|
147
164
|
}
|
|
148
|
-
return decodeRevisionBlob(cas)(parentHash)
|
|
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,10 +422,103 @@ export const proof = {
|
|
|
396
422
|
assertEq(result[0], 'error');
|
|
397
423
|
assert(result[0] === 'error' && result[1] === 'failed to write revision to CAS', ['unexpected message', result]);
|
|
398
424
|
},
|
|
425
|
+
// Every way `revision` can fail is its own message, not one `null`: an
|
|
426
|
+
// undecodable hash, a hash the store has nothing under, a read that failed
|
|
427
|
+
// for any other reason, and a blob that is not a revision.
|
|
428
|
+
// `decodeRevisionBlob` collapses all but the first (it exists to scan
|
|
429
|
+
// stores of arbitrary content); this read keeps them apart.
|
|
430
|
+
revisionInvalidHashIsError: () => {
|
|
431
|
+
const c = fileCas(sha256)(home);
|
|
432
|
+
const [state0, cacheKey] = virtual(emptyState)(initEvo(c));
|
|
433
|
+
const e = evo(c)(cacheKey);
|
|
434
|
+
const [, result] = virtual(state0)(e.revision('not a valid cbase32!'));
|
|
435
|
+
assertEq(result[0], 'error');
|
|
436
|
+
assert(result[0] === 'error' && result[1].includes('invalid hash'), ['unexpected message', result]);
|
|
437
|
+
},
|
|
438
|
+
revisionMissingHashIsError: () => {
|
|
439
|
+
const c = fileCas(sha256)(home);
|
|
440
|
+
const [state0, cacheKey] = virtual(emptyState)(initEvo(c));
|
|
441
|
+
const e = evo(c)(cacheKey);
|
|
442
|
+
const [, result] = virtual(state0)(e.revision(vecToCBase32(vec8(0x9an))));
|
|
443
|
+
assertEq(result[0], 'error');
|
|
444
|
+
assert(result[0] === 'error' && result[1].includes('revision not found'), ['unexpected message', result]);
|
|
445
|
+
},
|
|
446
|
+
// Regression: a read failure that is not a missing shard — a permission or
|
|
447
|
+
// mid-stream I/O error, or a blob too large to buffer into one `Vec` —
|
|
448
|
+
// must not be reported as "not found". The blob may well be there; saying
|
|
449
|
+
// it is absent would be a false answer, not merely a vague one.
|
|
450
|
+
revisionReadFailureIsNotReportedAsMissing: () => {
|
|
451
|
+
const [state0, cacheKey] = virtual(emptyState)(initEvo(readFailingCas));
|
|
452
|
+
const e = evo(readFailingCas)(cacheKey);
|
|
453
|
+
const [, result] = virtual(state0)(e.revision(vecToCBase32(vec8(0x9bn))));
|
|
454
|
+
assertEq(result[0], 'error');
|
|
455
|
+
assert(result[0] === 'error' && result[1].includes('failed to read revision'), ['unexpected message', result]);
|
|
456
|
+
},
|
|
457
|
+
revisionNonRevisionBlobIsError: () => {
|
|
458
|
+
const c = fileCas(sha256)(home);
|
|
459
|
+
const [state0, cacheKey] = virtual(emptyState)(initEvo(c));
|
|
460
|
+
const e = evo(c)(cacheKey);
|
|
461
|
+
const content = vec8(0x41n); // 'A' — valid UTF-8, not revision JSON
|
|
462
|
+
const [state1, w] = virtual(state0)(c.write(nonEmpty(ok(content), elEmpty())));
|
|
463
|
+
assert(w[0] === 'ok', ['expected write ok', w]);
|
|
464
|
+
const [, result] = virtual(state1)(e.revision(vecToCBase32(w[1])));
|
|
465
|
+
assertEq(result[0], 'error');
|
|
466
|
+
assert(result[0] === 'error' && result[1].includes('not a revision blob'), ['unexpected message', result]);
|
|
467
|
+
},
|
|
468
|
+
// A blob written by somebody else can spell its references in any accepted
|
|
469
|
+
// cbase32 alias (case, `i`/`l`/`o`). What `revision` returns is
|
|
470
|
+
// canonicalized, so a client can compare it against `head` output
|
|
471
|
+
// directly instead of knowing about cbase32 aliasing.
|
|
472
|
+
revisionCanonicalizesReferenceSpellings: () => {
|
|
473
|
+
const c = fileCas(sha256)(home);
|
|
474
|
+
const [state0, cacheKey] = virtual(emptyState)(initEvo(c));
|
|
475
|
+
const e = evo(c)(cacheKey);
|
|
476
|
+
const parentCanonical = vecToCBase32(vec8(0xffn));
|
|
477
|
+
const snapshotCanonical = vecToCBase32(vec8(0xfen));
|
|
478
|
+
const parentAlias = parentCanonical.toUpperCase();
|
|
479
|
+
const snapshotAlias = snapshotCanonical.toUpperCase();
|
|
480
|
+
assert(parentAlias !== parentCanonical && snapshotAlias !== snapshotCanonical, 'expected the sample hashes to contain letters');
|
|
481
|
+
const text = `{"dialect":"${revisionDialect}","subject":"doc","parents":["${parentAlias}"],"snapshot":"${snapshotAlias}","generation":1}`;
|
|
482
|
+
const bytes = tryUtf8(text);
|
|
483
|
+
assert(bytes !== null, 'expected the sample revision text to encode as UTF-8');
|
|
484
|
+
const [state1, w] = virtual(state0)(c.write(nonEmpty(ok(bytes), elEmpty())));
|
|
485
|
+
assert(w[0] === 'ok', ['expected write ok', w]);
|
|
486
|
+
const [, result] = virtual(state1)(e.revision(vecToCBase32(w[1])));
|
|
487
|
+
assert(result[0] === 'ok', ['expected revision ok', result]);
|
|
488
|
+
assertEq(result[1].parents.length, 1);
|
|
489
|
+
assertEq(result[1].parents[0], parentCanonical);
|
|
490
|
+
assertEq(result[1].snapshot, snapshotCanonical);
|
|
491
|
+
assertEq(result[1].subject, 'doc');
|
|
492
|
+
assertEq(result[1].archived, undefined);
|
|
493
|
+
},
|
|
494
|
+
// What you add is what you get back: every field of a stored revision
|
|
495
|
+
// comes back in the same `RevisionData` shape `add` takes, so the value
|
|
496
|
+
// can be added again unchanged — same content, same hash.
|
|
497
|
+
revisionRoundTripsThroughAdd: () => {
|
|
498
|
+
const c = fileCas(sha256)(home);
|
|
499
|
+
const [state0, cacheKey] = virtual(emptyState)(initEvo(c));
|
|
500
|
+
const e = evo(c)(cacheKey);
|
|
501
|
+
const subjectHash = vecToCBase32(vec8(0x74n));
|
|
502
|
+
const [state1, root] = virtual(state0)(e.add({ parents: [], subject: subjectHash }));
|
|
503
|
+
assert(root[0] === 'ok', ['expected root ok', root]);
|
|
504
|
+
const [state2, child] = virtual(state1)(e.add({ parents: [root[1]], archived: true }));
|
|
505
|
+
assert(child[0] === 'ok', ['expected child ok', child]);
|
|
506
|
+
const [state3, result] = virtual(state2)(e.revision(child[1]));
|
|
507
|
+
assert(result[0] === 'ok', ['expected revision ok', result]);
|
|
508
|
+
assertEq(result[1].subject, subjectHash);
|
|
509
|
+
assertEq(result[1].snapshot, subjectHash);
|
|
510
|
+
assertEq(result[1].generation, 1);
|
|
511
|
+
assertEq(result[1].archived, true);
|
|
512
|
+
assertEq(result[1].parents.length, 1);
|
|
513
|
+
assertEq(result[1].parents[0], root[1]);
|
|
514
|
+
const [, readded] = virtual(state3)(e.add(result[1]));
|
|
515
|
+
assert(readded[0] === 'ok', ['expected re-add ok', readded]);
|
|
516
|
+
assertEq(readded[1], child[1]);
|
|
517
|
+
},
|
|
399
518
|
// A raw CAS write (e.g. `cas_add`) of valid revision content is folded
|
|
400
519
|
// into the cache exactly as `addRevision` would, without going through
|
|
401
520
|
// `evo.add` — this is what keeps `cas_add` and `evo_add` writes to the
|
|
402
|
-
// same store consistent (see `fjs/
|
|
521
|
+
// same store consistent (see `fjs/mcp`).
|
|
403
522
|
syncRevisionFoldsValidRevisionIntoCache: () => {
|
|
404
523
|
const c = fileCas(sha256)(home);
|
|
405
524
|
const [state0, cacheKey] = virtual(emptyState)(initEvo(c));
|