@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.
- package/.ai/skills/framework-fs/SKILL.md +87 -186
- package/.ai/skills/framework-fs-split/SKILL.md +134 -306
- package/.ai/skills/framework-fs-write/SKILL.md +54 -212
- package/CHANGELOG.md +26 -0
- package/README.md +20 -351
- package/dist/activities/FileJoinActivity.d.ts +1 -2
- package/dist/activities/FileJoinActivity.d.ts.map +1 -1
- package/dist/activities/FileJoinActivity.js +38 -43
- package/dist/activities/FileJoinActivity.js.map +1 -1
- package/dist/activities/FileReadActivity.d.ts.map +1 -1
- package/dist/activities/FileReadActivity.js +18 -15
- package/dist/activities/FileReadActivity.js.map +1 -1
- package/dist/activities/FileSplitActivity.d.ts +21 -34
- package/dist/activities/FileSplitActivity.d.ts.map +1 -1
- package/dist/activities/FileSplitActivity.js +23 -38
- package/dist/activities/FileSplitActivity.js.map +1 -1
- package/dist/constants/operations.d.ts.map +1 -1
- package/dist/constants/operations.js +3 -14
- package/dist/constants/operations.js.map +1 -1
- package/dist/fs.d.ts +5 -1
- package/dist/fs.d.ts.map +1 -1
- package/dist/helpers/CSV.d.ts +2 -2
- package/dist/helpers/CSV.d.ts.map +1 -1
- package/dist/helpers/CSV.js +20 -24
- package/dist/helpers/CSV.js.map +1 -1
- package/dist/helpers/group-by-key.d.ts +8 -0
- package/dist/helpers/group-by-key.d.ts.map +1 -0
- package/dist/helpers/group-by-key.js +35 -0
- package/dist/helpers/group-by-key.js.map +1 -0
- package/dist/helpers/group-records.d.ts +2 -2
- package/dist/helpers/group-records.d.ts.map +1 -1
- package/dist/helpers/group-records.js +3 -3
- package/dist/helpers/group-records.js.map +1 -1
- package/dist/helpers/in-chunks.d.ts +5 -0
- package/dist/helpers/in-chunks.d.ts.map +1 -0
- package/dist/helpers/in-chunks.js +17 -0
- package/dist/helpers/in-chunks.js.map +1 -0
- package/dist/helpers/split-records.d.ts +4 -3
- package/dist/helpers/split-records.d.ts.map +1 -1
- package/dist/helpers/split-records.js +60 -41
- package/dist/helpers/split-records.js.map +1 -1
- package/dist/helpers/where-matcher.d.ts +21 -0
- package/dist/helpers/where-matcher.d.ts.map +1 -0
- package/dist/helpers/where-matcher.js +11 -0
- package/dist/helpers/where-matcher.js.map +1 -0
- package/dist/models/BinaryRecordKey.d.ts +27 -0
- package/dist/models/BinaryRecordKey.d.ts.map +1 -0
- package/dist/models/BinaryRecordKey.js +52 -0
- package/dist/models/BinaryRecordKey.js.map +1 -0
- package/dist/models/BinarySource.d.ts +45 -0
- package/dist/models/BinarySource.d.ts.map +1 -0
- package/dist/models/BinarySource.js +58 -0
- package/dist/models/BinarySource.js.map +1 -0
- package/dist/models/BinarySplit.d.ts +45 -0
- package/dist/models/BinarySplit.d.ts.map +1 -0
- package/dist/models/BinarySplit.js +81 -0
- package/dist/models/BinarySplit.js.map +1 -0
- package/dist/models/BinarySplitInput.d.ts +21 -0
- package/dist/models/BinarySplitInput.d.ts.map +1 -0
- package/dist/models/BinarySplitInput.js +85 -0
- package/dist/models/BinarySplitInput.js.map +1 -0
- package/dist/models/Database.d.ts +15 -0
- package/dist/models/Database.d.ts.map +1 -0
- package/dist/models/Database.js +57 -0
- package/dist/models/Database.js.map +1 -0
- package/dist/models/OpenKeyGroup.d.ts +23 -0
- package/dist/models/OpenKeyGroup.d.ts.map +1 -0
- package/dist/models/OpenKeyGroup.js +42 -0
- package/dist/models/OpenKeyGroup.js.map +1 -0
- package/dist/models/SplitDatabaseWorker.d.ts +12 -0
- package/dist/models/SplitDatabaseWorker.d.ts.map +1 -0
- package/dist/models/SplitDatabaseWorker.js +65 -0
- package/dist/models/SplitDatabaseWorker.js.map +1 -0
- package/dist/workers/split-database.d.ts +10 -0
- package/dist/workers/split-database.d.ts.map +1 -0
- package/dist/workers/split-database.js +43 -0
- package/dist/workers/split-database.js.map +1 -0
- package/package.json +3 -3
|
@@ -1,337 +1,165 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: framework-fs-split
|
|
3
3
|
description: >-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
with
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
batch,
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
#
|
|
20
|
+
# split and join — one child workflow per record
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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,
|
|
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(
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
219
|
-
returns its first 10 bytes, not the underlying file's) — slice ranges compose.
|
|
127
|
+
## Test
|
|
220
128
|
|
|
221
|
-
|
|
222
|
-
|
|
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
|
|
226
|
-
const
|
|
227
|
-
|
|
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
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
)
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
293
|
-
|
|
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
|
-
##
|
|
159
|
+
## Reference
|
|
332
160
|
|
|
333
|
-
- `framework
|
|
334
|
-
|
|
335
|
-
- `framework
|
|
336
|
-
|
|
337
|
-
|
|
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`.
|