@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
|
@@ -1,300 +0,0 @@
|
|
|
1
|
-
import { createRequire as __rebaseCreateRequire } from "module";
|
|
2
|
-
import __rebaseProcess from "process";
|
|
3
|
-
globalThis.process ??= __rebaseProcess;
|
|
4
|
-
__rebaseCreateRequire(import.meta.url);
|
|
5
|
-
import { n as __exportAll } from "./rolldown-runtime-dW7B1o5h.js";
|
|
6
|
-
import { P as revokeInternalTableSql } from "./src-I3aG1PcY.js";
|
|
7
|
-
import "./src-CatHFUym.js";
|
|
8
|
-
import { t as isSQLAdmin } from "./backend-DTAOsLQc.js";
|
|
9
|
-
import { r as logger } from "./logger-D-S-hO5e.js";
|
|
10
|
-
import { n as createDdlBootstrapper, r as hasInCauseChain } from "./ddl-bootstrap-BaqMSa4Y.js";
|
|
11
|
-
//#region src/cron/cron-store.ts
|
|
12
|
-
var cron_store_exports = /* @__PURE__ */ __exportAll({ createCronStore: () => createCronStore });
|
|
13
|
-
var TABLE = "rebase.cron_logs";
|
|
14
|
-
var CLAIMS_TABLE = "rebase.cron_claims";
|
|
15
|
-
var STATE_TABLE = "rebase.cron_job_state";
|
|
16
|
-
/** `$1, $2, …` for `count` values, starting at `$first`. */
|
|
17
|
-
function placeholders(count, first = 1) {
|
|
18
|
-
return Array.from({ length: count }, (_, i) => `$${first + i}`).join(", ");
|
|
19
|
-
}
|
|
20
|
-
/**
|
|
21
|
-
* Claims older than this are garbage-collected on startup — all but each
|
|
22
|
-
* job's most recent one, which is kept however old it is; see the sweep in
|
|
23
|
-
* `ensureTable`.
|
|
24
|
-
*/
|
|
25
|
-
var CLAIM_RETENTION_DAYS = 7;
|
|
26
|
-
/**
|
|
27
|
-
* How far ahead a claim may legitimately sit. Slots are claimed as they fire,
|
|
28
|
-
* so anything beyond this is a clock-skewed peer at best and a stranded claim
|
|
29
|
-
* at worst; see the sweep in `ensureTable`.
|
|
30
|
-
*/
|
|
31
|
-
var FUTURE_CLAIM_SKEW_MINUTES = 2;
|
|
32
|
-
/**
|
|
33
|
-
* Detect a unique-constraint violation anywhere in an error's cause chain.
|
|
34
|
-
* Match the SQLSTATE code, never message text. Also covers SQLite
|
|
35
|
-
* ("UNIQUE constraint failed") and MySQL (ER_DUP_ENTRY 1062) for future SQL
|
|
36
|
-
* drivers.
|
|
37
|
-
*
|
|
38
|
-
* Distinct from `isConcurrentDdlRace` in `boot/ddl-bootstrap.ts`, which shares
|
|
39
|
-
* the 23505 code but asks a different question — that one is about a losing
|
|
40
|
-
* `CREATE`, this one is about a losing claim, and only the latter means
|
|
41
|
-
* "another instance already has this slot".
|
|
42
|
-
*/
|
|
43
|
-
function isUniqueViolation(err) {
|
|
44
|
-
return hasInCauseChain(err, (e) => e.code === "23505" || e.errno === 1062 || typeof e.message === "string" && e.message.includes("UNIQUE constraint failed"));
|
|
45
|
-
}
|
|
46
|
-
function createCronStore(driver) {
|
|
47
|
-
const admin = driver.admin;
|
|
48
|
-
if (!isSQLAdmin(admin)) {
|
|
49
|
-
logger.warn("⚠️ [cron-store] DataDriver does not support SQL admin — cron logs will not be persisted.");
|
|
50
|
-
return;
|
|
51
|
-
}
|
|
52
|
-
const exec = (sqlText, options) => admin.executeSql(sqlText, options?.params ? { params: options.params } : void 0);
|
|
53
|
-
const ddl = createDdlBootstrapper(exec, "cron-store");
|
|
54
|
-
return {
|
|
55
|
-
async ensureTable() {
|
|
56
|
-
await ddl.ensureObject("Creating schema rebase", "CREATE SCHEMA IF NOT EXISTS rebase");
|
|
57
|
-
await ddl.ensureObject(`Creating ${TABLE}`, `
|
|
58
|
-
CREATE TABLE IF NOT EXISTS ${TABLE} (
|
|
59
|
-
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
60
|
-
job_id TEXT NOT NULL,
|
|
61
|
-
started_at TIMESTAMPTZ NOT NULL,
|
|
62
|
-
finished_at TIMESTAMPTZ NOT NULL,
|
|
63
|
-
duration_ms INTEGER NOT NULL,
|
|
64
|
-
success BOOLEAN NOT NULL DEFAULT true,
|
|
65
|
-
error TEXT,
|
|
66
|
-
result JSONB,
|
|
67
|
-
logs JSONB,
|
|
68
|
-
manual BOOLEAN NOT NULL DEFAULT false
|
|
69
|
-
)
|
|
70
|
-
`);
|
|
71
|
-
await ddl.ensureObject("Creating idx_cron_logs_job", `
|
|
72
|
-
CREATE INDEX IF NOT EXISTS idx_cron_logs_job
|
|
73
|
-
ON ${TABLE}(job_id, started_at DESC)
|
|
74
|
-
`);
|
|
75
|
-
await ddl.ensureObject("Creating idx_cron_logs_job_failures", `
|
|
76
|
-
CREATE INDEX IF NOT EXISTS idx_cron_logs_job_failures
|
|
77
|
-
ON ${TABLE}(job_id) WHERE NOT success
|
|
78
|
-
`);
|
|
79
|
-
await ddl.ensureObject(`Creating ${CLAIMS_TABLE}`, `
|
|
80
|
-
CREATE TABLE IF NOT EXISTS ${CLAIMS_TABLE} (
|
|
81
|
-
job_id TEXT NOT NULL,
|
|
82
|
-
slot TIMESTAMPTZ NOT NULL,
|
|
83
|
-
claimed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
84
|
-
PRIMARY KEY (job_id, slot)
|
|
85
|
-
)
|
|
86
|
-
`);
|
|
87
|
-
await ddl.ensureObject(`Creating ${STATE_TABLE}`, `
|
|
88
|
-
CREATE TABLE IF NOT EXISTS ${STATE_TABLE} (
|
|
89
|
-
job_id TEXT PRIMARY KEY,
|
|
90
|
-
enabled BOOLEAN,
|
|
91
|
-
updated_at TIMESTAMPTZ,
|
|
92
|
-
updated_by TEXT,
|
|
93
|
-
running_until TIMESTAMPTZ,
|
|
94
|
-
running_by TEXT
|
|
95
|
-
)
|
|
96
|
-
`);
|
|
97
|
-
const [logsReady, claimsReady, stateReady] = await Promise.all([
|
|
98
|
-
ddl.isReadable(TABLE),
|
|
99
|
-
ddl.isReadable(CLAIMS_TABLE),
|
|
100
|
-
ddl.isReadable(STATE_TABLE)
|
|
101
|
-
]);
|
|
102
|
-
if (claimsReady) {
|
|
103
|
-
await ddl.step("Future-slot claim sweep", async () => {
|
|
104
|
-
const stranded = await exec(`DELETE FROM ${CLAIMS_TABLE}
|
|
105
|
-
WHERE slot > now() + make_interval(mins => $1)
|
|
106
|
-
RETURNING job_id, slot`, { params: [FUTURE_CLAIM_SKEW_MINUTES] });
|
|
107
|
-
for (const row of stranded ?? []) logger.warn(`[cron-store] Released a claim on the future slot ${new Date(row.slot).toISOString()} for "${row.job_id}" — it was claimed by a timer that fired early, and would otherwise have skipped that run`);
|
|
108
|
-
});
|
|
109
|
-
await ddl.step("Claim retention sweep", async () => {
|
|
110
|
-
await exec(`DELETE FROM ${CLAIMS_TABLE} AS c
|
|
111
|
-
WHERE c.claimed_at < now() - make_interval(days => $1)
|
|
112
|
-
AND c.slot < (
|
|
113
|
-
SELECT max(latest.slot) FROM ${CLAIMS_TABLE} AS latest
|
|
114
|
-
WHERE latest.job_id = c.job_id
|
|
115
|
-
)`, { params: [CLAIM_RETENTION_DAYS] });
|
|
116
|
-
});
|
|
117
|
-
}
|
|
118
|
-
if (logsReady) await ddl.step("Revoking end-user access to cron_logs", () => exec(revokeInternalTableSql("rebase", "cron_logs")));
|
|
119
|
-
if (claimsReady) await ddl.step("Revoking end-user access to cron_claims", () => exec(revokeInternalTableSql("rebase", "cron_claims")));
|
|
120
|
-
if (stateReady) await ddl.step("Revoking end-user access to cron_job_state", () => exec(revokeInternalTableSql("rebase", "cron_job_state")));
|
|
121
|
-
if (logsReady && claimsReady && stateReady) {
|
|
122
|
-
logger.info("✅ Cron logs table ready");
|
|
123
|
-
return;
|
|
124
|
-
}
|
|
125
|
-
if (!claimsReady) logger.error(`❌ [cron-store] ${CLAIMS_TABLE} is unavailable — scheduled runs cannot be coordinated. With more than one app instance, every instance will now run every job on every tick.`);
|
|
126
|
-
if (!logsReady) logger.warn(`⚠️ [cron-store] ${TABLE} is unavailable — cron run history will not be persisted.`);
|
|
127
|
-
if (!stateReady) logger.warn(`⚠️ [cron-store] ${STATE_TABLE} is unavailable — pausing a job reaches only the process that served the request, and jobs run as their code declares on every other one. A manual trigger is not kept off a run another process is making.`);
|
|
128
|
-
},
|
|
129
|
-
async insertLog(entry) {
|
|
130
|
-
try {
|
|
131
|
-
const resultJson = entry.result !== void 0 ? JSON.stringify(entry.result) : null;
|
|
132
|
-
const logsJson = entry.logs.length > 0 ? JSON.stringify(entry.logs) : null;
|
|
133
|
-
await exec(`INSERT INTO ${TABLE} (job_id, started_at, finished_at, duration_ms, success, error, result, logs, manual)
|
|
134
|
-
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8::jsonb, $9)`, { params: [
|
|
135
|
-
entry.jobId,
|
|
136
|
-
entry.startedAt,
|
|
137
|
-
entry.finishedAt,
|
|
138
|
-
entry.durationMs,
|
|
139
|
-
entry.success,
|
|
140
|
-
entry.error || null,
|
|
141
|
-
resultJson,
|
|
142
|
-
logsJson,
|
|
143
|
-
entry.manual
|
|
144
|
-
] });
|
|
145
|
-
} catch (err) {
|
|
146
|
-
logger.error(`[cron-store] Failed to persist log for "${entry.jobId}"`, { error: err });
|
|
147
|
-
}
|
|
148
|
-
},
|
|
149
|
-
async fetchLogs(jobId, limit = 50) {
|
|
150
|
-
return (await exec(`SELECT job_id, started_at, finished_at, duration_ms, success, error, result, logs, manual
|
|
151
|
-
FROM ${TABLE}
|
|
152
|
-
WHERE job_id = $1
|
|
153
|
-
ORDER BY started_at DESC
|
|
154
|
-
LIMIT $2`, { params: [jobId, limit] })).map(rowToLogEntry);
|
|
155
|
-
},
|
|
156
|
-
async fetchJobStats() {
|
|
157
|
-
const stats = /* @__PURE__ */ new Map();
|
|
158
|
-
try {
|
|
159
|
-
const rows = await exec(`
|
|
160
|
-
SELECT
|
|
161
|
-
job_id,
|
|
162
|
-
COUNT(*)::int AS total_runs,
|
|
163
|
-
COUNT(*) FILTER (WHERE NOT success)::int AS total_failures,
|
|
164
|
-
MAX(started_at) AS last_run_at
|
|
165
|
-
FROM ${TABLE}
|
|
166
|
-
GROUP BY job_id
|
|
167
|
-
`);
|
|
168
|
-
for (const row of rows) stats.set(row.job_id, {
|
|
169
|
-
totalRuns: row.total_runs,
|
|
170
|
-
totalFailures: row.total_failures,
|
|
171
|
-
lastRunAt: row.last_run_at ? new Date(row.last_run_at).toISOString() : void 0
|
|
172
|
-
});
|
|
173
|
-
} catch (err) {
|
|
174
|
-
logger.error("[cron-store] Failed to fetch job stats", { error: err });
|
|
175
|
-
}
|
|
176
|
-
return stats;
|
|
177
|
-
},
|
|
178
|
-
async tryClaimRun(jobId, slot) {
|
|
179
|
-
try {
|
|
180
|
-
return (await exec(`INSERT INTO ${CLAIMS_TABLE} (job_id, slot)
|
|
181
|
-
VALUES ($1, $2)
|
|
182
|
-
ON CONFLICT (job_id, slot) DO NOTHING
|
|
183
|
-
RETURNING job_id`, { params: [jobId, slot] })).length > 0;
|
|
184
|
-
} catch (err) {
|
|
185
|
-
if (isUniqueViolation(err)) return false;
|
|
186
|
-
throw err;
|
|
187
|
-
}
|
|
188
|
-
},
|
|
189
|
-
async saveEnabledOverride(jobId, enabled, updatedBy) {
|
|
190
|
-
await exec(`INSERT INTO ${STATE_TABLE} (job_id, enabled, updated_at, updated_by)
|
|
191
|
-
VALUES ($1, $2::boolean, now(), $3)
|
|
192
|
-
ON CONFLICT (job_id) DO UPDATE
|
|
193
|
-
SET enabled = EXCLUDED.enabled, updated_at = now(), updated_by = EXCLUDED.updated_by`, { params: [
|
|
194
|
-
jobId,
|
|
195
|
-
enabled,
|
|
196
|
-
updatedBy ?? null
|
|
197
|
-
] });
|
|
198
|
-
},
|
|
199
|
-
async fetchJobStates(jobIds) {
|
|
200
|
-
const states = /* @__PURE__ */ new Map();
|
|
201
|
-
if (jobIds.length === 0) return states;
|
|
202
|
-
const rows = await exec(`SELECT job_id, enabled,
|
|
203
|
-
CASE WHEN running_until > now() THEN running_by END AS running_by
|
|
204
|
-
FROM ${STATE_TABLE}
|
|
205
|
-
WHERE job_id IN (${placeholders(jobIds.length)})`, { params: [...jobIds] });
|
|
206
|
-
for (const row of rows ?? []) states.set(String(row.job_id), {
|
|
207
|
-
enabled: typeof row.enabled === "boolean" ? row.enabled : null,
|
|
208
|
-
runningBy: typeof row.running_by === "string" ? row.running_by : void 0
|
|
209
|
-
});
|
|
210
|
-
return states;
|
|
211
|
-
},
|
|
212
|
-
async tryAcquireRunLease(jobId, holder, ttlSeconds) {
|
|
213
|
-
const row = (await exec(`WITH attempt AS (
|
|
214
|
-
INSERT INTO ${STATE_TABLE} AS s (job_id, running_until, running_by)
|
|
215
|
-
VALUES ($1, now() + make_interval(secs => $2::double precision), $3)
|
|
216
|
-
ON CONFLICT (job_id) DO UPDATE
|
|
217
|
-
SET running_until = EXCLUDED.running_until, running_by = EXCLUDED.running_by
|
|
218
|
-
WHERE s.running_until IS NULL OR s.running_until <= now()
|
|
219
|
-
RETURNING s.job_id
|
|
220
|
-
)
|
|
221
|
-
SELECT EXISTS (SELECT 1 FROM attempt) AS acquired,
|
|
222
|
-
(SELECT running_by FROM ${STATE_TABLE}
|
|
223
|
-
WHERE job_id = $1 AND running_until > now()) AS holder`, { params: [
|
|
224
|
-
jobId,
|
|
225
|
-
ttlSeconds,
|
|
226
|
-
holder
|
|
227
|
-
] }))?.[0];
|
|
228
|
-
if (!row) throw new Error(`The run lease for "${jobId}" got no answer from the database`);
|
|
229
|
-
if (row.acquired === true) return { acquired: true };
|
|
230
|
-
return {
|
|
231
|
-
acquired: false,
|
|
232
|
-
holder: typeof row.holder === "string" ? row.holder : void 0
|
|
233
|
-
};
|
|
234
|
-
},
|
|
235
|
-
async releaseRunLease(jobId, holder) {
|
|
236
|
-
await exec(`UPDATE ${STATE_TABLE}
|
|
237
|
-
SET running_until = NULL, running_by = NULL
|
|
238
|
-
WHERE job_id = $1 AND running_by = $2`, { params: [jobId, holder] });
|
|
239
|
-
},
|
|
240
|
-
async fetchRunSummaries(jobIds) {
|
|
241
|
-
const summaries = /* @__PURE__ */ new Map();
|
|
242
|
-
if (jobIds.length === 0) return summaries;
|
|
243
|
-
const rows = await exec(`SELECT j.job_id,
|
|
244
|
-
(SELECT count(*)::int FROM ${TABLE} l WHERE l.job_id = j.job_id) AS total_runs,
|
|
245
|
-
(SELECT count(*)::int FROM ${TABLE} l WHERE l.job_id = j.job_id AND NOT l.success) AS total_failures,
|
|
246
|
-
last.started_at, last.duration_ms, last.success, last.error
|
|
247
|
-
FROM (VALUES ${jobIds.map((_, i) => `($${i + 1}::text)`).join(", ")}) AS j(job_id)
|
|
248
|
-
LEFT JOIN LATERAL (
|
|
249
|
-
SELECT l.started_at, l.duration_ms, l.success, l.error
|
|
250
|
-
FROM ${TABLE} l
|
|
251
|
-
WHERE l.job_id = j.job_id
|
|
252
|
-
ORDER BY l.started_at DESC
|
|
253
|
-
LIMIT 1
|
|
254
|
-
) AS last ON true`, { params: [...jobIds] });
|
|
255
|
-
for (const row of rows ?? []) {
|
|
256
|
-
const summary = {
|
|
257
|
-
totalRuns: Number(row.total_runs ?? 0),
|
|
258
|
-
totalFailures: Number(row.total_failures ?? 0)
|
|
259
|
-
};
|
|
260
|
-
const lastRunAt = toIsoString(row.started_at);
|
|
261
|
-
if (lastRunAt) {
|
|
262
|
-
summary.lastRunAt = lastRunAt;
|
|
263
|
-
summary.lastDurationMs = Number(row.duration_ms);
|
|
264
|
-
summary.lastSuccess = row.success === true;
|
|
265
|
-
if (typeof row.error === "string") summary.lastError = row.error;
|
|
266
|
-
}
|
|
267
|
-
summaries.set(String(row.job_id), summary);
|
|
268
|
-
}
|
|
269
|
-
return summaries;
|
|
270
|
-
}
|
|
271
|
-
};
|
|
272
|
-
}
|
|
273
|
-
/** A TIMESTAMPTZ as the driver returned it — a `Date` or a string — in ISO form. */
|
|
274
|
-
function toIsoString(value) {
|
|
275
|
-
if (value instanceof Date) return value.toISOString();
|
|
276
|
-
if (typeof value === "string" || typeof value === "number") return new Date(value).toISOString();
|
|
277
|
-
}
|
|
278
|
-
function rowToLogEntry(row) {
|
|
279
|
-
return {
|
|
280
|
-
jobId: row.job_id,
|
|
281
|
-
startedAt: new Date(row.started_at).toISOString(),
|
|
282
|
-
finishedAt: new Date(row.finished_at).toISOString(),
|
|
283
|
-
durationMs: row.duration_ms,
|
|
284
|
-
success: row.success,
|
|
285
|
-
error: row.error ?? void 0,
|
|
286
|
-
result: row.result ?? void 0,
|
|
287
|
-
logs: Array.isArray(row.logs) ? row.logs : row.logs ? (() => {
|
|
288
|
-
try {
|
|
289
|
-
return JSON.parse(row.logs);
|
|
290
|
-
} catch {
|
|
291
|
-
return [];
|
|
292
|
-
}
|
|
293
|
-
})() : [],
|
|
294
|
-
manual: row.manual
|
|
295
|
-
};
|
|
296
|
-
}
|
|
297
|
-
//#endregion
|
|
298
|
-
export { cron_store_exports as n, createCronStore as t };
|
|
299
|
-
|
|
300
|
-
//# sourceMappingURL=cron-store-D2Q9-Aco.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"cron-store-D2Q9-Aco.js","names":[],"sources":["../src/cron/cron-store.ts"],"sourcesContent":["import type { CronJobLogEntry } from \"@rebasepro/types\";\nimport type { DataDriver } from \"@rebasepro/types\";\nimport { isSQLAdmin } from \"@rebasepro/types\";\nimport { revokeInternalTableSql } from \"@rebasepro/common\";\nimport { logger } from \"../utils/logger.js\";\nimport { createDdlBootstrapper, hasInCauseChain } from \"../boot/ddl-bootstrap.js\";\n\n/**\n * Persistence layer for cron: the run history (`rebase.cron_logs`), the slot\n * claims that keep replicas from running the same slot twice\n * (`rebase.cron_claims`), and each job's shared state — the enabled override\n * and the run lease — in `rebase.cron_job_state`.\n *\n * Uses the DataDriver's `admin.executeSql` capability. Falls back gracefully if\n * the driver doesn't support SQL (e.g. MongoDB) — in that case, no persistence\n * occurs, and a pause or a trigger's \"already executing\" is per process.\n */\nexport interface CronStore {\n /** Ensure the backing table exists. Called once on startup. */\n ensureTable(): Promise<void>;\n\n /** Persist a single log entry after execution. */\n insertLog(entry: CronJobLogEntry): Promise<void>;\n\n /**\n * Fetch the most recent logs for a job.\n *\n * Throws when the history cannot be read. An empty array is a claim — the\n * job has no recorded runs — and a caller shows it as \"never ran\".\n *\n * @param jobId The job identifier\n * @param limit Max entries to return (default 50)\n * @returns Logs sorted newest-first\n */\n fetchLogs(jobId: string, limit?: number): Promise<CronJobLogEntry[]>;\n\n /**\n * Fetch aggregate stats for all jobs (totalRuns, totalFailures, lastRunAt).\n * Used to seed in-memory counters on startup.\n */\n fetchJobStats(): Promise<Map<string, { totalRuns: number; totalFailures: number; lastRunAt?: string }>>;\n\n /**\n * Atomically claim a scheduled run slot for a job.\n *\n * `slot` is the *scheduled* fire time (ISO string) derived from the cron\n * expression — deterministic across instances regardless of timer drift,\n * so all instances contend on the same (jobId, slot) key. Exactly one\n * caller wins the insert against the unique constraint and executes;\n * the rest skip.\n *\n * Throws when the store cannot tell — a missing or unreadable claims\n * table, a dropped connection. What that means is the caller's decision,\n * and the scheduler's two callers decide differently: a scheduled run\n * fails open, so a broken claims table degrades to uncoordinated execution\n * rather than silently never running jobs; a catch-up fails closed, so it\n * does not re-run the last slot on every boot of every replica.\n *\n * Optional so custom stores written against the pre-claims interface\n * keep working — the scheduler treats a missing implementation as\n * uncoordinated (always run).\n */\n tryClaimRun?(jobId: string, slot: string): Promise<boolean>;\n\n // ─── Fleet-wide job state ────────────────────────────────────────\n //\n // Everything below lives in `rebase.cron_job_state`, one row per job that\n // has ever been paused, resumed or run under a lease. It exists because the\n // alternative — each process's memory — is per process: a pause served by\n // one replica stopped only that replica, and on a split deployment the\n // process serving the admin API is not the one whose timers fire, so a\n // pause there stopped nothing at all.\n //\n // Optional, like `tryClaimRun`, so a custom store written against the\n // older interface keeps working: without them a pause is local to the\n // process that served it and a manual trigger is guarded only in-process.\n\n /**\n * Persist a job's enabled override: `true` or `false`, or `null` to hand the\n * decision back to the code's own `enabled`. Survives restarts and\n * redeploys — that is the point. Throws when the write fails.\n */\n saveEnabledOverride?(jobId: string, enabled: boolean | null, updatedBy?: string): Promise<void>;\n\n /**\n * The persisted state of the jobs asked about. A job with no row has no\n * override. `runningBy` is set only while a lease is live.\n *\n * Throws when the store cannot tell. The scheduler falls back to the code's\n * `enabled` then — a scheduled run fails open — and a listing falls back to\n * what this process knows.\n */\n fetchJobStates?(jobIds: readonly string[]): Promise<Map<string, CronJobPersistedState>>;\n\n /**\n * Take the job's run lease unless another process holds a live one.\n *\n * The cross-process half of \"already executing\": a manual trigger, a\n * scheduled run and a catch-up all take it, so none of them overlaps a run\n * another replica — or the worker, from the api role — is making. The lease\n * expires on its own after `ttlSeconds`, which is what frees a job whose\n * holder crashed.\n *\n * Throws when the store cannot tell.\n */\n tryAcquireRunLease?(jobId: string, holder: string, ttlSeconds: number): Promise<CronRunLease>;\n\n /** Release a lease taken with {@link tryAcquireRunLease} — only if `holder` still holds it. */\n releaseRunLease?(jobId: string, holder: string): Promise<void>;\n\n /**\n * Run count, failure count and last run of each job, read from\n * `cron_logs` — every process's runs, where the scheduler's own counters\n * hold only the ones it made.\n */\n fetchRunSummaries?(jobIds: readonly string[]): Promise<Map<string, CronJobRunSummary>>;\n}\n\n/** One job's row in `rebase.cron_job_state`, as the scheduler reads it. */\nexport interface CronJobPersistedState {\n /** The override, or `null` to follow the code's `enabled`. */\n enabled: boolean | null;\n /** Who holds the run lease, when a live one is held. */\n runningBy?: string;\n}\n\n/** The outcome of {@link CronStore.tryAcquireRunLease}. */\nexport type CronRunLease =\n | { acquired: true }\n | { acquired: false; holder?: string };\n\n/** A job's run history in brief, read from `cron_logs`. */\nexport interface CronJobRunSummary {\n totalRuns: number;\n totalFailures: number;\n lastRunAt?: string;\n lastDurationMs?: number;\n lastSuccess?: boolean;\n lastError?: string;\n}\n\n// ─── SQL-based implementation ────────────────────────────────────────\n\nconst TABLE = \"rebase.cron_logs\";\nconst CLAIMS_TABLE = \"rebase.cron_claims\";\nconst STATE_TABLE = \"rebase.cron_job_state\";\n\n/** `$1, $2, …` for `count` values, starting at `$first`. */\nfunction placeholders(count: number, first = 1): string {\n return Array.from({ length: count }, (_, i) => `$${first + i}`).join(\", \");\n}\n\n/**\n * Claims older than this are garbage-collected on startup — all but each\n * job's most recent one, which is kept however old it is; see the sweep in\n * `ensureTable`.\n */\nconst CLAIM_RETENTION_DAYS = 7;\n\n/**\n * How far ahead a claim may legitimately sit. Slots are claimed as they fire,\n * so anything beyond this is a clock-skewed peer at best and a stranded claim\n * at worst; see the sweep in `ensureTable`.\n */\nconst FUTURE_CLAIM_SKEW_MINUTES = 2;\n\n/**\n * Detect a unique-constraint violation anywhere in an error's cause chain.\n * Match the SQLSTATE code, never message text. Also covers SQLite\n * (\"UNIQUE constraint failed\") and MySQL (ER_DUP_ENTRY 1062) for future SQL\n * drivers.\n *\n * Distinct from `isConcurrentDdlRace` in `boot/ddl-bootstrap.ts`, which shares\n * the 23505 code but asks a different question — that one is about a losing\n * `CREATE`, this one is about a losing claim, and only the latter means\n * \"another instance already has this slot\".\n */\nfunction isUniqueViolation(err: unknown): boolean {\n return hasInCauseChain(err, (e) =>\n e.code === \"23505\" ||\n e.errno === 1062 ||\n (typeof e.message === \"string\" && e.message.includes(\"UNIQUE constraint failed\"))\n );\n}\n\nexport function createCronStore(driver: DataDriver): Required<CronStore> | undefined {\n const admin = driver.admin;\n if (!isSQLAdmin(admin)) {\n logger.warn(\"⚠️ [cron-store] DataDriver does not support SQL admin — cron logs will not be persisted.\");\n return undefined;\n }\n\n const exec = (sqlText: string, options?: { params?: unknown[] }) =>\n admin.executeSql(sqlText, options?.params ? { params: options.params } : undefined);\n\n const ddl = createDdlBootstrapper(exec, \"cron-store\");\n\n return {\n async ensureTable(): Promise<void> {\n // Creation. Every statement here is idempotent, so losing the race\n // to a peer that booted at the same moment is survivable — but only\n // if the loser retries rather than abandoning everything below it.\n // One step each, so a hard failure on any one of them does not take\n // the others with it. The claims table in particular must not be\n // lost because an index on the *logs* table could not be built.\n await ddl.ensureObject(\"Creating schema rebase\", \"CREATE SCHEMA IF NOT EXISTS rebase\");\n\n await ddl.ensureObject(`Creating ${TABLE}`, `\n CREATE TABLE IF NOT EXISTS ${TABLE} (\n id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,\n job_id TEXT NOT NULL,\n started_at TIMESTAMPTZ NOT NULL,\n finished_at TIMESTAMPTZ NOT NULL,\n duration_ms INTEGER NOT NULL,\n success BOOLEAN NOT NULL DEFAULT true,\n error TEXT,\n result JSONB,\n logs JSONB,\n manual BOOLEAN NOT NULL DEFAULT false\n )\n `);\n\n await ddl.ensureObject(\"Creating idx_cron_logs_job\", `\n CREATE INDEX IF NOT EXISTS idx_cron_logs_job\n ON ${TABLE}(job_id, started_at DESC)\n `);\n\n // What a process whose scheduler is not started reads to show a\n // job's failure count. Partial, so it holds only the failed runs:\n // the count reads those and nothing else, and a healthy job's\n // inserts never touch it.\n await ddl.ensureObject(\"Creating idx_cron_logs_job_failures\", `\n CREATE INDEX IF NOT EXISTS idx_cron_logs_job_failures\n ON ${TABLE}(job_id) WHERE NOT success\n `);\n\n await ddl.ensureObject(`Creating ${CLAIMS_TABLE}`, `\n CREATE TABLE IF NOT EXISTS ${CLAIMS_TABLE} (\n job_id TEXT NOT NULL,\n slot TIMESTAMPTZ NOT NULL,\n claimed_at TIMESTAMPTZ NOT NULL DEFAULT now(),\n PRIMARY KEY (job_id, slot)\n )\n `);\n\n // One row per job that has been paused, resumed or run under a\n // lease. `enabled` NULL means \"follow the code\"; `updated_at` and\n // `updated_by` say who last set it, and stay NULL on a row that has\n // only ever held a lease. Created here rather than by a migration,\n // so a database that predates it gains it on its next boot.\n await ddl.ensureObject(`Creating ${STATE_TABLE}`, `\n CREATE TABLE IF NOT EXISTS ${STATE_TABLE} (\n job_id TEXT PRIMARY KEY,\n enabled BOOLEAN,\n updated_at TIMESTAMPTZ,\n updated_by TEXT,\n running_until TIMESTAMPTZ,\n running_by TEXT\n )\n `);\n\n // Everything from here is keyed on what actually exists, not on\n // whether *this* instance is the one that created it. A single\n // failure above used to abandon the rest of this method, which meant\n // the loser of a boot race skipped the sweeps and — far worse — the\n // privilege revocation, leaving the claims table writable by end\n // users on an instance that reported nothing but a warning about\n // log persistence.\n const [logsReady, claimsReady, stateReady] = await Promise.all([\n ddl.isReadable(TABLE),\n ddl.isReadable(CLAIMS_TABLE),\n ddl.isReadable(STATE_TABLE)\n ]);\n\n if (claimsReady) {\n // Drop claims for slots that have not happened yet. A slot is\n // claimed at the moment it fires, so a future one can only come\n // from a timer that woke early — and because claims are\n // permanent, that claim would silently skip the real run when it\n // finally came due. The margin keeps a legitimate claim made\n // moments early by a clock-skewed peer.\n await ddl.step(\"Future-slot claim sweep\", async () => {\n const stranded = await exec(\n `DELETE FROM ${CLAIMS_TABLE}\n WHERE slot > now() + make_interval(mins => $1)\n RETURNING job_id, slot`,\n { params: [FUTURE_CLAIM_SKEW_MINUTES] }\n );\n // A driver that does not honour RETURNING gives back\n // nothing; the rows are only used to report, so treat that\n // as \"none\".\n for (const row of (stranded ?? []) as { job_id: string; slot: string }[]) {\n logger.warn(\n `[cron-store] Released a claim on the future slot ${new Date(row.slot).toISOString()} ` +\n `for \"${row.job_id}\" — it was claimed by a timer that fired early, and would ` +\n \"otherwise have skipped that run\"\n );\n }\n });\n\n // Garbage-collect old claims — except each job's most recent\n // one. A claim is also the record that a slot already ran, and\n // the catch-up at boot asks exactly that about the job's latest\n // slot, as far back as its `catchUpWindowSeconds` reaches —\n // which can be a month. Sweeping that claim after seven days\n // would make the boot that swept it re-run the slot. A claim\n // behind the latest answers for a slot no catch-up looks at,\n // since a catch-up considers only the most recent one.\n //\n // After the future-slot sweep, not before: a stranded future\n // claim would otherwise count as the job's latest, and the real\n // one behind it would be swept here and then released there.\n await ddl.step(\"Claim retention sweep\", async () => {\n await exec(\n `DELETE FROM ${CLAIMS_TABLE} AS c\n WHERE c.claimed_at < now() - make_interval(days => $1)\n AND c.slot < (\n SELECT max(latest.slot) FROM ${CLAIMS_TABLE} AS latest\n WHERE latest.job_id = c.job_id\n )`,\n { params: [CLAIM_RETENTION_DAYS] }\n );\n });\n }\n\n // Neither table is a collection, so neither carries RLS, while the\n // Postgres driver's schema-wide grant reaches both. Cron logs hold\n // job output — arbitrary application data — and a writable\n // `cron_claims` lets any signed-in user suppress a scheduled run by\n // claiming its slot. This is a security control, so it is re-applied\n // by every instance on every boot, whatever else went wrong.\n if (logsReady) {\n await ddl.step(\"Revoking end-user access to cron_logs\", () =>\n exec(revokeInternalTableSql(\"rebase\", \"cron_logs\")));\n }\n if (claimsReady) {\n await ddl.step(\"Revoking end-user access to cron_claims\", () =>\n exec(revokeInternalTableSql(\"rebase\", \"cron_claims\")));\n }\n // A writable `cron_job_state` would let any signed-in user pause a\n // job for the whole fleet, or hold its run lease so that nothing\n // runs it.\n if (stateReady) {\n await ddl.step(\"Revoking end-user access to cron_job_state\", () =>\n exec(revokeInternalTableSql(\"rebase\", \"cron_job_state\")));\n }\n\n if (logsReady && claimsReady && stateReady) {\n logger.info(\"✅ Cron logs table ready\");\n return;\n }\n // Say which capability is gone, and what it costs. \"Continuing\n // without cron log persistence\" undersold this: the claims table is\n // the only thing stopping every instance from running every job.\n if (!claimsReady) {\n logger.error(\n `❌ [cron-store] ${CLAIMS_TABLE} is unavailable — scheduled runs cannot be coordinated. ` +\n \"With more than one app instance, every instance will now run every job on every tick.\"\n );\n }\n if (!logsReady) {\n logger.warn(`⚠️ [cron-store] ${TABLE} is unavailable — cron run history will not be persisted.`);\n }\n if (!stateReady) {\n logger.warn(\n `⚠️ [cron-store] ${STATE_TABLE} is unavailable — pausing a job reaches only the process ` +\n \"that served the request, and jobs run as their code declares on every other one. \" +\n \"A manual trigger is not kept off a run another process is making.\"\n );\n }\n },\n\n async insertLog(entry: CronJobLogEntry): Promise<void> {\n try {\n const resultJson = entry.result !== undefined ? JSON.stringify(entry.result) : null;\n const logsJson = entry.logs.length > 0 ? JSON.stringify(entry.logs) : null;\n\n await exec(\n `INSERT INTO ${TABLE} (job_id, started_at, finished_at, duration_ms, success, error, result, logs, manual)\n VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8::jsonb, $9)`,\n { params: [\n entry.jobId,\n entry.startedAt,\n entry.finishedAt,\n entry.durationMs,\n entry.success,\n entry.error || null,\n resultJson,\n logsJson,\n entry.manual\n ]}\n );\n } catch (err) {\n // Non-blocking — log persistence should never crash the scheduler\n logger.error(`[cron-store] Failed to persist log for \"${entry.jobId}\"`, { error: err });\n }\n },\n\n async fetchLogs(jobId: string, limit = 50): Promise<CronJobLogEntry[]> {\n const rows = await exec(\n `SELECT job_id, started_at, finished_at, duration_ms, success, error, result, logs, manual\n FROM ${TABLE}\n WHERE job_id = $1\n ORDER BY started_at DESC\n LIMIT $2`,\n { params: [jobId, limit] }\n );\n\n return rows.map(rowToLogEntry);\n },\n\n async fetchJobStats(): Promise<Map<string, { totalRuns: number; totalFailures: number; lastRunAt?: string }>> {\n const stats = new Map<string, { totalRuns: number; totalFailures: number; lastRunAt?: string }>();\n try {\n const rows = await exec(`\n SELECT\n job_id,\n COUNT(*)::int AS total_runs,\n COUNT(*) FILTER (WHERE NOT success)::int AS total_failures,\n MAX(started_at) AS last_run_at\n FROM ${TABLE}\n GROUP BY job_id\n `);\n\n for (const row of rows) {\n stats.set(row.job_id as string, {\n totalRuns: row.total_runs as number,\n totalFailures: row.total_failures as number,\n lastRunAt: row.last_run_at ? new Date(row.last_run_at as string).toISOString() : undefined\n });\n }\n } catch (err) {\n logger.error(\"[cron-store] Failed to fetch job stats\", { error: err });\n }\n return stats;\n },\n\n async tryClaimRun(jobId: string, slot: string): Promise<boolean> {\n try {\n const rows = await exec(\n `INSERT INTO ${CLAIMS_TABLE} (job_id, slot)\n VALUES ($1, $2)\n ON CONFLICT (job_id, slot) DO NOTHING\n RETURNING job_id`,\n { params: [jobId, slot] }\n );\n return rows.length > 0;\n } catch (err) {\n if (isUniqueViolation(err)) {\n // Another instance won the race for this slot\n return false;\n }\n // Neither won nor lost: the store does not know, and says so.\n // Answering `true` here would decide \"fail open\" for every\n // caller, including the catch-up, whose whole safety rests on\n // failing closed.\n throw err;\n }\n },\n\n async saveEnabledOverride(jobId: string, enabled: boolean | null, updatedBy?: string): Promise<void> {\n await exec(\n `INSERT INTO ${STATE_TABLE} (job_id, enabled, updated_at, updated_by)\n VALUES ($1, $2::boolean, now(), $3)\n ON CONFLICT (job_id) DO UPDATE\n SET enabled = EXCLUDED.enabled, updated_at = now(), updated_by = EXCLUDED.updated_by`,\n { params: [jobId, enabled, updatedBy ?? null] }\n );\n },\n\n async fetchJobStates(jobIds: readonly string[]): Promise<Map<string, CronJobPersistedState>> {\n const states = new Map<string, CronJobPersistedState>();\n if (jobIds.length === 0) return states;\n const rows = await exec(\n `SELECT job_id, enabled,\n CASE WHEN running_until > now() THEN running_by END AS running_by\n FROM ${STATE_TABLE}\n WHERE job_id IN (${placeholders(jobIds.length)})`,\n { params: [...jobIds] }\n );\n for (const row of rows ?? []) {\n states.set(String(row.job_id), {\n enabled: typeof row.enabled === \"boolean\" ? row.enabled : null,\n runningBy: typeof row.running_by === \"string\" ? row.running_by : undefined\n });\n }\n return states;\n },\n\n async tryAcquireRunLease(jobId: string, holder: string, ttlSeconds: number): Promise<CronRunLease> {\n // One statement, always one row. The insert covers a job that has\n // no row yet; the conditional update covers one whose lease is\n // absent or expired, and does nothing over a live one — so of two\n // processes racing, the second waits on the first's row and then\n // finds it held. The outer SELECT sees the table as it was before\n // this statement, which is exactly the holder that refused us.\n const rows = await exec(\n `WITH attempt AS (\n INSERT INTO ${STATE_TABLE} AS s (job_id, running_until, running_by)\n VALUES ($1, now() + make_interval(secs => $2::double precision), $3)\n ON CONFLICT (job_id) DO UPDATE\n SET running_until = EXCLUDED.running_until, running_by = EXCLUDED.running_by\n WHERE s.running_until IS NULL OR s.running_until <= now()\n RETURNING s.job_id\n )\n SELECT EXISTS (SELECT 1 FROM attempt) AS acquired,\n (SELECT running_by FROM ${STATE_TABLE}\n WHERE job_id = $1 AND running_until > now()) AS holder`,\n { params: [jobId, ttlSeconds, holder] }\n );\n const row = rows?.[0];\n if (!row) {\n throw new Error(`The run lease for \"${jobId}\" got no answer from the database`);\n }\n if (row.acquired === true) return { acquired: true };\n return {\n acquired: false,\n holder: typeof row.holder === \"string\" ? row.holder : undefined\n };\n },\n\n async releaseRunLease(jobId: string, holder: string): Promise<void> {\n await exec(\n `UPDATE ${STATE_TABLE}\n SET running_until = NULL, running_by = NULL\n WHERE job_id = $1 AND running_by = $2`,\n { params: [jobId, holder] }\n );\n },\n\n async fetchRunSummaries(jobIds: readonly string[]): Promise<Map<string, CronJobRunSummary>> {\n const summaries = new Map<string, CronJobRunSummary>();\n if (jobIds.length === 0) return summaries;\n // Per job, never a GROUP BY over the whole table: each subquery\n // reads one job's range of `idx_cron_logs_job` (the failures, the\n // partial `idx_cron_logs_job_failures`), and the last run is one\n // index probe. Jobs no longer registered are never read at all.\n const rows = await exec(\n `SELECT j.job_id,\n (SELECT count(*)::int FROM ${TABLE} l WHERE l.job_id = j.job_id) AS total_runs,\n (SELECT count(*)::int FROM ${TABLE} l WHERE l.job_id = j.job_id AND NOT l.success) AS total_failures,\n last.started_at, last.duration_ms, last.success, last.error\n FROM (VALUES ${jobIds.map((_, i) => `($${i + 1}::text)`).join(\", \")}) AS j(job_id)\n LEFT JOIN LATERAL (\n SELECT l.started_at, l.duration_ms, l.success, l.error\n FROM ${TABLE} l\n WHERE l.job_id = j.job_id\n ORDER BY l.started_at DESC\n LIMIT 1\n ) AS last ON true`,\n { params: [...jobIds] }\n );\n for (const row of rows ?? []) {\n const summary: CronJobRunSummary = {\n totalRuns: Number(row.total_runs ?? 0),\n totalFailures: Number(row.total_failures ?? 0)\n };\n const lastRunAt = toIsoString(row.started_at);\n if (lastRunAt) {\n summary.lastRunAt = lastRunAt;\n summary.lastDurationMs = Number(row.duration_ms);\n summary.lastSuccess = row.success === true;\n if (typeof row.error === \"string\") summary.lastError = row.error;\n }\n summaries.set(String(row.job_id), summary);\n }\n return summaries;\n }\n };\n}\n\n// ─── Helpers ─────────────────────────────────────────────────────────\n\n/** A TIMESTAMPTZ as the driver returned it — a `Date` or a string — in ISO form. */\nfunction toIsoString(value: unknown): string | undefined {\n if (value instanceof Date) return value.toISOString();\n if (typeof value === \"string\" || typeof value === \"number\") return new Date(value).toISOString();\n return undefined;\n}\n\nfunction rowToLogEntry(row: Record<string, unknown>): CronJobLogEntry {\n return {\n jobId: row.job_id as string,\n startedAt: new Date(row.started_at as string).toISOString(),\n finishedAt: new Date(row.finished_at as string).toISOString(),\n durationMs: row.duration_ms as number,\n success: row.success as boolean,\n error: (row.error as string) ?? undefined,\n result: row.result ?? undefined,\n logs: Array.isArray(row.logs) ? row.logs : (row.logs ? (() => { try { return JSON.parse(row.logs as string); } catch { return []; } })() : []),\n manual: row.manual as boolean\n };\n}\n"],"mappings":"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"}
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
import { createRequire as __rebaseCreateRequire } from "module";
|
|
2
|
-
import __rebaseProcess from "process";
|
|
3
|
-
globalThis.process ??= __rebaseProcess;
|
|
4
|
-
__rebaseCreateRequire(import.meta.url);
|
|
5
|
-
//#region src/utils/dynamic-import.ts
|
|
6
|
-
/**
|
|
7
|
-
* Native ESM dynamic import.
|
|
8
|
-
*
|
|
9
|
-
* Wrapped in `new Function` so TypeScript / bundlers don't down-compile the
|
|
10
|
-
* `import()` to `require()` — the loaders must import user-authored `.ts`/`.js`
|
|
11
|
-
* files at runtime using the host's real ESM loader. This is the production
|
|
12
|
-
* default for {@link loadFunctionsFromDirectory} / {@link loadCronJobsFromDirectory}.
|
|
13
|
-
*
|
|
14
|
-
* @internal
|
|
15
|
-
*/
|
|
16
|
-
var nativeDynamicImport = (() => {
|
|
17
|
-
const doImport = new Function("url", "return import(url)");
|
|
18
|
-
return (url) => doImport(url);
|
|
19
|
-
})();
|
|
20
|
-
//#endregion
|
|
21
|
-
export { nativeDynamicImport as t };
|
|
22
|
-
|
|
23
|
-
//# sourceMappingURL=dynamic-import-X40pTZUQ.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"dynamic-import-X40pTZUQ.js","names":[],"sources":["../src/utils/dynamic-import.ts"],"sourcesContent":["/**\n * Shape returned by a dynamic module import: an object whose `default`\n * property holds the module's default export (CJS `module.exports` is exposed\n * as `default` by Node's ESM interop).\n */\nexport interface ImportedModule {\n default?: unknown;\n [key: string]: unknown;\n}\n\n/**\n * Imports a module by file URL. Injected into the directory loaders so tests\n * can supply a deterministic importer.\n */\nexport type ModuleImporter = (url: string) => Promise<ImportedModule>;\n\n/**\n * Native ESM dynamic import.\n *\n * Wrapped in `new Function` so TypeScript / bundlers don't down-compile the\n * `import()` to `require()` — the loaders must import user-authored `.ts`/`.js`\n * files at runtime using the host's real ESM loader. This is the production\n * default for {@link loadFunctionsFromDirectory} / {@link loadCronJobsFromDirectory}.\n *\n * @internal\n */\nexport const nativeDynamicImport: ModuleImporter = (() => {\n const doImport = new Function(\"url\", \"return import(url)\") as (url: string) => Promise<ImportedModule>;\n return (url: string) => doImport(url);\n})();\n"],"mappings":";;;;;;;;;;;;;;;AA0BA,IAAa,6BAA6C;CACtD,MAAM,WAAW,IAAI,SAAS,OAAO,oBAAoB;CACzD,QAAQ,QAAgB,SAAS,GAAG;AACxC,EAAA,CAAG"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"function-loader-D7o5Epjj.js","names":[],"sources":["../src/functions/function-loader.ts"],"sourcesContent":["import * as fs from \"fs\";\nimport * as path from \"path\";\nimport { pathToFileURL } from \"url\";\nimport { Hono } from \"hono\";\nimport { logger } from \"../utils/logger.js\";\nimport { nativeDynamicImport, type ModuleImporter } from \"../utils/dynamic-import.js\";\n\nexport interface LoadedFunction {\n /** Endpoint name derived from filename (e.g., \"send-invoice\") */\n name: string;\n /** The Hono sub-app to mount */\n app: Hono<import(\"hono\").Env>;\n}\n\n/** What a directory of function files produced: what mounted, and what did not. */\nexport interface LoadedFunctions {\n /** The functions that will be served. */\n functions: LoadedFunction[];\n /**\n * One entry per file the loader saw and did **not** mount, each already\n * phrased as `<name> (<reason>)`. Returned rather than only logged so the\n * running server can say what is missing — a boot log line is not reachable\n * from `GET /api/functions`.\n */\n problems: string[];\n}\n\n/**\n * Extensions the bundler compiles (or a developer might reasonably write) that\n * this loader cannot import. `rebase build` globs `functions/**\\/*.ts`, so the\n * build's idea of \"a function file\" is strictly wider than the runtime's — the\n * worst direction for a mismatch to go. Anything listed here is reported as a\n * problem instead of vanishing; non-code files (`.md`, `.json`, `.txt`) stay\n * silent, because a README next to your functions is not a mistake.\n */\nconst UNSUPPORTED_CODE_EXTENSIONS = [\".mts\", \".cts\", \".tsx\", \".jsx\", \".mjs\", \".cjs\"];\n\n/**\n * Auto-discover Hono route files from a directory.\n *\n * Each file should default-export a Hono app (or router).\n * The filename (without extension) becomes the mount path:\n * `functions/send-invoice.ts` → mounted at `/send-invoice`\n *\n * This mirrors how `loadCollectionsFromDirectory` works for collections.\n *\n * Returns only what loaded. Use {@link loadFunctionsWithDiagnostics} when the\n * caller also needs to report what did not.\n */\nexport async function loadFunctionsFromDirectory(\n directory: string,\n importModule: ModuleImporter = nativeDynamicImport\n): Promise<LoadedFunction[]> {\n return (await loadFunctionsWithDiagnostics(directory, importModule)).functions;\n}\n\n/**\n * {@link loadFunctionsFromDirectory}, plus the list of files that were skipped.\n *\n * Never throws: a single malformed function must not crash server boot. The\n * caller decides what to do with `problems` — `init.ts` mounts the router\n * regardless and surfaces the count on the listing endpoint.\n */\nexport async function loadFunctionsWithDiagnostics(\n directory: string,\n importModule: ModuleImporter = nativeDynamicImport\n): Promise<LoadedFunctions> {\n const functions: LoadedFunction[] = [];\n // Aggregate problem files so a broken function surfaces as one loud\n // summary line, not just a warning buried per-file. We still don't throw:\n // a single malformed function must not crash server boot.\n const problems: string[] = [];\n\n if (!fs.existsSync(directory)) {\n return { functions, problems };\n }\n\n // `withFileTypes` so a directory entry is a *reported* skip rather than a\n // filter miss. `readdirSync(dir)` returned bare names, and a subdirectory\n // simply failed the `.ts`/`.js` test — so `functions/admin/users.ts` was\n // compiled by `rebase build`, shipped in the bundle, and then dropped at\n // boot without a single log line.\n const entries = fs.readdirSync(directory, { withFileTypes: true });\n for (const entry of entries) {\n const file = entry.name;\n\n if (entry.isDirectory()) {\n // Dot-directories are tooling (`.git`, `.turbo`), not intent.\n if (file.startsWith(\".\") || file === \"node_modules\") continue;\n logger.warn(\n `[functions] ${file}/: subdirectory ignored. Functions are loaded from the top level of ` +\n `${directory} only, so nothing under ${file}/ is served. Move the file up (or flatten the ` +\n \"name: `admin/users.ts` → `admin-users.ts`).\"\n );\n problems.push(`${file}/ (subdirectory — functions are not loaded recursively)`);\n continue;\n }\n\n const extension = path.extname(file);\n if (\n !file.startsWith(\".\") &&\n !file.includes(\".test.\") &&\n UNSUPPORTED_CODE_EXTENSIONS.includes(extension)\n ) {\n logger.warn(\n `[functions] ${file}: ${extension} files are not loaded. Rename it to .ts (or .js) to serve it.`\n );\n problems.push(`${file} (unsupported extension ${extension})`);\n continue;\n }\n\n if (\n (file.endsWith(\".ts\") || file.endsWith(\".js\")) &&\n // Dotfiles: notably macOS bsdtar AppleDouble sidecars (`._foo.ts`),\n // binary blobs that cannot be imported.\n !file.startsWith(\".\") &&\n !file.includes(\".test.\") &&\n !file.endsWith(\".d.ts\") &&\n file !== \"index.ts\" &&\n file !== \"index.js\"\n ) {\n const filePath = path.join(directory, file);\n try {\n const fileUrl = pathToFileURL(filePath).href;\n\n const mod = await importModule(fileUrl);\n\n const exported = mod.default;\n\n if (!exported) {\n logger.warn(`[functions] ${file}: no default export. Skipping.`);\n problems.push(`${file} (no default export)`);\n continue;\n }\n\n // Accept a Hono instance — use duck-typing to handle different\n // Hono versions which may not share the same prototype.\n if (isHonoLike(exported)) {\n const name = path.basename(file, path.extname(file));\n functions.push({ name,\napp: exported as Hono });\n logger.debug(`⚡ Loaded function route: ${name}`);\n continue;\n }\n\n // Also accept a factory function that returns a Hono instance\n if (typeof exported === \"function\") {\n const result = exported();\n if (isHonoLike(result)) {\n const name = path.basename(file, path.extname(file));\n functions.push({ name,\napp: result as Hono });\n logger.debug(`⚡ Loaded function route: ${name}`);\n continue;\n }\n }\n\n // Provide actionable diagnostics\n const exportType = typeof exported;\n const keys = exported && typeof exported === \"object\"\n ? Object.getOwnPropertyNames(Object.getPrototypeOf(exported)).slice(0, 10).join(\", \")\n : \"N/A\";\n logger.warn(\n `[functions] ${file}: default export is not a Hono app or factory. Skipping.\\n` +\n ` export type: ${exportType}${exported?.constructor?.name ? ` (${exported.constructor.name})` : \"\"}\\n` +\n ` prototype methods: ${keys}\\n` +\n \" Hint: ensure the function exports a Hono app created with the same hono version as the server.\\n\" +\n \" Author with `defineFunction(...)` from @rebasepro/server for a typed, checked contract.\\n\" +\n \" The loader checks for .fetch() and .routes — any Hono-compatible app will work.\"\n );\n problems.push(`${file} (not a Hono app or factory)`);\n } catch (err: unknown) {\n const message =\n err instanceof Error ? err.message : String(err);\n logger.error(`[functions] Failed to load ${file}: ${message}`);\n problems.push(`${file} (threw: ${message})`);\n }\n }\n }\n\n if (problems.length > 0) {\n // Advice keyed to what actually went wrong. The old line said\n // \"author them with `defineFunction(...)`\" for every failure — which is\n // the fix for a wrong export shape and no help at all for the commonest\n // one: a file that threw while being imported, usually a module-scope\n // `process.env.X` read that came back undefined. Told to reach for\n // `defineFunction`, an author rewrites a file whose export was already\n // correct and gets the same failure.\n const advice: string[] = [];\n if (problems.some(p => p.includes(\"(threw:\"))) {\n advice.push(\n \" A file that threw ran at import time. Read configuration *inside* a handler \" +\n \"(`requireEnv(c, \\\"STRIPE_KEY\\\")`, or a lazily-built client) rather than at module \" +\n \"scope, where one undefined variable takes the whole file down before any route exists.\"\n );\n }\n if (problems.some(p => p.includes(\"(no default export)\"))) {\n advice.push(\n \" A file with no default export exports nothing the loader can mount. \" +\n \"`export default defineFunction((app) => { … })`.\"\n );\n }\n if (problems.some(p => p.includes(\"(not a Hono app or factory)\"))) {\n advice.push(\n \" A default export the loader did not recognise is usually two copies of hono. \" +\n \"Author with `defineFunction(...)` from @rebasepro/server/functions, which uses the \" +\n \"server's own copy.\"\n );\n }\n logger.warn(\n `[functions] ${problems.length} function file(s) were skipped and will NOT be served:\\n` +\n problems.map((p) => ` - ${p}`).join(\"\\n\") + \"\\n\" +\n advice.join(\"\\n\")\n );\n }\n\n return { functions, problems };\n}\n\n/**\n * Duck-type check for Hono apps.\n * We avoid `instanceof Hono` because different Hono versions\n * installed in the user's project vs. our dependencies will\n * not share the same prototype, causing false negatives.\n */\nfunction isHonoLike(obj: unknown): boolean {\n if (!obj || typeof obj !== \"object\") return false;\n // Hono instances always have .fetch() and .routes\n const record = obj as Record<string, unknown>;\n return (\n typeof record.fetch === \"function\" &&\n Array.isArray(record.routes)\n );\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;AAmCA,IAAM,8BAA8B;CAAC;CAAQ;CAAQ;CAAQ;CAAQ;CAAQ;AAAM;;;;;;;;;;;;;AAcnF,eAAsB,2BAClB,WACA,eAA+B,qBACN;CACzB,QAAQ,MAAM,6BAA6B,WAAW,YAAY,EAAA,CAAG;AACzE;;;;;;;;AASA,eAAsB,6BAClB,WACA,eAA+B,qBACP;CACxB,MAAM,YAA8B,CAAC;CAIrC,MAAM,WAAqB,CAAC;CAE5B,IAAI,CAAC,KAAG,WAAW,SAAS,GACxB,OAAO;EAAE;EAAW;CAAS;CAQjC,MAAM,UAAU,KAAG,YAAY,WAAW,EAAE,eAAe,KAAK,CAAC;CACjE,KAAK,MAAM,SAAS,SAAS;EACzB,MAAM,OAAO,MAAM;EAEnB,IAAI,MAAM,YAAY,GAAG;GAErB,IAAI,KAAK,WAAW,GAAG,KAAK,SAAS,gBAAgB;GACrD,OAAO,KACH,eAAe,KAAK,sEACjB,UAAU,0BAA0B,KAAK,8FAEhD;GACA,SAAS,KAAK,GAAG,KAAK,wDAAwD;GAC9E;EACJ;EAEA,MAAM,YAAY,OAAK,QAAQ,IAAI;EACnC,IACI,CAAC,KAAK,WAAW,GAAG,KACpB,CAAC,KAAK,SAAS,QAAQ,KACvB,4BAA4B,SAAS,SAAS,GAChD;GACE,OAAO,KACH,eAAe,KAAK,IAAI,UAAU,8DACtC;GACA,SAAS,KAAK,GAAG,KAAK,0BAA0B,UAAU,EAAE;GAC5D;EACJ;EAEA,KACK,KAAK,SAAS,KAAK,KAAK,KAAK,SAAS,KAAK,MAG5C,CAAC,KAAK,WAAW,GAAG,KACpB,CAAC,KAAK,SAAS,QAAQ,KACvB,CAAC,KAAK,SAAS,OAAO,KACtB,SAAS,cACT,SAAS,YACX;GACE,MAAM,WAAW,OAAK,KAAK,WAAW,IAAI;GAC1C,IAAI;IACA,MAAM,UAAU,cAAc,QAAQ,CAAC,CAAC;IAIxC,MAAM,YAAW,MAFC,aAAa,OAAO,EAAA,CAEjB;IAErB,IAAI,CAAC,UAAU;KACX,OAAO,KAAK,eAAe,KAAK,+BAA+B;KAC/D,SAAS,KAAK,GAAG,KAAK,qBAAqB;KAC3C;IACJ;IAIA,IAAI,WAAW,QAAQ,GAAG;KACtB,MAAM,OAAO,OAAK,SAAS,MAAM,OAAK,QAAQ,IAAI,CAAC;KACnD,UAAU,KAAK;MAAE;MACrC,KAAK;KAAiB,CAAC;KACH,OAAO,MAAM,4BAA4B,MAAM;KAC/C;IACJ;IAGA,IAAI,OAAO,aAAa,YAAY;KAChC,MAAM,SAAS,SAAS;KACxB,IAAI,WAAW,MAAM,GAAG;MACpB,MAAM,OAAO,OAAK,SAAS,MAAM,OAAK,QAAQ,IAAI,CAAC;MACnD,UAAU,KAAK;OAAE;OACzC,KAAK;MAAe,CAAC;MACG,OAAO,MAAM,4BAA4B,MAAM;MAC/C;KACJ;IACJ;IAGA,MAAM,aAAa,OAAO;IAC1B,MAAM,OAAO,YAAY,OAAO,aAAa,WACvC,OAAO,oBAAoB,OAAO,eAAe,QAAQ,CAAC,CAAC,CAAC,MAAM,GAAG,EAAE,CAAC,CAAC,KAAK,IAAI,IAClF;IACN,OAAO,KACH,eAAe,KAAK,2EACF,aAAa,UAAU,aAAa,OAAO,KAAK,SAAS,YAAY,KAAK,KAAK,GAAG,yBAC5E,KAAK;;kFAIjC;IACA,SAAS,KAAK,GAAG,KAAK,6BAA6B;GACvD,SAAS,KAAc;IACnB,MAAM,UACF,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;IACnD,OAAO,MAAM,8BAA8B,KAAK,IAAI,SAAS;IAC7D,SAAS,KAAK,GAAG,KAAK,WAAW,QAAQ,EAAE;GAC/C;EACJ;CACJ;CAEA,IAAI,SAAS,SAAS,GAAG;EAQrB,MAAM,SAAmB,CAAC;EAC1B,IAAI,SAAS,MAAK,MAAK,EAAE,SAAS,SAAS,CAAC,GACxC,OAAO,KACH,wPAGJ;EAEJ,IAAI,SAAS,MAAK,MAAK,EAAE,SAAS,qBAAqB,CAAC,GACpD,OAAO,KACH,wHAEJ;EAEJ,IAAI,SAAS,MAAK,MAAK,EAAE,SAAS,6BAA6B,CAAC,GAC5D,OAAO,KACH,sLAGJ;EAEJ,OAAO,KACH,eAAe,SAAS,OAAO,4DAC/B,SAAS,KAAK,MAAM,OAAO,GAAG,CAAC,CAAC,KAAK,IAAI,IAAI,OAC7C,OAAO,KAAK,IAAI,CACpB;CACJ;CAEA,OAAO;EAAE;EAAW;CAAS;AACjC;;;;;;;AAQA,SAAS,WAAW,KAAuB;CACvC,IAAI,CAAC,OAAO,OAAO,QAAQ,UAAU,OAAO;CAE5C,MAAM,SAAS;CACf,OACI,OAAO,OAAO,UAAU,cACxB,MAAM,QAAQ,OAAO,MAAM;AAEnC"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"function-routes-CaNG4waN.js","names":[],"sources":["../src/auth/admin-roles.ts","../src/functions/context.ts","../src/functions/guards.ts","../src/functions/function-routes.ts"],"sourcesContent":["/**\n * The admin role and scope matching, for code that may not import\n * `@rebasepro/types`.\n *\n * The custom-functions surface (`@rebasepro/server/functions`) must bundle for\n * a runtime with no Node built-ins and imports nothing but `hono`, so its\n * guards cannot reach the canonical definitions in `@rebasepro/types`\n * (`ADMIN_ROLE`, `hasAdminRole`, `scopeGrants`). These are the same rules,\n * restated, and `test/admin-roles.test.ts` holds them equal.\n *\n * Everything else imports the `@rebasepro/types` versions.\n */\n\n/** The one built-in role. It holds every scope. */\nexport const ADMIN_ROLE_NAME = \"admin\";\n\n/** Does this list of roles include the admin role? */\nexport function holdsAdminRole(roles: readonly string[] | null | undefined): boolean {\n return !!roles?.includes(ADMIN_ROLE_NAME);\n}\n\n/**\n * Does a set of held scopes grant `scope`, optionally on one `target`? The\n * unqualified grant covers every target; `scope:target` covers its own.\n */\nexport function heldScopesGrant(held: readonly string[], scope: string, target?: string): boolean {\n for (const entry of held) {\n if (entry === scope) return true;\n if (target !== undefined && entry === `${scope}:${target}`) return true;\n }\n return false;\n}\n","/**\n * Reading the request context from inside a custom function.\n *\n * The functions router resolves the caller's identity before any handler runs\n * and leaves the result on the Hono context. Getting it back out used to be the\n * user's problem, and the shape made that worse than it sounds: `HonoEnv`\n * types `user` as `AuthResult`, a union that includes `boolean`, `null` and an\n * index signature, because the same slot is filled by four different middlewares\n * — JWT, service key, API key, and a user-supplied validator that may return\n * `true`. Every example in the documentation therefore opened with\n *\n * const user = c.get(\"user\") as { uid: string; roles?: string[] } | undefined;\n *\n * and an assertion in a security-relevant position is exactly the kind of line\n * that gets copied once and then never re-examined. It is also wrong in one\n * case that occurs in practice: a custom validator returning `true` stores\n * `{ uid: \"default\", roles: [] }`, which the assertion above types as having a\n * `uid` — true here, but nothing checks it.\n *\n * These accessors do the narrowing once, in the framework, where it can be\n * tested. They are also **runtime-neutral by construction** — no crypto, no\n * token parsing, no I/O, nothing but property reads on an object another\n * middleware already populated. That is what lets them live in\n * `@rebasepro/server/functions` and run unchanged on a host that has no Node\n * built-ins.\n *\n * @module\n */\nimport type { Context } from \"hono\";\nimport type { DataDriver } from \"@rebasepro/types\";\nimport type { HonoEnv } from \"../api/types\";\nimport type { ApiKeyMasked } from \"../auth/api-keys/api-key-types\";\nimport { heldScopesGrant, holdsAdminRole } from \"../auth/admin-roles\";\n\n/**\n * The caller, as a custom function sees them.\n *\n * A narrowed view of whatever the auth middleware resolved: `uid` and `roles`\n * are guaranteed, and the index signature keeps any extra claims the token or\n * the adapter carried (`email`, `org_id`, anything a custom validator added)\n * reachable without a cast.\n */\nexport interface FunctionUser {\n /** Stable id of the caller. `\"service\"` for service-key and API-key callers. */\n uid: string;\n /** Roles as resolved for this request. Never `undefined` — an empty array instead. */\n roles: string[];\n /** Present when the identity carried one. Not every auth method does. */\n email?: string;\n /** Any further claim the token, adapter or validator supplied. */\n [claim: string]: unknown;\n}\n\n/** Anything with a Hono-style `.get`, so these work on any `Context` shape. */\ntype CtxLike = Context<HonoEnv> | Context;\n\nfunction read<K extends keyof HonoEnv[\"Variables\"]>(\n c: CtxLike,\n key: K\n): HonoEnv[\"Variables\"][K] | undefined {\n // `c.get` is typed against the app's own Env, which a handler mounted\n // through `app.route()` may have declared more loosely. The cast is\n // confined to this one function rather than repeated at every call site.\n return (c as Context<HonoEnv>).get(key);\n}\n\n/**\n * The authenticated caller, or `undefined` for an anonymous request.\n *\n * **`undefined` is not a permission decision.** The functions router mounts its\n * auth middleware with `requireAuth: false` on purpose — a webhook receiver has\n * no token to send — so an anonymous caller reaches the handler and reads\n * `undefined` here while the handler runs on regardless. Use {@link requireAuth}\n * (or a `!user` branch that returns 401) to make it a decision.\n *\n * A caller who presented a *bad* token never gets this far: both auth\n * middlewares reject an unverifiable token with 401 before the router is\n * reached, precisely so an expired session cannot be silently downgraded to an\n * anonymous one.\n */\nexport function getUser(c: CtxLike): FunctionUser | undefined {\n const raw = read(c, \"user\");\n if (!raw || typeof raw !== \"object\") return undefined;\n\n const record = raw as Record<string, unknown>;\n const uid = typeof record.uid === \"string\" ? record.uid : undefined;\n if (uid === undefined) return undefined;\n\n const roles = Array.isArray(record.roles)\n ? record.roles.filter((role): role is string => typeof role === \"string\")\n : [];\n\n return { ...record,\n uid,\n roles } as FunctionUser;\n}\n\n/** The caller's id, or `undefined` when nobody is signed in. */\nexport function getUserId(c: CtxLike): string | undefined {\n return getUser(c)?.uid;\n}\n\n/** The caller's roles. Empty for an anonymous request — never `undefined`. */\nexport function getRoles(c: CtxLike): string[] {\n return getUser(c)?.roles ?? [];\n}\n\n/**\n * Whether the caller holds **any** of the named roles.\n *\n * Any rather than all, because that is what a route guard means by a list of\n * roles; require several by calling this more than once.\n */\nexport function hasRole(c: CtxLike, ...roles: string[]): boolean {\n if (roles.length === 0) return false;\n const held = new Set(getRoles(c));\n return roles.some(role => held.has(role));\n}\n\n/** Whether the caller holds the `admin` role, which holds every scope. */\nexport function isAdmin(c: CtxLike): boolean {\n return holdsAdminRole(getRoles(c));\n}\n\n/**\n * Everything the caller may do, as `resource:action[:target]` scope strings —\n * the data plane, the app's own `auth.scopes`, and whatever their roles (or\n * their API key) hold. Empty for an anonymous request.\n *\n * Resolved by the framework before the handler runs; `undefined` only when no\n * Rebase auth middleware ran (see {@link identityResolved}).\n */\nexport function getScopes(c: CtxLike): string[] | undefined {\n return read(c, \"scopes\");\n}\n\n/**\n * Whether the caller holds `scope`, on `target` when one is given.\n *\n * For an app scope declared under `auth.scopes` — `project:deploy` — a\n * signed-in person always holds it, so this narrows only API keys and tokens.\n * Whether the *person* may deploy *this* project is still the handler's to\n * decide.\n */\nexport function hasScope(c: CtxLike, scope: string, target?: string): boolean {\n return heldScopesGrant(getScopes(c) ?? [], scope, target);\n}\n\n/** Whether the request carries an identity at all. */\nexport function isAuthenticated(c: CtxLike): boolean {\n return getUser(c) !== undefined;\n}\n\n/**\n * The request-scoped data driver: reads and writes run as **the caller**, with\n * your row-level security policies evaluated against their identity.\n *\n * This is the accessor to reach for when a function serves user-facing data.\n * `rebase.dataAsAdmin` is the other one, and it is not the same thing — it runs\n * as `{ uid: \"service\", roles: [\"admin\"] }` for every caller alike, which is\n * correct for trusted background work and wrong for a request.\n *\n * `undefined` only when no Rebase auth middleware ran (see\n * {@link identityResolved}); inside a function mounted by the framework it is\n * always present, anonymous requests included — they get an anon-scoped driver\n * so policies still have an identity to evaluate.\n */\nexport function getDriver(c: CtxLike): DataDriver | undefined {\n return read(c, \"driver\");\n}\n\n/**\n * {@link getDriver}, but throws instead of handing back `undefined`.\n *\n * For the common case where a handler cannot proceed without it and would\n * otherwise write `c.get(\"driver\")!` — an assertion that turns a wiring problem\n * into `Cannot read properties of undefined (reading 'fetchCollection')` twenty\n * lines away from the cause.\n */\nexport function requireDriver(c: CtxLike): DataDriver {\n const driver = getDriver(c);\n if (!driver) {\n throw new Error(\n \"No request-scoped driver on this context. A Rebase auth middleware \" +\n \"populates it before any custom function runs, so this means the handler \" +\n \"was mounted outside the functions router — e.g. added to your own Hono \" +\n \"app directly. Mount it from the functions directory, or use \" +\n \"`rebase.dataAsAdmin` if the work is genuinely service-scoped.\"\n );\n }\n return driver;\n}\n\n/**\n * The API key this request authenticated with, masked, or `undefined` when it\n * did not use one.\n *\n * Useful for attribution and for per-key behaviour. The permission check itself\n * has already happened — reaching a handler means the key was allowed to.\n */\nexport function getApiKey(c: CtxLike): ApiKeyMasked | undefined {\n return read(c, \"apiKey\");\n}\n\n/**\n * The correlation id for this request — generated, or taken from an inbound\n * `X-Request-ID`.\n *\n * Log it. It is the only thing that ties a line written inside a function to\n * the framework's own lines for the same request.\n */\nexport function getRequestId(c: CtxLike): string | undefined {\n return read(c, \"requestId\");\n}\n\n/**\n * Whether a Rebase auth middleware has run on this request.\n *\n * Both middlewares populate `driver` for *every* outcome, anonymous included,\n * and populate `user` whenever there is one. So \"neither is set\" does not mean\n * \"anonymous\" — it means nothing resolved the identity, and treating that as\n * anonymous is the dangerous reading. The guards use this to tell a genuinely\n * anonymous caller (401) from a misconfigured mount (500), because answering\n * 401 to the second sends whoever is debugging it to look at the token.\n */\nexport function identityResolved(c: CtxLike): boolean {\n return read(c, \"user\") !== undefined || read(c, \"driver\") !== undefined;\n}\n","/**\n * Route guards for custom functions.\n *\n * These decide access from the identity the platform already resolved. They do\n * **not** verify tokens, and that division is the point rather than a\n * limitation:\n *\n * - Verifying a token needs a signing key, constant-time comparison and a\n * revocation lookup. That is host work, it belongs to the process that holds\n * the secret, and it is the part of the stack that cannot be made\n * runtime-neutral without rewriting it against WebCrypto.\n * - Deciding whether *this* caller may call *this* route is application work.\n * It needs nothing but the resolved identity, so it costs nothing to make it\n * portable — and it is the half that lives in user code.\n *\n * Splitting there is what lets a function file compile and run unchanged on a\n * host with no Node built-ins, and it is why these live in\n * `@rebasepro/server/functions` while `verifyAccessToken` does not.\n *\n * **Inside the functions router these are equivalent to the guards exported\n * from the package root.** Both auth middlewares resolve the identity before\n * any handler runs: a valid credential populates `user`, an invalid one is\n * rejected with 401 by the middleware itself, and a missing one leaves `user`\n * unset. So the root `requireAuth`'s token-parsing branch is unreachable from a\n * function, and removing it changes no outcome. The one difference is a handler\n * mounted **outside** the framework's router, where no middleware ran: the root\n * guard would parse the `Authorization` header itself, and these refuse the\n * request with a 500 that names the wiring problem. Fail-closed, and legible.\n *\n * @module\n */\nimport type { Context, MiddlewareHandler } from \"hono\";\nimport type { HonoEnv } from \"../api/types\";\nimport { getUser, isAdmin, getRoles, identityResolved, hasScope } from \"./context\";\n\n/**\n * The answer to \"a guard ran, but no middleware had resolved anything\".\n *\n * Deliberately a 500 and not a 401. A 401 tells the caller their credential is\n * the problem, and here the caller's credential was never looked at — sending\n * them to check their token is sending them to the one place the answer is not.\n */\nfunction unresolvedIdentity(): { error: { message: string; code: string } } {\n return {\n error: {\n message:\n \"This route's identity was never resolved: no Rebase auth middleware ran \" +\n \"before the guard. A function loaded from the functions directory always \" +\n \"has one. This usually means the Hono app was mounted onto your own \" +\n \"server directly, bypassing the functions router.\",\n code: \"AUTH_MIDDLEWARE_MISSING\"\n }\n };\n}\n\n/**\n * Reject anonymous callers with 401.\n *\n * Put it in the route's own middleware slot rather than `app.use(\"/*\", …)`:\n * `use()` covers only the routes declared *below* it, so a route appended later\n * — by you, months from now, at the bottom of the file — is silently\n * unprotected. The per-route form cannot drift that way.\n *\n * @example\n * ```ts\n * app.post(\"/\", requireAuth, async (c) => {\n * const user = getUser(c)!; // guaranteed by the guard\n * return c.json({ uid: user.uid });\n * });\n * ```\n */\nexport const requireAuth: MiddlewareHandler<HonoEnv> = async (c, next) => {\n if (getUser(c)) return next();\n if (!identityResolved(c)) return c.json(unresolvedIdentity(), 500);\n\n return c.json({\n error: {\n message: \"Authentication required\",\n code: \"UNAUTHORIZED\"\n }\n }, 401);\n};\n\n/**\n * Reject callers without the `admin` role with 403.\n *\n * Must come **after** {@link requireAuth}: on its own it answers 401 for an\n * anonymous caller, which is right, but pairing them keeps the two failures\n * distinguishable — 401 \"who are you\", 403 \"not you\".\n *\n * Prefer {@link requireScope} for anything an app declares a scope for: a\n * scope can be granted to a narrower role and to a key, and `admin` cannot.\n */\nexport const requireAdmin: MiddlewareHandler<HonoEnv> = async (c, next) => {\n const user = getUser(c);\n if (!user) {\n if (!identityResolved(c)) return c.json(unresolvedIdentity(), 500);\n return c.json({\n error: {\n message: \"Authentication required\",\n code: \"UNAUTHORIZED\"\n }\n }, 401);\n }\n\n if (!isAdmin(c)) {\n return c.json({\n error: {\n message: \"Admin privileges required for this operation\",\n code: \"FORBIDDEN\"\n }\n }, 403);\n }\n\n return next();\n};\n\n/**\n * Reject callers holding none of the named roles with 403.\n *\n * Any of them, not all — require several by chaining the guard twice. Naming no\n * role at all is a programming error and throws at module load rather than at\n * request time, because `requireRole()` with an empty list would otherwise read\n * as a guard while admitting everyone.\n *\n * @example\n * ```ts\n * app.post(\"/publish\", requireAuth, requireRole(\"editor\", \"admin\"), handler);\n * ```\n */\nexport function requireRole(...roles: string[]): MiddlewareHandler<HonoEnv> {\n if (roles.length === 0) {\n throw new Error(\n \"requireRole() needs at least one role. An empty list would admit every \" +\n \"signed-in caller while reading as a restriction.\"\n );\n }\n\n const allowed = new Set(roles);\n return async (c, next) => {\n const user = getUser(c);\n if (!user) {\n if (!identityResolved(c)) return c.json(unresolvedIdentity(), 500);\n return c.json({\n error: {\n message: \"Authentication required\",\n code: \"UNAUTHORIZED\"\n }\n }, 401);\n }\n\n if (!getRoles(c).some(role => allowed.has(role))) {\n return c.json({\n error: {\n message: `This operation requires one of these roles: ${roles.join(\", \")}`,\n code: \"FORBIDDEN\"\n }\n }, 403);\n }\n\n return next();\n };\n}\n\n/**\n * Reject callers who do not hold `scope` with 403.\n *\n * `scope` is a built-in scope or one the app declares under `auth.scopes` on\n * the users collection. `target` narrows it to one resource — a key holding\n * `project:deploy:p1` passes `requireScope(\"project:deploy\", c => c.req.param(\"project\"))`\n * for `p1` only — and may be read from the request.\n *\n * A signed-in person holds every app scope, so for a person this is no\n * authorization at all: it narrows API keys and tokens. Decide whether the\n * person may act in the handler, as you would without it.\n *\n * @example\n * ```ts\n * app.post(\"/deploy/:project\", requireAuth, requireScope(\"project:deploy\", c => c.req.param(\"project\")), handler);\n * ```\n */\nexport function requireScope(\n scope: string,\n target?: string | ((c: Context<HonoEnv>) => string | undefined)\n): MiddlewareHandler<HonoEnv> {\n return async (c, next) => {\n if (!getUser(c)) {\n if (!identityResolved(c)) return c.json(unresolvedIdentity(), 500);\n return c.json({\n error: {\n message: \"Authentication required\",\n code: \"UNAUTHORIZED\"\n }\n }, 401);\n }\n const resolvedTarget = typeof target === \"function\" ? target(c) : target;\n if (!hasScope(c, scope, resolvedTarget)) {\n const wanted = resolvedTarget !== undefined ? `${scope}:${resolvedTarget}` : scope;\n return c.json({\n error: {\n message: `This credential does not hold the \"${wanted}\" scope.`,\n code: \"SCOPE_MISSING\",\n details: { requiredScope: wanted }\n }\n }, 403);\n }\n return next();\n };\n}\n","import { Hono } from \"hono\";\nimport { HonoEnv } from \"../api/types\";\nimport { ApiError } from \"../api/errors\";\nimport { LoadedFunction } from \"./function-loader\";\nimport { requireAuth } from \"./guards\";\n\n/** The file a loader problem names, without its extension: `broken.ts (threw: …)` → `broken`. */\nfunction problemName(problem: string): string {\n return problem.split(\" \")[0].replace(/\\/$/, \"\").replace(/\\.[cm]?[jt]s$/, \"\");\n}\n\n/**\n * Mount all loaded function routes under a single Hono router.\n *\n * Each function is mounted at `/<function-name>`, preserving\n * whatever HTTP methods and middleware the Hono sub-app defines.\n *\n * @param functions What loaded. May be empty — the router still mounts, so\n * \"no functions are served\" answers 200 with an empty list instead of 404.\n * @param problems The files the loader saw and could not serve, as\n * `\"<file> (<reason>)\"`. The listing reports a count and a pointer to the log,\n * not the reasons: those carry import errors, and the listing is one guard\n * away from anyone. The unmatched-route handler uses the *names* to answer the\n * one question a 404 on a function nobody can find should answer — \"there is a\n * file for this and it did not load\" — which is the difference between a typo\n * and a broken deploy, and the loader was the only thing that knew.\n */\nexport function createFunctionRoutes(\n functions: LoadedFunction[],\n problems: string[] = [],\n /**\n * Where this router is mounted, so the listing can report a path a caller\n * can actually request. It used to hardcode `/functions/<name>`, which is\n * wrong under every `basePath` including the default `/api`.\n */\n mountPath = \"/functions\"\n): Hono<HonoEnv> {\n const router = new Hono<HonoEnv>();\n const skipped = problems.length;\n\n // Listing endpoint: GET / → list available functions.\n //\n // Functions themselves stay anonymous-callable by default — a webhook\n // receiver has to be — but the index of them does not: it is an inventory\n // of every custom endpoint, for whoever asks. `requireAuth` admits any\n // resolved identity (a signed-in user, an API key, the service key), so\n // `rebase doctor` and `rebase cloud debug` keep their answer; the latter\n // already reads a 401 here as \"mounted\".\n router.get(\"/\", requireAuth, (c) => {\n return c.json({\n functions: functions.map((fn) => ({\n name: fn.name,\n endpoint: `${mountPath}/${fn.name}`\n })),\n ...(skipped > 0 && {\n skipped,\n note: `${skipped} function file(s) failed to load and are NOT served — see the server log for the reason.`\n })\n });\n });\n\n for (const fn of functions) {\n router.route(`/${fn.name}`, fn.app);\n }\n\n // A name that matches nothing, answered in the envelope.\n //\n // Registered last so every real route wins it. Without it, a typo'd\n // function name — the single most likely 404 a developer meets on this\n // surface — fell through to Hono's default `404 Not Found` as `text/plain`,\n // and through the SDK arrived as `RebaseApiError { code: undefined }`, so\n // the documented `e.code === \"FUNCTION_NOT_FOUND\"` branch never ran.\n //\n // What the message may say depends on who is asking. The mounted names are\n // an inventory of every custom endpoint, which is exactly what the listing\n // above requires an identity to see — so an anonymous caller is told their\n // name is unknown and nothing more, and a resolved caller gets the list\n // that turns the 404 into a fix.\n const mounted = new Set(functions.map(fn => fn.name));\n const failedToLoad = new Map(problems.map(p => [problemName(p), p.split(\" \")[0]]));\n\n router.all(\"/:name{.*}\", (c): never => {\n const requested = (c.req.param(\"name\") ?? \"\").split(\"/\").filter(Boolean);\n const name = requested[0] ?? \"\";\n const rest = requested.slice(1).join(\"/\");\n const identified = Boolean(c.get(\"user\"));\n\n const refuse = (message: string): never => {\n throw new ApiError(404, \"FUNCTION_NOT_FOUND\", message, { function: name }, true);\n };\n\n if (!name) {\n refuse(`No function in the request path. Expected ${mountPath}/<function>.`);\n }\n if (mounted.has(name)) {\n refuse(\n `The function '${name}' is served, and has no ${c.req.method} route at '/${rest}'. ` +\n \"The path after the function name is routed by the function's own Hono app.\"\n );\n }\n const file = failedToLoad.get(name);\n if (file) {\n refuse(\n `The function '${name}' is not served: '${file}' failed to load. ` +\n \"The server log records why, at boot.\"\n );\n }\n return refuse(\n `No function named '${name}' on this backend.` +\n (identified\n ? (mounted.size > 0\n ? ` This backend serves: ${[...mounted].sort().join(\", \")}.`\n : \" This backend serves no functions.\")\n : \"\") +\n (skipped > 0 && identified\n ? ` ${skipped} function file(s) failed to load and are not served — see the server log.`\n : \"\")\n );\n });\n\n return router;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;AAcA,IAAa,kBAAkB;;AAG/B,SAAgB,eAAe,OAAsD;CACjF,OAAO,CAAC,CAAC,OAAO,SAAS,eAAe;AAC5C;;;ACqCA,SAAS,KACL,GACA,KACmC;CAInC,OAAQ,EAAuB,IAAI,GAAG;AAC1C;;;;;;;;;;;;;;;AAgBA,SAAgB,QAAQ,GAAsC;CAC1D,MAAM,MAAM,KAAK,GAAG,MAAM;CAC1B,IAAI,CAAC,OAAO,OAAO,QAAQ,UAAU,OAAO,KAAA;CAE5C,MAAM,SAAS;CACf,MAAM,MAAM,OAAO,OAAO,QAAQ,WAAW,OAAO,MAAM,KAAA;CAC1D,IAAI,QAAQ,KAAA,GAAW,OAAO,KAAA;CAE9B,MAAM,QAAQ,MAAM,QAAQ,OAAO,KAAK,IAClC,OAAO,MAAM,QAAQ,SAAyB,OAAO,SAAS,QAAQ,IACtE,CAAC;CAEP,OAAO;EAAE,GAAG;EACR;EACA;CAAM;AACd;;AAGA,SAAgB,UAAU,GAAgC;CACtD,OAAO,QAAQ,CAAC,CAAC,EAAE;AACvB;;AAGA,SAAgB,SAAS,GAAsB;CAC3C,OAAO,QAAQ,CAAC,CAAC,EAAE,SAAS,CAAC;AACjC;;;;;;;AAQA,SAAgB,QAAQ,GAAY,GAAG,OAA0B;CAC7D,IAAI,MAAM,WAAW,GAAG,OAAO;CAC/B,MAAM,OAAO,IAAI,IAAI,SAAS,CAAC,CAAC;CAChC,OAAO,MAAM,MAAK,SAAQ,KAAK,IAAI,IAAI,CAAC;AAC5C;;AAGA,SAAgB,QAAQ,GAAqB;CACzC,OAAO,eAAe,SAAS,CAAC,CAAC;AACrC;;AA2BA,SAAgB,gBAAgB,GAAqB;CACjD,OAAO,QAAQ,CAAC,MAAM,KAAA;AAC1B;;;;;;;;;;;;;;;AAgBA,SAAgB,UAAU,GAAoC;CAC1D,OAAO,KAAK,GAAG,QAAQ;AAC3B;;;;;;;;;AAUA,SAAgB,cAAc,GAAwB;CAClD,MAAM,SAAS,UAAU,CAAC;CAC1B,IAAI,CAAC,QACD,MAAM,IAAI,MACN,6UAKJ;CAEJ,OAAO;AACX;;;;;;;;AASA,SAAgB,UAAU,GAAsC;CAC5D,OAAO,KAAK,GAAG,QAAQ;AAC3B;;;;;;;;AASA,SAAgB,aAAa,GAAgC;CACzD,OAAO,KAAK,GAAG,WAAW;AAC9B;;;;;;;;;;;AAYA,SAAgB,iBAAiB,GAAqB;CAClD,OAAO,KAAK,GAAG,MAAM,MAAM,KAAA,KAAa,KAAK,GAAG,QAAQ,MAAM,KAAA;AAClE;;;;;;;;;;ACzLA,SAAS,qBAAmE;CACxE,OAAO,EACH,OAAO;EACH,SACI;EAIJ,MAAM;CACV,EACJ;AACJ;;;;;;;;;;;;;;;;;AAkBA,IAAa,cAA0C,OAAO,GAAG,SAAS;CACtE,IAAI,QAAQ,CAAC,GAAG,OAAO,KAAK;CAC5B,IAAI,CAAC,iBAAiB,CAAC,GAAG,OAAO,EAAE,KAAK,mBAAmB,GAAG,GAAG;CAEjE,OAAO,EAAE,KAAK,EACV,OAAO;EACH,SAAS;EACT,MAAM;CACV,EACJ,GAAG,GAAG;AACV;;;;;;;;;;;;;;AAiDA,SAAgB,YAAY,GAAG,OAA6C;CACxE,IAAI,MAAM,WAAW,GACjB,MAAM,IAAI,MACN,yHAEJ;CAGJ,MAAM,UAAU,IAAI,IAAI,KAAK;CAC7B,OAAO,OAAO,GAAG,SAAS;EAEtB,IAAI,CADS,QAAQ,CAChB,GAAM;GACP,IAAI,CAAC,iBAAiB,CAAC,GAAG,OAAO,EAAE,KAAK,mBAAmB,GAAG,GAAG;GACjE,OAAO,EAAE,KAAK,EACV,OAAO;IACH,SAAS;IACT,MAAM;GACV,EACJ,GAAG,GAAG;EACV;EAEA,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC,MAAK,SAAQ,QAAQ,IAAI,IAAI,CAAC,GAC3C,OAAO,EAAE,KAAK,EACV,OAAO;GACH,SAAS,+CAA+C,MAAM,KAAK,IAAI;GACvE,MAAM;EACV,EACJ,GAAG,GAAG;EAGV,OAAO,KAAK;CAChB;AACJ;;;;;AC3JA,SAAS,YAAY,SAAyB;CAC1C,OAAO,QAAQ,MAAM,GAAG,CAAC,CAAC,EAAE,CAAC,QAAQ,OAAO,EAAE,CAAC,CAAC,QAAQ,iBAAiB,EAAE;AAC/E;;;;;;;;;;;;;;;;;AAkBA,SAAgB,qBACZ,WACA,WAAqB,CAAC,GAMtB,YAAY,cACC;CACb,MAAM,SAAS,IAAI,KAAc;CACjC,MAAM,UAAU,SAAS;CAUzB,OAAO,IAAI,KAAK,cAAc,MAAM;EAChC,OAAO,EAAE,KAAK;GACV,WAAW,UAAU,KAAK,QAAQ;IAC9B,MAAM,GAAG;IACT,UAAU,GAAG,UAAU,GAAG,GAAG;GACjC,EAAE;GACF,GAAI,UAAU,KAAK;IACf;IACA,MAAM,GAAG,QAAQ;GACrB;EACJ,CAAC;CACL,CAAC;CAED,KAAK,MAAM,MAAM,WACb,OAAO,MAAM,IAAI,GAAG,QAAQ,GAAG,GAAG;CAgBtC,MAAM,UAAU,IAAI,IAAI,UAAU,KAAI,OAAM,GAAG,IAAI,CAAC;CACpD,MAAM,eAAe,IAAI,IAAI,SAAS,KAAI,MAAK,CAAC,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,CAAC,CAAC,EAAE,CAAC,CAAC;CAEjF,OAAO,IAAI,eAAe,MAAa;EACnC,MAAM,aAAa,EAAE,IAAI,MAAM,MAAM,KAAK,GAAA,CAAI,MAAM,GAAG,CAAC,CAAC,OAAO,OAAO;EACvE,MAAM,OAAO,UAAU,MAAM;EAC7B,MAAM,OAAO,UAAU,MAAM,CAAC,CAAC,CAAC,KAAK,GAAG;EACxC,MAAM,aAAa,QAAQ,EAAE,IAAI,MAAM,CAAC;EAExC,MAAM,UAAU,YAA2B;GACvC,MAAM,IAAI,SAAS,KAAK,sBAAsB,SAAS,EAAE,UAAU,KAAK,GAAG,IAAI;EACnF;EAEA,IAAI,CAAC,MACD,OAAO,6CAA6C,UAAU,aAAa;EAE/E,IAAI,QAAQ,IAAI,IAAI,GAChB,OACI,iBAAiB,KAAK,0BAA0B,EAAE,IAAI,OAAO,cAAc,KAAK,8EAEpF;EAEJ,MAAM,OAAO,aAAa,IAAI,IAAI;EAClC,IAAI,MACA,OACI,iBAAiB,KAAK,oBAAoB,KAAK,uDAEnD;EAEJ,OAAO,OACH,sBAAsB,KAAK,uBAC1B,aACM,QAAQ,OAAO,IACZ,yBAAyB,CAAC,GAAG,OAAO,CAAC,CAAC,KAAK,CAAC,CAAC,KAAK,IAAI,EAAE,KACxD,uCACJ,OACL,UAAU,KAAK,aACV,IAAI,QAAQ,6EACZ,GACV;CACJ,CAAC;CAED,OAAO;AACX"}
|