diff --git a/.gitignore b/.gitignore index 5cc3074..2916c47 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,13 @@ coverage/ coverage-cli/ CLAUDE.md _gitless/ + +# Design specs and brainstorming artifacts are kept local-only. +# The repo holds runnable code + tests + docs meant for publication; +# per-issue design specs and superpowers/ scratchpads live adjacent +# to the code but aren't part of the published history. +docs/superpowers/ +tsadwyn-issue-*.md +tsadwyn-issues-*.md +consumer-integration-followups.md +migrate-to-latest.md diff --git a/README.md b/README.md index 546d656..7108e11 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,582 @@ tsadwyn automatically migrates requests from old versions to the latest format b For full documentation on the head-first API versioning pattern, see the [Cadwyn docs](https://docs.cadwyn.dev/) — the concepts carry over directly. +## Client pinning + +tsadwyn implements the **Stripe-style per-client pinning** model. Every client is associated with a specific API version — the **version they signed up under** or upgraded to — and tsadwyn migrates requests and responses to match that pin transparently. The client never sees a behavior change unless they explicitly upgrade. + +Three terms you'll see throughout the docs: + +- **initial version** — the oldest supported version in the bundle. Clients that signed up before any changes shipped are pinned here. Still a first-class contract. +- **previous version** — any version one step back from the latest. Useful when discussing "clients on the version right before we added X". +- **latest / head version** — the newest version. Business logic runs against this shape; all migrations are expressed relative to it. + +Two widely-used patterns for deciding which version a request runs under: + +### 1. Explicit per-request header + +The simplest: the client sets `x-api-version` (or Stripe's `stripe-version`) on every request. Works out of the box with `new Tsadwyn({ apiVersionHeaderName: 'x-api-version' })`. + +### 2. Per-client default from your database (Stripe's model) + +Each client has a pinned version stored in a DB row. When no header is present, tsadwyn resolves the default from the authenticated identity. Pair the `preVersionPick` hook (runs auth before version resolution) with the `perClientDefaultVersion` helper: + +```ts +import { Tsadwyn, perClientDefaultVersion } from 'tsadwyn'; + +const app = new Tsadwyn({ + versions: /* ... */, + + preVersionPick: (req, res, next) => { + authenticate(req) + .then(user => { (req as any).user = user; next(); }) + .catch(next); + }, + + apiVersionDefaultValue: perClientDefaultVersion({ + identify: req => (req as any).user?.accountId ?? null, + resolvePin: accountId => accountRepo.getApiVersion(accountId), + saveVersion: (accountId, v) => accountRepo.setApiVersion(accountId, v), + fallback: bundle.versionValues[0], // latest — Stripe's "pin to current latest at signup" model + pinOnFirstResolve: true, // persist the pin on first authenticated call + supportedVersions: bundle.versionValues, + onStalePin: 'fallback', // if stored pin isn't in bundle + }), + + // Strict 400 when the header names a version that isn't in the bundle. + // Default is 'passthrough' (preserves historical behavior). + // Pass to versionPickingMiddleware options when you own the picker. +}); +``` + +An explicit `x-api-version` header always wins over the resolver — useful for staging, per-request overrides, and admin tooling. + +### High-traffic caching — `cachedPerClientDefaultVersion` + +`perClientDefaultVersion` calls `resolvePin` on **every** authenticated request that doesn't send an explicit version header. For a modest API that's fine — it's one indexed lookup per request, and you were probably going to touch the DB anyway. For a high-QPS API (or one where the pin lives in a slow upstream service), that call goes from "cheap" to "dominant cost." `cachedPerClientDefaultVersion` is the same resolver with a cross-request cache layered on top, plus the invalidation machinery you need to keep the cache honest. + +```ts +import { cachedPerClientDefaultVersion } from 'tsadwyn'; + +const { resolver, invalidate, invalidateAll } = cachedPerClientDefaultVersion({ + identify: req => (req as any).user?.accountId ?? null, + resolvePin: accountId => accountRepo.getApiVersion(accountId), + fallback: bundle.versionValues[0], + supportedVersions: bundle.versionValues, + ttlMs: 5 * 60 * 1000, // default — cap on how stale one pod's view can be + // pinOnFirstResolve + saveVersion are both supported; the newly persisted pin + // is written to the cache, so the second request skips storage entirely. +}); + +const app = new Tsadwyn({ versions, apiVersionDefaultValue: resolver }); +``` + +**Three things it gives you that the uncached form doesn't:** + +1. **Amortized reads.** The first request for a client goes to storage; subsequent requests within `ttlMs` return from the in-memory map. The cache key is whatever `identify(req)` returns — most people key on `user.accountId` or an equivalent stable id. + +2. **Single-flight concurrent first-misses.** When a client hits your API cold (no cache entry) with, say, 20 parallel requests during a post-deploy ramp, only *one* call to `resolvePin` fires — the other 19 await the same promise. Without single-flight, a cold cache + bursty traffic = N duplicate queries for the same client, which is usually the exact scenario a cache is supposed to solve. The helper handles this for you. + +3. **Explicit invalidation handles.** The returned tuple includes `invalidate(clientId)` and `invalidateAll()`. Call `invalidate` from your upgrade handler immediately after persisting the new pin so the next request sees the update instantly: + +```ts +// In the handler for POST /versioning (or wherever you persist upgrades): +await accountRepo.setApiVersion(accountId, newVersion); +invalidate(accountId); // CRITICAL — otherwise stale up to ttlMs +``` + +If you forget this call, a client who just upgraded will keep being served under their old pin for up to `ttlMs`. The TTL is a safety net (see below), not a correctness layer. + +**The TTL's real job: multi-instance drift.** In a rolling-deploy / multi-pod setup, one pod handles the `POST /versioning` request, writes the DB, and calls `invalidate` on *its own* local cache. Every other pod still has the pre-upgrade pin cached. If you never invalidated at all, those pods would serve the old pin forever. The TTL bounds that staleness — after `ttlMs`, every pod re-reads storage regardless. Pick a TTL that matches your tolerance for cross-pod convergence time: 5 minutes is a good default for user-facing APIs; tighten to 30 seconds for internal services that mutate pins frequently. + +**Error semantics.** If `resolvePin` throws, the rejection *is not cached* — the cache entry is deleted on rejection and the next call retries fresh. This matches what production adopters landed on: transient DB failures shouldn't pin a client to `fallback` for the full TTL. + +**When NOT to use it.** If your auth middleware already resolves the client's pin (e.g., `req.user.apiVersion` is populated by the JWT's claims or set by an upstream gateway), don't cache a layer you don't need — just write `identify: req => req.user.apiVersion ?? null` and return it directly from the resolver (or point `apiVersionDefaultValue` at a plain function that reads it). Double-caching adds a TTL you have to invalidate and a cache key drift risk you don't need. + +`ttlMs: 0` disables caching entirely — every request falls through to `resolvePin`. Useful for testing and for environments where you want the cache interface (invalidation, single-flight) but not the staleness. + +### Stale pins — what they are, and why tsadwyn doesn't auto-heal them + +A **stale pin** is a stored pin value that points at a version no longer in the current `VersionBundle`. The pin was written at some point, persisted, and is now orphaned because the bundle has evolved out from under it. This happens in four realistic scenarios: + +1. **Version retirement.** The team sunsets `2024-01-15` — removes it from the `VersionBundle`, deploys the new build. Every account still pinned to `"2024-01-15"` is stale until they upgrade. Stripe themselves retire old versions eventually. +2. **Data seed / backfill drift.** An ops migration imports accounts from a legacy system with pin strings that don't match the current bundle — typos (`2024-1-15`), old formats (`v3` vs `2024-01-15`), or values from a different environment. Surfaces the first time those accounts make a call. +3. **Cross-environment pin drift.** Staging has `2025-06-01` in the bundle; production doesn't yet. A pin written in staging gets promoted to production and is stale until the production bundle catches up. +4. **Rollback.** Production deployed `2025-06-01`, accounts pinned to it (explicitly via `/versioning` or implicitly via `pinOnFirstResolve`). A regression was found, the team rolled back to a build without `2025-06-01`. All those accounts are now stale. + +**Why tsadwyn doesn't auto-heal** (silently overwrite stale pins with the fallback): + +- **Typos are bugs, not fixes.** If the stale pin is `2024-1-15` (missing a zero-pad), silently coercing to fallback hides the bug forever — the operator never finds out the source of the corruption and the wrong value gets normalized in. +- **Retirement-with-coercion violates the versioning contract.** A client who was explicitly pinned to `2024-01-15` made integration decisions based on that contract. Silently moving them to fallback means they wake up with responses they didn't sign up for. +- **Most consumers want to notice.** Operators generally want stale-pin events to page someone for investigation, not auto-silent-fixed. That's why the default is `onStalePin: 'fallback'` with a **warn** (via the logger) rather than an overwrite. +- **Healing is a consumer concern.** When you actually want to rewrite stale pins (e.g., you just retired v1 and want every v1 client upgraded to v2 next time they log in), that's a policy decision with business-specific rules. Do it in your own middleware or a scheduled task — with audit logging — not as a side-effect of a framework's default path. + +**What tsadwyn does give you** via `perClientDefaultVersion.onStalePin`: + +```ts +perClientDefaultVersion({ + // ... + onStalePin: 'fallback', // (default) use fallback + emit warn — SAFE, observable + // onStalePin: 'passthrough', // store the stale string; downstream picker decides + // onStalePin: 'reject', // throw — surfaces immediately as 500 (great in dev/CI) + logger: pinoLogger, // warns include { pin, clientId, supportedVersions } +}); +``` + +Three modes + a structured log surface. When you decide to actually heal, you do it deliberately — a scheduled task that calls `saveVersion(clientId, targetVersion)` with whatever logic you want (upgrade to next-supported, force-pin to latest, flag the account for manual review, etc.). tsadwyn stays out of the write path. + +For the common cases, `onStalePin: 'fallback'` + pino-style warn telemetry is usually enough: stale accounts keep getting served something reasonable, alerts page on-call when the warn rate is non-zero, the team investigates before reaching for the overwrite. + +### Versioning error responses + +Error responses (4xx / 5xx) **run response migrations by default** — tsadwyn's version-pin contract applies to error envelopes just as it does to successful responses, matching Stripe's actual behavior. An `errorMapper`-produced `HttpError` or a handler-thrown `HttpError` or an auto-generated `ValidationError` all flow through the same pipeline: + +```ts +class AddErrorCode extends VersionChange { + description = "v2 adds `code` to error bodies; v1 clients get just `message`"; + instructions = []; + + // Default migrateHttpErrors: true — migration fires on 4xx/5xx bodies too. + migrate = convertResponseToPreviousVersionFor(MyErrorEnvelope)( + (res: ResponseInfo) => { + if (res.body?.code !== undefined) { + delete res.body.code; // legacy clients don't have the field + } + }, + ); +} +``` + +**Defensive pattern.** Since a schema-targeted migration can now fire on error bodies whose shape may NOT match the schema (e.g., a `UserResource`-targeted migration running against `{detail: [...]}` validation envelope), migrations should null-check the fields they touch: + +```ts +migrate = convertResponseToPreviousVersionFor(UserResource)( + (res: ResponseInfo) => { + if (res.body?.addresses) { // ← null-check the field before mutating + res.body.address = res.body.addresses[0]; + delete res.body.addresses; + } + }, +); +``` + +**Opting out per-migration** — `{migrateHttpErrors: false}` when a migration should only apply to success-response bodies: + +```ts +convertResponseToPreviousVersionFor(UserResource, { migrateHttpErrors: false })( + transformer, // runs on 2xx only — skips every 4xx/5xx +); +``` + +**Interaction with `errorMapper`.** Domain exceptions translated by `errorMapper` enter the same pipeline: + +``` +domain throw → errorMapper produces HttpError(status, body) + → response migrations apply to body (default: all of them, + unless they pass migrateHttpErrors: false) + → client receives the version-migrated error envelope +``` + +`errorMapper` defines the **head-shape** error body; the response migrations down-shift that body for each client's pin. `ValidationError` (thrown automatically on Zod validation failures) flows through the exact same pipeline — consumers can reshape validation-error envelopes via either mechanism. + +**The `headerOnly: true` cousin.** Some migrations only mutate `res.headers` (e.g., renaming an `x-rate-limit-*` header across versions). Flag those with `headerOnly: true` — they run on body-less responses (204, 304, HEAD requests, null handler returns) where body-mutating migrations would NPE on `undefined`. `headerOnly` and `migrateHttpErrors` are orthogonal: a migration can set both (runs everywhere) or just `headerOnly` (runs everywhere but only touches headers). + +### Versioning behavior changes (not just schemas) + +Schema migrations only cover **shape**: fields rename, types change, endpoints appear and disappear. The other half of API versioning is **behavior** — same shape, different side effects, policy, or semantics. + +Real examples: + +- v1 `POST /charges` captures funds immediately; v2 requires an explicit `/capture` follow-up +- v1's rate limit is 100 req/s; v2's is 1000 req/s on the same endpoints +- v1 `DELETE /users/:id` cascades to delete associated posts; v2 soft-deletes only +- v1 idempotency keys live 24h; v2 live 7 days +- v1 webhooks retry 3×; v2 retries 5× with exponential backoff + +None of these show up in the request/response body — the shape is identical across versions. The difference is what the handler (or surrounding infrastructure) *does*. Your handlers need to branch on the caller's pinned version — but hard-coding `apiVersionStorage.getStore()` checks in every handler is the same sprawl pattern versioning was supposed to avoid. + +tsadwyn ships two primitives for centralizing those branches. Use whichever matches the shape of the behavior change. + +#### Pattern 1: `VersionChangeWithSideEffects.isApplied` — boolean toggles + +Good for **on/off** behavior changes. A `VersionChangeWithSideEffects` subclass is a lint-trackable marker: the class is bound to a version, carries a description (so it lands in the changelog automatically), and exposes a static `isApplied` getter that returns true when the current request's version is at-or-newer-than the bound version. + +```ts +import { VersionChangeWithSideEffects, VersionBundle, Version } from 'tsadwyn'; + +class SoftDeleteUsers extends VersionChangeWithSideEffects { + description = "v2 soft-deletes users instead of cascading delete"; + instructions = []; // pure behavior change — no schema instructions +} + +const versions = new VersionBundle( + new Version('2025-01-01', SoftDeleteUsers), + new Version('2024-01-01'), +); + +// Handler reads isApplied, not the raw version string: +router.delete('/users/:id', null, null, async (req) => { + if (SoftDeleteUsers.isApplied) { + await userRepo.softDelete(req.params.id); + } else { + await userRepo.deleteWithCascade(req.params.id); + } + return undefined; +}); +``` + +**Why this over `if (apiVersionStorage.getStore() === '2025-01-01')`:** + +- The comparison is **named and centralized**: one place in your codebase says "SoftDeleteUsers is the change at 2025-01-01," and every handler just asks `SoftDeleteUsers.isApplied`. Rename the version, don't touch the handlers. +- The change auto-documents: `description` lands in the changelog endpoint + generated OpenAPI. +- It's lint-grep-able: `git grep VersionChangeWithSideEffects` surfaces every behavior-level change at once. +- The `isApplied` getter enforces the "at-or-newer-than" semantic correctly — you don't hand-roll date comparisons per handler. + +#### Pattern 2: Declarative `behaviorHad` overlay — typed behavior per version + +Once you have more than one or two behavior toggles, scattering `VersionChangeWithSideEffects` classes gets awkward: each class is a singleton `isApplied` boolean, and there's no single catalog you can point at to answer "what does version 2024-06-01 actually do differently?". The `createVersionedBehavior` primitive lets you declare a **typed behavior shape** plus per-change deltas (`behaviorHad: Partial`) and derive the per-version snapshot map by overlay. + +```ts +import { createVersionedBehavior } from 'tsadwyn'; + +// 1. Declare the typed behavior shape + the head (latest) values. +interface ApiVersionBehavior { + requireIdempotencyKey: boolean; + deleteBehavior: 'cascade' | 'soft'; + rateLimitPerSecond: number; + errorDetailShape: 'flat' | 'rfc7807'; + timestampFormat: 'iso' | 'unix'; + // ... one field per behavior axis you version. +} + +const HEAD_BEHAVIOR: ApiVersionBehavior = { + requireIdempotencyKey: true, + deleteBehavior: 'soft', + rateLimitPerSecond: 1000, + errorDetailShape: 'rfc7807', + timestampFormat: 'iso', +}; + +// 2. Build the versioned behavior. Each change declares what the version +// BEFORE its `version` field had — `behaviorHad: Partial` is +// compile-time-checked against the head shape so you can't typo a field. +export const behavior = createVersionedBehavior({ + head: HEAD_BEHAVIOR, + initialVersion: '2024-01-01', // optional: oldest version in your bundle + changes: [ + { + version: '2025-06-01', + description: 'Idempotency keys now required on all POST endpoints.', + behaviorHad: { requireIdempotencyKey: false }, + }, + { + version: '2025-06-01', + description: 'DELETE endpoints now soft-delete by default.', + behaviorHad: { deleteBehavior: 'cascade' }, + }, + { + version: '2025-01-01', + description: 'Errors switched to RFC 7807 problem+json.', + behaviorHad: { errorDetailShape: 'flat' }, + }, + ], +}); + +// 3. Use `behavior.get()` everywhere — it reads the current request's +// version out of `apiVersionStorage` and returns the typed snapshot. +// Also available: `behavior.at(version)` for tests/admin UIs and +// `behavior.map` for changelog rendering. +``` + +Then handlers, middleware, and service code consume `behavior.get()` — **never** the version string: + +```ts +// middleware/idempotency.ts +export function enforceIdempotency(req, res, next) { + if (!behavior.get().requireIdempotencyKey) return next(); // off for older versions + if (!req.headers['idempotency-key']) { + throw new HttpError(400, { detail: 'idempotency-key header is required' }); + } + next(); +} + +// services/userService.ts +export async function deleteUser(id: string) { + if (behavior.get().deleteBehavior === 'soft') { + await userRepo.softDelete(id); + } else { + await userRepo.deleteWithCascade(id); + } +} + +// routes/orders.ts +router.get('/orders/:id', null, Order, async (req) => { + const order = await orderService.get(req.params.id); + return behavior.get().timestampFormat === 'unix' + ? { ...order, created_at: Math.floor(order.created_at.getTime() / 1000) } + : order; +}); +``` + +**Why `createVersionedBehavior` beats a raw `Map`:** + +- **Single authoritative site per behavior axis.** `deleteBehavior: 'cascade' | 'soft'` is typed in `ApiVersionBehavior`; any code that reads or writes it passes through the type system. Adding a third mode forces you to update head, the change that introduced the split, and every branch that consumed it — the compiler tells you every site. +- **Compile-time typo protection.** `behaviorHad: Partial` means the compiler rejects `behaviorHad: { idemp_key_required: true }` because that field doesn't exist — no silent no-ops from typo'd field names. +- **Scales as versions add.** When you cut a new intermediate version, add its boundary as a new `change.version` and the overlay walker reconstructs every version's snapshot automatically — no rewriting of consumer callsites. +- **Auto-surfaces in the changelog.** Each change's `description` pairs with your OpenAPI changelog entry so consumers see both shape *and* behavior changes in one place. +- **`behaviorHad: Partial<>` mirrors how you already think about schemas.** Schema instructions say "this field *had* X in the previous version" — now behavior says the same thing. Same reverse-chronology mental model, no new idiom. +- **Head is the reference.** The head snapshot is the one true baseline; every other version is overlay deltas on top. You can't drift an older version's behavior out of sync by editing one callsite — the only way to change it is to edit a `behaviorHad`. + +**What about simple "is this behavior change applied?" booleans?** Still use Pattern 1 — a standalone `VersionChangeWithSideEffects` class is fine when the change doesn't belong in the typed behavior shape (e.g., a one-off operational switch that only affects one handler). But once you have three or more behavior axes, centralize them into `createVersionedBehavior` — the list-every-version-change-in-one-file benefit compounds fast. + +> **Library escape hatch: `buildBehaviorResolver(map, fallback, options?)`.** For one-off cases where you don't want to declare a typed shape (e.g. a single rate-limit table), tsadwyn exports a bare-bones resolver that takes a raw `Map` and returns a getter. `onUnknown: 'silent' | 'warn-once' | 'warn-every'` gives you a signal when a request shows up with a version the map doesn't know about (typo'd header, stale pin). The resolver falls back to `fallback` and keeps serving. + +#### Which to reach for + +| Shape of the change | Reach for | +|---|---| +| One-off "v2 does X differently" boolean | `VersionChangeWithSideEffects.isApplied` | +| Three or more behavior axes that differ per version | `createVersionedBehavior({ head, changes })` | +| Quick one-off version → value map, no typed shape needed | `buildBehaviorResolver(map, fallback)` | +| A behavior change that ALSO changes the wire shape | Regular `VersionChange` with migrations + behavior branch in the handler | + +All three patterns read from the same `apiVersionStorage` the request-dispatch pipeline writes — so whichever version a client resolved to (explicit header, `perClientDefaultVersion`, fallback) is what the behavior branches see. No extra wiring. + +### Upgrade semantics — the `/versioning` resource (optional) + +tsadwyn ships a pre-wired RESTful `/versioning` resource so consumers don't have to hand-roll the upgrade endpoint. It's **fully opt-in** — you don't have to mount it at all, and you don't have to use it if you do. If your API doesn't expose self-service upgrades (clients pin via an admin ticket, their signup config, etc.) just skip this section. + +**tsadwyn owns no persistence.** Pinned versions live in whatever storage the consumer already has — an `api_version` column on the accounts table, a Redis key, an entry in a config service. The helper is a three-callback adapter: + +- `identify(req)` — consumer's auth layer extracts the client id from the request +- `loadVersion(clientId)` — consumer reads their storage +- `saveVersion(clientId, version)` — consumer writes their storage + +tsadwyn never sees the DB, doesn't ship a migration, doesn't assume a column name, and doesn't run SQL. If the consumer swaps Postgres for DynamoDB, only their callbacks change. + +```ts +import { Tsadwyn, VersionBundle, createVersioningRoutes } from 'tsadwyn'; + +const versions = new VersionBundle(/* ... */); + +const versioningRoutes = createVersioningRoutes({ + // path: '/versioning', // default + identify: req => (req as any).user?.accountId ?? null, + loadVersion: accountId => accountRepo.getApiVersion(accountId), + saveVersion: (accountId, v) => accountRepo.setApiVersion(accountId, v), + supportedVersions: versions.versionValues, + // allowDowngrade: false, // default + // allowNoChange: false, // default + // compare: 'iso-date', // 'semver' | custom fn +}); + +const app = new Tsadwyn({ versions, preVersionPick: authMiddleware }); +app.generateAndIncludeVersionedRouters(versioningRoutes, myDomainRoutes); +``` + +**Different persistence backends, same callbacks.** The helper doesn't care what's on the other side of `loadVersion` / `saveVersion`. Three common shapes: + +```ts +// Postgres via a repo class — the shape we recommend when a dedicated +// api_version column fits on your existing accounts table. +createVersioningRoutes({ + identify: req => req.user?.accountId ?? null, + loadVersion: accountId => db.selectFrom('accounts') + .select('api_version') + .where('id', '=', accountId) + .executeTakeFirst().then(r => r?.api_version ?? null), + saveVersion: (accountId, v) => db.updateTable('accounts') + .set({ api_version: v }) + .where('id', '=', accountId) + .execute().then(() => {}), + supportedVersions: versions.versionValues, +}); + +// Redis — when the pin is a cache-layer concern or you're doing a +// side-car deployment before touching the primary DB schema. +createVersioningRoutes({ + identify: req => req.user?.accountId ?? null, + loadVersion: accountId => redis.get(`account:${accountId}:api_version`), + saveVersion: (accountId, v) => redis.set(`account:${accountId}:api_version`, v).then(() => {}), + supportedVersions: versions.versionValues, +}); + +// Remote config service — if your pins live in a separate account-service +// that your API calls out to. (Great fit for the warn-once logger pattern +// from perClientDefaultVersion too.) +createVersioningRoutes({ + identify: req => req.user?.accountId ?? null, + loadVersion: accountId => accountService.getApiVersion({ accountId }), + saveVersion: (accountId, v) => accountService.setApiVersion({ accountId, version: v }), + supportedVersions: versions.versionValues, +}); +``` + +`async` / `Promise`-returning callbacks are awaited; sync-returning callbacks are treated as resolved. Throwing from a callback surfaces as 500 via the standard error pipeline (or as a structured `HttpError` if your `errorMapper` maps the underlying exception). + +The resource: + +| Method | Body | Success 200 | Failure | +|---|---|---|---| +| `GET /versioning` | — | `{version, supported[], latest}` | 401 unauthenticated | +| `POST /versioning` | `{from, to}` | `{previous_version, current_version}` | **409** `version_mismatch` (`from` ≠ stored), **400** `unsupported` / `downgrade-blocked` / `no-change`, **401** unauthenticated, **422** malformed body | + +Two design decisions worth calling out: + +**1. Optimistic concurrency via `{from, to}`.** The client reads their current pin with GET, then posts `{from: , to: }`. If another actor (an admin force-pin, a replicated DB converging) changed the stored pin in between, the server rejects with 409 rather than silently overwriting: + +```json +{ "error": "version_mismatch", "expected": "2024-01-01", "actual": "2025-01-01" } +``` + +The client re-reads and decides whether to retry. + +**2. First-upgrade convention.** A client who has never explicitly pinned reads `GET /versioning` and sees either: + +- **`{version: null, ...}`** — when no `fallback` option is configured. The client is truly unpinned from tsadwyn's perspective. +- **`{version: , ...}`** — when `fallback` is set (pass the same value you pass to `perClientDefaultVersion.fallback`). `GET /versioning` then reports the *effective* version tsadwyn would use at dispatch, so the resource shape and the runtime behavior stay in sync. + +Either way, the first upgrade can pass either `from: null` OR `from: ` — they describe the same unpinned state: + +```http +POST /versioning +{ "from": null, "to": "2024-06-01" } # works when unpinned, no matter whether fallback is set +POST /versioning +{ "from": "2024-01-15", "to": "2024-06-01" } # equivalent when fallback: "2024-01-15" + +HTTP/1.1 200 OK +{ "previous_version": null, "current_version": "2024-06-01" } +``` + +When `fallback` is set, the first-upgrade also runs through the standard downgrade / no-change policy: `POST {from: "2024-01-15", to: "2024-01-15"}` against `fallback: "2024-01-15"` is 400 `no-change`, not 200, which matches what a second upgrade would do from that same starting point. + +**Admin force-pin** (bypass the forward-only policy) is supported via `allowDowngrade: true`. The test suite covers this case. Typically the admin endpoint is a separate route that mounts its own version of `createVersioningRoutes({...allowDowngrade: true, identify: adminIdentify})` with a different auth scope. + +If you need finer control than the helper provides, the underlying pieces — `validateVersionUpgrade` + `HttpError` — compose directly: + +```ts +router.post('/versioning', UpgradeReq, UpgradeRes, async (req) => { + const current = await accountRepo.getApiVersion(req.body.accountId); + const decision = validateVersionUpgrade({ + current, + target: req.body.target, + supported: versions.versionValues, + }); + if (!decision.ok) { + throw new HttpError(400, { code: decision.reason, detail: decision.detail }); + } + await accountRepo.setApiVersion(req.body.accountId, decision.next); + return { previous_version: decision.previous, current_version: decision.next }; +}); +``` + +Forward-only upgrades are the Stripe convention — `allowDowngrade: true` is the admin escape hatch. + +### Reading the raw request — `currentRequest()` + +**The stripped-handler-view problem.** tsadwyn hands your handler a deliberately narrow argument: `{ body, params, query, headers }`. That's it. It doesn't pass through the full Express `Request` because the framework's contract is supposed to be explicit: every value your handler consumes should be either (a) a schema-typed piece of the HTTP contract or (b) something the framework guarantees. Passing `req` around lets handlers reach for arbitrary things — session objects, cookies, IP addresses, per-request state written by some middleware five layers up — and that couples the versioned contract to an ever-growing implicit surface. + +**But real apps need the escape hatch.** The stripped view is fine for pure request/response mapping. It falls short the moment upstream middleware mutates `req` with state that isn't part of the schema: `req.user` from your auth middleware, `req.claims` from a JWT decoder, `req.tenantId` from a multi-tenant resolver, `req.traceId` from OpenTelemetry. Those values aren't *part of the wire contract* — they're framework-level context that handlers need but clients don't send. Pre-stripping them out means your handler would either have to re-read the header and re-decode the JWT (gross) or thread the state through some side channel (worse). + +`currentRequest()` is that escape hatch: + +```ts +import { currentRequest } from 'tsadwyn'; + +router.get('/me', null, User, async () => { + const req = currentRequest(); // raw Express Request + const userId = req.user?.id; // set by auth middleware + const traceId = req.headers['x-trace-id']; // could also read via headers arg + const tenantId = (req as any).tenant?.id; // set by tenant middleware + return userService.getForContext(userId, tenantId, traceId); +}); +``` + +**How it works — no middleware to mount.** tsadwyn captures the full `Request` into an `AsyncLocalStorage` instance *inside its own handler dispatcher*, immediately before invoking your handler. You don't install anything: any call to `currentRequest()` from inside a versioned handler (or from code it awaits) reads the correct request. Concurrent requests are isolated by Node's ALS semantics — request A's handler never sees request B's `req`, even if their promise chains interleave. + +The same ALS scope extends into migration callbacks: + +```ts +class LogUserOnRequest extends VersionChange { + description = 'capture caller identity for audit migrations'; + instructions = []; + + migrateRequest = convertRequestToNextVersionFor(Order)( + (_req: RequestInfo) => { + // Response/request migrations run inside the same dispatch scope — + // they see the originating Express request too. + const actor = (currentRequest() as any).user?.id ?? 'anonymous'; + auditLog.record({ actor, version: apiVersionStorage.getStore() }); + }, + ); +} +``` + +**When NOT to use it.** If the value you're reaching for belongs in the versioned contract — a piece of the request body, a query parameter, a documented header — put it on the schema instead. `currentRequest()` is for context that comes from *framework* layers (middleware, transport), not from the HTTP message the client sends. A handler that reaches for `currentRequest().body` has strayed from its own contract. + +**Throws vs null.** `currentRequest()` throws if called outside a tsadwyn handler scope — that's a loud signal you've got a bug (calling it from a background job, an import-time module, an Express handler that bypassed the tsadwyn dispatcher). Use `currentRequestOrNull()` when the call sits at a library boundary where absence is expected (shared helpers used from both tsadwyn handlers and plain Express). + +### Route shadowing — the first-match-wins trap + +**The bug.** Express routes via `path-to-regexp`, which resolves paths in **registration order** and takes the **first** pattern that matches. Most of the time this is fine. It becomes a production bug when a parameterized pattern is registered before a sibling literal: + +```ts +// Registered first: catches EVERY /users/ including /users/search. +router.get('/users/:id', ..., handler); +// Registered second: NEVER reached. Path-to-regexp already matched :id = "search" +// on the previous line, and the UUID validator on that handler 400s with +// "search is not a valid UUID" from deep inside your handler chain. +router.get('/users/search', ..., searchHandler); +``` + +The first-time-hit-by-this signature is always the same: a handler's validator middleware rejects a request with a cryptic error, you spend an afternoon walking the stack, and eventually realize the handler that 400'd was never the intended recipient. Production consumers hit this roughly once per real-world app. + +**What tsadwyn detects.** At `generateAndIncludeVersionedRouters()` time, tsadwyn scans every route in registration order (per method). For each later route whose path is fully *literal* (no `:param`, no `*`), it checks whether any earlier route on the same method has a pattern that would match the literal. If yes, that pair is a shadow. The detection is conservative: a `:id(\\d+)` constrained param is still treated as a catch-all for safety, so you get a false-positive warn rather than a missed bug. + +Overlapping-wildcard cases (`/users/:id` then `/users/:name`) are deliberately ignored — those are either intentional duplicates that Express itself will complain about, or they're ambiguous enough that warning would be noise. + +**Policy — when to pick which:** + +| Policy | Pick this when | +|---|---| +| `'warn'` *(default)* | Safe for any app. You get one log line per shadow at boot; the app still starts. Use when you're adopting tsadwyn on an existing codebase and don't want to risk a boot break from a shadow you haven't audited yet. | +| `'throw'` | New apps and CI enforcement. Refuses to initialize, so the mistake can't ship. Best paired with a fresh team convention ("register literals before wildcards, always"). | +| `'silent'` | You've got an intentional shadow (rare — usually when a wildcard is *meant* to be a catch-all and a literal sibling is handled by a different mount or middleware short-circuit). Audit first, silence only after. | + +```ts +const app = new Tsadwyn({ + versions, + onRouteShadowing: 'throw', // or 'warn' | 'silent' + routeShadowingLogger: { // structured log sink; defaults to console.warn + warn: (ctx, msg) => pinoLogger.warn(ctx, msg), + }, +}); +``` + +**The policy is global, not per-pair.** If you have one intentional shadow, silencing globally hides every other one — usually not what you want. The cleaner fix is to reorder the routes (register the literal first). If you genuinely need per-pair suppression, tell us and we'll add a marker — but 99% of the time reordering is the right answer. + +### Adopting tsadwyn incrementally alongside existing Express routes + +You don't have to version your whole surface at once. tsadwyn mounts on an Express app with fall-through semantics — the versioned dispatcher catches its registered paths, and everything else passes through to the rest of your Express chain: + +```ts +const expressApp = express(); +expressApp.use(express.json()); +expressApp.use(authMiddleware); + +// tsadwyn handles the routes it owns +const versioned = new Tsadwyn({ versions: /* ... */ }); +versioned.generateAndIncludeVersionedRouters(myVersionedRouter); +expressApp.use(versioned.expressApp); + +// Existing unversioned routes still work — tsadwyn falls through on unregistered paths +expressApp.use(existingRouter); +``` + +**One landmine to watch for:** path-to-regexp is first-match-wins, so a parameterized route registered before a sibling literal silently eats every request that should have reached the literal. tsadwyn ships a dedicated detector for this — see [Route shadowing — the first-match-wins trap](#route-shadowing--the-first-match-wins-trap) for the full explanation and policy options. + +For running examples of both patterns end-to-end, see [`examples/stripe-api.ts`](./examples/stripe-api.ts) (Stripe-style multi-version API) and [`examples/task-api.ts`](./examples/task-api.ts) (webhook versioning, CSV export via `raw()`, domain exceptions, `deletedResponseSchema`). + ## API Reference ### Core @@ -140,6 +716,69 @@ For full documentation on the head-first API versioning pattern, see the [Cadwyn | `apiVersionStorage` | AsyncLocalStorage holding the current version | | `generateVersionedRouters` | Low-level router generation function | +### Error handling + +| Export | Description | +|--------|-------------| +| `HttpError` | Throw from a handler to send a versionable error response (flows through `migrateHttpErrors: true` migrations) | +| `TsadwynOptions.errorMapper` | `(err) => HttpError \| null` invoked in the handler catch block before the HTTP-likeness check — translate domain exceptions into HTTP errors without coupling your domain layer | +| `exceptionMap(config)` | Declarative table form of `errorMapper` keyed by `err.name` (survives module-boundary identity drift) with introspection (`describe()`, `has`, `lookup`, `registeredNames`). Pass directly as `errorMapper`. | +| `exceptionMap.merge(a, b, …)` | Merge multiple configs; throws `TsadwynStructureError` on duplicate keys | +| `isExceptionMapFn(value)` | Type guard for introspectable mappers | + +### Middleware & version resolution + +| Export | Description | +|--------|-------------| +| `versionPickingMiddleware(options)` | Built-in middleware that extracts the version and runs the `apiVersionDefaultValue` resolver | +| `VersionPickingOptions.onUnsupportedVersion` | `'reject'` (400 with `{error, sent, supported}`) \| `'fallback'` (substitute default + warn) \| `'passthrough'` (default, stores verbatim) | +| `TsadwynOptions.preVersionPick` | Middleware that runs **before** `versionPickingMiddleware` — the place to put auth so `apiVersionDefaultValue` can read `req.user`. Scoped to versioned dispatch (utility endpoints bypass). | +| `perClientDefaultVersion(opts)` | Canonical DB-backed default resolver: `identify` extracts client id, `resolvePin` loads their version, `onStalePin` handles bundle evictions. Per-request WeakMap cache. Optional `pinOnFirstResolve: true` + `saveVersion` implements Stripe's "pin to current latest on the first authenticated call" behavior. | +| `cachedPerClientDefaultVersion(opts)` | High-QPS variant: same options plus `ttlMs` for cross-request caching. Returns `{resolver, invalidate, invalidateAll}` so the upgrade endpoint can drop the cache for one client. Single-flights concurrent first-misses; errors bypass caching. | +| `currentRequest()` / `currentRequestOrNull()` | Access the raw Express `Request` from inside any tsadwyn handler or migration callback. Captures `req` into AsyncLocalStorage automatically — no middleware to mount. Recovers middleware-injected state (`req.user`, claims, trace IDs) that the stripped handler view hides. | + +### Helpers + +| Export | Description | +|--------|-------------| +| `deletedResponseSchema(objectName, extraFields?)` | Stripe-style `{id, object, deleted: true}` schema for DELETE endpoints (use with `statusCode: 200` — 204 strips the body at the wire level per RFC 9110) | +| `raw({mimeType, supportsRanges?})` | Response-schema marker for binary/streaming routes; sets `Content-Type` at emission and marks response migrations targeting this route as dead code | +| `migratePayloadToVersion(schemaName, payload, targetVersion, versionBundle)` | Standalone payload reshaper — runs the same response migrations used in-flight against an outbound webhook payload for the destination client's pin | +| `buildBehaviorResolver(map, fallback, opts?)` | Resolve per-version behavior flags in handlers; reads from `apiVersionStorage`, optional `warn-once`/`warn-every` telemetry on unknown versions | +| `createVersionedBehavior({head, changes, initialVersion?})` | Typed overlay primitive: declare a `Behavior` shape + per-change deltas as `behaviorHad: Partial` and the builder derives each supported version's snapshot. Returns `{get, at, map}`. Compile-time protection against typo'd field names. | +| `validateVersionUpgrade(args)` | Pure policy helper. Discriminated-union result (`{ok, previous, next}` \| `{ok: false, reason}`). Blocks downgrade + no-change by default; `allowDowngrade`/`allowNoChange` opt-outs; `iso-date` / `semver` / custom comparator. | +| `createVersioningRoutes(opts)` | Pre-wired `VersionedRouter` exposing the RESTful `/versioning` resource (GET + POST with optimistic concurrency). Wraps `validateVersionUpgrade` with identify/load/save callbacks so consumers don't hand-roll the endpoint. | +| `migrateResponseBody` | Standalone response migration utility (T-1701) | + +### Route & handler options + +| Export | Description | +|--------|-------------| +| `VersionedRouter.head(path, reqSchema, resSchema, handler, opts?)` | Explicit HEAD handler registration. Wins over Express's auto-mirror to GET. Pipeline skips response-body migrations on HEAD and emits no body at the wire (HEAD is body-less per HTTP spec). | +| `RouteOptions.tags: string[]` | OpenAPI tags for Swagger/ReDoc grouping — flow into `operation.tags`. Composes with `endpoint().had({tags})` across versions. | +| `RouteOptions.statusCode: number` | Override the emitted status code (default 200). Common: `201` for creates, `202` for async, `204` for truly body-less. | +| `ResponseMigrationOptions.migrateHttpErrors: true` | Migration also runs on 4xx/5xx error responses | +| `ResponseMigrationOptions.headerOnly: true` | Migration runs on body-less responses too (204, 304, null/undefined handler return) | + +### Introspection (programmatic) + +| Export | Description | +|--------|-------------| +| `dumpRouteTable(app, opts?)` | Enumerate registered routes per version; filter by method/path/visibility | +| `inspectMigrationChain(app, opts)` | Return the ordered migration chain for a schema + client version, direction `'request'` or `'response'` | +| `simulateRoute(app, opts)` | Simulate a request against the route table: matched route, every candidate with match reason, fallthrough diagnostics, migration chain summaries, up-migrated body preview | + +### Generation-time lints (free, no opt-in) + +tsadwyn warns at `generateAndIncludeVersionedRouters()` time on these common mistakes: + +- Wildcard route registered before a sibling literal (`/users/:id` before `/users/archived`) — path-to-regexp is first-match-wins and the wildcard shadows the literal silently. Policy is configurable via `onRouteShadowing: 'warn' | 'throw' | 'silent'` (default `'warn'`) and the structured `routeShadowingLogger` on `TsadwynOptions`. +- `statusCode: 204` with a non-null `responseSchema` — Node strips the body at the wire level; body won't arrive at the client. Recommends `statusCode: 200` or `deletedResponseSchema()`. +- Body-mutating response migration targeting a 204/304 route without `headerOnly: true` — dead code (body is stripped). +- Response migration targeting a `raw()` route — dead code (body is opaque bytes, not JSON). +- Both `.get()` and `.head()` registered for the same path — Express auto-mirrors GET to HEAD; explicit HEAD is rarely intentional when GET also exists. +- Tags starting with `_TSADWYN` — reserved for internal bookkeeping. + ## CLI tsadwyn ships a small CLI for codegen and introspection. When the package is installed, the `tsadwyn` binary is available on your `PATH`; during development you can invoke it with `npx tsx src/cli.ts`. @@ -180,6 +819,98 @@ Options: tsadwyn's schemas are runtime Zod objects rather than source code, so `info` is the canonical way to introspect the versioned surface area of a deployed app. +### `tsadwyn routes --app ` + +Enumerate the registered route table per version — complements `info` with per-route detail (handler name, schemas, tags, visibility). Useful for code review (`did this PR actually register the route?`), incident triage (`what's the v1 surface?`), and OpenAPI audit. + +```bash +tsadwyn routes --app ./src/app.ts +tsadwyn routes --app ./src/app.ts --version 2025-01-01 +tsadwyn routes --app ./src/app.ts --method POST --path-matches billing +tsadwyn routes --app ./src/app.ts --format json | jq '.[] | select(.deprecated)' +tsadwyn routes --app ./src/app.ts --include-private +``` + +| Flag | Description | +|------|-------------| +| `--app ` | Required. Path to the Tsadwyn app module. | +| `--version ` | Restrict output to one version. Default: all versions. | +| `--method ` | Filter by HTTP method (case-insensitive). | +| `--path-matches ` | Filter by path — regex source or substring. | +| `--include-private` | Include routes with `includeInSchema: false`. | +| `--format ` | `table` (default) \| `json` \| `markdown`. | + +### `tsadwyn migrations --app --schema --version ` + +Show the ordered migration chain that fires for a given schema + client version. Answers "why is my v1 client receiving a v2-shape field?" without stepping through code. + +```bash +# Response migrations (head → client) for UserResponse at 2024-01-01 +tsadwyn migrations --app ./src/app.ts --schema UserResponse --version 2024-01-01 + +# Request direction (client → head) +tsadwyn migrations --app ./src/app.ts --schema UserCreateRequest --version 2024-01-01 --direction request + +# JSON output for piping +tsadwyn migrations --app ./src/app.ts --schema UserResponse --version 2024-01-01 --format json +``` + +| Flag | Description | +|------|-------------| +| `--app ` | Required. | +| `--schema ` | Required. Schema name (set via `.named()`). | +| `--version ` | Required. Client pin version. | +| `--direction ` | `response` (default) \| `request`. | +| `--path ` | Scope to a single path-based migration target. | +| `--method ` | Paired with `--path`. | +| `--no-error-migrations` | Exclude migrations with `migrateHttpErrors: true`. | +| `--format ` | `pipeline` (default) \| `json`. | + +### `tsadwyn simulate --app --method --path

` + +Simulate a request against the route table *without* dispatching. Answers "is tsadwyn responsible for this 4xx?" and "what migrations would fire?" in one command. Essential during incident triage. + +```bash +# Matched route + candidates + migration chain +tsadwyn simulate --app ./src/app.ts \ + --method POST --path /api/charges/ch_abc/capture \ + --version 2025-06-01 + +# With body — get an up-migrated preview (head-shape body the handler sees) +tsadwyn simulate --app ./src/app.ts \ + --method POST --path /api/charges --version 2024-01-01 \ + --body '{"amount": 100}' + +# JSON for piping +tsadwyn simulate --app ./src/app.ts --method GET --path /api/users/xyz \ + --version 2024-01-01 --format json +``` + +| Flag | Description | +|------|-------------| +| `--app ` | Required. | +| `--method ` | Required. | +| `--path ` | Required. | +| `--version ` | API version. Explicit overrides headers/default. | +| `--body ` | Optional. Enables `upMigratedBody` preview. | +| `--format ` | `table` (default) \| `json`. | + +### `tsadwyn exceptions --app ` + +Introspect the configured `errorMapper`'s exception → HttpError table. Requires the app's mapper to be built via `exceptionMap()` (plain function mappers aren't introspectable). + +```bash +tsadwyn exceptions --app ./src/app.ts +tsadwyn exceptions --app ./src/app.ts --format json | jq '.[] | select(.kind == "function")' +tsadwyn exceptions --app ./src/app.ts --filter '^Idempotency' +``` + +| Flag | Description | +|------|-------------| +| `--app ` | Required. | +| `--format ` | `table` (default) \| `json` \| `markdown`. | +| `--filter ` | Filter entries by exception class name. | + ### `tsadwyn new version --date ` Scaffolds a new `VersionChange` file for a breaking API change. The easiest way to answer "I need to make a breaking change — what do I type?" diff --git a/coverage/base.css b/coverage/base.css deleted file mode 100644 index f418035..0000000 --- a/coverage/base.css +++ /dev/null @@ -1,224 +0,0 @@ -body, html { - margin:0; padding: 0; - height: 100%; -} -body { - font-family: Helvetica Neue, Helvetica, Arial; - font-size: 14px; - color:#333; -} -.small { font-size: 12px; } -*, *:after, *:before { - -webkit-box-sizing:border-box; - -moz-box-sizing:border-box; - box-sizing:border-box; - } -h1 { font-size: 20px; margin: 0;} -h2 { font-size: 14px; } -pre { - font: 12px/1.4 Consolas, "Liberation Mono", Menlo, Courier, monospace; - margin: 0; - padding: 0; - -moz-tab-size: 2; - -o-tab-size: 2; - tab-size: 2; -} -a { color:#0074D9; text-decoration:none; } -a:hover { text-decoration:underline; } -.strong { font-weight: bold; } -.space-top1 { padding: 10px 0 0 0; } -.pad2y { padding: 20px 0; } -.pad1y { padding: 10px 0; } -.pad2x { padding: 0 20px; } -.pad2 { padding: 20px; } -.pad1 { padding: 10px; } -.space-left2 { padding-left:55px; } -.space-right2 { padding-right:20px; } -.center { text-align:center; } -.clearfix { display:block; } -.clearfix:after { - content:''; - display:block; - height:0; - clear:both; - visibility:hidden; - } -.fl { float: left; } -@media only screen and (max-width:640px) { - .col3 { width:100%; max-width:100%; } - .hide-mobile { display:none!important; } -} - -.quiet { - color: #7f7f7f; - color: rgba(0,0,0,0.5); -} -.quiet a { opacity: 0.7; } - -.fraction { - font-family: Consolas, 'Liberation Mono', Menlo, Courier, monospace; - font-size: 10px; - color: #555; - background: #E8E8E8; - padding: 4px 5px; - border-radius: 3px; - vertical-align: middle; -} - -div.path a:link, div.path a:visited { color: #333; } -table.coverage { - border-collapse: collapse; - margin: 10px 0 0 0; - padding: 0; -} - -table.coverage td { - margin: 0; - padding: 0; - vertical-align: top; -} -table.coverage td.line-count { - text-align: right; - padding: 0 5px 0 20px; -} -table.coverage td.line-coverage { - text-align: right; - padding-right: 10px; - min-width:20px; -} - -table.coverage td span.cline-any { - display: inline-block; - padding: 0 5px; - width: 100%; -} -.missing-if-branch { - display: inline-block; - margin-right: 5px; - border-radius: 3px; - position: relative; - padding: 0 4px; - background: #333; - color: yellow; -} - -.skip-if-branch { - display: none; - margin-right: 10px; - position: relative; - padding: 0 4px; - background: #ccc; - color: white; -} -.missing-if-branch .typ, .skip-if-branch .typ { - color: inherit !important; -} -.coverage-summary { - border-collapse: collapse; - width: 100%; -} -.coverage-summary tr { border-bottom: 1px solid #bbb; } -.keyline-all { border: 1px solid #ddd; } -.coverage-summary td, .coverage-summary th { padding: 10px; } -.coverage-summary tbody { border: 1px solid #bbb; } -.coverage-summary td { border-right: 1px solid #bbb; } -.coverage-summary td:last-child { border-right: none; } -.coverage-summary th { - text-align: left; - font-weight: normal; - white-space: nowrap; -} -.coverage-summary th.file { border-right: none !important; } -.coverage-summary th.pct { } -.coverage-summary th.pic, -.coverage-summary th.abs, -.coverage-summary td.pct, -.coverage-summary td.abs { text-align: right; } -.coverage-summary td.file { white-space: nowrap; } -.coverage-summary td.pic { min-width: 120px !important; } -.coverage-summary tfoot td { } - -.coverage-summary .sorter { - height: 10px; - width: 7px; - display: inline-block; - margin-left: 0.5em; - background: url(sort-arrow-sprite.png) no-repeat scroll 0 0 transparent; -} -.coverage-summary .sorted .sorter { - background-position: 0 -20px; -} -.coverage-summary .sorted-desc .sorter { - background-position: 0 -10px; -} -.status-line { height: 10px; } -/* yellow */ -.cbranch-no { background: yellow !important; color: #111; } -/* dark red */ -.red.solid, .status-line.low, .low .cover-fill { background:#C21F39 } -.low .chart { border:1px solid #C21F39 } -.highlighted, -.highlighted .cstat-no, .highlighted .fstat-no, .highlighted .cbranch-no{ - background: #C21F39 !important; -} -/* medium red */ -.cstat-no, .fstat-no, .cbranch-no, .cbranch-no { background:#F6C6CE } -/* light red */ -.low, .cline-no { background:#FCE1E5 } -/* light green */ -.high, .cline-yes { background:rgb(230,245,208) } -/* medium green */ -.cstat-yes { background:rgb(161,215,106) } -/* dark green */ -.status-line.high, .high .cover-fill { background:rgb(77,146,33) } -.high .chart { border:1px solid rgb(77,146,33) } -/* dark yellow (gold) */ -.status-line.medium, .medium .cover-fill { background: #f9cd0b; } -.medium .chart { border:1px solid #f9cd0b; } -/* light yellow */ -.medium { background: #fff4c2; } - -.cstat-skip { background: #ddd; color: #111; } -.fstat-skip { background: #ddd; color: #111 !important; } -.cbranch-skip { background: #ddd !important; color: #111; } - -span.cline-neutral { background: #eaeaea; } - -.coverage-summary td.empty { - opacity: .5; - padding-top: 4px; - padding-bottom: 4px; - line-height: 1; - color: #888; -} - -.cover-fill, .cover-empty { - display:inline-block; - height: 12px; -} -.chart { - line-height: 0; -} -.cover-empty { - background: white; -} -.cover-full { - border-right: none !important; -} -pre.prettyprint { - border: none !important; - padding: 0 !important; - margin: 0 !important; -} -.com { color: #999 !important; } -.ignore-none { color: #999; font-weight: normal; } - -.wrapper { - min-height: 100%; - height: auto !important; - height: 100%; - margin: 0 auto -48px; -} -.footer, .push { - height: 48px; -} diff --git a/coverage/block-navigation.js b/coverage/block-navigation.js deleted file mode 100644 index 530d1ed..0000000 --- a/coverage/block-navigation.js +++ /dev/null @@ -1,87 +0,0 @@ -/* eslint-disable */ -var jumpToCode = (function init() { - // Classes of code we would like to highlight in the file view - var missingCoverageClasses = ['.cbranch-no', '.cstat-no', '.fstat-no']; - - // Elements to highlight in the file listing view - var fileListingElements = ['td.pct.low']; - - // We don't want to select elements that are direct descendants of another match - var notSelector = ':not(' + missingCoverageClasses.join('):not(') + ') > '; // becomes `:not(a):not(b) > ` - - // Selector that finds elements on the page to which we can jump - var selector = - fileListingElements.join(', ') + - ', ' + - notSelector + - missingCoverageClasses.join(', ' + notSelector); // becomes `:not(a):not(b) > a, :not(a):not(b) > b` - - // The NodeList of matching elements - var missingCoverageElements = document.querySelectorAll(selector); - - var currentIndex; - - function toggleClass(index) { - missingCoverageElements - .item(currentIndex) - .classList.remove('highlighted'); - missingCoverageElements.item(index).classList.add('highlighted'); - } - - function makeCurrent(index) { - toggleClass(index); - currentIndex = index; - missingCoverageElements.item(index).scrollIntoView({ - behavior: 'smooth', - block: 'center', - inline: 'center' - }); - } - - function goToPrevious() { - var nextIndex = 0; - if (typeof currentIndex !== 'number' || currentIndex === 0) { - nextIndex = missingCoverageElements.length - 1; - } else if (missingCoverageElements.length > 1) { - nextIndex = currentIndex - 1; - } - - makeCurrent(nextIndex); - } - - function goToNext() { - var nextIndex = 0; - - if ( - typeof currentIndex === 'number' && - currentIndex < missingCoverageElements.length - 1 - ) { - nextIndex = currentIndex + 1; - } - - makeCurrent(nextIndex); - } - - return function jump(event) { - if ( - document.getElementById('fileSearch') === document.activeElement && - document.activeElement != null - ) { - // if we're currently focused on the search input, we don't want to navigate - return; - } - - switch (event.which) { - case 78: // n - case 74: // j - goToNext(); - break; - case 66: // b - case 75: // k - case 80: // p - goToPrevious(); - break; - } - }; -})(); -window.addEventListener('keydown', jumpToCode); diff --git a/coverage/cli.ts.html b/coverage/cli.ts.html deleted file mode 100644 index 5967289..0000000 --- a/coverage/cli.ts.html +++ /dev/null @@ -1,2896 +0,0 @@ - - - - - - Code coverage report for cli.ts - - - - - - - - - -

-
-

All files cli.ts

-
- -
- 96.79% - Statements - 573/592 -
- - -
- 90.32% - Branches - 168/186 -
- - -
- 100% - Functions - 18/18 -
- - -
- 96.79% - Lines - 573/592 -
- - -
-

- Press n or j to go to the next uncovered block, b, p or k for the previous block. -

- -
-
-

-
1 -2 -3 -4 -5 -6 -7 -8 -9 -10 -11 -12 -13 -14 -15 -16 -17 -18 -19 -20 -21 -22 -23 -24 -25 -26 -27 -28 -29 -30 -31 -32 -33 -34 -35 -36 -37 -38 -39 -40 -41 -42 -43 -44 -45 -46 -47 -48 -49 -50 -51 -52 -53 -54 -55 -56 -57 -58 -59 -60 -61 -62 -63 -64 -65 -66 -67 -68 -69 -70 -71 -72 -73 -74 -75 -76 -77 -78 -79 -80 -81 -82 -83 -84 -85 -86 -87 -88 -89 -90 -91 -92 -93 -94 -95 -96 -97 -98 -99 -100 -101 -102 -103 -104 -105 -106 -107 -108 -109 -110 -111 -112 -113 -114 -115 -116 -117 -118 -119 -120 -121 -122 -123 -124 -125 -126 -127 -128 -129 -130 -131 -132 -133 -134 -135 -136 -137 -138 -139 -140 -141 -142 -143 -144 -145 -146 -147 -148 -149 -150 -151 -152 -153 -154 -155 -156 -157 -158 -159 -160 -161 -162 -163 -164 -165 -166 -167 -168 -169 -170 -171 -172 -173 -174 -175 -176 -177 -178 -179 -180 -181 -182 -183 -184 -185 -186 -187 -188 -189 -190 -191 -192 -193 -194 -195 -196 -197 -198 -199 -200 -201 -202 -203 -204 -205 -206 -207 -208 -209 -210 -211 -212 -213 -214 -215 -216 -217 -218 -219 -220 -221 -222 -223 -224 -225 -226 -227 -228 -229 -230 -231 -232 -233 -234 -235 -236 -237 -238 -239 -240 -241 -242 -243 -244 -245 -246 -247 -248 -249 -250 -251 -252 -253 -254 -255 -256 -257 -258 -259 -260 -261 -262 -263 -264 -265 -266 -267 -268 -269 -270 -271 -272 -273 -274 -275 -276 -277 -278 -279 -280 -281 -282 -283 -284 -285 -286 -287 -288 -289 -290 -291 -292 -293 -294 -295 -296 -297 -298 -299 -300 -301 -302 -303 -304 -305 -306 -307 -308 -309 -310 -311 -312 -313 -314 -315 -316 -317 -318 -319 -320 -321 -322 -323 -324 -325 -326 -327 -328 -329 -330 -331 -332 -333 -334 -335 -336 -337 -338 -339 -340 -341 -342 -343 -344 -345 -346 -347 -348 -349 -350 -351 -352 -353 -354 -355 -356 -357 -358 -359 -360 -361 -362 -363 -364 -365 -366 -367 -368 -369 -370 -371 -372 -373 -374 -375 -376 -377 -378 -379 -380 -381 -382 -383 -384 -385 -386 -387 -388 -389 -390 -391 -392 -393 -394 -395 -396 -397 -398 -399 -400 -401 -402 -403 -404 -405 -406 -407 -408 -409 -410 -411 -412 -413 -414 -415 -416 -417 -418 -419 -420 -421 -422 -423 -424 -425 -426 -427 -428 -429 -430 -431 -432 -433 -434 -435 -436 -437 -438 -439 -440 -441 -442 -443 -444 -445 -446 -447 -448 -449 -450 -451 -452 -453 -454 -455 -456 -457 -458 -459 -460 -461 -462 -463 -464 -465 -466 -467 -468 -469 -470 -471 -472 -473 -474 -475 -476 -477 -478 -479 -480 -481 -482 -483 -484 -485 -486 -487 -488 -489 -490 -491 -492 -493 -494 -495 -496 -497 -498 -499 -500 -501 -502 -503 -504 -505 -506 -507 -508 -509 -510 -511 -512 -513 -514 -515 -516 -517 -518 -519 -520 -521 -522 -523 -524 -525 -526 -527 -528 -529 -530 -531 -532 -533 -534 -535 -536 -537 -538 -539 -540 -541 -542 -543 -544 -545 -546 -547 -548 -549 -550 -551 -552 -553 -554 -555 -556 -557 -558 -559 -560 -561 -562 -563 -564 -565 -566 -567 -568 -569 -570 -571 -572 -573 -574 -575 -576 -577 -578 -579 -580 -581 -582 -583 -584 -585 -586 -587 -588 -589 -590 -591 -592 -593 -594 -595 -596 -597 -598 -599 -600 -601 -602 -603 -604 -605 -606 -607 -608 -609 -610 -611 -612 -613 -614 -615 -616 -617 -618 -619 -620 -621 -622 -623 -624 -625 -626 -627 -628 -629 -630 -631 -632 -633 -634 -635 -636 -637 -638 -639 -640 -641 -642 -643 -644 -645 -646 -647 -648 -649 -650 -651 -652 -653 -654 -655 -656 -657 -658 -659 -660 -661 -662 -663 -664 -665 -666 -667 -668 -669 -670 -671 -672 -673 -674 -675 -676 -677 -678 -679 -680 -681 -682 -683 -684 -685 -686 -687 -688 -689 -690 -691 -692 -693 -694 -695 -696 -697 -698 -699 -700 -701 -702 -703 -704 -705 -706 -707 -708 -709 -710 -711 -712 -713 -714 -715 -716 -717 -718 -719 -720 -721 -722 -723 -724 -725 -726 -727 -728 -729 -730 -731 -732 -733 -734 -735 -736 -737 -738 -739 -740 -741 -742 -743 -744 -745 -746 -747 -748 -749 -750 -751 -752 -753 -754 -755 -756 -757 -758 -759 -760 -761 -762 -763 -764 -765 -766 -767 -768 -769 -770 -771 -772 -773 -774 -775 -776 -777 -778 -779 -780 -781 -782 -783 -784 -785 -786 -787 -788 -789 -790 -791 -792 -793 -794 -795 -796 -797 -798 -799 -800 -801 -802 -803 -804 -805 -806 -807 -808 -809 -810 -811 -812 -813 -814 -815 -816 -817 -818 -819 -820 -821 -822 -823 -824 -825 -826 -827 -828 -829 -830 -831 -832 -833 -834 -835 -836 -837 -838 -839 -840 -841 -842 -843 -844 -845 -846 -847 -848 -849 -850 -851 -852 -853 -854 -855 -856 -857 -858 -859 -860 -861 -862 -863 -864 -865 -866 -867 -868 -869 -870 -871 -872 -873 -874 -875 -876 -877 -878 -879 -880 -881 -882 -883 -884 -885 -886 -887 -888 -889 -890 -891 -892 -893 -894 -895 -896 -897 -898 -899 -900 -901 -902 -903 -904 -905 -906 -907 -908 -909 -910 -911 -912 -913 -914 -915 -916 -917 -918 -919 -920 -921 -922 -923 -924 -925 -926 -927 -928 -929 -930 -931 -932 -933 -934 -935 -936 -937 -938  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -1x -1x -1x -1x -  -  -  -  -  -1x -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -31x -31x -31x -31x -31x -  -  -  -  -  -  -  -  -  -17x -17x -17x -17x -1x -1x -14x -14x -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -13x -13x -13x -13x -13x -  -13x -  -13x -13x -2x -2x -  -2x -2x -2x -  -13x -1x -1x -  -1x -1x -1x -  -  -13x -7x -7x -7x -10x -10x -7x -  -  -  -  -13x -13x -2x -2x -13x -1x -1x -  -  -7x -13x -6x -6x -9x -9x -9x -13x -1x -1x -1x -  -1x -1x -  -7x -7x -13x -3x -3x -3x -3x -13x -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -11x -11x -11x -  -11x -11x -11x -11x -11x -11x -  -  -  -11x -11x -11x -11x -20x -10x -10x -10x -10x -10x -10x -20x -11x -11x -  -  -  -11x -20x -20x -  -20x -20x -  -20x -20x -20x -16x -16x -  -17x -17x -17x -17x -17x -17x -17x -17x -  -11x -11x -  -  -  -  -5x -5x -5x -5x -5x -5x -9x -4x -5x -4x -1x -9x -9x -9x -5x -5x -5x -5x -5x -  -  -  -  -  -  -  -  -  -  -18x -18x -18x -18x -17x -  -18x -3x -3x -  -  -3x -3x -3x -  -  -18x -1x -1x -1x -  -  -1x -  -  -18x -6x -6x -3x -3x -3x -3x -3x -3x -6x -  -11x -  -18x -6x -18x -5x -5x -  -11x -18x -1x -1x -1x -1x -18x -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -1x -1x -  -  -  -  -  -5x -5x -5x -4x -5x -4x -4x -4x -5x -4x -4x -  -  -  -  -7x -7x -7x -5x -5x -7x -5x -5x -  -  -  -  -5x -5x -5x -4x -5x -3x -3x -  -  -  -  -  -  -20x -20x -  -2x -2x -2x -2x -20x -  -3x -3x -3x -3x -3x -3x -3x -3x -3x -3x -3x -15x -15x -  -  -  -  -  -20x -20x -20x -20x -20x -20x -20x -20x -20x -20x -20x -  -  -  -  -  -1x -20x -20x -20x -20x -20x -20x -  -20x -20x -20x -20x -20x -20x -20x -20x -20x -20x -20x -20x -20x -20x -20x -  -20x -20x -20x -20x -20x -20x -20x -  -20x -20x -20x -20x -20x -20x -4x -4x -4x -4x -4x -4x -4x -  -20x -  -20x -20x -20x -20x -20x -20x -20x -20x -20x -20x -20x -20x -20x -  -20x -6x -6x -6x -6x -20x -4x -4x -20x -  -20x -20x -20x -  -20x -9x -9x -3x -3x -3x -3x -3x -3x -3x -9x -3x -3x -3x -3x -3x -3x -3x -9x -2x -2x -2x -2x -2x -2x -2x -2x -2x -2x -9x -2x -2x -2x -2x -2x -2x -2x -9x -1x -1x -1x -1x -1x -1x -1x -9x -20x -11x -11x -11x -11x -11x -11x -20x -  -  -  -  -  -20x -3x -3x -3x -3x -3x -3x -3x -3x -3x -3x -3x -3x -3x -3x -3x -3x -3x -3x -3x -3x -3x -3x -3x -  -20x -2x -2x -2x -2x -2x -2x -2x -2x -2x -2x -2x -  -20x -20x -  -20x -20x -  -  -  -  -  -  -  -14x -14x -  -  -14x -2x -2x -2x -2x -2x -  -  -14x -2x -1x -1x -1x -1x -1x -1x -2x -14x -1x -1x -1x -1x -1x -14x -1x -1x -1x -1x -1x -14x -1x -1x -1x -1x -1x -1x -1x -14x -1x -1x -1x -1x -1x -1x -1x -  -7x -14x -14x -14x -14x -  -14x -1x -1x -1x -1x -1x -1x -  -  -14x -1x -1x -1x -1x -1x -  -  -5x -14x -1x -1x -1x -14x -  -  -  -  -  -  -5x -5x -14x -  -  -  -  -  -5x -5x -5x -5x -5x -5x -5x -5x -5x -5x -5x -5x -5x -  -5x -5x -  -  -  -  -  -  -  -  -5x -5x -  -  -  -5x -5x -13x -13x -5x -2x -2x -5x -  -  -  -  -  -  -  -  -  -1x -15x -  -15x -15x -15x -15x -  -15x -15x -15x -15x -15x -2x -2x -15x -  -15x -15x -15x -15x -15x -15x -15x -  -15x -15x -15x -3x -3x -3x -3x -3x -3x -15x -  -  -  -  -15x -15x -15x -  -15x -15x -15x -15x -  -  -15x -15x -15x -15x -15x -15x -15x -15x -  -  -15x -15x -15x -15x -15x -  -15x -15x -15x -15x -15x -  -15x -15x -15x -15x -15x -  -15x -15x -15x -15x -15x -  -15x -15x -15x -15x -15x -  -  -15x -  -15x -15x -  -  -  -  -  -1x -  -  -  -  -  -  -  -  -  -  -  -  -1x -2x -2x -2x -2x -  -  -2x -  -  -  -  -  -1x -  -  -  -  -  - 
#!/usr/bin/env node
- 
-/**
- * CLI tool for tsadwyn.
- *
- * Usage:
- *   npx tsadwyn codegen --app path/to/app.ts
- *   npx tsadwyn info --app path/to/app.ts
- *   npx tsadwyn --version
- *
- * Commands:
- *   codegen - Dynamically imports the module specified by --app, looks for a
- *             default or named `app` export that is a Cadwyn instance, calls
- *             app.generateAndIncludeVersionedRouters() to trigger generation,
- *             and prints a summary of generated versions and routes.
- *
- *   info    - Prints structured information about the app's versions: version
- *             count, version list, route count per version, and a changelog
- *             summary. Accepts an optional `--version <value>` to scope output
- *             to a single version, and `--json` to emit JSON instead of text.
- */
- 
-import { Command, CommanderError } from "commander";
-import { pathToFileURL } from "node:url";
-import { resolve, join, dirname } from "node:path";
-import { existsSync, mkdirSync, writeFileSync } from "node:fs";
- 
-/**
- * The version string reported by `tsadwyn --version` / `tsadwyn -V`.
- * Kept in sync with package.json's `version` field.
- */
-export const CLI_VERSION = "0.1.0";
- 
-/**
- * Result of running a command handler. `output` contains the lines that should
- * be printed to the user (stdout-style messages plus error messages); callers
- * decide whether to route the lines to stdout, stderr, or a test buffer based
- * on `exitCode`.
- */
-export interface CommandResult {
-  exitCode: number;
-  output: string[];
-}
- 
-/**
- * Dynamically import a user's app module from the given path.
- *
- * Returns the imported module. Throws on I/O / resolution errors; callers
- * should catch and convert to a friendly error message.
- */
-async function loadAppModule(appPath: string): Promise<any> {
-  const modulePath = resolve(process.cwd(), appPath);
-  const moduleUrl = pathToFileURL(modulePath).href;
-  return await import(moduleUrl);
-}
- 
-/**
- * Extract the Cadwyn `app` instance from a loaded module, checking the default
- * export first and then the named `app` export.
- *
- * Returns `null` if the module does not export a Cadwyn app in either slot, or
- * if the exported value does not look like a Cadwyn instance (missing the
- * `generateAndIncludeVersionedRouters` method).
- */
-function resolveAppInstance(mod: any): any | null {
-  const app = mod?.default ?? mod?.app;
-  if (!app) return null;
-  if (typeof app.generateAndIncludeVersionedRouters !== "function") {
-    return null;
-  }
-  return app;
-}
- 
-/**
- * Options accepted by the `codegen` command.
- */
-export interface CodegenOptions {
-  app: string;
-}
- 
-/**
- * Run the `codegen` command: load the user's app module, trigger versioned
- * router generation if needed, and return a summary of the generated routers.
- *
- * Returns `{ exitCode: 0, output }` on success, or `{ exitCode: 1, output }`
- * on failure. `output` is a list of human-readable lines; the CLI prints them
- * to stdout (success) or stderr (failure).
- */
-export async function runCodegen(options: CodegenOptions): Promise<CommandResult> {
-  const output: string[] = [];
-  try {
-    const modulePath = resolve(process.cwd(), options.app);
-    output.push(`Loading module from: ${modulePath}`);
- 
-    const mod = await loadAppModule(options.app);
- 
-    const app = mod?.default ?? mod?.app;
-    if (!app) {
-      output.push(
-        "Error: Could not find a Cadwyn app export. " +
-        "The module should have a default export or a named 'app' export.",
-      );
-      return { exitCode: 1, output };
-    }
- 
-    if (typeof app.generateAndIncludeVersionedRouters !== "function") {
-      output.push(
-        "Error: The exported object does not appear to be a Cadwyn instance. " +
-        "It must have a generateAndIncludeVersionedRouters() method.",
-      );
-      return { exitCode: 1, output };
-    }
- 
-    // Print the list of API versions from the bundle, if available.
-    if (typeof app.versions?.versionValues !== "undefined") {
-      const versionValues: string[] = app.versions.versionValues;
-      output.push(`\nFound ${versionValues.length} API version(s):`);
-      for (const v of versionValues) {
-        output.push(`  - ${v}`);
-      }
-    }
- 
-    // If the module also exports routers, pass them to the generator. Otherwise
-    // assume the module already called generateAndIncludeVersionedRouters() at
-    // construction time.
-    const routers = mod.routers ?? mod.versionedRouters;
-    if (routers) {
-      const routerArr = Array.isArray(routers) ? routers : [routers];
-      app.generateAndIncludeVersionedRouters(...routerArr);
-    } else if (app._pendingRouters) {
-      app._performInitialization?.();
-    }
- 
-    // Print a summary of the generated versioned routers.
-    const versionedRouters: Map<string, any> = app._versionedRouters;
-    if (versionedRouters && versionedRouters.size > 0) {
-      output.push(`\nGenerated ${versionedRouters.size} versioned router(s).`);
-      for (const [version, router] of versionedRouters) {
-        const routeCount = router?.stack?.length ?? "unknown";
-        output.push(`  Version ${version}: ${routeCount} route(s)`);
-      }
-    } else {
-      output.push("\nNo versioned routers were generated.");
-      output.push(
-        "Make sure the module exports routers (as 'routers' or 'versionedRouters') " +
-        "or calls generateAndIncludeVersionedRouters() before export.",
-      );
-    }
- 
-    output.push("\nCode generation complete.");
-    return { exitCode: 0, output };
-  } catch (err) {
-    const message = err instanceof Error ? err.message : String(err);
-    output.push(`Error during code generation: ${message}`);
-    return { exitCode: 1, output };
-  }
-}
- 
-/**
- * Options accepted by the `info` command.
- */
-export interface InfoOptions {
-  app: string;
-  version?: string;
-  json?: boolean;
-}
- 
-/**
- * Structured info payload printed by the `info` command when `--json` is used.
- * Also used internally to format the plaintext output.
- */
-export interface InfoPayload {
-  versions: Array<{
-    value: string;
-    isLatest: boolean;
-    isOldest: boolean;
-    routeCount: number | null;
-    changeCount: number;
-  }>;
-  totalVersions: number;
-  totalSchemas: number;
-  totalChanges: number;
-}
- 
-/**
- * Build an InfoPayload from a Cadwyn app instance. Extracted from runInfo so
- * the rendering logic can be unit-tested independently of module loading.
- */
-function buildInfoPayload(app: any, onlyVersion?: string): InfoPayload {
-  const versionValues: string[] = app.versions?.versionValues ?? [];
-  const versionedRouters: Map<string, any> | undefined = app._versionedRouters;
- 
-  const payload: InfoPayload = {
-    versions: [],
-    totalVersions: versionValues.length,
-    totalSchemas: 0,
-    totalChanges: 0,
-  };
- 
-  // Best-effort schema + change counting. Swallow errors so `info` never
-  // crashes just because an exotic app is missing a field.
-  try {
-    const versions = app.versions?.versions ?? [];
-    const schemaNames = new Set<string>();
-    for (const version of versions) {
-      for (const change of version.changes ?? []) {
-        payload.totalChanges++;
-        for (const instr of change._alterSchemaInstructions ?? []) {
-          const name = instr?.schemaName;
-          if (typeof name === "string") schemaNames.add(name);
-        }
-      }
-    }
-    payload.totalSchemas = schemaNames.size;
-  } catch {
-    // Ignore introspection failures.
-  }
- 
-  for (let i = 0; i < versionValues.length; i++) {
-    const value = versionValues[i];
-    if (onlyVersion !== undefined && value !== onlyVersion) continue;
- 
-    const router = versionedRouters?.get(value);
-    const routeCount = router?.stack?.length ?? null;
- 
-    let changeCount = 0;
-    const versionObj = app.versions?.versions?.[i];
-    if (versionObj && Array.isArray(versionObj.changes)) {
-      changeCount = versionObj.changes.length;
-    }
- 
-    payload.versions.push({
-      value,
-      isLatest: i === 0,
-      isOldest: i === versionValues.length - 1,
-      routeCount,
-      changeCount,
-    });
-  }
- 
-  return payload;
-}
- 
-/**
- * Render an InfoPayload as plaintext output lines.
- */
-function renderInfoPayload(payload: InfoPayload): string[] {
-  const lines: string[] = [];
-  lines.push("tsadwyn info");
-  lines.push("============");
-  lines.push(`Versions: ${payload.totalVersions}`);
-  for (const v of payload.versions) {
-    const tag = v.isLatest
-      ? " (latest)"
-      : v.isOldest
-        ? " (oldest)"
-        : "";
-    const routes = v.routeCount === null ? "unknown" : `${v.routeCount}`;
-    lines.push(`  ${v.value}${tag} - ${routes} route(s), ${v.changeCount} change(s)`);
-  }
-  lines.push("");
-  lines.push(`Schemas: ${payload.totalSchemas}`);
-  lines.push(`Total version changes: ${payload.totalChanges}`);
-  return lines;
-}
- 
-/**
- * Run the `info` command: load the user's app module and print a structured
- * summary of its versions, routes, and schemas. When `options.version` is set,
- * only that version is included. When `options.json` is true, emit a single
- * JSON line instead of formatted text.
- *
- * Returns `{ exitCode: 0, output }` on success and `{ exitCode: 1, output }`
- * on failure.
- */
-export async function runInfo(options: InfoOptions): Promise<CommandResult> {
-  const output: string[] = [];
-  try {
-    const mod = await loadAppModule(options.app);
-    const app = resolveAppInstance(mod);
- 
-    if (!app) {
-      output.push(
-        "Error: Could not find a Cadwyn app export. " +
-        "The module should have a default export or a named 'app' export " +
-        "that is a Cadwyn instance.",
-      );
-      return { exitCode: 1, output };
-    }
- 
-    // Ensure lazy initialization has run so _versionedRouters is populated.
-    if (app._pendingRouters && typeof app._performInitialization === "function") {
-      try {
-        app._performInitialization();
-      } catch {
-        // Ignore — info should still work on partially-initialized apps.
-      }
-    }
- 
-    // Validate --version if provided.
-    if (options.version !== undefined) {
-      const knownVersions: string[] = app.versions?.versionValues ?? [];
-      if (!knownVersions.includes(options.version)) {
-        output.push(
-          `Error: Unknown version "${options.version}". ` +
-          `Available versions: ${knownVersions.join(", ") || "(none)"}`,
-        );
-        return { exitCode: 1, output };
-      }
-    }
- 
-    const payload = buildInfoPayload(app, options.version);
- 
-    if (options.json) {
-      output.push(JSON.stringify(payload));
-    } else {
-      output.push(...renderInfoPayload(payload));
-    }
- 
-    return { exitCode: 0, output };
-  } catch (err) {
-    const message = err instanceof Error ? err.message : String(err);
-    output.push(`Error during info lookup: ${message}`);
-    return { exitCode: 1, output };
-  }
-}
- 
-// ═══════════════════════════════════════════════════════════════════════════
-// `new version` command — scaffold a new VersionChange file
-// ═══════════════════════════════════════════════════════════════════════════
- 
-/**
- * A single field rename parsed from `--rename-field Schema.currentName=oldName` flags.
- * `currentName` is the name in the head (latest) schema; `oldName` is what the
- * field was called in the previous version.
- */
-interface RenameFieldSpec {
-  schema: string;
-  /** The name in the head (latest) schema. */
-  currentName: string;
-  /** The name in the previous (older) version. */
-  oldName: string;
-}
- 
-/**
- * A single field addition/removal parsed from `--add-field Schema.name` /
- * `--remove-field Schema.name`. The semantic is from the perspective of the
- * new version: "add" means head gained the field (older version didn't have it),
- * "remove" means head dropped the field (older version kept it).
- */
-interface FieldSpec {
-  schema: string;
-  field: string;
-}
- 
-/**
- * A single endpoint addition/removal parsed from
- * `--add-endpoint "METHOD /path"` / `--remove-endpoint "METHOD /path"`.
- */
-interface EndpointSpec {
-  method: string;
-  path: string;
-}
- 
-/**
- * Options accepted by the `new version` command.
- */
-export interface NewVersionOptions {
-  /** Date string (YYYY-MM-DD) for the new version. */
-  date: string;
-  /** Human-readable description of what changed. */
-  description?: string;
-  /** Output directory for the new file (default: `./src/versions`). */
-  dir?: string;
-  /** Class name for the VersionChange subclass (default: auto-derived). */
-  name?: string;
-  /** Field rename specs: "Schema.old=new". */
-  renameField?: string[];
-  /** Field addition specs: "Schema.field" (head added, old version didn't have). */
-  addField?: string[];
-  /** Field removal specs: "Schema.field" (head removed, old version had). */
-  removeField?: string[];
-  /** Endpoint addition specs: "METHOD /path" (head added, old version didn't have). */
-  addEndpoint?: string[];
-  /** Endpoint removal specs: "METHOD /path" (head removed, old version had). */
-  removeEndpoint?: string[];
-  /** Print the generated content without writing to disk. */
-  dryRun?: boolean;
-  /** Overwrite existing file if present. */
-  force?: boolean;
-}
- 
-const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/;
-const VALID_HTTP_METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]);
- 
-/**
- * Parse a "Schema.currentName=oldName" rename spec.
- * LHS is the name in the head schema (current), RHS is the name in the previous version.
- */
-function parseRenameFieldSpec(spec: string): RenameFieldSpec | null {
-  const [left, right] = spec.split("=", 2);
-  if (!left || !right) return null;
-  const dotIdx = left.indexOf(".");
-  if (dotIdx < 0) return null;
-  const schema = left.slice(0, dotIdx).trim();
-  const currentName = left.slice(dotIdx + 1).trim();
-  const oldName = right.trim();
-  if (!schema || !currentName || !oldName) return null;
-  return { schema, currentName, oldName };
-}
- 
-/**
- * Parse a "Schema.field" spec.
- */
-function parseFieldSpec(spec: string): FieldSpec | null {
-  const dotIdx = spec.indexOf(".");
-  if (dotIdx < 0) return null;
-  const schema = spec.slice(0, dotIdx).trim();
-  const field = spec.slice(dotIdx + 1).trim();
-  if (!schema || !field) return null;
-  return { schema, field };
-}
- 
-/**
- * Parse a "METHOD /path" spec.
- */
-function parseEndpointSpec(spec: string): EndpointSpec | null {
-  const match = spec.trim().match(/^([A-Za-z]+)\s+(\S+)$/);
-  if (!match) return null;
-  const method = match[1].toUpperCase();
-  if (!VALID_HTTP_METHODS.has(method)) return null;
-  return { method, path: match[2] };
-}
- 
-/**
- * Convert a date + optional name into a valid TypeScript class name.
- * Example: "2024-12-01" -> "V20241201Change"
- *          "2024-12-01" + "Rename payment_method" -> "RenamePaymentMethod"
- */
-function deriveClassName(date: string, explicitName?: string, description?: string): string {
-  if (explicitName) {
-    // Ensure first character is a letter
-    const cleaned = explicitName.replace(/[^A-Za-z0-9]/g, "");
-    if (cleaned && /^[A-Za-z]/.test(cleaned)) return cleaned;
-    return `V${cleaned || date.replace(/-/g, "")}Change`;
-  }
-  if (description) {
-    // PascalCase from description, max 40 chars
-    const words = description
-      .toLowerCase()
-      .replace(/[^a-z0-9\s]/g, " ")
-      .trim()
-      .split(/\s+/)
-      .filter((w) => w.length > 0 && !/^\d/.test(w))
-      .slice(0, 6);
-    if (words.length > 0) {
-      return words.map((w) => w[0].toUpperCase() + w.slice(1)).join("");
-    }
-  }
-  return `V${date.replace(/-/g, "")}Change`;
-}
- 
-/**
- * Collect all unique schema names referenced across all field specs so we
- * can emit a single import statement for the user to fill in.
- */
-function collectSchemaNames(
-  renames: RenameFieldSpec[],
-  adds: FieldSpec[],
-  removes: FieldSpec[],
-): string[] {
-  const set = new Set<string>();
-  for (const r of renames) set.add(r.schema);
-  for (const a of adds) set.add(a.schema);
-  for (const r of removes) set.add(r.schema);
-  return [...set].sort();
-}
- 
-/**
- * Generate the TypeScript source for a new VersionChange file. Pure function —
- * no file I/O — so it can be tested independently.
- */
-export function generateVersionChangeSource(
-  options: NewVersionOptions,
-): { className: string; source: string } {
-  const date = options.date;
-  const description =
-    options.description ?? `TODO: describe what changed in version ${date}`;
-  const className = deriveClassName(date, options.name, options.description);
- 
-  const renames = (options.renameField ?? [])
-    .map(parseRenameFieldSpec)
-    .filter((s): s is RenameFieldSpec => s !== null);
-  const adds = (options.addField ?? [])
-    .map(parseFieldSpec)
-    .filter((s): s is FieldSpec => s !== null);
-  const removes = (options.removeField ?? [])
-    .map(parseFieldSpec)
-    .filter((s): s is FieldSpec => s !== null);
-  const addEndpoints = (options.addEndpoint ?? [])
-    .map(parseEndpointSpec)
-    .filter((s): s is EndpointSpec => s !== null);
-  const removeEndpoints = (options.removeEndpoint ?? [])
-    .map(parseEndpointSpec)
-    .filter((s): s is EndpointSpec => s !== null);
- 
-  const schemaNames = collectSchemaNames(renames, adds, removes);
-  const hasSchemaInstructions =
-    renames.length > 0 || adds.length > 0 || removes.length > 0;
-  const hasEndpointInstructions =
-    addEndpoints.length > 0 || removeEndpoints.length > 0;
-  const hasAnyInstructions = hasSchemaInstructions || hasEndpointInstructions;
-  const hasMigrationCallbacks = renames.length > 0 || removes.length > 0;
- 
-  const tsadwynImports: string[] = [
-    "VersionChange",
-  ];
-  if (hasSchemaInstructions) tsadwynImports.push("schema");
-  if (hasEndpointInstructions) tsadwynImports.push("endpoint");
-  if (hasMigrationCallbacks) {
-    tsadwynImports.push(
-      "convertRequestToNextVersionFor",
-      "convertResponseToPreviousVersionFor",
-      "RequestInfo",
-      "ResponseInfo",
-    );
-  }
- 
-  const lines: string[] = [];
- 
-  lines.push(`/**`);
-  lines.push(` * Version ${date}`);
-  lines.push(` *`);
-  const descLines = description.split("\n");
-  for (const dl of descLines) lines.push(` * ${dl}`);
-  lines.push(` *`);
-  lines.push(` * Next steps:`);
-  lines.push(` *   1. Fill in the migration instructions and callbacks below.`);
-  lines.push(` *   2. Import this class in your VersionBundle file.`);
-  lines.push(` *   3. Add \`new Version("${date}", ${className})\` to the bundle`);
-  lines.push(` *      (newest version first).`);
-  lines.push(` */`);
-  lines.push(`import { ${tsadwynImports.join(", ")} } from "tsadwyn";`);
- 
-  if (schemaNames.length > 0) {
-    lines.push(
-      `import { ${schemaNames.join(", ")} } from "../schemas.js"; // TODO: adjust import path`,
-    );
-  }
-  if (hasMigrationCallbacks) {
-    lines.push(`import { z } from "zod";`);
-  }
-  lines.push(``);
- 
-  lines.push(`export class ${className} extends VersionChange {`);
-  lines.push(`  description = ${JSON.stringify(description)};`);
-  lines.push(``);
- 
-  if (hasAnyInstructions) {
-    lines.push(`  instructions = [`);
-    for (const r of renames) {
-      lines.push(
-        `    // In the previous version, ${r.schema}.${r.currentName} was called "${r.oldName}".`,
-      );
-      lines.push(
-        `    schema(${r.schema}).field(${JSON.stringify(r.currentName)}).had({ name: ${JSON.stringify(r.oldName)} }),`,
-      );
-    }
-    for (const a of adds) {
-      lines.push(
-        `    // ${a.schema}.${a.field} is new in this version — the previous version did not have it.`,
-      );
-      lines.push(
-        `    schema(${a.schema}).field(${JSON.stringify(a.field)}).didntExist,`,
-      );
-    }
-    for (const r of removes) {
-      lines.push(
-        `    // ${r.schema}.${r.field} was removed in this version — the previous version had it.`,
-      );
-      lines.push(
-        `    // TODO: replace z.unknown() with the actual Zod type the field used to have.`,
-      );
-      lines.push(
-        `    schema(${r.schema}).field(${JSON.stringify(r.field)}).existedAs({ type: z.unknown() }),`,
-      );
-    }
-    for (const e of addEndpoints) {
-      lines.push(
-        `    // ${e.method} ${e.path} is new in this version — the previous version did not have it.`,
-      );
-      lines.push(
-        `    endpoint(${JSON.stringify(e.path)}, [${JSON.stringify(e.method)}]).didntExist,`,
-      );
-    }
-    for (const e of removeEndpoints) {
-      lines.push(
-        `    // ${e.method} ${e.path} was removed in this version — the previous version had it.`,
-      );
-      lines.push(
-        `    endpoint(${JSON.stringify(e.path)}, [${JSON.stringify(e.method)}]).existed,`,
-      );
-    }
-    lines.push(`  ];`);
-  } else {
-    lines.push(`  instructions = [`);
-    lines.push(`    // TODO: add schema() / endpoint() instructions here.`);
-    lines.push(`    //   schema(MySchema).field("name").had({ name: "oldName" }),`);
-    lines.push(`    //   endpoint("/users/:id", ["DELETE"]).didntExist,`);
-    lines.push(`  ];`);
-  }
-  lines.push(``);
- 
-  // Emit migration callback stubs for each rename.
-  // The old version uses `oldName`; the head (latest) version uses `currentName`.
-  // Request migration: old client sends `oldName` -> rename to `currentName` for the handler.
-  // Response migration: handler returns `currentName` -> rename back to `oldName` for the old client.
-  for (const r of renames) {
-    const sanitize = (s: string) => s.replace(/[^A-Za-z0-9]/g, "_");
-    const methodSuffix = `${sanitize(r.schema)}_${sanitize(r.currentName)}`;
-    lines.push(`  // Request migration: old version sends "${r.oldName}", rename to "${r.currentName}".`);
-    lines.push(`  migrateRequest_${methodSuffix} = convertRequestToNextVersionFor(${r.schema})(`);
-    lines.push(`    (request: RequestInfo) => {`);
-    lines.push(`      if (${JSON.stringify(r.oldName)} in request.body) {`);
-    lines.push(`        request.body[${JSON.stringify(r.currentName)}] = request.body[${JSON.stringify(r.oldName)}];`);
-    lines.push(`        delete request.body[${JSON.stringify(r.oldName)}];`);
-    lines.push(`      }`);
-    lines.push(`    },`);
-    lines.push(`  );`);
-    lines.push(``);
-    lines.push(`  // Response migration: head returns "${r.currentName}", rename back to "${r.oldName}" for old version.`);
-    lines.push(`  migrateResponse_${methodSuffix} = convertResponseToPreviousVersionFor(${r.schema})(`);
-    lines.push(`    (response: ResponseInfo) => {`);
-    lines.push(`      if (${JSON.stringify(r.currentName)} in response.body) {`);
-    lines.push(`        response.body[${JSON.stringify(r.oldName)}] = response.body[${JSON.stringify(r.currentName)}];`);
-    lines.push(`        delete response.body[${JSON.stringify(r.currentName)}];`);
-    lines.push(`      }`);
-    lines.push(`    },`);
-    lines.push(`  );`);
-    lines.push(``);
-  }
- 
-  for (const r of removes) {
-    const sanitize = (s: string) => s.replace(/[^A-Za-z0-9]/g, "_");
-    const methodSuffix = `${sanitize(r.schema)}_${sanitize(r.field)}`;
-    lines.push(`  // Response migration: head dropped ${r.field}, but old version still expects it.`);
-    lines.push(`  // TODO: compute a sensible value for ${r.field} from the remaining response fields.`);
-    lines.push(`  migrateResponse_${methodSuffix} = convertResponseToPreviousVersionFor(${r.schema})(`);
-    lines.push(`    (response: ResponseInfo) => {`);
-    lines.push(`      response.body[${JSON.stringify(r.field)}] = null; // TODO: replace with real value`);
-    lines.push(`    },`);
-    lines.push(`  );`);
-    lines.push(``);
-  }
- 
-  lines.push(`}`);
-  lines.push(``);
- 
-  return { className, source: lines.join("\n") };
-}
- 
-/**
- * Run the `new version` command: scaffold a new VersionChange file.
- *
- * Writes to disk unless `dryRun` is set. Returns a CommandResult whose output
- * includes the generated file path and next-step instructions.
- */
-export async function runNewVersion(options: NewVersionOptions): Promise<CommandResult> {
-  const output: string[] = [];
- 
-  // Validate date
-  if (!options.date || !ISO_DATE_REGEX.test(options.date)) {
-    output.push(
-      `Error: --date must be an ISO date string (YYYY-MM-DD). Got: "${options.date ?? "(missing)"}"`,
-    );
-    return { exitCode: 1, output };
-  }
- 
-  // Validate any rename/add/remove/endpoint specs early so users get clear errors
-  for (const spec of options.renameField ?? []) {
-    if (!parseRenameFieldSpec(spec)) {
-      output.push(
-        `Error: --rename-field must be "Schema.currentName=oldName" ` +
-        `(e.g. "ChargeResource.payment_source=payment_method"). Got: "${spec}"`,
-      );
-      return { exitCode: 1, output };
-    }
-  }
-  for (const spec of options.addField ?? []) {
-    if (!parseFieldSpec(spec)) {
-      output.push(`Error: --add-field must be "Schema.field". Got: "${spec}"`);
-      return { exitCode: 1, output };
-    }
-  }
-  for (const spec of options.removeField ?? []) {
-    if (!parseFieldSpec(spec)) {
-      output.push(`Error: --remove-field must be "Schema.field". Got: "${spec}"`);
-      return { exitCode: 1, output };
-    }
-  }
-  for (const spec of options.addEndpoint ?? []) {
-    if (!parseEndpointSpec(spec)) {
-      output.push(
-        `Error: --add-endpoint must be "METHOD /path" (e.g. "POST /users"). Got: "${spec}"`,
-      );
-      return { exitCode: 1, output };
-    }
-  }
-  for (const spec of options.removeEndpoint ?? []) {
-    if (!parseEndpointSpec(spec)) {
-      output.push(
-        `Error: --remove-endpoint must be "METHOD /path" (e.g. "DELETE /users/:id"). Got: "${spec}"`,
-      );
-      return { exitCode: 1, output };
-    }
-  }
- 
-  const { className, source } = generateVersionChangeSource(options);
-  const dir = options.dir ?? "./src/versions";
-  const absDir = resolve(process.cwd(), dir);
-  const absFile = join(absDir, `${options.date}.ts`);
-  const relFile = join(dir, `${options.date}.ts`);
- 
-  if (options.dryRun) {
-    output.push(`# Dry run — file would be written to: ${relFile}`);
-    output.push(`# Class name: ${className}`);
-    output.push(``);
-    output.push(source);
-    return { exitCode: 0, output };
-  }
- 
-  // Check for existing file
-  if (existsSync(absFile) && !options.force) {
-    output.push(
-      `Error: File already exists at ${relFile}. Use --force to overwrite.`,
-    );
-    return { exitCode: 1, output };
-  }
- 
-  // Create directory if missing
-  try {
-    if (!existsSync(absDir)) {
-      mkdirSync(absDir, { recursive: true });
-      output.push(`Created directory: ${dir}`);
-    }
-  } catch (err) {
-    const message = err instanceof Error ? err.message : String(err);
-    output.push(`Error creating directory ${dir}: ${message}`);
-    return { exitCode: 1, output };
-  }
- 
-  // Write file
-  try {
-    writeFileSync(absFile, source, "utf-8");
-  } catch (err) {
-    const message = err instanceof Error ? err.message : String(err);
-    output.push(`Error writing file ${relFile}: ${message}`);
-    return { exitCode: 1, output };
-  }
- 
-  output.push(`Created new version file: ${relFile}`);
-  output.push(``);
-  output.push(`Next steps:`);
-  output.push(`  1. Edit ${relFile} to fill in the migration details.`);
-  output.push(`  2. In your VersionBundle file, add the import:`);
-  output.push(`       import { ${className} } from "./versions/${options.date}.js";`);
-  output.push(`  3. Add the new Version to your VersionBundle (newest first):`);
-  output.push(`       new VersionBundle(`);
-  output.push(`         new Version("${options.date}", ${className}),   // <-- add this line`);
-  output.push(`         // ... existing versions ...`);
-  output.push(`       )`);
-  output.push(``);
-  output.push(`Remember: tsadwyn versions are sorted newest-first.`);
- 
-  return { exitCode: 0, output };
-}
- 
-/**
- * Pipe a CommandResult's output lines through a Commander command, writing to
- * stdout on success and stderr on failure. On failure, throw a CommanderError
- * so that parseAsync() rejects (or process exits, depending on the caller's
- * exitOverride configuration). Commander provides default writeOut/writeErr
- * sinks that target process.stdout/stderr, so no fallback is needed.
- */
-function emitResult(cmd: Command, result: CommandResult, errorCode: string): void {
-  const { writeOut, writeErr } = cmd.configureOutput() as {
-    writeOut: (s: string) => void;
-    writeErr: (s: string) => void;
-  };
-  const sink = result.exitCode === 0 ? writeOut : writeErr;
-  for (const line of result.output) {
-    sink(line + "\n");
-  }
-  if (result.exitCode !== 0) {
-    throw new CommanderError(result.exitCode, errorCode, "command failed");
-  }
-}
- 
-/**
- * Construct a fresh `Command` with every tsadwyn subcommand registered.
- *
- * A factory is exposed (in addition to the singleton `program`) so that tests
- * can obtain a clean program per test case — Commander's internal state
- * (seen-options, exit-override flags, output configuration, etc.) is
- * per-instance, and reusing one instance across tests leaks state.
- */
-export function createProgram(): Command {
-  const cmd = new Command();
- 
-  cmd
-    .name("tsadwyn")
-    .description("Stripe-like API versioning framework for TypeScript/Express")
-    .version(CLI_VERSION, "-V, --version", "output the current version");
- 
-  cmd
-    .command("codegen")
-    .description("Generate versioned routers from a Cadwyn application module")
-    .requiredOption("--app <path>", "Path to the module that exports the Cadwyn app")
-    .action(async (options: CodegenOptions) => {
-      const result = await runCodegen(options);
-      emitResult(cmd, result, "tsadwyn.codegenFailed");
-    });
- 
-  cmd
-    .command("info")
-    .description("Print structured info about the app's versions and routes")
-    .requiredOption("--app <path>", "Path to the module that exports the Cadwyn app")
-    .option(
-      "--api-version <value>",
-      "Show info for a single API version only (use this instead of --version " +
-      "to avoid collision with the program's --version flag)",
-    )
-    .option("--json", "Emit output as JSON instead of formatted text")
-    .action(async (options: { app: string; apiVersion?: string; json?: boolean }) => {
-      const result = await runInfo({
-        app: options.app,
-        version: options.apiVersion,
-        json: options.json,
-      });
-      emitResult(cmd, result, "tsadwyn.infoFailed");
-    });
- 
-  // ─────────────────────────────────────────────────────────────────────
-  // `new` — scaffolding subcommands
-  // ─────────────────────────────────────────────────────────────────────
-  const newCmd = cmd
-    .command("new")
-    .description("Scaffold new tsadwyn resources");
- 
-  newCmd
-    .command("version")
-    .description(
-      "Scaffold a new VersionChange file for a breaking API change. " +
-      "Creates a ready-to-edit TypeScript file with imports, class structure, " +
-      "and optional pre-populated migration instructions.",
-    )
-    .requiredOption("--date <YYYY-MM-DD>", "ISO date for the new version")
-    .option("--description <text>", "Human-readable description of what changed")
-    .option("--dir <path>", "Output directory for the generated file", "./src/versions")
-    .option("--name <ClassName>", "Override the VersionChange class name")
-    .option(
-      "--rename-field <spec>",
-      'Pre-populate a field rename: "Schema.newName=oldName" — ' +
-      "the field is currently called 'newName' in the head schema and was called 'oldName' in the previous version. " +
-      "Can be repeated.",
-      (value: string, previous: string[] | undefined) => (previous ?? []).concat([value]),
-    )
-    .option(
-      "--add-field <spec>",
-      'Pre-populate a field addition: "Schema.field" — ' +
-      "the field is new in this version (previous version did not have it). Can be repeated.",
-      (value: string, previous: string[] | undefined) => (previous ?? []).concat([value]),
-    )
-    .option(
-      "--remove-field <spec>",
-      'Pre-populate a field removal: "Schema.field" — ' +
-      "the field was removed in this version (previous version had it). Can be repeated.",
-      (value: string, previous: string[] | undefined) => (previous ?? []).concat([value]),
-    )
-    .option(
-      "--add-endpoint <spec>",
-      'Pre-populate an endpoint addition: "METHOD /path" (e.g. "POST /users"). ' +
-      "Endpoint is new in this version. Can be repeated.",
-      (value: string, previous: string[] | undefined) => (previous ?? []).concat([value]),
-    )
-    .option(
-      "--remove-endpoint <spec>",
-      'Pre-populate an endpoint removal: "METHOD /path" (e.g. "DELETE /users/:id"). ' +
-      "Endpoint was removed in this version. Can be repeated.",
-      (value: string, previous: string[] | undefined) => (previous ?? []).concat([value]),
-    )
-    .option("--dry-run", "Print the generated file content without writing to disk")
-    .option("--force", "Overwrite an existing file at the target path")
-    .action(async (options: NewVersionOptions) => {
-      const result = await runNewVersion(options);
-      emitResult(cmd, result, "tsadwyn.newVersionFailed");
-    });
- 
-  return cmd;
-}
- 
-/**
- * The default singleton program instance. Kept as a named export for
- * backwards compatibility and for CLI-as-script use.
- */
-export const program: Command = createProgram();
- 
-/**
- * Determine whether the current module is the entrypoint (i.e. being executed
- * directly via `node dist/cli.js` or `tsx src/cli.ts`), as opposed to being
- * imported by tests or library code.
- *
- * We compare the basename of `process.argv[1]` to the expected CLI entry
- * filenames (`cli.js`, `cli.cjs`, `cli.mjs`, `cli.ts`) since `import.meta`
- * cannot be used in files that compile to CommonJS. The vitest runner loads
- * this file via its test runner binary, so `argv[1]` does not match any of
- * those names and the guard correctly returns `false` at import time.
- */
-export function isMainModule(): boolean {
-  try {
-    if (typeof process === "undefined" || !process.argv?.[1]) return false;
-    return /[\\/]cli\.(c|m)?(j|t)s$/.test(process.argv[1]);
-  } catch {
-    return false;
-  }
-}
- 
-// Only parse argv when this file is executed directly, not when it is imported
-// by the test suite. The bootstrap block below cannot execute under vitest
-// (argv[1] is the vitest runner), so any uncovered statements within it are
-// expected.
-if (isMainModule()) {
-  program.parseAsync(process.argv).catch((err) => {
-    process.stderr.write(`${(err as Error).message ?? err}\n`);
-    process.exit(1);
-  });
-}
- -
-
- - - - - - - - \ No newline at end of file diff --git a/coverage/clover.xml b/coverage/clover.xml deleted file mode 100644 index 63a65e5..0000000 --- a/coverage/clover.xml +++ /dev/null @@ -1,601 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/coverage/coverage-final.json b/coverage/coverage-final.json deleted file mode 100644 index de8675f..0000000 --- a/coverage/coverage-final.json +++ /dev/null @@ -1,2 +0,0 @@ -{"/Volumes/code/kirafin/tsadwyn/src/cli.ts": {"path":"/Volumes/code/kirafin/tsadwyn/src/cli.ts","all":false,"statementMap":{"22":{"start":{"line":23,"column":0},"end":{"line":23,"column":52}},"23":{"start":{"line":24,"column":0},"end":{"line":24,"column":41}},"24":{"start":{"line":25,"column":0},"end":{"line":25,"column":51}},"25":{"start":{"line":26,"column":0},"end":{"line":26,"column":63}},"31":{"start":{"line":32,"column":0},"end":{"line":32,"column":35}},"50":{"start":{"line":51,"column":0},"end":{"line":51,"column":61}},"51":{"start":{"line":52,"column":0},"end":{"line":52,"column":53}},"52":{"start":{"line":53,"column":0},"end":{"line":53,"column":51}},"53":{"start":{"line":54,"column":0},"end":{"line":54,"column":33}},"54":{"start":{"line":55,"column":0},"end":{"line":55,"column":1}},"64":{"start":{"line":65,"column":0},"end":{"line":65,"column":51}},"65":{"start":{"line":66,"column":0},"end":{"line":66,"column":39}},"66":{"start":{"line":67,"column":0},"end":{"line":67,"column":24}},"67":{"start":{"line":68,"column":0},"end":{"line":68,"column":69}},"68":{"start":{"line":69,"column":0},"end":{"line":69,"column":16}},"69":{"start":{"line":70,"column":0},"end":{"line":70,"column":3}},"70":{"start":{"line":71,"column":0},"end":{"line":71,"column":13}},"71":{"start":{"line":72,"column":0},"end":{"line":72,"column":1}},"88":{"start":{"line":89,"column":0},"end":{"line":89,"column":83}},"89":{"start":{"line":90,"column":0},"end":{"line":90,"column":30}},"90":{"start":{"line":91,"column":0},"end":{"line":91,"column":7}},"91":{"start":{"line":92,"column":0},"end":{"line":92,"column":59}},"92":{"start":{"line":93,"column":0},"end":{"line":93,"column":54}},"94":{"start":{"line":95,"column":0},"end":{"line":95,"column":49}},"96":{"start":{"line":97,"column":0},"end":{"line":97,"column":41}},"97":{"start":{"line":98,"column":0},"end":{"line":98,"column":15}},"98":{"start":{"line":99,"column":0},"end":{"line":99,"column":18}},"99":{"start":{"line":100,"column":0},"end":{"line":100,"column":55}},"101":{"start":{"line":102,"column":0},"end":{"line":102,"column":8}},"102":{"start":{"line":103,"column":0},"end":{"line":103,"column":37}},"103":{"start":{"line":104,"column":0},"end":{"line":104,"column":5}},"105":{"start":{"line":106,"column":0},"end":{"line":106,"column":71}},"106":{"start":{"line":107,"column":0},"end":{"line":107,"column":18}},"107":{"start":{"line":108,"column":0},"end":{"line":108,"column":80}},"109":{"start":{"line":110,"column":0},"end":{"line":110,"column":8}},"110":{"start":{"line":111,"column":0},"end":{"line":111,"column":37}},"111":{"start":{"line":112,"column":0},"end":{"line":112,"column":5}},"114":{"start":{"line":115,"column":0},"end":{"line":115,"column":61}},"115":{"start":{"line":116,"column":0},"end":{"line":116,"column":65}},"116":{"start":{"line":117,"column":0},"end":{"line":117,"column":69}},"117":{"start":{"line":118,"column":0},"end":{"line":118,"column":38}},"118":{"start":{"line":119,"column":0},"end":{"line":119,"column":32}},"119":{"start":{"line":120,"column":0},"end":{"line":120,"column":7}},"120":{"start":{"line":121,"column":0},"end":{"line":121,"column":5}},"125":{"start":{"line":126,"column":0},"end":{"line":126,"column":56}},"126":{"start":{"line":127,"column":0},"end":{"line":127,"column":18}},"127":{"start":{"line":128,"column":0},"end":{"line":128,"column":69}},"128":{"start":{"line":129,"column":0},"end":{"line":129,"column":59}},"129":{"start":{"line":130,"column":0},"end":{"line":130,"column":37}},"130":{"start":{"line":131,"column":0},"end":{"line":131,"column":37}},"131":{"start":{"line":132,"column":0},"end":{"line":132,"column":5}},"134":{"start":{"line":135,"column":0},"end":{"line":135,"column":69}},"135":{"start":{"line":136,"column":0},"end":{"line":136,"column":56}},"136":{"start":{"line":137,"column":0},"end":{"line":137,"column":79}},"137":{"start":{"line":138,"column":0},"end":{"line":138,"column":57}},"138":{"start":{"line":139,"column":0},"end":{"line":139,"column":62}},"139":{"start":{"line":140,"column":0},"end":{"line":140,"column":68}},"140":{"start":{"line":141,"column":0},"end":{"line":141,"column":7}},"141":{"start":{"line":142,"column":0},"end":{"line":142,"column":12}},"142":{"start":{"line":143,"column":0},"end":{"line":143,"column":60}},"143":{"start":{"line":144,"column":0},"end":{"line":144,"column":18}},"144":{"start":{"line":145,"column":0},"end":{"line":145,"column":86}},"146":{"start":{"line":147,"column":0},"end":{"line":147,"column":8}},"147":{"start":{"line":148,"column":0},"end":{"line":148,"column":5}},"149":{"start":{"line":150,"column":0},"end":{"line":150,"column":47}},"150":{"start":{"line":151,"column":0},"end":{"line":151,"column":35}},"151":{"start":{"line":152,"column":0},"end":{"line":152,"column":17}},"152":{"start":{"line":153,"column":0},"end":{"line":153,"column":69}},"153":{"start":{"line":154,"column":0},"end":{"line":154,"column":60}},"154":{"start":{"line":155,"column":0},"end":{"line":155,"column":35}},"155":{"start":{"line":156,"column":0},"end":{"line":156,"column":3}},"156":{"start":{"line":157,"column":0},"end":{"line":157,"column":1}},"188":{"start":{"line":189,"column":0},"end":{"line":189,"column":72}},"189":{"start":{"line":190,"column":0},"end":{"line":190,"column":68}},"190":{"start":{"line":191,"column":0},"end":{"line":191,"column":79}},"192":{"start":{"line":193,"column":0},"end":{"line":193,"column":32}},"193":{"start":{"line":194,"column":0},"end":{"line":194,"column":17}},"194":{"start":{"line":195,"column":0},"end":{"line":195,"column":40}},"195":{"start":{"line":196,"column":0},"end":{"line":196,"column":20}},"196":{"start":{"line":197,"column":0},"end":{"line":197,"column":20}},"197":{"start":{"line":198,"column":0},"end":{"line":198,"column":4}},"201":{"start":{"line":202,"column":0},"end":{"line":202,"column":7}},"202":{"start":{"line":203,"column":0},"end":{"line":203,"column":50}},"203":{"start":{"line":204,"column":0},"end":{"line":204,"column":42}},"204":{"start":{"line":205,"column":0},"end":{"line":205,"column":37}},"205":{"start":{"line":206,"column":0},"end":{"line":206,"column":51}},"206":{"start":{"line":207,"column":0},"end":{"line":207,"column":31}},"207":{"start":{"line":208,"column":0},"end":{"line":208,"column":68}},"208":{"start":{"line":209,"column":0},"end":{"line":209,"column":41}},"209":{"start":{"line":210,"column":0},"end":{"line":210,"column":62}},"210":{"start":{"line":211,"column":0},"end":{"line":211,"column":9}},"211":{"start":{"line":212,"column":0},"end":{"line":212,"column":7}},"212":{"start":{"line":213,"column":0},"end":{"line":213,"column":5}},"213":{"start":{"line":214,"column":0},"end":{"line":214,"column":44}},"214":{"start":{"line":215,"column":0},"end":{"line":215,"column":11}},"216":{"start":{"line":217,"column":0},"end":{"line":217,"column":3}},"218":{"start":{"line":219,"column":0},"end":{"line":219,"column":50}},"219":{"start":{"line":220,"column":0},"end":{"line":220,"column":35}},"220":{"start":{"line":221,"column":0},"end":{"line":221,"column":69}},"222":{"start":{"line":223,"column":0},"end":{"line":223,"column":48}},"223":{"start":{"line":224,"column":0},"end":{"line":224,"column":53}},"225":{"start":{"line":226,"column":0},"end":{"line":226,"column":24}},"226":{"start":{"line":227,"column":0},"end":{"line":227,"column":51}},"227":{"start":{"line":228,"column":0},"end":{"line":228,"column":58}},"228":{"start":{"line":229,"column":0},"end":{"line":229,"column":46}},"229":{"start":{"line":230,"column":0},"end":{"line":230,"column":5}},"231":{"start":{"line":232,"column":0},"end":{"line":232,"column":27}},"232":{"start":{"line":233,"column":0},"end":{"line":233,"column":12}},"233":{"start":{"line":234,"column":0},"end":{"line":234,"column":24}},"234":{"start":{"line":235,"column":0},"end":{"line":235,"column":47}},"235":{"start":{"line":236,"column":0},"end":{"line":236,"column":17}},"236":{"start":{"line":237,"column":0},"end":{"line":237,"column":18}},"237":{"start":{"line":238,"column":0},"end":{"line":238,"column":7}},"238":{"start":{"line":239,"column":0},"end":{"line":239,"column":3}},"240":{"start":{"line":241,"column":0},"end":{"line":241,"column":17}},"241":{"start":{"line":242,"column":0},"end":{"line":242,"column":1}},"246":{"start":{"line":247,"column":0},"end":{"line":247,"column":60}},"247":{"start":{"line":248,"column":0},"end":{"line":248,"column":29}},"248":{"start":{"line":249,"column":0},"end":{"line":249,"column":29}},"249":{"start":{"line":250,"column":0},"end":{"line":250,"column":29}},"250":{"start":{"line":251,"column":0},"end":{"line":251,"column":51}},"251":{"start":{"line":252,"column":0},"end":{"line":252,"column":37}},"252":{"start":{"line":253,"column":0},"end":{"line":253,"column":26}},"253":{"start":{"line":254,"column":0},"end":{"line":254,"column":19}},"254":{"start":{"line":255,"column":0},"end":{"line":255,"column":18}},"255":{"start":{"line":256,"column":0},"end":{"line":256,"column":21}},"256":{"start":{"line":257,"column":0},"end":{"line":257,"column":13}},"257":{"start":{"line":258,"column":0},"end":{"line":258,"column":73}},"258":{"start":{"line":259,"column":0},"end":{"line":259,"column":86}},"259":{"start":{"line":260,"column":0},"end":{"line":260,"column":3}},"260":{"start":{"line":261,"column":0},"end":{"line":261,"column":17}},"261":{"start":{"line":262,"column":0},"end":{"line":262,"column":49}},"262":{"start":{"line":263,"column":0},"end":{"line":263,"column":63}},"263":{"start":{"line":264,"column":0},"end":{"line":264,"column":15}},"264":{"start":{"line":265,"column":0},"end":{"line":265,"column":1}},"275":{"start":{"line":276,"column":0},"end":{"line":276,"column":77}},"276":{"start":{"line":277,"column":0},"end":{"line":277,"column":30}},"277":{"start":{"line":278,"column":0},"end":{"line":278,"column":7}},"278":{"start":{"line":279,"column":0},"end":{"line":279,"column":49}},"279":{"start":{"line":280,"column":0},"end":{"line":280,"column":40}},"281":{"start":{"line":282,"column":0},"end":{"line":282,"column":15}},"282":{"start":{"line":283,"column":0},"end":{"line":283,"column":18}},"283":{"start":{"line":284,"column":0},"end":{"line":284,"column":55}},"286":{"start":{"line":287,"column":0},"end":{"line":287,"column":8}},"287":{"start":{"line":288,"column":0},"end":{"line":288,"column":37}},"288":{"start":{"line":289,"column":0},"end":{"line":289,"column":5}},"291":{"start":{"line":292,"column":0},"end":{"line":292,"column":82}},"292":{"start":{"line":293,"column":0},"end":{"line":293,"column":11}},"293":{"start":{"line":294,"column":0},"end":{"line":294,"column":37}},"294":{"start":{"line":295,"column":0},"end":{"line":295,"column":15}},"296":{"start":{"line":297,"column":0},"end":{"line":297,"column":7}},"297":{"start":{"line":298,"column":0},"end":{"line":298,"column":5}},"300":{"start":{"line":301,"column":0},"end":{"line":301,"column":40}},"301":{"start":{"line":302,"column":0},"end":{"line":302,"column":72}},"302":{"start":{"line":303,"column":0},"end":{"line":303,"column":53}},"303":{"start":{"line":304,"column":0},"end":{"line":304,"column":20}},"304":{"start":{"line":305,"column":0},"end":{"line":305,"column":59}},"305":{"start":{"line":306,"column":0},"end":{"line":306,"column":72}},"306":{"start":{"line":307,"column":0},"end":{"line":307,"column":10}},"307":{"start":{"line":308,"column":0},"end":{"line":308,"column":39}},"308":{"start":{"line":309,"column":0},"end":{"line":309,"column":7}},"309":{"start":{"line":310,"column":0},"end":{"line":310,"column":5}},"311":{"start":{"line":312,"column":0},"end":{"line":312,"column":59}},"313":{"start":{"line":314,"column":0},"end":{"line":314,"column":23}},"314":{"start":{"line":315,"column":0},"end":{"line":315,"column":43}},"315":{"start":{"line":316,"column":0},"end":{"line":316,"column":12}},"316":{"start":{"line":317,"column":0},"end":{"line":317,"column":49}},"317":{"start":{"line":318,"column":0},"end":{"line":318,"column":5}},"319":{"start":{"line":320,"column":0},"end":{"line":320,"column":35}},"320":{"start":{"line":321,"column":0},"end":{"line":321,"column":17}},"321":{"start":{"line":322,"column":0},"end":{"line":322,"column":69}},"322":{"start":{"line":323,"column":0},"end":{"line":323,"column":56}},"323":{"start":{"line":324,"column":0},"end":{"line":324,"column":35}},"324":{"start":{"line":325,"column":0},"end":{"line":325,"column":3}},"325":{"start":{"line":326,"column":0},"end":{"line":326,"column":1}},"392":{"start":{"line":393,"column":0},"end":{"line":393,"column":45}},"393":{"start":{"line":394,"column":0},"end":{"line":394,"column":97}},"399":{"start":{"line":400,"column":0},"end":{"line":400,"column":69}},"400":{"start":{"line":401,"column":0},"end":{"line":401,"column":43}},"401":{"start":{"line":402,"column":0},"end":{"line":402,"column":35}},"402":{"start":{"line":403,"column":0},"end":{"line":403,"column":35}},"403":{"start":{"line":404,"column":0},"end":{"line":404,"column":30}},"404":{"start":{"line":405,"column":0},"end":{"line":405,"column":46}},"405":{"start":{"line":406,"column":0},"end":{"line":406,"column":52}},"406":{"start":{"line":407,"column":0},"end":{"line":407,"column":31}},"407":{"start":{"line":408,"column":0},"end":{"line":408,"column":55}},"408":{"start":{"line":409,"column":0},"end":{"line":409,"column":42}},"409":{"start":{"line":410,"column":0},"end":{"line":410,"column":1}},"414":{"start":{"line":415,"column":0},"end":{"line":415,"column":57}},"415":{"start":{"line":416,"column":0},"end":{"line":416,"column":35}},"416":{"start":{"line":417,"column":0},"end":{"line":417,"column":30}},"417":{"start":{"line":418,"column":0},"end":{"line":418,"column":46}},"418":{"start":{"line":419,"column":0},"end":{"line":419,"column":46}},"419":{"start":{"line":420,"column":0},"end":{"line":420,"column":37}},"420":{"start":{"line":421,"column":0},"end":{"line":421,"column":27}},"421":{"start":{"line":422,"column":0},"end":{"line":422,"column":1}},"426":{"start":{"line":427,"column":0},"end":{"line":427,"column":63}},"427":{"start":{"line":428,"column":0},"end":{"line":428,"column":59}},"428":{"start":{"line":429,"column":0},"end":{"line":429,"column":26}},"429":{"start":{"line":430,"column":0},"end":{"line":430,"column":40}},"430":{"start":{"line":431,"column":0},"end":{"line":431,"column":51}},"431":{"start":{"line":432,"column":0},"end":{"line":432,"column":36}},"432":{"start":{"line":433,"column":0},"end":{"line":433,"column":1}},"439":{"start":{"line":440,"column":0},"end":{"line":440,"column":93}},"440":{"start":{"line":441,"column":0},"end":{"line":441,"column":21}},"442":{"start":{"line":443,"column":0},"end":{"line":443,"column":62}},"443":{"start":{"line":444,"column":0},"end":{"line":444,"column":61}},"444":{"start":{"line":445,"column":0},"end":{"line":445,"column":57}},"445":{"start":{"line":446,"column":0},"end":{"line":446,"column":3}},"446":{"start":{"line":447,"column":0},"end":{"line":447,"column":20}},"448":{"start":{"line":449,"column":0},"end":{"line":449,"column":29}},"449":{"start":{"line":450,"column":0},"end":{"line":450,"column":20}},"450":{"start":{"line":451,"column":0},"end":{"line":451,"column":35}},"451":{"start":{"line":452,"column":0},"end":{"line":452,"column":13}},"452":{"start":{"line":453,"column":0},"end":{"line":453,"column":19}},"453":{"start":{"line":454,"column":0},"end":{"line":454,"column":52}},"454":{"start":{"line":455,"column":0},"end":{"line":455,"column":19}},"455":{"start":{"line":456,"column":0},"end":{"line":456,"column":27}},"456":{"start":{"line":457,"column":0},"end":{"line":457,"column":72}},"457":{"start":{"line":458,"column":0},"end":{"line":458,"column":5}},"458":{"start":{"line":459,"column":0},"end":{"line":459,"column":3}},"459":{"start":{"line":460,"column":0},"end":{"line":460,"column":44}},"460":{"start":{"line":461,"column":0},"end":{"line":461,"column":1}},"466":{"start":{"line":467,"column":0},"end":{"line":467,"column":28}},"467":{"start":{"line":468,"column":0},"end":{"line":468,"column":29}},"468":{"start":{"line":469,"column":0},"end":{"line":469,"column":20}},"469":{"start":{"line":470,"column":0},"end":{"line":470,"column":23}},"470":{"start":{"line":471,"column":0},"end":{"line":471,"column":13}},"471":{"start":{"line":472,"column":0},"end":{"line":472,"column":32}},"472":{"start":{"line":473,"column":0},"end":{"line":473,"column":45}},"473":{"start":{"line":474,"column":0},"end":{"line":474,"column":42}},"474":{"start":{"line":475,"column":0},"end":{"line":475,"column":45}},"475":{"start":{"line":476,"column":0},"end":{"line":476,"column":25}},"476":{"start":{"line":477,"column":0},"end":{"line":477,"column":1}},"482":{"start":{"line":483,"column":0},"end":{"line":483,"column":44}},"483":{"start":{"line":484,"column":0},"end":{"line":484,"column":29}},"484":{"start":{"line":485,"column":0},"end":{"line":485,"column":42}},"485":{"start":{"line":486,"column":0},"end":{"line":486,"column":28}},"486":{"start":{"line":487,"column":0},"end":{"line":487,"column":21}},"487":{"start":{"line":488,"column":0},"end":{"line":488,"column":76}},"488":{"start":{"line":489,"column":0},"end":{"line":489,"column":77}},"490":{"start":{"line":491,"column":0},"end":{"line":491,"column":45}},"491":{"start":{"line":492,"column":0},"end":{"line":492,"column":30}},"492":{"start":{"line":493,"column":0},"end":{"line":493,"column":53}},"493":{"start":{"line":494,"column":0},"end":{"line":494,"column":39}},"494":{"start":{"line":495,"column":0},"end":{"line":495,"column":24}},"495":{"start":{"line":496,"column":0},"end":{"line":496,"column":47}},"496":{"start":{"line":497,"column":0},"end":{"line":497,"column":45}},"497":{"start":{"line":498,"column":0},"end":{"line":498,"column":24}},"498":{"start":{"line":499,"column":0},"end":{"line":499,"column":47}},"499":{"start":{"line":500,"column":0},"end":{"line":500,"column":50}},"500":{"start":{"line":501,"column":0},"end":{"line":501,"column":27}},"501":{"start":{"line":502,"column":0},"end":{"line":502,"column":50}},"502":{"start":{"line":503,"column":0},"end":{"line":503,"column":56}},"503":{"start":{"line":504,"column":0},"end":{"line":504,"column":27}},"504":{"start":{"line":505,"column":0},"end":{"line":505,"column":50}},"506":{"start":{"line":507,"column":0},"end":{"line":507,"column":65}},"507":{"start":{"line":508,"column":0},"end":{"line":508,"column":31}},"508":{"start":{"line":509,"column":0},"end":{"line":509,"column":64}},"509":{"start":{"line":510,"column":0},"end":{"line":510,"column":33}},"510":{"start":{"line":511,"column":0},"end":{"line":511,"column":58}},"511":{"start":{"line":512,"column":0},"end":{"line":512,"column":78}},"512":{"start":{"line":513,"column":0},"end":{"line":513,"column":73}},"514":{"start":{"line":515,"column":0},"end":{"line":515,"column":36}},"515":{"start":{"line":516,"column":0},"end":{"line":516,"column":20}},"516":{"start":{"line":517,"column":0},"end":{"line":517,"column":4}},"517":{"start":{"line":518,"column":0},"end":{"line":518,"column":59}},"518":{"start":{"line":519,"column":0},"end":{"line":519,"column":63}},"519":{"start":{"line":520,"column":0},"end":{"line":520,"column":30}},"520":{"start":{"line":521,"column":0},"end":{"line":521,"column":24}},"521":{"start":{"line":522,"column":0},"end":{"line":522,"column":39}},"522":{"start":{"line":523,"column":0},"end":{"line":523,"column":44}},"523":{"start":{"line":524,"column":0},"end":{"line":524,"column":20}},"524":{"start":{"line":525,"column":0},"end":{"line":525,"column":21}},"525":{"start":{"line":526,"column":0},"end":{"line":526,"column":6}},"526":{"start":{"line":527,"column":0},"end":{"line":527,"column":3}},"528":{"start":{"line":529,"column":0},"end":{"line":529,"column":29}},"530":{"start":{"line":531,"column":0},"end":{"line":531,"column":20}},"531":{"start":{"line":532,"column":0},"end":{"line":532,"column":35}},"532":{"start":{"line":533,"column":0},"end":{"line":533,"column":19}},"533":{"start":{"line":534,"column":0},"end":{"line":534,"column":44}},"534":{"start":{"line":535,"column":0},"end":{"line":535,"column":53}},"535":{"start":{"line":536,"column":0},"end":{"line":536,"column":19}},"536":{"start":{"line":537,"column":0},"end":{"line":537,"column":31}},"537":{"start":{"line":538,"column":0},"end":{"line":538,"column":80}},"538":{"start":{"line":539,"column":0},"end":{"line":539,"column":70}},"539":{"start":{"line":540,"column":0},"end":{"line":540,"column":83}},"540":{"start":{"line":541,"column":0},"end":{"line":541,"column":48}},"541":{"start":{"line":542,"column":0},"end":{"line":542,"column":20}},"542":{"start":{"line":543,"column":0},"end":{"line":543,"column":72}},"544":{"start":{"line":545,"column":0},"end":{"line":545,"column":31}},"545":{"start":{"line":546,"column":0},"end":{"line":546,"column":15}},"546":{"start":{"line":547,"column":0},"end":{"line":547,"column":95}},"547":{"start":{"line":548,"column":0},"end":{"line":548,"column":6}},"548":{"start":{"line":549,"column":0},"end":{"line":549,"column":3}},"549":{"start":{"line":550,"column":0},"end":{"line":550,"column":30}},"550":{"start":{"line":551,"column":0},"end":{"line":551,"column":43}},"551":{"start":{"line":552,"column":0},"end":{"line":552,"column":3}},"552":{"start":{"line":553,"column":0},"end":{"line":553,"column":17}},"554":{"start":{"line":555,"column":0},"end":{"line":555,"column":66}},"555":{"start":{"line":556,"column":0},"end":{"line":556,"column":64}},"556":{"start":{"line":557,"column":0},"end":{"line":557,"column":17}},"558":{"start":{"line":559,"column":0},"end":{"line":559,"column":27}},"559":{"start":{"line":560,"column":0},"end":{"line":560,"column":37}},"560":{"start":{"line":561,"column":0},"end":{"line":561,"column":30}},"561":{"start":{"line":562,"column":0},"end":{"line":562,"column":17}},"562":{"start":{"line":563,"column":0},"end":{"line":563,"column":98}},"563":{"start":{"line":564,"column":0},"end":{"line":564,"column":8}},"564":{"start":{"line":565,"column":0},"end":{"line":565,"column":17}},"565":{"start":{"line":566,"column":0},"end":{"line":566,"column":119}},"566":{"start":{"line":567,"column":0},"end":{"line":567,"column":8}},"567":{"start":{"line":568,"column":0},"end":{"line":568,"column":5}},"568":{"start":{"line":569,"column":0},"end":{"line":569,"column":27}},"569":{"start":{"line":570,"column":0},"end":{"line":570,"column":17}},"570":{"start":{"line":571,"column":0},"end":{"line":571,"column":103}},"571":{"start":{"line":572,"column":0},"end":{"line":572,"column":8}},"572":{"start":{"line":573,"column":0},"end":{"line":573,"column":17}},"573":{"start":{"line":574,"column":0},"end":{"line":574,"column":80}},"574":{"start":{"line":575,"column":0},"end":{"line":575,"column":8}},"575":{"start":{"line":576,"column":0},"end":{"line":576,"column":5}},"576":{"start":{"line":577,"column":0},"end":{"line":577,"column":30}},"577":{"start":{"line":578,"column":0},"end":{"line":578,"column":17}},"578":{"start":{"line":579,"column":0},"end":{"line":579,"column":99}},"579":{"start":{"line":580,"column":0},"end":{"line":580,"column":8}},"580":{"start":{"line":581,"column":0},"end":{"line":581,"column":17}},"581":{"start":{"line":582,"column":0},"end":{"line":582,"column":92}},"582":{"start":{"line":583,"column":0},"end":{"line":583,"column":8}},"583":{"start":{"line":584,"column":0},"end":{"line":584,"column":17}},"584":{"start":{"line":585,"column":0},"end":{"line":585,"column":102}},"585":{"start":{"line":586,"column":0},"end":{"line":586,"column":8}},"586":{"start":{"line":587,"column":0},"end":{"line":587,"column":5}},"587":{"start":{"line":588,"column":0},"end":{"line":588,"column":35}},"588":{"start":{"line":589,"column":0},"end":{"line":589,"column":17}},"589":{"start":{"line":590,"column":0},"end":{"line":590,"column":102}},"590":{"start":{"line":591,"column":0},"end":{"line":591,"column":8}},"591":{"start":{"line":592,"column":0},"end":{"line":592,"column":17}},"592":{"start":{"line":593,"column":0},"end":{"line":593,"column":93}},"593":{"start":{"line":594,"column":0},"end":{"line":594,"column":8}},"594":{"start":{"line":595,"column":0},"end":{"line":595,"column":5}},"595":{"start":{"line":596,"column":0},"end":{"line":596,"column":38}},"596":{"start":{"line":597,"column":0},"end":{"line":597,"column":17}},"597":{"start":{"line":598,"column":0},"end":{"line":598,"column":98}},"598":{"start":{"line":599,"column":0},"end":{"line":599,"column":8}},"599":{"start":{"line":600,"column":0},"end":{"line":600,"column":17}},"600":{"start":{"line":601,"column":0},"end":{"line":601,"column":90}},"601":{"start":{"line":602,"column":0},"end":{"line":602,"column":8}},"602":{"start":{"line":603,"column":0},"end":{"line":603,"column":5}},"603":{"start":{"line":604,"column":0},"end":{"line":604,"column":23}},"604":{"start":{"line":605,"column":0},"end":{"line":605,"column":10}},"605":{"start":{"line":606,"column":0},"end":{"line":606,"column":37}},"606":{"start":{"line":607,"column":0},"end":{"line":607,"column":76}},"607":{"start":{"line":608,"column":0},"end":{"line":608,"column":84}},"608":{"start":{"line":609,"column":0},"end":{"line":609,"column":74}},"609":{"start":{"line":610,"column":0},"end":{"line":610,"column":23}},"610":{"start":{"line":611,"column":0},"end":{"line":611,"column":3}},"611":{"start":{"line":612,"column":0},"end":{"line":612,"column":17}},"617":{"start":{"line":618,"column":0},"end":{"line":618,"column":28}},"618":{"start":{"line":619,"column":0},"end":{"line":619,"column":68}},"619":{"start":{"line":620,"column":0},"end":{"line":620,"column":76}},"620":{"start":{"line":621,"column":0},"end":{"line":621,"column":106}},"621":{"start":{"line":622,"column":0},"end":{"line":622,"column":98}},"622":{"start":{"line":623,"column":0},"end":{"line":623,"column":50}},"623":{"start":{"line":624,"column":0},"end":{"line":624,"column":76}},"624":{"start":{"line":625,"column":0},"end":{"line":625,"column":119}},"625":{"start":{"line":626,"column":0},"end":{"line":626,"column":77}},"626":{"start":{"line":627,"column":0},"end":{"line":627,"column":26}},"627":{"start":{"line":628,"column":0},"end":{"line":628,"column":25}},"628":{"start":{"line":629,"column":0},"end":{"line":629,"column":23}},"629":{"start":{"line":630,"column":0},"end":{"line":630,"column":19}},"630":{"start":{"line":631,"column":0},"end":{"line":631,"column":123}},"631":{"start":{"line":632,"column":0},"end":{"line":632,"column":104}},"632":{"start":{"line":633,"column":0},"end":{"line":633,"column":52}},"633":{"start":{"line":634,"column":0},"end":{"line":634,"column":81}},"634":{"start":{"line":635,"column":0},"end":{"line":635,"column":121}},"635":{"start":{"line":636,"column":0},"end":{"line":636,"column":82}},"636":{"start":{"line":637,"column":0},"end":{"line":637,"column":26}},"637":{"start":{"line":638,"column":0},"end":{"line":638,"column":25}},"638":{"start":{"line":639,"column":0},"end":{"line":639,"column":23}},"639":{"start":{"line":640,"column":0},"end":{"line":640,"column":19}},"640":{"start":{"line":641,"column":0},"end":{"line":641,"column":3}},"642":{"start":{"line":643,"column":0},"end":{"line":643,"column":28}},"643":{"start":{"line":644,"column":0},"end":{"line":644,"column":68}},"644":{"start":{"line":645,"column":0},"end":{"line":645,"column":70}},"645":{"start":{"line":646,"column":0},"end":{"line":646,"column":102}},"646":{"start":{"line":647,"column":0},"end":{"line":647,"column":105}},"647":{"start":{"line":648,"column":0},"end":{"line":648,"column":104}},"648":{"start":{"line":649,"column":0},"end":{"line":649,"column":52}},"649":{"start":{"line":650,"column":0},"end":{"line":650,"column":107}},"650":{"start":{"line":651,"column":0},"end":{"line":651,"column":25}},"651":{"start":{"line":652,"column":0},"end":{"line":652,"column":23}},"652":{"start":{"line":653,"column":0},"end":{"line":653,"column":19}},"653":{"start":{"line":654,"column":0},"end":{"line":654,"column":3}},"655":{"start":{"line":656,"column":0},"end":{"line":656,"column":18}},"656":{"start":{"line":657,"column":0},"end":{"line":657,"column":17}},"658":{"start":{"line":659,"column":0},"end":{"line":659,"column":49}},"659":{"start":{"line":660,"column":0},"end":{"line":660,"column":1}},"667":{"start":{"line":668,"column":0},"end":{"line":668,"column":89}},"668":{"start":{"line":669,"column":0},"end":{"line":669,"column":30}},"671":{"start":{"line":672,"column":0},"end":{"line":672,"column":60}},"672":{"start":{"line":673,"column":0},"end":{"line":673,"column":16}},"673":{"start":{"line":674,"column":0},"end":{"line":674,"column":101}},"674":{"start":{"line":675,"column":0},"end":{"line":675,"column":6}},"675":{"start":{"line":676,"column":0},"end":{"line":676,"column":35}},"676":{"start":{"line":677,"column":0},"end":{"line":677,"column":3}},"679":{"start":{"line":680,"column":0},"end":{"line":680,"column":49}},"680":{"start":{"line":681,"column":0},"end":{"line":681,"column":38}},"681":{"start":{"line":682,"column":0},"end":{"line":682,"column":18}},"682":{"start":{"line":683,"column":0},"end":{"line":683,"column":71}},"683":{"start":{"line":684,"column":0},"end":{"line":684,"column":80}},"684":{"start":{"line":685,"column":0},"end":{"line":685,"column":8}},"685":{"start":{"line":686,"column":0},"end":{"line":686,"column":37}},"686":{"start":{"line":687,"column":0},"end":{"line":687,"column":5}},"687":{"start":{"line":688,"column":0},"end":{"line":688,"column":3}},"688":{"start":{"line":689,"column":0},"end":{"line":689,"column":46}},"689":{"start":{"line":690,"column":0},"end":{"line":690,"column":32}},"690":{"start":{"line":691,"column":0},"end":{"line":691,"column":79}},"691":{"start":{"line":692,"column":0},"end":{"line":692,"column":37}},"692":{"start":{"line":693,"column":0},"end":{"line":693,"column":5}},"693":{"start":{"line":694,"column":0},"end":{"line":694,"column":3}},"694":{"start":{"line":695,"column":0},"end":{"line":695,"column":49}},"695":{"start":{"line":696,"column":0},"end":{"line":696,"column":32}},"696":{"start":{"line":697,"column":0},"end":{"line":697,"column":82}},"697":{"start":{"line":698,"column":0},"end":{"line":698,"column":37}},"698":{"start":{"line":699,"column":0},"end":{"line":699,"column":5}},"699":{"start":{"line":700,"column":0},"end":{"line":700,"column":3}},"700":{"start":{"line":701,"column":0},"end":{"line":701,"column":49}},"701":{"start":{"line":702,"column":0},"end":{"line":702,"column":35}},"702":{"start":{"line":703,"column":0},"end":{"line":703,"column":18}},"703":{"start":{"line":704,"column":0},"end":{"line":704,"column":92}},"704":{"start":{"line":705,"column":0},"end":{"line":705,"column":8}},"705":{"start":{"line":706,"column":0},"end":{"line":706,"column":37}},"706":{"start":{"line":707,"column":0},"end":{"line":707,"column":5}},"707":{"start":{"line":708,"column":0},"end":{"line":708,"column":3}},"708":{"start":{"line":709,"column":0},"end":{"line":709,"column":52}},"709":{"start":{"line":710,"column":0},"end":{"line":710,"column":35}},"710":{"start":{"line":711,"column":0},"end":{"line":711,"column":18}},"711":{"start":{"line":712,"column":0},"end":{"line":712,"column":101}},"712":{"start":{"line":713,"column":0},"end":{"line":713,"column":8}},"713":{"start":{"line":714,"column":0},"end":{"line":714,"column":37}},"714":{"start":{"line":715,"column":0},"end":{"line":715,"column":5}},"715":{"start":{"line":716,"column":0},"end":{"line":716,"column":3}},"717":{"start":{"line":718,"column":0},"end":{"line":718,"column":69}},"718":{"start":{"line":719,"column":0},"end":{"line":719,"column":46}},"719":{"start":{"line":720,"column":0},"end":{"line":720,"column":45}},"720":{"start":{"line":721,"column":0},"end":{"line":721,"column":53}},"721":{"start":{"line":722,"column":0},"end":{"line":722,"column":50}},"723":{"start":{"line":724,"column":0},"end":{"line":724,"column":23}},"724":{"start":{"line":725,"column":0},"end":{"line":725,"column":68}},"725":{"start":{"line":726,"column":0},"end":{"line":726,"column":46}},"726":{"start":{"line":727,"column":0},"end":{"line":727,"column":20}},"727":{"start":{"line":728,"column":0},"end":{"line":728,"column":24}},"728":{"start":{"line":729,"column":0},"end":{"line":729,"column":35}},"729":{"start":{"line":730,"column":0},"end":{"line":730,"column":3}},"732":{"start":{"line":733,"column":0},"end":{"line":733,"column":46}},"733":{"start":{"line":734,"column":0},"end":{"line":734,"column":16}},"734":{"start":{"line":735,"column":0},"end":{"line":735,"column":76}},"735":{"start":{"line":736,"column":0},"end":{"line":736,"column":6}},"736":{"start":{"line":737,"column":0},"end":{"line":737,"column":35}},"737":{"start":{"line":738,"column":0},"end":{"line":738,"column":3}},"740":{"start":{"line":741,"column":0},"end":{"line":741,"column":7}},"741":{"start":{"line":742,"column":0},"end":{"line":742,"column":30}},"742":{"start":{"line":743,"column":0},"end":{"line":743,"column":45}},"743":{"start":{"line":744,"column":0},"end":{"line":744,"column":47}},"744":{"start":{"line":745,"column":0},"end":{"line":745,"column":5}},"745":{"start":{"line":746,"column":0},"end":{"line":746,"column":17}},"746":{"start":{"line":747,"column":0},"end":{"line":747,"column":69}},"747":{"start":{"line":748,"column":0},"end":{"line":748,"column":63}},"748":{"start":{"line":749,"column":0},"end":{"line":749,"column":35}},"749":{"start":{"line":750,"column":0},"end":{"line":750,"column":3}},"752":{"start":{"line":753,"column":0},"end":{"line":753,"column":7}},"753":{"start":{"line":754,"column":0},"end":{"line":754,"column":44}},"754":{"start":{"line":755,"column":0},"end":{"line":755,"column":17}},"755":{"start":{"line":756,"column":0},"end":{"line":756,"column":69}},"756":{"start":{"line":757,"column":0},"end":{"line":757,"column":61}},"757":{"start":{"line":758,"column":0},"end":{"line":758,"column":35}},"758":{"start":{"line":759,"column":0},"end":{"line":759,"column":3}},"760":{"start":{"line":761,"column":0},"end":{"line":761,"column":54}},"761":{"start":{"line":762,"column":0},"end":{"line":762,"column":18}},"762":{"start":{"line":763,"column":0},"end":{"line":763,"column":29}},"763":{"start":{"line":764,"column":0},"end":{"line":764,"column":72}},"764":{"start":{"line":765,"column":0},"end":{"line":765,"column":66}},"765":{"start":{"line":766,"column":0},"end":{"line":766,"column":86}},"766":{"start":{"line":767,"column":0},"end":{"line":767,"column":80}},"767":{"start":{"line":768,"column":0},"end":{"line":768,"column":43}},"768":{"start":{"line":769,"column":0},"end":{"line":769,"column":95}},"769":{"start":{"line":770,"column":0},"end":{"line":770,"column":55}},"770":{"start":{"line":771,"column":0},"end":{"line":771,"column":26}},"771":{"start":{"line":772,"column":0},"end":{"line":772,"column":18}},"772":{"start":{"line":773,"column":0},"end":{"line":773,"column":69}},"774":{"start":{"line":775,"column":0},"end":{"line":775,"column":33}},"775":{"start":{"line":776,"column":0},"end":{"line":776,"column":1}},"784":{"start":{"line":785,"column":0},"end":{"line":785,"column":83}},"785":{"start":{"line":786,"column":0},"end":{"line":786,"column":59}},"789":{"start":{"line":790,"column":0},"end":{"line":790,"column":59}},"790":{"start":{"line":791,"column":0},"end":{"line":791,"column":37}},"791":{"start":{"line":792,"column":0},"end":{"line":792,"column":22}},"792":{"start":{"line":793,"column":0},"end":{"line":793,"column":3}},"793":{"start":{"line":794,"column":0},"end":{"line":794,"column":30}},"794":{"start":{"line":795,"column":0},"end":{"line":795,"column":75}},"795":{"start":{"line":796,"column":0},"end":{"line":796,"column":3}},"796":{"start":{"line":797,"column":0},"end":{"line":797,"column":1}},"806":{"start":{"line":807,"column":0},"end":{"line":807,"column":42}},"807":{"start":{"line":808,"column":0},"end":{"line":808,"column":28}},"809":{"start":{"line":810,"column":0},"end":{"line":810,"column":5}},"810":{"start":{"line":811,"column":0},"end":{"line":811,"column":20}},"811":{"start":{"line":812,"column":0},"end":{"line":812,"column":79}},"812":{"start":{"line":813,"column":0},"end":{"line":813,"column":73}},"814":{"start":{"line":815,"column":0},"end":{"line":815,"column":5}},"815":{"start":{"line":816,"column":0},"end":{"line":816,"column":23}},"816":{"start":{"line":817,"column":0},"end":{"line":817,"column":79}},"817":{"start":{"line":818,"column":0},"end":{"line":818,"column":85}},"818":{"start":{"line":819,"column":0},"end":{"line":819,"column":48}},"819":{"start":{"line":820,"column":0},"end":{"line":820,"column":47}},"820":{"start":{"line":821,"column":0},"end":{"line":821,"column":55}},"821":{"start":{"line":822,"column":0},"end":{"line":822,"column":7}},"823":{"start":{"line":824,"column":0},"end":{"line":824,"column":5}},"824":{"start":{"line":825,"column":0},"end":{"line":825,"column":20}},"825":{"start":{"line":826,"column":0},"end":{"line":826,"column":77}},"826":{"start":{"line":827,"column":0},"end":{"line":827,"column":85}},"827":{"start":{"line":828,"column":0},"end":{"line":828,"column":12}},"828":{"start":{"line":829,"column":0},"end":{"line":829,"column":30}},"829":{"start":{"line":830,"column":0},"end":{"line":830,"column":81}},"831":{"start":{"line":832,"column":0},"end":{"line":832,"column":5}},"832":{"start":{"line":833,"column":0},"end":{"line":833,"column":70}},"833":{"start":{"line":834,"column":0},"end":{"line":834,"column":86}},"834":{"start":{"line":835,"column":0},"end":{"line":835,"column":36}},"835":{"start":{"line":836,"column":0},"end":{"line":836,"column":25}},"836":{"start":{"line":837,"column":0},"end":{"line":837,"column":36}},"837":{"start":{"line":838,"column":0},"end":{"line":838,"column":27}},"838":{"start":{"line":839,"column":0},"end":{"line":839,"column":9}},"839":{"start":{"line":840,"column":0},"end":{"line":840,"column":52}},"840":{"start":{"line":841,"column":0},"end":{"line":841,"column":7}},"845":{"start":{"line":846,"column":0},"end":{"line":846,"column":20}},"846":{"start":{"line":847,"column":0},"end":{"line":847,"column":19}},"847":{"start":{"line":848,"column":0},"end":{"line":848,"column":51}},"849":{"start":{"line":850,"column":0},"end":{"line":850,"column":8}},"850":{"start":{"line":851,"column":0},"end":{"line":851,"column":23}},"851":{"start":{"line":852,"column":0},"end":{"line":852,"column":17}},"852":{"start":{"line":853,"column":0},"end":{"line":853,"column":71}},"855":{"start":{"line":856,"column":0},"end":{"line":856,"column":5}},"856":{"start":{"line":857,"column":0},"end":{"line":857,"column":74}},"857":{"start":{"line":858,"column":0},"end":{"line":858,"column":81}},"858":{"start":{"line":859,"column":0},"end":{"line":859,"column":88}},"859":{"start":{"line":860,"column":0},"end":{"line":860,"column":74}},"860":{"start":{"line":861,"column":0},"end":{"line":861,"column":12}},"861":{"start":{"line":862,"column":0},"end":{"line":862,"column":30}},"862":{"start":{"line":863,"column":0},"end":{"line":863,"column":66}},"865":{"start":{"line":866,"column":0},"end":{"line":866,"column":90}},"866":{"start":{"line":867,"column":0},"end":{"line":867,"column":5}},"867":{"start":{"line":868,"column":0},"end":{"line":868,"column":12}},"868":{"start":{"line":869,"column":0},"end":{"line":869,"column":27}},"869":{"start":{"line":870,"column":0},"end":{"line":870,"column":58}},"871":{"start":{"line":872,"column":0},"end":{"line":872,"column":90}},"872":{"start":{"line":873,"column":0},"end":{"line":873,"column":5}},"873":{"start":{"line":874,"column":0},"end":{"line":874,"column":12}},"874":{"start":{"line":875,"column":0},"end":{"line":875,"column":30}},"875":{"start":{"line":876,"column":0},"end":{"line":876,"column":57}},"877":{"start":{"line":878,"column":0},"end":{"line":878,"column":90}},"878":{"start":{"line":879,"column":0},"end":{"line":879,"column":5}},"879":{"start":{"line":880,"column":0},"end":{"line":880,"column":12}},"880":{"start":{"line":881,"column":0},"end":{"line":881,"column":30}},"881":{"start":{"line":882,"column":0},"end":{"line":882,"column":82}},"883":{"start":{"line":884,"column":0},"end":{"line":884,"column":90}},"884":{"start":{"line":885,"column":0},"end":{"line":885,"column":5}},"885":{"start":{"line":886,"column":0},"end":{"line":886,"column":12}},"886":{"start":{"line":887,"column":0},"end":{"line":887,"column":33}},"887":{"start":{"line":888,"column":0},"end":{"line":888,"column":87}},"889":{"start":{"line":890,"column":0},"end":{"line":890,"column":90}},"890":{"start":{"line":891,"column":0},"end":{"line":891,"column":5}},"891":{"start":{"line":892,"column":0},"end":{"line":892,"column":84}},"892":{"start":{"line":893,"column":0},"end":{"line":893,"column":71}},"893":{"start":{"line":894,"column":0},"end":{"line":894,"column":51}},"894":{"start":{"line":895,"column":0},"end":{"line":895,"column":50}},"895":{"start":{"line":896,"column":0},"end":{"line":896,"column":58}},"896":{"start":{"line":897,"column":0},"end":{"line":897,"column":7}},"898":{"start":{"line":899,"column":0},"end":{"line":899,"column":13}},"899":{"start":{"line":900,"column":0},"end":{"line":900,"column":1}},"905":{"start":{"line":906,"column":0},"end":{"line":906,"column":48}},"918":{"start":{"line":919,"column":0},"end":{"line":919,"column":41}},"919":{"start":{"line":920,"column":0},"end":{"line":920,"column":7}},"920":{"start":{"line":921,"column":0},"end":{"line":921,"column":75}},"921":{"start":{"line":922,"column":0},"end":{"line":922,"column":59}},"922":{"start":{"line":923,"column":0},"end":{"line":923,"column":11}},"923":{"start":{"line":924,"column":0},"end":{"line":924,"column":17}},"924":{"start":{"line":925,"column":0},"end":{"line":925,"column":3}},"925":{"start":{"line":926,"column":0},"end":{"line":926,"column":1}},"931":{"start":{"line":932,"column":0},"end":{"line":932,"column":21}},"932":{"start":{"line":933,"column":0},"end":{"line":933,"column":51}},"933":{"start":{"line":934,"column":0},"end":{"line":934,"column":63}},"934":{"start":{"line":935,"column":0},"end":{"line":935,"column":20}},"935":{"start":{"line":936,"column":0},"end":{"line":936,"column":5}},"936":{"start":{"line":937,"column":0},"end":{"line":937,"column":1}}},"s":{"22":1,"23":1,"24":1,"25":1,"31":1,"50":31,"51":31,"52":31,"53":31,"54":31,"64":17,"65":17,"66":17,"67":17,"68":1,"69":1,"70":14,"71":14,"88":13,"89":13,"90":13,"91":13,"92":13,"94":13,"96":13,"97":13,"98":2,"99":2,"101":2,"102":2,"103":2,"105":13,"106":1,"107":1,"109":1,"110":1,"111":1,"114":13,"115":7,"116":7,"117":7,"118":10,"119":10,"120":7,"125":13,"126":13,"127":2,"128":2,"129":13,"130":1,"131":1,"134":7,"135":13,"136":6,"137":6,"138":9,"139":9,"140":9,"141":13,"142":1,"143":1,"144":1,"146":1,"147":1,"149":7,"150":7,"151":13,"152":3,"153":3,"154":3,"155":3,"156":13,"188":11,"189":11,"190":11,"192":11,"193":11,"194":11,"195":11,"196":11,"197":11,"201":11,"202":11,"203":11,"204":11,"205":20,"206":10,"207":10,"208":10,"209":10,"210":10,"211":10,"212":20,"213":11,"214":11,"216":0,"218":11,"219":20,"220":20,"222":20,"223":20,"225":20,"226":20,"227":20,"228":16,"229":16,"231":17,"232":17,"233":17,"234":17,"235":17,"236":17,"237":17,"238":17,"240":11,"241":11,"246":5,"247":5,"248":5,"249":5,"250":5,"251":5,"252":9,"253":4,"254":5,"255":4,"256":1,"257":9,"258":9,"259":9,"260":5,"261":5,"262":5,"263":5,"264":5,"275":18,"276":18,"277":18,"278":18,"279":17,"281":18,"282":3,"283":3,"286":3,"287":3,"288":3,"291":18,"292":1,"293":1,"294":1,"296":0,"297":1,"300":18,"301":6,"302":6,"303":3,"304":3,"305":3,"306":3,"307":3,"308":3,"309":6,"311":11,"313":18,"314":6,"315":18,"316":5,"317":5,"319":11,"320":18,"321":1,"322":1,"323":1,"324":1,"325":18,"392":1,"393":1,"399":5,"400":5,"401":5,"402":4,"403":5,"404":4,"405":4,"406":4,"407":5,"408":4,"409":4,"414":7,"415":7,"416":7,"417":5,"418":5,"419":7,"420":5,"421":5,"426":5,"427":5,"428":5,"429":4,"430":5,"431":3,"432":3,"439":20,"440":20,"442":2,"443":2,"444":2,"445":2,"446":20,"448":3,"449":3,"450":3,"451":3,"452":3,"453":3,"454":3,"455":3,"456":3,"457":3,"458":3,"459":15,"460":15,"466":20,"467":20,"468":20,"469":20,"470":20,"471":20,"472":20,"473":20,"474":20,"475":20,"476":20,"482":1,"483":20,"484":20,"485":20,"486":20,"487":20,"488":20,"490":20,"491":20,"492":20,"493":20,"494":20,"495":20,"496":20,"497":20,"498":20,"499":20,"500":20,"501":20,"502":20,"503":20,"504":20,"506":20,"507":20,"508":20,"509":20,"510":20,"511":20,"512":20,"514":20,"515":20,"516":20,"517":20,"518":20,"519":20,"520":4,"521":4,"522":4,"523":4,"524":4,"525":4,"526":4,"528":20,"530":20,"531":20,"532":20,"533":20,"534":20,"535":20,"536":20,"537":20,"538":20,"539":20,"540":20,"541":20,"542":20,"544":20,"545":6,"546":6,"547":6,"548":6,"549":20,"550":4,"551":4,"552":20,"554":20,"555":20,"556":20,"558":20,"559":9,"560":9,"561":3,"562":3,"563":3,"564":3,"565":3,"566":3,"567":3,"568":9,"569":3,"570":3,"571":3,"572":3,"573":3,"574":3,"575":3,"576":9,"577":2,"578":2,"579":2,"580":2,"581":2,"582":2,"583":2,"584":2,"585":2,"586":2,"587":9,"588":2,"589":2,"590":2,"591":2,"592":2,"593":2,"594":2,"595":9,"596":1,"597":1,"598":1,"599":1,"600":1,"601":1,"602":1,"603":9,"604":20,"605":11,"606":11,"607":11,"608":11,"609":11,"610":11,"611":20,"617":20,"618":3,"619":3,"620":3,"621":3,"622":3,"623":3,"624":3,"625":3,"626":3,"627":3,"628":3,"629":3,"630":3,"631":3,"632":3,"633":3,"634":3,"635":3,"636":3,"637":3,"638":3,"639":3,"640":3,"642":20,"643":2,"644":2,"645":2,"646":2,"647":2,"648":2,"649":2,"650":2,"651":2,"652":2,"653":2,"655":20,"656":20,"658":20,"659":20,"667":14,"668":14,"671":14,"672":2,"673":2,"674":2,"675":2,"676":2,"679":14,"680":2,"681":1,"682":1,"683":1,"684":1,"685":1,"686":1,"687":2,"688":14,"689":1,"690":1,"691":1,"692":1,"693":1,"694":14,"695":1,"696":1,"697":1,"698":1,"699":1,"700":14,"701":1,"702":1,"703":1,"704":1,"705":1,"706":1,"707":1,"708":14,"709":1,"710":1,"711":1,"712":1,"713":1,"714":1,"715":1,"717":7,"718":14,"719":14,"720":14,"721":14,"723":14,"724":1,"725":1,"726":1,"727":1,"728":1,"729":1,"732":14,"733":1,"734":1,"735":1,"736":1,"737":1,"740":5,"741":14,"742":1,"743":1,"744":1,"745":14,"746":0,"747":0,"748":0,"749":0,"752":5,"753":5,"754":14,"755":0,"756":0,"757":0,"758":0,"760":5,"761":5,"762":5,"763":5,"764":5,"765":5,"766":5,"767":5,"768":5,"769":5,"770":5,"771":5,"772":5,"774":5,"775":5,"784":5,"785":5,"789":5,"790":5,"791":13,"792":13,"793":5,"794":2,"795":2,"796":5,"806":1,"807":15,"809":15,"810":15,"811":15,"812":15,"814":15,"815":15,"816":15,"817":15,"818":15,"819":2,"820":2,"821":15,"823":15,"824":15,"825":15,"826":15,"827":15,"828":15,"829":15,"831":15,"832":15,"833":15,"834":3,"835":3,"836":3,"837":3,"838":3,"839":3,"840":15,"845":15,"846":15,"847":15,"849":15,"850":15,"851":15,"852":15,"855":15,"856":15,"857":15,"858":15,"859":15,"860":15,"861":15,"862":15,"865":15,"866":15,"867":15,"868":15,"869":15,"871":15,"872":15,"873":15,"874":15,"875":15,"877":15,"878":15,"879":15,"880":15,"881":15,"883":15,"884":15,"885":15,"886":15,"887":15,"889":15,"890":15,"891":15,"892":15,"893":15,"894":0,"895":0,"896":15,"898":15,"899":15,"905":1,"918":1,"919":2,"920":2,"921":2,"922":2,"923":0,"924":0,"925":2,"931":1,"932":0,"933":0,"934":0,"935":0,"936":0},"branchMap":{"0":{"type":"branch","line":932,"loc":{"start":{"line":932,"column":20},"end":{"line":937,"column":1}},"locations":[{"start":{"line":932,"column":20},"end":{"line":937,"column":1}}]},"1":{"type":"branch","line":51,"loc":{"start":{"line":51,"column":0},"end":{"line":55,"column":1}},"locations":[{"start":{"line":51,"column":0},"end":{"line":55,"column":1}}]},"2":{"type":"branch","line":65,"loc":{"start":{"line":65,"column":0},"end":{"line":72,"column":1}},"locations":[{"start":{"line":65,"column":0},"end":{"line":72,"column":1}}]},"3":{"type":"branch","line":66,"loc":{"start":{"line":66,"column":19},"end":{"line":66,"column":39}},"locations":[{"start":{"line":66,"column":19},"end":{"line":66,"column":39}}]},"4":{"type":"branch","line":67,"loc":{"start":{"line":67,"column":12},"end":{"line":67,"column":24}},"locations":[{"start":{"line":67,"column":12},"end":{"line":67,"column":24}}]},"5":{"type":"branch","line":67,"loc":{"start":{"line":67,"column":19},"end":{"line":68,"column":68}},"locations":[{"start":{"line":67,"column":19},"end":{"line":68,"column":68}}]},"6":{"type":"branch","line":68,"loc":{"start":{"line":68,"column":68},"end":{"line":70,"column":3}},"locations":[{"start":{"line":68,"column":68},"end":{"line":70,"column":3}}]},"7":{"type":"branch","line":70,"loc":{"start":{"line":70,"column":2},"end":{"line":72,"column":1}},"locations":[{"start":{"line":70,"column":2},"end":{"line":72,"column":1}}]},"8":{"type":"branch","line":89,"loc":{"start":{"line":89,"column":0},"end":{"line":157,"column":1}},"locations":[{"start":{"line":89,"column":0},"end":{"line":157,"column":1}}]},"9":{"type":"branch","line":95,"loc":{"start":{"line":95,"column":47},"end":{"line":97,"column":32}},"locations":[{"start":{"line":95,"column":47},"end":{"line":97,"column":32}}]},"10":{"type":"branch","line":97,"loc":{"start":{"line":97,"column":21},"end":{"line":97,"column":41}},"locations":[{"start":{"line":97,"column":21},"end":{"line":97,"column":41}}]},"11":{"type":"branch","line":98,"loc":{"start":{"line":98,"column":14},"end":{"line":104,"column":5}},"locations":[{"start":{"line":98,"column":14},"end":{"line":104,"column":5}}]},"12":{"type":"branch","line":104,"loc":{"start":{"line":104,"column":4},"end":{"line":106,"column":70}},"locations":[{"start":{"line":104,"column":4},"end":{"line":106,"column":70}}]},"13":{"type":"branch","line":106,"loc":{"start":{"line":106,"column":70},"end":{"line":112,"column":5}},"locations":[{"start":{"line":106,"column":70},"end":{"line":112,"column":5}}]},"14":{"type":"branch","line":112,"loc":{"start":{"line":112,"column":4},"end":{"line":115,"column":47}},"locations":[{"start":{"line":112,"column":4},"end":{"line":115,"column":47}}]},"15":{"type":"branch","line":115,"loc":{"start":{"line":115,"column":60},"end":{"line":126,"column":35}},"locations":[{"start":{"line":115,"column":60},"end":{"line":126,"column":35}}]},"16":{"type":"branch","line":118,"loc":{"start":{"line":118,"column":37},"end":{"line":120,"column":7}},"locations":[{"start":{"line":118,"column":37},"end":{"line":120,"column":7}}]},"17":{"type":"branch","line":126,"loc":{"start":{"line":126,"column":24},"end":{"line":126,"column":56}},"locations":[{"start":{"line":126,"column":24},"end":{"line":126,"column":56}}]},"18":{"type":"branch","line":127,"loc":{"start":{"line":127,"column":17},"end":{"line":130,"column":15}},"locations":[{"start":{"line":127,"column":17},"end":{"line":130,"column":15}}]},"19":{"type":"branch","line":128,"loc":{"start":{"line":128,"column":45},"end":{"line":128,"column":59}},"locations":[{"start":{"line":128,"column":45},"end":{"line":128,"column":59}}]},"20":{"type":"branch","line":128,"loc":{"start":{"line":128,"column":49},"end":{"line":128,"column":69}},"locations":[{"start":{"line":128,"column":49},"end":{"line":128,"column":69}}]},"21":{"type":"branch","line":130,"loc":{"start":{"line":130,"column":4},"end":{"line":132,"column":5}},"locations":[{"start":{"line":130,"column":4},"end":{"line":132,"column":5}}]},"22":{"type":"branch","line":130,"loc":{"start":{"line":130,"column":36},"end":{"line":132,"column":5}},"locations":[{"start":{"line":130,"column":36},"end":{"line":132,"column":5}}]},"23":{"type":"branch","line":132,"loc":{"start":{"line":132,"column":4},"end":{"line":136,"column":55}},"locations":[{"start":{"line":132,"column":4},"end":{"line":136,"column":55}}]},"24":{"type":"branch","line":136,"loc":{"start":{"line":136,"column":55},"end":{"line":142,"column":11}},"locations":[{"start":{"line":136,"column":55},"end":{"line":142,"column":11}}]},"25":{"type":"branch","line":138,"loc":{"start":{"line":138,"column":56},"end":{"line":141,"column":7}},"locations":[{"start":{"line":138,"column":56},"end":{"line":141,"column":7}}]},"26":{"type":"branch","line":139,"loc":{"start":{"line":139,"column":35},"end":{"line":139,"column":52}},"locations":[{"start":{"line":139,"column":35},"end":{"line":139,"column":52}}]},"27":{"type":"branch","line":139,"loc":{"start":{"line":139,"column":42},"end":{"line":139,"column":62}},"locations":[{"start":{"line":139,"column":42},"end":{"line":139,"column":62}}]},"28":{"type":"branch","line":142,"loc":{"start":{"line":142,"column":4},"end":{"line":148,"column":5}},"locations":[{"start":{"line":142,"column":4},"end":{"line":148,"column":5}}]},"29":{"type":"branch","line":148,"loc":{"start":{"line":148,"column":4},"end":{"line":152,"column":11}},"locations":[{"start":{"line":148,"column":4},"end":{"line":152,"column":11}}]},"30":{"type":"branch","line":152,"loc":{"start":{"line":152,"column":2},"end":{"line":156,"column":3}},"locations":[{"start":{"line":152,"column":2},"end":{"line":156,"column":3}}]},"31":{"type":"branch","line":153,"loc":{"start":{"line":153,"column":35},"end":{"line":153,"column":57}},"locations":[{"start":{"line":153,"column":35},"end":{"line":153,"column":57}}]},"32":{"type":"branch","line":153,"loc":{"start":{"line":153,"column":47},"end":{"line":153,"column":69}},"locations":[{"start":{"line":153,"column":47},"end":{"line":153,"column":69}}]},"33":{"type":"branch","line":189,"loc":{"start":{"line":189,"column":0},"end":{"line":242,"column":1}},"locations":[{"start":{"line":189,"column":0},"end":{"line":242,"column":1}}]},"34":{"type":"branch","line":190,"loc":{"start":{"line":190,"column":48},"end":{"line":190,"column":68}},"locations":[{"start":{"line":190,"column":48},"end":{"line":190,"column":68}}]},"35":{"type":"branch","line":203,"loc":{"start":{"line":203,"column":35},"end":{"line":203,"column":50}},"locations":[{"start":{"line":203,"column":35},"end":{"line":203,"column":50}}]},"36":{"type":"branch","line":205,"loc":{"start":{"line":205,"column":36},"end":{"line":213,"column":5}},"locations":[{"start":{"line":205,"column":36},"end":{"line":213,"column":5}}]},"37":{"type":"branch","line":206,"loc":{"start":{"line":206,"column":35},"end":{"line":206,"column":50}},"locations":[{"start":{"line":206,"column":35},"end":{"line":206,"column":50}}]},"38":{"type":"branch","line":206,"loc":{"start":{"line":206,"column":50},"end":{"line":212,"column":7}},"locations":[{"start":{"line":206,"column":50},"end":{"line":212,"column":7}}]},"39":{"type":"branch","line":208,"loc":{"start":{"line":208,"column":35},"end":{"line":208,"column":67}},"locations":[{"start":{"line":208,"column":35},"end":{"line":208,"column":67}}]},"40":{"type":"branch","line":210,"loc":{"start":{"line":210,"column":40},"end":{"line":210,"column":62}},"locations":[{"start":{"line":210,"column":40},"end":{"line":210,"column":62}}]},"41":{"type":"branch","line":215,"loc":{"start":{"line":215,"column":2},"end":{"line":217,"column":3}},"locations":[{"start":{"line":215,"column":2},"end":{"line":217,"column":3}}]},"42":{"type":"branch","line":219,"loc":{"start":{"line":219,"column":49},"end":{"line":239,"column":3}},"locations":[{"start":{"line":219,"column":49},"end":{"line":239,"column":3}}]},"43":{"type":"branch","line":221,"loc":{"start":{"line":221,"column":24},"end":{"line":221,"column":60}},"locations":[{"start":{"line":221,"column":24},"end":{"line":221,"column":60}}]},"44":{"type":"branch","line":221,"loc":{"start":{"line":221,"column":60},"end":{"line":221,"column":69}},"locations":[{"start":{"line":221,"column":60},"end":{"line":221,"column":69}}]},"45":{"type":"branch","line":221,"loc":{"start":{"line":221,"column":60},"end":{"line":223,"column":41}},"locations":[{"start":{"line":221,"column":60},"end":{"line":223,"column":41}}]},"46":{"type":"branch","line":224,"loc":{"start":{"line":224,"column":23},"end":{"line":224,"column":38}},"locations":[{"start":{"line":224,"column":23},"end":{"line":224,"column":38}}]},"47":{"type":"branch","line":224,"loc":{"start":{"line":224,"column":31},"end":{"line":224,"column":48}},"locations":[{"start":{"line":224,"column":31},"end":{"line":224,"column":48}}]},"48":{"type":"branch","line":224,"loc":{"start":{"line":224,"column":38},"end":{"line":224,"column":53}},"locations":[{"start":{"line":224,"column":38},"end":{"line":224,"column":53}}]},"49":{"type":"branch","line":227,"loc":{"start":{"line":227,"column":27},"end":{"line":227,"column":51}},"locations":[{"start":{"line":227,"column":27},"end":{"line":227,"column":51}}]},"50":{"type":"branch","line":228,"loc":{"start":{"line":228,"column":8},"end":{"line":228,"column":57}},"locations":[{"start":{"line":228,"column":8},"end":{"line":228,"column":57}}]},"51":{"type":"branch","line":228,"loc":{"start":{"line":228,"column":57},"end":{"line":230,"column":5}},"locations":[{"start":{"line":228,"column":57},"end":{"line":230,"column":5}}]},"52":{"type":"branch","line":230,"loc":{"start":{"line":230,"column":4},"end":{"line":239,"column":3}},"locations":[{"start":{"line":230,"column":4},"end":{"line":239,"column":3}}]},"53":{"type":"branch","line":247,"loc":{"start":{"line":247,"column":0},"end":{"line":265,"column":1}},"locations":[{"start":{"line":247,"column":0},"end":{"line":265,"column":1}}]},"54":{"type":"branch","line":252,"loc":{"start":{"line":252,"column":36},"end":{"line":260,"column":3}},"locations":[{"start":{"line":252,"column":36},"end":{"line":260,"column":3}}]},"55":{"type":"branch","line":253,"loc":{"start":{"line":253,"column":18},"end":{"line":254,"column":19}},"locations":[{"start":{"line":253,"column":18},"end":{"line":254,"column":19}}]},"56":{"type":"branch","line":254,"loc":{"start":{"line":254,"column":8},"end":{"line":257,"column":13}},"locations":[{"start":{"line":254,"column":8},"end":{"line":257,"column":13}}]},"57":{"type":"branch","line":255,"loc":{"start":{"line":255,"column":10},"end":{"line":256,"column":21}},"locations":[{"start":{"line":255,"column":10},"end":{"line":256,"column":21}}]},"58":{"type":"branch","line":256,"loc":{"start":{"line":256,"column":10},"end":{"line":257,"column":13}},"locations":[{"start":{"line":256,"column":10},"end":{"line":257,"column":13}}]},"59":{"type":"branch","line":258,"loc":{"start":{"line":258,"column":36},"end":{"line":258,"column":55}},"locations":[{"start":{"line":258,"column":36},"end":{"line":258,"column":55}}]},"60":{"type":"branch","line":258,"loc":{"start":{"line":258,"column":43},"end":{"line":258,"column":73}},"locations":[{"start":{"line":258,"column":43},"end":{"line":258,"column":73}}]},"61":{"type":"branch","line":276,"loc":{"start":{"line":276,"column":0},"end":{"line":326,"column":1}},"locations":[{"start":{"line":276,"column":0},"end":{"line":326,"column":1}}]},"62":{"type":"branch","line":279,"loc":{"start":{"line":279,"column":47},"end":{"line":282,"column":14}},"locations":[{"start":{"line":279,"column":47},"end":{"line":282,"column":14}}]},"63":{"type":"branch","line":282,"loc":{"start":{"line":282,"column":14},"end":{"line":289,"column":5}},"locations":[{"start":{"line":282,"column":14},"end":{"line":289,"column":5}}]},"64":{"type":"branch","line":289,"loc":{"start":{"line":289,"column":4},"end":{"line":292,"column":31}},"locations":[{"start":{"line":289,"column":4},"end":{"line":292,"column":31}}]},"65":{"type":"branch","line":292,"loc":{"start":{"line":292,"column":12},"end":{"line":292,"column":81}},"locations":[{"start":{"line":292,"column":12},"end":{"line":292,"column":81}}]},"66":{"type":"branch","line":292,"loc":{"start":{"line":292,"column":81},"end":{"line":298,"column":5}},"locations":[{"start":{"line":292,"column":81},"end":{"line":298,"column":5}}]},"67":{"type":"branch","line":295,"loc":{"start":{"line":295,"column":6},"end":{"line":297,"column":7}},"locations":[{"start":{"line":295,"column":6},"end":{"line":297,"column":7}}]},"68":{"type":"branch","line":298,"loc":{"start":{"line":298,"column":4},"end":{"line":301,"column":39}},"locations":[{"start":{"line":298,"column":4},"end":{"line":301,"column":39}}]},"69":{"type":"branch","line":301,"loc":{"start":{"line":301,"column":39},"end":{"line":310,"column":5}},"locations":[{"start":{"line":301,"column":39},"end":{"line":310,"column":5}}]},"70":{"type":"branch","line":302,"loc":{"start":{"line":302,"column":52},"end":{"line":302,"column":72}},"locations":[{"start":{"line":302,"column":52},"end":{"line":302,"column":72}}]},"71":{"type":"branch","line":303,"loc":{"start":{"line":303,"column":52},"end":{"line":309,"column":7}},"locations":[{"start":{"line":303,"column":52},"end":{"line":309,"column":7}}]},"72":{"type":"branch","line":306,"loc":{"start":{"line":306,"column":56},"end":{"line":306,"column":69}},"locations":[{"start":{"line":306,"column":56},"end":{"line":306,"column":69}}]},"73":{"type":"branch","line":310,"loc":{"start":{"line":310,"column":4},"end":{"line":314,"column":22}},"locations":[{"start":{"line":310,"column":4},"end":{"line":314,"column":22}}]},"74":{"type":"branch","line":314,"loc":{"start":{"line":314,"column":22},"end":{"line":316,"column":11}},"locations":[{"start":{"line":314,"column":22},"end":{"line":316,"column":11}}]},"75":{"type":"branch","line":316,"loc":{"start":{"line":316,"column":4},"end":{"line":318,"column":5}},"locations":[{"start":{"line":316,"column":4},"end":{"line":318,"column":5}}]},"76":{"type":"branch","line":318,"loc":{"start":{"line":318,"column":4},"end":{"line":321,"column":11}},"locations":[{"start":{"line":318,"column":4},"end":{"line":321,"column":11}}]},"77":{"type":"branch","line":321,"loc":{"start":{"line":321,"column":2},"end":{"line":325,"column":3}},"locations":[{"start":{"line":321,"column":2},"end":{"line":325,"column":3}}]},"78":{"type":"branch","line":322,"loc":{"start":{"line":322,"column":47},"end":{"line":322,"column":69}},"locations":[{"start":{"line":322,"column":47},"end":{"line":322,"column":69}}]},"79":{"type":"branch","line":400,"loc":{"start":{"line":400,"column":0},"end":{"line":410,"column":1}},"locations":[{"start":{"line":400,"column":0},"end":{"line":410,"column":1}}]},"80":{"type":"branch","line":402,"loc":{"start":{"line":402,"column":23},"end":{"line":402,"column":35}},"locations":[{"start":{"line":402,"column":23},"end":{"line":402,"column":35}}]},"81":{"type":"branch","line":402,"loc":{"start":{"line":402,"column":30},"end":{"line":404,"column":25}},"locations":[{"start":{"line":402,"column":30},"end":{"line":404,"column":25}}]},"82":{"type":"branch","line":404,"loc":{"start":{"line":404,"column":18},"end":{"line":404,"column":30}},"locations":[{"start":{"line":404,"column":18},"end":{"line":404,"column":30}}]},"83":{"type":"branch","line":404,"loc":{"start":{"line":404,"column":25},"end":{"line":408,"column":33}},"locations":[{"start":{"line":404,"column":25},"end":{"line":408,"column":33}}]},"84":{"type":"branch","line":408,"loc":{"start":{"line":408,"column":18},"end":{"line":408,"column":43}},"locations":[{"start":{"line":408,"column":18},"end":{"line":408,"column":43}}]},"85":{"type":"branch","line":408,"loc":{"start":{"line":408,"column":43},"end":{"line":408,"column":55}},"locations":[{"start":{"line":408,"column":43},"end":{"line":408,"column":55}}]},"86":{"type":"branch","line":408,"loc":{"start":{"line":408,"column":50},"end":{"line":410,"column":1}},"locations":[{"start":{"line":408,"column":50},"end":{"line":410,"column":1}}]},"87":{"type":"branch","line":415,"loc":{"start":{"line":415,"column":0},"end":{"line":422,"column":1}},"locations":[{"start":{"line":415,"column":0},"end":{"line":422,"column":1}}]},"88":{"type":"branch","line":417,"loc":{"start":{"line":417,"column":18},"end":{"line":417,"column":30}},"locations":[{"start":{"line":417,"column":18},"end":{"line":417,"column":30}}]},"89":{"type":"branch","line":417,"loc":{"start":{"line":417,"column":25},"end":{"line":420,"column":25}},"locations":[{"start":{"line":417,"column":25},"end":{"line":420,"column":25}}]},"90":{"type":"branch","line":420,"loc":{"start":{"line":420,"column":25},"end":{"line":420,"column":37}},"locations":[{"start":{"line":420,"column":25},"end":{"line":420,"column":37}}]},"91":{"type":"branch","line":420,"loc":{"start":{"line":420,"column":32},"end":{"line":422,"column":1}},"locations":[{"start":{"line":420,"column":32},"end":{"line":422,"column":1}}]},"92":{"type":"branch","line":427,"loc":{"start":{"line":427,"column":0},"end":{"line":433,"column":1}},"locations":[{"start":{"line":427,"column":0},"end":{"line":433,"column":1}}]},"93":{"type":"branch","line":429,"loc":{"start":{"line":429,"column":14},"end":{"line":429,"column":26}},"locations":[{"start":{"line":429,"column":14},"end":{"line":429,"column":26}}]},"94":{"type":"branch","line":429,"loc":{"start":{"line":429,"column":21},"end":{"line":431,"column":46}},"locations":[{"start":{"line":429,"column":21},"end":{"line":431,"column":46}}]},"95":{"type":"branch","line":431,"loc":{"start":{"line":431,"column":39},"end":{"line":431,"column":51}},"locations":[{"start":{"line":431,"column":39},"end":{"line":431,"column":51}}]},"96":{"type":"branch","line":431,"loc":{"start":{"line":431,"column":46},"end":{"line":433,"column":1}},"locations":[{"start":{"line":431,"column":46},"end":{"line":433,"column":1}}]},"97":{"type":"branch","line":440,"loc":{"start":{"line":440,"column":0},"end":{"line":461,"column":1}},"locations":[{"start":{"line":440,"column":0},"end":{"line":461,"column":1}}]},"98":{"type":"branch","line":441,"loc":{"start":{"line":441,"column":20},"end":{"line":446,"column":3}},"locations":[{"start":{"line":441,"column":20},"end":{"line":446,"column":3}}]},"99":{"type":"branch","line":444,"loc":{"start":{"line":444,"column":46},"end":{"line":445,"column":26}},"locations":[{"start":{"line":444,"column":46},"end":{"line":445,"column":26}}]},"100":{"type":"branch","line":445,"loc":{"start":{"line":445,"column":15},"end":{"line":445,"column":48}},"locations":[{"start":{"line":445,"column":15},"end":{"line":445,"column":48}}]},"101":{"type":"branch","line":446,"loc":{"start":{"line":446,"column":2},"end":{"line":447,"column":19}},"locations":[{"start":{"line":446,"column":2},"end":{"line":447,"column":19}}]},"102":{"type":"branch","line":447,"loc":{"start":{"line":447,"column":19},"end":{"line":459,"column":3}},"locations":[{"start":{"line":447,"column":19},"end":{"line":459,"column":3}}]},"103":{"type":"branch","line":459,"loc":{"start":{"line":459,"column":2},"end":{"line":461,"column":1}},"locations":[{"start":{"line":459,"column":2},"end":{"line":461,"column":1}}]},"104":{"type":"branch","line":454,"loc":{"start":{"line":454,"column":14},"end":{"line":454,"column":51}},"locations":[{"start":{"line":454,"column":14},"end":{"line":454,"column":51}}]},"105":{"type":"branch","line":457,"loc":{"start":{"line":457,"column":23},"end":{"line":457,"column":61}},"locations":[{"start":{"line":457,"column":23},"end":{"line":457,"column":61}}]},"106":{"type":"branch","line":467,"loc":{"start":{"line":467,"column":0},"end":{"line":477,"column":1}},"locations":[{"start":{"line":467,"column":0},"end":{"line":477,"column":1}}]},"107":{"type":"branch","line":473,"loc":{"start":{"line":473,"column":27},"end":{"line":473,"column":45}},"locations":[{"start":{"line":473,"column":27},"end":{"line":473,"column":45}}]},"108":{"type":"branch","line":474,"loc":{"start":{"line":474,"column":24},"end":{"line":474,"column":42}},"locations":[{"start":{"line":474,"column":24},"end":{"line":474,"column":42}}]},"109":{"type":"branch","line":475,"loc":{"start":{"line":475,"column":27},"end":{"line":475,"column":45}},"locations":[{"start":{"line":475,"column":27},"end":{"line":475,"column":45}}]},"110":{"type":"branch","line":483,"loc":{"start":{"line":483,"column":7},"end":{"line":660,"column":1}},"locations":[{"start":{"line":483,"column":7},"end":{"line":660,"column":1}}]},"111":{"type":"branch","line":488,"loc":{"start":{"line":488,"column":12},"end":{"line":488,"column":76}},"locations":[{"start":{"line":488,"column":12},"end":{"line":488,"column":76}}]},"112":{"type":"branch","line":491,"loc":{"start":{"line":491,"column":27},"end":{"line":491,"column":45}},"locations":[{"start":{"line":491,"column":27},"end":{"line":491,"column":45}}]},"113":{"type":"branch","line":494,"loc":{"start":{"line":494,"column":24},"end":{"line":494,"column":39}},"locations":[{"start":{"line":494,"column":24},"end":{"line":494,"column":39}}]},"114":{"type":"branch","line":497,"loc":{"start":{"line":497,"column":27},"end":{"line":497,"column":45}},"locations":[{"start":{"line":497,"column":27},"end":{"line":497,"column":45}}]},"115":{"type":"branch","line":500,"loc":{"start":{"line":500,"column":32},"end":{"line":500,"column":50}},"locations":[{"start":{"line":500,"column":32},"end":{"line":500,"column":50}}]},"116":{"type":"branch","line":503,"loc":{"start":{"line":503,"column":35},"end":{"line":503,"column":56}},"locations":[{"start":{"line":503,"column":35},"end":{"line":503,"column":56}}]},"117":{"type":"branch","line":509,"loc":{"start":{"line":509,"column":21},"end":{"line":509,"column":45}},"locations":[{"start":{"line":509,"column":21},"end":{"line":509,"column":45}}]},"118":{"type":"branch","line":509,"loc":{"start":{"line":509,"column":40},"end":{"line":509,"column":64}},"locations":[{"start":{"line":509,"column":40},"end":{"line":509,"column":64}}]},"119":{"type":"branch","line":511,"loc":{"start":{"line":511,"column":26},"end":{"line":511,"column":58}},"locations":[{"start":{"line":511,"column":26},"end":{"line":511,"column":58}}]},"120":{"type":"branch","line":512,"loc":{"start":{"line":512,"column":29},"end":{"line":512,"column":78}},"locations":[{"start":{"line":512,"column":29},"end":{"line":512,"column":78}}]},"121":{"type":"branch","line":513,"loc":{"start":{"line":513,"column":49},"end":{"line":513,"column":73}},"locations":[{"start":{"line":513,"column":49},"end":{"line":513,"column":73}}]},"122":{"type":"branch","line":518,"loc":{"start":{"line":518,"column":29},"end":{"line":518,"column":59}},"locations":[{"start":{"line":518,"column":29},"end":{"line":518,"column":59}}]},"123":{"type":"branch","line":519,"loc":{"start":{"line":519,"column":31},"end":{"line":519,"column":63}},"locations":[{"start":{"line":519,"column":31},"end":{"line":519,"column":63}}]},"124":{"type":"branch","line":520,"loc":{"start":{"line":520,"column":29},"end":{"line":527,"column":3}},"locations":[{"start":{"line":520,"column":29},"end":{"line":527,"column":3}}]},"125":{"type":"branch","line":545,"loc":{"start":{"line":545,"column":30},"end":{"line":549,"column":3}},"locations":[{"start":{"line":545,"column":30},"end":{"line":549,"column":3}}]},"126":{"type":"branch","line":550,"loc":{"start":{"line":550,"column":29},"end":{"line":552,"column":3}},"locations":[{"start":{"line":550,"column":29},"end":{"line":552,"column":3}}]},"127":{"type":"branch","line":559,"loc":{"start":{"line":559,"column":26},"end":{"line":605,"column":9}},"locations":[{"start":{"line":559,"column":26},"end":{"line":605,"column":9}}]},"128":{"type":"branch","line":561,"loc":{"start":{"line":561,"column":29},"end":{"line":568,"column":5}},"locations":[{"start":{"line":561,"column":29},"end":{"line":568,"column":5}}]},"129":{"type":"branch","line":569,"loc":{"start":{"line":569,"column":26},"end":{"line":576,"column":5}},"locations":[{"start":{"line":569,"column":26},"end":{"line":576,"column":5}}]},"130":{"type":"branch","line":577,"loc":{"start":{"line":577,"column":29},"end":{"line":587,"column":5}},"locations":[{"start":{"line":577,"column":29},"end":{"line":587,"column":5}}]},"131":{"type":"branch","line":588,"loc":{"start":{"line":588,"column":34},"end":{"line":595,"column":5}},"locations":[{"start":{"line":588,"column":34},"end":{"line":595,"column":5}}]},"132":{"type":"branch","line":596,"loc":{"start":{"line":596,"column":37},"end":{"line":603,"column":5}},"locations":[{"start":{"line":596,"column":37},"end":{"line":603,"column":5}}]},"133":{"type":"branch","line":605,"loc":{"start":{"line":605,"column":2},"end":{"line":611,"column":3}},"locations":[{"start":{"line":605,"column":2},"end":{"line":611,"column":3}}]},"134":{"type":"branch","line":618,"loc":{"start":{"line":618,"column":27},"end":{"line":641,"column":3}},"locations":[{"start":{"line":618,"column":27},"end":{"line":641,"column":3}}]},"135":{"type":"branch","line":643,"loc":{"start":{"line":643,"column":27},"end":{"line":654,"column":3}},"locations":[{"start":{"line":643,"column":27},"end":{"line":654,"column":3}}]},"136":{"type":"branch","line":493,"loc":{"start":{"line":493,"column":12},"end":{"line":493,"column":51}},"locations":[{"start":{"line":493,"column":12},"end":{"line":493,"column":51}}]},"137":{"type":"branch","line":496,"loc":{"start":{"line":496,"column":12},"end":{"line":496,"column":45}},"locations":[{"start":{"line":496,"column":12},"end":{"line":496,"column":45}}]},"138":{"type":"branch","line":499,"loc":{"start":{"line":499,"column":12},"end":{"line":499,"column":45}},"locations":[{"start":{"line":499,"column":12},"end":{"line":499,"column":45}}]},"139":{"type":"branch","line":502,"loc":{"start":{"line":502,"column":12},"end":{"line":502,"column":48}},"locations":[{"start":{"line":502,"column":12},"end":{"line":502,"column":48}}]},"140":{"type":"branch","line":505,"loc":{"start":{"line":505,"column":12},"end":{"line":505,"column":48}},"locations":[{"start":{"line":505,"column":12},"end":{"line":505,"column":48}}]},"141":{"type":"branch","line":619,"loc":{"start":{"line":619,"column":21},"end":{"line":619,"column":68}},"locations":[{"start":{"line":619,"column":21},"end":{"line":619,"column":68}}]},"142":{"type":"branch","line":644,"loc":{"start":{"line":644,"column":21},"end":{"line":644,"column":68}},"locations":[{"start":{"line":644,"column":21},"end":{"line":644,"column":68}}]},"143":{"type":"branch","line":668,"loc":{"start":{"line":668,"column":0},"end":{"line":776,"column":1}},"locations":[{"start":{"line":668,"column":0},"end":{"line":776,"column":1}}]},"144":{"type":"branch","line":672,"loc":{"start":{"line":672,"column":15},"end":{"line":672,"column":59}},"locations":[{"start":{"line":672,"column":15},"end":{"line":672,"column":59}}]},"145":{"type":"branch","line":672,"loc":{"start":{"line":672,"column":59},"end":{"line":677,"column":3}},"locations":[{"start":{"line":672,"column":59},"end":{"line":677,"column":3}}]},"146":{"type":"branch","line":674,"loc":{"start":{"line":674,"column":78},"end":{"line":674,"column":97}},"locations":[{"start":{"line":674,"column":78},"end":{"line":674,"column":97}}]},"147":{"type":"branch","line":677,"loc":{"start":{"line":677,"column":2},"end":{"line":680,"column":44}},"locations":[{"start":{"line":677,"column":2},"end":{"line":680,"column":44}}]},"148":{"type":"branch","line":680,"loc":{"start":{"line":680,"column":29},"end":{"line":680,"column":48}},"locations":[{"start":{"line":680,"column":29},"end":{"line":680,"column":48}}]},"149":{"type":"branch","line":680,"loc":{"start":{"line":680,"column":48},"end":{"line":688,"column":3}},"locations":[{"start":{"line":680,"column":48},"end":{"line":688,"column":3}}]},"150":{"type":"branch","line":681,"loc":{"start":{"line":681,"column":37},"end":{"line":687,"column":5}},"locations":[{"start":{"line":681,"column":37},"end":{"line":687,"column":5}}]},"151":{"type":"branch","line":688,"loc":{"start":{"line":688,"column":2},"end":{"line":689,"column":41}},"locations":[{"start":{"line":688,"column":2},"end":{"line":689,"column":41}}]},"152":{"type":"branch","line":689,"loc":{"start":{"line":689,"column":29},"end":{"line":689,"column":45}},"locations":[{"start":{"line":689,"column":29},"end":{"line":689,"column":45}}]},"153":{"type":"branch","line":689,"loc":{"start":{"line":689,"column":45},"end":{"line":694,"column":3}},"locations":[{"start":{"line":689,"column":45},"end":{"line":694,"column":3}}]},"154":{"type":"branch","line":694,"loc":{"start":{"line":694,"column":2},"end":{"line":695,"column":44}},"locations":[{"start":{"line":694,"column":2},"end":{"line":695,"column":44}}]},"155":{"type":"branch","line":695,"loc":{"start":{"line":695,"column":29},"end":{"line":695,"column":48}},"locations":[{"start":{"line":695,"column":29},"end":{"line":695,"column":48}}]},"156":{"type":"branch","line":695,"loc":{"start":{"line":695,"column":48},"end":{"line":700,"column":3}},"locations":[{"start":{"line":695,"column":48},"end":{"line":700,"column":3}}]},"157":{"type":"branch","line":700,"loc":{"start":{"line":700,"column":2},"end":{"line":701,"column":44}},"locations":[{"start":{"line":700,"column":2},"end":{"line":701,"column":44}}]},"158":{"type":"branch","line":701,"loc":{"start":{"line":701,"column":29},"end":{"line":701,"column":48}},"locations":[{"start":{"line":701,"column":29},"end":{"line":701,"column":48}}]},"159":{"type":"branch","line":701,"loc":{"start":{"line":701,"column":48},"end":{"line":708,"column":3}},"locations":[{"start":{"line":701,"column":48},"end":{"line":708,"column":3}}]},"160":{"type":"branch","line":708,"loc":{"start":{"line":708,"column":2},"end":{"line":709,"column":47}},"locations":[{"start":{"line":708,"column":2},"end":{"line":709,"column":47}}]},"161":{"type":"branch","line":709,"loc":{"start":{"line":709,"column":29},"end":{"line":709,"column":51}},"locations":[{"start":{"line":709,"column":29},"end":{"line":709,"column":51}}]},"162":{"type":"branch","line":709,"loc":{"start":{"line":709,"column":51},"end":{"line":716,"column":3}},"locations":[{"start":{"line":709,"column":51},"end":{"line":716,"column":3}}]},"163":{"type":"branch","line":716,"loc":{"start":{"line":716,"column":2},"end":{"line":719,"column":29}},"locations":[{"start":{"line":716,"column":2},"end":{"line":719,"column":29}}]},"164":{"type":"branch","line":719,"loc":{"start":{"line":719,"column":22},"end":{"line":719,"column":46}},"locations":[{"start":{"line":719,"column":22},"end":{"line":719,"column":46}}]},"165":{"type":"branch","line":724,"loc":{"start":{"line":724,"column":22},"end":{"line":730,"column":3}},"locations":[{"start":{"line":724,"column":22},"end":{"line":730,"column":3}}]},"166":{"type":"branch","line":730,"loc":{"start":{"line":730,"column":2},"end":{"line":733,"column":29}},"locations":[{"start":{"line":730,"column":2},"end":{"line":733,"column":29}}]},"167":{"type":"branch","line":733,"loc":{"start":{"line":733,"column":24},"end":{"line":733,"column":45}},"locations":[{"start":{"line":733,"column":24},"end":{"line":733,"column":45}}]},"168":{"type":"branch","line":733,"loc":{"start":{"line":733,"column":45},"end":{"line":738,"column":3}},"locations":[{"start":{"line":733,"column":45},"end":{"line":738,"column":3}}]},"169":{"type":"branch","line":738,"loc":{"start":{"line":738,"column":2},"end":{"line":742,"column":29}},"locations":[{"start":{"line":738,"column":2},"end":{"line":742,"column":29}}]},"170":{"type":"branch","line":742,"loc":{"start":{"line":742,"column":29},"end":{"line":745,"column":5}},"locations":[{"start":{"line":742,"column":29},"end":{"line":745,"column":5}}]},"171":{"type":"branch","line":746,"loc":{"start":{"line":746,"column":2},"end":{"line":750,"column":3}},"locations":[{"start":{"line":746,"column":2},"end":{"line":750,"column":3}}]},"172":{"type":"branch","line":750,"loc":{"start":{"line":750,"column":2},"end":{"line":755,"column":11}},"locations":[{"start":{"line":750,"column":2},"end":{"line":755,"column":11}}]},"173":{"type":"branch","line":755,"loc":{"start":{"line":755,"column":2},"end":{"line":759,"column":3}},"locations":[{"start":{"line":755,"column":2},"end":{"line":759,"column":3}}]},"174":{"type":"branch","line":759,"loc":{"start":{"line":759,"column":2},"end":{"line":776,"column":1}},"locations":[{"start":{"line":759,"column":2},"end":{"line":776,"column":1}}]},"175":{"type":"branch","line":785,"loc":{"start":{"line":785,"column":0},"end":{"line":797,"column":1}},"locations":[{"start":{"line":785,"column":0},"end":{"line":797,"column":1}}]},"176":{"type":"branch","line":790,"loc":{"start":{"line":790,"column":35},"end":{"line":790,"column":50}},"locations":[{"start":{"line":790,"column":35},"end":{"line":790,"column":50}}]},"177":{"type":"branch","line":790,"loc":{"start":{"line":790,"column":39},"end":{"line":790,"column":59}},"locations":[{"start":{"line":790,"column":39},"end":{"line":790,"column":59}}]},"178":{"type":"branch","line":791,"loc":{"start":{"line":791,"column":36},"end":{"line":793,"column":3}},"locations":[{"start":{"line":791,"column":36},"end":{"line":793,"column":3}}]},"179":{"type":"branch","line":794,"loc":{"start":{"line":794,"column":29},"end":{"line":796,"column":3}},"locations":[{"start":{"line":794,"column":29},"end":{"line":796,"column":3}}]},"180":{"type":"branch","line":807,"loc":{"start":{"line":807,"column":7},"end":{"line":900,"column":1}},"locations":[{"start":{"line":807,"column":7},"end":{"line":900,"column":1}}]},"181":{"type":"branch","line":819,"loc":{"start":{"line":819,"column":12},"end":{"line":822,"column":5}},"locations":[{"start":{"line":819,"column":12},"end":{"line":822,"column":5}}]},"182":{"type":"branch","line":834,"loc":{"start":{"line":834,"column":12},"end":{"line":841,"column":5}},"locations":[{"start":{"line":834,"column":12},"end":{"line":841,"column":5}}]},"183":{"type":"branch","line":919,"loc":{"start":{"line":919,"column":7},"end":{"line":926,"column":1}},"locations":[{"start":{"line":919,"column":7},"end":{"line":926,"column":1}}]},"184":{"type":"branch","line":921,"loc":{"start":{"line":921,"column":62},"end":{"line":921,"column":75}},"locations":[{"start":{"line":921,"column":62},"end":{"line":921,"column":75}}]},"185":{"type":"branch","line":923,"loc":{"start":{"line":923,"column":2},"end":{"line":925,"column":3}},"locations":[{"start":{"line":923,"column":2},"end":{"line":925,"column":3}}]}},"b":{"0":[0],"1":[31],"2":[17],"3":[8],"4":[2],"5":[15],"6":[1],"7":[14],"8":[13],"9":[10],"10":[8],"11":[2],"12":[8],"13":[1],"14":[7],"15":[7],"16":[10],"17":[5],"18":[2],"19":[1],"20":[1],"21":[5],"22":[1],"23":[7],"24":[6],"25":[9],"26":[8],"27":[1],"28":[1],"29":[7],"30":[3],"31":[2],"32":[1],"33":[11],"34":[0],"35":[0],"36":[20],"37":[1],"38":[10],"39":[1],"40":[9],"41":[0],"42":[20],"43":[6],"44":[3],"45":[17],"46":[16],"47":[15],"48":[2],"49":[17],"50":[17],"51":[16],"52":[17],"53":[5],"54":[9],"55":[4],"56":[5],"57":[4],"58":[1],"59":[1],"60":[8],"61":[18],"62":[17],"63":[3],"64":[14],"65":[1],"66":[1],"67":[0],"68":[14],"69":[6],"70":[0],"71":[3],"72":[0],"73":[11],"74":[6],"75":[5],"76":[11],"77":[1],"78":[0],"79":[5],"80":[1],"81":[4],"82":[0],"83":[4],"84":[4],"85":[0],"86":[4],"87":[7],"88":[2],"89":[5],"90":[0],"91":[5],"92":[5],"93":[1],"94":[4],"95":[1],"96":[3],"97":[20],"98":[2],"99":[1],"100":[0],"101":[18],"102":[3],"103":[15],"104":[11],"105":[11],"106":[20],"107":[3],"108":[3],"109":[2],"110":[20],"111":[17],"112":[17],"113":[17],"114":[18],"115":[18],"116":[19],"117":[17],"118":[15],"119":[18],"120":[14],"121":[17],"122":[6],"123":[3],"124":[4],"125":[6],"126":[4],"127":[9],"128":[3],"129":[3],"130":[2],"131":[2],"132":[1],"133":[11],"134":[3],"135":[2],"136":[3],"137":[3],"138":[2],"139":[2],"140":[1],"141":[6],"142":[4],"143":[14],"144":[13],"145":[2],"146":[0],"147":[12],"148":[10],"149":[2],"150":[1],"151":[11],"152":[10],"153":[1],"154":[10],"155":[9],"156":[1],"157":[9],"158":[8],"159":[1],"160":[8],"161":[7],"162":[1],"163":[7],"164":[0],"165":[1],"166":[6],"167":[2],"168":[1],"169":[5],"170":[1],"171":[0],"172":[5],"173":[0],"174":[5],"175":[5],"176":[3],"177":[2],"178":[13],"179":[2],"180":[15],"181":[2],"182":[3],"183":[2],"184":[0],"185":[0]},"fnMap":{"0":{"name":"loadAppModule","decl":{"start":{"line":51,"column":0},"end":{"line":55,"column":1}},"loc":{"start":{"line":51,"column":0},"end":{"line":55,"column":1}},"line":51},"1":{"name":"resolveAppInstance","decl":{"start":{"line":65,"column":0},"end":{"line":72,"column":1}},"loc":{"start":{"line":65,"column":0},"end":{"line":72,"column":1}},"line":65},"2":{"name":"runCodegen","decl":{"start":{"line":89,"column":0},"end":{"line":157,"column":1}},"loc":{"start":{"line":89,"column":0},"end":{"line":157,"column":1}},"line":89},"3":{"name":"buildInfoPayload","decl":{"start":{"line":189,"column":0},"end":{"line":242,"column":1}},"loc":{"start":{"line":189,"column":0},"end":{"line":242,"column":1}},"line":189},"4":{"name":"renderInfoPayload","decl":{"start":{"line":247,"column":0},"end":{"line":265,"column":1}},"loc":{"start":{"line":247,"column":0},"end":{"line":265,"column":1}},"line":247},"5":{"name":"runInfo","decl":{"start":{"line":276,"column":0},"end":{"line":326,"column":1}},"loc":{"start":{"line":276,"column":0},"end":{"line":326,"column":1}},"line":276},"6":{"name":"parseRenameFieldSpec","decl":{"start":{"line":400,"column":0},"end":{"line":410,"column":1}},"loc":{"start":{"line":400,"column":0},"end":{"line":410,"column":1}},"line":400},"7":{"name":"parseFieldSpec","decl":{"start":{"line":415,"column":0},"end":{"line":422,"column":1}},"loc":{"start":{"line":415,"column":0},"end":{"line":422,"column":1}},"line":415},"8":{"name":"parseEndpointSpec","decl":{"start":{"line":427,"column":0},"end":{"line":433,"column":1}},"loc":{"start":{"line":427,"column":0},"end":{"line":433,"column":1}},"line":427},"9":{"name":"deriveClassName","decl":{"start":{"line":440,"column":0},"end":{"line":461,"column":1}},"loc":{"start":{"line":440,"column":0},"end":{"line":461,"column":1}},"line":440},"10":{"name":"collectSchemaNames","decl":{"start":{"line":467,"column":0},"end":{"line":477,"column":1}},"loc":{"start":{"line":467,"column":0},"end":{"line":477,"column":1}},"line":467},"11":{"name":"generateVersionChangeSource","decl":{"start":{"line":483,"column":7},"end":{"line":660,"column":1}},"loc":{"start":{"line":483,"column":7},"end":{"line":660,"column":1}},"line":483},"12":{"name":"sanitize","decl":{"start":{"line":619,"column":21},"end":{"line":619,"column":68}},"loc":{"start":{"line":619,"column":21},"end":{"line":619,"column":68}},"line":619},"13":{"name":"sanitize","decl":{"start":{"line":644,"column":21},"end":{"line":644,"column":68}},"loc":{"start":{"line":644,"column":21},"end":{"line":644,"column":68}},"line":644},"14":{"name":"runNewVersion","decl":{"start":{"line":668,"column":0},"end":{"line":776,"column":1}},"loc":{"start":{"line":668,"column":0},"end":{"line":776,"column":1}},"line":668},"15":{"name":"emitResult","decl":{"start":{"line":785,"column":0},"end":{"line":797,"column":1}},"loc":{"start":{"line":785,"column":0},"end":{"line":797,"column":1}},"line":785},"16":{"name":"createProgram","decl":{"start":{"line":807,"column":7},"end":{"line":900,"column":1}},"loc":{"start":{"line":807,"column":7},"end":{"line":900,"column":1}},"line":807},"17":{"name":"isMainModule","decl":{"start":{"line":919,"column":7},"end":{"line":926,"column":1}},"loc":{"start":{"line":919,"column":7},"end":{"line":926,"column":1}},"line":919}},"f":{"0":31,"1":17,"2":13,"3":11,"4":5,"5":18,"6":5,"7":7,"8":5,"9":20,"10":20,"11":20,"12":6,"13":4,"14":14,"15":5,"16":15,"17":2}} -} diff --git a/coverage/favicon.png b/coverage/favicon.png deleted file mode 100644 index c1525b8..0000000 Binary files a/coverage/favicon.png and /dev/null differ diff --git a/coverage/index.html b/coverage/index.html deleted file mode 100644 index e6d515e..0000000 --- a/coverage/index.html +++ /dev/null @@ -1,116 +0,0 @@ - - - - - - Code coverage report for All files - - - - - - - - - -
-
-

All files

-
- -
- 96.79% - Statements - 573/592 -
- - -
- 90.32% - Branches - 168/186 -
- - -
- 100% - Functions - 18/18 -
- - -
- 96.79% - Lines - 573/592 -
- - -
-

- Press n or j to go to the next uncovered block, b, p or k for the previous block. -

- -
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
FileStatementsBranchesFunctionsLines
cli.ts -
-
96.79%573/59290.32%168/186100%18/1896.79%573/592
-
-
-
- - - - - - - - \ No newline at end of file diff --git a/coverage/prettify.css b/coverage/prettify.css deleted file mode 100644 index b317a7c..0000000 --- a/coverage/prettify.css +++ /dev/null @@ -1 +0,0 @@ -.pln{color:#000}@media screen{.str{color:#080}.kwd{color:#008}.com{color:#800}.typ{color:#606}.lit{color:#066}.pun,.opn,.clo{color:#660}.tag{color:#008}.atn{color:#606}.atv{color:#080}.dec,.var{color:#606}.fun{color:red}}@media print,projection{.str{color:#060}.kwd{color:#006;font-weight:bold}.com{color:#600;font-style:italic}.typ{color:#404;font-weight:bold}.lit{color:#044}.pun,.opn,.clo{color:#440}.tag{color:#006;font-weight:bold}.atn{color:#404}.atv{color:#060}}pre.prettyprint{padding:2px;border:1px solid #888}ol.linenums{margin-top:0;margin-bottom:0}li.L0,li.L1,li.L2,li.L3,li.L5,li.L6,li.L7,li.L8{list-style-type:none}li.L1,li.L3,li.L5,li.L7,li.L9{background:#eee} diff --git a/coverage/prettify.js b/coverage/prettify.js deleted file mode 100644 index b322523..0000000 --- a/coverage/prettify.js +++ /dev/null @@ -1,2 +0,0 @@ -/* eslint-disable */ -window.PR_SHOULD_USE_CONTINUATION=true;(function(){var h=["break,continue,do,else,for,if,return,while"];var u=[h,"auto,case,char,const,default,double,enum,extern,float,goto,int,long,register,short,signed,sizeof,static,struct,switch,typedef,union,unsigned,void,volatile"];var p=[u,"catch,class,delete,false,import,new,operator,private,protected,public,this,throw,true,try,typeof"];var l=[p,"alignof,align_union,asm,axiom,bool,concept,concept_map,const_cast,constexpr,decltype,dynamic_cast,explicit,export,friend,inline,late_check,mutable,namespace,nullptr,reinterpret_cast,static_assert,static_cast,template,typeid,typename,using,virtual,where"];var x=[p,"abstract,boolean,byte,extends,final,finally,implements,import,instanceof,null,native,package,strictfp,super,synchronized,throws,transient"];var R=[x,"as,base,by,checked,decimal,delegate,descending,dynamic,event,fixed,foreach,from,group,implicit,in,interface,internal,into,is,lock,object,out,override,orderby,params,partial,readonly,ref,sbyte,sealed,stackalloc,string,select,uint,ulong,unchecked,unsafe,ushort,var"];var r="all,and,by,catch,class,else,extends,false,finally,for,if,in,is,isnt,loop,new,no,not,null,of,off,on,or,return,super,then,true,try,unless,until,when,while,yes";var w=[p,"debugger,eval,export,function,get,null,set,undefined,var,with,Infinity,NaN"];var s="caller,delete,die,do,dump,elsif,eval,exit,foreach,for,goto,if,import,last,local,my,next,no,our,print,package,redo,require,sub,undef,unless,until,use,wantarray,while,BEGIN,END";var I=[h,"and,as,assert,class,def,del,elif,except,exec,finally,from,global,import,in,is,lambda,nonlocal,not,or,pass,print,raise,try,with,yield,False,True,None"];var f=[h,"alias,and,begin,case,class,def,defined,elsif,end,ensure,false,in,module,next,nil,not,or,redo,rescue,retry,self,super,then,true,undef,unless,until,when,yield,BEGIN,END"];var H=[h,"case,done,elif,esac,eval,fi,function,in,local,set,then,until"];var A=[l,R,w,s+I,f,H];var e=/^(DIR|FILE|vector|(de|priority_)?queue|list|stack|(const_)?iterator|(multi)?(set|map)|bitset|u?(int|float)\d*)/;var C="str";var z="kwd";var j="com";var O="typ";var G="lit";var L="pun";var F="pln";var m="tag";var E="dec";var J="src";var P="atn";var n="atv";var N="nocode";var M="(?:^^\\.?|[+-]|\\!|\\!=|\\!==|\\#|\\%|\\%=|&|&&|&&=|&=|\\(|\\*|\\*=|\\+=|\\,|\\-=|\\->|\\/|\\/=|:|::|\\;|<|<<|<<=|<=|=|==|===|>|>=|>>|>>=|>>>|>>>=|\\?|\\@|\\[|\\^|\\^=|\\^\\^|\\^\\^=|\\{|\\||\\|=|\\|\\||\\|\\|=|\\~|break|case|continue|delete|do|else|finally|instanceof|return|throw|try|typeof)\\s*";function k(Z){var ad=0;var S=false;var ac=false;for(var V=0,U=Z.length;V122)){if(!(al<65||ag>90)){af.push([Math.max(65,ag)|32,Math.min(al,90)|32])}if(!(al<97||ag>122)){af.push([Math.max(97,ag)&~32,Math.min(al,122)&~32])}}}}af.sort(function(av,au){return(av[0]-au[0])||(au[1]-av[1])});var ai=[];var ap=[NaN,NaN];for(var ar=0;arat[0]){if(at[1]+1>at[0]){an.push("-")}an.push(T(at[1]))}}an.push("]");return an.join("")}function W(al){var aj=al.source.match(new RegExp("(?:\\[(?:[^\\x5C\\x5D]|\\\\[\\s\\S])*\\]|\\\\u[A-Fa-f0-9]{4}|\\\\x[A-Fa-f0-9]{2}|\\\\[0-9]+|\\\\[^ux0-9]|\\(\\?[:!=]|[\\(\\)\\^]|[^\\x5B\\x5C\\(\\)\\^]+)","g"));var ah=aj.length;var an=[];for(var ak=0,am=0;ak=2&&ai==="["){aj[ak]=X(ag)}else{if(ai!=="\\"){aj[ak]=ag.replace(/[a-zA-Z]/g,function(ao){var ap=ao.charCodeAt(0);return"["+String.fromCharCode(ap&~32,ap|32)+"]"})}}}}return aj.join("")}var aa=[];for(var V=0,U=Z.length;V=0;){S[ac.charAt(ae)]=Y}}var af=Y[1];var aa=""+af;if(!ag.hasOwnProperty(aa)){ah.push(af);ag[aa]=null}}ah.push(/[\0-\uffff]/);V=k(ah)})();var X=T.length;var W=function(ah){var Z=ah.sourceCode,Y=ah.basePos;var ad=[Y,F];var af=0;var an=Z.match(V)||[];var aj={};for(var ae=0,aq=an.length;ae=5&&"lang-"===ap.substring(0,5);if(am&&!(ai&&typeof ai[1]==="string")){am=false;ap=J}if(!am){aj[ag]=ap}}var ab=af;af+=ag.length;if(!am){ad.push(Y+ab,ap)}else{var al=ai[1];var ak=ag.indexOf(al);var ac=ak+al.length;if(ai[2]){ac=ag.length-ai[2].length;ak=ac-al.length}var ar=ap.substring(5);B(Y+ab,ag.substring(0,ak),W,ad);B(Y+ab+ak,al,q(ar,al),ad);B(Y+ab+ac,ag.substring(ac),W,ad)}}ah.decorations=ad};return W}function i(T){var W=[],S=[];if(T.tripleQuotedStrings){W.push([C,/^(?:\'\'\'(?:[^\'\\]|\\[\s\S]|\'{1,2}(?=[^\']))*(?:\'\'\'|$)|\"\"\"(?:[^\"\\]|\\[\s\S]|\"{1,2}(?=[^\"]))*(?:\"\"\"|$)|\'(?:[^\\\']|\\[\s\S])*(?:\'|$)|\"(?:[^\\\"]|\\[\s\S])*(?:\"|$))/,null,"'\""])}else{if(T.multiLineStrings){W.push([C,/^(?:\'(?:[^\\\']|\\[\s\S])*(?:\'|$)|\"(?:[^\\\"]|\\[\s\S])*(?:\"|$)|\`(?:[^\\\`]|\\[\s\S])*(?:\`|$))/,null,"'\"`"])}else{W.push([C,/^(?:\'(?:[^\\\'\r\n]|\\.)*(?:\'|$)|\"(?:[^\\\"\r\n]|\\.)*(?:\"|$))/,null,"\"'"])}}if(T.verbatimStrings){S.push([C,/^@\"(?:[^\"]|\"\")*(?:\"|$)/,null])}var Y=T.hashComments;if(Y){if(T.cStyleComments){if(Y>1){W.push([j,/^#(?:##(?:[^#]|#(?!##))*(?:###|$)|.*)/,null,"#"])}else{W.push([j,/^#(?:(?:define|elif|else|endif|error|ifdef|include|ifndef|line|pragma|undef|warning)\b|[^\r\n]*)/,null,"#"])}S.push([C,/^<(?:(?:(?:\.\.\/)*|\/?)(?:[\w-]+(?:\/[\w-]+)+)?[\w-]+\.h|[a-z]\w*)>/,null])}else{W.push([j,/^#[^\r\n]*/,null,"#"])}}if(T.cStyleComments){S.push([j,/^\/\/[^\r\n]*/,null]);S.push([j,/^\/\*[\s\S]*?(?:\*\/|$)/,null])}if(T.regexLiterals){var X=("/(?=[^/*])(?:[^/\\x5B\\x5C]|\\x5C[\\s\\S]|\\x5B(?:[^\\x5C\\x5D]|\\x5C[\\s\\S])*(?:\\x5D|$))+/");S.push(["lang-regex",new RegExp("^"+M+"("+X+")")])}var V=T.types;if(V){S.push([O,V])}var U=(""+T.keywords).replace(/^ | $/g,"");if(U.length){S.push([z,new RegExp("^(?:"+U.replace(/[\s,]+/g,"|")+")\\b"),null])}W.push([F,/^\s+/,null," \r\n\t\xA0"]);S.push([G,/^@[a-z_$][a-z_$@0-9]*/i,null],[O,/^(?:[@_]?[A-Z]+[a-z][A-Za-z_$@0-9]*|\w+_t\b)/,null],[F,/^[a-z_$][a-z_$@0-9]*/i,null],[G,new RegExp("^(?:0x[a-f0-9]+|(?:\\d(?:_\\d+)*\\d*(?:\\.\\d*)?|\\.\\d\\+)(?:e[+\\-]?\\d+)?)[a-z]*","i"),null,"0123456789"],[F,/^\\[\s\S]?/,null],[L,/^.[^\s\w\.$@\'\"\`\/\#\\]*/,null]);return g(W,S)}var K=i({keywords:A,hashComments:true,cStyleComments:true,multiLineStrings:true,regexLiterals:true});function Q(V,ag){var U=/(?:^|\s)nocode(?:\s|$)/;var ab=/\r\n?|\n/;var ac=V.ownerDocument;var S;if(V.currentStyle){S=V.currentStyle.whiteSpace}else{if(window.getComputedStyle){S=ac.defaultView.getComputedStyle(V,null).getPropertyValue("white-space")}}var Z=S&&"pre"===S.substring(0,3);var af=ac.createElement("LI");while(V.firstChild){af.appendChild(V.firstChild)}var W=[af];function ae(al){switch(al.nodeType){case 1:if(U.test(al.className)){break}if("BR"===al.nodeName){ad(al);if(al.parentNode){al.parentNode.removeChild(al)}}else{for(var an=al.firstChild;an;an=an.nextSibling){ae(an)}}break;case 3:case 4:if(Z){var am=al.nodeValue;var aj=am.match(ab);if(aj){var ai=am.substring(0,aj.index);al.nodeValue=ai;var ah=am.substring(aj.index+aj[0].length);if(ah){var ak=al.parentNode;ak.insertBefore(ac.createTextNode(ah),al.nextSibling)}ad(al);if(!ai){al.parentNode.removeChild(al)}}}break}}function ad(ak){while(!ak.nextSibling){ak=ak.parentNode;if(!ak){return}}function ai(al,ar){var aq=ar?al.cloneNode(false):al;var ao=al.parentNode;if(ao){var ap=ai(ao,1);var an=al.nextSibling;ap.appendChild(aq);for(var am=an;am;am=an){an=am.nextSibling;ap.appendChild(am)}}return aq}var ah=ai(ak.nextSibling,0);for(var aj;(aj=ah.parentNode)&&aj.nodeType===1;){ah=aj}W.push(ah)}for(var Y=0;Y=S){ah+=2}if(V>=ap){Z+=2}}}var t={};function c(U,V){for(var S=V.length;--S>=0;){var T=V[S];if(!t.hasOwnProperty(T)){t[T]=U}else{if(window.console){console.warn("cannot override language handler %s",T)}}}}function q(T,S){if(!(T&&t.hasOwnProperty(T))){T=/^\s*]*(?:>|$)/],[j,/^<\!--[\s\S]*?(?:-\->|$)/],["lang-",/^<\?([\s\S]+?)(?:\?>|$)/],["lang-",/^<%([\s\S]+?)(?:%>|$)/],[L,/^(?:<[%?]|[%?]>)/],["lang-",/^]*>([\s\S]+?)<\/xmp\b[^>]*>/i],["lang-js",/^]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-css",/^]*>([\s\S]*?)(<\/style\b[^>]*>)/i],["lang-in.tag",/^(<\/?[a-z][^<>]*>)/i]]),["default-markup","htm","html","mxml","xhtml","xml","xsl"]);c(g([[F,/^[\s]+/,null," \t\r\n"],[n,/^(?:\"[^\"]*\"?|\'[^\']*\'?)/,null,"\"'"]],[[m,/^^<\/?[a-z](?:[\w.:-]*\w)?|\/?>$/i],[P,/^(?!style[\s=]|on)[a-z](?:[\w:-]*\w)?/i],["lang-uq.val",/^=\s*([^>\'\"\s]*(?:[^>\'\"\s\/]|\/(?=\s)))/],[L,/^[=<>\/]+/],["lang-js",/^on\w+\s*=\s*\"([^\"]+)\"/i],["lang-js",/^on\w+\s*=\s*\'([^\']+)\'/i],["lang-js",/^on\w+\s*=\s*([^\"\'>\s]+)/i],["lang-css",/^style\s*=\s*\"([^\"]+)\"/i],["lang-css",/^style\s*=\s*\'([^\']+)\'/i],["lang-css",/^style\s*=\s*([^\"\'>\s]+)/i]]),["in.tag"]);c(g([],[[n,/^[\s\S]+/]]),["uq.val"]);c(i({keywords:l,hashComments:true,cStyleComments:true,types:e}),["c","cc","cpp","cxx","cyc","m"]);c(i({keywords:"null,true,false"}),["json"]);c(i({keywords:R,hashComments:true,cStyleComments:true,verbatimStrings:true,types:e}),["cs"]);c(i({keywords:x,cStyleComments:true}),["java"]);c(i({keywords:H,hashComments:true,multiLineStrings:true}),["bsh","csh","sh"]);c(i({keywords:I,hashComments:true,multiLineStrings:true,tripleQuotedStrings:true}),["cv","py"]);c(i({keywords:s,hashComments:true,multiLineStrings:true,regexLiterals:true}),["perl","pl","pm"]);c(i({keywords:f,hashComments:true,multiLineStrings:true,regexLiterals:true}),["rb"]);c(i({keywords:w,cStyleComments:true,regexLiterals:true}),["js"]);c(i({keywords:r,hashComments:3,cStyleComments:true,multilineStrings:true,tripleQuotedStrings:true,regexLiterals:true}),["coffee"]);c(g([],[[C,/^[\s\S]+/]]),["regex"]);function d(V){var U=V.langExtension;try{var S=a(V.sourceNode);var T=S.sourceCode;V.sourceCode=T;V.spans=S.spans;V.basePos=0;q(U,T)(V);D(V)}catch(W){if("console" in window){console.log(W&&W.stack?W.stack:W)}}}function y(W,V,U){var S=document.createElement("PRE");S.innerHTML=W;if(U){Q(S,U)}var T={langExtension:V,numberLines:U,sourceNode:S};d(T);return S.innerHTML}function b(ad){function Y(af){return document.getElementsByTagName(af)}var ac=[Y("pre"),Y("code"),Y("xmp")];var T=[];for(var aa=0;aa=0){var ah=ai.match(ab);var am;if(!ah&&(am=o(aj))&&"CODE"===am.tagName){ah=am.className.match(ab)}if(ah){ah=ah[1]}var al=false;for(var ak=aj.parentNode;ak;ak=ak.parentNode){if((ak.tagName==="pre"||ak.tagName==="code"||ak.tagName==="xmp")&&ak.className&&ak.className.indexOf("prettyprint")>=0){al=true;break}}if(!al){var af=aj.className.match(/\blinenums\b(?::(\d+))?/);af=af?af[1]&&af[1].length?+af[1]:true:false;if(af){Q(aj,af)}S={langExtension:ah,sourceNode:aj,numberLines:af};d(S)}}}if(X]*(?:>|$)/],[PR.PR_COMMENT,/^<\!--[\s\S]*?(?:-\->|$)/],[PR.PR_PUNCTUATION,/^(?:<[%?]|[%?]>)/],["lang-",/^<\?([\s\S]+?)(?:\?>|$)/],["lang-",/^<%([\s\S]+?)(?:%>|$)/],["lang-",/^]*>([\s\S]+?)<\/xmp\b[^>]*>/i],["lang-handlebars",/^]*type\s*=\s*['"]?text\/x-handlebars-template['"]?\b[^>]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-js",/^]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-css",/^]*>([\s\S]*?)(<\/style\b[^>]*>)/i],["lang-in.tag",/^(<\/?[a-z][^<>]*>)/i],[PR.PR_DECLARATION,/^{{[#^>/]?\s*[\w.][^}]*}}/],[PR.PR_DECLARATION,/^{{&?\s*[\w.][^}]*}}/],[PR.PR_DECLARATION,/^{{{>?\s*[\w.][^}]*}}}/],[PR.PR_COMMENT,/^{{![^}]*}}/]]),["handlebars","hbs"]);PR.registerLangHandler(PR.createSimpleLexer([[PR.PR_PLAIN,/^[ \t\r\n\f]+/,null," \t\r\n\f"]],[[PR.PR_STRING,/^\"(?:[^\n\r\f\\\"]|\\(?:\r\n?|\n|\f)|\\[\s\S])*\"/,null],[PR.PR_STRING,/^\'(?:[^\n\r\f\\\']|\\(?:\r\n?|\n|\f)|\\[\s\S])*\'/,null],["lang-css-str",/^url\(([^\)\"\']*)\)/i],[PR.PR_KEYWORD,/^(?:url|rgb|\!important|@import|@page|@media|@charset|inherit)(?=[^\-\w]|$)/i,null],["lang-css-kw",/^(-?(?:[_a-z]|(?:\\[0-9a-f]+ ?))(?:[_a-z0-9\-]|\\(?:\\[0-9a-f]+ ?))*)\s*:/i],[PR.PR_COMMENT,/^\/\*[^*]*\*+(?:[^\/*][^*]*\*+)*\//],[PR.PR_COMMENT,/^(?:)/],[PR.PR_LITERAL,/^(?:\d+|\d*\.\d+)(?:%|[a-z]+)?/i],[PR.PR_LITERAL,/^#(?:[0-9a-f]{3}){1,2}/i],[PR.PR_PLAIN,/^-?(?:[_a-z]|(?:\\[\da-f]+ ?))(?:[_a-z\d\-]|\\(?:\\[\da-f]+ ?))*/i],[PR.PR_PUNCTUATION,/^[^\s\w\'\"]+/]]),["css"]);PR.registerLangHandler(PR.createSimpleLexer([],[[PR.PR_KEYWORD,/^-?(?:[_a-z]|(?:\\[\da-f]+ ?))(?:[_a-z\d\-]|\\(?:\\[\da-f]+ ?))*/i]]),["css-kw"]);PR.registerLangHandler(PR.createSimpleLexer([],[[PR.PR_STRING,/^[^\)\"\']+/]]),["css-str"]); diff --git a/coverage/sort-arrow-sprite.png b/coverage/sort-arrow-sprite.png deleted file mode 100644 index 6ed6831..0000000 Binary files a/coverage/sort-arrow-sprite.png and /dev/null differ diff --git a/coverage/sorter.js b/coverage/sorter.js deleted file mode 100644 index 4ed70ae..0000000 --- a/coverage/sorter.js +++ /dev/null @@ -1,210 +0,0 @@ -/* eslint-disable */ -var addSorting = (function() { - 'use strict'; - var cols, - currentSort = { - index: 0, - desc: false - }; - - // returns the summary table element - function getTable() { - return document.querySelector('.coverage-summary'); - } - // returns the thead element of the summary table - function getTableHeader() { - return getTable().querySelector('thead tr'); - } - // returns the tbody element of the summary table - function getTableBody() { - return getTable().querySelector('tbody'); - } - // returns the th element for nth column - function getNthColumn(n) { - return getTableHeader().querySelectorAll('th')[n]; - } - - function onFilterInput() { - const searchValue = document.getElementById('fileSearch').value; - const rows = document.getElementsByTagName('tbody')[0].children; - - // Try to create a RegExp from the searchValue. If it fails (invalid regex), - // it will be treated as a plain text search - let searchRegex; - try { - searchRegex = new RegExp(searchValue, 'i'); // 'i' for case-insensitive - } catch (error) { - searchRegex = null; - } - - for (let i = 0; i < rows.length; i++) { - const row = rows[i]; - let isMatch = false; - - if (searchRegex) { - // If a valid regex was created, use it for matching - isMatch = searchRegex.test(row.textContent); - } else { - // Otherwise, fall back to the original plain text search - isMatch = row.textContent - .toLowerCase() - .includes(searchValue.toLowerCase()); - } - - row.style.display = isMatch ? '' : 'none'; - } - } - - // loads the search box - function addSearchBox() { - var template = document.getElementById('filterTemplate'); - var templateClone = template.content.cloneNode(true); - templateClone.getElementById('fileSearch').oninput = onFilterInput; - template.parentElement.appendChild(templateClone); - } - - // loads all columns - function loadColumns() { - var colNodes = getTableHeader().querySelectorAll('th'), - colNode, - cols = [], - col, - i; - - for (i = 0; i < colNodes.length; i += 1) { - colNode = colNodes[i]; - col = { - key: colNode.getAttribute('data-col'), - sortable: !colNode.getAttribute('data-nosort'), - type: colNode.getAttribute('data-type') || 'string' - }; - cols.push(col); - if (col.sortable) { - col.defaultDescSort = col.type === 'number'; - colNode.innerHTML = - colNode.innerHTML + ''; - } - } - return cols; - } - // attaches a data attribute to every tr element with an object - // of data values keyed by column name - function loadRowData(tableRow) { - var tableCols = tableRow.querySelectorAll('td'), - colNode, - col, - data = {}, - i, - val; - for (i = 0; i < tableCols.length; i += 1) { - colNode = tableCols[i]; - col = cols[i]; - val = colNode.getAttribute('data-value'); - if (col.type === 'number') { - val = Number(val); - } - data[col.key] = val; - } - return data; - } - // loads all row data - function loadData() { - var rows = getTableBody().querySelectorAll('tr'), - i; - - for (i = 0; i < rows.length; i += 1) { - rows[i].data = loadRowData(rows[i]); - } - } - // sorts the table using the data for the ith column - function sortByIndex(index, desc) { - var key = cols[index].key, - sorter = function(a, b) { - a = a.data[key]; - b = b.data[key]; - return a < b ? -1 : a > b ? 1 : 0; - }, - finalSorter = sorter, - tableBody = document.querySelector('.coverage-summary tbody'), - rowNodes = tableBody.querySelectorAll('tr'), - rows = [], - i; - - if (desc) { - finalSorter = function(a, b) { - return -1 * sorter(a, b); - }; - } - - for (i = 0; i < rowNodes.length; i += 1) { - rows.push(rowNodes[i]); - tableBody.removeChild(rowNodes[i]); - } - - rows.sort(finalSorter); - - for (i = 0; i < rows.length; i += 1) { - tableBody.appendChild(rows[i]); - } - } - // removes sort indicators for current column being sorted - function removeSortIndicators() { - var col = getNthColumn(currentSort.index), - cls = col.className; - - cls = cls.replace(/ sorted$/, '').replace(/ sorted-desc$/, ''); - col.className = cls; - } - // adds sort indicators for current column being sorted - function addSortIndicators() { - getNthColumn(currentSort.index).className += currentSort.desc - ? ' sorted-desc' - : ' sorted'; - } - // adds event listeners for all sorter widgets - function enableUI() { - var i, - el, - ithSorter = function ithSorter(i) { - var col = cols[i]; - - return function() { - var desc = col.defaultDescSort; - - if (currentSort.index === i) { - desc = !currentSort.desc; - } - sortByIndex(i, desc); - removeSortIndicators(); - currentSort.index = i; - currentSort.desc = desc; - addSortIndicators(); - }; - }; - for (i = 0; i < cols.length; i += 1) { - if (cols[i].sortable) { - // add the click event handler on the th so users - // dont have to click on those tiny arrows - el = getNthColumn(i).querySelector('.sorter').parentElement; - if (el.addEventListener) { - el.addEventListener('click', ithSorter(i)); - } else { - el.attachEvent('onclick', ithSorter(i)); - } - } - } - } - // adds sorting functionality to the UI - return function() { - if (!getTable()) { - return; - } - cols = loadColumns(); - loadData(); - addSearchBox(); - addSortIndicators(); - enableUI(); - }; -})(); - -window.addEventListener('load', addSorting); diff --git a/examples/stripe-api.ts b/examples/stripe-api.ts index b615a3c..1fe5125 100644 --- a/examples/stripe-api.ts +++ b/examples/stripe-api.ts @@ -14,6 +14,7 @@ * Run: npx tsx examples/stripe-api.ts */ import crypto from "node:crypto"; +import type { Request } from "express"; import { z } from "zod"; import { Tsadwyn, @@ -27,8 +28,41 @@ import { convertResponseToPreviousVersionFor, RequestInfo, ResponseInfo, + HttpError, + exceptionMap, + deletedResponseSchema, + createVersioningRoutes, + perClientDefaultVersion, } from "../src/index.js"; +// ═══════════════════════════════════════════════════════════════════════════ +// Domain exceptions (no HTTP semantics leak into service layers) +// +// Keyed by err.name string in exceptionMap — survives module-boundary +// identity traps (Jest resetModules, dual-install, ESM/CJS interop). +// ═══════════════════════════════════════════════════════════════════════════ + +class NoSuchCustomerError extends Error { + constructor(public readonly customerId: string) { + super(`No such customer: '${customerId}'`); + this.name = "NoSuchCustomerError"; + } +} + +class NoSuchChargeError extends Error { + constructor(public readonly chargeId: string) { + super(`No such charge: '${chargeId}'`); + this.name = "NoSuchChargeError"; + } +} + +class NoSuchPaymentIntentError extends Error { + constructor(public readonly piId: string) { + super(`No such payment_intent: '${piId}'`); + this.name = "NoSuchPaymentIntentError"; + } +} + // ═══════════════════════════════════════════════════════════════════════════ // Schemas — latest version (2024-11-01) // ═══════════════════════════════════════════════════════════════════════════ @@ -109,6 +143,12 @@ const PaymentIntentResource = z.object({ metadata: z.record(z.string()), }).named("PaymentIntentResource"); +// ── Deleted-resource envelopes (Stripe shape: {id, object, deleted}) ────── + +const DeletedCustomer = deletedResponseSchema("customer").named("DeletedCustomer"); +const DeletedCharge = deletedResponseSchema("charge").named("DeletedCharge"); +const DeletedPaymentIntent = deletedResponseSchema("payment_intent").named("DeletedPaymentIntent"); + // ── List wrappers ────────────────────────────────────────────────────────── const CustomerList = z.object({ @@ -140,6 +180,27 @@ const customers: Record = {}; const charges: Record = {}; const paymentIntents: Record = {}; +// ───────────────────────────────────────────────────────────────────────── +// Client → pinned version store (in-memory for the demo). +// In production, this would be a column on the `accounts` table, a Redis +// key, or a remote account-service call — the createVersioningRoutes and +// perClientDefaultVersion helpers don't care. They only see these two +// small callbacks (load + save). +// ───────────────────────────────────────────────────────────────────────── +const clientPins: Record = {}; +const SUPPORTED_VERSIONS = ["2024-11-01", "2024-06-01", "2024-01-15"] as const; + +/** + * Extract the calling account from the request. In production this comes + * from the authenticated session (Stripe uses the secret key's account + * binding). Here we use an `x-account-id` header so curl examples are + * easy to run. + */ +function identifyAccount(req: Request): string | null { + const hdr = req.headers["x-account-id"]; + return typeof hdr === "string" && hdr.length > 0 ? hdr : null; +} + function genId(prefix: string) { return `${prefix}_${crypto.randomUUID().replace(/-/g, "").slice(0, 24)}`; } @@ -170,10 +231,25 @@ router.post("/customers", CustomerCreate, CustomerResource, async (req) => { router.get("/customers/:customerId", null, CustomerResource, async (req) => { const c = customers[req.params.customerId]; - if (!c) throw new Error("No such customer"); + if (!c) throw new NoSuchCustomerError(req.params.customerId); return c; }); +// DELETE using Stripe's exact wire shape — verified via +// `curl -X DELETE https://api.stripe.com/v1/customers/`: +// returns 200 + {id, object, deleted}, NOT 204. +// Status 200 default is correct — 204 would strip the body on the wire. +router.delete("/customers/:customerId", null, DeletedCustomer, async (req) => { + const c = customers[req.params.customerId]; + if (!c) throw new NoSuchCustomerError(req.params.customerId); + delete customers[req.params.customerId]; + return { + id: req.params.customerId, + object: "customer" as const, + deleted: true as const, + }; +}); + router.get("/customers", null, CustomerList, async () => { const data = Object.values(customers); return { object: "list" as const, data, has_more: false, url: "/v1/customers" }; @@ -202,7 +278,7 @@ router.post("/charges", ChargeCreate, ChargeResource, async (req) => { router.get("/charges/:chargeId", null, ChargeResource, async (req) => { const ch = charges[req.params.chargeId]; - if (!ch) throw new Error("No such charge"); + if (!ch) throw new NoSuchChargeError(req.params.chargeId); return ch; }); @@ -238,7 +314,7 @@ router.post("/payment_intents", PaymentIntentCreate, PaymentIntentResource, router.get("/payment_intents/:piId", null, PaymentIntentResource, async (req) => { const pi = paymentIntents[req.params.piId]; - if (!pi) throw new Error("No such payment intent"); + if (!pi) throw new NoSuchPaymentIntentError(req.params.piId); return pi; }); @@ -268,10 +344,12 @@ class RemoveSourceAddPaymentIntent extends VersionChange { "back-reference, added payment_intents.automatic_payment_methods"; instructions = [ - // In v2024-06-01, charges still had `source` alongside payment_method - schema(ChargeCreate).field("payment_method").had({ - name: "payment_method", // keep the field - }), + // At v2024-06-01, `payment_method` was optional and `source` existed as + // an optional alias — clients could send either. (Validation runs + // BEFORE request migrations, so the schema itself has to accept + // both shapes.) + schema(ChargeCreate).field("payment_method").had({ optional: true }), + schema(ChargeCreate).field("source").existedAs({ type: z.string().optional() }), schema(ChargeResource).field("payment_intent").didntExist, schema(PaymentIntentCreate).field("automatic_payment_methods").didntExist, schema(PaymentIntentResource).field("automatic_payment_methods").didntExist, @@ -349,6 +427,13 @@ class RenameBalanceAndAddPaymentIntents extends VersionChange { endpoint("/v1/payment_intents", ["POST"]).didntExist, endpoint("/v1/payment_intents", ["GET"]).didntExist, endpoint("/v1/payment_intents/:piId", ["GET"]).didntExist, + + // At v2024-01-15, `source` was the ONLY card token field and it was + // REQUIRED (payment_method didn't exist yet). Make source non-optional + // here (undoes the optional wrapper RemoveSourceAddPaymentIntent + // applied to produce the v2024-06-01 shape). + schema(ChargeCreate).field("payment_method").didntExist, + schema(ChargeCreate).field("source").had({ optional: false }), ]; // Rename account_balance → balance in customer requests @@ -415,6 +500,26 @@ class RenameBalanceAndAddPaymentIntents extends VersionChange { // Wire it up // ═══════════════════════════════════════════════════════════════════════════ +// Pre-wired RESTful /versioning resource. tsadwyn owns no persistence — +// loadVersion and saveVersion hand the callback off to our in-memory +// store; any real consumer would back this with Postgres, Redis, or an +// accounts microservice. +const versioningRoutes = createVersioningRoutes({ + identify: identifyAccount, + loadVersion: (accountId) => clientPins[accountId] ?? null, + saveVersion: (accountId, version) => { + clientPins[accountId] = version; + }, + supportedVersions: SUPPORTED_VERSIONS, + // Match perClientDefaultVersion's fallback so GET /versioning + // reports what tsadwyn would actually use at dispatch: latest + // (2024-11-01) when no pin is stored — Stripe's "new accounts pin + // to current latest" semantic. + fallback: SUPPORTED_VERSIONS[0], + // allowDowngrade: false, // default + // allowNoChange: false, // default +}); + const app = new Tsadwyn({ versions: new VersionBundle( new Version("2024-11-01", RemoveSourceAddPaymentIntent), @@ -423,9 +528,69 @@ const app = new Tsadwyn({ ), title: "Stripe-like Payments API", apiVersionHeaderName: "stripe-version", + + // When a client doesn't send `stripe-version`, fall back to their + // stored pin. Same identify callback as /versioning so there's one + // source of truth per account. + // + // - fallback: SUPPORTED_VERSIONS[0] → latest — matches Stripe's + // "new accounts pin to current latest at signup" semantic. + // - pinOnFirstResolve: true → the first authenticated call from an + // unpinned account SAVES the fallback as their pin via + // saveVersion(). Subsequent calls read the stored pin and + // behave like any pinned account. + // - onStalePin: 'fallback' → if an account has a pin we've since + // dropped from the bundle, tsadwyn uses fallback (without auto- + // overwriting; stale-pin healing is a separate consumer concern). + apiVersionDefaultValue: perClientDefaultVersion({ + identify: identifyAccount, + resolvePin: (accountId) => clientPins[accountId] ?? null, + saveVersion: (accountId, version) => { + clientPins[accountId] = version; + }, + fallback: SUPPORTED_VERSIONS[0], // latest + pinOnFirstResolve: true, + supportedVersions: SUPPORTED_VERSIONS, + onStalePin: "fallback", + }), + + // Domain exceptions → HttpError. Matches Stripe's own error envelope + // shape: {error: {code, message, param?}}. Keyed by err.name string + // so dual-install / resetModules never break instanceof checks. + errorMapper: exceptionMap({ + NoSuchCustomerError: (err) => + new HttpError(404, { + error: { + code: "resource_missing", + message: err.message, + param: "id", + type: "invalid_request_error", + }, + }), + NoSuchChargeError: (err) => + new HttpError(404, { + error: { + code: "resource_missing", + message: err.message, + param: "id", + type: "invalid_request_error", + }, + }), + NoSuchPaymentIntentError: (err) => + new HttpError(404, { + error: { + code: "resource_missing", + message: err.message, + param: "id", + type: "invalid_request_error", + }, + }), + }), }); -app.generateAndIncludeVersionedRouters(router); +// Mount domain routes + the /versioning resource. tsadwyn accepts N +// VersionedRouters and merges them; both are versioned uniformly. +app.generateAndIncludeVersionedRouters(router, versioningRoutes); // ═══════════════════════════════════════════════════════════════════════════ // Start @@ -436,8 +601,46 @@ app.expressApp.listen(PORT, () => { console.log(`\n Stripe-like Payments API on http://localhost:${PORT}\n`); console.log(" Versions: 2024-01-15 │ 2024-06-01 │ 2024-11-01"); console.log(" Header: Stripe-Version: "); - console.log(" Docs: http://localhost:${PORT}/docs"); - console.log(" Changelog: http://localhost:${PORT}/changelog\n"); + console.log(` Docs: http://localhost:${PORT}/docs`); + console.log(` Changelog: http://localhost:${PORT}/changelog\n`); + + console.log(" Try Stripe's exact DELETE pattern:"); + console.log(` 1) POST /v1/customers → note the id`); + console.log(` 2) DELETE /v1/customers/ → 200 + {id, object, deleted}\n`); + + console.log(" Domain error → HttpError via exceptionMap (404 resource_missing):"); + console.log(` curl -s -w '\\n%{http_code}\\n' http://localhost:${PORT}/v1/customers/cus_does_not_exist \\`); + console.log(` -H 'stripe-version: 2024-11-01' | jq .\n`); + + console.log(" Self-service version upgrades via the /versioning resource:"); + console.log(` # 1) Read current pin — first read shows null (never pinned)`); + console.log(` curl -s http://localhost:${PORT}/versioning -H 'x-account-id: acct_demo' | jq .\n`); + console.log(` # 2) First-upgrade flow: from null → install initial pin`); + console.log(` curl -s -X POST http://localhost:${PORT}/versioning \\`); + console.log(` -H 'Content-Type: application/json' -H 'x-account-id: acct_demo' \\`); + console.log(` -d '{"from": null, "to": "2024-01-15"}' | jq .\n`); + console.log(` # 3) Now requests without stripe-version automatically use the pin`); + console.log(` curl -s -X POST http://localhost:${PORT}/v1/customers \\`); + console.log(` -H 'Content-Type: application/json' -H 'x-account-id: acct_demo' \\`); + console.log(` -d '{"email":"a@b.c","name":"A"}' | jq . # → account_balance shape (2024-01-15)\n`); + console.log(` # 4) Upgrade to middle version`); + console.log(` curl -s -X POST http://localhost:${PORT}/versioning \\`); + console.log(` -H 'Content-Type: application/json' -H 'x-account-id: acct_demo' \\`); + console.log(` -d '{"from": "2024-01-15", "to": "2024-06-01"}' | jq .\n`); + console.log(` # 5) Drift rejection — stale 'from' triggers 409`); + console.log(` curl -s -w '\\nstatus: %{http_code}\\n' -X POST http://localhost:${PORT}/versioning \\`); + console.log(` -H 'Content-Type: application/json' -H 'x-account-id: acct_demo' \\`); + console.log(` -d '{"from": "2024-01-15", "to": "2024-11-01"}' | jq . # actual is now 2024-06-01\n`); + console.log(` # 6) Downgrade blocked by default`); + console.log(` curl -s -w '\\nstatus: %{http_code}\\n' -X POST http://localhost:${PORT}/versioning \\`); + console.log(` -H 'Content-Type: application/json' -H 'x-account-id: acct_demo' \\`); + console.log(` -d '{"from": "2024-06-01", "to": "2024-01-15"}' | jq . # 400 downgrade-blocked\n`); + + console.log(" Introspection (in another shell):"); + console.log(` npx tsx src/cli.ts routes --app examples/stripe-api.ts --format table`); + console.log(` npx tsx src/cli.ts migrations --app examples/stripe-api.ts --schema CustomerResource --version 2024-01-15`); + console.log(` npx tsx src/cli.ts simulate --app examples/stripe-api.ts --method DELETE --path /v1/customers/cus_x --version 2024-06-01`); + console.log(` npx tsx src/cli.ts exceptions --app examples/stripe-api.ts --format table\n`); }); export { app }; diff --git a/examples/task-api.ts b/examples/task-api.ts index e94be83..a765ed9 100644 --- a/examples/task-api.ts +++ b/examples/task-api.ts @@ -1,5 +1,15 @@ /** - * Example: A task management API with 3 API versions. + * Example: A task management API with 3 API versions, demonstrating the + * full tsadwyn surface: + * + * • schema + endpoint DSL (request/response migrations, field renames) + * • exceptionMap + errorMapper — domain exceptions → HttpError + * • deletedResponseSchema — Stripe-style DELETE envelope + * • raw() — binary / CSV export + * • migratePayloadToVersion — outbound webhooks versioned to each + * subscriber's pinned version + * • buildBehaviorResolver — per-version feature flags + * • onUnsupportedVersion — strict 400 on bad x-api-version * * Run: npx tsx examples/task-api.ts * Test: curl commands are printed on startup @@ -17,6 +27,12 @@ import { convertResponseToPreviousVersionFor, RequestInfo, ResponseInfo, + HttpError, + exceptionMap, + deletedResponseSchema, + raw, + migratePayloadToVersion, + buildBehaviorResolver, } from "../src/index.js"; // --------------------------------------------------------------------------- @@ -44,6 +60,38 @@ const TaskList = z.object({ total: z.number(), }).named("TaskList"); +// Stripe-style DELETE envelope: { id, object: 'task', deleted: true } +// plus optional audit fields that appear only at the latest version. +const DeletedTask = deletedResponseSchema("task", { + deleted_at: z.string().optional(), + deleted_by: z.string().optional(), +}).named("DeletedTask"); + +// Webhook payload shape (sent to external subscribers per client pin). +const TaskCreatedWebhook = z.object({ + type: z.literal("task.created"), + data: TaskResource, + occurred_at: z.string(), +}).named("TaskCreatedWebhook"); + +// --------------------------------------------------------------------------- +// Domain exceptions — no HTTP semantics leak into service/model layers +// --------------------------------------------------------------------------- + +class TaskNotFoundError extends Error { + constructor(public readonly taskId: string) { + super(`Task "${taskId}" not found.`); + this.name = "TaskNotFoundError"; + } +} + +class TaskValidationError extends Error { + constructor(message: string, public readonly field: string) { + super(message); + this.name = "TaskValidationError"; + } +} + // --------------------------------------------------------------------------- // In-memory database // --------------------------------------------------------------------------- @@ -59,12 +107,70 @@ interface Task { const db: Record = {}; +// --------------------------------------------------------------------------- +// Per-version behavior flags — demonstrates buildBehaviorResolver +// --------------------------------------------------------------------------- + +interface TaskBehavior { + /** Whether notifications fire on task creation. v2024-03-01 only. */ + emitNotifications: boolean; + /** Webhook event format used when firing task.created. */ + webhookShape: "flat" | "envelope"; +} + +const behaviorMap = new Map([ + ["2024-03-01", { emitNotifications: true, webhookShape: "envelope" }], + ["2024-02-01", { emitNotifications: true, webhookShape: "flat" }], + ["2024-01-01", { emitNotifications: false, webhookShape: "flat" }], +]); + +const getBehavior = buildBehaviorResolver(behaviorMap, { + emitNotifications: true, + webhookShape: "envelope", +}); + +// --------------------------------------------------------------------------- +// Outbound webhook dispatcher (demonstrates migratePayloadToVersion) +// +// In real production this would write to a queue; here we just print. +// Each subscriber is pinned to an API version; the helper reshapes the +// webhook payload for their pin before delivery. +// --------------------------------------------------------------------------- + +const webhookSubscribers: Array<{ url: string; pinnedVersion: string }> = []; + +function registerWebhookSubscriber(url: string, pinnedVersion: string) { + webhookSubscribers.push({ url, pinnedVersion }); +} + +function emitTaskCreatedWebhook(task: Task) { + const headPayload = { + type: "task.created" as const, + data: task, + occurred_at: new Date().toISOString(), + }; + for (const sub of webhookSubscribers) { + const shaped = migratePayloadToVersion( + "TaskCreatedWebhook", + headPayload, + sub.pinnedVersion, + app.versions, + ); + // eslint-disable-next-line no-console + console.log(` → webhook ${sub.url} (pin=${sub.pinnedVersion}):`, JSON.stringify(shaped)); + } +} + // --------------------------------------------------------------------------- // Routes (latest version only — that's the whole point!) // --------------------------------------------------------------------------- const router = new VersionedRouter(); +// Register the webhook schema on the app.webhooks router so it appears +// in the per-version OpenAPI `webhooks:` section. +// (See `app.webhooks` registration at the bottom.) + router.post("/tasks", TaskCreate, TaskResource, async (req) => { const id = crypto.randomUUID(); const task: Task = { @@ -76,6 +182,12 @@ router.post("/tasks", TaskCreate, TaskResource, async (req) => { createdAt: new Date().toISOString(), }; db[id] = task; + + // Per-version behavior toggle: older versions didn't emit webhooks + if (getBehavior().emitNotifications) { + emitTaskCreatedWebhook(task); + } + return task; }); @@ -84,19 +196,53 @@ router.get("/tasks", null, TaskList, async () => { return { items, total: items.length }; }); +// CSV export using raw() — registered BEFORE the /:taskId wildcard so +// path-to-regexp's first-match-wins resolves to the literal. (Without +// this ordering, tsadwyn's generation-time lint warns about the +// wildcard-shadowing landmine.) Response migrations targeting this route +// would warn as dead code since the body is opaque bytes. +router.get( + "/tasks/export.csv", + null, + raw({ mimeType: "text/csv; charset=utf-8" }), + async () => { + const items = Object.values(db); + const lines = ["id,title,priority,assignees,createdAt"]; + for (const t of items) { + lines.push( + [t.id, JSON.stringify(t.title), t.priority, t.assignees.join(";"), t.createdAt].join(","), + ); + } + return Buffer.from(lines.join("\n"), "utf-8"); + }, +); + router.get("/tasks/:taskId", null, TaskResource, async (req) => { const task = db[req.params.taskId]; - if (!task) throw new Error("Task not found"); + if (!task) { + // Throw a DOMAIN exception — errorMapper converts to HttpError(404). + throw new TaskNotFoundError(req.params.taskId); + } return task; }); -router.delete("/tasks/:taskId", null, null, async (req) => { +// DELETE using the Stripe-style envelope. Note: no statusCode: 204 — +// the body MUST arrive on the wire (204 strips body per RFC 9110). +router.delete("/tasks/:taskId", null, DeletedTask, async (req) => { + const task = db[req.params.taskId]; + if (!task) throw new TaskNotFoundError(req.params.taskId); delete db[req.params.taskId]; - return { deleted: true }; + return { + id: req.params.taskId, + object: "task" as const, + deleted: true as const, + deleted_at: new Date().toISOString(), + deleted_by: "user:anonymous", + }; }); // --------------------------------------------------------------------------- -// Version Changes (using function-wrapper mode — no decorators needed) +// Version Changes (function-wrapper mode — no decorators needed) // --------------------------------------------------------------------------- /** @@ -112,7 +258,6 @@ class AddCriticalPriorityAndMultipleAssignees extends VersionChange { schema(TaskResource).field("assignees").had({ name: "assignee", type: z.string() }), ]; - // Function-wrapper mode: wrap the migration function directly migrateRequest = convertRequestToNextVersionFor(TaskCreate)( (request: RequestInfo) => { request.body.assignees = [request.body.assignee]; @@ -132,6 +277,34 @@ class AddCriticalPriorityAndMultipleAssignees extends VersionChange { } }, ); + + // Previous version's DELETE envelope didn't include the audit fields. + // Strip them for initial-version clients. + migrateDeletedTask = convertResponseToPreviousVersionFor(DeletedTask)( + (response: ResponseInfo) => { + if (response.body) { + delete response.body.deleted_at; + delete response.body.deleted_by; + } + }, + ); + + // Outbound webhook payload: v2024-02-01 subscribers get the envelope + // form but with the flat task resource (no multi-assignee array). + // Webhook schema isn't a response of any mounted route — it's dispatched + // via migratePayloadToVersion(). checkUsage: false opts out of the usage + // lint (tsadwyn can't see the outbound emission path statically). + migrateWebhook = convertResponseToPreviousVersionFor(TaskCreatedWebhook, { checkUsage: false })( + (response: ResponseInfo) => { + if (response.body?.data?.assignees) { + response.body.data.assignee = response.body.data.assignees[0]; + delete response.body.data.assignees; + } + if (response.body?.data?.priority === "critical") { + response.body.data.priority = "high"; + } + }, + ); } /** @@ -146,8 +319,8 @@ class AddDescription extends VersionChange { ]; migrateRequest = convertRequestToNextVersionFor(TaskCreate)( - (request: RequestInfo) => { - // Old version doesn't send description — leave it undefined so .optional() passes + (_request: RequestInfo) => { + // Initial version doesn't send description — .optional() allows that. }, ); @@ -156,6 +329,19 @@ class AddDescription extends VersionChange { delete response.body.description; }, ); + + // Initial-version webhook subscribers get a flat task without + // description (in addition to the single-assignee migration above). + // Webhook schema isn't a response of any mounted route — it's dispatched + // via migratePayloadToVersion(). checkUsage: false opts out of the usage + // lint (tsadwyn can't see the outbound emission path statically). + migrateWebhook = convertResponseToPreviousVersionFor(TaskCreatedWebhook, { checkUsage: false })( + (response: ResponseInfo) => { + if (response.body?.data) { + delete response.body.data.description; + } + }, + ); } // --------------------------------------------------------------------------- @@ -170,10 +356,47 @@ const app = new Tsadwyn({ ), title: "Task Management API", apiVersionHeaderName: "x-api-version", + + // errorMapper: domain exceptions → HttpError — handlers throw + // TaskNotFoundError and get a clean 404 with a structured body. + // Keyed by err.name string (survives module-boundary identity drift). + errorMapper: exceptionMap({ + TaskNotFoundError: (err) => + new HttpError(404, { + code: "task_not_found", + message: err.message, + task_id: (err as TaskNotFoundError).taskId, + }), + TaskValidationError: (err) => + new HttpError(400, { + code: "validation_error", + message: err.message, + field: (err as TaskValidationError).field, + }), + }), }); +// Register the webhook schema so the OpenAPI `webhooks:` section reflects +// per-version shapes. Webhook routes are documentation-only — they don't +// get mounted as HTTP endpoints; the `migratePayloadToVersion` helper is +// what actually shapes outbound payloads at dispatch time. +app.webhooks.post( + "task.created", + TaskCreatedWebhook, + null, + async () => { + // no-op — webhooks are documented, not served + }, +); + app.generateAndIncludeVersionedRouters(router); +// Register a couple of demo subscribers pinned to different versions. +// In real production these come from a database. +registerWebhookSubscriber("https://example.com/hooks/initial-version", "2024-01-01"); +registerWebhookSubscriber("https://example.com/hooks/middle-version", "2024-02-01"); +registerWebhookSubscriber("https://example.com/hooks/latest", "2024-03-01"); + // --------------------------------------------------------------------------- // Start server // --------------------------------------------------------------------------- @@ -187,30 +410,51 @@ app.expressApp.listen(PORT, () => { console.log("--- Try these curl commands ---"); console.log(); - console.log("# v2024-01-01 (oldest): no description, single assignee, no critical priority"); + console.log("# Initial version (2024-01-01): no description, single assignee, no critical priority"); console.log(`curl -s -X POST http://localhost:${PORT}/tasks \\`); console.log(` -H "Content-Type: application/json" \\`); console.log(` -H "x-api-version: 2024-01-01" \\`); console.log(` -d '{"title":"Fix login bug","priority":"high","assignee":"alice"}' | jq .`); console.log(); - console.log("# v2024-02-01 (middle): has description, single assignee, no critical"); + console.log("# Previous version (2024-02-01): has description, single assignee, no critical"); console.log(`curl -s -X POST http://localhost:${PORT}/tasks \\`); console.log(` -H "Content-Type: application/json" \\`); console.log(` -H "x-api-version: 2024-02-01" \\`); console.log(` -d '{"title":"Add dark mode","description":"Users want dark mode","priority":"medium","assignee":"bob"}' | jq .`); console.log(); - console.log("# v2024-03-01 (latest): has description, multiple assignees, has critical"); + console.log("# Latest version (2024-03-01): description, multiple assignees, critical priority"); console.log(`curl -s -X POST http://localhost:${PORT}/tasks \\`); console.log(` -H "Content-Type: application/json" \\`); console.log(` -H "x-api-version: 2024-03-01" \\`); console.log(` -d '{"title":"Security patch","description":"Critical CVE","priority":"critical","assignees":["alice","bob"]}' | jq .`); console.log(); - console.log("# List all tasks (try with different versions to see different shapes)"); + console.log("# Domain exception → HttpError via exceptionMap (404 'task_not_found')"); + console.log(`curl -s -w '\\nstatus: %{http_code}\\n' http://localhost:${PORT}/tasks/does-not-exist -H "x-api-version: 2024-03-01" | jq .`); + console.log(); + + console.log("# DELETE: Stripe-style envelope at 200 + body (audit fields only in latest)"); + console.log(`# 1) Create a task first, capture id, then:`); + console.log(`curl -s -X DELETE http://localhost:${PORT}/tasks/ -H "x-api-version: 2024-03-01" | jq .`); + console.log(`curl -s -X DELETE http://localhost:${PORT}/tasks/ -H "x-api-version: 2024-01-01" | jq . # no audit fields`); + console.log(); + + console.log("# raw() binary export as CSV — content-type text/csv on the wire"); + console.log(`curl -s -D - http://localhost:${PORT}/tasks/export.csv -H "x-api-version: 2024-03-01"`); + console.log(); + + console.log("# List all tasks (try different versions to see shape changes)"); console.log(`curl -s http://localhost:${PORT}/tasks -H "x-api-version: 2024-01-01" | jq .`); console.log(`curl -s http://localhost:${PORT}/tasks -H "x-api-version: 2024-03-01" | jq .`); + console.log(); + + console.log("# Introspection (in another shell):"); + console.log(`npx tsx src/cli.ts routes --app examples/task-api.ts --format table`); + console.log(`npx tsx src/cli.ts migrations --app examples/task-api.ts --schema TaskResource --version 2024-01-01`); + console.log(`npx tsx src/cli.ts simulate --app examples/task-api.ts --method GET --path /tasks/abc --version 2024-01-01`); + console.log(`npx tsx src/cli.ts exceptions --app examples/task-api.ts --format table`); }); export { app }; diff --git a/src/application.ts b/src/application.ts index 0879d79..b533a57 100644 --- a/src/application.ts +++ b/src/application.ts @@ -20,12 +20,62 @@ import { ZodSchemaRegistry, generateVersionedSchemas } from "./schema-generation import { renderDocsDashboard, renderSwaggerUI, renderRedocUI, DEFAULT_ASSET_URLS } from "./docs.js"; import type { DocsAssetUrls } from "./docs.js"; import { RootTsadwynRouter } from "./routing.js"; +import { + detectRouteShadows, + reportRouteShadows, + type RouteShadowingPolicy, + type RouteShadowingLogger, +} from "./route-shadowing.js"; +import { requestContextStorage } from "./request-context.js"; +import { getSchemaName } from "./zod-extend.js"; /** * Regex for validating ISO date strings (YYYY-MM-DD). */ const _ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/; +/** + * Module-scoped registry of Tsadwyn instances that asked for shutdown + * handling. On SIGTERM / SIGINT we drain all of them in parallel and + * then exit, rather than letting each instance attach its own process + * listener (which leaks listeners across instances in test suites and + * multi-tenant deployments — Node emits MaxListenersExceededWarning at + * ~11 and the shared exit step races between the per-instance handlers). + * + * Instances are registered on construction when an `onShutdown` hook is + * supplied and unregistered via `Tsadwyn#close()` (useful for test + * teardowns). The process listener itself is installed exactly once + * across the whole process lifetime. + */ +const _tsadwynActiveInstances = new Set(); +let _tsadwynSignalHandlerInstalled = false; + +function _installTsadwynSignalHandlerOnce(): void { + if (_tsadwynSignalHandlerInstalled) return; + _tsadwynSignalHandlerInstalled = true; + const handler = () => { + const toDrain = [..._tsadwynActiveInstances]; + if (toDrain.length === 0) { + process.exit(0); + return; + } + const pending = toDrain.map((inst) => { + try { + const result = inst._runOnShutdownHook(); + return Promise.resolve(result); + } catch (err) { + return Promise.reject(err); + } + }); + Promise.allSettled(pending).then((results) => { + const anyFailed = results.some((r) => r.status === "rejected"); + process.exit(anyFailed ? 1 : 0); + }); + }; + process.on("SIGTERM", handler); + process.on("SIGINT", handler); +} + /** * Check if a string is a valid ISO date (YYYY-MM-DD) that represents a real calendar date. */ @@ -77,6 +127,25 @@ export interface TsadwynOptions { */ versioningMiddleware?: (req: Request, res: Response, next: NextFunction) => void; + /** + * Consumer middleware that runs BEFORE versionPickingMiddleware. Useful for + * authentication or other request enrichment that must happen before the + * default-version resolver runs — e.g., an `apiVersionDefaultValue` + * implemented via `perClientDefaultVersion()` needs `req.user` to be + * populated by upstream auth. + * + * Scope: only runs for requests that will reach versioned dispatch — utility + * endpoints (OpenAPI JSON, docs, redoc, changelog) bypass this hook. + * + * Mutually exclusive with `versioningMiddleware`. The constructor throws + * `TsadwynStructureError` if both are supplied (when you own the full + * picker, there's no built-in pick to run before). + * + * Inside this middleware, `apiVersionStorage.getStore()` is undefined — + * the version hasn't been resolved yet. + */ + preVersionPick?: (req: Request, res: Response, next: NextFunction) => void; + /** Application title used in OpenAPI docs. */ title?: string; /** Application description used in OpenAPI docs. */ @@ -137,6 +206,61 @@ export interface TsadwynOptions { * Default: false */ separateInputOutputSchemas?: boolean; + + /** + * Pure function invoked inside each versioned handler's catch block BEFORE + * tsadwyn's HTTP-likeness check. Lets consumers translate domain exceptions + * (which don't carry HTTP semantics) into `HttpError` so they flow through + * the response-migration pipeline. + * + * Return `HttpError` to short-circuit the handler with that status + body. + * Return `null` to preserve the existing `next(err)` fall-through. A + * throwing mapper does NOT crash tsadwyn — the original error is passed + * to `next(err)` and Express's default error handler takes over (500). + * + * Pairs with `exceptionMap()` for a declarative, introspectable map form. + */ + errorMapper?: (err: unknown) => import("./exceptions.js").HttpError | null; + + /** + * Policy applied when an incoming `X-Api-Version` header doesn't match + * any value in the `VersionBundle`. Delegated to `versionPickingMiddleware`. + * + * - `'reject'` — respond 400 with `{error: 'unsupported_api_version', + * sent, supported}` immediately. Stripe-style. + * - `'fallback'` — substitute `apiVersionDefaultValue` and emit a + * structured warn via `versionPickingLogger`. + * - `'passthrough'` (default) — store the verbatim string and let the + * downstream dispatcher 422 it. Preserves + * historical behavior. + */ + onUnsupportedVersion?: "reject" | "fallback" | "passthrough"; + + /** + * Structured logger passed to `versionPickingMiddleware` — used when + * `onUnsupportedVersion: 'fallback'` substitutes the default version. + */ + versionPickingLogger?: { + warn: (ctx: Record, msg: string) => void; + }; + + /** + * Policy applied when a parameterized route (e.g. `/users/:id`) is + * registered before a literal route it would shadow (e.g. `/users/search`). + * path-to-regexp is first-match-wins, so the literal path never receives + * traffic — an easy and costly production bug. + * + * - `'warn'` (default) — emit one log line per shadow via + * `routeShadowingLogger` or `console.warn`. + * - `'throw'` — surface as `TsadwynStructureError` during + * `generateAndIncludeVersionedRouters()`. Best + * for CI enforcement on new apps. + * - `'silent'` — suppress the diagnostic entirely. + */ + onRouteShadowing?: RouteShadowingPolicy; + + /** Structured logger used for route-shadowing warns. */ + routeShadowingLogger?: RouteShadowingLogger; } /** @@ -217,6 +341,26 @@ export class Tsadwyn { /** T-2203: Separate input/output schemas flag. */ separateInputOutputSchemas: boolean; + /** Domain exception → HttpError translator. Invoked in handler catch blocks. */ + _errorMapper: ((err: unknown) => import("./exceptions.js").HttpError | null) | null; + + /** + * Policy for shadowed-route diagnostic: + * - 'warn' (default): emit one log line per shadow via `routeShadowingLogger` or console.warn + * - 'throw': refuse to initialize; `TsadwynStructureError` is thrown + * - 'silent': no-op (suppress all shadow reporting) + */ + _onRouteShadowing: RouteShadowingPolicy; + /** Structured logger for route-shadowing warns. Ignored when policy !== 'warn'. */ + _routeShadowingLogger: RouteShadowingLogger | undefined; + + /** Policy for unknown `X-Api-Version` header values. */ + _onUnsupportedVersion: "reject" | "fallback" | "passthrough" | undefined; + /** Structured logger used by `versionPickingMiddleware`. */ + _versionPickingLogger: + | { warn: (ctx: Record, msg: string) => void } + | undefined; + /** * Access the internal versioned routers map. * Used by the CLI and for introspection. @@ -265,6 +409,17 @@ export class Tsadwyn { // T-2203: Separate input/output schemas this.separateInputOutputSchemas = options.separateInputOutputSchemas ?? false; + // Error mapper (domain exceptions → HttpError inside handler catch blocks) + this._errorMapper = options.errorMapper ?? null; + + // Route-shadowing diagnostic policy (default: warn) + this._onRouteShadowing = options.onRouteShadowing ?? "warn"; + this._routeShadowingLogger = options.routeShadowingLogger; + + // Unsupported-version header policy (default: passthrough — historical) + this._onUnsupportedVersion = options.onUnsupportedVersion; + this._versionPickingLogger = options.versionPickingLogger; + // T-1003: Validate version format and ordering this._validateVersionFormat(); @@ -296,36 +451,92 @@ export class Tsadwyn { this.expressApp = express(); this.expressApp.use(express.json()); + // Mutual-exclusion check: preVersionPick only makes sense when the + // built-in picker is in use; versioningMiddleware is a full override. + if (options.preVersionPick && this._customVersioningMiddleware) { + throw new TsadwynStructureError( + "preVersionPick cannot be combined with versioningMiddleware. " + + "When you supply a full versioningMiddleware override, it replaces " + + "the built-in version picker entirely — there's no built-in pick to " + + "run before. Merge your preVersionPick logic into the custom " + + "versioningMiddleware instead.", + ); + } + // Set up version picking middleware if (this._customVersioningMiddleware) { this.expressApp.use(this._customVersioningMiddleware); } else { + // preVersionPick (if supplied) runs BEFORE versionPickingMiddleware so + // async enrichment (auth, tenant resolution) happens before the + // default-version resolver. Utility endpoints (OpenAPI, docs) are + // excluded via path-check — they read version from query/header + // directly and don't need the hook. + if (options.preVersionPick) { + const preHook = options.preVersionPick; + const utilityPaths = [ + this.openApiUrl, + this.docsUrl, + this.redocUrl, + this.changelogUrl, + ].filter((p): p is string => typeof p === "string"); + this.expressApp.use((req: Request, res: Response, next: NextFunction) => { + const path = req.path; + if ( + utilityPaths.some( + (p) => path === p || path.startsWith(p + "/"), + ) + ) { + return next(); + } + preHook(req, res, next); + }); + } const pickingOpts: VersionPickingOptions = { headerName: this.apiVersionHeaderName, apiVersionLocation: this.apiVersionLocation, apiVersionDefaultValue: this.apiVersionDefaultValue, versionValues: this.versions.versionValues, }; + if (this._onUnsupportedVersion !== undefined) { + pickingOpts.onUnsupportedVersion = this._onUnsupportedVersion; + } + if (this._versionPickingLogger) { + pickingOpts.logger = this._versionPickingLogger; + } this.expressApp.use(versionPickingMiddleware(pickingOpts)); } this._mountUtilityEndpoints(); - // T-2202: Register shutdown hooks + // T-2202: Register shutdown hooks. Uses a module-scoped instance set + // + a single shared SIGTERM/SIGINT handler so listener count doesn't + // grow with instance count. if (this._onShutdown) { - const shutdownHandler = () => { - const result = this._onShutdown!(); - if (result && typeof (result as Promise).then === "function") { - (result as Promise).then(() => process.exit(0)).catch(() => process.exit(1)); - } else { - process.exit(0); - } - }; - process.on("SIGTERM", shutdownHandler); - process.on("SIGINT", shutdownHandler); + _tsadwynActiveInstances.add(this); + _installTsadwynSignalHandlerOnce(); } } + /** + * Runs the configured `onShutdown` hook. Public-internal helper used + * by the shared signal handler — you don't normally call this from + * consumer code. + */ + _runOnShutdownHook(): void | Promise { + if (!this._onShutdown) return; + return this._onShutdown(); + } + + /** + * Deregister this instance from the module-scoped shutdown set. Call + * from test teardowns so subsequent test instances don't share + * shutdown callbacks with this one. Safe to call multiple times. + */ + close(): void { + _tsadwynActiveInstances.delete(this); + } + /** * Mount utility endpoints (OpenAPI, docs, redoc, changelog) on the Express app. * These are mounted before versioned routers so they take priority. @@ -467,31 +678,47 @@ export class Tsadwyn { /** * Wrap a route handler to check dependencyOverrides before calling. + * Captures the raw Express Request into `requestContextStorage` so + * handlers (and any awaited helpers) can call `currentRequest()` + * identically on versioned + unversioned routes — the versioned + * dispatcher in route-generation.ts does the same wrap. */ private _wrapHandlerWithOverrides(routeDef: RouteDefinition): (req: Request, res: Response, next: NextFunction) => void { const successStatus = routeDef.statusCode ?? 200; - return async (req: Request, res: Response, next: NextFunction) => { - try { - const effectiveHandler = this.dependencyOverrides.get(routeDef.handler) as - | typeof routeDef.handler - | undefined; - const handler = effectiveHandler || routeDef.handler; - - const handlerReq = { - body: req.body, - params: req.params, - query: req.query, - headers: req.headers, - }; - - const result = await handler(handlerReq); - res.status(successStatus).json(result); - } catch (err) { - next(err); - } + return (req: Request, res: Response, next: NextFunction) => { + requestContextStorage.run(req, () => { + void this._dispatchUnversionedHandler(routeDef, successStatus, req, res, next); + }); }; } + private async _dispatchUnversionedHandler( + routeDef: RouteDefinition, + successStatus: number, + req: Request, + res: Response, + next: NextFunction, + ): Promise { + try { + const effectiveHandler = this.dependencyOverrides.get(routeDef.handler) as + | typeof routeDef.handler + | undefined; + const handler = effectiveHandler || routeDef.handler; + + const handlerReq = { + body: req.body, + params: req.params, + query: req.query, + headers: req.headers, + }; + + const result = await handler(handlerReq); + res.status(successStatus).json(result); + } catch (err) { + next(err); + } + } + /** * Perform lazy initialization: generate versioned routers from pending routers. */ @@ -511,11 +738,17 @@ export class Tsadwyn { // Store routes for OpenAPI generation this._routes = mergedRouter.routes; + // Scan for route-shadowing before binding — catches :id-then-literal + // mistakes while the user can still see them. Policy governs severity. + const shadows = detectRouteShadows(mergedRouter.routes); + reportRouteShadows(shadows, this._onRouteShadowing, this._routeShadowingLogger); + const generatedRouters = generateVersionedRouters( mergedRouter, this.versions, this.dependencyOverrides, this.webhooks.routes.length > 0 ? this.webhooks.routes : undefined, + this._errorMapper ?? undefined, ); // Store per-version routes for OpenAPI generation @@ -540,9 +773,17 @@ export class Tsadwyn { this._initialized = true; this._pendingRouters = null; - // T-2202: Call onStartup hook at the end of initialization + // T-2202: Call onStartup hook at the end of initialization. + // Wrap via Promise.resolve so a sync-throwing or async-rejecting hook + // doesn't escape as an unhandled rejection (which Node 20+ terminates + // the process on by default). Matches onShutdown's handling below. if (this._onStartup) { - this._onStartup(); + Promise.resolve() + .then(() => this._onStartup!()) + .catch((err: unknown) => { + // eslint-disable-next-line no-console + console.error("[tsadwyn] onStartup hook rejected:", err); + }); } } @@ -708,21 +949,24 @@ export class Tsadwyn { /** * Build a ZodSchemaRegistry from route definitions. + * + * Goes through `getSchemaName()` rather than reading `._tsadwynName` + * directly. The direct-property path silently drops schemas when the + * legacy prop is absent (e.g., a downstream serializer that cleared + * non-enumerable properties) — `getSchemaName` also falls back to the + * WeakMap registry so the canonical schema→name binding is honored + * wherever it lives. CLAUDE.md states this invariant explicitly. */ private _buildRegistryFromRoutes(routes: RouteDefinition[]): ZodSchemaRegistry { const registry = new ZodSchemaRegistry(); for (const route of routes) { - if (route.requestSchema && (route.requestSchema as any)._tsadwynName) { - registry.register( - (route.requestSchema as any)._tsadwynName, - route.requestSchema, - ); + const reqName = getSchemaName(route.requestSchema); + if (route.requestSchema && reqName) { + registry.register(reqName, route.requestSchema); } - if (route.responseSchema && (route.responseSchema as any)._tsadwynName) { - registry.register( - (route.responseSchema as any)._tsadwynName, - route.responseSchema, - ); + const resName = getSchemaName(route.responseSchema); + if (route.responseSchema && resName) { + registry.register(resName, route.responseSchema); } } diff --git a/src/behavior-resolver.ts b/src/behavior-resolver.ts new file mode 100644 index 0000000..8c08729 --- /dev/null +++ b/src/behavior-resolver.ts @@ -0,0 +1,85 @@ +/** + * `buildBehaviorResolver` — standardize the per-version behavior-map fallback + * that every tsadwyn adopter rolls by hand. Closes the "consumer writes the + * same 3-line function" gap identified in production. + */ + +import { apiVersionStorage } from "./middleware.js"; +import { TsadwynStructureError } from "./exceptions.js"; + +export interface BuildBehaviorResolverOptions { + /** + * Telemetry policy for unknown-version lookups. Default: 'silent'. + * - 'silent' — never warn. + * - 'warn-once' — warn exactly once per unique unknown version string. + * - 'warn-every' — warn on every unknown lookup. + */ + onUnknown?: "silent" | "warn-once" | "warn-every"; + /** + * Structured logger. **Required** when `onUnknown !== 'silent'` — the + * builder throws at construction if you ask for warnings without + * providing somewhere to send them. This prevents the silent-no-op + * footgun where a caller opts into telemetry, forgets the logger, and + * wonders why no warnings appear. + */ + logger?: { + warn: (ctx: Record, msg: string) => void; + }; +} + +/** + * Build a resolver that returns the per-version behavior for the current + * request, falling back to `fallback` when the version is unknown or absent. + * + * The resolver reads the version from `apiVersionStorage`, so it MUST be + * called inside a request scope (inside `versionPickingMiddleware.run()`). + * When no version is in storage (e.g., unversioned paths), `fallback` is + * returned silently regardless of `onUnknown` — absence is not an error. + * + * Throws `TsadwynStructureError` at construction if `onUnknown` is + * `'warn-once'` or `'warn-every'` without a `logger` — opt-in telemetry + * with no sink is always a bug. + */ +export function buildBehaviorResolver( + map: ReadonlyMap, + fallback: B, + opts: BuildBehaviorResolverOptions = {}, +): () => B { + const onUnknown = opts.onUnknown ?? "silent"; + const logger = opts.logger; + + if (onUnknown !== "silent" && !logger) { + throw new TsadwynStructureError( + `buildBehaviorResolver: onUnknown: "${onUnknown}" requires a logger. ` + + `Either pass a logger ({ warn: (ctx, msg) => void }) or set ` + + `onUnknown: "silent" to opt out of telemetry.`, + ); + } + const warned = new Set(); + // Snapshot the supported list once for warning context. Callers that add + // entries to the map after construction will see a stale list — documented. + const supportedVersions = [...map.keys()]; + + return function resolve(): B { + const version = apiVersionStorage.getStore(); + if (version === null || version === undefined) { + return fallback; + } + if (map.has(version)) { + return map.get(version)!; + } + if (onUnknown !== "silent" && logger) { + const shouldWarn = + onUnknown === "warn-every" || + (onUnknown === "warn-once" && !warned.has(version)); + if (shouldWarn) { + if (onUnknown === "warn-once") warned.add(version); + logger.warn( + { version, supportedVersions }, + `Unknown API version "${version}"; using fallback behavior.`, + ); + } + } + return fallback; + }; +} diff --git a/src/cached-per-client-default.ts b/src/cached-per-client-default.ts new file mode 100644 index 0000000..bc89dc5 --- /dev/null +++ b/src/cached-per-client-default.ts @@ -0,0 +1,215 @@ +/** + * `cachedPerClientDefaultVersion` — TTL-cached variant of + * `perClientDefaultVersion` with explicit invalidation handles. + * + * `perClientDefaultVersion` calls `resolvePin` on every unauthenticated- + * default request. For high-traffic APIs with DB-backed pin storage, that + * becomes N queries per second. The fix is a cross-request cache keyed by + * client id — but caching needs invalidation: when a client hits the + * upgrade endpoint and the stored pin changes, the cache entry must drop or + * subsequent requests continue seeing the old pin until TTL. + * + * This helper exposes `{ resolver, invalidate, invalidateAll }` so the + * upgrade endpoint can wire invalidation explicitly. Stripe-style + * `pinOnFirstResolve` is honored and the newly-persisted pin is written to + * the cache so the next request skips storage entirely. + * + * Cache policy: + * - In-memory `Map`. + * - TTL-based expiry (default 5min). Stale entries are re-resolved on + * access; successful re-resolution replaces the entry. + * - Single-flight: concurrent first-misses share one `Promise` so + * `resolvePin` is called exactly once. + * - Errors bypass the cache (next request retries) — matches the + * precedent set by production adopters. + * + * When the cache is hit, neither `resolvePin` nor `pinOnFirstResolve`'s + * `saveVersion` runs — the cached value is returned directly. First-miss + * after `invalidate(clientId)` behaves like a brand-new client. + */ + +import type { Request } from "express"; +import { TsadwynStructureError } from "./exceptions.js"; + +export interface CachedPerClientDefaultVersionOptions { + /** Extract a stable client identifier from the request. Return null for unknown. */ + identify: (req: Request) => string | null | Promise; + /** Look up the client's stored pin. Return null if none. */ + resolvePin: (clientId: string) => string | null | Promise; + /** Value returned when identity is unknown or no pin is stored. Required. */ + fallback: string; + /** + * Stale-pin policy; see `perClientDefaultVersion` for semantics. Default: 'fallback'. + * + * **Interaction with caching:** all three modes that *succeed* (return a + * string) get cached for `ttlMs` — so a stale pin resolved via + * `'fallback'` is cached at the fallback value, and `'passthrough'` is + * cached at the stored stale value. **`'reject'` throws from + * `resolvePin`**, and per the rejection-bypass policy every request + * for a stale-pinned client hammers `resolvePin` again (no back-off, + * no negative caching). That's intentional — if the pin's truly + * invalid, you want to know about it on every call, not be silenced + * for `ttlMs`. If that's a problem for your traffic shape, pick a + * different stale policy or narrow `supportedVersions` to accept the + * value. + */ + onStalePin?: "fallback" | "passthrough" | "reject"; + /** Enables the stale-pin check against the VersionBundle. */ + supportedVersions?: readonly string[]; + /** Persist the client's pin. Required when `pinOnFirstResolve: true`. */ + saveVersion?: (clientId: string, version: string) => void | Promise; + /** + * Stripe-style "pin-on-first-call" semantic; see `perClientDefaultVersion`. + * Triggers when a genuinely-unpinned client hits the resolver. The + * persisted pin is written to the cache so subsequent requests skip storage. + */ + pinOnFirstResolve?: boolean; + /** Optional structured logger for telemetry. */ + logger?: { + warn: (ctx: Record, msg: string) => void; + }; + /** + * Cache TTL in milliseconds. Default: 5 * 60 * 1000 (5 minutes). A TTL of + * 0 disables caching (each request re-resolves). Negative values throw. + */ + ttlMs?: number; +} + +export interface CachedPerClientDefaultVersion { + /** The resolver — pass to `versionPickingMiddleware.apiVersionDefaultValue`. */ + resolver: (req: Request) => Promise; + /** Drop the cached pin for one client. Call this from your upgrade handler. */ + invalidate: (clientId: string) => void; + /** Drop every cached pin. Use for rolling deploys / test teardowns. */ + invalidateAll: () => void; +} + +interface CacheEntry { + promise: Promise; + cachedAt: number; + // If the underlying resolve rejects, we still let the promise reject — + // but we bypass caching the rejection so the NEXT access retries fresh. + // Tracked via a flag so we can drop the entry on rejection. + settled: "pending" | "fulfilled" | "rejected"; +} + +export function cachedPerClientDefaultVersion( + opts: CachedPerClientDefaultVersionOptions, +): CachedPerClientDefaultVersion { + if (opts.pinOnFirstResolve && typeof opts.saveVersion !== "function") { + throw new TsadwynStructureError( + "cachedPerClientDefaultVersion: pinOnFirstResolve requires a saveVersion callback.", + ); + } + + const ttlMs = opts.ttlMs ?? 5 * 60 * 1000; + if (ttlMs < 0) { + throw new TsadwynStructureError( + `cachedPerClientDefaultVersion: ttlMs must be >= 0 (got ${ttlMs}).`, + ); + } + + const cache = new Map(); + + async function doResolve(clientId: string): Promise { + const pin = await Promise.resolve(opts.resolvePin(clientId)); + if (pin === null || pin === undefined) { + if (opts.pinOnFirstResolve && opts.saveVersion) { + opts.logger?.warn( + { clientId, pin: opts.fallback, reason: "pin-on-first-resolve" }, + `Pinning client "${clientId}" to "${opts.fallback}" on first authenticated call.`, + ); + await Promise.resolve(opts.saveVersion(clientId, opts.fallback)); + } else { + opts.logger?.warn( + { clientId, reason: "no-stored-pin" }, + `No stored pin for client "${clientId}"; using fallback.`, + ); + } + return opts.fallback; + } + if (opts.supportedVersions && !opts.supportedVersions.includes(pin)) { + const stalePolicy = opts.onStalePin ?? "fallback"; + if (stalePolicy === "reject") { + throw new TsadwynStructureError( + `Stored API version pin "${pin}" for client "${clientId}" is not in the current VersionBundle.`, + ); + } + if (stalePolicy === "fallback") { + opts.logger?.warn( + { + pin, + reason: "stale", + clientId, + supportedVersions: [...opts.supportedVersions], + }, + `Stored pin "${pin}" is not in the bundle; using fallback.`, + ); + return opts.fallback; + } + return pin; + } + return pin; + } + + function getOrCreate(clientId: string): Promise { + const now = Date.now(); + const existing = cache.get(clientId); + // Cache hit + fresh: return the cached promise directly. + if (existing) { + const age = now - existing.cachedAt; + if (existing.settled !== "rejected" && (ttlMs === 0 ? false : age < ttlMs)) { + return existing.promise; + } + // Stale or rejected — fall through and re-create. + cache.delete(clientId); + } + // Create a new entry, tracking settlement so rejections don't poison + // the cache for the full TTL. This is a deliberate contract — callers + // using `onStalePin: 'reject'` rely on it: every request for a + // stale-pinned client re-hits resolvePin, surfacing the misconfig on + // every call rather than getting silenced for ttlMs. See the test + // "Finding #8" in tests/reviewer-findings.test.ts for the lock-in. + // Do NOT "optimize" by caching rejections — it breaks that contract. + const entry: CacheEntry = { + promise: doResolve(clientId), + cachedAt: now, + settled: "pending", + }; + entry.promise.then( + () => { + entry.settled = "fulfilled"; + }, + () => { + entry.settled = "rejected"; + cache.delete(clientId); + }, + ); + if (ttlMs > 0) { + cache.set(clientId, entry); + } + return entry.promise; + } + + async function resolver(req: Request): Promise { + const clientId = await Promise.resolve(opts.identify(req)); + if (clientId === null || clientId === undefined) { + opts.logger?.warn( + { reason: "unauthenticated" }, + "No client identity for default-version resolution; using fallback.", + ); + return opts.fallback; + } + return getOrCreate(clientId); + } + + return { + resolver, + invalidate: (clientId: string) => { + cache.delete(clientId); + }, + invalidateAll: () => { + cache.clear(); + }, + }; +} diff --git a/src/cli.ts b/src/cli.ts index 95d140b..c919db2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -25,6 +25,11 @@ import { pathToFileURL } from "node:url"; import { resolve, join, dirname } from "node:path"; import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { isExceptionMapFn, type ExceptionMapEntry } from "./exception-map.js"; +import { dumpRouteTable, type RouteTableEntry } from "./route-table.js"; +import { inspectMigrationChain, type MigrationChainEntry } from "./migration-chain.js"; +import { simulateRoute, type SimulationResult } from "./route-simulation.js"; + /** * The version string reported by `tsadwyn --version` / `tsadwyn -V`. * Kept in sync with package.json's `version` field. @@ -42,6 +47,17 @@ export interface CommandResult { output: string[]; } +/** + * Alternative command-result shape for subcommands that emit tabular/JSON + * payloads and should distinguish between the primary output stream + * (stdout — the rendered data) and diagnostic / error output (stderr). + */ +export interface StreamedCommandResult { + exitCode: number; + stdout: string; + stderr: string; +} + /** * Dynamically import a user's app module from the given path. * @@ -796,6 +812,511 @@ function emitResult(cmd: Command, result: CommandResult, errorCode: string): voi } } +// ───────────────────────────────────────────────────────────────────────── +// `routes` — enumerate the registered route table per version +// ───────────────────────────────────────────────────────────────────────── + +export interface RoutesOptions { + app: string; + version?: string; + method?: string; + pathMatches?: string; + includePrivate?: boolean; + format?: "table" | "json" | "markdown"; +} + +function renderRouteTable( + entries: ReadonlyArray, + format: "table" | "json" | "markdown", +): string { + if (format === "json") return JSON.stringify(entries, null, 2); + + const rows = entries.map((e) => ({ + version: e.version, + method: e.method, + path: e.path, + handler: e.handlerName ?? "", + status: String(e.statusCode), + response: e.responseSchemaName ?? "-", + })); + const headers = ["Version", "Method", "Path", "Handler", "Status", "Response"]; + + if (format === "markdown") { + const lines: string[] = []; + lines.push(`| ${headers.join(" | ")} |`); + lines.push(`| ${headers.map(() => "---").join(" | ")} |`); + for (const r of rows) { + lines.push( + `| ${r.version} | ${r.method} | ${r.path} | ${r.handler} | ${r.status} | ${r.response} |`, + ); + } + return lines.join("\n"); + } + + const cols = [ + { key: "version", label: headers[0] }, + { key: "method", label: headers[1] }, + { key: "path", label: headers[2] }, + { key: "handler", label: headers[3] }, + { key: "status", label: headers[4] }, + { key: "response", label: headers[5] }, + ] as const; + const widths = cols.map((c) => + Math.max(c.label.length, ...rows.map((r) => (r as any)[c.key].length)), + ); + const pad = (s: string, w: number) => s + " ".repeat(Math.max(0, w - s.length)); + const sep = "+-" + widths.map((w) => "-".repeat(w)).join("-+-") + "-+"; + const render = (values: string[]) => + "| " + values.map((v, i) => pad(v, widths[i])).join(" | ") + " |"; + + const lines: string[] = []; + lines.push(`Routes (${entries.length})`); + lines.push(""); + lines.push(sep); + lines.push(render(cols.map((c) => c.label))); + lines.push(sep); + for (const r of rows) { + lines.push(render(cols.map((c) => (r as any)[c.key]))); + } + lines.push(sep); + return lines.join("\n"); +} + +export async function runRoutes( + options: RoutesOptions, +): Promise { + const format = options.format ?? "table"; + try { + const mod = await loadAppModule(options.app); + const app = resolveAppInstance(mod); + if (!app) { + return { + exitCode: 1, + stdout: "", + stderr: + "Error: Could not find a Tsadwyn app export. " + + "The module should have a default export or a named 'app' export.", + }; + } + // Trigger initialization if needed so _versionedRoutes is populated. + const routers = mod.routers ?? mod.versionedRouters; + if (routers) { + const routerArr = Array.isArray(routers) ? routers : [routers]; + app.generateAndIncludeVersionedRouters(...routerArr); + } else if (app._pendingRouters) { + app._performInitialization?.(); + } + + const entries = dumpRouteTable(app, { + version: options.version, + method: options.method, + pathMatches: options.pathMatches, + includePrivate: options.includePrivate ?? false, + }); + + return { + exitCode: 0, + stdout: renderRouteTable(entries, format), + stderr: "", + }; + } catch (err) { + return { + exitCode: 1, + stdout: "", + stderr: `Error in routes command: ${err instanceof Error ? err.message : String(err)}`, + }; + } +} + +// ───────────────────────────────────────────────────────────────────────── +// `migrations` — inspect the migration chain for a schema + client version +// ───────────────────────────────────────────────────────────────────────── + +export interface MigrationsOptions { + app: string; + schema: string; + version: string; + direction?: "request" | "response"; + path?: string; + method?: string; + includeErrorMigrations?: boolean; + format?: "pipeline" | "json"; +} + +function renderMigrationChain( + entries: ReadonlyArray, + direction: "request" | "response", + clientVersion: string, + schemaName: string, + format: "pipeline" | "json", +): string { + if (format === "json") return JSON.stringify(entries, null, 2); + + const lines: string[] = []; + lines.push(`Schema: ${schemaName}`); + lines.push(`Direction: ${direction} (${direction === "response" ? "head → client" : "client → head"})`); + lines.push(`Client version: ${clientVersion}`); + lines.push(""); + if (entries.length === 0) { + lines.push("No migrations in chain."); + return lines.join("\n"); + } + for (const entry of entries) { + lines.push( + ` ↓ ${entry.version} — ${entry.changeClassName}.${entry.functionName} (${entry.kind}${ + entry.migrateHttpErrors ? ", migrateHttpErrors" : "" + })`, + ); + } + lines.push(""); + lines.push(`${entries.length} migration(s) in chain.`); + return lines.join("\n"); +} + +export async function runMigrations( + options: MigrationsOptions, +): Promise { + const format = options.format ?? "pipeline"; + const direction = options.direction ?? "response"; + try { + const mod = await loadAppModule(options.app); + const app = resolveAppInstance(mod); + if (!app) { + return { + exitCode: 1, + stdout: "", + stderr: + "Error: Could not find a Tsadwyn app export. " + + "The module should have a default export or a named 'app' export.", + }; + } + const routers = mod.routers ?? mod.versionedRouters; + if (routers) { + const routerArr = Array.isArray(routers) ? routers : [routers]; + app.generateAndIncludeVersionedRouters(...routerArr); + } else if (app._pendingRouters) { + app._performInitialization?.(); + } + + const entries = inspectMigrationChain(app, { + schemaName: options.schema, + clientVersion: options.version, + direction, + path: options.path, + method: options.method, + includeErrorMigrations: options.includeErrorMigrations, + }); + + return { + exitCode: 0, + stdout: renderMigrationChain( + entries, + direction, + options.version, + options.schema, + format, + ), + stderr: "", + }; + } catch (err) { + return { + exitCode: 1, + stdout: "", + stderr: `Error in migrations command: ${err instanceof Error ? err.message : String(err)}`, + }; + } +} + +// ───────────────────────────────────────────────────────────────────────── +// `simulate` — simulate a request against the route table +// ───────────────────────────────────────────────────────────────────────── + +export interface SimulateOptions { + app: string; + method: string; + path: string; + version?: string; + body?: string; + format?: "table" | "json"; +} + +function renderSimulation( + result: SimulationResult, + format: "table" | "json", +): string { + if (format === "json") return JSON.stringify(result, null, 2); + + const lines: string[] = []; + lines.push(`Resolved version: ${result.resolvedVersion}`); + lines.push(""); + if (result.matchedRoute) { + lines.push( + `Matched route: [${result.matchedRoute.method}] ${result.matchedRoute.path}`, + ); + if (Object.keys(result.matchedRoute.params).length > 0) { + lines.push(` params: ${JSON.stringify(result.matchedRoute.params)}`); + } + lines.push(` handler: ${result.matchedRoute.handler}`); + if (result.matchedRoute.schemaName) { + lines.push(` response schema: ${result.matchedRoute.schemaName}`); + } + } else { + lines.push("Matched route: (none — request would fall through)"); + } + lines.push(""); + lines.push("Candidates:"); + for (const c of result.candidates) { + const mark = c.matched ? "✓" : "✗"; + lines.push(` ${mark} [${c.method}] ${c.path} — ${c.reason}`); + } + if (result.fallthrough) { + lines.push(""); + lines.push("Fallthrough:"); + lines.push(` reason: ${result.fallthrough.reason}`); + if (result.fallthrough.availableAtOtherVersions.length > 0) { + lines.push( + ` available at other versions: ${result.fallthrough.availableAtOtherVersions.join(", ")}`, + ); + } + } + if (result.requestMigrations.length > 0) { + lines.push(""); + lines.push("Request migrations:"); + for (const m of result.requestMigrations) { + lines.push(` ${m.fromVersion} → ${m.toVersion} (${m.schemaName ?? m.path})`); + } + } + if (result.responseMigrations.length > 0) { + lines.push(""); + lines.push("Response migrations:"); + for (const m of result.responseMigrations) { + lines.push(` ${m.fromVersion} → ${m.toVersion} (${m.schemaName ?? m.path})`); + } + } + if (result.upMigratedBody !== undefined) { + lines.push(""); + lines.push(`Up-migrated body: ${JSON.stringify(result.upMigratedBody)}`); + } + return lines.join("\n"); +} + +export async function runSimulate( + options: SimulateOptions, +): Promise { + const format = options.format ?? "table"; + if (!options.method) { + return { + exitCode: 1, + stdout: "", + stderr: "Error: --method is required for tsadwyn simulate.", + }; + } + if (!options.path) { + return { + exitCode: 1, + stdout: "", + stderr: "Error: --path is required for tsadwyn simulate.", + }; + } + try { + const mod = await loadAppModule(options.app); + const app = resolveAppInstance(mod); + if (!app) { + return { + exitCode: 1, + stdout: "", + stderr: + "Error: Could not find a Tsadwyn app export. " + + "The module should have a default export or a named 'app' export.", + }; + } + const routers = mod.routers ?? mod.versionedRouters; + if (routers) { + const routerArr = Array.isArray(routers) ? routers : [routers]; + app.generateAndIncludeVersionedRouters(...routerArr); + } else if (app._pendingRouters) { + app._performInitialization?.(); + } + + let parsedBody: unknown = undefined; + if (options.body) { + try { + parsedBody = JSON.parse(options.body); + } catch { + return { + exitCode: 1, + stdout: "", + stderr: `Error: --body is not valid JSON: ${options.body}`, + }; + } + } + + const result = simulateRoute(app, { + method: options.method, + path: options.path, + version: options.version, + body: parsedBody, + }); + + return { + exitCode: 0, + stdout: renderSimulation(result, format), + stderr: "", + }; + } catch (err) { + return { + exitCode: 1, + stdout: "", + stderr: `Error in simulate command: ${err instanceof Error ? err.message : String(err)}`, + }; + } +} + +// ───────────────────────────────────────────────────────────────────────── +// `exceptions` — introspect the configured errorMapper's exception table +// ───────────────────────────────────────────────────────────────────────── + +/** + * Options accepted by the `exceptions` command. + */ +export interface ExceptionsOptions { + app: string; + format?: "table" | "json" | "markdown"; + filter?: string; +} + +/** + * Render an array of ExceptionMapEntry as a formatted output string per the + * requested format. Extracted for unit testing. + */ +function renderExceptionsTable( + entries: ReadonlyArray, + format: "table" | "json" | "markdown", +): string { + if (format === "json") { + return JSON.stringify(entries, null, 2); + } + + const rows = entries.map((e) => ({ + name: e.name, + kind: e.kind, + status: e.status === null ? "(dyn)" : String(e.status), + code: e.code === null ? "(dyn)" : e.code, + transform: e.hasTransform ? "yes" : "no", + })); + + const headers = ["Exception name", "Kind", "Status", "Code", "Transform?"]; + + if (format === "markdown") { + const lines: string[] = []; + lines.push( + `| ${headers.join(" | ")} |`, + `| ${headers.map(() => "---").join(" | ")} |`, + ); + for (const r of rows) { + lines.push( + `| ${r.name} | ${r.kind} | ${r.status} | ${r.code} | ${r.transform} |`, + ); + } + return lines.join("\n"); + } + + // ASCII table + const cols = [ + { key: "name", label: headers[0] }, + { key: "kind", label: headers[1] }, + { key: "status", label: headers[2] }, + { key: "code", label: headers[3] }, + { key: "transform", label: headers[4] }, + ] as const; + const widths = cols.map((c) => + Math.max(c.label.length, ...rows.map((r) => (r as any)[c.key].length)), + ); + const pad = (s: string, w: number) => s + " ".repeat(Math.max(0, w - s.length)); + const sep = + "+-" + widths.map((w) => "-".repeat(w)).join("-+-") + "-+"; + const render = (values: string[]) => + "| " + values.map((v, i) => pad(v, widths[i])).join(" | ") + " |"; + + const lines: string[] = []; + lines.push(`Exception mappings (${entries.length} registered)`); + lines.push(""); + lines.push(sep); + lines.push(render(cols.map((c) => c.label))); + lines.push(sep); + for (const r of rows) { + lines.push(render(cols.map((c) => (r as any)[c.key]))); + } + lines.push(sep); + return lines.join("\n"); +} + +/** + * Run the `exceptions` command: load the user's app, look up the configured + * errorMapper, and if it's an introspectable ExceptionMapFn (produced by + * `exceptionMap()`), render its table in the requested format. + * + * Returns `{stdout, stderr, exitCode}`. Unlike `runCodegen` / `runInfo`, the + * rendered table is the primary stdout artifact; diagnostic messages go to + * stderr. Non-zero exit when the app has no introspectable mapper. + */ +export async function runExceptions( + options: ExceptionsOptions, +): Promise { + const format = options.format ?? "table"; + try { + const mod = await loadAppModule(options.app); + const app = resolveAppInstance(mod); + if (!app) { + return { + exitCode: 1, + stdout: "", + stderr: + "Error: Could not find a Tsadwyn app export. " + + "The module should have a default export or a named 'app' export.", + }; + } + const mapper = app._errorMapper; + if (!mapper) { + return { + exitCode: 1, + stdout: "", + stderr: + "Error: the loaded app does not have an errorMapper configured. " + + "`tsadwyn exceptions` requires an introspectable errorMapper built via `exceptionMap()`.", + }; + } + if (!isExceptionMapFn(mapper)) { + return { + exitCode: 1, + stdout: "", + stderr: + "Error: the configured errorMapper is a plain function, not an introspectable ExceptionMapFn. " + + "Wrap your mapping with `exceptionMap()` to enable `tsadwyn exceptions` introspection.", + }; + } + + let entries = mapper.describe(); + if (options.filter) { + const regex = new RegExp(options.filter); + entries = entries.filter((e) => regex.test(e.name)); + } + + return { + exitCode: 0, + stdout: renderExceptionsTable(entries, format), + stderr: "", + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { + exitCode: 1, + stdout: "", + stderr: `Error in exceptions command: ${message}`, + }; + } +} + /** * Construct a fresh `Command` with every tsadwyn subcommand registered. * @@ -840,6 +1361,109 @@ export function createProgram(): Command { emitResult(cmd, result, "tsadwyn.infoFailed"); }); + cmd + .command("exceptions") + .description( + "Introspect the configured errorMapper's exception→HttpError table. " + + "Requires the app's errorMapper to be produced by `exceptionMap()`.", + ) + .requiredOption("--app ", "Path to the module that exports the Tsadwyn app") + .option("--format ", "Output format: table (default) | json | markdown", "table") + .option("--filter ", "Filter entries by name (regex)") + .action(async (options: ExceptionsOptions) => { + const result = await runExceptions(options); + if (result.stdout) process.stdout.write(result.stdout + "\n"); + if (result.stderr) process.stderr.write(result.stderr + "\n"); + if (result.exitCode !== 0) { + throw new CommanderError( + result.exitCode, + "tsadwyn.exceptionsFailed", + result.stderr || "exceptions command failed", + ); + } + }); + + cmd + .command("routes") + .description("Enumerate the registered route table per version.") + .requiredOption("--app ", "Path to the module that exports the Tsadwyn app") + .option("--version ", "Filter to a single API version") + .option("--method ", "Filter by HTTP method (case-insensitive)") + .option("--path-matches ", "Filter by path — regex source or substring") + .option("--include-private", "Include routes with includeInSchema: false") + .option("--format ", "Output format: table (default) | json | markdown", "table") + .action(async (options: RoutesOptions) => { + const result = await runRoutes(options); + if (result.stdout) process.stdout.write(result.stdout + "\n"); + if (result.stderr) process.stderr.write(result.stderr + "\n"); + if (result.exitCode !== 0) { + throw new CommanderError( + result.exitCode, + "tsadwyn.routesFailed", + result.stderr || "routes command failed", + ); + } + }); + + cmd + .command("migrations") + .description( + "Inspect the request or response migration chain for a schema at a client version.", + ) + .requiredOption("--app ", "Path to the module that exports the Tsadwyn app") + .requiredOption("--schema ", "Schema name (as set via .named())") + .requiredOption("--version ", "Client API version") + .option("--direction ", "response (default) | request", "response") + .option("--path ", "Scope to a single path-based migration target") + .option("--method ", "Paired with --path") + .option( + "--no-error-migrations", + "Exclude migrations with migrateHttpErrors: true", + ) + .option("--format ", "Output format: pipeline (default) | json", "pipeline") + .action( + async (options: MigrationsOptions & { errorMigrations?: boolean }) => { + const result = await runMigrations({ + ...options, + includeErrorMigrations: options.errorMigrations !== false, + }); + if (result.stdout) process.stdout.write(result.stdout + "\n"); + if (result.stderr) process.stderr.write(result.stderr + "\n"); + if (result.exitCode !== 0) { + throw new CommanderError( + result.exitCode, + "tsadwyn.migrationsFailed", + result.stderr || "migrations command failed", + ); + } + }, + ); + + cmd + .command("simulate") + .description( + "Simulate a request against the route table: matched route, candidates, " + + "migration chains, and up-migrated body preview.", + ) + .requiredOption("--app ", "Path to the module that exports the Tsadwyn app") + .requiredOption("--method ", "HTTP method of the simulated request") + .requiredOption("--path ", "Request path") + .option("--version ", "API version (explicit overrides headers/default)") + .option("--body ", "Request body as a JSON string") + .option("--format ", "Output format: table (default) | json", "table") + .action(async (options: SimulateOptions) => { + const result = await runSimulate(options); + if (result.stdout) process.stdout.write(result.stdout + "\n"); + if (result.stderr) process.stderr.write(result.stderr + "\n"); + if (result.exitCode !== 0) { + throw new CommanderError( + result.exitCode, + "tsadwyn.simulateFailed", + result.stderr || "simulate command failed", + ); + } + }); + // ───────────────────────────────────────────────────────────────────── // `new` — scaffolding subcommands // ───────────────────────────────────────────────────────────────────── diff --git a/src/delete-response.ts b/src/delete-response.ts new file mode 100644 index 0000000..eea9f38 --- /dev/null +++ b/src/delete-response.ts @@ -0,0 +1,42 @@ +/** + * `deletedResponseSchema` — produces the Stripe-style deleted-resource + * response shape. + * + * Stripe's `DELETE /v1/customers/{id}` (and every other DELETE in its API) + * returns **HTTP 200** with `{ id, object, deleted: true }` — NOT 204. + * RFC 9110 §15.3.5 says a 204 response "cannot contain content", and + * Node's HTTP writer enforces that at the wire level: bodies written + * to res.end() on a 204 response are stripped before bytes reach the + * client. Verified empirically against api.stripe.com. + * + * This helper makes the Stripe shape a one-liner and keeps consumers + * off the 204-with-body footgun. For richer audit envelopes — tracking + * `deleted_at` / `deleted_by` / etc. — pass `extraFields` and either + * evolve the shape across versions with a VersionChange or declare it + * nested under `response:` from the start. + * + * Usage: + * + * const DeletedCustomer = deletedResponseSchema("customer") + * .named("DeletedCustomer"); + * + * router.delete("/customers/:id", null, DeletedCustomer, async (req) => { + * const existing = await customers.delete(req.params.id); + * return { id: existing.id, object: "customer", deleted: true }; + * }); + * // Note: no statusCode override needed — defaults to 200 (correct). + */ + +import { z, type ZodRawShape } from "zod"; + +export function deletedResponseSchema( + objectName: string, + extraFields?: E, +) { + return z.object({ + id: z.string(), + object: z.literal(objectName), + deleted: z.literal(true), + ...(extraFields ?? ({} as E)), + }); +} diff --git a/src/exception-map.ts b/src/exception-map.ts new file mode 100644 index 0000000..22fefbb --- /dev/null +++ b/src/exception-map.ts @@ -0,0 +1,197 @@ +/** + * `exceptionMap` — declarative exception→HttpError table with introspection. + * + * Replaces the if-chain form of `errorMapper` with a keyed-by-err.name map. + * Keying by `err.name` string (rather than `instanceof`) survives module- + * boundary identity traps (Jest `resetModules`, dual package installs, + * ESM/CJS interop). The returned function is structurally compatible with + * `TsadwynOptions.errorMapper`, and also carries introspection methods used + * by the `tsadwyn exceptions` CLI subcommand and runtime audit tooling. + */ + +import { HttpError, TsadwynStructureError } from "./exceptions.js"; + +export type ExceptionMapping = + | ((err: Error) => HttpError) + | { status: number; code: string; message?: string } + | { + status: number; + code: string; + transform: (err: Error) => Record; + }; + +export type ExceptionMapConfig = Record; + +export interface ExceptionMapEntry { + /** Error class name (the map key; also the value matched against err.name). */ + name: string; + /** Kind of mapping; drives how status/code/hasTransform are rendered. */ + kind: "static" | "function" | "static-with-transform"; + /** Known statically, or null when the mapping is a plain function. */ + status: number | null; + /** Known statically, or null when the mapping is a plain function. */ + code: string | null; + /** True when the mapping computes body dynamically. */ + hasTransform: boolean; +} + +export interface ExceptionMapFn { + (err: unknown): HttpError | null; + readonly registeredNames: readonly string[]; + has(name: string): boolean; + lookup(name: string): ExceptionMapping | undefined; + describe(): ReadonlyArray; +} + +function isStaticMapping(m: ExceptionMapping): m is { + status: number; + code: string; + message?: string; +} { + return ( + typeof m === "object" && + m !== null && + "status" in m && + !("transform" in m) + ); +} + +function isStaticWithTransform( + m: ExceptionMapping, +): m is { + status: number; + code: string; + transform: (err: Error) => Record; +} { + return ( + typeof m === "object" && + m !== null && + "status" in m && + "transform" in m + ); +} + +function validateStatus(name: string, status: number): void { + if (!Number.isInteger(status) || status < 400 || status >= 600) { + throw new TsadwynStructureError( + `exceptionMap: invalid status ${status} for "${name}". ` + + "Static mappings must use a 4xx or 5xx HTTP status code.", + ); + } +} + +/** + * Build an `errorMapper`-compatible function from a declarative config. + */ +export function exceptionMap(config: ExceptionMapConfig): ExceptionMapFn { + // Validate static entries up-front. + for (const [name, mapping] of Object.entries(config)) { + if (isStaticMapping(mapping) || isStaticWithTransform(mapping)) { + validateStatus(name, mapping.status); + } else if (typeof mapping !== "function") { + throw new TsadwynStructureError( + `exceptionMap: mapping for "${name}" must be a function or an object with {status, code, ...}.`, + ); + } + } + + const map = new Map(Object.entries(config)); + + const fn = function exceptionMapFn(err: unknown): HttpError | null { + if (!(err instanceof Error)) return null; + const mapping = map.get(err.name); + if (!mapping) return null; + + if (typeof mapping === "function") { + return mapping(err); + } + + if (isStaticWithTransform(mapping)) { + const body = { code: mapping.code, ...mapping.transform(err) }; + return new HttpError(mapping.status, body); + } + + // Plain static form + return new HttpError(mapping.status, { + code: mapping.code, + message: mapping.message ?? err.message, + }); + } as ExceptionMapFn; + + Object.defineProperty(fn, "registeredNames", { + get: () => Object.freeze([...map.keys()]), + enumerable: true, + }); + + fn.has = (name: string): boolean => map.has(name); + fn.lookup = (name: string): ExceptionMapping | undefined => map.get(name); + + fn.describe = (): ReadonlyArray => { + const entries: ExceptionMapEntry[] = []; + for (const [name, mapping] of map) { + if (typeof mapping === "function") { + entries.push({ + name, + kind: "function", + status: null, + code: null, + hasTransform: false, + }); + } else if (isStaticWithTransform(mapping)) { + entries.push({ + name, + kind: "static-with-transform", + status: mapping.status, + code: mapping.code, + hasTransform: true, + }); + } else { + entries.push({ + name, + kind: "static", + status: mapping.status, + code: mapping.code, + hasTransform: false, + }); + } + } + return Object.freeze(entries); + }; + + return fn; +} + +/** + * Merge multiple exception-map configs. Throws on overlapping keys so + * accidental duplicates don't silently overwrite earlier entries. + */ +exceptionMap.merge = function merge( + ...configs: ExceptionMapConfig[] +): ExceptionMapConfig { + const merged: ExceptionMapConfig = {}; + for (const config of configs) { + for (const name of Object.keys(config)) { + if (Object.prototype.hasOwnProperty.call(merged, name)) { + throw new TsadwynStructureError( + `exceptionMap.merge: duplicate key "${name}" — resolve the collision explicitly before merging.`, + ); + } + merged[name] = config[name]; + } + } + return merged; +}; + +/** + * Check at runtime whether a value is an introspectable ExceptionMapFn + * (i.e., produced by `exceptionMap()`). Used by the CLI and audit tooling + * to decide whether to offer introspection. + */ +export function isExceptionMapFn(value: unknown): value is ExceptionMapFn { + return ( + typeof value === "function" && + typeof (value as ExceptionMapFn).describe === "function" && + typeof (value as ExceptionMapFn).has === "function" && + typeof (value as ExceptionMapFn).lookup === "function" + ); +} diff --git a/src/exceptions.ts b/src/exceptions.ts index b1bf312..362e23b 100644 --- a/src/exceptions.ts +++ b/src/exceptions.ts @@ -164,6 +164,38 @@ export class HttpError extends Error { } } +/** + * HttpError subclass thrown when tsadwyn's built-in request validation + * rejects an incoming request (Zod schema parse fails on body, params, + * or query). Carries the Zod error list under `body.detail` — shape is + * byte-identical to the previous inline 422 response so existing clients + * see no change by default. + * + * Because it flows through tsadwyn's error pipeline (errorMapper + + * migrateHttpErrors response migrations), consumers can reshape the + * wire envelope via any of: + * + * - `errorMapper` / `exceptionMap` keyed on `err.name === "ValidationError"` + * - `convertResponseToPreviousVersionFor(..., { migrateHttpErrors: true })` + * - catching in Express middleware downstream + * + * `where` identifies which validator rejected: 'body' | 'params' | 'query'. + */ +export class ValidationError extends HttpError { + /** Which slot failed validation — body, path params, or query string. */ + where: "body" | "params" | "query"; + + constructor( + where: "body" | "params" | "query", + errors: unknown[], + ) { + super(422, { detail: errors }); + this.name = "ValidationError"; + this.where = where; + Object.setPrototypeOf(this, new.target.prototype); + } +} + // ── Module errors ─────────────────────────────────────────────────────────── export class ModuleIsNotVersionedError extends TsadwynError { diff --git a/src/index.ts b/src/index.ts index 999b4f4..6003690 100644 --- a/src/index.ts +++ b/src/index.ts @@ -67,6 +67,7 @@ export { InvalidGenerationInstructionError, ModuleIsNotVersionedError, HttpError, + ValidationError, } from "./exceptions.js"; // OpenAPI @@ -102,6 +103,100 @@ export { // T-1701: Standalone response migration utility export { migrateResponseBody } from "./migrate.js"; +// Per-client default version resolver (pairs with preVersionPick) +export { perClientDefaultVersion } from "./per-client-default.js"; +export type { PerClientDefaultVersionOptions } from "./per-client-default.js"; + +// Cached variant: TTL'd map + invalidation handles for the upgrade endpoint +export { cachedPerClientDefaultVersion } from "./cached-per-client-default.js"; +export type { + CachedPerClientDefaultVersionOptions, + CachedPerClientDefaultVersion, +} from "./cached-per-client-default.js"; + +// Request-scoped access to the raw Express Request inside tsadwyn handlers +// (captures middleware-injected state that the stripped handler view hides) +export { + currentRequest, + currentRequestOrNull, + requestContextStorage, +} from "./request-context.js"; + +// Behavior-map helper for per-version behavior branching in handlers +export { buildBehaviorResolver } from "./behavior-resolver.js"; +export type { BuildBehaviorResolverOptions } from "./behavior-resolver.js"; + +// Typed overlay primitive — declarative behavior catalog built from HEAD + deltas +export { createVersionedBehavior } from "./versioned-behavior.js"; +export type { + CreateVersionedBehaviorOptions, + VersionBehaviorChange, + VersionedBehavior, +} from "./versioned-behavior.js"; + +// Route-shadowing detector (exposed for CLI tools and advanced callers) +export { detectRouteShadows, reportRouteShadows } from "./route-shadowing.js"; +export type { + RouteShadowingPolicy, + RouteShadowingLogger, + RouteShadow, +} from "./route-shadowing.js"; + +// Canonical upgrade-policy helper for /versioning/upgrade endpoints +export { validateVersionUpgrade } from "./version-upgrade.js"; +export type { + ValidateVersionUpgradeArgs, + ValidateVersionUpgradeResult, + CompareFn, +} from "./version-upgrade.js"; + +// Pre-wired RESTful /versioning resource (GET + POST with optimistic +// concurrency) built on top of validateVersionUpgrade. +export { createVersioningRoutes } from "./versioning-routes.js"; +export type { CreateVersioningRoutesOptions } from "./versioning-routes.js"; + +// Declarative exception→HttpError helper (pairs with errorMapper option) +export { exceptionMap, isExceptionMapFn } from "./exception-map.js"; +export type { + ExceptionMapConfig, + ExceptionMapEntry, + ExceptionMapFn, + ExceptionMapping, +} from "./exception-map.js"; + +// Outbound payload migration (webhooks, internal events) +export { migratePayloadToVersion } from "./migrate-payload.js"; + +// Stripe-style deleted-resource response helper +export { deletedResponseSchema } from "./delete-response.js"; + +// Raw / binary / streaming response marker +export { raw, isRawResponse, RAW_RESPONSE_MARKER } from "./raw-response.js"; +export type { RawResponseOptions, RawResponseMarker } from "./raw-response.js"; + +// Debugging / introspection trio: routes / migrations / simulation +export { dumpRouteTable } from "./route-table.js"; +export type { + DumpRouteTableOptions, + RouteTableEntry, +} from "./route-table.js"; + +export { inspectMigrationChain } from "./migration-chain.js"; +export type { + InspectMigrationChainOptions, + MigrationChainEntry, +} from "./migration-chain.js"; + +export { simulateRoute } from "./route-simulation.js"; +export type { + SimulateRouteOptions, + SimulationResult, + MatchedRouteSummary, + RouteCandidate, + FallthroughSummary, + MigrationSummary, +} from "./route-simulation.js"; + // T-1300 and T-1301: AST analysis and custom module loading // These features are N/A in the TypeScript version. In the Python Tsadwyn library, // T-1300 (AST analysis) uses Python's ast module to analyze and render versioned diff --git a/src/middleware.ts b/src/middleware.ts index 9d08d6f..6e87162 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -29,6 +29,20 @@ export interface VersionPickingOptions { apiVersionLocation: APIVersionLocation; apiVersionDefaultValue: string | ((req: Request) => string | Promise) | null; versionValues: string[]; + /** + * Policy for handling an `X-Api-Version` header whose value isn't in + * `versionValues`. Default: `'passthrough'` (store the string verbatim so + * the downstream dispatcher can decide what to do — preserves current behavior). + * + * - `'reject'` — respond 400 with `{error: 'unsupported_api_version', sent, supported}`. + * - `'fallback'` — substitute `apiVersionDefaultValue` and emit a warn (if logger supplied). + * - `'passthrough'` — store verbatim. Current behavior. + */ + onUnsupportedVersion?: "reject" | "fallback" | "passthrough"; + /** Optional logger used for `fallback`-mode warns. */ + logger?: { + warn: (ctx: Record, msg: string) => void; + }; } /** @@ -96,11 +110,54 @@ export function versionPickingMiddleware( // Apply default value if no version found if (version === undefined && opts.apiVersionDefaultValue !== null) { - if (typeof opts.apiVersionDefaultValue === "function") { - version = await opts.apiVersionDefaultValue(req); - } else if (typeof opts.apiVersionDefaultValue === "string") { - version = opts.apiVersionDefaultValue; + try { + if (typeof opts.apiVersionDefaultValue === "function") { + version = await opts.apiVersionDefaultValue(req); + } else if (typeof opts.apiVersionDefaultValue === "string") { + version = opts.apiVersionDefaultValue; + } + } catch (err) { + next(err); + return; + } + } + + // onUnsupportedVersion policy check: if an explicit/default version was + // resolved but it isn't in versionValues, apply the configured policy. + if ( + version !== undefined && + version !== null && + opts.versionValues.length > 0 && + !opts.versionValues.includes(version) + ) { + const policy = opts.onUnsupportedVersion ?? "passthrough"; + if (policy === "reject") { + res.status(400).json({ + error: "unsupported_api_version", + sent: version, + supported: opts.versionValues, + }); + return; + } + if (policy === "fallback") { + opts.logger?.warn( + { sent: version, supported: opts.versionValues }, + `Unsupported API version "${version}"; falling back to default.`, + ); + try { + if (typeof opts.apiVersionDefaultValue === "function") { + version = await opts.apiVersionDefaultValue(req); + } else if (typeof opts.apiVersionDefaultValue === "string") { + version = opts.apiVersionDefaultValue; + } else { + version = undefined; + } + } catch (err) { + next(err); + return; + } } + // passthrough: leave version as-is so the downstream dispatcher handles it } const versionValue = version || null; diff --git a/src/migrate-payload.ts b/src/migrate-payload.ts new file mode 100644 index 0000000..cff99c3 --- /dev/null +++ b/src/migrate-payload.ts @@ -0,0 +1,122 @@ +/** + * `migratePayloadToVersion` — standalone helper that reshapes a head-shape + * payload for a pinned client version by replaying the response migrations + * (schema-based AND/OR path-based) registered between head and `targetVersion`. + * + * Primary use case: outbound webhook dispatch. `convertResponseToPreviousVersionFor` + * only fires for in-flight HTTP responses; a background job dispatching + * outbound webhooks needs to run the same migration chain against a handcrafted + * payload before delivering it to a pinned client's registered webhook URL. + * + * Supports both migration forms: + * - Schema-based: `convertResponseToPreviousVersionFor(Schema)(fn)` — keyed + * by the registered `.named()` schema name, addressed via `schemaName`. + * - Path-based: `convertResponseToPreviousVersionFor(path, methods)(fn)` — + * keyed by path + HTTP methods, addressed by passing `opts.path` (and + * optionally `opts.methods` to restrict to a method subset). + * + * Pass neither (or just `schemaName`) for the common webhook-by-schema case. + * Pass `opts.path` when the consumer registered path-based migrations and + * their webhook dispatch corresponds to a known route path. Passing both + * runs both kinds in the order the in-flight dispatcher would: each + * version's migrations fire once for the version boundary. + */ + +import type { VersionBundle } from "./structure/versions.js"; +import { ResponseInfo } from "./structure/data.js"; +import { TsadwynStructureError } from "./exceptions.js"; + +export interface MigratePayloadOptions { + /** When supplied, also apply path-based migrations keyed on this path. */ + path?: string; + /** + * Restrict path-based migrations to these HTTP methods. Default: apply + * every path-based migration registered at `path` regardless of method + * (common when the caller is dispatching webhooks and doesn't have an + * HTTP method to gate on). + */ + methods?: readonly string[]; +} + +/** + * Reshape `payload` from the current head shape to the shape expected at + * `targetVersion`, applying the same response migrations the framework + * would run for an in-flight HTTP response at that version. Input is + * deep-cloned so callers can pass a reference safely. + * + * Throws `TsadwynStructureError` when `targetVersion` is not in the + * `VersionBundle`. + */ +export function migratePayloadToVersion( + schemaName: string, + payload: T, + targetVersion: string, + versions: VersionBundle, + opts: MigratePayloadOptions = {}, +): T { + const idx = versions.versionValues.indexOf(targetVersion); + if (idx === -1) { + throw new TsadwynStructureError( + `migratePayloadToVersion: targetVersion "${targetVersion}" is not in the VersionBundle. ` + + `Known versions: [${versions.versionValues.join(", ")}].`, + ); + } + + // Deep clone so the input payload isn't mutated by transformers. + const cloned = + payload === null || payload === undefined + ? payload + : (JSON.parse(JSON.stringify(payload)) as T); + + // Target is head → no migrations need to run. + if (idx === 0) return cloned; + + const responseInfo = new ResponseInfo(cloned, 200); + const methodFilter = opts.methods + ? new Set(opts.methods.map((m) => m.toUpperCase())) + : null; + + // Walk versions newest → oldest, stopping just before the target. Each + // iteration applies one version's migrations to the accumulating + // payload, producing the shape at the next-older version. + for (let i = 0; i < idx; i++) { + const version = versions.versions[i]; + for (const change of version.changes) { + // Schema-based: directly keyed on schemaName. + const schemaInstrs = + change._alterResponseBySchemaInstructions.get(schemaName); + if (schemaInstrs) { + for (const instr of schemaInstrs) { + instr.transformer(responseInfo); + } + } + + // Path-based: fire when the caller supplied a matching `opts.path`. + // Without `opts.path`, path-based migrations are silently skipped — + // the caller's schemaName doesn't tell us which path the payload + // would have come from. + if (opts.path !== undefined) { + const pathInstrs = change._alterResponseByPathInstructions.get( + opts.path, + ); + if (pathInstrs) { + for (const instr of pathInstrs) { + if (methodFilter) { + let intersects = false; + for (const m of instr.methods) { + if (methodFilter.has(m)) { + intersects = true; + break; + } + } + if (!intersects) continue; + } + instr.transformer(responseInfo); + } + } + } + } + } + + return responseInfo.body as T; +} diff --git a/src/migration-chain.ts b/src/migration-chain.ts new file mode 100644 index 0000000..8b4f326 --- /dev/null +++ b/src/migration-chain.ts @@ -0,0 +1,199 @@ +/** + * `inspectMigrationChain` — given a schema name and a client version, return + * the ordered list of migrations that tsadwyn would run to migrate a request + * (client → head) or response (head → client). Used by consumers debugging + * "why is my v1 client receiving a v2-shape field?" without stepping through + * code. + */ + +import type { RouteDefinition } from "./router.js"; +import type { VersionBundle } from "./structure/versions.js"; +import { TsadwynStructureError } from "./exceptions.js"; +import { getSchemaName } from "./zod-extend.js"; + +export interface InspectMigrationChainOptions { + schemaName: string; + clientVersion: string; + direction: "request" | "response"; + /** Optionally scope to one path-based migration target. */ + path?: string; + /** Paired with `path` to scope to a single method. */ + method?: string; + /** Include migrations where migrateHttpErrors: true. Default: true. */ + includeErrorMigrations?: boolean; +} + +export interface MigrationChainEntry { + /** Version at which the VersionChange lives. */ + version: string; + /** The class name of the VersionChange. */ + changeClassName: string; + /** Schema-based (registered against a schema name) or path-based (registered against a path+methods). */ + kind: "schema-based" | "path-based"; + /** Method / function name (used for debugging + CLI display). */ + functionName: string; + /** Present for schema-based entries. */ + schemaName?: string; + /** Present for path-based entries. */ + path?: string; + /** Present for path-based entries. */ + methods?: string[]; + /** Present for response migrations. */ + migrateHttpErrors?: boolean; + /** Position in the resolved chain (0 = runs first). */ + order: number; +} + +interface InspectApp { + versions: VersionBundle; + readonly _versionedRoutes?: Map; +} + +function schemaIsKnown(app: InspectApp, schemaName: string): boolean { + const routesMap = (app as any)._versionedRoutes as + | Map + | undefined; + if (routesMap) { + for (const routes of routesMap.values()) { + for (const route of routes) { + if (getSchemaName(route.requestSchema) === schemaName) return true; + if (getSchemaName(route.responseSchema) === schemaName) return true; + } + } + } + // Also accept schemas referenced only in instruction sets. + for (const version of app.versions.versions) { + for (const change of version.changes) { + if ( + change._alterResponseBySchemaInstructions.has(schemaName) || + change._alterRequestBySchemaInstructions.has(schemaName) + ) { + return true; + } + } + } + return false; +} + +export function inspectMigrationChain( + app: InspectApp, + opts: InspectMigrationChainOptions, +): MigrationChainEntry[] { + const { + schemaName, + clientVersion, + direction, + path, + method, + includeErrorMigrations = true, + } = opts; + const bundle = app.versions; + + if (!bundle.versionValues.includes(clientVersion)) { + throw new TsadwynStructureError( + `inspectMigrationChain: clientVersion "${clientVersion}" is not in the VersionBundle. ` + + `Known versions: [${bundle.versionValues.join(", ")}].`, + ); + } + if (!schemaIsKnown(app, schemaName)) { + throw new TsadwynStructureError( + `inspectMigrationChain: schema "${schemaName}" is not registered on any route or instruction.`, + ); + } + + const entries: MigrationChainEntry[] = []; + const upperMethod = method?.toUpperCase(); + + if (direction === "response") { + // head → client: walk versionValues (newest-first) from index 0 + // up to (but excluding) clientVersion's index. + const clientIdx = bundle.versionValues.indexOf(clientVersion); + for (let i = 0; i < clientIdx; i++) { + const v = bundle.versions[i]; + for (const change of v.changes) { + // Schema-based + const schemaInstrs = + change._alterResponseBySchemaInstructions.get(schemaName); + if (schemaInstrs) { + for (const instr of schemaInstrs) { + if (!includeErrorMigrations && instr.migrateHttpErrors) continue; + entries.push({ + version: v.value, + changeClassName: change.constructor.name, + kind: "schema-based", + functionName: instr.methodName, + schemaName, + migrateHttpErrors: instr.migrateHttpErrors, + order: entries.length, + }); + } + } + // Path-based (scoped by opts.path + opts.method) + if (path) { + const pathInstrs = change._alterResponseByPathInstructions.get(path); + if (pathInstrs) { + for (const instr of pathInstrs) { + if (upperMethod && !instr.methods.has(upperMethod)) continue; + if (!includeErrorMigrations && instr.migrateHttpErrors) continue; + entries.push({ + version: v.value, + changeClassName: change.constructor.name, + kind: "path-based", + functionName: instr.methodName, + path: instr.path, + methods: [...instr.methods], + migrateHttpErrors: instr.migrateHttpErrors, + order: entries.length, + }); + } + } + } + } + } + } else { + // direction === 'request'. client → head: walk reversedVersions + // (oldest-first) starting at index (reversedIdx + 1), i.e., the + // version just newer than the client's pin, up through head. + const reversedIdx = bundle.reversedVersionValues.indexOf(clientVersion); + for (let i = reversedIdx + 1; i < bundle.reversedVersions.length; i++) { + const v = bundle.reversedVersions[i]; + for (const change of v.changes) { + // Schema-based + const schemaInstrs = + change._alterRequestBySchemaInstructions.get(schemaName); + if (schemaInstrs) { + for (const instr of schemaInstrs) { + entries.push({ + version: v.value, + changeClassName: change.constructor.name, + kind: "schema-based", + functionName: instr.methodName, + schemaName, + order: entries.length, + }); + } + } + // Path-based + if (path) { + const pathInstrs = change._alterRequestByPathInstructions.get(path); + if (pathInstrs) { + for (const instr of pathInstrs) { + if (upperMethod && !instr.methods.has(upperMethod)) continue; + entries.push({ + version: v.value, + changeClassName: change.constructor.name, + kind: "path-based", + functionName: instr.methodName, + path: instr.path, + methods: [...instr.methods], + order: entries.length, + }); + } + } + } + } + } + } + + return entries; +} diff --git a/src/openapi.ts b/src/openapi.ts index 73a65b1..d233da2 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -3,6 +3,7 @@ import { zodToJsonSchema } from "zod-to-json-schema"; import type { RouteDefinition } from "./router.js"; import type { VersionBundle } from "./structure/versions.js"; import type { ZodSchemaRegistry } from "./schema-generation.js"; +import { getSchemaName as _getSchemaName } from "./zod-extend.js"; /** * OpenAPI 3.1.0 document shape (simplified). @@ -49,11 +50,12 @@ export interface OpenAPIBuildOptions { } /** - * Get a schema name from a ZodTypeAny if it has a _tsadwynName. + * Local indirection so the OpenAPI builder accepts the narrower + * `ZodTypeAny | null` shape it uses at callsites, while the underlying + * resolution goes through the canonical WeakMap-backed helper. */ function getSchemaName(schema: ZodTypeAny | null): string | null { - if (!schema) return null; - return (schema as any)._tsadwynName || null; + return _getSchemaName(schema); } /** diff --git a/src/per-client-default.ts b/src/per-client-default.ts new file mode 100644 index 0000000..5506360 --- /dev/null +++ b/src/per-client-default.ts @@ -0,0 +1,136 @@ +/** + * `perClientDefaultVersion` — canonical per-client default-version resolver + * suitable for the `apiVersionDefaultValue` option on `Tsadwyn`. + * + * Every Stripe-style adopter writes the same identify→resolvePin→fallback + * chain (usually against a DB row keyed by authenticated client id). This + * helper standardizes the pattern, adds a per-request WeakMap cache, and + * codifies the "what if the stored pin is no longer in the bundle?" policy. + */ + +import type { Request } from "express"; +import { TsadwynStructureError } from "./exceptions.js"; + +export interface PerClientDefaultVersionOptions { + /** Extract a stable client identifier from the request. Return null for unknown. */ + identify: (req: Request) => string | null | Promise; + /** Look up the client's stored pin. Return null if none. */ + resolvePin: (clientId: string) => string | null | Promise; + /** Value returned when identity is unknown or no pin is stored. Required. */ + fallback: string; + /** + * Policy when the resolved pin is not in `supportedVersions`. Default: 'fallback'. + * - 'fallback' — substitute `fallback` and emit warn (if logger supplied). + * - 'passthrough' — return the stale pin as-is (the downstream picker will + * treat it as unknown per its own onUnsupportedVersion). + * - 'reject' — throw TsadwynStructureError. + */ + onStalePin?: "fallback" | "passthrough" | "reject"; + /** Per-request caching. Default: 'per-request'. */ + cache?: "per-request" | "none"; + /** Optional structured logger for telemetry. */ + logger?: { + warn: (ctx: Record, msg: string) => void; + }; + /** Enables the stale-pin check. When omitted, the check is skipped. */ + supportedVersions?: readonly string[]; + /** + * Persist the client's pin. Required when `pinOnFirstResolve: true`. + * Called with (clientId, version) — tsadwyn doesn't know your storage. + */ + saveVersion?: (clientId: string, version: string) => void | Promise; + /** + * Stripe-style "pin-on-first-call" semantic: when an authenticated + * client has no stored pin, save `fallback` as their pin (via + * `saveVersion`) BEFORE returning it. Subsequent calls read the + * stored pin and behave identically to any other pinned client. + * + * Requires `saveVersion` to be supplied. Default: false. + * + * Does NOT overwrite existing stored pins (including stale ones — + * those flow through the `onStalePin` policy instead). + */ + pinOnFirstResolve?: boolean; +} + +/** + * Build an `apiVersionDefaultValue`-compatible resolver. + */ +export function perClientDefaultVersion( + opts: PerClientDefaultVersionOptions, +): (req: Request) => Promise { + if (opts.pinOnFirstResolve && typeof opts.saveVersion !== "function") { + throw new TsadwynStructureError( + "perClientDefaultVersion: pinOnFirstResolve requires a saveVersion callback " + + "to persist the pin on the client's first authenticated call.", + ); + } + + const cacheEnabled = opts.cache !== "none"; + const cache = new WeakMap>(); + + async function doResolve(req: Request): Promise { + const clientId = await Promise.resolve(opts.identify(req)); + if (clientId === null || clientId === undefined) { + opts.logger?.warn( + { reason: "unauthenticated" }, + "No client identity for default-version resolution; using fallback.", + ); + return opts.fallback; + } + const pin = await Promise.resolve(opts.resolvePin(clientId)); + if (pin === null || pin === undefined) { + // Stripe-style pin-on-first-call: persist the fallback as the + // client's pin so subsequent calls find it in storage. Only + // triggers on genuinely unpinned clients (stored = null) — + // stale stored pins are handled via onStalePin below. + if (opts.pinOnFirstResolve && opts.saveVersion) { + opts.logger?.warn( + { clientId, pin: opts.fallback, reason: "pin-on-first-resolve" }, + `Pinning client "${clientId}" to "${opts.fallback}" on first authenticated call.`, + ); + await Promise.resolve(opts.saveVersion(clientId, opts.fallback)); + } else { + opts.logger?.warn( + { clientId, reason: "no-stored-pin" }, + `No stored pin for client "${clientId}"; using fallback.`, + ); + } + return opts.fallback; + } + if (opts.supportedVersions && !opts.supportedVersions.includes(pin)) { + const stalePolicy = opts.onStalePin ?? "fallback"; + if (stalePolicy === "reject") { + throw new TsadwynStructureError( + `Stored API version pin "${pin}" for client "${clientId}" is not in the current VersionBundle.`, + ); + } + if (stalePolicy === "fallback") { + opts.logger?.warn( + { + pin, + reason: "stale", + clientId, + supportedVersions: [...opts.supportedVersions], + }, + `Stored pin "${pin}" is not in the bundle; using fallback.`, + ); + return opts.fallback; + } + // passthrough + return pin; + } + return pin; + } + + return function resolver(req: Request): Promise { + if (cacheEnabled) { + const cached = cache.get(req); + if (cached) return cached; + const promise = doResolve(req); + cache.set(req, promise); + return promise; + } + return doResolve(req); + }; +} diff --git a/src/raw-response.ts b/src/raw-response.ts new file mode 100644 index 0000000..896fd6b --- /dev/null +++ b/src/raw-response.ts @@ -0,0 +1,71 @@ +/** + * `raw()` — declarative response marker for routes that return binary / + * streaming content (PDFs, CSVs, images, pre-rendered thumbnails) rather + * than JSON. + * + * tsadwyn already detects Buffer / Readable returns at runtime and sends + * them with `application/octet-stream`. The marker adds three things: + * + * 1. Explicit mime type at registration time (no more octet-stream + * default for known formats). + * 2. A generation-time lint: response migrations that target a raw() + * route are dead code because the body is opaque bytes — tsadwyn + * warns. + * 3. A signal for future OpenAPI output to describe the response as + * `{type: string, format: binary}` with the correct content-type. + * + * Usage: + * + * router.get('/reports/:id/export.pdf', null, raw({mimeType: 'application/pdf'}), + * async (req) => reportService.renderPdf(req.params.id) // returns Buffer + * ); + */ + +import { z } from "zod"; + +/** Sentinel used to detect raw() markers without attaching enumerable noise. */ +export const RAW_RESPONSE_MARKER = Symbol.for("tsadwyn.rawResponse"); + +export interface RawResponseOptions { + mimeType: string; + /** + * Reserved for a future range-request implementation (§4.5 in the + * landscape doc). The flag is accepted today but not yet honored — + * tsadwyn currently streams the full buffer regardless. + */ + supportsRanges?: boolean; +} + +export interface RawResponseMarker { + readonly mimeType: string; + readonly supportsRanges: boolean; +} + +/** + * Produce a raw-response marker. The returned value is structurally a + * Zod schema (so it satisfies the `responseSchema` slot's type signature) + * with metadata attached for tsadwyn's runtime + generation-time checks. + */ +export function raw(options: RawResponseOptions) { + const schema = z.any() as any; + schema.mimeType = options.mimeType; + schema.supportsRanges = options.supportsRanges ?? false; + schema[RAW_RESPONSE_MARKER] = true; + return schema as z.ZodTypeAny & RawResponseMarker; +} + +/** + * Runtime detection: returns the marker's metadata if `schema` was + * produced by `raw()`, otherwise null. + */ +export function isRawResponse( + schema: unknown, +): RawResponseMarker | null { + if (schema && typeof schema === "object" && (schema as any)[RAW_RESPONSE_MARKER]) { + return { + mimeType: (schema as any).mimeType, + supportsRanges: (schema as any).supportsRanges ?? false, + }; + } + return null; +} diff --git a/src/request-context.ts b/src/request-context.ts new file mode 100644 index 0000000..5cb3cce --- /dev/null +++ b/src/request-context.ts @@ -0,0 +1,56 @@ +/** + * `currentRequest()` — request-scoped accessor for the raw Express `Request` + * inside a tsadwyn handler. + * + * Tsadwyn handlers receive a stripped view: `{ body, params, query, headers }`. + * Anything upstream middleware mutates on `req` (auth claims, tenant context, + * trace IDs) is invisible through that stripped view. This module captures + * the full `Request` into an `AsyncLocalStorage` immediately before invoking + * the user's handler, so handlers (and migration callbacks) can recover the + * raw request via `currentRequest()` without plumbing it through the handler + * signature or wiring a "mount-last" `captureRequestContext` middleware per + * route. + * + * Capture happens inside the framework dispatch wrapper — consumers never + * mount anything. + */ + +import type { Request } from "express"; +import { AsyncLocalStorage } from "node:async_hooks"; + +/** + * Internal ALS instance holding the current request for the duration of a + * dispatch. Exported for advanced use (tests, instrumentation); most code + * should call `currentRequest()` or `currentRequestOrNull()` instead. + */ +export const requestContextStorage = new AsyncLocalStorage(); + +/** + * Returns the Express `Request` for the currently-executing tsadwyn handler + * or migration callback. + * + * Throws if called outside a tsadwyn dispatch scope (e.g., during module + * import, from a background worker, or from a plain Express route that + * bypassed the tsadwyn router). Use `currentRequestOrNull()` if the absence + * of a request context is a valid state at the call site. + */ +export function currentRequest(): Request { + const req = requestContextStorage.getStore(); + if (!req) { + throw new Error( + "currentRequest() called outside a tsadwyn handler scope. " + + "This accessor only works inside handlers, migration callbacks, or code " + + "awaited by them. For optional access, use currentRequestOrNull().", + ); + } + return req; +} + +/** + * Returns the Express `Request` for the currently-executing tsadwyn handler, + * or `null` when called outside a dispatch scope. Use when absence is a + * valid state (library-internal helpers, optional instrumentation). + */ +export function currentRequestOrNull(): Request | null { + return requestContextStorage.getStore() ?? null; +} diff --git a/src/route-generation.ts b/src/route-generation.ts index e73b291..e73d9e2 100644 --- a/src/route-generation.ts +++ b/src/route-generation.ts @@ -18,6 +18,7 @@ import { AlterRequestByPathInstruction, AlterResponseByPathInstruction, } from "./structure/data.js"; +import { isRawResponse } from "./raw-response.js"; import { ZodSchemaRegistry, generateVersionedSchemas } from "./schema-generation.js"; import { TsadwynHeadRequestValidationError, @@ -28,9 +29,11 @@ import { RouteRequestBySchemaConverterDoesNotApplyToAnythingError, RouteResponseBySchemaConverterDoesNotApplyToAnythingError, HttpError, + ValidationError, } from "./exceptions.js"; import { getSchemaName } from "./zod-extend.js"; import { AlterSchemaInstructionFactory } from "./structure/schemas.js"; +import { requestContextStorage } from "./request-context.js"; /** * Build a ZodSchemaRegistry from the route definitions AND from schemas @@ -208,6 +211,8 @@ function validatePathConverterUsage(versions: VersionBundle, routes: RouteDefini interface ResponseMigration { transformer: (response: ResponseInfo) => void; migrateHttpErrors: boolean; + /** When true, migration runs on body-less responses (HEAD, 204, 304). */ + headerOnly: boolean; } /** @@ -521,6 +526,13 @@ export function generateVersionedRouters( * returned in `versionedWebhookRoutes`. */ webhookRoutes?: RouteDefinition[], + /** + * Optional domain-exception → HttpError mapper. Invoked inside each + * generated handler's catch block BEFORE the HTTP-likeness check, so + * domain exceptions become HttpErrors that flow through response + * migrations. + */ + errorMapper?: (err: unknown) => HttpError | null, ): VersionedRouterResult { // Combine regular + webhook routes for validation and schema discovery const allRoutes = webhookRoutes @@ -533,6 +545,96 @@ export function generateVersionedRouters( // T-1604: Validate path-based converter usage against all routes validatePathConverterUsage(versions, allRoutes); + // Route-shadowing detection is run by the enclosing Tsadwyn application + // with a configurable policy (warn/throw/silent) before this function is + // called. Direct callers of generateVersionedRouters that want the lint + // can invoke detectRouteShadows + reportRouteShadows explicitly. + + // Lint: response migrations (path- or schema-based) targeting a route + // whose responseSchema is a raw() marker. The response body is opaque + // bytes (Buffer / Readable) — transformer code that touches `res.body` + // as JSON is dead. + for (const version of versions.versions) { + for (const change of version.changes) { + for (const [path, instrs] of change._alterResponseByPathInstructions) { + const normalizedPath = path.replace(/\/+$/, ""); + for (const instr of instrs) { + for (const method of instr.methods) { + const route = allRoutes.find( + (r) => + r.path.replace(/\/+$/, "") === normalizedPath && + r.method.toUpperCase() === method, + ); + if (!route) continue; + if (isRawResponse(route.responseSchema)) { + // eslint-disable-next-line no-console + console.warn( + `tsadwyn: response migration "${instr.methodName}" targets ` + + `${method} ${path} which is a raw()/binary route. ` + + `Transformers on raw routes are dead code — the response ` + + `body is opaque bytes (not JSON). Remove the migration or ` + + `register a JSON responseSchema instead.`, + ); + } + } + } + } + } + } + + // Lint: statusCode: 204 + a non-null responseSchema. The in-memory + // migration pipeline runs correctly, but Node's HTTP server strips bodies + // on 204 responses at the wire level per RFC 9110 §15.3.5 (verified + // empirically against api.stripe.com: Stripe uses 200+body for DELETE, + // never 204+body). Warn loudly so consumers discover this during dev + // rather than wondering why their client sees empty bodies in prod. + for (const route of allRoutes) { + if (route.statusCode === 204 && route.responseSchema !== null) { + // eslint-disable-next-line no-console + console.warn( + `tsadwyn: route [${route.method}] ${route.path} has statusCode: 204 ` + + `AND a non-null responseSchema. Node's HTTP writer strips the body ` + + `on 204 responses at the wire level per RFC 9110 §15.3.5 — the body ` + + `won't arrive at the client. Use statusCode: 200 for delete-envelope ` + + `responses (Stripe pattern), or use the deletedResponseSchema() ` + + `helper which defaults to 200. If you intentionally want an empty ` + + `response, set responseSchema to null.`, + ); + } + } + + // Lint: body-mutating path-based response migrations targeting routes that + // return 204 (No Content) or 304 (Not Modified). Body transformers are + // skipped on body-less responses — the migration is dead code unless it + // declares `headerOnly: true` (which opts in to running on body-less). + for (const version of versions.versions) { + for (const change of version.changes) { + for (const [path, instrs] of change._alterResponseByPathInstructions) { + for (const instr of instrs) { + if ((instr as any).headerOnly) continue; + const normalizedPath = path.replace(/\/+$/, ""); + for (const method of instr.methods) { + const route = allRoutes.find( + (r) => + r.path.replace(/\/+$/, "") === normalizedPath && + r.method.toUpperCase() === method, + ); + if (!route) continue; + if (route.statusCode === 204 || route.statusCode === 304) { + // eslint-disable-next-line no-console + console.warn( + `tsadwyn: response migration "${instr.methodName}" targets ` + + `${method} ${path} which returns statusCode ${route.statusCode}. ` + + `Body transformers are skipped on body-less responses — use ` + + `{ headerOnly: true } if your migration only touches headers.`, + ); + } + } + } + } + } + } + const baseRegistry = buildRegistryFromRoutes(allRoutes, versions); const versionedSchemas = generateVersionedSchemas(versions, baseRegistry); const result = new Map(); @@ -557,8 +659,26 @@ export function generateVersionedRouters( const router = Router(); const registry = versionedSchemas.get(version.value); + // Track methods-per-path for this version so we can: + // (a) emit a warn when both GET and HEAD are explicitly registered + // (b) register a 405-with-Allow HEAD catch-all for paths that have + // other methods but no GET and no explicit HEAD. + const methodsPerPath = new Map>(); + + // Reorder routes so explicit HEAD entries precede their GET siblings for + // the same path. Express's Route object auto-falls-back HEAD → GET when a + // Route matches the path; iterating Routes in registration order means the + // first GET match will intercept HEAD before a later explicit HEAD Route + // is reached. Registering HEAD first makes the explicit handler win. + const sortedRoutes = [...currentRoutes].sort((a, b) => { + if (a.path !== b.path) return 0; + if (a.method === "HEAD" && b.method === "GET") return -1; + if (a.method === "GET" && b.method === "HEAD") return 1; + return 0; + }); + // Mount only non-deleted, non-webhook routes for this version - for (const routeDef of currentRoutes) { + for (const routeDef of sortedRoutes) { if (routeDef.tags.includes(_DELETED_ROUTE_TAG)) { continue; // Skip deleted routes } @@ -567,12 +687,22 @@ export function generateVersionedRouters( if (webhookPaths.has(routeKey)) { continue; } + + // Record method for path so we can compute 405 Allow / overlap warnings below. + const method = routeDef.method.toUpperCase(); + if (!methodsPerPath.has(routeDef.path)) { + methodsPerPath.set(routeDef.path, new Set()); + } + methodsPerPath.get(routeDef.path)!.add(method); + const expressMethod = routeDef.method.toLowerCase() as | "get" | "post" | "put" | "patch" - | "delete"; + | "delete" + | "head" + | "options"; // Determine the versioned request schema for validation const requestSchemaName = getSchemaName(routeDef.requestSchema); @@ -624,6 +754,7 @@ export function generateVersionedRouters( version.value, dependencyOverrides, versionedQuerySchema, + errorMapper, ); // T-602: Collect middleware (router-level + route-level) @@ -641,6 +772,34 @@ export function generateVersionedRouters( } } + // HEAD method post-processing for this version: + // 1. Warn when both GET and an explicit HEAD are registered for the same + // path — the explicit HEAD will override Express's auto-mirror, which + // is rarely the intent. + // 2. Register a 405 Method Not Allowed + Allow handler for HEAD on paths + // that have other methods registered but no GET (no auto-mirror) and + // no explicit HEAD. Otherwise HEAD on such paths 404s silently. + for (const [path, methods] of methodsPerPath) { + if (methods.has("GET") && methods.has("HEAD")) { + // eslint-disable-next-line no-console + console.warn( + `tsadwyn: route ${path} has BOTH GET and HEAD registered. Express ` + + `auto-mirrors HEAD → GET; an explicit HEAD handler overrides that. ` + + `Verify both handlers are intentional, or remove the HEAD to rely ` + + `on auto-mirror.`, + ); + } + if (!methods.has("GET") && !methods.has("HEAD")) { + const allowList = [...methods, "OPTIONS"].sort().join(", "); + router.head(path, (_req, res) => { + res.setHeader("Allow", allowList); + res.status(405).json({ + detail: `Method Not Allowed. Allowed: ${allowList}`, + }); + }); + } + } + result.set(version.value, router); // Snapshot the current routes for this version, split into regular and webhook const regularSnapshot: RouteDefinition[] = []; @@ -758,6 +917,7 @@ function collectResponseMigrations( migrations.push({ transformer: instr.transformer, migrateHttpErrors: instr.migrateHttpErrors, + headerOnly: (instr as any).headerOnly ?? false, }); } } @@ -771,6 +931,7 @@ function collectResponseMigrations( migrations.push({ transformer: instr.transformer, migrateHttpErrors: instr.migrateHttpErrors, + headerOnly: (instr as any).headerOnly ?? false, }); } } @@ -822,22 +983,44 @@ function isNonJsonResponse(result: any): boolean { /** * T-605: Send a non-JSON response appropriately. + * + * When `isHead` is true, headers are still set (content-type, + * content-length if known) so a client can read metadata from a HEAD + * probe, but no body bytes are written — per RFC 7231 §4.3.2, a response + * to HEAD must never carry a message body. For streaming results this + * means we suppress the pipe and close the response after headers. */ -function sendNonJsonResponse(res: Response, result: any, statusCode: number): void { +function sendNonJsonResponse( + res: Response, + result: any, + statusCode: number, + isHead: boolean = false, +): void { if (Buffer.isBuffer(result)) { res.status(statusCode); if (!res.getHeader("content-type")) { res.setHeader("content-type", "application/octet-stream"); } res.setHeader("content-length", result.length.toString()); - res.end(result); + if (isHead) { + res.end(); + } else { + res.end(result); + } } else if (typeof result.pipe === "function") { // ReadableStream / Node.js Readable res.status(statusCode); if (!res.getHeader("content-type")) { res.setHeader("content-type", "application/octet-stream"); } - result.pipe(res); + if (isHead) { + // Don't pipe — HEAD forbids a body. If the caller needed + // content-length for their HEAD clients, they should emit a Buffer + // (length known) or use a schema+JSON response instead of raw streaming. + res.end(); + } else { + result.pipe(res); + } } else if (typeof result === "string") { res.status(statusCode); if (!res.getHeader("content-type")) { @@ -845,7 +1028,11 @@ function sendNonJsonResponse(res: Response, result: any, statusCode: number): vo } const bodyBuf = Buffer.from(result, "utf-8"); res.setHeader("content-length", bodyBuf.length.toString()); - res.end(result); + if (isHead) { + res.end(); + } else { + res.end(result); + } } } @@ -871,21 +1058,30 @@ function createVersionedHandler( currentVersion: string, dependencyOverrides?: Map, versionedQuerySchema?: ZodTypeAny | null, + errorMapper?: (err: unknown) => HttpError | null, ): (req: Request, res: Response, next: NextFunction) => void { const successStatus = routeDef.statusCode ?? 200; - return async (req: Request, res: Response, next: NextFunction) => { + return (req: Request, res: Response, next: NextFunction) => { + // Capture the raw Express Request into ALS so handlers + migration + // callbacks can recover middleware-injected state (req.user, claims, + // trace IDs, etc.) via currentRequest() without threading it through + // the stripped handler signature. + requestContextStorage.run(req, () => { + void dispatch(req, res, next); + }); + }; + + async function dispatch(req: Request, res: Response, next: NextFunction) { try { - // T-600: Validate path parameters + // T-600: Validate path parameters. Thrown as ValidationError so + // the error flows through the catch block's errorMapper + + // migrateHttpErrors pipeline (consumer can reshape the envelope). if (routeDef.paramsSchema) { const paramsResult = routeDef.paramsSchema.safeParse(req.params); if (!paramsResult.success) { - res.status(422).json({ - detail: paramsResult.error.errors, - }); - return; + throw new ValidationError("params", paramsResult.error.errors); } - // Apply parsed params back (handles coercion) Object.assign(req.params, paramsResult.data); } @@ -894,12 +1090,8 @@ function createVersionedHandler( if (activeQuerySchema) { const queryResult = activeQuerySchema.safeParse(req.query); if (!queryResult.success) { - res.status(422).json({ - detail: queryResult.error.errors, - }); - return; + throw new ValidationError("query", queryResult.error.errors); } - // Apply parsed query back for (const [key, value] of Object.entries(queryResult.data as Record)) { (req.query as any)[key] = value; } @@ -911,10 +1103,7 @@ function createVersionedHandler( if (versionedRequestSchema && body !== undefined && body !== null) { const parseResult = versionedRequestSchema.safeParse(body); if (!parseResult.success) { - res.status(422).json({ - detail: parseResult.error.errors, - }); - return; + throw new ValidationError("body", parseResult.error.errors); } body = parseResult.data; } @@ -1041,9 +1230,22 @@ function createVersionedHandler( const activeHandler = effectiveHandler || routeDef.handler; const result = await activeHandler(handlerReq); + // Compute HEAD early so every wire-emit path below can suppress + // body bytes per RFC 7231 §4.3.2. Previously this flag was only + // checked in the JSON and null-result paths; Buffer / stream / + // plain-string paths leaked body content on HEAD. + const isHead = req.method === "HEAD"; + + // raw() marker: set the declared mime type so the non-JSON path + // below picks it up (sendNonJsonResponse preserves pre-set headers). + const rawMarker = isRawResponse(routeDef.responseSchema); + if (rawMarker && !res.getHeader("content-type")) { + res.setHeader("content-type", rawMarker.mimeType); + } + // T-605: Handle non-JSON responses if (isNonJsonResponse(result)) { - sendNonJsonResponse(res, result, successStatus); + sendNonJsonResponse(res, result, successStatus, isHead); return; } @@ -1051,7 +1253,7 @@ function createVersionedHandler( if (typeof result === "string") { // Check if response migrations need to run on this if (responseMigrations.length === 0) { - sendNonJsonResponse(res, result, successStatus); + sendNonJsonResponse(res, result, successStatus, isHead); return; } // If there are migrations and it looks like JSON, try to parse and migrate @@ -1072,15 +1274,61 @@ function createVersionedHandler( const bodyBuffer = Buffer.from(jsonBody, "utf-8"); res.setHeader("content-length", bodyBuffer.length.toString()); res.setHeader("content-type", "application/json; charset=utf-8"); - res.status(responseInfo.statusCode).end(jsonBody); + res.status(responseInfo.statusCode).end(isHead ? undefined : jsonBody); return; } catch { // Not JSON - send as string - sendNonJsonResponse(res, result, successStatus); + sendNonJsonResponse(res, result, successStatus, isHead); return; } } + // Detect body-less contexts. tsadwyn's default is permissive: a 204 + // response MAY carry a body if the handler returned one (Stripe-style — + // Stripe returns bodies with 204 on some endpoints even though RFC + // 9110 §15.3.5 says 204 "cannot contain content"). Consumers relying on + // that behavior get it out of the box. + // + // The short-circuit only triggers when the handler's return value is + // `null` or `undefined` — then we emit status + empty body and skip + // body-mutating response migrations (running only `headerOnly` or + // `migrateHttpErrors`-flagged migrations, which opt in to body-less + // contexts explicitly). + // `isHead` was computed earlier before the non-JSON / string branches. + const isNullResult = result === undefined || result === null; + + if (isNullResult && !isHead) { + const responseInfo = new ResponseInfo(undefined, successStatus); + for (const migration of responseMigrations) { + // Body-less contexts (204, 304, null handler return): only + // headerOnly migrations run — body-mutating transformers would + // NPE on `undefined`. `migrateHttpErrors` is about error vs + // success responses, orthogonal to body-presence. + if (!migration.headerOnly) { + continue; + } + migration.transformer(responseInfo); + } + _applyResponseInfoToExpressResponse(res, responseInfo); + + // If migrations populated a body (e.g., legacy clients want a + // 200+{deleted: true} shape where head returns 204+empty), emit it. + // Otherwise keep the response body-less per HTTP spec for 204/304. + if ( + responseInfo.body !== undefined && + responseInfo.body !== null + ) { + const jsonBody = JSON.stringify(responseInfo.body); + const bodyBuffer = Buffer.from(jsonBody, "utf-8"); + res.setHeader("content-length", bodyBuffer.length.toString()); + res.setHeader("content-type", "application/json; charset=utf-8"); + res.status(responseInfo.statusCode).end(jsonBody); + } else { + res.status(responseInfo.statusCode).end(); + } + return; + } + // T-403: Handle array and object response bodies - deep clone to avoid mutation const responseBody = typeof result === "object" && result !== null @@ -1094,8 +1342,20 @@ function createVersionedHandler( successStatus, ); for (const migration of responseMigrations) { - // T-401: Skip response migration if status >= 300 and migrateHttpErrors is false - if (responseInfo.statusCode >= 300 && !migration.migrateHttpErrors) { + // HEAD: the wire-level body is stripped. Only headerOnly + // migrations run (body-mutating transformers are dead code). + if (isHead && !migration.headerOnly) { + continue; + } + // 3xx/4xx/5xx: body-mutating migrations only fire when the + // migration has opted into error-response migration via + // `migrateHttpErrors: true` (default TRUE — matches Stripe). + // headerOnly migrations always run regardless of status. + if ( + responseInfo.statusCode >= 300 && + !migration.migrateHttpErrors && + !migration.headerOnly + ) { continue; } migration.transformer(responseInfo); @@ -1103,20 +1363,53 @@ function createVersionedHandler( _applyResponseInfoToExpressResponse(res, responseInfo); - // T-606: Recalculate content-length after response migration - const jsonBody = JSON.stringify(responseInfo.body); - const bodyBuffer = Buffer.from(jsonBody, "utf-8"); - res.setHeader("content-length", bodyBuffer.length.toString()); - res.setHeader("content-type", "application/json; charset=utf-8"); - res.status(responseInfo.statusCode).end(jsonBody); + // If a migration cleared the body (e.g., head 200+body → legacy + // 204+empty), emit an empty response rather than trying to + // JSON.stringify undefined. + if ( + responseInfo.body === undefined || + responseInfo.body === null + ) { + res.status(responseInfo.statusCode).end(); + } else { + // T-606: Recalculate content-length after response migration + const jsonBody = JSON.stringify(responseInfo.body); + const bodyBuffer = Buffer.from(jsonBody, "utf-8"); + res.setHeader("content-length", bodyBuffer.length.toString()); + res.setHeader("content-type", "application/json; charset=utf-8"); + // For HEAD: HTTP spec requires no body. Content-Length still reflects + // the would-be body so intermediaries can size their buffers. + res.status(responseInfo.statusCode).end(isHead ? undefined : jsonBody); + } } else { res.status(successStatus).json(result); } } catch (err) { + // Consumer-supplied domain-exception → HttpError mapper. Invoked BEFORE + // the HTTP-likeness check so plain domain exceptions can be translated + // into HttpError and flow through the response-migration pipeline. + let mappedErr: unknown = err; + if (errorMapper) { + try { + const mapped = errorMapper(err); + if (mapped !== null && mapped !== undefined) { + mappedErr = mapped; + } + } catch { + // Mapper threw — don't crash the pipeline. Fall through to + // next(err) with the ORIGINAL error so Express's error handler + // renders a 500. The mapper's own error is swallowed (per spec: + // mapper failures must not mask handler failures). + next(err); + return; + } + } + // T-1900: Intercept HttpError (or error-like objects with statusCode) and // run response migrations with migrateHttpErrors=true before sending the // error response. This mirrors Tsadwyn's HTTPException interception. - if (_isHttpLikeError(err)) { + if (_isHttpLikeError(mappedErr)) { + const err = mappedErr; // shadow for the existing block below const httpErr = err as { statusCode: number; body?: any; message?: string; headers?: Record }; const errStatusCode = httpErr.statusCode; const errBody = httpErr.body !== undefined @@ -1160,7 +1453,7 @@ function createVersionedHandler( // Non-HTTP errors continue to the Express error handler next(err); } - }; + } } /** diff --git a/src/route-shadowing.ts b/src/route-shadowing.ts new file mode 100644 index 0000000..5954163 --- /dev/null +++ b/src/route-shadowing.ts @@ -0,0 +1,207 @@ +/** + * Route-shadowing detector. + * + * path-to-regexp (Express's routing library) is FIRST-MATCH-WINS. Registering + * `GET /users/:id` before `GET /users/search` silently routes `/search` to + * the `:id` handler, producing mystery 400s ("search is not a UUID") far + * from the actual root cause. Production consumers hit this bug once per + * real-world app. + * + * This module scans a flat `RouteDefinition[]` list in registration order + * and detects cases where an earlier wildcard/parameterized path would + * catch a later literal path on the same method. Reports one diagnostic + * per shadow pair so the user can either reorder the registration or + * acknowledge the intent (e.g., via `'silent'` mode). + * + * Detection strategy: + * - Build a "matcher" regex from each path pattern by replacing `:param` + * and `*` segments with wildcard fragments. + * - For each fully-literal later route, check whether any earlier route's + * matcher regex matches the literal path (same method). + * - Parameterized later routes are skipped: two overlapping wildcards + * are either a legit duplicate (Express will error) or both shadow + * each other ambiguously, which isn't the production bug pattern. + * + * This is a heuristic, not a full path-to-regexp reimplementation. It + * catches the common case. When a consumer wants CI enforcement they can + * set `onRouteShadowing: 'throw'`. + */ + +import type { RouteDefinition } from "./router.js"; +import { TsadwynStructureError } from "./exceptions.js"; + +export type RouteShadowingPolicy = "warn" | "throw" | "silent"; + +export interface RouteShadowingLogger { + warn: (ctx: Record, msg: string) => void; +} + +export interface RouteShadow { + /** The earlier-registered path that catches the later literal. */ + shadower: { method: string; path: string }; + /** The later-registered literal path that gets caught. */ + shadowed: { method: string; path: string }; +} + +/** + * Walk `routes` in registration order and return every shadow pair. + * Complexity: O(n²) per method. Routes with non-literal paths (wildcards, + * params) are only considered as potential shadowers, not shadowees — + * detecting overlapping-wildcard shadows requires a different heuristic + * and is out of scope. + */ +export function detectRouteShadows( + routes: ReadonlyArray, +): RouteShadow[] { + const shadows: RouteShadow[] = []; + // Group routes by method preserving registration order. + const byMethod = new Map(); + for (const route of routes) { + const method = route.method.toUpperCase(); + const bucket = byMethod.get(method) ?? []; + bucket.push(route); + byMethod.set(method, bucket); + } + + for (const [method, bucket] of byMethod) { + for (let i = 0; i < bucket.length; i++) { + const later = bucket[i]; + if (isLiteralPath(later.path) === false) continue; // skip wildcard later-routes + for (let j = 0; j < i; j++) { + const earlier = bucket[j]; + if (isLiteralPath(earlier.path)) { + // Both literal — either different paths (no shadow) or the exact + // same path (Express errors on duplicate — not our problem here). + continue; + } + if (matchesPath(earlier.path, later.path)) { + shadows.push({ + shadower: { method, path: earlier.path }, + shadowed: { method, path: later.path }, + }); + } + } + } + } + + return shadows; +} + +/** + * Apply the configured policy to the detected shadows. Separate from + * detection so callers can log, throw, or format errors themselves. + */ +export function reportRouteShadows( + shadows: ReadonlyArray, + policy: RouteShadowingPolicy, + logger?: RouteShadowingLogger, +): void { + if (shadows.length === 0 || policy === "silent") return; + + const messages = shadows.map( + (s) => + `${s.shadower.method} ${s.shadower.path} (registered earlier) shadows ` + + `${s.shadowed.method} ${s.shadowed.path}. Reorder: register the literal ` + + `path BEFORE the parameterized one.`, + ); + + if (policy === "throw") { + throw new TsadwynStructureError( + `Route shadowing detected:\n - ${messages.join("\n - ")}`, + ); + } + + // warn — emit one log line per shadow so each is greppable. + const out = logger?.warn ?? defaultWarnLogger; + for (let i = 0; i < shadows.length; i++) { + out( + { + shadower: shadows[i].shadower, + shadowed: shadows[i].shadowed, + }, + messages[i], + ); + } +} + +function defaultWarnLogger( + ctx: Record, + msg: string, +): void { + // Structured-first: keep the message + context both accessible at a + // glance even when the consumer hasn't supplied a logger. + // eslint-disable-next-line no-console + console.warn(`[tsadwyn:route-shadowing] ${msg}`, ctx); +} + +/** + * A path is "literal" if it contains no path-to-regexp wildcard markers. + * Anything with `:param`, `*`, or `(...)` regex groups is considered a + * pattern, not a literal. + */ +function isLiteralPath(path: string): boolean { + return !/[:*()\\]/.test(path); +} + +/** + * Build a matcher regex from a path-to-regexp pattern and test it against + * a concrete literal path. Supports the subset of patterns tsadwyn users + * actually write: `:param`, `:param(\\d+)`, `*`, and `(...)` groups. + */ +function matchesPath(pattern: string, literal: string): boolean { + const regex = patternToRegex(pattern); + return regex.test(literal); +} + +function patternToRegex(pattern: string): RegExp { + // Escape regex metacharacters OTHER than the ones we'll substitute for. + // Strategy: walk the pattern and emit regex fragments. + let out = ""; + let i = 0; + while (i < pattern.length) { + const ch = pattern[i]; + if (ch === ":") { + // Param segment. Read the name, then optionally a `(...)` regex group. + i++; + while (i < pattern.length && /[A-Za-z0-9_]/.test(pattern[i])) i++; + if (pattern[i] === "(") { + // Capture the grouped regex and use it as the segment matcher. + let depth = 1; + i++; + let inner = ""; + while (i < pattern.length && depth > 0) { + if (pattern[i] === "(") depth++; + else if (pattern[i] === ")") { + depth--; + if (depth === 0) break; + } + inner += pattern[i]; + i++; + } + i++; // skip closing ) + // Be conservative — treat user regex groups as `[^/]+` matchers so + // an overly-restrictive user regex doesn't cause us to MISS a + // shadow. Detection favors false-positive-warn over missed shadows. + void inner; + out += "[^/]+"; + } else { + out += "[^/]+"; + } + continue; + } + if (ch === "*") { + // Match rest of path — one or more segments. + out += ".*"; + i++; + continue; + } + // Regex metacharacter escape. + if (/[.+?^${}()|[\]\\]/.test(ch)) { + out += "\\" + ch; + } else { + out += ch; + } + i++; + } + return new RegExp("^" + out + "$"); +} diff --git a/src/route-simulation.ts b/src/route-simulation.ts new file mode 100644 index 0000000..b6fc1e2 --- /dev/null +++ b/src/route-simulation.ts @@ -0,0 +1,420 @@ +/** + * `simulateRoute` — answer "is tsadwyn responsible for this request, and + * what would it do?" without dispatching. Matches the exact same route + * table dispatch would use, explains every candidate match, surfaces the + * migration chain that would fire, and optionally previews the up-migrated + * request body. + */ + +import type { Request } from "express"; +import type { RouteDefinition } from "./router.js"; +import { _DELETED_ROUTE_TAG } from "./router.js"; +import type { VersionBundle } from "./structure/versions.js"; +import { RequestInfo, ResponseInfo } from "./structure/data.js"; +import { getSchemaName } from "./zod-extend.js"; + +export interface SimulateRouteOptions { + method: string; + path: string; + /** Explicit version — takes precedence over headers and default. */ + version?: string; + /** Request headers (e.g., x-api-version). */ + headers?: Record; + /** Optional body — enables `upMigratedBody` preview. */ + body?: unknown; +} + +export interface RouteCandidate { + method: string; + path: string; + matched: boolean; + reason: string; + regex: string; + params?: Record; +} + +export interface MatchedRouteSummary { + method: string; + path: string; + params: Record; + handler: string; + schemaName: string | null; +} + +export interface FallthroughSummary { + reason: string; + availableAtOtherVersions: string[]; + closestMisses: Array<{ method: string; path: string; diff: string }>; +} + +export interface MigrationSummary { + schemaName: string | null; + fromVersion: string; + toVersion: string; + path: string; +} + +export interface SimulationResult { + resolvedVersion: string; + matchedRoute: MatchedRouteSummary | null; + candidates: RouteCandidate[]; + requestMigrations: MigrationSummary[]; + responseMigrations: MigrationSummary[]; + fallthrough: FallthroughSummary | null; + upMigratedBody?: unknown; +} + +interface SimulateApp { + versions: VersionBundle; + apiVersionHeaderName?: string; + apiVersionDefaultValue?: + | string + | ((req: Request) => string | Promise) + | null; + readonly _versionedRoutes?: Map; +} + +interface PathMatchResult { + matched: boolean; + params?: Record; + reason: string; + regex: string; +} + +/** + * Simplified path matcher that mirrors path-to-regexp's first-match behavior + * for the subset of patterns tsadwyn exposes: literal segments and `:param` + * captures. Built in-house so we don't have to pin the consumer's + * path-to-regexp version to get matching parity. + */ +function matchPath(pattern: string, input: string): PathMatchResult { + const patternSegments = pattern.split("/").filter((s) => s.length > 0); + const inputSegments = input.split("/").filter((s) => s.length > 0); + + // Build a pseudo-regex for introspection output. + const regexParts = patternSegments.map((s) => + s.startsWith(":") ? `(?<${s.slice(1)}>[^/]+)` : s, + ); + const regex = `^/${regexParts.join("/")}$`; + + if (patternSegments.length !== inputSegments.length) { + const diff = inputSegments.length - patternSegments.length; + if (diff > 0) { + const extra = inputSegments.slice(patternSegments.length).join("/"); + return { + matched: false, + reason: `extra segments beyond match: /${extra}`, + regex, + }; + } + const missing = patternSegments.slice(inputSegments.length).join("/"); + return { + matched: false, + reason: `missing segments: /${missing}`, + regex, + }; + } + + const params: Record = {}; + for (let i = 0; i < patternSegments.length; i++) { + const p = patternSegments[i]; + const v = inputSegments[i]; + if (p.startsWith(":")) { + params[p.slice(1)] = v; + } else if (p !== v) { + return { + matched: false, + reason: `segment ${i} mismatch: expected "${p}", got "${v}"`, + regex, + }; + } + } + return { matched: true, reason: "exact match", regex, params }; +} + +function closestMissDiff(pattern: string, input: string): string { + const pSeg = pattern.split("/").filter((s) => s.length > 0); + const iSeg = input.split("/").filter((s) => s.length > 0); + if (pSeg.length < iSeg.length) { + const extra = iSeg.slice(pSeg.length).join("/"); + return `one extra segment: /${extra}`; + } + if (pSeg.length > iSeg.length) { + return `shorter by ${pSeg.length - iSeg.length} segment(s)`; + } + return "segment content differs"; +} + +export function simulateRoute( + app: SimulateApp, + opts: SimulateRouteOptions, +): SimulationResult { + const versionedRoutes = (app as any)._versionedRoutes as + | Map + | undefined; + + if (!versionedRoutes) { + throw new Error( + "simulateRoute: app has no _versionedRoutes — did you call generateAndIncludeVersionedRouters()?", + ); + } + + // Resolve version: explicit > headers > apiVersionDefaultValue > head. + const versionHeaderName = (app.apiVersionHeaderName ?? "x-api-version").toLowerCase(); + const headerValue = + opts.headers?.[versionHeaderName] ?? + opts.headers?.[versionHeaderName.toUpperCase()]; + let resolvedVersion: string | undefined = opts.version; + if (!resolvedVersion && typeof headerValue === "string") { + resolvedVersion = headerValue; + } + if (!resolvedVersion && typeof app.apiVersionDefaultValue === "string") { + resolvedVersion = app.apiVersionDefaultValue; + } + // Note: intentionally do NOT resolve async apiVersionDefaultValue here. + // simulateRoute is synchronous so consumers can call it at any time + // (CLI, REPL, test, debugger) without juggling Promises. When the + // default is a function, we fall back to head — if consumers want the + // async-resolver value they pass `version` explicitly. + if (!resolvedVersion) { + resolvedVersion = app.versions.versionValues[0]; + } + + const routesAtVersion = + versionedRoutes.get(resolvedVersion) ?? []; + + const inputMethod = opts.method.toUpperCase(); + const candidates: RouteCandidate[] = []; + let matchedRoute: MatchedRouteSummary | null = null; + + for (const route of routesAtVersion) { + if (route.tags.includes(_DELETED_ROUTE_TAG)) continue; + const routeMethod = route.method.toUpperCase(); + const match = matchPath(route.path, opts.path); + + const candidate: RouteCandidate = { + method: routeMethod, + path: route.path, + matched: false, + reason: "", + regex: match.regex, + params: match.params, + }; + + if (routeMethod !== inputMethod) { + candidate.matched = false; + candidate.reason = match.matched + ? `method mismatch: expected ${routeMethod}, got ${inputMethod}` + : `method mismatch (${routeMethod})`; + } else if (match.matched) { + candidate.matched = true; + if (matchedRoute !== null) { + // An earlier candidate already matched — this one is shadowed + // by registration order (path-to-regexp is first-match-wins). + candidate.reason = `shadowed by earlier match ${matchedRoute.path} (first-match-wins)`; + } else { + candidate.reason = "exact match"; + matchedRoute = { + method: routeMethod, + path: route.path, + params: match.params ?? {}, + handler: route.funcName ?? route.handler.name ?? "", + schemaName: getSchemaName(route.responseSchema) ?? null, + }; + } + } else { + candidate.matched = false; + candidate.reason = match.reason; + } + + candidates.push(candidate); + } + + // Compute migration chain for the matched route (if any). + let requestMigrations: MigrationSummary[] = []; + let responseMigrations: MigrationSummary[] = []; + let upMigratedBody: unknown = undefined; + + if (matchedRoute) { + const routeDef = routesAtVersion.find( + (r) => + r.path === matchedRoute!.path && + r.method.toUpperCase() === matchedRoute!.method, + ); + if (routeDef) { + const reqSchemaName = getSchemaName(routeDef.requestSchema); + const resSchemaName = getSchemaName(routeDef.responseSchema); + + // Request migrations: client → head + const reversedIdx = app.versions.reversedVersionValues.indexOf( + resolvedVersion, + ); + if (reversedIdx !== -1) { + for ( + let i = reversedIdx + 1; + i < app.versions.reversedVersions.length; + i++ + ) { + const v = app.versions.reversedVersions[i]; + for (const change of v.changes) { + if (reqSchemaName) { + const instrs = + change._alterRequestBySchemaInstructions.get(reqSchemaName); + if (instrs) { + for (const _instr of instrs) { + requestMigrations.push({ + schemaName: reqSchemaName, + fromVersion: resolvedVersion, + toVersion: v.value, + path: matchedRoute.path, + }); + } + } + } + const pathInstrs = + change._alterRequestByPathInstructions.get(matchedRoute.path); + if (pathInstrs) { + for (const instr of pathInstrs) { + if (instr.methods.has(matchedRoute.method)) { + requestMigrations.push({ + schemaName: null, + fromVersion: resolvedVersion, + toVersion: v.value, + path: matchedRoute.path, + }); + } + } + } + } + } + } + + // Response migrations: head → client + const clientIdx = app.versions.versionValues.indexOf(resolvedVersion); + if (clientIdx !== -1) { + for (let i = 0; i < clientIdx; i++) { + const v = app.versions.versions[i]; + for (const change of v.changes) { + if (resSchemaName) { + const instrs = + change._alterResponseBySchemaInstructions.get(resSchemaName); + if (instrs) { + for (const _instr of instrs) { + responseMigrations.push({ + schemaName: resSchemaName, + fromVersion: v.value, + toVersion: resolvedVersion, + path: matchedRoute.path, + }); + } + } + } + const pathInstrs = + change._alterResponseByPathInstructions.get(matchedRoute.path); + if (pathInstrs) { + for (const instr of pathInstrs) { + if (instr.methods.has(matchedRoute.method)) { + responseMigrations.push({ + schemaName: null, + fromVersion: v.value, + toVersion: resolvedVersion, + path: matchedRoute.path, + }); + } + } + } + } + } + } + + // Up-migrate the supplied body preview. + if (opts.body !== undefined && opts.body !== null) { + const cloned = JSON.parse(JSON.stringify(opts.body)); + const requestInfo = new RequestInfo(cloned, {}, {}, {}, null); + if (reversedIdx !== -1) { + for ( + let i = reversedIdx + 1; + i < app.versions.reversedVersions.length; + i++ + ) { + const v = app.versions.reversedVersions[i]; + for (const change of v.changes) { + if (reqSchemaName) { + const instrs = + change._alterRequestBySchemaInstructions.get(reqSchemaName); + if (instrs) { + for (const instr of instrs) { + instr.transformer(requestInfo); + } + } + } + const pathInstrs = + change._alterRequestByPathInstructions.get(matchedRoute.path); + if (pathInstrs) { + for (const instr of pathInstrs) { + if (instr.methods.has(matchedRoute.method)) { + instr.transformer(requestInfo); + } + } + } + } + } + } + upMigratedBody = requestInfo.body; + } + } + } + + // Fallthrough: if nothing matched, compute diagnostic info. + let fallthrough: FallthroughSummary | null = null; + if (!matchedRoute) { + const availableAtOtherVersions: string[] = []; + const closestMisses: FallthroughSummary["closestMisses"] = []; + + for (const otherVersion of app.versions.versionValues) { + if (otherVersion === resolvedVersion) continue; + const otherRoutes = versionedRoutes.get(otherVersion) ?? []; + for (const route of otherRoutes) { + if (route.tags.includes(_DELETED_ROUTE_TAG)) continue; + if (route.method.toUpperCase() !== inputMethod) continue; + const match = matchPath(route.path, opts.path); + if (match.matched) { + if (!availableAtOtherVersions.includes(otherVersion)) { + availableAtOtherVersions.push(otherVersion); + } + } + } + } + + // Closest misses: same-method routes in the target version whose + // path is one segment longer/shorter than the input. + for (const route of routesAtVersion) { + if (route.tags.includes(_DELETED_ROUTE_TAG)) continue; + if (route.method.toUpperCase() !== inputMethod) continue; + const diff = closestMissDiff(route.path, opts.path); + if (diff === "one extra segment" || diff.startsWith("one extra segment")) { + closestMisses.push({ + method: route.method.toUpperCase(), + path: route.path, + diff, + }); + } + } + + fallthrough = { + reason: `no registered route matches ${inputMethod} ${opts.path} at version ${resolvedVersion}`, + availableAtOtherVersions, + closestMisses, + }; + } + + return { + resolvedVersion, + matchedRoute, + candidates, + requestMigrations, + responseMigrations, + fallthrough, + upMigratedBody, + }; +} diff --git a/src/route-table.ts b/src/route-table.ts new file mode 100644 index 0000000..526ed4a --- /dev/null +++ b/src/route-table.ts @@ -0,0 +1,114 @@ +/** + * `dumpRouteTable` — enumerate registered routes per version for debugging, + * code review, and OpenAPI audit. Complements the `tsadwyn routes` CLI + * subcommand. + */ + +import type { RouteDefinition } from "./router.js"; +import { _DELETED_ROUTE_TAG } from "./router.js"; +import { getSchemaName } from "./zod-extend.js"; + +export interface DumpRouteTableOptions { + /** Restrict output to one version. Default: all versions. */ + version?: string; + /** Filter by HTTP method (case-insensitive). */ + method?: string; + /** Filter by path — regex or substring. */ + pathMatches?: RegExp | string; + /** Include routes with includeInSchema: false. Default: false. */ + includePrivate?: boolean; +} + +export interface RouteTableEntry { + version: string; + method: string; + path: string; + handlerName: string | null; + requestSchemaName: string | null; + responseSchemaName: string | null; + statusCode: number; + deprecated: boolean; + includeInSchema: boolean; + tags: string[]; + middleware: string[]; +} + +/** + * Minimal duck-type the helper needs from a Tsadwyn-like instance. Kept + * loose so CLI callers and tests can mock without pulling a full Tsadwyn + * import chain. + */ +interface DumpRouteTableApp { + versions: { versionValues: readonly string[] }; + // Private on Tsadwyn but exposed via the getter; accessed through `as any`. + readonly _versionedRoutes?: Map; +} + +function pathMatchesFilter( + path: string, + filter: RegExp | string | undefined, +): boolean { + if (filter === undefined) return true; + if (typeof filter === "string") return path.includes(filter); + return filter.test(path); +} + +function entryFromRoute(route: RouteDefinition, version: string): RouteTableEntry { + return { + version, + method: route.method.toUpperCase(), + path: route.path, + handlerName: route.funcName ?? route.handler.name ?? null, + requestSchemaName: getSchemaName(route.requestSchema) ?? null, + responseSchemaName: getSchemaName(route.responseSchema) ?? null, + statusCode: route.statusCode, + deprecated: route.deprecated, + includeInSchema: route.includeInSchema, + tags: route.tags.filter((t) => !t.startsWith("_TSADWYN")), + middleware: (route.middleware ?? []).map( + (mw) => (mw as any).name || "", + ), + }; +} + +/** + * Enumerate registered routes across versions. Returns a flat array with one + * entry per route-per-version. Each entry carries its origin version on + * `entry.version` so callers that want a per-version breakdown can `filter`. + */ +export function dumpRouteTable( + app: DumpRouteTableApp, + opts: DumpRouteTableOptions = {}, +): RouteTableEntry[] { + const versionedRoutes = (app as any)._versionedRoutes as + | Map + | undefined; + + if (!versionedRoutes) { + throw new Error( + "dumpRouteTable: app has no _versionedRoutes — did you call generateAndIncludeVersionedRouters()?", + ); + } + + const targetVersions = opts.version + ? [opts.version] + : [...app.versions.versionValues]; + + const methodFilter = opts.method?.toUpperCase(); + const entries: RouteTableEntry[] = []; + + for (const version of targetVersions) { + const routes = versionedRoutes.get(version); + if (!routes) continue; + for (const route of routes) { + if (route.tags.includes(_DELETED_ROUTE_TAG)) continue; + if (!opts.includePrivate && route.includeInSchema === false) continue; + const routeMethod = route.method.toUpperCase(); + if (methodFilter && routeMethod !== methodFilter) continue; + if (!pathMatchesFilter(route.path, opts.pathMatches)) continue; + entries.push(entryFromRoute(route, version)); + } + } + + return entries; +} diff --git a/src/router.ts b/src/router.ts index 27dc8db..e963541 100644 --- a/src/router.ts +++ b/src/router.ts @@ -110,6 +110,16 @@ export interface RouteOptions { responses?: Record; /** T-2003: Callbacks for OpenAPI. */ callbacks?: Array<{ path: string; method: string; description?: string }>; + /** + * OpenAPI tags for grouping this route in generated Swagger UI / ReDoc + * output. Flow into `RouteDefinition.tags` at registration and compose + * with any `endpoint().had({tags})` mutations in downstream VersionChanges + * (the `had` form is a REPLACEMENT, not a merge). + * + * Tags starting with `_TSADWYN` are reserved for internal use and emit + * a registration-time warning. + */ + tags?: string[]; } /** @@ -163,6 +173,29 @@ export class VersionedRouter { ): void { // T-603: Apply prefix const fullPath = this.prefix ? this.prefix + path : path; + + // Tags — registration-time warn for reserved _TSADWYN prefix; dedup preserves + // insertion order so OpenAPI output doesn't shuffle consumer intent. + const optionTags = options?.tags ?? []; + for (const t of optionTags) { + if (t.startsWith("_TSADWYN")) { + // eslint-disable-next-line no-console + console.warn( + `tsadwyn: tag "${t}" on route [${method}] ${fullPath} starts with the ` + + `reserved "_TSADWYN" prefix. Tags starting with "_TSADWYN" are reserved ` + + `for internal tsadwyn bookkeeping — rename to avoid future collisions.`, + ); + } + } + const seenTags = new Set(); + const dedupedTags: string[] = []; + for (const t of optionTags) { + if (!seenTags.has(t)) { + seenTags.add(t); + dedupedTags.push(t); + } + } + this.routes.push({ method, path: fullPath, @@ -170,7 +203,7 @@ export class VersionedRouter { responseSchema, handler, funcName: handler.name || null, - tags: [], + tags: dedupedTags, statusCode: options?.statusCode ?? 200, deprecated: false, summary: "", @@ -245,6 +278,28 @@ export class VersionedRouter { this.addRoute("DELETE", path, requestSchema, responseSchema, handler, options); } + /** + * Explicit HEAD handler registration. HEAD is GET without a body — + * consumers use it for existence checks and cache validation. When no + * explicit HEAD is registered for a path that has a GET, Express + * auto-mirrors the GET handler. Explicit registration wins for precise + * HEAD-specific semantics (skip expensive body computation, HEAD-only + * cache validators). + * + * Handlers return void — HEAD responses carry no body per HTTP spec. + */ + head( + path: string, + requestSchema: TReq, + responseSchema: TRes, + handler: ( + req: TypedRequest : unknown>, + ) => Promise : any)>, + options?: RouteOptions, + ): void { + this.addRoute("HEAD", path, requestSchema, responseSchema, handler as any, options); + } + /** * Mark a route so it is excluded from the head (latest) version but can be * restored in older versions via `endpoint(...).existed`. diff --git a/src/schema-generation.ts b/src/schema-generation.ts index 49d8ec1..3229260 100644 --- a/src/schema-generation.ts +++ b/src/schema-generation.ts @@ -44,7 +44,7 @@ import type { } from "./structure/enums.js"; import { InvalidGenerationInstructionError } from "./exceptions.js"; import type { VersionBundle } from "./structure/versions.js"; -import { setSchemaName } from "./zod-extend.js"; +import { getSchemaName, setSchemaName } from "./zod-extend.js"; /** * A named Zod schema entry in the registry. @@ -151,11 +151,11 @@ export class ZodSchemaRegistry { /** * T-505: Version-aware schema lookup. - * Given an original schema (with _tsadwynName), returns the versioned copy - * from this registry, or the original if not found. + * Given an original schema (named via the canonical API), returns the + * versioned copy from this registry, or the original if not found. */ getVersioned(originalSchema: ZodTypeAny): ZodTypeAny { - const name = (originalSchema as any)._tsadwynName; + const name = getSchemaName(originalSchema); if (!name) return originalSchema; const entry = this.schemas.get(name); if (!entry) return originalSchema; @@ -212,7 +212,7 @@ export function transformSchemaReferences( registry: ZodSchemaRegistry, ): ZodTypeAny { // If this schema itself is named and has a versioned copy, return it - const name = (schema as any)._tsadwynName; + const name = getSchemaName(schema); if (name && registry.has(name)) { return registry.get(name)!.schema; } diff --git a/src/structure/data.ts b/src/structure/data.ts index 525e297..aa9b5a6 100644 --- a/src/structure/data.ts +++ b/src/structure/data.ts @@ -122,6 +122,8 @@ export interface AlterResponseBySchemaInstruction { methodName: string; migrateHttpErrors: boolean; checkUsage: boolean; + /** When true, migration runs on body-less responses (HEAD, 204, 304). */ + headerOnly: boolean; } /** @@ -145,6 +147,8 @@ export interface AlterResponseByPathInstruction { transformer: (response: ResponseInfo) => void; methodName: string; migrateHttpErrors: boolean; + /** When true, migration runs on body-less responses (HEAD, 204, 304). */ + headerOnly: boolean; } /** @@ -160,6 +164,15 @@ export interface RequestMigrationOptions { export interface ResponseMigrationOptions { migrateHttpErrors?: boolean; checkUsage?: boolean; + /** + * When true, the migration runs even on body-less responses (HEAD, 204 No + * Content, 304 Not Modified). Use when your transformer only touches + * `res.headers` and doesn't depend on `res.body` being populated. + * + * Composes with `migrateHttpErrors: true` — a migration flagged both + * headerOnly and migrateHttpErrors runs on error responses too. + */ + headerOnly?: boolean; } /** @@ -322,11 +335,18 @@ export function convertResponseToPreviousVersionFor( const path = pathOrFirstSchema; const methods = new Set(methodsOrSecondSchema.map((m: string) => m.toUpperCase())); - // Check for options in rest - let migrateHttpErrors = false; + // Check for options in rest. Default: TRUE — response migrations apply + // to error responses by default, matching Stripe's versioning semantics. + // Pass { migrateHttpErrors: false } to opt out for migrations that only + // touch success-response shapes. + let migrateHttpErrors = true; + let headerOnly = false; for (const arg of rest) { if (arg && typeof arg === "object" && "migrateHttpErrors" in arg) { - migrateHttpErrors = arg.migrateHttpErrors ?? false; + migrateHttpErrors = arg.migrateHttpErrors ?? true; + } + if (arg && typeof arg === "object" && "headerOnly" in arg) { + headerOnly = arg.headerOnly ?? false; } } @@ -345,6 +365,7 @@ export function convertResponseToPreviousVersionFor( transformer: (response: ResponseInfo) => originalMethod.call(targetOrTransformer, response), methodName: String(propertyKeyOrUndefined), migrateHttpErrors, + headerOnly, }; descriptorOrUndefined.value = instruction; return descriptorOrUndefined; @@ -359,6 +380,7 @@ export function convertResponseToPreviousVersionFor( transformer, methodName: transformer.name || "anonymous", migrateHttpErrors, + headerOnly, }; return instruction; }; @@ -385,8 +407,14 @@ export function convertResponseToPreviousVersionFor( } } - const migrateHttpErrors = options.migrateHttpErrors !== undefined ? options.migrateHttpErrors : false; + // Default: TRUE — response migrations apply to error responses by default. + // Stripe-style versioning: error envelopes drift across versions and clients + // pinned to older versions see their version's error shape. Pass + // { migrateHttpErrors: false } for migrations that should only affect + // success-response bodies. + const migrateHttpErrors = options.migrateHttpErrors !== undefined ? options.migrateHttpErrors : true; const checkUsage = options.checkUsage !== undefined ? options.checkUsage : true; + const headerOnly = options.headerOnly ?? false; const schemaNames = schemas.map((s) => { const name = _getSchemaName(s); @@ -411,6 +439,7 @@ export function convertResponseToPreviousVersionFor( methodName: String(propertyKeyOrUndefined), migrateHttpErrors, checkUsage, + headerOnly, }; descriptorOrUndefined.value = instruction; return descriptorOrUndefined; @@ -425,6 +454,7 @@ export function convertResponseToPreviousVersionFor( methodName: transformer.name || "anonymous", migrateHttpErrors, checkUsage, + headerOnly, }; return instruction; }; diff --git a/src/structure/enums.ts b/src/structure/enums.ts index f6d6786..75e3be1 100644 --- a/src/structure/enums.ts +++ b/src/structure/enums.ts @@ -1,5 +1,6 @@ import type { HiddenFromChangelogMixin } from "./schemas.js"; import { z, ZodEnum, ZodNativeEnum } from "zod"; +import { getSchemaName } from "../zod-extend.js"; /** * A named Zod enum schema reference. @@ -92,7 +93,7 @@ export class EnumInstructionFactory { export function enum_( zodEnum: (ZodEnum | ZodNativeEnum) & { _tsadwynName?: string }, ): EnumInstructionFactory { - const name = zodEnum._tsadwynName; + const name = getSchemaName(zodEnum); if (!name) { throw new Error( "Enum schema must have a name. Use `.named('EnumName')` on the Zod enum schema.", diff --git a/src/version-upgrade.ts b/src/version-upgrade.ts new file mode 100644 index 0000000..54b36bd --- /dev/null +++ b/src/version-upgrade.ts @@ -0,0 +1,127 @@ +/** + * Canonical upgrade-policy helper for `/versioning/upgrade`-style endpoints. + * + * Consumers building a POST /versioning/upgrade endpoint all face the same + * policy decisions: is the target version supported, is it a downgrade, is it + * a no-op. This helper standardizes the answer as a pure function so every + * adopter exposes the same upgrade semantics. + */ + +export type CompareFn = (a: string, b: string) => number; + +export interface ValidateVersionUpgradeArgs { + current: string; + target: string; + supported: readonly string[]; + /** Default: false — downgrades are rejected. */ + allowDowngrade?: boolean; + /** Default: false — same-version target is rejected. */ + allowNoChange?: boolean; + /** + * Version comparison strategy. + * - 'iso-date' (default): lexicographic string comparison. Correct for YYYY-MM-DD. + * - 'semver': strips a leading `v`, compares semver parts numerically. + * - function: custom comparator returning negative / zero / positive. + */ + compare?: "iso-date" | "semver" | CompareFn; +} + +export type ValidateVersionUpgradeResult = + | { ok: true; previous: string; next: string } + | { + ok: false; + reason: "unsupported" | "downgrade-blocked" | "no-change"; + detail?: string; + }; + +/** + * Parse a semver-ish string (optionally prefixed with `v`) into an array of + * numeric parts. Missing parts are treated as 0. + */ +function parseSemverParts(value: string): number[] { + const stripped = value.startsWith("v") ? value.slice(1) : value; + return stripped.split(".").map((p) => { + const n = parseInt(p, 10); + return Number.isFinite(n) ? n : 0; + }); +} + +function semverCompare(a: string, b: string): number { + const pa = parseSemverParts(a); + const pb = parseSemverParts(b); + const len = Math.max(pa.length, pb.length); + for (let i = 0; i < len; i++) { + const ai = pa[i] ?? 0; + const bi = pb[i] ?? 0; + if (ai !== bi) return ai - bi; + } + return 0; +} + +function isoDateCompare(a: string, b: string): number { + if (a < b) return -1; + if (a > b) return 1; + return 0; +} + +/** + * Evaluate whether a client may upgrade from `current` to `target`. + * + * Returns a discriminated union: either `{ok: true, previous, next}` for a + * permitted transition, or `{ok: false, reason}` with a structured reason + * that consumers can map onto their own error codes. + */ +export function validateVersionUpgrade( + args: ValidateVersionUpgradeArgs, +): ValidateVersionUpgradeResult { + const { + current, + target, + supported, + allowDowngrade = false, + allowNoChange = false, + compare = "iso-date", + } = args; + + if (!supported.includes(target)) { + return { + ok: false, + reason: "unsupported", + detail: `Target version "${target}" is not in the supported list.`, + }; + } + + const cmp: CompareFn = + typeof compare === "function" + ? compare + : compare === "semver" + ? semverCompare + : isoDateCompare; + + const diff = cmp(current, target); + + if (diff === 0) { + if (allowNoChange) { + return { ok: true, previous: current, next: target }; + } + return { + ok: false, + reason: "no-change", + detail: `Target version equals current version "${current}".`, + }; + } + + if (diff > 0) { + // current is newer than target -> downgrade + if (allowDowngrade) { + return { ok: true, previous: current, next: target }; + } + return { + ok: false, + reason: "downgrade-blocked", + detail: `Downgrading from "${current}" to "${target}" is not allowed.`, + }; + } + + return { ok: true, previous: current, next: target }; +} diff --git a/src/versioned-behavior.ts b/src/versioned-behavior.ts new file mode 100644 index 0000000..790cbd3 --- /dev/null +++ b/src/versioned-behavior.ts @@ -0,0 +1,209 @@ +/** + * `createVersionedBehavior` — typed overlay primitive for per-version + * behavior (not schema) changes. + * + * Schema migrations cover wire-shape changes (field renames, additions, + * endpoint lifecycle). The other half of API versioning is behavior: same + * shape, different side effects, policy, or defaults. Consumers already + * have `VersionChangeWithSideEffects` for on/off flags and + * `buildBehaviorResolver` for raw `Map` lookups. This + * primitive fills the middle: a typed behavior shape + per-change deltas + * (`behaviorHad: Partial`) that overlay newest-to-oldest to derive each + * supported version's snapshot at build time. + * + * Semantics: + * `change.version` = the version at which the change TAKES EFFECT. At that + * version (and any newer version that doesn't introduce a newer overriding + * change), the post-change value is `head[field]`. At versions STRICTLY + * OLDER than `change.version`, the pre-change value (`behaviorHad[field]`) + * is active. + * + * Usage: + * + * interface Behavior { requireIdempotencyKey: boolean; rateLimitPerSec: number; } + * const HEAD: Behavior = { requireIdempotencyKey: true, rateLimitPerSec: 1000 }; + * + * const behavior = createVersionedBehavior({ + * head: HEAD, + * initialVersion: '2024-01-01', + * changes: [ + * { version: '2025-06-01', description: 'rate limit bumped', + * behaviorHad: { rateLimitPerSec: 100 } }, + * { version: '2025-01-01', description: 'idem now required', + * behaviorHad: { requireIdempotencyKey: false } }, + * ], + * }); + * + * behavior.get().rateLimitPerSec; // inside a request + * behavior.at('2025-01-01'); // explicit lookup (tests, admin) + * behavior.map; // readonly Map for changelog UI + */ + +import { buildBehaviorResolver } from "./behavior-resolver.js"; +import { TsadwynStructureError } from "./exceptions.js"; + +export interface VersionBehaviorChange { + /** + * The version at which this change takes effect. At this version (and + * newer), the post-change value is head's value. At versions strictly + * older, `behaviorHad` is active. + */ + version: string; + /** Human-readable description — pairs with the changelog entry. */ + description?: string; + /** Field values in the version BEFORE this change was introduced. */ + behaviorHad: Partial; +} + +export interface CreateVersionedBehaviorOptions { + /** The head (latest) behavior snapshot. All older versions derive from this. */ + head: B; + /** + * Per-version deltas. Order doesn't matter — the builder groups by + * `version` and walks newest-first internally. + */ + changes: ReadonlyArray>; + /** + * Optional version string representing the oldest supported version. + * When supplied, `map[initialVersion]` contains the snapshot with every + * change's `behaviorHad` applied (i.e., before any tracked change). + * Typical use: the `INITIAL_VERSION` constant in your `VersionBundle`. + */ + initialVersion?: string; + /** + * Fallback when an unknown version is active at `.get()` time. Default: `head`. + */ + fallback?: B; + /** Telemetry policy for unknown-version lookups via `.get()`. */ + onUnknown?: "silent" | "warn-once" | "warn-every"; + /** + * Structured logger. **Required** when `onUnknown !== 'silent'` — + * `createVersionedBehavior` throws `TsadwynStructureError` at + * construction if you ask for warnings without providing a sink. + * Delegated to `buildBehaviorResolver`'s enforcement. + */ + logger?: { + warn: (ctx: Record, msg: string) => void; + }; + /** + * Optional comparator controlling "strictly older than" ordering. Default: + * ISO-date string compare (works for `YYYY-MM-DD` version strings). Supply + * a custom comparator for semver or other formats. + * + * Contract: `compare(a, b) < 0` iff `a` is older than `b`. + */ + compare?: (a: string, b: string) => number; +} + +export interface VersionedBehavior { + /** + * Resolve the behavior for the current request (reads `apiVersionStorage`). + * Returns `fallback` when the version is absent or unknown. + */ + get(): B; + /** + * Explicit lookup for a known version. Throws on unknown — use when + * absence should surface as a bug (tests, admin UIs, diagnostics). + */ + at(version: string): B; + /** Read-only snapshot map for changelog UIs / admin introspection. */ + readonly map: ReadonlyMap; +} + +export function createVersionedBehavior( + opts: CreateVersionedBehaviorOptions, +): VersionedBehavior { + if (!opts.head || typeof opts.head !== "object") { + throw new TsadwynStructureError( + "createVersionedBehavior: `head` must be an object describing the latest behavior snapshot.", + ); + } + + const compare = opts.compare ?? ((a: string, b: string) => a < b ? -1 : a > b ? 1 : 0); + + // Group changes by version so duplicates at the same version merge. + const byVersion = new Map>>(); + for (const change of opts.changes) { + const bucket = byVersion.get(change.version) ?? []; + bucket.push(change); + byVersion.set(change.version, bucket); + } + + // Reject change.version === initialVersion — initialVersion is the floor + // (the version BEFORE any tracked change), so a change can't be introduced + // at it. + if (opts.initialVersion !== undefined && byVersion.has(opts.initialVersion)) { + throw new TsadwynStructureError( + `createVersionedBehavior: change.version "${opts.initialVersion}" matches initialVersion. ` + + `The initial version is the floor (before any tracked change) — changes must be introduced AT a newer version.`, + ); + } + + // Distinct change versions, newest-first. + const changeVersions = [...byVersion.keys()].sort((a, b) => compare(b, a)); + + // Build snapshots: for each change.version key, the snapshot is HEAD with + // every `behaviorHad` from changes STRICTLY NEWER than this version applied. + const map = new Map(); + for (const v of changeVersions) { + const snapshot: B = { ...opts.head }; + for (const newerV of changeVersions) { + if (compare(newerV, v) <= 0) continue; // only strictly newer + applyBucket(snapshot, byVersion.get(newerV)!, newerV, opts.logger); + } + map.set(v, snapshot); + } + + // If initialVersion is supplied, its snapshot applies EVERY change (no + // tracked change is newer than the initial — everything is newer). + if (opts.initialVersion !== undefined) { + const initial: B = { ...opts.head }; + for (const v of changeVersions) { + applyBucket(initial, byVersion.get(v)!, v, opts.logger); + } + map.set(opts.initialVersion, initial); + } + + const fallback = opts.fallback ?? opts.head; + const resolveViaBase = buildBehaviorResolver(map, fallback, { + onUnknown: opts.onUnknown, + logger: opts.logger, + }); + + return { + get: () => resolveViaBase(), + at: (version: string) => { + const snapshot = map.get(version); + if (!snapshot) { + throw new TsadwynStructureError( + `createVersionedBehavior.at("${version}"): unknown version. ` + + `Known versions: [${[...map.keys()].join(", ")}]`, + ); + } + return snapshot; + }, + map, + }; +} + +function applyBucket( + snapshot: B, + bucket: ReadonlyArray>, + version: string, + logger?: { warn: (ctx: Record, msg: string) => void }, +): void { + const writes = new Map(); + for (const change of bucket) { + for (const key of Object.keys(change.behaviorHad) as (keyof B)[]) { + const nextValue = change.behaviorHad[key]; + if (writes.has(key) && writes.get(key) !== nextValue) { + logger?.warn( + { version, field: String(key), previousValue: writes.get(key), nextValue }, + `Two VersionBehaviorChange entries at "${version}" set field "${String(key)}" to different values; last-write-wins.`, + ); + } + writes.set(key, nextValue); + (snapshot as Record)[key] = nextValue as B[typeof key]; + } + } +} diff --git a/src/versioning-routes.ts b/src/versioning-routes.ts new file mode 100644 index 0000000..ced9149 --- /dev/null +++ b/src/versioning-routes.ts @@ -0,0 +1,227 @@ +/** + * `createVersioningRoutes` — pre-wired RESTful `/versioning` resource for + * self-service API-version reads and upgrades. Every Stripe-style adopter + * ends up writing the same endpoint; this helper collapses it to one + * import + callbacks. + * + * Shape (default path `/versioning`): + * + * GET /versioning → 200 { version, supported[], latest } + * POST /versioning {from, to} → 200 { previous_version, current_version } + * | 409 { error: "version_mismatch", expected, actual } + * | 400 { error: "unsupported" | "downgrade-blocked" | "no-change" } + * | 401 unauthenticated + * | 422 malformed request body + * + * The `{from, to}` shape gives **optimistic concurrency** — if the stored + * pin has drifted since the client last read it (e.g., an admin force-pin + * upgraded them), the server rejects with 409 rather than silently + * overwriting. + * + * First-upgrade convention: clients who have never explicitly pinned a + * version read `GET /versioning` → `{version: null, ...}`. Their first + * upgrade passes `from: null` to install the initial pin. + */ + +import type { Request } from "express"; +import { z } from "zod"; + +import { VersionedRouter } from "./router.js"; +import { HttpError } from "./exceptions.js"; +import { named } from "./zod-extend.js"; +import { + validateVersionUpgrade, + type CompareFn, +} from "./version-upgrade.js"; + +export interface CreateVersioningRoutesOptions { + /** Default: '/versioning'. */ + path?: string; + /** Extract a stable client / account identifier. Return null if unauthenticated. */ + identify: (req: Request) => string | null | Promise; + /** Load the stored pinned version for a client. Return null if none. */ + loadVersion: (clientId: string) => string | null | Promise; + /** Persist the new pin. */ + saveVersion: (clientId: string, version: string) => void | Promise; + /** Versions the upgrade handler will accept as `to`. Typically `bundle.versionValues`. */ + supportedVersions: readonly string[]; + /** Default false — downgrades are rejected with 400 downgrade-blocked. */ + allowDowngrade?: boolean; + /** Default false — same-version target is rejected with 400 no-change. */ + allowNoChange?: boolean; + /** Version comparison strategy. Default 'iso-date'. */ + compare?: "iso-date" | "semver" | CompareFn; + /** + * Effective version for unpinned clients. When supplied, `GET /versioning` + * returns `{version: fallback}` for clients whose `loadVersion` returns + * null, matching what `perClientDefaultVersion` (or any equivalent + * default-version resolver) would actually use at dispatch time. + * + * `POST /versioning` accepts either `from: null` OR `from: fallback` as + * the "unpinned" starting state — they describe the same situation. + * + * Pass the same value you pass to `perClientDefaultVersion.fallback` so + * the two helpers agree on what the client is effectively running. + */ + fallback?: string; +} + +const VersioningState = named( + z.object({ + version: z.string().nullable(), + supported: z.array(z.string()), + latest: z.string(), + }), + "VersioningState", +); + +const UpgradeRequest = named( + z.object({ + from: z.string().nullable(), + to: z.string(), + }), + "UpgradeRequest", +); + +const UpgradeResponse = named( + z.object({ + previous_version: z.string().nullable(), + current_version: z.string(), + }), + "UpgradeResponse", +); + +export function createVersioningRoutes( + opts: CreateVersioningRoutesOptions, +): VersionedRouter { + // Validate supportedVersions up-front. Empty list would make `latest` + // resolve to `undefined`, which fails the Zod response schema at + // dispatch time with a confusing "expected string, got undefined" + // error far from the misconfiguration. Fail loudly at construction + // instead so the problem surfaces at app boot. + if (!opts.supportedVersions || opts.supportedVersions.length === 0) { + throw new Error( + "createVersioningRoutes: `supportedVersions` must contain at least one " + + "version string. Typically pass `bundle.versionValues` from your " + + "VersionBundle.", + ); + } + + const path = opts.path ?? "/versioning"; + const router = new VersionedRouter(); + + // ── GET /versioning ──────────────────────────────────────────────────── + // Returns the authenticated client's current pin + the full supported set. + // When no pin is stored and `fallback` is configured, the effective + // version (what tsadwyn actually uses at dispatch) is reported instead + // of null — so clients can't get confused between "what I said" and + // "what the server will do". + router.get(path, null, VersioningState, async (req: any) => { + const clientId = await Promise.resolve(opts.identify(req)); + if (!clientId) { + throw new HttpError(401, { error: "unauthorized" }); + } + const stored = await Promise.resolve(opts.loadVersion(clientId)); + const effective = stored ?? opts.fallback ?? null; + return { + version: effective, + supported: [...opts.supportedVersions], + // supportedVersions is newest-first per tsadwyn convention, so [0] is head. + latest: opts.supportedVersions[0], + }; + }); + + // ── POST /versioning ─────────────────────────────────────────────────── + // Optimistic-concurrency-aware upgrade. `from` must match the client's + // effective current version (stored pin, or `fallback` if unpinned). + // Mismatch → 409 and the client must re-read + retry. When no pin is + // stored and a fallback is configured, the client may pass EITHER + // `from: null` OR `from: ` — both describe the same state. + router.post(path, UpgradeRequest, UpgradeResponse, async (req: any) => { + const clientId = await Promise.resolve(opts.identify(req)); + if (!clientId) { + throw new HttpError(401, { error: "unauthorized" }); + } + + const { from, to } = req.body as { from: string | null; to: string }; + const stored = await Promise.resolve(opts.loadVersion(clientId)); + const effective = stored ?? opts.fallback ?? null; + + // Acceptable `from` values match the effective current. When the client + // is unpinned and a fallback is configured, `null` AND `fallback` both + // describe the unpinned state — accept either. + const fromMatches = + from === effective || + (stored === null && (from === null || from === opts.fallback)); + + if (!fromMatches) { + throw new HttpError(409, { + error: "version_mismatch", + expected: from, + actual: effective, + }); + } + + // First-upgrade flow: the client is unpinned (no stored value). We + // install their first explicit pin, subject to the supported-list + // check. Downgrade / no-change policy is evaluated against the + // effective version when a fallback is configured, otherwise skipped + // (null-from case) — either way matching the prior behavior. + if (stored === null) { + if (!opts.supportedVersions.includes(to)) { + throw new HttpError(400, { + error: "unsupported", + detail: `Target version "${to}" is not in the supported list.`, + }); + } + // If a fallback is configured, evaluate policy vs effective version + // to prevent a "first upgrade" sneaking in a downgrade or no-change. + if (opts.fallback !== undefined) { + const decision = validateVersionUpgrade({ + current: opts.fallback, + target: to, + supported: opts.supportedVersions, + allowDowngrade: opts.allowDowngrade, + allowNoChange: opts.allowNoChange, + compare: opts.compare, + }); + if (!decision.ok) { + throw new HttpError(400, { + error: decision.reason, + detail: decision.detail, + }); + } + } + await Promise.resolve(opts.saveVersion(clientId, to)); + return { + previous_version: stored, // null — no prior explicit pin + current_version: to, + }; + } + + // Standard upgrade: both `from` and `to` are concrete versions. + const decision = validateVersionUpgrade({ + current: stored, + target: to, + supported: opts.supportedVersions, + allowDowngrade: opts.allowDowngrade, + allowNoChange: opts.allowNoChange, + compare: opts.compare, + }); + + if (!decision.ok) { + throw new HttpError(400, { + error: decision.reason, + detail: decision.detail, + }); + } + + await Promise.resolve(opts.saveVersion(clientId, decision.next)); + return { + previous_version: decision.previous, + current_version: decision.next, + }; + }); + + return router; +} diff --git a/tests/basic.test.ts b/tests/basic.test.ts index 729e207..d1847e4 100644 --- a/tests/basic.test.ts +++ b/tests/basic.test.ts @@ -82,8 +82,14 @@ function createApp() { @convertResponseToPreviousVersionFor(UserResource) changeAddressesToSingleItem(response: ResponseInfo) { - response.body.address = response.body.addresses[0]; - delete response.body.addresses; + // With migrateHttpErrors defaulting to true (Stripe semantics), this + // migration can fire on 422 validation errors whose body is + // {detail: [...]} rather than a UserResource. Null-check the shape + // before mutating. + if (response.body?.addresses) { + response.body.address = response.body.addresses[0]; + delete response.body.addresses; + } } } diff --git a/tests/cached-per-client-default.test.ts b/tests/cached-per-client-default.test.ts new file mode 100644 index 0000000..65d27e8 --- /dev/null +++ b/tests/cached-per-client-default.test.ts @@ -0,0 +1,298 @@ +/** + * Tests for `cachedPerClientDefaultVersion` — TTL-cached per-client pin + * resolver with explicit invalidation handles. + * + * Covers: + * - Cache hit: resolvePin is called once for repeated requests within TTL. + * - Cache miss after TTL expires: resolvePin fires again. + * - `invalidate(clientId)` drops one entry, `invalidateAll()` drops all. + * - Single-flight: concurrent first-misses share one resolvePin call. + * - Unknown client (identify returns null): fallback returned, no cache. + * - pinOnFirstResolve populates the cache with the fallback. + * - resolvePin rejection is NOT cached; next call retries. + * - Stale pin policy (fallback / passthrough / reject). + * - ttlMs = 0 disables caching entirely. + * - Negative ttlMs throws. + */ +import { describe, it, expect, vi } from "vitest"; +import type { Request } from "express"; + +import { cachedPerClientDefaultVersion } from "../src/index.js"; + +function fakeReq(clientId: string | null = "c1"): Request { + return { headers: {}, ["__clientId"]: clientId } as unknown as Request; +} + +describe("cachedPerClientDefaultVersion — hit/miss", () => { + it("calls resolvePin once per TTL window per client", async () => { + const resolvePin = vi.fn(async (id: string) => `pin-${id}`); + const { resolver } = cachedPerClientDefaultVersion({ + identify: (req: any) => req.__clientId, + resolvePin, + fallback: "FALLBACK", + ttlMs: 5_000, + }); + + expect(await resolver(fakeReq("c1"))).toBe("pin-c1"); + expect(await resolver(fakeReq("c1"))).toBe("pin-c1"); + expect(await resolver(fakeReq("c1"))).toBe("pin-c1"); + expect(resolvePin).toHaveBeenCalledTimes(1); + }); + + it("re-resolves a different client even when one is cached", async () => { + const resolvePin = vi.fn(async (id: string) => `pin-${id}`); + const { resolver } = cachedPerClientDefaultVersion({ + identify: (req: any) => req.__clientId, + resolvePin, + fallback: "FALLBACK", + }); + + await resolver(fakeReq("c1")); + await resolver(fakeReq("c2")); + await resolver(fakeReq("c1")); + expect(resolvePin).toHaveBeenCalledTimes(2); + expect(resolvePin).toHaveBeenNthCalledWith(1, "c1"); + expect(resolvePin).toHaveBeenNthCalledWith(2, "c2"); + }); + + it("re-resolves after TTL expires (fake timers)", async () => { + vi.useFakeTimers(); + try { + const resolvePin = vi.fn(async (id: string) => `pin-${id}`); + const { resolver } = cachedPerClientDefaultVersion({ + identify: (req: any) => req.__clientId, + resolvePin, + fallback: "FALLBACK", + ttlMs: 1000, + }); + + await resolver(fakeReq("c1")); + vi.advanceTimersByTime(500); + await resolver(fakeReq("c1")); + expect(resolvePin).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(600); // now age > 1000 + await resolver(fakeReq("c1")); + expect(resolvePin).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe("cachedPerClientDefaultVersion — invalidation", () => { + it("invalidate(clientId) drops that entry only", async () => { + const resolvePin = vi.fn(async (id: string) => `pin-${id}`); + const { resolver, invalidate } = cachedPerClientDefaultVersion({ + identify: (req: any) => req.__clientId, + resolvePin, + fallback: "FALLBACK", + }); + + await resolver(fakeReq("c1")); + await resolver(fakeReq("c2")); + invalidate("c1"); + await resolver(fakeReq("c1")); // re-resolves + await resolver(fakeReq("c2")); // still cached + expect(resolvePin).toHaveBeenCalledTimes(3); + expect(resolvePin.mock.calls.map((c) => c[0])).toEqual(["c1", "c2", "c1"]); + }); + + it("invalidateAll() drops every entry", async () => { + const resolvePin = vi.fn(async (id: string) => `pin-${id}`); + const { resolver, invalidateAll } = cachedPerClientDefaultVersion({ + identify: (req: any) => req.__clientId, + resolvePin, + fallback: "FALLBACK", + }); + + await resolver(fakeReq("c1")); + await resolver(fakeReq("c2")); + invalidateAll(); + await resolver(fakeReq("c1")); + await resolver(fakeReq("c2")); + expect(resolvePin).toHaveBeenCalledTimes(4); + }); +}); + +describe("cachedPerClientDefaultVersion — single-flight", () => { + it("concurrent first-misses share one resolvePin call", async () => { + let resolveGate: ((v: string) => void) | undefined; + const gate = new Promise((r) => { + resolveGate = r; + }); + const resolvePin = vi.fn(() => gate); + const { resolver } = cachedPerClientDefaultVersion({ + identify: (req: any) => req.__clientId, + resolvePin, + fallback: "FALLBACK", + }); + + const p1 = resolver(fakeReq("c1")); + const p2 = resolver(fakeReq("c1")); + const p3 = resolver(fakeReq("c1")); + + // Drain the microtask queue so each resolver's `await identify` resolves + // and it reaches getOrCreate. Single-flight is enforced there. + await new Promise((r) => setImmediate(r)); + + // All three should be waiting on the same underlying resolvePin. + expect(resolvePin).toHaveBeenCalledTimes(1); + + resolveGate!("shared-pin"); + const [a, b, c] = await Promise.all([p1, p2, p3]); + expect([a, b, c]).toEqual(["shared-pin", "shared-pin", "shared-pin"]); + }); +}); + +describe("cachedPerClientDefaultVersion — identity + fallback", () => { + it("returns fallback and does NOT cache when identify returns null", async () => { + const resolvePin = vi.fn(); + const { resolver } = cachedPerClientDefaultVersion({ + identify: () => null, + resolvePin, + fallback: "FALLBACK", + }); + + expect(await resolver(fakeReq())).toBe("FALLBACK"); + expect(await resolver(fakeReq())).toBe("FALLBACK"); + expect(resolvePin).not.toHaveBeenCalled(); + }); + + it("returns fallback when resolvePin returns null (no stored pin)", async () => { + const resolvePin = vi.fn(async () => null); + const { resolver } = cachedPerClientDefaultVersion({ + identify: (req: any) => req.__clientId, + resolvePin, + fallback: "FALLBACK", + }); + + expect(await resolver(fakeReq("c1"))).toBe("FALLBACK"); + }); +}); + +describe("cachedPerClientDefaultVersion — pinOnFirstResolve", () => { + it("persists the fallback as the pin on first call, and caches it", async () => { + const resolvePin = vi.fn(async () => null); + const saveVersion = vi.fn(async () => {}); + const { resolver } = cachedPerClientDefaultVersion({ + identify: (req: any) => req.__clientId, + resolvePin, + saveVersion, + pinOnFirstResolve: true, + fallback: "2025-01-01", + }); + + expect(await resolver(fakeReq("c1"))).toBe("2025-01-01"); + expect(saveVersion).toHaveBeenCalledWith("c1", "2025-01-01"); + + // Subsequent call: cache hit, no resolvePin or saveVersion. + expect(await resolver(fakeReq("c1"))).toBe("2025-01-01"); + expect(resolvePin).toHaveBeenCalledTimes(1); + expect(saveVersion).toHaveBeenCalledTimes(1); + }); + + it("throws at construction when pinOnFirstResolve is set without saveVersion", () => { + expect(() => + cachedPerClientDefaultVersion({ + identify: () => "c1", + resolvePin: async () => null, + fallback: "F", + pinOnFirstResolve: true, + }), + ).toThrow(/requires a saveVersion/i); + }); +}); + +describe("cachedPerClientDefaultVersion — error semantics", () => { + it("does NOT cache a rejection; next call retries", async () => { + let shouldFail = true; + const resolvePin = vi.fn(async () => { + if (shouldFail) throw new Error("db down"); + return "pin-c1"; + }); + const { resolver } = cachedPerClientDefaultVersion({ + identify: (req: any) => req.__clientId, + resolvePin, + fallback: "FALLBACK", + }); + + await expect(resolver(fakeReq("c1"))).rejects.toThrow("db down"); + shouldFail = false; + expect(await resolver(fakeReq("c1"))).toBe("pin-c1"); + expect(resolvePin).toHaveBeenCalledTimes(2); + }); +}); + +describe("cachedPerClientDefaultVersion — stale pin policy", () => { + it("fallback: logs and returns fallback; does NOT cache the stored stale value", async () => { + const warn = vi.fn(); + const resolvePin = vi.fn(async () => "ancient"); + const { resolver } = cachedPerClientDefaultVersion({ + identify: (req: any) => req.__clientId, + resolvePin, + fallback: "CURRENT", + supportedVersions: ["CURRENT", "PREV"], + onStalePin: "fallback", + logger: { warn }, + }); + + expect(await resolver(fakeReq("c1"))).toBe("CURRENT"); + expect(warn).toHaveBeenCalled(); + // Second call is cached but returns fallback (since that's what was resolved) + expect(await resolver(fakeReq("c1"))).toBe("CURRENT"); + expect(resolvePin).toHaveBeenCalledTimes(1); + }); + + it("reject: throws instead of returning", async () => { + const { resolver } = cachedPerClientDefaultVersion({ + identify: (req: any) => req.__clientId, + resolvePin: async () => "ancient", + fallback: "CURRENT", + supportedVersions: ["CURRENT"], + onStalePin: "reject", + }); + + await expect(resolver(fakeReq("c1"))).rejects.toThrow(/not in the current VersionBundle/i); + }); + + it("passthrough: returns the stale string verbatim", async () => { + const { resolver } = cachedPerClientDefaultVersion({ + identify: (req: any) => req.__clientId, + resolvePin: async () => "ancient", + fallback: "CURRENT", + supportedVersions: ["CURRENT"], + onStalePin: "passthrough", + }); + + expect(await resolver(fakeReq("c1"))).toBe("ancient"); + }); +}); + +describe("cachedPerClientDefaultVersion — ttlMs edges", () => { + it("ttlMs: 0 disables caching (resolvePin fires every call)", async () => { + const resolvePin = vi.fn(async (id: string) => `pin-${id}`); + const { resolver } = cachedPerClientDefaultVersion({ + identify: (req: any) => req.__clientId, + resolvePin, + fallback: "F", + ttlMs: 0, + }); + + await resolver(fakeReq("c1")); + await resolver(fakeReq("c1")); + await resolver(fakeReq("c1")); + expect(resolvePin).toHaveBeenCalledTimes(3); + }); + + it("throws at construction on negative ttlMs", () => { + expect(() => + cachedPerClientDefaultVersion({ + identify: () => "c", + resolvePin: async () => null, + fallback: "F", + ttlMs: -1, + }), + ).toThrow(/ttlMs must be >= 0/i); + }); +}); diff --git a/tests/current-request.test.ts b/tests/current-request.test.ts new file mode 100644 index 0000000..d148138 --- /dev/null +++ b/tests/current-request.test.ts @@ -0,0 +1,193 @@ +/** + * Tests for `currentRequest()` — request-scoped access to the raw Express + * Request from inside tsadwyn handlers + migration callbacks. + * + * Covers: + * - Handler reads a middleware-injected field on req. + * - Access survives through awaited async sub-calls. + * - Migration callbacks (convertRequest / convertResponse) see the req. + * - Throw-outside-request: bare call without an active dispatch errors. + * - Two concurrent requests don't bleed context between each other. + * - currentRequestOrNull() returns null outside a dispatch scope. + */ +import { describe, it, expect } from "vitest"; +import request from "supertest"; +import { z } from "zod"; + +import { + Tsadwyn, + Version, + VersionBundle, + VersionChange, + VersionedRouter, + RequestInfo, + ResponseInfo, + convertRequestToNextVersionFor, + convertResponseToPreviousVersionFor, + currentRequest, + currentRequestOrNull, +} from "../src/index.js"; + +const Echo = z.object({ seen: z.string() }).named("CurrentReq_Echo"); + +describe("currentRequest()", () => { + it("lets a handler read an Express-middleware-injected field off req", async () => { + const router = new VersionedRouter(); + router.get("/me", null, Echo, async () => { + // `req.user` was set by the middleware below; tsadwyn's stripped + // handler view doesn't include it, so we recover via currentRequest(). + const req = currentRequest(); + return { seen: (req as any).user?.id ?? "anonymous" }; + }); + + const app = new Tsadwyn({ + versions: new VersionBundle(new Version("2024-01-01")), + preVersionPick: (req, _res, next) => { + (req as any).user = { id: "user_42" }; + next(); + }, + }); + app.generateAndIncludeVersionedRouters(router); + + const res = await request(app.expressApp) + .get("/me") + .set("x-api-version", "2024-01-01"); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ seen: "user_42" }); + }); + + it("propagates through awaited async sub-calls", async () => { + async function readerAfterAwait(): Promise { + await new Promise((r) => setImmediate(r)); + return (currentRequest() as any).user?.id ?? "none"; + } + + const router = new VersionedRouter(); + router.get("/deep", null, Echo, async () => { + const seen = await readerAfterAwait(); + return { seen }; + }); + + const app = new Tsadwyn({ + versions: new VersionBundle(new Version("2024-01-01")), + preVersionPick: (req, _res, next) => { + (req as any).user = { id: "deep_user" }; + next(); + }, + }); + app.generateAndIncludeVersionedRouters(router); + + const res = await request(app.expressApp) + .get("/deep") + .set("x-api-version", "2024-01-01"); + + expect(res.body).toEqual({ seen: "deep_user" }); + }); + + it("exposes the raw req to request-migration callbacks", async () => { + const seen: string[] = []; + + class ObserveRequest extends VersionChange { + description = "observes currentRequest() from a request migration"; + instructions = []; + migrateRequest = convertRequestToNextVersionFor(Echo)( + (_req: RequestInfo) => { + seen.push((currentRequest() as any).user?.id ?? "missing"); + }, + ); + } + + const router = new VersionedRouter(); + router.post("/things", Echo, Echo, async () => ({ seen: "handler" })); + + const app = new Tsadwyn({ + versions: new VersionBundle( + new Version("2025-01-01", ObserveRequest), + new Version("2024-01-01"), + ), + preVersionPick: (req, _res, next) => { + (req as any).user = { id: "mig_reader" }; + next(); + }, + }); + app.generateAndIncludeVersionedRouters(router); + + await request(app.expressApp) + .post("/things") + .set("x-api-version", "2024-01-01") + .send({ seen: "client" }); + + expect(seen).toEqual(["mig_reader"]); + }); + + it("exposes the raw req to response-migration callbacks", async () => { + const seen: string[] = []; + + class ObserveResponse extends VersionChange { + description = "observes currentRequest() from a response migration"; + instructions = []; + migrateResponse = convertResponseToPreviousVersionFor(Echo)( + (_res: ResponseInfo) => { + seen.push((currentRequest() as any).user?.id ?? "missing"); + }, + ); + } + + const router = new VersionedRouter(); + router.get("/things/:id", null, Echo, async () => ({ seen: "handler" })); + + const app = new Tsadwyn({ + versions: new VersionBundle( + new Version("2025-01-01", ObserveResponse), + new Version("2024-01-01"), + ), + preVersionPick: (req, _res, next) => { + (req as any).user = { id: "resp_reader" }; + next(); + }, + }); + app.generateAndIncludeVersionedRouters(router); + + await request(app.expressApp) + .get("/things/abc") + .set("x-api-version", "2024-01-01"); + + expect(seen).toEqual(["resp_reader"]); + }); + + it("throws when called outside a tsadwyn handler scope", () => { + expect(() => currentRequest()).toThrow(/outside a tsadwyn handler scope/i); + }); + + it("returns null from currentRequestOrNull() outside a scope", () => { + expect(currentRequestOrNull()).toBeNull(); + }); + + it("keeps two concurrent requests' contexts isolated", async () => { + const router = new VersionedRouter(); + router.get("/who", null, Echo, async () => { + // Insert a microtask to give the event loop a chance to interleave. + await new Promise((r) => setImmediate(r)); + return { seen: (currentRequest() as any).user?.id ?? "none" }; + }); + + const app = new Tsadwyn({ + versions: new VersionBundle(new Version("2024-01-01")), + preVersionPick: (req, _res, next) => { + const header = req.headers["x-who"]; + (req as any).user = { id: typeof header === "string" ? header : "anon" }; + next(); + }, + }); + app.generateAndIncludeVersionedRouters(router); + + const [a, b] = await Promise.all([ + request(app.expressApp).get("/who").set("x-api-version", "2024-01-01").set("x-who", "alice"), + request(app.expressApp).get("/who").set("x-api-version", "2024-01-01").set("x-who", "bob"), + ]); + + expect(a.body).toEqual({ seen: "alice" }); + expect(b.body).toEqual({ seen: "bob" }); + }); +}); diff --git a/tests/data-coverage.test.ts b/tests/data-coverage.test.ts index f33cca2..5693bbc 100644 --- a/tests/data-coverage.test.ts +++ b/tests/data-coverage.test.ts @@ -324,13 +324,13 @@ describe("Section 7: convertResponseToPreviousVersionFor", () => { expect(instruction.methodName).toBe("resXform"); }); - it("path-based without options — migrateHttpErrors defaults to false", () => { + it("path-based without options — migrateHttpErrors defaults to true (Stripe semantics)", () => { const i: any = convertResponseToPreviousVersionFor("/x", ["GET"])( (res: ResponseInfo) => { void res; }, ); - expect(i.migrateHttpErrors).toBe(false); + expect(i.migrateHttpErrors).toBe(true); }); it("path-based throws for invalid HTTP method", () => { @@ -375,12 +375,12 @@ describe("Section 7: convertResponseToPreviousVersionFor", () => { expect(i.checkUsage).toBe(false); }); - it("schema-based default options — migrateHttpErrors=false, checkUsage=true", () => { + it("schema-based default options — migrateHttpErrors=true, checkUsage=true", () => { const S = z.object({ v: z.string() }).named(uniq("ResDefaults")); const i: any = convertResponseToPreviousVersionFor(S)((res: ResponseInfo) => { void res; }); - expect(i.migrateHttpErrors).toBe(false); + expect(i.migrateHttpErrors).toBe(true); expect(i.checkUsage).toBe(true); }); diff --git a/tests/delete-response-helper.test.ts b/tests/delete-response-helper.test.ts new file mode 100644 index 0000000..08e46a2 --- /dev/null +++ b/tests/delete-response-helper.test.ts @@ -0,0 +1,167 @@ +/** + * Tests for `deletedResponseSchema` — the Stripe-style DELETE response + * helper. Verifies that: + * (1) the schema validates the Stripe-shape body + * (2) a real route using it actually delivers the body to the client + * at status 200 (unlike 204, which strips the body at the wire) + * (3) version migrations on the delete-envelope run end-to-end + * + * Motivated by empirical verification against api.stripe.com: + * DELETE /v1/customers/{id} → HTTP/2 200 + {id, object, deleted} + */ +import { describe, it, expect } from "vitest"; +import request from "supertest"; +import { z } from "zod"; + +import { + Tsadwyn, + Version, + VersionBundle, + VersionChange, + VersionedRouter, + ResponseInfo, + convertResponseToPreviousVersionFor, + deletedResponseSchema, +} from "../src/index.js"; + +describe("deletedResponseSchema — Stripe-style DELETE helper", () => { + it("produces a schema that validates the { id, object, deleted } shape", () => { + const DeletedCustomer = deletedResponseSchema("customer").named( + "DeletedCustomerShape", + ); + + const ok = DeletedCustomer.safeParse({ + id: "cus_NffrFeUfNV2Hib", + object: "customer", + deleted: true, + }); + expect(ok.success).toBe(true); + + // object literal must match + const wrongObject = DeletedCustomer.safeParse({ + id: "cus_x", + object: "subscription", + deleted: true, + }); + expect(wrongObject.success).toBe(false); + + // deleted must be literally true + const wrongDeleted = DeletedCustomer.safeParse({ + id: "cus_x", + object: "customer", + deleted: false, + }); + expect(wrongDeleted.success).toBe(false); + }); + + it("accepts extra fields for richer audit envelopes", () => { + const DeletedCustomerWithAudit = deletedResponseSchema("customer", { + deleted_at: z.string(), + deleted_by: z.string(), + }).named("DeletedCustomerWithAudit"); + + const ok = DeletedCustomerWithAudit.safeParse({ + id: "cus_x", + object: "customer", + deleted: true, + deleted_at: "2026-04-16T12:00:00Z", + deleted_by: "admin:42", + }); + expect(ok.success).toBe(true); + }); + + it("end-to-end: route using deletedResponseSchema delivers 200 + body to the client", async () => { + const DeletedCustomer = deletedResponseSchema("customer").named( + "DeletedCustomer_E2E", + ); + + const router = new VersionedRouter(); + router.delete("/customers/:id", null, DeletedCustomer, async (req: any) => ({ + id: req.params.id, + object: "customer" as const, + deleted: true as const, + })); + + const app = new Tsadwyn({ + versions: new VersionBundle(new Version("2024-01-01")), + }); + app.generateAndIncludeVersionedRouters(router); + + const res = await request(app.expressApp) + .delete("/customers/cus_NffrFeUfNV2Hib") + .set("x-api-version", "2024-01-01"); + + // Stripe's exact wire-level behavior: 200 + JSON body + expect(res.status).toBe(200); + expect(res.body).toEqual({ + id: "cus_NffrFeUfNV2Hib", + object: "customer", + deleted: true, + }); + }); + + it("version migration: head emits rich envelope; legacy clients get the original flat shape", async () => { + // Stripe itself evolves delete envelopes by adding audit fields (deleted_at + // etc.). This test demonstrates the flow end-to-end with the helper. + const DeletedCustomer = deletedResponseSchema("customer", { + deleted_at: z.string().optional(), + deleted_by: z.string().optional(), + }).named("DeletedCustomer_V2"); + + class DropAuditFieldsForLegacy extends VersionChange { + description = + "v2 adds deleted_at + deleted_by; v1 clients see the original flat shape"; + instructions = []; + + r1 = convertResponseToPreviousVersionFor(DeletedCustomer)( + (res: ResponseInfo) => { + if (res.body && typeof res.body === "object") { + delete res.body.deleted_at; + delete res.body.deleted_by; + } + }, + ); + } + + const router = new VersionedRouter(); + router.delete("/customers/:id", null, DeletedCustomer, async (req: any) => ({ + id: req.params.id, + object: "customer" as const, + deleted: true as const, + deleted_at: "2026-04-16T12:00:00Z", + deleted_by: "admin:42", + })); + + const app = new Tsadwyn({ + versions: new VersionBundle( + new Version("2025-01-01", DropAuditFieldsForLegacy), + new Version("2024-01-01"), + ), + }); + app.generateAndIncludeVersionedRouters(router); + + // Head client — full audit envelope + const headRes = await request(app.expressApp) + .delete("/customers/cus_x") + .set("x-api-version", "2025-01-01"); + expect(headRes.status).toBe(200); + expect(headRes.body).toEqual({ + id: "cus_x", + object: "customer", + deleted: true, + deleted_at: "2026-04-16T12:00:00Z", + deleted_by: "admin:42", + }); + + // Legacy client — flat Stripe shape, audit fields stripped by migration + const legacyRes = await request(app.expressApp) + .delete("/customers/cus_x") + .set("x-api-version", "2024-01-01"); + expect(legacyRes.status).toBe(200); + expect(legacyRes.body).toEqual({ + id: "cus_x", + object: "customer", + deleted: true, + }); + }); +}); diff --git a/tests/fixtures/cli-exception-map-app.ts b/tests/fixtures/cli-exception-map-app.ts new file mode 100644 index 0000000..a1671dd --- /dev/null +++ b/tests/fixtures/cli-exception-map-app.ts @@ -0,0 +1,41 @@ +/** + * Fixture app for CLI tests that exercise the exception-map introspection + * subcommand (`tsadwyn exceptions`). Exposes a Tsadwyn instance whose + * `errorMapper` is an introspectable ExceptionMapFn with three mapping + * kinds — function, static, static-with-transform — so the CLI has + * concrete data to render. + */ +import { + Tsadwyn, + Version, + VersionBundle, + VersionedRouter, + HttpError, + exceptionMap, +} from "../../src/index.js"; + +const router = new VersionedRouter(); +router.get("/ping", null, null, async () => ({ ok: true })); + +const app = new Tsadwyn({ + versions: new VersionBundle(new Version("2024-01-01")), + errorMapper: exceptionMap({ + IdempotencyKeyReuseError: (err) => + new HttpError(409, { + code: "idempotency_key_reused", + message: err.message, + }), + NotFoundError: { status: 404, code: "not_found" }, + RateLimitError: { + status: 429, + code: "rate_limited", + transform: (err) => ({ + message: err.message, + retryAfter: (err as any).retryAfter, + }), + }, + }), +}); +app.generateAndIncludeVersionedRouters(router); + +export default app; diff --git a/tests/fixtures/cli-migrations-app.ts b/tests/fixtures/cli-migrations-app.ts new file mode 100644 index 0000000..0998403 --- /dev/null +++ b/tests/fixtures/cli-migrations-app.ts @@ -0,0 +1,49 @@ +/** + * Fixture with a real runtime response migration so + * `tsadwyn migrations` + inspectMigrationChain have something + * non-empty to report. + */ +import { z } from "zod"; +import { + Tsadwyn, + Version, + VersionBundle, + VersionChange, + VersionedRouter, + convertResponseToPreviousVersionFor, + ResponseInfo, +} from "../../src/index.js"; + +const Thing = z + .object({ id: z.string(), name: z.string() }) + .named("CliMigrationsFixture_Thing"); + +const router = new VersionedRouter(); +router.get("/things/:id", null, Thing, async (req: any) => ({ + id: req.params.id, + name: "example", +})); + +class RenameThingNameToTitle extends VersionChange { + description = + "Initial version used `name`; 2001 renames to `title` and this " + + "migration maps it back for initial-version clients."; + instructions = []; + + r1 = convertResponseToPreviousVersionFor(Thing)((res: ResponseInfo) => { + if (res.body && typeof res.body === "object" && res.body.title !== undefined) { + res.body.name = res.body.title; + delete res.body.title; + } + }); +} + +const app = new Tsadwyn({ + versions: new VersionBundle( + new Version("2001-01-01", RenameThingNameToTitle), + new Version("2000-01-01"), + ), +}); +app.generateAndIncludeVersionedRouters(router); + +export default app; diff --git a/tests/http-errors.test.ts b/tests/http-errors.test.ts index b2d41df..8f1eaac 100644 --- a/tests/http-errors.test.ts +++ b/tests/http-errors.test.ts @@ -141,7 +141,10 @@ describe("HTTPException response migration", () => { schema(SuccessResponse2).field("field").had({ name: "old_field" }), ]; - r1 = convertResponseToPreviousVersionFor(SuccessResponse2)( + // Explicit opt-out: migrateHttpErrors defaults to TRUE now (Stripe + // semantics). Pass false when the migration should only touch + // success-response bodies. + r1 = convertResponseToPreviousVersionFor(SuccessResponse2, { migrateHttpErrors: false })( (res: ResponseInfo) => { if (res.body.field !== undefined) { res.body.old_field = res.body.field; diff --git a/tests/issue-204-body-lint.test.ts b/tests/issue-204-body-lint.test.ts new file mode 100644 index 0000000..4878777 --- /dev/null +++ b/tests/issue-204-body-lint.test.ts @@ -0,0 +1,147 @@ +/** + * Covers the generation-time lint that warns when `statusCode: 204` is + * paired with a non-null `responseSchema`. The in-memory migration + * pipeline may run successfully, but Node's HTTP writer strips the body + * at the wire level per RFC 9110 §15.3.5 (verified empirically against + * api.stripe.com). The warning recommends the fix (use 200 or the + * `deletedResponseSchema()` helper). + * + * Run: npx vitest run tests/issue-204-body-lint.test.ts + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { z } from "zod"; + +import { + Tsadwyn, + Version, + VersionBundle, + VersionedRouter, +} from "../src/index.js"; + +describe("Issue: 204+body lint at generation time", () => { + let warnSpy: ReturnType; + + beforeEach(() => { + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + it("warns when a route has statusCode: 204 AND a non-null responseSchema", () => { + const DeleteResp = z + .object({ id: z.string(), deleted: z.boolean() }) + .named("Issue204Lint_DeleteResp"); + + const router = new VersionedRouter(); + router.delete( + "/users/:id", + null, + DeleteResp, + async () => ({ id: "x", deleted: true }), + { statusCode: 204 }, + ); + + const app = new Tsadwyn({ + versions: new VersionBundle(new Version("2024-01-01")), + }); + app.generateAndIncludeVersionedRouters(router); + + const warned = warnSpy.mock.calls.some((args) => + args.some( + (a) => + typeof a === "string" && + /204/.test(a) && + /users\/:id/.test(a) && + /(wire|strip|RFC|body.*not.*arrive|body won't arrive|deletedResponseSchema|statusCode.*200)/i.test( + a, + ), + ), + ); + expect( + warned, + `Expected a generation-time warn pointing out the 204+body wire-strip footgun. ` + + `Got: ${JSON.stringify(warnSpy.mock.calls)}`, + ).toBe(true); + }); + + it("does NOT warn when statusCode: 204 has a null responseSchema", () => { + const router = new VersionedRouter(); + router.delete( + "/users/:id", + null, + null, + async () => undefined, + { statusCode: 204 }, + ); + + const app = new Tsadwyn({ + versions: new VersionBundle(new Version("2024-01-01")), + }); + app.generateAndIncludeVersionedRouters(router); + + const warned = warnSpy.mock.calls.some((args) => + args.some( + (a) => typeof a === "string" && /204/.test(a) && /wire|strip/i.test(a), + ), + ); + expect(warned).toBe(false); + }); + + it("does NOT warn for statusCode: 200 + non-null responseSchema (the recommended pattern)", () => { + const DeleteResp = z + .object({ id: z.string(), deleted: z.boolean() }) + .named("Issue204Lint_200DeleteResp"); + + const router = new VersionedRouter(); + router.delete( + "/users/:id", + null, + DeleteResp, + async () => ({ id: "x", deleted: true }), + // no statusCode override = defaults to 200 + ); + + const app = new Tsadwyn({ + versions: new VersionBundle(new Version("2024-01-01")), + }); + app.generateAndIncludeVersionedRouters(router); + + const warned = warnSpy.mock.calls.some((args) => + args.some( + (a) => typeof a === "string" && /204/.test(a) && /wire|strip/i.test(a), + ), + ); + expect(warned).toBe(false); + }); + + it("warn message recommends the fix (200 or deletedResponseSchema)", () => { + const DeleteResp = z + .object({ id: z.string() }) + .named("Issue204Lint_RecommendResp"); + + const router = new VersionedRouter(); + router.delete( + "/items/:id", + null, + DeleteResp, + async () => ({ id: "x" }), + { statusCode: 204 }, + ); + + const app = new Tsadwyn({ + versions: new VersionBundle(new Version("2024-01-01")), + }); + app.generateAndIncludeVersionedRouters(router); + + const foundRecommendation = warnSpy.mock.calls.some((args) => + args.some( + (a) => + typeof a === "string" && + /(deletedResponseSchema|statusCode.*200|use 200)/i.test(a), + ), + ); + expect(foundRecommendation).toBe(true); + }); +}); diff --git a/tests/issue-build-behavior-resolver.test.ts b/tests/issue-build-behavior-resolver.test.ts new file mode 100644 index 0000000..f1e30de --- /dev/null +++ b/tests/issue-build-behavior-resolver.test.ts @@ -0,0 +1,179 @@ +/** + * Covers `buildBehaviorResolver(map, fallback, opts?)` — the low-level + * behavior-map resolver consumers reach for when they want a raw + * `Map` lookup without the full `createVersionedBehavior` + * typed-shape ceremony. Exercises the `'silent' | 'warn-once' | 'warn-every'` + * telemetry for unknown-version lookups. + * + * Run: npx vitest run tests/issue-build-behavior-resolver.test.ts + */ +import { describe, it, expect, vi } from "vitest"; + +import { apiVersionStorage, buildBehaviorResolver } from "../src/index.js"; + +interface Behavior { + feature: string; +} + +describe("Issue: buildBehaviorResolver helper", () => { + it("returns the mapped behavior for a known version", () => { + const map = new Map([ + ["2024-01-01", { feature: "v1" }], + ["2025-01-01", { feature: "v2" }], + ]); + const fallback: Behavior = { feature: "head" }; + const resolve = buildBehaviorResolver(map, fallback); + + let result: Behavior | undefined; + apiVersionStorage.run("2024-01-01", () => { + result = resolve(); + }); + expect(result).toEqual({ feature: "v1" }); + }); + + it("returns fallback when version is unknown", () => { + const map = new Map([ + ["2024-01-01", { feature: "v1" }], + ]); + const fallback: Behavior = { feature: "head" }; + const resolve = buildBehaviorResolver(map, fallback); + + let result: Behavior | undefined; + apiVersionStorage.run("2099-12-31", () => { + result = resolve(); + }); + expect(result).toEqual({ feature: "head" }); + }); + + it("returns fallback when no version is set in async storage", () => { + const map = new Map([ + ["2024-01-01", { feature: "v1" }], + ]); + const fallback: Behavior = { feature: "head" }; + const resolve = buildBehaviorResolver(map, fallback); + + // Outside of any apiVersionStorage.run() — store is undefined + expect(resolve()).toEqual({ feature: "head" }); + }); + + it("warn-once dedupes per-version on unknown lookups", () => { + const map = new Map([ + ["2024-01-01", { feature: "v1" }], + ]); + const fallback: Behavior = { feature: "head" }; + const warn = vi.fn(); + const resolve = buildBehaviorResolver(map, fallback, { + onUnknown: "warn-once", + logger: { warn }, + }); + + apiVersionStorage.run("2099-12-31", () => { + resolve(); + resolve(); + resolve(); + }); + apiVersionStorage.run("1999-12-31", () => { + resolve(); + resolve(); + }); + + // Two distinct unknown versions — two warns total + expect(warn).toHaveBeenCalledTimes(2); + const ctxArgs = warn.mock.calls.map((c) => c[0]); + expect(ctxArgs.some((c: any) => c.version === "2099-12-31")).toBe(true); + expect(ctxArgs.some((c: any) => c.version === "1999-12-31")).toBe(true); + }); + + it("warn-every emits on every unknown lookup", () => { + const fallback: Behavior = { feature: "head" }; + const warn = vi.fn(); + const resolve = buildBehaviorResolver(new Map(), fallback, { + onUnknown: "warn-every", + logger: { warn }, + }); + + apiVersionStorage.run("2099-12-31", () => { + resolve(); + resolve(); + resolve(); + }); + + expect(warn).toHaveBeenCalledTimes(3); + }); + + it("silent mode emits no warnings", () => { + const fallback: Behavior = { feature: "head" }; + const warn = vi.fn(); + const resolve = buildBehaviorResolver(new Map(), fallback, { + onUnknown: "silent", + logger: { warn }, + }); + + apiVersionStorage.run("2099-12-31", () => { + resolve(); + }); + + expect(warn).not.toHaveBeenCalled(); + }); + + it("warning context includes the supportedVersions for diagnosis", () => { + const map = new Map([ + ["2024-01-01", { feature: "v1" }], + ["2025-01-01", { feature: "v2" }], + ]); + const warn = vi.fn(); + const resolve = buildBehaviorResolver(map, { feature: "head" }, { + onUnknown: "warn-every", + logger: { warn }, + }); + + apiVersionStorage.run("bogus", () => { + resolve(); + }); + + expect(warn).toHaveBeenCalledOnce(); + const ctx = warn.mock.calls[0][0]; + expect(ctx.version).toBe("bogus"); + expect(ctx.supportedVersions).toEqual(["2024-01-01", "2025-01-01"]); + }); +}); + +describe("buildBehaviorResolver — logger-required enforcement", () => { + // Asking for warnings but providing nowhere to send them is the silent- + // no-op footgun we want to eliminate. Throw loudly at construction so + // the misconfiguration surfaces at boot, not in production when a + // warning quietly fails to appear. + it("throws when onUnknown is 'warn-once' and logger is missing", () => { + const map = new Map([["2024-01-01", { feature: "v1" }]]); + expect(() => + buildBehaviorResolver(map, { feature: "head" }, { + onUnknown: "warn-once", + }), + ).toThrow(/requires a logger/i); + }); + + it("throws when onUnknown is 'warn-every' and logger is missing", () => { + const map = new Map([["2024-01-01", { feature: "v1" }]]); + expect(() => + buildBehaviorResolver(map, { feature: "head" }, { + onUnknown: "warn-every", + }), + ).toThrow(/requires a logger/i); + }); + + it("does not throw when onUnknown is 'silent' and logger is missing", () => { + const map = new Map([["2024-01-01", { feature: "v1" }]]); + expect(() => + buildBehaviorResolver(map, { feature: "head" }, { + onUnknown: "silent", + }), + ).not.toThrow(); + }); + + it("does not throw when onUnknown is unspecified (defaults to 'silent')", () => { + const map = new Map([["2024-01-01", { feature: "v1" }]]); + expect(() => + buildBehaviorResolver(map, { feature: "head" }), + ).not.toThrow(); + }); +}); diff --git a/tests/issue-cli-introspection-subcommands.test.ts b/tests/issue-cli-introspection-subcommands.test.ts new file mode 100644 index 0000000..10b670f --- /dev/null +++ b/tests/issue-cli-introspection-subcommands.test.ts @@ -0,0 +1,171 @@ +/** + * Covers the CLI shells for the introspection triad — + * `tsadwyn routes`, `tsadwyn migrations`, `tsadwyn simulate`. The + * programmatic APIs (`dumpRouteTable`, `inspectMigrationChain`, + * `simulateRoute`) have their own unit tests; this file wires each + * CLI runner against a real fixture app to guard the user-facing + * command contract. + * + * Run: npx vitest run tests/issue-cli-introspection-subcommands.test.ts + */ +import { describe, it, expect } from "vitest"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { runRoutes, runMigrations, runSimulate } from "../src/cli.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const FIXTURES = resolve(__dirname, "fixtures"); +const CLI_APP = resolve(FIXTURES, "cli-happy-app.ts"); +const MIGRATIONS_APP = resolve(FIXTURES, "cli-migrations-app.ts"); + +describe("CLI: tsadwyn routes", () => { + it("runRoutes() with --format json returns a parseable route table", async () => { + const result = await runRoutes({ app: CLI_APP, format: "json" }); + expect(result.exitCode).toBe(0); + const parsed = JSON.parse(result.stdout); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed.length).toBeGreaterThan(0); + // Each entry has the expected RouteTableEntry shape + for (const entry of parsed) { + expect(entry).toHaveProperty("method"); + expect(entry).toHaveProperty("path"); + expect(entry).toHaveProperty("version"); + expect(entry).toHaveProperty("statusCode"); + } + }); + + it("runRoutes() --version filters to one version", async () => { + const result = await runRoutes({ + app: CLI_APP, + version: "2001-01-01", + format: "json", + }); + const parsed = JSON.parse(result.stdout); + expect(parsed.every((e: any) => e.version === "2001-01-01")).toBe(true); + }); + + it("runRoutes() --method filters case-insensitively", async () => { + const result = await runRoutes({ + app: CLI_APP, + method: "post", + format: "json", + }); + const parsed = JSON.parse(result.stdout); + expect(parsed.every((e: any) => e.method === "POST")).toBe(true); + }); + + it("runRoutes() --format table renders a readable header row", async () => { + const result = await runRoutes({ app: CLI_APP, format: "table" }); + expect(result.stdout).toMatch(/Method/); + expect(result.stdout).toMatch(/Path/); + }); + + it("runRoutes() exits non-zero when --app path doesn't export a Tsadwyn", async () => { + const result = await runRoutes({ + app: resolve(FIXTURES, "cli-no-app.ts"), + format: "json", + }); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toBeTruthy(); + }); +}); + +describe("CLI: tsadwyn migrations", () => { + it("runMigrations() returns JSON list of migrations for a schema+version", async () => { + const result = await runMigrations({ + app: MIGRATIONS_APP, + schema: "CliMigrationsFixture_Thing", + version: "2000-01-01", + direction: "response", + format: "json", + }); + expect(result.exitCode).toBe(0); + const parsed = JSON.parse(result.stdout); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed.length).toBeGreaterThan(0); + expect(parsed[0]).toHaveProperty("version"); + expect(parsed[0]).toHaveProperty("changeClassName"); + expect(parsed[0]).toHaveProperty("kind"); + }); + + it("runMigrations() exits non-zero when schema is unknown", async () => { + const result = await runMigrations({ + app: MIGRATIONS_APP, + schema: "NoSuchSchema", + version: "2000-01-01", + direction: "response", + format: "json", + }); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toMatch(/NoSuchSchema|not registered/i); + }); + + it("runMigrations() direction defaults to 'response'", async () => { + const withDefault = await runMigrations({ + app: MIGRATIONS_APP, + schema: "CliMigrationsFixture_Thing", + version: "2000-01-01", + format: "json", + }); + expect(withDefault.exitCode).toBe(0); + }); +}); + +describe("CLI: tsadwyn simulate", () => { + it("runSimulate() returns the simulation result as JSON", async () => { + const result = await runSimulate({ + app: CLI_APP, + method: "GET", + path: "/things/abc", + version: "2001-01-01", + format: "json", + }); + expect(result.exitCode).toBe(0); + const parsed = JSON.parse(result.stdout); + expect(parsed).toHaveProperty("resolvedVersion"); + expect(parsed).toHaveProperty("matchedRoute"); + expect(parsed).toHaveProperty("candidates"); + expect(parsed).toHaveProperty("fallthrough"); + expect(parsed.resolvedVersion).toBe("2001-01-01"); + expect(parsed.matchedRoute).not.toBeNull(); + }); + + it("runSimulate() renders a candidates list in table format", async () => { + const result = await runSimulate({ + app: CLI_APP, + method: "GET", + path: "/things/abc", + version: "2001-01-01", + format: "table", + }); + expect(result.exitCode).toBe(0); + // The table should mention the matched path somewhere + expect(result.stdout).toMatch(/\/things/); + }); + + it("runSimulate() accepts a JSON body via --body and echoes upMigratedBody in JSON output", async () => { + const result = await runSimulate({ + app: CLI_APP, + method: "POST", + path: "/things", + version: "2001-01-01", + body: JSON.stringify({ id: "x", name: "y" }), + format: "json", + }); + expect(result.exitCode).toBe(0); + const parsed = JSON.parse(result.stdout); + // No migrations should run at head, but the field should exist in output. + expect(parsed).toHaveProperty("upMigratedBody"); + }); + + it("runSimulate() exits non-zero when --method or --path is missing", async () => { + const missingMethod = await runSimulate({ + app: CLI_APP, + method: "", + path: "/things/abc", + format: "json", + } as any); + expect(missingMethod.exitCode).not.toBe(0); + }); +}); diff --git a/tests/issue-error-mapper.test.ts b/tests/issue-error-mapper.test.ts new file mode 100644 index 0000000..7198267 --- /dev/null +++ b/tests/issue-error-mapper.test.ts @@ -0,0 +1,206 @@ +/** + * Covers `TsadwynOptions.errorMapper` — a pure + * `(err: unknown) => HttpError | null` invoked inside every versioned + * handler's catch block BEFORE tsadwyn's internal HTTP-likeness check. + * Lets consumers translate domain exceptions (which don't carry HTTP + * semantics) into `HttpError` so they flow through the response-migration + * pipeline instead of escaping to Express's default error handler. + * + * Invariants under test: + * 1. `errorMapper` is accepted at `Tsadwyn` construction. + * 2. The mapper runs in the catch block before the HTTP-likeness check. + * 3. Mapped `HttpError` flows through `migrateHttpErrors: true` migrations. + * 4. A throwing mapper does NOT crash the response — tsadwyn returns 500 + * via Express's default handler. + * + * Run: npx vitest run tests/issue-error-mapper.test.ts + */ +import { describe, it, expect } from "vitest"; +import request from "supertest"; +import { z } from "zod"; + +import { + Tsadwyn, + Version, + VersionBundle, + VersionChange, + VersionedRouter, + HttpError, + convertResponseToPreviousVersionFor, + ResponseInfo, +} from "../src/index.js"; + +// --------------------------------------------------------------------------- +// Domain exception classes that intentionally don't carry HTTP semantics. +// In real consumer codebases these live in /domain or /service layers and +// must not depend on tsadwyn or Express. +// --------------------------------------------------------------------------- + +class IdempotencyKeyReuseError extends Error { + constructor(message: string) { + super(message); + this.name = "IdempotencyKeyReuseError"; + } +} + +class ServiceValidationError extends Error { + details: unknown; + constructor(message: string, details: unknown) { + super(message); + this.name = "ServiceValidationError"; + this.details = details; + } +} + +const ErrorBody = z + .object({ + code: z.string(), + message: z.string(), + details: z.unknown().optional(), + }) + .named("IssueErrorMapper_ErrorBody"); + +const Resp = z.object({ ok: z.literal(true) }).named("IssueErrorMapper_Resp"); + +describe("Issue: errorMapper option translates domain exceptions to HttpError", () => { + it("invokes errorMapper for non-HTTP-like errors and returns the mapped status + body", async () => { + const router = new VersionedRouter(); + router.post("/users", null, Resp, async () => { + throw new IdempotencyKeyReuseError("key already used"); + }); + + const app = new Tsadwyn({ + versions: new VersionBundle(new Version("2024-01-01")), + // GAP: errorMapper is not yet a recognized option. + errorMapper: (err: unknown) => { + if (err instanceof Error && err.name === "IdempotencyKeyReuseError") { + return new HttpError(409, { + code: "idempotency_key_reused", + message: err.message, + }); + } + return null; + }, + } as any); + app.generateAndIncludeVersionedRouters(router); + + const res = await request(app.expressApp) + .post("/users") + .set("x-api-version", "2024-01-01") + .send({}); + + expect(res.status).toBe(409); + expect(res.body).toEqual({ + code: "idempotency_key_reused", + message: "key already used", + }); + }); + + it("falls through to next(err) when errorMapper returns null", async () => { + const router = new VersionedRouter(); + router.get("/things", null, Resp, async () => { + throw new Error("some unrelated error"); + }); + + const app = new Tsadwyn({ + versions: new VersionBundle(new Version("2024-01-01")), + errorMapper: (_err: unknown) => null, + } as any); + app.generateAndIncludeVersionedRouters(router); + + // Express's default error handler renders a 500 with no JSON body + // when no other handler claims the error. The exact shape isn't what + // we're asserting — we're asserting the mapper did NOT short-circuit + // the error to a 200/400/etc. + const res = await request(app.expressApp) + .get("/things") + .set("x-api-version", "2024-01-01"); + expect(res.status).toBe(500); + }); + + it("runs response migrations on the mapped HttpError when migrateHttpErrors=true", async () => { + // Legacy clients expect { error_code, error_message } instead of { code, message }. + class RenameErrorFields extends VersionChange { + description = "Legacy error envelope used error_code/error_message"; + instructions = []; + + r1 = convertResponseToPreviousVersionFor(ErrorBody, { + migrateHttpErrors: true, + })((res: ResponseInfo) => { + if (res.body && typeof res.body === "object") { + if (res.body.code !== undefined) { + res.body.error_code = res.body.code; + delete res.body.code; + } + if (res.body.message !== undefined) { + res.body.error_message = res.body.message; + delete res.body.message; + } + } + }); + } + + const router = new VersionedRouter(); + router.post("/validate", null, ErrorBody, async () => { + throw new ServiceValidationError("name is required", { field: "name" }); + }); + + const app = new Tsadwyn({ + versions: new VersionBundle( + new Version("2025-01-01", RenameErrorFields), + new Version("2024-01-01"), + ), + errorMapper: (err: unknown) => { + if (err instanceof Error && err.name === "ServiceValidationError") { + return new HttpError(400, { + code: "validation_error", + message: err.message, + details: (err as ServiceValidationError).details, + }); + } + return null; + }, + } as any); + app.generateAndIncludeVersionedRouters(router); + + // Head client — gets the new shape + const headRes = await request(app.expressApp) + .post("/validate") + .set("x-api-version", "2025-01-01") + .send({}); + expect(headRes.status).toBe(400); + expect(headRes.body.code).toBe("validation_error"); + expect(headRes.body.message).toBe("name is required"); + + // Legacy client — gets the legacy shape via the migration on the + // mapper-produced HttpError + const legacyRes = await request(app.expressApp) + .post("/validate") + .set("x-api-version", "2024-01-01") + .send({}); + expect(legacyRes.status).toBe(400); + expect(legacyRes.body.error_code).toBe("validation_error"); + expect(legacyRes.body.error_message).toBe("name is required"); + expect(legacyRes.body.code).toBeUndefined(); + }); + + it("handles errorMapper that itself throws — returns 500 without crashing the process", async () => { + const router = new VersionedRouter(); + router.get("/danger", null, Resp, async () => { + throw new Error("from handler"); + }); + + const app = new Tsadwyn({ + versions: new VersionBundle(new Version("2024-01-01")), + errorMapper: (_err: unknown) => { + throw new Error("mapper itself blew up"); + }, + } as any); + app.generateAndIncludeVersionedRouters(router); + + const res = await request(app.expressApp) + .get("/danger") + .set("x-api-version", "2024-01-01"); + expect(res.status).toBe(500); + }); +}); diff --git a/tests/issue-exception-map.test.ts b/tests/issue-exception-map.test.ts new file mode 100644 index 0000000..799b616 --- /dev/null +++ b/tests/issue-exception-map.test.ts @@ -0,0 +1,436 @@ +/** + * Covers `exceptionMap()` — a declarative helper on top of the + * `errorMapper` option that adds introspection (has / lookup / + * registeredNames / describe) and CLI integration via + * `tsadwyn exceptions`. + * + * Run: npx vitest run tests/issue-exception-map.test.ts + */ +import { describe, it, expect } from "vitest"; +import request from "supertest"; +import { z } from "zod"; + +import { + Tsadwyn, + Version, + VersionBundle, + VersionChange, + VersionedRouter, + HttpError, + ResponseInfo, + convertResponseToPreviousVersionFor, + TsadwynStructureError, + exceptionMap, +} from "../src/index.js"; + +import { runExceptions } from "../src/cli.js"; + +// --------------------------------------------------------------------------- +// Domain exception classes that do NOT carry HTTP semantics +// --------------------------------------------------------------------------- + +class IdempotencyKeyReuseError extends Error { + constructor(message: string) { + super(message); + this.name = "IdempotencyKeyReuseError"; + } +} + +class ServiceValidationError extends Error { + details: unknown; + constructor(message: string, details: unknown) { + super(message); + this.name = "ServiceValidationError"; + this.details = details; + } +} + +class NotFoundError extends Error { + constructor(message: string) { + super(message); + this.name = "NotFoundError"; + } +} + +class RateLimitError extends Error { + retryAfter: number; + constructor(message: string, retryAfter: number) { + super(message); + this.name = "RateLimitError"; + this.retryAfter = retryAfter; + } +} + +const Resp = z.object({ ok: z.literal(true) }).named("IssueExceptionMap_Resp"); + +// --------------------------------------------------------------------------- +// Helper contract +// --------------------------------------------------------------------------- + +describe("Issue: exceptionMap — function-form mapping", () => { + it("returns the HttpError constructed by the function-form mapping", () => { + const mapper = exceptionMap({ + IdempotencyKeyReuseError: (err: any) => + new HttpError(409, { code: "idempotency_key_reused", message: err.message }), + }); + + const err = new IdempotencyKeyReuseError("key xyz used"); + const result = mapper(err); + + expect(result).toBeInstanceOf(HttpError); + expect(result!.statusCode).toBe(409); + expect(result!.body).toEqual({ + code: "idempotency_key_reused", + message: "key xyz used", + }); + }); + + it("returns null for unmapped errors (fallthrough)", () => { + const mapper = exceptionMap({ + IdempotencyKeyReuseError: (err: any) => + new HttpError(409, { message: err.message }), + }); + + expect(mapper(new Error("totally unrelated"))).toBeNull(); + }); +}); + +describe("Issue: exceptionMap — static-form mapping", () => { + it("produces HttpError(status, {code, message: err.message}) for static-form entries", () => { + const mapper = exceptionMap({ + NotFoundError: { status: 404, code: "not_found" }, + }); + + const err = new NotFoundError("user 123 missing"); + const result = mapper(err); + + expect(result).toBeInstanceOf(HttpError); + expect(result!.statusCode).toBe(404); + expect(result!.body).toMatchObject({ + code: "not_found", + message: "user 123 missing", + }); + }); + + it("honors the explicit `message` override in the static form", () => { + const mapper = exceptionMap({ + NotFoundError: { status: 404, code: "not_found", message: "resource missing" }, + }); + + const err = new NotFoundError("some internal detail"); + const result = mapper(err); + + expect(result!.body.message).toBe("resource missing"); + }); +}); + +describe("Issue: exceptionMap — static-with-transform mapping", () => { + it("composes static status/code with dynamic body from transform", () => { + const mapper = exceptionMap({ + RateLimitError: { + status: 429, + code: "rate_limited", + transform: (err: any) => ({ + message: err.message, + retryAfter: err.retryAfter, + }), + }, + }); + + const err = new RateLimitError("too many", 60); + const result = mapper(err); + + expect(result!.statusCode).toBe(429); + expect(result!.body).toMatchObject({ + code: "rate_limited", + message: "too many", + retryAfter: 60, + }); + }); +}); + +// --------------------------------------------------------------------------- +// Introspection +// --------------------------------------------------------------------------- + +describe("Issue: exceptionMap — introspection", () => { + function buildMapper() { + return exceptionMap({ + IdempotencyKeyReuseError: (err: any) => + new HttpError(409, { code: "idempotency_key_reused", message: err.message }), + NotFoundError: { status: 404, code: "not_found" }, + RateLimitError: { + status: 429, + code: "rate_limited", + transform: (err: any) => ({ message: err.message }), + }, + }); + } + + it("exposes registeredNames as a readonly array of all mapped names", () => { + const mapper = buildMapper(); + expect(mapper.registeredNames).toEqual( + expect.arrayContaining([ + "IdempotencyKeyReuseError", + "NotFoundError", + "RateLimitError", + ]), + ); + expect(mapper.registeredNames.length).toBe(3); + }); + + it("has(name) reports mapped + unmapped classes correctly", () => { + const mapper = buildMapper(); + expect(mapper.has("IdempotencyKeyReuseError")).toBe(true); + expect(mapper.has("NotFoundError")).toBe(true); + expect(mapper.has("TotallyUnknownError")).toBe(false); + }); + + it("lookup(name) returns the registered mapping or undefined", () => { + const mapper = buildMapper(); + expect(mapper.lookup("NotFoundError")).toEqual({ + status: 404, + code: "not_found", + }); + expect(mapper.lookup("TotallyUnknownError")).toBeUndefined(); + }); + + it("describe() returns a structured table with kind/status/code/hasTransform per entry", () => { + const mapper = buildMapper(); + const table = mapper.describe(); + + const byName = Object.fromEntries(table.map((e: any) => [e.name, e])); + + expect(byName["NotFoundError"]).toMatchObject({ + kind: "static", + status: 404, + code: "not_found", + hasTransform: false, + }); + + expect(byName["IdempotencyKeyReuseError"]).toMatchObject({ + kind: "function", + status: null, + code: null, + hasTransform: false, + }); + + expect(byName["RateLimitError"]).toMatchObject({ + kind: "static-with-transform", + status: 429, + code: "rate_limited", + hasTransform: true, + }); + }); +}); + +// --------------------------------------------------------------------------- +// Constructor-time validation +// --------------------------------------------------------------------------- + +describe("Issue: exceptionMap — construction validation", () => { + it("throws TsadwynStructureError on duplicate keys (detected via case-insensitive comparison)", () => { + // JS objects can't have duplicate string keys — the only way to hit this is + // merging two configs. The helper should offer a merge primitive or detect + // collisions in a declared-duplicates form. For now, a merge helper: + // exceptionMap({ X: ..., ...other, X: ... }) — JS keeps the last value, no dedup. + // A merge helper exceptionMap.merge(a, b) should throw on overlap: + + const a: Record = { + NotFoundError: { status: 404, code: "not_found" }, + }; + const b: Record = { + NotFoundError: { status: 410, code: "gone" }, // duplicate + }; + + expect(() => (exceptionMap as any).merge(a, b)).toThrow(TsadwynStructureError); + }); + + it("rejects a static-form mapping with status outside 4xx/5xx range (construction-time)", () => { + expect(() => + exceptionMap({ + WeirdError: { status: 200, code: "ok" } as any, + }), + ).toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// End-to-end integration with Tsadwyn.errorMapper +// --------------------------------------------------------------------------- + +describe("Issue: exceptionMap — integration with errorMapper", () => { + it("wires directly into Tsadwyn.errorMapper and produces the mapped HTTP response", async () => { + const router = new VersionedRouter(); + router.post("/users", null, Resp, async () => { + throw new IdempotencyKeyReuseError("key xyz used"); + }); + + const app = new Tsadwyn({ + versions: new VersionBundle(new Version("2024-01-01")), + errorMapper: exceptionMap({ + IdempotencyKeyReuseError: (err: any) => + new HttpError(409, { code: "idempotency_key_reused", message: err.message }), + }), + } as any); + app.generateAndIncludeVersionedRouters(router); + + const res = await request(app.expressApp) + .post("/users") + .set("x-api-version", "2024-01-01") + .send({}); + + expect(res.status).toBe(409); + expect(res.body).toEqual({ + code: "idempotency_key_reused", + message: "key xyz used", + }); + }); + + it("composes with migrateHttpErrors — mapped HttpError flows through response migrations", async () => { + const ErrorBody = z + .object({ code: z.string(), message: z.string() }) + .named("IssueExceptionMap_ErrorBody"); + + class RenameErrorFields extends VersionChange { + description = "legacy error envelope used error_code/error_message"; + instructions = []; + + r1 = convertResponseToPreviousVersionFor(ErrorBody, { + migrateHttpErrors: true, + })((res: ResponseInfo) => { + if (res.body?.code !== undefined) { + res.body.error_code = res.body.code; + delete res.body.code; + } + if (res.body?.message !== undefined) { + res.body.error_message = res.body.message; + delete res.body.message; + } + }); + } + + const router = new VersionedRouter(); + router.post("/validate", null, ErrorBody, async () => { + throw new ServiceValidationError("name required", { field: "name" }); + }); + + const app = new Tsadwyn({ + versions: new VersionBundle( + new Version("2025-01-01", RenameErrorFields), + new Version("2024-01-01"), + ), + errorMapper: exceptionMap({ + ServiceValidationError: (err: any) => + new HttpError(400, { + code: "validation_error", + message: err.message, + }), + }), + } as any); + app.generateAndIncludeVersionedRouters(router); + + // Legacy client — gets migrated error shape + const legacyRes = await request(app.expressApp) + .post("/validate") + .set("x-api-version", "2024-01-01") + .send({}); + + expect(legacyRes.status).toBe(400); + expect(legacyRes.body.error_code).toBe("validation_error"); + expect(legacyRes.body.error_message).toBe("name required"); + expect(legacyRes.body.code).toBeUndefined(); + }); + + it("matches by err.name string, NOT instanceof (survives module identity drift)", () => { + const mapper = exceptionMap({ + NotFoundError: { status: 404, code: "not_found" }, + }); + + // Construct an err that has the right NAME but is NOT an instance of our + // NotFoundError class (simulating dual-install / resetModules scenario). + const crossBoundaryErr = Object.assign(new Error("cross-boundary"), { + name: "NotFoundError", + }); + + const result = mapper(crossBoundaryErr); + + expect(result).toBeInstanceOf(HttpError); + expect(result!.statusCode).toBe(404); + }); + + it("does NOT match by inheritance — subclass names are distinct entries", () => { + const mapper = exceptionMap({ + ConflictException: (err: any) => new HttpError(409, { code: "conflict" }), + }); + + class IdempotencyKeyReuseErrorSubclass extends Error { + constructor() { + super("subclass"); + this.name = "IdempotencyKeyReuseErrorSubclass"; + } + } + + // Even though conceptually it's a "conflict", name differs → null + expect(mapper(new IdempotencyKeyReuseErrorSubclass())).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// CLI integration — tsadwyn exceptions +// --------------------------------------------------------------------------- + +describe("Issue: exceptionMap — `tsadwyn exceptions` CLI", () => { + it("runExceptions() produces JSON output matching describe()", async () => { + // The CLI subcommand is expected to be exported for programmatic testing, + // same pattern as runCodegen, runInfo, runNewVersion in cli.ts today. + const result = await runExceptions({ + app: "./tests/fixtures/cli-exception-map-app.ts", + format: "json", + }); + + // Output is parseable JSON + const parsed = JSON.parse(result.stdout); + expect(Array.isArray(parsed)).toBe(true); + + // Contains at least one known entry + const hasNotFound = parsed.some( + (e: any) => e.name === "NotFoundError" && e.kind === "static", + ); + expect(hasNotFound).toBe(true); + }); + + it("runExceptions() filters entries by --filter regex", async () => { + const result = await runExceptions({ + app: "./tests/fixtures/cli-exception-map-app.ts", + format: "json", + filter: "^Idempotency", + }); + + const parsed = JSON.parse(result.stdout); + expect(parsed.every((e: any) => /^Idempotency/.test(e.name))).toBe(true); + expect(parsed.length).toBeGreaterThan(0); + }); + + it("runExceptions() table format renders a readable ASCII table", async () => { + const result = await runExceptions({ + app: "./tests/fixtures/cli-exception-map-app.ts", + format: "table", + }); + + // Must contain a header row naming the columns + expect(result.stdout).toMatch(/Exception name/); + expect(result.stdout).toMatch(/Status/); + expect(result.stdout).toMatch(/Code/); + }); + + it("runExceptions() exits non-zero when --app path doesn't expose an introspectable errorMapper", async () => { + const result = await runExceptions({ + app: "./tests/fixtures/cli-happy-app.ts", // app without exception map + format: "json", + }); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toMatch(/errorMapper/i); + }); +}); diff --git a/tests/issue-head-requests.test.ts b/tests/issue-head-requests.test.ts new file mode 100644 index 0000000..1852a7e --- /dev/null +++ b/tests/issue-head-requests.test.ts @@ -0,0 +1,286 @@ +/** + * Covers explicit HEAD method support in `VersionedRouter`: + * + * 1. `VersionedRouter.head()` exists with the same signature as `.get()`. + * 2. The generated handler skips response-body migrations when + * `req.method === 'HEAD'` (no wire body to mutate). + * 3. Header-only migrations still fire on HEAD. + * 4. `migrateHttpErrors` applies on HEAD error paths (status + headers, + * no body). + * 5. 405 with an `Allow` header is returned when HEAD is requested on a + * path with no matching GET. + * 6. Generation-time lint warns when `.get()` and `.head()` share a + * path (Express auto-mirrors — explicit HEAD is rarely intentional). + * + * Run: npx vitest run tests/issue-head-requests.test.ts + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import request from "supertest"; +import { z } from "zod"; + +import { + Tsadwyn, + Version, + VersionBundle, + VersionChange, + VersionedRouter, + HttpError, + ResponseInfo, + convertResponseToPreviousVersionFor, +} from "../src/index.js"; + +const UserSchema = z + .object({ id: z.string(), name: z.string() }) + .named("IssueHead_User"); + +describe("Issue: HEAD request support", () => { + let warnSpy: ReturnType; + + beforeEach(() => { + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + it("dispatches HEAD /users/:id to the registered GET handler when no explicit HEAD is set", async () => { + const router = new VersionedRouter(); + router.get("/users/:id", null, UserSchema, async (req: any) => ({ + id: req.params.id, + name: "alice", + })); + + const app = new Tsadwyn({ + versions: new VersionBundle(new Version("2024-01-01")), + }); + app.generateAndIncludeVersionedRouters(router); + + const res = await request(app.expressApp) + .head("/users/123") + .set("x-api-version", "2024-01-01"); + + expect(res.status).toBe(200); + // HEAD must have no body + expect(res.body).toEqual({}); + expect(res.text).toBeFalsy(); + }); + + it("dispatches to the explicit HEAD handler when one is registered (overrides GET auto-mirror)", async () => { + const router = new VersionedRouter(); + const getSpy = vi.fn(async (req: any) => ({ id: req.params.id, name: "alice" })); + const headSpy = vi.fn(async (_req: any) => undefined); + + router.get("/users/:id", null, UserSchema, getSpy); + // GAP: .head() is not a method on VersionedRouter today + (router as any).head("/users/:id", null, null, headSpy); + + const app = new Tsadwyn({ + versions: new VersionBundle(new Version("2024-01-01")), + }); + app.generateAndIncludeVersionedRouters(router); + + await request(app.expressApp) + .head("/users/123") + .set("x-api-version", "2024-01-01"); + + expect(headSpy).toHaveBeenCalledOnce(); + expect(getSpy).not.toHaveBeenCalled(); + }); + + it("skips response-body migrations on HEAD (body transformer NOT called)", async () => { + const bodyTransformSpy = vi.fn((res: ResponseInfo) => { + // rename `name` → `display_name` for legacy clients + if (res.body?.name !== undefined) { + res.body.display_name = res.body.name; + delete res.body.name; + } + }); + + class RenameNameField extends VersionChange { + description = "rename name → display_name for legacy clients"; + instructions = []; + + r1 = convertResponseToPreviousVersionFor(UserSchema)(bodyTransformSpy); + } + + const router = new VersionedRouter(); + router.get("/users/:id", null, UserSchema, async (req: any) => ({ + id: req.params.id, + name: "alice", + })); + + const app = new Tsadwyn({ + versions: new VersionBundle( + new Version("2025-01-01", RenameNameField), + new Version("2024-01-01"), + ), + }); + app.generateAndIncludeVersionedRouters(router); + + // GET at legacy version — transformer SHOULD fire + await request(app.expressApp) + .get("/users/123") + .set("x-api-version", "2024-01-01"); + expect(bodyTransformSpy).toHaveBeenCalledOnce(); + + bodyTransformSpy.mockClear(); + + // HEAD at legacy version — transformer should NOT fire + await request(app.expressApp) + .head("/users/123") + .set("x-api-version", "2024-01-01"); + expect(bodyTransformSpy).not.toHaveBeenCalled(); + }); + + it("still runs header migrations on HEAD", async () => { + const headerTransformSpy = vi.fn((res: ResponseInfo) => { + res.headers["x-legacy-header"] = "set-by-migration"; + }); + + class AddLegacyHeader extends VersionChange { + description = "add x-legacy-header for legacy clients"; + instructions = []; + + // headerOnly: true signals the migration only touches res.headers — + // safe to run on body-less contexts (HEAD, 204/304, null-result). + r1 = convertResponseToPreviousVersionFor(UserSchema, { headerOnly: true })( + headerTransformSpy, + ); + } + + const router = new VersionedRouter(); + router.get("/users/:id", null, UserSchema, async (req: any) => ({ + id: req.params.id, + name: "alice", + })); + + const app = new Tsadwyn({ + versions: new VersionBundle( + new Version("2025-01-01", AddLegacyHeader), + new Version("2024-01-01"), + ), + }); + app.generateAndIncludeVersionedRouters(router); + + const res = await request(app.expressApp) + .head("/users/123") + .set("x-api-version", "2024-01-01"); + + expect(headerTransformSpy).toHaveBeenCalledOnce(); + expect(res.headers["x-legacy-header"]).toBe("set-by-migration"); + }); + + it("applies migrateHttpErrors on HEAD error paths without emitting the error body", async () => { + const ErrorBody = z + .object({ code: z.string(), message: z.string() }) + .named("IssueHead_ErrorBody"); + + class RenameErrorCode extends VersionChange { + description = "rename error `code` → `err_code` for legacy"; + instructions = []; + + r1 = convertResponseToPreviousVersionFor(ErrorBody, { migrateHttpErrors: true })( + (res: ResponseInfo) => { + if (res.body?.code !== undefined) { + res.body.err_code = res.body.code; + delete res.body.code; + } + }, + ); + } + + const router = new VersionedRouter(); + router.get("/users/:id", null, ErrorBody, async () => { + throw new HttpError(404, { code: "user_not_found", message: "no such user" }); + }); + + const app = new Tsadwyn({ + versions: new VersionBundle( + new Version("2025-01-01", RenameErrorCode), + new Version("2024-01-01"), + ), + }); + app.generateAndIncludeVersionedRouters(router); + + const res = await request(app.expressApp) + .head("/users/missing") + .set("x-api-version", "2024-01-01"); + + expect(res.status).toBe(404); + // HEAD must have no body even on error + expect(res.text).toBeFalsy(); + expect(res.body).toEqual({}); + }); + + it("Content-Length header matches the equivalent GET response body byte length", async () => { + const router = new VersionedRouter(); + router.get("/users/:id", null, UserSchema, async (req: any) => ({ + id: req.params.id, + name: "alice", + })); + + const app = new Tsadwyn({ + versions: new VersionBundle(new Version("2024-01-01")), + }); + app.generateAndIncludeVersionedRouters(router); + + const getRes = await request(app.expressApp) + .get("/users/123") + .set("x-api-version", "2024-01-01"); + const headRes = await request(app.expressApp) + .head("/users/123") + .set("x-api-version", "2024-01-01"); + + expect(getRes.status).toBe(200); + expect(headRes.status).toBe(200); + // Both must agree on Content-Length (strict HEAD/GET parity) + expect(headRes.headers["content-length"]).toBe(getRes.headers["content-length"]); + }); + + it("returns 405 Method Not Allowed with Allow header on HEAD-to-path-with-only-POST", async () => { + const router = new VersionedRouter(); + router.post("/charges", null, null, async () => ({ ok: true })); + + const app = new Tsadwyn({ + versions: new VersionBundle(new Version("2024-01-01")), + }); + app.generateAndIncludeVersionedRouters(router); + + const res = await request(app.expressApp) + .head("/charges") + .set("x-api-version", "2024-01-01"); + + expect(res.status).toBe(405); + // Allow header should list the methods that ARE registered + expect(res.headers["allow"]).toMatch(/POST/); + }); + + it("emits a registration-time warning when both .get() and .head() are registered for the same path", async () => { + const router = new VersionedRouter(); + router.get("/users/:id", null, UserSchema, async (req: any) => ({ + id: req.params.id, + name: "alice", + })); + (router as any).head("/users/:id", null, null, async () => undefined); + + const app = new Tsadwyn({ + versions: new VersionBundle(new Version("2024-01-01")), + }); + app.generateAndIncludeVersionedRouters(router); + + const warned = warnSpy.mock.calls.some((args) => + args.some( + (a) => + typeof a === "string" && + a.includes("/users/:id") && + /HEAD/i.test(a) && + /GET/i.test(a), + ), + ); + expect( + warned, + `Expected a warning naming both GET and HEAD for /users/:id. Got: ${JSON.stringify(warnSpy.mock.calls)}`, + ).toBe(true); + }); +}); diff --git a/tests/issue-migrate-payload-to-version.test.ts b/tests/issue-migrate-payload-to-version.test.ts new file mode 100644 index 0000000..8a176f6 --- /dev/null +++ b/tests/issue-migrate-payload-to-version.test.ts @@ -0,0 +1,206 @@ +/** + * Covers `migratePayloadToVersion(schemaName, payload, targetVersion, bundle)` + * — the out-of-band payload reshaper. `convertResponseToPreviousVersionFor` + * only fires for in-flight HTTP responses, so outbound webhooks dispatched + * from background jobs hand-build payloads that would otherwise bypass the + * migration pipeline entirely. This helper walks the same response + * migrations registered against `schemaName` and returns the payload + * reshaped for the destination client's pin. + * + * NOTE: VersionChange subclasses are bound to one VersionBundle for life + * (T-1602). Each `it()` declares its own classes so the bundles are + * independent. + * + * Run: npx vitest run tests/issue-migrate-payload-to-version.test.ts + */ +import { describe, it, expect } from "vitest"; +import { z } from "zod"; + +import { + Version, + VersionBundle, + VersionChange, + ResponseInfo, + convertResponseToPreviousVersionFor, + migratePayloadToVersion, +} from "../src/index.js"; + +const VirtualAccount = z + .object({ + id: z.string(), + status: z.enum(["pending", "ok", "declined", "failed"]), + }) + .named("IssueWebhook_VirtualAccount"); + +describe("Issue: migratePayloadToVersion for outbound webhooks", () => { + it("applies the response migration to take a head payload back to a legacy version", () => { + class RenameDeclinedToFailed_a extends VersionChange { + description = + "Initial webhook payload used status: 'failed'; head renamed to 'declined'"; + instructions = []; + + r1 = convertResponseToPreviousVersionFor(VirtualAccount)( + (res: ResponseInfo) => { + if (res.body?.status === "declined") { + res.body.status = "failed"; + } + }, + ); + } + + const versions = new VersionBundle( + new Version("2025-01-01", RenameDeclinedToFailed_a), + new Version("2024-01-01"), + ); + + const headPayload = { id: "va_123", status: "declined" as const }; + + const legacyPayload = migratePayloadToVersion( + "IssueWebhook_VirtualAccount", + headPayload, + "2024-01-01", + versions, + ); + + expect(legacyPayload).toEqual({ id: "va_123", status: "failed" }); + }); + + it("returns the payload unchanged when target == head", () => { + class RenameDeclinedToFailed_b extends VersionChange { + description = "rename declined to failed"; + instructions = []; + + r1 = convertResponseToPreviousVersionFor(VirtualAccount)( + (res: ResponseInfo) => { + if (res.body?.status === "declined") { + res.body.status = "failed"; + } + }, + ); + } + + const versions = new VersionBundle( + new Version("2025-01-01", RenameDeclinedToFailed_b), + new Version("2024-01-01"), + ); + + const headPayload = { id: "va_123", status: "declined" as const }; + + const result = migratePayloadToVersion( + "IssueWebhook_VirtualAccount", + headPayload, + "2025-01-01", + versions, + ); + + expect(result).toEqual(headPayload); + }); + + it("does not mutate the input payload", () => { + class RenameDeclinedToFailed_c extends VersionChange { + description = "rename declined to failed"; + instructions = []; + + r1 = convertResponseToPreviousVersionFor(VirtualAccount)( + (res: ResponseInfo) => { + if (res.body?.status === "declined") { + res.body.status = "failed"; + } + }, + ); + } + + const versions = new VersionBundle( + new Version("2025-01-01", RenameDeclinedToFailed_c), + new Version("2024-01-01"), + ); + + const headPayload = { id: "va_123", status: "declined" as const }; + const headPayloadSnapshot = { ...headPayload }; + + migratePayloadToVersion( + "IssueWebhook_VirtualAccount", + headPayload, + "2024-01-01", + versions, + ); + + expect(headPayload).toEqual(headPayloadSnapshot); + }); + + it("walks multiple intermediate version changes", () => { + class RenameDeclinedToFailed_d extends VersionChange { + description = "rename declined to failed"; + instructions = []; + + r1 = convertResponseToPreviousVersionFor(VirtualAccount)( + (res: ResponseInfo) => { + if (res.body?.status === "declined") { + res.body.status = "failed"; + } + }, + ); + } + + class AddNestedMeta_d extends VersionChange { + description = "Earlier shape had a nested meta object"; + instructions = []; + + r1 = convertResponseToPreviousVersionFor(VirtualAccount)( + (res: ResponseInfo) => { + res.body.meta = { id: res.body.id }; + delete res.body.id; + }, + ); + } + + const versions = new VersionBundle( + new Version("2026-01-01", RenameDeclinedToFailed_d), + new Version("2025-01-01", AddNestedMeta_d), + new Version("2024-01-01"), + ); + + const headPayload = { id: "va_123", status: "declined" as const }; + const legacyPayload = migratePayloadToVersion( + "IssueWebhook_VirtualAccount", + headPayload, + "2024-01-01", + versions, + ); + + // Both migrations applied: rename, then nest + expect(legacyPayload).toEqual({ + meta: { id: "va_123" }, + status: "failed", + }); + }); + + it("throws or returns clearly when targetVersion is not in the bundle", () => { + class RenameDeclinedToFailed_e extends VersionChange { + description = "rename declined to failed"; + instructions = []; + + r1 = convertResponseToPreviousVersionFor(VirtualAccount)( + (res: ResponseInfo) => { + if (res.body?.status === "declined") { + res.body.status = "failed"; + } + }, + ); + } + + const versions = new VersionBundle( + new Version("2025-01-01", RenameDeclinedToFailed_e), + new Version("2024-01-01"), + ); + + expect(() => + migratePayloadToVersion( + "IssueWebhook_VirtualAccount", + { id: "x", status: "declined" as const }, + "1999-01-01", + versions, + ), + ).toThrow(); + }); +}); diff --git a/tests/issue-migration-chain-inspector.test.ts b/tests/issue-migration-chain-inspector.test.ts new file mode 100644 index 0000000..64c915c --- /dev/null +++ b/tests/issue-migration-chain-inspector.test.ts @@ -0,0 +1,424 @@ +/** + * Covers `inspectMigrationChain()` — the public introspection API for + * enumerating which migrations fire for a given schema + client version, + * in the order they'd run. Replaces the grep-the-source fallback + * consumers used to rely on. + * + * Run: npx vitest run tests/issue-migration-chain-inspector.test.ts + */ +import { describe, it, expect } from "vitest"; +import { z } from "zod"; + +import { + Tsadwyn, + Version, + VersionBundle, + VersionChange, + VersionedRouter, + ResponseInfo, + RequestInfo, + convertResponseToPreviousVersionFor, + convertRequestToNextVersionFor, + inspectMigrationChain, +} from "../src/index.js"; + +const Order = z + .object({ id: z.string(), amount: z.number(), currency: z.string() }) + .named("IssueMigChain_Order"); + +describe("Issue: inspectMigrationChain()", () => { + it("returns response migrations in head → client order", () => { + class AddTaxField extends VersionChange { + description = "AddTaxField at 2025-01-01"; + instructions = []; + + migrateResponse = convertResponseToPreviousVersionFor(Order)( + (_res: ResponseInfo) => {}, + ); + } + + class RenameCurrency extends VersionChange { + description = "RenameCurrency at 2025-06-01"; + instructions = []; + + migrateResponse = convertResponseToPreviousVersionFor(Order)( + (_res: ResponseInfo) => {}, + ); + } + + const router = new VersionedRouter(); + router.get("/orders/:id", null, Order, async () => ({ + id: "o1", + amount: 100, + currency: "USD", + })); + + const app = new Tsadwyn({ + versions: new VersionBundle( + new Version("2025-06-01", RenameCurrency), + new Version("2025-01-01", AddTaxField), + new Version("2024-01-01"), + ), + }); + app.generateAndIncludeVersionedRouters(router); + + const chain = inspectMigrationChain(app, { + schemaName: "IssueMigChain_Order", + clientVersion: "2024-01-01", + direction: "response", + }); + + // head → client ordering + expect(chain.length).toBe(2); + expect(chain[0].changeClassName).toBe("RenameCurrency"); + expect(chain[1].changeClassName).toBe("AddTaxField"); + expect(chain[0].order).toBe(0); + expect(chain[1].order).toBe(1); + }); + + it("returns request migrations in client → head order", () => { + class AddCurrencyField extends VersionChange { + description = "AddCurrencyField at 2025-01-01"; + instructions = []; + + migrateRequest = convertRequestToNextVersionFor(Order)( + (_req: RequestInfo) => {}, + ); + } + + class NormalizeAmount extends VersionChange { + description = "NormalizeAmount at 2025-06-01"; + instructions = []; + + migrateRequest = convertRequestToNextVersionFor(Order)( + (_req: RequestInfo) => {}, + ); + } + + const router = new VersionedRouter(); + router.post("/orders", Order, Order, async () => ({ + id: "o1", + amount: 100, + currency: "USD", + })); + + const app = new Tsadwyn({ + versions: new VersionBundle( + new Version("2025-06-01", NormalizeAmount), + new Version("2025-01-01", AddCurrencyField), + new Version("2024-01-01"), + ), + }); + app.generateAndIncludeVersionedRouters(router); + + const chain = inspectMigrationChain(app, { + schemaName: "IssueMigChain_Order", + clientVersion: "2024-01-01", + direction: "request", + }); + + expect(chain.length).toBe(2); + // client → head ordering + expect(chain[0].changeClassName).toBe("AddCurrencyField"); + expect(chain[1].changeClassName).toBe("NormalizeAmount"); + }); + + it("returns empty array when no migrations match the schema", () => { + class NoOp extends VersionChange { + description = "no migrations targeting Order"; + instructions = []; + } + + const router = new VersionedRouter(); + router.get("/orders/:id", null, Order, async () => ({ + id: "o1", + amount: 100, + currency: "USD", + })); + + const app = new Tsadwyn({ + versions: new VersionBundle( + new Version("2025-01-01", NoOp), + new Version("2024-01-01"), + ), + }); + app.generateAndIncludeVersionedRouters(router); + + const chain = inspectMigrationChain(app, { + schemaName: "IssueMigChain_Order", + clientVersion: "2024-01-01", + direction: "response", + }); + + expect(chain).toEqual([]); + }); + + it("includes path-based migrations alongside schema-based ones", () => { + class SchemaBased extends VersionChange { + description = "schema-based migration on Order"; + instructions = []; + + migrateResponse = convertResponseToPreviousVersionFor(Order)( + (_res: ResponseInfo) => {}, + ); + } + + class PathBased extends VersionChange { + description = "path-based migration on /orders/:id GET"; + instructions = []; + + migrateResponse = convertResponseToPreviousVersionFor("/orders/:id", ["GET"])( + (_res: ResponseInfo) => {}, + ); + } + + const router = new VersionedRouter(); + router.get("/orders/:id", null, Order, async () => ({ + id: "o1", + amount: 100, + currency: "USD", + })); + + const app = new Tsadwyn({ + versions: new VersionBundle( + new Version("2025-06-01", PathBased), + new Version("2025-01-01", SchemaBased), + new Version("2024-01-01"), + ), + }); + app.generateAndIncludeVersionedRouters(router); + + const chain = inspectMigrationChain(app, { + schemaName: "IssueMigChain_Order", + clientVersion: "2024-01-01", + direction: "response", + path: "/orders/:id", + method: "GET", + }); + + const kinds = chain.map((e: any) => e.kind); + expect(kinds).toContain("schema-based"); + expect(kinds).toContain("path-based"); + }); + + it("filters out migrateHttpErrors entries when includeErrorMigrations: false", () => { + class ErrorMig extends VersionChange { + description = "error-only migration"; + instructions = []; + + migrate = convertResponseToPreviousVersionFor(Order, { migrateHttpErrors: true })( + (_res: ResponseInfo) => {}, + ); + } + + class SuccessMig extends VersionChange { + description = "success-only migration (opts out of error responses)"; + instructions = []; + + // Default is migrateHttpErrors: true now. Opt out so this migration + // is a clean 'success-only' example for the includeErrorMigrations + // filter test. + migrate = convertResponseToPreviousVersionFor(Order, { migrateHttpErrors: false })( + (_res: ResponseInfo) => {}, + ); + } + + const router = new VersionedRouter(); + router.get("/orders/:id", null, Order, async () => ({ + id: "o1", + amount: 100, + currency: "USD", + })); + + const app = new Tsadwyn({ + versions: new VersionBundle( + new Version("2025-06-01", SuccessMig), + new Version("2025-01-01", ErrorMig), + new Version("2024-01-01"), + ), + }); + app.generateAndIncludeVersionedRouters(router); + + const all = inspectMigrationChain(app, { + schemaName: "IssueMigChain_Order", + clientVersion: "2024-01-01", + direction: "response", + }); + expect(all.length).toBe(2); + + const successOnly = inspectMigrationChain(app, { + schemaName: "IssueMigChain_Order", + clientVersion: "2024-01-01", + direction: "response", + includeErrorMigrations: false, + }); + expect(successOnly.length).toBe(1); + expect(successOnly[0].changeClassName).toBe("SuccessMig"); + }); + + it("throws when schemaName isn't registered in any route or instruction", () => { + const router = new VersionedRouter(); + router.get("/orders/:id", null, Order, async () => ({ + id: "o1", + amount: 100, + currency: "USD", + })); + + const app = new Tsadwyn({ + versions: new VersionBundle(new Version("2024-01-01")), + }); + app.generateAndIncludeVersionedRouters(router); + + expect(() => + inspectMigrationChain(app, { + schemaName: "DoesNotExist", + clientVersion: "2024-01-01", + direction: "response", + }), + ).toThrow(); + }); + + it("throws when clientVersion isn't in the bundle", () => { + const router = new VersionedRouter(); + router.get("/orders/:id", null, Order, async () => ({ + id: "o1", + amount: 100, + currency: "USD", + })); + + const app = new Tsadwyn({ + versions: new VersionBundle(new Version("2024-01-01")), + }); + app.generateAndIncludeVersionedRouters(router); + + expect(() => + inspectMigrationChain(app, { + schemaName: "IssueMigChain_Order", + clientVersion: "1999-01-01", + direction: "response", + }), + ).toThrow(); + }); + + it("returns path-based REQUEST migrations in client → head order when direction='request' + path is supplied", () => { + class PathBasedRequestMig extends VersionChange { + description = "path-based request migration on POST /orders at 2025-01-01"; + instructions = []; + + r1 = convertRequestToNextVersionFor("/orders", ["POST"])( + (_req: RequestInfo) => {}, + ); + } + + const router = new VersionedRouter(); + router.post("/orders", Order, Order, async () => ({ + id: "o1", + amount: 100, + currency: "USD", + })); + + const app = new Tsadwyn({ + versions: new VersionBundle( + new Version("2025-01-01", PathBasedRequestMig), + new Version("2024-01-01"), + ), + }); + app.generateAndIncludeVersionedRouters(router); + + const chain = inspectMigrationChain(app, { + schemaName: "IssueMigChain_Order", + clientVersion: "2024-01-01", + direction: "request", + path: "/orders", + method: "POST", + }); + + const pathBased = chain.filter((e: any) => e.kind === "path-based"); + expect(pathBased.length).toBe(1); + expect(pathBased[0]).toMatchObject({ + version: "2025-01-01", + changeClassName: "PathBasedRequestMig", + kind: "path-based", + path: "/orders", + }); + expect(pathBased[0].methods).toContain("POST"); + }); + + it("path-based REQUEST migration method filter excludes non-matching methods", () => { + class OnlyPostMig extends VersionChange { + description = "path-based request migration on POST only"; + instructions = []; + + r1 = convertRequestToNextVersionFor("/orders", ["POST"])( + (_req: RequestInfo) => {}, + ); + } + + const router = new VersionedRouter(); + router.post("/orders", Order, Order, async () => ({ + id: "o1", + amount: 100, + currency: "USD", + })); + + const app = new Tsadwyn({ + versions: new VersionBundle( + new Version("2025-01-01", OnlyPostMig), + new Version("2024-01-01"), + ), + }); + app.generateAndIncludeVersionedRouters(router); + + // Filter by GET — the POST-only path-based migration should NOT appear. + const chain = inspectMigrationChain(app, { + schemaName: "IssueMigChain_Order", + clientVersion: "2024-01-01", + direction: "request", + path: "/orders", + method: "GET", + }); + + const pathBased = chain.filter((e: any) => e.kind === "path-based"); + expect(pathBased.length).toBe(0); + }); + + it("entries include changeClassName, kind, and order for rendering", () => { + class Mig extends VersionChange { + description = "some migration"; + instructions = []; + + migrate = convertResponseToPreviousVersionFor(Order)( + (_res: ResponseInfo) => {}, + ); + } + + const router = new VersionedRouter(); + router.get("/orders/:id", null, Order, async () => ({ + id: "o1", + amount: 100, + currency: "USD", + })); + + const app = new Tsadwyn({ + versions: new VersionBundle( + new Version("2025-01-01", Mig), + new Version("2024-01-01"), + ), + }); + app.generateAndIncludeVersionedRouters(router); + + const chain = inspectMigrationChain(app, { + schemaName: "IssueMigChain_Order", + clientVersion: "2024-01-01", + direction: "response", + }); + + expect(chain[0]).toMatchObject({ + version: "2025-01-01", + changeClassName: "Mig", + kind: "schema-based", + schemaName: "IssueMigChain_Order", + order: 0, + }); + }); +}); diff --git a/tests/issue-no-content-shortcircuit.test.ts b/tests/issue-no-content-shortcircuit.test.ts new file mode 100644 index 0000000..ad9a645 --- /dev/null +++ b/tests/issue-no-content-shortcircuit.test.ts @@ -0,0 +1,492 @@ +/** + * Covers 204 No-Content short-circuit semantics in the dispatch pipeline: + * + * 1. 204 routes returning `undefined`/`null` produce an empty body. + * 2. Body-mutating response migrations are skipped on 204 (no NPE on + * `response.body.something = ...` when body is absent). + * 3. `headerOnly: true` migrations still fire on 204 (only touch headers). + * 4. Generation-time lint warns when a body-mutating migration targets + * a 204 route (dead code at dispatch). + * 5. `TsadwynStructureError` is thrown when a handler returns a + * non-empty body on a 204-declared route. + * + * Run: npx vitest run tests/issue-no-content-shortcircuit.test.ts + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import request from "supertest"; +import { z } from "zod"; + +import { + Tsadwyn, + Version, + VersionBundle, + VersionChange, + VersionedRouter, + ResponseInfo, + convertResponseToPreviousVersionFor, +} from "../src/index.js"; + +const DeleteResult = z + .object({ ok: z.boolean() }) + .named("Issue204_DeleteResult"); + +describe("Issue: 204 No Content — body-migration short-circuit", () => { + let warnSpy: ReturnType; + + beforeEach(() => { + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + it("emits 204 with empty body when handler returns undefined", async () => { + const router = new VersionedRouter(); + router.delete("/users/:id", null, null, async () => undefined, { + statusCode: 204, + }); + + const app = new Tsadwyn({ + versions: new VersionBundle(new Version("2024-01-01")), + }); + app.generateAndIncludeVersionedRouters(router); + + const res = await request(app.expressApp) + .delete("/users/123") + .set("x-api-version", "2024-01-01"); + + expect(res.status).toBe(204); + expect(res.text).toBeFalsy(); + expect(res.body).toEqual({}); + }); + + it("emits 204 with empty body when handler returns null", async () => { + const router = new VersionedRouter(); + router.delete( + "/users/:id", + null, + null, + async () => null as any, + { statusCode: 204 }, + ); + + const app = new Tsadwyn({ + versions: new VersionBundle(new Version("2024-01-01")), + }); + app.generateAndIncludeVersionedRouters(router); + + const res = await request(app.expressApp) + .delete("/users/123") + .set("x-api-version", "2024-01-01"); + + expect(res.status).toBe(204); + expect(res.text).toBeFalsy(); + }); + + it("skips body-mutating schema-based migrations on 204 routes (no NPE)", async () => { + const bodyTransformSpy = vi.fn((res: ResponseInfo) => { + // If this transformer is called with res.body === undefined, it NPEs — + // the test asserts it's not called at all. + (res.body as any).legacyField = "x"; + }); + + class MutateBody extends VersionChange { + description = "add legacyField to DeleteResult for legacy clients"; + instructions = []; + + r1 = convertResponseToPreviousVersionFor(DeleteResult)(bodyTransformSpy); + } + + const router = new VersionedRouter(); + // 204 DELETE sharing the DeleteResult schema with a 200 route + router.delete("/users/:id", null, DeleteResult, async () => undefined, { + statusCode: 204, + }); + + const app = new Tsadwyn({ + versions: new VersionBundle( + new Version("2025-01-01", MutateBody), + new Version("2024-01-01"), + ), + }); + app.generateAndIncludeVersionedRouters(router); + + const res = await request(app.expressApp) + .delete("/users/123") + .set("x-api-version", "2024-01-01"); + + expect(res.status).toBe(204); + // Body transformer MUST NOT have been called on the 204 + expect(bodyTransformSpy).not.toHaveBeenCalled(); + }); + + it("runs headerOnly: true migrations on 204 routes", async () => { + const headerTransformSpy = vi.fn((res: ResponseInfo) => { + res.headers["x-deprecation"] = "upgrade to 2025-01-01"; + }); + + class AddDeprecationHeader extends VersionChange { + description = "add x-deprecation header for legacy clients"; + instructions = []; + + // GAP: `headerOnly: true` option doesn't exist yet. + r1 = convertResponseToPreviousVersionFor(DeleteResult, { + headerOnly: true, + } as any)(headerTransformSpy); + } + + const router = new VersionedRouter(); + router.delete("/users/:id", null, DeleteResult, async () => undefined, { + statusCode: 204, + }); + + const app = new Tsadwyn({ + versions: new VersionBundle( + new Version("2025-01-01", AddDeprecationHeader), + new Version("2024-01-01"), + ), + }); + app.generateAndIncludeVersionedRouters(router); + + const res = await request(app.expressApp) + .delete("/users/123") + .set("x-api-version", "2024-01-01"); + + expect(res.status).toBe(204); + expect(headerTransformSpy).toHaveBeenCalledOnce(); + expect(res.headers["x-deprecation"]).toBe("upgrade to 2025-01-01"); + }); + + it("emits registration-time warning for a body-mutating migration against a 204 route", async () => { + class DeadBodyMigration extends VersionChange { + description = "body migration targeting a 204-only route (dead code)"; + instructions = []; + + r1 = convertResponseToPreviousVersionFor("/users/:id", ["DELETE"])( + (res: ResponseInfo) => { + (res.body as any).x = 1; + }, + ); + } + + const router = new VersionedRouter(); + router.delete("/users/:id", null, null, async () => undefined, { + statusCode: 204, + }); + + const app = new Tsadwyn({ + versions: new VersionBundle( + new Version("2025-01-01", DeadBodyMigration), + new Version("2024-01-01"), + ), + }); + app.generateAndIncludeVersionedRouters(router); + + const warned = warnSpy.mock.calls.some((args) => + args.some( + (a) => + typeof a === "string" && + /204/.test(a) && + /users\/:id/.test(a) && + /body/i.test(a), + ), + ); + expect( + warned, + `Expected a warn naming the 204 route and the dead body migration. Got: ${JSON.stringify(warnSpy.mock.calls)}`, + ).toBe(true); + }); + + it("permits a 204-declared handler to return a body (Stripe-style permissive 204+body)", async () => { + // RFC 9110 §15.3.5 says 204 "cannot contain content" — but Stripe + // does return bodies with 204 on some endpoints, and tsadwyn's default + // is permissive to support that pattern. If the handler returns a body + // with statusCode: 204, it flows through normally (status stays 204, + // body is emitted). Consumers who want strict RFC behavior can return + // undefined/null from the handler instead. + const router = new VersionedRouter(); + router.delete( + "/users/:id", + null, + null, + async () => ({ ok: true }) as any, + { statusCode: 204 }, + ); + + const app = new Tsadwyn({ + versions: new VersionBundle(new Version("2024-01-01")), + }); + app.generateAndIncludeVersionedRouters(router); + + const res = await request(app.expressApp) + .delete("/users/123") + .set("x-api-version", "2024-01-01"); + + expect(res.status).toBe(204); + // Body presence is permitted per the Stripe-style permissive default — + // we don't crash, we don't throw, we don't silently drop. + }); + + it("migrations on 204 routes can add a body + change status (head 204 → legacy 200+body)", async () => { + // Concrete scenario: head returns 204 No Content for DELETE /users/:id, + // but legacy clients (who shipped SDKs expecting a JSON envelope) + // should still get 200 { deleted: true, id }. A headerOnly migration + // opts into running on the body-less response and can BOTH add a body + // AND rewrite the status code. + const DeleteEnvelope = z + .object({ deleted: z.boolean(), id: z.string() }) + .named("Issue204_DeleteEnvelope"); + + class LegacyReturnsEnvelope extends VersionChange { + description = + "legacy clients received 200+envelope for DELETE; head returns 204"; + instructions = []; + + r1 = convertResponseToPreviousVersionFor(DeleteEnvelope, { + headerOnly: true, + } as any)((res: ResponseInfo) => { + // Legacy shape: 200 with an envelope. Status and body both change. + res.statusCode = 200; + res.body = { deleted: true, id: "restored-by-migration" }; + }); + } + + const router = new VersionedRouter(); + router.delete( + "/users/:id", + null, + DeleteEnvelope, + async () => undefined, + { statusCode: 204 }, + ); + + const app = new Tsadwyn({ + versions: new VersionBundle( + new Version("2025-01-01", LegacyReturnsEnvelope), + new Version("2024-01-01"), + ), + }); + app.generateAndIncludeVersionedRouters(router); + + // Legacy client — expects 200 + envelope + const legacyRes = await request(app.expressApp) + .delete("/users/abc") + .set("x-api-version", "2024-01-01"); + + expect(legacyRes.status).toBe(200); + expect(legacyRes.body).toEqual({ + deleted: true, + id: "restored-by-migration", + }); + + // Head client — still gets 204 empty + const headRes = await request(app.expressApp) + .delete("/users/abc") + .set("x-api-version", "2025-01-01"); + + expect(headRes.status).toBe(204); + expect(headRes.text).toBeFalsy(); + }); + + it("schema-based migration runs on 204+body routes but Node strips the body at the wire (document the constraint)", async () => { + // Scenario: API evolved from + // v1: DELETE /users/:id → 204 { deleted: true } + // v2: DELETE /users/:id → 204 { response: { deleted, deleted_at, deleted_by } } + // + // tsadwyn's migration pipeline runs against the handler's body correctly + // — the transformer IS called, and res.body IS reshaped. But per RFC + // 9110 §15.3.5 a 204 response carries no content, and Node's HTTP writer + // enforces that at the wire level: the body bytes are never sent to the + // client even when Express/tsadwyn writes them to the socket. + // + // The test locks in BOTH facts: + // (1) migration was invoked and mutated the in-memory body (proves the + // pipeline works for 204) + // (2) the client receives 204 with an empty body (proves Node strips) + // + // For real production use, consumers who want a per-version BODY shape + // to actually arrive at the client should use statusCode: 200 instead + // of 204. Stripe's DELETE /v1/customers/cus_xxx follows this pattern — + // it returns 200 with { id, object, deleted } rather than 204. + + const DeletedResource = z + .object({ + response: z + .object({ + deleted: z.boolean(), + deleted_at: z.string(), + deleted_by: z.string(), + }) + .optional(), + deleted: z.boolean().optional(), + }) + .named("Issue204_DeletedResource"); + + const transformSpy = vi.fn((res: ResponseInfo) => { + if (res.body && typeof res.body === "object" && res.body.response) { + const inner = res.body.response as { deleted: boolean }; + res.body = { deleted: inner.deleted }; + } + }); + + class FlattenDeleteEnvelope extends VersionChange { + description = + "v1 returned a flat { deleted }; v2 wraps in .response with audit metadata"; + instructions = []; + + r1 = convertResponseToPreviousVersionFor(DeletedResource)(transformSpy); + } + + const router = new VersionedRouter(); + router.delete( + "/users/:id", + null, + DeletedResource, + async () => ({ + response: { + deleted: true, + deleted_at: "2026-04-16T12:00:00Z", + deleted_by: "admin:42", + }, + }), + { statusCode: 204 }, + ); + + const app = new Tsadwyn({ + versions: new VersionBundle( + new Version("2025-01-01", FlattenDeleteEnvelope), + new Version("2024-01-01"), + ), + }); + app.generateAndIncludeVersionedRouters(router); + + // Legacy client — migration IS invoked on the body. + const legacyRes = await request(app.expressApp) + .delete("/users/u_123") + .set("x-api-version", "2024-01-01"); + + expect(legacyRes.status).toBe(204); + expect(transformSpy).toHaveBeenCalledOnce(); + // Node strips the body at the wire level for 204 — the client sees empty. + expect(legacyRes.text).toBe(""); + + // Head client — no migration runs, but same wire-level body strip. + transformSpy.mockClear(); + const headRes = await request(app.expressApp) + .delete("/users/u_123") + .set("x-api-version", "2025-01-01"); + + expect(headRes.status).toBe(204); + expect(transformSpy).not.toHaveBeenCalled(); + expect(headRes.text).toBe(""); + }); + + it("the SAME versioning shape with statusCode: 200 delivers the migrated body to the client (recommended pattern)", async () => { + // Same API-evolution scenario as the 204 test above, but at 200 — + // here the bodies actually arrive. This is the Stripe-idiomatic pattern + // for 'resource deleted with envelope' endpoints. + const DeletedResource = z + .object({ + response: z + .object({ + deleted: z.boolean(), + deleted_at: z.string(), + deleted_by: z.string(), + }) + .optional(), + deleted: z.boolean().optional(), + }) + .named("Issue204_DeletedResource_200"); + + class FlattenDeleteEnvelope extends VersionChange { + description = "v1 flat, v2 nested"; + instructions = []; + + r1 = convertResponseToPreviousVersionFor(DeletedResource)( + (res: ResponseInfo) => { + if (res.body && typeof res.body === "object" && res.body.response) { + const inner = res.body.response as { deleted: boolean }; + res.body = { deleted: inner.deleted }; + } + }, + ); + } + + const router = new VersionedRouter(); + router.delete( + "/resources/:id", + null, + DeletedResource, + async () => ({ + response: { + deleted: true, + deleted_at: "2026-04-16T12:00:00Z", + deleted_by: "admin:42", + }, + }), + // statusCode: 200 (default) — bodies arrive on the wire + ); + + const app = new Tsadwyn({ + versions: new VersionBundle( + new Version("2025-01-01", FlattenDeleteEnvelope), + new Version("2024-01-01"), + ), + }); + app.generateAndIncludeVersionedRouters(router); + + const legacyRes = await request(app.expressApp) + .delete("/resources/u_123") + .set("x-api-version", "2024-01-01"); + expect(legacyRes.status).toBe(200); + expect(legacyRes.body).toEqual({ deleted: true }); + + const headRes = await request(app.expressApp) + .delete("/resources/u_123") + .set("x-api-version", "2025-01-01"); + expect(headRes.status).toBe(200); + expect(headRes.body).toEqual({ + response: { + deleted: true, + deleted_at: "2026-04-16T12:00:00Z", + deleted_by: "admin:42", + }, + }); + }); + + it("still respects migrateHttpErrors on a 204 route that throws HttpError", async () => { + // A 204 route's success path has no body, but error paths have JSON bodies + // and migrateHttpErrors still applies — short-circuit is only for successes. + const router = new VersionedRouter(); + router.delete( + "/users/:id", + null, + null, + async (req: any) => { + if (req.params.id === "missing") { + const { HttpError } = await import("../src/index.js"); + throw new HttpError(404, { code: "not_found" }); + } + return undefined; + }, + { statusCode: 204 }, + ); + + const app = new Tsadwyn({ + versions: new VersionBundle(new Version("2024-01-01")), + }); + app.generateAndIncludeVersionedRouters(router); + + const ok = await request(app.expressApp) + .delete("/users/123") + .set("x-api-version", "2024-01-01"); + expect(ok.status).toBe(204); + + const notFound = await request(app.expressApp) + .delete("/users/missing") + .set("x-api-version", "2024-01-01"); + expect(notFound.status).toBe(404); + expect(notFound.body.code).toBe("not_found"); + }); +}); diff --git a/tests/issue-on-unsupported-version.test.ts b/tests/issue-on-unsupported-version.test.ts new file mode 100644 index 0000000..4fede97 --- /dev/null +++ b/tests/issue-on-unsupported-version.test.ts @@ -0,0 +1,127 @@ +/** + * Covers `onUnsupportedVersion: 'reject' | 'fallback' | 'passthrough'` on + * `versionPickingMiddleware`. Controls how an unknown `X-Api-Version` + * header is handled: + * - `'reject'` — 400 with `{error, sent, supported}` (Stripe-style). + * - `'fallback'` — silently substitute `apiVersionDefaultValue` + warn. + * - `'passthrough'` (default) — store the verbatim string and let the + * downstream dispatcher decide. Preserves historical behavior. + * + * Run: npx vitest run tests/issue-on-unsupported-version.test.ts + */ +import { describe, it, expect, vi } from "vitest"; +import express from "express"; +import request from "supertest"; + +import { versionPickingMiddleware, apiVersionStorage } from "../src/index.js"; + +describe("Issue: onUnsupportedVersion policy on versionPickingMiddleware", () => { + it("'reject' mode returns 400 with structured body listing supported versions", async () => { + const app = express(); + app.use(express.json()); + app.use( + versionPickingMiddleware({ + headerName: "x-api-version", + apiVersionLocation: "custom_header", + apiVersionDefaultValue: "2024-01-01", + versionValues: ["2025-01-01", "2024-01-01"], + // GAP: option not recognized today — middleware lets the request through. + onUnsupportedVersion: "reject", + } as any), + ); + app.get("/anything", (_req, res) => { + res.json({ ok: true }); + }); + + const res = await request(app) + .get("/anything") + .set("x-api-version", "2026-04-15"); // off-by-one typo + + expect(res.status).toBe(400); + expect(res.body).toEqual({ + error: "unsupported_api_version", + sent: "2026-04-15", + supported: ["2025-01-01", "2024-01-01"], + }); + }); + + it("'fallback' mode substitutes apiVersionDefaultValue and emits a warning", async () => { + const warn = vi.fn(); + const app = express(); + app.use(express.json()); + app.use( + versionPickingMiddleware({ + headerName: "x-api-version", + apiVersionLocation: "custom_header", + apiVersionDefaultValue: "2024-01-01", + versionValues: ["2025-01-01", "2024-01-01"], + onUnsupportedVersion: "fallback", + logger: { warn }, + } as any), + ); + app.get("/anything", (_req, res) => { + const stored = apiVersionStorage.getStore(); + res.json({ stored }); + }); + + const res = await request(app) + .get("/anything") + .set("x-api-version", "2026-04-15"); + + expect(res.status).toBe(200); + expect(res.body.stored).toBe("2024-01-01"); + expect(warn).toHaveBeenCalled(); + const args = warn.mock.calls[0]; + // First argument should be a structured context with the bad version. + const ctx = args[0]; + expect(ctx).toMatchObject({ sent: "2026-04-15" }); + }); + + it("'passthrough' mode (current default behavior) stores the verbatim string", async () => { + const app = express(); + app.use(express.json()); + app.use( + versionPickingMiddleware({ + headerName: "x-api-version", + apiVersionLocation: "custom_header", + apiVersionDefaultValue: null, + versionValues: ["2025-01-01", "2024-01-01"], + onUnsupportedVersion: "passthrough", + } as any), + ); + app.get("/anything", (_req, res) => { + res.json({ stored: apiVersionStorage.getStore() }); + }); + + const res = await request(app) + .get("/anything") + .set("x-api-version", "2026-04-15"); + + expect(res.status).toBe(200); + expect(res.body.stored).toBe("2026-04-15"); + }); + + it("default behavior (no option) is 'passthrough' for backwards compatibility", async () => { + const app = express(); + app.use(express.json()); + app.use( + versionPickingMiddleware({ + headerName: "x-api-version", + apiVersionLocation: "custom_header", + apiVersionDefaultValue: null, + versionValues: ["2025-01-01", "2024-01-01"], + // no onUnsupportedVersion — must behave as today + }), + ); + app.get("/anything", (_req, res) => { + res.json({ stored: apiVersionStorage.getStore() }); + }); + + const res = await request(app) + .get("/anything") + .set("x-api-version", "2026-04-15"); + + expect(res.status).toBe(200); + expect(res.body.stored).toBe("2026-04-15"); + }); +}); diff --git a/tests/issue-per-client-default-version.test.ts b/tests/issue-per-client-default-version.test.ts new file mode 100644 index 0000000..d765765 --- /dev/null +++ b/tests/issue-per-client-default-version.test.ts @@ -0,0 +1,434 @@ +/** + * Covers `perClientDefaultVersion` — the canonical DB-backed default- + * version resolver. Standardizes the identify → resolvePin → fallback + * chain every Stripe-style adopter rolls by hand (and usually forgets + * the per-request dedupe + stale-pin policy). + * + * Run: npx vitest run tests/issue-per-client-default-version.test.ts + */ +import { describe, it, expect, vi } from "vitest"; +import request from "supertest"; + +import { + Tsadwyn, + Version, + VersionBundle, + VersionedRouter, + apiVersionStorage, + perClientDefaultVersion, +} from "../src/index.js"; + +describe("Issue: perClientDefaultVersion helper", () => { + it("identifies client, resolves pin, uses it as default version", async () => { + const router = new VersionedRouter(); + router.get("/whoami", null, null, async () => ({ + version: apiVersionStorage.getStore(), + })); + + const app = new Tsadwyn({ + versions: new VersionBundle( + new Version("2025-01-01"), + new Version("2024-01-01"), + ), + apiVersionDefaultValue: perClientDefaultVersion({ + identify: () => "client-a", + resolvePin: (id: string) => (id === "client-a" ? "2024-01-01" : null), + fallback: "2025-01-01", + supportedVersions: ["2025-01-01", "2024-01-01"], + }), + } as any); + app.generateAndIncludeVersionedRouters(router); + + const res = await request(app.expressApp).get("/whoami"); + expect(res.status).toBe(200); + expect(res.body.version).toBe("2024-01-01"); + }); + + it("falls back when identify returns null", async () => { + const router = new VersionedRouter(); + router.get("/whoami", null, null, async () => ({ + version: apiVersionStorage.getStore(), + })); + + const app = new Tsadwyn({ + versions: new VersionBundle( + new Version("2025-01-01"), + new Version("2024-01-01"), + ), + apiVersionDefaultValue: perClientDefaultVersion({ + identify: () => null, // no identity + resolvePin: () => "2024-01-01", + fallback: "2025-01-01", + }), + } as any); + app.generateAndIncludeVersionedRouters(router); + + const res = await request(app.expressApp).get("/whoami"); + expect(res.body.version).toBe("2025-01-01"); + }); + + it("falls back when resolvePin returns null", async () => { + const router = new VersionedRouter(); + router.get("/whoami", null, null, async () => ({ + version: apiVersionStorage.getStore(), + })); + + const app = new Tsadwyn({ + versions: new VersionBundle( + new Version("2025-01-01"), + new Version("2024-01-01"), + ), + apiVersionDefaultValue: perClientDefaultVersion({ + identify: () => "client-a", + resolvePin: () => null, // no stored pin + fallback: "2025-01-01", + }), + } as any); + app.generateAndIncludeVersionedRouters(router); + + const res = await request(app.expressApp).get("/whoami"); + expect(res.body.version).toBe("2025-01-01"); + }); + + it("explicit X-Api-Version overrides the resolver", async () => { + const router = new VersionedRouter(); + router.get("/whoami", null, null, async () => ({ + version: apiVersionStorage.getStore(), + })); + + const identifySpy = vi.fn(() => "client-a"); + const resolvePinSpy = vi.fn(() => "2024-01-01"); + + const app = new Tsadwyn({ + versions: new VersionBundle( + new Version("2025-01-01"), + new Version("2024-01-01"), + ), + apiVersionDefaultValue: perClientDefaultVersion({ + identify: identifySpy, + resolvePin: resolvePinSpy, + fallback: "2025-01-01", + }), + } as any); + app.generateAndIncludeVersionedRouters(router); + + const res = await request(app.expressApp) + .get("/whoami") + .set("x-api-version", "2025-01-01"); + + expect(res.body.version).toBe("2025-01-01"); + // Resolver should not be invoked at all when explicit header present + expect(identifySpy).not.toHaveBeenCalled(); + expect(resolvePinSpy).not.toHaveBeenCalled(); + }); + + it("caches per-request — identify and resolvePin called at most once per request", async () => { + const router = new VersionedRouter(); + router.get("/whoami", null, null, async () => ({ + version: apiVersionStorage.getStore(), + })); + + const identifySpy = vi.fn(() => "client-a"); + const resolvePinSpy = vi.fn(() => "2024-01-01"); + + const app = new Tsadwyn({ + versions: new VersionBundle( + new Version("2025-01-01"), + new Version("2024-01-01"), + ), + apiVersionDefaultValue: perClientDefaultVersion({ + identify: identifySpy, + resolvePin: resolvePinSpy, + fallback: "2025-01-01", + cache: "per-request", + }), + } as any); + app.generateAndIncludeVersionedRouters(router); + + // Two distinct requests + await request(app.expressApp).get("/whoami"); + await request(app.expressApp).get("/whoami"); + + // Each request ran identify and resolvePin exactly once (2 requests × 1 call) + expect(identifySpy).toHaveBeenCalledTimes(2); + expect(resolvePinSpy).toHaveBeenCalledTimes(2); + }); + + it("onStalePin: 'fallback' substitutes fallback + emits warn when stored pin is not in bundle", async () => { + const router = new VersionedRouter(); + router.get("/whoami", null, null, async () => ({ + version: apiVersionStorage.getStore(), + })); + + const warn = vi.fn(); + + const app = new Tsadwyn({ + versions: new VersionBundle( + new Version("2025-01-01"), + new Version("2024-01-01"), + ), + apiVersionDefaultValue: perClientDefaultVersion({ + identify: () => "client-a", + resolvePin: () => "2023-01-01", // no longer in bundle + fallback: "2025-01-01", + supportedVersions: ["2025-01-01", "2024-01-01"], + onStalePin: "fallback", + logger: { warn }, + }), + } as any); + app.generateAndIncludeVersionedRouters(router); + + const res = await request(app.expressApp).get("/whoami"); + expect(res.body.version).toBe("2025-01-01"); + expect(warn).toHaveBeenCalled(); + const ctx = warn.mock.calls[0][0]; + expect(ctx).toMatchObject({ + pin: "2023-01-01", + reason: expect.stringMatching(/stale/i), + }); + }); + + it("onStalePin: 'reject' throws at resolver time (surfaces as 500)", async () => { + const router = new VersionedRouter(); + router.get("/whoami", null, null, async () => ({ ok: true })); + + const app = new Tsadwyn({ + versions: new VersionBundle( + new Version("2025-01-01"), + new Version("2024-01-01"), + ), + apiVersionDefaultValue: perClientDefaultVersion({ + identify: () => "client-a", + resolvePin: () => "2023-01-01", + fallback: "2025-01-01", + supportedVersions: ["2025-01-01", "2024-01-01"], + onStalePin: "reject", + }), + } as any); + app.generateAndIncludeVersionedRouters(router); + + const res = await request(app.expressApp).get("/whoami"); + expect(res.status).toBe(500); + }); + + it("async identify + resolvePin awaited correctly", async () => { + const router = new VersionedRouter(); + router.get("/whoami", null, null, async () => ({ + version: apiVersionStorage.getStore(), + })); + + const app = new Tsadwyn({ + versions: new VersionBundle( + new Version("2025-01-01"), + new Version("2024-01-01"), + ), + apiVersionDefaultValue: perClientDefaultVersion({ + identify: async () => { + await new Promise((r) => setTimeout(r, 5)); + return "client-a"; + }, + resolvePin: async () => { + await new Promise((r) => setTimeout(r, 5)); + return "2024-01-01"; + }, + fallback: "2025-01-01", + }), + } as any); + app.generateAndIncludeVersionedRouters(router); + + const res = await request(app.expressApp).get("/whoami"); + expect(res.body.version).toBe("2024-01-01"); + }); + + describe("pinOnFirstResolve — Stripe-style auto-pin on first authenticated call", () => { + it("writes fallback as the stored pin the FIRST time an authenticated client has no pin", async () => { + const store: Record = {}; + const saveSpy = vi.fn((id: string, v: string) => { + store[id] = v; + }); + + const router = new VersionedRouter(); + router.get("/whoami", null, null, async () => ({ + version: apiVersionStorage.getStore(), + })); + + const app = new Tsadwyn({ + versions: new VersionBundle( + new Version("2025-06-01"), + new Version("2025-01-01"), + new Version("2024-01-01"), + ), + apiVersionDefaultValue: perClientDefaultVersion({ + identify: (req: any) => req.headers["x-account-id"] ?? null, + resolvePin: (id: string) => store[id] ?? null, + saveVersion: saveSpy, + fallback: "2025-06-01", // latest — Stripe convention for new accounts + pinOnFirstResolve: true, + supportedVersions: ["2025-06-01", "2025-01-01", "2024-01-01"], + }), + } as any); + app.generateAndIncludeVersionedRouters(router); + + // First call — no stored pin → fallback is both returned AND persisted. + const first = await request(app.expressApp) + .get("/whoami") + .set("x-account-id", "acct_new"); + expect(first.body.version).toBe("2025-06-01"); + expect(saveSpy).toHaveBeenCalledOnce(); + expect(saveSpy).toHaveBeenCalledWith("acct_new", "2025-06-01"); + expect(store["acct_new"]).toBe("2025-06-01"); + + // Second call — stored pin now exists, no extra saveVersion call. + saveSpy.mockClear(); + const second = await request(app.expressApp) + .get("/whoami") + .set("x-account-id", "acct_new"); + expect(second.body.version).toBe("2025-06-01"); + expect(saveSpy).not.toHaveBeenCalled(); + }); + + it("does NOT auto-pin when identify returns null (unauthenticated)", async () => { + const saveSpy = vi.fn(); + + const router = new VersionedRouter(); + router.get("/whoami", null, null, async () => ({ ok: true })); + + const app = new Tsadwyn({ + versions: new VersionBundle( + new Version("2025-06-01"), + new Version("2024-01-01"), + ), + apiVersionDefaultValue: perClientDefaultVersion({ + identify: () => null, + resolvePin: () => null, + saveVersion: saveSpy, + fallback: "2025-06-01", + pinOnFirstResolve: true, + }), + } as any); + app.generateAndIncludeVersionedRouters(router); + + await request(app.expressApp).get("/whoami"); + + expect(saveSpy).not.toHaveBeenCalled(); + }); + + it("does NOT overwrite an existing stored pin (even if out-of-bundle)", async () => { + const store: Record = { acct_old: "2020-01-01" }; // stale + const saveSpy = vi.fn((id: string, v: string) => { + store[id] = v; + }); + + const router = new VersionedRouter(); + router.get("/whoami", null, null, async () => ({ + version: apiVersionStorage.getStore(), + })); + + const app = new Tsadwyn({ + versions: new VersionBundle( + new Version("2025-06-01"), + new Version("2024-01-01"), + ), + apiVersionDefaultValue: perClientDefaultVersion({ + identify: (req: any) => req.headers["x-account-id"] ?? null, + resolvePin: (id: string) => store[id] ?? null, + saveVersion: saveSpy, + fallback: "2025-06-01", + pinOnFirstResolve: true, + onStalePin: "fallback", + supportedVersions: ["2025-06-01", "2024-01-01"], + }), + } as any); + app.generateAndIncludeVersionedRouters(router); + + await request(app.expressApp) + .get("/whoami") + .set("x-account-id", "acct_old"); + + // Stale-pin handling returned fallback, but we did NOT auto-overwrite. + // pinOnFirstResolve is specifically for 'no pin stored yet'. + expect(saveSpy).not.toHaveBeenCalled(); + expect(store["acct_old"]).toBe("2020-01-01"); + }); + + it("pinOnFirstResolve: false (default) means saveVersion is never called", async () => { + const saveSpy = vi.fn(); + const router = new VersionedRouter(); + router.get("/whoami", null, null, async () => ({ ok: true })); + + const app = new Tsadwyn({ + versions: new VersionBundle( + new Version("2025-06-01"), + new Version("2024-01-01"), + ), + apiVersionDefaultValue: perClientDefaultVersion({ + identify: () => "acct_x", + resolvePin: () => null, + saveVersion: saveSpy, + fallback: "2025-06-01", + // pinOnFirstResolve omitted → default false + }), + } as any); + app.generateAndIncludeVersionedRouters(router); + + await request(app.expressApp).get("/whoami"); + expect(saveSpy).not.toHaveBeenCalled(); + }); + + it("throws TsadwynStructureError at construction when pinOnFirstResolve: true + saveVersion missing", () => { + expect(() => + perClientDefaultVersion({ + identify: () => "acct_x", + resolvePin: () => null, + fallback: "2025-06-01", + pinOnFirstResolve: true, + // saveVersion missing — invalid + } as any), + ).toThrow(); + }); + + it("saveVersion errors surface as 500 (don't silently lose the request)", async () => { + const router = new VersionedRouter(); + router.get("/whoami", null, null, async () => ({ ok: true })); + + const app = new Tsadwyn({ + versions: new VersionBundle(new Version("2024-01-01")), + apiVersionDefaultValue: perClientDefaultVersion({ + identify: () => "acct_x", + resolvePin: () => null, + saveVersion: () => { + throw new Error("db unavailable"); + }, + fallback: "2024-01-01", + pinOnFirstResolve: true, + }), + } as any); + app.generateAndIncludeVersionedRouters(router); + + const res = await request(app.expressApp).get("/whoami"); + expect(res.status).toBe(500); + }); + }); + + it("propagates errors from identify/resolvePin as 500 with a specific error code", async () => { + const router = new VersionedRouter(); + router.get("/whoami", null, null, async () => ({ ok: true })); + + const app = new Tsadwyn({ + versions: new VersionBundle( + new Version("2024-01-01"), + ), + apiVersionDefaultValue: perClientDefaultVersion({ + identify: () => { + throw new Error("jwt verification failed"); + }, + resolvePin: () => "2024-01-01", + fallback: "2024-01-01", + }), + } as any); + app.generateAndIncludeVersionedRouters(router); + + const res = await request(app.expressApp).get("/whoami"); + expect(res.status).toBe(500); + }); +}); diff --git a/tests/issue-pre-version-pick-hook.test.ts b/tests/issue-pre-version-pick-hook.test.ts new file mode 100644 index 0000000..c3c8d3f --- /dev/null +++ b/tests/issue-pre-version-pick-hook.test.ts @@ -0,0 +1,198 @@ +/** + * Covers `TsadwynOptions.preVersionPick` — a hook for consumer middleware + * (typically auth) that must run BEFORE `versionPickingMiddleware`, so the + * `apiVersionDefaultValue` resolver sees the enriched request. Without + * this hook, the only escape was a full `versioningMiddleware` override, + * which forced consumers to re-implement header extraction, default + * resolution, and `apiVersionStorage` scoping. + * + * Invariants under test: + * 1. `preVersionPick` runs before `versionPickingMiddleware`. + * 2. `req.user` set in the hook is visible inside `apiVersionDefaultValue`. + * 3. Errors from the hook propagate via `next(err)`. + * 4. Async hooks are awaited. + * 5. Combining `preVersionPick` + `versioningMiddleware` throws + * `TsadwynStructureError` at construction. + * 6. `apiVersionStorage` is empty inside the hook (version not yet picked). + * + * Run: npx vitest run tests/issue-pre-version-pick-hook.test.ts + */ +import { describe, it, expect, vi } from "vitest"; +import request from "supertest"; + +import { + Tsadwyn, + Version, + VersionBundle, + VersionedRouter, + TsadwynStructureError, + apiVersionStorage, +} from "../src/index.js"; + +describe("Issue: preVersionPick middleware hook", () => { + it("runs before versionPickingMiddleware — default resolver sees req.user", async () => { + const resolvedVersions: string[] = []; + + const router = new VersionedRouter(); + router.get("/whoami", null, null, async () => ({ ok: true })); + + const app = new Tsadwyn({ + versions: new VersionBundle( + new Version("2025-01-01"), + new Version("2024-01-01"), + ), + // GAP: preVersionPick doesn't exist + preVersionPick: (req: any, _res: any, next: any) => { + req.user = { apiVersion: "2024-01-01" }; + next(); + }, + apiVersionDefaultValue: (req: any) => { + const v = req.user?.apiVersion ?? "2025-01-01"; + resolvedVersions.push(v); + return v; + }, + } as any); + app.generateAndIncludeVersionedRouters(router); + + await request(app.expressApp).get("/whoami"); + + // Resolver saw the user-supplied version + expect(resolvedVersions).toEqual(["2024-01-01"]); + }); + + it("propagates errors via next(err) without running versioned dispatch", async () => { + const dispatchSpy = vi.fn(); + + const router = new VersionedRouter(); + router.get("/whoami", null, null, async () => { + dispatchSpy(); + return { ok: true }; + }); + + const app = new Tsadwyn({ + versions: new VersionBundle(new Version("2024-01-01")), + preVersionPick: (_req: any, _res: any, next: any) => { + next(new Error("auth failed")); + }, + } as any); + app.generateAndIncludeVersionedRouters(router); + + const res = await request(app.expressApp) + .get("/whoami") + .set("x-api-version", "2024-01-01"); + + // Express default error handler returns 500 for unhandled errors + expect(res.status).toBe(500); + expect(dispatchSpy).not.toHaveBeenCalled(); + }); + + it("supports async preVersionPick (Promise-then-next pattern)", async () => { + const router = new VersionedRouter(); + router.get("/whoami", null, null, async () => ({ ok: true })); + + const app = new Tsadwyn({ + versions: new VersionBundle(new Version("2024-01-01")), + preVersionPick: (req: any, _res: any, next: any) => { + Promise.resolve() + .then(() => { + req.user = { apiVersion: "2024-01-01" }; + }) + .then(next); + }, + apiVersionDefaultValue: (req: any) => req.user?.apiVersion ?? null, + } as any); + app.generateAndIncludeVersionedRouters(router); + + const res = await request(app.expressApp).get("/whoami"); + expect(res.status).toBe(200); + }); + + it("apiVersionStorage.getStore() returns undefined inside preVersionPick", async () => { + let storageInsideHook: string | null | undefined; + + const router = new VersionedRouter(); + router.get("/whoami", null, null, async () => ({ ok: true })); + + const app = new Tsadwyn({ + versions: new VersionBundle(new Version("2024-01-01")), + preVersionPick: (_req: any, _res: any, next: any) => { + storageInsideHook = apiVersionStorage.getStore(); + next(); + }, + } as any); + app.generateAndIncludeVersionedRouters(router); + + await request(app.expressApp) + .get("/whoami") + .set("x-api-version", "2024-01-01"); + + // Version is NOT yet in storage during preVersionPick + expect(storageInsideHook).toBeUndefined(); + }); + + it("throws TsadwynStructureError when preVersionPick and versioningMiddleware are both set", () => { + expect(() => { + new Tsadwyn({ + versions: new VersionBundle(new Version("2024-01-01")), + preVersionPick: (_req: any, _res: any, next: any) => next(), + versioningMiddleware: (_req: any, _res: any, next: any) => next(), + } as any); + }).toThrow(TsadwynStructureError); + }); + + it("composes correctly with VersionedRouter.use() middleware (both run, in order)", async () => { + const callOrder: string[] = []; + + const router = new VersionedRouter(); + router.use((_req: any, _res: any, next: any) => { + callOrder.push("per-version-middleware"); + next(); + }); + router.get("/whoami", null, null, async () => { + callOrder.push("handler"); + return { ok: true }; + }); + + const app = new Tsadwyn({ + versions: new VersionBundle(new Version("2024-01-01")), + preVersionPick: (_req: any, _res: any, next: any) => { + callOrder.push("pre-version-pick"); + next(); + }, + } as any); + app.generateAndIncludeVersionedRouters(router); + + await request(app.expressApp) + .get("/whoami") + .set("x-api-version", "2024-01-01"); + + expect(callOrder).toEqual([ + "pre-version-pick", + "per-version-middleware", + "handler", + ]); + }); + + it("runs only for requests that reach the versioned dispatch, not utility endpoints", async () => { + const hookSpy = vi.fn((_req: any, _res: any, next: any) => next()); + + const router = new VersionedRouter(); + router.get("/whoami", null, null, async () => ({ ok: true })); + + const app = new Tsadwyn({ + versions: new VersionBundle(new Version("2024-01-01")), + preVersionPick: hookSpy, + } as any); + app.generateAndIncludeVersionedRouters(router); + + // Utility endpoints — should NOT invoke preVersionPick + await request(app.expressApp).get("/openapi.json?version=2024-01-01"); + expect(hookSpy).not.toHaveBeenCalled(); + + // Versioned route — should invoke + await request(app.expressApp) + .get("/whoami") + .set("x-api-version", "2024-01-01"); + expect(hookSpy).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/issue-raw-response.test.ts b/tests/issue-raw-response.test.ts new file mode 100644 index 0000000..b473acd --- /dev/null +++ b/tests/issue-raw-response.test.ts @@ -0,0 +1,146 @@ +/** + * Covers the `raw()` binary / streaming response marker. + * + * Handlers that return Buffer or Readable work without it, but + * `responseSchema: null` doesn't communicate the actual contract (there + * IS a schema — it's just not JSON). `raw()` makes the declaration + * explicit: + * - The mime type is set automatically from the marker. + * - Response migrations targeting the route are flagged as dead code + * at generation time (body is opaque bytes). + * - OpenAPI output can describe the binary response shape. + * + * Run: npx vitest run tests/issue-raw-response.test.ts + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import request from "supertest"; + +import { + Tsadwyn, + Version, + VersionBundle, + VersionChange, + VersionedRouter, + ResponseInfo, + convertResponseToPreviousVersionFor, + raw, +} from "../src/index.js"; + +describe("Issue: raw() binary/streaming response marker", () => { + let warnSpy: ReturnType; + + beforeEach(() => { + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + it("raw() returns a marker with mimeType", () => { + const marker = raw({ mimeType: "application/pdf" }); + expect(marker).toBeDefined(); + expect(marker.mimeType).toBe("application/pdf"); + }); + + it("delivers a Buffer response with the declared mime type", async () => { + const router = new VersionedRouter(); + router.get( + "/reports/:id/export.pdf", + null, + raw({ mimeType: "application/pdf" }), + async () => Buffer.from("%PDF-1.4 fake pdf content"), + ); + + const app = new Tsadwyn({ + versions: new VersionBundle(new Version("2024-01-01")), + }); + app.generateAndIncludeVersionedRouters(router); + + const res = await request(app.expressApp) + .get("/reports/123/export.pdf") + .set("x-api-version", "2024-01-01"); + + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toMatch(/application\/pdf/); + expect(res.body.toString()).toContain("%PDF-1.4"); + }); + + it("emits a warn at generation time when a response migration targets a raw() route", () => { + class DeadMigration extends VersionChange { + description = + "response migration on a raw() route — transformer won't fire"; + instructions = []; + + r1 = convertResponseToPreviousVersionFor("/reports/:id/export.pdf", [ + "GET", + ])((_res: ResponseInfo) => { + // Body is a Buffer — this transformer is dead code. + }); + } + + const router = new VersionedRouter(); + router.get( + "/reports/:id/export.pdf", + null, + raw({ mimeType: "application/pdf" }), + async () => Buffer.from("x"), + ); + + const app = new Tsadwyn({ + versions: new VersionBundle( + new Version("2025-01-01", DeadMigration), + new Version("2024-01-01"), + ), + }); + app.generateAndIncludeVersionedRouters(router); + + const warned = warnSpy.mock.calls.some((args) => + args.some( + (a) => + typeof a === "string" && + /export\.pdf/.test(a) && + /(raw|binary|opaque)/i.test(a), + ), + ); + expect( + warned, + `Expected a warn that a response migration targets a raw() route. ` + + `Got: ${JSON.stringify(warnSpy.mock.calls)}`, + ).toBe(true); + }); + + it("error responses from a raw() route still produce JSON via the error pipeline", async () => { + const { HttpError } = await import("../src/index.js"); + const router = new VersionedRouter(); + router.get( + "/reports/:id/export.pdf", + null, + raw({ mimeType: "application/pdf" }), + async (req: any) => { + if (req.params.id === "missing") { + throw new HttpError(404, { code: "report_not_found" }); + } + return Buffer.from("x"); + }, + ); + + const app = new Tsadwyn({ + versions: new VersionBundle(new Version("2024-01-01")), + }); + app.generateAndIncludeVersionedRouters(router); + + const ok = await request(app.expressApp) + .get("/reports/123/export.pdf") + .set("x-api-version", "2024-01-01"); + expect(ok.status).toBe(200); + expect(ok.headers["content-type"]).toMatch(/application\/pdf/); + + const notFound = await request(app.expressApp) + .get("/reports/missing/export.pdf") + .set("x-api-version", "2024-01-01"); + expect(notFound.status).toBe(404); + expect(notFound.headers["content-type"]).toMatch(/application\/json/); + expect(notFound.body.code).toBe("report_not_found"); + }); +}); diff --git a/tests/issue-route-options-tags.test.ts b/tests/issue-route-options-tags.test.ts new file mode 100644 index 0000000..29e6fd4 --- /dev/null +++ b/tests/issue-route-options-tags.test.ts @@ -0,0 +1,208 @@ +/** + * Covers `RouteOptions.tags` — the registration-time tag list that flows + * into `RouteDefinition.tags` and then into OpenAPI `operation.tags`. + * Exercises the full lifecycle: + * 1. Tags accepted at registration, propagated to RouteDefinition. + * 2. OpenAPI output emits them as operation.tags. + * 3. `endpoint().had({ tags })` replaces the registration-time list + * per-version. + * 4. `_TSADWYN`-prefixed tags warn (reserved namespace). + * 5. Duplicate tags dedupe at emission. + * + * Run: npx vitest run tests/issue-route-options-tags.test.ts + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { z } from "zod"; + +import { + Tsadwyn, + Version, + VersionBundle, + VersionChange, + VersionedRouter, + endpoint, +} from "../src/index.js"; + +const ChargeRes = z + .object({ id: z.string(), amount: z.number() }) + .named("IssueTags_ChargeRes"); + +describe("Issue: tags in RouteOptions", () => { + let warnSpy: ReturnType; + + beforeEach(() => { + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + it("flows RouteOptions.tags into RouteDefinition.tags at registration", () => { + const router = new VersionedRouter(); + + router.post( + "/billing/charge", + null, + ChargeRes, + async () => ({ id: "c1", amount: 100 }), + // GAP: `tags` is not a recognized option today + { tags: ["Billing"] } as any, + ); + + const route = router.routes.find((r) => r.path === "/billing/charge"); + expect(route).toBeDefined(); + expect(route!.tags).toContain("Billing"); + }); + + it("OpenAPI operation.tags reflects the registration-time tags", () => { + const router = new VersionedRouter(); + router.post( + "/billing/charge", + null, + ChargeRes, + async () => ({ id: "c1", amount: 100 }), + { tags: ["Billing"] } as any, + ); + + const app = new Tsadwyn({ + versions: new VersionBundle(new Version("2024-01-01")), + }); + app.generateAndIncludeVersionedRouters(router); + + const doc = app.openapi("2024-01-01"); + const op = (doc.paths["/billing/charge"] as any)?.post; + expect(op).toBeDefined(); + expect(op.tags).toEqual(["Billing"]); + }); + + it("groups multiple routes with the same tag in OpenAPI", () => { + const router = new VersionedRouter(); + const billingOpts = { tags: ["Billing"] } as any; + + router.post("/billing/charge", null, ChargeRes, async () => ({ id: "1", amount: 100 }), billingOpts); + router.post("/billing/refund", null, ChargeRes, async () => ({ id: "2", amount: 50 }), billingOpts); + router.post("/billing/capture", null, ChargeRes, async () => ({ id: "3", amount: 100 }), billingOpts); + + const app = new Tsadwyn({ + versions: new VersionBundle(new Version("2024-01-01")), + }); + app.generateAndIncludeVersionedRouters(router); + + const doc = app.openapi("2024-01-01"); + const paths = ["/billing/charge", "/billing/refund", "/billing/capture"]; + for (const p of paths) { + const op = (doc.paths[p] as any)?.post; + expect(op?.tags, `path ${p} missing or has no tags`).toEqual(["Billing"]); + } + }); + + it("endpoint().had({tags}) replaces the registration-time tag list for older versions", () => { + class LegacyTagging extends VersionChange { + description = + "legacy clients see the route tagged 'Billing' and 'Deprecated'"; + instructions = [ + endpoint("/billing/charge", ["POST"]).had({ + tags: ["Billing", "Deprecated"], + }), + ]; + } + + const router = new VersionedRouter(); + router.post( + "/billing/charge", + null, + ChargeRes, + async () => ({ id: "c1", amount: 100 }), + { tags: ["Billing"] } as any, + ); + + const app = new Tsadwyn({ + versions: new VersionBundle( + new Version("2025-01-01", LegacyTagging), + new Version("2024-01-01"), + ), + }); + app.generateAndIncludeVersionedRouters(router); + + const headDoc = app.openapi("2025-01-01"); + const legacyDoc = app.openapi("2024-01-01"); + + const headOp = (headDoc.paths["/billing/charge"] as any)?.post; + const legacyOp = (legacyDoc.paths["/billing/charge"] as any)?.post; + + expect(headOp.tags).toEqual(["Billing"]); + // Legacy clients see the replacement list + expect(legacyOp.tags).toEqual(["Billing", "Deprecated"]); + }); + + it("emits a registration-time warn when a user-supplied tag starts with _TSADWYN", () => { + const router = new VersionedRouter(); + router.post( + "/billing/charge", + null, + ChargeRes, + async () => ({ id: "c1", amount: 100 }), + { tags: ["_TSADWYN_USER_MARKER"] } as any, + ); + + const app = new Tsadwyn({ + versions: new VersionBundle(new Version("2024-01-01")), + }); + app.generateAndIncludeVersionedRouters(router); + + const warned = warnSpy.mock.calls.some((args) => + args.some( + (a) => + typeof a === "string" && + /_TSADWYN/.test(a) && + /reserved/i.test(a), + ), + ); + expect( + warned, + `Expected a warn about the reserved _TSADWYN prefix. Got: ${JSON.stringify(warnSpy.mock.calls)}`, + ).toBe(true); + }); + + it("deduplicates tags at OpenAPI emission", () => { + const router = new VersionedRouter(); + router.post( + "/billing/charge", + null, + ChargeRes, + async () => ({ id: "c1", amount: 100 }), + { tags: ["Billing", "Billing", "Commerce", "Commerce"] } as any, + ); + + const app = new Tsadwyn({ + versions: new VersionBundle(new Version("2024-01-01")), + }); + app.generateAndIncludeVersionedRouters(router); + + const doc = app.openapi("2024-01-01"); + const op = (doc.paths["/billing/charge"] as any)?.post; + expect(op?.tags).toEqual(["Billing", "Commerce"]); + }); + + it("no warn when tags field is omitted entirely", () => { + const router = new VersionedRouter(); + router.post( + "/billing/charge", + null, + ChargeRes, + async () => ({ id: "c1", amount: 100 }), + // no options at all + ); + + const app = new Tsadwyn({ + versions: new VersionBundle(new Version("2024-01-01")), + }); + app.generateAndIncludeVersionedRouters(router); + + const warned = warnSpy.mock.calls.some((args) => + args.some((a) => typeof a === "string" && /_TSADWYN/.test(a)), + ); + expect(warned).toBe(false); + }); +}); diff --git a/tests/issue-route-simulation.test.ts b/tests/issue-route-simulation.test.ts new file mode 100644 index 0000000..2dd82b1 --- /dev/null +++ b/tests/issue-route-simulation.test.ts @@ -0,0 +1,531 @@ +/** + * Covers `simulateRoute()` — the programmatic "what would tsadwyn do with + * this request?" introspector. Given method + path + version (+ optional + * body), returns the matched route (if any), every candidate and why it + * did or didn't match, fallthrough reason with closest-miss suggestions, + * and the request / response migration chains that would run. Intended + * for incident triage ("is tsadwyn responsible for this 4xx?"). + * + * Run: npx vitest run tests/issue-route-simulation.test.ts + */ +import { describe, it, expect } from "vitest"; +import { z } from "zod"; + +import { + Tsadwyn, + Version, + VersionBundle, + VersionChange, + VersionedRouter, + ResponseInfo, + RequestInfo, + convertRequestToNextVersionFor, + convertResponseToPreviousVersionFor, + endpoint, + simulateRoute, +} from "../src/index.js"; + +const UserResp = z + .object({ id: z.string(), name: z.string() }) + .named("IssueRouteSim_User"); + +const ChargeReq = z + .object({ amount: z.number() }) + .named("IssueRouteSim_ChargeReq"); +const ChargeResp = z + .object({ id: z.string(), amount: z.number() }) + .named("IssueRouteSim_ChargeResp"); + +function makeRealApp() { + const router = new VersionedRouter({ prefix: "/api" }); + + router.get( + "/virtual-accounts/deposits", + null, + UserResp, + async () => ({ id: "list", name: "deposits" }), + ); + router.get( + "/virtual-accounts/:id", + null, + UserResp, + async (req: any) => ({ id: req.params.id, name: "va" }), + ); + router.post( + "/virtual-accounts/:id/payout", + ChargeReq, + ChargeResp, + async (req: any) => ({ id: "p-" + req.params.id, amount: req.body.amount }), + ); + router.post( + "/virtual-accounts", + ChargeReq, + ChargeResp, + async (req: any) => ({ id: "new", amount: req.body.amount }), + ); + + const app = new Tsadwyn({ + versions: new VersionBundle( + new Version("2025-06-01"), + new Version("2024-01-01"), + ), + }); + app.generateAndIncludeVersionedRouters(router); + return app; +} + +describe("Issue: simulateRoute() — match semantics", () => { + it("returns matchedRoute with captured params for an unambiguous match", () => { + const app = makeRealApp(); + + const result = simulateRoute(app, { + method: "POST", + path: "/api/virtual-accounts/c3e1a4b2-1111-2222-3333-444455556666/payout", + version: "2025-06-01", + }); + + expect(result.matchedRoute).not.toBeNull(); + expect(result.matchedRoute.method).toBe("POST"); + expect(result.matchedRoute.path).toBe("/api/virtual-accounts/:id/payout"); + expect(result.matchedRoute.params).toEqual({ + id: "c3e1a4b2-1111-2222-3333-444455556666", + }); + expect(result.fallthrough).toBeNull(); + }); + + it("returns matchedRoute = null and populates fallthrough when nothing matches", () => { + const app = makeRealApp(); + + const result = simulateRoute(app, { + method: "POST", + path: "/api/virtual-accounts/abc/payout/preview", + version: "2025-06-01", + }); + + expect(result.matchedRoute).toBeNull(); + expect(result.fallthrough).not.toBeNull(); + expect(result.fallthrough.reason).toMatch(/no.*match|does not match/i); + + // Closest miss should name /virtual-accounts/:id/payout + const closest = result.fallthrough.closestMisses ?? []; + const hasPayoutMiss = closest.some( + (m: any) => + m.method === "POST" && m.path === "/api/virtual-accounts/:id/payout", + ); + expect(hasPayoutMiss).toBe(true); + }); + + it("resolves version from an explicit version argument first, then header default", () => { + const app = makeRealApp(); + + const explicit = simulateRoute(app, { + method: "GET", + path: "/api/virtual-accounts/deposits", + version: "2024-01-01", + }); + expect(explicit.resolvedVersion).toBe("2024-01-01"); + + const headerBased = simulateRoute(app, { + method: "GET", + path: "/api/virtual-accounts/deposits", + headers: { "x-api-version": "2024-01-01" }, + }); + expect(headerBased.resolvedVersion).toBe("2024-01-01"); + + const fallbackToHead = simulateRoute(app, { + method: "GET", + path: "/api/virtual-accounts/deposits", + }); + expect(fallbackToHead.resolvedVersion).toBe("2025-06-01"); + }); +}); + +describe("Issue: simulateRoute() — candidate reasoning", () => { + it("tests every registered route and explains why each did or didn't match", () => { + const app = makeRealApp(); + + const result = simulateRoute(app, { + method: "POST", + path: "/api/virtual-accounts/abc/payout/preview", + version: "2025-06-01", + }); + + expect(Array.isArray(result.candidates)).toBe(true); + // Must have tested every registered route at this version (4 routes) + expect(result.candidates.length).toBe(4); + + // Each candidate has matched + reason + regex + for (const c of result.candidates) { + expect(typeof c.matched).toBe("boolean"); + expect(typeof c.reason).toBe("string"); + expect(typeof c.regex).toBe("string"); + } + + // The /virtual-accounts/:id/payout candidate was tested and NOT matched + const payoutCandidate = result.candidates.find( + (c: any) => + c.method === "POST" && c.path === "/api/virtual-accounts/:id/payout", + ); + expect(payoutCandidate).toBeDefined(); + expect(payoutCandidate.matched).toBe(false); + // Reason should identify that there's an extra segment after the match + expect(payoutCandidate.reason).toMatch(/extra segment|too long|preview/i); + }); + + it("distinguishes method mismatch as its own reason type", () => { + const app = makeRealApp(); + + const result = simulateRoute(app, { + method: "DELETE", + path: "/api/virtual-accounts/deposits", + version: "2025-06-01", + }); + + const depositsCandidate = result.candidates.find( + (c: any) => c.path === "/api/virtual-accounts/deposits", + ); + expect(depositsCandidate).toBeDefined(); + expect(depositsCandidate.matched).toBe(false); + expect(depositsCandidate.reason).toMatch(/method mismatch/i); + }); + + it("respects registration order for first-match-wins (documents the wildcard-collision landmine)", () => { + const app = makeRealApp(); + + // In makeRealApp, /virtual-accounts/deposits is registered BEFORE + // /virtual-accounts/:id. GET /virtual-accounts/deposits matches the + // literal first. + const result = simulateRoute(app, { + method: "GET", + path: "/api/virtual-accounts/deposits", + version: "2025-06-01", + }); + + expect(result.matchedRoute).not.toBeNull(); + expect(result.matchedRoute.path).toBe("/api/virtual-accounts/deposits"); + + // The wildcard candidate ALSO matched regex-wise, but registration order + // prefers the literal. Both facts should be visible in candidates. + const literalCandidate = result.candidates.find( + (c: any) => c.path === "/api/virtual-accounts/deposits", + ); + const wildcardCandidate = result.candidates.find( + (c: any) => c.path === "/api/virtual-accounts/:id", + ); + expect(literalCandidate.matched).toBe(true); + expect(wildcardCandidate.matched).toBe(true); // would-also-match + // Reason on the wildcard should note it was shadowed + expect(wildcardCandidate.reason).toMatch(/shadowed|first-match|order/i); + }); +}); + +describe("Issue: simulateRoute() — migration visibility", () => { + it("returns the request migrations that would run for a versioned request", () => { + class NormalizeAmount extends VersionChange { + description = "normalize amount at 2025-06-01"; + instructions = []; + + migrate = convertRequestToNextVersionFor(ChargeReq)( + (_req: RequestInfo) => {}, + ); + } + + const router = new VersionedRouter({ prefix: "/api" }); + router.post( + "/charges", + ChargeReq, + ChargeResp, + async () => ({ id: "c1", amount: 100 }), + ); + + const app = new Tsadwyn({ + versions: new VersionBundle( + new Version("2025-06-01", NormalizeAmount), + new Version("2024-01-01"), + ), + }); + app.generateAndIncludeVersionedRouters(router); + + const result = simulateRoute(app, { + method: "POST", + path: "/api/charges", + version: "2024-01-01", + }); + + expect(result.matchedRoute).not.toBeNull(); + expect(Array.isArray(result.requestMigrations)).toBe(true); + expect(result.requestMigrations.length).toBeGreaterThan(0); + expect(result.requestMigrations[0]).toMatchObject({ + schemaName: "IssueRouteSim_ChargeReq", + }); + }); + + it("returns the response migrations that would run for a versioned response", () => { + class RenameAmount extends VersionChange { + description = "rename amount at 2025-06-01"; + instructions = []; + + migrate = convertResponseToPreviousVersionFor(ChargeResp)( + (_res: ResponseInfo) => {}, + ); + } + + const router = new VersionedRouter({ prefix: "/api" }); + router.post( + "/charges", + ChargeReq, + ChargeResp, + async () => ({ id: "c1", amount: 100 }), + ); + + const app = new Tsadwyn({ + versions: new VersionBundle( + new Version("2025-06-01", RenameAmount), + new Version("2024-01-01"), + ), + }); + app.generateAndIncludeVersionedRouters(router); + + const result = simulateRoute(app, { + method: "POST", + path: "/api/charges", + version: "2024-01-01", + }); + + expect(result.responseMigrations.length).toBeGreaterThan(0); + expect(result.responseMigrations[0]).toMatchObject({ + schemaName: "IssueRouteSim_ChargeResp", + }); + }); + + it("surfaces PATH-based request AND response migrations in the chain summaries", () => { + class PathBasedBothDirections extends VersionChange { + description = "path-based request + response migrations on POST /api/charges"; + instructions = []; + + req1 = convertRequestToNextVersionFor("/api/charges", ["POST"])( + (_req: RequestInfo) => {}, + ); + + res1 = convertResponseToPreviousVersionFor("/api/charges", ["POST"])( + (_res: ResponseInfo) => {}, + ); + } + + const router = new VersionedRouter({ prefix: "/api" }); + router.post( + "/charges", + ChargeReq, + ChargeResp, + async () => ({ id: "c1", amount: 100 }), + ); + + const app = new Tsadwyn({ + versions: new VersionBundle( + new Version("2025-06-01", PathBasedBothDirections), + new Version("2024-01-01"), + ), + }); + app.generateAndIncludeVersionedRouters(router); + + const result = simulateRoute(app, { + method: "POST", + path: "/api/charges", + version: "2024-01-01", + }); + + // Path-based request migration surfaces (schemaName: null signals path-based) + const pathBasedReq = result.requestMigrations.filter( + (m: any) => m.schemaName === null, + ); + expect(pathBasedReq.length).toBeGreaterThan(0); + expect(pathBasedReq[0].path).toBe("/api/charges"); + + // Path-based response migration surfaces + const pathBasedRes = result.responseMigrations.filter( + (m: any) => m.schemaName === null, + ); + expect(pathBasedRes.length).toBeGreaterThan(0); + expect(pathBasedRes[0].path).toBe("/api/charges"); + }); + + it("body preview runs PATH-based request migrations too (not only schema-based)", () => { + class PathBasedBodyRewriter extends VersionChange { + description = + "path-based request migration that injects a default field for legacy clients"; + instructions = []; + + r1 = convertRequestToNextVersionFor("/api/charges", ["POST"])( + (req: RequestInfo) => { + if ((req.body as any) && typeof req.body === "object") { + (req.body as any).currency = (req.body as any).currency ?? "USD"; + } + }, + ); + } + + const router = new VersionedRouter({ prefix: "/api" }); + router.post( + "/charges", + ChargeReq, + ChargeResp, + async () => ({ id: "c1", amount: 100 }), + ); + + const app = new Tsadwyn({ + versions: new VersionBundle( + new Version("2025-06-01", PathBasedBodyRewriter), + new Version("2024-01-01"), + ), + }); + app.generateAndIncludeVersionedRouters(router); + + const result = simulateRoute(app, { + method: "POST", + path: "/api/charges", + version: "2024-01-01", + body: { amount: 100 }, // legacy client didn't send currency + }); + + // The path-based migration populated .currency even though we didn't send it. + expect(result.upMigratedBody).toMatchObject({ + amount: 100, + currency: "USD", + }); + }); + + it("both migration arrays are empty when client pin == head", () => { + class RenameAmount extends VersionChange { + description = "rename amount at 2025-06-01"; + instructions = []; + + migrate = convertResponseToPreviousVersionFor(ChargeResp)( + (_res: ResponseInfo) => {}, + ); + } + + const router = new VersionedRouter({ prefix: "/api" }); + router.post( + "/charges", + ChargeReq, + ChargeResp, + async () => ({ id: "c1", amount: 100 }), + ); + + const app = new Tsadwyn({ + versions: new VersionBundle( + new Version("2025-06-01", RenameAmount), + new Version("2024-01-01"), + ), + }); + app.generateAndIncludeVersionedRouters(router); + + const result = simulateRoute(app, { + method: "POST", + path: "/api/charges", + version: "2025-06-01", // HEAD + }); + + expect(result.requestMigrations).toEqual([]); + expect(result.responseMigrations).toEqual([]); + }); +}); + +describe("Issue: simulateRoute() — body preview", () => { + it("up-migrates a supplied legacy body and exposes the head-shape it produces", () => { + class AddCurrency extends VersionChange { + description = "legacy clients omit currency; default to USD at head"; + instructions = []; + + migrate = convertRequestToNextVersionFor(ChargeReq)( + (req: RequestInfo) => { + if ((req.body as any).currency === undefined) { + (req.body as any).currency = "USD"; + } + }, + ); + } + + const router = new VersionedRouter({ prefix: "/api" }); + router.post( + "/charges", + ChargeReq, + ChargeResp, + async () => ({ id: "c1", amount: 100 }), + ); + + const app = new Tsadwyn({ + versions: new VersionBundle( + new Version("2025-06-01", AddCurrency), + new Version("2024-01-01"), + ), + }); + app.generateAndIncludeVersionedRouters(router); + + const result = simulateRoute(app, { + method: "POST", + path: "/api/charges", + version: "2024-01-01", + body: { amount: 100 }, + }); + + expect(result.upMigratedBody).toEqual({ amount: 100, currency: "USD" }); + }); + + it("omits upMigratedBody when no body is supplied", () => { + const app = makeRealApp(); + + const result = simulateRoute(app, { + method: "GET", + path: "/api/virtual-accounts/deposits", + version: "2025-06-01", + // no body + }); + + expect(result.upMigratedBody).toBeUndefined(); + }); +}); + +describe("Issue: simulateRoute() — fallthrough diagnostics", () => { + it("lists other versions at which the path DOES exist when fallthrough happens at the target version", () => { + // Endpoint lifecycle: /api/legacy exists at 2024-01-01 but is removed at head. + const router = new VersionedRouter({ prefix: "/api" }); + router.get("/users/:id", null, UserResp, async (req: any) => ({ + id: req.params.id, + name: "alice", + })); + router.get( + "/legacy", + null, + UserResp, + async () => ({ id: "l", name: "legacy" }), + ); + // onlyExistsInOlderVersions needs the stored (prefixed) path. + router.onlyExistsInOlderVersions("/api/legacy", ["GET"]); + + class RestoreLegacy extends VersionChange { + description = "legacy clients had GET /api/legacy"; + instructions = [endpoint("/api/legacy", ["GET"]).existed]; + } + + const app = new Tsadwyn({ + versions: new VersionBundle( + new Version("2025-06-01", RestoreLegacy), + new Version("2024-01-01"), + ), + }); + app.generateAndIncludeVersionedRouters(router); + + // At HEAD, /api/legacy does NOT exist — expect fallthrough with the + // other version where it DID exist listed. + const result = simulateRoute(app, { + method: "GET", + path: "/api/legacy", + version: "2025-06-01", + }); + + expect(result.matchedRoute).toBeNull(); + expect(result.fallthrough).not.toBeNull(); + expect(result.fallthrough!.availableAtOtherVersions).toEqual(["2024-01-01"]); + }); +}); diff --git a/tests/issue-route-table-dump.test.ts b/tests/issue-route-table-dump.test.ts new file mode 100644 index 0000000..cfe83fa --- /dev/null +++ b/tests/issue-route-table-dump.test.ts @@ -0,0 +1,171 @@ +/** + * Covers `dumpRouteTable` — the public API for enumerating registered + * routes per version with filters on method / path / visibility. Replaces + * the private `_versionedRouters` grepping consumers used to fall back on. + * + * Run: npx vitest run tests/issue-route-table-dump.test.ts + */ +import { describe, it, expect } from "vitest"; +import { z } from "zod"; + +import { + Tsadwyn, + Version, + VersionBundle, + VersionChange, + VersionedRouter, + endpoint, + dumpRouteTable, +} from "../src/index.js"; + +const UserResp = z.object({ id: z.string(), name: z.string() }).named("IssueRouteDump_User"); +const ChargeResp = z.object({ id: z.string(), amount: z.number() }).named("IssueRouteDump_Charge"); + +function makeApp() { + const router = new VersionedRouter({ prefix: "/api" }); + router.get("/users/:id", null, UserResp, async (req: any) => ({ + id: req.params.id, + name: "alice", + })); + router.post("/charges", null, ChargeResp, async () => ({ id: "c1", amount: 100 }), { + statusCode: 201, + }); + router.get("/internal/metrics", null, null, async () => ({}), { + includeInSchema: false, + }); + + const app = new Tsadwyn({ + versions: new VersionBundle( + new Version("2025-01-01"), + new Version("2024-01-01"), + ), + }); + app.generateAndIncludeVersionedRouters(router); + return app; +} + +describe("Issue: dumpRouteTable()", () => { + it("returns every registered route for a specified version", () => { + const app = makeApp(); + const table = dumpRouteTable(app, { version: "2025-01-01" }); + + expect(Array.isArray(table)).toBe(true); + const paths = table.map((r: any) => `${r.method} ${r.path}`); + expect(paths).toContain("GET /api/users/:id"); + expect(paths).toContain("POST /api/charges"); + }); + + it("excludes includeInSchema: false routes by default", () => { + const app = makeApp(); + const table = dumpRouteTable(app, { version: "2025-01-01" }); + const paths = table.map((r: any) => r.path); + expect(paths).not.toContain("/api/internal/metrics"); + }); + + it("includes private routes when includePrivate: true", () => { + const app = makeApp(); + const table = dumpRouteTable(app, { + version: "2025-01-01", + includePrivate: true, + }); + const paths = table.map((r: any) => r.path); + expect(paths).toContain("/api/internal/metrics"); + }); + + it("filters by method case-insensitively", () => { + const app = makeApp(); + const table = dumpRouteTable(app, { version: "2025-01-01", method: "post" }); + expect(table.every((r: any) => r.method === "POST")).toBe(true); + expect(table.length).toBeGreaterThan(0); + }); + + it("filters by pathMatches regex", () => { + const app = makeApp(); + const table = dumpRouteTable(app, { + version: "2025-01-01", + pathMatches: /users/, + }); + expect(table.every((r: any) => /users/.test(r.path))).toBe(true); + expect(table.length).toBeGreaterThan(0); + }); + + it("filters by pathMatches substring string", () => { + const app = makeApp(); + const table = dumpRouteTable(app, { + version: "2025-01-01", + pathMatches: "charges", + }); + expect(table.every((r: any) => r.path.includes("charges"))).toBe(true); + }); + + it("entries expose handler name, schemas, statusCode, deprecated flag", () => { + const app = makeApp(); + const table = dumpRouteTable(app, { version: "2025-01-01" }); + const charge = table.find((r: any) => r.path === "/api/charges"); + expect(charge).toMatchObject({ + method: "POST", + statusCode: 201, + deprecated: false, + responseSchemaName: "IssueRouteDump_Charge", + }); + }); + + it("returns per-version sections when version is omitted", () => { + const app = makeApp(); + const result = dumpRouteTable(app); // no version + // Structure TBD by implementer: object keyed by version, or flat with + // version field on each entry. Both viable; test asserts the + // 2024-01-01 version is distinguishable. + expect(Array.isArray(result) || typeof result === "object").toBe(true); + // Must be possible to inspect 2024-01-01 entries: + const v1Entries = Array.isArray(result) + ? result.filter((r: any) => r.version === "2024-01-01") + : (result as any)["2024-01-01"]; + expect(v1Entries).toBeDefined(); + expect(Array.isArray(v1Entries)).toBe(true); + }); + + it("includes routes added via endpoint().existed at older versions", () => { + class RestoreLegacyRoute extends VersionChange { + description = "legacy clients had GET /api/legacy-only"; + instructions = [endpoint("/api/legacy-only", ["GET"]).existed]; + } + + const router = new VersionedRouter({ prefix: "/api" }); + router.get("/users/:id", null, UserResp, async (req: any) => ({ + id: req.params.id, + name: "alice", + })); + // Legacy route registered but marked deleted in head; existed restores it at 2024-01-01. + // The route is stored at its prefixed path (/api/legacy-only), so that's + // the path passed to onlyExistsInOlderVersions. + router.get("/legacy-only", null, UserResp, async () => ({ id: "l", name: "legacy" })); + router.onlyExistsInOlderVersions("/api/legacy-only", ["GET"]); + + const app = new Tsadwyn({ + versions: new VersionBundle( + new Version("2025-01-01", RestoreLegacyRoute), + new Version("2024-01-01"), + ), + }); + app.generateAndIncludeVersionedRouters(router); + + const v1 = dumpRouteTable(app, { version: "2024-01-01" }); + const v2 = dumpRouteTable(app, { version: "2025-01-01" }); + + const v1Paths = v1.map((r: any) => r.path); + const v2Paths = v2.map((r: any) => r.path); + expect(v1Paths).toContain("/api/legacy-only"); + expect(v2Paths).not.toContain("/api/legacy-only"); + }); + + it("filters by method + pathMatches combined (AND semantics)", () => { + const app = makeApp(); + const table = dumpRouteTable(app, { + version: "2025-01-01", + method: "GET", + pathMatches: "charges", // no GET /charges — result empty + }); + expect(table).toEqual([]); + }); +}); diff --git a/tests/issue-validate-version-upgrade.test.ts b/tests/issue-validate-version-upgrade.test.ts new file mode 100644 index 0000000..e96e641 --- /dev/null +++ b/tests/issue-validate-version-upgrade.test.ts @@ -0,0 +1,201 @@ +/** + * Covers `validateVersionUpgrade` — the pure upgrade-policy helper that + * standardizes the decisions every `/versioning/upgrade` endpoint needs: + * is the target supported, is it a downgrade, is it a no-op, how do we + * compare version strings (iso-date / semver / custom comparator). + * + * Run: npx vitest run tests/issue-validate-version-upgrade.test.ts + */ +import { describe, it, expect } from "vitest"; + +import { validateVersionUpgrade } from "../src/index.js"; + +const SUPPORTED = ["2026-01-01", "2025-06-01", "2025-01-01", "2024-01-01"] as const; + +describe("Issue: validateVersionUpgrade policy helper", () => { + it("accepts a valid forward upgrade and returns previous + next", () => { + const decision = validateVersionUpgrade({ + current: "2025-01-01", + target: "2025-06-01", + supported: SUPPORTED, + }); + expect(decision).toEqual({ + ok: true, + previous: "2025-01-01", + next: "2025-06-01", + }); + }); + + it("rejects an unsupported target version", () => { + const decision = validateVersionUpgrade({ + current: "2024-01-01", + target: "2099-01-01", + supported: SUPPORTED, + }); + expect(decision.ok).toBe(false); + expect(decision.reason).toBe("unsupported"); + }); + + it("rejects downgrade by default", () => { + const decision = validateVersionUpgrade({ + current: "2026-01-01", + target: "2025-01-01", + supported: SUPPORTED, + }); + expect(decision.ok).toBe(false); + expect(decision.reason).toBe("downgrade-blocked"); + }); + + it("permits downgrade when allowDowngrade is true", () => { + const decision = validateVersionUpgrade({ + current: "2026-01-01", + target: "2025-01-01", + supported: SUPPORTED, + allowDowngrade: true, + }); + expect(decision).toEqual({ + ok: true, + previous: "2026-01-01", + next: "2025-01-01", + }); + }); + + it("rejects no-change by default", () => { + const decision = validateVersionUpgrade({ + current: "2025-01-01", + target: "2025-01-01", + supported: SUPPORTED, + }); + expect(decision.ok).toBe(false); + expect(decision.reason).toBe("no-change"); + }); + + it("permits no-change when allowNoChange is true", () => { + const decision = validateVersionUpgrade({ + current: "2025-01-01", + target: "2025-01-01", + supported: SUPPORTED, + allowNoChange: true, + }); + expect(decision.ok).toBe(true); + }); + + it("default 'iso-date' comparison sorts lexicographically (which works for YYYY-MM-DD)", () => { + // 2025-06-01 > 2025-01-01 lexicographically — forward upgrade + const fwd = validateVersionUpgrade({ + current: "2025-01-01", + target: "2025-06-01", + supported: SUPPORTED, + }); + expect(fwd.ok).toBe(true); + + // 2025-01-01 < 2025-06-01 → downgrade blocked + const back = validateVersionUpgrade({ + current: "2025-06-01", + target: "2025-01-01", + supported: SUPPORTED, + }); + expect(back.ok).toBe(false); + }); + + it("supports a custom comparator", () => { + const SEMVER = ["v1.0.0", "v2.0.0", "v3.0.0"] as const; + const decision = validateVersionUpgrade({ + current: "v1.0.0", + target: "v3.0.0", + supported: SEMVER, + compare: (a: string, b: string) => + parseInt(a.slice(1), 10) - parseInt(b.slice(1), 10), + }); + expect(decision).toEqual({ ok: true, previous: "v1.0.0", next: "v3.0.0" }); + }); + + describe("built-in 'semver' comparator", () => { + const SEMVER = ["v1.0.0", "v1.2.0", "v2.0.0", "v2.5.3"] as const; + + it("accepts forward semver upgrade (v1.0.0 → v2.0.0)", () => { + const decision = validateVersionUpgrade({ + current: "v1.0.0", + target: "v2.0.0", + supported: SEMVER, + compare: "semver", + }); + expect(decision).toEqual({ ok: true, previous: "v1.0.0", next: "v2.0.0" }); + }); + + it("rejects downgrade by semver (v2.0.0 → v1.0.0 blocked)", () => { + const decision = validateVersionUpgrade({ + current: "v2.0.0", + target: "v1.0.0", + supported: SEMVER, + compare: "semver", + }); + expect(decision.ok).toBe(false); + expect(decision.reason).toBe("downgrade-blocked"); + }); + + it("minor version bump is a forward upgrade (v1.0.0 → v1.2.0)", () => { + const decision = validateVersionUpgrade({ + current: "v1.0.0", + target: "v1.2.0", + supported: SEMVER, + compare: "semver", + }); + expect(decision.ok).toBe(true); + }); + + it("patch version bump is a forward upgrade (v2.0.0 → v2.5.3)", () => { + const decision = validateVersionUpgrade({ + current: "v2.0.0", + target: "v2.5.3", + supported: SEMVER, + compare: "semver", + }); + expect(decision.ok).toBe(true); + }); + + it("same semver is a no-change (blocked by default)", () => { + const decision = validateVersionUpgrade({ + current: "v1.2.0", + target: "v1.2.0", + supported: SEMVER, + compare: "semver", + }); + expect(decision.ok).toBe(false); + expect(decision.reason).toBe("no-change"); + }); + + it("accepts versions WITHOUT the leading 'v' prefix", () => { + const NAKED = ["1.0.0", "2.0.0"] as const; + const decision = validateVersionUpgrade({ + current: "1.0.0", + target: "2.0.0", + supported: NAKED, + compare: "semver", + }); + expect(decision.ok).toBe(true); + }); + + it("treats missing semver parts as zero (v1 == v1.0 == v1.0.0)", () => { + // v1 parses as [1], v1.0.0 as [1,0,0]. The comparator pads missing + // parts with 0, so v1 and v1.0.0 compare equal. + const MIXED = ["v1", "v1.0.0", "v1.0.1"] as const; + const noChange = validateVersionUpgrade({ + current: "v1", + target: "v1.0.0", + supported: MIXED, + compare: "semver", + }); + expect(noChange.ok).toBe(false); + expect(noChange.reason).toBe("no-change"); + + const forward = validateVersionUpgrade({ + current: "v1", + target: "v1.0.1", + supported: MIXED, + compare: "semver", + }); + expect(forward.ok).toBe(true); + }); + }); +}); diff --git a/tests/issue-validation-error-envelope.test.ts b/tests/issue-validation-error-envelope.test.ts new file mode 100644 index 0000000..04b70a6 --- /dev/null +++ b/tests/issue-validation-error-envelope.test.ts @@ -0,0 +1,190 @@ +/** + * Validation errors (body / params / query schema parse failures) now + * flow through tsadwyn's error pipeline as `ValidationError extends + * HttpError`, so consumers can reshape the wire envelope via + * `errorMapper` / `exceptionMap` or `migrateHttpErrors` response + * migrations. + * + * Run: npx vitest run tests/issue-validation-error-envelope.test.ts + */ +import { describe, it, expect } from "vitest"; +import request from "supertest"; +import { z } from "zod"; + +import { + Tsadwyn, + Version, + VersionBundle, + VersionChange, + VersionedRouter, + HttpError, + ValidationError, + exceptionMap, + convertResponseToPreviousVersionFor, + ResponseInfo, +} from "../src/index.js"; + +describe("Issue: validation errors as first-class HttpError (ValidationError)", () => { + const CreateUser = z + .object({ + email: z.string().email(), + age: z.number().int().min(0), + }) + .named("IssueValErr_CreateUser"); + + it("backward-compatible: default wire shape is still {detail: [...]} at 422", async () => { + const router = new VersionedRouter(); + router.post("/users", CreateUser, null, async () => ({ ok: true })); + + const app = new Tsadwyn({ + versions: new VersionBundle(new Version("2024-01-01")), + }); + app.generateAndIncludeVersionedRouters(router); + + const res = await request(app.expressApp) + .post("/users") + .set("x-api-version", "2024-01-01") + .send({ email: "not-an-email", age: -1 }); + + expect(res.status).toBe(422); + expect(Array.isArray(res.body.detail)).toBe(true); + expect(res.body.detail.length).toBeGreaterThan(0); + }); + + it("errorMapper can reshape validation errors via err.name === 'ValidationError'", async () => { + const router = new VersionedRouter(); + router.post("/users", CreateUser, null, async () => ({ ok: true })); + + const app = new Tsadwyn({ + versions: new VersionBundle(new Version("2024-01-01")), + errorMapper: exceptionMap({ + ValidationError: (err: any) => + new HttpError(422, { + error: { + code: "validation_error", + message: "One or more fields failed validation.", + where: err.where, + fields: err.body.detail.map((e: any) => ({ + path: e.path, + message: e.message, + code: e.code, + })), + }, + }), + }), + }); + app.generateAndIncludeVersionedRouters(router); + + const res = await request(app.expressApp) + .post("/users") + .set("x-api-version", "2024-01-01") + .send({ email: "not-an-email", age: -1 }); + + expect(res.status).toBe(422); + expect(res.body).toMatchObject({ + error: { + code: "validation_error", + message: "One or more fields failed validation.", + where: "body", + }, + }); + expect(Array.isArray(res.body.error.fields)).toBe(true); + expect(res.body.error.fields.length).toBeGreaterThan(0); + }); + + it("ValidationError.where distinguishes body / params / query", async () => { + const Params = z.object({ id: z.string().uuid() }).named("IssueValErr_Params"); + const Query = z.object({ limit: z.coerce.number().int().positive() }).named("IssueValErr_Query"); + + const seenWhere: string[] = []; + + const router = new VersionedRouter(); + router.get("/items/:id", null, null, async () => ({ ok: true }), { + paramsSchema: Params, + querySchema: Query, + }); + + const app = new Tsadwyn({ + versions: new VersionBundle(new Version("2024-01-01")), + errorMapper: exceptionMap({ + ValidationError: (err: any) => { + seenWhere.push(err.where); + return new HttpError(422, { + error: { code: `validation_${err.where}` }, + }); + }, + }), + }); + app.generateAndIncludeVersionedRouters(router); + + // Bad path param (not a uuid) — where === 'params' + const paramsRes = await request(app.expressApp) + .get("/items/not-a-uuid?limit=10") + .set("x-api-version", "2024-01-01"); + expect(paramsRes.status).toBe(422); + expect(paramsRes.body.error.code).toBe("validation_params"); + + // Bad query (limit non-numeric) — where === 'query' + const queryRes = await request(app.expressApp) + .get("/items/00000000-0000-4000-8000-000000000000?limit=notanumber") + .set("x-api-version", "2024-01-01"); + expect(queryRes.status).toBe(422); + expect(queryRes.body.error.code).toBe("validation_query"); + + expect(seenWhere).toEqual(["params", "query"]); + }); + + it("ValidationError can be reshaped per-version via migrateHttpErrors response migrations", async () => { + class FlattenValidationEnvelope extends VersionChange { + description = + "initial version returned a flat {errors: [...]} body; head uses {detail: [...]}"; + instructions = []; + + r1 = convertResponseToPreviousVersionFor("/users", ["POST"], { + migrateHttpErrors: true, + })((res: ResponseInfo) => { + if (res.statusCode === 422 && res.body?.detail) { + res.body = { errors: res.body.detail }; + } + }); + } + + const router = new VersionedRouter(); + router.post("/users", CreateUser, null, async () => ({ ok: true })); + + const app = new Tsadwyn({ + versions: new VersionBundle( + new Version("2025-01-01", FlattenValidationEnvelope), + new Version("2024-01-01"), + ), + }); + app.generateAndIncludeVersionedRouters(router); + + // Initial-version client sees the flat {errors: [...]} shape + const legacyRes = await request(app.expressApp) + .post("/users") + .set("x-api-version", "2024-01-01") + .send({ email: "nope", age: -1 }); + expect(legacyRes.status).toBe(422); + expect(legacyRes.body.errors).toBeDefined(); + expect(legacyRes.body.detail).toBeUndefined(); + + // Head client sees the current {detail: [...]} shape + const headRes = await request(app.expressApp) + .post("/users") + .set("x-api-version", "2025-01-01") + .send({ email: "nope", age: -1 }); + expect(headRes.status).toBe(422); + expect(headRes.body.detail).toBeDefined(); + }); + + it("ValidationError instances pass instanceof HttpError AND instanceof ValidationError", () => { + const err = new ValidationError("body", [{ path: ["x"], message: "bad" }]); + expect(err).toBeInstanceOf(HttpError); + expect(err).toBeInstanceOf(ValidationError); + expect(err.name).toBe("ValidationError"); + expect(err.statusCode).toBe(422); + expect(err.body).toEqual({ detail: [{ path: ["x"], message: "bad" }] }); + expect(err.where).toBe("body"); + }); +}); diff --git a/tests/issue-versioning-resource.test.ts b/tests/issue-versioning-resource.test.ts new file mode 100644 index 0000000..6fdd146 --- /dev/null +++ b/tests/issue-versioning-resource.test.ts @@ -0,0 +1,397 @@ +/** + * Covers `createVersioningRoutes` — the pre-wired RESTful `/versioning` + * resource helper for self-service API-version upgrades. Wraps + * `validateVersionUpgrade` (the policy core) in the canonical resource shape: + * + * GET /versioning → {version, supported[], latest} + * POST /versioning {from, to} → {previous_version, current_version} + * + * The `{from, to}` payload implements optimistic concurrency: if the stored + * pin drifted since the client last read it, the server rejects with 409 + * rather than silently overwriting. + * + * Run: npx vitest run tests/issue-versioning-resource.test.ts + */ +import { describe, it, expect } from "vitest"; +import request from "supertest"; + +import { + Tsadwyn, + Version, + VersionBundle, + createVersioningRoutes, +} from "../src/index.js"; + +// An in-memory "account repo" simulates the consumer's persistence layer. +function buildStore() { + const pins: Record = {}; + return { + set(accountId: string, version: string) { + pins[accountId] = version; + }, + load(accountId: string) { + return pins[accountId] ?? null; + }, + save(accountId: string, version: string) { + pins[accountId] = version; + }, + }; +} + +function buildApp( + store: ReturnType, + opts: { + allowDowngrade?: boolean; + allowNoChange?: boolean; + fallback?: string; + } = {}, +) { + const versions = new VersionBundle( + new Version("2025-06-01"), + new Version("2025-01-01"), + new Version("2024-01-01"), + ); + + const versioningRoutes = createVersioningRoutes({ + path: "/versioning", + identify: (req: any) => req.headers["x-account-id"] ?? null, + loadVersion: (accountId: string) => store.load(accountId), + saveVersion: (accountId: string, version: string) => + store.save(accountId, version), + supportedVersions: versions.versionValues, + allowDowngrade: opts.allowDowngrade ?? false, + allowNoChange: opts.allowNoChange ?? false, + fallback: opts.fallback, + }); + + const app = new Tsadwyn({ versions }); + app.generateAndIncludeVersionedRouters(versioningRoutes); + return app; +} + +describe("createVersioningRoutes — RESTful /versioning resource", () => { + it("GET /versioning returns {version, supported, latest} for an authenticated client", async () => { + const store = buildStore(); + store.set("acct_1", "2024-01-01"); + const app = buildApp(store); + + const res = await request(app.expressApp) + .get("/versioning") + .set("x-account-id", "acct_1") + .set("x-api-version", "2025-06-01"); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + version: "2024-01-01", + supported: ["2025-06-01", "2025-01-01", "2024-01-01"], + latest: "2025-06-01", + }); + }); + + it("GET /versioning returns 401 when identify returns null", async () => { + const app = buildApp(buildStore()); + + const res = await request(app.expressApp) + .get("/versioning") + .set("x-api-version", "2025-06-01"); + // No x-account-id header → identify returns null → 401 + + expect(res.status).toBe(401); + }); + + it("GET /versioning returns null for unpinned clients when no fallback is configured", async () => { + const store = buildStore(); + const app = buildApp(store); // no fallback option + + const res = await request(app.expressApp) + .get("/versioning") + .set("x-account-id", "acct_never_upgraded") + .set("x-api-version", "2025-06-01"); + + expect(res.status).toBe(200); + // No fallback → version is null (the client is truly unpinned from + // tsadwyn's perspective; consumer may handle this out-of-band). + expect(res.body.version).toBeNull(); + }); + + it("POST /versioning with matching from + valid to → 200 + updated pin", async () => { + const store = buildStore(); + store.set("acct_1", "2024-01-01"); + const app = buildApp(store); + + const res = await request(app.expressApp) + .post("/versioning") + .set("x-account-id", "acct_1") + .set("x-api-version", "2025-06-01") + .send({ from: "2024-01-01", to: "2025-01-01" }); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + previous_version: "2024-01-01", + current_version: "2025-01-01", + }); + // Persisted + expect(store.load("acct_1")).toBe("2025-01-01"); + }); + + it("POST /versioning with from ≠ stored returns 409 version_mismatch (optimistic concurrency)", async () => { + const store = buildStore(); + store.set("acct_1", "2025-01-01"); // already upgraded by someone else + const app = buildApp(store); + + const res = await request(app.expressApp) + .post("/versioning") + .set("x-account-id", "acct_1") + .set("x-api-version", "2025-06-01") + .send({ from: "2024-01-01", to: "2025-06-01" }); // stale 'from' + + expect(res.status).toBe(409); + expect(res.body).toMatchObject({ + error: "version_mismatch", + expected: "2024-01-01", + actual: "2025-01-01", + }); + // Not persisted + expect(store.load("acct_1")).toBe("2025-01-01"); + }); + + it("POST /versioning with unsupported 'to' returns 400 unsupported", async () => { + const store = buildStore(); + store.set("acct_1", "2024-01-01"); + const app = buildApp(store); + + const res = await request(app.expressApp) + .post("/versioning") + .set("x-account-id", "acct_1") + .set("x-api-version", "2025-06-01") + .send({ from: "2024-01-01", to: "2099-12-31" }); + + expect(res.status).toBe(400); + expect(res.body).toMatchObject({ error: "unsupported" }); + }); + + it("POST /versioning downgrade returns 400 downgrade-blocked by default", async () => { + const store = buildStore(); + store.set("acct_1", "2025-06-01"); + const app = buildApp(store); + + const res = await request(app.expressApp) + .post("/versioning") + .set("x-account-id", "acct_1") + .set("x-api-version", "2025-06-01") + .send({ from: "2025-06-01", to: "2024-01-01" }); + + expect(res.status).toBe(400); + expect(res.body).toMatchObject({ error: "downgrade-blocked" }); + }); + + it("POST /versioning downgrade succeeds when allowDowngrade: true (admin force-pin)", async () => { + const store = buildStore(); + store.set("acct_1", "2025-06-01"); + const app = buildApp(store, { allowDowngrade: true }); + + const res = await request(app.expressApp) + .post("/versioning") + .set("x-account-id", "acct_1") + .set("x-api-version", "2025-06-01") + .send({ from: "2025-06-01", to: "2024-01-01" }); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + previous_version: "2025-06-01", + current_version: "2024-01-01", + }); + }); + + it("POST /versioning same from + to returns 400 no-change by default", async () => { + const store = buildStore(); + store.set("acct_1", "2024-01-01"); + const app = buildApp(store); + + const res = await request(app.expressApp) + .post("/versioning") + .set("x-account-id", "acct_1") + .set("x-api-version", "2025-06-01") + .send({ from: "2024-01-01", to: "2024-01-01" }); + + expect(res.status).toBe(400); + expect(res.body).toMatchObject({ error: "no-change" }); + }); + + it("POST /versioning returns 401 when identify returns null", async () => { + const app = buildApp(buildStore()); + + const res = await request(app.expressApp) + .post("/versioning") + .set("x-api-version", "2025-06-01") + .send({ from: "2024-01-01", to: "2025-01-01" }); + + expect(res.status).toBe(401); + }); + + it("POST /versioning rejects malformed body (missing 'from' or 'to')", async () => { + const store = buildStore(); + store.set("acct_1", "2024-01-01"); + const app = buildApp(store); + + const missingTo = await request(app.expressApp) + .post("/versioning") + .set("x-account-id", "acct_1") + .set("x-api-version", "2025-06-01") + .send({ from: "2024-01-01" }); + expect(missingTo.status).toBe(422); + + const missingFrom = await request(app.expressApp) + .post("/versioning") + .set("x-account-id", "acct_1") + .set("x-api-version", "2025-06-01") + .send({ to: "2025-01-01" }); + expect(missingFrom.status).toBe(422); + }); + + describe("fallback — effective version for unpinned clients", () => { + it("GET /versioning returns the fallback value when no pin is stored", async () => { + const store = buildStore(); + const app = buildApp(store, { fallback: "2024-01-01" }); + + const res = await request(app.expressApp) + .get("/versioning") + .set("x-account-id", "acct_never_upgraded") + .set("x-api-version", "2025-06-01"); + + expect(res.status).toBe(200); + // Reports what tsadwyn would actually use at dispatch time. + expect(res.body.version).toBe("2024-01-01"); + }); + + it("POST /versioning accepts from: fallback as 'unpinned starting state'", async () => { + const store = buildStore(); // acct_1 is unpinned + const app = buildApp(store, { fallback: "2024-01-01" }); + + const res = await request(app.expressApp) + .post("/versioning") + .set("x-account-id", "acct_1") + .set("x-api-version", "2025-06-01") + .send({ from: "2024-01-01", to: "2025-01-01" }); // from == fallback + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + previous_version: null, + current_version: "2025-01-01", + }); + expect(store.load("acct_1")).toBe("2025-01-01"); + }); + + it("POST /versioning still accepts from: null alongside fallback (either describes unpinned)", async () => { + const store = buildStore(); + const app = buildApp(store, { fallback: "2024-01-01" }); + + const res = await request(app.expressApp) + .post("/versioning") + .set("x-account-id", "acct_1") + .set("x-api-version", "2025-06-01") + .send({ from: null, to: "2025-01-01" }); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + previous_version: null, + current_version: "2025-01-01", + }); + }); + + it("POST /versioning with from: → 409 against effective version (not null)", async () => { + const store = buildStore(); + const app = buildApp(store, { fallback: "2024-01-01" }); + + const res = await request(app.expressApp) + .post("/versioning") + .set("x-account-id", "acct_1") + .set("x-api-version", "2025-06-01") + .send({ from: "2025-01-01", to: "2025-06-01" }); // wrong from + + expect(res.status).toBe(409); + expect(res.body).toMatchObject({ + error: "version_mismatch", + expected: "2025-01-01", + actual: "2024-01-01", // effective, not null + }); + }); + + it("first-upgrade policy: downgrade from fallback is blocked by default", async () => { + const store = buildStore(); + const app = buildApp(store, { fallback: "2025-01-01" }); + + // Client tries to "upgrade" to a version older than the fallback. + const res = await request(app.expressApp) + .post("/versioning") + .set("x-account-id", "acct_1") + .set("x-api-version", "2025-06-01") + .send({ from: "2025-01-01", to: "2024-01-01" }); + + expect(res.status).toBe(400); + expect(res.body).toMatchObject({ error: "downgrade-blocked" }); + }); + + it("first-upgrade policy: no-change vs fallback is blocked by default", async () => { + const store = buildStore(); + const app = buildApp(store, { fallback: "2024-01-01" }); + + const res = await request(app.expressApp) + .post("/versioning") + .set("x-account-id", "acct_1") + .set("x-api-version", "2025-06-01") + .send({ from: "2024-01-01", to: "2024-01-01" }); + + expect(res.status).toBe(400); + expect(res.body).toMatchObject({ error: "no-change" }); + }); + }); + + it("POST /versioning first-upgrade flow: from: null for unpinned clients", async () => { + // Convention: a client who has never upgraded reads GET → {version: null}; + // their first upgrade passes from: null to set the initial pin. + const store = buildStore(); + const app = buildApp(store); + + const res = await request(app.expressApp) + .post("/versioning") + .set("x-account-id", "acct_new") + .set("x-api-version", "2025-06-01") + .send({ from: null, to: "2024-01-01" }); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + previous_version: null, + current_version: "2024-01-01", + }); + expect(store.load("acct_new")).toBe("2024-01-01"); + }); +}); + +describe("createVersioningRoutes — construction-time guards", () => { + // Empty supportedVersions would leave `latest: supportedVersions[0]` as + // `undefined`, which fails the Zod response schema at dispatch far from + // the misconfiguration. Fail fast at construction with a clear error. + it("throws when supportedVersions is an empty array", () => { + expect(() => + createVersioningRoutes({ + identify: () => "acct", + loadVersion: () => null, + saveVersion: () => {}, + supportedVersions: [], + }), + ).toThrow(/supportedVersions.* must contain at least one/i); + }); + + it("throws when supportedVersions is missing (undefined)", () => { + expect(() => + createVersioningRoutes({ + identify: () => "acct", + loadVersion: () => null, + saveVersion: () => {}, + // @ts-expect-error — intentionally omitted to exercise the guard + supportedVersions: undefined, + }), + ).toThrow(/supportedVersions.* must contain at least one/i); + }); +}); diff --git a/tests/issue-wildcard-route-collision.test.ts b/tests/issue-wildcard-route-collision.test.ts new file mode 100644 index 0000000..cf087a4 --- /dev/null +++ b/tests/issue-wildcard-route-collision.test.ts @@ -0,0 +1,103 @@ +/** + * Regression coverage for the wildcard-before-literal route-collision + * pattern — `path-to-regexp` matches first-registered-wins, so + * `GET /widgets/:id` before sibling literal `GET /widgets/archived` + * silently steals the literal's traffic. + * + * tsadwyn now detects this at generation time via `detectRouteShadows` + * (policy: `onRouteShadowing: 'warn' | 'throw' | 'silent'`). This file + * locks in the detection behavior: a warning is emitted naming both + * colliding routes, or the routes are reordered such that the literal + * endpoint becomes reachable. + * + * Run: npx vitest run tests/issue-wildcard-route-collision.test.ts + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import request from "supertest"; +import { z } from "zod"; + +import { + Tsadwyn, + Version, + VersionBundle, + VersionedRouter, +} from "../src/index.js"; + +const ItemResp = z + .object({ id: z.string() }) + .named("IssueWildcard_Item"); +const ListResp = z + .object({ widgets: z.array(z.string()) }) + .named("IssueWildcard_List"); + +describe("Issue: wildcard route shadows later sibling literal", () => { + let warnSpy: ReturnType; + + beforeEach(() => { + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + it("either warns at registration OR auto-sorts so the literal is reachable", async () => { + const router = new VersionedRouter(); + const idParams = z.object({ id: z.string().uuid("Invalid ID format") }); + + // Wildcard registered FIRST with a strict validator middleware. + router.get( + "/widgets/:id", + null, + ItemResp, + async (req: any) => ({ id: req.params.id }), + { + middleware: [ + (req, res, next) => { + const r = idParams.safeParse(req.params); + if (!r.success) { + return res.status(400).json({ + error: r.error.issues[0]?.message ?? "invalid", + }); + } + next(); + }, + ], + }, + ); + + // Literal registered SECOND. + router.get("/widgets/archived", null, ListResp, async () => ({ + widgets: [], + })); + + const app = new Tsadwyn({ + versions: new VersionBundle(new Version("2026-04-01")), + }); + app.generateAndIncludeVersionedRouters(router); + + // EITHER (1) a warning was emitted naming both colliding routes… + const warningEmitted = warnSpy.mock.calls.some((args) => + args.some( + (a) => + typeof a === "string" && + /widgets\/:id/.test(a) && + /widgets\/archived/.test(a), + ), + ); + + // …OR (2) the literal is reachable (auto-sorted). + const res = await request(app.expressApp) + .get("/widgets/archived") + .set("x-api-version", "2026-04-01"); + const literalReachable = res.status === 200 && Array.isArray(res.body?.widgets); + + expect( + warningEmitted || literalReachable, + `Expected either a registration-time warning naming both colliding routes ` + + `(/widgets/:id and /widgets/archived) OR the literal route to be reachable ` + + `via auto-sort. Neither happened. Got status=${res.status}, body=${JSON.stringify(res.body)}, ` + + `warn calls=${JSON.stringify(warnSpy.mock.calls)}`, + ).toBe(true); + }); +}); diff --git a/tests/migration-coverage.test.ts b/tests/migration-coverage.test.ts index 1b86ccc..8547118 100644 --- a/tests/migration-coverage.test.ts +++ b/tests/migration-coverage.test.ts @@ -734,8 +734,9 @@ describe("Section 5: HTTP error migration", () => { class Change extends VersionChange { description = "should not run on error bodies"; instructions: any[] = []; - // Defaults to migrateHttpErrors: false - migrateRes = convertResponseToPreviousVersionFor(R)( + // Default is TRUE now (Stripe semantics). Opt out explicitly to + // preserve the success-only scope this test is asserting. + migrateRes = convertResponseToPreviousVersionFor(R, { migrateHttpErrors: false })( (res: ResponseInfo) => { if (res.body && typeof res.body === "object") { res.body.should_not_appear = true; diff --git a/tests/response-types.test.ts b/tests/response-types.test.ts index bd85c20..28de230 100644 --- a/tests/response-types.test.ts +++ b/tests/response-types.test.ts @@ -232,7 +232,9 @@ describe("T-1900: HttpError response migration", () => { description = "Does NOT migrate error responses"; instructions: any[] = []; - @convertResponseToPreviousVersionFor(SkipRes) + // Explicit opt-out: default is TRUE now (Stripe semantics). Pass + // false to preserve the success-only scope this test asserts. + @convertResponseToPreviousVersionFor(SkipRes, { migrateHttpErrors: false }) migrateRes(response: ResponseInfo) { response.body.extra = "should_not_appear"; } diff --git a/tests/reviewer-findings.test.ts b/tests/reviewer-findings.test.ts new file mode 100644 index 0000000..01e4a33 --- /dev/null +++ b/tests/reviewer-findings.test.ts @@ -0,0 +1,517 @@ +/** + * Failing tests derived from the 2026-04 expert code review of PR #4. + * + * Each `describe` block corresponds to one review finding. Tests are + * EXPECTED TO FAIL against HEAD — they lock in the gap so the bug can't + * quietly re-emerge after the fix lands. + * + * Status legend in comments: + * 🔴 HIGH — real correctness bug, merge-blocking + * 🟡 MEDIUM — design hazard, worth fixing before v0.2 + * + * Each test file-level comment names the file + line of the bug so the + * reviewer / future-maintainer can correlate failing assertion → code. + * + * Run: npx vitest run tests/reviewer-findings.test.ts + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import request from "supertest"; +import { z } from "zod"; + +import { + Tsadwyn, + Version, + VersionBundle, + VersionChange, + VersionedRouter, + ResponseInfo, + convertResponseToPreviousVersionFor, + migratePayloadToVersion, + cachedPerClientDefaultVersion, + currentRequest, + raw, +} from "../src/index.js"; + +// ──────────────────────────────────────────────────────────────────────────── +// 🔴 HIGH #1 — onStartup async rejection is silently swallowed +// Location: src/application.ts:666 +// Bug: `this._onStartup()` is called without a `.catch` handler, so a +// rejecting async onStartup becomes an unhandled Promise rejection → Node +// 20+ terminates the process. `onShutdown` handles this correctly (441-445) +// but `onStartup` does not. +// ──────────────────────────────────────────────────────────────────────────── +describe("Finding #1 (HIGH): onStartup async rejection handling", () => { + let unhandled: unknown[]; + const handler = (reason: unknown) => { + unhandled.push(reason); + }; + + beforeEach(() => { + unhandled = []; + process.on("unhandledRejection", handler); + }); + + afterEach(() => { + process.off("unhandledRejection", handler); + }); + + it("does not produce an unhandled rejection when onStartup rejects", async () => { + const app = new Tsadwyn({ + versions: new VersionBundle(new Version("2024-01-01")), + onStartup: async () => { + throw new Error("startup-failed-intentionally"); + }, + }); + const router = new VersionedRouter(); + router.get("/x", null, null, async () => ({ ok: true })); + app.generateAndIncludeVersionedRouters(router); + + // Let the microtask queue drain so the rejection lands either on the + // process hook (broken) or on an internal .catch (fixed). + await new Promise((r) => setImmediate(r)); + await new Promise((r) => setImmediate(r)); + + const startupFailures = unhandled.filter( + (r) => r instanceof Error && r.message === "startup-failed-intentionally", + ); + expect(startupFailures).toHaveLength(0); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// 🔴 HIGH #2 — migratePayloadToVersion skips path-based response migrations +// Location: src/migrate-payload.ts:55-62 (pre-fix) +// Bug: the function only iterated `_alterResponseBySchemaInstructions`. +// Any migration registered via `convertResponseToPreviousVersionFor(path, +// methods)` was silently skipped when a consumer called migratePayloadToVersion +// for outbound webhooks/events — resulting in unmigrated payloads reaching +// older clients with no error. +// +// Fix: add optional `opts.path` + `opts.methods` so callers can address +// path-based migrations. Path-based migrations are opt-in (the function +// doesn't know which path a raw payload would have come from unless told). +// Without opts.path, the old schema-only behavior is preserved and +// documented. +// ──────────────────────────────────────────────────────────────────────────── +describe("Finding #2 (HIGH): migratePayloadToVersion applies path-based migrations", () => { + // Shared setup extracted so both branches exercise the same VersionBundle + // + migration registration. Declaring the classes inside the outer scope + // keeps tsadwyn's "VersionChange is bound to one bundle for life" (T-1602) + // contract intact — one bundle, shared across describes in one test run. + const WebhookEvent = z + .object({ + id: z.string(), + event_type: z.string(), + amount: z.number(), + }) + .named("Finding2_WebhookEvent"); + + class RenameEventType extends VersionChange { + description = "renames event_type → type on older version (path-based)"; + instructions = []; + + // Path-based registration: consumer keyed on the route, not the schema. + migrateWebhook = convertResponseToPreviousVersionFor("/webhooks/events", ["POST"])( + (response: ResponseInfo) => { + if (response.body && typeof response.body === "object") { + response.body.type = response.body.event_type; + delete response.body.event_type; + } + }, + ); + } + + const router = new VersionedRouter(); + router.post("/webhooks/events", WebhookEvent, WebhookEvent, async () => ({ + id: "evt_1", + event_type: "charge.succeeded", + amount: 100, + })); + + const versions = new VersionBundle( + new Version("2025-06-01", RenameEventType), + new Version("2024-01-01"), + ); + + const head = () => ({ + id: "evt_1", + event_type: "charge.succeeded", + amount: 100, + }); + + it("applies the path-based migration when opts.path is supplied", () => { + const migrated = migratePayloadToVersion( + "Finding2_WebhookEvent", + head(), + "2024-01-01", + versions, + { path: "/webhooks/events", methods: ["POST"] }, + ); + + expect(migrated).toEqual({ + id: "evt_1", + type: "charge.succeeded", + amount: 100, + }); + }); + + it("skips path-based migrations when opts.path is omitted (documented behavior)", () => { + // Without opts.path, the function has no way to know which path-based + // migrations apply to the caller's raw payload, so it skips them. + // Callers who need path-based migrations must address them explicitly. + const migrated = migratePayloadToVersion( + "Finding2_WebhookEvent", + head(), + "2024-01-01", + versions, + ); + + // Unchanged — path-based migration was not addressed. + expect(migrated).toEqual({ + id: "evt_1", + event_type: "charge.succeeded", + amount: 100, + }); + }); + + it("methods filter excludes non-matching methods on path-based migrations", () => { + // RenameEventType registered for ['POST']. Asking for GET → no match. + const migrated = migratePayloadToVersion( + "Finding2_WebhookEvent", + head(), + "2024-01-01", + versions, + { path: "/webhooks/events", methods: ["GET"] }, + ); + expect(migrated).toEqual({ + id: "evt_1", + event_type: "charge.succeeded", + amount: 100, + }); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// 🟡 MEDIUM #3 — HEAD requests receive bodies on non-JSON / string responses +// Location: src/route-generation.ts:1215-1252 +// Bug: the `isHead = req.method === "HEAD"` guard is computed at line 1265, +// AFTER the non-JSON (Buffer/Readable) branch at 1215 and the string branch +// at 1221. Those branches call sendNonJsonResponse/res.end unconditionally, +// violating RFC 7231 §4.3.2 ("the server MUST NOT send a message body in +// the response"). The JSON path at 1350 correctly suppresses via isHead. +// ──────────────────────────────────────────────────────────────────────────── +describe("Finding #3 (MEDIUM): HEAD requests do not send a body on non-JSON responses", () => { + // NOTE: Node's HTTP writer strips bodies from HEAD responses at the + // wire level — so the bug is NOT directly observable at the client. + // The fix is about app-level correctness: tsadwyn should not WRITE + // body bytes into the socket knowing they'll be discarded (wasted + // work on large Buffers) and should not leak body bytes into any + // logging / middleware that wraps res.end. We test by spying on + // res.end's arguments to verify nothing body-like was written. + function appWithEndSpy(register: (router: VersionedRouter) => void) { + const endCalls: Array<{ method: string; url: string; args: any[] }> = []; + const app = new Tsadwyn({ + versions: new VersionBundle(new Version("2024-01-01")), + preVersionPick: (req, res, next) => { + const originalEnd = res.end.bind(res); + (res as any).end = function (...args: any[]) { + endCalls.push({ method: req.method, url: req.url, args }); + return (originalEnd as any)(...args); + }; + next(); + }, + }); + const router = new VersionedRouter(); + register(router); + app.generateAndIncludeVersionedRouters(router); + return { app, endCalls }; + } + + it("Buffer response on HEAD: res.end is NOT called with the buffer content", async () => { + const { app, endCalls } = appWithEndSpy((router) => { + router.head( + "/download", + null, + raw({ mimeType: "application/octet-stream" }), + async () => Buffer.from("secret-payload-should-not-ship", "utf-8"), + ); + }); + + const res = await request(app.expressApp) + .head("/download") + .set("x-api-version", "2024-01-01"); + + expect(res.status).toBe(200); + // Content-length header preserves the would-be length so HEAD + // probes carry the size metadata. + expect(res.headers["content-length"]).toBeDefined(); + + // The HEAD request should have invoked res.end() with NO body argument. + const headEndCalls = endCalls.filter((c) => c.method === "HEAD"); + expect(headEndCalls.length).toBeGreaterThan(0); + for (const call of headEndCalls) { + // Before the fix: args[0] is the Buffer("secret-payload-..."). After the + // fix: args is empty or args[0] is undefined. + const arg0 = call.args[0]; + if (arg0 !== undefined) { + if (Buffer.isBuffer(arg0)) { + expect(arg0.length).toBe(0); + } else if (typeof arg0 === "string") { + expect(arg0).toBe(""); + } + } + } + }); + + it("string response on HEAD: res.end is NOT called with the string content", async () => { + const { app, endCalls } = appWithEndSpy((router) => { + router.head( + "/text", + null, + null, + async () => "text-payload-should-not-ship", + ); + }); + + const res = await request(app.expressApp) + .head("/text") + .set("x-api-version", "2024-01-01"); + + expect(res.status).toBe(200); + + const headEndCalls = endCalls.filter((c) => c.method === "HEAD"); + expect(headEndCalls.length).toBeGreaterThan(0); + for (const call of headEndCalls) { + const arg0 = call.args[0]; + if (arg0 !== undefined) { + expect(arg0 === "" || (Buffer.isBuffer(arg0) && arg0.length === 0)).toBe( + true, + ); + } + } + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// 🟡 MEDIUM #4 — onUnsupportedVersion / versionPickingLogger not forwarded +// Locations: +// src/middleware.ts:41 (defines VersionPickingOptions.onUnsupportedVersion) +// src/application.ts:418-424 (pickingOpts build; doesn't copy the field) +// Bug: Consumers using `new Tsadwyn({...})` cannot configure the policy. +// The option only works if they opt into `versioningMiddleware` override, +// which forces them to re-implement header extraction, default resolution, +// and apiVersionStorage scoping. +// ──────────────────────────────────────────────────────────────────────────── +describe("Finding #4 (MEDIUM): onUnsupportedVersion wired through TsadwynOptions", () => { + it("TsadwynOptions.onUnsupportedVersion='reject' produces a structured 400", async () => { + const app = new Tsadwyn({ + versions: new VersionBundle(new Version("2024-01-01")), + onUnsupportedVersion: "reject", + }); + const router = new VersionedRouter(); + router.get("/ping", null, null, async () => ({ ok: true })); + app.generateAndIncludeVersionedRouters(router); + + const res = await request(app.expressApp) + .get("/ping") + .set("x-api-version", "9999-99-99"); + + // Pre-fix: dispatcher returns 422 because the option was ignored. + // Post-fix: the middleware's `reject` policy fires → 400 with + // a structured body per `src/middleware.ts:134-141`. + expect(res.status).toBe(400); + expect(res.body).toEqual({ + error: "unsupported_api_version", + sent: "9999-99-99", + supported: ["2024-01-01"], + }); + }); + + it("onUnsupportedVersion='fallback' substitutes default + calls versionPickingLogger", async () => { + const warn = vi.fn(); + const app = new Tsadwyn({ + versions: new VersionBundle(new Version("2024-01-01")), + apiVersionDefaultValue: "2024-01-01", + onUnsupportedVersion: "fallback", + versionPickingLogger: { warn }, + }); + const router = new VersionedRouter(); + router.get("/ping", null, null, async () => ({ ok: true })); + app.generateAndIncludeVersionedRouters(router); + + const res = await request(app.expressApp) + .get("/ping") + .set("x-api-version", "9999-99-99"); + + expect(res.status).toBe(200); + expect(warn).toHaveBeenCalledWith( + expect.objectContaining({ + sent: "9999-99-99", + supported: ["2024-01-01"], + }), + expect.any(String), + ); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// 🟡 MEDIUM #5 — SIGTERM/SIGINT listener accumulation +// Location: src/application.ts:439-440 +// Bug: Every Tsadwyn instance with an onShutdown hook registers a permanent +// listener. After ~11 instances Node emits MaxListenersExceededWarning. In +// test suites (where many apps are constructed) every SIGTERM triggers all +// accumulated handlers and calls process.exit(0), which can mask failures. +// ──────────────────────────────────────────────────────────────────────────── +describe("Finding #5 (MEDIUM): SIGTERM listeners do not accumulate per instance", () => { + it("constructing 15 Tsadwyn instances does not add 15 SIGTERM listeners", () => { + const before = process.listenerCount("SIGTERM"); + + const apps: Tsadwyn[] = []; + for (let i = 0; i < 15; i++) { + apps.push( + new Tsadwyn({ + versions: new VersionBundle(new Version("2024-01-01")), + onShutdown: () => {}, + }), + ); + } + + const after = process.listenerCount("SIGTERM"); + // Fix uses a single module-scoped listener shared across instances. + // Delta should stay ≤ 1, NOT scale with instance count. + expect(after - before).toBeLessThanOrEqual(1); + + // Clean up so test teardowns don't leave shutdown hooks registered + // across the rest of the suite. + apps.forEach((a) => a.close()); + }); + + it("close() removes the instance from the shared shutdown registry", () => { + // This is testable only through observable effects — the internal + // Set is module-private. Proxy: after close(), subsequent + // constructions still work and don't complain about double-registration. + const app = new Tsadwyn({ + versions: new VersionBundle(new Version("2024-01-01")), + onShutdown: () => {}, + }); + // Calling close() once unregisters; calling it twice is a safe no-op. + expect(() => { + app.close(); + app.close(); + }).not.toThrow(); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// 🟡 MEDIUM #6 — _buildRegistryFromRoutes reads `._tsadwynName` directly +// Location: src/application.ts :: _buildRegistryFromRoutes +// Bug: the method checks `(schema as any)._tsadwynName` directly, despite +// CLAUDE.md's explicit "use `getSchemaName` / `setSchemaName` rather than +// reading `._tsadwynName` directly" rule. Today this works because +// setSchemaName writes BOTH the WeakMap AND the legacy property — but if +// either (a) the WeakMap-only path is ever exercised, or (b) the legacy +// property is cleared by a downstream consumer (e.g., schema cloning +// that drops non-enumerable props), the registry silently drops the +// schema and OpenAPI output gets broken $refs. +// +// Test: simulate the WeakMap-only path by deleting the legacy property +// AFTER `.named()` set it. `getSchemaName()` falls back to the WeakMap; +// direct `._tsadwynName` access sees `undefined` and the schema is +// skipped. The OpenAPI output should still include the named schema. +// ──────────────────────────────────────────────────────────────────────────── +describe("Finding #6 (MEDIUM): _buildRegistryFromRoutes uses getSchemaName, not direct property", () => { + it("includes the schema in OpenAPI components when ._tsadwynName is absent but WeakMap has it", () => { + const MySchema = z + .object({ x: z.string() }) + .named("Finding6_MySchema"); + + // Simulate the WeakMap-only path: the name was set via the canonical + // API (so it's in the WeakMap), but a downstream transform or serializer + // cleared the legacy property. With direct `._tsadwynName` access the + // schema is dropped; with `getSchemaName()` it's found via the WeakMap. + delete (MySchema as unknown as Record)._tsadwynName; + + const router = new VersionedRouter(); + router.get("/x", null, MySchema, async () => ({ x: "ok" })); + + const app = new Tsadwyn({ + versions: new VersionBundle(new Version("2024-01-01")), + }); + app.generateAndIncludeVersionedRouters(router); + + const doc = app.openapi("2024-01-01"); + expect(doc.components?.schemas).toBeDefined(); + expect(Object.keys(doc.components!.schemas!)).toContain("Finding6_MySchema"); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// 🟡 MEDIUM #7 — currentRequest() silently broken on unversioned routes +// Location: src/application.ts `_wrapHandlerWithOverrides` (lines 586-608) +// Bug: Versioned route dispatch wraps the handler body in +// requestContextStorage.run(req, ...). Unversioned routes go through +// `_wrapHandlerWithOverrides`, which calls `handler(handlerReq)` directly — +// no ALS scope. A handler or service helper that calls `currentRequest()` +// throws "called outside a tsadwyn handler scope". +// ──────────────────────────────────────────────────────────────────────────── +describe("Finding #7 (MEDIUM): currentRequest() works on unversioned routes", () => { + it("handler on app.unversionedRouter can call currentRequest() without error", async () => { + const app = new Tsadwyn({ + versions: new VersionBundle(new Version("2024-01-01")), + preVersionPick: (req, _res, next) => { + (req as any).user = { id: "unversioned_user" }; + next(); + }, + }); + + app.unversionedRouter.get("/health", null, null, async () => { + // This is the whole point of currentRequest(): recover middleware- + // injected state from inside the stripped handler view. + const req = currentRequest(); + return { user: (req as any).user?.id ?? "missing" }; + }); + + // Register an empty versioned router so generation runs. + const versionedRouter = new VersionedRouter(); + versionedRouter.get("/_placeholder", null, null, async () => ({})); + app.generateAndIncludeVersionedRouters(versionedRouter); + + const res = await request(app.expressApp).get("/health"); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ user: "unversioned_user" }); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// 🟡 MEDIUM #8 — onStalePin='reject' per-call retry count is untested +// Location: src/cached-per-client-default.ts (rejection-bypass semantics) +// Documented contract: "Errors bypass the cache — the next request retries +// fresh." The code does this correctly (rejections delete the cache entry). +// But no test locks in the per-call count, so a future author could cache +// rejections (e.g., for back-off) without any test catching the regression. +// This adds the assertion. +// ──────────────────────────────────────────────────────────────────────────── +describe("Finding #8 (MEDIUM): onStalePin='reject' retries resolvePin on every call", () => { + it("resolvePin is invoked once per request (not cached)", async () => { + const resolvePin = vi.fn(async () => "ancient-version"); + const { resolver } = cachedPerClientDefaultVersion({ + identify: (req: any) => req.__clientId, + resolvePin, + fallback: "2024-01-01", + supportedVersions: ["2024-01-01"], + onStalePin: "reject", + }); + + const fakeReq = (id: string) => + ({ headers: {}, ["__clientId"]: id }) as unknown as import("express").Request; + + // Each call should reject with the stale-pin error AND re-hit resolvePin. + for (let i = 0; i < 3; i++) { + await expect(resolver(fakeReq("client_1"))).rejects.toThrow( + /not in the current VersionBundle/i, + ); + } + expect(resolvePin).toHaveBeenCalledTimes(3); + }); +}); diff --git a/tests/route-shadowing.test.ts b/tests/route-shadowing.test.ts new file mode 100644 index 0000000..c5a094d --- /dev/null +++ b/tests/route-shadowing.test.ts @@ -0,0 +1,176 @@ +/** + * Tests for the route-shadowing diagnostic. + * + * Covers: + * - :id registered before literal → warn/throw (depending on policy). + * - literal registered before :id → no shadow (correct order). + * - Different methods: GET :id + POST literal → no shadow. + * - Constrained param :id(\\d+) → still flagged (conservative heuristic). + * - Wildcard * → flagged. + * - Policy: 'silent' emits nothing; 'throw' raises; 'warn' logs once per shadow. + * - detectRouteShadows returns the pair list without side effects. + */ +import { describe, it, expect, vi } from "vitest"; +import { z } from "zod"; + +import { + Tsadwyn, + Version, + VersionBundle, + VersionedRouter, + detectRouteShadows, + TsadwynStructureError, +} from "../src/index.js"; + +const Body = z.object({ ok: z.boolean() }).named("Shadowing_Body"); + +function makeRouter(routes: Array<[method: "get" | "post", path: string]>): VersionedRouter { + const r = new VersionedRouter(); + for (const [method, path] of routes) { + if (method === "get") r.get(path, null, Body, async () => ({ ok: true })); + else r.post(path, null, Body, async () => ({ ok: true })); + } + return r; +} + +describe("detectRouteShadows (pure)", () => { + it("flags :id before literal (same method)", () => { + const r = makeRouter([ + ["get", "/users/:id"], + ["get", "/users/search"], + ]); + const shadows = detectRouteShadows(r.routes); + expect(shadows).toHaveLength(1); + expect(shadows[0]).toEqual({ + shadower: { method: "GET", path: "/users/:id" }, + shadowed: { method: "GET", path: "/users/search" }, + }); + }); + + it("does NOT flag literal before :id (correct registration order)", () => { + const r = makeRouter([ + ["get", "/users/search"], + ["get", "/users/:id"], + ]); + expect(detectRouteShadows(r.routes)).toEqual([]); + }); + + it("does NOT flag across different methods", () => { + const r = makeRouter([ + ["get", "/users/:id"], + ["post", "/users/search"], + ]); + expect(detectRouteShadows(r.routes)).toEqual([]); + }); + + it("flags wildcard routes (* segment)", () => { + const r = makeRouter([ + ["get", "/files/*"], + ["get", "/files/index"], + ]); + const shadows = detectRouteShadows(r.routes); + expect(shadows).toHaveLength(1); + expect(shadows[0].shadower.path).toBe("/files/*"); + expect(shadows[0].shadowed.path).toBe("/files/index"); + }); + + it("flags constrained params (:id(\\d+)) conservatively", () => { + const r = makeRouter([ + ["get", "/users/:id(\\d+)"], + ["get", "/users/search"], + ]); + const shadows = detectRouteShadows(r.routes); + // Even though \d+ wouldn't actually match 'search' at runtime, our + // heuristic treats the param segment as a catch-all for safety. + expect(shadows).toHaveLength(1); + }); + + it("catches transitive shadows (multiple earlier catchers)", () => { + const r = makeRouter([ + ["get", "/a/:x"], + ["get", "/a/:y"], // duplicate wildcard — ignored for shadow purposes + ["get", "/a/literal"], // both earlier :x and :y shadow this + ]); + const shadows = detectRouteShadows(r.routes); + const shadowerPaths = shadows.map((s) => s.shadower.path); + expect(shadowerPaths).toContain("/a/:x"); + expect(shadowerPaths).toContain("/a/:y"); + }); + + it("two fully-literal routes are never flagged", () => { + const r = makeRouter([ + ["get", "/a"], + ["get", "/b"], + ]); + expect(detectRouteShadows(r.routes)).toEqual([]); + }); +}); + +describe("Tsadwyn application integration", () => { + it("default policy 'warn' logs via supplied logger", () => { + const warn = vi.fn(); + const router = makeRouter([ + ["get", "/users/:id"], + ["get", "/users/search"], + ]); + const app = new Tsadwyn({ + versions: new VersionBundle(new Version("2024-01-01")), + routeShadowingLogger: { warn }, + }); + app.generateAndIncludeVersionedRouters(router); + + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith( + expect.objectContaining({ + shadower: { method: "GET", path: "/users/:id" }, + shadowed: { method: "GET", path: "/users/search" }, + }), + expect.stringMatching(/shadows/), + ); + }); + + it("policy 'throw' surfaces TsadwynStructureError", () => { + const router = makeRouter([ + ["get", "/users/:id"], + ["get", "/users/search"], + ]); + const app = new Tsadwyn({ + versions: new VersionBundle(new Version("2024-01-01")), + onRouteShadowing: "throw", + }); + expect(() => app.generateAndIncludeVersionedRouters(router)).toThrow( + TsadwynStructureError, + ); + }); + + it("policy 'silent' emits nothing", () => { + const warn = vi.fn(); + const router = makeRouter([ + ["get", "/users/:id"], + ["get", "/users/search"], + ]); + const app = new Tsadwyn({ + versions: new VersionBundle(new Version("2024-01-01")), + onRouteShadowing: "silent", + routeShadowingLogger: { warn }, + }); + app.generateAndIncludeVersionedRouters(router); + + expect(warn).not.toHaveBeenCalled(); + }); + + it("clean registration order does not fire diagnostic", () => { + const warn = vi.fn(); + const router = makeRouter([ + ["get", "/users/search"], + ["get", "/users/:id"], + ]); + const app = new Tsadwyn({ + versions: new VersionBundle(new Version("2024-01-01")), + routeShadowingLogger: { warn }, + }); + app.generateAndIncludeVersionedRouters(router); + + expect(warn).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/versioned-behavior.test.ts b/tests/versioned-behavior.test.ts new file mode 100644 index 0000000..05cd109 --- /dev/null +++ b/tests/versioned-behavior.test.ts @@ -0,0 +1,348 @@ +/** + * Tests for `createVersionedBehavior` — typed overlay primitive. + * + * Covers: + * - Two-version (ares-monolith style): HEAD + INITIAL, all changes collapse. + * - Three-version: intermediate snapshot correctly reflects partial overlay. + * - .at() throws on unknown version. + * - .get() returns fallback for unknown version in apiVersionStorage. + * - Duplicate changes at same version: partials merge, conflicting keys warn. + * - Change at initialVersion throws (floor cannot introduce a change). + * - Custom compare function (semver/custom format). + * - Empty changes: map is empty; .get() falls back; .at() throws. + * - onUnknown: 'warn-once' logs exactly once per unknown version. + */ +import { describe, it, expect, vi } from "vitest"; +import { + createVersionedBehavior, + apiVersionStorage, +} from "../src/index.js"; + +interface Behavior { + requireIdempotencyKey: boolean; + rateLimitPerSec: number; + errorShape: "flat" | "rfc7807"; +} + +const HEAD: Behavior = { + requireIdempotencyKey: true, + rateLimitPerSec: 1000, + errorShape: "rfc7807", +}; + +describe("createVersionedBehavior — two-version collapse", () => { + it("maps HEAD to HEAD_BEHAVIOR and INITIAL to HEAD + all behaviorHad", () => { + const behavior = createVersionedBehavior({ + head: HEAD, + initialVersion: "2024-01-01", + changes: [ + { + version: "2025-06-01", + description: "idem now required at head", + behaviorHad: { requireIdempotencyKey: false }, + }, + { + version: "2025-06-01", + description: "rate limit bumped", + behaviorHad: { rateLimitPerSec: 100 }, + }, + { + version: "2025-06-01", + description: "error shape switched", + behaviorHad: { errorShape: "flat" }, + }, + ], + }); + + expect(behavior.at("2025-06-01")).toEqual(HEAD); + expect(behavior.at("2024-01-01")).toEqual({ + requireIdempotencyKey: false, + rateLimitPerSec: 100, + errorShape: "flat", + }); + }); +}); + +describe("createVersionedBehavior — multi-version interpolation", () => { + it("intermediate version sees only changes newer than it", () => { + const behavior = createVersionedBehavior({ + head: HEAD, + initialVersion: "2024-01-01", + changes: [ + { version: "2026-04-14", behaviorHad: { requireIdempotencyKey: false } }, + { version: "2025-06-01", behaviorHad: { rateLimitPerSec: 100 } }, + { version: "2025-01-01", behaviorHad: { errorShape: "flat" } }, + ], + }); + + // At 2026-04-14 (newest): no change is newer → pure HEAD. + expect(behavior.at("2026-04-14")).toEqual(HEAD); + + // At 2025-06-01: the 2026-04-14 change is newer → idem rolled back. + expect(behavior.at("2025-06-01")).toEqual({ + requireIdempotencyKey: false, + rateLimitPerSec: 1000, + errorShape: "rfc7807", + }); + + // At 2025-01-01: both 2026-04-14 and 2025-06-01 are newer → idem + rate rolled back. + expect(behavior.at("2025-01-01")).toEqual({ + requireIdempotencyKey: false, + rateLimitPerSec: 100, + errorShape: "rfc7807", + }); + + // At 2024-01-01 (initial): every change is newer → everything rolled back. + expect(behavior.at("2024-01-01")).toEqual({ + requireIdempotencyKey: false, + rateLimitPerSec: 100, + errorShape: "flat", + }); + }); + + it("exposes the snapshot map for changelog/admin UIs", () => { + const behavior = createVersionedBehavior({ + head: HEAD, + initialVersion: "2024-01-01", + changes: [{ version: "2025-06-01", behaviorHad: { rateLimitPerSec: 100 } }], + }); + + const keys = [...behavior.map.keys()]; + expect(keys).toContain("2025-06-01"); + expect(keys).toContain("2024-01-01"); + expect(behavior.map.size).toBe(2); + }); +}); + +describe("createVersionedBehavior — .get() via apiVersionStorage", () => { + it("resolves from the ALS-stored version", () => { + const behavior = createVersionedBehavior({ + head: HEAD, + initialVersion: "2024-01-01", + changes: [{ version: "2025-06-01", behaviorHad: { rateLimitPerSec: 100 } }], + }); + + apiVersionStorage.run("2024-01-01", () => { + expect(behavior.get().rateLimitPerSec).toBe(100); + }); + apiVersionStorage.run("2025-06-01", () => { + expect(behavior.get().rateLimitPerSec).toBe(1000); + }); + }); + + it("returns fallback when the version is unknown", () => { + const fallback: Behavior = { + requireIdempotencyKey: true, + rateLimitPerSec: 42, + errorShape: "flat", + }; + const behavior = createVersionedBehavior({ + head: HEAD, + fallback, + initialVersion: "2024-01-01", + changes: [{ version: "2025-06-01", behaviorHad: { rateLimitPerSec: 100 } }], + }); + + apiVersionStorage.run("1999-01-01", () => { + expect(behavior.get()).toEqual(fallback); + }); + }); + + it("returns fallback (head by default) when ALS has no version", () => { + const behavior = createVersionedBehavior({ + head: HEAD, + initialVersion: "2024-01-01", + changes: [{ version: "2025-06-01", behaviorHad: { rateLimitPerSec: 100 } }], + }); + + expect(behavior.get()).toEqual(HEAD); + }); +}); + +describe("createVersionedBehavior — errors and edges", () => { + it(".at() throws on unknown version", () => { + const behavior = createVersionedBehavior({ + head: HEAD, + initialVersion: "2024-01-01", + changes: [{ version: "2025-06-01", behaviorHad: { rateLimitPerSec: 100 } }], + }); + + expect(() => behavior.at("not-a-version")).toThrow(/unknown version/i); + }); + + it("rejects a change whose version equals initialVersion", () => { + expect(() => + createVersionedBehavior({ + head: HEAD, + initialVersion: "2024-01-01", + changes: [{ version: "2024-01-01", behaviorHad: { rateLimitPerSec: 100 } }], + }), + ).toThrow(/matches initialVersion/i); + }); + + it("throws when head is not an object", () => { + expect(() => + createVersionedBehavior({ + // @ts-expect-error — intentionally invalid + head: null, + changes: [], + }), + ).toThrow(/must be an object/i); + }); + + it("handles empty changes list: map is empty, .get() falls back", () => { + const behavior = createVersionedBehavior({ + head: HEAD, + changes: [], + }); + + expect(behavior.map.size).toBe(0); + expect(behavior.get()).toEqual(HEAD); + expect(() => behavior.at("2024-01-01")).toThrow(/unknown version/i); + }); + + it("supports a custom compare function (semver-style)", () => { + const semverCompare = (a: string, b: string) => { + const parse = (s: string) => s.split(".").map(Number); + const [am, an, ap] = parse(a); + const [bm, bn, bp] = parse(b); + return am - bm || an - bn || ap - bp; + }; + + const behavior = createVersionedBehavior({ + head: HEAD, + initialVersion: "1.0.0", + compare: semverCompare, + changes: [ + { version: "2.0.0", behaviorHad: { requireIdempotencyKey: false } }, + { version: "1.5.0", behaviorHad: { rateLimitPerSec: 100 } }, + ], + }); + + // At 2.0.0: no change is newer → HEAD. + expect(behavior.at("2.0.0")).toEqual(HEAD); + // At 1.5.0: 2.0.0 is newer → idem rolled back. + expect(behavior.at("1.5.0").requireIdempotencyKey).toBe(false); + expect(behavior.at("1.5.0").rateLimitPerSec).toBe(1000); + // At 1.0.0: everything rolled back. + expect(behavior.at("1.0.0")).toEqual({ + requireIdempotencyKey: false, + rateLimitPerSec: 100, + errorShape: "rfc7807", + }); + }); + + it("warns when two changes at same version set a field to different values", () => { + const warn = vi.fn(); + createVersionedBehavior({ + head: HEAD, + initialVersion: "2024-01-01", + logger: { warn }, + changes: [ + { version: "2025-06-01", behaviorHad: { rateLimitPerSec: 100 } }, + { version: "2025-06-01", behaviorHad: { rateLimitPerSec: 50 } }, // conflict + ], + }); + + expect(warn).toHaveBeenCalledWith( + expect.objectContaining({ version: "2025-06-01", field: "rateLimitPerSec" }), + expect.stringMatching(/set field "rateLimitPerSec" to different values/), + ); + }); + + it("does NOT warn when same-value duplicates merge (no conflict)", () => { + const warn = vi.fn(); + createVersionedBehavior({ + head: HEAD, + initialVersion: "2024-01-01", + logger: { warn }, + changes: [ + { version: "2025-06-01", behaviorHad: { rateLimitPerSec: 100 } }, + { version: "2025-06-01", behaviorHad: { rateLimitPerSec: 100 } }, // same value + ], + }); + + expect(warn).not.toHaveBeenCalled(); + }); + + it("onUnknown: 'warn-once' logs exactly once per unique unknown version", () => { + const warn = vi.fn(); + const behavior = createVersionedBehavior({ + head: HEAD, + initialVersion: "2024-01-01", + onUnknown: "warn-once", + logger: { warn }, + changes: [{ version: "2025-06-01", behaviorHad: { rateLimitPerSec: 100 } }], + }); + + apiVersionStorage.run("ghost-1", () => behavior.get()); + apiVersionStorage.run("ghost-1", () => behavior.get()); // dedup + apiVersionStorage.run("ghost-2", () => behavior.get()); + apiVersionStorage.run("ghost-2", () => behavior.get()); // dedup + + expect(warn).toHaveBeenCalledTimes(2); + }); +}); + +describe("createVersionedBehavior — type contract", () => { + it("rejects behaviorHad fields that don't exist on head (compile-time)", () => { + // This test is more about forcing the Partial contract to be + // exercised at build time. Uncomment the block below to verify the + // type-checker catches it; kept as a ts-expect-error so the regression + // would surface as a test-file compile error. + createVersionedBehavior({ + head: HEAD, + initialVersion: "2024-01-01", + changes: [ + { + version: "2025-06-01", + // @ts-expect-error — `nonExistentField` is not on Behavior + behaviorHad: { nonExistentField: true }, + }, + ], + }); + // At runtime we don't assert here — the ts-expect-error above is the + // contract. This body is just placeholder so the test passes. + expect(true).toBe(true); + }); +}); + +describe("createVersionedBehavior — logger-required enforcement", () => { + // createVersionedBehavior delegates telemetry to buildBehaviorResolver, + // so the guard on missing-logger-with-warn-* should flow through. These + // tests lock in that delegation so a future refactor can't accidentally + // reintroduce the silent-no-op footgun. + it("throws when onUnknown is 'warn-once' and logger is missing", () => { + expect(() => + createVersionedBehavior({ + head: HEAD, + initialVersion: "2024-01-01", + changes: [{ version: "2025-06-01", behaviorHad: { rateLimitPerSec: 100 } }], + onUnknown: "warn-once", + }), + ).toThrow(/requires a logger/i); + }); + + it("throws when onUnknown is 'warn-every' and logger is missing", () => { + expect(() => + createVersionedBehavior({ + head: HEAD, + initialVersion: "2024-01-01", + changes: [{ version: "2025-06-01", behaviorHad: { rateLimitPerSec: 100 } }], + onUnknown: "warn-every", + }), + ).toThrow(/requires a logger/i); + }); + + it("does not throw when onUnknown is 'warn-once' and logger IS provided", () => { + expect(() => + createVersionedBehavior({ + head: HEAD, + initialVersion: "2024-01-01", + changes: [{ version: "2025-06-01", behaviorHad: { rateLimitPerSec: 100 } }], + onUnknown: "warn-once", + logger: { warn: () => {} }, + }), + ).not.toThrow(); + }); +});