UmbrelApps: community app store whatsnext
Electrum Gate, Evolu Relay en Atreides Assets, met de image-recepten in tools/ en de tests in tests/. Begonnen zonder voorgeschiedenis op 09-10-2026. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
85d85a1295
63 files changed
+13987
No files matched your search
@@ -0,0 +1,258 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// 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();
|
||||
},
|
||||
};
|
||||
};
|
||||
Reference in new issue
Block a user