@dynamic-entity/server 2.1.1 → 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 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-C2dCEp48.mjs';
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-C2dCEp48.js';
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 reader = core.createCsvReader();
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
- for (const row of reader.push(decoder.decode(chunk, { stream: true }))) yield row;
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
- for (const row of reader.push(decoder.decode())) yield row;
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 rows = format === "xlsx" ? xlsxRows(stream, limits) : csvRows(stream);
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(sampleText);
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(sampleText));
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 { columns } = core.deriveImportColumns(options.config, { lang: options.lang });
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);