@protobase/server 0.8.2 → 0.9.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 (46) hide show
  1. package/dist/batch/run-batch.js +4 -2
  2. package/dist/changes/actor.js +7 -1
  3. package/dist/changes/capture-schema.js +1 -1
  4. package/dist/changes/capture-sql.js +13 -2
  5. package/dist/changes/coalesce.d.ts +4 -0
  6. package/dist/changes/coalesce.js +2 -1
  7. package/dist/changes/outbox.d.ts +14 -0
  8. package/dist/changes/outbox.js +51 -0
  9. package/dist/changes/resource-change.d.ts +2 -0
  10. package/dist/changes/resource-change.js +2 -1
  11. package/dist/create-admin.d.ts +10 -5
  12. package/dist/create-admin.js +20 -7
  13. package/dist/index.d.ts +10 -4
  14. package/dist/index.js +13 -8
  15. package/dist/jobs/current.d.ts +2 -0
  16. package/dist/jobs/current.js +3 -1
  17. package/dist/jobs/runtime.js +7 -5
  18. package/dist/project-config.d.ts +4 -1
  19. package/dist/project-config.js +10 -2
  20. package/dist/resource-source.d.ts +8 -3
  21. package/dist/resource-source.js +6 -3
  22. package/dist/types.d.ts +5 -1
  23. package/dist/webhooks/attempt.js +91 -0
  24. package/dist/webhooks/retry-schedule.js +40 -0
  25. package/dist/webhooks/route-events.js +61 -0
  26. package/dist/webhooks/standard-webhooks.d.ts +4 -0
  27. package/dist/webhooks/standard-webhooks.js +36 -0
  28. package/dist/webhooks/subscriber-view.js +76 -0
  29. package/dist/webhooks/subscription.d.ts +92 -0
  30. package/dist/webhooks/subscription.js +28 -0
  31. package/dist/webhooks/table/address-check.js +28 -0
  32. package/dist/webhooks/table/options.d.ts +23 -0
  33. package/dist/webhooks/table/options.js +14 -0
  34. package/dist/webhooks/table/owner-roles.js +23 -0
  35. package/dist/webhooks/table/plugin.d.ts +10 -0
  36. package/dist/webhooks/table/plugin.js +18 -0
  37. package/dist/webhooks/table/schema-sql.js +116 -0
  38. package/dist/webhooks/table/schema.d.ts +13 -0
  39. package/dist/webhooks/table/schema.js +44 -0
  40. package/dist/webhooks/table/table-subscriptions.js +77 -0
  41. package/dist/webhooks/table/validate-hook.js +32 -0
  42. package/dist/webhooks/table/webhook-table.js +23 -0
  43. package/dist/webhooks/test-event.js +48 -0
  44. package/dist/webhooks/webhooks.js +233 -0
  45. package/dist/write-pipeline.js +4 -2
  46. package/package.json +4 -4
@@ -0,0 +1,116 @@
1
+ import { jobsSchema } from "../../jobs/schema.js";
2
+ import { quoteIdent } from "../../changes/capture-sql.js";
3
+ /** The comment the schema's functions carry, which the startup check reads. */
4
+ const webhooksComment = `Protobase webhooks 1`;
5
+ /** The delivery jobs, whose waiting and failed ones the deliveries view lists. */
6
+ const deliveryJobs = ["webhooks.deliver", "webhooks.deliver-ordered"];
7
+ /** The job that sends a subscription a test event. */
8
+ const sendTestJob = "webhooks.send-test";
9
+ const randomSecret = `'whsec_' || encode(decode(replace(gen_random_uuid()::text || gen_random_uuid()::text, '-', ''), 'hex'), 'base64')`;
10
+ /**
11
+ * The subscriptions table. The owner is the API's caller, from the change capture's `actor` setting; the tenant is set by
12
+ * the API like any tenant column. `rotate_secret`, `send_test` and `resend_failed` are commands: an update that sets one
13
+ * runs it and stores false.
14
+ */
15
+ const subscriptionsSql = (schema, changesSchema) => `create table if not exists ${quoteIdent(schema)}."subscriptions" (
16
+ "id" uuid primary key default gen_random_uuid(),
17
+ "name" text not null,
18
+ "events" text not null,
19
+ "url" text not null,
20
+ "filter" text,
21
+ "headers" jsonb,
22
+ "payload" text not null default 'full' check ("payload" in ('full', 'thin')),
23
+ "enabled" boolean not null default true,
24
+ "secret" text not null default (${randomSecret}),
25
+ "previous_secret" text,
26
+ "previous_secret_until" timestamptz,
27
+ "tenant_id" text,
28
+ "owner_id" text default (nullif(current_setting('${changesSchema}.actor', true), '')::jsonb ->> 'id'),
29
+ "disabled_reason" text,
30
+ "last_success_at" timestamptz,
31
+ "failing_since" timestamptz,
32
+ "created_at" timestamptz not null default now(),
33
+ "updated_at" timestamptz not null default now(),
34
+ "rotate_secret" boolean not null default false,
35
+ "send_test" boolean not null default false,
36
+ "resend_failed" boolean not null default false
37
+ );
38
+ create index if not exists "subscriptions_tenant" on ${quoteIdent(schema)}."subscriptions" ("tenant_id")`;
39
+ const retryJobs = (where) => `update ${jobsSchema}.job
40
+ set state = 'retry', retry_limit = retry_limit + 1, completed_on = null,
41
+ start_after = greatest(start_after, ${jobsSchema}.job_now()), keep_until = greatest(keep_until, ${jobsSchema}.job_now() + interval '14 days')
42
+ where name = any(array['${deliveryJobs.join("', '")}']) and state = 'failed' and ${where}`;
43
+ /** The commands of the subscriptions table, and a re-enabled subscription starting afresh. */
44
+ const commandsSql = (schema) => `create or replace function ${quoteIdent(schema)}."subscription_commands"() returns trigger
45
+ language plpgsql as $$
46
+ begin
47
+ if new.rotate_secret then
48
+ new.previous_secret := old.secret;
49
+ new.previous_secret_until := now() + interval '24 hours';
50
+ new.secret := ${randomSecret};
51
+ new.rotate_secret := false;
52
+ end if;
53
+ if new.send_test then
54
+ perform ${jobsSchema}.enqueue('${sendTestJob}', jsonb_build_object('subscription', new.id));
55
+ new.send_test := false;
56
+ end if;
57
+ if new.resend_failed then
58
+ ${retryJobs(`data ->> 'subscription' = 'ui:' || new.id::text`)};
59
+ new.resend_failed := false;
60
+ end if;
61
+ if new.enabled and not old.enabled then
62
+ new.disabled_reason := null;
63
+ new.failing_since := null;
64
+ end if;
65
+ new.updated_at := now();
66
+ return new;
67
+ end
68
+ $$;
69
+ comment on function ${quoteIdent(schema)}."subscription_commands"() is '${webhooksComment}';
70
+ drop trigger if exists "subscription_commands" on ${quoteIdent(schema)}."subscriptions";
71
+ create trigger "subscription_commands" before update on ${quoteIdent(schema)}."subscriptions" for each row execute function ${quoteIdent(schema)}."subscription_commands"()`;
72
+ /**
73
+ * The deliveries of table subscriptions that wait for a retry or failed for good: the delivery jobs, with their
74
+ * subscription's tenant. Updating `resend` to true runs a failed one once more.
75
+ */
76
+ const deliveriesSql = (schema) => `drop view if exists ${quoteIdent(schema)}."deliveries";
77
+ create view ${quoteIdent(schema)}."deliveries" as
78
+ select j.id, s.id as subscription_id, s.tenant_id,
79
+ coalesce(j.data -> 'event' ->> 'type', j.data -> 'events' -> 0 ->> 'type') as event_type,
80
+ coalesce(j.data -> 'event' ->> 'id', j.data -> 'events' -> 0 ->> 'id') as event_id,
81
+ case when j.state = 'failed' then 'failed' else 'waiting' end as status,
82
+ coalesce((j.data ->> 'attempt')::integer, j.retry_count + 1) as attempt,
83
+ case when j.state = 'failed' then null else j.start_after end as next_attempt,
84
+ coalesce(j.output ->> 'message', j.data ->> 'lastError') as last_error,
85
+ j.created_on as created_at,
86
+ false as resend
87
+ from ${jobsSchema}.job j join ${quoteIdent(schema)}."subscriptions" s on j.data ->> 'subscription' = 'ui:' || s.id::text
88
+ where j.name = any(array['${deliveryJobs.join("', '")}']) and j.state in ('created', 'retry', 'failed');
89
+ create or replace function ${quoteIdent(schema)}."deliveries_resend"() returns trigger
90
+ language plpgsql as $$
91
+ begin
92
+ if new.resend then
93
+ ${retryJobs("id = old.id")};
94
+ end if;
95
+ return new;
96
+ end
97
+ $$;
98
+ comment on function ${quoteIdent(schema)}."deliveries_resend"() is '${webhooksComment}';
99
+ create trigger "deliveries_resend" instead of update on ${quoteIdent(schema)}."deliveries" for each row execute function ${quoteIdent(schema)}."deliveries_resend"()`;
100
+ /** Every try of a table subscription's deliveries, kept 30 days, with `deliveryLog`. */
101
+ const deliveryLogSql = (schema) => `create table if not exists ${quoteIdent(schema)}."delivery_log" (
102
+ "id" bigint generated always as identity primary key,
103
+ "subscription_id" uuid not null references ${quoteIdent(schema)}."subscriptions" ("id") on delete cascade,
104
+ "tenant_id" text,
105
+ "event_id" text not null,
106
+ "event_type" text not null,
107
+ "status" text not null check ("status" in ('delivered', 'failed')),
108
+ "attempt" integer not null,
109
+ "status_code" integer,
110
+ "response" text,
111
+ "duration_ms" integer not null,
112
+ "at" timestamptz not null default now()
113
+ );
114
+ create index if not exists "delivery_log_subscription" on ${quoteIdent(schema)}."delivery_log" ("subscription_id", "at")`;
115
+ //#endregion
116
+ export { commandsSql, deliveriesSql, deliveryJobs, deliveryLogSql, sendTestJob, subscriptionsSql, webhooksComment };
@@ -0,0 +1,13 @@
1
+ import { EventsOptions } from "../../changes/events-options.js";
2
+ import { WebhooksOptions } from "./options.js";
3
+ import { Db } from "@protobase/query";
4
+ //#region src/webhooks/table/schema.d.ts
5
+ /**
6
+ * The SQL that brings the `webhooks` schema to what `webhooks()` needs: the subscriptions table, its commands, the
7
+ * deliveries view and, with `deliveryLog`, the log table. `undefined` when current. Needs the jobs schema, which the
8
+ * deliveries view reads. Tables are never dropped.
9
+ */
10
+ export declare const webhooksMigration: (db: Db, options?: WebhooksOptions, events?: EventsOptions) => Promise<string | undefined>;
11
+ /** Refuses to go on while the `webhooks` schema lacks what `webhooks()` needs; the project's migrations add it. */
12
+ export declare const checkWebhooksSchema: (db: Db, options?: WebhooksOptions) => Promise<void>;
13
+ //#endregion
@@ -0,0 +1,44 @@
1
+ import { quoteIdent } from "../../changes/capture-sql.js";
2
+ import { changesSchema } from "../../changes/events-options.js";
3
+ import { webhooksSchema } from "./options.js";
4
+ import { commandsSql, deliveriesSql, deliveryLogSql, subscriptionsSql, webhooksComment } from "./schema-sql.js";
5
+ import { migrationCommand } from "../../changes/capture-schema.js";
6
+ import { sql } from "kysely";
7
+ //#region src/webhooks/table/schema.ts
8
+ const functionNames = ["subscription_commands", "deliveries_resend"];
9
+ const readWebhooksSchema = async (db, schema) => {
10
+ const exists = async (table) => (await sql`select to_regclass(${`${quoteIdent(schema)}.${quoteIdent(table)}`}) is not null as found`.execute(db)).rows[0].found;
11
+ const { rows } = await sql`select p.proname as name, obj_description(p.oid, 'pg_proc') as comment
12
+ from pg_proc p join pg_namespace n on n.oid = p.pronamespace where n.nspname = ${schema} and p.proname = any(${functionNames}::text[])`.execute(db);
13
+ return {
14
+ subscriptions: await exists("subscriptions"),
15
+ current: functionNames.every((name) => rows.some((row) => row.name === name && row.comment === webhooksComment)) && await exists("deliveries"),
16
+ deliveryLog: await exists("delivery_log")
17
+ };
18
+ };
19
+ /**
20
+ * The SQL that brings the `webhooks` schema to what `webhooks()` needs: the subscriptions table, its commands, the
21
+ * deliveries view and, with `deliveryLog`, the log table. `undefined` when current. Needs the jobs schema, which the
22
+ * deliveries view reads. Tables are never dropped.
23
+ */
24
+ const webhooksMigration = async (db, options = {}, events) => {
25
+ const schema = webhooksSchema(options);
26
+ const found = await readWebhooksSchema(db, schema);
27
+ const needsLog = Boolean(options.deliveryLog) && !found.deliveryLog;
28
+ if (found.subscriptions && found.current && !needsLog) return void 0;
29
+ return `${[
30
+ `create schema if not exists ${quoteIdent(schema)}`,
31
+ ...found.subscriptions ? [] : [subscriptionsSql(schema, changesSchema(events))],
32
+ ...found.current && found.subscriptions ? [] : [commandsSql(schema), deliveriesSql(schema)],
33
+ ...needsLog ? [deliveryLogSql(schema)] : []
34
+ ].join(";\n\n")};`;
35
+ };
36
+ /** Refuses to go on while the `webhooks` schema lacks what `webhooks()` needs; the project's migrations add it. */
37
+ const checkWebhooksSchema = async (db, options = {}) => {
38
+ const schema = webhooksSchema(options);
39
+ const found = await readWebhooksSchema(db, schema);
40
+ if (found.subscriptions && found.current && (!options.deliveryLog || found.deliveryLog)) return;
41
+ throw new Error(`The ${schema} schema is missing or behind this version of Protobase. Write it as a migration with \`${migrationCommand}\`, apply it with the project's migrations, then start again.`);
42
+ };
43
+ //#endregion
44
+ export { checkWebhooksSchema, webhooksMigration };
@@ -0,0 +1,77 @@
1
+ import { quoteIdent } from "../../changes/capture-sql.js";
2
+ import { parseEvents, tableSubscriptionName } from "./options.js";
3
+ import { sql } from "kysely";
4
+ //#region src/webhooks/table/table-subscriptions.ts
5
+ /** A UI subscription is disabled after this long without a success. */
6
+ const failingFor = "3 days";
7
+ /**
8
+ * The subscriptions people made in the admin app, as the dispatcher and the delivery jobs use them: named `ui:<id>`,
9
+ * delivering as their owner with the roles they hold now, signed with their secret and, for 24 hours after a rotation,
10
+ * the previous one too. What happens to their deliveries is kept on their row.
11
+ */
12
+ const tableSubscriptions = ({ schema, ownerRoles, deliveryLog }) => {
13
+ const table = sql.raw(`${quoteIdent(schema)}."subscriptions"`);
14
+ const definition = async (row) => {
15
+ const tenant = row.tenant_id ?? void 0;
16
+ const roles = row.owner_id ? await ownerRoles({
17
+ id: row.owner_id,
18
+ ...tenant !== void 0 && { tenant }
19
+ }) : [];
20
+ return {
21
+ kind: "subscription",
22
+ name: tableSubscriptionName(row.id),
23
+ options: {
24
+ events: parseEvents(row.events),
25
+ url: row.url,
26
+ ...row.filter && { when: row.filter },
27
+ ...row.headers && { headers: row.headers },
28
+ payload: row.payload,
29
+ enabled: row.enabled,
30
+ secret: row.overlapping && row.previous_secret ? [row.secret, row.previous_secret] : row.secret,
31
+ ...tenant !== void 0 && { tenant },
32
+ as: { roles }
33
+ }
34
+ };
35
+ };
36
+ const select = sql`select "id"::text as id, "events", "url", "filter", "headers", "payload", "enabled", "secret", "previous_secret",
37
+ coalesce("previous_secret_until" > now(), false) as overlapping, "tenant_id", "owner_id" from ${table}`;
38
+ /** Every enabled one. */
39
+ const load = async (db) => Promise.all((await sql`${select} where "enabled"`.execute(db)).rows.map(definition));
40
+ /** One by id, enabled or not; `undefined` once it is deleted. */
41
+ const find = async (db, id) => {
42
+ const [row] = (await sql`${select} where "id"::text = ${id}`.execute(db)).rows;
43
+ return row && definition(row);
44
+ };
45
+ /** The row's record of a try: its last success, how long it has been failing, and disabling it when it should stop. */
46
+ const record = async (db, id, event, tried, result, durationMs) => {
47
+ if (result.ok) await sql`update ${table} set "last_success_at" = now(), "failing_since" = null where "id"::text = ${id}`.execute(db);
48
+ else {
49
+ const gone = result.status === 410;
50
+ await sql`update ${table} set "failing_since" = coalesce("failing_since", now()),
51
+ "enabled" = "enabled" and not (${gone} or coalesce("failing_since", now()) < now() - ${failingFor}::interval),
52
+ "disabled_reason" = case
53
+ when ${gone} then 'The receiver answered 410 Gone'
54
+ when coalesce("failing_since", now()) < now() - ${failingFor}::interval then 'No delivery succeeded for ${sql.raw(failingFor)}'
55
+ else "disabled_reason" end
56
+ where "id"::text = ${id}`.execute(db);
57
+ }
58
+ if (!deliveryLog) return;
59
+ const status = result.ok ? "delivered" : "failed";
60
+ const response = result.ok ? null : result.error.slice(0, 2048);
61
+ await sql`insert into ${sql.raw(`${quoteIdent(schema)}."delivery_log"`)} ("subscription_id", "tenant_id", "event_id", "event_type", "status", "attempt", "status_code", "response", "duration_ms")
62
+ select "id", "tenant_id", ${event.id}, ${event.type}, ${status}, ${tried}, ${result.status ?? null}, ${response}, ${Math.round(durationMs)} from ${table} where "id"::text = ${id}`.execute(db);
63
+ };
64
+ /** Deletes the log's tries older than 30 days. */
65
+ const prune = async (db) => {
66
+ await sql`delete from ${sql.raw(`${quoteIdent(schema)}."delivery_log"`)} where "at" < now() - interval '30 days'`.execute(db);
67
+ };
68
+ return {
69
+ load,
70
+ find,
71
+ record,
72
+ prune,
73
+ deliveryLog
74
+ };
75
+ };
76
+ //#endregion
77
+ export { tableSubscriptions };
@@ -0,0 +1,32 @@
1
+ import { badRequest } from "../../problem.js";
2
+ import { subscriptionProblems } from "../route-events.js";
3
+ import { parseEvents } from "./options.js";
4
+ //#region src/webhooks/table/validate-hook.ts
5
+ const isHeaders = (value) => value === null || value === void 0 || typeof value === "object" && !Array.isArray(value) && Object.values(value).every((item) => typeof item === "string");
6
+ /**
7
+ * Checks a webhook subscription as it is written: events that are declared, a filter that checks against every resource
8
+ * it covers, headers of text, and a URL at a public address. A refusal is a 400 problem, and the write rolls back.
9
+ */
10
+ const subscriptionWriteCheck = (models, checkUrl) => async (event) => {
11
+ if (event.resource.name !== "webhookSubscriptions" || !event.after) return;
12
+ const { events, url, filter, headers } = event.after;
13
+ const candidate = {
14
+ kind: "subscription",
15
+ name: "it",
16
+ options: {
17
+ events: parseEvents(events),
18
+ url,
19
+ ...filter && { when: filter }
20
+ }
21
+ };
22
+ const problems = [
23
+ ...parseEvents(events).length === 0 ? ["names no events"] : [],
24
+ ...subscriptionProblems([candidate], models, []).map((problem) => problem.replace("Subscription \"it\" ", "")),
25
+ ...isHeaders(headers) ? [] : ["headers must be an object of text values"]
26
+ ];
27
+ const blocked = problems.length === 0 && checkUrl ? await checkUrl(url) : void 0;
28
+ const all = [...problems, ...blocked ? [blocked] : []];
29
+ if (all.length > 0) throw badRequest("invalid-webhook-subscription", `The webhook subscription cannot be saved: ${all.join("; ")}`);
30
+ };
31
+ //#endregion
32
+ export { subscriptionWriteCheck };
@@ -0,0 +1,23 @@
1
+ import { webhooksSchema } from "./options.js";
2
+ import { checkWebhookUrl } from "./address-check.js";
3
+ import { betterAuthOwnerRoles } from "./owner-roles.js";
4
+ import { tableSubscriptions } from "./table-subscriptions.js";
5
+ //#region src/webhooks/table/webhook-table.ts
6
+ /**
7
+ * What `createAdmin` needs for the subscriptions of `webhooks()`: the table, the owners' current roles (from Better Auth
8
+ * unless the options say otherwise) and, unless allowed, the check that keeps URLs off private addresses.
9
+ */
10
+ const webhookTable = (options, auth) => {
11
+ const ownerRoles = options.ownerRoles ?? (auth && betterAuthOwnerRoles(auth));
12
+ if (!ownerRoles) throw new Error("webhooks() delivers as each subscription's owner, with their current roles: pass `auth` (Better Auth) or `ownerRoles` to webhooks()");
13
+ return {
14
+ subscriptions: tableSubscriptions({
15
+ schema: webhooksSchema(options),
16
+ ownerRoles,
17
+ deliveryLog: Boolean(options.deliveryLog)
18
+ }),
19
+ ...!options.allowPrivateAddresses && { checkUrl: checkWebhookUrl }
20
+ };
21
+ };
22
+ //#endregion
23
+ export { webhookTable };
@@ -0,0 +1,48 @@
1
+ import { HttpProblem } from "../problem.js";
2
+ import { subscriberSession } from "./subscriber-view.js";
3
+ import { eventPatternMatches } from "./subscription.js";
4
+ import { encodeKey } from "@protobase/schema";
5
+ //#region src/webhooks/test-event.ts
6
+ /**
7
+ * What Send test event sends: the first declared event the subscription takes, made up from the newest record of its
8
+ * resource that the subscription may read, or with no record when it may read none. It is marked `test`.
9
+ */
10
+ const testEvent = async (deps, records, subscription) => {
11
+ const { options } = subscription;
12
+ const found = deps.registry.entries.flatMap((entry) => (entry.model.events ?? []).map((event) => ({
13
+ entry,
14
+ name: event.name
15
+ }))).find(({ name }) => options.events.some((pattern) => eventPatternMatches(pattern, name)));
16
+ if (!found) throw new Error(`The subscription "${subscription.name}" takes no declared event`);
17
+ const { entry, name } = found;
18
+ const item = (await records(subscriberSession(subscription) ?? { user: {
19
+ id: `subscription:${subscription.name}`,
20
+ roles: []
21
+ } }).list(entry.name, { page_size: 1 }).catch((error) => {
22
+ if (error instanceof HttpProblem) return void 0;
23
+ throw error;
24
+ }))?.items[0];
25
+ const record = item && Object.fromEntries(Object.entries(item).filter(([field]) => Object.hasOwn(entry.model.fields, field)));
26
+ const thin = options.payload === "thin";
27
+ const tenant = entry.model.tenant && record ? record[entry.model.tenant] : void 0;
28
+ return {
29
+ id: `test_${crypto.randomUUID()}`,
30
+ type: name,
31
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
32
+ data: {
33
+ resource: entry.name,
34
+ operation: "create",
35
+ key: record ? encodeKey(entry.model.primaryKey.map((field) => record[field])) : "",
36
+ sequence: "0",
37
+ ...record && !thin && { after: record },
38
+ changed: [],
39
+ ...tenant !== void 0 && tenant !== null && { tenant },
40
+ actor: null,
41
+ source: "database",
42
+ transaction: "0"
43
+ },
44
+ test: true
45
+ };
46
+ };
47
+ //#endregion
48
+ export { testEvent };
@@ -0,0 +1,233 @@
1
+ import { dispatchJobName } from "../changes/capture-sql.js";
2
+ import { coalesce } from "../changes/coalesce.js";
3
+ import { eventMatcher } from "../changes/match-events.js";
4
+ import { deleteOutbox, readOutbox } from "../changes/outbox.js";
5
+ import { resourceChange } from "../changes/resource-change.js";
6
+ import { callerRecords } from "../functions/records.js";
7
+ import { defineJob } from "../jobs/define-job.js";
8
+ import { FatalJobError } from "../jobs/run-job.js";
9
+ import { maxTries, nextRetryDelay } from "./retry-schedule.js";
10
+ import { subscriberEvents } from "./subscriber-view.js";
11
+ import { attemptDelivery } from "./attempt.js";
12
+ import { subscriptionProblems, subscriptionRouter } from "./route-events.js";
13
+ import { tableSubscriptionName } from "./table/options.js";
14
+ import { sendTestJob } from "./table/schema-sql.js";
15
+ import { testEvent } from "./test-event.js";
16
+ //#region src/webhooks/webhooks.ts
17
+ /** Outbox rows one dispatch reads at a time. */
18
+ const batchSize = 500;
19
+ /** Receivers tried at once by one dispatch. */
20
+ const parallelAttempts = 16;
21
+ const mapLimit = async (items, limit, run) => {
22
+ const results = new Array(items.length);
23
+ let next = 0;
24
+ const lane = async () => {
25
+ while (next < items.length) {
26
+ const index = next++;
27
+ results[index] = await run(items[index]);
28
+ }
29
+ };
30
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, lane));
31
+ return results;
32
+ };
33
+ /** The key of an unordered delivery: one waiting retry per event and subscription. */
34
+ const deliveryKey = (payload) => `${payload.event.id}:${payload.subscription}`;
35
+ /** The group of an ordered delivery: one at a time per subscription and record. */
36
+ const deliveryGroup = ({ subscription, change }) => `${subscription.name}:${change.resource}:${change.key}`;
37
+ /**
38
+ * Resource events delivered to the config's subscriptions, as three jobs the app's workers run:
39
+ *
40
+ * - `changes.dispatch`, which `capture()` enqueues once per writing transaction, in one ordered group so one runs at a
41
+ * time: it turns the committed outbox rows into events, makes the first try of every unordered delivery itself,
42
+ * queues the failures and the ordered deliveries, and deletes the rows in that same transaction. A process that dies
43
+ * before that commits leaves the rows, and the job's retry delivers them again, under the same `webhook-id`.
44
+ * - `webhooks.deliver`, one try of an unordered delivery; a failure queues the next try on the Standard Webhooks
45
+ * schedule, or Retry-After, and the last one is kept as failed.
46
+ * - `webhooks.deliver-ordered`, the ordered deliveries of one dispatch for one subscription and record, retried with
47
+ * doubling waits up to a day, holding back the record's later deliveries to that subscription meanwhile.
48
+ *
49
+ * With `table`, also `webhooks.send-test`, which Send test event queues, and with its log `webhooks.prune-log`, daily.
50
+ *
51
+ * No jobs without a resource with events; the subscriptions must name declared events.
52
+ */
53
+ const createWebhooks = ({ deps, subscriptions, functions, schema, log, table }) => {
54
+ const entries = deps.registry.entries;
55
+ const models = entries.map((entry) => entry.model);
56
+ const problems = subscriptionProblems(subscriptions, models, Object.keys(functions));
57
+ if (problems.length > 0) throw new Error(problems.join("; "));
58
+ if (!models.some((model) => model.events)) return { jobs: [] };
59
+ const matchers = new Map(entries.map((entry) => [entry.name, eventMatcher(entry.model, entry.source.eventMatches?.() ?? {})]));
60
+ const keyColumns = (resource) => {
61
+ const model = deps.registry.find(resource)?.model;
62
+ return model ? model.primaryKey.map((name) => model.fields[name].column) : [];
63
+ };
64
+ const byName = new Map(subscriptions.map((subscription) => [subscription.name, subscription]));
65
+ const records = (session) => callerRecords(deps, session);
66
+ const tableId = (subscription) => subscription.name.startsWith("ui:") ? subscription.name.slice(3) : void 0;
67
+ const attempt = async (subscription, event, db, tried, signal) => {
68
+ const id = tableId(subscription);
69
+ const started = performance.now();
70
+ const checkUrl = id === void 0 ? void 0 : table?.checkUrl;
71
+ const result = await attemptDelivery(subscription, event, {
72
+ db,
73
+ functions,
74
+ records,
75
+ attempt: tried,
76
+ signal,
77
+ ...checkUrl && { checkUrl }
78
+ });
79
+ if (id !== void 0) await table.subscriptions.record(db, id, event, tried, result, performance.now() - started);
80
+ return result;
81
+ };
82
+ const current = async (db) => {
83
+ if (!table) return subscriptions;
84
+ const rows = (await table.subscriptions.load(db)).filter((row) => subscriptionProblems([row], models, []).length === 0);
85
+ return [...subscriptions, ...rows];
86
+ };
87
+ const report = (subscription, event, tried, result) => {
88
+ const what = `webhook ${subscription} ${event.type} ${event.id}`;
89
+ if (result.ok) log(`${what} delivered${result.status ? ` (${result.status})` : ""}${tried > 1 ? ` on try ${tried}` : ""}`);
90
+ else log(`${what} failed on try ${tried}: ${result.error}`);
91
+ };
92
+ const subscriptionOf = async (name, db) => {
93
+ const id = name.startsWith("ui:") ? name.slice(3) : void 0;
94
+ const found = id === void 0 ? byName.get(name) : await table?.subscriptions.find(db, id);
95
+ if (!found) throw new FatalJobError(`The subscription "${name}" no longer exists`);
96
+ if (found.options.enabled === false) throw new FatalJobError(`The subscription "${name}" is disabled`);
97
+ return found;
98
+ };
99
+ const deliver = defineJob({
100
+ name: "webhooks.deliver",
101
+ maxAttempts: 3,
102
+ retryDelay: "30 seconds",
103
+ timeout: "1 minute",
104
+ concurrency: parallelAttempts,
105
+ run: async (payload, { db, signal }) => {
106
+ const subscription = await subscriptionOf(payload.subscription, db);
107
+ const result = await attempt(subscription, payload.event, db, payload.attempt, signal);
108
+ report(payload.subscription, payload.event, payload.attempt, result);
109
+ if (result.ok) return;
110
+ const delay = result.final ? void 0 : nextRetryDelay(payload.attempt, result.retryAfterMs);
111
+ if (delay === void 0) throw new FatalJobError(result.error);
112
+ await deliver.enqueue(db, {
113
+ ...payload,
114
+ attempt: payload.attempt + 1,
115
+ lastError: result.error
116
+ }, {
117
+ key: deliveryKey(payload),
118
+ delay
119
+ });
120
+ }
121
+ });
122
+ const deliverOrdered = defineJob({
123
+ name: "webhooks.deliver-ordered",
124
+ ordered: true,
125
+ maxAttempts: maxTries,
126
+ retryDelay: "5 seconds",
127
+ maxRetryDelay: "24 hours",
128
+ timeout: "5 minutes",
129
+ concurrency: parallelAttempts,
130
+ run: async (payload, { db, signal, job }) => {
131
+ const subscription = await subscriptionOf(payload.subscription, db);
132
+ for (const event of payload.events) {
133
+ const result = await attempt(subscription, event, db, job.attempt, signal);
134
+ report(payload.subscription, event, job.attempt, result);
135
+ if (result.ok) continue;
136
+ if (result.final) throw new FatalJobError(result.error);
137
+ throw new Error(result.error);
138
+ }
139
+ }
140
+ });
141
+ const deliveriesOf = async (changes, db) => {
142
+ const route = subscriptionRouter(await current(db), models);
143
+ const eventOf = subscriberEvents(deps);
144
+ const deliveries = [];
145
+ for (const change of changes) for (const { subscription, event } of route(change, matchers.get(change.resource)(change))) {
146
+ const shaped = await eventOf(subscription, change, event);
147
+ if (shaped) deliveries.push({
148
+ subscription,
149
+ event: shaped,
150
+ change
151
+ });
152
+ }
153
+ return deliveries;
154
+ };
155
+ const dispatchBatch = async (db, signal) => {
156
+ const rows = await readOutbox(db, schema, models, batchSize);
157
+ if (rows.length === 0) return 0;
158
+ const changes = coalesce(rows, keyColumns).flatMap((change) => {
159
+ const entry = deps.registry.find(change.resource);
160
+ return entry ? [resourceChange(entry.model, change)] : [];
161
+ });
162
+ const deliveries = await deliveriesOf(changes, db);
163
+ const now = deliveries.filter(({ subscription }) => !subscription.options.ordered);
164
+ const results = await mapLimit(now, parallelAttempts, ({ subscription, event }) => attempt(subscription, event, db, 1, signal));
165
+ await db.transaction().execute(async (trx) => {
166
+ for (const [index, delivery] of now.entries()) {
167
+ const result = results[index];
168
+ if (result.ok) continue;
169
+ const delay = result.final ? void 0 : nextRetryDelay(1, result.retryAfterMs);
170
+ if (delay === void 0) continue;
171
+ const payload = {
172
+ subscription: delivery.subscription.name,
173
+ event: delivery.event,
174
+ attempt: 2,
175
+ lastError: result.error
176
+ };
177
+ await deliver.enqueue(trx, payload, {
178
+ key: deliveryKey(payload),
179
+ delay
180
+ });
181
+ }
182
+ const groups = /* @__PURE__ */ new Map();
183
+ for (const delivery of deliveries.filter(({ subscription }) => subscription.options.ordered)) {
184
+ const group = deliveryGroup(delivery);
185
+ const payload = groups.get(group) ?? {
186
+ subscription: delivery.subscription.name,
187
+ events: []
188
+ };
189
+ payload.events.push(delivery.event);
190
+ groups.set(group, payload);
191
+ }
192
+ for (const [group, payload] of groups) await deliverOrdered.enqueue(trx, payload, { group });
193
+ await deleteOutbox(trx, schema, rows.map((row) => row.id));
194
+ });
195
+ for (const [index, { subscription, event }] of now.entries()) report(subscription.name, event, 1, results[index]);
196
+ return rows.length;
197
+ };
198
+ const dispatch = defineJob({
199
+ name: dispatchJobName,
200
+ ordered: true,
201
+ maxAttempts: 1e6,
202
+ retryDelay: "1 second",
203
+ maxRetryDelay: "1 minute",
204
+ timeout: "5 minutes",
205
+ run: async (_payload, { db, signal }) => {
206
+ while (await dispatchBatch(db, signal) === batchSize && !signal.aborted);
207
+ }
208
+ });
209
+ const sendTest = defineJob({
210
+ name: sendTestJob,
211
+ maxAttempts: 1,
212
+ timeout: "1 minute",
213
+ run: async (payload, { db, signal }) => {
214
+ const subscription = await subscriptionOf(tableSubscriptionName(payload.subscription), db);
215
+ const event = await testEvent(deps, records, subscription);
216
+ report(subscription.name, event, 1, await attempt(subscription, event, db, 1, signal));
217
+ }
218
+ });
219
+ const pruneLog = defineJob({
220
+ name: "webhooks.prune-log",
221
+ schedule: "17 3 * * *",
222
+ run: async (_payload, { db }) => table.subscriptions.prune(db)
223
+ });
224
+ return { jobs: [
225
+ dispatch,
226
+ deliver,
227
+ deliverOrdered,
228
+ ...table ? [sendTest] : [],
229
+ ...table?.subscriptions.deliveryLog ? [pruneLog] : []
230
+ ] };
231
+ };
232
+ //#endregion
233
+ export { createWebhooks };
@@ -4,7 +4,7 @@ import { tenantScope } from "./tenant.js";
4
4
  import { requestAccess, requireOperation, targetFilter, writableFields } from "./request-access.js";
5
5
  import { writeTransaction } from "./transactions.js";
6
6
  import { attachFiles } from "./files/attach.js";
7
- import { actorSettings } from "./changes/actor.js";
7
+ import { actorSettings, wakeDispatch } from "./changes/actor.js";
8
8
  import { validateBody } from "./validate-body.js";
9
9
  import { assertWritten, checkPrecondition, decide, denied, firstKeyField, missing, notifier, shown } from "./write-shared.js";
10
10
  import { applyUpdate, planUpdate } from "./write-update.js";
@@ -90,7 +90,9 @@ const writeIn = async (trx, deps, access, request) => {
90
90
  const runWrite = async (deps, entry, session, request) => {
91
91
  const access = await requestAccess(deps, entry, session);
92
92
  requireOperation(access, request.operation === "undelete" ? "update" : request.operation);
93
- return writeTransaction(deps.db, deps.statementTimeoutMs, (trx) => writeIn(trx, deps, access, request), actorSettings(deps, session));
93
+ const result = await writeTransaction(deps.db, deps.statementTimeoutMs, (trx) => writeIn(trx, deps, access, request), actorSettings(deps, session));
94
+ wakeDispatch(deps);
95
+ return result;
94
96
  };
95
97
  //#endregion
96
98
  export { runWrite, writeIn };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/server",
3
- "version": "0.8.2",
3
+ "version": "0.9.0",
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,9 +30,9 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@better-auth/passkey": "^1.7.7",
33
- "@protobase/layout": "0.8.2",
34
- "@protobase/query": "0.8.2",
35
- "@protobase/schema": "0.8.2",
33
+ "@protobase/layout": "0.9.0",
34
+ "@protobase/query": "0.9.0",
35
+ "@protobase/schema": "0.9.0",
36
36
  "@scalar/hono-api-reference": "^0.12.10",
37
37
  "@simplewebauthn/server": "^13.3.1",
38
38
  "better-auth": "^1.7.7",