@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 +236 -0
- package/express.d.mts +89 -0
- package/express.d.ts +89 -0
- package/express.js +1091 -0
- package/express.js.map +1 -0
- package/express.mjs +1079 -0
- package/express.mjs.map +1 -0
- package/index.d.mts +180 -0
- package/index.d.ts +180 -0
- package/index.js +826 -0
- package/index.js.map +1 -0
- package/index.mjs +800 -0
- package/index.mjs.map +1 -0
- package/package.json +58 -0
- package/write-template-C2dCEp48.d.mts +332 -0
- package/write-template-C2dCEp48.d.ts +332 -0
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
|
+
[](https://www.npmjs.com/package/@dynamic-entity/server)
|
|
6
|
+
[](../../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 };
|