From 209f9d85b7939af911f7d47134c0120a5b22d65e Mon Sep 17 00:00:00 2001 From: mahmoudimus Date: Thu, 16 Apr 2026 12:19:48 -0700 Subject: [PATCH 01/58] chore(gitignore): exclude local-only design specs and docs/superpowers/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-issue design specs (tsadwyn-issue-*.md), brainstorming artifacts under docs/superpowers/, and the downstream-repo consumer-integration tracking doc are kept in working dir but not tracked. The repo should contain only runnable artifacts — source, tests, config, README. Design specs have a half-life measured in weeks (the code becomes the truth once it lands) and clutter PR review; they live adjacent to the code but stay local. Matches how CLAUDE.md and _gitless/ are already handled. --- .gitignore | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.gitignore b/.gitignore index 5cc3074..8fca7be 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,12 @@ 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 From afef0784d05973a6280d57a63c1a9df6d76ba047 Mon Sep 17 00:00:00 2001 From: mahmoudimus Date: Thu, 16 Apr 2026 12:19:55 -0700 Subject: [PATCH 02/58] test(issue-error-mapper): failing tests for errorMapper option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Encodes the contract for TsadwynOptions.errorMapper (maps domain exceptions → HttpError so response migrations apply): - mapper returns HttpError → handler returns that status + body - mapper returns null → existing next(err) behavior - mapped HttpError flows through migrateHttpErrors: true migrations - a throwing mapper doesn't crash — tsadwyn returns 500 Paired with local design doc (not committed per repo convention). --- tests/issue-error-mapper.test.ts | 212 +++++++++++++++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 tests/issue-error-mapper.test.ts diff --git a/tests/issue-error-mapper.test.ts b/tests/issue-error-mapper.test.ts new file mode 100644 index 0000000..2788800 --- /dev/null +++ b/tests/issue-error-mapper.test.ts @@ -0,0 +1,212 @@ +/** + * FAILING TEST — verifies the gap described in tsadwyn-issue-error-mapper.md + * + * Today, when a handler throws a domain exception that doesn't carry a + * `statusCode` property, tsadwyn's `_isHttpLikeError()` check fails and the + * error escapes via `next(err)` to Express's default error handler. That + * bypasses the response-migration pipeline entirely and forces consumers to + * couple their domain layer to tsadwyn's internal detection. + * + * The proposed fix adds an `errorMapper` option on `TsadwynOptions` — a pure + * function `(err: unknown) => HttpError | null` invoked inside the handler's + * catch block before `_isHttpLikeError`. When it returns an `HttpError`, the + * existing migration / status / header machinery picks up. When it returns + * `null`, current behavior (`next(err)`) is preserved. + * + * These tests will turn green when: + * 1. `TsadwynOptions.errorMapper` is accepted at 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 + * + * 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); + }); +}); From 0047b2b7c639a27e92a92e0a6563005376013973 Mon Sep 17 00:00:00 2001 From: mahmoudimus Date: Thu, 16 Apr 2026 12:19:59 -0700 Subject: [PATCH 03/58] test(issue-wildcard-route-collision): failing test for ambiguous route ordering Registers /widgets/:id with a UUID validator then /widgets/archived. On main today the wildcard shadows the literal silently. Test passes when EITHER a registration-time warning is emitted naming both routes OR routes are auto-sorted so the literal is reachable. Paired with local design doc (not committed per repo convention). --- tests/issue-wildcard-route-collision.test.ts | 109 +++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 tests/issue-wildcard-route-collision.test.ts diff --git a/tests/issue-wildcard-route-collision.test.ts b/tests/issue-wildcard-route-collision.test.ts new file mode 100644 index 0000000..c2fed2f --- /dev/null +++ b/tests/issue-wildcard-route-collision.test.ts @@ -0,0 +1,109 @@ +/** + * FAILING TEST — verifies the gap described in tsadwyn-issue-wildcard-route-collision.md + * + * path-to-regexp matches first-registered-wins. If `GET /widgets/:id` is + * registered before sibling literal `GET /widgets/archived`, the wildcard + * captures `:id = "archived"` and any UUID validator middleware on the + * wildcard 400s the request — the literal handler never runs. + * + * tsadwyn does not warn at registration time and does not auto-sort. The bug + * is only visible the first time the literal endpoint is exercised against + * a real client. + * + * Acceptable resolutions (the test passes if EITHER holds): + * 1. A warning is emitted at `generateAndIncludeVersionedRouters()` / + * `generateVersionedRouters()` time naming both colliding routes. + * 2. Routes are auto-sorted so literals precede wildcard siblings, and + * the literal endpoint is 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); + }); +}); From d29f60e3111a48b7d16bcc36037be45e6083a783 Mon Sep 17 00:00:00 2001 From: mahmoudimus Date: Thu, 16 Apr 2026 12:20:02 -0700 Subject: [PATCH 04/58] test(issue-build-behavior-resolver): failing tests for behavior-map helper buildBehaviorResolver(map, fallback, opts) standardizes the per-version behavior-flag lookup that every tsadwyn adopter hand-rolls. Tests cover known-version lookup, unknown-version fallback, warn-once / warn-every / silent telemetry modes, and the supportedVersions context in warning output. Paired with local design doc (not committed per repo convention). --- tests/issue-build-behavior-resolver.test.ts | 149 ++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 tests/issue-build-behavior-resolver.test.ts diff --git a/tests/issue-build-behavior-resolver.test.ts b/tests/issue-build-behavior-resolver.test.ts new file mode 100644 index 0000000..12d836e --- /dev/null +++ b/tests/issue-build-behavior-resolver.test.ts @@ -0,0 +1,149 @@ +/** + * FAILING TEST — verifies the gap described in tsadwyn-issues-additional-gaps.md §1 + * + * Every consumer of tsadwyn ends up writing the same 3-line behavior-map + * resolver: + * + * const v = apiVersionStorage.getStore() ?? HEAD; + * return map.get(v) ?? HEAD_BEHAVIOR; + * + * The proposed `buildBehaviorResolver(map, fallback, opts?)` standardizes + * this with optional warn-once / warn-every / silent telemetry on unknown + * versions. + * + * Run: npx vitest run tests/issue-build-behavior-resolver.test.ts + */ +import { describe, it, expect, vi } from "vitest"; + +import { apiVersionStorage } from "../src/index.js"; +// GAP: buildBehaviorResolver is not exported from tsadwyn yet. This import +// will fail at module load until the helper ships. +// @ts-expect-error — intentional: drives the failing-import signal +import { 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"]); + }); +}); From 7932892e9f3f6159482a795f60eeea213c2d48f1 Mon Sep 17 00:00:00 2001 From: mahmoudimus Date: Thu, 16 Apr 2026 12:20:06 -0700 Subject: [PATCH 05/58] test(issue-migrate-payload-to-version): failing tests for outbound webhook migration migratePayloadToVersion(schemaName, payload, targetVersion, bundle) reuses convertResponseToPreviousVersionFor migrations to shape an outbound payload for a pinned client. Tests cover shape transforms, target==head no-op, input-payload immutability, multi-change walks, and unknown-target throws. Each it() declares its own VersionChange subclasses because of the bind-once-per-bundle rule (T-1602). Paired with local design doc (not committed per repo convention). --- .../issue-migrate-payload-to-version.test.ts | 216 ++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 tests/issue-migrate-payload-to-version.test.ts 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..90a6727 --- /dev/null +++ b/tests/issue-migrate-payload-to-version.test.ts @@ -0,0 +1,216 @@ +/** + * FAILING TEST — verifies the gap described in tsadwyn-issues-additional-gaps.md §2 + * + * `convertResponseToPreviousVersionFor` only fires for in-flight HTTP + * responses. Outbound webhooks dispatched from background jobs hand-build + * payloads that bypass the migration pipeline entirely — a client pinned to + * an older API version receives head-shaped webhook bodies. + * + * The proposal is a standalone helper: + * + * migratePayloadToVersion(schemaName, payload, targetVersion, versionBundle) + * + * that walks the same response migrations registered against `schemaName` + * and returns the payload reshaped for `targetVersion`. + * + * 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, +} from "../src/index.js"; + +// GAP: migratePayloadToVersion is not exported. The import is intentionally +// expected to fail at module-load until the helper ships. +// @ts-expect-error — intentional: drives the failing-import signal +import { 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(); + }); +}); From fc161fa68997ddc113c62df19ccc936b302b8d88 Mon Sep 17 00:00:00 2001 From: mahmoudimus Date: Thu, 16 Apr 2026 12:20:09 -0700 Subject: [PATCH 06/58] test(issue-on-unsupported-version): failing tests for unsupported-version policy Option onUnsupportedVersion on versionPickingMiddleware with three modes: 'reject' (400 structured body), 'fallback' (substitute default + warn), 'passthrough' (current default behavior). Default remains 'passthrough' for backwards compatibility. Paired with local design doc (not committed per repo convention). --- tests/issue-on-unsupported-version.test.ts | 132 +++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 tests/issue-on-unsupported-version.test.ts diff --git a/tests/issue-on-unsupported-version.test.ts b/tests/issue-on-unsupported-version.test.ts new file mode 100644 index 0000000..8dc523b --- /dev/null +++ b/tests/issue-on-unsupported-version.test.ts @@ -0,0 +1,132 @@ +/** + * FAILING TEST — verifies the gap described in tsadwyn-issues-additional-gaps.md §3 + * + * Today, an unknown `X-Api-Version` header is stored verbatim in + * `apiVersionStorage`. The internal dispatcher then 422s, but consumers have + * no way to: + * - return a structured 400 with `{error, sent, supported}` (Stripe-like) + * - silently fall back to the configured default and emit telemetry + * - keep the existing passthrough behavior explicitly + * + * The proposal adds `onUnsupportedVersion: 'reject' | 'fallback' | 'passthrough'` + * to `versionPickingMiddleware`'s options, default `'passthrough'` to preserve + * current 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"); + }); +}); From 328b4b04f01a84da997bf627223fd5e979d23347 Mon Sep 17 00:00:00 2001 From: mahmoudimus Date: Thu, 16 Apr 2026 12:20:12 -0700 Subject: [PATCH 07/58] test(issue-validate-version-upgrade): failing tests for upgrade-policy helper validateVersionUpgrade(args) returns a structured decision: {ok: true, previous, next} | {ok: false, reason: 'unsupported' | 'downgrade-blocked' | 'no-change'}. Defaults block downgrade and no-change; allowDowngrade / allowNoChange opt-outs. ISO-date lex compare is the default; custom comparator option for semver / etc. Paired with local design doc (not committed per repo convention). --- tests/issue-validate-version-upgrade.test.ts | 116 +++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 tests/issue-validate-version-upgrade.test.ts diff --git a/tests/issue-validate-version-upgrade.test.ts b/tests/issue-validate-version-upgrade.test.ts new file mode 100644 index 0000000..e24edc5 --- /dev/null +++ b/tests/issue-validate-version-upgrade.test.ts @@ -0,0 +1,116 @@ +/** + * FAILING TEST — verifies the gap described in tsadwyn-issues-additional-gaps.md §4 + * + * Every adopter writing a `/versioning/upgrade` endpoint re-implements the + * same upgrade-policy decisions: is target supported, is it a downgrade, is + * it a no-op, how do we compare version strings. The proposed + * `validateVersionUpgrade()` standardizes this as a pure function. + * + * Run: npx vitest run tests/issue-validate-version-upgrade.test.ts + */ +import { describe, it, expect } from "vitest"; + +// GAP: validateVersionUpgrade is not exported from tsadwyn yet. +// @ts-expect-error — intentional: drives the failing-import signal +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" }); + }); +}); From 8a95328caa0d3890c05c87f3a81250d53e54c678 Mon Sep 17 00:00:00 2001 From: mahmoudimus Date: Thu, 16 Apr 2026 12:20:17 -0700 Subject: [PATCH 08/58] test(issue-head-requests): failing tests for HEAD request support VersionedRouter exposes .get/.post/.put/.patch/.delete but no .head(). HEAD requests auto-mirror GET via Express, but response-body migrations run against a body that's then discarded. Tests cover: - explicit .head() registration overrides auto-mirror - body migrations skipped on HEAD; header migrations still fire - migrateHttpErrors applies (status + headers; no body) - Content-Length matches equivalent GET - 405 + Allow when HEAD hits a method-only route - registration-time warn when .get() + .head() share a path Paired with local design doc (not committed per repo convention). --- tests/issue-head-requests.test.ts | 290 ++++++++++++++++++++++++++++++ 1 file changed, 290 insertions(+) create mode 100644 tests/issue-head-requests.test.ts diff --git a/tests/issue-head-requests.test.ts b/tests/issue-head-requests.test.ts new file mode 100644 index 0000000..9bc6532 --- /dev/null +++ b/tests/issue-head-requests.test.ts @@ -0,0 +1,290 @@ +/** + * FAILING TEST — verifies the gap described in tsadwyn-issue-head-requests.md + * + * Today: + * - VersionedRouter has no .head() method (router.ts:188-246); consumers + * can't register HEAD handlers. + * - HEAD requests to a registered GET route land on the GET handler via + * Express's default HEAD-mirrors-GET behavior, but response-body + * migrations run against the would-be body and the output is discarded + * — wasted work. + * - migrateHttpErrors behavior on HEAD is untested. + * + * These tests turn green when: + * 1. VersionedRouter.head() exists with the same signature as .get() + * 2. The generated handler skips response-body migrations when req.method === 'HEAD' + * 3. Header migrations still fire on HEAD + * 4. migrateHttpErrors applies on HEAD error paths (status + headers, no body) + * 5. A 405 is returned with an Allow header when HEAD is requested on a path with no matching GET + * 6. A lint warn fires at generation time when .get() and .head() share a path + * + * 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 = []; + + r1 = convertResponseToPreviousVersionFor(UserSchema, { migrateHttpErrors: 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); + }); +}); From e73bcf6a13d838296292b2405570d350aa90bcfe Mon Sep 17 00:00:00 2001 From: mahmoudimus Date: Thu, 16 Apr 2026 12:20:21 -0700 Subject: [PATCH 09/58] test(issue-no-content-shortcircuit): failing tests for 204 semantics When statusCode=204 or handler returns null/undefined: - no body emitted - body-mutating response migrations skipped (no NPE on res.body) - headerOnly:true migrations still fire - migrateHttpErrors on error paths unaffected - registration-time warn for body-mutating migrations against a 204 route - TsadwynStructureError when a 204-declared handler returns a body Paired with local design doc (not committed per repo convention). --- tests/issue-no-content-shortcircuit.test.ts | 268 ++++++++++++++++++++ 1 file changed, 268 insertions(+) create mode 100644 tests/issue-no-content-shortcircuit.test.ts diff --git a/tests/issue-no-content-shortcircuit.test.ts b/tests/issue-no-content-shortcircuit.test.ts new file mode 100644 index 0000000..f0bbffa --- /dev/null +++ b/tests/issue-no-content-shortcircuit.test.ts @@ -0,0 +1,268 @@ +/** + * FAILING TEST — verifies the gap described in tsadwyn-issue-no-content-shortcircuit.md + * + * Today: + * - A 204 route with a schema-shared response migration MAY invoke the + * body-mutating transformer with `undefined` body (pipeline contract + * is unspecified). + * - There is no `headerOnly: true` option on response migrations — if + * you want a header migration on a 204 route, you have to write a + * body-safe transformer and hope the pipeline treats it right. + * - No registration-time lint catches "body migration targeting 204 route". + * - Handler returning a non-empty body on a 204-declared route is not + * explicitly rejected. + * + * These tests turn green when: + * 1. 204 routes with return undefined / null produce empty body + * 2. Body-mutating migrations are skipped on 204 (no NPE) + * 3. headerOnly: true migrations fire on 204 + * 4. Registration-time lint warns for body-mutating migrations on 204 routes + * 5. TsadwynStructureError thrown when handler returns non-empty body on 204 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("throws TsadwynStructureError when a 204-declared handler returns a non-empty body", async () => { + const router = new VersionedRouter(); + router.delete( + "/users/:id", + null, + null, + async () => ({ ok: true }) as any, // violates 204 contract + { 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 a 500 surfacing a TsadwynStructureError (the handler produced + // a body on a 204 route — misconfiguration) + expect(res.status).toBe(500); + }); + + 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"); + }); +}); From e809ec27099610d3161c9fc23032323527682082 Mon Sep 17 00:00:00 2001 From: mahmoudimus Date: Thu, 16 Apr 2026 12:20:26 -0700 Subject: [PATCH 10/58] test(issue-route-options-tags): failing tests for tags in RouteOptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RouteDefinition.tags and OpenAPI operation.tags plumbing already exists; only the registration-time RouteOptions.tags field is missing. Tests cover: registration-time tag flow into OpenAPI output, grouping multiple routes under a shared tag, endpoint().had({tags}) replacement at older versions, reserved _TSADWYN prefix warning, deduplication. Two-line change in RouteOptions + a small lint in route-generation — highest-leverage tier-1 item for non-RESTful APIs. Paired with local design doc (not committed per repo convention). --- tests/issue-route-options-tags.test.ts | 214 +++++++++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 tests/issue-route-options-tags.test.ts diff --git a/tests/issue-route-options-tags.test.ts b/tests/issue-route-options-tags.test.ts new file mode 100644 index 0000000..b3e196e --- /dev/null +++ b/tests/issue-route-options-tags.test.ts @@ -0,0 +1,214 @@ +/** + * FAILING TEST — verifies the gap described in tsadwyn-issue-route-options-tags.md + * + * Today: + * - RouteDefinition.tags exists (router.ts:26) and flows into OpenAPI. + * - endpoint().had({tags}) can mutate tags per-version. + * - But RouteOptions has no `tags` field — consumers can't set tags + * at registration time. + * + * These tests turn green when: + * 1. RouteOptions.tags is accepted at registration + * 2. Those tags flow into RouteDefinition.tags + * 3. OpenAPI output emits them as operation.tags + * 4. endpoint().had({tags}) replaces the registration-time list + * 5. Warn emitted for tags matching _TSADWYN prefix + * 6. Tags are deduped at OpenAPI 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); + }); +}); From 88eb56d97710329dfae0c51770e2123dca9d8e9d Mon Sep 17 00:00:00 2001 From: mahmoudimus Date: Thu, 16 Apr 2026 12:20:31 -0700 Subject: [PATCH 11/58] test(issue-pre-version-pick-hook): failing tests for preVersionPick hook TsadwynOptions.preVersionPick runs middleware BEFORE versionPickingMiddleware so the default-version resolver can read req.user. Tests cover: - req.user set by hook is visible inside apiVersionDefaultValue - errors propagate via next(err) - async middleware supported - mutual-exclusion TsadwynStructureError when combined with versioningMiddleware full-override - composes correctly with VersionedRouter.use() per-version middleware - scoped to versioned dispatch (utility endpoints bypass the hook) Paired with local design doc (not committed per repo convention). --- tests/issue-pre-version-pick-hook.test.ts | 197 ++++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 tests/issue-pre-version-pick-hook.test.ts 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..6bd11ae --- /dev/null +++ b/tests/issue-pre-version-pick-hook.test.ts @@ -0,0 +1,197 @@ +/** + * FAILING TEST — verifies the gap described in tsadwyn-issue-pre-version-pick-hook.md + * + * Today: TsadwynOptions has no `preVersionPick` hook. The only way to run + * consumer middleware before version pick is to supply the full + * `versioningMiddleware` override, which forces consumers to re-implement + * header extraction, default resolution, and apiVersionStorage scoping. + * + * These tests turn green when: + * 1. preVersionPick runs before versionPickingMiddleware + * 2. req.user set by preVersionPick is visible inside apiVersionDefaultValue + * 3. Errors in preVersionPick propagate via next(err) + * 4. Async preVersionPick is supported + * 5. Combining with versioningMiddleware throws TsadwynStructureError + * 6. apiVersionStorage is empty inside preVersionPick + * + * 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(); + }); +}); From 4a0ff8675708400dcf10202e0100cfe21d2e19c6 Mon Sep 17 00:00:00 2001 From: mahmoudimus Date: Thu, 16 Apr 2026 12:20:35 -0700 Subject: [PATCH 12/58] test(issue-per-client-default-version): failing tests for per-client default helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit perClientDefaultVersion({identify, resolvePin, fallback, onStalePin, cache, supportedVersions, logger}) returns an apiVersionDefaultValue- compatible function. Tests cover: happy resolver chain, fallback on null identity / null stored pin, explicit X-Api-Version override, per-request caching via WeakMap, onStalePin: fallback/reject behaviors, async support, error propagation. Pairs with preVersionPick — auth typically runs first so identify can read req.user. Paired with local design doc (not committed per repo convention). --- .../issue-per-client-default-version.test.ts | 269 ++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 tests/issue-per-client-default-version.test.ts 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..e85310d --- /dev/null +++ b/tests/issue-per-client-default-version.test.ts @@ -0,0 +1,269 @@ +/** + * FAILING TEST — verifies the gap described in tsadwyn-issue-per-client-default-version.md + * + * Today: consumers hand-roll the resolver chain (identify → resolvePin → + * fallback), forget to dedupe, and forget the stale-pin case. + * + * These tests turn green when `perClientDefaultVersion` is exported with + * the contract documented in the issue spec. + * + * 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, +} from "../src/index.js"; +// GAP: not exported today +// @ts-expect-error — intentional +import { 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"); + }); + + 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); + }); +}); From bcb271245429188c36fc54aad7e58ed20bea08f4 Mon Sep 17 00:00:00 2001 From: mahmoudimus Date: Thu, 16 Apr 2026 12:20:39 -0700 Subject: [PATCH 13/58] test(issue-route-table-dump): failing tests for dumpRouteTable + tsadwyn routes CLI dumpRouteTable(app, {version, method, pathMatches, includePrivate}) returns every registered route per version. Tests cover: basic enumeration, includeInSchema:false exclusion/inclusion, method + pathMatches filtering, per-version sections when version omitted, endpoint().existed visibility, combined filter AND semantics. Top-priority tier-1 debugging tool. Pairs with the migration-chain inspector and the route simulator. Paired with local design doc (not committed per repo convention). --- tests/issue-route-table-dump.test.ts | 175 +++++++++++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 tests/issue-route-table-dump.test.ts diff --git a/tests/issue-route-table-dump.test.ts b/tests/issue-route-table-dump.test.ts new file mode 100644 index 0000000..32b7000 --- /dev/null +++ b/tests/issue-route-table-dump.test.ts @@ -0,0 +1,175 @@ +/** + * FAILING TEST — verifies the gap described in tsadwyn-issue-route-table-dump.md + * + * Today: no public API for enumerating registered routes per version. + * Consumers grep source or read private `_versionedRouters`. + * + * These tests turn green when `dumpRouteTable()` is exported. + * + * 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, +} from "../src/index.js"; + +// GAP: not exported +// @ts-expect-error — intentional +import { 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 + router.get("/legacy-only", null, UserResp, async () => ({ id: "l", name: "legacy" })); + router.onlyExistsInOlderVersions("/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([]); + }); +}); From 90372feffd74c12b9bc26a3edc25ecec197e4718 Mon Sep 17 00:00:00 2001 From: mahmoudimus Date: Thu, 16 Apr 2026 12:20:43 -0700 Subject: [PATCH 14/58] test(issue-migration-chain-inspector): failing tests for migration inspector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit inspectMigrationChain(app, {schemaName, clientVersion, direction, path?, method?, includeErrorMigrations?}) returns the ordered migrations that would fire. Tests cover: response direction (head→client), request direction (client→head), empty-when-no-match, schema + path-based composition, error-migration filter, throws on unknown schema / unknown version, entry shape (changeClassName/kind/order). Paired with local design doc (not committed per repo convention). --- tests/issue-migration-chain-inspector.test.ts | 344 ++++++++++++++++++ 1 file changed, 344 insertions(+) create mode 100644 tests/issue-migration-chain-inspector.test.ts diff --git a/tests/issue-migration-chain-inspector.test.ts b/tests/issue-migration-chain-inspector.test.ts new file mode 100644 index 0000000..42a7c47 --- /dev/null +++ b/tests/issue-migration-chain-inspector.test.ts @@ -0,0 +1,344 @@ +/** + * FAILING TEST — verifies the gap described in tsadwyn-issue-migration-chain-inspector.md + * + * Today: no public API for introspecting which migrations fire for a + * given schema + client version. + * + * These tests turn green when `inspectMigrationChain()` is exported. + * + * 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, +} from "../src/index.js"; + +// GAP: not exported +// @ts-expect-error — intentional +import { 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"; + 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-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("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, + }); + }); +}); From d050fda4035d64c7695be267dcf48428a8107b4f Mon Sep 17 00:00:00 2001 From: mahmoudimus Date: Thu, 16 Apr 2026 12:20:49 -0700 Subject: [PATCH 15/58] test(issue-exception-map): failing tests for exceptionMap helper + CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit exceptionMap(config) returns an errorMapper-compatible function with three mapping forms (function / static / static-with-transform) keyed by err.name (not instanceof — survives module identity drift). Tests cover: all three mapping forms, introspection API (registeredNames/has/lookup/describe), construction validation (duplicate-key detection via merge, non-4xx/5xx rejection), end-to-end Tsadwyn integration including migrateHttpErrors composition, and tsadwyn exceptions CLI subcommand (JSON/table/ markdown formats + --filter regex). Completes the debugging introspection quartet with routes / migrations / simulator. Paired with local design doc (not committed per repo convention). --- tests/issue-exception-map.test.ts | 441 ++++++++++++++++++++++++++++++ 1 file changed, 441 insertions(+) create mode 100644 tests/issue-exception-map.test.ts diff --git a/tests/issue-exception-map.test.ts b/tests/issue-exception-map.test.ts new file mode 100644 index 0000000..2f9f381 --- /dev/null +++ b/tests/issue-exception-map.test.ts @@ -0,0 +1,441 @@ +/** + * FAILING TEST — verifies the gap described in tsadwyn-issue-exception-map-helper.md + * + * exceptionMap() is 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, +} from "../src/index.js"; + +// GAP: not exported +// @ts-expect-error — intentional +import { exceptionMap } from "../src/index.js"; +// GAP: CLI subcommand not available yet +// @ts-expect-error — intentional +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); + }); +}); From 921c2581041a9a3bd4929de46fc00b38dd2e5a03 Mon Sep 17 00:00:00 2001 From: mahmoudimus Date: Thu, 16 Apr 2026 12:20:54 -0700 Subject: [PATCH 16/58] test(issue-route-simulation): failing tests for simulateRoute() debugger simulateRoute(app, {method, path, version?, headers?, body?}) answers 'is tsadwyn responsible for this request, and what would it do?' without actually dispatching. Tests cover: matchedRoute with captured params, fallthrough with closest-miss suggestions, candidate table with per-candidate match reasons (method mismatch / extra segments / shadowed wildcard), version resolution precedence (explicit > header > default), request+response migration chains surfaced in order, upMigratedBody preview when a legacy body is supplied, availableAtOtherVersions diagnostic via endpoint().existed lifecycle. Paired with local design doc (not committed per repo convention). --- tests/issue-route-simulation.test.ts | 447 +++++++++++++++++++++++++++ 1 file changed, 447 insertions(+) create mode 100644 tests/issue-route-simulation.test.ts diff --git a/tests/issue-route-simulation.test.ts b/tests/issue-route-simulation.test.ts new file mode 100644 index 0000000..9af1176 --- /dev/null +++ b/tests/issue-route-simulation.test.ts @@ -0,0 +1,447 @@ +/** + * FAILING TEST — verifies the gap described in tsadwyn-issue-route-simulation-debug-tool.md + * + * `simulateRoute()` is the programmatic API that answers "is tsadwyn + * responsible for this request, and if so, what would it do?" without + * actually dispatching. Input: method + path + version (+ optional body). + * Output: matched route (if any), every candidate and why it did/didn't + * match, fallthrough reason with closest-miss suggestions, and the + * request/response migration chains that would run. + * + * These tests turn green when `simulateRoute()` is exported. + * + * 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, +} from "../src/index.js"; + +// GAP: not exported +// @ts-expect-error — intentional +import { 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("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 didn't at head. + const router = new VersionedRouter({ prefix: "/api" }); + router.get( + "/legacy", + null, + UserResp, + async () => ({ id: "l", name: "legacy" }), + ); + router.onlyExistsInOlderVersions("/legacy", ["GET"]); + + class RestoreLegacy extends VersionChange { + description = "legacy clients had GET /legacy"; + instructions = []; + } + + // Give RestoreLegacy the `existed` instruction via endpoint().existed + // (using a separate change class so we don't modify RestoreLegacy inline) + class RestoreLegacy2 extends VersionChange { + description = "legacy clients had GET /legacy (restored)"; + instructions = [ + // eslint-disable-next-line @typescript-eslint/no-require-imports + (require("../src/index.js") as typeof import("../src/index.js")).endpoint( + "/api/legacy", + ["GET"], + ).existed, + ]; + } + + const app = new Tsadwyn({ + versions: new VersionBundle( + new Version("2025-06-01", RestoreLegacy2), + new Version("2024-01-01"), + ), + }); + app.generateAndIncludeVersionedRouters(router); + + // At HEAD, /api/legacy doesn't exist + const result = simulateRoute(app, { + method: "GET", + path: "/api/legacy", + version: "2025-06-01", + }); + + expect(result.matchedRoute).toBeNull(); + expect(result.fallthrough.availableAtOtherVersions).toEqual(["2024-01-01"]); + }); +}); From 676e1a6c11681d5623ca0a02c284f77124f1acb1 Mon Sep 17 00:00:00 2001 From: mahmoudimus Date: Thu, 16 Apr 2026 12:27:37 -0700 Subject: [PATCH 17/58] =?UTF-8?q?feat:=20pure=20helpers=20=E2=80=94=20vali?= =?UTF-8?q?dateVersionUpgrade=20+=20buildBehaviorResolver=20+=20perClientD?= =?UTF-8?q?efaultVersion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three helpers + one middleware fix: src/version-upgrade.ts validateVersionUpgrade({current, target, supported, ...}) Discriminated-union result (ok: true | {ok: false, reason}). iso-date (default) / semver / custom comparator. Closes tests/issue-validate-version-upgrade.test.ts (8/8 green). src/behavior-resolver.ts buildBehaviorResolver(map, fallback, opts?) Reads version from apiVersionStorage; silent fallback when no version is set; warn-once / warn-every / silent telemetry on unknown versions. Closes tests/issue-build-behavior-resolver.test.ts (7/7 green). src/per-client-default.ts perClientDefaultVersion({identify, resolvePin, fallback, onStalePin, cache, logger, supportedVersions}) Per-request WeakMap cache; stale-pin policy (fallback/passthrough/reject). Closes tests/issue-per-client-default-version.test.ts (9/9 green). src/middleware.ts Wrap await apiVersionDefaultValue(req) in try/catch → next(err) so resolver rejections propagate to Express error handling instead of hanging the request. All three exported from src/index.ts. --- src/behavior-resolver.ts | 66 ++++++++++++++++++++ src/index.ts | 16 +++++ src/middleware.ts | 13 ++-- src/per-client-default.ts | 100 ++++++++++++++++++++++++++++++ src/version-upgrade.ts | 127 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 318 insertions(+), 4 deletions(-) create mode 100644 src/behavior-resolver.ts create mode 100644 src/per-client-default.ts create mode 100644 src/version-upgrade.ts diff --git a/src/behavior-resolver.ts b/src/behavior-resolver.ts new file mode 100644 index 0000000..b6e7ef1 --- /dev/null +++ b/src/behavior-resolver.ts @@ -0,0 +1,66 @@ +/** + * `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"; + +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"; + /** Optional structured logger. Required if `onUnknown !== 'silent'`. */ + 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. + */ +export function buildBehaviorResolver( + map: ReadonlyMap, + fallback: B, + opts: BuildBehaviorResolverOptions = {}, +): () => B { + const onUnknown = opts.onUnknown ?? "silent"; + const logger = opts.logger; + 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/index.ts b/src/index.ts index 999b4f4..b782be1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -102,6 +102,22 @@ 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"; + +// Behavior-map helper for per-version behavior branching in handlers +export { buildBehaviorResolver } from "./behavior-resolver.js"; +export type { BuildBehaviorResolverOptions } from "./behavior-resolver.js"; + +// Canonical upgrade-policy helper for /versioning/upgrade endpoints +export { validateVersionUpgrade } from "./version-upgrade.js"; +export type { + ValidateVersionUpgradeArgs, + ValidateVersionUpgradeResult, + CompareFn, +} from "./version-upgrade.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..bd1f69e 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -96,10 +96,15 @@ 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; } } diff --git a/src/per-client-default.ts b/src/per-client-default.ts new file mode 100644 index 0000000..9260846 --- /dev/null +++ b/src/per-client-default.ts @@ -0,0 +1,100 @@ +/** + * `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[]; +} + +/** + * Build an `apiVersionDefaultValue`-compatible resolver. + */ +export function perClientDefaultVersion( + opts: PerClientDefaultVersionOptions, +): (req: Request) => Promise { + 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) { + 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/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 }; +} From 36b08e99a4605081f98960017accad489c005a1e Mon Sep 17 00:00:00 2001 From: mahmoudimus Date: Thu, 16 Apr 2026 12:30:03 -0700 Subject: [PATCH 18/58] =?UTF-8?q?feat:=20middleware=20options=20=E2=80=94?= =?UTF-8?q?=20onUnsupportedVersion=20+=20preVersionPick?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/middleware.ts VersionPickingOptions gets onUnsupportedVersion ('reject' | 'fallback' | 'passthrough', default 'passthrough') and optional logger. - 'reject': 400 {error: 'unsupported_api_version', sent, supported} - 'fallback': substitute apiVersionDefaultValue + warn - 'passthrough': current behavior, verbatim storage Closes tests/issue-on-unsupported-version.test.ts (4/4 green). src/application.ts TsadwynOptions.preVersionPick hook runs before versionPickingMiddleware for requests destined for versioned dispatch. Scoped out from utility endpoints (openApiUrl, docsUrl, redocUrl, changelogUrl) via path-check. Constructor throws TsadwynStructureError if preVersionPick and versioningMiddleware are both supplied (mutual exclusion). Closes tests/issue-pre-version-pick-hook.test.ts (7/7 green). --- src/application.ts | 56 ++++++++++++++++++++++++++++++++++++++++++++++ src/middleware.ts | 52 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+) diff --git a/src/application.ts b/src/application.ts index 0879d79..e7d944c 100644 --- a/src/application.ts +++ b/src/application.ts @@ -77,6 +77,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. */ @@ -296,10 +315,47 @@ 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, diff --git a/src/middleware.ts b/src/middleware.ts index bd1f69e..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; + }; } /** @@ -108,6 +122,44 @@ export function versionPickingMiddleware( } } + // 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; apiVersionStorage.run(versionValue, () => { From 3b6e446416984671c649de34bdcf078740d49543 Mon Sep 17 00:00:00 2001 From: mahmoudimus Date: Thu, 16 Apr 2026 12:33:41 -0700 Subject: [PATCH 19/58] =?UTF-8?q?feat:=20error=20handling=20=E2=80=94=20er?= =?UTF-8?q?rorMapper=20+=20exceptionMap=20+=20tsadwyn=20exceptions=20CLI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/exception-map.ts exceptionMap(config) returns a function compatible with errorMapper: - function form: (err) => new HttpError(...) - static form: {status, code, message?} - static-with-transform: {status, code, transform: (err) => body} Introspection: registeredNames / has() / lookup() / describe(). exceptionMap.merge(...) throws on overlapping keys. isExceptionMapFn(v) type guard for CLI detection. src/application.ts TsadwynOptions.errorMapper stored on instance._errorMapper and passed through generateAndIncludeVersionedRouters. src/route-generation.ts Handler catch block invokes errorMapper BEFORE the _isHttpLikeError check. If it returns an HttpError, the existing response-migration pipeline runs against it. If it returns null, next(err) preserves the original behavior. If it throws, next(err) with the ORIGINAL err — mapper failures never mask handler failures. src/cli.ts runExceptions({app, format?, filter?}) returns {stdout, stderr, exitCode}. Renders table / json / markdown formats. Non-zero exit when the app has no errorMapper or a non-introspectable one. subcommand registered in createProgram. tests/fixtures/cli-exception-map-app.ts Test fixture with exceptionMap-based errorMapper for CLI smoke tests. Closes tests/issue-error-mapper.test.ts (4/4 green) + tests/issue-exception-map.test.ts (19/19 green). --- src/application.ts | 22 +++ src/cli.ts | 180 ++++++++++++++++++++++ src/exception-map.ts | 197 ++++++++++++++++++++++++ src/index.ts | 9 ++ src/route-generation.ts | 32 +++- tests/fixtures/cli-exception-map-app.ts | 41 +++++ 6 files changed, 480 insertions(+), 1 deletion(-) create mode 100644 src/exception-map.ts create mode 100644 tests/fixtures/cli-exception-map-app.ts diff --git a/src/application.ts b/src/application.ts index e7d944c..f1d6e6c 100644 --- a/src/application.ts +++ b/src/application.ts @@ -156,6 +156,21 @@ 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; } /** @@ -236,6 +251,9 @@ 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; + /** * Access the internal versioned routers map. * Used by the CLI and for introspection. @@ -284,6 +302,9 @@ 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; + // T-1003: Validate version format and ordering this._validateVersionFormat(); @@ -572,6 +593,7 @@ export class Tsadwyn { this.versions, this.dependencyOverrides, this.webhooks.routes.length > 0 ? this.webhooks.routes : undefined, + this._errorMapper ?? undefined, ); // Store per-version routes for OpenAPI generation diff --git a/src/cli.ts b/src/cli.ts index 95d140b..fb2c4b3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -25,6 +25,8 @@ 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"; + /** * The version string reported by `tsadwyn --version` / `tsadwyn -V`. * Kept in sync with package.json's `version` field. @@ -42,6 +44,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 +809,151 @@ function emitResult(cmd: Command, result: CommandResult, errorCode: string): voi } } +// ───────────────────────────────────────────────────────────────────────── +// `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 +998,28 @@ 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", + ); + } + }); + // ───────────────────────────────────────────────────────────────────── // `new` — scaffolding subcommands // ───────────────────────────────────────────────────────────────────── 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/index.ts b/src/index.ts index b782be1..3061f60 100644 --- a/src/index.ts +++ b/src/index.ts @@ -118,6 +118,15 @@ export type { CompareFn, } from "./version-upgrade.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"; + // 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/route-generation.ts b/src/route-generation.ts index e73b291..5acc8df 100644 --- a/src/route-generation.ts +++ b/src/route-generation.ts @@ -521,6 +521,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 @@ -624,6 +631,7 @@ export function generateVersionedRouters( version.value, dependencyOverrides, versionedQuerySchema, + errorMapper, ); // T-602: Collect middleware (router-level + route-level) @@ -871,6 +879,7 @@ 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; @@ -1113,10 +1122,31 @@ function createVersionedHandler( 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 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; From 2078eeabd8e5434f2f337ee595a15673c1abc9de Mon Sep 17 00:00:00 2001 From: mahmoudimus Date: Thu, 16 Apr 2026 12:44:01 -0700 Subject: [PATCH 20/58] =?UTF-8?q?feat:=20router=20surface=20=E2=80=94=20ta?= =?UTF-8?q?gs=20in=20RouteOptions=20+=20HEAD=20support=20+=20204=20short-c?= =?UTF-8?q?ircuit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/router.ts RouteOptions.tags registered at construction; dedup preserves order; warn for reserved _TSADWYN prefix. New VersionedRouter.head() method mirrors the other verbs and returns void (HEAD carries no body per HTTP spec). src/structure/data.ts AlterResponseBySchemaInstruction + AlterResponseByPathInstruction both gain a headerOnly flag. ResponseMigrationOptions.headerOnly:boolean plumbed through the schema-based and path-based forms of convertResponseToPreviousVersionFor. src/route-generation.ts - Migration short-circuit for null/undefined handler returns and HEAD: skip body-mutating migrations; only run headerOnly: true or migrateHttpErrors: true migrations. - HEAD on a successful response: still compute Content-Length but emit no body (res.end() without buffer). - Route reorder within each version: HEAD before GET for the same path so explicit HEAD handlers win over Express's HEAD→GET auto-mirror. - 405 + Allow header for HEAD requests on paths that have other methods but no GET and no explicit HEAD. - Registration-time warn when both GET and HEAD are explicitly registered for the same path. - Lint: path-based body migrations targeting 204/304 routes without headerOnly=true emit a warn at generation time. tests/issue-no-content-shortcircuit.test.ts Per user feedback: 204+body is permissive by default (Stripe does this; RFC §15.3.5 is stricter than production reality). Removed the strict-throw assertion; test now verifies 204 status passes through unchanged even when the handler returns content. Closes tests/issue-head-requests.test.ts (8/8 green) + tests/issue-no-content-shortcircuit.test.ts (7/7 green) + tests/issue-route-options-tags.test.ts (7/7 green). 628 existing tests remain green — no regressions. --- src/route-generation.ts | 137 +++++++++++++++++++- src/router.ts | 57 +++++++- src/structure/data.ts | 22 ++++ tests/issue-no-content-shortcircuit.test.ts | 16 ++- 4 files changed, 221 insertions(+), 11 deletions(-) diff --git a/src/route-generation.ts b/src/route-generation.ts index 5acc8df..f68d8b1 100644 --- a/src/route-generation.ts +++ b/src/route-generation.ts @@ -208,6 +208,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; } /** @@ -540,6 +542,38 @@ export function generateVersionedRouters( // T-1604: Validate path-based converter usage against all routes validatePathConverterUsage(versions, allRoutes); + // 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(); @@ -564,8 +598,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 } @@ -574,12 +626,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); @@ -649,6 +711,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[] = []; @@ -766,6 +856,7 @@ function collectResponseMigrations( migrations.push({ transformer: instr.transformer, migrateHttpErrors: instr.migrateHttpErrors, + headerOnly: (instr as any).headerOnly ?? false, }); } } @@ -779,6 +870,7 @@ function collectResponseMigrations( migrations.push({ transformer: instr.transformer, migrateHttpErrors: instr.migrateHttpErrors, + headerOnly: (instr as any).headerOnly ?? false, }); } } @@ -1090,6 +1182,33 @@ function createVersionedHandler( } } + // 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). + const isHead = req.method === "HEAD"; + const isNullResult = result === undefined || result === null; + + if (isNullResult && !isHead) { + const responseInfo = new ResponseInfo(undefined, successStatus); + for (const migration of responseMigrations) { + if (!migration.headerOnly && !migration.migrateHttpErrors) { + continue; + } + migration.transformer(responseInfo); + } + _applyResponseInfoToExpressResponse(res, responseInfo); + 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 @@ -1103,8 +1222,14 @@ 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) { + // T-401: Skip response migration if status >= 300 and migrateHttpErrors is false. + // HEAD is treated like a body-less response — same skip semantics + // (only migrateHttpErrors-flagged or headerOnly migrations run). + if ( + (isHead || responseInfo.statusCode >= 300) && + !migration.migrateHttpErrors && + !migration.headerOnly + ) { continue; } migration.transformer(responseInfo); @@ -1117,7 +1242,9 @@ 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); + // 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); } 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/structure/data.ts b/src/structure/data.ts index 525e297..58374fb 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; } /** @@ -324,10 +337,14 @@ export function convertResponseToPreviousVersionFor( // Check for options in rest let migrateHttpErrors = false; + let headerOnly = false; for (const arg of rest) { if (arg && typeof arg === "object" && "migrateHttpErrors" in arg) { migrateHttpErrors = arg.migrateHttpErrors ?? false; } + if (arg && typeof arg === "object" && "headerOnly" in arg) { + headerOnly = arg.headerOnly ?? false; + } } return function decoratorOrWrapper( @@ -345,6 +362,7 @@ export function convertResponseToPreviousVersionFor( transformer: (response: ResponseInfo) => originalMethod.call(targetOrTransformer, response), methodName: String(propertyKeyOrUndefined), migrateHttpErrors, + headerOnly, }; descriptorOrUndefined.value = instruction; return descriptorOrUndefined; @@ -359,6 +377,7 @@ export function convertResponseToPreviousVersionFor( transformer, methodName: transformer.name || "anonymous", migrateHttpErrors, + headerOnly, }; return instruction; }; @@ -387,6 +406,7 @@ export function convertResponseToPreviousVersionFor( const migrateHttpErrors = options.migrateHttpErrors !== undefined ? options.migrateHttpErrors : false; const checkUsage = options.checkUsage !== undefined ? options.checkUsage : true; + const headerOnly = options.headerOnly ?? false; const schemaNames = schemas.map((s) => { const name = _getSchemaName(s); @@ -411,6 +431,7 @@ export function convertResponseToPreviousVersionFor( methodName: String(propertyKeyOrUndefined), migrateHttpErrors, checkUsage, + headerOnly, }; descriptorOrUndefined.value = instruction; return descriptorOrUndefined; @@ -425,6 +446,7 @@ export function convertResponseToPreviousVersionFor( methodName: transformer.name || "anonymous", migrateHttpErrors, checkUsage, + headerOnly, }; return instruction; }; diff --git a/tests/issue-no-content-shortcircuit.test.ts b/tests/issue-no-content-shortcircuit.test.ts index f0bbffa..e1d59dd 100644 --- a/tests/issue-no-content-shortcircuit.test.ts +++ b/tests/issue-no-content-shortcircuit.test.ts @@ -207,13 +207,19 @@ describe("Issue: 204 No Content — body-migration short-circuit", () => { ).toBe(true); }); - it("throws TsadwynStructureError when a 204-declared handler returns a non-empty body", async () => { + 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, // violates 204 contract + async () => ({ ok: true }) as any, { statusCode: 204 }, ); @@ -226,9 +232,9 @@ describe("Issue: 204 No Content — body-migration short-circuit", () => { .delete("/users/123") .set("x-api-version", "2024-01-01"); - // Expect a 500 surfacing a TsadwynStructureError (the handler produced - // a body on a 204 route — misconfiguration) - expect(res.status).toBe(500); + 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("still respects migrateHttpErrors on a 204 route that throws HttpError", async () => { From 2e4f16a9a5897cd52a59071e2d9380ff9cc8a35d Mon Sep 17 00:00:00 2001 From: mahmoudimus Date: Thu, 16 Apr 2026 12:45:29 -0700 Subject: [PATCH 21/58] =?UTF-8?q?feat:=20generation=20primitives=20?= =?UTF-8?q?=E2=80=94=20wildcard=20collision=20warning=20+=20migratePayload?= =?UTF-8?q?ToVersion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/migrate-payload.ts migratePayloadToVersion(schemaName, payload, targetVersion, versions): standalone helper that replays the schema-based response migrations registered between head and target against a head-shape payload and returns the reshaped result. Primary use case: outbound webhook dispatch that needs to honor the destination client's pinned version. Deep-clones input; throws when targetVersion is not in the bundle. src/route-generation.ts Registration-time lint: for each pair of routes (same method) where an earlier wildcard-containing path would match a later literal sibling, emit a warn naming both paths. path-to-regexp is first-match-wins; the wildcard silently intercepts the literal unless the consumer reorders. This is the failure mode that precipitated the original production incident. Closes tests/issue-wildcard-route-collision.test.ts (1/1 green) + tests/issue-migrate-payload-to-version.test.ts (5/5 green). --- src/index.ts | 3 ++ src/migrate-payload.ts | 65 +++++++++++++++++++++++++++++++++++++++++ src/route-generation.ts | 45 ++++++++++++++++++++++++++++ 3 files changed, 113 insertions(+) create mode 100644 src/migrate-payload.ts diff --git a/src/index.ts b/src/index.ts index 3061f60..f295c6b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -127,6 +127,9 @@ export type { ExceptionMapping, } from "./exception-map.js"; +// Outbound payload migration (webhooks, internal events) +export { migratePayloadToVersion } from "./migrate-payload.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/migrate-payload.ts b/src/migrate-payload.ts new file mode 100644 index 0000000..af84db0 --- /dev/null +++ b/src/migrate-payload.ts @@ -0,0 +1,65 @@ +/** + * `migratePayloadToVersion` — standalone helper that reshapes a head-shape + * payload for a pinned client version by replaying the schema-based response + * migrations registered against `schemaName` 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. + */ + +import type { VersionBundle } from "./structure/versions.js"; +import { ResponseInfo } from "./structure/data.js"; +import { TsadwynStructureError } from "./exceptions.js"; + +/** + * 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, +): 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); + + // 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) { + const instrs = change._alterResponseBySchemaInstructions.get(schemaName); + if (!instrs) continue; + for (const instr of instrs) { + instr.transformer(responseInfo); + } + } + } + + return responseInfo.body as T; +} diff --git a/src/route-generation.ts b/src/route-generation.ts index f68d8b1..af3a27e 100644 --- a/src/route-generation.ts +++ b/src/route-generation.ts @@ -542,6 +542,51 @@ export function generateVersionedRouters( // T-1604: Validate path-based converter usage against all routes validatePathConverterUsage(versions, allRoutes); + // Lint: wildcard-before-literal collisions. + // path-to-regexp is first-match-wins; if a parameterized route (e.g., + // /widgets/:id) is registered before a sibling literal (/widgets/archived), + // the wildcard intercepts every request and — combined with any UUID/slug + // validator middleware on the wildcard — silently 400s the literal. Warn at + // generation time naming both routes so consumers can reorder (or fix + // upstream with explicit regex validators). + { + const sameMethodCheck = (a: string, b: string): boolean => + a.toUpperCase() === b.toUpperCase(); + const pathsCollide = (wildcardPath: string, literalPath: string): boolean => { + const aSeg = wildcardPath.split("/").filter((s) => s.length > 0); + const bSeg = literalPath.split("/").filter((s) => s.length > 0); + if (aSeg.length !== bSeg.length) return false; + let hasWildcardOverLiteral = false; + for (let i = 0; i < aSeg.length; i++) { + const a = aSeg[i]; + const b = bSeg[i]; + if (a.startsWith(":") && !b.startsWith(":")) { + hasWildcardOverLiteral = true; + } else if (a !== b) { + return false; + } + } + return hasWildcardOverLiteral; + }; + for (let i = 0; i < versionedRouter.routes.length; i++) { + for (let j = i + 1; j < versionedRouter.routes.length; j++) { + const earlier = versionedRouter.routes[i]; + const later = versionedRouter.routes[j]; + if (!sameMethodCheck(earlier.method, later.method)) continue; + if (pathsCollide(earlier.path, later.path)) { + // eslint-disable-next-line no-console + console.warn( + `tsadwyn: route [${earlier.method}] ${earlier.path} is registered ` + + `before sibling literal [${later.method}] ${later.path}. path-to-regexp ` + + `is first-match-wins — the wildcard will intercept requests meant for ` + + `the literal. Reorder so the literal is registered first, or split ` + + `the wildcard's pattern to exclude the literal segment.`, + ); + } + } + } + } + // 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 From 20f7d81104fd9e5b1049e1fa1c0f675b6c1bffc2 Mon Sep 17 00:00:00 2001 From: mahmoudimus Date: Thu, 16 Apr 2026 12:51:29 -0700 Subject: [PATCH 22/58] =?UTF-8?q?feat:=20introspection=20trio=20=E2=80=94?= =?UTF-8?q?=20dumpRouteTable=20+=20inspectMigrationChain=20+=20simulateRou?= =?UTF-8?q?te?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/route-table.ts dumpRouteTable(app, opts) enumerates registered routes per version. Filters by method / pathMatches (regex or substring) / includePrivate. Entries expose handler name, request/response schema names, statusCode, deprecated, tags (sans _TSADWYN internals), middleware. Uses per-version snapshots from Tsadwyn._versionedRoutes so endpoint().didntExist / .existed / .had mutations are reflected per version. src/migration-chain.ts inspectMigrationChain(app, {schemaName, clientVersion, direction, path?, method?, includeErrorMigrations?}) returns ordered MigrationChainEntry list. Response direction walks head → client; request direction walks client → head. Entries mark kind (schema-based vs path-based), changeClassName, functionName, order. Throws TsadwynStructureError for unknown schema / unknown version. src/route-simulation.ts simulateRoute(app, {method, path, version?, headers?, body?}) — synchronous SimulationResult with matchedRoute + every candidate's match reason + fallthrough diagnostics + request/response migration chain summaries + up-migrated body preview. Version resolution: explicit > header > apiVersionDefaultValue (when string) > head. Async default resolvers fall back to head so the function stays sync. In-house path matcher mirrors path-to-regexp's literal+:param subset; avoids pinning the consumer's path-to-regexp version. First-match-wins semantics preserved; later matching candidates are marked 'shadowed by earlier match'. tests/issue-route-simulation.test.ts tests/issue-route-table-dump.test.ts Minor test fixups: use prefixed paths with onlyExistsInOlderVersions (matches real stored path); replace require() with top-level import of endpoint. All 15 issue test files + 31 existing test files green — 744/744 tests pass. typecheck + build clean. --- src/index.ts | 23 ++ src/migration-chain.ts | 199 +++++++++++++ src/route-simulation.ts | 420 +++++++++++++++++++++++++++ src/route-table.ts | 114 ++++++++ tests/issue-route-simulation.test.ts | 35 +-- tests/issue-route-table-dump.test.ts | 6 +- 6 files changed, 775 insertions(+), 22 deletions(-) create mode 100644 src/migration-chain.ts create mode 100644 src/route-simulation.ts create mode 100644 src/route-table.ts diff --git a/src/index.ts b/src/index.ts index f295c6b..45f2bac 100644 --- a/src/index.ts +++ b/src/index.ts @@ -130,6 +130,29 @@ export type { // Outbound payload migration (webhooks, internal events) export { migratePayloadToVersion } from "./migrate-payload.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/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/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/tests/issue-route-simulation.test.ts b/tests/issue-route-simulation.test.ts index 9af1176..d255a4b 100644 --- a/tests/issue-route-simulation.test.ts +++ b/tests/issue-route-simulation.test.ts @@ -25,6 +25,7 @@ import { RequestInfo, convertRequestToNextVersionFor, convertResponseToPreviousVersionFor, + endpoint, } from "../src/index.js"; // GAP: not exported @@ -398,43 +399,36 @@ describe("Issue: simulateRoute() — body preview", () => { 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 didn't at head. + // 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" }), ); - router.onlyExistsInOlderVersions("/legacy", ["GET"]); + // onlyExistsInOlderVersions needs the stored (prefixed) path. + router.onlyExistsInOlderVersions("/api/legacy", ["GET"]); class RestoreLegacy extends VersionChange { - description = "legacy clients had GET /legacy"; - instructions = []; - } - - // Give RestoreLegacy the `existed` instruction via endpoint().existed - // (using a separate change class so we don't modify RestoreLegacy inline) - class RestoreLegacy2 extends VersionChange { - description = "legacy clients had GET /legacy (restored)"; - instructions = [ - // eslint-disable-next-line @typescript-eslint/no-require-imports - (require("../src/index.js") as typeof import("../src/index.js")).endpoint( - "/api/legacy", - ["GET"], - ).existed, - ]; + 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", RestoreLegacy2), + new Version("2025-06-01", RestoreLegacy), new Version("2024-01-01"), ), }); app.generateAndIncludeVersionedRouters(router); - // At HEAD, /api/legacy doesn't exist + // 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", @@ -442,6 +436,7 @@ describe("Issue: simulateRoute() — fallthrough diagnostics", () => { }); expect(result.matchedRoute).toBeNull(); - expect(result.fallthrough.availableAtOtherVersions).toEqual(["2024-01-01"]); + 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 index 32b7000..51e5548 100644 --- a/tests/issue-route-table-dump.test.ts +++ b/tests/issue-route-table-dump.test.ts @@ -142,9 +142,11 @@ describe("Issue: dumpRouteTable()", () => { id: req.params.id, name: "alice", })); - // Legacy route registered but marked deleted in head; existed restores it at 2024-01-01 + // 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("/legacy-only", ["GET"]); + router.onlyExistsInOlderVersions("/api/legacy-only", ["GET"]); const app = new Tsadwyn({ versions: new VersionBundle( From fa955821592702b489373af9b8db95c0c1730f44 Mon Sep 17 00:00:00 2001 From: mahmoudimus Date: Thu, 16 Apr 2026 14:30:40 -0700 Subject: [PATCH 23/58] =?UTF-8?q?fix:=20204=20migrations=20can=20add=20a?= =?UTF-8?q?=20body=20+=20rewrite=20status=20(head=20204=20=E2=86=92=20lega?= =?UTF-8?q?cy=20200+body)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the body-less short-circuit called res.end() without the buffer even when a migration populated responseInfo.body. That dropped the body for the exact 'head returns 204, legacy wants 200+envelope' scenario consumers most want to version. Fix in src/route-generation.ts: - Body-less short-circuit (null/undefined handler return): after migrations run, if responseInfo.body is populated, emit it with the migrated statusCode + recomputed content-length. Otherwise keep the response body-less. - Normal migration path: if a migration cleared the body (head 200+body → legacy 204+empty), emit an empty response instead of attempting JSON.stringify(undefined) which would throw on Buffer.from. Test added in tests/issue-no-content-shortcircuit.test.ts: 'migrations on 204 routes can add a body + change status' — head returns 204+empty for DELETE /users/:id; legacy version uses a headerOnly migration to rewrite statusCode to 200 AND populate res.body with a {deleted, id} envelope. Legacy client sees 200+body; head client still sees 204+empty. 745 tests pass (8/8 in the 204 suite, 737 elsewhere). --- src/route-generation.ts | 43 ++++++++++++--- tests/issue-no-content-shortcircuit.test.ts | 61 +++++++++++++++++++++ 2 files changed, 95 insertions(+), 9 deletions(-) diff --git a/src/route-generation.ts b/src/route-generation.ts index af3a27e..64cd7bb 100644 --- a/src/route-generation.ts +++ b/src/route-generation.ts @@ -1250,7 +1250,22 @@ function createVersionedHandler( migration.transformer(responseInfo); } _applyResponseInfoToExpressResponse(res, responseInfo); - res.status(responseInfo.statusCode).end(); + + // 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; } @@ -1282,14 +1297,24 @@ 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"); - // 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); + // 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); } diff --git a/tests/issue-no-content-shortcircuit.test.ts b/tests/issue-no-content-shortcircuit.test.ts index e1d59dd..8a4eb80 100644 --- a/tests/issue-no-content-shortcircuit.test.ts +++ b/tests/issue-no-content-shortcircuit.test.ts @@ -237,6 +237,67 @@ describe("Issue: 204 No Content — body-migration short-circuit", () => { // 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("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. From 2940d46ad4fbdcb4df61a1b391c8b5666fc8f359 Mon Sep 17 00:00:00 2001 From: mahmoudimus Date: Thu, 16 Apr 2026 14:35:04 -0700 Subject: [PATCH 24/58] test: lock in the 204+body wire-strip constraint; recommend 200 for body-bearing deletes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Motivated by user question: 'what if the API does 204 {deleted: true} across versions with different body shapes?'. Empirical check with an Express + node:http probe confirmed Node's HTTP server strips the body on 204 responses at the wire level regardless of what Express/tsadwyn writes — RFC 9110 §15.3.5 says '204 responses cannot contain content' and Node enforces it. Test updated in tests/issue-no-content-shortcircuit.test.ts: (1) schema-based migration runs correctly on 204+body routes — transformer IS invoked, res.body IS reshaped in memory. Test asserts the spy was called and res.text is '' at the client (body stripped at wire level by Node). (2) NEW test: identical versioning pattern with statusCode: 200 (default) — bodies actually arrive. Recommended pattern for 'resource deleted with envelope' endpoints. Matches Stripe's DELETE /v1/customers/cus_xxx which returns 200 + {id, object, deleted}. This does NOT change runtime behavior — it documents what's possible and recommends the idiomatic workaround. tsadwyn's 204 migration support is correct at the in-memory level; the HTTP wire layer is simply below tsadwyn's control. All 747 tests pass. --- tests/issue-no-content-shortcircuit.test.ts | 166 ++++++++++++++++++++ 1 file changed, 166 insertions(+) diff --git a/tests/issue-no-content-shortcircuit.test.ts b/tests/issue-no-content-shortcircuit.test.ts index 8a4eb80..6795415 100644 --- a/tests/issue-no-content-shortcircuit.test.ts +++ b/tests/issue-no-content-shortcircuit.test.ts @@ -298,6 +298,172 @@ describe("Issue: 204 No Content — body-migration short-circuit", () => { 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. From 6b2b40ca9084509fc015c69120c1b9b04f6966d6 Mon Sep 17 00:00:00 2001 From: mahmoudimus Date: Thu, 16 Apr 2026 14:47:42 -0700 Subject: [PATCH 25/58] =?UTF-8?q?feat:=20deletedResponseSchema=20=E2=80=94?= =?UTF-8?q?=20Stripe-style=20DELETE=20envelope=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/delete-response.ts Produces { id, object, deleted: true } the way Stripe's DELETE endpoints actually return. Motivated by empirical verification: curl -X DELETE https://api.stripe.com/v1/customers/ → HTTP/2 200 + {id, object, deleted} ... NEVER 204. 204 strips the body at the Node HTTP wire level per RFC 9110 §15.3.5. Generic extension point for audit envelopes: deletedResponseSchema('customer', { deleted_at: z.string(), ... }) src/index.ts Public export: deletedResponseSchema. tests/delete-response-helper.test.ts 4 tests covering: - schema validation of the Stripe shape - extensibility (audit fields) - end-to-end route: DELETE returns 200 + body (no wire strip) - version migration: v2 head emits rich envelope with deleted_at + deleted_by; v1 legacy clients see the flat Stripe shape via a response migration that strips the extras 751 tests green, no regressions. --- src/delete-response.ts | 42 +++++++ src/index.ts | 3 + tests/delete-response-helper.test.ts | 167 +++++++++++++++++++++++++++ 3 files changed, 212 insertions(+) create mode 100644 src/delete-response.ts create mode 100644 tests/delete-response-helper.test.ts 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/index.ts b/src/index.ts index 45f2bac..f2f0c7b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -130,6 +130,9 @@ export type { // Outbound payload migration (webhooks, internal events) export { migratePayloadToVersion } from "./migrate-payload.js"; +// Stripe-style deleted-resource response helper +export { deletedResponseSchema } from "./delete-response.js"; + // Debugging / introspection trio: routes / migrations / simulation export { dumpRouteTable } from "./route-table.js"; export type { 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, + }); + }); +}); From b66d9089569a86dc0b1e250cf7e719071a01b638 Mon Sep 17 00:00:00 2001 From: mahmoudimus Date: Thu, 16 Apr 2026 14:59:57 -0700 Subject: [PATCH 26/58] test: failing tests for quick-wins bundle (CLI + 204 lint + raw) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tests/issue-cli-introspection-subcommands.test.ts runRoutes / runMigrations / runSimulate exports on src/cli.js. Cover: json/table/markdown formats, --version/--method/--path-matches filters, --body preview, exit-code semantics. tests/issue-204-body-lint.test.ts Generation-time warn when a route has statusCode: 204 AND a non-null responseSchema. Warn must name the path and '204' and reference the wire-strip + recommend the fix (200 or deletedResponseSchema). Negative cases: statusCode 204 + null responseSchema → no warn; statusCode 200 + schema → no warn. tests/issue-raw-response.test.ts raw({mimeType}) marker for binary/streaming responses. Buffer handler delivers bytes with the declared content-type. Response migrations targeting raw() routes emit a dead-code warn. Error responses still use the JSON error pipeline. --- tests/issue-204-body-lint.test.ts | 150 +++++++++++++++ ...ssue-cli-introspection-subcommands.test.ts | 175 ++++++++++++++++++ tests/issue-raw-response.test.ts | 149 +++++++++++++++ 3 files changed, 474 insertions(+) create mode 100644 tests/issue-204-body-lint.test.ts create mode 100644 tests/issue-cli-introspection-subcommands.test.ts create mode 100644 tests/issue-raw-response.test.ts diff --git a/tests/issue-204-body-lint.test.ts b/tests/issue-204-body-lint.test.ts new file mode 100644 index 0000000..f3b4c2a --- /dev/null +++ b/tests/issue-204-body-lint.test.ts @@ -0,0 +1,150 @@ +/** + * FAILING TEST — statusCode: 204 with a non-null responseSchema is a + * common footgun. The in-memory migration pipeline runs, but Node's + * HTTP writer strips the body at the wire level per RFC 9110 §15.3.5 + * (verified empirically against api.stripe.com). + * + * tsadwyn should warn at generation time so consumers discover this + * during development, not in production when a client reports "I'm + * getting 204 but no body". The warning should recommend the fix + * (use 200 or deletedResponseSchema). + * + * 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-cli-introspection-subcommands.test.ts b/tests/issue-cli-introspection-subcommands.test.ts new file mode 100644 index 0000000..2dbb74e --- /dev/null +++ b/tests/issue-cli-introspection-subcommands.test.ts @@ -0,0 +1,175 @@ +/** + * FAILING TEST — verifies the CLI shells for the introspection triad. + * + * The programmatic APIs (`dumpRouteTable`, `inspectMigrationChain`, + * `simulateRoute`) already exist and have their own test coverage. This + * file proves the CLI subcommands — `tsadwyn routes`, `tsadwyn migrations`, + * `tsadwyn simulate` — are wired up in `cli.ts` and work against a real + * fixture app. + * + * 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"; + +// GAP: these three runners are not exported from cli.ts yet +// @ts-expect-error — intentional +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"); + +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: CLI_APP, + schema: "CliFixtureThing", + 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); + // The fixture has a RenameThingName change at 2001-01-01 — a 2000-01-01 + // client sees it in the response chain. + 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: CLI_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: CLI_APP, + schema: "CliFixtureThing", + 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-raw-response.test.ts b/tests/issue-raw-response.test.ts new file mode 100644 index 0000000..a11068d --- /dev/null +++ b/tests/issue-raw-response.test.ts @@ -0,0 +1,149 @@ +/** + * FAILING TEST — `raw()` binary / streaming response marker. + * + * Consumers that return Buffer or Readable today work (route-generation + * detects and sends them with application/octet-stream), but the pattern + * is undeclared — `responseSchema: null` is a lie (there IS a schema, + * it's just not JSON). The `raw()` marker makes the contract 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 eventually 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, +} from "../src/index.js"; + +// GAP: not exported +// @ts-expect-error — intentional +import { 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"); + }); +}); From e7cbdbd7871803eb2789ec97eb1169ef1151b02c Mon Sep 17 00:00:00 2001 From: mahmoudimus Date: Thu, 16 Apr 2026 15:04:31 -0700 Subject: [PATCH 27/58] feat: CLI introspection trio + 204 body lint + raw() binary marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/cli.ts Three new subcommands wrapping the existing programmatic helpers: tsadwyn routes dumpRouteTable tsadwyn migrations inspectMigrationChain tsadwyn simulate simulateRoute All three follow the runX/StreamedCommandResult pattern established by 'tsadwyn exceptions' — JSON / table / markdown (where applicable) output, non-zero exit with a structured stderr message on failure. src/raw-response.ts raw({mimeType, supportsRanges?}) produces a response marker that sets the Content-Type at send time and signals the generation-time lint that any response migrations targeting this route are dead code (body is opaque bytes). Detects via a Symbol.for() sentinel so it survives dual-install identity traps. Paired with isRawResponse() for consumers building custom introspection. src/route-generation.ts Two new generation-time lints: (1) route has statusCode: 204 AND a non-null responseSchema → warn that Node strips the body at the wire level per RFC 9110 §15.3.5; recommend statusCode: 200 or deletedResponseSchema() (the Stripe-shape helper). (2) response migration (path-based) targets a raw() route → warn that transformers are dead code on opaque-byte routes. Plus: handler detects raw() marker and sets content-type from the marker before sendNonJsonResponse so buffers/streams go out with the declared mime type. tests/fixtures/cli-migrations-app.ts New fixture with a real runtime response migration (convertResponseToPreviousVersionFor) so tsadwyn migrations has a non-empty chain to render. 3 new failing tests → green (12 CLI + 4 204-lint + 4 raw). 771/771 tests pass overall. --- src/cli.ts | 444 ++++++++++++++++++ src/index.ts | 4 + src/raw-response.ts | 71 +++ src/route-generation.ts | 61 +++ tests/fixtures/cli-migrations-app.ts | 49 ++ ...ssue-cli-introspection-subcommands.test.ts | 13 +- 6 files changed, 635 insertions(+), 7 deletions(-) create mode 100644 src/raw-response.ts create mode 100644 tests/fixtures/cli-migrations-app.ts diff --git a/src/cli.ts b/src/cli.ts index fb2c4b3..c919db2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -26,6 +26,9 @@ 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`. @@ -809,6 +812,366 @@ 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 // ───────────────────────────────────────────────────────────────────────── @@ -1020,6 +1383,87 @@ export function createProgram(): Command { } }); + 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/index.ts b/src/index.ts index f2f0c7b..2885f48 100644 --- a/src/index.ts +++ b/src/index.ts @@ -133,6 +133,10 @@ 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 { 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/route-generation.ts b/src/route-generation.ts index 64cd7bb..2051d2a 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, @@ -587,6 +588,59 @@ export function generateVersionedRouters( } } + // 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 @@ -1187,6 +1241,13 @@ function createVersionedHandler( const activeHandler = effectiveHandler || routeDef.handler; const result = await activeHandler(handlerReq); + // 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); 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/issue-cli-introspection-subcommands.test.ts b/tests/issue-cli-introspection-subcommands.test.ts index 2dbb74e..3ccc1d4 100644 --- a/tests/issue-cli-introspection-subcommands.test.ts +++ b/tests/issue-cli-introspection-subcommands.test.ts @@ -20,6 +20,7 @@ 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 () => { @@ -76,8 +77,8 @@ describe("CLI: tsadwyn routes", () => { describe("CLI: tsadwyn migrations", () => { it("runMigrations() returns JSON list of migrations for a schema+version", async () => { const result = await runMigrations({ - app: CLI_APP, - schema: "CliFixtureThing", + app: MIGRATIONS_APP, + schema: "CliMigrationsFixture_Thing", version: "2000-01-01", direction: "response", format: "json", @@ -85,8 +86,6 @@ describe("CLI: tsadwyn migrations", () => { expect(result.exitCode).toBe(0); const parsed = JSON.parse(result.stdout); expect(Array.isArray(parsed)).toBe(true); - // The fixture has a RenameThingName change at 2001-01-01 — a 2000-01-01 - // client sees it in the response chain. expect(parsed.length).toBeGreaterThan(0); expect(parsed[0]).toHaveProperty("version"); expect(parsed[0]).toHaveProperty("changeClassName"); @@ -95,7 +94,7 @@ describe("CLI: tsadwyn migrations", () => { it("runMigrations() exits non-zero when schema is unknown", async () => { const result = await runMigrations({ - app: CLI_APP, + app: MIGRATIONS_APP, schema: "NoSuchSchema", version: "2000-01-01", direction: "response", @@ -107,8 +106,8 @@ describe("CLI: tsadwyn migrations", () => { it("runMigrations() direction defaults to 'response'", async () => { const withDefault = await runMigrations({ - app: CLI_APP, - schema: "CliFixtureThing", + app: MIGRATIONS_APP, + schema: "CliMigrationsFixture_Thing", version: "2000-01-01", format: "json", }); From aefb8a42335e131ef60af5c52e61013078d206c5 Mon Sep 17 00:00:00 2001 From: mahmoudimus Date: Thu, 16 Apr 2026 15:10:43 -0700 Subject: [PATCH 28/58] docs+examples: client pinning explainer + show new features end-to-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README.md New 'Client pinning' section explaining: - Stripe-style per-client pinning model - Terminology: initial / previous / latest / head (explicitly avoiding 'legacy' which implies deprecation — pinned versions are first-class contracts) - Two patterns: explicit header vs per-client default from DB - preVersionPick + perClientDefaultVersion recipe - Upgrade semantics via validateVersionUpgrade - Incremental tsadwyn adoption alongside existing Express routes - The wildcard-before-literal landmine + the lint that catches it examples/task-api.ts End-to-end demonstration of: - errorMapper + exceptionMap (TaskNotFoundError → 404 structured body) - deletedResponseSchema (Stripe-style DELETE envelope with optional audit fields deleted_at / deleted_by that only exist at latest) - raw({mimeType}) for CSV export — content-type text/csv on the wire - migratePayloadToVersion for outbound webhooks (subscribers pinned to different versions get differently-shaped payloads) - buildBehaviorResolver for per-version feature flags - Route registration order (literal /tasks/export.csv BEFORE the /tasks/:taskId wildcard — the lint catches it if you get it wrong) - CLI subcommand hints printed on startup Smoke-tested: POST → errorMapper 404 → CSV raw response → DELETE envelope all work live against the running server. examples/stripe-api.ts - Three domain exception classes (NoSuchCustomerError, etc.) replace plain new Error() throws. - errorMapper produces Stripe's exact error envelope: {error: {code: 'resource_missing', message, param, type}} - DELETE /v1/customers/:customerId uses deletedResponseSchema to emit the exact wire shape we verified via 'curl -X DELETE https://api.stripe.com/v1/customers/' — 200 + {id, object, deleted}, matching Stripe verbatim. - Commented-out perClientDefaultVersion + preVersionPick recipe shows the Stripe-model DB-backed pinning integration. - Fixed a pre-existing bug: schema(ChargeCreate).field('payment_method') .had({name: 'payment_method'}) was a no-op rename that threw InvalidGenerationInstructionError at generation — removed since the request migration handles the source→payment_method mapping. - Startup log emits the CLI introspection commands for routes / migrations / simulate / exceptions against this app. Smoke-tested: POST → 201, DELETE → 200 Stripe shape, errorMapper → 404 Stripe envelope. 771 tests pass, typecheck clean, no regressions. --- README.md | 95 +++++++++++++++ examples/stripe-api.ts | 130 ++++++++++++++++++-- examples/task-api.ts | 268 +++++++++++++++++++++++++++++++++++++++-- 3 files changed, 472 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 546d656..2531041 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,101 @@ 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), + fallback: '2024-01-15', // initial version + 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. + +### Upgrade semantics + +When a client wants to upgrade, use the `validateVersionUpgrade` helper in your `/versioning/upgrade` handler: + +```ts +import { validateVersionUpgrade } from 'tsadwyn'; + +router.post('/versioning/upgrade', UpgradeReq, UpgradeRes, async (req) => { + const current = await accountRepo.getApiVersion(req.body.accountId); + const decision = validateVersionUpgrade({ + current, + target: req.body.target, + supported: versions.versionValues, + // Defaults: allowDowngrade=false, allowNoChange=false, iso-date compare + }); + 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 available for admin force-pin flows. + +### 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. If you register a parameterized route like `GET /widgets/:id` before a sibling literal `GET /widgets/archived` (whether in tsadwyn or upstream Express), the wildcard will shadow the literal silently. tsadwyn emits a generation-time warning when it detects this; register the literal first to fix. + +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 diff --git a/examples/stripe-api.ts b/examples/stripe-api.ts index b615a3c..98c5e51 100644 --- a/examples/stripe-api.ts +++ b/examples/stripe-api.ts @@ -27,8 +27,39 @@ import { convertResponseToPreviousVersionFor, RequestInfo, ResponseInfo, + HttpError, + exceptionMap, + deletedResponseSchema, } 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 +140,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({ @@ -170,10 +207,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 +254,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 +290,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 +320,9 @@ 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 - }), + // In v2024-06-01, charges still had `source` alongside payment_method — + // no schema-side declaration is needed because the request migration + // below maps `source` → `payment_method` before validation. schema(ChargeResource).field("payment_intent").didntExist, schema(PaymentIntentCreate).field("automatic_payment_methods").didntExist, schema(PaymentIntentResource).field("automatic_payment_methods").didntExist, @@ -423,6 +474,53 @@ const app = new Tsadwyn({ ), title: "Stripe-like Payments API", apiVersionHeaderName: "stripe-version", + + // 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", + }, + }), + }), + + // In production, pair with perClientDefaultVersion() for per-account + // pinning from a DB lookup (and preVersionPick for auth). Example: + // + // preVersionPick: (req, res, next) => { authenticate(req).then(u => { + // (req as any).user = u; next(); + // }).catch(next); }, + // apiVersionDefaultValue: perClientDefaultVersion({ + // identify: req => (req as any).user?.accountId ?? null, + // resolvePin: accountId => accountRepo.getApiVersion(accountId), + // fallback: "2024-01-15", + // supportedVersions: ["2024-11-01", "2024-06-01", "2024-01-15"], + // onStalePin: "fallback", + // }), }); app.generateAndIncludeVersionedRouters(router); @@ -436,8 +534,22 @@ 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(" 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 }; From c638b91cbe13a37761721f97b68d9bc3a92830db Mon Sep 17 00:00:00 2001 From: mahmoudimus Date: Thu, 16 Apr 2026 15:24:32 -0700 Subject: [PATCH 29/58] docs(README): comprehensive coverage of all new exports + CLI subcommands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API Reference expanded with five new tables covering everything we shipped over the session: Error handling — HttpError, errorMapper, exceptionMap helpers, isExceptionMapFn Middleware & — onUnsupportedVersion, preVersionPick, version resolution perClientDefaultVersion Helpers — deletedResponseSchema, raw(), migratePayloadToVersion, buildBehaviorResolver, validateVersionUpgrade, migrateResponseBody Route & handler — VersionedRouter.head(), RouteOptions.tags + options statusCode, ResponseMigrationOptions.{migrateHttpErrors, headerOnly} Introspection — dumpRouteTable, inspectMigrationChain, (programmatic) simulateRoute Generation-time — wildcard-before-literal shadow, 204+body wire- lints (auto) strip, body-mig vs 204/304, mig vs raw(), GET+HEAD overlap, reserved _TSADWYN tag prefix CLI section expanded with 4 new subcommands (full flag tables): tsadwyn routes — enumerate route table per version tsadwyn migrations — ordered migration chain for schema+version tsadwyn simulate — simulate a request against the route table tsadwyn exceptions — introspect errorMapper exception table README grew from 265 → 511 lines. typecheck clean, 771 tests pass. --- README.md | 151 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) diff --git a/README.md b/README.md index 2531041..64c18bd 100644 --- a/README.md +++ b/README.md @@ -235,6 +235,65 @@ For running examples of both patterns end-to-end, see [`examples/stripe-api.ts`] | `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. | + +### 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 | +| `validateVersionUpgrade(args)` | Pure policy helper for `/versioning/upgrade` endpoints. Discriminated-union result (`{ok, previous, next}` \| `{ok: false, reason}`). Blocks downgrade + no-change by default; `allowDowngrade`/`allowNoChange` opt-outs; `iso-date` / `semver` / custom comparator. | +| `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. +- `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`. @@ -275,6 +334,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/virtual-accounts/abc/payout \ + --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?" From 244a5bca870ee902e34136f3ffa9f2ae58520c32 Mon Sep 17 00:00:00 2001 From: mahmoudimus Date: Thu, 16 Apr 2026 16:23:09 -0700 Subject: [PATCH 30/58] test: close coverage gaps + stop tracking generated coverage/ artifacts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit coverage/ was already in .gitignore (since before this session) but a historical commit had added the HTML report files to the index. Every run of 'vitest --coverage' dirtied the working tree with regenerated artifacts. Untracking now so the directory is truly local. Test additions (all green): tests/issue-validate-version-upgrade.test.ts — built-in 'semver' comparator Six tests covering compare: 'semver' (parseSemverParts + semverCompare) that was previously untested; existing suite only exercised the default 'iso-date' and a custom-function comparator. Covers: forward upgrade, downgrade block, minor/patch bumps, same- version no-change, naked (no v-prefix) semver, padding missing parts with zero (v1 == v1.0.0). Coverage: 72.85% → 100%. tests/issue-migration-chain-inspector.test.ts — request+path-based direction Two tests covering direction: 'request' + path filter — the branch inspectMigrationChain walks differently from response direction. Covers: path-based request migrations surface in the chain; method filter excludes non-matching methods (POST-only mig + GET filter). Coverage: 86.46% → 98.49%. tests/issue-route-simulation.test.ts — path-based migrations Two tests covering request+response path-based migrations in simulateRoute()'s chain summaries and up-migrated body preview. Covers: summary surfaces schemaName: null for path-based; body preview runs path-based request migs too (not only schema-based). Coverage: 85.66% → 95.22%. 810 tests pass overall. src/ coverage: 90.56% → 91.73%. --- coverage/base.css | 224 -- coverage/block-navigation.js | 87 - coverage/cli.ts.html | 2896 ----------------- coverage/clover.xml | 601 ---- coverage/coverage-final.json | 2 - coverage/favicon.png | Bin 445 -> 0 bytes coverage/index.html | 116 - coverage/prettify.css | 1 - coverage/prettify.js | 2 - coverage/sort-arrow-sprite.png | Bin 138 -> 0 bytes coverage/sorter.js | 210 -- tests/issue-migration-chain-inspector.test.ts | 82 + tests/issue-route-simulation.test.ts | 96 + tests/issue-validate-version-upgrade.test.ts | 89 + 14 files changed, 267 insertions(+), 4139 deletions(-) delete mode 100644 coverage/base.css delete mode 100644 coverage/block-navigation.js delete mode 100644 coverage/cli.ts.html delete mode 100644 coverage/clover.xml delete mode 100644 coverage/coverage-final.json delete mode 100644 coverage/favicon.png delete mode 100644 coverage/index.html delete mode 100644 coverage/prettify.css delete mode 100644 coverage/prettify.js delete mode 100644 coverage/sort-arrow-sprite.png delete mode 100644 coverage/sorter.js 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 c1525b811a167671e9de1fa78aab9f5c0b61cef7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 445 zcmV;u0Yd(XP))rP{nL}Ln%S7`m{0DjX9TLF* zFCb$4Oi7vyLOydb!7n&^ItCzb-%BoB`=x@N2jll2Nj`kauio%aw_@fe&*}LqlFT43 z8doAAe))z_%=P%v^@JHp3Hjhj^6*Kr_h|g_Gr?ZAa&y>wxHE99Gk>A)2MplWz2xdG zy8VD2J|Uf#EAw*bo5O*PO_}X2Tob{%bUoO2G~T`@%S6qPyc}VkhV}UifBuRk>%5v( z)x7B{I~z*k<7dv#5tC+m{km(D087J4O%+<<;K|qwefb6@GSX45wCK}Sn*> - - - - 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 6ed68316eb3f65dec9063332d2f69bf3093bbfab..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 138 zcmeAS@N?(olHy`uVBq!ia0vp^>_9Bd!3HEZxJ@+%Qh}Z>jv*C{$p!i!8j}?a+@3A= zIAGwzjijN=FBi!|L1t?LM;Q;gkwn>2cAy-KV{dn nf0J1DIvEHQu*n~6U}x}qyky7vi4|9XhBJ7&`njxgN@xNA8m%nc 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/tests/issue-migration-chain-inspector.test.ts b/tests/issue-migration-chain-inspector.test.ts index 42a7c47..340eafe 100644 --- a/tests/issue-migration-chain-inspector.test.ts +++ b/tests/issue-migration-chain-inspector.test.ts @@ -302,6 +302,88 @@ describe("Issue: inspectMigrationChain()", () => { ).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"; diff --git a/tests/issue-route-simulation.test.ts b/tests/issue-route-simulation.test.ts index d255a4b..8a08b85 100644 --- a/tests/issue-route-simulation.test.ts +++ b/tests/issue-route-simulation.test.ts @@ -305,6 +305,102 @@ describe("Issue: simulateRoute() — migration visibility", () => { }); }); + 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"; diff --git a/tests/issue-validate-version-upgrade.test.ts b/tests/issue-validate-version-upgrade.test.ts index e24edc5..9c1e724 100644 --- a/tests/issue-validate-version-upgrade.test.ts +++ b/tests/issue-validate-version-upgrade.test.ts @@ -113,4 +113,93 @@ describe("Issue: validateVersionUpgrade policy helper", () => { }); 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); + }); + }); }); From 543b0280e8f9572affcece7f8083dfdb55aef427 Mon Sep 17 00:00:00 2001 From: mahmoudimus Date: Thu, 16 Apr 2026 16:32:42 -0700 Subject: [PATCH 31/58] fix(examples/stripe-api): middle-version charges now accept source OR payment_method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-existing example bug: a 2024-06-01 client sending {source: 'tok_x'} got 422 because the head ChargeCreate schema requires payment_method and validation runs BEFORE the source→payment_method request migration. The fix uses schema instructions to declare the v2024-06-01 shape explicitly: RemoveSourceAddPaymentIntent (v2024-06-01 → v2024-11-01): + schema(ChargeCreate).field('payment_method').had({optional: true}) + schema(ChargeCreate).field('source').existedAs({type: z.string().optional()}) (at v2024-06-01, both fields are optional — validation accepts either) RenameBalanceAndAddPaymentIntents (v2024-01-15 → v2024-06-01): + schema(ChargeCreate).field('payment_method').didntExist + schema(ChargeCreate).field('source').had({optional: false}) (at v2024-01-15, payment_method didn't exist; source was required) Verified live against all three versions: v2024-01-15: POST charges with source only → 200 + source field v2024-06-01: POST charges with source (legacy) → 200 + both fields v2024-06-01: POST charges with payment_method → 200 + both fields v2024-11-01: POST charges with payment_method → 200 + payment_method + payment_intent List at v2024-01-15: source field, no payment_method, no payment_intent List at v2024-11-01: payment_method + payment_intent, no source 782 tests pass, typecheck clean. --- examples/stripe-api.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/examples/stripe-api.ts b/examples/stripe-api.ts index 98c5e51..05507f2 100644 --- a/examples/stripe-api.ts +++ b/examples/stripe-api.ts @@ -320,9 +320,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 — - // no schema-side declaration is needed because the request migration - // below maps `source` → `payment_method` before validation. + // 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, @@ -400,6 +403,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 From 6c82ad04e6b377d441c54e44cb8590573752ce7b Mon Sep 17 00:00:00 2001 From: mahmoudimus Date: Thu, 16 Apr 2026 16:39:36 -0700 Subject: [PATCH 32/58] =?UTF-8?q?feat:=20createVersioningRoutes=20?= =?UTF-8?q?=E2=80=94=20pre-wired=20RESTful=20/versioning=20resource?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/versioning-routes.ts createVersioningRoutes({path?, identify, loadVersion, saveVersion, supportedVersions, allowDowngrade?, allowNoChange?, compare?}) returns a VersionedRouter with: 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 body Composes validateVersionUpgrade internally. Two deliberate design decisions: 1. {from, to} shape gives optimistic concurrency — if the stored pin has drifted (admin force-pin, DB replication, etc.) between the client's GET and POST, the server rejects with 409 instead of silently overwriting. Client re-reads + decides whether to retry. 2. First-upgrade convention: clients who have never pinned read {version: null}. Their first POST passes {from: null, to: X} to install the initial pin. Admin force-pin supported via allowDowngrade: true on a separate mount of the helper with admin-scoped identify. tests/issue-versioning-resource.test.ts 12/12 green. Covers: GET shape, GET 401, POST happy, POST 409 mismatch, POST 400 unsupported, POST 400 downgrade-blocked, POST 400 no-change, POST first-upgrade (from:null), POST 422 malformed body, POST 401, allowDowngrade: true admin flow. README.md 'Upgrade semantics' section rewritten around the new helper — shows the one-import setup, the HTTP shape, optimistic concurrency semantics, first-upgrade convention, admin force-pin pattern. The compose-your-own snippet (direct validateVersionUpgrade + HttpError) retained for consumers who need finer control. 794 tests pass, typecheck clean. --- README.md | 63 +++++- src/index.ts | 5 + src/versioning-routes.ts | 161 ++++++++++++++ tests/issue-versioning-resource.test.ts | 273 ++++++++++++++++++++++++ 4 files changed, 495 insertions(+), 7 deletions(-) create mode 100644 src/versioning-routes.ts create mode 100644 tests/issue-versioning-resource.test.ts diff --git a/README.md b/README.md index 64c18bd..8ffcda5 100644 --- a/README.md +++ b/README.md @@ -149,20 +149,68 @@ const app = new Tsadwyn({ An explicit `x-api-version` header always wins over the resolver — useful for staging, per-request overrides, and admin tooling. -### Upgrade semantics +### Upgrade semantics — the `/versioning` resource -When a client wants to upgrade, use the `validateVersionUpgrade` helper in your `/versioning/upgrade` handler: +tsadwyn ships a pre-wired RESTful `/versioning` resource so consumers don't have to hand-roll the upgrade endpoint. Consumer supplies identify / load / save callbacks; tsadwyn does the rest (policy check via `validateVersionUpgrade`, optimistic concurrency, error envelopes). ```ts -import { validateVersionUpgrade } from 'tsadwyn'; +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); +``` + +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" } +``` -router.post('/versioning/upgrade', UpgradeReq, UpgradeRes, async (req) => { +The client re-reads and decides whether to retry. + +**2. First-upgrade convention.** A client who has never explicitly pinned a version reads `GET /versioning` → `{version: null, …}`. Their first upgrade passes `from: null` to install the initial pin: + +```http +POST /versioning +{ "from": null, "to": "2024-01-01" } + +HTTP/1.1 200 OK +{ "previous_version": null, "current_version": "2024-01-01" } +``` + +**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, - // Defaults: allowDowngrade=false, allowNoChange=false, iso-date compare }); if (!decision.ok) { throw new HttpError(400, { code: decision.reason, detail: decision.detail }); @@ -172,7 +220,7 @@ router.post('/versioning/upgrade', UpgradeReq, UpgradeRes, async (req) => { }); ``` -Forward-only upgrades are the Stripe convention. `allowDowngrade: true` is available for admin force-pin flows. +Forward-only upgrades are the Stripe convention — `allowDowngrade: true` is the admin escape hatch. ### Adopting tsadwyn incrementally alongside existing Express routes @@ -262,7 +310,8 @@ For running examples of both patterns end-to-end, see [`examples/stripe-api.ts`] | `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 | -| `validateVersionUpgrade(args)` | Pure policy helper for `/versioning/upgrade` endpoints. Discriminated-union result (`{ok, previous, next}` \| `{ok: false, reason}`). Blocks downgrade + no-change by default; `allowDowngrade`/`allowNoChange` opt-outs; `iso-date` / `semver` / custom comparator. | +| `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 diff --git a/src/index.ts b/src/index.ts index 2885f48..0175540 100644 --- a/src/index.ts +++ b/src/index.ts @@ -118,6 +118,11 @@ export type { 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 { diff --git a/src/versioning-routes.ts b/src/versioning-routes.ts new file mode 100644 index 0000000..6139292 --- /dev/null +++ b/src/versioning-routes.ts @@ -0,0 +1,161 @@ +/** + * `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; +} + +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 { + const path = opts.path ?? "/versioning"; + const router = new VersionedRouter(); + + // ── GET /versioning ──────────────────────────────────────────────────── + // Returns the authenticated client's current pin + the full supported set. + 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 current = await Promise.resolve(opts.loadVersion(clientId)); + return { + version: current, + 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 stored pin, + // or the server 409s and the client must re-read + retry. + 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 current = await Promise.resolve(opts.loadVersion(clientId)); + + if (from !== current) { + throw new HttpError(409, { + error: "version_mismatch", + expected: from, + actual: current, + }); + } + + // First-upgrade flow: client has never pinned (from === current === null). + // Install the initial pin, subject only to the supported-list check. + if (from === null) { + if (!opts.supportedVersions.includes(to)) { + throw new HttpError(400, { + error: "unsupported", + detail: `Target version "${to}" is not in the supported list.`, + }); + } + await Promise.resolve(opts.saveVersion(clientId, to)); + return { previous_version: null, current_version: to }; + } + + // Standard upgrade: both `from` and `to` are concrete versions. + const decision = validateVersionUpgrade({ + current: from, + 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/issue-versioning-resource.test.ts b/tests/issue-versioning-resource.test.ts new file mode 100644 index 0000000..f6afdf1 --- /dev/null +++ b/tests/issue-versioning-resource.test.ts @@ -0,0 +1,273 @@ +/** + * FAILING TEST — RESTful /versioning resource helper for + * self-service API-version upgrades. + * + * Every Stripe-style adopter ends up writing the same endpoint: a client + * reads their current pin, then posts an upgrade. tsadwyn already ships + * `validateVersionUpgrade` as the policy core; `createVersioningRoutes` + * wraps it in the canonical RESTful resource shape. + * + * GET /versioning → {version, supported[], latest} + * POST /versioning {from, to} → {previous_version, current_version} + * + * `{from, to}` gives optimistic concurrency: if the stored pin has 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, +} from "../src/index.js"; + +// GAP: not exported +// @ts-expect-error — intentional +import { 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 } = {}, +) { + 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, + }); + + 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 the fallback version when no pin is stored", async () => { + const store = buildStore(); + const app = buildApp(store); + + 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); + // When no pin is stored, `version` is null (client is unpinned; + // server falls back to its own default). + 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); + }); + + 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"); + }); +}); From 836d628eebec020e97446e7bd9f54180cc937958 Mon Sep 17 00:00:00 2001 From: mahmoudimus Date: Thu, 16 Apr 2026 16:41:39 -0700 Subject: [PATCH 33/58] docs(README): clarify /versioning is optional + tsadwyn owns no persistence Added an explicit paragraph pointing out: - The /versioning resource is fully opt-in; plenty of APIs don't expose self-service upgrades and skipping this entirely is supported. - Pinned versions live in consumer storage (accounts table column, Redis key, remote service). The helper is a three-callback adapter; tsadwyn never sees the DB. - No shipped migration, no assumed column name, no SQL. Three concrete backend examples showing identical callback surface with different persistence: - Postgres via a query builder (kysely-style) - Redis cache - Remote account-service RPC Notes that async and sync callbacks are both accepted (async awaited, sync treated as resolved), and that errors surface via the standard pipeline (500 by default, or structured via errorMapper). --- README.md | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8ffcda5..6d584b8 100644 --- a/README.md +++ b/README.md @@ -149,9 +149,17 @@ const app = new Tsadwyn({ An explicit `x-api-version` header always wins over the resolver — useful for staging, per-request overrides, and admin tooling. -### Upgrade semantics — the `/versioning` resource +### 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. Consumer supplies identify / load / save callbacks; tsadwyn does the rest (policy check via `validateVersionUpgrade`, optimistic concurrency, error envelopes). +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'; @@ -173,6 +181,46 @@ 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 | From 80de482ddf5b60276324107bbb5dc9baca09de8c Mon Sep 17 00:00:00 2001 From: mahmoudimus Date: Thu, 16 Apr 2026 16:44:04 -0700 Subject: [PATCH 34/58] examples(stripe-api): add live /versioning resource + perClientDefaultVersion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Demonstrates the full Stripe-pattern pinning stack end-to-end: 1. createVersioningRoutes exposes the RESTful /versioning resource (GET + POST with optimistic concurrency) backed by an in-memory clientPins store. In production the consumer's two callbacks (loadVersion, saveVersion) would delegate to their accounts table — tsadwyn owns no persistence; the example's Record is interchangeable with Postgres, Redis, or a remote account-service RPC. 2. perClientDefaultVersion wires the SAME clientPins store as the apiVersionDefaultValue resolver. Requests arriving without a stripe-version header automatically use the caller's stored pin, resolved from x-account-id → clientPins[accountId]. 3. identifyAccount is a single function used by BOTH helpers — one source of truth for 'who is this request on behalf of'. In a real deployment this reads from the authenticated session (Stripe's own model binds the secret key to an account). Startup log adds curl examples for the full flow: GET /versioning → first-upgrade from null → implicit-pin customer create → mid-upgrade → stale-from 409 → downgrade-blocked 400 Verified live against all six scenarios. 794 tests pass, typecheck clean. --- examples/stripe-api.ts | 94 +++++++++++++++++++++++++++++++++++------- 1 file changed, 79 insertions(+), 15 deletions(-) diff --git a/examples/stripe-api.ts b/examples/stripe-api.ts index 05507f2..9b702d1 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, @@ -30,6 +31,8 @@ import { HttpError, exceptionMap, deletedResponseSchema, + createVersioningRoutes, + perClientDefaultVersion, } from "../src/index.js"; // ═══════════════════════════════════════════════════════════════════════════ @@ -177,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)}`; } @@ -476,6 +500,21 @@ 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, + // allowDowngrade: false, // default + // allowNoChange: false, // default +}); + const app = new Tsadwyn({ versions: new VersionBundle( new Version("2024-11-01", RemoveSourceAddPaymentIntent), @@ -485,6 +524,19 @@ 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. onStalePin: 'fallback' means if an + // account has a pin we've since dropped from the bundle, tsadwyn + // treats them as unpinned and uses `fallback` instead. + apiVersionDefaultValue: perClientDefaultVersion({ + identify: identifyAccount, + resolvePin: (accountId) => clientPins[accountId] ?? null, + fallback: "2024-01-15", + 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. @@ -517,23 +569,11 @@ const app = new Tsadwyn({ }, }), }), - - // In production, pair with perClientDefaultVersion() for per-account - // pinning from a DB lookup (and preVersionPick for auth). Example: - // - // preVersionPick: (req, res, next) => { authenticate(req).then(u => { - // (req as any).user = u; next(); - // }).catch(next); }, - // apiVersionDefaultValue: perClientDefaultVersion({ - // identify: req => (req as any).user?.accountId ?? null, - // resolvePin: accountId => accountRepo.getApiVersion(accountId), - // fallback: "2024-01-15", - // supportedVersions: ["2024-11-01", "2024-06-01", "2024-01-15"], - // onStalePin: "fallback", - // }), }); -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 @@ -555,6 +595,30 @@ app.expressApp.listen(PORT, () => { 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`); From 0edd1e4e99a72d9b4e564ad81bd8610436c71de7 Mon Sep 17 00:00:00 2001 From: mahmoudimus Date: Thu, 16 Apr 2026 16:48:24 -0700 Subject: [PATCH 35/58] fix(createVersioningRoutes): add fallback option so GET reports the effective version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Catches an inconsistency between what GET /versioning reports and what tsadwyn actually uses at dispatch: Before — unpinned client with perClientDefaultVersion(fallback: 'v1'): GET /versioning → {version: null} ← reported 'unpinned' POST /anything → runs at 'v1' shape ← but actually uses v1 After — same consumer, with createVersioningRoutes(fallback: 'v1'): GET /versioning → {version: 'v1'} ← effective version POST /anything → runs at 'v1' shape ← same, now consistent Behavior changes: src/versioning-routes.ts New CreateVersioningRoutesOptions.fallback?: string. GET /versioning: returns . POST /versioning: optimistic-concurrency check evaluates 'from' against the EFFECTIVE current (stored || fallback). When a client is unpinned and fallback is configured, both from:null AND from: describe the unpinned state and are accepted. First-upgrade path: when fallback is configured, the first pin install runs through the standard downgrade/no-change policy against the fallback value. A client can't sneak a downgrade through the 'I've never pinned' flow. tests/issue-versioning-resource.test.ts Six new tests in a 'fallback' describe block covering the above. Existing behavior without fallback: unchanged (still returns null). Total: 18/18 green (up from 12). examples/stripe-api.ts Sets fallback: '2024-01-15' to match the perClientDefaultVersion fallback. GET now reports the effective version for unpinned accounts; smoke-tested live. README.md First-upgrade convention section updated to document both paths (with and without fallback), plus the 'from:null == from:fallback' equivalence for unpinned state. 800 tests pass, typecheck clean. --- README.md | 15 +++- examples/stripe-api.ts | 4 + src/versioning-routes.ts | 77 +++++++++++++--- tests/issue-versioning-resource.test.ts | 113 ++++++++++++++++++++++-- 4 files changed, 189 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 6d584b8..4ddb67b 100644 --- a/README.md +++ b/README.md @@ -238,16 +238,25 @@ Two design decisions worth calling out: The client re-reads and decides whether to retry. -**2. First-upgrade convention.** A client who has never explicitly pinned a version reads `GET /versioning` → `{version: null, …}`. Their first upgrade passes `from: null` to install the initial pin: +**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-01-01" } +{ "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-01-01" } +{ "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: diff --git a/examples/stripe-api.ts b/examples/stripe-api.ts index 9b702d1..fe15526 100644 --- a/examples/stripe-api.ts +++ b/examples/stripe-api.ts @@ -511,6 +511,10 @@ const versioningRoutes = createVersioningRoutes({ clientPins[accountId] = version; }, supportedVersions: SUPPORTED_VERSIONS, + // Match perClientDefaultVersion's fallback below so GET /versioning + // reports what tsadwyn would actually use at dispatch: the initial + // version (2024-01-15) when no pin is stored. + fallback: "2024-01-15", // allowDowngrade: false, // default // allowNoChange: false, // default }); diff --git a/src/versioning-routes.ts b/src/versioning-routes.ts index 6139292..3ce9109 100644 --- a/src/versioning-routes.ts +++ b/src/versioning-routes.ts @@ -51,6 +51,19 @@ export interface CreateVersioningRoutesOptions { 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( @@ -86,14 +99,19 @@ export function createVersioningRoutes( // ── 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 current = await Promise.resolve(opts.loadVersion(clientId)); + const stored = await Promise.resolve(opts.loadVersion(clientId)); + const effective = stored ?? opts.fallback ?? null; return { - version: current, + version: effective, supported: [...opts.supportedVersions], // supportedVersions is newest-first per tsadwyn convention, so [0] is head. latest: opts.supportedVersions[0], @@ -101,8 +119,11 @@ export function createVersioningRoutes( }); // ── POST /versioning ─────────────────────────────────────────────────── - // Optimistic-concurrency-aware upgrade. `from` must match the stored pin, - // or the server 409s and the client must re-read + retry. + // 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) { @@ -110,32 +131,64 @@ export function createVersioningRoutes( } const { from, to } = req.body as { from: string | null; to: string }; - const current = await Promise.resolve(opts.loadVersion(clientId)); + const stored = await Promise.resolve(opts.loadVersion(clientId)); + const effective = stored ?? opts.fallback ?? null; - if (from !== current) { + // 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: current, + actual: effective, }); } - // First-upgrade flow: client has never pinned (from === current === null). - // Install the initial pin, subject only to the supported-list check. - if (from === null) { + // 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: null, current_version: 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: from, + current: stored, target: to, supported: opts.supportedVersions, allowDowngrade: opts.allowDowngrade, diff --git a/tests/issue-versioning-resource.test.ts b/tests/issue-versioning-resource.test.ts index f6afdf1..d39ae2f 100644 --- a/tests/issue-versioning-resource.test.ts +++ b/tests/issue-versioning-resource.test.ts @@ -47,7 +47,11 @@ function buildStore() { function buildApp( store: ReturnType, - opts: { allowDowngrade?: boolean; allowNoChange?: boolean } = {}, + opts: { + allowDowngrade?: boolean; + allowNoChange?: boolean; + fallback?: string; + } = {}, ) { const versions = new VersionBundle( new Version("2025-06-01"), @@ -64,6 +68,7 @@ function buildApp( supportedVersions: versions.versionValues, allowDowngrade: opts.allowDowngrade ?? false, allowNoChange: opts.allowNoChange ?? false, + fallback: opts.fallback, }); const app = new Tsadwyn({ versions }); @@ -101,9 +106,9 @@ describe("createVersioningRoutes — RESTful /versioning resource", () => { expect(res.status).toBe(401); }); - it("GET /versioning returns the fallback version when no pin is stored", async () => { + it("GET /versioning returns null for unpinned clients when no fallback is configured", async () => { const store = buildStore(); - const app = buildApp(store); + const app = buildApp(store); // no fallback option const res = await request(app.expressApp) .get("/versioning") @@ -111,8 +116,8 @@ describe("createVersioningRoutes — RESTful /versioning resource", () => { .set("x-api-version", "2025-06-01"); expect(res.status).toBe(200); - // When no pin is stored, `version` is null (client is unpinned; - // server falls back to its own default). + // 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(); }); @@ -251,6 +256,104 @@ describe("createVersioningRoutes — RESTful /versioning resource", () => { 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. From bf4ba5eddbf4ec8d4fb136acb1169f4d8e1fa9e2 Mon Sep 17 00:00:00 2001 From: mahmoudimus Date: Thu, 16 Apr 2026 16:51:07 -0700 Subject: [PATCH 36/58] =?UTF-8?q?feat:=20validation=20errors=20throw=20Val?= =?UTF-8?q?idationError=20(extends=20HttpError)=20=E2=80=94=20consumer=20c?= =?UTF-8?q?an=20reshape=20envelope?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the last fixed-shape gap in tsadwyn's error pipeline. Previously, Zod schema parse failures emitted 'res.status(422).json({detail})' directly — bypassing errorMapper and migrateHttpErrors response migrations. Consumers who wanted their error envelope ({error: {code, message}, ...}) on validation errors couldn't get it without a downstream Express middleware. src/exceptions.ts New class ValidationError extends HttpError, carrying: - where: 'body' | 'params' | 'query' — which validator rejected - body: {detail: } — unchanged wire shape by default - name: 'ValidationError' — keyable in exceptionMap src/route-generation.ts Three validation sites (params, query, body) now throw ValidationError instead of emitting 422 directly. The catch block's existing pipeline picks it up: 1. errorMapper sees err.name === 'ValidationError' and can reshape to the consumer's envelope 2. migrateHttpErrors response migrations apply — per-version envelope shapes work end-to-end 3. If neither intervenes, the default {detail: [...]} wire shape is byte-identical to before tests/issue-validation-error-envelope.test.ts 5/5 green. Covers: backward-compat default shape unchanged, errorMapper reshape via ValidationError keying, where field discriminates body/params/query, per-version reshape via convertResponseToPreviousVersionFor(path, methods, {migrateHttpErrors: true}), instanceof chain (HttpError + ValidationError). 805 tests pass, typecheck clean. No breaking changes — existing tests that assert the {detail: [...]} shape continue to pass unmodified. --- src/exceptions.ts | 32 +++ src/index.ts | 1 + src/route-generation.ts | 22 +- tests/issue-validation-error-envelope.test.ts | 190 ++++++++++++++++++ 4 files changed, 230 insertions(+), 15 deletions(-) create mode 100644 tests/issue-validation-error-envelope.test.ts 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 0175540..8f22b8a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -67,6 +67,7 @@ export { InvalidGenerationInstructionError, ModuleIsNotVersionedError, HttpError, + ValidationError, } from "./exceptions.js"; // OpenAPI diff --git a/src/route-generation.ts b/src/route-generation.ts index 2051d2a..882d423 100644 --- a/src/route-generation.ts +++ b/src/route-generation.ts @@ -29,6 +29,7 @@ import { RouteRequestBySchemaConverterDoesNotApplyToAnythingError, RouteResponseBySchemaConverterDoesNotApplyToAnythingError, HttpError, + ValidationError, } from "./exceptions.js"; import { getSchemaName } from "./zod-extend.js"; import { AlterSchemaInstructionFactory } from "./structure/schemas.js"; @@ -1076,16 +1077,14 @@ function createVersionedHandler( return async (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); } @@ -1094,12 +1093,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; } @@ -1111,10 +1106,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; } 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"); + }); +}); From 0a2b55088aa6cd8f27351f65cb4334bb850f1df9 Mon Sep 17 00:00:00 2001 From: mahmoudimus Date: Thu, 16 Apr 2026 16:55:15 -0700 Subject: [PATCH 37/58] =?UTF-8?q?feat(perClientDefaultVersion):=20add=20pi?= =?UTF-8?q?nOnFirstResolve=20=E2=80=94=20Stripe's=20pin-on-first-call=20se?= =?UTF-8?q?mantic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/per-client-default.ts Two new optional fields on PerClientDefaultVersionOptions: saveVersion: (clientId, version) => void | Promise; pinOnFirstResolve: boolean // default false When pinOnFirstResolve: true AND a client has no stored pin (null from resolvePin), the resolver persists the fallback via saveVersion(clientId, fallback) BEFORE returning it. Subsequent calls find the stored pin and behave like any pinned client. Matches Stripe's 'new accounts pin to current latest at signup' behavior (from the new-account perspective — the pin materializes on first authenticated call rather than at DB insert, but from the client's POV the end state is identical). Construction throws TsadwynStructureError when pinOnFirstResolve is true but saveVersion is missing. pinOnFirstResolve explicitly does NOT overwrite existing stored pins, including stale (out-of-bundle) ones — those continue to flow through onStalePin policy. 'Healing' stale pins is a separate consumer concern; keeping these orthogonal avoids surprise overwrites. saveVersion errors surface as 500 via the standard pipeline (the promise rejection propagates through versionPickingMiddleware's try/catch, which calls next(err)). tests/issue-per-client-default-version.test.ts Six new tests in a 'pinOnFirstResolve' describe block covering: - persist fallback on first call + no extra call on second - does NOT auto-pin unauthenticated (identify returns null) - does NOT overwrite stale stored pins - default (pinOnFirstResolve: false) never calls saveVersion - pinOnFirstResolve: true without saveVersion throws at construction - saveVersion errors surface as 500 15/15 total in the file (up from 9), 811 overall. examples/stripe-api.ts Enables pinOnFirstResolve + saveVersion, with fallback changed from '2024-01-15' (initial) to SUPPORTED_VERSIONS[0] (latest). createVersioningRoutes fallback matches so GET /versioning reports the effective version consistently. Smoke-tested live — first-call from unpinned account auto-pins to latest, second GET confirms the stored pin matches. Full Stripe semantic reproduced end-to-end. README.md perClientDefaultVersion helpers table entry expanded to mention pinOnFirstResolve. Client-pinning section example updated to use fallback: bundle.versionValues[0] + pinOnFirstResolve: true pattern. 811 tests, typecheck clean. --- README.md | 6 +- examples/stripe-api.ts | 29 ++- src/per-client-default.ts | 44 ++++- .../issue-per-client-default-version.test.ts | 170 ++++++++++++++++++ 4 files changed, 235 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 4ddb67b..9fc58e1 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,9 @@ const app = new Tsadwyn({ apiVersionDefaultValue: perClientDefaultVersion({ identify: req => (req as any).user?.accountId ?? null, resolvePin: accountId => accountRepo.getApiVersion(accountId), - fallback: '2024-01-15', // initial version + 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 }), @@ -357,7 +359,7 @@ For running examples of both patterns end-to-end, see [`examples/stripe-api.ts`] | `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. | +| `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. | ### Helpers diff --git a/examples/stripe-api.ts b/examples/stripe-api.ts index fe15526..1fe5125 100644 --- a/examples/stripe-api.ts +++ b/examples/stripe-api.ts @@ -511,10 +511,11 @@ const versioningRoutes = createVersioningRoutes({ clientPins[accountId] = version; }, supportedVersions: SUPPORTED_VERSIONS, - // Match perClientDefaultVersion's fallback below so GET /versioning - // reports what tsadwyn would actually use at dispatch: the initial - // version (2024-01-15) when no pin is stored. - fallback: "2024-01-15", + // 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 }); @@ -530,13 +531,25 @@ const app = new Tsadwyn({ // 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. onStalePin: 'fallback' means if an - // account has a pin we've since dropped from the bundle, tsadwyn - // treats them as unpinned and uses `fallback` instead. + // 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, - fallback: "2024-01-15", + saveVersion: (accountId, version) => { + clientPins[accountId] = version; + }, + fallback: SUPPORTED_VERSIONS[0], // latest + pinOnFirstResolve: true, supportedVersions: SUPPORTED_VERSIONS, onStalePin: "fallback", }), diff --git a/src/per-client-default.ts b/src/per-client-default.ts index 9260846..5506360 100644 --- a/src/per-client-default.ts +++ b/src/per-client-default.ts @@ -34,6 +34,23 @@ export interface PerClientDefaultVersionOptions { }; /** 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; } /** @@ -42,6 +59,13 @@ export interface PerClientDefaultVersionOptions { 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>(); @@ -56,10 +80,22 @@ export function perClientDefaultVersion( } const pin = await Promise.resolve(opts.resolvePin(clientId)); if (pin === null || pin === undefined) { - opts.logger?.warn( - { clientId, reason: "no-stored-pin" }, - `No stored pin for client "${clientId}"; using fallback.`, - ); + // 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)) { diff --git a/tests/issue-per-client-default-version.test.ts b/tests/issue-per-client-default-version.test.ts index e85310d..599ed28 100644 --- a/tests/issue-per-client-default-version.test.ts +++ b/tests/issue-per-client-default-version.test.ts @@ -245,6 +245,176 @@ describe("Issue: perClientDefaultVersion helper", () => { 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 })); From c2306c179fea61f0a75e82db894b2ab9a3602b41 Mon Sep 17 00:00:00 2001 From: mahmoudimus Date: Thu, 16 Apr 2026 17:00:10 -0700 Subject: [PATCH 38/58] docs(README): document stale pins + rationale against auto-heal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New 'Stale pins' subsection in the Client Pinning section covering: - Definition: pin points at a version no longer in the VersionBundle - Four concrete scenarios where it happens: 1. Version retirement (team sunsets an old version) 2. Data seed / backfill drift (imported pins, typos, wrong env) 3. Cross-environment pin drift (staging vs prod bundle skew) 4. Rollback (version was in the bundle, deploy reverted) - Why tsadwyn doesn't auto-heal (overwrite stale pins): - Typos are bugs not fixes — coercion hides the corruption - Retirement-with-coercion violates the versioning contract - Most operators want telemetry, not silent mutation - Healing is consumer policy (upgrade to next, force-pin, flag for manual review) — tsadwyn stays out of that write path - What tsadwyn provides: onStalePin 'fallback' | 'passthrough' | 'reject' with structured warn logs via the logger option Placed between the per-client default resolver recipe and the /versioning resource docs — flows from 'here's how pins are stored' → 'here's what happens when the stored value is out of bundle' → 'here's the helper that manages upgrades explicitly'. --- README.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/README.md b/README.md index 9fc58e1..26c6ce5 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,38 @@ const app = new Tsadwyn({ An explicit `x-api-version` header always wins over the resolver — useful for staging, per-request overrides, and admin tooling. +### 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. + ### 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. From a043f4e2f32cfa3c3297e99a401a5c21b70d4e4a Mon Sep 17 00:00:00 2001 From: mahmoudimus Date: Thu, 16 Apr 2026 17:04:13 -0700 Subject: [PATCH 39/58] docs(README): versioning error responses + migrateHttpErrors / errorMapper interaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the last documentation gap — a dedicated 'Versioning error responses' section in the Client Pinning chapter covers: - Default behavior: error responses bypass response migrations - Per-migration opt-in: migrateHttpErrors: true - Why it's opt-in (not default): stable error envelope contract - What to flip when your error envelope does drift (Stripe pattern) - errorMapper ↔ migrateHttpErrors interaction: domain throw → errorMapper → HttpError (head-shape body) → migrateHttpErrors migrations → client-shape body - ValidationError flows through the same pipeline - headerOnly: true cousin for body-less responses (HEAD/204/304/null handler return) This rounds out the three outstanding issues on the repo: #1 (Discoverability of path-based migrations) — addressed via inspectMigrationChain() programmatic API + 'tsadwyn migrations' CLI subcommand. Both surface the migration chain tsadwyn actually picked up, turning 'nothing happens' into a one-command check ('tsadwyn migrations --app ... --schema ... --version ...'). The issue proposed either a decorator API OR a boot-time diagnostic; we shipped the diagnostic. #2 (errorMapper option) — shipped as specified, plus exceptionMap() declarative helper and 'tsadwyn exceptions' CLI for introspection. ValidationError subclass lets consumers key on validation errors specifically in their exceptionMap config. All four failing- test-plan items from the issue pass. #3 (Docs for migrateHttpErrors + errorMapper interaction) — this commit plus earlier changes. The API Reference tables call out migrateHttpErrors on ResponseMigrationOptions, and the new narrative section explains the full flow including the errorMapper interaction the issue specifically asked for. Fixes #1 Fixes #2 Fixes #3 --- README.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/README.md b/README.md index 26c6ce5..7fa9abb 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,41 @@ Three modes + a structured log surface. When you decide to actually heal, you do 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) **bypass response migrations by default**. A migration opts into running on error paths per-migration via `migrateHttpErrors: true`: + +```ts +class AddErrorCode extends VersionChange { + description = "v2 adds `code` to error bodies; v1 clients get just `message`"; + instructions = []; + + migrate = convertResponseToPreviousVersionFor(MySchema, { + migrateHttpErrors: true, // ← opt in to running on errors + })((res: ResponseInfo) => { + if (res.body?.code !== undefined) { + delete res.body.code; // legacy clients don't have the field + } + }); +} +``` + +**Why it's opt-in** (rather than running on errors by default): many APIs intentionally keep their error envelope stable across versions so clients can centralize error-handling logic without per-version branches. If error migrations ran automatically, that promise would quietly break the first time a consumer added a new field to their error body. + +**What you need if your error envelope actually does change per version** (Stripe's pattern — `type`/`code`/nesting drifted across their version history): flip `migrateHttpErrors: true` on the specific migrations that reshape error bodies. Successful-response migrations with the flag unset stay unaffected. + +**Interaction with `errorMapper`.** When a consumer throws a domain exception and `errorMapper` translates it into an `HttpError`, that `HttpError` enters the response-migration pipeline: + +``` +domain throw → errorMapper produces HttpError(status, body) + → migrations with migrateHttpErrors: true apply to body + → client receives the version-migrated error envelope +``` + +So `errorMapper` defines the **head-shape** error body; the `migrateHttpErrors` 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 otherwise be skipped. + ### 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. From e93d774dad8af052dba16c7e9b502a3f526238f1 Mon Sep 17 00:00:00 2001 From: mahmoudimus Date: Thu, 16 Apr 2026 17:14:22 -0700 Subject: [PATCH 40/58] =?UTF-8?q?feat!:=20migrateHttpErrors=20defaults=20t?= =?UTF-8?q?o=20true=20=E2=80=94=20Stripe-style=20error=20versioning=20by?= =?UTF-8?q?=20default?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING: response migrations now fire on error responses (4xx/5xx) by default. Previously opt-in via migrateHttpErrors: true; now opt- out via migrateHttpErrors: false. Rationale (per the recommendation in #3 marked as 'v2 consideration', plus user confirmation that the one active consumer can absorb the change): tsadwyn's stated goal is Stripe-style versioning, and Stripe's version-pin contract applies to error envelopes too. The prior opt-in default made it silently easy to ship error-body field additions that leaked to legacy-pinned clients without notice. src/structure/data.ts Path-based + schema-based forms of convertResponseToPreviousVersionFor now default migrateHttpErrors to true. src/route-generation.ts Body-less + HEAD short-circuits now gate on headerOnly (the flag that cleanly means 'safe on undefined body'), not migrateHttpErrors (the flag that means 'applies to error-status responses'). Keeps the two concerns orthogonal: - migrateHttpErrors: true → migration fires on 4xx/5xx (default) - migrateHttpErrors: false → migration skips 4xx/5xx (opt-out) - headerOnly: true → migration fires on body-less too - headerOnly: false → migration skips body-less (default) Test updates (no behavior surprises, all 811 tests green): - tests/basic.test.ts: null-check the UserResource migration so it doesn't NPE when the 422 validation body (shape {detail: [...]}) runs through it. Demonstrates the new defensive pattern consumers should apply to schema-targeted migrations. - tests/http-errors.test.ts + migration-coverage.test.ts + response-types.test.ts: the 'skips when false' / 'skips non- flagged migration' tests now explicitly pass {migrateHttpErrors: false} to preserve their success-only assertion. - tests/issue-head-requests.test.ts: the header-migration test switches from migrateHttpErrors: true to headerOnly: true — matches the clarified semantic (headerOnly is for body-less contexts). - tests/issue-migration-chain-inspector.test.ts: the 'success-only' migration explicitly opts out so the includeErrorMigrations filter test has something to filter against. - tests/data-coverage.test.ts: default-value assertions updated (false → true) and the it() titles renamed to match. README.md 'Versioning error responses' section rewritten around the new default. Highlights the defensive null-check pattern for schema- targeted migrations (since error bodies may not match the schema). No migration guide needed — the single known consumer updates per the summary above. Future: if a second consumer hits this, a compat mode TsadwynOptions.errorMigrationMode: 'opt-in' | 'opt-out' can be added without breakage. --- README.md | 47 +++++++++++++------ src/route-generation.ts | 20 ++++++-- src/structure/data.ts | 16 +++++-- tests/basic.test.ts | 10 +++- tests/data-coverage.test.ts | 8 ++-- tests/http-errors.test.ts | 5 +- tests/issue-head-requests.test.ts | 4 +- tests/issue-migration-chain-inspector.test.ts | 7 ++- tests/migration-coverage.test.ts | 5 +- tests/response-types.test.ts | 4 +- 10 files changed, 90 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 7fa9abb..b68ea5a 100644 --- a/README.md +++ b/README.md @@ -185,38 +185,57 @@ For the common cases, `onStalePin: 'fallback'` + pino-style warn telemetry is us ### Versioning error responses -Error responses (4xx / 5xx) **bypass response migrations by default**. A migration opts into running on error paths per-migration via `migrateHttpErrors: true`: +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 = []; - migrate = convertResponseToPreviousVersionFor(MySchema, { - migrateHttpErrors: true, // ← opt in to running on errors - })((res: ResponseInfo) => { - if (res.body?.code !== undefined) { - delete res.body.code; // legacy clients don't have the field - } - }); + // 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 + } + }, + ); } ``` -**Why it's opt-in** (rather than running on errors by default): many APIs intentionally keep their error envelope stable across versions so clients can centralize error-handling logic without per-version branches. If error migrations ran automatically, that promise would quietly break the first time a consumer added a new field to their error body. +**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: -**What you need if your error envelope actually does change per version** (Stripe's pattern — `type`/`code`/nesting drifted across their version history): flip `migrateHttpErrors: true` on the specific migrations that reshape error bodies. Successful-response migrations with the flag unset stay unaffected. +```ts +convertResponseToPreviousVersionFor(UserResource, { migrateHttpErrors: false })( + transformer, // runs on 2xx only — skips every 4xx/5xx +); +``` -**Interaction with `errorMapper`.** When a consumer throws a domain exception and `errorMapper` translates it into an `HttpError`, that `HttpError` enters the response-migration pipeline: +**Interaction with `errorMapper`.** Domain exceptions translated by `errorMapper` enter the same pipeline: ``` domain throw → errorMapper produces HttpError(status, body) - → migrations with migrateHttpErrors: true apply to body + → response migrations apply to body (default: all of them, + unless they pass migrateHttpErrors: false) → client receives the version-migrated error envelope ``` -So `errorMapper` defines the **head-shape** error body; the `migrateHttpErrors` 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. +`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 otherwise be skipped. +**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). ### Upgrade semantics — the `/versioning` resource (optional) diff --git a/src/route-generation.ts b/src/route-generation.ts index 882d423..6b4b22f 100644 --- a/src/route-generation.ts +++ b/src/route-generation.ts @@ -1297,7 +1297,11 @@ function createVersionedHandler( if (isNullResult && !isHead) { const responseInfo = new ResponseInfo(undefined, successStatus); for (const migration of responseMigrations) { - if (!migration.headerOnly && !migration.migrateHttpErrors) { + // 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); @@ -1335,11 +1339,17 @@ function createVersionedHandler( successStatus, ); for (const migration of responseMigrations) { - // T-401: Skip response migration if status >= 300 and migrateHttpErrors is false. - // HEAD is treated like a body-less response — same skip semantics - // (only migrateHttpErrors-flagged or headerOnly migrations run). + // 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 ( - (isHead || responseInfo.statusCode >= 300) && + responseInfo.statusCode >= 300 && !migration.migrateHttpErrors && !migration.headerOnly ) { diff --git a/src/structure/data.ts b/src/structure/data.ts index 58374fb..aa9b5a6 100644 --- a/src/structure/data.ts +++ b/src/structure/data.ts @@ -335,12 +335,15 @@ 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; @@ -404,7 +407,12 @@ 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; 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/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/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-head-requests.test.ts b/tests/issue-head-requests.test.ts index 9bc6532..617c07f 100644 --- a/tests/issue-head-requests.test.ts +++ b/tests/issue-head-requests.test.ts @@ -148,7 +148,9 @@ describe("Issue: HEAD request support", () => { description = "add x-legacy-header for legacy clients"; instructions = []; - r1 = convertResponseToPreviousVersionFor(UserSchema, { migrateHttpErrors: true })( + // 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, ); } diff --git a/tests/issue-migration-chain-inspector.test.ts b/tests/issue-migration-chain-inspector.test.ts index 340eafe..b2034e3 100644 --- a/tests/issue-migration-chain-inspector.test.ts +++ b/tests/issue-migration-chain-inspector.test.ts @@ -217,10 +217,13 @@ describe("Issue: inspectMigrationChain()", () => { } class SuccessMig extends VersionChange { - description = "success-only migration"; + description = "success-only migration (opts out of error responses)"; instructions = []; - migrate = convertResponseToPreviousVersionFor(Order)( + // 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) => {}, ); } 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"; } From 18eb368853ec6f59dbbaf6aa32031b707c53312f Mon Sep 17 00:00:00 2001 From: mahmoudimus Date: Thu, 16 Apr 2026 17:15:24 -0700 Subject: [PATCH 41/58] docs(README): centralizing behavior changes across API versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New 'Versioning behavior changes (not just schemas)' section addressing the half of versioning schema migrations don't cover: same shape, different side effects, policy, or semantics. Five concrete real-world examples: - auto-capture vs deferred-capture charges - rate-limit tier changes per version - cascading vs soft-delete semantics - idempotency-key TTL drift - webhook retry policy changes Pattern 1 — VersionChangeWithSideEffects.isApplied: Good for boolean toggles ('v2 does X differently'). Named & centralized, auto-documents into the changelog, lint-grep-able. Static isApplied getter enforces 'at-or-newer-than' semantic correctly so handlers never hand-roll date comparisons. Pattern 2 — buildBehaviorResolver(map, fallback): Good for per-version configurable values (rate limits, timeouts, retry counts, feature configs). One resolver, many callsites, zero scattered version-string comparisons. onUnknown telemetry mode surfaces typos / stale pins without breaking the request. Decision table maps the shape of the change to the appropriate primitive. Notes that both primitives read from the same apiVersionStorage the dispatch pipeline writes — no extra wiring. Placed right after 'Versioning error responses' in the Client Pinning section since it completes the 'what a client's pin controls' story: shape (schemas), error envelope (migrateHttpErrors), and now behavior (isApplied / buildBehaviorResolver). --- README.md | 100 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/README.md b/README.md index b68ea5a..359031c 100644 --- a/README.md +++ b/README.md @@ -237,6 +237,106 @@ domain throw → errorMapper produces HttpError(status, body) **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: `buildBehaviorResolver(map, fallback)` — map version → value + +Good for **configurable values** that differ per version: rate limits, timeouts, retry counts, feature flags, field defaults. One resolver, many callsites, zero scattered comparisons. + +```ts +import { buildBehaviorResolver } from 'tsadwyn'; + +// Define the map once, close to the other versioning concerns. +const getRateLimit = buildBehaviorResolver( + new Map([ + ['2025-01-01', { perSecond: 1000, burst: 2000 }], + ['2024-06-01', { perSecond: 500, burst: 1000 }], + ['2024-01-01', { perSecond: 100, burst: 200 }], + ]), + { perSecond: 1000, burst: 2000 }, // fallback when version is unknown/absent + { onUnknown: 'warn-once', logger: pinoLogger }, // optional telemetry +); + +// Handlers and middleware read a value, never a version string: +router.use((req, res, next) => { + const limit = getRateLimit(); // reads apiVersionStorage, returns the right config + enforceRateLimit(req, limit); + next(); +}); + +router.post('/bulk-import', ..., async (req) => { + const { burst } = getRateLimit(); // same resolver, different callsite + // ... +}); +``` + +**When `buildBehaviorResolver` beats `isApplied`:** + +- The behavior has **more than two states** — e.g., three different rate limits across three versions +- You want to surface the per-version values in docs / admin UIs (`Object.fromEntries(map)` gives you the snapshot) +- A future version might want to introduce a new tier without touching every caller + +**`onUnknown` telemetry** (`'silent'` / `'warn-once'` / `'warn-every'`) gives you a signal when a request shows up with a version the map doesn't know about — usually a typo'd header or a stale pin. The resolver returns `fallback` so the request still completes while the log alerts you. + +#### Which to reach for + +| Shape of the change | Reach for | +|---|---| +| One thing on; one thing off — "v2 does X differently" | `VersionChangeWithSideEffects.isApplied` | +| A value that changes per version (int, config object, feature flags) | `buildBehaviorResolver` | +| A behavior change that ALSO changes the wire shape | A regular `VersionChange` with migrations + `isApplied` in the handler | + +Both primitives 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. From 4e88a67f6381b07d26298b53a0e1833008aa0b3c Mon Sep 17 00:00:00 2001 From: mahmoudimus Date: Fri, 17 Apr 2026 08:52:28 -0700 Subject: [PATCH 42/58] feat(request-context): currentRequest() accessor auto-captures Express Request inside dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tsadwyn handlers receive a deliberately stripped view — { body, params, query, headers } — so handlers can't silently reach into arbitrary parts of the Express Request. Real apps still need to read middleware-injected state (req.user, claims, req.tenantId, trace IDs) that isn't part of the wire contract, and the workarounds consumers landed on (captureRequestContext as LAST middleware + a local currentRequest() helper) were both fragile and per-consumer boilerplate. This ships the escape hatch as a framework primitive: - currentRequest() / currentRequestOrNull() — read the raw Request from inside any handler or migration callback. - requestContextStorage — the underlying AsyncLocalStorage, exported for advanced use (instrumentation, tests). - Capture happens inside the dispatch wrapper in createVersionedHandler right before the handler's try block, so no middleware-ordering discipline is required on the consumer side. Handlers AND migration callbacks see the correct req via ALS propagation; concurrent requests are isolated. currentRequest() throws when called outside a dispatch scope (loud failure on misuse); currentRequestOrNull() returns null for library-internal helpers where absence is valid. --- src/index.ts | 8 ++ src/request-context.ts | 56 ++++++++++ src/route-generation.ts | 15 ++- tests/current-request.test.ts | 193 ++++++++++++++++++++++++++++++++++ 4 files changed, 270 insertions(+), 2 deletions(-) create mode 100644 src/request-context.ts create mode 100644 tests/current-request.test.ts diff --git a/src/index.ts b/src/index.ts index 8f22b8a..f6dea68 100644 --- a/src/index.ts +++ b/src/index.ts @@ -107,6 +107,14 @@ export { migrateResponseBody } from "./migrate.js"; export { perClientDefaultVersion } from "./per-client-default.js"; export type { PerClientDefaultVersionOptions } from "./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"; 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 6b4b22f..8160779 100644 --- a/src/route-generation.ts +++ b/src/route-generation.ts @@ -33,6 +33,7 @@ import { } 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 @@ -1075,7 +1076,17 @@ function createVersionedHandler( ): (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. Thrown as ValidationError so // the error flows through the catch block's errorMapper + @@ -1450,7 +1461,7 @@ function createVersionedHandler( // Non-HTTP errors continue to the Express error handler next(err); } - }; + } } /** 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" }); + }); +}); From c6e4144875a02295cf60ed459038bc39cd57ed39 Mon Sep 17 00:00:00 2001 From: mahmoudimus Date: Fri, 17 Apr 2026 08:52:59 -0700 Subject: [PATCH 43/58] =?UTF-8?q?feat(versioned-behavior):=20createVersion?= =?UTF-8?q?edBehavior=20=E2=80=94=20typed=20per-version=20behavior=20overl?= =?UTF-8?q?ay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema migrations cover the wire shape. The other half of API versioning is behavior — same wire shape, different side effects or defaults. tsadwyn already had VersionChangeWithSideEffects (on/off flags) and buildBehaviorResolver (raw Map), but production adopters kept rolling their own typed-shape + per-version-overlay scaffolding. This ships that pattern as a first-class primitive. createVersionedBehavior({ head, changes, initialVersion? }) takes: - head: the latest (HEAD) behavior snapshot, typed by the caller. - changes: ReadonlyArray>, each declaring `version` (when the change took effect) and `behaviorHad: Partial` (the pre-change value). Partial is a compile-time contract — typo'd field names fail tsc. - initialVersion: optional oldest-supported version key. When supplied, every `behaviorHad` collapses onto that key to reconstruct the "before any tracked change" snapshot. Returns { get, at, map }: - get() reads apiVersionStorage and returns the snapshot (falls back to `fallback` on unknown version; delegates to buildBehaviorResolver for the ALS read + unknown-version telemetry). - at(version) is an explicit lookup — throws on unknown, for tests and admin UIs where absence is a bug. - map is a readonly snapshot of every resolved version → behavior, for changelog rendering / admin introspection. Same-version duplicates merge partials; conflicting field writes trigger a logger.warn with last-write-wins semantics. --- src/index.ts | 8 + src/versioned-behavior.ts | 204 ++++++++++++++++++++ tests/versioned-behavior.test.ts | 308 +++++++++++++++++++++++++++++++ 3 files changed, 520 insertions(+) create mode 100644 src/versioned-behavior.ts create mode 100644 tests/versioned-behavior.test.ts diff --git a/src/index.ts b/src/index.ts index f6dea68..1631100 100644 --- a/src/index.ts +++ b/src/index.ts @@ -119,6 +119,14 @@ export { 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"; + // Canonical upgrade-policy helper for /versioning/upgrade endpoints export { validateVersionUpgrade } from "./version-upgrade.js"; export type { diff --git a/src/versioned-behavior.ts b/src/versioned-behavior.ts new file mode 100644 index 0000000..85d6aa9 --- /dev/null +++ b/src/versioned-behavior.ts @@ -0,0 +1,204 @@ +/** + * `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"; + /** Optional structured logger. Required when `onUnknown !== 'silent'`. */ + 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/tests/versioned-behavior.test.ts b/tests/versioned-behavior.test.ts new file mode 100644 index 0000000..e1327c6 --- /dev/null +++ b/tests/versioned-behavior.test.ts @@ -0,0 +1,308 @@ +/** + * 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); + }); +}); From 0961bbca451d21a8af8979499b167a53389d47fd Mon Sep 17 00:00:00 2001 From: mahmoudimus Date: Fri, 17 Apr 2026 08:53:25 -0700 Subject: [PATCH 44/58] feat(cached-per-client-default): TTL cache + invalidation handles for high-QPS default resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit perClientDefaultVersion calls resolvePin on every authenticated request that doesn't send an explicit version header — fine for modest load, dominant cost on a high-QPS API with DB-backed pin storage. Production adopters layered their own TTL'd map + invalidation hook on top. cachedPerClientDefaultVersion ships that pattern verbatim, with a few correctness details baked in so consumers don't have to get them right themselves. Returns { resolver, invalidate, invalidateAll }: - resolver: Promise-returning function suitable for apiVersionDefaultValue on Tsadwyn / versionPickingMiddleware. Reads from the in-memory Map before consulting resolvePin. - invalidate(clientId): drops one entry. Wire this into the upgrade endpoint so a client who just upgraded doesn't serve stale for up to ttlMs. - invalidateAll(): nuke every entry. For rolling deploys / test teardowns. Correctness details handled by the primitive: - Single-flight on cold cache: concurrent first-misses for the same clientId share one resolvePin promise — the other N-1 await instead of firing duplicate queries. - Error bypass: if resolvePin rejects, the cache entry is deleted on rejection so the next request retries fresh instead of serving fallback for the full TTL. - pinOnFirstResolve interaction: when a genuinely-unpinned client hits the resolver and saveVersion persists the fallback, the newly-saved pin is cached immediately. - ttlMs: 0 disables caching entirely (every call falls through); negative values throw at construction. --- src/cached-per-client-default.ts | 196 ++++++++++++++++ src/index.ts | 7 + tests/cached-per-client-default.test.ts | 298 ++++++++++++++++++++++++ 3 files changed, 501 insertions(+) create mode 100644 src/cached-per-client-default.ts create mode 100644 tests/cached-per-client-default.test.ts diff --git a/src/cached-per-client-default.ts b/src/cached-per-client-default.ts new file mode 100644 index 0000000..0c78004 --- /dev/null +++ b/src/cached-per-client-default.ts @@ -0,0 +1,196 @@ +/** + * `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'. */ + 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. + 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/index.ts b/src/index.ts index 1631100..7d4511f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -107,6 +107,13 @@ export { migrateResponseBody } from "./migrate.js"; 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 { 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); + }); +}); From bff4e3cacd895ccdd6d088d3c8d391e002df070c Mon Sep 17 00:00:00 2001 From: mahmoudimus Date: Fri, 17 Apr 2026 08:54:39 -0700 Subject: [PATCH 45/58] feat(route-shadowing): configurable detector for wildcard-before-literal routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit path-to-regexp resolves routes in registration order and takes the first pattern that matches. Registering GET /users/:id before GET /users/search silently routes /users/search to the :id handler, which then 400s with "search is not a valid UUID" from deep inside a validator — a mystery- bug pattern every real-world adopter eventually hits. There was already an inline console.warn in generateVersionedRouters for this case. This commit promotes it to a proper module with a configurable policy, exposes the detection helpers publicly (so CLI tools and direct callers of generateVersionedRouters can use them), and removes the old inline check — the enclosing Tsadwyn application now runs the detector once at initialization time before binding. New exports: - detectRouteShadows(routes) -> RouteShadow[] Pure scan; groups routes by method, returns every (earlier wildcard, later literal) pair on the same method. Heuristic conservatively treats :param(\\d+) constraints as catch-alls so we warn on rare edge cases rather than miss them. - reportRouteShadows(shadows, policy, logger?) Applies the 'warn' | 'throw' | 'silent' policy; structured logger falls back to console.warn. TsadwynOptions gains: - onRouteShadowing: 'warn' (default) | 'throw' | 'silent' 'warn' matches prior behavior (one log line per shadow, boot continues). 'throw' is the CI-enforcement mode: surfaces as TsadwynStructureError. 'silent' is for acknowledging an intentional shadow. - routeShadowingLogger: optional structured logger. Overlapping-wildcard cases (/a/:x then /a/:y) are deliberately ignored — those are either Express-detected duplicates or ambiguous enough that warning would be noise. --- src/application.ts | 43 +++++++ src/index.ts | 8 ++ src/route-generation.ts | 48 +------- src/route-shadowing.ts | 207 ++++++++++++++++++++++++++++++++++ tests/route-shadowing.test.ts | 176 +++++++++++++++++++++++++++++ 5 files changed, 438 insertions(+), 44 deletions(-) create mode 100644 src/route-shadowing.ts create mode 100644 tests/route-shadowing.test.ts diff --git a/src/application.ts b/src/application.ts index f1d6e6c..d8e9533 100644 --- a/src/application.ts +++ b/src/application.ts @@ -20,6 +20,12 @@ 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"; /** * Regex for validating ISO date strings (YYYY-MM-DD). @@ -171,6 +177,24 @@ export interface TsadwynOptions { * Pairs with `exceptionMap()` for a declarative, introspectable map form. */ errorMapper?: (err: unknown) => import("./exceptions.js").HttpError | null; + + /** + * 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; } /** @@ -254,6 +278,16 @@ export class Tsadwyn { /** 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; + /** * Access the internal versioned routers map. * Used by the CLI and for introspection. @@ -305,6 +339,10 @@ export class Tsadwyn { // 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; + // T-1003: Validate version format and ordering this._validateVersionFormat(); @@ -588,6 +626,11 @@ 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, diff --git a/src/index.ts b/src/index.ts index 7d4511f..6003690 100644 --- a/src/index.ts +++ b/src/index.ts @@ -134,6 +134,14 @@ export type { 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 { diff --git a/src/route-generation.ts b/src/route-generation.ts index 8160779..1dc0c52 100644 --- a/src/route-generation.ts +++ b/src/route-generation.ts @@ -545,50 +545,10 @@ export function generateVersionedRouters( // T-1604: Validate path-based converter usage against all routes validatePathConverterUsage(versions, allRoutes); - // Lint: wildcard-before-literal collisions. - // path-to-regexp is first-match-wins; if a parameterized route (e.g., - // /widgets/:id) is registered before a sibling literal (/widgets/archived), - // the wildcard intercepts every request and — combined with any UUID/slug - // validator middleware on the wildcard — silently 400s the literal. Warn at - // generation time naming both routes so consumers can reorder (or fix - // upstream with explicit regex validators). - { - const sameMethodCheck = (a: string, b: string): boolean => - a.toUpperCase() === b.toUpperCase(); - const pathsCollide = (wildcardPath: string, literalPath: string): boolean => { - const aSeg = wildcardPath.split("/").filter((s) => s.length > 0); - const bSeg = literalPath.split("/").filter((s) => s.length > 0); - if (aSeg.length !== bSeg.length) return false; - let hasWildcardOverLiteral = false; - for (let i = 0; i < aSeg.length; i++) { - const a = aSeg[i]; - const b = bSeg[i]; - if (a.startsWith(":") && !b.startsWith(":")) { - hasWildcardOverLiteral = true; - } else if (a !== b) { - return false; - } - } - return hasWildcardOverLiteral; - }; - for (let i = 0; i < versionedRouter.routes.length; i++) { - for (let j = i + 1; j < versionedRouter.routes.length; j++) { - const earlier = versionedRouter.routes[i]; - const later = versionedRouter.routes[j]; - if (!sameMethodCheck(earlier.method, later.method)) continue; - if (pathsCollide(earlier.path, later.path)) { - // eslint-disable-next-line no-console - console.warn( - `tsadwyn: route [${earlier.method}] ${earlier.path} is registered ` + - `before sibling literal [${later.method}] ${later.path}. path-to-regexp ` + - `is first-match-wins — the wildcard will intercept requests meant for ` + - `the literal. Reorder so the literal is registered first, or split ` + - `the wildcard's pattern to exclude the literal segment.`, - ); - } - } - } - } + // 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 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/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(); + }); +}); From af28c778e29edb8b64f5515516ddb62c8aaf525d Mon Sep 17 00:00:00 2001 From: mahmoudimus Date: Fri, 17 Apr 2026 08:55:06 -0700 Subject: [PATCH 46/58] docs(README): deep-dive sections for currentRequest, high-traffic caching, route shadowing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flesh out the README for the four new primitives. Each section focuses on WHAT, WHEN, and HOW IT WORKS rather than just listing the API shape: - 'Versioning behavior changes' section updated to use the new createVersionedBehavior primitive instead of the four-file hand-rolled walker. Collapses ~60 lines of example into one block; buildBehaviorResolver is called out as the low-level escape hatch. Updated 'Which to reach for' table entries. - New '### High-traffic caching — cachedPerClientDefaultVersion' section under Client pinning. Covers the amortized-reads story, single-flight cold-cache dedup, the explicit invalidation contract (and what happens if you forget to invalidate after an upgrade), the TTL's real job as a safety net for multi-pod deploys, error bypass semantics, and when NOT to use it (when upstream auth already resolves the pin). - New '### Reading the raw request — currentRequest()' section explaining the stripped-handler-view design, the escape hatch for middleware- injected state, how auto-capture works without mount-order discipline, concurrent-request isolation, and the anti-pattern (if a value belongs in the versioned contract, put it on the schema). - New '### Route shadowing — the first-match-wins trap' section with a concrete before/after snippet of the /users/:id then /users/search bug, a per-method detection heuristic explainer, and a policy table (warn/throw/silent) with when-to-pick-which guidance. - API Reference tables gain entries for currentRequest / currentRequestOrNull, cachedPerClientDefaultVersion, createVersionedBehavior, and the onRouteShadowing policy option. gitignore: add migrate-to-latest.md alongside the other local-only spec/scratchpad artifacts (tsadwyn-issue-*.md, consumer-integration-followups.md, docs/superpowers/). --- .gitignore | 1 + README.md | 259 ++++++++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 226 insertions(+), 34 deletions(-) diff --git a/.gitignore b/.gitignore index 8fca7be..2916c47 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ 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 359031c..7108e11 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,50 @@ const app = new Tsadwyn({ 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: @@ -288,54 +332,115 @@ router.delete('/users/:id', null, null, async (req) => { - 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: `buildBehaviorResolver(map, fallback)` — map version → value +#### Pattern 2: Declarative `behaviorHad` overlay — typed behavior per version -Good for **configurable values** that differ per version: rate limits, timeouts, retry counts, feature flags, field defaults. One resolver, many callsites, zero scattered comparisons. +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 { buildBehaviorResolver } from 'tsadwyn'; - -// Define the map once, close to the other versioning concerns. -const getRateLimit = buildBehaviorResolver( - new Map([ - ['2025-01-01', { perSecond: 1000, burst: 2000 }], - ['2024-06-01', { perSecond: 500, burst: 1000 }], - ['2024-01-01', { perSecond: 100, burst: 200 }], - ]), - { perSecond: 1000, burst: 2000 }, // fallback when version is unknown/absent - { onUnknown: 'warn-once', logger: pinoLogger }, // optional telemetry -); +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. +} -// Handlers and middleware read a value, never a version string: -router.use((req, res, next) => { - const limit = getRateLimit(); // reads apiVersionStorage, returns the right config - enforceRateLimit(req, limit); - next(); +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' }, + }, + ], }); -router.post('/bulk-import', ..., async (req) => { - const { burst } = getRateLimit(); // same resolver, different callsite - // ... +// 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; }); ``` -**When `buildBehaviorResolver` beats `isApplied`:** +**Why `createVersionedBehavior` beats a raw `Map`:** -- The behavior has **more than two states** — e.g., three different rate limits across three versions -- You want to surface the per-version values in docs / admin UIs (`Object.fromEntries(map)` gives you the snapshot) -- A future version might want to introduce a new tier without touching every caller +- **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`. -**`onUnknown` telemetry** (`'silent'` / `'warn-once'` / `'warn-every'`) gives you a signal when a request shows up with a version the map doesn't know about — usually a typo'd header or a stale pin. The resolver returns `fallback` so the request still completes while the log alerts you. +**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 thing on; one thing off — "v2 does X differently" | `VersionChangeWithSideEffects.isApplied` | -| A value that changes per version (int, config object, feature flags) | `buildBehaviorResolver` | -| A behavior change that ALSO changes the wire shape | A regular `VersionChange` with migrations + `isApplied` in the handler | +| 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 | -Both primitives 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. +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) @@ -467,6 +572,89 @@ router.post('/versioning', UpgradeReq, UpgradeRes, async (req) => { 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: @@ -485,7 +673,7 @@ expressApp.use(versioned.expressApp); expressApp.use(existingRouter); ``` -**One landmine to watch for:** path-to-regexp is first-match-wins. If you register a parameterized route like `GET /widgets/:id` before a sibling literal `GET /widgets/archived` (whether in tsadwyn or upstream Express), the wildcard will shadow the literal silently. tsadwyn emits a generation-time warning when it detects this; register the literal first to fix. +**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`). @@ -546,6 +734,8 @@ For running examples of both patterns end-to-end, see [`examples/stripe-api.ts`] | `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 @@ -555,6 +745,7 @@ For running examples of both patterns end-to-end, see [`examples/stripe-api.ts`] | `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) | @@ -581,7 +772,7 @@ For running examples of both patterns end-to-end, see [`examples/stripe-api.ts`] 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. +- 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). @@ -682,7 +873,7 @@ Simulate a request against the route table *without* dispatching. Answers "is ts ```bash # Matched route + candidates + migration chain tsadwyn simulate --app ./src/app.ts \ - --method POST --path /api/virtual-accounts/abc/payout \ + --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) From 15c291b23f19954d6b96755840450cdc62245862 Mon Sep 17 00:00:00 2001 From: mahmoudimus Date: Sat, 18 Apr 2026 16:36:21 -0700 Subject: [PATCH 47/58] fix(versioning-routes): throw on empty/missing supportedVersions at construction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An empty `supportedVersions: []` would let `latest: supportedVersions[0]` resolve to `undefined`, failing the VersioningState Zod schema at dispatch time with a cryptic "expected string, got undefined" error far from the actual misconfiguration. Validate up-front so the problem surfaces at `createVersioningRoutes(...)` construction with a message pointing at the fix (typically `bundle.versionValues`). Also covers the undefined case (caller omits the option entirely) via the same guard — required by the interface but JS callers could still pass undefined at runtime. Addresses Copilot review feedback on PR #4. --- src/versioning-routes.ts | 13 +++++++ tests/issue-versioning-resource.test.ts | 49 ++++++++++++++++++------- 2 files changed, 48 insertions(+), 14 deletions(-) diff --git a/src/versioning-routes.ts b/src/versioning-routes.ts index 3ce9109..ced9149 100644 --- a/src/versioning-routes.ts +++ b/src/versioning-routes.ts @@ -94,6 +94,19 @@ const UpgradeResponse = named( 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(); diff --git a/tests/issue-versioning-resource.test.ts b/tests/issue-versioning-resource.test.ts index d39ae2f..6fdd146 100644 --- a/tests/issue-versioning-resource.test.ts +++ b/tests/issue-versioning-resource.test.ts @@ -1,18 +1,14 @@ /** - * FAILING TEST — RESTful /versioning resource helper for - * self-service API-version upgrades. - * - * Every Stripe-style adopter ends up writing the same endpoint: a client - * reads their current pin, then posts an upgrade. tsadwyn already ships - * `validateVersionUpgrade` as the policy core; `createVersioningRoutes` - * wraps it in the canonical RESTful resource shape. + * 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} * - * `{from, to}` gives optimistic concurrency: if the stored pin has drifted - * since the client last read it, the server rejects with 409 rather than - * silently overwriting. + * 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 */ @@ -23,12 +19,9 @@ import { Tsadwyn, Version, VersionBundle, + createVersioningRoutes, } from "../src/index.js"; -// GAP: not exported -// @ts-expect-error — intentional -import { createVersioningRoutes } from "../src/index.js"; - // An in-memory "account repo" simulates the consumer's persistence layer. function buildStore() { const pins: Record = {}; @@ -374,3 +367,31 @@ describe("createVersioningRoutes — RESTful /versioning resource", () => { 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); + }); +}); From e894bd82d857bcb435ad21ea133d6f2dee0dcdb2 Mon Sep 17 00:00:00 2001 From: mahmoudimus Date: Sat, 18 Apr 2026 16:36:35 -0700 Subject: [PATCH 48/58] chore(tests): remove stale @ts-expect-error + GAP framing from now-passing gap tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These test files were originally written as failing-import drivers to prove missing primitives before the implementation landed — "FAILING TEST" headers + `@ts-expect-error` pragmas on the imports, explicit "GAP: not exported" comments. The primitives have all shipped, so the stale framing is actively misleading for future readers. Cleanup per file: - Replace "FAILING TEST — verifies the gap..." headers with a clear "Covers ..." description of what the test actually asserts. - Collapse the fake-failing separate import back into the main import block where the other tsadwyn exports are imported. - Drop the @ts-expect-error pragmas + "GAP" / "failing-import signal" comments. Files touched: Flagged by Copilot review on PR #4: tests/issue-build-behavior-resolver.test.ts tests/issue-cli-introspection-subcommands.test.ts tests/issue-raw-response.test.ts tests/issue-route-table-dump.test.ts tests/issue-validate-version-upgrade.test.ts Same stale pattern, caught by grep while we were here: tests/issue-exception-map.test.ts tests/issue-migrate-payload-to-version.test.ts tests/issue-migration-chain-inspector.test.ts tests/issue-per-client-default-version.test.ts tests/issue-route-simulation.test.ts tests/issue-versioning-resource.test.ts Preserved intentionally: - tests/versioned-behavior.test.ts:186 — a legitimate `@ts-expect-error` on `behaviorHad: { nonExistentField: true }` asserting the `Partial` type contract at compile time. This is a real contract guard, not stale framing. No behavior change. All 862 tests still pass, typecheck clean. --- tests/issue-build-behavior-resolver.test.ts | 22 +++++----------- ...ssue-cli-introspection-subcommands.test.ts | 15 +++++------ tests/issue-exception-map.test.ts | 15 ++++------- .../issue-migrate-payload-to-version.test.ts | 26 ++++++------------- tests/issue-migration-chain-inspector.test.ts | 15 ++++------- .../issue-per-client-default-version.test.ts | 15 ++++------- tests/issue-raw-response.test.ts | 17 +++++------- tests/issue-route-simulation.test.ts | 21 +++++---------- tests/issue-route-table-dump.test.ts | 14 +++------- tests/issue-validate-version-upgrade.test.ts | 12 +++------ 10 files changed, 57 insertions(+), 115 deletions(-) diff --git a/tests/issue-build-behavior-resolver.test.ts b/tests/issue-build-behavior-resolver.test.ts index 12d836e..f64e10c 100644 --- a/tests/issue-build-behavior-resolver.test.ts +++ b/tests/issue-build-behavior-resolver.test.ts @@ -1,25 +1,15 @@ /** - * FAILING TEST — verifies the gap described in tsadwyn-issues-additional-gaps.md §1 - * - * Every consumer of tsadwyn ends up writing the same 3-line behavior-map - * resolver: - * - * const v = apiVersionStorage.getStore() ?? HEAD; - * return map.get(v) ?? HEAD_BEHAVIOR; - * - * The proposed `buildBehaviorResolver(map, fallback, opts?)` standardizes - * this with optional warn-once / warn-every / silent telemetry on unknown - * versions. + * 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 } from "../src/index.js"; -// GAP: buildBehaviorResolver is not exported from tsadwyn yet. This import -// will fail at module load until the helper ships. -// @ts-expect-error — intentional: drives the failing-import signal -import { buildBehaviorResolver } from "../src/index.js"; +import { apiVersionStorage, buildBehaviorResolver } from "../src/index.js"; interface Behavior { feature: string; diff --git a/tests/issue-cli-introspection-subcommands.test.ts b/tests/issue-cli-introspection-subcommands.test.ts index 3ccc1d4..10b670f 100644 --- a/tests/issue-cli-introspection-subcommands.test.ts +++ b/tests/issue-cli-introspection-subcommands.test.ts @@ -1,11 +1,10 @@ /** - * FAILING TEST — verifies the CLI shells for the introspection triad. - * - * The programmatic APIs (`dumpRouteTable`, `inspectMigrationChain`, - * `simulateRoute`) already exist and have their own test coverage. This - * file proves the CLI subcommands — `tsadwyn routes`, `tsadwyn migrations`, - * `tsadwyn simulate` — are wired up in `cli.ts` and work against a real - * fixture app. + * 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 */ @@ -13,8 +12,6 @@ import { describe, it, expect } from "vitest"; import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; -// GAP: these three runners are not exported from cli.ts yet -// @ts-expect-error — intentional import { runRoutes, runMigrations, runSimulate } from "../src/cli.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); diff --git a/tests/issue-exception-map.test.ts b/tests/issue-exception-map.test.ts index 2f9f381..799b616 100644 --- a/tests/issue-exception-map.test.ts +++ b/tests/issue-exception-map.test.ts @@ -1,9 +1,8 @@ /** - * FAILING TEST — verifies the gap described in tsadwyn-issue-exception-map-helper.md - * - * exceptionMap() is a declarative helper on top of the errorMapper option - * that adds introspection (has/lookup/registeredNames/describe) and CLI - * integration via `tsadwyn exceptions`. + * 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 */ @@ -21,13 +20,9 @@ import { ResponseInfo, convertResponseToPreviousVersionFor, TsadwynStructureError, + exceptionMap, } from "../src/index.js"; -// GAP: not exported -// @ts-expect-error — intentional -import { exceptionMap } from "../src/index.js"; -// GAP: CLI subcommand not available yet -// @ts-expect-error — intentional import { runExceptions } from "../src/cli.js"; // --------------------------------------------------------------------------- diff --git a/tests/issue-migrate-payload-to-version.test.ts b/tests/issue-migrate-payload-to-version.test.ts index 90a6727..8a176f6 100644 --- a/tests/issue-migrate-payload-to-version.test.ts +++ b/tests/issue-migrate-payload-to-version.test.ts @@ -1,17 +1,11 @@ /** - * FAILING TEST — verifies the gap described in tsadwyn-issues-additional-gaps.md §2 - * - * `convertResponseToPreviousVersionFor` only fires for in-flight HTTP - * responses. Outbound webhooks dispatched from background jobs hand-build - * payloads that bypass the migration pipeline entirely — a client pinned to - * an older API version receives head-shaped webhook bodies. - * - * The proposal is a standalone helper: - * - * migratePayloadToVersion(schemaName, payload, targetVersion, versionBundle) - * - * that walks the same response migrations registered against `schemaName` - * and returns the payload reshaped for `targetVersion`. + * 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 @@ -28,13 +22,9 @@ import { VersionChange, ResponseInfo, convertResponseToPreviousVersionFor, + migratePayloadToVersion, } from "../src/index.js"; -// GAP: migratePayloadToVersion is not exported. The import is intentionally -// expected to fail at module-load until the helper ships. -// @ts-expect-error — intentional: drives the failing-import signal -import { migratePayloadToVersion } from "../src/index.js"; - const VirtualAccount = z .object({ id: z.string(), diff --git a/tests/issue-migration-chain-inspector.test.ts b/tests/issue-migration-chain-inspector.test.ts index b2034e3..64c915c 100644 --- a/tests/issue-migration-chain-inspector.test.ts +++ b/tests/issue-migration-chain-inspector.test.ts @@ -1,10 +1,8 @@ /** - * FAILING TEST — verifies the gap described in tsadwyn-issue-migration-chain-inspector.md - * - * Today: no public API for introspecting which migrations fire for a - * given schema + client version. - * - * These tests turn green when `inspectMigrationChain()` is exported. + * 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 */ @@ -21,12 +19,9 @@ import { RequestInfo, convertResponseToPreviousVersionFor, convertRequestToNextVersionFor, + inspectMigrationChain, } from "../src/index.js"; -// GAP: not exported -// @ts-expect-error — intentional -import { inspectMigrationChain } from "../src/index.js"; - const Order = z .object({ id: z.string(), amount: z.number(), currency: z.string() }) .named("IssueMigChain_Order"); diff --git a/tests/issue-per-client-default-version.test.ts b/tests/issue-per-client-default-version.test.ts index 599ed28..d765765 100644 --- a/tests/issue-per-client-default-version.test.ts +++ b/tests/issue-per-client-default-version.test.ts @@ -1,11 +1,8 @@ /** - * FAILING TEST — verifies the gap described in tsadwyn-issue-per-client-default-version.md - * - * Today: consumers hand-roll the resolver chain (identify → resolvePin → - * fallback), forget to dedupe, and forget the stale-pin case. - * - * These tests turn green when `perClientDefaultVersion` is exported with - * the contract documented in the issue spec. + * 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 */ @@ -18,10 +15,8 @@ import { VersionBundle, VersionedRouter, apiVersionStorage, + perClientDefaultVersion, } from "../src/index.js"; -// GAP: not exported today -// @ts-expect-error — intentional -import { perClientDefaultVersion } from "../src/index.js"; describe("Issue: perClientDefaultVersion helper", () => { it("identifies client, resolves pin, uses it as default version", async () => { diff --git a/tests/issue-raw-response.test.ts b/tests/issue-raw-response.test.ts index a11068d..b473acd 100644 --- a/tests/issue-raw-response.test.ts +++ b/tests/issue-raw-response.test.ts @@ -1,14 +1,14 @@ /** - * FAILING TEST — `raw()` binary / streaming response marker. + * Covers the `raw()` binary / streaming response marker. * - * Consumers that return Buffer or Readable today work (route-generation - * detects and sends them with application/octet-stream), but the pattern - * is undeclared — `responseSchema: null` is a lie (there IS a schema, - * it's just not JSON). The `raw()` marker makes the contract explicit: + * 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 eventually describe the binary response shape. + * - OpenAPI output can describe the binary response shape. * * Run: npx vitest run tests/issue-raw-response.test.ts */ @@ -23,12 +23,9 @@ import { VersionedRouter, ResponseInfo, convertResponseToPreviousVersionFor, + raw, } from "../src/index.js"; -// GAP: not exported -// @ts-expect-error — intentional -import { raw } from "../src/index.js"; - describe("Issue: raw() binary/streaming response marker", () => { let warnSpy: ReturnType; diff --git a/tests/issue-route-simulation.test.ts b/tests/issue-route-simulation.test.ts index 8a08b85..2dd82b1 100644 --- a/tests/issue-route-simulation.test.ts +++ b/tests/issue-route-simulation.test.ts @@ -1,14 +1,10 @@ /** - * FAILING TEST — verifies the gap described in tsadwyn-issue-route-simulation-debug-tool.md - * - * `simulateRoute()` is the programmatic API that answers "is tsadwyn - * responsible for this request, and if so, what would it do?" without - * actually dispatching. Input: method + path + version (+ optional body). - * Output: matched route (if any), every candidate and why it did/didn't - * match, fallthrough reason with closest-miss suggestions, and the - * request/response migration chains that would run. - * - * These tests turn green when `simulateRoute()` is exported. + * 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 */ @@ -26,12 +22,9 @@ import { convertRequestToNextVersionFor, convertResponseToPreviousVersionFor, endpoint, + simulateRoute, } from "../src/index.js"; -// GAP: not exported -// @ts-expect-error — intentional -import { simulateRoute } from "../src/index.js"; - const UserResp = z .object({ id: z.string(), name: z.string() }) .named("IssueRouteSim_User"); diff --git a/tests/issue-route-table-dump.test.ts b/tests/issue-route-table-dump.test.ts index 51e5548..cfe83fa 100644 --- a/tests/issue-route-table-dump.test.ts +++ b/tests/issue-route-table-dump.test.ts @@ -1,10 +1,7 @@ /** - * FAILING TEST — verifies the gap described in tsadwyn-issue-route-table-dump.md - * - * Today: no public API for enumerating registered routes per version. - * Consumers grep source or read private `_versionedRouters`. - * - * These tests turn green when `dumpRouteTable()` is exported. + * 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 */ @@ -18,12 +15,9 @@ import { VersionChange, VersionedRouter, endpoint, + dumpRouteTable, } from "../src/index.js"; -// GAP: not exported -// @ts-expect-error — intentional -import { 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"); diff --git a/tests/issue-validate-version-upgrade.test.ts b/tests/issue-validate-version-upgrade.test.ts index 9c1e724..e96e641 100644 --- a/tests/issue-validate-version-upgrade.test.ts +++ b/tests/issue-validate-version-upgrade.test.ts @@ -1,17 +1,13 @@ /** - * FAILING TEST — verifies the gap described in tsadwyn-issues-additional-gaps.md §4 - * - * Every adopter writing a `/versioning/upgrade` endpoint re-implements the - * same upgrade-policy decisions: is target supported, is it a downgrade, is - * it a no-op, how do we compare version strings. The proposed - * `validateVersionUpgrade()` standardizes this as a pure function. + * 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"; -// GAP: validateVersionUpgrade is not exported from tsadwyn yet. -// @ts-expect-error — intentional: drives the failing-import signal import { validateVersionUpgrade } from "../src/index.js"; const SUPPORTED = ["2026-01-01", "2025-06-01", "2025-01-01", "2024-01-01"] as const; From dd464fd1606d555c574bfa4827ce67662cb1b895 Mon Sep 17 00:00:00 2001 From: mahmoudimus Date: Sat, 18 Apr 2026 20:01:05 -0700 Subject: [PATCH 49/58] fix(behavior-resolver): enforce logger requirement when onUnknown is warn-* MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docstring on `BuildBehaviorResolverOptions.logger` has always said the logger is "Required if onUnknown !== 'silent'" but the implementation silently no-op'd warnings when the logger was missing. That's the worst kind of bug — a user opts into telemetry (sets `onUnknown: 'warn-once'`), forgets to pass a logger, and never finds out why their warnings don't appear. Fail loudly at construction instead. Throws `TsadwynStructureError` with an actionable message when `onUnknown` is `'warn-once'` or `'warn-every'` and `logger` is absent. `onUnknown: 'silent'` (the default) requires nothing — the behavior there is unchanged. `createVersionedBehavior` delegates telemetry options through to `buildBehaviorResolver`, so the guard flows through without additional code. Added delegation-locking tests in `tests/versioned-behavior.test.ts` so a future refactor can't accidentally reintroduce the silent-no-op footgun by changing the delegation. TDD-verified by reverting the guard and running the 4 new enforcement tests — all 4 fail as expected. With the guard restored, all 869 tests pass. Addresses Copilot comments on src/behavior-resolver.ts:17 and src/versioned-behavior.ts:82. --- src/behavior-resolver.ts | 21 ++++++++++- src/versioned-behavior.ts | 7 +++- tests/issue-build-behavior-resolver.test.ts | 40 +++++++++++++++++++++ tests/versioned-behavior.test.ts | 40 +++++++++++++++++++++ 4 files changed, 106 insertions(+), 2 deletions(-) diff --git a/src/behavior-resolver.ts b/src/behavior-resolver.ts index b6e7ef1..8c08729 100644 --- a/src/behavior-resolver.ts +++ b/src/behavior-resolver.ts @@ -5,6 +5,7 @@ */ import { apiVersionStorage } from "./middleware.js"; +import { TsadwynStructureError } from "./exceptions.js"; export interface BuildBehaviorResolverOptions { /** @@ -14,7 +15,13 @@ export interface BuildBehaviorResolverOptions { * - 'warn-every' — warn on every unknown lookup. */ onUnknown?: "silent" | "warn-once" | "warn-every"; - /** Optional structured logger. Required if `onUnknown !== 'silent'`. */ + /** + * 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; }; @@ -28,6 +35,10 @@ export interface BuildBehaviorResolverOptions { * 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, @@ -36,6 +47,14 @@ export function buildBehaviorResolver( ): () => 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. diff --git a/src/versioned-behavior.ts b/src/versioned-behavior.ts index 85d6aa9..790cbd3 100644 --- a/src/versioned-behavior.ts +++ b/src/versioned-behavior.ts @@ -76,7 +76,12 @@ export interface CreateVersionedBehaviorOptions { fallback?: B; /** Telemetry policy for unknown-version lookups via `.get()`. */ onUnknown?: "silent" | "warn-once" | "warn-every"; - /** Optional structured logger. Required when `onUnknown !== 'silent'`. */ + /** + * 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; }; diff --git a/tests/issue-build-behavior-resolver.test.ts b/tests/issue-build-behavior-resolver.test.ts index f64e10c..f1e30de 100644 --- a/tests/issue-build-behavior-resolver.test.ts +++ b/tests/issue-build-behavior-resolver.test.ts @@ -137,3 +137,43 @@ describe("Issue: buildBehaviorResolver helper", () => { 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/versioned-behavior.test.ts b/tests/versioned-behavior.test.ts index e1327c6..05cd109 100644 --- a/tests/versioned-behavior.test.ts +++ b/tests/versioned-behavior.test.ts @@ -306,3 +306,43 @@ describe("createVersionedBehavior — type contract", () => { 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(); + }); +}); From 3ed1f669d72fc16fe6cfbb8925d27b975ad80c3b Mon Sep 17 00:00:00 2001 From: mahmoudimus Date: Sat, 18 Apr 2026 20:01:17 -0700 Subject: [PATCH 50/58] chore(tests): sweep remaining stale "FAILING TEST" headers into plain coverage docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to e894bd8, which caught all the test files with the stale @ts-expect-error pragma but missed 8 more that only had the narrative "FAILING TEST — verifies the gap..." header language (no @ts-expect-error to grep on). Copilot's second review flagged one of them (issue-error-mapper.test.ts); grep-ing for "FAILING TEST" found the other 7 while we were in there. Files touched: Flagged by Copilot second review: tests/issue-error-mapper.test.ts Same stale pattern, caught by grep: tests/issue-204-body-lint.test.ts tests/issue-head-requests.test.ts tests/issue-no-content-shortcircuit.test.ts tests/issue-on-unsupported-version.test.ts tests/issue-pre-version-pick-hook.test.ts tests/issue-route-options-tags.test.ts tests/issue-wildcard-route-collision.test.ts Each file's header rewritten to describe what the test actually asserts ("Covers ...") with an Invariants / Under test list where useful. Removed "will turn green when..." / "These tests turn green when..." wording since the primitives all shipped. Dropped the inline `(router.ts:188-246)` source-line pointers that rot with refactors. No behavior change. All 869 tests still pass, typecheck clean. --- tests/issue-204-body-lint.test.ts | 15 ++++------ tests/issue-error-mapper.test.ts | 30 ++++++++------------ tests/issue-head-requests.test.ts | 28 +++++++----------- tests/issue-no-content-shortcircuit.test.ts | 27 ++++++------------ tests/issue-on-unsupported-version.test.ts | 19 +++++-------- tests/issue-pre-version-pick-hook.test.ts | 27 +++++++++--------- tests/issue-route-options-tags.test.ts | 24 ++++++---------- tests/issue-wildcard-route-collision.test.ts | 24 ++++++---------- 8 files changed, 77 insertions(+), 117 deletions(-) diff --git a/tests/issue-204-body-lint.test.ts b/tests/issue-204-body-lint.test.ts index f3b4c2a..4878777 100644 --- a/tests/issue-204-body-lint.test.ts +++ b/tests/issue-204-body-lint.test.ts @@ -1,13 +1,10 @@ /** - * FAILING TEST — statusCode: 204 with a non-null responseSchema is a - * common footgun. The in-memory migration pipeline runs, but Node's - * HTTP writer strips the body at the wire level per RFC 9110 §15.3.5 - * (verified empirically against api.stripe.com). - * - * tsadwyn should warn at generation time so consumers discover this - * during development, not in production when a client reports "I'm - * getting 204 but no body". The warning should recommend the fix - * (use 200 or deletedResponseSchema). + * 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 */ diff --git a/tests/issue-error-mapper.test.ts b/tests/issue-error-mapper.test.ts index 2788800..7198267 100644 --- a/tests/issue-error-mapper.test.ts +++ b/tests/issue-error-mapper.test.ts @@ -1,23 +1,17 @@ /** - * FAILING TEST — verifies the gap described in tsadwyn-issue-error-mapper.md + * 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. * - * Today, when a handler throws a domain exception that doesn't carry a - * `statusCode` property, tsadwyn's `_isHttpLikeError()` check fails and the - * error escapes via `next(err)` to Express's default error handler. That - * bypasses the response-migration pipeline entirely and forces consumers to - * couple their domain layer to tsadwyn's internal detection. - * - * The proposed fix adds an `errorMapper` option on `TsadwynOptions` — a pure - * function `(err: unknown) => HttpError | null` invoked inside the handler's - * catch block before `_isHttpLikeError`. When it returns an `HttpError`, the - * existing migration / status / header machinery picks up. When it returns - * `null`, current behavior (`next(err)`) is preserved. - * - * These tests will turn green when: - * 1. `TsadwynOptions.errorMapper` is accepted at 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 + * 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 */ diff --git a/tests/issue-head-requests.test.ts b/tests/issue-head-requests.test.ts index 617c07f..1852a7e 100644 --- a/tests/issue-head-requests.test.ts +++ b/tests/issue-head-requests.test.ts @@ -1,22 +1,16 @@ /** - * FAILING TEST — verifies the gap described in tsadwyn-issue-head-requests.md + * Covers explicit HEAD method support in `VersionedRouter`: * - * Today: - * - VersionedRouter has no .head() method (router.ts:188-246); consumers - * can't register HEAD handlers. - * - HEAD requests to a registered GET route land on the GET handler via - * Express's default HEAD-mirrors-GET behavior, but response-body - * migrations run against the would-be body and the output is discarded - * — wasted work. - * - migrateHttpErrors behavior on HEAD is untested. - * - * These tests turn green when: - * 1. VersionedRouter.head() exists with the same signature as .get() - * 2. The generated handler skips response-body migrations when req.method === 'HEAD' - * 3. Header migrations still fire on HEAD - * 4. migrateHttpErrors applies on HEAD error paths (status + headers, no body) - * 5. A 405 is returned with an Allow header when HEAD is requested on a path with no matching GET - * 6. A lint warn fires at generation time when .get() and .head() share a path + * 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 */ diff --git a/tests/issue-no-content-shortcircuit.test.ts b/tests/issue-no-content-shortcircuit.test.ts index 6795415..ad9a645 100644 --- a/tests/issue-no-content-shortcircuit.test.ts +++ b/tests/issue-no-content-shortcircuit.test.ts @@ -1,23 +1,14 @@ /** - * FAILING TEST — verifies the gap described in tsadwyn-issue-no-content-shortcircuit.md + * Covers 204 No-Content short-circuit semantics in the dispatch pipeline: * - * Today: - * - A 204 route with a schema-shared response migration MAY invoke the - * body-mutating transformer with `undefined` body (pipeline contract - * is unspecified). - * - There is no `headerOnly: true` option on response migrations — if - * you want a header migration on a 204 route, you have to write a - * body-safe transformer and hope the pipeline treats it right. - * - No registration-time lint catches "body migration targeting 204 route". - * - Handler returning a non-empty body on a 204-declared route is not - * explicitly rejected. - * - * These tests turn green when: - * 1. 204 routes with return undefined / null produce empty body - * 2. Body-mutating migrations are skipped on 204 (no NPE) - * 3. headerOnly: true migrations fire on 204 - * 4. Registration-time lint warns for body-mutating migrations on 204 routes - * 5. TsadwynStructureError thrown when handler returns non-empty body on 204 route + * 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 */ diff --git a/tests/issue-on-unsupported-version.test.ts b/tests/issue-on-unsupported-version.test.ts index 8dc523b..4fede97 100644 --- a/tests/issue-on-unsupported-version.test.ts +++ b/tests/issue-on-unsupported-version.test.ts @@ -1,16 +1,11 @@ /** - * FAILING TEST — verifies the gap described in tsadwyn-issues-additional-gaps.md §3 - * - * Today, an unknown `X-Api-Version` header is stored verbatim in - * `apiVersionStorage`. The internal dispatcher then 422s, but consumers have - * no way to: - * - return a structured 400 with `{error, sent, supported}` (Stripe-like) - * - silently fall back to the configured default and emit telemetry - * - keep the existing passthrough behavior explicitly - * - * The proposal adds `onUnsupportedVersion: 'reject' | 'fallback' | 'passthrough'` - * to `versionPickingMiddleware`'s options, default `'passthrough'` to preserve - * current behavior. + * 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 */ diff --git a/tests/issue-pre-version-pick-hook.test.ts b/tests/issue-pre-version-pick-hook.test.ts index 6bd11ae..c3c8d3f 100644 --- a/tests/issue-pre-version-pick-hook.test.ts +++ b/tests/issue-pre-version-pick-hook.test.ts @@ -1,18 +1,19 @@ /** - * FAILING TEST — verifies the gap described in tsadwyn-issue-pre-version-pick-hook.md + * 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. * - * Today: TsadwynOptions has no `preVersionPick` hook. The only way to run - * consumer middleware before version pick is to supply the full - * `versioningMiddleware` override, which forces consumers to re-implement - * header extraction, default resolution, and apiVersionStorage scoping. - * - * These tests turn green when: - * 1. preVersionPick runs before versionPickingMiddleware - * 2. req.user set by preVersionPick is visible inside apiVersionDefaultValue - * 3. Errors in preVersionPick propagate via next(err) - * 4. Async preVersionPick is supported - * 5. Combining with versioningMiddleware throws TsadwynStructureError - * 6. apiVersionStorage is empty inside preVersionPick + * 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 */ diff --git a/tests/issue-route-options-tags.test.ts b/tests/issue-route-options-tags.test.ts index b3e196e..29e6fd4 100644 --- a/tests/issue-route-options-tags.test.ts +++ b/tests/issue-route-options-tags.test.ts @@ -1,19 +1,13 @@ /** - * FAILING TEST — verifies the gap described in tsadwyn-issue-route-options-tags.md - * - * Today: - * - RouteDefinition.tags exists (router.ts:26) and flows into OpenAPI. - * - endpoint().had({tags}) can mutate tags per-version. - * - But RouteOptions has no `tags` field — consumers can't set tags - * at registration time. - * - * These tests turn green when: - * 1. RouteOptions.tags is accepted at registration - * 2. Those tags flow into RouteDefinition.tags - * 3. OpenAPI output emits them as operation.tags - * 4. endpoint().had({tags}) replaces the registration-time list - * 5. Warn emitted for tags matching _TSADWYN prefix - * 6. Tags are deduped at OpenAPI emission + * 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 */ diff --git a/tests/issue-wildcard-route-collision.test.ts b/tests/issue-wildcard-route-collision.test.ts index c2fed2f..cf087a4 100644 --- a/tests/issue-wildcard-route-collision.test.ts +++ b/tests/issue-wildcard-route-collision.test.ts @@ -1,20 +1,14 @@ /** - * FAILING TEST — verifies the gap described in tsadwyn-issue-wildcard-route-collision.md + * 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. * - * path-to-regexp matches first-registered-wins. If `GET /widgets/:id` is - * registered before sibling literal `GET /widgets/archived`, the wildcard - * captures `:id = "archived"` and any UUID validator middleware on the - * wildcard 400s the request — the literal handler never runs. - * - * tsadwyn does not warn at registration time and does not auto-sort. The bug - * is only visible the first time the literal endpoint is exercised against - * a real client. - * - * Acceptable resolutions (the test passes if EITHER holds): - * 1. A warning is emitted at `generateAndIncludeVersionedRouters()` / - * `generateVersionedRouters()` time naming both colliding routes. - * 2. Routes are auto-sorted so literals precede wildcard siblings, and - * the literal endpoint is reachable. + * 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 */ From 0358f18c3334cbd3d30595d993d79697d0fd3795 Mon Sep 17 00:00:00 2001 From: mahmoudimus Date: Sat, 18 Apr 2026 22:01:45 -0700 Subject: [PATCH 51/58] fix(application): catch onStartup async rejections instead of escaping as unhandled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TDD cycle addressing review finding #1. Test-first: tests/reviewer-findings.test.ts::Finding #1 registers a process-level unhandledRejection listener, constructs a Tsadwyn with an async onStartup that throws, and asserts no unhandled rejection is captured. Before this fix, the test fails (the rejection escapes because `this._onStartup()` at application.ts:666 was called without .catch, discarding the returned Promise). On Node 20+ this would terminate the process by default. Fix: wrap the call in Promise.resolve().then(hook).catch(log). Matches the existing handling pattern on the sibling onShutdown hook at application.ts:434. onStartup remains fire-and-forget for the caller — we don't await it inside _performInitialization (which is still sync) because adding async would ripple through every call site. The .catch guarantees the exception is observable via console.error instead of crashing. --- src/application.ts | 12 +- tests/reviewer-findings.test.ts | 346 ++++++++++++++++++++++++++++++++ 2 files changed, 356 insertions(+), 2 deletions(-) create mode 100644 tests/reviewer-findings.test.ts diff --git a/src/application.ts b/src/application.ts index d8e9533..2cc1c8b 100644 --- a/src/application.ts +++ b/src/application.ts @@ -661,9 +661,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); + }); } } diff --git a/tests/reviewer-findings.test.ts b/tests/reviewer-findings.test.ts new file mode 100644 index 0000000..5a674cb --- /dev/null +++ b/tests/reviewer-findings.test.ts @@ -0,0 +1,346 @@ +/** + * 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 +// Bug: the function only iterates `_alterResponseBySchemaInstructions`. +// Any migration registered via `convertResponseToPreviousVersionFor(path, +// methods)` is silently skipped when a consumer calls migratePayloadToVersion +// for outbound webhooks/events — resulting in unmigrated payloads reaching +// older clients with no error. +// ──────────────────────────────────────────────────────────────────────────── +describe("Finding #2 (HIGH): migratePayloadToVersion applies path-based migrations", () => { + it("transforms the payload when only a path-based migration exists", () => { + 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, + }; + const migrated = migratePayloadToVersion( + "Finding2_WebhookEvent", + head, + "2024-01-01", + versions, + ); + + // The path-based migration SHOULD have renamed event_type → type. + expect(migrated).toEqual({ + id: "evt_1", + 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", () => { + function simpleApp() { + const router = new VersionedRouter(); + + router.head("/download", null, raw({ mimeType: "application/octet-stream" }), async () => { + return Buffer.from("secret-payload-should-not-ship", "utf-8"); + }); + + router.head("/text", null, null, async () => { + return "text-payload-should-not-ship"; + }); + + const app = new Tsadwyn({ + versions: new VersionBundle(new Version("2024-01-01")), + }); + app.generateAndIncludeVersionedRouters(router); + return app; + } + + it("Buffer response on HEAD: no body on the wire", async () => { + const app = simpleApp(); + const res = await request(app.expressApp) + .head("/download") + .set("x-api-version", "2024-01-01"); + + expect(res.status).toBe(200); + // supertest's body on HEAD is a Buffer; length 0 means no body was sent. + // Accept empty Buffer, empty string, or undefined — all represent "no body". + const bodyLength = + res.body instanceof Buffer + ? res.body.length + : typeof res.body === "string" + ? res.body.length + : res.body == null + ? 0 + : JSON.stringify(res.body).length; + expect(bodyLength).toBe(0); + }); + + it("string response on HEAD: no body on the wire", async () => { + const app = simpleApp(); + const res = await request(app.expressApp) + .head("/text") + .set("x-api-version", "2024-01-01"); + + expect(res.status).toBe(200); + expect(res.text ?? "").toBe(""); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// 🟡 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")), + // Cast to any because the field isn't in TsadwynOptions yet (that's + // the gap). Once the option is plumbed through the type should accept + // it and this cast can be removed. + ...({ + onUnsupportedVersion: "reject", + } as any), + }); + 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"); + + // Current behavior: dispatcher returns 422 because the option is ignored. + // Expected behavior: 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"], + }); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// 🟡 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"); + + for (let i = 0; i < 15; i++) { + const app = new Tsadwyn({ + versions: new VersionBundle(new Version("2024-01-01")), + onShutdown: () => {}, + }); + // Sanity: exercise the object so the cache isn't accidentally GC'd mid-loop. + void app.expressApp; + } + + const after = process.listenerCount("SIGTERM"); + // Fix should either reuse a single module-level listener, or expose a + // close()/destroy() method plus test-time cleanup. Either way, the + // delta should stay at a small constant (≤ 1), NOT scale with instance count. + expect(after - before).toBeLessThanOrEqual(1); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// 🟡 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); + }); +}); From 4d80c195c338d18c417ca887c9f6fba19a65b80a Mon Sep 17 00:00:00 2001 From: mahmoudimus Date: Sat, 18 Apr 2026 22:04:17 -0700 Subject: [PATCH 52/58] fix(migrate-payload): opt-in path-based migrations via opts.path + opts.methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TDD cycle addressing review finding #2. Test-first: tests/reviewer-findings.test.ts::Finding #2 registered a path-based response migration (`convertResponseToPreviousVersionFor(path, methods)`) and called `migratePayloadToVersion` for the corresponding schema. Before this fix, the payload returned unchanged — the helper only iterated `_alterResponseBySchemaInstructions` and silently skipped the path-based bucket entirely. Outbound webhooks dispatched from background jobs could deliver unmigrated payloads to older-pinned clients with no error and no log. Fix: extend the signature with an optional fifth parameter `MigratePayloadOptions { path?, methods? }`. When `opts.path` is supplied, the function also iterates `_alterResponseByPathInstructions` keyed on that path, applying each matching transformer. `opts.methods` (optional) restricts to a method subset; omitted means "any method registered at this path." Why opt-in instead of automatic? The function has no access to the route table — it only receives the `VersionBundle`. Path-based instructions are keyed on path, not schema name, so there's no way to know which path the raw payload would have originated from unless the caller tells us. Forcing opt-in makes the contract explicit: schema-based migrations fire on schemaName alone; path-based migrations require the caller to name the path. When `opts.path` is omitted, behavior is unchanged from HEAD — pure schema-based dispatch. The 3rd test asserts the `methods` filter actually excludes non-matching methods, so a GET-registered path-based migration doesn't accidentally apply to a POST payload delivered under the same path. Docstring updated to describe both forms and the opt-in semantic. --- src/migrate-payload.ts | 69 ++++++++++++++++-- tests/reviewer-findings.test.ts | 124 ++++++++++++++++++++++---------- 2 files changed, 148 insertions(+), 45 deletions(-) diff --git a/src/migrate-payload.ts b/src/migrate-payload.ts index af84db0..cff99c3 100644 --- a/src/migrate-payload.ts +++ b/src/migrate-payload.ts @@ -1,18 +1,43 @@ /** * `migratePayloadToVersion` — standalone helper that reshapes a head-shape - * payload for a pinned client version by replaying the schema-based response - * migrations registered against `schemaName` between head and `targetVersion`. + * 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 @@ -27,6 +52,7 @@ export function migratePayloadToVersion( payload: T, targetVersion: string, versions: VersionBundle, + opts: MigratePayloadOptions = {}, ): T { const idx = versions.versionValues.indexOf(targetVersion); if (idx === -1) { @@ -46,6 +72,9 @@ export function migratePayloadToVersion( 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 @@ -53,10 +82,38 @@ export function migratePayloadToVersion( for (let i = 0; i < idx; i++) { const version = versions.versions[i]; for (const change of version.changes) { - const instrs = change._alterResponseBySchemaInstructions.get(schemaName); - if (!instrs) continue; - for (const instr of instrs) { - instr.transformer(responseInfo); + // 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); + } + } } } } diff --git a/tests/reviewer-findings.test.ts b/tests/reviewer-findings.test.ts index 5a674cb..2afe665 100644 --- a/tests/reviewer-findings.test.ts +++ b/tests/reviewer-findings.test.ts @@ -80,66 +80,112 @@ describe("Finding #1 (HIGH): onStartup async rejection handling", () => { // ──────────────────────────────────────────────────────────────────────────── // 🔴 HIGH #2 — migratePayloadToVersion skips path-based response migrations -// Location: src/migrate-payload.ts:55-62 -// Bug: the function only iterates `_alterResponseBySchemaInstructions`. +// Location: src/migrate-payload.ts:55-62 (pre-fix) +// Bug: the function only iterated `_alterResponseBySchemaInstructions`. // Any migration registered via `convertResponseToPreviousVersionFor(path, -// methods)` is silently skipped when a consumer calls migratePayloadToVersion +// 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", () => { - it("transforms the payload when only a path-based migration exists", () => { - 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; - } - }, - ); - } + // 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 () => ({ + 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", - event_type: "charge.succeeded", + type: "charge.succeeded", amount: 100, - })); + }); + }); - const versions = new VersionBundle( - new Version("2025-06-01", RenameEventType), - new Version("2024-01-01"), + 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, ); - const head = { + // 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, + head(), "2024-01-01", versions, + { path: "/webhooks/events", methods: ["GET"] }, ); - - // The path-based migration SHOULD have renamed event_type → type. expect(migrated).toEqual({ id: "evt_1", - type: "charge.succeeded", + event_type: "charge.succeeded", amount: 100, }); }); From 8cc0478c96caa3c7ae211cef7ccad4db54179e68 Mon Sep 17 00:00:00 2001 From: mahmoudimus Date: Sat, 18 Apr 2026 22:09:17 -0700 Subject: [PATCH 53/58] fix(route-generation): suppress body on HEAD for Buffer / stream / string responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TDD cycle addressing review finding #3. Test-first: tests/reviewer-findings.test.ts::Finding #3 wraps res.end via a preVersionPick hook and records every call's arguments. On HEAD requests to a Buffer-returning handler, the pre-fix code called res.end(buffer) — the test captures that and asserts args[0] is either undefined or an empty buffer. Without the fix, args[0] is the actual payload Buffer. Why the bug matters (and why it's classified MEDIUM not HIGH): Node's HTTP writer strips bodies from HEAD responses at the wire level per RFC 7231 §4.3.2, so clients never actually receive the leaked bytes. But the app CODE was writing them into the socket knowing they'd be discarded — wasted work on large Buffers, and the bytes still pass through any logging / tracing / observability middleware that wraps res.end before the wire. Fix: compute `isHead = req.method === "HEAD"` once at the top of the dispatch body (moved up from line 1297 to before the non-JSON branch at line 1240), and thread it into `sendNonJsonResponse` as a new parameter. The helper now calls `res.end()` with no argument on HEAD for Buffer and string responses, and skips `.pipe()` entirely for streams. Headers (content-type, content-length) are still set so HEAD probes carry the metadata. Also patched the JSON-via-string-parse branch at line 1271 to use `isHead ? undefined : jsonBody`, matching the JSON object path that already had the guard. Updated the plain-string HEAD test case to assert the same internal behavior (res.end not called with body) rather than relying on Node's wire-level stripping masking the issue. --- src/route-generation.ts | 50 ++++++++++++++--- tests/reviewer-findings.test.ts | 98 +++++++++++++++++++++++---------- 2 files changed, 111 insertions(+), 37 deletions(-) diff --git a/src/route-generation.ts b/src/route-generation.ts index 1dc0c52..e73d9e2 100644 --- a/src/route-generation.ts +++ b/src/route-generation.ts @@ -983,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")) { @@ -1006,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); + } } } @@ -1204,6 +1230,12 @@ 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); @@ -1213,7 +1245,7 @@ function createVersionedHandler( // T-605: Handle non-JSON responses if (isNonJsonResponse(result)) { - sendNonJsonResponse(res, result, successStatus); + sendNonJsonResponse(res, result, successStatus, isHead); return; } @@ -1221,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 @@ -1242,11 +1274,11 @@ 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; } } @@ -1262,7 +1294,7 @@ function createVersionedHandler( // body-mutating response migrations (running only `headerOnly` or // `migrateHttpErrors`-flagged migrations, which opt in to body-less // contexts explicitly). - const isHead = req.method === "HEAD"; + // `isHead` was computed earlier before the non-JSON / string branches. const isNullResult = result === undefined || result === null; if (isNullResult && !isHead) { diff --git a/tests/reviewer-findings.test.ts b/tests/reviewer-findings.test.ts index 2afe665..c845cc9 100644 --- a/tests/reviewer-findings.test.ts +++ b/tests/reviewer-findings.test.ts @@ -201,52 +201,94 @@ describe("Finding #2 (HIGH): migratePayloadToVersion applies path-based migratio // 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", () => { - function simpleApp() { - const router = new VersionedRouter(); - - router.head("/download", null, raw({ mimeType: "application/octet-stream" }), async () => { - return Buffer.from("secret-payload-should-not-ship", "utf-8"); - }); - - router.head("/text", null, null, async () => { - return "text-payload-should-not-ship"; - }); - + // 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; + return { app, endCalls }; } - it("Buffer response on HEAD: no body on the wire", async () => { - const app = simpleApp(); + 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); - // supertest's body on HEAD is a Buffer; length 0 means no body was sent. - // Accept empty Buffer, empty string, or undefined — all represent "no body". - const bodyLength = - res.body instanceof Buffer - ? res.body.length - : typeof res.body === "string" - ? res.body.length - : res.body == null - ? 0 - : JSON.stringify(res.body).length; - expect(bodyLength).toBe(0); + // 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: no body on the wire", async () => { - const app = simpleApp(); + 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); - expect(res.text ?? "").toBe(""); + + 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, + ); + } + } }); }); From b7f7a6f66c228255d245437873145a492763c4d3 Mon Sep 17 00:00:00 2001 From: mahmoudimus Date: Sat, 18 Apr 2026 22:11:07 -0700 Subject: [PATCH 54/58] feat(application): expose onUnsupportedVersion + versionPickingLogger on TsadwynOptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TDD cycle addressing review finding #4. Test-first: two cases in tests/reviewer-findings.test.ts::Finding #4. One sets onUnsupportedVersion: 'reject' and asserts 400 with structured body; the other sets 'fallback' and asserts the configured logger's warn is called. Before this fix, both failed at runtime — the option was declared on VersionPickingOptions (src/middleware.ts) but never forwarded into the pickingOpts object at application.ts:418-423, so consumers using the Tsadwyn class had no way to reach the policy without overriding the entire versioningMiddleware. Fix: add `onUnsupportedVersion` + `versionPickingLogger` to TsadwynOptions; store them on the instance as `_onUnsupportedVersion` / `_versionPickingLogger`; forward into `pickingOpts` when present (leaving passthrough as the historical default when omitted). Verified with the revert-fix-rerun cycle: - With the forwarding removed, both tests fail (422 instead of 400 on 'reject'; logger.warn never called on 'fallback'). - With the forwarding restored, both pass. No type cast needed in the test now that the option is on TsadwynOptions. --- src/application.ts | 39 +++++++++++++++++++++++++++++++++ tests/reviewer-findings.test.ts | 37 ++++++++++++++++++++++++------- 2 files changed, 68 insertions(+), 8 deletions(-) diff --git a/src/application.ts b/src/application.ts index 2cc1c8b..c627a52 100644 --- a/src/application.ts +++ b/src/application.ts @@ -178,6 +178,28 @@ export interface TsadwynOptions { */ 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`). @@ -288,6 +310,13 @@ export class Tsadwyn { /** 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. @@ -343,6 +372,10 @@ export class Tsadwyn { 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(); @@ -421,6 +454,12 @@ export class Tsadwyn { 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)); } diff --git a/tests/reviewer-findings.test.ts b/tests/reviewer-findings.test.ts index c845cc9..471632d 100644 --- a/tests/reviewer-findings.test.ts +++ b/tests/reviewer-findings.test.ts @@ -306,12 +306,7 @@ 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")), - // Cast to any because the field isn't in TsadwynOptions yet (that's - // the gap). Once the option is plumbed through the type should accept - // it and this cast can be removed. - ...({ - onUnsupportedVersion: "reject", - } as any), + onUnsupportedVersion: "reject", }); const router = new VersionedRouter(); router.get("/ping", null, null, async () => ({ ok: true })); @@ -321,8 +316,8 @@ describe("Finding #4 (MEDIUM): onUnsupportedVersion wired through TsadwynOptions .get("/ping") .set("x-api-version", "9999-99-99"); - // Current behavior: dispatcher returns 422 because the option is ignored. - // Expected behavior: the middleware's `reject` policy fires → 400 with + // 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({ @@ -331,6 +326,32 @@ describe("Finding #4 (MEDIUM): onUnsupportedVersion wired through TsadwynOptions 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), + ); + }); }); // ──────────────────────────────────────────────────────────────────────────── From 5bec9322c7b9e92306a72a38b90bb77073dc2756 Mon Sep 17 00:00:00 2001 From: mahmoudimus Date: Sat, 18 Apr 2026 22:13:12 -0700 Subject: [PATCH 55/58] fix(application): share a single SIGTERM/SIGINT handler across Tsadwyn instances MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TDD cycle addressing review finding #5. Test-first: tests/reviewer-findings.test.ts::Finding #5 constructs 15 Tsadwyn instances each with an onShutdown hook and asserts the SIGTERM listener-count delta stays ≤ 1. Before this fix, each instance called `process.on("SIGTERM", ...)` + `process.on("SIGINT", ...)`, so 15 instances added 15 listeners per signal. Node's default maxListeners=10 triggers MaxListenersExceededWarning at ~11, and more subtly, a real SIGTERM would invoke all 15 handlers in parallel and each would race to call process.exit — masking failures in test suites. Fix: module-scoped `_tsadwynActiveInstances` Set + a single signal handler installed exactly once via `_installTsadwynSignalHandlerOnce`. On signal, the shared handler drains every registered instance's onShutdown in parallel via Promise.allSettled, then calls process.exit. Exit code is 1 if any instance's shutdown rejected, 0 otherwise. Also adds a public `Tsadwyn#close()` method so tests (and consumers that hot-swap instances) can unregister from the shared set. Idempotent — calling it twice is a safe no-op. Verified with revert-fix-rerun: - With per-instance process.on: 15-instance test shows 15 listeners added → test fails. - With module-scoped handler: 1 listener added total → test passes. Second test locks in that close() is safe to call multiple times. --- src/application.ts | 77 ++++++++++++++++++++++++++++----- tests/reviewer-findings.test.ts | 37 ++++++++++++---- 2 files changed, 94 insertions(+), 20 deletions(-) diff --git a/src/application.ts b/src/application.ts index c627a52..43967c8 100644 --- a/src/application.ts +++ b/src/application.ts @@ -32,6 +32,48 @@ import { */ 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. */ @@ -465,21 +507,34 @@ export class Tsadwyn { 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. diff --git a/tests/reviewer-findings.test.ts b/tests/reviewer-findings.test.ts index 471632d..37e3e4e 100644 --- a/tests/reviewer-findings.test.ts +++ b/tests/reviewer-findings.test.ts @@ -366,20 +366,39 @@ 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++) { - const app = new Tsadwyn({ - versions: new VersionBundle(new Version("2024-01-01")), - onShutdown: () => {}, - }); - // Sanity: exercise the object so the cache isn't accidentally GC'd mid-loop. - void app.expressApp; + apps.push( + new Tsadwyn({ + versions: new VersionBundle(new Version("2024-01-01")), + onShutdown: () => {}, + }), + ); } const after = process.listenerCount("SIGTERM"); - // Fix should either reuse a single module-level listener, or expose a - // close()/destroy() method plus test-time cleanup. Either way, the - // delta should stay at a small constant (≤ 1), NOT scale with instance count. + // 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(); }); }); From e381a6138806643b90674f9dee3b3cff6bc2203e Mon Sep 17 00:00:00 2001 From: mahmoudimus Date: Sat, 18 Apr 2026 22:14:40 -0700 Subject: [PATCH 56/58] fix(application): wrap unversioned route dispatch in requestContextStorage.run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TDD cycle addressing review finding #7. Test-first: tests/reviewer-findings.test.ts::Finding #7 registers an unversioned route via `app.unversionedRouter.get(...)` whose handler calls `currentRequest()` to read `req.user` injected by preVersionPick. Before this fix, the test returned 500 because `_wrapHandlerWithOverrides` invoked the handler directly — no AsyncLocalStorage scope, so `currentRequest()` threw "called outside a tsadwyn handler scope". Fix: match the versioned dispatcher's pattern. `_wrapHandlerWithOverrides` now returns a sync outer wrapper that calls `requestContextStorage.run(req, ...)` before the async body; the async body was extracted into `_dispatchUnversionedHandler` and does the same try/catch + handler invocation as before. Functionally identical except that `currentRequest()` now resolves to the live req inside unversioned handlers. Verified with revert-fix-rerun: - Without the run wrap, the test fails with 500 (throw from currentRequest). - With the wrap, 200 + the expected { user: "unversioned_user" } body. No regression in the 880 pre-existing tests — the public behavior of unversioned routes (dispatch, error handling, dependencyOverrides) is preserved; the ALS wrap only adds capability. --- src/application.ts | 55 ++++++++++++++++++++++++++++++---------------- 1 file changed, 36 insertions(+), 19 deletions(-) diff --git a/src/application.ts b/src/application.ts index 43967c8..841bdbe 100644 --- a/src/application.ts +++ b/src/application.ts @@ -26,6 +26,7 @@ import { type RouteShadowingPolicy, type RouteShadowingLogger, } from "./route-shadowing.js"; +import { requestContextStorage } from "./request-context.js"; /** * Regex for validating ISO date strings (YYYY-MM-DD). @@ -676,31 +677,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. */ From 72659a3ed719189481b778bb599d2d648978784a Mon Sep 17 00:00:00 2001 From: mahmoudimus Date: Sat, 18 Apr 2026 23:00:53 -0700 Subject: [PATCH 57/58] refactor: route all schema-name reads through getSchemaName() helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TDD cycle addressing review finding #6. Test-first: tests/reviewer-findings.test.ts::Finding #6 creates a named schema via `.named("Finding6_MySchema")` (which populates both the WeakMap registry and the legacy `._tsadwynName` property), then deletes the legacy property to simulate a downstream transform (serializer, clone, wrapper) that dropped it. The test asserts the schema still appears in the OpenAPI `components.schemas` output — which requires every schema-name read to fall back to the WeakMap. Before this fix, the test failed because 4 call sites read `._tsadwynName` directly instead of going through `getSchemaName()`: - src/application.ts::_buildRegistryFromRoutes — builds the registry used for versioned OpenAPI generation (original reviewer target) - src/schema-generation.ts::ZodSchemaRegistry#getVersioned — looks up the versioned copy of an original schema during routing - src/schema-generation.ts::transformSchemaReferences — rewrites schema references across a tree during versioned schema generation - src/openapi.ts::getSchemaName (local shadow) — used throughout OpenAPI builder for deciding when to inline vs $ref a schema - src/structure/enums.ts::enum_ — reads the enum's registered name All five now delegate to the canonical `getSchemaName` export from zod-extend.ts, which checks the WeakMap first and falls back to the legacy property. The WeakMap-first path is the documented invariant per CLAUDE.md: "Use `getSchemaName` / `setSchemaName` rather than reading `._tsadwynName` directly." Post-fix: 882/882 tests pass, typecheck clean. The legacy `._tsadwynName` property is still written by setSchemaName for backward compat; nothing removed from the public API. --- src/application.ts | 24 ++++++++++-------- src/openapi.ts | 8 +++--- src/schema-generation.ts | 10 ++++---- src/structure/enums.ts | 3 ++- tests/reviewer-findings.test.ts | 43 +++++++++++++++++++++++++++++++++ 5 files changed, 69 insertions(+), 19 deletions(-) diff --git a/src/application.ts b/src/application.ts index 841bdbe..b533a57 100644 --- a/src/application.ts +++ b/src/application.ts @@ -27,6 +27,7 @@ import { 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). @@ -948,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/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/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/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/tests/reviewer-findings.test.ts b/tests/reviewer-findings.test.ts index 37e3e4e..01e4a33 100644 --- a/tests/reviewer-findings.test.ts +++ b/tests/reviewer-findings.test.ts @@ -402,6 +402,49 @@ describe("Finding #5 (MEDIUM): SIGTERM listeners do not accumulate per instance" }); }); +// ──────────────────────────────────────────────────────────────────────────── +// 🟡 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) From fe063dfef2f5081931f854376ae0960891533ec0 Mon Sep 17 00:00:00 2001 From: mahmoudimus Date: Sat, 18 Apr 2026 23:02:24 -0700 Subject: [PATCH 58/58] =?UTF-8?q?docs(cached-per-client-default):=20docume?= =?UTF-8?q?nt=20onStalePin=3D'reject'=20=C3=97=20caching=20interaction=20e?= =?UTF-8?q?xplicitly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addressing review finding #8, which was classified as "missing test assertion rather than code defect." The existing test at tests/reviewer-findings.test.ts::Finding #8 (landed in commit 0358f18) already locks in the per-call retry contract — 3 sequential calls for a stale-pinned client produce 3 resolvePin invocations. This commit adds the other half the reviewer asked for: explicit docs so a future reader understands the behavior is intentional. 1. Expanded docstring on `onStalePin` in CachedPerClientDefaultVersionOptions: spells out that the 'fallback' / 'passthrough' resolutions get cached, while 'reject' throws and every subsequent request re-hits resolvePin (no back-off, no negative caching). Also notes the workaround (pick a different stale policy or narrow supportedVersions) so a surprised reader knows where to go. 2. Inline comment at getOrCreate's rejection branch warning against caching rejections as an "optimization" — cross-references the lock-in test so someone reaching for that change can see why it'd break the contract. No behavior change — purely clarifying a promise the code already kept. --- src/cached-per-client-default.ts | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src/cached-per-client-default.ts b/src/cached-per-client-default.ts index 0c78004..bc89dc5 100644 --- a/src/cached-per-client-default.ts +++ b/src/cached-per-client-default.ts @@ -38,7 +38,21 @@ export interface CachedPerClientDefaultVersionOptions { 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'. */ + /** + * 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[]; @@ -151,7 +165,12 @@ export function cachedPerClientDefaultVersion( cache.delete(clientId); } // Create a new entry, tracking settlement so rejections don't poison - // the cache for the full TTL. + // 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,