AppView in a box as a Vite plugin thing hatk.dev
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

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```