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.

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 items field (items are now a separate argument)
  • No generic <T> parameter
  • viewer gains optional handle field
  • db.query params typed as unknown[] not any[]

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, blobUrl
  • db field overrides BaseContext's db to add .run method (TypeScript allows this since it's a superset)
  • Remove fields already in BaseContext: getRecords, lookup, count, labels, blobUrl — they're inherited
  • viewer is inherited from BaseContext: { did: string; handle?: string } | null
  • Tighten any types to unknown where 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.lengthitems.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.lengthitems.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 HydrateContextBaseContext (no items, add handle to viewer), buildHydrateContextbuildBaseContext (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: HydrateContextBaseContext, defineFeed overloads get (ctx, items) signature
grain _hydrate.ts (ctx: HydrateContext<Gallery>)(ctx: BaseContext, items: Row<Gallery>[]), ctx.itemsitems
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.itemsitems
teal feeds (ctx) => hydratePlays(ctx)hydratePlays