@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 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-C2dCEp48.mjs';
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-C2dCEp48.js';
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 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 = forced ?? 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,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 rows = format === "xlsx" ? xlsxRows(stream, limits) : csvRows(stream);
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(sampleText);
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(sampleText));
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 { columns } = core.deriveImportColumns(options.config, { lang: options.lang });
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);