@trypattern/sdk 0.1.33 → 0.1.34
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/app.d.ts +6 -2
- package/dist/app.js +6 -1
- package/dist/blocks.js +11 -3
- package/dist/bookings.d.ts +78 -0
- package/dist/bookings.js +7 -1
- package/dist/bookingsStore.d.ts +78 -0
- package/dist/cli/app.js +17 -2
- package/dist/cli/requests.d.ts +5 -0
- package/dist/cli/requests.js +115 -0
- package/dist/components/AppFrame.js +12 -2
- package/dist/components/MenuItems.d.ts +1 -0
- package/dist/components/MenuItems.js +13 -2
- package/dist/components/SegmentedControl.d.ts +1 -0
- package/dist/components/SegmentedControl.js +9 -2
- package/dist/components/Sidebar.d.ts +1 -0
- package/dist/components/Sidebar.js +18 -2
- package/dist/components/TableRow.d.ts +2 -1
- package/dist/components/TableRow.js +5 -2
- package/dist/components/icons.js +1 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/inquiriesStore.d.ts +134 -0
- package/dist/inquiriesStore.js +64 -0
- package/dist/patternAccess.d.ts +8 -2
- package/dist/patternAccess.js +17 -5
- package/dist/publishWire.d.ts +866 -0
- package/dist/publishWire.js +78 -0
- package/dist/requests.d.ts +361 -0
- package/dist/requests.js +180 -0
- package/dist/schema.d.ts +2 -0
- package/dist/schema.js +1 -0
- package/dist/server/authService.d.ts +1 -0
- package/dist/server/bookings.d.ts +2 -9
- package/dist/server/bookings.js +149 -31
- package/dist/server/createServer.js +4 -0
- package/dist/server/env.d.ts +3 -0
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +1 -0
- package/dist/server/inquiries.d.ts +9 -0
- package/dist/server/inquiries.js +150 -0
- package/dist/server/patternViewer.js +7 -1
- package/dist/server/requests.d.ts +76 -0
- package/dist/server/requests.js +328 -0
- package/dist/server/services.js +9 -1
- package/package.json +1 -1
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { REQUESTS_ACT, verifiedAssertion } from "../patternAccess.js";
|
|
4
|
+
import { REQUEST_SYNC_PAGE, requestAct, requestBound, requestCard } from "../requests.js";
|
|
5
|
+
import { ServiceRefused, refused } from "../services.js";
|
|
6
|
+
import { authServiceFor } from "./authService.js";
|
|
7
|
+
import { PATTERN_KEYS } from "./patternKeys.js";
|
|
8
|
+
import { bearerOf } from "./patternViewer.js";
|
|
9
|
+
const DASHBOARD = 'https://onpattern.app';
|
|
10
|
+
const MAX_ATTEMPTS = 8;
|
|
11
|
+
const STALE_CLAIM_MS = 5 * 60 * 1000;
|
|
12
|
+
const BACKOFF_MINUTES = [1, 5, 30, 120, 720];
|
|
13
|
+
const DRAINED_AT_ONCE = 10;
|
|
14
|
+
const CHANGED = 'This request changed since you opened it. Open it again to see what’s new.';
|
|
15
|
+
const REQUEST_TABLES = [
|
|
16
|
+
'request_states',
|
|
17
|
+
'request_events',
|
|
18
|
+
'request_outbox',
|
|
19
|
+
'request_operations'
|
|
20
|
+
];
|
|
21
|
+
const NOT_MIGRATED = 'This site’s database doesn’t have Pattern’s request tables yet: run app migration, then publish';
|
|
22
|
+
const migrated = new WeakMap();
|
|
23
|
+
export function takesRequests(d1) {
|
|
24
|
+
const known = migrated.get(d1);
|
|
25
|
+
if (known !== undefined)
|
|
26
|
+
return known;
|
|
27
|
+
const found = d1
|
|
28
|
+
.prepare("SELECT COUNT(*) AS count FROM sqlite_master WHERE type = 'table' AND name IN (SELECT value FROM json_each(?1))")
|
|
29
|
+
.bind(JSON.stringify(REQUEST_TABLES))
|
|
30
|
+
.first()
|
|
31
|
+
.then((row) => Number(row?.count ?? 0) === REQUEST_TABLES.length);
|
|
32
|
+
migrated.set(d1, found);
|
|
33
|
+
return found;
|
|
34
|
+
}
|
|
35
|
+
export const NEXT_SEQUENCE = '(SELECT COALESCE(MAX(sequence), 0) + 1 FROM request_states)';
|
|
36
|
+
function stateId(source, requestId) {
|
|
37
|
+
return `${source}:${requestId}`;
|
|
38
|
+
}
|
|
39
|
+
export function requestsLink(env) {
|
|
40
|
+
const name = env.APP_NAME ?? '';
|
|
41
|
+
return name === '' ? DASHBOARD : `${DASHBOARD}/sites/${name}/requests`;
|
|
42
|
+
}
|
|
43
|
+
export function receivedStatements(d1, { source, table, requestId, notice }) {
|
|
44
|
+
const guard = `WHERE EXISTS (SELECT 1 FROM ${table} WHERE id = ?3)`;
|
|
45
|
+
const now = new Date().toISOString();
|
|
46
|
+
return [
|
|
47
|
+
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),
|
|
50
|
+
d1
|
|
51
|
+
.prepare(`INSERT INTO request_events (id, source, request_id, kind) SELECT ?1, ?2, ?3, 'received' ${guard}`)
|
|
52
|
+
.bind(crypto.randomUUID(), source, requestId),
|
|
53
|
+
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)
|
|
56
|
+
];
|
|
57
|
+
}
|
|
58
|
+
export function transitionStatements(d1, { source, requestId, event, state, expected }) {
|
|
59
|
+
const id = stateId(source, requestId);
|
|
60
|
+
const guard = expected === null
|
|
61
|
+
? ''
|
|
62
|
+
: `WHERE (SELECT revision FROM request_states WHERE id = ?4) = ?5`;
|
|
63
|
+
return [
|
|
64
|
+
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])),
|
|
67
|
+
d1
|
|
68
|
+
.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
|
+
.bind(state, new Date().toISOString(), id, ...(expected === null ? [] : [expected]))
|
|
70
|
+
];
|
|
71
|
+
}
|
|
72
|
+
function touchStatement(d1, source, requestId) {
|
|
73
|
+
return d1
|
|
74
|
+
.prepare(`UPDATE request_states SET sequence = ${NEXT_SEQUENCE} WHERE id = ?1`)
|
|
75
|
+
.bind(stateId(source, requestId));
|
|
76
|
+
}
|
|
77
|
+
function nextTry(attempts, now) {
|
|
78
|
+
const minutes = BACKOFF_MINUTES[Math.min(attempts - 1, BACKOFF_MINUTES.length - 1)] ?? 720;
|
|
79
|
+
return new Date(now + minutes * 60_000).toISOString();
|
|
80
|
+
}
|
|
81
|
+
async function deliver(env, row) {
|
|
82
|
+
const service = authServiceFor(env);
|
|
83
|
+
try {
|
|
84
|
+
return ((await service?.noteOwner({
|
|
85
|
+
subject: row.subject,
|
|
86
|
+
text: row.text,
|
|
87
|
+
...(row.reply_to === '' ? {} : { replyTo: row.reply_to }),
|
|
88
|
+
key: row.id
|
|
89
|
+
})) ?? false);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
export async function drainNotices(env, { force = false } = {}) {
|
|
96
|
+
const d1 = env.DB;
|
|
97
|
+
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
|
+
]);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
export function afterResponse(c, work) {
|
|
123
|
+
try {
|
|
124
|
+
c.executionCtx.waitUntil(work);
|
|
125
|
+
return Promise.resolve();
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return work.then(() => undefined, () => undefined);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
async function cardsOf(d1, sources, states) {
|
|
132
|
+
if (states.length === 0)
|
|
133
|
+
return [];
|
|
134
|
+
const keys = JSON.stringify(states.map((state) => state.id));
|
|
135
|
+
const [events, notices] = await Promise.all([
|
|
136
|
+
d1
|
|
137
|
+
.prepare("SELECT source, request_id, kind, created_at FROM request_events WHERE source || ':' || request_id IN (SELECT value FROM json_each(?1)) ORDER BY created_at")
|
|
138
|
+
.bind(keys)
|
|
139
|
+
.all(),
|
|
140
|
+
d1
|
|
141
|
+
.prepare("SELECT source, request_id, status, attempts, sent_at, created_at FROM request_outbox WHERE source || ':' || request_id IN (SELECT value FROM json_each(?1)) ORDER BY created_at")
|
|
142
|
+
.bind(keys)
|
|
143
|
+
.all()
|
|
144
|
+
]);
|
|
145
|
+
const cards = [];
|
|
146
|
+
for (const source of sources) {
|
|
147
|
+
const mine = states.filter((state) => state.source === source.name);
|
|
148
|
+
if (mine.length === 0)
|
|
149
|
+
continue;
|
|
150
|
+
const contents = await source.contents(d1, new Map(mine.map((state) => [
|
|
151
|
+
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
|
+
}
|
|
158
|
+
])));
|
|
159
|
+
for (const state of mine) {
|
|
160
|
+
const content = contents.get(state.request_id);
|
|
161
|
+
if (content === undefined)
|
|
162
|
+
continue;
|
|
163
|
+
const of = (row) => row.source === state.source && row.request_id === state.request_id;
|
|
164
|
+
const notice = notices.results.filter(of).at(-1);
|
|
165
|
+
cards.push({
|
|
166
|
+
...content,
|
|
167
|
+
source: state.source,
|
|
168
|
+
id: state.request_id,
|
|
169
|
+
revision: Number(state.revision),
|
|
170
|
+
history: events.results
|
|
171
|
+
.filter(of)
|
|
172
|
+
.map((event) => ({ kind: event.kind, at: event.created_at })),
|
|
173
|
+
notice: notice === undefined
|
|
174
|
+
? null
|
|
175
|
+
: {
|
|
176
|
+
status: notice.status,
|
|
177
|
+
attempts: Number(notice.attempts),
|
|
178
|
+
at: notice.sent_at
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return cards;
|
|
184
|
+
}
|
|
185
|
+
async function readStates(d1, after) {
|
|
186
|
+
const { results } = await d1
|
|
187
|
+
.prepare(`SELECT id, source, request_id, state, revision, sequence FROM request_states WHERE sequence > ?1 ORDER BY sequence LIMIT ${REQUEST_SYNC_PAGE}`)
|
|
188
|
+
.bind(after)
|
|
189
|
+
.all();
|
|
190
|
+
return results;
|
|
191
|
+
}
|
|
192
|
+
async function cardOf(d1, sources, source, requestId) {
|
|
193
|
+
const state = await d1
|
|
194
|
+
.prepare('SELECT id, source, request_id, state, revision, sequence FROM request_states WHERE id = ?1')
|
|
195
|
+
.bind(stateId(source, requestId))
|
|
196
|
+
.first();
|
|
197
|
+
if (state === null)
|
|
198
|
+
return undefined;
|
|
199
|
+
const [card] = await cardsOf(d1, sources, [state]);
|
|
200
|
+
return card;
|
|
201
|
+
}
|
|
202
|
+
function requirePattern(c) {
|
|
203
|
+
const viewer = c.get('viewer');
|
|
204
|
+
if (viewer === null || !viewer.userId.startsWith('pattern:')) {
|
|
205
|
+
throw refused(401, 'Signed out');
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
async function sync(c, sources) {
|
|
209
|
+
requirePattern(c);
|
|
210
|
+
if (!(await takesRequests(c.env.DB)))
|
|
211
|
+
throw refused(404, NOT_MIGRATED);
|
|
212
|
+
const after = z.coerce
|
|
213
|
+
.number()
|
|
214
|
+
.int()
|
|
215
|
+
.min(0)
|
|
216
|
+
.catch(0)
|
|
217
|
+
.parse(c.req.query('after'));
|
|
218
|
+
await drainNotices(c.env, { force: c.req.query('deliver') === 'now' });
|
|
219
|
+
const backfills = sources.flatMap((source) => source.backfill === undefined ? [] : [source.backfill(c.env.DB)]);
|
|
220
|
+
if (backfills.length > 0)
|
|
221
|
+
await c.env.DB.batch(backfills);
|
|
222
|
+
const states = await readStates(c.env.DB, after);
|
|
223
|
+
return {
|
|
224
|
+
cursor: states.reduce((highest, state) => Math.max(highest, Number(state.sequence)), after),
|
|
225
|
+
requests: await cardsOf(c.env.DB, sources, states)
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
async function remembered(d1, operation) {
|
|
229
|
+
const row = await d1
|
|
230
|
+
.prepare('SELECT result FROM request_operations WHERE id = ?1')
|
|
231
|
+
.bind(operation)
|
|
232
|
+
.first();
|
|
233
|
+
if (row === null || row.result === '')
|
|
234
|
+
return undefined;
|
|
235
|
+
const card = requestCard.safeParse(JSON.parse(row.result));
|
|
236
|
+
return card.success ? card.data : undefined;
|
|
237
|
+
}
|
|
238
|
+
async function actionStatements(d1, source, act) {
|
|
239
|
+
const base = { source: act.source, requestId: act.id, expected: act.revision };
|
|
240
|
+
if (act.action === 'reply') {
|
|
241
|
+
return transitionStatements(d1, {
|
|
242
|
+
...base,
|
|
243
|
+
event: 'replied',
|
|
244
|
+
state: 'open'
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
if (act.action === 'done') {
|
|
248
|
+
return transitionStatements(d1, { ...base, event: 'done', state: 'done' });
|
|
249
|
+
}
|
|
250
|
+
if (source.decide === undefined)
|
|
251
|
+
throw refused(400, 'Not allowed');
|
|
252
|
+
return source.decide(d1, act.id, act.action, act.revision);
|
|
253
|
+
}
|
|
254
|
+
async function act(c, sources) {
|
|
255
|
+
requirePattern(c);
|
|
256
|
+
if (!(await takesRequests(c.env.DB)))
|
|
257
|
+
throw refused(404, NOT_MIGRATED);
|
|
258
|
+
const parsed = requestAct.safeParse(await c.req.json().catch(() => null));
|
|
259
|
+
if (!parsed.success)
|
|
260
|
+
throw refused(400, z.prettifyError(parsed.error));
|
|
261
|
+
const wanted = parsed.data;
|
|
262
|
+
const email = await verifiedAssertion(bearerOf(c) ?? '', {
|
|
263
|
+
hostname: new URL(c.req.url).hostname,
|
|
264
|
+
keys: PATTERN_KEYS,
|
|
265
|
+
now: Math.floor(Date.now() / 1000),
|
|
266
|
+
scope: REQUESTS_ACT,
|
|
267
|
+
bound: await requestBound(wanted)
|
|
268
|
+
});
|
|
269
|
+
if (email === undefined)
|
|
270
|
+
throw refused(403, 'This pass is for another action');
|
|
271
|
+
const d1 = c.env.DB;
|
|
272
|
+
const earlier = await remembered(d1, wanted.operation);
|
|
273
|
+
if (earlier !== undefined)
|
|
274
|
+
return earlier;
|
|
275
|
+
const source = sources.find((item) => item.name === wanted.source);
|
|
276
|
+
const current = source === undefined
|
|
277
|
+
? undefined
|
|
278
|
+
: await cardOf(d1, sources, wanted.source, wanted.id);
|
|
279
|
+
if (source === undefined || current === undefined) {
|
|
280
|
+
throw refused(404, 'No such request');
|
|
281
|
+
}
|
|
282
|
+
if (current.revision !== wanted.revision)
|
|
283
|
+
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
|
+
}
|
|
287
|
+
const statements = await actionStatements(d1, source, wanted);
|
|
288
|
+
try {
|
|
289
|
+
await d1.batch([
|
|
290
|
+
d1
|
|
291
|
+
.prepare('INSERT INTO request_operations (id, result) VALUES (?1, ?2)')
|
|
292
|
+
.bind(wanted.operation, ''),
|
|
293
|
+
...statements
|
|
294
|
+
]);
|
|
295
|
+
}
|
|
296
|
+
catch (error) {
|
|
297
|
+
const again = await remembered(d1, wanted.operation);
|
|
298
|
+
if (again !== undefined)
|
|
299
|
+
return again;
|
|
300
|
+
throw error;
|
|
301
|
+
}
|
|
302
|
+
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
|
+
}
|
|
310
|
+
await d1
|
|
311
|
+
.prepare('UPDATE request_operations SET result = ?1 WHERE id = ?2')
|
|
312
|
+
.bind(JSON.stringify(after), wanted.operation)
|
|
313
|
+
.run();
|
|
314
|
+
return after;
|
|
315
|
+
}
|
|
316
|
+
function answer(c, work) {
|
|
317
|
+
return work().then((reply) => c.json(reply), (error) => {
|
|
318
|
+
if (error instanceof ServiceRefused) {
|
|
319
|
+
return c.json({ error: error.message }, error.status);
|
|
320
|
+
}
|
|
321
|
+
throw error;
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
export function requestGateway(sources) {
|
|
325
|
+
return new Hono()
|
|
326
|
+
.get('/pattern/requests/sync', (c) => answer(c, () => sync(c, sources)))
|
|
327
|
+
.post('/pattern/requests/act', (c) => answer(c, () => act(c, sources)));
|
|
328
|
+
}
|
package/dist/server/services.js
CHANGED
|
@@ -23,7 +23,15 @@ async function inputOf(c, call) {
|
|
|
23
23
|
? { ok: true, data: parsed.data }
|
|
24
24
|
: { ok: false, problem: z.prettifyError(parsed.error) };
|
|
25
25
|
}
|
|
26
|
-
const BUILT_IN_PATHS = [
|
|
26
|
+
const BUILT_IN_PATHS = [
|
|
27
|
+
'/auth',
|
|
28
|
+
'/health',
|
|
29
|
+
'/tables',
|
|
30
|
+
'/app',
|
|
31
|
+
'/me',
|
|
32
|
+
'/schema',
|
|
33
|
+
'/pattern'
|
|
34
|
+
];
|
|
27
35
|
function builtInPathOf(path) {
|
|
28
36
|
return BUILT_IN_PATHS.find((prefix) => path === prefix || path.startsWith(`${prefix}/`));
|
|
29
37
|
}
|