AppView in a box as a Vite plugin thing
hatk.dev
1# Hook & XRPC Record Helpers Implementation Plan
2
3> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
5**Goal:** Add `createRecord`, `putRecord`, `deleteRecord` helpers to `OnLoginCtx` and `XrpcContext` so server-side code can write records through the PDS with local indexing, without raw SQL.
6
7**Architecture:** The helpers wrap existing `pdsCreateRecord`/`pdsPutRecord`/`pdsDeleteRecord` from `pds-proxy.ts`. Both contexts need `oauthConfig` plumbed through — `OnLoginCtx` gets it via `fireOnLoginHook(did, config)` from `oauth/server.ts`, and `XrpcContext` gets it via a module-level setter called during boot.
8
9**Tech Stack:** TypeScript, AT Protocol PDS proxy, SQLite indexing
10
11---
12
13### Task 1: Add `oauthConfig` to `fireOnLoginHook` and wire record helpers into `OnLoginCtx`
14
15**Files:**
16- Modify: `packages/hatk/src/hooks.ts`
17- Modify: `packages/hatk/src/oauth/server.ts:540`
18
19**Step 1: Update `OnLoginCtx` type to include record helpers**
20
21In `packages/hatk/src/hooks.ts`, add the record helper types to `OnLoginCtx`:
22
23```typescript
24import type { OAuthConfig } from './config.ts'
25import { pdsCreateRecord, pdsPutRecord, pdsDeleteRecord } from './pds-proxy.ts'
26
27export type OnLoginCtx = Omit<BaseContext, 'db'> & {
28 did: string
29 db: {
30 query: (sql: string, params?: unknown[]) => Promise<unknown[]>
31 run: (sql: string, params?: unknown[]) => Promise<void>
32 }
33 ensureRepo: (did: string) => Promise<void>
34 createRecord: (
35 collection: string,
36 record: Record<string, unknown>,
37 opts?: { rkey?: string },
38 ) => Promise<{ uri?: string; cid?: string }>
39 putRecord: (
40 collection: string,
41 rkey: string,
42 record: Record<string, unknown>,
43 ) => Promise<{ uri?: string; cid?: string }>
44 deleteRecord: (
45 collection: string,
46 rkey: string,
47 ) => Promise<void>
48}
49```
50
51**Step 2: Update `fireOnLoginHook` to accept `oauthConfig` and build helpers**
52
53Change the signature and body of `fireOnLoginHook`:
54
55```typescript
56export async function fireOnLoginHook(did: string, oauthConfig?: OAuthConfig | null): Promise<void> {
57 if (!onLoginHook) return
58 try {
59 const base = buildBaseContext({ did })
60 const viewer = { did }
61
62 const hookPromise = onLoginHook({
63 ...base,
64 did,
65 db: { query: base.db.query, run: runSQL },
66 ensureRepo,
67 createRecord: async (collection, record, opts) => {
68 if (!oauthConfig) throw new Error('No OAuth config — cannot write to PDS')
69 return pdsCreateRecord(oauthConfig, viewer, { collection, record, rkey: opts?.rkey })
70 },
71 putRecord: async (collection, rkey, record) => {
72 if (!oauthConfig) throw new Error('No OAuth config — cannot write to PDS')
73 return pdsPutRecord(oauthConfig, viewer, { collection, rkey, record })
74 },
75 deleteRecord: async (collection, rkey) => {
76 if (!oauthConfig) throw new Error('No OAuth config — cannot write to PDS')
77 await pdsDeleteRecord(oauthConfig, viewer, { collection, rkey })
78 },
79 })
80 const timeout = new Promise<void>((_, reject) =>
81 setTimeout(() => reject(new Error('on-login hook timed out after 30s')), 30_000)
82 )
83 await Promise.race([hookPromise, timeout])
84 } catch (err: any) {
85 emit('hooks', 'on_login_error', { did, error: err.message })
86 }
87}
88```
89
90**Step 3: Pass `config` in `oauth/server.ts`**
91
92In `packages/hatk/src/oauth/server.ts`, line 540, change:
93
94```typescript
95// Before:
96await fireOnLoginHook(did)
97
98// After:
99await fireOnLoginHook(did, config)
100```
101
102**Step 4: Verify the build**
103
104Run: `cd /Users/chadmiller/code/hatk && npx tsc --noEmit -p packages/hatk/tsconfig.json`
105Expected: No type errors
106
107**Step 5: Commit**
108
109```bash
110git add packages/hatk/src/hooks.ts packages/hatk/src/oauth/server.ts
111git commit -m "feat: add createRecord/putRecord/deleteRecord helpers to OnLoginCtx"
112```
113
114---
115
116### Task 2: Add record helpers to `XrpcContext`
117
118**Files:**
119- Modify: `packages/hatk/src/xrpc.ts`
120
121**Step 1: Add module-level `oauthConfig` setter and record helper types to `XrpcContext`**
122
123Add imports and a module-level config variable at the top of `xrpc.ts`:
124
125```typescript
126import type { OAuthConfig } from './config.ts'
127import { pdsCreateRecord, pdsPutRecord, pdsDeleteRecord } from './pds-proxy.ts'
128
129let _oauthConfig: OAuthConfig | null = null
130
131export function configureOAuth(config: OAuthConfig | null) {
132 _oauthConfig = config
133}
134```
135
136Add the helper fields to the `XrpcContext` interface:
137
138```typescript
139export interface XrpcContext<
140 P = Record<string, string>,
141 Records extends Record<string, any> = Record<string, any>,
142 I = unknown,
143> extends BaseContext {
144 // ... existing fields ...
145 createRecord: (
146 collection: string,
147 record: Record<string, unknown>,
148 opts?: { rkey?: string },
149 ) => Promise<{ uri?: string; cid?: string }>
150 putRecord: (
151 collection: string,
152 rkey: string,
153 record: Record<string, unknown>,
154 ) => Promise<{ uri?: string; cid?: string }>
155 deleteRecord: (
156 collection: string,
157 rkey: string,
158 ) => Promise<void>
159}
160```
161
162**Step 2: Wire helpers into `buildXrpcContext`**
163
164Update the `buildXrpcContext` function to include the record helpers. The helpers use the `viewer` param already available in the function:
165
166```typescript
167export function buildXrpcContext(
168 params: Record<string, string>,
169 cursor: string | undefined,
170 limit: number,
171 viewer: { did: string; handle?: string } | null,
172 input?: unknown,
173): XrpcContext {
174 const base = buildBaseContext(viewer)
175 return {
176 ...base,
177 db: { query: querySQL, run: runSQL },
178 params,
179 input: input || {},
180 cursor,
181 limit,
182 packCursor,
183 unpackCursor,
184 isTakendown: isTakendownDid,
185 filterTakendownDids,
186 search: searchRecords,
187 resolve: resolveRecords as any,
188 exists: async (collection, filters) => {
189 const conditions = Object.entries(filters).map(([field, value]) => ({ field, value }))
190 const uri = await findUriByFields(collection, conditions)
191 return uri !== null
192 },
193 createRecord: async (collection, record, opts) => {
194 if (!_oauthConfig) throw new Error('No OAuth config — cannot write to PDS')
195 if (!viewer) throw new Error('Authentication required to write records')
196 return pdsCreateRecord(_oauthConfig, viewer, { collection, record, rkey: opts?.rkey })
197 },
198 putRecord: async (collection, rkey, record) => {
199 if (!_oauthConfig) throw new Error('No OAuth config — cannot write to PDS')
200 if (!viewer) throw new Error('Authentication required to write records')
201 return pdsPutRecord(_oauthConfig, viewer, { collection, rkey, record })
202 },
203 deleteRecord: async (collection, rkey) => {
204 if (!_oauthConfig) throw new Error('No OAuth config — cannot write to PDS')
205 if (!viewer) throw new Error('Authentication required to write records')
206 await pdsDeleteRecord(_oauthConfig, viewer, { collection, rkey })
207 },
208 }
209}
210```
211
212**Step 3: Verify the build**
213
214Run: `cd /Users/chadmiller/code/hatk && npx tsc --noEmit -p packages/hatk/tsconfig.json`
215Expected: No type errors
216
217**Step 4: Commit**
218
219```bash
220git add packages/hatk/src/xrpc.ts
221git commit -m "feat: add createRecord/putRecord/deleteRecord helpers to XrpcContext"
222```
223
224---
225
226### Task 3: Call `configureOAuth` during boot
227
228**Files:**
229- Modify: `packages/hatk/src/main.ts`
230- Modify: `packages/hatk/src/dev-entry.ts`
231
232**Step 1: Wire `configureOAuth` in `main.ts`**
233
234Import and call `configureOAuth` alongside the existing `registerCoreHandlers` call:
235
236```typescript
237import { initXrpc, listXrpc, configureRelay, callXrpc, configureOAuth } from './xrpc.ts'
238
239// After line 127 (registerCoreHandlers):
240configureOAuth(config.oauth)
241```
242
243**Step 2: Wire `configureOAuth` in `dev-entry.ts`**
244
245```typescript
246import { configureOAuth } from './xrpc.ts'
247
248// After line 76 (registerCoreHandlers):
249configureOAuth(config.oauth)
250```
251
252**Step 3: Verify the build**
253
254Run: `cd /Users/chadmiller/code/hatk && npx tsc --noEmit -p packages/hatk/tsconfig.json`
255Expected: No type errors
256
257**Step 4: Commit**
258
259```bash
260git add packages/hatk/src/main.ts packages/hatk/src/dev-entry.ts
261git commit -m "feat: wire configureOAuth at boot for XRPC record helpers"
262```
263
264---
265
266### Task 4: Update grain template `on-login.ts` to use `ctx.createRecord`
267
268**Files:**
269- Modify: `/Users/chadmiller/code/hatk-template-grain/server/on-login.ts`
270
271**Step 1: Replace raw SQL with `ctx.createRecord`**
272
273```typescript
274import { defineHook, type GrainActorProfile, type BskyActorProfile } from "$hatk";
275
276export default defineHook("on-login", async (ctx) => {
277 const { did, ensureRepo, lookup } = ctx;
278
279 // Backfill the user's repo and wait for completion
280 await ensureRepo(did);
281
282 // Check if user already has a grain profile
283 const grainProfiles = await lookup<GrainActorProfile>("social.grain.actor.profile", "did", [did]);
284 if (grainProfiles.has(did)) return;
285
286 // No grain profile — copy from bsky profile if available
287 const bskyProfiles = await lookup<BskyActorProfile>("app.bsky.actor.profile", "did", [did]);
288 const bsky = bskyProfiles.get(did);
289 if (!bsky) return;
290
291 const record: Record<string, unknown> = {
292 createdAt: new Date().toISOString(),
293 };
294 if (bsky.value.displayName) record.displayName = bsky.value.displayName;
295 if (bsky.value.description) record.description = bsky.value.description;
296 if (bsky.value.avatar) record.avatar = bsky.value.avatar;
297
298 await ctx.createRecord("social.grain.actor.profile", record, { rkey: "self" });
299});
300```
301
302**Step 2: Verify the template builds**
303
304Run: `cd /Users/chadmiller/code/hatk-template-grain && npx tsc --noEmit`
305Expected: No type errors (after hatk types are regenerated)
306
307**Step 3: Commit**
308
309```bash
310git add server/on-login.ts
311git commit -m "refactor: replace raw SQL with ctx.createRecord in on-login hook"
312```
313
314---
315
316### Task 5: Update documentation
317
318**Files:**
319- Modify: `packages/hatk/docs/site/guides/hooks.md`
320
321**Step 1: Add record helpers to the hook context table**
322
323Add three rows to the context table in `hooks.md`:
324
325```markdown
326| `createRecord` | `(collection, record, opts?) => Promise<{uri?, cid?}>` | Write a record to the user's PDS and index locally |
327| `putRecord` | `(collection, rkey, record) => Promise<{uri?, cid?}>` | Create or update a record on the user's PDS |
328| `deleteRecord` | `(collection, rkey) => Promise<void>` | Delete a record from the user's PDS and local index |
329```
330
331**Step 2: Update the "Populating records on first login" example**
332
333Replace the raw SQL example with the `ctx.createRecord` version:
334
335```typescript
336// server/on-login.ts
337import { defineHook, type BskyActorProfile, type MyAppProfile } from '$hatk'
338
339export default defineHook('on-login', async (ctx) => {
340 const { did, ensureRepo, lookup } = ctx
341
342 await ensureRepo(did)
343
344 // Check if user already has an app profile
345 const existing = await lookup<MyAppProfile>('my.app.profile', 'did', [did])
346 if (existing.has(did)) return
347
348 // Copy from Bluesky profile
349 const bsky = await lookup<BskyActorProfile>('app.bsky.actor.profile', 'did', [did])
350 const profile = bsky.get(did)
351 if (!profile) return
352
353 await ctx.createRecord('my.app.profile', {
354 displayName: profile.value.displayName,
355 description: profile.value.description,
356 avatar: profile.value.avatar,
357 createdAt: new Date().toISOString(),
358 }, { rkey: 'self' })
359})
360```
361
362**Step 3: Commit**
363
364```bash
365git add packages/hatk/docs/site/guides/hooks.md
366git commit -m "docs: update hooks guide with record helper examples"
367```