@dynamic-entity/server 1.14.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.
@@ -0,0 +1,332 @@
1
+ import { ImportRowError, ConfigProblem, EntityFormConfig, MappingPlan, FormRule, ImportLookups, TemplateSpec } from '@dynamic-entity/core';
2
+ import { Writable } from 'node:stream';
3
+
4
+ /**
5
+ * limits.ts — every bound this package enforces, and its default.
6
+ *
7
+ * **Every limit has a finite default.** A limit that is only a limit when a consumer
8
+ * remembers to set one is documentation, not a guard, and this is the first
9
+ * attacker-reachable code in the repository. A consumer who wants more raises the number;
10
+ * nobody has to know the number exists to be protected by it.
11
+ *
12
+ * Each is enforced **during** streaming rather than after. A check that runs once the file is
13
+ * in memory has already lost the thing it was checking.
14
+ */
15
+ interface ImportLimits {
16
+ /** Total bytes accepted from the request body. Counted as they arrive. */
17
+ maxBytes: number;
18
+ /** Multipart fields accepted, so a body of ten thousand tiny parts is not a parse loop. */
19
+ maxFields: number;
20
+ /** Bytes accepted in one non-file field — in practice the JSON `plan`. */
21
+ maxFieldBytes: number;
22
+ /** Bytes accepted in a field *name*, which is also attacker-chosen. */
23
+ maxFieldNameBytes: number;
24
+ /** Files accepted. One import is one file; more is either a mistake or an attack. */
25
+ maxFiles: number;
26
+ /** Data rows read. The header is not one of them. */
27
+ maxRows: number;
28
+ /** Columns read from the header row, and the width any row may reach. */
29
+ maxColumns: number;
30
+ /**
31
+ * Characters in one cell.
32
+ *
33
+ * This is also the bound on `validators.pattern`: a config-supplied regex with
34
+ * catastrophic backtracking is CPU-bound in the length of what it is run against, and on a
35
+ * server that input is attacker-chosen. See SECURITY.md.
36
+ */
37
+ maxCellLength: number;
38
+ /** Total inflated bytes across every entry, checked *as they inflate*. */
39
+ maxUncompressedBytes: number;
40
+ /** Inflated-to-compressed ratio at which an entry is refused. */
41
+ maxCompressionRatio: number;
42
+ /** Entries in the archive. A workbook has tens; a bomb has thousands. */
43
+ maxZipEntries: number;
44
+ /**
45
+ * Rows handed to `applyMapping`, and therefore to `onBatch`, at a time.
46
+ *
47
+ * Peak memory is a function of this number and not of the file's size. That is the entire
48
+ * reason this package exists.
49
+ */
50
+ batchSize: number;
51
+ /**
52
+ * Row errors **retained**. The count and the truncation flag are always exact.
53
+ *
54
+ * A file where every row fails accumulates one error object per failing row, and fifty
55
+ * thousand of those is the same unbounded growth as holding the file — the failure this
56
+ * package exists to avoid, arrived at from the other direction.
57
+ *
58
+ * Lowering it costs a user *reasons*, never *counts*: `errorCount` and `failed` are tallied
59
+ * as rows go past and do not depend on what was kept.
60
+ */
61
+ maxReportedErrors: number;
62
+ /** Rows a preview returns as a sample. */
63
+ sampleRows: number;
64
+ /** Milliseconds of silence on the request body before it is abandoned. Slowloris. */
65
+ idleTimeoutMs: number;
66
+ /** Milliseconds a whole request may take, however chatty it is. */
67
+ totalTimeoutMs: number;
68
+ }
69
+ /**
70
+ * Sized for "a large legitimate export", not for "the largest thing imaginable".
71
+ *
72
+ * 10 MB of xlsx is comfortably the fifty-thousand-row workbook that motivated the package;
73
+ * `maxRows` is above it so the byte limit is the one a user meets first, and the row limit is
74
+ * the backstop for a file that is mostly empty cells.
75
+ */
76
+ declare const DEFAULT_LIMITS: ImportLimits;
77
+ /**
78
+ * Fill in what a caller left out.
79
+ *
80
+ * A non-finite or non-positive override falls back to the default rather than being accepted:
81
+ * `maxBytes: 0` from a mis-parsed environment variable would otherwise disable every upload,
82
+ * and `maxBytes: Infinity` would disable the guard — the two ways a configuration mistake
83
+ * turns a limit off, both of which look deliberate in a diff.
84
+ */
85
+ declare function resolveLimits(overrides?: Partial<ImportLimits>): ImportLimits;
86
+
87
+ /**
88
+ * bytes.ts — the three things every reader here needs from a byte stream.
89
+ *
90
+ * Deliberately framework-neutral: a `ByteSource` is anything you can `for await` bytes out of,
91
+ * which a Node `Readable`, a `Buffer`, and a hand-rolled async generator all are. Nothing in
92
+ * this file knows what a request is.
93
+ */
94
+ /** Anything bytes can be pulled from — a request, a file handle, a buffer, a test fixture. */
95
+ type ByteSource = AsyncIterable<Uint8Array> | Iterable<Uint8Array> | Uint8Array;
96
+ /** Normalise the three shapes above into one async iterable. */
97
+ declare function toByteStream(source: ByteSource): AsyncGenerator<Uint8Array>;
98
+ /**
99
+ * Refuse the upload at the byte that crosses the line, not once it has all arrived.
100
+ *
101
+ * Counting after the body is buffered is the guard that has already lost: the memory it was
102
+ * protecting is spent by the time it runs. This throws mid-stream, and the caller destroys
103
+ * the source — so a hostile 4 GB body costs `maxBytes` plus one chunk.
104
+ */
105
+ declare function limitBytes(source: AsyncIterable<Uint8Array>, maxBytes: number): AsyncGenerator<Uint8Array>;
106
+ /**
107
+ * Take the first `n` bytes without consuming them.
108
+ *
109
+ * Format is decided by what a file *is* rather than by what its name claims, which means
110
+ * looking at its first four bytes before choosing a reader — and then handing those same
111
+ * bytes to the reader that was chosen. Returning the head separately and re-yielding it is
112
+ * the whole trick; buffering the file to seek back to zero is the thing being avoided.
113
+ */
114
+ declare function peek(source: AsyncIterable<Uint8Array>, n: number): Promise<{
115
+ head: Uint8Array;
116
+ stream: AsyncGenerator<Uint8Array>;
117
+ }>;
118
+ /** Best-effort close of whatever the bytes came from, once a guard has decided to stop. */
119
+ declare function destroySource(source: unknown): void;
120
+
121
+ /**
122
+ * read-sheet.ts — bytes in, a header row and an async stream of rows out.
123
+ *
124
+ * Two properties this file exists to hold:
125
+ *
126
+ * 1. **Format is decided by content, never by the filename.** `.csv` is client-controlled and
127
+ * an attacker who wants a zip parsed will name their zip `.csv`. The first four bytes are
128
+ * not. Both readers then meet the same row guards, so a workbook cannot buy itself a
129
+ * larger sheet by being a workbook.
130
+ * 2. **Rows arrive one at a time.** Nothing here ever holds the sheet. `rows` is an
131
+ * `AsyncIterable`, so a consumer that stops pulling stops the read.
132
+ *
133
+ * Cells are `unknown[]`, never `string[]`. An xlsx cell is a number, a boolean or a `Date`,
134
+ * and core's `coerceCell` reads a typed cell as its type — stringifying here to make the
135
+ * signature tidier is exactly the bug that would reintroduce.
136
+ */
137
+
138
+ /** The formats this package reads. Both are decided by magic bytes. */
139
+ type SheetFormat = 'csv' | 'xlsx';
140
+ interface SheetSource {
141
+ /** What the bytes turned out to be, which may not be what the filename claimed. */
142
+ format: SheetFormat;
143
+ headers: string[];
144
+ /** Data rows, one at a time. Pulling stops the read; not pulling stops it too. */
145
+ rows: AsyncIterable<unknown[]>;
146
+ }
147
+ interface ReadSheetOptions {
148
+ stream: ByteSource;
149
+ /** Display only — it names the file in an error message and decides nothing. */
150
+ filename?: string;
151
+ limits?: Partial<ImportLimits>;
152
+ }
153
+ /**
154
+ * What these bytes actually are.
155
+ *
156
+ * Exported because the multipart layer wants the same answer for the same reason, and because
157
+ * a second copy of a magic-byte table is a second table to be wrong.
158
+ */
159
+ declare function detectFormat(head: Uint8Array): SheetFormat;
160
+ /**
161
+ * Enforce the row, column and cell bounds as rows go past, and pad short rows.
162
+ *
163
+ * Padding matches `parseCsv`, through the same `padRow` core exports, so a file imported on a
164
+ * server and the same file imported in a browser produce the same rows. A second padding rule
165
+ * here would be a place for the two halves to disagree, silently, about a row that ended early.
166
+ */
167
+ declare function guardRows(rows: AsyncIterable<unknown[]>, width: number, limits: ImportLimits): AsyncGenerator<unknown[]>;
168
+ /**
169
+ * Open a sheet: read its header row, and hand back the rest as a stream.
170
+ *
171
+ * The header row is pulled eagerly because everything downstream — the column count, the
172
+ * mapping suggestion, the padding width — is a function of it. Exactly one row is held.
173
+ */
174
+ declare function readSheet(options: ReadSheetOptions): Promise<SheetSource>;
175
+
176
+ /**
177
+ * run-import.ts — the package, really. Everything else is plumbing around this file.
178
+ *
179
+ * `runImport` pulls rows off the reader in batches, hands each batch to core's `applyMapping`,
180
+ * **awaits** `onBatch`, and drops the batch. Peak memory is a function of `batchSize`, not of
181
+ * the file's size — which is the one reason this package exists, and the claim every decision
182
+ * below is answerable against.
183
+ *
184
+ * Nothing here decides anything core already decides. The mapping rules, the coercion, the
185
+ * validation and the record shape all come from `applyMapping`; this file's entire job is to
186
+ * feed it and to not accumulate.
187
+ */
188
+
189
+ /** Where a batch sits in the user's spreadsheet, for a consumer that logs or reports progress. */
190
+ interface BatchInfo {
191
+ /** Zero-based batch number. */
192
+ index: number;
193
+ /** Spreadsheet row number of the first row this batch was built from. */
194
+ firstRow: number;
195
+ /** Records handed over before this batch, so `imported + records.length` is the running total. */
196
+ imported: number;
197
+ }
198
+ /**
199
+ * Write a batch of records.
200
+ *
201
+ * **It is awaited, and that is load-bearing.** A consumer writing to a database is slower than
202
+ * a parser reading a file; not awaiting would turn bounded memory into an unbounded queue of
203
+ * pending writes — the exact failure this package exists to avoid, reached from the other
204
+ * direction.
205
+ *
206
+ * **It must be idempotent.** A stream that fails at row 30,000 has already written 29,999
207
+ * records, and an HTTP client — or a proxy, or a user — will retry. See SECURITY.md.
208
+ */
209
+ type OnBatch = (records: Record<string, unknown>[], info: BatchInfo) => void | Promise<void>;
210
+ interface RunImportOptions {
211
+ stream: ByteSource;
212
+ /** Display only. The format is decided by the file's first bytes, never by its name. */
213
+ filename?: string;
214
+ plan: MappingPlan;
215
+ config: EntityFormConfig;
216
+ rules?: readonly FormRule[];
217
+ /** Values for every `listName` the config mentions. A server has no `LOOKUP_REGISTRY`. */
218
+ lookups?: ImportLookups;
219
+ lang?: string;
220
+ /** Omit to validate without writing: the identical pipeline, every error, nothing stored. */
221
+ onBatch?: OnBatch;
222
+ limits?: Partial<ImportLimits>;
223
+ }
224
+ interface ImportRunResult {
225
+ /**
226
+ * Records the run produced — and therefore, when `onBatch` was supplied, how many were
227
+ * handed over to be written. The records themselves are deliberately not here: returning
228
+ * fifty thousand of them would undo the streaming this whole file is for.
229
+ */
230
+ imported: number;
231
+ /** Rows that held no values at all. A blank line is not an error and is not a record. */
232
+ skipped: number;
233
+ /**
234
+ * Rows that produced at least one error, counted exactly — **not** derived from `errors`.
235
+ *
236
+ * `errors` is capped, so counting the distinct rows in it answers how many rows fitted in the
237
+ * cap rather than how many failed. Measured on a run of a thousand rows where two hundred
238
+ * failed: the sample held seven of them. The number a user acts on is "which rows do I fix",
239
+ * so it is the one that has to be exact.
240
+ *
241
+ * Row numbers never span a batch, so summing the per-batch distinct counts is exact rather
242
+ * than approximate.
243
+ */
244
+ failed: number;
245
+ /** A **sample**, capped at `limits.maxReportedErrors`. `errorCount` is the true total. */
246
+ errors: ImportRowError[];
247
+ /** Every problem found, whether or not it was retained. */
248
+ errorCount: number;
249
+ /** `errors` is shorter than `errorCount`. */
250
+ truncated: boolean;
251
+ /** What is wrong with the plan itself. An `error` here means no row was read at all. */
252
+ planProblems: ConfigProblem[];
253
+ /** Data rows read from the sheet, blank ones included. */
254
+ rowsRead: number;
255
+ /**
256
+ * What the file turned out to be — **absent when no byte of it was ever read**.
257
+ *
258
+ * A plan refused before the stream is touched has nothing to report here, and it used to
259
+ * report `'csv'` anyway. A guessed field is worse than a missing one: it is indistinguishable
260
+ * from a measured one.
261
+ */
262
+ format?: SheetFormat;
263
+ }
264
+ declare function runImport(options: RunImportOptions): Promise<ImportRunResult>;
265
+ interface PreviewSheetOptions {
266
+ stream: ByteSource;
267
+ filename?: string;
268
+ config: EntityFormConfig;
269
+ lang?: string;
270
+ limits?: Partial<ImportLimits>;
271
+ }
272
+ /** What a first look at an uploaded file yields. Matches the renderer's `ImportPreview`. */
273
+ interface SheetPreview {
274
+ headers: string[];
275
+ /** The first few rows as text, for the mapping screen to show under each column. */
276
+ sample: string[][];
277
+ /** Every inferred match is tagged `guess`. */
278
+ suggestion: MappingPlan;
279
+ /** Data rows in the file, which the whole stream is read to count. */
280
+ rowCount: number;
281
+ format: SheetFormat;
282
+ }
283
+ /**
284
+ * Headers, a sample, and a suggested mapping.
285
+ *
286
+ * The whole file is read, because `rowCount` is a number the user is shown before they commit
287
+ * and "about this many" is not a useful thing to tell someone about their own data. Reading it
288
+ * also means every limit applies to a preview exactly as it applies to an import — a preview
289
+ * endpoint that skipped them would be the cheapest way in.
290
+ */
291
+ declare function previewSheet(options: PreviewSheetOptions): Promise<SheetPreview>;
292
+
293
+ /**
294
+ * write-template.ts — the sheet to hand a user so their data arrives already in the right shape.
295
+ *
296
+ * **This is the one place in the library that writes a file another program executes.** A
297
+ * template is opened in Excel, and `=cmd|'/C calc'!A0` in a cell is a command, not text. So
298
+ * every cell goes through core's `escapeFormula` on the way in.
299
+ *
300
+ * The cell's *type* is the other half, and it cannot be set: exceljs derives it from the value
301
+ * and exposes `type` read-only. Handing it a string is therefore what makes a string cell —
302
+ * which is a claim about a library rather than a line of code here, so `write-template.spec.ts`
303
+ * reads the written XML back and asserts no `<f>` element appears in it. A guard that is an
304
+ * assertion in a test is a guard; a comment saying "exceljs writes this as text" is not.
305
+ *
306
+ * The header text itself is derived from config labels, which is authored data like any other
307
+ * and therefore not trusted here.
308
+ */
309
+
310
+ /** The formats a generated template can be written in. */
311
+ type TemplateFormat = 'csv' | 'xlsx';
312
+ interface WriteTemplateOptions {
313
+ spec: TemplateSpec;
314
+ format: TemplateFormat;
315
+ /** Written into as the file is produced; an HTTP response is the intended one. */
316
+ out: Writable;
317
+ limits?: Partial<ImportLimits>;
318
+ }
319
+ /** The extension a written template should be offered under. */
320
+ declare const TEMPLATE_EXTENSION: Record<TemplateFormat, string>;
321
+ /** The media type a written template should be served as. */
322
+ declare const TEMPLATE_MEDIA_TYPE: Record<TemplateFormat, string>;
323
+ /**
324
+ * Write a template into `out`.
325
+ *
326
+ * Resolves when the file is complete and the stream has been ended. A caller that owns the
327
+ * stream for other reasons — an Express response, say — gets it back finished, which is what
328
+ * a download handler wants.
329
+ */
330
+ declare function writeTemplate(options: WriteTemplateOptions): Promise<void>;
331
+
332
+ export { type BatchInfo as B, DEFAULT_LIMITS as D, type ImportLimits as I, type OnBatch as O, type PreviewSheetOptions as P, type ReadSheetOptions as R, type SheetFormat as S, type TemplateFormat as T, type WriteTemplateOptions as W, type ByteSource as a, type ImportRunResult as b, type RunImportOptions as c, type SheetPreview as d, type SheetSource as e, TEMPLATE_EXTENSION as f, TEMPLATE_MEDIA_TYPE as g, destroySource as h, detectFormat as i, guardRows as j, previewSheet as k, limitBytes as l, resolveLimits as m, runImport as n, peek as p, readSheet as r, toByteStream as t, writeTemplate as w };