Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions docs/extending/extending.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,10 +168,71 @@ the Page node. No new revision is created. `rebuild()` returns a `PageRefreshOut
- `SkippedUnreadableSubjects` — the page's subject slot holds content NeoWiki cannot read as Subjects.
- `SkippedUnreadablePageProperties` — the page's properties could not be built, for instance because a provider or the
page's own parse threw.
- `SkippedUnpublishableRevision` — a registered revision policy publishes no revision of the page.

A graph store that fails is logged and skipped, exactly as on a normal page save; only request timeouts and
wiki-database errors throw.

### Choosing which revision NeoWiki publishes

By default NeoWiki publishes each page's latest revision: that is the revision it projects to the graph stores and
exports as RDF. An approval extension shows readers an approved revision instead, and registers a `RevisionPolicy` so
NeoWiki publishes the same one.

```php
public function onNeoWikiRegistration( NeoWikiRegistrar $registrar ): void {
$registrar->setRevisionPolicy( new MyApprovalPolicy( $this->approvalLookup ) );
}
```

```php
class MyApprovalPolicy implements RevisionPolicy {

public function publishesRevision( RevisionRecord $revision ): bool {
return $this->approvalLookup->isApproved( $revision );
}

public function publishedRevision( RevisionRecord $revision ): ?RevisionRecord {
return $this->approvalLookup->lastApprovedRevisionOf( $revision->getPage() );
}

public function revisionIsReadableBy( RevisionRecord $revision, Authority $viewer ): bool {
return $this->approvalLookup->isApproved( $revision )
|| $viewer->isAllowed( 'my-extension-see-drafts' );
}

}
```

`publishesRevision()` is asked as a revision is written; return false and the graph keeps whatever it published
before. `publishedRevision()` is asked when a page is reprojected; return the argument to leave the projection alone,
or `null` when the page has nothing publishable. `revisionIsReadableBy()` is asked only when a caller names a revision
itself, which neither publishing method can intercept — a revision it refuses answers exactly like one that does not
exist.

- **A policy answers for the wiki, not for a viewer.** The graph and the RDF export are one state every reader sees.
- **Only one extension can decide this.** A second policy is refused with a warning in the `NeoWiki` log channel; the
first one keeps deciding.
- **Subject writes read the latest revision**, so a contributor still edits what they last saved. Schemas are the
exception: a Subject is validated against the Schema revision the policy publishes, while the Schema editor shows
the latest one ([#1392](https://github.com/ProfessionalWiki/NeoWiki/issues/1392)).

Call [`newPageRebuilder()->rebuild( $title )`](#refreshing-a-pages-data-without-an-edit) whenever your extension
changes which revision it approves; nothing else tells NeoWiki the answer has changed. `publishedRevision()` is also
asked on every Schema, Layout and Mapping read and every RDF export, so keep it cheap.

Two gaps:

- Schemas and Mappings are read through a cache keyed on the page's latest revision id, so an approval change with
no accompanying page edit does not take effect until that page is edited or the cache entry expires
([#1392](https://github.com/ProfessionalWiki/NeoWiki/issues/1392)).
- The RDF export stops answering for a page once approval is withdrawn, but what was published stays in the graph
stores until the page is deleted; a rebuild does not remove it
([#1391](https://github.com/ProfessionalWiki/NeoWiki/issues/1391)). Revocation is not a retraction mechanism.

Subject reads over REST, and the parse-time accessors, are not yet covered
([#1390](https://github.com/ProfessionalWiki/NeoWiki/issues/1390)).

### Graph Database Backends

NeoWiki currently supports Neo4j only, but the graph projection is an extension point: implement
Expand Down
2 changes: 1 addition & 1 deletion maintenance/DumpRdf.php
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ private function dumpPage(
}

if ( $page === null ) {
$this->error( "Skipped page $pageId: it no longer exists or its subject slot does not hold Subject data" );
$this->error( "Skipped page $pageId: it no longer exists, its subject slot does not hold Subject data, or the registered revision policy publishes no revision of it" );
return false;
}

Expand Down
104 changes: 104 additions & 0 deletions src/Application/FailureIsolatingRevisionPolicy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<?php

declare( strict_types = 1 );

namespace ProfessionalWiki\NeoWiki\Application;

use MediaWiki\Permissions\Authority;
use MediaWiki\Revision\RevisionRecord;
use Psr\Log\LoggerInterface;
use Throwable;

/**
* Holds a registered policy to what NeoWiki can accept from one, and fails closed when it cannot.
*
* A policy that throws is treated as publishing nothing and hiding everything: the edit hook it runs
* on sits inside PageUpdater's atomic section, so a throw there would roll the contributor's save
* back, and a throw on a read would take every Schema, Layout and Mapping read down with it. The other
* extension-contributed plugins are wrapped the same way; see FailureIsolatingGraphDatabasePlugin.
*
* Two answers are refused as well as caught. A revision from another page: every caller keys its write
* or its export on the returned revision's page id, so accepting one would publish page B under page
* A's name, behind A's read gate. And a revision whose text is suppressed: only the current revision
* was ever published before this policy existed, and core refuses to suppress that one, so nothing
* read a suppressed revision's Subjects — a policy naming an older one must not start to.
*/
class FailureIsolatingRevisionPolicy implements RevisionPolicy {

public function __construct(
private readonly RevisionPolicy $policy,
private readonly LoggerInterface $logger,
) {
}

public function publishesRevision( RevisionRecord $revision ): bool {
if ( $revision->isDeleted( RevisionRecord::DELETED_TEXT ) ) {
return false;
}

try {
return $this->policy->publishesRevision( $revision );
} catch ( Throwable $exception ) {
$this->logFailure( 'publishesRevision', $revision, $exception );
return false;
}
}

public function publishedRevision( RevisionRecord $revision ): ?RevisionRecord {
try {
$published = $this->policy->publishedRevision( $revision );
} catch ( Throwable $exception ) {
$this->logFailure( 'publishedRevision', $revision, $exception );
return null;
}

if ( $published === null ) {
return null;
}

if ( $published->getPageId() !== $revision->getPageId() ) {
$this->logger->error(
'The revision policy {class} named revision {published} of page {publishedPage} for page {page}; '
. 'a policy may only name a revision of the page it was asked about. Publishing nothing for it.',
[
'class' => $this->policy::class,
'published' => $published->getId(),
'publishedPage' => $published->getPageId(),
'page' => $revision->getPageId(),
]
);
return null;
}

if ( $published->isDeleted( RevisionRecord::DELETED_TEXT ) ) {
return null;
}

return $published;
}

public function revisionIsReadableBy( RevisionRecord $revision, Authority $viewer ): bool {
try {
return $this->policy->revisionIsReadableBy( $revision, $viewer );
} catch ( Throwable $exception ) {
$this->logFailure( 'revisionIsReadableBy', $revision, $exception );
return false;
}
}

private function logFailure( string $method, RevisionRecord $revision, Throwable $exception ): void {
$this->logger->error(
'The revision policy {class} threw from {method} for revision {revision} of page {page}; '
. 'treating the page as publishing nothing. {message}',
[
'class' => $this->policy::class,
'method' => $method,
'revision' => $revision->getId(),
'page' => $revision->getPageId(),
'message' => $exception->getMessage(),
'exception' => $exception,
]
);
}

}
4 changes: 2 additions & 2 deletions src/Application/GraphRebuild/RebuildProgress.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ public function pageProjected( int $pageId ): void {
}

/**
* The page held no Subject to project after all — it has no current revision, or its latest one
* dropped the subject slot. Nothing failed, and nothing was projected.
* The page was not projected, and nothing failed: it has no current revision, its subject slot
* does not hold Subject data, or the registered revision policy publishes no revision of it.
*/
public function pageSkipped( int $pageId ): void {
$this->cursor = $pageId;
Expand Down
29 changes: 29 additions & 0 deletions src/Application/NullRevisionPolicy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

declare( strict_types = 1 );

namespace ProfessionalWiki\NeoWiki\Application;

use MediaWiki\Permissions\Authority;
use MediaWiki\Revision\RevisionRecord;

/**
* Publishes every revision, keeps whichever one the caller resolved, and hides none. This is what runs
* when no extension registers a policy, so an installation without an approval extension behaves
* exactly as it did before there was a policy at all.
*/
class NullRevisionPolicy implements RevisionPolicy {

public function publishesRevision( RevisionRecord $revision ): bool {
return true;
}

public function publishedRevision( RevisionRecord $revision ): ?RevisionRecord {
return $revision;
}

public function revisionIsReadableBy( RevisionRecord $revision, Authority $viewer ): bool {
return true;
}

}
25 changes: 22 additions & 3 deletions src/Application/PageRebuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,20 @@ class PageRebuilder {
public function __construct(
private readonly OnRevisionCreatedHandler $handler,
private readonly WikiPageFactory $wikiPageFactory,
private readonly RevisionPolicy $revisionPolicy,
) {
}

/**
* Reprojects the page from the revision the registered policy publishes. This is the path an
* approval extension calls when its answer changes. A page save takes the other path: it already
* knows its revision and is only asked whether to publish it.
*
* The handler this hands the substituted revision to must not index, since it would index the
* published revision's Subjects in place of the latest one's. See NeoWikiExtension.
*/
public function rebuild( Title $title ): PageRefreshOutcome {
return $this->rebuildWithReadFlags( $title, IDBAccessObject::READ_NORMAL );
return $this->rebuildWithReadFlags( $title, IDBAccessObject::READ_NORMAL, substitute: true );
}

/**
Expand All @@ -27,10 +36,10 @@ public function rebuild( Title $title ): PageRefreshOutcome {
* replaced, which would project outdated content.
*/
public function rebuildFromPrimary( Title $title ): PageRefreshOutcome {
return $this->rebuildWithReadFlags( $title, IDBAccessObject::READ_LATEST );
return $this->rebuildWithReadFlags( $title, IDBAccessObject::READ_LATEST, substitute: false );
}

private function rebuildWithReadFlags( Title $title, int $readFlags ): PageRefreshOutcome {
private function rebuildWithReadFlags( Title $title, int $readFlags, bool $substitute ): PageRefreshOutcome {
$wikiPage = $this->wikiPageFactory->newFromTitle( $title );
$wikiPage->loadPageData( $readFlags );

Expand All @@ -40,6 +49,16 @@ private function rebuildWithReadFlags( Title $title, int $readFlags ): PageRefre
return PageRefreshOutcome::SkippedMissingRevision;
}

if ( $substitute ) {
$published = $this->revisionPolicy->publishedRevision( $revision );

if ( $published === null ) {
return PageRefreshOutcome::SkippedUnpublishableRevision;
}

$revision = $published;
}

return $this->handler->onRevisionCreated( $revision );
}

Expand Down
2 changes: 2 additions & 0 deletions src/Application/PageRefreshOutcome.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ enum PageRefreshOutcome: string {
case SkippedMissingRevision = 'skippedMissingRevision';
case SkippedUnreadableSubjects = 'skippedUnreadableSubjects';
case SkippedUnreadablePageProperties = 'skippedUnreadablePageProperties';
case SkippedUnpublishableRevision = 'skippedUnpublishableRevision';

/**
* Why the page was not written, phrased to complete "Skipped <page>: ...".
Expand All @@ -26,6 +27,7 @@ public function skipReason(): string {
self::SkippedMissingRevision => 'no current revision',
self::SkippedUnreadableSubjects => 'its subject slot does not hold Subject data',
self::SkippedUnreadablePageProperties => 'its page properties could not be built',
self::SkippedUnpublishableRevision => 'the registered revision policy does not publish it',
self::Refreshed => throw new LogicException( 'Refreshed is not a skip' ),
};
}
Expand Down
14 changes: 10 additions & 4 deletions src/Application/Rdf/RdfPageLoader.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use MediaWiki\Page\WikiPageFactory;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Title\Title;
use ProfessionalWiki\NeoWiki\Application\RevisionPolicy;
use ProfessionalWiki\NeoWiki\Domain\Page\Page;
use ProfessionalWiki\NeoWiki\Domain\Page\PageId;
use ProfessionalWiki\NeoWiki\Domain\Page\PageSubjects;
Expand All @@ -15,20 +16,23 @@
use ProfessionalWiki\NeoWiki\Persistence\MediaWiki\Subject\MediaWikiSubjectRepository;

/**
* Loads a {@see Page} domain object from a page's current revision so it can be projected to RDF.
* Loads a {@see Page} domain object from the revision the registered revision policy publishes, so it
* can be projected to RDF.
* The revision slot is the source of truth (as in OnRevisionCreatedHandler and the graph rebuild),
* so the export reflects the stored data rather than the secondary graph projection.
*
* Every page is exported, with the Subjects it holds and with none when it holds none, which keeps the
* RDF surfaces describing the same pages as the graph databases. Returns null when the page does not
* exist, and for the one page state the write path also refuses to touch: a subject slot holding content
* that is not Subject data, which the export must not describe as a page without Subjects.
* exist, when the policy publishes no revision of it, and for the one page state the write path also
* refuses to touch: a subject slot holding content that is not Subject data, which the export must not
* describe as a page without Subjects.
*/
class RdfPageLoader {

public function __construct(
private readonly WikiPageFactory $wikiPageFactory,
private readonly PagePropertiesBuilder $pagePropertiesBuilder,
private readonly RevisionPolicy $revisionPolicy,
) {
}

Expand All @@ -49,7 +53,9 @@ public function loadByTitle( Title $title ): ?Page {
return null;
}

return $this->buildPage( $revision );
$published = $this->revisionPolicy->publishedRevision( $revision );

return $published === null ? null : $this->buildPage( $published );
}

private function buildPage( RevisionRecord $revision ): ?Page {
Expand Down
51 changes: 51 additions & 0 deletions src/Application/RevisionPolicy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

declare( strict_types = 1 );

namespace ProfessionalWiki\NeoWiki\Application;

use MediaWiki\Permissions\Authority;
use MediaWiki\Revision\RevisionRecord;

/**
* Which revision of a page NeoWiki publishes: projects to the graph stores, exports as RDF, and reads
* Schemas, Layouts, Mappings and the on-wiki configuration from. Registered by an approval extension
* such as ContentStabilization, which shows readers an approved revision rather than the newest one.
* With no policy registered every page publishes its latest revision, as NeoWiki has always done.
*
* A page save knows its revision and only needs to be told whether to publish it. A reprojection, a
* configuration read and an RDF export are told nothing beyond the page, so they ask which revision
* to publish. publishedRevision() therefore runs on every Schema, Layout and Mapping read and every
* RDF export, not only on a rebuild, and an implementation must be cheap enough for that.
*
* The answers must agree: publishesRevision() is true for whatever publishedRevision() names, since a
* reprojection hands the named revision back to the save path's check.
*
* A policy answers for the wiki, not for a viewer: the graph and the RDF export are one state that
* every reader sees. Only revisionIsReadableBy(), which serves a single request, takes a viewer.
*
* The subject-to-page index is deliberately not governed by any of this. It records where a Subject
* lives, not whether it is published, and every read of it is re-checked against the revision the
* caller actually reads ([[ADR 32]]).
*/
interface RevisionPolicy {

/**
* Whether this revision may be published, asked as it is written. False leaves the graph holding
* whatever it published before, which for an approval extension is the last approved revision.
*/
public function publishesRevision( RevisionRecord $revision ): bool;

/**
* The revision to publish for this revision's page, asked when a page is reprojected rather than
* written. Null when the page has nothing publishable. Return the argument to leave it alone.
*/
public function publishedRevision( RevisionRecord $revision ): ?RevisionRecord;

/**
* Whether the viewer may read a revision they asked for by id. Neither publishing method can
* answer this: naming a revision bypasses both.
*/
public function revisionIsReadableBy( RevisionRecord $revision, Authority $viewer ): bool;

}
Loading