feat(read-api): GET endpoints for projects, people, tags + FTS + serializers#22
Merged
Conversation
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
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>
e6a0500 to
aeb7254
Compare
This was referenced May 16, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
GETendpoint across projects, people, tags, project-updates, project-buzz, and help-wanted roles, wired through thin route handlers → entity services → response serializers.Maps + secondary indices) loaded at boot from gitsheets, abetter-sqlite3FTS5 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).services/permissions.ts, readingrequest.session?.personso this lands cleanly alongsideauth-jwt-substrate(which is in parallel).Test plan
GET /api/projectsreturns the documented shape includingmetadata.facetsGET /api/projects?stage=…&tag=…filters; facets reflect the unfiltered corpusGET /api/projects?q=…returns via FTSGET /api/projects/:slugreturns the full Project shape (memberships, tags, open help-wanted, counts, permissions)GET /api/projects/nope→404 not_foundGET /api/people[, /:slug]happy + 404GET /api/tags[, /:handle, /:handle/projects, /:handle/people]happy + 404GET /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=)422 validation_failedoverviewHtml,bodyHtml,descriptionHtml,summaryHtml,bioHtml) come back sanitizedpermissions.canEditisfalsefor anonymous callers on project detailnpm run lint,npm run type-check,npm test,npm run buildall green