@t4h.framework/zip 0.2.10 → 0.2.11

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.
@@ -1,67 +1,43 @@
1
1
  ---
2
2
  name: framework-zip
3
3
  description: >-
4
- Guides correct use of @t4h.framework/zip in workflows: opening a ZIP archive
5
- that arrives from SFTP, HTTP or storage, listing what is inside it without
6
- downloading it, and extracting the entries that matter into `Binary` files
7
- that `read`, `split` and `sftp.put` accept. Use whenever a workflow receives
8
- a `.zip` (a compressed batch file, a bundle of invoices, a partner's return
9
- package), whenever the user mentions unzipping, decompressing, inflate,
10
- deflate, zip entries, or the symbols `unzip`, `list`, `ZipEntry`,
11
- `ZipExtractActivity`, and above all when someone is about to hand-roll a
12
- lazy `Binary` that inflates on `stream()` in App code. Prefer this skill
13
- over `node:zlib` in workflow code and over any hand-written ZIP parsing.
4
+ Guides the use of @t4h.framework/zip to read a ZIP archive in a T4H
5
+ Framework workflow: list() to read the entries without their content, and
6
+ unzip() with include/exclude globs to extract entries into Binary files that
7
+ read, split and sftp.put accept (ZipEntry, ZipEntryInfo, ZipExtractActivity,
8
+ ZipListActivity). Use when a workflow receives a .zip (a compressed batch
9
+ file, a bundle of invoices, a partner return package), when someone asks to
10
+ unzip, decompress or inflate, and when someone is about to use node:zlib or
11
+ write a lazy Binary that inflates on stream() in workflow code.
14
12
  ---
15
13
 
16
14
  # @t4h.framework/zip
17
15
 
18
- Reading ZIP archives inside a workflow. Package path: `framework/packages/zip`.
19
- Peer dependencies: `@t4h.framework/core`, `@t4h.framework/fs`.
16
+ ## Extract the entry you need
20
17
 
21
- ## Import surface
22
-
23
- ```typescript
24
- import { list, unzip } from '@t4h.framework/zip'
25
- ```
26
-
27
- Two durable activities, nothing else to wire:
28
-
29
- - `unzip(archive, options?)` — extracts entries into files, resolves with
30
- `ZipEntry[]` (`{ path, binary, contentLength, compressedLength,
31
- compressionMethod, crc32, lastModifiedAt, directory }`), directories
32
- excluded, in central directory order.
33
- - `list(archive, options?)` — the same metadata **without** `binary` and
34
- without transferring content, directories included.
35
-
36
- `archive` is any `Binary`: `sftp.get`, an HTTP response body, `write`, a slice.
37
-
38
- ## Workflow usage
39
-
40
- The canonical pipeline — fetch a compressed batch, extract the one file that
41
- carries the records, fan it out:
18
+ 1. Get the archive as a `Binary` (`sftp.get`, an HTTP body, `write`).
19
+ 2. Call `unzip` once, high in the workflow, with an `include` glob.
20
+ 3. Check the result: no match gives an empty array, not an error.
21
+ 4. Pass `entry.binary` down to `read`, `split` or `sftp.put`.
42
22
 
43
23
  ```typescript
44
24
  import { split, Split } from '@t4h.framework/fs'
45
25
  import { unzip } from '@t4h.framework/zip'
46
26
 
47
- const main = new Workflow({ id: 'main', schema: inputSchema }, async input => {
48
- const archive = await sftp.get(input.path)
27
+ const archive = await sftp.get(input.path)
49
28
 
50
- const [file] = await unzip(archive, { include: 'MOVIMENTO.TXT' })
29
+ const [file] = await unzip(archive, { include: 'MOVIMENTO.TXT' })
51
30
 
52
- if (!file) throw new Error(`${input.path} has no movement file`)
31
+ if (!file) throw new Error(`${input.path} has no movement file`)
53
32
 
54
- return await split(file.binary, processRecord, {
55
- delimiter: Split.line(),
56
- })
57
- })
33
+ return await split(file.binary, processRecord, { delimiter: Split.line() })
58
34
  ```
59
35
 
60
- Deciding from metadata first, when most runs do not need the content:
36
+ When the decision needs only names, sizes or dates, call `list` first. It does
37
+ not extract content:
61
38
 
62
39
  ```typescript
63
40
  const entries = await list(archive)
64
-
65
41
  const invoice = entries.find(entry => entry.path.endsWith(`/${id}.txt`))
66
42
 
67
43
  if (!invoice) return { status: 'missing' }
@@ -69,80 +45,39 @@ if (!invoice) return { status: 'missing' }
69
45
  const [extracted] = await unzip(archive, { include: invoice.path })
70
46
  ```
71
47
 
72
- ## Options
73
-
74
- | Option | Applies to | Default | Behavior |
75
- | ------------- | --------------- | ------- | ---------------------------------------------------------------------- |
76
- | `include` | `unzip`, `list` | — | Glob or array of globs an entry must match. |
77
- | `exclude` | `unzip`, `list` | — | Globs that drop entries, applied after `include`. |
78
- | `encoding` | `unzip`, `list` | `utf8` | Decoding of entry names not flagged UTF-8 (legacy archives: `latin1`). |
79
- | `contentType` | `unzip` | — | Content type of the extracted files; omitted, the claim infers it. |
80
-
81
- Patterns match the whole entry path, case sensitively: `*` and `?` never cross
82
- a `/`, `**` does (`'**.csv'` reaches any depth, `'invoices/*'` one level). An
83
- `include` matching nothing resolves with an empty array — always check.
84
-
85
- ## Behaviors you can rely on
86
-
87
- - **Content never enters the workflow.** Only file references do; the bytes go
88
- from the archive to storage, inflated in a stream with backpressure.
89
- - **You pay for what you extract.** A ZIP's index sits at the end of the file,
90
- so the listing is a ranged read of the tail and each entry is fetched by
91
- offset — pulling one file out of a 6 MB archive does not download 6 MB. A
92
- binary the claim cannot slice (SFTP, HTTP) is streamed **once** into storage
93
- first, then read by range.
94
- - **Corruption fails the activity.** CRC-32 and size are verified as the entry
95
- flows to storage.
96
- - **Format coverage.** Stored and deflate, ZIP64, data descriptors, archive
97
- comments, UTF-8 and legacy names, empty entries.
98
- - **What `unzip` refuses.** It throws a `TypeError` for an encrypted entry and
99
- for a compression method other than stored or deflate (bzip2, LZMA, zstd).
100
- It also throws for a hostile path (`../`, absolute, drive qualified), before
101
- it writes any entry.
102
-
103
- ## Limits
104
-
105
- - **No writing.** The package reads archives; it does not create them. A file
106
- to send compressed has to be produced another way.
107
- - **Extraction is one activity per call.** The result is recorded whole, so a
108
- `unzip` with no `include` over a 3 000-entry archive puts 3 000 references in
109
- the history and 3 000 files in storage. Filter.
110
- - **What is bounded and what is not.** Entry content, the reads over the
111
- archive and the history are constant whatever the archive weighs. Memory
112
- grows with the _number_ of entries — the central directory is read whole,
113
- 50 to 100 bytes per entry — not with their size. A zip bomb costs storage,
114
- not memory: the read stops at the declared compressed size and the inflate is
115
- aborted the moment the output goes past the declared uncompressed size, so a
116
- lying entry never writes more than it declares — one that honestly declares
117
- 40 GB still writes 40 GB, and `list` shows `contentLength` beforehand. A
118
- non-sliceable source adds one durable copy of the archive, and whether that
119
- copy streams is the `FileSystemClaim`'s contract, as it is for `split`.
120
-
121
- ## Runtime requirements
122
-
123
- - Claim provider: `FileSystemClaim` (from `@t4h.framework/fs`).
124
- - Production allowlist: register `ZipExtractActivity` and `ZipListActivity`
125
- via `runtime.addActivity`.
126
-
127
- ## Anti-patterns
128
-
129
- - `@t4h.framework/core/no-binary-subclass` rejects a lazy `Binary` that
130
- inflates on `stream()` (`class ZipEntryBinary extends Binary`). `unzip` does
131
- the same work inside an activity.
132
- - `@t4h.framework/core/no-io-in-workflow` rejects `node:zlib` calls, such as
133
- `createInflateRaw`, in a workflow. Extraction belongs to an activity.
134
- - **Calling `unzip` per record.** Extract once, high in the workflow, and pass
135
- the entry `Binary` down to children. `unzip` inside a loop or inside a helper
136
- every child calls turns one extraction into thousands of activities.
137
- - **Extracting to read metadata.** If the run only needs a name, a size or a
138
- date, `list` answers without transferring content.
139
- - **Trusting `include` to find something.** It is a filter: no match is an
140
- empty array, not an error.
141
-
142
- ## Related skills
143
-
144
- - `framework-fs-split` — `split`/`join`/`read`: what to do with the entry once
145
- it is a `Binary`.
146
- - `framework-sftp` — `sftp.get` is where most archives come from.
147
- - `framework-transforms` — JSON inside the archive: normalize to NDJSON before
148
- fanning out.
48
+ ## Traps
49
+
50
+ - Globs match the whole entry path, case sensitive. `*` and `?` do not cross a
51
+ `/`; `**` does. `'*.csv'` matches only the root; use `'**.csv'` for any
52
+ depth.
53
+ - `unzip` skips directory entries; `list` returns them with
54
+ `directory: true`.
55
+ - `unzip` without `include` extracts every entry: one file in storage and one
56
+ reference in the history for each. Filter.
57
+ - Do not call `unzip` in a loop or in a helper that each child workflow runs.
58
+ Extract once and pass the `Binary` down.
59
+ - `lastModifiedAt` is a string in local time with no time zone
60
+ (`'2024-03-07T14:25:38'`). `contentLength` is the uncompressed size in
61
+ bytes.
62
+ - Only stored and deflate entries. An encrypted entry, another compression
63
+ method or a path with `..`, `/` or a drive letter throws a `TypeError`. A
64
+ bad or corrupted archive throws a `SyntaxError`. The package cannot create a
65
+ ZIP.
66
+ - `@t4h.framework/core/no-io-in-workflow` rejects `node:zlib` in a workflow,
67
+ and `@t4h.framework/core/no-binary-subclass` rejects a `Binary` subclass. Do
68
+ not inflate by hand; use `unzip`. See the `framework-fs` skill for `Binary`.
69
+ - The runtime must provide `FileSystemClaim` and allow `ZipExtractActivity`
70
+ and `ZipListActivity`.
71
+
72
+ ## Test
73
+
74
+ `unzip` and `list` run in the test. Commit a real `.zip` fixture and mock
75
+ `FileSystemClaim` with `write` (keep the bytes) and `slice` (return a window).
76
+ Make `slice` throw for a binary the mock did not write to test the fallback
77
+ copy. The cookbook recipe below has a complete test.
78
+
79
+ ## Reference
80
+
81
+ - Guide: https://tech4humans-brasil.github.io/framework/zip/
82
+ - Recipe with a test: https://tech4humans-brasil.github.io/framework/cookbook/zip-batch
83
+ - Types: `node_modules/@t4h.framework/zip/dist/zip.d.ts`
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @t4h.framework/zip
2
2
 
3
+ ## 0.2.11
4
+
5
+ ### Patch Changes
6
+
7
+ - [#257](https://github.com/tech4humans-brasil/framework/pull/257) [`586bfb4`](https://github.com/tech4humans-brasil/framework/commit/586bfb4663f4a478527ee0c8db360603f034d615) Thanks [@gusteycamargo](https://github.com/gusteycamargo)! - The zip README is now short and links to the site. The docs page now has a testing section and all the errors, and it no longer names the internals of sftp, the dev server or the runtime.
8
+
3
9
  ## 0.2.10
4
10
 
5
11
  ### Patch Changes
package/README.md CHANGED
@@ -1,208 +1,34 @@
1
1
  # @t4h.framework/zip
2
2
 
3
- Reads ZIP archives inside a workflow. `list` tells you what is in an archive
4
- without transferring its content, and `unzip` unpacks the entries you ask for
5
- into files — each one a `Binary`, the same reference every other package
6
- consumes.
3
+ Reads ZIP archives in a T4H Framework workflow. `list` tells what an archive
4
+ holds without its content, and `unzip` extracts the entries you select into
5
+ files. Each entry is a `Binary`.
7
6
 
8
- Both are durable activities: the work happens once, the result is recorded in
9
- the workflow history, and a replay never re-reads the archive.
10
-
11
- ## Installation
7
+ ## Install
12
8
 
13
9
  ```bash
14
10
  npm install @t4h.framework/zip
15
- # or
16
- yarn add @t4h.framework/zip
17
- ```
18
-
19
- Peer dependencies: `@t4h.framework/core` and `@t4h.framework/fs` — entries are
20
- written through the `FileSystemClaim`, so a file system provider must be
21
- registered in the runtime.
22
-
23
- ## Basic usage
24
-
25
- ```typescript
26
- import { unzip } from '@t4h.framework/zip'
27
-
28
- const [entry] = await unzip(archive)
29
-
30
- // entry.binary is a regular Binary: read a window, split it, upload it
31
- const header = await read(entry.binary, { range: { start: 0, end: 240 } })
32
- ```
33
-
34
- `archive` is any `Binary`: an `sftp.get`, an HTTP response body, a file written
35
- by `write`. `unzip` resolves with one `ZipEntry` per file in the archive, in
36
- central directory order, directories excluded.
37
-
38
- ## Extract only what you need
39
-
40
- An archive with three thousand invoices in it does not have to become three
41
- thousand files:
42
-
43
- ```typescript
44
- const [invoice] = await unzip(archive, { include: 'invoices/0001.txt' })
45
-
46
- const csvs = await unzip(archive, {
47
- include: '**.csv',
48
- exclude: '**/draft-*.csv',
49
- })
50
11
  ```
51
12
 
52
- `include` and `exclude` are glob patterns (or arrays of them) matched against
53
- the whole entry path:
54
-
55
- | Pattern | Matches |
56
- | -------------- | --------------------------------------- |
57
- | `notes.txt` | exactly that path |
58
- | `*.txt` | `.txt` files at the root of the archive |
59
- | `invoices/*` | anything directly inside `invoices/` |
60
- | `**.csv` | `.csv` files at any depth |
61
- | `report-?.txt` | `report-1.txt`, not `report-42.txt` |
13
+ Peer dependencies: `@t4h.framework/core` and `@t4h.framework/fs`. The runtime
14
+ must provide the `FileSystemClaim`.
62
15
 
63
- Matching is case sensitive, `include` runs before `exclude`, and an `include`
64
- that matches nothing resolves with an empty array rather than throwing.
65
-
66
- ## Listing without unpacking
16
+ ## Usage
67
17
 
68
18
  ```typescript
69
- import { list } from '@t4h.framework/zip'
19
+ import { read } from '@t4h.framework/fs'
20
+ import { list, unzip } from '@t4h.framework/zip'
70
21
 
71
22
  const entries = await list(archive)
72
- // [{ path, contentLength, compressedLength, compressionMethod,
73
- // crc32, lastModifiedAt, directory }, ...]
74
- ```
75
-
76
- `list` reads the archive's index (its central directory), which lives in the
77
- last few kilobytes of the file — so on a sliceable binary a listing costs about
78
- a kilobyte, whatever the archive weighs. Use it whenever the decision (does
79
- this archive contain the file I need? how big is it?) can be made from metadata
80
- alone, and unzip afterwards only if the content is really needed.
81
-
82
- Unlike `unzip`, `list` reports directory entries too, flagged with
83
- `directory: true`.
84
-
85
- ## How the bytes move
86
23
 
87
- The package never holds an archive, or an entry, in memory.
24
+ const [entry] = await unzip(archive, { include: 'invoices/0001.txt' })
88
25
 
89
- 1. **Ranged reads.** A ZIP is read back to front — the central directory sits
90
- at the end of the file and points backwards at each entry — so every read
91
- goes through `FileSystemClaim.slice`: the tail for the index, one local
92
- header per entry, then the entry's compressed range.
93
- 2. **Streaming inflate.** That range is streamed through `inflateRaw` and
94
- straight into `FileSystemClaim.write`, with backpressure all the way.
95
- Extracting a thousand entries costs the same memory as extracting one.
96
- 3. **One entry at a time.** Entries are extracted sequentially, on purpose:
97
- parallel extraction would multiply the memory and the storage pressure for
98
- no gain on a single source stream.
99
- 4. **Verified content.** Every entry is checked against the CRC-32 and the size
100
- the archive declares while it flows to storage, so a truncated download or a
101
- corrupted archive fails the activity instead of quietly producing a shorter
102
- file.
103
-
104
- ### Binaries that cannot be sliced
105
-
106
- Ranged reads need a binary the file system claim owns. An `sftp.get` reference
107
- or an HTTP body is not one: for those, the archive is streamed **once** into
108
- storage (`zip/<activity-id>.zip`) and the ranges are read from that copy. It
109
- costs one pass and one durable copy of the archive, and everything downstream
110
- behaves identically.
111
-
112
- Nothing has to be configured for this; it is the fallback, not a mode.
113
-
114
- ## Memory: what scales with what
115
-
116
- Bounded, whatever the archive weighs: entry content (source range →
117
- `inflateRaw` → CRC-32 → `FileSystemClaim.write`, one entry at a time), the
118
- reads over the archive (a ranged read of the tail plus one 30-byte local header
119
- per entry) and the workflow history (references only).
120
-
121
- What scales, and with what:
122
-
123
- - **The central directory is read whole**, so memory grows with the _number_ of
124
- entries — 50 to 100 bytes each. Three thousand entries is 200 KB; a million
125
- is around 100 MB.
126
- - **`unzip` without `include` extracts everything**: one file in storage and
127
- one reference in the history per entry.
128
- - **A zip bomb costs storage, not memory.** The read stops at the declared
129
- compressed size, and the inflate is aborted the moment the output goes past
130
- the declared uncompressed size — an entry that lies never writes more than it
131
- declares. One that honestly declares 40 GB still writes 40 GB, so check
132
- `contentLength` with `list` beforehand.
133
- - **A non-sliceable source** is streamed once into a durable copy of the
134
- archive; whether that copy streams is the `FileSystemClaim`'s contract.
135
- - **Downstream is yours.** `buffer(await entry.binary.stream())` pulls the file
136
- into workflow memory; `read` is capped at 1 MiB and `split` streams.
137
-
138
- ## Never build a lazy `Binary` by hand
139
-
140
- The temptation, when a workflow needs one entry out of a zip, is to write a
141
- `Binary` subclass that inflates on `stream()` and pass it around. It works in
142
- the dev server and breaks in production:
143
-
144
- - A `Binary` handed to a primitive becomes the **input of a durable activity**,
145
- and that input is serialized to storage before the activity runs. A class
146
- holding a closure over another stream has nothing to serialize into.
147
- - After a replay the activity may run in **another process**, where the object
148
- no longer exists.
149
- - `contentLength` has to lie (the uncompressed size is only in the central
150
- directory), and every consumer that trusts it — a ranged `read`, a
151
- `Content-Length` header — is then wrong.
152
-
153
- `unzip` is the supported answer: it does the same work inside an activity, and
154
- what travels is a file reference the runtime knows how to rehydrate.
155
-
156
- ## Cost model
157
-
158
- `unzip` and `list` are activities, so each call is one history entry with the
159
- work behind it done exactly once. The reference to watch is **how many times
160
- you call them**, not how big the archive is:
161
-
162
- ```typescript
163
- // once per run, however many records follow
164
- const [entry] = await unzip(archive, { include: 'MOVIMENTO.TXT' })
165
-
166
- const batch = await split(entry.binary, processRecord, {
167
- delimiter: Split.line(),
168
- })
26
+ const header = await read(entry.binary, { range: { start: 0, end: 240 } })
169
27
  ```
170
28
 
171
- Calling `unzip` once per record instead — inside a loop, or inside a helper
172
- called by every child — turns one extraction into thousands of activities, each
173
- with its own history entry and its own copy of the entry in storage. Extract
174
- high, pass the `Binary` down.
175
-
176
- ## Limits
177
-
178
- - **Deflate and stored only** (methods `0` and `8`) — the two an archiver
179
- produces without being asked. Anything else (bzip2, LZMA, zstd) is refused
180
- with a `TypeError` naming the method.
181
- - **No encryption.** An encrypted entry is refused; the ZIP crypto formats are
182
- either broken (ZipCrypto) or vendor-specific (AES extensions).
183
- - **No writing.** This package reads archives; it does not create them.
184
- - **Reading, not extracting to disk.** Entries land wherever the
185
- `FileSystemClaim` puts them, under `zip/<activity-id>/<entry path>`.
186
- - **Hostile paths are refused.** An entry whose path is absolute, drive
187
- qualified or contains `..` fails the whole activity before anything is
188
- written (the "zip slip" class of archive).
189
-
190
- ZIP64 archives, entries written with a data descriptor (the streaming writers'
191
- flavour, where the local header has no sizes), UTF-8 and legacy entry names,
192
- archive comments and empty entries are all handled.
193
-
194
- ## Errors
195
-
196
- | Situation | Error |
197
- | ----------------------------------------------- | ------------- |
198
- | Not a ZIP, or truncated beyond repair | `SyntaxError` |
199
- | CRC-32 or size mismatch on an entry | `SyntaxError` |
200
- | Encrypted entry, unsupported compression method | `TypeError` |
201
- | Entry path escaping the extraction directory | `TypeError` |
202
- | Multi-disk archive | `TypeError` |
29
+ `archive` is any `Binary`, for example from `sftp.get`, an HTTP response body
30
+ or `write`.
203
31
 
204
- ## Runtime requirements
32
+ ## Documentation
205
33
 
206
- - Claim provider: `FileSystemClaim` (from `@t4h.framework/fs`).
207
- - Production allowlist: register `ZipExtractActivity` and `ZipListActivity`
208
- through `runtime.addActivity`.
34
+ https://tech4humans-brasil.github.io/framework/zip/
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@t4h.framework/zip",
3
- "version": "0.2.10",
3
+ "version": "0.2.11",
4
4
  "description": "ZIP extraction module for the T4H Framework",
5
5
  "homepage": "https://github.com/tech4humans-brasil/framework/tree/main/packages/zip",
6
6
  "bugs": "https://github.com/tech4humans-brasil/framework/issues",
@@ -33,8 +33,8 @@
33
33
  "test:watch": "vitest"
34
34
  },
35
35
  "devDependencies": {
36
- "@t4h.framework/core": "^0.20.0",
37
- "@t4h.framework/fs": "^0.13.1",
36
+ "@t4h.framework/core": "^0.21.0",
37
+ "@t4h.framework/fs": "^0.14.0",
38
38
  "typescript": "^5.9.3",
39
39
  "vitest": "^4.0.18"
40
40
  },