@rebasepro/server 0.25.1 → 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-CRd9bFrc.js → GCSStorageController-40FyFeSx.js} +6 -2
- package/dist/GCSStorageController-40FyFeSx.js.map +1 -0
- package/dist/{S3StorageController-D00EFa5P.js → S3StorageController-Bl5shp94.js} +6 -2
- 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/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-Dkvo2tTJ.js → auth-izI7AEzC.js} +370 -1880
- 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/create-backend.d.ts +6 -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-B9m3cb3X.js → backup-BVbHoKo8.js} +6 -6
- package/dist/{backup-B9m3cb3X.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-CsFXW7j4.js → contract-routes-CU2A3JnF.js} +2 -2
- package/dist/{contract-routes-CsFXW7j4.js.map → contract-routes-CU2A3JnF.js.map} +1 -1
- package/dist/cron/cron-store.d.ts +8 -1
- package/dist/{cron-loader-Dj6lA4HQ.js → cron-loader-mMhqbVsl.js} +2 -2
- package/dist/{cron-loader-Dj6lA4HQ.js.map → cron-loader-mMhqbVsl.js.map} +1 -1
- package/dist/{cron-routes-CwbLZguO.js → cron-routes-Cs33j_Kr.js} +3 -3
- package/dist/{cron-routes-CwbLZguO.js.map → cron-routes-Cs33j_Kr.js.map} +1 -1
- package/dist/{cron-scheduler-B04ACoUE.js → cron-scheduler-Be7CNghL.js} +2 -2
- package/dist/{cron-scheduler-B04ACoUE.js.map → cron-scheduler-Be7CNghL.js.map} +1 -1
- package/dist/{cron-store-DkyfuLOH.js → cron-store-DLn11M8H.js} +10 -9
- 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-Ck20GSIX.js → ddl-bootstrap-CjRrn12l.js} +91 -2
- 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/{soft-delete-params-BmlVwecC.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-KKDqTcIr.js → function-loader-B8YzXEsQ.js} +21 -14
- package/dist/function-loader-B8YzXEsQ.js.map +1 -0
- package/dist/functions/hono-like.d.ts +12 -0
- package/dist/functions/index.js +19 -1
- 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-B2rkuhuy.js → history-recorder-Bsau7h4X.js} +6 -6
- 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 +2449 -543
- 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-h1WRq3cp.js → jobs-Co6Ni5HP.js} +6 -6
- package/dist/jobs-Co6Ni5HP.js.map +1 -0
- package/dist/{jwt-D3oFauZI.js → jwt-rDS0884_.js} +4 -3
- package/dist/{jwt-D3oFauZI.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/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-BjHr6upl.js → openapi-generator-CyxEtYEX.js} +61 -22
- package/dist/openapi-generator-CyxEtYEX.js.map +1 -0
- package/dist/{proxy-CH8Jsovg.js → proxy-BYir8tWK.js} +4 -6
- package/dist/proxy-BYir8tWK.js.map +1 -0
- package/dist/{query-parser-otFZqJsC.js → query-parser-CDXWf2hH.js} +6 -5
- package/dist/{query-parser-otFZqJsC.js.map → query-parser-CDXWf2hH.js.map} +1 -1
- package/dist/{request-timeout-59crS-9u.js → request-timeout-DVjo-c_8.js} +4 -4
- 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-Bsut0bCC.js → schema-editor-routes-Dc3DU_jm.js} +3 -3
- package/dist/{schema-editor-routes-Bsut0bCC.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-DmB_uZ4l.js → sql-export-routes-QycX8W8S.js} +38 -4
- 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-Bgo626uY.js.map +1 -1
- package/package.json +5 -5
- package/dist/GCSStorageController-CRd9bFrc.js.map +0 -1
- package/dist/S3StorageController-D00EFa5P.js.map +0 -1
- package/dist/auth-Dkvo2tTJ.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-DkyfuLOH.js.map +0 -1
- package/dist/ddl-bootstrap-Ck20GSIX.js.map +0 -1
- package/dist/function-loader-KKDqTcIr.js.map +0 -1
- package/dist/history-recorder-B2rkuhuy.js.map +0 -1
- package/dist/history-store-DPofGVDp.js.map +0 -1
- package/dist/jobs-h1WRq3cp.js.map +0 -1
- package/dist/offline-BrqCRgih-BtDB1ecy.js.map +0 -1
- package/dist/openapi-generator-BjHr6upl.js.map +0 -1
- package/dist/proxy-CH8Jsovg.js.map +0 -1
- package/dist/request-timeout-59crS-9u.js.map +0 -1
- package/dist/soft-delete-params-BmlVwecC.js.map +0 -1
- package/dist/sql-export-routes-DmB_uZ4l.js.map +0 -1
- package/dist/strings.es-BeZWAodn.js.map +0 -1
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
import { createRequire as __rebaseCreateRequire } from "module";
|
|
2
|
-
import __rebaseProcess from "process";
|
|
3
|
-
globalThis.process ??= __rebaseProcess;
|
|
4
|
-
__rebaseCreateRequire(import.meta.url);
|
|
5
|
-
//#region ../types/src/types/auth_adapter.ts
|
|
6
|
-
/**
|
|
7
|
-
* The request header that asks for a data-plane request to run as another
|
|
8
|
-
* user, named by uid.
|
|
9
|
-
*
|
|
10
|
-
* The server honours it only on a signed-in administrator's own session —
|
|
11
|
-
* never on an API key or the service key — and only through an adapter that
|
|
12
|
-
* implements {@link AuthAdapter.resolveUser}. Everywhere else a request that
|
|
13
|
-
* carries it is refused, never run as its caller.
|
|
14
|
-
*
|
|
15
|
-
* @group Auth
|
|
16
|
-
*/
|
|
17
|
-
var IMPERSONATE_HEADER = "x-rebase-impersonate";
|
|
18
|
-
//#endregion
|
|
19
|
-
export { IMPERSONATE_HEADER as t };
|
|
20
|
-
|
|
21
|
-
//# sourceMappingURL=auth_adapter-CIItCTc8.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"auth_adapter-CIItCTc8.js","names":[],"sources":["../../types/src/types/auth_adapter.ts"],"sourcesContent":["/**\n * @module AuthAdapter\n *\n * Pluggable authentication abstraction for Rebase.\n *\n * An `AuthAdapter` decouples authentication from the database layer,\n * allowing users to bring their own auth system (Clerk, Auth0, or other\n * external providers) while keeping the Rebase admin frontend fully functional.\n *\n * @example Built-in auth (default — zero config change)\n * ```ts\n * initializeRebaseBackend({\n * auth: { jwtSecret: \"...\", google: { clientId: \"...\" } },\n * database: createPostgresAdapter({ ... }),\n * });\n * ```\n *\n * @example Custom auth\n * ```ts\n * import { createCustomAuthAdapter } from \"@rebasepro/server\";\n *\n * initializeRebaseBackend({\n * auth: createCustomAuthAdapter({\n * verifyRequest: async (req) => { ... },\n * }),\n * database: createPostgresAdapter({ ... }),\n * });\n * ```\n *\n * @group Auth\n */\n\n/**\n * A sub-app an adapter hands back for the framework to mount.\n *\n * Structural on purpose. This was `Hono<any, any, any>`, and that one\n * `import type { Hono } from \"hono\"` was the whole reason `@rebasepro/types`\n * peer-depended on hono — which npm and pnpm auto-install, so **every browser\n * app that installed `@rebasepro/client` got a 2.8 MB server framework in its\n * lockfile and its security scanners**, for a type used by two optional methods\n * that a browser app can never call.\n *\n * The shape is the same duck-type the function loader already validates at\n * runtime (`isHonoLike`: a `fetch` method and a `routes` array), and a real\n * `Hono` satisfies it — asserted in `auth_adapter.test.ts`, which does import\n * hono, because a structural type is only worth having if something proves the\n * real thing still fits.\n *\n * @group Auth\n */\nexport interface MountableRouter {\n /** Handle a request. Hono, Elysia and a hand-rolled router all have this. */\n fetch: (request: Request) => Response | Promise<Response>;\n /** The routes the sub-app declares. Only its presence is contractual. */\n routes: readonly unknown[];\n}\n\n// ─── Impersonation ───────────────────────────────────────────────────────────\n\n/**\n * The request header that asks for a data-plane request to run as another\n * user, named by uid.\n *\n * The server honours it only on a signed-in administrator's own session —\n * never on an API key or the service key — and only through an adapter that\n * implements {@link AuthAdapter.resolveUser}. Everywhere else a request that\n * carries it is refused, never run as its caller.\n *\n * @group Auth\n */\nexport const IMPERSONATE_HEADER = \"x-rebase-impersonate\";\n\n// ─── Authenticated User ──────────────────────────────────────────────────────\n\n/**\n * The normalized user object returned by `AuthAdapter.verifyRequest()`.\n *\n * Regardless of the auth provider, every request is resolved to this shape\n * so that downstream middleware (RLS scoping, route guards) can work uniformly.\n *\n * @group Auth\n */\nexport interface AuthenticatedUser {\n /** Unique user identifier (provider-specific). */\n uid: string;\n /** Primary email address. */\n email: string;\n /** Human-readable display name. */\n displayName?: string | null;\n /** Avatar URL. */\n photoUrl?: string | null;\n /** Role identifiers the user holds. */\n roles: string[];\n /**\n * Whether `roles` includes `admin`. Informational — every authorization\n * decision reads `roles` and the scopes they hold, so an adapter that\n * wants a user treated as an administrator gives them the `admin` role.\n */\n isAdmin: boolean;\n /**\n * Whether this session is a GUEST — anonymous sign-in rather than an\n * account.\n *\n * Optional, and absent means \"not a guest\", so an external adapter that\n * has no such concept keeps behaving exactly as it did. When present it\n * reaches the database as `rebase.is_anonymous()`, which is what lets a\n * policy say \"a signed-in person, not somebody who pressed Continue as\n * guest\" — two things that were the same principal inside every rule,\n * because anonymous sign-in mints a real user row with a real uid.\n */\n isAnonymous?: boolean;\n /** Raw bearer token from the request (for forwarding). */\n rawToken?: string;\n /** Extra claims/metadata from the auth provider. */\n claims?: Record<string, unknown>;\n /**\n * What this credential may do, when it is narrower than its person — set\n * for an API key. Absent means everything the user's roles hold\n * (`scopesForRoles`).\n */\n scopes?: string[];\n}\n\n// ─── Capabilities ────────────────────────────────────────────────────────────\n\n/**\n * Feature flags advertised by an auth adapter.\n *\n * The frontend reads these from `GET /api/auth/config` to dynamically\n * show/hide UI elements (login form, registration, password reset, etc.).\n *\n * @group Auth\n */\nexport interface AuthAdapterCapabilities {\n /**\n * Whether this adapter mounts its own `/auth/*` routes.\n *\n * - `true` for the built-in Rebase auth (login, register, refresh, etc.)\n * - `false` for external providers like Clerk or Auth0 that handle\n * auth flows outside of the Rebase backend.\n */\n hasBuiltInAuthRoutes: boolean;\n\n /** Supports email/password login. */\n emailPasswordLogin: boolean;\n /**\n * Whether self-registration is open **right now**.\n *\n * A runtime answer, not a static feature list: the built-in adapter also\n * reports `true` during the first-user bootstrap window (an empty user\n * table) and `false` the moment `disableSelfRegistration` is set. Whatever\n * this says, `POST /auth/register` does — both read the same predicate, so\n * the UI can never be sent to a form that can only 403.\n */\n registrationEnabled: boolean;\n /**\n * Supports the end-user password reset flow (emailing a reset link).\n *\n * This is about *self-service* reset, so it is typically tied to whether an\n * email service is configured. It says nothing about whether an admin can\n * reset someone else's password — see `adminPasswordReset`.\n */\n passwordReset: boolean;\n /**\n * Whether the adapter exposes `POST /admin/users/:uid/reset-password`,\n * letting an admin reset another user's password.\n *\n * Independent of `passwordReset`: the built-in adapter supports this even\n * with no email service configured (it returns a one-time temporary\n * password instead of sending a link). Adapters that mount their own admin\n * routes must set this to `true` only once that route actually exists —\n * the admin UI hides the \"Reset Password\" action when it is `false`.\n */\n adminPasswordReset: boolean;\n /** Supports session listing/revocation. */\n sessionManagement: boolean;\n /** Supports profile updates (display name, photo). */\n profileUpdate: boolean;\n /** Supports email verification. */\n emailVerification: boolean;\n /** Supports passwordless magic link login. */\n magicLink: boolean;\n /**\n * Supports passwordless sign-in with a six-digit code sent by email.\n *\n * Optional so that an external adapter written before this existed still\n * satisfies the interface; absent reads as \"no\", which is what an adapter\n * that has never heard of the flow means.\n */\n emailOtp?: boolean;\n /** Whether `POST /auth/anonymous` will mint a credential-less session. */\n anonymousLogin: boolean;\n /** List of enabled OAuth provider IDs (e.g. `[\"google\", \"github\"]`). */\n enabledProviders: string[];\n\n /**\n * For external auth (Clerk, Auth0, etc.): the URL where the user should\n * be redirected for login. The Rebase frontend will navigate here instead\n * of showing its own login form.\n */\n externalLoginUrl?: string;\n\n /**\n * True when no users exist yet — first-user bootstrap mode.\n * Only applicable for built-in auth.\n */\n needsSetup?: boolean;\n}\n\n/**\n * What `GET /api/auth/config` answers: the auth adapter's capabilities, plus\n * the optional features of the backend around it.\n *\n * The admin panel reads this document before anything else, so it is where\n * the panel learns that a feature is off, without first asking for that\n * feature and getting a refusal.\n *\n * @group Auth\n */\nexport interface AuthConfigDocument extends AuthAdapterCapabilities {\n /**\n * Whether this backend serves file storage. When it is `false`, no storage\n * backend is configured: `GET /storage/sources` lists nothing and every\n * other `/storage` route answers 501 `STORAGE_NOT_CONFIGURED`. Absent from\n * a backend that predates the field, so a client must ask\n * `GET /storage/sources` there.\n */\n storage?: boolean;\n}\n\n// ─── User Management ────────────────────────────────────────────────────────\n\n/**\n * Options for paginated user listing.\n * @group Auth\n */\nexport interface AuthUserListOptions {\n limit?: number;\n offset?: number;\n search?: string;\n orderBy?: string;\n orderDir?: \"asc\" | \"desc\";\n roleId?: string;\n}\n\n/**\n * Paginated user listing result.\n * @group Auth\n */\nexport interface AuthUserListResult {\n users: AuthUserData[];\n total: number;\n limit: number;\n offset: number;\n}\n\n/**\n * User data exposed by the auth adapter.\n * @group Auth\n */\nexport interface AuthUserData {\n id: string;\n email: string;\n displayName?: string | null;\n photoUrl?: string | null;\n emailVerified?: boolean;\n metadata?: Record<string, unknown>;\n createdAt?: Date;\n updatedAt?: Date;\n}\n\n/**\n * Data for creating a user.\n * @group Auth\n */\nexport interface AuthCreateUserData {\n email: string;\n password?: string;\n displayName?: string;\n photoUrl?: string;\n metadata?: Record<string, unknown>;\n /**\n * Store the address as verified: whoever creates the account named it\n * (an operator, an administrator), so nobody needs to prove it.\n */\n emailVerified?: boolean;\n}\n\n/**\n * User management operations for the admin panel.\n *\n * Optional — if not provided by the adapter, the user management UI is hidden.\n *\n * @group Auth\n */\nexport interface UserManagementAdapter {\n listUsers(options?: AuthUserListOptions): Promise<AuthUserListResult>;\n getUserById(id: string): Promise<AuthUserData | null>;\n createUser(data: AuthCreateUserData): Promise<AuthUserData>;\n updateUser(id: string, data: Partial<AuthCreateUserData>): Promise<AuthUserData | null>;\n deleteUser(id: string): Promise<void>;\n getUserRoles(uid: string): Promise<string[]>;\n setUserRoles(uid: string, roleIds: string[]): Promise<void>;\n}\n\n// ─── User Creation Lifecycle ─────────────────────────────────────────────────\n\n/**\n * Result of `AuthAdapter.prepareUserCreation()`.\n *\n * Contains the processed values ready for persistence and metadata\n * needed by the finalization step.\n *\n * @group Auth\n */\nexport interface UserCreationPrepareResult {\n /** Processed values to persist (passwordHash instead of raw password, etc.). */\n values: Record<string, unknown>;\n /** Cleartext password for post-save processing (email or admin display). */\n clearPassword?: string;\n /**\n * Whether the hook already handled the invitation (email, etc.). When\n * `true`, `finalizeUserCreation` is skipped and `clearPassword` is returned\n * to the admin as the temporary password.\n */\n hookHandledEmail: boolean;\n /** Whether an invitation was sent (only relevant when hookHandledEmail is true). */\n invitationSent: boolean;\n}\n\n/**\n * What the door creating a user knows about its caller, for\n * `AuthAdapter.prepareUserCreation()`.\n *\n * @group Auth\n */\nexport interface UserCreationOptions {\n /**\n * Whether the caller may waive the password policy with\n * `skipPasswordPolicy: true` in the body. The doors set it for an\n * administrator (the service key signs in as one) and for nobody else, so\n * an adapter that reads the flag is taking an administrator at their word —\n * an import of accounts whose passwords were set under another system's\n * rules. Absent, the flag waives nothing.\n */\n mayWaivePasswordPolicy?: boolean;\n}\n\n/**\n * What a create body for an auth collection may name beyond the collection's\n * own declared fields — see `AuthAdapter.describeUserCreationContract()`.\n *\n * @group Auth\n */\nexport interface UserCreationWriteContract {\n /**\n * Whether to check the body for fields neither the collection nor\n * {@link extraFields} declares. `false` skips the check entirely.\n */\n validate: boolean;\n /**\n * Credential and provider keys the adapter consumes itself, which the\n * collection therefore does not declare as columns. `password` is the\n * canonical one: `prepareUserCreation` hashes it into `passwordHash` and\n * deletes it before the row is ever built.\n */\n extraFields: string[];\n /**\n * Each body's `prepareUserCreation` touches nothing but its own result —\n * no hook, no email, no write — so a bulk create may prepare several at\n * once. The built-in adapter's path with no `onCreateUser`, no\n * `onAdminCreateUser` and the default `hashPassword` is: it hashes a\n * password and normalizes an email, and a 1,000-row bulk spent half a\n * minute hashing one password at a time. Leave it unset when a hook runs:\n * preparations then run one at a time, in the order the rows were given.\n */\n independent?: boolean;\n}\n\n/**\n * Result of `AuthAdapter.finalizeUserCreation()`.\n *\n * Returned to the REST API for inclusion in the response.\n *\n * @group Auth\n */\nexport interface UserCreationFinalizeResult {\n /** If set, returned to the admin in the API response. */\n temporaryPassword?: string;\n /** Whether an invitation email was sent. */\n invitationSent: boolean;\n /**\n * Whether an email service was configured but delivery failed, causing the\n * fallback to `temporaryPassword`. Absent when no email service is configured.\n */\n emailDeliveryFailed?: boolean;\n}\n\n// ─── Auth Response Transform ─────────────────────────────────────────────────\n\n/**\n * The auth response payload shape that flows through `transformAuthResponse`.\n *\n * For login, register, OAuth, anonymous, and magic-link flows the payload\n * contains both `user` and `tokens`. For refresh and MFA flows the payload\n * contains only `tokens` (no `user`).\n *\n * @group Auth\n */\nexport interface AuthResponsePayload {\n user?: {\n uid: string;\n email: string;\n displayName: string | null;\n photoURL: string | null;\n providerId?: string;\n isAnonymous?: boolean;\n emailVerified?: boolean;\n roles: string[];\n metadata: Record<string, unknown>;\n };\n tokens: {\n accessToken: string;\n refreshToken: string;\n accessTokenExpiresAt: number;\n /** Additional tokens injected by `transformAuthResponse`. */\n [key: string]: unknown;\n };\n}\n\n/**\n * Context passed to the `transformAuthResponse` hook.\n *\n * @group Auth\n */\nexport interface TransformAuthResponseContext {\n /** The authenticated user's ID. */\n uid: string;\n /** The auth method that triggered this response. */\n method: \"login\" | \"register\" | \"oauth\" | \"refresh\" | \"anonymous\" | \"magic-link\" | \"otp\" | \"mfa\";\n /** The raw HTTP request (for reading headers, IP, etc.). */\n request: Request;\n}\n\n// ─── Auth Adapter ────────────────────────────────────────────────────────────\n\n/**\n * Pluggable authentication adapter for Rebase.\n *\n * This is the **key interface** that decouples authentication from the\n * database layer. Each auth adapter knows how to:\n *\n * 1. Verify incoming HTTP requests (`verifyRequest`)\n * 2. Optionally manage users (for the admin panel)\n * 3. Optionally mount auth-specific routes (login, register, etc.)\n * 4. Advertise its capabilities so the frontend can adapt\n *\n * The built-in Rebase auth implements this interface internally.\n * External providers (Clerk, Auth0, or others) provide their own adapters.\n * Users with custom auth can use `createCustomAuthAdapter()` for a minimal setup.\n *\n * @group Auth\n */\nexport interface AuthAdapter {\n /**\n * Unique identifier for this auth adapter.\n *\n * @example \"rebase-builtin\", \"clerk\", \"auth0\", \"external-provider\", \"custom\"\n */\n readonly id: string;\n\n // ── Request Authentication ──────────────────────────────────────────\n\n /**\n * Verify an incoming request and extract the authenticated user.\n *\n * This replaces the hardcoded JWT verification in server's middleware.\n * Each adapter implements its own token verification strategy:\n * - Built-in: verify Rebase JWT\n * - Clerk: call Clerk's `verifyToken()`\n * - Auth0: validate Auth0 JWT with JWKS\n * - Custom: whatever logic the user provides\n *\n * @param request - The raw `Request` object (portable across Hono, Express, Fastify)\n * @returns The authenticated user, or `null` for unauthenticated requests.\n * Throw an error to reject the request with 401.\n */\n verifyRequest(request: Request): Promise<AuthenticatedUser | null>;\n\n /**\n * Verify a raw bearer token and extract the authenticated user.\n *\n * Used for **WebSocket authentication**, where there is no HTTP `Request`\n * object — only a token string sent over the socket.\n *\n * If not implemented, the default behavior synthesizes a minimal `Request`\n * with an `Authorization: Bearer <token>` header and delegates to\n * `verifyRequest()`. Adapters should override this if their token\n * verification logic doesn't depend on request headers/cookies.\n *\n * @param token - The raw bearer token string.\n * @returns The authenticated user, or `null` if the token is invalid.\n */\n verifyToken?(token: string): Promise<AuthenticatedUser | null>;\n\n /**\n * The identity a verified request from this account would carry right\n * now, with no credential involved: its uid, its roles as they are now,\n * its guest flag, and the custom claims a token minted for it now would\n * hold. `null` when no active account has this uid — none at all, or one\n * that is disabled.\n *\n * This is what lets an administrator run a request as another user (the\n * {@link IMPERSONATE_HEADER} header). The server calls it only after it\n * has decided the caller may do that, and it calls it for the caller too,\n * so the decision rests on the caller's roles as they are now rather than\n * on the ones their token claims.\n *\n * Optional. An adapter without it cannot be impersonated through, and a\n * request carrying the header is refused rather than run as its caller.\n */\n resolveUser?(uid: string): Promise<AuthenticatedUser | null>;\n\n // ── User Management (for admin panel) ────────────────────────────\n\n /**\n * User CRUD for the admin panel's user management UI.\n * Optional — if not provided, user management UI is hidden.\n */\n userManagement?: UserManagementAdapter;\n\n // ── Auth Routes ─────────────────────────────────────────────────────\n\n /**\n * Mount adapter-specific auth routes (login, register, refresh, etc.).\n *\n * - Built-in adapter: mounts `/auth/login`, `/auth/register`, etc.\n * - External adapter: typically returns `undefined` (auth is handled externally).\n * - Custom adapter: user mounts their own routes.\n *\n * The return type is the structural {@link MountableRouter} rather than\n * `Hono`, so this package needs no hono dependency. A real `Hono<HonoEnv>`\n * satisfies it, and implementations are free to use their own env.\n *\n * @returns A Hono sub-app with auth routes, or `undefined` to skip route mounting.\n */\n createAuthRoutes?(): MountableRouter | undefined;\n\n /**\n * Mount admin routes (e.g. password reset for users).\n *\n * @returns A Hono sub-app with admin routes, or `undefined` to skip.\n */\n createAdminRoutes?(): MountableRouter | undefined;\n\n // ── Feature Detection ───────────────────────────────────────────────\n\n /**\n * Advertise what this auth adapter supports.\n *\n * The frontend reads this from `GET /api/auth/config` to dynamically\n * show/hide UI elements. This is the bridge between backend capabilities\n * and the frontend's `AuthCapabilities` type.\n */\n getCapabilities(): AuthAdapterCapabilities | Promise<AuthAdapterCapabilities>;\n\n // ── Lifecycle ───────────────────────────────────────────────────────\n\n /**\n * Called during backend initialization.\n * Use for running migrations, creating tables, seeding initial data, etc.\n */\n initialize?(): Promise<void>;\n\n /**\n * Called during graceful shutdown.\n * Use for closing connections, flushing caches, etc.\n */\n destroy?(): Promise<void>;\n\n // ── Collection User Creation (for auth collections) ───────────────\n\n /**\n * Prepare values for creating a user via the auth collection's REST API.\n *\n * Called on POST to a collection with `auth: true`. Handles password\n * hashing, email normalization, and any collection/backend-level hooks.\n *\n * If not implemented, the collection saves values as-is (no password hashing).\n *\n * A chosen `password` is held to the password policy unless the body says\n * `skipPasswordPolicy: true` and `options.mayWaivePasswordPolicy` is set.\n *\n * @param values - Raw request body from the client.\n * @param collectionAuth - The parsed `AuthCollectionConfig` from the collection (if `auth` is an object).\n * @param options - What the door knows about its caller.\n * @returns Processed values ready for `driver.save()`, plus metadata for the post-save step.\n */\n prepareUserCreation?(\n values: Record<string, unknown>,\n collectionAuth?: unknown,\n options?: UserCreationOptions\n ): Promise<UserCreationPrepareResult>;\n\n /**\n * Describe what a create body for this auth collection is allowed to name,\n * so unknown-field validation can run on it.\n *\n * A signup body is not the collection's shape: it carries credential fields\n * like `password` that the users table never declares as columns, and\n * `prepareUserCreation` maps them onto real ones. Validating the raw body\n * against the collection alone would reject every legitimate signup — which\n * is why the check used to be skipped outright for auth collections. That\n * skip was total, so an undeclared field was silently dropped and the write\n * still returned 201, while the same typo on a normal collection was a 400.\n *\n * This narrows the exemption to the fields the adapter actually consumes.\n *\n * `validate: false` disables the check for this collection, and is the right\n * answer when a custom `onCreateUser` hook is configured: the body is then\n * the hook's contract, not the collection's, and this layer cannot know what\n * the hook accepts.\n *\n * If not implemented, validation is skipped — the pre-existing behaviour.\n *\n * @param collectionAuth - The parsed `AuthCollectionConfig` from the collection (if `auth` is an object).\n */\n describeUserCreationContract?(\n collectionAuth?: unknown\n ): UserCreationWriteContract;\n\n /**\n * Finalize a user creation after the entity has been persisted.\n *\n * Handles post-save work: sending invitation emails, generating\n * password-reset tokens, or falling back to returning a temporary password.\n *\n * Not called when `prepareUserCreation` reported `hookHandledEmail`. A\n * create hook then owns delivery, and the response carries the\n * `invitationSent` and `clearPassword` the prepare step returned. This\n * holds for both ways of creating a user: a `POST` to the auth collection\n * and `POST /admin/users`.\n *\n * @param entity - The persisted entity (id + values).\n * @param clearPassword - The cleartext password from the prepare step (if any).\n * @returns Metadata for the API response (temporary password, invitation status).\n */\n finalizeUserCreation?(\n entity: { id: string; values: Record<string, unknown> },\n clearPassword?: string\n ): Promise<UserCreationFinalizeResult>;\n\n // ── User Administration Through the Auth Collection ─────────────────\n\n /**\n * Check, and put in stored form, updates to rows of the auth collection\n * that arrive through the data API — `PATCH /<users>/:id`, `PATCH /bulk`,\n * `_batch`, the realtime socket.\n *\n * Those rows are the users, so an update to one is user administration,\n * and has to hold to what the admin user routes hold to: no update that\n * leaves the project without an administrator, and an email stored the\n * way sign-in looks it up. Throws to refuse; the updates are judged\n * together, so a bulk demotion of every admin is refused even though each\n * row alone would pass.\n *\n * If not implemented, the values are written as they arrived.\n *\n * @returns Each update's values, as they should be written, in order.\n */\n prepareUserUpdates?(\n updates: ReadonlyArray<{ uid: string; values: Record<string, unknown> }>\n ): Promise<Record<string, unknown>[]>;\n\n /**\n * Check a deletion of these users through the data API before any row goes,\n * and run the hooks that may veto it (`beforeUserDelete`). Throws to\n * refuse — for one that would leave the project without an administrator,\n * among others.\n *\n * If not implemented, the rows are deleted as any other.\n */\n prepareUserDeletions?(uids: readonly string[]): Promise<void>;\n\n /**\n * After those users' rows are deleted through the data API: end their\n * sessions and run the after-delete hooks (`afterUserDelete`).\n */\n finalizeUserDeletions?(uids: readonly string[]): Promise<void>;\n\n // ── Service Key (optional) ──────────────────────────────────────────\n\n /**\n * A static secret key for server-to-server / script authentication.\n *\n * When set, requests with `Authorization: Bearer <serviceKey>` bypass\n * normal token verification and are granted admin-level access.\n */\n serviceKey?: string;\n\n // ── Response Transform ───────────────────────────────────────────────\n\n /**\n * Transform the auth response before sending it to the client.\n *\n * Called after successful login, register, refresh, OAuth, anonymous,\n * magic-link, and MFA flows. The hook receives the fully-formed\n * response and returns a (potentially enriched) response.\n *\n * Use cases:\n * - Inject tokens from external auth systems (custom provider tokens, etc.)\n * - Add project-specific metadata to the response\n * - Enrich the user object with data from external sources\n *\n * The hook runs in the request path — keep it fast.\n * Heavy work should be offloaded to `onAuthenticated` (fire-and-forget).\n */\n transformAuthResponse?(\n response: AuthResponsePayload,\n context: TransformAuthResponseContext\n ): Promise<AuthResponsePayload>;\n}\n\n// ─── Custom Auth Adapter Options ─────────────────────────────────────────────\n\n/**\n * Options for creating a minimal custom auth adapter via `createCustomAuthAdapter()`.\n *\n * This is the simplest way to plug an existing auth system into Rebase.\n * Only `verifyRequest` is required — everything else is optional.\n *\n * @group Auth\n */\nexport interface CustomAuthAdapterOptions {\n /**\n * Verify an incoming request and return the authenticated user.\n * This is the only required method.\n */\n verifyRequest: (request: Request) => Promise<AuthenticatedUser | null>;\n\n /**\n * Verify a raw bearer token for WebSocket authentication.\n * Optional — if omitted, a synthetic `Request` is constructed and passed\n * to `verifyRequest`.\n */\n verifyToken?: (token: string) => Promise<AuthenticatedUser | null>;\n\n /** Optional user management for the admin panel. */\n userManagement?: UserManagementAdapter;\n\n /** Static service key for server-to-server auth. */\n serviceKey?: string;\n\n /** Override default capabilities. */\n capabilities?: Partial<AuthAdapterCapabilities>;\n\n /**\n * Transform the auth response before sending it to the client.\n * Same semantics as `AuthAdapter.transformAuthResponse`.\n */\n transformAuthResponse?: (\n response: AuthResponsePayload,\n context: TransformAuthResponseContext\n ) => Promise<AuthResponsePayload>;\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAsEA,IAAa,qBAAqB"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"backend-DTAOsLQc.js","names":[],"sources":["../../types/src/types/backend.ts"],"sourcesContent":["import type { CollectionConfig, FilterValues, WhereFilterOp } from \"./collections\";\nimport type { CollectionCallbacks } from \"./entity_callbacks\";\nimport type { OrderByTuple } from \"./filter-operators\";\nimport type { LogicalCondition } from \"../controllers/data\";\nimport type { AuthAdapter } from \"./auth_adapter\";\nimport type { HistoryConfig } from \"../controllers/client\";\nimport type { ChannelBusSetting } from \"./channel_bus\";\nimport type { SchemaEditingAdmin } from \"./schema_editing\";\nimport type { AccessModel } from \"./scopes\";\n\n// =============================================================================\n// DATABASE CONNECTION INTERFACES\n// =============================================================================\n\n/**\n * Abstract database connection interface.\n * Represents a connection to any database system.\n */\nexport interface DatabaseConnection {\n /**\n * Type identifier for this database (e.g., 'postgres', 'mongodb', 'mysql')\n */\n readonly type: string;\n\n /**\n * Whether the connection is currently active\n */\n readonly isConnected?: boolean;\n\n /**\n * Close the database connection and release resources.\n */\n close?(): Promise<void>;\n}\n\n// =============================================================================\n// QUERY BUILDING INTERFACES\n// =============================================================================\n\n/**\n * A single filter condition for database queries\n */\nexport interface QueryFilter {\n field: string;\n operator: WhereFilterOp;\n value: unknown;\n}\n\n/**\n * Options for fetching a collection of entities\n */\nexport interface FetchCollectionOptions<M extends Record<string, unknown> = Record<string, unknown>> {\n filter?: FilterValues<Extract<keyof M, string>>;\n /** See `FetchCollectionProps.orderBy`: a field name plus `order`, or a list of tuples. */\n orderBy?: string | OrderByTuple[];\n order?: \"desc\" | \"asc\";\n limit?: number;\n offset?: number;\n startAfter?: unknown;\n searchString?: string;\n databaseId?: string;\n collection?: CollectionConfig;\n}\n\n/**\n * Options for searching entities\n */\nexport interface SearchOptions<M extends Record<string, unknown> = Record<string, unknown>> {\n filter?: FilterValues<Extract<keyof M, string>>;\n /** See `FetchCollectionProps.orderBy`: a field name plus `order`, or a list of tuples. */\n orderBy?: string | OrderByTuple[];\n order?: \"desc\" | \"asc\";\n limit?: number;\n databaseId?: string;\n collection?: CollectionConfig;\n}\n\n/**\n * Options for counting entities\n */\nexport interface CountOptions<M extends Record<string, unknown> = Record<string, unknown>> {\n filter?: FilterValues<Extract<keyof M, string>>;\n /**\n * An `or(...)`/`and(...)` group, alongside `filter`.\n *\n * Counted as well as fetched, or `total` describes a different set of rows\n * from the one that was served — the same reason `filter` is here.\n */\n logical?: LogicalCondition;\n searchString?: string;\n databaseId?: string;\n}\n\n/**\n * Abstract condition builder interface.\n * Implementations translate Rebase filter conditions to database-specific queries.\n *\n * Note: This interface can be implemented as instance methods or as a class with static methods.\n * For static implementations (like DrizzleConditionBuilder), use the ConditionBuilderStatic type.\n *\n * @template T The type of condition returned by the builder (e.g., SQL for PostgreSQL, Filter<Document> for MongoDB)\n */\nexport interface ConditionBuilder<T = unknown> {\n /**\n * Build filter conditions from Rebase FilterValues\n */\n buildFilterConditions<M extends Record<string, unknown>>(\n filter: FilterValues<Extract<keyof M, string>>,\n collectionPath: string,\n ...args: unknown[]\n ): T[];\n\n /**\n * Build search conditions for text search.\n *\n * At most one condition comes back, already complete: callers OR what they\n * get, and OR is right across the searchable *fields* but wrong across the\n * *terms* of the search string — a person typing two words means both, and\n * the two halves of a name are two different fields. The empty array still\n * means \"nothing here can be searched\", which every caller reads as \"no\n * rows\".\n */\n buildSearchConditions(\n searchString: string,\n properties: Record<string, unknown>,\n ...args: unknown[]\n ): T[];\n\n /**\n * Combine multiple conditions with AND operator\n */\n combineConditionsWithAnd(conditions: T[]): T | undefined;\n\n /**\n * Combine multiple conditions with OR operator\n */\n combineConditionsWithOr(conditions: T[]): T | undefined;\n}\n\n/**\n * Static condition builder type for implementations using static methods.\n * Use this type when the class provides static methods rather than instance methods.\n *\n * @example\n * // DrizzleConditionBuilder satisfies this type\n * const builder: ConditionBuilderStatic<SQL> = DrizzleConditionBuilder;\n */\nexport type ConditionBuilderStatic<T = unknown> = {\n buildFilterConditions<M extends Record<string, unknown>>(\n filter: FilterValues<Extract<keyof M, string>>,\n ...args: unknown[]\n ): T[];\n buildSearchConditions(\n searchString: string,\n properties: Record<string, unknown>,\n ...args: unknown[]\n ): T[];\n combineConditionsWithAnd(conditions: T[]): T | undefined;\n combineConditionsWithOr(conditions: T[]): T | undefined;\n};\n\n// =============================================================================\n// ENTITY REPOSITORY INTERFACES\n// =============================================================================\n\n/**\n * Abstract entity repository interface.\n * Handles all CRUD operations for entities in the database.\n *\n * Implementations should handle:\n * - Entity serialization/deserialization\n * - Relation resolution\n * - ID generation and conversion\n */\nexport interface DataRepository {\n /**\n * Fetch a single entity by ID\n */\n fetchOne<M extends Record<string, unknown>>(\n collectionPath: string,\n id: string | number,\n databaseId?: string\n ): Promise<Record<string, unknown> | undefined>;\n\n /**\n * Fetch a collection of entities with optional filtering, ordering, and pagination\n */\n fetchCollection<M extends Record<string, unknown>>(\n collectionPath: string,\n options?: FetchCollectionOptions<M>\n ): Promise<Record<string, unknown>[]>;\n\n /**\n * Search entities by text\n */\n searchRows<M extends Record<string, unknown>>(\n collectionPath: string,\n searchString: string,\n options?: SearchOptions<M>\n ): Promise<Record<string, unknown>[]>;\n\n /**\n * Count entities in a collection\n */\n count<M extends Record<string, unknown>>(\n collectionPath: string,\n options?: CountOptions<M>\n ): Promise<number>;\n\n /**\n * Save a entity (create or update)\n */\n save<M extends Record<string, unknown>>(\n collectionPath: string,\n values: Partial<M>,\n id?: string | number,\n databaseId?: string\n ): Promise<Record<string, unknown>>;\n\n /**\n * Delete a entity by ID\n */\n delete(\n collectionPath: string,\n id: string | number,\n databaseId?: string\n ): Promise<void>;\n\n /**\n * Check if a field value is unique in a collection\n */\n checkUniqueField(\n collectionPath: string,\n fieldName: string,\n value: unknown,\n excludeEntityId?: string,\n databaseId?: string\n ): Promise<boolean>;\n\n}\n\n// =============================================================================\n// REALTIME INTERFACES\n// =============================================================================\n\n/**\n * Configuration for subscribing to a collection\n */\nexport interface CollectionSubscriptionConfig {\n clientId: string;\n path: string;\n filter?: unknown;\n /**\n * An `or(...)`/`and(...)` group, applied alongside `filter`.\n *\n * Declared here because a subscription is a query, and every field a query\n * has this one needs too. It was missing, so the type-checked boundary\n * dropped it: the client sent the group, nothing rejected it, and the\n * subscription re-fetched with the group gone — pushing every row the\n * caller's policies allowed rather than the ones they asked for. The same\n * defect `FetchCollectionProps.logical` documents, one layer up.\n */\n logical?: LogicalCondition;\n /**\n * Where the subscription's page starts. Missing for the same reason, with\n * a quieter symptom: a subscriber watching page two was pushed page one,\n * and a `collection_update` frame carries no window for it to notice with.\n */\n offset?: number;\n /** See `FetchCollectionProps.orderBy`: a field name plus `order`, or a list of tuples. */\n orderBy?: string | OrderByTuple[];\n order?: \"desc\" | \"asc\";\n limit?: number;\n startAfter?: unknown;\n databaseId?: string;\n searchString?: string;\n /** Ask each row which declared search field matched. */\n searchExplain?: boolean;\n}\n\n/**\n * Configuration for subscribing to a single entity\n */\nexport interface SingleSubscriptionConfig {\n clientId: string;\n path: string;\n id: string | number;\n}\n\n/**\n * Opt-in retention for one set of broadcast channels.\n *\n * Retention is configured on the server and nowhere else. A channel is created\n * by whoever names it, so letting a client ask for its own history depth would\n * let any visitor commit the backend to unbounded storage; and presence-only or\n * notification-only channels — the overwhelming majority — must not pay for a\n * feature they never use. With no rules configured nothing is written, no table\n * is created, and broadcast behaves exactly as it did before history existed.\n */\nexport interface ChannelRetentionRule {\n /**\n * Channel name to match. Either exact (`\"doc:42\"`) or a trailing-`*` prefix\n * (`\"doc:*\"`). Deliberately not a full glob or RegExp: this decides what\n * gets written to disk, and a rule whose blast radius is not obvious at a\n * glance is the wrong shape for that.\n */\n match: string;\n /** Keep at most this many of the most recent messages per channel. */\n limit?: number;\n /**\n * Keep messages for at most this long. Accepts a millisecond count or a\n * short duration string (`\"30s\"`, `\"15m\"`, `\"24h\"`, `\"7d\"`).\n */\n ttl?: number | string;\n}\n\n/**\n * Server-side realtime options.\n *\n * The channel bus contract and its config live in `./channel_bus` so that a\n * transport shipped as its own package depends on the contract alone.\n */\nexport interface RealtimeChannelsConfig {\n /**\n * Retention rules, most specific first — the first match wins. Omitted or\n * empty means no channel retains anything.\n */\n channels?: ChannelRetentionRule[];\n /**\n * How channel broadcast and presence reach other backend instances.\n * Defaults to `{ type: \"memory\" }` — i.e. they don't.\n */\n bus?: ChannelBusSetting;\n /**\n * How many subscriptions one socket may hold; the next is refused with\n * `TOO_MANY_SUBSCRIPTIONS`. A positive whole number, checked at boot.\n * Defaults to 1000. `REALTIME_MAX_SUBSCRIPTIONS_PER_SOCKET` wins over it.\n */\n maxSubscriptionsPerSocket?: number;\n /**\n * How many sockets one user (one identity: a signed-in user, an API key's\n * user, an impersonated user) may hold open on one server instance. The\n * next one's `AUTHENTICATE` is refused with `TOO_MANY_SOCKETS` and the\n * socket closed with 4002; the SDK keeps retrying with backoff, so it gets\n * in once another closes. A positive whole number, checked at boot.\n * Unset (the default) is no ceiling. `REALTIME_MAX_SOCKETS_PER_USER` wins\n * over it.\n */\n maxSocketsPerUser?: number;\n /**\n * The longest a change waits for the refetch that delivers it, in ms. A\n * refetch waits for 300 ms without a change, and at most this long under a\n * steady stream of writes. Defaults to 1000. A positive whole number,\n * checked at boot. `REALTIME_REFETCH_MAX_WAIT_MS` wins over it.\n */\n refetchMaxWaitMs?: number;\n /**\n * How long a big list subscription's total may be reused before it is\n * counted again, in ms. Unset (the default), every frame counts. Frames\n * that reuse it carry `meta.totalAsOf`. `REALTIME_RECOUNT_INTERVAL_MS`\n * wins over it.\n */\n recountIntervalMs?: number;\n /**\n * The smallest last total at which {@link recountIntervalMs} applies;\n * smaller lists are counted on every frame. Defaults to 100 000.\n * `REALTIME_RECOUNT_MIN_ROWS` wins over it.\n */\n recountMinRows?: number;\n /**\n * How changes are captured for realtime. `auto` (the default) installs\n * database-level capture where the connection can and falls back to\n * announcing the API's own writes where it cannot; `trigger` asks for\n * capture and warns when it is refused; `wal` is `trigger` until WAL\n * streaming ships; `off` announces the API's own writes only.\n * `REALTIME_CDC` wins over it.\n */\n cdc?: \"auto\" | \"wal\" | \"trigger\" | \"off\";\n /**\n * The URL path the realtime socket accepts upgrades on. Defaults to the\n * one the server derives from where it is mounted — `/` for a server at\n * `/api`, which is where the SDK dials. Set it when clients dial elsewhere\n * (a `websocketUrl` with a path). See {@link RealtimeSocketOptions.path}.\n */\n path?: string;\n}\n\n/**\n * Abstract realtime provider interface.\n * Handles real-time subscriptions and notifications for entity changes.\n */\nexport interface RealtimeProvider {\n /**\n * Subscribe to collection changes.\n *\n * `onError` is called when a fetch behind the subscription fails, a\n * refetch after a change included, with the error as it was thrown. It is\n * not called for a fetch that newer rows have already overtaken, or after\n * the subscription is gone. Without it, a subscriber whose fetch failed\n * was told nothing and kept waiting for rows.\n */\n subscribeToCollection(\n subscriptionId: string,\n config: CollectionSubscriptionConfig,\n callback?: (rows: Record<string, unknown>[]) => void,\n onError?: (error: unknown) => void\n ): void;\n\n /**\n * Subscribe to single entity changes. `onError` as for\n * {@link RealtimeProvider.subscribeToCollection}.\n */\n subscribeToOne(\n subscriptionId: string,\n config: SingleSubscriptionConfig,\n callback?: (row: Record<string, unknown> | null) => void,\n onError?: (error: unknown) => void\n ): void;\n\n /**\n * Unsubscribe from a subscription\n */\n unsubscribe(subscriptionId: string): void;\n\n /**\n * Notify all relevant subscribers of a entity update\n */\n notifyUpdate(\n path: string,\n id: string,\n row: Record<string, unknown> | null,\n databaseId?: string\n ): Promise<void>;\n\n /**\n * Called when the HTTP server is ready and listening.\n * Useful for providers that need the server address for callbacks.\n */\n onServerReady?(serverInfo: { port: number; hostname?: string }): void;\n\n /**\n * Gracefully shut down the realtime provider.\n * Called during server shutdown to clean up resources.\n */\n destroy?(): Promise<void>;\n\n /**\n * Stop the internal LISTEN client (e.g., PostgreSQL LISTEN/NOTIFY).\n * Called during graceful shutdown before closing database connections.\n */\n stopListening?(): Promise<void>;\n\n /**\n * The long-lived connections realtime depends on, for `/health`. A change\n * feed that has gone quiet — a half-open LISTEN connection — loses every\n * external and cross-instance change while writes through this instance\n * still look live, so it is reported rather than inferred.\n */\n health?(): RealtimeListenerHealth[];\n}\n\n/**\n * One long-lived realtime connection, as `/health` reports it.\n * @group Backend\n */\nexport interface RealtimeListenerHealth {\n /** What it carries — `\"cdc\"`, `\"cross-instance\"`, `\"channel-bus\"`. */\n name: string;\n /** Listening on a connection that answered its last heartbeat. */\n connected: boolean;\n /** When (epoch ms) it went down, while it is down. */\n downSince?: number;\n}\n\n// =============================================================================\n// COLLECTION REGISTRY INTERFACES\n// =============================================================================\n\n/**\n * Abstract collection registry interface.\n * Manages registration and lookup of entity collections.\n */\nexport interface CollectionRegistryInterface {\n /**\n * Register a collection\n */\n register(collection: CollectionConfig): void;\n\n /**\n * Get a collection by its path\n */\n getCollectionByPath(path: string): CollectionConfig | undefined;\n\n /**\n * Get all registered collections\n */\n getCollections(): CollectionConfig[];\n\n /**\n * Get the currently registered global callbacks, if any.\n */\n getGlobalCallbacks(): any | undefined;\n\n /**\n * Take the global callbacks declared on `initializeRebaseBackend({ callbacks })`.\n *\n * A driver resolves callbacks from the registry it builds for itself, so\n * this is how the backend's global ones reach it: the coordinator hands\n * them over after `initializeDriver` returns. Optional so a registry for a\n * driver that runs no callbacks still type-checks, but a project that\n * declares global callbacks over a registry without it is refused at boot\n * — hooks that never run look exactly like hooks that passed.\n */\n setGlobalCallbacks?(callbacks: CollectionCallbacks): void;\n}\n\n// =============================================================================\n// DATA TRANSFORMER INTERFACES\n// =============================================================================\n\n/**\n * Abstract data transformer interface.\n * Handles serialization/deserialization between frontend and database formats.\n */\nexport interface DataTransformer {\n /**\n * Transform entity data for storage in the database\n */\n serializeToDatabase<M extends Record<string, unknown>>(\n entity: M,\n collection: CollectionConfig\n ): Record<string, unknown>;\n\n /**\n * Transform database data back to entity format\n */\n deserializeFromDatabase<M extends Record<string, unknown>>(\n data: Record<string, unknown>,\n collection: CollectionConfig\n ): Promise<M>;\n}\n\n// =============================================================================\n// DATABASE ADMIN — CAPABILITY-SPECIFIC INTERFACES (1.3)\n// =============================================================================\n\n/**\n * The table column a value of a {@link SqlScriptResult} was read from, as the\n * database itself reported it — not as the query's text suggests.\n *\n * @group Admin\n */\nexport interface SqlScriptColumnSource {\n schema: string;\n table: string;\n column: string;\n}\n\n/**\n * One column of a {@link SqlScriptResult}, in result order.\n *\n * @group Admin\n */\nexport interface SqlScriptColumn {\n /** The name the result gives it. Two columns of one result may share it. */\n name: string;\n /** Its type, as the database names it: `integer`, `text[]`, `timestamp with time zone`. */\n type?: string;\n /**\n * Where the database says the value came from: a plain reference to a\n * column of a table or view, through any number of subqueries.\n *\n * Absent for everything no stored row holds — an expression, a cast that\n * changes the type, a literal, an aggregate, a function's output, a\n * `UNION`.\n */\n source?: SqlScriptColumnSource;\n}\n\n/**\n * A table or view some column of a {@link SqlScriptResult} came from.\n *\n * @group Admin\n */\nexport interface SqlScriptTable {\n schema: string;\n table: string;\n /** What the relation is. Only a `table` or a `partitioned table` holds rows that can be updated by key. */\n kind: \"table\" | \"partitioned table\" | \"view\" | \"materialized view\" | \"foreign table\" | \"other\";\n /** Its primary key columns, in key order. Empty when it has none. */\n primaryKey: string[];\n /**\n * Other tables inherit this one (by `INHERITS`, not as partitions). A row\n * read from it may live in one of them, and a primary key is not unique\n * across them.\n */\n hasInheritors: boolean;\n}\n\n/**\n * Something the database said while a script ran: `there is no transaction in\n * progress` for a ROLLBACK with nothing to end, a `RAISE NOTICE`.\n *\n * @group Admin\n */\nexport interface SqlScriptNotice {\n severity: string;\n message: string;\n}\n\n/**\n * What a SQL script a person wrote returned. See {@link SQLAdmin.runSqlScript}.\n *\n * @group Admin\n */\nexport interface SqlScriptResult {\n /**\n * The last statement's rows, every value as the text the database wrote\n * for it and `null` for SQL NULL — so a value written back is the value\n * that was read, whatever its type. Keyed by column name: of two columns\n * that share a name, the row holds the last.\n */\n rows: Record<string, string | null>[];\n /** The last statement's columns, in order. */\n columns: SqlScriptColumn[];\n /** The tables and views {@link SqlScriptColumn.source} names. */\n tables: SqlScriptTable[];\n /** The last statement's command: `SELECT`, `UPDATE`, `CREATE TABLE` … */\n command?: string;\n /**\n * How many rows the last statement returned or changed, when its command\n * reports a count — or, for a {@link truncated} result, how many it\n * returned, of which {@link rows} holds the first.\n */\n rowCount?: number;\n /**\n * The last statement returned more than a run keeps, and {@link rows}\n * holds only its first rows. The Studio console's door keeps at most\n * 10,000 rows, or 32 Mi characters of values, of one result: a run that\n * held every row of a million-row table took the server down with it.\n */\n truncated?: boolean;\n /** What the database said while the script ran, in order. */\n notices: SqlScriptNotice[];\n /**\n * The database role the script ran as, when it asked for one — said by\n * the transport that ran it. The role asked for, unless the server has\n * role switching turned off (`DISABLE_DB_ROLE_SWITCHING`) and runs every\n * statement as the connection owner, who is named here instead.\n */\n effectiveRole?: string;\n}\n\n/**\n * A query's result being read a batch at a time — see `SQLAdmin.openSqlCursor`.\n *\n * @group Admin\n */\nexport interface SqlCursor {\n /** The result's column names, in order. */\n readonly columns: string[];\n /**\n * The next rows, each value the text the database wrote for it and `null`\n * for NULL — `null` once there are none left.\n */\n read(): Promise<(string | null)[][] | null>;\n /** End the read and give the session back. Safe to call more than once. */\n close(): Promise<void>;\n}\n\n/**\n * Administrative operations for SQL-based databases (PostgreSQL, MySQL, etc.).\n * Used by the SQL Editor, RLS Editor, and schema browser.\n *\n * @group Admin\n */\nexport interface SQLAdmin {\n /**\n * Execute raw SQL against the database.\n *\n * `isolateSession` runs it on a connection of its own and resets that\n * connection's session state — role, session authorization, settings —\n * before it is reused. For SQL a person wrote (the Studio editor), which\n * may `SET ROLE` and must not leave that on a pooled connection the\n * server's own queries use next.\n *\n * `maxResultBytes` refuses a result whose rows take more than that many\n * bytes, with an error whose `code` is `RESULT_TOO_LARGE`, rather than\n * return it — never a part of it. On a session of its own the read stops\n * there; elsewhere the rows are measured once read. The WebSocket\n * `EXECUTE_SQL` door sets it for every caller.\n */\n executeSql(sql: string, options?: { database?: string; role?: string; params?: unknown[]; isolateSession?: boolean; maxResultBytes?: number }): Promise<Record<string, unknown>[]>;\n\n /**\n * Run a script a person wrote — the Studio SQL console — and describe what\n * it returned.\n *\n * On a session of its own, reset afterwards, as `role` for every statement\n * of it. Values come back as the database's text, and each column says\n * which table column it was read from, when the database says so: what\n * the console needs to write a cell back to the row it came from and to\n * no other. A script that leaves a transaction open is refused, and the\n * transaction rolled back: nothing outlives the run to be committed or\n * rolled back by a later one.\n *\n * Aborting `signal` cancels the run: the statement in progress is stopped\n * (`pg_cancel_backend` on a Postgres server; the in-process development\n * database cannot be interrupted, and the run ends on its own), and the\n * promise rejects.\n */\n runSqlScript?(sql: string, options?: { database?: string; role?: string; signal?: AbortSignal }): Promise<SqlScriptResult>;\n\n /**\n * Read the whole result of ONE query a person wrote, a batch at a time —\n * the Studio SQL console's \"Download full result as CSV\", which the\n * console's 10,000-row cap would otherwise leave no way to get.\n *\n * Through a server-side cursor, in a read-only transaction, on a session\n * of its own, as `role`: a write is refused by the database, and a second\n * statement by the protocol. Each batch is read when the previous one has\n * been consumed, so memory holds one batch whatever the result's size;\n * every read runs under the session's `statement_timeout`. The session is\n * reset and returned when the cursor is closed — which the caller must do,\n * on success, failure or a reader that went away.\n */\n openSqlCursor?(sql: string, options?: { database?: string; role?: string; batchSize?: number }): Promise<SqlCursor>;\n\n /**\n * Fetch the available databases on the server.\n */\n fetchAvailableDatabases?(): Promise<string[]>;\n\n /**\n * Fetch the available *native PostgreSQL* database roles (from `pg_roles`).\n *\n * These are connection-level roles — what the SQL editor can `SET ROLE` to,\n * and what `SecurityRule.pgRoles` targets. They are NOT application roles;\n * for those use {@link fetchApplicationRoles}.\n */\n fetchAvailableRoles?(): Promise<string[]>;\n\n /**\n * Fetch the *application-level* roles in use in this project.\n *\n * These are the strings stored on the users table's `roles` column and\n * exposed to policies as `rebase.roles()` — what `SecurityRule.roles`\n * matches against. Distinct from {@link fetchAvailableRoles}; the two are\n * not interchangeable.\n */\n fetchApplicationRoles?(): Promise<string[]>;\n\n /**\n * Fetch the current database name.\n */\n fetchCurrentDatabase?(): Promise<string | undefined>;\n\n /**\n * Hold a `LISTEN` on `channel`, on a connection of its own that stays open\n * and comes back after a drop, and call `onNotify` with each payload — what\n * wakes the job worker when another process enqueues, instead of a poll\n * every two seconds.\n *\n * Resolves once listening, with the stop. Rejects when no such connection\n * can be had — an in-process database, a first connect that fails — so the\n * caller can fall back to polling. `onReconnect` is called each time a\n * dropped connection is listening again: whatever was sent meanwhile is\n * lost. `channel` must be a plain identifier.\n */\n listen?(\n channel: string,\n onNotify: (payload: string) => void,\n options?: { onReconnect?: () => void }\n ): Promise<() => Promise<void>>;\n}\n\n/**\n * Administrative operations for document-based databases (MongoDB, Firestore, etc.).\n * Used by future document administration tools.\n *\n * @group Admin\n */\nexport interface DocumentAdmin {\n /**\n * Execute an aggregation pipeline or equivalent query.\n */\n executeAggregate?(pipeline: Record<string, unknown>[]): Promise<Record<string, unknown>[]>;\n\n /**\n * Fetch statistics for a collection (document count, size, etc.).\n */\n fetchCollectionStats?(collectionName: string): Promise<{ count: number; sizeBytes?: number }>;\n}\n\n/**\n * Administrative operations for schema management.\n * Shared across SQL and document databases.\n *\n * @group Admin\n */\nexport interface SchemaAdmin {\n /**\n * Fetch database tables/collections not yet mapped to a Rebase collection.\n */\n fetchUnmappedTables?(mappedPaths?: string[]): Promise<string[]>;\n\n /**\n * Fetch column/field metadata for a single table/collection.\n * The return type is generic — SQL backends return TableMetadata,\n * document backends may return a different shape.\n */\n fetchTableMetadata?(tableName: string): Promise<unknown>;\n}\n\n/**\n * Metadata for a database branch.\n * @group Admin\n */\nexport interface BranchInfo {\n /** Branch name (without prefix). */\n name: string;\n /**\n * The PostgreSQL database the branch is — what to connect to, and what to\n * name as the source when copying it. As the branch's record says, never\n * derived from `name` again.\n */\n database: string;\n /** The database this branch was created from. */\n parentDatabase: string;\n /** When the branch was created. */\n createdAt: Date;\n /** Size in bytes, if available from the server. */\n sizeBytes?: number;\n}\n\n/**\n * Administrative operations for database branching.\n * Allows creating isolated database copies for development/preview workflows.\n *\n * @group Admin\n */\nexport interface BranchAdmin {\n /** Create a new branch (database copy) from the current or specified source database. */\n createBranch(name: string, options?: { source?: string }): Promise<BranchInfo>;\n\n /** Delete a branch database. Cannot delete the main/default database. */\n deleteBranch(name: string): Promise<void>;\n\n /** List all branches (databases that were created via branching). */\n listBranches(): Promise<BranchInfo[]>;\n\n /** Get info about a specific branch. */\n getBranchInfo(name: string): Promise<BranchInfo | undefined>;\n}\n\n/**\n * Union type for all admin capabilities.\n * A backend may implement any combination of these interfaces.\n *\n * Use type guards (`isSQLAdmin`, `isDocumentAdmin`, `isSchemaAdmin`, `isBranchAdmin`)\n * to safely narrow the type before calling methods.\n *\n * @group Admin\n */\nexport type DatabaseAdmin = Partial<SQLAdmin> & Partial<DocumentAdmin> & Partial<SchemaAdmin>\n & Partial<BranchAdmin> & Partial<SchemaEditingAdmin>;\n\n/**\n * Type guard: can this admin plan a live schema change?\n *\n * Planning is engine-specific — it renders DDL, a Drizzle schema and the\n * declarative SQL artifacts — so the implementation lives in the driver\n * package. The server detects the capability structurally, exactly as it does\n * for SQL, rather than importing an engine it is supposed to know nothing\n * about.\n *\n * @group Admin\n */\nexport function isSchemaEditingAdmin(admin: DatabaseAdmin | undefined): admin is SchemaEditingAdmin {\n return !!admin && typeof (admin as SchemaEditingAdmin).planSchemaChange === \"function\";\n}\n\n/**\n * Type guard: does this admin support SQL operations?\n * @group Admin\n */\nexport function isSQLAdmin(admin: DatabaseAdmin | undefined): admin is SQLAdmin {\n return !!admin && typeof (admin as SQLAdmin).executeSql === \"function\";\n}\n\n/**\n * Type guard: does this admin support document operations?\n * @group Admin\n */\nexport function isDocumentAdmin(admin: DatabaseAdmin | undefined): admin is DocumentAdmin {\n return !!admin && (\n typeof (admin as DocumentAdmin).executeAggregate === \"function\" ||\n typeof (admin as DocumentAdmin).fetchCollectionStats === \"function\"\n );\n}\n\n/**\n * Type guard: does this admin support schema management?\n * @group Admin\n */\nexport function isSchemaAdmin(admin: DatabaseAdmin | undefined): admin is SchemaAdmin {\n return !!admin && (\n typeof (admin as SchemaAdmin).fetchUnmappedTables === \"function\" ||\n typeof (admin as SchemaAdmin).fetchTableMetadata === \"function\"\n );\n}\n\n/**\n * Type guard: does this admin support database branching?\n * @group Admin\n */\nexport function isBranchAdmin(admin: DatabaseAdmin | undefined): admin is BranchAdmin {\n return !!admin && typeof (admin as BranchAdmin).createBranch === \"function\";\n}\n\n// =============================================================================\n// LIFECYCLE INTERFACES (1.4)\n// =============================================================================\n\n/**\n * Health check result returned by `healthCheck()`.\n * @group Lifecycle\n */\nexport interface HealthCheckResult {\n /** Whether the backend is healthy and able to serve requests. */\n healthy: boolean;\n /** Round-trip latency to the database in milliseconds. */\n latencyMs: number;\n /** Optional details (e.g., pool stats, replication lag). */\n details?: Record<string, unknown>;\n}\n\n/**\n * Lifecycle contract for backend components that hold resources\n * (database connections, WebSocket pools, timers, etc.).\n *\n * All methods are optional — simple backends (e.g., in-memory) can skip them.\n * @group Lifecycle\n */\nexport interface BackendLifecycle {\n /**\n * Initialize the backend: open connections, run migrations, seed data.\n * Called once during startup. Idempotent.\n */\n initialize?(): Promise<void>;\n\n /**\n * Check whether the backend is healthy and reachable.\n * Should be fast (< 1 s) and safe to call frequently.\n */\n healthCheck?(): Promise<HealthCheckResult>;\n\n /**\n * Gracefully shut down: close connections, flush buffers, cancel timers.\n * After calling `destroy()`, no other methods should be called.\n */\n destroy?(): Promise<void>;\n}\n\n// =============================================================================\n// BACKEND FACTORY INTERFACES\n// =============================================================================\n\n/**\n * Configuration for creating a database backend\n */\nexport interface BackendConfig {\n /**\n * Type of database backend\n */\n type: string;\n\n /**\n * Database connection (implementation-specific)\n */\n connection: unknown;\n\n /**\n * Schema definition (implementation-specific, e.g., Drizzle schema for PostgreSQL)\n */\n schema?: unknown;\n}\n\n/**\n * A complete backend instance with all required services.\n *\n * Now includes optional lifecycle management and admin capabilities.\n */\nexport interface BackendInstance extends BackendLifecycle {\n /**\n * Entity repository for CRUD operations\n */\n entityRepository: DataRepository;\n\n /**\n * Realtime provider for subscriptions\n */\n realtimeProvider: RealtimeProvider;\n\n /**\n * Collection registry\n */\n collectionRegistry: CollectionRegistryInterface;\n\n /**\n * The underlying database connection\n */\n connection: DatabaseConnection;\n\n /**\n * Administrative operations (SQL, schema, documents).\n * What's available depends on the backend type — use type guards\n * (`isSQLAdmin`, `isSchemaAdmin`, etc.) to narrow.\n */\n admin?: DatabaseAdmin;\n}\n\n/**\n * Factory function type for creating backend instances\n */\nexport type BackendFactory<TConfig extends BackendConfig = BackendConfig> =\n (config: TConfig) => BackendInstance;\n\n// =============================================================================\n// BACKEND BOOTSTRAPPER (1.2)\n// =============================================================================\n\n/**\n * A caller of the data API as its rate limiter buckets one outside an HTTP\n * request: a realtime socket's frame, counted in the same bucket the same\n * person's HTTP requests are.\n * @group Backend\n */\nexport interface DataRateLimitCaller {\n /**\n * The signed-in user's uid. Absent, or the anonymous principal, buckets\n * the caller by address at the anonymous allowance.\n */\n uid?: string;\n /**\n * The API key the connection authenticated with. Its frames count in the\n * key's own bucket at the key's own `rate_limit`, the bucket its HTTP\n * requests count in, rather than as `uid`.\n */\n apiKey?: { id: string; rate_limit: number | null };\n /**\n * A header of the request that opened the connection, by lower-case name:\n * the proxy headers an address is read from when proxies are declared.\n */\n header(name: string): string | undefined;\n /** The address the connection comes from. */\n socketAddress?: string;\n}\n\n/**\n * What the realtime socket shares with the HTTP data API: its request limits,\n * so a frame carries the ones an HTTP request to the same rows does, and its\n * API-key verification, so a key means the same thing on both.\n * @group Backend\n */\nexport interface RealtimeSocketOptions {\n /**\n * Verify an `rk_` API key presented to `AUTHENTICATE`: the identity it acts\n * as and the scopes it holds, or why it does not authenticate. Absent when\n * the deployment has no API-key store, and then keys cannot authenticate\n * the socket.\n */\n resolveApiKey?: (token: string) => Promise<{\n uid: string;\n roles: string[];\n scopes: string[];\n /** The key itself: its id and `rate_limit` decide which rate-limit bucket its frames count in. */\n apiKey?: { id: string; rate_limit: number | null };\n } | { message: string }>;\n /**\n * Verify a session token presented to `AUTHENTICATE` with the backend's own\n * keys, when the socket has no auth adapter to ask: who it names, or null\n * when it does not verify. The server passes its backend's; absent, the\n * driver falls back to the process default.\n */\n verifyAccessToken?: (token: string) => Promise<{\n uid: string;\n roles?: string[];\n isAnonymous?: boolean;\n claims?: Record<string, unknown>;\n } | null>;\n /**\n * The backend's access model — what a session's roles hold on the socket's\n * admin frames. The server passes its backend's; absent, the driver falls\n * back to the process default.\n */\n accessModel?: AccessModel;\n /**\n * The largest frame accepted, in bytes — the data API's body limit. `0` or\n * less for none. Defaults to the server's default body limit.\n */\n maxPayload?: number;\n /**\n * Count one data request from `caller` in the data API's per-caller\n * buckets and say whether it is allowed; `null` for a caller who is not\n * limited. Absent when the deployment has rate limiting off.\n */\n dataRateLimit?: (caller: DataRateLimitCaller) => Promise<{ allowed: boolean; retryAfterMs: number } | null>;\n /**\n * How often a socket that only listens has its identity re-read, in ms\n * (default 30 000). A socket that sends frames is re-checked before each\n * one; this bounds how long a subscription keeps receiving rows as an\n * identity that has since signed out, been revoked, demoted or deleted.\n */\n identityRecheckIntervalMs?: number;\n /**\n * How often a socket that asked for heartbeats (`?rebase_caps=heartbeat`)\n * is sent one, in ms (default 30 000). The SDK redials after two missed.\n */\n heartbeatIntervalMs?: number;\n /**\n * How many sockets one identity may hold open on this server; the next\n * one's `AUTHENTICATE` is refused with `TOO_MANY_SOCKETS` and the socket\n * closed (4002). Unset for no ceiling. See `realtime.maxSocketsPerUser`.\n */\n maxSocketsPerUser?: number;\n /**\n * The URL path the socket accepts upgrades on (default `/`). The SDK dials\n * its `baseUrl` with no extra segment, so for a server mounted at `/api`\n * this is `/`, and for one at `/app/api` it is `/app`. A trailing `/` is\n * ignored, and so is the query string.\n *\n * An upgrade to any other path is not this socket's: it is left untouched\n * for another `upgrade` listener on the same `http.Server` — a host app's\n * own WebSocket server — or, when the socket's listener is the only one,\n * answered `404` and closed, so the client is not left hanging.\n */\n path?: string;\n}\n\n/**\n * A `BackendBootstrapper` encapsulates all driver-specific initialization logic.\n *\n * Instead of hard-coding Postgres setup into `initializeRebaseBackend()`,\n * each database backend provides its own bootstrapper that knows how to:\n * - Create the DataDriver from a config object\n * - Optionally initialize auth tables\n * - Optionally create a realtime service\n * - Mount driver-specific API routes\n *\n * The main `initializeRebaseBackend()` becomes a **coordinator** that iterates\n * registered bootstrappers, calls their hooks, and wires the results together.\n *\n * @group Backend\n *\n * @example\n * ```typescript\n * // Third-party MySQL bootstrapper\n * const mysqlBootstrapper: BackendBootstrapper = {\n * type: \"mysql\",\n * initializeDriver: async (config) => new MySQLDataDriver(config.connection),\n * initializeRealtime: async (config) => new MySQLChangeStreamRealtime(config.connection),\n * };\n *\n * initializeRebaseBackend({\n * ...config,\n * bootstrappers: [postgresBootstrapper, mysqlBootstrapper]\n * });\n * ```\n */\nexport interface BackendBootstrapper {\n /**\n * Which driver type this bootstrapper handles.\n * Must match the `type` field on the driver config object\n * (e.g., `\"postgres\"`, `\"mongodb\"`, `\"mysql\"`).\n */\n type: string;\n\n /**\n * Unique identifier for this bootstrapper instance.\n * Used to register the driver in the driver registry.\n * Defaults to `type` if not set.\n */\n id?: string;\n\n /**\n * Whether this bootstrapper provides the default driver.\n * When true, the coordinator uses this driver as the primary one.\n */\n isDefault?: boolean;\n\n /**\n * Run database migrations for this driver.\n * Called by the coordinator after all drivers are initialized.\n */\n runMigrations?(config: unknown, driverResult: InitializedDriver): Promise<void>;\n\n /**\n * Create a DataDriver from the given config.\n * This is the only **required** method.\n */\n initializeDriver(config: unknown): Promise<InitializedDriver>;\n\n /**\n * Initialize auth tables / services if this driver supports them.\n * Return undefined if auth is not supported by this backend.\n */\n initializeAuth?(config: unknown, driverResult: InitializedDriver): Promise<BootstrappedAuth | undefined>;\n\n /**\n * Initialize history tables / services if this driver supports them.\n * Return undefined if history is not supported by this backend.\n */\n initializeHistory?(config: HistoryConfig, driverResult: InitializedDriver): Promise<{ historyService: unknown } | undefined>;\n\n /**\n * Create a realtime provider for this driver.\n * Return undefined if the driver does not support realtime.\n */\n initializeRealtime?(config: unknown, driverResult: InitializedDriver): Promise<RealtimeProvider | undefined>;\n\n /**\n * Never called: the server mounts no route on a bootstrapper's behalf. A\n * driver that needs HTTP routes exports a route factory, and the app\n * mounts it where it chooses.\n *\n * @deprecated Never called. Will be removed in a future release.\n */\n mountRoutes?(app: unknown, basePath: string, driverResult: InitializedDriver): void;\n\n /**\n * Return admin capabilities for this driver.\n */\n getAdmin?(driverResult: InitializedDriver): DatabaseAdmin | undefined;\n\n /**\n * Ask the database whether it is there, before anything else touches it.\n *\n * Boot's first database call is not `initializeDriver` — it is the schema\n * provisioning that runs ahead of it, and a driver's connection diagnosis\n * therefore never got the chance to run. A stopped database produced\n * `Failed query: [redacted]` and a stack through drizzle internals: no host,\n * no port, no `ECONNREFUSED`, and no hint about starting the thing.\n *\n * Implementations MUST issue the cheapest round trip they have (`SELECT 1`),\n * MUST throw an error whose message names the host, the port and the\n * driver's own reason, and MAY log a fuller diagnosis first. They MUST NOT\n * throw for a reachable database that merely answered something unexpected —\n * the caller treats a throw as fatal.\n *\n * `driverResult` is optional for the same reason as\n * {@link ensureCollectionSchema}: this runs before `initializeDriver`, so an\n * adapter that was constructed with its own connection has to fall back to\n * it.\n */\n verifyConnection?(driverResult?: InitializedDriver): Promise<void>;\n\n /**\n * Bring the database's collection tables up to date, additively.\n *\n * Optional because it is only meaningful for schema-ful drivers. A managed\n * runtime boots a compiled project against a database it has never seen; auth\n * tables are ensured on boot but collection tables were created by nothing,\n * so every data request answered 500 on a missing relation. The CLI's `db\n * push` cannot fill the gap — it needs Atlas, and the runtime image ships no\n * CLI.\n *\n * Implementations MUST be additive-only: create missing tables, columns and\n * enum types, and never drop, narrow or rewrite anything. This runs\n * unattended against live customer data with nobody reading a diff, so the\n * destructive half stays a deliberate migration.\n *\n * `driverResult` is optional: this runs before `initializeDriver`, and only\n * the bundle path has a pre-init stand-in to pass. An adapter built by an\n * application already holds its own connection and MUST use it when this is\n * `undefined` — dereferencing it unconditionally works for managed tenants\n * and breaks every app that builds its own adapter.\n */\n ensureCollectionSchema?(\n collections: unknown[],\n driverResult?: InitializedDriver,\n log?: (message: string) => void\n ): Promise<{ applied: number }>;\n\n /**\n * Apply the collections' row-level-security policies, additively and\n * idempotently — the companion to {@link ensureCollectionSchema}.\n *\n * That method creates the tables; a table with RLS disabled and no policies\n * is not servable, because authenticated requests run as a restricted role:\n * a read with no `SELECT` policy returns nothing (a public collection\n * answers 401) and a write with no `INSERT`/`UPDATE` policy is denied. The\n * `db push` CLI applies these from the same collections, but it cannot reach\n * a managed tenant's in-cluster database — the runtime, already connected,\n * is the only thing that can.\n *\n * MUST be idempotent (re-run on every boot) and MUST NOT be destructive.\n * Runs after auth initialization, because the generated policies call the\n * `auth.*` helper functions and `CREATE POLICY` validates they exist.\n */\n ensureCollectionPolicies?(\n collections: unknown[],\n driverResult?: InitializedDriver,\n log?: (message: string) => void\n ): Promise<{ applied: number }>;\n\n /**\n * Create the RLS helper functions on this source's database. See\n * `DatabaseAdapter.ensureRlsRuntime`; needed on every source that is not\n * the default, whose helpers arrive with the auth tables.\n */\n ensureRlsRuntime?(driverResult?: InitializedDriver): Promise<void>;\n\n /**\n * Re-check, after the schema exists, that requests will actually be\n * constrained by the database's own authorization.\n *\n * A driver that isolates user requests by switching to a restricted role has\n * to decide at connect time whether the switch is needed — and on a fresh\n * database that question is asked before there is anything to answer with.\n * The process then creates the schema, becomes its owner, and an owner is\n * exempt from the policies on what it owns. So the answer that was true when\n * the driver initialized can be false by the time it serves a request.\n *\n * This is where a driver asks again. It runs once, after collection tables,\n * auth tables and policies are all in place, and it MUST fail rather than\n * serve when the answer changed and cannot be acted on: booting anyway\n * produces exactly the unenforced server this exists to prevent.\n *\n * Optional, because it is only meaningful for drivers whose isolation\n * depends on state the schema affects. A driver with nothing to re-check\n * omits it.\n */\n finalizeSecurityPosture?(driverResult: InitializedDriver): Promise<void>;\n\n /**\n * Read the collections schema version this database was last provisioned\n * from, or `null` when nothing has ever stamped it.\n *\n * The companion to {@link stampCollectionsSchemaVersion}: one process writes\n * what it applied, every other process compares itself to it. This is what\n * lets a split deployment — several processes over one database, only one of\n * which provisions — notice that a unit is serving against a schema it was\n * not built for. That failure is otherwise silent in both directions: a\n * column that does not exist is a SQL error on one route, and a policy that\n * was never applied is a 200 with no rows.\n *\n * `null` is not an error and MUST NOT be treated as one — every database\n * provisioned before the stamp existed reads this way, and so does every\n * fresh one until its first provisioning boot finishes.\n */\n readCollectionsSchemaVersion?(\n driverResult?: InitializedDriver\n ): Promise<string | null>;\n\n /**\n * Record the collections schema version this process just applied.\n *\n * Called only by the process that provisions, and only after both\n * {@link ensureCollectionSchema} and {@link ensureCollectionPolicies} have\n * run — a stamp written before the policies would claim a schema that is\n * only half in place, and the half that is missing is the one that fails\n * without an error.\n */\n stampCollectionsSchemaVersion?(\n version: string,\n driverResult?: InitializedDriver\n ): Promise<void>;\n\n /**\n * Initialize WebSocket server for realtime operations.\n */\n initializeWebsockets?(server: unknown, realtimeService: RealtimeProvider, driver: import(\"../controllers/data_driver\").DataDriver, config?: unknown, authAdapter?: AuthAdapter, limits?: RealtimeSocketOptions): Promise<void> | void;\n}\n\n/**\n * Result of `BackendBootstrapper.initializeDriver()`.\n * @group Backend\n */\nexport interface InitializedDriver {\n /** The DataDriver instance, ready for use. */\n driver: import(\"../controllers/data_driver\").DataDriver;\n\n /** The realtime service, if the driver created one during init. */\n realtimeProvider?: RealtimeProvider;\n\n /** A collection registry to register schema / tables into. */\n collectionRegistry?: CollectionRegistryInterface;\n\n /**\n * Collections the driver derived from the live database schema.\n *\n * Set by drivers that introspect in `baas` mode; the server serves these\n * instead of collections loaded from config files.\n */\n collections?: import(\"./collections\").CollectionConfig[];\n\n /**\n * Tables the driver found while deriving {@link collections} and did not\n * serve, because row-level security is disabled on them and so they have\n * no authorization model (`baas.unprotectedTables` left at `\"exclude\"`).\n *\n * The server names them when it has nothing to serve: \"the database has\n * no tables\" is untrue of a database whose tables were all skipped.\n */\n unprotectedTables?: { schema: string; table: string }[];\n\n /** The underlying database connection (for lifecycle management). */\n connection?: DatabaseConnection;\n\n /**\n * Opaque handle that the bootstrapper can use in subsequent hooks\n * (e.g., `initializeAuth`, `getAdmin`) to access driver internals.\n * Not used by the coordinator.\n */\n internals?: unknown;\n}\n\n/**\n * Result of `BackendBootstrapper.initializeAuth()`.\n * @group Backend\n */\nexport interface BootstrappedAuth {\n /** User management service. */\n userService: unknown;\n /** Email service (optional). */\n emailService?: unknown;\n /** Combined Auth Repository for unified token and user management. */\n authRepository?: unknown;\n /**\n * Whether the auth schema in the database is one this runtime can serve.\n *\n * Folded into `healthCheck()` so a schema mismatch shows up as a degraded\n * health response. Without it, a server whose auth is entirely broken still\n * reports healthy — the database connection it probes is fine, and the\n * mismatch is only discovered one failed login at a time.\n */\n schemaHealthCheck?(): Promise<AuthSchemaHealth>;\n}\n\n/**\n * Result of {@link BootstrappedAuth.schemaHealthCheck}.\n * @group Lifecycle\n */\nexport interface AuthSchemaHealth {\n /** False when this runtime cannot be trusted to serve auth against this database. */\n healthy: boolean;\n /** Human-readable descriptions of each mismatch found. Empty when healthy. */\n problems: string[];\n /** Auth schema version recorded in the database, when it records one. */\n databaseVersion?: number | null;\n /** Auth schema version this runtime expects. */\n runtimeVersion?: number;\n}\n"],"mappings":";;;;;;;;;;;;;;;;AA82BA,SAAgB,qBAAqB,OAA+D;CAChG,OAAO,CAAC,CAAC,SAAS,OAAQ,MAA6B,qBAAqB;AAChF;;;;;AAMA,SAAgB,WAAW,OAAqD;CAC5E,OAAO,CAAC,CAAC,SAAS,OAAQ,MAAmB,eAAe;AAChE"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"cron-store-DkyfuLOH.js","names":[],"sources":["../src/cron/cron-store.ts"],"sourcesContent":["import type { CronJobLogEntry } from \"@rebasepro/types\";\nimport type { DataDriver } from \"@rebasepro/types\";\nimport { isSQLAdmin } from \"@rebasepro/types\";\nimport { REBASE_USER_ROLE, revokeInternalTableSql } from \"@rebasepro/common\";\nimport { logger } from \"../utils/logger.js\";\nimport { createDdlBootstrapper, hasInCauseChain } from \"../boot/ddl-bootstrap.js\";\n\n/**\n * Persistence layer for cron: the run history (`rebase.cron_logs`), the slot\n * claims that keep replicas from running the same slot twice\n * (`rebase.cron_claims`), and each job's shared state — the enabled override\n * and the run lease — in `rebase.cron_job_state`.\n *\n * Uses the DataDriver's `admin.executeSql` capability. Falls back gracefully if\n * the driver doesn't support SQL (e.g. MongoDB) — in that case, no persistence\n * occurs, and a pause or a trigger's \"already executing\" is per process.\n */\nexport interface CronStore {\n /**\n * Ensure the backing tables exist and are locked away from end users. Runs\n * once per process per database, however many stores ask: concurrent\n * callers share the attempt in flight, and an attempt that fell short is\n * made again on the next call.\n */\n ensureTable(): Promise<void>;\n\n /** Persist a single log entry after execution. */\n insertLog(entry: CronJobLogEntry): Promise<void>;\n\n /**\n * Fetch the most recent logs for a job.\n *\n * Throws when the history cannot be read. An empty array is a claim — the\n * job has no recorded runs — and a caller shows it as \"never ran\".\n *\n * @param jobId The job identifier\n * @param limit Max entries to return (default 50)\n * @returns Logs sorted newest-first\n */\n fetchLogs(jobId: string, limit?: number): Promise<CronJobLogEntry[]>;\n\n /**\n * Fetch aggregate stats for all jobs (totalRuns, totalFailures, lastRunAt).\n * Used to seed in-memory counters on startup. The SQL store reads them from\n * the all-time counters on `cron_job_state`, not from the history.\n */\n fetchJobStats(): Promise<Map<string, { totalRuns: number; totalFailures: number; lastRunAt?: string }>>;\n\n /**\n * Atomically claim a scheduled run slot for a job.\n *\n * `slot` is the *scheduled* fire time (ISO string) derived from the cron\n * expression — deterministic across instances regardless of timer drift,\n * so all instances contend on the same (jobId, slot) key. Exactly one\n * caller wins the insert against the unique constraint and executes;\n * the rest skip.\n *\n * Throws when the store cannot tell — a missing or unreadable claims\n * table, a dropped connection. What that means is the caller's decision,\n * and the scheduler's two callers decide differently: a scheduled run\n * fails open, so a broken claims table degrades to uncoordinated execution\n * rather than silently never running jobs; a catch-up fails closed, so it\n * does not re-run the last slot on every boot of every replica.\n *\n * Optional so custom stores written against the pre-claims interface\n * keep working — the scheduler treats a missing implementation as\n * uncoordinated (always run).\n */\n tryClaimRun?(jobId: string, slot: string): Promise<boolean>;\n\n // ─── Fleet-wide job state ────────────────────────────────────────\n //\n // Everything below lives in `rebase.cron_job_state`, one row per job that\n // has ever been paused, resumed or run under a lease. It exists because the\n // alternative — each process's memory — is per process: a pause served by\n // one replica stopped only that replica, and on a split deployment the\n // process serving the admin API is not the one whose timers fire, so a\n // pause there stopped nothing at all.\n //\n // Optional, like `tryClaimRun`, so a custom store written against the\n // older interface keeps working: without them a pause is local to the\n // process that served it and a manual trigger is guarded only in-process.\n\n /**\n * Persist a job's enabled override: `true` or `false`, or `null` to hand the\n * decision back to the code's own `enabled`. Survives restarts and\n * redeploys — that is the point. Throws when the write fails.\n */\n saveEnabledOverride?(jobId: string, enabled: boolean | null, updatedBy?: string): Promise<void>;\n\n /**\n * The persisted state of the jobs asked about. A job with no row has no\n * override. `runningBy` is set only while a lease is live.\n *\n * Throws when the store cannot tell. The scheduler falls back to the code's\n * `enabled` then — a scheduled run fails open — and a listing falls back to\n * what this process knows.\n */\n fetchJobStates?(jobIds: readonly string[]): Promise<Map<string, CronJobPersistedState>>;\n\n /**\n * Take the job's run lease unless another process holds a live one.\n *\n * The cross-process half of \"already executing\": a manual trigger, a\n * scheduled run and a catch-up all take it, so none of them overlaps a run\n * another replica — or the worker, from the api role — is making. The lease\n * expires on its own after `ttlSeconds`, which is what frees a job whose\n * holder crashed.\n *\n * Throws when the store cannot tell.\n */\n tryAcquireRunLease?(jobId: string, holder: string, ttlSeconds: number): Promise<CronRunLease>;\n\n /** Release a lease taken with {@link tryAcquireRunLease} — only if `holder` still holds it. */\n releaseRunLease?(jobId: string, holder: string): Promise<void>;\n\n /**\n * Run count, failure count and last run of each job — every process's\n * runs, where the scheduler's own counters hold only the ones it made. The\n * SQL store keeps them as all-time counters on `cron_job_state`, so they\n * outlive the history's retention.\n */\n fetchRunSummaries?(jobIds: readonly string[]): Promise<Map<string, CronJobRunSummary>>;\n\n /**\n * Delete slot claims past retention — all but each job's most recent one —\n * in batches. Resolves with how many went. The scheduler runs it on a\n * timer while it is started; a store without it keeps every claim.\n */\n sweepClaims?(): Promise<number>;\n\n /**\n * Delete run history past retention, and count into the job counters any\n * run that is not counted yet — a history written before the counters\n * existed, or by an older server during a rolling deploy. Both in batches.\n * The scheduler runs it with {@link sweepClaims}; a store without it keeps\n * every run.\n */\n sweepLogs?(): Promise<CronLogSweep>;\n}\n\n/** What one {@link CronStore.sweepLogs} did. */\nexport interface CronLogSweep {\n /** History rows deleted past retention. */\n deleted: number;\n /** Runs counted into the job counters that were not counted before. */\n counted: number;\n}\n\n/** One job's row in `rebase.cron_job_state`, as the scheduler reads it. */\nexport interface CronJobPersistedState {\n /** The override, or `null` to follow the code's `enabled`. */\n enabled: boolean | null;\n /** Who holds the run lease, when a live one is held. */\n runningBy?: string;\n}\n\n/** The outcome of {@link CronStore.tryAcquireRunLease}. */\nexport type CronRunLease =\n | { acquired: true }\n | { acquired: false; holder?: string };\n\n/** A job's run history in brief, read from `cron_logs`. */\nexport interface CronJobRunSummary {\n totalRuns: number;\n totalFailures: number;\n lastRunAt?: string;\n lastDurationMs?: number;\n lastSuccess?: boolean;\n lastError?: string;\n}\n\n// ─── SQL-based implementation ────────────────────────────────────────\n\nconst TABLE = \"rebase.cron_logs\";\nconst CLAIMS_TABLE = \"rebase.cron_claims\";\nconst STATE_TABLE = \"rebase.cron_job_state\";\n\n/** `$1, $2, …` for `count` values, starting at `$first`. */\nfunction placeholders(count: number, first = 1): string {\n return Array.from({ length: count }, (_, i) => `$${first + i}`).join(\", \");\n}\n\n/**\n * Claims older than this are garbage-collected — all but each job's most\n * recent one, which is kept however old it is; see `sweepClaims`.\n */\nconst CLAIM_RETENTION_DAYS = 7;\n\n/** Claims one retention statement deletes; a sweep repeats it until a batch comes back short. */\nconst CLAIM_SWEEP_BATCH = 5_000;\n\n/**\n * How far ahead a claim may legitimately sit. Slots are claimed as they fire,\n * so anything beyond this is a clock-skewed peer at best and a stranded claim\n * at worst; see the sweep in `ensureTable`.\n */\nconst FUTURE_CLAIM_SKEW_MINUTES = 2;\n\n/**\n * How long a run stays in `cron_logs`, which is what the history panel and\n * `GET /api/admin/cron/:id/logs` show. The run and failure counts do not\n * shrink with it: they are kept on `cron_job_state` for all time.\n *\n * Nothing used to delete from `cron_logs`: twelve ordinary jobs wrote 1.5M rows\n * and 617 MB a year, and the counts were `count(*)` over all of it on every\n * poll of the Studio panel.\n */\nconst LOG_RETENTION_DAYS = 30;\n\n/** History rows one retention or counting statement handles; a sweep repeats it until a batch comes back short. */\nconst LOG_SWEEP_BATCH = 5_000;\n\n/**\n * Whether the run being folded in is at least as recent as the job's last\n * one. The last-run fields only move forward: a long run is logged when it\n * ends, after the skip that started while it ran, and a counting batch can\n * hold any run of the job's history.\n */\nconst IS_NEWER_RUN = \"(s.last_run_at IS NULL OR EXCLUDED.last_run_at >= s.last_run_at)\";\n\n/**\n * Add a set of runs to their jobs' counters on `cron_job_state`, as one\n * `INSERT … ON CONFLICT`. `runs` names a relation of the runs —\n * `job_id, started_at, duration_ms, success, error` — and becomes one row per\n * job: how many, how many failed, and the most recent of them.\n *\n * Ordered by job, so two statements folding the same jobs lock their rows in\n * the same order and cannot deadlock.\n */\nfunction foldIntoCounters(runs: string): string {\n return `\n INSERT INTO ${STATE_TABLE} AS s\n (job_id, total_runs, total_failures, last_run_at, last_duration_ms, last_success, last_error)\n SELECT tally.job_id, tally.runs, tally.failures,\n latest.started_at, latest.duration_ms, latest.success, latest.error\n FROM (\n SELECT job_id, count(*) AS runs, count(*) FILTER (WHERE NOT success) AS failures\n FROM ${runs} GROUP BY job_id\n ) AS tally\n JOIN (\n SELECT DISTINCT ON (job_id) job_id, started_at, duration_ms, success, error\n FROM ${runs} ORDER BY job_id, started_at DESC\n ) AS latest ON latest.job_id = tally.job_id\n ORDER BY tally.job_id\n ON CONFLICT (job_id) DO UPDATE SET\n total_runs = s.total_runs + EXCLUDED.total_runs,\n total_failures = s.total_failures + EXCLUDED.total_failures,\n last_run_at = CASE WHEN ${IS_NEWER_RUN} THEN EXCLUDED.last_run_at ELSE s.last_run_at END,\n last_duration_ms = CASE WHEN ${IS_NEWER_RUN} THEN EXCLUDED.last_duration_ms ELSE s.last_duration_ms END,\n last_success = CASE WHEN ${IS_NEWER_RUN} THEN EXCLUDED.last_success ELSE s.last_success END,\n last_error = CASE WHEN ${IS_NEWER_RUN} THEN EXCLUDED.last_error ELSE s.last_error END`;\n}\n\n/**\n * SQLSTATEs that mean the counters are not there to write to — a column or\n * table a failed boot did not create, or a privilege it did not get. A log\n * insert that meets one is written without its count; the next sweep counts\n * it. Anything else is not retried, because a statement that failed after it\n * committed would then be logged — and counted — twice.\n */\nconst COUNTERS_MISSING_SQLSTATES = new Set([\"42703\", \"42P01\", \"42501\"]);\n/**\n * Detect a unique-constraint violation anywhere in an error's cause chain.\n * Match the SQLSTATE code, never message text. Also covers SQLite\n * (\"UNIQUE constraint failed\") and MySQL (ER_DUP_ENTRY 1062) for future SQL\n * drivers.\n *\n * Distinct from `isConcurrentDdlRace` in `boot/ddl-bootstrap.ts`, which shares\n * the 23505 code but asks a different question — that one is about a losing\n * `CREATE`, this one is about a losing claim, and only the latter means\n * \"another instance already has this slot\".\n */\nfunction isUniqueViolation(err: unknown): boolean {\n return hasInCauseChain(err, (e) =>\n e.code === \"23505\" ||\n e.errno === 1062 ||\n (typeof e.message === \"string\" && e.message.includes(\"UNIQUE constraint failed\"))\n );\n}\n\n/**\n * The setup each database is going through right now, in this process. The\n * scheduler, the expired-token sweep and the history retention sweep each make\n * a store on the same driver, and three setups at once ran the same REVOKEs\n * concurrently — which Postgres can answer with \"tuple concurrently updated\",\n * failing a security control at boot. Keyed on the driver, so a second\n * database gets a setup of its own.\n *\n * Only the attempt in flight is shared. A later call runs the setup again —\n * every statement in it is idempotent — because the tables can be gone by\n * then: a database reset under a running server, or a suite that drops the\n * schema between tests. A process that remembered \"set up\" would never bring\n * them back.\n */\nconst setUpsInFlight = new WeakMap<DataDriver, Promise<void>>();\n\n/** Databases this process has already said \"ready\" for, so it says so once. */\nconst announcedReady = new WeakSet<DataDriver>();\n\nfunction ensureSharedInFlight(driver: DataDriver, setUp: () => Promise<boolean>): Promise<void> {\n const current = setUpsInFlight.get(driver);\n if (current) return current;\n const attempt = setUp().then(() => undefined).finally(() => {\n if (setUpsInFlight.get(driver) === attempt) setUpsInFlight.delete(driver);\n });\n setUpsInFlight.set(driver, attempt);\n return attempt;\n}\n\nexport function createCronStore(driver: DataDriver): Required<CronStore> | undefined {\n const admin = driver.admin;\n if (!isSQLAdmin(admin)) {\n logger.warn(\"⚠️ [cron-store] DataDriver does not support SQL admin — cron logs will not be persisted.\");\n return undefined;\n }\n\n const exec = (sqlText: string, options?: { params?: unknown[] }) =>\n admin.executeSql(sqlText, options?.params ? { params: options.params } : undefined);\n\n const ddl = createDdlBootstrapper(exec, \"cron-store\");\n\n /** Whether `rebase.<table>` has `column`. False when it cannot tell, so the idempotent ALTER still runs. */\n const hasColumn = async (table: string, column: string): Promise<boolean> => {\n try {\n const rows = await exec(\n `SELECT 1 FROM information_schema.columns\n WHERE table_schema = 'rebase' AND table_name = $1 AND column_name = $2`,\n { params: [table, column] }\n );\n return (rows ?? []).length > 0;\n } catch {\n return false;\n }\n };\n\n /**\n * Whether a boot has nothing to do: the three tables, the three indexes and\n * the run-counter columns are there, the dropped index is gone, and the\n * end-user role holds no privilege on any of the tables. One catalogue\n * read, where every boot used to run eleven statements — `CREATE … IF NOT\n * EXISTS` takes its table's lock before it finds the object there, and a\n * `REVOKE` rewrites the table's catalogue row even when nothing is granted.\n * Anything missing, or a read that fails, and the boot runs them all.\n */\n const bootIsCurrent = async (): Promise<boolean> => {\n const objects = [\"cron_logs\", \"cron_claims\", \"cron_job_state\", \"idx_cron_logs_job\", \"idx_cron_logs_uncounted\", \"idx_cron_logs_started\"];\n const columns = [\"cron_logs.counted\", ...[\"total_runs\", \"total_failures\", \"last_run_at\", \"last_duration_ms\", \"last_success\", \"last_error\"].map(c => `cron_job_state.${c}`)];\n const quoted = (names: string[]) => names.map(name => `'${name}'`).join(\", \");\n try {\n const rows = await exec(`\n WITH objects AS (\n SELECT c.oid, c.relname, c.relkind, c.relrowsecurity, c.relacl FROM pg_class c\n WHERE c.relnamespace = to_regnamespace('rebase')\n ), target AS (\n SELECT oid FROM pg_roles WHERE rolname = '${REBASE_USER_ROLE}'\n )\n SELECT\n (SELECT count(*) FROM objects WHERE relname IN (${quoted(objects)}))::int AS present,\n (SELECT count(*) FROM objects WHERE relname = 'idx_cron_logs_job_failures')::int AS stale,\n (SELECT count(*) FROM information_schema.columns\n WHERE table_schema = 'rebase' AND table_name || '.' || column_name IN (${quoted(columns)}))::int AS columns,\n (SELECT count(*) FROM objects o\n WHERE o.relkind = 'r' AND o.relname IN ('cron_logs', 'cron_claims', 'cron_job_state')\n AND NOT o.relrowsecurity AND EXISTS (SELECT 1 FROM target)\n AND (o.relacl IS NULL OR EXISTS (\n SELECT 1 FROM aclexplode(o.relacl) a WHERE a.grantee = (SELECT oid FROM target)\n )))::int AS granted\n `);\n const row = rows?.[0];\n return Number(row?.present) === objects.length && Number(row?.stale) === 0\n && Number(row?.columns) === columns.length && Number(row?.granted) === 0;\n } catch {\n return false;\n }\n };\n\n /**\n * Drop claims for slots that have not happened yet. A slot is claimed at\n * the moment it fires, so a future one can only come from a timer that\n * woke early — and because claims are permanent, that claim would silently\n * skip the real run when it finally came due. The margin keeps a legitimate\n * claim made moments early by a clock-skewed peer.\n */\n const releaseFutureClaims = () => ddl.step(\"Future-slot claim sweep\", async () => {\n const stranded = await exec(\n `DELETE FROM ${CLAIMS_TABLE}\n WHERE slot > now() + make_interval(mins => $1)\n RETURNING job_id, slot`,\n { params: [FUTURE_CLAIM_SKEW_MINUTES] }\n );\n // A driver that does not honour RETURNING gives back nothing; the\n // rows are only used to report, so treat that as \"none\".\n for (const row of (stranded ?? []) as { job_id: string; slot: string }[]) {\n logger.warn(\n `[cron-store] Released a claim on the future slot ${new Date(row.slot).toISOString()} ` +\n `for \"${row.job_id}\" — it was claimed by a timer that fired early, and would ` +\n \"otherwise have skipped that run\"\n );\n }\n });\n\n /** Run one sweep statement until a batch comes back short. Resolves with the total of `column`. */\n const inBatches = async (sqlText: string, params: unknown[], column: string): Promise<number> => {\n let total = 0;\n for (;;) {\n const rows = await exec(sqlText, { params });\n const done = Number((rows?.[0] as Record<string, unknown> | undefined)?.[column] ?? 0);\n total += done;\n if (done < LOG_SWEEP_BATCH) return total;\n }\n };\n\n /**\n * The boot setup. Resolves with whether every table came up and was locked\n * away; anything short of that has been reported, and is worth another try.\n */\n const announceReady = (): void => {\n if (announcedReady.has(driver)) return;\n announcedReady.add(driver);\n logger.info(\"✅ Cron logs table ready\");\n };\n\n const setUp = async (): Promise<boolean> => {\n // A warm boot: everything is there and locked away. The future\n // claims are about the slots this boot is about to fire, so they\n // are released either way.\n if (await bootIsCurrent()) {\n await releaseFutureClaims();\n announceReady();\n return true;\n }\n\n // Creation. Every statement here is idempotent, so losing the race\n // to a peer that booted at the same moment is survivable — but only\n // if the loser retries rather than abandoning everything below it.\n // One step each, so a hard failure on any one of them does not take\n // the others with it. The claims table in particular must not be\n // lost because an index on the *logs* table could not be built.\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 job_id TEXT NOT NULL,\n started_at TIMESTAMPTZ NOT NULL,\n finished_at TIMESTAMPTZ NOT NULL,\n duration_ms INTEGER NOT NULL,\n success BOOLEAN NOT NULL DEFAULT true,\n error TEXT,\n result JSONB,\n logs JSONB,\n manual BOOLEAN NOT NULL DEFAULT false\n )\n `);\n\n await ddl.ensureObject(\"Creating idx_cron_logs_job\", `\n CREATE INDEX IF NOT EXISTS idx_cron_logs_job\n ON ${TABLE}(job_id, started_at DESC)\n `);\n\n // Whether a run is in its job's counters. A run logged by this\n // server is counted in the statement that logs it; NULL is a run\n // written before the counters existed, or by an older server\n // during a rolling deploy, which `sweepLogs` counts. Asked of the\n // catalog first: `ADD COLUMN IF NOT EXISTS` takes an ACCESS\n // EXCLUSIVE lock on the table even when the column is there.\n if (!(await hasColumn(\"cron_logs\", \"counted\"))) {\n await ddl.ensureObject(`Adding ${TABLE}.counted`, `\n ALTER TABLE ${TABLE} ADD COLUMN IF NOT EXISTS counted BOOLEAN\n `);\n }\n\n // The runs not counted yet — every row of a history that predates\n // the counters, then nothing. What the counting sweep walks, and\n // what a listing adds to the counters until it has.\n await ddl.ensureObject(\"Creating idx_cron_logs_uncounted\", `\n CREATE INDEX IF NOT EXISTS idx_cron_logs_uncounted\n ON ${TABLE}(job_id, started_at DESC) WHERE counted IS NULL\n `);\n\n // The retention sweep's index: it deletes the oldest rows first,\n // whatever job wrote them.\n await ddl.ensureObject(\"Creating idx_cron_logs_started\", `\n CREATE INDEX IF NOT EXISTS idx_cron_logs_started\n ON ${TABLE}(started_at)\n `);\n\n // It held the failed runs, for a failure count over the whole\n // history on every listing. The count is a counter now.\n await ddl.ensureObject(\"Dropping idx_cron_logs_job_failures\",\n \"DROP INDEX IF EXISTS rebase.idx_cron_logs_job_failures\");\n\n await ddl.ensureObject(`Creating ${CLAIMS_TABLE}`, `\n CREATE TABLE IF NOT EXISTS ${CLAIMS_TABLE} (\n job_id TEXT NOT NULL,\n slot TIMESTAMPTZ NOT NULL,\n claimed_at TIMESTAMPTZ NOT NULL DEFAULT now(),\n PRIMARY KEY (job_id, slot)\n )\n `);\n\n // One row per job that has been paused, resumed or run under a\n // lease. `enabled` NULL means \"follow the code\"; `updated_at` and\n // `updated_by` say who last set it, and stay NULL on a row that has\n // only ever held a lease. Created here rather than by a migration,\n // so a database that predates it gains it on its next boot.\n await ddl.ensureObject(`Creating ${STATE_TABLE}`, `\n CREATE TABLE IF NOT EXISTS ${STATE_TABLE} (\n job_id TEXT PRIMARY KEY,\n enabled BOOLEAN,\n updated_at TIMESTAMPTZ,\n updated_by TEXT,\n running_until TIMESTAMPTZ,\n running_by TEXT\n )\n `);\n\n // Each job's all-time run counts and its last run, kept in the\n // statement that logs the run, so a listing is one row per job\n // however long the history — and outlives its retention.\n if (!(await hasColumn(\"cron_job_state\", \"total_runs\"))) {\n await ddl.ensureObject(`Adding the run counters to ${STATE_TABLE}`, `\n ALTER TABLE ${STATE_TABLE}\n ADD COLUMN IF NOT EXISTS total_runs BIGINT NOT NULL DEFAULT 0,\n ADD COLUMN IF NOT EXISTS total_failures BIGINT NOT NULL DEFAULT 0,\n ADD COLUMN IF NOT EXISTS last_run_at TIMESTAMPTZ,\n ADD COLUMN IF NOT EXISTS last_duration_ms INTEGER,\n ADD COLUMN IF NOT EXISTS last_success BOOLEAN,\n ADD COLUMN IF NOT EXISTS last_error TEXT\n `);\n }\n\n // Everything from here is keyed on what actually exists, not on\n // whether *this* instance is the one that created it. A single\n // failure above used to abandon the rest of this method, which meant\n // the loser of a boot race skipped the sweeps and — far worse — the\n // privilege revocation, leaving the claims table writable by end\n // users on an instance that reported nothing but a warning about\n // log persistence.\n const [logsReady, claimsReady, stateReady] = await Promise.all([\n ddl.isReadable(TABLE),\n ddl.isReadable(CLAIMS_TABLE),\n ddl.isReadable(STATE_TABLE)\n ]);\n\n if (claimsReady) {\n await releaseFutureClaims();\n\n // The retention sweep is not here any more: it ran at boot\n // and only at boot, so a scheduler that lived for months kept\n // every claim it made — 525,000 a year for one per-minute job\n // — until the next deploy deleted them in the boot path. The\n // scheduler runs `sweepClaims` on a timer instead.\n }\n\n // Neither table is a collection, so neither carries RLS, while the\n // Postgres driver's schema-wide grant reaches both. Cron logs hold\n // job output — arbitrary application data — and a writable\n // `cron_claims` lets any signed-in user suppress a scheduled run by\n // claiming its slot. This is a security control, so it is re-applied\n // by every instance on every boot, whatever else went wrong.\n if (logsReady) {\n await ddl.step(\"Revoking end-user access to cron_logs\", () =>\n exec(revokeInternalTableSql(\"rebase\", \"cron_logs\")));\n }\n if (claimsReady) {\n await ddl.step(\"Revoking end-user access to cron_claims\", () =>\n exec(revokeInternalTableSql(\"rebase\", \"cron_claims\")));\n }\n // A writable `cron_job_state` would let any signed-in user pause a\n // job for the whole fleet, or hold its run lease so that nothing\n // runs it.\n if (stateReady) {\n await ddl.step(\"Revoking end-user access to cron_job_state\", () =>\n exec(revokeInternalTableSql(\"rebase\", \"cron_job_state\")));\n }\n\n if (logsReady && claimsReady && stateReady) {\n announceReady();\n return true;\n }\n // Say which capability is gone, and what it costs. \"Continuing\n // without cron log persistence\" undersold this: the claims table is\n // the only thing stopping every instance from running every job.\n if (!claimsReady) {\n logger.error(\n `❌ [cron-store] ${CLAIMS_TABLE} is unavailable — scheduled runs cannot be coordinated. ` +\n \"With more than one app instance, every instance will now run every job on every tick.\"\n );\n }\n if (!logsReady) {\n logger.warn(`⚠️ [cron-store] ${TABLE} is unavailable — cron run history will not be persisted.`);\n }\n if (!stateReady) {\n logger.warn(\n `⚠️ [cron-store] ${STATE_TABLE} is unavailable — pausing a job reaches only the process ` +\n \"that served the request, and jobs run as their code declares on every other one. \" +\n \"A manual trigger is not kept off a run another process is making.\"\n );\n }\n return false;\n };\n\n return {\n ensureTable: () => ensureSharedInFlight(driver, setUp),\n\n async sweepClaims(): Promise<number> {\n // Garbage-collect old claims — except each job's most recent one.\n // A claim is also the record that a slot already ran, and the\n // catch-up at boot asks exactly that about the job's latest slot,\n // as far back as its `catchUpWindowSeconds` reaches — which can be\n // a month. Sweeping that claim after seven days would make the\n // next boot re-run the slot. A claim behind the latest answers for\n // a slot no catch-up looks at, since a catch-up considers only the\n // most recent one.\n //\n // \"Most recent\" counts only slots that have happened (give or take\n // the skew margin). A stranded future claim — the boot's\n // future-slot sweep releases those — would otherwise count as the\n // job's latest, and the real one behind it would be swept here and\n // the future one released there, leaving the job with neither.\n // That used to be guarded by running this after that sweep; it\n // now runs on a timer, so the query guards itself.\n let total = 0;\n for (;;) {\n const rows = await exec(\n `WITH swept AS (\n DELETE FROM ${CLAIMS_TABLE}\n WHERE (job_id, slot) IN (\n SELECT c.job_id, c.slot FROM ${CLAIMS_TABLE} AS c\n WHERE c.claimed_at < now() - make_interval(days => $1)\n AND c.slot < (\n SELECT max(latest.slot) FROM ${CLAIMS_TABLE} AS latest\n WHERE latest.job_id = c.job_id\n AND latest.slot <= now() + make_interval(mins => $2)\n )\n LIMIT $3\n FOR UPDATE SKIP LOCKED\n )\n RETURNING 1\n )\n SELECT count(*)::int AS deleted FROM swept`,\n { params: [CLAIM_RETENTION_DAYS, FUTURE_CLAIM_SKEW_MINUTES, CLAIM_SWEEP_BATCH] }\n );\n const deleted = Number((rows?.[0] as { deleted?: number } | undefined)?.deleted ?? 0);\n total += deleted;\n if (deleted < CLAIM_SWEEP_BATCH) return total;\n }\n },\n\n async sweepLogs(): Promise<CronLogSweep> {\n // Retention first, so a history that predates the counters is\n // not marked counted row by row only to be deleted after. A row\n // deleted uncounted is counted in the statement that deletes it:\n // retention never takes a run out of the totals.\n //\n // Both statements take their rows `FOR UPDATE SKIP LOCKED`, so\n // replicas sweeping at once divide the rows between them, and a\n // row another sweep has just counted fails the `counted IS NULL`\n // recheck instead of being counted twice. Each batch is its own\n // short transaction: on a table that has grown for years this is\n // many of them, off the boot path, and never one long lock.\n const deleted = await inBatches(\n `WITH gone AS (\n DELETE FROM ${TABLE}\n WHERE id IN (\n SELECT id FROM ${TABLE}\n WHERE started_at < now() - make_interval(days => $1)\n ORDER BY started_at\n LIMIT $2\n FOR UPDATE SKIP LOCKED\n )\n RETURNING job_id, counted, started_at, duration_ms, success, error\n ), uncounted AS (\n SELECT job_id, started_at, duration_ms, success, error FROM gone WHERE counted IS NULL\n ), folded AS (\n ${foldIntoCounters(\"uncounted\")}\n RETURNING 1\n )\n SELECT count(*)::int AS deleted FROM gone`,\n [LOG_RETENTION_DAYS, LOG_SWEEP_BATCH],\n \"deleted\"\n );\n\n // Then the rest of the uncounted runs, newest first per job.\n const counted = await inBatches(\n `WITH batch AS (\n SELECT id FROM ${TABLE}\n WHERE counted IS NULL\n ORDER BY job_id, started_at DESC\n LIMIT $1\n FOR UPDATE SKIP LOCKED\n ), marked AS (\n UPDATE ${TABLE} AS l SET counted = true\n FROM batch WHERE l.id = batch.id\n RETURNING l.job_id, l.started_at, l.duration_ms, l.success, l.error\n ), folded AS (\n ${foldIntoCounters(\"marked\")}\n RETURNING 1\n )\n SELECT count(*)::int AS counted FROM marked`,\n [LOG_SWEEP_BATCH],\n \"counted\"\n );\n\n return { deleted, counted };\n },\n\n async insertLog(entry: CronJobLogEntry): Promise<void> {\n const resultJson = entry.result !== undefined ? JSON.stringify(entry.result) : null;\n const logsJson = entry.logs.length > 0 ? JSON.stringify(entry.logs) : null;\n const params = [\n entry.jobId,\n entry.startedAt,\n entry.finishedAt,\n entry.durationMs,\n entry.success,\n entry.error || null,\n resultJson,\n logsJson,\n entry.manual\n ];\n const columns = \"job_id, started_at, finished_at, duration_ms, success, error, result, logs, manual\";\n const values = \"$1, $2, $3, $4, $5, $6, $7::jsonb, $8::jsonb, $9\";\n try {\n try {\n // The run and its count in one statement, so neither is\n // ever written without the other. Every run counts — a\n // skip too, as it always has: it is a row of the history.\n await exec(\n `WITH logged AS (\n INSERT INTO ${TABLE} (${columns}, counted)\n VALUES (${values}, true)\n RETURNING job_id, started_at, duration_ms, success, error\n )\n ${foldIntoCounters(\"logged\")}`,\n { params }\n );\n } catch (err) {\n if (!hasInCauseChain(err, (e) => typeof e.code === \"string\" && COUNTERS_MISSING_SQLSTATES.has(e.code))) {\n throw err;\n }\n // The counters are not there to write to. Keep the run\n // anyway, uncounted: a listing adds it to the counts, and\n // the next sweep counts it.\n await exec(`INSERT INTO ${TABLE} (${columns}) VALUES (${values})`, { params });\n }\n } catch (err) {\n // Non-blocking — log persistence should never crash the scheduler\n logger.error(`[cron-store] Failed to persist log for \"${entry.jobId}\"`, { error: err });\n }\n },\n\n async fetchLogs(jobId: string, limit = 50): Promise<CronJobLogEntry[]> {\n const rows = await exec(\n `SELECT job_id, started_at, finished_at, duration_ms, success, error, result, logs, manual\n FROM ${TABLE}\n WHERE job_id = $1\n ORDER BY started_at DESC\n LIMIT $2`,\n { params: [jobId, limit] }\n );\n\n return rows.map(rowToLogEntry);\n },\n\n async fetchJobStats(): Promise<Map<string, { totalRuns: number; totalFailures: number; lastRunAt?: string }>> {\n const stats = new Map<string, { totalRuns: number; totalFailures: number; lastRunAt?: string }>();\n try {\n // The counters, one row per job — not a GROUP BY over the\n // whole history, which every start used to run. Runs not\n // counted yet are left to the sweep the scheduler starts with;\n // a listing adds them meanwhile.\n const rows = await exec(`\n SELECT job_id, total_runs, total_failures, last_run_at\n FROM ${STATE_TABLE}\n WHERE total_runs > 0\n `);\n\n for (const row of rows ?? []) {\n stats.set(String(row.job_id), {\n totalRuns: Number(row.total_runs),\n totalFailures: Number(row.total_failures),\n lastRunAt: toIsoString(row.last_run_at)\n });\n }\n } catch (err) {\n logger.error(\"[cron-store] Failed to fetch job stats\", { error: err });\n }\n return stats;\n },\n\n async tryClaimRun(jobId: string, slot: string): Promise<boolean> {\n try {\n const rows = await exec(\n `INSERT INTO ${CLAIMS_TABLE} (job_id, slot)\n VALUES ($1, $2)\n ON CONFLICT (job_id, slot) DO NOTHING\n RETURNING job_id`,\n { params: [jobId, slot] }\n );\n return rows.length > 0;\n } catch (err) {\n if (isUniqueViolation(err)) {\n // Another instance won the race for this slot\n return false;\n }\n // Neither won nor lost: the store does not know, and says so.\n // Answering `true` here would decide \"fail open\" for every\n // caller, including the catch-up, whose whole safety rests on\n // failing closed.\n throw err;\n }\n },\n\n async saveEnabledOverride(jobId: string, enabled: boolean | null, updatedBy?: string): Promise<void> {\n await exec(\n `INSERT INTO ${STATE_TABLE} (job_id, enabled, updated_at, updated_by)\n VALUES ($1, $2::boolean, now(), $3)\n ON CONFLICT (job_id) DO UPDATE\n SET enabled = EXCLUDED.enabled, updated_at = now(), updated_by = EXCLUDED.updated_by`,\n { params: [jobId, enabled, updatedBy ?? null] }\n );\n },\n\n async fetchJobStates(jobIds: readonly string[]): Promise<Map<string, CronJobPersistedState>> {\n const states = new Map<string, CronJobPersistedState>();\n if (jobIds.length === 0) return states;\n const rows = await exec(\n `SELECT job_id, enabled,\n CASE WHEN running_until > now() THEN running_by END AS running_by\n FROM ${STATE_TABLE}\n WHERE job_id IN (${placeholders(jobIds.length)})`,\n { params: [...jobIds] }\n );\n for (const row of rows ?? []) {\n states.set(String(row.job_id), {\n enabled: typeof row.enabled === \"boolean\" ? row.enabled : null,\n runningBy: typeof row.running_by === \"string\" ? row.running_by : undefined\n });\n }\n return states;\n },\n\n async tryAcquireRunLease(jobId: string, holder: string, ttlSeconds: number): Promise<CronRunLease> {\n // One statement, always one row. The insert covers a job that has\n // no row yet; the conditional update covers one whose lease is\n // absent or expired, and does nothing over a live one — so of two\n // processes racing, the second waits on the first's row and then\n // finds it held. The outer SELECT sees the table as it was before\n // this statement, which is exactly the holder that refused us.\n const rows = await exec(\n `WITH attempt AS (\n INSERT INTO ${STATE_TABLE} AS s (job_id, running_until, running_by)\n VALUES ($1, now() + make_interval(secs => $2::double precision), $3)\n ON CONFLICT (job_id) DO UPDATE\n SET running_until = EXCLUDED.running_until, running_by = EXCLUDED.running_by\n WHERE s.running_until IS NULL OR s.running_until <= now()\n RETURNING s.job_id\n )\n SELECT EXISTS (SELECT 1 FROM attempt) AS acquired,\n (SELECT running_by FROM ${STATE_TABLE}\n WHERE job_id = $1 AND running_until > now()) AS holder`,\n { params: [jobId, ttlSeconds, holder] }\n );\n const row = rows?.[0];\n if (!row) {\n throw new Error(`The run lease for \"${jobId}\" got no answer from the database`);\n }\n if (row.acquired === true) return { acquired: true };\n return {\n acquired: false,\n holder: typeof row.holder === \"string\" ? row.holder : undefined\n };\n },\n\n async releaseRunLease(jobId: string, holder: string): Promise<void> {\n await exec(\n `UPDATE ${STATE_TABLE}\n SET running_until = NULL, running_by = NULL\n WHERE job_id = $1 AND running_by = $2`,\n { params: [jobId, holder] }\n );\n },\n\n async fetchRunSummaries(jobIds: readonly string[]): Promise<Map<string, CronJobRunSummary>> {\n const summaries = new Map<string, CronJobRunSummary>();\n if (jobIds.length === 0) return summaries;\n // One primary-key read of the counters per job. It used to be two\n // `count(*)` over the job's whole history, on every 15-second poll\n // of the Studio panel: 64–132 ms at a year of twelve jobs, and\n // doubling every year.\n //\n // Plus the runs not counted yet, so the answer is exact while the\n // counting sweep has not caught up: a history from before the\n // counters, or a run an older server logged during a rolling\n // deploy. Both read only `idx_cron_logs_uncounted`, which is empty\n // once the sweep has run.\n const rows = await exec(\n `SELECT j.job_id,\n COALESCE(s.total_runs, 0) + COALESCE(pending.runs, 0) AS total_runs,\n COALESCE(s.total_failures, 0) + COALESCE(pending.failures, 0) AS total_failures,\n CASE WHEN newer.later THEN pending_last.started_at ELSE s.last_run_at END AS started_at,\n CASE WHEN newer.later THEN pending_last.duration_ms ELSE s.last_duration_ms END AS duration_ms,\n CASE WHEN newer.later THEN pending_last.success ELSE s.last_success END AS success,\n CASE WHEN newer.later THEN pending_last.error ELSE s.last_error END AS error\n FROM (VALUES ${jobIds.map((_, i) => `($${i + 1}::text)`).join(\", \")}) AS j(job_id)\n LEFT JOIN ${STATE_TABLE} AS s ON s.job_id = j.job_id\n LEFT JOIN LATERAL (\n SELECT count(*) AS runs, count(*) FILTER (WHERE NOT l.success) AS failures\n FROM ${TABLE} l\n WHERE l.job_id = j.job_id AND l.counted IS NULL\n ) AS pending ON true\n LEFT JOIN LATERAL (\n SELECT l.started_at, l.duration_ms, l.success, l.error\n FROM ${TABLE} l\n WHERE l.job_id = j.job_id AND l.counted IS NULL\n ORDER BY l.started_at DESC\n LIMIT 1\n ) AS pending_last ON true\n CROSS JOIN LATERAL (\n SELECT pending_last.started_at IS NOT NULL\n AND (s.last_run_at IS NULL OR pending_last.started_at >= s.last_run_at) AS later\n ) AS newer`,\n { params: [...jobIds] }\n );\n for (const row of rows ?? []) {\n const summary: CronJobRunSummary = {\n totalRuns: Number(row.total_runs ?? 0),\n totalFailures: Number(row.total_failures ?? 0)\n };\n const lastRunAt = toIsoString(row.started_at);\n if (lastRunAt) {\n summary.lastRunAt = lastRunAt;\n summary.lastDurationMs = Number(row.duration_ms);\n summary.lastSuccess = row.success === true;\n if (typeof row.error === \"string\") summary.lastError = row.error;\n }\n summaries.set(String(row.job_id), summary);\n }\n return summaries;\n }\n };\n}\n\n// ─── Helpers ─────────────────────────────────────────────────────────\n\n/** A TIMESTAMPTZ as the driver returned it — a `Date` or a string — in ISO form. */\nfunction toIsoString(value: unknown): string | undefined {\n if (value instanceof Date) return value.toISOString();\n if (typeof value === \"string\" || typeof value === \"number\") return new Date(value).toISOString();\n return undefined;\n}\n\nfunction rowToLogEntry(row: Record<string, unknown>): CronJobLogEntry {\n return {\n jobId: row.job_id as string,\n startedAt: new Date(row.started_at as string).toISOString(),\n finishedAt: new Date(row.finished_at as string).toISOString(),\n durationMs: row.duration_ms as number,\n success: row.success as boolean,\n error: (row.error as string) ?? undefined,\n result: row.result ?? undefined,\n logs: Array.isArray(row.logs) ? row.logs : (row.logs ? (() => { try { return JSON.parse(row.logs as string); } catch { return []; } })() : []),\n manual: row.manual as boolean\n };\n}\n"],"mappings":";;;;;;;;;;;;AA8KA,IAAM,QAAQ;AACd,IAAM,eAAe;AACrB,IAAM,cAAc;;AAGpB,SAAS,aAAa,OAAe,QAAQ,GAAW;CACpD,OAAO,MAAM,KAAK,EAAE,QAAQ,MAAM,IAAI,GAAG,MAAM,IAAI,QAAQ,GAAG,CAAC,CAAC,KAAK,IAAI;AAC7E;;;;;AAMA,IAAM,uBAAuB;;AAG7B,IAAM,oBAAoB;;;;;;AAO1B,IAAM,4BAA4B;;;;;;;;;;AAWlC,IAAM,qBAAqB;;AAG3B,IAAM,kBAAkB;;;;;;;AAQxB,IAAM,eAAe;;;;;;;;;;AAWrB,SAAS,iBAAiB,MAAsB;CAC5C,OAAO;sBACW,YAAY;;;;;;mBAMf,KAAK;;;;mBAIL,KAAK;;;;;;sCAMc,aAAa;2CACR,aAAa;uCACjB,aAAa;qCACf,aAAa;AAClD;;;;;;;;AASA,IAAM,6CAA6B,IAAI,IAAI;CAAC;CAAS;CAAS;AAAO,CAAC;;;;;;;;;;;;AAYtE,SAAS,kBAAkB,KAAuB;CAC9C,OAAO,gBAAgB,MAAM,MACzB,EAAE,SAAS,WACX,EAAE,UAAU,QACX,OAAO,EAAE,YAAY,YAAY,EAAE,QAAQ,SAAS,0BAA0B,CACnF;AACJ;;;;;;;;;;;;;;;AAgBA,IAAM,iCAAiB,IAAI,QAAmC;;AAG9D,IAAM,iCAAiB,IAAI,QAAoB;AAE/C,SAAS,qBAAqB,QAAoB,OAA8C;CAC5F,MAAM,UAAU,eAAe,IAAI,MAAM;CACzC,IAAI,SAAS,OAAO;CACpB,MAAM,UAAU,MAAM,CAAC,CAAC,WAAW,KAAA,CAAS,CAAC,CAAC,cAAc;EACxD,IAAI,eAAe,IAAI,MAAM,MAAM,SAAS,eAAe,OAAO,MAAM;CAC5E,CAAC;CACD,eAAe,IAAI,QAAQ,OAAO;CAClC,OAAO;AACX;AAEA,SAAgB,gBAAgB,QAAqD;CACjF,MAAM,QAAQ,OAAO;CACrB,IAAI,CAAC,WAAW,KAAK,GAAG;EACpB,OAAO,KAAK,0FAA0F;EACtG;CACJ;CAEA,MAAM,QAAQ,SAAiB,YAC3B,MAAM,WAAW,SAAS,SAAS,SAAS,EAAE,QAAQ,QAAQ,OAAO,IAAI,KAAA,CAAS;CAEtF,MAAM,MAAM,sBAAsB,MAAM,YAAY;;CAGpD,MAAM,YAAY,OAAO,OAAe,WAAqC;EACzE,IAAI;GAMA,QAAQ,MALW,KACf;0FAEA,EAAE,QAAQ,CAAC,OAAO,MAAM,EAAE,CAC9B,KACgB,CAAC,EAAA,CAAG,SAAS;EACjC,QAAQ;GACJ,OAAO;EACX;CACJ;;;;;;;;;;CAWA,MAAM,gBAAgB,YAA8B;EAChD,MAAM,UAAU;GAAC;GAAa;GAAe;GAAkB;GAAqB;GAA2B;EAAuB;EACtI,MAAM,UAAU,CAAC,qBAAqB,GAAG;GAAC;GAAc;GAAkB;GAAe;GAAoB;GAAgB;EAAY,CAAC,CAAC,KAAI,MAAK,kBAAkB,GAAG,CAAC;EAC1K,MAAM,UAAU,UAAoB,MAAM,KAAI,SAAQ,IAAI,KAAK,EAAE,CAAC,CAAC,KAAK,IAAI;EAC5E,IAAI;GAoBA,MAAM,OAAM,MAnBO,KAAK;;;;;gEAK4B,iBAAiB;;;sEAGX,OAAO,OAAO,EAAE;;;8FAGQ,OAAO,OAAO,EAAE;;;;;;;aAOjG,EAAA,GACkB;GACnB,OAAO,OAAO,KAAK,OAAO,MAAM,QAAQ,UAAU,OAAO,KAAK,KAAK,MAAM,KAClE,OAAO,KAAK,OAAO,MAAM,QAAQ,UAAU,OAAO,KAAK,OAAO,MAAM;EAC/E,QAAQ;GACJ,OAAO;EACX;CACJ;;;;;;;;CASA,MAAM,4BAA4B,IAAI,KAAK,2BAA2B,YAAY;EAC9E,MAAM,WAAW,MAAM,KACnB,eAAe,aAAa;;sCAG5B,EAAE,QAAQ,CAAC,yBAAyB,EAAE,CAC1C;EAGA,KAAK,MAAM,OAAQ,YAAY,CAAC,GAC5B,OAAO,KACH,oDAAoD,IAAI,KAAK,IAAI,IAAI,CAAC,CAAC,YAAY,EAAE,QAC7E,IAAI,OAAO,0FAEvB;CAER,CAAC;;CAGD,MAAM,YAAY,OAAO,SAAiB,QAAmB,WAAoC;EAC7F,IAAI,QAAQ;EACZ,SAAS;GACL,MAAM,OAAO,MAAM,KAAK,SAAS,EAAE,OAAO,CAAC;GAC3C,MAAM,OAAO,QAAQ,OAAO,GAAA,GAA6C,WAAW,CAAC;GACrF,SAAS;GACT,IAAI,OAAO,iBAAiB,OAAO;EACvC;CACJ;;;;;CAMA,MAAM,sBAA4B;EAC9B,IAAI,eAAe,IAAI,MAAM,GAAG;EAChC,eAAe,IAAI,MAAM;EACzB,OAAO,KAAK,yBAAyB;CACzC;CAEA,MAAM,QAAQ,YAA8B;EAIxC,IAAI,MAAM,cAAc,GAAG;GACvB,MAAM,oBAAoB;GAC1B,cAAc;GACd,OAAO;EACX;EAQA,MAAM,IAAI,aAAa,0BAA0B,oCAAoC;EAErF,MAAM,IAAI,aAAa,YAAY,SAAS;yCACX,MAAM;;;;;;;;;;;;SAYtC;EAED,MAAM,IAAI,aAAa,8BAA8B;;iBAE5C,MAAM;SACd;EAQD,IAAI,CAAE,MAAM,UAAU,aAAa,SAAS,GACxC,MAAM,IAAI,aAAa,UAAU,MAAM,WAAW;8BAChC,MAAM;aACvB;EAML,MAAM,IAAI,aAAa,oCAAoC;;iBAElD,MAAM;SACd;EAID,MAAM,IAAI,aAAa,kCAAkC;;iBAEhD,MAAM;SACd;EAID,MAAM,IAAI,aAAa,uCACnB,wDAAwD;EAE5D,MAAM,IAAI,aAAa,YAAY,gBAAgB;yCAClB,aAAa;;;;;;SAM7C;EAOD,MAAM,IAAI,aAAa,YAAY,eAAe;yCACjB,YAAY;;;;;;;;SAQ5C;EAKD,IAAI,CAAE,MAAM,UAAU,kBAAkB,YAAY,GAChD,MAAM,IAAI,aAAa,8BAA8B,eAAe;8BAClD,YAAY;;;;;;;aAO7B;EAUL,MAAM,CAAC,WAAW,aAAa,cAAc,MAAM,QAAQ,IAAI;GAC3D,IAAI,WAAW,KAAK;GACpB,IAAI,WAAW,YAAY;GAC3B,IAAI,WAAW,WAAW;EAC9B,CAAC;EAED,IAAI,aACA,MAAM,oBAAoB;EAe9B,IAAI,WACA,MAAM,IAAI,KAAK,+CACX,KAAK,uBAAuB,UAAU,WAAW,CAAC,CAAC;EAE3D,IAAI,aACA,MAAM,IAAI,KAAK,iDACX,KAAK,uBAAuB,UAAU,aAAa,CAAC,CAAC;EAK7D,IAAI,YACA,MAAM,IAAI,KAAK,oDACX,KAAK,uBAAuB,UAAU,gBAAgB,CAAC,CAAC;EAGhE,IAAI,aAAa,eAAe,YAAY;GACxC,cAAc;GACd,OAAO;EACX;EAIA,IAAI,CAAC,aACD,OAAO,MACH,kBAAkB,aAAa,8IAEnC;EAEJ,IAAI,CAAC,WACD,OAAO,KAAK,mBAAmB,MAAM,0DAA0D;EAEnG,IAAI,CAAC,YACD,OAAO,KACH,mBAAmB,YAAY,4MAGnC;EAEJ,OAAO;CACX;CAEA,OAAO;EACH,mBAAmB,qBAAqB,QAAQ,KAAK;EAErD,MAAM,cAA+B;GAiBjC,IAAI,QAAQ;GACZ,SAAS;IACL,MAAM,OAAO,MAAM,KACf;uCACmB,aAAa;;4DAEQ,aAAa;;;kEAGP,aAAa;;;;;;;;;kEAU3D,EAAE,QAAQ;KAAC;KAAsB;KAA2B;IAAiB,EAAE,CACnF;IACA,MAAM,UAAU,QAAQ,OAAO,GAAA,EAAyC,WAAW,CAAC;IACpF,SAAS;IACT,IAAI,UAAU,mBAAmB,OAAO;GAC5C;EACJ;EAEA,MAAM,YAAmC;GAuDrC,OAAO;IAAE,SAAA,MA3Ca,UAClB;mCACmB,MAAM;;0CAEC,MAAM;;;;;;;;;;uBAUzB,iBAAiB,WAAW,EAAE;;;6DAIrC,CAAC,oBAAoB,eAAe,GACpC,SACJ;IAuBkB,SAAA,MApBI,UAClB;sCACsB,MAAM;;;;;;8BAMd,MAAM;;;;uBAIb,iBAAiB,QAAQ,EAAE;;;+DAIlC,CAAC,eAAe,GAChB,SACJ;GAE0B;EAC9B;EAEA,MAAM,UAAU,OAAuC;GACnD,MAAM,aAAa,MAAM,WAAW,KAAA,IAAY,KAAK,UAAU,MAAM,MAAM,IAAI;GAC/E,MAAM,WAAW,MAAM,KAAK,SAAS,IAAI,KAAK,UAAU,MAAM,IAAI,IAAI;GACtE,MAAM,SAAS;IACX,MAAM;IACN,MAAM;IACN,MAAM;IACN,MAAM;IACN,MAAM;IACN,MAAM,SAAS;IACf;IACA;IACA,MAAM;GACV;GACA,MAAM,UAAU;GAChB,MAAM,SAAS;GACf,IAAI;IACA,IAAI;KAIA,MAAM,KACF;2CACmB,MAAM,IAAI,QAAQ;uCACtB,OAAO;;;2BAGnB,iBAAiB,QAAQ,KAC5B,EAAE,OAAO,CACb;IACJ,SAAS,KAAK;KACV,IAAI,CAAC,gBAAgB,MAAM,MAAM,OAAO,EAAE,SAAS,YAAY,2BAA2B,IAAI,EAAE,IAAI,CAAC,GACjG,MAAM;KAKV,MAAM,KAAK,eAAe,MAAM,IAAI,QAAQ,YAAY,OAAO,IAAI,EAAE,OAAO,CAAC;IACjF;GACJ,SAAS,KAAK;IAEV,OAAO,MAAM,2CAA2C,MAAM,MAAM,IAAI,EAAE,OAAO,IAAI,CAAC;GAC1F;EACJ;EAEA,MAAM,UAAU,OAAe,QAAQ,IAAgC;GAUnE,QAAO,MATY,KACf;wBACQ,MAAM;;;4BAId,EAAE,QAAQ,CAAC,OAAO,KAAK,EAAE,CAC7B,EAAA,CAEY,IAAI,aAAa;EACjC;EAEA,MAAM,gBAAwG;GAC1G,MAAM,wBAAQ,IAAI,IAA8E;GAChG,IAAI;IAKA,MAAM,OAAO,MAAM,KAAK;;2BAEb,YAAY;;iBAEtB;IAED,KAAK,MAAM,OAAO,QAAQ,CAAC,GACvB,MAAM,IAAI,OAAO,IAAI,MAAM,GAAG;KAC1B,WAAW,OAAO,IAAI,UAAU;KAChC,eAAe,OAAO,IAAI,cAAc;KACxC,WAAW,YAAY,IAAI,WAAW;IAC1C,CAAC;GAET,SAAS,KAAK;IACV,OAAO,MAAM,0CAA0C,EAAE,OAAO,IAAI,CAAC;GACzE;GACA,OAAO;EACX;EAEA,MAAM,YAAY,OAAe,MAAgC;GAC7D,IAAI;IAQA,QAAO,MAPY,KACf,eAAe,aAAa;;;wCAI5B,EAAE,QAAQ,CAAC,OAAO,IAAI,EAAE,CAC5B,EAAA,CACY,SAAS;GACzB,SAAS,KAAK;IACV,IAAI,kBAAkB,GAAG,GAErB,OAAO;IAMX,MAAM;GACV;EACJ;EAEA,MAAM,oBAAoB,OAAe,SAAyB,WAAmC;GACjG,MAAM,KACF,eAAe,YAAY;;;4GAI3B,EAAE,QAAQ;IAAC;IAAO;IAAS,aAAa;GAAI,EAAE,CAClD;EACJ;EAEA,MAAM,eAAe,QAAwE;GACzF,MAAM,yBAAS,IAAI,IAAmC;GACtD,IAAI,OAAO,WAAW,GAAG,OAAO;GAChC,MAAM,OAAO,MAAM,KACf;;wBAEQ,YAAY;oCACA,aAAa,OAAO,MAAM,EAAE,IAChD,EAAE,QAAQ,CAAC,GAAG,MAAM,EAAE,CAC1B;GACA,KAAK,MAAM,OAAO,QAAQ,CAAC,GACvB,OAAO,IAAI,OAAO,IAAI,MAAM,GAAG;IAC3B,SAAS,OAAO,IAAI,YAAY,YAAY,IAAI,UAAU;IAC1D,WAAW,OAAO,IAAI,eAAe,WAAW,IAAI,aAAa,KAAA;GACrE,CAAC;GAEL,OAAO;EACX;EAEA,MAAM,mBAAmB,OAAe,QAAgB,YAA2C;GAqB/F,MAAM,OAAM,MAdO,KACf;mCACmB,YAAY;;;;;;;;kDAQG,YAAY;kFAE9C,EAAE,QAAQ;IAAC;IAAO;IAAY;GAAM,EAAE,CAC1C,EAAA,GACmB;GACnB,IAAI,CAAC,KACD,MAAM,IAAI,MAAM,sBAAsB,MAAM,kCAAkC;GAElF,IAAI,IAAI,aAAa,MAAM,OAAO,EAAE,UAAU,KAAK;GACnD,OAAO;IACH,UAAU;IACV,QAAQ,OAAO,IAAI,WAAW,WAAW,IAAI,SAAS,KAAA;GAC1D;EACJ;EAEA,MAAM,gBAAgB,OAAe,QAA+B;GAChE,MAAM,KACF,UAAU,YAAY;;yDAGtB,EAAE,QAAQ,CAAC,OAAO,MAAM,EAAE,CAC9B;EACJ;EAEA,MAAM,kBAAkB,QAAoE;GACxF,MAAM,4BAAY,IAAI,IAA+B;GACrD,IAAI,OAAO,WAAW,GAAG,OAAO;GAWhC,MAAM,OAAO,MAAM,KACf;;;;;;;gCAOgB,OAAO,KAAK,GAAG,MAAM,KAAK,IAAI,EAAE,QAAQ,CAAC,CAAC,KAAK,IAAI,EAAE;6BACxD,YAAY;;;4BAGb,MAAM;;;;;4BAKN,MAAM;;;;;;;;8BASlB,EAAE,QAAQ,CAAC,GAAG,MAAM,EAAE,CAC1B;GACA,KAAK,MAAM,OAAO,QAAQ,CAAC,GAAG;IAC1B,MAAM,UAA6B;KAC/B,WAAW,OAAO,IAAI,cAAc,CAAC;KACrC,eAAe,OAAO,IAAI,kBAAkB,CAAC;IACjD;IACA,MAAM,YAAY,YAAY,IAAI,UAAU;IAC5C,IAAI,WAAW;KACX,QAAQ,YAAY;KACpB,QAAQ,iBAAiB,OAAO,IAAI,WAAW;KAC/C,QAAQ,cAAc,IAAI,YAAY;KACtC,IAAI,OAAO,IAAI,UAAU,UAAU,QAAQ,YAAY,IAAI;IAC/D;IACA,UAAU,IAAI,OAAO,IAAI,MAAM,GAAG,OAAO;GAC7C;GACA,OAAO;EACX;CACJ;AACJ;;AAKA,SAAS,YAAY,OAAoC;CACrD,IAAI,iBAAiB,MAAM,OAAO,MAAM,YAAY;CACpD,IAAI,OAAO,UAAU,YAAY,OAAO,UAAU,UAAU,OAAO,IAAI,KAAK,KAAK,CAAC,CAAC,YAAY;AAEnG;AAEA,SAAS,cAAc,KAA+C;CAClE,OAAO;EACH,OAAO,IAAI;EACX,WAAW,IAAI,KAAK,IAAI,UAAoB,CAAC,CAAC,YAAY;EAC1D,YAAY,IAAI,KAAK,IAAI,WAAqB,CAAC,CAAC,YAAY;EAC5D,YAAY,IAAI;EAChB,SAAS,IAAI;EACb,OAAQ,IAAI,SAAoB,KAAA;EAChC,QAAQ,IAAI,UAAU,KAAA;EACtB,MAAM,MAAM,QAAQ,IAAI,IAAI,IAAI,IAAI,OAAQ,IAAI,cAAc;GAAE,IAAI;IAAE,OAAO,KAAK,MAAM,IAAI,IAAc;GAAG,QAAQ;IAAE,OAAO,CAAC;GAAG;EAAE,EAAA,CAAG,IAAI,CAAC;EAC5I,QAAQ,IAAI;CAChB;AACJ"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"ddl-bootstrap-Ck20GSIX.js","names":[],"sources":["../src/boot/ddl-bootstrap.ts"],"sourcesContent":["import { logger } from \"../utils/logger.js\";\n\n/**\n * Helpers for the \"create my internal table if it isn't there yet\" bootstrap\n * that several stores run at boot.\n *\n * These all look trivially safe — every statement is `IF NOT EXISTS` — and they\n * are not, for two reasons that only show up with more than one app instance:\n *\n * 1. `CREATE … IF NOT EXISTS` reads the catalog and then writes to it, and\n * those two steps are not one atomic operation. Instances starting together\n * — a rolling deploy, a replica count going from 1 to N, a crash loop\n * restarting the fleet — can both see \"absent\" and both try to create. The\n * loser gets a duplicate key on a *catalog* index instead of the silent\n * no-op the syntax appears to promise. Measured against Postgres 18: with\n * five instances booting at once, 8 of 10 `ensureTable` calls hit it.\n *\n * 2. A bootstrap written as one long `try` block therefore abandons everything\n * after the losing statement — including, in every store that has one, the\n * `REVOKE` that takes the table back off the end-user role. That revoke is\n * a security control and must not be collateral damage from a race.\n *\n * So: retry the race, contain each statement separately, and decide what to do\n * next from what actually exists rather than from who won.\n */\n\n/** A driver's `executeSql`, narrowed to what a bootstrap needs. */\nexport type SqlExec = (sqlText: string, options?: { params?: unknown[] }) => Promise<Record<string, unknown>[]>;\n\n\n/**\n * SQLSTATEs a *simultaneous boot* can raise from statements that are otherwise\n * idempotent. Retrying is always the right answer for these: the second attempt\n * finds the object present and does nothing.\n */\nexport const CONCURRENT_DDL_SQLSTATES = new Set([\n \"23505\", // unique_violation on pg_type / pg_class / pg_namespace\n \"42P06\", // duplicate_schema\n \"42P07\", // duplicate_table\n \"42710\", // duplicate_object — an index or a constraint\n \"40P01\" // deadlock_detected — two boots taking catalog locks in step\n]);\n\n/** Attempts per idempotent DDL statement, including the first. */\nexport const DDL_ATTEMPTS = 4;\nconst DDL_RETRY_BASE_MS = 40;\n\n/**\n * Walk an error's `cause` chain, stopping at the first link `visit` accepts.\n * Drizzle wraps the driver error, so nothing useful is ever on the top level.\n */\nexport function hasInCauseChain(err: unknown, visit: (e: Record<string, unknown>) => boolean): boolean {\n let current: unknown = err;\n for (let depth = 0; depth < 10 && current; depth++) {\n if (typeof current !== \"object\") break;\n const e = current as Record<string, unknown>;\n if (visit(e)) return true;\n current = e.cause;\n }\n return false;\n}\n\n/**\n * Is this the loser of a race to create something that already exists?\n *\n * Deliberately narrow. A permission failure, an unreachable database or a typo\n * in the DDL must surface on the first attempt rather than being retried four\n * times and then reported as a race that never was.\n */\nexport function isConcurrentDdlRace(err: unknown): boolean {\n return hasInCauseChain(err, (e) =>\n (typeof e.code === \"string\" && CONCURRENT_DDL_SQLSTATES.has(e.code)) ||\n // SQLite and MySQL say it in words rather than in a shared SQLSTATE.\n (typeof e.message === \"string\" && /already exists/i.test(e.message))\n );\n}\n\n/**\n * SQLSTATEs that mean, unambiguously, *the object is already there*.\n *\n * A subset of {@link CONCURRENT_DDL_SQLSTATES} and a stricter question. The\n * broad set answers \"should this be retried\"; this one answers \"is it safe to\n * carry on as though the statement had succeeded\", which is a claim about the\n * end state rather than about the attempt. Deadlock is not in it — a deadlocked\n * statement did nothing and must be retried, not skipped.\n */\nconst DUPLICATE_OBJECT_SQLSTATES = new Set([\n \"42P06\", // duplicate_schema\n \"42P07\", // duplicate_table\n \"42710\" // duplicate_object — a type, an index, a constraint\n]);\n\n/**\n * Did this statement fail *because a peer already created the same object*?\n *\n * The narrow companion to {@link isConcurrentDdlRace}, for the one caller that\n * needs to tell \"someone beat me to it\" from \"this genuinely failed\": a loop\n * applying a schema plan, where treating every `23505` as a harmless race would\n * silently swallow the one that matters — a unique constraint that cannot be\n * added because the customer's existing rows violate it.\n *\n * `23505` is therefore only accepted when it names a `pg_catalog` index. That is\n * what a lost `CREATE TYPE`/`CREATE TABLE` race raises (`pg_type_typname_nsp_index`\n * is the one seen in practice); a unique violation on user data names the user's\n * own constraint and is left to the caller.\n *\n * With one exception, in {@link NOT_A_RACE_PG_INDEXES}.\n */\nexport function isDuplicateObjectRace(err: unknown): boolean {\n return hasInCauseChain(err, (e) => {\n if (typeof e.code !== \"string\") return false;\n if (DUPLICATE_OBJECT_SQLSTATES.has(e.code)) return true;\n if (e.code !== \"23505\") return false;\n // node-postgres puts the violated index in `constraint`; some paths only\n // carry it in the detail text, so check both rather than miss the race.\n const constraint = typeof e.constraint === \"string\" ? e.constraint : \"\";\n const detail = typeof e.detail === \"string\" ? e.detail : \"\";\n for (const index of NOT_A_RACE_PG_INDEXES) {\n if (constraint === index || detail.includes(index)) return false;\n }\n return constraint.startsWith(\"pg_\") || /\\bpg_[a-z_]+_index\\b/.test(detail);\n });\n}\n\n/**\n * `pg_catalog` indexes whose `23505` is never a race.\n *\n * `pg_enum_typid_label_index` is unique on (type, label), and one statement\n * violates it on its own: `CREATE TYPE t AS ENUM ('draft', 'draft')` — which is\n * what a collection with two enum entries carrying the same `id` generates.\n * Under the `pg_` prefix rule that read as \"a peer created it first\", so the\n * type was never created, boot carried on, and the column fell back to `TEXT`\n * with no enum behind it. Verified against PGlite: the duplicate `CREATE TYPE`\n * raises exactly this.\n *\n * The concurrent-boot case this rule exists for does not come through here at\n * all. Two pods adding the same label race on `ALTER TYPE … ADD VALUE`, which\n * raises `42710` (already in {@link DUPLICATE_OBJECT_SQLSTATES}) — and the\n * generator writes `ADD VALUE IF NOT EXISTS`, so it usually raises nothing.\n *\n * The duplicate `id` is rejected earlier now, by `validate-config`, which names\n * the property. This is the second line: a config that reaches the database with\n * one fails loudly instead of degrading.\n */\nconst NOT_A_RACE_PG_INDEXES = [\"pg_enum_typid_label_index\"];\n\nexport interface DdlBootstrapper {\n /**\n * Run one idempotent statement — `CREATE … IF NOT EXISTS`, `ALTER TABLE …\n * ADD COLUMN IF NOT EXISTS` — retrying the catalog race a simultaneous boot\n * produces. Never throws: a statement that cannot be made to work is logged\n * and the caller carries on to the next one.\n */\n ensureObject(label: string, sqlText: string): Promise<void>;\n\n /** Contain one step's failure so that the steps after it still run. */\n step(label: string, run: () => Promise<unknown>): Promise<void>;\n\n /**\n * Is this table there and readable? Asked with a query any SQL dialect\n * answers, rather than `to_regclass`, so a future non-Postgres SQL driver\n * gets a real answer instead of a syntax error read as \"missing\".\n */\n isReadable(table: string): Promise<boolean>;\n}\n\n/**\n * @param exec the driver's SQL escape hatch\n * @param scope log prefix identifying the caller, e.g. `\"cron-store\"`\n */\nexport function createDdlBootstrapper(exec: SqlExec, scope: string): DdlBootstrapper {\n /** Jittered, so peers that collided once do not collide again in lockstep. */\n const backoff = (attempt: number) =>\n new Promise(resolve => setTimeout(resolve, DDL_RETRY_BASE_MS * attempt * (1 + Math.random())));\n\n const step: DdlBootstrapper[\"step\"] = async (label, run) => {\n try {\n await run();\n } catch (err) {\n logger.error(`[${scope}] ${label} failed`, { error: err });\n }\n };\n\n return {\n step,\n\n ensureObject(label, sqlText) {\n return step(label, async () => {\n for (let attempt = 1; ; attempt++) {\n try {\n await exec(sqlText);\n return;\n } catch (err) {\n if (!isConcurrentDdlRace(err) || attempt >= DDL_ATTEMPTS) throw err;\n logger.debug(\n `[${scope}] Lost a create race for ${label} with another instance ` +\n `(attempt ${attempt}/${DDL_ATTEMPTS}) — retrying`\n );\n await backoff(attempt);\n }\n }\n });\n },\n\n async isReadable(table) {\n try {\n await exec(`SELECT 1 FROM ${table} WHERE false`);\n return true;\n } catch {\n return false;\n }\n }\n };\n}\n"],"mappings":";;;;;;;;;;;AAmCA,IAAa,2CAA2B,IAAI,IAAI;CAC5C;CACA;CACA;CACA;CACA;AACJ,CAAC;AAID,IAAM,oBAAoB;;;;;AAM1B,SAAgB,gBAAgB,KAAc,OAAyD;CACnG,IAAI,UAAmB;CACvB,KAAK,IAAI,QAAQ,GAAG,QAAQ,MAAM,SAAS,SAAS;EAChD,IAAI,OAAO,YAAY,UAAU;EACjC,MAAM,IAAI;EACV,IAAI,MAAM,CAAC,GAAG,OAAO;EACrB,UAAU,EAAE;CAChB;CACA,OAAO;AACX;;;;;;;;AASA,SAAgB,oBAAoB,KAAuB;CACvD,OAAO,gBAAgB,MAAM,MACxB,OAAO,EAAE,SAAS,YAAY,yBAAyB,IAAI,EAAE,IAAI,KAEjE,OAAO,EAAE,YAAY,YAAY,kBAAkB,KAAK,EAAE,OAAO,CACtE;AACJ;;;;;;;;;;AAWA,IAAM,6CAA6B,IAAI,IAAI;CACvC;CACA;CACA;AACJ,CAAC;;;;;;;;;;;;;;;;;AAkBD,SAAgB,sBAAsB,KAAuB;CACzD,OAAO,gBAAgB,MAAM,MAAM;EAC/B,IAAI,OAAO,EAAE,SAAS,UAAU,OAAO;EACvC,IAAI,2BAA2B,IAAI,EAAE,IAAI,GAAG,OAAO;EACnD,IAAI,EAAE,SAAS,SAAS,OAAO;EAG/B,MAAM,aAAa,OAAO,EAAE,eAAe,WAAW,EAAE,aAAa;EACrE,MAAM,SAAS,OAAO,EAAE,WAAW,WAAW,EAAE,SAAS;EACzD,KAAK,MAAM,SAAS,uBAChB,IAAI,eAAe,SAAS,OAAO,SAAS,KAAK,GAAG,OAAO;EAE/D,OAAO,WAAW,WAAW,KAAK,KAAK,uBAAuB,KAAK,MAAM;CAC7E,CAAC;AACL;;;;;;;;;;;;;;;;;;;;;AAsBA,IAAM,wBAAwB,CAAC,2BAA2B;;;;;AA0B1D,SAAgB,sBAAsB,MAAe,OAAgC;;CAEjF,MAAM,WAAW,YACb,IAAI,SAAQ,YAAW,WAAW,SAAS,oBAAoB,WAAW,IAAI,KAAK,OAAO,EAAE,CAAC;CAEjG,MAAM,OAAgC,OAAO,OAAO,QAAQ;EACxD,IAAI;GACA,MAAM,IAAI;EACd,SAAS,KAAK;GACV,OAAO,MAAM,IAAI,MAAM,IAAI,MAAM,UAAU,EAAE,OAAO,IAAI,CAAC;EAC7D;CACJ;CAEA,OAAO;EACH;EAEA,aAAa,OAAO,SAAS;GACzB,OAAO,KAAK,OAAO,YAAY;IAC3B,KAAK,IAAI,UAAU,IAAK,WACpB,IAAI;KACA,MAAM,KAAK,OAAO;KAClB;IACJ,SAAS,KAAK;KACV,IAAI,CAAC,oBAAoB,GAAG,KAAK,WAAA,GAAyB,MAAM;KAChE,OAAO,MACH,IAAI,MAAM,2BAA2B,MAAM,kCAC/B,QAAQ,eACxB;KACA,MAAM,QAAQ,OAAO;IACzB;GAER,CAAC;EACL;EAEA,MAAM,WAAW,OAAO;GACpB,IAAI;IACA,MAAM,KAAK,iBAAiB,MAAM,aAAa;IAC/C,OAAO;GACX,QAAQ;IACJ,OAAO;GACX;EACJ;CACJ;AACJ"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"function-loader-KKDqTcIr.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-B2rkuhuy.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"}
|