@palbase/backend 17.4.0 → 18.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/dist/bin/palbase-backend.cjs +1848 -0
  2. package/dist/bin/palbase-backend.cjs.map +1 -0
  3. package/dist/bin/palbase-backend.d.cts +1 -0
  4. package/dist/bin/palbase-backend.d.ts +1 -0
  5. package/dist/bin/palbase-backend.js +168 -0
  6. package/dist/bin/palbase-backend.js.map +1 -0
  7. package/dist/chunk-7D4SUZUM.js +38 -0
  8. package/dist/chunk-7D4SUZUM.js.map +1 -0
  9. package/dist/chunk-N32VDWKH.js +172 -0
  10. package/dist/chunk-N32VDWKH.js.map +1 -0
  11. package/dist/chunk-POYAFBLF.js +189 -0
  12. package/dist/chunk-POYAFBLF.js.map +1 -0
  13. package/dist/chunk-QMVK4X3V.js +200 -0
  14. package/dist/chunk-QMVK4X3V.js.map +1 -0
  15. package/dist/chunk-SSGAMC26.js +342 -0
  16. package/dist/chunk-SSGAMC26.js.map +1 -0
  17. package/dist/chunk-VYH4U7ZQ.js +1138 -0
  18. package/dist/chunk-VYH4U7ZQ.js.map +1 -0
  19. package/dist/{chunk-AAN642N5.js → chunk-W5ODXPY3.js} +2 -336
  20. package/dist/chunk-W5ODXPY3.js.map +1 -0
  21. package/dist/chunk-YL4C5NRY.js +90 -0
  22. package/dist/chunk-YL4C5NRY.js.map +1 -0
  23. package/dist/db/env.cjs.map +1 -1
  24. package/dist/db/env.d.cts +21 -1
  25. package/dist/db/env.d.ts +21 -1
  26. package/dist/db/index.cjs.map +1 -1
  27. package/dist/db/index.d.cts +2 -1
  28. package/dist/db/index.d.ts +2 -1
  29. package/dist/db/index.js +9 -6
  30. package/dist/{index-VLrU7rSW.d.ts → endpoint-B0LpZixz.d.cts} +124 -685
  31. package/dist/{index-BA_oFAz9.d.cts → endpoint-B0LpZixz.d.ts} +124 -685
  32. package/dist/engine/index.cjs +1797 -0
  33. package/dist/engine/index.cjs.map +1 -0
  34. package/dist/engine/index.d.cts +7 -0
  35. package/dist/engine/index.d.ts +7 -0
  36. package/dist/engine/index.js +43 -0
  37. package/dist/engine/index.js.map +1 -0
  38. package/dist/index-B46CGNvx.d.cts +839 -0
  39. package/dist/index-BGSCWlUa.d.cts +674 -0
  40. package/dist/index-DZDUMth5.d.ts +839 -0
  41. package/dist/index-g-EzitI-.d.ts +674 -0
  42. package/dist/index.cjs +1031 -11
  43. package/dist/index.cjs.map +1 -1
  44. package/dist/index.d.cts +290 -532
  45. package/dist/index.d.ts +290 -532
  46. package/dist/index.js +999 -509
  47. package/dist/index.js.map +1 -1
  48. package/dist/openapi/index.cjs +6464 -0
  49. package/dist/openapi/index.cjs.map +1 -0
  50. package/dist/openapi/index.d.cts +170 -0
  51. package/dist/openapi/index.d.ts +170 -0
  52. package/dist/openapi/index.js +6248 -0
  53. package/dist/openapi/index.js.map +1 -0
  54. package/dist/registry-3BLYv4si.d.ts +338 -0
  55. package/dist/registry-Cw0YEYCg.d.cts +338 -0
  56. package/dist/test/index.js +2 -0
  57. package/dist/test/index.js.map +1 -1
  58. package/docs/database.md +16 -3
  59. package/docs/llms-full.txt +16 -3
  60. package/package.json +43 -13
  61. package/stager/package.json +4 -0
  62. package/stager/return_types.js +338 -0
  63. package/stager/stage.js +78 -0
  64. package/stager/throw_analysis.js +726 -0
  65. package/template/AGENTS.md +261 -0
  66. package/template/config/secrets.ts +24 -0
  67. package/template/controllers/health.controller.ts +30 -0
  68. package/template/db/schema.ts +35 -0
  69. package/template/package.json +18 -0
  70. package/template/tsconfig.json +30 -0
  71. package/LICENSE +0 -21
  72. package/dist/chunk-AAN642N5.js.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/test/api.ts"],"sourcesContent":["/** The client a tenant's tests use to call their own backend.\n *\n * These tests run against a REAL deployment — the release the deploy just built,\n * serving from the same Environment as production, with the same database, the\n * same secrets and the same gateway in front of it. So this client is a plain\n * HTTP client, not a simulation: every call crosses Kong, the API key check, the\n * auth rail, the zod validation at the boundary, and row-level security, exactly\n * as a shipped app's call does.\n *\n * There is deliberately no schema knowledge here. The tenant already wrote their\n * types — `import type { TodoSchema } from \"../models/todos/shared.js\"` — so a\n * test types its own call (`api.get<TodoSchema[]>(\"/todos\")`) and can validate it\n * with the same zod schema the endpoint declares. A second generated client would\n * be a second thing to keep in step.\n */\n\n/** How to reach the release under test. Supplied by the deploy, never guessed. */\nexport interface TestApiConfig {\n baseUrl: string;\n apiKey: string;\n /** This deploy's secret. Without it the request is served the LIVE release. */\n candidateToken: string;\n /** The run's minted logins, keyed by the name declared in config/test-users.ts. */\n identities?: Record<string, TestIdentity>;\n}\n\n/** One login the deploy minted for this run.\n *\n * `accessToken` is the session issued when the identity was created, and is what\n * `signInAs` uses. The credentials come along for a test that wants to exercise\n * the login rail itself — but a suite that switches users repeatedly must not be\n * signing in each time: those calls come from one address and trip the login rate\n * limiter, failing tests for a reason that has nothing to do with the code\n * under test.\n */\nexport interface TestIdentity {\n id?: string;\n email: string;\n password: string;\n accessToken?: string;\n}\n\n/** One call the suite made, in the order it was made. */\nexport interface RecordedRequest {\n method: string;\n path: string;\n status: number;\n ms: number;\n}\n\n/** A non-2xx answer, carrying the platform's error envelope.\n *\n * The envelope is the contract every Palbase endpoint answers with, so a test\n * asserts on `status`/`error`/`data` rather than parsing a message. The message\n * exists for the human reading a failed deploy.\n */\nexport class TestApiError extends Error {\n readonly status: number;\n readonly error: string;\n /** Payload of an error your code threw — `new BadRequest({ fields })` arrives here. */\n readonly data: unknown;\n /** The whole envelope, exactly as the server sent it. */\n readonly body: ErrorEnvelope;\n\n constructor(method: string, path: string, status: number, body: unknown) {\n const envelope = (body ?? {}) as ErrorEnvelope;\n const code = envelope.error ?? String(status);\n super(`${method} ${path} → ${status} ${code}${envelope.error_description ? `: ${envelope.error_description}` : \"\"}`);\n this.name = \"TestApiError\";\n this.status = status;\n this.error = code;\n this.data = envelope.data;\n this.body = envelope;\n }\n}\n\n/** A Palbase error response.\n *\n * `data` carries the payload of an error your code threw. Validation refused at\n * the boundary — before your handler runs — answers with `details` instead, one\n * entry per field. The index signature is deliberate: whatever the server sends\n * is readable from a test, so no assertion is ever blocked on this type being\n * exhaustive.\n */\nexport interface ErrorEnvelope {\n error?: string;\n error_description?: string;\n status?: number;\n request_id?: string;\n data?: unknown;\n details?: Array<{ field: string; message: string }>;\n [key: string]: unknown;\n}\n\nexport interface CallOptions {\n headers?: Record<string, string>;\n}\n\nexport interface TestApi {\n get<T = unknown>(path: string, opts?: CallOptions): Promise<T>;\n post<T = unknown>(path: string, body?: unknown, opts?: CallOptions): Promise<T>;\n patch<T = unknown>(path: string, body?: unknown, opts?: CallOptions): Promise<T>;\n put<T = unknown>(path: string, body?: unknown, opts?: CallOptions): Promise<T>;\n delete<T = unknown>(path: string, opts?: CallOptions): Promise<T>;\n /** HTTP QUERY (RFC 10008): a safe, idempotent read whose filter travels in the body. */\n query<T = unknown>(path: string, body?: unknown, opts?: CallOptions): Promise<T>;\n /** Sign in with credentials you supply. */\n signIn(credentials: { email: string; password: string }): Promise<{ id: string; email?: string }>;\n /**\n * Sign in as one of the identities this run was given, by the name you\n * declared it under in `config/test-users.ts`.\n *\n * Each is minted by the platform for the length of ONE deploy, seeded with the\n * data that declaration describes, and retired after — which is why this works\n * on every Environment including production, where a committed fixture password\n * is refused on purpose.\n */\n signInAs(name: string): Promise<{ id: string; email?: string }>;\n signOut(): Promise<void>;\n /** Drop the bearer without calling the server — the anonymous caller. */\n asAnonymous(): void;\n /** Every call made, in order. Printed for the failing test in a red deploy. */\n readonly requests: readonly RecordedRequest[];\n}\n\nfunction required(value: string, envName: string): string {\n if (!value) {\n throw new Error(\n `${envName} is not set — the test client has nowhere to send requests. ` +\n `This is set by the deploy that runs your tests; if you are running them by hand, set it yourself.`,\n );\n }\n return value;\n}\n\nexport function createTestApi(config: TestApiConfig): TestApi {\n const baseUrl = required(config.baseUrl, \"PALBASE_TEST_BASE_URL\").replace(/\\/$/, \"\");\n const apiKey = required(config.apiKey, \"PALBASE_TEST_API_KEY\");\n const candidateToken = required(config.candidateToken, \"PALBASE_TEST_CANDIDATE\");\n\n const requests: RecordedRequest[] = [];\n let bearer: string | null = null;\n\n async function call<T>(method: string, path: string, body: unknown, opts: CallOptions = {}): Promise<T> {\n const headers: Record<string, string> = {\n apikey: apiKey,\n // Selects the release under test. Omit it and the gateway serves the LIVE\n // one, which would make the whole suite grade the wrong code.\n \"x-palbase-candidate\": candidateToken,\n ...opts.headers,\n };\n if (bearer) headers.authorization = `Bearer ${bearer}`;\n if (body !== undefined) headers[\"content-type\"] = \"application/json\";\n\n const startedAt = Date.now();\n const res = await fetch(`${baseUrl}${path}`, {\n method,\n headers,\n body: body === undefined ? undefined : JSON.stringify(body),\n });\n const text = await res.text();\n const parsed: unknown = text ? safeParse(text) : undefined;\n\n requests.push({ method, path, status: res.status, ms: Date.now() - startedAt });\n\n if (!res.ok) throw new TestApiError(method, path, res.status, parsed);\n return parsed as T;\n }\n\n return {\n requests,\n get: (path, opts) => call(\"GET\", path, undefined, opts),\n post: (path, body, opts) => call(\"POST\", path, body, opts),\n patch: (path, body, opts) => call(\"PATCH\", path, body, opts),\n put: (path, body, opts) => call(\"PUT\", path, body, opts),\n delete: (path, opts) => call(\"DELETE\", path, undefined, opts),\n query: (path, body, opts) => call(\"QUERY\", path, body, opts),\n\n async signInAs(name) {\n const identity = (config.identities ?? {})[name];\n if (!identity) {\n const declared = Object.keys(config.identities ?? {});\n throw new Error(\n `no test identity named ${JSON.stringify(name)} — the deploy mints one per user declared in ` +\n `config/test-users.ts` +\n (declared.length\n ? `; this run has: ${declared.join(\", \")}`\n : // Not \"you declared none\": from here the two causes are\n // indistinguishable, and blaming the customer's config for a\n // platform failure sends them to look in the wrong file. The\n // deploy log names which one it was.\n \", and this run has none — either your config declares no users \" +\n \"or the deploy could not mint them; the deploy log says which\"),\n );\n }\n // The session the mint already issued — no network call, so switching\n // users is free and the login rail never sees this run.\n if (identity.accessToken) {\n bearer = identity.accessToken;\n return { id: identity.id ?? \"\", email: identity.email };\n }\n return this.signIn(identity);\n },\n\n async signIn(credentials) {\n const result = await call<{ access_token: string; user?: { id: string; email?: string } }>(\n \"POST\",\n \"/auth/login\",\n credentials,\n );\n bearer = result.access_token;\n return result.user ?? { id: \"\" };\n },\n async signOut() {\n await call(\"POST\", \"/auth/logout\", undefined);\n bearer = null;\n },\n asAnonymous() {\n bearer = null;\n },\n };\n}\n\n/** The run's identities, as the deploy passed them. Absent is not an error: a\n * project that declares none still runs every test that needs no login. */\nfunction parseIdentities(raw: string | undefined): Record<string, TestIdentity> {\n if (!raw) return {};\n try {\n return JSON.parse(raw) as Record<string, TestIdentity>;\n } catch {\n return {};\n }\n}\n\nfunction safeParse(text: string): unknown {\n try {\n return JSON.parse(text);\n } catch {\n return text;\n }\n}\n\n/** The client the deploy configured, from the environment it set.\n *\n * Constructed lazily so importing this module outside a test run — a typecheck,\n * an editor — does not fail on absent configuration.\n */\nlet configured: TestApi | null = null;\n\nexport const api: TestApi = new Proxy({} as TestApi, {\n get(_target, prop) {\n configured ??= createTestApi({\n baseUrl: process.env.PALBASE_TEST_BASE_URL ?? \"\",\n apiKey: process.env.PALBASE_TEST_API_KEY ?? \"\",\n candidateToken: process.env.PALBASE_TEST_CANDIDATE ?? \"\",\n identities: parseIdentities(process.env.PALBASE_TEST_IDENTITIES),\n });\n return Reflect.get(configured, prop, configured);\n },\n});\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/test/api.ts"],"sourcesContent":["/** The client a tenant's tests use to call their own backend.\n *\n * These tests run against a REAL deployment — the release the deploy just built,\n * serving from the same Environment as production, with the same database, the\n * same secrets and the same gateway in front of it. So this client is a plain\n * HTTP client, not a simulation: every call crosses Kong, the API key check, the\n * auth rail, the zod validation at the boundary, and row-level security, exactly\n * as a shipped app's call does.\n *\n * There is deliberately no schema knowledge here. The tenant already wrote their\n * types — `import type { TodoSchema } from \"../models/todos/shared.js\"` — so a\n * test types its own call (`api.get<TodoSchema[]>(\"/todos\")`) and can validate it\n * with the same zod schema the endpoint declares. A second generated client would\n * be a second thing to keep in step.\n */\n\n/** How to reach the release under test. Supplied by the deploy, never guessed. */\nexport interface TestApiConfig {\n baseUrl: string;\n apiKey: string;\n /** This deploy's secret. Without it the request is served the LIVE release. */\n candidateToken: string;\n /** The run's minted logins, keyed by the name declared in config/test-users.ts. */\n identities?: Record<string, TestIdentity>;\n}\n\n/** One login the deploy minted for this run.\n *\n * `accessToken` is the session issued when the identity was created, and is what\n * `signInAs` uses. The credentials come along for a test that wants to exercise\n * the login rail itself — but a suite that switches users repeatedly must not be\n * signing in each time: those calls come from one address and trip the login rate\n * limiter, failing tests for a reason that has nothing to do with the code\n * under test.\n */\nexport interface TestIdentity {\n id?: string;\n email: string;\n password: string;\n accessToken?: string;\n}\n\n/** One call the suite made, in the order it was made. */\nexport interface RecordedRequest {\n method: string;\n path: string;\n status: number;\n ms: number;\n}\n\n/** A non-2xx answer, carrying the platform's error envelope.\n *\n * The envelope is the contract every Palbase endpoint answers with, so a test\n * asserts on `status`/`error`/`data` rather than parsing a message. The message\n * exists for the human reading a failed deploy.\n */\nexport class TestApiError extends Error {\n readonly status: number;\n readonly error: string;\n /** Payload of an error your code threw — `new BadRequest({ fields })` arrives here. */\n readonly data: unknown;\n /** The whole envelope, exactly as the server sent it. */\n readonly body: ErrorEnvelope;\n\n constructor(method: string, path: string, status: number, body: unknown) {\n const envelope = (body ?? {}) as ErrorEnvelope;\n const code = envelope.error ?? String(status);\n super(`${method} ${path} → ${status} ${code}${envelope.error_description ? `: ${envelope.error_description}` : \"\"}`);\n this.name = \"TestApiError\";\n this.status = status;\n this.error = code;\n this.data = envelope.data;\n this.body = envelope;\n }\n}\n\n/** A Palbase error response.\n *\n * `data` carries the payload of an error your code threw. Validation refused at\n * the boundary — before your handler runs — answers with `details` instead, one\n * entry per field. The index signature is deliberate: whatever the server sends\n * is readable from a test, so no assertion is ever blocked on this type being\n * exhaustive.\n */\nexport interface ErrorEnvelope {\n error?: string;\n error_description?: string;\n status?: number;\n request_id?: string;\n data?: unknown;\n details?: Array<{ field: string; message: string }>;\n [key: string]: unknown;\n}\n\nexport interface CallOptions {\n headers?: Record<string, string>;\n}\n\nexport interface TestApi {\n get<T = unknown>(path: string, opts?: CallOptions): Promise<T>;\n post<T = unknown>(path: string, body?: unknown, opts?: CallOptions): Promise<T>;\n patch<T = unknown>(path: string, body?: unknown, opts?: CallOptions): Promise<T>;\n put<T = unknown>(path: string, body?: unknown, opts?: CallOptions): Promise<T>;\n delete<T = unknown>(path: string, opts?: CallOptions): Promise<T>;\n /** HTTP QUERY (RFC 10008): a safe, idempotent read whose filter travels in the body. */\n query<T = unknown>(path: string, body?: unknown, opts?: CallOptions): Promise<T>;\n /** Sign in with credentials you supply. */\n signIn(credentials: { email: string; password: string }): Promise<{ id: string; email?: string }>;\n /**\n * Sign in as one of the identities this run was given, by the name you\n * declared it under in `config/test-users.ts`.\n *\n * Each is minted by the platform for the length of ONE deploy, seeded with the\n * data that declaration describes, and retired after — which is why this works\n * on every Environment including production, where a committed fixture password\n * is refused on purpose.\n */\n signInAs(name: string): Promise<{ id: string; email?: string }>;\n signOut(): Promise<void>;\n /** Drop the bearer without calling the server — the anonymous caller. */\n asAnonymous(): void;\n /** Every call made, in order. Printed for the failing test in a red deploy. */\n readonly requests: readonly RecordedRequest[];\n}\n\nfunction required(value: string, envName: string): string {\n if (!value) {\n throw new Error(\n `${envName} is not set — the test client has nowhere to send requests. ` +\n `This is set by the deploy that runs your tests; if you are running them by hand, set it yourself.`,\n );\n }\n return value;\n}\n\nexport function createTestApi(config: TestApiConfig): TestApi {\n const baseUrl = required(config.baseUrl, \"PALBASE_TEST_BASE_URL\").replace(/\\/$/, \"\");\n const apiKey = required(config.apiKey, \"PALBASE_TEST_API_KEY\");\n const candidateToken = required(config.candidateToken, \"PALBASE_TEST_CANDIDATE\");\n\n const requests: RecordedRequest[] = [];\n let bearer: string | null = null;\n\n async function call<T>(method: string, path: string, body: unknown, opts: CallOptions = {}): Promise<T> {\n const headers: Record<string, string> = {\n apikey: apiKey,\n // Selects the release under test. Omit it and the gateway serves the LIVE\n // one, which would make the whole suite grade the wrong code.\n \"x-palbase-candidate\": candidateToken,\n ...opts.headers,\n };\n if (bearer) headers.authorization = `Bearer ${bearer}`;\n if (body !== undefined) headers[\"content-type\"] = \"application/json\";\n\n const startedAt = Date.now();\n const res = await fetch(`${baseUrl}${path}`, {\n method,\n headers,\n body: body === undefined ? undefined : JSON.stringify(body),\n });\n const text = await res.text();\n const parsed: unknown = text ? safeParse(text) : undefined;\n\n requests.push({ method, path, status: res.status, ms: Date.now() - startedAt });\n\n if (!res.ok) throw new TestApiError(method, path, res.status, parsed);\n return parsed as T;\n }\n\n return {\n requests,\n get: (path, opts) => call(\"GET\", path, undefined, opts),\n post: (path, body, opts) => call(\"POST\", path, body, opts),\n patch: (path, body, opts) => call(\"PATCH\", path, body, opts),\n put: (path, body, opts) => call(\"PUT\", path, body, opts),\n delete: (path, opts) => call(\"DELETE\", path, undefined, opts),\n query: (path, body, opts) => call(\"QUERY\", path, body, opts),\n\n async signInAs(name) {\n const identity = (config.identities ?? {})[name];\n if (!identity) {\n const declared = Object.keys(config.identities ?? {});\n throw new Error(\n `no test identity named ${JSON.stringify(name)} — the deploy mints one per user declared in ` +\n `config/test-users.ts` +\n (declared.length\n ? `; this run has: ${declared.join(\", \")}`\n : // Not \"you declared none\": from here the two causes are\n // indistinguishable, and blaming the customer's config for a\n // platform failure sends them to look in the wrong file. The\n // deploy log names which one it was.\n \", and this run has none — either your config declares no users \" +\n \"or the deploy could not mint them; the deploy log says which\"),\n );\n }\n // The session the mint already issued — no network call, so switching\n // users is free and the login rail never sees this run.\n if (identity.accessToken) {\n bearer = identity.accessToken;\n return { id: identity.id ?? \"\", email: identity.email };\n }\n return this.signIn(identity);\n },\n\n async signIn(credentials) {\n const result = await call<{ access_token: string; user?: { id: string; email?: string } }>(\n \"POST\",\n \"/auth/login\",\n credentials,\n );\n bearer = result.access_token;\n return result.user ?? { id: \"\" };\n },\n async signOut() {\n await call(\"POST\", \"/auth/logout\", undefined);\n bearer = null;\n },\n asAnonymous() {\n bearer = null;\n },\n };\n}\n\n/** The run's identities, as the deploy passed them. Absent is not an error: a\n * project that declares none still runs every test that needs no login. */\nfunction parseIdentities(raw: string | undefined): Record<string, TestIdentity> {\n if (!raw) return {};\n try {\n return JSON.parse(raw) as Record<string, TestIdentity>;\n } catch {\n return {};\n }\n}\n\nfunction safeParse(text: string): unknown {\n try {\n return JSON.parse(text);\n } catch {\n return text;\n }\n}\n\n/** The client the deploy configured, from the environment it set.\n *\n * Constructed lazily so importing this module outside a test run — a typecheck,\n * an editor — does not fail on absent configuration.\n */\nlet configured: TestApi | null = null;\n\nexport const api: TestApi = new Proxy({} as TestApi, {\n get(_target, prop) {\n configured ??= createTestApi({\n baseUrl: process.env.PALBASE_TEST_BASE_URL ?? \"\",\n apiKey: process.env.PALBASE_TEST_API_KEY ?? \"\",\n candidateToken: process.env.PALBASE_TEST_CANDIDATE ?? \"\",\n identities: parseIdentities(process.env.PALBASE_TEST_IDENTITIES),\n });\n return Reflect.get(configured, prop, configured);\n },\n});\n"],"mappings":"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","names":[]}
package/docs/database.md CHANGED
@@ -178,9 +178,9 @@ nothing your code does. See
178
178
 
179
179
  Sometimes you need to read or write **across all users** — an admin endpoint, a
180
180
  background job that fans out notifications, a cleanup task. For that, call
181
- `Database.asService()`. It returns a sibling client that runs as the
182
- `service_role` (which has `BYPASSRLS`), exposing the exact same surface —
183
- `tables`, the raw string ops, and `transaction`:
181
+ `Database.asService()`. It returns a sibling client that runs as
182
+ **`backend_service_role`** (the role that carries `BYPASSRLS`), exposing the
183
+ exact same surface — `tables`, the raw string ops, and `transaction`:
184
184
 
185
185
  ```ts
186
186
  import { Database } from "@palbase/backend";
@@ -209,3 +209,16 @@ Guidelines:
209
209
  transaction. Use `Database.transaction(...)` for an authenticated one and
210
210
  `Database.asService().transaction(...)` for a service-role one; you cannot mix
211
211
  enforced and bypassed operations inside a single plan.
212
+ - **It is a second transaction, on a second connection.** It has to be: the
213
+ Postgres role is bound once, when the transaction opens, so a sibling sharing
214
+ the request's transaction would run as `backend_authenticated` and silently
215
+ keep filtering. Three consequences worth knowing:
216
+ - The request opens it at most once, however many times you call
217
+ `asService()`, and never at all if you don't use it.
218
+ - Both settle with the request — commit when your handler returns, roll back
219
+ when it throws — but they settle as two transactions. A row written through
220
+ one surface is **not visible** to the other until the request commits.
221
+ - So don't touch the **same row** from both surfaces in one request: the
222
+ second surface waits for a lock the first cannot release until the handler
223
+ returns. The service side gives up after five seconds and tells you this;
224
+ the default side would wait until the edge cuts the request.
@@ -1006,9 +1006,9 @@ nothing your code does. See
1006
1006
 
1007
1007
  Sometimes you need to read or write **across all users** — an admin endpoint, a
1008
1008
  background job that fans out notifications, a cleanup task. For that, call
1009
- `Database.asService()`. It returns a sibling client that runs as the
1010
- `service_role` (which has `BYPASSRLS`), exposing the exact same surface —
1011
- `tables`, the raw string ops, and `transaction`:
1009
+ `Database.asService()`. It returns a sibling client that runs as
1010
+ **`backend_service_role`** (the role that carries `BYPASSRLS`), exposing the
1011
+ exact same surface — `tables`, the raw string ops, and `transaction`:
1012
1012
 
1013
1013
  ```ts
1014
1014
  import { Database } from "@palbase/backend";
@@ -1037,6 +1037,19 @@ Guidelines:
1037
1037
  transaction. Use `Database.transaction(...)` for an authenticated one and
1038
1038
  `Database.asService().transaction(...)` for a service-role one; you cannot mix
1039
1039
  enforced and bypassed operations inside a single plan.
1040
+ - **It is a second transaction, on a second connection.** It has to be: the
1041
+ Postgres role is bound once, when the transaction opens, so a sibling sharing
1042
+ the request's transaction would run as `backend_authenticated` and silently
1043
+ keep filtering. Three consequences worth knowing:
1044
+ - The request opens it at most once, however many times you call
1045
+ `asService()`, and never at all if you don't use it.
1046
+ - Both settle with the request — commit when your handler returns, roll back
1047
+ when it throws — but they settle as two transactions. A row written through
1048
+ one surface is **not visible** to the other until the request commits.
1049
+ - So don't touch the **same row** from both surfaces in one request: the
1050
+ second surface waits for a lock the first cannot release until the handler
1051
+ returns. The service side gives up after five seconds and tells you this;
1052
+ the default side would wait until the edge cuts the request.
1040
1053
 
1041
1054
 
1042
1055
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@palbase/backend",
3
- "version": "17.4.0",
4
- "description": "Palbase Backend SDK class controllers (@Controller/@Get/@Post + @Body/@QueryParams/@Param), error classes, schema DSL",
3
+ "version": "18.0.0",
4
+ "description": "Palbase Backend SDK \u2014 class controllers (@Controller/@Get/@Post + @Body/@QueryParams/@Param), error classes, schema DSL",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -62,12 +62,49 @@
62
62
  "types": "./dist/purchases/keys.d.cts",
63
63
  "default": "./dist/purchases/keys.cjs"
64
64
  }
65
+ },
66
+ "./engine": {
67
+ "import": {
68
+ "types": "./dist/engine/index.d.ts",
69
+ "default": "./dist/engine/index.js"
70
+ },
71
+ "require": {
72
+ "types": "./dist/engine/index.d.cts",
73
+ "default": "./dist/engine/index.cjs"
74
+ }
75
+ },
76
+ "./openapi": {
77
+ "import": {
78
+ "types": "./dist/openapi/index.d.ts",
79
+ "default": "./dist/openapi/index.js"
80
+ },
81
+ "require": {
82
+ "types": "./dist/openapi/index.d.cts",
83
+ "default": "./dist/openapi/index.cjs"
84
+ }
85
+ },
86
+ "./stager": {
87
+ "require": "./stager/stage.js",
88
+ "default": "./stager/stage.js"
65
89
  }
66
90
  },
67
91
  "files": [
68
92
  "dist",
69
- "docs"
93
+ "docs",
94
+ "stager",
95
+ "template"
70
96
  ],
97
+ "scripts": {
98
+ "build": "tsup && npm run build:stager",
99
+ "test": "vitest run",
100
+ "test:watch": "vitest",
101
+ "typecheck": "tsc --noEmit",
102
+ "docs:build": "node scripts/build-llms.mjs",
103
+ "emit:openapi-fixture": "tsx scripts/emit-openapi-fixture.mjs",
104
+ "check:api": "node scripts/api-surface.mjs",
105
+ "api:update": "node scripts/api-surface.mjs --update",
106
+ "build:stager": "mkdir -p stager && cp src/stager/*.js src/stager/package.json stager/"
107
+ },
71
108
  "dependencies": {
72
109
  "@asteasolutions/zod-to-openapi": "^7.3.4",
73
110
  "zod": "^3.24.0"
@@ -84,14 +121,7 @@
84
121
  "publishConfig": {
85
122
  "access": "public"
86
123
  },
87
- "scripts": {
88
- "build": "tsup",
89
- "test": "vitest run",
90
- "test:watch": "vitest",
91
- "typecheck": "tsc --noEmit",
92
- "docs:build": "node scripts/build-llms.mjs",
93
- "emit:openapi-fixture": "tsx scripts/emit-openapi-fixture.mjs",
94
- "check:api": "node scripts/api-surface.mjs",
95
- "api:update": "node scripts/api-surface.mjs --update"
124
+ "bin": {
125
+ "palbase-backend": "./dist/bin/palbase-backend.js"
96
126
  }
97
- }
127
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "//": "The stager is CommonJS: it is a BUILD-time script driven by node, sharing its two analyzers verbatim with the CLI and the cloud's deploy stager, where they are CommonJS too. The SDK itself is ESM (\"type\": \"module\" one level up), and without this file node reads these .js files as ES modules and refuses their first require().",
3
+ "type": "commonjs"
4
+ }
@@ -0,0 +1,338 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Palbase Backend Runtime — Return-Type Schema Binder (source-`.ts` name reader)
5
+ *
6
+ * The response schema of a route is the method's RETURN TYPE, not a separate
7
+ * `@Returns(...)` decorator (which was removed — clean cutover). For each route
8
+ * method this reads the WRITTEN return-type annotation from the controller's
9
+ * SOURCE `.ts` (types are erased by esbuild, so they only exist in source),
10
+ * resolves the same-named exported zod const, and emits an injection snippet that
11
+ * binds it onto the controller's route registry at module-eval time — so BOTH the
12
+ * OpenAPI/codegen extractor AND the deployed pod's runtime output-validation see
13
+ * the live zod (no esbuild plugin needed; the bind is plain top-level code in the
14
+ * bundle).
15
+ *
16
+ * Convention (enforced — a violation is a HARD build error, never silent):
17
+ * - a body-returning route MUST return a NAMED zod-inferred type:
18
+ * foo(): Promise<TodoSchema> → schema = TodoSchema
19
+ * foo(): Promise<TodoSchema[]> → schema = z.array(TodoSchema)
20
+ * foo(): TodoSchema / TodoSchema[] → (Promise optional)
21
+ * - `void` / `Promise<void>` / no annotation → no 200 body (allowed)
22
+ * - anything else (inline object, union, intersection, a type with no
23
+ * same-named exported zod) → HARD error naming <Controller>.<method>.
24
+ *
25
+ * This module is SHARED VERBATIM by the deploy extractor and `palbase build`
26
+ * (the load-bearing serve==deploy parity rule). It uses the `typescript` PARSER
27
+ * only (ts.createSourceFile) — no type-checker, no Program — so it's cheap and
28
+ * needs only `typescript` on NODE_PATH.
29
+ */
30
+
31
+ let ts = null;
32
+ function loadTS() {
33
+ if (ts) return ts;
34
+ let mod;
35
+ try {
36
+ mod = require('typescript');
37
+ } catch (e) {
38
+ throw new Error(TS_PARSER_HELP('the `typescript` package could not be loaded (' + e.message + ')'));
39
+ }
40
+ // TypeScript 7 is the Go-native compiler: its CommonJS entry exports only
41
+ // { version, versionMajorMinor } — no createSourceFile, no ScriptTarget. Reading
42
+ // `ts.ScriptTarget.ES2022` off it threw "Cannot read properties of undefined",
43
+ // which told the user nothing. Check the surface we need and SAY what's wrong.
44
+ if (typeof mod.createSourceFile !== 'function' || !mod.ScriptTarget) {
45
+ throw new Error(TS_PARSER_HELP(
46
+ 'the resolved `typescript` (v' + (mod.version || 'unknown') + ') has no compiler API — ' +
47
+ 'TypeScript 7 ships the Go-native compiler, whose CommonJS build exposes version metadata only',
48
+ ));
49
+ }
50
+ ts = mod;
51
+ return ts;
52
+ }
53
+
54
+ // One actionable message for both parser-load failures. The CLI/pod normally puts
55
+ // its OWN pinned TypeScript 5 ahead of the project on NODE_PATH, so reaching this
56
+ // means that provisioning was skipped (offline first run) and the fallback — the
57
+ // project's typescript — is unusable.
58
+ function TS_PARSER_HELP(reason) {
59
+ return (
60
+ 'palbase needs the TypeScript 5 compiler API to read controller return types, but ' +
61
+ reason +
62
+ '.\n fix: npm install --save-dev typescript@5 (or re-run once online: the CLI installs its own pinned parser)'
63
+ );
64
+ }
65
+
66
+ class ReturnTypeError extends Error {}
67
+
68
+ // Symbols MUST match the SDK (@palbase/backend src/decorators/registry.ts).
69
+ const ROUTES_SYMBOL_KEY = 'palbase.backend.routes';
70
+ const RETURN_BUFFER_SYMBOL_KEY = 'palbase.backend.returnBuffer';
71
+
72
+ /**
73
+ * Parse one controller source file and return, per method, the resolved
74
+ * return-type binding the injector needs.
75
+ *
76
+ * @param {string} sourceText the controller's .ts source
77
+ * @param {string} fileLabel path/name for error messages
78
+ * @returns {{ className: string, methods: Array<{ fnName, typeName, isArray }>, imports: Record<string,string> }}
79
+ * - methods: only routes that HAVE a resolvable named return type (void/none omitted)
80
+ * - imports: local-binding-name → module-specifier (for the injector's import)
81
+ * Throws ReturnTypeError on an un-resolvable/disallowed return type.
82
+ */
83
+ function readReturnTypes(sourceText, fileLabel) {
84
+ const tsapi = loadTS();
85
+ const sf = tsapi.createSourceFile(
86
+ fileLabel || 'controller.ts',
87
+ sourceText,
88
+ tsapi.ScriptTarget.ES2022,
89
+ /* setParentNodes */ true,
90
+ );
91
+
92
+ // Map local binding name → module specifier, for named imports only.
93
+ // `import { TodoSchema } from "../models/todo"` → TodoSchema:"../models/todo"
94
+ // `import { TodoSchema as T } from "../models/todo"` → T:"../models/todo" (+ original)
95
+ const imports = {}; // localName → specifier
96
+ const importOriginal = {}; // localName → originalExportedName (for aliases)
97
+ for (const stmt of sf.statements) {
98
+ if (!tsapi.isImportDeclaration(stmt) || !stmt.importClause) continue;
99
+ const spec = stmt.moduleSpecifier.text;
100
+ const named = stmt.importClause.namedBindings;
101
+ if (named && tsapi.isNamedImports(named)) {
102
+ for (const el of named.elements) {
103
+ const local = el.name.text;
104
+ const original = el.propertyName ? el.propertyName.text : local;
105
+ imports[local] = spec;
106
+ importOriginal[local] = original;
107
+ }
108
+ }
109
+ }
110
+
111
+ let className = null;
112
+ const methods = [];
113
+
114
+ function err(method, msg) {
115
+ return new ReturnTypeError(
116
+ `${fileLabel || 'controller'} — ${className || '?'}.${method}: ${msg}`,
117
+ );
118
+ }
119
+
120
+ // Unwrap Promise<T>, detect arrays, and require a single named type reference.
121
+ // Returns { typeName, isArray } or null for void/undefined/no-annotation.
122
+ function resolveType(typeNode, method) {
123
+ if (!typeNode) return null; // no annotation → allowed (void-ish)
124
+
125
+ // Promise<X> → unwrap
126
+ if (
127
+ tsapi.isTypeReferenceNode(typeNode) &&
128
+ typeNode.typeName.getText(sf) === 'Promise' &&
129
+ typeNode.typeArguments &&
130
+ typeNode.typeArguments.length === 1
131
+ ) {
132
+ return resolveType(typeNode.typeArguments[0], method);
133
+ }
134
+
135
+ // void / undefined → no body
136
+ if (
137
+ typeNode.kind === tsapi.SyntaxKind.VoidKeyword ||
138
+ typeNode.kind === tsapi.SyntaxKind.UndefinedKeyword
139
+ ) {
140
+ return null;
141
+ }
142
+
143
+ // X[] → array of element
144
+ if (tsapi.isArrayTypeNode(typeNode)) {
145
+ const inner = resolveNamed(typeNode.elementType, method);
146
+ return { typeName: inner, isArray: true };
147
+ }
148
+
149
+ // Array<X> → array of element
150
+ if (
151
+ tsapi.isTypeReferenceNode(typeNode) &&
152
+ typeNode.typeName.getText(sf) === 'Array' &&
153
+ typeNode.typeArguments &&
154
+ typeNode.typeArguments.length === 1
155
+ ) {
156
+ const inner = resolveNamed(typeNode.typeArguments[0], method);
157
+ return { typeName: inner, isArray: true };
158
+ }
159
+
160
+ // A single named type reference (no type args) → the schema name
161
+ return { typeName: resolveNamed(typeNode, method), isArray: false };
162
+ }
163
+
164
+ // A node that resolves to a single schema NAME. Accepts either form (so the
165
+ // author never has to write a separate `export type X` line):
166
+ // - a bare reference to the schema: `TodoSchema`
167
+ // - the zod-inferred type of the schema: `z.infer<typeof TodoSchema>`
168
+ // (and the std `z.output<...>` / `z.input<...>` aliases)
169
+ // Anything else — inline object, union, intersection, literal, other generic —
170
+ // is a hard error (name the schema in a model file).
171
+ function resolveNamed(node, method) {
172
+ // bare `TodoSchema`
173
+ if (tsapi.isTypeReferenceNode(node) && !node.typeArguments) {
174
+ return node.typeName.getText(sf);
175
+ }
176
+ // `z.infer<typeof TodoSchema>` (or z.output / z.input) → unwrap to TodoSchema
177
+ if (tsapi.isTypeReferenceNode(node) && node.typeArguments && node.typeArguments.length === 1) {
178
+ const head = node.typeName.getText(sf); // e.g. "z.infer" / "infer"
179
+ if (/^(z\.)?(infer|output|input)$/.test(head)) {
180
+ const arg = node.typeArguments[0];
181
+ // arg is `typeof TodoSchema` — a TypeQuery whose exprName is the schema.
182
+ if (tsapi.isTypeQueryNode(arg)) {
183
+ return arg.exprName.getText(sf);
184
+ }
185
+ // tolerate a plain reference inside (rare): z.infer<TodoSchema>
186
+ if (tsapi.isTypeReferenceNode(arg) && !arg.typeArguments) {
187
+ return arg.typeName.getText(sf);
188
+ }
189
+ }
190
+ }
191
+ if (node.kind === tsapi.SyntaxKind.VoidKeyword) {
192
+ throw err(method, 'void cannot appear inside an array/Promise here');
193
+ }
194
+ throw err(
195
+ method,
196
+ 'return type must be a NAMED zod schema — write `Promise<z.infer<typeof TodoSchema>>` ' +
197
+ '(or `TodoSchema` / `TodoSchema[]`); inline objects, unions, literals, and other ' +
198
+ 'generics are not allowed — name the schema in a model file',
199
+ );
200
+ }
201
+
202
+ for (const stmt of sf.statements) {
203
+ if (!tsapi.isClassDeclaration(stmt)) continue;
204
+ // Only consider a class decorated with @Controller (the routing surface).
205
+ const decos = tsapi.getDecorators ? tsapi.getDecorators(stmt) || [] : [];
206
+ const isController = decos.some((d) => {
207
+ const ex = d.expression;
208
+ const callee = tsapi.isCallExpression(ex) ? ex.expression : ex;
209
+ return callee && callee.getText(sf) === 'Controller';
210
+ });
211
+ if (!isController) continue;
212
+ className = stmt.name ? stmt.name.text : null;
213
+
214
+ for (const m of stmt.members) {
215
+ if (!tsapi.isMethodDeclaration(m)) continue;
216
+ const mdecos = tsapi.getDecorators ? tsapi.getDecorators(m) || [] : [];
217
+ const isRoute = mdecos.some((d) => {
218
+ const ex = d.expression;
219
+ const callee = tsapi.isCallExpression(ex) ? ex.expression : ex;
220
+ const n = callee && callee.getText(sf);
221
+ return n === 'Get' || n === 'Post' || n === 'Put' || n === 'Patch' || n === 'Delete' || n === 'Query'
222
+ // @Upload is a route like any other: on the wire it is a POST, and its
223
+ // RETURN TYPE is the completion's response contract. Leaving it out here
224
+ // meant an @Upload method's return type was never read, so the operation
225
+ // shipped with NO 200 schema and every generated client got an EMPTY
226
+ // response struct — the upload worked and the document it returns
227
+ // (url, variants, caption) was untyped and unreachable from the app.
228
+ || n === 'Upload';
229
+ });
230
+ if (!isRoute) continue;
231
+
232
+ const fnName = m.name.getText(sf);
233
+ // A route method MUST declare its return type explicitly — either a named
234
+ // zod schema or `void`/`Promise<void>`. A missing annotation is the SAME
235
+ // silent gap @Returns had (codegen would emit an untyped struct), so it's a
236
+ // HARD error: the author must say what the route returns.
237
+ if (!m.type) {
238
+ throw err(
239
+ fnName,
240
+ 'route method has no return type — annotate it (e.g. `: Promise<TodoSchema>`, ' +
241
+ '`: TodoSchema[]`, or `: void` for no body)',
242
+ );
243
+ }
244
+ const resolved = resolveType(m.type, fnName);
245
+ if (!resolved) continue; // explicit void/Promise<void> — no 200 body, allowed
246
+
247
+ const { typeName, isArray } = resolved;
248
+ // The schema name must be an imported (or locally-defined) value. If it's
249
+ // not imported, we can't bind it from the bundle — hard error.
250
+ if (!(typeName in imports) && !localValueExists(sf, typeName, tsapi)) {
251
+ throw err(
252
+ fnName,
253
+ `return type \`${typeName}\` has no matching imported/exported zod schema in scope — ` +
254
+ `export \`const ${typeName} = z.object(...)\` and import it`,
255
+ );
256
+ }
257
+ methods.push({ fnName, typeName, isArray });
258
+ }
259
+ }
260
+
261
+ return { className, methods, imports, importOriginal };
262
+ }
263
+
264
+ // A schema declared in the controller file itself (rare) — `const X = z...`.
265
+ function localValueExists(sf, name, tsapi) {
266
+ for (const stmt of sf.statements) {
267
+ if (tsapi.isVariableStatement(stmt)) {
268
+ for (const d of stmt.declarationList.declarations) {
269
+ if (d.name.getText(sf) === name) return true;
270
+ }
271
+ }
272
+ }
273
+ return false;
274
+ }
275
+
276
+ /**
277
+ * Build the JS injection snippet appended to a controller's source (or its
278
+ * generated sibling) BEFORE esbuild bundles it. It writes each method's
279
+ * return-schema onto the controller class's route registry (the same
280
+ * ROUTES/returnBuffer symbols the SDK uses), wrapping arrays in z.array.
281
+ *
282
+ * The snippet imports nothing new beyond `z` and the schema consts (which the
283
+ * controller already imports, so they're already in the bundle — referencing
284
+ * them here keeps esbuild from tree-shaking them).
285
+ *
286
+ * @returns {string} the snippet, or '' when there's nothing to bind.
287
+ */
288
+ function buildInjection(parsed) {
289
+ if (!parsed.className || parsed.methods.length === 0) return '';
290
+ const cls = parsed.className;
291
+ const lines = [];
292
+ lines.push('');
293
+ lines.push('// --- AUTO-GENERATED: route return-schema bindings (from return types) ---');
294
+ lines.push('(() => {');
295
+ lines.push(` const __ROUTES = Symbol.for(${JSON.stringify(ROUTES_SYMBOL_KEY)});`);
296
+ lines.push(` const __RBUF = Symbol.for(${JSON.stringify(RETURN_BUFFER_SYMBOL_KEY)});`);
297
+ lines.push(` const __ctor = ${cls};`);
298
+ lines.push(' const __bind = (fn, schema) => {');
299
+ lines.push(' const routes = __ctor[__ROUTES];');
300
+ lines.push(' const r = routes && routes.find((x) => x.fnName === fn);');
301
+ lines.push(' if (r) { r.returnSchema = schema; return; }');
302
+ lines.push(' if (!__ctor[__RBUF]) __ctor[__RBUF] = {};');
303
+ lines.push(' __ctor[__RBUF][fn] = schema;');
304
+ lines.push(' };');
305
+ for (const m of parsed.methods) {
306
+ const expr = m.isArray ? `z.array(${m.typeName})` : m.typeName;
307
+ lines.push(` __bind(${JSON.stringify(m.fnName)}, ${expr});`);
308
+ }
309
+ lines.push('})();');
310
+ lines.push('');
311
+ return lines.join('\n');
312
+ }
313
+
314
+ /**
315
+ * Append the return-schema injection to a controller's source text. Ensures `z`
316
+ * is in scope (controllers already import it from @palbase/backend; if not, we
317
+ * add the import). Pure string transform — the caller writes the result to the
318
+ * file esbuild will bundle.
319
+ */
320
+ function injectReturnBindings(sourceText, fileLabel) {
321
+ const parsed = readReturnTypes(sourceText, fileLabel);
322
+ const snippet = buildInjection(parsed);
323
+ if (!snippet) return sourceText;
324
+ let out = sourceText;
325
+ // Ensure `z` is importable for z.array(...) wrapping.
326
+ if (!/\bimport\b[^\n]*\bz\b[^\n]*@palbase\/backend/.test(sourceText) &&
327
+ !/\bimport\b[^\n]*\{[^}]*\bz\b[^}]*\}/.test(sourceText)) {
328
+ out = `import { z } from "@palbase/backend";\n` + out;
329
+ }
330
+ return out + '\n' + snippet + '\n';
331
+ }
332
+
333
+ module.exports = {
334
+ ReturnTypeError,
335
+ readReturnTypes,
336
+ buildInjection,
337
+ injectReturnBindings,
338
+ };
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Staging a project's controllers so their RETURN TYPES become response schemas.
3
+ *
4
+ * WHY THIS LIVES IN THE SDK. The two analyzers beside this file read THIS
5
+ * package's decorators and write THIS package's `recordReturn` / `recordThrows`
6
+ * calls — so the code that reads a return type and the code that defines what a
7
+ * return type means are versioned together, and cannot drift apart. They used to
8
+ * live in the CLI and in the cloud's deploy stager as verbatim copies kept
9
+ * honest by a workflow; a third copy was about to be written for the container
10
+ * push, which is the point at which copies stop being kept honest.
11
+ *
12
+ * node stage.js <controllers-dir> <staging-dir> <project-root>
13
+ *
14
+ * The staging directory must be a SIBLING of controllers/: module resolution
15
+ * walks up from the file being bundled, so a directory outside the project
16
+ * cannot see node_modules, and one nested a level deeper turns every
17
+ * `../services` into a path outside the project.
18
+ */
19
+ const fs = require("node:fs");
20
+ const path = require("node:path");
21
+
22
+ const returnTypes = require("./return_types.js");
23
+ const throwAnalysis = require("./throw_analysis.js");
24
+
25
+ function walk(dir) {
26
+ const out = [];
27
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
28
+ const full = path.join(dir, entry.name);
29
+ if (entry.isDirectory()) out.push(...walk(full));
30
+ else out.push(full);
31
+ }
32
+ return out;
33
+ }
34
+
35
+ /**
36
+ * Copy `srcDir` into `stageDir`, injecting into controllers and copying
37
+ * everything else verbatim — services and models are imported from the staged
38
+ * tree and must still resolve.
39
+ */
40
+ function stageControllers(srcDir, stageDir, projectRoot) {
41
+ let injected = 0;
42
+ for (const file of walk(srcDir)) {
43
+ const rel = path.relative(srcDir, file);
44
+ const dest = path.join(stageDir, rel);
45
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
46
+ if (/\.controller\.(c?ts|tsx)$/i.test(path.basename(file))) {
47
+ let out = returnTypes.injectReturnBindings(fs.readFileSync(file, "utf8"), rel);
48
+ out = throwAnalysis.injectThrowBindings(out, file, {
49
+ readFile: (p) => {
50
+ try {
51
+ return fs.readFileSync(p, "utf8");
52
+ } catch {
53
+ return null;
54
+ }
55
+ },
56
+ fileExists: (p) => fs.existsSync(p),
57
+ projectRoot,
58
+ });
59
+ fs.writeFileSync(dest, out);
60
+ injected += 1;
61
+ } else {
62
+ fs.copyFileSync(file, dest);
63
+ }
64
+ }
65
+ return injected;
66
+ }
67
+
68
+ module.exports = { stageControllers };
69
+
70
+ if (require.main === module) {
71
+ const [srcDir, stageDir, projectRoot] = process.argv.slice(2);
72
+ if (!srcDir || !stageDir || !projectRoot) {
73
+ console.error("usage: stage.js <controllers-dir> <staging-dir> <project-root>");
74
+ process.exit(2);
75
+ }
76
+ const injected = stageControllers(srcDir, stageDir, projectRoot);
77
+ console.log(`typed ${injected} controller file(s) from their return types`);
78
+ }