@t4h.framework/fs 0.13.1 → 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 (78) hide show
  1. package/.ai/skills/framework-fs/SKILL.md +87 -186
  2. package/.ai/skills/framework-fs-split/SKILL.md +134 -306
  3. package/.ai/skills/framework-fs-write/SKILL.md +54 -212
  4. package/CHANGELOG.md +26 -0
  5. package/README.md +20 -351
  6. package/dist/activities/FileJoinActivity.d.ts +1 -2
  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.map +1 -1
  11. package/dist/activities/FileReadActivity.js +18 -15
  12. package/dist/activities/FileReadActivity.js.map +1 -1
  13. package/dist/activities/FileSplitActivity.d.ts +21 -34
  14. package/dist/activities/FileSplitActivity.d.ts.map +1 -1
  15. package/dist/activities/FileSplitActivity.js +23 -38
  16. package/dist/activities/FileSplitActivity.js.map +1 -1
  17. package/dist/constants/operations.d.ts.map +1 -1
  18. package/dist/constants/operations.js +3 -14
  19. package/dist/constants/operations.js.map +1 -1
  20. package/dist/fs.d.ts +5 -1
  21. package/dist/fs.d.ts.map +1 -1
  22. package/dist/helpers/CSV.d.ts +2 -2
  23. package/dist/helpers/CSV.d.ts.map +1 -1
  24. package/dist/helpers/CSV.js +20 -24
  25. package/dist/helpers/CSV.js.map +1 -1
  26. package/dist/helpers/group-by-key.d.ts +8 -0
  27. package/dist/helpers/group-by-key.d.ts.map +1 -0
  28. package/dist/helpers/group-by-key.js +35 -0
  29. package/dist/helpers/group-by-key.js.map +1 -0
  30. package/dist/helpers/group-records.d.ts +2 -2
  31. package/dist/helpers/group-records.d.ts.map +1 -1
  32. package/dist/helpers/group-records.js +3 -3
  33. package/dist/helpers/group-records.js.map +1 -1
  34. package/dist/helpers/in-chunks.d.ts +5 -0
  35. package/dist/helpers/in-chunks.d.ts.map +1 -0
  36. package/dist/helpers/in-chunks.js +17 -0
  37. package/dist/helpers/in-chunks.js.map +1 -0
  38. package/dist/helpers/split-records.d.ts +4 -3
  39. package/dist/helpers/split-records.d.ts.map +1 -1
  40. package/dist/helpers/split-records.js +60 -41
  41. package/dist/helpers/split-records.js.map +1 -1
  42. package/dist/helpers/where-matcher.d.ts +21 -0
  43. package/dist/helpers/where-matcher.d.ts.map +1 -0
  44. package/dist/helpers/where-matcher.js +11 -0
  45. package/dist/helpers/where-matcher.js.map +1 -0
  46. package/dist/models/BinaryRecordKey.d.ts +27 -0
  47. package/dist/models/BinaryRecordKey.d.ts.map +1 -0
  48. package/dist/models/BinaryRecordKey.js +52 -0
  49. package/dist/models/BinaryRecordKey.js.map +1 -0
  50. package/dist/models/BinarySource.d.ts +45 -0
  51. package/dist/models/BinarySource.d.ts.map +1 -0
  52. package/dist/models/BinarySource.js +58 -0
  53. package/dist/models/BinarySource.js.map +1 -0
  54. package/dist/models/BinarySplit.d.ts +45 -0
  55. package/dist/models/BinarySplit.d.ts.map +1 -0
  56. package/dist/models/BinarySplit.js +81 -0
  57. package/dist/models/BinarySplit.js.map +1 -0
  58. package/dist/models/BinarySplitInput.d.ts +21 -0
  59. package/dist/models/BinarySplitInput.d.ts.map +1 -0
  60. package/dist/models/BinarySplitInput.js +85 -0
  61. package/dist/models/BinarySplitInput.js.map +1 -0
  62. package/dist/models/Database.d.ts +15 -0
  63. package/dist/models/Database.d.ts.map +1 -0
  64. package/dist/models/Database.js +57 -0
  65. package/dist/models/Database.js.map +1 -0
  66. package/dist/models/OpenKeyGroup.d.ts +23 -0
  67. package/dist/models/OpenKeyGroup.d.ts.map +1 -0
  68. package/dist/models/OpenKeyGroup.js +42 -0
  69. package/dist/models/OpenKeyGroup.js.map +1 -0
  70. package/dist/models/SplitDatabaseWorker.d.ts +12 -0
  71. package/dist/models/SplitDatabaseWorker.d.ts.map +1 -0
  72. package/dist/models/SplitDatabaseWorker.js +65 -0
  73. package/dist/models/SplitDatabaseWorker.js.map +1 -0
  74. package/dist/workers/split-database.d.ts +10 -0
  75. package/dist/workers/split-database.d.ts.map +1 -0
  76. package/dist/workers/split-database.js +43 -0
  77. package/dist/workers/split-database.js.map +1 -0
  78. package/package.json +3 -3
@@ -1,337 +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({ id: 'main', schema: 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, and no copy of the file is written.
68
- 2. Each child receives its slice as a plain string — what its
69
- `Type.String()` schema declares.
70
- 3. The parent resumes with only `WorkflowBatchRef { id, count }`. Per-child
71
- results are **never aggregated onto the ref**: they live with each child
72
- run.
73
- 4. `join` streams results one at a time through `WorkflowBatchClaim.outputs`
74
- into `FileSystemClaim.write` with backpressure; only the resulting `Binary`
75
- reaches history.
76
-
77
- **Golden rule:** volume proportional to the batch size only ever flows inside
78
- an activity, through a claim. Into workflow history goes a fold result or a
79
- bounded slice — never the N items.
80
-
81
- ---
82
-
83
- ## `split`
84
-
85
- ```typescript
86
- async function split(
87
- binary: Binary,
88
- workflow: Workflow<any, any>,
89
- options: FileSplitOptions,
90
- ): Promise<WorkflowBatchRef>
91
- ```
92
-
93
- The child workflow declares its **real input type** (e.g. `Type.String()`) —
94
- it never sees `Binary`, ranges, or storage. `await split(...)` suspends the
95
- parent (one pending activity, one resume, one replay) until every child is
96
- terminal — **regardless of each child succeeding or failing**: a rejected
97
- child never rejects the batch (allSettled semantics). An empty batch resolves
98
- immediately.
99
-
100
- ### 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 } })
101
38
 
102
- The cut is a serializable spec. When the delimiter is found at
103
- `[p, p + length)`, the previous slice ends at `p + keep.previous` and the next
104
- slice starts at `p + length - keep.next`; the bytes in between are discarded
105
- (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
+ })
106
44
 
107
- ```typescript
108
- Split.line() // cut at every '\n', delimiter discarded
109
- Split.char(';') // cut at any byte sequence
110
- Split.char(';', { previous: 1 }) // keep the ';' with the previous slice
111
- Split.char('\n10', { next: 2 }) // positional files, see below
112
- ```
113
-
114
- Positional files where a logical record spans multiple physical lines and a
115
- new record only starts when a line begins with a marker (the record type
116
- code):
117
-
118
- ```
119
- 10JOHN;123\n20detail-a\n20detail-b\n10MARY;456\n20detail-c
120
- └──────────── record 1 ───────────┘└─────── record 2 ──────┘
121
- ```
122
-
123
- `Split.char('\n10', { next: 2 })`: the newline between records is discarded
124
- and the `10` marker — which is **data**, not separator — stays at the start of
125
- the next slice. Forgetting `{ next: 2 }` silently strips the record type code
126
- from every slice.
127
-
128
- Offsets are always computed over **raw bytes** (never decoded characters), so
129
- multi-byte encodings cannot skew the cuts.
130
-
131
- ### Other options
132
-
133
- | Option | Default | Behavior |
134
- |--------|---------|----------|
135
- | `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 over 3 MiB makes the split throw a `RangeError`; lower `every`. |
136
- | `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. |
137
- | `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. |
138
- | `encoding` | `'utf8'` | Converts the delimiter to bytes and decodes each slice into the string the child receives. |
139
- | `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. |
140
-
141
- ---
142
-
143
- ## `join`
144
-
145
- ```typescript
146
- async function join(
147
- batch: WorkflowBatchRef,
148
- options: FileJoinOptions,
149
- ): Promise<Binary>
150
- ```
151
-
152
- Formats (content is the **child's** job — the child returns the final line;
153
- `join` only owns structure):
154
-
155
- | Format | Behavior |
156
- |--------|----------|
157
- | `lines` | Concatenates raw child outputs, one per line. Options: `header`, `separator` (default `'\n'`). Non-string outputs are JSON-stringified. |
158
- | `entries` | NDJSON dump of `{ index, status, output }`, one per line — the format-agnostic view of the batch, rejected children included by default. |
159
- | `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. |
160
-
161
- **Status filter** (`status?: ('fulfilled' | 'rejected')[]`) selects which
162
- results make it into the file — there is no error policy. Defaults:
163
- `['fulfilled']` for `lines`/`csv`, both for `entries`. Filtering is silent by
164
- design; a reprocessing file of failures is one call away:
165
-
166
- ```typescript
167
- const failures = await join(batch, { format: 'entries', status: ['rejected'] })
168
- ```
169
-
170
- **Where filter** (`where?`) narrows results with a **sift (MongoDB-style)
171
- query** over each entry's output, applied after `status`. Whitelisted subset
172
- only — declarative, JSON-only operators: `$eq`, `$ne`, `$lt`, `$lte`, `$gt`,
173
- `$gte`, `$in`, `$nin`, `$exists`, `$not`, `$and`, `$or`, `$nor`, plus plain
174
- equality and dot-paths for nested fields. Pass the child output type as a
175
- generic to narrow top-level keys/values (dot-paths accepted, not narrowed):
176
-
177
- ```typescript
178
- const relevant = await join<InvoiceResult>(batch, {
179
- format: 'csv',
180
- columns: ['id', 'total'],
181
- where: { valid: true, total: { $gt: 100 }, 'meta.situacao': 'aprovada' },
182
- })
183
- ```
184
-
185
- For an arbitrary predicate, write a custom activity in project code, so the
186
- function lives in versioned code. MongoDB semantics apply: array fields match
187
- by containment (`{ tags: 'a' }` matches `['a', 'b']`), object values compare
188
- deeply, no type coercion. The query compiles once and runs inside the existing
189
- streaming loop over already-hydrated outputs — same memory profile; app-side
190
- (decides what enters the file, not what leaves the database).
191
-
192
- ---
193
-
194
- ## `read`
195
-
196
- ```typescript
197
- async function read(
198
- binary: Binary,
199
- options?: { range?: { start: number; end: number }; encoding?: BufferEncoding },
200
- ): Promise<string>
45
+ return await join(batch, { format: 'lines', header: 'id;value;date' })
46
+ },
47
+ )
201
48
  ```
202
49
 
203
- Reads a byte window of a `Binary` into the workflow as a decoded string. The
204
- output is recorded in history (replay never re-reads the file). Use it for
205
- headers, trailers and single records; a data file flows through `split`.
206
- `range` defaults to the whole binary; `encoding` defaults to `'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
+ ```
207
117
 
208
- 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.
209
121
 
210
- 1. **Fast path** — `FileSystemClaim.slice`: on a file-backed binary a trailer
211
- read is a ranged download, whatever the file size.
212
- 2. **Streaming fallback** — binaries the claim cannot slice (e.g. an SFTP
213
- reference): the stream is consumed up to the window and destroyed as soon
214
- as it is filled. A header read pulls the first chunks only, whatever the
215
- file size; a **trailing window over a non-sliceable binary** is the one
216
- 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.
217
126
 
218
- Reading a slice is **relative to the slice** (`range: { start: 0, end: 10 }`
219
- returns its first 10 bytes, not the underlying file's) — slice ranges compose.
127
+ ## Test
220
128
 
221
- The positional-file recipe — header/trailer in the parent, records in the
222
- 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`):
223
132
 
224
133
  ```typescript
225
- const header = await read(file, { range: { start: 0, end: HEADER } })
226
- const trailer = await read(file, {
227
- range: { start: file.contentLength - TRAILER, end: file.contentLength },
228
- })
229
-
230
- const batch = await split(file, processInvoice, {
231
- delimiter: Split.char('\n10', { next: 2 }),
232
- 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)
233
138
  })
234
-
235
- // e.g. validate the trailer's record count against batch.count
236
- ```
237
-
238
- ---
239
-
240
- ## Runtime requirements
241
-
242
- - Claim providers: `FileSystemClaim` (with `slice`, for `read` and `join`),
243
- `WorkflowClaim` (with `startMany` reading an `AsyncIterable`), and
244
- `WorkflowBatchClaim` (from `@t4h.framework/core`).
245
- - Production whitelist: `FileSplitActivity`, `FileJoinActivity`,
246
- `FileReadActivity` and core's `StartManyWorkflowsActivity` must be
247
- registered via `runtime.addActivity`.
248
- - `FileSystemClaim.slice` implementations must **compose ranges** when slicing
249
- an already-sliced binary (relative in, absolute against the underlying file
250
- out) — `read` and slice consumers rely on that semantics.
251
- - `FileSplitActivity` is an **AsyncActivity**: the heavy work happens in
252
- `bootstrap`, executed worker side atomically with the pending suspension —
253
- children cannot finish before the parent is awaiting them.
254
- - `FileJoinActivity` is a **SyncActivity**: batches are only consumed after
255
- the completion barrier resolved, so there is nothing to await.
256
-
257
- ---
258
-
259
- ## Testing
260
-
261
- Drive `bootstrap`/`run` directly with fake claims inside `Claim.run` (patterns
262
- from `src/activities/__tests__/`):
263
-
264
- ```typescript
265
- import { AsyncActivityResultPending, Claim, WorkflowBatchClaim, WorkflowBatchRef, WorkflowClaim } from '@t4h.framework/core'
266
- import { FileSplitActivity, Split } from '@t4h.framework/fs'
267
-
268
- const inputs: unknown[] = []
269
- const startMany = vi.fn(async (_wf, slices: AsyncIterable<unknown>) => {
270
- for await (const slice of slices) inputs.push(slice) // must consume the stream
271
- 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' }
272
141
  })
273
142
 
274
- const result = await Claim.run(
275
- [{ provide: WorkflowClaim, value: { startMany } as never }],
276
- () =>
277
- new FileSplitActivity().bootstrap({ binary, workflow: 'child', options: { delimiter: Split.line() } }),
278
- )
279
-
280
- expect(result).toBeInstanceOf(AsyncActivityResultPending)
281
- expect(inputs).toEqual(['foo', 'bar', 'baz'])
282
- ```
283
-
284
- For `join`, provide `WorkflowBatchClaim` as an async generator:
285
-
286
- ```typescript
287
- const outputs = vi.fn(async function* () {
288
- 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
+ ),
289
153
  })
290
154
  ```
291
155
 
292
- Behaviors worth asserting because they regress easily: the source is streamed
293
- once; `keep` arithmetic on positional cuts; the empty batch resolving immediately;
294
- CSV escaping (quotes doubled, delimiter/newline wrapped); the status filter.
295
-
296
- ---
297
-
298
- ## Anti-patterns
299
-
300
- - **Paging batch results into the workflow.** A `readBatch(ref, cursor)` loop
301
- in workflow code turns every page into a replayed history entry — O(N) is
302
- back. Aggregation belongs inside `join` (or a framework activity consuming
303
- `WorkflowBatchClaim`).
304
- - **Expecting per-child outputs on the ref.** `WorkflowBatchRef` is
305
- deliberately `{ id, count }` only. Results are read through `join` — e.g.
306
- `format: 'entries'` for the faithful dump.
307
- - **Treating a rejected child as a batch failure.** The batch always resolves
308
- (allSettled). Decide what failures mean: compare against `batch.count`,
309
- generate the `status: ['rejected']` file, or tolerate.
310
- - **Baking record format into `join`.** The child returns the final line
311
- (`lines`) or a projectable object (`csv`); custom formats live in the child
312
- workflow — sandboxed user code — not in new join options.
313
- - **Splitting when you need the file whole.** If downstream wants the blob
314
- intact (hash it, upload it), pass the `Binary` through.
315
- - **Forgetting `keep` on positional delimiters.** `Split.char('\n10')` without
316
- `{ next: 2 }` silently discards the record type code from every slice.
317
- - **Splitting a positional file without `trim`.** The header rides inside the
318
- first slice (with `every: N` it silently consumes a record slot) and the
319
- trailer fuses into the last one. Trim both ends; fetch them with `read`.
320
- - **Reading a data file with `read`.** The output lands in workflow history.
321
- Records flow through `split`. `read` throws a `RangeError` past 1 MiB.
322
- - **A non-serializable `where`.** `tsc` rejects a function through
323
- `WorkflowBatchWhere`. `toInput` throws a `TypeError` for a function,
324
- `$where`, a `RegExp`, `undefined` or an operator outside the subset.
325
- - **Hand-rolling the loop.** A stream-and-`startWorkflow` loop inside an
326
- activity re-parses on replay, misses the barrier, and pushes N entries into
327
- history.
328
-
329
- ---
156
+ When `startMany` returns a count of `0`, `split` resolves at once without the
157
+ `mockAsync` handler.
330
158
 
331
- ## Related skills
159
+ ## Reference
332
160
 
333
- - `framework-fs` — `FileSystemClaim` contract (including `slice`), `Binary`,
334
- provider wiring.
335
- - `framework-core` — `startWorkflowMany` (the array-input sibling of `split`),
336
- `WorkflowBatchRef`, `WorkflowBatchClaim`, `Claim.run`, activity model,
337
- 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`.