// ═══════════════════════════════════════════════════════════════════════════════ // 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'), ); // ═══════════════════════════════════════════════════════════════════════════════ // De gedeelde afhankelijkheden: één stel per proces, voor alle eigenaars samen. // // Dat is geen zuinigheid maar een eis. `createEvoluDeps` vraagt via // `navigator.locks` het slot "tab" aan en kondigt de houder ervan aan als "tab // leader"; de database-worker van een instantie breekt af met "Expected tab // leader port." als die aankondiging er niet is. Eén slot betekent één houder, // dus een tweede stel afhankelijkheden in hetzelfde proces wordt nooit leider en // de tweede eigenaar valt om. // // Precies dat gebeurde op 09-09-2026 bij de gebruiker, bij de tweede eigenaar. // Het stond al als valstrik 2 in PLAN.md §4e en in het commentaar van // evolu-node.js ("Eén keer aanroepen en hergebruiken voor alle eigenaars"), en // het was er alsnog in geslopen omdat `openStore` per aanroep een eigen stel // maakte. In een browser is dit vanzelf goed: daar is er één tabblad met één // gedeelde worker voor alle instanties. // // Meegeteld wordt hoeveel stores er open zijn, zodat het pas wordt opgeruimd als // de laatste sluit. // ═══════════════════════════════════════════════════════════════════════════════ let gedeeld = null; /** Wie er een defect wil horen. Per open store één, en die gaat er bij het sluiten uit. */ const defectMelders = new Set(); /** Wie er wil weten welke WebSocket-URL er werkelijk geopend wordt. */ const socketMelders = new Set(); const haalGedeeldeDeps = (directory, consoleLevel) => { if (gedeeld === null) { const { deps, dispose } = createNodeEvoluDeps({ directory, consoleLevel, onDefect: (defect) => { // Naar iedereen die luistert. Een defect komt uit een gedeelde worker en // is dus niet aan één eigenaar toe te schrijven; doen alsof van wel zou // het onderzoek de verkeerde kant op sturen. for (const melder of defectMelders) melder(defect); }, onWebSocket: (url) => { for (const melder of socketMelders) melder(url); }, }); gedeeld = { directory, deps, dispose, aantal: 0 }; } else if (gedeeld.directory !== directory) { throw new Error( `Er is al een gegevensmap open (${gedeeld.directory}). Eén map per proces; ` + 'sluit eerst alle stores voordat je een andere map gebruikt.', ); } gedeeld.aantal += 1; return gedeeld.deps; }; const laatGedeeldeDepsLos = async () => { if (gedeeld === null) return; gedeeld.aantal -= 1; if (gedeeld.aantal > 0) return; const weg = gedeeld; gedeeld = null; await weg.dispose(); }; /** 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, onWebSocket, consoleLevel = 'error', }) => { const deps = haalGedeeldeDeps(directory, consoleLevel); if (onDefect) defectMelders.add(onDefect); if (onWebSocket) socketMelders.add(onWebSocket); // Wél een eigen run per store, want die bezit de instantie: één eigenaar // sluiten hoort de andere niet te raken. Alleen de afhankelijkheden eronder // zijn gedeeld. 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 HomeGit/Docs/Referenties/UmbrelApps/Upstream-evolu-relay.md. const transports = relayUrl ? [createOwnerWebSocketTransport({ url: relayUrl, ownerId: owner.id })] : []; // De instantie start ZONDER sync en de transport gaat er daarna met `useOwner` // in, ook al is het de eigen appOwner. Dat is geen omweg maar het enige wat // losmaken werkelijk losmaakt. // // Waarom: de socket zit niet in deze instantie maar in de gedeelde worker, en // die blijft staan zolang er nog één store open is (zie `gedeeld` bovenaan). // Een transport die je in de config meegeeft, kun je daarna nergens meer // opzeggen; het gevolg was dat de relay een eigenaar bleef zien nadat je hem // in de cliënt had losgekoppeld of zelfs vergeten, tot je het proces stopte. // Gemeld door de gebruiker op 10-09-2026. `useOwner` geeft een opzegging terug // en Evolu telt de verwijzingen naar een transport, dus de laatste opzegging // sluit de socket. const uitkomst = await run( createEvolu(Schema, { appName: APP_NAME, appOwner: owner, transports: [] }), ); if (!uitkomst.ok) { await run[Symbol.asyncDispose](); if (onDefect) defectMelders.delete(onDefect); if (onWebSocket) socketMelders.delete(onWebSocket); await laatGedeeldeDepsLos(); throw new Error(`Evolu wilde niet starten: ${JSON.stringify(uitkomst.error)}`); } const evolu = uitkomst.value; // `useOwner` eist een niet-lege lijst, dus alleen als er een relay is. let zegSyncOp = transports.length > 0 ? evolu.useOwner(owner, transports) : null; 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. * * Asynchroon, en dat is geen stijlkeuze. `evolu.insert` geeft meteen een id * terug en zet de schrijfactie in de wachtrij van de database-worker; sluit * je vlak daarna af, dan is de rij nooit op schijf beland. Dat kostte op * 09-09-2026 een `lees` die "nog geen blobs" meldde over een blob die * zojuist bevestigd was. De query erachter dwingt de heenreis af. */ schrijf: async (label, body) => { const geschreven = evolu.insert('blob', { label, body }); await evolu.loadQuery(allesQuery); return geschreven; }, /** 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. * * De gedeelde afhankelijkheden gaan pas weg als de láátste store sluit; zie * de uitleg bovenaan bij `gedeeld`. */ sluit: async () => { // Eerst de sync opzeggen en dan de run weggooien. Andersom is de instantie // al weg als de opzegging aankomt. if (zegSyncOp) { zegSyncOp(); zegSyncOp = null; } await run[Symbol.asyncDispose](); if (onDefect) defectMelders.delete(onDefect); if (onWebSocket) socketMelders.delete(onWebSocket); await laatGedeeldeDepsLos(); }, }; };