@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 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
- # Temporary Holding Version
1
+ # @yedoma-labs/ysyr-import
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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