@rebasepro/server 0.24.0 → 0.24.1-canary.g56af4a4
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-DivE9bAx.js +402 -0
- package/dist/GCSStorageController-DivE9bAx.js.map +1 -0
- package/dist/S3StorageController-D_vKs4kP.js +480 -0
- package/dist/S3StorageController-D_vKs4kP.js.map +1 -0
- package/dist/api/rest/api-generator.d.ts +53 -1
- package/dist/api/rest/auth-collection-writes.d.ts +28 -21
- package/dist/api/rest/field-access-query.d.ts +28 -8
- package/dist/api/rest/field-ops.d.ts +19 -7
- package/dist/api/rest/query-body.d.ts +10 -0
- package/dist/api/rest/write-validation.d.ts +7 -5
- package/dist/api/schema-editor-routes.d.ts +10 -1
- package/dist/api/sql-export-routes.d.ts +8 -0
- package/dist/api/types.d.ts +11 -0
- package/dist/{ast-schema-editor-CWqS_sLJ.js → ast-schema-editor-CSdL0iCk.js} +2 -2
- package/dist/{ast-schema-editor-CWqS_sLJ.js.map → ast-schema-editor-CSdL0iCk.js.map} +1 -1
- package/dist/auth/access.d.ts +16 -0
- package/dist/auth/admin-user-ops.d.ts +24 -2
- package/dist/auth/admin-users-route.d.ts +1 -0
- package/dist/auth/expired-token-sweep.d.ts +5 -1
- package/dist/auth/interfaces.d.ts +10 -0
- package/dist/auth/jwt-crypto.d.ts +7 -0
- package/dist/auth/jwt-keys.d.ts +4 -0
- package/dist/auth/jwt.d.ts +6 -1
- package/dist/auth/rate-limiter.d.ts +13 -1
- package/dist/auth/registration-policy.d.ts +17 -0
- package/dist/auth/user-picking.d.ts +35 -0
- package/dist/{auth-DMLngxn_.js → auth-OPq_FeMs.js} +972 -85
- package/dist/auth-OPq_FeMs.js.map +1 -0
- package/dist/auth_adapter-CIItCTc8.js +21 -0
- package/dist/auth_adapter-CIItCTc8.js.map +1 -0
- package/dist/backend-DTAOsLQc.js.map +1 -1
- package/dist/backup/backup-common.d.ts +14 -3
- package/dist/backup/backup-routes.d.ts +23 -2
- package/dist/backup/backup-storage.d.ts +3 -1
- package/dist/backup/index.d.ts +3 -1
- package/dist/{backup-CN0s50D2.js → backup-5d7XhLX3.js} +170 -33
- package/dist/backup-5d7XhLX3.js.map +1 -0
- package/dist/boot/driver.d.ts +3 -0
- package/dist/boot/env.d.ts +7 -0
- package/dist/boot/http-server.d.ts +22 -0
- package/dist/boot/options.d.ts +16 -1
- package/dist/boot/runtime-cors.d.ts +17 -0
- package/dist/boot/static-routing.d.ts +9 -0
- package/dist/{contract-routes-fz8i4pxs.js → contract-routes-02lASyJI.js} +3 -19
- package/dist/contract-routes-02lASyJI.js.map +1 -0
- package/dist/cron/cron-routes.d.ts +1 -1
- package/dist/cron/cron-scheduler.d.ts +69 -3
- package/dist/cron/cron-store.d.ts +27 -4
- package/dist/cron/define-cron.d.ts +6 -2
- package/dist/cron/index.d.ts +1 -1
- package/dist/{cron-loader-CwaANlOG.js → cron-loader-D7OmleAb.js} +8 -4
- package/dist/cron-loader-D7OmleAb.js.map +1 -0
- package/dist/{cron-routes-Bc-SB0Se.js → cron-routes-CBfiaqEL.js} +27 -4
- package/dist/cron-routes-CBfiaqEL.js.map +1 -0
- package/dist/{cron-scheduler-CYQgco86.js → cron-scheduler-C2vbETMo.js} +298 -32
- package/dist/cron-scheduler-C2vbETMo.js.map +1 -0
- package/dist/cron-store-B2Ih0gCN.js +540 -0
- package/dist/cron-store-B2Ih0gCN.js.map +1 -0
- package/dist/email/smtp-email-service.d.ts +23 -0
- package/dist/email/types.d.ts +18 -0
- package/dist/env.d.ts +0 -37
- package/dist/filter-dialect-REl-TjZr.js +1628 -0
- package/dist/filter-dialect-REl-TjZr.js.map +1 -0
- package/dist/{function-loader-D7o5Epjj.js → function-loader-BDKKiBmy.js} +4 -3
- package/dist/function-loader-BDKKiBmy.js.map +1 -0
- package/dist/{function-routes-CaNG4waN.js → function-routes-DWB7BBsi.js} +2 -2
- package/dist/function-routes-DWB7BBsi.js.map +1 -0
- package/dist/functions/context.d.ts +9 -0
- package/dist/functions/define-function.d.ts +7 -4
- package/dist/functions/index.d.ts +1 -1
- package/dist/functions/index.js +25 -2
- package/dist/functions/index.js.map +1 -1
- package/dist/functions/request-timeout.d.ts +7 -5
- package/dist/history/history-routes.d.ts +9 -0
- package/dist/history/retention-sweep.d.ts +81 -0
- package/dist/{history-recorder-Nr8zLvoU.js → history-recorder-DHenr3lm.js} +2 -2
- package/dist/{history-recorder-Nr8zLvoU.js.map → history-recorder-DHenr3lm.js.map} +1 -1
- package/dist/{history-store-rcAm_xFR.js → history-store-DPofGVDp.js} +2 -2
- package/dist/{history-store-rcAm_xFR.js.map → history-store-DPofGVDp.js.map} +1 -1
- package/dist/index.d.ts +6 -5
- package/dist/index.es.js +22671 -19191
- package/dist/index.es.js.map +1 -1
- package/dist/init/shutdown.d.ts +12 -2
- package/dist/init.d.ts +16 -6
- package/dist/internal-tables-L2t39RdT.js +136 -0
- package/dist/internal-tables-L2t39RdT.js.map +1 -0
- package/dist/jobs/job-queue.d.ts +8 -0
- package/dist/jobs/job-store.d.ts +24 -0
- package/dist/jobs/types.d.ts +25 -4
- package/dist/{jobs-DqYNfquG.js → jobs-BpD4Q0qR.js} +232 -22
- package/dist/jobs-BpD4Q0qR.js.map +1 -0
- package/dist/{jwt-R6bSPMjk.js → jwt-Bqcu7cGt.js} +171 -65
- package/dist/jwt-Bqcu7cGt.js.map +1 -0
- package/dist/load-failure-UvzZgGXT.js +48 -0
- package/dist/load-failure-UvzZgGXT.js.map +1 -0
- package/dist/mcp/mcp-tools.d.ts +8 -0
- package/dist/metrics/index.d.ts +18 -0
- package/dist/metrics/pool-wait.d.ts +50 -0
- package/dist/offline-8Ks3yp9n-CadBKm2D.js +3305 -0
- package/dist/offline-8Ks3yp9n-CadBKm2D.js.map +1 -0
- package/dist/offline-store-D9ytNNaa-BCr2vsFB.js +1903 -0
- package/dist/offline-store-D9ytNNaa-BCr2vsFB.js.map +1 -0
- package/dist/{openapi-generator-DAq_XVDu.js → openapi-generator-BqoUh4Ij.js} +365 -81
- package/dist/openapi-generator-BqoUh4Ij.js.map +1 -0
- package/dist/policy-PdzX-Opd.js +121 -0
- package/dist/policy-PdzX-Opd.js.map +1 -0
- package/dist/project_manifest-R6y_KHms.js +59 -0
- package/dist/project_manifest-R6y_KHms.js.map +1 -0
- package/dist/{query-parser-BgiKJKvc.js → query-parser-DK9eMkb9.js} +510 -15
- package/dist/query-parser-DK9eMkb9.js.map +1 -0
- package/dist/{request-timeout-DgH7j8qO.js → request-timeout-Dnl2_OBc.js} +15 -8
- package/dist/request-timeout-Dnl2_OBc.js.map +1 -0
- package/dist/resumable-upload-threshold-CKv-Ry0l.js +24 -0
- package/dist/resumable-upload-threshold-CKv-Ry0l.js.map +1 -0
- package/dist/schema-edit/apply-schema-change.d.ts +10 -0
- package/dist/schema-edit/local-source.d.ts +29 -0
- package/dist/schema-edit/remote-source.d.ts +13 -3
- package/dist/schema-edit/source-editor.d.ts +28 -0
- package/dist/{schema-editor-routes-oIyuWl3L.js → schema-editor-routes-BFf3i8jm.js} +53 -9
- package/dist/schema-editor-routes-BFf3i8jm.js.map +1 -0
- package/dist/services/outbound-post.d.ts +50 -0
- package/dist/services/outbound-url-guard.d.ts +34 -5
- package/dist/services/routed-realtime-service.d.ts +5 -0
- package/dist/services/webhook-service.d.ts +66 -14
- package/dist/soft-delete-params-47yBGs3_.js +668 -0
- package/dist/soft-delete-params-47yBGs3_.js.map +1 -0
- package/dist/sql-export-routes-BNNuGGLP.js +240 -0
- package/dist/sql-export-routes-BNNuGGLP.js.map +1 -0
- package/dist/src-CatHFUym.js.map +1 -1
- package/dist/storage/GCSStorageController.d.ts +46 -2
- package/dist/storage/LocalStorageController.d.ts +67 -2
- package/dist/storage/S3StorageController.d.ts +37 -3
- package/dist/storage/image-transform.d.ts +5 -0
- package/dist/storage/maintenance-routes.d.ts +23 -0
- package/dist/storage/maintenance.d.ts +126 -0
- package/dist/storage/rendition-cache.d.ts +9 -11
- package/dist/storage/request-principals.d.ts +30 -0
- package/dist/storage/routes.d.ts +20 -0
- package/dist/storage/source-variables.d.ts +10 -0
- package/dist/storage/streams.d.ts +42 -0
- package/dist/storage/tus-handler.d.ts +46 -2
- package/dist/storage/types.d.ts +82 -7
- package/dist/storage/upload-validation.d.ts +13 -0
- package/dist/storage-resumable-VO-cMpse-C_YX71pO.js +125 -0
- package/dist/storage-resumable-VO-cMpse-C_YX71pO.js.map +1 -0
- package/dist/strings.es-BeZWAodn.js +256 -0
- package/dist/strings.es-BeZWAodn.js.map +1 -0
- package/dist/upload-validation-Dd-fnsbA.js +172 -0
- package/dist/upload-validation-Dd-fnsbA.js.map +1 -0
- package/dist/utils/byte-size.d.ts +10 -0
- package/dist/utils/compression.d.ts +6 -0
- package/dist/utils/load-failure.d.ts +14 -0
- package/package.json +5 -5
- package/dist/GCSStorageController-BSiP1c-f.js +0 -256
- package/dist/GCSStorageController-BSiP1c-f.js.map +0 -1
- package/dist/S3StorageController-CAwFRgjV.js +0 -276
- package/dist/S3StorageController-CAwFRgjV.js.map +0 -1
- package/dist/auth-DMLngxn_.js.map +0 -1
- package/dist/backup-CN0s50D2.js.map +0 -1
- package/dist/contract-routes-fz8i4pxs.js.map +0 -1
- package/dist/cron-loader-CwaANlOG.js.map +0 -1
- package/dist/cron-routes-Bc-SB0Se.js.map +0 -1
- package/dist/cron-scheduler-CYQgco86.js.map +0 -1
- package/dist/cron-store-D2Q9-Aco.js +0 -300
- package/dist/cron-store-D2Q9-Aco.js.map +0 -1
- package/dist/dynamic-import-X40pTZUQ.js +0 -23
- package/dist/dynamic-import-X40pTZUQ.js.map +0 -1
- package/dist/function-loader-D7o5Epjj.js.map +0 -1
- package/dist/function-routes-CaNG4waN.js.map +0 -1
- package/dist/jobs-DqYNfquG.js.map +0 -1
- package/dist/jwt-R6bSPMjk.js.map +0 -1
- package/dist/openapi-generator-DAq_XVDu.js.map +0 -1
- package/dist/query-parser-BgiKJKvc.js.map +0 -1
- package/dist/request-timeout-DgH7j8qO.js.map +0 -1
- package/dist/schema-editor-routes-oIyuWl3L.js.map +0 -1
- package/dist/soft-delete-params-BWPilMPF.js +0 -59
- package/dist/soft-delete-params-BWPilMPF.js.map +0 -1
- package/dist/src-I3aG1PcY.js +0 -6870
- package/dist/src-I3aG1PcY.js.map +0 -1
- package/dist/types-BfKcm9do.js +0 -39
- package/dist/types-BfKcm9do.js.map +0 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"contract-routes-02lASyJI.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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"}
|
|
@@ -7,7 +7,7 @@ import { type CronScheduler } from "./cron-scheduler.js";
|
|
|
7
7
|
* Routes:
|
|
8
8
|
* GET / → list all cron jobs
|
|
9
9
|
* GET /:id → get a single job's status
|
|
10
|
-
* POST /:id/trigger → manually trigger a job
|
|
10
|
+
* POST /:id/trigger → manually trigger a job (`?async=1`: 202 once it has started)
|
|
11
11
|
* GET /:id/logs → get execution logs for a job
|
|
12
12
|
* PUT /:id → update job (enable/disable, or `null` to follow the code)
|
|
13
13
|
*
|
|
@@ -30,9 +30,9 @@ export declare function parseCronExpression(expression: string, after: Date, tim
|
|
|
30
30
|
* The latest slot matching `expression` within the inclusive window
|
|
31
31
|
* `[from, to]`, or `undefined` when the expression has no slot in it.
|
|
32
32
|
*
|
|
33
|
-
* Walks backwards
|
|
34
|
-
*
|
|
35
|
-
*
|
|
33
|
+
* Walks backwards from `to`, so the first hit is already the answer — in the
|
|
34
|
+
* common case (a job that ran normally moments ago) that is a handful of
|
|
35
|
+
* steps, not a scan of the whole window.
|
|
36
36
|
*
|
|
37
37
|
* `to`'s own minute is included: an instance booting at 06:00:30 has *not* run
|
|
38
38
|
* the 06:00 slot — `parseCronExpression` already skipped past it to tomorrow —
|
|
@@ -43,6 +43,29 @@ export declare function parseCronExpression(expression: string, after: Date, tim
|
|
|
43
43
|
* ISO string down either path. The claim key depends on that.
|
|
44
44
|
*/
|
|
45
45
|
export declare function findMostRecentSlot(expression: string, from: Date, to: Date, timezone?: string): Date | undefined;
|
|
46
|
+
/**
|
|
47
|
+
* What one run keeps of its `ctx.log` lines: up to 500 lines or 64 KB,
|
|
48
|
+
* whichever comes first. The lines past that are counted, and one line at the
|
|
49
|
+
* end says how many there were; every line still reaches the server log.
|
|
50
|
+
*
|
|
51
|
+
* Uncapped, a job logging one line per item kept them all — in the 50-run ring
|
|
52
|
+
* per job, in a `cron_logs` row, and in every history read: a per-minute sync
|
|
53
|
+
* of 1,000 items held 4 MB per job in memory and wrote 115 MB a day.
|
|
54
|
+
*/
|
|
55
|
+
export declare const MAX_CAPTURED_LOG_LINES = 500;
|
|
56
|
+
export declare const MAX_CAPTURED_LOG_BYTES: number;
|
|
57
|
+
/**
|
|
58
|
+
* What {@link CronScheduler.startJob} answers: the run began at `startedAt`
|
|
59
|
+
* (its history entry will carry the same timestamp), or it did not start and
|
|
60
|
+
* `log` is the skip that says why.
|
|
61
|
+
*/
|
|
62
|
+
export type StartedCronRun = {
|
|
63
|
+
started: true;
|
|
64
|
+
startedAt: string;
|
|
65
|
+
} | {
|
|
66
|
+
started: false;
|
|
67
|
+
log: CronJobLogEntry;
|
|
68
|
+
};
|
|
46
69
|
/**
|
|
47
70
|
* Whether a log entry records a run that did not happen because one was
|
|
48
71
|
* already executing — on this process, or on another that holds the job's run
|
|
@@ -69,6 +92,10 @@ export declare class CronScheduler {
|
|
|
69
92
|
* ever clear the lease that run took.
|
|
70
93
|
*/
|
|
71
94
|
private readonly instanceLabel;
|
|
95
|
+
/** The hourly claim- and log-retention sweep, while the scheduler is started with a store. */
|
|
96
|
+
private claimSweepTimer?;
|
|
97
|
+
private sweepingClaims;
|
|
98
|
+
private sweepingLogs;
|
|
72
99
|
/**
|
|
73
100
|
* Set the server singleton to make it available to cron job handlers.
|
|
74
101
|
*
|
|
@@ -98,6 +125,14 @@ export declare class CronScheduler {
|
|
|
98
125
|
* Start the scheduler — begins ticking all enabled jobs.
|
|
99
126
|
*/
|
|
100
127
|
start(): void;
|
|
128
|
+
/** Delete slot claims past retention. Failures are logged; the next hour tries again. */
|
|
129
|
+
private sweepClaims;
|
|
130
|
+
/**
|
|
131
|
+
* Delete run history past retention, and count the runs not counted yet.
|
|
132
|
+
* Failures are logged; the next hour tries again, from where this one
|
|
133
|
+
* stopped — each batch it finished is committed.
|
|
134
|
+
*/
|
|
135
|
+
private sweepLogs;
|
|
101
136
|
/**
|
|
102
137
|
* Stop the scheduler: clear every timer, then wait for the runs executing
|
|
103
138
|
* right now — for at most `timeoutMs` when given.
|
|
@@ -204,6 +239,22 @@ export declare class CronScheduler {
|
|
|
204
239
|
* recognises it.
|
|
205
240
|
*/
|
|
206
241
|
triggerJob(id: string): Promise<CronJobLogEntry | undefined>;
|
|
242
|
+
/**
|
|
243
|
+
* Start a job now and answer as soon as the run has begun, not when it
|
|
244
|
+
* ends — the manual trigger's `?async=1`.
|
|
245
|
+
*
|
|
246
|
+
* The synchronous {@link triggerJob} holds its HTTP request open for the
|
|
247
|
+
* whole run, and a proxy in front of the server (many stop at 60 s) cuts
|
|
248
|
+
* a long job's answer while the run carries on. This resolves once the
|
|
249
|
+
* run lease is held and the handler is about to be called, so a run that
|
|
250
|
+
* cannot start — already executing here or on the lease holder — is still
|
|
251
|
+
* answered as the skip it is. The run itself is tracked like any other:
|
|
252
|
+
* `stop()` waits for it, and its outcome lands in the run history, where
|
|
253
|
+
* the entry carries `manual: true` and the `startedAt` returned here.
|
|
254
|
+
*
|
|
255
|
+
* Returns `undefined` if the job doesn't exist.
|
|
256
|
+
*/
|
|
257
|
+
startJob(id: string): Promise<StartedCronRun | undefined>;
|
|
207
258
|
/**
|
|
208
259
|
* Record a run that did not happen because the previous one had not
|
|
209
260
|
* finished.
|
|
@@ -267,6 +318,21 @@ export declare class CronScheduler {
|
|
|
267
318
|
* rest as a view for {@link toStatus}. Never throws.
|
|
268
319
|
*/
|
|
269
320
|
private readFleet;
|
|
321
|
+
/**
|
|
322
|
+
* The next slot of `job` after `now`, for a listing on a process whose
|
|
323
|
+
* scheduler is not started — the `api` role, which Studio's Cron panel
|
|
324
|
+
* polls every 15 seconds.
|
|
325
|
+
*
|
|
326
|
+
* Cached on the job. The key is the job's schedule and zone (a re-register
|
|
327
|
+
* replaces the job, and with it the cache); the entry holds while
|
|
328
|
+
* `computedAt <= now < slot`. That is the same answer, not an
|
|
329
|
+
* approximation: the search found no matching minute between
|
|
330
|
+
* `computedAt`'s next minute and `slot`, so from any later instant before
|
|
331
|
+
* `slot` it lands on `slot` again. A clock that moves back, or reaching the
|
|
332
|
+
* slot, recomputes. The cache holds a pure function of the schedule, so
|
|
333
|
+
* there is nothing user- or role-scoped in it.
|
|
334
|
+
*/
|
|
335
|
+
private listedNextRun;
|
|
270
336
|
/**
|
|
271
337
|
* Warn once at start when the process looks like it is running on a
|
|
272
338
|
* platform that freezes or evicts instances between requests, where the
|
|
@@ -28,7 +28,8 @@ export interface CronStore {
|
|
|
28
28
|
fetchLogs(jobId: string, limit?: number): Promise<CronJobLogEntry[]>;
|
|
29
29
|
/**
|
|
30
30
|
* Fetch aggregate stats for all jobs (totalRuns, totalFailures, lastRunAt).
|
|
31
|
-
* Used to seed in-memory counters on startup.
|
|
31
|
+
* Used to seed in-memory counters on startup. The SQL store reads them from
|
|
32
|
+
* the all-time counters on `cron_job_state`, not from the history.
|
|
32
33
|
*/
|
|
33
34
|
fetchJobStats(): Promise<Map<string, {
|
|
34
35
|
totalRuns: number;
|
|
@@ -86,11 +87,33 @@ export interface CronStore {
|
|
|
86
87
|
/** Release a lease taken with {@link tryAcquireRunLease} — only if `holder` still holds it. */
|
|
87
88
|
releaseRunLease?(jobId: string, holder: string): Promise<void>;
|
|
88
89
|
/**
|
|
89
|
-
* Run count, failure count and last run of each job
|
|
90
|
-
*
|
|
91
|
-
*
|
|
90
|
+
* Run count, failure count and last run of each job — every process's
|
|
91
|
+
* runs, where the scheduler's own counters hold only the ones it made. The
|
|
92
|
+
* SQL store keeps them as all-time counters on `cron_job_state`, so they
|
|
93
|
+
* outlive the history's retention.
|
|
92
94
|
*/
|
|
93
95
|
fetchRunSummaries?(jobIds: readonly string[]): Promise<Map<string, CronJobRunSummary>>;
|
|
96
|
+
/**
|
|
97
|
+
* Delete slot claims past retention — all but each job's most recent one —
|
|
98
|
+
* in batches. Resolves with how many went. The scheduler runs it on a
|
|
99
|
+
* timer while it is started; a store without it keeps every claim.
|
|
100
|
+
*/
|
|
101
|
+
sweepClaims?(): Promise<number>;
|
|
102
|
+
/**
|
|
103
|
+
* Delete run history past retention, and count into the job counters any
|
|
104
|
+
* run that is not counted yet — a history written before the counters
|
|
105
|
+
* existed, or by an older server during a rolling deploy. Both in batches.
|
|
106
|
+
* The scheduler runs it with {@link sweepClaims}; a store without it keeps
|
|
107
|
+
* every run.
|
|
108
|
+
*/
|
|
109
|
+
sweepLogs?(): Promise<CronLogSweep>;
|
|
110
|
+
}
|
|
111
|
+
/** What one {@link CronStore.sweepLogs} did. */
|
|
112
|
+
export interface CronLogSweep {
|
|
113
|
+
/** History rows deleted past retention. */
|
|
114
|
+
deleted: number;
|
|
115
|
+
/** Runs counted into the job counters that were not counted before. */
|
|
116
|
+
counted: number;
|
|
94
117
|
}
|
|
95
118
|
/** One job's row in `rebase.cron_job_state`, as the scheduler reads it. */
|
|
96
119
|
export interface CronJobPersistedState {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CronJobDefinition } from "@rebasepro/types";
|
|
1
|
+
import type { CronJobDefinition, RegisteredDatabase } from "@rebasepro/types";
|
|
2
2
|
/**
|
|
3
3
|
* Typed authoring helper for a cron job file. Identity at runtime —
|
|
4
4
|
* a plain default-exported {@link CronJobDefinition} works identically;
|
|
@@ -38,5 +38,9 @@ import type { CronJobDefinition } from "@rebasepro/types";
|
|
|
38
38
|
* },
|
|
39
39
|
* });
|
|
40
40
|
* ```
|
|
41
|
+
*
|
|
42
|
+
* `ctx.rebase` is typed from the `Database` the project registered on
|
|
43
|
+
* `Register` (see the typed server data guide), and untyped when it registered
|
|
44
|
+
* none. `defineCron<Database>({…})` types one job without the registration.
|
|
41
45
|
*/
|
|
42
|
-
export declare function defineCron(definition: CronJobDefinition): CronJobDefinition
|
|
46
|
+
export declare function defineCron<DB = RegisteredDatabase>(definition: CronJobDefinition<DB>): CronJobDefinition<DB>;
|
package/dist/cron/index.d.ts
CHANGED
|
@@ -5,4 +5,4 @@ export { CronScheduler, validateCronExpression } from "./cron-scheduler.js";
|
|
|
5
5
|
export { createCronRoutes } from "./cron-routes.js";
|
|
6
6
|
export { createCronStore } from "./cron-store.js";
|
|
7
7
|
export { detectFreezableRuntime, buildScaleToZeroWarning, CRON_ALWAYS_ON_ENV } from "./scale-to-zero.js";
|
|
8
|
-
export type { CronStore, CronJobPersistedState, CronRunLease, CronJobRunSummary } from "./cron-store.js";
|
|
8
|
+
export type { CronStore, CronJobPersistedState, CronRunLease, CronJobRunSummary, CronLogSweep } from "./cron-store.js";
|
|
@@ -5,7 +5,7 @@ __rebaseCreateRequire(import.meta.url);
|
|
|
5
5
|
import { n as __exportAll } from "./rolldown-runtime-dW7B1o5h.js";
|
|
6
6
|
import { x as declareCron } from "./src-CatHFUym.js";
|
|
7
7
|
import { r as logger } from "./logger-D-S-hO5e.js";
|
|
8
|
-
import {
|
|
8
|
+
import { n as describeLoadFailure, r as nativeDynamicImport, t as SYNTAX_ERROR_ADVICE } from "./load-failure-UvzZgGXT.js";
|
|
9
9
|
import * as fs$1 from "fs";
|
|
10
10
|
import * as path$1 from "path";
|
|
11
11
|
import { pathToFileURL } from "url";
|
|
@@ -86,11 +86,15 @@ async function loadCronJobsWithDiagnostics(directory, importModule = nativeDynam
|
|
|
86
86
|
logger.info(`⏰ Loaded cron job: ${id} (${definition.schedule}${definition.timezone ? ` ${definition.timezone}` : " in the process's local time"})`);
|
|
87
87
|
} catch (err) {
|
|
88
88
|
const message = err instanceof Error ? err.message : String(err);
|
|
89
|
-
problems.push(
|
|
89
|
+
problems.push(describeLoadFailure(file, err));
|
|
90
90
|
logger.debug(`[cron] Failed to load ${file}: ${message}`);
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
|
-
if (problems.length > 0)
|
|
93
|
+
if (problems.length > 0) {
|
|
94
|
+
const syntax = problems.some((p) => p.includes("(syntax error"));
|
|
95
|
+
const other = problems.some((p) => !p.includes("(syntax error"));
|
|
96
|
+
logger.warn(`[cron] ${problems.length} cron file(s) were skipped and will NOT be scheduled:\n` + problems.map((p) => ` - ${p}`).join("\n") + "\n" + [...syntax ? [SYNTAX_ERROR_ADVICE] : [], ...other ? [" Fix these or author them with `defineCron(...)` for a typed, compile-checked contract."] : []].join("\n"));
|
|
97
|
+
}
|
|
94
98
|
return {
|
|
95
99
|
jobs,
|
|
96
100
|
problems
|
|
@@ -115,4 +119,4 @@ function cronResourceOptions(definition) {
|
|
|
115
119
|
//#endregion
|
|
116
120
|
export { loadCronJobsFromDirectory as n, loadCronJobsWithDiagnostics as r, cron_loader_exports as t };
|
|
117
121
|
|
|
118
|
-
//# sourceMappingURL=cron-loader-
|
|
122
|
+
//# sourceMappingURL=cron-loader-D7OmleAb.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cron-loader-D7OmleAb.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\";\n\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 * Auto-discover cron job files from a directory.\n *\n * Each file should default-export a `CronJobDefinition`.\n * The filename (without extension) becomes the job ID:\n * `crons/cleanup-sessions.ts` → id = \"cleanup-sessions\"\n *\n * Follows the same discovery pattern as `loadFunctionsFromDirectory` — see\n * {@link loadCronJobsWithDiagnostics} for what happens to the files it cannot\n * load.\n */\nexport async function loadCronJobsFromDirectory(\n directory: string,\n importModule: ModuleImporter = nativeDynamicImport\n): Promise<LoadedCronJob[]> {\n return (await loadCronJobsWithDiagnostics(directory, importModule)).jobs;\n}\n\n/**\n * {@link loadCronJobsFromDirectory}, plus the list of files that were skipped.\n *\n * A job that does not load does not run, and until now the only trace was 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. A cron file is written once and then trusted\n * for months, so \"it silently never ran\" is the failure mode this surface has,\n * and a log line is not a surface.\n *\n * Never throws, for the same reason `loadFunctionsWithDiagnostics` does not: one\n * malformed file must not take the server down, and the other jobs in the\n * directory are still worth scheduling. The difference is that the skips are now\n * counted, summarised in one place, and answered by `GET /api/cron` — so the\n * question \"why did nothing run?\" has an answer that does not require log\n * access.\n */\nexport async function loadCronJobsWithDiagnostics(\n directory: string,\n importModule: ModuleImporter = nativeDynamicImport\n): Promise<LoadedCronJobs> {\n const jobs: LoadedCronJob[] = [];\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 { jobs, 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\" || 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 // Spread first, then normalise. Rebuilding this object field by\n // field dropped `catchUpWindowSeconds` — and since this loader\n // is the only production caller of `registerJobs`, catch-up\n // never ran for any job authored the documented way: 64 lines\n // of docblock, a docs section, a unit suite and a Postgres e2e\n // all describing a feature that was switched off in the one\n // path that matters. Every existing test built `LoadedCronJob`\n // literals directly, so none of them went through here.\n //\n // The shape is the bug, not the missing line: a field added to\n // `CronJobDefinition` tomorrow would be dropped the same way.\n const definition: CronJobDefinition = {\n ...(def as Partial<CronJobDefinition>),\n schedule: def.schedule as string,\n name: (def.name as string) || id,\n enabled: def.enabled !== false,\n // `??`, not `||`: zero and NaN are values the author\n // wrote, and the scheduler refuses both by name (they end\n // up under `rejected`). `|| 300` swallowed them, so a job\n // written with `0` meaning \"no limit\" ran with five\n // minutes and was listed as healthy. `Infinity` is \"none\".\n timeoutSeconds: (def.timeoutSeconds as number | undefined) ?? 300,\n handler: def.handler as CronJobDefinition[\"handler\"]\n };\n\n // The declaration, keyed by the same id the scheduler, the\n // routes and the Studio use — the filename. `defineCron` cannot\n // do this: it never sees the filename, and `name` is a label.\n // The derive step runs this same loader, so the graph a host\n // reads and the schedule this process runs come from one place.\n // From the raw export, not the normalised definition: the\n // defaults filled in above (`enabled: true`, `timeoutSeconds:\n // 300`) are this process's, and a graph that records them for\n // every cron would say something the author never wrote.\n declareCron(id, cronResourceOptions(def as unknown as CronJobDefinition));\n\n jobs.push({ id,\ndefinition });\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 { jobs, 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-
|
|
6
|
+
import { r as isAlreadyExecutingSkip } from "./cron-scheduler-C2vbETMo.js";
|
|
7
|
+
import { a as resolveListLimitParam } from "./query-parser-DK9eMkb9.js";
|
|
7
8
|
import { r as logger } from "./logger-D-S-hO5e.js";
|
|
8
9
|
import { r as errorHandler, t as ApiError } from "./errors-D6_y86c5.js";
|
|
9
|
-
import { a as resolveListLimitParam } from "./query-parser-BgiKJKvc.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 });
|
|
@@ -18,12 +18,24 @@ function actorOf(c) {
|
|
|
18
18
|
return apiKey ? `api-key:${apiKey.id}` : void 0;
|
|
19
19
|
}
|
|
20
20
|
/**
|
|
21
|
+
* The manual trigger's `?async` switch. Absent means synchronous, as it always
|
|
22
|
+
* was; anything other than a plain yes or no is refused rather than read as
|
|
23
|
+
* one, so `?async=yes-please` does not quietly hold the request open.
|
|
24
|
+
*/
|
|
25
|
+
function readAsyncFlag(raw) {
|
|
26
|
+
if (raw === void 0) return false;
|
|
27
|
+
const value = raw.trim().toLowerCase();
|
|
28
|
+
if (value === "1" || value === "true") return true;
|
|
29
|
+
if (value === "" || value === "0" || value === "false") return false;
|
|
30
|
+
throw ApiError.badRequest(`?async must be 1, true, 0 or false — got "${raw}"`);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
21
33
|
* Create admin REST routes for managing cron jobs.
|
|
22
34
|
*
|
|
23
35
|
* Routes:
|
|
24
36
|
* GET / → list all cron jobs
|
|
25
37
|
* GET /:id → get a single job's status
|
|
26
|
-
* POST /:id/trigger → manually trigger a job
|
|
38
|
+
* POST /:id/trigger → manually trigger a job (`?async=1`: 202 once it has started)
|
|
27
39
|
* GET /:id/logs → get execution logs for a job
|
|
28
40
|
* PUT /:id → update job (enable/disable, or `null` to follow the code)
|
|
29
41
|
*
|
|
@@ -56,7 +68,18 @@ function createCronRoutes(scheduler, skipped = 0) {
|
|
|
56
68
|
});
|
|
57
69
|
router.post("/:id/trigger", async (c) => {
|
|
58
70
|
const id = c.req.param("id");
|
|
71
|
+
const asyncRun = readAsyncFlag(c.req.query("async"));
|
|
59
72
|
if (!scheduler.getJob(id)) throw ApiError.notFound(`Cron job "${id}" not found`);
|
|
73
|
+
if (asyncRun) {
|
|
74
|
+
const outcome = await scheduler.startJob(id);
|
|
75
|
+
if (!outcome) throw ApiError.notFound(`Cron job "${id}" not found`);
|
|
76
|
+
if (!outcome.started) throw ApiError.conflict(`Cron job "${id}" is already executing — try again when that run has finished`, "CRON_JOB_ALREADY_EXECUTING", { log: outcome.log });
|
|
77
|
+
return c.json({
|
|
78
|
+
accepted: true,
|
|
79
|
+
startedAt: outcome.startedAt,
|
|
80
|
+
job: await scheduler.fetchJob(id)
|
|
81
|
+
}, 202);
|
|
82
|
+
}
|
|
60
83
|
const log = await scheduler.triggerJob(id);
|
|
61
84
|
if (log && isAlreadyExecutingSkip(log)) throw ApiError.conflict(`Cron job "${id}" is already executing — try again when that run has finished`, "CRON_JOB_ALREADY_EXECUTING", { log });
|
|
62
85
|
return c.json({
|
|
@@ -93,4 +116,4 @@ function createCronRoutes(scheduler, skipped = 0) {
|
|
|
93
116
|
//#endregion
|
|
94
117
|
export { cron_routes_exports as n, createCronRoutes as t };
|
|
95
118
|
|
|
96
|
-
//# sourceMappingURL=cron-routes-
|
|
119
|
+
//# sourceMappingURL=cron-routes-CBfiaqEL.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cron-routes-CBfiaqEL.js","names":[],"sources":["../src/cron/cron-routes.ts"],"sourcesContent":["import { Hono } from \"hono\";\nimport type { Context } from \"hono\";\nimport type { HonoEnv } from \"../api/types\";\nimport { isAlreadyExecutingSkip, type CronScheduler } from \"./cron-scheduler\";\nimport { ApiError, errorHandler } from \"../api/errors\";\nimport { resolveListLimitParam } from \"../api/rest/query-parser\";\nimport { logger } from \"../utils/logger.js\";\n\n/** Who made a change, as `cron_job_state.updated_by` records it. */\nfunction actorOf(c: Context<HonoEnv>): string | undefined {\n const user = c.get(\"user\");\n if (typeof user === \"object\" && user !== null && typeof user.uid === \"string\") return user.uid;\n const apiKey = c.get(\"apiKey\");\n return apiKey ? `api-key:${apiKey.id}` : undefined;\n}\n\n/**\n * The manual trigger's `?async` switch. Absent means synchronous, as it always\n * was; anything other than a plain yes or no is refused rather than read as\n * one, so `?async=yes-please` does not quietly hold the request open.\n */\nfunction readAsyncFlag(raw: string | undefined): boolean {\n if (raw === undefined) return false;\n const value = raw.trim().toLowerCase();\n if (value === \"1\" || value === \"true\") return true;\n if (value === \"\" || value === \"0\" || value === \"false\") return false;\n throw ApiError.badRequest(`?async must be 1, true, 0 or false — got \"${raw}\"`);\n}\n\n/**\n * Create admin REST routes for managing cron jobs.\n *\n * Routes:\n * GET / → list all cron jobs\n * GET /:id → get a single job's status\n * POST /:id/trigger → manually trigger a job (`?async=1`: 202 once it has started)\n * GET /:id/logs → get execution logs for a job\n * PUT /:id → update job (enable/disable, or `null` to follow the code)\n *\n * Every answer about a job's state is read from the store, not from this\n * process's memory: the process serving these routes is often not the one\n * running the jobs (the `api` role never starts its scheduler), and is only\n * ever one replica of several.\n */\nexport function createCronRoutes(scheduler: CronScheduler, skipped = 0): Hono<HonoEnv> {\n const router = new Hono<HonoEnv>();\n // Hono's onError does NOT propagate from parent to child routers, so this\n // child router registers its own handler to format thrown ApiErrors.\n router.onError(errorHandler);\n\n // List all jobs\n router.get(\"/\", async (c) => {\n const jobs = await scheduler.fetchJobs();\n // A file that failed to load is not a job, so it appears nowhere in\n // this list — and \"my job is missing\" and \"my job is not scheduled\"\n // look identical from here. Say how many were dropped, as the\n // functions listing does, so the Studio panel and anyone with curl can\n // see it without boot-log access.\n //\n // Two ways to be dropped, counted together and reported apart. A file\n // the loader could not read has only a count: the failure happened\n // before there was a job to name. A schedule the scheduler refused has a\n // name and a reason — most often \"Expected 5 fields, got 6\", from an\n // expression copied out of a tool that supports seconds — and quoting it\n // here turns a job that silently never fires into a one-line fix.\n const rejected = scheduler.listRejectedJobs();\n const total = skipped + rejected.length;\n return c.json({\n jobs,\n ...(total > 0 && {\n skipped: total,\n ...(rejected.length > 0 && { rejected }),\n note: [\n skipped > 0 ? `${skipped} cron file(s) failed to load` : undefined,\n // Not \"an invalid schedule\": a job is also refused for an\n // unknown timezone or a timeout that is not a positive\n // number, and `rejected` says which.\n rejected.length > 0 ? `${rejected.length} job(s) were refused` : undefined\n ].filter(Boolean).join(\" and \") +\n \" — NOT scheduled. \" +\n (rejected.length > 0\n ? \"See `rejected` for the reason; \"\n : \"\") +\n \"the server log has the rest.\"\n })\n });\n });\n\n // Get single job\n router.get(\"/:id\", async (c) => {\n const id = c.req.param(\"id\");\n const job = await scheduler.fetchJob(id);\n if (!job) {\n throw ApiError.notFound(`Cron job \"${id}\" not found`);\n }\n return c.json({ job });\n });\n\n // Trigger a job manually\n router.post(\"/:id/trigger\", async (c) => {\n const id = c.req.param(\"id\");\n const asyncRun = readAsyncFlag(c.req.query(\"async\"));\n const job = scheduler.getJob(id);\n if (!job) {\n throw ApiError.notFound(`Cron job \"${id}\" not found`);\n }\n\n // `?async=1`: answer once the run has started rather than when it\n // ends. A long job's synchronous answer is cut by any proxy with a\n // shorter timeout than the job (60 s is common) while the run carries\n // on; this one is not. The outcome is in `GET /:id/logs`, as the entry\n // with `manual: true` and this `startedAt`.\n if (asyncRun) {\n const outcome = await scheduler.startJob(id);\n if (!outcome) throw ApiError.notFound(`Cron job \"${id}\" not found`);\n if (!outcome.started) {\n throw ApiError.conflict(\n `Cron job \"${id}\" is already executing — try again when that run has finished`,\n \"CRON_JOB_ALREADY_EXECUTING\",\n { log: outcome.log }\n );\n }\n return c.json({\n accepted: true,\n startedAt: outcome.startedAt,\n job: await scheduler.fetchJob(id)\n }, 202);\n }\n\n const log = await scheduler.triggerJob(id);\n // Nothing ran, so this is not a success: the job is executing already —\n // on this process, or on whichever one holds its run lease (the worker,\n // seen from the api role). The skip is in the run history either way;\n // `details.log` carries it, and its line names the process.\n if (log && isAlreadyExecutingSkip(log)) {\n throw ApiError.conflict(\n `Cron job \"${id}\" is already executing — try again when that run has finished`,\n \"CRON_JOB_ALREADY_EXECUTING\",\n { log }\n );\n }\n return c.json({ log,\njob: await scheduler.fetchJob(id) });\n });\n\n // Get job logs\n router.get(\"/:id/logs\", async (c) => {\n const id = c.req.param(\"id\");\n // Validated, not `parseInt`-ed. `?limit=abc` used to reach the store as\n // `NaN`, where Postgres refused `LIMIT NaN`, the store swallowed the\n // error and returned `[]` — a 200 with an empty list, which reads as\n // \"this job has never run\". The data plane answers 400 for the same\n // input; so does this now.\n const limit = resolveListLimitParam(c.req.query(\"limit\") ?? null, { defaultLimit: 50 });\n\n const job = scheduler.getJob(id);\n if (!job) {\n throw ApiError.notFound(`Cron job \"${id}\" not found`);\n }\n\n const logs = await scheduler.getJobLogsFromDb(id, limit).catch((err: unknown) => {\n // Not an empty 200: Studio shows that as \"No executions yet\".\n logger.error(`[cron] Could not read the run history of \"${id}\"`, { error: err });\n throw ApiError.serviceUnavailable(\n `The run history of cron job \"${id}\" could not be read. The server log has the reason.`\n );\n });\n return c.json({ logs });\n });\n\n // Enable/disable a job — for every process, and across redeploys\n router.put(\"/:id\", async (c) => {\n const id = c.req.param(\"id\");\n const body: unknown = await c.req.json().catch(() => ({}));\n const enabled = typeof body === \"object\" && body !== null && \"enabled\" in body ? body.enabled : undefined;\n\n if (typeof enabled !== \"boolean\" && enabled !== null) {\n throw ApiError.badRequest(\n \"Missing 'enabled' in body: true or false to override the job's code, or null to follow it again\"\n );\n }\n\n if (!scheduler.getJob(id)) {\n throw ApiError.notFound(`Cron job \"${id}\" not found`);\n }\n\n try {\n await scheduler.persistJobEnabled(id, enabled, actorOf(c));\n } catch (err) {\n logger.error(`[cron] Could not save the enabled state of \"${id}\"`, { error: err });\n throw ApiError.serviceUnavailable(\n `The change to cron job \"${id}\" could not be saved, so no process was changed. ` +\n \"The server log has the reason.\"\n );\n }\n\n return c.json({ job: await scheduler.fetchJob(id) });\n });\n\n return router;\n}\n"],"mappings":"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"}
|