@dynamic-entity/server 2.2.0 → 2.3.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 +14 -1
- package/express.d.mts +1 -1
- package/express.d.ts +1 -1
- package/express.js +49 -21
- package/express.js.map +1 -1
- package/express.mjs +50 -22
- package/express.mjs.map +1 -1
- package/index.d.mts +4 -22
- package/index.d.ts +4 -22
- package/index.js +52 -23
- package/index.js.map +1 -1
- package/index.mjs +50 -23
- package/index.mjs.map +1 -1
- package/package.json +2 -2
- package/{write-template-C2dCEp48.d.mts → write-template-CzKVVjgT.d.mts} +20 -3
- package/{write-template-C2dCEp48.d.ts → write-template-CzKVVjgT.d.ts} +20 -3
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,13 @@ 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 named `.tsv` is always split
|
|
274
|
+
on tabs, as in the browser; that is the one thing a name decides, and only once the bytes
|
|
275
|
+
have said the file is text. A `;` file is read with a **decimal
|
|
276
|
+
comma** (`1,5` is one and a half), because Excel writes `;` in precisely the locales that
|
|
277
|
+
use one; pass `decimal` to `runImport` to override. Generated CSV templates stay
|
|
278
|
+
comma-separated, so Excel in those locales opens them as one column — offer `xlsx` there.
|
|
266
279
|
- **`.xlsx`** — through `exceljs`. Cells keep their types: a date cell arrives as a `Date` at
|
|
267
280
|
UTC midnight and is read as the calendar date it is, in every timezone.
|
|
268
281
|
- 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-CzKVVjgT.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-CzKVVjgT.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, forced) {
|
|
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 = forced ?? 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,12 @@ 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 forced = /\.tsv$/i.test(options.filename ?? "") ? " " : void 0;
|
|
729
|
+
const rows = format === "xlsx" ? xlsxRows(stream, limits) : csvRows(stream, limits, found, forced);
|
|
710
730
|
const iterator = rows[Symbol.asyncIterator]();
|
|
711
731
|
const first = await iterator.next();
|
|
712
|
-
const headers = first.done ? [] : first.value.map(
|
|
732
|
+
const headers = first.done ? [] : first.value.map(core.cellText);
|
|
713
733
|
if (headers.length > limits.maxColumns) {
|
|
714
734
|
throw new ImportError(
|
|
715
735
|
"SHEET_TOO_LARGE",
|
|
@@ -720,7 +740,8 @@ async function readSheet(options) {
|
|
|
720
740
|
return {
|
|
721
741
|
format,
|
|
722
742
|
headers,
|
|
723
|
-
rows: closing(guardRows(remaining, headers.length, limits), options.stream)
|
|
743
|
+
rows: closing(guardRows(remaining, headers.length, limits), options.stream),
|
|
744
|
+
...found.delimiter ? { delimiter: found.delimiter } : {}
|
|
724
745
|
};
|
|
725
746
|
} catch (error) {
|
|
726
747
|
destroySource(options.stream);
|
|
@@ -774,6 +795,7 @@ async function runImport(options) {
|
|
|
774
795
|
filename: options.filename,
|
|
775
796
|
limits
|
|
776
797
|
});
|
|
798
|
+
const decimal = options.decimal ?? core.decimalMarkFor(sheet.delimiter);
|
|
777
799
|
const errors = [];
|
|
778
800
|
let errorCount = 0;
|
|
779
801
|
let imported = 0;
|
|
@@ -789,6 +811,7 @@ async function runImport(options) {
|
|
|
789
811
|
batch = [];
|
|
790
812
|
const result = core.applyMapping(rows, plan, config, {
|
|
791
813
|
lang: options.lang,
|
|
814
|
+
decimal,
|
|
792
815
|
rules: options.rules,
|
|
793
816
|
lookups: options.lookups,
|
|
794
817
|
firstRowNumber: firstRow
|
|
@@ -851,19 +874,22 @@ async function previewSheet(options) {
|
|
|
851
874
|
try {
|
|
852
875
|
for await (const row of sheet.rows) {
|
|
853
876
|
rowCount++;
|
|
854
|
-
if (sample.length < limits.sampleRows) sample.push(row.map(
|
|
877
|
+
if (sample.length < limits.sampleRows) sample.push(row.map(core.cellText));
|
|
855
878
|
}
|
|
856
879
|
} catch (error) {
|
|
857
880
|
destroySource(options.stream);
|
|
858
881
|
throw error;
|
|
859
882
|
}
|
|
860
|
-
const
|
|
883
|
+
const arrayBound = core.arrayBoundFor(sheet.headers, options.config, null, options.lang);
|
|
884
|
+
const { columns } = core.deriveImportColumns(options.config, { lang: options.lang, maxArrayRows: arrayBound });
|
|
861
885
|
return {
|
|
862
886
|
headers: sheet.headers,
|
|
863
887
|
sample,
|
|
864
888
|
rowCount,
|
|
865
889
|
suggestion: core.suggestMapping(sheet.headers, columns, options.config?.entity ?? ""),
|
|
866
|
-
format: sheet.format
|
|
890
|
+
format: sheet.format,
|
|
891
|
+
...sheet.delimiter ? { delimiter: sheet.delimiter } : {},
|
|
892
|
+
arrayBound
|
|
867
893
|
};
|
|
868
894
|
}
|
|
869
895
|
var TEMPLATE_EXTENSION = { csv: "csv", xlsx: "xlsx" };
|
|
@@ -1040,6 +1066,8 @@ function createImportRouter(options) {
|
|
|
1040
1066
|
sample: preview.sample,
|
|
1041
1067
|
suggestion: preview.suggestion,
|
|
1042
1068
|
rowCount: preview.rowCount,
|
|
1069
|
+
arrayBound: preview.arrayBound,
|
|
1070
|
+
...preview.delimiter ? { delimiter: preview.delimiter } : {},
|
|
1043
1071
|
engineVersion: core.CORE_VERSION
|
|
1044
1072
|
};
|
|
1045
1073
|
response.json(body);
|