@rebasepro/server 0.25.0 → 0.26.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/{GCSStorageController-C7ARrDAp.js → GCSStorageController-40FyFeSx.js} +8 -4
- package/dist/GCSStorageController-40FyFeSx.js.map +1 -0
- package/dist/{S3StorageController-qevJ0pVE.js → S3StorageController-Bl5shp94.js} +8 -4
- package/dist/S3StorageController-Bl5shp94.js.map +1 -0
- package/dist/access-BFAUvsSs.js +1897 -0
- package/dist/access-BFAUvsSs.js.map +1 -0
- package/dist/api/branch-routes.d.ts +25 -0
- package/dist/api/closed-database.d.ts +25 -0
- package/dist/api/live-schema-routes.d.ts +10 -0
- package/dist/api/rest/api-generator.d.ts +9 -1
- package/dist/api/rest/idempotency.d.ts +15 -1
- package/dist/api/root-error-handler.d.ts +6 -5
- package/dist/api/types.d.ts +12 -0
- package/dist/{ast-schema-editor-mCeSC03x.js → ast-schema-editor-madTa9Hh.js} +2 -2
- package/dist/{ast-schema-editor-mCeSC03x.js.map → ast-schema-editor-madTa9Hh.js.map} +1 -1
- package/dist/auth/access.d.ts +10 -0
- package/dist/auth/adapter-middleware.d.ts +2 -1
- package/dist/auth/adapter-service-key.d.ts +24 -0
- package/dist/auth/api-keys/api-key-middleware.d.ts +21 -1
- package/dist/auth/api-keys/api-key-store.d.ts +8 -1
- package/dist/auth/api-keys/key-grant.d.ts +18 -1
- package/dist/auth/expired-token-sweep.d.ts +5 -0
- package/dist/auth/impersonation.d.ts +3 -1
- package/dist/auth/jwt.d.ts +8 -2
- package/dist/auth/oauth-signin-policy.d.ts +21 -35
- package/dist/auth/rls-scope.d.ts +17 -7
- package/dist/auth/rls-simulator-routes.d.ts +51 -0
- package/dist/auth/simulation-read-only.d.ts +28 -0
- package/dist/auth/simulation.d.ts +141 -0
- package/dist/auth/sql-rate-limit-store.d.ts +15 -1
- package/dist/{auth-CteGWxsL.js → auth-izI7AEzC.js} +391 -1893
- package/dist/auth-izI7AEzC.js.map +1 -0
- package/dist/auth_adapter-B7RUMk6e.js +89 -0
- package/dist/auth_adapter-B7RUMk6e.js.map +1 -0
- package/dist/backend/boot-announcements.d.ts +13 -0
- package/dist/backend/create-backend.d.ts +10 -0
- package/dist/backend/options.d.ts +9 -4
- package/dist/{backend-DTAOsLQc.js → backend-CaG_Jyso.js} +9 -2
- package/dist/backend-CaG_Jyso.js.map +1 -0
- package/dist/{backup-Dq0lyJ_D.js → backup-BVbHoKo8.js} +8 -8
- package/dist/{backup-Dq0lyJ_D.js.map → backup-BVbHoKo8.js.map} +1 -1
- package/dist/boot/boot.d.ts +2 -15
- package/dist/boot/ddl-bootstrap.d.ts +34 -0
- package/dist/boot/env.d.ts +6 -0
- package/dist/boot/fetch-bundle.d.ts +5 -1
- package/dist/boot/provision-only.d.ts +23 -0
- package/dist/boot/provision.d.ts +53 -2
- package/dist/boot/role.d.ts +2 -0
- package/dist/boot/schema-change-recorder.d.ts +45 -0
- package/dist/boot/security-headers.d.ts +22 -0
- package/dist/branch-routes-B2vvEx53.js +84 -0
- package/dist/branch-routes-B2vvEx53.js.map +1 -0
- package/dist/{contract-routes-DiFwiLg2.js → contract-routes-CU2A3JnF.js} +3 -3
- package/dist/{contract-routes-DiFwiLg2.js.map → contract-routes-CU2A3JnF.js.map} +1 -1
- package/dist/cron/cron-store.d.ts +8 -1
- package/dist/{cron-loader-NHqRfKF0.js → cron-loader-mMhqbVsl.js} +3 -3
- package/dist/{cron-loader-NHqRfKF0.js.map → cron-loader-mMhqbVsl.js.map} +1 -1
- package/dist/{cron-routes-Dhf82HzT.js → cron-routes-Cs33j_Kr.js} +5 -5
- package/dist/{cron-routes-Dhf82HzT.js.map → cron-routes-Cs33j_Kr.js.map} +1 -1
- package/dist/{cron-scheduler-D1DT0nw5.js → cron-scheduler-Be7CNghL.js} +3 -3
- package/dist/{cron-scheduler-D1DT0nw5.js.map → cron-scheduler-Be7CNghL.js.map} +1 -1
- package/dist/{cron-store-DEpVstUU.js → cron-store-DLn11M8H.js} +11 -10
- package/dist/cron-store-DLn11M8H.js.map +1 -0
- package/dist/{strings.es-BeZWAodn.js → data_driver-D0mg1Y_x.js} +2 -90
- package/dist/data_driver-D0mg1Y_x.js.map +1 -0
- package/dist/{ddl-bootstrap-BNqi2NZe.js → ddl-bootstrap-CjRrn12l.js} +92 -3
- package/dist/ddl-bootstrap-CjRrn12l.js.map +1 -0
- package/dist/deploy/pod-contract.d.ts +1 -1
- package/dist/email/dev-sink.d.ts +6 -0
- package/dist/email/index.d.ts +1 -1
- package/dist/{errors-_ytoGi8A.js → errors-OMWkKSH-.js} +66 -5
- package/dist/errors-OMWkKSH-.js.map +1 -0
- package/dist/{soft-delete-params-BAgpmBvp.js → field-access-DYsvjI_P.js} +27 -57
- package/dist/field-access-DYsvjI_P.js.map +1 -0
- package/dist/{filter-dialect-O1HK6Nvr.js → filter-dialect-TrKFWLqT.js} +3 -3
- package/dist/{filter-dialect-O1HK6Nvr.js.map → filter-dialect-TrKFWLqT.js.map} +1 -1
- package/dist/{function-loader-DbCSoNJj.js → function-loader-B8YzXEsQ.js} +22 -15
- package/dist/function-loader-B8YzXEsQ.js.map +1 -0
- package/dist/{function-routes-DNV4RUF0.js → function-routes-RxrCHNuM.js} +2 -2
- package/dist/{function-routes-DNV4RUF0.js.map → function-routes-RxrCHNuM.js.map} +1 -1
- package/dist/functions/hono-like.d.ts +12 -0
- package/dist/functions/index.js +37 -2
- package/dist/functions/index.js.map +1 -1
- package/dist/functions/request-timeout.d.ts +3 -3
- package/dist/history/history-routes.d.ts +6 -0
- package/dist/history/retention-sweep.d.ts +5 -0
- package/dist/{history-recorder-RZ1Ow4YY.js → history-recorder-Bsau7h4X.js} +7 -7
- package/dist/history-recorder-Bsau7h4X.js.map +1 -0
- package/dist/{history-store-DPofGVDp.js → history-store-BQz5YCkW.js} +26 -2
- package/dist/history-store-BQz5YCkW.js.map +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.es.js +3003 -681
- package/dist/index.es.js.map +1 -1
- package/dist/init/process-safety.d.ts +4 -3
- package/dist/init/shutdown.d.ts +8 -0
- package/dist/init.d.ts +6 -1
- package/dist/jobs/job-store.d.ts +8 -1
- package/dist/{jobs-8G-_agld.js → jobs-Co6Ni5HP.js} +7 -7
- package/dist/jobs-Co6Ni5HP.js.map +1 -0
- package/dist/{jwt-YhciR93H.js → jwt-rDS0884_.js} +5 -4
- package/dist/{jwt-YhciR93H.js.map → jwt-rDS0884_.js.map} +1 -1
- package/dist/{keys-COzQ2aE8.js → keys-BXcdRNhJ.js} +2 -2
- package/dist/{keys-COzQ2aE8.js.map → keys-BXcdRNhJ.js.map} +1 -1
- package/dist/{logger-Bt32sHFw.js → logger-CW8pmTS6.js} +35 -3
- package/dist/logger-CW8pmTS6.js.map +1 -0
- package/dist/{logs-routes-CelTiS7C.js → logs-routes-CBt7mMIL.js} +3 -3
- package/dist/{logs-routes-CelTiS7C.js.map → logs-routes-CBt7mMIL.js.map} +1 -1
- package/dist/mcp/consent-page.d.ts +38 -1
- package/dist/mcp/mcp-routes.d.ts +9 -0
- package/dist/mcp/mcp-tools.d.ts +7 -1
- package/dist/mcp/oauth-store.d.ts +12 -5
- package/dist/metrics/history-recorder.d.ts +4 -1
- package/dist/metrics/history-store.d.ts +8 -0
- package/dist/{offline-BrqCRgih-BtDB1ecy.js → offline-AvuEcg7u-DFHL98OT.js} +127 -40
- package/dist/offline-AvuEcg7u-DFHL98OT.js.map +1 -0
- package/dist/{offline-store-BhYXViha-BW5j1We_.js → offline-store-BhYXViha-YTmjBpo2.js} +5 -5
- package/dist/{offline-store-BhYXViha-BW5j1We_.js.map → offline-store-BhYXViha-YTmjBpo2.js.map} +1 -1
- package/dist/{openapi-generator-BamY6-ga.js → openapi-generator-CyxEtYEX.js} +61 -22
- package/dist/openapi-generator-CyxEtYEX.js.map +1 -0
- package/dist/{proxy-BEKbsil6.js → proxy-BYir8tWK.js} +5 -7
- package/dist/proxy-BYir8tWK.js.map +1 -0
- package/dist/{query-parser-CGohjyWI.js → query-parser-CDXWf2hH.js} +7 -6
- package/dist/{query-parser-CGohjyWI.js.map → query-parser-CDXWf2hH.js.map} +1 -1
- package/dist/{request-timeout-CRny5KD0.js → request-timeout-DVjo-c_8.js} +6 -6
- package/dist/request-timeout-DVjo-c_8.js.map +1 -0
- package/dist/rls-simulator-routes-bEY7-5-u.js +190 -0
- package/dist/rls-simulator-routes-bEY7-5-u.js.map +1 -0
- package/dist/schema-edit/deployed-schema-guard.d.ts +47 -0
- package/dist/{schema-editor-routes-CnNGCayU.js → schema-editor-routes-Dc3DU_jm.js} +4 -4
- package/dist/{schema-editor-routes-CnNGCayU.js.map → schema-editor-routes-Dc3DU_jm.js.map} +1 -1
- package/dist/soft-delete-params-BRONCOA2.js +59 -0
- package/dist/soft-delete-params-BRONCOA2.js.map +1 -0
- package/dist/{sql-export-routes-BgTJ7K3S.js → sql-export-routes-QycX8W8S.js} +40 -6
- package/dist/sql-export-routes-QycX8W8S.js.map +1 -0
- package/dist/{src-D5Y3N-S4.js → src-Bh9LRPb9.js} +21 -2
- package/dist/{src-D5Y3N-S4.js.map → src-Bh9LRPb9.js.map} +1 -1
- package/dist/storage/GCSStorageController.d.ts +2 -0
- package/dist/storage/LocalStorageController.d.ts +8 -3
- package/dist/storage/S3StorageController.d.ts +2 -0
- package/dist/storage/maintenance-keys.d.ts +17 -0
- package/dist/storage/maintenance-routes.d.ts +28 -5
- package/dist/storage/maintenance.d.ts +136 -4
- package/dist/storage/orphan-scans.d.ts +120 -0
- package/dist/storage/types.d.ts +11 -0
- package/dist/strings.es-B7QLnHh2.js +96 -0
- package/dist/strings.es-B7QLnHh2.js.map +1 -0
- package/dist/{upload-validation-I9cCUBIW.js → upload-validation-Bgo626uY.js} +2 -2
- package/dist/upload-validation-Bgo626uY.js.map +1 -0
- package/dist/utils/dev-dependency-command.d.ts +15 -0
- package/dist/utils/dev-port.d.ts +8 -0
- package/dist/utils/logger.d.ts +24 -0
- package/package.json +5 -5
- package/dist/GCSStorageController-C7ARrDAp.js.map +0 -1
- package/dist/S3StorageController-qevJ0pVE.js.map +0 -1
- package/dist/auth-CteGWxsL.js.map +0 -1
- package/dist/auth_adapter-CIItCTc8.js +0 -21
- package/dist/auth_adapter-CIItCTc8.js.map +0 -1
- package/dist/backend-DTAOsLQc.js.map +0 -1
- package/dist/cron-store-DEpVstUU.js.map +0 -1
- package/dist/ddl-bootstrap-BNqi2NZe.js.map +0 -1
- package/dist/errors-_ytoGi8A.js.map +0 -1
- package/dist/function-loader-DbCSoNJj.js.map +0 -1
- package/dist/history-recorder-RZ1Ow4YY.js.map +0 -1
- package/dist/history-store-DPofGVDp.js.map +0 -1
- package/dist/jobs-8G-_agld.js.map +0 -1
- package/dist/logger-Bt32sHFw.js.map +0 -1
- package/dist/offline-BrqCRgih-BtDB1ecy.js.map +0 -1
- package/dist/openapi-generator-BamY6-ga.js.map +0 -1
- package/dist/proxy-BEKbsil6.js.map +0 -1
- package/dist/request-timeout-CRny5KD0.js.map +0 -1
- package/dist/soft-delete-params-BAgpmBvp.js.map +0 -1
- package/dist/sql-export-routes-BgTJ7K3S.js.map +0 -1
- package/dist/strings.es-BeZWAodn.js.map +0 -1
- package/dist/upload-validation-I9cCUBIW.js.map +0 -1
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"function-loader-DbCSoNJj.js","names":[],"sources":["../src/functions/function-loader.ts"],"sourcesContent":["import * as fs from \"fs\";\nimport * as path from \"path\";\nimport { pathToFileURL } from \"url\";\nimport { Hono } from \"hono\";\nimport type { HonoEnv } from \"../api/types\";\nimport { logger } from \"../utils/logger.js\";\nimport { nativeDynamicImport, type ModuleImporter } from \"../utils/dynamic-import.js\";\nimport { describeLoadFailure, SYNTAX_ERROR_ADVICE } from \"../utils/load-failure.js\";\nimport { warnDeprecated } from \"@rebasepro/common\";\n\n/**\n * One custom function: a Hono app, served at `<functions mount>/<name>`.\n *\n * What `defineFunction(...)` returns, and what a function file default-exports:\n * a Hono app over {@link HonoEnv}, the environment a function runs in — build\n * one by hand as `new Hono<HonoEnv>()`. Any Hono-compatible app works at\n * runtime — the loader checks for `.fetch()` and `.routes` rather than an\n * `instanceof`, because a project and the server can hold two copies of hono.\n */\nexport type FunctionApp = Hono<HonoEnv>;\n\n/**\n * Functions as values: the URL segment each one answers at, to its app.\n *\n * `functions: { \"send-invoice\": sendInvoice }` serves `sendInvoice` at\n * `/api/functions/send-invoice` — the same thing a file named\n * `functions/send-invoice.ts` does. {@link loadFunctions} returns exactly this\n * shape, so `functions: await loadFunctions(dir)` and `functionsDir: dir` serve\n * the same routes.\n */\nexport type FunctionsRecord = Record<string, FunctionApp>;\n\n/** How a directory of modules is read. Shared by the function and cron loaders. */\nexport interface LoadDirectoryOptions {\n /**\n * How a module file is imported. Defaults to the runtime's own `import()`;\n * a caller with its own loader hooks (tsx, a test runner's sandbox) passes\n * `url => import(url)` so they apply.\n */\n importModule?: ModuleImporter;\n /**\n * Called once for every file the loader saw and did **not** load, as\n * `<file> (<reason>)`. The files are skipped either way — one malformed\n * file must not take the others with it — and logged; this is how a caller\n * reports them anywhere else.\n */\n onSkip?: (problem: string) => void;\n}\n\n/**\n * Load the custom functions in a directory, as values.\n *\n * Each file default-exports a Hono app — `defineFunction(...)` — or a factory\n * returning one. The filename without its extension is the record key, which\n * is the URL segment it is served at: `functions/send-invoice.ts` →\n * `{ \"send-invoice\": app }`. Only the top level is read.\n *\n * Never throws for a bad file: it is skipped, logged, and passed to\n * `options.onSkip`.\n *\n * @example\n * ```ts\n * const functions = await loadFunctions(path.resolve(\"functions\"));\n * const backend = await createRebaseBackend({ database, functions, provision: true });\n * ```\n */\nexport async function loadFunctions(directory: string, options: LoadDirectoryOptions = {}): Promise<FunctionsRecord> {\n const { functions, problems } = await readFunctionsDirectory(directory, options.importModule);\n if (options.onSkip) for (const problem of problems) options.onSkip(problem);\n return functions;\n}\n\n/** A loaded function, by name. The shape {@link createFunctionRoutes} mounts. */\nexport interface LoadedFunction {\n /** Endpoint name derived from filename (e.g., \"send-invoice\") */\n name: string;\n /** The Hono sub-app to mount */\n app: FunctionApp;\n}\n\n/** What a directory of function files produced: what mounted, and what did not. */\nexport interface LoadedFunctions {\n /** The functions that will be served. */\n functions: LoadedFunction[];\n /**\n * One entry per file the loader saw and did **not** mount, each already\n * phrased as `<name> (<reason>)`.\n */\n problems: string[];\n}\n\n/** A record of functions as the list {@link createFunctionRoutes} mounts, in record order. */\nexport function functionList(functions: FunctionsRecord): LoadedFunction[] {\n return Object.entries(functions).map(([name, app]) => ({ name, app }));\n}\n\n/**\n * Extensions the bundler compiles (or a developer might reasonably write) that\n * this loader cannot import. `rebase build` globs `functions/**\\/*.ts`, so the\n * build's idea of \"a function file\" is strictly wider than the runtime's — the\n * worst direction for a mismatch to go. Anything listed here is reported as a\n * problem instead of vanishing; non-code files (`.md`, `.json`, `.txt`) stay\n * silent, because a README next to your functions is not a mistake.\n */\nconst UNSUPPORTED_CODE_EXTENSIONS = [\".mts\", \".cts\", \".tsx\", \".jsx\", \".mjs\", \".cjs\"];\n\n/**\n * @deprecated Use `loadFunctions` instead, which returns the functions as a\n * record keyed by name. Will be removed in a future release.\n */\nexport async function loadFunctionsFromDirectory(\n directory: string,\n importModule: ModuleImporter = nativeDynamicImport\n): Promise<LoadedFunction[]> {\n warnDeprecated(\n \"server.loadFunctionsFromDirectory\",\n \"`loadFunctionsFromDirectory` is deprecated; use `loadFunctions(dir)`, which returns `{ [name]: app }`.\"\n );\n return functionList((await readFunctionsDirectory(directory, importModule)).functions);\n}\n\n/**\n * @deprecated Use `loadFunctions` instead, with `onSkip` to collect the files\n * it skipped. Will be removed in a future release.\n */\nexport async function loadFunctionsWithDiagnostics(\n directory: string,\n importModule: ModuleImporter = nativeDynamicImport\n): Promise<LoadedFunctions> {\n warnDeprecated(\n \"server.loadFunctionsWithDiagnostics\",\n \"`loadFunctionsWithDiagnostics` is deprecated; use `loadFunctions(dir, { onSkip })`, which returns `{ [name]: app }`.\"\n );\n const { functions, problems } = await readFunctionsDirectory(directory, importModule);\n return { functions: functionList(functions), problems };\n}\n\n/**\n * Read a directory of function files: what loaded, keyed by name, and one\n * `<file> (<reason>)` per file that did not.\n *\n * Never throws: a single malformed function must not crash server boot. The\n * backend mounts the router regardless and surfaces the count of skipped files\n * on the listing endpoint.\n */\nexport async function readFunctionsDirectory(\n directory: string,\n importModule: ModuleImporter = nativeDynamicImport\n): Promise<{ functions: FunctionsRecord; problems: string[] }> {\n const functions: FunctionsRecord = {};\n // Aggregate problem files so a broken function surfaces as one loud\n // summary line, not just a warning buried per-file. We still don't throw:\n // a single malformed function must not crash server boot.\n const problems: string[] = [];\n\n if (!fs.existsSync(directory)) {\n return { functions, problems };\n }\n\n // `withFileTypes` so a directory entry is a *reported* skip rather than a\n // filter miss. `readdirSync(dir)` returned bare names, and a subdirectory\n // simply failed the `.ts`/`.js` test — so `functions/admin/users.ts` was\n // compiled by `rebase build`, shipped in the bundle, and then dropped at\n // boot without a single log line.\n const entries = fs.readdirSync(directory, { withFileTypes: true });\n for (const entry of entries) {\n const file = entry.name;\n\n if (entry.isDirectory()) {\n // Dot-directories are tooling (`.git`, `.turbo`), not intent.\n if (file.startsWith(\".\") || file === \"node_modules\") continue;\n logger.warn(\n `[functions] ${file}/: subdirectory ignored. Functions are loaded from the top level of ` +\n `${directory} only, so nothing under ${file}/ is served. Move the file up (or flatten the ` +\n \"name: `admin/users.ts` → `admin-users.ts`).\"\n );\n problems.push(`${file}/ (subdirectory — functions are not loaded recursively)`);\n continue;\n }\n\n const extension = path.extname(file);\n if (\n !file.startsWith(\".\") &&\n !file.includes(\".test.\") &&\n UNSUPPORTED_CODE_EXTENSIONS.includes(extension)\n ) {\n logger.warn(\n `[functions] ${file}: ${extension} files are not loaded. Rename it to .ts (or .js) to serve it.`\n );\n problems.push(`${file} (unsupported extension ${extension})`);\n continue;\n }\n\n if (\n (file.endsWith(\".ts\") || file.endsWith(\".js\")) &&\n // Dotfiles: notably macOS bsdtar AppleDouble sidecars (`._foo.ts`),\n // binary blobs that cannot be imported.\n !file.startsWith(\".\") &&\n !file.includes(\".test.\") &&\n !file.endsWith(\".d.ts\") &&\n file !== \"index.ts\" &&\n file !== \"index.js\"\n ) {\n const filePath = path.join(directory, file);\n try {\n const fileUrl = pathToFileURL(filePath).href;\n\n const mod = await importModule(fileUrl);\n\n const exported = mod.default;\n\n if (!exported) {\n logger.warn(`[functions] ${file}: no default export. Skipping.`);\n problems.push(`${file} (no default export)`);\n continue;\n }\n\n // Accept a Hono instance — use duck-typing to handle different\n // Hono versions which may not share the same prototype.\n const app = isHonoLike(exported)\n ? exported as FunctionApp\n // Also accept a factory function that returns a Hono instance\n : typeof exported === \"function\" ? exported() : undefined;\n if (isHonoLike(app)) {\n const name = path.basename(file, path.extname(file));\n // `send.ts` and `send.js` side by side name one URL. A\n // record holds one app per name, so the second is reported\n // rather than silently replacing the first.\n if (Object.hasOwn(functions, name)) {\n logger.warn(`[functions] ${file}: another file is already served as '${name}'. Skipping.`);\n problems.push(`${file} (another file is already served as '${name}')`);\n continue;\n }\n functions[name] = app as FunctionApp;\n logger.debug(`⚡ Loaded function route: ${name}`);\n continue;\n }\n\n // Provide actionable diagnostics\n const exportType = typeof exported;\n const keys = exported && typeof exported === \"object\"\n ? Object.getOwnPropertyNames(Object.getPrototypeOf(exported)).slice(0, 10).join(\", \")\n : \"N/A\";\n logger.warn(\n `[functions] ${file}: default export is not a Hono app or factory. Skipping.\\n` +\n ` export type: ${exportType}${exported?.constructor?.name ? ` (${exported.constructor.name})` : \"\"}\\n` +\n ` prototype methods: ${keys}\\n` +\n \" Hint: ensure the function exports a Hono app created with the same hono version as the server.\\n\" +\n \" Author with `defineFunction(...)` from @rebasepro/server for a typed, checked contract.\\n\" +\n \" The loader checks for .fetch() and .routes — any Hono-compatible app will work.\"\n );\n problems.push(`${file} (not a Hono app or factory)`);\n } catch (err: unknown) {\n const message =\n err instanceof Error ? err.message : String(err);\n logger.error(`[functions] Failed to load ${file}: ${message}`);\n problems.push(describeLoadFailure(file, err));\n }\n }\n }\n\n if (problems.length > 0) {\n // Advice keyed to what actually went wrong. The old line said\n // \"author them with `defineFunction(...)`\" for every failure — which is\n // the fix for a wrong export shape and no help at all for the commonest\n // one: a file that threw while being imported, usually a module-scope\n // `process.env.X` read that came back undefined. Told to reach for\n // `defineFunction`, an author rewrites a file whose export was already\n // correct and gets the same failure.\n const advice: string[] = [];\n if (problems.some(p => p.includes(\"(syntax error\"))) {\n advice.push(SYNTAX_ERROR_ADVICE);\n }\n if (problems.some(p => p.includes(\"(threw:\"))) {\n advice.push(\n \" A file that threw ran at import time. Read configuration *inside* a handler \" +\n \"(`requireEnv(c, \\\"STRIPE_KEY\\\")`, or a lazily-built client) rather than at module \" +\n \"scope, where one undefined variable takes the whole file down before any route exists.\"\n );\n }\n if (problems.some(p => p.includes(\"(no default export)\"))) {\n advice.push(\n \" A file with no default export exports nothing the loader can mount. \" +\n \"`export default defineFunction((app) => { … })`.\"\n );\n }\n if (problems.some(p => p.includes(\"(not a Hono app or factory)\"))) {\n advice.push(\n \" A default export the loader did not recognise is usually two copies of hono. \" +\n \"Author with `defineFunction(...)` from @rebasepro/server/functions, which uses the \" +\n \"server's own copy.\"\n );\n }\n logger.warn(\n `[functions] ${problems.length} function file(s) were skipped and will NOT be served:\\n` +\n problems.map((p) => ` - ${p}`).join(\"\\n\") + \"\\n\" +\n advice.join(\"\\n\")\n );\n }\n\n return { functions, problems };\n}\n\n/**\n * Duck-type check for Hono apps.\n * We avoid `instanceof Hono` because different Hono versions\n * installed in the user's project vs. our dependencies will\n * not share the same prototype, causing false negatives.\n */\nfunction isHonoLike(obj: unknown): boolean {\n if (!obj || typeof obj !== \"object\") return false;\n // Hono instances always have .fetch() and .routes\n const record = obj as Record<string, unknown>;\n return (\n typeof record.fetch === \"function\" &&\n Array.isArray(record.routes)\n );\n}\n"],"mappings":"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"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"history-recorder-RZ1Ow4YY.js","names":[],"sources":["../src/metrics/history-recorder.ts"],"sourcesContent":["/**\n * Wiring the history store to a driver and a timer.\n *\n * Split from `history-store.ts` so the store stays pure — every rule in it is\n * testable with a fake executor, and nothing there knows what a DataDriver is.\n * This half is the part that cannot be unit-tested meaningfully: it opens a\n * connection and starts an interval.\n */\nimport { monitorEventLoopDelay } from \"node:perf_hooks\";\nimport { logger } from \"../utils/logger.js\";\nimport { isSQLAdmin, type DataDriver } from \"@rebasepro/types\";\nimport {\n ensureMetricsHistory,\n recordSamples,\n readSeries,\n sampleSelf,\n SAMPLE_INTERVAL_MS,\n type Exec,\n type MetricSeries,\n type SeriesPoint\n} from \"./history-store.js\";\n\nexport interface MetricsHistory {\n /** Create the table and sweep what has aged out. */\n ensure(): Promise<void>;\n /** Begin sampling this process. Returns a stop. */\n start(): () => void;\n /** Read one series, for the route and for anything else that asks. */\n read(series: MetricSeries, sinceMinutes: number): Promise<SeriesPoint[]>;\n}\n\n/** The driver's SQL executor, when its admin can run SQL at all. */\nfunction sqlExecutorOf(driver: DataDriver): Exec | undefined {\n const admin = driver.admin;\n if (!isSQLAdmin(admin)) return undefined;\n return (sql, params) => admin.executeSql(sql, params ? { params } : undefined);\n}\n\n/**\n * History for this deployment, or `undefined` when it cannot have any.\n *\n * Undefined rather than a no-op: the route turns it into a named 501, because a\n * chart that renders empty is indistinguishable from a quiet period and this is\n * exactly the class of silence the panel it feeds exists to remove.\n */\nexport function createMetricsHistory(driver: DataDriver): MetricsHistory | undefined {\n const exec = sqlExecutorOf(driver);\n if (!exec) {\n logger.debug(\"[metrics] driver has no SQL admin — no metrics history will be kept.\");\n return undefined;\n }\n\n return {\n ensure: () => ensureMetricsHistory(exec),\n read: (series, sinceMinutes) => readSeries(exec, series, sinceMinutes),\n start(): () => void {\n // Which process this is. A tenant's replicas share one database and\n // each records its own numbers, so a row needs to say whose they\n // are — without it they overwrite each other and a scaled-out app\n // charts one arbitrary pod.\n //\n // HOSTNAME is the pod name on Kubernetes and the container id under\n // Docker; the pid fallback keeps two local processes distinct.\n const instance = process.env.HOSTNAME?.trim() || `pid-${process.pid}`;\n let cursor: { cpu: NodeJS.CpuUsage; at: number } | null = null;\n let stopped = false;\n\n // Event-loop delay: how long a callback waited past its schedule.\n // The one number here that says whether this process is *healthy*\n // rather than how much it is consuming — a pod can sit at 20% CPU\n // and still be unable to answer, and nothing else recorded would\n // show it.\n //\n // `monitorEventLoopDelay` samples in libuv at a fixed resolution and\n // costs effectively nothing; `.enable()` is required, and the\n // histogram is reset each tick so every sample describes its own\n // minute rather than the process's whole life.\n interface LoopHistogram { mean: number; enable(): void; disable(): void; reset(): void }\n let loop: LoopHistogram | null = null;\n try {\n loop = monitorEventLoopDelay({ resolution: 20 }) as LoopHistogram;\n loop.enable();\n } catch {\n // A runtime without it still records the other two.\n loop = null;\n }\n\n const tick = async () => {\n if (stopped) return;\n // Nanoseconds from the histogram, milliseconds on the wire.\n const delayMs = loop ? loop.mean / 1e6 : undefined;\n loop?.reset();\n const { samples, cursor: next } = sampleSelf(cursor, Date.now(), delayMs);\n cursor = next;\n try {\n await recordSamples(exec, samples, instance);\n } catch (err) {\n // Never fatal, and never noisy: a sampler that crash-loops a\n // pod over a chart would be a far worse trade than a gap in\n // one. The gap is visible in the data; a restart loop is not.\n logger.debug(\"[metrics] could not record a sample\", { err });\n }\n };\n\n // The first tick establishes the CPU cursor and publishes memory;\n // the rate needs a second reading, which is why nothing claims a CPU\n // figure until one interval has passed.\n void tick();\n const timer = setInterval(() => void tick(), SAMPLE_INTERVAL_MS);\n // Not the reason this process should stay alive.\n timer.unref?.();\n\n // The histogram samples in libuv until it is disabled, so the stop\n // ends both — a backend shut down in-process leaves nothing running.\n return () => {\n stopped = true;\n clearInterval(timer);\n loop?.disable();\n };\n }\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;AAgCA,SAAS,cAAc,QAAsC;CACzD,MAAM,QAAQ,OAAO;CACrB,IAAI,CAAC,WAAW,KAAK,GAAG,OAAO,KAAA;CAC/B,QAAQ,KAAK,WAAW,MAAM,WAAW,KAAK,SAAS,EAAE,OAAO,IAAI,KAAA,CAAS;AACjF;;;;;;;;AASA,SAAgB,qBAAqB,QAAgD;CACjF,MAAM,OAAO,cAAc,MAAM;CACjC,IAAI,CAAC,MAAM;EACP,OAAO,MAAM,sEAAsE;EACnF;CACJ;CAEA,OAAO;EACH,cAAc,qBAAqB,IAAI;EACvC,OAAO,QAAQ,iBAAiB,WAAW,MAAM,QAAQ,YAAY;EACrE,QAAoB;GAQhB,MAAM,WAAW,QAAQ,IAAI,UAAU,KAAK,KAAK,OAAO,QAAQ;GAChE,IAAI,SAAsD;GAC1D,IAAI,UAAU;GAad,IAAI,OAA6B;GACjC,IAAI;IACA,OAAO,sBAAsB,EAAE,YAAY,GAAG,CAAC;IAC/C,KAAK,OAAO;GAChB,QAAQ;IAEJ,OAAO;GACX;GAEA,MAAM,OAAO,YAAY;IACrB,IAAI,SAAS;IAEb,MAAM,UAAU,OAAO,KAAK,OAAO,MAAM,KAAA;IACzC,MAAM,MAAM;IACZ,MAAM,EAAE,SAAS,QAAQ,SAAS,WAAW,QAAQ,KAAK,IAAI,GAAG,OAAO;IACxE,SAAS;IACT,IAAI;KACA,MAAM,cAAc,MAAM,SAAS,QAAQ;IAC/C,SAAS,KAAK;KAIV,OAAO,MAAM,uCAAuC,EAAE,IAAI,CAAC;IAC/D;GACJ;GAKA,KAAU;GACV,MAAM,QAAQ,kBAAkB,KAAK,KAAK,GAAG,kBAAkB;GAE/D,MAAM,QAAQ;GAId,aAAa;IACT,UAAU;IACV,cAAc,KAAK;IACnB,MAAM,QAAQ;GAClB;EACJ;CACJ;AACJ"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"history-store-DPofGVDp.js","names":[],"sources":["../src/metrics/history-store.ts"],"sourcesContent":["/**\n * A little history for the metrics this process already keeps.\n *\n * ## Why this is in the framework and not in the cloud console\n *\n * The console wants to draw \"CPU over the last hour\". The obvious way to get it\n * on GKE is Cloud Monitoring, which already collects exactly this — and which\n * would make the panel unportable the day the platform moves, for a feature\n * every self-hoster also wants. So the history lives where the runtime lives:\n * the process samples ITSELF, into its OWN database, and anything that can read\n * the database can draw the chart. No cluster, no metrics-server, no vendor.\n *\n * That is the same rule the binder follows — the cloud is a better\n * implementation behind the same interface, never a different one.\n *\n * ## Why it stays cheap\n *\n * One row per series per instance per minute. Three series across two replicas\n * is 8,640 rows a day, and the sweep below bounds the window, so the table settles\n * at a size measured in megabytes. It is deliberately NOT a general time-series\n * store: no labels, no cardinality to explode, no per-request rows.\n *\n * A minute is the resolution because that is what the question needs — \"was it\n * slow at 15:40\", \"did my deploy cause that\" — and because a finer grain buys\n * nothing a reader can see on a chart of an hour.\n */\n/**\n * The positional-parameter shape every store in this package settles on.\n *\n * The bootstrapper's own `SqlExec` takes an options object; each store wraps it\n * once and reads better for it. Same two shapes, same reason, as `job-store`.\n */\nimport { revokeInternalTableSql, sqlRows } from \"@rebasepro/common\";\n\n\nexport type Exec = (sql: string, params?: unknown[]) => Promise<unknown>;\n\n/** Where the samples live. Framework-owned, like `rebase.jobs`. */\nexport const METRICS_HISTORY_TABLE = \"rebase.metric_samples\";\n\n/**\n * How long a sample is kept.\n *\n * Two weeks answers \"is this worse than last week\" and stops well short of\n * being an archive. Anything that needs to outlive it — billing, capacity\n * planning — is a rollup somebody else owns, not a longer retention here.\n *\n * Read it as \"14 days, or since this process started, whichever is longer\": the\n * sweep runs at boot and nowhere else, so a pod up for 90 days holds 90 days.\n * That is a deliberate trade rather than an oversight — a cron for it would be\n * machinery for a table that stays trivial either way, and reads are bounded by\n * the index regardless — but the table does not \"reach a steady size and stay\n * there\" on a long-lived pod, which an earlier version of this comment claimed.\n */\nexport const RETENTION_DAYS = 14;\n\n/** How often the process samples itself. Matched to the resolution it stores. */\nexport const SAMPLE_INTERVAL_MS = 60_000;\n\n/**\n * The series this records. A closed set on purpose — see the cardinality note.\n *\n * A list rather than only a union, because the route validates against it: an\n * unknown `?series=` must be a named 400 rather than an empty chart, which\n * reads exactly like a quiet period.\n *\n * **It lists what `sampleSelf` actually writes, and nothing else.** It used to\n * also name `requests_total`, `errors_total` and `event_loop_delay_ms`, none of\n * which anything ever recorded — so those three were *valid* parameters that\n * returned `points: []`, which is precisely the empty chart the 400 two\n * paragraphs up exists to prevent. A declared-but-unwritten series is worse\n * than an absent one: the 400 tells you the name is wrong, and the empty array\n * tells you the app was quiet.\n *\n * `event_loop_delay_ms` kept its place by gaining a sampler, because it is the\n * one signal here that says whether the process is *healthy* rather than how\n * much it is using, and it has no counter semantics to get wrong.\n *\n * `requests_total` and `errors_total` were dropped rather than wired up. The\n * registry does hold them, but they reset on restart, and a resetting counter\n * summed across replicas is not monotonic — a rolling deploy would draw a\n * cliff that looks like traffic collapsing. That needs a deliberate decision\n * about deltas and resets, not a line added at 2am. Adding either back means\n * adding its sampler in the same commit.\n */\nexport const METRIC_SERIES = [\n \"cpu_millicores\",\n \"memory_bytes\",\n \"event_loop_delay_ms\"\n] as const;\n\nexport type MetricSeries = (typeof METRIC_SERIES)[number];\n\nexport interface MetricSample {\n at: Date;\n series: MetricSeries;\n value: number;\n}\n\n/**\n * Create the table and sweep what has aged out.\n *\n * Called at boot, beside the job and cron stores, and for the same reason they\n * do it there: the only moment the schema is guaranteed to be reachable and\n * nobody is mid-request.\n */\nexport async function ensureMetricsHistory(exec: Exec): Promise<void> {\n await exec(`\n CREATE TABLE IF NOT EXISTS ${METRICS_HISTORY_TABLE} (\n at timestamptz NOT NULL,\n series text NOT NULL,\n instance text NOT NULL,\n value double precision NOT NULL,\n PRIMARY KEY (series, instance, at)\n )\n `, []);\n\n // (series, at DESC) rather than the primary key's order: every read is\n // \"this series across all instances, bounded by time\", and the PK leads with\n // instance, which is the wrong first column for that scan.\n await exec(`\n CREATE INDEX IF NOT EXISTS metric_samples_series_recent\n ON ${METRICS_HISTORY_TABLE} (series, at DESC)\n `, []);\n\n // Framework-internal, so the end-user role must not be able to address it.\n //\n // The driver grants `rebase_user` full DML across the schemas a project\n // uses, plus `ALTER DEFAULT PRIVILEGES` so tables created later inherit it —\n // and this one is created later, at boot. Without the revoke, an\n // authenticated request could read and write another deployment's process\n // metrics, and `pnpm rls:check` correctly called that `[critical]\n // rls-disabled` the first time CI saw the table.\n //\n // REVOKE rather than `ENABLE ROW LEVEL SECURITY`, matching every other table\n // in `REBASE_INTERNAL_TABLES`: RLS with no policy denies the same rows but\n // is the weaker statement, because the grant survives and a later policy\n // reopens it. There is no row here any end user should reach, so \"this role\n // has no privilege at all\" is the honest encoding. The owner connection the\n // recorder runs on is unaffected.\n await exec(revokeInternalTableSql(\"rebase\", \"metric_samples\"), []);\n\n // Swept here rather than by a cron, so a deployment that runs no scheduler\n // still stays bounded. A DELETE is the right tool at this size — a few\n // thousand rows a day — and partitioning would be machinery for a table\n // that never gets big.\n await exec(\n `DELETE FROM ${METRICS_HISTORY_TABLE} WHERE at < now() - make_interval(days => $1)`,\n [RETENTION_DAYS]\n );\n}\n\n/**\n * What this process is using right now.\n *\n * `process.cpuUsage()` is cumulative, so a rate needs two readings and the gap\n * between them — which is why the previous one is threaded through rather than\n * held in a module global: a module global is shared by every test in a file\n * and makes the first assertion depend on whatever ran before it.\n */\nexport function sampleSelf(\n previous: { cpu: NodeJS.CpuUsage; at: number } | null,\n now = Date.now(),\n /**\n * Mean event-loop delay over the last window, in milliseconds, or undefined\n * where it cannot be measured. Passed in rather than read here so this\n * function stays pure: the histogram is a stateful handle the recorder owns.\n */\n eventLoopDelayMs?: number\n): { samples: Omit<MetricSample, \"at\">[]; cursor: { cpu: NodeJS.CpuUsage; at: number } } {\n const cpu = process.cpuUsage();\n const memory = process.memoryUsage();\n const samples: Omit<MetricSample, \"at\">[] = [\n { series: \"memory_bytes\", value: memory.rss }\n ];\n\n // Only when it was actually measured. Zero is a real and common reading for\n // an idle process, so a `?? 0` here would be indistinguishable from a\n // healthy one — the same substitution this module rejects everywhere else.\n if (typeof eventLoopDelayMs === \"number\" && Number.isFinite(eventLoopDelayMs)) {\n samples.push({ series: \"event_loop_delay_ms\", value: eventLoopDelayMs });\n }\n\n if (previous) {\n const elapsedMs = now - previous.at;\n if (elapsedMs > 0) {\n // Microseconds of CPU over milliseconds of wall clock, as\n // millicores: 1000m is one core saturated for the whole window.\n const usedMicros = (cpu.user - previous.cpu.user) + (cpu.system - previous.cpu.system);\n samples.push({ series: \"cpu_millicores\", value: (usedMicros / 1000 / elapsedMs) * 1000 });\n }\n }\n\n return { samples, cursor: { cpu, at: now } };\n}\n\n/**\n * Write one tick's samples, for one instance.\n *\n * ## Why `instance` is part of the key\n *\n * A tenant's replicas share one database, and each records its OWN process. Key\n * a row by `(series, minute)` alone and the pods overwrite each other every\n * tick: one pod at 5m and another at 500m leave whichever wrote last, so a\n * scaled-out tenant charts one arbitrary replica and calls it the app. Adding\n * the instance makes each pod its own row, and lets the read decide whether the\n * question is \"the whole deployment\" or \"which pod is hot\".\n *\n * Cardinality stays bounded: rows are series × replicas × minutes, and replicas\n * are capped by the autoscaling ceiling. Six pods is ~43k rows a day and a\n * fortnight of them is well under a million.\n */\nexport async function recordSamples(\n exec: Exec,\n samples: Omit<MetricSample, \"at\">[],\n instance: string,\n at: Date = new Date()\n): Promise<void> {\n if (samples.length === 0) return;\n // Truncated to the minute, so a pod restarting mid-minute overwrites its own\n // earlier row rather than adding a second one for the same instant.\n const bucket = new Date(Math.floor(at.getTime() / 60_000) * 60_000);\n for (const s of samples) {\n if (!Number.isFinite(s.value)) continue;\n await exec(\n `INSERT INTO ${METRICS_HISTORY_TABLE} (at, series, instance, value)\n VALUES ($1, $2, $3, $4)\n ON CONFLICT (series, instance, at) DO UPDATE SET value = EXCLUDED.value`,\n [bucket, s.series, instance, s.value]\n );\n }\n}\n\n/**\n * How a series combines across the replicas that reported it.\n *\n * Not one answer for everything, because the right one differs by what the\n * number means. CPU and memory are consumption: the deployment's figure is the\n * sum, and a mean would make scaling out look like it reduced usage. A queue\n * depth or an event-loop delay is a condition each process is independently in,\n * and summing those produces a number no single pod ever experienced.\n */\nconst COMBINE: Record<MetricSeries, \"sum\" | \"avg\"> = {\n cpu_millicores: \"sum\",\n memory_bytes: \"sum\",\n event_loop_delay_ms: \"avg\"\n};\n\nexport interface SeriesPoint {\n at: string;\n /** The deployment's figure, combined per `COMBINE`. */\n value: number;\n /** How many instances reported in this bucket. */\n instances: number;\n}\n\n/**\n * Read one series over a window, oldest first — the order a chart draws in.\n *\n * Combined across instances rather than returned per-pod. A chart of \"this\n * app's CPU\" is the question people ask; \"which pod is hot\" is answered by the\n * live panel, which already lists instances individually and does not need\n * history to do it.\n *\n * `instances` rides along because a sum whose contributor count changed is not\n * comparable with itself: CPU doubling because the app got busy and CPU\n * doubling because it scaled from one replica to two are different events, and\n * a line chart alone cannot tell them apart.\n */\nexport async function readSeries(\n exec: Exec,\n series: MetricSeries,\n sinceMinutes: number\n): Promise<SeriesPoint[]> {\n const combine = COMBINE[series] === \"avg\" ? \"avg\" : \"sum\";\n // The current minute is EXCLUDED, and that is not tidiness.\n //\n // Each replica samples on its own phase — `setInterval` from whenever that\n // pod booted — so at 10:45:20 the bucket for 10:45 holds rows from whichever\n // pods have ticked so far, typically one of three. The chart takes the last\n // point as its headline figure, so a three-replica app displayed one\n // replica's CPU as the deployment's, the line ended in a cliff, and the\n // instance step dropped underneath it — which the chart's own caption\n // explains to the reader as a scale-down that never happened.\n //\n // Every live pod has written bucket M-1 before the clock enters M, so the\n // newest bucket returned is complete and its `instances` is the true\n // contributor count. `date_trunc` rather than arithmetic because it matches\n // the recorder's `floor(t / 60_000) * 60_000` exactly, and is\n // timezone-independent on a timestamptz.\n const result = await exec(\n `SELECT at, ${combine}(value) AS value, count(*) AS instances\n FROM ${METRICS_HISTORY_TABLE}\n WHERE series = $1\n AND at >= now() - make_interval(mins => $2)\n AND at < date_trunc('minute', now())\n GROUP BY at\n ORDER BY at ASC`,\n [series, sinceMinutes]\n );\n\n return sqlRows<RawPoint>(result).map(r => ({\n at: r.at instanceof Date ? r.at.toISOString() : String(r.at),\n value: Number(r.value),\n instances: Number(r.instances ?? 1)\n }));\n}\n\ninterface RawPoint { at: Date | string; value: number; instances?: number | string }\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAsCA,IAAa,wBAAwB;;AAmBrC,IAAa,qBAAqB;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4BlC,IAAa,gBAAgB;CACzB;CACA;CACA;AACJ;;;;;;;;AAiBA,eAAsB,qBAAqB,MAA2B;CAClE,MAAM,KAAK;qCACsB,sBAAsB;;;;;;;OAOpD,CAAC,CAAC;CAKL,MAAM,KAAK;;iBAEE,sBAAsB;OAChC,CAAC,CAAC;CAiBL,MAAM,KAAK,uBAAuB,UAAU,gBAAgB,GAAG,CAAC,CAAC;CAMjE,MAAM,KACF,eAAe,sBAAsB,gDACrC,CAAA,EAAe,CACnB;AACJ;;;;;;;;;AAUA,SAAgB,WACZ,UACA,MAAM,KAAK,IAAI,GAMf,kBACqF;CACrF,MAAM,MAAM,QAAQ,SAAS;CAE7B,MAAM,UAAsC,CACxC;EAAE,QAAQ;EAAgB,OAFf,QAAQ,YAEc,CAAA,CAAO;CAAI,CAChD;CAKA,IAAI,OAAO,qBAAqB,YAAY,OAAO,SAAS,gBAAgB,GACxE,QAAQ,KAAK;EAAE,QAAQ;EAAuB,OAAO;CAAiB,CAAC;CAG3E,IAAI,UAAU;EACV,MAAM,YAAY,MAAM,SAAS;EACjC,IAAI,YAAY,GAAG;GAGf,MAAM,aAAc,IAAI,OAAO,SAAS,IAAI,QAAS,IAAI,SAAS,SAAS,IAAI;GAC/E,QAAQ,KAAK;IAAE,QAAQ;IAAkB,OAAQ,aAAa,MAAO,YAAa;GAAK,CAAC;EAC5F;CACJ;CAEA,OAAO;EAAE;EAAS,QAAQ;GAAE;GAAK,IAAI;EAAI;CAAE;AAC/C;;;;;;;;;;;;;;;;;AAkBA,eAAsB,cAClB,MACA,SACA,UACA,qBAAW,IAAI,KAAK,GACP;CACb,IAAI,QAAQ,WAAW,GAAG;CAG1B,MAAM,yBAAS,IAAI,KAAK,KAAK,MAAM,GAAG,QAAQ,IAAI,GAAM,IAAI,GAAM;CAClE,KAAK,MAAM,KAAK,SAAS;EACrB,IAAI,CAAC,OAAO,SAAS,EAAE,KAAK,GAAG;EAC/B,MAAM,KACF,eAAe,sBAAsB;;uFAGrC;GAAC;GAAQ,EAAE;GAAQ;GAAU,EAAE;EAAK,CACxC;CACJ;AACJ;;;;;;;;;;AAWA,IAAM,UAA+C;CACjD,gBAAgB;CAChB,cAAc;CACd,qBAAqB;AACzB;;;;;;;;;;;;;;AAuBA,eAAsB,WAClB,MACA,QACA,cACsB;CA4BtB,OAAO,QAAkB,MAXJ,KACjB,cAjBY,QAAQ,YAAY,QAAQ,QAAQ,MAiB1B;kBACZ,sBAAsB;;;;;4BAMhC,CAAC,QAAQ,YAAY,CACzB,CAE+B,CAAC,CAAC,KAAI,OAAM;EACvC,IAAI,EAAE,cAAc,OAAO,EAAE,GAAG,YAAY,IAAI,OAAO,EAAE,EAAE;EAC3D,OAAO,OAAO,EAAE,KAAK;EACrB,WAAW,OAAO,EAAE,aAAa,CAAC;CACtC,EAAE;AACN"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"jobs-8G-_agld.js","names":[],"sources":["../src/db/ambient-transaction.ts","../src/jobs/job-queue.ts","../src/jobs/job-store.ts","../src/jobs/index.ts"],"sourcesContent":["/**\n * The write transaction the current code is running inside, when there is one.\n *\n * A collection callback runs inside the transaction that carries its write.\n * Work it hands off — a job, a queue message, a topic event, a webhook\n * delivery — has to share that write's fate: queued when it commits, never\n * queued when it rolls back, and invisible to a worker until the commit. The\n * code that queues it lives in this package and knows no driver; the\n * transaction belongs to the driver. So the driver publishes the transaction it\n * opened for a write, and the queueing code asks for it here.\n *\n * Until this existed the job store wrote through the default driver's own\n * connection, in autocommit, whatever called it. A job enqueued from an\n * `afterSave` that then threw stayed queued for a row that was never written,\n * and a worker could claim it before the row it was about committed.\n *\n * The resolver lives on `globalThis` under a registered symbol, like the\n * `rebase` singleton: a managed runtime can load two copies of this package,\n * and the copy a driver registers with must be the one the job store reads.\n * No Node imports — the store is reachable from code that has to stay portable.\n *\n * **The symbol's key is a contract between packages, and frozen.** The\n * Postgres driver writes the slot itself rather than importing a setter from\n * here: the managed runtime supplies this package from the image while a\n * bundle brings its own driver, and a driver importing a function an older\n * image does not export would fail to link and take the boot down. Written\n * through the slot, any pairing of versions degrades to the old behaviour —\n * jobs committing on their own — instead.\n */\nexport interface AmbientTransaction {\n /**\n * Run one statement on the transaction, `$1…` placeholders as\n * `executeSql` takes them. It runs as the transaction's current role — on\n * Postgres the request's restricted one — so a statement that needs more\n * goes through a function granted to that role.\n */\n exec(sqlText: string, params?: unknown[]): Promise<Record<string, unknown>[]>;\n /** Run `fn` after the transaction commits. Never runs if it rolls back. */\n afterCommit(fn: () => void): void;\n}\n\n/** Frozen — see above. `server-postgres/src/services/write-transaction-scope.ts` writes it. */\nconst RESOLVER_SLOT = Symbol.for(\"rebase.server.ambientTransactionResolver\");\n\ntype ResolverSlot = { [RESOLVER_SLOT]?: () => AmbientTransaction | undefined };\n\n/**\n * Register how to find the write transaction the caller is inside; pass\n * `undefined` to unregister. For tests and for drivers in this repository's\n * lockstep — a driver shipped separately writes the slot, as above.\n */\nexport function setAmbientTransactionResolver(resolve: (() => AmbientTransaction | undefined) | undefined): void {\n (globalThis as ResolverSlot)[RESOLVER_SLOT] = resolve;\n}\n\n/** The write transaction the caller is inside, or `undefined` outside one. */\nexport function currentAmbientTransaction(): AmbientTransaction | undefined {\n return (globalThis as ResolverSlot)[RESOLVER_SLOT]?.();\n}\n","import { randomUUID } from \"crypto\";\nimport { logger } from \"../utils/logger.js\";\nimport { currentAmbientTransaction } from \"../db/ambient-transaction.js\";\nimport type { JobClaim, JobStore } from \"./job-store.js\";\nimport type { EnqueueOptions, JobHandler, JobQueueClient, JobQueueOptions, JobRecord } from \"./types.js\";\n\n/**\n * The worker: claim, run, record, repeat.\n *\n * Everything difficult about running jobs concurrently is in `job-store.ts`,\n * where one `UPDATE … FOR UPDATE SKIP LOCKED` does the arbitration. What is\n * left here is a loop and the decisions around a handler that throws.\n */\n\nconst DEFAULT_CONCURRENCY = 5;\nconst DEFAULT_POLL_INTERVAL_MS = 2_000;\nconst DEFAULT_VISIBILITY_TIMEOUT_MS = 5 * 60_000;\nconst DEFAULT_MAX_ATTEMPTS = 3;\n\n/**\n * How long one attempt may run when `jobs.timeoutMs` is left out. Without a\n * bound, a handler awaiting a socket that never answers held its worker slot\n * and its claim for the life of the process, with `/health` green. Fifteen\n * minutes is far past any job that is merely slow, and a job meant to run\n * longer says so with its own `timeoutMs` — or `0`, for no limit.\n */\nexport const DEFAULT_JOB_TIMEOUT_MS = 15 * 60_000;\n\n/**\n * The longest an idle worker goes without looking for work.\n *\n * An idle worker used to claim every 2 seconds, forever: about 45,000 round\n * trips a day per pod, each with a pool checkout, and a database that could\n * never suspend. Now it is woken instead — by `NOTIFY` when another process\n * enqueues, by its own enqueues, retries and delayed jobs — and this is the\n * safety poll behind that. Where the database cannot notify it, it backs off\n * from `pollIntervalMs` to this, doubling after each look that found nothing.\n */\nconst IDLE_POLL_MAX_MS = 30_000;\n\n/** 1s, 5s, 25s, 125s … capped at an hour. */\nexport function defaultBackoff(attempt: number): number {\n return Math.min(1_000 * Math.pow(5, Math.max(0, attempt - 1)), 60 * 60_000);\n}\n\n/**\n * How often the reaper runs, relative to the visibility timeout.\n *\n * A quarter of it, so a stranded job waits at most 1.25× the timeout rather\n * than 2× — and so the sweep is not itself a per-poll query against a table\n * whose interesting rows are, almost always, none.\n */\nconst REAP_INTERVAL_FACTOR = 0.25;\n\n/**\n * How often a worker deletes finished jobs past retention. Hourly keeps the\n * table near its retention size on a pod that is never redeployed; each sweep\n * reads only the finished rows, through `idx_jobs_finished`.\n */\nconst RETENTION_SWEEP_INTERVAL_MS = 60 * 60_000;\n\n/**\n * Thrown by a handler for a failure no retry can change — a destination that\n * is refused, a receiver that redirects. The job is dead-lettered on this\n * attempt, with the message as its `last_error`, instead of spending its\n * remaining attempts proving the same thing. Returning normally is not the\n * alternative: that records the job as succeeded.\n */\nexport class PermanentJobError extends Error {\n constructor(message: string, options?: { cause?: unknown }) {\n super(message, options);\n this.name = \"PermanentJobError\";\n }\n}\n\nexport interface JobQueue extends JobQueueClient {\n start(): void;\n /**\n * Stop claiming, and wait for the jobs in flight — for at most `timeoutMs`\n * when given. A job still running when the budget runs out keeps its claim\n * and is recovered by the visibility timeout; waiting on it without a bound\n * would let one handler that never settles hold the whole shutdown.\n */\n stop(timeoutMs?: number): Promise<void>;\n /**\n * Claim what fits in the free slots, run it, and resolve with how many jobs\n * ran. For tests and for `/jobs/drain`; not meant to run beside `start()`.\n */\n runOnce(): Promise<number>;\n /** Registered after construction — how `tasks` from config and internal producers meet. */\n register<P = unknown>(task: string, handler: JobHandler<P>): void;\n isRunning(): boolean;\n}\n\nexport function createJobQueue(store: JobStore, options: JobQueueOptions = {}): JobQueue {\n const concurrency = options.concurrency ?? DEFAULT_CONCURRENCY;\n const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;\n const visibilityTimeoutMs = options.visibilityTimeoutMs ?? DEFAULT_VISIBILITY_TIMEOUT_MS;\n const defaultMaxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;\n const backoff = options.backoff ?? defaultBackoff;\n const configuredTimeoutMs = options.timeoutMs;\n if (configuredTimeoutMs !== undefined && !(Number.isFinite(configuredTimeoutMs) && configuredTimeoutMs >= 0)) {\n // NaN is what a mistyped env var parses to; it would fail every\n // attempt at once.\n throw new Error(`jobs.timeoutMs must be a number of milliseconds, or 0 for no limit (got ${String(configuredTimeoutMs)}).`);\n }\n /** `undefined` means no limit: only an explicit `0` asks for that. */\n const timeoutMs = configuredTimeoutMs === undefined\n ? DEFAULT_JOB_TIMEOUT_MS\n : configuredTimeoutMs === 0 ? undefined : configuredTimeoutMs;\n\n const handlers = new Map<string, JobHandler<never>>();\n for (const [task, handler] of Object.entries(options.tasks ?? {})) {\n handlers.set(task, handler);\n }\n\n // Identifies this process in `locked_by`: \"which pod had it when it\n // stopped\" is the first question anyone asks of a stuck job, and with the\n // attempt it is the claim an outcome is written under (see `JobClaim`).\n const workerId = `${process.pid}-${randomUUID().slice(0, 8)}`;\n\n let timer: NodeJS.Timeout | null = null;\n /** When {@link timer} fires (epoch ms), so a later wake never postpones an earlier one. */\n let timerDue = Infinity;\n /**\n * The wait after a look that found nothing. Doubles to {@link idleCeilingMs}\n * while the worker polls on its own; back to `pollIntervalMs` whenever\n * there is work or a wake. Fixed at the ceiling while notifications wake it.\n */\n const idleCeilingMs = Math.max(IDLE_POLL_MAX_MS, pollIntervalMs);\n let idleIntervalMs = pollIntervalMs;\n /** Notifications from the database wake this worker — see `JobStore.subscribe`. */\n let listening = false;\n let unsubscribe: (() => Promise<void>) | null = null;\n /** A wake arrived while a claim was in flight: claim again once it is done. */\n let rerun = false;\n let reapTimer: NodeJS.Timeout | null = null;\n let sweepTimer: NodeJS.Timeout | null = null;\n let sweeping = false;\n let running = false;\n /**\n * Every job this worker is running, tracked one by one rather than per\n * claimed batch: a batch would let its slowest job decide when the next\n * claim happens, so one handler that never settled would idle every other\n * slot — and hold `stop()` with it.\n */\n const active = new Set<Promise<void>>();\n /** The claim in progress, so `stop()` also waits for jobs it is about to hand out. */\n let claiming: Promise<unknown> | null = null;\n /** The last claim filled every free slot, so more work is probably waiting. */\n let backlog = false;\n let reaping = false;\n let draining: Promise<void> | null = null;\n\n /**\n * Renew `claim` while its handler runs, at a third of the visibility\n * timeout, so the reaper only ever takes a job from a worker that stopped\n * renewing — a dead one — and never from one that is merely slow. Returns\n * the stop.\n */\n function keepClaim(job: JobRecord, claim: JobClaim): () => void {\n if (!store.heartbeat) return () => undefined;\n const renew = store.heartbeat.bind(store);\n const interval = setInterval(() => {\n renew(job.id, claim).then((held) => {\n if (held) return;\n clearInterval(interval);\n logger.warn(\n `[jobs] \"${job.task}\" lost its claim on attempt ${claim.attempt} while running; ` +\n \"its outcome will not be recorded\", { jobId: job.id }\n );\n }).catch((error: unknown) => {\n logger.warn(`[jobs] Could not renew the claim on \"${job.task}\"`, { jobId: job.id, error });\n });\n }, Math.max(1, Math.floor(visibilityTimeoutMs / 3)));\n interval.unref?.();\n return () => clearInterval(interval);\n }\n\n async function runJob(job: JobRecord): Promise<void> {\n const handler = handlers.get(job.task);\n const claim: JobClaim = { workerId, attempt: job.attempts };\n\n if (!handler) {\n // Only reachable through a store that ignores the `tasks` filter on\n // `claim` — the one that ships never hands this worker a task it\n // has no handler for. Give the row back for a peer that has one.\n // The attempt the claim spent is not undone, so a task nobody in\n // the fleet implements dead-letters with an error naming it rather\n // than cycling through such a store forever.\n logger.warn(`[jobs] No handler registered for task \"${job.task}\" — returning the job to the queue`);\n const retryAt = job.attempts < job.maxAttempts ? new Date(Date.now() + backoff(job.attempts)) : null;\n await store.fail(job.id, `No handler registered for task \"${job.task}\"`, retryAt, claim);\n if (retryAt) wake(retryAt.getTime());\n return;\n }\n\n const releaseClaim = keepClaim(job, claim);\n const controller = new AbortController();\n let deadline: NodeJS.Timeout | undefined;\n try {\n try {\n const run = Promise.resolve().then(() => handler({\n id: job.id,\n task: job.task,\n payload: job.payload as never,\n attempt: job.attempts,\n maxAttempts: job.maxAttempts,\n signal: controller.signal\n } as never));\n if (timeoutMs === undefined) {\n await run;\n } else {\n // A handler that ignores the signal outlives its attempt;\n // what it does after is not this attempt's outcome.\n run.catch(() => undefined);\n await Promise.race([run, new Promise<never>((_, reject) => {\n deadline = setTimeout(() => {\n const error = new Error(`\"${job.task}\" timed out after ${timeoutMs}ms (jobs.timeoutMs)`);\n controller.abort(error);\n reject(error);\n }, timeoutMs);\n // A deadline is not work: it must not hold a process\n // open that is otherwise done.\n deadline.unref?.();\n })]);\n }\n } finally {\n clearTimeout(deadline);\n releaseClaim();\n }\n await store.complete(job.id, claim);\n } catch (error) {\n const message = error instanceof Error ? (error.stack ?? error.message) : String(error);\n const permanent = error instanceof PermanentJobError;\n const willRetry = !permanent && job.attempts < job.maxAttempts;\n\n // Truncated, because `last_error` holds a stack and a queue that\n // accumulates megabytes of them is its own outage.\n const retryAt = willRetry ? new Date(Date.now() + backoff(job.attempts)) : null;\n await store.fail(job.id, message.slice(0, 4_000), retryAt, claim);\n // This worker knows when the retry is due; nobody will notify it.\n if (retryAt) wake(retryAt.getTime());\n\n if (willRetry) {\n logger.warn(`[jobs] \"${job.task}\" failed on attempt ${job.attempts}/${job.maxAttempts}; retrying`, { jobId: job.id });\n } else if (permanent) {\n logger.error(`[jobs] \"${job.task}\" failed permanently on attempt ${job.attempts}; not retrying`, { jobId: job.id, error: message });\n } else {\n // The last attempt is an error, not a warning: nothing else will\n // touch this job, and if nobody looks at the table it is simply\n // lost work.\n logger.error(`[jobs] \"${job.task}\" failed permanently after ${job.attempts} attempts`, { jobId: job.id, error: message });\n }\n }\n }\n\n /**\n * Return jobs stranded by a dead worker. On its own timer, not inside the\n * poll: a poll can be skipped for as long as every slot is busy, and that\n * is exactly when a stranded job most needs a peer to take it back.\n */\n async function reap(): Promise<void> {\n if (reaping) return;\n reaping = true;\n try {\n // Revived jobs are runnable now, and nothing notified anyone.\n if (await store.reapExpired(visibilityTimeoutMs) > 0) wake();\n } catch (error) {\n logger.error(\"[jobs] Failed to reclaim expired jobs\", { error });\n } finally {\n reaping = false;\n }\n }\n\n /**\n * Delete finished jobs past retention. On the worker, on its own timer:\n * the sweep used to run only in the boot path, awaited before the server\n * served, so a pod that lived for weeks never pruned and the next boot\n * paid for all of it at once.\n */\n async function sweep(): Promise<void> {\n if (sweeping || !store.sweepExpired) return;\n sweeping = true;\n try {\n const deleted = await store.sweepExpired();\n if (deleted > 0) logger.info(`[jobs] Removed ${deleted} finished job(s) past retention`);\n } catch (error) {\n logger.warn(\"[jobs] The retention sweep failed; it runs again within the hour\", { error });\n } finally {\n sweeping = false;\n }\n }\n\n /** Claim as many jobs as there are free slots and start each one on its own. */\n async function claimAndStart(): Promise<Promise<void>[]> {\n const free = concurrency - active.size;\n if (free <= 0) return [];\n\n // Only the tasks this worker can run. During a rolling deploy an\n // instance on older code shares the table with jobs whose task only\n // the newer code implements, and a claim spends an attempt: claiming\n // them here would dead-letter them before an updated peer got to them.\n // A handler registered after `start()` joins the next claim.\n if (handlers.size === 0) return [];\n const jobs = await store.claim(free, workerId, [...handlers.keys()]);\n backlog = jobs.length >= free;\n // Found work: look again soon if it runs out. Found none: wait longer.\n if (jobs.length > 0) idleIntervalMs = pollIntervalMs;\n\n return jobs.map((job) => {\n const run: Promise<void> = runJob(job)\n .catch((error) => {\n // `runJob` handles a throwing handler; this is a store\n // write failing after it. The job keeps its claim, so the\n // visibility timeout recovers it.\n logger.error(`[jobs] Could not record the outcome of \"${job.task}\"`, { jobId: job.id, error });\n })\n .finally(() => {\n active.delete(run);\n // A slot just came free and there is work waiting: take the\n // next job now, not a poll interval from now.\n if (backlog) schedule(0);\n });\n active.add(run);\n return run;\n });\n }\n\n /** Look for work in `delayMs` — unless a look is already due sooner. */\n function schedule(delayMs: number): void {\n if (!running) return;\n const due = Date.now() + delayMs;\n if (timer && timerDue <= due) return;\n if (timer) clearTimeout(timer);\n timerDue = due;\n timer = setTimeout(() => {\n timer = null;\n timerDue = Infinity;\n void tick();\n }, delayMs);\n // Never hold the process open. A queue with nothing to do should not be\n // the reason `rebase dev` will not exit.\n timer.unref?.();\n }\n\n /**\n * Work may be runnable at `runAtMs` (now, when absent): a job was enqueued\n * — here, or in another process that notified — or a retry or a reclaimed\n * job came due. Resets the back-off.\n */\n function wake(runAtMs?: number): void {\n if (!running) return;\n idleIntervalMs = pollIntervalMs;\n const delay = runAtMs === undefined ? 0 : Math.max(0, runAtMs - Date.now());\n if (delay === 0 && active.size >= concurrency) {\n // Every slot is busy: the next one to free claims at once.\n backlog = true;\n return;\n }\n schedule(delay);\n }\n\n async function tick(): Promise<void> {\n if (!running) return;\n // A claim already in progress looks again when it finishes — the wake\n // may be for a row its snapshot did not see.\n if (claiming) {\n rerun = true;\n return;\n }\n const work = claimAndStart().catch((error) => {\n logger.error(\"[jobs] Poll failed\", { error });\n backlog = false;\n });\n claiming = work;\n await work;\n claiming = null;\n\n // A full claim with a slot still free — a job already finished — means\n // go straight back, and so does a wake that came in mid-claim.\n // Otherwise sleep; a job finishing during a backlog wakes the loop\n // early, and so does anything that calls `wake`.\n if ((backlog && active.size < concurrency) || rerun) {\n rerun = false;\n schedule(0);\n return;\n }\n if (listening) {\n schedule(idleCeilingMs);\n return;\n }\n schedule(idleIntervalMs);\n idleIntervalMs = Math.min(idleIntervalMs * 2, idleCeilingMs);\n }\n\n /** Be woken by the database when another process enqueues — where it can. */\n function listen(): void {\n if (!store.subscribe) return;\n store.subscribe(\n (runAtMs) => wake(runAtMs),\n // Notifications sent while the connection was down are gone.\n () => wake()\n ).then((stop) => {\n if (!running) {\n void stop().catch(() => undefined);\n return;\n }\n unsubscribe = stop;\n listening = true;\n logger.info(`[jobs] Woken by the database on enqueue; looking every ${idleCeilingMs / 1000}s besides`);\n }).catch((error: unknown) => {\n logger.info(\n `[jobs] Not woken by the database (${error instanceof Error ? error.message : String(error)}); ` +\n `polling instead, from every ${pollIntervalMs}ms up to every ${idleCeilingMs / 1000}s while idle`\n );\n });\n }\n\n async function drain(): Promise<void> {\n await claiming;\n await Promise.allSettled([...active]);\n }\n\n return {\n start(): void {\n if (running) return;\n running = true;\n idleIntervalMs = pollIntervalMs;\n logger.info(`[jobs] Worker started (concurrency ${concurrency}, poll ${pollIntervalMs}ms, up to ${idleCeilingMs}ms while idle)`);\n if (configuredTimeoutMs === undefined) {\n logger.info(\n `[jobs] Each attempt is limited to ${DEFAULT_JOB_TIMEOUT_MS / 60_000} minutes, the default jobs.timeoutMs. ` +\n \"Set jobs.timeoutMs to change it, or 0 for no limit.\"\n );\n }\n void reap();\n reapTimer = setInterval(() => { void reap(); }, visibilityTimeoutMs * REAP_INTERVAL_FACTOR);\n reapTimer.unref?.();\n void sweep();\n sweepTimer = setInterval(() => { void sweep(); }, RETENTION_SWEEP_INTERVAL_MS);\n sweepTimer.unref?.();\n listen();\n schedule(0);\n },\n\n async stop(timeoutMs?: number): Promise<void> {\n running = false;\n if (timer) {\n clearTimeout(timer);\n timer = null;\n timerDue = Infinity;\n }\n listening = false;\n const stopListening = unsubscribe;\n unsubscribe = null;\n if (stopListening) {\n await stopListening().catch((error: unknown) => {\n logger.warn(\"[jobs] Could not close the notification connection\", { error });\n });\n }\n if (reapTimer) {\n clearInterval(reapTimer);\n reapTimer = null;\n }\n if (sweepTimer) {\n clearInterval(sweepTimer);\n sweepTimer = null;\n }\n // Jobs in flight keep their claim until they finish or the\n // visibility timeout expires, so waiting here is what turns a\n // graceful shutdown into \"no job runs twice\".\n draining ??= drain().finally(() => { draining = null; });\n if (timeoutMs === undefined) {\n await draining;\n return;\n }\n let budget: NodeJS.Timeout | undefined;\n const finished = await Promise.race([\n draining.then(() => true),\n new Promise<false>((resolve) => {\n budget = setTimeout(() => resolve(false), timeoutMs);\n budget.unref?.();\n })\n ]);\n clearTimeout(budget);\n if (!finished) {\n logger.warn(\n `[jobs] ${active.size} job(s) still running after ${timeoutMs}ms; stopping without them. ` +\n \"They keep their claim and are retried once the visibility timeout reclaims it.\"\n );\n }\n },\n\n async runOnce(): Promise<number> {\n const started = await claimAndStart();\n await Promise.allSettled(started);\n return started.length;\n },\n\n register<P = unknown>(task: string, handler: JobHandler<P>): void {\n if (handlers.has(task)) {\n logger.warn(`[jobs] Task \"${task}\" was already registered; the later handler wins`);\n }\n handlers.set(task, handler as JobHandler<never>);\n },\n\n isRunning(): boolean {\n return running;\n },\n\n async enqueue<P = unknown>(task: string, payload?: P, enqueueOptions: EnqueueOptions = {}): Promise<string | null> {\n const runAt = new Date(Date.now() + (enqueueOptions.delayMs ?? 0));\n // Read before the insert, which may await: inside a write the row\n // is invisible until that write commits, so that is when to look.\n const ambient = currentAmbientTransaction();\n const id = await store.insert({\n task,\n payload: payload ?? null,\n runAt,\n maxAttempts: enqueueOptions.maxAttempts ?? defaultMaxAttempts,\n idempotencyKey: enqueueOptions.idempotencyKey\n });\n // This process's own worker needs no notification to hear about it.\n if (id !== null && running) {\n if (ambient) ambient.afterCommit(() => wake(runAt.getTime()));\n else wake(runAt.getTime());\n }\n return id;\n }\n };\n}\n","import type { DataDriver } from \"@rebasepro/types\";\nimport { isSQLAdmin } from \"@rebasepro/types\";\nimport { REBASE_USER_ROLE, revokeInternalTableSql, sqlRows, firstSqlRow } from \"@rebasepro/common\";\nimport { logger } from \"../utils/logger.js\";\nimport { createDdlBootstrapper, hasInCauseChain, type SqlExec } from \"../boot/ddl-bootstrap.js\";\nimport { currentAmbientTransaction } from \"../db/ambient-transaction.js\";\nimport type { JobRecord } from \"./types.js\";\n\n/**\n * The queue's storage, as SQL against `rebase.jobs`.\n *\n * Split from the worker for the same reason `cron-store` is split from\n * `cron-scheduler`: the interesting parts here are three statements that have\n * to be exactly right under concurrency, and they are much easier to reason\n * about — and to test — away from a polling loop.\n */\n\nconst TABLE = \"rebase.jobs\";\n\n/** How long finished jobs are kept before the retention sweep removes them. */\nconst SUCCEEDED_RETENTION_DAYS = 3;\n/**\n * Failures outlive successes by a lot. A dead-lettered job is evidence, and the\n * person who needs it is usually looking on Monday for something that happened\n * on Friday night.\n */\nconst FAILED_RETENTION_DAYS = 30;\n\n/**\n * Rows one retention statement deletes. A sweep repeats it until a batch\n * comes back short, so a backlog — the first sweep on a table a long-lived\n * pod let grow to millions of rows — is many short transactions rather than\n * one that holds locks and writes a burst of WAL for seconds.\n */\nconst RETENTION_BATCH = 5_000;\n\n/** A row as Postgres returns it. */\ninterface JobRow {\n id: string;\n task: string;\n payload: unknown;\n status: string;\n run_at: string;\n attempts: number;\n max_attempts: number;\n last_error: string | null;\n created_at: string;\n updated_at: string;\n}\n\nfunction toRecord(row: JobRow): JobRecord {\n return {\n id: row.id,\n task: row.task,\n payload: row.payload,\n status: row.status as JobRecord[\"status\"],\n runAt: new Date(row.run_at).toISOString(),\n attempts: Number(row.attempts),\n maxAttempts: Number(row.max_attempts),\n lastError: row.last_error,\n createdAt: new Date(row.created_at).toISOString(),\n updatedAt: new Date(row.updated_at).toISOString()\n };\n}\n\n/** Same rule as `cron-store`: match the SQLSTATE, never the message. */\nfunction isUniqueViolation(err: unknown): boolean {\n return hasInCauseChain(err, (e) => e.code === \"23505\");\n}\n\n/**\n * How a job gets into `rebase.jobs` from inside somebody else's transaction.\n *\n * An enqueue from a collection callback has to ride the write's transaction —\n * committed with it, discarded with it, invisible to workers until then — and\n * that transaction runs as the request's restricted role, which has no access\n * to `rebase.jobs` at all (see the revoke in `ensureTable`). This function is\n * the one door: `SECURITY DEFINER`, so the insert runs with the owner's rights\n * for that one statement and the transaction's role never changes. Switching\n * the role for the insert instead would elevate the whole connection for as\n * long as the insert took, and a callback's un-awaited statement would run\n * in that window as the owner.\n *\n * The door is narrow on purpose. It takes the five columns an enqueue sets and\n * nothing else, so it cannot claim, complete or rewrite a job — and only the\n * request role is granted it, not PUBLIC. That role runs statements the server\n * writes; nobody outside gets SQL as it.\n *\n * `ON CONFLICT DO NOTHING` rather than a caught unique violation: an error\n * inside a transaction aborts the whole transaction, so an idempotency clash\n * would have failed the customer's write. A clash returns no row, hence NULL.\n */\nconst ENQUEUE_FUNCTION = \"rebase.enqueue_job\";\nconst ENQUEUE_SIGNATURE = `${ENQUEUE_FUNCTION}(text, jsonb, timestamptz, integer, text)`;\n\n/**\n * The `NOTIFY` channel an insert announces itself on, with the job's `run_at`\n * in epoch milliseconds as the payload. Workers listening on it are woken\n * instead of polling; Postgres delivers it when the insert commits, and not at\n * all if it rolls back. Frozen: workers of the previous release listen on it\n * during a rolling deploy.\n */\nexport const JOBS_NOTIFY_CHANNEL = \"rebase_jobs\";\n\n/** `run_at` as the notification's payload: epoch milliseconds, as text. */\nconst NOTIFY_PAYLOAD = (runAt: string) => `(extract(epoch FROM ${runAt}) * 1000)::bigint::text`;\n\n/**\n * Which claim an outcome belongs to: the worker holding the job, and the\n * attempt that claim spent.\n *\n * The visibility timeout hands a job to a second worker when the first stops\n * renewing its claim, and the first may still be running — slow, not dead. Its\n * outcome is then about an attempt the table has moved past, and writing it\n * would mark a running job `succeeded`, or send a finished one back to\n * `pending` for another run. Both writes therefore apply only while the claim\n * they name still holds the row. The attempt is part of the identity because a\n * worker's own reaper can hand a job back to the same worker in another slot.\n */\nexport interface JobClaim {\n workerId: string;\n attempt: number;\n}\n\nexport interface JobStore {\n ensureTable(): Promise<void>;\n /** Returns the new job's id, or `null` if an idempotency key matched unfinished work. */\n insert(job: {\n task: string;\n payload: unknown;\n runAt: Date;\n maxAttempts: number;\n idempotencyKey?: string;\n }): Promise<string | null>;\n /**\n * Atomically take up to `limit` runnable jobs for this worker — only jobs\n * whose task is in `tasks`, when it is given. A worker never claims work it\n * cannot run: a claim spends an attempt, so an instance running older code\n * during a rollout would otherwise dead-letter the newer code's jobs.\n */\n claim(limit: number, workerId: string, tasks?: readonly string[]): Promise<JobRecord[]>;\n /** Mark the job `succeeded` — a no-op once `claim` no longer holds it. */\n complete(id: string, claim: JobClaim): Promise<void>;\n /**\n * Back to `pending` with a later `runAt`, or `failed` when out of attempts —\n * a no-op once `claim` no longer holds it.\n */\n fail(id: string, error: string, retryAt: Date | null, claim: JobClaim): Promise<void>;\n /**\n * Renew `claim` on a job whose handler is still running, so the visibility\n * timeout only ever reclaims a job from a worker that stopped renewing.\n * Resolves with whether the claim still held. A store without it leaves a\n * handler that outlives the timeout to be run a second time beside itself.\n */\n heartbeat?(id: string, claim: JobClaim): Promise<boolean>;\n /** Return jobs stranded by a worker that died holding them. Resolves with how many. */\n reapExpired(visibilityTimeoutMs: number): Promise<number>;\n /**\n * Call `onWake` whenever any process inserts a job, with the job's `run_at`\n * in epoch milliseconds. Resolves, once listening, with the stop; rejects\n * when this database cannot notify this process — no session connection\n * to hold a `LISTEN` on — and the worker polls instead. `onReconnect` is\n * called after a dropped connection is back: what was sent meanwhile is\n * lost. A store without it leaves the worker polling.\n */\n subscribe?(onWake: (runAtMs?: number) => void, onReconnect: () => void): Promise<() => Promise<void>>;\n /**\n * Delete finished jobs past retention — succeeded ones after\n * {@link SUCCEEDED_RETENTION_DAYS} days, failed ones after\n * {@link FAILED_RETENTION_DAYS} — in batches. Resolves with how many went.\n * The worker runs it on a timer; a store without it keeps every row.\n */\n sweepExpired?(): Promise<number>;\n fetch(id: string): Promise<JobRecord | null>;\n}\n\n/** The rows a claim's writes may touch: `$1` the job, `$2` the worker, `$3` the attempt. */\nconst HELD_BY_CLAIM = \"id = $1 AND status = 'running' AND locked_by = $2 AND attempts = $3\";\n\nfunction warnStaleOutcome(id: string, claim: JobClaim, outcome: \"success\" | \"failure\"): void {\n logger.warn(\n `[jobs] Ignored the ${outcome} of attempt ${claim.attempt} of job ${id}: the job was reclaimed ` +\n \"from this worker while it ran, so that attempt's outcome is no longer the job's.\"\n );\n}\n\nexport function createJobStore(driver: DataDriver): JobStore | undefined {\n const admin = driver.admin;\n if (!isSQLAdmin(admin)) {\n logger.warn(\n \"⚠️ [jobs] DataDriver does not support SQL admin — the durable job queue is unavailable. \" +\n \"Work that would have been queued runs inline instead.\"\n );\n return undefined;\n }\n\n // Two shapes of the same call: the bootstrapper's `SqlExec` takes an\n // options object, while everything below reads better with a positional\n // parameter array.\n const execRaw: SqlExec = (sqlText, options) =>\n admin.executeSql(sqlText, options?.params ? { params: options.params } : undefined);\n const exec = (sqlText: string, params?: unknown[]) =>\n execRaw(sqlText, params ? { params } : undefined);\n\n const ddl = createDdlBootstrapper(execRaw, \"jobs\");\n\n /**\n * Whether {@link ENQUEUE_FUNCTION} exists, so an enqueue inside a write can\n * ride its transaction. Decided at boot rather than assumed: a database\n * that refused the function would otherwise fail every write whose callback\n * enqueues, where falling back to the old behaviour only loses atomicity.\n */\n let enqueueInTransaction = false;\n\n return {\n async ensureTable(): Promise<void> {\n await ddl.ensureObject(\"Creating schema rebase\", \"CREATE SCHEMA IF NOT EXISTS rebase\");\n\n await ddl.ensureObject(`Creating ${TABLE}`, `\n CREATE TABLE IF NOT EXISTS ${TABLE} (\n id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,\n task TEXT NOT NULL,\n payload JSONB,\n status TEXT NOT NULL DEFAULT 'pending',\n run_at TIMESTAMPTZ NOT NULL DEFAULT now(),\n attempts INTEGER NOT NULL DEFAULT 0,\n max_attempts INTEGER NOT NULL DEFAULT 3,\n locked_at TIMESTAMPTZ,\n locked_by TEXT,\n idempotency_key TEXT,\n last_error TEXT,\n created_at TIMESTAMPTZ NOT NULL DEFAULT now(),\n updated_at TIMESTAMPTZ NOT NULL DEFAULT now()\n )\n `);\n\n // The claim query's index. Partial, because the rows it has to find\n // fast are a shrinking minority of a table that also holds every\n // success and every dead letter.\n await ddl.ensureObject(\"Creating idx_jobs_runnable\", `\n CREATE INDEX IF NOT EXISTS idx_jobs_runnable\n ON ${TABLE}(run_at, created_at)\n WHERE status = 'pending'\n `);\n\n // Finds jobs stranded by a dead worker.\n await ddl.ensureObject(\"Creating idx_jobs_running\", `\n CREATE INDEX IF NOT EXISTS idx_jobs_running\n ON ${TABLE}(locked_at)\n WHERE status = 'running'\n `);\n\n // What makes `idempotencyKey` a guarantee rather than a\n // check-then-act race: two instances reacting to one event both\n // reach the INSERT, and the index decides. Partial on unfinished\n // work — see `EnqueueOptions.idempotencyKey` for why a key must be\n // reusable once its job is done.\n await ddl.ensureObject(\"Creating idx_jobs_idempotency\", `\n CREATE UNIQUE INDEX IF NOT EXISTS idx_jobs_idempotency\n ON ${TABLE}(idempotency_key)\n WHERE idempotency_key IS NOT NULL AND status IN ('pending', 'running')\n `);\n\n // The retention sweep's index. Partial on the finished rows — the\n // only ones it deletes — so a sweep reads what it removes instead\n // of the whole table; without it every sweep was a sequential scan,\n // six seconds over two million rows.\n await ddl.ensureObject(\"Creating idx_jobs_finished\", `\n CREATE INDEX IF NOT EXISTS idx_jobs_finished\n ON ${TABLE}(status, updated_at)\n WHERE status IN ('succeeded', 'failed')\n `);\n\n const ready = await ddl.isReadable(TABLE);\n\n if (ready) {\n // No retention sweep here. It used to run at boot and only at\n // boot: awaited before the server served, a sequential scan of\n // the whole table, and never again for as long as the pod lived\n // — a worker up for three weeks held seven times the rows\n // retention meant to keep. The worker runs `sweepExpired` on a\n // timer instead.\n\n // Payloads are arbitrary application data — a webhook body, a\n // user id, whatever was passed — and a writable queue lets any\n // signed-in user schedule work of their choosing under the\n // server's own authority. Same reasoning, and the same\n // unconditional re-application, as `cron_claims`.\n await ddl.step(\"Revoking end-user access to jobs\", () =>\n exec(revokeInternalTableSql(\"rebase\", \"jobs\")));\n\n await ddl.ensureObject(`Creating ${ENQUEUE_FUNCTION}`, `\n CREATE OR REPLACE FUNCTION ${ENQUEUE_FUNCTION}(\n p_task text, p_payload jsonb, p_run_at timestamptz,\n p_max_attempts integer, p_idempotency_key text\n ) RETURNS text\n LANGUAGE sql\n SECURITY DEFINER\n SET search_path = pg_catalog, pg_temp\n AS $fn$\n SELECT pg_notify('${JOBS_NOTIFY_CHANNEL}', ${NOTIFY_PAYLOAD(\"p_run_at\")});\n INSERT INTO ${TABLE} (task, payload, run_at, max_attempts, idempotency_key)\n VALUES (p_task, p_payload, p_run_at, p_max_attempts, p_idempotency_key)\n ON CONFLICT DO NOTHING\n RETURNING id\n $fn$\n `);\n // Re-applied every boot: a new function is executable by PUBLIC\n // until told otherwise, and the request role may be provisioned\n // after the function was first made.\n await ddl.step(`Granting ${ENQUEUE_FUNCTION} to the request role`, async () => {\n await exec(`REVOKE ALL ON FUNCTION ${ENQUEUE_SIGNATURE} FROM PUBLIC`);\n await exec(`\n DO $grant$ BEGIN\n IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${REBASE_USER_ROLE}') THEN\n GRANT EXECUTE ON FUNCTION ${ENQUEUE_SIGNATURE} TO ${REBASE_USER_ROLE};\n END IF;\n END $grant$\n `);\n });\n enqueueInTransaction = firstSqlRow<{ present: boolean }>(await exec(\n `SELECT to_regprocedure('${ENQUEUE_SIGNATURE}') IS NOT NULL AS present`\n ))?.present === true;\n if (!enqueueInTransaction) {\n logger.warn(\n `⚠️ [jobs] ${ENQUEUE_FUNCTION} could not be created, so a job enqueued inside a write ` +\n \"commits on its own: it stays queued if the write rolls back.\"\n );\n }\n\n logger.info(\"✅ Job queue table ready\");\n } else {\n logger.error(\n `❌ [jobs] ${TABLE} is unavailable — nothing can be queued and nothing queued earlier ` +\n \"will run. Callers fall back to running the work inline.\"\n );\n }\n },\n\n async insert(job): Promise<string | null> {\n const params = [\n job.task,\n JSON.stringify(job.payload ?? null),\n job.runAt.toISOString(),\n job.maxAttempts,\n job.idempotencyKey ?? null\n ];\n // Inside a write: on its transaction, through the definer function\n // — see `ENQUEUE_FUNCTION`. Checked and used with no `await`\n // between, so the transaction cannot close in the gap.\n const ambient = enqueueInTransaction ? currentAmbientTransaction() : undefined;\n if (ambient) {\n const rows = await ambient.exec(\n `SELECT ${ENQUEUE_FUNCTION}($1::text, $2::jsonb, $3::timestamptz, $4::integer, $5::text) AS id`,\n params\n );\n return (rows?.[0]?.id as string | null | undefined) ?? null;\n }\n try {\n // The notification rides the insert's own statement, so it\n // is sent exactly when the row commits.\n const rows = await exec(\n `WITH inserted AS (\n INSERT INTO ${TABLE} (task, payload, run_at, max_attempts, idempotency_key)\n VALUES ($1, $2::jsonb, $3, $4, $5)\n RETURNING id, run_at\n )\n SELECT id, pg_notify('${JOBS_NOTIFY_CHANNEL}', ${NOTIFY_PAYLOAD(\"run_at\")}) AS notified\n FROM inserted`,\n params\n );\n return (rows?.[0]?.id as string) ?? null;\n } catch (error) {\n // The losing side of an idempotency race. Not an error: the\n // work the caller wanted is already queued, which is the\n // outcome they asked for.\n if (isUniqueViolation(error)) return null;\n throw error;\n }\n },\n\n async claim(limit: number, workerId: string, tasks?: readonly string[]): Promise<JobRecord[]> {\n if (tasks && tasks.length === 0) return [];\n // One placeholder per task rather than an array parameter: every\n // driver binds a string, and not every driver binds an array.\n const taskFilter = tasks\n ? `AND task IN (${tasks.map((_, i) => `$${i + 3}`).join(\", \")})`\n : \"\";\n // `FOR UPDATE SKIP LOCKED` is the whole design. The inner select\n // takes row locks on the jobs it picks and *skips* any a concurrent\n // worker already holds, so N workers polling the same table divide\n // the work instead of contending for the head of it — and no job is\n // ever handed to two of them.\n //\n // `attempts` is incremented here, on claim, rather than on failure.\n // A worker that is killed mid-job never reports anything, so\n // counting on failure would let a job that crashes the process be\n // retried forever, once per restart, taking the process down each\n // time.\n const rows = await exec(\n `UPDATE ${TABLE} SET\n status = 'running',\n attempts = attempts + 1,\n locked_at = now(),\n locked_by = $2,\n updated_at = now()\n WHERE id IN (\n SELECT id FROM ${TABLE}\n WHERE status = 'pending' AND run_at <= now() ${taskFilter}\n ORDER BY run_at, created_at\n LIMIT $1\n FOR UPDATE SKIP LOCKED\n )\n RETURNING *`,\n [limit, workerId, ...(tasks ?? [])]\n );\n return sqlRows<JobRow>(rows).map(toRecord);\n },\n\n async complete(id: string, claim: JobClaim): Promise<void> {\n const rows = await exec(\n `UPDATE ${TABLE} SET status = 'succeeded', locked_at = NULL, locked_by = NULL,\n last_error = NULL, updated_at = now()\n WHERE ${HELD_BY_CLAIM}\n RETURNING id`,\n [id, claim.workerId, claim.attempt]\n );\n if (sqlRows(rows).length === 0) warnStaleOutcome(id, claim, \"success\");\n },\n\n async fail(id: string, error: string, retryAt: Date | null, claim: JobClaim): Promise<void> {\n const rows = retryAt\n ? await exec(\n `UPDATE ${TABLE} SET status = 'pending', run_at = $4, locked_at = NULL,\n locked_by = NULL, last_error = $5, updated_at = now()\n WHERE ${HELD_BY_CLAIM}\n RETURNING id`,\n [id, claim.workerId, claim.attempt, retryAt.toISOString(), error]\n )\n : await exec(\n `UPDATE ${TABLE} SET status = 'failed', locked_at = NULL, locked_by = NULL,\n last_error = $4, updated_at = now()\n WHERE ${HELD_BY_CLAIM}\n RETURNING id`,\n [id, claim.workerId, claim.attempt, error]\n );\n if (sqlRows(rows).length === 0) warnStaleOutcome(id, claim, \"failure\");\n },\n\n async heartbeat(id: string, claim: JobClaim): Promise<boolean> {\n const rows = await exec(\n `UPDATE ${TABLE} SET locked_at = now()\n WHERE ${HELD_BY_CLAIM}\n RETURNING id`,\n [id, claim.workerId, claim.attempt]\n );\n return sqlRows(rows).length > 0;\n },\n\n async reapExpired(visibilityTimeoutMs: number): Promise<number> {\n // A worker killed while holding a job cannot release it, so nothing\n // but a timeout will ever free the row. Jobs that still have\n // attempts left go back to `pending`; the rest are dead-lettered\n // with an error that says what happened, because \"attempts: 3,\n // lastError: null\" is otherwise a genuinely baffling row to find.\n const seconds = Math.max(1, Math.round(visibilityTimeoutMs / 1000));\n\n const revived = await exec(\n `UPDATE ${TABLE} SET status = 'pending', locked_at = NULL, locked_by = NULL,\n last_error = 'Worker stopped responding; the job was reclaimed', updated_at = now()\n WHERE status = 'running'\n AND locked_at < now() - make_interval(secs => $1)\n AND attempts < max_attempts\n RETURNING id`,\n [seconds]\n );\n\n const buried = await exec(\n `UPDATE ${TABLE} SET status = 'failed', locked_at = NULL, locked_by = NULL,\n last_error = 'Worker stopped responding on the final attempt', updated_at = now()\n WHERE status = 'running'\n AND locked_at < now() - make_interval(secs => $1)\n AND attempts >= max_attempts\n RETURNING id`,\n [seconds]\n );\n\n const count = (revived?.length ?? 0) + (buried?.length ?? 0);\n if (count > 0) {\n logger.warn(\n `[jobs] Reclaimed ${count} job(s) from a worker that stopped responding ` +\n `(${revived?.length ?? 0} retryable, ${buried?.length ?? 0} dead-lettered)`\n );\n }\n return count;\n },\n\n async sweepExpired(): Promise<number> {\n // `SKIP LOCKED`, so replicas sweeping at once divide the rows\n // rather than queue behind each other's batch. The count comes back\n // as one row, not one id per deleted job.\n let total = 0;\n for (;;) {\n const rows = await exec(\n `WITH swept AS (\n DELETE FROM ${TABLE}\n WHERE id IN (\n SELECT id FROM ${TABLE}\n WHERE (status = 'succeeded' AND updated_at < now() - make_interval(days => $1))\n OR (status = 'failed' AND updated_at < now() - make_interval(days => $2))\n LIMIT $3\n FOR UPDATE SKIP LOCKED\n )\n RETURNING 1\n )\n SELECT count(*)::int AS deleted FROM swept`,\n [SUCCEEDED_RETENTION_DAYS, FAILED_RETENTION_DAYS, RETENTION_BATCH]\n );\n const deleted = Number(firstSqlRow<{ deleted: number }>(rows)?.deleted ?? 0);\n total += deleted;\n if (deleted < RETENTION_BATCH) return total;\n }\n },\n\n async subscribe(onWake: (runAtMs?: number) => void, onReconnect: () => void): Promise<() => Promise<void>> {\n if (!admin.listen) {\n throw new Error(\"this database driver has no notifications\");\n }\n return admin.listen(JOBS_NOTIFY_CHANNEL, (payload) => {\n const runAtMs = Number(payload);\n onWake(Number.isFinite(runAtMs) && runAtMs > 0 ? runAtMs : undefined);\n }, { onReconnect });\n },\n\n async fetch(id: string): Promise<JobRecord | null> {\n const rows = await exec(`SELECT * FROM ${TABLE} WHERE id = $1`, [id]);\n const row = firstSqlRow<JobRow>(rows);\n return row ? toRecord(row) : null;\n }\n };\n}\n","export { createJobStore } from \"./job-store\";\nexport type { JobClaim, JobStore } from \"./job-store\";\nexport { createJobQueue, defaultBackoff, PermanentJobError } from \"./job-queue\";\nexport type { JobQueue } from \"./job-queue\";\nexport type {\n EnqueueOptions,\n JobContext,\n JobHandler,\n JobQueueClient,\n JobQueueOptions,\n JobRecord,\n JobStatus\n} from \"./types\";\n"],"mappings":";;;;;;;;;;;;;;AA0CA,IAAM,gBAAgB,OAAO,IAAI,0CAA0C;;AAc3E,SAAgB,4BAA4D;CACxE,OAAQ,WAA4B,cAAc,GAAG;AACzD;;;;;;;;;;AC5CA,IAAM,sBAAsB;AAC5B,IAAM,2BAA2B;AACjC,IAAM,gCAAgC,IAAI;AAC1C,IAAM,uBAAuB;;;;;;;;AAS7B,IAAa,yBAAyB,KAAK;;;;;;;;;;;AAY3C,IAAM,mBAAmB;;AAGzB,SAAgB,eAAe,SAAyB;CACpD,OAAO,KAAK,IAAI,MAAQ,KAAK,IAAI,GAAG,KAAK,IAAI,GAAG,UAAU,CAAC,CAAC,GAAG,KAAK,GAAM;AAC9E;;;;;;;;AASA,IAAM,uBAAuB;;;;;;AAO7B,IAAM,8BAA8B,KAAK;;;;;;;;AASzC,IAAa,oBAAb,cAAuC,MAAM;CACzC,YAAY,SAAiB,SAA+B;EACxD,MAAM,SAAS,OAAO;EACtB,KAAK,OAAO;CAChB;AACJ;AAqBA,SAAgB,eAAe,OAAiB,UAA2B,CAAC,GAAa;CACrF,MAAM,cAAc,QAAQ,eAAe;CAC3C,MAAM,iBAAiB,QAAQ,kBAAkB;CACjD,MAAM,sBAAsB,QAAQ,uBAAuB;CAC3D,MAAM,qBAAqB,QAAQ,eAAe;CAClD,MAAM,UAAU,QAAQ,WAAW;CACnC,MAAM,sBAAsB,QAAQ;CACpC,IAAI,wBAAwB,KAAA,KAAa,EAAE,OAAO,SAAS,mBAAmB,KAAK,uBAAuB,IAGtG,MAAM,IAAI,MAAM,2EAA2E,OAAO,mBAAmB,EAAE,GAAG;;CAG9H,MAAM,YAAY,wBAAwB,KAAA,IACpC,yBACA,wBAAwB,IAAI,KAAA,IAAY;CAE9C,MAAM,2BAAW,IAAI,IAA+B;CACpD,KAAK,MAAM,CAAC,MAAM,YAAY,OAAO,QAAQ,QAAQ,SAAS,CAAC,CAAC,GAC5D,SAAS,IAAI,MAAM,OAAO;CAM9B,MAAM,WAAW,GAAG,QAAQ,IAAI,GAAG,WAAW,CAAC,CAAC,MAAM,GAAG,CAAC;CAE1D,IAAI,QAA+B;;CAEnC,IAAI,WAAW;;;;;;CAMf,MAAM,gBAAgB,KAAK,IAAI,kBAAkB,cAAc;CAC/D,IAAI,iBAAiB;;CAErB,IAAI,YAAY;CAChB,IAAI,cAA4C;;CAEhD,IAAI,QAAQ;CACZ,IAAI,YAAmC;CACvC,IAAI,aAAoC;CACxC,IAAI,WAAW;CACf,IAAI,UAAU;;;;;;;CAOd,MAAM,yBAAS,IAAI,IAAmB;;CAEtC,IAAI,WAAoC;;CAExC,IAAI,UAAU;CACd,IAAI,UAAU;CACd,IAAI,WAAiC;;;;;;;CAQrC,SAAS,UAAU,KAAgB,OAA6B;EAC5D,IAAI,CAAC,MAAM,WAAW,aAAa,KAAA;EACnC,MAAM,QAAQ,MAAM,UAAU,KAAK,KAAK;EACxC,MAAM,WAAW,kBAAkB;GAC/B,MAAM,IAAI,IAAI,KAAK,CAAC,CAAC,MAAM,SAAS;IAChC,IAAI,MAAM;IACV,cAAc,QAAQ;IACtB,OAAO,KACH,WAAW,IAAI,KAAK,8BAA8B,MAAM,QAAQ,mDAC5B,EAAE,OAAO,IAAI,GAAG,CACxD;GACJ,CAAC,CAAC,CAAC,OAAO,UAAmB;IACzB,OAAO,KAAK,wCAAwC,IAAI,KAAK,IAAI;KAAE,OAAO,IAAI;KAAI;IAAM,CAAC;GAC7F,CAAC;EACL,GAAG,KAAK,IAAI,GAAG,KAAK,MAAM,sBAAsB,CAAC,CAAC,CAAC;EACnD,SAAS,QAAQ;EACjB,aAAa,cAAc,QAAQ;CACvC;CAEA,eAAe,OAAO,KAA+B;EACjD,MAAM,UAAU,SAAS,IAAI,IAAI,IAAI;EACrC,MAAM,QAAkB;GAAE;GAAU,SAAS,IAAI;EAAS;EAE1D,IAAI,CAAC,SAAS;GAOV,OAAO,KAAK,0CAA0C,IAAI,KAAK,mCAAmC;GAClG,MAAM,UAAU,IAAI,WAAW,IAAI,cAAc,IAAI,KAAK,KAAK,IAAI,IAAI,QAAQ,IAAI,QAAQ,CAAC,IAAI;GAChG,MAAM,MAAM,KAAK,IAAI,IAAI,mCAAmC,IAAI,KAAK,IAAI,SAAS,KAAK;GACvF,IAAI,SAAS,KAAK,QAAQ,QAAQ,CAAC;GACnC;EACJ;EAEA,MAAM,eAAe,UAAU,KAAK,KAAK;EACzC,MAAM,aAAa,IAAI,gBAAgB;EACvC,IAAI;EACJ,IAAI;GACA,IAAI;IACA,MAAM,MAAM,QAAQ,QAAQ,CAAC,CAAC,WAAW,QAAQ;KAC7C,IAAI,IAAI;KACR,MAAM,IAAI;KACV,SAAS,IAAI;KACb,SAAS,IAAI;KACb,aAAa,IAAI;KACjB,QAAQ,WAAW;IACvB,CAAU,CAAC;IACX,IAAI,cAAc,KAAA,GACd,MAAM;SACH;KAGH,IAAI,YAAY,KAAA,CAAS;KACzB,MAAM,QAAQ,KAAK,CAAC,KAAK,IAAI,SAAgB,GAAG,WAAW;MACvD,WAAW,iBAAiB;OACxB,MAAM,wBAAQ,IAAI,MAAM,IAAI,IAAI,KAAK,oBAAoB,UAAU,oBAAoB;OACvF,WAAW,MAAM,KAAK;OACtB,OAAO,KAAK;MAChB,GAAG,SAAS;MAGZ,SAAS,QAAQ;KACrB,CAAC,CAAC,CAAC;IACP;GACJ,UAAU;IACN,aAAa,QAAQ;IACrB,aAAa;GACjB;GACA,MAAM,MAAM,SAAS,IAAI,IAAI,KAAK;EACtC,SAAS,OAAO;GACZ,MAAM,UAAU,iBAAiB,QAAS,MAAM,SAAS,MAAM,UAAW,OAAO,KAAK;GACtF,MAAM,YAAY,iBAAiB;GACnC,MAAM,YAAY,CAAC,aAAa,IAAI,WAAW,IAAI;GAInD,MAAM,UAAU,YAAY,IAAI,KAAK,KAAK,IAAI,IAAI,QAAQ,IAAI,QAAQ,CAAC,IAAI;GAC3E,MAAM,MAAM,KAAK,IAAI,IAAI,QAAQ,MAAM,GAAG,GAAK,GAAG,SAAS,KAAK;GAEhE,IAAI,SAAS,KAAK,QAAQ,QAAQ,CAAC;GAEnC,IAAI,WACA,OAAO,KAAK,WAAW,IAAI,KAAK,sBAAsB,IAAI,SAAS,GAAG,IAAI,YAAY,aAAa,EAAE,OAAO,IAAI,GAAG,CAAC;QACjH,IAAI,WACP,OAAO,MAAM,WAAW,IAAI,KAAK,kCAAkC,IAAI,SAAS,iBAAiB;IAAE,OAAO,IAAI;IAAI,OAAO;GAAQ,CAAC;QAKlI,OAAO,MAAM,WAAW,IAAI,KAAK,6BAA6B,IAAI,SAAS,YAAY;IAAE,OAAO,IAAI;IAAI,OAAO;GAAQ,CAAC;EAEhI;CACJ;;;;;;CAOA,eAAe,OAAsB;EACjC,IAAI,SAAS;EACb,UAAU;EACV,IAAI;GAEA,IAAI,MAAM,MAAM,YAAY,mBAAmB,IAAI,GAAG,KAAK;EAC/D,SAAS,OAAO;GACZ,OAAO,MAAM,yCAAyC,EAAE,MAAM,CAAC;EACnE,UAAU;GACN,UAAU;EACd;CACJ;;;;;;;CAQA,eAAe,QAAuB;EAClC,IAAI,YAAY,CAAC,MAAM,cAAc;EACrC,WAAW;EACX,IAAI;GACA,MAAM,UAAU,MAAM,MAAM,aAAa;GACzC,IAAI,UAAU,GAAG,OAAO,KAAK,kBAAkB,QAAQ,gCAAgC;EAC3F,SAAS,OAAO;GACZ,OAAO,KAAK,oEAAoE,EAAE,MAAM,CAAC;EAC7F,UAAU;GACN,WAAW;EACf;CACJ;;CAGA,eAAe,gBAA0C;EACrD,MAAM,OAAO,cAAc,OAAO;EAClC,IAAI,QAAQ,GAAG,OAAO,CAAC;EAOvB,IAAI,SAAS,SAAS,GAAG,OAAO,CAAC;EACjC,MAAM,OAAO,MAAM,MAAM,MAAM,MAAM,UAAU,CAAC,GAAG,SAAS,KAAK,CAAC,CAAC;EACnE,UAAU,KAAK,UAAU;EAEzB,IAAI,KAAK,SAAS,GAAG,iBAAiB;EAEtC,OAAO,KAAK,KAAK,QAAQ;GACrB,MAAM,MAAqB,OAAO,GAAG,CAAC,CACjC,OAAO,UAAU;IAId,OAAO,MAAM,2CAA2C,IAAI,KAAK,IAAI;KAAE,OAAO,IAAI;KAAI;IAAM,CAAC;GACjG,CAAC,CAAC,CACD,cAAc;IACX,OAAO,OAAO,GAAG;IAGjB,IAAI,SAAS,SAAS,CAAC;GAC3B,CAAC;GACL,OAAO,IAAI,GAAG;GACd,OAAO;EACX,CAAC;CACL;;CAGA,SAAS,SAAS,SAAuB;EACrC,IAAI,CAAC,SAAS;EACd,MAAM,MAAM,KAAK,IAAI,IAAI;EACzB,IAAI,SAAS,YAAY,KAAK;EAC9B,IAAI,OAAO,aAAa,KAAK;EAC7B,WAAW;EACX,QAAQ,iBAAiB;GACrB,QAAQ;GACR,WAAW;GACX,KAAU;EACd,GAAG,OAAO;EAGV,MAAM,QAAQ;CAClB;;;;;;CAOA,SAAS,KAAK,SAAwB;EAClC,IAAI,CAAC,SAAS;EACd,iBAAiB;EACjB,MAAM,QAAQ,YAAY,KAAA,IAAY,IAAI,KAAK,IAAI,GAAG,UAAU,KAAK,IAAI,CAAC;EAC1E,IAAI,UAAU,KAAK,OAAO,QAAQ,aAAa;GAE3C,UAAU;GACV;EACJ;EACA,SAAS,KAAK;CAClB;CAEA,eAAe,OAAsB;EACjC,IAAI,CAAC,SAAS;EAGd,IAAI,UAAU;GACV,QAAQ;GACR;EACJ;EACA,MAAM,OAAO,cAAc,CAAC,CAAC,OAAO,UAAU;GAC1C,OAAO,MAAM,sBAAsB,EAAE,MAAM,CAAC;GAC5C,UAAU;EACd,CAAC;EACD,WAAW;EACX,MAAM;EACN,WAAW;EAMX,IAAK,WAAW,OAAO,OAAO,eAAgB,OAAO;GACjD,QAAQ;GACR,SAAS,CAAC;GACV;EACJ;EACA,IAAI,WAAW;GACX,SAAS,aAAa;GACtB;EACJ;EACA,SAAS,cAAc;EACvB,iBAAiB,KAAK,IAAI,iBAAiB,GAAG,aAAa;CAC/D;;CAGA,SAAS,SAAe;EACpB,IAAI,CAAC,MAAM,WAAW;EACtB,MAAM,WACD,YAAY,KAAK,OAAO,SAEnB,KAAK,CACf,CAAC,CAAC,MAAM,SAAS;GACb,IAAI,CAAC,SAAS;IACV,KAAU,CAAC,CAAC,YAAY,KAAA,CAAS;IACjC;GACJ;GACA,cAAc;GACd,YAAY;GACZ,OAAO,KAAK,0DAA0D,gBAAgB,IAAK,UAAU;EACzG,CAAC,CAAC,CAAC,OAAO,UAAmB;GACzB,OAAO,KACH,qCAAqC,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK,EAAE,iCAC7D,eAAe,iBAAiB,gBAAgB,IAAK,aACxF;EACJ,CAAC;CACL;CAEA,eAAe,QAAuB;EAClC,MAAM;EACN,MAAM,QAAQ,WAAW,CAAC,GAAG,MAAM,CAAC;CACxC;CAEA,OAAO;EACH,QAAc;GACV,IAAI,SAAS;GACb,UAAU;GACV,iBAAiB;GACjB,OAAO,KAAK,sCAAsC,YAAY,SAAS,eAAe,YAAY,cAAc,eAAe;GAC/H,IAAI,wBAAwB,KAAA,GACxB,OAAO,KACH,qCAAqC,yBAAyB,IAAO,0FAEzE;GAEJ,KAAU;GACV,YAAY,kBAAkB;IAAE,KAAU;GAAG,GAAG,sBAAsB,oBAAoB;GAC1F,UAAU,QAAQ;GAClB,MAAW;GACX,aAAa,kBAAkB;IAAE,MAAW;GAAG,GAAG,2BAA2B;GAC7E,WAAW,QAAQ;GACnB,OAAO;GACP,SAAS,CAAC;EACd;EAEA,MAAM,KAAK,WAAmC;GAC1C,UAAU;GACV,IAAI,OAAO;IACP,aAAa,KAAK;IAClB,QAAQ;IACR,WAAW;GACf;GACA,YAAY;GACZ,MAAM,gBAAgB;GACtB,cAAc;GACd,IAAI,eACA,MAAM,cAAc,CAAC,CAAC,OAAO,UAAmB;IAC5C,OAAO,KAAK,sDAAsD,EAAE,MAAM,CAAC;GAC/E,CAAC;GAEL,IAAI,WAAW;IACX,cAAc,SAAS;IACvB,YAAY;GAChB;GACA,IAAI,YAAY;IACZ,cAAc,UAAU;IACxB,aAAa;GACjB;GAIA,aAAa,MAAM,CAAC,CAAC,cAAc;IAAE,WAAW;GAAM,CAAC;GACvD,IAAI,cAAc,KAAA,GAAW;IACzB,MAAM;IACN;GACJ;GACA,IAAI;GACJ,MAAM,WAAW,MAAM,QAAQ,KAAK,CAChC,SAAS,WAAW,IAAI,GACxB,IAAI,SAAgB,YAAY;IAC5B,SAAS,iBAAiB,QAAQ,KAAK,GAAG,SAAS;IACnD,OAAO,QAAQ;GACnB,CAAC,CACL,CAAC;GACD,aAAa,MAAM;GACnB,IAAI,CAAC,UACD,OAAO,KACH,UAAU,OAAO,KAAK,8BAA8B,UAAU,0GAElE;EAER;EAEA,MAAM,UAA2B;GAC7B,MAAM,UAAU,MAAM,cAAc;GACpC,MAAM,QAAQ,WAAW,OAAO;GAChC,OAAO,QAAQ;EACnB;EAEA,SAAsB,MAAc,SAA8B;GAC9D,IAAI,SAAS,IAAI,IAAI,GACjB,OAAO,KAAK,gBAAgB,KAAK,iDAAiD;GAEtF,SAAS,IAAI,MAAM,OAA4B;EACnD;EAEA,YAAqB;GACjB,OAAO;EACX;EAEA,MAAM,QAAqB,MAAc,SAAa,iBAAiC,CAAC,GAA2B;GAC/G,MAAM,QAAQ,IAAI,KAAK,KAAK,IAAI,KAAK,eAAe,WAAW,EAAE;GAGjE,MAAM,UAAU,0BAA0B;GAC1C,MAAM,KAAK,MAAM,MAAM,OAAO;IAC1B;IACA,SAAS,WAAW;IACpB;IACA,aAAa,eAAe,eAAe;IAC3C,gBAAgB,eAAe;GACnC,CAAC;GAED,IAAI,OAAO,QAAQ,SACf,IAAI,SAAS,QAAQ,kBAAkB,KAAK,MAAM,QAAQ,CAAC,CAAC;QACvD,KAAK,MAAM,QAAQ,CAAC;GAE7B,OAAO;EACX;CACJ;AACJ;;;;;;;;;;;AClgBA,IAAM,QAAQ;;AAGd,IAAM,2BAA2B;;;;;;AAMjC,IAAM,wBAAwB;;;;;;;AAQ9B,IAAM,kBAAkB;AAgBxB,SAAS,SAAS,KAAwB;CACtC,OAAO;EACH,IAAI,IAAI;EACR,MAAM,IAAI;EACV,SAAS,IAAI;EACb,QAAQ,IAAI;EACZ,OAAO,IAAI,KAAK,IAAI,MAAM,CAAC,CAAC,YAAY;EACxC,UAAU,OAAO,IAAI,QAAQ;EAC7B,aAAa,OAAO,IAAI,YAAY;EACpC,WAAW,IAAI;EACf,WAAW,IAAI,KAAK,IAAI,UAAU,CAAC,CAAC,YAAY;EAChD,WAAW,IAAI,KAAK,IAAI,UAAU,CAAC,CAAC,YAAY;CACpD;AACJ;;AAGA,SAAS,kBAAkB,KAAuB;CAC9C,OAAO,gBAAgB,MAAM,MAAM,EAAE,SAAS,OAAO;AACzD;;;;;;;;;;;;;;;;;;;;;;;AAwBA,IAAM,mBAAmB;AACzB,IAAM,oBAAoB,GAAG,iBAAiB;;;;;;;;AAS9C,IAAa,sBAAsB;;AAGnC,IAAM,kBAAkB,UAAkB,uBAAuB,MAAM;;AAwEvE,IAAM,gBAAgB;AAEtB,SAAS,iBAAiB,IAAY,OAAiB,SAAsC;CACzF,OAAO,KACH,sBAAsB,QAAQ,cAAc,MAAM,QAAQ,UAAU,GAAG,yGAE3E;AACJ;AAEA,SAAgB,eAAe,QAA0C;CACrE,MAAM,QAAQ,OAAO;CACrB,IAAI,CAAC,WAAW,KAAK,GAAG;EACpB,OAAO,KACH,+IAEJ;EACA;CACJ;CAKA,MAAM,WAAoB,SAAS,YAC/B,MAAM,WAAW,SAAS,SAAS,SAAS,EAAE,QAAQ,QAAQ,OAAO,IAAI,KAAA,CAAS;CACtF,MAAM,QAAQ,SAAiB,WAC3B,QAAQ,SAAS,SAAS,EAAE,OAAO,IAAI,KAAA,CAAS;CAEpD,MAAM,MAAM,sBAAsB,SAAS,MAAM;;;;;;;CAQjD,IAAI,uBAAuB;CAE3B,OAAO;EACH,MAAM,cAA6B;GAC/B,MAAM,IAAI,aAAa,0BAA0B,oCAAoC;GAErF,MAAM,IAAI,aAAa,YAAY,SAAS;6CACX,MAAM;;;;;;;;;;;;;;;aAetC;GAKD,MAAM,IAAI,aAAa,8BAA8B;;qBAE5C,MAAM;;aAEd;GAGD,MAAM,IAAI,aAAa,6BAA6B;;qBAE3C,MAAM;;aAEd;GAOD,MAAM,IAAI,aAAa,iCAAiC;;qBAE/C,MAAM;;aAEd;GAMD,MAAM,IAAI,aAAa,8BAA8B;;qBAE5C,MAAM;;aAEd;GAID,IAAI,MAFgB,IAAI,WAAW,KAAK,GAE7B;IAaP,MAAM,IAAI,KAAK,0CACX,KAAK,uBAAuB,UAAU,MAAM,CAAC,CAAC;IAElD,MAAM,IAAI,aAAa,YAAY,oBAAoB;iDACtB,iBAAiB;;;;;;;;4CAQtB,oBAAoB,KAAK,eAAe,UAAU,EAAE;sCAC1D,MAAM;;;;;iBAK3B;IAID,MAAM,IAAI,KAAK,YAAY,iBAAiB,uBAAuB,YAAY;KAC3E,MAAM,KAAK,0BAA0B,kBAAkB,aAAa;KACpE,MAAM,KAAK;;iFAEkD,iBAAiB;4DACtC,kBAAkB,MAAM,iBAAiB;;;qBAGhF;IACL,CAAC;IACD,uBAAuB,YAAkC,MAAM,KAC3D,2BAA2B,kBAAkB,0BACjD,CAAC,CAAC,EAAE,YAAY;IAChB,IAAI,CAAC,sBACD,OAAO,KACH,aAAa,iBAAiB,qHAElC;IAGJ,OAAO,KAAK,yBAAyB;GACzC,OACI,OAAO,MACH,YAAY,MAAM,2HAEtB;EAER;EAEA,MAAM,OAAO,KAA6B;GACtC,MAAM,SAAS;IACX,IAAI;IACJ,KAAK,UAAU,IAAI,WAAW,IAAI;IAClC,IAAI,MAAM,YAAY;IACtB,IAAI;IACJ,IAAI,kBAAkB;GAC1B;GAIA,MAAM,UAAU,uBAAuB,0BAA0B,IAAI,KAAA;GACrE,IAAI,SAKA,QAAQ,MAJW,QAAQ,KACvB,UAAU,iBAAiB,sEAC3B,MACJ,EAAA,GACe,EAAE,EAAE,MAAoC;GAE3D,IAAI;IAaA,QAAQ,MAVW,KACf;uCACmB,MAAM;;;;2DAIyB,eAAe,QAAQ,EAAE;qCAE3E,MACJ,EAAA,GACe,EAAE,EAAE,MAAiB;GACxC,SAAS,OAAO;IAIZ,IAAI,kBAAkB,KAAK,GAAG,OAAO;IACrC,MAAM;GACV;EACJ;EAEA,MAAM,MAAM,OAAe,UAAkB,OAAiD;GAC1F,IAAI,SAAS,MAAM,WAAW,GAAG,OAAO,CAAC;GAGzC,MAAM,aAAa,QACb,gBAAgB,MAAM,KAAK,GAAG,MAAM,IAAI,IAAI,GAAG,CAAC,CAAC,KAAK,IAAI,EAAE,KAC5D;GA6BN,OAAO,QAAgB,MAjBJ,KACf,UAAU,MAAM;;;;;;;sCAOM,MAAM;oEACwB,WAAW;;;;;+BAM/D;IAAC;IAAO;IAAU,GAAI,SAAS,CAAC;GAAE,CACtC,CAC2B,CAAC,CAAC,IAAI,QAAQ;EAC7C;EAEA,MAAM,SAAS,IAAY,OAAgC;GAQvD,IAAI,QAAQ,MAPO,KACf,UAAU,MAAM;;yBAEP,cAAc;gCAEvB;IAAC;IAAI,MAAM;IAAU,MAAM;GAAO,CACtC,CACgB,CAAC,CAAC,WAAW,GAAG,iBAAiB,IAAI,OAAO,SAAS;EACzE;EAEA,MAAM,KAAK,IAAY,OAAe,SAAsB,OAAgC;GAgBxF,IAAI,QAfS,UACP,MAAM,KACJ,UAAU,MAAM;;6BAEP,cAAc;oCAEvB;IAAC;IAAI,MAAM;IAAU,MAAM;IAAS,QAAQ,YAAY;IAAG;GAAK,CACpE,IACE,MAAM,KACJ,UAAU,MAAM;;6BAEP,cAAc;oCAEvB;IAAC;IAAI,MAAM;IAAU,MAAM;IAAS;GAAK,CAC7C,CACY,CAAC,CAAC,WAAW,GAAG,iBAAiB,IAAI,OAAO,SAAS;EACzE;EAEA,MAAM,UAAU,IAAY,OAAmC;GAO3D,OAAO,QAAQ,MANI,KACf,UAAU,MAAM;yBACP,cAAc;gCAEvB;IAAC;IAAI,MAAM;IAAU,MAAM;GAAO,CACtC,CACmB,CAAC,CAAC,SAAS;EAClC;EAEA,MAAM,YAAY,qBAA8C;GAM5D,MAAM,UAAU,KAAK,IAAI,GAAG,KAAK,MAAM,sBAAsB,GAAI,CAAC;GAElE,MAAM,UAAU,MAAM,KAClB,UAAU,MAAM;;;;;gCAMhB,CAAC,OAAO,CACZ;GAEA,MAAM,SAAS,MAAM,KACjB,UAAU,MAAM;;;;;gCAMhB,CAAC,OAAO,CACZ;GAEA,MAAM,SAAS,SAAS,UAAU,MAAM,QAAQ,UAAU;GAC1D,IAAI,QAAQ,GACR,OAAO,KACH,oBAAoB,MAAM,iDACtB,SAAS,UAAU,EAAE,cAAc,QAAQ,UAAU,EAAE,gBAC/D;GAEJ,OAAO;EACX;EAEA,MAAM,eAAgC;GAIlC,IAAI,QAAQ;GACZ,SAAS;IACL,MAAM,OAAO,MAAM,KACf;uCACmB,MAAM;;8CAEC,MAAM;;;;;;;;kEAShC;KAAC;KAA0B;KAAuB;IAAe,CACrE;IACA,MAAM,UAAU,OAAO,YAAiC,IAAI,CAAC,EAAE,WAAW,CAAC;IAC3E,SAAS;IACT,IAAI,UAAU,iBAAiB,OAAO;GAC1C;EACJ;EAEA,MAAM,UAAU,QAAoC,aAAuD;GACvG,IAAI,CAAC,MAAM,QACP,MAAM,IAAI,MAAM,2CAA2C;GAE/D,OAAO,MAAM,OAAO,sBAAsB,YAAY;IAClD,MAAM,UAAU,OAAO,OAAO;IAC9B,OAAO,OAAO,SAAS,OAAO,KAAK,UAAU,IAAI,UAAU,KAAA,CAAS;GACxE,GAAG,EAAE,YAAY,CAAC;EACtB;EAEA,MAAM,MAAM,IAAuC;GAE/C,MAAM,MAAM,YAAoB,MADb,KAAK,iBAAiB,MAAM,iBAAiB,CAAC,EAAE,CAAC,CAChC;GACpC,OAAO,MAAM,SAAS,GAAG,IAAI;EACjC;CACJ;AACJ"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"logger-Bt32sHFw.js","names":[],"sources":["../src/utils/host.ts","../src/utils/logger.ts"],"sourcesContent":["/**\n * The host globals this package reads, behind functions that do not assume Node.\n *\n * `process` is not defined on workerd, on Deno Deploy without the compat flag,\n * or in a browser. That matters here for one specific reason: the portable\n * authoring surface (`@rebasepro/server/functions`) reaches the logger and the\n * error handler, and a bare `process.env.NODE_ENV` inside either of them turns\n * the first log line of a request into a `ReferenceError` on a runtime that has\n * no `process` — a failure that reads as \"the framework crashed\" rather than\n * \"this runtime has no process object\".\n *\n * Nothing here throws and nothing here is async. A runtime that cannot answer\n * gets the empty answer, because every caller in this file's blast radius is\n * choosing a log level or a format, and the safe default for both is the\n * development one.\n *\n * @module\n */\n\n/**\n * Where an adapter with no `process` can publish the environment.\n *\n * Cloudflare Workers hand the environment to the *request*, not to the module,\n * so there is no global to read at import time. An edge adapter that has\n * already seen a request can stash the bag here and every contextless reader in\n * the framework — the logger, chiefly — starts answering correctly.\n *\n * `Symbol.for` rather than a module-local for the same reason the singleton\n * uses it: more than one copy of this module can be loaded into one process,\n * and a module-local would leave every copy but the writer's blind. See\n * `../singleton.ts`.\n *\n * Request-scoped code should NOT read this. Use `getEnv(c)` from\n * `@rebasepro/server/functions`, which reads the binding attached to the\n * request it is serving — the only correct source on a runtime where two\n * concurrent requests can carry different bindings.\n */\nconst ENV_SLOT = Symbol.for(\"@rebasepro/server:host-env\");\n\ntype GlobalWithEnv = typeof globalThis & {\n [ENV_SLOT]?: Record<string, string | undefined>;\n process?: { env?: Record<string, string | undefined> };\n};\n\n/**\n * The process environment, or the closest thing this runtime has to one.\n *\n * Order: a bag published by {@link setHostEnv} first, because an adapter that\n * set one knows more than the ambient globals do; then `process.env`; then\n * nothing.\n */\nexport function hostEnv(): Record<string, string | undefined> {\n const global = globalThis as GlobalWithEnv;\n return global[ENV_SLOT] ?? global.process?.env ?? {};\n}\n\n/**\n * Read one environment variable without touching `process` directly.\n *\n * Trimmed, and blank is treated as absent — a variable declared with no value\n * is the ordinary way to write a compose file or a `.env` line, and every\n * caller in this package means \"unset\" by it. See `resolveFunctionsTimeoutMs`,\n * which learned that the hard way.\n */\nexport function hostEnvVar(name: string): string | undefined {\n const raw = hostEnv()[name];\n if (typeof raw !== \"string\") return undefined;\n const trimmed = raw.trim();\n return trimmed === \"\" ? undefined : trimmed;\n}\n\n/**\n * Publish an environment bag for contextless readers.\n *\n * Called by an adapter for a runtime whose environment is not ambient. Merges\n * rather than replaces, so two adapters (or an adapter plus a test) do not\n * silently erase each other's variables.\n */\nexport function setHostEnv(env: Record<string, string | undefined>): void {\n const global = globalThis as GlobalWithEnv;\n global[ENV_SLOT] = {\n ...(global[ENV_SLOT] ?? {}),\n ...env\n };\n}\n\n/** @internal Test seam — drops anything {@link setHostEnv} published. */\nexport function _clearHostEnv(): void {\n delete (globalThis as GlobalWithEnv)[ENV_SLOT];\n}\n\ntype GlobalWithStdio = typeof globalThis & {\n process?: {\n stdout?: { write?: (chunk: string) => unknown };\n stderr?: { write?: (chunk: string) => unknown };\n };\n};\n\n/**\n * Write one already-formatted line to the process's output.\n *\n * `process.stdout.write` is preferred where it exists because it is the only\n * one of the two that does not append its own formatting to a line that is\n * already a complete JSON document — `console.log` on Node is\n * `process.stdout.write` plus `util.format`, and `util.format` will happily\n * reinterpret a `%s` that appeared inside a user's log message.\n *\n * Where it does not exist, `console` is the runtime's log sink and is what its\n * platform collects.\n */\nexport function writeLine(stream: \"out\" | \"err\", line: string): void {\n const proc = (globalThis as GlobalWithStdio).process;\n const sink = stream === \"err\" ? proc?.stderr : proc?.stdout;\n if (typeof sink?.write === \"function\") {\n sink.write(line + \"\\n\");\n return;\n }\n if (stream === \"err\") console.error(line);\n else console.log(line);\n}\n","/**\n * Structured Logger for Rebase Backend\n *\n * Outputs JSON lines when `NODE_ENV=production`, human-readable prefixed\n * lines otherwise. Designed to work with Google Cloud Logging severity levels.\n *\n * Every line — message and data, at any depth — passes through the redaction\n * below, which strips Drizzle's `Failed query: … / params: …` wrapper and the\n * values of secret-looking keys. See the block above `serialiseError`.\n *\n * Usage:\n * import { logger } from \"./utils/logger\";\n * logger.info(\"Server started\", { port: 3001 });\n * logger.error(\"Request failed\", { path: \"/api/test\", error: err });\n *\n * Every host global goes through `./host`, and that is load-bearing rather than\n * tidy: this module is reachable from `@rebasepro/server/functions`, the\n * authoring surface that has to import cleanly on a runtime with no `process`.\n * A bare `process.env.NODE_ENV` here would make the first log line of the first\n * request on workerd a `ReferenceError`.\n */\nimport { hostEnv, writeLine } from \"./host\";\n\nexport type LogLevel = \"debug\" | \"info\" | \"warn\" | \"error\";\n\n/** Google Cloud Logging severity strings. */\nconst GCP_SEVERITY: Record<LogLevel, string> = {\n debug: \"DEBUG\",\n info: \"INFO\",\n warn: \"WARNING\",\n error: \"ERROR\"\n};\n\nconst LOG_PRIORITY: Record<LogLevel, number> = {\n debug: 0,\n info: 1,\n warn: 2,\n error: 3\n};\n\nexport interface LogEntry {\n severity: string;\n message: string;\n timestamp: string;\n [key: string]: unknown;\n}\n\nexport interface Logger {\n debug(message: string, data?: Record<string, unknown>): void;\n info(message: string, data?: Record<string, unknown>): void;\n warn(message: string, data?: Record<string, unknown>): void;\n error(message: string, data?: Record<string, unknown>): void;\n child(defaultFields: Record<string, unknown>): Logger;\n}\n\nfunction isProduction(): boolean {\n return hostEnv().NODE_ENV === \"production\";\n}\n\n/**\n * An explicit level from `config.logging.level`, when a project set one.\n *\n * Outranks `LOG_LEVEL` because it is the more specific statement: an\n * environment variable is the deployment's default, and this is the\n * application saying what it wants regardless of where it runs.\n *\n * There used to be a second, separate mechanism for this — `utils/logging.ts`\n * reassigned `console.debug`/`console.log`/`console.warn` to no-ops — and the\n * two disagreed in a way nobody could have guessed from either: `LOG_LEVEL=warn`\n * silenced this logger's info lines *and* every `console.log` in the process,\n * including a dependency's, including a project's own debugging. It also could\n * not be undone, because the originals were gone.\n */\nlet configuredLevel: LogLevel | undefined;\n\n/**\n * Set the level from configuration. `undefined` returns to `LOG_LEVEL`.\n *\n * Read per line rather than captured at construction, so a logger created\n * before configuration is read still honours it — which the singleton below\n * always is.\n */\nexport function setLogLevel(level?: LogLevel): void {\n configuredLevel = level;\n}\n\n/** Every line to stderr, when a command's stdout is its result. See {@link setLogOutput}. */\nlet everyLineToStderr = false;\n\n/**\n * Send every log line to stderr — `\"stderr\"` — or back to the default with\n * `undefined`, where errors (and in development, warnings) go to stderr and\n * the rest to stdout.\n *\n * For a process whose stdout is its answer: `rebase console --eval` prints a\n * value for `jq` to read, and a log line on stdout would be part of it.\n */\nexport function setLogOutput(output?: \"stderr\"): void {\n everyLineToStderr = output === \"stderr\";\n}\n\nfunction getMinLevel(): LogLevel {\n if (configuredLevel) return configuredLevel;\n const env = (hostEnv().LOG_LEVEL || \"info\").trim().toLowerCase();\n if (env in LOG_PRIORITY) return env as LogLevel;\n return \"info\";\n}\n\n// ── Redaction ───────────────────────────────────────────────────────\n//\n// Drizzle builds every query failure as\n// `Failed query: ${query}\\nparams: ${params}` (drizzle-orm/errors.js), so the\n// statement *and* every bound value ride along in `.message` and `.stack` of\n// whatever a driver rethrows — an email and a bcrypt hash reach stdout the\n// moment a registration hits a unique violation. The redaction lives here, in\n// the one function every log line passes through, rather than at the ~124\n// `{ error: … }` call sites: a per-site rule is what produced the leak (one\n// file suppressed the stack, four others did not), and the next caller would\n// reintroduce it. Nothing above this line needs to know about it.\n\nconst FAILED_QUERY_MARKER = \"Failed query:\";\n/**\n * The marker says how to lift it.\n *\n * Every DDL, RLS and CDC failure ends at this string, and the statement is the\n * whole diagnosis — three of them landed in one boot of a two-database project,\n * each a dead end. The switch existed; nothing named it, in the log or in the\n * docs, so `grep -rn REBASE_LOG_RAW_QUERIES` over the documentation, the\n * templates and the agent skills came back empty.\n */\nconst REDACTED_QUERY =\n \"Failed query: [redacted — set REBASE_LOG_RAW_QUERIES=true in development to see it]\";\nconst REDACTED_VALUE = \"[redacted]\";\n\n/**\n * Key fragments whose values are never safe to publish. Compared against the\n * key with separators and case removed, so `api_key`, `apiKey` and `API-KEY`\n * all match `apikey`.\n */\nconst SENSITIVE_KEY_FRAGMENTS = [\n \"password\",\n \"passwd\",\n \"passphrase\",\n \"secret\",\n \"token\",\n \"apikey\",\n \"authorization\",\n \"credential\",\n \"cookie\",\n \"privatekey\",\n \"sessionid\"\n];\n\n/** Longest structure the redactor will walk before giving up. */\nconst MAX_REDACT_DEPTH = 8;\n\nfunction isSensitiveKey(key: string): boolean {\n const normalised = key.toLowerCase().replace(/[^a-z0-9]/g, \"\");\n return SENSITIVE_KEY_FRAGMENTS.some(fragment => normalised.includes(fragment));\n}\n\n/**\n * Whether a SQL statement may be written out at all.\n *\n * The escape hatch for the `Failed query:` strip — the statement is the fastest\n * way to diagnose a failing query on a developer machine. Ignored in\n * production, so a runtime that inherits the variable cannot leak because of\n * it, and it never re-enables the key deny-list.\n *\n * Exported because it is the *only* answer to \"may this process print SQL\", and\n * a driver that wants to trace what it executes has to ask the same question.\n * The Postgres driver used to decide for itself, with a `console.debug` gated\n * on `NODE_ENV` alone: every statement went to stdout whatever `LOG_LEVEL`\n * said, and it went there without passing through the redaction that lives in\n * this file.\n */\nexport function rawQueryLoggingEnabled(): boolean {\n return hostEnv().NODE_ENV !== \"production\"\n && hostEnv().REBASE_LOG_RAW_QUERIES === \"true\";\n}\n\n/**\n * Strip every `Failed query: … / params: …` span out of a message or stack,\n * keeping the surrounding text (including stack frames, which carry no user\n * data). When no `params:` line follows the marker the rest of the string is\n * dropped: a statement of unknown extent is treated as sensitive rather than\n * guessed at.\n *\n * Idempotent, and it has to be: an already-redacted span still starts with the\n * marker but has no `params:` line, so a second pass over it would fall into\n * the drop-the-rest branch and eat the stack frames behind it. Redaction runs\n * more than once on the same string in practice — the cron scheduler redacts\n * before persisting and then logs the result.\n */\nexport function redactSensitiveText(text: string): string {\n if (!text.includes(FAILED_QUERY_MARKER) || rawQueryLoggingEnabled()) return text;\n\n let out = text;\n let idx = out.indexOf(FAILED_QUERY_MARKER);\n while (idx !== -1) {\n if (out.startsWith(REDACTED_QUERY, idx)) {\n idx = out.indexOf(FAILED_QUERY_MARKER, idx + REDACTED_QUERY.length);\n continue;\n }\n const paramsIdx = out.indexOf(\"\\nparams:\", idx);\n let end: number;\n if (paramsIdx === -1) {\n end = out.length;\n } else {\n const eol = out.indexOf(\"\\n\", paramsIdx + 1);\n end = eol === -1 ? out.length : eol;\n }\n out = out.slice(0, idx) + REDACTED_QUERY + out.slice(end);\n idx = out.indexOf(FAILED_QUERY_MARKER, idx + REDACTED_QUERY.length);\n }\n return out;\n}\n\n/**\n * Diagnostic own-properties worth carrying up out of an error.\n *\n * These are what a socket failure actually says: `ECONNREFUSED` with the\n * `address` and `port` it was refused on, `ENOTFOUND` with the hostname that\n * did not resolve. They live as own-properties on the Node error rather than in\n * its message, so a serialiser that copies only `message` and `stack` prints a\n * boot failure that names no host, no port and no reason.\n *\n * Deliberately a fixed list rather than \"every own-property\": `DrizzleQueryError`\n * carries `query` and `params` beside its message, and copying those would put\n * the statement and its bound values — an email, a bcrypt hash — straight back\n * on stdout, which is what the redaction above exists to prevent. Postgres's own\n * `detail` and `hint` are left out for the same reason: `23505` reports\n * `Key (email)=(a@b.c) already exists.`, which is a row's contents.\n */\nconst ERROR_DETAIL_KEYS = [\"code\", \"errno\", \"syscall\", \"address\", \"port\", \"hostname\"] as const;\n\n/** How far the cause chain is followed before the serialiser gives up. */\nconst MAX_CAUSE_DEPTH = 4;\n\n/** How many of an `AggregateError`'s children are serialised. */\nconst MAX_AGGREGATE_ERRORS = 4;\n\n/**\n * Serialise an Error into a plain object, with the query text redacted out of\n * its message and stack. `query`/`params` own-properties — which\n * `DrizzleQueryError` carries beside the message — are deliberately not copied.\n *\n * The chain matters more than the top. Everything a driver rethrows is a\n * wrapper: Drizzle's is `Failed query: SELECT 1` with a stack through drizzle\n * internals, and the sentence that says what is wrong — `connect ECONNREFUSED\n * 127.0.0.1:5432`, `password authentication failed for user \"app\"` — sits in\n * `.cause`, or inside the `AggregateError.errors` that `net` raises when every\n * resolved address is refused. Serialising only the wrapper is why a boot\n * against a stopped database used to log a redacted query and nothing else.\n *\n * Handles non-Error values gracefully.\n */\nfunction serialiseError(value: unknown, depth = 0): Record<string, unknown> {\n const isError = value instanceof Error;\n // A cause is not always an Error: drivers throw plain `{ code, address,\n // port }` bags, and stringifying one yields `[object Object]`, which is\n // worse than nothing. Only the named fields are copied out of it — the same\n // fixed list, for the same reason.\n const isDetailBag = !isError && depth > 0 && Boolean(value) && typeof value === \"object\" && !Array.isArray(value);\n if (!isError && !isDetailBag) {\n return { value: redactSensitiveText(String(value)) };\n }\n\n const own = value as Record<string, unknown>;\n const out: Record<string, unknown> = isError\n ? {\n name: (value as Error).name,\n message: redactSensitiveText((value as Error).message),\n stack: (value as Error).stack ? redactSensitiveText((value as Error).stack as string) : undefined\n }\n : {\n ...(typeof own.name === \"string\" ? { name: own.name } : {}),\n ...(typeof own.message === \"string\" ? { message: redactSensitiveText(own.message) } : {})\n };\n\n for (const key of ERROR_DETAIL_KEYS) {\n const detail = own[key];\n if (detail === undefined || detail === null) continue;\n if (typeof detail === \"object\") continue;\n out[key] = typeof detail === \"string\" ? redactSensitiveText(detail) : detail;\n }\n\n if (depth >= MAX_CAUSE_DEPTH) return out;\n\n if (own.cause !== undefined && own.cause !== null) {\n out.cause = serialiseError(own.cause, depth + 1);\n }\n const aggregated = own.errors;\n if (Array.isArray(aggregated) && aggregated.length > 0) {\n out.errors = aggregated\n .slice(0, MAX_AGGREGATE_ERRORS)\n .map(item => serialiseError(item, depth + 1));\n }\n return out;\n}\n\n/**\n * Redact one logged value: errors are serialised, strings are stripped of\n * query text, objects and arrays are walked. Cycles and over-deep structures\n * collapse to a marker rather than throwing — a logger that can fail is worse\n * than one that logs less. (An object referenced twice in one payload is\n * reported as `[circular]` the second time; bounding the walk matters more\n * than rendering a shared reference twice.)\n */\nfunction redactValue(value: unknown, depth: number, seen: WeakSet<object>): unknown {\n // `serialiseError` returns only already-redacted strings, so it is the\n // terminal step — walking its output again would redact twice.\n if (value instanceof Error) return serialiseError(value);\n if (typeof value === \"string\") return redactSensitiveText(value);\n if (value === null || typeof value !== \"object\") return value;\n if (depth >= MAX_REDACT_DEPTH) return \"[truncated]\";\n if (seen.has(value)) return \"[circular]\";\n seen.add(value);\n\n if (Array.isArray(value)) {\n return value.map(item => redactValue(item, depth + 1, seen));\n }\n if (value instanceof Date) return value;\n\n const out: Record<string, unknown> = {};\n for (const [key, val] of Object.entries(value as Record<string, unknown>)) {\n out[key] = isSensitiveKey(key) ? REDACTED_VALUE : redactValue(val, depth + 1, seen);\n }\n return out;\n}\n\nfunction formatData(data?: Record<string, unknown>): Record<string, unknown> | undefined {\n if (!data) return undefined;\n const seen = new WeakSet<object>();\n const out: Record<string, unknown> = {};\n for (const [key, val] of Object.entries(data)) {\n out[key] = isSensitiveKey(key) ? REDACTED_VALUE : redactValue(val, 0, seen);\n }\n return out;\n}\n\n/**\n * `JSON.stringify`, with the redaction a structured field gets.\n *\n * For a value that has to go *into the message*. Once an object is a string,\n * `emit` can only strip `Failed query:` spans out of it: a sensitive key would\n * pass, and so would the `query` and `params` own-properties `DrizzleQueryError`\n * carries beside its message, which `JSON.stringify` copies and the message\n * redaction never sees. Unlike the structured walk this honours `toJSON`, since\n * the result is read as text. Never throws: a value that cannot be stringified\n * is a marker, not a failed log line.\n */\nexport function stringifyForLog(value: unknown): string | undefined {\n try {\n return JSON.stringify(value, (key, val: unknown) => {\n if (isSensitiveKey(key)) return REDACTED_VALUE;\n if (val instanceof Error) return serialiseError(val);\n if (typeof val === \"string\") return redactSensitiveText(val);\n return val;\n });\n } catch {\n return \"[unserialisable]\";\n }\n}\n\n/**\n * Something that wants a copy of every line this logger writes.\n *\n * Receives the message and fields *after* redaction, never before: a sink is\n * another destination for the same line, and the one thing that must not vary\n * by destination is whether the query and its bound values are in it.\n */\nexport type LogSink = (\n level: LogLevel,\n message: string,\n data: Record<string, unknown>\n) => void;\n\nconst sinks = new Set<LogSink>();\n\n/**\n * Tee this logger somewhere else. Returns the unsubscribe.\n *\n * The Studio's Logs Explorer is the caller: its ring buffer used to be fed only\n * by a request middleware, so the panel showed a wall of `GET … 200` and not one\n * of the errors, warnings or diagnoses the server was writing to stdout at the\n * same moment. A log viewer that cannot show you an error is a log viewer\n * nobody opens twice.\n *\n * A sink MUST NOT log. It is called from inside `emit`, so anything that comes\n * back through `logger` recurses; the guard below stops the stack blowing, but\n * the line is dropped rather than delivered, which is its own bug.\n */\nexport function addLogSink(sink: LogSink): () => void {\n sinks.add(sink);\n return () => { sinks.delete(sink); };\n}\n\n/** Re-entrancy guard: see `addLogSink`. */\nlet inSink = false;\n\nfunction fanOut(level: LogLevel, message: string, data: Record<string, unknown>): void {\n if (sinks.size === 0 || inSink) return;\n inSink = true;\n try {\n for (const sink of sinks) {\n // One broken sink must not take down the line, nor the request that\n // was writing it.\n try { sink(level, message, data); } catch { /* a broken tee is not the caller's problem */ }\n }\n } finally {\n inSink = false;\n }\n}\n\nfunction createLogger(rawDefaultFields: Record<string, unknown> = {}): Logger {\n // Child fields go through the same pass as per-call data — they are merged\n // into every line this logger emits, so leaving them raw would be a hole\n // the moment `child()` gets its first caller.\n const defaultFields = formatData(rawDefaultFields) ?? {};\n\n function emit(level: LogLevel, message: string, data?: Record<string, unknown>): void {\n // Per line, not captured at construction: the singleton is created when\n // this module is first imported, which is long before a project's\n // `config.logging.level` has been read.\n if (LOG_PRIORITY[level] < LOG_PRIORITY[getMinLevel()]) return;\n\n // The message is redacted too, not just the data: several call sites\n // interpolate `error.message` straight into the line they log.\n const safeMessage = redactSensitiveText(message);\n const merged = { ...defaultFields,\n...formatData(data) };\n\n // Before the write, so a sink still sees the line if stdout is the\n // thing that is broken.\n fanOut(level, safeMessage, merged);\n\n if (isProduction()) {\n // Structured JSON for Cloud Logging\n const entry: LogEntry = {\n severity: GCP_SEVERITY[level],\n message: safeMessage,\n timestamp: new Date().toISOString(),\n ...merged\n };\n const line = JSON.stringify(entry);\n\n if (level === \"error\" || everyLineToStderr) {\n writeLine(\"err\", line);\n } else {\n writeLine(\"out\", line);\n }\n } else {\n // Human-readable for development\n const prefix = level === \"error\" ? \"❌\"\n : level === \"warn\" ? \"⚠️\"\n : level === \"info\" ? \"ℹ️\"\n : \"🐛\";\n const extra = Object.keys(merged).length > 0 ? ` ${JSON.stringify(merged)}` : \"\";\n const out = `${prefix} [${level.toUpperCase()}] ${safeMessage}${extra}`;\n\n if (level === \"error\" || everyLineToStderr) {\n console.error(out);\n } else if (level === \"warn\") {\n console.warn(out);\n } else {\n console.log(out);\n }\n }\n }\n\n return {\n debug: (msg, data) => emit(\"debug\", msg, data),\n info: (msg, data) => emit(\"info\", msg, data),\n warn: (msg, data) => emit(\"warn\", msg, data),\n error: (msg, data) => emit(\"error\", msg, data),\n child(fields: Record<string, unknown>): Logger {\n return createLogger({ ...defaultFields,\n...fields });\n }\n };\n}\n\n/**\n * Singleton logger instance.\n * In production: emits JSON lines with `severity`, `message`, `timestamp`.\n * In development: emits human-readable prefixed lines.\n */\nexport const logger: Logger = createLogger();\n\n/**\n * The cause chain, one readable line per link.\n *\n * `serialiseError` puts the chain in the structured payload, which is the right\n * place for a log aggregator and the wrong place for a person staring at a\n * container that will not start: the sentence they need is inside a JSON blob\n * behind an escaped stack trace. This renders the same chain as lines to print\n * beside the headline, so the first thing on screen after \"Failed to start\" is\n * `caused by: connect ECONNREFUSED 127.0.0.1:5432 (ECONNREFUSED)`.\n *\n * Redacted like everything else, and bounded by the same depth: a chain is\n * usually two links and never usefully more than four.\n */\nexport function describeCauseChain(error: unknown): string[] {\n const lines: string[] = [];\n const seen = new Set<unknown>();\n\n const walk = (value: unknown, depth: number): void => {\n if (depth > MAX_CAUSE_DEPTH || value === undefined || value === null) return;\n if (typeof value === \"object\") {\n if (seen.has(value)) return;\n seen.add(value);\n }\n if (depth > 0) {\n const described = describeOneCause(value);\n if (described) lines.push(`caused by: ${described}`);\n }\n if (typeof value !== \"object\") return;\n const own = value as Record<string, unknown>;\n walk(own.cause, depth + 1);\n const aggregated = own.errors;\n if (Array.isArray(aggregated)) {\n for (const item of aggregated.slice(0, MAX_AGGREGATE_ERRORS)) walk(item, depth + 1);\n }\n };\n\n walk(error, 0);\n return lines;\n}\n\n/** One cause rendered as `message (CODE) address:port`, or nothing to say. */\nfunction describeOneCause(value: unknown): string | undefined {\n if (value === null || typeof value !== \"object\") {\n const text = redactSensitiveText(String(value));\n return text || undefined;\n }\n const own = value as Record<string, unknown>;\n const message = typeof own.message === \"string\" && own.message\n ? redactSensitiveText(own.message)\n : undefined;\n const code = typeof own.code === \"string\" ? own.code : undefined;\n // Only when the message does not already carry it. Node writes\n // `connect ECONNREFUSED 127.0.0.1:5432` and also sets `address`/`port`, and\n // repeating the endpoint reads like two different facts.\n const endpoint = own.address !== undefined && own.port !== undefined\n ? `${String(own.address)}:${String(own.port)}`\n : undefined;\n const where = endpoint && !(message ?? \"\").includes(endpoint) ? endpoint : undefined;\n const parts = [message ?? code, code && message ? `(${code})` : undefined, where]\n .filter(Boolean);\n return parts.length > 0 ? parts.join(\" \") : undefined;\n}\n"],"mappings":"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"}
|