Skip to content

fix(admin/spa): drop redundant abort guard and add Modal a11y#650

Merged
bootjp merged 2 commits intomainfrom
feat/admin-dashboard-spa-followups
Apr 26, 2026
Merged

fix(admin/spa): drop redundant abort guard and add Modal a11y#650
bootjp merged 2 commits intomainfrom
feat/admin-dashboard-spa-followups

Conversation

@bootjp
Copy link
Copy Markdown
Owner

@bootjp bootjp commented Apr 26, 2026

Summary

Two follow-ups from the #649 review that were intentionally deferred to keep that PR focused on the functional bugs.

Stacked on feat/admin-dashboard-spa (#649). Once #649 merges, this rebases cleanly onto main.

1. useApi.ts — drop the redundant abort guard

The catch handler had two checks back-to-back:

if (cancelled) return;
if (ctrl.signal.aborted) return;  // ← unreachable

The cleanup function sets cancelled = true before calling ctrl.abort(), so any abort path is already covered by the first check. Dropped the second guard and recorded why one check is enough in a comment.

2. Modal.tsx — full WAI-ARIA dialog contract + focus trap

Added:

  • role="dialog", aria-modal="true", aria-labelledby on the dialog root (uses useId() for the title id).
  • Focus management:
    • On open: store the previously-focused element, focus the first focusable inside the dialog.
    • Tab / Shift+Tab cycle within the dialog instead of escaping to the page underneath.
    • On close: restore focus to whoever opened the dialog (skipped if that element was unmounted by the dialog action — e.g. the dialog deleted the row whose button opened it).
    • Empty-dialog fallback focuses the dialog container itself with tabindex=-1.

Vanilla DOM implementation — focus-trap-react / react-focus-lock would have been 5–7 KB gzip for what is a ~40 line solution.

Bundle delta: +0.4 KB gzip (60.23 → 60.59 KB JS, 14.55 → 14.57 KB CSS).

Test plan

  • cd web/admin && npm install && npm run build
  • cd web/admin && npm run linttsc --strict
  • go build ./...
  • Manual smoke (after feat(admin): React + Vite SPA for the admin dashboard (P3) #649 merges and the dashboard is visitable):
    • Open Create Table / Delete Table dialog → focus lands inside the dialog.
    • Tab past the last button → wraps back to the close button.
    • Shift+Tab from the close button → wraps to the last form field.
    • Escape closes (when not busy) and focus returns to the trigger button.
    • Screen reader announces the dialog name from the title text.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 26, 2026

Warning

Rate limit exceeded

@bootjp has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 58 minutes and 16 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 58 minutes and 16 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d9ec74f6-c920-4227-9049-a1717240fdc7

📥 Commits

Reviewing files that changed from the base of the PR and between bf67fa5 and 086480c.

📒 Files selected for processing (2)
  • web/admin/src/components/Modal.tsx
  • web/admin/src/lib/useApi.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/admin-dashboard-spa-followups

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request enhances the Modal component with accessibility features, including ARIA roles, focus trapping, and focus restoration. It also simplifies error handling in useApiQuery by removing redundant abort signal checks. A significant issue was identified in the Modal component's focus management logic: because the effect is dependent on the busy state, focus is reset to the first element whenever the modal's busy status changes, which disrupts the user experience during form submissions or other asynchronous actions.

Comment on lines 33 to 81
useEffect(() => {
if (!open) return;

previouslyFocusedRef.current = (document.activeElement as HTMLElement | null) ?? null;
// Focus the first focusable element so keyboard users land
// inside the dialog instead of the page underneath. Run on the
// next tick so the dialog DOM exists and any autofocus has had
// a chance to settle.
queueMicrotask(() => focusFirstFocusable(dialogRef.current));

const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && !busy) onClose();
if (e.key === "Escape" && !busy) {
onClose();
return;
}
if (e.key !== "Tab") return;
const root = dialogRef.current;
if (!root) return;
const focusables = focusableElements(root);
if (focusables.length === 0) {
// Empty dialog: keep Tab from leaving via the page.
e.preventDefault();
return;
}
const first = focusables[0];
const last = focusables[focusables.length - 1];
const active = document.activeElement;
if (e.shiftKey && active === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && active === last) {
e.preventDefault();
first.focus();
}
};

window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
return () => {
window.removeEventListener("keydown", onKey);
// Restore focus to whoever opened the dialog. Guard against
// the trigger being unmounted (e.g. the dialog deleted the
// row whose button opened it) by checking isConnected.
const restore = previouslyFocusedRef.current;
previouslyFocusedRef.current = null;
if (restore && restore.isConnected) {
restore.focus();
}
};
}, [open, busy, onClose]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The focus management logic is currently bundled with the busy and onClose dependencies. This causes the focus to be reset to the first element and the "previously focused" element to be captured again every time the modal's busy state changes (e.g., during a save operation). This results in a poor user experience where focus jumps back to the start of the form unexpectedly and causes focus flicker for screen readers.

Focus capture and restoration should be tied strictly to the open state transition, while the keyboard listener can remain reactive to busy and onClose changes.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b36cfed7b4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread web/admin/src/components/Modal.tsx Outdated
Comment on lines +70 to +78
return () => {
window.removeEventListener("keydown", onKey);
// Restore focus to whoever opened the dialog. Guard against
// the trigger being unmounted (e.g. the dialog deleted the
// row whose button opened it) by checking isConnected.
const restore = previouslyFocusedRef.current;
previouslyFocusedRef.current = null;
if (restore && restore.isConnected) {
restore.focus();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore focus only when modal actually closes

The effect cleanup restores focus unconditionally, but this cleanup also runs whenever dependencies change while the modal is still open (not just on close). In this codebase, onClose is passed inline in modal callers and busy can toggle during actions, so re-renders while open will run this block and move focus out of the dialog mid-interaction, breaking keyboard flow and screen-reader context. Focus restoration should be gated to the open→closed transition (or split into a dedicated close-only effect).

Useful? React with 👍 / 👎.

@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented Apr 26, 2026

@claude review

@claude
Copy link
Copy Markdown

claude Bot commented Apr 26, 2026

Claude finished @bootjp's task in 2m 11s —— View job


Code Review

  • Gather context
  • Read changed files
  • Review useApi.ts
  • Review Modal.tsx

useApi.ts — ✅ Clean

The removed guard was genuinely unreachable. The cleanup always executes cancelled = true before ctrl.abort() (lines 60–61), so by the time the catch handler fires the if (cancelled) check on line 50 already covers every abort path. The added comment records the invariant clearly. No issues.


Modal.tsx — one real bug, a few minor notes

🔴 Effect dependency conflation breaks focus on every busy toggle

Modal.tsx:81 — The single effect mixes focus capture/restoration (which should be tied to open) with the keyboard handler (which legitimately needs busy and onClose). Because busy is in the dep array, React runs the cleanup whenever busy changes while the modal is still open — e.g. when the user clicks Save and the parent sets busy = true:

  1. Cleanup fires: removes keydown listener, then calls restore.focus()focus leaves the dialog (lands on the trigger button outside the modal).
  2. Effect re-runs: re-captures document.activeElement (now the trigger button) as the new restore target, queues focusFirstFocusable → focus snaps back in.
  3. Repeat on busy false → true → false.

Net result: focus flickers out and back on every in-flight operation; previouslyFocusedRef gets clobbered with the wrong element; when the dialog closes, focus may go to the wrong place. All three automated reviewers (gemini, codex, and this one) flag this.

The fix is a two-effect split:

// Tied to open only — runs once on open, once on close
useEffect(() => {
  if (!open) return;
  previouslyFocusedRef.current = (document.activeElement as HTMLElement | null) ?? null;
  queueMicrotask(() => focusFirstFocusable(dialogRef.current));
  return () => {
    const restore = previouslyFocusedRef.current;
    previouslyFocusedRef.current = null;
    if (restore?.isConnected) restore.focus();
  };
}, [open]);

// Tied to busy + onClose — re-registers the handler when either changes
useEffect(() => {
  if (!open) return;
  const onKey = (e: KeyboardEvent) => {
    if (e.key === "Escape" && !busy) { onClose(); return; }
    if (e.key !== "Tab") return;
    // ... tab cycling unchanged
  };
  window.addEventListener("keydown", onKey);
  return () => window.removeEventListener("keydown", onKey);
}, [open, busy, onClose]);

Fix this →


🟡 queueMicrotask is harmless but slightly misleading

Modal.tsx:41 — The comment says "Run on the next tick so the dialog DOM exists." Effects already run after the browser has painted, so dialogRef.current is set by the time the effect body executes — the DOM is there unconditionally. The queue is not needed for DOM presence, but it does give child useEffect hooks a chance to settle. Either keep it with a corrected comment ("let child effects settle before stealing focus") or call focusFirstFocusable directly. Low priority.


✅ Everything else looks good

  • role="dialog" + aria-modal="true" + aria-labelledby={titleId} — correct WAI-ARIA contract.
  • useId() for the title id — correct React 18+ pattern; avoids SSR collisions.
  • isConnected guard on focus restore — correct (handles the "dialog deleted its own trigger row" case documented in the PR).
  • focusableSelector — appropriately narrow; [tabindex="-1"] correctly excluded from the cycle.
  • tabindex="-1" fallback on empty dialog root — correct; browser accepts the focus call without making it a tab stop.
  • The × close button has disabled={busy}, so even though onClick={onClose} doesn't gate on busy, the disabled attribute prevents the click while busy. Consistent.
  • useApi.ts comment is clear and accurate.

Summary: useApi.ts is ready. Modal.tsx needs the two-effect split before merging — the current single effect produces real (if brief) focus-leaving events on every busy toggle, which is harmful for screen reader users.

@bootjp bootjp force-pushed the feat/admin-dashboard-spa branch from 6349276 to e3b9555 Compare April 26, 2026 09:33
@chatgpt-codex-connector
Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented Apr 26, 2026

@claude review

685f410 addresses the Modal focus-management bug from your previous review. The single useEffect is now split into two:

  • Focus capture/restore — deps [open] only. previouslyFocusedRef stays valid across the whole dialog lifetime; no clobber on busy toggle.
  • Keyboard handler — deps [open, busy, onClose]. Re-binds on those changes; only side effect is one window listener swap.

The dist/ placeholder index.html is intentionally NOT regenerated (per .gitignore: assets are built at deploy time, not committed).

@claude
Copy link
Copy Markdown

claude Bot commented Apr 26, 2026

Claude finished @bootjp's task in 1m 28s —— View job


Code Review

  • Gather context
  • Read Modal.tsx (post-fix)
  • Read useApi.ts
  • Verify two-effect split correctness
  • Post review

Modal.tsx — ✅ Fix is correct

The two-effect split addresses the clobbering bug precisely.

Effect 1 ([open])Modal.tsx:42–60

The invariant now holds: previouslyFocusedRef is written exactly once per open/close cycle. When busy toggles, Effect 1 does not re-run, so the ref is never clobbered with a stale element. The cleanup fires only on open → false, at which point restore.focus() targets the right element.

The queueMicrotask comment is now accurate ("let sibling useEffect hooks settle, notably autofocus on form fields") — the prior wording about DOM presence was the only nit from the last review, and it's fixed.

Effect 2 ([open, busy, onClose])Modal.tsx:66–95

Correctly isolated: re-binding the listener when busy changes has no observable side effect for the user. The !open early-return prevents stale listeners from accumulating across open cycles.

One micro-observation (not a blocker): React runs effect cleanups in source order, so on close, Effect 1's cleanup (focus restore) fires before Effect 2's cleanup (listener remove). There's a theoretically brief window where the keydown listener is still active after focus has moved to the trigger. In practice this is a non-issue — React processes the state flush synchronously before any keydown event could arrive — but worth knowing if you ever see a double-close in a pathological test environment.


useApi.ts — ✅ Clean

Comment at line 47–49 is precise: cancelled = true is set synchronously before ctrl.abort(), so the cancelled check already catches every abort path. The eslint-disable on line 68 is appropriate and the inline explanation is sufficient.


Summary: Both changes are correct. The focus-management bug is resolved cleanly. Ready to merge once #649 lands.

Base automatically changed from feat/admin-dashboard-spa to main April 26, 2026 09:41
bootjp added 2 commits April 26, 2026 19:05
Two follow-ups from the review of #649:

1. useApi.ts: removed the `if (ctrl.signal.aborted) return;` check
   inside the catch handler. The cleanup function sets
   `cancelled = true` *before* calling `ctrl.abort()`, so any abort
   path is already covered by the preceding `cancelled` check. The
   second guard was harmless belt-and-suspenders dead code; the
   comment now records why one check is enough.

2. Modal.tsx: added the WAI-ARIA dialog contract (`role="dialog"`,
   `aria-modal="true"`, `aria-labelledby` pointing at the title)
   and a focus trap:
   - On open: store the previously-focused element, then focus the
     first focusable inside the dialog so keyboard users do not stay
     stranded on the page underneath. Empty dialogs fall back to
     focusing the dialog container itself with tabindex=-1.
   - Tab / Shift+Tab cycle within the dialog instead of escaping to
     the page underneath.
   - On close: restore focus to whoever opened the dialog (skipped
     when that element has been unmounted, e.g. the dialog deleted
     the row whose button opened it).
   - Escape and backdrop click stay wired through the existing
     `busy` gate so a half-applied operation cannot be dismissed.

   Implementation is vanilla DOM querying — focus-trap-react /
   react-focus-lock would have added 5–7 KB gzip for what is a
   ~40 line vanilla solution. Bundle delta from this PR is +0.4 KB
   gzip (60.23 -> 60.59 KB).

Verified with `npm run build` and `npm run lint` (tsc --strict
--noUnusedLocals --noUncheckedSideEffectImports), and `go build ./...`.

Stacked on top of #649 (feat/admin-dashboard-spa); merge that one
first, then this rebases cleanly onto main.
Claude review on b36cfed caught a real focus-management bug: the
single useEffect in Modal.tsx mixed two invariants — focus
capture/restore (open) and the keyboard handler (open + busy +
onClose) — into one effect with deps [open, busy, onClose].

Concrete failure: user clicks Save, parent flips busy=true. React
runs the cleanup, which removes the keydown listener AND restores
focus to the previously-focused element (the trigger button, outside
the dialog). The effect then re-runs, captures the trigger button
as the NEW restore target, and snaps focus back. Net: focus
flickers out and back on every busy toggle, previouslyFocusedRef
gets clobbered with the wrong element, and screen readers announce
the dialog as briefly "exited" mid-operation.

Fix: split into two effects.
- Focus capture/restore — deps [open] only. Runs once on open, once
  on close. previouslyFocusedRef stays valid for the whole dialog
  lifetime.
- Keyboard handler — deps [open, busy, onClose]. Re-binds when
  any of those change; only observable side effect is one window
  listener swap.

All three automated reviewers (Claude, Gemini, Codex) flagged this.
The split is the standard React idiom — each invariant owns its own
state slice.

The dist/ placeholder index.html is intentionally NOT regenerated
(per .gitignore: assets are built at deploy time, not committed).
@bootjp bootjp force-pushed the feat/admin-dashboard-spa-followups branch from 685f410 to 086480c Compare April 26, 2026 10:05
@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented Apr 26, 2026

@claude review

Rebased onto main (086480c) now that #649 is merged. Dropped the 2 commits that landed via #649's squash merge; kept the 2 commits unique to this PR (4569191 abort-guard cleanup + Modal a11y, 086480c effect-split focus fix). Build passes.

@claude
Copy link
Copy Markdown

claude Bot commented Apr 26, 2026

Claude finished @bootjp's task in 1m 22s —— View job


Code Review

  • Gather context
  • Read changed files (post-rebase state)
  • Review useApi.ts
  • Review Modal.tsx
  • Post final review

useApi.ts — ✅ Clean

The removed guard and the replacement comment are correct. cancelled = true is set synchronously at line 60 before ctrl.abort() at line 61, so the if (cancelled) return on line 50 covers every abort path. No second check needed. The eslint-disable on line 68 is well-justified by the surrounding comment.


Modal.tsx — ✅ Correct

Two-effect split (the main fix)

Effect 1 ([open], lines 42–60) and Effect 2 ([open, busy, onClose], lines 66–95) are now properly separated. The invariant the previous review asked for holds:

  • previouslyFocusedRef is written exactly once per open/close cycle — busy toggles no longer re-run the capture.
  • Cleanup (focus restore) fires only on the open → false transition.
  • isConnected guard correctly handles the "dialog deleted its own trigger row" case.
  • queueMicrotask comment is accurate — "let sibling effects settle" is the right frame.

WAI-ARIA

All required attributes are in place: role="dialog", aria-modal="true", aria-labelledby={titleId}, and the title <div> carries the matching id. useId() is the right React 18 pattern.

Focus trap

  • focusableSelector correctly excludes [tabindex="-1"] targets.
  • Shift+Tab from first → last and Tab from last → first are both covered.
  • Empty-dialog fallback promotes the container to a focusable via a dynamic tabindex="-1". The if (!open) return null on line 97 ensures the container unmounts when closed, so the dynamically-added attribute never lingers across open cycles.

One minor observation (not a blocker)

onClose in Effect 2's dep array will re-register the window listener whenever the caller passes a new function identity. If the parent renders an inline arrow, that's every render. The cost is trivial (one listener swap), and the alternatives (a useRef wrapper for onClose) add complexity with no observable benefit here. Worth knowing if you ever see DevTools warnings about frequent listener churn, but there's nothing to change now.


Summary: Both files are correct. The two-effect split resolves the focus-flicker bug cleanly. Ready to merge.

@bootjp bootjp merged commit 1e0a924 into main Apr 26, 2026
9 checks passed
@bootjp bootjp deleted the feat/admin-dashboard-spa-followups branch April 26, 2026 11:06
bootjp added a commit that referenced this pull request Apr 26, 2026
Per `docs/design/README.md`'s lifecycle convention, the admin dashboard
design doc is now "partial":

- **P1** (DynamoDB CRUD + AdminForward) — shipped via #634, #635, #644,
#648
- **P2** (S3 buckets list/create/delete/ACL + DescribeTable) — shipped
via #658, with #669 + #673 in flight
- **P3** (React SPA + embed) — shipped via #649, #650
- **P4** (TLS / role / CSRF / operator docs) — TLS, role, CSRF are
already live in P1; operator docs in #674

Independent of the in-flight slice 2 PRs (#669/#673) and the docs PR
(#674) — this rename only reflects what is already on main today, plus
an "Implementation status" table mapping each phase to the PR it landed
in.

## What this PR changes

- `git mv` the design doc from `2026_04_24_proposed_admin_dashboard.md`
to `2026_04_24_partial_admin_dashboard.md` so its history follows
- Add an "Implementation status" header table indexing each phase to the
PRs that landed it
- List the outstanding open items so future readers know what is still
owed against the original proposal:
- AdminForward acceptance criterion 5 (rolling-upgrade compat flag) —
deferred
  - S3 object browser — explicitly out of scope per Section 2 Non-goals
  - TLS cert hot-reload — restart-to-rotate is the documented model

When the rolling-upgrade flag lands, the doc gets renamed once more to
`2026_04_24_implemented_admin_dashboard.md` per the README's lifecycle
convention.
bootjp added a commit that referenced this pull request Apr 26, 2026
## Summary

Phase **3.B** of
[`docs/design/2026_04_24_partial_sqs_compatible_adapter.md`](https://github.com/bootjp/elastickv/blob/main/docs/design/2026_04_24_partial_sqs_compatible_adapter.md)
§16.4. Adds the AWS SQS **query protocol** (form-encoded request, XML
response) alongside the existing JSON protocol on the same listener —
older `aws-sdk-java` v1, `boto < 1.34`, and AWS CLI clients can now talk
to elastickv without modification. Detection happens per-request via
`Content-Type` / `X-Amz-Target` / `Action` presence; no flag, no
separate port. See the new proposal doc committed alongside.

This is an **architectural proof PR**: dispatch + decoding + encoding +
error envelope + the `*Core` refactor pattern are all in place, with
**three verbs** wired end-to-end as concrete proof. Each follow-up verb
is a parser + response struct + one switch arm — no further design work
needed.

### Verbs in this PR

| Verb | Why it's in the proof set |
|---|---|
| `CreateQueue` | Exercises the `Attribute.N.{Name,Value}`
indexed-collection parser. |
| `ListQueues` | Exercises the repeated-element XML response shape. |
| `GetQueueUrl` | Exercises the `<ErrorResponse>` envelope path via
`QueueDoesNotExist`. |

Every other verb returns a structured **501 `NotImplementedYet`** XML
envelope so operators see the gap explicitly. `SendMessage` /
`ReceiveMessage` / `DeleteMessage` are the highest-priority follow-ups
(they need the same `*Core` refactor on the FIFO send loop).

### Key design points

- **No new listener / no flag.** `pickSqsProtocol(*http.Request)`
decides per request. JSON and Query share the SQS port and the SigV4
path.
- **Wire-format-free cores.** `createQueue` / `listQueues` /
`getQueueUrl` are now `decode → core → encode` with `core(ctx, in) (out,
error)`. The JSON wrappers are unchanged in behavior; existing JSON
tests pass without modification.
- **DoS protection inherited.** Body read is bounded by the same
`sqsMaxRequestBodyBytes` the JSON path uses.
- **SigV4 unchanged.** The signed canonical request includes the
form-encoded body, so the existing SigV4 middleware verifies query
requests without code changes.
- **Error parity.** `<Code>` reuses the existing `sqsErr*` constants.
HTTP status mirrors what the JSON path returns, so SDK retry classifiers
work across protocols.
- **Cyclomatic budget honoured.** `handle()` was refactored to extract
`handleQueryProtocol` — `cyclop ≤ 10` per project rules, no `//nolint`.

### Known limitation (design §11.4)

`proxyToLeader`'s error writer always emits the JSON envelope, so a
query-protocol client hitting a follower during a leader flip sees one
JSON error before retry lands on the new leader. Follow-up PR threads
the detected protocol onto the request context so the proxy emits
matching XML.

## Test plan

- [x] `go build ./...` — clean
- [x] `go test -count=1 -race -run
"TestSQS|QueryProtocol|TestPickSqs|TestCollectIndexedKV|TestWriteSQSQueryError"
./adapter/` — passes
- [x] `golangci-lint run ./adapter/...` — `0 issues.`
- [x] `pickSqsProtocol` table tests cover documented edge cases (header
precedence, charset suffix, GET with Action, missing Action, nil
request).
- [x] `collectIndexedKVPairs` tests cover happy path, orphan Name, empty
input, unrelated prefix.
- [x] End-to-end via the in-process listener: CreateQueue / ListQueues /
GetQueueUrl round-trip on the query side.
- [x] **Cross-protocol parity**: a queue created via Query is visible
via JSON `GetQueueUrl` with the same URL.
- [x] Error envelope: 4xx maps to `<Type>Sender</Type>`, 5xx to
`<Type>Receiver</Type>`, namespace pinned, `x-amzn-ErrorType` header
set.
- [x] Unknown verb returns 501 with the `NotImplementedYet` XML
envelope.
- [x] Missing `Action` parameter returns 400 (per design §3).

## Self-review (5 lenses)

1. **Data loss** — Wire-format change only. Cores are byte-for-byte
identical to the previous handler bodies; no Raft / OCC / MVCC code is
touched.
2. **Concurrency** — No new shared state. Detection is request-local.
Body parsing is bounded.
3. **Performance** — One additional `Content-Type` string compare per
request on the dispatch hot path. Negligible.
4. **Data consistency** — `*Core` returns the same business-logic
outputs as before; the JSON tests are the regression net for parity.
Cross-protocol parity test pins behaviour.
5. **Test coverage** — 10 new test cases cover detection, parsing,
envelope shape, and three end-to-end verbs. Existing `TestSQS*` race
suite passes on the refactor.

## Stacking

This PR is **independent** — branched from current `main` (which has
#638 + #649 merged). It does not depend on PR #650 / PR #659. Merge
whenever ready.
bootjp added a commit that referenced this pull request Apr 26, 2026
)

## Summary

**Replaces #659**, which has unresolvable conflicts now that main has
moved on (PR #649 squashed into main; PR #658 added the S3 admin
endpoints; the Approximate counters implementation now lives directly in
`adapter/sqs_catalog.go` via `scanApproxCounters`). Rather than a
multi-day rebase, this PR re-applies the unique SQS admin code on a
fresh branch off current main.

What survived from #659:
- `adapter/sqs_admin.go` — `SQSServer.AdminListQueues` /
`AdminDescribeQueue` / `AdminDeleteQueue` (SigV4-bypass entrypoints,
same shape as `AdminListTables` / `AdminListBuckets`).
- `internal/admin/sqs_handler.go` — HTTP handler for
`/admin/api/v1/sqs/queues{,/{name}}` with role re-evaluation on DELETE.
- `web/admin/src/pages/SqsList.tsx` / `SqsDetail.tsx` — SPA pages for
the queues view + delete confirmation.

What changed during the re-apply:
- `AdminQueueCounters` is now `int64` (matches `sqsApproxCounters` from
main; bridge does no width conversion).
- `AdminDescribeQueue` calls main's `scanApproxCounters` instead of the
duplicate `computeApproxCounters` from the old branch — same numeric
output, single implementation.
- Dropped the `CountersTruncated` field; main's counter type doesn't
expose truncation. SPA's "truncated" pill came out with it.
- `apiRouteTable.dispatch` refactored to extract `resourceHandlerFor` so
the dispatcher stays under cyclop=10 as new resources land.

## Backend

- Re-evaluates the principal's role against the live `MapRoleStore` on
every `DELETE` so a downgraded key cannot keep mutating with a
still-valid JWT (Codex P1 pattern from earlier admin PRs).
- `admin.QueuesSource` is **opt-in**: deployments without `--sqsAddress`
leave `/admin/api/v1/sqs/*` off the wire; the SPA renders a soft
"endpoint pending" notice on the 404, mirroring the Tables / Buckets
`nil` contract.
- The bridge in `main_admin.go` (`sqsQueuesBridge`,
`convertAdminQueueSummary`, `translateAdminQueuesError`) keeps
`internal/admin` free of the heavy adapter dependency tree, same
architectural pattern as Dynamo and S3.

## Frontend

- **/sqs** queue list with refresh + per-row link to detail.
- **/sqs/:name** detail showing FIFO badge, counters card (Visible /
In-flight / Delayed), raw attributes table, and a Delete confirmation
`Modal` gated by `RequireFullAccess`.
- `api/client.ts` gains `listQueues` / `describeQueue` / `deleteQueue`
with the same `AbortSignal` pattern used for `cluster` / `dynamo` / `s3`
reads.
- Layout nav adds an SQS tab between DynamoDB and S3.

## Out of scope (recorded in
`docs/design/2026_04_24_proposed_sqs_compatible_adapter.md` Section 14,
deferred per the SQS partial doc §16.2)

- **PurgeQueue from the SPA** — the underlying `purgeQueueWithRetry`
adapter method is on main; the admin entrypoint is a trivial follow-up.
- **Send / Peek / CreateQueue from the SPA** — each needs its own
SigV4-bypass adapter entrypoint and form UX; deferred to keep this PR
focused.

## Test plan

- [x] `go build ./...` — clean
- [x] `go test -race ./internal/admin/...` — passes
- [x] `go test -race -run TestSQS ./adapter/` — passes
- [x] `go test -run TestStartAdmin .` — passes
- [x] `golangci-lint run ./adapter/... ./internal/admin/... ./...` — `0
issues.`, no `//nolint`
- [x] `cd web/admin && npm run build` — 49 modules, 199 KB JS / 61 KB
gzip + 14.7 KB CSS
- [ ] Manual smoke (after PR lands): start a node with `--sqsAddress
:4566 --adminEnabled --adminAllowInsecureDevCookie`, create a queue,
send a few messages, hit `/admin/sqs/<name>` → counters match
`GetQueueAttributes("All")`, Delete dialog returns to list.

## Self-review (5 lenses)

1. **Data loss** — Delete reuses the existing `deleteQueueWithRetry` OCC
path; counters are read-only. No new write paths.
2. **Concurrency** — Per-request leader check on Delete; counters scan
uses one snapshot read TS.
3. **Performance** — Counters bounded by main's existing
`sqsApproxCounterScanLimit`; admin reads are cheap point lookups + one
bounded scan.
4. **Data consistency** — `AdminDescribeQueue` and SigV4
`GetQueueAttributes` both call `scanApproxCounters` at a fresh
`nextTxnReadTS`, so a single point in time produces the same counters
via either surface.
5. **Test coverage** — Existing admin / SQS race suites stay green via
the new `nil` Queues argument added to `startAdminServer` call sites;
the new bridge is exercised by the cross-package build itself.

## Stacking

This PR is **independent** — branched from current `main` (which has the
merged versions of #649 / #658 / #650 / counter implementation). Closing
#659 in favour of this clean rewrite.
bootjp added a commit that referenced this pull request Apr 28, 2026
Per docs/design/README.md's lifecycle convention. The original
P1–P4 plan has fully shipped:

  - P1 (admin skeleton + Dynamo + AdminForward) — #634/#635/#644/#648
  - P2 (S3 endpoints incl. write paths and AdminForward integration)
        — #658 / #669 / #673 / #695 (TOCTOU safety net)
  - P3 (React SPA + embed) — #649 / #650
  - P4 (TLS / role / CSRF / operator doc / deployment runbook
        / scripts/rolling-update.sh admin support) — #674 / #669
        / #678

The AdminDeleteBucket TOCTOU caught during PR #669 review (the
last "in-flight" item that kept the doc at _partial_) is fully
resolved by the safety-net design landed in #695.

What changed:

  - git mv 2026_04_24_partial_admin_dashboard.md →
            2026_04_24_implemented_admin_dashboard.md (history
            follows the rename)
  - Header Status line: "Partial" → "Implemented", explanation
    updated to reflect the post-fix state and the rationale for
    promotion.
  - "Last updated" bumped to 2026-04-28 with the rename trigger.
  - Section heading "Outstanding open items" → "Out-of-scope
    follow-ups" — the remaining three entries (criterion 5,
    object browser, TLS hot-reload) are not in-flight work; they
    are deferred-at-design or Non-goal items. The TOCTOU bullet
    is removed (resolved) and replaced with a one-line
    cross-link to the safety-net design + admin_deployment.md
    §4.6 contract.
  - Removed the closing "rename trigger" sentence — we just did
    the rename.
  - Status table: P2 row now lists #695 alongside #658/#669/#673
    so a future reader can find the TOCTOU fix from the index.
  - Cross-references updated everywhere the old filename
    appeared:
      docs/admin.md (header link + Cross-references)
      docs/admin_deployment.md (header link + final cross-ref)
      docs/design/2026_04_28_proposed_admin_delete_bucket_safety_net.md
        (Background section pointer)
      internal/admin/config.go (Section 7.1 reference comment)

No code changes other than the comment-only filename refresh in
config.go.
bootjp added a commit that referenced this pull request Apr 28, 2026
## Summary

Promote the admin dashboard design doc from `_partial_` →
`_implemented_` per `docs/design/README.md`'s lifecycle convention. PR
#695 landed the TOCTOU safety-net fix (the last in-flight item that kept
the doc at `_partial_`), so the original P1–P4 plan is now fully
shipped:

| Phase | Landed via |
|---|---|
| P1 (admin skeleton + Dynamo + AdminForward) | #634 / #635 / #644 /
#648 |
| P2 (S3 endpoints + writes + AdminForward S3 + TOCTOU fix) | #658 /
#669 / #673 / **#695** |
| P3 (React SPA + embed) | #649 / #650 |
| P4 (TLS / role / CSRF / operator doc / runbook / script wiring) | #674
/ #669 / #678 |

The remaining three items in the doc move from "Outstanding open items"
(in-flight) to **"Out-of-scope follow-ups"** (deferred-at-design or
Non-goal):

- AdminForward criterion 5 — rolling-upgrade flag, deferred behind a
cluster-version bump that doesn't exist yet
- S3 object browser — Non-goal per §2.2
- TLS cert hot-reload — out of scope per `docs/admin.md`

## Changes

- `git mv` partial → implemented (history follows the rename)
- Status line / Last-updated / status-table / Out-of-scope section
content reflects the promotion
- Cross-references updated in all 4 referencing files: `docs/admin.md`,
`docs/admin_deployment.md`,
`docs/design/2026_04_28_proposed_admin_delete_bucket_safety_net.md`,
`internal/admin/config.go` (comment-only)

## Test plan

- [x] No code changes other than a comment-only filename refresh in
`config.go`
- [x] `go build ./...` passes
- [x] `golangci-lint run ./internal/admin/...` — 0 issues
- [x] `grep -rn "2026_04_24_partial\|2026_04_24_proposed_admin"` returns
nothing — no stale references
bootjp added a commit that referenced this pull request Apr 28, 2026
…FO) (#664)

## Summary

**Docs-only PR.** Two design proposals for the remaining Phase 3 SQS
items per
[`docs/design/2026_04_24_proposed_sqs_compatible_adapter.md`](https://github.com/bootjp/elastickv/blob/main/docs/design/2026_04_24_proposed_sqs_compatible_adapter.md)
Section 14 (Phase 3 bullets). Both items were explicitly called out as
needing separate design docs before any implementation work; this PR
lands those proposals so the implementation PRs have a reviewed
architecture to build on.

### 3.C — Per-queue throttling and tenant fairness
([proposal](docs/design/2026_04_26_proposed_sqs_per_queue_throttling.md))

Per-queue token-bucket throttling configured on queue meta (no separate
keyspace), evaluated at the SQS adapter layer on the leader (no Raft per
request), surfaced as the AWS `Throttling` error envelope so SDK
retry/backoff just works.

Key decisions:
- **Default-off**. Existing queues are unaffected; operators opt in per
queue via `SetQueueAttributes`.
- **Per-action buckets** (Send / Receive / Default) so a slow consumer
cannot pin the producer.
- **Per-leader buckets, no replication**. Worst case on failover: one
extra burst on the new leader. Acceptable per AWS-equivalent behaviour
at region failover boundaries; replicating would cost a Raft commit per
`SendMessage`.
- **Batch verbs charge by entry count**, not call count, with
all-or-nothing rejection (matches AWS).
- **Admin-only configuration plane**. Standard SQS clients see
`InvalidAttributeName` on the `Throttle*` attributes (matches AWS
behaviour for unknown attributes); the data-plane enforcement runs for
everyone.

### 3.D — Split-queue FIFO (high-throughput FIFO)
([proposal](docs/design/2026_04_26_proposed_sqs_split_queue_fifo.md))

Per-`MessageGroupId` hash partitioning across multiple Raft groups,
mirroring AWS High Throughput FIFO. Within-group ordering preserved;
across-group throughput scales with the partition count.

Key decisions:
- **Existing single-partition FIFO queues stay byte-identical**
(`PartitionCount = 0` path is the legacy layout; no migration runs
implicitly).
- **Power-of-two partition counts only** (1, 2, 4, 8, 16, 32) so the
routing step is `hash & (N-1)` and future offline rebuilds stay
tractable.
- **Partition count is immutable after first SendMessage**. Live
re-partitioning would break ordering for in-flight messages of every
group whose hash bucket changed; out of scope.
- **Multi-PR rollout plan** with an explicit "gate of no return" called
out at PR 5 (the data-plane PR). PRs 1–4 are reversible no-ops on data
layout; once a partitioned FIFO holds real data, rollback means draining
and recreating the queue.
- **FNV-1a hash** (deterministic across processes / Go versions /
architectures). Risk of attacker-controlled `MessageGroupId` pinning all
traffic to one partition is documented and accepted (the feature is for
cooperative operators).

## Test plan

- [x] Markdown renders correctly on GitHub (manually previewed).
- [x] Cross-references resolve (the partial-doc rename in PR #659 is in
flight; both proposals reference the partial filename — they will
resolve once #659 merges, otherwise resolve to the current `_proposed_`
filename for a 1-character mismatch that is fine to leave for now).
- [x] No code changes; CI is irrelevant beyond the markdown lint pass.

## Self-review

This is a docs-only PR; the 5-lens self-review collapses to:

1. **Data loss / Concurrency / Performance / Consistency** — N/A, no
code touched.
2. **Test coverage** — N/A, no code touched. The proposals themselves
include a Testing Strategy section (§6 / §9) so the implementation PRs
have explicit acceptance criteria.

## Stacking

This PR is **independent** of #650, #659, and #662. Branched from
current `main`. Merge whenever ready — landing the proposal docs early
lets reviewers comment on the architecture before the implementation PRs
go up.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Documentation**
* Added detailed designs for optional per-queue token-bucket throttling
(rules, batch semantics, AWS-shaped Throttling errors with Retry-After,
metrics, rollout default-off) and for HT‑FIFO “split-queue” partitioning
(routing, immutability, gating/rollout).
* **New Features**
* Optional queue-level send/receive throttling with batch-aware charging
and Retry-After; HT‑FIFO partitioning with deterministic partition
routing and immutable partition attributes.
* **Tests**
* Extensive unit and integration tests for throttling, HT‑FIFO
partitioning, attribute validation, immutability, idempotency, and cache
invalidation.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
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