functionalscript 0.32.4 → 0.34.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/fs/asserts/module.f.d.ts +1 -1
- package/fs/asserts/module.f.js +4 -1
- package/fs/bnf/data/module.f.d.ts +0 -103
- package/fs/bnf/data/module.f.js +10 -246
- package/fs/bnf/data/proof.f.d.ts +0 -7
- package/fs/bnf/data/proof.f.js +3 -636
- package/fs/bnf/descent/module.f.d.ts +56 -0
- package/fs/bnf/descent/module.f.js +119 -0
- package/fs/bnf/descent/proof.f.d.ts +5 -0
- package/fs/bnf/descent/proof.f.js +296 -0
- package/fs/bnf/ll1/module.f.d.ts +72 -0
- package/fs/bnf/ll1/module.f.js +153 -0
- package/fs/bnf/ll1/proof.f.d.ts +9 -0
- package/fs/bnf/ll1/proof.f.js +358 -0
- package/fs/cas/cli/module.f.d.ts +6 -0
- package/fs/cas/cli/module.f.js +56 -0
- package/fs/cas/cli/proof.f.d.ts +14 -0
- package/fs/cas/cli/proof.f.js +149 -0
- package/fs/cas/mcp/module.f.d.ts +9 -17
- package/fs/cas/mcp/module.f.js +117 -83
- package/fs/cas/mcp/proof.f.d.ts +5 -1
- package/fs/cas/mcp/proof.f.js +79 -32
- package/fs/cas/module.f.d.ts +33 -27
- package/fs/cas/module.f.js +151 -126
- package/fs/cas/proof.f.d.ts +16 -13
- package/fs/cas/proof.f.js +255 -130
- package/fs/ci/config/module.f.d.ts +15 -15
- package/fs/ci/config/module.f.js +15 -15
- package/fs/ci/proof.f.js +8 -6
- package/fs/cli/proof.f.js +2 -12
- package/fs/djs/parser/module.f.d.ts +0 -5
- package/fs/djs/proof.f.js +5 -6
- package/fs/djs/serializer/module.f.d.ts +1 -3
- package/fs/djs/tokenizer-new/module.f.js +3 -2
- package/fs/djs/tokenizer-new/proof.f.d.ts +3 -0
- package/fs/djs/tokenizer-new/proof.f.js +458 -296
- package/fs/djs/transpiler/proof.f.js +10 -10
- package/fs/effects/list/module.f.d.ts +31 -0
- package/fs/effects/list/module.f.js +16 -0
- package/fs/effects/module.f.d.ts +11 -5
- package/fs/effects/module.f.js +16 -6
- package/fs/effects/node/module.f.d.ts +29 -3
- package/fs/effects/node/module.f.js +35 -1
- package/fs/effects/node/module.js +32 -18
- package/fs/effects/node/proof.f.js +36 -36
- package/fs/effects/node/virtual/module.f.d.ts +16 -2
- package/fs/effects/node/virtual/module.f.js +129 -20
- package/fs/effects/node/virtual/proof.f.d.ts +4 -0
- package/fs/effects/node/virtual/proof.f.js +47 -14
- package/fs/effects/proof.f.d.ts +4 -0
- package/fs/effects/proof.f.js +18 -1
- package/fs/emergent_testing/proof.f.js +3 -11
- package/fs/fjs/module.f.js +2 -8
- package/fs/fjs/proof.f.js +2 -11
- package/fs/html/module.f.js +6 -8
- package/fs/js/tokenizer/module.f.d.ts +5 -0
- package/fs/js/tokenizer/module.f.js +13 -0
- package/fs/json/serializer/module.f.js +0 -1
- package/fs/mcp/module.f.d.ts +1 -1
- package/fs/mime/module.f.d.ts +101 -5
- package/fs/mime/module.f.js +159 -6
- package/fs/mime/proof.f.d.ts +25 -0
- package/fs/mime/proof.f.js +193 -3
- package/fs/text/code_point/module.f.d.ts +39 -2
- package/fs/text/code_point/module.f.js +74 -2
- package/fs/text/code_point/proof.f.d.ts +8 -0
- package/fs/text/code_point/proof.f.js +72 -0
- package/fs/text/sgr/proof.f.js +2 -11
- package/fs/text/utf16/module.f.js +4 -26
- package/fs/text/utf8/module.f.d.ts +27 -0
- package/fs/text/utf8/module.f.js +8 -14
- package/fs/types/bigint/module.f.d.ts +8 -15
- package/fs/types/bigint/module.f.js +9 -22
- package/fs/types/bigint/proof.f.d.ts +0 -2
- package/fs/types/bigint/proof.f.js +6 -16
- package/fs/types/function/operator/module.f.d.ts +26 -0
- package/fs/types/list/module.f.d.ts +3 -0
- package/fs/types/result/module.d.ts +2 -4
- package/fs/types/result/module.js +9 -3
- package/package.json +3 -3
- package/issues/demo/data/data.f.js +0 -12
- package/issues/demo/data/shared.f.js +0 -3
- package/issues/demo/fs/app.js +0 -4
- package/issues/demo/fs/math.f.js +0 -4
- package/issues/demo/sample/proof.f.js +0 -13
- /package/{issues → todo}/031-json.f.d.ts +0 -0
- /package/{issues → todo}/031-json.f.js +0 -0
- /package/{issues → todo}/proof.f.d.ts +0 -0
- /package/{issues → todo}/proof.f.js +0 -0
package/fs/cas/mcp/proof.f.js
CHANGED
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
import { assert, assertEq } from "../../asserts/module.f.js";
|
|
2
2
|
import { pure } from "../../effects/module.f.js";
|
|
3
3
|
import { run } from "../../effects/mock/module.f.js";
|
|
4
|
-
import { asBase, asNominal, create
|
|
5
|
-
import { msb, u8ListToVec, vec8 } from "../../types/bit_vec/module.f.js";
|
|
4
|
+
import { asBase, asNominal, create } from "../../effects/memory/module.f.js";
|
|
5
|
+
import { msb, u8ListToVec, vec8, repeat, length, maxLengthBytes } from "../../types/bit_vec/module.f.js";
|
|
6
6
|
import { vecToCBase32 } from "../../cbase32/module.f.js";
|
|
7
7
|
import { encode as base64Encode } from "../../base64/module.f.js";
|
|
8
|
-
import { sha256 } from "../../crypto/sha2/module.f.js";
|
|
9
8
|
import { utf8 } from "../../text/module.f.js";
|
|
10
|
-
import {
|
|
9
|
+
import {} from "../module.f.js";
|
|
11
10
|
import { mcpStep, uninitializedState, } from "../../mcp/module.f.js";
|
|
12
|
-
import {} from "../../effects/node/module.f.js";
|
|
13
11
|
import { emptyState, virtual } from "../../effects/node/virtual/module.f.js";
|
|
14
12
|
import { casConfig, casMcpHandlers } from "./module.f.js";
|
|
15
13
|
import { ok as resultOk } from "../../types/result/module.f.js";
|
|
@@ -32,13 +30,15 @@ const mock = {
|
|
|
32
30
|
rename: (_src, _dst) => _ => { throw new Error('rename not supported in memory mock'); },
|
|
33
31
|
readBytes: (_path, _offset, _size) => _ => { throw new Error('readBytes not supported in memory mock'); },
|
|
34
32
|
randomInt: () => _ => { throw new Error('randomInt not supported in memory mock'); },
|
|
33
|
+
now: () => state => [state, 0],
|
|
34
|
+
access: (_path) => _ => { throw new Error('access not supported in memory mock'); },
|
|
35
|
+
createExclusive: (_path) => _ => { throw new Error('createExclusive not supported in memory mock'); },
|
|
36
|
+
readdir: (_path, _opts) => _ => { throw new Error('readdir not supported in memory mock'); },
|
|
37
|
+
rm: (_path) => _ => { throw new Error('rm not supported in memory mock'); },
|
|
38
|
+
stat: (_path) => _ => { throw new Error('stat not supported in memory mock'); },
|
|
39
|
+
writeBytes: (_path, _offset, _data) => _ => { throw new Error('writeBytes not supported in memory mock'); },
|
|
35
40
|
};
|
|
36
41
|
const runMem = (effect) => run(mock)(initialTestState)(effect)[1];
|
|
37
|
-
const memKvStore = (mapKey) => ({
|
|
38
|
-
read: (key) => read(mapKey).step(m => pure(m[vecToCBase32(key)]?.[1])),
|
|
39
|
-
write: (key, value) => read(mapKey).step(m => write(mapKey, { ...m, [vecToCBase32(key)]: [key, value] })),
|
|
40
|
-
list: () => read(mapKey).step(m => pure(Object.values(m).map(([k]) => k))),
|
|
41
|
-
});
|
|
42
42
|
// ── Session driver ──────────────────────────────────────────────────────────────
|
|
43
43
|
// Feeds each message to `step` in order, collecting every response.
|
|
44
44
|
const feed = (step) => (msgs) => {
|
|
@@ -47,19 +47,12 @@ const feed = (step) => (msgs) => {
|
|
|
47
47
|
: step(msgs[i]).step(r => go(i + 1, [...acc, r]));
|
|
48
48
|
return go(0, []);
|
|
49
49
|
};
|
|
50
|
-
// Runs a full session over a fresh in-memory CAS, returning all responses.
|
|
51
|
-
const runSession = (msgs, home = '/home/user') => runMem(create({}).step(mapKey => create(uninitializedState).step(sessionKey => {
|
|
52
|
-
const c = cas(sha256)(memKvStore(mapKey));
|
|
53
|
-
const step = mcpStep(casConfig)(casMcpHandlers(c, home))(sessionKey);
|
|
54
|
-
return feed(step)(msgs);
|
|
55
|
-
})));
|
|
56
50
|
// Runs a session backed by the virtual node runner (for cas_upload which uses
|
|
57
51
|
// Rename/ReadBytes/RandomInt/Mkdir). Uses fileKvStore so upload and get share
|
|
58
52
|
// the same filesystem-backed CAS.
|
|
59
53
|
const runSessionVirtual = (root, home = '/home/user') => (msgs) => {
|
|
60
54
|
const effect = create(uninitializedState).step(sessionKey => {
|
|
61
|
-
const
|
|
62
|
-
const step = mcpStep(casConfig)(casMcpHandlers(c, home))(sessionKey);
|
|
55
|
+
const step = mcpStep(casConfig)(casMcpHandlers(home))(sessionKey);
|
|
63
56
|
return feed(step)(msgs);
|
|
64
57
|
});
|
|
65
58
|
return virtual({ ...emptyState, root })(effect)[1];
|
|
@@ -71,7 +64,7 @@ const initialized = { jsonrpc: '2.0', method: 'notifications/initialized' };
|
|
|
71
64
|
const call = (id, name, args) => ({ jsonrpc: '2.0', method: 'tools/call', id, params: { name, arguments: args } });
|
|
72
65
|
const list = (id) => ({ jsonrpc: '2.0', method: 'tools/list', id });
|
|
73
66
|
// Runs `init`, `notifications/initialized`, then `msgs`; returns the tool responses.
|
|
74
|
-
const session = (...msgs) =>
|
|
67
|
+
const session = (...msgs) => runSessionVirtual({}, '/home/user')([init, initialized, ...msgs]).slice(2);
|
|
75
68
|
const resultOf = (resp) => resp.result;
|
|
76
69
|
const item0 = (resp) => resultOf(resp).content[0];
|
|
77
70
|
const textOf = (resp) => item0(resp).text;
|
|
@@ -83,9 +76,47 @@ const binarySample = base64Encode(vec8(0x2an));
|
|
|
83
76
|
// `cas_get` detects its type and returns base64 with mime_type image/png.
|
|
84
77
|
const pngSample = base64Encode(u8ListToVec(msb)([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x01]));
|
|
85
78
|
// ── Tests ───────────────────────────────────────────────────────────────────────
|
|
79
|
+
// A blob larger than one read chunk (> 128 KiB) used to fail metadata-only
|
|
80
|
+
// cas_get because `collectRead` overflowed `maxLength`. With `detectStream`,
|
|
81
|
+
// content:false returns correct metadata regardless of size. Each chunk is
|
|
82
|
+
// exactly `maxLengthBytes` (the largest single `Vec` the runtime allows), so the
|
|
83
|
+
// two-chunk blob spans two read chunks without any one `Vec` exceeding the cap.
|
|
84
|
+
const largeMultiChunkBlobMeta = (chunk0, chunk1, expectedType, expectedMime) => () => {
|
|
85
|
+
const root = { 'home': { 'user': { 'cas_upload': { 'big': [chunk0, chunk1] } } } };
|
|
86
|
+
const [addResp] = runSessionVirtual(root)([
|
|
87
|
+
init, initialized,
|
|
88
|
+
call(2, 'cas_add', { content: '/home/user/cas_upload/big', type: 'url' }),
|
|
89
|
+
]).slice(2);
|
|
90
|
+
assert(!resultOf(addResp).isError);
|
|
91
|
+
const hash = textOf(addResp);
|
|
92
|
+
const [, metaResp] = runSessionVirtual(root)([
|
|
93
|
+
init, initialized,
|
|
94
|
+
call(2, 'cas_add', { content: '/home/user/cas_upload/big', type: 'url' }),
|
|
95
|
+
call(3, 'cas_get', { hash }),
|
|
96
|
+
]).slice(2);
|
|
97
|
+
assert(!resultOf(metaResp).isError);
|
|
98
|
+
const meta = JSON.parse(textOf(metaResp));
|
|
99
|
+
assertEq(meta.type, expectedType);
|
|
100
|
+
assertEq(meta.mime_type, expectedMime);
|
|
101
|
+
assertEq(meta.length, Number((length(chunk0) + length(chunk1)) / 8n));
|
|
102
|
+
assertEq(meta.content, undefined);
|
|
103
|
+
};
|
|
104
|
+
// A full `maxLengthBytes`-long chunk of repeated ASCII 'a' — valid UTF-8.
|
|
105
|
+
const asciiChunk = repeat(maxLengthBytes)(vec8(0x61n));
|
|
106
|
+
// The same length, but repeated 2-byte "é" (C3 A9) — valid UTF-8 of multi-byte symbols.
|
|
107
|
+
const symbolChunk = repeat(maxLengthBytes / 2n)(u8ListToVec(msb)([0xc3, 0xa9]));
|
|
108
|
+
// A full chunk of 0xFF — an invalid UTF-8 lead byte, so binary (no magic match).
|
|
109
|
+
const binaryChunk = repeat(maxLengthBytes)(vec8(0xffn));
|
|
86
110
|
export const proof = {
|
|
111
|
+
// Both chunks repeated ASCII → text/plain, no error on a > 128 KiB blob.
|
|
112
|
+
getMetaLargeMultiChunkBlobNoError: largeMultiChunkBlobMeta(asciiChunk, asciiChunk, 'text', 'text/plain'),
|
|
113
|
+
// Both chunks valid UTF-8 sequences of multi-byte symbols → text/plain.
|
|
114
|
+
getMetaLargeMultiChunkUtf8Symbols: largeMultiChunkBlobMeta(symbolChunk, symbolChunk, 'text', 'text/plain'),
|
|
115
|
+
// First chunk valid UTF-8, second chunk binary → base64/octet-stream. The
|
|
116
|
+
// streaming validator must see the trailing binary chunk to reject text.
|
|
117
|
+
getMetaLargeMultiChunkTextThenBinary: largeMultiChunkBlobMeta(asciiChunk, binaryChunk, 'base64', 'application/octet-stream'),
|
|
87
118
|
toolsListAdvertisesThreeTools: () => {
|
|
88
|
-
const [resp] =
|
|
119
|
+
const [resp] = runSessionVirtual({})([init, initialized, list(2)]).slice(2);
|
|
89
120
|
const tools = resp.result.tools;
|
|
90
121
|
assertEq(tools.length, 3);
|
|
91
122
|
assertEq(tools.map(t => t.name).join(','), 'cas_add,cas_get,cas_list');
|
|
@@ -218,7 +249,7 @@ export const proof = {
|
|
|
218
249
|
// cas_add with type:'url' streams a file from /home/user/cas_upload/ into CAS.
|
|
219
250
|
addUrlStoresFileAndReturnsHash: () => {
|
|
220
251
|
const fileContent = utf8('hello from file');
|
|
221
|
-
const root = { 'home': { 'user': { 'cas_upload': { 'hello.txt': fileContent } } } };
|
|
252
|
+
const root = { 'home': { 'user': { 'cas_upload': { 'hello.txt': [fileContent] } } } };
|
|
222
253
|
const [addUrlResp] = runSessionVirtual(root)([
|
|
223
254
|
init, initialized,
|
|
224
255
|
call(2, 'cas_add', { content: '/home/user/cas_upload/hello.txt', type: 'url' }),
|
|
@@ -228,7 +259,7 @@ export const proof = {
|
|
|
228
259
|
},
|
|
229
260
|
addUrlRoundTrips: () => {
|
|
230
261
|
const fileContent = utf8('round-trip content');
|
|
231
|
-
const root = { 'home': { 'user': { 'cas_upload': { 'rt.txt': fileContent } } } };
|
|
262
|
+
const root = { 'home': { 'user': { 'cas_upload': { 'rt.txt': [fileContent] } } } };
|
|
232
263
|
// First pass: add to get the hash (deterministic for same content).
|
|
233
264
|
const [addResp] = runSessionVirtual(root)([
|
|
234
265
|
init, initialized,
|
|
@@ -256,7 +287,7 @@ export const proof = {
|
|
|
256
287
|
// cas_get without content:true returns only metadata.
|
|
257
288
|
getMetaReturnsLengthAndMimeType: () => {
|
|
258
289
|
const fileContent = utf8('text content');
|
|
259
|
-
const root = { 'home': { 'user': { 'cas_upload': { 'f': fileContent } } } };
|
|
290
|
+
const root = { 'home': { 'user': { 'cas_upload': { 'f': [fileContent] } } } };
|
|
260
291
|
// First pass: add to get the hash.
|
|
261
292
|
const [addResp] = runSessionVirtual(root)([
|
|
262
293
|
init, initialized,
|
|
@@ -273,7 +304,7 @@ export const proof = {
|
|
|
273
304
|
const meta = JSON.parse(textOf(metaResp));
|
|
274
305
|
assertEq(meta.mime_type, 'text/plain');
|
|
275
306
|
assertEq(meta.type, 'text');
|
|
276
|
-
assertEq(meta.length,
|
|
307
|
+
assertEq(meta.length, 12);
|
|
277
308
|
assertEq(meta.content, undefined);
|
|
278
309
|
},
|
|
279
310
|
getMetaBinaryBlob: () => {
|
|
@@ -299,15 +330,18 @@ export const proof = {
|
|
|
299
330
|
assertEq(meta.type, 'base64');
|
|
300
331
|
assertEq(meta.content, undefined);
|
|
301
332
|
},
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
const
|
|
333
|
+
// A NUL-bearing blob is valid UTF-8 yet binary: cas_get must report
|
|
334
|
+
// base64/octet-stream, not text/plain.
|
|
335
|
+
getMetaOctetStreamForNulBlob: () => {
|
|
336
|
+
const nulContent = u8ListToVec(msb)([0x00, 0x00, 0x00]);
|
|
337
|
+
const nulB64 = base64Encode(nulContent);
|
|
338
|
+
const [addResp] = session(call(2, 'cas_add', { content: nulB64, type: 'base64' }));
|
|
306
339
|
const hash = textOf(addResp);
|
|
307
|
-
const [, metaResp] = session(call(2, 'cas_add', { content:
|
|
340
|
+
const [, metaResp] = session(call(2, 'cas_add', { content: nulB64, type: 'base64' }), call(3, 'cas_get', { hash }));
|
|
308
341
|
assert(!resultOf(metaResp).isError);
|
|
309
342
|
const meta = JSON.parse(textOf(metaResp));
|
|
310
|
-
assertEq(meta.
|
|
343
|
+
assertEq(meta.mime_type, 'application/octet-stream');
|
|
344
|
+
assertEq(meta.type, 'base64');
|
|
311
345
|
},
|
|
312
346
|
getMetaMissingHashIsError: () => {
|
|
313
347
|
const absent = vecToCBase32(vec8(0x77n));
|
|
@@ -321,7 +355,7 @@ export const proof = {
|
|
|
321
355
|
// cas_add type:'url' with a subdirectory path flattens slashes to '-' in staging.
|
|
322
356
|
addUrlFromSubdirectorySucceeds: () => {
|
|
323
357
|
const fileContent = utf8('nested file content');
|
|
324
|
-
const root = { 'home': { 'user': { 'cas_upload': { 'subdir': { 'file.txt': fileContent } } } } };
|
|
358
|
+
const root = { 'home': { 'user': { 'cas_upload': { 'subdir': { 'file.txt': [fileContent] } } } } };
|
|
325
359
|
const [resp] = runSessionVirtual(root)([
|
|
326
360
|
init, initialized,
|
|
327
361
|
call(2, 'cas_add', { content: '/home/user/cas_upload/subdir/file.txt', type: 'url' }),
|
|
@@ -332,7 +366,7 @@ export const proof = {
|
|
|
332
366
|
// cas_add with type:'url' accepts paths within /home/user/cas_upload/
|
|
333
367
|
addUrlFromApprovedDirectorySucceeds: () => {
|
|
334
368
|
const fileContent = utf8('approved file');
|
|
335
|
-
const root = { 'home': { 'user': { 'cas_upload': { 'test.txt': fileContent } } } };
|
|
369
|
+
const root = { 'home': { 'user': { 'cas_upload': { 'test.txt': [fileContent] } } } };
|
|
336
370
|
const [resp] = runSessionVirtual(root)([
|
|
337
371
|
init, initialized,
|
|
338
372
|
call(2, 'cas_add', { content: '/home/user/cas_upload/test.txt', type: 'url' }),
|
|
@@ -352,4 +386,17 @@ export const proof = {
|
|
|
352
386
|
assert(resultOf(resp).isError === true);
|
|
353
387
|
assert(textOf(resp).includes('/home/user/cas_upload/'));
|
|
354
388
|
},
|
|
389
|
+
// cas_get with content:true on octet-stream (no magic bytes, not UTF-8) returns inline base64.
|
|
390
|
+
getOctetStreamWithContentIncludesBase64: () => {
|
|
391
|
+
const binaryContent = u8ListToVec(msb)([0xFF, 0xFE, 0x00, 0x01]);
|
|
392
|
+
const binaryB64 = base64Encode(binaryContent);
|
|
393
|
+
const [addResp] = session(call(2, 'cas_add', { content: binaryB64, type: 'base64' }));
|
|
394
|
+
const hash = textOf(addResp);
|
|
395
|
+
const [, getResp] = session(call(2, 'cas_add', { content: binaryB64, type: 'base64' }), call(3, 'cas_get', { hash, content: true }));
|
|
396
|
+
assert(!resultOf(getResp).isError);
|
|
397
|
+
const result = JSON.parse(textOf(getResp));
|
|
398
|
+
assertEq(result.mime_type, 'application/octet-stream');
|
|
399
|
+
assertEq(result.type, 'base64');
|
|
400
|
+
assertEq(result.content, binaryB64);
|
|
401
|
+
},
|
|
355
402
|
};
|
package/fs/cas/module.f.d.ts
CHANGED
|
@@ -6,43 +6,49 @@
|
|
|
6
6
|
import { type Sha2 } from '../crypto/sha2/module.f.ts';
|
|
7
7
|
import { type Vec } from '../types/bit_vec/module.f.ts';
|
|
8
8
|
import { type Effect, type Operation } from '../effects/module.f.ts';
|
|
9
|
-
import { type Access, type
|
|
10
|
-
import { type
|
|
11
|
-
import type { MemOp } from '../effects/memory/module.f.ts';
|
|
12
|
-
export type KvStore<O extends Operation> = {
|
|
13
|
-
/** Reads a value by key; returns `undefined` when the key does not exist. */
|
|
14
|
-
readonly read: (key: Vec) => Effect<O, Vec | undefined>;
|
|
15
|
-
/** Writes a key/value pair to the underlying storage. */
|
|
16
|
-
readonly write: (key: Vec, value: Vec) => Effect<O, void>;
|
|
17
|
-
/** Lists all keys available in the store. */
|
|
18
|
-
readonly list: () => Effect<O, readonly Vec[]>;
|
|
19
|
-
};
|
|
20
|
-
/** A key/value tuple where index `0` is the key and index `1` is the value. */
|
|
21
|
-
export type Kv = readonly [Vec, Vec];
|
|
9
|
+
import { type Access, type CreateExclusive, type IoResult, type Mkdir, type Now, type RandomInt, type ReadBytes, type Readdir, type Rename, type Rm, type Stat, type WriteBytes } from '../effects/node/module.f.ts';
|
|
10
|
+
import { type List } from '../effects/list/module.f.ts';
|
|
22
11
|
/** Converts a content key to its sharded relative CAS file path. */
|
|
23
12
|
export declare const toPath: (key: Vec) => string;
|
|
24
|
-
export type FileKvStoreOperation = ReadFile | Mkdir | WriteFile | Access | Readdir;
|
|
25
13
|
/**
|
|
26
|
-
*
|
|
14
|
+
* The filesystem effects the streaming CAS performs: `read` pulls shards
|
|
15
|
+
* (`ReadBytes`); `list` walks the store (`Access`/`Readdir`); `write` runs the
|
|
16
|
+
* lock-free staging upload (`Mkdir`/`CreateExclusive`/`WriteBytes`/`Rename`/`Rm`/
|
|
17
|
+
* `Stat`, lease deadlines from `Now`, staging names from `RandomInt`) and GC's
|
|
18
|
+
* expired staging files (`Readdir`/`Rm`).
|
|
27
19
|
*/
|
|
28
|
-
export
|
|
20
|
+
export type FileCasOperation = ReadBytes | Mkdir | Readdir | Access | Rename | Rm | RandomInt | Now | CreateExclusive | WriteBytes | Stat | Now | Readdir | Rm;
|
|
29
21
|
export type Cas<O extends Operation> = {
|
|
30
|
-
/**
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Streams the content for `hash` out in `<=128 KiB` chunks. Every pull yields an
|
|
24
|
+
* explicit `ok(chunk)` or `error` item, so a missing shard or I/O error is a distinct
|
|
25
|
+
* error *item* in the stream, never collapsed into end-of-stream (`undefined`).
|
|
26
|
+
*/
|
|
27
|
+
readonly read: (hash: Vec) => List<O, IoResult<Vec>>;
|
|
28
|
+
/**
|
|
29
|
+
* Consumes a chunk stream — each item `ok(chunk)` or `error` — hashing incrementally,
|
|
30
|
+
* and returns the content address. An error item aborts the upload.
|
|
31
|
+
*/
|
|
32
|
+
readonly write: <O1 extends Operation>(payload: List<O1, IoResult<Vec>>) => Effect<O | O1, IoResult<Vec>>;
|
|
34
33
|
/** Lists all stored content hashes. */
|
|
35
34
|
readonly list: () => Effect<O, readonly Vec[]>;
|
|
36
35
|
};
|
|
36
|
+
export type FileCas = Cas<FileCasOperation> & {
|
|
37
|
+
url: (v: Vec) => string;
|
|
38
|
+
};
|
|
37
39
|
/**
|
|
38
40
|
* Builds a content-addressable storage facade from a SHA-2 implementation.
|
|
39
41
|
*/
|
|
40
|
-
export declare const
|
|
42
|
+
export declare const fileCas: (sha2: Sha2) => (path: string) => FileCas;
|
|
43
|
+
/**
|
|
44
|
+
* Streams the file at `path` through `cas.write`, returning the content hash.
|
|
45
|
+
* Both the CLI `cas add` and the MCP `add` delegate to this; the MCP layer
|
|
46
|
+
* additionally deletes the source file on success.
|
|
47
|
+
*/
|
|
48
|
+
export declare const casAddFile: <O extends Operation>(cas: Cas<O>) => (path: string) => Effect<O | ReadBytes, IoResult<Vec>>;
|
|
41
49
|
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
50
|
+
* Upload pipeline: streams `fileName` from `~/cas_upload/` through `casAddFile`,
|
|
51
|
+
* then deletes the source on success. On failure the source is left in place so
|
|
52
|
+
* the upload can be retried; `write` already cleans up its own partial staging file.
|
|
45
53
|
*/
|
|
46
|
-
export declare const casUpload: (home: string) => (fileName: string) => Effect<
|
|
47
|
-
export declare const commands: Commands<FileKvStoreOperation | Write | All | MemOp | Read>;
|
|
48
|
-
export declare const main: (options: NodeProgramOptions) => Effect<All | Read | Write | FileKvStoreOperation | MemOp, number>;
|
|
54
|
+
export declare const casUpload: (home: string) => (fileName: string) => Effect<FileCasOperation, IoResult<Vec>>;
|
package/fs/cas/module.f.js
CHANGED
|
@@ -3,18 +3,16 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @module
|
|
5
5
|
*/
|
|
6
|
-
import {
|
|
6
|
+
import { sha256 } from "../crypto/sha2/module.f.js";
|
|
7
7
|
import { join, normalize, parse } from "../path/module.f.js";
|
|
8
8
|
import { empty, length, maxLengthBytes, msb, vec } from "../types/bit_vec/module.f.js";
|
|
9
9
|
import { cBase32ToVec, vecToCBase32 } from "../cbase32/module.f.js";
|
|
10
10
|
import { foldStep, forEachStep, pure } from "../effects/module.f.js";
|
|
11
|
-
import { access,
|
|
12
|
-
import { dispatch } from "../cli/module.f.js";
|
|
13
|
-
import { casMcpServer } from "./mcp/module.f.js";
|
|
11
|
+
import { access, createExclusive, isNotFound, mkdir, now, randomInt, readBytes, readdir, rename, rm, stat, writeBytes, } from "../effects/node/module.f.js";
|
|
14
12
|
import { toOption } from "../types/nullable/module.f.js";
|
|
15
13
|
import { error, ok, unwrap } from "../types/result/module.f.js";
|
|
16
14
|
import { splitAt } from "../types/string/module.f.js";
|
|
17
|
-
|
|
15
|
+
import { nonEmpty, empty as elEmpty } from "../effects/list/module.f.js";
|
|
18
16
|
const split2 = splitAt(2);
|
|
19
17
|
const prefix = '.cas';
|
|
20
18
|
/** Converts a content key to its sharded relative CAS file path. */
|
|
@@ -24,27 +22,134 @@ export const toPath = (key) => {
|
|
|
24
22
|
const [b, c] = split2(bc);
|
|
25
23
|
return join(prefix, a, b, c);
|
|
26
24
|
};
|
|
25
|
+
/** Maximum chunk size for streaming reads: the largest `Vec` the runtime allows. */
|
|
26
|
+
const chunkBytes = Number(maxLengthBytes);
|
|
27
|
+
/** Staging directory under the store root; GC and every uploader share it. */
|
|
28
|
+
const stageRel = '_stage';
|
|
29
|
+
/**
|
|
30
|
+
* Lease duration in ms: a staging file's deadline is `now() + leaseDelta`.
|
|
31
|
+
* Renewed after every chunk, so it only has to cover the gap between two
|
|
32
|
+
* consecutive chunks (see [staging-lease.md](../../issues/cas/staging-lease.md)).
|
|
33
|
+
*/
|
|
34
|
+
const leaseDelta = 30_000;
|
|
35
|
+
/**
|
|
36
|
+
* Fixed width for the zero-padded epoch-ms deadline embedded in a staging name,
|
|
37
|
+
* so names sort lexically exactly as they sort chronologically. 19 digits keeps
|
|
38
|
+
* epoch-ms (13 digits today) padded with headroom well past year 2286.
|
|
39
|
+
*/
|
|
40
|
+
const deadlineWidth = 19;
|
|
41
|
+
/** Builds a `<deadline>-<random256>` staging file name. */
|
|
42
|
+
const stageName = (deadline, rnd) => `${String(deadline).padStart(deadlineWidth, '0')}-${rnd}`;
|
|
43
|
+
/** Recovers the deadline (epoch ms) from a `<deadline>-<random256>` name. */
|
|
44
|
+
const deadlineOf = (name) => Number(name.slice(0, name.indexOf('-')));
|
|
27
45
|
/**
|
|
28
|
-
*
|
|
46
|
+
* Reclaims expired staging files: any `_stage/<deadline>-<rand>` whose deadline
|
|
47
|
+
* is already in the past. Lazy and piggy-backed on `write`. Best-effort — a
|
|
48
|
+
* missing `_stage/` (fresh store) or any `readdir`/`rm` error is ignored, and a
|
|
49
|
+
* still-live lease is left alone; the fencing rename keeps even a misjudged
|
|
50
|
+
* reclaim fail-safe (worst case: that upload restarts).
|
|
29
51
|
*/
|
|
30
|
-
|
|
52
|
+
const gcStage = (stageDir) => now().step(t => readdir(stageDir, {}).step(([k, v]) => {
|
|
53
|
+
if (k === 'error') {
|
|
54
|
+
return pure(undefined);
|
|
55
|
+
}
|
|
56
|
+
const expired = v.flatMap(d => d.isFile && deadlineOf(d.name) < t ? [d.name] : []);
|
|
57
|
+
return forEachStep((name) => rm(join(stageDir, name)).step(() => pure(undefined)))(expired);
|
|
58
|
+
}));
|
|
59
|
+
/**
|
|
60
|
+
* Builds a content-addressable storage facade from a SHA-2 implementation.
|
|
61
|
+
*/
|
|
62
|
+
export const fileCas = (sha2) => (path) => {
|
|
31
63
|
const storePrefix = join(path, prefix);
|
|
32
64
|
const normalizedStorePrefix = normalize(storePrefix);
|
|
65
|
+
const stageDir = join(storePrefix, stageRel);
|
|
33
66
|
return {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
67
|
+
read: (hash) => {
|
|
68
|
+
const p = join(path, toPath(hash));
|
|
69
|
+
const loop = (offset) => readBytes(p, offset, chunkBytes)
|
|
70
|
+
.step((result) => {
|
|
71
|
+
const [t, v] = result;
|
|
72
|
+
// A missing shard or read error is an explicit error item, never EOF.
|
|
73
|
+
if (t === 'error') {
|
|
74
|
+
return nonEmpty(result, elEmpty());
|
|
75
|
+
}
|
|
76
|
+
// End the stream only on an empty read; every non-empty read — including a
|
|
77
|
+
// final short (`< CHUNK_BYTES`) chunk — is emitted as an `ok` item.
|
|
78
|
+
return length(v) === 0n
|
|
79
|
+
? elEmpty()
|
|
80
|
+
: nonEmpty(ok(v), loop(offset + chunkBytes));
|
|
81
|
+
});
|
|
82
|
+
return loop(0);
|
|
83
|
+
},
|
|
84
|
+
// Lock-free staging upload (issues/cas/staging-lease.md): stream each chunk straight
|
|
85
|
+
// to a `_stage/<deadline>-<rand>` file via `writeBytes` while folding it into the
|
|
86
|
+
// running SHA-2 state — the payload never lives in memory as a whole. The lease is
|
|
87
|
+
// renewed (rename to a fresh deadline) after every chunk; any error deletes the
|
|
88
|
+
// partial file and fails. On end-of-stream the file is published to its hash-derived
|
|
89
|
+
// shard path by a replace-`rename` (which also dedups/repairs a same-content shard),
|
|
90
|
+
// and success is confirmed by a `stat` size check. GC of expired staging files is
|
|
91
|
+
// piggy-backed at the start.
|
|
92
|
+
write: (payload) => {
|
|
93
|
+
// Publish the finished staging file to its content-addressed shard. The three
|
|
94
|
+
// filesystem steps run best-effort with their results ignored; success is decided
|
|
95
|
+
// afterward by observing the target's size (see staging-lease.md "Publish ignores
|
|
96
|
+
// results and checks the end state").
|
|
97
|
+
const publish = (state, offset, curPath) => {
|
|
98
|
+
const hash = sha2.end(state);
|
|
99
|
+
const rel = toPath(hash);
|
|
100
|
+
const dst = join(path, rel);
|
|
101
|
+
const dstDir = join(path, ...parse(rel).slice(0, -1));
|
|
102
|
+
return mkdir(dstDir, { recursive: true })
|
|
103
|
+
.step(() => rename(curPath, dst))
|
|
104
|
+
.step(() => rm(curPath))
|
|
105
|
+
.step(() => stat(dst))
|
|
106
|
+
.step(st => pure(st[0] === 'ok' && st[1].size === offset ? ok(hash) : error('publish size mismatch')));
|
|
107
|
+
};
|
|
108
|
+
// Any streaming error fails closed: delete the partial file, return the error.
|
|
109
|
+
const fail = (curPath, e) => rm(curPath).step(() => pure(error(e)));
|
|
110
|
+
return gcStage(stageDir).step(() => random256.step(rnd => {
|
|
111
|
+
const rndStr = vecToCBase32(rnd);
|
|
112
|
+
const loop = (state, offset, curPath) => (stream) => stream
|
|
113
|
+
.step((node) => {
|
|
114
|
+
if (node === undefined) {
|
|
115
|
+
return publish(state, offset, curPath);
|
|
116
|
+
}
|
|
117
|
+
const { first, tail } = node;
|
|
118
|
+
if (first[0] === 'error') {
|
|
119
|
+
return fail(curPath, first[1]);
|
|
120
|
+
}
|
|
121
|
+
const chunk = first[1];
|
|
122
|
+
return writeBytes(curPath, offset, chunk)
|
|
123
|
+
.step(wb => {
|
|
124
|
+
if (wb[0] === 'error') {
|
|
125
|
+
return fail(curPath, wb[1]);
|
|
126
|
+
}
|
|
127
|
+
const newState = sha2.append(chunk)(state);
|
|
128
|
+
const newOffset = offset + Number(length(chunk) / 8n);
|
|
129
|
+
// Renew the lease: rename to a fresh deadline (keeps `delta` constant).
|
|
130
|
+
return now()
|
|
131
|
+
.step(t => {
|
|
132
|
+
const next = join(stageDir, stageName(t + leaseDelta, rndStr));
|
|
133
|
+
return rename(curPath, next).step(([t, v]) => t === 'error'
|
|
134
|
+
? fail(curPath, v)
|
|
135
|
+
: loop(newState, newOffset, next)(tail));
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
return mkdir(stageDir, { recursive: true })
|
|
140
|
+
.step(() => now()
|
|
141
|
+
.step(t0 => {
|
|
142
|
+
const path0 = join(stageDir, stageName(t0 + leaseDelta, rndStr));
|
|
143
|
+
return createExclusive(path0)
|
|
144
|
+
.step(([c, e]) => c === 'error'
|
|
145
|
+
? pure(error(e))
|
|
146
|
+
: loop(sha2.init, 0, path0)(payload));
|
|
147
|
+
}));
|
|
148
|
+
}));
|
|
44
149
|
},
|
|
45
150
|
list: () =>
|
|
46
151
|
// A fresh store has no `.cas` directory yet. Treat *only* that case as an
|
|
47
|
-
// empty store, mirroring how `read` maps a missing
|
|
152
|
+
// empty store, mirroring how `read` maps a missing shard to an error item.
|
|
48
153
|
// A `.cas` that exists but cannot be read (permissions, corruption) is a
|
|
49
154
|
// genuine storage error and is surfaced, not masked as "no hashes".
|
|
50
155
|
access(storePrefix).step(a => {
|
|
@@ -59,126 +164,46 @@ export const fileKvStore = (path) => {
|
|
|
59
164
|
? cBase32ToVec(normalize(parentPath).substring(normalizedStorePrefix.length).replaceAll('/', '') + name)
|
|
60
165
|
: null))));
|
|
61
166
|
}),
|
|
167
|
+
url: (hash) => join(path, toPath(hash))
|
|
62
168
|
};
|
|
63
169
|
};
|
|
64
|
-
/**
|
|
65
|
-
* Builds a content-addressable storage facade from a SHA-2 implementation.
|
|
66
|
-
*/
|
|
67
|
-
export const cas = (sha2) => {
|
|
68
|
-
const compute = computeSync(sha2);
|
|
69
|
-
return ({ read, write, list }) => ({
|
|
70
|
-
read,
|
|
71
|
-
write: (value) => {
|
|
72
|
-
const hash = compute([value]);
|
|
73
|
-
return write(hash, value)
|
|
74
|
-
.step(() => pure(hash));
|
|
75
|
-
},
|
|
76
|
-
list,
|
|
77
|
-
});
|
|
78
|
-
};
|
|
79
|
-
/** Maximum chunk size for streaming reads: the largest `Vec` the runtime allows. */
|
|
80
|
-
const CHUNK_BYTES = Number(maxLengthBytes);
|
|
81
170
|
/** 256-bit random `Vec` built from 8 sequential `randomInt` (32-bit) calls. */
|
|
82
171
|
const random256 = foldStep((_) => (acc) => randomInt().step(r => pure(msb.concat(acc)(vec(32n)(BigInt(r))))))(empty)([0, 1, 2, 3, 4, 5, 6, 7]);
|
|
83
|
-
/**
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
*/
|
|
87
|
-
const streamHash = (sha2) => (path) => {
|
|
88
|
-
const chunkBits = BigInt(CHUNK_BYTES) * 8n;
|
|
89
|
-
const loop = (state, offset) => readBytes(path, offset, CHUNK_BYTES).step(result => {
|
|
172
|
+
/** Streams any file at `filePath` in `<=128 KiB` chunks as a `ListEffect` of `ok` items. */
|
|
173
|
+
const streamFile = (filePath) => {
|
|
174
|
+
const loop = (offset) => readBytes(filePath, offset, chunkBytes).step((result) => {
|
|
90
175
|
if (result[0] === 'error') {
|
|
91
|
-
return
|
|
176
|
+
return nonEmpty(result, elEmpty());
|
|
92
177
|
}
|
|
93
178
|
const chunk = result[1];
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}
|
|
98
|
-
return loop(newState, offset + CHUNK_BYTES);
|
|
179
|
+
return length(chunk) === 0n
|
|
180
|
+
? elEmpty()
|
|
181
|
+
: nonEmpty(ok(chunk), loop(offset + chunkBytes));
|
|
99
182
|
});
|
|
100
|
-
return loop(
|
|
183
|
+
return loop(0);
|
|
101
184
|
};
|
|
102
185
|
/**
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
186
|
+
* Streams the file at `path` through `cas.write`, returning the content hash.
|
|
187
|
+
* Both the CLI `cas add` and the MCP `add` delegate to this; the MCP layer
|
|
188
|
+
* additionally deletes the source file on success.
|
|
189
|
+
*/
|
|
190
|
+
export const casAddFile = (cas) => (path) =>
|
|
191
|
+
// streamFile produces only ReadBytes effects. TypeScript can't prove ListEffect<ReadBytes,T>
|
|
192
|
+
// ≤ ListEffect<O,T> for generic O (recursive type), but the cast is sound: every concrete
|
|
193
|
+
// caller passes a Cas<O> where ReadBytes ⊆ O (e.g. FileCasOperation).
|
|
194
|
+
cas.write(streamFile(path));
|
|
195
|
+
/**
|
|
196
|
+
* Upload pipeline: streams `fileName` from `~/cas_upload/` through `casAddFile`,
|
|
197
|
+
* then deletes the source on success. On failure the source is left in place so
|
|
198
|
+
* the upload can be retried; `write` already cleans up its own partial staging file.
|
|
106
199
|
*/
|
|
107
200
|
export const casUpload = (home) => (fileName) => {
|
|
108
201
|
const src = join(home, 'cas_upload', fileName);
|
|
109
|
-
const
|
|
110
|
-
return
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
.step(r => {
|
|
116
|
-
if (r[0] === 'error') {
|
|
117
|
-
return pure(error(r[1]));
|
|
118
|
-
}
|
|
119
|
-
return streamHash(sha256)(stagePath)
|
|
120
|
-
.step(hashResult => {
|
|
121
|
-
if (hashResult[0] === 'error') {
|
|
122
|
-
return pure(hashResult);
|
|
123
|
-
}
|
|
124
|
-
const hash = hashResult[1];
|
|
125
|
-
const p = toPath(hash);
|
|
126
|
-
const parts = parse(p);
|
|
127
|
-
const finalDir = join(home, ...parts.slice(0, -1));
|
|
128
|
-
const finalPath = join(home, p);
|
|
129
|
-
return mkdir(finalDir, { recursive: true })
|
|
130
|
-
.step(() => rename(stagePath, finalPath))
|
|
131
|
-
.step(r2 => pure(r2[0] === 'error' ? error(r2[1]) : ok(hash)));
|
|
132
|
-
});
|
|
133
|
-
});
|
|
202
|
+
const c = fileCas(sha256)(home);
|
|
203
|
+
return casAddFile(c)(src).step((result) => {
|
|
204
|
+
if (result[0] === 'error') {
|
|
205
|
+
return pure(result);
|
|
206
|
+
}
|
|
207
|
+
return rm(src).step(() => pure(result));
|
|
134
208
|
});
|
|
135
209
|
};
|
|
136
|
-
export const commands = [
|
|
137
|
-
{
|
|
138
|
-
names: ['add'],
|
|
139
|
-
description: 'Store file content and print its hash',
|
|
140
|
-
handler: ({ home, args: [path, ...rest] }) => {
|
|
141
|
-
if (path === undefined || rest.length !== 0) {
|
|
142
|
-
return errorExit("'cas add' expects one parameter");
|
|
143
|
-
}
|
|
144
|
-
const c = cas(sha256)(fileKvStore(home));
|
|
145
|
-
return readFile(path)
|
|
146
|
-
.step(v => c.write(unwrap(v)))
|
|
147
|
-
.step(hash => log(vecToCBase32(hash)))
|
|
148
|
-
.step(() => pure(0));
|
|
149
|
-
},
|
|
150
|
-
},
|
|
151
|
-
{
|
|
152
|
-
names: ['get'],
|
|
153
|
-
description: 'Restore content by hash into a file',
|
|
154
|
-
handler: ({ home, args: [hashCBase32, path, ...rest] }) => {
|
|
155
|
-
if (hashCBase32 === undefined || path === undefined || rest.length !== 0) {
|
|
156
|
-
return errorExit("'cas get' expects two parameters");
|
|
157
|
-
}
|
|
158
|
-
const hash = cBase32ToVec(hashCBase32);
|
|
159
|
-
if (hash === null) {
|
|
160
|
-
return errorExit(`invalid hash format: ${hashCBase32}`);
|
|
161
|
-
}
|
|
162
|
-
const c = cas(sha256)(fileKvStore(home));
|
|
163
|
-
return c.read(hash)
|
|
164
|
-
.step(v => {
|
|
165
|
-
const result = v === undefined
|
|
166
|
-
? errorExit(`no such hash: ${hashCBase32}`)
|
|
167
|
-
: writeFile(path, v)
|
|
168
|
-
.step(() => pure(0));
|
|
169
|
-
return result;
|
|
170
|
-
});
|
|
171
|
-
},
|
|
172
|
-
},
|
|
173
|
-
{
|
|
174
|
-
names: ['list'],
|
|
175
|
-
description: 'List all stored content hashes',
|
|
176
|
-
handler: ({ home }) => {
|
|
177
|
-
const c = cas(sha256)(fileKvStore(home));
|
|
178
|
-
return c.list()
|
|
179
|
-
.step(forEachStep(j => log(vecToCBase32(j))))
|
|
180
|
-
.step(() => pure(0));
|
|
181
|
-
},
|
|
182
|
-
},
|
|
183
|
-
];
|
|
184
|
-
export const main = dispatch(commands);
|