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.

feat: add BaseContext, deduplicate context construction, tighten types

- Replace HydrateContext with BaseContext (no items field, viewer gains handle)
- Feed hydrate signature: (ctx: BaseContext, items: Row<T>[]) => Promise<unknown[]>
- XrpcContext extends BaseContext, removing duplicate field declarations
- Add buildBaseContext and buildXrpcContext factories, eliminating 4 copy-pasted context constructions across xrpc.ts and opengraph.ts
- Tighten db layer: run/runBatch/all params from any[] to unknown[], typed all<T>() generics at every call site
- FeedContext.db.query tightened to unknown[]
- Codegen updated: BaseContext imports/exports, defineFeed overloads

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+710 -233
+532
docs/superpowers/plans/2026-03-20-base-context-implementation.md
··· 1 + # BaseContext Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Extract a shared `BaseContext` type so hydrators work with both feed and XRPC contexts without manual field spreading or `as any` casts. 6 + 7 + **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. 8 + 9 + **Tech Stack:** TypeScript, hatk framework (`packages/hatk/src/`), template projects (grain, teal) 10 + 11 + --- 12 + 13 + ### Task 1: Add BaseContext and buildBaseContext to hydrate.ts 14 + 15 + **Files:** 16 + - Modify: `packages/hatk/src/hydrate.ts` 17 + 18 + **Step 1: Replace HydrateContext with BaseContext** 19 + 20 + Replace the `HydrateContext` interface and `buildHydrateContext` function with: 21 + 22 + ```ts 23 + export interface BaseContext { 24 + viewer: { did: string; handle?: string } | null 25 + db: { query: (sql: string, params?: unknown[]) => Promise<unknown[]> } 26 + getRecords: <R = unknown>(collection: string, uris: string[]) => Promise<Map<string, Row<R>>> 27 + lookup: <R = unknown>(collection: string, field: string, values: string[]) => Promise<Map<string, Row<R>>> 28 + count: (collection: string, field: string, values: string[]) => Promise<Map<string, number>> 29 + labels: (uris: string[]) => Promise<Map<string, unknown[]>> 30 + blobUrl: ( 31 + did: string, 32 + ref: unknown, 33 + preset?: 'avatar' | 'banner' | 'feed_thumbnail' | 'feed_fullsize', 34 + ) => string | undefined 35 + } 36 + ``` 37 + 38 + Key differences from `HydrateContext`: 39 + - No `items` field (items are now a separate argument) 40 + - No generic `<T>` parameter 41 + - `viewer` gains optional `handle` field 42 + - `db.query` params typed as `unknown[]` not `any[]` 43 + 44 + Replace `buildHydrateContext`: 45 + 46 + ```ts 47 + /** Build a BaseContext for hydration. */ 48 + export function buildBaseContext(viewer: { did: string; handle?: string } | null): BaseContext { 49 + return { 50 + viewer, 51 + db: { query: querySQL }, 52 + getRecords: getRecordsMap, 53 + lookup: async (collection, field, values) => { 54 + if (values.length === 0) return new Map() 55 + const unique = [...new Set(values.filter(Boolean))] 56 + return lookupByFieldBatch(collection, field, unique) as any 57 + }, 58 + count: async (collection, field, values) => { 59 + if (values.length === 0) return new Map() 60 + const unique = [...new Set(values.filter(Boolean))] 61 + return countByFieldBatch(collection, field, unique) 62 + }, 63 + labels: queryLabelsForUris, 64 + blobUrl, 65 + } 66 + } 67 + ``` 68 + 69 + 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. 70 + 71 + **Step 2: Verify the file compiles** 72 + 73 + Run: `cd /Users/chadmiller/code/hatk && npx tsc --noEmit -p packages/hatk/tsconfig.json 2>&1 | head -30` 74 + 75 + Expected: Errors in `feeds.ts` referencing the old `HydrateContext` name — that's expected, we fix it in Task 2. 76 + 77 + --- 78 + 79 + ### Task 2: Update feeds.ts to use BaseContext 80 + 81 + **Files:** 82 + - Modify: `packages/hatk/src/feeds.ts` 83 + 84 + **Step 1: Update imports** 85 + 86 + Change: 87 + ```ts 88 + import { resolveRecords, buildHydrateContext } from './hydrate.ts' 89 + import type { HydrateContext, Row } from './hydrate.ts' 90 + 91 + export type { HydrateContext, Row } 92 + ``` 93 + 94 + To: 95 + ```ts 96 + import { resolveRecords, buildBaseContext } from './hydrate.ts' 97 + import type { BaseContext, Row } from './hydrate.ts' 98 + 99 + export type { BaseContext, Row } 100 + ``` 101 + 102 + **Step 2: Update FeedHandler interface** 103 + 104 + Change the `hydrate` field type: 105 + ```ts 106 + // Before 107 + hydrate?: (ctx: HydrateContext) => Promise<unknown[]> 108 + 109 + // After 110 + hydrate?: (ctx: BaseContext, items: Row<unknown>[]) => Promise<unknown[]> 111 + ``` 112 + 113 + **Step 3: Update FeedOpts type** 114 + 115 + Change both union members: 116 + ```ts 117 + type FeedOpts = 118 + | { 119 + collection: string 120 + view?: string 121 + label: string 122 + generate: FeedGenerate 123 + hydrate?: (ctx: BaseContext, items: Row<any>[]) => Promise<unknown[]> 124 + } 125 + | { 126 + collection?: never 127 + view?: never 128 + label: string 129 + generate: FeedGenerate 130 + hydrate: (ctx: BaseContext, items: Row<any>[]) => Promise<unknown[]> 131 + } 132 + ``` 133 + 134 + **Step 4: Update executeFeed** 135 + 136 + Change the hydrate call site from: 137 + ```ts 138 + const ctx = buildHydrateContext(items, viewer || null) 139 + const hydrated = await handler.hydrate(ctx) 140 + ``` 141 + 142 + To: 143 + ```ts 144 + const ctx = buildBaseContext(viewer || null) 145 + const hydrated = await handler.hydrate(ctx, items) 146 + ``` 147 + 148 + **Step 5: Verify** 149 + 150 + Run: `cd /Users/chadmiller/code/hatk && npx tsc --noEmit -p packages/hatk/tsconfig.json 2>&1 | head -30` 151 + 152 + Expected: Errors in `cli.ts` referencing old `HydrateContext` — fixed in Task 4. 153 + 154 + --- 155 + 156 + ### Task 3: XrpcContext extends BaseContext in xrpc.ts 157 + 158 + **Files:** 159 + - Modify: `packages/hatk/src/xrpc.ts` 160 + 161 + **Step 1: Import BaseContext** 162 + 163 + Add to the imports from `./hydrate.ts`: 164 + ```ts 165 + import { resolveRecords } from './hydrate.ts' 166 + import type { BaseContext } from './hydrate.ts' 167 + ``` 168 + 169 + **Step 2: Update XrpcContext interface** 170 + 171 + Change from a standalone interface to one that extends BaseContext: 172 + 173 + ```ts 174 + export interface XrpcContext< 175 + P = Record<string, string>, 176 + Records extends Record<string, any> = Record<string, any>, 177 + I = unknown, 178 + > extends BaseContext { 179 + db: { 180 + query: (sql: string, params?: unknown[]) => Promise<unknown[]> 181 + run: (sql: string, ...params: unknown[]) => Promise<void> 182 + } 183 + params: P 184 + input: I 185 + cursor?: string 186 + limit: number 187 + packCursor: (primary: string | number, cid: string) => string 188 + unpackCursor: (cursor: string) => { primary: string; cid: string } | null 189 + isTakendown: (did: string) => Promise<boolean> 190 + filterTakendownDids: (dids: string[]) => Promise<Set<string>> 191 + search: <K extends string & keyof Records>( 192 + collection: K, 193 + q: string, 194 + opts?: { limit?: number; cursor?: string; fuzzy?: boolean }, 195 + ) => Promise<{ records: Row<Records[K]>[]; cursor?: string }> 196 + resolve: <R = unknown>(uris: string[]) => Promise<Row<R>[]> 197 + exists: (collection: string, filters: Record<string, string>) => Promise<boolean> 198 + } 199 + ``` 200 + 201 + Key changes: 202 + - `extends BaseContext` — inherits viewer, db.query, getRecords, lookup, count, labels, blobUrl 203 + - `db` field overrides BaseContext's `db` to add `.run` method (TypeScript allows this since it's a superset) 204 + - Remove fields already in BaseContext: `getRecords`, `lookup`, `count`, `labels`, `blobUrl` — they're inherited 205 + - `viewer` is inherited from BaseContext: `{ did: string; handle?: string } | null` 206 + - Tighten `any` types to `unknown` where possible 207 + 208 + **Step 3: Update context construction in initXrpc and registerXrpcHandler** 209 + 210 + 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: 211 + 212 + ```ts 213 + const ctx: XrpcContext = { 214 + db: { query: querySQL, run: runSQL }, 215 + params, 216 + input: input || {}, 217 + cursor, 218 + limit, 219 + viewer, 220 + packCursor, 221 + unpackCursor, 222 + isTakendown: isTakendownDid, 223 + filterTakendownDids, 224 + search: searchRecords, 225 + resolve: resolveRecords as any, 226 + getRecords: getRecordsMap, 227 + lookup: async (collection, field, values) => { 228 + if (values.length === 0) return new Map() 229 + const unique = [...new Set(values.filter(Boolean))] 230 + return lookupByFieldBatch(collection, field, unique) as any 231 + }, 232 + count: async (collection, field, values) => { 233 + if (values.length === 0) return new Map() 234 + const unique = [...new Set(values.filter(Boolean))] 235 + return countByFieldBatch(collection, field, unique) 236 + }, 237 + exists: async (collection, filters) => { 238 + const conditions = Object.entries(filters).map(([field, value]) => ({ field, value })) 239 + const uri = await findUriByFields(collection, conditions) 240 + return uri !== null 241 + }, 242 + labels: queryLabelsForUris, 243 + blobUrl, 244 + } 245 + ``` 246 + 247 + The object literal is the same — `extends` just means the interface is compatible, not that construction changes. 248 + 249 + **Step 4: Verify** 250 + 251 + Run: `cd /Users/chadmiller/code/hatk && npx tsc --noEmit -p packages/hatk/tsconfig.json 2>&1 | head -30` 252 + 253 + --- 254 + 255 + ### Task 4: Update opengraph.ts context construction 256 + 257 + **Files:** 258 + - Modify: `packages/hatk/src/opengraph.ts` 259 + 260 + **Step 1: No structural changes needed** 261 + 262 + `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. 263 + 264 + 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. 265 + 266 + **Step 2: Verify** 267 + 268 + Run: `cd /Users/chadmiller/code/hatk && npx tsc --noEmit -p packages/hatk/tsconfig.json 2>&1 | head -30` 269 + 270 + --- 271 + 272 + ### Task 5: Update cli.ts codegen 273 + 274 + **Files:** 275 + - Modify: `packages/hatk/src/cli.ts` 276 + 277 + **Step 1: Update the import line (around line 1527)** 278 + 279 + Change: 280 + ```ts 281 + out += `import { defineFeed as _defineFeed, type FeedResult, type FeedContext, type HydrateContext, type Row } from '@hatk/hatk/feeds'\n` 282 + ``` 283 + 284 + To: 285 + ```ts 286 + out += `import { defineFeed as _defineFeed, type FeedResult, type FeedContext, type BaseContext, type Row } from '@hatk/hatk/feeds'\n` 287 + ``` 288 + 289 + **Step 2: Update the re-export line (around line 1695)** 290 + 291 + Change: 292 + ```ts 293 + out += `export type { HydrateContext, Row } from '@hatk/hatk/feeds'\n` 294 + ``` 295 + 296 + To: 297 + ```ts 298 + out += `export type { BaseContext, Row } from '@hatk/hatk/feeds'\n` 299 + ``` 300 + 301 + **Step 3: Update defineFeed overloads (around lines 1733-1738)** 302 + 303 + Change: 304 + ```ts 305 + out += `export function defineFeed<K extends keyof RecordRegistry>(\n` 306 + out += ` opts: { collection: K; view?: string; label: string; generate: FeedGenerate; hydrate?: (ctx: HydrateContext<RecordRegistry[K]>) => Promise<unknown[]> }\n` 307 + out += `): ReturnType<typeof _defineFeed>\n` 308 + out += `export function defineFeed(\n` 309 + out += ` opts: { collection?: never; view?: never; label: string; generate: FeedGenerate; hydrate: (ctx: HydrateContext<any>) => Promise<unknown[]> }\n` 310 + out += `): ReturnType<typeof _defineFeed>\n` 311 + ``` 312 + 313 + To: 314 + ```ts 315 + out += `export function defineFeed<K extends keyof RecordRegistry>(\n` 316 + out += ` opts: { collection: K; view?: string; label: string; generate: FeedGenerate; hydrate?: (ctx: BaseContext, items: Row<RecordRegistry[K]>[]) => Promise<unknown[]> }\n` 317 + out += `): ReturnType<typeof _defineFeed>\n` 318 + out += `export function defineFeed(\n` 319 + out += ` opts: { collection?: never; view?: never; label: string; generate: FeedGenerate; hydrate: (ctx: BaseContext, items: Row<unknown>[]) => Promise<unknown[]> }\n` 320 + out += `): ReturnType<typeof _defineFeed>\n` 321 + ``` 322 + 323 + Key change: `hydrate` goes from `(ctx: HydrateContext<T>) => ...` to `(ctx: BaseContext, items: Row<T>[]) => ...`. The second overload uses `Row<unknown>` instead of `HydrateContext<any>`. 324 + 325 + **Step 4: Verify hatk compiles** 326 + 327 + Run: `cd /Users/chadmiller/code/hatk && npx tsc --noEmit -p packages/hatk/tsconfig.json 2>&1 | head -30` 328 + 329 + Expected: Clean compile. All hatk framework changes are done. 330 + 331 + **Step 5: Bump version and publish** 332 + 333 + Run: `cd /Users/chadmiller/code/hatk && npm version prerelease --preid alpha -w packages/hatk` 334 + 335 + Then commit all hatk changes (hydrate.ts, feeds.ts, xrpc.ts, cli.ts, and version bump). 336 + 337 + --- 338 + 339 + ### Task 6: Update grain template — _hydrate.ts 340 + 341 + **Files:** 342 + - Modify: `/Users/chadmiller/code/hatk-template-grain/server/feeds/_hydrate.ts` 343 + 344 + **Step 1: Install updated hatk** 345 + 346 + Run: `cd /Users/chadmiller/code/hatk-template-grain && npm install` 347 + 348 + **Step 2: Regenerate types** 349 + 350 + Run: `cd /Users/chadmiller/code/hatk-template-grain && npx hatk generate` 351 + 352 + **Step 3: Update hydrateGalleries signature** 353 + 354 + Change: 355 + ```ts 356 + import type { HydrateContext, Row } from "$hatk"; 357 + 358 + export async function hydrateGalleries(ctx: HydrateContext<Gallery>): Promise<GalleryView[]> { 359 + const dids = [...new Set(ctx.items.map((item) => item.did).filter(Boolean))]; 360 + ``` 361 + 362 + To: 363 + ```ts 364 + import type { BaseContext, Row } from "$hatk"; 365 + 366 + export async function hydrateGalleries(ctx: BaseContext, items: Row<Gallery>[]): Promise<GalleryView[]> { 367 + const dids = [...new Set(items.map((item) => item.did).filter(Boolean))]; 368 + ``` 369 + 370 + **Step 4: Replace all `ctx.items` with `items` in the function body** 371 + 372 + Every reference to `ctx.items` becomes just `items`: 373 + - `ctx.items.map(...)` → `items.map(...)` 374 + - `ctx.items.length` → `items.length` 375 + 376 + These occur at: 377 + - Line 8: `ctx.items.map((item) => item.did)` → `items.map((item) => item.did)` 378 + - Line 9: `ctx.items.map((item) => item.uri)` → `items.map((item) => item.uri)` 379 + - Line 61: `return ctx.items.map((item) => {` → `return items.map((item) => {` 380 + 381 + **Step 5: Verify** 382 + 383 + Run: `cd /Users/chadmiller/code/hatk-template-grain && npx tsc --noEmit 2>&1 | head -30` 384 + 385 + Expected: Errors in feed files and searchGalleries.ts — fixed in Tasks 7-8. 386 + 387 + --- 388 + 389 + ### Task 7: Update grain template — feed files 390 + 391 + **Files:** 392 + - Modify: `/Users/chadmiller/code/hatk-template-grain/server/feeds/recent.ts` 393 + - Modify: `/Users/chadmiller/code/hatk-template-grain/server/feeds/actor.ts` 394 + 395 + **Step 1: Update recent.ts** 396 + 397 + Change: 398 + ```ts 399 + hydrate: hydrateGalleries, 400 + ``` 401 + 402 + To: 403 + ```ts 404 + hydrate: (ctx, items) => hydrateGalleries(ctx, items), 405 + ``` 406 + 407 + Or more concisely, since the signature now matches directly: 408 + ```ts 409 + hydrate: hydrateGalleries, 410 + ``` 411 + 412 + 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[]>`. 413 + 414 + So **no change needed** in recent.ts — the function reference still works. 415 + 416 + **Step 2: Same for actor.ts — no change needed** 417 + 418 + **Step 3: Verify** 419 + 420 + Run: `cd /Users/chadmiller/code/hatk-template-grain && npx tsc --noEmit 2>&1 | head -30` 421 + 422 + --- 423 + 424 + ### Task 8: Update grain template — searchGalleries.ts 425 + 426 + **Files:** 427 + - Modify: `/Users/chadmiller/code/hatk-template-grain/server/xrpc/searchGalleries.ts` 428 + 429 + **Step 1: Replace the ugly field spreading** 430 + 431 + Change: 432 + ```ts 433 + const galleries = await hydrateGalleries({ 434 + items: result.records, 435 + viewer: ctx.viewer, 436 + db: ctx.db, 437 + getRecords: ctx.getRecords, 438 + lookup: ctx.lookup, 439 + count: ctx.count, 440 + labels: ctx.labels, 441 + blobUrl: ctx.blobUrl, 442 + } as any); 443 + ``` 444 + 445 + To: 446 + ```ts 447 + const galleries = await hydrateGalleries(ctx, result.records); 448 + ``` 449 + 450 + This works because `XrpcContext extends BaseContext`, so `ctx` is directly assignable to `BaseContext`. 451 + 452 + **Step 2: Verify** 453 + 454 + Run: `cd /Users/chadmiller/code/hatk-template-grain && npx tsc --noEmit 2>&1 | head -30` 455 + 456 + Expected: Clean compile. 457 + 458 + --- 459 + 460 + ### Task 9: Update teal template 461 + 462 + **Files:** 463 + - Modify: `/Users/chadmiller/code/hatk-template-teal/server/feeds/_hydrate.ts` 464 + 465 + **Step 1: Install updated hatk and regenerate types** 466 + 467 + Run: 468 + ```bash 469 + cd /Users/chadmiller/code/hatk-template-teal && npm install && npx hatk generate 470 + ``` 471 + 472 + **Step 2: Update hydratePlays signature** 473 + 474 + Change: 475 + ```ts 476 + import { views, type HydrateContext, type Play, type Profile } from "$hatk"; 477 + 478 + export async function hydratePlays(ctx: HydrateContext<Play>) { 479 + const dids = [...new Set(ctx.items.map((item) => item.did).filter(Boolean))]; 480 + ``` 481 + 482 + To: 483 + ```ts 484 + import { views, type BaseContext, type Row, type Play, type Profile } from "$hatk"; 485 + 486 + export async function hydratePlays(ctx: BaseContext, items: Row<Play>[]) { 487 + const dids = [...new Set(items.map((item) => item.did).filter(Boolean))]; 488 + ``` 489 + 490 + **Step 3: Replace all `ctx.items` with `items`** 491 + 492 + - Line 6: `ctx.items.map((item) => item.did)` → `items.map((item) => item.did)` 493 + - Line 10: `ctx.items.length` → `items.length` 494 + - Line 27: `return ctx.items.map((item) => {` → `return items.map((item) => {` 495 + 496 + **Step 4: Update feed files if needed** 497 + 498 + 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`. 499 + 500 + In teal's `recent.ts`: 501 + ```ts 502 + // Before 503 + hydrate: (ctx) => hydratePlays(ctx), 504 + 505 + // After 506 + hydrate: hydratePlays, 507 + ``` 508 + 509 + Apply the same pattern to all teal feed files that reference `hydratePlays`. 510 + 511 + **Step 5: Verify** 512 + 513 + Run: `cd /Users/chadmiller/code/hatk-template-teal && npx tsc --noEmit 2>&1 | head -30` 514 + 515 + Expected: Clean compile. 516 + 517 + --- 518 + 519 + ## Summary of all changes 520 + 521 + | File | Change | 522 + |------|--------| 523 + | `hydrate.ts` | `HydrateContext` → `BaseContext` (no items, add handle to viewer), `buildHydrateContext` → `buildBaseContext` (no items param) | 524 + | `feeds.ts` | Import/export `BaseContext`, hydrate signature gets `items` param, `executeFeed` passes ctx and items separately | 525 + | `xrpc.ts` | `XrpcContext extends BaseContext`, import BaseContext, tighten types | 526 + | `opengraph.ts` | No changes needed (constructs XrpcContext which now extends BaseContext) | 527 + | `cli.ts` | Codegen: `HydrateContext` → `BaseContext`, `defineFeed` overloads get `(ctx, items)` signature | 528 + | grain `_hydrate.ts` | `(ctx: HydrateContext<Gallery>)` → `(ctx: BaseContext, items: Row<Gallery>[])`, `ctx.items` → `items` | 529 + | grain feeds | No changes (function reference still works) | 530 + | grain `searchGalleries.ts` | Remove 10-line field spread + `as any`, replace with `hydrateGalleries(ctx, result.records)` | 531 + | teal `_hydrate.ts` | `(ctx: HydrateContext<Play>)` → `(ctx: BaseContext, items: Row<Play>[])`, `ctx.items` → `items` | 532 + | teal feeds | `(ctx) => hydratePlays(ctx)` → `hydratePlays` |
+63
docs/superpowers/plans/2026-03-20-base-context.md
··· 1 + # BaseContext: Shared Hydration Context 2 + 3 + **Goal:** Extract a shared `BaseContext` type from hatk so hydrators work with both feed and XRPC contexts without manual field spreading or `as any` casts. 4 + 5 + **Problem:** `HydrateContext` and `XrpcContext` share 90% of their fields (db, lookup, count, getRecords, labels, blobUrl, viewer). Hydrators that need to work with both must manually spread every field and cast. Adding `getRecords` to `XrpcContext` required touching four construction sites. 6 + 7 + ## Design 8 + 9 + ### BaseContext 10 + 11 + Shared interface in `hydrate.ts`. Provides data access tools any hydrator needs. 12 + 13 + ```ts 14 + export interface BaseContext { 15 + viewer: { did: string; handle?: string } | null 16 + db: { query: (sql: string, params?: unknown[]) => Promise<unknown[]> } 17 + getRecords: <R = unknown>(collection: string, uris: string[]) => Promise<Map<string, Row<R>>> 18 + lookup: <R = unknown>(collection: string, field: string, values: string[]) => Promise<Map<string, Row<R>>> 19 + count: (collection: string, field: string, values: string[]) => Promise<Map<string, number>> 20 + labels: (uris: string[]) => Promise<Map<string, unknown[]>> 21 + blobUrl: (did: string, ref: unknown, preset?: 'avatar' | 'banner' | 'feed_thumbnail' | 'feed_fullsize') => string | undefined 22 + } 23 + ``` 24 + 25 + `buildBaseContext(viewer)` constructs one. No items parameter — items are always a separate argument. 26 + 27 + ### XrpcContext extends BaseContext 28 + 29 + Adds XRPC-specific fields: params, cursor, limit, search, resolve, exists, packCursor, unpackCursor, isTakendown, filterTakendownDids, db.run. Context construction in xrpc.ts and opengraph.ts reuses `buildBaseContext` and extends. 30 + 31 + ### Feed hydrate signature 32 + 33 + Changes from `(ctx: HydrateContext<T>) => Promise<View[]>` to `(ctx: BaseContext, items: Row<T>[]) => Promise<View[]>`. `HydrateContext` is removed entirely. 34 + 35 + ### Hydrator reuse 36 + 37 + XRPC handlers pass `ctx` directly to hydrators since `XrpcContext extends BaseContext`: 38 + 39 + ```ts 40 + // Before 41 + const galleries = await hydrateGalleries({ 42 + items: result.records, viewer: ctx.viewer, db: ctx.db, 43 + getRecords: ctx.getRecords, lookup: ctx.lookup, count: ctx.count, 44 + labels: ctx.labels, blobUrl: ctx.blobUrl, 45 + } as any); 46 + 47 + // After 48 + const galleries = await hydrateGalleries(ctx, result.records); 49 + ``` 50 + 51 + ## Files to change 52 + 53 + **hatk framework:** 54 + - `hydrate.ts` — Add BaseContext, remove HydrateContext, buildBaseContext 55 + - `xrpc.ts` — XrpcContext extends BaseContext, reuse buildBaseContext 56 + - `opengraph.ts` — Reuse buildBaseContext 57 + - `feeds.ts` — Hydrate signature, executeFeed, exports 58 + - `cli.ts` — Codegen: BaseContext imports/exports, defineFeed overloads 59 + 60 + **Template projects:** 61 + - `server/feeds/_hydrate.ts` — Accept (ctx: BaseContext, items: Row<T>[]) 62 + - `server/feeds/*.ts` — Pass (ctx, items) to hydrate 63 + - `server/xrpc/*.ts` — Pass ctx directly to hydrators
+2 -2
package-lock.json
··· 2216 2216 "version": "19.2.14", 2217 2217 "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", 2218 2218 "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", 2219 - "dev": true, 2219 + "devOptional": true, 2220 2220 "license": "MIT", 2221 2221 "dependencies": { 2222 2222 "csstype": "^3.2.2" ··· 5771 5771 }, 5772 5772 "packages/hatk": { 5773 5773 "name": "@hatk/hatk", 5774 - "version": "0.0.1-alpha.34", 5774 + "version": "0.0.1-alpha.41", 5775 5775 "license": "MIT", 5776 5776 "dependencies": { 5777 5777 "@bigmoves/lexicon": "^0.2.2",
+1 -1
packages/hatk/package.json
··· 1 1 { 2 2 "name": "@hatk/hatk", 3 - "version": "0.0.1-alpha.39", 3 + "version": "0.0.1-alpha.40", 4 4 "license": "MIT", 5 5 "bin": { 6 6 "hatk": "dist/cli.js"
+2 -2
packages/hatk/src/backfill.ts
··· 465 465 466 466 // Wait until the earliest retry_after has passed 467 467 const now = Math.floor(Date.now() / 1000) 468 - const rows = await querySQL( 468 + const rows = (await querySQL( 469 469 `SELECT MIN(retry_after) as earliest FROM _repos WHERE status = 'failed' AND retry_after > $1 AND retry_count < $2`, 470 470 [now, maxRetries], 471 - ) 471 + )) as { earliest: number | null }[] 472 472 const earliest = rows[0]?.earliest ? Number(rows[0].earliest) : 0 473 473 if (earliest > now) { 474 474 await new Promise((resolve) => setTimeout(resolve, (earliest - now) * 1000))
+4 -4
packages/hatk/src/cli.ts
··· 1524 1524 out += `import type { ${[...usedWrappers].sort().join(', ')}, LexServerParams, Checked, Prettify, StrictArg } from '@hatk/hatk/lex-types'\n` 1525 1525 out += `import type { XrpcContext } from '@hatk/hatk/xrpc'\n` 1526 1526 out += `import { callXrpc as _callXrpc } from '@hatk/hatk/xrpc'\n` 1527 - out += `import { defineFeed as _defineFeed, type FeedResult, type FeedContext, type HydrateContext, type Row } from '@hatk/hatk/feeds'\n` 1527 + out += `import { defineFeed as _defineFeed, type FeedResult, type FeedContext, type BaseContext, type Row } from '@hatk/hatk/feeds'\n` 1528 1528 out += `import { seed as _seed, type SeedOpts } from '@hatk/hatk/seed'\n` 1529 1529 1530 1530 // Emit ALL lexicons as `const ... = {...} as const` (including defs-only) ··· 1692 1692 1693 1693 // Emit Ctx helper for typesafe XRPC handler contexts 1694 1694 out += `\n// ─── XRPC Helpers ───────────────────────────────────────────────────\n\n` 1695 - out += `export type { HydrateContext, Row } from '@hatk/hatk/feeds'\n` 1695 + out += `export type { BaseContext, Row } from '@hatk/hatk/feeds'\n` 1696 1696 out += `export { InvalidRequestError, NotFoundError } from '@hatk/hatk/xrpc'\n` 1697 1697 out += `export { defineSetup } from '@hatk/hatk/setup'\n` 1698 1698 out += `export { defineHook } from '@hatk/hatk/hooks'\n` ··· 1731 1731 out += `// ─── Feed & Seed Helpers ────────────────────────────────────────────\n\n` 1732 1732 out += `type FeedGenerate = (ctx: FeedContext & { ok: (value: FeedResult) => Checked<FeedResult> }) => Promise<Checked<FeedResult>>\n` 1733 1733 out += `export function defineFeed<K extends keyof RecordRegistry>(\n` 1734 - out += ` opts: { collection: K; view?: string; label: string; generate: FeedGenerate; hydrate?: (ctx: HydrateContext<RecordRegistry[K]>) => Promise<unknown[]> }\n` 1734 + out += ` opts: { collection: K; view?: string; label: string; generate: FeedGenerate; hydrate?: (ctx: BaseContext, items: Row<RecordRegistry[K]>[]) => Promise<unknown[]> }\n` 1735 1735 out += `): ReturnType<typeof _defineFeed>\n` 1736 1736 out += `export function defineFeed(\n` 1737 - out += ` opts: { collection?: never; view?: never; label: string; generate: FeedGenerate; hydrate: (ctx: HydrateContext<any>) => Promise<unknown[]> }\n` 1737 + out += ` opts: { collection?: never; view?: never; label: string; generate: FeedGenerate; hydrate: (ctx: BaseContext, items: Row<unknown>[]) => Promise<unknown[]> }\n` 1738 1738 out += `): ReturnType<typeof _defineFeed>\n` 1739 1739 out += `export function defineFeed(opts: any) { return _defineFeed(opts) }\n` 1740 1740 out += `export function seed(opts?: SeedOpts) { return _seed<RecordRegistry>(opts) }\n`
+44 -44
packages/hatk/src/database/db.ts
··· 21 21 port?.close() 22 22 } 23 23 24 - async function run(sql: string, params: any[] = []): Promise<void> { 24 + async function run(sql: string, params: unknown[] = []): Promise<void> { 25 25 return port.execute(sql, params) 26 26 } 27 27 28 - export async function runBatch(operations: Array<{ sql: string; params: any[] }>): Promise<void> { 28 + export async function runBatch(operations: Array<{ sql: string; params: unknown[] }>): Promise<void> { 29 29 await port.beginTransaction() 30 30 try { 31 31 for (const op of operations) { ··· 41 41 } 42 42 } 43 43 44 - async function all(sql: string, params: any[] = []): Promise<any[]> { 45 - return port.query(sql, params) 44 + async function all<T = unknown>(sql: string, params: unknown[] = []): Promise<T[]> { 45 + return port.query<T>(sql, params) 46 46 } 47 47 48 48 export async function initDatabase( ··· 151 151 const cols = new Map<string, string>() 152 152 try { 153 153 const query = dialect.introspectColumnsQuery(tableName) 154 - const rows = await all(query) 154 + const rows = await all<{ column_name?: string; name?: string; data_type?: string; type?: string }>(query) 155 155 for (const row of rows) { 156 156 // SQLite PRAGMA returns { name, type }, DuckDB returns { column_name, data_type } 157 157 const name = (row.column_name || row.name) as string ··· 335 335 } 336 336 337 337 export async function getCursor(key: string): Promise<string | null> { 338 - const rows = await all(`SELECT value FROM _cursor WHERE key = $1`, [key]) 338 + const rows = await all<{ value: string }>(`SELECT value FROM _cursor WHERE key = $1`, [key]) 339 339 return rows[0]?.value || null 340 340 } 341 341 ··· 344 344 } 345 345 346 346 export async function getRepoStatus(did: string): Promise<string | null> { 347 - const rows = await all(`SELECT status FROM _repos WHERE did = $1`, [did]) 347 + const rows = await all<{ status: string }>(`SELECT status FROM _repos WHERE did = $1`, [did]) 348 348 return rows[0]?.status || null 349 349 } 350 350 ··· 382 382 } 383 383 384 384 export async function getRepoRev(did: string): Promise<string | null> { 385 - const rows = await all(`SELECT rev FROM _repos WHERE did = $1`, [did]) 385 + const rows = await all<{ rev: string }>(`SELECT rev FROM _repos WHERE did = $1`, [did]) 386 386 return rows[0]?.rev ?? null 387 387 } 388 388 389 389 export async function getRepoRetryInfo(did: string): Promise<{ retryCount: number; retryAfter: number } | null> { 390 - const rows = await all(`SELECT retry_count, retry_after FROM _repos WHERE did = $1`, [did]) 390 + const rows = await all<{ retry_count: number; retry_after: number }>(`SELECT retry_count, retry_after FROM _repos WHERE did = $1`, [did]) 391 391 if (rows.length === 0) return null 392 392 return { retryCount: Number(rows[0].retry_count), retryAfter: Number(rows[0].retry_after) } 393 393 } 394 394 395 395 export async function listRetryEligibleRepos(maxRetries: number): Promise<string[]> { 396 396 const now = Math.floor(Date.now() / 1000) 397 - const rows = await all(`SELECT did FROM _repos WHERE status = 'failed' AND retry_after <= $1 AND retry_count < $2`, [ 397 + const rows = await all<{ did: string }>(`SELECT did FROM _repos WHERE status = 'failed' AND retry_after <= $1 AND retry_count < $2`, [ 398 398 now, 399 399 maxRetries, 400 400 ]) 401 - return rows.map((r: any) => r.did) 401 + return rows.map((r) => r.did) 402 402 } 403 403 404 404 export async function listPendingRepos(): Promise<string[]> { 405 - const rows = await all(`SELECT did FROM _repos WHERE status = 'pending'`) 406 - return rows.map((r: any) => r.did) 405 + const rows = await all<{ did: string }>(`SELECT did FROM _repos WHERE status = 'pending'`) 406 + return rows.map((r) => r.did) 407 407 } 408 408 409 409 export async function listActiveRepoDids(): Promise<string[]> { ··· 416 416 } 417 417 418 418 export async function getRepoHandle(did: string): Promise<string | null> { 419 - const rows = await all(`SELECT handle FROM _repos WHERE did = $1`, [did]) 419 + const rows = await all<{ handle: string }>(`SELECT handle FROM _repos WHERE did = $1`, [did]) 420 420 return rows[0]?.handle ?? null 421 421 } 422 422 ··· 449 449 450 450 const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : '' 451 451 452 - const countRows = await all(`SELECT ${dialect.countAsInteger} as total FROM _repos${where}`, params) 452 + const countRows = await all<{ total: number }>(`SELECT ${dialect.countAsInteger} as total FROM _repos${where}`, params) 453 453 const total = Number(countRows[0]?.total || 0) 454 454 455 455 const rows = await all( ··· 463 463 export async function getCollectionCounts(): Promise<Record<string, number>> { 464 464 const counts: Record<string, number> = {} 465 465 for (const [collection, schema] of schemas) { 466 - const rows = await all(`SELECT ${dialect.countAsInteger} as count FROM ${schema.tableName}`) 466 + const rows = await all<{ count: number }>(`SELECT ${dialect.countAsInteger} as count FROM ${schema.tableName}`) 467 467 counts[collection] = Number(rows[0]?.count || 0) 468 468 } 469 469 return counts 470 470 } 471 471 472 472 export async function getRepoStatusCounts(): Promise<Record<string, number>> { 473 - const rows = await all(`SELECT status, ${dialect.countAsInteger} as count FROM _repos GROUP BY status`) 473 + const rows = await all<{ status: string; count: number }>(`SELECT status, ${dialect.countAsInteger} as count FROM _repos GROUP BY status`) 474 474 const counts: Record<string, number> = {} 475 - for (const row of rows) counts[row.status as string] = Number(row.count) 475 + for (const row of rows) counts[row.status] = Number(row.count) 476 476 return counts 477 477 } 478 478 ··· 483 483 return (rows[0] as Record<string, string>) ?? {} 484 484 } 485 485 // SQLite: compute from page_count * page_size 486 - const pages = await all('SELECT page_count FROM pragma_page_count()') 487 - const sizes = await all('SELECT page_size FROM pragma_page_size()') 486 + const pages = await all<{ page_count: number }>('SELECT page_count FROM pragma_page_count()') 487 + const sizes = await all<{ page_size: number }>('SELECT page_size FROM pragma_page_size()') 488 488 const pageCount = Number(pages[0]?.page_count ?? 0) 489 489 const pageSize = Number(sizes[0]?.page_size ?? 0) 490 490 const bytes = pageCount * pageSize ··· 493 493 } 494 494 495 495 export async function getLabelCount(val: string): Promise<number> { 496 - const rows = await all(`SELECT ${dialect.countAsInteger} as count FROM _labels WHERE val = $1`, [val]) 496 + const rows = await all<{ count: number }>(`SELECT ${dialect.countAsInteger} as count FROM _labels WHERE val = $1`, [val]) 497 497 return Number(rows[0]?.count || 0) 498 498 } 499 499 ··· 751 751 > { 752 752 if (uris.length === 0) return new Map() 753 753 const placeholders = uris.map((_, i) => `$${i + 1}`).join(',') 754 - const rows = await all( 754 + const rows = await all<{ src: string; uri: string; val: string; neg: boolean; cts: string; exp: string | null }>( 755 755 `SELECT src, uri, val, neg, cts, exp FROM _labels l1 WHERE uri IN (${placeholders}) AND (exp IS NULL OR exp > CURRENT_TIMESTAMP) AND neg = false AND NOT EXISTS (SELECT 1 FROM _labels l2 WHERE l2.uri = l1.uri AND l2.val = l1.val AND l2.neg = true AND l2.id > l1.id)`, 756 756 uris, 757 757 ) 758 758 const result = new Map<string, Array<any>>() 759 759 for (const row of rows) { 760 - const key = row.uri as string 760 + const key = row.uri 761 761 if (!result.has(key)) result.set(key, []) 762 762 result.get(key)!.push({ 763 763 src: row.src, ··· 1122 1122 } 1123 1123 } 1124 1124 1125 - const lastRow = rows[rows.length - 1] 1126 - const nextCursor = hasMore && lastRow ? packCursor(lastRow[sortName], lastRow.cid) : undefined 1125 + const lastRow = rows[rows.length - 1] as Record<string, unknown> | undefined 1126 + const nextCursor = hasMore && lastRow ? packCursor(lastRow[sortName] as string, lastRow.cid as string) : undefined 1127 1127 1128 1128 return { records: rows, cursor: nextCursor } 1129 1129 } ··· 1320 1320 LIMIT $2` 1321 1321 1322 1322 try { 1323 - const fuzzyRows = await all(fuzzySQL, [query, remaining + existingUris.size]) 1323 + const fuzzyRows = await all<Record<string, unknown>>(fuzzySQL, [query, remaining + existingUris.size]) 1324 1324 phasesUsed.push('fuzzy') 1325 1325 for (const row of fuzzyRows) { 1326 1326 if (bm25Results.length >= limit) break ··· 1358 1358 } 1359 1359 1360 1360 // Raw SQL for script feeds 1361 - export async function querySQL(sql: string, params: any[] = []): Promise<any[]> { 1361 + export async function querySQL(sql: string, params: unknown[] = []): Promise<unknown[]> { 1362 1362 return all(sql, params) 1363 1363 } 1364 1364 1365 - export async function runSQL(sql: string, params: any[] = []): Promise<void> { 1365 + export async function runSQL(sql: string, params: unknown[] = []): Promise<void> { 1366 1366 return run(sql, params) 1367 1367 } 1368 1368 ··· 1381 1381 export async function countByField(collection: string, field: string, value: string): Promise<number> { 1382 1382 const schema = schemas.get(collection) 1383 1383 if (!schema) return 0 1384 - const rows = await all(`SELECT COUNT(*) as count FROM ${schema.tableName} WHERE ${field} = $1`, [value]) 1384 + const rows = await all<{ count: number }>(`SELECT COUNT(*) as count FROM ${schema.tableName} WHERE ${field} = $1`, [value]) 1385 1385 return Number(rows[0]?.count || 0) 1386 1386 } 1387 1387 ··· 1394 1394 const schema = schemas.get(collection) 1395 1395 if (!schema) return new Map() 1396 1396 const placeholders = values.map((_, i) => `$${i + 1}`).join(',') 1397 - const rows = await all( 1397 + const rows = await all<Record<string, unknown>>( 1398 1398 `SELECT ${field}, COUNT(*) as count FROM ${schema.tableName} WHERE ${field} IN (${placeholders}) GROUP BY ${field}`, 1399 1399 values, 1400 1400 ) 1401 1401 const result = new Map<string, number>() 1402 1402 for (const row of rows) { 1403 - result.set(row[field], Number(row.count)) 1403 + result.set(row[field] as string, Number(row.count)) 1404 1404 } 1405 1405 return result 1406 1406 } ··· 1421 1421 const schema = schemas.get(collection) 1422 1422 if (!schema) return new Map() 1423 1423 const placeholders = values.map((_, i) => `$${i + 1}`).join(',') 1424 - const rows = await all( 1424 + const rows = await all<Record<string, any>>( 1425 1425 `SELECT t.*, r.handle FROM ${schema.tableName} t LEFT JOIN _repos r ON t.did = r.did WHERE t.${field} IN (${placeholders})`, 1426 1426 values, 1427 1427 ) 1428 1428 // Attach child data if this collection has decomposed arrays 1429 1429 if (schema.children.length > 0 && rows.length > 0) { 1430 - const uris = rows.map((r: any) => r.uri) 1430 + const uris = rows.map((r) => r.uri) 1431 1431 const childData = new Map<string, Map<string, any[]>>() 1432 1432 for (const child of schema.children) { 1433 1433 const childRows = await getChildRows(child.tableName, uris) 1434 1434 childData.set(child.fieldName, childRows) 1435 1435 } 1436 1436 for (const row of rows) { 1437 - ;(row as any).__childData = childData 1437 + row.__childData = childData 1438 1438 } 1439 1439 } 1440 1440 ··· 1470 1470 if (!schema) return null 1471 1471 const where = conditions.map((c, i) => `${c.field} = $${i + 1}`).join(' AND ') 1472 1472 const params = conditions.map((c) => c.value) 1473 - const rows = await all(`SELECT uri FROM ${schema.tableName} WHERE ${where} LIMIT 1`, params) 1473 + const rows = await all<{ uri: string }>(`SELECT uri FROM ${schema.tableName} WHERE ${where} LIMIT 1`, params) 1474 1474 return rows[0]?.uri || null 1475 1475 } 1476 1476 ··· 1486 1486 export async function getChildRows(childTableName: string, parentUris: string[]): Promise<Map<string, any[]>> { 1487 1487 if (parentUris.length === 0) return new Map() 1488 1488 const placeholders = parentUris.map((_, i) => `$${i + 1}`).join(',') 1489 - const rows = await all(`SELECT * FROM ${childTableName} WHERE parent_uri IN (${placeholders})`, parentUris) 1489 + const rows = await all<Record<string, any>>(`SELECT * FROM ${childTableName} WHERE parent_uri IN (${placeholders})`, parentUris) 1490 1490 const result = new Map<string, any[]>() 1491 1491 for (const row of rows) { 1492 1492 const key = row.parent_uri as string ··· 1631 1631 export async function getAccountRecordCount(did: string): Promise<number> { 1632 1632 let total = 0 1633 1633 for (const [, schema] of schemas) { 1634 - const rows = await all(`SELECT COUNT(*) as count FROM ${schema.tableName} WHERE did = $1`, [did]) 1634 + const rows = await all<{ count: number }>(`SELECT COUNT(*) as count FROM ${schema.tableName} WHERE did = $1`, [did]) 1635 1635 total += Number(rows[0]?.count || 0) 1636 1636 } 1637 1637 return total ··· 1640 1640 export async function getAllRecordUrisForDid(did: string): Promise<string[]> { 1641 1641 const uris: string[] = [] 1642 1642 for (const [, schema] of schemas) { 1643 - const rows = await all(`SELECT uri FROM ${schema.tableName} WHERE did = $1`, [did]) 1644 - uris.push(...rows.map((r: any) => r.uri)) 1643 + const rows = await all<{ uri: string }>(`SELECT uri FROM ${schema.tableName} WHERE did = $1`, [did]) 1644 + uris.push(...rows.map((r) => r.uri)) 1645 1645 } 1646 1646 return uris 1647 1647 } ··· 1652 1652 } 1653 1653 1654 1654 export async function getPreferences(did: string): Promise<Record<string, any>> { 1655 - const rows = await all(`SELECT key, value FROM _preferences WHERE did = $1`, [did]) 1655 + const rows = await all<{ key: string; value: string }>(`SELECT key, value FROM _preferences WHERE did = $1`, [did]) 1656 1656 const prefs: Record<string, any> = {} 1657 1657 for (const row of rows) { 1658 1658 try { 1659 - prefs[row.key as string] = typeof row.value === 'string' ? JSON.parse(row.value as string) : row.value 1659 + prefs[row.key] = typeof row.value === 'string' ? JSON.parse(row.value) : row.value 1660 1660 } catch { 1661 - prefs[row.key as string] = row.value 1661 + prefs[row.key] = row.value 1662 1662 } 1663 1663 } 1664 1664 return prefs ··· 1676 1676 export async function filterTakendownDids(dids: string[]): Promise<Set<string>> { 1677 1677 if (dids.length === 0) return new Set() 1678 1678 const placeholders = dids.map((_, i) => `$${i + 1}`).join(',') 1679 - const rows = await all(`SELECT did FROM _repos WHERE did IN (${placeholders}) AND status = 'takendown'`, dids) 1680 - return new Set(rows.map((r: any) => r.did)) 1679 + const rows = await all<{ did: string }>(`SELECT did FROM _repos WHERE did IN (${placeholders}) AND status = 'takendown'`, dids) 1680 + return new Set(rows.map((r) => r.did)) 1681 1681 }
+10 -10
packages/hatk/src/feeds.ts
··· 2 2 import { readdirSync } from 'node:fs' 3 3 import { log } from './logger.ts' 4 4 import { querySQL, packCursor, unpackCursor, isTakendownDid, filterTakendownDids } from './database/db.ts' 5 - import { resolveRecords, buildHydrateContext } from './hydrate.ts' 6 - import type { HydrateContext, Row } from './hydrate.ts' 5 + import { resolveRecords, buildBaseContext } from './hydrate.ts' 6 + import type { BaseContext, Row } from './hydrate.ts' 7 7 import type { Checked } from './lex-types.ts' 8 8 9 - export type { HydrateContext, Row } 9 + export type { BaseContext, Row } 10 10 11 11 export interface FeedResult { 12 12 uris: string[] ··· 25 25 } 26 26 27 27 export interface FeedContext { 28 - db: { query: (sql: string, params?: any[]) => Promise<any[]> } 28 + db: { query: (sql: string, params?: unknown[]) => Promise<unknown[]> } 29 29 params: Record<string, string> 30 30 cursor?: string 31 31 limit: number ··· 48 48 limit: number, 49 49 viewer: { did: string } | null, 50 50 ) => Promise<FeedResult> 51 - hydrate?: (ctx: HydrateContext) => Promise<unknown[]> 51 + hydrate?: (ctx: BaseContext, items: Row<unknown>[]) => Promise<unknown[]> 52 52 } 53 53 54 54 // --- Typed feed helper --- ··· 63 63 view?: string 64 64 label: string 65 65 generate: FeedGenerate 66 - hydrate?: (ctx: HydrateContext<any>) => Promise<unknown[]> 66 + hydrate?: (ctx: BaseContext, items: Row<any>[]) => Promise<unknown[]> 67 67 } 68 68 | { 69 69 collection?: never 70 70 view?: never 71 71 label: string 72 72 generate: FeedGenerate 73 - hydrate: (ctx: HydrateContext<any>) => Promise<unknown[]> 73 + hydrate: (ctx: BaseContext, items: Row<any>[]) => Promise<unknown[]> 74 74 } 75 75 76 76 export function createPaginate(deps: { 77 - db: { query: (sql: string, params?: any[]) => Promise<any[]> } 77 + db: { query: (sql: string, params?: unknown[]) => Promise<unknown[]> } 78 78 cursor?: string 79 79 limit: number 80 80 packCursor: (primary: string | number, cid: string) => string ··· 260 260 261 261 if (handler.hydrate) { 262 262 const items = await resolveRecords(result.uris) 263 - const ctx = buildHydrateContext(items, viewer || null) 264 - const hydrated = await handler.hydrate(ctx) 263 + const ctx = buildBaseContext(viewer || null) 264 + const hydrated = await handler.hydrate(ctx, items) 265 265 return { items: hydrated, cursor: result.cursor } 266 266 } 267 267
+4 -6
packages/hatk/src/hydrate.ts
··· 15 15 16 16 // --- Types --- 17 17 18 - export interface HydrateContext<T = unknown> { 19 - items: Row<T>[] 20 - viewer: { did: string } | null 18 + export interface BaseContext { 19 + viewer: { did: string; handle?: string } | null 21 20 db: { query: (sql: string, params?: unknown[]) => Promise<unknown[]> } 22 21 getRecords: <R = unknown>(collection: string, uris: string[]) => Promise<Map<string, Row<R>>> 23 22 lookup: <R = unknown>(collection: string, field: string, values: string[]) => Promise<Map<string, Row<R>>> ··· 73 72 74 73 // --- Context Builder --- 75 74 76 - /** Build a HydrateContext for a feed's hydrate function. */ 77 - export function buildHydrateContext(items: Row<unknown>[], viewer: { did: string } | null): HydrateContext { 75 + /** Build a BaseContext for hydration. */ 76 + export function buildBaseContext(viewer: { did: string; handle?: string } | null): BaseContext { 78 77 return { 79 - items, 80 78 viewer, 81 79 db: { query: querySQL }, 82 80 getRecords: getRecordsMap,
+3 -3
packages/hatk/src/labels.ts
··· 169 169 * @returns Count of records scanned and new labels applied 170 170 */ 171 171 export async function rescanLabels(collections: string[]): Promise<{ scanned: number; labeled: number }> { 172 - const beforeRows = await querySQL(`SELECT COUNT(*) as count FROM _labels`) 172 + const beforeRows = (await querySQL(`SELECT COUNT(*) as count FROM _labels`)) as { count: number }[] 173 173 const beforeCount = Number(beforeRows[0]?.count || 0) 174 174 175 175 let scanned = 0 ··· 178 178 const schema = getSchema(collection) 179 179 if (!schema) continue 180 180 181 - const rows = await querySQL(`SELECT * FROM ${schema.tableName}`) 181 + const rows = (await querySQL(`SELECT * FROM ${schema.tableName}`)) as Record<string, any>[] 182 182 for (const row of rows) { 183 183 scanned++ 184 184 const value: Record<string, any> = {} ··· 203 203 } 204 204 } 205 205 206 - const afterRows = await querySQL(`SELECT COUNT(*) as count FROM _labels`) 206 + const afterRows = (await querySQL(`SELECT COUNT(*) as count FROM _labels`)) as { count: number }[] 207 207 const afterCount = Number(afterRows[0]?.count || 0) 208 208 209 209 return { scanned, labeled: afterCount - beforeCount }
+1 -1
packages/hatk/src/main.ts
··· 140 140 141 141 // Detect orphaned tables 142 142 try { 143 - const existingTables = await querySQL(getSqlDialect().listTablesQuery) 143 + const existingTables = (await querySQL(getSqlDialect().listTablesQuery)) as { table_name: string }[] 144 144 for (const row of existingTables) { 145 145 const tableName = row.table_name 146 146 const isChildTable = collections.some((c) => tableName.startsWith(c + '__'))
+4 -4
packages/hatk/src/oauth/db.ts
··· 67 67 // --- Key Management --- 68 68 69 69 export async function getServerKey(kid: string): Promise<{ privateKey: string; publicKey: string } | null> { 70 - const rows = await querySQL('SELECT private_key, public_key FROM _oauth_keys WHERE kid = $1', [kid]) 70 + const rows = (await querySQL('SELECT private_key, public_key FROM _oauth_keys WHERE kid = $1', [kid])) as { private_key: string; public_key: string }[] 71 71 if (rows.length === 0) return null 72 - return { privateKey: rows[0].private_key as string, publicKey: rows[0].public_key as string } 72 + return { privateKey: rows[0].private_key, publicKey: rows[0].public_key } 73 73 } 74 74 75 75 export async function storeServerKey(kid: string, privateKey: string, publicKey: string): Promise<void> { ··· 147 147 } 148 148 149 149 export async function consumeAuthCode(code: string): Promise<string | null> { 150 - const rows = await querySQL('SELECT request_uri FROM _oauth_codes WHERE code = $1', [code]) 150 + const rows = (await querySQL('SELECT request_uri FROM _oauth_codes WHERE code = $1', [code])) as { request_uri: string }[] 151 151 if (rows.length === 0) return null 152 152 await runSQL('DELETE FROM _oauth_codes WHERE code = $1', [code]) 153 - return rows[0].request_uri as string 153 + return rows[0].request_uri 154 154 } 155 155 156 156 // --- Sessions ---
+3 -78
packages/hatk/src/opengraph.ts
··· 14 14 if (!_Resvg) _Resvg = (await import('@resvg/resvg-js')).Resvg 15 15 return _Resvg 16 16 } 17 - import { 18 - querySQL, 19 - runSQL, 20 - packCursor, 21 - unpackCursor, 22 - isTakendownDid, 23 - filterTakendownDids, 24 - searchRecords, 25 - findUriByFields, 26 - lookupByFieldBatch, 27 - countByFieldBatch, 28 - queryLabelsForUris, 29 - getRecordsMap, 30 - } from './database/db.ts' 31 - import { resolveRecords } from './hydrate.ts' 32 - import { blobUrl } from './xrpc.ts' 17 + import { buildXrpcContext } from './xrpc.ts' 33 18 import type { XrpcContext } from './xrpc.ts' 34 19 35 20 /** Virtual DOM node for satori rendering */ ··· 132 117 pattern, 133 118 paramNames, 134 119 execute: async (params) => { 135 - const ctx: XrpcContext = { 136 - db: { query: querySQL, run: runSQL }, 137 - params, 138 - input: {}, 139 - limit: 1, 140 - viewer: null, 141 - packCursor, 142 - unpackCursor, 143 - isTakendown: isTakendownDid, 144 - filterTakendownDids, 145 - search: searchRecords, 146 - resolve: resolveRecords as any, 147 - getRecords: getRecordsMap, 148 - lookup: async (collection, field, values) => { 149 - if (values.length === 0) return new Map() 150 - const unique = [...new Set(values.filter(Boolean))] 151 - return lookupByFieldBatch(collection, field, unique) as any 152 - }, 153 - count: async (collection, field, values) => { 154 - if (values.length === 0) return new Map() 155 - const unique = [...new Set(values.filter(Boolean))] 156 - return countByFieldBatch(collection, field, unique) 157 - }, 158 - exists: async (collection, filters) => { 159 - const conditions = Object.entries(filters).map(([field, value]) => ({ field, value })) 160 - const uri = await findUriByFields(collection, conditions) 161 - return uri !== null 162 - }, 163 - labels: queryLabelsForUris, 164 - blobUrl, 165 - } 120 + const ctx = buildXrpcContext(params, undefined, 1, null) 166 121 ;(ctx as any).fetchImage = async (url: string): Promise<string | null> => { 167 122 try { 168 123 const resp = await fetch(url, { redirect: 'follow' }) ··· 219 174 pattern, 220 175 paramNames, 221 176 execute: async (params) => { 222 - const ctx: XrpcContext = { 223 - db: { query: querySQL, run: runSQL }, 224 - params, 225 - input: {}, 226 - limit: 1, 227 - viewer: null, 228 - packCursor, 229 - unpackCursor, 230 - isTakendown: isTakendownDid, 231 - filterTakendownDids, 232 - search: searchRecords, 233 - resolve: resolveRecords as any, 234 - getRecords: getRecordsMap, 235 - lookup: async (collection, field, values) => { 236 - if (values.length === 0) return new Map() 237 - const unique = [...new Set(values.filter(Boolean))] 238 - return lookupByFieldBatch(collection, field, unique) as any 239 - }, 240 - count: async (collection, field, values) => { 241 - if (values.length === 0) return new Map() 242 - const unique = [...new Set(values.filter(Boolean))] 243 - return countByFieldBatch(collection, field, unique) 244 - }, 245 - exists: async (collection, filters) => { 246 - const conditions = Object.entries(filters).map(([field, value]) => ({ field, value })) 247 - const uri = await findUriByFields(collection, conditions) 248 - return uri !== null 249 - }, 250 - labels: queryLabelsForUris, 251 - blobUrl, 252 - } 177 + const ctx = buildXrpcContext(params, undefined, 1, null) 253 178 ;(ctx as any).fetchImage = async (url: string): Promise<string | null> => { 254 179 try { 255 180 const resp = await fetch(url, { redirect: 'follow' })
+37 -78
packages/hatk/src/xrpc.ts
··· 35 35 queryLabelsForUris, 36 36 getRecordsMap, 37 37 } from './database/db.ts' 38 - import { resolveRecords } from './hydrate.ts' 38 + import { resolveRecords, buildBaseContext } from './hydrate.ts' 39 + import type { BaseContext } from './hydrate.ts' 39 40 import { getLexicon } from './database/schema.ts' 40 41 import type { Row, FlatRow } from './lex-types.ts' 41 42 ··· 71 72 P = Record<string, string>, 72 73 Records extends Record<string, any> = Record<string, any>, 73 74 I = unknown, 74 - > { 75 + > extends BaseContext { 75 76 db: { 76 - query: (sql: string, params?: any[]) => Promise<any[]> 77 - run: (sql: string, ...params: any[]) => Promise<void> 77 + query: (sql: string, params?: unknown[]) => Promise<unknown[]> 78 + run: (sql: string, params?: unknown[]) => Promise<void> 78 79 } 79 80 params: P 80 81 input: I 81 82 cursor?: string 82 83 limit: number 83 - viewer: { did: string; handle?: string } | null 84 84 packCursor: (primary: string | number, cid: string) => string 85 85 unpackCursor: (cursor: string) => { primary: string; cid: string } | null 86 86 isTakendown: (did: string) => Promise<boolean> ··· 91 91 opts?: { limit?: number; cursor?: string; fuzzy?: boolean }, 92 92 ) => Promise<{ records: Row<Records[K]>[]; cursor?: string }> 93 93 resolve: <R = unknown>(uris: string[]) => Promise<Row<R>[]> 94 - getRecords: <R = unknown>(collection: string, uris: string[]) => Promise<Map<string, Row<R>>> 95 - lookup: <R = any>(collection: string, field: string, values: string[]) => Promise<Map<string, Row<R>>> 96 - count: (collection: string, field: string, values: string[]) => Promise<Map<string, number>> 97 94 exists: (collection: string, filters: Record<string, string>) => Promise<boolean> 98 - labels: (uris: string[]) => Promise<Map<string, any[]>> 99 - blobUrl: ( 100 - did: string, 101 - ref: unknown, 102 - preset?: 'avatar' | 'banner' | 'feed_thumbnail' | 'feed_fullsize', 103 - ) => string | undefined 104 95 } 105 96 106 97 /** Internal representation of a loaded XRPC handler module. */ ··· 140 131 return `https://cdn.bsky.app/img/${preset}/plain/${did}/${p.ref.$link}@jpeg` 141 132 } 142 133 134 + /** Build a full XrpcContext from request parameters. Reuses buildBaseContext for shared fields. */ 135 + export function buildXrpcContext( 136 + params: Record<string, string>, 137 + cursor: string | undefined, 138 + limit: number, 139 + viewer: { did: string; handle?: string } | null, 140 + input?: unknown, 141 + ): XrpcContext { 142 + const base = buildBaseContext(viewer) 143 + return { 144 + ...base, 145 + db: { query: querySQL, run: runSQL }, 146 + params, 147 + input: input || {}, 148 + cursor, 149 + limit, 150 + packCursor, 151 + unpackCursor, 152 + isTakendown: isTakendownDid, 153 + filterTakendownDids, 154 + search: searchRecords, 155 + resolve: resolveRecords as any, 156 + exists: async (collection, filters) => { 157 + const conditions = Object.entries(filters).map(([field, value]) => ({ field, value })) 158 + const uri = await findUriByFields(collection, conditions) 159 + return uri !== null 160 + }, 161 + } 162 + } 163 + 143 164 const handlers = new Map<string, XrpcHandler>() 144 165 145 166 /** Recursively collect .ts/.js files in a directory, skipping files prefixed with `_`. */ ··· 197 218 } 198 219 } 199 220 200 - const ctx: XrpcContext = { 201 - db: { query: querySQL, run: runSQL }, 202 - params, 203 - input: input || {}, 204 - cursor, 205 - limit, 206 - viewer, 207 - packCursor, 208 - unpackCursor, 209 - isTakendown: isTakendownDid, 210 - filterTakendownDids, 211 - search: searchRecords, 212 - resolve: resolveRecords as any, 213 - getRecords: getRecordsMap, 214 - lookup: async (collection, field, values) => { 215 - if (values.length === 0) return new Map() 216 - const unique = [...new Set(values.filter(Boolean))] 217 - return lookupByFieldBatch(collection, field, unique) as any 218 - }, 219 - count: async (collection, field, values) => { 220 - if (values.length === 0) return new Map() 221 - const unique = [...new Set(values.filter(Boolean))] 222 - return countByFieldBatch(collection, field, unique) 223 - }, 224 - exists: async (collection, filters) => { 225 - const conditions = Object.entries(filters).map(([field, value]) => ({ field, value })) 226 - const uri = await findUriByFields(collection, conditions) 227 - return uri !== null 228 - }, 229 - labels: queryLabelsForUris, 230 - blobUrl, 231 - } 221 + const ctx = buildXrpcContext(params, cursor, limit, viewer, input) 232 222 return handler.handler(ctx) 233 223 }, 234 224 }) ··· 260 250 } 261 251 } 262 252 263 - const ctx: XrpcContext = { 264 - db: { query: querySQL, run: runSQL }, 265 - params, 266 - input: input || {}, 267 - cursor, 268 - limit, 269 - viewer, 270 - packCursor, 271 - unpackCursor, 272 - isTakendown: isTakendownDid, 273 - filterTakendownDids, 274 - search: searchRecords, 275 - resolve: resolveRecords as any, 276 - getRecords: getRecordsMap, 277 - lookup: async (collection, field, values) => { 278 - if (values.length === 0) return new Map() 279 - const unique = [...new Set(values.filter(Boolean))] 280 - return lookupByFieldBatch(collection, field, unique) as any 281 - }, 282 - count: async (collection, field, values) => { 283 - if (values.length === 0) return new Map() 284 - const unique = [...new Set(values.filter(Boolean))] 285 - return countByFieldBatch(collection, field, unique) 286 - }, 287 - exists: async (collection, filters) => { 288 - const conditions = Object.entries(filters).map(([field, value]) => ({ field, value })) 289 - const uri = await findUriByFields(collection, conditions) 290 - return uri !== null 291 - }, 292 - labels: queryLabelsForUris, 293 - blobUrl, 294 - } 253 + const ctx = buildXrpcContext(params, cursor, limit, viewer, input) 295 254 return handlerModule.handler(ctx) 296 255 }, 297 256 })