Skip to content

Run the header counts in their own scope so signed-in pages load on PostgreSQL - #2642

Merged
pournasserian merged 6 commits into
v2from
2635-header-counts-scope
Oct 19, 2026
Merged

pournasserian merged 6 commits into
v2from
2635-header-counts-scope

Conversation

@pournasserian

@pournasserian pournasserian commented Oct 19, 2026 •

Copy link
Copy Markdown
Member

Closes #2635

What

Follow-up from the independent review of #2590 (F-DSK, #2422). On PostgreSQL, signed-in pages failed most of the time with "A second operation was started on this context instance".

  • NotificationLinks reads the header counts through a service scope of its own. The layout renders while the page loads its data, and the two used to share the request's DbContext. The caller is still the request's, because the initiator comes from the HttpContext.
  • An index on AuditEvents (Operation, Timestamp), with migrations for SQLite and PostgreSQL, generated after v2's latest. The security view lists and counts a few operations across the whole log, and no index covered Operation.
  • CONTRIBUTING.md states the rule: a component that loads data next to the page reads through a scope of its own.

Why

  • A scope, not a DbContext factory. The directories and the unit of work share one context per scope by design (ADR-0005). Only components that run beside the page need their own, and today that is the header counts. Make the form pickers keep typed text, start with large types and survive a lost circuit #2639 applies the same rule to the picker's searches.
  • SQLite hid it. Its async calls finish synchronously, so no host test ever overlapped two queries. The new page test runs on both providers.

Tests

  • SignedInPageTests, on SQLite and PostgreSQL: loads every signed-in page five times, with a set awaiting review, an entry, an asset and their pages. Without the fix it fails on PostgreSQL.
  • Every SEC-08 event the code appends today is appended by the code that owns it and read back from /security with the kind and name of who did it, so a renamed operation fails a test:
    • SecurityEventTests, on both providers:
      • an agent over its calls cap, over MCP;
      • an agent over its items-per-set and sets-per-day caps, over MCP;
      • an agent refused a move it can't make;
      • a key issued and revoked;
      • a user created, disabled and enabled;
      • a routing rule created, revised, activated and deactivated.
    • PasswordResetTests: a reset link issued and used.
    • ExternalSignInTests: a sign-in linked through the provider.
    • SecurityRows reads the view's rows for all three.
  • The header counts follow a set into review and out of it, and follow security events as they are appended.
  • The axe check covers /security with more than a page of events, so the table, badges, links and pager are checked.

dotnet build -warnaserror, the unit and bUnit suite, the browser tests, the integration suite on SQLite and PostgreSQL and markdownlint pass locally. No new package and no new project reference.

Checklist

  • Linked to an issue a maintainer has confirmed
  • Targets v2
  • Tests or docs updated where behaviour changed
  • No tool-generated co-author trailers or footers

@pournasserian pournasserian added this to the M1 Governance core milestone Oct 19, 2026
@pournasserian pournasserian added v2 FluentCMS v2 (v2 branch) type:bug Defect area:desk labels Oct 19, 2026
@pournasserian
pournasserian merged commit 2be58a0 into v2 Oct 19, 2026
2 checks passed
@pournasserian
pournasserian deleted the 2635-header-counts-scope branch October 19, 2026 22:55
pournasserian added a commit that referenced this pull request Oct 20, 2026
Closes #2624

## What

Follow-ups from the independent review of #2581 (F-DSK, #2422).

- **Each review action is taken only from a state the panel offers it
in,** whatever a post claims:
  - submit from open;
  - approve from awaiting review;
  - publish from approved;
  - reject from awaiting review, checks failed or approved;
  - revert from published.

A post for any other state gets the "moved on" refusal and changes
nothing, so a set can't be approved past its checks.
- **Revert is idempotent.** Before opening a revert, the page looks for
a revert of this set still in progress (open, checks running, checks
failed, awaiting review or approved). If there is one, a second click
lands on it. `ChangeSetListQuery.Reverts` filters the list by the set a
revert undoes.
- Two posts at the very same instant can still both open a set. A unique
index belongs to the change sets lane, and #2646 asks for it.
- **The diff island reads through a scope of its own.** The new
PostgreSQL review tests exposed it: on PostgreSQL an async read can
finish at once, so the set view sometimes renders `ChangeSetItems`
before its own reads are done. The island's prerendered diff then ran on
the same request `DbContext` and failed the page with a 500 or a stall.
This is the #2642 rule applied to the last component that loads data
beside its page. It is also the likely cause of the intermittent
`SignedInPageTests` hang on v2.

## Tests

- `ReviewActionTests` runs on SQLite and PostgreSQL.
- The approve test asserts the stored state is approved.
- Each of these posts carries the set's current `Seen` and is refused,
leaving the state unchanged:
- an approve for a set in checks running, checks failed, open or
published;
  - a publish from awaiting review;
  - a revert from approved;
  - a submit from checks failed;
  - a reject from open.
- A second revert of the same published set lands on the first, and only
one revert set exists.
- Every action's form is refused without the anti-forgery token, and an
anonymous post of each goes to sign-in.
- Without the guards, the refusal and revert tests fail.

`dotnet build -warnaserror`, the unit and bUnit suite and the review,
list and queue tests on SQLite and PostgreSQL pass locally. No new
package and no new project reference.

## Checklist

- [x] Linked to an issue a maintainer has confirmed
- [x] Targets `v2`
- [x] Tests or docs updated where behaviour changed
- [x] No tool-generated co-author trailers or footers
pournasserian added a commit that referenced this pull request Oct 20, 2026
…vive a lost circuit (#2655)

Closes #2639

## What

Follow-ups from the independent review of #2622 (F-FRM, #2426).

- **The search box owns its text.** The server never writes `value`
back, so a key typed while a search runs is never lost. `admin.js`
empties the box after a choice.
- **The island takes no option list.** Its parameters are the field, the
chosen id and text, so the circuit's start message stays small however
many entries or assets there are.
- Before the circuit attaches, the prerendered select loads its own
choices: the 200 newest, and the chosen one named.
  - `EntryEditor` no longer loads every entry and asset for its pickers.
- **Every read goes through a scope of its own.** The pickers of a form
load at once, and searches overlap as the person types, while a
`DbContext` takes one query at a time. This is the rule from #2642.
- **A search waits for a 250 ms pause in typing,** and only the newest
search shows its results.
- **The media picker asks the library for its text.** `MediaQuery`
gains:
  - `Text`, which matches a file name or folder whatever its case;
- `Newest`, which returns that many of the newest assets, newest first.

The text is matched in .NET over the names alone, because SQLite's
`lower()` folds ASCII letters only. Only the matching assets are read
after that, so the picker no longer reads every asset on every
keystroke.
- **Enter in the box never submits the form.** It chooses the only
match, or enters the list when there are more (`admin.js`).
- The list says which text it answers (`data-for`). Enter acts only when
the box still holds that text, so a fast typist never chooses a match
left from an earlier word.
- Text, not a count of keys, so keys typed while the connection was down
can't put the box and the server out of step.
- **Focus stays in the box** after a choice, Clear or Escape.
- **The choice is kept with the circuit** (`[PersistentState]`, keyed by
field). A circuit paused and resumed, as after a laptop sleeps, keeps
what the person chose rather than going back to the stored value. A new
stored value replaces it, as when enhanced navigation shows another
version of the entry.
- **A choice is named by the type's first text field,** in definition
order, with its state and short id. An entity's fact value counts. The
chosen option in the prerendered select is named the same way.
- **A lost circuit doesn't reload a page whose form holds a picker or
any typed input,** such as a rejection reason on the set view.
- The reconnect display says the connection was lost and the page keeps
what was typed. It offers to reload, and says that reloading loses it.
  - The picker boxes are disabled. The hidden choice still posts.
- Enhanced navigation is turned off for the rest of the page's life, so
every link loads its page in full. So do Back and Forward: Blazor
handles them without looking at that attribute, so a capture-phase
`popstate` listener reloads the page instead. Without a circuit nothing
can render the next page's islands, and an enhanced navigation would
keep this page's: another version's picker choice, or another set's
items.

A choice made in the select before the circuit attaches is still
replaced by the stored one. That is #2654. The select still reads every
entry of its type to show the newest 200. That is #2656.

## Why

- **No `value` on the box rather than `@bind`.** Every server render
writes a bound value back, and that write is what dropped keys under
latency. The box needs no server state between keys: the search reads
the text from the event.
- **Full page loads after a lost circuit belong to this PR.** Before it,
a lost circuit always reloaded the page. #2639's acceptance keeps the
page, so the form's input survives. A page kept without a circuit can't
render the next page's islands, and an enhanced navigation would carry
this page's dead ones. Turning enhanced navigation off once the page is
kept completes that change.
- **The select loads its choices in prerender.** Anything passed as an
island parameter travels in the circuit's start message, and SignalR
closes the connection when that message passes 32 KB.

## Tests

- **Browser, on PostgreSQL when it is configured (as in CI), otherwise
SQLite:**
- A keyboard-only path from the top of the page, with no `FocusAsync`,
on a target type of 500 entries:
    - type at full speed and keep every character;
- Enter chooses the only match without submitting, and focus stays in
the box;
    - Tab to the rationale and save.
- Searches with no pause overlap, the newest one shows, and the circuit
stays up.
  - Enter never chooses a match left from an earlier search.
- A paused and resumed circuit keeps the choice, and Enter still chooses
after keys typed while it was paused. Without the fix, the test fails.
- Starting from the published version gives the picker that version's
value, and the save proposes it.
- A rejected circuit keeps the form, the typed title and the choice, and
disables the search. The form then saves with both.
- A rejected circuit keeps a set view's typed rejection reason, and N
then loads the next set in full with its own items.
- With the circuit lost, "Start from the published version" loads the
published value into the picker.
- Back after an enhanced move, with the circuit lost, loads the earlier
page in full: the draft with its own choice, or the first set with its
own items.
  - Without the fix, both of these fail.
- The axe check on a form with pickers is the kitchen-sink form from
#2638.
- **Integration, on SQLite and PostgreSQL:** media text search finds
"Ärzte.png" by "ärzte" and "ÄRZTE", and `Newest` keeps the newest first.
- **bUnit:**
  - The island's parameters are only strings and flags.
  - Only the newest of two overlapping searches shows.
  - A media search asks the library for its text and the 10 newest.
- An entry is named by its type's first text field, and the select names
the chosen entry.

`dotnet build -warnaserror`, the unit and bUnit suite, the browser tests
on SQLite and PostgreSQL, and the form and media tests pass locally. No
new package and no new project reference.

## Checklist

- [x] Linked to an issue a maintainer has confirmed
- [x] Targets `v2`
- [x] Tests or docs updated where behaviour changed
- [x] No tool-generated co-author trailers or footers
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type:bug Defect v2 FluentCMS v2 (v2 branch)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Run the header counts in their own scope so signed-in pages load on PostgreSQL

1 participant