@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.
package/README.md ADDED
@@ -0,0 +1,236 @@
1
+ # @dynamic-entity/server
2
+
3
+ > Streaming spreadsheet import for [`@dynamic-entity/core`](https://www.npmjs.com/package/@dynamic-entity/core), with an Express adapter.
4
+
5
+ [![npm](https://img.shields.io/npm/v/@dynamic-entity/server.svg)](https://www.npmjs.com/package/@dynamic-entity/server)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](../../LICENSE)
7
+
8
+ The import wizard in `ngx-dynamic-entity` works with **no backend at all** — it reads the file
9
+ in the browser and runs the whole import there. This package exists for the one case that
10
+ makes that wrong: a fifty-thousand-row workbook, which a browser has nowhere to put.
11
+
12
+ It re-decides nothing. The mapping, the coercion, the validation and the record shape all come
13
+ from `@dynamic-entity/core` — the same functions the browser runs. **A server-side import is
14
+ meant to be indistinguishable from a client-side one**, and anywhere this package decided
15
+ something core already decides would be a defect rather than an optimisation.
16
+
17
+ ---
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ npm install @dynamic-entity/server @dynamic-entity/core express
23
+ ```
24
+
25
+ `express` is an optional peer: it is needed only for the adapter, which lives behind its own
26
+ entry point. Node 20 or newer.
27
+
28
+ ---
29
+
30
+ ## The Express adapter
31
+
32
+ ```ts
33
+ import express from 'express';
34
+ import { createImportRouter } from '@dynamic-entity/server/express';
35
+ import type { EntityFormConfig, FormRule } from '@dynamic-entity/core';
36
+
37
+ // Your own — a config you authored, the rules you render the form with, your database.
38
+ declare const employeeConfig: EntityFormConfig;
39
+ declare const employeeRules: FormRule[];
40
+ declare const db: { insertMany(entity: string, records: Record<string, unknown>[]): Promise<void> };
41
+
42
+ const app = express();
43
+
44
+ app.use(
45
+ '/api/import',
46
+ createImportRouter({
47
+ configs: { employee: employeeConfig },
48
+ rules: { employee: employeeRules },
49
+ // Every `listName` any config mentions. A server has no LOOKUP_REGISTRY, and the router
50
+ // refuses to start if one is missing — see "Lookups" below.
51
+ lookups: { statuses: [{ en: 'Active' }, { en: 'Inactive' }] },
52
+ async onImport(records, { entity, request }) {
53
+ await db.insertMany(entity, records);
54
+ },
55
+ }),
56
+ );
57
+ ```
58
+
59
+ | Route | Does |
60
+ |---|---|
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 |
63
+ | `POST /:entity/validate` | The identical import pipeline with **no** writing — every error, nothing stored. Answers `written: false` |
64
+ | `POST /:entity/import` | multipart + a `plan` field → runs the import, calling `onImport` per batch. Answers `written: true` |
65
+
66
+ `configs` may be a **function** rather than a map, so an app that keeps its configs in a
67
+ database does not need a restart to add an entity. The map key is a **route segment and nothing
68
+ else** — mount `{ employees: employeeConfig }` and the route is `/employees/…` while the
69
+ download is still named from `config.entity`.
70
+
71
+ `/validate` and `/import` answer with the same body shape. `imported` counts records the run
72
+ *produced*; `written` says whether they were stored. Read them together — on `/validate`,
73
+ `imported` is what would have been written.
74
+
75
+ The counts reconcile: **`imported + skipped + failed === rowsRead`**. `failed` is rows, counted
76
+ exactly; `errorCount` is *problems*, and `errors` is a sample of them capped at
77
+ `maxReportedErrors`. Do not count the distinct rows in `errors` — that answers how many rows
78
+ fitted inside the cap, which on a thousand-row file with two hundred failures is seven.
79
+
80
+ ### The client half
81
+
82
+ ```typescript
83
+ // app.config.ts
84
+ import { provideNgxDynamicEntity, provideHttpImportTransport } from 'ngx-dynamic-entity';
85
+
86
+ export const providers = [
87
+ provideNgxDynamicEntity({}),
88
+ provideHttpImportTransport({ baseUrl: '/api/import' }),
89
+ ];
90
+ ```
91
+
92
+ The wizard components do not change: they already talk to an `ImportTransport`, which is what
93
+ the seam was for.
94
+
95
+ ---
96
+
97
+ ## Without Express
98
+
99
+ The package root exports the whole engine with no framework in it. A Fastify, Nest or Next
100
+ route wires it in the same few lines the Express adapter does — the adapter is one caller, not
101
+ the shape of the package.
102
+
103
+ ```ts
104
+ import { runImport } from '@dynamic-entity/server';
105
+ import type { EntityFormConfig, ImportLookups, MappingPlan } from '@dynamic-entity/core';
106
+ import type { Readable } from 'node:stream';
107
+
108
+ declare const body: Readable; // anything you can `for await` bytes out of
109
+ declare const plan: MappingPlan;
110
+ declare const config: EntityFormConfig;
111
+ declare const lookups: ImportLookups;
112
+ declare const db: { insertMany(records: Record<string, unknown>[]): Promise<void> };
113
+
114
+ const result = await runImport({
115
+ stream: body,
116
+ plan,
117
+ config,
118
+ lookups,
119
+ limits: { batchSize: 500 },
120
+ onBatch: async records => {
121
+ await db.insertMany(records); // awaited — this is the backpressure
122
+ },
123
+ });
124
+
125
+ console.log(`imported ${result.imported}, skipped ${result.skipped}`);
126
+ ```
127
+
128
+ `runImport` pulls rows off the reader in batches, hands each batch to core's `applyMapping`,
129
+ **awaits** `onBatch`, and drops the batch. Peak memory is a function of `batchSize`, not of the
130
+ file's size — which is the entire reason this package exists.
131
+
132
+ ---
133
+
134
+ ## Four things to know before you deploy this
135
+
136
+ ### 1. An import is not transactional
137
+
138
+ **A failure part-way through leaves the rows before it written.** There is no rollback: the
139
+ package does not own your database and cannot own your transaction. `POST /:entity/validate`
140
+ is the answer — it runs the identical pipeline with no writing, so a user can see every problem
141
+ in the file before anything at all is stored.
142
+
143
+ **`onImport` must be idempotent.** A stream that fails at row 30,000 has written 29,999
144
+ records, and over HTTP a retry is *likely* rather than possible — a client, a proxy or a user
145
+ will send the same file again. Deduplication is yours: an `EntityFormConfig` has no natural-key
146
+ concept for this package to deduplicate on.
147
+
148
+ ### 2. Lookups are checked at startup
149
+
150
+ A `listName` field resolves against a named list. The browser has `LOOKUP_REGISTRY`; a server
151
+ has nothing, so an unsupplied list means the raw cell text is stored — a value that renders
152
+ correctly and matches nothing, in every rule and every option comparison that names it.
153
+
154
+ So `createImportRouter` **throws at construction** when a config mentions a list you did not
155
+ supply, naming the list. A silent wrong value found in a database next quarter is the expensive
156
+ failure; an app that will not boot is the cheap one.
157
+
158
+ ### 3. Authentication, authorization and concurrency are yours
159
+
160
+ The router is mounted inside your app, behind your middleware. Nothing here checks who is
161
+ asking, and nothing here limits how many imports run at once. See [SECURITY.md](../../SECURITY.md).
162
+
163
+ ### 4. The file is uploaded twice
164
+
165
+ Preview reads it, import reads it again. Server-side staging with a token would fix that and
166
+ bring a temp-file lifecycle, a TTL, cleanup and an unbounded-storage surface with it — a worse
167
+ trade for a first slice. Slicing a prefix for the preview does not work either: a zip's central
168
+ directory is at the *end* of the file.
169
+
170
+ ---
171
+
172
+ ## Limits
173
+
174
+ Every limit has a **finite default**, and each is enforced *during* streaming rather than after
175
+ — a guard that runs once the file is in memory has already lost. Raise one where you need to;
176
+ nobody has to know it exists to be protected by it.
177
+
178
+ ```ts
179
+ import { createImportRouter } from '@dynamic-entity/server/express';
180
+ import type { ImportRouterOptions } from '@dynamic-entity/server/express';
181
+
182
+ declare const options: ImportRouterOptions;
183
+
184
+ createImportRouter({ ...options, limits: { maxBytes: 50 * 1024 * 1024, batchSize: 1000 } });
185
+ ```
186
+
187
+ | Limit | Default | Bounds |
188
+ |---|---|---|
189
+ | `maxBytes` | 10 MB | The upload, counted as bytes arrive |
190
+ | `maxRows` / `maxColumns` / `maxCellLength` | 200,000 / 512 / 32,768 | The sheet |
191
+ | `maxUncompressedBytes` | 200 MB | What an `.xlsx` inflates to, checked *as it inflates* |
192
+ | `maxCompressionRatio` / `maxZipEntries` | 200 / 512 | Zip bombs |
193
+ | `batchSize` | 500 | Rows in memory at once |
194
+ | `maxReportedErrors` | 200 | Error objects **retained**. `errorCount` and `failed` stay exact |
195
+ | `maxFields` / `maxFieldBytes` / `maxFieldNameBytes` / `maxFiles` | 16 / 1 MB / 200 / 1 | Multipart abuse |
196
+ | `idleTimeoutMs` / `totalTimeoutMs` | 30 s / 10 min | Stalled and never-ending uploads |
197
+
198
+ `maxCellLength` is doing double duty: `validators.pattern` is a config-supplied regex that runs
199
+ against cell text, and on a server that text is attacker-chosen. Bounding the input bounds a
200
+ catastrophic backtrack. See [SECURITY.md](../../SECURITY.md).
201
+
202
+ ---
203
+
204
+ ## What it holds in memory
205
+
206
+ Stated rather than left to be discovered:
207
+
208
+ - **CSV streams properly.** Rows are read incrementally and never accumulated.
209
+ - **Exactly one worksheet is read** — the lowest-numbered `sheetN.xml`, not whichever the
210
+ archive happens to list first. True tab order lives in `workbook.xml` and is not parsed; the
211
+ sheet number is what exceljs matches on and is a great deal closer than byte order.
212
+ - **An `.xlsx` holds the archive, compressed** — bounded by `maxBytes`, so a few megabytes for
213
+ a fifty-thousand-row workbook. The archive is read and rebuilt before the parser sees it, for
214
+ two reasons: the only place its inflated size is knowable early is there, and exceljs's
215
+ streaming reader loses entries when a worksheet precedes the shared strings. The inflated
216
+ sheet XML is never held.
217
+ - **Errors are capped** at `maxReportedErrors`. The response carries the retained sample plus a
218
+ true `errorCount` and a `truncated` flag, so a report never quietly reads as complete.
219
+
220
+ ---
221
+
222
+ ## Formats
223
+
224
+ Decided by the file's **first bytes**, never by its name — an attacker who wants a zip parsed
225
+ will name their zip `.csv`.
226
+
227
+ - **CSV** — through core's dependency-free incremental parser. UTF-8, with or without a BOM.
228
+ - **`.xlsx`** — through `exceljs`. Cells keep their types: a date cell arrives as a `Date` at
229
+ UTC midnight and is read as the calendar date it is, in every timezone.
230
+ - A pre-2007 `.xls` and a UTF-16 file are **named** rather than parsed into nonsense.
231
+
232
+ ---
233
+
234
+ ## Licence
235
+
236
+ MIT © Nizamudeen
package/express.d.mts ADDED
@@ -0,0 +1,89 @@
1
+ import { EntityFormConfig, FormRule, ImportLookups } from '@dynamic-entity/core';
2
+ import { Request, Router, Response } from 'express';
3
+ import { B as BatchInfo, I as ImportLimits, T as TemplateFormat } from './write-template-C2dCEp48.mjs';
4
+ import 'node:stream';
5
+
6
+ /**
7
+ * express.ts — four routes over the framework-neutral core, and no privileges.
8
+ *
9
+ * Everything this file does, a Fastify, Nest or Next route could do in the same few lines:
10
+ * find the config, read the upload, call `previewSheet` / `runImport` / `writeTemplate`, and
11
+ * answer. It is one caller of the package rather than the package's shape.
12
+ *
13
+ * **Not this router's job, said out loud.** Concurrency limiting, authentication and
14
+ * authorization are the consumer's. The router is mounted inside their app, behind their
15
+ * middleware, and nothing here checks who is asking. SECURITY.md says so rather than leaving
16
+ * a reader to assume the router protects them.
17
+ *
18
+ * This module is published as `@dynamic-entity/server/express` rather than from the package
19
+ * root, so that it — and its `express` import — is reached only by someone who wants it. A
20
+ * consumer wiring Fastify imports the root and never pays for a framework they do not use.
21
+ */
22
+
23
+ /** What the consumer does with a batch of records. Called once per batch, and awaited. */
24
+ type OnImport = (records: Record<string, unknown>[], context: BatchInfo & {
25
+ entity: string;
26
+ config: EntityFormConfig;
27
+ /** For a tenant id, a user, a transaction — whatever the consumer's writer needs. */
28
+ request: Request;
29
+ }) => void | Promise<void>;
30
+ /**
31
+ * Where the configs come from.
32
+ *
33
+ * A map **or** a function, because a real app keeps its configs in a database and should not
34
+ * need a restart to add an entity. Returning `undefined` is how a loader says "no such
35
+ * entity"; throwing is how it says something went wrong.
36
+ */
37
+ type ConfigSource = Record<string, EntityFormConfig> | ((entity: string, request: Request) => EntityFormConfig | undefined | Promise<EntityFormConfig | undefined>);
38
+ interface ImportRouterOptions {
39
+ configs: ConfigSource;
40
+ /** Rules per entity. Without them an import checks validators only, which the form does not. */
41
+ rules?: Record<string, readonly FormRule[]>;
42
+ /** Values for every `listName` any config mentions. A server has no `LOOKUP_REGISTRY`. */
43
+ lookups?: ImportLookups;
44
+ /**
45
+ * Where records go.
46
+ *
47
+ * **It must be idempotent.** A stream that fails at row 30,000 has written 29,999 records,
48
+ * and over HTTP a retry is likely rather than possible — a client, a proxy or a user will
49
+ * send the same file again. This package does not deduplicate: the config has no natural-key
50
+ * concept to deduplicate on. See SECURITY.md.
51
+ */
52
+ onImport: OnImport;
53
+ limits?: Partial<ImportLimits>;
54
+ /** Which language error text is produced in. Default `en`. */
55
+ lang?: string | ((request: Request) => string);
56
+ }
57
+ /**
58
+ * Every `listName` the importable columns of a config mention.
59
+ *
60
+ * Derived from the same column walk the import uses, so the set checked is exactly the set
61
+ * that will be asked for — a field the sheet cannot carry cannot need a list.
62
+ */
63
+ declare function listNamesOf(config: EntityFormConfig): string[];
64
+ /**
65
+ * A download filename derived from the config, never from the client.
66
+ *
67
+ * A filename that reaches a `Content-Disposition` having come from a request is header
68
+ * injection with extra steps: a newline in it ends the header and starts another. This takes
69
+ * the entity and keeps only characters that cannot mean anything in a header.
70
+ *
71
+ * The caller passes `config.entity`, not the route segment. They are usually the same string,
72
+ * and when they are not — a consumer mounting a plural route, `{ employees: employeeConfig }` —
73
+ * the config is the one that names the data. Reading the route key instead made the map key
74
+ * carry meaning it was never given, which is a footgun with no upside: the key is a route
75
+ * segment and nothing else.
76
+ */
77
+ declare function templateFilename(entity: string, format: TemplateFormat): string;
78
+ /**
79
+ * Send whatever went wrong as the envelope, unless the response has already started.
80
+ *
81
+ * Exported so it can be tested against a response that has already begun: a template that
82
+ * failed part-way is already partly on the wire, and the only honest thing left is to end the
83
+ * connection abruptly. A 200 with a truncated body is a corrupt file that looks like a good
84
+ * one, which is the worst of the available outcomes.
85
+ */
86
+ declare function sendFailure(response: Response, error: unknown): void;
87
+ declare function createImportRouter(options: ImportRouterOptions): Router;
88
+
89
+ export { type ConfigSource, type ImportRouterOptions, type OnImport, createImportRouter, listNamesOf, sendFailure, templateFilename };
package/express.d.ts ADDED
@@ -0,0 +1,89 @@
1
+ import { EntityFormConfig, FormRule, ImportLookups } from '@dynamic-entity/core';
2
+ import { Request, Router, Response } from 'express';
3
+ import { B as BatchInfo, I as ImportLimits, T as TemplateFormat } from './write-template-C2dCEp48.js';
4
+ import 'node:stream';
5
+
6
+ /**
7
+ * express.ts — four routes over the framework-neutral core, and no privileges.
8
+ *
9
+ * Everything this file does, a Fastify, Nest or Next route could do in the same few lines:
10
+ * find the config, read the upload, call `previewSheet` / `runImport` / `writeTemplate`, and
11
+ * answer. It is one caller of the package rather than the package's shape.
12
+ *
13
+ * **Not this router's job, said out loud.** Concurrency limiting, authentication and
14
+ * authorization are the consumer's. The router is mounted inside their app, behind their
15
+ * middleware, and nothing here checks who is asking. SECURITY.md says so rather than leaving
16
+ * a reader to assume the router protects them.
17
+ *
18
+ * This module is published as `@dynamic-entity/server/express` rather than from the package
19
+ * root, so that it — and its `express` import — is reached only by someone who wants it. A
20
+ * consumer wiring Fastify imports the root and never pays for a framework they do not use.
21
+ */
22
+
23
+ /** What the consumer does with a batch of records. Called once per batch, and awaited. */
24
+ type OnImport = (records: Record<string, unknown>[], context: BatchInfo & {
25
+ entity: string;
26
+ config: EntityFormConfig;
27
+ /** For a tenant id, a user, a transaction — whatever the consumer's writer needs. */
28
+ request: Request;
29
+ }) => void | Promise<void>;
30
+ /**
31
+ * Where the configs come from.
32
+ *
33
+ * A map **or** a function, because a real app keeps its configs in a database and should not
34
+ * need a restart to add an entity. Returning `undefined` is how a loader says "no such
35
+ * entity"; throwing is how it says something went wrong.
36
+ */
37
+ type ConfigSource = Record<string, EntityFormConfig> | ((entity: string, request: Request) => EntityFormConfig | undefined | Promise<EntityFormConfig | undefined>);
38
+ interface ImportRouterOptions {
39
+ configs: ConfigSource;
40
+ /** Rules per entity. Without them an import checks validators only, which the form does not. */
41
+ rules?: Record<string, readonly FormRule[]>;
42
+ /** Values for every `listName` any config mentions. A server has no `LOOKUP_REGISTRY`. */
43
+ lookups?: ImportLookups;
44
+ /**
45
+ * Where records go.
46
+ *
47
+ * **It must be idempotent.** A stream that fails at row 30,000 has written 29,999 records,
48
+ * and over HTTP a retry is likely rather than possible — a client, a proxy or a user will
49
+ * send the same file again. This package does not deduplicate: the config has no natural-key
50
+ * concept to deduplicate on. See SECURITY.md.
51
+ */
52
+ onImport: OnImport;
53
+ limits?: Partial<ImportLimits>;
54
+ /** Which language error text is produced in. Default `en`. */
55
+ lang?: string | ((request: Request) => string);
56
+ }
57
+ /**
58
+ * Every `listName` the importable columns of a config mention.
59
+ *
60
+ * Derived from the same column walk the import uses, so the set checked is exactly the set
61
+ * that will be asked for — a field the sheet cannot carry cannot need a list.
62
+ */
63
+ declare function listNamesOf(config: EntityFormConfig): string[];
64
+ /**
65
+ * A download filename derived from the config, never from the client.
66
+ *
67
+ * A filename that reaches a `Content-Disposition` having come from a request is header
68
+ * injection with extra steps: a newline in it ends the header and starts another. This takes
69
+ * the entity and keeps only characters that cannot mean anything in a header.
70
+ *
71
+ * The caller passes `config.entity`, not the route segment. They are usually the same string,
72
+ * and when they are not — a consumer mounting a plural route, `{ employees: employeeConfig }` —
73
+ * the config is the one that names the data. Reading the route key instead made the map key
74
+ * carry meaning it was never given, which is a footgun with no upside: the key is a route
75
+ * segment and nothing else.
76
+ */
77
+ declare function templateFilename(entity: string, format: TemplateFormat): string;
78
+ /**
79
+ * Send whatever went wrong as the envelope, unless the response has already started.
80
+ *
81
+ * Exported so it can be tested against a response that has already begun: a template that
82
+ * failed part-way is already partly on the wire, and the only honest thing left is to end the
83
+ * connection abruptly. A 200 with a truncated body is a corrupt file that looks like a good
84
+ * one, which is the worst of the available outcomes.
85
+ */
86
+ declare function sendFailure(response: Response, error: unknown): void;
87
+ declare function createImportRouter(options: ImportRouterOptions): Router;
88
+
89
+ export { type ConfigSource, type ImportRouterOptions, type OnImport, createImportRouter, listNamesOf, sendFailure, templateFilename };