@dynamic-entity/server 2.2.0 → 2.3.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/README.md +12 -1
- package/express.d.mts +1 -1
- package/express.d.ts +1 -1
- package/express.js +48 -21
- package/express.js.map +1 -1
- package/express.mjs +49 -22
- package/express.mjs.map +1 -1
- package/index.d.mts +4 -22
- package/index.d.ts +4 -22
- package/index.js +51 -23
- package/index.js.map +1 -1
- package/index.mjs +49 -23
- package/index.mjs.map +1 -1
- package/package.json +2 -2
- package/{write-template-C2dCEp48.d.mts → write-template-B6KIIBRw.d.mts} +12 -1
- package/{write-template-C2dCEp48.d.ts → write-template-B6KIIBRw.d.ts} +12 -1
package/README.md
CHANGED
|
@@ -59,7 +59,7 @@ app.use(
|
|
|
59
59
|
| Route | Does |
|
|
60
60
|
|---|---|
|
|
61
61
|
| `GET /:entity/template?fields=&format=` | Streams a CSV or xlsx template. No `fields` means every column |
|
|
62
|
-
| `POST /:entity/preview` | multipart → headers, a sample, a suggested mapping, a row count |
|
|
62
|
+
| `POST /:entity/preview` | multipart → headers, a sample, a suggested mapping, a row count, the `arrayBound` the suggestion was sized for, and a CSV's `delimiter` |
|
|
63
63
|
| `POST /:entity/validate` | The identical import pipeline with **no** writing — every error, nothing stored. Answers `written: false` |
|
|
64
64
|
| `POST /:entity/import` | multipart + a `plan` field → runs the import, calling `onImport` per batch. Answers `written: true` |
|
|
65
65
|
|
|
@@ -205,6 +205,12 @@ createImportRouter({ ...options, limits: { maxBytes: 50 * 1024 * 1024, batchSize
|
|
|
205
205
|
| `maxFields` / `maxFieldBytes` / `maxFieldNameBytes` / `maxFiles` | 16 / 1 MB / 200 / 1 | Multipart abuse |
|
|
206
206
|
| `idleTimeoutMs` / `totalTimeoutMs` | 30 s / 10 min | Stalled and never-ending uploads |
|
|
207
207
|
|
|
208
|
+
A limit refuses with a code, never a stack: `maxBytes` → `TOO_LARGE` (413); the sheet bounds →
|
|
209
|
+
`SHEET_TOO_LARGE` (413); the three archive bounds → `ARCHIVE_REFUSED` (400), raised while the
|
|
210
|
+
entry inflates, before anything reaches exceljs; the timeouts → `TIMEOUT` (408). A zip that is
|
|
211
|
+
not a workbook at all — a `.docx`, say — is `UNSUPPORTED_FORMAT` (415), as are `.xls` and
|
|
212
|
+
UTF-16 text.
|
|
213
|
+
|
|
208
214
|
`maxCellLength` is doing double duty: `validators.pattern` is a config-supplied regex that runs
|
|
209
215
|
against cell text, and on a server that text is attacker-chosen. Bounding the input bounds a
|
|
210
216
|
catastrophic backtrack. See [SECURITY.md](../../SECURITY.md).
|
|
@@ -263,6 +269,11 @@ Decided by the file's **first bytes**, never by its name — an attacker who wan
|
|
|
263
269
|
will name their zip `.csv`.
|
|
264
270
|
|
|
265
271
|
- **CSV** — through core's dependency-free incremental parser. UTF-8, with or without a BOM.
|
|
272
|
+
The separator — `,`, `;` or tab — is detected from the header line, exactly as the browser
|
|
273
|
+
detects it, and returned as `delimiter` from a preview. A `;` file is read with a **decimal
|
|
274
|
+
comma** (`1,5` is one and a half), because Excel writes `;` in precisely the locales that
|
|
275
|
+
use one; pass `decimal` to `runImport` to override. Generated CSV templates stay
|
|
276
|
+
comma-separated, so Excel in those locales opens them as one column — offer `xlsx` there.
|
|
266
277
|
- **`.xlsx`** — through `exceljs`. Cells keep their types: a date cell arrives as a `Date` at
|
|
267
278
|
UTC midnight and is read as the calendar date it is, in every timezone.
|
|
268
279
|
- A pre-2007 `.xls` and a UTF-16 file are **named** rather than parsed into nonsense.
|
package/express.d.mts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { EntityFormConfig, FormRule, ImportLookups } from '@dynamic-entity/core';
|
|
2
2
|
import { Request, Router, Response } from 'express';
|
|
3
|
-
import { B as BatchInfo, I as ImportLimits, T as TemplateFormat } from './write-template-
|
|
3
|
+
import { B as BatchInfo, I as ImportLimits, T as TemplateFormat } from './write-template-B6KIIBRw.mjs';
|
|
4
4
|
import 'node:stream';
|
|
5
5
|
|
|
6
6
|
/**
|
package/express.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { EntityFormConfig, FormRule, ImportLookups } from '@dynamic-entity/core';
|
|
2
2
|
import { Request, Router, Response } from 'express';
|
|
3
|
-
import { B as BatchInfo, I as ImportLimits, T as TemplateFormat } from './write-template-
|
|
3
|
+
import { B as BatchInfo, I as ImportLimits, T as TemplateFormat } from './write-template-B6KIIBRw.js';
|
|
4
4
|
import 'node:stream';
|
|
5
5
|
|
|
6
6
|
/**
|
package/express.js
CHANGED
|
@@ -244,17 +244,6 @@ function readPlanField(fields) {
|
|
|
244
244
|
throw new ImportError("INVALID_PLAN", "The mapping plan is not valid JSON.", { cause });
|
|
245
245
|
}
|
|
246
246
|
}
|
|
247
|
-
|
|
248
|
-
// src/cell-text.ts
|
|
249
|
-
function sampleText(raw) {
|
|
250
|
-
if (raw === null || raw === void 0) return "";
|
|
251
|
-
if (raw instanceof Date) {
|
|
252
|
-
if (Number.isNaN(raw.getTime())) return "";
|
|
253
|
-
const iso = raw.toISOString();
|
|
254
|
-
return raw.getTime() % 864e5 === 0 ? iso.slice(0, 10) : iso;
|
|
255
|
-
}
|
|
256
|
-
return typeof raw === "string" ? raw : String(raw);
|
|
257
|
-
}
|
|
258
247
|
var LOCAL_HEADER = 67324752;
|
|
259
248
|
var CENTRAL_HEADER = 33639248;
|
|
260
249
|
var END_OF_CENTRAL = 101010256;
|
|
@@ -571,6 +560,12 @@ async function guardZip(source, limits) {
|
|
|
571
560
|
}
|
|
572
561
|
}
|
|
573
562
|
if (!sheet) {
|
|
563
|
+
if (!kept.has("xl/workbook.xml")) {
|
|
564
|
+
throw new ImportError(
|
|
565
|
+
"UNSUPPORTED_FORMAT",
|
|
566
|
+
"This is a zip archive that is not an Excel workbook. Upload an .xlsx or .csv file."
|
|
567
|
+
);
|
|
568
|
+
}
|
|
574
569
|
throw new ImportError("MALFORMED_FILE", "This workbook has no worksheet.");
|
|
575
570
|
}
|
|
576
571
|
const ordered = [...WANTED, sheet.name].map((wanted) => kept.get(wanted)).filter((entry) => entry !== void 0);
|
|
@@ -665,13 +660,36 @@ function detectFormat(head) {
|
|
|
665
660
|
}
|
|
666
661
|
return "csv";
|
|
667
662
|
}
|
|
668
|
-
async function* csvRows(stream) {
|
|
663
|
+
async function* csvRows(stream, limits, found) {
|
|
669
664
|
const decoder = new TextDecoder("utf-8");
|
|
670
|
-
const
|
|
665
|
+
const maxHeader = limits.maxCellLength * limits.maxColumns + limits.maxColumns;
|
|
666
|
+
let held = "";
|
|
667
|
+
let reader = null;
|
|
668
|
+
const open = (headerLine) => {
|
|
669
|
+
found.delimiter = core.detectDelimiter(headerLine);
|
|
670
|
+
return core.createCsvReader({ delimiter: found.delimiter });
|
|
671
|
+
};
|
|
671
672
|
for await (const chunk of stream) {
|
|
672
|
-
|
|
673
|
+
const text = decoder.decode(chunk, { stream: true });
|
|
674
|
+
if (!reader) {
|
|
675
|
+
held += text;
|
|
676
|
+
const line = core.completeFirstLine(held);
|
|
677
|
+
if (line === null) {
|
|
678
|
+
if (held.length > maxHeader) {
|
|
679
|
+
throw new ImportError("SHEET_TOO_LARGE", "The header row is longer than any row may be.");
|
|
680
|
+
}
|
|
681
|
+
continue;
|
|
682
|
+
}
|
|
683
|
+
reader = open(line);
|
|
684
|
+
for (const row of reader.push(held)) yield row;
|
|
685
|
+
held = "";
|
|
686
|
+
continue;
|
|
687
|
+
}
|
|
688
|
+
for (const row of reader.push(text)) yield row;
|
|
673
689
|
}
|
|
674
|
-
|
|
690
|
+
const rest = held + decoder.decode();
|
|
691
|
+
reader ??= open(core.completeFirstLine(rest) ?? rest);
|
|
692
|
+
for (const row of reader.push(rest)) yield row;
|
|
675
693
|
for (const row of reader.end()) yield row;
|
|
676
694
|
}
|
|
677
695
|
async function* guardRows(rows, width, limits) {
|
|
@@ -706,10 +724,11 @@ async function readSheet(options) {
|
|
|
706
724
|
const counted = limitBytes(toByteStream(options.stream), limits.maxBytes);
|
|
707
725
|
const { head, stream } = await peek(counted, MAGIC_BYTES);
|
|
708
726
|
const format = detectFormat(head);
|
|
709
|
-
const
|
|
727
|
+
const found = {};
|
|
728
|
+
const rows = format === "xlsx" ? xlsxRows(stream, limits) : csvRows(stream, limits, found);
|
|
710
729
|
const iterator = rows[Symbol.asyncIterator]();
|
|
711
730
|
const first = await iterator.next();
|
|
712
|
-
const headers = first.done ? [] : first.value.map(
|
|
731
|
+
const headers = first.done ? [] : first.value.map(core.cellText);
|
|
713
732
|
if (headers.length > limits.maxColumns) {
|
|
714
733
|
throw new ImportError(
|
|
715
734
|
"SHEET_TOO_LARGE",
|
|
@@ -720,7 +739,8 @@ async function readSheet(options) {
|
|
|
720
739
|
return {
|
|
721
740
|
format,
|
|
722
741
|
headers,
|
|
723
|
-
rows: closing(guardRows(remaining, headers.length, limits), options.stream)
|
|
742
|
+
rows: closing(guardRows(remaining, headers.length, limits), options.stream),
|
|
743
|
+
...found.delimiter ? { delimiter: found.delimiter } : {}
|
|
724
744
|
};
|
|
725
745
|
} catch (error) {
|
|
726
746
|
destroySource(options.stream);
|
|
@@ -774,6 +794,7 @@ async function runImport(options) {
|
|
|
774
794
|
filename: options.filename,
|
|
775
795
|
limits
|
|
776
796
|
});
|
|
797
|
+
const decimal = options.decimal ?? core.decimalMarkFor(sheet.delimiter);
|
|
777
798
|
const errors = [];
|
|
778
799
|
let errorCount = 0;
|
|
779
800
|
let imported = 0;
|
|
@@ -789,6 +810,7 @@ async function runImport(options) {
|
|
|
789
810
|
batch = [];
|
|
790
811
|
const result = core.applyMapping(rows, plan, config, {
|
|
791
812
|
lang: options.lang,
|
|
813
|
+
decimal,
|
|
792
814
|
rules: options.rules,
|
|
793
815
|
lookups: options.lookups,
|
|
794
816
|
firstRowNumber: firstRow
|
|
@@ -851,19 +873,22 @@ async function previewSheet(options) {
|
|
|
851
873
|
try {
|
|
852
874
|
for await (const row of sheet.rows) {
|
|
853
875
|
rowCount++;
|
|
854
|
-
if (sample.length < limits.sampleRows) sample.push(row.map(
|
|
876
|
+
if (sample.length < limits.sampleRows) sample.push(row.map(core.cellText));
|
|
855
877
|
}
|
|
856
878
|
} catch (error) {
|
|
857
879
|
destroySource(options.stream);
|
|
858
880
|
throw error;
|
|
859
881
|
}
|
|
860
|
-
const
|
|
882
|
+
const arrayBound = core.arrayBoundFor(sheet.headers, options.config, null, options.lang);
|
|
883
|
+
const { columns } = core.deriveImportColumns(options.config, { lang: options.lang, maxArrayRows: arrayBound });
|
|
861
884
|
return {
|
|
862
885
|
headers: sheet.headers,
|
|
863
886
|
sample,
|
|
864
887
|
rowCount,
|
|
865
888
|
suggestion: core.suggestMapping(sheet.headers, columns, options.config?.entity ?? ""),
|
|
866
|
-
format: sheet.format
|
|
889
|
+
format: sheet.format,
|
|
890
|
+
...sheet.delimiter ? { delimiter: sheet.delimiter } : {},
|
|
891
|
+
arrayBound
|
|
867
892
|
};
|
|
868
893
|
}
|
|
869
894
|
var TEMPLATE_EXTENSION = { csv: "csv", xlsx: "xlsx" };
|
|
@@ -1040,6 +1065,8 @@ function createImportRouter(options) {
|
|
|
1040
1065
|
sample: preview.sample,
|
|
1041
1066
|
suggestion: preview.suggestion,
|
|
1042
1067
|
rowCount: preview.rowCount,
|
|
1068
|
+
arrayBound: preview.arrayBound,
|
|
1069
|
+
...preview.delimiter ? { delimiter: preview.delimiter } : {},
|
|
1043
1070
|
engineVersion: core.CORE_VERSION
|
|
1044
1071
|
};
|
|
1045
1072
|
response.json(body);
|