@trypattern/sdk 0.1.21 → 0.1.23

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,113 @@
1
+ import { eq } from 'drizzle-orm';
2
+ import { drizzle } from 'drizzle-orm/d1';
3
+ import { z } from 'zod';
4
+ import { dataChange } from "../publishWire.js";
5
+ import { column, readRow, sqlTable } from "./tables.js";
6
+ const changeRequest = z.object({ changes: z.array(dataChange).max(1000) });
7
+ const META_KEYS = new Set(['id', 'createdAt', 'ownerId']);
8
+ function sameValue(left, right) {
9
+ return JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
10
+ }
11
+ function changedSince(current, before, keys) {
12
+ return keys.some((key) => !sameValue(current[key], before[key]));
13
+ }
14
+ function withoutMeta(row) {
15
+ return Object.fromEntries(Object.entries(row).filter(([key]) => !META_KEYS.has(key)));
16
+ }
17
+ function failed(reason) {
18
+ return { status: 'failed', reason, row: null };
19
+ }
20
+ function skipped(reason) {
21
+ return { status: 'skipped', reason, row: null };
22
+ }
23
+ function problemOf(failure) {
24
+ for (let error = failure; error instanceof Error; error = error.cause) {
25
+ if (error.message.includes('UNIQUE constraint failed')) {
26
+ return 'Another row already has that value';
27
+ }
28
+ }
29
+ return 'The site couldn’t save this row';
30
+ }
31
+ async function applyOne(c, table, change) {
32
+ const target = sqlTable(table);
33
+ const db = drizzle(c.env.DB);
34
+ if (change.op === 'add') {
35
+ if (table.owned)
36
+ return failed('Rows in this table belong to their makers');
37
+ const id = typeof change.row['id'] === 'string' && change.row['id'] !== ''
38
+ ? change.row['id']
39
+ : crypto.randomUUID();
40
+ const parsed = table.incoming.safeParse({ ...withoutMeta(change.row), id });
41
+ if (!parsed.success)
42
+ return failed(z.prettifyError(parsed.error));
43
+ const saved = await db
44
+ .insert(target)
45
+ .values(parsed.data)
46
+ .returning()
47
+ .get()
48
+ .catch((failure) => problemOf(failure));
49
+ if (typeof saved === 'string')
50
+ return failed(saved);
51
+ return {
52
+ status: 'applied',
53
+ reason: null,
54
+ row: (await readRow(c.env.DB, table, id)) ?? null
55
+ };
56
+ }
57
+ const current = await readRow(c.env.DB, table, change.id);
58
+ if (current === undefined)
59
+ return skipped('It was removed since');
60
+ if (change.op === 'remove') {
61
+ if (changedSince(current, change.before, Object.keys(change.before))) {
62
+ return skipped('It changed since');
63
+ }
64
+ await db.delete(target).where(eq(column(target, 'id'), change.id));
65
+ return { status: 'applied', reason: null, row: current };
66
+ }
67
+ const keys = Object.keys(withoutMeta(change.after));
68
+ if (changedSince(current, change.before, keys)) {
69
+ return skipped('It changed since');
70
+ }
71
+ const parsed = table.incoming.safeParse({
72
+ ...withoutMeta(current),
73
+ ...withoutMeta(change.after),
74
+ id: change.id
75
+ });
76
+ if (!parsed.success)
77
+ return failed(z.prettifyError(parsed.error));
78
+ const values = Object.fromEntries(keys.map((key) => [key, Reflect.get(parsed.data, key)]));
79
+ const saved = await db
80
+ .update(target)
81
+ .set(values)
82
+ .where(eq(column(target, 'id'), change.id))
83
+ .run()
84
+ .then(() => undefined)
85
+ .catch((failure) => problemOf(failure));
86
+ if (saved !== undefined)
87
+ return failed(saved);
88
+ return {
89
+ status: 'applied',
90
+ reason: null,
91
+ row: (await readRow(c.env.DB, table, change.id)) ?? null
92
+ };
93
+ }
94
+ export async function applyChanges(c, tables) {
95
+ const viewer = c.get('viewer');
96
+ if (viewer === null)
97
+ return c.json({ error: 'Signed out' }, 401);
98
+ if (!viewer.isAdmin)
99
+ return c.json({ error: 'forbidden' }, 403);
100
+ const body = changeRequest.safeParse(await c.req.json().catch(() => null));
101
+ if (!body.success) {
102
+ return c.json({ error: z.prettifyError(body.error) }, 400);
103
+ }
104
+ const known = new Map(Object.values(tables).map((table) => [table.name, table]));
105
+ const outcomes = [];
106
+ for (const change of body.data.changes) {
107
+ const table = known.get(change.table);
108
+ outcomes.push(table === undefined || table.access.read === 'server'
109
+ ? failed('No such table')
110
+ : await applyOne(c, table, change));
111
+ }
112
+ return c.json({ outcomes });
113
+ }
@@ -213,6 +213,44 @@ export declare function createServer<Group extends Tables, Bindings extends Serv
213
213
  status: import("hono/utils/http-status").ContentfulStatusCode;
214
214
  };
215
215
  };
216
+ } & {
217
+ "/changes": {
218
+ $post: {
219
+ input: {};
220
+ output: {
221
+ error: string;
222
+ };
223
+ outputFormat: "json";
224
+ status: 401;
225
+ } | {
226
+ input: {};
227
+ output: {
228
+ error: string;
229
+ };
230
+ outputFormat: "json";
231
+ status: 403;
232
+ } | {
233
+ input: {};
234
+ output: {
235
+ error: string;
236
+ };
237
+ outputFormat: "json";
238
+ status: 400;
239
+ } | {
240
+ input: {};
241
+ output: {
242
+ outcomes: {
243
+ status: "failed" | "applied" | "skipped";
244
+ reason: string | null;
245
+ row: {
246
+ [x: string]: import("hono/utils/types").JSONValue;
247
+ } | null;
248
+ }[];
249
+ };
250
+ outputFormat: "json";
251
+ status: import("hono/utils/http-status").ContentfulStatusCode;
252
+ };
253
+ };
216
254
  } & {
217
255
  "/tables/users": {
218
256
  $get: {
@@ -39,6 +39,44 @@ export declare function serveDatabase<Group extends Tables>(tables: Group): impo
39
39
  status: import("hono/utils/http-status").ContentfulStatusCode;
40
40
  };
41
41
  };
42
+ } & {
43
+ "/changes": {
44
+ $post: {
45
+ input: {};
46
+ output: {
47
+ error: string;
48
+ };
49
+ outputFormat: "json";
50
+ status: 401;
51
+ } | {
52
+ input: {};
53
+ output: {
54
+ error: string;
55
+ };
56
+ outputFormat: "json";
57
+ status: 403;
58
+ } | {
59
+ input: {};
60
+ output: {
61
+ error: string;
62
+ };
63
+ outputFormat: "json";
64
+ status: 400;
65
+ } | {
66
+ input: {};
67
+ output: {
68
+ outcomes: {
69
+ status: "failed" | "applied" | "skipped";
70
+ reason: string | null;
71
+ row: {
72
+ [x: string]: import("hono/utils/types").JSONValue;
73
+ } | null;
74
+ }[];
75
+ };
76
+ outputFormat: "json";
77
+ status: import("hono/utils/http-status").ContentfulStatusCode;
78
+ };
79
+ };
42
80
  } & {
43
81
  "/tables/users": {
44
82
  $get: {
@@ -2,6 +2,7 @@ import { asc, count } from 'drizzle-orm';
2
2
  import { drizzle } from 'drizzle-orm/d1';
3
3
  import { Hono } from 'hono';
4
4
  import { z } from 'zod';
5
+ import { applyChanges } from "./changes.js";
5
6
  import { PAGE_PROBLEM, afterCursor, pageOf, pageReply } from "./keyset.js";
6
7
  import { sqlTable } from "./tables.js";
7
8
  import { users } from "./users.js";
@@ -66,6 +67,7 @@ export function serveDatabase(tables) {
66
67
  };
67
68
  return c.json({ tables: [...own, builtIn] });
68
69
  })
70
+ .post('/changes', (c) => applyChanges(c, tables))
69
71
  .get('/tables/users', async (c) => {
70
72
  const refusal = adminRefusal(c);
71
73
  if (refusal !== undefined)
@@ -8,7 +8,7 @@ export { seedSql, serveTables, sqlTable, sqlTables } from './tables.ts';
8
8
  export { refused } from '../services.ts';
9
9
  export { serveServices } from './services.ts';
10
10
  export type { ServiceHandlers } from './services.ts';
11
- export { sessionCookieFor } from './session.ts';
11
+ export { sessionCookieFor, sessionIdOf } from './session.ts';
12
12
  export { deleteSession, sessions } from './sessions.ts';
13
13
  export { readTitle, seedTitleSql, settings, writeTitle } from './settings.ts';
14
14
  export { exchangeReply, exchangeRequest, sendCodeReply, sendCodeRequest, verifiedIdentity, verifyCodeReply, verifyCodeRequest } from './signInWire.ts';
@@ -4,7 +4,7 @@ export { bookings } from "./bookings.js";
4
4
  export { seedSql, serveTables, sqlTable, sqlTables } from "./tables.js";
5
5
  export { refused } from "../services.js";
6
6
  export { serveServices } from "./services.js";
7
- export { sessionCookieFor } from "./session.js";
7
+ export { sessionCookieFor, sessionIdOf } from "./session.js";
8
8
  export { deleteSession, sessions } from "./sessions.js";
9
9
  export { readTitle, seedTitleSql, settings, writeTitle } from "./settings.js";
10
10
  export { exchangeReply, exchangeRequest, sendCodeReply, sendCodeRequest, verifiedIdentity, verifyCodeReply, verifyCodeRequest } from "./signInWire.js";
@@ -1,9 +1,10 @@
1
- import { MEDIA_WRITE, TABLES_READ, verifiedAssertion } from "../patternAccess.js";
1
+ import { MEDIA_WRITE, TABLES_READ, TABLES_WRITE, verifiedAssertion } from "../patternAccess.js";
2
2
  import { PATTERN_KEYS } from "./patternKeys.js";
3
3
  const BEARER = /^bearer\s+/i;
4
4
  const SCHEMA_PATH = '/api/schema';
5
5
  const TABLES_PATH = '/api/tables/';
6
6
  const MEDIA_PATH = '/api/media';
7
+ const CHANGES_PATH = '/api/changes';
7
8
  export function bearerOf(c) {
8
9
  const header = c.req.header('authorization') ?? '';
9
10
  return BEARER.test(header) ? header.replace(BEARER, '').trim() : undefined;
@@ -14,6 +15,8 @@ function scopeOf(c) {
14
15
  (path === SCHEMA_PATH || path.startsWith(TABLES_PATH))) {
15
16
  return TABLES_READ;
16
17
  }
18
+ if (method === 'POST' && path === CHANGES_PATH)
19
+ return TABLES_WRITE;
17
20
  return method === 'POST' && path === MEDIA_PATH ? MEDIA_WRITE : undefined;
18
21
  }
19
22
  export async function attachPatternViewer(c, next, token) {
@@ -27,6 +27,7 @@ export declare function readPass(c: Context<ServerEnv>): Promise<{
27
27
  email: string;
28
28
  recheckAt: number;
29
29
  } | undefined>;
30
+ export declare function sessionIdOf<Env extends ServerEnv>(c: Context<Env>): Promise<string | undefined>;
30
31
  export declare const needsRecheck: (pass: Pass) => boolean;
31
32
  export declare function forgetSession(c: Context<ServerEnv>): void;
32
33
  export {};
@@ -58,6 +58,9 @@ export async function readPass(c) {
58
58
  return undefined;
59
59
  }
60
60
  }
61
+ export async function sessionIdOf(c) {
62
+ return (await readPass(c))?.sessionId;
63
+ }
61
64
  export const needsRecheck = (pass) => pass.recheckAt <= nowInSeconds();
62
65
  export function forgetSession(c) {
63
66
  deleteCookie(c, SESSION_COOKIE, { path: '/', secure: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trypattern/sdk",
3
- "version": "0.1.21",
3
+ "version": "0.1.23",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist",