Testclient fase 1: er is geen Evolu-client voor Node, dus die stellen we samen

Het plan is gepromoveerd naar Actief/003-Testclient en tier A, en fase 1 is af.

De uitkomst die het plan als eerste wilde weten: @evolu/nodejs 3.1.0 bevat geen
client. Het pakket levert de relay plus losse bouwstenen, maar er is geen
createEvoluDeps voor Node zoals @evolu/web die voor de browser heeft. De
afhankelijkheden voor createEvolu worden nu samengesteld in
tools/relay-client/src/evolu-node.js, uit de in-memory workers van
@evolu/common; src/client.js is de publieke kant met eigenaars en blobs.

Vier dingen zaten in de weg en alle vier faalden ze stil, met een instantie die
het lijkt te doen tot de eerste schrijfactie: de ontbrekende installPolyfills
(Node 24 mist Map.getOrInsertComputed), createEvoluDeps overslaan, de
AsyncDisposableStack van initSharedWorker laten vallen, en workers zonder eigen
reportDefect. Dat laatste is waarom de andere drie te vinden waren. Uitgeschreven
in PLAN.md 4e.

tests/test_client_lokaal.mjs schrijft daarom een blob weg en leest hem terug in
plaats van alleen een instantie te maken, en heeft een wachthond: bij een defect
in een worker blijft de suite anders hangen in plaats van rood te worden, en dat
gebeurde bij de mutatietoets letterlijk. Beide mutaties lieten de juiste toets
omvallen.

Nog niet geprobeerd: praten met de relay. Daarvoor is het adres van de Umbrel
nodig en dat komt van de gebruiker; het gaat niet in deze publieke repo.

Suite: 405 goed, 0 fout over alle acht de toetsen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Harmen
2026-09-09 12:59:55 +02:00
co-authored by Claude Opus 5
parent 9ec39b1cd0
commit de955b607d
12 changed files with 1424 additions and 1 deletions
+141
View File
@@ -0,0 +1,141 @@
// ═══════════════════════════════════════════════════════════════════════════════
// De testcliënt zelf: eigenaars aanmaken en blobs door de relay duwen.
//
// Dit is de publieke kant van dit gereedschap. De opdrachtregel en straks de
// pagina praten hiermee en niet rechtstreeks met Evolu; `evolu-node.js` ernaast
// doet alleen de platformafhankelijkheden en heeft geen weet van eigenaars.
//
// Eén eigenaar is één mnemonic plus één database. Evolu leidt de bestandsnaam af
// uit `appName` en de eigenaar, dus alle eigenaars kunnen in dezelfde map staan
// zonder elkaar te raken; wat ze deelt is het proces, en dat is precies wat de
// gedeelde worker aankan (zie evolu-node.js).
//
// Wat hier NIET in hoort: iets dat de gegevens van Trezor Suite probeert te
// lezen. Deze cliënt maakt eigen sleutels en leest zijn eigen gegevens; zie de
// niet-doelen in het plan Testclient.
// ═══════════════════════════════════════════════════════════════════════════════
import {
AppName,
createAppOwner,
createEvolu,
createOwnerSecret,
createOwnerWebSocketTransport,
createQueryBuilder,
createRandomBytes,
createRun,
id,
mnemonicToOwnerSecret,
NonEmptyTrimmedString100,
Uint8Array as Uint8ArrayType,
} from '@evolu/common';
import { installPolyfills } from '@evolu/common/polyfills';
import { createNodeEvoluDeps } from './evolu-node.js';
// Node 24 mist `Map.prototype.getOrInsertComputed`, dat Evolu wel gebruikt.
// Zonder deze aanroep valt niet het aanmaken om maar de eerste schrijfactie, en
// dat is precies het soort fout dat je pas op het verkeerde moment vindt. Hier en
// niet in het startbestand, zodat elke ingang van dit gereedschap hem meekrijgt.
installPolyfills();
/**
* De naam waaronder deze cliënt zijn databases wegzet.
*
* Onderdeel van de bestandsnaam, dus veranderen betekent dat elke eigenaar zijn
* lokale gegevens kwijt is. Dat maakt op de relay niets uit (die kent alleen het
* `OwnerId`), maar de geschiedenis moet dan opnieuw opgehaald worden.
*/
export const APP_NAME = AppName.orThrow('WhatsnextRelayClient');
/**
* Het schema: één tabel met een label en een blob.
*
* Bewust klein. Wat hier beproefd wordt is de relay, niet een gegevensmodel. Het
* label is er om in een uitdraai te kunnen zien wélke rij je terugkrijgt, en de
* blob is waar de omvang in zit die de bytegrens van de relay moet raken.
*/
export const Schema = {
blob: {
id: id('Blob'),
label: NonEmptyTrimmedString100,
body: Uint8ArrayType,
},
};
const allesQuery = createQueryBuilder(Schema)((db) =>
db.selectFrom('blob').selectAll().orderBy('createdAt'),
);
/** Maakt een nieuwe eigenaar met verse sleutels. */
export const nieuweEigenaar = () =>
createAppOwner(createOwnerSecret({ randomBytes: createRandomBytes() }));
/**
* Herstelt een eigenaar uit zijn mnemonic.
*
* Dit is de enige weg terug: de mnemonic is wat bewaard wordt, het `OwnerId` is
* er een afgeleide van. Een onleesbare mnemonic gooit, en dat hoort: stil een
* andere eigenaar maken zou een proef opleveren die nergens over gaat.
*/
export const eigenaarUitMnemonic = (mnemonic) => createAppOwner(mnemonicToOwnerSecret(mnemonic));
/**
* Opent de opslag van één eigenaar.
*
* `relayUrl` weglaten of leeg laten geeft een instantie zonder synchronisatie.
* Dat is niet alleen voor toetsen nuttig: het is ook hoe je een eigenaar met
* gegevens vult vóórdat je hem voor het eerst laat verbinden, en dat is wat de
* proef "toelaten en opnieuw verbinden" nodig heeft.
*
* @param {{directory: string, owner: object, relayUrl?: string, onDefect?: Function, consoleLevel?: string}} config
*/
export const openStore = async ({ directory, owner, relayUrl, onDefect, consoleLevel = 'error' }) => {
const { deps, dispose } = createNodeEvoluDeps({ directory, consoleLevel, onDefect });
const run = createRun(deps);
// Het OwnerId gaat mee in de URL, en dat is wat de relay bij de
// WebSocket-upgrade leest om te beslissen of deze eigenaar erin mag. Zonder dat
// is er niets te beslissen en is de hele allowlist niet te beproeven; zie §10
// van Docs/Referenties/Upstream-evolu-relay.md.
const transports = relayUrl
? [createOwnerWebSocketTransport({ url: relayUrl, ownerId: owner.id })]
: [];
const uitkomst = await run(
createEvolu(Schema, { appName: APP_NAME, appOwner: owner, transports }),
);
if (!uitkomst.ok) {
await run[Symbol.asyncDispose]();
await dispose();
throw new Error(`Evolu wilde niet starten: ${JSON.stringify(uitkomst.error)}`);
}
const evolu = uitkomst.value;
return {
ownerId: owner.id,
/** De URL waarmee verbonden wordt, of null bij een instantie zonder sync. */
transportUrl: transports[0]?.url ?? null,
/** Schrijft een blob weg. Geeft het id van de nieuwe rij terug. */
schrijf: (label, body) => evolu.insert('blob', { label, body }),
/** Alle blobs van deze eigenaar, oudste eerst. */
lees: () => evolu.loadQuery(allesQuery),
/** Voor wie meer wil dan schrijven en lezen. */
evolu,
/**
* Sluit af. Eerst de run, dan de afhankelijkheden: de run bezit de instantie
* en die gebruikt de workers die in de afhankelijkheden zitten. Andersom
* eindigt elk afsluiten met een SuppressedError zonder oorzaak.
*/
sluit: async () => {
await run[Symbol.asyncDispose]();
await dispose();
},
};
};
+182
View File
@@ -0,0 +1,182 @@
// ═══════════════════════════════════════════════════════════════════════════════
// Een Evolu-cliënt op Node in elkaar zetten.
//
// Dit is het stuk waarvan het plan zei dat het eerst geverifieerd moest worden,
// en het antwoord was niet wat we hoopten: **`@evolu/nodejs` bevat geen cliënt.**
// Het pakket levert de relay plus losse bouwstenen (de better-sqlite3-driver, een
// BroadcastChannel, een klok), maar er is geen `createEvoluDeps` voor Node zoals
// `@evolu/web` die voor de browser heeft. Wie op Node een cliënt wil, zet de
// afhankelijkheden zelf in elkaar, en dat is wat hier gebeurt.
//
// Nagetrokken op 09-09-2026 in @evolu/common 8.7.0 en @evolu/nodejs 3.1.0. Wat
// `createEvolu` verlangt is `EvoluPlatformDeps`, en dat is de som van zes dingen:
//
// createDbWorker de database-worker
// sharedWorker de gedeelde worker, die de synchronisatie doet
// createBroadcastChannel, createMessageChannel, lockManager, reloadApp
//
// Alles draait hier in één proces. De "worker" en de "shared worker" zijn dus de
// in-memory varianten uit `@evolu/common`, en dat is precies waar die voor
// bestaan: hun eigen documentatie noemt ze de terugvaloptie voor platforms zonder
// echte workers. Voor een testcliënt is dat geen concessie, want er is niets te
// winnen met een tweede draad.
//
// `lockManager` komt van `navigator.locks`. Dat is er sinds Node 24, en het is de
// reden dat `package.json` die ondergrens stelt; de documentatie van Evolu noemt
// "Web and Node.js 24+" bij dit onderdeel met zoveel woorden.
// ═══════════════════════════════════════════════════════════════════════════════
import { join } from 'node:path';
import {
createConsole,
createConsoleStoreOutput,
createMessageChannel,
createMessagePort,
createRun,
createSharedWorker,
createWebSocket,
createWorker,
} from '@evolu/common';
import { createEvoluDeps, initSharedWorker, startDbWorker } from '@evolu/common/local-first';
import { createBetterSqliteDriver, createBroadcastChannel } from '@evolu/nodejs';
/**
* De SQLite-driver, maar met alle bestanden in één map.
*
* `createBetterSqliteDriver` maakt `${name}.db` in de werkmap, waarbij `name` de
* instantienaam is die Evolu uit `appName` en de eigenaar afleidt. Elke eigenaar
* krijgt dus al zijn eigen bestand; wat ontbreekt is een map om ze in te zetten.
* Dit voorvoegsel doet dat zonder `process.chdir`, want dat is procesbreed en
* deze cliënt draait meerdere eigenaars naast elkaar.
*/
const createSqliteDriverIn = (directory) => (name, options) =>
createBetterSqliteDriver(join(directory, name), options);
/**
* Zet de afhankelijkheden neer die `createEvolu` op Node nodig heeft.
*
* Geeft naast de afhankelijkheden een `dispose` terug. Die hoort aangeroepen te
* worden bij het afsluiten: de runs die hieronder gemaakt worden bezitten de
* workers, en een worker die blijft staan houdt het proces open.
*
* @param {{directory: string, consoleLevel?: string}} config
*/
export const createNodeEvoluDeps = ({ directory, consoleLevel = 'warn', onDefect }) => {
const console = createConsole({ level: consoleLevel });
const consoleStoreOutput = createConsoleStoreOutput();
// De basis die zowel de database-worker als de gedeelde worker verlangt.
// `consoleStoreOutputEntry` is hoe een worker zijn logregels naar buiten geeft;
// wij lezen dat niet uit, maar de afhankelijkheid moet er zijn.
const workerDeps = {
console,
consoleStoreOutputEntry: consoleStoreOutput.entry,
createMessagePort,
};
const lockManager = globalThis.navigator?.locks;
if (!lockManager) {
throw new Error(
'navigator.locks ontbreekt. Dit vraagt Node 24 of hoger; zie de kop van dit bestand.',
);
}
// Een defect in een worker is standaard een uitzondering uit een microtaak, en
// die komt dus nergens terecht waar je hem kunt lezen: het proces valt om met
// "An error was suppressed during disposal" en zonder oorzaak. Deze cliënt
// bestaat om fouten zichtbaar te maken, dus dat wordt hier omgeleid.
const reportDefect = (defect) => {
if (onDefect) {
onDefect(defect);
return;
}
console.error('[evolu] defect', defect);
};
// Twee runs, want de twee workers hebben verschillende afhankelijkheden. Ze
// worden hier vastgehouden zodat `dispose` ze allebei kan opruimen.
const dbRun = createRun({
...workerDeps,
createBroadcastChannel,
lockManager,
createSqliteDriver: createSqliteDriverIn(directory),
reportDefect,
});
const sharedRun = createRun({
...workerDeps,
createBroadcastChannel,
createMessageChannel,
createWebSocket,
lockManager,
reportDefect,
});
const createDbWorker = () =>
createWorker((self) => {
void dbRun(startDbWorker(self));
});
// `initSharedWorker` geeft een AsyncDisposableStack terug, en die moet iemand
// bezitten. Laat je hem vallen, dan wordt hij pas opgeruimd als de run eronder
// al weg is, en dat is precies de "Cannot use a disposed object" die deze
// cliënt bij elk afsluiten liet zien.
let sharedWorkerResources = null;
const sharedWorker = createSharedWorker((self) => {
void Promise.resolve(sharedRun(initSharedWorker(self))).then((result) => {
if (result.ok) sharedWorkerResources = result.value;
});
});
// En dan door `createEvoluDeps` heen, want dat is geen optionele verpakking.
// Dáár wordt de gedeelde worker aan de database-worker geknoopt en wordt de
// "tab leader" aangekondigd; zonder die stap breekt de gedeelde worker af met
// "Expected tab leader port." zodra de eerste schrijfactie binnenkomt. Dat is
// de tweede valstrik van vandaag: de typen laten `EvoluPlatformDeps` toe waar
// `EvoluDeps` hoort, dus het compileert en het faalt pas als je iets doet.
//
// Eén keer aanroepen en hergebruiken voor alle eigenaars, zoals de
// documentatie voorschrijft. Dat is ook precies goed voor deze cliënt: in een
// browser bedient één gedeelde worker alle instanties van alle tabbladen, en
// hier bedient hij alle eigenaars in dit proces.
const evoluDeps = createEvoluDeps({
console,
createDbWorker,
createBroadcastChannel,
createMessageChannel,
lockManager,
// Een cliënt zonder venster heeft niets te herladen. Evolu roept dit aan als
// hij de app wil laten herstarten; hier is dat een logregel waard en verder
// niets, want het proces herstarten zou de proef die loopt weggooien.
reloadApp: () => {
console.warn('[evolu] reloadApp aangeroepen, genegeerd in deze cliënt');
},
sharedWorker,
});
return {
deps: evoluDeps,
/**
* Ruimt de workers op. Asynchroon, en dat moet.
*
* Het afsluiten van een worker is zelf een taak op de run die hem draagt.
* Wordt die run meteen weggegooid, dan loopt dat afsluiten tegen "Cannot use
* a disposed object" aan, en dat komt naar buiten als een SuppressedError
* zonder oorzaak. De volgorde is dus: eerst de afhankelijkheden, dan de runs
* afwachten in plaats van weggooien.
*/
dispose: async () => {
evoluDeps[Symbol.dispose]();
// De in-memory workers bezorgen hun berichten asynchroon, dus op dit punt
// staat er nog post in de wacht die bij aankomst zijn eigen run gebruikt.
// Even doorlaten voordat die runs weggaan.
await new Promise((klaar) => {
setImmediate(klaar);
});
if (sharedWorkerResources) await sharedWorkerResources[Symbol.asyncDispose]();
await sharedRun[Symbol.asyncDispose]();
await dbRun[Symbol.asyncDispose]();
},
};
};