Het werkstation
Echte bestanden, geannoteerd.
Geen fragmenten die voor de gelegenheid geschreven zijn. Elk blok hieronder komt uit een repository die draait: de wiki van atlasmuseum, en dit platform zelf. Bij elk blok staat wat het doet, welk patroon het volgt, wat de afweging was en wat het opleverde.
Geteld in de repository van atlasmuseum op 12 september 2026, zonder de ingesloten jQuery en zonder het importscript van voor de opdracht. Het laatst gewijzigde eigen bestand dateert van 11 september 2026. De bestanden van atlasmuseum staan als letterlijke kopie in deze repository, pad voor pad, zodat de regelnummers hier de regelnummers in de wiki zijn.
atlasmuseum
De wiki die was opgegeven: gemigreerd van MediaWiki 1.26.4 naar 1.39.10, met een eigen API-laag, eigen extensies en een eigen skin. Vier bestanden uit het eigen werk.
De kaartquery, met gebonden parameters
amapi/generators/map.php
try {
$mysqli = new mysqli(DB_SERVER, DB_USER, DB_PASSWORD, DB_NAME, DB_PORT, DB_SOCKET);
} catch (mysqli_sql_exception $e) {
error_log('[AMAPI Map] database connection failed: ' . $e->getMessage());
return [];
}
$mysqli->set_charset('utf8mb4');
// Only the columns actually used below, instead of SELECT *.
$columns = 'article, title, artist, lat, lon, nature, wikidata';
try {
if (is_array($nature) && sizeof($nature) > 0) {
$placeholders = implode(', ', array_fill(0, sizeof($nature), '?'));
$stmt = $mysqli->prepare(
'SELECT ' . $columns . ' FROM ' . DATABASE_MAP . ' WHERE nature IN (' . $placeholders . ')'
);
// All bound values are strings; str_repeat builds the type string.
$stmt->bind_param(str_repeat('s', sizeof($nature)), ...array_values($nature));
$stmt->execute();
$result = $stmt->get_result();
} else {
$result = $mysqli->query('SELECT ' . $columns . ' FROM ' . DATABASE_MAP);
}
} catch (mysqli_sql_exception $e) {
- Rol
- De enige plek waar AMAPI rechtstreeks SQL leest. Alle andere endpoints gaan via de action API van MediaWiki, zodat een upgrade van de wiki de backend niet kan breken. Een kaart met duizenden punten is de uitzondering: die komt uit een gedenormaliseerde kaarttabel.
- Patroon
- Prepared statement met een placeholder per waarde; het typestring wordt uit de lengte van de lijst gebouwd. De verbinding staat in een try/catch omdat mysqli sinds PHP 8.1 fouten als excepties meldt en de oude foutbranch daardoor nooit meer bereikt werd.
- Afweging
- Alleen de kolommen die de kaart gebruikt in plaats van SELECT *, ten koste van een langere query in de code. Een mislukte verbinding levert een lege kaart op in plaats van een lege response met een fatale fout.
- Wat het opleverde
- Dit verving een handgeschreven str_replace die het aanhalingsteken ontsnapte maar de backslash niet: een SQL-injectie die van buitenaf bereikbaar was via de parameter nature. Opgelost met precies dit blok.
De kaart herbouwd in één transactie
amapi/rebuildMap.php
// 3) Rewrite am_map inside a transaction (atomic; the map is never empty
// mid-rebuild). DELETE + INSERT rather than TRUNCATE so it can roll back.
$mysqli = @new mysqli( DB_SERVER, DB_USER, DB_PASSWORD, DB_NAME, DB_PORT, DB_SOCKET );
if ( $mysqli->connect_errno ) {
rm_log( 'Database connection failed: ' . $mysqli->connect_error );
flock( $lock, LOCK_UN );
exit( 1 );
}
$mysqli->set_charset( 'utf8' );
$mysqli->begin_transaction();
try {
$mysqli->query( 'DELETE FROM ' . DATABASE_MAP . ';' );
$batchSize = 100;
for ( $j = 0; $j < $total; $j += $batchSize ) {
$values = [];
for ( $i = $j; $i < $j + $batchSize && $i < $total; $i++ ) {
$row = $dataAM[ $i ];
// Keep each value within its column width so a stray oversized value
// can never abort the whole rebuild. Wikidata must be a Q-id.
$wd = (string)( $row['wikidata'] ?? '' );
if ( !preg_match( '/^Q[0-9]{1,14}$/', $wd ) ) {
$wd = '';
}
$article = mb_substr( (string)( $row['article'] ?? '' ), 0, 255 );
$title = mb_substr( (string)( $row['title'] ?? '' ), 0, 255 );
$artist = mb_substr( (string)( $row['artist'] ?? '' ), 0, 255 );
$nature = mb_substr( (string)( $row['nature'] ?? '' ), 0, 20 );
$image = mb_substr( (string)( $row['image'] ?? '' ), 0, 255 );
$values[] = sprintf(
'(%d,"%s","%s","%s",%f,%f,"%s","%s","%s")',
$i + 1,
$mysqli->real_escape_string( $article ),
$mysqli->real_escape_string( $title ),
$mysqli->real_escape_string( $artist ),
isset( $row['lat'] ) ? (float)$row['lat'] : 0,
isset( $row['lon'] ) ? (float)$row['lon'] : 0,
$mysqli->real_escape_string( $nature ),
$mysqli->real_escape_string( $wd ),
$mysqli->real_escape_string( $image )
);
}
if ( $values ) {
$sql = 'INSERT INTO ' . DATABASE_MAP
. ' (id,article,title,artist,lat,lon,nature,wikidata,image) VALUES '
. implode( ',', $values );
if ( !$mysqli->query( $sql ) ) {
throw new RuntimeException( $mysqli->error );
}
}
}
$mysqli->commit();
} catch ( \Throwable $e ) {
$mysqli->rollback();
rm_log( 'Rebuild failed, rolled back: ' . $e->getMessage() );
flock( $lock, LOCK_UN );
exit( 1 );
}
$ms = round( ( microtime( true ) - $t0 ) * 1000 );
rm_log( "Map rebuilt: $total points written to " . DATABASE_MAP . " in {$ms} ms." );
- Rol
- Het commandoregelscript dat de kaarttabel opnieuw vult uit Semantic MediaWiki en Wikidata. Het draait vanaf de opdrachtregel, zodat geen enkele HTTP-aanroep in een andere genest zit.
- Patroon
- DELETE en INSERT binnen één transactie, in batches van honderd, met rollback bij elke fout. Geen TRUNCATE, want die kan niet terugdraaien. De kaart is dus nooit leeg terwijl hij herbouwd wordt.
- Afweging
- Een transactie over duizenden rijen houdt een lock langer vast dan een TRUNCATE zou doen. Dat is de prijs voor een kaart die halverwege een mislukte herbouw nog gewoon werkt.
- Wat het opleverde
- Boven dit blok staan twee beveiligingen: een lege laag wordt nooit weggeschreven, en een onvolledige laag ook niet. Die tweede kwam er nadat een batch met een time-out ooit als "geen resultaten meer" was gelezen en de kaart van 4.537 naar 988 punten terugviel.
Labels uit Wikidata, in stukken van vijftig
extensions/AtlasRepair/includes/Repair.php
public static function resolveLabels( array $qids ): array {
$out = [];
$fetch = [];
$dir = sys_get_temp_dir();
foreach ( $qids as $q ) {
$cf = $dir . '/amrepair-' . $q . '.txt';
if ( is_file( $cf ) && ( time() - filemtime( $cf ) < 2592000 ) ) {
$v = @file_get_contents( $cf );
if ( $v !== false && $v !== '' ) { $out[ $q ] = $v; continue; }
}
$fetch[] = $q;
}
// Wikidata allows up to 50 ids per call.
foreach ( array_chunk( $fetch, 50 ) as $chunk ) {
$url = 'https://www.wikidata.org/w/api.php?action=wbgetentities&props=labels'
. '&languages=fr%7Cen&format=json&ids=' . implode( '%7C', $chunk );
$raw = self::httpGet( $url );
$json = $raw ? json_decode( $raw ) : null;
if ( !$json || !isset( $json->entities ) ) { continue; }
foreach ( $chunk as $q ) {
$label = '';
if ( isset( $json->entities->{$q}->labels->fr->value ) ) {
$label = $json->entities->{$q}->labels->fr->value;
} elseif ( isset( $json->entities->{$q}->labels->en->value ) ) {
$label = $json->entities->{$q}->labels->en->value;
}
if ( $label !== '' ) {
$out[ $q ] = $label;
@file_put_contents( $dir . '/amrepair-' . $q . '.txt', $label );
}
}
}
return $out;
}
/** Simple GET with a descriptive User-Agent (Wikidata requires one). */
- Rol
- AtlasRepair vervangt Q-nummers die bij een oude import in kunstenaars- en werknotities terechtkwamen door de naam die Wikidata erbij kent. Dit is de functie die die namen ophaalt.
- Patroon
- Een cache per Q-nummer op schijf, dertig dagen geldig, en daarna één aanroep per vijftig nummers: het maximum dat de Wikidata-API per verzoek toestaat. Frans eerst, Engels als terugval.
- Afweging
- Een bestandscache in plaats van een tabel: geen migratie nodig in een wiki die net gered was, en een reparatie die je kunt herhalen zonder Wikidata opnieuw te belasten. Het bestand kan verdwijnen bij een herstart van de server; dan wordt het gewoon opnieuw opgehaald.
- Wat het opleverde
- De reparatie draait eerst als plan en pas met --apply als wijziging, met een rapport ertussen. Vier bestanden, 488 regels, en de notities waren weer leesbaar.
De kwaliteitsindex, per notitie een score
extensions/AtlasQualite/includes/QualityIndex.php
public static function rebuild( ?callable $progress = null ): array {
$config = MediaWikiServices::getInstance()->getMainConfig();
$category = $config->get( 'AtlasQualiteCategory' );
$checks = self::checks();
$rows = self::querySmw( $category, $checks );
$records = [];
$issueCounts = [];
foreach ( $checks as $c ) {
$issueCounts[ $c['key'] ] = 0;
}
$complete = 0;
$done = 0;
foreach ( $rows as $page => $values ) {
$missing = [];
$maxWeight = 0;
$gotWeight = 0;
foreach ( $checks as $c ) {
$maxWeight += $c['weight'];
$val = $values[ $c['key'] ] ?? '';
$ok = self::passes( $c, $val );
if ( $ok ) {
$gotWeight += $c['weight'];
} else {
$missing[] = $c['key'];
$issueCounts[ $c['key'] ]++;
}
}
$score = $maxWeight > 0 ? (int) round( 100 * $gotWeight / $maxWeight ) : 100;
if ( $score === 100 ) {
$complete++;
}
$records[] = [
'page' => $page,
'score' => $score,
'missing' => $missing,
];
$done++;
if ( $progress && ( $done % 200 === 0 ) ) {
$progress( $done );
}
}
- Rol
- AtlasQualité geeft elke werknotitie een score op basis van een lijst controles met gewichten, en zet de meest onvolledige notities bovenaan: de werklijst van een conservator.
- Patroon
- Eén doorloop over de resultaten van een semantische query, een gewogen som per pagina, en een index als JSON op schijf. De opbouw draait alleen vanaf de opdrachtregel, nooit bij het openen van een pagina.
- Afweging
- Een index die bij het openen van het dashboard even oud kan zijn als de laatste herbouw, in ruil voor een dashboard dat direct opent op een wiki met tienduizenden pagina's.
- Wat het opleverde
- Vier bestanden en één stylesheet, 640 regels, en een vereniging die voor het eerst kon zien welke notities nog aandacht nodig hadden.
Dit platform
De site die je nu leest meet zichzelf en bewaart elke meting in een keten die niet ongemerkt te wijzigen is. Drie bestanden uit de kern.
De schakel van de keten
packages/core/src/chain/hash-chain.ts
export function linkHash(prevHash: Sha256, body: ChainRecordBody): Sha256 {
const canonical = canonicalJsonOrThrow(bodyForHashing(body));
return Sha256.unsafe(sha256Hex(concatBytes(utf8(prevHash), utf8(canonical))));
}
/**
* The hashed shape is written out field by field rather than spread from the
* argument. An extra property on a caller's object must never silently become
* part of the digest, and a renamed field must break the build rather than
* quietly invalidate every stored chain.
*/
function bodyForHashing(body: ChainRecordBody): JsonValue {
return {
chainId: body.chainId,
payload: body.payload,
recordedAt: body.recordedAt,
sequence: body.sequence,
};
}
- Rol
- De kern van het bewijs op deze site. Elke meting krijgt een hash over de hash van zijn voorganger en zijn eigen canonieke JSON. Zonder I/O, dus volledig te unit-testen.
- Patroon
- De gehashte vorm wordt veld voor veld uitgeschreven in plaats van gekopieerd uit het argument. Een extra eigenschap op het object van een aanroeper kan zo nooit ongemerkt in de digest belanden, en een hernoemd veld breekt de build in plaats van elke opgeslagen keten.
- Afweging
- Meer regels dan een spread, en dat is precies de bedoeling: de vorm van de schakel is een contract dat de server, de worker en de verificatie in de browser byte voor byte delen.
- Wat het opleverde
- Dezelfde functie draait in de worker die schrijft, in de API die leest, en in de browser van de bezoeker die controleert. Er is één definitie, en die staat hier.
Dezelfde regel, nog een keer, in de database
packages/db/migrations/0001_targets_runs_measurements.sql
CREATE TABLE measurements (
id uuid PRIMARY KEY,
run_id uuid NOT NULL UNIQUE REFERENCES runs (id) ON DELETE RESTRICT,
target_id uuid NOT NULL REFERENCES targets (id) ON DELETE RESTRICT,
-- The chain. `payload` is the canonical-JSON body; `hash` is
-- sha256(prev_hash || canonical_json(body)) as defined in packages/core.
chain_id uuid NOT NULL,
sequence bigint NOT NULL,
recorded_at timestamptz NOT NULL,
payload jsonb NOT NULL,
prev_hash char(64) NOT NULL,
hash char(64) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT measurements_sequence_non_negative CHECK (sequence >= 0),
CONSTRAINT measurements_hash_hex CHECK (hash ~ '^[0-9a-f]{64}$'),
CONSTRAINT measurements_prev_hash_hex CHECK (prev_hash ~ '^[0-9a-f]{64}$'),
-- The genesis record is the only one that may point at the zero hash.
CONSTRAINT measurements_genesis_is_first CHECK (
(sequence = 0) = (prev_hash = repeat('0', 64))
),
-- A chain never forks: one record per position, and one successor per record.
CONSTRAINT measurements_one_per_position UNIQUE (chain_id, sequence),
CONSTRAINT measurements_one_successor UNIQUE (chain_id, prev_hash),
CONSTRAINT measurements_hash_unique UNIQUE (hash)
);
- Rol
- Verdediging in de diepte. Elke invariant die de kern in TypeScript afdwingt, staat hier nog een keer als constraint: een hash is 64 hexadecimale tekens, alleen het eerste record wijst naar de nulhash, en een keten vertakt nooit.
- Patroon
- CHECK-constraints voor de vorm, UNIQUE-constraints voor de structuur: één record per positie en één opvolger per record. Het is de keten als schema, niet als afspraak.
- Afweging
- Twee keer dezelfde regel is geen herhaling. De code geeft de ontwikkelaar een garantie bij het compileren en testen; de database geeft de beheerder een garantie die ook standhoudt bij een migratiescript of een handmatige psql-sessie.
- Wat het opleverde
- Een keten die alleen intact blijft zolang de applicatie netjes schrijft, is geen keten. Deze constraints zijn wat het woord "niet ongemerkt te wijzigen" waarmaakt.
Een taak claimen zonder dubbele uitvoering
packages/db/src/jobs.ts
export async function claimJobs(
sql: Sql,
workerId: string,
limit = 1,
leaseMs: number = DEFAULT_LEASE_MS,
): Promise<ClaimedJob[]> {
const rows = await sql<JobRow[]>`
UPDATE jobs AS j
SET state = 'claimed',
claimed_by = ${workerId},
claimed_at = now(),
lease_expires_at = now() + make_interval(secs => ${leaseMs / 1000}),
attempts = j.attempts + 1,
updated_at = now()
WHERE j.id IN (
SELECT c.id
FROM jobs c
WHERE c.state = 'pending'
AND c.run_at <= now()
-- A job with no attempts left must never be picked up: the claim
-- increments attempts, and jobs_attempts_bounded would reject it.
-- Reaching that constraint means the queue tried to run something it
-- had already given up on.
AND c.attempts < c.max_attempts
ORDER BY c.run_at
FOR UPDATE SKIP LOCKED
LIMIT ${limit}
)
RETURNING j.id, j.target_id, j.state, j.run_at, j.attempts, j.max_attempts,
j.claimed_by, j.lease_expires_at, j.last_error, j.dead_lettered_at
`;
return rows.map(toClaimed);
}
- Rol
- De wachtrij van de meetworkers draait op Postgres, zonder aparte berichtenbroker. Dit is de ene instructie waarmee een worker een taak voor zichzelf neemt.
- Patroon
- UPDATE met een geneste SELECT ... FOR UPDATE SKIP LOCKED. De binnenste select vergrendelt kandidaten en slaat rijen over die een andere transactie al vasthoudt. Twee workers op hetzelfde moment krijgen zo disjuncte sets, en de UPDATE zet de status om voordat de lock vrijkomt.
- Afweging
- Postgres als wachtrij betekent één systeem minder om te beheren, met een gemeten doorvoer die voor deze schaal ruim volstaat. De beslissing en de cijfers staan in de architectuurbeslissingen van de repository.
- Wat het opleverde
- Zonder SKIP LOCKED is dit de klassieke wachtrijfout: twee workers lezen dezelfde oudste rij en voeren dezelfde taak uit. Een rijlock alleen is ook niet genoeg, die zet de workers achter elkaar in een rij van één.
Meer van dit platform, met de metingen die het over zichzelf bijhoudt, staat op de werkpagina.