258 lines
11 KiB
JavaScript
258 lines
11 KiB
JavaScript
// ═══════════════════════════════════════════════════════════════════════════════
|
|||
|
|
// 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();
|
||
|
|
},
|
||
|
|
};
|
||
|
|
};
|