functionalscript 0.32.3 → 0.33.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.
Files changed (41) hide show
  1. package/fs/bnf/data/proof.f.d.ts +3 -0
  2. package/fs/bnf/data/proof.f.js +7 -0
  3. package/fs/cas/mcp/module.f.d.ts +4 -4
  4. package/fs/cas/mcp/module.f.js +21 -17
  5. package/fs/cas/mcp/proof.f.d.ts +1 -0
  6. package/fs/cas/mcp/proof.f.js +62 -42
  7. package/fs/cas/module.f.d.ts +13 -21
  8. package/fs/cas/module.f.js +100 -54
  9. package/fs/cas/proof.f.js +15 -24
  10. package/fs/ci/config/module.f.d.ts +6 -6
  11. package/fs/ci/config/module.f.js +6 -6
  12. package/fs/ci/proof.f.js +8 -6
  13. package/fs/cli/proof.f.js +2 -12
  14. package/fs/djs/parser/module.f.d.ts +0 -5
  15. package/fs/djs/proof.f.js +5 -6
  16. package/fs/djs/serializer/module.f.d.ts +1 -3
  17. package/fs/djs/tokenizer-new/module.f.js +2 -1
  18. package/fs/djs/tokenizer-new/proof.f.d.ts +1 -0
  19. package/fs/djs/tokenizer-new/proof.f.js +101 -67
  20. package/fs/djs/transpiler/proof.f.js +10 -10
  21. package/fs/effects/node/module.f.d.ts +8 -2
  22. package/fs/effects/node/module.f.js +3 -0
  23. package/fs/effects/node/module.js +21 -1
  24. package/fs/effects/node/proof.f.d.ts +15 -0
  25. package/fs/effects/node/proof.f.js +137 -26
  26. package/fs/effects/node/virtual/module.f.d.ts +18 -2
  27. package/fs/effects/node/virtual/module.f.js +185 -10
  28. package/fs/effects/node/virtual/proof.f.d.ts +14 -0
  29. package/fs/effects/node/virtual/proof.f.js +98 -5
  30. package/fs/emergent_testing/proof.f.js +3 -11
  31. package/fs/fjs/module.f.js +3 -3
  32. package/fs/fjs/proof.f.js +2 -11
  33. package/fs/js/tokenizer/module.f.d.ts +5 -0
  34. package/fs/js/tokenizer/module.f.js +13 -0
  35. package/fs/json/serializer/module.f.js +0 -1
  36. package/fs/text/sgr/proof.f.js +2 -11
  37. package/fs/types/bigint/module.f.d.ts +8 -15
  38. package/fs/types/bigint/module.f.js +9 -22
  39. package/fs/types/bigint/proof.f.d.ts +0 -2
  40. package/fs/types/bigint/proof.f.js +6 -16
  41. package/package.json +2 -2
@@ -11,4 +11,7 @@ export declare const proof: {
11
11
  repeat: (() => void)[];
12
12
  repeatParser: (() => void)[];
13
13
  example: () => void;
14
+ throw: {
15
+ ambiguousVariantDispatch: () => void;
16
+ };
14
17
  };
@@ -763,5 +763,12 @@ export const proof = {
763
763
  'digit': 'digit',
764
764
  }
765
765
  };
766
+ },
767
+ throw: {
768
+ ambiguousVariantDispatch: () => {
769
+ // Two alternatives covering the same code point — dispatch merge throws.
770
+ const conflictRule = { 'a': range('AA'), 'b': range('AA') };
771
+ dispatchMap(toData(conflictRule)[0]);
772
+ }
766
773
  }
767
774
  };
@@ -1,9 +1,9 @@
1
1
  import { type Effect, type Operation } from '../../effects/module.f.ts';
2
2
  import { type MemOp } from '../../effects/memory/module.f.ts';
3
3
  import { type Vec } from '../../types/bit_vec/module.f.ts';
4
- import { type Read, type ReadFile, type Write } from '../../effects/node/module.f.ts';
4
+ import { type Mkdir, type RandomInt, type Read, type ReadBytes, type Rename, type Write } from '../../effects/node/module.f.ts';
5
5
  import { type McpConfig, type McpHandlers } from '../../mcp/module.f.ts';
6
- import type { Cas } from '../module.f.ts';
6
+ import { type Cas } from '../module.f.ts';
7
7
  /** Arguments for `cas_add`: content to store, with optional encoding type. */
8
8
  export declare const casAddArgs: {
9
9
  readonly content: import("../../types/rtti/module.f.ts").String;
@@ -24,7 +24,7 @@ export declare const casListArgs: {};
24
24
  * the blob on the local filesystem. When absent (e.g. memory-backed tests),
25
25
  * `url` is omitted.
26
26
  */
27
- export declare const casMcpHandlers: <O extends Operation>(c: Cas<O>, home: string, toUrl?: (hash: Vec) => string) => McpHandlers<ReadFile | O>;
27
+ export declare const casMcpHandlers: <O extends Operation>(c: Cas<O>, home: string, toUrl?: (hash: Vec) => string) => McpHandlers<Mkdir | Rename | RandomInt | ReadBytes | O>;
28
28
  /**
29
29
  * Static MCP configuration for the CAS server: advertises the `tools`
30
30
  * capability, identifies the server, and pins the protocol version.
@@ -37,4 +37,4 @@ export declare const casConfig: McpConfig;
37
37
  *
38
38
  * When `toUrl` is provided, `cas_get` includes the blob's filesystem URL.
39
39
  */
40
- export declare const casMcpServer: <O extends Operation>(c: Cas<O>, home: string, toUrl?: (hash: Vec) => string) => Effect<Read | Write | MemOp | ReadFile | O, void>;
40
+ export declare const casMcpServer: <O extends Operation>(c: Cas<O>, home: string, toUrl?: (hash: Vec) => string) => Effect<Read | Write | MemOp | Mkdir | Rename | RandomInt | ReadBytes | O, void>;
@@ -9,9 +9,9 @@
9
9
  *
10
10
  * ## Tools
11
11
  *
12
- * | Tool | args | CAS call | result |
12
+ * | Tool | args | action | result |
13
13
  * |------------|-------------------------------|------------------|-------------------------------------|
14
- * | `cas_add` | `{ content, type? }` | `c.write(value)` | hash (cBase32) |
14
+ * | `cas_add` | `{ content, type? }` | write/upload | hash (cBase32) |
15
15
  * | `cas_get` | `{ hash, content?: boolean }` | `c.read(key)` | JSON `{length,mime_type,type[,content]}` |
16
16
  * | `cas_list` | `{}` | `c.list()` | hashes, one per line |
17
17
  *
@@ -24,8 +24,8 @@
24
24
  * - `'base64'`: `content` is RFC 4648 base64, decoded to bytes before storage.
25
25
  * Use this for pre-encoded binary payloads.
26
26
  * - `'url'`: `content` is a filesystem path within `$HOME/cas_upload/`; the server
27
- * reads the file at that path and stores its raw bytes. Paths outside
28
- * `$HOME/cas_upload/` or containing `..` are rejected for security.
27
+ * moves the file via the streaming move-hash-move pipeline (no size limit).
28
+ * Paths outside `$HOME/cas_upload/` or containing `..` are rejected for security.
29
29
  *
30
30
  * ## `cas_get` output
31
31
  *
@@ -71,9 +71,10 @@ import { decode as base64Decode, encode as base64Encode } from "../../base64/mod
71
71
  import { utf8 } from "../../text/module.f.js";
72
72
  import { detect } from "../../mime/module.f.js";
73
73
  import { length as bitVecLength } from "../../types/bit_vec/module.f.js";
74
- import { readFile } from "../../effects/node/module.f.js";
74
+ import {} from "../../effects/node/module.f.js";
75
75
  import { stdioTransport } from "../../mcp/stdio/module.f.js";
76
76
  import { mcpStep, uninitializedState, toolEntry, fromRegistry, errorResult, } from "../../mcp/module.f.js";
77
+ import { casUpload } from "../module.f.js";
77
78
  import { fromVec } from "../../text/utf8/module.f.js";
78
79
  // ── Argument schemas (declared once, used for both inputSchema and validate) ─────
79
80
  /** Arguments for `cas_add`: content to store, with optional encoding type. */
@@ -87,19 +88,20 @@ export const casListArgs = {};
87
88
  const casToolRegistry = (c, home, toUrl) => {
88
89
  const casUploadDir = `${home}/cas_upload`;
89
90
  return [
90
- toolEntry('cas_add', 'Store content and return its hash (cBase32). Pass type:"base64" for binary; type:"url" to read from a filesystem path within $HOME/cas_upload/; omit or pass type:"text" for UTF-8 text (default).', casAddArgs, ({ type, content }) => {
91
+ toolEntry('cas_add', 'Store content and return its hash (cBase32). Pass type:"base64" for binary; type:"url" to stream a file from $HOME/cas_upload/ (no size limit); omit or pass type:"text" for UTF-8 text (default).', casAddArgs, ({ type, content }) => {
92
+ // type:'url' — streaming move-hash-move pipeline (no size limit)
93
+ if (type === 'url') {
94
+ if (!content.startsWith(`${casUploadDir}/`) || content.includes('..')) {
95
+ return pure(errorResult(`cas_add type:url paths must be within ${casUploadDir}/ — got: ${content}`));
96
+ }
97
+ const fileName = content.slice(`${casUploadDir}/`.length);
98
+ return casUpload(home)(fileName).step(result => pure(result[0] === 'error'
99
+ ? errorResult(`upload failed: ${result[1]}`)
100
+ : okResult(vecToCBase32(result[1]))));
101
+ }
102
+ // type:'text' or 'base64' — resolve content to Vec, store via c.write()
91
103
  let x;
92
104
  switch (type) {
93
- case 'url':
94
- if (!content.startsWith(`${casUploadDir}/`) || content.includes('..')) {
95
- x = pure(`cas_add type:url paths must be within ${casUploadDir}/ — got: ${content}`);
96
- }
97
- else {
98
- x = readFile(content).step(([t, v]) => pure(t === 'error'
99
- ? `cannot read file: ${content}: ${v}`
100
- : v));
101
- }
102
- break;
103
105
  case 'base64':
104
106
  const value = base64Decode(content);
105
107
  x = pure(value === null ? `invalid base64 content: ${content}` : value);
@@ -110,7 +112,9 @@ const casToolRegistry = (c, home, toUrl) => {
110
112
  }
111
113
  return x.step(value => typeof value === 'string'
112
114
  ? pure(errorResult(value))
113
- : c.write(value).step(hash => pure(okResult(vecToCBase32(hash)))));
115
+ : c.write(value).step(hash => pure(hash === undefined
116
+ ? errorResult('write')
117
+ : okResult(vecToCBase32(hash)))));
114
118
  }),
115
119
  toolEntry('cas_get', 'Inspect a blob by hash. Always returns JSON {length,mime_type,type[,url]} where type is "text" or "base64". Pass content:true to also include the inline content string.', casGetArgs, r => {
116
120
  const key = cBase32ToVec(r.hash);
@@ -27,6 +27,7 @@ export declare const proof: {
27
27
  getMetaNoUrlWhenToUrlAbsent: () => void;
28
28
  getMetaMissingHashIsError: () => void;
29
29
  getMetaInvalidHashIsError: () => void;
30
+ addUrlFromSubdirectorySucceeds: () => void;
30
31
  addUrlFromApprovedDirectorySucceeds: () => void;
31
32
  addUrlFromRandomDirectoryIsRejected: () => void;
32
33
  addUrlWithPathTraversalIsRejected: () => void;
@@ -5,13 +5,15 @@ import { asBase, asNominal, create, read, write } from "../../effects/memory/mod
5
5
  import { msb, u8ListToVec, vec8 } 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";
8
+ import { computeSync, sha256 } from "../../crypto/sha2/module.f.js";
9
9
  import { utf8 } from "../../text/module.f.js";
10
- import { cas } from "../module.f.js";
10
+ import { fileCas } from "../module.f.js";
11
11
  import { mcpStep, uninitializedState, } from "../../mcp/module.f.js";
12
12
  import {} from "../../effects/node/module.f.js";
13
+ import { emptyState, virtual } from "../../effects/node/virtual/module.f.js";
13
14
  import { casConfig, casMcpHandlers } from "./module.f.js";
14
- const initialTestState = { memory: { next: 0, values: {} }, files: {} };
15
+ import { ok as resultOk } from "../../types/result/module.f.js";
16
+ const initialTestState = { memory: { next: 0, values: {} } };
15
17
  const mock = {
16
18
  memCreate: value => state => {
17
19
  const id = `k${state.memory.next}`;
@@ -26,18 +28,20 @@ const mock = {
26
28
  const id = asBase(key);
27
29
  return [{ ...state, memory: { ...state.memory, values: { ...state.memory.values, [id]: value } } }, undefined];
28
30
  },
29
- readFile: path => state => {
30
- const v = state.files[path];
31
- return v !== undefined
32
- ? [state, ['ok', v]]
33
- : [state, ['error', new Error(`ENOENT: ${path}`)]];
34
- },
31
+ mkdir: (_path, _opts) => state => [state, resultOk(undefined)],
32
+ rename: (_src, _dst) => _ => { throw new Error('rename not supported in memory mock'); },
33
+ readBytes: (_path, _offset, _size) => _ => { throw new Error('readBytes not supported in memory mock'); },
34
+ randomInt: () => _ => { throw new Error('randomInt not supported in memory mock'); },
35
35
  };
36
36
  const runMem = (effect) => run(mock)(initialTestState)(effect)[1];
37
- const runMemWithFiles = (files) => (effect) => run(mock)({ memory: { next: 0, values: {} }, files })(effect)[1];
38
- const memKvStore = (mapKey) => ({
37
+ const memCas = (mapKey) => ({
39
38
  read: (key) => read(mapKey).step(m => pure(m[vecToCBase32(key)]?.[1])),
40
- write: (key, value) => read(mapKey).step(m => write(mapKey, { ...m, [vecToCBase32(key)]: [key, value] })),
39
+ write: (value) => {
40
+ const key = computeSync(sha256)([value]);
41
+ return read(mapKey)
42
+ .step(m => write(mapKey, { ...m, [vecToCBase32(key)]: [key, value] }))
43
+ .step(() => pure(key));
44
+ },
41
45
  list: () => read(mapKey).step(m => pure(Object.values(m).map(([k]) => k))),
42
46
  });
43
47
  // ── Session driver ──────────────────────────────────────────────────────────────
@@ -50,16 +54,21 @@ const feed = (step) => (msgs) => {
50
54
  };
51
55
  // Runs a full session over a fresh in-memory CAS, returning all responses.
52
56
  const runSession = (msgs, home = '/home/user') => runMem(create({}).step(mapKey => create(uninitializedState).step(sessionKey => {
53
- const c = cas(sha256)(memKvStore(mapKey));
54
- const step = mcpStep(casConfig)(casMcpHandlers(c, home))(sessionKey);
55
- return feed(step)(msgs);
56
- })));
57
- // Runs a session with a mocked filesystem (for cas_add with type:'url' tests).
58
- const runSessionWithFiles = (files, home = '/home/user') => (msgs) => runMemWithFiles(files)(create({}).step(mapKey => create(uninitializedState).step(sessionKey => {
59
- const c = cas(sha256)(memKvStore(mapKey));
57
+ const c = memCas(mapKey);
60
58
  const step = mcpStep(casConfig)(casMcpHandlers(c, home))(sessionKey);
61
59
  return feed(step)(msgs);
62
60
  })));
61
+ // Runs a session backed by the virtual node runner (for cas_upload which uses
62
+ // Rename/ReadBytes/RandomInt/Mkdir). Uses fileKvStore so upload and get share
63
+ // the same filesystem-backed CAS.
64
+ const runSessionVirtual = (root, home = '/home/user') => (msgs) => {
65
+ const effect = create(uninitializedState).step(sessionKey => {
66
+ const c = fileCas(sha256)(home);
67
+ const step = mcpStep(casConfig)(casMcpHandlers(c, home))(sessionKey);
68
+ return feed(step)(msgs);
69
+ });
70
+ return virtual({ ...emptyState, root })(effect)[1];
71
+ };
63
72
  // ── Messages ────────────────────────────────────────────────────────────────────
64
73
  const init = { jsonrpc: '2.0', method: 'initialize', id: 1,
65
74
  params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'c', version: '0' } } };
@@ -211,10 +220,11 @@ export const proof = {
211
220
  assert(!('error' in resp));
212
221
  assert('result' in resp);
213
222
  },
214
- // cas_add with type:'url' reads a file from /home/user/cas_upload/ and stores it.
223
+ // cas_add with type:'url' streams a file from /home/user/cas_upload/ into CAS.
215
224
  addUrlStoresFileAndReturnsHash: () => {
216
225
  const fileContent = utf8('hello from file');
217
- const [addUrlResp] = runSessionWithFiles({ '/home/user/cas_upload/hello.txt': fileContent })([
226
+ const root = { 'home': { 'user': { 'cas_upload': { 'hello.txt': [fileContent] } } } };
227
+ const [addUrlResp] = runSessionVirtual(root)([
218
228
  init, initialized,
219
229
  call(2, 'cas_add', { content: '/home/user/cas_upload/hello.txt', type: 'url' }),
220
230
  ]).slice(2);
@@ -223,23 +233,26 @@ export const proof = {
223
233
  },
224
234
  addUrlRoundTrips: () => {
225
235
  const fileContent = utf8('round-trip content');
226
- const msgs = runSessionWithFiles({ '/home/user/cas_upload/rt.txt': fileContent })([
236
+ const root = { 'home': { 'user': { 'cas_upload': { 'rt.txt': [fileContent] } } } };
237
+ // First pass: add to get the hash (deterministic for same content).
238
+ const [addResp] = runSessionVirtual(root)([
227
239
  init, initialized,
228
240
  call(2, 'cas_add', { content: '/home/user/cas_upload/rt.txt', type: 'url' }),
229
241
  ]).slice(2);
230
- const hash = textOf(msgs[0]);
231
- const msgs2 = runSessionWithFiles({ '/home/user/cas_upload/rt.txt': fileContent })([
242
+ const hash = textOf(addResp);
243
+ // Second pass: add again + get in one session (file re-present in fresh virtual state).
244
+ const [, getResp] = runSessionVirtual(root)([
232
245
  init, initialized,
233
246
  call(2, 'cas_add', { content: '/home/user/cas_upload/rt.txt', type: 'url' }),
234
247
  call(3, 'cas_get', { hash, content: true }),
235
248
  ]).slice(2);
236
- assert(!resultOf(msgs2[1]).isError);
237
- const result = JSON.parse(textOf(msgs2[1]));
249
+ assert(!resultOf(getResp).isError);
250
+ const result = JSON.parse(textOf(getResp));
238
251
  assertEq(result.type, 'text');
239
252
  assertEq(result.content, 'round-trip content');
240
253
  },
241
254
  addUrlMissingFileIsError: () => {
242
- const [resp] = runSessionWithFiles({})([
255
+ const [resp] = runSessionVirtual({})([
243
256
  init, initialized,
244
257
  call(2, 'cas_add', { content: '/home/user/cas_upload/nonexistent.txt', type: 'url' }),
245
258
  ]).slice(2);
@@ -248,18 +261,21 @@ export const proof = {
248
261
  // cas_get without content:true returns only metadata.
249
262
  getMetaReturnsLengthAndMimeType: () => {
250
263
  const fileContent = utf8('text content');
251
- const [addResp] = runSessionWithFiles({ '/home/user/cas_upload/f': fileContent })([
264
+ const root = { 'home': { 'user': { 'cas_upload': { 'f': [fileContent] } } } };
265
+ // First pass: add to get the hash.
266
+ const [addResp] = runSessionVirtual(root)([
252
267
  init, initialized,
253
268
  call(2, 'cas_add', { content: '/home/user/cas_upload/f', type: 'url' }),
254
269
  ]).slice(2);
255
270
  const hash = textOf(addResp);
256
- const [, metaResp2] = runSessionWithFiles({ '/home/user/cas_upload/f': fileContent })([
271
+ // Second pass: add + get metadata.
272
+ const [, metaResp] = runSessionVirtual(root)([
257
273
  init, initialized,
258
274
  call(2, 'cas_add', { content: '/home/user/cas_upload/f', type: 'url' }),
259
275
  call(3, 'cas_get', { hash }),
260
276
  ]).slice(2);
261
- assert(!resultOf(metaResp2).isError);
262
- const meta = JSON.parse(textOf(metaResp2));
277
+ assert(!resultOf(metaResp).isError);
278
+ const meta = JSON.parse(textOf(metaResp));
263
279
  assertEq(meta.mime_type, 'text/plain');
264
280
  assertEq(meta.type, 'text');
265
281
  assertEq(meta.length, Number(BigInt(/* 'text content'.length */ 12)));
@@ -307,10 +323,22 @@ export const proof = {
307
323
  const [resp] = session(call(2, 'cas_get', { hash: 'bad!' }));
308
324
  assertEq(resultOf(resp).isError, true);
309
325
  },
326
+ // cas_add type:'url' with a subdirectory path flattens slashes to '-' in staging.
327
+ addUrlFromSubdirectorySucceeds: () => {
328
+ const fileContent = utf8('nested file content');
329
+ const root = { 'home': { 'user': { 'cas_upload': { 'subdir': { 'file.txt': [fileContent] } } } } };
330
+ const [resp] = runSessionVirtual(root)([
331
+ init, initialized,
332
+ call(2, 'cas_add', { content: '/home/user/cas_upload/subdir/file.txt', type: 'url' }),
333
+ ]).slice(2);
334
+ assert(!resultOf(resp).isError);
335
+ assert(textOf(resp).length > 0);
336
+ },
310
337
  // cas_add with type:'url' accepts paths within /home/user/cas_upload/
311
338
  addUrlFromApprovedDirectorySucceeds: () => {
312
339
  const fileContent = utf8('approved file');
313
- const [resp] = runSessionWithFiles({ '/home/user/cas_upload/test.txt': fileContent })([
340
+ const root = { 'home': { 'user': { 'cas_upload': { 'test.txt': [fileContent] } } } };
341
+ const [resp] = runSessionVirtual(root)([
314
342
  init, initialized,
315
343
  call(2, 'cas_add', { content: '/home/user/cas_upload/test.txt', type: 'url' }),
316
344
  ]).slice(2);
@@ -319,21 +347,13 @@ export const proof = {
319
347
  },
320
348
  // cas_add with type:'url' rejects paths outside /home/user/cas_upload/
321
349
  addUrlFromRandomDirectoryIsRejected: () => {
322
- const fileContent = utf8('forbidden file');
323
- const [resp] = runSessionWithFiles({ '/tmp/secret.txt': fileContent })([
324
- init, initialized,
325
- call(2, 'cas_add', { content: '/tmp/secret.txt', type: 'url' }),
326
- ]).slice(2);
350
+ const [resp] = session(call(2, 'cas_add', { content: '/tmp/secret.txt', type: 'url' }));
327
351
  assert(resultOf(resp).isError === true);
328
352
  assert(textOf(resp).includes('/home/user/cas_upload/'));
329
353
  },
330
354
  // cas_add with type:'url' rejects path traversal attempts with ..
331
355
  addUrlWithPathTraversalIsRejected: () => {
332
- const fileContent = utf8('secret content');
333
- const [resp] = runSessionWithFiles({ '/home/user/cas_upload/../../etc/passwd': fileContent })([
334
- init, initialized,
335
- call(2, 'cas_add', { content: '/home/user/cas_upload/../../etc/passwd', type: 'url' }),
336
- ]).slice(2);
356
+ const [resp] = session(call(2, 'cas_add', { content: '/home/user/cas_upload/../../etc/passwd', type: 'url' }));
337
357
  assert(resultOf(resp).isError === true);
338
358
  assert(textOf(resp).includes('/home/user/cas_upload/'));
339
359
  },
@@ -4,39 +4,31 @@
4
4
  * @module
5
5
  */
6
6
  import { type Sha2 } from '../crypto/sha2/module.f.ts';
7
- import type { Vec } from '../types/bit_vec/module.f.ts';
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 All, type Mkdir, type NodeProgramOptions, type Read, type Readdir, type ReadFile, type Write, type WriteFile } from '../effects/node/module.f.ts';
9
+ import { type Access, type All, type IoResult, type Mkdir, type NodeProgramOptions, type RandomInt, type Read, type ReadBytes, type Readdir, type ReadFile, type Rename, type Write, type WriteFile } from '../effects/node/module.f.ts';
10
10
  import { type Commands } from '../cli/module.f.ts';
11
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];
22
12
  /** Converts a content key to its sharded relative CAS file path. */
23
13
  export declare const toPath: (key: Vec) => string;
24
- export type FileKvStoreOperation = ReadFile | Mkdir | WriteFile | Access | Readdir;
25
- /**
26
- * Creates a filesystem-backed key/value store under the provided root path.
27
- */
28
- export declare const fileKvStore: (path: string) => KvStore<FileKvStoreOperation>;
14
+ export type FileCasOperation = ReadFile | Mkdir | WriteFile | Access | Readdir;
29
15
  export type Cas<O extends Operation> = {
30
16
  /** Reads content by hash; returns `undefined` when not found. */
31
17
  readonly read: (key: Vec) => Effect<O, Vec | undefined>;
32
18
  /** Stores content and returns its computed hash. */
33
- readonly write: (value: Vec) => Effect<O, Vec>;
19
+ readonly write: (value: Vec) => Effect<O, Vec | undefined>;
34
20
  /** Lists all stored content hashes. */
35
21
  readonly list: () => Effect<O, readonly Vec[]>;
36
22
  };
37
23
  /**
38
24
  * Builds a content-addressable storage facade from a SHA-2 implementation.
39
25
  */
40
- export declare const cas: (sha2: Sha2) => <O extends Operation>(_: KvStore<O>) => Cas<O>;
41
- export declare const commands: Commands<FileKvStoreOperation | Write | All | MemOp | Read>;
42
- export declare const main: (options: NodeProgramOptions) => Effect<All | Read | Write | FileKvStoreOperation | MemOp, number>;
26
+ export declare const fileCas: (sha2: Sha2) => (path: string) => Cas<FileCasOperation>;
27
+ /**
28
+ * Move-hash-move upload pipeline: moves `fileName` from `~/cas_upload/` to a
29
+ * random staging path, stream-hashes it, then renames it to its final CAS shard
30
+ * path. Returns `ok(hash)` on success or `error(reason)` on any I/O failure.
31
+ */
32
+ export declare const casUpload: (home: string) => (fileName: string) => Effect<Mkdir | Rename | RandomInt | ReadBytes, IoResult<Vec>>;
33
+ export declare const commands: Commands<FileCasOperation | Write | All | MemOp | Read>;
34
+ export declare const main: (options: NodeProgramOptions) => Effect<All | Read | Write | FileCasOperation | MemOp, number>;
@@ -5,15 +5,14 @@
5
5
  */
6
6
  import { computeSync, sha256 } from "../crypto/sha2/module.f.js";
7
7
  import { join, normalize, parse } from "../path/module.f.js";
8
+ import { empty, length, maxLengthBytes, msb, vec } from "../types/bit_vec/module.f.js";
8
9
  import { cBase32ToVec, vecToCBase32 } from "../cbase32/module.f.js";
9
- import { forEachStep, pure } from "../effects/module.f.js";
10
- import { access, errorExit, isNotFound, log, mkdir, readdir, readFile, writeFile } from "../effects/node/module.f.js";
10
+ import { foldStep, forEachStep, pure } from "../effects/module.f.js";
11
+ import { access, errorExit, isNotFound, log, mkdir, randomInt, readBytes, readdir, readFile, rename, writeFile } from "../effects/node/module.f.js";
11
12
  import { dispatch } from "../cli/module.f.js";
12
- import { casMcpServer } from "./mcp/module.f.js";
13
13
  import { toOption } from "../types/nullable/module.f.js";
14
- import { unwrap } from "../types/result/module.f.js";
14
+ import { error, ok, unwrap } from "../types/result/module.f.js";
15
15
  import { splitAt } from "../types/string/module.f.js";
16
- const o = { withFileTypes: true };
17
16
  const split2 = splitAt(2);
18
17
  const prefix = '.cas';
19
18
  /** Converts a content key to its sharded relative CAS file path. */
@@ -24,55 +23,101 @@ export const toPath = (key) => {
24
23
  return join(prefix, a, b, c);
25
24
  };
26
25
  /**
27
- * Creates a filesystem-backed key/value store under the provided root path.
26
+ * Builds a content-addressable storage facade from a SHA-2 implementation.
28
27
  */
29
- export const fileKvStore = (path) => {
30
- const storePrefix = join(path, prefix);
31
- const normalizedStorePrefix = normalize(storePrefix);
32
- return {
33
- // TODO: extend the interface with `Result<Vec, unknown>` instead of `Vec|undefined`.
34
- read: (key) => readFile(join(path, toPath(key))).step(([t, v]) => pure(t === 'ok' ? v : undefined)),
35
- write: (key, value) => {
36
- const p = toPath(key);
37
- const parts = parse(p);
38
- const dir = join(path, ...parts.slice(0, -1));
39
- // TODO: error handling
40
- return mkdir(dir, { recursive: true })
41
- .step(() => writeFile(join(path, p), value))
42
- .step(() => pure(undefined));
43
- },
44
- list: () =>
45
- // A fresh store has no `.cas` directory yet. Treat *only* that case as an
46
- // empty store, mirroring how `read` maps a missing file to `undefined`.
47
- // A `.cas` that exists but cannot be read (permissions, corruption) is a
48
- // genuine storage error and is surfaced, not masked as "no hashes".
49
- access(storePrefix).step(a => {
50
- if (a[0] === 'error') {
51
- if (isNotFound(a[1])) {
52
- return pure([]);
28
+ export const fileCas = (sha2) => {
29
+ const compute = computeSync(sha2);
30
+ return (path) => {
31
+ const storePrefix = join(path, prefix);
32
+ const normalizedStorePrefix = normalize(storePrefix);
33
+ return {
34
+ // TODO: extend the interface with `Result<Vec, unknown>` instead of `Vec|undefined`.
35
+ read: (key) => readFile(join(path, toPath(key))).step(([t, v]) => pure(t === 'ok' ? v : undefined)),
36
+ write: (value) => {
37
+ const hash = compute([value]);
38
+ const p = toPath(hash);
39
+ const parts = parse(p);
40
+ const dir = join(path, ...parts.slice(0, -1));
41
+ // TODO: error handling
42
+ return mkdir(dir, { recursive: true })
43
+ .step(() => writeFile(join(path, p), value))
44
+ .step(([t]) => pure(t === 'ok' ? hash : undefined));
45
+ },
46
+ list: () =>
47
+ // A fresh store has no `.cas` directory yet. Treat *only* that case as an
48
+ // empty store, mirroring how `read` maps a missing file to `undefined`.
49
+ // A `.cas` that exists but cannot be read (permissions, corruption) is a
50
+ // genuine storage error and is surfaced, not masked as "no hashes".
51
+ access(storePrefix).step(a => {
52
+ if (a[0] === 'error') {
53
+ if (isNotFound(a[1])) {
54
+ return pure([]);
55
+ }
56
+ throw a[1];
53
57
  }
54
- throw a[1];
55
- }
56
- return readdir(storePrefix, { recursive: true })
57
- .step(r => pure(unwrap(r).flatMap(({ name, parentPath, isFile }) => toOption(isFile
58
- ? cBase32ToVec(normalize(parentPath).substring(normalizedStorePrefix.length).replaceAll('/', '') + name)
59
- : null))));
60
- }),
58
+ return readdir(storePrefix, { recursive: true })
59
+ .step(r => pure(unwrap(r).flatMap(({ name, parentPath, isFile }) => toOption(isFile
60
+ ? cBase32ToVec(normalize(parentPath).substring(normalizedStorePrefix.length).replaceAll('/', '') + name)
61
+ : null))));
62
+ }),
63
+ };
61
64
  };
62
65
  };
66
+ /** Maximum chunk size for streaming reads: the largest `Vec` the runtime allows. */
67
+ const CHUNK_BYTES = Number(maxLengthBytes);
68
+ /** 256-bit random `Vec` built from 8 sequential `randomInt` (32-bit) calls. */
69
+ const random256 = foldStep((_) => (acc) => randomInt().step(r => pure(msb.concat(acc)(vec(32n)(BigInt(r))))))(empty)([0, 1, 2, 3, 4, 5, 6, 7]);
63
70
  /**
64
- * Builds a content-addressable storage facade from a SHA-2 implementation.
71
+ * Streams a file in `CHUNK_BYTES` chunks, feeding each into the SHA-2 state,
72
+ * and returns the final hash without loading the whole file into memory.
65
73
  */
66
- export const cas = (sha2) => {
67
- const compute = computeSync(sha2);
68
- return ({ read, write, list }) => ({
69
- read,
70
- write: (value) => {
71
- const hash = compute([value]);
72
- return write(hash, value)
73
- .step(() => pure(hash));
74
- },
75
- list,
74
+ const streamHash = (sha2) => (path) => {
75
+ const chunkBits = BigInt(CHUNK_BYTES) * 8n;
76
+ const loop = (state, offset) => readBytes(path, offset, CHUNK_BYTES).step(result => {
77
+ if (result[0] === 'error') {
78
+ return pure(error(result[1]));
79
+ }
80
+ const chunk = result[1];
81
+ const newState = sha2.append(chunk)(state);
82
+ if (length(chunk) < chunkBits) {
83
+ return pure(ok(sha2.end(newState)));
84
+ }
85
+ return loop(newState, offset + CHUNK_BYTES);
86
+ });
87
+ return loop(sha2.init, 0);
88
+ };
89
+ /**
90
+ * Move-hash-move upload pipeline: moves `fileName` from `~/cas_upload/` to a
91
+ * random staging path, stream-hashes it, then renames it to its final CAS shard
92
+ * path. Returns `ok(hash)` on success or `error(reason)` on any I/O failure.
93
+ */
94
+ export const casUpload = (home) => (fileName) => {
95
+ const src = join(home, 'cas_upload', fileName);
96
+ const stageDir = join(home, prefix, '.stage');
97
+ return random256.step(rnd => {
98
+ const rndStr = vecToCBase32(rnd);
99
+ const stagePath = join(stageDir, `${rndStr}-${fileName.replaceAll('/', '-')}`);
100
+ return mkdir(stageDir, { recursive: true })
101
+ .step(() => rename(src, stagePath))
102
+ .step(r => {
103
+ if (r[0] === 'error') {
104
+ return pure(error(r[1]));
105
+ }
106
+ return streamHash(sha256)(stagePath)
107
+ .step(hashResult => {
108
+ if (hashResult[0] === 'error') {
109
+ return pure(hashResult);
110
+ }
111
+ const hash = hashResult[1];
112
+ const p = toPath(hash);
113
+ const parts = parse(p);
114
+ const finalDir = join(home, ...parts.slice(0, -1));
115
+ const finalPath = join(home, p);
116
+ return mkdir(finalDir, { recursive: true })
117
+ .step(() => rename(stagePath, finalPath))
118
+ .step(r2 => pure(r2[0] === 'error' ? error(r2[1]) : ok(hash)));
119
+ });
120
+ });
76
121
  });
77
122
  };
78
123
  export const commands = [
@@ -83,11 +128,12 @@ export const commands = [
83
128
  if (path === undefined || rest.length !== 0) {
84
129
  return errorExit("'cas add' expects one parameter");
85
130
  }
86
- const c = cas(sha256)(fileKvStore(home));
131
+ const c = fileCas(sha256)(home);
87
132
  return readFile(path)
88
133
  .step(v => c.write(unwrap(v)))
89
- .step(hash => log(vecToCBase32(hash)))
90
- .step(() => pure(0));
134
+ .step(hash => hash === undefined
135
+ ? pure(1)
136
+ : log(vecToCBase32(hash)).step(() => pure(0)));
91
137
  },
92
138
  },
93
139
  {
@@ -101,7 +147,7 @@ export const commands = [
101
147
  if (hash === null) {
102
148
  return errorExit(`invalid hash format: ${hashCBase32}`);
103
149
  }
104
- const c = cas(sha256)(fileKvStore(home));
150
+ const c = fileCas(sha256)(home);
105
151
  return c.read(hash)
106
152
  .step(v => {
107
153
  const result = v === undefined
@@ -116,11 +162,11 @@ export const commands = [
116
162
  names: ['list'],
117
163
  description: 'List all stored content hashes',
118
164
  handler: ({ home }) => {
119
- const c = cas(sha256)(fileKvStore(home));
165
+ const c = fileCas(sha256)(home);
120
166
  return c.list()
121
167
  .step(forEachStep(j => log(vecToCBase32(j))))
122
168
  .step(() => pure(0));
123
169
  },
124
- }
170
+ },
125
171
  ];
126
172
  export const main = dispatch(commands);