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 accessOnLoginCtx— has a viewer (the user who just logged in) with a fresh PDS sessionBaseContext— stays read-only (feeds can run without a viewer)
New fields
createRecord: (collection: string, record: Record<string, unknown>, opts?: { rkey?: string }) =>
Promise<{ uri?: string; cid?: string }>
putRecord: (collection: string, rkey: string, record: Record<string, unknown>) =>
Promise<{ uri?: string; cid?: string }>
deleteRecord: (collection: string, rkey: string) => Promise<void>
What they do
Each helper wraps the existing pdsCreateRecord / pdsPutRecord / pdsDeleteRecord from pds-proxy.ts:
- Validate record against lexicons
- Authenticate to user's PDS via DPoP (with nonce retry + token refresh)
- Write to PDS
- Index locally (insert/delete in SQLite)
- 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:
// oauth/server.ts
await fireOnLoginHook(did, config)
Usage in hooks
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
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 toOnLoginCtx, updatefireOnLoginHook(did, oauthConfig)xrpc.ts— Add helpers toXrpcContext, wire inbuildXrpcContextoauth/server.ts— PassconfigtofireOnLoginHook
Templates:
server/on-login.ts— Replace raw SQL withctx.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.