@dynamic-entity/server 3.0.0-rc.3 → 3.0.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
@@ -3,7 +3,7 @@
3
3
  > Streaming spreadsheet import for [`@dynamic-entity/core`](https://www.npmjs.com/package/@dynamic-entity/core), with an Express adapter.
4
4
 
5
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)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/berserker5619/Dynamic-Entity/blob/main/LICENSE)
7
7
 
8
8
  The import wizard in `ngx-dynamic-entity` works with **no backend at all** — it reads the file
9
9
  in the browser and runs the whole import there. This package exists for the one case that
@@ -25,6 +25,29 @@ npm install @dynamic-entity/server @dynamic-entity/core express
25
25
  `express` is an optional peer: it is needed only for the adapter, which lives behind its own
26
26
  entry point. Node 20 or newer.
27
27
 
28
+ **TypeScript** needs no particular `@types/express`, or any: the adapter's typings describe the
29
+ router, request and response by what it uses, and an Express 4 or 5 one fits them. Callbacks
30
+ are handed an `ImportRequest` (a Node request with `params` and `query`). One that reads more,
31
+ such as a user or a tenant, annotates its parameter, and the options take that type:
32
+
33
+ ```ts
34
+ import express from 'express';
35
+ import { createImportRouter } from '@dynamic-entity/server/express';
36
+ import type { EntityFormConfig } from '@dynamic-entity/core';
37
+
38
+ declare const loadConfig: (tenant: string, entity: string) => Promise<EntityFormConfig | undefined>;
39
+ declare const saveAll: (tenant: string, entity: string, records: Record<string, unknown>[]) => Promise<void>;
40
+
41
+ const app = express();
42
+ app.use(
43
+ '/api/import',
44
+ createImportRouter({
45
+ configs: (entity, request: express.Request & { tenant: string }) => loadConfig(request.tenant, entity),
46
+ onImport: (records, { entity, request }) => saveAll(request.tenant, entity, records), // request is typed the same
47
+ }),
48
+ );
49
+ ```
50
+
28
51
  ---
29
52
 
30
53
  ## The Express adapter
@@ -168,7 +191,7 @@ failure; an app that will not boot is the cheap one.
168
191
  ### 3. Authentication, authorization and concurrency are yours
169
192
 
170
193
  The router is mounted inside your app, behind your middleware. Nothing here checks who is
171
- asking, and nothing here limits how many imports run at once. See [SECURITY.md](../../SECURITY.md).
194
+ asking, and nothing here limits how many imports run at once. See [SECURITY.md](https://github.com/berserker5619/Dynamic-Entity/blob/main/SECURITY.md).
172
195
 
173
196
  ### 4. The file is uploaded twice
174
197
 
@@ -213,7 +236,7 @@ UTF-16 text.
213
236
 
214
237
  `maxCellLength` is doing double duty: `validators.pattern` is a config-supplied regex that runs
215
238
  against cell text, and on a server that text is attacker-chosen. Bounding the input bounds a
216
- catastrophic backtrack. See [SECURITY.md](../../SECURITY.md).
239
+ catastrophic backtrack. See [SECURITY.md](https://github.com/berserker5619/Dynamic-Entity/blob/main/SECURITY.md).
217
240
 
218
241
  ---
219
242
 
package/express.d.mts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { EntityFormConfig, FormRule, ImportLookups, DateOrder } from '@dynamic-entity/core';
2
- import { Request, Router } from 'express';
3
- import { sendFailure as sendFailure$1, templateFilename as templateFilename$1 } from 'sheet-to-schema/node';
2
+ import { IncomingMessage, ServerResponse } from 'node:http';
3
+ import { ImportRequest, ImportRouter as ImportRouter$1, sendFailure as sendFailure$1, templateFilename as templateFilename$1 } from 'sheet-to-schema/node';
4
+ export { ImportRequest, ImportResponse } from 'sheet-to-schema/node';
4
5
  import { B as BatchInfo, I as ImportLimits } from './run-import-BUClq6BR.mjs';
5
6
 
6
7
  /**
@@ -21,14 +22,29 @@ import { B as BatchInfo, I as ImportLimits } from './run-import-BUClq6BR.mjs';
21
22
  * This module is published as `@dynamic-entity/server/express` rather than from the package
22
23
  * root, so that it — and its `express` import — is reached only by someone who wants it. A
23
24
  * consumer wiring Fastify imports the root and never pays for a framework they do not use.
25
+ *
26
+ * **Its typings name nothing from `express`.** The request, the response and the router are
27
+ * sheet-to-schema's structural types, which an Express 4 or 5 request, response and router
28
+ * satisfy as they are. So a TypeScript app needs no particular `@types/express`, or none, and
29
+ * two copies of it in one install cannot make the router's types disagree with the app's. A
30
+ * callback that reads more of the request than `ImportRequest` has (a user, a tenant)
31
+ * annotates its parameter with the app's own request type, and the options take that type.
24
32
  */
25
33
 
34
+ /**
35
+ * What `createImportRouter` returns: an Express router, described by what an app does with it.
36
+ * It is middleware, so `app.use('/import', router)` takes it, and it has the four routes on it.
37
+ * Nothing here is typed by `@types/express`, so any copy of it, or none, works.
38
+ */
39
+ interface ImportRouter<TRequest extends ImportRequest = ImportRequest> extends ImportRouter$1<TRequest> {
40
+ (request: IncomingMessage, response: ServerResponse, next: (error?: unknown) => void): void;
41
+ }
26
42
  /** What the consumer does with a batch of records. Called once per batch, and awaited. */
27
- type OnImport = (records: Record<string, unknown>[], context: BatchInfo & {
43
+ type OnImport<TRequest extends ImportRequest = ImportRequest> = (records: Record<string, unknown>[], context: BatchInfo & {
28
44
  entity: string;
29
45
  config: EntityFormConfig;
30
46
  /** For a tenant id, a user, a transaction — whatever the consumer's writer needs. */
31
- request: Request;
47
+ request: TRequest;
32
48
  }) => void | Promise<void>;
33
49
  /**
34
50
  * Where the configs come from.
@@ -37,9 +53,13 @@ type OnImport = (records: Record<string, unknown>[], context: BatchInfo & {
37
53
  * need a restart to add an entity. Returning `undefined` is how a loader says "no such
38
54
  * entity"; throwing is how it says something went wrong.
39
55
  */
40
- type ConfigSource = Record<string, EntityFormConfig> | ((entity: string, request: Request) => EntityFormConfig | undefined | Promise<EntityFormConfig | undefined>);
41
- interface ImportRouterOptions {
42
- configs: ConfigSource;
56
+ type ConfigSource<TRequest extends ImportRequest = ImportRequest> = Record<string, EntityFormConfig> | ((entity: string, request: TRequest) => EntityFormConfig | undefined | Promise<EntityFormConfig | undefined>);
57
+ /**
58
+ * `TRequest` is the request the callbacks below are handed: `ImportRequest` unless a callback
59
+ * annotates its own, such as `(entity, request: express.Request & { tenant: string }) => ...`.
60
+ */
61
+ interface ImportRouterOptions<TRequest extends ImportRequest = ImportRequest> {
62
+ configs: ConfigSource<TRequest>;
43
63
  /** Rules per entity. Without them an import checks validators only, which the form does not. */
44
64
  rules?: Record<string, readonly FormRule[]>;
45
65
  /** Values for every `listName` any config mentions. A server has no `LOOKUP_REGISTRY`. */
@@ -58,10 +78,10 @@ interface ImportRouterOptions {
58
78
  * send the same file again. This package does not deduplicate: the config has no natural-key
59
79
  * concept to deduplicate on. See SECURITY.md.
60
80
  */
61
- onImport: OnImport;
81
+ onImport: OnImport<TRequest>;
62
82
  limits?: Partial<ImportLimits>;
63
83
  /** Which language error text is produced in. Default `en`. */
64
- lang?: string | ((request: Request) => string);
84
+ lang?: string | ((request: TRequest) => string);
65
85
  }
66
86
  /**
67
87
  * Every `listName` the importable columns of a config mention.
@@ -90,6 +110,6 @@ declare const templateFilename: typeof templateFilename$1;
90
110
  * looks like a good one, which is the worst of the available outcomes.
91
111
  */
92
112
  declare const sendFailure: typeof sendFailure$1;
93
- declare function createImportRouter(options: ImportRouterOptions): Router;
113
+ declare function createImportRouter<TRequest extends ImportRequest = ImportRequest>(options: ImportRouterOptions<TRequest>): ImportRouter<TRequest>;
94
114
 
95
- export { type ConfigSource, type ImportRouterOptions, type OnImport, createImportRouter, listNamesOf, sendFailure, templateFilename };
115
+ export { type ConfigSource, type ImportRouter, type ImportRouterOptions, type OnImport, createImportRouter, listNamesOf, sendFailure, templateFilename };
package/express.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { EntityFormConfig, FormRule, ImportLookups, DateOrder } from '@dynamic-entity/core';
2
- import { Request, Router } from 'express';
3
- import { sendFailure as sendFailure$1, templateFilename as templateFilename$1 } from 'sheet-to-schema/node';
2
+ import { IncomingMessage, ServerResponse } from 'node:http';
3
+ import { ImportRequest, ImportRouter as ImportRouter$1, sendFailure as sendFailure$1, templateFilename as templateFilename$1 } from 'sheet-to-schema/node';
4
+ export { ImportRequest, ImportResponse } from 'sheet-to-schema/node';
4
5
  import { B as BatchInfo, I as ImportLimits } from './run-import-BUClq6BR.js';
5
6
 
6
7
  /**
@@ -21,14 +22,29 @@ import { B as BatchInfo, I as ImportLimits } from './run-import-BUClq6BR.js';
21
22
  * This module is published as `@dynamic-entity/server/express` rather than from the package
22
23
  * root, so that it — and its `express` import — is reached only by someone who wants it. A
23
24
  * consumer wiring Fastify imports the root and never pays for a framework they do not use.
25
+ *
26
+ * **Its typings name nothing from `express`.** The request, the response and the router are
27
+ * sheet-to-schema's structural types, which an Express 4 or 5 request, response and router
28
+ * satisfy as they are. So a TypeScript app needs no particular `@types/express`, or none, and
29
+ * two copies of it in one install cannot make the router's types disagree with the app's. A
30
+ * callback that reads more of the request than `ImportRequest` has (a user, a tenant)
31
+ * annotates its parameter with the app's own request type, and the options take that type.
24
32
  */
25
33
 
34
+ /**
35
+ * What `createImportRouter` returns: an Express router, described by what an app does with it.
36
+ * It is middleware, so `app.use('/import', router)` takes it, and it has the four routes on it.
37
+ * Nothing here is typed by `@types/express`, so any copy of it, or none, works.
38
+ */
39
+ interface ImportRouter<TRequest extends ImportRequest = ImportRequest> extends ImportRouter$1<TRequest> {
40
+ (request: IncomingMessage, response: ServerResponse, next: (error?: unknown) => void): void;
41
+ }
26
42
  /** What the consumer does with a batch of records. Called once per batch, and awaited. */
27
- type OnImport = (records: Record<string, unknown>[], context: BatchInfo & {
43
+ type OnImport<TRequest extends ImportRequest = ImportRequest> = (records: Record<string, unknown>[], context: BatchInfo & {
28
44
  entity: string;
29
45
  config: EntityFormConfig;
30
46
  /** For a tenant id, a user, a transaction — whatever the consumer's writer needs. */
31
- request: Request;
47
+ request: TRequest;
32
48
  }) => void | Promise<void>;
33
49
  /**
34
50
  * Where the configs come from.
@@ -37,9 +53,13 @@ type OnImport = (records: Record<string, unknown>[], context: BatchInfo & {
37
53
  * need a restart to add an entity. Returning `undefined` is how a loader says "no such
38
54
  * entity"; throwing is how it says something went wrong.
39
55
  */
40
- type ConfigSource = Record<string, EntityFormConfig> | ((entity: string, request: Request) => EntityFormConfig | undefined | Promise<EntityFormConfig | undefined>);
41
- interface ImportRouterOptions {
42
- configs: ConfigSource;
56
+ type ConfigSource<TRequest extends ImportRequest = ImportRequest> = Record<string, EntityFormConfig> | ((entity: string, request: TRequest) => EntityFormConfig | undefined | Promise<EntityFormConfig | undefined>);
57
+ /**
58
+ * `TRequest` is the request the callbacks below are handed: `ImportRequest` unless a callback
59
+ * annotates its own, such as `(entity, request: express.Request & { tenant: string }) => ...`.
60
+ */
61
+ interface ImportRouterOptions<TRequest extends ImportRequest = ImportRequest> {
62
+ configs: ConfigSource<TRequest>;
43
63
  /** Rules per entity. Without them an import checks validators only, which the form does not. */
44
64
  rules?: Record<string, readonly FormRule[]>;
45
65
  /** Values for every `listName` any config mentions. A server has no `LOOKUP_REGISTRY`. */
@@ -58,10 +78,10 @@ interface ImportRouterOptions {
58
78
  * send the same file again. This package does not deduplicate: the config has no natural-key
59
79
  * concept to deduplicate on. See SECURITY.md.
60
80
  */
61
- onImport: OnImport;
81
+ onImport: OnImport<TRequest>;
62
82
  limits?: Partial<ImportLimits>;
63
83
  /** Which language error text is produced in. Default `en`. */
64
- lang?: string | ((request: Request) => string);
84
+ lang?: string | ((request: TRequest) => string);
65
85
  }
66
86
  /**
67
87
  * Every `listName` the importable columns of a config mention.
@@ -90,6 +110,6 @@ declare const templateFilename: typeof templateFilename$1;
90
110
  * looks like a good one, which is the worst of the available outcomes.
91
111
  */
92
112
  declare const sendFailure: typeof sendFailure$1;
93
- declare function createImportRouter(options: ImportRouterOptions): Router;
113
+ declare function createImportRouter<TRequest extends ImportRequest = ImportRequest>(options: ImportRouterOptions<TRequest>): ImportRouter<TRequest>;
94
114
 
95
- export { type ConfigSource, type ImportRouterOptions, type OnImport, createImportRouter, listNamesOf, sendFailure, templateFilename };
115
+ export { type ConfigSource, type ImportRouter, type ImportRouterOptions, type OnImport, createImportRouter, listNamesOf, sendFailure, templateFilename };
package/express.js CHANGED
@@ -73,7 +73,8 @@ function createImportRouter(options) {
73
73
  plan: (plan) => core.planForConfig(plan, config)
74
74
  };
75
75
  };
76
- return node.createImportRouter(express__default.default.Router(), {
76
+ const router = express__default.default.Router();
77
+ return node.createImportRouter(router, {
77
78
  bindings: bindingFor,
78
79
  param: "entity",
79
80
  // What a Dynamic Entity client compares with its own `CORE_VERSION`, as it always has.
package/express.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/run-import.ts","../src/write-template.ts","../src/express.ts"],"names":["arrayBoundFor","deriveImportColumns","suggestMapping","importerTemplateFilename","sendImportFailure","ImportError","entityFormConfigAdapter","buildTemplateSpec","planForConfig","createRouterOverAdapters","express","CORE_VERSION"],"mappings":"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","file":"express.js","sourcesContent":["/**\n * run-import.ts — a file in, records out, with memory bounded by `batchSize` and not by the file.\n *\n * Since 3.0 the runner is sheet-to-schema's (`sheet-to-schema/node`), which takes a schema\n * adapter. These are Dynamic Entity's signatures over it: a config, its rules and its lookups\n * in, and the same result out. The config becomes `entityFormConfigAdapter`, which is also\n * what a browser import runs on, so the two cannot disagree about what a file produces.\n *\n * Nothing here decides anything core already decides, and nothing here reads a row.\n */\n\nimport {\n arrayBoundFor,\n deriveImportColumns,\n entityFormConfigAdapter,\n planForConfig,\n suggestMapping,\n type AnyMappingPlan,\n type ConfigProblem,\n type CsvDelimiter,\n type DateOrder,\n type EntityFormConfig,\n type FormRule,\n type ImportLookups,\n type ImportRowError,\n type MappingPlan,\n} from '@dynamic-entity/core';\nimport {\n previewSheet as previewOverAdapter,\n runImport as runOverAdapter,\n type BatchInfo as ImporterBatchInfo,\n type OnBatch as ImporterOnBatch,\n} from 'sheet-to-schema/node';\nimport type { ByteSource } from './bytes';\nimport type { ImportLimits } from './limits';\nimport type { SheetFormat } from './read-sheet';\n\n/** Where a batch sits in the user's spreadsheet, for a consumer that logs or reports progress. */\nexport type BatchInfo = ImporterBatchInfo;\n\n/**\n * Write a batch of records.\n *\n * **It is awaited, and that is load-bearing.** A consumer writing to a database is slower than\n * a parser reading a file; not awaiting would turn bounded memory into an unbounded queue of\n * pending writes — the exact failure this package exists to avoid, reached from the other\n * direction.\n *\n * **It must be idempotent.** A stream that fails at row 30,000 has already written 29,999\n * records, and an HTTP client — or a proxy, or a user — will retry. See SECURITY.md.\n */\nexport type OnBatch = ImporterOnBatch;\n\nexport interface RunImportOptions {\n stream: ByteSource;\n /** Display only. The format is decided by the file's first bytes, never by its name. */\n filename?: string;\n /** In either shape: plan v2, or a stored 2.x plan. */\n plan: AnyMappingPlan;\n config: EntityFormConfig;\n rules?: readonly FormRule[];\n /** Values for every `listName` the config mentions. A server has no `LOOKUP_REGISTRY`. */\n lookups?: ImportLookups;\n lang?: string;\n /**\n * The decimal mark in number cells. Defaults to `,` for a CSV that uses `;` between fields,\n * because that is what Excel writes in exactly those locales, and `.` otherwise.\n */\n decimal?: '.' | ',';\n /**\n * The order of a date written in digits, such as `05/09/2026`, for every column whose plan\n * entry states none. Without either, one that reads two ways fails its row: it is never\n * guessed. `MDY` reads it month first, as every release before 2.5.0 did without saying so.\n */\n dateOrder?: DateOrder;\n /** Omit to validate without writing: the identical pipeline, every error, nothing stored. */\n onBatch?: OnBatch;\n limits?: Partial<ImportLimits>;\n}\n\nexport interface ImportRunResult {\n /**\n * Records the run produced — and therefore, when `onBatch` was supplied, how many were\n * handed over to be written. The records themselves are deliberately not here: returning\n * fifty thousand of them would undo the streaming this whole file is for.\n */\n imported: number;\n /** Rows that held no values at all. A blank line is not an error and is not a record. */\n skipped: number;\n /**\n * Rows that produced at least one error, counted exactly — **not** derived from `errors`,\n * which is capped: the distinct rows in it say how many fitted in the cap, not how many\n * failed. The number a user acts on is \"which rows do I fix\", so it is the exact one.\n */\n failed: number;\n /** A **sample**, capped at `limits.maxReportedErrors`. `errorCount` is the true total. */\n errors: ImportRowError[];\n /** Every problem found, whether or not it was retained. */\n errorCount: number;\n /** `errors` is shorter than `errorCount`. */\n truncated: boolean;\n /** What is wrong with the plan itself. An `error` here means no row was read at all. */\n planProblems: ConfigProblem[];\n /** Data rows read from the sheet, blank ones included. */\n rowsRead: number;\n /**\n * What the file turned out to be — **absent when no byte of it was ever read**. A guessed\n * field is worse than a missing one: it is indistinguishable from a measured one.\n */\n format?: SheetFormat;\n /** Since 3.0: sheet rows the imported records came from. Equal to `imported` while no plan groups rows. */\n rowsInImported?: number;\n /** Since 3.0: a sample of the warnings, capped like `errors`. A record with a warning was imported. */\n warnings?: ImportRowError[];\n /** Since 3.0: every warning found, whether or not it was retained. */\n warningCount?: number;\n}\n\n/**\n * Run an import, or with no `onBatch` a validation: every row read, every error found.\n *\n * The plan is checked before a single row is read. A plan that groups rows is refused with\n * `INVALID_PLAN`: the streaming runner does not group yet, and one that ignored `group` would\n * import a record per row and report success. The browser import does group.\n */\nexport function runImport(options: RunImportOptions): Promise<ImportRunResult> {\n const { config } = options;\n return runOverAdapter({\n stream: options.stream,\n filename: options.filename,\n // A 2.x plan that names no entity is read against this config, as 2.x read it.\n plan: planForConfig(options.plan, config),\n adapter: entityFormConfigAdapter(config, { rules: options.rules, lookups: options.lookups }),\n ctx: { lang: options.lang },\n decimal: options.decimal,\n dateOrder: options.dateOrder,\n onBatch: options.onBatch,\n limits: options.limits,\n });\n}\n\n// ─── Preview ──────────────────────────────────────────────────────────────────\n\nexport interface PreviewSheetOptions {\n stream: ByteSource;\n filename?: string;\n config: EntityFormConfig;\n lang?: string;\n limits?: Partial<ImportLimits>;\n}\n\n/** What a first look at an uploaded file yields. Matches the renderer's `ImportPreview`. */\nexport interface SheetPreview {\n headers: string[];\n /** The first few rows as text, for the mapping screen to show under each column. */\n sample: string[][];\n /** Every inferred match is tagged `guess`. Plan v2 since 3.0. */\n suggestion: MappingPlan;\n /** Data rows in the file, which the whole stream is read to count. */\n rowCount: number;\n format: SheetFormat;\n /** The field separator a CSV uses. Absent for a workbook. */\n delimiter?: CsvDelimiter;\n /** Rows of each repeating field the suggestion was sized for — see `arrayBoundFor`. */\n arrayBound: number;\n}\n\n/**\n * The suggestion for a sheet's headers, and the rows of each repeating field it was sized for.\n *\n * **The same derivation the browser transport uses**, so the two suggest the same mapping for\n * the same file: sized from the headers, so a sheet with `Phone 6 Number` is offered six\n * rows, and without readonly and system-default fields, because nobody maps a column onto a\n * field the form fills in itself.\n */\nexport function suggestionFor(\n headers: readonly string[],\n config: EntityFormConfig,\n lang?: string,\n): { suggestion: MappingPlan; arrayBound: number } {\n const arrayBound = arrayBoundFor(headers, config, null, lang);\n const { columns } = deriveImportColumns(config, { lang, maxArrayRows: arrayBound });\n return { suggestion: suggestMapping(headers, columns, config?.entity ?? ''), arrayBound };\n}\n\n/**\n * Headers, a sample, and a suggested mapping.\n *\n * The whole file is read, because `rowCount` is a number the user is shown before they commit\n * and \"about this many\" is not a useful thing to tell someone about their own data. Reading it\n * also means every limit applies to a preview exactly as it applies to an import — a preview\n * endpoint that skipped them would be the cheapest way in.\n */\nexport async function previewSheet(options: PreviewSheetOptions): Promise<SheetPreview> {\n const preview = await previewOverAdapter({\n stream: options.stream,\n filename: options.filename,\n adapter: entityFormConfigAdapter(options.config, { includeReadonly: false, includeSystemDefault: false }),\n lang: options.lang,\n limits: options.limits,\n });\n return {\n headers: preview.headers,\n sample: preview.sample,\n rowCount: preview.rowCount,\n format: preview.format,\n ...(preview.delimiter ? { delimiter: preview.delimiter } : {}),\n ...suggestionFor(preview.headers, options.config, options.lang),\n };\n}\n","/**\n * write-template.ts — a template a user downloads, fills in, and uploads again.\n *\n * Since 3.0 the writer is sheet-to-schema's, which takes a sheet name, headers and notes. This\n * is Dynamic Entity's signature over it: a `TemplateSpec`, as `buildTemplateSpec` makes one.\n * The bytes are the same bytes.\n */\n\nimport type { TemplateSpec } from '@dynamic-entity/core';\nimport type { Writable } from 'node:stream';\nimport {\n TEMPLATE_EXTENSION as IMPORTER_TEMPLATE_EXTENSION,\n TEMPLATE_MEDIA_TYPE as IMPORTER_TEMPLATE_MEDIA_TYPE,\n writeTemplate as writeSheetTemplate,\n type TemplateFormat as ImporterTemplateFormat,\n type TemplateSheet,\n} from 'sheet-to-schema/node';\nimport type { ImportLimits } from './limits';\n\n/** @deprecated Import `TemplateFormat` from `sheet-to-schema/node`. Removed in 4.0. */\nexport type TemplateFormat = ImporterTemplateFormat;\n/** @deprecated Import `TEMPLATE_EXTENSION` from `sheet-to-schema/node`. Removed in 4.0. */\nexport const TEMPLATE_EXTENSION = IMPORTER_TEMPLATE_EXTENSION;\n/** @deprecated Import `TEMPLATE_MEDIA_TYPE` from `sheet-to-schema/node`. Removed in 4.0. */\nexport const TEMPLATE_MEDIA_TYPE = IMPORTER_TEMPLATE_MEDIA_TYPE;\n\nexport interface WriteTemplateOptions {\n spec: TemplateSpec;\n format: TemplateFormat;\n /** Where the bytes go. An HTTP response, a file stream, anything writable. */\n out: Writable;\n limits?: Partial<ImportLimits>;\n}\n\n/** A `TemplateSpec` as the sheet the writer takes: its name, its headers, a note per header. */\nexport function templateSheetOf(spec: TemplateSpec): TemplateSheet {\n return { sheetName: spec.sheetName, headers: spec.columns.map(column => column.header), notes: spec.notes };\n}\n\n/**\n * Write a blank template: the header row, and for a workbook a note on each header cell.\n *\n * A CSV carries no notes, because it has nowhere to put them. Either way the file holds\n * headers and no formulas, and a header that would run as one is neutralised.\n */\nexport function writeTemplate(options: WriteTemplateOptions): Promise<void> {\n return writeSheetTemplate({ sheet: templateSheetOf(options.spec), format: options.format, out: options.out, limits: options.limits });\n}\n","/**\n * express.ts — four routes, and no privileges.\n *\n * Since 3.0 the routes are sheet-to-schema's (`createImportRouter` in `sheet-to-schema/node`),\n * which resolves a schema adapter by the name in the path. This is that router with a config\n * behind each name: it finds the config, checks its lookups were supplied, turns it into\n * `entityFormConfigAdapter` with the entity's rules, and answers a preview with the same\n * suggestion the browser makes. Reading the upload, the limits, the timeouts and the failure\n * envelope are the router's own, and are the code this file had until 3.0.\n *\n * **Not this router's job, said out loud.** Concurrency limiting, authentication and\n * authorization are the consumer's. The router is mounted inside their app, behind their\n * middleware, and nothing here checks who is asking. SECURITY.md says so rather than leaving\n * a reader to assume the router protects them.\n *\n * This module is published as `@dynamic-entity/server/express` rather than from the package\n * root, so that it — and its `express` import — is reached only by someone who wants it. A\n * consumer wiring Fastify imports the root and never pays for a framework they do not use.\n */\n\nimport {\n buildTemplateSpec,\n CORE_VERSION,\n deriveImportColumns,\n entityFormConfigAdapter,\n planForConfig,\n type DateOrder,\n type EntityFormConfig,\n type FormRule,\n type ImportLookups,\n} from '@dynamic-entity/core';\nimport express, { type Request, type Router } from 'express';\nimport {\n createImportRouter as createRouterOverAdapters,\n ImportError,\n sendFailure as sendImportFailure,\n templateFilename as importerTemplateFilename,\n type ImportBinding,\n} from 'sheet-to-schema/node';\nimport type { ImportLimits } from './limits';\nimport { suggestionFor, type BatchInfo } from './run-import';\nimport { templateSheetOf } from './write-template';\n\n/** What the consumer does with a batch of records. Called once per batch, and awaited. */\nexport type OnImport = (\n records: Record<string, unknown>[],\n context: BatchInfo & {\n entity: string;\n config: EntityFormConfig;\n /** For a tenant id, a user, a transaction — whatever the consumer's writer needs. */\n request: Request;\n },\n) => void | Promise<void>;\n\n/**\n * Where the configs come from.\n *\n * A map **or** a function, because a real app keeps its configs in a database and should not\n * need a restart to add an entity. Returning `undefined` is how a loader says \"no such\n * entity\"; throwing is how it says something went wrong.\n */\nexport type ConfigSource =\n | Record<string, EntityFormConfig>\n | ((entity: string, request: Request) => EntityFormConfig | undefined | Promise<EntityFormConfig | undefined>);\n\nexport interface ImportRouterOptions {\n configs: ConfigSource;\n /** Rules per entity. Without them an import checks validators only, which the form does not. */\n rules?: Record<string, readonly FormRule[]>;\n /** Values for every `listName` any config mentions. A server has no `LOOKUP_REGISTRY`. */\n lookups?: ImportLookups;\n /**\n * The order of a date written in digits, for every import and validation this router runs:\n * `DMY` for day first, `MDY` for month first. Without it, a date that reads two ways\n * (`05/09/2026`) fails its row. See `RunImportOptions.dateOrder`.\n */\n dateOrder?: DateOrder;\n /**\n * Where records go.\n *\n * **It must be idempotent.** A stream that fails at row 30,000 has written 29,999 records,\n * and over HTTP a retry is likely rather than possible — a client, a proxy or a user will\n * send the same file again. This package does not deduplicate: the config has no natural-key\n * concept to deduplicate on. See SECURITY.md.\n */\n onImport: OnImport;\n limits?: Partial<ImportLimits>;\n /** Which language error text is produced in. Default `en`. */\n lang?: string | ((request: Request) => string);\n}\n\n/**\n * Every `listName` the importable columns of a config mention.\n *\n * Derived from the same column walk the import uses, so the set checked is exactly the set\n * that will be asked for — a field the sheet cannot carry cannot need a list.\n */\nexport function listNamesOf(config: EntityFormConfig): string[] {\n const names = new Set<string>();\n const { columns } = deriveImportColumns(config, {\n includeReadonly: true,\n includeSystemDefault: true,\n });\n for (const column of columns) {\n if (column.field.listName) names.add(column.field.listName);\n }\n return [...names];\n}\n\n/**\n * Refuse to start rather than store the wrong value at row 30,000.\n *\n * The browser resolves a `listName` field from `LOOKUP_REGISTRY`; a server has no such\n * registry, so an unsupplied list means `coerceCell` finds no options to match against and\n * passes the raw text straight through. The record then looks right and compares wrong against\n * every rule that names the option.\n *\n * So it is not left to vigilance. A silent wrong value found in a database next quarter is the\n * expensive failure; an app that will not boot is the cheap one.\n */\nfunction checkLookups(entity: string, config: EntityFormConfig, lookups: ImportLookups | undefined): void {\n const missing = listNamesOf(config).filter(name => !lookups?.[name]);\n if (!missing.length) return;\n\n throw new Error(\n `@dynamic-entity/server: config \"${entity}\" has fields resolving against ` +\n `${missing.map(name => `\"${name}\"`).join(', ')}, and no such list was supplied.\\n` +\n 'Pass them as `lookups` to createImportRouter. Without them those columns would store ' +\n 'the raw cell text, which renders correctly and matches nothing.',\n );\n}\n\n/** A map's own entry for `key`, never one inherited from `Object.prototype`. */\nfunction ownValue<T>(map: Readonly<Record<string, T>>, key: string): T | undefined {\n return Object.hasOwn(map, key) ? map[key] : undefined;\n}\n\n/**\n * A download filename derived from the config, never from the client.\n *\n * A filename that reaches a `Content-Disposition` having come from a request is header\n * injection with extra steps: a newline in it ends the header and starts another. This takes\n * the entity and keeps only characters that cannot mean anything in a header.\n *\n * The caller passes `config.entity`, not the route segment. They are usually the same string,\n * and when they are not — a consumer mounting a plural route, `{ employees: employeeConfig }` —\n * the config is the one that names the data.\n */\nexport const templateFilename = importerTemplateFilename;\n\n/**\n * Send whatever went wrong as the envelope, unless the response has already started.\n *\n * A template that failed part-way is already partly on the wire, and the only honest thing\n * left is to end the connection abruptly. A 200 with a truncated body is a corrupt file that\n * looks like a good one, which is the worst of the available outcomes.\n */\nexport const sendFailure = sendImportFailure;\n\n/** The config a binding was made from, for the writer, which is handed the config and not the binding. */\ntype ConfigBinding = ImportBinding & { config: EntityFormConfig };\n\nexport function createImportRouter(options: ImportRouterOptions): Router {\n // At construction, not at first use: a map is knowable now, and an app that refuses to start\n // is cheaper than a column that silently stored the wrong thing.\n if (typeof options.configs !== 'function') {\n for (const [entity, config] of Object.entries(options.configs)) {\n checkLookups(entity, config, options.lookups);\n }\n }\n\n const languageOf = (request: Request): string =>\n typeof options.lang === 'function' ? options.lang(request) : options.lang ?? 'en';\n\n /**\n * Resolve `:entity` to what it imports into, or to nothing, which the router answers 404\n * without echoing what was asked for.\n *\n * The key is a lookup into a map the consumer supplied — never a path segment, never\n * interpolated into anything. An own-property check, not `configs[entity]`: `constructor`,\n * `__proto__` or `toString` would otherwise resolve to a built-in and run an import against it.\n */\n const bindingFor = async (entity: string, request: Request): Promise<ConfigBinding | undefined> => {\n const config =\n typeof options.configs === 'function' ? await options.configs(entity, request) : ownValue(options.configs, entity);\n if (!config) throw new ImportError('UNKNOWN_ENTITY', 'No import is configured for that entity.');\n // A loader can return a config the router never saw at construction, so the boot-time\n // check gets a second chance here — before a row is read, which is the point of it.\n if (typeof options.configs === 'function') checkLookups(entity, config, options.lookups);\n\n const lang = languageOf(request);\n const adapter = entityFormConfigAdapter(config, {\n rules: options.rules && ownValue(options.rules, entity),\n lookups: options.lookups,\n });\n return {\n adapter: adapter as ImportBinding['adapter'],\n ctx: { lang },\n kind: 'dynamic-entity',\n config,\n // Named from the config. The route segment only when the config names nothing.\n templateName: config.entity || entity,\n template: ({ lang: language, fields }) => templateSheetOf(buildTemplateSpec(config, { lang: language, fields })),\n // What the browser suggests for the same headers, and the one number it was sized with.\n previewExtras: preview => suggestionFor(preview.headers, config, lang),\n // A 2.x plan that names no entity is read against this config, as 2.x read it.\n plan: plan => planForConfig(plan, config),\n };\n };\n\n return createRouterOverAdapters(express.Router(), {\n bindings: bindingFor,\n param: 'entity',\n // What a Dynamic Entity client compares with its own `CORE_VERSION`, as it always has.\n engineVersion: CORE_VERSION,\n dateOrder: options.dateOrder,\n limits: options.limits,\n lang: options.lang,\n onImport: (records, { index, firstRow, imported, name, binding, request }) =>\n options.onImport(records, { index, firstRow, imported, entity: name, config: (binding as ConfigBinding).config, request }),\n });\n}\n"]}
1
+ {"version":3,"sources":["../src/run-import.ts","../src/write-template.ts","../src/express.ts"],"names":["arrayBoundFor","deriveImportColumns","suggestMapping","importerTemplateFilename","sendImportFailure","ImportError","entityFormConfigAdapter","buildTemplateSpec","planForConfig","express","createRouterOverAdapters","CORE_VERSION"],"mappings":"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","file":"express.js","sourcesContent":["/**\n * run-import.ts — a file in, records out, with memory bounded by `batchSize` and not by the file.\n *\n * Since 3.0 the runner is sheet-to-schema's (`sheet-to-schema/node`), which takes a schema\n * adapter. These are Dynamic Entity's signatures over it: a config, its rules and its lookups\n * in, and the same result out. The config becomes `entityFormConfigAdapter`, which is also\n * what a browser import runs on, so the two cannot disagree about what a file produces.\n *\n * Nothing here decides anything core already decides, and nothing here reads a row.\n */\n\nimport {\n arrayBoundFor,\n deriveImportColumns,\n entityFormConfigAdapter,\n planForConfig,\n suggestMapping,\n type AnyMappingPlan,\n type ConfigProblem,\n type CsvDelimiter,\n type DateOrder,\n type EntityFormConfig,\n type FormRule,\n type ImportLookups,\n type ImportRowError,\n type MappingPlan,\n} from '@dynamic-entity/core';\nimport {\n previewSheet as previewOverAdapter,\n runImport as runOverAdapter,\n type BatchInfo as ImporterBatchInfo,\n type OnBatch as ImporterOnBatch,\n} from 'sheet-to-schema/node';\nimport type { ByteSource } from './bytes';\nimport type { ImportLimits } from './limits';\nimport type { SheetFormat } from './read-sheet';\n\n/** Where a batch sits in the user's spreadsheet, for a consumer that logs or reports progress. */\nexport type BatchInfo = ImporterBatchInfo;\n\n/**\n * Write a batch of records.\n *\n * **It is awaited, and that is load-bearing.** A consumer writing to a database is slower than\n * a parser reading a file; not awaiting would turn bounded memory into an unbounded queue of\n * pending writes — the exact failure this package exists to avoid, reached from the other\n * direction.\n *\n * **It must be idempotent.** A stream that fails at row 30,000 has already written 29,999\n * records, and an HTTP client — or a proxy, or a user — will retry. See SECURITY.md.\n */\nexport type OnBatch = ImporterOnBatch;\n\nexport interface RunImportOptions {\n stream: ByteSource;\n /** Display only. The format is decided by the file's first bytes, never by its name. */\n filename?: string;\n /** In either shape: plan v2, or a stored 2.x plan. */\n plan: AnyMappingPlan;\n config: EntityFormConfig;\n rules?: readonly FormRule[];\n /** Values for every `listName` the config mentions. A server has no `LOOKUP_REGISTRY`. */\n lookups?: ImportLookups;\n lang?: string;\n /**\n * The decimal mark in number cells. Defaults to `,` for a CSV that uses `;` between fields,\n * because that is what Excel writes in exactly those locales, and `.` otherwise.\n */\n decimal?: '.' | ',';\n /**\n * The order of a date written in digits, such as `05/09/2026`, for every column whose plan\n * entry states none. Without either, one that reads two ways fails its row: it is never\n * guessed. `MDY` reads it month first, as every release before 2.5.0 did without saying so.\n */\n dateOrder?: DateOrder;\n /** Omit to validate without writing: the identical pipeline, every error, nothing stored. */\n onBatch?: OnBatch;\n limits?: Partial<ImportLimits>;\n}\n\nexport interface ImportRunResult {\n /**\n * Records the run produced — and therefore, when `onBatch` was supplied, how many were\n * handed over to be written. The records themselves are deliberately not here: returning\n * fifty thousand of them would undo the streaming this whole file is for.\n */\n imported: number;\n /** Rows that held no values at all. A blank line is not an error and is not a record. */\n skipped: number;\n /**\n * Rows that produced at least one error, counted exactly — **not** derived from `errors`,\n * which is capped: the distinct rows in it say how many fitted in the cap, not how many\n * failed. The number a user acts on is \"which rows do I fix\", so it is the exact one.\n */\n failed: number;\n /** A **sample**, capped at `limits.maxReportedErrors`. `errorCount` is the true total. */\n errors: ImportRowError[];\n /** Every problem found, whether or not it was retained. */\n errorCount: number;\n /** `errors` is shorter than `errorCount`. */\n truncated: boolean;\n /** What is wrong with the plan itself. An `error` here means no row was read at all. */\n planProblems: ConfigProblem[];\n /** Data rows read from the sheet, blank ones included. */\n rowsRead: number;\n /**\n * What the file turned out to be — **absent when no byte of it was ever read**. A guessed\n * field is worse than a missing one: it is indistinguishable from a measured one.\n */\n format?: SheetFormat;\n /** Since 3.0: sheet rows the imported records came from. Equal to `imported` while no plan groups rows. */\n rowsInImported?: number;\n /** Since 3.0: a sample of the warnings, capped like `errors`. A record with a warning was imported. */\n warnings?: ImportRowError[];\n /** Since 3.0: every warning found, whether or not it was retained. */\n warningCount?: number;\n}\n\n/**\n * Run an import, or with no `onBatch` a validation: every row read, every error found.\n *\n * The plan is checked before a single row is read. A plan that groups rows is refused with\n * `INVALID_PLAN`: the streaming runner does not group yet, and one that ignored `group` would\n * import a record per row and report success. The browser import does group.\n */\nexport function runImport(options: RunImportOptions): Promise<ImportRunResult> {\n const { config } = options;\n return runOverAdapter({\n stream: options.stream,\n filename: options.filename,\n // A 2.x plan that names no entity is read against this config, as 2.x read it.\n plan: planForConfig(options.plan, config),\n adapter: entityFormConfigAdapter(config, { rules: options.rules, lookups: options.lookups }),\n ctx: { lang: options.lang },\n decimal: options.decimal,\n dateOrder: options.dateOrder,\n onBatch: options.onBatch,\n limits: options.limits,\n });\n}\n\n// ─── Preview ──────────────────────────────────────────────────────────────────\n\nexport interface PreviewSheetOptions {\n stream: ByteSource;\n filename?: string;\n config: EntityFormConfig;\n lang?: string;\n limits?: Partial<ImportLimits>;\n}\n\n/** What a first look at an uploaded file yields. Matches the renderer's `ImportPreview`. */\nexport interface SheetPreview {\n headers: string[];\n /** The first few rows as text, for the mapping screen to show under each column. */\n sample: string[][];\n /** Every inferred match is tagged `guess`. Plan v2 since 3.0. */\n suggestion: MappingPlan;\n /** Data rows in the file, which the whole stream is read to count. */\n rowCount: number;\n format: SheetFormat;\n /** The field separator a CSV uses. Absent for a workbook. */\n delimiter?: CsvDelimiter;\n /** Rows of each repeating field the suggestion was sized for — see `arrayBoundFor`. */\n arrayBound: number;\n}\n\n/**\n * The suggestion for a sheet's headers, and the rows of each repeating field it was sized for.\n *\n * **The same derivation the browser transport uses**, so the two suggest the same mapping for\n * the same file: sized from the headers, so a sheet with `Phone 6 Number` is offered six\n * rows, and without readonly and system-default fields, because nobody maps a column onto a\n * field the form fills in itself.\n */\nexport function suggestionFor(\n headers: readonly string[],\n config: EntityFormConfig,\n lang?: string,\n): { suggestion: MappingPlan; arrayBound: number } {\n const arrayBound = arrayBoundFor(headers, config, null, lang);\n const { columns } = deriveImportColumns(config, { lang, maxArrayRows: arrayBound });\n return { suggestion: suggestMapping(headers, columns, config?.entity ?? ''), arrayBound };\n}\n\n/**\n * Headers, a sample, and a suggested mapping.\n *\n * The whole file is read, because `rowCount` is a number the user is shown before they commit\n * and \"about this many\" is not a useful thing to tell someone about their own data. Reading it\n * also means every limit applies to a preview exactly as it applies to an import — a preview\n * endpoint that skipped them would be the cheapest way in.\n */\nexport async function previewSheet(options: PreviewSheetOptions): Promise<SheetPreview> {\n const preview = await previewOverAdapter({\n stream: options.stream,\n filename: options.filename,\n adapter: entityFormConfigAdapter(options.config, { includeReadonly: false, includeSystemDefault: false }),\n lang: options.lang,\n limits: options.limits,\n });\n return {\n headers: preview.headers,\n sample: preview.sample,\n rowCount: preview.rowCount,\n format: preview.format,\n ...(preview.delimiter ? { delimiter: preview.delimiter } : {}),\n ...suggestionFor(preview.headers, options.config, options.lang),\n };\n}\n","/**\n * write-template.ts — a template a user downloads, fills in, and uploads again.\n *\n * Since 3.0 the writer is sheet-to-schema's, which takes a sheet name, headers and notes. This\n * is Dynamic Entity's signature over it: a `TemplateSpec`, as `buildTemplateSpec` makes one.\n * The bytes are the same bytes.\n */\n\nimport type { TemplateSpec } from '@dynamic-entity/core';\nimport type { Writable } from 'node:stream';\nimport {\n TEMPLATE_EXTENSION as IMPORTER_TEMPLATE_EXTENSION,\n TEMPLATE_MEDIA_TYPE as IMPORTER_TEMPLATE_MEDIA_TYPE,\n writeTemplate as writeSheetTemplate,\n type TemplateFormat as ImporterTemplateFormat,\n type TemplateSheet,\n} from 'sheet-to-schema/node';\nimport type { ImportLimits } from './limits';\n\n/** @deprecated Import `TemplateFormat` from `sheet-to-schema/node`. Removed in 4.0. */\nexport type TemplateFormat = ImporterTemplateFormat;\n/** @deprecated Import `TEMPLATE_EXTENSION` from `sheet-to-schema/node`. Removed in 4.0. */\nexport const TEMPLATE_EXTENSION = IMPORTER_TEMPLATE_EXTENSION;\n/** @deprecated Import `TEMPLATE_MEDIA_TYPE` from `sheet-to-schema/node`. Removed in 4.0. */\nexport const TEMPLATE_MEDIA_TYPE = IMPORTER_TEMPLATE_MEDIA_TYPE;\n\nexport interface WriteTemplateOptions {\n spec: TemplateSpec;\n format: TemplateFormat;\n /** Where the bytes go. An HTTP response, a file stream, anything writable. */\n out: Writable;\n limits?: Partial<ImportLimits>;\n}\n\n/** A `TemplateSpec` as the sheet the writer takes: its name, its headers, a note per header. */\nexport function templateSheetOf(spec: TemplateSpec): TemplateSheet {\n return { sheetName: spec.sheetName, headers: spec.columns.map(column => column.header), notes: spec.notes };\n}\n\n/**\n * Write a blank template: the header row, and for a workbook a note on each header cell.\n *\n * A CSV carries no notes, because it has nowhere to put them. Either way the file holds\n * headers and no formulas, and a header that would run as one is neutralised.\n */\nexport function writeTemplate(options: WriteTemplateOptions): Promise<void> {\n return writeSheetTemplate({ sheet: templateSheetOf(options.spec), format: options.format, out: options.out, limits: options.limits });\n}\n","/**\n * express.ts — four routes, and no privileges.\n *\n * Since 3.0 the routes are sheet-to-schema's (`createImportRouter` in `sheet-to-schema/node`),\n * which resolves a schema adapter by the name in the path. This is that router with a config\n * behind each name: it finds the config, checks its lookups were supplied, turns it into\n * `entityFormConfigAdapter` with the entity's rules, and answers a preview with the same\n * suggestion the browser makes. Reading the upload, the limits, the timeouts and the failure\n * envelope are the router's own, and are the code this file had until 3.0.\n *\n * **Not this router's job, said out loud.** Concurrency limiting, authentication and\n * authorization are the consumer's. The router is mounted inside their app, behind their\n * middleware, and nothing here checks who is asking. SECURITY.md says so rather than leaving\n * a reader to assume the router protects them.\n *\n * This module is published as `@dynamic-entity/server/express` rather than from the package\n * root, so that it — and its `express` import — is reached only by someone who wants it. A\n * consumer wiring Fastify imports the root and never pays for a framework they do not use.\n *\n * **Its typings name nothing from `express`.** The request, the response and the router are\n * sheet-to-schema's structural types, which an Express 4 or 5 request, response and router\n * satisfy as they are. So a TypeScript app needs no particular `@types/express`, or none, and\n * two copies of it in one install cannot make the router's types disagree with the app's. A\n * callback that reads more of the request than `ImportRequest` has (a user, a tenant)\n * annotates its parameter with the app's own request type, and the options take that type.\n */\n\nimport {\n buildTemplateSpec,\n CORE_VERSION,\n deriveImportColumns,\n entityFormConfigAdapter,\n planForConfig,\n type DateOrder,\n type EntityFormConfig,\n type FormRule,\n type ImportLookups,\n} from '@dynamic-entity/core';\nimport express from 'express';\nimport type { IncomingMessage, ServerResponse } from 'node:http';\nimport {\n createImportRouter as createRouterOverAdapters,\n ImportError,\n sendFailure as sendImportFailure,\n templateFilename as importerTemplateFilename,\n type ImportBinding,\n type ImportRequest,\n type ImportRouter as RouteTable,\n} from 'sheet-to-schema/node';\nimport type { ImportLimits } from './limits';\nimport { suggestionFor, type BatchInfo } from './run-import';\nimport { templateSheetOf } from './write-template';\n\nexport type { ImportRequest, ImportResponse } from 'sheet-to-schema/node';\n\n/**\n * What `createImportRouter` returns: an Express router, described by what an app does with it.\n * It is middleware, so `app.use('/import', router)` takes it, and it has the four routes on it.\n * Nothing here is typed by `@types/express`, so any copy of it, or none, works.\n */\nexport interface ImportRouter<TRequest extends ImportRequest = ImportRequest> extends RouteTable<TRequest> {\n (request: IncomingMessage, response: ServerResponse, next: (error?: unknown) => void): void;\n}\n\n/** What the consumer does with a batch of records. Called once per batch, and awaited. */\nexport type OnImport<TRequest extends ImportRequest = ImportRequest> = (\n records: Record<string, unknown>[],\n context: BatchInfo & {\n entity: string;\n config: EntityFormConfig;\n /** For a tenant id, a user, a transaction — whatever the consumer's writer needs. */\n request: TRequest;\n },\n) => void | Promise<void>;\n\n/**\n * Where the configs come from.\n *\n * A map **or** a function, because a real app keeps its configs in a database and should not\n * need a restart to add an entity. Returning `undefined` is how a loader says \"no such\n * entity\"; throwing is how it says something went wrong.\n */\nexport type ConfigSource<TRequest extends ImportRequest = ImportRequest> =\n | Record<string, EntityFormConfig>\n | ((entity: string, request: TRequest) => EntityFormConfig | undefined | Promise<EntityFormConfig | undefined>);\n\n/**\n * `TRequest` is the request the callbacks below are handed: `ImportRequest` unless a callback\n * annotates its own, such as `(entity, request: express.Request & { tenant: string }) => ...`.\n */\nexport interface ImportRouterOptions<TRequest extends ImportRequest = ImportRequest> {\n configs: ConfigSource<TRequest>;\n /** Rules per entity. Without them an import checks validators only, which the form does not. */\n rules?: Record<string, readonly FormRule[]>;\n /** Values for every `listName` any config mentions. A server has no `LOOKUP_REGISTRY`. */\n lookups?: ImportLookups;\n /**\n * The order of a date written in digits, for every import and validation this router runs:\n * `DMY` for day first, `MDY` for month first. Without it, a date that reads two ways\n * (`05/09/2026`) fails its row. See `RunImportOptions.dateOrder`.\n */\n dateOrder?: DateOrder;\n /**\n * Where records go.\n *\n * **It must be idempotent.** A stream that fails at row 30,000 has written 29,999 records,\n * and over HTTP a retry is likely rather than possible — a client, a proxy or a user will\n * send the same file again. This package does not deduplicate: the config has no natural-key\n * concept to deduplicate on. See SECURITY.md.\n */\n onImport: OnImport<TRequest>;\n limits?: Partial<ImportLimits>;\n /** Which language error text is produced in. Default `en`. */\n lang?: string | ((request: TRequest) => string);\n}\n\n/**\n * Every `listName` the importable columns of a config mention.\n *\n * Derived from the same column walk the import uses, so the set checked is exactly the set\n * that will be asked for — a field the sheet cannot carry cannot need a list.\n */\nexport function listNamesOf(config: EntityFormConfig): string[] {\n const names = new Set<string>();\n const { columns } = deriveImportColumns(config, {\n includeReadonly: true,\n includeSystemDefault: true,\n });\n for (const column of columns) {\n if (column.field.listName) names.add(column.field.listName);\n }\n return [...names];\n}\n\n/**\n * Refuse to start rather than store the wrong value at row 30,000.\n *\n * The browser resolves a `listName` field from `LOOKUP_REGISTRY`; a server has no such\n * registry, so an unsupplied list means `coerceCell` finds no options to match against and\n * passes the raw text straight through. The record then looks right and compares wrong against\n * every rule that names the option.\n *\n * So it is not left to vigilance. A silent wrong value found in a database next quarter is the\n * expensive failure; an app that will not boot is the cheap one.\n */\nfunction checkLookups(entity: string, config: EntityFormConfig, lookups: ImportLookups | undefined): void {\n const missing = listNamesOf(config).filter(name => !lookups?.[name]);\n if (!missing.length) return;\n\n throw new Error(\n `@dynamic-entity/server: config \"${entity}\" has fields resolving against ` +\n `${missing.map(name => `\"${name}\"`).join(', ')}, and no such list was supplied.\\n` +\n 'Pass them as `lookups` to createImportRouter. Without them those columns would store ' +\n 'the raw cell text, which renders correctly and matches nothing.',\n );\n}\n\n/** A map's own entry for `key`, never one inherited from `Object.prototype`. */\nfunction ownValue<T>(map: Readonly<Record<string, T>>, key: string): T | undefined {\n return Object.hasOwn(map, key) ? map[key] : undefined;\n}\n\n/**\n * A download filename derived from the config, never from the client.\n *\n * A filename that reaches a `Content-Disposition` having come from a request is header\n * injection with extra steps: a newline in it ends the header and starts another. This takes\n * the entity and keeps only characters that cannot mean anything in a header.\n *\n * The caller passes `config.entity`, not the route segment. They are usually the same string,\n * and when they are not — a consumer mounting a plural route, `{ employees: employeeConfig }` —\n * the config is the one that names the data.\n */\nexport const templateFilename = importerTemplateFilename;\n\n/**\n * Send whatever went wrong as the envelope, unless the response has already started.\n *\n * A template that failed part-way is already partly on the wire, and the only honest thing\n * left is to end the connection abruptly. A 200 with a truncated body is a corrupt file that\n * looks like a good one, which is the worst of the available outcomes.\n */\nexport const sendFailure = sendImportFailure;\n\n/** The config a binding was made from, for the writer, which is handed the config and not the binding. */\ntype ConfigBinding = ImportBinding & { config: EntityFormConfig };\n\nexport function createImportRouter<TRequest extends ImportRequest = ImportRequest>(\n options: ImportRouterOptions<TRequest>,\n): ImportRouter<TRequest> {\n // At construction, not at first use: a map is knowable now, and an app that refuses to start\n // is cheaper than a column that silently stored the wrong thing.\n if (typeof options.configs !== 'function') {\n for (const [entity, config] of Object.entries(options.configs)) {\n checkLookups(entity, config, options.lookups);\n }\n }\n\n const languageOf = (request: TRequest): string =>\n typeof options.lang === 'function' ? options.lang(request) : options.lang ?? 'en';\n\n /**\n * Resolve `:entity` to what it imports into, or to nothing, which the router answers 404\n * without echoing what was asked for.\n *\n * The key is a lookup into a map the consumer supplied — never a path segment, never\n * interpolated into anything. An own-property check, not `configs[entity]`: `constructor`,\n * `__proto__` or `toString` would otherwise resolve to a built-in and run an import against it.\n */\n const bindingFor = async (entity: string, request: TRequest): Promise<ConfigBinding | undefined> => {\n const config =\n typeof options.configs === 'function' ? await options.configs(entity, request) : ownValue(options.configs, entity);\n if (!config) throw new ImportError('UNKNOWN_ENTITY', 'No import is configured for that entity.');\n // A loader can return a config the router never saw at construction, so the boot-time\n // check gets a second chance here — before a row is read, which is the point of it.\n if (typeof options.configs === 'function') checkLookups(entity, config, options.lookups);\n\n const lang = languageOf(request);\n const adapter = entityFormConfigAdapter(config, {\n rules: options.rules && ownValue(options.rules, entity),\n lookups: options.lookups,\n });\n return {\n adapter: adapter as ImportBinding['adapter'],\n ctx: { lang },\n kind: 'dynamic-entity',\n config,\n // Named from the config. The route segment only when the config names nothing.\n templateName: config.entity || entity,\n template: ({ lang: language, fields }) => templateSheetOf(buildTemplateSpec(config, { lang: language, fields })),\n // What the browser suggests for the same headers, and the one number it was sized with.\n previewExtras: preview => suggestionFor(preview.headers, config, lang),\n // A 2.x plan that names no entity is read against this config, as 2.x read it.\n plan: plan => planForConfig(plan, config),\n };\n };\n\n // An Express router is an `ImportRouter` at run time; the cast is only to the structural type\n // the typings describe, so that they need nothing from `@types/express`.\n const router = express.Router() as unknown as ImportRouter<TRequest>;\n return createRouterOverAdapters<ImportRouter<TRequest>, TRequest>(router, {\n bindings: bindingFor,\n param: 'entity',\n // What a Dynamic Entity client compares with its own `CORE_VERSION`, as it always has.\n engineVersion: CORE_VERSION,\n dateOrder: options.dateOrder,\n limits: options.limits,\n lang: options.lang,\n onImport: (records, { index, firstRow, imported, name, binding, request }) =>\n options.onImport(records, { index, firstRow, imported, entity: name, config: (binding as ConfigBinding).config, request }),\n });\n}\n"]}
package/express.mjs CHANGED
@@ -67,7 +67,8 @@ function createImportRouter(options) {
67
67
  plan: (plan) => planForConfig(plan, config)
68
68
  };
69
69
  };
70
- return createImportRouter$1(express.Router(), {
70
+ const router = express.Router();
71
+ return createImportRouter$1(router, {
71
72
  bindings: bindingFor,
72
73
  param: "entity",
73
74
  // What a Dynamic Entity client compares with its own `CORE_VERSION`, as it always has.
package/express.mjs.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/run-import.ts","../src/write-template.ts","../src/express.ts"],"names":["deriveImportColumns","importerTemplateFilename","sendImportFailure","entityFormConfigAdapter","planForConfig","createRouterOverAdapters"],"mappings":"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","file":"express.mjs","sourcesContent":["/**\n * run-import.ts — a file in, records out, with memory bounded by `batchSize` and not by the file.\n *\n * Since 3.0 the runner is sheet-to-schema's (`sheet-to-schema/node`), which takes a schema\n * adapter. These are Dynamic Entity's signatures over it: a config, its rules and its lookups\n * in, and the same result out. The config becomes `entityFormConfigAdapter`, which is also\n * what a browser import runs on, so the two cannot disagree about what a file produces.\n *\n * Nothing here decides anything core already decides, and nothing here reads a row.\n */\n\nimport {\n arrayBoundFor,\n deriveImportColumns,\n entityFormConfigAdapter,\n planForConfig,\n suggestMapping,\n type AnyMappingPlan,\n type ConfigProblem,\n type CsvDelimiter,\n type DateOrder,\n type EntityFormConfig,\n type FormRule,\n type ImportLookups,\n type ImportRowError,\n type MappingPlan,\n} from '@dynamic-entity/core';\nimport {\n previewSheet as previewOverAdapter,\n runImport as runOverAdapter,\n type BatchInfo as ImporterBatchInfo,\n type OnBatch as ImporterOnBatch,\n} from 'sheet-to-schema/node';\nimport type { ByteSource } from './bytes';\nimport type { ImportLimits } from './limits';\nimport type { SheetFormat } from './read-sheet';\n\n/** Where a batch sits in the user's spreadsheet, for a consumer that logs or reports progress. */\nexport type BatchInfo = ImporterBatchInfo;\n\n/**\n * Write a batch of records.\n *\n * **It is awaited, and that is load-bearing.** A consumer writing to a database is slower than\n * a parser reading a file; not awaiting would turn bounded memory into an unbounded queue of\n * pending writes — the exact failure this package exists to avoid, reached from the other\n * direction.\n *\n * **It must be idempotent.** A stream that fails at row 30,000 has already written 29,999\n * records, and an HTTP client — or a proxy, or a user — will retry. See SECURITY.md.\n */\nexport type OnBatch = ImporterOnBatch;\n\nexport interface RunImportOptions {\n stream: ByteSource;\n /** Display only. The format is decided by the file's first bytes, never by its name. */\n filename?: string;\n /** In either shape: plan v2, or a stored 2.x plan. */\n plan: AnyMappingPlan;\n config: EntityFormConfig;\n rules?: readonly FormRule[];\n /** Values for every `listName` the config mentions. A server has no `LOOKUP_REGISTRY`. */\n lookups?: ImportLookups;\n lang?: string;\n /**\n * The decimal mark in number cells. Defaults to `,` for a CSV that uses `;` between fields,\n * because that is what Excel writes in exactly those locales, and `.` otherwise.\n */\n decimal?: '.' | ',';\n /**\n * The order of a date written in digits, such as `05/09/2026`, for every column whose plan\n * entry states none. Without either, one that reads two ways fails its row: it is never\n * guessed. `MDY` reads it month first, as every release before 2.5.0 did without saying so.\n */\n dateOrder?: DateOrder;\n /** Omit to validate without writing: the identical pipeline, every error, nothing stored. */\n onBatch?: OnBatch;\n limits?: Partial<ImportLimits>;\n}\n\nexport interface ImportRunResult {\n /**\n * Records the run produced — and therefore, when `onBatch` was supplied, how many were\n * handed over to be written. The records themselves are deliberately not here: returning\n * fifty thousand of them would undo the streaming this whole file is for.\n */\n imported: number;\n /** Rows that held no values at all. A blank line is not an error and is not a record. */\n skipped: number;\n /**\n * Rows that produced at least one error, counted exactly — **not** derived from `errors`,\n * which is capped: the distinct rows in it say how many fitted in the cap, not how many\n * failed. The number a user acts on is \"which rows do I fix\", so it is the exact one.\n */\n failed: number;\n /** A **sample**, capped at `limits.maxReportedErrors`. `errorCount` is the true total. */\n errors: ImportRowError[];\n /** Every problem found, whether or not it was retained. */\n errorCount: number;\n /** `errors` is shorter than `errorCount`. */\n truncated: boolean;\n /** What is wrong with the plan itself. An `error` here means no row was read at all. */\n planProblems: ConfigProblem[];\n /** Data rows read from the sheet, blank ones included. */\n rowsRead: number;\n /**\n * What the file turned out to be — **absent when no byte of it was ever read**. A guessed\n * field is worse than a missing one: it is indistinguishable from a measured one.\n */\n format?: SheetFormat;\n /** Since 3.0: sheet rows the imported records came from. Equal to `imported` while no plan groups rows. */\n rowsInImported?: number;\n /** Since 3.0: a sample of the warnings, capped like `errors`. A record with a warning was imported. */\n warnings?: ImportRowError[];\n /** Since 3.0: every warning found, whether or not it was retained. */\n warningCount?: number;\n}\n\n/**\n * Run an import, or with no `onBatch` a validation: every row read, every error found.\n *\n * The plan is checked before a single row is read. A plan that groups rows is refused with\n * `INVALID_PLAN`: the streaming runner does not group yet, and one that ignored `group` would\n * import a record per row and report success. The browser import does group.\n */\nexport function runImport(options: RunImportOptions): Promise<ImportRunResult> {\n const { config } = options;\n return runOverAdapter({\n stream: options.stream,\n filename: options.filename,\n // A 2.x plan that names no entity is read against this config, as 2.x read it.\n plan: planForConfig(options.plan, config),\n adapter: entityFormConfigAdapter(config, { rules: options.rules, lookups: options.lookups }),\n ctx: { lang: options.lang },\n decimal: options.decimal,\n dateOrder: options.dateOrder,\n onBatch: options.onBatch,\n limits: options.limits,\n });\n}\n\n// ─── Preview ──────────────────────────────────────────────────────────────────\n\nexport interface PreviewSheetOptions {\n stream: ByteSource;\n filename?: string;\n config: EntityFormConfig;\n lang?: string;\n limits?: Partial<ImportLimits>;\n}\n\n/** What a first look at an uploaded file yields. Matches the renderer's `ImportPreview`. */\nexport interface SheetPreview {\n headers: string[];\n /** The first few rows as text, for the mapping screen to show under each column. */\n sample: string[][];\n /** Every inferred match is tagged `guess`. Plan v2 since 3.0. */\n suggestion: MappingPlan;\n /** Data rows in the file, which the whole stream is read to count. */\n rowCount: number;\n format: SheetFormat;\n /** The field separator a CSV uses. Absent for a workbook. */\n delimiter?: CsvDelimiter;\n /** Rows of each repeating field the suggestion was sized for — see `arrayBoundFor`. */\n arrayBound: number;\n}\n\n/**\n * The suggestion for a sheet's headers, and the rows of each repeating field it was sized for.\n *\n * **The same derivation the browser transport uses**, so the two suggest the same mapping for\n * the same file: sized from the headers, so a sheet with `Phone 6 Number` is offered six\n * rows, and without readonly and system-default fields, because nobody maps a column onto a\n * field the form fills in itself.\n */\nexport function suggestionFor(\n headers: readonly string[],\n config: EntityFormConfig,\n lang?: string,\n): { suggestion: MappingPlan; arrayBound: number } {\n const arrayBound = arrayBoundFor(headers, config, null, lang);\n const { columns } = deriveImportColumns(config, { lang, maxArrayRows: arrayBound });\n return { suggestion: suggestMapping(headers, columns, config?.entity ?? ''), arrayBound };\n}\n\n/**\n * Headers, a sample, and a suggested mapping.\n *\n * The whole file is read, because `rowCount` is a number the user is shown before they commit\n * and \"about this many\" is not a useful thing to tell someone about their own data. Reading it\n * also means every limit applies to a preview exactly as it applies to an import — a preview\n * endpoint that skipped them would be the cheapest way in.\n */\nexport async function previewSheet(options: PreviewSheetOptions): Promise<SheetPreview> {\n const preview = await previewOverAdapter({\n stream: options.stream,\n filename: options.filename,\n adapter: entityFormConfigAdapter(options.config, { includeReadonly: false, includeSystemDefault: false }),\n lang: options.lang,\n limits: options.limits,\n });\n return {\n headers: preview.headers,\n sample: preview.sample,\n rowCount: preview.rowCount,\n format: preview.format,\n ...(preview.delimiter ? { delimiter: preview.delimiter } : {}),\n ...suggestionFor(preview.headers, options.config, options.lang),\n };\n}\n","/**\n * write-template.ts — a template a user downloads, fills in, and uploads again.\n *\n * Since 3.0 the writer is sheet-to-schema's, which takes a sheet name, headers and notes. This\n * is Dynamic Entity's signature over it: a `TemplateSpec`, as `buildTemplateSpec` makes one.\n * The bytes are the same bytes.\n */\n\nimport type { TemplateSpec } from '@dynamic-entity/core';\nimport type { Writable } from 'node:stream';\nimport {\n TEMPLATE_EXTENSION as IMPORTER_TEMPLATE_EXTENSION,\n TEMPLATE_MEDIA_TYPE as IMPORTER_TEMPLATE_MEDIA_TYPE,\n writeTemplate as writeSheetTemplate,\n type TemplateFormat as ImporterTemplateFormat,\n type TemplateSheet,\n} from 'sheet-to-schema/node';\nimport type { ImportLimits } from './limits';\n\n/** @deprecated Import `TemplateFormat` from `sheet-to-schema/node`. Removed in 4.0. */\nexport type TemplateFormat = ImporterTemplateFormat;\n/** @deprecated Import `TEMPLATE_EXTENSION` from `sheet-to-schema/node`. Removed in 4.0. */\nexport const TEMPLATE_EXTENSION = IMPORTER_TEMPLATE_EXTENSION;\n/** @deprecated Import `TEMPLATE_MEDIA_TYPE` from `sheet-to-schema/node`. Removed in 4.0. */\nexport const TEMPLATE_MEDIA_TYPE = IMPORTER_TEMPLATE_MEDIA_TYPE;\n\nexport interface WriteTemplateOptions {\n spec: TemplateSpec;\n format: TemplateFormat;\n /** Where the bytes go. An HTTP response, a file stream, anything writable. */\n out: Writable;\n limits?: Partial<ImportLimits>;\n}\n\n/** A `TemplateSpec` as the sheet the writer takes: its name, its headers, a note per header. */\nexport function templateSheetOf(spec: TemplateSpec): TemplateSheet {\n return { sheetName: spec.sheetName, headers: spec.columns.map(column => column.header), notes: spec.notes };\n}\n\n/**\n * Write a blank template: the header row, and for a workbook a note on each header cell.\n *\n * A CSV carries no notes, because it has nowhere to put them. Either way the file holds\n * headers and no formulas, and a header that would run as one is neutralised.\n */\nexport function writeTemplate(options: WriteTemplateOptions): Promise<void> {\n return writeSheetTemplate({ sheet: templateSheetOf(options.spec), format: options.format, out: options.out, limits: options.limits });\n}\n","/**\n * express.ts — four routes, and no privileges.\n *\n * Since 3.0 the routes are sheet-to-schema's (`createImportRouter` in `sheet-to-schema/node`),\n * which resolves a schema adapter by the name in the path. This is that router with a config\n * behind each name: it finds the config, checks its lookups were supplied, turns it into\n * `entityFormConfigAdapter` with the entity's rules, and answers a preview with the same\n * suggestion the browser makes. Reading the upload, the limits, the timeouts and the failure\n * envelope are the router's own, and are the code this file had until 3.0.\n *\n * **Not this router's job, said out loud.** Concurrency limiting, authentication and\n * authorization are the consumer's. The router is mounted inside their app, behind their\n * middleware, and nothing here checks who is asking. SECURITY.md says so rather than leaving\n * a reader to assume the router protects them.\n *\n * This module is published as `@dynamic-entity/server/express` rather than from the package\n * root, so that it — and its `express` import — is reached only by someone who wants it. A\n * consumer wiring Fastify imports the root and never pays for a framework they do not use.\n */\n\nimport {\n buildTemplateSpec,\n CORE_VERSION,\n deriveImportColumns,\n entityFormConfigAdapter,\n planForConfig,\n type DateOrder,\n type EntityFormConfig,\n type FormRule,\n type ImportLookups,\n} from '@dynamic-entity/core';\nimport express, { type Request, type Router } from 'express';\nimport {\n createImportRouter as createRouterOverAdapters,\n ImportError,\n sendFailure as sendImportFailure,\n templateFilename as importerTemplateFilename,\n type ImportBinding,\n} from 'sheet-to-schema/node';\nimport type { ImportLimits } from './limits';\nimport { suggestionFor, type BatchInfo } from './run-import';\nimport { templateSheetOf } from './write-template';\n\n/** What the consumer does with a batch of records. Called once per batch, and awaited. */\nexport type OnImport = (\n records: Record<string, unknown>[],\n context: BatchInfo & {\n entity: string;\n config: EntityFormConfig;\n /** For a tenant id, a user, a transaction — whatever the consumer's writer needs. */\n request: Request;\n },\n) => void | Promise<void>;\n\n/**\n * Where the configs come from.\n *\n * A map **or** a function, because a real app keeps its configs in a database and should not\n * need a restart to add an entity. Returning `undefined` is how a loader says \"no such\n * entity\"; throwing is how it says something went wrong.\n */\nexport type ConfigSource =\n | Record<string, EntityFormConfig>\n | ((entity: string, request: Request) => EntityFormConfig | undefined | Promise<EntityFormConfig | undefined>);\n\nexport interface ImportRouterOptions {\n configs: ConfigSource;\n /** Rules per entity. Without them an import checks validators only, which the form does not. */\n rules?: Record<string, readonly FormRule[]>;\n /** Values for every `listName` any config mentions. A server has no `LOOKUP_REGISTRY`. */\n lookups?: ImportLookups;\n /**\n * The order of a date written in digits, for every import and validation this router runs:\n * `DMY` for day first, `MDY` for month first. Without it, a date that reads two ways\n * (`05/09/2026`) fails its row. See `RunImportOptions.dateOrder`.\n */\n dateOrder?: DateOrder;\n /**\n * Where records go.\n *\n * **It must be idempotent.** A stream that fails at row 30,000 has written 29,999 records,\n * and over HTTP a retry is likely rather than possible — a client, a proxy or a user will\n * send the same file again. This package does not deduplicate: the config has no natural-key\n * concept to deduplicate on. See SECURITY.md.\n */\n onImport: OnImport;\n limits?: Partial<ImportLimits>;\n /** Which language error text is produced in. Default `en`. */\n lang?: string | ((request: Request) => string);\n}\n\n/**\n * Every `listName` the importable columns of a config mention.\n *\n * Derived from the same column walk the import uses, so the set checked is exactly the set\n * that will be asked for — a field the sheet cannot carry cannot need a list.\n */\nexport function listNamesOf(config: EntityFormConfig): string[] {\n const names = new Set<string>();\n const { columns } = deriveImportColumns(config, {\n includeReadonly: true,\n includeSystemDefault: true,\n });\n for (const column of columns) {\n if (column.field.listName) names.add(column.field.listName);\n }\n return [...names];\n}\n\n/**\n * Refuse to start rather than store the wrong value at row 30,000.\n *\n * The browser resolves a `listName` field from `LOOKUP_REGISTRY`; a server has no such\n * registry, so an unsupplied list means `coerceCell` finds no options to match against and\n * passes the raw text straight through. The record then looks right and compares wrong against\n * every rule that names the option.\n *\n * So it is not left to vigilance. A silent wrong value found in a database next quarter is the\n * expensive failure; an app that will not boot is the cheap one.\n */\nfunction checkLookups(entity: string, config: EntityFormConfig, lookups: ImportLookups | undefined): void {\n const missing = listNamesOf(config).filter(name => !lookups?.[name]);\n if (!missing.length) return;\n\n throw new Error(\n `@dynamic-entity/server: config \"${entity}\" has fields resolving against ` +\n `${missing.map(name => `\"${name}\"`).join(', ')}, and no such list was supplied.\\n` +\n 'Pass them as `lookups` to createImportRouter. Without them those columns would store ' +\n 'the raw cell text, which renders correctly and matches nothing.',\n );\n}\n\n/** A map's own entry for `key`, never one inherited from `Object.prototype`. */\nfunction ownValue<T>(map: Readonly<Record<string, T>>, key: string): T | undefined {\n return Object.hasOwn(map, key) ? map[key] : undefined;\n}\n\n/**\n * A download filename derived from the config, never from the client.\n *\n * A filename that reaches a `Content-Disposition` having come from a request is header\n * injection with extra steps: a newline in it ends the header and starts another. This takes\n * the entity and keeps only characters that cannot mean anything in a header.\n *\n * The caller passes `config.entity`, not the route segment. They are usually the same string,\n * and when they are not — a consumer mounting a plural route, `{ employees: employeeConfig }` —\n * the config is the one that names the data.\n */\nexport const templateFilename = importerTemplateFilename;\n\n/**\n * Send whatever went wrong as the envelope, unless the response has already started.\n *\n * A template that failed part-way is already partly on the wire, and the only honest thing\n * left is to end the connection abruptly. A 200 with a truncated body is a corrupt file that\n * looks like a good one, which is the worst of the available outcomes.\n */\nexport const sendFailure = sendImportFailure;\n\n/** The config a binding was made from, for the writer, which is handed the config and not the binding. */\ntype ConfigBinding = ImportBinding & { config: EntityFormConfig };\n\nexport function createImportRouter(options: ImportRouterOptions): Router {\n // At construction, not at first use: a map is knowable now, and an app that refuses to start\n // is cheaper than a column that silently stored the wrong thing.\n if (typeof options.configs !== 'function') {\n for (const [entity, config] of Object.entries(options.configs)) {\n checkLookups(entity, config, options.lookups);\n }\n }\n\n const languageOf = (request: Request): string =>\n typeof options.lang === 'function' ? options.lang(request) : options.lang ?? 'en';\n\n /**\n * Resolve `:entity` to what it imports into, or to nothing, which the router answers 404\n * without echoing what was asked for.\n *\n * The key is a lookup into a map the consumer supplied — never a path segment, never\n * interpolated into anything. An own-property check, not `configs[entity]`: `constructor`,\n * `__proto__` or `toString` would otherwise resolve to a built-in and run an import against it.\n */\n const bindingFor = async (entity: string, request: Request): Promise<ConfigBinding | undefined> => {\n const config =\n typeof options.configs === 'function' ? await options.configs(entity, request) : ownValue(options.configs, entity);\n if (!config) throw new ImportError('UNKNOWN_ENTITY', 'No import is configured for that entity.');\n // A loader can return a config the router never saw at construction, so the boot-time\n // check gets a second chance here — before a row is read, which is the point of it.\n if (typeof options.configs === 'function') checkLookups(entity, config, options.lookups);\n\n const lang = languageOf(request);\n const adapter = entityFormConfigAdapter(config, {\n rules: options.rules && ownValue(options.rules, entity),\n lookups: options.lookups,\n });\n return {\n adapter: adapter as ImportBinding['adapter'],\n ctx: { lang },\n kind: 'dynamic-entity',\n config,\n // Named from the config. The route segment only when the config names nothing.\n templateName: config.entity || entity,\n template: ({ lang: language, fields }) => templateSheetOf(buildTemplateSpec(config, { lang: language, fields })),\n // What the browser suggests for the same headers, and the one number it was sized with.\n previewExtras: preview => suggestionFor(preview.headers, config, lang),\n // A 2.x plan that names no entity is read against this config, as 2.x read it.\n plan: plan => planForConfig(plan, config),\n };\n };\n\n return createRouterOverAdapters(express.Router(), {\n bindings: bindingFor,\n param: 'entity',\n // What a Dynamic Entity client compares with its own `CORE_VERSION`, as it always has.\n engineVersion: CORE_VERSION,\n dateOrder: options.dateOrder,\n limits: options.limits,\n lang: options.lang,\n onImport: (records, { index, firstRow, imported, name, binding, request }) =>\n options.onImport(records, { index, firstRow, imported, entity: name, config: (binding as ConfigBinding).config, request }),\n });\n}\n"]}
1
+ {"version":3,"sources":["../src/run-import.ts","../src/write-template.ts","../src/express.ts"],"names":["deriveImportColumns","importerTemplateFilename","sendImportFailure","entityFormConfigAdapter","planForConfig","createRouterOverAdapters"],"mappings":"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","file":"express.mjs","sourcesContent":["/**\n * run-import.ts — a file in, records out, with memory bounded by `batchSize` and not by the file.\n *\n * Since 3.0 the runner is sheet-to-schema's (`sheet-to-schema/node`), which takes a schema\n * adapter. These are Dynamic Entity's signatures over it: a config, its rules and its lookups\n * in, and the same result out. The config becomes `entityFormConfigAdapter`, which is also\n * what a browser import runs on, so the two cannot disagree about what a file produces.\n *\n * Nothing here decides anything core already decides, and nothing here reads a row.\n */\n\nimport {\n arrayBoundFor,\n deriveImportColumns,\n entityFormConfigAdapter,\n planForConfig,\n suggestMapping,\n type AnyMappingPlan,\n type ConfigProblem,\n type CsvDelimiter,\n type DateOrder,\n type EntityFormConfig,\n type FormRule,\n type ImportLookups,\n type ImportRowError,\n type MappingPlan,\n} from '@dynamic-entity/core';\nimport {\n previewSheet as previewOverAdapter,\n runImport as runOverAdapter,\n type BatchInfo as ImporterBatchInfo,\n type OnBatch as ImporterOnBatch,\n} from 'sheet-to-schema/node';\nimport type { ByteSource } from './bytes';\nimport type { ImportLimits } from './limits';\nimport type { SheetFormat } from './read-sheet';\n\n/** Where a batch sits in the user's spreadsheet, for a consumer that logs or reports progress. */\nexport type BatchInfo = ImporterBatchInfo;\n\n/**\n * Write a batch of records.\n *\n * **It is awaited, and that is load-bearing.** A consumer writing to a database is slower than\n * a parser reading a file; not awaiting would turn bounded memory into an unbounded queue of\n * pending writes — the exact failure this package exists to avoid, reached from the other\n * direction.\n *\n * **It must be idempotent.** A stream that fails at row 30,000 has already written 29,999\n * records, and an HTTP client — or a proxy, or a user — will retry. See SECURITY.md.\n */\nexport type OnBatch = ImporterOnBatch;\n\nexport interface RunImportOptions {\n stream: ByteSource;\n /** Display only. The format is decided by the file's first bytes, never by its name. */\n filename?: string;\n /** In either shape: plan v2, or a stored 2.x plan. */\n plan: AnyMappingPlan;\n config: EntityFormConfig;\n rules?: readonly FormRule[];\n /** Values for every `listName` the config mentions. A server has no `LOOKUP_REGISTRY`. */\n lookups?: ImportLookups;\n lang?: string;\n /**\n * The decimal mark in number cells. Defaults to `,` for a CSV that uses `;` between fields,\n * because that is what Excel writes in exactly those locales, and `.` otherwise.\n */\n decimal?: '.' | ',';\n /**\n * The order of a date written in digits, such as `05/09/2026`, for every column whose plan\n * entry states none. Without either, one that reads two ways fails its row: it is never\n * guessed. `MDY` reads it month first, as every release before 2.5.0 did without saying so.\n */\n dateOrder?: DateOrder;\n /** Omit to validate without writing: the identical pipeline, every error, nothing stored. */\n onBatch?: OnBatch;\n limits?: Partial<ImportLimits>;\n}\n\nexport interface ImportRunResult {\n /**\n * Records the run produced — and therefore, when `onBatch` was supplied, how many were\n * handed over to be written. The records themselves are deliberately not here: returning\n * fifty thousand of them would undo the streaming this whole file is for.\n */\n imported: number;\n /** Rows that held no values at all. A blank line is not an error and is not a record. */\n skipped: number;\n /**\n * Rows that produced at least one error, counted exactly — **not** derived from `errors`,\n * which is capped: the distinct rows in it say how many fitted in the cap, not how many\n * failed. The number a user acts on is \"which rows do I fix\", so it is the exact one.\n */\n failed: number;\n /** A **sample**, capped at `limits.maxReportedErrors`. `errorCount` is the true total. */\n errors: ImportRowError[];\n /** Every problem found, whether or not it was retained. */\n errorCount: number;\n /** `errors` is shorter than `errorCount`. */\n truncated: boolean;\n /** What is wrong with the plan itself. An `error` here means no row was read at all. */\n planProblems: ConfigProblem[];\n /** Data rows read from the sheet, blank ones included. */\n rowsRead: number;\n /**\n * What the file turned out to be — **absent when no byte of it was ever read**. A guessed\n * field is worse than a missing one: it is indistinguishable from a measured one.\n */\n format?: SheetFormat;\n /** Since 3.0: sheet rows the imported records came from. Equal to `imported` while no plan groups rows. */\n rowsInImported?: number;\n /** Since 3.0: a sample of the warnings, capped like `errors`. A record with a warning was imported. */\n warnings?: ImportRowError[];\n /** Since 3.0: every warning found, whether or not it was retained. */\n warningCount?: number;\n}\n\n/**\n * Run an import, or with no `onBatch` a validation: every row read, every error found.\n *\n * The plan is checked before a single row is read. A plan that groups rows is refused with\n * `INVALID_PLAN`: the streaming runner does not group yet, and one that ignored `group` would\n * import a record per row and report success. The browser import does group.\n */\nexport function runImport(options: RunImportOptions): Promise<ImportRunResult> {\n const { config } = options;\n return runOverAdapter({\n stream: options.stream,\n filename: options.filename,\n // A 2.x plan that names no entity is read against this config, as 2.x read it.\n plan: planForConfig(options.plan, config),\n adapter: entityFormConfigAdapter(config, { rules: options.rules, lookups: options.lookups }),\n ctx: { lang: options.lang },\n decimal: options.decimal,\n dateOrder: options.dateOrder,\n onBatch: options.onBatch,\n limits: options.limits,\n });\n}\n\n// ─── Preview ──────────────────────────────────────────────────────────────────\n\nexport interface PreviewSheetOptions {\n stream: ByteSource;\n filename?: string;\n config: EntityFormConfig;\n lang?: string;\n limits?: Partial<ImportLimits>;\n}\n\n/** What a first look at an uploaded file yields. Matches the renderer's `ImportPreview`. */\nexport interface SheetPreview {\n headers: string[];\n /** The first few rows as text, for the mapping screen to show under each column. */\n sample: string[][];\n /** Every inferred match is tagged `guess`. Plan v2 since 3.0. */\n suggestion: MappingPlan;\n /** Data rows in the file, which the whole stream is read to count. */\n rowCount: number;\n format: SheetFormat;\n /** The field separator a CSV uses. Absent for a workbook. */\n delimiter?: CsvDelimiter;\n /** Rows of each repeating field the suggestion was sized for — see `arrayBoundFor`. */\n arrayBound: number;\n}\n\n/**\n * The suggestion for a sheet's headers, and the rows of each repeating field it was sized for.\n *\n * **The same derivation the browser transport uses**, so the two suggest the same mapping for\n * the same file: sized from the headers, so a sheet with `Phone 6 Number` is offered six\n * rows, and without readonly and system-default fields, because nobody maps a column onto a\n * field the form fills in itself.\n */\nexport function suggestionFor(\n headers: readonly string[],\n config: EntityFormConfig,\n lang?: string,\n): { suggestion: MappingPlan; arrayBound: number } {\n const arrayBound = arrayBoundFor(headers, config, null, lang);\n const { columns } = deriveImportColumns(config, { lang, maxArrayRows: arrayBound });\n return { suggestion: suggestMapping(headers, columns, config?.entity ?? ''), arrayBound };\n}\n\n/**\n * Headers, a sample, and a suggested mapping.\n *\n * The whole file is read, because `rowCount` is a number the user is shown before they commit\n * and \"about this many\" is not a useful thing to tell someone about their own data. Reading it\n * also means every limit applies to a preview exactly as it applies to an import — a preview\n * endpoint that skipped them would be the cheapest way in.\n */\nexport async function previewSheet(options: PreviewSheetOptions): Promise<SheetPreview> {\n const preview = await previewOverAdapter({\n stream: options.stream,\n filename: options.filename,\n adapter: entityFormConfigAdapter(options.config, { includeReadonly: false, includeSystemDefault: false }),\n lang: options.lang,\n limits: options.limits,\n });\n return {\n headers: preview.headers,\n sample: preview.sample,\n rowCount: preview.rowCount,\n format: preview.format,\n ...(preview.delimiter ? { delimiter: preview.delimiter } : {}),\n ...suggestionFor(preview.headers, options.config, options.lang),\n };\n}\n","/**\n * write-template.ts — a template a user downloads, fills in, and uploads again.\n *\n * Since 3.0 the writer is sheet-to-schema's, which takes a sheet name, headers and notes. This\n * is Dynamic Entity's signature over it: a `TemplateSpec`, as `buildTemplateSpec` makes one.\n * The bytes are the same bytes.\n */\n\nimport type { TemplateSpec } from '@dynamic-entity/core';\nimport type { Writable } from 'node:stream';\nimport {\n TEMPLATE_EXTENSION as IMPORTER_TEMPLATE_EXTENSION,\n TEMPLATE_MEDIA_TYPE as IMPORTER_TEMPLATE_MEDIA_TYPE,\n writeTemplate as writeSheetTemplate,\n type TemplateFormat as ImporterTemplateFormat,\n type TemplateSheet,\n} from 'sheet-to-schema/node';\nimport type { ImportLimits } from './limits';\n\n/** @deprecated Import `TemplateFormat` from `sheet-to-schema/node`. Removed in 4.0. */\nexport type TemplateFormat = ImporterTemplateFormat;\n/** @deprecated Import `TEMPLATE_EXTENSION` from `sheet-to-schema/node`. Removed in 4.0. */\nexport const TEMPLATE_EXTENSION = IMPORTER_TEMPLATE_EXTENSION;\n/** @deprecated Import `TEMPLATE_MEDIA_TYPE` from `sheet-to-schema/node`. Removed in 4.0. */\nexport const TEMPLATE_MEDIA_TYPE = IMPORTER_TEMPLATE_MEDIA_TYPE;\n\nexport interface WriteTemplateOptions {\n spec: TemplateSpec;\n format: TemplateFormat;\n /** Where the bytes go. An HTTP response, a file stream, anything writable. */\n out: Writable;\n limits?: Partial<ImportLimits>;\n}\n\n/** A `TemplateSpec` as the sheet the writer takes: its name, its headers, a note per header. */\nexport function templateSheetOf(spec: TemplateSpec): TemplateSheet {\n return { sheetName: spec.sheetName, headers: spec.columns.map(column => column.header), notes: spec.notes };\n}\n\n/**\n * Write a blank template: the header row, and for a workbook a note on each header cell.\n *\n * A CSV carries no notes, because it has nowhere to put them. Either way the file holds\n * headers and no formulas, and a header that would run as one is neutralised.\n */\nexport function writeTemplate(options: WriteTemplateOptions): Promise<void> {\n return writeSheetTemplate({ sheet: templateSheetOf(options.spec), format: options.format, out: options.out, limits: options.limits });\n}\n","/**\n * express.ts — four routes, and no privileges.\n *\n * Since 3.0 the routes are sheet-to-schema's (`createImportRouter` in `sheet-to-schema/node`),\n * which resolves a schema adapter by the name in the path. This is that router with a config\n * behind each name: it finds the config, checks its lookups were supplied, turns it into\n * `entityFormConfigAdapter` with the entity's rules, and answers a preview with the same\n * suggestion the browser makes. Reading the upload, the limits, the timeouts and the failure\n * envelope are the router's own, and are the code this file had until 3.0.\n *\n * **Not this router's job, said out loud.** Concurrency limiting, authentication and\n * authorization are the consumer's. The router is mounted inside their app, behind their\n * middleware, and nothing here checks who is asking. SECURITY.md says so rather than leaving\n * a reader to assume the router protects them.\n *\n * This module is published as `@dynamic-entity/server/express` rather than from the package\n * root, so that it — and its `express` import — is reached only by someone who wants it. A\n * consumer wiring Fastify imports the root and never pays for a framework they do not use.\n *\n * **Its typings name nothing from `express`.** The request, the response and the router are\n * sheet-to-schema's structural types, which an Express 4 or 5 request, response and router\n * satisfy as they are. So a TypeScript app needs no particular `@types/express`, or none, and\n * two copies of it in one install cannot make the router's types disagree with the app's. A\n * callback that reads more of the request than `ImportRequest` has (a user, a tenant)\n * annotates its parameter with the app's own request type, and the options take that type.\n */\n\nimport {\n buildTemplateSpec,\n CORE_VERSION,\n deriveImportColumns,\n entityFormConfigAdapter,\n planForConfig,\n type DateOrder,\n type EntityFormConfig,\n type FormRule,\n type ImportLookups,\n} from '@dynamic-entity/core';\nimport express from 'express';\nimport type { IncomingMessage, ServerResponse } from 'node:http';\nimport {\n createImportRouter as createRouterOverAdapters,\n ImportError,\n sendFailure as sendImportFailure,\n templateFilename as importerTemplateFilename,\n type ImportBinding,\n type ImportRequest,\n type ImportRouter as RouteTable,\n} from 'sheet-to-schema/node';\nimport type { ImportLimits } from './limits';\nimport { suggestionFor, type BatchInfo } from './run-import';\nimport { templateSheetOf } from './write-template';\n\nexport type { ImportRequest, ImportResponse } from 'sheet-to-schema/node';\n\n/**\n * What `createImportRouter` returns: an Express router, described by what an app does with it.\n * It is middleware, so `app.use('/import', router)` takes it, and it has the four routes on it.\n * Nothing here is typed by `@types/express`, so any copy of it, or none, works.\n */\nexport interface ImportRouter<TRequest extends ImportRequest = ImportRequest> extends RouteTable<TRequest> {\n (request: IncomingMessage, response: ServerResponse, next: (error?: unknown) => void): void;\n}\n\n/** What the consumer does with a batch of records. Called once per batch, and awaited. */\nexport type OnImport<TRequest extends ImportRequest = ImportRequest> = (\n records: Record<string, unknown>[],\n context: BatchInfo & {\n entity: string;\n config: EntityFormConfig;\n /** For a tenant id, a user, a transaction — whatever the consumer's writer needs. */\n request: TRequest;\n },\n) => void | Promise<void>;\n\n/**\n * Where the configs come from.\n *\n * A map **or** a function, because a real app keeps its configs in a database and should not\n * need a restart to add an entity. Returning `undefined` is how a loader says \"no such\n * entity\"; throwing is how it says something went wrong.\n */\nexport type ConfigSource<TRequest extends ImportRequest = ImportRequest> =\n | Record<string, EntityFormConfig>\n | ((entity: string, request: TRequest) => EntityFormConfig | undefined | Promise<EntityFormConfig | undefined>);\n\n/**\n * `TRequest` is the request the callbacks below are handed: `ImportRequest` unless a callback\n * annotates its own, such as `(entity, request: express.Request & { tenant: string }) => ...`.\n */\nexport interface ImportRouterOptions<TRequest extends ImportRequest = ImportRequest> {\n configs: ConfigSource<TRequest>;\n /** Rules per entity. Without them an import checks validators only, which the form does not. */\n rules?: Record<string, readonly FormRule[]>;\n /** Values for every `listName` any config mentions. A server has no `LOOKUP_REGISTRY`. */\n lookups?: ImportLookups;\n /**\n * The order of a date written in digits, for every import and validation this router runs:\n * `DMY` for day first, `MDY` for month first. Without it, a date that reads two ways\n * (`05/09/2026`) fails its row. See `RunImportOptions.dateOrder`.\n */\n dateOrder?: DateOrder;\n /**\n * Where records go.\n *\n * **It must be idempotent.** A stream that fails at row 30,000 has written 29,999 records,\n * and over HTTP a retry is likely rather than possible — a client, a proxy or a user will\n * send the same file again. This package does not deduplicate: the config has no natural-key\n * concept to deduplicate on. See SECURITY.md.\n */\n onImport: OnImport<TRequest>;\n limits?: Partial<ImportLimits>;\n /** Which language error text is produced in. Default `en`. */\n lang?: string | ((request: TRequest) => string);\n}\n\n/**\n * Every `listName` the importable columns of a config mention.\n *\n * Derived from the same column walk the import uses, so the set checked is exactly the set\n * that will be asked for — a field the sheet cannot carry cannot need a list.\n */\nexport function listNamesOf(config: EntityFormConfig): string[] {\n const names = new Set<string>();\n const { columns } = deriveImportColumns(config, {\n includeReadonly: true,\n includeSystemDefault: true,\n });\n for (const column of columns) {\n if (column.field.listName) names.add(column.field.listName);\n }\n return [...names];\n}\n\n/**\n * Refuse to start rather than store the wrong value at row 30,000.\n *\n * The browser resolves a `listName` field from `LOOKUP_REGISTRY`; a server has no such\n * registry, so an unsupplied list means `coerceCell` finds no options to match against and\n * passes the raw text straight through. The record then looks right and compares wrong against\n * every rule that names the option.\n *\n * So it is not left to vigilance. A silent wrong value found in a database next quarter is the\n * expensive failure; an app that will not boot is the cheap one.\n */\nfunction checkLookups(entity: string, config: EntityFormConfig, lookups: ImportLookups | undefined): void {\n const missing = listNamesOf(config).filter(name => !lookups?.[name]);\n if (!missing.length) return;\n\n throw new Error(\n `@dynamic-entity/server: config \"${entity}\" has fields resolving against ` +\n `${missing.map(name => `\"${name}\"`).join(', ')}, and no such list was supplied.\\n` +\n 'Pass them as `lookups` to createImportRouter. Without them those columns would store ' +\n 'the raw cell text, which renders correctly and matches nothing.',\n );\n}\n\n/** A map's own entry for `key`, never one inherited from `Object.prototype`. */\nfunction ownValue<T>(map: Readonly<Record<string, T>>, key: string): T | undefined {\n return Object.hasOwn(map, key) ? map[key] : undefined;\n}\n\n/**\n * A download filename derived from the config, never from the client.\n *\n * A filename that reaches a `Content-Disposition` having come from a request is header\n * injection with extra steps: a newline in it ends the header and starts another. This takes\n * the entity and keeps only characters that cannot mean anything in a header.\n *\n * The caller passes `config.entity`, not the route segment. They are usually the same string,\n * and when they are not — a consumer mounting a plural route, `{ employees: employeeConfig }` —\n * the config is the one that names the data.\n */\nexport const templateFilename = importerTemplateFilename;\n\n/**\n * Send whatever went wrong as the envelope, unless the response has already started.\n *\n * A template that failed part-way is already partly on the wire, and the only honest thing\n * left is to end the connection abruptly. A 200 with a truncated body is a corrupt file that\n * looks like a good one, which is the worst of the available outcomes.\n */\nexport const sendFailure = sendImportFailure;\n\n/** The config a binding was made from, for the writer, which is handed the config and not the binding. */\ntype ConfigBinding = ImportBinding & { config: EntityFormConfig };\n\nexport function createImportRouter<TRequest extends ImportRequest = ImportRequest>(\n options: ImportRouterOptions<TRequest>,\n): ImportRouter<TRequest> {\n // At construction, not at first use: a map is knowable now, and an app that refuses to start\n // is cheaper than a column that silently stored the wrong thing.\n if (typeof options.configs !== 'function') {\n for (const [entity, config] of Object.entries(options.configs)) {\n checkLookups(entity, config, options.lookups);\n }\n }\n\n const languageOf = (request: TRequest): string =>\n typeof options.lang === 'function' ? options.lang(request) : options.lang ?? 'en';\n\n /**\n * Resolve `:entity` to what it imports into, or to nothing, which the router answers 404\n * without echoing what was asked for.\n *\n * The key is a lookup into a map the consumer supplied — never a path segment, never\n * interpolated into anything. An own-property check, not `configs[entity]`: `constructor`,\n * `__proto__` or `toString` would otherwise resolve to a built-in and run an import against it.\n */\n const bindingFor = async (entity: string, request: TRequest): Promise<ConfigBinding | undefined> => {\n const config =\n typeof options.configs === 'function' ? await options.configs(entity, request) : ownValue(options.configs, entity);\n if (!config) throw new ImportError('UNKNOWN_ENTITY', 'No import is configured for that entity.');\n // A loader can return a config the router never saw at construction, so the boot-time\n // check gets a second chance here — before a row is read, which is the point of it.\n if (typeof options.configs === 'function') checkLookups(entity, config, options.lookups);\n\n const lang = languageOf(request);\n const adapter = entityFormConfigAdapter(config, {\n rules: options.rules && ownValue(options.rules, entity),\n lookups: options.lookups,\n });\n return {\n adapter: adapter as ImportBinding['adapter'],\n ctx: { lang },\n kind: 'dynamic-entity',\n config,\n // Named from the config. The route segment only when the config names nothing.\n templateName: config.entity || entity,\n template: ({ lang: language, fields }) => templateSheetOf(buildTemplateSpec(config, { lang: language, fields })),\n // What the browser suggests for the same headers, and the one number it was sized with.\n previewExtras: preview => suggestionFor(preview.headers, config, lang),\n // A 2.x plan that names no entity is read against this config, as 2.x read it.\n plan: plan => planForConfig(plan, config),\n };\n };\n\n // An Express router is an `ImportRouter` at run time; the cast is only to the structural type\n // the typings describe, so that they need nothing from `@types/express`.\n const router = express.Router() as unknown as ImportRouter<TRequest>;\n return createRouterOverAdapters<ImportRouter<TRequest>, TRequest>(router, {\n bindings: bindingFor,\n param: 'entity',\n // What a Dynamic Entity client compares with its own `CORE_VERSION`, as it always has.\n engineVersion: CORE_VERSION,\n dateOrder: options.dateOrder,\n limits: options.limits,\n lang: options.lang,\n onImport: (records, { index, firstRow, imported, name, binding, request }) =>\n options.onImport(records, { index, firstRow, imported, entity: name, config: (binding as ConfigBinding).config, request }),\n });\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dynamic-entity/server",
3
- "version": "3.0.0-rc.3",
3
+ "version": "3.0.0",
4
4
  "description": "Streaming spreadsheet import for @dynamic-entity/core, with an Express adapter. Dynamic Entity's configs over sheet-to-schema/node.",
5
5
  "keywords": [
6
6
  "dynamic-entity",
@@ -45,10 +45,10 @@
45
45
  "dependencies": {
46
46
  "busboy": "1.6.0",
47
47
  "exceljs": "4.4.0",
48
- "sheet-to-schema": "1.0.0-rc.3"
48
+ "sheet-to-schema": "^1.0.0"
49
49
  },
50
50
  "peerDependencies": {
51
- "@dynamic-entity/core": "^3.0.0-rc.3",
51
+ "@dynamic-entity/core": "^3.0.0",
52
52
  "express": "^4.18.0 || ^5.0.0"
53
53
  },
54
54
  "peerDependenciesMeta": {