@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
@@ -1,15 +1,12 @@
1
- import { FULL, RULES_ID, UNAVAILABLE, bookingBlackout, bookingRequests, bookingRules, bookingServices, bookingTables, daysOf, isValidTimezone, rulesOf, rulesProblem, slotFor, takenKey, windowsOn } from "../bookings.js";
1
+ import { FULL, RULES_ID, UNAVAILABLE, bookingBlackout, bookingRequests, bookingRules, bookingServices, bookingTables, daysOf, isValidTimezone, rulesOf, rulesProblem, slotFor, takenKey, WEEKDAYS, windowId, windowsOn } from "../bookings.js";
2
2
  import { refused } from "../services.js";
3
+ import { storedRows, textOf } from "./inquiries.js";
3
4
  import { notifyOwner } from "./authService.js";
5
+ import { afterResponse, drainNotices, receivedStatements, requestsLink, takesRequests, transitionStatements } from "./requests.js";
4
6
  import { serveServices } from "./services.js";
5
7
  import { readRow, readRows } from "./tables.js";
6
8
  const HOLDING_SQL = "status IN ('requested', 'accepted')";
7
- export async function sendBookingEmail(c, email) {
8
- if (!import.meta.env.DEV)
9
- return;
10
- const to = email.kind === 'requested' ? 'the owner' : email.request.email;
11
- console.log(`Booking email (${email.kind}) to ${to} from ${new URL(c.req.url).host}: ${email.request.name}, ${email.request.date} ${email.request.window}`);
12
- }
9
+ const SOURCE = 'bookings';
13
10
  function clockOf(time) {
14
11
  const [hours = 0, minutes = 0] = time.split(':').map(Number);
15
12
  const hour = hours % 12 === 0 ? 12 : hours % 12;
@@ -23,7 +20,7 @@ function whenOf(request, label) {
23
20
  const [start = '', end = ''] = request.window.split('-');
24
21
  return `${day}, ${label} (${clockOf(start)}–${clockOf(end)})`;
25
22
  }
26
- function ownerNoteOf(request, label, manage) {
23
+ function ownerNoteOf(request, label, link) {
27
24
  const lines = [
28
25
  `${request.name} asked to book ${request.offering} on ${whenOf(request, label)}.`,
29
26
  '',
@@ -33,8 +30,8 @@ function ownerNoteOf(request, label, manage) {
33
30
  ...(request.notes === '' ? [] : ['', request.notes]),
34
31
  '',
35
32
  request.status === 'accepted'
36
- ? `It is booked: your booking rules accept requests on their own. See it at ${manage}`
37
- : `Accept or decline it at ${manage}`,
33
+ ? `It is booked: your booking rules accept requests on their own. See it at ${link}`
34
+ : `Accept or decline it at ${link}`,
38
35
  `Reply to this email to write to ${request.name}.`
39
36
  ];
40
37
  return {
@@ -78,28 +75,121 @@ async function readRequest(d1, id) {
78
75
  const row = await readRow(d1, bookingRequests, id);
79
76
  return row === undefined ? undefined : bookingRequests.row.parse(row);
80
77
  }
78
+ const DECIDABLE = {
79
+ accepted: ['requested'],
80
+ declined: ['requested', 'accepted']
81
+ };
82
+ function decisionStatements(d1, id, status, expected) {
83
+ const from = DECIDABLE[status].map((item) => `'${item}'`).join(', ');
84
+ const guard = expected === null
85
+ ? ''
86
+ : ' AND (SELECT revision FROM request_states WHERE id = ?3) = ?4';
87
+ return [
88
+ d1
89
+ .prepare(`UPDATE booking_requests SET status = ?1 WHERE id = ?2 AND status IN (${from})${guard}`)
90
+ .bind(status, id, ...(expected === null ? [] : [`${SOURCE}:${id}`, expected])),
91
+ ...transitionStatements(d1, {
92
+ source: SOURCE,
93
+ requestId: id,
94
+ event: status,
95
+ state: 'done',
96
+ expected
97
+ })
98
+ ];
99
+ }
81
100
  async function decide(c, id, status) {
82
101
  requireAdmin(c);
83
- const from = status === 'accepted' ? ['requested'] : ['requested', 'accepted'];
84
102
  const existing = await readRequest(c.env.DB, id);
85
103
  if (existing === undefined)
86
104
  throw refused(404, 'No such request');
87
- if (!from.includes(existing.status)) {
105
+ if (!DECIDABLE[status].includes(existing.status)) {
88
106
  throw refused(409, `This request is already ${existing.status}`);
89
107
  }
90
- const { meta } = await c.env.DB.prepare(`UPDATE booking_requests SET status = ?1 WHERE id = ?2 AND status IN (${from.map((item) => `'${item}'`).join(', ')})`)
91
- .bind(status, id)
92
- .run();
93
- if (meta.changes === 0)
108
+ const statements = decisionStatements(c.env.DB, id, status, null);
109
+ const [result] = await c.env.DB.batch((await takesRequests(c.env.DB)) ? statements : statements.slice(0, 1));
110
+ if (result?.meta.changes === 0) {
94
111
  throw refused(409, 'This request just changed');
95
- const decided = { ...existing, status };
96
- await sendBookingEmail(c, { kind: status, request: decided });
97
- return decided;
112
+ }
113
+ return { ...existing, status };
114
+ }
115
+ function longDay(date) {
116
+ return new Date(`${date}T12:00:00Z`).toLocaleDateString('en-GB', {
117
+ weekday: 'long',
118
+ day: 'numeric',
119
+ month: 'long',
120
+ timeZone: 'UTC'
121
+ });
122
+ }
123
+ function rangeOf(window) {
124
+ const [start = '', end = ''] = window.split('-');
125
+ return `${clockOf(start)}–${clockOf(end)}`;
98
126
  }
127
+ function windowLabel(rules, date, window) {
128
+ const weekday = new Date(`${date}T12:00:00Z`).getUTCDay();
129
+ const rule = rules.windows.find((item) => item.weekday === WEEKDAYS[weekday] && windowId(item) === window);
130
+ return rule === undefined
131
+ ? rangeOf(window)
132
+ : `${rule.label}, ${rangeOf(window)}`;
133
+ }
134
+ function bookingContent(request, rules) {
135
+ const [head = request.offering, ...rest] = request.offering.split(', ');
136
+ const time = windowLabel(rules, request.date, request.window);
137
+ const isRequested = request.status === 'requested';
138
+ return {
139
+ kind: 'booking',
140
+ title: 'Booking request',
141
+ label: head,
142
+ summary: rest.join(', '),
143
+ name: request.name,
144
+ email: request.email,
145
+ when: request.date,
146
+ whenDetail: time,
147
+ fields: [
148
+ { label: 'Booking', value: request.offering },
149
+ { label: 'Day', value: longDay(request.date) },
150
+ { label: 'Time', value: time },
151
+ ...(request.address === ''
152
+ ? []
153
+ : [{ label: 'Address', value: request.address }])
154
+ ],
155
+ contact: [
156
+ { label: 'Email', value: request.email },
157
+ ...(request.phone === ''
158
+ ? []
159
+ : [{ label: 'Phone', value: request.phone }])
160
+ ],
161
+ message: request.notes,
162
+ status: request.status,
163
+ needsAnswer: isRequested,
164
+ actions: isRequested ? ['accept', 'decline'].slice() : [],
165
+ receivedAt: request.createdAt
166
+ };
167
+ }
168
+ const bookingSource = {
169
+ name: SOURCE,
170
+ table: 'booking_requests',
171
+ contents: async (d1, progress) => {
172
+ const [rules, rows] = await Promise.all([
173
+ readRules(d1),
174
+ storedRows(d1, bookingRequests, [...progress.keys()])
175
+ ]);
176
+ return new Map([...rows].flatMap(([id, row]) => {
177
+ const request = bookingRequests.row.safeParse({
178
+ ...row,
179
+ id: textOf(row['id'])
180
+ });
181
+ return request.success
182
+ ? [[id, bookingContent(request.data, rules)]]
183
+ : [];
184
+ }));
185
+ },
186
+ decide: async (d1, id, action, expected) => decisionStatements(d1, id, action === 'accept' ? 'accepted' : 'declined', expected),
187
+ backfill: (d1) => d1.prepare(`INSERT INTO request_states (id, source, request_id, state, revision, sequence, updated_at) SELECT '${SOURCE}:' || id, '${SOURCE}', id, CASE status WHEN 'requested' THEN 'open' ELSE 'done' END, 1, (SELECT COALESCE(MAX(sequence), 0) FROM request_states) + ROW_NUMBER() OVER (ORDER BY created_at, id), created_at FROM booking_requests WHERE '${SOURCE}:' || id NOT IN (SELECT id FROM request_states)`)
188
+ };
99
189
  function monthRange(month) {
100
190
  return { first: `${month}-01`, last: `${month}-31` };
101
191
  }
102
- export function bookings({ timezone, managePath = '/' }) {
192
+ export function bookings({ timezone }) {
103
193
  if (!isValidTimezone(timezone)) {
104
194
  throw new Error(`bookings({ timezone: '${timezone}' }) is not a time zone: use an IANA name such as 'America/Toronto'`);
105
195
  }
@@ -123,19 +213,46 @@ export function bookings({ timezone, managePath = '/' }) {
123
213
  throw refused(409, FULL);
124
214
  const id = crypto.randomUUID();
125
215
  const status = facts.rules.autoAccept ? 'accepted' : 'requested';
126
- const { meta } = await c.env.DB.prepare(`INSERT INTO booking_requests (id, offering, date, "window", name, email, phone, address, notes, status) SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10 WHERE (SELECT COUNT(*) FROM booking_requests WHERE date = ?3 AND "window" = ?4 AND ${HOLDING_SQL}) < ?11`)
127
- .bind(id, fields.offering, fields.date, fields.window, fields.name, fields.email, fields.phone ?? '', fields.address ?? '', fields.notes ?? '', status, slot.capacity)
128
- .run();
129
- if (meta.changes === 0)
216
+ const d1 = c.env.DB;
217
+ const shown = {
218
+ ...fields,
219
+ id,
220
+ createdAt: new Date().toISOString(),
221
+ phone: fields.phone ?? '',
222
+ address: fields.address ?? '',
223
+ notes: fields.notes ?? '',
224
+ status
225
+ };
226
+ const tracked = await takesRequests(d1);
227
+ const notice = ownerNoteOf(shown, slot.label, requestsLink(c.env));
228
+ const [inserted] = await d1.batch([
229
+ d1
230
+ .prepare(`INSERT INTO booking_requests (id, offering, date, "window", name, email, phone, address, notes, status) SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10 WHERE (SELECT COUNT(*) FROM booking_requests WHERE date = ?3 AND "window" = ?4 AND ${HOLDING_SQL}) < ?11`)
231
+ .bind(id, shown.offering, shown.date, shown.window, shown.name, shown.email, shown.phone, shown.address, shown.notes, status, slot.capacity),
232
+ ...(tracked
233
+ ? receivedStatements(d1, {
234
+ source: SOURCE,
235
+ table: 'booking_requests',
236
+ requestId: id,
237
+ notice
238
+ })
239
+ : []),
240
+ ...(tracked && status === 'accepted'
241
+ ? transitionStatements(d1, {
242
+ source: SOURCE,
243
+ requestId: id,
244
+ event: 'accepted',
245
+ state: 'done',
246
+ expected: null
247
+ })
248
+ : [])
249
+ ]);
250
+ if (inserted?.meta.changes === 0)
130
251
  throw refused(409, FULL);
131
- const saved = await readRequest(c.env.DB, id);
252
+ const saved = await readRequest(d1, id);
132
253
  if (saved === undefined)
133
254
  throw new Error('The request was not saved');
134
- await notifyOwner(c.env, ownerNoteOf(saved, slot.label, new URL(managePath, c.req.url).href));
135
- await sendBookingEmail(c, {
136
- kind: status === 'accepted' ? 'accepted' : 'requested',
137
- request: saved
138
- });
255
+ await afterResponse(c, tracked ? drainNotices(c.env) : notifyOwner(c.env, notice));
139
256
  return saved;
140
257
  },
141
258
  requests: async (c) => {
@@ -172,6 +289,7 @@ export function bookings({ timezone, managePath = '/' }) {
172
289
  name: 'bookings',
173
290
  tables: bookingTables,
174
291
  openPaths: ['/api/bookings/'],
175
- routes
292
+ routes,
293
+ requests: [bookingSource]
176
294
  };
177
295
  }
@@ -7,6 +7,7 @@ import { media } from "../media.js";
7
7
  import { auth, devSignIn } from "./auth.js";
8
8
  import { serveDatabase } from "./database.js";
9
9
  import { attachPatternViewer, bearerOf } from "./patternViewer.js";
10
+ import { requestGateway } from "./requests.js";
10
11
  import { forgetSession, issuePass, needsRecheck, readPass, rememberUser } from "./session.js";
11
12
  import { renewSession } from "./sessions.js";
12
13
  import { readTitle, writeTitle } from "./settings.js";
@@ -97,6 +98,9 @@ export function createServer({ tables, admins, routes, pages, blocks = [] }) {
97
98
  .route('/tables', serveTables(tables));
98
99
  if (hasMedia)
99
100
  server.post('/media', uploadMedia);
101
+ const sources = blocks.flatMap((block) => block.requests ?? []);
102
+ if (sources.length > 0)
103
+ server.route('/', requestGateway(sources));
100
104
  for (const block of blocks)
101
105
  server.route('/', block.routes);
102
106
  const own = routes;
@@ -2,12 +2,14 @@ import type { D1Database, Fetcher, R2Bucket } from '@cloudflare/workers-types';
2
2
  import type { Hono } from 'hono';
3
3
  import type { Viewer } from '../access.ts';
4
4
  import type { Tables } from '../table.ts';
5
+ import type { RequestSource } from './requests.ts';
5
6
  export type { Viewer };
6
7
  export type ServerBindings = {
7
8
  DB: D1Database;
8
9
  SESSION_SECRET: string;
9
10
  AUTH_SECRET?: string;
10
11
  OWNER_EMAIL?: string;
12
+ APP_NAME?: string;
11
13
  MEDIA?: R2Bucket;
12
14
  ASSETS?: Fetcher;
13
15
  };
@@ -23,4 +25,5 @@ export type ServerBlock = {
23
25
  readonly tables: Tables;
24
26
  readonly openPaths: readonly string[];
25
27
  readonly routes: Hono<ServerEnv>;
28
+ readonly requests?: readonly RequestSource[];
26
29
  };
@@ -3,6 +3,7 @@ export type { ServerBindings, ServerBlock, ServerEnv, Viewer } from './env.ts';
3
3
  export { notifyOwner } from './authService.ts';
4
4
  export type { OwnerNote } from './authService.ts';
5
5
  export { bookings } from './bookings.ts';
6
+ export { inquiries } from './inquiries.ts';
6
7
  export type { BookingsOptions } from './bookings.ts';
7
8
  export { seedSql, serveTables, sqlTable, sqlTables } from './tables.ts';
8
9
  export { refused } from '../services.ts';
@@ -1,6 +1,7 @@
1
1
  export { createServer } from "./createServer.js";
2
2
  export { notifyOwner } from "./authService.js";
3
3
  export { bookings } from "./bookings.js";
4
+ export { inquiries } from "./inquiries.js";
4
5
  export { seedSql, serveTables, sqlTable, sqlTables } from "./tables.js";
5
6
  export { refused } from "../services.js";
6
7
  export { serveServices } from "./services.js";
@@ -0,0 +1,9 @@
1
+ import type { D1Database } from '@cloudflare/workers-types';
2
+ import type { Inquiry } from '../requests.ts';
3
+ import type { Table } from '../table.ts';
4
+ import type { ServerBlock } from './env.ts';
5
+ type Stored = Readonly<Record<string, unknown>>;
6
+ export declare function textOf(value: unknown): string;
7
+ export declare function storedRows(d1: D1Database, table: Table, ids: readonly string[]): Promise<ReadonlyMap<string, Stored>>;
8
+ export declare function inquiries(sources: readonly Inquiry[]): ServerBlock;
9
+ export {};
@@ -0,0 +1,150 @@
1
+ import { drizzle } from 'drizzle-orm/d1';
2
+ import { z } from 'zod';
3
+ import { inquiryServices, requestTables } from "../requests.js";
4
+ import { refused } from "../services.js";
5
+ import { snakeCase } from "../table.js";
6
+ import { afterResponse, drainNotices, receivedStatements, requestsLink } from "./requests.js";
7
+ import { serveServices } from "./services.js";
8
+ import { sqlTable } from "./tables.js";
9
+ export function textOf(value) {
10
+ if (value === null || value === undefined)
11
+ return '';
12
+ if (typeof value === 'string')
13
+ return value.trim();
14
+ return typeof value === 'number' || typeof value === 'boolean'
15
+ ? String(value)
16
+ : '';
17
+ }
18
+ export async function storedRows(d1, table, ids) {
19
+ if (ids.length === 0)
20
+ return new Map();
21
+ const { results } = await d1
22
+ .prepare(`SELECT * FROM ${snakeCase(table.name)} WHERE id IN (SELECT value FROM json_each(?1))`)
23
+ .bind(JSON.stringify(ids))
24
+ .all();
25
+ const keys = ['id', 'createdAt', ...Object.keys(table.columns)];
26
+ return new Map(results.map((row) => [
27
+ textOf(row['id']),
28
+ Object.fromEntries(keys.map((key) => [key, row[snakeCase(key)]]))
29
+ ]));
30
+ }
31
+ function fieldsOf(inquiry, row) {
32
+ return [
33
+ ...inquiry.fields.flatMap(({ key, label }) => {
34
+ const value = textOf(row[key]);
35
+ return value === '' ? [] : [{ label, value }];
36
+ }),
37
+ { label: 'Email', value: textOf(row[inquiry.email]) }
38
+ ];
39
+ }
40
+ function noticeOf(inquiry, row, link) {
41
+ const name = textOf(row[inquiry.name]);
42
+ const message = inquiry.message === null ? '' : textOf(row[inquiry.message]);
43
+ const noun = inquiry.title.toLowerCase();
44
+ return {
45
+ subject: `New ${noun} from ${name}`,
46
+ text: [
47
+ `${name} sent a ${noun} from your site.`,
48
+ '',
49
+ ...fieldsOf(inquiry, row).map(({ label, value }) => `${label}: ${value}`),
50
+ ...(message === '' ? [] : ['', message]),
51
+ '',
52
+ `Answer it at ${link}`,
53
+ `Or reply to this email to write to ${name}.`
54
+ ].join('\n'),
55
+ replyTo: textOf(row[inquiry.email])
56
+ };
57
+ }
58
+ function contentOf(inquiry, row, progress) {
59
+ const isOpen = progress.state === 'open';
60
+ const message = inquiry.message === null ? '' : textOf(row[inquiry.message]);
61
+ const brief = inquiry.summary === null ? message : textOf(row[inquiry.summary]);
62
+ const when = inquiry.when === null ? '' : textOf(row[inquiry.when.field]);
63
+ return {
64
+ kind: 'inquiry',
65
+ title: inquiry.title,
66
+ label: inquiry.title,
67
+ summary: brief === '' ? inquiry.form : `${inquiry.form} · ${brief}`,
68
+ name: textOf(row[inquiry.name]),
69
+ email: textOf(row[inquiry.email]),
70
+ when: when === '' ? null : when,
71
+ whenDetail: inquiry.when === null ? null : inquiry.when.detail,
72
+ fields: fieldsOf(inquiry, row),
73
+ contact: [],
74
+ message,
75
+ status: !isOpen
76
+ ? 'done'
77
+ : progress.replied
78
+ ? 'replied'
79
+ : 'open',
80
+ needsAnswer: isOpen,
81
+ actions: isOpen ? ['reply', 'done'].slice() : [],
82
+ receivedAt: textOf(row['createdAt'])
83
+ };
84
+ }
85
+ function sourceOf(inquiry) {
86
+ return {
87
+ name: inquiry.source,
88
+ table: snakeCase(inquiry.table.name),
89
+ contents: async (d1, progress) => {
90
+ const rows = await storedRows(d1, inquiry.table, [...progress.keys()]);
91
+ return new Map([...progress].flatMap(([id, step]) => {
92
+ const row = rows.get(id);
93
+ return row === undefined
94
+ ? []
95
+ : [[id, contentOf(inquiry, row, step)]];
96
+ }));
97
+ }
98
+ };
99
+ }
100
+ function checked(sources) {
101
+ const names = new Set();
102
+ for (const inquiry of sources) {
103
+ if (names.has(inquiry.source)) {
104
+ throw new Error(`Two inquiries are called '${inquiry.source}': give each its own source name`);
105
+ }
106
+ names.add(inquiry.source);
107
+ }
108
+ return sources;
109
+ }
110
+ export function inquiries(sources) {
111
+ const known = checked(sources);
112
+ const routes = serveServices(inquiryServices, {
113
+ inquiries: {
114
+ send: async (c, { source, fields }) => {
115
+ const inquiry = known.find((item) => item.source === source);
116
+ if (inquiry === undefined)
117
+ throw refused(404, 'No such form');
118
+ const parsed = inquiry.table.fields.safeParse(fields);
119
+ if (!parsed.success) {
120
+ throw refused(400, z.prettifyError(parsed.error));
121
+ }
122
+ const id = crypto.randomUUID();
123
+ const row = { ...parsed.data, id };
124
+ const d1 = c.env.DB;
125
+ const insert = drizzle(d1)
126
+ .insert(sqlTable(inquiry.table))
127
+ .values(row)
128
+ .toSQL();
129
+ await d1.batch([
130
+ d1.prepare(insert.sql).bind(...insert.params),
131
+ ...receivedStatements(d1, {
132
+ source: inquiry.source,
133
+ table: snakeCase(inquiry.table.name),
134
+ requestId: id,
135
+ notice: noticeOf(inquiry, row, requestsLink(c.env))
136
+ })
137
+ ]);
138
+ await afterResponse(c, drainNotices(c.env));
139
+ return { id };
140
+ }
141
+ }
142
+ });
143
+ return {
144
+ name: 'inquiries',
145
+ tables: requestTables,
146
+ openPaths: ['/api/requests/'],
147
+ routes,
148
+ requests: known.map(sourceOf)
149
+ };
150
+ }
@@ -1,4 +1,5 @@
1
- import { MEDIA_WRITE, TABLES_READ, TABLES_WRITE, verifiedAssertion } from "../patternAccess.js";
1
+ import { MEDIA_WRITE, REQUESTS_ACT, REQUESTS_READ, TABLES_READ, TABLES_WRITE, verifiedAssertion } from "../patternAccess.js";
2
+ import { REQUESTS_PATH } from "../requests.js";
2
3
  import { PATTERN_KEYS } from "./patternKeys.js";
3
4
  const BEARER = /^bearer\s+/i;
4
5
  const SCHEMA_PATH = '/api/schema';
@@ -17,6 +18,11 @@ function scopeOf(c) {
17
18
  }
18
19
  if (method === 'POST' && path === CHANGES_PATH)
19
20
  return TABLES_WRITE;
21
+ if (method === 'GET' && path === `${REQUESTS_PATH}/sync`) {
22
+ return REQUESTS_READ;
23
+ }
24
+ if (method === 'POST' && path === `${REQUESTS_PATH}/act`)
25
+ return REQUESTS_ACT;
20
26
  return method === 'POST' && path === MEDIA_PATH ? MEDIA_WRITE : undefined;
21
27
  }
22
28
  export async function attachPatternViewer(c, next, token) {
@@ -0,0 +1,76 @@
1
+ import type { D1Database, D1PreparedStatement } from '@cloudflare/workers-types';
2
+ import type { Context } from 'hono';
3
+ import type { RequestCard } from '../requests.ts';
4
+ import type { ServerBindings, ServerEnv } from './env.ts';
5
+ export type RequestContent = Omit<RequestCard, 'revision' | 'history' | 'notice' | 'source' | 'id' | 'receivedAt'>;
6
+ export type RequestProgress = {
7
+ state: 'open' | 'done';
8
+ replied: boolean;
9
+ };
10
+ export type RequestSource = {
11
+ readonly name: string;
12
+ readonly table: string;
13
+ readonly contents: (d1: D1Database, progress: ReadonlyMap<string, RequestProgress>) => Promise<ReadonlyMap<string, RequestContent & {
14
+ receivedAt: string;
15
+ }>>;
16
+ readonly decide?: (d1: D1Database, id: string, action: 'accept' | 'decline', expected: number) => Promise<readonly D1PreparedStatement[]>;
17
+ readonly backfill?: (d1: D1Database) => D1PreparedStatement;
18
+ };
19
+ export type OwnerNotice = {
20
+ subject: string;
21
+ text: string;
22
+ replyTo: string;
23
+ };
24
+ export declare function takesRequests(d1: D1Database): Promise<boolean>;
25
+ export declare const NEXT_SEQUENCE = "(SELECT COALESCE(MAX(sequence), 0) + 1 FROM request_states)";
26
+ export declare function requestsLink(env: ServerBindings): string;
27
+ export declare function receivedStatements(d1: D1Database, { source, table, requestId, notice }: {
28
+ source: string;
29
+ table: string;
30
+ requestId: string;
31
+ notice: OwnerNotice;
32
+ }): D1PreparedStatement[];
33
+ export declare function transitionStatements(d1: D1Database, { source, requestId, event, state, expected }: {
34
+ source: string;
35
+ requestId: string;
36
+ event: 'accepted' | 'declined' | 'replied' | 'done';
37
+ state: 'open' | 'done';
38
+ expected: number | null;
39
+ }): D1PreparedStatement[];
40
+ export declare function drainNotices(env: ServerBindings, { force }?: {
41
+ force?: boolean;
42
+ }): Promise<void>;
43
+ export declare function afterResponse(c: Context<ServerEnv>, work: Promise<unknown>): Promise<void>;
44
+ export declare function requestGateway(sources: readonly RequestSource[]): import("hono/hono-base").HonoBase<ServerEnv, {
45
+ "/pattern/requests/sync": {
46
+ $get: {
47
+ input: {};
48
+ output: never;
49
+ outputFormat: "json";
50
+ status: import("hono/utils/http-status").ContentfulStatusCode;
51
+ } | {
52
+ input: {};
53
+ output: {
54
+ error: string;
55
+ };
56
+ outputFormat: "json";
57
+ status: 400;
58
+ };
59
+ };
60
+ } & {
61
+ "/pattern/requests/act": {
62
+ $post: {
63
+ input: {};
64
+ output: never;
65
+ outputFormat: "json";
66
+ status: import("hono/utils/http-status").ContentfulStatusCode;
67
+ } | {
68
+ input: {};
69
+ output: {
70
+ error: string;
71
+ };
72
+ outputFormat: "json";
73
+ status: 400;
74
+ };
75
+ };
76
+ }, "/", "/pattern/requests/act">;