@t4h.framework/fs 0.5.0 → 0.7.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 (73) hide show
  1. package/.ai/skills/framework-fs/SKILL.md +23 -3
  2. package/.ai/skills/framework-fs-split/SKILL.md +350 -0
  3. package/README.md +297 -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 +59 -0
  8. package/dist/activities/FileJoinActivity.d.ts.map +1 -0
  9. package/dist/activities/FileJoinActivity.js +74 -0
  10. package/dist/activities/FileJoinActivity.js.map +1 -0
  11. package/dist/activities/FileReadActivity.d.ts +22 -0
  12. package/dist/activities/FileReadActivity.d.ts.map +1 -0
  13. package/dist/activities/FileReadActivity.js +68 -0
  14. package/dist/activities/FileReadActivity.js.map +1 -0
  15. package/dist/activities/FileSplitActivity.d.ts +31 -28
  16. package/dist/activities/FileSplitActivity.d.ts.map +1 -1
  17. package/dist/activities/FileSplitActivity.js +40 -34
  18. package/dist/activities/FileSplitActivity.js.map +1 -1
  19. package/dist/activities/__tests__/FileJoinActivity.spec.d.ts +2 -0
  20. package/dist/activities/__tests__/FileJoinActivity.spec.d.ts.map +1 -0
  21. package/dist/activities/__tests__/FileJoinActivity.spec.js +323 -0
  22. package/dist/activities/__tests__/FileJoinActivity.spec.js.map +1 -0
  23. package/dist/activities/__tests__/FileReadActivity.spec.d.ts +2 -0
  24. package/dist/activities/__tests__/FileReadActivity.spec.d.ts.map +1 -0
  25. package/dist/activities/__tests__/FileReadActivity.spec.js +120 -0
  26. package/dist/activities/__tests__/FileReadActivity.spec.js.map +1 -0
  27. package/dist/activities/__tests__/FileSplitActivity.spec.js +135 -132
  28. package/dist/activities/__tests__/FileSplitActivity.spec.js.map +1 -1
  29. package/dist/constants/operations.d.ts +3 -0
  30. package/dist/constants/operations.d.ts.map +1 -0
  31. package/dist/constants/operations.js +18 -0
  32. package/dist/constants/operations.js.map +1 -0
  33. package/dist/fs.d.ts +4 -0
  34. package/dist/fs.d.ts.map +1 -1
  35. package/dist/fs.js +4 -0
  36. package/dist/fs.js.map +1 -1
  37. package/dist/helpers/CSV.d.ts +14 -0
  38. package/dist/helpers/CSV.d.ts.map +1 -0
  39. package/dist/helpers/CSV.js +44 -0
  40. package/dist/helpers/CSV.js.map +1 -0
  41. package/dist/helpers/__tests__/byte-delimiter-scanner.spec.d.ts +2 -0
  42. package/dist/helpers/__tests__/byte-delimiter-scanner.spec.d.ts.map +1 -0
  43. package/dist/helpers/__tests__/byte-delimiter-scanner.spec.js +196 -0
  44. package/dist/helpers/__tests__/byte-delimiter-scanner.spec.js.map +1 -0
  45. package/dist/helpers/byte-delimiter-scanner.d.ts +43 -0
  46. package/dist/helpers/byte-delimiter-scanner.d.ts.map +1 -0
  47. package/dist/helpers/byte-delimiter-scanner.js +85 -0
  48. package/dist/helpers/byte-delimiter-scanner.js.map +1 -0
  49. package/dist/tools/assert-where-serializable.d.ts +2 -0
  50. package/dist/tools/assert-where-serializable.d.ts.map +1 -0
  51. package/dist/tools/assert-where-serializable.js +19 -0
  52. package/dist/tools/assert-where-serializable.js.map +1 -0
  53. package/package.json +11 -5
  54. package/dist/ActivityFileSystemClaim.d.ts +0 -7
  55. package/dist/ActivityFileSystemClaim.d.ts.map +0 -1
  56. package/dist/ActivityFileSystemClaim.js +0 -3
  57. package/dist/ActivityFileSystemClaim.js.map +0 -1
  58. package/dist/helpers/__tests__/csv-reader.spec.d.ts +0 -2
  59. package/dist/helpers/__tests__/csv-reader.spec.d.ts.map +0 -1
  60. package/dist/helpers/__tests__/csv-reader.spec.js +0 -60
  61. package/dist/helpers/__tests__/csv-reader.spec.js.map +0 -1
  62. package/dist/helpers/csv-reader.d.ts +0 -8
  63. package/dist/helpers/csv-reader.d.ts.map +0 -1
  64. package/dist/helpers/csv-reader.js +0 -14
  65. package/dist/helpers/csv-reader.js.map +0 -1
  66. package/dist/helpers/parsers.d.ts +0 -9
  67. package/dist/helpers/parsers.d.ts.map +0 -1
  68. package/dist/helpers/parsers.js +0 -14
  69. package/dist/helpers/parsers.js.map +0 -1
  70. package/dist/testing/index.d.ts +0 -3
  71. package/dist/testing/index.d.ts.map +0 -1
  72. package/dist/testing/index.js +0 -32
  73. package/dist/testing/index.js.map +0 -1
@@ -19,10 +19,17 @@ 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 / windows — see the framework-fs-split skill
28
+ split, Split, FileSplitActivity, type FileSplitOptions,
29
+ join, FileJoinActivity, type FileJoinOptions, type FileJoinWhere,
30
+ read, FileReadActivity, type FileReadOptions,
31
+ ByteDelimiterScanner, type FileSplitDelimiter, type ByteRange, type ByteTrim,
32
+ } from '@t4h.framework/fs'
26
33
  ```
27
34
 
28
35
  Do not import from internal paths such as `src/FileSystemClaim.ts` in application code.
@@ -42,6 +49,12 @@ export abstract class FileSystemClaim {
42
49
  ): Binary | Promise<Binary>
43
50
 
44
51
  public abstract read(path: string): Binary | Promise<Binary>
52
+
53
+ public abstract slice(
54
+ binary: Binary,
55
+ range: { start: number; end: number },
56
+ encoding?: BufferEncoding,
57
+ ): Binary | Promise<Binary>
45
58
  }
46
59
  ```
47
60
 
@@ -49,8 +62,9 @@ export abstract class FileSystemClaim {
49
62
  |--------|---------|
50
63
  | `write(path, readable)` | Persist stream or buffer at `path`; return a `Binary` handle (metadata + `stream()`). |
51
64
  | `read(path)` | Load an existing file at `path`; return a `Binary` handle. |
65
+ | `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. Implementations must **compose ranges**: slicing a slice is relative to it, and the stored range is absolute against the underlying file. |
52
66
 
53
- Both methods may return synchronously or as a `Promise`. Implementations choose storage layout; callers should only rely on the returned `Binary` API.
67
+ All methods may return synchronously or as a `Promise`. Implementations choose storage layout; callers should only rely on the returned `Binary` API.
54
68
 
55
69
  ### Binary (from `@t4h.framework/core`)
56
70
 
@@ -82,6 +96,10 @@ read the resulting `Binary` (e.g. `await res.body.json()` / `text()`). Your only
82
96
  responsibility is that the runtime **registers a provider** (below). See
83
97
  **Known consumers** for which package primitives to orchestrate.
84
98
 
99
+ This package's own workflow-facing primitives are `split`, `join` and `read`
100
+ (small byte windows — headers, trailers — hydrated into the workflow as a
101
+ string, capped at 1 MiB). See the `framework-fs-split` skill.
102
+
85
103
  ---
86
104
 
87
105
  ## Runtime provider
@@ -104,6 +122,7 @@ These packages depend on `@t4h.framework/fs` and declare `FileSystemClaim`; read
104
122
  |---------|--------|
105
123
  | `@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()`. |
106
124
  | `@t4h.framework/jsx` | Render activities write their output to `fs` and hand back a `Binary` you consume in the workflow. |
125
+ | `@t4h.framework/fs` itself | `FileSplitActivity` persists the durable copy and slices it by ranges; `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. |
107
126
 
108
127
  Orchestrate these package primitives from your workflow; the `fs` write/read happens inside them.
109
128
 
@@ -186,6 +205,7 @@ Assert `mockWrite` was called with the expected path and body when relevant.
186
205
 
187
206
  ## Related skills
188
207
 
208
+ - `framework-fs-split` — `split`/`join`: fan a file out into a batch of workflows and fold the results back into a file.
189
209
  - `framework-core` — `Claim`, `Binary`, and the `TestWorkflowEnvironment` testing harness.
190
210
  - `framework-http` — HTTP activities that persist bodies via `FileSystemClaim`.
191
211
  - `framework-jsx` — render activities that write/read via `fs`.
@@ -0,0 +1,350 @@
1
+ ---
2
+ name: framework-fs-split
3
+ description: >-
4
+ Guides correct use of `split`, `join` and `read` from @t4h.framework/fs:
5
+ fanning one large file out into a batch of workflows (one per byte-range
6
+ slice), folding the batch results back into a single file, and reading small
7
+ byte windows (headers, trailers) into the workflow — with O(1) parent
8
+ history and bounded memory regardless of file size. Use whenever a workflow
9
+ must process a large file record by record — positional files with
10
+ header/trailer, plain-text lines, NDJSON, batch imports, per-record fan-out
11
+ with an aggregated output (e.g. a CSV report) — or when the user mentions
12
+ splitting/chunking a file, trimming a header/trailer, reading a file header,
13
+ filtering batch results, running a workflow per line/record, waiting for a
14
+ batch, collecting batch results, or the symbols `split`, `join`, `read`,
15
+ `Split`, `FileSplitActivity`, `FileJoinActivity`, `FileReadActivity`,
16
+ `WorkflowBatchRef`, `WorkflowBatchClaim`. Prefer this skill over
17
+ hand-rolling stream loops + `startWorkflow` calls.
18
+ ---
19
+
20
+ # `split` / `join` / `read` — file fan-out, fold and windows for workflows
21
+
22
+ `split` turns **one file into a batch of workflows**: it streams the file once,
23
+ cuts it into **byte-range slices** (never copying slice content), starts one
24
+ child workflow per slice, and **suspends the parent until every child is
25
+ terminal**. `join` is the inverse: it folds the batch results back into a
26
+ single file, streaming one result at a time.
27
+
28
+ ```typescript
29
+ import { read, split, join, Split } from '@t4h.framework/fs'
30
+
31
+ const processRecord = new Workflow(
32
+ { id: 'process-record', schema: Type.String() },
33
+ async (record: string) => {
34
+ // record is the raw slice content — no Binary handling needed
35
+ return toCsvRow(parsePositional(record))
36
+ },
37
+ )
38
+
39
+ const main = new Workflow(fileSchema, async (file: Binary) => {
40
+ const header = await read(file, { range: { start: 0, end: 240 } })
41
+ // header: string — recorded in history, never re-read on replay
42
+
43
+ const batch = await split(file, processRecord, {
44
+ delimiter: Split.char('\n10', { next: 2 }),
45
+ trim: { start: 241, end: 241 }, // header/trailer stay out of the batch
46
+ timeoutInMs: 3_600_000,
47
+ })
48
+ // this line only runs once the WHOLE batch is terminal;
49
+ // batch: WorkflowBatchRef — { id: string, count: number }
50
+
51
+ return await join(batch, { format: 'lines', header: 'id;value;date' })
52
+ // Binary: the aggregated file — no slice ever crossed workflow memory
53
+ })
54
+ ```
55
+
56
+ Package path: `framework/packages/fs`. Import only from the package root.
57
+
58
+ ---
59
+
60
+ ## The memory model (the golden rule)
61
+
62
+ Everything here exists to keep the parent history **O(1)** whatever the file
63
+ size:
64
+
65
+ 1. `split` reads the source stream **exactly once, worker side**: the same
66
+ pass persists a durable copy through `FileSystemClaim.write` while a byte
67
+ scanner records slice offsets. No slice content is materialized.
68
+ 2. Children receive **range views** over the durable copy
69
+ (`FileSystemClaim.slice`); the runtime hydrates each slice only inside that
70
+ child's own run — as a plain decoded string when the child's schema is
71
+ `Type.String()`.
72
+ 3. The parent resumes with only `WorkflowBatchRef { id, count }`. Per-child
73
+ results are **never aggregated onto the ref**: they live with each child
74
+ run.
75
+ 4. `join` streams results one at a time through `WorkflowBatchClaim.outputs`
76
+ into `FileSystemClaim.write` with backpressure; only the resulting `Binary`
77
+ reaches history.
78
+
79
+ **Golden rule:** volume proportional to the batch size only ever flows inside
80
+ an activity, through a claim. Into workflow history goes a fold result or a
81
+ bounded slice — never the N items.
82
+
83
+ ---
84
+
85
+ ## `split`
86
+
87
+ ```typescript
88
+ async function split(
89
+ binary: Binary,
90
+ workflow: Workflow<any, any>,
91
+ options: FileSplitOptions,
92
+ ): Promise<WorkflowBatchRef>
93
+ ```
94
+
95
+ The child workflow declares its **real input type** (e.g. `Type.String()`) —
96
+ it never sees `Binary`, ranges, or storage. `await split(...)` suspends the
97
+ parent (one pending activity, one resume, one replay) until every child is
98
+ terminal — **regardless of each child succeeding or failing**: a rejected
99
+ child never rejects the batch (allSettled semantics). An empty batch resolves
100
+ immediately.
101
+
102
+ ### Delimiters (`options.delimiter`)
103
+
104
+ The cut is a serializable spec. When the delimiter is found at
105
+ `[p, p + length)`, the previous slice ends at `p + keep.previous` and the next
106
+ slice starts at `p + length - keep.next`; the bytes in between are discarded
107
+ (defaults discard the whole delimiter — classic line semantics).
108
+
109
+ ```typescript
110
+ Split.line() // cut at every '\n', delimiter discarded
111
+ Split.char(';') // cut at any byte sequence
112
+ Split.char(';', { previous: 1 }) // keep the ';' with the previous slice
113
+ Split.char('\n10', { next: 2 }) // positional files, see below
114
+ ```
115
+
116
+ Positional files where a logical record spans multiple physical lines and a
117
+ new record only starts when a line begins with a marker (the record type
118
+ code):
119
+
120
+ ```
121
+ 10JOHN;123\n20detail-a\n20detail-b\n10MARY;456\n20detail-c
122
+ └──────────── record 1 ───────────┘└─────── record 2 ──────┘
123
+ ```
124
+
125
+ `Split.char('\n10', { next: 2 })`: the newline between records is discarded
126
+ and the `10` marker — which is **data**, not separator — stays at the start of
127
+ the next slice. Forgetting `{ next: 2 }` silently strips the record type code
128
+ from every slice.
129
+
130
+ Offsets are always computed over **raw bytes** (never decoded characters), so
131
+ multi-byte encodings cannot skew the ranges a ranged download will use.
132
+
133
+ ### Other options
134
+
135
+ | Option | Default | Behavior |
136
+ |--------|---------|----------|
137
+ | `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. |
138
+ | `trim` | none | `{ start?, end? }` **bytes** cut off each end of the file before scanning — a positional header/trailer. Byte-based on purpose: a record-start delimiter (`'\n10'`) fuses the trailer into the last record's slice, so a record-count skip would drop a valid record along with it. Trimmed content never becomes a slice nor counts toward `every`; fetch it with `read`. Positional layouts make the byte counts constants — getting them right is the caller's business. |
139
+ | `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. |
140
+ | `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). |
141
+ | `timeoutInMs` | 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. |
142
+
143
+ ---
144
+
145
+ ## `join`
146
+
147
+ ```typescript
148
+ async function join(
149
+ batch: WorkflowBatchRef,
150
+ options: FileJoinOptions,
151
+ ): Promise<Binary>
152
+ ```
153
+
154
+ Formats (content is the **child's** job — the child returns the final line;
155
+ `join` only owns structure):
156
+
157
+ | Format | Behavior |
158
+ |--------|----------|
159
+ | `lines` | Concatenates raw child outputs, one per line. Options: `header`, `separator` (default `'\n'`). Non-string outputs are JSON-stringified. |
160
+ | `entries` | NDJSON dump of `{ index, status, output }`, one per line — the format-agnostic view of the batch, rejected children included by default. |
161
+ | `csv` | Projects object outputs into RFC 4180 escaped columns. Options: `columns` (required; entries are `{ key, header? }` — `key` selects the value on the child output (a dot-path like `'meta.total'` reaches nested fields, matching the `where` semantics; a literal top-level key with dots wins over the nested path), `header` labels the header row, plain string is shorthand for `{ key }`), `delimiter` (default `','`), `header` (default `true`). Missing keys become empty cells; nested values selected whole are JSON inside the cell. Value *formatting* belongs in the child workflow (sandboxed user code); the join only owns layout. |
162
+
163
+ **Status filter** (`status?: ('fulfilled' | 'rejected')[]`) selects which
164
+ results make it into the file — there is no error policy. Defaults:
165
+ `['fulfilled']` for `lines`/`csv`, both for `entries`. Filtering is silent by
166
+ design; a reprocessing file of failures is one call away:
167
+
168
+ ```typescript
169
+ const failures = await join(batch, { format: 'entries', status: ['rejected'] })
170
+ ```
171
+
172
+ **Where filter** (`where?`) narrows results with a **sift (MongoDB-style)
173
+ query** over each entry's output, applied after `status`. Whitelisted subset
174
+ only — declarative, JSON-only operators: `$eq`, `$ne`, `$lt`, `$lte`, `$gt`,
175
+ `$gte`, `$in`, `$nin`, `$exists`, `$not`, `$and`, `$or`, `$nor`, plus plain
176
+ equality and dot-paths for nested fields. Pass the child output type as a
177
+ generic to narrow top-level keys/values (dot-paths accepted, not narrowed):
178
+
179
+ ```typescript
180
+ const relevant = await join<InvoiceResult>(batch, {
181
+ format: 'csv',
182
+ columns: ['id', 'total'],
183
+ where: { valid: true, total: { $gt: 100 }, 'meta.situacao': 'aprovada' },
184
+ })
185
+ ```
186
+
187
+ Non-serializable filters are rejected eagerly at `toInput` (`TypeError`):
188
+ functions (incl. `$where`), `RegExp` (incl. `$regex`), `undefined`, operators
189
+ outside the subset — a dropped closure would make the replay filter
190
+ differently than the first execution. Arbitrary predicates belong in a custom
191
+ activity in project code (the function then lives in versioned code).
192
+ MongoDB semantics apply: array fields match by containment (`{ tags: 'a' }`
193
+ matches `['a', 'b']`), object values compare deeply, no type coercion. The
194
+ query compiles once and runs inside the existing streaming loop over
195
+ already-hydrated outputs — same memory profile; app-side (decides what enters
196
+ the file, not what leaves the database).
197
+
198
+ ---
199
+
200
+ ## `read`
201
+
202
+ ```typescript
203
+ async function read(
204
+ binary: Binary,
205
+ options?: { range?: { start: number; end: number }; encoding?: BufferEncoding },
206
+ ): Promise<string>
207
+ ```
208
+
209
+ Reads a byte window of a `Binary` into the workflow as a decoded string. The
210
+ output is recorded in history (replay never re-reads the file), so reads are
211
+ **capped at 1 MiB** — this is the primitive for headers, trailers and single
212
+ records; a data file flows through `split`, never through `read`. `range`
213
+ defaults to the whole binary (same cap applies); `encoding` defaults to
214
+ `'utf8'`.
215
+
216
+ Two paths under the hood:
217
+
218
+ 1. **Fast path** — `FileSystemClaim.slice`: on a file-backed binary a trailer
219
+ read is a ranged download, whatever the file size.
220
+ 2. **Streaming fallback** — binaries the claim cannot slice (e.g. an SFTP
221
+ reference): the stream is consumed up to the window and destroyed as soon
222
+ as it is filled. A header read pulls the first chunks only, whatever the
223
+ file size; a **trailing window over a non-sliceable binary** is the one
224
+ expensive case — the stream traverses the whole file to reach it.
225
+
226
+ Reading a slice is **relative to the slice** (`range: { start: 0, end: 10 }`
227
+ returns its first 10 bytes, not the underlying file's) — slice ranges compose.
228
+
229
+ The positional-file recipe — header/trailer in the parent, records in the
230
+ batch:
231
+
232
+ ```typescript
233
+ const header = await read(file, { range: { start: 0, end: HEADER } })
234
+ const trailer = await read(file, {
235
+ range: { start: file.contentLength - TRAILER, end: file.contentLength },
236
+ })
237
+
238
+ const batch = await split(file, processInvoice, {
239
+ delimiter: Split.char('\n10', { next: 2 }),
240
+ trim: { start: HEADER + 1, end: TRAILER + 1 }, // +1: boundary newlines
241
+ })
242
+
243
+ // e.g. validate the trailer's record count against batch.count
244
+ ```
245
+
246
+ ---
247
+
248
+ ## Runtime requirements
249
+
250
+ - Claim providers: `FileSystemClaim` (with `slice`), `WorkflowClaim` (with
251
+ `startMany`), and `WorkflowBatchClaim` (from `@t4h.framework/core`).
252
+ - Production whitelist: `FileSplitActivity`, `FileJoinActivity`,
253
+ `FileReadActivity` and core's `StartManyWorkflowsActivity` must be
254
+ registered via `runtime.addActivity`.
255
+ - `FileSystemClaim.slice` implementations must **compose ranges** when slicing
256
+ an already-sliced binary (relative in, absolute against the underlying file
257
+ out) — `read` and slice consumers rely on that semantics.
258
+ - `FileSplitActivity` is an **AsyncActivity**: the heavy work happens in
259
+ `bootstrap`, executed worker side atomically with the pending suspension —
260
+ children cannot finish before the parent is awaiting them.
261
+ - `FileJoinActivity` is a **SyncActivity**: batches are only consumed after
262
+ the completion barrier resolved, so there is nothing to await.
263
+
264
+ ---
265
+
266
+ ## Testing
267
+
268
+ Drive `bootstrap`/`run` directly with fake claims inside `Claim.run` (patterns
269
+ from `src/activities/__tests__/`):
270
+
271
+ ```typescript
272
+ import { AsyncActivityResultPending, Claim, WorkflowBatchClaim, WorkflowBatchRef, WorkflowClaim } from '@t4h.framework/core'
273
+ import { FileSplitActivity, FileSystemClaim, Split } from '@t4h.framework/fs'
274
+
275
+ const write = vi.fn(async (_path, readable) => durableBinary) // must consume the stream
276
+ const slice = vi.fn((binary, range, encoding) => ({ binary, range, encoding }))
277
+ const startMany = vi.fn(async (_wf, inputs) => new WorkflowBatchRef('batch-1', inputs.length))
278
+
279
+ const result = await Claim.run(
280
+ [
281
+ { provide: FileSystemClaim, value: { write, slice } as never },
282
+ { provide: WorkflowClaim, value: { startMany } as never },
283
+ ],
284
+ () => {
285
+ const activity = new FileSplitActivity()
286
+ activity.id = 'activity-1'
287
+ return activity.bootstrap({ binary, workflow: 'child', options: { delimiter: Split.line() } })
288
+ },
289
+ )
290
+
291
+ expect(result).toBeInstanceOf(AsyncActivityResultPending)
292
+ expect(slice).toHaveBeenCalledWith(durableBinary, { start: 0, end: 3 }, 'utf8')
293
+ ```
294
+
295
+ For `join`, provide `WorkflowBatchClaim` as an async generator:
296
+
297
+ ```typescript
298
+ const outputs = vi.fn(async function* () {
299
+ yield { index: 0, status: 'fulfilled', output: 'line-1' }
300
+ })
301
+ ```
302
+
303
+ Behaviors worth asserting because they regress easily: the durable copy keeps
304
+ the original bytes; slices are views over the durable copy (not the source);
305
+ `keep` arithmetic on positional cuts; the empty batch resolving immediately;
306
+ CSV escaping (quotes doubled, delimiter/newline wrapped); the status filter.
307
+
308
+ ---
309
+
310
+ ## Anti-patterns
311
+
312
+ - **Paging batch results into the workflow.** A `readBatch(ref, cursor)` loop
313
+ in workflow code turns every page into a replayed history entry — O(N) is
314
+ back. Aggregation belongs inside `join` (or a framework activity consuming
315
+ `WorkflowBatchClaim`).
316
+ - **Expecting per-child outputs on the ref.** `WorkflowBatchRef` is
317
+ deliberately `{ id, count }` only. Results are read through `join` — e.g.
318
+ `format: 'entries'` for the faithful dump.
319
+ - **Treating a rejected child as a batch failure.** The batch always resolves
320
+ (allSettled). Decide what failures mean: compare against `batch.count`,
321
+ generate the `status: ['rejected']` file, or tolerate.
322
+ - **Baking record format into `join`.** The child returns the final line
323
+ (`lines`) or a projectable object (`csv`); custom formats live in the child
324
+ workflow — sandboxed user code — not in new join options.
325
+ - **Splitting when you need the file whole.** If downstream wants the blob
326
+ intact (hash it, upload it), pass the `Binary` through.
327
+ - **Forgetting `keep` on positional delimiters.** `Split.char('\n10')` without
328
+ `{ next: 2 }` silently discards the record type code from every slice.
329
+ - **Splitting a positional file without `trim`.** The header rides inside the
330
+ first slice (with `every: N` it silently consumes a record slot) and the
331
+ trailer fuses into the last one. Trim both ends; fetch them with `read`.
332
+ - **Reading a data file with `read`.** The output lands in workflow history;
333
+ the 1 MiB cap is a guard, not a target. Records flow through `split`.
334
+ - **Passing a function as a join filter.** Options serialize into history — a
335
+ closure cannot survive it, so `toInput` rejects functions, `$where`,
336
+ `RegExp` and out-of-subset operators with a `TypeError`. Use the `where`
337
+ sift subset or a custom activity whose predicate lives in versioned code.
338
+ - **Hand-rolling the loop.** A stream-and-`startWorkflow` loop inside an
339
+ activity re-parses on replay, misses the barrier, and pushes N entries into
340
+ history.
341
+
342
+ ---
343
+
344
+ ## Related skills
345
+
346
+ - `framework-fs` — `FileSystemClaim` contract (including `slice`), `Binary`,
347
+ provider wiring.
348
+ - `framework-core` — `startWorkflowMany` (the array-input sibling of `split`),
349
+ `WorkflowBatchRef`, `WorkflowBatchClaim`, `Claim.run`, activity model,
350
+ testing harness.