@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.
- package/dist/bin/palbase-backend.cjs +1848 -0
- package/dist/bin/palbase-backend.cjs.map +1 -0
- package/dist/bin/palbase-backend.d.cts +1 -0
- package/dist/bin/palbase-backend.d.ts +1 -0
- package/dist/bin/palbase-backend.js +168 -0
- package/dist/bin/palbase-backend.js.map +1 -0
- package/dist/chunk-7D4SUZUM.js +38 -0
- package/dist/chunk-7D4SUZUM.js.map +1 -0
- package/dist/chunk-N32VDWKH.js +172 -0
- package/dist/chunk-N32VDWKH.js.map +1 -0
- package/dist/chunk-POYAFBLF.js +189 -0
- package/dist/chunk-POYAFBLF.js.map +1 -0
- package/dist/chunk-QMVK4X3V.js +200 -0
- package/dist/chunk-QMVK4X3V.js.map +1 -0
- package/dist/chunk-SSGAMC26.js +342 -0
- package/dist/chunk-SSGAMC26.js.map +1 -0
- package/dist/chunk-VYH4U7ZQ.js +1138 -0
- package/dist/chunk-VYH4U7ZQ.js.map +1 -0
- package/dist/{chunk-AAN642N5.js → chunk-W5ODXPY3.js} +2 -336
- package/dist/chunk-W5ODXPY3.js.map +1 -0
- package/dist/chunk-YL4C5NRY.js +90 -0
- package/dist/chunk-YL4C5NRY.js.map +1 -0
- package/dist/db/env.cjs.map +1 -1
- package/dist/db/env.d.cts +21 -1
- package/dist/db/env.d.ts +21 -1
- package/dist/db/index.cjs.map +1 -1
- package/dist/db/index.d.cts +2 -1
- package/dist/db/index.d.ts +2 -1
- package/dist/db/index.js +9 -6
- package/dist/{index-VLrU7rSW.d.ts → endpoint-B0LpZixz.d.cts} +124 -685
- package/dist/{index-BA_oFAz9.d.cts → endpoint-B0LpZixz.d.ts} +124 -685
- package/dist/engine/index.cjs +1797 -0
- package/dist/engine/index.cjs.map +1 -0
- package/dist/engine/index.d.cts +7 -0
- package/dist/engine/index.d.ts +7 -0
- package/dist/engine/index.js +43 -0
- package/dist/engine/index.js.map +1 -0
- package/dist/index-B46CGNvx.d.cts +839 -0
- package/dist/index-BGSCWlUa.d.cts +674 -0
- package/dist/index-DZDUMth5.d.ts +839 -0
- package/dist/index-g-EzitI-.d.ts +674 -0
- package/dist/index.cjs +1031 -11
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +290 -532
- package/dist/index.d.ts +290 -532
- package/dist/index.js +999 -509
- package/dist/index.js.map +1 -1
- package/dist/openapi/index.cjs +6464 -0
- package/dist/openapi/index.cjs.map +1 -0
- package/dist/openapi/index.d.cts +170 -0
- package/dist/openapi/index.d.ts +170 -0
- package/dist/openapi/index.js +6248 -0
- package/dist/openapi/index.js.map +1 -0
- package/dist/registry-3BLYv4si.d.ts +338 -0
- package/dist/registry-Cw0YEYCg.d.cts +338 -0
- package/dist/test/index.js +2 -0
- package/dist/test/index.js.map +1 -1
- package/docs/database.md +16 -3
- package/docs/llms-full.txt +16 -3
- package/package.json +43 -13
- package/stager/package.json +4 -0
- package/stager/return_types.js +338 -0
- package/stager/stage.js +78 -0
- package/stager/throw_analysis.js +726 -0
- package/template/AGENTS.md +261 -0
- package/template/config/secrets.ts +24 -0
- package/template/controllers/health.controller.ts +30 -0
- package/template/db/schema.ts +35 -0
- package/template/package.json +18 -0
- package/template/tsconfig.json +30 -0
- package/LICENSE +0 -21
- package/dist/chunk-AAN642N5.js.map +0 -1
package/dist/test/index.js.map
CHANGED
|
@@ -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":";;;AAwDO,IAAM,eAAN,cAA2B,MAAM;AAAA,EAC7B;AAAA,EACA;AAAA;AAAA,EAEA;AAAA;AAAA,EAEA;AAAA,EAET,YAAY,QAAgB,MAAc,QAAgB,MAAe;AACvE,UAAM,WAAY,QAAQ,CAAC;AAC3B,UAAM,OAAO,SAAS,SAAS,OAAO,MAAM;AAC5C,UAAM,GAAG,MAAM,IAAI,IAAI,WAAM,MAAM,IAAI,IAAI,GAAG,SAAS,oBAAoB,KAAK,SAAS,iBAAiB,KAAK,EAAE,EAAE;AACnH,SAAK,OAAO;AACZ,SAAK,SAAS;AACd,SAAK,QAAQ;AACb,SAAK,OAAO,SAAS;AACrB,SAAK,OAAO;AAAA,EACd;AACF;AAmDA,SAAS,SAAS,OAAe,SAAyB;AACxD,MAAI,CAAC,OAAO;AACV,UAAM,IAAI;AAAA,MACR,GAAG,OAAO;AAAA,IAEZ;AAAA,EACF;AACA,SAAO;AACT;AAEO,SAAS,cAAc,QAAgC;AAC5D,QAAM,UAAU,SAAS,OAAO,SAAS,uBAAuB,EAAE,QAAQ,OAAO,EAAE;AACnF,QAAM,SAAS,SAAS,OAAO,QAAQ,sBAAsB;AAC7D,QAAM,iBAAiB,SAAS,OAAO,gBAAgB,wBAAwB;AAE/E,QAAM,WAA8B,CAAC;AACrC,MAAI,SAAwB;AAE5B,iBAAe,KAAQ,QAAgB,MAAc,MAAe,OAAoB,CAAC,GAAe;AACtG,UAAM,UAAkC;AAAA,MACtC,QAAQ;AAAA;AAAA;AAAA,MAGR,uBAAuB;AAAA,MACvB,GAAG,KAAK;AAAA,IACV;AACA,QAAI,OAAQ,SAAQ,gBAAgB,UAAU,MAAM;AACpD,QAAI,SAAS,OAAW,SAAQ,cAAc,IAAI;AAElD,UAAM,YAAY,KAAK,IAAI;AAC3B,UAAM,MAAM,MAAM,MAAM,GAAG,OAAO,GAAG,IAAI,IAAI;AAAA,MAC3C;AAAA,MACA;AAAA,MACA,MAAM,SAAS,SAAY,SAAY,KAAK,UAAU,IAAI;AAAA,IAC5D,CAAC;AACD,UAAM,OAAO,MAAM,IAAI,KAAK;AAC5B,UAAM,SAAkB,OAAO,UAAU,IAAI,IAAI;AAEjD,aAAS,KAAK,EAAE,QAAQ,MAAM,QAAQ,IAAI,QAAQ,IAAI,KAAK,IAAI,IAAI,UAAU,CAAC;AAE9E,QAAI,CAAC,IAAI,GAAI,OAAM,IAAI,aAAa,QAAQ,MAAM,IAAI,QAAQ,MAAM;AACpE,WAAO;AAAA,EACT;AAEA,SAAO;AAAA,IACL;AAAA,IACA,KAAK,CAAC,MAAM,SAAS,KAAK,OAAO,MAAM,QAAW,IAAI;AAAA,IACtD,MAAM,CAAC,MAAM,MAAM,SAAS,KAAK,QAAQ,MAAM,MAAM,IAAI;AAAA,IACzD,OAAO,CAAC,MAAM,MAAM,SAAS,KAAK,SAAS,MAAM,MAAM,IAAI;AAAA,IAC3D,KAAK,CAAC,MAAM,MAAM,SAAS,KAAK,OAAO,MAAM,MAAM,IAAI;AAAA,IACvD,QAAQ,CAAC,MAAM,SAAS,KAAK,UAAU,MAAM,QAAW,IAAI;AAAA,IAC5D,OAAO,CAAC,MAAM,MAAM,SAAS,KAAK,SAAS,MAAM,MAAM,IAAI;AAAA,IAE3D,MAAM,SAAS,MAAM;AACnB,YAAM,YAAY,OAAO,cAAc,CAAC,GAAG,IAAI;AAC/C,UAAI,CAAC,UAAU;AACb,cAAM,WAAW,OAAO,KAAK,OAAO,cAAc,CAAC,CAAC;AACpD,cAAM,IAAI;AAAA,UACR,0BAA0B,KAAK,UAAU,IAAI,CAAC,4EAE3C,SAAS,SACN,mBAAmB,SAAS,KAAK,IAAI,CAAC;AAAA;AAAA;AAAA;AAAA;AAAA,YAKtC;AAAA;AAAA,QAER;AAAA,MACF;AAGA,UAAI,SAAS,aAAa;AACxB,iBAAS,SAAS;AAClB,eAAO,EAAE,IAAI,SAAS,MAAM,IAAI,OAAO,SAAS,MAAM;AAAA,MACxD;AACA,aAAO,KAAK,OAAO,QAAQ;AAAA,IAC7B;AAAA,IAEA,MAAM,OAAO,aAAa;AACxB,YAAM,SAAS,MAAM;AAAA,QACnB;AAAA,QACA;AAAA,QACA;AAAA,MACF;AACA,eAAS,OAAO;AAChB,aAAO,OAAO,QAAQ,EAAE,IAAI,GAAG;AAAA,IACjC;AAAA,IACA,MAAM,UAAU;AACd,YAAM,KAAK,QAAQ,gBAAgB,MAAS;AAC5C,eAAS;AAAA,IACX;AAAA,IACA,cAAc;AACZ,eAAS;AAAA,IACX;AAAA,EACF;AACF;AAIA,SAAS,gBAAgB,KAAuD;AAC9E,MAAI,CAAC,IAAK,QAAO,CAAC;AAClB,MAAI;AACF,WAAO,KAAK,MAAM,GAAG;AAAA,EACvB,QAAQ;AACN,WAAO,CAAC;AAAA,EACV;AACF;AAEA,SAAS,UAAU,MAAuB;AACxC,MAAI;AACF,WAAO,KAAK,MAAM,IAAI;AAAA,EACxB,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAOA,IAAI,aAA6B;AAE1B,IAAM,MAAe,IAAI,MAAM,CAAC,GAAc;AAAA,EACnD,IAAI,SAAS,MAAM;AACjB,mBAAe,cAAc;AAAA,MAC3B,SAAS,QAAQ,IAAI,yBAAyB;AAAA,MAC9C,QAAQ,QAAQ,IAAI,wBAAwB;AAAA,MAC5C,gBAAgB,QAAQ,IAAI,0BAA0B;AAAA,MACtD,YAAY,gBAAgB,QAAQ,IAAI,uBAAuB;AAAA,IACjE,CAAC;AACD,WAAO,QAAQ,IAAI,YAAY,MAAM,UAAU;AAAA,EACjD;AACF,CAAC;","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
|
|
182
|
-
|
|
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.
|
package/docs/llms-full.txt
CHANGED
|
@@ -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
|
|
1010
|
-
|
|
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": "
|
|
4
|
-
"description": "Palbase Backend SDK
|
|
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
|
-
"
|
|
88
|
-
"
|
|
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
|
+
};
|
package/stager/stage.js
ADDED
|
@@ -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
|
+
}
|