@protobase/server 0.8.1 → 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 +11 -3
- package/dist/create-admin.js +21 -5
- package/dist/functions/require-env.d.ts +1 -1
- package/dist/index.d.ts +15 -3
- package/dist/index.js +15 -5
- package/dist/jobs/current.d.ts +10 -0
- package/dist/jobs/current.js +20 -0
- package/dist/jobs/database.js +36 -0
- package/dist/jobs/define-job.d.ts +8 -0
- package/dist/jobs/define-job.js +38 -0
- package/dist/jobs/drain-route.d.ts +21 -0
- package/dist/jobs/drain-route.js +28 -0
- package/dist/jobs/failed-jobs.js +67 -0
- package/dist/jobs/functions-sql.js +126 -0
- package/dist/jobs/job-types.d.ts +73 -0
- package/dist/jobs/options.js +44 -0
- package/dist/jobs/run-job.d.ts +9 -0
- package/dist/jobs/run-job.js +78 -0
- package/dist/jobs/runtime.d.ts +46 -0
- package/dist/jobs/runtime.js +158 -0
- package/dist/jobs/schedule.js +25 -0
- package/dist/jobs/schedules.js +41 -0
- package/dist/jobs/schema.d.ts +15 -0
- package/dist/jobs/schema.js +63 -0
- package/dist/project-config.d.ts +7 -2
- package/dist/project-config.js +24 -3
- 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 +6 -4
|
@@ -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,18 +30,20 @@
|
|
|
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",
|
|
39
|
+
"cron-parser": "^5.10.1",
|
|
39
40
|
"file-type": "^22.1.1",
|
|
40
41
|
"hono": "^4.13.13",
|
|
41
42
|
"image-size": "^2.0.4",
|
|
42
43
|
"jose": "^6.2.12",
|
|
43
44
|
"kysely": "^0.29.6",
|
|
44
45
|
"nodemailer": "^10.0.16",
|
|
46
|
+
"pg-boss": "12.37.1",
|
|
45
47
|
"zod": "^4.6.5"
|
|
46
48
|
}
|
|
47
49
|
}
|