@t4h.framework/fs 0.4.0 → 0.6.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 (51) hide show
  1. package/.ai/skills/framework-fs/SKILL.md +72 -115
  2. package/.ai/skills/framework-fs-split/SKILL.md +255 -0
  3. package/README.md +184 -3
  4. package/dist/FileSystemClaim.d.ts +4 -0
  5. package/dist/FileSystemClaim.d.ts.map +1 -1
  6. package/dist/FileSystemClaim.js.map +1 -1
  7. package/dist/activities/FileJoinActivity.d.ts +39 -0
  8. package/dist/activities/FileJoinActivity.d.ts.map +1 -0
  9. package/dist/activities/FileJoinActivity.js +87 -0
  10. package/dist/activities/FileJoinActivity.js.map +1 -0
  11. package/dist/activities/FileSplitActivity.d.ts +38 -0
  12. package/dist/activities/FileSplitActivity.d.ts.map +1 -0
  13. package/dist/activities/FileSplitActivity.js +51 -0
  14. package/dist/activities/FileSplitActivity.js.map +1 -0
  15. package/dist/activities/__tests__/FileJoinActivity.spec.d.ts +2 -0
  16. package/dist/activities/__tests__/FileJoinActivity.spec.d.ts.map +1 -0
  17. package/dist/activities/__tests__/FileJoinActivity.spec.js +160 -0
  18. package/dist/activities/__tests__/FileJoinActivity.spec.js.map +1 -0
  19. package/dist/activities/__tests__/FileSplitActivity.spec.d.ts +2 -0
  20. package/dist/activities/__tests__/FileSplitActivity.spec.d.ts.map +1 -0
  21. package/dist/activities/__tests__/FileSplitActivity.spec.js +149 -0
  22. package/dist/activities/__tests__/FileSplitActivity.spec.js.map +1 -0
  23. package/dist/fs.d.ts +3 -0
  24. package/dist/fs.d.ts.map +1 -1
  25. package/dist/fs.js +3 -0
  26. package/dist/fs.js.map +1 -1
  27. package/dist/helpers/__tests__/byte-delimiter-scanner.spec.d.ts +2 -0
  28. package/dist/helpers/__tests__/byte-delimiter-scanner.spec.d.ts.map +1 -0
  29. package/dist/helpers/__tests__/byte-delimiter-scanner.spec.js +127 -0
  30. package/dist/helpers/__tests__/byte-delimiter-scanner.spec.js.map +1 -0
  31. package/dist/helpers/__tests__/csv-reader.spec.d.ts +2 -0
  32. package/dist/helpers/__tests__/csv-reader.spec.d.ts.map +1 -0
  33. package/dist/helpers/__tests__/csv-reader.spec.js +60 -0
  34. package/dist/helpers/__tests__/csv-reader.spec.js.map +1 -0
  35. package/dist/helpers/byte-delimiter-scanner.d.ts +36 -0
  36. package/dist/helpers/byte-delimiter-scanner.d.ts.map +1 -0
  37. package/dist/helpers/byte-delimiter-scanner.js +61 -0
  38. package/dist/helpers/byte-delimiter-scanner.js.map +1 -0
  39. package/dist/helpers/csv-reader.d.ts +8 -0
  40. package/dist/helpers/csv-reader.d.ts.map +1 -0
  41. package/dist/helpers/csv-reader.js +14 -0
  42. package/dist/helpers/csv-reader.js.map +1 -0
  43. package/dist/helpers/parsers.d.ts +9 -0
  44. package/dist/helpers/parsers.d.ts.map +1 -0
  45. package/dist/helpers/parsers.js +14 -0
  46. package/dist/helpers/parsers.js.map +1 -0
  47. package/dist/testing/index.d.ts +3 -0
  48. package/dist/testing/index.d.ts.map +1 -0
  49. package/dist/testing/index.js +32 -0
  50. package/dist/testing/index.js.map +1 -0
  51. package/package.json +8 -5
@@ -2,11 +2,11 @@
2
2
  name: framework-fs
3
3
  description: >-
4
4
  Guides correct use of @t4h.framework/fs in T4H Framework workflows: the
5
- FileSystemClaim abstraction for reading/writing files inside activities,
6
- Claim wiring, Binary results, and runtime providers. Use when implementing
7
- activities that persist or load file payloads, mocking FileSystemClaim in tests,
8
- registering fs claim providers, debugging missing fs provider errors, or working
9
- in packages/fs, packages/http, or packages/jsx.
5
+ FileSystemClaim abstraction that the package activities (http, jsx) use to
6
+ read/write files, the Binary result workflows consume, and runtime providers.
7
+ Use when a workflow orchestrates file-backed package activities, consuming
8
+ Binary results, mocking FileSystemClaim in workflow tests, registering fs
9
+ claim providers, or debugging missing fs provider errors.
10
10
  ---
11
11
 
12
12
  # @t4h.framework/fs
@@ -19,10 +19,16 @@ Package path: `framework/packages/fs`. Peer dependency: `@t4h.framework/core` (`
19
19
 
20
20
  ## Import surface
21
21
 
22
- The package exports a single public symbol from `src/fs.ts`:
22
+ Public exports live in `src/fs.ts`:
23
23
 
24
24
  ```typescript
25
- import { FileSystemClaim } from '@t4h.framework/fs'
25
+ import {
26
+ FileSystemClaim,
27
+ // file fan-out / fold — see the framework-fs-split skill
28
+ split, Split, FileSplitActivity, type FileSplitOptions,
29
+ join, FileJoinActivity, type FileJoinOptions,
30
+ ByteDelimiterScanner, type FileSplitDelimiter, type ByteRange,
31
+ } from '@t4h.framework/fs'
26
32
  ```
27
33
 
28
34
  Do not import from internal paths such as `src/FileSystemClaim.ts` in application code.
@@ -42,6 +48,12 @@ export abstract class FileSystemClaim {
42
48
  ): Binary | Promise<Binary>
43
49
 
44
50
  public abstract read(path: string): Binary | Promise<Binary>
51
+
52
+ public abstract slice(
53
+ binary: Binary,
54
+ range: { start: number; end: number },
55
+ encoding?: BufferEncoding,
56
+ ): Binary | Promise<Binary>
45
57
  }
46
58
  ```
47
59
 
@@ -49,8 +61,9 @@ export abstract class FileSystemClaim {
49
61
  |--------|---------|
50
62
  | `write(path, readable)` | Persist stream or buffer at `path`; return a `Binary` handle (metadata + `stream()`). |
51
63
  | `read(path)` | Load an existing file at `path`; return a `Binary` handle. |
64
+ | `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 `split` children receive plain strings. |
52
65
 
53
- Both methods may return synchronously or as a `Promise`. Implementations choose storage layout; callers should only rely on the returned `Binary` API.
66
+ All methods may return synchronously or as a `Promise`. Implementations choose storage layout; callers should only rely on the returned `Binary` API.
54
67
 
55
68
  ### Binary (from `@t4h.framework/core`)
56
69
 
@@ -62,45 +75,25 @@ export abstract class Binary {
62
75
  }
63
76
  ```
64
77
 
65
- Typical activity flow after `write` or `read`:
78
+ The package activities return this `Binary` to your workflow — e.g. an `http`
79
+ response `Body` is backed by it. Consume it with `stream()` (or the higher-level
80
+ `body.json()` / `text()` that packages expose):
66
81
 
67
82
  ```typescript
68
- const binary = await this.claims.fs.write('reports/output.pdf', buffer)
69
83
  const stream = await binary.stream()
70
84
  // consume stream (e.g. stream.toArray() then Buffer.concat)
71
85
  ```
72
86
 
73
87
  ---
74
88
 
75
- ## Declaring the claim in an activity
76
-
77
- Activities that touch files declare `FileSystemClaim` via `Claim` from `@t4h.framework/core`. Access `this.claims.fs` only while execution is inside `Claim.run` (same as any claim).
78
-
79
- ```typescript
80
- import { SyncActivity, Claim } from '@t4h.framework/core'
81
- import { FileSystemClaim } from '@t4h.framework/fs'
82
-
83
- class SaveReportActivity extends SyncActivity<[data: Buffer], { path: string }, any, any> {
84
- private readonly claims = new Claim({
85
- fs: FileSystemClaim,
86
- })
87
-
88
- public async toInput(data: Buffer) {
89
- return { path: 'reports/output.pdf' }
90
- }
91
-
92
- public async run(input: { path: string }) {
93
- const binary = await this.claims.fs.write(input.path, Buffer.from('...'))
94
- return { contentType: binary.contentType }
95
- }
96
-
97
- public toOutput(output: any) {
98
- return output
99
- }
100
- }
101
- ```
89
+ ## How workflows use it
102
90
 
103
- Claim key name (`fs` above) is arbitrary but must match how you access `this.claims.fs`. Downstream packages use `fs` consistently (`AbstractHttpRequestActivity`, jsx render activities).
91
+ Workflows never call `FileSystemClaim` directly. File I/O happens inside the
92
+ **ready-made package activities** that already own the claim — the `http` client
93
+ persists response bodies, jsx render activities write their output — and you
94
+ read the resulting `Binary` (e.g. `await res.body.json()` / `text()`). Your only
95
+ responsibility is that the runtime **registers a provider** (below). See
96
+ **Known consumers** for which package primitives to orchestrate.
104
97
 
105
98
  ---
106
99
 
@@ -122,94 +115,59 @@ These packages depend on `@t4h.framework/fs` and declare `FileSystemClaim`; read
122
115
 
123
116
  | Package | Usage |
124
117
  |---------|--------|
125
- | `@t4h.framework/http` | `AbstractHttpRequestActivity` writes HTTP response bodies via `fs.write(\`${uuid}-http-request-activity-response-body.${ext}\`, response.body)`; wraps result in `Body`. |
126
- | `@t4h.framework/jsx` | `RenderWorkflowActivity` / `RenderFormWorkflowActivity` use `fs.write('rsc.txt', ...)` in `toInput` and consume `Binary` in `run`. |
127
-
128
- ### HTTP response persistence (reference)
129
-
130
- From `AbstractHttpRequestActivity.run`:
131
-
132
- ```typescript
133
- const ext =
134
- typeof contentType === 'string' ? mime.getExtension(contentType) : 'bin'
135
-
136
- const binary = await this[CLAIMS_KEY].fs.write(
137
- `${uuidv7()}-http-request-activity-response-body.${ext}`,
138
- response.body,
139
- )
140
-
141
- return this.toHttpOutput({
142
- status: response.status,
143
- headers: response.headers,
144
- body: new Body(binary),
145
- })
146
- ```
118
+ | `@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()`. |
119
+ | `@t4h.framework/jsx` | Render activities write their output to `fs` and hand back a `Binary` you consume in the workflow. |
120
+ | `@t4h.framework/fs` itself | `FileSplitActivity` persists the durable copy and slices it by ranges; `FileJoinActivity` streams batch results into a new file. See the `framework-fs-split` skill. |
147
121
 
148
- Extension comes from response `content-type` when present; otherwise `'bin'`. Path is unique per request (uuid prefix).
149
-
150
- ### JSX render activities (reference)
151
-
152
- ```typescript
153
- public readonly claims = new Claim({
154
- fs: FileSystemClaim,
155
- })
156
-
157
- public async toInput() {
158
- return await this.claims.fs.write('rsc.txt', Buffer.from('Hello World!'))
159
- }
160
-
161
- public async run(binary: Binary) {
162
- const stream = await binary.stream()
163
- const chunks = await stream.toArray()
164
- return Buffer.concat(chunks).toString()
165
- }
166
- ```
167
-
168
- Current `toInput` stubs are placeholders; real implementations should still use `FileSystemClaim.write(path, Buffer | Readable)` per README.
122
+ Orchestrate these package primitives from your workflow; the `fs` write/read happens inside them.
169
123
 
170
124
  ---
171
125
 
172
126
  ## Testing
173
127
 
174
- There are **no unit tests inside `packages/fs`**. Use patterns from `packages/http` activity tests.
175
-
176
- ### Mocking FileSystemClaim in activity tests
177
-
178
- From `AbstractHttpRequestActivity.spec.ts` — wrap activity execution in `Claim.run` and provide partial mocks:
128
+ Test the **workflow** with `TestWorkflowEnvironment` from
129
+ `@t4h.framework/core/testing`, mocking `FileSystemClaim` (plus whatever other
130
+ claims the triggered activities need, e.g. `HttpClientRequestClaim` for an HTTP
131
+ call). See the **framework-workflow-testing** skill for the harness API.
179
132
 
180
133
  ```typescript
181
- import { Claim } from '@t4h.framework/core'
134
+ import { Readable } from 'node:stream'
135
+ import { Binary } from '@t4h.framework/core'
136
+ import { TestWorkflowEnvironment, mockClaim } from '@t4h.framework/core/testing'
182
137
  import { FileSystemClaim } from '@t4h.framework/fs'
183
- import { HttpClientRequestClaim } from '@t4h.framework/http'
184
138
 
185
- mockWrite.mockImplementation(async (_path: string, readable: Readable) => {
139
+ class MockBinary extends Binary {
140
+ public readonly contentType: string
141
+ public readonly contentLength: number
142
+
143
+ constructor(private readonly data: Buffer, contentType: string) {
144
+ super()
145
+ this.contentType = contentType
146
+ this.contentLength = data.byteLength
147
+ }
148
+
149
+ public stream() {
150
+ return Readable.from(this.data)
151
+ }
152
+ }
153
+
154
+ // mockImplementation: replay runs the workflow twice, so return a fresh Binary each call.
155
+ const mockWrite = vi.fn().mockImplementation(async (_path: string, readable: Readable) => {
186
156
  const chunks = await readable.toArray()
187
- return new MockBinary(Buffer.concat(chunks), contentType)
157
+ return new MockBinary(Buffer.concat(chunks), 'application/json')
188
158
  })
189
159
 
190
- await Claim.run(
191
- [
192
- {
193
- provide: HttpClientRequestClaim,
194
- value: { request: mockRequest } as HttpClientRequestClaim,
195
- },
196
- {
197
- provide: FileSystemClaim,
198
- value: {
199
- write: mockWrite,
200
- read: vi.fn(),
201
- } as unknown as FileSystemClaim,
202
- },
160
+ const env = TestWorkflowEnvironment.create({ now: 0 })
161
+
162
+ await env.execute(workflow, input, {
163
+ claims: [
164
+ mockClaim(FileSystemClaim, { write: mockWrite, read: vi.fn() }),
165
+ // ...other claims the workflow's activities need
203
166
  ],
204
- () => new TestableActivity().run(input),
205
- )
167
+ })
206
168
  ```
207
169
 
208
- Mock `write` to return a `Binary` compatible with what the activity expects (e.g. `Body` for HTTP). Assert `mockWrite` was called with expected path and body when relevant.
209
-
210
- ### Workflow-level tests
211
-
212
- `WorkflowTesting.run` from `@t4h.framework/core/testing` runs the full claim stack when you pass explicit `FileSystemClaim` providers. For isolated activity unit tests, prefer `Claim.run` with partial mocks.
170
+ Assert `mockWrite` was called with the expected path and body when relevant.
213
171
 
214
172
  ---
215
173
 
@@ -218,7 +176,7 @@ Mock `write` to return a `Binary` compatible with what the activity expects (e.g
218
176
  1. **Extend** `FileSystemClaim` from `@t4h.framework/fs`.
219
177
  2. **Return** a `Binary` subclass (or core-compatible `Binary`) from `write` and `read` with correct `contentType`, `contentLength`, and `stream()`.
220
178
  3. **Accept** `Readable | Buffer` in `write` (match the abstract signature).
221
- 4. **Register** `{ provide: FileSystemClaim, value: new YourImpl() }` in runtime or test `Claim.run` providers.
179
+ 4. **Register** `{ provide: FileSystemClaim, value: new YourImpl() }` in the runtime (tests use `mockClaim(FileSystemClaim, ...)` instead).
222
180
  5. **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.
223
181
 
224
182
  ---
@@ -228,15 +186,13 @@ Mock `write` to return a `Binary` compatible with what the activity expects (e.g
228
186
  1. Add `"@t4h.framework/fs": "workspace:^"` (or published version) to app `dependencies` when activities use `FileSystemClaim`.
229
187
  2. Ensure **`@t4h.framework/core`** is available (peer of `fs`).
230
188
  3. Register **`FileSystemClaim`** in the runtime or test `claims` array.
231
- 4. Declare `new Claim({ fs: FileSystemClaim })` (or equivalent key) on every activity that calls `this.claims.fs`.
232
- 5. Build before publish: `yarn build` in `packages/fs` (`tsc --project tsconfig.build.json`).
189
+ 4. Orchestrate file I/O through the package activities that own `FileSystemClaim` (http, jsx) — workflow code never declares an activity or accesses `this.claims.fs`.
233
190
 
234
191
  ---
235
192
 
236
193
  ## Anti-patterns
237
194
 
238
195
  - Importing from `@t4h.framework/fs` paths other than the package export (`FileSystemClaim` only).
239
- - Calling `this.claims.fs` **outside** `Claim.run` / workflow execution context → `ClaimStorageNotActiveException`.
240
196
  - Omitting `FileSystemClaim` from providers while using `@t4h.framework/http` or `@t4h.framework/jsx` activities that require `fs`.
241
197
  - Assuming `packages/fs` ships a concrete disk/S3 implementation — it does not; implement and register `FileSystemClaim` in your runtime or test harness.
242
198
 
@@ -244,6 +200,7 @@ Mock `write` to return a `Binary` compatible with what the activity expects (e.g
244
200
 
245
201
  ## Related skills
246
202
 
247
- - `framework-core` — `Claim`, `SyncActivity`, `Binary`, `Claim.run`, `WorkflowTesting`.
203
+ - `framework-fs-split` — `split`/`join`: fan a file out into a batch of workflows and fold the results back into a file.
204
+ - `framework-core` — `Claim`, `Binary`, and the `TestWorkflowEnvironment` testing harness.
248
205
  - `framework-http` — HTTP activities that persist bodies via `FileSystemClaim`.
249
206
  - `framework-jsx` — render activities that write/read via `fs`.
@@ -0,0 +1,255 @@
1
+ ---
2
+ name: framework-fs-split
3
+ description: >-
4
+ Guides correct use of `split` and `join` from @t4h.framework/fs: fanning one
5
+ large file out into a batch of workflows (one per byte-range slice) and
6
+ folding the batch results back into a single file — with O(1) parent history
7
+ and bounded memory regardless of file size. Use whenever a workflow must
8
+ process a large file record by record — positional files, plain-text lines,
9
+ NDJSON, batch imports, per-record fan-out with an aggregated output (e.g. a
10
+ CSV report) — or when the user mentions splitting/chunking a file, running a
11
+ workflow per line/record, waiting for a batch, collecting batch results, or
12
+ the symbols `split`, `join`, `Split`, `FileSplitActivity`,
13
+ `FileJoinActivity`, `WorkflowBatchRef`, `WorkflowBatchClaim`. Prefer this
14
+ skill over hand-rolling stream loops + `startWorkflow` calls.
15
+ ---
16
+
17
+ # `split` / `join` — file fan-out and fold for workflows
18
+
19
+ `split` turns **one file into a batch of workflows**: it streams the file once,
20
+ cuts it into **byte-range slices** (never copying slice content), starts one
21
+ child workflow per slice, and **suspends the parent until every child is
22
+ terminal**. `join` is the inverse: it folds the batch results back into a
23
+ single file, streaming one result at a time.
24
+
25
+ ```typescript
26
+ import { split, join, Split } from '@t4h.framework/fs'
27
+
28
+ const processRecord = new Workflow(
29
+ { id: 'process-record', schema: Type.String() },
30
+ async (record: string) => {
31
+ // record is the raw slice content — no Binary handling needed
32
+ return toCsvRow(parsePositional(record))
33
+ },
34
+ )
35
+
36
+ const main = new Workflow(fileSchema, async (file: Binary) => {
37
+ const batch = await split(file, processRecord, {
38
+ delimiter: Split.char('\n10', { next: 2 }),
39
+ timeout: 3_600_000,
40
+ })
41
+ // this line only runs once the WHOLE batch is terminal;
42
+ // batch: WorkflowBatchRef — { id: string, count: number }
43
+
44
+ return await join(batch, { format: 'lines', header: 'id;value;date' })
45
+ // Binary: the aggregated file — no slice ever crossed workflow memory
46
+ })
47
+ ```
48
+
49
+ Package path: `framework/packages/fs`. Import only from the package root.
50
+
51
+ ---
52
+
53
+ ## The memory model (the golden rule)
54
+
55
+ Everything here exists to keep the parent history **O(1)** whatever the file
56
+ size:
57
+
58
+ 1. `split` reads the source stream **exactly once, worker side**: the same
59
+ pass persists a durable copy through `FileSystemClaim.write` while a byte
60
+ scanner records slice offsets. No slice content is materialized.
61
+ 2. Children receive **range views** over the durable copy
62
+ (`FileSystemClaim.slice`); the runtime hydrates each slice only inside that
63
+ child's own run — as a plain decoded string when the child's schema is
64
+ `Type.String()`.
65
+ 3. The parent resumes with only `WorkflowBatchRef { id, count }`. Per-child
66
+ results are **never aggregated onto the ref**: they live with each child
67
+ run.
68
+ 4. `join` streams results one at a time through `WorkflowBatchClaim.outputs`
69
+ into `FileSystemClaim.write` with backpressure; only the resulting `Binary`
70
+ reaches history.
71
+
72
+ **Golden rule:** volume proportional to the batch size only ever flows inside
73
+ an activity, through a claim. Into workflow history goes a fold result or a
74
+ bounded slice — never the N items.
75
+
76
+ ---
77
+
78
+ ## `split`
79
+
80
+ ```typescript
81
+ async function split(
82
+ binary: Binary,
83
+ workflow: Workflow<any, any>,
84
+ options: FileSplitOptions,
85
+ ): Promise<WorkflowBatchRef>
86
+ ```
87
+
88
+ The child workflow declares its **real input type** (e.g. `Type.String()`) —
89
+ it never sees `Binary`, ranges, or storage. `await split(...)` suspends the
90
+ parent (one pending activity, one resume, one replay) until every child is
91
+ terminal — **regardless of each child succeeding or failing**: a rejected
92
+ child never rejects the batch (allSettled semantics). An empty batch resolves
93
+ immediately.
94
+
95
+ ### Delimiters (`options.delimiter`)
96
+
97
+ The cut is a serializable spec. When the delimiter is found at
98
+ `[p, p + length)`, the previous slice ends at `p + keep.previous` and the next
99
+ slice starts at `p + length - keep.next`; the bytes in between are discarded
100
+ (defaults discard the whole delimiter — classic line semantics).
101
+
102
+ ```typescript
103
+ Split.line() // cut at every '\n', delimiter discarded
104
+ Split.char(';') // cut at any byte sequence
105
+ Split.char(';', { previous: 1 }) // keep the ';' with the previous slice
106
+ Split.char('\n10', { next: 2 }) // positional files, see below
107
+ ```
108
+
109
+ Positional files where a logical record spans multiple physical lines and a
110
+ new record only starts when a line begins with a marker (the record type
111
+ code):
112
+
113
+ ```
114
+ 10JOHN;123\n20detail-a\n20detail-b\n10MARY;456\n20detail-c
115
+ └──────────── record 1 ───────────┘└─────── record 2 ──────┘
116
+ ```
117
+
118
+ `Split.char('\n10', { next: 2 })`: the newline between records is discarded
119
+ and the `10` marker — which is **data**, not separator — stays at the start of
120
+ the next slice. Forgetting `{ next: 2 }` silently strips the record type code
121
+ from every slice.
122
+
123
+ Offsets are always computed over **raw bytes** (never decoded characters), so
124
+ multi-byte encodings cannot skew the ranges a ranged download will use.
125
+
126
+ ### Other options
127
+
128
+ | Option | Default | Behavior |
129
+ |--------|---------|----------|
130
+ | `every` | `1` | Cut only every N delimiter occurrences — groups N records per child. One million lines with `every: 10_000` start 100 children, each receiving a raw block (inner delimiters untouched) to iterate locally. `keep` arithmetic applies only at actual cut points. |
131
+ | `skipEmpty` | `true` | Drops zero-length slices (consecutive delimiters, file starting/ending with one) so they never become child workflows. Turn off when slice index must align 1:1 with record position. "Empty" means zero bytes — a whitespace-only line is a regular slice. |
132
+ | `encoding` | `'utf8'` | Converts the delimiter to bytes and tells the runtime how to hydrate each slice for the child (string when set on the slice). |
133
+ | `timeout` | none | Max milliseconds to await the batch; on expiry the activity **rejects** (reason `timeout`) instead of suspending forever — the safety net for a child that never reaches a terminal state. |
134
+
135
+ ---
136
+
137
+ ## `join`
138
+
139
+ ```typescript
140
+ async function join(
141
+ batch: WorkflowBatchRef,
142
+ options: FileJoinOptions,
143
+ ): Promise<Binary>
144
+ ```
145
+
146
+ Formats (content is the **child's** job — the child returns the final line;
147
+ `join` only owns structure):
148
+
149
+ | Format | Behavior |
150
+ |--------|----------|
151
+ | `lines` | Concatenates raw child outputs, one per line. Options: `header`, `separator` (default `'\n'`). Non-string outputs are JSON-stringified. |
152
+ | `entries` | NDJSON dump of `{ index, status, output }`, one per line — the format-agnostic view of the batch, rejected children included by default. |
153
+ | `csv` | Projects object outputs into RFC 4180 escaped columns. Options: `columns` (required; entries are `{ key, header? }` — `key` selects the value on the child output, `header` labels the header row, plain string is shorthand for `{ key }`), `delimiter` (default `','`), `header` (default `true`). Missing keys become empty cells; nested values are JSON inside the cell. Value *formatting* belongs in the child workflow (sandboxed user code); the join only owns layout. |
154
+
155
+ **Status filter** (`status?: ('fulfilled' | 'rejected')[]`) selects which
156
+ results make it into the file — there is no error policy. Defaults:
157
+ `['fulfilled']` for `lines`/`csv`, both for `entries`. Filtering is silent by
158
+ design; a reprocessing file of failures is one call away:
159
+
160
+ ```typescript
161
+ const failures = await join(batch, { format: 'entries', status: ['rejected'] })
162
+ ```
163
+
164
+ ---
165
+
166
+ ## Runtime requirements
167
+
168
+ - Claim providers: `FileSystemClaim` (with `slice`), `WorkflowClaim` (with
169
+ `startMany`), and `WorkflowBatchClaim` (from `@t4h.framework/core`).
170
+ - Production whitelist: `FileSplitActivity`, `FileJoinActivity` and core's
171
+ `StartManyWorkflowsActivity` must be registered via `runtime.addActivity`.
172
+ - `FileSplitActivity` is an **AsyncActivity**: the heavy work happens in
173
+ `bootstrap`, executed worker side atomically with the pending suspension —
174
+ children cannot finish before the parent is awaiting them.
175
+ - `FileJoinActivity` is a **SyncActivity**: batches are only consumed after
176
+ the completion barrier resolved, so there is nothing to await.
177
+
178
+ ---
179
+
180
+ ## Testing
181
+
182
+ Drive `bootstrap`/`run` directly with fake claims inside `Claim.run` (patterns
183
+ from `src/activities/__tests__/`):
184
+
185
+ ```typescript
186
+ import { AsyncActivityResultPending, Claim, WorkflowBatchClaim, WorkflowBatchRef, WorkflowClaim } from '@t4h.framework/core'
187
+ import { FileSplitActivity, FileSystemClaim, Split } from '@t4h.framework/fs'
188
+
189
+ const write = vi.fn(async (_path, readable) => durableBinary) // must consume the stream
190
+ const slice = vi.fn((binary, range, encoding) => ({ binary, range, encoding }))
191
+ const startMany = vi.fn(async (_wf, inputs) => new WorkflowBatchRef('batch-1', inputs.length))
192
+
193
+ const result = await Claim.run(
194
+ [
195
+ { provide: FileSystemClaim, value: { write, slice } as never },
196
+ { provide: WorkflowClaim, value: { startMany } as never },
197
+ ],
198
+ () => {
199
+ const activity = new FileSplitActivity()
200
+ activity.id = 'activity-1'
201
+ return activity.bootstrap({ binary, workflow: 'child', options: { delimiter: Split.line() } })
202
+ },
203
+ )
204
+
205
+ expect(result).toBeInstanceOf(AsyncActivityResultPending)
206
+ expect(slice).toHaveBeenCalledWith(durableBinary, { start: 0, end: 3 }, 'utf8')
207
+ ```
208
+
209
+ For `join`, provide `WorkflowBatchClaim` as an async generator:
210
+
211
+ ```typescript
212
+ const outputs = vi.fn(async function* () {
213
+ yield { index: 0, status: 'fulfilled', output: 'line-1' }
214
+ })
215
+ ```
216
+
217
+ Behaviors worth asserting because they regress easily: the durable copy keeps
218
+ the original bytes; slices are views over the durable copy (not the source);
219
+ `keep` arithmetic on positional cuts; the empty batch resolving immediately;
220
+ CSV escaping (quotes doubled, delimiter/newline wrapped); the status filter.
221
+
222
+ ---
223
+
224
+ ## Anti-patterns
225
+
226
+ - **Paging batch results into the workflow.** A `readBatch(ref, cursor)` loop
227
+ in workflow code turns every page into a replayed history entry — O(N) is
228
+ back. Aggregation belongs inside `join` (or a framework activity consuming
229
+ `WorkflowBatchClaim`).
230
+ - **Expecting per-child outputs on the ref.** `WorkflowBatchRef` is
231
+ deliberately `{ id, count }` only. Results are read through `join` — e.g.
232
+ `format: 'entries'` for the faithful dump.
233
+ - **Treating a rejected child as a batch failure.** The batch always resolves
234
+ (allSettled). Decide what failures mean: compare against `batch.count`,
235
+ generate the `status: ['rejected']` file, or tolerate.
236
+ - **Baking record format into `join`.** The child returns the final line
237
+ (`lines`) or a projectable object (`csv`); custom formats live in the child
238
+ workflow — sandboxed user code — not in new join options.
239
+ - **Splitting when you need the file whole.** If downstream wants the blob
240
+ intact (hash it, upload it), pass the `Binary` through.
241
+ - **Forgetting `keep` on positional delimiters.** `Split.char('\n10')` without
242
+ `{ next: 2 }` silently discards the record type code from every slice.
243
+ - **Hand-rolling the loop.** A stream-and-`startWorkflow` loop inside an
244
+ activity re-parses on replay, misses the barrier, and pushes N entries into
245
+ history.
246
+
247
+ ---
248
+
249
+ ## Related skills
250
+
251
+ - `framework-fs` — `FileSystemClaim` contract (including `slice`), `Binary`,
252
+ provider wiring.
253
+ - `framework-core` — `startWorkflowMany` (the array-input sibling of `split`),
254
+ `WorkflowBatchRef`, `WorkflowBatchClaim`, `Claim.run`, activity model,
255
+ testing harness.