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

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.2 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,20 @@ 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`. On the server it
141
+ is opt-in in 1.1: `runImport` and `createImportRouter` group when given `grouping: true`, and
142
+ give the same records as `applyMapping` (a group that straddles a batch is carried into the next
143
+ one and written whole). Without it a grouped plan is refused, as in 1.0. The runner groups
144
+ contiguous rows only (sort the sheet by the key columns); `contiguous: false` works in
145
+ `applyMapping` and is refused by the runner, since it would hold every group to the end of the
146
+ file.
147
+
148
+ Grouping is bounded: a group of more than 10,000 rows, more than 200,000 groups, or (for
149
+ contiguous groups) more than 8 MiB of group keys stops the import with an `ImportFailure`
150
+ (`GROUP_TOO_LARGE` or `TOO_MANY_GROUPS`), as does a key that comes back after its rows ended
151
+ (`GROUP_NOT_CONTIGUOUS`). Raise a limit with
152
+ `applyMapping(rows, plan, adapter, ctx, { groupLimits: { maxGroupRows: 50_000 } })`, or with the
153
+ server's `limits`. Over HTTP these answer 413 and 422.
140
154
 
141
155
  ## Dates are never guessed
142
156
 
@@ -160,6 +174,42 @@ For anything else, implement `SchemaAdapter`: `id`, `adapterVersion`, and
160
174
  its own cells (`coerce`), finish a record (`finalize`) and check one (`validate`). That is how
161
175
  Dynamic Entity's form configs run on this engine.
162
176
 
177
+ ## In a browser: `sheet-to-schema/browser`
178
+
179
+ ```ts
180
+ import { applyMapping, decimalMarkFor } from 'sheet-to-schema';
181
+ import { readSheetFile } from 'sheet-to-schema/browser';
182
+
183
+ // `file` from an <input type="file">: a CSV, a TSV or an .xlsx, decided by its bytes.
184
+ const sheet = await readSheetFile(file, { filename: file.name });
185
+ const result = applyMapping(sheet.rows, plan, adapter, undefined, { decimal: decimalMarkFor(sheet.delimiter) });
186
+ ```
187
+
188
+ `readSheetFile` returns the grid the server's `readSheet` reads from the same file: the same
189
+ headers, the same rows with the same typed cells (a date cell is a `Date`, a formula is its
190
+ cached result), the same separator, and the same refusals, with the server's error codes and
191
+ messages. Pass `sheet.delimiter` to `decimalMarkFor`, as the server does, so a semicolon file
192
+ reads `1,5` as 1.5 on both sides. `readXlsx` is the same for a caller that already knows the
193
+ file is a workbook.
194
+
195
+ - **A CSV needs nothing installed.** `exceljs` is loaded with `import()` the first time a
196
+ workbook is read, so a CSV-only app never downloads it. Without it, a workbook fails with
197
+ what to install.
198
+ - **The limits are the server's**, with its defaults: `maxBytes`, `maxRows`, `maxColumns`,
199
+ `maxCellLength` and `maxZipEntries`, through `limits`.
200
+ - **A workbook's zip is checked by what it declares** (entry count, sizes, compression ratio)
201
+ before ExcelJS sees it. The server also checks what each entry actually inflates to; the
202
+ browser does not, because there the file is the user's own and only their tab is at risk.
203
+ - **The first sheet, header row 1**, as on the server.
204
+
205
+ Known limitations of the browser path:
206
+
207
+ - **It needs `DecompressionStream`** (every current browser, and Node 20) to read a workbook;
208
+ without it, a workbook fails with a message saying so. A CSV needs nothing.
209
+ - **A broken archive may be described differently.** Both readers refuse it, but the server
210
+ reads entries front to back and the browser reads the zip's directory, so the message for a
211
+ damaged `.xlsx` can differ. Every well-formed workbook in the parity fixtures reads the same.
212
+
163
213
  ## On a server: `sheet-to-schema/node`
164
214
 
165
215
  ```ts
@@ -172,6 +222,8 @@ app.use(
172
222
  createImportRouter(express.Router(), {
173
223
  bindings: { order: { adapter, ctx: undefined, kind: 'json-schema' } },
174
224
  dateOrder: 'DMY',
225
+ // Opt-in in 1.1: import plans that group rows into one record (previews then say "group").
226
+ grouping: true,
175
227
  // Called once per batch, and awaited. It must be idempotent: a client will retry.
176
228
  onImport: async (records, { name }) => saveAll(name, records),
177
229
  }),
@@ -203,13 +255,18 @@ createImportRouter(express.Router(), {
203
255
  Four things to know before deploying it:
204
256
 
205
257
  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.
258
+ records. Run `/validate` first, and make your writer idempotent. A grouped import can stop
259
+ on a group limit or a key that comes back, after earlier groups were written; `/validate`
260
+ runs the same pipeline and finds that first.
207
261
  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.
262
+ 3. **Every limit is on by default**: bytes, rows, columns, cell size, zip expansion, group
263
+ size and count, idle and total time. See `DEFAULT_LIMITS`; pass `limits` to change one.
264
+ 4. **A client must check what the server reads.** `planForServer(plan, preview)` returns the
265
+ plan to send to that server, or the reason not to: a server from before plan v2 would ignore
266
+ `group` and import one record per row, and a 1.0 server reads plan v2 but refuses `group`.
267
+ A preview lists `planVersions` and, from 1.1, `features` (`["group"]` on a router created
268
+ with `grouping: true`); a missing `features` means the server will not group. The 1.0 form, `planForServer(plan, preview.planVersions)`,
269
+ still works and checks plan versions only.
213
270
 
214
271
  ## Problem codes
215
272
 
@@ -221,6 +278,15 @@ Four things to know before deploying it:
221
278
 
222
279
  `PLAN_PROBLEM_CODES`, `CELL_PROBLEM_CODES` and `RECORD_PROBLEM_CODES` list them all.
223
280
 
281
+ A grouped import can also stop as a whole, with an `ImportFailure` (`IMPORT_FAILURE_CODES`).
282
+ `applyMapping` and `runImport` throw it; over HTTP it is the error body's `code`:
283
+
284
+ | Code | HTTP | Raised when |
285
+ | --- | --- | --- |
286
+ | `GROUP_NOT_CONTIGUOUS` | 422 | A group's key comes back after its rows ended. `details` has the key and both rows. |
287
+ | `GROUP_TOO_LARGE` | 413 | A group has more than `maxGroupRows` rows. |
288
+ | `TOO_MANY_GROUPS` | 413 | More than `maxGroups` groups, or more than `maxGroupKeyBytes` of group keys. |
289
+
224
290
  ## Licence
225
291
 
226
292
  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 };