@protobase/server 0.8.2 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/dist/active-role.js +8 -0
  2. package/dist/admin-options.d.ts +8 -0
  3. package/dist/api-cors.d.ts +5 -0
  4. package/dist/api-cors.js +53 -0
  5. package/dist/batch/run-batch.js +4 -2
  6. package/dist/better-auth/admin-only.js +5 -3
  7. package/dist/better-auth/authenticator.d.ts +1 -1
  8. package/dist/better-auth/authenticator.js +1 -1
  9. package/dist/better-auth/create-auth.d.ts +166 -160
  10. package/dist/better-auth/create-auth.js +40 -10
  11. package/dist/better-auth/operator-provider.d.ts +2 -1
  12. package/dist/better-auth/origins.d.ts +4 -0
  13. package/dist/better-auth/origins.js +43 -0
  14. package/dist/better-auth/passkey-domain.js +41 -0
  15. package/dist/better-auth/passkey-plugin.js +151 -0
  16. package/dist/better-auth/roles/active-role.js +26 -0
  17. package/dist/better-auth/roles/roles-plugin.js +73 -0
  18. package/dist/better-auth/roles/session-role.js +28 -0
  19. package/dist/better-auth/staff-sign-in-endpoints.js +9 -2
  20. package/dist/better-auth/status-route.js +5 -1
  21. package/dist/better-auth/tokens.d.ts +2 -1
  22. package/dist/better-auth/tokens.js +5 -2
  23. package/dist/changes/actor.js +7 -1
  24. package/dist/changes/capture-schema.js +1 -1
  25. package/dist/changes/capture-sql.js +13 -2
  26. package/dist/changes/coalesce.d.ts +4 -0
  27. package/dist/changes/coalesce.js +2 -1
  28. package/dist/changes/outbox.d.ts +14 -0
  29. package/dist/changes/outbox.js +51 -0
  30. package/dist/changes/resource-change.d.ts +2 -0
  31. package/dist/changes/resource-change.js +2 -1
  32. package/dist/check-shell.js +11 -2
  33. package/dist/create-admin.d.ts +13 -6
  34. package/dist/create-admin.js +42 -8
  35. package/dist/index.d.ts +13 -5
  36. package/dist/index.js +14 -8
  37. package/dist/jobs/current.d.ts +2 -0
  38. package/dist/jobs/current.js +3 -1
  39. package/dist/jobs/failed-jobs.js +10 -10
  40. package/dist/jobs/functions-sql.js +8 -5
  41. package/dist/jobs/runtime.js +9 -5
  42. package/dist/meta.js +9 -2
  43. package/dist/openapi/components.js +1 -1
  44. package/dist/openapi/document.js +7 -5
  45. package/dist/openapi/naming.js +2 -1
  46. package/dist/project-config.d.ts +4 -1
  47. package/dist/project-config.js +10 -2
  48. package/dist/resource-source.d.ts +9 -3
  49. package/dist/resource-source.js +8 -3
  50. package/dist/routes/docs.js +2 -2
  51. package/dist/types.d.ts +5 -1
  52. package/dist/webhooks/attempt.js +91 -0
  53. package/dist/webhooks/retry-schedule.js +40 -0
  54. package/dist/webhooks/route-events.js +61 -0
  55. package/dist/webhooks/standard-webhooks.d.ts +4 -0
  56. package/dist/webhooks/standard-webhooks.js +36 -0
  57. package/dist/webhooks/subscriber-view.js +76 -0
  58. package/dist/webhooks/subscription.d.ts +92 -0
  59. package/dist/webhooks/subscription.js +28 -0
  60. package/dist/webhooks/table/address-check.js +28 -0
  61. package/dist/webhooks/table/options.d.ts +23 -0
  62. package/dist/webhooks/table/options.js +14 -0
  63. package/dist/webhooks/table/owner-roles.js +29 -0
  64. package/dist/webhooks/table/plugin.d.ts +10 -0
  65. package/dist/webhooks/table/plugin.js +18 -0
  66. package/dist/webhooks/table/schema-sql.js +116 -0
  67. package/dist/webhooks/table/schema.d.ts +13 -0
  68. package/dist/webhooks/table/schema.js +44 -0
  69. package/dist/webhooks/table/table-subscriptions.js +77 -0
  70. package/dist/webhooks/table/validate-hook.js +32 -0
  71. package/dist/webhooks/table/webhook-table.js +23 -0
  72. package/dist/webhooks/test-event.js +48 -0
  73. package/dist/webhooks/webhooks.js +233 -0
  74. package/dist/write-pipeline.js +4 -2
  75. package/package.json +4 -4
@@ -1,3 +1,4 @@
1
+ import { jobsSchema } from "../jobs/schema.js";
1
2
  //#region src/changes/capture-sql.ts
2
3
  const quoteIdent = (name) => `"${name.replaceAll("\"", "\"\"")}"`;
3
4
  const quoteLiteral = (text) => `'${text.replaceAll("'", "''")}'`;
@@ -31,10 +32,16 @@ const outboxSql = (schema) => `create table if not exists ${quoteIdent(schema)}.
31
32
  "dispatch_after" timestamptz not null default now(),
32
33
  "created_at" timestamptz not null default now()
33
34
  )`;
35
+ /** The job that turns captured changes into events and deliveries: one per writing transaction, run one at a time. */
36
+ const dispatchJobName = "changes.dispatch";
37
+ /** Every dispatch job's group in its ordered queue, so only one runs at a time, across processes too. */
38
+ const dispatchGroup = "changes";
34
39
  /**
35
40
  * The body of `capture()`: its arguments are the resource, the columns to keep and the sensitive columns, whose names
36
41
  * it lists when they change but whose values it never keeps. It skips a transaction that set `<schema>.capture` to
37
- * `off`, and an update that changed none of those columns.
42
+ * `off`, and an update that changed none of those columns. The first row a transaction captures enqueues a dispatch
43
+ * job in the same transaction; `<schema>.dispatch` remembers that for the rest of it, and a rolled-back savepoint
44
+ * forgets it with the job.
38
45
  */
39
46
  const captureBody = (schema) => `
40
47
  declare
@@ -71,6 +78,10 @@ begin
71
78
  changed,
72
79
  nullif(current_setting('${schema}.actor', true), '')::jsonb
73
80
  );
81
+ if current_setting('${schema}.dispatch', true) is distinct from 'queued' then
82
+ perform ${jobsSchema}.enqueue('${dispatchJobName}', null, null, null, '${dispatchGroup}');
83
+ perform set_config('${schema}.dispatch', 'queued', true);
84
+ end if;
74
85
  return null;
75
86
  end
76
87
  `;
@@ -105,4 +116,4 @@ const createTriggerSql = (schema, trigger, table) => {
105
116
  return `create trigger ${quoteIdent(trigger.name)} ${timing} on ${table} for each row${when} execute function ${quoteIdent(schema)}."capture"(${args})`;
106
117
  };
107
118
  //#endregion
108
- export { captureBody, captureFunctionSql, createTriggerSql, dropTriggerSql, outboxSql, quoteIdent, resourceTriggers, tableSql };
119
+ export { captureBody, captureFunctionSql, createTriggerSql, dispatchGroup, dispatchJobName, dropTriggerSql, outboxSql, quoteIdent, resourceTriggers, tableSql };
@@ -14,6 +14,8 @@ export type CapturedRow = {
14
14
  /** The columns an update changed, sensitive ones included; `null` for inserts and deletes. */
15
15
  changed: string[] | null;
16
16
  actor: EventActor | null;
17
+ /** When the writing transaction started, ISO 8601. */
18
+ at: string;
17
19
  };
18
20
  /**
19
21
  * One record's changes in one transaction, as one: its columns at the start (`before`, none when it was inserted) and
@@ -28,6 +30,8 @@ export type CoalescedChange = {
28
30
  actor: EventActor | null;
29
31
  /** The id of its last outbox row. */
30
32
  sequence: string;
33
+ /** When its last change's transaction started. */
34
+ at: string;
31
35
  };
32
36
  /**
33
37
  * The outbox rows of one or more transactions, in id order, as one change per (transaction, record). An insert then a
@@ -17,7 +17,8 @@ const merged = ({ first, last, actor, listed }) => {
17
17
  transaction: first.transaction,
18
18
  resource: first.resource,
19
19
  actor,
20
- sequence: last.id
20
+ sequence: last.id,
21
+ at: last.at
21
22
  };
22
23
  const before = first.op === "INSERT" ? null : first.old;
23
24
  const after = last.op === "DELETE" ? null : last.new;
@@ -0,0 +1,14 @@
1
+ import { Row } from "../types.js";
2
+ import { CapturedRow } from "./coalesce.js";
3
+ import { ResourceModel } from "@protobase/schema";
4
+ import { Db } from "@protobase/query";
5
+ //#region src/changes/outbox.d.ts
6
+ /**
7
+ * A captured row's columns from the JSON text the database stores: the columns of 64-bit integer and decimal fields as
8
+ * their exact digits, other numbers as numbers. Needs a runtime that passes JSON.parse revivers the source text
9
+ * (Node 21 and later, Bun).
10
+ */
11
+ export declare const parseColumns: (text: string | null, model?: ResourceModel) => Row | null;
12
+ /** The oldest `limit` rows of the outbox that committed, in id order. */
13
+ export declare const readOutbox: (db: Db, schema: string, models: readonly ResourceModel[], limit: number) => Promise<CapturedRow[]>;
14
+ //#endregion
@@ -0,0 +1,51 @@
1
+ import { quoteIdent } from "./capture-sql.js";
2
+ import { sql } from "kysely";
3
+ //#region src/changes/outbox.ts
4
+ var ExactNumber = class {
5
+ source;
6
+ constructor(source) {
7
+ this.source = source;
8
+ }
9
+ };
10
+ const exact = (_key, value, context) => typeof value === "number" && context?.source !== void 0 ? new ExactNumber(context.source) : value;
11
+ const plain = (value) => {
12
+ if (value instanceof ExactNumber) return Number(value.source);
13
+ if (Array.isArray(value)) return value.map(plain);
14
+ if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, plain(item)]));
15
+ return value;
16
+ };
17
+ const textTypes = /* @__PURE__ */ new Set(["bigint", "decimal"]);
18
+ /**
19
+ * A captured row's columns from the JSON text the database stores: the columns of 64-bit integer and decimal fields as
20
+ * their exact digits, other numbers as numbers. Needs a runtime that passes JSON.parse revivers the source text
21
+ * (Node 21 and later, Bun).
22
+ */
23
+ const parseColumns = (text, model) => {
24
+ if (text === null) return null;
25
+ const parsed = JSON.parse(text, exact);
26
+ const types = new Map(Object.values(model?.fields ?? {}).map((field) => [field.column, field.type]));
27
+ return Object.fromEntries(Object.entries(parsed).map(([column, value]) => [column, value instanceof ExactNumber && textTypes.has(types.get(column) ?? "") ? value.source : plain(value)]));
28
+ };
29
+ /** The oldest `limit` rows of the outbox that committed, in id order. */
30
+ const readOutbox = async (db, schema, models, limit) => {
31
+ const byName = new Map(models.map((model) => [model.name, model]));
32
+ const { rows } = await sql`select "id"::text as id, "transaction"::text as transaction, "resource", "op", "old"::text as old, "new"::text as new, "changed",
33
+ "actor"::text as actor, to_char("created_at" at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') as at
34
+ from ${sql.raw(`${quoteIdent(schema)}."outbox"`)} where "dispatch_after" <= now() order by "id" limit ${limit}`.execute(db);
35
+ return rows.map((row) => {
36
+ const model = byName.get(row.resource);
37
+ return {
38
+ ...row,
39
+ old: parseColumns(row.old, model),
40
+ new: parseColumns(row.new, model),
41
+ actor: row.actor === null ? null : JSON.parse(row.actor)
42
+ };
43
+ });
44
+ };
45
+ /** Removes dispatched rows from the outbox. */
46
+ const deleteOutbox = async (db, schema, ids) => {
47
+ if (ids.length === 0) return;
48
+ await sql`delete from ${sql.raw(`${quoteIdent(schema)}."outbox"`)} where "id" = any(${ids}::bigint[])`.execute(db);
49
+ };
50
+ //#endregion
51
+ export { deleteOutbox, parseColumns, readOutbox };
@@ -19,6 +19,8 @@ export type ResourceChange = {
19
19
  transaction: string;
20
20
  /** Grows with every change of a record, so receivers can drop stale events. */
21
21
  sequence: string;
22
+ /** When the change's transaction started, ISO 8601. */
23
+ at: string;
22
24
  };
23
25
  /** A captured row by field name, fields without a captured column left out. */
24
26
  export declare const recordOf: (model: ResourceModel, columns: Row) => Row;
@@ -36,7 +36,8 @@ const resourceChange = (model, change) => {
36
36
  source: change.actor ? "api" : "database",
37
37
  ...tenant != null && { tenant },
38
38
  transaction: change.transaction,
39
- sequence: change.sequence
39
+ sequence: change.sequence,
40
+ at: change.at
40
41
  };
41
42
  };
42
43
  //#endregion
@@ -1,4 +1,4 @@
1
- import { navRecentErrors } from "@protobase/schema";
1
+ import { bottomBarErrors, navRecentErrors } from "@protobase/schema";
2
2
  //#region src/check-shell.ts
3
3
  /** Fails at startup when a sidebar group or a user menu entry does not fit the resources, instead of on the first page load. */
4
4
  const checkShell = (registry, views, userMenu, pages = []) => {
@@ -13,5 +13,14 @@ const checkShell = (registry, views, userMenu, pages = []) => {
13
13
  if (item.kind === "page" && !pages.includes(item.page)) throw new Error(`User menu: page "${item.page}" does not exist`);
14
14
  }
15
15
  };
16
+ /** Fails at startup when a bottom bar names a resource, page or role the app does not have, or bars share a role. */
17
+ const checkBottomBars = (registry, bars, pages, roles) => {
18
+ const errors = bottomBarErrors(bars, {
19
+ resources: registry.entries.map((entry) => entry.name),
20
+ pages,
21
+ ...roles && { roles }
22
+ });
23
+ if (errors.length > 0) throw new Error(errors.join("; "));
24
+ };
16
25
  //#endregion
17
- export { checkShell };
26
+ export { checkBottomBars, checkShell };
@@ -1,13 +1,14 @@
1
1
  import { AdminAuth } from "./better-auth/create-auth.js";
2
2
  import { EventsOptions } from "./changes/events-options.js";
3
+ import { JobDefinition } from "./jobs/job-types.js";
4
+ import { Admin, Authenticator, PageSource, ViewSource } from "./types.js";
5
+ import { WebhooksOptions } from "./webhooks/table/options.js";
6
+ import { SubscriptionDefinition } from "./webhooks/subscription.js";
3
7
  import { FilesOptions } from "./files/options.js";
4
- import { AdminEnv, Authenticator, PageSource, ViewSource } from "./types.js";
5
8
  import { ResourceSource } from "./resource-source.js";
6
9
  import { FunctionSource } from "./functions/define-function.js";
7
10
  import { AdminOptions } from "./admin-options.js";
8
- import { JobDefinition } from "./jobs/job-types.js";
9
- import { Hono } from "hono";
10
- import { UserMenuSource } from "@protobase/schema";
11
+ import { BottomBarSource, UserMenuSource } from "@protobase/schema";
11
12
  import { Db } from "@protobase/query";
12
13
  //#region src/create-admin.d.ts
13
14
  export type CreateAdminInput = {
@@ -15,6 +16,8 @@ export type CreateAdminInput = {
15
16
  views?: ViewSource[];
16
17
  /** The `userMenu(...)` export of the config module: pages under the signed-in user in the sidebar. */
17
18
  userMenu?: UserMenuSource;
19
+ /** The `bottomBar(...)` exports of the config module, in order: tabs on phones for the roles each names. */
20
+ bottomBars?: BottomBarSource[];
18
21
  /** Composed pages (`page()` from @protobase/layout), served by /meta and checked against the resources here. */
19
22
  pages?: PageSource[];
20
23
  db: Db;
@@ -27,10 +30,14 @@ export type CreateAdminInput = {
27
30
  files?: FilesOptions;
28
31
  /** Where the changes that resources' events are raised from are captured (see `EventsOptions`). */
29
32
  events?: EventsOptions;
30
- /** The app's jobs (see `defineJob`): with any, admins get the failed jobs page (`failedJobs`). */
33
+ /** The app's jobs (see `defineJob`): with any, or a resource with events, admins get the failed jobs page (`failed_jobs`). */
31
34
  jobs?: JobDefinition[];
35
+ /** Where the resources' events are delivered (`subscription(...)` exports of the config module). */
36
+ subscriptions?: SubscriptionDefinition[];
37
+ /** Webhook subscriptions made in the admin app, from `extends: [webhooks()]` (see `WebhooksOptions`). */
38
+ webhooks?: WebhooksOptions;
32
39
  options?: AdminOptions;
33
40
  };
34
41
  /** The admin REST API as a Hono app. It uses only Web APIs, so it runs on Node, Workers and anywhere else Hono does. */
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, "/">;
42
+ export declare const createAdmin: ({ resources: appResources, views: appViews, pages, userMenu, bottomBars, db, authenticate, auth, functions, files: filesOptions, events, jobs, subscriptions, webhooks: webhooksOptions, options }: CreateAdminInput) => Admin;
36
43
  //#endregion
@@ -3,16 +3,21 @@ import { createErrorHandler } from "./error-handler.js";
3
3
  import { assistantRecords } from "./assistant/records.js";
4
4
  import { builtInAssistant } from "./assistant/built-in-assistant.js";
5
5
  import { consoleAuditQueue } from "./audit/console-queue.js";
6
+ import { webauthnPath } from "./better-auth/origins.js";
7
+ import { apiCors, readCorsOrigins } from "./api-cors.js";
6
8
  import { authBasePath } from "./better-auth/create-auth.js";
7
9
  import { organizationRoutes } from "./better-auth/organizations/admin-routes.js";
8
10
  import { statusRoute } from "./better-auth/status-route.js";
9
11
  import { changesSchema } from "./changes/events-options.js";
10
- import { checkShell } from "./check-shell.js";
12
+ import { callerRecords } from "./functions/records.js";
13
+ import { toApiFunction } from "./functions/define-function.js";
14
+ import { createWebhooks } from "./webhooks/webhooks.js";
15
+ import { webhookTable } from "./webhooks/table/webhook-table.js";
16
+ import { subscriptionWriteCheck } from "./webhooks/table/validate-hook.js";
17
+ import { checkBottomBars, checkShell } from "./check-shell.js";
11
18
  import { checkViews } from "./check-views.js";
12
19
  import { createFilesRuntime } from "./files/runtime.js";
13
20
  import { fileRoutes } from "./files/routes.js";
14
- import { toApiFunction } from "./functions/define-function.js";
15
- import { callerRecords } from "./functions/records.js";
16
21
  import { functionRoutes } from "./functions/routes.js";
17
22
  import { runtimeUrl } from "./runtime/runtime-settings.js";
18
23
  import { createMeta } from "./meta.js";
@@ -27,17 +32,24 @@ import { failedJobs, failedJobsView } from "./jobs/failed-jobs.js";
27
32
  import { Hono } from "hono";
28
33
  import { checkPages } from "@protobase/layout";
29
34
  //#region src/create-admin.ts
35
+ const declaredRoles = (auth, roles) => {
36
+ const names = [...auth?.roles ?? [], ...Object.keys(roles?.definitions ?? {})];
37
+ return names.length > 0 ? [...new Set(names)] : void 0;
38
+ };
30
39
  /** The admin REST API as a Hono app. It uses only Web APIs, so it runs on Node, Workers and anywhere else Hono does. */
31
- const createAdmin = ({ resources: appResources, views: appViews = [], pages = [], userMenu, db, authenticate, auth, functions = {}, files: filesOptions, events, jobs = [], options = {} }) => {
40
+ const createAdmin = ({ resources: appResources, views: appViews = [], pages = [], userMenu, bottomBars = [], db, authenticate, auth, functions = {}, files: filesOptions, events, jobs = [], subscriptions = [], webhooks: webhooksOptions, options = {} }) => {
32
41
  const basePath = options.basePath ?? "/api/v1";
33
- const resources = jobs.length > 0 ? [...appResources, failedJobs] : appResources;
34
- const views = jobs.length > 0 ? [...appViews, failedJobsView] : appViews;
42
+ const withJobs = jobs.length > 0 || appResources.some((resource) => resource.toModel().events);
43
+ const resources = withJobs ? [...appResources, failedJobs] : appResources;
44
+ const views = withJobs ? [...appViews, failedJobsView] : appViews;
35
45
  const registry = buildRegistry(resources);
36
46
  const apiFunctions = Object.fromEntries(Object.entries(functions).map(([name, source]) => [name, toApiFunction(name, source)]));
37
47
  const viewModels = views.map((view) => view.toModel());
38
48
  const userMenuModel = userMenu?.toUserMenuModel();
39
49
  const pageModels = pages.map((entry) => entry.toModel());
40
50
  checkShell(registry, viewModels, userMenuModel, pageModels.map((entry) => entry.name));
51
+ const bottomBarModels = bottomBars.map((bar) => bar.toBottomBarModel());
52
+ checkBottomBars(registry, bottomBarModels, pageModels.map((entry) => entry.name), declaredRoles(auth, options.roles));
41
53
  checkViews(registry, viewModels);
42
54
  checkPages(pageModels, {
43
55
  models: Object.fromEntries(registry.entries.map((entry) => [entry.name, entry.model])),
@@ -52,17 +64,19 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
52
64
  models: registry.entries.map((entry) => entry.model),
53
65
  systemPath
54
66
  });
67
+ const table = webhooksOptions && webhookTable(webhooksOptions, auth);
55
68
  const deps = {
56
69
  db,
57
70
  registry,
58
71
  views: viewModels,
59
72
  pages: pageModels,
60
73
  ...userMenuModel && { userMenu: userMenuModel },
74
+ bottomBars: bottomBarModels,
61
75
  basePath,
62
76
  systemPath,
63
77
  statementTimeoutMs: options.statementTimeoutMs ?? 15e3,
64
78
  guard: createScanGuard(db, options.scanGuard),
65
- hooks: options.writeHooks ?? [],
79
+ hooks: [...options.writeHooks ?? [], ...table ? [subscriptionWriteCheck(registry.entries.map((entry) => entry.model), table.checkUrl)] : []],
66
80
  audit: options.audit ?? consoleAuditQueue(),
67
81
  accessOptions: {
68
82
  ...options.roles && { roles: options.roles },
@@ -100,6 +114,17 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
100
114
  });
101
115
  const app = new Hono();
102
116
  app.onError(onError);
117
+ const corsOrigins = options.cors ?? readCorsOrigins(globalThis.process?.env ?? {});
118
+ const ownOrigins = auth?.origins ?? [];
119
+ if (corsOrigins !== void 0 || ownOrigins.length > 1) {
120
+ const answering = [authBasePath, ...Object.entries(apiFunctions).filter(([, fn]) => fn.options.cors).map(([name]) => `${systemPath}/functions/${name}`)];
121
+ const skip = (path) => answering.some((prefix) => path === prefix || path.startsWith(`${prefix}/`));
122
+ app.use(`${systemPath}/*`, apiCors({
123
+ own: ownOrigins,
124
+ allowed: corsOrigins ?? [],
125
+ skip
126
+ }));
127
+ }
103
128
  if (auth) {
104
129
  app.route(authBasePath, statusRoute(auth));
105
130
  if (auth.organizations) app.route(authBasePath, organizationRoutes({
@@ -109,6 +134,7 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
109
134
  db
110
135
  }));
111
136
  app.on(["GET", "POST"], `${authBasePath}/*`, (c) => auth.handler(c.req.raw));
137
+ if (auth.origins.length > 1) app.get(webauthnPath, (c) => c.json({ origins: auth.origins }));
112
138
  }
113
139
  if (assistant?.kind === "built-in") {
114
140
  const tools = options.assistant ? options.assistant.tools : void 0;
@@ -136,7 +162,15 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
136
162
  app.route("/", fileRoutes(deps, authenticate));
137
163
  app.route(basePath, api);
138
164
  app.route(deps.systemPath || "/", system);
139
- return app;
165
+ const webhooks = createWebhooks({
166
+ deps,
167
+ subscriptions,
168
+ functions: apiFunctions,
169
+ schema: changesSchema(events),
170
+ log: (line) => console.log(line),
171
+ ...table && { table }
172
+ });
173
+ return Object.assign(app, { jobs: webhooks.jobs });
140
174
  };
141
175
  //#endregion
142
176
  export { createAdmin };
package/dist/index.d.ts CHANGED
@@ -1,21 +1,26 @@
1
1
  import { SignInPolicy, SignInRule, StaffAccessRule, defaultSignInPolicy, signInPolicyMethods } from "./better-auth/sign-in-policy.js";
2
2
  import { StaffSignIn } from "./better-auth/staff-log.js";
3
- import { RoleDefinition, RolesInput } from "./better-auth/organizations/role-definitions.js";
4
3
  import { OperatorProvider, readOperatorSettings } from "./better-auth/operator-provider.js";
4
+ import { RoleDefinition, RolesInput } from "./better-auth/organizations/role-definitions.js";
5
5
  import { OrganizationRole, StoredMember, StoredOrganization } from "./better-auth/organizations/store.js";
6
6
  import { OrganizationsOptions } from "./better-auth/organizations/options.js";
7
7
  import { PlatformSignIn, readPlatformSignIn } from "./better-auth/platform-sign-in.js";
8
8
  import { AdminAuth, CreateAuthOptions, UserCreatedHook, createAuth, normalizeRoles } from "./better-auth/create-auth.js";
9
9
  import { EventsOptions } from "./changes/events-options.js";
10
+ import { StandardSchema, readJson } from "./functions/read-json.js";
11
+ import { EnqueueOptions, JobContext, JobDefinition, JobOptions } from "./jobs/job-types.js";
12
+ import { Admin, AuditEvent, AuditQueue, Authenticator, PipelineHook, ScanGuardMode, Session, SessionOrganization, TenantValue, User, ViewSource, WriteEvent, WriteOperation } from "./types.js";
13
+ import { WebhooksOptions } from "./webhooks/table/options.js";
14
+ import { EventData, RunContext, SubscriptionDefinition, SubscriptionEvent, SubscriptionOptions, subscription } from "./webhooks/subscription.js";
10
15
  import { FileProvider, FileStore, StoredObject } from "./files/store.js";
11
16
  import { FilesOptions } from "./files/options.js";
12
- import { Admin, AuditEvent, AuditQueue, Authenticator, PipelineHook, ScanGuardMode, Session, SessionOrganization, TenantValue, User, ViewSource, WriteEvent, WriteOperation } from "./types.js";
13
17
  import { CleanupEntry, CleanupSchedule, ScheduledDelete, storageCleanupSchedule } from "./files/cleanup-schedule.js";
14
18
  import { CleanupReport } from "./files/cleanup-runner.js";
15
19
  import { ResourceSource, configExports } from "./resource-source.js";
16
20
  import { RecordKeyInput, ToolRecord, ToolRecords } from "./assistant/records.js";
17
21
  import { FunctionRecords, ListParams, RecordPage } from "./functions/records.js";
18
22
  import { ApiFunction, CorsOptions, FunctionApp, FunctionCaller, FunctionContext, FunctionHandler, FunctionOptions, FunctionSource, ProtectedFunctionOptions, PublicFunctionContext, PublicFunctionHandler, PublicFunctionOptions, defineFunction } from "./functions/define-function.js";
23
+ import { CorsOrigins } from "./api-cors.js";
19
24
  import { ChatMessage, ChatTextPart, Completion, CompletionOptions, ModelError, ModelSettings, ReasoningDetail, ReasoningEffort, ToolCall, ToolSpec, streamChatCompletion } from "./assistant/chat-completions.js";
20
25
  import { ConversationRecord, ConversationStore } from "./assistant/conversation-store.js";
21
26
  import { Conversation, ConversationsOptions, conversationKey, createConversations } from "./assistant/conversations.js";
@@ -24,8 +29,6 @@ import { AssistantTool, ToolContext, defineTool } from "./assistant/tools.js";
24
29
  import { AssistantOptions, AssistantSettings, assistantRoles, assistantSettings, assistantVariables, seesAssistant } from "./assistant/assistant-settings.js";
25
30
  import { RuntimeOptions, runtimeUrl, runtimeVariable, seesRuntime } from "./runtime/runtime-settings.js";
26
31
  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";
29
32
  import { CreateAdminInput, createAdmin } from "./create-admin.js";
30
33
  import { ProjectConfig, defineConfig, mergeConfig } from "./project-config.js";
31
34
  import { consoleAuditQueue } from "./audit/console-queue.js";
@@ -35,11 +38,16 @@ import { BetterAuthAuthenticatorOptions, betterAuthAuthenticator } from "./bette
35
38
  import { parseRoles } from "./better-auth/parse-roles.js";
36
39
  import { LinkedAccount, NewUser, StoredUser, createUser, deleteUser, hasUsers, listUsers, roleChoices, setUserBanned, setUserRole } from "./better-auth/users.js";
37
40
  import { IssueTokenOptions, issueToken, maxTokenTtlSeconds } from "./better-auth/tokens.js";
41
+ import { webauthnPath } from "./better-auth/origins.js";
38
42
  import { authSchemaMigration, checkAuthSchema } from "./better-auth/auth-schema.js";
39
43
  import { changesMigration, checkChanges, migrationCommand } from "./changes/capture-schema.js";
40
44
  import { CapturedRow, CoalescedChange, coalesce } from "./changes/coalesce.js";
41
45
  import { ResourceChange, recordOf, resourceChange } from "./changes/resource-change.js";
42
46
  import { eventMatcher } from "./changes/match-events.js";
47
+ import { parseColumns, readOutbox } from "./changes/outbox.js";
48
+ import { newWebhookSecret } from "./webhooks/standard-webhooks.js";
49
+ import { webhooks } from "./webhooks/table/plugin.js";
50
+ import { checkWebhooksSchema, webhooksMigration } from "./webhooks/table/schema.js";
43
51
  import { MemberInput, NewOrganization, addAppRoleToOwners, addMember, createOrganization, listOrganizations, removeMember, setMemberRoles } from "./better-auth/organizations/host.js";
44
52
  import { AssistantBackend, assistantProtocolRoutes } from "./assistant/protocol-routes.js";
45
53
  import { fileConversationStore } from "./assistant/file-conversation-store.js";
@@ -60,4 +68,4 @@ import { FatalJobError } from "./jobs/run-job.js";
60
68
  import { DrainResult, JobsRuntime, StartJobsOptions, startJobs } from "./jobs/runtime.js";
61
69
  import { jobsDrainApp } from "./jobs/drain-route.js";
62
70
  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 };
71
+ 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 CorsOrigins, type CreateAdminInput, type CreateAuthOptions, type DrainResult, type EnqueueOptions, type EventData, 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 RunContext, 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 SubscriptionDefinition, type SubscriptionEvent, type SubscriptionOptions, type TenantValue, type ToolCall, type ToolContext, type ToolRecord, type ToolRecords, type ToolSpec, type TurnOptions, type User, type UserCreatedHook, type ViewSource, type WebhooksOptions, type WidgetOptions, type WriteEvent, type WriteOperation, addAppRoleToOwners, addMember, aspectRatio, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, authSchemaMigration, badRequest, betterAuthAuthenticator, changesMigration, checkAuthSchema, checkChanges, checkJobsSchema, checkWebhooksSchema, 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, newWebhookSecret, normalizeRoles, notFound, parseColumns, parseRoles, processor, readJson, readOnlyQueryTool, readOperatorSettings, readOutbox, readPlatformSignIn, readWriteQueryTool, recordOf, removeMember, requestApproval, requireEnv, resourceChange, roleChoices, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, seesAssistant, seesRuntime, setMemberRoles, setUserBanned, setUserRole, signInPolicyMethods, startJobs, storageCleanupSchedule, streamChatCompletion, subscription, unauthorized, verifySignature, verifyStandardWebhook, webauthnPath, webhooks, webhooksMigration, widget, withCacheBreakpoints };
package/dist/index.js CHANGED
@@ -9,8 +9,10 @@ import { readOnlyQueryTool, readWriteQueryTool } from "./assistant/query-tools.j
9
9
  import { requestApproval } from "./assistant/approval.js";
10
10
  import { withCacheBreakpoints } from "./assistant/cache-breakpoints.js";
11
11
  import { ModelError, streamChatCompletion } from "./assistant/chat-completions.js";
12
+ import { checkJobsSchema, jobsSchema, jobsSchemaMigration } from "./jobs/schema.js";
12
13
  import { runTurn } from "./assistant/turn.js";
13
14
  import { consoleAuditQueue } from "./audit/console-queue.js";
15
+ import { webauthnPath } from "./better-auth/origins.js";
14
16
  import { readOperatorSettings } from "./better-auth/operator-provider.js";
15
17
  import { parseRoles } from "./better-auth/parse-roles.js";
16
18
  import { defaultSignInPolicy, signInPolicyMethods } from "./better-auth/sign-in-policy.js";
@@ -18,23 +20,28 @@ import { readPlatformSignIn } from "./better-auth/platform-sign-in.js";
18
20
  import { createAuth, normalizeRoles } from "./better-auth/create-auth.js";
19
21
  import { addAppRoleToOwners, addMember, createOrganization, listOrganizations, removeMember, setMemberRoles } from "./better-auth/organizations/host.js";
20
22
  import { createUser, deleteUser, hasUsers, listUsers, roleChoices, setUserBanned, setUserRole } from "./better-auth/users.js";
23
+ import { coalesce } from "./changes/coalesce.js";
24
+ import { eventMatcher } from "./changes/match-events.js";
25
+ import { parseColumns, readOutbox } from "./changes/outbox.js";
26
+ import { recordOf, resourceChange } from "./changes/resource-change.js";
27
+ import { defineJob, isJobDefinition, jobDefinitions } from "./jobs/define-job.js";
28
+ import { FatalJobError } from "./jobs/run-job.js";
29
+ import { defineFunction } from "./functions/define-function.js";
30
+ import { newWebhookSecret } from "./webhooks/standard-webhooks.js";
31
+ import { subscription } from "./webhooks/subscription.js";
21
32
  import { storageCleanupSchedule } from "./files/cleanup-schedule.js";
22
33
  import { localFileStore, localFiles } from "./files/local-files.js";
23
- import { defineFunction } from "./functions/define-function.js";
24
34
  import { runtimeUrl, runtimeVariable, seesRuntime } from "./runtime/runtime-settings.js";
25
- import { checkJobsSchema, jobsSchema, jobsSchemaMigration } from "./jobs/schema.js";
26
35
  import { createAdmin } from "./create-admin.js";
27
36
  import { configExports } from "./resource-source.js";
28
- import { defineJob, isJobDefinition, jobDefinitions } from "./jobs/define-job.js";
29
37
  import { defineConfig, mergeConfig } from "./project-config.js";
30
38
  import { jwtAuthenticator } from "./auth/jwt.js";
31
39
  import { betterAuthAuthenticator } from "./better-auth/authenticator.js";
32
40
  import { issueToken, maxTokenTtlSeconds } from "./better-auth/tokens.js";
33
41
  import { authSchemaMigration, checkAuthSchema } from "./better-auth/auth-schema.js";
34
42
  import { changesMigration, checkChanges, migrationCommand } from "./changes/capture-schema.js";
35
- import { coalesce } from "./changes/coalesce.js";
36
- import { recordOf, resourceChange } from "./changes/resource-change.js";
37
- import { eventMatcher } from "./changes/match-events.js";
43
+ import { webhooks } from "./webhooks/table/plugin.js";
44
+ import { checkWebhooksSchema, webhooksMigration } from "./webhooks/table/schema.js";
38
45
  import { maxWidgetPropsBytes, widget } from "./assistant/widget.js";
39
46
  import { functionApp } from "./functions/function-app.js";
40
47
  import { readJson } from "./functions/read-json.js";
@@ -42,7 +49,6 @@ import { verifySignature, verifyStandardWebhook } from "./functions/verify-signa
42
49
  import { requireEnv } from "./functions/require-env.js";
43
50
  import { createFileCleanup } from "./files/file-cleanup.js";
44
51
  import { aspectRatio, fileSize, fileType, imageSize, processor } from "./files/processors.js";
45
- import { FatalJobError } from "./jobs/run-job.js";
46
52
  import { startJobs } from "./jobs/runtime.js";
47
53
  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 };
54
+ export { FatalJobError, HttpProblem, ModelError, addAppRoleToOwners, addMember, aspectRatio, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, authSchemaMigration, badRequest, betterAuthAuthenticator, changesMigration, checkAuthSchema, checkChanges, checkJobsSchema, checkWebhooksSchema, 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, newWebhookSecret, normalizeRoles, notFound, parseColumns, parseRoles, processor, readJson, readOnlyQueryTool, readOperatorSettings, readOutbox, readPlatformSignIn, readWriteQueryTool, recordOf, removeMember, requestApproval, requireEnv, resourceChange, roleChoices, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, seesAssistant, seesRuntime, setMemberRoles, setUserBanned, setUserRole, signInPolicyMethods, startJobs, storageCleanupSchedule, streamChatCompletion, subscription, unauthorized, verifySignature, verifyStandardWebhook, webauthnPath, webhooks, webhooksMigration, widget, withCacheBreakpoints };
@@ -4,5 +4,7 @@ import { Db } from "@protobase/query";
4
4
  /** What `defineJob(...).enqueue` reaches: the jobs runtime this process started. */
5
5
  export type JobsEnqueuer = {
6
6
  enqueue(definition: JobDefinition, db: Db, payload: unknown, options?: EnqueueOptions): Promise<string>;
7
+ /** Has this process's worker of the job look for due jobs now rather than at its next poll; nothing without one. */
8
+ wake(name: string): void;
7
9
  };
8
10
  //#endregion
@@ -7,6 +7,8 @@ const currentJobs = () => {
7
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
8
  return runtime;
9
9
  };
10
+ /** The running jobs runtime, if this process started one. */
11
+ const runningJobs = () => holder[key];
10
12
  const setCurrentJobs = (runtime) => {
11
13
  if (holder[key]) throw new Error("Jobs are already running in this process; stop them before starting them again");
12
14
  holder[key] = runtime;
@@ -15,4 +17,4 @@ const clearCurrentJobs = (runtime) => {
15
17
  if (holder[key] === runtime) delete holder[key];
16
18
  };
17
19
  //#endregion
18
- export { clearCurrentJobs, currentJobs, setCurrentJobs };
20
+ export { clearCurrentJobs, currentJobs, runningJobs, setCurrentJobs };
@@ -6,13 +6,13 @@ const admins = ({ user }) => user?.roles?.includes("admin") ?? false;
6
6
  * The failed jobs, over the `jobs.failed` view: the admin app lists them with their last error, and retries or deletes
7
7
  * one through the view's triggers. Admins only, whatever the project's roles say.
8
8
  */
9
- const failedJobs = resource("failedJobs").table(`${jobsSchema}.failed`).fields({
9
+ const failedJobs = resource("failed_jobs").table(`${jobsSchema}.failed`).fields({
10
10
  id: f.uuid().readOnly().filterable(),
11
11
  name: f.text().readOnly().filterable().sortable(),
12
12
  error: f.text().optional().readOnly(),
13
13
  attempts: f.integer().readOnly(),
14
- failedAt: f.timestamp().readOnly().filterable().sortable(),
15
- createdAt: f.timestamp().readOnly().sortable(),
14
+ failed_at: f.timestamp().readOnly().filterable().sortable(),
15
+ created_at: f.timestamp().readOnly().sortable(),
16
16
  key: f.text().optional().readOnly().filterable(),
17
17
  payload: f.json().optional().readOnly(),
18
18
  output: f.json().optional().readOnly(),
@@ -24,23 +24,23 @@ const failedJobs = resource("failedJobs").table(`${jobsSchema}.failed`).fields({
24
24
  delete: admins,
25
25
  create: () => false
26
26
  });
27
- const failedJobsView = view("failedJobs").names({
27
+ const failedJobsView = view("failed_jobs").names({
28
28
  singular: "Failed job",
29
29
  plural: "Failed jobs"
30
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
31
  name: r.name.label("Job"),
32
32
  error: r.error.label("Last error"),
33
- failedAt: r.failedAt.label("Failed"),
34
- createdAt: r.createdAt.label("Enqueued"),
33
+ failed_at: r.failed_at.label("Failed"),
34
+ created_at: r.created_at.label("Enqueued"),
35
35
  output: r.output.label("Error details")
36
36
  })).list((r) => ({
37
37
  columns: [
38
38
  r.name,
39
39
  r.error,
40
40
  r.attempts,
41
- r.failedAt
41
+ r.failed_at
42
42
  ],
43
- sort: [[r.failedAt, "desc"]]
43
+ sort: [[r.failed_at, "desc"]]
44
44
  })).layout((r) => [
45
45
  l.section("Failure", [r.error, r.output]),
46
46
  l.section("Job", [
@@ -50,8 +50,8 @@ const failedJobsView = view("failedJobs").names({
50
50
  ]),
51
51
  l.sidebar([
52
52
  r.attempts,
53
- r.failedAt,
54
- r.createdAt
53
+ r.failed_at,
54
+ r.created_at
55
55
  ])
56
56
  ]).actions((a) => [a.update("retry", {
57
57
  label: "Retry",
@@ -1,5 +1,5 @@
1
1
  /** The comment each Protobase function in the jobs schema carries, which the startup check reads. */
2
- const functionsComment = `Protobase jobs 2`;
2
+ const functionsComment = `Protobase jobs 3`;
3
3
  /** Protobase's own functions beside pg-boss's tables, by name; each carries `functionsComment`. */
4
4
  const functionNames = [
5
5
  "enqueue",
@@ -11,7 +11,9 @@ const functionNames = [
11
11
  * `jobs.enqueue(name, payload, run_at, key, group_key, priority)`: a job from SQL, such as a trigger, in the caller's
12
12
  * transaction. It inserts what pg-boss's `send()` does, with the queue's retry settings; it creates a queue that no
13
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.
14
+ * as `group_key`, and an id from `uuidv7()`, which rises within a database session: pg-boss runs a group's jobs by their
15
+ * transaction's start, then by id, so the jobs one transaction enqueues run in that order. Tied to the pg-boss schema
16
+ * version, which the startup check pins.
15
17
  */
16
18
  const enqueue = (schema) => `create or replace function ${schema}.enqueue(
17
19
  job_name text,
@@ -45,14 +47,15 @@ begin
45
47
  end if;
46
48
  execute format(
47
49
  '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
+ retry_delay_max, expire_seconds, deletion_seconds, keep_until, policy, dead_letter, heartbeat_seconds, upsert_by_key, id)
51
+ values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $4 + $12 * interval ''1s'', $13, $14, $15, $16, $17)
50
52
  on conflict do nothing
51
53
  returning id',
52
54
  queue.table_name)
53
55
  using job_name, payload, priority, start, coalesce(key, group_key), queue.retry_limit, queue.retry_delay,
54
56
  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
57
+ queue.policy, queue.dead_letter, queue.heartbeat_seconds, key is not null,
58
+ case when group_key is null then gen_random_uuid() else uuidv7() end
56
59
  into job_id;
57
60
  -- A concurrent enqueue of the same key inserted first: its job is the one waiting.
58
61
  if job_id is null and key is not null then