Skip to content

fix(api,editor): per-request content-access checker + parallel entry listing; navigator loading state#78

Merged
jpslav merged 6 commits into
mainfrom
fix/entries-list-perf-and-loading-state
Jun 13, 2026
Merged

fix(api,editor): per-request content-access checker + parallel entry listing; navigator loading state#78
jpslav merged 6 commits into
mainfrom
fix/entries-list-perf-and-loading-state

Conversation

@jpslav

@jpslav jpslav commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Summary

Two user-facing improvements to the editor's entry list, plus supporting test/infra cleanup.

1. Performance — per-request batch content-access checker

The entry-list endpoint re-loaded path permissions and re-resolved the settings-branch root once per entry (every checkContentAccess call ran getSettingsBranchRoot + a permission load). On a branch with many collections this took tens of seconds.

  • New createContentAccessChecker(deps, context, branchRoot, user) (in authorization/content.ts) does the request-constant work once — branch access, settings-root resolution, permission-rule load — and returns a synchronous (relativePath, level) => ContentAccessResult checker. checkContentAccess now delegates to it (signature/behavior unchanged).
  • Exposed on CanopyServices; adopted by the three looping endpoints: api/entries.ts, api/reference-options.ts, api/resolve-references.ts.
  • entries.ts also lists collections in parallel (Promise.all, matching the recursive path and content-listing.ts); array ordering and filter-before-slice pagination are preserved.
  • Per-request scope (not a process-global cache) sidesteps stale-ACL invalidation — see ARCHITECTURE.md.

2. UX — navigator / empty-pane loading state

  • EntryNavigator gains a loading? prop; a new entriesInitializing flag in useEntryManager is seeded from the branch prop (true on the first render) so the empty pane shows "Loading content…" instead of flashing "No content" / "Select an item…". It clears when the first per-branch load settles, so a genuinely empty branch falls back to the normal empty state (no stuck loader).
  • The branch-load .finally is seq-guarded (branchLoadSeqRef) so a superseded load from a rapid branch switch can't clear the flag early.

3. Test & infra cleanup

  • resolve-references now surfaces a settings-workspace failure instead of swallowing it into a 200 {} (previously indistinguishable from "no references"). Covered by a new test.
  • openBareRepo() test helper sets safe.bareRepository=all so git-manager.test.ts can inspect bare simulated-remotes in environments that inject safe.bareRepository=explicit (sandboxed/CI git). Product code already handles this via explicit --git-dir.
  • MockServicesOptions retyped from a bag of any to Partial<Omit<CanopyServices, 'config'>>; the hand-rolled 14-field mock-service objects in content.test.ts (4 sites) and assets.test.ts migrated onto createMockApiContext.

Follow-up (documented, not in this PR)

  • BACKLOG Fix/package normalization #19: content listing uses unbounded Promise.all FS fan-out (consistent across all listing sites). Intentional; a bounded-concurrency helper is deferred unless prod EFS throttling appears.

Testing

  • Full suite green: typecheck, lint (0 errors), prettier, ~2090 tests.
  • New coverage: per-request "loaded once" assertions, checkContentAccess parity, the entriesInitializing lifecycle (including the superseded-load race), navigator loading states, and the resolve-references error-surfacing.

Review

Reviewed across two independent sub-reviews; no Critical/High issues. The one behavioral note — the checker now builds eagerly, so an empty list/refs request does one extra (per-request-cached) getSettingsBranchRoot — is a consciously-accepted trade-off.

jpslav added 6 commits June 13, 2026 12:36
…g; navigator loading state

GET /:branch/entries took ~45s per editor load on a branch with ~31
collections. Two compounding causes: the handler iterated collections
serially, and access control re-loaded permissions (and re-resolved the
settings-branch root) once per entry — both constant per request.

- authorization: add createContentAccessChecker, a per-request batch
  primitive that resolves branch access, the settings root, and the
  permission rules ONCE and returns a synchronous per-path checker.
  checkContentAccess now delegates to it (public API unchanged). Chosen
  over a process-global cache to avoid serving stale ACLs after a
  permissions edit and to mirror the prod Lambda model.
- api/entries: build one checker per request, make filterWithAccessControl
  synchronous, and parallelize the per-collection loop with Promise.all
  (mirrors the shared listEntries). Pagination stays filter-then-slice.
- api/resolve-references, api/reference-options: same anti-pattern removed
  (build the checker once before the loop).
- editor: thread entries-loading into EntryNavigator (loading prop) and the
  preview/form empty state so "No content" / "Select an item" no longer
  show while content is still loading.
- tests: createTestContentAccess helper; once-per-request and
  pagination-with-filter coverage; EntryNavigator loading-state tests.
- docs: note the batch checker in ARCHITECTURE.md and CODEBASE_GUIDE.md.
…or surfacing

Address branch-review finding 3 (test gap) for the two reference endpoints
whose behavior this branch deliberately changed:

- reference-options: assert createContentAccessChecker is built once per
  request even with multiple options (guards against reintroducing per-item
  permission loads).
- resolve-references: assert a settings-root build failure now surfaces
  (rejects) instead of being swallowed into a 200 with an empty resolved map.
…search filters

Branch-review follow-up. In filterWithAccessControl the synchronous edit-access check now runs after the read and search filters instead of before, so it is skipped for entries that are filtered out. Behavior-preserving.

Also slim the authorization file table in CODEBASE_GUIDE.md: the long createContentAccessChecker description moves from a wide table cell into the existing "Batch primitive" prose note (no information lost).
…ng…" not a flash

The empty editor pane and navigator keyed "Loading content…" off the shared entriesLoading busy flag, which starts false and only flips true inside a post-mount effect — so initial load briefly flashed "Select an item to start editing." first.

Add a dedicated entriesInitializing flag to useEntryManager, seeded from the branch prop so it is already true on the first render (no flash) and cleared when the first per-branch load settles (so a genuinely empty branch falls back to the normal empty state instead of a stuck loader). Editor's empty-pane copy and the navigator loading prop key off this flag. Covered by useEntryManager tests.

Also document the deliberate unbounded read-concurrency trade-off in content listing (BACKLOG #19 + an inline note in api/entries.ts) so it isn't reinvestigated.
… helper

git-manager.test.ts inspected bare repos with simpleGit({ baseDir }), which fails under safe.bareRepository=explicit (injected by sandboxed/CI git environments) — git refuses cwd-based discovery of bare repos. Add an openBareRepo() test helper that sets safe.bareRepository=all for its own invocations (safe: the path is one
the test created) and route the bare-repo inspection sites through it. Product code already handles hostile-env git via explicit --git-dir; no product change.

Files: test-utils/git-helpers.ts, test-utils/index.ts, git-manager.test.ts
Retype MockServicesOptions from a bag of `any` fields to
`Partial<Omit<CanopyServices, 'config'>> & { config?: Partial<CanopyConfig> }`, so
wrong-shape overrides are caught at compile time (vitest mocks stay assignable). The
intentionally-partial mock GitManager default keeps one narrow cast; make
refreshActiveBranch overridable for consistency.

Migrate the hand-rolled 14-field services objects in api/content.test.ts (4 sites)
and api/assets.test.ts (makeCtx) onto createMockApiContext + allowContentAccess, so
new service fields no longer need to be added to each test. Behavior preserved
(handlers read branchContext.flatSchema / config.contentRoot, never config.schema).
@jpslav jpslav requested a review from debshila June 13, 2026 22:59
@jpslav jpslav merged commit 6dbe6b1 into main Jun 13, 2026
5 checks passed
@jpslav jpslav deleted the fix/entries-list-perf-and-loading-state branch June 13, 2026 23:07
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.

2 participants