@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.
- package/dist/bookings.d.ts +1 -7
- package/dist/bookingsStore.d.ts +1 -7
- package/dist/cli/app.js +71 -2
- package/dist/cli/guidance.d.ts +8 -0
- package/dist/cli/guidance.js +121 -0
- package/dist/cli/instructions.d.ts +21 -0
- package/dist/cli/instructions.js +206 -0
- package/dist/inquiriesStore.d.ts +1 -7
- package/dist/publishWire.d.ts +37 -59
- package/dist/publishWire.js +4 -2
- package/dist/requests.d.ts +14 -32
- package/dist/requests.js +12 -5
- package/dist/server/index.d.ts +2 -0
- package/dist/server/index.js +1 -0
- package/dist/server/inquiries.d.ts +7 -2
- package/dist/server/inquiries.js +2 -2
- package/dist/server/requestSources.d.ts +41 -0
- package/dist/server/requestSources.js +157 -0
- package/dist/server/requests.d.ts +7 -3
- package/dist/server/requests.js +130 -62
- package/docs/CHANGELOG.json +331 -0
- package/docs/PATTERN.md +87 -0
- package/docs/app.md +74 -0
- package/docs/data.md +109 -0
- package/docs/history/agents.json +1141 -0
- package/docs/look.md +147 -0
- package/docs/requests.md +115 -0
- package/docs/state.md +142 -0
- package/package.json +4 -3
|
@@ -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:
|
|
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:
|
|
40
|
+
event: string;
|
|
37
41
|
state: 'open' | 'done';
|
|
38
42
|
expected: number | null;
|
|
39
43
|
}): D1PreparedStatement[];
|
package/dist/server/requests.js
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
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
|
|
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,
|
|
66
|
-
.bind(crypto.randomUUID(), source, requestId,
|
|
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
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
for (
|
|
106
|
-
const
|
|
107
|
-
.prepare(`
|
|
108
|
-
.bind(force ? 1 : 0,
|
|
109
|
-
.
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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) => (
|
|
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
|
-
|
|
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
|
|
236
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
304
|
-
|
|
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) {
|