sheet-to-schema 0.0.0-stage → 1.0.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
@@ -1,3 +1,214 @@
1
- # Temporary Holding Version
1
+ # sheet-to-schema
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
+ Turn the rows of a spreadsheet into records that fit a schema you already have.
4
+
5
+ You describe where records go (a JSON Schema, an example record, or your own adapter). The
6
+ library suggests which column feeds which field, lets a person correct that, and then builds
7
+ one record per row, or per group of rows, reporting every cell it could not use.
8
+
9
+ - **Browser-safe core, no dependencies.** The main entry runs in a browser tab or in Node.
10
+ - **A streaming server half.** `sheet-to-schema/node` reads CSV and `.xlsx` files of any size
11
+ with memory bounded by a batch, and serves the import over HTTP.
12
+ - **The same engine on both sides.** A file imports to the same records in the browser and
13
+ on the server, because both run the same function over the same plan.
14
+
15
+ > **Status: release candidate (1.0.0-rc.2).** It is the import engine of
16
+ > [Dynamic Entity](https://github.com/berserker5619/Dynamic-Entity#readme) 3.0, extracted so it can be used without it.
17
+ > Members marked `@internal` are not covered by semver and may change in any release.
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ 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
+ npm install busboy # to receive uploads
26
+ npm install express # for the ready-made router
27
+ ```
28
+
29
+ Node 18 or later for the main entry; the three packages above are optional peers, loaded the
30
+ first time they are needed. Reading a CSV needs none of them.
31
+
32
+ ## Quick start
33
+
34
+ ```ts
35
+ import { applyMapping, jsonSchemaAdapter, parseCsv, suggestMapping } from 'sheet-to-schema';
36
+
37
+ const adapter = jsonSchemaAdapter({
38
+ $id: 'order',
39
+ type: 'object',
40
+ required: ['orderId'],
41
+ properties: {
42
+ orderId: { type: 'string', title: 'Order' },
43
+ placed: { type: 'string', format: 'date', title: 'Placed' },
44
+ tags: { type: 'array', title: 'Tags', items: { type: 'string' } },
45
+ items: {
46
+ type: 'array',
47
+ title: 'Item',
48
+ items: {
49
+ type: 'object',
50
+ properties: { sku: { type: 'string', title: 'SKU' }, qty: { type: 'integer', title: 'Qty' } },
51
+ },
52
+ },
53
+ },
54
+ });
55
+
56
+ const sheet = parseCsv(
57
+ 'Order,Placed,Item 1 SKU,Item 1 Qty,Item 2 SKU,Item 2 Qty\n' +
58
+ 'A-1,2026-09-05,PEN,2,INK,1\n' +
59
+ 'A-2,2026-09-06,PAD,x,,\n',
60
+ );
61
+
62
+ // 1. A suggestion, for a person to check. Anything inferred is tagged `confidence: 'guess'`.
63
+ const plan = suggestMapping(sheet.headers, adapter);
64
+
65
+ // 2. Rows in, records out. The fourth argument is whatever context your adapter wants.
66
+ const result = applyMapping(sheet.rows, plan, adapter, undefined);
67
+
68
+ result.records;
69
+ // [{ orderId: 'A-1', placed: '2026-09-05', items: [{ sku: 'PEN', qty: 2 }, { sku: 'INK', qty: 1 }] }]
70
+ result.errors;
71
+ // [{ row: 3, ref: 'items.0.qty', column: 3, code: 'CELL_FORMAT', message: '"x" is not a number', raw: 'x' }]
72
+ ```
73
+
74
+ A row that fails is collected, not thrown, and its record is left out. `row` is the number
75
+ the user sees in their spreadsheet: the header is row 1.
76
+
77
+ ## The mapping plan
78
+
79
+ A plan is plain JSON, so it can be stored, sent to a server, and applied again next month.
80
+
81
+ ```ts
82
+ interface MappingPlan {
83
+ planVersion: 2;
84
+ target: string; // the adapter's id
85
+ schemaVersion?: number; // the adapter's version when the plan was written
86
+ sourceHeaders?: string[];
87
+ entries: {
88
+ ref: string; // where the value goes: 'placed', 'items.0.sku'
89
+ column?: number; // which sheet column, from 0 …
90
+ constant?: unknown; // … or one value for every row
91
+ split?: string; // a separator, for a list written in one cell
92
+ dateOrder?: 'DMY' | 'MDY' | 'YMD';
93
+ }[];
94
+ lists?: Record<string, { compact?: boolean }>;
95
+ group?: { key: number[]; collect: string[]; contiguous?: boolean };
96
+ }
97
+ ```
98
+
99
+ `readPlan` reads a plan from anywhere, including the older v1 shape (`entity`,
100
+ `configVersion`), and returns it normalised with every problem it found. Problems carry a
101
+ stable `code`; compare that, never the message.
102
+
103
+ ## Three ways a sheet holds a list
104
+
105
+ **Numbered columns.** `Item 1 SKU`, `Item 2 SKU`: one slot per pair of columns, as in the
106
+ quick start. Refs carry the slot: `items.0.sku`, `items.1.sku`. Empty slots are dropped and
107
+ the rest renumbered, unless `lists: { items: { compact: false } }`.
108
+
109
+ **One cell, split.** `rush; gift` in a `Tags` column becomes `['rush', 'gift']`. The default
110
+ separator is `;`. An entry's `split` states another.
111
+
112
+ **One row per item, grouped.** The order repeats on every row and each row carries one item:
113
+
114
+ ```ts
115
+ const sheet = parseCsv('Order,Placed,SKU,Qty\nA-1,05/09/2026,PEN,2\nA-1,06/09/2026,INK,1\nA-2,06/09/2026,PAD,4\n');
116
+
117
+ const plan = {
118
+ planVersion: 2,
119
+ target: 'order',
120
+ entries: [
121
+ { ref: 'orderId', column: 0 },
122
+ { ref: 'placed', column: 1, dateOrder: 'DMY' },
123
+ { ref: 'items.sku', column: 2 }, // no slot: one item per row
124
+ { ref: 'items.qty', column: 3 },
125
+ ],
126
+ group: { key: [0], collect: ['items'] },
127
+ };
128
+
129
+ const result = applyMapping(sheet.rows, plan, adapter, undefined);
130
+ result.records;
131
+ // [{ orderId: 'A-1', placed: '2026-09-05', items: [{ sku: 'PEN', qty: 2 }, { sku: 'INK', qty: 1 }] },
132
+ // { orderId: 'A-2', placed: '2026-09-06', items: [{ sku: 'PAD', qty: 4 }] }]
133
+ result.warnings;
134
+ // [{ row: 2, rows: [2, 3], ref: 'placed', code: 'RECORD_GROUP_CONFLICT', message: 'Rows disagree on placed; kept row 2.', raw: '2026-09-06' }]
135
+ ```
136
+
137
+ 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
+
141
+ ## Dates are never guessed
142
+
143
+ `2026-09-05` is read as written. `05/09/2026` could be 5 September or 9 May, so it is an
144
+ error unless something states the order: an entry's `dateOrder`, or the run's
145
+ (`applyMapping(rows, plan, adapter, ctx, { dateOrder: 'DMY' })`). The entry wins. Pass a
146
+ sample of rows to `suggestMapping(headers, adapter, { sample })` and it states the order for a
147
+ column whose values settle it (a `31/08/2026` can only be day first).
148
+
149
+ ## Adapters
150
+
151
+ An adapter is the one thing that knows your schema. Two ship with the package:
152
+
153
+ | Adapter | Use it when |
154
+ | --- | --- |
155
+ | `jsonSchemaAdapter(schema, options?)` | You have a JSON Schema. Types, formats, `enum`, `required`, nested objects, and arrays of objects or of scalars. |
156
+ | `exampleJsonAdapter(sample, options?)` | You have one example record and no schema. Types are inferred from its values. |
157
+
158
+ For anything else, implement `SchemaAdapter`: `id`, `adapterVersion`, and
159
+ `targets({ slots, lang })`, which lists every place a value can go. Optional hooks let it read
160
+ its own cells (`coerce`), finish a record (`finalize`) and check one (`validate`). That is how
161
+ Dynamic Entity's form configs run on this engine.
162
+
163
+ ## On a server: `sheet-to-schema/node`
164
+
165
+ ```ts
166
+ import express from 'express';
167
+ import { createImportRouter } from 'sheet-to-schema/node';
168
+
169
+ const app = express();
170
+ app.use(
171
+ '/import',
172
+ createImportRouter(express.Router(), {
173
+ bindings: { order: { adapter, ctx: undefined, kind: 'json-schema' } },
174
+ dateOrder: 'DMY',
175
+ // Called once per batch, and awaited. It must be idempotent: a client will retry.
176
+ onImport: async (records, { name }) => saveAll(name, records),
177
+ }),
178
+ );
179
+ ```
180
+
181
+ | Route | What it does |
182
+ | --- | --- |
183
+ | `GET /:name/template?format=csv\|xlsx` | A blank sheet with the right headers |
184
+ | `POST /:name/preview` | Headers, a sample, a suggested plan, and which plan versions this server reads |
185
+ | `POST /:name/validate` | The whole import with nothing written: every error, before anything is stored |
186
+ | `POST /:name/import` | The import. Multipart: the `plan` field first, then the `file` |
187
+
188
+ Without Express, call `runImport`, `previewSheet`, `writeTemplate` and `receiveUpload`
189
+ yourself; the router is a few lines over them.
190
+
191
+ Four things to know before deploying it:
192
+
193
+ 1. **An import is not transactional.** A file that fails at row 30,000 has written 29,999
194
+ records. Run `/validate` first, and make your writer idempotent.
195
+ 2. **Authentication, authorization and concurrency are yours.** The router checks none.
196
+ 3. **Every limit is on by default**: bytes, rows, columns, cell size, zip expansion, idle and
197
+ total time. See `DEFAULT_LIMITS`; pass `limits` to change one.
198
+ 4. **A client must check `planVersions`.** `planForServer(plan, preview.planVersions)` returns
199
+ the plan to send to that server, or the reason not to: a server from before plan v2 would
200
+ ignore `group` and import one record per row.
201
+
202
+ ## Problem codes
203
+
204
+ | Prefix | Raised when | Examples |
205
+ | --- | --- | --- |
206
+ | `PLAN_` | The plan itself is wrong. An error means no row is read. | `PLAN_UNKNOWN_REF`, `PLAN_VERSION`, `PLAN_GROUP` |
207
+ | `CELL_` | One cell could not be read. | `CELL_FORMAT`, `CELL_UNKNOWN_OPTION` |
208
+ | `RECORD_` | The record built from a row is not valid, or needs a warning. | `RECORD_REQUIRED`, `RECORD_GROUP_CONFLICT` |
209
+
210
+ `PLAN_PROBLEM_CODES`, `CELL_PROBLEM_CODES` and `RECORD_PROBLEM_CODES` list them all.
211
+
212
+ ## Licence
213
+
214
+ MIT