@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.
- package/dist/batch/run-batch.js +4 -2
- package/dist/changes/actor.js +7 -1
- package/dist/changes/capture-schema.js +1 -1
- package/dist/changes/capture-sql.js +13 -2
- package/dist/changes/coalesce.d.ts +4 -0
- package/dist/changes/coalesce.js +2 -1
- package/dist/changes/outbox.d.ts +14 -0
- package/dist/changes/outbox.js +51 -0
- package/dist/changes/resource-change.d.ts +2 -0
- package/dist/changes/resource-change.js +2 -1
- package/dist/create-admin.d.ts +10 -5
- package/dist/create-admin.js +20 -7
- package/dist/index.d.ts +10 -4
- package/dist/index.js +13 -8
- package/dist/jobs/current.d.ts +2 -0
- package/dist/jobs/current.js +3 -1
- package/dist/jobs/runtime.js +7 -5
- package/dist/project-config.d.ts +4 -1
- package/dist/project-config.js +10 -2
- package/dist/resource-source.d.ts +8 -3
- package/dist/resource-source.js +6 -3
- package/dist/types.d.ts +5 -1
- package/dist/webhooks/attempt.js +91 -0
- package/dist/webhooks/retry-schedule.js +40 -0
- package/dist/webhooks/route-events.js +61 -0
- package/dist/webhooks/standard-webhooks.d.ts +4 -0
- package/dist/webhooks/standard-webhooks.js +36 -0
- package/dist/webhooks/subscriber-view.js +76 -0
- package/dist/webhooks/subscription.d.ts +92 -0
- package/dist/webhooks/subscription.js +28 -0
- package/dist/webhooks/table/address-check.js +28 -0
- package/dist/webhooks/table/options.d.ts +23 -0
- package/dist/webhooks/table/options.js +14 -0
- package/dist/webhooks/table/owner-roles.js +23 -0
- package/dist/webhooks/table/plugin.d.ts +10 -0
- package/dist/webhooks/table/plugin.js +18 -0
- package/dist/webhooks/table/schema-sql.js +116 -0
- package/dist/webhooks/table/schema.d.ts +13 -0
- package/dist/webhooks/table/schema.js +44 -0
- package/dist/webhooks/table/table-subscriptions.js +77 -0
- package/dist/webhooks/table/validate-hook.js +32 -0
- package/dist/webhooks/table/webhook-table.js +23 -0
- package/dist/webhooks/test-event.js +48 -0
- package/dist/webhooks/webhooks.js +233 -0
- package/dist/write-pipeline.js +4 -2
- 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 };
|
package/dist/write-pipeline.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
34
|
-
"@protobase/query": "0.
|
|
35
|
-
"@protobase/schema": "0.
|
|
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",
|