@t4h.framework/fs 0.13.0 → 0.14.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 (80) hide show
  1. package/.ai/skills/framework-fs/SKILL.md +87 -186
  2. package/.ai/skills/framework-fs-split/SKILL.md +134 -314
  3. package/.ai/skills/framework-fs-write/SKILL.md +54 -214
  4. package/CHANGELOG.md +56 -0
  5. package/README.md +20 -351
  6. package/dist/activities/FileJoinActivity.d.ts +4 -5
  7. package/dist/activities/FileJoinActivity.d.ts.map +1 -1
  8. package/dist/activities/FileJoinActivity.js +38 -43
  9. package/dist/activities/FileJoinActivity.js.map +1 -1
  10. package/dist/activities/FileReadActivity.d.ts +1 -0
  11. package/dist/activities/FileReadActivity.d.ts.map +1 -1
  12. package/dist/activities/FileReadActivity.js +34 -26
  13. package/dist/activities/FileReadActivity.js.map +1 -1
  14. package/dist/activities/FileSplitActivity.d.ts +23 -43
  15. package/dist/activities/FileSplitActivity.d.ts.map +1 -1
  16. package/dist/activities/FileSplitActivity.js +23 -38
  17. package/dist/activities/FileSplitActivity.js.map +1 -1
  18. package/dist/constants/operations.d.ts +1 -1
  19. package/dist/constants/operations.d.ts.map +1 -1
  20. package/dist/constants/operations.js +3 -14
  21. package/dist/constants/operations.js.map +1 -1
  22. package/dist/fs.d.ts +5 -1
  23. package/dist/fs.d.ts.map +1 -1
  24. package/dist/helpers/CSV.d.ts +2 -2
  25. package/dist/helpers/CSV.d.ts.map +1 -1
  26. package/dist/helpers/CSV.js +20 -22
  27. package/dist/helpers/CSV.js.map +1 -1
  28. package/dist/helpers/group-by-key.d.ts +8 -0
  29. package/dist/helpers/group-by-key.d.ts.map +1 -0
  30. package/dist/helpers/group-by-key.js +35 -0
  31. package/dist/helpers/group-by-key.js.map +1 -0
  32. package/dist/helpers/group-records.d.ts +2 -2
  33. package/dist/helpers/group-records.d.ts.map +1 -1
  34. package/dist/helpers/group-records.js +3 -3
  35. package/dist/helpers/group-records.js.map +1 -1
  36. package/dist/helpers/in-chunks.d.ts +5 -0
  37. package/dist/helpers/in-chunks.d.ts.map +1 -0
  38. package/dist/helpers/in-chunks.js +17 -0
  39. package/dist/helpers/in-chunks.js.map +1 -0
  40. package/dist/helpers/split-records.d.ts +4 -3
  41. package/dist/helpers/split-records.d.ts.map +1 -1
  42. package/dist/helpers/split-records.js +75 -46
  43. package/dist/helpers/split-records.js.map +1 -1
  44. package/dist/helpers/where-matcher.d.ts +21 -0
  45. package/dist/helpers/where-matcher.d.ts.map +1 -0
  46. package/dist/helpers/where-matcher.js +11 -0
  47. package/dist/helpers/where-matcher.js.map +1 -0
  48. package/dist/models/BinaryRecordKey.d.ts +27 -0
  49. package/dist/models/BinaryRecordKey.d.ts.map +1 -0
  50. package/dist/models/BinaryRecordKey.js +52 -0
  51. package/dist/models/BinaryRecordKey.js.map +1 -0
  52. package/dist/models/BinarySource.d.ts +45 -0
  53. package/dist/models/BinarySource.d.ts.map +1 -0
  54. package/dist/models/BinarySource.js +58 -0
  55. package/dist/models/BinarySource.js.map +1 -0
  56. package/dist/models/BinarySplit.d.ts +45 -0
  57. package/dist/models/BinarySplit.d.ts.map +1 -0
  58. package/dist/models/BinarySplit.js +81 -0
  59. package/dist/models/BinarySplit.js.map +1 -0
  60. package/dist/models/BinarySplitInput.d.ts +21 -0
  61. package/dist/models/BinarySplitInput.d.ts.map +1 -0
  62. package/dist/models/BinarySplitInput.js +85 -0
  63. package/dist/models/BinarySplitInput.js.map +1 -0
  64. package/dist/models/Database.d.ts +15 -0
  65. package/dist/models/Database.d.ts.map +1 -0
  66. package/dist/models/Database.js +57 -0
  67. package/dist/models/Database.js.map +1 -0
  68. package/dist/models/OpenKeyGroup.d.ts +23 -0
  69. package/dist/models/OpenKeyGroup.d.ts.map +1 -0
  70. package/dist/models/OpenKeyGroup.js +42 -0
  71. package/dist/models/OpenKeyGroup.js.map +1 -0
  72. package/dist/models/SplitDatabaseWorker.d.ts +12 -0
  73. package/dist/models/SplitDatabaseWorker.d.ts.map +1 -0
  74. package/dist/models/SplitDatabaseWorker.js +65 -0
  75. package/dist/models/SplitDatabaseWorker.js.map +1 -0
  76. package/dist/workers/split-database.d.ts +10 -0
  77. package/dist/workers/split-database.d.ts.map +1 -0
  78. package/dist/workers/split-database.js +43 -0
  79. package/dist/workers/split-database.js.map +1 -0
  80. package/package.json +5 -4
@@ -1,345 +1,165 @@
1
1
  ---
2
2
  name: framework-fs-split
3
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
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.
4
+ Shows how to process a large file record by record in a T4H Framework
5
+ workflow with split() and join() from @t4h.framework/fs: split starts one
6
+ child workflow per record or block of records (Split.line, Split.char,
7
+ every, where, trim, timeoutInMs) and waits for the batch, and join folds the
8
+ child outputs into one file (lines, entries, csv, status, where). It also
9
+ shows split with input (Split.slice, Split.key, Split.rows) and with
10
+ groupBy and with, which start one child per key with the rows of other
11
+ files with that key. Use when a workflow must run a workflow per line or
12
+ record, import a positional file with a header and a trailer, chunk a file,
13
+ filter records, collect batch results, build a report or return file from a
14
+ batch, cross a file with other files by key, or pass context to each child,
15
+ or when someone mentions WorkflowBatchCollectionRef, WorkflowBatchRef,
16
+ FileSplitActivity or startMany for a file. Prefer it to a loop of
17
+ startWorkflow calls, and to a join or a split per key in a loop.
18
18
  ---
19
19
 
20
- # `split` / `join` / `read` — file fan-out, fold and windows for workflows
20
+ # split and join — one child workflow per record
21
21
 
22
- `split` turns **one file into a batch of workflows**: it streams the file once,
23
- cuts it into slices, starts one child workflow per slice, and **suspends the parent until every child is
24
- terminal**. `join` is the inverse: it folds the batch results back into a
25
- single file, streaming one result at a time.
22
+ Read the skill `framework-fs` first for the `Binary` rules and for `read`.
23
+
24
+ ## Fan a file out and fold it back
26
25
 
27
26
  ```typescript
28
- import { read, split, join, Split } from '@t4h.framework/fs'
27
+ import { join, read, split, Split } from '@t4h.framework/fs'
29
28
 
30
29
  const processRecord = new Workflow(
31
30
  { id: 'process-record', schema: Type.String() },
32
- async (record: string) => {
33
- // record is the raw slice content — no Binary handling needed
34
- return toCsvRow(parsePositional(record))
35
- },
31
+ async (record: string) => toCsvRow(record),
36
32
  )
37
33
 
38
- const main = new Workflow(fileSchema, async (file: Binary) => {
39
- const header = await read(file, { range: { start: 0, end: 240 } })
40
- // header: string — recorded in history, never re-read on replay
41
-
42
- const batch = await split(file, processRecord, {
43
- delimiter: Split.char('\n10', { next: 2 }),
44
- trim: { start: 241, end: 241 }, // header/trailer stay out of the batch
45
- timeoutInMs: 3_600_000,
46
- })
47
- // this line only runs once the WHOLE batch is terminal;
48
- // batch: WorkflowBatchRef — { id: string, count: number }
49
-
50
- return await join(batch, { format: 'lines', header: 'id;value;date' })
51
- // Binary: the aggregated file — no slice ever crossed workflow memory
52
- })
53
- ```
54
-
55
- Package path: `framework/packages/fs`. Import only from the package root.
56
-
57
- ---
58
-
59
- ## The memory model (the golden rule)
60
-
61
- Everything here exists to keep the parent history **O(1)** whatever the file
62
- size:
63
-
64
- 1. `split` reads the source once, worker side: it cuts records, drops the ones
65
- `where` rejects, groups the rest by `every` and hands each slice to the
66
- batch as a decoded string as soon as it is complete. Nothing collects the
67
- slices, no copy of the file is written, and a slice past 3 MiB
68
- fails the split while it reads.
69
- 2. Each child receives its slice as a plain string — what its
70
- `Type.String()` schema declares.
71
- 3. The parent resumes with only `WorkflowBatchRef { id, count }`. Per-child
72
- results are **never aggregated onto the ref**: they live with each child
73
- run.
74
- 4. `join` streams results one at a time through `WorkflowBatchClaim.outputs`
75
- into `FileSystemClaim.write` with backpressure; only the resulting `Binary`
76
- reaches history.
77
-
78
- **Golden rule:** volume proportional to the batch size only ever flows inside
79
- an activity, through a claim. Into workflow history goes a fold result or a
80
- bounded slice — never the N items.
81
-
82
- ---
83
-
84
- ## `split`
85
-
86
- ```typescript
87
- async function split(
88
- binary: Binary,
89
- workflow: Workflow<any, any>,
90
- options: FileSplitOptions,
91
- ): Promise<WorkflowBatchRef>
92
- ```
93
-
94
- The child workflow declares its **real input type** (e.g. `Type.String()`) —
95
- it never sees `Binary`, ranges, or storage. `await split(...)` suspends the
96
- parent (one pending activity, one resume, one replay) until every child is
97
- terminal — **regardless of each child succeeding or failing**: a rejected
98
- child never rejects the batch (allSettled semantics). An empty batch resolves
99
- immediately.
100
-
101
- ### Delimiters (`options.delimiter`)
34
+ const main = new Workflow(
35
+ { id: 'main', schema: fileSchema },
36
+ async (file: Binary) => {
37
+ const header = await read(file, { range: { start: 0, end: 240 } })
102
38
 
103
- The cut is a serializable spec. When the delimiter is found at
104
- `[p, p + length)`, the previous slice ends at `p + keep.previous` and the next
105
- slice starts at `p + length - keep.next`; the bytes in between are discarded
106
- (defaults discard the whole delimiter — classic line semantics).
39
+ const batch = await split(file, processRecord, {
40
+ delimiter: Split.char('\n10', { next: 2 }),
41
+ trim: { start: 241, end: 241 },
42
+ timeoutInMs: 3_600_000,
43
+ })
107
44
 
108
- ```typescript
109
- Split.line() // cut at every '\n', delimiter discarded
110
- Split.char(';') // cut at any byte sequence
111
- Split.char(';', { previous: 1 }) // keep the ';' with the previous slice
112
- Split.char('\n10', { next: 2 }) // positional files, see below
113
- ```
114
-
115
- Positional files where a logical record spans multiple physical lines and a
116
- new record only starts when a line begins with a marker (the record type
117
- code):
118
-
119
- ```
120
- 10JOHN;123\n20detail-a\n20detail-b\n10MARY;456\n20detail-c
121
- └──────────── record 1 ───────────┘└─────── record 2 ──────┘
122
- ```
123
-
124
- `Split.char('\n10', { next: 2 })`: the newline between records is discarded
125
- and the `10` marker — which is **data**, not separator — stays at the start of
126
- the next slice. Forgetting `{ next: 2 }` silently strips the record type code
127
- from every slice.
128
-
129
- Offsets are always computed over **raw bytes** (never decoded characters), so
130
- multi-byte encodings cannot skew the cuts.
131
-
132
- ### Other options
133
-
134
- | Option | Default | Behavior |
135
- |--------|---------|----------|
136
- | `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. A slice may take up to 3 MiB; past that the split fails while it reads — lower `every`. |
137
- | `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. |
138
- | `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. |
139
- | `encoding` | `'utf8'` | Converts the delimiter to bytes and decodes each slice into the string the child receives. |
140
- | `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. |
141
-
142
- ---
143
-
144
- ## `join`
145
-
146
- ```typescript
147
- async function join(
148
- batch: WorkflowBatchRef,
149
- options: FileJoinOptions,
150
- ): Promise<Binary>
151
- ```
152
-
153
- Formats (content is the **child's** job — the child returns the final line;
154
- `join` only owns structure):
155
-
156
- | Format | Behavior |
157
- |--------|----------|
158
- | `lines` | Concatenates raw child outputs, one per line. Options: `header`, `separator` (default `'\n'`). Non-string outputs are JSON-stringified. |
159
- | `entries` | NDJSON dump of `{ index, status, output }`, one per line — the format-agnostic view of the batch, rejected children included by default. |
160
- | `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. |
161
-
162
- **Status filter** (`status?: ('fulfilled' | 'rejected')[]`) selects which
163
- results make it into the file — there is no error policy. Defaults:
164
- `['fulfilled']` for `lines`/`csv`, both for `entries`. Filtering is silent by
165
- design; a reprocessing file of failures is one call away:
166
-
167
- ```typescript
168
- const failures = await join(batch, { format: 'entries', status: ['rejected'] })
169
- ```
170
-
171
- **Where filter** (`where?`) narrows results with a **sift (MongoDB-style)
172
- query** over each entry's output, applied after `status`. Whitelisted subset
173
- only — declarative, JSON-only operators: `$eq`, `$ne`, `$lt`, `$lte`, `$gt`,
174
- `$gte`, `$in`, `$nin`, `$exists`, `$not`, `$and`, `$or`, `$nor`, plus plain
175
- equality and dot-paths for nested fields. Pass the child output type as a
176
- generic to narrow top-level keys/values (dot-paths accepted, not narrowed):
177
-
178
- ```typescript
179
- const relevant = await join<InvoiceResult>(batch, {
180
- format: 'csv',
181
- columns: ['id', 'total'],
182
- where: { valid: true, total: { $gt: 100 }, 'meta.situacao': 'aprovada' },
183
- })
184
- ```
185
-
186
- Non-serializable filters are rejected eagerly at `toInput` (`TypeError`):
187
- functions (incl. `$where`), `RegExp` (incl. `$regex`), `undefined`, operators
188
- outside the subset — a dropped closure would make the replay filter
189
- differently than the first execution. Arbitrary predicates belong in a custom
190
- activity in project code (the function then lives in versioned code).
191
- MongoDB semantics apply: array fields match by containment (`{ tags: 'a' }`
192
- matches `['a', 'b']`), object values compare deeply, no type coercion. The
193
- query compiles once and runs inside the existing streaming loop over
194
- already-hydrated outputs — same memory profile; app-side (decides what enters
195
- the file, not what leaves the database).
196
-
197
- ---
198
-
199
- ## `read`
200
-
201
- ```typescript
202
- async function read(
203
- binary: Binary,
204
- options?: { range?: { start: number; end: number }; encoding?: BufferEncoding },
205
- ): Promise<string>
45
+ return await join(batch, { format: 'lines', header: 'id;value;date' })
46
+ },
47
+ )
206
48
  ```
207
49
 
208
- Reads a byte window of a `Binary` into the workflow as a decoded string. The
209
- output is recorded in history (replay never re-reads the file), so reads are
210
- **capped at 1 MiB** — this is the primitive for headers, trailers and single
211
- records; a data file flows through `split`, never through `read`. `range`
212
- defaults to the whole binary (same cap applies); `encoding` defaults to
213
- `'utf8'`.
50
+ 1. Write the child workflow with an `id` and the schema `Type.String()`. It
51
+ gets the raw slice text. With `every: N` it gets N records joined by the
52
+ delimiter and must iterate them.
53
+ 2. Call `split(file, child, { delimiter })`. The parent waits until every
54
+ child is terminal and gets a `WorkflowBatchCollectionRef` (`{ id, count }`).
55
+ 3. Call `join(batch, { format })` to get one `Binary` with the outputs.
56
+
57
+ ## Traps
58
+
59
+ - `split` resolves when all children are terminal, also when some fail. A
60
+ rejected child does not reject `split`. Compare with `batch.count`, or join
61
+ the failures with `join(batch, { format: 'entries', status: ['rejected'] })`.
62
+ - The outputs are not on the reference. Do not page them into the workflow;
63
+ each page goes into the history. Fold them with `join`.
64
+ - `WorkflowBatchRef` is deprecated. Use `WorkflowBatchCollectionRef`.
65
+ - Do not call `split`, `join`, `read` or a batch query (`size`, `take`,
66
+ `toArray`) in a loop. The `no-batch-query-in-loop` rule rejects it.
67
+ - Do not construct `FileSplitActivity` or `FileJoinActivity`, and do not loop
68
+ over a stream with `startWorkflow`. Call `split`.
69
+ - Positional file with a record-start code: `Split.char('\n10', { next: 2 })`.
70
+ Without `{ next: 2 }` every slice loses its `10` code.
71
+ - Positional header and trailer: drop them with `trim` (in bytes, including
72
+ the newline) and read them with `read`. Without `trim` the header goes into
73
+ the first slice and the trailer into the last.
74
+ - A slice must fit in 3 MiB, or `split` throws a `RangeError`. Lower `every`
75
+ or check the delimiter.
76
+ - Always set `timeoutInMs`. On expiry `split` does not throw: it resolves with
77
+ a ref whose `id` and `count` are `undefined`. Check `ref.id` before you use
78
+ the ref.
79
+ - `where` must be JSON: no function and no `RegExp`. In `split`, `where` tests
80
+ each record as a string and accepts `$regex` as a string with `$options`
81
+ (`{ $regex: '^01', $options: 'i' }`). In `join`, `where` tests each output
82
+ object and does not accept `$regex`.
83
+ - `join` formats: `lines` (child returns the final line), `csv` (child returns
84
+ an object, `columns` is required), `entries` (NDJSON of
85
+ `{ index, status, output }`). `status` defaults to `['fulfilled']`, and to
86
+ both statuses for `entries`. Put value formatting in the child, not in
87
+ `join`.
88
+ - To get a file whole (hash, upload), do not split it. Pass the `Binary`.
89
+ - To give each child more than its slice, use
90
+ `input: { record: Split.slice, tenant }`. Put the markers (`Split.slice`,
91
+ `Split.key`, `Split.rows(name)`) as they are: `` `id-${Split.slice}` `` or
92
+ `Split.slice.toUpperCase()` throws a `TypeError`. Parse the slice in the
93
+ child.
94
+ - To cross a file with other files by key (an alert with the rows of its
95
+ customer), do not call `split` or `join` per key in a loop: each call reads
96
+ the whole file. Use one `split` with `groupBy` and `with`:
97
+
98
+ ```typescript
99
+ await split(alerts, openTicket, {
100
+ delimiter: Split.line(),
101
+ groupBy: { match: /^([^;]*);"?0*(\d+)/ },
102
+ with: {
103
+ accounts: {
104
+ file: accounts,
105
+ delimiter: Split.line(),
106
+ key: { match: /^([^;]*);"?0*(\d+)/ },
107
+ },
108
+ },
109
+ input: {
110
+ key: Split.key,
111
+ alerts: Split.slice,
112
+ accounts: Split.rows('accounts'),
113
+ },
114
+ timeoutInMs: 3_600_000,
115
+ })
116
+ ```
214
117
 
215
- Two paths under the hood:
118
+ Both `match` patterns must give the same key text; use `replace` to drop
119
+ separators or quotes. Only the keys of the main file start a child. `input`
120
+ is required, `every` does not apply, and one key must fit in 3 MiB.
216
121
 
217
- 1. **Fast path** — `FileSystemClaim.slice`: on a file-backed binary a trailer
218
- read is a ranged download, whatever the file size.
219
- 2. **Streaming fallback** — binaries the claim cannot slice (e.g. an SFTP
220
- reference): the stream is consumed up to the window and destroyed as soon
221
- as it is filled. A header read pulls the first chunks only, whatever the
222
- file size; a **trailing window over a non-sliceable binary** is the one
223
- expensive case — the stream traverses the whole file to reach it.
122
+ - Put in the main file the file with one line per case (the alerts), and the
123
+ large support files in `with`: only their rows with a key of the main file
124
+ are kept. The main file and the `with` files together can have 1 GiB at
125
+ most, or `split` throws a `RangeError` before it reads them.
224
126
 
225
- Reading a slice is **relative to the slice** (`range: { start: 0, end: 10 }`
226
- returns its first 10 bytes, not the underlying file's) — slice ranges compose.
127
+ ## Test
227
128
 
228
- The positional-file recipe — header/trailer in the parent, records in the
229
- batch:
129
+ `split` starts the children with `WorkflowClaim.startMany`; `join` reads
130
+ `WorkflowBatchClaim.outputs` and writes with `FileSystemClaim.write` (mock
131
+ from the skill `framework-fs`):
230
132
 
231
133
  ```typescript
232
- const header = await read(file, { range: { start: 0, end: HEADER } })
233
- const trailer = await read(file, {
234
- range: { start: file.contentLength - TRAILER, end: file.contentLength },
235
- })
236
-
237
- const batch = await split(file, processInvoice, {
238
- delimiter: Split.char('\n10', { next: 2 }),
239
- trim: { start: HEADER + 1, end: TRAILER + 1 }, // +1: boundary newlines
134
+ const slices: string[] = []
135
+ const startMany = vi.fn(async (_workflow, inputs: AsyncIterable<string>) => {
136
+ for await (const slice of inputs) slices.push(slice)
137
+ return new WorkflowBatchCollectionRef('batch-1', slices.length)
240
138
  })
241
-
242
- // e.g. validate the trailer's record count against batch.count
243
- ```
244
-
245
- ---
246
-
247
- ## Runtime requirements
248
-
249
- - Claim providers: `FileSystemClaim` (with `slice`, for `read` and `join`),
250
- `WorkflowClaim` (with `startMany` reading an `AsyncIterable`), and
251
- `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, Split } from '@t4h.framework/fs'
274
-
275
- const inputs: unknown[] = []
276
- const startMany = vi.fn(async (_wf, slices: AsyncIterable<unknown>) => {
277
- for await (const slice of slices) inputs.push(slice) // must consume the stream
278
- return new WorkflowBatchRef('batch-1', inputs.length)
139
+ const outputs = vi.fn(async function* () {
140
+ yield { index: 0, status: 'fulfilled' as const, output: '1;10.00' }
279
141
  })
280
142
 
281
- const result = await Claim.run(
282
- [{ provide: WorkflowClaim, value: { startMany } as never }],
283
- () =>
284
- new FileSplitActivity().bootstrap({ binary, workflow: 'child', options: { delimiter: Split.line() } }),
285
- )
286
-
287
- expect(result).toBeInstanceOf(AsyncActivityResultPending)
288
- expect(inputs).toEqual(['foo', 'bar', 'baz'])
289
- ```
290
-
291
- For `join`, provide `WorkflowBatchClaim` as an async generator:
292
-
293
- ```typescript
294
- const outputs = vi.fn(async function* () {
295
- yield { index: 0, status: 'fulfilled', output: 'line-1' }
143
+ await TestWorkflowEnvironment.create().execute(main, file, {
144
+ claims: [
145
+ mockClaim(WorkflowClaim, { startMany }),
146
+ mockClaim(WorkflowBatchClaim, { outputs }),
147
+ mockClaim(FileSystemClaim, { write }),
148
+ ],
149
+ mockAsyncActivity: mockAsync().on(
150
+ FileSplitActivity,
151
+ fulfilled({ batch: 'batch-1', count: 1 }),
152
+ ),
296
153
  })
297
154
  ```
298
155
 
299
- Behaviors worth asserting because they regress easily: the source is streamed
300
- once; `keep` arithmetic on positional cuts; the empty batch resolving immediately;
301
- CSV escaping (quotes doubled, delimiter/newline wrapped); the status filter.
302
-
303
- ---
304
-
305
- ## Anti-patterns
306
-
307
- - **Paging batch results into the workflow.** A `readBatch(ref, cursor)` loop
308
- in workflow code turns every page into a replayed history entry — O(N) is
309
- back. Aggregation belongs inside `join` (or a framework activity consuming
310
- `WorkflowBatchClaim`).
311
- - **Expecting per-child outputs on the ref.** `WorkflowBatchRef` is
312
- deliberately `{ id, count }` only. Results are read through `join` — e.g.
313
- `format: 'entries'` for the faithful dump.
314
- - **Treating a rejected child as a batch failure.** The batch always resolves
315
- (allSettled). Decide what failures mean: compare against `batch.count`,
316
- generate the `status: ['rejected']` file, or tolerate.
317
- - **Baking record format into `join`.** The child returns the final line
318
- (`lines`) or a projectable object (`csv`); custom formats live in the child
319
- workflow — sandboxed user code — not in new join options.
320
- - **Splitting when you need the file whole.** If downstream wants the blob
321
- intact (hash it, upload it), pass the `Binary` through.
322
- - **Forgetting `keep` on positional delimiters.** `Split.char('\n10')` without
323
- `{ next: 2 }` silently discards the record type code from every slice.
324
- - **Splitting a positional file without `trim`.** The header rides inside the
325
- first slice (with `every: N` it silently consumes a record slot) and the
326
- trailer fuses into the last one. Trim both ends; fetch them with `read`.
327
- - **Reading a data file with `read`.** The output lands in workflow history;
328
- the 1 MiB cap is a guard, not a target. Records flow through `split`.
329
- - **Passing a function as a join filter.** Options serialize into history — a
330
- closure cannot survive it, so `toInput` rejects functions, `$where`,
331
- `RegExp` and out-of-subset operators with a `TypeError`. Use the `where`
332
- sift subset or a custom activity whose predicate lives in versioned code.
333
- - **Hand-rolling the loop.** A stream-and-`startWorkflow` loop inside an
334
- activity re-parses on replay, misses the barrier, and pushes N entries into
335
- history.
336
-
337
- ---
156
+ When `startMany` returns a count of `0`, `split` resolves at once without the
157
+ `mockAsync` handler.
338
158
 
339
- ## Related skills
159
+ ## Reference
340
160
 
341
- - `framework-fs` — `FileSystemClaim` contract (including `slice`), `Binary`,
342
- provider wiring.
343
- - `framework-core` — `startWorkflowMany` (the array-input sibling of `split`),
344
- `WorkflowBatchRef`, `WorkflowBatchClaim`, `Claim.run`, activity model,
345
- testing harness.
161
+ - `split`: https://tech4humans-brasil.github.io/framework/fs/split
162
+ - `join`: https://tech4humans-brasil.github.io/framework/fs/join
163
+ - Types: `node_modules/@t4h.framework/fs/dist/activities/FileSplitActivity.d.ts`
164
+ and `FileJoinActivity.d.ts` in the same folder
165
+ - Related skills: `framework-fs`, `framework-workflow-testing`.