---
name: framework-fs
description: >-
  Guides correct use of @t4h.framework/fs in T4H Framework workflows: the
  FileSystemClaim abstraction that the package activities (http, pdf) use to
  read/write files, the Binary result workflows consume, and runtime providers.
  A workflow never implements Binary itself — see the framework-fs-write skill.
  Use when a workflow orchestrates file-backed package activities, consuming
  Binary results, mocking FileSystemClaim in workflow tests, registering fs
  claim providers, or debugging missing fs provider errors.
---

# @t4h.framework/fs

File system **claim** for reading and writing files inside workflow activities. The package defines only the abstract contract; concrete storage (local disk, object storage, etc.) is supplied by the **runtime** or **dev** claim registry.

Package path: `framework/packages/fs`. Peer dependency: `@t4h.framework/core` (`Binary`).

---

## Import surface

Public exports live in `src/fs.ts`:

```typescript
import {
  FileSystemClaim,
  // file fan-out / fold / windows — see the framework-fs-split skill
  split, Split, FileSplitActivity, type FileSplitOptions,
  join, FileJoinActivity, type FileJoinOptions, type FileJoinWhere,
  read, FileReadActivity, type FileReadOptions,
  // turn workflow content into a Binary — see the framework-fs-write skill
  write, FileWriteActivity, type FileWriteOptions,
} from '@t4h.framework/fs'
```

---

## FileSystemClaim contract

```typescript
import type { Readable } from 'node:stream'
import type { Binary } from '@t4h.framework/core'

export abstract class FileSystemClaim {
  public abstract write(
    path: string,
    readable: Readable | Buffer,
    contentType?: string,
  ): Binary | Promise<Binary>

  public abstract read(path: string): Binary | Promise<Binary>

  public abstract slice(
    binary: Binary,
    range: { start: number; end: number },
    encoding?: BufferEncoding,
  ): Binary | Promise<Binary>
}
```

| Method | Purpose |
|--------|---------|
| `write(path, readable, contentType?)` | Persist stream or buffer at `path`; return a `Binary` handle (metadata + `stream()`). When `contentType` is omitted, infer it from the path. |
| `read(path)` | Load an existing file at `path`; return a `Binary` handle. |
| `slice(binary, range, encoding?)` | A view over a byte range of an existing binary, **without copying data**. When `encoding` is set, the runtime hydrates the slice as a decoded string instead of a binary — this is how `read` returns a window as a string. Implementations must **compose ranges**: slicing a slice is relative to it, and the stored range is absolute against the underlying file. |

All methods may return synchronously or as a `Promise`. Implementations choose storage layout; callers should only rely on the returned `Binary` API.

### Binary (from `@t4h.framework/core`)

```typescript
export abstract class Binary {
  public abstract readonly contentType: string
  public abstract readonly contentLength: number
  public abstract stream(): Readable | Promise<Readable>
}
```

The package activities return this `Binary` to your workflow — e.g. an `http`
response `Body` is backed by it. The workflow only passes the `Binary` to
another activity. An activity reads it in `run`:

```typescript
async run(file: Binary) {
  const stream = await file.stream()
  // consume stream (e.g. stream.toArray() then Buffer.concat)
}
```

---

## How workflows use it

Workflows never call `FileSystemClaim` directly. File I/O happens inside the
**ready-made package activities** that already own the claim — the `http` client
persists response bodies, the pdf render activity writes its output — and you
read the resulting `Binary` (e.g. `await res.body.json()` / `text()`). Your only
responsibility is that the runtime **registers a provider** (below). See
**Known consumers** for which package primitives to orchestrate.

This package's own workflow-facing primitives are `split`, `join`, `read`
(small byte windows — headers, trailers — hydrated into the workflow as a
string, capped at 1 MiB) and `write` (content produced in the workflow turned
into a file). See the `framework-fs-split` and `framework-fs-write` skills.

**A workflow never implements `Binary`.** Every `Binary` comes from the
framework — an HTTP response body, `sftp.get`, `write`, `join`. A class holding
bytes in memory cannot be persisted, and every primitive it is handed to
persists its input. See the `framework-fs-write` skill.

---

## Runtime provider

The runtime must register a concrete subclass:

```typescript
{ provide: FileSystemClaim, value: new MyFileSystemClaimImpl() }
```

`FileSystemClaim` is abstract, so `tsc` rejects a subclass that does not implement `write`, `read` and `slice`. The README uses `LocalFileSystemClaimImpl` as a **placeholder name** for that implementation; there is no class by that name in this monorepo.

---

## Known consumers in this monorepo

These packages depend on `@t4h.framework/fs` and declare `FileSystemClaim`; read their skills/README for full workflow patterns.

| Package | Usage |
|---------|--------|
| `@t4h.framework/http` | The HTTP activity persists response bodies to `fs` and returns them wrapped in a `Body` — you read them with `res.body.json()` / `text()`. |
| `@t4h.framework/pdf` | The render activity writes the PDF to `fs` and hands back a `Binary` you consume in the workflow. |
| `@t4h.framework/fs` itself | `FileSplitActivity` streams the source and hands its slices to `startMany` as strings; `FileJoinActivity` streams batch results into a new file; `FileReadActivity` slices (or streams, when the binary is not file-backed) a small window into a string. See the `framework-fs-split` skill. |

Orchestrate these package primitives from your workflow; the `fs` write/read happens inside them.

---

## Testing

Test the **workflow** with `TestWorkflowEnvironment` from
`@t4h.framework/core/testing`, mocking `FileSystemClaim` (plus whatever other
claims the triggered activities need, e.g. `HttpClientRequestClaim` for an HTTP
call). See the **framework-workflow-testing** skill for the harness API.

`@t4h.framework/core/no-binary-subclass` rejects a `Binary` subclass out of a
test file. A test double for the claim can extend `Binary`:

```typescript
import { Readable } from 'node:stream'
import { Binary } from '@t4h.framework/core'
import { TestWorkflowEnvironment, mockClaim } from '@t4h.framework/core/testing'
import { FileSystemClaim } from '@t4h.framework/fs'

class MockBinary extends Binary {
  public readonly contentType: string
  public readonly contentLength: number

  constructor(private readonly data: Buffer, contentType: string) {
    super()
    this.contentType = contentType
    this.contentLength = data.byteLength
  }

  public stream() {
    return Readable.from(this.data)
  }
}

// mockImplementation: replay runs the workflow twice, so return a fresh Binary each call.
const mockWrite = vi.fn().mockImplementation(async (_path: string, readable: Readable) => {
  const chunks = await readable.toArray()
  return new MockBinary(Buffer.concat(chunks), 'application/json')
})

const env = TestWorkflowEnvironment.create({ now: 0 })

await env.execute(workflow, input, {
  claims: [
    mockClaim(FileSystemClaim, { write: mockWrite, read: vi.fn() }),
    // ...other claims the workflow's activities need
  ],
})
```

Assert `mockWrite` was called with the expected path and body when relevant.

---

## Implementing a custom FileSystemClaim

1. **Extend** `FileSystemClaim` from `@t4h.framework/fs`. `tsc` checks the method signatures.
2. **Return** a `Binary` from `write`, `read` and `slice` with correct `contentType`, `contentLength`, and `stream()`.
3. **Infer** the content type from the path when `write` gets no `contentType`.
4. **Compose** ranges in `slice`: a slice of a slice is relative to it.
5. **Register** `{ provide: FileSystemClaim, value: new YourImpl() }` in the runtime (tests use `mockClaim(FileSystemClaim, ...)` instead).
6. **Keep paths deterministic** when activities replay — unpredictable temp paths are fine if only `Binary` metadata is serialized and replay re-executes `write`; know your serializer/replay story if `path` is persisted.

---

## App / monorepo checklist

1. Add `"@t4h.framework/fs": "workspace:^"` (or published version) to app `dependencies` when activities use `FileSystemClaim`.
2. Ensure **`@t4h.framework/core`** is available (peer of `fs`).
3. Register **`FileSystemClaim`** in the runtime or test `claims` array.
4. Orchestrate file I/O through the package activities that own `FileSystemClaim` (http, pdf) — workflow code never declares an activity or accesses `this.claims.fs`.

---

## Anti-patterns

- A deep import. The package exports only its entry, so a deep import fails in `tsc`.
- Calling `stream()` in a workflow, or in an activity `constructor`, `toInput` or `toOutput`. The `no-binary-stream-outside-activity` rule of `@t4h.framework/core/eslint` reports it.
- A missing `FileSystemClaim` provider. The activity throws `ClaimProviderNotFoundException` when it reads the claim.
- Assuming `packages/fs` ships a concrete disk/S3 implementation — it does not; implement and register `FileSystemClaim` in your runtime or test harness.

---

## Related skills

- `framework-fs-split` — `split`/`join`: fan a file out into a batch of workflows and fold the results back into a file.
- `framework-fs-write` — `write`: turn workflow content into a `Binary`, and why an App must never implement `Binary`.
- `framework-core` — `Claim`, `Binary`, and the `TestWorkflowEnvironment` testing harness.
- `framework-http` — HTTP activities that persist bodies via `FileSystemClaim`.
- `framework-pdf` — the render activity that writes PDFs via `fs`.
