Skip to content

Simplify the Subject move - #1361

Draft
JeroenDeDauw wants to merge 6 commits into
feature/1338-move-subject-to-pagefrom
feature/1338-move-subject-simplify
Draft

Simplify the Subject move#1361
JeroenDeDauw wants to merge 6 commits into
feature/1338-move-subject-to-pagefrom
feature/1338-move-subject-simplify

Conversation

@JeroenDeDauw

Copy link
Copy Markdown
Member

Follows-up to #1356, into its branch. One commit per item, so any of them can be dropped on its own. User-visible behaviour is unchanged; the one API-visible difference is under the second item.

  • One read of the source page. PageSubjects::without() answers a copy without the Subject. The move writes the copy, and the rollback writes back the object it read, which nothing has mutated. The second read of the source page goes, and with it the reliance on repository reads handing out distinct objects, which SubjectRepository never promised.
  • One not-found outcome for the source. A source page that vanishes between the read check and its write is presented as the Subject not found: it is written first, so nothing has been written yet. presentSourcePageNotFound goes. The response is still 404, with the message Subject not found instead of Page not found.
  • One error path in the REST client. moveSubject no longer reads a server message off a non-ok Response. The production client rejects on every non-2xx but 422, so that branch never carried one. The rejection path still delivers the server's message to the dialog, unchanged.
  • Page creation behind the service registry. MediaWikiPageCreator wraps mw.Api().create; MoveSubjectDialog consumes its outcome and reads the Subject repository through NeoWikiServices instead of the NeoWikiExtension singleton. The dialog no longer knows MediaWiki error codes.
  • Three @group Database tests dropped: source-page 403, source untouched on an unreadable target, edit summary on both pages. MoveSubjectActionTest covers each; the target-page 403 test keeps proving the Authority wiring.

Considered, omitted:

  • Replacing dropFromRegistryOnceUnlisted with a plain listing reload: the moved Subject's registry copy carries its old page, so dropping it does real work.
  • Sharing the Codex lookup handling between PagePicker and SubjectPicker: the largest duplication in the PR, but it means reworking SubjectPicker; a PR of its own.

AI-authored — Claude Code, Fable 5.1 (max); @JeroenDeDauw asked for a follow-up PR that reduces complexity after an in-session assessment of the original, scope chosen by the assistant, no redirects; diff not yet human-reviewed; full PHPUnit suite and tsci green locally on the branch, each new test mutation-checked, a move onto a page that did not exist yet exercised in a dev wiki.

Production notes

Design, diff review and this text by Fable 5.1 (max); implementation and tests by an Opus 5 (max) subagent from a written spec. The dev-wiki check covered the page-creation path only; the other paths rest on the retained unit and Database-group tests.

alistair3149 and others added 6 commits September 3, 2026 21:31
Fixes #1338

A Subject created in flow from a relation field is stored on the page holding the Subject whose
relation was being filled in, which is often not where it belongs. Relocating one meant deleting it
and recreating it elsewhere, which mints a new id and breaks every relation pointing at the old one.

## What it does

Every Subject row on the Data tab gains a Move action, before Delete in both the button strip and
the overflow menu. It opens a dialog holding a page lookup, a checkbox for making the Subject the
target page's Main Subject, and the notices each choice earns. The Subject keeps its id, so the
relations targeting it keep resolving and the subject-page index follows it to its new page.

A page that does not exist yet can be named in the lookup; it is created when the move is confirmed,
so an abandoned dialog leaves nothing behind.

`POST /neowiki/v0/subject/{subjectId}/move` is the write surface.

## Decisions worth challenging

**The source page is written first.** A move is two page edits and there is no cross-page write
primitive. The graph projection is page-scoped: every page write rewrites the nodes of the Subjects
that page holds and tears down the ones it no longer does, so the page written last is the one that
decides what the moved Subject's node looks like. Writing the source last strips the node the target
write has just built and leaves a bare `:Subject` stub belonging to no page until the next rebuild,
while the wiki database and the index both look correct. The cost of source-first is a window in
which the Subject is on no page; a target write that fails is answered by writing the source page
back exactly as it was read.

**Move is offered on the main-subject row as well**, with a warning that the page will be left
without a main subject. Withholding it would only have hidden the action behind a demote-then-move
dance.

**The promotion checkbox is never checked for you**, and once a target is chosen it names the
Subject that promoting would demote, so an effect on a page the user is not looking at is stated
rather than sprung.

**Page search is MediaWiki's own** `/v1/search/title`, which already carries page ids and drops
results the viewer may not read. It is scoped to the wiki's default search namespaces, so a page
outside them has to be typed in full. NeoWiki adds no page-search endpoint of its own.

**The target page is not restricted by namespace**, because creating a Subject is not either.
Restricting only move would be inconsistent.

## Manual Browser Check

On a wiki with the demo data, as a user who can edit:

1. Open `Rijksmuseum`, then the **Data** tab. Every row has a Move button between copy-link and
   delete; narrow the window past the mobile breakpoint and it is in the ⋯ menu instead, above
   Delete.
2. Move one of the *Rijksmuseum attendance* subjects. The dialog opens with Move disabled. Type
   `ACME` — matching pages appear, with **Use "ACME" as a new page** last, under a separator.
3. Pick `ACME Inc`. Tick the checkbox: it names *ACME Inc*'s current main subject as the one that
   would be demoted. Untick it and move. A green toast names the subject and its new page, and the
   row leaves the listing.
4. Open `ACME Inc`'s Data tab: the attendance subject is there as a child. Its ID is unchanged.
5. Move it back to `Rijksmuseum`. On the source row for the **main** subject, open Move: an inline
   warning says the page will be left without a main subject.
6. In the dialog, type a title no page has and pick **Use "…" as a new page**, then Move. The page
   is created and the subject lands on it; visit the page to confirm.
7. Type a title, pick the create option, then correct the text without re-picking. Move is offered
   only once a target is chosen again — it must not create the page you first typed.

## Testing

1903 vitest and 1979 PHPUnit tests pass, plus the `@group Database` endpoint tests. The new tests
were each mutation-verified: every guard inverted and seen to fail by name. One of them mounts the
real graph and asserts the moved Subject has exactly one hosting page node and keeps its schema
label, which is what the source-first ordering buys.

Exercised end to end against a dev wiki: a Subject moved between pages and back, its relations still
resolving, both pages carrying the edit summary, and every refusal path (400, 403, 404, 409, missing
CSRF) answered as documented.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012KQMvby9ED7qd7E2XC6csE
PageSubjects::without() answers a copy without the given Subject. The move
writes that copy and the rollback writes back the object it read, which nothing
has mutated. The second read of the source page goes, and with it the reliance
on repository reads handing out distinct objects, which SubjectRepository never
promised.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A source page that vanishes between the read check and its write is written
first, so nothing has been written yet; from the caller's side the Subject is
gone. presentSourcePageNotFound() goes. The response is still 404, with the
message "Subject not found" instead of "Page not found".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The production HTTP client rejects on every non-2xx status but 422, so a non-ok
Response never carried a server message. moveSubject now fails generically on
one, like every sibling method; the rejection path still delivers the server's
own message to the dialog, unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MediaWikiPageCreator wraps mw.Api().create behind the service registry.
MoveSubjectDialog consumes its outcome and reads the Subject repository through
NeoWikiServices instead of the NeoWikiExtension singleton, so the dialog no
longer knows MediaWiki error codes and its spec provides both collaborators.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Source-page 403, source untouched on an unreadable target, and the edit summary
reaching both pages are each asserted in MoveSubjectActionTest; the target-page
403 test keeps proving the Authority wiring through the real stack.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@JeroenDeDauw

Copy link
Copy Markdown
Member Author

Ugh. Try to get claude to remove lines. 🤔

@JeroenDeDauw
JeroenDeDauw force-pushed the feature/1338-move-subject-to-page branch from 5c41fec to 583af10 Compare September 6, 2026 22: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.

2 participants