@rebasepro/server 0.25.0-canary.ge06257e → 0.25.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.
- package/dist/{GCSStorageController-CAKa_EFW.js → GCSStorageController-CRd9bFrc.js} +3 -3
- package/dist/{GCSStorageController-CAKa_EFW.js.map → GCSStorageController-CRd9bFrc.js.map} +1 -1
- package/dist/{S3StorageController-Kt-LJpf3.js → S3StorageController-D00EFa5P.js} +3 -3
- package/dist/{S3StorageController-Kt-LJpf3.js.map → S3StorageController-D00EFa5P.js.map} +1 -1
- package/dist/api/closed-database.d.ts +25 -0
- package/dist/api/errors.d.ts +6 -10
- package/dist/{auth-xk37vrHO.js → auth-Dkvo2tTJ.js} +43 -35
- package/dist/auth-Dkvo2tTJ.js.map +1 -0
- package/dist/backend/boot-announcements.d.ts +13 -0
- package/dist/backend/create-backend.d.ts +4 -0
- package/dist/{backup-CpaJWXSc.js → backup-B9m3cb3X.js} +7 -7
- package/dist/{backup-CpaJWXSc.js.map → backup-B9m3cb3X.js.map} +1 -1
- package/dist/boot/boot.d.ts +10 -0
- package/dist/{contract-routes-DiFwiLg2.js → contract-routes-CsFXW7j4.js} +2 -2
- package/dist/{contract-routes-DiFwiLg2.js.map → contract-routes-CsFXW7j4.js.map} +1 -1
- package/dist/{cron-loader-NHqRfKF0.js → cron-loader-Dj6lA4HQ.js} +2 -2
- package/dist/{cron-loader-NHqRfKF0.js.map → cron-loader-Dj6lA4HQ.js.map} +1 -1
- package/dist/{cron-routes-CPIhdasM.js → cron-routes-CwbLZguO.js} +5 -5
- package/dist/{cron-routes-CPIhdasM.js.map → cron-routes-CwbLZguO.js.map} +1 -1
- package/dist/{cron-scheduler-D1DT0nw5.js → cron-scheduler-B04ACoUE.js} +2 -2
- package/dist/{cron-scheduler-D1DT0nw5.js.map → cron-scheduler-B04ACoUE.js.map} +1 -1
- package/dist/{cron-store-DEpVstUU.js → cron-store-DkyfuLOH.js} +3 -3
- package/dist/{cron-store-DEpVstUU.js.map → cron-store-DkyfuLOH.js.map} +1 -1
- package/dist/{ddl-bootstrap-BNqi2NZe.js → ddl-bootstrap-Ck20GSIX.js} +2 -2
- package/dist/{ddl-bootstrap-BNqi2NZe.js.map → ddl-bootstrap-Ck20GSIX.js.map} +1 -1
- package/dist/{errors-CHSJYpoy.js → errors-OMWkKSH-.js} +76 -22
- package/dist/errors-OMWkKSH-.js.map +1 -0
- package/dist/{function-loader-DbCSoNJj.js → function-loader-KKDqTcIr.js} +2 -2
- package/dist/{function-loader-DbCSoNJj.js.map → function-loader-KKDqTcIr.js.map} +1 -1
- package/dist/{function-routes-C0emkHtX.js → function-routes-RxrCHNuM.js} +2 -2
- package/dist/{function-routes-C0emkHtX.js.map → function-routes-RxrCHNuM.js.map} +1 -1
- package/dist/functions/index.js +18 -1
- package/dist/functions/index.js.map +1 -1
- package/dist/{history-recorder-RZ1Ow4YY.js → history-recorder-B2rkuhuy.js} +2 -2
- package/dist/{history-recorder-RZ1Ow4YY.js.map → history-recorder-B2rkuhuy.js.map} +1 -1
- package/dist/index.es.js +632 -129
- package/dist/index.es.js.map +1 -1
- package/dist/{jobs-8G-_agld.js → jobs-h1WRq3cp.js} +3 -3
- package/dist/{jobs-8G-_agld.js.map → jobs-h1WRq3cp.js.map} +1 -1
- package/dist/{jwt-YhciR93H.js → jwt-D3oFauZI.js} +2 -2
- package/dist/{jwt-YhciR93H.js.map → jwt-D3oFauZI.js.map} +1 -1
- package/dist/{logger-Bt32sHFw.js → logger-CW8pmTS6.js} +35 -3
- package/dist/logger-CW8pmTS6.js.map +1 -0
- package/dist/{logs-routes-BM2cDory.js → logs-routes-CBt7mMIL.js} +3 -3
- package/dist/{logs-routes-BM2cDory.js.map → logs-routes-CBt7mMIL.js.map} +1 -1
- package/dist/{openapi-generator-IKwDAQGO.js → openapi-generator-BjHr6upl.js} +2 -2
- package/dist/{openapi-generator-IKwDAQGO.js.map → openapi-generator-BjHr6upl.js.map} +1 -1
- package/dist/{proxy-BEKbsil6.js → proxy-CH8Jsovg.js} +2 -2
- package/dist/{proxy-BEKbsil6.js.map → proxy-CH8Jsovg.js.map} +1 -1
- package/dist/{query-parser-BnhugNwZ.js → query-parser-otFZqJsC.js} +3 -3
- package/dist/{query-parser-BnhugNwZ.js.map → query-parser-otFZqJsC.js.map} +1 -1
- package/dist/{request-timeout-LiGpCCP1.js → request-timeout-59crS-9u.js} +3 -3
- package/dist/{request-timeout-LiGpCCP1.js.map → request-timeout-59crS-9u.js.map} +1 -1
- package/dist/{schema-editor-routes-zjVKreVQ.js → schema-editor-routes-Bsut0bCC.js} +2 -2
- package/dist/{schema-editor-routes-zjVKreVQ.js.map → schema-editor-routes-Bsut0bCC.js.map} +1 -1
- package/dist/{soft-delete-params-uHxJoETb.js → soft-delete-params-BmlVwecC.js} +2 -2
- package/dist/{soft-delete-params-uHxJoETb.js.map → soft-delete-params-BmlVwecC.js.map} +1 -1
- package/dist/{sql-export-routes-D86u7N9i.js → sql-export-routes-DmB_uZ4l.js} +3 -3
- package/dist/{sql-export-routes-D86u7N9i.js.map → sql-export-routes-DmB_uZ4l.js.map} +1 -1
- package/dist/{upload-validation-pXcDy_EQ.js → upload-validation-Bgo626uY.js} +2 -2
- package/dist/{upload-validation-pXcDy_EQ.js.map → upload-validation-Bgo626uY.js.map} +1 -1
- package/dist/utils/dev-dependency-command.d.ts +15 -0
- package/dist/utils/dev-port.d.ts +8 -0
- package/dist/utils/logger.d.ts +24 -0
- package/package.json +5 -5
- package/dist/auth-xk37vrHO.js.map +0 -1
- package/dist/errors-CHSJYpoy.js.map +0 -1
- package/dist/logger-Bt32sHFw.js.map +0 -1
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** One backend's announcements. See the module documentation. */
|
|
2
|
+
export interface BootAnnouncements {
|
|
3
|
+
/** Run `fn` as this backend: info lines logged inside it, and in everything it awaits, are this backend's. */
|
|
4
|
+
run<T>(fn: () => T): T;
|
|
5
|
+
/** The build returned. Until {@link serve}, this backend is a library. */
|
|
6
|
+
built(): void;
|
|
7
|
+
/** The build threw: write what it held. */
|
|
8
|
+
failed(): void;
|
|
9
|
+
/** The backend serves: write what the build held, and hold nothing from now on. Idempotent. */
|
|
10
|
+
serve(): void;
|
|
11
|
+
}
|
|
12
|
+
/** Announcements for one backend about to be built. */
|
|
13
|
+
export declare function createBootAnnouncements(): BootAnnouncements;
|
|
@@ -272,6 +272,10 @@ export declare function wrapDatabaseAdapter(dbAdapter: DatabaseAdapter): Backend
|
|
|
272
272
|
* which {@link setDefaultBackend} sets and `initializeRebaseBackend` sets for
|
|
273
273
|
* its backend. The one process-wide setting it touches is the log level, and
|
|
274
274
|
* only when `logging.level` is given: the logger is the process's.
|
|
275
|
+
*
|
|
276
|
+
* What the build logs at info is written when the backend first serves — see
|
|
277
|
+
* `boot-announcements.ts` — so a script that builds one and never mounts it
|
|
278
|
+
* prints nothing of the boot's on its stdout.
|
|
275
279
|
*/
|
|
276
280
|
export declare function createRebaseBackend(options: RebaseBackendOptions): Promise<RebaseBackend>;
|
|
277
281
|
/**
|
|
@@ -4,12 +4,12 @@ globalThis.process ??= __rebaseProcess;
|
|
|
4
4
|
__rebaseCreateRequire(import.meta.url);
|
|
5
5
|
import { n as __exportAll } from "./rolldown-runtime-dW7B1o5h.js";
|
|
6
6
|
import "./src-D5Y3N-S4.js";
|
|
7
|
-
import { a as parseEnvBoolean, r as isAlreadyExecutingSkip } from "./cron-scheduler-
|
|
8
|
-
import {
|
|
9
|
-
import { i as errorHandler, t as ApiError } from "./errors-
|
|
10
|
-
import { m as jwtOf } from "./jwt-
|
|
11
|
-
import { t as S3StorageController } from "./S3StorageController-
|
|
12
|
-
import { t as GCSStorageController } from "./GCSStorageController-
|
|
7
|
+
import { a as parseEnvBoolean, r as isAlreadyExecutingSkip } from "./cron-scheduler-B04ACoUE.js";
|
|
8
|
+
import { a as logger } from "./logger-CW8pmTS6.js";
|
|
9
|
+
import { i as errorHandler, t as ApiError } from "./errors-OMWkKSH-.js";
|
|
10
|
+
import { m as jwtOf } from "./jwt-D3oFauZI.js";
|
|
11
|
+
import { t as S3StorageController } from "./S3StorageController-D00EFa5P.js";
|
|
12
|
+
import { t as GCSStorageController } from "./GCSStorageController-CRd9bFrc.js";
|
|
13
13
|
import { Hono } from "hono";
|
|
14
14
|
import fs from "fs";
|
|
15
15
|
import path from "path";
|
|
@@ -582,4 +582,4 @@ var backup_exports = /* @__PURE__ */ __exportAll({
|
|
|
582
582
|
//#endregion
|
|
583
583
|
export { listBackupObjects as a, readBackupBytes as c, backupStorageFromEnv as i, createBackupLinkRoutes as n, parseBackupDestination as o, createBackupRoutes as r, parseBackupTimestamp as s, backup_exports as t };
|
|
584
584
|
|
|
585
|
-
//# sourceMappingURL=backup-
|
|
585
|
+
//# sourceMappingURL=backup-B9m3cb3X.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"backup-CpaJWXSc.js","names":[],"sources":["../src/backup/backup-common.ts","../src/backup/backup-storage.ts","../src/backup/backup-routes.ts","../src/backup/backup-schedule.ts","../src/backup/index.ts"],"sourcesContent":["/**\n * Storage-generic backup helpers used by the admin backup routes.\n *\n * These live in server (which owns {@link StorageController}) so the\n * admin API can list backups without depending on the Postgres driver —\n * `server` must not import from `server-postgres`. The `pg_dump`-side\n * copies of the tiny pure parsers live in `@rebasepro/server-postgres`;\n * keep the two in sync if the naming scheme ever changes.\n */\nimport fs from \"fs\";\nimport path from \"path\";\nimport type { BackupInfo, BackupDestinationKind } from \"@rebasepro/types\";\nimport type { StorageController } from \"../storage\";\nimport { logger } from \"../utils/logger\";\n\nexport type BackupDestination =\n | { kind: \"local\"; path: string }\n | { kind: \"s3\"; bucket: string; prefix: string }\n | { kind: \"gcs\"; bucket: string; prefix: string };\n\n/** Parse a destination string (`s3://…`, `gs://…`, or a local path). */\nexport function parseBackupDestination(out: string): BackupDestination {\n const s3 = out.match(/^s3:\\/\\/([^/]+)\\/?(.*)$/);\n if (s3) return { kind: \"s3\", bucket: s3[1], prefix: s3[2].replace(/\\/+$/, \"\") };\n const gcs = out.match(/^gs:\\/\\/([^/]+)\\/?(.*)$/);\n if (gcs) return { kind: \"gcs\", bucket: gcs[1], prefix: gcs[2].replace(/\\/+$/, \"\") };\n return { kind: \"local\", path: out };\n}\n\n/**\n * Recover the UTC creation time encoded in a `rebase-<db>-<ts>.dump` name.\n * Returns `null` for names that don't match, so foreign objects are ignored.\n */\nexport function parseBackupTimestamp(fileName: string): Date | null {\n const base = fileName.split(\"/\").pop() ?? fileName;\n const match = base.match(/-(\\d{8})T(\\d{6})Z\\.dump$/);\n if (!match) return null;\n const [, ymd, hms] = match;\n const iso =\n `${ymd.slice(0, 4)}-${ymd.slice(4, 6)}-${ymd.slice(6, 8)}` +\n `T${hms.slice(0, 2)}:${hms.slice(2, 4)}:${hms.slice(4, 6)}Z`;\n const date = new Date(iso);\n return Number.isNaN(date.getTime()) ? null : date;\n}\n\n/**\n * A backup is two files: the `.dump`, and beside it a `.globals.sql` holding\n * the cluster roles the dump's GRANTs and RLS policies name. Without that\n * sidecar a restore into a fresh cluster stops at the first GRANT to a role\n * that does not exist there. The CLI uploads, restores and prunes the two as\n * a pair. This listing used to know only the `.dump`, so a backup downloaded\n * from Studio arrived without its roles.\n *\n * The suffix mirrors `globalsFileForDump` in `@rebasepro/server-postgres`.\n */\nconst DUMP_SUFFIX = \".dump\";\nconst GLOBALS_SUFFIX = \".globals.sql\";\n\nfunction globalsKeyFor(dumpKey: string): string {\n return dumpKey.slice(0, -DUMP_SUFFIX.length) + GLOBALS_SUFFIX;\n}\n\n/** The only files the download route will serve. */\nfunction isBackupFile(key: string): boolean {\n return key.endsWith(DUMP_SUFFIX) || key.endsWith(GLOBALS_SUFFIX);\n}\n\n/**\n * How many pages of an object-storage listing to read before giving up — at\n * the providers' 1,000 keys a page, 100,000 objects, or 50,000 backups.\n */\nconst MAX_LIST_PAGES = 100;\n\n/**\n * Every key under a prefix, across pages.\n *\n * S3 and GCS answer a listing a page at a time, in ascending key order, and a\n * backup's key is `rebase-<db>-<UTC timestamp>`. Reading one page therefore\n * returned the OLDEST thousand objects: with retention unset and an hourly\n * schedule, the newest backup the panel and `rebase db backups list` showed\n * stopped advancing after about three weeks, which reads as \"backups stopped\".\n */\nexport async function listAllObjectKeys(\n storage: StorageController,\n prefix: string,\n bucket: string\n): Promise<string[]> {\n const keys: string[] = [];\n let pageToken: string | undefined;\n for (let page = 0; page < MAX_LIST_PAGES; page++) {\n const result = await storage.listObjects(prefix, { bucket, maxResults: 1000, pageToken });\n for (const item of result.items) keys.push(item.fullPath);\n pageToken = result.nextPageToken;\n if (!pageToken) return keys;\n }\n logger.warn(\n `[backups] Stopped listing ${bucket}/${prefix} after ${MAX_LIST_PAGES} pages (${keys.length} objects). ` +\n \"Set BACKUP_RETENTION_DAYS so old backups are pruned, or give the backups a prefix of their own.\"\n );\n return keys;\n}\n\n/**\n * Whether an object key is one the backup routes may serve: a backup file, at\n * the destination's prefix and nowhere else.\n *\n * The local branch has always been held to the backup directory; this branch\n * checked only the suffix, so any `.dump` in the bucket — `uploads/x.dump`, a\n * neighbouring `nightlyish/` prefix — was readable by naming it. A key is taken\n * as written, so a scheme (`s3://other-bucket/…`, which S3StorageController\n * would follow) or a `.`/`..`/empty segment (which a filesystem-backed or\n * S3-compatible store may resolve) is refused rather than interpreted.\n */\nfunction isObjectBackupKey(dest: { prefix: string }, key: string): boolean {\n if (!isBackupFile(key) || /^[a-z][a-z0-9+.-]*:\\/\\//i.test(key)) return false;\n const segments = key.split(\"/\");\n if (segments.some((segment) => segment === \"\" || segment === \".\" || segment === \"..\")) return false;\n return dest.prefix === \"\" || key.startsWith(`${dest.prefix}/`);\n}\n\n/**\n * List the backups at a destination as {@link BackupInfo}, newest first.\n * One entry per `.dump`, with its `.globals.sql` sidecar attached as\n * `globalsKey` when there is one.\n */\nexport async function listBackupObjects(\n dest: BackupDestination,\n storage?: StorageController\n): Promise<BackupInfo[]> {\n if (dest.kind === \"local\") {\n // Asynchronous throughout, a few stats at a time. This runs on the\n // Backups panel's request path, and a year of hourly backups is 8,760\n // dumps: stat'ed synchronously, the server answered nothing else\n // while it listed them.\n const stat = await fs.promises.stat(dest.path).catch(() => null);\n if (!stat) return [];\n const dir = stat.isDirectory() ? dest.path : path.dirname(dest.path);\n const files = await fs.promises.readdir(dir);\n const present = new Set(files);\n const dumps = files.filter((f) => f.endsWith(DUMP_SUFFIX));\n const stats = await mapBounded(dumps, LOCAL_STAT_CONCURRENCY, (f) => fs.promises.stat(path.join(dir, f)));\n return dumps\n .map((f, i): BackupInfo => {\n const full = path.join(dir, f);\n const st = stats[i];\n const createdAt = parseBackupTimestamp(f) ?? st.mtime;\n const globals = globalsKeyFor(f);\n return {\n key: full,\n name: f,\n sizeBytes: st.size,\n createdAt: createdAt.toISOString(),\n destinationKind: \"local\",\n ...(present.has(globals) ? { globalsKey: path.join(dir, globals) } : {})\n };\n })\n .sort(byNewest);\n }\n\n // An empty list is a claim about the bucket. Without a controller for it\n // nothing was read, and the panel said \"No backups yet.\" about a bucket\n // full of them.\n if (!storage) throw new Error(noControllerFor(dest));\n const keys = await listAllObjectKeys(storage, dest.prefix ? `${dest.prefix}/` : \"\", dest.bucket);\n const present = new Set(keys);\n return keys\n .filter((key) => key.endsWith(DUMP_SUFFIX))\n .map((key): BackupInfo => {\n const createdAt = parseBackupTimestamp(key);\n const globals = globalsKeyFor(key);\n return {\n key,\n name: key.split(\"/\").pop() || key,\n createdAt: createdAt ? createdAt.toISOString() : undefined,\n destinationKind: dest.kind as BackupDestinationKind,\n ...(present.has(globals) ? { globalsKey: globals } : {})\n };\n })\n .sort(byNewest);\n}\n\n/** Stats in flight at once while listing a local backup folder. */\nconst LOCAL_STAT_CONCURRENCY = 32;\n\n/** `fn` over `items`, at most `limit` at a time; results in input order. */\nasync function mapBounded<T, R>(items: readonly T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {\n const results = new Array<R>(items.length);\n let next = 0;\n const worker = async () => {\n while (next < items.length) {\n const i = next++;\n results[i] = await fn(items[i]);\n }\n };\n await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));\n return results;\n}\n\nfunction noControllerFor(dest: { kind: \"s3\" | \"gcs\"; bucket: string; prefix: string }): string {\n const scheme = dest.kind === \"s3\" ? \"s3\" : \"gs\";\n return `No storage controller for ${scheme}://${dest.bucket}${dest.prefix ? `/${dest.prefix}` : \"\"}, so its backups cannot be read.`;\n}\n\nfunction byNewest(a: BackupInfo, b: BackupInfo): number {\n const ta = a.createdAt ? Date.parse(a.createdAt) : 0;\n const tb = b.createdAt ? Date.parse(b.createdAt) : 0;\n return tb - ta;\n}\n\n/**\n * The absolute path of a local backup file, or `null` for anything the download\n * route must not serve: a path outside the backup directory, a file that is not\n * a backup, or one that does not exist.\n */\nfunction resolveLocalBackupFile(dest: { path: string }, key: string): string | null {\n const stat = fs.existsSync(dest.path) ? fs.statSync(dest.path) : null;\n const dir = stat?.isDirectory() ? dest.path : path.dirname(dest.path);\n const resolvedDir = path.resolve(dir);\n const resolved = path.resolve(key);\n // Only allow reads inside the backup directory, and only backup files.\n if (!resolved.startsWith(resolvedDir + path.sep) || !isBackupFile(resolved)) {\n return null;\n }\n return fs.existsSync(resolved) ? resolved : null;\n}\n\n/** Read a single backup's bytes. Guards against path traversal for local. */\nexport async function readBackupBytes(\n dest: BackupDestination,\n key: string,\n storage?: StorageController\n): Promise<{ bytes: Uint8Array<ArrayBuffer>; name: string } | null> {\n if (dest.kind === \"local\") {\n const resolved = resolveLocalBackupFile(dest, key);\n if (!resolved) return null;\n return { bytes: new Uint8Array(fs.readFileSync(resolved)), name: path.basename(resolved) };\n }\n\n if (!storage) throw new Error(noControllerFor(dest));\n if (!isObjectBackupKey(dest, key)) return null;\n const file = await storage.getObject(key, dest.bucket);\n if (!file) return null;\n return { bytes: new Uint8Array(await file.arrayBuffer()), name: key.split(\"/\").pop() || key };\n}\n\n/**\n * Whether `key` names a backup the download route would serve, without\n * opening it: the same guards as {@link openBackupStream}, and the file or\n * object is there. An object store without `statObject` is taken at its key —\n * reading the object to find out would be the download itself.\n */\nexport async function findBackup(\n dest: BackupDestination,\n key: string,\n storage?: StorageController\n): Promise<{ name: string } | null> {\n if (dest.kind === \"local\") {\n const resolved = resolveLocalBackupFile(dest, key);\n return resolved ? { name: path.basename(resolved) } : null;\n }\n if (!storage) throw new Error(noControllerFor(dest));\n if (!isObjectBackupKey(dest, key)) return null;\n if (storage.statObject && !(await storage.statObject(key, dest.bucket))) return null;\n return { name: key.split(\"/\").pop() || key };\n}\n\n/**\n * A single backup, opened for streaming, with its size. Same guards as\n * {@link readBackupBytes}.\n *\n * What the download route serves. A dump is the size of the database, and\n * reading it whole put all of it on the API process's heap — twice for a local\n * destination (`readFileSync`, then a copy) and on top of the controller's own\n * buffer for object storage — and a file over 2 GiB never downloaded at all:\n * `readFileSync` throws `ERR_FS_FILE_TOO_LARGE` there.\n *\n * A local file is read from disk as the response is written, and so is an\n * object through a controller with `getObjectStream` (the built-in S3 and GCS\n * ones). A custom controller without it still arrives through\n * {@link StorageController.getObject}, a buffered `File` by contract;\n * streaming that avoids the second copy.\n */\nexport async function openBackupStream(\n dest: BackupDestination,\n key: string,\n storage?: StorageController\n): Promise<{ stream: ReadableStream<Uint8Array>; size: number; name: string } | null> {\n if (dest.kind === \"local\") {\n const resolved = resolveLocalBackupFile(dest, key);\n if (!resolved) return null;\n const { size } = await fs.promises.stat(resolved);\n return { stream: fileStream(resolved), size, name: path.basename(resolved) };\n }\n\n if (!storage) throw new Error(noControllerFor(dest));\n if (!isObjectBackupKey(dest, key)) return null;\n const name = key.split(\"/\").pop() || key;\n if (storage.getObjectStream) {\n const object = await storage.getObjectStream(key, { bucket: dest.bucket });\n return object ? { stream: object.stream, size: object.size, name } : null;\n }\n const file = await storage.getObject(key, dest.bucket);\n if (!file) return null;\n return { stream: file.stream(), size: file.size, name };\n}\n\n/** A file on disk as a web stream, read a chunk at a time as the response is written. */\nfunction fileStream(filePath: string): ReadableStream<Uint8Array> {\n const file = fs.createReadStream(filePath);\n const chunks: AsyncIterator<Buffer> = file[Symbol.asyncIterator]();\n return new ReadableStream<Uint8Array>({\n async pull(controller) {\n const next = await chunks.next();\n if (next.done) controller.close();\n else controller.enqueue(next.value);\n },\n cancel() {\n file.destroy();\n }\n });\n}\n","/**\n * The storage controller that reads a backup bucket.\n *\n * A backup destination is its own bucket, named by `BACKUP_DESTINATION`, and\n * the documented backup cron writes to it through a controller it builds from\n * the `S3_*` variables (GCS: application default credentials) — as\n * `rebase db backups list` does. The admin routes have to read it the same way.\n * The app's file storage is not that controller: it is configured for the\n * uploads bucket, it is a local directory in development, and in production\n * without an uploads bucket there is none at all.\n */\nimport { parseEnvBoolean } from \"@rebasepro/types\";\nimport { GCSStorageController } from \"../storage/GCSStorageController\";\nimport { S3StorageController } from \"../storage/S3StorageController\";\nimport type { StorageController } from \"../storage/types\";\nimport { parseBackupDestination, type BackupDestination } from \"./backup-common\";\n\n/** A destination in object storage — `s3://bucket/prefix` or `gs://bucket/prefix`. */\nexport type ObjectBackupDestination = Exclude<BackupDestination, { kind: \"local\" }>;\n\n/** The destination `BACKUP_DESTINATION` names, or `null` when it is unset. */\nexport function backupDestinationFromEnv(env: Record<string, string | undefined>): BackupDestination | null {\n const out = env.BACKUP_DESTINATION?.trim();\n return out ? parseBackupDestination(out) : null;\n}\n\n/** The destination as `BACKUP_DESTINATION` spells it. */\nexport function describeBackupDestination(dest: BackupDestination): string {\n if (dest.kind === \"local\") return dest.path;\n const scheme = dest.kind === \"s3\" ? \"s3\" : \"gs\";\n return `${scheme}://${dest.bucket}${dest.prefix ? `/${dest.prefix}` : \"\"}`;\n}\n\n/**\n * A controller for an object-storage destination's bucket, built from the\n * environment the way the backup cron and `rebase db backups list` build\n * theirs. Throws when an S3 destination has no credentials to read it with.\n */\nexport function backupStorageFromEnv(\n dest: ObjectBackupDestination,\n env: Record<string, string | undefined>\n): StorageController {\n if (dest.kind === \"gcs\") {\n return new GCSStorageController({ type: \"gcs\", bucket: dest.bucket });\n }\n // s3 also covers R2, MinIO and the other S3-compatible stores, through S3_ENDPOINT.\n if (!env.S3_ACCESS_KEY_ID || !env.S3_SECRET_ACCESS_KEY) {\n throw new Error(\n \"An S3 backup destination needs S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY in the environment \" +\n \"— the same S3_* variables the backup cron uses.\"\n );\n }\n return new S3StorageController({\n type: \"s3\",\n bucket: dest.bucket,\n region: env.S3_REGION || \"auto\",\n accessKeyId: env.S3_ACCESS_KEY_ID,\n secretAccessKey: env.S3_SECRET_ACCESS_KEY,\n endpoint: env.S3_ENDPOINT,\n // Unset stays unset, so the controller decides from the endpoint.\n forcePathStyle: parseEnvBoolean(env.S3_FORCE_PATH_STYLE)\n });\n}\n","import { Hono, type Context } from \"hono\";\nimport type { BackupDownloadLink, BackupListing, BackupScheduleStatus } from \"@rebasepro/types\";\nimport type { HonoEnv } from \"../api/types\";\nimport { ApiError, errorHandler } from \"../api/errors\";\nimport { jwtOf, type JwtService } from \"../auth/jwt\";\nimport type { StorageController } from \"../storage/types\";\nimport { BackupDestination, findBackup, listBackupObjects, openBackupStream } from \"./backup-common\";\nimport { backupStorageFromEnv, describeBackupDestination, type ObjectBackupDestination } from \"./backup-storage\";\n\nexport interface BackupRoutesConfig {\n /**\n * Resolve the current backup destination, or `null` when backups are not\n * configured (`BACKUP_DESTINATION` unset). Read lazily so a restart isn't\n * required to pick up config.\n */\n getDestination: () => BackupDestination | null;\n /**\n * The controller that reads an object-storage destination. It has to\n * address the destination's own bucket — never the app's file storage,\n * which is another bucket, a local directory, or nothing. Defaults to\n * {@link backupStorageFromEnv} over `process.env`, which is how the backup\n * cron builds the one it writes with.\n */\n storageFor?: (dest: ObjectBackupDestination) => StorageController;\n /**\n * The scheduled backup job and its last run — see `readBackupSchedule`.\n * Omitted where this process has no cron scheduler; the listing then\n * answers `schedule: null`.\n */\n getSchedule?: () => Promise<BackupScheduleStatus | null>;\n /**\n * True when this process does not run the scheduled jobs (the `api` role\n * of a split deployment). A local destination read here is then this\n * process's disk, not the one the scheduled backup writes to.\n */\n scheduledElsewhere?: boolean;\n}\n\n/** Why a destination could not be read, as the answer says it. */\nfunction unreadable(dest: BackupDestination, err: unknown): ApiError {\n const reason = err instanceof Error ? err.message : String(err);\n return ApiError.serviceUnavailable(`Cannot read the backups at ${describeBackupDestination(dest)}: ${reason}`);\n}\n\n// ─── Download links ──────────────────────────────────────────────────\n\n/**\n * The `purpose` of a download link's token. `verifyAccessToken` refuses\n * any token with a purpose, and the storage, MFA and MCP verifiers each demand\n * their own, so a link is never a session or any other credential, and none of\n * those is a link.\n */\nconst BACKUP_DOWNLOAD_PURPOSE = \"backup-download\";\n/** The token's audience: the one route that redeems it. */\nconst BACKUP_DOWNLOAD_AUDIENCE = \"rebase:admin/backups/download\";\n/** How long a link works. Long enough to start the download, not to pass around. */\nexport const BACKUP_DOWNLOAD_LINK_TTL_SECONDS = 60;\n\n/** What a link grants: one backup, at one destination. */\nfunction linkScope(dest: BackupDestination, key: string): string {\n return `backup:${describeBackupDestination(dest)}:${key}`;\n}\n\n/**\n * Whether `token` is a live link for `key` at `dest`, signed by `jwt` — the\n * backend answering. Never throws: anything else is a no.\n */\nasync function linkGrants(jwt: JwtService, token: string, dest: BackupDestination, key: string): Promise<boolean> {\n if (!jwt.isConfigured()) return false;\n const claims = await jwt.verifyPurposeToken<{ aud?: unknown; scope?: unknown }>(BACKUP_DOWNLOAD_PURPOSE, token)\n .catch(() => null);\n return claims?.aud === BACKUP_DOWNLOAD_AUDIENCE && claims.scope === linkScope(dest, key);\n}\n\n/** The configured destination, or the 400 every backup route answers without one. */\nfunction destinationOf(config: Pick<BackupRoutesConfig, \"getDestination\">): BackupDestination {\n const dest = config.getDestination();\n if (!dest) throw ApiError.badRequest(\"Backups are not configured (set BACKUP_DESTINATION).\");\n return dest;\n}\n\n/** {@link BackupRoutesConfig.storageFor}, with its default, for a destination that has a controller. */\nfunction storageResolver(config: Pick<BackupRoutesConfig, \"storageFor\">): (dest: BackupDestination) => StorageController | undefined {\n const storageFor = config.storageFor ?? ((dest: ObjectBackupDestination) => backupStorageFromEnv(dest, process.env));\n /** The controller for `dest`, or none for a local one. A destination it cannot read is an error, never `[]`. */\n return (dest) => {\n if (dest.kind === \"local\") return undefined;\n try {\n return storageFor(dest);\n } catch (err) {\n throw unreadable(dest, err);\n }\n };\n}\n\n/** One backup's bytes as the response, streamed: a dump is the size of the database. See `openBackupStream`. */\nasync function serveBackup(c: Context<HonoEnv>, dest: BackupDestination, key: string, storage: StorageController | undefined): Promise<Response> {\n const result = await openBackupStream(dest, key, storage);\n if (!result) {\n throw ApiError.notFound(`Backup not found: ${key}`);\n }\n c.header(\"Content-Type\", \"application/octet-stream\");\n c.header(\"Content-Length\", String(result.size));\n c.header(\"Content-Disposition\", `attachment; filename=\"${result.name}\"`);\n // A whole database, and on a link a URL that grants it: nothing in between keeps a copy.\n c.header(\"Cache-Control\", \"no-store\");\n return c.body(result.stream);\n}\n\n/**\n * `GET /download?key=…&token=…`: a download link spent in place of the bearer\n * header, for the one backup it names.\n *\n * Mount it at the backups prefix AHEAD of every gate on the admin prefix: the\n * auth adapter's own admin routers authenticate everything under `/admin`\n * before the Backups gate is reached. A request with no `token` passes\n * through untouched; one with a token is answered here — the backup, or a\n * 401 — and never falls through to the header: a bad link is a bad link.\n *\n * A browser cannot put a header on a navigation, so before links the SDK\n * fetched the dump and held it whole in the tab as a Blob. With a link the\n * browser streams it to disk.\n */\nexport function createBackupLinkRoutes(config: Pick<BackupRoutesConfig, \"getDestination\" | \"storageFor\">): Hono<HonoEnv> {\n const router = new Hono<HonoEnv>();\n router.onError(errorHandler);\n const storageOf = storageResolver(config);\n router.get(\"/download\", async (c, next) => {\n const token = c.req.query(\"token\");\n if (token === undefined) return next();\n const dest = destinationOf(config);\n const key = c.req.query(\"key\") ?? \"\";\n if (!(await linkGrants(jwtOf(c), token, dest, key))) {\n throw ApiError.unauthenticated(\n \"This download link is not valid for this backup, or it has expired. Links last \" +\n `${BACKUP_DOWNLOAD_LINK_TTL_SECONDS} seconds: ask for a new one.`\n );\n }\n return serveBackup(c, dest, key, storageOf(dest));\n });\n return router;\n}\n\n/**\n * Admin REST routes for the Backups panel.\n *\n * Routes (mounted under `/admin/backups`, admin-guarded by the caller):\n * GET / → list available backups, and the scheduled job's last run\n * GET /download → download a backup's bytes (?key=…)\n * POST /download-url → a 60-second link to one backup, `{ key }` → {@link BackupDownloadLink}\n *\n * The link is spent at `GET /download?key=…&token=…`, which\n * {@link createBackupLinkRoutes} answers ahead of the gate.\n */\nexport function createBackupRoutes(config: BackupRoutesConfig): Hono<HonoEnv> {\n const router = new Hono<HonoEnv>();\n router.onError(errorHandler);\n\n const storageOf = storageResolver(config);\n\n router.get(\"/\", async (c) => {\n // Read whether or not a destination is configured: a backup cron with\n // no BACKUP_DESTINATION fails every run, and that is exactly what the\n // panel has to be able to say.\n const schedule = config.getSchedule ? await config.getSchedule() : null;\n const dest = config.getDestination();\n if (!dest) {\n return c.json({ backups: [], destinationKind: \"local\", configured: false, schedule } satisfies BackupListing);\n }\n const backups = await listBackupObjects(dest, storageOf(dest)).catch((err: unknown) => {\n throw unreadable(dest, err);\n });\n // Only where there is a scheduled backup to write elsewhere.\n const localDiskOfThisProcess = dest.kind === \"local\" && config.scheduledElsewhere === true && schedule !== null;\n return c.json({\n backups,\n destinationKind: dest.kind,\n configured: true,\n schedule,\n ...(localDiskOfThisProcess ? { localDiskOfThisProcess } : {})\n } satisfies BackupListing);\n });\n\n router.get(\"/download\", async (c) => {\n const dest = destinationOf(config);\n const key = c.req.query(\"key\");\n if (!key) {\n throw ApiError.badRequest(\"Missing 'key' query parameter.\");\n }\n return serveBackup(c, dest, key, storageOf(dest));\n });\n\n router.post(\"/download-url\", async (c) => {\n const dest = destinationOf(config);\n const body: unknown = await c.req.json().catch(() => null);\n const key = typeof body === \"object\" && body !== null && \"key\" in body ? body.key : undefined;\n if (typeof key !== \"string\" || !key) {\n throw ApiError.badRequest(\"Send the backup's key: { \\\"key\\\": \\\"…\\\" }.\");\n }\n // The download route's own guards, so a link is never minted for a\n // key it would refuse; and the backup is there, so the browser is not\n // sent to a 404 in place of a file.\n if (!(await findBackup(dest, key, storageOf(dest)))) {\n throw ApiError.notFound(`Backup not found: ${key}`);\n }\n const jwt = jwtOf(c);\n if (!jwt.isConfigured()) {\n throw ApiError.serviceUnavailable(\n \"Download links are signed with the JWT secret, and this server has none (auth.jwtSecret). \" +\n \"Download through GET /download with the Authorization header instead.\"\n );\n }\n const token = await jwt.signPurposeToken(\n BACKUP_DOWNLOAD_PURPOSE,\n { aud: BACKUP_DOWNLOAD_AUDIENCE, scope: linkScope(dest, key) },\n BACKUP_DOWNLOAD_LINK_TTL_SECONDS\n );\n const download = new URL(\"download\", c.req.url).pathname;\n return c.json({\n url: `${download}?key=${encodeURIComponent(key)}&token=${encodeURIComponent(token)}`,\n token,\n expiresIn: BACKUP_DOWNLOAD_LINK_TTL_SECONDS\n } satisfies BackupDownloadLink);\n });\n\n return router;\n}\n","/**\n * The scheduled backup job, as the Backups panel reports it.\n *\n * A backup that fails every night has to be visible where the backups are\n * listed. Before this the panel read the destination and nothing else, so a\n * cron that could not run `pg_dump` left an empty list under \"wait for the next\n * scheduled run\" — while each failure sat in `cron_logs`, one panel away.\n */\nimport type {\n BackupRunOutcome,\n BackupScheduleStatus,\n CronJobDefinition,\n CronJobLogEntry,\n CronJobStatus,\n RejectedCronJob\n} from \"@rebasepro/types\";\nimport { isAlreadyExecutingSkip } from \"../cron/cron-scheduler\";\nimport { logger } from \"../utils/logger\";\n\n/**\n * The mark `createBackupCron` (in `@rebasepro/server-postgres`) puts on the\n * definition it returns.\n *\n * A registry symbol, so it is the same value across two installed copies of\n * either package, and neither package has to import the other: `server` must\n * not depend on `server-postgres`. The job cannot be recognised any other way —\n * its id is the cron file's name and its display name is configurable.\n */\nexport const BACKUP_CRON_MARK: unique symbol = Symbol.for(\"rebase.backupCron\");\n\n/** Whether a cron definition is the scheduled backup. */\nexport function isBackupCronDefinition(definition: CronJobDefinition): boolean {\n return Reflect.get(definition, BACKUP_CRON_MARK) === true;\n}\n\n/** The part of the cron scheduler this reads. */\nexport interface BackupScheduleSource {\n jobIdsWhere(predicate: (definition: CronJobDefinition) => boolean): string[];\n rejectedJobsWhere(predicate: (definition: CronJobDefinition) => boolean): RejectedCronJob[];\n fetchJob(id: string): Promise<CronJobStatus | undefined>;\n getJobLogsFromDb(id: string, limit?: number): Promise<CronJobLogEntry[]>;\n}\n\n/**\n * How many recent log entries to look through for the last scheduled run. A\n * skip — a slot that found the previous run still going — is logged as a\n * success with nothing done, and must not stand in for the run it skipped; nor\n * may a run someone started by hand, which is not the schedule's.\n */\nconst RECENT_RUNS = 50;\n\n/** A log entry as the panel reports a run. */\nfunction outcomeOf(entry: CronJobLogEntry): BackupRunOutcome {\n return {\n startedAt: entry.startedAt,\n finishedAt: entry.finishedAt,\n success: entry.success,\n ...(entry.success || !entry.error ? {} : { error: entry.error }),\n manual: entry.manual === true\n };\n}\n\n/**\n * The scheduled backup job and its last run, or `null` when the deployment\n * has none — registered or refused.\n */\nexport async function readBackupSchedule(source: BackupScheduleSource): Promise<BackupScheduleStatus | null> {\n const [jobId] = source.jobIdsWhere(isBackupCronDefinition);\n if (jobId === undefined) {\n // A refused job is not registered, and answered `null` here it read\n // as \"no backup cron\" under a hint to add the file the project has.\n const [refused] = source.rejectedJobsWhere(isBackupCronDefinition);\n return refused\n ? { jobId: refused.id, name: refused.name, schedule: refused.schedule, enabled: false, refused: refused.reason }\n : null;\n }\n\n const job = await source.fetchJob(jobId);\n if (!job) return null;\n // Off because its own definition says so — the documented cron file\n // exports `enabled: false` while BACKUP_SCHEDULE is unset — rather than\n // paused by someone. Resuming that in Cron Jobs would run the placeholder.\n const disabledInCode = !job.enabled &&\n source.jobIdsWhere(definition => isBackupCronDefinition(definition) && definition.enabled === false).includes(jobId);\n\n let recent: CronJobLogEntry[];\n let historyError: string | undefined;\n try {\n recent = await source.getJobLogsFromDb(jobId, RECENT_RUNS);\n } catch (err) {\n // Said, not read as \"has not run yet\". The listing itself still\n // answers: the backups at the destination are worth showing.\n logger.error(`[backups] Could not read the run history of the backup job \"${jobId}\"`, { error: err });\n recent = [];\n historyError = \"The backup job's run history could not be read. The server log has the reason.\";\n }\n const runs = recent.filter(entry => !isAlreadyExecutingSkip(entry));\n // \"Last scheduled backup\" is the schedule's: a later \"Run Now\" that worked\n // hid a failed nightly, and a failed test run read as a failed nightly.\n const lastScheduled = runs.find(entry => entry.manual !== true);\n // A run by hand since then is reported too, as what it is.\n const lastManual = runs.find(entry => entry.manual === true);\n const manualSince = lastManual && (!lastScheduled || lastManual.startedAt > lastScheduled.startedAt)\n ? lastManual\n : undefined;\n\n return {\n jobId,\n name: job.name,\n schedule: job.schedule,\n enabled: job.enabled,\n ...(disabledInCode ? { disabledInCode } : {}),\n ...(job.nextRunAt ? { nextRunAt: job.nextRunAt } : {}),\n ...(historyError ? { historyError } : {}),\n ...(lastScheduled ? { lastRun: outcomeOf(lastScheduled) } : {}),\n ...(manualSince ? { lastManualRun: outcomeOf(manualSince) } : {})\n };\n}\n","/**\n * Storage-generic backup helpers and admin routes for the Backups panel.\n * The `pg_dump`/`pg_restore` machinery lives in `@rebasepro/server-postgres`.\n */\nexport * from \"./backup-common\";\nexport { createBackupRoutes, createBackupLinkRoutes, BACKUP_DOWNLOAD_LINK_TTL_SECONDS } from \"./backup-routes\";\nexport { backupDestinationFromEnv, backupStorageFromEnv } from \"./backup-storage\";\nexport type { ObjectBackupDestination } from \"./backup-storage\";\nexport type { BackupRoutesConfig } from \"./backup-routes\";\nexport { readBackupSchedule, isBackupCronDefinition, BACKUP_CRON_MARK } from \"./backup-schedule\";\nexport type { BackupScheduleSource } from \"./backup-schedule\";\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"backup-B9m3cb3X.js","names":[],"sources":["../src/backup/backup-common.ts","../src/backup/backup-storage.ts","../src/backup/backup-routes.ts","../src/backup/backup-schedule.ts","../src/backup/index.ts"],"sourcesContent":["/**\n * Storage-generic backup helpers used by the admin backup routes.\n *\n * These live in server (which owns {@link StorageController}) so the\n * admin API can list backups without depending on the Postgres driver —\n * `server` must not import from `server-postgres`. The `pg_dump`-side\n * copies of the tiny pure parsers live in `@rebasepro/server-postgres`;\n * keep the two in sync if the naming scheme ever changes.\n */\nimport fs from \"fs\";\nimport path from \"path\";\nimport type { BackupInfo, BackupDestinationKind } from \"@rebasepro/types\";\nimport type { StorageController } from \"../storage\";\nimport { logger } from \"../utils/logger\";\n\nexport type BackupDestination =\n | { kind: \"local\"; path: string }\n | { kind: \"s3\"; bucket: string; prefix: string }\n | { kind: \"gcs\"; bucket: string; prefix: string };\n\n/** Parse a destination string (`s3://…`, `gs://…`, or a local path). */\nexport function parseBackupDestination(out: string): BackupDestination {\n const s3 = out.match(/^s3:\\/\\/([^/]+)\\/?(.*)$/);\n if (s3) return { kind: \"s3\", bucket: s3[1], prefix: s3[2].replace(/\\/+$/, \"\") };\n const gcs = out.match(/^gs:\\/\\/([^/]+)\\/?(.*)$/);\n if (gcs) return { kind: \"gcs\", bucket: gcs[1], prefix: gcs[2].replace(/\\/+$/, \"\") };\n return { kind: \"local\", path: out };\n}\n\n/**\n * Recover the UTC creation time encoded in a `rebase-<db>-<ts>.dump` name.\n * Returns `null` for names that don't match, so foreign objects are ignored.\n */\nexport function parseBackupTimestamp(fileName: string): Date | null {\n const base = fileName.split(\"/\").pop() ?? fileName;\n const match = base.match(/-(\\d{8})T(\\d{6})Z\\.dump$/);\n if (!match) return null;\n const [, ymd, hms] = match;\n const iso =\n `${ymd.slice(0, 4)}-${ymd.slice(4, 6)}-${ymd.slice(6, 8)}` +\n `T${hms.slice(0, 2)}:${hms.slice(2, 4)}:${hms.slice(4, 6)}Z`;\n const date = new Date(iso);\n return Number.isNaN(date.getTime()) ? null : date;\n}\n\n/**\n * A backup is two files: the `.dump`, and beside it a `.globals.sql` holding\n * the cluster roles the dump's GRANTs and RLS policies name. Without that\n * sidecar a restore into a fresh cluster stops at the first GRANT to a role\n * that does not exist there. The CLI uploads, restores and prunes the two as\n * a pair. This listing used to know only the `.dump`, so a backup downloaded\n * from Studio arrived without its roles.\n *\n * The suffix mirrors `globalsFileForDump` in `@rebasepro/server-postgres`.\n */\nconst DUMP_SUFFIX = \".dump\";\nconst GLOBALS_SUFFIX = \".globals.sql\";\n\nfunction globalsKeyFor(dumpKey: string): string {\n return dumpKey.slice(0, -DUMP_SUFFIX.length) + GLOBALS_SUFFIX;\n}\n\n/** The only files the download route will serve. */\nfunction isBackupFile(key: string): boolean {\n return key.endsWith(DUMP_SUFFIX) || key.endsWith(GLOBALS_SUFFIX);\n}\n\n/**\n * How many pages of an object-storage listing to read before giving up — at\n * the providers' 1,000 keys a page, 100,000 objects, or 50,000 backups.\n */\nconst MAX_LIST_PAGES = 100;\n\n/**\n * Every key under a prefix, across pages.\n *\n * S3 and GCS answer a listing a page at a time, in ascending key order, and a\n * backup's key is `rebase-<db>-<UTC timestamp>`. Reading one page therefore\n * returned the OLDEST thousand objects: with retention unset and an hourly\n * schedule, the newest backup the panel and `rebase db backups list` showed\n * stopped advancing after about three weeks, which reads as \"backups stopped\".\n */\nexport async function listAllObjectKeys(\n storage: StorageController,\n prefix: string,\n bucket: string\n): Promise<string[]> {\n const keys: string[] = [];\n let pageToken: string | undefined;\n for (let page = 0; page < MAX_LIST_PAGES; page++) {\n const result = await storage.listObjects(prefix, { bucket, maxResults: 1000, pageToken });\n for (const item of result.items) keys.push(item.fullPath);\n pageToken = result.nextPageToken;\n if (!pageToken) return keys;\n }\n logger.warn(\n `[backups] Stopped listing ${bucket}/${prefix} after ${MAX_LIST_PAGES} pages (${keys.length} objects). ` +\n \"Set BACKUP_RETENTION_DAYS so old backups are pruned, or give the backups a prefix of their own.\"\n );\n return keys;\n}\n\n/**\n * Whether an object key is one the backup routes may serve: a backup file, at\n * the destination's prefix and nowhere else.\n *\n * The local branch has always been held to the backup directory; this branch\n * checked only the suffix, so any `.dump` in the bucket — `uploads/x.dump`, a\n * neighbouring `nightlyish/` prefix — was readable by naming it. A key is taken\n * as written, so a scheme (`s3://other-bucket/…`, which S3StorageController\n * would follow) or a `.`/`..`/empty segment (which a filesystem-backed or\n * S3-compatible store may resolve) is refused rather than interpreted.\n */\nfunction isObjectBackupKey(dest: { prefix: string }, key: string): boolean {\n if (!isBackupFile(key) || /^[a-z][a-z0-9+.-]*:\\/\\//i.test(key)) return false;\n const segments = key.split(\"/\");\n if (segments.some((segment) => segment === \"\" || segment === \".\" || segment === \"..\")) return false;\n return dest.prefix === \"\" || key.startsWith(`${dest.prefix}/`);\n}\n\n/**\n * List the backups at a destination as {@link BackupInfo}, newest first.\n * One entry per `.dump`, with its `.globals.sql` sidecar attached as\n * `globalsKey` when there is one.\n */\nexport async function listBackupObjects(\n dest: BackupDestination,\n storage?: StorageController\n): Promise<BackupInfo[]> {\n if (dest.kind === \"local\") {\n // Asynchronous throughout, a few stats at a time. This runs on the\n // Backups panel's request path, and a year of hourly backups is 8,760\n // dumps: stat'ed synchronously, the server answered nothing else\n // while it listed them.\n const stat = await fs.promises.stat(dest.path).catch(() => null);\n if (!stat) return [];\n const dir = stat.isDirectory() ? dest.path : path.dirname(dest.path);\n const files = await fs.promises.readdir(dir);\n const present = new Set(files);\n const dumps = files.filter((f) => f.endsWith(DUMP_SUFFIX));\n const stats = await mapBounded(dumps, LOCAL_STAT_CONCURRENCY, (f) => fs.promises.stat(path.join(dir, f)));\n return dumps\n .map((f, i): BackupInfo => {\n const full = path.join(dir, f);\n const st = stats[i];\n const createdAt = parseBackupTimestamp(f) ?? st.mtime;\n const globals = globalsKeyFor(f);\n return {\n key: full,\n name: f,\n sizeBytes: st.size,\n createdAt: createdAt.toISOString(),\n destinationKind: \"local\",\n ...(present.has(globals) ? { globalsKey: path.join(dir, globals) } : {})\n };\n })\n .sort(byNewest);\n }\n\n // An empty list is a claim about the bucket. Without a controller for it\n // nothing was read, and the panel said \"No backups yet.\" about a bucket\n // full of them.\n if (!storage) throw new Error(noControllerFor(dest));\n const keys = await listAllObjectKeys(storage, dest.prefix ? `${dest.prefix}/` : \"\", dest.bucket);\n const present = new Set(keys);\n return keys\n .filter((key) => key.endsWith(DUMP_SUFFIX))\n .map((key): BackupInfo => {\n const createdAt = parseBackupTimestamp(key);\n const globals = globalsKeyFor(key);\n return {\n key,\n name: key.split(\"/\").pop() || key,\n createdAt: createdAt ? createdAt.toISOString() : undefined,\n destinationKind: dest.kind as BackupDestinationKind,\n ...(present.has(globals) ? { globalsKey: globals } : {})\n };\n })\n .sort(byNewest);\n}\n\n/** Stats in flight at once while listing a local backup folder. */\nconst LOCAL_STAT_CONCURRENCY = 32;\n\n/** `fn` over `items`, at most `limit` at a time; results in input order. */\nasync function mapBounded<T, R>(items: readonly T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {\n const results = new Array<R>(items.length);\n let next = 0;\n const worker = async () => {\n while (next < items.length) {\n const i = next++;\n results[i] = await fn(items[i]);\n }\n };\n await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));\n return results;\n}\n\nfunction noControllerFor(dest: { kind: \"s3\" | \"gcs\"; bucket: string; prefix: string }): string {\n const scheme = dest.kind === \"s3\" ? \"s3\" : \"gs\";\n return `No storage controller for ${scheme}://${dest.bucket}${dest.prefix ? `/${dest.prefix}` : \"\"}, so its backups cannot be read.`;\n}\n\nfunction byNewest(a: BackupInfo, b: BackupInfo): number {\n const ta = a.createdAt ? Date.parse(a.createdAt) : 0;\n const tb = b.createdAt ? Date.parse(b.createdAt) : 0;\n return tb - ta;\n}\n\n/**\n * The absolute path of a local backup file, or `null` for anything the download\n * route must not serve: a path outside the backup directory, a file that is not\n * a backup, or one that does not exist.\n */\nfunction resolveLocalBackupFile(dest: { path: string }, key: string): string | null {\n const stat = fs.existsSync(dest.path) ? fs.statSync(dest.path) : null;\n const dir = stat?.isDirectory() ? dest.path : path.dirname(dest.path);\n const resolvedDir = path.resolve(dir);\n const resolved = path.resolve(key);\n // Only allow reads inside the backup directory, and only backup files.\n if (!resolved.startsWith(resolvedDir + path.sep) || !isBackupFile(resolved)) {\n return null;\n }\n return fs.existsSync(resolved) ? resolved : null;\n}\n\n/** Read a single backup's bytes. Guards against path traversal for local. */\nexport async function readBackupBytes(\n dest: BackupDestination,\n key: string,\n storage?: StorageController\n): Promise<{ bytes: Uint8Array<ArrayBuffer>; name: string } | null> {\n if (dest.kind === \"local\") {\n const resolved = resolveLocalBackupFile(dest, key);\n if (!resolved) return null;\n return { bytes: new Uint8Array(fs.readFileSync(resolved)), name: path.basename(resolved) };\n }\n\n if (!storage) throw new Error(noControllerFor(dest));\n if (!isObjectBackupKey(dest, key)) return null;\n const file = await storage.getObject(key, dest.bucket);\n if (!file) return null;\n return { bytes: new Uint8Array(await file.arrayBuffer()), name: key.split(\"/\").pop() || key };\n}\n\n/**\n * Whether `key` names a backup the download route would serve, without\n * opening it: the same guards as {@link openBackupStream}, and the file or\n * object is there. An object store without `statObject` is taken at its key —\n * reading the object to find out would be the download itself.\n */\nexport async function findBackup(\n dest: BackupDestination,\n key: string,\n storage?: StorageController\n): Promise<{ name: string } | null> {\n if (dest.kind === \"local\") {\n const resolved = resolveLocalBackupFile(dest, key);\n return resolved ? { name: path.basename(resolved) } : null;\n }\n if (!storage) throw new Error(noControllerFor(dest));\n if (!isObjectBackupKey(dest, key)) return null;\n if (storage.statObject && !(await storage.statObject(key, dest.bucket))) return null;\n return { name: key.split(\"/\").pop() || key };\n}\n\n/**\n * A single backup, opened for streaming, with its size. Same guards as\n * {@link readBackupBytes}.\n *\n * What the download route serves. A dump is the size of the database, and\n * reading it whole put all of it on the API process's heap — twice for a local\n * destination (`readFileSync`, then a copy) and on top of the controller's own\n * buffer for object storage — and a file over 2 GiB never downloaded at all:\n * `readFileSync` throws `ERR_FS_FILE_TOO_LARGE` there.\n *\n * A local file is read from disk as the response is written, and so is an\n * object through a controller with `getObjectStream` (the built-in S3 and GCS\n * ones). A custom controller without it still arrives through\n * {@link StorageController.getObject}, a buffered `File` by contract;\n * streaming that avoids the second copy.\n */\nexport async function openBackupStream(\n dest: BackupDestination,\n key: string,\n storage?: StorageController\n): Promise<{ stream: ReadableStream<Uint8Array>; size: number; name: string } | null> {\n if (dest.kind === \"local\") {\n const resolved = resolveLocalBackupFile(dest, key);\n if (!resolved) return null;\n const { size } = await fs.promises.stat(resolved);\n return { stream: fileStream(resolved), size, name: path.basename(resolved) };\n }\n\n if (!storage) throw new Error(noControllerFor(dest));\n if (!isObjectBackupKey(dest, key)) return null;\n const name = key.split(\"/\").pop() || key;\n if (storage.getObjectStream) {\n const object = await storage.getObjectStream(key, { bucket: dest.bucket });\n return object ? { stream: object.stream, size: object.size, name } : null;\n }\n const file = await storage.getObject(key, dest.bucket);\n if (!file) return null;\n return { stream: file.stream(), size: file.size, name };\n}\n\n/** A file on disk as a web stream, read a chunk at a time as the response is written. */\nfunction fileStream(filePath: string): ReadableStream<Uint8Array> {\n const file = fs.createReadStream(filePath);\n const chunks: AsyncIterator<Buffer> = file[Symbol.asyncIterator]();\n return new ReadableStream<Uint8Array>({\n async pull(controller) {\n const next = await chunks.next();\n if (next.done) controller.close();\n else controller.enqueue(next.value);\n },\n cancel() {\n file.destroy();\n }\n });\n}\n","/**\n * The storage controller that reads a backup bucket.\n *\n * A backup destination is its own bucket, named by `BACKUP_DESTINATION`, and\n * the documented backup cron writes to it through a controller it builds from\n * the `S3_*` variables (GCS: application default credentials) — as\n * `rebase db backups list` does. The admin routes have to read it the same way.\n * The app's file storage is not that controller: it is configured for the\n * uploads bucket, it is a local directory in development, and in production\n * without an uploads bucket there is none at all.\n */\nimport { parseEnvBoolean } from \"@rebasepro/types\";\nimport { GCSStorageController } from \"../storage/GCSStorageController\";\nimport { S3StorageController } from \"../storage/S3StorageController\";\nimport type { StorageController } from \"../storage/types\";\nimport { parseBackupDestination, type BackupDestination } from \"./backup-common\";\n\n/** A destination in object storage — `s3://bucket/prefix` or `gs://bucket/prefix`. */\nexport type ObjectBackupDestination = Exclude<BackupDestination, { kind: \"local\" }>;\n\n/** The destination `BACKUP_DESTINATION` names, or `null` when it is unset. */\nexport function backupDestinationFromEnv(env: Record<string, string | undefined>): BackupDestination | null {\n const out = env.BACKUP_DESTINATION?.trim();\n return out ? parseBackupDestination(out) : null;\n}\n\n/** The destination as `BACKUP_DESTINATION` spells it. */\nexport function describeBackupDestination(dest: BackupDestination): string {\n if (dest.kind === \"local\") return dest.path;\n const scheme = dest.kind === \"s3\" ? \"s3\" : \"gs\";\n return `${scheme}://${dest.bucket}${dest.prefix ? `/${dest.prefix}` : \"\"}`;\n}\n\n/**\n * A controller for an object-storage destination's bucket, built from the\n * environment the way the backup cron and `rebase db backups list` build\n * theirs. Throws when an S3 destination has no credentials to read it with.\n */\nexport function backupStorageFromEnv(\n dest: ObjectBackupDestination,\n env: Record<string, string | undefined>\n): StorageController {\n if (dest.kind === \"gcs\") {\n return new GCSStorageController({ type: \"gcs\", bucket: dest.bucket });\n }\n // s3 also covers R2, MinIO and the other S3-compatible stores, through S3_ENDPOINT.\n if (!env.S3_ACCESS_KEY_ID || !env.S3_SECRET_ACCESS_KEY) {\n throw new Error(\n \"An S3 backup destination needs S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY in the environment \" +\n \"— the same S3_* variables the backup cron uses.\"\n );\n }\n return new S3StorageController({\n type: \"s3\",\n bucket: dest.bucket,\n region: env.S3_REGION || \"auto\",\n accessKeyId: env.S3_ACCESS_KEY_ID,\n secretAccessKey: env.S3_SECRET_ACCESS_KEY,\n endpoint: env.S3_ENDPOINT,\n // Unset stays unset, so the controller decides from the endpoint.\n forcePathStyle: parseEnvBoolean(env.S3_FORCE_PATH_STYLE)\n });\n}\n","import { Hono, type Context } from \"hono\";\nimport type { BackupDownloadLink, BackupListing, BackupScheduleStatus } from \"@rebasepro/types\";\nimport type { HonoEnv } from \"../api/types\";\nimport { ApiError, errorHandler } from \"../api/errors\";\nimport { jwtOf, type JwtService } from \"../auth/jwt\";\nimport type { StorageController } from \"../storage/types\";\nimport { BackupDestination, findBackup, listBackupObjects, openBackupStream } from \"./backup-common\";\nimport { backupStorageFromEnv, describeBackupDestination, type ObjectBackupDestination } from \"./backup-storage\";\n\nexport interface BackupRoutesConfig {\n /**\n * Resolve the current backup destination, or `null` when backups are not\n * configured (`BACKUP_DESTINATION` unset). Read lazily so a restart isn't\n * required to pick up config.\n */\n getDestination: () => BackupDestination | null;\n /**\n * The controller that reads an object-storage destination. It has to\n * address the destination's own bucket — never the app's file storage,\n * which is another bucket, a local directory, or nothing. Defaults to\n * {@link backupStorageFromEnv} over `process.env`, which is how the backup\n * cron builds the one it writes with.\n */\n storageFor?: (dest: ObjectBackupDestination) => StorageController;\n /**\n * The scheduled backup job and its last run — see `readBackupSchedule`.\n * Omitted where this process has no cron scheduler; the listing then\n * answers `schedule: null`.\n */\n getSchedule?: () => Promise<BackupScheduleStatus | null>;\n /**\n * True when this process does not run the scheduled jobs (the `api` role\n * of a split deployment). A local destination read here is then this\n * process's disk, not the one the scheduled backup writes to.\n */\n scheduledElsewhere?: boolean;\n}\n\n/** Why a destination could not be read, as the answer says it. */\nfunction unreadable(dest: BackupDestination, err: unknown): ApiError {\n const reason = err instanceof Error ? err.message : String(err);\n return ApiError.serviceUnavailable(`Cannot read the backups at ${describeBackupDestination(dest)}: ${reason}`);\n}\n\n// ─── Download links ──────────────────────────────────────────────────\n\n/**\n * The `purpose` of a download link's token. `verifyAccessToken` refuses\n * any token with a purpose, and the storage, MFA and MCP verifiers each demand\n * their own, so a link is never a session or any other credential, and none of\n * those is a link.\n */\nconst BACKUP_DOWNLOAD_PURPOSE = \"backup-download\";\n/** The token's audience: the one route that redeems it. */\nconst BACKUP_DOWNLOAD_AUDIENCE = \"rebase:admin/backups/download\";\n/** How long a link works. Long enough to start the download, not to pass around. */\nexport const BACKUP_DOWNLOAD_LINK_TTL_SECONDS = 60;\n\n/** What a link grants: one backup, at one destination. */\nfunction linkScope(dest: BackupDestination, key: string): string {\n return `backup:${describeBackupDestination(dest)}:${key}`;\n}\n\n/**\n * Whether `token` is a live link for `key` at `dest`, signed by `jwt` — the\n * backend answering. Never throws: anything else is a no.\n */\nasync function linkGrants(jwt: JwtService, token: string, dest: BackupDestination, key: string): Promise<boolean> {\n if (!jwt.isConfigured()) return false;\n const claims = await jwt.verifyPurposeToken<{ aud?: unknown; scope?: unknown }>(BACKUP_DOWNLOAD_PURPOSE, token)\n .catch(() => null);\n return claims?.aud === BACKUP_DOWNLOAD_AUDIENCE && claims.scope === linkScope(dest, key);\n}\n\n/** The configured destination, or the 400 every backup route answers without one. */\nfunction destinationOf(config: Pick<BackupRoutesConfig, \"getDestination\">): BackupDestination {\n const dest = config.getDestination();\n if (!dest) throw ApiError.badRequest(\"Backups are not configured (set BACKUP_DESTINATION).\");\n return dest;\n}\n\n/** {@link BackupRoutesConfig.storageFor}, with its default, for a destination that has a controller. */\nfunction storageResolver(config: Pick<BackupRoutesConfig, \"storageFor\">): (dest: BackupDestination) => StorageController | undefined {\n const storageFor = config.storageFor ?? ((dest: ObjectBackupDestination) => backupStorageFromEnv(dest, process.env));\n /** The controller for `dest`, or none for a local one. A destination it cannot read is an error, never `[]`. */\n return (dest) => {\n if (dest.kind === \"local\") return undefined;\n try {\n return storageFor(dest);\n } catch (err) {\n throw unreadable(dest, err);\n }\n };\n}\n\n/** One backup's bytes as the response, streamed: a dump is the size of the database. See `openBackupStream`. */\nasync function serveBackup(c: Context<HonoEnv>, dest: BackupDestination, key: string, storage: StorageController | undefined): Promise<Response> {\n const result = await openBackupStream(dest, key, storage);\n if (!result) {\n throw ApiError.notFound(`Backup not found: ${key}`);\n }\n c.header(\"Content-Type\", \"application/octet-stream\");\n c.header(\"Content-Length\", String(result.size));\n c.header(\"Content-Disposition\", `attachment; filename=\"${result.name}\"`);\n // A whole database, and on a link a URL that grants it: nothing in between keeps a copy.\n c.header(\"Cache-Control\", \"no-store\");\n return c.body(result.stream);\n}\n\n/**\n * `GET /download?key=…&token=…`: a download link spent in place of the bearer\n * header, for the one backup it names.\n *\n * Mount it at the backups prefix AHEAD of every gate on the admin prefix: the\n * auth adapter's own admin routers authenticate everything under `/admin`\n * before the Backups gate is reached. A request with no `token` passes\n * through untouched; one with a token is answered here — the backup, or a\n * 401 — and never falls through to the header: a bad link is a bad link.\n *\n * A browser cannot put a header on a navigation, so before links the SDK\n * fetched the dump and held it whole in the tab as a Blob. With a link the\n * browser streams it to disk.\n */\nexport function createBackupLinkRoutes(config: Pick<BackupRoutesConfig, \"getDestination\" | \"storageFor\">): Hono<HonoEnv> {\n const router = new Hono<HonoEnv>();\n router.onError(errorHandler);\n const storageOf = storageResolver(config);\n router.get(\"/download\", async (c, next) => {\n const token = c.req.query(\"token\");\n if (token === undefined) return next();\n const dest = destinationOf(config);\n const key = c.req.query(\"key\") ?? \"\";\n if (!(await linkGrants(jwtOf(c), token, dest, key))) {\n throw ApiError.unauthenticated(\n \"This download link is not valid for this backup, or it has expired. Links last \" +\n `${BACKUP_DOWNLOAD_LINK_TTL_SECONDS} seconds: ask for a new one.`\n );\n }\n return serveBackup(c, dest, key, storageOf(dest));\n });\n return router;\n}\n\n/**\n * Admin REST routes for the Backups panel.\n *\n * Routes (mounted under `/admin/backups`, admin-guarded by the caller):\n * GET / → list available backups, and the scheduled job's last run\n * GET /download → download a backup's bytes (?key=…)\n * POST /download-url → a 60-second link to one backup, `{ key }` → {@link BackupDownloadLink}\n *\n * The link is spent at `GET /download?key=…&token=…`, which\n * {@link createBackupLinkRoutes} answers ahead of the gate.\n */\nexport function createBackupRoutes(config: BackupRoutesConfig): Hono<HonoEnv> {\n const router = new Hono<HonoEnv>();\n router.onError(errorHandler);\n\n const storageOf = storageResolver(config);\n\n router.get(\"/\", async (c) => {\n // Read whether or not a destination is configured: a backup cron with\n // no BACKUP_DESTINATION fails every run, and that is exactly what the\n // panel has to be able to say.\n const schedule = config.getSchedule ? await config.getSchedule() : null;\n const dest = config.getDestination();\n if (!dest) {\n return c.json({ backups: [], destinationKind: \"local\", configured: false, schedule } satisfies BackupListing);\n }\n const backups = await listBackupObjects(dest, storageOf(dest)).catch((err: unknown) => {\n throw unreadable(dest, err);\n });\n // Only where there is a scheduled backup to write elsewhere.\n const localDiskOfThisProcess = dest.kind === \"local\" && config.scheduledElsewhere === true && schedule !== null;\n return c.json({\n backups,\n destinationKind: dest.kind,\n configured: true,\n schedule,\n ...(localDiskOfThisProcess ? { localDiskOfThisProcess } : {})\n } satisfies BackupListing);\n });\n\n router.get(\"/download\", async (c) => {\n const dest = destinationOf(config);\n const key = c.req.query(\"key\");\n if (!key) {\n throw ApiError.badRequest(\"Missing 'key' query parameter.\");\n }\n return serveBackup(c, dest, key, storageOf(dest));\n });\n\n router.post(\"/download-url\", async (c) => {\n const dest = destinationOf(config);\n const body: unknown = await c.req.json().catch(() => null);\n const key = typeof body === \"object\" && body !== null && \"key\" in body ? body.key : undefined;\n if (typeof key !== \"string\" || !key) {\n throw ApiError.badRequest(\"Send the backup's key: { \\\"key\\\": \\\"…\\\" }.\");\n }\n // The download route's own guards, so a link is never minted for a\n // key it would refuse; and the backup is there, so the browser is not\n // sent to a 404 in place of a file.\n if (!(await findBackup(dest, key, storageOf(dest)))) {\n throw ApiError.notFound(`Backup not found: ${key}`);\n }\n const jwt = jwtOf(c);\n if (!jwt.isConfigured()) {\n throw ApiError.serviceUnavailable(\n \"Download links are signed with the JWT secret, and this server has none (auth.jwtSecret). \" +\n \"Download through GET /download with the Authorization header instead.\"\n );\n }\n const token = await jwt.signPurposeToken(\n BACKUP_DOWNLOAD_PURPOSE,\n { aud: BACKUP_DOWNLOAD_AUDIENCE, scope: linkScope(dest, key) },\n BACKUP_DOWNLOAD_LINK_TTL_SECONDS\n );\n const download = new URL(\"download\", c.req.url).pathname;\n return c.json({\n url: `${download}?key=${encodeURIComponent(key)}&token=${encodeURIComponent(token)}`,\n token,\n expiresIn: BACKUP_DOWNLOAD_LINK_TTL_SECONDS\n } satisfies BackupDownloadLink);\n });\n\n return router;\n}\n","/**\n * The scheduled backup job, as the Backups panel reports it.\n *\n * A backup that fails every night has to be visible where the backups are\n * listed. Before this the panel read the destination and nothing else, so a\n * cron that could not run `pg_dump` left an empty list under \"wait for the next\n * scheduled run\" — while each failure sat in `cron_logs`, one panel away.\n */\nimport type {\n BackupRunOutcome,\n BackupScheduleStatus,\n CronJobDefinition,\n CronJobLogEntry,\n CronJobStatus,\n RejectedCronJob\n} from \"@rebasepro/types\";\nimport { isAlreadyExecutingSkip } from \"../cron/cron-scheduler\";\nimport { logger } from \"../utils/logger\";\n\n/**\n * The mark `createBackupCron` (in `@rebasepro/server-postgres`) puts on the\n * definition it returns.\n *\n * A registry symbol, so it is the same value across two installed copies of\n * either package, and neither package has to import the other: `server` must\n * not depend on `server-postgres`. The job cannot be recognised any other way —\n * its id is the cron file's name and its display name is configurable.\n */\nexport const BACKUP_CRON_MARK: unique symbol = Symbol.for(\"rebase.backupCron\");\n\n/** Whether a cron definition is the scheduled backup. */\nexport function isBackupCronDefinition(definition: CronJobDefinition): boolean {\n return Reflect.get(definition, BACKUP_CRON_MARK) === true;\n}\n\n/** The part of the cron scheduler this reads. */\nexport interface BackupScheduleSource {\n jobIdsWhere(predicate: (definition: CronJobDefinition) => boolean): string[];\n rejectedJobsWhere(predicate: (definition: CronJobDefinition) => boolean): RejectedCronJob[];\n fetchJob(id: string): Promise<CronJobStatus | undefined>;\n getJobLogsFromDb(id: string, limit?: number): Promise<CronJobLogEntry[]>;\n}\n\n/**\n * How many recent log entries to look through for the last scheduled run. A\n * skip — a slot that found the previous run still going — is logged as a\n * success with nothing done, and must not stand in for the run it skipped; nor\n * may a run someone started by hand, which is not the schedule's.\n */\nconst RECENT_RUNS = 50;\n\n/** A log entry as the panel reports a run. */\nfunction outcomeOf(entry: CronJobLogEntry): BackupRunOutcome {\n return {\n startedAt: entry.startedAt,\n finishedAt: entry.finishedAt,\n success: entry.success,\n ...(entry.success || !entry.error ? {} : { error: entry.error }),\n manual: entry.manual === true\n };\n}\n\n/**\n * The scheduled backup job and its last run, or `null` when the deployment\n * has none — registered or refused.\n */\nexport async function readBackupSchedule(source: BackupScheduleSource): Promise<BackupScheduleStatus | null> {\n const [jobId] = source.jobIdsWhere(isBackupCronDefinition);\n if (jobId === undefined) {\n // A refused job is not registered, and answered `null` here it read\n // as \"no backup cron\" under a hint to add the file the project has.\n const [refused] = source.rejectedJobsWhere(isBackupCronDefinition);\n return refused\n ? { jobId: refused.id, name: refused.name, schedule: refused.schedule, enabled: false, refused: refused.reason }\n : null;\n }\n\n const job = await source.fetchJob(jobId);\n if (!job) return null;\n // Off because its own definition says so — the documented cron file\n // exports `enabled: false` while BACKUP_SCHEDULE is unset — rather than\n // paused by someone. Resuming that in Cron Jobs would run the placeholder.\n const disabledInCode = !job.enabled &&\n source.jobIdsWhere(definition => isBackupCronDefinition(definition) && definition.enabled === false).includes(jobId);\n\n let recent: CronJobLogEntry[];\n let historyError: string | undefined;\n try {\n recent = await source.getJobLogsFromDb(jobId, RECENT_RUNS);\n } catch (err) {\n // Said, not read as \"has not run yet\". The listing itself still\n // answers: the backups at the destination are worth showing.\n logger.error(`[backups] Could not read the run history of the backup job \"${jobId}\"`, { error: err });\n recent = [];\n historyError = \"The backup job's run history could not be read. The server log has the reason.\";\n }\n const runs = recent.filter(entry => !isAlreadyExecutingSkip(entry));\n // \"Last scheduled backup\" is the schedule's: a later \"Run Now\" that worked\n // hid a failed nightly, and a failed test run read as a failed nightly.\n const lastScheduled = runs.find(entry => entry.manual !== true);\n // A run by hand since then is reported too, as what it is.\n const lastManual = runs.find(entry => entry.manual === true);\n const manualSince = lastManual && (!lastScheduled || lastManual.startedAt > lastScheduled.startedAt)\n ? lastManual\n : undefined;\n\n return {\n jobId,\n name: job.name,\n schedule: job.schedule,\n enabled: job.enabled,\n ...(disabledInCode ? { disabledInCode } : {}),\n ...(job.nextRunAt ? { nextRunAt: job.nextRunAt } : {}),\n ...(historyError ? { historyError } : {}),\n ...(lastScheduled ? { lastRun: outcomeOf(lastScheduled) } : {}),\n ...(manualSince ? { lastManualRun: outcomeOf(manualSince) } : {})\n };\n}\n","/**\n * Storage-generic backup helpers and admin routes for the Backups panel.\n * The `pg_dump`/`pg_restore` machinery lives in `@rebasepro/server-postgres`.\n */\nexport * from \"./backup-common\";\nexport { createBackupRoutes, createBackupLinkRoutes, BACKUP_DOWNLOAD_LINK_TTL_SECONDS } from \"./backup-routes\";\nexport { backupDestinationFromEnv, backupStorageFromEnv } from \"./backup-storage\";\nexport type { ObjectBackupDestination } from \"./backup-storage\";\nexport type { BackupRoutesConfig } from \"./backup-routes\";\nexport { readBackupSchedule, isBackupCronDefinition, BACKUP_CRON_MARK } from \"./backup-schedule\";\nexport type { BackupScheduleSource } from \"./backup-schedule\";\n"],"mappings":"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"}
|
package/dist/boot/boot.d.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { type Server } from "http";
|
|
2
2
|
import { Hono } from "hono";
|
|
3
|
+
import { type StorageSourceDefinition } from "@rebasepro/types";
|
|
3
4
|
import type { RebaseBackendInstance } from "../init.js";
|
|
4
5
|
import type { HonoEnv } from "../api/types.js";
|
|
5
6
|
import { type Logger } from "../utils/logger.js";
|
|
6
7
|
import { type RebaseBootEnv } from "./env.js";
|
|
7
8
|
import { type LoadedBundle } from "./bundle.js";
|
|
9
|
+
import { resolveStorageSources, type EnvBag } from "./sources.js";
|
|
8
10
|
import { type InitializedDataSource } from "./driver.js";
|
|
9
11
|
/** A running runtime, and the handle to stop it. */
|
|
10
12
|
export interface BootedRuntime {
|
|
@@ -163,6 +165,14 @@ export declare function reportBootFailure(err: unknown, log?: BootFailureLog, en
|
|
|
163
165
|
* the two need separate sentences: bump the *image*, not the package.json.
|
|
164
166
|
*/
|
|
165
167
|
export declare function warnOnDriverSkew(dataSources: InitializedDataSource[], runtimeVersion: string | undefined): void;
|
|
168
|
+
/**
|
|
169
|
+
* The storage step of {@link bootFromBundle}: every storage source this process
|
|
170
|
+
* serves, from the declarations and the environment.
|
|
171
|
+
*
|
|
172
|
+
* Local storage that `STORAGE_PATH` does not place keeps its files in
|
|
173
|
+
* {@link defaultStorageDirectory}.
|
|
174
|
+
*/
|
|
175
|
+
export declare function resolveBootStorage(bundle: LoadedBundle, env: EnvBag, definitions: StorageSourceDefinition[] | undefined, production: boolean): ReturnType<typeof resolveStorageSources>;
|
|
166
176
|
/**
|
|
167
177
|
* Warn about a bundle whose shape makes provisioning impossible.
|
|
168
178
|
*
|
|
@@ -5,7 +5,7 @@ __rebaseCreateRequire(import.meta.url);
|
|
|
5
5
|
import { n as __exportAll } from "./rolldown-runtime-dW7B1o5h.js";
|
|
6
6
|
import "./src-D5Y3N-S4.js";
|
|
7
7
|
import { t as SCHEMA_VERSION_HEADER } from "./project_manifest-R6y_KHms.js";
|
|
8
|
-
import {
|
|
8
|
+
import { a as logger } from "./logger-CW8pmTS6.js";
|
|
9
9
|
import { Hono } from "hono";
|
|
10
10
|
//#region ../types/src/types/collection_contract.ts
|
|
11
11
|
/** Depth limit for the walk — deep enough for real configs, finite for cyclic ones. */
|
|
@@ -274,4 +274,4 @@ function createContractRoutes(config) {
|
|
|
274
274
|
//#endregion
|
|
275
275
|
export { createContractRoutes as n, computeSchemaVersion as r, contract_routes_exports as t };
|
|
276
276
|
|
|
277
|
-
//# sourceMappingURL=contract-routes-
|
|
277
|
+
//# sourceMappingURL=contract-routes-CsFXW7j4.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"contract-routes-DiFwiLg2.js","names":[],"sources":["../../types/src/types/collection_contract.ts","../../types/src/types/schema_version.ts","../src/api/contract-routes.ts"],"sourcesContent":["import type { CollectionConfig } from \"./collections\";\n\n/**\n * Serializing collections so they survive a network hop.\n *\n * A collection definition is not plain data. Relations point at their target\n * with a *function* (`target: () => usersCollection`) so two collections can\n * reference each other without an import cycle, and collections also carry\n * callbacks, custom views and component references. `JSON.stringify` silently\n * drops every one of those, which matters because the SDK generator *calls*\n * `relation.target()` to decide whether a foreign key is a string or a number.\n * Serialize naively and remote SDK generation produces subtly wrong types\n * instead of failing — the worst possible outcome.\n *\n * So relation targets are resolved to a slug reference on the way out and\n * rebuilt into functions on the way in. Everything else that cannot cross a wire\n * is dropped deliberately: an SDK is generated from the *shape* of the data, and\n * server-side behaviour is neither useful to a client nor safe to publish.\n */\n\n/** Marker replacing a relation's `target` function in serialized form. */\nexport interface SerializedCollectionRef {\n __collectionRef: string;\n}\n\nexport function isSerializedCollectionRef(value: unknown): value is SerializedCollectionRef {\n return typeof value === \"object\"\n && value !== null\n && typeof (value as SerializedCollectionRef).__collectionRef === \"string\";\n}\n\n/** Depth limit for the walk — deep enough for real configs, finite for cyclic ones. */\nconst MAX_DEPTH = 64;\n\n/**\n * Resolve whatever a `target` thunk returns down to a collection.\n *\n * A target may be the collection, a module namespace (when the authoring file\n * used `import * as`), or a default-export wrapper. All three appear in real\n * projects, and the SDK generator already unwraps them the same way.\n */\nfunction unwrapTarget(value: unknown): CollectionConfig | undefined {\n if (!value || typeof value !== \"object\") return undefined;\n const candidate = value as { default?: unknown; __esModule?: boolean; properties?: unknown };\n if (candidate.default || candidate.__esModule) {\n const inner = candidate.default;\n if (inner && typeof inner === \"object\") return inner as CollectionConfig;\n }\n if (candidate.properties) return value as CollectionConfig;\n return undefined;\n}\n\n/** The identity a serialized reference uses. Slug first — it is the routing key. */\nfunction refFor(collection: CollectionConfig | undefined): string | undefined {\n if (!collection) return undefined;\n const withPath = collection as CollectionConfig & { path?: string };\n return collection.slug || withPath.path || collection.name;\n}\n\n/**\n * Deep-copy a value into something JSON can carry.\n *\n * `target` keys are special-cased into refs. Other functions vanish, cycles are\n * cut, and everything else is copied structurally.\n */\n/** Shared walk state: the memo, plus a count of depth-cap hits. */\ninterface WalkState {\n memo: WeakMap<object, unknown>;\n /**\n * How many times the walk has truncated a subtree — by hitting the depth\n * cap, or by cutting a cycle.\n *\n * Either kind of truncation makes a result valid only at the *position* it\n * was produced at, so caching it and serving it elsewhere silently drops\n * content that would have been included. Comparing this counter before and\n * after a node's children tells us whether its result is position-\n * independent and therefore safe to memoize.\n *\n * The cycle case is the subtle one: with `a.b = b` and `b.a = a`, serializing\n * `{ first: b, second: a }` visits `a` beneath `b` — where the cycle back to\n * `b` is cut — and would then reuse that truncated `a` for `second`, where\n * nothing needed cutting.\n */\n truncations: number;\n}\n\nfunction toSerializable(\n value: unknown,\n seen: WeakSet<object>,\n depth: number,\n state: WalkState,\n key?: string\n): unknown {\n if (depth > MAX_DEPTH) {\n state.truncations++;\n return undefined;\n }\n\n if (typeof value === \"function\") {\n // Only a relation target carries information a client needs. Calling it\n // is safe here — this runs on the server, where the target module is\n // already loaded — and a throwing target simply yields no reference,\n // which degrades the generated FK type rather than failing the request.\n if (key === \"target\") {\n try {\n const resolved = unwrapTarget((value as () => unknown)());\n const ref = refFor(resolved);\n return ref ? { __collectionRef: ref } : undefined;\n } catch {\n return undefined;\n }\n }\n return undefined;\n }\n\n if (value === null || typeof value !== \"object\") {\n return value;\n }\n\n if (value instanceof Date) return value.toISOString();\n if (value instanceof RegExp) return value.source;\n\n if (seen.has(value as object)) {\n state.truncations++;\n return undefined;\n }\n\n // A shared (non-cyclic) subgraph is reachable by many paths, and `seen` is a\n // *path* set — released in the `finally` below so a node referenced twice in\n // different branches is emitted twice rather than dropped as a false cycle.\n // Without memoization that makes the walk exponential in depth: a diamond\n // graph 20 levels deep took ~400ms, and each further level doubled it. The\n // result is a plain data tree, so handing back the same converted object for\n // a repeat visit is indistinguishable after JSON.stringify.\n const cached = state.memo.get(value as object);\n if (cached !== undefined) return cached;\n\n seen.add(value as object);\n const truncationsBefore = state.truncations;\n const memoize = (result: unknown): unknown => {\n // Only cache a result that nothing was cut from.\n if (result !== undefined && state.truncations === truncationsBefore) {\n state.memo.set(value as object, result);\n }\n return result;\n };\n\n try {\n if (Array.isArray(value)) {\n const items = value\n .map(item => toSerializable(item, seen, depth + 1, state))\n .filter(item => item !== undefined);\n // A container that had content, none of which can be represented, is\n // itself unrepresentable — see the note below.\n return memoize(value.length > 0 && items.length === 0 ? undefined : items);\n }\n\n // A React element or component reference has no meaning to a client and\n // will not survive JSON anyway.\n if (\"$$typeof\" in (value as Record<string, unknown>)) return undefined;\n\n const entries = Object.entries(value as Record<string, unknown>);\n const out: Record<string, unknown> = {};\n for (const [k, v] of entries) {\n const converted = toSerializable(v, seen, depth + 1, state, k);\n if (converted !== undefined) out[k] = converted;\n }\n\n // Drop a container whose entire content was dropped.\n //\n // `callbacks: { beforeSave() {…} }` would otherwise serialize to\n // `callbacks: {}` — an empty husk that carries no information but is not\n // *nothing*, so it lands in the payload and, worse, in the schema hash.\n // Editing a hook would then change every client's schema version and\n // report perfectly current SDKs as stale.\n //\n // A container that started empty stays empty: `properties: {}` is a\n // deliberate statement, not a casualty.\n if (entries.length > 0 && Object.keys(out).length === 0) return undefined;\n\n return memoize(out);\n } finally {\n // Released so a collection referenced twice in different branches is\n // emitted twice rather than being dropped as a false cycle.\n seen.delete(value as object);\n }\n}\n\n/**\n * Serialize collections for transport over the contract endpoint.\n *\n * Sorted by slug so the output — and therefore the schema hash computed from it\n * — does not depend on filesystem ordering.\n */\nexport function serializeCollections(collections: CollectionConfig[]): unknown[] {\n return [...collections]\n .sort((a, b) => String(a.slug ?? \"\").localeCompare(String(b.slug ?? \"\")))\n .map(collection => toSerializable(withoutAdminBlock(collection), new WeakSet(), 0, {\n memo: new WeakMap(),\n truncations: 0\n }))\n .filter((c): c is Record<string, unknown> => c !== undefined);\n}\n\n/**\n * Drop the admin block before the walk.\n *\n * Nothing downstream of serialization is an admin panel. The contract endpoint\n * feeds remote SDK generation, and `rebase build` writes the result into a bundle\n * manifest that only the backend runtime reads. The block would survive the walk\n * as a husk anyway — its React elements and component functions are dropped\n * individually — and that husk has two costs worth avoiding: it puts every custom\n * component's *file path* on an endpoint whose job is to describe data shapes, and\n * it grows a payload that is fetched and cached per project.\n *\n * Removing it here rather than at each call site means one chokepoint, so a future\n * consumer of `serializeCollections` cannot forget.\n *\n * Child collections carry their own block, so this recurses — stripping only the\n * top level was the mistake `stripNonClientFields` in the contract routes already\n * had to fix once for security rules.\n */\nfunction withoutAdminBlock(collection: CollectionConfig): CollectionConfig {\n const { admin: _admin, ...rest } = collection as CollectionConfig & Record<string, unknown>;\n const nested = rest as Record<string, unknown>;\n if (Array.isArray(nested.subcollections)) {\n nested.subcollections = nested.subcollections.map(\n (child) => withoutAdminBlock(child as CollectionConfig)\n );\n }\n return rest as CollectionConfig;\n}\n\n/**\n * Rebuild collections received from a contract endpoint.\n *\n * Relation refs become real thunks resolving through the returned set, so\n * downstream consumers — the SDK generator above all — see exactly the shape\n * they would have seen had the collections been imported from source.\n *\n * A ref naming a collection that is not in the payload resolves to `undefined`\n * rather than throwing: the generator already tolerates an unresolvable target\n * by falling back to a permissive key type, and a partial contract should still\n * produce a usable SDK.\n */\nexport function deserializeCollections(payload: unknown[]): CollectionConfig[] {\n const collections = payload\n .filter((c): c is Record<string, unknown> => typeof c === \"object\" && c !== null)\n .map(c => ({ ...c })) as unknown as CollectionConfig[];\n\n const bySlug = new Map<string, CollectionConfig>();\n for (const collection of collections) {\n const ref = refFor(collection);\n if (ref) bySlug.set(ref, collection);\n }\n\n const rehydrate = (value: unknown, depth: number): void => {\n if (depth > MAX_DEPTH || !value || typeof value !== \"object\") return;\n\n if (Array.isArray(value)) {\n for (const item of value) rehydrate(item, depth + 1);\n return;\n }\n\n const record = value as Record<string, unknown>;\n for (const [key, child] of Object.entries(record)) {\n if (key === \"target\" && isSerializedCollectionRef(child)) {\n const slug = child.__collectionRef;\n record.target = () => bySlug.get(slug);\n continue;\n }\n rehydrate(child, depth + 1);\n }\n };\n\n for (const collection of collections) rehydrate(collection, 0);\n return collections;\n}\n","import type { CollectionConfig } from \"./collections\";\nimport { serializeCollections } from \"./collection_contract\";\n\n/**\n * The schema version stamp.\n *\n * One function, used in three places that must agree or the whole drift-detection\n * story is noise: `rebase build` writes it into a bundle manifest, the runtime\n * serves it from the contract endpoint, and a generated SDK records the value it\n * was built from. If any two of those computed it differently, every client would\n * look permanently out of date.\n *\n * It covers **collections only** — the client's contract is the shape of the\n * data, so editing a hook or a server function must not invalidate every SDK in\n * every repository. That is a deliberate narrowing, not an oversight.\n */\n\n/** Stable stringify: object keys sorted at every level, so key order cannot alter the hash. */\nfunction canonicalize(value: unknown): string {\n if (value === null || typeof value !== \"object\") {\n return JSON.stringify(value) ?? \"null\";\n }\n if (Array.isArray(value)) {\n return `[${value.map(canonicalize).join(\",\")}]`;\n }\n const entries = Object.entries(value as Record<string, unknown>)\n .filter(([, v]) => v !== undefined)\n .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));\n return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalize(v)}`).join(\",\")}}`;\n}\n\n/**\n * Reduce a collection to the parts a generated client is actually built from.\n *\n * The version answers one question — \"is this SDK stale?\" — so it must change\n * exactly when the generated types could change, and never otherwise. Hashing a\n * whole collection fails both halves of that:\n *\n * - Security rules, callbacks, icons, groups and UI settings do not appear in a\n * generated client, so including them reports perfectly current SDKs as stale.\n * - Worse, they are not stable *inputs*. The runtime applies default security\n * rules when it loads collections, so the same source hashed before and after\n * loading produced two different answers — a build-time stamp that could never\n * match the server that served it.\n *\n * Codegen reads the slug (for the `Database` key and type names), the properties,\n * and the relations. That is the projection.\n */\nfunction projectForCodegen(collection: CollectionConfig): Record<string, unknown> {\n const source = collection as CollectionConfig & {\n relations?: unknown;\n subcollections?: CollectionConfig[];\n path?: string;\n engine?: unknown;\n dataSource?: unknown;\n };\n\n return {\n slug: collection.slug ?? source.path,\n properties: collection.properties,\n relations: source.relations,\n // The engine decides whether relations are resolved at all: codegen asks\n // `getDataSourceCapabilities(collection.engine).supportsRelations`, and an\n // engine that answers no drops every foreign-key column from the\n // generated Row/Insert/Update types. Moving a collection to such an\n // engine is a real change to the generated types, so it has to move the\n // version. `dataSource` is what resolves to `engine`, so it counts too.\n engine: source.engine,\n dataSource: source.dataSource,\n subcollections: source.subcollections?.map(projectForCodegen)\n };\n}\n\n/**\n * Compute the canonical string a schema version hashes.\n *\n * Exposed separately so the hashing itself can differ by environment: Node has\n * `crypto`, and callers without it can still compare canonical forms directly.\n */\nexport function canonicalSchemaPayload(collections: CollectionConfig[]): string {\n const projected = serializeCollections(collections)\n .map(collection => projectForCodegen(collection as CollectionConfig));\n return canonicalize(projected);\n}\n\n/**\n * A short, non-cryptographic digest of the canonical payload.\n *\n * FNV-1a style, 64 bits, as two 32-bit halves. This is an identity, not a\n * security boundary: nothing trusts a schema version to prove anything, it only\n * answers \"is this the same schema as before\". A hand-rolled hash keeps this\n * module free of `node:crypto`, so the identical function runs in the browser,\n * in the CLI, and in the runtime — which is the property that actually matters.\n */\nexport function computeSchemaVersion(collections: CollectionConfig[]): string {\n const payload = canonicalSchemaPayload(collections);\n\n let h1 = 0x811c9dc5;\n let h2 = 0x01000193;\n\n for (let i = 0; i < payload.length; i++) {\n const code = payload.charCodeAt(i);\n h1 ^= code;\n // Multiply by the FNV prime using shifts to stay in 32-bit integer math.\n h1 = (h1 + ((h1 << 1) + (h1 << 4) + (h1 << 7) + (h1 << 8) + (h1 << 24))) >>> 0;\n h2 ^= code + i;\n h2 = (h2 + ((h2 << 1) + (h2 << 5) + (h2 << 9) + (h2 << 15) + (h2 << 24))) >>> 0;\n }\n\n const hex = (n: number): string => n.toString(16).padStart(8, \"0\");\n return `v1:${hex(h1)}${hex(h2)}`;\n}\n","import { Hono } from \"hono\";\nimport {\n RUNTIME_CONTRACT_VERSION,\n SCHEMA_VERSION_HEADER,\n computeSchemaVersion,\n serializeCollections,\n type CollectionConfig,\n type RebaseProjectContract\n} from \"@rebasepro/types\";\nimport type { HonoEnv } from \"./types\";\nimport { logger } from \"../utils/logger\";\n\n/**\n * The project contract endpoint.\n *\n * This is what makes a repository able to build against a project it does not\n * contain. Without it, a typed client can only be generated from local\n * collection *source*, which means every frontend must live in the same\n * repository as the backend. Serving the contract turns that around: an app\n * asks the project what its shape is, so a web app, a second web app and a\n * mobile app can each live wherever they like and none of them needs to know\n * about the others.\n *\n * Admin-gated. Collection definitions describe every table, column and relation\n * in the project, including ones no security rule would ever expose — that is a\n * map of the database, not public API documentation.\n */\n\nexport interface ContractRoutesConfig {\n collectionRegistry: { getRawCollections(): CollectionConfig[] };\n /**\n * The schema version recorded at build time.\n *\n * Preferred over recomputing, so that what a client is told matches exactly\n * what the bundle claims. It is recomputed only when a bundle did not record\n * one — a `baas`-mode project derives its collections from the live database\n * at boot, so there was nothing to hash when it was built.\n */\n schemaVersion?: string;\n /** Runtime package version, surfaced so a client can report what it built against. */\n runtimeVersion?: string;\n}\n\n/**\n * Strip everything a client does not need from a serialized collection.\n *\n * The generator reads the slug, the properties and the relations. It never reads\n * a security rule — but `securityRules` carries the raw SQL of every RLS\n * predicate guarding the project, which is a description of the authorization\n * model rather than of the data shape. Publishing it to anyone who can generate\n * an SDK gives away more than the endpoint is for, so it is removed here rather\n * than trusted not to matter.\n */\nfunction stripNonClientFields(collection: unknown): unknown {\n if (!collection || typeof collection !== \"object\") return collection;\n const {\n securityRules: _securityRules,\n callbacks: _callbacks,\n ...rest\n } = collection as Record<string, unknown>;\n\n // Subcollections are collections, and carry their own rules. Stripping only\n // the top level published every nested policy — the leak this exists to\n // prevent, just one level down.\n if (Array.isArray(rest.subcollections)) {\n rest.subcollections = rest.subcollections.map(stripNonClientFields);\n }\n\n return rest;\n}\n\nexport function createContractRoutes(config: ContractRoutesConfig): Hono<HonoEnv> {\n const router = new Hono<HonoEnv>();\n\n // Computing a version walks and canonicalizes every collection, and\n // `/schema-version` is deliberately unauthenticated and meant to be polled.\n // Recomputing per request would make a CI convenience into a CPU\n // amplification anyone could aim at the server. Collections do not change\n // after boot, so once is enough.\n let cachedVersion: string | undefined;\n const schemaVersionOf = (collections: CollectionConfig[]): string => {\n if (config.schemaVersion) return config.schemaVersion;\n if (cachedVersion === undefined) cachedVersion = computeSchemaVersion(collections);\n return cachedVersion;\n };\n\n router.get(\"/contract\", (c) => {\n const collections = config.collectionRegistry.getRawCollections();\n const serialized = serializeCollections(collections).map(stripNonClientFields);\n\n // In `baas` mode the collections are whatever introspection found at\n // boot, so the version has to be computed from them rather than taken\n // from a build that never saw them.\n const schemaVersion = schemaVersionOf(collections);\n\n const contract: RebaseProjectContract = {\n schemaVersion,\n runtime: {\n version: config.runtimeVersion ?? \"unknown\",\n contract: RUNTIME_CONTRACT_VERSION\n },\n collections: serialized,\n collectionSlugs: collections\n .map(collection => collection.slug)\n .filter((slug): slug is string => Boolean(slug))\n .sort(),\n generatedAt: new Date().toISOString()\n };\n\n c.header(SCHEMA_VERSION_HEADER, schemaVersion);\n return c.json(contract);\n });\n\n /**\n * Cheap drift check.\n *\n * Deliberately unauthenticated and deliberately tiny: two version stamps\n * and nothing else. A CI job that only wants to know whether its generated\n * SDK is stale should not need admin credentials, and a version stamp\n * reveals nothing about the schema it stands for.\n *\n * `runtime` is here as well as on `/contract` because the two answer\n * different questions and only one of them was reachable. Which runtime a\n * project is on decides whether a client's wire format is understood at\n * all, and it was published solely on the admin-gated route — so a CLI or\n * an SDK, the two callers that actually need to know, could not ask. That\n * is the same shape as the header this route echoes: a documented signal\n * with no reachable sender. `contract` is the number that matters for\n * compatibility; `version` names the release a human should quote.\n */\n router.get(\"/schema-version\", (c) => {\n const schemaVersion = schemaVersionOf(config.collectionRegistry.getRawCollections());\n c.header(SCHEMA_VERSION_HEADER, schemaVersion);\n return c.json({\n schemaVersion,\n runtime: {\n version: config.runtimeVersion ?? \"unknown\",\n contract: RUNTIME_CONTRACT_VERSION\n }\n });\n });\n\n logger.debug(\"Contract routes mounted\");\n return router;\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"contract-routes-CsFXW7j4.js","names":[],"sources":["../../types/src/types/collection_contract.ts","../../types/src/types/schema_version.ts","../src/api/contract-routes.ts"],"sourcesContent":["import type { CollectionConfig } from \"./collections\";\n\n/**\n * Serializing collections so they survive a network hop.\n *\n * A collection definition is not plain data. Relations point at their target\n * with a *function* (`target: () => usersCollection`) so two collections can\n * reference each other without an import cycle, and collections also carry\n * callbacks, custom views and component references. `JSON.stringify` silently\n * drops every one of those, which matters because the SDK generator *calls*\n * `relation.target()` to decide whether a foreign key is a string or a number.\n * Serialize naively and remote SDK generation produces subtly wrong types\n * instead of failing — the worst possible outcome.\n *\n * So relation targets are resolved to a slug reference on the way out and\n * rebuilt into functions on the way in. Everything else that cannot cross a wire\n * is dropped deliberately: an SDK is generated from the *shape* of the data, and\n * server-side behaviour is neither useful to a client nor safe to publish.\n */\n\n/** Marker replacing a relation's `target` function in serialized form. */\nexport interface SerializedCollectionRef {\n __collectionRef: string;\n}\n\nexport function isSerializedCollectionRef(value: unknown): value is SerializedCollectionRef {\n return typeof value === \"object\"\n && value !== null\n && typeof (value as SerializedCollectionRef).__collectionRef === \"string\";\n}\n\n/** Depth limit for the walk — deep enough for real configs, finite for cyclic ones. */\nconst MAX_DEPTH = 64;\n\n/**\n * Resolve whatever a `target` thunk returns down to a collection.\n *\n * A target may be the collection, a module namespace (when the authoring file\n * used `import * as`), or a default-export wrapper. All three appear in real\n * projects, and the SDK generator already unwraps them the same way.\n */\nfunction unwrapTarget(value: unknown): CollectionConfig | undefined {\n if (!value || typeof value !== \"object\") return undefined;\n const candidate = value as { default?: unknown; __esModule?: boolean; properties?: unknown };\n if (candidate.default || candidate.__esModule) {\n const inner = candidate.default;\n if (inner && typeof inner === \"object\") return inner as CollectionConfig;\n }\n if (candidate.properties) return value as CollectionConfig;\n return undefined;\n}\n\n/** The identity a serialized reference uses. Slug first — it is the routing key. */\nfunction refFor(collection: CollectionConfig | undefined): string | undefined {\n if (!collection) return undefined;\n const withPath = collection as CollectionConfig & { path?: string };\n return collection.slug || withPath.path || collection.name;\n}\n\n/**\n * Deep-copy a value into something JSON can carry.\n *\n * `target` keys are special-cased into refs. Other functions vanish, cycles are\n * cut, and everything else is copied structurally.\n */\n/** Shared walk state: the memo, plus a count of depth-cap hits. */\ninterface WalkState {\n memo: WeakMap<object, unknown>;\n /**\n * How many times the walk has truncated a subtree — by hitting the depth\n * cap, or by cutting a cycle.\n *\n * Either kind of truncation makes a result valid only at the *position* it\n * was produced at, so caching it and serving it elsewhere silently drops\n * content that would have been included. Comparing this counter before and\n * after a node's children tells us whether its result is position-\n * independent and therefore safe to memoize.\n *\n * The cycle case is the subtle one: with `a.b = b` and `b.a = a`, serializing\n * `{ first: b, second: a }` visits `a` beneath `b` — where the cycle back to\n * `b` is cut — and would then reuse that truncated `a` for `second`, where\n * nothing needed cutting.\n */\n truncations: number;\n}\n\nfunction toSerializable(\n value: unknown,\n seen: WeakSet<object>,\n depth: number,\n state: WalkState,\n key?: string\n): unknown {\n if (depth > MAX_DEPTH) {\n state.truncations++;\n return undefined;\n }\n\n if (typeof value === \"function\") {\n // Only a relation target carries information a client needs. Calling it\n // is safe here — this runs on the server, where the target module is\n // already loaded — and a throwing target simply yields no reference,\n // which degrades the generated FK type rather than failing the request.\n if (key === \"target\") {\n try {\n const resolved = unwrapTarget((value as () => unknown)());\n const ref = refFor(resolved);\n return ref ? { __collectionRef: ref } : undefined;\n } catch {\n return undefined;\n }\n }\n return undefined;\n }\n\n if (value === null || typeof value !== \"object\") {\n return value;\n }\n\n if (value instanceof Date) return value.toISOString();\n if (value instanceof RegExp) return value.source;\n\n if (seen.has(value as object)) {\n state.truncations++;\n return undefined;\n }\n\n // A shared (non-cyclic) subgraph is reachable by many paths, and `seen` is a\n // *path* set — released in the `finally` below so a node referenced twice in\n // different branches is emitted twice rather than dropped as a false cycle.\n // Without memoization that makes the walk exponential in depth: a diamond\n // graph 20 levels deep took ~400ms, and each further level doubled it. The\n // result is a plain data tree, so handing back the same converted object for\n // a repeat visit is indistinguishable after JSON.stringify.\n const cached = state.memo.get(value as object);\n if (cached !== undefined) return cached;\n\n seen.add(value as object);\n const truncationsBefore = state.truncations;\n const memoize = (result: unknown): unknown => {\n // Only cache a result that nothing was cut from.\n if (result !== undefined && state.truncations === truncationsBefore) {\n state.memo.set(value as object, result);\n }\n return result;\n };\n\n try {\n if (Array.isArray(value)) {\n const items = value\n .map(item => toSerializable(item, seen, depth + 1, state))\n .filter(item => item !== undefined);\n // A container that had content, none of which can be represented, is\n // itself unrepresentable — see the note below.\n return memoize(value.length > 0 && items.length === 0 ? undefined : items);\n }\n\n // A React element or component reference has no meaning to a client and\n // will not survive JSON anyway.\n if (\"$$typeof\" in (value as Record<string, unknown>)) return undefined;\n\n const entries = Object.entries(value as Record<string, unknown>);\n const out: Record<string, unknown> = {};\n for (const [k, v] of entries) {\n const converted = toSerializable(v, seen, depth + 1, state, k);\n if (converted !== undefined) out[k] = converted;\n }\n\n // Drop a container whose entire content was dropped.\n //\n // `callbacks: { beforeSave() {…} }` would otherwise serialize to\n // `callbacks: {}` — an empty husk that carries no information but is not\n // *nothing*, so it lands in the payload and, worse, in the schema hash.\n // Editing a hook would then change every client's schema version and\n // report perfectly current SDKs as stale.\n //\n // A container that started empty stays empty: `properties: {}` is a\n // deliberate statement, not a casualty.\n if (entries.length > 0 && Object.keys(out).length === 0) return undefined;\n\n return memoize(out);\n } finally {\n // Released so a collection referenced twice in different branches is\n // emitted twice rather than being dropped as a false cycle.\n seen.delete(value as object);\n }\n}\n\n/**\n * Serialize collections for transport over the contract endpoint.\n *\n * Sorted by slug so the output — and therefore the schema hash computed from it\n * — does not depend on filesystem ordering.\n */\nexport function serializeCollections(collections: CollectionConfig[]): unknown[] {\n return [...collections]\n .sort((a, b) => String(a.slug ?? \"\").localeCompare(String(b.slug ?? \"\")))\n .map(collection => toSerializable(withoutAdminBlock(collection), new WeakSet(), 0, {\n memo: new WeakMap(),\n truncations: 0\n }))\n .filter((c): c is Record<string, unknown> => c !== undefined);\n}\n\n/**\n * Drop the admin block before the walk.\n *\n * Nothing downstream of serialization is an admin panel. The contract endpoint\n * feeds remote SDK generation, and `rebase build` writes the result into a bundle\n * manifest that only the backend runtime reads. The block would survive the walk\n * as a husk anyway — its React elements and component functions are dropped\n * individually — and that husk has two costs worth avoiding: it puts every custom\n * component's *file path* on an endpoint whose job is to describe data shapes, and\n * it grows a payload that is fetched and cached per project.\n *\n * Removing it here rather than at each call site means one chokepoint, so a future\n * consumer of `serializeCollections` cannot forget.\n *\n * Child collections carry their own block, so this recurses — stripping only the\n * top level was the mistake `stripNonClientFields` in the contract routes already\n * had to fix once for security rules.\n */\nfunction withoutAdminBlock(collection: CollectionConfig): CollectionConfig {\n const { admin: _admin, ...rest } = collection as CollectionConfig & Record<string, unknown>;\n const nested = rest as Record<string, unknown>;\n if (Array.isArray(nested.subcollections)) {\n nested.subcollections = nested.subcollections.map(\n (child) => withoutAdminBlock(child as CollectionConfig)\n );\n }\n return rest as CollectionConfig;\n}\n\n/**\n * Rebuild collections received from a contract endpoint.\n *\n * Relation refs become real thunks resolving through the returned set, so\n * downstream consumers — the SDK generator above all — see exactly the shape\n * they would have seen had the collections been imported from source.\n *\n * A ref naming a collection that is not in the payload resolves to `undefined`\n * rather than throwing: the generator already tolerates an unresolvable target\n * by falling back to a permissive key type, and a partial contract should still\n * produce a usable SDK.\n */\nexport function deserializeCollections(payload: unknown[]): CollectionConfig[] {\n const collections = payload\n .filter((c): c is Record<string, unknown> => typeof c === \"object\" && c !== null)\n .map(c => ({ ...c })) as unknown as CollectionConfig[];\n\n const bySlug = new Map<string, CollectionConfig>();\n for (const collection of collections) {\n const ref = refFor(collection);\n if (ref) bySlug.set(ref, collection);\n }\n\n const rehydrate = (value: unknown, depth: number): void => {\n if (depth > MAX_DEPTH || !value || typeof value !== \"object\") return;\n\n if (Array.isArray(value)) {\n for (const item of value) rehydrate(item, depth + 1);\n return;\n }\n\n const record = value as Record<string, unknown>;\n for (const [key, child] of Object.entries(record)) {\n if (key === \"target\" && isSerializedCollectionRef(child)) {\n const slug = child.__collectionRef;\n record.target = () => bySlug.get(slug);\n continue;\n }\n rehydrate(child, depth + 1);\n }\n };\n\n for (const collection of collections) rehydrate(collection, 0);\n return collections;\n}\n","import type { CollectionConfig } from \"./collections\";\nimport { serializeCollections } from \"./collection_contract\";\n\n/**\n * The schema version stamp.\n *\n * One function, used in three places that must agree or the whole drift-detection\n * story is noise: `rebase build` writes it into a bundle manifest, the runtime\n * serves it from the contract endpoint, and a generated SDK records the value it\n * was built from. If any two of those computed it differently, every client would\n * look permanently out of date.\n *\n * It covers **collections only** — the client's contract is the shape of the\n * data, so editing a hook or a server function must not invalidate every SDK in\n * every repository. That is a deliberate narrowing, not an oversight.\n */\n\n/** Stable stringify: object keys sorted at every level, so key order cannot alter the hash. */\nfunction canonicalize(value: unknown): string {\n if (value === null || typeof value !== \"object\") {\n return JSON.stringify(value) ?? \"null\";\n }\n if (Array.isArray(value)) {\n return `[${value.map(canonicalize).join(\",\")}]`;\n }\n const entries = Object.entries(value as Record<string, unknown>)\n .filter(([, v]) => v !== undefined)\n .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));\n return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalize(v)}`).join(\",\")}}`;\n}\n\n/**\n * Reduce a collection to the parts a generated client is actually built from.\n *\n * The version answers one question — \"is this SDK stale?\" — so it must change\n * exactly when the generated types could change, and never otherwise. Hashing a\n * whole collection fails both halves of that:\n *\n * - Security rules, callbacks, icons, groups and UI settings do not appear in a\n * generated client, so including them reports perfectly current SDKs as stale.\n * - Worse, they are not stable *inputs*. The runtime applies default security\n * rules when it loads collections, so the same source hashed before and after\n * loading produced two different answers — a build-time stamp that could never\n * match the server that served it.\n *\n * Codegen reads the slug (for the `Database` key and type names), the properties,\n * and the relations. That is the projection.\n */\nfunction projectForCodegen(collection: CollectionConfig): Record<string, unknown> {\n const source = collection as CollectionConfig & {\n relations?: unknown;\n subcollections?: CollectionConfig[];\n path?: string;\n engine?: unknown;\n dataSource?: unknown;\n };\n\n return {\n slug: collection.slug ?? source.path,\n properties: collection.properties,\n relations: source.relations,\n // The engine decides whether relations are resolved at all: codegen asks\n // `getDataSourceCapabilities(collection.engine).supportsRelations`, and an\n // engine that answers no drops every foreign-key column from the\n // generated Row/Insert/Update types. Moving a collection to such an\n // engine is a real change to the generated types, so it has to move the\n // version. `dataSource` is what resolves to `engine`, so it counts too.\n engine: source.engine,\n dataSource: source.dataSource,\n subcollections: source.subcollections?.map(projectForCodegen)\n };\n}\n\n/**\n * Compute the canonical string a schema version hashes.\n *\n * Exposed separately so the hashing itself can differ by environment: Node has\n * `crypto`, and callers without it can still compare canonical forms directly.\n */\nexport function canonicalSchemaPayload(collections: CollectionConfig[]): string {\n const projected = serializeCollections(collections)\n .map(collection => projectForCodegen(collection as CollectionConfig));\n return canonicalize(projected);\n}\n\n/**\n * A short, non-cryptographic digest of the canonical payload.\n *\n * FNV-1a style, 64 bits, as two 32-bit halves. This is an identity, not a\n * security boundary: nothing trusts a schema version to prove anything, it only\n * answers \"is this the same schema as before\". A hand-rolled hash keeps this\n * module free of `node:crypto`, so the identical function runs in the browser,\n * in the CLI, and in the runtime — which is the property that actually matters.\n */\nexport function computeSchemaVersion(collections: CollectionConfig[]): string {\n const payload = canonicalSchemaPayload(collections);\n\n let h1 = 0x811c9dc5;\n let h2 = 0x01000193;\n\n for (let i = 0; i < payload.length; i++) {\n const code = payload.charCodeAt(i);\n h1 ^= code;\n // Multiply by the FNV prime using shifts to stay in 32-bit integer math.\n h1 = (h1 + ((h1 << 1) + (h1 << 4) + (h1 << 7) + (h1 << 8) + (h1 << 24))) >>> 0;\n h2 ^= code + i;\n h2 = (h2 + ((h2 << 1) + (h2 << 5) + (h2 << 9) + (h2 << 15) + (h2 << 24))) >>> 0;\n }\n\n const hex = (n: number): string => n.toString(16).padStart(8, \"0\");\n return `v1:${hex(h1)}${hex(h2)}`;\n}\n","import { Hono } from \"hono\";\nimport {\n RUNTIME_CONTRACT_VERSION,\n SCHEMA_VERSION_HEADER,\n computeSchemaVersion,\n serializeCollections,\n type CollectionConfig,\n type RebaseProjectContract\n} from \"@rebasepro/types\";\nimport type { HonoEnv } from \"./types\";\nimport { logger } from \"../utils/logger\";\n\n/**\n * The project contract endpoint.\n *\n * This is what makes a repository able to build against a project it does not\n * contain. Without it, a typed client can only be generated from local\n * collection *source*, which means every frontend must live in the same\n * repository as the backend. Serving the contract turns that around: an app\n * asks the project what its shape is, so a web app, a second web app and a\n * mobile app can each live wherever they like and none of them needs to know\n * about the others.\n *\n * Admin-gated. Collection definitions describe every table, column and relation\n * in the project, including ones no security rule would ever expose — that is a\n * map of the database, not public API documentation.\n */\n\nexport interface ContractRoutesConfig {\n collectionRegistry: { getRawCollections(): CollectionConfig[] };\n /**\n * The schema version recorded at build time.\n *\n * Preferred over recomputing, so that what a client is told matches exactly\n * what the bundle claims. It is recomputed only when a bundle did not record\n * one — a `baas`-mode project derives its collections from the live database\n * at boot, so there was nothing to hash when it was built.\n */\n schemaVersion?: string;\n /** Runtime package version, surfaced so a client can report what it built against. */\n runtimeVersion?: string;\n}\n\n/**\n * Strip everything a client does not need from a serialized collection.\n *\n * The generator reads the slug, the properties and the relations. It never reads\n * a security rule — but `securityRules` carries the raw SQL of every RLS\n * predicate guarding the project, which is a description of the authorization\n * model rather than of the data shape. Publishing it to anyone who can generate\n * an SDK gives away more than the endpoint is for, so it is removed here rather\n * than trusted not to matter.\n */\nfunction stripNonClientFields(collection: unknown): unknown {\n if (!collection || typeof collection !== \"object\") return collection;\n const {\n securityRules: _securityRules,\n callbacks: _callbacks,\n ...rest\n } = collection as Record<string, unknown>;\n\n // Subcollections are collections, and carry their own rules. Stripping only\n // the top level published every nested policy — the leak this exists to\n // prevent, just one level down.\n if (Array.isArray(rest.subcollections)) {\n rest.subcollections = rest.subcollections.map(stripNonClientFields);\n }\n\n return rest;\n}\n\nexport function createContractRoutes(config: ContractRoutesConfig): Hono<HonoEnv> {\n const router = new Hono<HonoEnv>();\n\n // Computing a version walks and canonicalizes every collection, and\n // `/schema-version` is deliberately unauthenticated and meant to be polled.\n // Recomputing per request would make a CI convenience into a CPU\n // amplification anyone could aim at the server. Collections do not change\n // after boot, so once is enough.\n let cachedVersion: string | undefined;\n const schemaVersionOf = (collections: CollectionConfig[]): string => {\n if (config.schemaVersion) return config.schemaVersion;\n if (cachedVersion === undefined) cachedVersion = computeSchemaVersion(collections);\n return cachedVersion;\n };\n\n router.get(\"/contract\", (c) => {\n const collections = config.collectionRegistry.getRawCollections();\n const serialized = serializeCollections(collections).map(stripNonClientFields);\n\n // In `baas` mode the collections are whatever introspection found at\n // boot, so the version has to be computed from them rather than taken\n // from a build that never saw them.\n const schemaVersion = schemaVersionOf(collections);\n\n const contract: RebaseProjectContract = {\n schemaVersion,\n runtime: {\n version: config.runtimeVersion ?? \"unknown\",\n contract: RUNTIME_CONTRACT_VERSION\n },\n collections: serialized,\n collectionSlugs: collections\n .map(collection => collection.slug)\n .filter((slug): slug is string => Boolean(slug))\n .sort(),\n generatedAt: new Date().toISOString()\n };\n\n c.header(SCHEMA_VERSION_HEADER, schemaVersion);\n return c.json(contract);\n });\n\n /**\n * Cheap drift check.\n *\n * Deliberately unauthenticated and deliberately tiny: two version stamps\n * and nothing else. A CI job that only wants to know whether its generated\n * SDK is stale should not need admin credentials, and a version stamp\n * reveals nothing about the schema it stands for.\n *\n * `runtime` is here as well as on `/contract` because the two answer\n * different questions and only one of them was reachable. Which runtime a\n * project is on decides whether a client's wire format is understood at\n * all, and it was published solely on the admin-gated route — so a CLI or\n * an SDK, the two callers that actually need to know, could not ask. That\n * is the same shape as the header this route echoes: a documented signal\n * with no reachable sender. `contract` is the number that matters for\n * compatibility; `version` names the release a human should quote.\n */\n router.get(\"/schema-version\", (c) => {\n const schemaVersion = schemaVersionOf(config.collectionRegistry.getRawCollections());\n c.header(SCHEMA_VERSION_HEADER, schemaVersion);\n return c.json({\n schemaVersion,\n runtime: {\n version: config.runtimeVersion ?? \"unknown\",\n contract: RUNTIME_CONTRACT_VERSION\n }\n });\n });\n\n logger.debug(\"Contract routes mounted\");\n return router;\n}\n"],"mappings":";;;;;;;;;;;AAgCA,IAAM,YAAY;;;;;;;;AASlB,SAAS,aAAa,OAA8C;CAChE,IAAI,CAAC,SAAS,OAAO,UAAU,UAAU,OAAO,KAAA;CAChD,MAAM,YAAY;CAClB,IAAI,UAAU,WAAW,UAAU,YAAY;EAC3C,MAAM,QAAQ,UAAU;EACxB,IAAI,SAAS,OAAO,UAAU,UAAU,OAAO;CACnD;CACA,IAAI,UAAU,YAAY,OAAO;AAErC;;AAGA,SAAS,OAAO,YAA8D;CAC1E,IAAI,CAAC,YAAY,OAAO,KAAA;CACxB,MAAM,WAAW;CACjB,OAAO,WAAW,QAAQ,SAAS,QAAQ,WAAW;AAC1D;AA6BA,SAAS,eACL,OACA,MACA,OACA,OACA,KACO;CACP,IAAI,QAAQ,WAAW;EACnB,MAAM;EACN;CACJ;CAEA,IAAI,OAAO,UAAU,YAAY;EAK7B,IAAI,QAAQ,UACR,IAAI;GAEA,MAAM,MAAM,OADK,aAAc,MAAwB,CACpC,CAAQ;GAC3B,OAAO,MAAM,EAAE,iBAAiB,IAAI,IAAI,KAAA;EAC5C,QAAQ;GACJ;EACJ;EAEJ;CACJ;CAEA,IAAI,UAAU,QAAQ,OAAO,UAAU,UACnC,OAAO;CAGX,IAAI,iBAAiB,MAAM,OAAO,MAAM,YAAY;CACpD,IAAI,iBAAiB,QAAQ,OAAO,MAAM;CAE1C,IAAI,KAAK,IAAI,KAAe,GAAG;EAC3B,MAAM;EACN;CACJ;CASA,MAAM,SAAS,MAAM,KAAK,IAAI,KAAe;CAC7C,IAAI,WAAW,KAAA,GAAW,OAAO;CAEjC,KAAK,IAAI,KAAe;CACxB,MAAM,oBAAoB,MAAM;CAChC,MAAM,WAAW,WAA6B;EAE1C,IAAI,WAAW,KAAA,KAAa,MAAM,gBAAgB,mBAC9C,MAAM,KAAK,IAAI,OAAiB,MAAM;EAE1C,OAAO;CACX;CAEA,IAAI;EACA,IAAI,MAAM,QAAQ,KAAK,GAAG;GACtB,MAAM,QAAQ,MACT,KAAI,SAAQ,eAAe,MAAM,MAAM,QAAQ,GAAG,KAAK,CAAC,CAAC,CACzD,QAAO,SAAQ,SAAS,KAAA,CAAS;GAGtC,OAAO,QAAQ,MAAM,SAAS,KAAK,MAAM,WAAW,IAAI,KAAA,IAAY,KAAK;EAC7E;EAIA,IAAI,cAAe,OAAmC,OAAO,KAAA;EAE7D,MAAM,UAAU,OAAO,QAAQ,KAAgC;EAC/D,MAAM,MAA+B,CAAC;EACtC,KAAK,MAAM,CAAC,GAAG,MAAM,SAAS;GAC1B,MAAM,YAAY,eAAe,GAAG,MAAM,QAAQ,GAAG,OAAO,CAAC;GAC7D,IAAI,cAAc,KAAA,GAAW,IAAI,KAAK;EAC1C;EAYA,IAAI,QAAQ,SAAS,KAAK,OAAO,KAAK,GAAG,CAAC,CAAC,WAAW,GAAG,OAAO,KAAA;EAEhE,OAAO,QAAQ,GAAG;CACtB,UAAU;EAGN,KAAK,OAAO,KAAe;CAC/B;AACJ;;;;;;;AAQA,SAAgB,qBAAqB,aAA4C;CAC7E,OAAO,CAAC,GAAG,WAAW,CAAC,CAClB,MAAM,GAAG,MAAM,OAAO,EAAE,QAAQ,EAAE,CAAC,CAAC,cAAc,OAAO,EAAE,QAAQ,EAAE,CAAC,CAAC,CAAC,CACxE,KAAI,eAAc,eAAe,kBAAkB,UAAU,mBAAG,IAAI,QAAQ,GAAG,GAAG;EAC/E,sBAAM,IAAI,QAAQ;EAClB,aAAa;CACjB,CAAC,CAAC,CAAC,CACF,QAAQ,MAAoC,MAAM,KAAA,CAAS;AACpE;;;;;;;;;;;;;;;;;;;AAoBA,SAAS,kBAAkB,YAAgD;CACvE,MAAM,EAAE,OAAO,QAAQ,GAAG,SAAS;CACnC,MAAM,SAAS;CACf,IAAI,MAAM,QAAQ,OAAO,cAAc,GACnC,OAAO,iBAAiB,OAAO,eAAe,KACzC,UAAU,kBAAkB,KAAyB,CAC1D;CAEJ,OAAO;AACX;;;;;;;;;;;;;;;;;ACrNA,SAAS,aAAa,OAAwB;CAC1C,IAAI,UAAU,QAAQ,OAAO,UAAU,UACnC,OAAO,KAAK,UAAU,KAAK,KAAK;CAEpC,IAAI,MAAM,QAAQ,KAAK,GACnB,OAAO,IAAI,MAAM,IAAI,YAAY,CAAC,CAAC,KAAK,GAAG,EAAE;CAKjD,OAAO,IAHS,OAAO,QAAQ,KAAgC,CAAC,CAC3D,QAAQ,GAAG,OAAO,MAAM,KAAA,CAAS,CAAC,CAClC,MAAM,CAAC,IAAI,CAAC,OAAQ,IAAI,IAAI,KAAK,IAAI,IAAI,IAAI,CACvC,CAAA,CAAQ,KAAK,CAAC,GAAG,OAAO,GAAG,KAAK,UAAU,CAAC,EAAE,GAAG,aAAa,CAAC,GAAG,CAAC,CAAC,KAAK,GAAG,EAAE;AAC5F;;;;;;;;;;;;;;;;;;AAmBA,SAAS,kBAAkB,YAAuD;CAC9E,MAAM,SAAS;CAQf,OAAO;EACH,MAAM,WAAW,QAAQ,OAAO;EAChC,YAAY,WAAW;EACvB,WAAW,OAAO;EAOlB,QAAQ,OAAO;EACf,YAAY,OAAO;EACnB,gBAAgB,OAAO,gBAAgB,IAAI,iBAAiB;CAChE;AACJ;;;;;;;AAQA,SAAgB,uBAAuB,aAAyC;CAG5E,OAAO,aAFW,qBAAqB,WAAW,CAAC,CAC9C,KAAI,eAAc,kBAAkB,UAA8B,CACnD,CAAS;AACjC;;;;;;;;;;AAWA,SAAgB,qBAAqB,aAAyC;CAC1E,MAAM,UAAU,uBAAuB,WAAW;CAElD,IAAI,KAAK;CACT,IAAI,KAAK;CAET,KAAK,IAAI,IAAI,GAAG,IAAI,QAAQ,QAAQ,KAAK;EACrC,MAAM,OAAO,QAAQ,WAAW,CAAC;EACjC,MAAM;EAEN,KAAM,OAAO,MAAM,MAAM,MAAM,MAAM,MAAM,MAAM,MAAM,MAAM,MAAM,SAAU;EAC7E,MAAM,OAAO;EACb,KAAM,OAAO,MAAM,MAAM,MAAM,MAAM,MAAM,MAAM,MAAM,OAAO,MAAM,SAAU;CAClF;CAEA,MAAM,OAAO,MAAsB,EAAE,SAAS,EAAE,CAAC,CAAC,SAAS,GAAG,GAAG;CACjE,OAAO,MAAM,IAAI,EAAE,IAAI,IAAI,EAAE;AACjC;;;;;;;;;;;;;;AC1DA,SAAS,qBAAqB,YAA8B;CACxD,IAAI,CAAC,cAAc,OAAO,eAAe,UAAU,OAAO;CAC1D,MAAM,EACF,eAAe,gBACf,WAAW,YACX,GAAG,SACH;CAKJ,IAAI,MAAM,QAAQ,KAAK,cAAc,GACjC,KAAK,iBAAiB,KAAK,eAAe,IAAI,oBAAoB;CAGtE,OAAO;AACX;AAEA,SAAgB,qBAAqB,QAA6C;CAC9E,MAAM,SAAS,IAAI,KAAc;CAOjC,IAAI;CACJ,MAAM,mBAAmB,gBAA4C;EACjE,IAAI,OAAO,eAAe,OAAO,OAAO;EACxC,IAAI,kBAAkB,KAAA,GAAW,gBAAgB,qBAAqB,WAAW;EACjF,OAAO;CACX;CAEA,OAAO,IAAI,cAAc,MAAM;EAC3B,MAAM,cAAc,OAAO,mBAAmB,kBAAkB;EAChE,MAAM,aAAa,qBAAqB,WAAW,CAAC,CAAC,IAAI,oBAAoB;EAK7E,MAAM,gBAAgB,gBAAgB,WAAW;EAEjD,MAAM,WAAkC;GACpC;GACA,SAAS;IACL,SAAS,OAAO,kBAAkB;IAClC,UAAA;GACJ;GACA,aAAa;GACb,iBAAiB,YACZ,KAAI,eAAc,WAAW,IAAI,CAAC,CAClC,QAAQ,SAAyB,QAAQ,IAAI,CAAC,CAAC,CAC/C,KAAK;GACV,8BAAa,IAAI,KAAK,EAAA,CAAE,YAAY;EACxC;EAEA,EAAE,OAAO,uBAAuB,aAAa;EAC7C,OAAO,EAAE,KAAK,QAAQ;CAC1B,CAAC;;;;;;;;;;;;;;;;;;CAmBD,OAAO,IAAI,oBAAoB,MAAM;EACjC,MAAM,gBAAgB,gBAAgB,OAAO,mBAAmB,kBAAkB,CAAC;EACnF,EAAE,OAAO,uBAAuB,aAAa;EAC7C,OAAO,EAAE,KAAK;GACV;GACA,SAAS;IACL,SAAS,OAAO,kBAAkB;IAClC,UAAA;GACJ;EACJ,CAAC;CACL,CAAC;CAED,OAAO,MAAM,yBAAyB;CACtC,OAAO;AACX"}
|
|
@@ -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 { x as declareCron } from "./src-D5Y3N-S4.js";
|
|
7
|
-
import {
|
|
7
|
+
import { a as logger } from "./logger-CW8pmTS6.js";
|
|
8
8
|
import { i as warnDeprecated, n as describeLoadFailure, r as nativeDynamicImport, t as SYNTAX_ERROR_ADVICE } from "./load-failure-L5hvfDoG.js";
|
|
9
9
|
import * as fs$1 from "fs";
|
|
10
10
|
import * as path$1 from "path";
|
|
@@ -199,4 +199,4 @@ function cronResourceOptions(definition) {
|
|
|
199
199
|
//#endregion
|
|
200
200
|
export { loadCrons as a, loadCronJobsWithDiagnostics as i, declareCrons as n, loadCronJobsFromDirectory as r, cron_loader_exports as t };
|
|
201
201
|
|
|
202
|
-
//# sourceMappingURL=cron-loader-
|
|
202
|
+
//# sourceMappingURL=cron-loader-Dj6lA4HQ.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cron-loader-NHqRfKF0.js","names":[],"sources":["../src/cron/cron-loader.ts"],"sourcesContent":["import * as fs from \"fs\";\nimport * as path from \"path\";\nimport { pathToFileURL } from \"url\";\nimport { declareCron, type CronJobDefinition, type CronResourceOptions } from \"@rebasepro/types\";\nimport { logger } from \"../utils/logger.js\";\nimport { nativeDynamicImport, type ModuleImporter } from \"../utils/dynamic-import.js\";\nimport { describeLoadFailure, SYNTAX_ERROR_ADVICE } from \"../utils/load-failure.js\";\nimport { warnDeprecated } from \"@rebasepro/common\";\nimport type { LoadDirectoryOptions } from \"../functions/function-loader.js\";\n\n/**\n * Cron jobs as values: the job id, to its definition.\n *\n * `crons: { \"nightly-report\": nightlyReport }` schedules `nightlyReport` as the\n * job `nightly-report` — the same thing a file named `crons/nightly-report.ts`\n * does. {@link loadCrons} returns exactly this shape, so\n * `crons: await loadCrons(dir)` and `cronsDir: dir` schedule the same jobs.\n *\n * A value is what the author wrote — `defineCron({...})` — with no defaults\n * filled in. The scheduler fills them in when it registers the job.\n */\nexport type CronsRecord = Record<string, CronJobDefinition>;\n\n/** A job as the scheduler registers it: its id, and its definition with defaults filled in. */\nexport interface LoadedCronJob {\n /** Job ID derived from filename (e.g. \"cleanup-sessions\"). */\n id: string;\n /** The full definition. */\n definition: CronJobDefinition;\n}\n\n/** {@link loadCronJobsWithDiagnostics}' answer: what loaded, and what did not. */\nexport interface LoadedCronJobs {\n jobs: LoadedCronJob[];\n /** One entry per file that will NOT be scheduled, with the reason. */\n problems: string[];\n}\n\n/**\n * Load the cron jobs in a directory, as values.\n *\n * Each file default-exports a `CronJobDefinition` — `defineCron({...})`. The\n * filename without its extension is the record key, which is the job id:\n * `crons/cleanup-sessions.ts` → `{ \"cleanup-sessions\": definition }`.\n *\n * Loading declares nothing: the definitions come back exactly as written, and\n * whoever uses them decides what they are for — a backend schedules them, the\n * build's derive step records them in the resource graph ({@link declareCrons}).\n *\n * Never throws for a bad file: it is skipped, logged, and passed to\n * `options.onSkip`.\n *\n * @example\n * ```ts\n * const crons = await loadCrons(path.resolve(\"crons\"));\n * const backend = await createRebaseBackend({ database, crons, provision: true });\n * ```\n */\nexport async function loadCrons(directory: string, options: LoadDirectoryOptions = {}): Promise<CronsRecord> {\n const { crons, problems } = await readCronsDirectory(directory, options.importModule);\n if (options.onSkip) for (const problem of problems) options.onSkip(problem);\n return crons;\n}\n\n/**\n * Record each cron in the project's resource graph — what `rebase resources`\n * lists and a host reads to know a schedule exists, without running it.\n *\n * Keyed by the same id the scheduler, the routes and the Studio use, and built\n * from the definitions as written: a default the scheduler fills in\n * (`enabled: true`, `timeoutSeconds: 300`) is this process's, not something the\n * author declared.\n */\nexport function declareCrons(crons: CronsRecord): void {\n for (const [id, definition] of Object.entries(crons)) {\n declareCron(id, cronResourceOptions(definition));\n }\n}\n\n/**\n * The jobs a scheduler registers, from cron values: the definitions with this\n * process's defaults filled in.\n *\n * Spread first, then normalise. Rebuilding the object field by field dropped\n * `catchUpWindowSeconds` once — and since this is the path every job takes to\n * the scheduler, catch-up never ran for any job authored the documented way. A\n * field added to `CronJobDefinition` tomorrow would be dropped the same way.\n *\n * @throws when a value has no `schedule` or no `handler`. A file like that is\n * skipped by the loader with a reason; a value handed over in code is refused,\n * because the caller is right there to fix it.\n */\nexport function cronJobsFrom(crons: CronsRecord): LoadedCronJob[] {\n return Object.entries(crons).map(([id, definition]) => {\n const def = definition as Partial<CronJobDefinition> | undefined;\n if (!def || typeof def.schedule !== \"string\" || typeof def.handler !== \"function\") {\n throw new Error(\n `crons[\"${id}\"] is not a cron job: it needs a \\`schedule\\` string and a \\`handler\\` function. ` +\n \"Author it with `defineCron({ schedule, handler })`.\"\n );\n }\n return {\n id,\n definition: {\n ...def,\n schedule: def.schedule,\n name: def.name || id,\n enabled: def.enabled !== false,\n // `??`, not `||`: zero and NaN are values the author wrote, and\n // the scheduler refuses both by name (they end up under\n // `rejected`). `|| 300` swallowed them, so a job written with\n // `0` meaning \"no limit\" ran with five minutes and was listed as\n // healthy. `Infinity` is \"none\".\n timeoutSeconds: def.timeoutSeconds ?? 300,\n handler: def.handler\n }\n };\n });\n}\n\n/**\n * @deprecated Use `loadCrons` instead, which returns the jobs as a record keyed\n * by id. Will be removed in a future release.\n */\nexport async function loadCronJobsFromDirectory(\n directory: string,\n importModule: ModuleImporter = nativeDynamicImport\n): Promise<LoadedCronJob[]> {\n warnDeprecated(\n \"server.loadCronJobsFromDirectory\",\n \"`loadCronJobsFromDirectory` is deprecated; use `loadCrons(dir)`, which returns `{ [id]: definition }`.\"\n );\n return (await legacyLoad(directory, importModule)).jobs;\n}\n\n/**\n * @deprecated Use `loadCrons` instead, with `onSkip` to collect the files it\n * skipped — and `declareCrons` where the jobs belong in the resource graph,\n * which this used to do as a side effect of loading. Will be removed in a\n * future release.\n */\nexport async function loadCronJobsWithDiagnostics(\n directory: string,\n importModule: ModuleImporter = nativeDynamicImport\n): Promise<LoadedCronJobs> {\n warnDeprecated(\n \"server.loadCronJobsWithDiagnostics\",\n \"`loadCronJobsWithDiagnostics` is deprecated; use `loadCrons(dir, { onSkip })`, which returns \" +\n \"`{ [id]: definition }`, and `declareCrons(crons)` to record them in the resource graph.\"\n );\n return legacyLoad(directory, importModule);\n}\n\n/**\n * What the deprecated loaders did: load, declare every job in the resource\n * graph, and hand back normalised jobs. A job the graph refuses is skipped as a\n * problem, as it was when the declaration ran inside the loader.\n */\nasync function legacyLoad(directory: string, importModule: ModuleImporter): Promise<LoadedCronJobs> {\n const { crons, problems } = await readCronsDirectory(directory, importModule);\n const declared: CronsRecord = {};\n for (const [id, definition] of Object.entries(crons)) {\n try {\n declareCron(id, cronResourceOptions(definition));\n declared[id] = definition;\n } catch (err) {\n problems.push(describeLoadFailure(id, err));\n }\n }\n return { jobs: cronJobsFrom(declared), problems };\n}\n\n/**\n * Read a directory of cron files: the definitions that loaded, keyed by id, and\n * one `<file> (<reason>)` per file that will NOT be scheduled.\n *\n * A job that does not load does not run, and the only trace used to be one\n * `warn` per file in the boot log — next to nothing at 3am when the report that\n * should have been mailed was not. So the skips are counted, summarised in one\n * place, and answered by `GET /api/admin/cron`.\n *\n * Never throws, for the same reason the function loader does not: one\n * malformed file must not take the server down, and the other jobs in the\n * directory are still worth scheduling.\n */\nexport async function readCronsDirectory(\n directory: string,\n importModule: ModuleImporter = nativeDynamicImport\n): Promise<{ crons: CronsRecord; problems: string[] }> {\n const crons: CronsRecord = {};\n // Aggregated so a broken job surfaces as one loud summary rather than a\n // warning buried per-file among the boot noise.\n const problems: string[] = [];\n\n if (!fs.existsSync(directory)) {\n return { crons, problems };\n }\n\n const files = fs.readdirSync(directory);\n for (const file of files) {\n if (\n (file.endsWith(\".ts\") || file.endsWith(\".js\")) &&\n // Dotfiles: notably macOS bsdtar AppleDouble sidecars (`._foo.ts`),\n // binary blobs that cannot be imported.\n !file.startsWith(\".\") &&\n !file.includes(\".test.\") &&\n !file.endsWith(\".d.ts\") &&\n file !== \"index.ts\" &&\n file !== \"index.js\"\n ) {\n const filePath = path.join(directory, file);\n try {\n const fileUrl = pathToFileURL(filePath).href;\n\n const mod = await importModule(fileUrl);\n\n const exported: unknown = mod.default;\n\n if (!exported || typeof exported !== \"object\") {\n problems.push(`${file} (no default export)`);\n logger.warn(`[cron] ${file}: no valid default export. Skipping.`);\n continue;\n }\n\n const def = exported as Record<string, unknown>;\n if (typeof def.schedule !== \"string\" || def.schedule.trim() === \"\" || typeof def.handler !== \"function\") {\n problems.push(`${file} (default export has no 'schedule' or no 'handler')`);\n logger.warn(`[cron] ${file}: default export missing required 'schedule' or 'handler'. Skipping.`);\n continue;\n }\n\n const id = path.basename(file, path.extname(file));\n const definition = exported as CronJobDefinition;\n crons[id] = definition;\n logger.info(\n `⏰ Loaded cron job: ${id} (${definition.schedule}` +\n `${definition.timezone ? ` ${definition.timezone}` : \" in the process's local time\"})`\n );\n } catch (err: unknown) {\n const message =\n err instanceof Error ? err.message : String(err);\n problems.push(describeLoadFailure(file, err));\n // At `debug`: the aggregate warning below lists every problem\n // by file, so this line was the same text a second time — and\n // when the throw is a Zod error, that text is ten lines of\n // serialised issues. The reader met it twice before reaching\n // the sentence that says what to do.\n logger.debug(`[cron] Failed to load ${file}: ${message}`);\n }\n }\n }\n\n if (problems.length > 0) {\n // Advice keyed to what went wrong, as the functions loader does:\n // `defineCron` is the remedy for a wrong export shape, not for a typo.\n const syntax = problems.some(p => p.includes(\"(syntax error\"));\n const other = problems.some(p => !p.includes(\"(syntax error\"));\n logger.warn(\n `[cron] ${problems.length} cron file(s) were skipped and will NOT be scheduled:\\n` +\n problems.map((p) => ` - ${p}`).join(\"\\n\") + \"\\n\" +\n [\n ...(syntax ? [SYNTAX_ERROR_ADVICE] : []),\n ...(other ? [\" Fix these or author them with `defineCron(...)` for a typed, compile-checked contract.\"] : [])\n ].join(\"\\n\")\n );\n }\n\n return { crons, problems };\n}\n\n/**\n * The declaration a definition makes: every field a host can read without the\n * handler. Spelled once here so the graph and the scheduler cannot disagree\n * about what a cron is.\n */\nexport function cronResourceOptions(definition: CronJobDefinition): CronResourceOptions {\n return {\n schedule: definition.schedule,\n ...(definition.timezone !== undefined ? { timezone: definition.timezone } : {}),\n ...(definition.description !== undefined ? { description: definition.description } : {}),\n ...(definition.enabled !== undefined ? { enabled: definition.enabled } : {}),\n ...(definition.timeoutSeconds !== undefined ? { timeoutSeconds: definition.timeoutSeconds } : {}),\n ...(definition.catchUpWindowSeconds !== undefined ? { catchUpWindowSeconds: definition.catchUpWindowSeconds } : {}),\n ...(definition.name ? { label: definition.name } : {})\n };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"cron-loader-Dj6lA4HQ.js","names":[],"sources":["../src/cron/cron-loader.ts"],"sourcesContent":["import * as fs from \"fs\";\nimport * as path from \"path\";\nimport { pathToFileURL } from \"url\";\nimport { declareCron, type CronJobDefinition, type CronResourceOptions } from \"@rebasepro/types\";\nimport { logger } from \"../utils/logger.js\";\nimport { nativeDynamicImport, type ModuleImporter } from \"../utils/dynamic-import.js\";\nimport { describeLoadFailure, SYNTAX_ERROR_ADVICE } from \"../utils/load-failure.js\";\nimport { warnDeprecated } from \"@rebasepro/common\";\nimport type { LoadDirectoryOptions } from \"../functions/function-loader.js\";\n\n/**\n * Cron jobs as values: the job id, to its definition.\n *\n * `crons: { \"nightly-report\": nightlyReport }` schedules `nightlyReport` as the\n * job `nightly-report` — the same thing a file named `crons/nightly-report.ts`\n * does. {@link loadCrons} returns exactly this shape, so\n * `crons: await loadCrons(dir)` and `cronsDir: dir` schedule the same jobs.\n *\n * A value is what the author wrote — `defineCron({...})` — with no defaults\n * filled in. The scheduler fills them in when it registers the job.\n */\nexport type CronsRecord = Record<string, CronJobDefinition>;\n\n/** A job as the scheduler registers it: its id, and its definition with defaults filled in. */\nexport interface LoadedCronJob {\n /** Job ID derived from filename (e.g. \"cleanup-sessions\"). */\n id: string;\n /** The full definition. */\n definition: CronJobDefinition;\n}\n\n/** {@link loadCronJobsWithDiagnostics}' answer: what loaded, and what did not. */\nexport interface LoadedCronJobs {\n jobs: LoadedCronJob[];\n /** One entry per file that will NOT be scheduled, with the reason. */\n problems: string[];\n}\n\n/**\n * Load the cron jobs in a directory, as values.\n *\n * Each file default-exports a `CronJobDefinition` — `defineCron({...})`. The\n * filename without its extension is the record key, which is the job id:\n * `crons/cleanup-sessions.ts` → `{ \"cleanup-sessions\": definition }`.\n *\n * Loading declares nothing: the definitions come back exactly as written, and\n * whoever uses them decides what they are for — a backend schedules them, the\n * build's derive step records them in the resource graph ({@link declareCrons}).\n *\n * Never throws for a bad file: it is skipped, logged, and passed to\n * `options.onSkip`.\n *\n * @example\n * ```ts\n * const crons = await loadCrons(path.resolve(\"crons\"));\n * const backend = await createRebaseBackend({ database, crons, provision: true });\n * ```\n */\nexport async function loadCrons(directory: string, options: LoadDirectoryOptions = {}): Promise<CronsRecord> {\n const { crons, problems } = await readCronsDirectory(directory, options.importModule);\n if (options.onSkip) for (const problem of problems) options.onSkip(problem);\n return crons;\n}\n\n/**\n * Record each cron in the project's resource graph — what `rebase resources`\n * lists and a host reads to know a schedule exists, without running it.\n *\n * Keyed by the same id the scheduler, the routes and the Studio use, and built\n * from the definitions as written: a default the scheduler fills in\n * (`enabled: true`, `timeoutSeconds: 300`) is this process's, not something the\n * author declared.\n */\nexport function declareCrons(crons: CronsRecord): void {\n for (const [id, definition] of Object.entries(crons)) {\n declareCron(id, cronResourceOptions(definition));\n }\n}\n\n/**\n * The jobs a scheduler registers, from cron values: the definitions with this\n * process's defaults filled in.\n *\n * Spread first, then normalise. Rebuilding the object field by field dropped\n * `catchUpWindowSeconds` once — and since this is the path every job takes to\n * the scheduler, catch-up never ran for any job authored the documented way. A\n * field added to `CronJobDefinition` tomorrow would be dropped the same way.\n *\n * @throws when a value has no `schedule` or no `handler`. A file like that is\n * skipped by the loader with a reason; a value handed over in code is refused,\n * because the caller is right there to fix it.\n */\nexport function cronJobsFrom(crons: CronsRecord): LoadedCronJob[] {\n return Object.entries(crons).map(([id, definition]) => {\n const def = definition as Partial<CronJobDefinition> | undefined;\n if (!def || typeof def.schedule !== \"string\" || typeof def.handler !== \"function\") {\n throw new Error(\n `crons[\"${id}\"] is not a cron job: it needs a \\`schedule\\` string and a \\`handler\\` function. ` +\n \"Author it with `defineCron({ schedule, handler })`.\"\n );\n }\n return {\n id,\n definition: {\n ...def,\n schedule: def.schedule,\n name: def.name || id,\n enabled: def.enabled !== false,\n // `??`, not `||`: zero and NaN are values the author wrote, and\n // the scheduler refuses both by name (they end up under\n // `rejected`). `|| 300` swallowed them, so a job written with\n // `0` meaning \"no limit\" ran with five minutes and was listed as\n // healthy. `Infinity` is \"none\".\n timeoutSeconds: def.timeoutSeconds ?? 300,\n handler: def.handler\n }\n };\n });\n}\n\n/**\n * @deprecated Use `loadCrons` instead, which returns the jobs as a record keyed\n * by id. Will be removed in a future release.\n */\nexport async function loadCronJobsFromDirectory(\n directory: string,\n importModule: ModuleImporter = nativeDynamicImport\n): Promise<LoadedCronJob[]> {\n warnDeprecated(\n \"server.loadCronJobsFromDirectory\",\n \"`loadCronJobsFromDirectory` is deprecated; use `loadCrons(dir)`, which returns `{ [id]: definition }`.\"\n );\n return (await legacyLoad(directory, importModule)).jobs;\n}\n\n/**\n * @deprecated Use `loadCrons` instead, with `onSkip` to collect the files it\n * skipped — and `declareCrons` where the jobs belong in the resource graph,\n * which this used to do as a side effect of loading. Will be removed in a\n * future release.\n */\nexport async function loadCronJobsWithDiagnostics(\n directory: string,\n importModule: ModuleImporter = nativeDynamicImport\n): Promise<LoadedCronJobs> {\n warnDeprecated(\n \"server.loadCronJobsWithDiagnostics\",\n \"`loadCronJobsWithDiagnostics` is deprecated; use `loadCrons(dir, { onSkip })`, which returns \" +\n \"`{ [id]: definition }`, and `declareCrons(crons)` to record them in the resource graph.\"\n );\n return legacyLoad(directory, importModule);\n}\n\n/**\n * What the deprecated loaders did: load, declare every job in the resource\n * graph, and hand back normalised jobs. A job the graph refuses is skipped as a\n * problem, as it was when the declaration ran inside the loader.\n */\nasync function legacyLoad(directory: string, importModule: ModuleImporter): Promise<LoadedCronJobs> {\n const { crons, problems } = await readCronsDirectory(directory, importModule);\n const declared: CronsRecord = {};\n for (const [id, definition] of Object.entries(crons)) {\n try {\n declareCron(id, cronResourceOptions(definition));\n declared[id] = definition;\n } catch (err) {\n problems.push(describeLoadFailure(id, err));\n }\n }\n return { jobs: cronJobsFrom(declared), problems };\n}\n\n/**\n * Read a directory of cron files: the definitions that loaded, keyed by id, and\n * one `<file> (<reason>)` per file that will NOT be scheduled.\n *\n * A job that does not load does not run, and the only trace used to be one\n * `warn` per file in the boot log — next to nothing at 3am when the report that\n * should have been mailed was not. So the skips are counted, summarised in one\n * place, and answered by `GET /api/admin/cron`.\n *\n * Never throws, for the same reason the function loader does not: one\n * malformed file must not take the server down, and the other jobs in the\n * directory are still worth scheduling.\n */\nexport async function readCronsDirectory(\n directory: string,\n importModule: ModuleImporter = nativeDynamicImport\n): Promise<{ crons: CronsRecord; problems: string[] }> {\n const crons: CronsRecord = {};\n // Aggregated so a broken job surfaces as one loud summary rather than a\n // warning buried per-file among the boot noise.\n const problems: string[] = [];\n\n if (!fs.existsSync(directory)) {\n return { crons, problems };\n }\n\n const files = fs.readdirSync(directory);\n for (const file of files) {\n if (\n (file.endsWith(\".ts\") || file.endsWith(\".js\")) &&\n // Dotfiles: notably macOS bsdtar AppleDouble sidecars (`._foo.ts`),\n // binary blobs that cannot be imported.\n !file.startsWith(\".\") &&\n !file.includes(\".test.\") &&\n !file.endsWith(\".d.ts\") &&\n file !== \"index.ts\" &&\n file !== \"index.js\"\n ) {\n const filePath = path.join(directory, file);\n try {\n const fileUrl = pathToFileURL(filePath).href;\n\n const mod = await importModule(fileUrl);\n\n const exported: unknown = mod.default;\n\n if (!exported || typeof exported !== \"object\") {\n problems.push(`${file} (no default export)`);\n logger.warn(`[cron] ${file}: no valid default export. Skipping.`);\n continue;\n }\n\n const def = exported as Record<string, unknown>;\n if (typeof def.schedule !== \"string\" || def.schedule.trim() === \"\" || typeof def.handler !== \"function\") {\n problems.push(`${file} (default export has no 'schedule' or no 'handler')`);\n logger.warn(`[cron] ${file}: default export missing required 'schedule' or 'handler'. Skipping.`);\n continue;\n }\n\n const id = path.basename(file, path.extname(file));\n const definition = exported as CronJobDefinition;\n crons[id] = definition;\n logger.info(\n `⏰ Loaded cron job: ${id} (${definition.schedule}` +\n `${definition.timezone ? ` ${definition.timezone}` : \" in the process's local time\"})`\n );\n } catch (err: unknown) {\n const message =\n err instanceof Error ? err.message : String(err);\n problems.push(describeLoadFailure(file, err));\n // At `debug`: the aggregate warning below lists every problem\n // by file, so this line was the same text a second time — and\n // when the throw is a Zod error, that text is ten lines of\n // serialised issues. The reader met it twice before reaching\n // the sentence that says what to do.\n logger.debug(`[cron] Failed to load ${file}: ${message}`);\n }\n }\n }\n\n if (problems.length > 0) {\n // Advice keyed to what went wrong, as the functions loader does:\n // `defineCron` is the remedy for a wrong export shape, not for a typo.\n const syntax = problems.some(p => p.includes(\"(syntax error\"));\n const other = problems.some(p => !p.includes(\"(syntax error\"));\n logger.warn(\n `[cron] ${problems.length} cron file(s) were skipped and will NOT be scheduled:\\n` +\n problems.map((p) => ` - ${p}`).join(\"\\n\") + \"\\n\" +\n [\n ...(syntax ? [SYNTAX_ERROR_ADVICE] : []),\n ...(other ? [\" Fix these or author them with `defineCron(...)` for a typed, compile-checked contract.\"] : [])\n ].join(\"\\n\")\n );\n }\n\n return { crons, problems };\n}\n\n/**\n * The declaration a definition makes: every field a host can read without the\n * handler. Spelled once here so the graph and the scheduler cannot disagree\n * about what a cron is.\n */\nexport function cronResourceOptions(definition: CronJobDefinition): CronResourceOptions {\n return {\n schedule: definition.schedule,\n ...(definition.timezone !== undefined ? { timezone: definition.timezone } : {}),\n ...(definition.description !== undefined ? { description: definition.description } : {}),\n ...(definition.enabled !== undefined ? { enabled: definition.enabled } : {}),\n ...(definition.timeoutSeconds !== undefined ? { timeoutSeconds: definition.timeoutSeconds } : {}),\n ...(definition.catchUpWindowSeconds !== undefined ? { catchUpWindowSeconds: definition.catchUpWindowSeconds } : {}),\n ...(definition.name ? { label: definition.name } : {})\n };\n}\n"],"mappings":"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"}
|
|
@@ -3,10 +3,10 @@ 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 { r as isAlreadyExecutingSkip } from "./cron-scheduler-
|
|
7
|
-
import {
|
|
8
|
-
import { a as resolveListLimitParam } from "./query-parser-
|
|
9
|
-
import { i as errorHandler, t as ApiError } from "./errors-
|
|
6
|
+
import { r as isAlreadyExecutingSkip } from "./cron-scheduler-B04ACoUE.js";
|
|
7
|
+
import { a as logger } from "./logger-CW8pmTS6.js";
|
|
8
|
+
import { a as resolveListLimitParam } from "./query-parser-otFZqJsC.js";
|
|
9
|
+
import { i as errorHandler, t as ApiError } from "./errors-OMWkKSH-.js";
|
|
10
10
|
import { Hono } from "hono";
|
|
11
11
|
//#region src/cron/cron-routes.ts
|
|
12
12
|
var cron_routes_exports = /* @__PURE__ */ __exportAll({ createCronRoutes: () => createCronRoutes });
|
|
@@ -116,4 +116,4 @@ function createCronRoutes(scheduler, skipped = 0) {
|
|
|
116
116
|
//#endregion
|
|
117
117
|
export { cron_routes_exports as n, createCronRoutes as t };
|
|
118
118
|
|
|
119
|
-
//# sourceMappingURL=cron-routes-
|
|
119
|
+
//# sourceMappingURL=cron-routes-CwbLZguO.js.map
|