# Hook & XRPC Record Helpers Design **Goal:** Add `createRecord`, `deleteRecord`, `putRecord` helpers to `OnLoginCtx` and `XrpcContext` so server-side code can write records through the PDS with local indexing, without raw SQL. **Motivation:** Writing records currently requires raw SQL with manual column mapping, JSON serialization, and AT URI construction. The framework already has PDS proxy functions (`pdsCreateRecord`, etc.) that validate against lexicons, write to the user's PDS, and index locally — but they're only wired up to the core HTTP handlers, not available to hook or XRPC authors. ## Design ### Where the helpers live - `XrpcContext` — already has an authenticated viewer and PDS session access - `OnLoginCtx` — has a viewer (the user who just logged in) with a fresh PDS session - `BaseContext` — stays read-only (feeds can run without a viewer) ### New fields ```typescript createRecord: (collection: string, record: Record, opts?: { rkey?: string }) => Promise<{ uri?: string; cid?: string }> putRecord: (collection: string, rkey: string, record: Record) => Promise<{ uri?: string; cid?: string }> deleteRecord: (collection: string, rkey: string) => Promise ``` ### What they do Each helper wraps the existing `pdsCreateRecord` / `pdsPutRecord` / `pdsDeleteRecord` from `pds-proxy.ts`: 1. Validate record against lexicons 2. Authenticate to user's PDS via DPoP (with nonce retry + token refresh) 3. Write to PDS 4. Index locally (insert/delete in SQLite) 5. Log errors on index failure (record still exists on PDS, will sync via firehose) ### Wiring Both `XrpcContext` and `OnLoginCtx` need `oauthConfig` to call the PDS proxy functions. **XrpcContext:** `buildXrpcContext` in `xrpc.ts` already runs inside `initXrpc` which has `oauthConfig`. Add the helpers there. **OnLoginCtx:** `fireOnLoginHook` is called from `oauth/server.ts` `handleCallback` which has `config: OAuthConfig`. Pass it through: ```typescript // oauth/server.ts await fireOnLoginHook(did, config) ``` ### Usage in hooks ```typescript export default defineHook('on-login', async (ctx) => { await ctx.ensureRepo(ctx.did) const existing = await ctx.lookup('social.grain.actor.profile', 'did', [ctx.did]) if (existing.has(ctx.did)) return const bsky = await ctx.lookup('app.bsky.actor.profile', 'did', [ctx.did]) const profile = bsky.get(ctx.did) if (!profile) return await ctx.createRecord( 'social.grain.actor.profile', { displayName: profile.value.displayName, description: profile.value.description, avatar: profile.value.avatar, createdAt: new Date().toISOString(), }, { rkey: 'self' }, ) }) ``` ### Usage in XRPC handlers ```typescript export default defineQuery('my.app.doThing', async (ctx) => { await ctx.createRecord('my.app.activity', { action: 'did-thing', createdAt: new Date().toISOString(), }) return ctx.ok({ success: true }) }) ``` ## Files to change **hatk framework:** - `hooks.ts` — Add helpers to `OnLoginCtx`, update `fireOnLoginHook(did, oauthConfig)` - `xrpc.ts` — Add helpers to `XrpcContext`, wire in `buildXrpcContext` - `oauth/server.ts` — Pass `config` to `fireOnLoginHook` **Templates:** - `server/on-login.ts` — Replace raw SQL with `ctx.createRecord` ## Future: typed records The helpers can later be typed with `RecordRegistry` so `createRecord('social.grain.actor.profile', ...)` validates the record shape at compile time, matching how `defineFeed` and `defineQuery` work. Not required for v1. ## Edge cases - **No PDS session:** Throws `ProxyError(401)`. Caught by hook/XRPC error handling. Login/request still succeeds. - **PDS write fails:** Error propagates to caller. In hooks, caught and logged. In XRPCs, returns error to client. - **Local index fails after PDS write:** Already handled in pds-proxy.ts — logged, record syncs back via firehose. - **No viewer (unauthenticated XRPC):** Helpers should not be called. Throws if no session exists.