feat(webhooks): add per-verb subscriptions and bulk-create debounce

Subscriptions now declare a `verb` (create | delete | bulk-create).
POST /:did/webhooks defaults to inserting both create and delete rows
when no verb is given, preserving existing all-events behavior. Update
events fold into the create verb. The new bulk-create verb debounces
creates per DID over 400 ms and delivers a `records` array.

Migration adds the verb column with default 'create' and clones every
existing row for the delete verb so legacy subscriptions keep firing
on both events.
This commit is contained in:
Julien Calixte
2026-05-05 12:25:54 +02:00
parent 8e967ba43c
commit 7b53909c52
4 changed files with 122 additions and 18 deletions

View File

@@ -87,26 +87,30 @@ export const saveCursor = (cursor: number) => {
);
};
export type WebhookVerb = "create" | "delete" | "bulk-create";
type WebhookSubscriptionRow = {
id: number;
did: string;
method: string;
url: string;
token?: string;
verb: WebhookVerb;
};
export const addWebhookSubscription = (
{ did, method, url, token }: Omit<WebhookSubscriptionRow, "id">,
{ did, method, url, token, verb }: Omit<WebhookSubscriptionRow, "id">,
): WebhookSubscriptionRow => {
db.exec(
"INSERT INTO webhook_subscription (did, method, url, token) VALUES (?, ?, ?, ?)",
"INSERT INTO webhook_subscription (did, method, url, token, verb) VALUES (?, ?, ?, ?, ?)",
did,
method,
url,
token ?? null,
verb,
);
return db.prepare(
"SELECT id, did, method, url FROM webhook_subscription WHERE id = last_insert_rowid()",
"SELECT id, did, method, url, verb FROM webhook_subscription WHERE id = last_insert_rowid()",
).get<WebhookSubscriptionRow>()!;
// Note: token is intentionally excluded from the SELECT (write-only)
};
@@ -115,10 +119,13 @@ export const deleteWebhooksByDid = (did: string): void => {
db.exec("DELETE FROM webhook_subscription WHERE did = ?", did);
};
export const getWebhooksByDid = (did: string): WebhookSubscriptionRow[] => {
export const getWebhooksByDidAndVerb = (
did: string,
verb: WebhookVerb,
): WebhookSubscriptionRow[] => {
return db.prepare(
"SELECT id, did, method, url, token FROM webhook_subscription WHERE did = ? ORDER BY id DESC LIMIT 10",
).all<WebhookSubscriptionRow>(did);
"SELECT id, did, method, url, token, verb FROM webhook_subscription WHERE did = ? AND verb = ? ORDER BY id DESC LIMIT 10",
).all<WebhookSubscriptionRow>(did, verb);
};
export const upsertNote = (note: Note) => {

View File

@@ -61,4 +61,23 @@ try {
// Column already exists — no-op
}
try {
db.exec(
`ALTER TABLE webhook_subscription ADD COLUMN verb TEXT NOT NULL DEFAULT 'create';`,
);
db.exec(`
INSERT INTO webhook_subscription (did, method, url, token, verb)
SELECT did, method, url, token, 'delete'
FROM webhook_subscription
WHERE verb = 'create';
`);
} catch {
// Column already exists — backfill already happened on a previous run.
}
db.exec(`
CREATE INDEX IF NOT EXISTS idx_webhook_subscription_did_verb
ON webhook_subscription(did, verb);
`);
db.close();