@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.
Files changed (45) hide show
  1. package/dist/app.d.ts +6 -2
  2. package/dist/app.js +6 -1
  3. package/dist/blocks.js +11 -3
  4. package/dist/bookings.d.ts +78 -0
  5. package/dist/bookings.js +7 -1
  6. package/dist/bookingsStore.d.ts +78 -0
  7. package/dist/cli/app.js +17 -2
  8. package/dist/cli/requests.d.ts +5 -0
  9. package/dist/cli/requests.js +115 -0
  10. package/dist/components/AppFrame.js +12 -2
  11. package/dist/components/MenuItems.d.ts +1 -0
  12. package/dist/components/MenuItems.js +13 -2
  13. package/dist/components/SegmentedControl.d.ts +1 -0
  14. package/dist/components/SegmentedControl.js +9 -2
  15. package/dist/components/Sidebar.d.ts +1 -0
  16. package/dist/components/Sidebar.js +18 -2
  17. package/dist/components/TableRow.d.ts +2 -1
  18. package/dist/components/TableRow.js +5 -2
  19. package/dist/components/icons.js +1 -2
  20. package/dist/index.d.ts +2 -0
  21. package/dist/index.js +1 -0
  22. package/dist/inquiriesStore.d.ts +134 -0
  23. package/dist/inquiriesStore.js +64 -0
  24. package/dist/patternAccess.d.ts +8 -2
  25. package/dist/patternAccess.js +17 -5
  26. package/dist/publishWire.d.ts +866 -0
  27. package/dist/publishWire.js +78 -0
  28. package/dist/requests.d.ts +361 -0
  29. package/dist/requests.js +180 -0
  30. package/dist/schema.d.ts +2 -0
  31. package/dist/schema.js +1 -0
  32. package/dist/server/authService.d.ts +1 -0
  33. package/dist/server/bookings.d.ts +2 -9
  34. package/dist/server/bookings.js +149 -31
  35. package/dist/server/createServer.js +4 -0
  36. package/dist/server/env.d.ts +3 -0
  37. package/dist/server/index.d.ts +1 -0
  38. package/dist/server/index.js +1 -0
  39. package/dist/server/inquiries.d.ts +9 -0
  40. package/dist/server/inquiries.js +150 -0
  41. package/dist/server/patternViewer.js +7 -1
  42. package/dist/server/requests.d.ts +76 -0
  43. package/dist/server/requests.js +328 -0
  44. package/dist/server/services.js +9 -1
  45. 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
+ }
@@ -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 = ['/auth', '/health', '/tables', '/app', '/me', '/schema'];
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trypattern/sdk",
3
- "version": "0.1.33",
3
+ "version": "0.1.34",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist",