sheet-to-schema 1.0.0 → 1.1.0-rc.1

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/README.md CHANGED
@@ -11,8 +11,10 @@ one record per row, or per group of rows, reporting every cell it could not use.
11
11
  with memory bounded by a batch, and serves the import over HTTP.
12
12
  - **The same engine on both sides.** A file imports to the same records in the browser and
13
13
  on the server, because both run the same function over the same plan.
14
+ - **The same reader on both sides.** `sheet-to-schema/browser` reads a CSV, TSV or `.xlsx`
15
+ file in a browser into exactly the rows the server reads from the same bytes.
14
16
 
15
- > **Status: stable (1.0.0).** It is the import engine of
17
+ > **Status: stable (1.0.0); 1.1.0-rc.1 on `next`.** It is the import engine of
16
18
  > [Dynamic Entity](https://github.com/berserker5619/Dynamic-Entity#readme) 3.0, extracted so it can be used without it.
17
19
  > Members marked `@internal` are not covered by semver and may change in any release.
18
20
 
@@ -20,8 +22,8 @@ one record per row, or per group of rows, reporting every cell it could not use.
20
22
 
21
23
  ```bash
22
24
  npm install sheet-to-schema
23
- # Only for sheet-to-schema/node, and only the ones you use:
24
- npm install exceljs # to read or write .xlsx
25
+ # Only for sheet-to-schema/node and /browser, and only the ones you use:
26
+ npm install exceljs # to read or write .xlsx (on a server or in a browser)
25
27
  npm install busboy # to receive uploads
26
28
  npm install express # for the ready-made router
27
29
  ```
@@ -135,8 +137,18 @@ result.warnings;
135
137
  ```
136
138
 
137
139
  Rows of a group that disagree on a parent field keep the first row's value and raise a
138
- **warning**: the record is still imported. Grouping works in `applyMapping`. The streaming
139
- runner in `/node` does not group yet, and refuses a grouped plan instead of ignoring it.
140
+ **warning**: the record is still imported. Grouping works in `applyMapping` and in the
141
+ streaming runner in `/node`, which gives the same records: a group that straddles a batch is
142
+ carried into the next one and written whole. The runner groups contiguous rows only (sort the
143
+ sheet by the key columns); `contiguous: false` works in `applyMapping` and is refused by the
144
+ runner, since it would hold every group to the end of the file.
145
+
146
+ Grouping is bounded: a group of more than 10,000 rows, more than 200,000 groups, or (for
147
+ contiguous groups) more than 8 MiB of group keys stops the import with an `ImportFailure`
148
+ (`GROUP_TOO_LARGE` or `TOO_MANY_GROUPS`), as does a key that comes back after its rows ended
149
+ (`GROUP_NOT_CONTIGUOUS`). Raise a limit with
150
+ `applyMapping(rows, plan, adapter, ctx, { groupLimits: { maxGroupRows: 50_000 } })`, or with the
151
+ server's `limits`. Over HTTP these answer 413 and 422.
140
152
 
141
153
  ## Dates are never guessed
142
154
 
@@ -160,6 +172,42 @@ For anything else, implement `SchemaAdapter`: `id`, `adapterVersion`, and
160
172
  its own cells (`coerce`), finish a record (`finalize`) and check one (`validate`). That is how
161
173
  Dynamic Entity's form configs run on this engine.
162
174
 
175
+ ## In a browser: `sheet-to-schema/browser`
176
+
177
+ ```ts
178
+ import { applyMapping, decimalMarkFor } from 'sheet-to-schema';
179
+ import { readSheetFile } from 'sheet-to-schema/browser';
180
+
181
+ // `file` from an <input type="file">: a CSV, a TSV or an .xlsx, decided by its bytes.
182
+ const sheet = await readSheetFile(file, { filename: file.name });
183
+ const result = applyMapping(sheet.rows, plan, adapter, undefined, { decimal: decimalMarkFor(sheet.delimiter) });
184
+ ```
185
+
186
+ `readSheetFile` returns the grid the server's `readSheet` reads from the same file: the same
187
+ headers, the same rows with the same typed cells (a date cell is a `Date`, a formula is its
188
+ cached result), the same separator, and the same refusals, with the server's error codes and
189
+ messages. Pass `sheet.delimiter` to `decimalMarkFor`, as the server does, so a semicolon file
190
+ reads `1,5` as 1.5 on both sides. `readXlsx` is the same for a caller that already knows the
191
+ file is a workbook.
192
+
193
+ - **A CSV needs nothing installed.** `exceljs` is loaded with `import()` the first time a
194
+ workbook is read, so a CSV-only app never downloads it. Without it, a workbook fails with
195
+ what to install.
196
+ - **The limits are the server's**, with its defaults: `maxBytes`, `maxRows`, `maxColumns`,
197
+ `maxCellLength` and `maxZipEntries`, through `limits`.
198
+ - **A workbook's zip is checked by what it declares** (entry count, sizes, compression ratio)
199
+ before ExcelJS sees it. The server also checks what each entry actually inflates to; the
200
+ browser does not, because there the file is the user's own and only their tab is at risk.
201
+ - **The first sheet, header row 1**, as on the server.
202
+
203
+ Known limitations of the browser path:
204
+
205
+ - **It needs `DecompressionStream`** (every current browser, and Node 20) to read a workbook;
206
+ without it, a workbook fails with a message saying so. A CSV needs nothing.
207
+ - **A broken archive may be described differently.** Both readers refuse it, but the server
208
+ reads entries front to back and the browser reads the zip's directory, so the message for a
209
+ damaged `.xlsx` can differ. Every well-formed workbook in the parity fixtures reads the same.
210
+
163
211
  ## On a server: `sheet-to-schema/node`
164
212
 
165
213
  ```ts
@@ -203,13 +251,18 @@ createImportRouter(express.Router(), {
203
251
  Four things to know before deploying it:
204
252
 
205
253
  1. **An import is not transactional.** A file that fails at row 30,000 has written 29,999
206
- records. Run `/validate` first, and make your writer idempotent.
254
+ records. Run `/validate` first, and make your writer idempotent. A grouped import can stop
255
+ on a group limit or a key that comes back, after earlier groups were written; `/validate`
256
+ runs the same pipeline and finds that first.
207
257
  2. **Authentication, authorization and concurrency are yours.** The router checks none.
208
- 3. **Every limit is on by default**: bytes, rows, columns, cell size, zip expansion, idle and
209
- total time. See `DEFAULT_LIMITS`; pass `limits` to change one.
210
- 4. **A client must check `planVersions`.** `planForServer(plan, preview.planVersions)` returns
211
- the plan to send to that server, or the reason not to: a server from before plan v2 would
212
- ignore `group` and import one record per row.
258
+ 3. **Every limit is on by default**: bytes, rows, columns, cell size, zip expansion, group
259
+ size and count, idle and total time. See `DEFAULT_LIMITS`; pass `limits` to change one.
260
+ 4. **A client must check what the server reads.** `planForServer(plan, preview)` returns the
261
+ plan to send to that server, or the reason not to: a server from before plan v2 would ignore
262
+ `group` and import one record per row, and a 1.0 server reads plan v2 but refuses `group`.
263
+ A preview lists `planVersions` and, from 1.1, `features` (`["group"]`); a missing `features`
264
+ means the server cannot group. The 1.0 form, `planForServer(plan, preview.planVersions)`,
265
+ still works and checks plan versions only.
213
266
 
214
267
  ## Problem codes
215
268
 
@@ -221,6 +274,15 @@ Four things to know before deploying it:
221
274
 
222
275
  `PLAN_PROBLEM_CODES`, `CELL_PROBLEM_CODES` and `RECORD_PROBLEM_CODES` list them all.
223
276
 
277
+ A grouped import can also stop as a whole, with an `ImportFailure` (`IMPORT_FAILURE_CODES`).
278
+ `applyMapping` and `runImport` throw it; over HTTP it is the error body's `code`:
279
+
280
+ | Code | HTTP | Raised when |
281
+ | --- | --- | --- |
282
+ | `GROUP_NOT_CONTIGUOUS` | 422 | A group's key comes back after its rows ended. `details` has the key and both rows. |
283
+ | `GROUP_TOO_LARGE` | 413 | A group has more than `maxGroupRows` rows. |
284
+ | `TOO_MANY_GROUPS` | 413 | More than `maxGroups` groups, or more than `maxGroupKeyBytes` of group keys. |
285
+
224
286
  ## Licence
225
287
 
226
288
  MIT
@@ -0,0 +1,41 @@
1
+ import { SheetGrid, CsvDelimiter } from 'sheet-to-schema';
2
+ import { I as ImportLimits } from '../import-error-Co6lRAqv.mjs';
3
+ export { a as ImportError, b as ImportErrorCode } from '../import-error-Co6lRAqv.mjs';
4
+
5
+ /** The limits a browser read enforces. Each one left out is the server's default. */
6
+ type BrowserLimits = Pick<ImportLimits, 'maxBytes' | 'maxRows' | 'maxColumns' | 'maxCellLength' | 'maxZipEntries'>;
7
+ interface ReadSheetFileOptions {
8
+ /**
9
+ * The file's name. A delimiter hint only, as on the server: once the bytes have said the file
10
+ * is text, a name ending in `.tsv` means tab. It never decides the format.
11
+ */
12
+ filename?: string;
13
+ limits?: Partial<BrowserLimits>;
14
+ }
15
+ /** What `readSheetFile` reads: the grid, what the bytes turned out to be, and a CSV's separator. */
16
+ interface SheetFile extends SheetGrid {
17
+ format: 'csv' | 'xlsx';
18
+ /** The separator a CSV turned out to use; pass it to `decimalMarkFor`. Absent for a workbook. */
19
+ delimiter?: CsvDelimiter;
20
+ }
21
+ /** What a file can arrive as: a `File` or other `Blob`, or its bytes. */
22
+ type SheetFileInput = Blob | ArrayBuffer | Uint8Array;
23
+ /**
24
+ * Read a CSV, TSV or `.xlsx` file into exactly the grid the server's `readSheet` reads from the
25
+ * same bytes: the same headers, the same rows with the same typed cells, the same separator,
26
+ * and the same refusals with the same codes and messages.
27
+ *
28
+ * @throws ImportError `TOO_LARGE`, `SHEET_TOO_LARGE`, `UNSUPPORTED_FORMAT`, `MALFORMED_FILE`,
29
+ * `ARCHIVE_REFUSED`, as the server would. A workbook read without `exceljs` installed throws a
30
+ * plain `Error` saying what to install.
31
+ */
32
+ declare function readSheetFile(input: SheetFileInput, options?: ReadSheetFileOptions): Promise<SheetFile>;
33
+ /**
34
+ * Read an `.xlsx` file by the server's rules, for a caller that already knows the format.
35
+ *
36
+ * @throws ImportError `UNSUPPORTED_FORMAT` for anything that is not a zip, and whatever
37
+ * `readSheetFile` throws for a workbook.
38
+ */
39
+ declare function readXlsx(input: SheetFileInput, options?: Omit<ReadSheetFileOptions, 'filename'>): Promise<SheetGrid>;
40
+
41
+ export { type BrowserLimits, type ReadSheetFileOptions, type SheetFile, type SheetFileInput, readSheetFile, readXlsx };
@@ -0,0 +1,41 @@
1
+ import { SheetGrid, CsvDelimiter } from 'sheet-to-schema';
2
+ import { I as ImportLimits } from '../import-error-Co6lRAqv.js';
3
+ export { a as ImportError, b as ImportErrorCode } from '../import-error-Co6lRAqv.js';
4
+
5
+ /** The limits a browser read enforces. Each one left out is the server's default. */
6
+ type BrowserLimits = Pick<ImportLimits, 'maxBytes' | 'maxRows' | 'maxColumns' | 'maxCellLength' | 'maxZipEntries'>;
7
+ interface ReadSheetFileOptions {
8
+ /**
9
+ * The file's name. A delimiter hint only, as on the server: once the bytes have said the file
10
+ * is text, a name ending in `.tsv` means tab. It never decides the format.
11
+ */
12
+ filename?: string;
13
+ limits?: Partial<BrowserLimits>;
14
+ }
15
+ /** What `readSheetFile` reads: the grid, what the bytes turned out to be, and a CSV's separator. */
16
+ interface SheetFile extends SheetGrid {
17
+ format: 'csv' | 'xlsx';
18
+ /** The separator a CSV turned out to use; pass it to `decimalMarkFor`. Absent for a workbook. */
19
+ delimiter?: CsvDelimiter;
20
+ }
21
+ /** What a file can arrive as: a `File` or other `Blob`, or its bytes. */
22
+ type SheetFileInput = Blob | ArrayBuffer | Uint8Array;
23
+ /**
24
+ * Read a CSV, TSV or `.xlsx` file into exactly the grid the server's `readSheet` reads from the
25
+ * same bytes: the same headers, the same rows with the same typed cells, the same separator,
26
+ * and the same refusals with the same codes and messages.
27
+ *
28
+ * @throws ImportError `TOO_LARGE`, `SHEET_TOO_LARGE`, `UNSUPPORTED_FORMAT`, `MALFORMED_FILE`,
29
+ * `ARCHIVE_REFUSED`, as the server would. A workbook read without `exceljs` installed throws a
30
+ * plain `Error` saying what to install.
31
+ */
32
+ declare function readSheetFile(input: SheetFileInput, options?: ReadSheetFileOptions): Promise<SheetFile>;
33
+ /**
34
+ * Read an `.xlsx` file by the server's rules, for a caller that already knows the format.
35
+ *
36
+ * @throws ImportError `UNSUPPORTED_FORMAT` for anything that is not a zip, and whatever
37
+ * `readSheetFile` throws for a workbook.
38
+ */
39
+ declare function readXlsx(input: SheetFileInput, options?: Omit<ReadSheetFileOptions, 'filename'>): Promise<SheetGrid>;
40
+
41
+ export { type BrowserLimits, type ReadSheetFileOptions, type SheetFile, type SheetFileInput, readSheetFile, readXlsx };