diff --git a/ContractTests/Client/package.json b/ContractTests/Client/package.json index ee2b1ce6..7c1e54d4 100644 --- a/ContractTests/Client/package.json +++ b/ContractTests/Client/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.core-client-contract", - "version": "0.51.0", + "version": "0.52.0", "private": true, "type": "module", "dependencies": { diff --git a/Documentation/index.md b/Documentation/index.md index 0c2f0c0a..a5baa901 100644 --- a/Documentation/index.md +++ b/Documentation/index.md @@ -8,7 +8,7 @@ Arc for TypeScript is a Node.js server implementation of [Arc](/arc/), the Crati Without it, a Node.js backend for an Arc frontend means writing every route, request parser, validation response, and status code by hand, then keeping all of it in step with the frontend. With it, commands and queries run through one pipeline that owns those concerns, the wire behavior follows Arc on .NET, and the proxy generator writes the typed frontend client from your source. :::caution[Source preview, no full parity] -No package is published to npm; the manifests are at version 0.51.0 for a source preview. Arc for TypeScript does **not** have full parity with Arc on .NET, and package names and APIs can still change. The [capability reference](reference/capabilities.md) is the single place for status and evidence. +No package is published to npm; the manifests are at version 0.52.0 for a source preview. Arc for TypeScript does **not** have full parity with Arc on .NET, and package names and APIs can still change. The [capability reference](reference/capabilities.md) is the single place for status and evidence. ::: ## What it looks like diff --git a/Documentation/proxy-generation/configuration.md b/Documentation/proxy-generation/configuration.md index 87d47e1b..10aba2f4 100644 --- a/Documentation/proxy-generation/configuration.md +++ b/Documentation/proxy-generation/configuration.md @@ -40,12 +40,56 @@ Match these to the server's [endpoint mapping](../core/endpoint-mapping.md), or | `--metadata ` | Off | Generate server artifact metadata at the given absolute path and infer undecorated bindings | | `--use-generated-metadata` | Off | Infer the same bindings for client-only generation without publishing a metadata module | | `--check-metadata` | Off | Read-only check that a module passed with `--metadata` matches current source | +| `--type-mapping =[#]` | None | Import a type from another package instead of generating it; repeat for several types. See [Map types to another package](#map-types-to-another-package) | | `--watch` | Off | Debounce edits under the artifacts root or in referenced local source files and regenerate; ignores co-located `*.proxy.ts` writes, a dedicated nested output folder, and the generated metadata module. Backend edits inside a co-located nested output folder trigger regeneration. Stdout reports `Watch ready` after the initial generation and watcher registration, then `Watch change detected` when an edit schedules regeneration; referenced external files are also polled to recover missed directory notifications | Output is co-located when it equals or contains the artifacts root, or when a nested output folder contains a discovered backend contributor: a command, read model, validator, resolved concept/model/enum, or class emitted in generated metadata (for example, `--artifacts src --output src/Features` with a validator in `Features`). An unrelated helper alone does not make a nested output co-located. Co-located output requires `--use-proxy-file-suffix` (generation fails without it) and writes no generated barrels. A handwritten `*.proxy.ts` collision fails before any files are published. Stale deletion affects only generator-owned files, not backend modules or components. Runtime discovery ignores the proxy suffix and `.tsx` components. A genuinely dedicated output folder nested inside the artifacts root (for example, `--artifacts src --output src/generated` with no analyzed backend contributors in `generated`) retains separate-output behavior: barrels are written by default, the suffix is optional, and generated files are excluded from artifact analysis and watch regeneration. If the server also discovers the artifacts root at runtime, place dedicated output outside that root or use `--use-proxy-file-suffix` so runtime discovery does not import generated proxy modules. +## Map types to another package + +When several packages share a model, generate it once and import it everywhere else. A mapped type is not written to the proxy tree; every command, query, and model that references it imports it from the package instead. Route names, output layout, and every other default stay the same. + +```bash +arc-proxygenerator --project tsconfig.json --artifacts src --output src \ + --type-mapping Shared.Money=@acme/money \ + --type-mapping Shared.Amount=@acme/money#Money +``` + +The generated proxies then contain: + +```typescript +import { Money } from '@acme/money'; +``` + +The value is `=[#]`. Programmatic callers pass the same settings as `typeMappings` to `generateFromSource` and `analyzeSource`: + +```typescript +await generateFromSource({ + project, artifacts, output, + typeMappings: { + 'Shared.Money': { package: '@acme/money' }, + 'Shared.Amount': { package: '@acme/money', export: 'Money' } + } +}); +``` + +| Part | Meaning | +| --- | --- | +| Type | The type's namespace-qualified name: the [root namespace](#route-alignment), then the folders between the artifacts root and its file, then the class or enum name. `Shared/Money.ts` under the artifacts root is `Shared.Money`. Segments to skip do not change it. The type is matched by its resolved declaration, not by how it is spelled at the use site, so an aliased import maps the same way | +| `package` | The npm package, optionally with a subpath, such as `@acme/money` or `@acme/money/models` | +| `export` | The symbol the package exports. Defaults to the type's own name; set it when the package uses a different name | + +Behavior and limits: + +- A mapped class or enum works as a field, an array element, a command or query result, or a base class. A mapped enum keeps its primitive runtime constructor. +- The package must export a class the generated code can use as a runtime constructor, decorated the way generated models are. It is imported as a value, including with `--emit-interfaces`. Install the package separately; the generator does not build or publish it. +- Only types below the artifacts root can be matched. The fields of a mapped type are not analyzed. +- Each type can be mapped once. A repeated `--type-mapping` for one type fails, and so does an invalid type name, package name, or export name. +- Generation fails, naming the file, when a mapped export would share a name with another import or declaration in the same generated file, for example a mapped `Command` next to the `Command` imported from `@cratis/arc/commands`. Export the type from your package under a different name. +- A mapping that matches no type reachable from the artifacts root is reported as a warning, since it is usually a misspelled name. + ## Programmatic use The package also exports `analyzeSource`, `renderSource`, `renderGeneratedMetadata`, and `generateFromSource` for the same pipeline, with the `SourceGeneratorOptions` and `SourceRenderOptions` types. Programmatic `generateFromSource` accepts `metadata` or `generatedMetadata: true` for inference. `renderSource` accepts a `recordedRules` override. The low-level manifest path exports `renderClientManifest` and `generateClient`; see [Low-level manifest](low-level-manifest.md). diff --git a/Documentation/reference/packages.md b/Documentation/reference/packages.md index 905356b8..c93b1fbf 100644 --- a/Documentation/reference/packages.md +++ b/Documentation/reference/packages.md @@ -3,7 +3,7 @@ title: Packages description: The packages this repository builds, what each exports, their peer dependencies and Node.js requirements, and how they relate to the published @cratis/arc client. --- -Every package in this repository is at version 0.51.0, the version of the source preview. **None is published to npm.** They ship ES modules only. Clone this repository, run `yarn install` and `yarn build`, and then use the packages in one of two ways: +Every package in this repository is at version 0.52.0, the version of the source preview. **None is published to npm.** They ship ES modules only. Clone this repository, run `yarn install` and `yarn build`, and then use the packages in one of two ways: - **Inside the clone.** Put your application in a folder under `Samples/`, which the root `workspaces` list includes, and reference the packages with the `workspace:^` protocol, as [`Samples/Tasks/package.json`](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Tasks/package.json) does. `workspace:^` resolves only inside this repository's Yarn workspace. - **In your own project.** Pack each package you need with `yarn workspace pack --out ` and install the tarballs with npm. Use `yarn pack`: it rewrites `workspace:^` dependencies to version ranges, and `npm pack` does not. `yarn check:consumers` installs packed packages this way to check NodeNext and Bundler consumers. diff --git a/README.md b/README.md index b7b77dec..b8fea9b6 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ export class TaskItem { | `@cratis/arc.chronicle` | [`Source/Chronicle`](Source/Chronicle) | **Experimental.** `builder.withChronicle` appends returned events and resolves registered read models by command key; nested command returns join one event-log batch. In-memory command assertions are available under `@cratis/arc.chronicle/testing`. SDK 6.19.0 imports natively and infers read models from projections/reducers; an opt-in kernel suite covers aggregate replay and reactor commands. Full .NET transaction parity remains unverified. | | `@cratis/cratis` | [`Source/Cratis`](Source/Cratis) | **Experimental source preview.** `CratisApplication.createBuilder()` and `builder.addCratis()` compose Arc and a Chronicle client without installing authentication; not yet published to npm. | -Every package manifest is at version 0.51.0. That is the version of this source preview, not an npm release, and the Chronicle package is experimental. The packages ship ES modules only, and schemas use Zod 4. The default core entry, host adapters, MongoDB, and Drizzle packages need Node.js 22 or later. The Fetch entry has a neutral bundle with `node:async_hooks` as its only Node import; its command, query, and SSE paths run in a Next.js App Router route handler on the Node.js runtime, with Bun and Deno smoke checks; Cloudflare Workers and the Next.js Edge runtime are not supported. See [Fetch API runtimes](Documentation/hosts/fetch-runtimes.md). The root workspace needs Node.js 22.19 or later, because it installs the Chronicle SDK; Node.js 24 LTS is recommended. +Every package manifest is at version 0.52.0. That is the version of this source preview, not an npm release, and the Chronicle package is experimental. The packages ship ES modules only, and schemas use Zod 4. The default core entry, host adapters, MongoDB, and Drizzle packages need Node.js 22 or later. The Fetch entry has a neutral bundle with `node:async_hooks` as its only Node import; its command, query, and SSE paths run in a Next.js App Router route handler on the Node.js runtime, with Bun and Deno smoke checks; Cloudflare Workers and the Next.js Edge runtime are not supported. See [Fetch API runtimes](Documentation/hosts/fetch-runtimes.md). The root workspace needs Node.js 22.19 or later, because it installs the Chronicle SDK; Node.js 24 LTS is recommended. ## Try it diff --git a/Source/Chronicle/package.json b/Source/Chronicle/package.json index 9c6d2565..283ddda9 100644 --- a/Source/Chronicle/package.json +++ b/Source/Chronicle/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.chronicle", - "version": "0.51.0", + "version": "0.52.0", "publishConfig": { "access": "public" }, @@ -34,8 +34,8 @@ "README.md" ], "peerDependencies": { - "@cratis/arc.core": "^0.51.0", - "@cratis/arc.testing": "^0.51.0", + "@cratis/arc.core": "^0.52.0", + "@cratis/arc.testing": "^0.52.0", "@cratis/chronicle": "^6.17.0", "@cratis/fundamentals": "^7.19.6", "rxjs": "^7.8.2", diff --git a/Source/CodeAnalysis/package.json b/Source/CodeAnalysis/package.json index 773a1211..455fdb88 100644 --- a/Source/CodeAnalysis/package.json +++ b/Source/CodeAnalysis/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/eslint-plugin-arc-core", - "version": "0.51.0", + "version": "0.52.0", "type": "module", "license": "MIT", "description": "ESLint diagnostics for Arc for TypeScript server artifacts", diff --git a/Source/Core/package.json b/Source/Core/package.json index 11ffd97a..7ebd3a15 100644 --- a/Source/Core/package.json +++ b/Source/Core/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.core", - "version": "0.51.0", + "version": "0.52.0", "type": "module", "license": "MIT", "publishConfig": { diff --git a/Source/Cratis/package.json b/Source/Cratis/package.json index 23ca0f42..9e2248ff 100644 --- a/Source/Cratis/package.json +++ b/Source/Cratis/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/cratis", - "version": "0.51.0", + "version": "0.52.0", "type": "module", "license": "MIT", "description": "Arc and experimental Chronicle composition for Node.js", diff --git a/Source/Drizzle/package.json b/Source/Drizzle/package.json index 680a4b53..e8161465 100644 --- a/Source/Drizzle/package.json +++ b/Source/Drizzle/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.drizzle", - "version": "0.51.0", + "version": "0.52.0", "type": "module", "license": "MIT", "publishConfig": { @@ -29,7 +29,7 @@ "README.md" ], "peerDependencies": { - "@cratis/arc.core": "^0.51.0", + "@cratis/arc.core": "^0.52.0", "@cratis/fundamentals": "^7.19.6", "drizzle-orm": "^0.45.0", "rxjs": "^7.8.2" diff --git a/Source/Express/package.json b/Source/Express/package.json index 1323b0d3..861f3e59 100644 --- a/Source/Express/package.json +++ b/Source/Express/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.express", - "version": "0.51.0", + "version": "0.52.0", "type": "module", "license": "MIT", "publishConfig": { diff --git a/Source/Fastify/package.json b/Source/Fastify/package.json index 1009164c..18a93df7 100644 --- a/Source/Fastify/package.json +++ b/Source/Fastify/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.fastify", - "version": "0.51.0", + "version": "0.52.0", "type": "module", "license": "MIT", "publishConfig": { diff --git a/Source/Hono/package.json b/Source/Hono/package.json index f3c857e5..6f24c4da 100644 --- a/Source/Hono/package.json +++ b/Source/Hono/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.hono", - "version": "0.51.0", + "version": "0.52.0", "type": "module", "license": "MIT", "publishConfig": { diff --git a/Source/MongoDB/package.json b/Source/MongoDB/package.json index 3983eeb9..f1359e07 100644 --- a/Source/MongoDB/package.json +++ b/Source/MongoDB/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.mongodb", - "version": "0.51.0", + "version": "0.52.0", "type": "module", "license": "MIT", "publishConfig": { @@ -29,7 +29,7 @@ "README.md" ], "peerDependencies": { - "@cratis/arc.core": "^0.51.0", + "@cratis/arc.core": "^0.52.0", "@cratis/fundamentals": "^7.19.6", "@opentelemetry/api": "^1.9.0", "mongodb": "^6.21.0", diff --git a/Source/Testing/package.json b/Source/Testing/package.json index 18f90f1d..6ae64be4 100644 --- a/Source/Testing/package.json +++ b/Source/Testing/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.testing", - "version": "0.51.0", + "version": "0.52.0", "type": "module", "license": "MIT", "publishConfig": { diff --git a/Source/Tools/ProxyGenerator/SourceModel.ts b/Source/Tools/ProxyGenerator/SourceModel.ts index f6712448..6198e74c 100644 --- a/Source/Tools/ProxyGenerator/SourceModel.ts +++ b/Source/Tools/ProxyGenerator/SourceModel.ts @@ -9,5 +9,7 @@ export interface SourceModel { readonly members?: readonly { name: string; value: string | number }[]; readonly base?: string; readonly baseKey?: string; + /** Package a mapped base class is imported from; the base is not generated. */ + readonly basePackage?: string; readonly derivedTypeId?: string; } diff --git a/Source/Tools/ProxyGenerator/SourceType.ts b/Source/Tools/ProxyGenerator/SourceType.ts index ebf2131d..cced8db0 100644 --- a/Source/Tools/ProxyGenerator/SourceType.ts +++ b/Source/Tools/ProxyGenerator/SourceType.ts @@ -9,6 +9,8 @@ export interface SourceType { /** Local import name when a model name collides in the generated file. */ readonly alias?: string; readonly package?: string; + /** Set when the type is imported from a user-configured package mapping rather than generated. */ + readonly mapped?: boolean; readonly enumerable: boolean; readonly nullable: boolean; readonly void: boolean; diff --git a/Source/Tools/ProxyGenerator/SourceTypeResolver.ts b/Source/Tools/ProxyGenerator/SourceTypeResolver.ts index f6e2f3d1..e49383b3 100644 --- a/Source/Tools/ProxyGenerator/SourceTypeResolver.ts +++ b/Source/Tools/ProxyGenerator/SourceTypeResolver.ts @@ -5,6 +5,7 @@ import { dirname, relative, sep } from 'node:path'; import type { SourceField } from './SourceField.js'; import type { SourceModel } from './SourceModel.js'; import type { SourceType } from './SourceType.js'; +import { resolveTypeMappings, type ResolvedTypeMapping, type TypeMappings } from './typeMappings.js'; import { fieldName, isPackageSymbol, isStandardType, isTypeFrom, originalSymbol } from './sourceSymbols.js'; const fundamentals = new Set(['Guid', 'DateOnly', 'TimeOnly', 'TimeSpan']); @@ -14,7 +15,20 @@ export class SourceTypeResolver { private readonly declarations = new Map(); constructor(private readonly checker: ts.TypeChecker, private readonly artifacts: string, private readonly generatedMetadata = false, private readonly rootNamespace = '', - private readonly contribute: (declaration: ts.Declaration) => void = () => {}) {} + private readonly contribute: (declaration: ts.Declaration) => void = () => {}, typeMappings?: TypeMappings) { + this.mappings = resolveTypeMappings(typeMappings); + } + private readonly mappings: ReadonlyMap; + private readonly usedMappings = new Set(); + /** Mappings that never matched a reachable type: almost always a misspelled key. */ + unusedMappings(): string[] { return [...this.mappings.keys()].filter(key => !this.usedMappings.has(key)); } + /** An imported class is its own runtime constructor; an imported enum keeps the primitive constructor it would have had. */ + private mapped(key: string, enumConstructor?: 'String' | 'Number'): SourceType | undefined { + const mapping = this.mappings.get(key); + if (!mapping) return undefined; + this.usedMappings.add(key); + return { ...primitive(mapping.export, enumConstructor ?? mapping.export), package: mapping.package, mapped: true }; + } private namespace(declaration: ts.Declaration): string { const segments = relative(this.artifacts, dirname(declaration.getSourceFile().fileName)).split(sep).filter(Boolean); if (segments.includes('..')) throw new Error(`${declaration.getSourceFile().fileName}: reachable model is outside the artifacts root`); @@ -73,6 +87,8 @@ export class SourceTypeResolver { if (declaration && name && !declaration.getSourceFile().isDeclarationFile) { this.contribute(declaration); const key = [this.namespace(declaration), name].filter(Boolean).join('.'); + const mapped = this.mapped(key); + if (mapped) return { ...mapped, nullable }; this.checkIdentity(key, declaration); if (!this.models.has(key)) { this.models.set(key, { kind: 'model', name, namespace: this.namespace(declaration), fields: [] }); @@ -106,6 +122,7 @@ export class SourceTypeResolver { const baseType = type.getBaseTypes()?.find(base => base.symbol?.declarations?.some(ts.isClassDeclaration) && !base.symbol.declarations.every(origin => origin.getSourceFile().isDeclarationFile)); const base = baseType ? this.resolve(baseType, declaration) : undefined; + const basePackage = base?.mapped ? base.package : undefined; const derived = (ts.getDecorators(declaration) ?? []).map(decorator => decorator.expression).find(expression => { if (!ts.isCallExpression(expression)) return false; const symbol = ts.isPropertyAccessExpression(expression.expression) ? expression.expression.name : expression.expression; @@ -113,7 +130,7 @@ export class SourceTypeResolver { }); const derivedTypeId = derived && ts.isCallExpression(derived) && derived.arguments[0] && ts.isStringLiteral(derived.arguments[0]) ? derived.arguments[0].text : undefined; this.models.set(key, { kind: 'model', name, namespace: this.namespace(declaration), fields, - base: base?.model, baseKey: base?.modelKey, derivedTypeId }); + base: basePackage ? base!.text : base?.model, baseKey: base?.modelKey, basePackage, derivedTypeId }); } return { ...primitive(name, name), model: name, modelKey: key, nullable }; } @@ -127,6 +144,8 @@ export class SourceTypeResolver { const members = declaration.members.map(member => ({ name: fieldName(member.name), value: this.checker.getConstantValue(member) })); if (members.some(member => typeof member.value !== 'number' && typeof member.value !== 'string')) return this.unsupported(type, location); const key = [this.namespace(declaration), name].filter(Boolean).join('.'); + const mapped = this.mapped(key, typeof members[0]?.value === 'string' ? 'String' : 'Number'); + if (mapped) return { ...mapped, nullable }; this.checkIdentity(key, declaration); this.models.set(key, { kind: 'enum', name, namespace: this.namespace(declaration), fields: [], members: members as { name: string; value: number | string }[] diff --git a/Source/Tools/ProxyGenerator/analyzeSource.ts b/Source/Tools/ProxyGenerator/analyzeSource.ts index a2644a35..e8b12312 100644 --- a/Source/Tools/ProxyGenerator/analyzeSource.ts +++ b/Source/Tools/ProxyGenerator/analyzeSource.ts @@ -10,6 +10,7 @@ import { SourceTypeResolver } from './SourceTypeResolver.js'; import type { SourceAnalysis } from './SourceAnalysis.js'; import type { SourceOperation } from './SourceOperation.js'; import { extractValidatorRules, type ValidatorRules } from './extractValidatorRules.js'; +import type { TypeMappings } from './typeMappings.js'; import { sourceProgram } from './sourceProgram.js'; import { warningOption } from './sourceOperationOptions.js'; import { resolveIdentityDetails } from './resolveIdentityDetails.js'; @@ -103,16 +104,18 @@ function discoverClasses(state: Collection, visit?: (declaration: ts.ClassDeclar /** Analyze commands, queries, models, and client-safe validation rules beneath an artifacts root. */ export function analyzeSource(project: string, artifacts: string, rootNamespace = '', generatedMetadata = false, program = sourceProgram(project), visit?: (declaration: ts.ClassDeclaration) => void, - contributingFiles = new Set()): SourceAnalysis & { readonly contributingFiles: readonly string[] } { + contributingFiles = new Set(), typeMappings?: TypeMappings): SourceAnalysis & { readonly contributingFiles: readonly string[] } { const checker = program.getTypeChecker(); const root = resolve(artifacts); const state: Collection = { checker, program, root, rootNamespace, hasMetadata: generatedMetadata, resolver: new SourceTypeResolver(checker, root, generatedMetadata, rootNamespace, - declaration => contributingFiles.add(resolve(declaration.getSourceFile().fileName))), + declaration => contributingFiles.add(resolve(declaration.getSourceFile().fileName)), typeMappings), diagnostics: [], operations: [], validators: [], contributingFiles, targets: new Map(), concepts: new Map() }; discoverClasses(state, visit); + for (const type of state.resolver.unusedMappings()) + state.diagnostics.push(`Type mapping '${type}' did not match any type reachable from ${root}; check its namespace-qualified name`); if (!state.operations.length) throw new Error(`No @command or @readModel queries below ${root} in ${project}`); const recordedRules = collectSourceRules(state.targets, state.concepts, state.validators, state.operations, state.diagnostics); return { operations: state.operations, models: [...state.resolver.models.values()], contributingFiles: [...contributingFiles], recordedRules, diff --git a/Source/Tools/ProxyGenerator/cli.ts b/Source/Tools/ProxyGenerator/cli.ts index 70f9073b..2f7c482e 100644 --- a/Source/Tools/ProxyGenerator/cli.ts +++ b/Source/Tools/ProxyGenerator/cli.ts @@ -13,7 +13,8 @@ import { watchSource } from './watchSource.js'; const usage = 'Usage: arc-proxygenerator --project --artifacts --output ' + ' [--metadata | --use-generated-metadata] [--check-metadata | --watch]' + ' [--root-namespace ] [--api-prefix=] [--segments-to-skip ]' + - ' [--use-proxy-file-suffix] [--skip-index-generation] [--skip-output-deletion] [--emit-interfaces] [--skip-react-hooks]'; + ' [--use-proxy-file-suffix] [--skip-index-generation] [--skip-output-deletion] [--emit-interfaces] [--skip-react-hooks]' + + ' [--type-mapping =[#]]...'; async function generateManifest(): Promise { const [manifestPath, outputRoot, extra] = process.argv.slice(2); diff --git a/Source/Tools/ProxyGenerator/for_analyzeSource/given/collision_project/artifacts/Orders/Shadow.ts b/Source/Tools/ProxyGenerator/for_analyzeSource/given/collision_project/artifacts/Orders/Shadow.ts new file mode 100644 index 00000000..2af8ee98 --- /dev/null +++ b/Source/Tools/ProxyGenerator/for_analyzeSource/given/collision_project/artifacts/Orders/Shadow.ts @@ -0,0 +1,11 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { command } from '@cratis/arc.core'; +import { field } from '@cratis/fundamentals'; +import { Command as SharedCommand } from '../Shared/Command.js'; + +@command() +export class Shadow { + @field(SharedCommand) other!: SharedCommand; + handle(): void {} +} diff --git a/Source/Tools/ProxyGenerator/for_analyzeSource/given/collision_project/artifacts/Shared/Command.ts b/Source/Tools/ProxyGenerator/for_analyzeSource/given/collision_project/artifacts/Shared/Command.ts new file mode 100644 index 00000000..d217f1af --- /dev/null +++ b/Source/Tools/ProxyGenerator/for_analyzeSource/given/collision_project/artifacts/Shared/Command.ts @@ -0,0 +1,7 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { field } from '@cratis/fundamentals'; + +export class Command { + @field(String) name!: string; +} diff --git a/Source/Tools/ProxyGenerator/for_analyzeSource/given/collision_project/tsconfig.json b/Source/Tools/ProxyGenerator/for_analyzeSource/given/collision_project/tsconfig.json new file mode 100644 index 00000000..bd0fb67e --- /dev/null +++ b/Source/Tools/ProxyGenerator/for_analyzeSource/given/collision_project/tsconfig.json @@ -0,0 +1 @@ +{"extends":"../../../../../../tsconfig.json","compilerOptions":{"composite":false,"noEmit":true},"include":["artifacts/**/*"]} diff --git a/Source/Tools/ProxyGenerator/for_analyzeSource/given/mapped_project/artifacts/Orders/Order.ts b/Source/Tools/ProxyGenerator/for_analyzeSource/given/mapped_project/artifacts/Orders/Order.ts new file mode 100644 index 00000000..edc16544 --- /dev/null +++ b/Source/Tools/ProxyGenerator/for_analyzeSource/given/mapped_project/artifacts/Orders/Order.ts @@ -0,0 +1,13 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { enumeration } from '@cratis/arc.core'; +import { field } from '@cratis/fundamentals'; +import { Currency } from '../Shared/Currency.js'; +import { Entity } from '../Shared/Entity.js'; +import { Money } from '../Shared/Money.js'; + +export class Order extends Entity { + @field(Money) total!: Money; + @field(Money, true) lines!: Money[]; + @enumeration(Currency) @field(String) currency!: Currency; +} diff --git a/Source/Tools/ProxyGenerator/for_analyzeSource/given/mapped_project/artifacts/Orders/Orders.ts b/Source/Tools/ProxyGenerator/for_analyzeSource/given/mapped_project/artifacts/Orders/Orders.ts new file mode 100644 index 00000000..dfd10d6b --- /dev/null +++ b/Source/Tools/ProxyGenerator/for_analyzeSource/given/mapped_project/artifacts/Orders/Orders.ts @@ -0,0 +1,10 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { readModel, query } from '@cratis/arc.core'; +import { Order } from './Order.js'; + +@readModel() +export class Orders { + @query() + static all(): Order[] { return []; } +} diff --git a/Source/Tools/ProxyGenerator/for_analyzeSource/given/mapped_project/artifacts/Orders/PlaceOrder.ts b/Source/Tools/ProxyGenerator/for_analyzeSource/given/mapped_project/artifacts/Orders/PlaceOrder.ts new file mode 100644 index 00000000..02cd0283 --- /dev/null +++ b/Source/Tools/ProxyGenerator/for_analyzeSource/given/mapped_project/artifacts/Orders/PlaceOrder.ts @@ -0,0 +1,12 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { command } from '@cratis/arc.core'; +import { field } from '@cratis/fundamentals'; +import { Money } from '../Shared/Money.js'; +import { Order } from './Order.js'; + +@command() +export class PlaceOrder { + @field(Money) price!: Money; + handle(): Order { return new Order(); } +} diff --git a/Source/Tools/ProxyGenerator/for_analyzeSource/given/mapped_project/artifacts/Orders/Prices.ts b/Source/Tools/ProxyGenerator/for_analyzeSource/given/mapped_project/artifacts/Orders/Prices.ts new file mode 100644 index 00000000..36696505 --- /dev/null +++ b/Source/Tools/ProxyGenerator/for_analyzeSource/given/mapped_project/artifacts/Orders/Prices.ts @@ -0,0 +1,10 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { readModel, query } from '@cratis/arc.core'; +import { Money } from '../Shared/Money.js'; + +@readModel() +export class Prices { + @query() + static current(): Money[] { return []; } +} diff --git a/Source/Tools/ProxyGenerator/for_analyzeSource/given/mapped_project/artifacts/Shared/Currency.ts b/Source/Tools/ProxyGenerator/for_analyzeSource/given/mapped_project/artifacts/Shared/Currency.ts new file mode 100644 index 00000000..7b3958c8 --- /dev/null +++ b/Source/Tools/ProxyGenerator/for_analyzeSource/given/mapped_project/artifacts/Shared/Currency.ts @@ -0,0 +1,3 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +export enum Currency { Nok = 'nok', Usd = 'usd' } diff --git a/Source/Tools/ProxyGenerator/for_analyzeSource/given/mapped_project/artifacts/Shared/Entity.ts b/Source/Tools/ProxyGenerator/for_analyzeSource/given/mapped_project/artifacts/Shared/Entity.ts new file mode 100644 index 00000000..a641861e --- /dev/null +++ b/Source/Tools/ProxyGenerator/for_analyzeSource/given/mapped_project/artifacts/Shared/Entity.ts @@ -0,0 +1,7 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { field } from '@cratis/fundamentals'; + +export class Entity { + @field(String) id!: string; +} diff --git a/Source/Tools/ProxyGenerator/for_analyzeSource/given/mapped_project/artifacts/Shared/Money.ts b/Source/Tools/ProxyGenerator/for_analyzeSource/given/mapped_project/artifacts/Shared/Money.ts new file mode 100644 index 00000000..ed93ab30 --- /dev/null +++ b/Source/Tools/ProxyGenerator/for_analyzeSource/given/mapped_project/artifacts/Shared/Money.ts @@ -0,0 +1,8 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { field } from '@cratis/fundamentals'; + +export class Money { + @field(Number) amount!: number; + @field(String) currency!: string; +} diff --git a/Source/Tools/ProxyGenerator/for_analyzeSource/given/mapped_project/packages/money/index.ts b/Source/Tools/ProxyGenerator/for_analyzeSource/given/mapped_project/packages/money/index.ts new file mode 100644 index 00000000..a242aed9 --- /dev/null +++ b/Source/Tools/ProxyGenerator/for_analyzeSource/given/mapped_project/packages/money/index.ts @@ -0,0 +1,16 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { field } from '@cratis/fundamentals'; + +/** A stand-in for a model published from another package. */ +export class Money { + @field(Number) amount!: number; + @field(String) currency!: string; +} +export class Entity { + @field(String) id!: string; +} +export enum Currency { Nok = 'nok', Usd = 'usd' } +export class Price { + @field(Number) amount!: number; +} diff --git a/Source/Tools/ProxyGenerator/for_analyzeSource/given/mapped_project/packages/money/package.json b/Source/Tools/ProxyGenerator/for_analyzeSource/given/mapped_project/packages/money/package.json new file mode 100644 index 00000000..09efd107 --- /dev/null +++ b/Source/Tools/ProxyGenerator/for_analyzeSource/given/mapped_project/packages/money/package.json @@ -0,0 +1 @@ +{ "name": "@acme/money", "version": "1.0.0", "private": true, "type": "module", "main": "index.ts" } diff --git a/Source/Tools/ProxyGenerator/for_analyzeSource/given/mapped_project/tsconfig.json b/Source/Tools/ProxyGenerator/for_analyzeSource/given/mapped_project/tsconfig.json new file mode 100644 index 00000000..bd0fb67e --- /dev/null +++ b/Source/Tools/ProxyGenerator/for_analyzeSource/given/mapped_project/tsconfig.json @@ -0,0 +1 @@ +{"extends":"../../../../../../tsconfig.json","compilerOptions":{"composite":false,"noEmit":true},"include":["artifacts/**/*"]} diff --git a/Source/Tools/ProxyGenerator/for_analyzeSource/when_mapping_types/with_a_different_export_name.ts b/Source/Tools/ProxyGenerator/for_analyzeSource/when_mapping_types/with_a_different_export_name.ts new file mode 100644 index 00000000..ad9d194a --- /dev/null +++ b/Source/Tools/ProxyGenerator/for_analyzeSource/when_mapping_types/with_a_different_export_name.ts @@ -0,0 +1,22 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { resolve } from 'node:path'; +import { analyzeSource } from '../../analyzeSource.js'; +import { renderSource } from '../../renderSource.js'; + +const root = resolve(import.meta.dirname, '../given/mapped_project'); + +describe('when mapping a type to a package export with another name', () => { + let files: ReturnType; + beforeEach(() => { + files = renderSource(analyzeSource(resolve(root, 'tsconfig.json'), resolve(root, 'artifacts'), '', true, undefined, undefined, undefined, + { 'Shared.Money': { package: '@acme/money', export: 'Price' } })); + }); + it('should import and use the exported name', () => { + files.get('Orders/PlaceOrder.ts')!.should.include("import { Price } from '@acme/money';") + .and.include("new PropertyDescriptor('price', Price, false)"); + }); + it('should still generate the unmapped types', () => { + [...files.keys()].should.include.members(['Shared/Entity.ts', 'Shared/Currency.ts']).and.not.include('Shared/Money.ts'); + }); +}); diff --git a/Source/Tools/ProxyGenerator/for_analyzeSource/when_mapping_types/with_a_package_mapping.ts b/Source/Tools/ProxyGenerator/for_analyzeSource/when_mapping_types/with_a_package_mapping.ts new file mode 100644 index 00000000..ebdf4f97 --- /dev/null +++ b/Source/Tools/ProxyGenerator/for_analyzeSource/when_mapping_types/with_a_package_mapping.ts @@ -0,0 +1,49 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { resolve } from 'node:path'; +import { analyzeSource } from '../../analyzeSource.js'; +import { renderSource } from '../../renderSource.js'; +import type { TypeMappings } from '../../typeMappings.js'; + +const root = resolve(import.meta.dirname, '../given/mapped_project'); +const artifacts = resolve(root, 'artifacts'); +const money = { package: '@acme/money' }; +const mappings: TypeMappings = { 'Shared.Money': money, 'Shared.Entity': money, 'Shared.Currency': money }; + +describe('when mapping types to a package', () => { + let files: ReturnType; + let diagnostics: string[]; + beforeEach(() => { + diagnostics = []; + files = renderSource(analyzeSource(resolve(root, 'tsconfig.json'), artifacts, '', true, undefined, undefined, undefined, mappings), + { onDiagnostic: message => diagnostics.push(message) }); + }); + it('should not generate the mapped types', () => { + [...files.keys()].filter(path => path.startsWith('Shared/')).should.deep.equal([]); + }); + it('should still generate every other type and operation', () => { + [...files.keys()].should.include.members(['Orders/Order.ts', 'Orders/PlaceOrder.ts', 'Orders/All.ts', 'Orders/Current.ts']); + }); + it('should import a mapped class in a command and use it as the runtime constructor', () => { + files.get('Orders/PlaceOrder.ts')!.should.include("import { Money } from '@acme/money';") + .and.include('new PropertyDescriptor(\'price\', Money, false)'); + }); + it('should import a mapped class in a model', () => { + files.get('Orders/Order.ts')!.should.include("import { Money } from '@acme/money';").and.include('@field(Money)'); + }); + it('should extend a mapped base class from its package', () => { + files.get('Orders/Order.ts')!.should.include("import { Entity } from '@acme/money';").and.include('export class Order extends Entity'); + }); + it('should import a mapped enum and keep its primitive constructor', () => { + files.get('Orders/Order.ts')!.should.include("import { Currency } from '@acme/money';").and.include('@field(String)'); + }); + it('should import a mapped array element in a query', () => { + files.get('Orders/Current.ts')!.should.include("import { Money } from '@acme/money';").and.include('Money[]'); + }); + it('should keep the route', () => { + files.get('Orders/PlaceOrder.ts')!.should.include("route: string = '/api/orders/place-order'"); + }); + it('should not report a diagnostic when every mapping matched', () => { + diagnostics.should.deep.equal([]); + }); +}); diff --git a/Source/Tools/ProxyGenerator/for_analyzeSource/when_mapping_types/with_generated_typescript.ts b/Source/Tools/ProxyGenerator/for_analyzeSource/when_mapping_types/with_generated_typescript.ts new file mode 100644 index 00000000..6a6cc7aa --- /dev/null +++ b/Source/Tools/ProxyGenerator/for_analyzeSource/when_mapping_types/with_generated_typescript.ts @@ -0,0 +1,39 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { resolve } from 'node:path'; +import ts from 'typescript'; +import { analyzeSource } from '../../analyzeSource.js'; +import { renderSource } from '../../renderSource.js'; + +const root = resolve(import.meta.dirname, '../given/mapped_project'); +const money = { package: '@acme/money' }; + +describe('when compiling generated output that references mapped types', () => { + let diagnostics: readonly ts.Diagnostic[]; + beforeEach(() => { + const files = renderSource(analyzeSource(resolve(root, 'tsconfig.json'), resolve(root, 'artifacts'), '', true, undefined, undefined, undefined, + { 'Shared.Money': money, 'Shared.Entity': money, 'Shared.Currency': money }), { jsImportSpecifiers: true }); + const clientRoot = resolve(process.cwd(), 'ContractTests/Client'); + const clientSource = resolve(clientRoot, 'src'); + const virtual = new Map([...files].map(([path, text]) => [resolve(clientSource, path), text])); + const config = ts.readConfigFile(resolve(clientRoot, 'tsconfig.json'), ts.sys.readFile); + const parsed = ts.convertCompilerOptionsFromJson(config.config.compilerOptions, clientRoot); + if (parsed.errors.length) throw new Error(ts.formatDiagnostics(parsed.errors, { + getCanonicalFileName: path => path, getCurrentDirectory: () => clientRoot, getNewLine: () => '\n' + })); + // The tiny local package stands in for @acme/money; the paths target is absolute so no base URL is needed. + const options = { ...parsed.options, noEmit: true, skipLibCheck: true, rootDir: process.cwd(), + paths: { '@acme/money': [resolve(root, 'packages/money/index.ts')] } }; + const host = ts.createCompilerHost(options); + const read = host.readFile.bind(host); + const exists = host.fileExists.bind(host); + host.readFile = path => virtual.get(path) ?? read(path); + host.fileExists = path => virtual.has(path) || exists(path); + const directoryExists = host.directoryExists?.bind(host); + host.directoryExists = path => path.startsWith(clientSource) || directoryExists?.(path) === true; + diagnostics = ts.getPreEmitDiagnostics(ts.createProgram([...virtual.keys()], options, host)); + }); + it('should compile the generated proxies against the mapped package with strict settings', () => { + diagnostics.map(diagnostic => ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n')).should.deep.equal([]); + }); +}); diff --git a/Source/Tools/ProxyGenerator/for_analyzeSource/when_mapping_types/with_invalid_or_conflicting_mappings.ts b/Source/Tools/ProxyGenerator/for_analyzeSource/when_mapping_types/with_invalid_or_conflicting_mappings.ts new file mode 100644 index 00000000..2ae4d9d9 --- /dev/null +++ b/Source/Tools/ProxyGenerator/for_analyzeSource/when_mapping_types/with_invalid_or_conflicting_mappings.ts @@ -0,0 +1,49 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { resolve } from 'node:path'; +import { analyzeSource } from '../../analyzeSource.js'; +import { renderSource } from '../../renderSource.js'; +import { parseSourceOptions } from '../../parseSourceOptions.js'; +import { parseTypeMappingOptions, type TypeMappings } from '../../typeMappings.js'; + +const project = resolve(import.meta.dirname, '../given/collision_project/tsconfig.json'); +const artifacts = resolve(import.meta.dirname, '../given/collision_project/artifacts'); +const analyze = (mappings: TypeMappings, diagnostics: string[] = []) => renderSource(analyzeSource(project, artifacts, '', true, undefined, undefined, + undefined, mappings), { onDiagnostic: message => diagnostics.push(message) }); + +describe('when mapping types with invalid or conflicting settings', () => { + it('should fail when a mapped export collides with an import the generator needs', () => { + (() => analyze({ 'Shared.Command': { package: '@acme/money' } })).should.throw(/Type mapping export 'Command' collides.*Orders\/Shadow\.ts/); + }); + it('should fail when a mapped export collides with a generated declaration', () => { + (() => analyze({ 'Shared.Command': { package: '@acme/money', export: 'Shadow' } })).should.throw(/'Shadow' collides.*Orders\/Shadow\.ts/); + }); + it('should fail when a key is not a namespace-qualified name', () => { + (() => analyze({ 'Shared/Command': { package: '@acme/money' } })).should.throw(/Invalid type mapping key/); + }); + it('should fail when the package is not a package name', () => { + (() => analyze({ 'Shared.Command': { package: "x'; evil" } })).should.throw(/Invalid package/); + }); + it('should fail when the export is not an identifier', () => { + (() => analyze({ 'Shared.Command': { package: '@acme/money', export: 'not valid' } })).should.throw(/Invalid export/); + }); + it('should report a mapping that matched no type', () => { + const diagnostics: string[] = []; + analyze({ 'Shared.Comand': { package: '@acme/money' } }, diagnostics); + diagnostics.should.deep.equal([expectedUnused()]); + }); + it('should fail when the command line maps one type twice', () => { + (() => parseTypeMappingOptions(['Shared.Money=@a/one', 'Shared.Money=@b/two'])).should.throw(/Duplicate type mapping for 'Shared.Money'/); + }); + it('should fail on a malformed command line mapping', () => { + (() => parseTypeMappingOptions(['Shared.Money'])).should.throw(/expected =\[#\]/); + }); + it('should read repeated command line mappings with an optional export', () => { + parseSourceOptions(['--project', '/p', '--artifacts', '/a', '--output', '/o', '--type-mapping', 'A.Money=@acme/money#Price', + '--type-mapping=A.Entity=@acme/money'], 'usage').configuration.typeMappings!.should.deep.equal({ + 'A.Money': { package: '@acme/money', export: 'Price' }, 'A.Entity': { package: '@acme/money' } }); + }); +}); +function expectedUnused(): string { + return `Type mapping 'Shared.Comand' did not match any type reachable from ${artifacts}; check its namespace-qualified name`; +} diff --git a/Source/Tools/ProxyGenerator/for_analyzeSource/when_mapping_types/with_skipped_react_hooks.ts b/Source/Tools/ProxyGenerator/for_analyzeSource/when_mapping_types/with_skipped_react_hooks.ts new file mode 100644 index 00000000..4cf83b63 --- /dev/null +++ b/Source/Tools/ProxyGenerator/for_analyzeSource/when_mapping_types/with_skipped_react_hooks.ts @@ -0,0 +1,24 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { resolve } from 'node:path'; +import { analyzeSource } from '../../analyzeSource.js'; +import { renderSource } from '../../renderSource.js'; +import type { TypeMappings } from '../../typeMappings.js'; + +const root = resolve(import.meta.dirname, '../given/mapped_project'); +const mappings: TypeMappings = { 'Shared.Money': { package: '@acme/money' } }; + +describe('when mapping types and skipping react hooks', () => { + let files: ReturnType; + beforeEach(() => { + files = renderSource(analyzeSource(resolve(root, 'tsconfig.json'), resolve(root, 'artifacts'), '', true, undefined, undefined, undefined, mappings), + { skipReactHooks: true }); + }); + it('should still import the mapped type in a command', () => { + files.get('Orders/PlaceOrder.ts')!.should.include("import { Money } from '@acme/money';"); + }); + it('should not import react hooks', () => { + files.get('Orders/PlaceOrder.ts')!.should.not.include('@cratis/arc.react'); + files.get('Orders/Current.ts')!.should.not.include('@cratis/arc.react'); + }); +}); diff --git a/Source/Tools/ProxyGenerator/generateFromSource.ts b/Source/Tools/ProxyGenerator/generateFromSource.ts index 72887638..ff4a08ab 100644 --- a/Source/Tools/ProxyGenerator/generateFromSource.ts +++ b/Source/Tools/ProxyGenerator/generateFromSource.ts @@ -10,6 +10,7 @@ import { preflightGeneratedMetadata } from './publishGeneratedMetadata.js'; import { buildSourceFiles } from './buildSourceFiles.js'; import { preflightSourceFiles } from './preflightSourceFiles.js'; import { publishSourceFiles } from './publishSourceFiles.js'; +import type { TypeMappings } from './typeMappings.js'; import { isColocatedOutput } from './isColocatedOutput.js'; /** Options for generating browser clients from TypeScript source. */ @@ -21,6 +22,8 @@ export interface SourceGeneratorOptions extends SourceRenderOptions { readonly skipIndexGeneration?: boolean; readonly skipOutputDeletion?: boolean; readonly emitInterfaces?: boolean; + /** Import these types from other packages instead of generating them, keyed by namespace-qualified type name. */ + readonly typeMappings?: TypeMappings; /** Absolute path of an optional source-generated server metadata module. */ readonly metadata?: string; /** Opt into source-inferred bindings even when this invocation does not publish metadata. */ @@ -42,7 +45,7 @@ export async function generateFromSource(options: SourceGeneratorOptions): Promi const contributingFiles = new Set(); const collector = options.metadata ? metadataCollector(program, options.metadata, contributingFiles) : undefined; const analysis = analyzeSource(options.project, artifacts, options.rootNamespace, - !!collector || options.generatedMetadata === true, program, collector?.visit, contributingFiles); + !!collector || options.generatedMetadata === true, program, collector?.visit, contributingFiles, options.typeMappings); const colocated = await isColocatedOutput(artifacts, output, analysis); if (colocated && !options.useProxyFileSuffix) throw new Error('Output is co-located with backend artifacts; --use-proxy-file-suffix is required to keep generated files distinct from backend modules'); diff --git a/Source/Tools/ProxyGenerator/index.ts b/Source/Tools/ProxyGenerator/index.ts index aaf0666c..8f36a8df 100644 --- a/Source/Tools/ProxyGenerator/index.ts +++ b/Source/Tools/ProxyGenerator/index.ts @@ -7,6 +7,7 @@ export { renderSource } from './renderSource.js'; export { generateFromSource } from './generateFromSource.js'; export type { SourceGeneratorOptions } from './generateFromSource.js'; export type { SourceRenderOptions } from './renderSource.js'; +export type { TypeMapping, TypeMappings } from './typeMappings.js'; export type { SourceAnalysis } from './SourceAnalysis.js'; export type { SourceOperation } from './SourceOperation.js'; export type { SourceModel } from './SourceModel.js'; diff --git a/Source/Tools/ProxyGenerator/package.json b/Source/Tools/ProxyGenerator/package.json index bbc9e8a2..592905cc 100644 --- a/Source/Tools/ProxyGenerator/package.json +++ b/Source/Tools/ProxyGenerator/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.proxygenerator", - "version": "0.51.0", + "version": "0.52.0", "description": "TypeScript source analyzer and deterministic Arc client proxy generator", "repository": { "type": "git", diff --git a/Source/Tools/ProxyGenerator/parseSourceOptions.ts b/Source/Tools/ProxyGenerator/parseSourceOptions.ts index 19c043b2..791614dd 100644 --- a/Source/Tools/ProxyGenerator/parseSourceOptions.ts +++ b/Source/Tools/ProxyGenerator/parseSourceOptions.ts @@ -1,6 +1,7 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. import type { SourceGeneratorOptions } from './generateFromSource.js'; +import { parseTypeMappingOptions } from './typeMappings.js'; /** Parse the source-generation CLI options without changing its accepted switches. */ export function parseSourceOptions(values: readonly string[], usage: string): @@ -10,8 +11,16 @@ export function parseSourceOptions(values: readonly string[], usage: string): '--skip-index-generation', '--skip-output-deletion', '--emit-interfaces', '--watch', '--check-metadata', '--use-generated-metadata', '--skip-react-hooks']; const arguments_ = ['--project', '--artifacts', '--output', '--metadata', '--segments-to-skip', '--api-prefix', '--root-namespace']; + const repeatable = '--type-mapping'; + const typeMappings: string[] = []; for (let index = 0; index < values.length; index++) { const [key, attached] = values[index]!.split(/=(.*)/s, 2); + if (key === repeatable) { + const value = attached ?? values[++index]; + if (value === undefined || value === '' || value.startsWith('--')) throw new Error(`Missing value for ${key}`); + typeMappings.push(value); + continue; + } if ((!flags.includes(key!) && !arguments_.includes(key!)) || key! in options || flags.includes(key!) && attached !== undefined) throw new Error(`Unknown or duplicate option: ${values[index]}`); if (flags.includes(key!)) options[key!] = true; @@ -38,7 +47,8 @@ export function parseSourceOptions(values: readonly string[], usage: string): skipIndexGeneration: options['--skip-index-generation'] === true, skipOutputDeletion: options['--skip-output-deletion'] === true, emitInterfaces: options['--emit-interfaces'] === true, - skipReactHooks: options['--skip-react-hooks'] === true + skipReactHooks: options['--skip-react-hooks'] === true, + typeMappings: typeMappings.length ? parseTypeMappingOptions(typeMappings) : undefined }; return { configuration, watch: options['--watch'] === true, checkMetadata: options['--check-metadata'] === true }; } diff --git a/Source/Tools/ProxyGenerator/renderSource.ts b/Source/Tools/ProxyGenerator/renderSource.ts index 19421363..cadb9ea5 100644 --- a/Source/Tools/ProxyGenerator/renderSource.ts +++ b/Source/Tools/ProxyGenerator/renderSource.ts @@ -77,8 +77,8 @@ export function aliasTypes(types: readonly SourceType[], source: string, destina } export function renderModel(model: SourceModel, path: string, destinations: ReadonlyMap, options: SourceRenderOptions = {}): string { if (model.kind === 'enum') return `export enum ${model.name} {\n${model.members!.map(member => ` ${member.name} = ${typeof member.value === 'string' ? quote(member.value) : member.value},`).join('\n')}\n}\n`; - const base = model.base ? { text: model.base, constructor: model.base, model: model.base, modelKey: model.baseKey, - enumerable: false, nullable: false, void: false } : undefined; + const base: SourceType | undefined = model.base ? { text: model.base, constructor: model.base, model: model.basePackage ? undefined : model.base, + modelKey: model.baseKey, package: model.basePackage, mapped: !!model.basePackage, enumerable: false, nullable: false, void: false } : undefined; const types = aliasTypes([...model.fields.map(field => field.type), ...(base ? [base] : [])], path, destinations, model.name); model = { ...model, fields: model.fields.map((field, index) => ({ ...field, type: types[index]! })), base: base && types.at(-1)!.text }; const baseType = base && types.at(-1); @@ -99,6 +99,23 @@ export function renderModel(model: SourceModel, path: string, destinations: Read const fields = model.fields.map(field => ` @field(${field.type.constructor}${field.type.enumerable ? ', true' : ''})\n ${wireName(field.name)}${field.optional ? '?' : '!'}: ${field.type.text}${field.nullable ? ' | null' : ''};`).join('\n\n'); return `${imports.join('\n')}${imports.length ? '\n\n' : ''}${model.derivedTypeId ? `@derivedType(${quote(model.derivedTypeId)})\n` : ''}export class ${model.name}${model.base ? ` extends ${model.base}` : ''} {${fields ? `\n${fields}\n` : '\n'}}\n`; } +const importedNames = /^import\s+(?:type\s+)?\{([^}]*)\}\s+from\s/gm; +const declaredNames = /^(?:export\s+)?(?:abstract\s+)?(?:class|interface|enum)\s+([A-Za-z_$][A-Za-z0-9_$]*)/gm; +/** A mapped type is imported under its export name; refuse a file where anything else already owns that name. */ +function checkMappedNames(path: string, text: string, mapped: ReadonlySet): void { + const names = [...text.matchAll(importedNames)].flatMap(match => match[1]!.split(',').map(part => part.trim().replace(/^type\s+/, '').split(/\s+as\s+/).at(-1)!)) + .concat([...text.matchAll(declaredNames)].map(match => match[1]!)); + for (const name of mapped) { + if (names.filter(candidate => candidate === name).length > 1) + throw new Error(`Type mapping export '${name}' collides with another import or declaration named '${name}' in ${path}`); + } +} +function mappedNames(analysis: SourceAnalysis): Set { + const types = [...analysis.models.flatMap(model => model.fields.map(field => field.type)), + ...analysis.operations.flatMap(operation => [operation.result, ...operation.fields.map(field => field.type)])]; + return new Set([...types.filter(type => type.mapped).map(type => type.text.replace(/\[\]$/, '')), + ...analysis.models.filter(model => model.basePackage).map(model => model.base!)]); +} /** Render analyzer results without importing or executing the application. */ export function renderSource(analysis: SourceAnalysis, options: SourceRenderOptions = {}): ReadonlyMap { const destinations = new Map(); @@ -106,7 +123,9 @@ export function renderSource(analysis: SourceAnalysis, options: SourceRenderOpti for (const message of analysis.diagnostics ?? []) diagnostic(message); const files = new Map(); const folded = new Set(); + const mapped = mappedNames(analysis); const add = (path: string, text: string): void => { + checkMappedNames(path, text, mapped); if (folded.has(path.toLowerCase())) throw new Error(`Generated output collision: ${path}`); folded.add(path.toLowerCase()); files.set(path, notice + (text.startsWith('export enum ') ? '' : `/* eslint-disable sort-imports */\n${text.includes('export interface I') ? '/* eslint-disable @typescript-eslint/no-empty-interface */\n' : ''}`) + '// eslint-disable-next-line header/header\n' + text); diff --git a/Source/Tools/ProxyGenerator/typeMappings.ts b/Source/Tools/ProxyGenerator/typeMappings.ts new file mode 100644 index 00000000..02a88e5f --- /dev/null +++ b/Source/Tools/ProxyGenerator/typeMappings.ts @@ -0,0 +1,46 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** Import a type from another package instead of generating it into the proxy tree. */ +export interface TypeMapping { + /** The npm package (optionally with a subpath) to import from. */ + readonly package: string; + /** The exported symbol; defaults to the mapped type's own name. */ + readonly export?: string; +} +/** Type mappings keyed by the generated type identity: root namespace, folders below the artifacts root, then the type name. */ +export type TypeMappings = Readonly>; +/** A validated mapping with its export resolved. */ +export interface ResolvedTypeMapping { readonly package: string; readonly export: string } + +const key = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/; +const identifier = /^[A-Za-z_$][A-Za-z0-9_$]*$/; +const packageName = /^(@[a-z0-9~][a-z0-9._~-]*\/)?[a-z0-9~][a-z0-9._~-]*(\/[A-Za-z0-9._~-]+)*$/; + +/** Validate mappings once, up front, so a bad entry fails before any analysis or output. */ +export function resolveTypeMappings(mappings: TypeMappings | undefined): ReadonlyMap { + const resolved = new Map(); + for (const [type, mapping] of Object.entries(mappings ?? {})) { + if (!key.test(type)) throw new Error(`Invalid type mapping key '${type}': use the type's namespace-qualified name, for example Orders.Money`); + if (typeof mapping?.package !== 'string' || !packageName.test(mapping.package)) + throw new Error(`Invalid package for type mapping '${type}': ${JSON.stringify(mapping?.package)}`); + const exportName = mapping.export ?? type.split('.').at(-1)!; + if (!identifier.test(exportName)) throw new Error(`Invalid export for type mapping '${type}': ${JSON.stringify(exportName)}`); + resolved.set(type, { package: mapping.package, export: exportName }); + } + return resolved; +} + +/** Parse repeated `--type-mapping =[#]` values. A type may be mapped once. */ +export function parseTypeMappingOptions(values: readonly string[]): TypeMappings { + const mappings: Record = {}; + for (const value of values) { + const match = /^([^=]+)=([^#=]+)(?:#([^#=]+))?$/.exec(value); + if (!match) throw new Error(`Invalid --type-mapping '${value}': expected =[#]`); + const [, type, pkg, exportName] = match as unknown as [string, string, string, string | undefined]; + if (Object.hasOwn(mappings, type)) throw new Error(`Duplicate type mapping for '${type}'`); + mappings[type] = exportName === undefined ? { package: pkg } : { package: pkg, export: exportName }; + } + resolveTypeMappings(mappings); + return mappings; +} diff --git a/Source/Tools/ProxyGenerator/watchSource.ts b/Source/Tools/ProxyGenerator/watchSource.ts index 06dd3e04..7d2f82d9 100644 --- a/Source/Tools/ProxyGenerator/watchSource.ts +++ b/Source/Tools/ProxyGenerator/watchSource.ts @@ -34,7 +34,7 @@ export async function watchSource(configuration: SourceGeneratorOptions, generat const outputRoot = await realpath(configuration.output); const program = sourceProgram(configuration.project); const analysis = analyzeSource(configuration.project, root, configuration.rootNamespace, - !!configuration.metadata || configuration.generatedMetadata === true, program); + !!configuration.metadata || configuration.generatedMetadata === true, program, undefined, undefined, configuration.typeMappings); const separateOutput = !(await isColocatedOutput(root, outputRoot, analysis)); const watched = new Set(await externalFiles(root, outputRoot, metadata, separateOutput, program)); let timer: NodeJS.Timeout | undefined, pending: Promise = Promise.resolve(); diff --git a/yarn.lock b/yarn.lock index a4dc6170..d860e747 100644 --- a/yarn.lock +++ b/yarn.lock @@ -110,8 +110,8 @@ __metadata: rxjs: "npm:^7.8.2" zod: "npm:^4.1.0" peerDependencies: - "@cratis/arc.core": ^0.51.0 - "@cratis/arc.testing": ^0.51.0 + "@cratis/arc.core": ^0.52.0 + "@cratis/arc.testing": ^0.52.0 "@cratis/chronicle": ^6.17.0 "@cratis/fundamentals": ^7.19.6 rxjs: ^7.8.2 @@ -216,7 +216,7 @@ __metadata: rxjs: "npm:^7.8.2" sql.js: "npm:^1.14.2" peerDependencies: - "@cratis/arc.core": ^0.51.0 + "@cratis/arc.core": ^0.52.0 "@cratis/fundamentals": ^7.19.6 drizzle-orm: ^0.45.0 rxjs: ^7.8.2 @@ -282,7 +282,7 @@ __metadata: mongodb: "npm:^6.21.0" rxjs: "npm:^7.8.2" peerDependencies: - "@cratis/arc.core": ^0.51.0 + "@cratis/arc.core": ^0.52.0 "@cratis/fundamentals": ^7.19.6 "@opentelemetry/api": ^1.9.0 mongodb: ^6.21.0