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 +213 -2
- package/dist/index.d.mts +974 -0
- package/dist/index.d.ts +974 -0
- package/dist/index.js +1754 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +1712 -0
- package/dist/index.mjs.map +1 -0
- package/dist/node/index.d.mts +726 -0
- package/dist/node/index.d.ts +726 -0
- package/dist/node/index.js +1143 -0
- package/dist/node/index.js.map +1 -0
- package/dist/node/index.mjs +1110 -0
- package/dist/node/index.mjs.map +1 -0
- package/package.json +75 -4
package/README.md
CHANGED
|
@@ -1,3 +1,214 @@
|
|
|
1
|
-
#
|
|
1
|
+
# sheet-to-schema
|
|
2
2
|
|
|
3
|
-
|
|
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
|