@protobase/server 0.14.5 → 0.14.6
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.
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
//#region src/changes/event-code-error.ts
|
|
2
|
+
/**
|
|
3
|
+
* An error the app's code threw while a change was dispatched: an event's `match` or filter, a subscription's `when`, or
|
|
4
|
+
* an access rule of the roles it delivers as. Dispatch keeps the change as failed and moves on; any other error, such
|
|
5
|
+
* as the database's, fails the whole dispatch, which is retried.
|
|
6
|
+
*/
|
|
7
|
+
var EventCodeError = class extends Error {
|
|
8
|
+
name = "EventCodeError";
|
|
9
|
+
constructor(what, error) {
|
|
10
|
+
super(`${what} threw: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
//#endregion
|
|
14
|
+
export { EventCodeError };
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { EventCodeError } from "./event-code-error.js";
|
|
1
2
|
import { evaluateFilter } from "@protobase/schema";
|
|
2
3
|
import { checkEventFilter } from "@protobase/schema/internal";
|
|
3
4
|
//#region src/changes/match-events.ts
|
|
@@ -39,7 +40,13 @@ const eventTest = (model, event, match) => {
|
|
|
39
40
|
if (!match) throw new Error(`Resource "${model.name}": event "${event.name}" has no match function`);
|
|
40
41
|
tests.push((change) => match(contextOf(change)));
|
|
41
42
|
}
|
|
42
|
-
return (change) =>
|
|
43
|
+
return (change) => {
|
|
44
|
+
try {
|
|
45
|
+
return tests.every((test) => test(change));
|
|
46
|
+
} catch (error) {
|
|
47
|
+
throw new EventCodeError(`Event ${event.name} on ${change.resource} ${change.key}`, error);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
43
50
|
};
|
|
44
51
|
/**
|
|
45
52
|
* The names of the resource's events a change raises, in declaration order. The filters are checked once, here; `matches`
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { EventCodeError } from "../changes/event-code-error.js";
|
|
1
2
|
import { eventPatternMatches } from "./subscription.js";
|
|
2
3
|
import { evaluateFilter } from "@protobase/schema";
|
|
3
4
|
import { checkEventFilter } from "@protobase/schema/internal";
|
|
@@ -48,7 +49,12 @@ const subscriptionRouter = (subscriptions, models) => {
|
|
|
48
49
|
if (options.enabled === false) return false;
|
|
49
50
|
if (options.tenant !== void 0 && String(options.tenant) !== String(change.tenant)) return false;
|
|
50
51
|
const filter = filters.get(`${subscription.name}\0${change.resource}`);
|
|
51
|
-
|
|
52
|
+
if (!filter) return true;
|
|
53
|
+
try {
|
|
54
|
+
return evaluateFilter(filter, change.after ?? change.before) === true;
|
|
55
|
+
} catch (error) {
|
|
56
|
+
throw new EventCodeError(`The when of subscription "${subscription.name}" on ${change.resource} ${change.key}`, error);
|
|
57
|
+
}
|
|
52
58
|
};
|
|
53
59
|
return (change, events) => subscriptions.flatMap((subscription) => {
|
|
54
60
|
if (!passes(subscription, change)) return [];
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { requestAccess } from "../request-access.js";
|
|
2
|
+
import { EventCodeError } from "../changes/event-code-error.js";
|
|
2
3
|
import { checkRecord, evaluateFilter } from "@protobase/schema";
|
|
3
4
|
//#region src/webhooks/subscriber-view.ts
|
|
4
5
|
/**
|
|
@@ -64,8 +65,14 @@ const subscriberEvents = (deps) => {
|
|
|
64
65
|
if (!entry) throw new Error(`Not implemented: a change of "${change.resource}", which is no resource of the app`);
|
|
65
66
|
const cacheKey = `${subscription.name}\0${change.resource}`;
|
|
66
67
|
if (!accesses.has(cacheKey)) accesses.set(cacheKey, requestAccess(deps, entry, session));
|
|
67
|
-
const
|
|
68
|
-
|
|
68
|
+
const readable = async () => {
|
|
69
|
+
const access = await accesses.get(cacheKey);
|
|
70
|
+
return await mayRead(access, change.after ?? change.before) ? access : void 0;
|
|
71
|
+
};
|
|
72
|
+
const access = await readable().catch((error) => {
|
|
73
|
+
throw new EventCodeError(`The access check of subscription "${subscription.name}" on ${change.resource} ${change.key}`, error);
|
|
74
|
+
});
|
|
75
|
+
if (!access) return void 0;
|
|
69
76
|
return subscriptionEvent(change, event, {
|
|
70
77
|
fields: Object.keys(access.readable.fields),
|
|
71
78
|
thin
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { dispatchJobName } from "../changes/capture-sql.js";
|
|
2
2
|
import { coalesce } from "../changes/coalesce.js";
|
|
3
|
+
import { EventCodeError } from "../changes/event-code-error.js";
|
|
3
4
|
import { eventMatcher } from "../changes/match-events.js";
|
|
4
5
|
import { deleteOutbox, readOutbox } from "../changes/outbox.js";
|
|
5
6
|
import { resourceChange } from "../changes/resource-change.js";
|
|
@@ -41,6 +42,9 @@ const deliveryGroup = ({ subscription, change }) => `${subscription.name}:${chan
|
|
|
41
42
|
* time: it turns the committed outbox rows into events, makes the first try of every unordered delivery itself,
|
|
42
43
|
* queues the failures and the ordered deliveries, and deletes the rows in that same transaction. A process that dies
|
|
43
44
|
* before that commits leaves the rows, and the job's retry delivers them again, under the same `webhook-id`.
|
|
45
|
+
* - `webhooks.dispatch-change`, one change whose app code threw in dispatch: an event's `match` or filter, a
|
|
46
|
+
* subscription's `when`, or an access rule of its roles. Kept as failed, so dispatch moves on to later changes; a
|
|
47
|
+
* retry after the fix queues the change's deliveries. Any other error fails the whole dispatch, which is retried.
|
|
44
48
|
* - `webhooks.deliver`, one try of an unordered delivery; a failure queues the next try on the Standard Webhooks
|
|
45
49
|
* schedule, or Retry-After, and the last one is kept as failed.
|
|
46
50
|
* - `webhooks.deliver-ordered`, the ordered deliveries of one dispatch for one subscription and record, retried with
|
|
@@ -138,20 +142,54 @@ const createWebhooks = ({ deps, subscriptions, functions, schema, log, table })
|
|
|
138
142
|
}
|
|
139
143
|
}
|
|
140
144
|
});
|
|
141
|
-
const
|
|
145
|
+
const changeDeliveries = async (db) => {
|
|
142
146
|
const route = subscriptionRouter(await current(db), models);
|
|
143
147
|
const eventOf = subscriberEvents(deps);
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
148
|
+
return async (change) => {
|
|
149
|
+
const deliveries = [];
|
|
150
|
+
for (const { subscription, event } of route(change, matchers.get(change.resource)(change))) {
|
|
151
|
+
const shaped = await eventOf(subscription, change, event);
|
|
152
|
+
if (shaped) deliveries.push({
|
|
153
|
+
subscription,
|
|
154
|
+
event: shaped,
|
|
155
|
+
change
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
return deliveries;
|
|
159
|
+
};
|
|
160
|
+
};
|
|
161
|
+
const queueOrdered = async (db, deliveries) => {
|
|
162
|
+
const groups = /* @__PURE__ */ new Map();
|
|
163
|
+
for (const delivery of deliveries.filter(({ subscription }) => subscription.options.ordered)) {
|
|
164
|
+
const group = deliveryGroup(delivery);
|
|
165
|
+
const payload = groups.get(group) ?? {
|
|
166
|
+
subscription: delivery.subscription.name,
|
|
167
|
+
events: []
|
|
168
|
+
};
|
|
169
|
+
payload.events.push(delivery.event);
|
|
170
|
+
groups.set(group, payload);
|
|
152
171
|
}
|
|
153
|
-
|
|
172
|
+
for (const [group, payload] of groups) await deliverOrdered.enqueue(db, payload, { group });
|
|
154
173
|
};
|
|
174
|
+
const dispatchChange = defineJob({
|
|
175
|
+
name: "webhooks.dispatch-change",
|
|
176
|
+
maxAttempts: 1,
|
|
177
|
+
timeout: "5 minutes",
|
|
178
|
+
run: async ({ change }, { db }) => {
|
|
179
|
+
const deliveries = await (await changeDeliveries(db))(change);
|
|
180
|
+
await db.transaction().execute(async (trx) => {
|
|
181
|
+
for (const { subscription, event } of deliveries.filter(({ subscription }) => !subscription.options.ordered)) {
|
|
182
|
+
const payload = {
|
|
183
|
+
subscription: subscription.name,
|
|
184
|
+
event,
|
|
185
|
+
attempt: 1
|
|
186
|
+
};
|
|
187
|
+
await deliver.enqueue(trx, payload, { key: deliveryKey(payload) });
|
|
188
|
+
}
|
|
189
|
+
await queueOrdered(trx, deliveries);
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
});
|
|
155
193
|
const dispatchBatch = async (db, signal) => {
|
|
156
194
|
const rows = await readOutbox(db, schema, models, batchSize);
|
|
157
195
|
if (rows.length === 0) return 0;
|
|
@@ -159,7 +197,18 @@ const createWebhooks = ({ deps, subscriptions, functions, schema, log, table })
|
|
|
159
197
|
const entry = deps.registry.find(change.resource);
|
|
160
198
|
return entry ? [resourceChange(entry.model, change)] : [];
|
|
161
199
|
});
|
|
162
|
-
const
|
|
200
|
+
const deliveriesOf = await changeDeliveries(db);
|
|
201
|
+
const deliveries = [];
|
|
202
|
+
const failures = [];
|
|
203
|
+
for (const change of changes) try {
|
|
204
|
+
deliveries.push(...await deliveriesOf(change));
|
|
205
|
+
} catch (error) {
|
|
206
|
+
if (!(error instanceof EventCodeError)) throw error;
|
|
207
|
+
failures.push({
|
|
208
|
+
change,
|
|
209
|
+
error
|
|
210
|
+
});
|
|
211
|
+
}
|
|
163
212
|
const now = deliveries.filter(({ subscription }) => !subscription.options.ordered);
|
|
164
213
|
const results = await mapLimit(now, parallelAttempts, ({ subscription, event }) => attempt(subscription, event, db, 1, signal));
|
|
165
214
|
await db.transaction().execute(async (trx) => {
|
|
@@ -179,20 +228,12 @@ const createWebhooks = ({ deps, subscriptions, functions, schema, log, table })
|
|
|
179
228
|
delay
|
|
180
229
|
});
|
|
181
230
|
}
|
|
182
|
-
|
|
183
|
-
for (const
|
|
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 });
|
|
231
|
+
await queueOrdered(trx, deliveries);
|
|
232
|
+
for (const { change } of failures) await dispatchChange.enqueue(trx, { change });
|
|
193
233
|
await deleteOutbox(trx, schema, rows.map((row) => row.id));
|
|
194
234
|
});
|
|
195
235
|
for (const [index, { subscription, event }] of now.entries()) report(subscription.name, event, 1, results[index]);
|
|
236
|
+
for (const { error } of failures) log(`events kept as failed (webhooks.dispatch-change): ${error.message}`);
|
|
196
237
|
return rows.length;
|
|
197
238
|
};
|
|
198
239
|
const dispatch = defineJob({
|
|
@@ -223,6 +264,7 @@ const createWebhooks = ({ deps, subscriptions, functions, schema, log, table })
|
|
|
223
264
|
});
|
|
224
265
|
return { jobs: [
|
|
225
266
|
dispatch,
|
|
267
|
+
dispatchChange,
|
|
226
268
|
deliver,
|
|
227
269
|
deliverOrdered,
|
|
228
270
|
...table ? [sendTest] : [],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protobase/server",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.6",
|
|
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.14.
|
|
34
|
-
"@protobase/query": "0.14.
|
|
35
|
-
"@protobase/schema": "0.14.
|
|
33
|
+
"@protobase/layout": "0.14.6",
|
|
34
|
+
"@protobase/query": "0.14.6",
|
|
35
|
+
"@protobase/schema": "0.14.6",
|
|
36
36
|
"@react-email/render": "^2.1.0",
|
|
37
37
|
"@scalar/hono-api-reference": "^0.12.10",
|
|
38
38
|
"@sentry/core": "^11.7.0",
|