@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) => tests.every((test) => test(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
- return !filter || evaluateFilter(filter, change.after ?? change.before) === true;
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 access = await accesses.get(cacheKey);
68
- if (!await mayRead(access, change.after ?? change.before)) return void 0;
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 deliveriesOf = async (changes, db) => {
145
+ const changeDeliveries = async (db) => {
142
146
  const route = subscriptionRouter(await current(db), models);
143
147
  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
- });
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
- return deliveries;
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 deliveries = await deliveriesOf(changes, db);
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
- 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 });
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.5",
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.5",
34
- "@protobase/query": "0.14.5",
35
- "@protobase/schema": "0.14.5",
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",