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.

15.929regels PHP, eigen werk in atlasmuseum
5.415regels JavaScript, eigen werk in atlasmuseum
22PHP-bestanden in AMAPI, de eigen API-laag
4talen op deze pagina: PHP, TypeScript, SQL, en de tekst ernaast

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.phpPHP · regel 347 tot 371 · 25 regels · letterlijke kopie uit de wiki

      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.phpPHP · regel 143 tot 207 · 65 regels · letterlijke kopie uit de wiki

// 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.phpPHP · regel 142 tot 177 · 36 regels · letterlijke kopie uit de wiki

	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.phpPHP · regel 86 tot 133 · 48 regels · letterlijke kopie uit de wiki

	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.

Dezelfde regel, nog een keer, in de database

packages/db/migrations/0001_targets_runs_measurements.sqlSQL · regel 89 tot 116 · 28 regels · in deze repository

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.tsTypeScript · regel 102 tot 134 · 33 regels · in deze repository

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.

Naar het werk