BaseContext Implementation Plan#
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Extract a shared BaseContext type so hydrators work with both feed and XRPC contexts without manual field spreading or as any casts.
Architecture: Replace HydrateContext with BaseContext (shared data access interface). XrpcContext extends BaseContext. Feed hydrate signature changes from (ctx: HydrateContext<T>) to (ctx: BaseContext, items: Row<T>[]). No deprecation — clean break.
Tech Stack: TypeScript, hatk framework (packages/hatk/src/), template projects (grain, teal)
Task 1: Add BaseContext and buildBaseContext to hydrate.ts#
Files:
- Modify:
packages/hatk/src/hydrate.ts
Step 1: Replace HydrateContext with BaseContext
Replace the HydrateContext interface and buildHydrateContext function with:
export interface BaseContext {
viewer: { did: string; handle?: string } | null
db: { query: (sql: string, params?: unknown[]) => Promise<unknown[]> }
getRecords: <R = unknown>(collection: string, uris: string[]) => Promise<Map<string, Row<R>>>
lookup: <R = unknown>(collection: string, field: string, values: string[]) => Promise<Map<string, Row<R>>>
count: (collection: string, field: string, values: string[]) => Promise<Map<string, number>>
labels: (uris: string[]) => Promise<Map<string, unknown[]>>
blobUrl: (
did: string,
ref: unknown,
preset?: 'avatar' | 'banner' | 'feed_thumbnail' | 'feed_fullsize',
) => string | undefined
}
Key differences from HydrateContext:
- No
itemsfield (items are now a separate argument) - No generic
<T>parameter viewergains optionalhandlefielddb.queryparams typed asunknown[]notany[]
Replace buildHydrateContext:
/** Build a BaseContext for hydration. */
export function buildBaseContext(viewer: { did: string; handle?: string } | null): BaseContext {
return {
viewer,
db: { query: querySQL },
getRecords: getRecordsMap,
lookup: async (collection, field, values) => {
if (values.length === 0) return new Map()
const unique = [...new Set(values.filter(Boolean))]
return lookupByFieldBatch(collection, field, unique) as any
},
count: async (collection, field, values) => {
if (values.length === 0) return new Map()
const unique = [...new Set(values.filter(Boolean))]
return countByFieldBatch(collection, field, unique)
},
labels: queryLabelsForUris,
blobUrl,
}
}
Note: buildBaseContext takes only viewer, not items. The as any on lookupByFieldBatch return is acceptable — it's an internal implementation detail where the batch function returns the right shape but TypeScript can't prove it.
Step 2: Verify the file compiles
Run: cd /Users/chadmiller/code/hatk && npx tsc --noEmit -p packages/hatk/tsconfig.json 2>&1 | head -30
Expected: Errors in feeds.ts referencing the old HydrateContext name — that's expected, we fix it in Task 2.
Task 2: Update feeds.ts to use BaseContext#
Files:
- Modify:
packages/hatk/src/feeds.ts
Step 1: Update imports
Change:
import { resolveRecords, buildHydrateContext } from './hydrate.ts'
import type { HydrateContext, Row } from './hydrate.ts'
export type { HydrateContext, Row }
To:
import { resolveRecords, buildBaseContext } from './hydrate.ts'
import type { BaseContext, Row } from './hydrate.ts'
export type { BaseContext, Row }
Step 2: Update FeedHandler interface
Change the hydrate field type:
// Before
hydrate?: (ctx: HydrateContext) => Promise<unknown[]>
// After
hydrate?: (ctx: BaseContext, items: Row<unknown>[]) => Promise<unknown[]>
Step 3: Update FeedOpts type
Change both union members:
type FeedOpts =
| {
collection: string
view?: string
label: string
generate: FeedGenerate
hydrate?: (ctx: BaseContext, items: Row<any>[]) => Promise<unknown[]>
}
| {
collection?: never
view?: never
label: string
generate: FeedGenerate
hydrate: (ctx: BaseContext, items: Row<any>[]) => Promise<unknown[]>
}
Step 4: Update executeFeed
Change the hydrate call site from:
const ctx = buildHydrateContext(items, viewer || null)
const hydrated = await handler.hydrate(ctx)
To:
const ctx = buildBaseContext(viewer || null)
const hydrated = await handler.hydrate(ctx, items)
Step 5: Verify
Run: cd /Users/chadmiller/code/hatk && npx tsc --noEmit -p packages/hatk/tsconfig.json 2>&1 | head -30
Expected: Errors in cli.ts referencing old HydrateContext — fixed in Task 4.
Task 3: XrpcContext extends BaseContext in xrpc.ts#
Files:
- Modify:
packages/hatk/src/xrpc.ts
Step 1: Import BaseContext
Add to the imports from ./hydrate.ts:
import { resolveRecords } from './hydrate.ts'
import type { BaseContext } from './hydrate.ts'
Step 2: Update XrpcContext interface
Change from a standalone interface to one that extends BaseContext:
export interface XrpcContext<
P = Record<string, string>,
Records extends Record<string, any> = Record<string, any>,
I = unknown,
> extends BaseContext {
db: {
query: (sql: string, params?: unknown[]) => Promise<unknown[]>
run: (sql: string, ...params: unknown[]) => Promise<void>
}
params: P
input: I
cursor?: string
limit: number
packCursor: (primary: string | number, cid: string) => string
unpackCursor: (cursor: string) => { primary: string; cid: string } | null
isTakendown: (did: string) => Promise<boolean>
filterTakendownDids: (dids: string[]) => Promise<Set<string>>
search: <K extends string & keyof Records>(
collection: K,
q: string,
opts?: { limit?: number; cursor?: string; fuzzy?: boolean },
) => Promise<{ records: Row<Records[K]>[]; cursor?: string }>
resolve: <R = unknown>(uris: string[]) => Promise<Row<R>[]>
exists: (collection: string, filters: Record<string, string>) => Promise<boolean>
}
Key changes:
extends BaseContext— inherits viewer, db.query, getRecords, lookup, count, labels, blobUrldbfield overrides BaseContext'sdbto add.runmethod (TypeScript allows this since it's a superset)- Remove fields already in BaseContext:
getRecords,lookup,count,labels,blobUrl— they're inherited vieweris inherited from BaseContext:{ did: string; handle?: string } | null- Tighten
anytypes tounknownwhere possible
Step 3: Update context construction in initXrpc and registerXrpcHandler
Both initXrpc (line ~200) and registerXrpcHandler (line ~263) construct XrpcContext objects. Remove the fields that are now inherited from BaseContext (they still need to be set since we're constructing an object literal, not using a builder). The construction stays the same shape but now the types are stricter:
const ctx: XrpcContext = {
db: { query: querySQL, run: runSQL },
params,
input: input || {},
cursor,
limit,
viewer,
packCursor,
unpackCursor,
isTakendown: isTakendownDid,
filterTakendownDids,
search: searchRecords,
resolve: resolveRecords as any,
getRecords: getRecordsMap,
lookup: async (collection, field, values) => {
if (values.length === 0) return new Map()
const unique = [...new Set(values.filter(Boolean))]
return lookupByFieldBatch(collection, field, unique) as any
},
count: async (collection, field, values) => {
if (values.length === 0) return new Map()
const unique = [...new Set(values.filter(Boolean))]
return countByFieldBatch(collection, field, unique)
},
exists: async (collection, filters) => {
const conditions = Object.entries(filters).map(([field, value]) => ({ field, value }))
const uri = await findUriByFields(collection, conditions)
return uri !== null
},
labels: queryLabelsForUris,
blobUrl,
}
The object literal is the same — extends just means the interface is compatible, not that construction changes.
Step 4: Verify
Run: cd /Users/chadmiller/code/hatk && npx tsc --noEmit -p packages/hatk/tsconfig.json 2>&1 | head -30
Task 4: Update opengraph.ts context construction#
Files:
- Modify:
packages/hatk/src/opengraph.ts
Step 1: No structural changes needed
opengraph.ts constructs XrpcContext objects (line ~135 in initOpengraph and line ~222 in registerOgHandler). Since XrpcContext extends BaseContext, these constructions are already correct — they include all BaseContext fields.
The only change: if TypeScript complains about the any types that were tightened to unknown in XrpcContext, update the db.query and labels types in the construction to match.
Step 2: Verify
Run: cd /Users/chadmiller/code/hatk && npx tsc --noEmit -p packages/hatk/tsconfig.json 2>&1 | head -30
Task 5: Update cli.ts codegen#
Files:
- Modify:
packages/hatk/src/cli.ts
Step 1: Update the import line (around line 1527)
Change:
out += `import { defineFeed as _defineFeed, type FeedResult, type FeedContext, type HydrateContext, type Row } from '@hatk/hatk/feeds'\n`
To:
out += `import { defineFeed as _defineFeed, type FeedResult, type FeedContext, type BaseContext, type Row } from '@hatk/hatk/feeds'\n`
Step 2: Update the re-export line (around line 1695)
Change:
out += `export type { HydrateContext, Row } from '@hatk/hatk/feeds'\n`
To:
out += `export type { BaseContext, Row } from '@hatk/hatk/feeds'\n`
Step 3: Update defineFeed overloads (around lines 1733-1738)
Change:
out += `export function defineFeed<K extends keyof RecordRegistry>(\n`
out += ` opts: { collection: K; view?: string; label: string; generate: FeedGenerate; hydrate?: (ctx: HydrateContext<RecordRegistry[K]>) => Promise<unknown[]> }\n`
out += `): ReturnType<typeof _defineFeed>\n`
out += `export function defineFeed(\n`
out += ` opts: { collection?: never; view?: never; label: string; generate: FeedGenerate; hydrate: (ctx: HydrateContext<any>) => Promise<unknown[]> }\n`
out += `): ReturnType<typeof _defineFeed>\n`
To:
out += `export function defineFeed<K extends keyof RecordRegistry>(\n`
out += ` opts: { collection: K; view?: string; label: string; generate: FeedGenerate; hydrate?: (ctx: BaseContext, items: Row<RecordRegistry[K]>[]) => Promise<unknown[]> }\n`
out += `): ReturnType<typeof _defineFeed>\n`
out += `export function defineFeed(\n`
out += ` opts: { collection?: never; view?: never; label: string; generate: FeedGenerate; hydrate: (ctx: BaseContext, items: Row<unknown>[]) => Promise<unknown[]> }\n`
out += `): ReturnType<typeof _defineFeed>\n`
Key change: hydrate goes from (ctx: HydrateContext<T>) => ... to (ctx: BaseContext, items: Row<T>[]) => .... The second overload uses Row<unknown> instead of HydrateContext<any>.
Step 4: Verify hatk compiles
Run: cd /Users/chadmiller/code/hatk && npx tsc --noEmit -p packages/hatk/tsconfig.json 2>&1 | head -30
Expected: Clean compile. All hatk framework changes are done.
Step 5: Bump version and publish
Run: cd /Users/chadmiller/code/hatk && npm version prerelease --preid alpha -w packages/hatk
Then commit all hatk changes (hydrate.ts, feeds.ts, xrpc.ts, cli.ts, and version bump).
Task 6: Update grain template — _hydrate.ts#
Files:
- Modify:
/Users/chadmiller/code/hatk-template-grain/server/feeds/_hydrate.ts
Step 1: Install updated hatk
Run: cd /Users/chadmiller/code/hatk-template-grain && npm install
Step 2: Regenerate types
Run: cd /Users/chadmiller/code/hatk-template-grain && npx hatk generate
Step 3: Update hydrateGalleries signature
Change:
import type { HydrateContext, Row } from "$hatk";
export async function hydrateGalleries(ctx: HydrateContext<Gallery>): Promise<GalleryView[]> {
const dids = [...new Set(ctx.items.map((item) => item.did).filter(Boolean))];
To:
import type { BaseContext, Row } from "$hatk";
export async function hydrateGalleries(ctx: BaseContext, items: Row<Gallery>[]): Promise<GalleryView[]> {
const dids = [...new Set(items.map((item) => item.did).filter(Boolean))];
Step 4: Replace all ctx.items with items in the function body
Every reference to ctx.items becomes just items:
ctx.items.map(...)→items.map(...)ctx.items.length→items.length
These occur at:
- Line 8:
ctx.items.map((item) => item.did)→items.map((item) => item.did) - Line 9:
ctx.items.map((item) => item.uri)→items.map((item) => item.uri) - Line 61:
return ctx.items.map((item) => {→return items.map((item) => {
Step 5: Verify
Run: cd /Users/chadmiller/code/hatk-template-grain && npx tsc --noEmit 2>&1 | head -30
Expected: Errors in feed files and searchGalleries.ts — fixed in Tasks 7-8.
Task 7: Update grain template — feed files#
Files:
- Modify:
/Users/chadmiller/code/hatk-template-grain/server/feeds/recent.ts - Modify:
/Users/chadmiller/code/hatk-template-grain/server/feeds/actor.ts
Step 1: Update recent.ts
Change:
hydrate: hydrateGalleries,
To:
hydrate: (ctx, items) => hydrateGalleries(ctx, items),
Or more concisely, since the signature now matches directly:
hydrate: hydrateGalleries,
This still works because hydrateGalleries now has the signature (ctx: BaseContext, items: Row<Gallery>[]) => Promise<GalleryView[]>, which matches the hydrate field type (ctx: BaseContext, items: Row<RecordRegistry[K]>[]) => Promise<unknown[]>.
So no change needed in recent.ts — the function reference still works.
Step 2: Same for actor.ts — no change needed
Step 3: Verify
Run: cd /Users/chadmiller/code/hatk-template-grain && npx tsc --noEmit 2>&1 | head -30
Task 8: Update grain template — searchGalleries.ts#
Files:
- Modify:
/Users/chadmiller/code/hatk-template-grain/server/xrpc/searchGalleries.ts
Step 1: Replace the ugly field spreading
Change:
const galleries = await hydrateGalleries({
items: result.records,
viewer: ctx.viewer,
db: ctx.db,
getRecords: ctx.getRecords,
lookup: ctx.lookup,
count: ctx.count,
labels: ctx.labels,
blobUrl: ctx.blobUrl,
} as any);
To:
const galleries = await hydrateGalleries(ctx, result.records);
This works because XrpcContext extends BaseContext, so ctx is directly assignable to BaseContext.
Step 2: Verify
Run: cd /Users/chadmiller/code/hatk-template-grain && npx tsc --noEmit 2>&1 | head -30
Expected: Clean compile.
Task 9: Update teal template#
Files:
- Modify:
/Users/chadmiller/code/hatk-template-teal/server/feeds/_hydrate.ts
Step 1: Install updated hatk and regenerate types
Run:
cd /Users/chadmiller/code/hatk-template-teal && npm install && npx hatk generate
Step 2: Update hydratePlays signature
Change:
import { views, type HydrateContext, type Play, type Profile } from "$hatk";
export async function hydratePlays(ctx: HydrateContext<Play>) {
const dids = [...new Set(ctx.items.map((item) => item.did).filter(Boolean))];
To:
import { views, type BaseContext, type Row, type Play, type Profile } from "$hatk";
export async function hydratePlays(ctx: BaseContext, items: Row<Play>[]) {
const dids = [...new Set(items.map((item) => item.did).filter(Boolean))];
Step 3: Replace all ctx.items with items
- Line 6:
ctx.items.map((item) => item.did)→items.map((item) => item.did) - Line 10:
ctx.items.length→items.length - Line 27:
return ctx.items.map((item) => {→return items.map((item) => {
Step 4: Update feed files if needed
Check recent.ts and other feed files. The hydrate: (ctx) => hydratePlays(ctx) pattern needs to change to hydrate: (ctx, items) => hydratePlays(ctx, items) or just hydrate: hydratePlays.
In teal's recent.ts:
// Before
hydrate: (ctx) => hydratePlays(ctx),
// After
hydrate: hydratePlays,
Apply the same pattern to all teal feed files that reference hydratePlays.
Step 5: Verify
Run: cd /Users/chadmiller/code/hatk-template-teal && npx tsc --noEmit 2>&1 | head -30
Expected: Clean compile.
Summary of all changes#
| File | Change |
|---|---|
hydrate.ts |
HydrateContext → BaseContext (no items, add handle to viewer), buildHydrateContext → buildBaseContext (no items param) |
feeds.ts |
Import/export BaseContext, hydrate signature gets items param, executeFeed passes ctx and items separately |
xrpc.ts |
XrpcContext extends BaseContext, import BaseContext, tighten types |
opengraph.ts |
No changes needed (constructs XrpcContext which now extends BaseContext) |
cli.ts |
Codegen: HydrateContext → BaseContext, defineFeed overloads get (ctx, items) signature |
grain _hydrate.ts |
(ctx: HydrateContext<Gallery>) → (ctx: BaseContext, items: Row<Gallery>[]), ctx.items → items |
| grain feeds | No changes (function reference still works) |
grain searchGalleries.ts |
Remove 10-line field spread + as any, replace with hydrateGalleries(ctx, result.records) |
teal _hydrate.ts |
(ctx: HydrateContext<Play>) → (ctx: BaseContext, items: Row<Play>[]), ctx.items → items |
| teal feeds | (ctx) => hydratePlays(ctx) → hydratePlays |