@t4h.framework/fs 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.ai/skills/framework-fs/SKILL.md +18 -3
- package/.ai/skills/framework-fs-split/SKILL.md +255 -0
- package/README.md +184 -3
- package/dist/FileSystemClaim.d.ts +4 -0
- package/dist/FileSystemClaim.d.ts.map +1 -1
- package/dist/FileSystemClaim.js.map +1 -1
- package/dist/activities/FileJoinActivity.d.ts +39 -0
- package/dist/activities/FileJoinActivity.d.ts.map +1 -0
- package/dist/activities/FileJoinActivity.js +87 -0
- package/dist/activities/FileJoinActivity.js.map +1 -0
- package/dist/activities/FileSplitActivity.d.ts +30 -28
- package/dist/activities/FileSplitActivity.d.ts.map +1 -1
- package/dist/activities/FileSplitActivity.js +39 -34
- package/dist/activities/FileSplitActivity.js.map +1 -1
- package/dist/activities/__tests__/FileJoinActivity.spec.d.ts +2 -0
- package/dist/activities/__tests__/FileJoinActivity.spec.d.ts.map +1 -0
- package/dist/activities/__tests__/FileJoinActivity.spec.js +160 -0
- package/dist/activities/__tests__/FileJoinActivity.spec.js.map +1 -0
- package/dist/activities/__tests__/FileSplitActivity.spec.js +114 -132
- package/dist/activities/__tests__/FileSplitActivity.spec.js.map +1 -1
- package/dist/fs.d.ts +3 -0
- package/dist/fs.d.ts.map +1 -1
- package/dist/fs.js +3 -0
- package/dist/fs.js.map +1 -1
- package/dist/helpers/__tests__/byte-delimiter-scanner.spec.d.ts +2 -0
- package/dist/helpers/__tests__/byte-delimiter-scanner.spec.d.ts.map +1 -0
- package/dist/helpers/__tests__/byte-delimiter-scanner.spec.js +127 -0
- package/dist/helpers/__tests__/byte-delimiter-scanner.spec.js.map +1 -0
- package/dist/helpers/byte-delimiter-scanner.d.ts +36 -0
- package/dist/helpers/byte-delimiter-scanner.d.ts.map +1 -0
- package/dist/helpers/byte-delimiter-scanner.js +61 -0
- package/dist/helpers/byte-delimiter-scanner.js.map +1 -0
- package/package.json +8 -5
|
@@ -19,10 +19,16 @@ Package path: `framework/packages/fs`. Peer dependency: `@t4h.framework/core` (`
|
|
|
19
19
|
|
|
20
20
|
## Import surface
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
Public exports live in `src/fs.ts`:
|
|
23
23
|
|
|
24
24
|
```typescript
|
|
25
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
FileSystemClaim,
|
|
27
|
+
// file fan-out / fold — see the framework-fs-split skill
|
|
28
|
+
split, Split, FileSplitActivity, type FileSplitOptions,
|
|
29
|
+
join, FileJoinActivity, type FileJoinOptions,
|
|
30
|
+
ByteDelimiterScanner, type FileSplitDelimiter, type ByteRange,
|
|
31
|
+
} from '@t4h.framework/fs'
|
|
26
32
|
```
|
|
27
33
|
|
|
28
34
|
Do not import from internal paths such as `src/FileSystemClaim.ts` in application code.
|
|
@@ -42,6 +48,12 @@ export abstract class FileSystemClaim {
|
|
|
42
48
|
): Binary | Promise<Binary>
|
|
43
49
|
|
|
44
50
|
public abstract read(path: string): Binary | Promise<Binary>
|
|
51
|
+
|
|
52
|
+
public abstract slice(
|
|
53
|
+
binary: Binary,
|
|
54
|
+
range: { start: number; end: number },
|
|
55
|
+
encoding?: BufferEncoding,
|
|
56
|
+
): Binary | Promise<Binary>
|
|
45
57
|
}
|
|
46
58
|
```
|
|
47
59
|
|
|
@@ -49,8 +61,9 @@ export abstract class FileSystemClaim {
|
|
|
49
61
|
|--------|---------|
|
|
50
62
|
| `write(path, readable)` | Persist stream or buffer at `path`; return a `Binary` handle (metadata + `stream()`). |
|
|
51
63
|
| `read(path)` | Load an existing file at `path`; return a `Binary` handle. |
|
|
64
|
+
| `slice(binary, range, encoding?)` | A view over a byte range of an existing binary, **without copying data**. When `encoding` is set, the runtime hydrates the slice as a decoded string instead of a binary — this is how `split` children receive plain strings. |
|
|
52
65
|
|
|
53
|
-
|
|
66
|
+
All methods may return synchronously or as a `Promise`. Implementations choose storage layout; callers should only rely on the returned `Binary` API.
|
|
54
67
|
|
|
55
68
|
### Binary (from `@t4h.framework/core`)
|
|
56
69
|
|
|
@@ -104,6 +117,7 @@ These packages depend on `@t4h.framework/fs` and declare `FileSystemClaim`; read
|
|
|
104
117
|
|---------|--------|
|
|
105
118
|
| `@t4h.framework/http` | The HTTP activity persists response bodies to `fs` and returns them wrapped in a `Body` — you read them with `res.body.json()` / `text()`. |
|
|
106
119
|
| `@t4h.framework/jsx` | Render activities write their output to `fs` and hand back a `Binary` you consume in the workflow. |
|
|
120
|
+
| `@t4h.framework/fs` itself | `FileSplitActivity` persists the durable copy and slices it by ranges; `FileJoinActivity` streams batch results into a new file. See the `framework-fs-split` skill. |
|
|
107
121
|
|
|
108
122
|
Orchestrate these package primitives from your workflow; the `fs` write/read happens inside them.
|
|
109
123
|
|
|
@@ -186,6 +200,7 @@ Assert `mockWrite` was called with the expected path and body when relevant.
|
|
|
186
200
|
|
|
187
201
|
## Related skills
|
|
188
202
|
|
|
203
|
+
- `framework-fs-split` — `split`/`join`: fan a file out into a batch of workflows and fold the results back into a file.
|
|
189
204
|
- `framework-core` — `Claim`, `Binary`, and the `TestWorkflowEnvironment` testing harness.
|
|
190
205
|
- `framework-http` — HTTP activities that persist bodies via `FileSystemClaim`.
|
|
191
206
|
- `framework-jsx` — render activities that write/read via `fs`.
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: framework-fs-split
|
|
3
|
+
description: >-
|
|
4
|
+
Guides correct use of `split` and `join` from @t4h.framework/fs: fanning one
|
|
5
|
+
large file out into a batch of workflows (one per byte-range slice) and
|
|
6
|
+
folding the batch results back into a single file — with O(1) parent history
|
|
7
|
+
and bounded memory regardless of file size. Use whenever a workflow must
|
|
8
|
+
process a large file record by record — positional files, plain-text lines,
|
|
9
|
+
NDJSON, batch imports, per-record fan-out with an aggregated output (e.g. a
|
|
10
|
+
CSV report) — or when the user mentions splitting/chunking a file, running a
|
|
11
|
+
workflow per line/record, waiting for a batch, collecting batch results, or
|
|
12
|
+
the symbols `split`, `join`, `Split`, `FileSplitActivity`,
|
|
13
|
+
`FileJoinActivity`, `WorkflowBatchRef`, `WorkflowBatchClaim`. Prefer this
|
|
14
|
+
skill over hand-rolling stream loops + `startWorkflow` calls.
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
# `split` / `join` — file fan-out and fold for workflows
|
|
18
|
+
|
|
19
|
+
`split` turns **one file into a batch of workflows**: it streams the file once,
|
|
20
|
+
cuts it into **byte-range slices** (never copying slice content), starts one
|
|
21
|
+
child workflow per slice, and **suspends the parent until every child is
|
|
22
|
+
terminal**. `join` is the inverse: it folds the batch results back into a
|
|
23
|
+
single file, streaming one result at a time.
|
|
24
|
+
|
|
25
|
+
```typescript
|
|
26
|
+
import { split, join, Split } from '@t4h.framework/fs'
|
|
27
|
+
|
|
28
|
+
const processRecord = new Workflow(
|
|
29
|
+
{ id: 'process-record', schema: Type.String() },
|
|
30
|
+
async (record: string) => {
|
|
31
|
+
// record is the raw slice content — no Binary handling needed
|
|
32
|
+
return toCsvRow(parsePositional(record))
|
|
33
|
+
},
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
const main = new Workflow(fileSchema, async (file: Binary) => {
|
|
37
|
+
const batch = await split(file, processRecord, {
|
|
38
|
+
delimiter: Split.char('\n10', { next: 2 }),
|
|
39
|
+
timeout: 3_600_000,
|
|
40
|
+
})
|
|
41
|
+
// this line only runs once the WHOLE batch is terminal;
|
|
42
|
+
// batch: WorkflowBatchRef — { id: string, count: number }
|
|
43
|
+
|
|
44
|
+
return await join(batch, { format: 'lines', header: 'id;value;date' })
|
|
45
|
+
// Binary: the aggregated file — no slice ever crossed workflow memory
|
|
46
|
+
})
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Package path: `framework/packages/fs`. Import only from the package root.
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## The memory model (the golden rule)
|
|
54
|
+
|
|
55
|
+
Everything here exists to keep the parent history **O(1)** whatever the file
|
|
56
|
+
size:
|
|
57
|
+
|
|
58
|
+
1. `split` reads the source stream **exactly once, worker side**: the same
|
|
59
|
+
pass persists a durable copy through `FileSystemClaim.write` while a byte
|
|
60
|
+
scanner records slice offsets. No slice content is materialized.
|
|
61
|
+
2. Children receive **range views** over the durable copy
|
|
62
|
+
(`FileSystemClaim.slice`); the runtime hydrates each slice only inside that
|
|
63
|
+
child's own run — as a plain decoded string when the child's schema is
|
|
64
|
+
`Type.String()`.
|
|
65
|
+
3. The parent resumes with only `WorkflowBatchRef { id, count }`. Per-child
|
|
66
|
+
results are **never aggregated onto the ref**: they live with each child
|
|
67
|
+
run.
|
|
68
|
+
4. `join` streams results one at a time through `WorkflowBatchClaim.outputs`
|
|
69
|
+
into `FileSystemClaim.write` with backpressure; only the resulting `Binary`
|
|
70
|
+
reaches history.
|
|
71
|
+
|
|
72
|
+
**Golden rule:** volume proportional to the batch size only ever flows inside
|
|
73
|
+
an activity, through a claim. Into workflow history goes a fold result or a
|
|
74
|
+
bounded slice — never the N items.
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## `split`
|
|
79
|
+
|
|
80
|
+
```typescript
|
|
81
|
+
async function split(
|
|
82
|
+
binary: Binary,
|
|
83
|
+
workflow: Workflow<any, any>,
|
|
84
|
+
options: FileSplitOptions,
|
|
85
|
+
): Promise<WorkflowBatchRef>
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The child workflow declares its **real input type** (e.g. `Type.String()`) —
|
|
89
|
+
it never sees `Binary`, ranges, or storage. `await split(...)` suspends the
|
|
90
|
+
parent (one pending activity, one resume, one replay) until every child is
|
|
91
|
+
terminal — **regardless of each child succeeding or failing**: a rejected
|
|
92
|
+
child never rejects the batch (allSettled semantics). An empty batch resolves
|
|
93
|
+
immediately.
|
|
94
|
+
|
|
95
|
+
### Delimiters (`options.delimiter`)
|
|
96
|
+
|
|
97
|
+
The cut is a serializable spec. When the delimiter is found at
|
|
98
|
+
`[p, p + length)`, the previous slice ends at `p + keep.previous` and the next
|
|
99
|
+
slice starts at `p + length - keep.next`; the bytes in between are discarded
|
|
100
|
+
(defaults discard the whole delimiter — classic line semantics).
|
|
101
|
+
|
|
102
|
+
```typescript
|
|
103
|
+
Split.line() // cut at every '\n', delimiter discarded
|
|
104
|
+
Split.char(';') // cut at any byte sequence
|
|
105
|
+
Split.char(';', { previous: 1 }) // keep the ';' with the previous slice
|
|
106
|
+
Split.char('\n10', { next: 2 }) // positional files, see below
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Positional files where a logical record spans multiple physical lines and a
|
|
110
|
+
new record only starts when a line begins with a marker (the record type
|
|
111
|
+
code):
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
10JOHN;123\n20detail-a\n20detail-b\n10MARY;456\n20detail-c
|
|
115
|
+
└──────────── record 1 ───────────┘└─────── record 2 ──────┘
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`Split.char('\n10', { next: 2 })`: the newline between records is discarded
|
|
119
|
+
and the `10` marker — which is **data**, not separator — stays at the start of
|
|
120
|
+
the next slice. Forgetting `{ next: 2 }` silently strips the record type code
|
|
121
|
+
from every slice.
|
|
122
|
+
|
|
123
|
+
Offsets are always computed over **raw bytes** (never decoded characters), so
|
|
124
|
+
multi-byte encodings cannot skew the ranges a ranged download will use.
|
|
125
|
+
|
|
126
|
+
### Other options
|
|
127
|
+
|
|
128
|
+
| Option | Default | Behavior |
|
|
129
|
+
|--------|---------|----------|
|
|
130
|
+
| `every` | `1` | Cut only every N delimiter occurrences — groups N records per child. One million lines with `every: 10_000` start 100 children, each receiving a raw block (inner delimiters untouched) to iterate locally. `keep` arithmetic applies only at actual cut points. |
|
|
131
|
+
| `skipEmpty` | `true` | Drops zero-length slices (consecutive delimiters, file starting/ending with one) so they never become child workflows. Turn off when slice index must align 1:1 with record position. "Empty" means zero bytes — a whitespace-only line is a regular slice. |
|
|
132
|
+
| `encoding` | `'utf8'` | Converts the delimiter to bytes and tells the runtime how to hydrate each slice for the child (string when set on the slice). |
|
|
133
|
+
| `timeout` | none | Max milliseconds to await the batch; on expiry the activity **rejects** (reason `timeout`) instead of suspending forever — the safety net for a child that never reaches a terminal state. |
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## `join`
|
|
138
|
+
|
|
139
|
+
```typescript
|
|
140
|
+
async function join(
|
|
141
|
+
batch: WorkflowBatchRef,
|
|
142
|
+
options: FileJoinOptions,
|
|
143
|
+
): Promise<Binary>
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Formats (content is the **child's** job — the child returns the final line;
|
|
147
|
+
`join` only owns structure):
|
|
148
|
+
|
|
149
|
+
| Format | Behavior |
|
|
150
|
+
|--------|----------|
|
|
151
|
+
| `lines` | Concatenates raw child outputs, one per line. Options: `header`, `separator` (default `'\n'`). Non-string outputs are JSON-stringified. |
|
|
152
|
+
| `entries` | NDJSON dump of `{ index, status, output }`, one per line — the format-agnostic view of the batch, rejected children included by default. |
|
|
153
|
+
| `csv` | Projects object outputs into RFC 4180 escaped columns. Options: `columns` (required; entries are `{ key, header? }` — `key` selects the value on the child output, `header` labels the header row, plain string is shorthand for `{ key }`), `delimiter` (default `','`), `header` (default `true`). Missing keys become empty cells; nested values are JSON inside the cell. Value *formatting* belongs in the child workflow (sandboxed user code); the join only owns layout. |
|
|
154
|
+
|
|
155
|
+
**Status filter** (`status?: ('fulfilled' | 'rejected')[]`) selects which
|
|
156
|
+
results make it into the file — there is no error policy. Defaults:
|
|
157
|
+
`['fulfilled']` for `lines`/`csv`, both for `entries`. Filtering is silent by
|
|
158
|
+
design; a reprocessing file of failures is one call away:
|
|
159
|
+
|
|
160
|
+
```typescript
|
|
161
|
+
const failures = await join(batch, { format: 'entries', status: ['rejected'] })
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
## Runtime requirements
|
|
167
|
+
|
|
168
|
+
- Claim providers: `FileSystemClaim` (with `slice`), `WorkflowClaim` (with
|
|
169
|
+
`startMany`), and `WorkflowBatchClaim` (from `@t4h.framework/core`).
|
|
170
|
+
- Production whitelist: `FileSplitActivity`, `FileJoinActivity` and core's
|
|
171
|
+
`StartManyWorkflowsActivity` must be registered via `runtime.addActivity`.
|
|
172
|
+
- `FileSplitActivity` is an **AsyncActivity**: the heavy work happens in
|
|
173
|
+
`bootstrap`, executed worker side atomically with the pending suspension —
|
|
174
|
+
children cannot finish before the parent is awaiting them.
|
|
175
|
+
- `FileJoinActivity` is a **SyncActivity**: batches are only consumed after
|
|
176
|
+
the completion barrier resolved, so there is nothing to await.
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
## Testing
|
|
181
|
+
|
|
182
|
+
Drive `bootstrap`/`run` directly with fake claims inside `Claim.run` (patterns
|
|
183
|
+
from `src/activities/__tests__/`):
|
|
184
|
+
|
|
185
|
+
```typescript
|
|
186
|
+
import { AsyncActivityResultPending, Claim, WorkflowBatchClaim, WorkflowBatchRef, WorkflowClaim } from '@t4h.framework/core'
|
|
187
|
+
import { FileSplitActivity, FileSystemClaim, Split } from '@t4h.framework/fs'
|
|
188
|
+
|
|
189
|
+
const write = vi.fn(async (_path, readable) => durableBinary) // must consume the stream
|
|
190
|
+
const slice = vi.fn((binary, range, encoding) => ({ binary, range, encoding }))
|
|
191
|
+
const startMany = vi.fn(async (_wf, inputs) => new WorkflowBatchRef('batch-1', inputs.length))
|
|
192
|
+
|
|
193
|
+
const result = await Claim.run(
|
|
194
|
+
[
|
|
195
|
+
{ provide: FileSystemClaim, value: { write, slice } as never },
|
|
196
|
+
{ provide: WorkflowClaim, value: { startMany } as never },
|
|
197
|
+
],
|
|
198
|
+
() => {
|
|
199
|
+
const activity = new FileSplitActivity()
|
|
200
|
+
activity.id = 'activity-1'
|
|
201
|
+
return activity.bootstrap({ binary, workflow: 'child', options: { delimiter: Split.line() } })
|
|
202
|
+
},
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
expect(result).toBeInstanceOf(AsyncActivityResultPending)
|
|
206
|
+
expect(slice).toHaveBeenCalledWith(durableBinary, { start: 0, end: 3 }, 'utf8')
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
For `join`, provide `WorkflowBatchClaim` as an async generator:
|
|
210
|
+
|
|
211
|
+
```typescript
|
|
212
|
+
const outputs = vi.fn(async function* () {
|
|
213
|
+
yield { index: 0, status: 'fulfilled', output: 'line-1' }
|
|
214
|
+
})
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Behaviors worth asserting because they regress easily: the durable copy keeps
|
|
218
|
+
the original bytes; slices are views over the durable copy (not the source);
|
|
219
|
+
`keep` arithmetic on positional cuts; the empty batch resolving immediately;
|
|
220
|
+
CSV escaping (quotes doubled, delimiter/newline wrapped); the status filter.
|
|
221
|
+
|
|
222
|
+
---
|
|
223
|
+
|
|
224
|
+
## Anti-patterns
|
|
225
|
+
|
|
226
|
+
- **Paging batch results into the workflow.** A `readBatch(ref, cursor)` loop
|
|
227
|
+
in workflow code turns every page into a replayed history entry — O(N) is
|
|
228
|
+
back. Aggregation belongs inside `join` (or a framework activity consuming
|
|
229
|
+
`WorkflowBatchClaim`).
|
|
230
|
+
- **Expecting per-child outputs on the ref.** `WorkflowBatchRef` is
|
|
231
|
+
deliberately `{ id, count }` only. Results are read through `join` — e.g.
|
|
232
|
+
`format: 'entries'` for the faithful dump.
|
|
233
|
+
- **Treating a rejected child as a batch failure.** The batch always resolves
|
|
234
|
+
(allSettled). Decide what failures mean: compare against `batch.count`,
|
|
235
|
+
generate the `status: ['rejected']` file, or tolerate.
|
|
236
|
+
- **Baking record format into `join`.** The child returns the final line
|
|
237
|
+
(`lines`) or a projectable object (`csv`); custom formats live in the child
|
|
238
|
+
workflow — sandboxed user code — not in new join options.
|
|
239
|
+
- **Splitting when you need the file whole.** If downstream wants the blob
|
|
240
|
+
intact (hash it, upload it), pass the `Binary` through.
|
|
241
|
+
- **Forgetting `keep` on positional delimiters.** `Split.char('\n10')` without
|
|
242
|
+
`{ next: 2 }` silently discards the record type code from every slice.
|
|
243
|
+
- **Hand-rolling the loop.** A stream-and-`startWorkflow` loop inside an
|
|
244
|
+
activity re-parses on replay, misses the barrier, and pushes N entries into
|
|
245
|
+
history.
|
|
246
|
+
|
|
247
|
+
---
|
|
248
|
+
|
|
249
|
+
## Related skills
|
|
250
|
+
|
|
251
|
+
- `framework-fs` — `FileSystemClaim` contract (including `slice`), `Binary`,
|
|
252
|
+
provider wiring.
|
|
253
|
+
- `framework-core` — `startWorkflowMany` (the array-input sibling of `split`),
|
|
254
|
+
`WorkflowBatchRef`, `WorkflowBatchClaim`, `Claim.run`, activity model,
|
|
255
|
+
testing harness.
|
package/README.md
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# @t4h.framework/fs
|
|
2
2
|
|
|
3
|
-
File system claim for reading and writing files within activities
|
|
3
|
+
File system claim for reading and writing files within activities, and a file
|
|
4
|
+
split activity that fans a large file out into one child workflow per slice.
|
|
4
5
|
|
|
5
6
|
## Claim
|
|
6
7
|
|
|
@@ -16,8 +17,17 @@ import { FileSystemClaim } from '@t4h.framework/fs'
|
|
|
16
17
|
|
|
17
18
|
```typescript
|
|
18
19
|
abstract class FileSystemClaim {
|
|
19
|
-
public abstract write(
|
|
20
|
+
public abstract write(
|
|
21
|
+
path: string,
|
|
22
|
+
readable: Readable | Buffer,
|
|
23
|
+
): Binary | Promise<Binary>
|
|
20
24
|
public abstract read(path: string): Binary | Promise<Binary>
|
|
25
|
+
|
|
26
|
+
public abstract slice(
|
|
27
|
+
binary: Binary,
|
|
28
|
+
range: { start: number; end: number },
|
|
29
|
+
encoding?: BufferEncoding,
|
|
30
|
+
): Binary | Promise<Binary>
|
|
21
31
|
}
|
|
22
32
|
```
|
|
23
33
|
|
|
@@ -27,7 +37,11 @@ abstract class FileSystemClaim {
|
|
|
27
37
|
import { SyncActivity, Claim } from '@t4h.framework/core'
|
|
28
38
|
import { FileSystemClaim } from '@t4h.framework/fs'
|
|
29
39
|
|
|
30
|
-
class SaveReportActivity extends SyncActivity<
|
|
40
|
+
class SaveReportActivity extends SyncActivity<
|
|
41
|
+
[data: Buffer],
|
|
42
|
+
{ path: string },
|
|
43
|
+
any
|
|
44
|
+
> {
|
|
31
45
|
private readonly claims = new Claim({
|
|
32
46
|
fs: FileSystemClaim,
|
|
33
47
|
})
|
|
@@ -54,3 +68,170 @@ The runtime must supply a concrete implementation:
|
|
|
54
68
|
```typescript
|
|
55
69
|
{ provide: FileSystemClaim, value: new LocalFileSystemClaimImpl() }
|
|
56
70
|
```
|
|
71
|
+
|
|
72
|
+
## File split
|
|
73
|
+
|
|
74
|
+
### split
|
|
75
|
+
|
|
76
|
+
Splits a file into byte-range slices, starts one child workflow per slice as
|
|
77
|
+
a single batch, and suspends the parent until every child is terminal —
|
|
78
|
+
regardless of each child succeeding or failing (a rejected child never
|
|
79
|
+
rejects the batch):
|
|
80
|
+
|
|
81
|
+
```typescript
|
|
82
|
+
import { split, Split } from '@t4h.framework/fs'
|
|
83
|
+
|
|
84
|
+
const processRecord = new Workflow(
|
|
85
|
+
{ id: 'process-record', schema: Type.String() },
|
|
86
|
+
async (record: string) => {
|
|
87
|
+
// record is the raw slice content
|
|
88
|
+
},
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
const main = new Workflow(fileSchema, async (file: Binary) => {
|
|
92
|
+
const batch = await split(file, processRecord, {
|
|
93
|
+
delimiter: Split.line(),
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
// this line only runs once the whole batch is terminal;
|
|
97
|
+
// batch: WorkflowBatchRef — { id: string, count: number }
|
|
98
|
+
})
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
The design goal is constant memory regardless of file size:
|
|
102
|
+
|
|
103
|
+
1. The source stream is read **exactly once**, worker side. The same pass
|
|
104
|
+
persists a durable copy through `FileSystemClaim.write` while a byte
|
|
105
|
+
scanner records the slice offsets — no slice content is ever materialized
|
|
106
|
+
in the activity.
|
|
107
|
+
2. Children receive **range views** over the durable copy
|
|
108
|
+
(`FileSystemClaim.slice`).
|
|
109
|
+
3. The activity output is just the batch reference (`{ id, count }`), so the
|
|
110
|
+
parent history stays O(1) whatever the slice count. Per-child results are
|
|
111
|
+
never aggregated: they live with each child run, to be inspected
|
|
112
|
+
individually through the batch id.
|
|
113
|
+
|
|
114
|
+
Child workflows declare their real input type (e.g. `Type.String()`); how the
|
|
115
|
+
bytes reach them is a runtime concern.
|
|
116
|
+
|
|
117
|
+
### Delimiters
|
|
118
|
+
|
|
119
|
+
The cut is defined by the dev through a serializable delimiter spec. When the
|
|
120
|
+
delimiter is found at `[p, p + length)`, the previous slice ends at
|
|
121
|
+
`p + keep.previous` and the next slice starts at `p + length - keep.next`; the
|
|
122
|
+
bytes in between are discarded. The defaults discard the whole delimiter
|
|
123
|
+
(classic line semantics).
|
|
124
|
+
|
|
125
|
+
```typescript
|
|
126
|
+
Split.line() // cut at every '\n', delimiter discarded
|
|
127
|
+
Split.char(';') // cut at any byte sequence
|
|
128
|
+
Split.char(';', { previous: 1 }) // keep the ';' with the previous slice
|
|
129
|
+
Split.char('\n10', { next: 2 }) // positional files, see below
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
The last form targets positional files where a logical record spans multiple
|
|
133
|
+
physical lines and a new record only starts when a line begins with a marker
|
|
134
|
+
(the record type code):
|
|
135
|
+
|
|
136
|
+
```
|
|
137
|
+
10JOHN;123\n20detail-a\n20detail-b\n10MARY;456\n20detail-c
|
|
138
|
+
└──────────── record 1 ───────────┘└─────── record 2 ──────┘
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
With `Split.char('\n10', { next: 2 })` the newline between records is
|
|
142
|
+
discarded, and the `10` marker — which is data, not separator — stays at the
|
|
143
|
+
start of the next slice.
|
|
144
|
+
|
|
145
|
+
### Options
|
|
146
|
+
|
|
147
|
+
```typescript
|
|
148
|
+
await split(file, workflow, {
|
|
149
|
+
delimiter: Split.line(), // required: where to cut
|
|
150
|
+
every: 10_000, // cut only every N occurrences (groups records)
|
|
151
|
+
skipEmpty: false, // keep zero-length slices (default: skipped)
|
|
152
|
+
encoding: 'latin1', // delimiter bytes + slice hydration (default: utf8)
|
|
153
|
+
timeout: 3_600_000, // max ms to await the batch; rejects on expiry
|
|
154
|
+
})
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
- **`every`** groups N records per child. With one million lines, `every:
|
|
158
|
+
10_000` starts 100 children instead of one million; each child receives a
|
|
159
|
+
raw block with its inner delimiters untouched and iterates locally. `keep`
|
|
160
|
+
arithmetic only applies at actual cut points.
|
|
161
|
+
- **`skipEmpty`** (default `true`) drops zero-length slices — consecutive
|
|
162
|
+
delimiters, or a file starting/ending with one — so they never become child
|
|
163
|
+
workflows. Turn it off when the slice index must align 1:1 with the record
|
|
164
|
+
position in the source file. "Empty" means zero bytes: a whitespace-only
|
|
165
|
+
line is a regular slice.
|
|
166
|
+
- **`encoding`** converts the delimiter to bytes and tells the runtime how to
|
|
167
|
+
hydrate each slice for the child. Offsets are always computed over raw
|
|
168
|
+
bytes, so multi-byte encodings never skew the ranges.
|
|
169
|
+
- **`timeout`** is the maximum time in milliseconds to await the batch. On
|
|
170
|
+
expiry the activity rejects (reason `timeout`) instead of suspending
|
|
171
|
+
forever — the safety net for a child that never reaches a terminal state.
|
|
172
|
+
|
|
173
|
+
## File join
|
|
174
|
+
|
|
175
|
+
The inverse of `split`: folds the results of a completed batch into a single
|
|
176
|
+
file. Outputs stream in one at a time through `WorkflowBatchClaim` and flow
|
|
177
|
+
straight into `FileSystemClaim.write` with backpressure, so memory stays
|
|
178
|
+
bounded whatever the batch size — only the resulting binary reaches the
|
|
179
|
+
workflow history.
|
|
180
|
+
|
|
181
|
+
```typescript
|
|
182
|
+
import { join, split, Split } from '@t4h.framework/fs'
|
|
183
|
+
|
|
184
|
+
const batch = await split(file, processRecord, { delimiter: Split.line() })
|
|
185
|
+
|
|
186
|
+
const csv = await join(batch, {
|
|
187
|
+
format: 'csv',
|
|
188
|
+
columns: [
|
|
189
|
+
{ key: 'id', header: 'Código' },
|
|
190
|
+
{ key: 'value', header: 'Valor (R$)' },
|
|
191
|
+
'date', // shorthand: key === header
|
|
192
|
+
],
|
|
193
|
+
})
|
|
194
|
+
// csv: Binary
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
### Formats
|
|
198
|
+
|
|
199
|
+
- **`lines`** — concatenates raw child outputs; the child owns the line
|
|
200
|
+
format. Options: `header`, `separator` (default `'\n'`).
|
|
201
|
+
- **`entries`** — NDJSON dump of `{ index, status, output }`, one entry per
|
|
202
|
+
line. The format-agnostic view of the batch, rejected children included by
|
|
203
|
+
default.
|
|
204
|
+
- **`csv`** — projects object outputs into RFC 4180 escaped columns. Options:
|
|
205
|
+
`columns` (required; each entry is `{ key, header? }` where `key` selects
|
|
206
|
+
the value on the child output and `header` is the label on the header row —
|
|
207
|
+
a plain string is shorthand for `{ key }`), `delimiter` (default `','`),
|
|
208
|
+
`header` (default `true`; emits the column labels as the first row).
|
|
209
|
+
|
|
210
|
+
### Status filter
|
|
211
|
+
|
|
212
|
+
`status` selects which child results make it into the file, instead of an
|
|
213
|
+
error policy: it defaults to `['fulfilled']` for `lines` and `csv`, and to
|
|
214
|
+
both statuses for `entries`. Filtering is silent by design — a file of
|
|
215
|
+
rejected children for reprocessing is one call away:
|
|
216
|
+
|
|
217
|
+
```typescript
|
|
218
|
+
const failures = await join(batch, { format: 'entries', status: ['rejected'] })
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
### ByteDelimiterScanner
|
|
222
|
+
|
|
223
|
+
The scanner behind `split` is exported for direct use. It is a pass-through
|
|
224
|
+
`Transform`: chunks flow through unchanged (so the same pass can persist the
|
|
225
|
+
file) while it records slice offsets in absolute byte positions, handling
|
|
226
|
+
delimiters split across chunk boundaries. `ranges` is complete once the stream
|
|
227
|
+
has flushed.
|
|
228
|
+
|
|
229
|
+
```typescript
|
|
230
|
+
import { ByteDelimiterScanner } from '@t4h.framework/fs'
|
|
231
|
+
|
|
232
|
+
const scanner = new ByteDelimiterScanner({ delimiter: { value: '\n' } })
|
|
233
|
+
|
|
234
|
+
await pipeline(source, scanner, destination)
|
|
235
|
+
|
|
236
|
+
scanner.ranges // [{ start: 0, end: 3 }, ...]
|
|
237
|
+
```
|
|
@@ -3,5 +3,9 @@ import type { Binary } from '@t4h.framework/core';
|
|
|
3
3
|
export declare abstract class FileSystemClaim {
|
|
4
4
|
abstract write(path: string, readable: Readable | Buffer): Binary | Promise<Binary>;
|
|
5
5
|
abstract read(path: string): Binary | Promise<Binary>;
|
|
6
|
+
abstract slice(binary: Binary, range: {
|
|
7
|
+
start: number;
|
|
8
|
+
end: number;
|
|
9
|
+
}, encoding?: BufferEncoding): Binary | Promise<Binary>;
|
|
6
10
|
}
|
|
7
11
|
//# sourceMappingURL=FileSystemClaim.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"FileSystemClaim.d.ts","sourceRoot":"","sources":["../src/FileSystemClaim.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAA;AAE3C,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAA;AAEjD,8BAAsB,eAAe;aACnB,KAAK,CACnB,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE,QAAQ,GAAG,MAAM,GAC1B,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;aAEX,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"FileSystemClaim.d.ts","sourceRoot":"","sources":["../src/FileSystemClaim.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAA;AAE3C,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAA;AAEjD,8BAAsB,eAAe;aACnB,KAAK,CACnB,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE,QAAQ,GAAG,MAAM,GAC1B,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;aAEX,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;aAE5C,KAAK,CACnB,MAAM,EAAE,MAAM,EACd,KAAK,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAE,EACrC,QAAQ,CAAC,EAAE,cAAc,GACxB,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;CAC5B"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"FileSystemClaim.js","sourceRoot":"","sources":["../src/FileSystemClaim.ts"],"names":[],"mappings":"AAIA,MAAM,OAAgB,eAAe;
|
|
1
|
+
{"version":3,"file":"FileSystemClaim.js","sourceRoot":"","sources":["../src/FileSystemClaim.ts"],"names":[],"mappings":"AAIA,MAAM,OAAgB,eAAe;CAapC"}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type Binary, SyncActivity, type WorkflowBatchRef } from '@t4h.framework/core';
|
|
2
|
+
export type FileJoinStatus = 'fulfilled' | 'rejected';
|
|
3
|
+
type FileJoinOptionsBase = {
|
|
4
|
+
status?: FileJoinStatus[];
|
|
5
|
+
};
|
|
6
|
+
type FileJoinOptionsLines = FileJoinOptionsBase & {
|
|
7
|
+
format: 'lines';
|
|
8
|
+
header?: string;
|
|
9
|
+
separator?: string;
|
|
10
|
+
};
|
|
11
|
+
type FileJoinOptionsEntries = FileJoinOptionsBase & {
|
|
12
|
+
format: 'entries';
|
|
13
|
+
};
|
|
14
|
+
export type FileJoinColumn = string | {
|
|
15
|
+
key: string;
|
|
16
|
+
header?: string;
|
|
17
|
+
};
|
|
18
|
+
type FileJoinOptionsCsv = FileJoinOptionsBase & {
|
|
19
|
+
format: 'csv';
|
|
20
|
+
columns: FileJoinColumn[];
|
|
21
|
+
delimiter?: string;
|
|
22
|
+
header?: boolean;
|
|
23
|
+
};
|
|
24
|
+
export type FileJoinOptions = FileJoinOptionsLines | FileJoinOptionsEntries | FileJoinOptionsCsv;
|
|
25
|
+
export type FileJoinActivityInput = {
|
|
26
|
+
batch: string;
|
|
27
|
+
options: FileJoinOptions;
|
|
28
|
+
};
|
|
29
|
+
export declare class FileJoinActivity extends SyncActivity<readonly [batch: WorkflowBatchRef, options: FileJoinOptions], FileJoinActivityInput, Binary, Binary> {
|
|
30
|
+
private readonly claims;
|
|
31
|
+
toInput(batch: WorkflowBatchRef, options: FileJoinOptions): FileJoinActivityInput;
|
|
32
|
+
run({ batch, options }: FileJoinActivityInput): Promise<Binary>;
|
|
33
|
+
toOutput(output: Binary): Binary;
|
|
34
|
+
private writeHeader;
|
|
35
|
+
private toLine;
|
|
36
|
+
}
|
|
37
|
+
export declare function join(batch: WorkflowBatchRef, options: FileJoinOptions): Promise<Binary>;
|
|
38
|
+
export {};
|
|
39
|
+
//# sourceMappingURL=FileJoinActivity.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"FileJoinActivity.d.ts","sourceRoot":"","sources":["../../src/activities/FileJoinActivity.ts"],"names":[],"mappings":"AAGA,OAAO,EACL,KAAK,MAAM,EAGX,YAAY,EAGZ,KAAK,gBAAgB,EACtB,MAAM,qBAAqB,CAAA;AAI5B,MAAM,MAAM,cAAc,GAAG,WAAW,GAAG,UAAU,CAAA;AAErD,KAAK,mBAAmB,GAAG;IACzB,MAAM,CAAC,EAAE,cAAc,EAAE,CAAA;CAC1B,CAAA;AAED,KAAK,oBAAoB,GAAG,mBAAmB,GAAG;IAChD,MAAM,EAAE,OAAO,CAAA;IACf,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB,CAAA;AAED,KAAK,sBAAsB,GAAG,mBAAmB,GAAG;IAClD,MAAM,EAAE,SAAS,CAAA;CAClB,CAAA;AAED,MAAM,MAAM,cAAc,GACtB,MAAM,GACN;IACE,GAAG,EAAE,MAAM,CAAA;IACX,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB,CAAA;AAEL,KAAK,kBAAkB,GAAG,mBAAmB,GAAG;IAC9C,MAAM,EAAE,KAAK,CAAA;IACb,OAAO,EAAE,cAAc,EAAE,CAAA;IACzB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,MAAM,CAAC,EAAE,OAAO,CAAA;CACjB,CAAA;AAED,MAAM,MAAM,eAAe,GACvB,oBAAoB,GACpB,sBAAsB,GACtB,kBAAkB,CAAA;AAEtB,MAAM,MAAM,qBAAqB,GAAG;IAClC,KAAK,EAAE,MAAM,CAAA;IACb,OAAO,EAAE,eAAe,CAAA;CACzB,CAAA;AAgCD,qBAAa,gBAAiB,SAAQ,YAAY,CAChD,SAAS,CAAC,KAAK,EAAE,gBAAgB,EAAE,OAAO,EAAE,eAAe,CAAC,EAC5D,qBAAqB,EACrB,MAAM,EACN,MAAM,CACP;IACC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAGrB;IAEK,OAAO,CACZ,KAAK,EAAE,gBAAgB,EACvB,OAAO,EAAE,eAAe,GACvB,qBAAqB;IAIX,GAAG,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,EAAE,qBAAqB,GAAG,OAAO,CAAC,MAAM,CAAC;IAgCrE,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM;YAIzB,WAAW;IAkBzB,OAAO,CAAC,MAAM;CAsBf;AAED,wBAAsB,IAAI,CACxB,KAAK,EAAE,gBAAgB,EACvB,OAAO,EAAE,eAAe,GACvB,OAAO,CAAC,MAAM,CAAC,CAEjB"}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { once } from 'node:events';
|
|
2
|
+
import { PassThrough } from 'node:stream';
|
|
3
|
+
import { Claim, History, SyncActivity, WorkflowBatchClaim, } from '@t4h.framework/core';
|
|
4
|
+
import { FileSystemClaim } from '../FileSystemClaim.js';
|
|
5
|
+
const extensions = {
|
|
6
|
+
lines: 'txt',
|
|
7
|
+
entries: 'ndjson',
|
|
8
|
+
csv: 'csv',
|
|
9
|
+
};
|
|
10
|
+
function columnKey(column) {
|
|
11
|
+
return typeof column === 'string' ? column : column.key;
|
|
12
|
+
}
|
|
13
|
+
function columnHeader(column) {
|
|
14
|
+
return typeof column === 'string' ? column : (column.header ?? column.key);
|
|
15
|
+
}
|
|
16
|
+
function csvEscape(value, delimiter) {
|
|
17
|
+
if (value === null || typeof value === 'undefined')
|
|
18
|
+
return '';
|
|
19
|
+
const text = typeof value === 'object' ? JSON.stringify(value) : String(value);
|
|
20
|
+
if (text.includes(delimiter) ||
|
|
21
|
+
text.includes('"') ||
|
|
22
|
+
text.includes('\n') ||
|
|
23
|
+
text.includes('\r'))
|
|
24
|
+
return `"${text.replaceAll('"', '""')}"`;
|
|
25
|
+
return text;
|
|
26
|
+
}
|
|
27
|
+
export class FileJoinActivity extends SyncActivity {
|
|
28
|
+
claims = new Claim({
|
|
29
|
+
fs: FileSystemClaim,
|
|
30
|
+
batch: WorkflowBatchClaim,
|
|
31
|
+
});
|
|
32
|
+
toInput(batch, options) {
|
|
33
|
+
return { batch: batch.id, options };
|
|
34
|
+
}
|
|
35
|
+
async run({ batch, options }) {
|
|
36
|
+
const stream = new PassThrough();
|
|
37
|
+
const writing = this.claims.fs.write(`join/${this.id}.${extensions[options.format]}`, stream);
|
|
38
|
+
const write = async (line) => {
|
|
39
|
+
if (!stream.write(line))
|
|
40
|
+
await once(stream, 'drain');
|
|
41
|
+
};
|
|
42
|
+
const statuses = new Set(options.status ??
|
|
43
|
+
(options.format === 'entries'
|
|
44
|
+
? ['fulfilled', 'rejected']
|
|
45
|
+
: ['fulfilled']));
|
|
46
|
+
await this.writeHeader(options, write);
|
|
47
|
+
for await (const entry of this.claims.batch.outputs(batch)) {
|
|
48
|
+
if (!statuses.has(entry.status))
|
|
49
|
+
continue;
|
|
50
|
+
await write(this.toLine(entry, options));
|
|
51
|
+
}
|
|
52
|
+
stream.end();
|
|
53
|
+
return await writing;
|
|
54
|
+
}
|
|
55
|
+
toOutput(output) {
|
|
56
|
+
return output;
|
|
57
|
+
}
|
|
58
|
+
async writeHeader(options, write) {
|
|
59
|
+
if (options.format === 'lines' && options.header)
|
|
60
|
+
await write(options.header + (options.separator ?? '\n'));
|
|
61
|
+
if (options.format === 'csv' && options.header !== false) {
|
|
62
|
+
const delimiter = options.delimiter ?? ',';
|
|
63
|
+
await write(options.columns
|
|
64
|
+
.map(column => csvEscape(columnHeader(column), delimiter))
|
|
65
|
+
.join(delimiter) + '\n');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
toLine(entry, options) {
|
|
69
|
+
if (options.format === 'entries')
|
|
70
|
+
return JSON.stringify(entry) + '\n';
|
|
71
|
+
if (options.format === 'csv') {
|
|
72
|
+
const delimiter = options.delimiter ?? ',';
|
|
73
|
+
const output = (entry.output ?? {});
|
|
74
|
+
return (options.columns
|
|
75
|
+
.map(column => csvEscape(output[columnKey(column)], delimiter))
|
|
76
|
+
.join(delimiter) + '\n');
|
|
77
|
+
}
|
|
78
|
+
const value = typeof entry.output === 'string'
|
|
79
|
+
? entry.output
|
|
80
|
+
: JSON.stringify(entry.output);
|
|
81
|
+
return value + (options.separator ?? '\n');
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
export async function join(batch, options) {
|
|
85
|
+
return await History.reconciler(FileJoinActivity, batch, options);
|
|
86
|
+
}
|
|
87
|
+
//# sourceMappingURL=FileJoinActivity.js.map
|