@protobase/server 0.8.1 → 0.8.2

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.
@@ -5,6 +5,7 @@ import { AdminEnv, Authenticator, PageSource, ViewSource } from "./types.js";
5
5
  import { ResourceSource } from "./resource-source.js";
6
6
  import { FunctionSource } from "./functions/define-function.js";
7
7
  import { AdminOptions } from "./admin-options.js";
8
+ import { JobDefinition } from "./jobs/job-types.js";
8
9
  import { Hono } from "hono";
9
10
  import { UserMenuSource } from "@protobase/schema";
10
11
  import { Db } from "@protobase/query";
@@ -26,8 +27,10 @@ export type CreateAdminInput = {
26
27
  files?: FilesOptions;
27
28
  /** Where the changes that resources' events are raised from are captured (see `EventsOptions`). */
28
29
  events?: EventsOptions;
30
+ /** The app's jobs (see `defineJob`): with any, admins get the failed jobs page (`failedJobs`). */
31
+ jobs?: JobDefinition[];
29
32
  options?: AdminOptions;
30
33
  };
31
34
  /** The admin REST API as a Hono app. It uses only Web APIs, so it runs on Node, Workers and anywhere else Hono does. */
32
- export declare const createAdmin: ({ resources, views, pages, userMenu, db, authenticate, auth, functions, files: filesOptions, events, options }: CreateAdminInput) => Hono<AdminEnv, import("hono/types").BlankSchema, "/">;
35
+ export declare const createAdmin: ({ resources: appResources, views: appViews, pages, userMenu, db, authenticate, auth, functions, files: filesOptions, events, jobs, options }: CreateAdminInput) => Hono<AdminEnv, import("hono/types").BlankSchema, "/">;
33
36
  //#endregion
@@ -23,12 +23,15 @@ import { docsRoutes } from "./routes/docs.js";
23
23
  import { metaRoutes } from "./routes/meta.js";
24
24
  import { recordRoutes } from "./routes/record.js";
25
25
  import { createScanGuard } from "./scan-guard.js";
26
+ import { failedJobs, failedJobsView } from "./jobs/failed-jobs.js";
26
27
  import { Hono } from "hono";
27
28
  import { checkPages } from "@protobase/layout";
28
29
  //#region src/create-admin.ts
29
30
  /** The admin REST API as a Hono app. It uses only Web APIs, so it runs on Node, Workers and anywhere else Hono does. */
30
- const createAdmin = ({ resources, views = [], pages = [], userMenu, db, authenticate, auth, functions = {}, files: filesOptions, events, options = {} }) => {
31
+ const createAdmin = ({ resources: appResources, views: appViews = [], pages = [], userMenu, db, authenticate, auth, functions = {}, files: filesOptions, events, jobs = [], options = {} }) => {
31
32
  const basePath = options.basePath ?? "/api/v1";
33
+ const resources = jobs.length > 0 ? [...appResources, failedJobs] : appResources;
34
+ const views = jobs.length > 0 ? [...appViews, failedJobsView] : appViews;
32
35
  const registry = buildRegistry(resources);
33
36
  const apiFunctions = Object.fromEntries(Object.entries(functions).map(([name, source]) => [name, toApiFunction(name, source)]));
34
37
  const viewModels = views.map((view) => view.toModel());
@@ -3,5 +3,5 @@
3
3
  * An environment variable the function cannot work without, such as an API key: `protobase dev` reads it from `.env`,
4
4
  * `protobase serve` from its environment. A missing one is an error, so the call is a 500 the app reports.
5
5
  */
6
- export declare const requireEnv: (name: string) => any;
6
+ export declare const requireEnv: (name: string) => string;
7
7
  //#endregion
package/dist/index.d.ts CHANGED
@@ -24,6 +24,8 @@ import { AssistantTool, ToolContext, defineTool } from "./assistant/tools.js";
24
24
  import { AssistantOptions, AssistantSettings, assistantRoles, assistantSettings, assistantVariables, seesAssistant } from "./assistant/assistant-settings.js";
25
25
  import { RuntimeOptions, runtimeUrl, runtimeVariable, seesRuntime } from "./runtime/runtime-settings.js";
26
26
  import { AdminOptions } from "./admin-options.js";
27
+ import { StandardSchema, readJson } from "./functions/read-json.js";
28
+ import { EnqueueOptions, JobContext, JobDefinition, JobOptions } from "./jobs/job-types.js";
27
29
  import { CreateAdminInput, createAdmin } from "./create-admin.js";
28
30
  import { ProjectConfig, defineConfig, mergeConfig } from "./project-config.js";
29
31
  import { consoleAuditQueue } from "./audit/console-queue.js";
@@ -47,11 +49,15 @@ import { WidgetOptions, maxWidgetPropsBytes, widget } from "./assistant/widget.j
47
49
  import { QueryOptions, QueryResult, runReadOnlyQuery, runReadWriteQuery } from "./assistant/run-query.js";
48
50
  import { readOnlyQueryTool, readWriteQueryTool } from "./assistant/query-tools.js";
49
51
  import { FunctionEnv, PublicFunctionEnv, functionApp } from "./functions/function-app.js";
50
- import { StandardSchema, readJson } from "./functions/read-json.js";
51
52
  import { SignatureOptions, StandardWebhookOptions, verifySignature, verifyStandardWebhook } from "./functions/verify-signature.js";
52
53
  import { requireEnv } from "./functions/require-env.js";
53
54
  import { LocalFilesOptions, localFileStore, localFiles } from "./files/local-files.js";
54
55
  import { FileCleanupInput, createFileCleanup } from "./files/file-cleanup.js";
55
56
  import { FileObject } from "./files/present.js";
56
57
  import { aspectRatio, fileSize, fileType, imageSize, processor } from "./files/processors.js";
57
- export { type Admin, type AdminAuth, type AdminOptions, type ApiFunction, type ApprovalAnswer, type ApprovalRequest, type AssistantBackend, type AssistantOptions, type AssistantSettings, type AssistantTool, type AuditEvent, type AuditQueue, type Authenticator, type BetterAuthAuthenticatorOptions, type CapturedRow, type ChatMessage, type ChatTextPart, type CleanupEntry, type CleanupReport, type CleanupSchedule, type CoalescedChange, type Completion, type CompletionOptions, type Conversation, type ConversationRecord, type ConversationStore, type ConversationsOptions, type CorsOptions, type CreateAdminInput, type CreateAuthOptions, type EventsOptions, type FileCleanupInput, type FileObject, type FileProvider, type FileStore, type FilesOptions, type FunctionApp, type FunctionCaller, type FunctionContext, type FunctionEnv, type FunctionHandler, type FunctionOptions, type FunctionRecords, type FunctionSource, HttpProblem, type IssueTokenOptions, type JwtOptions, type LinkedAccount, type ListParams, type LocalFilesOptions, type MemberInput, ModelError, type ModelSettings, type NewOrganization, type NewUser, type OperatorProvider, type OrganizationRole, type OrganizationsOptions, type PipelineHook, type PlatformSignIn, type ProjectConfig, type ProtectedFunctionOptions, type PublicFunctionContext, type PublicFunctionEnv, type PublicFunctionHandler, type PublicFunctionOptions, type QueryOptions, type QueryResult, type ReasoningDetail, type ReasoningEffort, type RecordKeyInput, type RecordPage, type ResourceChange, type ResourceSource, type RoleDefinition, type RolesInput, type RuntimeOptions, type ScanGuardMode, type ScheduledDelete, type Session, type SessionOrganization, type SignInPolicy, type SignInRule, type SignatureOptions, type StaffAccessRule, type StaffSignIn, type StandardSchema, type StandardWebhookOptions, type StoredMember, type StoredObject, type StoredOrganization, type StoredUser, type TenantValue, type ToolCall, type ToolContext, type ToolRecord, type ToolRecords, type ToolSpec, type TurnOptions, type User, type UserCreatedHook, type ViewSource, type WidgetOptions, type WriteEvent, type WriteOperation, addAppRoleToOwners, addMember, aspectRatio, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, authSchemaMigration, badRequest, betterAuthAuthenticator, changesMigration, checkAuthSchema, checkChanges, coalesce, configExports, consoleAuditQueue, conversationKey, createAdmin, createAuth, createConversations, createFileCleanup, createOrganization, createUser, defaultSignInPolicy, defineConfig, defineFunction, defineTool, deleteUser, eventMatcher, fileConversationStore, fileSize, fileType, forbidden, functionApp, hasUsers, imageSize, issueToken, jwtAuthenticator, listOrganizations, listUsers, localFileStore, localFiles, maxTokenTtlSeconds, maxWidgetPropsBytes, mergeConfig, migrationCommand, normalizeRoles, notFound, parseRoles, processor, readJson, readOnlyQueryTool, readOperatorSettings, readPlatformSignIn, readWriteQueryTool, recordOf, removeMember, requestApproval, requireEnv, resourceChange, roleChoices, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, seesAssistant, seesRuntime, setMemberRoles, setUserBanned, setUserRole, signInPolicyMethods, storageCleanupSchedule, streamChatCompletion, unauthorized, verifySignature, verifyStandardWebhook, widget, withCacheBreakpoints };
58
+ import { defineJob, isJobDefinition, jobDefinitions } from "./jobs/define-job.js";
59
+ import { FatalJobError } from "./jobs/run-job.js";
60
+ import { DrainResult, JobsRuntime, StartJobsOptions, startJobs } from "./jobs/runtime.js";
61
+ import { jobsDrainApp } from "./jobs/drain-route.js";
62
+ import { checkJobsSchema, jobsSchema, jobsSchemaMigration } from "./jobs/schema.js";
63
+ export { type Admin, type AdminAuth, type AdminOptions, type ApiFunction, type ApprovalAnswer, type ApprovalRequest, type AssistantBackend, type AssistantOptions, type AssistantSettings, type AssistantTool, type AuditEvent, type AuditQueue, type Authenticator, type BetterAuthAuthenticatorOptions, type CapturedRow, type ChatMessage, type ChatTextPart, type CleanupEntry, type CleanupReport, type CleanupSchedule, type CoalescedChange, type Completion, type CompletionOptions, type Conversation, type ConversationRecord, type ConversationStore, type ConversationsOptions, type CorsOptions, type CreateAdminInput, type CreateAuthOptions, type DrainResult, type EnqueueOptions, type EventsOptions, FatalJobError, type FileCleanupInput, type FileObject, type FileProvider, type FileStore, type FilesOptions, type FunctionApp, type FunctionCaller, type FunctionContext, type FunctionEnv, type FunctionHandler, type FunctionOptions, type FunctionRecords, type FunctionSource, HttpProblem, type IssueTokenOptions, type JobContext, type JobDefinition, type JobOptions, type JobsRuntime, type JwtOptions, type LinkedAccount, type ListParams, type LocalFilesOptions, type MemberInput, ModelError, type ModelSettings, type NewOrganization, type NewUser, type OperatorProvider, type OrganizationRole, type OrganizationsOptions, type PipelineHook, type PlatformSignIn, type ProjectConfig, type ProtectedFunctionOptions, type PublicFunctionContext, type PublicFunctionEnv, type PublicFunctionHandler, type PublicFunctionOptions, type QueryOptions, type QueryResult, type ReasoningDetail, type ReasoningEffort, type RecordKeyInput, type RecordPage, type ResourceChange, type ResourceSource, type RoleDefinition, type RolesInput, type RuntimeOptions, type ScanGuardMode, type ScheduledDelete, type Session, type SessionOrganization, type SignInPolicy, type SignInRule, type SignatureOptions, type StaffAccessRule, type StaffSignIn, type StandardSchema, type StandardWebhookOptions, type StartJobsOptions, type StoredMember, type StoredObject, type StoredOrganization, type StoredUser, type TenantValue, type ToolCall, type ToolContext, type ToolRecord, type ToolRecords, type ToolSpec, type TurnOptions, type User, type UserCreatedHook, type ViewSource, type WidgetOptions, type WriteEvent, type WriteOperation, addAppRoleToOwners, addMember, aspectRatio, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, authSchemaMigration, badRequest, betterAuthAuthenticator, changesMigration, checkAuthSchema, checkChanges, checkJobsSchema, coalesce, configExports, consoleAuditQueue, conversationKey, createAdmin, createAuth, createConversations, createFileCleanup, createOrganization, createUser, defaultSignInPolicy, defineConfig, defineFunction, defineJob, defineTool, deleteUser, eventMatcher, fileConversationStore, fileSize, fileType, forbidden, functionApp, hasUsers, imageSize, isJobDefinition, issueToken, jobDefinitions, jobsDrainApp, jobsSchema, jobsSchemaMigration, jwtAuthenticator, listOrganizations, listUsers, localFileStore, localFiles, maxTokenTtlSeconds, maxWidgetPropsBytes, mergeConfig, migrationCommand, normalizeRoles, notFound, parseRoles, processor, readJson, readOnlyQueryTool, readOperatorSettings, readPlatformSignIn, readWriteQueryTool, recordOf, removeMember, requestApproval, requireEnv, resourceChange, roleChoices, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, seesAssistant, seesRuntime, setMemberRoles, setUserBanned, setUserRole, signInPolicyMethods, startJobs, storageCleanupSchedule, streamChatCompletion, unauthorized, verifySignature, verifyStandardWebhook, widget, withCacheBreakpoints };
package/dist/index.js CHANGED
@@ -22,8 +22,10 @@ import { storageCleanupSchedule } from "./files/cleanup-schedule.js";
22
22
  import { localFileStore, localFiles } from "./files/local-files.js";
23
23
  import { defineFunction } from "./functions/define-function.js";
24
24
  import { runtimeUrl, runtimeVariable, seesRuntime } from "./runtime/runtime-settings.js";
25
+ import { checkJobsSchema, jobsSchema, jobsSchemaMigration } from "./jobs/schema.js";
25
26
  import { createAdmin } from "./create-admin.js";
26
27
  import { configExports } from "./resource-source.js";
28
+ import { defineJob, isJobDefinition, jobDefinitions } from "./jobs/define-job.js";
27
29
  import { defineConfig, mergeConfig } from "./project-config.js";
28
30
  import { jwtAuthenticator } from "./auth/jwt.js";
29
31
  import { betterAuthAuthenticator } from "./better-auth/authenticator.js";
@@ -40,4 +42,7 @@ import { verifySignature, verifyStandardWebhook } from "./functions/verify-signa
40
42
  import { requireEnv } from "./functions/require-env.js";
41
43
  import { createFileCleanup } from "./files/file-cleanup.js";
42
44
  import { aspectRatio, fileSize, fileType, imageSize, processor } from "./files/processors.js";
43
- export { HttpProblem, ModelError, addAppRoleToOwners, addMember, aspectRatio, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, authSchemaMigration, badRequest, betterAuthAuthenticator, changesMigration, checkAuthSchema, checkChanges, coalesce, configExports, consoleAuditQueue, conversationKey, createAdmin, createAuth, createConversations, createFileCleanup, createOrganization, createUser, defaultSignInPolicy, defineConfig, defineFunction, defineTool, deleteUser, eventMatcher, fileConversationStore, fileSize, fileType, forbidden, functionApp, hasUsers, imageSize, issueToken, jwtAuthenticator, listOrganizations, listUsers, localFileStore, localFiles, maxTokenTtlSeconds, maxWidgetPropsBytes, mergeConfig, migrationCommand, normalizeRoles, notFound, parseRoles, processor, readJson, readOnlyQueryTool, readOperatorSettings, readPlatformSignIn, readWriteQueryTool, recordOf, removeMember, requestApproval, requireEnv, resourceChange, roleChoices, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, seesAssistant, seesRuntime, setMemberRoles, setUserBanned, setUserRole, signInPolicyMethods, storageCleanupSchedule, streamChatCompletion, unauthorized, verifySignature, verifyStandardWebhook, widget, withCacheBreakpoints };
45
+ import { FatalJobError } from "./jobs/run-job.js";
46
+ import { startJobs } from "./jobs/runtime.js";
47
+ import { jobsDrainApp } from "./jobs/drain-route.js";
48
+ export { FatalJobError, HttpProblem, ModelError, addAppRoleToOwners, addMember, aspectRatio, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, authSchemaMigration, badRequest, betterAuthAuthenticator, changesMigration, checkAuthSchema, checkChanges, checkJobsSchema, coalesce, configExports, consoleAuditQueue, conversationKey, createAdmin, createAuth, createConversations, createFileCleanup, createOrganization, createUser, defaultSignInPolicy, defineConfig, defineFunction, defineJob, defineTool, deleteUser, eventMatcher, fileConversationStore, fileSize, fileType, forbidden, functionApp, hasUsers, imageSize, isJobDefinition, issueToken, jobDefinitions, jobsDrainApp, jobsSchema, jobsSchemaMigration, jwtAuthenticator, listOrganizations, listUsers, localFileStore, localFiles, maxTokenTtlSeconds, maxWidgetPropsBytes, mergeConfig, migrationCommand, normalizeRoles, notFound, parseRoles, processor, readJson, readOnlyQueryTool, readOperatorSettings, readPlatformSignIn, readWriteQueryTool, recordOf, removeMember, requestApproval, requireEnv, resourceChange, roleChoices, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, seesAssistant, seesRuntime, setMemberRoles, setUserBanned, setUserRole, signInPolicyMethods, startJobs, storageCleanupSchedule, streamChatCompletion, unauthorized, verifySignature, verifyStandardWebhook, widget, withCacheBreakpoints };
@@ -0,0 +1,8 @@
1
+ import { EnqueueOptions, JobDefinition } from "./job-types.js";
2
+ import { Db } from "@protobase/query";
3
+ //#region src/jobs/current.d.ts
4
+ /** What `defineJob(...).enqueue` reaches: the jobs runtime this process started. */
5
+ export type JobsEnqueuer = {
6
+ enqueue(definition: JobDefinition, db: Db, payload: unknown, options?: EnqueueOptions): Promise<string>;
7
+ };
8
+ //#endregion
@@ -0,0 +1,18 @@
1
+ //#region src/jobs/current.ts
2
+ const key = Symbol.for("protobase.jobs");
3
+ const holder = globalThis;
4
+ /** The running jobs runtime; enqueueing needs one, started by `protobase dev`, `serve` and `worker`, or by `startJobs`. */
5
+ const currentJobs = () => {
6
+ const runtime = holder[key];
7
+ if (!runtime) throw new Error("Jobs are not running in this process: start them with startJobs(), as protobase dev, serve and worker do");
8
+ return runtime;
9
+ };
10
+ const setCurrentJobs = (runtime) => {
11
+ if (holder[key]) throw new Error("Jobs are already running in this process; stop them before starting them again");
12
+ holder[key] = runtime;
13
+ };
14
+ const clearCurrentJobs = (runtime) => {
15
+ if (holder[key] === runtime) delete holder[key];
16
+ };
17
+ //#endregion
18
+ export { clearCurrentJobs, currentJobs, setCurrentJobs };
@@ -0,0 +1,36 @@
1
+ import { CompiledQuery } from "kysely";
2
+ //#region src/jobs/database.ts
3
+ const block = /^\s*BEGIN;\s*(SET LOCAL lock_timeout = \d+);\s*(SET LOCAL idle_in_transaction_session_timeout = \d+);\s*(SELECT pg_advisory_xact_lock\([^;]*\);\s*)?([\s\S]+?);\s*COMMIT;\s*$/;
4
+ /**
5
+ * The statements of one of pg-boss's transaction texts, in order, or `undefined` for any other text. A text that
6
+ * starts a transaction and is not in that shape is an error, so a pg-boss upgrade that changes it fails loudly.
7
+ */
8
+ const transactionStatements = (text) => {
9
+ if (!/^\s*BEGIN;/.test(text)) return void 0;
10
+ const match = block.exec(text);
11
+ if (!match) throw new Error(`Unexpected pg-boss transaction:\n${text}`);
12
+ const [, lockTimeout, idleTimeout, lock, statement] = match;
13
+ return [
14
+ lockTimeout,
15
+ idleTimeout,
16
+ ...lock ? [lock.trim().replace(/;$/, "")] : [],
17
+ statement
18
+ ];
19
+ };
20
+ /**
21
+ * pg-boss's database over the app's own Kysely pool or transaction: one connection pool for the app and its jobs, the
22
+ * same driver on Node, Bun and PGlite. Every statement runs on its own, or inside one transaction for pg-boss's
23
+ * transaction texts, which works through a pooler in transaction mode: pg-boss takes only transaction-scoped locks.
24
+ */
25
+ const jobsDatabase = (db) => ({ executeSql: async (text, values) => {
26
+ if (values && values.length > 0) return { rows: [...(await db.executeQuery(CompiledQuery.raw(text, values))).rows] };
27
+ const statements = transactionStatements(text);
28
+ if (!statements) return { rows: [...(await db.executeQuery(CompiledQuery.raw(text))).rows] };
29
+ return db.transaction().execute(async (trx) => {
30
+ let rows = [];
31
+ for (const statement of statements) rows = [...(await trx.executeQuery(CompiledQuery.raw(statement))).rows];
32
+ return { rows };
33
+ });
34
+ } });
35
+ //#endregion
36
+ export { jobsDatabase, transactionStatements };
@@ -0,0 +1,8 @@
1
+ import { JobDefinition, JobOptions } from "./job-types.js";
2
+ //#region src/jobs/define-job.d.ts
3
+ /** A job the app's workers run: export it from the module that `protobase.config.ts` passes as `jobs`. */
4
+ export declare const defineJob: <Payload = undefined>(options: JobOptions<Payload>) => JobDefinition<Payload>;
5
+ export declare const isJobDefinition: (value: unknown) => value is JobDefinition;
6
+ /** The job definitions among a module's exports, such as `import * as jobs from './config/jobs'`, each name once. */
7
+ export declare const jobDefinitions: (module: Record<string, unknown>) => JobDefinition[];
8
+ //#endregion
@@ -0,0 +1,38 @@
1
+ import { currentJobs } from "./current.js";
2
+ import { nextRun } from "./schedule.js";
3
+ //#region src/jobs/define-job.ts
4
+ const namePattern = /^[\w.\-/]+$/;
5
+ const checkSchedule = (name, schedule, timezone) => {
6
+ try {
7
+ nextRun(schedule, timezone, /* @__PURE__ */ new Date());
8
+ } catch (error) {
9
+ if (!(error instanceof Error)) throw error;
10
+ throw new Error(`Job "${name}" has the schedule "${schedule}" in ${timezone}, which is not a cron schedule there: ${error.message}`, { cause: error });
11
+ }
12
+ };
13
+ /** A job the app's workers run: export it from the module that `protobase.config.ts` passes as `jobs`. */
14
+ const defineJob = (options) => {
15
+ if (!namePattern.test(options.name)) throw new Error(`Job name "${options.name}" may only hold letters, digits, "_", "-", "." and "/"`);
16
+ if (options.schedule !== void 0) checkSchedule(options.name, options.schedule, options.timezone ?? "UTC");
17
+ const definition = {
18
+ kind: "job",
19
+ name: options.name,
20
+ options,
21
+ enqueue: async (db, payload, enqueueOptions) => currentJobs().enqueue(definition, db, payload, enqueueOptions)
22
+ };
23
+ return definition;
24
+ };
25
+ const isJobDefinition = (value) => typeof value === "object" && value !== null && value.kind === "job" && typeof value.enqueue === "function";
26
+ /** The job definitions among a module's exports, such as `import * as jobs from './config/jobs'`, each name once. */
27
+ const jobDefinitions = (module) => {
28
+ const byName = /* @__PURE__ */ new Map();
29
+ for (const value of Object.values(module)) {
30
+ if (!isJobDefinition(value)) continue;
31
+ const known = byName.get(value.name);
32
+ if (known && known !== value) throw new Error(`Two jobs are named "${value.name}"; rename one of them`);
33
+ byName.set(value.name, value);
34
+ }
35
+ return [...byName.values()];
36
+ };
37
+ //#endregion
38
+ export { defineJob, isJobDefinition, jobDefinitions };
@@ -0,0 +1,21 @@
1
+ import { JobsRuntime } from "./runtime.js";
2
+ //#region src/jobs/drain-route.d.ts
3
+ /**
4
+ * `POST /api/jobs/drain?seconds=25`: runs the app's due jobs until none is left or `seconds` have passed, and answers
5
+ * `{ drained, remaining, nextDue }`. For the operator of a host that lets the app sleep and wakes it for its jobs: it
6
+ * takes the operator's `token` as a bearer token, never a user's.
7
+ */
8
+ export declare const jobsDrainApp: ({ runtime, token }: {
9
+ runtime: JobsRuntime;
10
+ token: string;
11
+ }) => import("hono/hono-base").HonoBase<import("hono/types").BlankEnv, {
12
+ "/api/jobs/drain": {
13
+ $post: {
14
+ input: {};
15
+ output: {};
16
+ outputFormat: string;
17
+ status: import("hono/utils/http-status").StatusCode;
18
+ };
19
+ };
20
+ }, "/", "/api/jobs/drain">;
21
+ //#endregion
@@ -0,0 +1,28 @@
1
+ import { HttpProblem, badRequest, problemResponse, unauthorized } from "../problem.js";
2
+ import { Hono } from "hono";
3
+ import { createHash, timingSafeEqual } from "node:crypto";
4
+ //#region src/jobs/drain-route.ts
5
+ const drainPath = "/api/jobs/drain";
6
+ const digest = (text) => createHash("sha256").update(text).digest();
7
+ const sameToken = (given, token) => given !== void 0 && timingSafeEqual(digest(given), digest(token));
8
+ const maxSeconds = 300;
9
+ /**
10
+ * `POST /api/jobs/drain?seconds=25`: runs the app's due jobs until none is left or `seconds` have passed, and answers
11
+ * `{ drained, remaining, nextDue }`. For the operator of a host that lets the app sleep and wakes it for its jobs: it
12
+ * takes the operator's `token` as a bearer token, never a user's.
13
+ */
14
+ const jobsDrainApp = ({ runtime, token }) => new Hono().post(drainPath, async (c) => {
15
+ const problem = (error) => problemResponse(error, c.req.path);
16
+ if (!sameToken(c.req.header("authorization")?.replace(/^Bearer /, ""), token)) return problem(unauthorized("Send the operator token as a bearer token"));
17
+ const seconds = Number(c.req.query("seconds") ?? 25);
18
+ if (!Number.isFinite(seconds) || seconds <= 0 || seconds > maxSeconds) return problem(badRequest("invalid-seconds", `seconds must be more than 0 and at most ${maxSeconds}`));
19
+ if (!runtime.works) return problem(new HttpProblem(409, "jobs-elsewhere", "Conflict", "This process runs no jobs (PROTOBASE_JOBS=off); drain the one that does"));
20
+ const { drained, remaining, nextDue } = await runtime.drain(seconds);
21
+ return c.json({
22
+ drained,
23
+ remaining,
24
+ nextDue: nextDue?.toISOString() ?? null
25
+ });
26
+ });
27
+ //#endregion
28
+ export { drainPath, jobsDrainApp };
@@ -0,0 +1,67 @@
1
+ import { jobsSchema } from "./schema.js";
2
+ import { f, l, resource, view } from "@protobase/schema";
3
+ //#region src/jobs/failed-jobs.ts
4
+ const admins = ({ user }) => user?.roles?.includes("admin") ?? false;
5
+ /**
6
+ * The failed jobs, over the `jobs.failed` view: the admin app lists them with their last error, and retries or deletes
7
+ * one through the view's triggers. Admins only, whatever the project's roles say.
8
+ */
9
+ const failedJobs = resource("failedJobs").table(`${jobsSchema}.failed`).fields({
10
+ id: f.uuid().readOnly().filterable(),
11
+ name: f.text().readOnly().filterable().sortable(),
12
+ error: f.text().optional().readOnly(),
13
+ attempts: f.integer().readOnly(),
14
+ failedAt: f.timestamp().readOnly().filterable().sortable(),
15
+ createdAt: f.timestamp().readOnly().sortable(),
16
+ key: f.text().optional().readOnly().filterable(),
17
+ payload: f.json().optional().readOnly(),
18
+ output: f.json().optional().readOnly(),
19
+ retry: f.boolean()
20
+ }).primaryKey((r) => r.id).access({
21
+ list: admins,
22
+ read: admins,
23
+ update: admins,
24
+ delete: admins,
25
+ create: () => false
26
+ });
27
+ const failedJobsView = view("failedJobs").names({
28
+ singular: "Failed job",
29
+ plural: "Failed jobs"
30
+ }).icon("triangle-alert").title((r) => r.name).help("Jobs that used up their attempts, or failed for good. Retry one once its cause is fixed: it gets one more attempt.").fields((r) => ({
31
+ name: r.name.label("Job"),
32
+ error: r.error.label("Last error"),
33
+ failedAt: r.failedAt.label("Failed"),
34
+ createdAt: r.createdAt.label("Enqueued"),
35
+ output: r.output.label("Error details")
36
+ })).list((r) => ({
37
+ columns: [
38
+ r.name,
39
+ r.error,
40
+ r.attempts,
41
+ r.failedAt
42
+ ],
43
+ sort: [[r.failedAt, "desc"]]
44
+ })).layout((r) => [
45
+ l.section("Failure", [r.error, r.output]),
46
+ l.section("Job", [
47
+ r.name,
48
+ r.key,
49
+ r.payload
50
+ ]),
51
+ l.sidebar([
52
+ r.attempts,
53
+ r.failedAt,
54
+ r.createdAt
55
+ ])
56
+ ]).actions((a) => [a.update("retry", {
57
+ label: "Retry",
58
+ icon: "rotate-ccw",
59
+ set: { retry: true },
60
+ confirm: "Run this job once more, from now?"
61
+ }), a.remove("delete", {
62
+ label: "Delete",
63
+ icon: "trash-2",
64
+ confirm: "Delete this job? It will not run again."
65
+ })]);
66
+ //#endregion
67
+ export { failedJobs, failedJobsView };
@@ -0,0 +1,126 @@
1
+ /** The comment each Protobase function in the jobs schema carries, which the startup check reads. */
2
+ const functionsComment = `Protobase jobs 2`;
3
+ /** Protobase's own functions beside pg-boss's tables, by name; each carries `functionsComment`. */
4
+ const functionNames = [
5
+ "enqueue",
6
+ "next_due",
7
+ "failed_retry",
8
+ "failed_delete"
9
+ ];
10
+ /**
11
+ * `jobs.enqueue(name, payload, run_at, key, group_key, priority)`: a job from SQL, such as a trigger, in the caller's
12
+ * transaction. It inserts what pg-boss's `send()` does, with the queue's retry settings; it creates a queue that no
13
+ * worker has created yet. A `key` keeps one waiting job, as `mode: 'dedupe'` does, and an ordered job takes its group
14
+ * as `group_key`. Tied to the pg-boss schema version, which the startup check pins.
15
+ */
16
+ const enqueue = (schema) => `create or replace function ${schema}.enqueue(
17
+ job_name text,
18
+ payload jsonb default null,
19
+ run_at timestamptz default null,
20
+ key text default null,
21
+ group_key text default null,
22
+ priority integer default 0
23
+ ) returns uuid
24
+ language plpgsql as $$
25
+ declare
26
+ queue ${schema}.queue;
27
+ start timestamptz := coalesce(run_at, ${schema}.job_now());
28
+ job_id uuid;
29
+ begin
30
+ if key is not null and group_key is not null then
31
+ raise exception 'Job "%": an ordered job takes a group_key and no key', job_name;
32
+ end if;
33
+ perform ${schema}.create_queue(job_name, jsonb_build_object('policy', case when group_key is null then 'standard' else 'key_strict_fifo' end));
34
+ select * into queue from ${schema}.queue where name = job_name;
35
+ if (queue.policy = 'key_strict_fifo') <> (group_key is not null) then
36
+ raise exception 'Job "%" %', job_name, case when group_key is null then 'is ordered: pass its group_key' else 'is not ordered: it takes no group_key' end;
37
+ end if;
38
+ if key is not null then
39
+ select id into job_id from ${schema}.job
40
+ where name = job_name and singleton_key = key and state < 'active'
41
+ order by created_on desc limit 1;
42
+ if found then
43
+ return job_id;
44
+ end if;
45
+ end if;
46
+ execute format(
47
+ 'insert into ${schema}.%I (name, data, priority, start_after, singleton_key, retry_limit, retry_delay, retry_backoff,
48
+ retry_delay_max, expire_seconds, deletion_seconds, keep_until, policy, dead_letter, heartbeat_seconds, upsert_by_key)
49
+ values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $4 + $12 * interval ''1s'', $13, $14, $15, $16)
50
+ on conflict do nothing
51
+ returning id',
52
+ queue.table_name)
53
+ using job_name, payload, priority, start, coalesce(key, group_key), queue.retry_limit, queue.retry_delay,
54
+ queue.retry_backoff, queue.retry_delay_max, queue.expire_seconds, queue.deletion_seconds, queue.retention_seconds,
55
+ queue.policy, queue.dead_letter, queue.heartbeat_seconds, key is not null
56
+ into job_id;
57
+ -- A concurrent enqueue of the same key inserted first: its job is the one waiting.
58
+ if job_id is null and key is not null then
59
+ select id into job_id from ${schema}.job
60
+ where name = job_name and singleton_key = key and state < 'active'
61
+ order by created_on desc limit 1;
62
+ end if;
63
+ return job_id;
64
+ end
65
+ $$;
66
+ comment on function ${schema}.enqueue(text, jsonb, timestamptz, text, text, integer) is '${functionsComment}';`;
67
+ /**
68
+ * `jobs.next_due()`: when the next job is due, or `null` when none waits: what a host that sleeps reads to know when to
69
+ * wake the app, through any pooler. A waiting job counts from its start time, unless a failed job holds back its ordered
70
+ * group; a running one from when its try times out, since its worker may have died with the app. Recurring jobs wait
71
+ * as their next run, so they count too.
72
+ */
73
+ const nextDue = (schema) => `create or replace function ${schema}.next_due() returns timestamptz
74
+ language sql stable as $$
75
+ select min(due) from (
76
+ select j.start_after as due from ${schema}.job j
77
+ where j.state < 'active' and not j.blocked
78
+ and not (j.policy = 'key_strict_fifo' and exists (
79
+ select 1 from ${schema}.job h where h.name = j.name and h.singleton_key = j.singleton_key and h.state = 'failed'))
80
+ union all
81
+ select started_on + expire_seconds * interval '1s' from ${schema}.job where state = 'active'
82
+ ) jobs
83
+ $$;
84
+ comment on function ${schema}.next_due() is '${functionsComment}';`;
85
+ /**
86
+ * `jobs.failed`: the failed jobs, for the admin app's page, with their last error. Updating `retry` to true retries a
87
+ * job, as pg-boss's `retry()` does: one more attempt, from now; deleting a row deletes the job.
88
+ */
89
+ const failed = (schema) => `create index if not exists job_failed_i on ${schema}.job (completed_on) where state = 'failed';
90
+ drop view if exists ${schema}.failed;
91
+ create view ${schema}.failed as
92
+ select id, name, data as payload, retry_count + 1 as attempts,
93
+ coalesce(output ->> 'message', output -> 'value' ->> 'message', output ->> 'value') as error,
94
+ output, completed_on as failed_at, created_on as created_at, singleton_key as key, false as retry
95
+ from ${schema}.job where state = 'failed';
96
+ create or replace function ${schema}.failed_retry() returns trigger
97
+ language plpgsql as $$
98
+ begin
99
+ if new.retry then
100
+ update ${schema}.job
101
+ set state = 'retry', retry_limit = retry_limit + 1, completed_on = null,
102
+ start_after = greatest(start_after, ${schema}.job_now()), keep_until = greatest(keep_until, ${schema}.job_now() + interval '14 days')
103
+ where name = old.name and id = old.id and state = 'failed';
104
+ end if;
105
+ return new;
106
+ end
107
+ $$;
108
+ comment on function ${schema}.failed_retry() is '${functionsComment}';
109
+ create or replace function ${schema}.failed_delete() returns trigger
110
+ language plpgsql as $$
111
+ begin
112
+ delete from ${schema}.job where name = old.name and id = old.id and state = 'failed';
113
+ return old;
114
+ end
115
+ $$;
116
+ comment on function ${schema}.failed_delete() is '${functionsComment}';
117
+ create trigger failed_retry instead of update on ${schema}.failed for each row execute function ${schema}.failed_retry();
118
+ create trigger failed_delete instead of delete on ${schema}.failed for each row execute function ${schema}.failed_delete();`;
119
+ /** The statements that create or replace every Protobase object in `schema`. */
120
+ const functionsSql = (schema) => [
121
+ enqueue(schema),
122
+ nextDue(schema),
123
+ failed(schema)
124
+ ].join("\n\n");
125
+ //#endregion
126
+ export { functionNames, functionsComment, functionsSql };
@@ -0,0 +1,73 @@
1
+ import { StandardSchema } from "../functions/read-json.js";
2
+ import { Db } from "@protobase/query";
3
+ //#region src/jobs/job-types.d.ts
4
+ /** What `run` gets beside the payload. */
5
+ export type JobContext = {
6
+ /** The database pool. The enqueuer's transaction has committed by the time a job runs. */
7
+ db: Db;
8
+ job: {
9
+ id: string;
10
+ name: string;
11
+ attempt: number;
12
+ maxAttempts: number;
13
+ };
14
+ /** Aborted when the job's timeout passes, or when the worker shuts down and its grace period ends. */
15
+ signal: AbortSignal;
16
+ };
17
+ export type JobOptions<Payload> = {
18
+ /** Letters, digits, `_`, `-`, `.` and `/`; the job's queue in the `jobs` schema. */
19
+ name: string;
20
+ /** Checks the payload when it is enqueued and again before it runs, such as a zod schema. */
21
+ payload?: StandardSchema<Payload>;
22
+ run: (payload: Payload, context: JobContext) => Promise<void> | void;
23
+ /** Tries before the job is kept as failed, listed in the admin app. Default 10. */
24
+ maxAttempts?: number;
25
+ /** The wait before the first retry, doubling with each one: milliseconds or text such as `30 seconds`. Default `2s`. */
26
+ retryDelay?: number | string;
27
+ /** The longest wait between two tries. Default `1 hour`. */
28
+ maxRetryDelay?: number | string;
29
+ /** How long one try may run before it counts as failed. Default `15 minutes`. */
30
+ timeout?: number | string;
31
+ /** Jobs of this name running at once in each worker process. Default 1. */
32
+ concurrency?: number;
33
+ /**
34
+ * Jobs run one at a time per group, in the order they were enqueued, and every enqueue names its group. A job that is
35
+ * retrying or failed holds back the rest of its group until it succeeds, or is retried or deleted in the admin app.
36
+ */
37
+ ordered?: boolean;
38
+ /**
39
+ * Runs the job on this cron schedule, such as `0 7 * * *` (with an optional seconds field first), with no payload: one
40
+ * run per time, however many workers run. A run missed while no worker ran happens once, as soon as one does.
41
+ */
42
+ schedule?: string;
43
+ /** The time zone `schedule` is read in, such as `Europe/Amsterdam`. Default `UTC`. */
44
+ timezone?: string;
45
+ };
46
+ /** Where and how one enqueue lands; a job runs as soon as possible by default. */
47
+ export type EnqueueOptions = {
48
+ /** When the job may run first. */
49
+ runAt?: Date;
50
+ /** How long from now before the job may run first: milliseconds or text such as `5 minutes`. */
51
+ delay?: number | string;
52
+ /**
53
+ * Keeps one waiting job per key; a running job does not count. With `mode: 'dedupe'` (the default) an enqueue while
54
+ * one waits leaves it as it is, with `'replace'` it takes this payload and these options. Not for ordered jobs.
55
+ */
56
+ key?: string;
57
+ mode?: 'dedupe' | 'replace';
58
+ /** The group of an ordered job; required there, refused elsewhere. */
59
+ group?: string;
60
+ /** Higher runs first. Default 0. */
61
+ priority?: number;
62
+ };
63
+ export type JobDefinition<Payload = any> = {
64
+ readonly kind: 'job';
65
+ readonly name: string;
66
+ readonly options: JobOptions<Payload>;
67
+ /**
68
+ * Adds a job and returns its id: in `db`'s own transaction when it is one, so the job commits with the caller's
69
+ * writes or not at all. With a `key` it returns the waiting job's id when that one is kept.
70
+ */
71
+ enqueue(db: Db, ...args: Payload extends undefined ? [payload?: Payload, options?: EnqueueOptions] : [payload: Payload, options?: EnqueueOptions]): Promise<string>;
72
+ };
73
+ //#endregion
@@ -0,0 +1,44 @@
1
+ import { parseDuration } from "../files/duration.js";
2
+ //#region src/jobs/options.ts
3
+ /** pg-boss counts in whole seconds; a duration rounds up, so a wait is never shorter than asked. */
4
+ const seconds = (value, label) => Math.ceil(parseDuration(value, label) / 1e3);
5
+ const positive = (value, label) => {
6
+ if (!Number.isSafeInteger(value) || value < 1) throw new Error(`${label} must be a whole number of at least 1, not ${value}`);
7
+ return value;
8
+ };
9
+ /** The pg-boss queue a job definition runs in: its policy, retries with backoff and timeout. */
10
+ const queueOptions = (options) => {
11
+ const label = `Job "${options.name}"`;
12
+ return {
13
+ policy: options.ordered ? "key_strict_fifo" : "standard",
14
+ retryLimit: positive(options.maxAttempts ?? 10, `${label} maxAttempts`) - 1,
15
+ retryDelay: positive(seconds(options.retryDelay ?? "2s", `${label} retryDelay`), `${label} retryDelay in seconds`),
16
+ retryBackoff: true,
17
+ retryDelayMax: positive(seconds(options.maxRetryDelay ?? "1 hour", `${label} maxRetryDelay`), `${label} maxRetryDelay in seconds`),
18
+ expireInSeconds: positive(seconds(options.timeout ?? "15 minutes", `${label} timeout`), `${label} timeout in seconds`)
19
+ };
20
+ };
21
+ /** The queue settings `queueOptions` sets, as `getQueue` reads them back, so a changed definition is noticed. */
22
+ const queueSettingsDiffer = (wanted, stored) => Object.keys(wanted).some((key) => key !== "policy" && stored[key] !== wanted[key]);
23
+ /**
24
+ * The pg-boss send options of one enqueue: when, how urgent, and the key a waiting job is found by. An ordered job's
25
+ * group is its key in pg-boss, which keeps one group's jobs in order.
26
+ */
27
+ const sendOptions = (options, enqueue = {}) => {
28
+ const label = `Job "${options.name}"`;
29
+ if (enqueue.runAt && enqueue.delay !== void 0) throw new Error(`${label}: pass runAt or delay, not both`);
30
+ if (options.ordered && enqueue.group === void 0) throw new Error(`${label} is ordered: name the group each job runs in with { group }`);
31
+ if (options.ordered && enqueue.key !== void 0) throw new Error(`${label} is ordered: its jobs are found by their group, so it takes no key`);
32
+ if (!options.ordered && enqueue.group !== void 0) throw new Error(`${label} is not ordered: a group needs ordered: true in defineJob`);
33
+ if (enqueue.mode && enqueue.key === void 0) throw new Error(`${label}: mode needs a key`);
34
+ if (enqueue.priority !== void 0 && !Number.isSafeInteger(enqueue.priority)) throw new Error(`${label}: priority must be a whole number`);
35
+ const startAfter = enqueue.runAt ?? (enqueue.delay === void 0 ? void 0 : seconds(enqueue.delay, `${label} delay`));
36
+ const singletonKey = options.ordered ? enqueue.group : enqueue.key;
37
+ return {
38
+ ...startAfter !== void 0 && { startAfter },
39
+ ...enqueue.priority !== void 0 && { priority: enqueue.priority },
40
+ ...singletonKey !== void 0 && { singletonKey }
41
+ };
42
+ };
43
+ //#endregion
44
+ export { queueOptions, queueSettingsDiffer, sendOptions };
@@ -0,0 +1,9 @@
1
+ import "./job-types.js";
2
+ import { Db } from "@protobase/query";
3
+ import "pg-boss";
4
+ //#region src/jobs/run-job.d.ts
5
+ /** Thrown from a job's `run`, it fails the job now: the attempts it has left are not used. */
6
+ export declare class FatalJobError extends Error {
7
+ name: string;
8
+ }
9
+ //#endregion
@@ -0,0 +1,78 @@
1
+ //#region src/jobs/run-job.ts
2
+ /** Thrown from a job's `run`, it fails the job now: the attempts it has left are not used. */
3
+ var FatalJobError = class extends Error {
4
+ name = "FatalJobError";
5
+ };
6
+ /** The payload as the job's `payload` schema returns it, or why it does not pass. */
7
+ const validatePayload = async (definition, payload) => {
8
+ const schema = definition.options.payload;
9
+ if (!schema) return { value: payload };
10
+ const result = await schema["~standard"].validate(payload);
11
+ if (!result.issues) return { value: result.value };
12
+ const issues = result.issues.map((issue) => {
13
+ const field = (issue.path ?? []).map((part) => String(typeof part === "object" ? part.key : part)).join(".");
14
+ return field ? `${field}: ${issue.message}` : issue.message;
15
+ });
16
+ return { invalid: `Job "${definition.name}" has an invalid payload: ${issues.join("; ")}` };
17
+ };
18
+ const checkPayload = async (definition, payload) => {
19
+ const checked = await validatePayload(definition, payload);
20
+ if ("invalid" in checked) throw new FatalJobError(checked.invalid);
21
+ return checked.value;
22
+ };
23
+ const readOnly = (error) => error?.code === "25006";
24
+ const pause = (ms, signal) => new Promise((resolve) => {
25
+ if (signal.aborted) return resolve();
26
+ const timer = setTimeout(resolve, ms);
27
+ signal.addEventListener("abort", () => {
28
+ clearTimeout(timer);
29
+ resolve();
30
+ }, { once: true });
31
+ });
32
+ /** Runs `run` again every `waitMs` while the database is read-only, until the signal ends the try. */
33
+ const throughWriteFreeze = async (run, signal, waitMs = 5e3) => {
34
+ for (;;) try {
35
+ return await run();
36
+ } catch (error) {
37
+ if (!readOnly(error) || signal.aborted) throw error;
38
+ await pause(waitMs, signal);
39
+ }
40
+ };
41
+ /** What a failed try keeps as the job's output, which the admin app shows as its last error. */
42
+ const errorOutput = (error) => error instanceof Error ? {
43
+ name: error.name,
44
+ message: error.message,
45
+ ...error.stack && { stack: error.stack }
46
+ } : { message: String(error) };
47
+ /**
48
+ * One try of one job: completed when `run` returns, failed (to be retried while attempts are left) when it throws, and
49
+ * failed for good on a `FatalJobError` or an invalid payload.
50
+ */
51
+ const runJob = async (definition, job, db) => {
52
+ try {
53
+ const payload = await checkPayload(definition, job.data ?? void 0);
54
+ const context = {
55
+ db,
56
+ job: {
57
+ id: job.id,
58
+ name: job.name,
59
+ attempt: job.retryCount + 1,
60
+ maxAttempts: job.retryLimit + 1
61
+ },
62
+ signal: job.signal
63
+ };
64
+ await throughWriteFreeze(() => definition.options.run(payload, context), job.signal);
65
+ return {
66
+ id: job.id,
67
+ status: "completed"
68
+ };
69
+ } catch (error) {
70
+ return {
71
+ id: job.id,
72
+ status: error instanceof FatalJobError ? "deadletter" : "failed",
73
+ output: errorOutput(error)
74
+ };
75
+ }
76
+ };
77
+ //#endregion
78
+ export { FatalJobError, runJob, throughWriteFreeze, validatePayload };
@@ -0,0 +1,46 @@
1
+ import { JobDefinition } from "./job-types.js";
2
+ import { JobsEnqueuer } from "./current.js";
3
+ import { Db } from "@protobase/query";
4
+ //#region src/jobs/runtime.d.ts
5
+ export type StartJobsOptions = {
6
+ db: Db;
7
+ jobs: JobDefinition[];
8
+ /** Runs the jobs in this process; `false` only enqueues them, for a process whose jobs another one runs. Default true. */
9
+ work?: boolean;
10
+ /** Seconds between a worker's checks for due jobs; an enqueue outside a transaction wakes this process's worker at once. Default 2. */
11
+ pollSeconds?: number;
12
+ /** Seconds running jobs get to finish when the jobs stop, before they are failed and retried later. Default 30. */
13
+ shutdownSeconds?: number;
14
+ /** Gets every error the workers and the queue maintenance meet outside a job's own `run`. */
15
+ onError: (error: unknown) => void;
16
+ /** The time recurring jobs are scheduled from; tests pass their own. */
17
+ now?: () => Date;
18
+ };
19
+ /** What a drain left: whether no job is due or running any more, how many are, and when the next one is due. */
20
+ export type DrainResult = {
21
+ drained: boolean;
22
+ remaining: number;
23
+ nextDue: Date | null;
24
+ };
25
+ export type JobsRuntime = JobsEnqueuer & {
26
+ /** Whether this process runs the jobs, or only enqueues them. */
27
+ readonly works: boolean;
28
+ /** Takes the project's jobs again, as `protobase dev` does when their files change. */
29
+ update(jobs: JobDefinition[]): Promise<void>;
30
+ /**
31
+ * Runs the jobs that are due until none is due or running, or `seconds` have passed: for a host that wakes a sleeping
32
+ * app to run its jobs. Needs this process to run jobs.
33
+ */
34
+ drain(seconds: number): Promise<DrainResult>;
35
+ /** When the next job is due, or `null` when none waits (`jobs.next_due()`). */
36
+ nextDue(): Promise<Date | null>;
37
+ /** Stops taking jobs, lets running ones finish within `shutdownSeconds`, then fails and releases the rest. */
38
+ stop(): Promise<void>;
39
+ };
40
+ /**
41
+ * Starts the app's jobs in this process: their queues, a worker per job unless `work` is false, and the maintenance
42
+ * that retries jobs whose worker died. It reads the jobs schema, never changes it: check it first with
43
+ * `checkJobsSchema`. Enqueueing through `defineJob(...).enqueue` reaches the runtime started here.
44
+ */
45
+ export declare const startJobs: ({ db, jobs, work, pollSeconds, shutdownSeconds, onError, now }: StartJobsOptions) => Promise<JobsRuntime>;
46
+ //#endregion
@@ -0,0 +1,156 @@
1
+ import { jobsSchema } from "./schema.js";
2
+ import { clearCurrentJobs, setCurrentJobs } from "./current.js";
3
+ import "./schedule.js";
4
+ import { runJob, validatePayload } from "./run-job.js";
5
+ import { jobsDatabase } from "./database.js";
6
+ import { queueOptions, queueSettingsDiffer, sendOptions } from "./options.js";
7
+ import { createSchedules } from "./schedules.js";
8
+ import { sql } from "kysely";
9
+ import { PgBoss } from "pg-boss";
10
+ //#region src/jobs/runtime.ts
11
+ const createBoss = (db, work) => new PgBoss({
12
+ db: jobsDatabase(db),
13
+ schema: jobsSchema,
14
+ migrate: false,
15
+ createSchema: false,
16
+ schedule: false,
17
+ supervise: work,
18
+ registerInstance: false,
19
+ reindex: false,
20
+ monitorVacuum: false,
21
+ useListenNotify: false
22
+ });
23
+ /**
24
+ * Starts the app's jobs in this process: their queues, a worker per job unless `work` is false, and the maintenance
25
+ * that retries jobs whose worker died. It reads the jobs schema, never changes it: check it first with
26
+ * `checkJobsSchema`. Enqueueing through `defineJob(...).enqueue` reaches the runtime started here.
27
+ */
28
+ const startJobs = async ({ db, jobs, work = true, pollSeconds = 2, shutdownSeconds = 30, onError, now = () => /* @__PURE__ */ new Date() }) => {
29
+ const boss = createBoss(db, work);
30
+ boss.on("error", onError);
31
+ await boss.start();
32
+ const schedules = createSchedules(boss, db, now);
33
+ const definitions = /* @__PURE__ */ new Map();
34
+ const workers = /* @__PURE__ */ new Map();
35
+ const ensureQueue = async (definition) => {
36
+ const wanted = queueOptions(definition.options);
37
+ const queue = await boss.getQueue(definition.name);
38
+ if (!queue) return boss.createQueue(definition.name, wanted);
39
+ if (queue.policy !== wanted.policy) throw new Error(`Job "${definition.name}" ${definition.options.ordered ? "became" : "is no longer"} ordered, but its queue keeps the old order. Let its jobs finish or delete them, drop the queue with \`select ${jobsSchema}.delete_queue('${definition.name}')\`, then start again.`);
40
+ if (queueSettingsDiffer(wanted, queue)) {
41
+ const { policy, ...settings } = wanted;
42
+ await boss.updateQueue(definition.name, settings);
43
+ }
44
+ };
45
+ const startWorker = async (definition) => {
46
+ const concurrency = definition.options.concurrency ?? 1;
47
+ const running = workers.get(definition.name);
48
+ if (running?.concurrency === concurrency) return;
49
+ if (running) await boss.offWork(definition.name);
50
+ const id = await boss.work(definition.name, {
51
+ batchSize: 1,
52
+ localConcurrency: concurrency,
53
+ pollingIntervalSeconds: pollSeconds,
54
+ includeMetadata: true,
55
+ perJobResults: true
56
+ }, async ([job]) => {
57
+ const current = definitions.get(definition.name) ?? definition;
58
+ if (current.options.schedule !== void 0 && job.singletonKey === "protobase.schedule") await schedules.following(current, job.startAfter);
59
+ return [await runJob(current, job, db)];
60
+ });
61
+ workers.set(definition.name, {
62
+ id,
63
+ concurrency
64
+ });
65
+ };
66
+ const update = async (next) => {
67
+ for (const definition of next) await ensureQueue(definition);
68
+ for (const name of definitions.keys()) {
69
+ if (next.some((definition) => definition.name === name)) continue;
70
+ definitions.delete(name);
71
+ if (workers.delete(name)) await boss.offWork(name);
72
+ }
73
+ for (const definition of next) definitions.set(definition.name, definition);
74
+ if (!work) return;
75
+ for (const definition of next) await startWorker(definition);
76
+ await schedules.sync(next);
77
+ };
78
+ const enqueue = async (definition, target, payload, options = {}) => {
79
+ const { name } = definition;
80
+ if (!definitions.has(name)) throw new Error(`Job "${name}" is not among the app's jobs; export it from the module protobase.config.ts passes as \`jobs\``);
81
+ const checked = await validatePayload(definition, payload);
82
+ if ("invalid" in checked) throw new Error(checked.invalid);
83
+ const data = checked.value ?? null;
84
+ const send = {
85
+ ...sendOptions(definition.options, options),
86
+ db: jobsDatabase(target)
87
+ };
88
+ const keyed = async (key) => {
89
+ if (options.mode !== "replace") {
90
+ const [waiting] = await boss.findJobs(name, {
91
+ key,
92
+ queued: true,
93
+ db: send.db
94
+ });
95
+ if (waiting) return waiting.id;
96
+ }
97
+ return (await boss.upsert(name, data, send)).jobs[0];
98
+ };
99
+ const id = options.key === void 0 ? await boss.send(name, data, send) : await keyed(options.key);
100
+ if (!id) throw new Error(`Job "${name}" was not enqueued`);
101
+ if (!target.isTransaction) {
102
+ const worker = workers.get(name);
103
+ if (worker) boss.notifyWorker(worker.id);
104
+ }
105
+ return id;
106
+ };
107
+ const nextDue = async () => (await sql`select ${sql.id(jobsSchema, "next_due")}() as due`.execute(db)).rows[0].due;
108
+ const pending = async () => {
109
+ const job = sql.id(jobsSchema, "job");
110
+ const { rows } = await sql`
111
+ select count(*)::int as count from ${job} j where j.name = any(${[...definitions.keys()]}::text[]) and (
112
+ (j.state < 'active' and not j.blocked and j.start_after <= now()
113
+ and not (j.policy = 'key_strict_fifo' and exists (
114
+ select 1 from ${job} h where h.name = j.name and h.singleton_key = j.singleton_key and h.state = 'failed')))
115
+ or (j.state = 'active' and j.started_on + j.expire_seconds * interval '1s' > now()))`.execute(db);
116
+ return rows[0].count;
117
+ };
118
+ const drain = async (seconds) => {
119
+ if (!work) throw new Error("This process runs no jobs, so it cannot drain them");
120
+ const deadline = Date.now() + seconds * 1e3;
121
+ for (;;) {
122
+ for (const worker of workers.values()) boss.notifyWorker(worker.id);
123
+ const remaining = await pending();
124
+ if (remaining === 0 || Date.now() >= deadline) return {
125
+ drained: remaining === 0,
126
+ remaining,
127
+ nextDue: await nextDue()
128
+ };
129
+ await new Promise((resolve) => setTimeout(resolve, 100));
130
+ }
131
+ };
132
+ const runtime = {
133
+ works: work,
134
+ enqueue,
135
+ update,
136
+ drain,
137
+ nextDue,
138
+ stop: async () => {
139
+ await boss.stop({
140
+ graceful: true,
141
+ timeout: shutdownSeconds * 1e3
142
+ });
143
+ clearCurrentJobs(runtime);
144
+ }
145
+ };
146
+ try {
147
+ await update(jobs);
148
+ setCurrentJobs(runtime);
149
+ } catch (error) {
150
+ await boss.stop({ graceful: false });
151
+ throw error;
152
+ }
153
+ return runtime;
154
+ };
155
+ //#endregion
156
+ export { startJobs };
@@ -0,0 +1,25 @@
1
+ import { createHash } from "node:crypto";
2
+ import { CronExpressionParser } from "cron-parser";
3
+ //#region src/jobs/schedule.ts
4
+ /**
5
+ * The key every run of a recurring job waits under. Each run is a job of its own, enqueued for its time: it is what
6
+ * `jobs.next_due()` sees while the app sleeps, so a host that wakes the app for due jobs wakes it for these too.
7
+ */
8
+ const scheduleKey = "protobase.schedule";
9
+ /** The first run of `schedule` strictly after `after`, in `timezone`; throws on an expression cron-parser refuses. */
10
+ const nextRun = (schedule, timezone, after) => CronExpressionParser.parse(schedule, {
11
+ currentDate: after,
12
+ tz: timezone
13
+ }).next().toDate();
14
+ /** Whether `at` is a run of `schedule`, so a run left from an earlier schedule can be told apart. */
15
+ const isRun = (schedule, timezone, at) => nextRun(schedule, timezone, /* @__PURE__ */ new Date(at.getTime() - 1)).getTime() === at.getTime();
16
+ /**
17
+ * The id of the run of job `name` at `at`: the same in every process, so however many workers enqueue a run, the
18
+ * database keeps one (its primary key), and the run happens once.
19
+ */
20
+ const runId = (name, at) => {
21
+ const hex = createHash("sha256").update(`${name}@${at.toISOString()}`).digest("hex");
22
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-8${hex.slice(13, 16)}-${(parseInt(hex[16], 16) & 3 | 8).toString(16)}${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
23
+ };
24
+ //#endregion
25
+ export { isRun, nextRun, runId, scheduleKey };
@@ -0,0 +1,41 @@
1
+ import { jobsSchema } from "./schema.js";
2
+ import { isRun, nextRun, runId, scheduleKey } from "./schedule.js";
3
+ import { jobsDatabase } from "./database.js";
4
+ import { sql } from "kysely";
5
+ //#region src/jobs/schedules.ts
6
+ const job = sql.id(jobsSchema, "job");
7
+ /** The recurring jobs' runs: enqueued ahead, one per time, and kept in step with their schedules. */
8
+ const createSchedules = (boss, db, now) => {
9
+ const timezone = (definition) => definition.options.timezone ?? "UTC";
10
+ const enqueueRun = (definition, at) => boss.send(definition.name, null, {
11
+ id: runId(definition.name, at),
12
+ startAfter: at,
13
+ singletonKey: scheduleKey,
14
+ db: jobsDatabase(db)
15
+ });
16
+ /**
17
+ * Makes the waiting runs match `definitions`: drops those of jobs without a schedule now, and those at a time the
18
+ * schedule no longer has, then enqueues each schedule's next run. A run that is due stays, to run as soon as it can.
19
+ */
20
+ const sync = async (definitions) => {
21
+ const scheduled = definitions.filter((definition) => definition.options.schedule !== void 0);
22
+ await sql`delete from ${job} where singleton_key = ${scheduleKey} and state < 'active' and not (name = any(${scheduled.map(({ name }) => name)}::text[]))`.execute(db);
23
+ for (const definition of scheduled) {
24
+ const stale = (await sql`
25
+ select id, start_after from ${job} where name = ${definition.name} and singleton_key = ${scheduleKey} and state < 'active'`.execute(db)).rows.filter((run) => !isRun(definition.options.schedule, timezone(definition), new Date(run.start_after))).map((run) => run.id);
26
+ if (stale.length > 0) await sql`delete from ${job} where name = ${definition.name} and id = any(${stale}::uuid[])`.execute(db);
27
+ await enqueueRun(definition, nextRun(definition.options.schedule, timezone(definition), now()));
28
+ }
29
+ };
30
+ /** As a run starts, the one after it: from its own time, or from now when it ran late, so missed times are skipped. */
31
+ const following = async (definition, ranAt) => {
32
+ const from = new Date(Math.max(ranAt.getTime(), now().getTime()));
33
+ await enqueueRun(definition, nextRun(definition.options.schedule, timezone(definition), from));
34
+ };
35
+ return {
36
+ sync,
37
+ following
38
+ };
39
+ };
40
+ //#endregion
41
+ export { createSchedules };
@@ -0,0 +1,15 @@
1
+ import { Db } from "@protobase/query";
2
+ //#region src/jobs/schema.d.ts
3
+ /** The schema the job queue lives in: pg-boss's tables and Protobase's functions. */
4
+ export declare const jobsSchema = "jobs";
5
+ /**
6
+ * The SQL that brings the jobs schema to what this version of Protobase runs: pg-boss's own construction plan when it
7
+ * is missing, or its migration plan from the version installed, then Protobase's functions. `undefined` when current.
8
+ */
9
+ export declare const jobsSchemaMigration: (db: Db) => Promise<string | undefined>;
10
+ /**
11
+ * Refuses to go on while the jobs schema is missing or not the version this Protobase runs. The server never creates
12
+ * or migrates it: the project's migrations do, before the new version serves.
13
+ */
14
+ export declare const checkJobsSchema: (db: Db) => Promise<void>;
15
+ //#endregion
@@ -0,0 +1,63 @@
1
+ import { functionNames, functionsComment, functionsSql } from "./functions-sql.js";
2
+ import { CompiledQuery } from "kysely";
3
+ import { getConstructionPlans, getMigrationPlans } from "pg-boss";
4
+ //#region src/jobs/schema.ts
5
+ /** The schema the job queue lives in: pg-boss's tables and Protobase's functions. */
6
+ const jobsSchema = "jobs";
7
+ /** The command that writes what the database lacks as the project's next migration. */
8
+ const migrationCommand = "protobase migration";
9
+ /**
10
+ * A pg-boss plan as a migration the project applies in its own transaction: without its BEGIN and COMMIT, without the
11
+ * timeouts it sets for that transaction, which would hold for the project's later migrations too, and with index
12
+ * builds that are not `CONCURRENTLY`, which Postgres refuses in a transaction. Migrations run while nothing else
13
+ * writes, so a plain index build only costs a lock nobody waits on.
14
+ */
15
+ const migrationStatements = (plan) => {
16
+ const match = /^\s*BEGIN;\n\s*SET LOCAL lock_timeout = \d+;\n\s*SET LOCAL idle_in_transaction_session_timeout = \d+;\n([\s\S]*?)\n\s*COMMIT;\n?([\s\S]*)$/.exec(plan);
17
+ if (!match) throw new Error(`Unexpected pg-boss plan:\n${plan.slice(0, 200)}`);
18
+ return `${match[1].trim()}\n${match[2]}`.replace(/\b(CREATE (?:UNIQUE )?INDEX|DROP INDEX|REINDEX INDEX) CONCURRENTLY\b/gi, "$1").trim();
19
+ };
20
+ const rows = async (db, text, parameters = []) => (await db.executeQuery(CompiledQuery.raw(text, parameters))).rows;
21
+ /** What the database holds of the jobs schema: pg-boss's version, when installed, and whether Protobase's functions are current. */
22
+ const readJobsSchema = async (db) => {
23
+ const [{ installed }] = await rows(db, `select to_regclass('${jobsSchema}.version') is not null as installed`);
24
+ if (!installed) return {
25
+ pgBoss: void 0,
26
+ functionsCurrent: false
27
+ };
28
+ const [version] = await rows(db, `select version from ${jobsSchema}.version`);
29
+ const functions = await rows(db, `select p.proname as name, obj_description(p.oid, 'pg_proc') as comment from pg_proc p
30
+ join pg_namespace n on n.oid = p.pronamespace where n.nspname = $1 and p.proname = any($2)`, [jobsSchema, functionNames]);
31
+ const functionsCurrent = functionNames.every((name) => functions.some((found) => found.name === name && found.comment === functionsComment));
32
+ return {
33
+ pgBoss: version === void 0 ? void 0 : Number(version.version),
34
+ functionsCurrent
35
+ };
36
+ };
37
+ const newer = (version) => /* @__PURE__ */ new Error(`The jobs schema is at pg-boss schema version ${version}, newer than the 45 this version of Protobase runs; upgrade Protobase`);
38
+ /**
39
+ * The SQL that brings the jobs schema to what this version of Protobase runs: pg-boss's own construction plan when it
40
+ * is missing, or its migration plan from the version installed, then Protobase's functions. `undefined` when current.
41
+ */
42
+ const jobsSchemaMigration = async (db) => {
43
+ const { pgBoss, functionsCurrent } = await readJobsSchema(db);
44
+ if (pgBoss !== void 0 && pgBoss > 45) throw newer(pgBoss);
45
+ if (pgBoss === 45 && functionsCurrent) return void 0;
46
+ const parts = [];
47
+ if (pgBoss === void 0) parts.push(migrationStatements(getConstructionPlans(jobsSchema)));
48
+ else if (pgBoss < 45) parts.push(migrationStatements(getMigrationPlans(jobsSchema, pgBoss)));
49
+ parts.push(functionsSql(jobsSchema));
50
+ return parts.join("\n\n");
51
+ };
52
+ /**
53
+ * Refuses to go on while the jobs schema is missing or not the version this Protobase runs. The server never creates
54
+ * or migrates it: the project's migrations do, before the new version serves.
55
+ */
56
+ const checkJobsSchema = async (db) => {
57
+ const { pgBoss, functionsCurrent } = await readJobsSchema(db);
58
+ if (pgBoss !== void 0 && pgBoss > 45) throw newer(pgBoss);
59
+ if (pgBoss === 45 && functionsCurrent) return;
60
+ throw new Error(`The jobs schema is ${pgBoss === void 0 ? "missing" : "behind this version of Protobase"}. Write it as a migration with \`${migrationCommand}\`, apply it with the project's migrations, then start again.`);
61
+ };
62
+ //#endregion
63
+ export { checkJobsSchema, jobsSchema, jobsSchemaMigration, migrationCommand, migrationStatements, readJobsSchema };
@@ -24,13 +24,15 @@ export type ProjectConfig = {
24
24
  files?: FilesOptions;
25
25
  /** Where the changes that resources' events are raised from are captured (see `EventsOptions`). */
26
26
  events?: EventsOptions;
27
+ /** The module of the app's jobs, e.g. `import * as jobs from './config/jobs'`: every `defineJob` it exports. */
28
+ jobs?: Record<string, unknown>;
27
29
  options?: AdminOptions;
28
30
  };
29
31
  /** Typed identity, for `export default defineConfig({ extends: [...], config, auth, ... })` in `protobase.config.ts`. */
30
32
  export declare const defineConfig: (config: ProjectConfig) => ProjectConfig;
31
33
  /**
32
34
  * One config from several, each after the ones it `extends`, the last being the app's own. Named things are all kept:
33
- * resources, views and pages of the config modules, functions, assistant tools, file providers; one name defined by two configs is an error naming
35
+ * resources, views and pages of the config modules, functions, jobs, assistant tools, file providers; one name defined by two configs is an error naming
34
36
  * both. Write hooks run in the same order. Every other value is the last config's that sets it, so the app's own wins.
35
37
  */
36
38
  export declare const mergeConfig: (...configs: ProjectConfig[]) => ProjectConfig;
@@ -1,3 +1,4 @@
1
+ import { jobDefinitions } from "./jobs/define-job.js";
1
2
  import { isPageDefinition } from "@protobase/layout";
2
3
  //#region src/project-config.ts
3
4
  /** Typed identity, for `export default defineConfig({ extends: [...], config, auth, ... })` in `protobase.config.ts`. */
@@ -104,6 +105,16 @@ const mergedFiles = (named) => {
104
105
  ...secret !== void 0 && { secret }
105
106
  };
106
107
  };
108
+ const mergedJobs = (named) => {
109
+ const all = named.flatMap(({ config, label }) => jobDefinitions(config.jobs ?? {}).map((value) => ({
110
+ name: value.name,
111
+ value,
112
+ label
113
+ })));
114
+ if (all.length === 0) return void 0;
115
+ checkUnique("The job", all);
116
+ return Object.fromEntries(all.map(({ name, value }) => [name, value]));
117
+ };
107
118
  const mergedOptions = (named) => {
108
119
  const all = named.flatMap(({ config }) => config.options ? [config.options] : []);
109
120
  if (all.length === 0) return void 0;
@@ -117,7 +128,7 @@ const mergedOptions = (named) => {
117
128
  };
118
129
  /**
119
130
  * One config from several, each after the ones it `extends`, the last being the app's own. Named things are all kept:
120
- * resources, views and pages of the config modules, functions, assistant tools, file providers; one name defined by two configs is an error naming
131
+ * resources, views and pages of the config modules, functions, jobs, assistant tools, file providers; one name defined by two configs is an error naming
121
132
  * both. Write hooks run in the same order. Every other value is the last config's that sets it, so the app's own wins.
122
133
  */
123
134
  const mergeConfig = (...configs) => {
@@ -131,6 +142,7 @@ const mergeConfig = (...configs) => {
131
142
  const module = mergedModule(named);
132
143
  const functions = mergedFunctions(named);
133
144
  const files = mergedFiles(named);
145
+ const jobs = mergedJobs(named);
134
146
  const options = mergedOptions(named);
135
147
  const name = configs.at(-1)?.name;
136
148
  return {
@@ -142,6 +154,7 @@ const mergeConfig = (...configs) => {
142
154
  ...functions && { functions },
143
155
  ...files && { files },
144
156
  ...events && { events },
157
+ ...jobs && { jobs },
145
158
  ...options && { options }
146
159
  };
147
160
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/server",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
4
4
  "description": "The Protobase REST API as a Hono app, with Better Auth sign-in.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -30,18 +30,20 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@better-auth/passkey": "^1.7.7",
33
- "@protobase/layout": "0.8.1",
34
- "@protobase/query": "0.8.1",
35
- "@protobase/schema": "0.8.1",
33
+ "@protobase/layout": "0.8.2",
34
+ "@protobase/query": "0.8.2",
35
+ "@protobase/schema": "0.8.2",
36
36
  "@scalar/hono-api-reference": "^0.12.10",
37
37
  "@simplewebauthn/server": "^13.3.1",
38
38
  "better-auth": "^1.7.7",
39
+ "cron-parser": "^5.10.1",
39
40
  "file-type": "^22.1.1",
40
41
  "hono": "^4.13.13",
41
42
  "image-size": "^2.0.4",
42
43
  "jose": "^6.2.12",
43
44
  "kysely": "^0.29.6",
44
45
  "nodemailer": "^10.0.16",
46
+ "pg-boss": "12.37.1",
45
47
  "zod": "^4.6.5"
46
48
  }
47
49
  }