Skip to content

feat(read-api): GET endpoints for projects, people, tags + FTS + serializers#22

Merged
themightychris merged 9 commits into
mainfrom
feat/read-api
May 16, 2026
Merged

feat(read-api): GET endpoints for projects, people, tags + FTS + serializers#22
themightychris merged 9 commits into
mainfrom
feat/read-api

Conversation

@themightychris
Copy link
Copy Markdown
Member

Summary

  • Every documented GET endpoint across projects, people, tags, project-updates, project-buzz, and help-wanted roles, wired through thin route handlers → entity services → response serializers.
  • In-memory state (typed Maps + secondary indices) loaded at boot from gitsheets, a better-sqlite3 FTS5 engine for ?q= over projects, people, and help-wanted roles, and a project/people facet cache computed over the unfiltered corpus (invalidated on each boot so tests stay clean).
  • Permission hints centralized in services/permissions.ts, reading request.session?.person so this lands cleanly alongside auth-jwt-substrate (which is in parallel).

Test plan

  • GET /api/projects returns the documented shape including metadata.facets
  • GET /api/projects?stage=…&tag=… filters; facets reflect the unfiltered corpus
  • GET /api/projects?q=… returns via FTS
  • GET /api/projects/:slug returns the full Project shape (memberships, tags, open help-wanted, counts, permissions)
  • GET /api/projects/nope404 not_found
  • GET /api/people[, /:slug] happy + 404
  • GET /api/tags[, /:handle, /:handle/projects, /:handle/people] happy + 404
  • GET /api/projects/:slug/updates[, /:number] + /api/project-updates (global feed)
  • GET /api/projects/:slug/buzz + /api/project-buzz (global feed)
  • GET /api/projects/:slug/help-wanted + /api/help-wanted (cross-project + facets + ?q=)
  • Pagination + default sort honored; unknown sort key → 422 validation_failed
  • Markdown fields (overviewHtml, bodyHtml, descriptionHtml, summaryHtml, bioHtml) come back sanitized
  • permissions.canEdit is false for anonymous callers on project detail
  • npm run lint, npm run type-check, npm test, npm run build all green

themightychris added a commit that referenced this pull request May 16, 2026
- plans/read-api.md: status → done, pr: 22, tick verified validation
  criteria, leave permissions-across-roles unchecked with a Notes
  one-liner explaining it depends on auth-jwt-substrate to mint
  sessions in tests (logic-level test closes out alongside write-api)
- plans/write-api.md: absorb two read-api deferrals
  - new Approach: invalidateFacets() + FTS upsert/remove hooks on every
    relevant mutation; authenticated permissions integration check
  - new Validation criteria for both
- Follow-ups recorded: write-api absorbs the two deferrals;
  Issue #23 tracks the MiniSearch fallback decision
themightychris and others added 9 commits May 16, 2026 17:35
npm install --workspace=apps/api better-sqlite3
npm install --workspace=apps/api --save-dev @types/better-sqlite3
Boot-time pipeline for read-api: load every gitsheets sheet into typed Maps
plus secondary indices, build an in-memory SQLite FTS5 index for project,
person, and help-wanted full-text search, and cache project/people facet
counts over the unfiltered corpus so filtered list responses don't
whipsaw their sidebars.

- store/memory/state.ts — primary Maps + secondary indices + index helpers
  used by both the boot loader and (eventually) the write-api mutation path
- store/memory/loader.ts — boot loader: queryAll() across all sheets and
  populate state
- store/memory/facets.ts — computed-once project + people facets with an
  invalidation hook for write-api to call after a mutation
- store/fts.ts — better-sqlite3 backed FTS5 engine with porter/ascii
  tokenization, an upsert/remove/search interface, and per-word phrase
  quoting to sanitize user queries
Each entity (project, person, tag, project-update, project-buzz,
help-wanted) gets a service class with list + get methods that operate
on the in-memory state from storage-foundation, and a serializer that
converts the raw record into the documented response shape.

- services/{project,person,tag,project-update,project-buzz,help-wanted}.ts
  filter/sort/paginate against the in-memory Maps. q-search routes through
  the FTS engine. AND-combine repeated tag filters. Default sort + allowed
  sort keys match each endpoint's spec.
- services/permissions.ts — centralizes canEdit/canDelete/etc. so the
  rules don't scatter across route handlers; reads request.session?.person
  with optional chaining so it works before auth-jwt-substrate lands
- services/serializers/ — one file per shape (ProjectListItem, Project,
  PersonListItem, Person, Tag, ProjectUpdate, ProjectBuzz, HelpWantedRole)
  plus common helpers for PersonAvatar, tag grouping, and markdown
  rendering. *Html/*Excerpt fields come from renderMarkdown in
  @cfp/shared so clients never run a markdown library on user content.
- plugins/services.ts loads in-memory state and the FTS engine after the
  store plugin and decorates fastify.services with the entity service
  instances. Invalidates the module-level facet cache on every boot so
  tests that build multiple app instances see fresh state.
- lib/session.ts declares the FastifyRequest.session augmentation that
  auth-jwt-substrate will populate. Until that plan lands, getCallerSession
  is the single read site for the optional caller so handlers and
  permission helpers don't need to know whether auth has landed yet.
- app.ts: register servicesPlugin after storePlugin and mount the
  projects, people, tags, project-updates, project-buzz, and help-wanted
  route files.
Thin route handlers that parse query params, call the entity service,
map service errors (not_found/invalid_sort/invalid_filter) onto the
documented API error envelope, and return the success envelope with the
appropriate metadata.

- /api/projects[, /:slug] with filters (stage, tag, maintainer,
  memberSlug, helpWanted, featured, includeDeleted), q (FTS), default
  sort -updatedAt, and metadata.facets over the unfiltered corpus
- /api/people[, /:slug] with q (FTS), tag, accountLevel (staff-only)
- /api/tags[, /:handle, /:handle/projects, /:handle/people] — last two
  delegate to the projects / people lists with the tag handle pre-applied
- /api/projects/:slug/updates[, /:number] + /api/project-updates global
  feed with since + tag filters
- /api/projects/:slug/buzz + /api/project-buzz global feed
- /api/projects/:slug/help-wanted + /api/help-wanted cross-project
  browse with status, tag, commitmentMax, q (FTS), and tag-namespace
  facets

Anonymous callers can hit every endpoint per the public-by-design civic
transparency stance in specs/architecture.md. Permission hints in
detail responses fall back to all-false until auth-jwt-substrate
populates the caller session.
End-to-end tests against the validation criteria in plans/read-api.md:
every documented GET endpoint with a fixture-seeded happy path plus a
not-found / 422 unhappy path, the metadata.facets shape, ?q= FTS,
?sort=-updatedAt, ?tag=, pagination, rendered *Html markdown fields,
and the permissions block on the project detail response.

- tests/helpers/seed-fixtures.ts uses the raw gitsheets Repository
  (no validators) so it can emit the denormalized path fields
  (projectSlug, personSlug, etc.) the sheet configs expect alongside
  the canonical IDs the Zod schemas require. In production write-api
  will derive these from the in-memory index at write time.
- tests/read-api.test.ts uses inject() against the full app booted
  with the filesystem private-store backend.
- vitest.config.ts: fileParallelism: false. The git-heavy boot
  sequence + module-singleton state (facet cache) cross-pollute
  between parallel files; serializing file execution is the cleanest
  fix and individual file runtime is already dominated by gitsheets
  boot, not test work.
The `apps/api/src/lib/session.ts` shim was introduced when `read-api` and
`auth-jwt-substrate` were running in parallel; it declared
`request.session?.person?: CallerSession` as a forward-compat for
auth-jwt-substrate to fill in.

Rebasing onto a main that includes auth-jwt-substrate, the shim's
declaration conflicts with the real one
(`request.session: SessionContext` with `person: Person | null`). Drop
the shim; route handlers import `getCallerSession()` from
`services/permissions.ts` instead, which now derives the minimal
`CallerSession` (`{ id, accountLevel }`) from the full `Person` on
`request.session.person`.

No behavior change — anonymous and claim-pending sessions still
short-circuit to `undefined`; only the layering is different.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@themightychris themightychris merged commit b80936d into main May 16, 2026
1 check passed
@themightychris themightychris deleted the feat/read-api branch May 16, 2026 21:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant