@trypattern/sdk 0.1.38 → 0.1.39

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,157 @@
1
+ import { Hono } from 'hono';
2
+ import { REQUEST_ACTION_NAME, SOURCE_NAME, inquiry, requestTables } from "../requests.js";
3
+ import { refused } from "../services.js";
4
+ import { snakeCase } from "../table.js";
5
+ import { fieldsOf, noticeOf, storedRows, textOf } from "./inquiries.js";
6
+ import { afterResponse, drainNotices, receivedStatements, requestsLink, transitionStatements } from "./requests.js";
7
+ const BUILT_IN = new Set(['reply', 'done']);
8
+ const DECISIONS = {
9
+ accept: 'Accept',
10
+ decline: 'Decline'
11
+ };
12
+ function declared(source, actions) {
13
+ return Object.entries(actions).map(([name, action]) => {
14
+ if (!REQUEST_ACTION_NAME.test(name) || BUILT_IN.has(name)) {
15
+ throw new Error(`${source} declares an action called '${name}': give it a short camelCase name of its own, like 'markFulfilled'. Reply and Mark done come with every request`);
16
+ }
17
+ const isDecision = Object.hasOwn(DECISIONS, name);
18
+ const label = action.label ?? DECISIONS[name];
19
+ if (label === undefined || label.trim() === '') {
20
+ throw new Error(`${source}'s ${name} action needs a label, the words on its button, like 'Mark fulfilled'`);
21
+ }
22
+ if (!isDecision && action.resolves === undefined) {
23
+ throw new Error(`${source}'s ${name} action needs resolves: true when it finishes the request, or false when the request stays open`);
24
+ }
25
+ return {
26
+ ...action,
27
+ name,
28
+ label,
29
+ resolves: action.resolves ?? true
30
+ };
31
+ });
32
+ }
33
+ export function requestSource(table, options) {
34
+ if (!SOURCE_NAME.test(options.source) || options.source === 'bookings') {
35
+ throw new Error(`request source '${options.source}' needs a short camelCase name of its own, like 'rentals'`);
36
+ }
37
+ if (table.access.create !== 'server') {
38
+ throw new Error(`${table.name} holds the ${options.source} requests, so its create access is 'server': your own server code saves each row and records the request in the same batch with recordRequest(c, ${options.source}, row)`);
39
+ }
40
+ const { kind, actions, ...presentation } = options;
41
+ return {
42
+ source: options.source,
43
+ table,
44
+ kind: kind ?? 'inquiry',
45
+ described: inquiry(table, presentation),
46
+ actions: declared(options.source, actions ?? {})
47
+ };
48
+ }
49
+ export function recordRequest(c, source, row) {
50
+ return receivedStatements(c.env.DB, {
51
+ source: source.source,
52
+ table: snakeCase(source.table.name),
53
+ requestId: row.id,
54
+ notice: noticeOf(source.described, row, requestsLink(c.env))
55
+ });
56
+ }
57
+ export function deliverRequestNotices(c) {
58
+ return afterResponse(c, drainNotices(c.env));
59
+ }
60
+ function statusOf(source, progress) {
61
+ if (progress.state === 'done')
62
+ return progress.decision ?? 'done';
63
+ if (progress.replied)
64
+ return 'replied';
65
+ return source.actions.some((action) => action.name === 'accept')
66
+ ? 'requested'
67
+ : 'open';
68
+ }
69
+ function contentOf(source, row, progress) {
70
+ const { described } = source;
71
+ const isOpen = progress.state === 'open';
72
+ const message = described.message === null ? '' : textOf(row[described.message]);
73
+ const brief = described.summary === null ? message : textOf(row[described.summary]);
74
+ const when = described.when === null ? '' : textOf(row[described.when.field]);
75
+ const available = source.actions.filter((action) => action.can === undefined || action.can(row));
76
+ return {
77
+ kind: source.kind,
78
+ title: described.title,
79
+ label: described.title,
80
+ summary: brief === '' ? described.form : `${described.form} · ${brief}`,
81
+ name: textOf(row[described.name]),
82
+ email: textOf(row[described.email]),
83
+ when: when === '' ? null : when,
84
+ whenDetail: described.when === null ? null : described.when.detail,
85
+ fields: fieldsOf(described, row),
86
+ contact: [],
87
+ message,
88
+ status: statusOf(source, progress),
89
+ needsAnswer: isOpen,
90
+ actions: isOpen
91
+ ? ['reply', ...available.map((action) => action.name), 'done']
92
+ : [],
93
+ actionLabels: Object.fromEntries(source.actions.map((action) => [action.name, action.label])),
94
+ receivedAt: textOf(row['createdAt'])
95
+ };
96
+ }
97
+ function eventOf(name) {
98
+ if (name === 'accept')
99
+ return 'accepted';
100
+ if (name === 'decline')
101
+ return 'declined';
102
+ return name;
103
+ }
104
+ function gatewaySource(source) {
105
+ return {
106
+ name: source.source,
107
+ table: snakeCase(source.table.name),
108
+ labels: Object.fromEntries(source.actions.map((action) => [eventOf(action.name), action.label])),
109
+ contents: async (d1, progress) => {
110
+ const rows = await storedRows(d1, source.table, [...progress.keys()]);
111
+ return new Map([...progress].flatMap(([id, step]) => {
112
+ const row = rows.get(id);
113
+ return row === undefined
114
+ ? []
115
+ : [[id, contentOf(source, row, step)]];
116
+ }));
117
+ },
118
+ decide: async (d1, id, name, expected) => {
119
+ const action = source.actions.find((item) => item.name === name);
120
+ if (action === undefined)
121
+ throw refused(400, 'Not allowed');
122
+ const row = (await storedRows(d1, source.table, [id])).get(id);
123
+ if (row === undefined)
124
+ throw refused(404, 'No such request');
125
+ if (action.can !== undefined && !action.can(row)) {
126
+ throw refused(409, 'That can’t be done to this request now');
127
+ }
128
+ const own = await action.run({ d1, id, row });
129
+ return [
130
+ ...own,
131
+ ...transitionStatements(d1, {
132
+ source: source.source,
133
+ requestId: id,
134
+ event: eventOf(action.name),
135
+ state: action.resolves ? 'done' : 'open',
136
+ expected
137
+ })
138
+ ];
139
+ }
140
+ };
141
+ }
142
+ export function requestSources(sources) {
143
+ const names = new Set();
144
+ for (const source of sources) {
145
+ if (names.has(source.source)) {
146
+ throw new Error(`Two request sources are called '${source.source}': give each its own source name`);
147
+ }
148
+ names.add(source.source);
149
+ }
150
+ return {
151
+ name: 'requestSources',
152
+ tables: requestTables,
153
+ openPaths: [],
154
+ routes: new Hono(),
155
+ requests: sources.map(gatewaySource)
156
+ };
157
+ }
@@ -2,18 +2,22 @@ import type { D1Database, D1PreparedStatement } from '@cloudflare/workers-types'
2
2
  import type { Context } from 'hono';
3
3
  import type { RequestCard } from '../requests.ts';
4
4
  import type { ServerBindings, ServerEnv } from './env.ts';
5
- export type RequestContent = Omit<RequestCard, 'revision' | 'history' | 'notice' | 'source' | 'id' | 'receivedAt'>;
5
+ export type RequestContent = Omit<RequestCard, 'revision' | 'history' | 'notice' | 'source' | 'id' | 'receivedAt' | 'actionLabels'> & {
6
+ actionLabels?: Readonly<Record<string, string>>;
7
+ };
6
8
  export type RequestProgress = {
7
9
  state: 'open' | 'done';
8
10
  replied: boolean;
11
+ decision: 'accepted' | 'declined' | null;
9
12
  };
10
13
  export type RequestSource = {
11
14
  readonly name: string;
12
15
  readonly table: string;
16
+ readonly labels?: Readonly<Record<string, string>>;
13
17
  readonly contents: (d1: D1Database, progress: ReadonlyMap<string, RequestProgress>) => Promise<ReadonlyMap<string, RequestContent & {
14
18
  receivedAt: string;
15
19
  }>>;
16
- readonly decide?: (d1: D1Database, id: string, action: 'accept' | 'decline', expected: number) => Promise<readonly D1PreparedStatement[]>;
20
+ readonly decide?: (d1: D1Database, id: string, action: string, expected: number) => Promise<readonly D1PreparedStatement[]>;
17
21
  readonly backfill?: (d1: D1Database) => D1PreparedStatement;
18
22
  };
19
23
  export type OwnerNotice = {
@@ -33,7 +37,7 @@ export declare function receivedStatements(d1: D1Database, { source, table, requ
33
37
  export declare function transitionStatements(d1: D1Database, { source, requestId, event, state, expected }: {
34
38
  source: string;
35
39
  requestId: string;
36
- event: 'accepted' | 'declined' | 'replied' | 'done';
40
+ event: string;
37
41
  state: 'open' | 'done';
38
42
  expected: number | null;
39
43
  }): D1PreparedStatement[];
@@ -1,7 +1,7 @@
1
1
  import { Hono } from 'hono';
2
2
  import { z } from 'zod';
3
3
  import { REQUESTS_ACT, verifiedAssertion } from "../patternAccess.js";
4
- import { REQUEST_SYNC_PAGE, requestAct, requestBound, requestCard } from "../requests.js";
4
+ import { REQUEST_EVENTS, REQUEST_SYNC_PAGE, requestAct, requestBound, requestCard } from "../requests.js";
5
5
  import { ServiceRefused, refused } from "../services.js";
6
6
  import { authServiceFor } from "./authService.js";
7
7
  import { PATTERN_KEYS } from "./patternKeys.js";
@@ -11,7 +11,11 @@ const MAX_ATTEMPTS = 8;
11
11
  const STALE_CLAIM_MS = 5 * 60 * 1000;
12
12
  const BACKOFF_MINUTES = [1, 5, 30, 120, 720];
13
13
  const DRAINED_AT_ONCE = 10;
14
+ const DRAIN_ROUNDS = 5;
14
15
  const CHANGED = 'This request changed since you opened it. Open it again to see what’s new.';
16
+ const NOT_NOW = 'That can’t be done to this request now';
17
+ const UNCHANGED = 'unchanged';
18
+ const BUILT_IN_EVENTS = new Set(REQUEST_EVENTS);
15
19
  const REQUEST_TABLES = [
16
20
  'request_states',
17
21
  'request_events',
@@ -36,6 +40,16 @@ export const NEXT_SEQUENCE = '(SELECT COALESCE(MAX(sequence), 0) + 1 FROM reques
36
40
  function stateId(source, requestId) {
37
41
  return `${source}:${requestId}`;
38
42
  }
43
+ function mustChange(d1) {
44
+ return d1.prepare(`SELECT json(CASE WHEN changes() > 0 THEN '1' ELSE '${UNCHANGED}' END)`);
45
+ }
46
+ function eachMustChange(d1, statements) {
47
+ return statements.flatMap((statement) => [statement, mustChange(d1)]);
48
+ }
49
+ function isAbandoned(error) {
50
+ const text = error instanceof Error ? error.message : String(error);
51
+ return /malformed JSON|NOT NULL constraint/i.test(text);
52
+ }
39
53
  export function requestsLink(env) {
40
54
  const name = env.APP_NAME ?? '';
41
55
  return name === '' ? DASHBOARD : `${DASHBOARD}/sites/${name}/requests`;
@@ -43,27 +57,26 @@ export function requestsLink(env) {
43
57
  export function receivedStatements(d1, { source, table, requestId, notice }) {
44
58
  const guard = `WHERE EXISTS (SELECT 1 FROM ${table} WHERE id = ?3)`;
45
59
  const now = new Date().toISOString();
60
+ const id = stateId(source, requestId);
46
61
  return [
47
62
  d1
48
- .prepare(`INSERT INTO request_states (id, source, request_id, state, revision, sequence, updated_at) SELECT ?1, ?2, ?3, 'open', 1, ${NEXT_SEQUENCE}, ?4 ${guard}`)
49
- .bind(stateId(source, requestId), source, requestId, now),
63
+ .prepare(`INSERT OR IGNORE INTO request_states (id, source, request_id, state, revision, sequence, updated_at) SELECT ?1, ?2, ?3, 'open', 1, ${NEXT_SEQUENCE}, ?4 ${guard}`)
64
+ .bind(id, source, requestId, now),
50
65
  d1
51
- .prepare(`INSERT INTO request_events (id, source, request_id, kind) SELECT ?1, ?2, ?3, 'received' ${guard}`)
52
- .bind(crypto.randomUUID(), source, requestId),
66
+ .prepare(`INSERT OR IGNORE INTO request_events (id, source, request_id, kind) SELECT ?1, ?2, ?3, 'received' ${guard}`)
67
+ .bind(`${id}:received`, source, requestId),
53
68
  d1
54
- .prepare(`INSERT INTO request_outbox (id, source, request_id, subject, text, reply_to, status, attempts, next_at, sent_at, claimed_at) SELECT ?1, ?2, ?3, ?4, ?5, ?6, 'pending', 0, NULL, NULL, NULL ${guard}`)
55
- .bind(crypto.randomUUID(), source, requestId, notice.subject, notice.text, notice.replyTo)
69
+ .prepare(`INSERT OR IGNORE INTO request_outbox (id, source, request_id, subject, text, reply_to, status, attempts, next_at, sent_at, claimed_at) SELECT ?1, ?2, ?3, ?4, ?5, ?6, 'pending', 0, NULL, NULL, NULL ${guard}`)
70
+ .bind(`${id}:received`, source, requestId, notice.subject, notice.text, notice.replyTo)
56
71
  ];
57
72
  }
58
73
  export function transitionStatements(d1, { source, requestId, event, state, expected }) {
59
74
  const id = stateId(source, requestId);
60
- const guard = expected === null
61
- ? ''
62
- : `WHERE (SELECT revision FROM request_states WHERE id = ?4) = ?5`;
75
+ const guarded = expected === null ? [] : [id, expected];
63
76
  return [
64
77
  d1
65
- .prepare(`INSERT INTO request_events (id, source, request_id, kind) SELECT ?1, ?2, ?3, '${event}' ${guard}`)
66
- .bind(crypto.randomUUID(), source, requestId, ...(expected === null ? [] : [id, expected])),
78
+ .prepare(`INSERT INTO request_events (id, source, request_id, kind) SELECT ?1, ?2, ?3, ?4${expected === null ? '' : ' WHERE (SELECT revision FROM request_states WHERE id = ?5) = ?6'}`)
79
+ .bind(crypto.randomUUID(), source, requestId, event, ...guarded),
67
80
  d1
68
81
  .prepare(`UPDATE request_states SET state = ?1, revision = revision + 1, sequence = ${NEXT_SEQUENCE}, updated_at = ?2 WHERE id = ?3${expected === null ? '' : ' AND revision = ?4'}`)
69
82
  .bind(state, new Date().toISOString(), id, ...(expected === null ? [] : [expected]))
@@ -92,31 +105,44 @@ async function deliver(env, row) {
92
105
  return false;
93
106
  }
94
107
  }
108
+ async function sendNotice(env, row, claim) {
109
+ const d1 = env.DB;
110
+ const claimed = await d1
111
+ .prepare(`UPDATE request_outbox SET status = 'sending', claimed_at = ?4 WHERE id = ?5 AND ${claim.claimable}`)
112
+ .bind(claim.force ? 1 : 0, claim.now, claim.stale, claim.now, row.id)
113
+ .run();
114
+ if (claimed.meta.changes !== 1)
115
+ return;
116
+ const sent = await deliver(env, row);
117
+ const attempts = row.attempts + 1;
118
+ await d1.batch([
119
+ d1
120
+ .prepare('UPDATE request_outbox SET status = ?1, attempts = ?2, sent_at = ?3, next_at = ?4, claimed_at = NULL WHERE id = ?5')
121
+ .bind(sent ? 'sent' : 'failed', attempts, sent ? new Date().toISOString() : null, sent ? null : nextTry(attempts, Date.now()), row.id),
122
+ touchStatement(d1, row.source, row.request_id)
123
+ ]);
124
+ }
95
125
  export async function drainNotices(env, { force = false } = {}) {
96
126
  const d1 = env.DB;
97
127
  const now = Date.now();
98
- const nowText = new Date(now).toISOString();
99
- const stale = new Date(now - STALE_CLAIM_MS).toISOString();
100
- const claimable = `(status = 'pending' OR (status = 'failed' AND attempts < ${MAX_ATTEMPTS} AND (?1 = 1 OR next_at <= ?2)) OR (status = 'sending' AND claimed_at < ?3))`;
101
- const { results } = await d1
102
- .prepare(`SELECT id, source, request_id, subject, text, reply_to, status, attempts FROM request_outbox WHERE ${claimable} ORDER BY created_at LIMIT ${DRAINED_AT_ONCE}`)
103
- .bind(force ? 1 : 0, nowText, stale)
104
- .all();
105
- for (const row of results) {
106
- const claimed = await d1
107
- .prepare(`UPDATE request_outbox SET status = 'sending', claimed_at = ?4 WHERE id = ?5 AND ${claimable}`)
108
- .bind(force ? 1 : 0, nowText, stale, nowText, row.id)
109
- .run();
110
- if (claimed.meta.changes !== 1)
111
- continue;
112
- const sent = await deliver(env, row);
113
- const attempts = row.attempts + 1;
114
- await d1.batch([
115
- d1
116
- .prepare('UPDATE request_outbox SET status = ?1, attempts = ?2, sent_at = ?3, next_at = ?4, claimed_at = NULL WHERE id = ?5')
117
- .bind(sent ? 'sent' : 'failed', attempts, sent ? new Date().toISOString() : null, sent ? null : nextTry(attempts, Date.now()), row.id),
118
- touchStatement(d1, row.source, row.request_id)
119
- ]);
128
+ const claim = {
129
+ force,
130
+ now: new Date(now).toISOString(),
131
+ stale: new Date(now - STALE_CLAIM_MS).toISOString(),
132
+ claimable: `(status = 'pending' OR (status = 'failed' AND attempts < ${MAX_ATTEMPTS} AND (?1 = 1 OR next_at <= ?2)) OR (status = 'sending' AND claimed_at < ?3))`
133
+ };
134
+ const tried = [];
135
+ for (let round = 0; round < DRAIN_ROUNDS; round += 1) {
136
+ const { results } = await d1
137
+ .prepare(`SELECT id, source, request_id, subject, text, reply_to, status, attempts FROM request_outbox WHERE ${claim.claimable} AND id NOT IN (SELECT value FROM json_each(?4)) ORDER BY created_at LIMIT ${DRAINED_AT_ONCE}`)
138
+ .bind(force ? 1 : 0, claim.now, claim.stale, JSON.stringify(tried))
139
+ .all();
140
+ for (const row of results) {
141
+ tried.push(row.id);
142
+ await sendNotice(env, row, claim);
143
+ }
144
+ if (results.length < DRAINED_AT_ONCE)
145
+ return;
120
146
  }
121
147
  }
122
148
  export function afterResponse(c, work) {
@@ -128,6 +154,33 @@ export function afterResponse(c, work) {
128
154
  return work.then(() => undefined, () => undefined);
129
155
  }
130
156
  }
157
+ function historyItem(source, event) {
158
+ if (BUILT_IN_EVENTS.has(event.kind)) {
159
+ return {
160
+ kind: event.kind,
161
+ at: event.created_at,
162
+ label: null
163
+ };
164
+ }
165
+ return {
166
+ kind: 'acted',
167
+ at: event.created_at,
168
+ label: source.labels?.[event.kind] ?? event.kind
169
+ };
170
+ }
171
+ function progressOf(state, events) {
172
+ const own = events.filter((event) => event.source === state.source && event.request_id === state.request_id);
173
+ const decision = own
174
+ .filter((event) => event.kind === 'accepted' || event.kind === 'declined')
175
+ .at(-1);
176
+ return {
177
+ state: state.state,
178
+ replied: own.some((event) => event.kind === 'replied'),
179
+ decision: decision === undefined
180
+ ? null
181
+ : decision.kind
182
+ };
183
+ }
131
184
  async function cardsOf(d1, sources, states) {
132
185
  if (states.length === 0)
133
186
  return [];
@@ -149,12 +202,7 @@ async function cardsOf(d1, sources, states) {
149
202
  continue;
150
203
  const contents = await source.contents(d1, new Map(mine.map((state) => [
151
204
  state.request_id,
152
- {
153
- state: state.state,
154
- replied: events.results.some((event) => event.kind === 'replied' &&
155
- event.source === state.source &&
156
- event.request_id === state.request_id)
157
- }
205
+ progressOf(state, events.results)
158
206
  ])));
159
207
  for (const state of mine) {
160
208
  const content = contents.get(state.request_id);
@@ -164,12 +212,13 @@ async function cardsOf(d1, sources, states) {
164
212
  const notice = notices.results.filter(of).at(-1);
165
213
  cards.push({
166
214
  ...content,
215
+ actionLabels: content.actionLabels ?? {},
167
216
  source: state.source,
168
217
  id: state.request_id,
169
218
  revision: Number(state.revision),
170
219
  history: events.results
171
220
  .filter(of)
172
- .map((event) => ({ kind: event.kind, at: event.created_at })),
221
+ .map((event) => historyItem(source, event)),
173
222
  notice: notice === undefined
174
223
  ? null
175
224
  : {
@@ -225,15 +274,29 @@ async function sync(c, sources) {
225
274
  requests: await cardsOf(c.env.DB, sources, states)
226
275
  };
227
276
  }
228
- async function remembered(d1, operation) {
277
+ const pointer = z.object({
278
+ source: z.string(),
279
+ id: z.string(),
280
+ revision: z.number().int()
281
+ });
282
+ async function remembered(d1, sources, operation) {
229
283
  const row = await d1
230
284
  .prepare('SELECT result FROM request_operations WHERE id = ?1')
231
285
  .bind(operation)
232
286
  .first();
233
287
  if (row === null || row.result === '')
234
288
  return undefined;
235
- const card = requestCard.safeParse(JSON.parse(row.result));
236
- return card.success ? card.data : undefined;
289
+ const saved = JSON.parse(row.result);
290
+ const card = requestCard.safeParse(saved);
291
+ if (card.success)
292
+ return card.data;
293
+ const done = pointer.safeParse(saved);
294
+ if (!done.success)
295
+ return undefined;
296
+ const now = await cardOf(d1, sources, done.data.source, done.data.id);
297
+ if (now === undefined)
298
+ throw refused(404, 'No such request');
299
+ return now;
237
300
  }
238
301
  async function actionStatements(d1, source, act) {
239
302
  const base = { source: act.source, requestId: act.id, expected: act.revision };
@@ -251,6 +314,15 @@ async function actionStatements(d1, source, act) {
251
314
  throw refused(400, 'Not allowed');
252
315
  return source.decide(d1, act.id, act.action, act.revision);
253
316
  }
317
+ function operationStatement(d1, act) {
318
+ return d1
319
+ .prepare('INSERT INTO request_operations (id, result) VALUES (?1, CASE WHEN (SELECT revision FROM request_states WHERE id = ?3) = ?4 THEN ?2 END)')
320
+ .bind(act.operation, JSON.stringify({
321
+ source: act.source,
322
+ id: act.id,
323
+ revision: act.revision + 1
324
+ }), stateId(act.source, act.id), act.revision);
325
+ }
254
326
  async function act(c, sources) {
255
327
  requirePattern(c);
256
328
  if (!(await takesRequests(c.env.DB)))
@@ -269,7 +341,7 @@ async function act(c, sources) {
269
341
  if (email === undefined)
270
342
  throw refused(403, 'This pass is for another action');
271
343
  const d1 = c.env.DB;
272
- const earlier = await remembered(d1, wanted.operation);
344
+ const earlier = await remembered(d1, sources, wanted.operation);
273
345
  if (earlier !== undefined)
274
346
  return earlier;
275
347
  const source = sources.find((item) => item.name === wanted.source);
@@ -281,36 +353,32 @@ async function act(c, sources) {
281
353
  }
282
354
  if (current.revision !== wanted.revision)
283
355
  throw refused(409, CHANGED);
284
- if (!current.actions.includes(wanted.action)) {
285
- throw refused(409, 'That can’t be done to this request now');
286
- }
356
+ if (!current.actions.includes(wanted.action))
357
+ throw refused(409, NOT_NOW);
287
358
  const statements = await actionStatements(d1, source, wanted);
288
359
  try {
289
360
  await d1.batch([
290
- d1
291
- .prepare('INSERT INTO request_operations (id, result) VALUES (?1, ?2)')
292
- .bind(wanted.operation, ''),
293
- ...statements
361
+ operationStatement(d1, wanted),
362
+ ...eachMustChange(d1, statements)
294
363
  ]);
295
364
  }
296
365
  catch (error) {
297
- const again = await remembered(d1, wanted.operation);
366
+ const again = await remembered(d1, sources, wanted.operation);
298
367
  if (again !== undefined)
299
368
  return again;
300
- throw error;
369
+ if (!isAbandoned(error))
370
+ throw error;
371
+ const latest = await cardOf(d1, sources, wanted.source, wanted.id);
372
+ throw refused(409, latest?.revision === wanted.revision ? NOT_NOW : CHANGED);
301
373
  }
302
374
  const after = await cardOf(d1, sources, wanted.source, wanted.id);
303
- if (after === undefined || after.revision !== wanted.revision + 1) {
304
- await d1
305
- .prepare('DELETE FROM request_operations WHERE id = ?1')
306
- .bind(wanted.operation)
307
- .run();
308
- throw refused(409, CHANGED);
309
- }
375
+ if (after === undefined)
376
+ throw refused(404, 'No such request');
310
377
  await d1
311
378
  .prepare('UPDATE request_operations SET result = ?1 WHERE id = ?2')
312
379
  .bind(JSON.stringify(after), wanted.operation)
313
- .run();
380
+ .run()
381
+ .catch(() => undefined);
314
382
  return after;
315
383
  }
316
384
  function answer(c, work) {