@rebasepro/server 0.21.0 → 0.21.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +29 -11
  2. package/dist/api/errors.d.ts +41 -0
  3. package/dist/auth/admin-user-ops.d.ts +23 -1
  4. package/dist/auth/auth-hooks.d.ts +4 -0
  5. package/dist/auth/reset-password-admin.d.ts +4 -0
  6. package/dist/auth/token-revocation.d.ts +39 -5
  7. package/dist/{auth-gzoRjnuh.js → auth-BS4WBA10.js} +1636 -1565
  8. package/dist/auth-BS4WBA10.js.map +1 -0
  9. package/dist/backup/backup-common.d.ts +2 -1
  10. package/dist/{backup-DGu0v9Ku.js → backup-DzI9jLwc.js} +38 -10
  11. package/dist/backup-DzI9jLwc.js.map +1 -0
  12. package/dist/collections/validate-config.d.ts +4 -2
  13. package/dist/{cron-routes-BamgO0Xr.js → cron-routes-B_wlLybo.js} +3 -3
  14. package/dist/{cron-routes-BamgO0Xr.js.map → cron-routes-B_wlLybo.js.map} +1 -1
  15. package/dist/{cron-scheduler-D47tdB9T.js → cron-scheduler-COPQxlEq.js} +59 -10
  16. package/dist/cron-scheduler-COPQxlEq.js.map +1 -0
  17. package/dist/{cron-store-yXOyIEmn.js → cron-store-Dp0X7zRB.js} +2 -2
  18. package/dist/{cron-store-yXOyIEmn.js.map → cron-store-Dp0X7zRB.js.map} +1 -1
  19. package/dist/ddl-bootstrap-CfNvxMuK.js.map +1 -1
  20. package/dist/deploy/pod-contract.d.ts +14 -0
  21. package/dist/{errors-DMImyqyR.js → errors-DWsX4yTd.js} +55 -18
  22. package/dist/errors-DWsX4yTd.js.map +1 -0
  23. package/dist/{function-routes-C4nB2h0z.js → function-routes-Chet4-lB.js} +2 -2
  24. package/dist/{function-routes-C4nB2h0z.js.map → function-routes-Chet4-lB.js.map} +1 -1
  25. package/dist/functions/index.js.map +1 -1
  26. package/dist/{history-recorder-hBGuXcZc.js → history-recorder-5VKT7nss.js} +2 -2
  27. package/dist/{history-recorder-hBGuXcZc.js.map → history-recorder-5VKT7nss.js.map} +1 -1
  28. package/dist/{history-store-C9EtGtoL.js → history-store-By2R_G1b.js} +2 -2
  29. package/dist/{history-store-C9EtGtoL.js.map → history-store-By2R_G1b.js.map} +1 -1
  30. package/dist/index.d.ts +1 -1
  31. package/dist/index.es.js +286 -245
  32. package/dist/index.es.js.map +1 -1
  33. package/dist/init/middlewares.d.ts +19 -1
  34. package/dist/init/storage.d.ts +17 -0
  35. package/dist/init.d.ts +6 -2
  36. package/dist/{jobs-DZ8DLLcU.js → jobs-1IkI4Lle.js} +2 -2
  37. package/dist/{jobs-DZ8DLLcU.js.map → jobs-1IkI4Lle.js.map} +1 -1
  38. package/dist/{logs-routes-DnJINsMu.js → logs-routes-3EEzPjhl.js} +2 -2
  39. package/dist/{logs-routes-DnJINsMu.js.map → logs-routes-3EEzPjhl.js.map} +1 -1
  40. package/dist/{openapi-generator-CAFKF36E.js → openapi-generator-BDpoKZQ9.js} +2 -2
  41. package/dist/{openapi-generator-CAFKF36E.js.map → openapi-generator-BDpoKZQ9.js.map} +1 -1
  42. package/dist/{query-parser-uqzkZ82i.js → query-parser-C-rl30ce.js} +3 -3
  43. package/dist/{query-parser-uqzkZ82i.js.map → query-parser-C-rl30ce.js.map} +1 -1
  44. package/dist/{request-timeout-BR-OBwES.js → request-timeout-C_4C2BeR.js} +2 -2
  45. package/dist/{request-timeout-BR-OBwES.js.map → request-timeout-C_4C2BeR.js.map} +1 -1
  46. package/dist/{schema-editor-routes-BKOmdf4M.js → schema-editor-routes-DdLihzp0.js} +2 -2
  47. package/dist/{schema-editor-routes-BKOmdf4M.js.map → schema-editor-routes-DdLihzp0.js.map} +1 -1
  48. package/dist/src-Br6ARbs6.js.map +1 -1
  49. package/dist/{src-DW1cQ_28.js → src-Caz7nREj.js} +2 -3
  50. package/dist/src-Caz7nREj.js.map +1 -0
  51. package/package.json +5 -5
  52. package/dist/auth-gzoRjnuh.js.map +0 -1
  53. package/dist/backup-DGu0v9Ku.js.map +0 -1
  54. package/dist/cron-scheduler-D47tdB9T.js.map +0 -1
  55. package/dist/errors-DMImyqyR.js.map +0 -1
  56. package/dist/src-DW1cQ_28.js.map +0 -1
@@ -16,5 +16,23 @@ interface MiddlewareConfig {
16
16
  origin: string | string[] | ((origin: string) => boolean);
17
17
  };
18
18
  }
19
- export declare function configureMiddlewares(app: Hono<HonoEnv>, basePath: string, isProduction: boolean, config: MiddlewareConfig): void;
19
+ export interface ConfiguredMiddlewares {
20
+ /**
21
+ * Take one path out of the global body limit, for a route that installs
22
+ * its own.
23
+ *
24
+ * Two `bodyLimit`s on one path do not combine into "the inner one wins".
25
+ * Each answers 413 on the `Content-Length` alone, so the global one, which
26
+ * runs first, refuses everything above its size before the route's own
27
+ * limit is consulted. A route that needs a *larger* ceiling has to be taken
28
+ * out of the global one. Only the code that installs the replacement limit
29
+ * should call this. Called from anywhere else, it leaves a path with no
30
+ * limit at all.
31
+ *
32
+ * The path is matched exactly. A prefix would take every sibling route
33
+ * with it.
34
+ */
35
+ exemptFromBodyLimit(path: string): void;
36
+ }
37
+ export declare function configureMiddlewares(app: Hono<HonoEnv>, basePath: string, isProduction: boolean, config: MiddlewareConfig): ConfiguredMiddlewares;
20
38
  export {};
@@ -1,4 +1,21 @@
1
1
  import { BackendStorageConfig, StorageController, StorageRegistry } from "../storage/index.js";
2
+ /**
3
+ * Whether `FORCE_LOCAL_STORAGE` says a durable volume is mounted, so a `local`
4
+ * backend may serve in production.
5
+ *
6
+ * The one reader of the variable: this guard and the `bucket` resolver that
7
+ * `rebase status` asks both call it, so status cannot promise a backend boot
8
+ * is about to drop. It used to be `!process.env.FORCE_LOCAL_STORAGE` in both,
9
+ * which is a test for "set", not for "true" — `FORCE_LOCAL_STORAGE=false` and
10
+ * `=0` read as forced, and the guard stood down for the operator who had just
11
+ * said there was no volume.
12
+ *
13
+ * Only an explicit yes forces. `loadEnv` parses the variable to the same
14
+ * boolean and refuses a value outside `true`/`false`/blank before storage is
15
+ * initialised; this reads the raw environment because a backend assembled
16
+ * without `loadEnv` still has to get the same answer.
17
+ */
18
+ export declare function localStorageForced(env?: Record<string, string | undefined>): boolean;
2
19
  export declare function initializeStorage(storageConfig: BackendStorageConfig | StorageController | Record<string, BackendStorageConfig | StorageController> | undefined, isProduction: boolean): Promise<{
3
20
  storageRegistry?: StorageRegistry;
4
21
  storageController?: StorageController;
package/dist/init.d.ts CHANGED
@@ -651,8 +651,12 @@ export interface RebaseBackendConfig {
651
651
  * Maximum request body size in bytes for API routes (default: 10MB).
652
652
  * Set to 0 to disable the global limit entirely.
653
653
  *
654
- * Note: Storage upload routes use their own limit from the storage config's
655
- * `maxFileSize` property (default: 50MB), which takes precedence over this.
654
+ * Note: `POST /storage/upload` is not under this limit. It is capped by the
655
+ * storage config's `maxFileSize` instead (default: 50MB), whether that is
656
+ * larger or smaller than this. Every other route stays under this limit,
657
+ * the rest of `/storage` included. A TUS upload is many requests, so this
658
+ * caps each chunk (`PATCH /storage/tus/:id`), and `maxFileSize` caps the
659
+ * whole file.
656
660
  */
657
661
  maxBodySize?: number;
658
662
  /**
@@ -3,7 +3,7 @@ import __rebaseProcess from "process";
3
3
  globalThis.process ??= __rebaseProcess;
4
4
  __rebaseCreateRequire(import.meta.url);
5
5
  import { n as __exportAll } from "./rolldown-runtime-dW7B1o5h.js";
6
- import { N as REBASE_USER_ROLE, P as revokeInternalTableSql } from "./src-DW1cQ_28.js";
6
+ import { N as REBASE_USER_ROLE, P as revokeInternalTableSql } from "./src-Caz7nREj.js";
7
7
  import "./src-Br6ARbs6.js";
8
8
  import { n as createDdlBootstrapper, o as isSQLAdmin, r as hasInCauseChain } from "./ddl-bootstrap-CfNvxMuK.js";
9
9
  import { n as sqlRows, t as firstSqlRow } from "./sql-rows-C6GEc2oE.js";
@@ -394,4 +394,4 @@ var jobs_exports = /* @__PURE__ */ __exportAll({
394
394
  //#endregion
395
395
  export { currentAmbientTransaction as a, createJobStore as i, createJobQueue as n, defaultBackoff as r, jobs_exports as t };
396
396
 
397
- //# sourceMappingURL=jobs-DZ8DLLcU.js.map
397
+ //# sourceMappingURL=jobs-1IkI4Lle.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"jobs-DZ8DLLcU.js","names":[],"sources":["../src/db/ambient-transaction.ts","../src/jobs/job-store.ts","../src/jobs/job-queue.ts","../src/jobs/index.ts"],"sourcesContent":["/**\n * The write transaction the current code is running inside, when there is one.\n *\n * A collection callback runs inside the transaction that carries its write.\n * Work it hands off — a job, a queue message, a topic event, a webhook\n * delivery — has to share that write's fate: queued when it commits, never\n * queued when it rolls back, and invisible to a worker until the commit. The\n * code that queues it lives in this package and knows no driver; the\n * transaction belongs to the driver. So the driver publishes the transaction it\n * opened for a write, and the queueing code asks for it here.\n *\n * Until this existed the job store wrote through the default driver's own\n * connection, in autocommit, whatever called it. A job enqueued from an\n * `afterSave` that then threw stayed queued for a row that was never written,\n * and a worker could claim it before the row it was about committed.\n *\n * The resolver lives on `globalThis` under a registered symbol, like the\n * `rebase` singleton: a managed runtime can load two copies of this package,\n * and the copy a driver registers with must be the one the job store reads.\n * No Node imports — the store is reachable from code that has to stay portable.\n *\n * **The symbol's key is a contract between packages, and frozen.** The\n * Postgres driver writes the slot itself rather than importing a setter from\n * here: the managed runtime supplies this package from the image while a\n * bundle brings its own driver, and a driver importing a function an older\n * image does not export would fail to link and take the boot down. Written\n * through the slot, any pairing of versions degrades to the old behaviour —\n * jobs committing on their own — instead.\n */\nexport interface AmbientTransaction {\n /**\n * Run one statement on the transaction, `$1…` placeholders as\n * `executeSql` takes them. It runs as the transaction's current role — on\n * Postgres the request's restricted one — so a statement that needs more\n * goes through a function granted to that role.\n */\n exec(sqlText: string, params?: unknown[]): Promise<Record<string, unknown>[]>;\n /** Run `fn` after the transaction commits. Never runs if it rolls back. */\n afterCommit(fn: () => void): void;\n}\n\n/** Frozen — see above. `server-postgres/src/services/write-transaction-scope.ts` writes it. */\nconst RESOLVER_SLOT = Symbol.for(\"rebase.server.ambientTransactionResolver\");\n\ntype ResolverSlot = { [RESOLVER_SLOT]?: () => AmbientTransaction | undefined };\n\n/**\n * Register how to find the write transaction the caller is inside; pass\n * `undefined` to unregister. For tests and for drivers in this repository's\n * lockstep — a driver shipped separately writes the slot, as above.\n */\nexport function setAmbientTransactionResolver(resolve: (() => AmbientTransaction | undefined) | undefined): void {\n (globalThis as ResolverSlot)[RESOLVER_SLOT] = resolve;\n}\n\n/** The write transaction the caller is inside, or `undefined` outside one. */\nexport function currentAmbientTransaction(): AmbientTransaction | undefined {\n return (globalThis as ResolverSlot)[RESOLVER_SLOT]?.();\n}\n","import type { DataDriver } from \"@rebasepro/types\";\nimport { isSQLAdmin } from \"@rebasepro/types\";\nimport { REBASE_USER_ROLE, revokeInternalTableSql, sqlRows, firstSqlRow } from \"@rebasepro/common\";\nimport { logger } from \"../utils/logger.js\";\nimport { createDdlBootstrapper, hasInCauseChain, type SqlExec } from \"../boot/ddl-bootstrap.js\";\nimport { currentAmbientTransaction } from \"../db/ambient-transaction.js\";\nimport type { JobRecord } from \"./types.js\";\n\n/**\n * The queue's storage, as SQL against `rebase.jobs`.\n *\n * Split from the worker for the same reason `cron-store` is split from\n * `cron-scheduler`: the interesting parts here are three statements that have\n * to be exactly right under concurrency, and they are much easier to reason\n * about — and to test — away from a polling loop.\n */\n\nconst TABLE = \"rebase.jobs\";\n\n/** How long finished jobs are kept before the boot sweep removes them. */\nconst SUCCEEDED_RETENTION_DAYS = 3;\n/**\n * Failures outlive successes by a lot. A dead-lettered job is evidence, and the\n * person who needs it is usually looking on Monday for something that happened\n * on Friday night.\n */\nconst FAILED_RETENTION_DAYS = 30;\n\n/** A row as Postgres returns it. */\ninterface JobRow {\n id: string;\n task: string;\n payload: unknown;\n status: string;\n run_at: string;\n attempts: number;\n max_attempts: number;\n last_error: string | null;\n created_at: string;\n updated_at: string;\n}\n\nfunction toRecord(row: JobRow): JobRecord {\n return {\n id: row.id,\n task: row.task,\n payload: row.payload,\n status: row.status as JobRecord[\"status\"],\n runAt: new Date(row.run_at).toISOString(),\n attempts: Number(row.attempts),\n maxAttempts: Number(row.max_attempts),\n lastError: row.last_error,\n createdAt: new Date(row.created_at).toISOString(),\n updatedAt: new Date(row.updated_at).toISOString()\n };\n}\n\n/** Same rule as `cron-store`: match the SQLSTATE, never the message. */\nfunction isUniqueViolation(err: unknown): boolean {\n return hasInCauseChain(err, (e) => e.code === \"23505\");\n}\n\n/**\n * How a job gets into `rebase.jobs` from inside somebody else's transaction.\n *\n * An enqueue from a collection callback has to ride the write's transaction —\n * committed with it, discarded with it, invisible to workers until then — and\n * that transaction runs as the request's restricted role, which has no access\n * to `rebase.jobs` at all (see the revoke in `ensureTable`). This function is\n * the one door: `SECURITY DEFINER`, so the insert runs with the owner's rights\n * for that one statement and the transaction's role never changes. Switching\n * the role for the insert instead would elevate the whole connection for as\n * long as the insert took, and a callback's un-awaited statement would run\n * in that window as the owner.\n *\n * The door is narrow on purpose. It takes the five columns an enqueue sets and\n * nothing else, so it cannot claim, complete or rewrite a job — and only the\n * request role is granted it, not PUBLIC. That role runs statements the server\n * writes; nobody outside gets SQL as it.\n *\n * `ON CONFLICT DO NOTHING` rather than a caught unique violation: an error\n * inside a transaction aborts the whole transaction, so an idempotency clash\n * would have failed the customer's write. A clash returns no row, hence NULL.\n */\nconst ENQUEUE_FUNCTION = \"rebase.enqueue_job\";\nconst ENQUEUE_SIGNATURE = `${ENQUEUE_FUNCTION}(text, jsonb, timestamptz, integer, text)`;\n\nexport interface JobStore {\n ensureTable(): Promise<void>;\n /** Returns the new job's id, or `null` if an idempotency key matched unfinished work. */\n insert(job: {\n task: string;\n payload: unknown;\n runAt: Date;\n maxAttempts: number;\n idempotencyKey?: string;\n }): Promise<string | null>;\n /** Atomically take up to `limit` runnable jobs for this worker. */\n claim(limit: number, workerId: string): Promise<JobRecord[]>;\n complete(id: string): Promise<void>;\n /** Back to `pending` with a later `runAt`, or `failed` when out of attempts. */\n fail(id: string, error: string, retryAt: Date | null): Promise<void>;\n /** Return jobs stranded by a worker that died holding them. Resolves with how many. */\n reapExpired(visibilityTimeoutMs: number): Promise<number>;\n fetch(id: string): Promise<JobRecord | null>;\n}\n\nexport function createJobStore(driver: DataDriver): JobStore | undefined {\n const admin = driver.admin;\n if (!isSQLAdmin(admin)) {\n logger.warn(\n \"⚠️ [jobs] DataDriver does not support SQL admin — the durable job queue is unavailable. \" +\n \"Work that would have been queued runs inline instead.\"\n );\n return undefined;\n }\n\n // Two shapes of the same call: the bootstrapper's `SqlExec` takes an\n // options object, while everything below reads better with a positional\n // parameter array.\n const execRaw: SqlExec = (sqlText, options) =>\n admin.executeSql(sqlText, options?.params ? { params: options.params } : undefined);\n const exec = (sqlText: string, params?: unknown[]) =>\n execRaw(sqlText, params ? { params } : undefined);\n\n const ddl = createDdlBootstrapper(execRaw, \"jobs\");\n\n /**\n * Whether {@link ENQUEUE_FUNCTION} exists, so an enqueue inside a write can\n * ride its transaction. Decided at boot rather than assumed: a database\n * that refused the function would otherwise fail every write whose callback\n * enqueues, where falling back to the old behaviour only loses atomicity.\n */\n let enqueueInTransaction = false;\n\n return {\n async ensureTable(): Promise<void> {\n await ddl.ensureObject(\"Creating schema rebase\", \"CREATE SCHEMA IF NOT EXISTS rebase\");\n\n await ddl.ensureObject(`Creating ${TABLE}`, `\n CREATE TABLE IF NOT EXISTS ${TABLE} (\n id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,\n task TEXT NOT NULL,\n payload JSONB,\n status TEXT NOT NULL DEFAULT 'pending',\n run_at TIMESTAMPTZ NOT NULL DEFAULT now(),\n attempts INTEGER NOT NULL DEFAULT 0,\n max_attempts INTEGER NOT NULL DEFAULT 3,\n locked_at TIMESTAMPTZ,\n locked_by TEXT,\n idempotency_key TEXT,\n last_error TEXT,\n created_at TIMESTAMPTZ NOT NULL DEFAULT now(),\n updated_at TIMESTAMPTZ NOT NULL DEFAULT now()\n )\n `);\n\n // The claim query's index. Partial, because the rows it has to find\n // fast are a shrinking minority of a table that also holds every\n // success and every dead letter.\n await ddl.ensureObject(\"Creating idx_jobs_runnable\", `\n CREATE INDEX IF NOT EXISTS idx_jobs_runnable\n ON ${TABLE}(run_at, created_at)\n WHERE status = 'pending'\n `);\n\n // Finds jobs stranded by a dead worker.\n await ddl.ensureObject(\"Creating idx_jobs_running\", `\n CREATE INDEX IF NOT EXISTS idx_jobs_running\n ON ${TABLE}(locked_at)\n WHERE status = 'running'\n `);\n\n // What makes `idempotencyKey` a guarantee rather than a\n // check-then-act race: two instances reacting to one event both\n // reach the INSERT, and the index decides. Partial on unfinished\n // work — see `EnqueueOptions.idempotencyKey` for why a key must be\n // reusable once its job is done.\n await ddl.ensureObject(\"Creating idx_jobs_idempotency\", `\n CREATE UNIQUE INDEX IF NOT EXISTS idx_jobs_idempotency\n ON ${TABLE}(idempotency_key)\n WHERE idempotency_key IS NOT NULL AND status IN ('pending', 'running')\n `);\n\n const ready = await ddl.isReadable(TABLE);\n\n if (ready) {\n await ddl.step(\"Job retention sweep\", async () => {\n await exec(\n `DELETE FROM ${TABLE}\n WHERE (status = 'succeeded' AND updated_at < now() - make_interval(days => $1))\n OR (status = 'failed' AND updated_at < now() - make_interval(days => $2))`,\n [SUCCEEDED_RETENTION_DAYS, FAILED_RETENTION_DAYS]\n );\n });\n\n // Payloads are arbitrary application data — a webhook body, a\n // user id, whatever was passed — and a writable queue lets any\n // signed-in user schedule work of their choosing under the\n // server's own authority. Same reasoning, and the same\n // unconditional re-application, as `cron_claims`.\n await ddl.step(\"Revoking end-user access to jobs\", () =>\n exec(revokeInternalTableSql(\"rebase\", \"jobs\")));\n\n await ddl.ensureObject(`Creating ${ENQUEUE_FUNCTION}`, `\n CREATE OR REPLACE FUNCTION ${ENQUEUE_FUNCTION}(\n p_task text, p_payload jsonb, p_run_at timestamptz,\n p_max_attempts integer, p_idempotency_key text\n ) RETURNS text\n LANGUAGE sql\n SECURITY DEFINER\n SET search_path = pg_catalog, pg_temp\n AS $fn$\n INSERT INTO ${TABLE} (task, payload, run_at, max_attempts, idempotency_key)\n VALUES (p_task, p_payload, p_run_at, p_max_attempts, p_idempotency_key)\n ON CONFLICT DO NOTHING\n RETURNING id\n $fn$\n `);\n // Re-applied every boot: a new function is executable by PUBLIC\n // until told otherwise, and the request role may be provisioned\n // after the function was first made.\n await ddl.step(`Granting ${ENQUEUE_FUNCTION} to the request role`, async () => {\n await exec(`REVOKE ALL ON FUNCTION ${ENQUEUE_SIGNATURE} FROM PUBLIC`);\n await exec(`\n DO $grant$ BEGIN\n IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${REBASE_USER_ROLE}') THEN\n GRANT EXECUTE ON FUNCTION ${ENQUEUE_SIGNATURE} TO ${REBASE_USER_ROLE};\n END IF;\n END $grant$\n `);\n });\n enqueueInTransaction = firstSqlRow<{ present: boolean }>(await exec(\n `SELECT to_regprocedure('${ENQUEUE_SIGNATURE}') IS NOT NULL AS present`\n ))?.present === true;\n if (!enqueueInTransaction) {\n logger.warn(\n `⚠️ [jobs] ${ENQUEUE_FUNCTION} could not be created, so a job enqueued inside a write ` +\n \"commits on its own: it stays queued if the write rolls back.\"\n );\n }\n\n logger.info(\"✅ Job queue table ready\");\n } else {\n logger.error(\n `❌ [jobs] ${TABLE} is unavailable — nothing can be queued and nothing queued earlier ` +\n \"will run. Callers fall back to running the work inline.\"\n );\n }\n },\n\n async insert(job): Promise<string | null> {\n const params = [\n job.task,\n JSON.stringify(job.payload ?? null),\n job.runAt.toISOString(),\n job.maxAttempts,\n job.idempotencyKey ?? null\n ];\n // Inside a write: on its transaction, through the definer function\n // — see `ENQUEUE_FUNCTION`. Checked and used with no `await`\n // between, so the transaction cannot close in the gap.\n const ambient = enqueueInTransaction ? currentAmbientTransaction() : undefined;\n if (ambient) {\n const rows = await ambient.exec(\n `SELECT ${ENQUEUE_FUNCTION}($1::text, $2::jsonb, $3::timestamptz, $4::integer, $5::text) AS id`,\n params\n );\n return (rows?.[0]?.id as string | null | undefined) ?? null;\n }\n try {\n const rows = await exec(\n `INSERT INTO ${TABLE} (task, payload, run_at, max_attempts, idempotency_key)\n VALUES ($1, $2::jsonb, $3, $4, $5)\n RETURNING id`,\n params\n );\n return (rows?.[0]?.id as string) ?? null;\n } catch (error) {\n // The losing side of an idempotency race. Not an error: the\n // work the caller wanted is already queued, which is the\n // outcome they asked for.\n if (isUniqueViolation(error)) return null;\n throw error;\n }\n },\n\n async claim(limit: number, workerId: string): Promise<JobRecord[]> {\n // `FOR UPDATE SKIP LOCKED` is the whole design. The inner select\n // takes row locks on the jobs it picks and *skips* any a concurrent\n // worker already holds, so N workers polling the same table divide\n // the work instead of contending for the head of it — and no job is\n // ever handed to two of them.\n //\n // `attempts` is incremented here, on claim, rather than on failure.\n // A worker that is killed mid-job never reports anything, so\n // counting on failure would let a job that crashes the process be\n // retried forever, once per restart, taking the process down each\n // time.\n const rows = await exec(\n `UPDATE ${TABLE} SET\n status = 'running',\n attempts = attempts + 1,\n locked_at = now(),\n locked_by = $2,\n updated_at = now()\n WHERE id IN (\n SELECT id FROM ${TABLE}\n WHERE status = 'pending' AND run_at <= now()\n ORDER BY run_at, created_at\n LIMIT $1\n FOR UPDATE SKIP LOCKED\n )\n RETURNING *`,\n [limit, workerId]\n );\n return sqlRows<JobRow>(rows).map(toRecord);\n },\n\n async complete(id: string): Promise<void> {\n await exec(\n `UPDATE ${TABLE} SET status = 'succeeded', locked_at = NULL, locked_by = NULL,\n last_error = NULL, updated_at = now()\n WHERE id = $1`,\n [id]\n );\n },\n\n async fail(id: string, error: string, retryAt: Date | null): Promise<void> {\n if (retryAt) {\n await exec(\n `UPDATE ${TABLE} SET status = 'pending', run_at = $2, locked_at = NULL,\n locked_by = NULL, last_error = $3, updated_at = now()\n WHERE id = $1`,\n [id, retryAt.toISOString(), error]\n );\n return;\n }\n await exec(\n `UPDATE ${TABLE} SET status = 'failed', locked_at = NULL, locked_by = NULL,\n last_error = $2, updated_at = now()\n WHERE id = $1`,\n [id, error]\n );\n },\n\n async reapExpired(visibilityTimeoutMs: number): Promise<number> {\n // A worker killed while holding a job cannot release it, so nothing\n // but a timeout will ever free the row. Jobs that still have\n // attempts left go back to `pending`; the rest are dead-lettered\n // with an error that says what happened, because \"attempts: 3,\n // lastError: null\" is otherwise a genuinely baffling row to find.\n const seconds = Math.max(1, Math.round(visibilityTimeoutMs / 1000));\n\n const revived = await exec(\n `UPDATE ${TABLE} SET status = 'pending', locked_at = NULL, locked_by = NULL,\n last_error = 'Worker stopped responding; the job was reclaimed', updated_at = now()\n WHERE status = 'running'\n AND locked_at < now() - make_interval(secs => $1)\n AND attempts < max_attempts\n RETURNING id`,\n [seconds]\n );\n\n const buried = await exec(\n `UPDATE ${TABLE} SET status = 'failed', locked_at = NULL, locked_by = NULL,\n last_error = 'Worker stopped responding on the final attempt', updated_at = now()\n WHERE status = 'running'\n AND locked_at < now() - make_interval(secs => $1)\n AND attempts >= max_attempts\n RETURNING id`,\n [seconds]\n );\n\n const count = (revived?.length ?? 0) + (buried?.length ?? 0);\n if (count > 0) {\n logger.warn(\n `[jobs] Reclaimed ${count} job(s) from a worker that stopped responding ` +\n `(${revived?.length ?? 0} retryable, ${buried?.length ?? 0} dead-lettered)`\n );\n }\n return count;\n },\n\n async fetch(id: string): Promise<JobRecord | null> {\n const rows = await exec(`SELECT * FROM ${TABLE} WHERE id = $1`, [id]);\n const row = firstSqlRow<JobRow>(rows);\n return row ? toRecord(row) : null;\n }\n };\n}\n","import { randomUUID } from \"crypto\";\nimport { logger } from \"../utils/logger.js\";\nimport type { JobStore } from \"./job-store.js\";\nimport type { EnqueueOptions, JobHandler, JobQueueClient, JobQueueOptions, JobRecord } from \"./types.js\";\n\n/**\n * The worker: claim, run, record, repeat.\n *\n * Everything difficult about running jobs concurrently is in `job-store.ts`,\n * where one `UPDATE … FOR UPDATE SKIP LOCKED` does the arbitration. What is\n * left here is a loop and the decisions around a handler that throws.\n */\n\nconst DEFAULT_CONCURRENCY = 5;\nconst DEFAULT_POLL_INTERVAL_MS = 2_000;\nconst DEFAULT_VISIBILITY_TIMEOUT_MS = 5 * 60_000;\nconst DEFAULT_MAX_ATTEMPTS = 3;\n\n/** 1s, 5s, 25s, 125s … capped at an hour. */\nexport function defaultBackoff(attempt: number): number {\n return Math.min(1_000 * Math.pow(5, Math.max(0, attempt - 1)), 60 * 60_000);\n}\n\n/**\n * How often the reaper runs, relative to the visibility timeout.\n *\n * A quarter of it, so a stranded job waits at most 1.25× the timeout rather\n * than 2× — and so the sweep is not itself a per-poll query against a table\n * whose interesting rows are, almost always, none.\n */\nconst REAP_INTERVAL_FACTOR = 0.25;\n\nexport interface JobQueue extends JobQueueClient {\n start(): void;\n stop(): Promise<void>;\n /** Run one poll's worth of work and return how many jobs ran. For tests and for `/jobs/drain`. */\n runOnce(): Promise<number>;\n /** Registered after construction — how `tasks` from config and internal producers meet. */\n register<P = unknown>(task: string, handler: JobHandler<P>): void;\n isRunning(): boolean;\n}\n\nexport function createJobQueue(store: JobStore, options: JobQueueOptions = {}): JobQueue {\n const concurrency = options.concurrency ?? DEFAULT_CONCURRENCY;\n const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;\n const visibilityTimeoutMs = options.visibilityTimeoutMs ?? DEFAULT_VISIBILITY_TIMEOUT_MS;\n const defaultMaxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;\n const backoff = options.backoff ?? defaultBackoff;\n\n const handlers = new Map<string, JobHandler<never>>();\n for (const [task, handler] of Object.entries(options.tasks ?? {})) {\n handlers.set(task, handler);\n }\n\n // Identifies this process in `locked_by`. Purely diagnostic — the claim is\n // enforced by the row lock, not by this — but \"which pod had it when it\n // stopped\" is the first question anyone asks of a stuck job.\n const workerId = `${process.pid}-${randomUUID().slice(0, 8)}`;\n\n let timer: NodeJS.Timeout | null = null;\n let running = false;\n let draining = false;\n /** Resolves when the in-flight poll finishes, so `stop()` can wait for it. */\n let inFlight: Promise<unknown> = Promise.resolve();\n let lastReapAt = 0;\n\n async function runJob(job: JobRecord): Promise<void> {\n const handler = handlers.get(job.task);\n\n if (!handler) {\n // Not a failure. A rolling deploy runs old and new code at once, and\n // an instance that has not been updated yet must not burn the\n // attempts of a job belonging to one that has. Give the row back\n // and let a peer — or this process after its next deploy — take it.\n //\n // The attempt increment from the claim is deliberately not undone:\n // a task nobody in the fleet implements would otherwise cycle\n // forever, and this way it dead-letters after `maxAttempts` with an\n // error naming the task.\n logger.warn(`[jobs] No handler registered for task \"${job.task}\" — returning the job to the queue`);\n await store.fail(\n job.id,\n `No handler registered for task \"${job.task}\"`,\n job.attempts < job.maxAttempts ? new Date(Date.now() + backoff(job.attempts)) : null\n );\n return;\n }\n\n try {\n await handler({\n id: job.id,\n task: job.task,\n payload: job.payload as never,\n attempt: job.attempts,\n maxAttempts: job.maxAttempts\n } as never);\n await store.complete(job.id);\n } catch (error) {\n const message = error instanceof Error ? (error.stack ?? error.message) : String(error);\n const willRetry = job.attempts < job.maxAttempts;\n\n // Truncated, because `last_error` holds a stack and a queue that\n // accumulates megabytes of them is its own outage.\n await store.fail(job.id, message.slice(0, 4_000), willRetry ? new Date(Date.now() + backoff(job.attempts)) : null);\n\n if (willRetry) {\n logger.warn(`[jobs] \"${job.task}\" failed on attempt ${job.attempts}/${job.maxAttempts}; retrying`, { jobId: job.id });\n } else {\n // The last attempt is an error, not a warning: nothing else will\n // touch this job, and if nobody looks at the table it is simply\n // lost work.\n logger.error(`[jobs] \"${job.task}\" failed permanently after ${job.attempts} attempts`, { jobId: job.id, error: message });\n }\n }\n }\n\n async function poll(): Promise<number> {\n // The reaper, on its own cadence.\n const now = Date.now();\n if (now - lastReapAt > visibilityTimeoutMs * REAP_INTERVAL_FACTOR) {\n lastReapAt = now;\n try {\n await store.reapExpired(visibilityTimeoutMs);\n } catch (error) {\n logger.error(\"[jobs] Failed to reclaim expired jobs\", { error });\n }\n }\n\n const jobs = await store.claim(concurrency, workerId);\n if (jobs.length === 0) return 0;\n\n // Settled, not `all`: `runJob` handles its own errors, but a store\n // write failing inside it must not abandon this batch's siblings.\n await Promise.allSettled(jobs.map(runJob));\n return jobs.length;\n }\n\n function schedule(delayMs: number): void {\n if (!running) return;\n timer = setTimeout(() => {\n void tick();\n }, delayMs);\n // Never hold the process open. A queue with nothing to do should not be\n // the reason `rebase dev` will not exit.\n timer.unref?.();\n }\n\n async function tick(): Promise<void> {\n if (!running) return;\n const work = (async () => {\n try {\n return await poll();\n } catch (error) {\n logger.error(\"[jobs] Poll failed\", { error });\n return 0;\n }\n })();\n inFlight = work;\n const count = await work;\n\n // A full batch means there is probably more waiting, so go straight\n // back rather than sleeping through a backlog.\n schedule(count >= concurrency ? 0 : pollIntervalMs);\n }\n\n return {\n start(): void {\n if (running) return;\n running = true;\n logger.info(`[jobs] Worker started (concurrency ${concurrency}, poll ${pollIntervalMs}ms)`);\n schedule(0);\n },\n\n async stop(): Promise<void> {\n running = false;\n if (timer) {\n clearTimeout(timer);\n timer = null;\n }\n if (draining) return;\n draining = true;\n // Jobs in flight keep their claim until they finish or the\n // visibility timeout expires, so waiting here is what turns a\n // graceful shutdown into \"no job runs twice\".\n await inFlight.catch(() => undefined);\n draining = false;\n },\n\n runOnce(): Promise<number> {\n return poll();\n },\n\n register<P = unknown>(task: string, handler: JobHandler<P>): void {\n if (handlers.has(task)) {\n logger.warn(`[jobs] Task \"${task}\" was already registered; the later handler wins`);\n }\n handlers.set(task, handler as JobHandler<never>);\n },\n\n isRunning(): boolean {\n return running;\n },\n\n async enqueue<P = unknown>(task: string, payload?: P, enqueueOptions: EnqueueOptions = {}): Promise<string | null> {\n return store.insert({\n task,\n payload: payload ?? null,\n runAt: new Date(Date.now() + (enqueueOptions.delayMs ?? 0)),\n maxAttempts: enqueueOptions.maxAttempts ?? defaultMaxAttempts,\n idempotencyKey: enqueueOptions.idempotencyKey\n });\n }\n };\n}\n","export { createJobStore } from \"./job-store\";\nexport type { JobStore } from \"./job-store\";\nexport { createJobQueue, defaultBackoff } from \"./job-queue\";\nexport type { JobQueue } from \"./job-queue\";\nexport type {\n EnqueueOptions,\n JobContext,\n JobHandler,\n JobQueueClient,\n JobQueueOptions,\n JobRecord,\n JobStatus\n} from \"./types\";\n"],"mappings":"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"}
1
+ {"version":3,"file":"jobs-1IkI4Lle.js","names":[],"sources":["../src/db/ambient-transaction.ts","../src/jobs/job-store.ts","../src/jobs/job-queue.ts","../src/jobs/index.ts"],"sourcesContent":["/**\n * The write transaction the current code is running inside, when there is one.\n *\n * A collection callback runs inside the transaction that carries its write.\n * Work it hands off — a job, a queue message, a topic event, a webhook\n * delivery — has to share that write's fate: queued when it commits, never\n * queued when it rolls back, and invisible to a worker until the commit. The\n * code that queues it lives in this package and knows no driver; the\n * transaction belongs to the driver. So the driver publishes the transaction it\n * opened for a write, and the queueing code asks for it here.\n *\n * Until this existed the job store wrote through the default driver's own\n * connection, in autocommit, whatever called it. A job enqueued from an\n * `afterSave` that then threw stayed queued for a row that was never written,\n * and a worker could claim it before the row it was about committed.\n *\n * The resolver lives on `globalThis` under a registered symbol, like the\n * `rebase` singleton: a managed runtime can load two copies of this package,\n * and the copy a driver registers with must be the one the job store reads.\n * No Node imports — the store is reachable from code that has to stay portable.\n *\n * **The symbol's key is a contract between packages, and frozen.** The\n * Postgres driver writes the slot itself rather than importing a setter from\n * here: the managed runtime supplies this package from the image while a\n * bundle brings its own driver, and a driver importing a function an older\n * image does not export would fail to link and take the boot down. Written\n * through the slot, any pairing of versions degrades to the old behaviour —\n * jobs committing on their own — instead.\n */\nexport interface AmbientTransaction {\n /**\n * Run one statement on the transaction, `$1…` placeholders as\n * `executeSql` takes them. It runs as the transaction's current role — on\n * Postgres the request's restricted one — so a statement that needs more\n * goes through a function granted to that role.\n */\n exec(sqlText: string, params?: unknown[]): Promise<Record<string, unknown>[]>;\n /** Run `fn` after the transaction commits. Never runs if it rolls back. */\n afterCommit(fn: () => void): void;\n}\n\n/** Frozen — see above. `server-postgres/src/services/write-transaction-scope.ts` writes it. */\nconst RESOLVER_SLOT = Symbol.for(\"rebase.server.ambientTransactionResolver\");\n\ntype ResolverSlot = { [RESOLVER_SLOT]?: () => AmbientTransaction | undefined };\n\n/**\n * Register how to find the write transaction the caller is inside; pass\n * `undefined` to unregister. For tests and for drivers in this repository's\n * lockstep — a driver shipped separately writes the slot, as above.\n */\nexport function setAmbientTransactionResolver(resolve: (() => AmbientTransaction | undefined) | undefined): void {\n (globalThis as ResolverSlot)[RESOLVER_SLOT] = resolve;\n}\n\n/** The write transaction the caller is inside, or `undefined` outside one. */\nexport function currentAmbientTransaction(): AmbientTransaction | undefined {\n return (globalThis as ResolverSlot)[RESOLVER_SLOT]?.();\n}\n","import type { DataDriver } from \"@rebasepro/types\";\nimport { isSQLAdmin } from \"@rebasepro/types\";\nimport { REBASE_USER_ROLE, revokeInternalTableSql, sqlRows, firstSqlRow } from \"@rebasepro/common\";\nimport { logger } from \"../utils/logger.js\";\nimport { createDdlBootstrapper, hasInCauseChain, type SqlExec } from \"../boot/ddl-bootstrap.js\";\nimport { currentAmbientTransaction } from \"../db/ambient-transaction.js\";\nimport type { JobRecord } from \"./types.js\";\n\n/**\n * The queue's storage, as SQL against `rebase.jobs`.\n *\n * Split from the worker for the same reason `cron-store` is split from\n * `cron-scheduler`: the interesting parts here are three statements that have\n * to be exactly right under concurrency, and they are much easier to reason\n * about — and to test — away from a polling loop.\n */\n\nconst TABLE = \"rebase.jobs\";\n\n/** How long finished jobs are kept before the boot sweep removes them. */\nconst SUCCEEDED_RETENTION_DAYS = 3;\n/**\n * Failures outlive successes by a lot. A dead-lettered job is evidence, and the\n * person who needs it is usually looking on Monday for something that happened\n * on Friday night.\n */\nconst FAILED_RETENTION_DAYS = 30;\n\n/** A row as Postgres returns it. */\ninterface JobRow {\n id: string;\n task: string;\n payload: unknown;\n status: string;\n run_at: string;\n attempts: number;\n max_attempts: number;\n last_error: string | null;\n created_at: string;\n updated_at: string;\n}\n\nfunction toRecord(row: JobRow): JobRecord {\n return {\n id: row.id,\n task: row.task,\n payload: row.payload,\n status: row.status as JobRecord[\"status\"],\n runAt: new Date(row.run_at).toISOString(),\n attempts: Number(row.attempts),\n maxAttempts: Number(row.max_attempts),\n lastError: row.last_error,\n createdAt: new Date(row.created_at).toISOString(),\n updatedAt: new Date(row.updated_at).toISOString()\n };\n}\n\n/** Same rule as `cron-store`: match the SQLSTATE, never the message. */\nfunction isUniqueViolation(err: unknown): boolean {\n return hasInCauseChain(err, (e) => e.code === \"23505\");\n}\n\n/**\n * How a job gets into `rebase.jobs` from inside somebody else's transaction.\n *\n * An enqueue from a collection callback has to ride the write's transaction —\n * committed with it, discarded with it, invisible to workers until then — and\n * that transaction runs as the request's restricted role, which has no access\n * to `rebase.jobs` at all (see the revoke in `ensureTable`). This function is\n * the one door: `SECURITY DEFINER`, so the insert runs with the owner's rights\n * for that one statement and the transaction's role never changes. Switching\n * the role for the insert instead would elevate the whole connection for as\n * long as the insert took, and a callback's un-awaited statement would run\n * in that window as the owner.\n *\n * The door is narrow on purpose. It takes the five columns an enqueue sets and\n * nothing else, so it cannot claim, complete or rewrite a job — and only the\n * request role is granted it, not PUBLIC. That role runs statements the server\n * writes; nobody outside gets SQL as it.\n *\n * `ON CONFLICT DO NOTHING` rather than a caught unique violation: an error\n * inside a transaction aborts the whole transaction, so an idempotency clash\n * would have failed the customer's write. A clash returns no row, hence NULL.\n */\nconst ENQUEUE_FUNCTION = \"rebase.enqueue_job\";\nconst ENQUEUE_SIGNATURE = `${ENQUEUE_FUNCTION}(text, jsonb, timestamptz, integer, text)`;\n\nexport interface JobStore {\n ensureTable(): Promise<void>;\n /** Returns the new job's id, or `null` if an idempotency key matched unfinished work. */\n insert(job: {\n task: string;\n payload: unknown;\n runAt: Date;\n maxAttempts: number;\n idempotencyKey?: string;\n }): Promise<string | null>;\n /** Atomically take up to `limit` runnable jobs for this worker. */\n claim(limit: number, workerId: string): Promise<JobRecord[]>;\n complete(id: string): Promise<void>;\n /** Back to `pending` with a later `runAt`, or `failed` when out of attempts. */\n fail(id: string, error: string, retryAt: Date | null): Promise<void>;\n /** Return jobs stranded by a worker that died holding them. Resolves with how many. */\n reapExpired(visibilityTimeoutMs: number): Promise<number>;\n fetch(id: string): Promise<JobRecord | null>;\n}\n\nexport function createJobStore(driver: DataDriver): JobStore | undefined {\n const admin = driver.admin;\n if (!isSQLAdmin(admin)) {\n logger.warn(\n \"⚠️ [jobs] DataDriver does not support SQL admin — the durable job queue is unavailable. \" +\n \"Work that would have been queued runs inline instead.\"\n );\n return undefined;\n }\n\n // Two shapes of the same call: the bootstrapper's `SqlExec` takes an\n // options object, while everything below reads better with a positional\n // parameter array.\n const execRaw: SqlExec = (sqlText, options) =>\n admin.executeSql(sqlText, options?.params ? { params: options.params } : undefined);\n const exec = (sqlText: string, params?: unknown[]) =>\n execRaw(sqlText, params ? { params } : undefined);\n\n const ddl = createDdlBootstrapper(execRaw, \"jobs\");\n\n /**\n * Whether {@link ENQUEUE_FUNCTION} exists, so an enqueue inside a write can\n * ride its transaction. Decided at boot rather than assumed: a database\n * that refused the function would otherwise fail every write whose callback\n * enqueues, where falling back to the old behaviour only loses atomicity.\n */\n let enqueueInTransaction = false;\n\n return {\n async ensureTable(): Promise<void> {\n await ddl.ensureObject(\"Creating schema rebase\", \"CREATE SCHEMA IF NOT EXISTS rebase\");\n\n await ddl.ensureObject(`Creating ${TABLE}`, `\n CREATE TABLE IF NOT EXISTS ${TABLE} (\n id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,\n task TEXT NOT NULL,\n payload JSONB,\n status TEXT NOT NULL DEFAULT 'pending',\n run_at TIMESTAMPTZ NOT NULL DEFAULT now(),\n attempts INTEGER NOT NULL DEFAULT 0,\n max_attempts INTEGER NOT NULL DEFAULT 3,\n locked_at TIMESTAMPTZ,\n locked_by TEXT,\n idempotency_key TEXT,\n last_error TEXT,\n created_at TIMESTAMPTZ NOT NULL DEFAULT now(),\n updated_at TIMESTAMPTZ NOT NULL DEFAULT now()\n )\n `);\n\n // The claim query's index. Partial, because the rows it has to find\n // fast are a shrinking minority of a table that also holds every\n // success and every dead letter.\n await ddl.ensureObject(\"Creating idx_jobs_runnable\", `\n CREATE INDEX IF NOT EXISTS idx_jobs_runnable\n ON ${TABLE}(run_at, created_at)\n WHERE status = 'pending'\n `);\n\n // Finds jobs stranded by a dead worker.\n await ddl.ensureObject(\"Creating idx_jobs_running\", `\n CREATE INDEX IF NOT EXISTS idx_jobs_running\n ON ${TABLE}(locked_at)\n WHERE status = 'running'\n `);\n\n // What makes `idempotencyKey` a guarantee rather than a\n // check-then-act race: two instances reacting to one event both\n // reach the INSERT, and the index decides. Partial on unfinished\n // work — see `EnqueueOptions.idempotencyKey` for why a key must be\n // reusable once its job is done.\n await ddl.ensureObject(\"Creating idx_jobs_idempotency\", `\n CREATE UNIQUE INDEX IF NOT EXISTS idx_jobs_idempotency\n ON ${TABLE}(idempotency_key)\n WHERE idempotency_key IS NOT NULL AND status IN ('pending', 'running')\n `);\n\n const ready = await ddl.isReadable(TABLE);\n\n if (ready) {\n await ddl.step(\"Job retention sweep\", async () => {\n await exec(\n `DELETE FROM ${TABLE}\n WHERE (status = 'succeeded' AND updated_at < now() - make_interval(days => $1))\n OR (status = 'failed' AND updated_at < now() - make_interval(days => $2))`,\n [SUCCEEDED_RETENTION_DAYS, FAILED_RETENTION_DAYS]\n );\n });\n\n // Payloads are arbitrary application data — a webhook body, a\n // user id, whatever was passed — and a writable queue lets any\n // signed-in user schedule work of their choosing under the\n // server's own authority. Same reasoning, and the same\n // unconditional re-application, as `cron_claims`.\n await ddl.step(\"Revoking end-user access to jobs\", () =>\n exec(revokeInternalTableSql(\"rebase\", \"jobs\")));\n\n await ddl.ensureObject(`Creating ${ENQUEUE_FUNCTION}`, `\n CREATE OR REPLACE FUNCTION ${ENQUEUE_FUNCTION}(\n p_task text, p_payload jsonb, p_run_at timestamptz,\n p_max_attempts integer, p_idempotency_key text\n ) RETURNS text\n LANGUAGE sql\n SECURITY DEFINER\n SET search_path = pg_catalog, pg_temp\n AS $fn$\n INSERT INTO ${TABLE} (task, payload, run_at, max_attempts, idempotency_key)\n VALUES (p_task, p_payload, p_run_at, p_max_attempts, p_idempotency_key)\n ON CONFLICT DO NOTHING\n RETURNING id\n $fn$\n `);\n // Re-applied every boot: a new function is executable by PUBLIC\n // until told otherwise, and the request role may be provisioned\n // after the function was first made.\n await ddl.step(`Granting ${ENQUEUE_FUNCTION} to the request role`, async () => {\n await exec(`REVOKE ALL ON FUNCTION ${ENQUEUE_SIGNATURE} FROM PUBLIC`);\n await exec(`\n DO $grant$ BEGIN\n IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${REBASE_USER_ROLE}') THEN\n GRANT EXECUTE ON FUNCTION ${ENQUEUE_SIGNATURE} TO ${REBASE_USER_ROLE};\n END IF;\n END $grant$\n `);\n });\n enqueueInTransaction = firstSqlRow<{ present: boolean }>(await exec(\n `SELECT to_regprocedure('${ENQUEUE_SIGNATURE}') IS NOT NULL AS present`\n ))?.present === true;\n if (!enqueueInTransaction) {\n logger.warn(\n `⚠️ [jobs] ${ENQUEUE_FUNCTION} could not be created, so a job enqueued inside a write ` +\n \"commits on its own: it stays queued if the write rolls back.\"\n );\n }\n\n logger.info(\"✅ Job queue table ready\");\n } else {\n logger.error(\n `❌ [jobs] ${TABLE} is unavailable — nothing can be queued and nothing queued earlier ` +\n \"will run. Callers fall back to running the work inline.\"\n );\n }\n },\n\n async insert(job): Promise<string | null> {\n const params = [\n job.task,\n JSON.stringify(job.payload ?? null),\n job.runAt.toISOString(),\n job.maxAttempts,\n job.idempotencyKey ?? null\n ];\n // Inside a write: on its transaction, through the definer function\n // — see `ENQUEUE_FUNCTION`. Checked and used with no `await`\n // between, so the transaction cannot close in the gap.\n const ambient = enqueueInTransaction ? currentAmbientTransaction() : undefined;\n if (ambient) {\n const rows = await ambient.exec(\n `SELECT ${ENQUEUE_FUNCTION}($1::text, $2::jsonb, $3::timestamptz, $4::integer, $5::text) AS id`,\n params\n );\n return (rows?.[0]?.id as string | null | undefined) ?? null;\n }\n try {\n const rows = await exec(\n `INSERT INTO ${TABLE} (task, payload, run_at, max_attempts, idempotency_key)\n VALUES ($1, $2::jsonb, $3, $4, $5)\n RETURNING id`,\n params\n );\n return (rows?.[0]?.id as string) ?? null;\n } catch (error) {\n // The losing side of an idempotency race. Not an error: the\n // work the caller wanted is already queued, which is the\n // outcome they asked for.\n if (isUniqueViolation(error)) return null;\n throw error;\n }\n },\n\n async claim(limit: number, workerId: string): Promise<JobRecord[]> {\n // `FOR UPDATE SKIP LOCKED` is the whole design. The inner select\n // takes row locks on the jobs it picks and *skips* any a concurrent\n // worker already holds, so N workers polling the same table divide\n // the work instead of contending for the head of it — and no job is\n // ever handed to two of them.\n //\n // `attempts` is incremented here, on claim, rather than on failure.\n // A worker that is killed mid-job never reports anything, so\n // counting on failure would let a job that crashes the process be\n // retried forever, once per restart, taking the process down each\n // time.\n const rows = await exec(\n `UPDATE ${TABLE} SET\n status = 'running',\n attempts = attempts + 1,\n locked_at = now(),\n locked_by = $2,\n updated_at = now()\n WHERE id IN (\n SELECT id FROM ${TABLE}\n WHERE status = 'pending' AND run_at <= now()\n ORDER BY run_at, created_at\n LIMIT $1\n FOR UPDATE SKIP LOCKED\n )\n RETURNING *`,\n [limit, workerId]\n );\n return sqlRows<JobRow>(rows).map(toRecord);\n },\n\n async complete(id: string): Promise<void> {\n await exec(\n `UPDATE ${TABLE} SET status = 'succeeded', locked_at = NULL, locked_by = NULL,\n last_error = NULL, updated_at = now()\n WHERE id = $1`,\n [id]\n );\n },\n\n async fail(id: string, error: string, retryAt: Date | null): Promise<void> {\n if (retryAt) {\n await exec(\n `UPDATE ${TABLE} SET status = 'pending', run_at = $2, locked_at = NULL,\n locked_by = NULL, last_error = $3, updated_at = now()\n WHERE id = $1`,\n [id, retryAt.toISOString(), error]\n );\n return;\n }\n await exec(\n `UPDATE ${TABLE} SET status = 'failed', locked_at = NULL, locked_by = NULL,\n last_error = $2, updated_at = now()\n WHERE id = $1`,\n [id, error]\n );\n },\n\n async reapExpired(visibilityTimeoutMs: number): Promise<number> {\n // A worker killed while holding a job cannot release it, so nothing\n // but a timeout will ever free the row. Jobs that still have\n // attempts left go back to `pending`; the rest are dead-lettered\n // with an error that says what happened, because \"attempts: 3,\n // lastError: null\" is otherwise a genuinely baffling row to find.\n const seconds = Math.max(1, Math.round(visibilityTimeoutMs / 1000));\n\n const revived = await exec(\n `UPDATE ${TABLE} SET status = 'pending', locked_at = NULL, locked_by = NULL,\n last_error = 'Worker stopped responding; the job was reclaimed', updated_at = now()\n WHERE status = 'running'\n AND locked_at < now() - make_interval(secs => $1)\n AND attempts < max_attempts\n RETURNING id`,\n [seconds]\n );\n\n const buried = await exec(\n `UPDATE ${TABLE} SET status = 'failed', locked_at = NULL, locked_by = NULL,\n last_error = 'Worker stopped responding on the final attempt', updated_at = now()\n WHERE status = 'running'\n AND locked_at < now() - make_interval(secs => $1)\n AND attempts >= max_attempts\n RETURNING id`,\n [seconds]\n );\n\n const count = (revived?.length ?? 0) + (buried?.length ?? 0);\n if (count > 0) {\n logger.warn(\n `[jobs] Reclaimed ${count} job(s) from a worker that stopped responding ` +\n `(${revived?.length ?? 0} retryable, ${buried?.length ?? 0} dead-lettered)`\n );\n }\n return count;\n },\n\n async fetch(id: string): Promise<JobRecord | null> {\n const rows = await exec(`SELECT * FROM ${TABLE} WHERE id = $1`, [id]);\n const row = firstSqlRow<JobRow>(rows);\n return row ? toRecord(row) : null;\n }\n };\n}\n","import { randomUUID } from \"crypto\";\nimport { logger } from \"../utils/logger.js\";\nimport type { JobStore } from \"./job-store.js\";\nimport type { EnqueueOptions, JobHandler, JobQueueClient, JobQueueOptions, JobRecord } from \"./types.js\";\n\n/**\n * The worker: claim, run, record, repeat.\n *\n * Everything difficult about running jobs concurrently is in `job-store.ts`,\n * where one `UPDATE … FOR UPDATE SKIP LOCKED` does the arbitration. What is\n * left here is a loop and the decisions around a handler that throws.\n */\n\nconst DEFAULT_CONCURRENCY = 5;\nconst DEFAULT_POLL_INTERVAL_MS = 2_000;\nconst DEFAULT_VISIBILITY_TIMEOUT_MS = 5 * 60_000;\nconst DEFAULT_MAX_ATTEMPTS = 3;\n\n/** 1s, 5s, 25s, 125s … capped at an hour. */\nexport function defaultBackoff(attempt: number): number {\n return Math.min(1_000 * Math.pow(5, Math.max(0, attempt - 1)), 60 * 60_000);\n}\n\n/**\n * How often the reaper runs, relative to the visibility timeout.\n *\n * A quarter of it, so a stranded job waits at most 1.25× the timeout rather\n * than 2× — and so the sweep is not itself a per-poll query against a table\n * whose interesting rows are, almost always, none.\n */\nconst REAP_INTERVAL_FACTOR = 0.25;\n\nexport interface JobQueue extends JobQueueClient {\n start(): void;\n stop(): Promise<void>;\n /** Run one poll's worth of work and return how many jobs ran. For tests and for `/jobs/drain`. */\n runOnce(): Promise<number>;\n /** Registered after construction — how `tasks` from config and internal producers meet. */\n register<P = unknown>(task: string, handler: JobHandler<P>): void;\n isRunning(): boolean;\n}\n\nexport function createJobQueue(store: JobStore, options: JobQueueOptions = {}): JobQueue {\n const concurrency = options.concurrency ?? DEFAULT_CONCURRENCY;\n const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;\n const visibilityTimeoutMs = options.visibilityTimeoutMs ?? DEFAULT_VISIBILITY_TIMEOUT_MS;\n const defaultMaxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;\n const backoff = options.backoff ?? defaultBackoff;\n\n const handlers = new Map<string, JobHandler<never>>();\n for (const [task, handler] of Object.entries(options.tasks ?? {})) {\n handlers.set(task, handler);\n }\n\n // Identifies this process in `locked_by`. Purely diagnostic — the claim is\n // enforced by the row lock, not by this — but \"which pod had it when it\n // stopped\" is the first question anyone asks of a stuck job.\n const workerId = `${process.pid}-${randomUUID().slice(0, 8)}`;\n\n let timer: NodeJS.Timeout | null = null;\n let running = false;\n let draining = false;\n /** Resolves when the in-flight poll finishes, so `stop()` can wait for it. */\n let inFlight: Promise<unknown> = Promise.resolve();\n let lastReapAt = 0;\n\n async function runJob(job: JobRecord): Promise<void> {\n const handler = handlers.get(job.task);\n\n if (!handler) {\n // Not a failure. A rolling deploy runs old and new code at once, and\n // an instance that has not been updated yet must not burn the\n // attempts of a job belonging to one that has. Give the row back\n // and let a peer — or this process after its next deploy — take it.\n //\n // The attempt increment from the claim is deliberately not undone:\n // a task nobody in the fleet implements would otherwise cycle\n // forever, and this way it dead-letters after `maxAttempts` with an\n // error naming the task.\n logger.warn(`[jobs] No handler registered for task \"${job.task}\" — returning the job to the queue`);\n await store.fail(\n job.id,\n `No handler registered for task \"${job.task}\"`,\n job.attempts < job.maxAttempts ? new Date(Date.now() + backoff(job.attempts)) : null\n );\n return;\n }\n\n try {\n await handler({\n id: job.id,\n task: job.task,\n payload: job.payload as never,\n attempt: job.attempts,\n maxAttempts: job.maxAttempts\n } as never);\n await store.complete(job.id);\n } catch (error) {\n const message = error instanceof Error ? (error.stack ?? error.message) : String(error);\n const willRetry = job.attempts < job.maxAttempts;\n\n // Truncated, because `last_error` holds a stack and a queue that\n // accumulates megabytes of them is its own outage.\n await store.fail(job.id, message.slice(0, 4_000), willRetry ? new Date(Date.now() + backoff(job.attempts)) : null);\n\n if (willRetry) {\n logger.warn(`[jobs] \"${job.task}\" failed on attempt ${job.attempts}/${job.maxAttempts}; retrying`, { jobId: job.id });\n } else {\n // The last attempt is an error, not a warning: nothing else will\n // touch this job, and if nobody looks at the table it is simply\n // lost work.\n logger.error(`[jobs] \"${job.task}\" failed permanently after ${job.attempts} attempts`, { jobId: job.id, error: message });\n }\n }\n }\n\n async function poll(): Promise<number> {\n // The reaper, on its own cadence.\n const now = Date.now();\n if (now - lastReapAt > visibilityTimeoutMs * REAP_INTERVAL_FACTOR) {\n lastReapAt = now;\n try {\n await store.reapExpired(visibilityTimeoutMs);\n } catch (error) {\n logger.error(\"[jobs] Failed to reclaim expired jobs\", { error });\n }\n }\n\n const jobs = await store.claim(concurrency, workerId);\n if (jobs.length === 0) return 0;\n\n // Settled, not `all`: `runJob` handles its own errors, but a store\n // write failing inside it must not abandon this batch's siblings.\n await Promise.allSettled(jobs.map(runJob));\n return jobs.length;\n }\n\n function schedule(delayMs: number): void {\n if (!running) return;\n timer = setTimeout(() => {\n void tick();\n }, delayMs);\n // Never hold the process open. A queue with nothing to do should not be\n // the reason `rebase dev` will not exit.\n timer.unref?.();\n }\n\n async function tick(): Promise<void> {\n if (!running) return;\n const work = (async () => {\n try {\n return await poll();\n } catch (error) {\n logger.error(\"[jobs] Poll failed\", { error });\n return 0;\n }\n })();\n inFlight = work;\n const count = await work;\n\n // A full batch means there is probably more waiting, so go straight\n // back rather than sleeping through a backlog.\n schedule(count >= concurrency ? 0 : pollIntervalMs);\n }\n\n return {\n start(): void {\n if (running) return;\n running = true;\n logger.info(`[jobs] Worker started (concurrency ${concurrency}, poll ${pollIntervalMs}ms)`);\n schedule(0);\n },\n\n async stop(): Promise<void> {\n running = false;\n if (timer) {\n clearTimeout(timer);\n timer = null;\n }\n if (draining) return;\n draining = true;\n // Jobs in flight keep their claim until they finish or the\n // visibility timeout expires, so waiting here is what turns a\n // graceful shutdown into \"no job runs twice\".\n await inFlight.catch(() => undefined);\n draining = false;\n },\n\n runOnce(): Promise<number> {\n return poll();\n },\n\n register<P = unknown>(task: string, handler: JobHandler<P>): void {\n if (handlers.has(task)) {\n logger.warn(`[jobs] Task \"${task}\" was already registered; the later handler wins`);\n }\n handlers.set(task, handler as JobHandler<never>);\n },\n\n isRunning(): boolean {\n return running;\n },\n\n async enqueue<P = unknown>(task: string, payload?: P, enqueueOptions: EnqueueOptions = {}): Promise<string | null> {\n return store.insert({\n task,\n payload: payload ?? null,\n runAt: new Date(Date.now() + (enqueueOptions.delayMs ?? 0)),\n maxAttempts: enqueueOptions.maxAttempts ?? defaultMaxAttempts,\n idempotencyKey: enqueueOptions.idempotencyKey\n });\n }\n };\n}\n","export { createJobStore } from \"./job-store\";\nexport type { JobStore } from \"./job-store\";\nexport { createJobQueue, defaultBackoff } from \"./job-queue\";\nexport type { JobQueue } from \"./job-queue\";\nexport type {\n EnqueueOptions,\n JobContext,\n JobHandler,\n JobQueueClient,\n JobQueueOptions,\n JobRecord,\n JobStatus\n} from \"./types\";\n"],"mappings":"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"}
@@ -4,7 +4,7 @@ globalThis.process ??= __rebaseProcess;
4
4
  __rebaseCreateRequire(import.meta.url);
5
5
  import { n as __exportAll } from "./rolldown-runtime-dW7B1o5h.js";
6
6
  import { t as addLogSink } from "./logger-DO2PZc4i.js";
7
- import { n as errorHandler, t as ApiError } from "./errors-DMImyqyR.js";
7
+ import { r as errorHandler, t as ApiError } from "./errors-DWsX4yTd.js";
8
8
  import { Hono } from "hono";
9
9
  import { streamSSE } from "hono/streaming";
10
10
  //#region src/api/logs-routes.ts
@@ -337,4 +337,4 @@ var logs_routes_default = createLogsRoutes();
337
337
  //#endregion
338
338
  export { logs_routes_exports as n, logMiddleware as t };
339
339
 
340
- //# sourceMappingURL=logs-routes-DnJINsMu.js.map
340
+ //# sourceMappingURL=logs-routes-3EEzPjhl.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"logs-routes-DnJINsMu.js","names":[],"sources":["../src/api/logs-routes.ts"],"sourcesContent":["import { Hono } from \"hono\";\nimport type { MiddlewareHandler } from \"hono\";\nimport { streamSSE } from \"hono/streaming\";\nimport type { HonoEnv } from \"./types\";\nimport { ApiError, errorHandler } from \"./errors\";\nimport { addLogSink } from \"../utils/logger\";\n\nexport interface LogEntry {\n id: string;\n timestamp: string;\n level: \"debug\" | \"info\" | \"warn\" | \"error\";\n source: \"api\" | \"auth\" | \"storage\" | \"realtime\" | \"system\";\n message: string;\n metadata?: Record<string, unknown>;\n}\n\n/** What a caller can narrow the log by, in either direction (query or stream). */\nexport interface LogFilterOptions {\n level?: string;\n source?: string;\n search?: string;\n since?: string;\n}\n\n/**\n * A filter with the search term already lowercased.\n *\n * The distinction matters on the stream path: `query()` lowercases once and then\n * scans, but a subscriber tests one entry at a time and would otherwise redo the\n * same `toLowerCase()` on every request the server handles.\n */\ntype NormalizedFilter = LogFilterOptions;\n\nfunction normalizeFilter(options: LogFilterOptions): NormalizedFilter {\n return { ...options,\n search: options.search?.toLowerCase() };\n}\n\n/**\n * Whether one entry belongs in a filtered view.\n *\n * Shared by the query and the stream on purpose: two copies of this would drift,\n * and the failure that produces is invisible — a tail that quietly shows a\n * different set of lines than the snapshot it started from.\n */\nfunction matchesFilter(entry: LogEntry, filter: NormalizedFilter): boolean {\n if (filter.level && entry.level !== filter.level) return false;\n if (filter.source && entry.source !== filter.source) return false;\n if (filter.search && !entry.message.toLowerCase().includes(filter.search)) return false;\n if (filter.since && entry.timestamp < filter.since) return false;\n return true;\n}\n\n/** Notified for every entry pushed, in push order. */\nexport type LogListener = (entry: LogEntry) => void;\n\nclass LogRingBuffer {\n private buffer: LogEntry[] = [];\n private maxSize: number;\n private idCounter = 0;\n private listeners = new Set<LogListener>();\n\n constructor(maxSize = 10000) {\n this.maxSize = maxSize;\n }\n\n push(entry: Omit<LogEntry, \"id\">): void {\n const id = `log_${++this.idCounter}`;\n const stored: LogEntry = { ...entry,\n id };\n this.buffer.push(stored);\n if (this.buffer.length > this.maxSize) {\n this.buffer.shift();\n }\n // This runs on the request hot path, so a listener must never be able to\n // take the request down with it: a tail that throws loses its own tail,\n // not the response the log line was describing.\n for (const listener of this.listeners) {\n try {\n listener(stored);\n } catch {\n /* a broken tail is not the request's problem */\n }\n }\n }\n\n /**\n * Follow the buffer. Returns the unsubscribe — call it, always: a listener\n * left behind holds its whole closure, and on this class that closure is a\n * pending-entry array.\n *\n * A listener must not log. It is called from inside `push`, so anything that\n * reaches `addLog` from here recurses until the stack gives out.\n */\n subscribe(listener: LogListener): () => void {\n this.listeners.add(listener);\n return () => {\n this.listeners.delete(listener);\n };\n }\n\n query(options: LogFilterOptions & {\n limit?: number;\n offset?: number;\n }): { entries: LogEntry[]; total: number } {\n const filter = normalizeFilter(options);\n const filtered = this.buffer.filter(e => matchesFilter(e, filter));\n\n // Newest first\n const sorted = [...filtered].reverse();\n const total = sorted.length;\n const limit = options.limit || 100;\n const offset = options.offset || 0;\n\n return {\n entries: sorted.slice(offset, offset + limit),\n total\n };\n }\n\n getLatest(count = 50): LogEntry[] {\n return this.buffer.slice(-count).reverse();\n }\n}\n\n// Global singleton\nexport const logBuffer = new LogRingBuffer();\n\n/** Add a log entry */\nexport function addLog(\n level: LogEntry[\"level\"],\n source: LogEntry[\"source\"],\n message: string,\n metadata?: Record<string, unknown>\n): void {\n logBuffer.push({\n timestamp: new Date().toISOString(),\n level,\n source,\n message,\n metadata\n });\n}\n\nexport interface LogMiddlewareOptions {\n /**\n * Paths this sink ignores, matched exactly against `c.req.path`.\n *\n * For requests whose only reason to exist is to read the log. Recording\n * those makes the reader the loudest thing in its own output, and on a quiet\n * server it is also the thing evicting real entries out of the ring.\n */\n ignorePaths?: string[];\n}\n\n/** Hono middleware to log API requests */\nexport function logMiddleware(options: LogMiddlewareOptions = {}): MiddlewareHandler<HonoEnv> {\n const ignored = new Set(options.ignorePaths ?? []);\n return async (c, next) => {\n const start = Date.now();\n await next();\n if (ignored.has(c.req.path)) return;\n const duration = Date.now() - start;\n const reqId = c.get(\"requestId\");\n // Every request used to be recorded at `info`, whatever it answered, so\n // the Logs Explorer's level filter could not find a single failure: a\n // 500 sat at the same level as the 200 above it, in a wall of them.\n const status = c.res.status;\n const level: LogEntry[\"level\"] = status >= 500 ? \"error\" : status >= 400 ? \"warn\" : \"info\";\n // What the error handler answered, so the failure is on the entry\n // rather than in a stdout line the panel cannot see. See\n // `HonoEnv.Variables.errorSummary`.\n const failure = c.get(\"errorSummary\");\n addLog(\n level,\n \"api\",\n `${c.req.method} ${c.req.path} ${status} ${duration}ms`\n + (failure ? ` — ${failure.code}: ${failure.message}` : \"\"),\n {\n method: c.req.method,\n path: c.req.path,\n status,\n duration,\n ...(reqId && { requestId: reqId }),\n ...(c.get(\"collection\") && { collection: c.get(\"collection\") }),\n ...(failure && { errorCode: failure.code, errorMessage: failure.message })\n }\n );\n };\n}\n\n/**\n * Prefixes the server writes at the head of a log message, and the `source`\n * each one belongs to.\n *\n * The ring's `source` is a closed set the Studio filters on, and the messages\n * carry their origin as a bracketed prefix — `[API]`, `[Auth]`, `[functions]`,\n * `[schema]`. Matching them is what makes a teed line filterable beside the\n * request entries rather than a heap under \"system\".\n */\nconst SOURCE_BY_PREFIX: Array<[RegExp, LogEntry[\"source\"]]> = [\n [/^\\[?(api|rest)\\b/i, \"api\"],\n [/^\\[?auth\\b/i, \"auth\"],\n [/^\\[?(storage|s3|gcs)\\b/i, \"storage\"],\n [/^\\[?(realtime|ws|websocket|cdc)\\b/i, \"realtime\"]\n];\n\n/**\n * The `source` for a teed line, from whatever prefix it carries.\n *\n * Exported for its test: the mapping is a string match on wording somebody else\n * writes, which is the shape of check that stops working without failing.\n */\nexport function sourceForMessage(message: string): LogEntry[\"source\"] {\n // The level emoji comes first on several call sites (`⚠️ [API] …`), so the\n // prefix is whatever is inside the first bracket, wherever that is.\n const bracketed = message.match(/\\[([^\\]]{1,32})\\]/);\n const candidate = bracketed?.[1] ?? message;\n for (const [pattern, source] of SOURCE_BY_PREFIX) {\n if (pattern.test(candidate)) return source;\n }\n return \"system\";\n}\n\n/**\n * Feed the Logs Explorer everything the server says at warn and above.\n *\n * The ring used to be filled by `logMiddleware` alone, so the panel showed a\n * wall of `GET /api/data/posts 200 4ms` and not one of the errors, warnings or\n * boot diagnoses being written to stdout at the same moment. A function that\n * threw was the sharpest case: the request entry said `500` and the reason\n * existed only in a terminal the person looking at the panel does not have.\n *\n * Warn and above, deliberately. `info` is where the steady-state chatter lives,\n * and a 10,000-entry ring filled with it evicts the lines somebody opened the\n * panel to find.\n *\n * Idempotent: called from `createLogsRoutes`, which a split deployment may\n * reach more than once.\n */\nlet detachLoggerTee: (() => void) | undefined;\nexport function teeLoggerIntoLogBuffer(): () => void {\n if (detachLoggerTee) return detachLoggerTee;\n const detach = addLogSink((level, message, data) => {\n if (level !== \"warn\" && level !== \"error\") return;\n // `requestLogger` writes this one to stdout for every request, and\n // `logMiddleware` has already recorded the same request here with the\n // fields this panel renders. Teeing it too would double every failure.\n if (message === \"request\") return;\n addLog(level, sourceForMessage(message), message, Object.keys(data).length > 0 ? data : undefined);\n });\n detachLoggerTee = () => {\n detach();\n detachLoggerTee = undefined;\n };\n return detachLoggerTee;\n}\n\n/**\n * How long entries accumulate before a batch goes out.\n *\n * Not zero, and that is the point. A busy server logs faster than a browser can\n * render, and one SSE frame per line would hand the client a re-render per\n * request served — worse than the 3s poll this replaces, precisely when the logs\n * are worth watching. Coalescing keeps the frame rate bounded by the window\n * rather than by traffic, and 250ms still reads as \"live\" to a person.\n */\nconst STREAM_FLUSH_MS = 250;\n\n/**\n * Idle gap after which the stream sends a comment line.\n *\n * A silent SSE connection is indistinguishable from a dead one to everything in\n * between — proxies, load balancers and laptop NICs all reap idle sockets, and a\n * server with nothing to say is the normal state here.\n */\nconst STREAM_HEARTBEAT_MS = 25_000;\n\n/**\n * Entries a single connection will hold between flushes.\n *\n * This is a *rate* ceiling, not just a memory bound, and that is easy to get\n * wrong: nothing drains `pending` between flushes, so the most a connection can\n * carry losslessly is `maxPending` per `flushMs` — here 2000 per 250ms, or 8000\n * entries a second. Above that the oldest pending entries go and the client is\n * told how many, whatever speed it is reading at.\n *\n * It was 500, which put that ceiling at 2000/s. A healthy reader on a loopback\n * socket lost 85% of a 20k burst to it — the cap fired on the server's own\n * coalescing window rather than on any slowness at the client, which is a drop\n * notice that says nothing true about why. 8000/s is past what one Node process\n * serves, so reaching it now means genuinely more log than a person can be shown.\n *\n * The memory this bounds is the copy a *stalled* reader causes: roughly 2000\n * entries, a few MB, per stuck connection.\n */\nconst STREAM_MAX_PENDING = 2000;\n\n/**\n * The largest window any of these routes will hand back.\n *\n * The ring buffer holds 10,000 entries, so asking for more than all of it is a\n * mistake worth naming rather than silently clamping — and a clamped answer is\n * indistinguishable from \"that is all there is\".\n */\nconst LOG_WINDOW_MAX = 10_000;\n\n/**\n * The stream's timings, injectable only so they can be tested.\n *\n * The defaults above are the contract and nothing in production passes this. A\n * heartbeat is a 25-second wait to observe, and a suite that cannot observe it is\n * a suite where the keepalive can rot — which surfaces as \"the tail dies after a\n * few minutes behind the load balancer\", months later, on someone else's cluster.\n */\nexport interface LogStreamTiming {\n flushMs?: number;\n heartbeatMs?: number;\n maxPending?: number;\n}\n\nexport function createLogsRoutes(timing: LogStreamTiming = {}): Hono<HonoEnv> {\n // Here rather than at module load: the ring exists whether or not anything\n // reads it, but there is no reason to fill it on a process that serves no\n // logs surface. Idempotent, so the repeated calls a split deployment makes\n // do not stack up sinks.\n teeLoggerIntoLogBuffer();\n\n const flushMs = timing.flushMs ?? STREAM_FLUSH_MS;\n const heartbeatMs = timing.heartbeatMs ?? STREAM_HEARTBEAT_MS;\n const maxPending = timing.maxPending ?? STREAM_MAX_PENDING;\n\n const app = new Hono<HonoEnv>();\n // Its own, like every other router here: nothing registers one on the host\n // app — not `boot.ts`, not the scaffolded backend, not the eject template —\n // so a router that throws without this answers Hono's default 500 in plain\n // text, outside the `{ error: { code, message } }` envelope the rest of the\n // API keeps to.\n app.onError(errorHandler);\n\n /**\n * A window into the ring buffer, or a 400 saying why not.\n *\n * `parseInt` was the whole of it before, and every malformed value failed\n * differently and silently: `?limit=abc` fell through to the default,\n * `?limit=-5` sliced an empty window and answered 200 with no entries, and\n * `?count=abc` made `slice(-NaN)` return the *entire* buffer. Three ways to\n * be wrong, none of them visible to the caller. The data plane refuses the\n * same input with a 400 — see `resolveListLimitParam`.\n */\n const window = (raw: string | undefined, what: string, max: number): number | undefined => {\n if (raw === undefined || raw.trim() === \"\") return undefined;\n const parsed = Number(raw.trim());\n if (!Number.isInteger(parsed) || parsed < 1 || parsed > max) {\n throw new ApiError(\n 400,\n \"INVALID_PARAM\",\n `Invalid \\`${what}\\`: ${raw}. Expected a whole number between 1 and ${max}.`,\n undefined,\n true\n );\n }\n return parsed;\n };\n\n // GET /api/logs — Query logs\n app.get(\"/\", (c) => {\n const query = c.req.query();\n const result = logBuffer.query({\n level: query.level,\n source: query.source,\n search: query.search,\n limit: window(query.limit, \"limit\", LOG_WINDOW_MAX),\n offset: window(query.offset, \"offset\", Number.MAX_SAFE_INTEGER),\n since: query.since\n });\n return c.json(result);\n });\n\n // GET /api/logs/latest — Get latest logs (for real-time)\n app.get(\"/latest\", (c) => {\n const count = window(c.req.query(\"count\"), \"count\", LOG_WINDOW_MAX) ?? 50;\n return c.json({ entries: logBuffer.getLatest(count) });\n });\n\n // GET /api/logs/stream — tail the buffer over SSE.\n //\n // The Logs Explorer used to poll this router every 3 seconds, which cost a\n // request per client per 3s to say \"nothing happened\" and still showed each\n // line up to 3s late. Here the buffer pushes instead, so an idle server is an\n // idle socket.\n //\n // Events:\n // snapshot {entries, total} the filtered window, oldest-first, at open\n // append {entries, dropped} entries since the last frame, oldest-first\n // `: ping` comment, keepalive only\n //\n // Snapshot and appends come down the same connection deliberately. A client\n // that fetched its backlog separately would race the subscription — entries\n // logged between the two calls belong to neither — and closing that race from\n // the outside needs an id cursor and dedupe on every frame.\n app.get(\"/stream\", (c) => {\n const query = c.req.query();\n const filter = normalizeFilter({\n level: query.level,\n source: query.source,\n search: query.search\n });\n const limit = window(query.limit, \"limit\", LOG_WINDOW_MAX) ?? 200;\n\n // Reverse proxies buffer text responses by default, which turns a live\n // tail into nothing at all until the buffer fills. nginx (and the ingress\n // in front of the managed runtime) reads this header; everything else\n // ignores it. The rest of the SSE headers are set by `streamSSE`.\n c.header(\"X-Accel-Buffering\", \"no\");\n\n return streamSSE(c, async (stream) => {\n let pending: LogEntry[] = [];\n let dropped = 0;\n\n // Subscribe *before* reading the backlog, with nothing awaited\n // between the two. Both are synchronous, so the two halves meet\n // exactly: an entry logged after the query but before the\n // subscription would otherwise be in neither, and that gap is the one\n // thing this route exists to close.\n const unsubscribe = logBuffer.subscribe(entry => {\n if (!matchesFilter(entry, filter)) return;\n if (pending.length >= maxPending) {\n pending.shift();\n dropped++;\n }\n pending.push(entry);\n });\n const snapshot = logBuffer.query({ ...filter,\n limit });\n\n // A client that goes away has to end this handler, or the\n // subscription outlives the socket. `streamSSE` only wires the\n // request signal through on old Bun, so do it here and let\n // `stream.aborted` be the one condition the loop tests.\n //\n // The `aborted` check is not belt-and-braces. A listener added to an\n // already-aborted signal is never called, so a client that leaves\n // during the snapshot write — a fast navigation, or a reconnect storm\n // against a restarting server — would leave this handler with no way\n // to learn it had gone: a subscriber and a flush loop, per attempt,\n // for the life of the process.\n const abortOnDisconnect = () => {\n if (!stream.closed) stream.abort();\n };\n c.req.raw.signal.addEventListener(\"abort\", abortOnDisconnect, { once: true });\n if (c.req.raw.signal.aborted) abortOnDisconnect();\n\n try {\n await stream.writeSSE({\n event: \"snapshot\",\n // The view tails like a terminal; both frames are oldest-first\n // so the client only ever appends.\n data: JSON.stringify({\n entries: snapshot.entries.slice().reverse(),\n total: snapshot.total\n })\n });\n\n let idleMs = 0;\n while (!stream.aborted && !stream.closed) {\n await stream.sleep(flushMs);\n if (stream.aborted || stream.closed) break;\n\n if (pending.length === 0) {\n idleMs += flushMs;\n if (idleMs >= heartbeatMs) {\n await stream.write(\": ping\\n\\n\");\n idleMs = 0;\n }\n continue;\n }\n\n const entries = pending;\n const lost = dropped;\n pending = [];\n dropped = 0;\n idleMs = 0;\n await stream.writeSSE({\n event: \"append\",\n data: JSON.stringify(lost > 0 ? { entries,\n dropped: lost } : { entries })\n });\n }\n } finally {\n unsubscribe();\n c.req.raw.signal.removeEventListener(\"abort\", abortOnDisconnect);\n }\n });\n });\n\n return app;\n}\n\nexport default createLogsRoutes();\n"],"mappings":";;;;;;;;;;;;;;;;;;;AAiCA,SAAS,gBAAgB,SAA6C;CAClE,OAAO;EAAE,GAAG;EACR,QAAQ,QAAQ,QAAQ,YAAY;CAAE;AAC9C;;;;;;;;AASA,SAAS,cAAc,OAAiB,QAAmC;CACvE,IAAI,OAAO,SAAS,MAAM,UAAU,OAAO,OAAO,OAAO;CACzD,IAAI,OAAO,UAAU,MAAM,WAAW,OAAO,QAAQ,OAAO;CAC5D,IAAI,OAAO,UAAU,CAAC,MAAM,QAAQ,YAAY,CAAC,CAAC,SAAS,OAAO,MAAM,GAAG,OAAO;CAClF,IAAI,OAAO,SAAS,MAAM,YAAY,OAAO,OAAO,OAAO;CAC3D,OAAO;AACX;AAKA,IAAM,gBAAN,MAAoB;CAChB,SAA6B,CAAC;CAC9B;CACA,YAAoB;CACpB,4BAAoB,IAAI,IAAiB;CAEzC,YAAY,UAAU,KAAO;EACzB,KAAK,UAAU;CACnB;CAEA,KAAK,OAAmC;EACpC,MAAM,KAAK,OAAO,EAAE,KAAK;EACzB,MAAM,SAAmB;GAAE,GAAG;GAC1B;EAAG;EACP,KAAK,OAAO,KAAK,MAAM;EACvB,IAAI,KAAK,OAAO,SAAS,KAAK,SAC1B,KAAK,OAAO,MAAM;EAKtB,KAAK,MAAM,YAAY,KAAK,WACxB,IAAI;GACA,SAAS,MAAM;EACnB,QAAQ,CAER;CAER;;;;;;;;;CAUA,UAAU,UAAmC;EACzC,KAAK,UAAU,IAAI,QAAQ;EAC3B,aAAa;GACT,KAAK,UAAU,OAAO,QAAQ;EAClC;CACJ;CAEA,MAAM,SAGqC;EACvC,MAAM,SAAS,gBAAgB,OAAO;EAItC,MAAM,SAAS,CAAC,GAHC,KAAK,OAAO,QAAO,MAAK,cAAc,GAAG,MAAM,CAG7C,CAAQ,CAAC,CAAC,QAAQ;EACrC,MAAM,QAAQ,OAAO;EACrB,MAAM,QAAQ,QAAQ,SAAS;EAC/B,MAAM,SAAS,QAAQ,UAAU;EAEjC,OAAO;GACH,SAAS,OAAO,MAAM,QAAQ,SAAS,KAAK;GAC5C;EACJ;CACJ;CAEA,UAAU,QAAQ,IAAgB;EAC9B,OAAO,KAAK,OAAO,MAAM,CAAC,KAAK,CAAC,CAAC,QAAQ;CAC7C;AACJ;AAGA,IAAa,YAAY,IAAI,cAAc;;AAG3C,SAAgB,OACZ,OACA,QACA,SACA,UACI;CACJ,UAAU,KAAK;EACX,4BAAW,IAAI,KAAK,EAAA,CAAE,YAAY;EAClC;EACA;EACA;EACA;CACJ,CAAC;AACL;;AAcA,SAAgB,cAAc,UAAgC,CAAC,GAA+B;CAC1F,MAAM,UAAU,IAAI,IAAI,QAAQ,eAAe,CAAC,CAAC;CACjD,OAAO,OAAO,GAAG,SAAS;EACtB,MAAM,QAAQ,KAAK,IAAI;EACvB,MAAM,KAAK;EACX,IAAI,QAAQ,IAAI,EAAE,IAAI,IAAI,GAAG;EAC7B,MAAM,WAAW,KAAK,IAAI,IAAI;EAC9B,MAAM,QAAQ,EAAE,IAAI,WAAW;EAI/B,MAAM,SAAS,EAAE,IAAI;EACrB,MAAM,QAA2B,UAAU,MAAM,UAAU,UAAU,MAAM,SAAS;EAIpF,MAAM,UAAU,EAAE,IAAI,cAAc;EACpC,OACI,OACA,OACA,GAAG,EAAE,IAAI,OAAO,GAAG,EAAE,IAAI,KAAK,GAAG,OAAO,GAAG,SAAS,OAC7C,UAAU,MAAM,QAAQ,KAAK,IAAI,QAAQ,YAAY,KAC5D;GACI,QAAQ,EAAE,IAAI;GACd,MAAM,EAAE,IAAI;GACZ;GACA;GACA,GAAI,SAAS,EAAE,WAAW,MAAM;GAChC,GAAI,EAAE,IAAI,YAAY,KAAK,EAAE,YAAY,EAAE,IAAI,YAAY,EAAE;GAC7D,GAAI,WAAW;IAAE,WAAW,QAAQ;IAAM,cAAc,QAAQ;GAAQ;EAC5E,CACJ;CACJ;AACJ;;;;;;;;;;AAWA,IAAM,mBAAwD;CAC1D,CAAC,qBAAqB,KAAK;CAC3B,CAAC,eAAe,MAAM;CACtB,CAAC,2BAA2B,SAAS;CACrC,CAAC,sCAAsC,UAAU;AACrD;;;;;;;AAQA,SAAgB,iBAAiB,SAAqC;CAIlE,MAAM,YADY,QAAQ,MAAM,mBACd,CAAA,GAAY,MAAM;CACpC,KAAK,MAAM,CAAC,SAAS,WAAW,kBAC5B,IAAI,QAAQ,KAAK,SAAS,GAAG,OAAO;CAExC,OAAO;AACX;;;;;;;;;;;;;;;;;AAkBA,IAAI;AACJ,SAAgB,yBAAqC;CACjD,IAAI,iBAAiB,OAAO;CAC5B,MAAM,SAAS,YAAY,OAAO,SAAS,SAAS;EAChD,IAAI,UAAU,UAAU,UAAU,SAAS;EAI3C,IAAI,YAAY,WAAW;EAC3B,OAAO,OAAO,iBAAiB,OAAO,GAAG,SAAS,OAAO,KAAK,IAAI,CAAC,CAAC,SAAS,IAAI,OAAO,KAAA,CAAS;CACrG,CAAC;CACD,wBAAwB;EACpB,OAAO;EACP,kBAAkB,KAAA;CACtB;CACA,OAAO;AACX;;;;;;;;;;AAWA,IAAM,kBAAkB;;;;;;;;AASxB,IAAM,sBAAsB;;;;;;;;;;;;;;;;;;;AAoB5B,IAAM,qBAAqB;;;;;;;;AAS3B,IAAM,iBAAiB;AAgBvB,SAAgB,iBAAiB,SAA0B,CAAC,GAAkB;CAK1E,uBAAuB;CAEvB,MAAM,UAAU,OAAO,WAAW;CAClC,MAAM,cAAc,OAAO,eAAe;CAC1C,MAAM,aAAa,OAAO,cAAc;CAExC,MAAM,MAAM,IAAI,KAAc;CAM9B,IAAI,QAAQ,YAAY;;;;;;;;;;;CAYxB,MAAM,UAAU,KAAyB,MAAc,QAAoC;EACvF,IAAI,QAAQ,KAAA,KAAa,IAAI,KAAK,MAAM,IAAI,OAAO,KAAA;EACnD,MAAM,SAAS,OAAO,IAAI,KAAK,CAAC;EAChC,IAAI,CAAC,OAAO,UAAU,MAAM,KAAK,SAAS,KAAK,SAAS,KACpD,MAAM,IAAI,SACN,KACA,iBACA,aAAa,KAAK,MAAM,IAAI,0CAA0C,IAAI,IAC1E,KAAA,GACA,IACJ;EAEJ,OAAO;CACX;CAGA,IAAI,IAAI,MAAM,MAAM;EAChB,MAAM,QAAQ,EAAE,IAAI,MAAM;EAC1B,MAAM,SAAS,UAAU,MAAM;GAC3B,OAAO,MAAM;GACb,QAAQ,MAAM;GACd,QAAQ,MAAM;GACd,OAAO,OAAO,MAAM,OAAO,SAAS,cAAc;GAClD,QAAQ,OAAO,MAAM,QAAQ,UAAU,OAAO,gBAAgB;GAC9D,OAAO,MAAM;EACjB,CAAC;EACD,OAAO,EAAE,KAAK,MAAM;CACxB,CAAC;CAGD,IAAI,IAAI,YAAY,MAAM;EACtB,MAAM,QAAQ,OAAO,EAAE,IAAI,MAAM,OAAO,GAAG,SAAS,cAAc,KAAK;EACvE,OAAO,EAAE,KAAK,EAAE,SAAS,UAAU,UAAU,KAAK,EAAE,CAAC;CACzD,CAAC;CAkBD,IAAI,IAAI,YAAY,MAAM;EACtB,MAAM,QAAQ,EAAE,IAAI,MAAM;EAC1B,MAAM,SAAS,gBAAgB;GAC3B,OAAO,MAAM;GACb,QAAQ,MAAM;GACd,QAAQ,MAAM;EAClB,CAAC;EACD,MAAM,QAAQ,OAAO,MAAM,OAAO,SAAS,cAAc,KAAK;EAM9D,EAAE,OAAO,qBAAqB,IAAI;EAElC,OAAO,UAAU,GAAG,OAAO,WAAW;GAClC,IAAI,UAAsB,CAAC;GAC3B,IAAI,UAAU;GAOd,MAAM,cAAc,UAAU,WAAU,UAAS;IAC7C,IAAI,CAAC,cAAc,OAAO,MAAM,GAAG;IACnC,IAAI,QAAQ,UAAU,YAAY;KAC9B,QAAQ,MAAM;KACd;IACJ;IACA,QAAQ,KAAK,KAAK;GACtB,CAAC;GACD,MAAM,WAAW,UAAU,MAAM;IAAE,GAAG;IAClC;GAAM,CAAC;GAaX,MAAM,0BAA0B;IAC5B,IAAI,CAAC,OAAO,QAAQ,OAAO,MAAM;GACrC;GACA,EAAE,IAAI,IAAI,OAAO,iBAAiB,SAAS,mBAAmB,EAAE,MAAM,KAAK,CAAC;GAC5E,IAAI,EAAE,IAAI,IAAI,OAAO,SAAS,kBAAkB;GAEhD,IAAI;IACA,MAAM,OAAO,SAAS;KAClB,OAAO;KAGP,MAAM,KAAK,UAAU;MACjB,SAAS,SAAS,QAAQ,MAAM,CAAC,CAAC,QAAQ;MAC1C,OAAO,SAAS;KACpB,CAAC;IACL,CAAC;IAED,IAAI,SAAS;IACb,OAAO,CAAC,OAAO,WAAW,CAAC,OAAO,QAAQ;KACtC,MAAM,OAAO,MAAM,OAAO;KAC1B,IAAI,OAAO,WAAW,OAAO,QAAQ;KAErC,IAAI,QAAQ,WAAW,GAAG;MACtB,UAAU;MACV,IAAI,UAAU,aAAa;OACvB,MAAM,OAAO,MAAM,YAAY;OAC/B,SAAS;MACb;MACA;KACJ;KAEA,MAAM,UAAU;KAChB,MAAM,OAAO;KACb,UAAU,CAAC;KACX,UAAU;KACV,SAAS;KACT,MAAM,OAAO,SAAS;MAClB,OAAO;MACP,MAAM,KAAK,UAAU,OAAO,IAAI;OAAE;OAC9B,SAAS;MAAK,IAAI,EAAE,QAAQ,CAAC;KACrC,CAAC;IACL;GACJ,UAAU;IACN,YAAY;IACZ,EAAE,IAAI,IAAI,OAAO,oBAAoB,SAAS,iBAAiB;GACnE;EACJ,CAAC;CACL,CAAC;CAED,OAAO;AACX;AAEA,IAAA,sBAAe,iBAAiB"}
1
+ {"version":3,"file":"logs-routes-3EEzPjhl.js","names":[],"sources":["../src/api/logs-routes.ts"],"sourcesContent":["import { Hono } from \"hono\";\nimport type { MiddlewareHandler } from \"hono\";\nimport { streamSSE } from \"hono/streaming\";\nimport type { HonoEnv } from \"./types\";\nimport { ApiError, errorHandler } from \"./errors\";\nimport { addLogSink } from \"../utils/logger\";\n\nexport interface LogEntry {\n id: string;\n timestamp: string;\n level: \"debug\" | \"info\" | \"warn\" | \"error\";\n source: \"api\" | \"auth\" | \"storage\" | \"realtime\" | \"system\";\n message: string;\n metadata?: Record<string, unknown>;\n}\n\n/** What a caller can narrow the log by, in either direction (query or stream). */\nexport interface LogFilterOptions {\n level?: string;\n source?: string;\n search?: string;\n since?: string;\n}\n\n/**\n * A filter with the search term already lowercased.\n *\n * The distinction matters on the stream path: `query()` lowercases once and then\n * scans, but a subscriber tests one entry at a time and would otherwise redo the\n * same `toLowerCase()` on every request the server handles.\n */\ntype NormalizedFilter = LogFilterOptions;\n\nfunction normalizeFilter(options: LogFilterOptions): NormalizedFilter {\n return { ...options,\n search: options.search?.toLowerCase() };\n}\n\n/**\n * Whether one entry belongs in a filtered view.\n *\n * Shared by the query and the stream on purpose: two copies of this would drift,\n * and the failure that produces is invisible — a tail that quietly shows a\n * different set of lines than the snapshot it started from.\n */\nfunction matchesFilter(entry: LogEntry, filter: NormalizedFilter): boolean {\n if (filter.level && entry.level !== filter.level) return false;\n if (filter.source && entry.source !== filter.source) return false;\n if (filter.search && !entry.message.toLowerCase().includes(filter.search)) return false;\n if (filter.since && entry.timestamp < filter.since) return false;\n return true;\n}\n\n/** Notified for every entry pushed, in push order. */\nexport type LogListener = (entry: LogEntry) => void;\n\nclass LogRingBuffer {\n private buffer: LogEntry[] = [];\n private maxSize: number;\n private idCounter = 0;\n private listeners = new Set<LogListener>();\n\n constructor(maxSize = 10000) {\n this.maxSize = maxSize;\n }\n\n push(entry: Omit<LogEntry, \"id\">): void {\n const id = `log_${++this.idCounter}`;\n const stored: LogEntry = { ...entry,\n id };\n this.buffer.push(stored);\n if (this.buffer.length > this.maxSize) {\n this.buffer.shift();\n }\n // This runs on the request hot path, so a listener must never be able to\n // take the request down with it: a tail that throws loses its own tail,\n // not the response the log line was describing.\n for (const listener of this.listeners) {\n try {\n listener(stored);\n } catch {\n /* a broken tail is not the request's problem */\n }\n }\n }\n\n /**\n * Follow the buffer. Returns the unsubscribe — call it, always: a listener\n * left behind holds its whole closure, and on this class that closure is a\n * pending-entry array.\n *\n * A listener must not log. It is called from inside `push`, so anything that\n * reaches `addLog` from here recurses until the stack gives out.\n */\n subscribe(listener: LogListener): () => void {\n this.listeners.add(listener);\n return () => {\n this.listeners.delete(listener);\n };\n }\n\n query(options: LogFilterOptions & {\n limit?: number;\n offset?: number;\n }): { entries: LogEntry[]; total: number } {\n const filter = normalizeFilter(options);\n const filtered = this.buffer.filter(e => matchesFilter(e, filter));\n\n // Newest first\n const sorted = [...filtered].reverse();\n const total = sorted.length;\n const limit = options.limit || 100;\n const offset = options.offset || 0;\n\n return {\n entries: sorted.slice(offset, offset + limit),\n total\n };\n }\n\n getLatest(count = 50): LogEntry[] {\n return this.buffer.slice(-count).reverse();\n }\n}\n\n// Global singleton\nexport const logBuffer = new LogRingBuffer();\n\n/** Add a log entry */\nexport function addLog(\n level: LogEntry[\"level\"],\n source: LogEntry[\"source\"],\n message: string,\n metadata?: Record<string, unknown>\n): void {\n logBuffer.push({\n timestamp: new Date().toISOString(),\n level,\n source,\n message,\n metadata\n });\n}\n\nexport interface LogMiddlewareOptions {\n /**\n * Paths this sink ignores, matched exactly against `c.req.path`.\n *\n * For requests whose only reason to exist is to read the log. Recording\n * those makes the reader the loudest thing in its own output, and on a quiet\n * server it is also the thing evicting real entries out of the ring.\n */\n ignorePaths?: string[];\n}\n\n/** Hono middleware to log API requests */\nexport function logMiddleware(options: LogMiddlewareOptions = {}): MiddlewareHandler<HonoEnv> {\n const ignored = new Set(options.ignorePaths ?? []);\n return async (c, next) => {\n const start = Date.now();\n await next();\n if (ignored.has(c.req.path)) return;\n const duration = Date.now() - start;\n const reqId = c.get(\"requestId\");\n // Every request used to be recorded at `info`, whatever it answered, so\n // the Logs Explorer's level filter could not find a single failure: a\n // 500 sat at the same level as the 200 above it, in a wall of them.\n const status = c.res.status;\n const level: LogEntry[\"level\"] = status >= 500 ? \"error\" : status >= 400 ? \"warn\" : \"info\";\n // What the error handler answered, so the failure is on the entry\n // rather than in a stdout line the panel cannot see. See\n // `HonoEnv.Variables.errorSummary`.\n const failure = c.get(\"errorSummary\");\n addLog(\n level,\n \"api\",\n `${c.req.method} ${c.req.path} ${status} ${duration}ms`\n + (failure ? ` — ${failure.code}: ${failure.message}` : \"\"),\n {\n method: c.req.method,\n path: c.req.path,\n status,\n duration,\n ...(reqId && { requestId: reqId }),\n ...(c.get(\"collection\") && { collection: c.get(\"collection\") }),\n ...(failure && { errorCode: failure.code, errorMessage: failure.message })\n }\n );\n };\n}\n\n/**\n * Prefixes the server writes at the head of a log message, and the `source`\n * each one belongs to.\n *\n * The ring's `source` is a closed set the Studio filters on, and the messages\n * carry their origin as a bracketed prefix — `[API]`, `[Auth]`, `[functions]`,\n * `[schema]`. Matching them is what makes a teed line filterable beside the\n * request entries rather than a heap under \"system\".\n */\nconst SOURCE_BY_PREFIX: Array<[RegExp, LogEntry[\"source\"]]> = [\n [/^\\[?(api|rest)\\b/i, \"api\"],\n [/^\\[?auth\\b/i, \"auth\"],\n [/^\\[?(storage|s3|gcs)\\b/i, \"storage\"],\n [/^\\[?(realtime|ws|websocket|cdc)\\b/i, \"realtime\"]\n];\n\n/**\n * The `source` for a teed line, from whatever prefix it carries.\n *\n * Exported for its test: the mapping is a string match on wording somebody else\n * writes, which is the shape of check that stops working without failing.\n */\nexport function sourceForMessage(message: string): LogEntry[\"source\"] {\n // The level emoji comes first on several call sites (`⚠️ [API] …`), so the\n // prefix is whatever is inside the first bracket, wherever that is.\n const bracketed = message.match(/\\[([^\\]]{1,32})\\]/);\n const candidate = bracketed?.[1] ?? message;\n for (const [pattern, source] of SOURCE_BY_PREFIX) {\n if (pattern.test(candidate)) return source;\n }\n return \"system\";\n}\n\n/**\n * Feed the Logs Explorer everything the server says at warn and above.\n *\n * The ring used to be filled by `logMiddleware` alone, so the panel showed a\n * wall of `GET /api/data/posts 200 4ms` and not one of the errors, warnings or\n * boot diagnoses being written to stdout at the same moment. A function that\n * threw was the sharpest case: the request entry said `500` and the reason\n * existed only in a terminal the person looking at the panel does not have.\n *\n * Warn and above, deliberately. `info` is where the steady-state chatter lives,\n * and a 10,000-entry ring filled with it evicts the lines somebody opened the\n * panel to find.\n *\n * Idempotent: called from `createLogsRoutes`, which a split deployment may\n * reach more than once.\n */\nlet detachLoggerTee: (() => void) | undefined;\nexport function teeLoggerIntoLogBuffer(): () => void {\n if (detachLoggerTee) return detachLoggerTee;\n const detach = addLogSink((level, message, data) => {\n if (level !== \"warn\" && level !== \"error\") return;\n // `requestLogger` writes this one to stdout for every request, and\n // `logMiddleware` has already recorded the same request here with the\n // fields this panel renders. Teeing it too would double every failure.\n if (message === \"request\") return;\n addLog(level, sourceForMessage(message), message, Object.keys(data).length > 0 ? data : undefined);\n });\n detachLoggerTee = () => {\n detach();\n detachLoggerTee = undefined;\n };\n return detachLoggerTee;\n}\n\n/**\n * How long entries accumulate before a batch goes out.\n *\n * Not zero, and that is the point. A busy server logs faster than a browser can\n * render, and one SSE frame per line would hand the client a re-render per\n * request served — worse than the 3s poll this replaces, precisely when the logs\n * are worth watching. Coalescing keeps the frame rate bounded by the window\n * rather than by traffic, and 250ms still reads as \"live\" to a person.\n */\nconst STREAM_FLUSH_MS = 250;\n\n/**\n * Idle gap after which the stream sends a comment line.\n *\n * A silent SSE connection is indistinguishable from a dead one to everything in\n * between — proxies, load balancers and laptop NICs all reap idle sockets, and a\n * server with nothing to say is the normal state here.\n */\nconst STREAM_HEARTBEAT_MS = 25_000;\n\n/**\n * Entries a single connection will hold between flushes.\n *\n * This is a *rate* ceiling, not just a memory bound, and that is easy to get\n * wrong: nothing drains `pending` between flushes, so the most a connection can\n * carry losslessly is `maxPending` per `flushMs` — here 2000 per 250ms, or 8000\n * entries a second. Above that the oldest pending entries go and the client is\n * told how many, whatever speed it is reading at.\n *\n * It was 500, which put that ceiling at 2000/s. A healthy reader on a loopback\n * socket lost 85% of a 20k burst to it — the cap fired on the server's own\n * coalescing window rather than on any slowness at the client, which is a drop\n * notice that says nothing true about why. 8000/s is past what one Node process\n * serves, so reaching it now means genuinely more log than a person can be shown.\n *\n * The memory this bounds is the copy a *stalled* reader causes: roughly 2000\n * entries, a few MB, per stuck connection.\n */\nconst STREAM_MAX_PENDING = 2000;\n\n/**\n * The largest window any of these routes will hand back.\n *\n * The ring buffer holds 10,000 entries, so asking for more than all of it is a\n * mistake worth naming rather than silently clamping — and a clamped answer is\n * indistinguishable from \"that is all there is\".\n */\nconst LOG_WINDOW_MAX = 10_000;\n\n/**\n * The stream's timings, injectable only so they can be tested.\n *\n * The defaults above are the contract and nothing in production passes this. A\n * heartbeat is a 25-second wait to observe, and a suite that cannot observe it is\n * a suite where the keepalive can rot — which surfaces as \"the tail dies after a\n * few minutes behind the load balancer\", months later, on someone else's cluster.\n */\nexport interface LogStreamTiming {\n flushMs?: number;\n heartbeatMs?: number;\n maxPending?: number;\n}\n\nexport function createLogsRoutes(timing: LogStreamTiming = {}): Hono<HonoEnv> {\n // Here rather than at module load: the ring exists whether or not anything\n // reads it, but there is no reason to fill it on a process that serves no\n // logs surface. Idempotent, so the repeated calls a split deployment makes\n // do not stack up sinks.\n teeLoggerIntoLogBuffer();\n\n const flushMs = timing.flushMs ?? STREAM_FLUSH_MS;\n const heartbeatMs = timing.heartbeatMs ?? STREAM_HEARTBEAT_MS;\n const maxPending = timing.maxPending ?? STREAM_MAX_PENDING;\n\n const app = new Hono<HonoEnv>();\n // Its own, like every other router here: nothing registers one on the host\n // app — not `boot.ts`, not the scaffolded backend, not the eject template —\n // so a router that throws without this answers Hono's default 500 in plain\n // text, outside the `{ error: { code, message } }` envelope the rest of the\n // API keeps to.\n app.onError(errorHandler);\n\n /**\n * A window into the ring buffer, or a 400 saying why not.\n *\n * `parseInt` was the whole of it before, and every malformed value failed\n * differently and silently: `?limit=abc` fell through to the default,\n * `?limit=-5` sliced an empty window and answered 200 with no entries, and\n * `?count=abc` made `slice(-NaN)` return the *entire* buffer. Three ways to\n * be wrong, none of them visible to the caller. The data plane refuses the\n * same input with a 400 — see `resolveListLimitParam`.\n */\n const window = (raw: string | undefined, what: string, max: number): number | undefined => {\n if (raw === undefined || raw.trim() === \"\") return undefined;\n const parsed = Number(raw.trim());\n if (!Number.isInteger(parsed) || parsed < 1 || parsed > max) {\n throw new ApiError(\n 400,\n \"INVALID_PARAM\",\n `Invalid \\`${what}\\`: ${raw}. Expected a whole number between 1 and ${max}.`,\n undefined,\n true\n );\n }\n return parsed;\n };\n\n // GET /api/logs — Query logs\n app.get(\"/\", (c) => {\n const query = c.req.query();\n const result = logBuffer.query({\n level: query.level,\n source: query.source,\n search: query.search,\n limit: window(query.limit, \"limit\", LOG_WINDOW_MAX),\n offset: window(query.offset, \"offset\", Number.MAX_SAFE_INTEGER),\n since: query.since\n });\n return c.json(result);\n });\n\n // GET /api/logs/latest — Get latest logs (for real-time)\n app.get(\"/latest\", (c) => {\n const count = window(c.req.query(\"count\"), \"count\", LOG_WINDOW_MAX) ?? 50;\n return c.json({ entries: logBuffer.getLatest(count) });\n });\n\n // GET /api/logs/stream — tail the buffer over SSE.\n //\n // The Logs Explorer used to poll this router every 3 seconds, which cost a\n // request per client per 3s to say \"nothing happened\" and still showed each\n // line up to 3s late. Here the buffer pushes instead, so an idle server is an\n // idle socket.\n //\n // Events:\n // snapshot {entries, total} the filtered window, oldest-first, at open\n // append {entries, dropped} entries since the last frame, oldest-first\n // `: ping` comment, keepalive only\n //\n // Snapshot and appends come down the same connection deliberately. A client\n // that fetched its backlog separately would race the subscription — entries\n // logged between the two calls belong to neither — and closing that race from\n // the outside needs an id cursor and dedupe on every frame.\n app.get(\"/stream\", (c) => {\n const query = c.req.query();\n const filter = normalizeFilter({\n level: query.level,\n source: query.source,\n search: query.search\n });\n const limit = window(query.limit, \"limit\", LOG_WINDOW_MAX) ?? 200;\n\n // Reverse proxies buffer text responses by default, which turns a live\n // tail into nothing at all until the buffer fills. nginx (and the ingress\n // in front of the managed runtime) reads this header; everything else\n // ignores it. The rest of the SSE headers are set by `streamSSE`.\n c.header(\"X-Accel-Buffering\", \"no\");\n\n return streamSSE(c, async (stream) => {\n let pending: LogEntry[] = [];\n let dropped = 0;\n\n // Subscribe *before* reading the backlog, with nothing awaited\n // between the two. Both are synchronous, so the two halves meet\n // exactly: an entry logged after the query but before the\n // subscription would otherwise be in neither, and that gap is the one\n // thing this route exists to close.\n const unsubscribe = logBuffer.subscribe(entry => {\n if (!matchesFilter(entry, filter)) return;\n if (pending.length >= maxPending) {\n pending.shift();\n dropped++;\n }\n pending.push(entry);\n });\n const snapshot = logBuffer.query({ ...filter,\n limit });\n\n // A client that goes away has to end this handler, or the\n // subscription outlives the socket. `streamSSE` only wires the\n // request signal through on old Bun, so do it here and let\n // `stream.aborted` be the one condition the loop tests.\n //\n // The `aborted` check is not belt-and-braces. A listener added to an\n // already-aborted signal is never called, so a client that leaves\n // during the snapshot write — a fast navigation, or a reconnect storm\n // against a restarting server — would leave this handler with no way\n // to learn it had gone: a subscriber and a flush loop, per attempt,\n // for the life of the process.\n const abortOnDisconnect = () => {\n if (!stream.closed) stream.abort();\n };\n c.req.raw.signal.addEventListener(\"abort\", abortOnDisconnect, { once: true });\n if (c.req.raw.signal.aborted) abortOnDisconnect();\n\n try {\n await stream.writeSSE({\n event: \"snapshot\",\n // The view tails like a terminal; both frames are oldest-first\n // so the client only ever appends.\n data: JSON.stringify({\n entries: snapshot.entries.slice().reverse(),\n total: snapshot.total\n })\n });\n\n let idleMs = 0;\n while (!stream.aborted && !stream.closed) {\n await stream.sleep(flushMs);\n if (stream.aborted || stream.closed) break;\n\n if (pending.length === 0) {\n idleMs += flushMs;\n if (idleMs >= heartbeatMs) {\n await stream.write(\": ping\\n\\n\");\n idleMs = 0;\n }\n continue;\n }\n\n const entries = pending;\n const lost = dropped;\n pending = [];\n dropped = 0;\n idleMs = 0;\n await stream.writeSSE({\n event: \"append\",\n data: JSON.stringify(lost > 0 ? { entries,\n dropped: lost } : { entries })\n });\n }\n } finally {\n unsubscribe();\n c.req.raw.signal.removeEventListener(\"abort\", abortOnDisconnect);\n }\n });\n });\n\n return app;\n}\n\nexport default createLogsRoutes();\n"],"mappings":"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"}
@@ -2,7 +2,7 @@ import { createRequire as __rebaseCreateRequire } from "module";
2
2
  import __rebaseProcess from "process";
3
3
  globalThis.process ??= __rebaseProcess;
4
4
  __rebaseCreateRequire(import.meta.url);
5
- import { B as findRelation, E as effectiveAccess, H as isRelationRequired, L as getTenantConfig, U as resolveCollectionRelations, ct as isToMany, z as fieldKeyForColumn } from "./src-DW1cQ_28.js";
5
+ import { B as findRelation, E as effectiveAccess, H as isRelationRequired, L as getTenantConfig, U as resolveCollectionRelations, ct as isToMany, z as fieldKeyForColumn } from "./src-Caz7nREj.js";
6
6
  import "./src-Br6ARbs6.js";
7
7
  //#region src/api/openapi-generator.ts
8
8
  function generateOpenApiSpec(collections, options = {}) {
@@ -1426,4 +1426,4 @@ function toPascalCase(str) {
1426
1426
  //#endregion
1427
1427
  export { generateOpenApiSpec };
1428
1428
 
1429
- //# sourceMappingURL=openapi-generator-CAFKF36E.js.map
1429
+ //# sourceMappingURL=openapi-generator-BDpoKZQ9.js.map