@yedoma-labs/ysyr-import 0.0.0-stage → 1.0.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/LICENSE +21 -0
- package/README.md +235 -2
- package/dist/index.cjs +527 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +176 -0
- package/dist/index.d.ts +176 -0
- package/dist/index.js +517 -0
- package/dist/index.js.map +1 -0
- package/package.json +86 -5
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 yedoma-labs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,236 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @yedoma-labs/ysyr-import
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Zero-dependency CSV import with partial success. Valid rows are accepted, broken rows are
|
|
4
|
+
collected with their line number, column and reason, and the result can be returned to the user as
|
|
5
|
+
a CSV error report.
|
|
6
|
+
|
|
7
|
+
## The problem
|
|
8
|
+
|
|
9
|
+
Most CSV importers stop at the first broken line, or fail the whole file. A 10,000-row upload fails
|
|
10
|
+
because three rows have a typo in the date column, and the user gets no hint which rows are wrong.
|
|
11
|
+
|
|
12
|
+
`ysyr-import` takes a different approach:
|
|
13
|
+
|
|
14
|
+
- valid rows are returned in `accepted`
|
|
15
|
+
- broken rows are returned in `rejected`, each with `row`, `line`, `column`, `code` and `message`
|
|
16
|
+
- `toErrorReportCsv()` turns the rejected rows into a CSV file the user can fix and re-upload
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
pnpm add @yedoma-labs/ysyr-import
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Requires Node.js 20 or newer. The package has no runtime dependencies.
|
|
25
|
+
|
|
26
|
+
## Quickstart
|
|
27
|
+
|
|
28
|
+
Given this file, `customers.csv`:
|
|
29
|
+
|
|
30
|
+
```csv
|
|
31
|
+
email,name,age,plan
|
|
32
|
+
ann@example.com,Ann,34,pro
|
|
33
|
+
bob@example.com,,41,free
|
|
34
|
+
cy@example.com,Cy,forty,pro
|
|
35
|
+
di@example.com,Di,29
|
|
36
|
+
ed@example.com,"Ed, Jr.",52,free
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
import { readFileSync } from "node:fs";
|
|
41
|
+
import { enumOf, importRows, int, string, toErrorReportCsv } from "@yedoma-labs/ysyr-import";
|
|
42
|
+
|
|
43
|
+
const result = importRows({
|
|
44
|
+
text: readFileSync("customers.csv", "utf8"),
|
|
45
|
+
columns: {
|
|
46
|
+
email: string({ required: true }),
|
|
47
|
+
name: string({ required: true }),
|
|
48
|
+
age: int(),
|
|
49
|
+
plan: enumOf(["free", "pro"], { required: true }),
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
result.accepted;
|
|
54
|
+
// [
|
|
55
|
+
// { email: "ann@example.com", name: "Ann", age: 34, plan: "pro" },
|
|
56
|
+
// { email: "ed@example.com", name: "Ed, Jr.", age: 52, plan: "free" },
|
|
57
|
+
// ]
|
|
58
|
+
|
|
59
|
+
result.rejected;
|
|
60
|
+
// [
|
|
61
|
+
// { row: 2, line: 3, column: "name", code: "REQUIRED", message: "value is required" },
|
|
62
|
+
// { row: 3, line: 4, column: "age", code: "PARSE", message: "expected an integer" },
|
|
63
|
+
// { row: 4, line: 5, code: "WRONG_FIELD_COUNT", message: "expected 4 fields, found 3" },
|
|
64
|
+
// ]
|
|
65
|
+
|
|
66
|
+
result.summary;
|
|
67
|
+
// { total: 5, accepted: 2, rejected: 3 }
|
|
68
|
+
|
|
69
|
+
const report = toErrorReportCsv(result.rejected);
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The report is a CSV file that you can offer for download or show in the UI:
|
|
73
|
+
|
|
74
|
+
```csv
|
|
75
|
+
line,row,column,code,message
|
|
76
|
+
3,2,name,REQUIRED,value is required
|
|
77
|
+
4,3,age,PARSE,expected an integer
|
|
78
|
+
5,4,,WRONG_FIELD_COUNT,"expected 4 fields, found 3"
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`row` counts data rows starting at 1 (the header line is not counted). `line` is the physical line
|
|
82
|
+
in the file where the row starts, starting at 1. Use `line` when the user opens the file in an
|
|
83
|
+
editor, and `row` when you talk about records in your application.
|
|
84
|
+
|
|
85
|
+
## Storing the result
|
|
86
|
+
|
|
87
|
+
This library does not write to any storage. What happens to the accepted rows is up to you. Two
|
|
88
|
+
common patterns:
|
|
89
|
+
|
|
90
|
+
- **Partial commit:** insert `result.accepted` and return `result.rejected` to the user. Simple, and
|
|
91
|
+
the user can re-upload only the fixed rows.
|
|
92
|
+
- **All or nothing:** only insert when `result.rejected.length === 0`, otherwise return the report.
|
|
93
|
+
|
|
94
|
+
For large files, insert the accepted rows in batches inside a transaction, so that a failure in the
|
|
95
|
+
database does not leave half of the file stored.
|
|
96
|
+
|
|
97
|
+
## API
|
|
98
|
+
|
|
99
|
+
### `importRows(options)`
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
importRows({
|
|
103
|
+
text: string, // CSV text (or `records`, see below)
|
|
104
|
+
columns: Schema, // column name -> rule
|
|
105
|
+
header?: true | string[], // default true: first line is the header
|
|
106
|
+
delimiter?: string, // default ","
|
|
107
|
+
autoDetectDelimiter?: boolean, // try ",", ";" and tab from the first line
|
|
108
|
+
unknownColumns?: "ignore" | "reject", // default "ignore"
|
|
109
|
+
validate?: (row) => string | undefined, // row-level check
|
|
110
|
+
onRow?: (outcome) => void, // called per row, in order
|
|
111
|
+
maxBytes?: number, // default 10 MiB
|
|
112
|
+
maxRows?: number, // default 100000
|
|
113
|
+
maxFieldLength?: number, // default 10000
|
|
114
|
+
});
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Returns `{ accepted, rejected, summary }`.
|
|
118
|
+
|
|
119
|
+
- `accepted`: typed row objects, one per valid row
|
|
120
|
+
- `rejected`: `RowError[]`, one entry per problem. A row with two problems produces two entries.
|
|
121
|
+
- `summary`: `total` (data rows), `accepted` (rows) and `rejected` (distinct rows)
|
|
122
|
+
|
|
123
|
+
Throws `ImportError` for whole-file problems, see below.
|
|
124
|
+
|
|
125
|
+
### Columns
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
columns: {
|
|
129
|
+
id: int({ required: true }),
|
|
130
|
+
name: string({ required: true }),
|
|
131
|
+
active: boolean(),
|
|
132
|
+
joined: date(),
|
|
133
|
+
plan: enumOf(["free", "pro"]),
|
|
134
|
+
score: number(),
|
|
135
|
+
custom: { parse: (raw) => raw.toUpperCase() },
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
| Helper | Value type | Accepts |
|
|
140
|
+
| --- | --- | --- |
|
|
141
|
+
| `string()` | `string` | any text, trimmed |
|
|
142
|
+
| `int()` | `number` | whole numbers in the safe integer range, e.g. `-42` |
|
|
143
|
+
| `number()` | `number` | decimals with `.`, e.g. `1.5`, `-2e3` |
|
|
144
|
+
| `boolean()` | `boolean` | `true`, `false`, `1`, `0` (case-insensitive) |
|
|
145
|
+
| `date()` | `Date` | ISO dates `YYYY-MM-DD`, UTC midnight, real calendar dates only |
|
|
146
|
+
| `enumOf([...])` | union of the values | exactly one of the values (case-sensitive) |
|
|
147
|
+
|
|
148
|
+
Any object with a `parse(raw: string)` function works as a column rule. A thrown error becomes a
|
|
149
|
+
`PARSE` row error. Helpers trim surrounding whitespace before parsing, custom `parse` functions get
|
|
150
|
+
the raw cell.
|
|
151
|
+
|
|
152
|
+
`enumOf` is named that way because `enum` is a reserved word in JavaScript.
|
|
153
|
+
|
|
154
|
+
Empty cells: a column with `required: true` reports `REQUIRED` for an empty cell. An optional
|
|
155
|
+
column yields `undefined` for an empty cell.
|
|
156
|
+
|
|
157
|
+
### Error codes
|
|
158
|
+
|
|
159
|
+
| Code | Meaning | `column` |
|
|
160
|
+
| --- | --- | --- |
|
|
161
|
+
| `REQUIRED` | required cell is empty | yes |
|
|
162
|
+
| `PARSE` | a rule's `parse` threw; the message is the thrown error's message | yes |
|
|
163
|
+
| `VALIDATION` | `validate(row)` returned a message, or threw | no |
|
|
164
|
+
| `UNKNOWN_COLUMN` | non-empty value in a column not in the schema (only with `unknownColumns: "reject"`) | yes |
|
|
165
|
+
| `WRONG_FIELD_COUNT` | the row has a different number of fields than the header | no |
|
|
166
|
+
| `PARSE_QUOTE` | broken quoting: unterminated quote at end of input, or characters after a closing quote | no |
|
|
167
|
+
|
|
168
|
+
Messages are at most 100 characters and never contain control characters or a dump of the raw
|
|
169
|
+
input.
|
|
170
|
+
|
|
171
|
+
### Whole-file errors
|
|
172
|
+
|
|
173
|
+
These abort the import by throwing `ImportError` with a `code`:
|
|
174
|
+
|
|
175
|
+
- `MISSING_COLUMN`: a required column is not in the header. `error.columns` lists the names.
|
|
176
|
+
- `LIMIT_EXCEEDED`: input or rows exceed a limit. `error.limit` and `error.max` say which one.
|
|
177
|
+
- `INVALID_HEADER`: the header line has broken quoting, so the columns cannot be determined.
|
|
178
|
+
|
|
179
|
+
### Limits
|
|
180
|
+
|
|
181
|
+
| Option | Default | Applies to |
|
|
182
|
+
| --- | --- | --- |
|
|
183
|
+
| `maxBytes` | 10 MiB (UTF-8) | the whole input text |
|
|
184
|
+
| `maxRows` | 100,000 | data rows |
|
|
185
|
+
| `maxFieldLength` | 10,000 | a single field, in UTF-16 code units |
|
|
186
|
+
|
|
187
|
+
Exceeding a limit throws `LIMIT_EXCEEDED`. Input is never truncated silently. A value equal to
|
|
188
|
+
the limit is accepted.
|
|
189
|
+
|
|
190
|
+
### `parseCsvRecords(text, options?)`
|
|
191
|
+
|
|
192
|
+
Parses the text without a schema. Returns `{ row, line, values }` per data record, with
|
|
193
|
+
`values` as raw strings. Accepts `delimiter`, `autoDetectDelimiter`, `header` and the limits.
|
|
194
|
+
A record with broken quoting carries `quoteError`.
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
const records = parseCsvRecords(text, { header: ["id", "name"] });
|
|
198
|
+
const result = importRows({ records, header: ["id", "name"], columns });
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
When passing `records` to `importRows`, you must also pass `header` with the column names, because
|
|
202
|
+
the records do not carry them.
|
|
203
|
+
|
|
204
|
+
### `toErrorReportCsv(rejected)`
|
|
205
|
+
|
|
206
|
+
Returns a CSV string with the header `line,row,column,code,message` and CRLF line endings.
|
|
207
|
+
|
|
208
|
+
CSV injection protection: text cells that start with `=`, `+`, `-`, `@`, tab or CR are prefixed
|
|
209
|
+
with an apostrophe (`'`), so spreadsheet applications do not evaluate them as formulas. Cells with
|
|
210
|
+
commas, quotes or line breaks are quoted per RFC 4180.
|
|
211
|
+
|
|
212
|
+
## CSV parsing rules
|
|
213
|
+
|
|
214
|
+
- RFC 4180 quoting: fields may be quoted with `"`, a doubled `""` is a quote character, quoted
|
|
215
|
+
fields may contain delimiters and line breaks.
|
|
216
|
+
- Line endings: `\n`, `\r\n` and `\r`.
|
|
217
|
+
- A leading UTF-8 byte order mark is removed.
|
|
218
|
+
- Blank lines are skipped and do not count as rows.
|
|
219
|
+
- A trailing newline at the end of the file does not create a row.
|
|
220
|
+
- Header names are trimmed. Values are not trimmed by the parser (helpers trim them).
|
|
221
|
+
- Quoting errors are row errors, not aborts. An unterminated quote consumes the rest of the file,
|
|
222
|
+
as RFC 4180 implies, so it is reported as one `PARSE_QUOTE` error for the record where it starts.
|
|
223
|
+
|
|
224
|
+
## Development
|
|
225
|
+
|
|
226
|
+
```sh
|
|
227
|
+
pnpm install
|
|
228
|
+
pnpm lint
|
|
229
|
+
pnpm typecheck
|
|
230
|
+
pnpm test
|
|
231
|
+
pnpm build
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
## License
|
|
235
|
+
|
|
236
|
+
MIT
|