{"id":50299110,"url":"https://github.com/mahmoudimus/tsadwyn","last_synced_at":"2026-05-28T11:02:15.126Z","repository":{"id":350549178,"uuid":"1207317671","full_name":"mahmoudimus/tsadwyn","owner":"mahmoudimus","description":"A Stripe-like API versioning library - full-featured enhanced Typescript port of Python's Cadwyn","archived":false,"fork":false,"pushed_at":"2026-04-17T20:15:09.000Z","size":802,"stargazers_count":3,"open_issues_count":9,"forks_count":0,"subscribers_count":1,"default_branch":"main","last_synced_at":"2026-04-17T22:28:10.761Z","etag":null,"topics":["api","api-versioning","backward-compatibility","express","express-js","express-middleware","expressjs","node-express","node-js","rest-api","restful-api","stripe-api","stripe-like","typescript","version-pinning"],"latest_commit_sha":null,"homepage":"","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"other","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/mahmoudimus.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-04-10T20:08:48.000Z","updated_at":"2026-04-17T21:41:33.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/mahmoudimus/tsadwyn","commit_stats":null,"previous_names":["mahmoudimus/tsadwyn"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/mahmoudimus/tsadwyn","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mahmoudimus%2Ftsadwyn","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mahmoudimus%2Ftsadwyn/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mahmoudimus%2Ftsadwyn/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mahmoudimus%2Ftsadwyn/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/mahmoudimus","download_url":"https://codeload.github.com/mahmoudimus/tsadwyn/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mahmoudimus%2Ftsadwyn/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33605379,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-05-28T02:00:06.440Z","response_time":99,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["api","api-versioning","backward-compatibility","express","express-js","express-middleware","expressjs","node-express","node-js","rest-api","restful-api","stripe-api","stripe-like","typescript","version-pinning"],"created_at":"2026-05-28T11:02:10.985Z","updated_at":"2026-05-28T11:02:15.121Z","avatar_url":"https://github.com/mahmoudimus.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# tsadwyn\n\n[![CI](https://github.com/mahmoudimus/tsadwyn/actions/workflows/ci.yml/badge.svg)](https://github.com/mahmoudimus/tsadwyn/actions/workflows/ci.yml)\n\nStripe-like API versioning for TypeScript/Express. tsadwyn is a TypeScript port of [Cadwyn](https://github.com/zmievsa/cadwyn) by Stanislav Zmiev — it enables you to maintain a single codebase that serves multiple API versions simultaneously. Instead of duplicating routes for each version, you define version changes declaratively and tsadwyn generates versioned routers with automatic request/response migration.\n\n## Installation\n\n```bash\nnpm install tsadwyn\n```\n\n## Quick Start\n\n```typescript\nimport { z } from \"zod\";\nimport {\n  Tsadwyn,\n  Version,\n  VersionBundle,\n  VersionChange,\n  VersionedRouter,\n  schema,\n  convertRequestToNextVersionFor,\n  convertResponseToPreviousVersionFor,\n  RequestInfo,\n  ResponseInfo,\n} from \"tsadwyn\";\n\n// 1. Define your latest (head) schemas\nconst UserCreateRequest = z\n  .object({ addresses: z.array(z.string()) })\n  .named(\"UserCreateRequest\");\n\nconst UserResource = z\n  .object({ id: z.string().uuid(), addresses: z.array(z.string()) })\n  .named(\"UserResource\");\n\n// 2. Define what changed between versions\nclass ChangeAddressToList extends VersionChange {\n  description = \"Changed address from string to array of strings\";\n\n  instructions = [\n    schema(UserCreateRequest)\n      .field(\"addresses\")\n      .had({ name: \"address\", type: z.string() }),\n    schema(UserResource)\n      .field(\"addresses\")\n      .had({ name: \"address\", type: z.string() }),\n  ];\n\n  @convertRequestToNextVersionFor(UserCreateRequest)\n  migrateRequest(request: RequestInfo) {\n    request.body.addresses = [request.body.address];\n    delete request.body.address;\n  }\n\n  @convertResponseToPreviousVersionFor(UserResource)\n  migrateResponse(response: ResponseInfo) {\n    response.body.address = response.body.addresses[0];\n    delete response.body.addresses;\n  }\n}\n\n// 3. Register routes against the latest schema\nconst router = new VersionedRouter();\n\nrouter.post(\"/users\", UserCreateRequest, UserResource, async (req) =\u003e {\n  // req.body is typed as { addresses: string[] }\n  return { id: \"some-uuid\", addresses: req.body.addresses };\n});\n\n// 4. Create the app with version declarations\nconst app = new Tsadwyn({\n  versions: new VersionBundle(\n    new Version(\"2025-01-01\", ChangeAddressToList),\n    new Version(\"2024-01-01\"), // oldest version, no changes\n  ),\n});\napp.generateAndIncludeVersionedRouters(router);\n\n// 5. Start the server\napp.expressApp.listen(3000);\n```\n\nNow clients can send requests with the `x-api-version` header:\n\n- `x-api-version: 2025-01-01` -- uses the latest schema with `addresses` array\n- `x-api-version: 2024-01-01` -- uses the old schema with `address` string\n\ntsadwyn automatically migrates requests from old versions to the latest format before your handler runs, and migrates responses back to the requested version's format before sending.\n\n## Concepts\n\n- **VersionedRouter**: Register routes once against the latest schema shape\n- **VersionChange**: Declare what changed between two adjacent versions\n- **schema().field().had()**: Describe how schema fields changed (renamed, type changed, added, removed)\n- **endpoint().didntExist / existed**: Describe routes added or removed between versions\n- **convertRequestToNextVersionFor**: Migrate request bodies from old to new format\n- **convertResponseToPreviousVersionFor**: Migrate response bodies from new to old format\n\nFor full documentation on the head-first API versioning pattern, see the [Cadwyn docs](https://docs.cadwyn.dev/) — the concepts carry over directly.\n\n## API Reference\n\n### Core\n\n| Export | Description |\n|--------|-------------|\n| `Tsadwyn` | Main application class wrapping Express |\n| `VersionedRouter` | Route collector with typed schema parameters |\n| `Version` | A single API version declaration |\n| `VersionBundle` | Bundle of all API versions (newest first) |\n| `VersionChange` | Base class for version change declarations |\n\n### Schema DSL\n\n| Export | Description |\n|--------|-------------|\n| `schema(zodSchema)` | Entry point for schema alteration instructions |\n| `.field(name).had({...})` | Field had different properties in previous version |\n| `.field(name).didntExist` | Field did not exist in previous version |\n| `.field(name).existedAs({type})` | Field existed with a different type |\n\n### Migration Decorators\n\n| Export | Description |\n|--------|-------------|\n| `convertRequestToNextVersionFor(Schema)` | Decorator for request migration |\n| `convertResponseToPreviousVersionFor(Schema)` | Decorator for response migration |\n| `RequestInfo` | Request context available in migration callbacks |\n| `ResponseInfo` | Response context available in migration callbacks |\n\n### Utilities\n\n| Export | Description |\n|--------|-------------|\n| `getSchemaName(schema)` | Get the tsadwyn name from a Zod schema |\n| `setSchemaName(schema, name)` | Set a tsadwyn name on a Zod schema |\n| `apiVersionStorage` | AsyncLocalStorage holding the current version |\n| `generateVersionedRouters` | Low-level router generation function |\n\n## CLI\n\ntsadwyn 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`.\n\n```bash\ntsadwyn --version            # prints the CLI version\ntsadwyn -V                   # alias for --version\ntsadwyn --help               # lists available commands\n```\n\n### `tsadwyn codegen --app \u003cpath\u003e`\n\nLoads a TypeScript/JavaScript module that exports a Tsadwyn application (either as `default` or as a named `app` export), triggers versioned-router generation, and prints a summary of the resulting versions and route counts.\n\n```bash\ntsadwyn codegen --app ./src/app.ts\n```\n\nIf the module also exports a `routers` (or `versionedRouters`) value, it is forwarded to `generateAndIncludeVersionedRouters()`; otherwise the CLI assumes the module already called it at import time.\n\n### `tsadwyn info --app \u003cpath\u003e`\n\nPrints structured information about an app's versions: the number of versions, a per-version route count, and a rollup of schemas touched by version changes. Useful for introspecting a deployed app or diffing versioned surface area in CI.\n\n```bash\ntsadwyn info --app ./src/app.ts\ntsadwyn info --app ./src/app.ts --api-version 2024-11-01\ntsadwyn info --app ./src/app.ts --json\n```\n\nOptions:\n\n| Flag | Description |\n|------|-------------|\n| `--app \u003cpath\u003e` | Required. Path to the module exporting the Tsadwyn app. |\n| `--api-version \u003cvalue\u003e` | Show info for a single API version only. Named `--api-version` (not `--version`) to avoid collision with the program's own `--version` flag. |\n| `--json` | Emit a single JSON line instead of formatted text, suitable for piping through `jq`. |\n\ntsadwyn'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.\n\n### `tsadwyn new version --date \u003cYYYY-MM-DD\u003e`\n\nScaffolds 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?\"\n\n```bash\n# Empty scaffold — fill in the instructions yourself\ntsadwyn new version --date 2024-12-01 --description \"Rename payment_method to payment_source\"\n\n# Scaffold with a field rename pre-populated (both instruction and migration callbacks)\ntsadwyn new version --date 2024-12-01 \\\n  --description \"Rename payment_method to payment_source on charges\" \\\n  --rename-field \"ChargeResource.payment_source=payment_method\"\n\n# Scaffold with multiple changes\ntsadwyn new version --date 2024-12-01 \\\n  --description \"Remove legacy flag, add phone field\" \\\n  --remove-field \"UserResource.legacy_flag\" \\\n  --add-field \"UserResource.phone_number\" \\\n  --remove-endpoint \"DELETE /users/:id/legacy\"\n\n# Print without writing\ntsadwyn new version --date 2024-12-01 --dry-run\n```\n\n**What it generates:** a TypeScript file at `./src/versions/\u003cdate\u003e.ts` (configurable via `--dir`) containing:\n- A `VersionChange` subclass with a derived PascalCase name (or `--name` override)\n- Imports for `VersionChange`, any helpers you need (`schema`, `endpoint`, migration decorators), and placeholder schema imports\n- The `instructions` array pre-populated with inline TODO comments\n- Request and response migration callback stubs that correctly route data in both directions\n- A \"Next steps\" block telling you exactly which line to add to your `VersionBundle`\n\n**Rename convention:** `--rename-field \"Schema.currentName=oldName\"` means the field is currently called `currentName` in the head schema and was called `oldName` in the previous version. The generated request migration converts old clients' `oldName` → `currentName`, and the response migration rewrites the head's `currentName` → `oldName` for old clients.\n\n**Add vs remove semantics:**\n- `--add-field \"Schema.field\"` — the field is *new* in this version. The older version didn't have it. Generates `schema().field().didntExist` (no migration callback needed — Zod just drops unknown fields from responses going back to old clients).\n- `--remove-field \"Schema.field\"` — the field was *removed* in this version. The older version still expects it. Generates `schema().field().existedAs({ type: z.unknown() })` plus a response migration stub where you need to supply a sensible default for the removed field.\n\n**Endpoint semantics:** `--add-endpoint \"METHOD /path\"` and `--remove-endpoint \"METHOD /path\"` produce `endpoint().didntExist` / `endpoint().existed` instructions.\n\nOptions:\n\n| Flag | Description |\n|------|-------------|\n| `--date \u003cYYYY-MM-DD\u003e` | Required. ISO date for the new version. |\n| `--description \u003ctext\u003e` | Human-readable description. Defaults to a TODO placeholder. |\n| `--dir \u003cpath\u003e` | Output directory. Default: `./src/versions`. |\n| `--name \u003cClassName\u003e` | Override the derived class name. |\n| `--rename-field \u003cspec\u003e` | Pre-populate a field rename. Repeatable. |\n| `--add-field \u003cspec\u003e` | Pre-populate a field addition. Repeatable. |\n| `--remove-field \u003cspec\u003e` | Pre-populate a field removal. Repeatable. |\n| `--add-endpoint \u003cspec\u003e` | Pre-populate an endpoint addition. Repeatable. |\n| `--remove-endpoint \u003cspec\u003e` | Pre-populate an endpoint removal. Repeatable. |\n| `--dry-run` | Print generated content without writing. |\n| `--force` | Overwrite an existing file at the target path. |\n\nAfter scaffolding, the CLI prints a \"Next steps\" box with the exact `import` and `new Version(...)` lines to add to your `VersionBundle`. tsadwyn does NOT auto-wire the VersionBundle for you — that's intentional, so you stay in control of your version ordering.\n\n**Known limitation:** `--remove-field` emits `existedAs({ type: z.unknown() })` with a TODO comment because the CLI doesn't parse your source to infer the field's real Zod type. Fill it in manually with the correct type (e.g. `z.string()`, `z.number().int()`). A future release will parse your schemas file via the TypeScript compiler API to emit the exact type automatically. Same caveat applies to `--add-field` when generating a default-value callback.\n\n## Development\n\nClone, install, test:\n\n```bash\nnpm ci\nnpm test         # vitest run\nnpm run typecheck\nnpm run build\n```\n\n### Test pool\n\nVitest defaults to `pool: \"forks\"` in this repo because supertest-heavy suites are flaky under `threads` due to shared Node HTTP keep-alive parser state (~20–30% per-file flake rate observed vs. 0% with forks). To validate behavior under the threads pool anyway:\n\n```bash\nTSADWYN_TEST_POOL=threads npm test\n```\n\nVitest will print a warning and run under the legacy pool. The standard `npx vitest run --pool=threads` CLI override also works and takes precedence over the env var.\n\n## License\n\nMIT\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmahmoudimus%2Ftsadwyn","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fmahmoudimus%2Ftsadwyn","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmahmoudimus%2Ftsadwyn/lists"}