Compare commits

..

11 Commits

Author SHA1 Message Date
Sylvain Duchesne 1f0bae461e chore: migration install bun→pnpm (runtime/test restent bun)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg
2026-07-14 12:12:00 +02:00
Sylvain Duchesne 70de7afa3c feat(logs): logs données restructurés — identité en préfixe, trace résolution/barrière, inspection outbox
Chemin données bas-niveau du polyfill rendu lisible pour diagnostiquer en session live :

- Format identité-en-premier : `[<identity>][polyfill] OP shortNuri (label)` ;
  console.error épars (store-registry, inbox) unifiés au même préfixe.
- Trace (derrière le flag debug) : issue de la barrière ensureRepoOpen
  (synced|timed-out + durée) et résultat sémantique de chaque étage de résolution
  (resolvePointer/canonicalDoc/resolveAccount/resolveShimDoc/readScopeIndex).
- outbox-log.ts (nouveau) : inspection read-only de l'outbox hors-ligne au
  démarrage de session ; console.warn si non vide (anomalie, toujours visible),
  sous flag si vide. Le comptage seul est fiable (payloads BARE opaques côté JS).

Pas de changement fonctionnel. bun test 126 pass ; tsc 0 erreur.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg
2026-07-14 10:37:46 +02:00
Sylvain Duchesne 3547967d37 chore(client): retirer la migration legacy + alléger les logs d'accès
- Migration des comptes legacy supprimée (migrateLegacyRecords + garde migratedInto
  + call-sites). Un wallet pré-fix (records store-root, pas de pointeur) provisionne
  simplement un doc-shim frais; contenu legacy ignoré (voulu, données = dev). La
  résolution barrière-autoritative + anti-fork (resolvePointer/ensureRepoOpen/
  canonicalDoc/ensureInFlight/pointerGuard) est inchangée.
- Logs d'accès SDK préfixés [polyfill] + NURI tronqué via shortNuri() (retire
  did:ng:o: et :v:…, garde 8 chars) → moins verbeux.
  Ex: [polyfill] [user1] READ vDlwbZio… (resolvePointer) → 1 triple-rows

Tests: bun test unit 126/0. Docs (nextgraph-current-state/simulation/migration-guide)
mis à jour (migration legacy retirée du modèle décrit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 18:08:30 +02:00
Sylvain Duchesne 5e91771da6 fix(client): résolution de compte barrière-autoritative — fin du fork à la reconnexion
Bug: à la reconnexion, resolveAccount lisait le shim depuis le store-root
(did🆖${privateStoreId}), NON abonnable → pas de barrière first-State → un "0 rows"
à froid est ambigu → le retry (resolveAccountReliably/provisionRetry) échoue → nouveau
compte provisionné → FORK → données du compte invisibles.

Cause NextGraph (vérifiée nextgraph-rs): "trouvable-sans-lookup" (store-root) et
"abonnable" (did:ng:o:<RepoID aléatoire>) sont DISJOINTS — pas de doc à la fois
devinable et attendable → une résolution shim purement barrière est impossible.

Fix (indirection pointeur → doc-shim abonnable):
- Les AccountRecord migrent dans un doc-shim doc_create'd (did:ng:o:..., a une barrière).
- Un pointeur écrit-une-fois dans le store-root (<shim:root> <shim:shimDoc> <docShim>)
  le nomme. resolveShimDoc lit le pointeur → ensureRepoOpen(docShim) [barrière] → lecture
  de compte AUTORITATIVE (cold 0 = absent pour de vrai). Retry de compte SUPPRIMÉ.
- Micro-garde résiduel (pointerGuard, ex-provisionRetry) sur le SEUL triple pointeur
  écrit-une-fois; ne peut jamais forker un compte; fork de pointeur réconcilié au
  doc-shim canonique (lexicographiquement-min), sans perte.
- Migration: migrateLegacyRecords copie (pas déplace) les comptes de l'ancien store-root
  vers le doc-shim avant toute conclusion "absent"; idempotent; wallet neuf → no-op.

Tests: unit 128/128, e2e réel 42/42 (CONTRACT 2 = non-fork du compte à la reconnexion),
red-before/green-after prouvé. Docs: nextgraph-current-state (antagonisme + indirection),
simulation, migration-guide.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 17:46:16 +02:00
Sylvain Duchesne 5cdc6ce77f feat(client): inbox.readSynced — lecture inbox gated sur la barrière first-State
ensureRepoOpen(target) (attend le 1er State, barrière de sync déterministe) puis
read(target) — même pattern que discovery.readIndex. Permet au propriétaire d'un
événement de voir un dépôt DÉJÀ synchronisé au lieu de lire trop tôt un inbox vide.
Consommé par Festipod (materializeAttendance). Pas de polling.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:40:37 +02:00
Sylvain Duchesne 3046ead08f fix(client): durcir le cold-start du shim — ensureRepoOpen(anchor) avant lecture/écriture
Défensif : loadShim/resolveAccount/ensureAccount ouvrent l'anchor (private-store-root)
avant de lire/écrire le compte shim, comme le fait déjà readScopeIndex. Robustesse
same-session si l'anchor est là-mais-pas-encore-souscrit.

NB (vérifié nextgraph-rs) : sur le login broker normal, le bootstrap charge le
private-store dans self.repos AVANT de rendre la session à JS → un wallet FRAIS
retourne 0 rows (pas RepoNotFound) et provisionne. Il n'existe AUCUN primitif JS
pour ouvrir un repo *inconnu* : ce heal n'est pas un remède à un store non
bootstrappé (limite NextGraph), juste une robustesse d'ouverture same-session.

Tests : cold-start-anchor.test.ts (rouge-avant/vert-après unit) ; harness e2e
repro-fresh-wallet (mint un wallet neuf par run — comble le trou "aucun test de
démarrage à froid sur wallet vierge"). Fakes anti-fork/watch-shape honorent
désormais la barrière first-State dont dépend le heal. e2e réel 42/42.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 15:06:30 +02:00
Sylvain Duchesne 078d675bbf chore(client): clarifier le log d'accès — "N rows" → "N triple-rows"
Le journal d'accès compte des triplets RDF (?s ?p ?o), pas des objets métier.
Clarification demandée côté Festipod (les logs "readDoc → N rows" étaient ambigus).
Test access-log mis à jour en conséquence.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 15:06:30 +02:00
Sylvain Duchesne 9103996dbe fix(client): dé-dupliquer les ensureAccount concurrents au cold-start (fork résolu)
Dernière couche du bug de reconnexion : au cold-start, `watchShape` public +
protected + l'effet owned-events appellent `ensureAccount(A)` quasi-simultanément
AVANT la sync du shim → chacun lit 0 → chacun provisionne un nouveau jeu de docs
(fork par-appelant) → la résolution déterministe canonique fait alors diverger
lecteur et écrivain sur le docProtected → `readScopeIndex` vide.

Fix : `ensureInFlight` (map de promesses) dé-duplique les provisions concurrentes
en UNE seule ; `discovery.readIndex` ouvre son repo au cold-start (`ensureRepoOpen`).
Avec la résolution canonique déjà committée, écrivain et lecteur convergent.
Mesuré (levier isSuccess) : la participation protected converge `isSuccess=true,
data=1` sur la page fraîche (plus « vide à 30s »).

gate : tsc 0 ; bun test 123 ; test:e2e 42/42.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 11:29:59 +02:00
Sylvain Duchesne bd48b16e31 fix(client): résolution de compte DÉTERMINISTE + anti-fork restauré
Cause racine du bug de reconnexion (probe contrôlé répété) : le shim d'un compte
accumule des `docPublic`/`docProtected` EN DOUBLE (forks passés), et
resolveAccount/indexDocOf les choisissaient de façon NON-DÉTERMINISTE → l'écrivain
et le lecteur (page fraîche) ancraient sur des docs d'index DIFFÉRENTS → lecture 0.

Fix :
- `canonicalDoc()`/`recordFromRows()` : parmi plusieurs valeurs d'un scope, choisir
  le NURI lexicographiquement le plus petit (les NURIs sont content-addressed →
  ordre total stable). Écrivain et lecteur résolvent TOUJOURS le même doc, même sur
  un shim corrompu par des doublons.
- `resolveAccountReliably` RESTAURÉ (retry borné avant provision sur read shim 0 à
  froid) : j'avais retiré l'anti-fork à tort (`38b1521`) — le gap EST exhibé (fork
  non-déterministe au cold-read), et la barrière `user_connect` n'est PAS accessible
  côté JS → un retry borné est la compensation légitime (pas la barrière-store
  cassée de `45dbd9a`). Budget injecté `provisionRetry` ; défaut attempts:1 (fakes
  synchrones), app/e2e attempts:8.

anti-fork.test.ts réécrit : docPublic dupliqué → même canonique ; retry sur lag →
réutilise ; neuf → provision 1×.

gate : tsc 0 ; bun test 122 ; test:e2e 42/42 (CONTRACT 2 non-fork vert).

Portée : corrige la couche docPublic de la reconnexion. Une couche PROTECTED
distincte (watchShape protected ne converge pas à froid) reste — diagnostic en cours.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 10:24:58 +02:00
Sylvain Duchesne 7c233df5c0 feat(client): watchShape — lecture réactive à la forme TanStack useQuery
Phase A du refactor des lectures. Surface la barrière de sync interne
(open-repo `getSyncState`) dans une API useQuery-shaped, en anticipation de la
mise à jour prévue de useShape par NextGraph — distingue nativement « sync en
cours » de « synchronisé mais vide ».

`watchShape<T>(shapeType, scope): { getSnapshot(): ShapeQuery<T>, subscribe(cb),
refetch() }` avec `ShapeQuery = { data, isPending, isSuccess, isError, error }`.
OBSERVABLE (pas de dépendance React — l'app câblera useSyncExternalStore en
phase B) ; getSnapshot rend une référence stable.

- Scope LOGIQUE (public/protected/private) résolu au wallet virtuel :
  listMyEntityDocs(getCurrentUser, scope) + découverte foldée pour public.
- isPending tant que la barrière n'est pas atteinte / 1er readUnion non rendu ;
  isSuccess après ; timed-out → isSuccess (best-effort, pas isError).
- Réactif SANS polling : subscribeDoc sur les docs + le doc d'index de scope
  (+ index découverte) → re-read/re-résolution au push ; souscriptions idempotentes.
- Générique : aucune logique domaine Festipod dans le lib ; filtre par la shape
  SHEX (rdf:type). Pas de double filtre cap.

gate : tsc 0 ; bun test 120 (+4 : pending→success, synced-vide, réactif, timed-out) ;
test:e2e 42 passed (+scénario broker réel : isPending au 1er snapshot → isSuccess
avec données, scope vide → isSuccess data:[]).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 23:17:18 +02:00
Sylvain Duchesne 38b152136b refactor(client): retirer anti-fork — gap non exhibé, resolveAccount simple
Preuve e2e (wallet frais, broker RÉEL rapide : 1er State 1-2 ms) : le private
store est synchronisé au login, la lecture du shim réussit à froid — le « fork sur
lag » que anti-fork compensait n'est PAS exhibé. Par le principe du polyfill
(compenser un gap RÉEL, jamais du poids mort), et par la règle no-polling :
- la version retry = polling (bannie) ;
- la version barrière `ensureRepoOpen(privateStore)` = CASSÉE (un store n'émet pas
  de `State`, la barrière timeout systématiquement → CONTRAT 2 e2e échouait) ;
- le gap = non exhibé.
→ `ensureAccount` fait un `resolveAccount(id)` SIMPLE (une lecture, provision si 0).
`resolveAccountReliably`, `_forceOpenedSyncState` retirés ; `provisionRetry` gardé
optionnel @deprecated (ignoré) pour ne pas casser les 8 tests qui le passent.
`ensureRepoOpen`/`getSyncState` inchangés (chemin de lecture open-repo).

gate : tsc 0 ; bun test 116 ; test:e2e 39 passed, CONTRAT 2 VERT (« same account,
no second provisioning »). Le ~10s du re-resolve public est du scaling anchorless,
pas de la lenteur broker (broker mesuré à 1-2 ms).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 13:47:07 +02:00
31 changed files with 3425 additions and 706 deletions
-266
View File
@@ -1,266 +0,0 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "ng-eventually",
"devDependencies": {
"@types/bun": "latest",
"playwright": "^1.61.1",
"typescript": "^5.6.0",
},
},
"packages/client": {
"name": "@ng-eventually/client",
"version": "0.0.0",
"devDependencies": {
"@ng-org/alien-deepsignals": "0.1.2-alpha.11",
"@ng-org/shex-orm": "0.1.2-alpha.8",
"@ng-org/web": "0.1.2-alpha.13",
},
"peerDependencies": {
"@ng-org/alien-deepsignals": "*",
"@ng-org/orm": "*",
"@ng-org/shex-orm": "*",
"@ng-org/web": "*",
},
"optionalPeers": [
"@ng-org/alien-deepsignals",
"@ng-org/orm",
"@ng-org/shex-orm",
"@ng-org/web",
],
},
},
"packages": {
"@ldo/traverser-shexj": ["@ldo/traverser-shexj@1.0.0-alpha.28", "", { "dependencies": { "@ldo/type-traverser": "^1.0.0-alpha.28" } }, "sha512-N06+LOWhv6//unPRLbFMd56MqPf5lO2ihZgle9hNLmxt6QJmNrZM3oXzHCL3TfDu4OT1/NUZp3kj2HmztQIZkg=="],
"@ldo/type-traverser": ["@ldo/type-traverser@1.0.0-alpha.28", "", { "dependencies": { "uuid": "^8.3.2" } }, "sha512-pGMIVxLzoLjYVhADuVhg6r5ZDNleXZ9DcyIvLXo1/ADEocLnysg/Xjk9D/7l/Rw3WtDJrTFOOtBv8OnH+VPgKA=="],
"@ng-eventually/client": ["@ng-eventually/client@workspace:packages/client"],
"@ng-org/alien-deepsignals": ["@ng-org/alien-deepsignals@0.1.2-alpha.11", "", { "dependencies": { "alien-signals": "^2.0.7" }, "peerDependencies": { "react": "^19.0.0 || ^18.0.0", "svelte": "^5.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["react", "svelte", "vue"] }, "sha512-nPgqOrheAda/pW5FHgSb45SrSZWuyMyEVqO683ijEsVPpD105bngfh92PPfcRoRnFzGSoKXa3CfuqUHi2+qVIQ=="],
"@ng-org/shex-orm": ["@ng-org/shex-orm@0.1.2-alpha.8", "", { "dependencies": { "@ldo/traverser-shexj": "1.0.0-alpha.28", "@ldo/type-traverser": "1.0.0-alpha.28", "@shexjs/parser": "^1.0.0-alpha.24", "commander": "^14.0.1", "dts-dom": "~3.6.0", "ejs": "^3.1.8", "fs-extra": "^10.1.0", "jsonld2graphobject": "^0.0.5", "loading-cli": "^1.1.0", "prettier": "^3.0.3" }, "peerDependencies": { "typescript": "^4.9.0 || ^5.0.0 || ^6.0.0" }, "bin": { "rdf-orm": "dist/cli.js" } }, "sha512-jogxiLfUtI3WkuF5O3PPwRBRWylpapk/JlvElGUqZnEavXXlEMmEGbK3VwqMUsbp1pweNA0sEKIFLIk3Lzt2Gg=="],
"@ng-org/web": ["@ng-org/web@0.1.2-alpha.13", "", { "dependencies": { "async-proxy": "^0.4.1" } }, "sha512-/xO0c+3NTphnws5Do2LDqgZWmAf+aNnYdChJKdU0dnp1U1iVSgi/y3yb8AYryf0v9sooj0aYJxt08B6DpirFMQ=="],
"@rdfjs/types": ["@rdfjs/types@1.1.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-wqpOJK1QCbmsGNtyzYnojPU8gRDPid2JO0Q0kMtb4j65xhCK880cnKAfEOwC+dX85VJcCByQx5zOwyyfCjDJsg=="],
"@shexjs/parser": ["@shexjs/parser@1.0.0-alpha.28", "", { "dependencies": { "@shexjs/util": "^1.0.0-alpha.28", "@ts-jison/parser": "^0.4.1-alpha.1" } }, "sha512-eeVeHq/2JG9X+3h7y+7EmuBSWWl2EMj/EQBLk5CTRx4W4hWDdjWczsY8RWwKjkIzLwUS1+G0aiAI1u5LHCZ2Rw=="],
"@shexjs/term": ["@shexjs/term@1.0.0-alpha.27", "", { "dependencies": { "@types/shexj": "^2.1.6", "rdf-data-factory": "^1.1.2", "relativize-url": "^0.1.0" } }, "sha512-+D7P7pglRPTZC2RkwaQuq+cgBZImx+61JZtcN77uEJVqcGaIscQK5hScsKhAPIo16/I+4jhIUCEFojXqw6otpg=="],
"@shexjs/util": ["@shexjs/util@1.0.0-alpha.28", "", { "dependencies": { "@shexjs/term": "^1.0.0-alpha.27", "@shexjs/visitor": "^1.0.0-alpha.27", "@types/shexj": "^2.1.6", "hierarchy-closure": "^1.2.2", "sync-request": "^6.1.0" } }, "sha512-L8pBokTU/5eNRJPkC8R9SIgPw6/JDh/bHKdV5TZzf8/FkOMNJwKIy6UDHXM1I8FJ+c8u2gOOHp2MA+7b+md+0A=="],
"@shexjs/visitor": ["@shexjs/visitor@1.0.0-alpha.27", "", {}, "sha512-9s67A+f0ZZNw/SNxqoi1483CqUca8dbnHM6WDWsRH4+eXlQpQqwOZDxA8uKEaWeX4VcDrDwzWpr0WvK6EyDWIQ=="],
"@ts-jison/common": ["@ts-jison/common@0.4.1-alpha.1", "", {}, "sha512-SDbHzq+UMD+V3ciKVBHwCEgVqSeyQPTCjOsd/ZNTGySUVg4x3EauR9ZcEfdVFAsYRR38XWgDI+spq5LDY46KvQ=="],
"@ts-jison/lexer": ["@ts-jison/lexer@0.4.1-alpha.1", "", { "dependencies": { "@ts-jison/common": "^0.4.1-alpha.1" } }, "sha512-5C1Wr+wixAzn2MOFtgy7KbT6N6j9mhmbjAtyvOqZKsikKtNOQj22MM5HxT+ooRexG2NbtxnDSXYdhHR1Lg58ow=="],
"@ts-jison/parser": ["@ts-jison/parser@0.4.1-alpha.1", "", { "dependencies": { "@ts-jison/common": "^0.4.1-alpha.1", "@ts-jison/lexer": "^0.4.1-alpha.1" } }, "sha512-xNj+qOez/7dju44LlYiTlCjxMzW5oek9EckUAElfln/GBK9vgMSk0swWcnacMr0TYbGjUQuXvL2wEgmDf5WajQ=="],
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
"@types/concat-stream": ["@types/concat-stream@1.6.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-eHE4cQPoj6ngxBZMvVf6Hw7Mh4jMW4U9lpGmS5GBPB9RYxlFg+CHaVN7ErNY4W9XfLIEn20b4VDYaIrbq0q4uA=="],
"@types/form-data": ["@types/form-data@0.0.33", "", { "dependencies": { "@types/node": "*" } }, "sha512-8BSvG1kGm83cyJITQMZSulnl6QV8jqAGreJsc5tPu1Jq0vTSOiY/k24Wx82JRpWwZSqrala6sd5rWi6aNXvqcw=="],
"@types/http-link-header": ["@types/http-link-header@1.0.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-snm5oLckop0K3cTDAiBnZDy6ncx9DJ3mCRDvs42C884MbVYPP74Tiq2hFsSDRTyjK6RyDYDIulPiW23ge+g5Lw=="],
"@types/jsonld": ["@types/jsonld@1.5.15", "", {}, "sha512-PlAFPZjL+AuGYmwlqwKEL0IMP8M8RexH0NIPGfCVWSQ041H2rR/8OlyZSD7KsCVoN8vCfWdtWDBxX8yBVP+xow=="],
"@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="],
"@types/qs": ["@types/qs@6.15.1", "", {}, "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw=="],
"@types/shexj": ["@types/shexj@2.1.7", "", {}, "sha512-pu/0vIZxFTMPVjTlo5MJKFkBL/EbAuFhtCXpmBB7ZdUiyNpc6pt8GxfyRPqdf6q2SsWu71a/vbhvGK2IZN2Eug=="],
"alien-signals": ["alien-signals@2.0.8", "", {}, "sha512-844G1VLkk0Pe2SJjY0J8vp8ADI73IM4KliNu2OGlYzWpO28NexEUvjHTcFjFX3VXoiUtwTbHxLNI9ImkcoBqzA=="],
"asap": ["asap@2.0.6", "", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="],
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
"async-proxy": ["async-proxy@0.4.1", "", { "dependencies": { "object-path-operator": "^3.0.0" } }, "sha512-4e+zNtoGL4+cnqib8v169CnKcRfAsAubp2EsjBhAA5jyW7jjI3t36rVvuqLwmhtliwf8JvSnxinE4ecQN+DK4w=="],
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="],
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
"caseless": ["caseless@0.12.0", "", {}, "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw=="],
"colors-cli": ["colors-cli@1.0.33", "", { "bin": { "colors": "bin/colors" } }, "sha512-PWGsmoJFdOB0t+BeHgmtuoRZUQucOLl5ii81NBzOOGVxlgE04muFNHlR5j8i8MKbOPELBl3243AI6lGBTj5ICQ=="],
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
"commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
"concat-stream": ["concat-stream@1.6.2", "", { "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^2.2.2", "typedarray": "^0.0.6" } }, "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw=="],
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
"cross-fetch": ["cross-fetch@3.2.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q=="],
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
"dts-dom": ["dts-dom@3.6.0", "", {}, "sha512-on5jxTgt+A6r0Zyyz6ZRHXaAO7J1VPnOd6+AmvI1vH440AlAZZNc5rUHzgPuTjGlrVr1rOWQYNl7ZJK6rDohbw=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="],
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
"filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="],
"form-data": ["form-data@2.5.6", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35", "safe-buffer": "^5.2.1" } }, "sha512-Ogz/E85h9tlfJzpI6TuFpGcHZFhLrb9Gw8wq9v40CxSCPnv7ahKr6Xgtkn0KYCDQJ8DNn5VoMO8EXr9V5PadyA=="],
"fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="],
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-port": ["get-port@3.2.0", "", {}, "sha512-x5UJKlgeUiNT8nyo/AcnwLnZuZNcSjSw0kogRB+Whd1fjjFq4B1hySFxSFWWSn4mIBzg3sRNUDFYc4g5gjPoLg=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
"hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
"hierarchy-closure": ["hierarchy-closure@1.2.2", "", {}, "sha512-ZqZvsA6HyMqrmm49D3llYA8x8hqdyDDEkaTXcqwyO+fGQlzxoeXws/5ze11M40s4EoTw7GFxdTKIwj5YDOicLQ=="],
"http-basic": ["http-basic@8.1.3", "", { "dependencies": { "caseless": "^0.12.0", "concat-stream": "^1.6.2", "http-response-object": "^3.0.1", "parse-cache-control": "^1.0.1" } }, "sha512-/EcDMwJZh3mABI2NhGfHOGOeOZITqfkEO4p/xK+l3NpyncIHUQBoMvCSF/b5GqvKtySC2srL/GGG3+EtlqlmCw=="],
"http-link-header": ["http-link-header@1.1.3", "", {}, "sha512-3cZ0SRL8fb9MUlU3mKM61FcQvPfXx2dBrZW3Vbg5CXa8jFlK8OaEpePenLe1oEXQduhz8b0QjsqfS59QP4AJDQ=="],
"http-response-object": ["http-response-object@3.0.2", "", { "dependencies": { "@types/node": "^10.0.3" } }, "sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
"jake": ["jake@10.9.4", "", { "dependencies": { "async": "^3.2.6", "filelist": "^1.0.4", "picocolors": "^1.1.1" }, "bin": { "jake": "bin/cli.js" } }, "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA=="],
"jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"jsonld-context-parser": ["jsonld-context-parser@2.4.0", "", { "dependencies": { "@types/http-link-header": "^1.0.1", "@types/node": "^18.0.0", "cross-fetch": "^3.0.6", "http-link-header": "^1.0.2", "relative-to-absolute-iri": "^1.0.5" }, "bin": { "jsonld-context-parse": "bin/jsonld-context-parse.js" } }, "sha512-ZYOfvh525SdPd9ReYY58dxB3E2RUEU4DJ6ZibO8AitcowPeBH4L5rCAitE2om5G1P+HMEgYEYEr4EZKbVN4tpA=="],
"jsonld2graphobject": ["jsonld2graphobject@0.0.5", "", { "dependencies": { "@rdfjs/types": "^1.0.1", "@types/jsonld": "^1.5.6", "jsonld-context-parser": "^2.1.5", "uuid": "^8.3.2" } }, "sha512-5BqfXOq96+OBjjiJNG8gQH66pYt6hW88z2SJxdvFJo4XNoVMvqAcUz+JSm/KEWS5NLRnebApEzFrYP3HUiUmYw=="],
"loading-cli": ["loading-cli@1.1.2", "", { "dependencies": { "colors-cli": "^1.0.26" } }, "sha512-M1ntfXHpdGoQxfaqKBOQPwSrTr9EIoTgj664Q9UVSbSnJvAFdribo+Ij//1jvACgrGHaTvfKoD9PG3NOxGj44g=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="],
"node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"object-path-operator": ["object-path-operator@3.0.0", "", {}, "sha512-Z7dlPUeXqRU/lLfGerP24dPC66n7ehyXaTM81k71EFlsaaEjOHkf4/uq1WGicfGfiO7snYShneE1YZZUkyRiLQ=="],
"parse-cache-control": ["parse-cache-control@1.0.1", "", {}, "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"playwright": ["playwright@1.61.1", "", { "dependencies": { "playwright-core": "1.61.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ=="],
"playwright-core": ["playwright-core@1.61.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg=="],
"prettier": ["prettier@3.8.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q=="],
"process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
"promise": ["promise@8.3.0", "", { "dependencies": { "asap": "~2.0.6" } }, "sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg=="],
"qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="],
"rdf-data-factory": ["rdf-data-factory@1.1.3", "", { "dependencies": { "@rdfjs/types": "^1.0.0" } }, "sha512-ny6CI7m2bq4lfQQmDYvcb2l1F9KtGwz9chipX4oWu2aAtVoXjb7k3d8J1EsgAsEbMXnBipB/iuRen5H2fwRWWQ=="],
"readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
"relative-to-absolute-iri": ["relative-to-absolute-iri@1.0.8", "", {}, "sha512-U1TmhrhCmXKkDL9mI8gBbF5TN6TKcuv28k5+H3gMCAjoz0TyyHAICHlaGDZsTEBSu2Y3HhDKc8e6X9n33qeIqA=="],
"relativize-url": ["relativize-url@0.1.0", "", {}, "sha512-YXet4a9wQP96Ru9MQSfoRUzsCaeboLPXj+rVG1ulH4t54zqFHiNmW6FPl7V2dTxk9uHlW3yb9+1jWO44AdWisw=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="],
"side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="],
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
"string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"sync-request": ["sync-request@6.1.0", "", { "dependencies": { "http-response-object": "^3.0.1", "sync-rpc": "^1.2.1", "then-request": "^6.0.0" } }, "sha512-8fjNkrNlNCrVc/av+Jn+xxqfCjYaBoHqCsDz6mt030UMxJGr+GSfCV1dQt2gRtlL63+VPidwDVLr7V2OcTSdRw=="],
"sync-rpc": ["sync-rpc@1.3.6", "", { "dependencies": { "get-port": "^3.1.0" } }, "sha512-J8jTXuZzRlvU7HemDgHi3pGnh/rkoqR/OZSjhTyyZrEkkYQbk7Z33AXp37mkPfPpfdOuj7Ex3H/TJM1z48uPQw=="],
"then-request": ["then-request@6.0.2", "", { "dependencies": { "@types/concat-stream": "^1.6.0", "@types/form-data": "0.0.33", "@types/node": "^8.0.0", "@types/qs": "^6.2.31", "caseless": "~0.12.0", "concat-stream": "^1.6.0", "form-data": "^2.2.0", "http-basic": "^8.1.1", "http-response-object": "^3.0.1", "promise": "^8.0.0", "qs": "^6.4.0" } }, "sha512-3ZBiG7JvP3wbDzA9iNY5zJQcHL4jn/0BWtXIkagfz7QgOL/LqjCEOBQuJNZfu0XYnv5JhKh+cDxCPM4ILrqruA=="],
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
"typedarray": ["typedarray@0.0.6", "", {}, "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
"universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
"http-response-object/@types/node": ["@types/node@10.17.60", "", {}, "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw=="],
"jsonld-context-parser/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
"readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
"string_decoder/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
"then-request/@types/node": ["@types/node@8.10.66", "", {}, "sha512-tktOkFUA4kXx2hhhrB8bIFb5TbwzS4uOhKEmwiD+NoiL0qtP2OQ9mFldbgD4dV1djrlBYP6eBuQZiWjuHUpqFw=="],
"jsonld-context-parser/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
}
}
+7 -4
View File
@@ -43,10 +43,13 @@ in the shim (see the two-axes section in [`simulation.md`](./simulation.md)).
resolvers) is designed to survive that swap unchanged. resolvers) is designed to survive that swap unchanged.
### 3. Drop the resolver / shim ### 3. Drop the resolver / shim
The `sharedWalletShim` (account → 3 scope-document NURIs, RDF in the private store) The `sharedWalletShim` (account → 3 scope-document NURIs, held in a subscribable
has no target equivalent — the target has no central directory. Remove it: doc-shim reached via a write-once pointer in the store-root — see
`store-registry.ts`, `configureStoreRegistry`, the shim SPARQL. Cross-wallet reads [`nextgraph-current-state.md`](./nextgraph-current-state.md) § *The pointer → doc-shim
replace the fan-out; per-user wallets replace the shared one. indirection*) has no target equivalent — the target has no central directory. Remove
it entirely: `store-registry.ts`, `configureStoreRegistry`, the pointer + doc-shim
resolution, and the `pointerGuard` dep. Cross-wallet reads replace the fan-out;
per-user wallets replace the shared one.
### 4. Real inbox → drop the in-lib read emulation ### 4. Real inbox → drop the in-lib read emulation
Replace the emulated `inbox.ts` deposit (`docs.sparqlUpdate` into a shared-wallet Replace the emulated `inbox.ts` deposit (`docs.sparqlUpdate` into a shared-wallet
+68
View File
@@ -217,6 +217,74 @@ broker sync (the `OpenRepo` TODO at `verifier.rs:1423`). Opening still requires
repo's NURI + ReadCap — there is no store-level read inheritance (see repo's NURI + ReadCap — there is no store-level read inheritance (see
§ Capability / ReadCap granularity). § Capability / ReadCap granularity).
### Findable-without-lookup vs subscribable (first-`State` barrier) — DISJOINT
Two properties a fresh session might want from a document, and **no single document
has both**:
- **Findable without a lookup.** The ONLY NURI a fresh session can NAME with nothing
but the session in hand is the store-root, `did:ng:${privateStoreId}` (from
`session.private_store_id`). Any per-document repo is `did:ng:o:<RepoID>` with a
**random** RepoID minted by `doc_create`**not derivable**, so it must be looked
up somewhere first.
- **Subscribable with a sync BARRIER.** `doc_subscribe(nuri)` delivers `TabInfo` then
an initial **`State`** (`verifier.rs:470`/`:476`); that first `State` is the sync
barrier — **after it, presence is guaranteed and absence is definitive** (pinned
empirically by CONTRACT 3 in `packages/client/e2e/`). But this barrier exists only
for a repo `doc_subscribe` can open, i.e. a `did:ng:o:<RepoID>` repo. A **store-root
has no first-`State` barrier**: an anchored read on it can return 0 rows during
sync-lag with no signal distinguishing "still syncing" from "genuinely empty".
These two are **mutually exclusive**: the guessable target (store-root) is not
barrier-authoritative, and the barrier-authoritative target (`o:` repo) is not
guessable. **Consequence:** you cannot build a lookup table that is BOTH reachable
cold (findable) AND authoritative on a cold read (barrier). A cold "0 rows" read of a
store-root graph is therefore fundamentally ambiguous — which is the trap the shim's
account map fell into (see next section).
### The pointer → doc-shim indirection (how the polyfill shim resolves accounts)
`store-registry.ts` keeps a map `identifier → {docPublic, docProtected, docPrivate}`
(the "shim", the account→document trust root). It must be reachable by a fresh
reconnecting session (findable) AND authoritative on a cold read (so a fresh page
does not mistake sync-lag for "account absent" and PROVISION a fork). Since no single
document is both (previous section), the shim uses an **indirection**:
1. **doc-shim** — a `doc_create`d graph document (`did:ng:o:...`, hence a first-`State`
barrier). **All `AccountRecord`s live inside it.** Because it is subscribable, an
anchored read behind its `ensureRepoOpen` barrier is **authoritative**: a cold 0
means the account is genuinely absent.
2. **pointer** — a single well-known, **write-once** triple in the store-root graph,
`<urn:ng-eventually:shim:root> <urn:ng-eventually:shim:shimDoc> <docShimNuri>`.
The store-root is findable-without-lookup, so a fresh session can always read it;
the pointer being the OLDEST, write-once triple in that graph, it is near-always
already synced on a cold read.
**Resolution** (`resolveShimDoc`): read the pointer from the store-root → open the
named doc-shim through its barrier (`ensureRepoOpen`) → read the account
AUTHORITATIVELY. First login (no pointer): `doc_create` the doc-shim, publish the
pointer, done. A **pointer fork** (two devices each writing a pointer before either
synced) is reconciled to the lexicographically-smallest doc-shim NURI
(content-addressed, so every device converges on the same doc-shim).
**The account-level retry is GONE.** Before this indirection the shim lived directly
in the store-root graph, so an account read had no barrier and a cold 0 was ambiguous;
the lib compensated with a **bounded account-level retry** (`provisionRetry` /
`resolveAccountReliably`) that re-read the account several times before concluding
"new". Moving the records behind the doc-shim barrier makes the account read
authoritative on the FIRST read, so **that retry was removed** — a barrier is
deterministic where a retry only guessed. The only residual bounded guard is a small
re-read of the **pointer** itself (`pointerGuard`, one write-once triple): it can
NEVER re-provision or fork an account — at worst it takes a couple extra reads to see
a pointer that is still landing.
**No legacy migration.** A wallet written under the OLD scheme (accounts directly in
the store-root graph, no pointer) is NOT recovered: opening it under the current scheme
simply provisions a fresh doc-shim, and the pre-indirection store-root records are
ignored. This is deliberate — the only such wallets are dev data — so the resolution
path carries no legacy-migration step; it always reads the account authoritatively from
the doc-shim.
### The union is read-only — writes must target one document ### The union is read-only — writes must target one document
`resolve_target_for_sparql(update=true)` returns `InvalidTarget` for `UserSite` / `resolve_target_for_sparql(update=true)` returns `InvalidTarget` for `UserSite` /
+17 -6
View File
@@ -98,12 +98,23 @@ public/protected/private stores — on top of one shared wallet.
`docs.docCreate` primitive. The `scope` (`public|protected|private`) is a `docs.docCreate` primitive. The `scope` (`public|protected|private`) is a
logical attribute tracked here, not a physical store. logical attribute tracked here, not a physical store.
- **The `sharedWalletShim`** is the mapping `account → its 3 scope-document - **The `sharedWalletShim`** is the mapping `account → its 3 scope-document
NURIs`, persisted as RDF in the shared wallet's private store (the anchor, NURIs`. It is persisted as RDF, but **not directly in the store-root graph** — it
always known from the session: `RegistrySession.privateStoreId`). That makes lives in a subscribable **doc-shim** reached through a write-once **pointer** in the
identity resolution cross-device: another device opening the same wallet reads store-root, an indirection forced by a NextGraph fact: "findable-without-lookup"
the same shim and finds the same accounts. It is the account→document trust root, (store-root) and "subscribable / cold-read-authoritative" (`did:ng:o:` repo with a
which is why every untrusted value that reaches its SPARQL is escaped (see first-`State` barrier) are DISJOINT. The pointer (findable) names the doc-shim
SPARQL hardening below). (authoritative); resolution reads the pointer from the store-root, opens the doc-shim
through its barrier, and reads the account authoritatively — so a fresh reconnecting
session never mistakes sync-lag for "account absent" (which would provision a FORK).
Full rationale — including why the old account-level retry (`provisionRetry`) is
removed (pre-indirection store-root records are NOT recovered; such wallets are dev
data and simply get a fresh doc-shim) — is in
[`nextgraph-current-state.md`](./nextgraph-current-state.md) §§ *Findable vs
subscribable* / *The pointer → doc-shim indirection*. This map is the
account→document trust root, which is why every untrusted value that reaches its
SPARQL is escaped (see SPARQL hardening below). It makes identity resolution
cross-device: another device opening the same wallet reads the same pointer → the
same doc-shim → the same accounts.
- **Per-entity documents + per-scope index.** `createEntityDoc(id, scope)` - **Per-entity documents + per-scope index.** `createEntityDoc(id, scope)`
makes a dedicated document for one entity (mirrors the target, where each entity makes a dedicated document for one entity (mirrors the target, where each entity
is its own document/repo with a future inbox) and appends its NURI to the is its own document/repo with a future inbox) and appends its NURI to the
+66
View File
@@ -140,6 +140,72 @@ export async function launchWalletContext(): Promise<BrowserContext> {
}); });
} }
/**
* Create a BRAND-NEW wallet in a BRAND-NEW profile dir and RETURN the launched
* context, without a `.wallet-ready` marker and WITHOUT tearing the context down.
* Unlike {@link ensureWallet} (which reuses one persistent dedicated wallet across
* runs — so it is always "hot"), this mints a genuinely FRESH wallet each call so
* the cold-start (private-store repo not yet in `self.repos`) can be exercised.
*
* Same headless nextgraph.eu creation + first-login-bootstrap flow as ensureWallet,
* but the context stays OPEN and is returned (with its dir) so the caller can then
* open the SDK page in the SAME profile — i.e. the very first app session over a
* wallet that has never run the app. Caller cleans up ctx + dir.
*/
export async function createFreshWalletContext(): Promise<{
ctx: BrowserContext;
dir: string;
name: string;
}> {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ng-eventually-fresh-"));
const name = "ng-fresh-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
const ctx = await chromium.launchPersistentContext(dir, {
headless: true,
executablePath: resolveChromePath(),
args: LAUNCH_ARGS,
});
const page = ctx.pages()[0] || (await ctx.newPage());
page.on("pageerror", () => {});
await page.goto("https://nextgraph.eu/", { waitUntil: "domcontentloaded", timeout: 30000 });
const createButton = page.getByText("Create Wallet", { exact: true });
await createButton.waitFor({ state: "visible", timeout: 15000 });
await createButton.click();
await page.waitForURL("**/account*", { timeout: 15000 }).catch(() => {});
const acceptButton = page.getByText("I accept", { exact: true });
await acceptButton.waitFor({ state: "visible", timeout: 15000 });
await acceptButton.click();
const usernameInput = page.locator("#username-input");
await usernameInput.waitFor({ state: "visible", timeout: 30000 });
await usernameInput.fill(name);
const passwordInput = page.locator("#password-input");
await passwordInput.waitFor({ state: "visible", timeout: 5000 });
await passwordInput.fill(WALLET_PASSWORD);
const submitButton = page.getByText("create my wallet", { exact: false });
await submitButton.waitFor({ state: "visible", timeout: 5000 });
await submitButton.click();
await page.waitForURL("**/#/wallet/login", { timeout: 30000 });
await page.waitForTimeout(2000);
// First login → bootstrap the verifier repos from the broker (this is what a
// brand-new wallet does on its very first unlock).
const walletLink = page.getByText("Click here to login with your wallet");
if (await walletLink.isVisible({ timeout: 5000 }).catch(() => false)) {
await walletLink.click();
await page.waitForTimeout(1000);
}
const loginPassword = page.locator('input[type="password"]');
await loginPassword.waitFor({ state: "visible", timeout: 10000 });
await loginPassword.fill(WALLET_PASSWORD);
await loginPassword.press("Enter");
await page.waitForTimeout(10000);
await page.close().catch(() => {});
return { ctx, dir, name };
}
/** /**
* Launch a persistent context on a FRESH, EMPTY profile dir (its own userDataDir). * Launch a persistent context on a FRESH, EMPTY profile dir (its own userDataDir).
* Empty local storage ⇒ empty verifier repo cache ⇒ the reconnection cold-start: * Empty local storage ⇒ empty verifier repo cache ⇒ the reconnection cold-start:
+121
View File
@@ -0,0 +1,121 @@
/**
* COLD-START repro — a genuinely FRESH wallet (never ran the app) driving the
* shim's first account resolution/provision.
*
* Unlike `run.ts`, which reuses ONE dedicated wallet (always "hot" — its
* private-store repo is already in the verifier's `self.repos`), this mints a
* BRAND-NEW wallet + fresh profile per run and opens the SDK page over it as the
* very first session. It then, in order:
* 1) probes the RAW anchored shim SELECT on `did:ng:${private_store_id}` — the
* cold-start bug surfaces here as `RepoNotFound` (the private-store repo not
* yet open) rather than a silent 0 rows;
* 2) runs `ensureAccount` (resetRegistryCache first) — the app's first-login
* bootstrap — and asserts it provisions 3 scope docs WITHOUT throwing;
* 3) re-resolves the SAME id from a fresh anchored read and asserts it returns
* the SAME docs (real persistence in the shim).
*
* Expected BEFORE the fix: step 1 throws RepoNotFound; step 2/3 fail to persist.
* Expected AFTER the fix: step 1 may still throw (raw, no open), but step 2/3
* succeed because ensureAccount opens the anchor repo before read/write.
*
* Run: `bun run e2e/repro-fresh-wallet.ts`.
*/
import * as fs from "node:fs";
import type { Frame, Page, BrowserContext } from "playwright";
import { buildBundle, serveHarness, createFreshWalletContext, setupBrokerPage } from "./broker";
type Check = { name: string; ok: boolean; detail?: string };
const results: Check[] = [];
function check(name: string, ok: boolean, detail?: string): void {
results.push({ name, ok, detail });
console.log(` [${ok ? "PASS" : "FAIL"}] ${name}${detail ? " — " + detail : ""}`);
}
function sdk<T>(frame: Frame, method: string, ...args: unknown[]): Promise<T> {
return frame.evaluate(
([m, a]) => (window as any).__sdk[m as string](...(a as unknown[])),
[method, args] as const,
) as Promise<T>;
}
async function main(): Promise<void> {
console.log("[repro] building SDK page bundle...");
buildBundle();
const { url, close: closeServer } = await serveHarness();
console.log(`[repro] harness served at ${url}`);
console.log("[repro] creating a BRAND-NEW wallet (fresh profile)...");
let ctx: BrowserContext | null = null;
let dir: string | null = null;
let page: Page | null = null;
try {
const fresh = await createFreshWalletContext();
ctx = fresh.ctx;
dir = fresh.dir;
console.log(`[repro] fresh wallet: ${fresh.name}`);
page = await ctx.newPage();
page.on("pageerror", (e) => console.error("[iframe error]", e.message));
page.on("console", (m) => {
if (m.type() === "error") console.error("[iframe console]", m.text());
});
console.log("[repro] opening SDK page over the FRESH wallet (first-ever app session)...");
const frame = await setupBrokerPage(page, url);
await frame.waitForFunction(() => (window as any).__sdk !== undefined, { timeout: 30000 });
await frame.waitForFunction(() => (window as any).__sdk.status() === "connected", {
timeout: 60000,
});
console.log("[repro] connected. Driving the cold-start shim resolution...");
// 1) RAW anchored shim probe — the diagnostic. Reports RepoNotFound if the
// private-store repo is not yet open in this fresh session.
const probe = await sdk<any>(frame, "shimAnchorProbe");
console.log(
` [DIAG] raw anchored shim read: threw=${probe.threw} error=${probe.error} rows=${probe.rows}`,
);
// 2) ensureAccount — the real bootstrap. This MUST provision cleanly on a fresh
// wallet (all 3 docs truthy, no throw). This is the load-bearing assertion.
const ensured = await sdk<any>(frame, "coldEnsureAccount", "@cold-user-1");
check(
"fresh wallet: ensureAccount provisions the account without throwing",
!ensured.threw && !!ensured.docPublic && !!ensured.docProtected && !!ensured.docPrivate,
ensured.threw
? `THREW: ${ensured.error}`
: `pub=${String(ensured.docPublic).slice(0, 22)}… prot=${String(ensured.docProtected).slice(0, 22)}`,
);
// 3) Re-resolve from a FRESH anchored read — proves the shim actually persisted.
const verified = await sdk<any>(frame, "verifyShimPersisted", "@cold-user-1");
check(
"fresh wallet: the provisioned account persists (re-resolves the SAME docs, no RepoNotFound)",
!verified.threw &&
verified.docPublic === ensured.docPublic &&
verified.docProtected === ensured.docProtected &&
verified.docPrivate === ensured.docPrivate,
verified.threw
? `THREW: ${verified.error}`
: `same=${verified.docPublic === ensured.docPublic && verified.docProtected === ensured.docProtected}`,
);
} finally {
try { if (page) await page.close(); } catch { /* ignore */ }
try { if (ctx) await ctx.close(); } catch { /* ignore */ }
try { if (dir) fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
closeServer();
}
const passed = results.filter((r) => r.ok).length;
const failed = results.length - passed;
console.log(`\n══ cold-start repro: ${passed} passed, ${failed} failed ══`);
if (failed > 0) {
for (const r of results.filter((x) => !x.ok)) console.log(` - ${r.name}: ${r.detail ?? ""}`);
process.exit(1);
}
}
main().catch((e) => {
console.error("[repro] fatal:", e);
process.exit(1);
});
+41
View File
@@ -294,6 +294,47 @@ async function main(): Promise<void> {
check("scope resolvers return NURIs (private distinct from protected/public)", !!r.priv && !!r.prot && !!r.pub && r.priv !== r.prot, `priv=${r.priv?.slice(0,16)}… prot=${r.prot?.slice(0,16)}`); check("scope resolvers return NURIs (private distinct from protected/public)", !!r.priv && !!r.prot && !!r.pub && r.priv !== r.prot, `priv=${r.priv?.slice(0,16)}… prot=${r.prot?.slice(0,16)}`);
}); });
// ── watchShape (reactive useQuery-shaped read) ──────────────────────────
// The real cycle: first subscription reads isPending (barrier not yet crossed),
// then after the broker sync it reaches isSuccess with the seeded datum present.
// A separate empty scope reaches isSuccess with data:[] (synced-but-empty — the
// distinction useShape's upgrade will make native, surfaced here from getSyncState).
console.log("\n── watchShape (reactive useQuery-shaped read) ──");
await step("watchShape: first subscribe isPending → isSuccess with data present", async () => {
const h = "cyc" + Date.now();
const CLS = "urn:e2e:ws:Event";
const seed = await sdk<any>(frame, "watchShapeSeedAndSubscribe", h, CLS);
check("first snapshot after subscribe is isPending (barrier not crossed)", seed.initial.isPending === true && seed.initial.isSuccess === false, `initial=${JSON.stringify(seed.initial)}`);
// Event-driven: wait for the barrier to cross + the datum to land.
await frame.waitForFunction(
(hh) => {
const s = (window as any).__sdk.watchShapeSnapshot(hh as string);
return s && s.isSuccess && s.dataLen >= 1;
},
h,
{ timeout: 30000 },
);
const snap = await sdkGet<any>(frame, "watchShapeSnapshot", h);
check("reaches isSuccess with the seeded datum (titles include 'seeded')", snap.isSuccess && !snap.isPending && !snap.isError && snap.dataLen >= 1 && snap.titles.includes("seeded"), `snap=${JSON.stringify(snap)}`);
await sdk(frame, "watchShapeStop", h);
});
await step("watchShape: empty scope reaches isSuccess with data:[]", async () => {
const h = "empty" + Date.now();
const CLS = "urn:e2e:ws:Event";
await sdk<any>(frame, "watchShapeEmptyStart", h, CLS);
await frame.waitForFunction(
(hh) => {
const s = (window as any).__sdk.watchShapeSnapshot(hh as string);
return s && s.isSuccess;
},
h,
{ timeout: 30000 },
);
const snap = await sdkGet<any>(frame, "watchShapeSnapshot", h);
check("empty scope: isSuccess, data:[] (synced-but-empty, not stuck pending)", snap.isSuccess && !snap.isPending && !snap.isError && snap.dataLen === 0, `snap=${JSON.stringify(snap)}`);
await sdk(frame, "watchShapeStop", h);
});
// ── caps / read-filter (in-memory cap model) ──────────────────────────── // ── caps / read-filter (in-memory cap model) ────────────────────────────
console.log("\n── caps / read-filter (in-memory cap model) ──"); console.log("\n── caps / read-filter (in-memory cap model) ──");
await step("read-filter: protected hidden from stranger", async () => { await step("read-filter: protected hidden from stranger", async () => {
+187
View File
@@ -25,8 +25,10 @@ import {
discovery, discovery,
storeRegistry, storeRegistry,
useShape as libUseShape, useShape as libUseShape,
watchShape,
accounts, accounts,
} from "@ng-eventually/client"; } from "@ng-eventually/client";
import type { ShapeObservable, ShapeQuery } from "@ng-eventually/client";
const { IdentityStore } = accounts; const { IdentityStore } = accounts;
@@ -84,6 +86,13 @@ configureStoreRegistry({
}, },
// Identity normalization used as the shim key (lowercase, strip leading `@`). // Identity normalization used as the shim key (lowercase, strip leading `@`).
normalizeId: (id: string) => id.trim().replace(/^@/, "").toLowerCase(), normalizeId: (id: string) => id.trim().replace(/^@/, "").toLowerCase(),
// REAL broker: enable the POINTER micro-guard. The account records live in a
// subscribable doc-shim reached via a write-once pointer triple in the store-root;
// the account read is barrier-authoritative (no account retry). The only residual
// store-root sync-lag is the pointer read — this bounded guard re-reads JUST that
// one write-once triple on a cold reconnect (CONTRACT 2 non-fork). It can never
// provision or fork an account.
pointerGuard: { attempts: 8, baseMs: 150, maxStepMs: 2000 },
}); });
// ── Bridge marshaling note ───────────────────────────────────────────────── // ── Bridge marshaling note ─────────────────────────────────────────────────
@@ -563,6 +572,184 @@ const identity = new IdentityStore(
return { priv, prot, pub }; return { priv, prot, pub };
}, },
/**
* COLD-START anchor probe. Runs the EXACT shim SELECT the registry issues,
* anchored to `did:ng:${private_store_id}` (the shim anchor), as the very first
* thing in a fresh session — BEFORE anything opens that repo. Reports whether the
* raw anchored query threw `RepoNotFound` (the cold-start bug: the private-store
* repo not yet in `self.repos`) or returned rows. Uses the low-level docs
* primitive directly so nothing (open-repo, ensureAccount) opens the repo first.
*/
async shimAnchorProbe() {
const s = session ?? (await sessionReady);
const anchor = `did:ng:${s.private_store_id}`;
const query =
"SELECT ?acc WHERE { GRAPH <" +
anchor +
"> { ?acc a <urn:ng-eventually:shim:Account> } }";
try {
const res: any = await docs.sparqlQuery(s.session_id, query, undefined, anchor);
const rows = Array.isArray(res) ? res.length : (res?.results?.bindings?.length ?? 0);
return { threw: false, error: null, rows, anchor };
} catch (e: any) {
return { threw: true, error: String(e?.message ?? e), rows: -1, anchor };
}
},
/**
* COLD-START account provision. resetRegistryCache then ensureAccount(id) — the
* real bootstrap the app runs on first login. On a fresh wallet, if the anchor
* repo isn't open, resolveAccount's read AND ensureAccount's provision write both
* hit RepoNotFound; the account never persists. Returns the 3 scope docs (all
* truthy iff provisioning succeeded) so the runner can gate on real persistence.
*/
async coldEnsureAccount(id: string) {
storeRegistry.resetRegistryCache();
try {
const rec = await storeRegistry.ensureAccount(id);
return {
threw: false,
error: null,
docPublic: rec.docPublic,
docProtected: rec.docProtected,
docPrivate: rec.docPrivate,
};
} catch (e: any) {
return { threw: true, error: String(e?.message ?? e), docPublic: "", docProtected: "", docPrivate: "" };
}
},
/**
* VERIFY the provisioned account actually PERSISTED to the shim: resetRegistryCache
* then re-resolve the SAME id via a fresh anchored read. Returns whether the read
* threw + the resolved docs. After the fix, on a fresh wallet this returns the SAME
* docs coldEnsureAccount minted (real persistence, no RepoNotFound).
*/
async verifyShimPersisted(id: string) {
storeRegistry.resetRegistryCache();
try {
const rec = await storeRegistry.ensureAccount(id);
return { threw: false, error: null, docPublic: rec.docPublic, docProtected: rec.docProtected, docPrivate: rec.docPrivate };
} catch (e: any) {
return { threw: true, error: String(e?.message ?? e), docPublic: "", docProtected: "", docPrivate: "" };
}
},
// ── watchShape (reactive useQuery-shaped read) ───────────────────────────
// A minimal SHEX-ish ShapeType pinning rdf:type to an e2e class IRI. watchShape
// reads only `.shape`/`.schema[...].predicates[rdf:type].dataTypes[].literals`
// to know which @type to keep — generic, no application domain.
_wsProbes: {} as Record<
string,
{ obs: ShapeObservable; initial: ShapeQuery; unsub: () => void }
>,
/**
* REAL-BROKER cycle proof. Under a fresh identity, create ONE protected entity
* document carrying an rdf:type=<e2e class> triple, then open a `watchShape` over
* (that class shape, "protected"). Capture the FIRST snapshot right after subscribe
* (must be isPending) so the caller can then wait event-driven for isSuccess with
* the seeded datum present. Returns the handle + the initial snapshot + the seed
* doc/type so the runner can assert the data landed.
*/
async watchShapeSeedAndSubscribe(handle: string, cls: string) {
storeRegistry.resetRegistryCache();
const id = "@ws-" + handle;
setCurrentUser(id);
const doc = await storeRegistry.createEntityDoc(id, "protected");
const s = await sessionReady;
// Seed the entity doc with the shape's type + a title (anchored default graph).
await docs.sparqlUpdate(
s.session_id,
`INSERT DATA { <${doc}> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <${cls}> ; <urn:e2e:ws:title> "seeded" }`,
doc,
);
// Wait until this session sees the index append (data persisted on the broker).
for (let i = 0; i < 15; i++) {
storeRegistry.resetRegistryCache();
const listed = await storeRegistry.listMyEntityDocs(id, "protected");
if (listed.includes(doc)) break;
await new Promise((r) => setTimeout(r, 1000));
}
const shape = {
shape: "urn:e2e:ws:Shape",
schema: {
"urn:e2e:ws:Shape": {
iri: "urn:e2e:ws:Shape",
predicates: [
{
iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
readablePredicate: "type",
maxCardinality: 1,
minCardinality: 1,
dataTypes: [{ literals: [cls], valType: "iri" }],
},
],
},
},
};
const obs = watchShape(shape, "protected") as ShapeObservable;
const unsub = obs.subscribe(() => {});
// First snapshot immediately after subscribe — the barrier is not yet crossed
// (docs opening via doc_subscribe), so this MUST be pending.
const initial = obs.getSnapshot();
(window as any).__sdk._wsProbes[handle] = { obs, initial, unsub };
// NB: leave the current user SET for the probe's lifetime — watchShape resolves
// the scope from getCurrentUser() on every (reactive) refresh, exactly as the app
// keeps a stable identity. watchShapeStop clears it.
return { doc, cls, initial: { isPending: initial.isPending, isSuccess: initial.isSuccess, dataLen: initial.data.length } };
},
/** Current snapshot of a watchShape probe (event-driven poll target). */
watchShapeSnapshot(handle: string) {
const rec = (window as any).__sdk._wsProbes[handle];
if (!rec) return null;
const snap: ShapeQuery = rec.obs.getSnapshot();
return {
isPending: snap.isPending,
isSuccess: snap.isSuccess,
isError: snap.isError,
dataLen: snap.data.length,
// The seeded title, if the datum is present (proves the real data landed).
titles: snap.data.flatMap((d: any) => d.props?.["urn:e2e:ws:title"] ?? []),
};
},
watchShapeStop(handle: string) {
const rec = (window as any).__sdk._wsProbes[handle];
if (rec) rec.unsub();
setCurrentUser(null);
},
/**
* EMPTY-scope proof: a brand-new identity with NO entity docs of `scope`. watchShape
* must reach isSuccess with data:[] (synced-but-empty), NOT stay pending. Returns
* the handle; poll watchShapeSnapshot for the transition.
*/
watchShapeEmptyStart(handle: string, cls: string) {
storeRegistry.resetRegistryCache();
const id = "@ws-empty-" + handle;
setCurrentUser(id);
const shape = {
shape: "urn:e2e:ws:Shape",
schema: {
"urn:e2e:ws:Shape": {
iri: "urn:e2e:ws:Shape",
predicates: [
{
iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
readablePredicate: "type",
maxCardinality: 1,
minCardinality: 1,
dataTypes: [{ literals: [cls], valType: "iri" }],
},
],
},
},
};
const obs = watchShape(shape, "protected") as ShapeObservable;
const unsub = obs.subscribe(() => {});
const initial = obs.getSnapshot();
(window as any).__sdk._wsProbes[handle] = { obs, initial, unsub };
// Keep the identity set for the probe's lifetime (watchShapeStop clears it): a
// faithful "empty scope for a real identity", not "no identity at all".
return { initial: { isPending: initial.isPending, isSuccess: initial.isSuccess } };
},
// ── caps / read-filter (in-memory cap model) ───────────────────────────── // ── caps / read-filter (in-memory cap model) ─────────────────────────────
// The read-filter over the injected useShape Set-like. Boundary note: the // The read-filter over the injected useShape Set-like. Boundary note: the
// caps/read-filter are EMULATED in-memory (CapRegistry) — the real broker does // caps/read-filter are EMULATED in-memory (CapRegistry) — the real broker does
+50 -5
View File
@@ -56,17 +56,62 @@ export function enabled(): boolean {
* (`getCurrentUser`) — the account/space the operation is scoped under, which is * (`getCurrentUser`) — the account/space the operation is scoped under, which is
* the discriminating signal for the isolation leak. NOT the physical wallet id * the discriminating signal for the isolation leak. NOT the physical wallet id
* (shared, constant → useless). `(none)` when no identity is set yet (startup). * (shared, constant → useless). `(none)` when no identity is set yet (startup).
* Exported so every other polyfill-layer log site (store-registry, inbox,
* outbox-log, …) shares the exact same identity resolution as the access log,
* instead of re-deriving it — see {@link accessLogPrefix}.
*/ */
function activeIdentity(): string { export function activeIdentity(): string {
return getCurrentUser() ?? "(none)"; return getCurrentUser() ?? "(none)";
} }
/**
* The common line prefix for every polyfill-layer low-level-data-path log:
* `[<identity>][polyfill]` — identity FIRST (the discriminating scan signal),
* `[polyfill]` glued right after with no space between the two brackets. Used by
* {@link logAccess} itself, by the unified `console.error`s in store-registry.ts /
* inbox.ts, and by the BARRIER / stage-resolution / OUTBOX lines (open-repo.ts,
* store-registry.ts, outbox-log.ts) — one single prefix builder so every polyfill
* log line is visually groupable by identity when scanning a live session.
*/
export function accessLogPrefix(): string {
return "[" + activeIdentity() + "][polyfill]";
}
/**
* Emit one CONCISE diagnostic line — ONLY when {@link enabled} — prefixed by
* {@link accessLogPrefix}. Used for the precise data-path trace (BARRIER
* resolution, per-stage resolution outcome, the empty-OUTBOX line): a single
* line per stage/event, never a dump. Callers pass the fully-composed suffix
* (e.g. `"BARRIER " + shortNuri(nuri) + " synced (842ms)"`).
*/
export function logStage(line: string): void {
if (!enabled()) return;
console.log(accessLogPrefix() + " " + line);
}
/**
* Shorten a NURI for the access log: the full form (`did:ng:o:<RepoID>:v:<...>`,
* ~100 chars) is too verbose to scan. Drop the `did:ng:o:` prefix and the `:v:<...>`
* overlay suffix, and keep the first 8 chars of the RepoID + an ellipsis — short but
* still identifiable (`vDlwbZio…`). A NURI that doesn't match the expected shape is
* returned unchanged (best-effort, this is only a diagnostic label).
*/
export function shortNuri(nuri: string): string {
const withoutPrefix = nuri.replace(/^did:ng:o:/, "");
const repoId = withoutPrefix.split(":v:")[0] ?? withoutPrefix;
return repoId.length > 8 ? repoId.slice(0, 8) + "…" : repoId;
}
/** /**
* Log one document access — but ONLY when {@link enabled}. Off → returns * Log one document access — but ONLY when {@link enabled}. Off → returns
* immediately, prints nothing. Format: * immediately, prints nothing. Format:
* `[<identity>] READ <nuri> (<label>)` — optionally with `<extra>` appended * `[<identity>][polyfill] READ <shortNuri> (<label>)` — identity FIRST (the
* (e.g. ` → 3 rows`, a strong signal a doc rendered data under an identity that * discriminating scan signal), `[polyfill]` glued right after with no space
* should see nothing). * between the two brackets — optionally with `<extra>` appended (e.g. ` → 3
* rows`, a strong signal a doc rendered data under an identity that should see
* nothing). The `[polyfill]` tag marks these as SDK-layer access logs (distinct
* from the consumer app's own logs). The NURI is shortened by {@link shortNuri}
* to keep the line scannable.
*/ */
export function logAccess( export function logAccess(
op: AccessOp, op: AccessOp,
@@ -76,6 +121,6 @@ export function logAccess(
): void { ): void {
if (!enabled()) return; if (!enabled()) return;
console.log( console.log(
"[" + activeIdentity() + "] " + op + " " + nuri + " (" + label + ")" + (extra ?? ""), accessLogPrefix() + " " + op + " " + shortNuri(nuri) + " (" + label + ")" + (extra ?? ""),
); );
} }
+25
View File
@@ -39,6 +39,7 @@
import * as inbox from "./inbox"; import * as inbox from "./inbox";
import { subscribeDoc } from "./subscribe"; import { subscribeDoc } from "./subscribe";
import { ensureRepoOpen } from "./open-repo";
import { ensureAccount, reservedAccount } from "./store-registry"; import { ensureAccount, reservedAccount } from "./store-registry";
import { getCaps } from "./polyfill"; import { getCaps } from "./polyfill";
import type { Nuri, PrincipalId } from "./types"; import type { Nuri, PrincipalId } from "./types";
@@ -101,6 +102,17 @@ async function indexInboxNuri(): Promise<Nuri> {
return record.docPublic; return record.docPublic;
} }
/**
* The NURI of the global discovery-index document (the inbox where submissions
* land). Exposed so a reactive reader ({@link watchShape}) that folds discovery
* into the public read-set can SUBSCRIBE to this document and re-resolve when a
* new public entity is announced. This is exactly {@link watchIndex}'s subscribe
* anchor. Removed against real NextGraph along with the special account.
*/
export async function indexDocNuri(): Promise<Nuri> {
return indexInboxNuri();
}
/** /**
* Submit a reference to the global discovery index — the SDK act "make this * Submit a reference to the global discovery index — the SDK act "make this
* discoverable". Deposits `ref` into the index document's inbox via * discoverable". Deposits `ref` into the index document's inbox via
@@ -143,6 +155,19 @@ export async function submitToIndex(ref: unknown, opts?: SubmitOptions): Promise
*/ */
export async function readIndex(): Promise<IndexEntry[]> { export async function readIndex(): Promise<IndexEntry[]> {
const target = await indexInboxNuri(); const target = await indexInboxNuri();
// COLD-START heal (polyfill-era): on a FRESH session over a persistent wallet the
// discovery-index inbox repo is not yet in the verifier's `self.repos`, so the
// anchored `inbox.read` below would resolve an unopened repo and silently return 0
// deposits — the same self-inflicted cold-read gap `readScopeIndex`/`readUnion`
// heal. This is what made the PUBLIC read's discovery fold come back empty on a
// reconnect, so a fresh page's home stayed empty for tens of seconds while the doc
// slowly synced by other means. Open/subscribe the index repo ONCE and await its
// first `State` (the sync barrier) before the anchored read. Idempotent per session;
// no-op with the unit fake ng (no `doc_subscribe`). This is done HERE (a cold direct
// reader) rather than inside `inbox.read`, because `inbox.watch` already holds the
// repo open via its own subscription and must not spawn a second bootstrap open. See
// open-repo.ts.
await ensureRepoOpen(target);
const deposits = await inbox.read(target); const deposits = await inbox.read(target);
const seen = new Set<string>(); const seen = new Set<string>();
const entries: IndexEntry[] = []; const entries: IndexEntry[] = [];
+5 -1
View File
@@ -93,7 +93,11 @@ export async function sparqlQuery(
// rows under an identity that should see nothing) can be appended. Skip the // rows under an identity that should see nothing) can be appended. Skip the
// rowCount work entirely when the log is off. // rowCount work entirely when the log is off.
if (accessLogEnabled()) { if (accessLogEnabled()) {
logAccess("READ", anchor ?? "(no anchor)", label, " → " + rowCount(result) + " rows"); // `rows` here are raw RDF triple bindings (the SPARQL `?s ?p ?o` result), NOT
// domain objects — one document's entity is spread across several triple rows.
// Spell that out so the log isn't mistaken for an object count (the app-level
// object/shape count is logged separately by useShapeQuery → dataStats).
logAccess("READ", anchor ?? "(no anchor)", label, " → " + rowCount(result) + " triple-rows");
} }
return result; return result;
} }
+35 -1
View File
@@ -26,8 +26,10 @@
import { sparqlUpdate, sparqlQuery } from "./docs"; import { sparqlUpdate, sparqlQuery } from "./docs";
import { subscribeDoc } from "./subscribe"; import { subscribeDoc } from "./subscribe";
import { ensureRepoOpen } from "./open-repo";
import { getCurrentUser, getStoreRegistryDeps } from "./polyfill"; import { getCurrentUser, getStoreRegistryDeps } from "./polyfill";
import { escapeLiteral } from "./sparql"; import { escapeLiteral } from "./sparql";
import { accessLogPrefix } from "./access-log";
import type { Nuri, PrincipalId } from "./types"; import type { Nuri, PrincipalId } from "./types";
// --- deposit model -------------------------------------------------------- // --- deposit model --------------------------------------------------------
@@ -156,6 +158,13 @@ export async function post(targetInbox: Nuri, opts: PostOptions): Promise<void>
*/ */
export async function read(targetInbox: Nuri): Promise<Deposit[]> { export async function read(targetInbox: Nuri): Promise<Deposit[]> {
const sid = await sessionId(); const sid = await sessionId();
// NOTE: cold-start repo opening is done by the COLD DIRECT readers that need it
// (e.g. `discovery.readIndex` → `ensureInboxRepoOpen`), NOT here — `inbox.watch`
// already holds the repo open via its own `subscribeDoc`, so opening a second
// bootstrap subscription from inside a watch's re-read would be redundant and can
// race the watch's own initial-`State` delivery. Keeping `read` a pure anchored
// read leaves both callers correct: the watch path stays event-driven, and the
// cold direct-read path opens the repo explicitly before calling `read`.
// NO explicit `GRAPH <…>` clause — read the anchored DEFAULT graph (see the // NO explicit `GRAPH <…>` clause — read the anchored DEFAULT graph (see the
// note in `post`). The anchor (`targetInbox`) scopes the query to that repo's // note in `post`). The anchor (`targetInbox`) scopes the query to that repo's
// default graph, exactly where `post` writes. // default graph, exactly where `post` writes.
@@ -188,6 +197,31 @@ export async function read(targetInbox: Nuri): Promise<Deposit[]> {
/** Alias for {@link read} — the name that reads as "process the inbox now". */ /** Alias for {@link read} — the name that reads as "process the inbox now". */
export const materialize = read; export const materialize = read;
/**
* COLD, BARRIER-GATED read of `targetInbox` — the reliable "process the inbox at
* (re)connection" read. Opens/subscribes the inbox repo and AWAITS its first
* `State` (the deterministic sync barrier — after it, presence is guaranteed and
* absence definitive, {@link ensureRepoOpen}) BEFORE the anchored {@link read}.
*
* Why this over a plain {@link read}: on a FRESH session over the persistent
* wallet (a (re)connection / new page), the inbox repo is not yet in the verifier's
* `self.repos`, so a plain anchored `read` resolves an unopened repo and silently
* returns 0 deposits — even for a deposit a remote session already synced to the
* broker. Gating on the sync barrier makes the read see the synced deposits. This
* is the SAME cold-read heal `discovery.readIndex` applies to the index inbox.
*
* NOT for the `watch` path: {@link watch} already holds the repo open via its own
* `subscribeDoc`, so opening a second bootstrap subscription from inside a watch
* re-read would be redundant and could race the watch's own initial-`State`
* delivery. Use this from a COLD reader (materialize-at-connection), like
* `discovery.readIndex` does. Idempotent per session (no polling); a no-op open on
* the unit fake-ng path (no `doc_subscribe`) so `bun test` is unaffected.
*/
export async function readSynced(targetInbox: Nuri): Promise<Deposit[]> {
await ensureRepoOpen(targetInbox);
return read(targetInbox);
}
/** /**
* Subscription over an inbox — **event-driven, not polled**. Subscribes to the * Subscription over an inbox — **event-driven, not polled**. Subscribes to the
* inbox document via {@link subscribeDoc} (the platform's `doc_subscribe` push): * inbox document via {@link subscribeDoc} (the platform's `doc_subscribe` push):
@@ -222,7 +256,7 @@ export function watch(
onDeposits(deposits); onDeposits(deposits);
} }
} catch (error) { } catch (error) {
console.error("[inbox] watch read failed:", error); console.error(accessLogPrefix() + " watch read failed:", error);
} }
}; };
+2
View File
@@ -13,6 +13,8 @@
export * from "./types"; export * from "./types";
export { useShape } from "./use-shape"; export { useShape } from "./use-shape";
export { watchShape } from "./watch-shape";
export type { ShapeQuery, ShapeObservable } from "./watch-shape";
export { init, initNg } from "./lifecycle"; export { init, initNg } from "./lifecycle";
export * as inbox from "./inbox"; export * as inbox from "./inbox";
export * as discovery from "./discovery"; export * as discovery from "./discovery";
+20 -12
View File
@@ -61,6 +61,7 @@
import { getConfig, getStoreRegistryDeps } from "./polyfill"; import { getConfig, getStoreRegistryDeps } from "./polyfill";
import { subscribeDoc, type Unsubscribe } from "./subscribe"; import { subscribeDoc, type Unsubscribe } from "./subscribe";
import { logStage, shortNuri } from "./access-log";
import type { Nuri } from "./types"; import type { Nuri } from "./types";
/** /**
@@ -91,7 +92,17 @@ let boundSessionId: string | number | null = null;
* pathological case (a doc that never pushes), so a read is never blocked forever — * pathological case (a doc that never pushes), so a read is never blocked forever —
* it just proceeds (and yields 0 rows, exactly as before, for a genuinely-absent doc). * it just proceeds (and yields 0 rows, exactly as before, for a genuinely-absent doc).
*/ */
const OPEN_TIMEOUT_MS = 8000; let OPEN_TIMEOUT_MS = 8000;
/**
* Override the bootstrap-open fallback timeout (ms). TEST-ONLY: the timed-out
* branch (a doc that never pushes a `State`) is otherwise only reachable after the
* 8s production wait, too slow for a unit test. Production never calls this — the
* default stands. `resetOpenedRepos` restores the default.
*/
export function setOpenTimeoutForTests(ms: number): void {
OPEN_TIMEOUT_MS = ms;
}
/** Reset the open registry (mainly for tests / a switched wallet). Tears down the /** Reset the open registry (mainly for tests / a switched wallet). Tears down the
* held bootstrap subscriptions so a subsequent open re-subscribes cleanly. */ * held bootstrap subscriptions so a subsequent open re-subscribes cleanly. */
@@ -108,17 +119,7 @@ export function resetOpenedRepos(): void {
held.clear(); held.clear();
syncState.clear(); syncState.clear();
boundSessionId = null; boundSessionId = null;
} OPEN_TIMEOUT_MS = 8000;
/**
* @internal TEST-ONLY — force a nuri into the opened registry with a given sync
* state, without going through `doc_subscribe`. Used by `anti-fork.test.ts` to
* simulate a timed-out barrier without waiting the full `OPEN_TIMEOUT_MS` (8s).
* Do NOT call from production code.
*/
export function _forceOpenedSyncState(nuri: Nuri, state: SyncState): void {
opened.add(nuri);
syncState.set(nuri, state);
} }
/** /**
@@ -182,6 +183,11 @@ export async function ensureRepoOpen(nuri: Nuri): Promise<void> {
// Subscribed, first `State` not yet seen. // Subscribed, first `State` not yet seen.
syncState.set(nuri, "syncing"); syncState.set(nuri, "syncing");
// Barrier clock: how long the subscribe→first-State (or fallback) round-trip
// took, surfaced on the BARRIER trace line below — the most important line in
// the whole low-level data-path trace: it distinguishes a genuine absence
// (`synced` → a 0-row read means it) from a not-yet-synced read (`timed-out`).
const barrierStartedAt = Date.now();
const p = (async () => { const p = (async () => {
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
@@ -191,6 +197,7 @@ export async function ensureRepoOpen(nuri: Nuri): Promise<void> {
if (settled) return; if (settled) return;
settled = true; settled = true;
syncState.set(nuri, "synced"); syncState.set(nuri, "synced");
logStage("BARRIER " + shortNuri(nuri) + " synced (" + (Date.now() - barrierStartedAt) + "ms)");
resolve(); resolve();
}; };
// Bounded fallback: proceed WITHOUT a `State`, but mark "timed-out" — NOT // Bounded fallback: proceed WITHOUT a `State`, but mark "timed-out" — NOT
@@ -200,6 +207,7 @@ export async function ensureRepoOpen(nuri: Nuri): Promise<void> {
if (settled) return; if (settled) return;
settled = true; settled = true;
syncState.set(nuri, "timed-out"); syncState.set(nuri, "timed-out");
logStage("BARRIER " + shortNuri(nuri) + " timed-out (" + (Date.now() - barrierStartedAt) + "ms)");
resolve(); resolve();
}; };
// The bootstrap subscription is kept ALIVE for the session — holding it open // The bootstrap subscription is kept ALIVE for the session — holding it open
+98
View File
@@ -0,0 +1,98 @@
/**
* outbox-log — read-only diagnostic inspection of `@ng-org/web`'s offline write
* outbox, at session bootstrap (polyfill-era, low-level-data-path trace).
*
* ── What this surfaces ──────────────────────────────────────────────────────
* `@ng-org/web` (the real injected SDK) queues writes made while offline/
* disconnected in an "outbox", persisted client-side in `sessionStorage` by the
* WASM verifier (see `sdk/rust/src/local_broker.rs` `JsStorageConfig::
* get_js_storage_config` in the `nextgraph-rs` core repo — read-only reference,
* NOT vendored here). A non-empty outbox at session start is an ANOMALY worth
* surfacing unconditionally: it means writes from a previous (disconnected)
* session are still queued and haven't reached the broker yet.
*
* ── sessionStorage key shapes (verified in the core repo, not guessed) ──────
* The outbox is keyed per LOCAL PEER id (`peer_id`, the persistent local peer's
* pubkey — NOT the ng-eventually shim's `account`/`identity` concept), via two
* key families written by `session_write`/read by `session_read`:
* - `ng_peer_last_seq@<peerId>` — the peer's last reserved seq number.
* - `ng_outboxes@<peerId>@start` — the seq number the outbox starts at.
* - `ng_outboxes@<peerId>@<00000-idx>` — one queued (base64url + BARE-encoded)
* event per zero-padded index, contiguous from 0 until the first miss (the
* exact shape `outbox_read_function` walks — see `local_broker.rs`).
* We don't know `peerId` ahead of time (it's internal to the injected SDK), so
* we DISCOVER it by scanning `sessionStorage` for `@start` markers instead of
* requiring it to be passed in — this also means the probe works unchanged
* however many peers/wallets the browser session has touched.
*
* ── Read-only, defensive, best-effort ────────────────────────────────────────
* This NEVER writes or deletes a key (unlike the real `outbox_read_function`,
* which drains on read) — it only counts. The queued event bytes are opaque
* (BARE-encoded Rust structs, base64url'd); decoding them to report concrete
* write TARGETS (topics/docs) would mean duplicating the WASM verifier's wire
* format in this polyfill, which is explicitly out of scope (SDK internals live
* in the `@ng-eventually/client`-independent core repo, per this repo's
* doctrine) — so only the pending COUNT is reported, never fabricated targets.
* `sessionStorage` access itself can throw (sandboxed iframe, disabled storage —
* see the exact error string handled in the core repo's `main.ts`
* `convert_error`), so the whole probe is wrapped in one try/catch: unavailable
* → skip silently, never throw into the caller.
*
* Polyfill-era; removed at the real multi-store migration alongside the rest of
* this low-level trace instrumentation.
*/
import { accessLogPrefix, logStage } from "./access-log";
/** Matches an outbox "start" marker key, capturing the peer id. */
const OUTBOX_START_KEY = /^ng_outboxes@(.+)@start$/;
/** Safety bound on the per-peer index walk, so a corrupted/mocked storage
* (e.g. a `@start` marker with no matching index gaps) can't spin forever.
* Real outboxes are queued-while-offline writes — nowhere near this size. */
const MAX_SCAN_PER_PEER = 10_000;
/**
* Inspect the outbox NOW and log its state — count only, never targets (see
* module doc). Non-empty → `console.warn`, ALWAYS printed (anomaly, not gated
* by the access-log flag). Empty → a normal {@link logStage} line, gated by the
* access-log flag like the rest of the low-level trace. Read-only: never
* mutates `sessionStorage`. Never throws.
*/
export function inspectOutbox(): void {
try {
const storage = (globalThis as any)?.sessionStorage;
if (!storage) return;
const peers = new Set<string>();
const length: number = storage.length ?? 0;
for (let i = 0; i < length; i++) {
const key = storage.key?.(i);
if (!key) continue;
const m = OUTBOX_START_KEY.exec(key);
const peerId = m?.[1];
if (peerId) peers.add(peerId);
}
let total = 0;
for (const peer of peers) {
let idx = 0;
while (idx < MAX_SCAN_PER_PEER) {
const idxKey = "ng_outboxes@" + peer + "@" + String(idx).padStart(5, "0");
if (storage.getItem(idxKey) === null) break;
total++;
idx++;
}
}
if (total > 0) {
// Anomaly: ALWAYS visible, regardless of the access-log flag.
console.warn(accessLogPrefix() + " OUTBOX " + total + " pending write(s)");
} else {
logStage("OUTBOX empty");
}
} catch {
// sessionStorage unavailable / access denied — skip silently. Diagnostic
// only, never a hard dependency of the read/write path.
}
}
+41 -22
View File
@@ -12,6 +12,7 @@ import type { NgLike, UseShapeLike, PrincipalId } from "./types";
import type { RegistrySession } from "./store-registry"; import type { RegistrySession } from "./store-registry";
import { CapRegistry } from "./caps"; import { CapRegistry } from "./caps";
import { setAccessLog } from "./access-log"; import { setAccessLog } from "./access-log";
import { inspectOutbox } from "./outbox-log";
/** /**
* Consumer-injected dependencies of the storeRegistry (polyfill-era). The * Consumer-injected dependencies of the storeRegistry (polyfill-era). The
@@ -25,19 +26,18 @@ export interface StoreRegistryDeps {
/** Normalize an identity id for shim keying. Default: trim (identity-ish). */ /** Normalize an identity id for shim keying. Default: trim (identity-ish). */
normalizeId?: (id: string) => string; normalizeId?: (id: string) => string;
/** /**
* ANTI-FORK budget for account resolution (polyfill-era). Before provisioning * POINTER micro-guard budget. The account records now live in a subscribable
* a "missing" account, the registry re-reads its shim record a bounded number * doc-shim (`did:ng:o:...`) reached through a well-known write-once POINTER triple
* of times with backoff, so a record merely lagging by broker sync (fresh * in the store-root graph. The doc-shim read is barrier-AUTHORITATIVE, so accounts
* session over a persistent wallet) is FOUND and REUSED instead of triggering * need NO retry (this replaces the deleted account-level `provisionRetry`). The
* a second set of scope docs (the account fork). See the note in * ONLY residual sync-lag window is the store-root pointer read itself — one
* store-registry.ts. Only if every attempt still reads 0 do we provision. * write-once triple. This bounded guard re-reads JUST that pointer a few times if a
* * fresh cold read misses it; it can never provision or fork an account (worst case:
* Default (production): a real budget (~8 attempts / ≲8.5s). Set `attempts: 1` * a couple extra reads before an existing pointer is seen). Enable it where the REAL
* to disable the retry entirely — appropriate for a SYNCHRONOUS in-memory * broker is used (app + e2e). Left UNSET (the default) → `attempts: 1` = single
* store (the unit fake `ng`) where a 0-row read is authoritative and the * read, keeping the synchronous unit fakes fast and unchanged.
* backoff would only add dead time; there is no sync lag to wait out there.
*/ */
provisionRetry?: { attempts?: number; baseMs?: number; maxStepMs?: number }; pointerGuard?: { attempts?: number; baseMs?: number; maxStepMs?: number };
} }
export interface EventuallyConfig { export interface EventuallyConfig {
@@ -64,7 +64,12 @@ export interface EventuallyConfig {
let cfg: EventuallyConfig | null = null; let cfg: EventuallyConfig | null = null;
let currentUser: PrincipalId | null = null; let currentUser: PrincipalId | null = null;
let registryDeps: Required<StoreRegistryDeps> | null = null; /** Required fields of StoreRegistryDeps after defaults are applied. `pointerGuard`
* defaults to `{ attempts: 1 }` (single read) when the consumer leaves it unset. */
type ResolvedRegistryDeps = Required<
Pick<StoreRegistryDeps, "getSession" | "normalizeId" | "pointerGuard">
>;
let registryDeps: ResolvedRegistryDeps | null = null;
/** The emulated ReadCap/WriteCap registry. Empty until the app declares caps; /** The emulated ReadCap/WriteCap registry. Empty until the app declares caps;
* while it has no read policy the read filter passes through (no regression). */ * while it has no read policy the read filter passes through (no regression). */
let caps = new CapRegistry(); let caps = new CapRegistry();
@@ -95,21 +100,35 @@ export function resetConfig(): void {
* disappears at migration. * disappears at migration.
*/ */
export function configureStoreRegistry(deps: StoreRegistryDeps): void { export function configureStoreRegistry(deps: StoreRegistryDeps): void {
// Fire the outbox inspection (Volet 3 of the low-level data-path trace) once,
// on the FIRST successful `getSession()` resolution — the most reliable
// "a session is established" signal available: every low-level reader/writer
// (store-registry, open-repo, read-model, subscribe, inbox) reaches its
// session through this SAME injected `getSession`, so wrapping it HERE catches
// the first success from whichever caller happens to run first, instead of
// tying the probe to one particular call site. Only on SUCCESS (an error
// propagates untouched, exactly as before) and only ONCE per
// `configureStoreRegistry()` call (a fresh session config → a fresh check).
let outboxInspected = false;
const getSession = async (): Promise<RegistrySession> => {
const session = await deps.getSession();
if (!outboxInspected) {
outboxInspected = true;
inspectOutbox();
}
return session;
};
registryDeps = { registryDeps = {
getSession: deps.getSession, getSession,
normalizeId: deps.normalizeId ?? ((id: string) => id.trim()), normalizeId: deps.normalizeId ?? ((id: string) => id.trim()),
// Production default: a real anti-fork budget. Consumers over a synchronous // Default: single read (no re-read). Only the real-broker consumers (app + e2e)
// store pass `{ attempts: 1 }` to opt out (see StoreRegistryDeps). // opt into the bounded pointer micro-guard; unit fakes stay synchronous.
provisionRetry: { pointerGuard: deps.pointerGuard ?? { attempts: 1 },
attempts: deps.provisionRetry?.attempts ?? 8,
baseMs: deps.provisionRetry?.baseMs ?? 300,
maxStepMs: deps.provisionRetry?.maxStepMs ?? 1500,
},
}; };
} }
/** @internal — used by the storeRegistry to reach its injected dependencies. */ /** @internal — used by the storeRegistry to reach its injected dependencies. */
export function getStoreRegistryDeps(): Required<StoreRegistryDeps> { export function getStoreRegistryDeps(): ResolvedRegistryDeps {
if (!registryDeps) { if (!registryDeps) {
throw new Error("[ng-eventually] configureStoreRegistry() must be called before use"); throw new Error("[ng-eventually] configureStoreRegistry() must be called before use");
} }
+454 -203
View File
@@ -9,10 +9,37 @@
* physical NextGraph store. Isolation is enforced by the app layer + the * physical NextGraph store. Isolation is enforced by the app layer + the
* emulated cap registry, not by crypto. * emulated cap registry, not by crypto.
* *
* The mapping (account → its 3 document NURIs) is the **sharedWalletShim**, * The mapping (account → its 3 document NURIs) is the **sharedWalletShim**. It
* persisted as RDF in the shared wallet's private store (the anchor, always * is persisted as RDF, but NOT in the store-root graph anymore — see the
* known from the session). That makes login cross-device: another device * indirection below. That makes login cross-device: another device opening the
* opening the same wallet reads the same shim and finds the same accounts. * same wallet reads the same shim and finds the same accounts.
*
* ── The indirection: pointer (store-root) → doc-shim (subscribable) ─────────
* On the real NextGraph platform "findable-without-lookup" and "subscribable"
* are DISJOINT (verified at the source, see docs/nextgraph-current-state.md
* § *Findable vs subscribable*):
* - the ONLY NURI a fresh session can name WITHOUT a lookup is the store-root
* `did:ng:${privateStoreId}` — but a store-root has NO first-`State` sync
* BARRIER, so a cold "0 rows" on it is AMBIGUOUS (could be sync-lag, could
* be truly empty);
* - the ONLY thing that DOES have a first-`State` barrier is a `did:ng:o:<RepoID>`
* doc from `doc_create` — but its RepoID is RANDOM, so a fresh session
* cannot GUESS it; it must be looked up.
* So a purely-barrier shim resolution is impossible: you cannot have a doc that
* is both guessable and authoritative on a cold read. The indirection bridges
* this: a well-known, write-ONCE **pointer** triple in the store-root names a
* **doc-shim** (`did:ng:o:...`) that holds all account records and IS
* subscribable. Resolution reads the pointer (a single oldest write-once triple,
* near-always synced), then opens the doc-shim through its `ensureRepoOpen`
* BARRIER and reads the account AUTHORITATIVELY (0 = genuinely absent).
*
* A pointer FORK (two devices writing the pointer before either synced) is
* reconciled to a canonical doc-shim (lexicographically-smallest NURI) so every
* device converges on the SAME doc-shim. This is why the OLD account-level retry
* (`resolveAccountReliably` / `provisionRetry`) is GONE: the account read is now
* barrier-authoritative, so it never needs to be retried to distinguish sync-lag
* from absence. A micro-guard remains ONLY on the pointer read (one write-once
* triple) — see resolvePointer.
* *
* ── Generic by construction ────────────────────────────────────────────── * ── Generic by construction ──────────────────────────────────────────────
* This module knows only the three native scopes; it knows no application * This module knows only the three native scopes; it knows no application
@@ -35,32 +62,8 @@
import { docCreate, sparqlUpdate, sparqlQuery } from "./docs"; import { docCreate, sparqlUpdate, sparqlQuery } from "./docs";
import { getStoreRegistryDeps } from "./polyfill"; import { getStoreRegistryDeps } from "./polyfill";
import { ensureRepoOpen } from "./open-repo"; import { ensureRepoOpen } from "./open-repo";
// --- provisioning anti-fork guard: barrier-first (polyfill-era) ------------
//
// The shim (account→docs trust root) lives in the shared wallet's PRIVATE store.
// On a fresh session over a persistent wallet, that repo may not have synced yet
// from the broker: a targeted account resolve would then read 0 rows even though
// the account WAS persisted in an earlier session. If ensureAccount took that 0
// at face value it would RE-PROVISION a second set of scope documents — an account
// FORK: one session writes/reads one set, another (or the same after a cache drop)
// the other, EMPTY set → the user "loses" their data on reconnect.
//
// Guard (barrier-first): BEFORE reading the shim, open/subscribe the private-store
// repo and AWAIT its first `State` push — the deterministic sync barrier (CONTRACT 3
// in e2e/). After the barrier, presence is GUARANTEED and absence DEFINITIVE. Then
// read the shim ONCE: 0 rows = account genuinely absent → provision exactly once;
// rows present = account found → reuse it (NO-FORK). No retry loop, no poll.
//
// Timed-out barrier (conservative): if `ensureRepoOpen` exits via the 8-second
// fallback timeout (getSyncState → "timed-out") the sync is UNCONFIRMED — we do NOT
// provision blindly (that would re-fork). Instead we surface a clear error so the
// caller can retry the full flow rather than silently creating a duplicate account.
// The timeout is rare (pathological broker) and a hard error there is far safer than
// a silent fork. Disappears at the real multi-store migration (per-user store NURIs
// make this moot — barrier becomes the native store-open which is always confirmed).
import { getSyncState } from "./open-repo";
import { escapeLiteral, escapeIri, assertNuri } from "./sparql"; import { escapeLiteral, escapeIri, assertNuri } from "./sparql";
import { accessLogPrefix, logStage, shortNuri } from "./access-log";
import type { Nuri, Scope } from "./types"; import type { Nuri, Scope } from "./types";
// --- sharedWalletShim model ---------------------------------------------- // --- sharedWalletShim model ----------------------------------------------
@@ -87,6 +90,18 @@ const P = {
// documents (one per entity) that live "in" that scope. // documents (one per entity) that live "in" that scope.
const INDEX_SUBJECT = `${SHIM}:index`; const INDEX_SUBJECT = `${SHIM}:index`;
// --- pointer (store-root → doc-shim indirection) --------------------------
//
// The pointer is a SINGLE well-known triple written ONCE into the store-root
// graph on the very first login, then IMMUTABLE. Its object is the NURI of the
// doc-shim (a `did:ng:o:...` repo) where all AccountRecords actually live. The
// store-root is NOT subscribable (no first-`State` barrier), but the pointer is
// the OLDEST triple in that graph and is write-once, so it is near-always synced
// on a cold read — and even a transient miss is bounded by a small guard
// (resolvePointer), NOT by an account-level retry.
const POINTER_SUBJECT = `${SHIM}:root`;
const POINTER_PRED = `${SHIM}:shimDoc`;
function accountSubject(id: string): string { function accountSubject(id: string): string {
// The id is UNTRUSTED and lands in an IRI position. Percent-encode it // The id is UNTRUSTED and lands in an IRI position. Percent-encode it
// (escapeIri) so no `>` / `"` / whitespace / control char can break out of // (escapeIri) so no `>` / `"` / whitespace / control char can break out of
@@ -135,7 +150,7 @@ function accountKey(id: string): string {
/** Minimal session shape the registry needs — provided by the consumer. */ /** Minimal session shape the registry needs — provided by the consumer. */
export interface RegistrySession { export interface RegistrySession {
sessionId: string; sessionId: string;
/** The shared wallet's private store id — the shim anchor. */ /** The shared wallet's private store id — the pointer anchor. */
privateStoreId: string; privateStoreId: string;
/** The shared wallet's protected store id (native store). Optional: only the /** The shared wallet's protected store id (native store). Optional: only the
* scope resolvers need it; the shim only needs the private anchor. */ * scope resolvers need it; the shim only needs the private anchor. */
@@ -152,8 +167,11 @@ async function session(): Promise<RegistrySession> {
return getStoreRegistryDeps().getSession(); return getStoreRegistryDeps().getSession();
} }
/** The shim lives in the shared wallet's private store (always-known anchor). */ /** The pointer lives in the shared wallet's private STORE-ROOT graph (the only
async function anchorNuri(): Promise<Nuri> { * always-known-without-lookup anchor). NOT subscribable — hence the pointer is
* a write-once triple, and the actual account records live in the doc-shim it
* names (see rootNuri vs the doc-shim). */
async function rootNuri(): Promise<Nuri> {
const s = await session(); const s = await session();
return `did:ng:${s.privateStoreId}`; return `did:ng:${s.privateStoreId}`;
} }
@@ -169,10 +187,20 @@ let cache: Map<string, AccountRecord> | null = null;
// targeted resolve never forces a full shim scan. Both are cleared together. // targeted resolve never forces a full shim scan. Both are cleared together.
const accountCache = new Map<string, AccountRecord>(); const accountCache = new Map<string, AccountRecord>();
// The resolved doc-shim NURI for the current session (cached: the pointer read +
// barrier open happen once, then every account read reuses this doc). Cleared on
// resetRegistryCache / wallet switch.
let shimDocNuri: Nuri | null = null;
// De-dupe concurrent pointer-resolutions so a fresh page firing many parallel
// ensureAccount/resolveAccount calls opens the doc-shim exactly once.
let shimDocInFlight: Promise<Nuri> | null = null;
/** Reset cache (e.g. after switching the shared wallet). Mostly for tests. */ /** Reset cache (e.g. after switching the shared wallet). Mostly for tests. */
export function resetRegistryCache(): void { export function resetRegistryCache(): void {
cache = null; cache = null;
accountCache.clear(); accountCache.clear();
shimDocNuri = null;
shimDocInFlight = null;
} }
// --- SPARQL result helpers ------------------------------------------------ // --- SPARQL result helpers ------------------------------------------------
@@ -192,147 +220,157 @@ function bindingValue(row: Record<string, { value: string }>, key: string): stri
return row[key]?.value ?? ""; return row[key]?.value ?? "";
} }
// --- shim load / account bootstrap ----------------------------------------
/** Load all accounts from the shim into the cache. */
export async function loadShim(): Promise<Map<string, AccountRecord>> {
if (cache) return cache;
const s = await session();
const anchor = await anchorNuri();
const query = `
SELECT ?id ?docPublic ?docProtected ?docPrivate WHERE {
GRAPH <${assertNuri(anchor)}> {
?acc a <${P.type}> ;
<${P.id}> ?id ;
<${P.docPublic}> ?docPublic ;
<${P.docProtected}> ?docProtected ;
<${P.docPrivate}> ?docPrivate .
}
}`;
const map = new Map<string, AccountRecord>();
try {
const result = await sparqlQuery(s.sessionId, query, undefined, anchor, "loadShim");
for (const row of readBindings(result)) {
const id = bindingValue(row, "id");
if (!id) continue;
const key = accountKey(id);
const record: AccountRecord = {
id,
docPublic: bindingValue(row, "docPublic"),
docProtected: bindingValue(row, "docProtected"),
docPrivate: bindingValue(row, "docPrivate"),
};
map.set(key, record);
// Feed the per-account cache too, so a subsequent targeted resolve is free.
accountCache.set(key, record);
}
} catch (error) {
console.error("[storeRegistry] loadShim failed:", error);
}
cache = map;
return map;
}
/** /**
* Resolve ONE account by its shim key with a BOUNDED query — O(1), independent * DETERMINISTIC resolution of an account's scope docs from a set of SPARQL
* of the number of accounts in the shim. This is the HOT-PATH lookup: it hits * bindings (all bindings for ONE account subject).
* the account record at its known subject (`accountSubject(id)`) directly,
* instead of scanning EVERY account like {@link loadShim}. Returns the account's
* record or `null` if it does not exist yet.
* *
* Cached per account (in `accountCache`); a hit skips the query entirely, so * ── Why this is load-bearing (residual fork residue) ───────────────────────
* repeated resolves of the same account are free. `resetRegistryCache` clears it. * A corrupted shim can carry the SAME account subject with MULTIPLE values for a
* scope predicate (e.g. 5 `shim:docPublic`) — the residue of past account FORKS
* (each stray provision appended another doc NURI). A query then returns several
* bindings (the cross-product of the duplicate values). Picking `rows[0]` is
* NON-DETERMINISTIC (binding order is not stable across sessions), so the session
* that WROTE an entity into one docPublic and a later fresh page that RESOLVED a
* DIFFERENT docPublic would disagree → the anchored `readScopeIndex` returns 0 →
* the home reads empty. When both happen to pick the same doc, it "works".
*
* The fix: for each scope field, collect EVERY distinct value across the bindings
* and choose the SAME one every time — the lexicographically-smallest NURI. NURIs
* are content-addressed and stable, so lexicographic order is a total, stable,
* session-independent order: writer and reader converge on the SAME canonical doc
* even on a wallet already corrupted by duplicates. (No creation timestamp is
* recorded in the shim, so lexicographic-min is the available deterministic key.)
*
* With the barrier-authoritative doc-shim read, account FORKS no longer occur (a
* fresh page reads the doc-shim through its first-`State` barrier, so a cold 0 is
* definitive and never triggers a fork-provision). `canonicalDoc` is RETAINED to
* stay robust against the residue of PAST forks already persisted in a wallet, and
* to reconcile a benign pointer fork the same content-addressed way.
*/ */
export async function resolveAccount(id: string): Promise<AccountRecord | null> { function canonicalDoc(rows: Array<Record<string, { value: string }>>, key: string): Nuri {
const key = accountKey(id); let chosen = "";
const cached = accountCache.get(key); const distinct = new Set<string>();
if (cached) return cached; for (const row of rows) {
const v = bindingValue(row, key);
const s = await session(); if (!v) continue;
const anchor = await anchorNuri(); distinct.add(v);
// `subj` is already IRI-safe (accountSubject → escapeIri); `anchor` is a if (chosen === "" || v < chosen) chosen = v;
// trusted-shaped NURI → assertNuri. The query is bounded to this one subject.
const subj = accountSubject(id);
const query = `
SELECT ?id ?docPublic ?docProtected ?docPrivate WHERE {
GRAPH <${assertNuri(anchor)}> {
<${subj}> a <${P.type}> ;
<${P.id}> ?id ;
<${P.docPublic}> ?docPublic ;
<${P.docProtected}> ?docProtected ;
<${P.docPrivate}> ?docPrivate .
} }
}`; // Stage trace: which doc got picked, and out of how many DISTINCT candidate
try { // values — >1 flags residual fork residue (see the module doc above) even
const result = await sparqlQuery(s.sessionId, query, undefined, anchor, "resolveAccount"); // when resolution still converges correctly on the canonical (smallest) one.
const rows = readBindings(result); logStage(
if (rows.length === 0) return null; "canonicalDoc(" + key + ") → " + (chosen ? shortNuri(chosen) : "none") +
const row = rows[0]!; " (" + distinct.size + (distinct.size === 1 ? " candidate)" : " candidates)"),
const record: AccountRecord = {
id: bindingValue(row, "id") || id,
docPublic: bindingValue(row, "docPublic"),
docProtected: bindingValue(row, "docProtected"),
docPrivate: bindingValue(row, "docPrivate"),
};
accountCache.set(key, record);
return record;
} catch (error) {
console.error("[storeRegistry] resolveAccount failed:", error);
return null;
}
}
/**
* Resolve ONE account after waiting for the private-store sync barrier, so a
* 0-row result is DEFINITIVE (account genuinely absent) rather than ambiguous
* (broker lag). This is the ANTI-FORK guard for the polyfill-era shim.
*
* Flow:
* 1. Cache hit → return immediately (session-idempotent, no I/O).
* 2. Open/subscribe the private-store repo (`did:ng:${privateStoreId}`) and
* await the first `State` push — the deterministic sync barrier (CONTRACT 3).
* After the barrier, presence is guaranteed and absence definitive.
* 3. If the barrier TIMED-OUT (getSyncState → "timed-out"), the sync is
* unconfirmed — provisioning here would risk a fork. Throw a clear error
* instead so the caller can retry the full login flow. This is the
* conservative-safe choice: a hard error is recoverable; a silent fork is not.
* 4. Read the shim ONCE. 0 rows = genuinely absent → caller provisions exactly
* once. Rows present → reuse (NO-FORK preserved).
*
* With the unit fake `ng` (no `doc_subscribe`), `ensureRepoOpen` is a no-op
* (getSyncState → "unknown") and the single read is immediate — synchronous
* behaviour preserved, no lag to wait out.
*/
async function resolveAccountReliably(id: string): Promise<AccountRecord | null> {
// Cache hit → session-idempotent, no query, no fork risk.
const cached = accountCache.get(accountKey(id));
if (cached) return cached;
// The shim lives in the private store. Open/subscribe it and await the first
// `State` push (the sync barrier). NURI: `did:ng:${session.privateStoreId}`.
const nuri = await anchorNuri();
await ensureRepoOpen(nuri);
// Conservative timed-out guard: if the barrier did not confirm sync,
// provisioning blind risks creating a fork. Raise a clear error — the caller
// (or the app's retry-login flow) should re-attempt when the broker is reachable.
// "unknown" means the fake-ng no-op path: no barrier semantics → safe to read.
const syncResult = getSyncState(nuri);
if (syncResult === "timed-out") {
throw new Error(
"[storeRegistry] sync barrier timed out for the private store — " +
"shim state unconfirmed, refusing to provision to avoid account fork. " +
"Retry when the broker is reachable.",
); );
return chosen;
} }
// Barrier reached (or fake-ng "unknown" path): single read, result is definitive. /** Build an AccountRecord by picking the canonical (lexicographically-smallest)
return resolveAccount(id); * doc NURI per scope across all bindings for one account. See {@link canonicalDoc}. */
function recordFromRows(
rows: Array<Record<string, { value: string }>>,
fallbackId: string,
): AccountRecord {
let id = "";
for (const row of rows) {
const v = bindingValue(row, "id");
if (v) { id = v; break; }
}
return {
id: id || fallbackId,
docPublic: canonicalDoc(rows, "docPublic"),
docProtected: canonicalDoc(rows, "docProtected"),
docPrivate: canonicalDoc(rows, "docPrivate"),
};
} }
/** All known accounts (from the shim). */ // --- pointer resolution + doc-shim bootstrap ------------------------------
export async function allAccounts(): Promise<AccountRecord[]> {
return [...(await loadShim()).values()]; const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
/**
* Read the pointer(s) from the store-root graph → the canonical doc-shim NURI, or
* `""` if no pointer exists yet.
*
* The store-root is NOT subscribable, so this read has no first-`State` barrier:
* a cold 0 is ambiguous. But the pointer is ONE write-once triple (the OLDEST in
* that graph), so it is near-always synced. The ONLY residual guard is a small
* bounded re-read here (NOT an account retry): a handful of quick re-reads of that
* single triple. It is bounded, benign, and — crucially — it can never re-provision
* an account or fork data; the worst it can do is take a couple extra reads to see a
* pointer that is still landing. The account records themselves are read
* authoritatively through the doc-shim barrier, never through this guard.
*
* If MULTIPLE pointers exist (a pointer fork: two devices each wrote a pointer to
* their own freshly-created doc-shim before either synced), reconcile to the
* canonical (lexicographically-smallest) doc-shim NURI — content-addressed and
* stable, so every device converges on the SAME doc-shim.
*/
async function resolvePointer(): Promise<Nuri> {
const s = await session();
const root = await rootNuri();
// COLD-START heal: open the store-root repo before the anchored read, so a fresh
// wallet whose store-root isn't yet in `self.repos` resolves instead of throwing
// `RepoNotFound`. Idempotent; a no-op with the unit fake ng. The store-root has no
// barrier, so this open cannot make the read authoritative — the guard below does.
await ensureRepoOpen(root);
const query = `
SELECT ?shimDoc WHERE {
GRAPH <${assertNuri(root)}> {
<${POINTER_SUBJECT}> <${POINTER_PRED}> ?shimDoc .
}
}`;
// Micro-guard (POINTER only): a small bounded re-read to bridge the store-root
// sync-lag window on the ONE write-once pointer triple. Bounded, and it can only
// ever DELAY seeing an existing pointer — never provision, never fork. Uses the
// injected pointerGuard budget (defaults to a single read when unset, so unit
// fakes stay synchronous). NB this is NOT the deleted account-level provisionRetry.
const budget = getStoreRegistryDeps().pointerGuard;
const attempts = Math.max(1, budget.attempts ?? 1);
const baseMs = budget.baseMs ?? 150;
const maxStepMs = budget.maxStepMs ?? 2000;
let step = baseMs;
for (let i = 0; i < attempts; i++) {
try {
const result = await sparqlQuery(s.sessionId, query, undefined, root, "resolvePointer");
const doc = canonicalDoc(readBindings(result), "shimDoc");
if (doc) {
logStage("resolvePointer → 1 target: " + shortNuri(doc));
return doc;
}
} catch (error) {
console.error(accessLogPrefix() + " resolvePointer failed:", error);
}
if (i < attempts - 1) {
await sleep(step);
step = Math.min(step * 2, maxStepMs);
}
}
logStage("resolvePointer → 0 targets");
return "";
}
/** Write the pointer (store-root → doc-shim), once, at first login. Idempotent in
* practice (only called when no pointer was found); a concurrent double-write is
* reconciled by canonicalDoc on read. */
async function writePointer(doc: Nuri): Promise<void> {
const s = await session();
const root = await rootNuri();
await ensureRepoOpen(root);
const update = `
INSERT DATA {
GRAPH <${assertNuri(root)}> {
<${POINTER_SUBJECT}> <${POINTER_PRED}> <${assertNuri(doc)}> .
}
}`;
try {
await sparqlUpdate(s.sessionId, update, root, "writePointer");
} catch (error) {
console.error(accessLogPrefix() + " writePointer failed:", error);
}
} }
/** Create one graph document in the shared wallet's private store (→ a NURI). */ /** Create one graph document in the shared wallet's private store (→ a NURI). */
@@ -343,59 +381,258 @@ async function createDoc(): Promise<Nuri> {
return docCreate(s.sessionId, "Graph", "data:graph", "store", undefined); return docCreate(s.sessionId, "Graph", "data:graph", "store", undefined);
} }
/**
* Resolve (or on first login, create) the doc-shim NURI for this session — the
* `did:ng:o:...` repo that holds every AccountRecord and IS subscribable.
*
* Steps (cached; runs at most once per session, concurrent callers share one):
* 1. Read the pointer from the store-root (resolvePointer). If present → that is
* the doc-shim; open it through its first-`State` BARRIER so subsequent account
* reads are AUTHORITATIVE.
* 2. No pointer → FIRST login:
* a. create a fresh doc-shim (`doc_create`), which bootstraps the repo into the
* session (`self.repos`) — so it is already open/synced in-session;
* b. publish the pointer (store-root → the new doc-shim), once;
* c. open it (barrier — trivially satisfied for a just-created in-session repo).
* The BARRIER matters on RECONNECT (step 1, reading an EXISTING remote doc-shim);
* on first-login creation it is a no-op, so the pointer is published first.
*/
async function resolveShimDoc(): Promise<Nuri> {
if (shimDocNuri) return shimDocNuri;
if (shimDocInFlight) return shimDocInFlight;
const p = (async (): Promise<Nuri> => {
const existing = await resolvePointer();
if (existing) {
// Open the doc-shim through its first-`State` barrier BEFORE any account read,
// so a cold 0 on the doc-shim is authoritative (genuinely absent), not sync-lag.
await ensureRepoOpen(existing);
shimDocNuri = existing;
logStage("resolveShimDoc → " + shortNuri(existing));
return existing;
}
// FIRST login (no pointer): create the doc-shim (bootstrapped in-session), publish
// the pointer, then open (no-op barrier for a just-created repo).
const doc = await createDoc();
await writePointer(doc);
await ensureRepoOpen(doc);
shimDocNuri = doc;
logStage("resolveShimDoc → " + shortNuri(doc));
return doc;
})();
shimDocInFlight = p;
try {
return await p;
} finally {
shimDocInFlight = null;
}
}
// --- shim load / account bootstrap ----------------------------------------
/** Load all accounts from the shim (the doc-shim) into the cache. */
export async function loadShim(): Promise<Map<string, AccountRecord>> {
if (cache) return cache;
const s = await session();
const doc = await resolveShimDoc();
const query = `
SELECT ?id ?docPublic ?docProtected ?docPrivate WHERE {
?acc a <${P.type}> ;
<${P.id}> ?id ;
<${P.docPublic}> ?docPublic ;
<${P.docProtected}> ?docProtected ;
<${P.docPrivate}> ?docPrivate .
}`;
const map = new Map<string, AccountRecord>();
// The doc-shim is opened (first-`State` barrier) by resolveShimDoc, so this read is
// authoritative.
await ensureRepoOpen(doc);
try {
const result = await sparqlQuery(s.sessionId, query, undefined, doc, "loadShim");
// Group ALL bindings by account key first, then pick the CANONICAL doc per
// scope (see recordFromRows / canonicalDoc). A single account subject may carry
// duplicate scope-doc values (fork residue) → several bindings; grouping +
// canonical selection makes loadShim resolve the SAME doc the targeted
// resolveAccount does, so full-scan and hot-path readers never disagree.
const byKey = new Map<string, Array<Record<string, { value: string }>>>();
for (const row of readBindings(result)) {
const id = bindingValue(row, "id");
if (!id) continue;
const key = accountKey(id);
const bucket = byKey.get(key) ?? [];
bucket.push(row);
byKey.set(key, bucket);
}
for (const [key, rows] of byKey) {
const record = recordFromRows(rows, rows[0] ? bindingValue(rows[0], "id") : key);
map.set(key, record);
// Feed the per-account cache too, so a subsequent targeted resolve is free.
accountCache.set(key, record);
}
} catch (error) {
console.error(accessLogPrefix() + " loadShim failed:", error);
}
cache = map;
return map;
}
/**
* Resolve ONE account by its shim key with a BOUNDED query — O(1), independent
* of the number of accounts in the shim. This is the HOT-PATH lookup: it hits
* the account record at its known subject (`accountSubject(id)`) directly in the
* doc-shim, instead of scanning EVERY account like {@link loadShim}. Returns the
* account's record or `null` if it does not exist yet.
*
* ── Barrier-AUTHORITATIVE (the reconnection fix) ────────────────────────────
* The read targets the DOC-SHIM (`did:ng:o:...`), which resolveShimDoc opened
* through its first-`State` barrier. So a cold 0 rows here is DEFINITIVE ("account
* genuinely absent"), not ambiguous sync-lag — no account-level retry is needed or
* used. This is what replaced the old `resolveAccountReliably` / `provisionRetry`
* loop: the store-root ambiguity that forced the retry is gone once the read moves
* behind the doc-shim barrier.
*
* Cached per account (in `accountCache`); a hit skips the query entirely, so
* repeated resolves of the same account are free. `resetRegistryCache` clears it.
*/
export async function resolveAccount(id: string): Promise<AccountRecord | null> {
const key = accountKey(id);
const cached = accountCache.get(key);
if (cached) return cached;
const s = await session();
const doc = await resolveShimDoc();
// `subj` is already IRI-safe (accountSubject → escapeIri). The read is anchored to
// the doc-shim's default graph (opened through its barrier by resolveShimDoc), so
// it is authoritative. The query is bounded to this one subject.
const subj = accountSubject(id);
const query = `
SELECT ?id ?docPublic ?docProtected ?docPrivate WHERE {
<${subj}> a <${P.type}> ;
<${P.id}> ?id ;
<${P.docPublic}> ?docPublic ;
<${P.docProtected}> ?docProtected ;
<${P.docPrivate}> ?docPrivate .
}`;
try {
const result = await sparqlQuery(s.sessionId, query, undefined, doc, "resolveAccount");
const rows = readBindings(result);
if (rows.length === 0) {
logStage("resolveAccount(" + key + ") → null");
return null;
}
// DETERMINISTIC: a corrupted shim may return SEVERAL bindings for this one
// account subject (duplicate scope-doc values from past forks). Pick the
// canonical (lexicographically-smallest) doc per scope so writer and reader
// always resolve the SAME docPublic (robustness against PAST fork residue).
const record = recordFromRows(rows, id);
accountCache.set(key, record);
logStage("resolveAccount(" + key + ") → 1 record");
return record;
} catch (error) {
console.error(accessLogPrefix() + " resolveAccount failed:", error);
return null;
}
}
/** All known accounts (from the shim). */
export async function allAccounts(): Promise<AccountRecord[]> {
return [...(await loadShim()).values()];
}
/** Persist one AccountRecord into the doc-shim (anchored default-graph write, the
* canonical always-safe shape — same convention as createEntityDoc). */
async function writeRecord(doc: Nuri, record: AccountRecord): Promise<void> {
const s = await session();
const subj = `${SHIM}:account:${escapeIri(accountKey(record.id))}`;
// `subj` is IRI-safe (escapeIri). `id` is UNTRUSTED text in a LITERAL position →
// escapeLiteral. The doc NURIs come from `ng` but are stored as literals here, so
// they are escaped as literals too (defence in depth). NO explicit `GRAPH <…>`
// wrapper: write the anchored DEFAULT graph (the `doc` anchor scopes it) — the
// canonical, always-safe shape the anchored default-graph read queries match.
const update = `
INSERT DATA {
<${subj}> a <${P.type}> ;
<${P.id}> "${escapeLiteral(record.id)}" ;
<${P.docPublic}> "${escapeLiteral(record.docPublic)}" ;
<${P.docProtected}> "${escapeLiteral(record.docProtected)}" ;
<${P.docPrivate}> "${escapeLiteral(record.docPrivate)}" .
}`;
try {
await sparqlUpdate(s.sessionId, update, doc, "writeRecord");
} catch (error) {
console.error(accessLogPrefix() + " writeRecord persist failed:", error);
}
}
/**
* In-flight `ensureAccount` promises, keyed by account key — so CONCURRENT
* `ensureAccount(id)` calls for the SAME account share ONE resolve-or-provision.
*
* ── Why this is load-bearing (concurrent-provision de-dup) ──────────────────
* On a fresh page over the persistent wallet, SEVERAL independent callers hit
* `ensureAccount(A)` near-simultaneously (watchShape public/protected, the container
* subscriptions, the app's owned-events effect). With the barrier-authoritative
* resolveAccount a fresh page NO LONGER mistakes sync-lag for absence — but if the
* account is GENUINELY new, N concurrent callers would still each see 0 and each
* provision a set of scope docs (an in-session fork). De-duping concurrent provisions
* collapses those N into ONE: the first caller resolves-or-provisions; every
* concurrent caller awaits the SAME promise and gets the SAME record. Not polling: a
* bounded in-memory promise map (mirrors open-repo.ts `inFlight`), cleared the instant
* it settles.
*/
const ensureInFlight = new Map<string, Promise<AccountRecord>>();
/** /**
* Ensure an account exists in the shim, creating its 3 scope documents on * Ensure an account exists in the shim, creating its 3 scope documents on
* first sight. Idempotent — returns the existing record if already present. * first sight. Idempotent — returns the existing record if already present.
* Concurrency-safe: concurrent calls for the same account share one provision
* (see {@link ensureInFlight}) so a fresh page never FORKS the account.
*/ */
export async function ensureAccount(id: string): Promise<AccountRecord> { export async function ensureAccount(id: string): Promise<AccountRecord> {
const key = accountKey(id); const key = accountKey(id);
// A completed provision/resolve is cached → no query, no fork risk.
const cached = accountCache.get(key);
if (cached) return cached;
// A concurrent provision for the SAME account is already running → await it,
// instead of racing a second (forking) provision. This is the anti-fork guard.
const pending = ensureInFlight.get(key);
if (pending) return pending;
const p = (async (): Promise<AccountRecord> => {
// HOT PATH: targeted O(1) lookup — does THIS account already exist? — instead // HOT PATH: targeted O(1) lookup — does THIS account already exist? — instead
// of a full-shim scan (loadShim). Off the read/write hot path entirely. // of a full-shim scan (loadShim). Off the read/write hot path entirely.
// //
// ANTI-FORK (polyfill-era): open the private-store repo and await its first // Barrier-AUTHORITATIVE: resolveAccount reads the doc-shim behind its first-`State`
// `State` push (the sync barrier) BEFORE reading the shim, so a 0-row result // barrier (opened by resolveShimDoc), so a 0 here means the account is GENUINELY
// is DEFINITIVE rather than a lag artifact. Only after the barrier do we treat // absent — not sync-lag. No account-level retry: the store-root ambiguity that
// 0 rows as "genuinely new" and provision. A cache hit inside // forced the old provisionRetry loop is gone once the read moves behind the barrier.
// resolveAccountReliably keeps same-session resolves free and deterministic const existing = await resolveAccount(id);
// (the cached record wins over any re-provision). Throws if the barrier timed
// out — safer than provisioning blind and creating a fork.
const existing = await resolveAccountReliably(id);
if (existing) return existing; if (existing) return existing;
const doc = await resolveShimDoc();
const [docPublic, docProtected, docPrivate] = await Promise.all([ const [docPublic, docProtected, docPrivate] = await Promise.all([
createDoc(), createDoc(),
createDoc(), createDoc(),
createDoc(), createDoc(),
]); ]);
const record: AccountRecord = { id, docPublic, docProtected, docPrivate }; const record: AccountRecord = { id, docPublic, docProtected, docPrivate };
// Persist the record INTO the doc-shim (not the store-root anymore).
const s = await session(); await writeRecord(doc, record);
const anchor = await anchorNuri();
const subj = accountSubject(id);
// `subj` is already IRI-safe (accountSubject → escapeIri). `anchor` is a
// trusted-shaped NURI → assertNuri. `id` is UNTRUSTED text in a LITERAL
// position → escapeLiteral. The doc NURIs come from `ng` but are stored as
// literals here, so they are escaped as literals too (defence in depth).
const update = `
INSERT DATA {
GRAPH <${assertNuri(anchor)}> {
<${subj}> a <${P.type}> ;
<${P.id}> "${escapeLiteral(id)}" ;
<${P.docPublic}> "${escapeLiteral(docPublic)}" ;
<${P.docProtected}> "${escapeLiteral(docProtected)}" ;
<${P.docPrivate}> "${escapeLiteral(docPrivate)}" .
}
}`;
try {
await sparqlUpdate(s.sessionId, update, anchor, "ensureAccount");
} catch (error) {
console.error("[storeRegistry] ensureAccount persist failed:", error);
}
// Feed the per-account cache, and the full-shim cache if it is already loaded // Feed the per-account cache, and the full-shim cache if it is already loaded
// (so allAccounts / the fan-out see the freshly-created account too). // (so allAccounts / the fan-out see the freshly-created account too).
accountCache.set(key, record); accountCache.set(key, record);
cache?.set(key, record); cache?.set(key, record);
return record; return record;
})();
ensureInFlight.set(key, p);
try {
return await p;
} finally {
ensureInFlight.delete(key);
}
} }
// --- resolvers ------------------------------------------------------------ // --- resolvers ------------------------------------------------------------
@@ -476,12 +713,11 @@ const INBOX_ANCHOR_ACCOUNT = reservedAccount("inbox");
* This is a DEDICATED inbox DOCUMENT (a reserved account's public scope document — * This is a DEDICATED inbox DOCUMENT (a reserved account's public scope document —
* a real repo NURI from `docCreate`, stable across clients via the shim), NOT the * a real repo NURI from `docCreate`, stable across clients via the shim), NOT the
* shared wallet's private-store root. Reason (perf + hygiene): the shim (the * shared wallet's private-store root. Reason (perf + hygiene): the shim (the
* account→document trust root) lives in the private-store graph and is scanned on * account→document trust root) is scanned on every `loadShim`; routing every inbox
* every `loadShim`; routing every inbox deposit into that SAME graph bloats it * deposit into that SAME graph bloats it without bound (thousands of deposit triples
* without bound (thousands of deposit triples across sessions), turning `loadShim` * across sessions). A separate inbox document keeps the shim graph small and the
* into a multi-second full-graph scan. A separate inbox document keeps the shim * deposits isolated. At migration this becomes the host's native per-document inbox
* graph small and the deposits isolated. At migration this becomes the host's * and the resolution moves here.
* native per-document inbox and the resolution moves here.
*/ */
export async function resolveInboxAnchor(): Promise<Nuri> { export async function resolveInboxAnchor(): Promise<Nuri> {
const record = await ensureAccount(INBOX_ANCHOR_ACCOUNT); const record = await ensureAccount(INBOX_ANCHOR_ACCOUNT);
@@ -517,7 +753,7 @@ export async function createEntityDoc(id: string, scope: Scope): Promise<Nuri> {
"createEntityDoc", "createEntityDoc",
); );
} catch (error) { } catch (error) {
console.error("[storeRegistry] createEntityDoc index append failed:", error); console.error(accessLogPrefix() + " createEntityDoc index append failed:", error);
} }
return entityNuri; return entityNuri;
} }
@@ -549,8 +785,9 @@ async function readScopeIndex(indexDoc: Nuri): Promise<Nuri[]> {
if (v) out.push(v); if (v) out.push(v);
} }
} catch (error) { } catch (error) {
console.error("[storeRegistry] readScopeIndex read failed:", error); console.error(accessLogPrefix() + " readScopeIndex failed:", error);
} }
logStage("readScopeIndex(" + shortNuri(indexDoc) + ") → " + out.length + " entities");
return out; return out;
} }
@@ -574,6 +811,20 @@ export async function listEntityDocs(scope: Scope): Promise<Nuri[]> {
return out; return out;
} }
/**
* The scope-INDEX document NURI of ONE account (`id`) for `scope` — the
* store-container document that LISTS the account's per-entity document NURIs
* (what {@link listMyEntityDocs} reads). Exposed so a reactive reader
* ({@link watchShape}) can SUBSCRIBE to this index document and re-resolve the
* entity-doc set when the index changes (a new entity created appends a NURI
* here). Idempotent via `ensureAccount`'s cache. At migration this becomes the
* user's real per-scope store NURI (the container the store itself provides).
*/
export async function scopeIndexDoc(id: string, scope: Scope): Promise<Nuri> {
const record = await ensureAccount(id);
return indexDocOf(record, scope);
}
/** /**
* The entity-document NURIs of `scope` belonging to ONE account (`id`) — * The entity-document NURIs of `scope` belonging to ONE account (`id`) —
* the read-by-need path for one account's own entities. Bounded to a SINGLE * the read-by-need path for one account's own entities. Bounded to a SINGLE
+384
View File
@@ -0,0 +1,384 @@
/**
* watch-shape — a REACTIVE, TanStack-`useQuery`-shaped read over one SHEX shape in
* one logical scope. This is the surface the consuming app will bind (phase B) with
* `useSyncExternalStore` — the polyfill deliberately exposes an OBSERVABLE, never a
* React hook (the lib has NO React dependency, same constraint as `subscribe.ts`).
*
* ── Why an observable, and why this exact shape ────────────────────────────
* It anticipates NextGraph's planned `useShape(shape, scope)` upgrade, which will
* natively distinguish "sync in progress" from "synced but empty". That distinction
* ALREADY exists lib-internally (`open-repo.ts` `getSyncState`: syncing / synced /
* timed-out); `watchShape` merely SURFACES it as a `useQuery`-minimal snapshot:
* `ShapeQuery<T> = { data: T[]; isPending; isSuccess; isError; error }`.
* `data` is ALWAYS an array (never `undefined`), so a synced-but-empty scope reads
* `{ data: [], isPending: false, isSuccess: true }` — the key distinction — while a
* scope still syncing reads `{ data: [], isPending: true, isSuccess: false }`.
*
* ── What the observable OWNS (the whole read pipeline) ─────────────────────
* 1. Resolve the logical scope → the doc set: the current identity's per-entity
* docs for that scope (`storeRegistry.listMyEntityDocs`), PLUS — for `public`
* only — the discovery index folded in (`discovery.readIndex`), so the app
* never orchestrates discovery to read. Faithful to the future
* `useShape(shape, 'public')`.
* 2. Open the docs (`ensureReposOpen`) — this AWAITS the sync BARRIER (first
* `State` per doc, `getSyncState` → `synced`, or `timed-out` on the bounded
* fallback). `isPending` holds until the barrier is reached for the current
* doc set AND the first `readUnion` has rendered.
* 3. `readUnion(docs)` — the read-model (cap filter already applied inside; we do
* NOT double-filter), then FILTER the union by the requested shape's `@type`
* (a `readUnion` union spans multiple types; each `watchShape` yields only the
* subjects of its shape). Non-domain: the type IRI is read from the SHEX
* ShapeType, not from any application concept.
*
* ── Reactivity WITHOUT polling (no `setInterval`) ──────────────────────────
* Reactivity is push-only (rule no-broker-polling): `subscribeDoc` on every doc in
* the current set re-runs `readUnion` on any push. The set is DYNAMIC (creating an
* entity appends a NURI to the scope-index doc; announcing a public entity appends
* to the discovery index), so we ALSO subscribe to the scope-index document (and,
* for `public`, the discovery-index document): a push there re-RESOLVES the scope
* and re-keys the subscribed set. Subscriptions are idempotent — an already-followed
* doc is not re-subscribed. Everything reuses `subscribe.ts` / `open-repo.ts`; no
* parallel channel.
*
* ── timed-out → isSuccess (best-effort), NOT isError ───────────────────────
* A doc whose barrier fell back to `timed-out` still counts as "barrier reached"
* (`isSuccess`): a slow-but-empty wallet must read as empty-success, not error.
* `isError` fires ONLY on a real thrown exception in the pipeline.
*/
import { getCurrentUser } from "./polyfill";
import { ensureReposOpen, getSyncState } from "./open-repo";
import { readUnion, type UnionSubject } from "./read-model";
import { subscribeDoc, type Unsubscribe } from "./subscribe";
import { listMyEntityDocs, scopeIndexDoc } from "./store-registry";
import { readIndex, indexDocNuri } from "./discovery";
import type { Nuri, Scope } from "./types";
/**
* The RDF `type` predicate IRI. A SHEX shape pins its class via a triple
* constraint on this predicate; we filter the read union by it.
*/
const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
/**
* A minimal TanStack-`useQuery`-shaped read snapshot. `data` is ALWAYS an array
* (never `undefined`). Defaults `T` to {@link UnionSubject} — `watchShape` yields
* the generic per-subject property bags of the read-model (NO application domain);
* the app maps them to its own entity types in phase B.
*/
export interface ShapeQuery<T = UnionSubject> {
/** The subjects of the requested shape/scope. Empty array when none (never undefined). */
data: T[];
/** True while the sync barrier for the current doc set is not yet reached OR the
* first `readUnion` has not rendered. Mutually exclusive with `isSuccess`. */
isPending: boolean;
/** True once the barrier is reached (all docs `synced` OR `timed-out`) AND the
* first `readUnion` has rendered. A synced-but-EMPTY scope is `isSuccess` with
* `data: []` — the distinction this surface exists for. */
isSuccess: boolean;
/** True ONLY on a real thrown exception in the read pipeline (never for `timed-out`). */
isError: boolean;
/** The caught error when `isError`, else `undefined`. */
error: unknown;
}
/** The observable a caller binds with `useSyncExternalStore` (phase B). */
export interface ShapeObservable<T = UnionSubject> {
/** The current snapshot. STABLE across calls until it actually changes (so
* `useSyncExternalStore` does not loop): the same reference is returned until a
* state transition produces a new one. */
getSnapshot(): ShapeQuery<T>;
/** Register a change listener; returns an unsubscribe. The last listener's
* unsubscribe tears down the underlying doc subscriptions. */
subscribe(onChange: () => void): () => void;
/** Force a re-resolve + re-read now (e.g. an imperative refresh). Idempotent
* w.r.t. subscriptions; never polls. */
refetch(): void;
}
/**
* Derive the class IRI(s) a SHEX {@link ShapeType} pins on `rdf:type`, if any.
* A generated shape constrains its subject's type via a triple constraint on the
* `rdf:type` predicate whose `literals` carry the class IRI(s). Returns the set of
* those IRIs, or `null` when the shape pins NO type (then no type-filter is applied
* and every subject in the doc set flows through). Purely structural — reads only
* the SHEX schema, no application domain.
*/
function shapeTypeIris(shapeType: unknown): Set<string> | null {
try {
const st = shapeType as {
shape?: string;
schema?: Record<string, { predicates?: Array<{ iri?: string; dataTypes?: Array<{ literals?: unknown[] }> }> }>;
};
const shape = st?.shape && st.schema ? st.schema[st.shape] : undefined;
const preds = shape?.predicates ?? [];
const iris = new Set<string>();
for (const p of preds) {
if (p?.iri !== RDF_TYPE) continue;
for (const dt of p.dataTypes ?? []) {
for (const lit of dt.literals ?? []) {
if (typeof lit === "string") iris.add(lit);
}
}
}
return iris.size > 0 ? iris : null;
} catch {
return null;
}
}
/** Whether a subject satisfies the shape's `@type` constraint (or the shape pins none). */
function matchesShape(subject: UnionSubject, typeIris: Set<string> | null): boolean {
if (!typeIris) return true; // shape pins no rdf:type → accept every subject
const types = subject.props[RDF_TYPE] ?? [];
return types.some((t) => typeIris.has(t));
}
/**
* Whether the sync BARRIER is reached for the whole doc set. Called only AFTER
* `ensureReposOpen(docs)` has resolved, so each doc has been requested; the only
* state that still holds the barrier open is `syncing` (subscribed, first `State`
* not yet received). `synced` and `timed-out` both count as reached (`timed-out` is
* best-effort, not an error). `unknown` means the injected `ng` has no
* `doc_subscribe` (the fake/no-op open path, which has NO barrier semantics) — after
* a completed open it can only mean that path, so it counts as reached (opened,
* nothing to wait on). An EMPTY doc set is trivially past the barrier.
*/
function barrierReached(docs: Nuri[]): boolean {
for (const d of docs) {
if (getSyncState(d) === "syncing") return false;
}
return true;
}
/**
* Create a reactive, `useQuery`-shaped observable over one SHEX `shapeType` in one
* logical `scope` (`'public' | 'protected' | 'private'`). See the module header for
* the full pipeline. The returned observable is inert until its first
* {@link ShapeObservable.subscribe} (or {@link ShapeObservable.refetch}) — that is
* what kicks off resolution, opening and the first read; before then `getSnapshot`
* reports the initial pending snapshot.
*/
export function watchShape<T = UnionSubject>(
shapeType: unknown,
scope: Scope,
): ShapeObservable<T> {
const typeIris = shapeTypeIris(shapeType);
// The current, STABLE snapshot (same reference until a transition rebuilds it).
let snapshot: ShapeQuery<UnionSubject> = {
data: [],
isPending: true,
isSuccess: false,
isError: false,
error: undefined,
};
const listeners = new Set<() => void>();
let started = false;
// The docs currently subscribed for change signals, keyed by NURI → unsubscribe.
// Idempotent: a doc already here is not re-subscribed. Excludes the container
// (scope-index / discovery-index) subscriptions, held separately.
const docSubs = new Map<Nuri, Unsubscribe>();
// Container subscriptions (scope-index doc; discovery-index doc for `public`) —
// a push here means the doc SET may have changed → re-resolve.
const containerSubs = new Map<Nuri, Unsubscribe>();
// Monotonic token so a slow in-flight refresh cannot clobber a newer one.
let refreshToken = 0;
function emit(): void {
for (const l of listeners) {
try {
l();
} catch (error) {
console.error("[watch-shape] listener threw", error);
}
}
}
function setSnapshot(next: ShapeQuery<UnionSubject>): void {
snapshot = next;
emit();
}
/** Extract candidate document NURIs from an opaque discovery `ref` — every
* string, recursively, that looks like a NextGraph doc NURI (`did:ng:`). Generic:
* the app puts the entity doc NURI inside the ref it submits; we fold those docs
* into the read-set so the app need not orchestrate discovery. Non-NURI refs
* contribute nothing (and readUnion+shape-filter drop anything irrelevant). */
function nurisFromRef(ref: unknown, out: Set<Nuri>): void {
if (typeof ref === "string") {
if (ref.startsWith("did:ng:")) out.add(ref);
return;
}
if (Array.isArray(ref)) {
for (const v of ref) nurisFromRef(v, out);
return;
}
if (ref && typeof ref === "object") {
for (const v of Object.values(ref)) nurisFromRef(v, out);
}
}
/** Resolve the logical scope → the current doc set (my entity docs + discovery
* fold for `public`). Tolerant: a resolution failure yields whatever resolved. */
async function resolveDocs(): Promise<Nuri[]> {
const user = getCurrentUser();
const set = new Set<Nuri>();
if (user) {
try {
for (const d of await listMyEntityDocs(user, scope)) set.add(d);
} catch (error) {
console.error("[watch-shape] listMyEntityDocs failed", error);
}
}
if (scope === "public") {
try {
for (const e of await readIndex()) nurisFromRef(e.ref, set);
} catch (error) {
console.error("[watch-shape] discovery readIndex failed", error);
}
}
return [...set];
}
/** Subscribe to the CONTAINER documents (scope-index; discovery-index for public)
* so a change to the doc SET re-resolves. Idempotent per NURI. */
async function ensureContainerSubs(): Promise<void> {
const containers: Nuri[] = [];
const user = getCurrentUser();
if (user) {
try {
containers.push(await scopeIndexDoc(user, scope));
} catch (error) {
console.error("[watch-shape] scopeIndexDoc failed", error);
}
}
if (scope === "public") {
try {
containers.push(await indexDocNuri());
} catch (error) {
console.error("[watch-shape] indexDocNuri failed", error);
}
}
for (const c of containers) {
if (!c || containerSubs.has(c)) continue;
// A push on a container doc means the set may have changed → full re-resolve.
containerSubs.set(c, subscribeDoc(c, () => void refresh()));
}
}
/** Re-key the per-doc change subscriptions to exactly `docs` (idempotent adds,
* prune removed). A push on any of these re-reads (data-only, no re-resolve). */
function syncDocSubs(docs: Nuri[]): void {
const wanted = new Set(docs.filter(Boolean));
for (const [nuri, unsub] of docSubs) {
if (!wanted.has(nuri)) {
try {
unsub();
} catch {
/* ignore */
}
docSubs.delete(nuri);
}
}
for (const nuri of wanted) {
if (docSubs.has(nuri)) continue;
docSubs.set(nuri, subscribeDoc(nuri, () => void reread()));
}
}
/** Read (union + shape filter) the CURRENT doc set and publish a snapshot.
* Derives isPending/isSuccess from the barrier + whether the read rendered. */
async function readAndPublish(docs: Nuri[], token: number): Promise<void> {
let subjects: UnionSubject[];
try {
subjects = await readUnion(docs);
} catch (error) {
if (token !== refreshToken) return;
setSnapshot({ data: [], isPending: false, isSuccess: false, isError: true, error });
return;
}
if (token !== refreshToken) return; // superseded by a newer refresh/reread
const data = subjects.filter((s) => matchesShape(s, typeIris));
const past = barrierReached(docs);
setSnapshot({
data,
isPending: !past,
isSuccess: past,
isError: false,
error: undefined,
});
}
/** Full cycle: resolve the scope, (re)establish container subs, open the docs
* (await the barrier), sync per-doc subs, then read + publish. */
async function refresh(): Promise<void> {
const token = ++refreshToken;
try {
await ensureContainerSubs();
const docs = await resolveDocs();
if (token !== refreshToken) return;
syncDocSubs(docs);
// Open/await the barrier (first State per doc, or timed-out). No-op once open.
await ensureReposOpen(docs);
if (token !== refreshToken) return;
await readAndPublish(docs, token);
} catch (error) {
if (token !== refreshToken) return;
setSnapshot({ data: [], isPending: false, isSuccess: false, isError: true, error });
}
}
/** A push on an already-open doc: re-read the CURRENT set only (no re-resolve,
* the set is unchanged). Reuses the docs we are subscribed to. */
async function reread(): Promise<void> {
const token = ++refreshToken;
const docs = [...docSubs.keys()];
await readAndPublish(docs, token);
}
function start(): void {
if (started) return;
started = true;
void refresh();
}
return {
getSnapshot(): ShapeQuery<T> {
return snapshot as unknown as ShapeQuery<T>;
},
subscribe(onChange: () => void): () => void {
listeners.add(onChange);
start();
return () => {
listeners.delete(onChange);
if (listeners.size === 0) {
// Last listener gone → tear down the underlying subscriptions. A later
// subscribe restarts a fresh cycle.
for (const u of docSubs.values()) {
try {
u();
} catch {
/* ignore */
}
}
for (const u of containerSubs.values()) {
try {
u();
} catch {
/* ignore */
}
}
docSubs.clear();
containerSubs.clear();
started = false;
}
};
},
refetch(): void {
start();
void refresh();
},
};
}
+20 -18
View File
@@ -6,8 +6,10 @@
* (a) OFF by default: reads + writes via sparqlQuery / sparqlUpdate / docCreate * (a) OFF by default: reads + writes via sparqlQuery / sparqlUpdate / docCreate
* emit nothing to console.log. * emit nothing to console.log.
* (b) ON via configure({ debugAccessLog: true }): each read/write emits a line * (b) ON via configure({ debugAccessLog: true }): each read/write emits a line
* matching `[<identity>] READ/WRITE <nuri> (<label>)` plus row-count suffix * matching `[<identity>][polyfill] READ/WRITE <shortNuri> (<label>)` (identity
* on READs. * FIRST, `[polyfill]` glued right after) plus row-count suffix on READs. The
* NURI is shortened by shortNuri (did:ng:o: prefix + :v: suffix stripped,
* RepoID truncated to 8 chars + ellipsis).
* (c) ON via env var NG_EVENTUALLY_ACCESS_LOG=1: same behavior without changing * (c) ON via env var NG_EVENTUALLY_ACCESS_LOG=1: same behavior without changing
* calling code. * calling code.
* (d) Identity follows setCurrentUser: after setCurrentUser the prefix changes. * (d) Identity follows setCurrentUser: after setCurrentUser the prefix changes.
@@ -17,7 +19,7 @@
*/ */
import { describe, it, expect, mock, beforeEach, afterEach, afterAll } from "bun:test"; import { describe, it, expect, mock, beforeEach, afterEach, afterAll } from "bun:test";
import { setAccessLog, enabled } from "../src/access-log"; import { setAccessLog, enabled, shortNuri } from "../src/access-log";
import { docCreate, sparqlUpdate, sparqlQuery } from "../src/docs"; import { docCreate, sparqlUpdate, sparqlQuery } from "../src/docs";
import { import {
configure, configure,
@@ -47,7 +49,6 @@ function injectFake(debugAccessLog = false) {
configure({ ng: ng as any, useShape: (() => {}) as any, debugAccessLog }); configure({ ng: ng as any, useShape: (() => {}) as any, debugAccessLog });
configureStoreRegistry({ configureStoreRegistry({
getSession: async () => ({ sessionId: "sid-log", privateStoreId: "P" }), getSession: async () => ({ sessionId: "sid-log", privateStoreId: "P" }),
provisionRetry: { attempts: 1 },
}); });
return ng; return ng;
} }
@@ -160,11 +161,12 @@ describe("access-log: ON via configure({ debugAccessLog: true })", () => {
restore(); restore();
} }
expect(lines.length).toBe(1); expect(lines.length).toBe(1);
expect(lines[0]).toMatch(/\[alice\]/); expect(lines[0]).toMatch(/^\[alice\]\[polyfill\] /); // identity-first, glued [polyfill] prefix
expect(lines[0]).toMatch(/READ/); expect(lines[0]).toMatch(/READ/);
expect(lines[0]).toMatch(/did:ng:o:q/); expect(lines[0]).toContain(shortNuri("did:ng:o:q")); // NURI shortened
expect(lines[0]).not.toContain("did:ng:o:"); // full prefix stripped
expect(lines[0]).toMatch(/myLabel/); expect(lines[0]).toMatch(/myLabel/);
expect(lines[0]).toMatch(/→ 1 rows/); // row-count from the 1-row fake result expect(lines[0]).toMatch(/→ 1 triple-rows/); // triple-count from the 1-row fake result
}); });
it("sparqlUpdate emits a WRITE line with identity, anchor nuri, and label", async () => { it("sparqlUpdate emits a WRITE line with identity, anchor nuri, and label", async () => {
@@ -177,9 +179,9 @@ describe("access-log: ON via configure({ debugAccessLog: true })", () => {
restore(); restore();
} }
expect(lines.length).toBe(1); expect(lines.length).toBe(1);
expect(lines[0]).toMatch(/\[alice\]/); expect(lines[0]).toMatch(/^\[alice\]\[polyfill\] /); // identity-first, glued [polyfill] prefix
expect(lines[0]).toMatch(/WRITE/); expect(lines[0]).toMatch(/WRITE/);
expect(lines[0]).toMatch(/did:ng:o:w/); expect(lines[0]).toContain(shortNuri("did:ng:o:w"));
expect(lines[0]).toMatch(/writeLabel/); expect(lines[0]).toMatch(/writeLabel/);
}); });
@@ -193,10 +195,10 @@ describe("access-log: ON via configure({ debugAccessLog: true })", () => {
restore(); restore();
} }
expect(lines.length).toBe(1); expect(lines.length).toBe(1);
expect(lines[0]).toMatch(/\[alice\]/); expect(lines[0]).toMatch(/^\[alice\]\[polyfill\] /); // identity-first, glued [polyfill] prefix
expect(lines[0]).toMatch(/WRITE/); expect(lines[0]).toMatch(/WRITE/);
// The nuri is the value returned by ng.doc_create // The nuri is the value returned by ng.doc_create, shortened by shortNuri.
expect(lines[0]).toMatch(/did:ng:o:log-doc/); expect(lines[0]).toContain(shortNuri("did:ng:o:log-doc"));
}); });
it("enabled() returns true when set via setAccessLog", () => { it("enabled() returns true when set via setAccessLog", () => {
@@ -219,9 +221,9 @@ describe("access-log: ON via env var NG_EVENTUALLY_ACCESS_LOG=1", () => {
restore(); restore();
} }
expect(lines.length).toBe(1); expect(lines.length).toBe(1);
expect(lines[0]).toMatch(/\[bob\]/); expect(lines[0]).toMatch(/^\[bob\]\[polyfill\] /); // identity-first, glued [polyfill] prefix
expect(lines[0]).toMatch(/READ/); expect(lines[0]).toMatch(/READ/);
expect(lines[0]).toMatch(/did:ng:o:env-q/); expect(lines[0]).toContain(shortNuri("did:ng:o:env-q"));
}); });
it("env var NG_EVENTUALLY_ACCESS_LOG=true also enables the log", async () => { it("env var NG_EVENTUALLY_ACCESS_LOG=true also enables the log", async () => {
@@ -236,7 +238,7 @@ describe("access-log: ON via env var NG_EVENTUALLY_ACCESS_LOG=1", () => {
restore(); restore();
} }
expect(lines.length).toBe(1); expect(lines.length).toBe(1);
expect(lines[0]).toMatch(/\[charlie\]/); expect(lines[0]).toMatch(/^\[charlie\]\[polyfill\] /); // identity-first, glued [polyfill] prefix
expect(lines[0]).toMatch(/WRITE/); expect(lines[0]).toMatch(/WRITE/);
}); });
@@ -261,8 +263,8 @@ describe("access-log: identity follows setCurrentUser", () => {
restore(); restore();
} }
expect(lines.length).toBe(2); expect(lines.length).toBe(2);
expect(lines[0]).toMatch(/\[first-user\]/); expect(lines[0]).toMatch(/^\[first-user\]\[polyfill\] /); // identity-first, glued [polyfill] prefix
expect(lines[1]).toMatch(/\[second-user\]/); expect(lines[1]).toMatch(/^\[second-user\]\[polyfill\] /);
}); });
it("prefix is (none) when no identity is set", async () => { it("prefix is (none) when no identity is set", async () => {
@@ -275,6 +277,6 @@ describe("access-log: identity follows setCurrentUser", () => {
restore(); restore();
} }
expect(lines.length).toBe(1); expect(lines.length).toBe(1);
expect(lines[0]).toMatch(/\[\(none\)\]/); expect(lines[0]).toMatch(/^\[\(none\)\]\[polyfill\] /); // identity-first, glued [polyfill] prefix
}); });
}); });
+198 -126
View File
@@ -1,23 +1,29 @@
/** /**
* anti-fork.test.ts — behavioral tests for the ANTI-FORK guard in ensureAccount / * anti-fork.test.ts — behavioral tests for the reconnection fix in the
* resolveAccountReliably (src/store-registry.ts). * polyfill-era shim (src/store-registry.ts), redesigned around the
* pointer → doc-shim indirection. Two groups, one file:
* *
* The guard is: BEFORE reading the shim, open the private-store repo and await * (1) DETERMINISTIC RESOLUTION — a doc-shim whose account subject carries
* the first `State` push (the deterministic sync barrier, CONTRACT 3). After the * DUPLICATE scope-doc values (fork residue: several `shim:docPublic`) must
* barrier, 0 rows is DEFINITIVE (account genuinely absent) → provision exactly * resolve to the SAME canonical doc every time (lexicographically-smallest
* once; rows present → reuse (NO-FORK). No retry loop, no polling. * NURI), so the session that WROTE an entity and a fresh page that RESOLVES
* the doc never disagree. Robustness against PAST fork residue.
* *
* With the unit fake `ng` (no `doc_subscribe`), `ensureRepoOpen` is a no-op * (2) BARRIER-AUTHORITATIVE RECONNECT (the core fix) — a fresh page over a
* (getSyncState → "unknown") and the single shim read is immediate — no lag to * persistent wallet resolves the SAME account through the doc-shim's
* wait out, synchronous behaviour preserved. * first-`State` BARRIER, with NO account-level retry. The account records
* * live in a subscribable doc-shim (`did:ng:o:...`) reached via a write-once
* Timed-out barrier: if the barrier expires without a `State`, we refuse to * POINTER triple in the store-root; opening the doc-shim makes a cold read
* provision (throwing a clear error) rather than risk a fork. * authoritative, so a genuinely-present account is found on the first read
* and never re-provisioned (no fork). This replaced the deleted
* `resolveAccountReliably` / `provisionRetry` account retry.
*/ */
import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test"; import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test";
import { import {
ensureAccount, ensureAccount,
resolveAccount,
loadShim,
resetRegistryCache, resetRegistryCache,
} from "../src/store-registry"; } from "../src/store-registry";
import type { RegistrySession } from "../src/store-registry"; import type { RegistrySession } from "../src/store-registry";
@@ -27,7 +33,7 @@ import {
resetStoreRegistry, resetStoreRegistry,
resetConfig, resetConfig,
} from "../src/polyfill"; } from "../src/polyfill";
import { resetOpenedRepos, _forceOpenedSyncState } from "../src/open-repo"; import { resetOpenedRepos } from "../src/open-repo";
afterAll(() => { afterAll(() => {
resetConfig(); resetConfig();
@@ -37,13 +43,17 @@ afterAll(() => {
}); });
const SESSION: RegistrySession = { sessionId: "sid-af", privateStoreId: "PRIV-AF" }; const SESSION: RegistrySession = { sessionId: "sid-af", privateStoreId: "PRIV-AF" };
const ROOT = "did:ng:PRIV-AF";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Fake ng — simple in-memory store (no doc_subscribe → fake-ng no-op path) // Fake ng — in-memory quad store modelling the pointer → doc-shim indirection.
// //
// Queries return data from the `quads` array immediately. No lag simulation: // - The POINTER (`<shim:root> <shim:shimDoc> <docShim>`) lives in the store-root
// the barrier mechanism (ensureRepoOpen) is a no-op in the fake-ng path, so // graph (keyed by GRAPH <ROOT>).
// the single shim read after "the barrier" is already authoritative. // - AccountRecords live in the doc-shim (anchored default graph, keyed by the
// anchor arg = the doc-shim NURI).
// - doc_subscribe pushes an initial `State` so ensureRepoOpen resolves at once
// (the barrier).
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
interface Quad { g: string; s: string; p: string; o: string } interface Quad { g: string; s: string; p: string; o: string }
@@ -61,14 +71,8 @@ function unescapeLiteral(s: string): string {
return out; return out;
} }
function makeFakeNg() { function makeSparqlUpdate(quads: Quad[]) {
const quads: Quad[] = []; return mock(async (...a: unknown[]) => {
let docCounter = 0;
let accountQueryCount = 0;
const doc_create = mock(async (..._a: unknown[]) => `did:ng:o:doc${++docCounter}`);
const sparql_update = mock(async (...a: unknown[]) => {
const query = a[1] as string; const query = a[1] as string;
const anchor = a[2] as string | undefined; const anchor = a[2] as string | undefined;
const gm = query.match(/GRAPH <([^>]+)>\s*\{([\s\S]*)\}/); const gm = query.match(/GRAPH <([^>]+)>\s*\{([\s\S]*)\}/);
@@ -95,163 +99,231 @@ function makeFakeNg() {
} }
return undefined; return undefined;
}); });
}
const sparql_query = mock(async (...a: unknown[]) => { /** Build the account-SELECT bindings from the quads in ONE graph (the doc-shim),
const query = a[1] as string; * grouped per subject. A subject with DUPLICATE scope docs yields a cross-product
const anchor = a[3] as string | undefined; * of bindings — a corrupted shim. */
function accountBindings(quads: Quad[], anchor: string | undefined, onlySubject: string | null) {
if (query.includes("<urn:ng-eventually:shim:id>")) { const bySubject = new Map<string, { id: string; pub: string[]; prot: string[]; priv: string[] }>();
// Account SELECT (resolveAccount / loadShim)
accountQueryCount++;
const subjM = query.match(/GRAPH <[^>]+>\s*\{\s*<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/);
const onlySubject = subjM ? subjM[1]! : null;
const bySubject = new Map<string, Record<string, string>>();
for (const q of quads) { for (const q of quads) {
if (q.g !== anchor) continue; if (q.g !== anchor) continue;
if (onlySubject !== null && q.s !== onlySubject) continue; if (onlySubject !== null && q.s !== onlySubject) continue;
const rec = bySubject.get(q.s) ?? {}; const rec = bySubject.get(q.s) ?? { id: "", pub: [], prot: [], priv: [] };
if (q.p === "urn:ng-eventually:shim:id") rec.id = q.o; if (q.p === "urn:ng-eventually:shim:id") rec.id = q.o;
if (q.p === "urn:ng-eventually:shim:docPublic") rec.docPublic = q.o; if (q.p === "urn:ng-eventually:shim:docPublic") rec.pub.push(q.o);
if (q.p === "urn:ng-eventually:shim:docProtected") rec.docProtected = q.o; if (q.p === "urn:ng-eventually:shim:docProtected") rec.prot.push(q.o);
if (q.p === "urn:ng-eventually:shim:docPrivate") rec.docPrivate = q.o; if (q.p === "urn:ng-eventually:shim:docPrivate") rec.priv.push(q.o);
bySubject.set(q.s, rec); bySubject.set(q.s, rec);
} }
const bindings = [...bySubject.values()] const bindings: Array<Record<string, { value: string }>> = [];
.filter((r) => r.id) for (const rec of bySubject.values()) {
.map((r) => ({ if (!rec.id) continue;
id: { value: r.id! }, const pubs = rec.pub.length ? rec.pub : [""];
docPublic: { value: r.docPublic ?? "" }, const prots = rec.prot.length ? rec.prot : [""];
docProtected: { value: r.docProtected ?? "" }, const privs = rec.priv.length ? rec.priv : [""];
docPrivate: { value: r.docPrivate ?? "" }, for (const pub of pubs)
})); for (const prot of prots)
return { results: { bindings } }; for (const priv of privs)
bindings.push({
id: { value: rec.id },
docPublic: { value: pub },
docProtected: { value: prot },
docPrivate: { value: priv },
});
}
return bindings;
} }
// Entity-index SELECT /** Reactive fake ng modelling the pointer → doc-shim indirection. `doc_subscribe`
* pushes a first `State` so the doc-shim barrier resolves synchronously. */
function makeFakeNg() {
const quads: Quad[] = [];
let docCounter = 0;
// Count account SELECTs per anchor graph. The AUTHORITATIVE account read is anchored
// to the doc-shim (a did:ng:o: NURI). Counting per-anchor lets a test assert "exactly
// one doc-shim account read" (no account RETRY).
const accountReadsByAnchor = new Map<string, number>();
const doc_create = mock(async (..._a: unknown[]) => `did:ng:o:doc${++docCounter}`);
const sparql_update = makeSparqlUpdate(quads);
const sparql_query = mock(async (...a: unknown[]) => {
const query = a[1] as string;
const anchor = a[3] as string | undefined;
// Pointer SELECT (store-root).
if (query.includes("<urn:ng-eventually:shim:shimDoc>")) {
const bindings = quads
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:shimDoc")
.map((q) => ({ shimDoc: { value: q.o } }));
return { results: { bindings } };
}
// Account SELECT — anchored to the doc-shim (the authoritative read).
if (query.includes("<urn:ng-eventually:shim:id>")) {
accountReadsByAnchor.set(anchor ?? "", (accountReadsByAnchor.get(anchor ?? "") ?? 0) + 1);
const subjM = query.match(/<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/);
return { results: { bindings: accountBindings(quads, anchor, subjM ? subjM[1]! : null) } };
}
const bindings = quads const bindings = quads
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:contains") .filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:contains")
.map((q) => ({ e: { value: q.o } })); .map((q) => ({ e: { value: q.o } }));
return { results: { bindings } }; return { results: { bindings } };
}); });
// Push a `State` on subscribe (the sync barrier) so ensureRepoOpen resolves at once.
const doc_subscribe = mock(async (_repo: unknown, _sid: unknown, cb: Function) => {
if (typeof cb === "function") cb({ V0: { State: {} } });
return () => {};
});
return { return {
doc_create, doc_create, sparql_update, sparql_query, doc_subscribe,
sparql_update,
sparql_query,
_quads: quads, _quads: quads,
getAccountQueryCount: () => accountQueryCount, // Total account reads across all anchors.
getAccountQueryCount: () => [...accountReadsByAnchor.values()].reduce((a, b) => a + b, 0),
// Account reads anchored to a did:ng:o: doc-shim.
getDocShimAccountReads: () =>
[...accountReadsByAnchor.entries()]
.filter(([g]) => g.startsWith("did:ng:o:"))
.reduce((a, [, n]) => a + n, 0),
}; };
} }
function inject(fakeNg: ReturnType<typeof makeFakeNg>) { function inject(
// No doc_subscribe → ensureRepoOpen is a no-op (fake-ng path). fakeNg: unknown,
pointerGuard?: { attempts?: number; baseMs?: number; maxStepMs?: number },
) {
configure({ ng: fakeNg as any, useShape: (() => {}) as any }); configure({ ng: fakeNg as any, useShape: (() => {}) as any });
configureStoreRegistry({ configureStoreRegistry({
getSession: async () => SESSION, getSession: async () => SESSION,
normalizeId: (u) => u.trim().toLowerCase(), normalizeId: (u) => u.trim().toLowerCase(),
// provisionRetry is now unused by the barrier mechanism; kept for API compat. pointerGuard,
provisionRetry: { attempts: 1 },
}); });
resetRegistryCache(); resetRegistryCache();
resetOpenedRepos(); resetOpenedRepos();
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Tests // (1) Deterministic resolution over fork residue (in the doc-shim)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describe("anti-fork: barrier-first resolveAccountReliably / ensureAccount", () => { describe("deterministic resolution over a doc-shim corrupted by fork residue", () => {
beforeEach(() => { beforeEach(() => { resetRegistryCache(); resetOpenedRepos(); });
resetRegistryCache();
resetOpenedRepos();
});
it("(a) NO-FORK: account already in shim → reused, 0 new doc_create", async () => { it("(1a) a subject with MULTIPLE docPublic values always resolves the SAME canonical (lexicographically-smallest)", async () => {
// Provision an account in an initial "session" (quads are written).
const fakeNg = makeFakeNg(); const fakeNg = makeFakeNg();
inject(fakeNg); inject(fakeNg);
const first = await ensureAccount("LauraBarrier"); const docShim = "did:ng:o:shimdoc";
expect(fakeNg.doc_create).toHaveBeenCalledTimes(3); // Seed the pointer (store-root → doc-shim) and the corrupted record IN the doc-shim.
fakeNg._quads.push({ g: ROOT, s: "urn:ng-eventually:shim:root", p: "urn:ng-eventually:shim:shimDoc", o: docShim });
const subj = "urn:ng-eventually:shim:account:dupuser";
const dupPublics = [
"did:ng:o:pub-m", "did:ng:o:pub-a", "did:ng:o:pub-z", "did:ng:o:pub-c", "did:ng:o:pub-a",
];
fakeNg._quads.push({ g: docShim, s: subj, p: "urn:ng-eventually:shim:id", o: "dupuser" });
for (const p of dupPublics)
fakeNg._quads.push({ g: docShim, s: subj, p: "urn:ng-eventually:shim:docPublic", o: p });
fakeNg._quads.push({ g: docShim, s: subj, p: "urn:ng-eventually:shim:docProtected", o: "did:ng:o:prot-1" });
fakeNg._quads.push({ g: docShim, s: subj, p: "urn:ng-eventually:shim:docPrivate", o: "did:ng:o:priv-1" });
// Simulate a fresh session: clear caches but keep quads intact (same fakeNg). const r1 = await resolveAccount("dupuser");
// In the barrier model the single post-barrier read finds the account immediately. resetRegistryCache();
const r2 = await resolveAccount("dupuser");
const viaShim = (await loadShim()).get("dupuser");
// Canonical = lexicographically smallest → "did:ng:o:pub-a".
expect(r1?.docPublic).toBe("did:ng:o:pub-a");
expect(r2?.docPublic).toBe(r1?.docPublic);
expect(viaShim?.docPublic).toBe(r1?.docPublic);
expect(viaShim?.docProtected).toBe("did:ng:o:prot-1");
expect(viaShim?.docPrivate).toBe("did:ng:o:priv-1");
});
});
// ---------------------------------------------------------------------------
// (2) Barrier-authoritative reconnect — the core fix (no account retry)
// ---------------------------------------------------------------------------
describe("reconnect resolves the SAME account through the doc-shim barrier (no fork, no account retry)", () => {
beforeEach(() => { resetRegistryCache(); resetOpenedRepos(); });
it("(2a) NO-FORK: account already in the doc-shim → reused, 0 new scope docs", async () => {
const fakeNg = makeFakeNg();
inject(fakeNg);
// First login: provisions the account (1 doc-shim + 3 scope docs).
const first = await ensureAccount("LauraBarrier");
expect(fakeNg.doc_create).toHaveBeenCalledTimes(4);
// Fresh page over the SAME persistent quads: reset all in-memory caches, keep the
// quads. Reconnect must find the SAME account via the doc-shim barrier, NO new docs.
resetRegistryCache(); resetRegistryCache();
resetOpenedRepos(); resetOpenedRepos();
const second = await ensureAccount("LauraBarrier"); const second = await ensureAccount("LauraBarrier");
// ANTI-FORK: same scope docs returned, no new provisioning
expect(second.docPublic).toBe(first.docPublic); expect(second.docPublic).toBe(first.docPublic);
expect(second.docProtected).toBe(first.docProtected); expect(second.docProtected).toBe(first.docProtected);
expect(second.docPrivate).toBe(first.docPrivate); expect(second.docPrivate).toBe(first.docPrivate);
expect(fakeNg.doc_create).toHaveBeenCalledTimes(3); // still exactly 3, not 6 // Still 4 — no doc-shim re-created (pointer reused), no scope docs re-created.
expect(fakeNg.doc_create).toHaveBeenCalledTimes(4);
}); });
it("(b) genuinely new account (fake returns 0 rows) → provisioned exactly once (3 doc_create)", async () => { it("(2b) barrier-authoritative: a fresh session finds the persisted account on the FIRST read — single doc-shim read, NO retry", async () => {
const fakeNg = makeFakeNg(); // empty quads → 0 rows on any account query // Seed session 1; capture the account NURIs + the persistent quads.
inject(fakeNg); const seed = makeFakeNg();
inject(seed);
const orig = await ensureAccount("BarrierUser");
expect(seed.doc_create).toHaveBeenCalledTimes(4);
// Fresh reactive session over the SAME persistent quads. The doc-shim pushes a
// `State` on subscribe → resolveShimDoc opens the barrier → the account read is
// authoritative on the FIRST attempt. Give a MULTI-attempt pointer guard to prove
// it is NOT used for the account.
const reconnect = makeFakeNg();
reconnect._quads.push(...seed._quads);
inject(reconnect, { attempts: 8, baseMs: 1, maxStepMs: 2 });
const resolved = await ensureAccount("BarrierUser");
expect(resolved.docPublic).toBe(orig.docPublic);
expect(resolved.docProtected).toBe(orig.docProtected);
expect(resolved.docPrivate).toBe(orig.docPrivate);
expect(reconnect.doc_create).toHaveBeenCalledTimes(0); // no new docs → no fork
// Barrier-authoritative: found on the FIRST doc-shim read.
expect(reconnect.getDocShimAccountReads()).toBe(1);
});
it("(2c) GENUINELY NEW: a cold doc-shim reads 0 → provisioned exactly once, no retry", async () => {
// Pointer + doc-shim exist but the doc-shim holds NO record for this account.
const fakeNg = makeFakeNg();
inject(fakeNg, { attempts: 5, baseMs: 1, maxStepMs: 2 });
const docShim = "did:ng:o:preexisting-shim";
fakeNg._quads.push({ g: ROOT, s: "urn:ng-eventually:shim:root", p: "urn:ng-eventually:shim:shimDoc", o: docShim });
const rec = await ensureAccount("BrandNewUser"); const rec = await ensureAccount("BrandNewUser");
// Exactly 3 doc_create calls (1 set of scope docs, no fork) // Provisioned exactly ONE set of 3 scope docs (the doc-shim already existed).
expect(fakeNg.doc_create).toHaveBeenCalledTimes(3); expect(fakeNg.doc_create).toHaveBeenCalledTimes(3);
expect(rec.docPublic).toMatch(/^did:ng:o:doc/); expect(rec.docPublic).toMatch(/^did:ng:o:doc/);
expect(rec.docProtected).not.toBe(rec.docPublic); expect(rec.docProtected).not.toBe(rec.docPublic);
expect(rec.docPrivate).not.toBe(rec.docProtected); // Barrier-authoritative: exactly ONE doc-shim account read (the 0 is definitive).
// Single read — no retry loop expect(fakeNg.getDocShimAccountReads()).toBe(1);
expect(fakeNg.getAccountQueryCount()).toBe(1);
}); });
it("(c) idempotence within a session: calling ensureAccount twice never creates 2 sets", async () => { it("(2d) default budget (unset pointer guard): genuinely-new account → single doc-shim account read", async () => {
const fakeNg = makeFakeNg();
inject(fakeNg); // pointerGuard unset → attempts:1 (synchronous default)
const rec = await ensureAccount("SyncUser");
// 1 doc-shim + 3 scope docs.
expect(fakeNg.doc_create).toHaveBeenCalledTimes(4);
expect(fakeNg.getDocShimAccountReads()).toBe(1);
expect(rec.docPublic).toMatch(/^did:ng:o:doc/);
});
it("(2e) idempotence within a session: ensureAccount twice never creates 2 sets", async () => {
const fakeNg = makeFakeNg(); const fakeNg = makeFakeNg();
inject(fakeNg); inject(fakeNg);
const a = await ensureAccount("SameUser"); const a = await ensureAccount("SameUser");
const b = await ensureAccount("SameUser"); const b = await ensureAccount("SameUser");
expect(b).toEqual(a); expect(b).toEqual(a);
// Must still be exactly 3 (not 6) expect(fakeNg.doc_create).toHaveBeenCalledTimes(4); // 1 doc-shim + 3 scope docs
expect(fakeNg.doc_create).toHaveBeenCalledTimes(3);
});
it("(d) fake-ng no-op barrier path: single shim read, no doc_subscribe calls", async () => {
// The fake ng has no doc_subscribe → ensureRepoOpen is a no-op (getSyncState → "unknown").
// resolveAccountReliably must proceed to the single read without waiting or throwing.
const fakeNg = makeFakeNg(); // no doc_subscribe
inject(fakeNg);
// Provision once, then verify a second resolve (fresh cache) finds it immediately.
const first = await ensureAccount("FakeNgUser");
resetRegistryCache();
resetOpenedRepos();
const second = await ensureAccount("FakeNgUser");
// Same docs reused (no fork), exactly 3 total doc_create across both calls
expect(second.docPublic).toBe(first.docPublic);
expect(fakeNg.doc_create).toHaveBeenCalledTimes(3);
// The account query was called exactly twice (once per ensureAccount, no retries)
expect(fakeNg.getAccountQueryCount()).toBe(2);
});
it("(e) timed-out barrier: resolveAccountReliably throws rather than provisioning", async () => {
// Simulate: the private store nuri is already in the `opened` set but with
// sync state "timed-out" (forced via _forceOpenedSyncState so the test
// does not have to wait 8s for the real OPEN_TIMEOUT_MS to fire).
const fakeNg = makeFakeNg(); // empty quads → 0 rows
inject(fakeNg);
// Force the private-store nuri ("did:ng:PRIV-AF") into timed-out state.
// resolveAccountReliably calls anchorNuri() → `did:ng:${privateStoreId}`.
_forceOpenedSyncState("did:ng:PRIV-AF", "timed-out");
// ensureAccount must throw (conservative anti-fork guard: do not provision blind)
await expect(ensureAccount("TimedOutUser")).rejects.toThrow(
/sync barrier timed out/,
);
// Must NOT have created any scope docs (refusing to provision)
expect(fakeNg.doc_create).toHaveBeenCalledTimes(0);
}); });
}); });
@@ -0,0 +1,199 @@
/**
* cold-start-anchor.test.ts — the shim ANCHOR (private-store-root) must be OPENED
* before the registry reads/writes it, or a cold anchor throws `RepoNotFound`.
*
* ── The gap this pins ──────────────────────────────────────────────────────
* The shim lives in the private-store-root graph (`did:ng:${privateStoreId}`, the
* "anchor"). Unlike a per-entity doc — whose anchored read on an unopened repo
* SILENTLY returns 0 rows — the private/store target resolves through the verifier's
* `resolve_target_for_sparql`, which HARD-errors `RepoNotFound` when the repo is not
* in `self.repos` (verified in nextgraph-rs `request_processor.rs`). On a wallet whose
* anchor repo is not yet loaded, both the shim READ (`resolveAccount`/`loadShim`) and
* the provision WRITE (`ensureAccount`) throw — so the account never provisions.
*
* The heal: `resolveAccount`/`loadShim`/`ensureAccount` call `ensureRepoOpen(anchor)`
* (open-repo.ts, via `doc_subscribe` + first-`State` barrier) before touching the
* shim — the same open-before-read guard `readScopeIndex` already applies to its
* index doc. This suite models a fake `ng` where the anchor throws `RepoNotFound`
* UNTIL it has been `doc_subscribe`-d, and asserts the registry provisions cleanly.
*
* RED without the heal (ensureAccount would throw on the cold anchor); GREEN with it.
*/
import { describe, it, expect, mock, afterAll, beforeEach } from "bun:test";
import { ensureAccount, resolveWriteGraph, resetRegistryCache } from "../src/store-registry";
import { resetOpenedRepos } from "../src/open-repo";
import {
configure,
configureStoreRegistry,
resetStoreRegistry,
resetConfig,
} from "../src/polyfill";
const SESSION = { sessionId: "sid-cold", privateStoreId: "PRIV-COLD" };
const ANCHOR = `did:ng:${SESSION.privateStoreId}`;
afterAll(() => {
resetConfig();
resetStoreRegistry();
resetRegistryCache();
resetOpenedRepos();
});
beforeEach(() => {
resetRegistryCache();
resetOpenedRepos();
});
interface Quad { g: string; s: string; p: string; o: string }
function unescapeLiteral(s: string): string {
let out = "";
for (let i = 0; i < s.length; i++) {
if (s[i] === "\\" && i + 1 < s.length) {
const next = s[++i];
out += next === "n" ? "\n" : next === "r" ? "\r" : next === "t" ? "\t" : next!;
} else out += s[i];
}
return out;
}
/**
* A fake `ng` whose ANCHOR repo behaves like the real private-store target:
* `sparql_query`/`sparql_update` anchored to it THROW `RepoNotFound` until the
* anchor has been `doc_subscribe`-d (i.e. opened into `self.repos`). Any OTHER
* anchor (per-entity docs) behaves normally. `doc_subscribe` fires the first
* `State` so `ensureRepoOpen` crosses the barrier.
*/
function makeColdAnchorNg() {
const quads: Quad[] = [];
let docCounter = 0;
const opened = new Set<string>();
let anchorSubscribes = 0;
const doc_create = mock(async () => `did:ng:o:doc${++docCounter}`);
const doc_subscribe = mock(async (nuri: string, _sid: string, cb: (r: unknown) => void) => {
if (nuri === ANCHOR) anchorSubscribes += 1;
opened.add(nuri);
setTimeout(() => cb({ V0: { State: {} } }), 0);
return () => {};
});
const sparql_update = mock(async (_sid: string, query: string, anchor?: string) => {
if (anchor === ANCHOR && !opened.has(ANCHOR)) throw new Error("RepoNotFound");
// TWO shapes: the POINTER write uses `GRAPH <root>` (keyed by IRI); the account
// record write into the doc-shim has NO explicit GRAPH (keyed by the anchor arg).
const gm = query.match(/GRAPH <([^>]+)>\s*\{([\s\S]*)\}/);
let g: string;
let body: string;
if (gm) {
g = gm[1]!;
body = gm[2]!;
} else {
if (!anchor) return undefined;
g = anchor;
body = query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, "");
}
const sm = body.match(/<([^>]+)>/);
if (!sm) return undefined;
const s = sm[1]!;
const after = body.slice(body.indexOf(sm[0]) + sm[0].length);
const pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g;
let m: RegExpExecArray | null;
while ((m = pairRe.exec(after)) !== null) {
const p = m[1] ?? "urn:ng-eventually:shim:Account";
const o = m[2] !== undefined ? unescapeLiteral(m[2]) : (m[3] ?? "");
quads.push({ g, s, p, o });
}
return undefined;
});
const sparql_query = mock(async (_sid: string, query: string, _base: unknown, anchor?: string) => {
if (anchor === ANCHOR && !opened.has(ANCHOR)) throw new Error("RepoNotFound");
// Pointer SELECT (store-root -> doc-shim).
if (query.includes("<urn:ng-eventually:shim:shimDoc>")) {
const bindings = quads
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:shimDoc")
.map((q) => ({ shimDoc: { value: q.o } }));
return { results: { bindings } };
}
const subjM = query.match(
/<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/,
);
const onlySubject = subjM ? subjM[1]! : null;
const bySubject = new Map<string, Record<string, string>>();
for (const q of quads) {
if (q.g !== anchor) continue;
if (onlySubject !== null && q.s !== onlySubject) continue;
const rec = bySubject.get(q.s) ?? {};
if (q.p === "urn:ng-eventually:shim:id") rec.id = q.o;
if (q.p === "urn:ng-eventually:shim:docPublic") rec.docPublic = q.o;
if (q.p === "urn:ng-eventually:shim:docProtected") rec.docProtected = q.o;
if (q.p === "urn:ng-eventually:shim:docPrivate") rec.docPrivate = q.o;
bySubject.set(q.s, rec);
}
const bindings = [...bySubject.values()]
.filter((r) => r.id)
.map((r) => ({
id: { value: r.id! },
docPublic: { value: r.docPublic ?? "" },
docProtected: { value: r.docProtected ?? "" },
docPrivate: { value: r.docPrivate ?? "" },
}));
return { results: { bindings } };
});
return {
doc_create, doc_subscribe, sparql_update, sparql_query,
_quads: quads,
anchorSubscribeCount: () => anchorSubscribes,
};
}
function inject(ng: ReturnType<typeof makeColdAnchorNg>) {
configure({ ng: ng as any, useShape: (() => {}) as any });
configureStoreRegistry({
getSession: async () => SESSION,
normalizeId: (u: string) => u.trim().replace(/^@+/, "").toLowerCase(),
});
resetRegistryCache();
resetOpenedRepos();
}
describe("cold-start anchor heal", () => {
it("ensureAccount provisions over a COLD anchor (RepoNotFound-until-opened) without throwing", async () => {
const ng = makeColdAnchorNg();
inject(ng);
// Without the open-before-shim heal, the read AND the provision write would both
// throw RepoNotFound on the cold anchor and the account would never persist.
const rec = await ensureAccount("@cold-alice");
expect(rec.docPublic).toBeTruthy();
expect(rec.docProtected).toBeTruthy();
expect(rec.docPrivate).toBeTruthy();
// The anchor repo was actually opened (doc_subscribe-d) before the shim op.
expect(ng.anchorSubscribeCount()).toBeGreaterThan(0);
});
it("the provisioned account re-resolves from the shim (real persistence, no RepoNotFound)", async () => {
const ng = makeColdAnchorNg();
inject(ng);
const first = await ensureAccount("@cold-bob");
// Fresh cache → a real anchored re-read of the shim (anchor already opened → OK).
resetRegistryCache();
const again = await ensureAccount("@cold-bob");
expect(again.docPublic).toBe(first.docPublic);
expect(again.docProtected).toBe(first.docProtected);
expect(again.docPrivate).toBe(first.docPrivate);
});
it("resolveWriteGraph (scope resolver) works over a cold anchor", async () => {
const ng = makeColdAnchorNg();
inject(ng);
const g = await resolveWriteGraph("@cold-carol", "protected");
expect(g).toBeTruthy();
});
});
+13 -8
View File
@@ -119,11 +119,18 @@ function makeFakeNg() {
const sparql_query = mock(async (...a: unknown[]) => { const sparql_query = mock(async (...a: unknown[]) => {
const query = a[1] as string; const query = a[1] as string;
const anchor = a[3] as string | undefined; const anchor = a[3] as string | undefined;
// Shim account SELECT. Two shapes: the full scan (`?acc a <Account>`) and // Pointer SELECT: `<shim:root> <shim:shimDoc> ?shimDoc` in the store-root graph.
// the TARGETED bounded resolve (`<subj> a <Account>`), which binds one if (query.includes(`<${SHIM}:shimDoc>`)) {
// subject — honour that subject filter so the bounded query is O(1)/exact. const bindings = quads
.filter((q) => q.g === anchor && q.p === `${SHIM}:shimDoc`)
.map((q) => ({ shimDoc: { value: q.o } }));
return { results: { bindings } };
}
// Shim account SELECT (anchored to the doc-shim, no GRAPH wrapper). Two shapes:
// the full scan (`?acc a <Account>`) and the TARGETED bounded resolve (`<subj> a
// <Account>`) — honour that subject filter so the bounded query is O(1)/exact.
if (query.includes(`<${SHIM}:id>`)) { if (query.includes(`<${SHIM}:id>`)) {
const subjM = query.match(new RegExp(`GRAPH <[^>]+>\\s*\\{\\s*<([^>]+)>\\s+a\\s+<${SHIM}:Account>`)); const subjM = query.match(new RegExp(`<([^>]+)>\\s+a\\s+<${SHIM}:Account>`));
const onlySubject = subjM ? subjM[1]! : null; const onlySubject = subjM ? subjM[1]! : null;
const bySubject = new Map<string, Record<string, string>>(); const bySubject = new Map<string, Record<string, string>>();
for (const q of quads) { for (const q of quads) {
@@ -188,8 +195,6 @@ function inject() {
configureStoreRegistry({ configureStoreRegistry({
getSession: async () => SESSION, getSession: async () => SESSION,
normalizeId: (u) => u.trim().replace(/^@+/, "").toLowerCase(), normalizeId: (u) => u.trim().replace(/^@+/, "").toLowerCase(),
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
provisionRetry: { attempts: 1 },
}); });
resetRegistryCache(); resetRegistryCache();
setCurrentUser(null); setCurrentUser(null);
@@ -203,8 +208,8 @@ beforeEach(() => {
test("submitToIndex creates the @index special account on first sight (3 docs)", async () => { test("submitToIndex creates the @index special account on first sight (3 docs)", async () => {
await submitToIndex({ nuri: "did:ng:o:event1", title: "Concert" }); await submitToIndex({ nuri: "did:ng:o:event1", title: "Concert" });
// ensureAccount('@index') created its 3 scope docs. // ensureAccount('@index') created its 3 scope docs + 1 doc-shim (first login).
expect(fake.doc_create).toHaveBeenCalledTimes(3); expect(fake.doc_create).toHaveBeenCalledTimes(4);
// The deposit landed in the @index public document (its inbox). // The deposit landed in the @index public document (its inbox).
const depositCall = fake.sparql_update.mock.calls.find((c) => const depositCall = fake.sparql_update.mock.calls.find((c) =>
(c[1] as string).includes(`${INBOX}:Deposit`), (c[1] as string).includes(`${INBOX}:Deposit`),
+1 -1
View File
@@ -153,7 +153,7 @@ function inject() {
const ng = makeFakeNg(); const ng = makeFakeNg();
configure({ ng: ng as any, useShape: (() => {}) as any }); configure({ ng: ng as any, useShape: (() => {}) as any });
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff. // Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
configureStoreRegistry({ getSession: async () => SESSION, provisionRetry: { attempts: 1 } }); configureStoreRegistry({ getSession: async () => SESSION });
setCurrentUser(null); setCurrentUser(null);
return ng; return ng;
} }
@@ -46,7 +46,7 @@ function inject() {
}; };
configure({ ng: ng as any, useShape: (() => {}) as any }); configure({ ng: ng as any, useShape: (() => {}) as any });
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff. // Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (id) => id.trim(), provisionRetry: { attempts: 1 } }); configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (id) => id.trim() });
resetRegistryCache(); resetRegistryCache();
resetCaps(); resetCaps();
setCurrentUser(null); setCurrentUser(null);
-2
View File
@@ -86,7 +86,6 @@ function inject(ng: ReturnType<typeof makeFakeNgWithSubscribe>) {
configureStoreRegistry({ configureStoreRegistry({
getSession: async () => SESSION, getSession: async () => SESSION,
normalizeId: (u: string) => u, normalizeId: (u: string) => u,
provisionRetry: { attempts: 1 },
}); });
} }
@@ -151,7 +150,6 @@ describe("ensureRepoOpen", () => {
configureStoreRegistry({ configureStoreRegistry({
getSession: async () => SESSION, getSession: async () => SESSION,
normalizeId: (u: string) => u, normalizeId: (u: string) => u,
provisionRetry: { attempts: 1 },
}); });
// Must not throw; nuri is added to opened Set (guard skips subscribe) // Must not throw; nuri is added to opened Set (guard skips subscribe)
-4
View File
@@ -35,8 +35,6 @@ function inject(triplesByDoc: Record<string, Array<[string, string]>>) {
configureStoreRegistry({ configureStoreRegistry({
getSession: async () => ({ sessionId: "sid-rm", privateStoreId: "priv" }), getSession: async () => ({ sessionId: "sid-rm", privateStoreId: "priv" }),
normalizeId: (u: string) => u, normalizeId: (u: string) => u,
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
provisionRetry: { attempts: 1 },
}); });
return ng; return ng;
} }
@@ -99,8 +97,6 @@ test("a doc that fails to read is skipped, not aborting the batch", async () =>
configureStoreRegistry({ configureStoreRegistry({
getSession: async () => ({ sessionId: "sid-rm", privateStoreId: "priv" }), getSession: async () => ({ sessionId: "sid-rm", privateStoreId: "priv" }),
normalizeId: (u: string) => u, normalizeId: (u: string) => u,
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
provisionRetry: { attempts: 1 },
}); });
const subjects = await readUnion(["did:ng:o:ok", "did:ng:o:bad"]); const subjects = await readUnion(["did:ng:o:ok", "did:ng:o:bad"]);
+49 -16
View File
@@ -112,12 +112,18 @@ function makeFakeNg() {
const sparql_query = mock(async (...a: unknown[]) => { const sparql_query = mock(async (...a: unknown[]) => {
const query = a[1] as string; const query = a[1] as string;
const anchor = a[3] as string | undefined; const anchor = a[3] as string | undefined;
// Pointer SELECT: `<shim:root> <shim:shimDoc> ?shimDoc` in the store-root graph.
if (query.includes("<urn:ng-eventually:shim:shimDoc>")) {
const bindings = quads
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:shimDoc")
.map((q) => ({ shimDoc: { value: q.o } }));
return { results: { bindings } };
}
if (query.includes("<urn:ng-eventually:shim:id>")) { if (query.includes("<urn:ng-eventually:shim:id>")) {
// Account SELECT, scoped to the anchor graph. Two shapes: the full scan // Account SELECT, anchored to the doc-shim's default graph (records live in the
// (`?acc a <Account>`) and the TARGETED bounded resolve (`<subj> a // doc-shim now, no GRAPH wrapper). Two shapes: the full scan (`?acc a <Account>`)
// <Account>`) which binds one subject — honour that subject so the bounded // and the TARGETED bounded resolve (`<subj> a <Account>`) — honour the subject.
// query returns exactly that account (or nothing). const subjM = query.match(/<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/);
const subjM = query.match(/GRAPH <[^>]+>\s*\{\s*<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/);
const onlySubject = subjM ? subjM[1]! : null; const onlySubject = subjM ? subjM[1]! : null;
const bySubject = new Map<string, Record<string, string>>(); const bySubject = new Map<string, Record<string, string>>();
for (const q of quads) { for (const q of quads) {
@@ -158,8 +164,6 @@ function inject() {
configureStoreRegistry({ configureStoreRegistry({
getSession: async () => SESSION, getSession: async () => SESSION,
normalizeId: (u) => u.trim().replace(/^@+/, "").toLowerCase(), normalizeId: (u) => u.trim().replace(/^@+/, "").toLowerCase(),
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
provisionRetry: { attempts: 1 },
}); });
resetRegistryCache(); resetRegistryCache();
return ng; return ng;
@@ -170,22 +174,53 @@ beforeEach(() => {
fake = inject(); fake = inject();
}); });
test("ensureAccount creates 3 scope docs and persists them to the shim", async () => { test("ensureAccount creates 3 scope docs and persists them to the doc-shim", async () => {
const rec = await ensureAccount("Alice"); const rec = await ensureAccount("Alice");
expect(rec.id).toBe("Alice"); expect(rec.id).toBe("Alice");
expect(fake.doc_create).toHaveBeenCalledTimes(3); // 4 doc_create: 1 doc-shim (first login, resolveShimDoc) + 3 scope docs.
expect(fake.doc_create).toHaveBeenCalledTimes(4);
expect(rec.docPublic).toMatch(/^did:ng:o:doc/); expect(rec.docPublic).toMatch(/^did:ng:o:doc/);
expect(rec.docProtected).not.toBe(rec.docPublic); expect(rec.docProtected).not.toBe(rec.docPublic);
expect(rec.docPrivate).not.toBe(rec.docProtected); expect(rec.docPrivate).not.toBe(rec.docProtected);
// Persisted into the shim anchor graph (did:ng:PRIV). // The pointer (store-root -> doc-shim) was written into the store-root graph.
expect(fake.sparql_update.mock.calls[0]![2]).toBe("did:ng:PRIV"); const pointerWrite = fake.sparql_update.mock.calls.find(
(c) => (c[1] as string).includes("<urn:ng-eventually:shim:shimDoc>"),
);
expect(pointerWrite?.[2]).toBe("did:ng:PRIV");
// The account record was persisted into the doc-shim (a did:ng:o: repo), not the root.
const recordWrite = fake.sparql_update.mock.calls.find(
(c) => (c[1] as string).includes("<urn:ng-eventually:shim:docPublic>"),
);
expect(recordWrite?.[2]).toMatch(/^did:ng:o:doc/);
}); });
test("ensureAccount is idempotent (case/@-insensitive key), no extra docs", async () => { test("ensureAccount is idempotent (case/@-insensitive key), no extra docs", async () => {
const a = await ensureAccount("Alice"); const a = await ensureAccount("Alice");
const b = await ensureAccount("@alice"); const b = await ensureAccount("@alice");
expect(b).toEqual(a); expect(b).toEqual(a);
expect(fake.doc_create).toHaveBeenCalledTimes(3); // not 6 expect(fake.doc_create).toHaveBeenCalledTimes(4); // 1 doc-shim + 3 scope docs, not 7
});
test("ensureAccount de-dupes CONCURRENT provisions (anti-fork): one account, 3 docs", async () => {
// The reconnection FORK: several callers (watchShape public+protected, the
// container subs, the owned-events effect) hit ensureAccount(SAME id) BEFORE
// the shim has synced, so each reads 0 rows and independently provisions a new
// set of scope docs — N forks, N×3 docs, duplicate docPublic/docProtected in the
// shim → a fresh reader picks a different canonical doc than the writer wrote to.
// The in-flight de-dup collapses N concurrent provisions into ONE.
const results = await Promise.all([
ensureAccount("Bob"),
ensureAccount("Bob"),
ensureAccount("@bob"),
ensureAccount("BOB"),
ensureAccount("bob"),
]);
// ONE set of 3 scope docs + 1 doc-shim (resolveShimDoc de-dupes concurrent pointer
// resolution too) — 4 total, not 5×3.
expect(fake.doc_create).toHaveBeenCalledTimes(4);
// Every caller got the SAME record (same docs), so writer/reader can never
// disagree on the canonical scope doc.
for (const r of results) expect(r).toEqual(results[0]!);
}); });
test("loadShim round-trips a persisted account across a cache reset", async () => { test("loadShim round-trips a persisted account across a cache reset", async () => {
@@ -215,8 +250,6 @@ test("resolveScopeGraph maps scopes to native store NURIs (no store-id leaks to
protectedStoreId: "PROT", protectedStoreId: "PROT",
publicStoreId: "PUB", publicStoreId: "PUB",
}), }),
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
provisionRetry: { attempts: 1 },
}); });
resetRegistryCache(); resetRegistryCache();
expect(await resolveScopeGraph("private")).toBe("did:ng:PRIV"); expect(await resolveScopeGraph("private")).toBe("did:ng:PRIV");
@@ -363,10 +396,10 @@ test("normalizeId defaults to trim when not provided", async () => {
const ng = makeFakeNg(); const ng = makeFakeNg();
configure({ ng: ng as any, useShape: (() => {}) as any }); configure({ ng: ng as any, useShape: (() => {}) as any });
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff. // Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
configureStoreRegistry({ getSession: async () => SESSION, provisionRetry: { attempts: 1 } }); configureStoreRegistry({ getSession: async () => SESSION });
resetRegistryCache(); resetRegistryCache();
const a = await ensureAccount(" Ivy "); const a = await ensureAccount(" Ivy ");
const b = await ensureAccount("Ivy"); // trimmed key matches const b = await ensureAccount("Ivy"); // trimmed key matches
expect(b).toEqual(a); expect(b).toEqual(a);
expect(ng.doc_create).toHaveBeenCalledTimes(3); expect(ng.doc_create).toHaveBeenCalledTimes(4); // 1 doc-shim + 3 scope docs
}); });
+1 -1
View File
@@ -50,7 +50,7 @@ function inject(failFor?: Set<string>) {
const ng = makeFakeNg(failFor); const ng = makeFakeNg(failFor);
configure({ ng: ng as any, useShape: (() => {}) as any }); configure({ ng: ng as any, useShape: (() => {}) as any });
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff. // Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
configureStoreRegistry({ getSession: async () => SESSION, provisionRetry: { attempts: 1 } }); configureStoreRegistry({ getSession: async () => SESSION });
return ng; return ng;
} }
+372
View File
@@ -0,0 +1,372 @@
/**
* watch-shape.test.ts — behavioural tests for `watchShape` (src/watch-shape.ts),
* against a STATEFUL fake `ng` with a CONTROLLABLE `doc_subscribe`.
*
* The fake emulates just enough of the broker:
* - `doc_create` mints monotonic doc NURIs.
* - `sparql_update` parses the shim account writes + the per-entity index
* `contains` append + arbitrary anchored triple writes into an in-memory quad
* store (same tolerant parser shape as store-registry.test / read-model.test).
* - `sparql_query` answers the shim account SELECT, the scope-index `contains`
* SELECT, and the anchored per-doc `?s ?p ?o` read (readUnion) — each scoped to
* the anchor graph.
* - `doc_subscribe` models the platform push order TabInfo→State: on subscribe it
* records the callback and fires a `TabInfo` immediately, but the sync BARRIER
* `State` is fired only when the TEST releases it (`fireState`) — so we can
* assert isPending BEFORE the barrier and isSuccess AFTER. A later write to a
* subscribed doc fires a `Patch` push (reactivity).
*
* These prove the four distinctions the surface exists for:
* (a) isPending at first, isSuccess after the first State (barrier);
* (b) isSuccess + data:[] on a synced-but-EMPTY scope (the key distinction);
* (c) a write then push → data updates (reactivity, no polling);
* (d) timed-out → isSuccess (best-effort), NOT isError.
*/
import { describe, it, expect, mock, beforeEach, afterEach, afterAll } from "bun:test";
import { watchShape } from "../src/watch-shape";
import {
configure,
configureStoreRegistry,
resetStoreRegistry,
resetConfig,
setCurrentUser,
} from "../src/polyfill";
import { resetRegistryCache, createEntityDoc } from "../src/store-registry";
import { resetOpenedRepos, setOpenTimeoutForTests, getSyncState } from "../src/open-repo";
const TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
const FP = "http://festipod.org/";
const SESSION = { sessionId: "sid-ws", privateStoreId: "PRIV-WS" };
interface Quad { g: string; s: string; p: string; o: string }
/** Reverse of escapeLiteral: single left-to-right pass over `\x`. */
function unescapeLiteral(s: string): string {
let out = "";
for (let i = 0; i < s.length; i++) {
if (s[i] === "\\" && i + 1 < s.length) {
const next = s[++i];
out += next === "n" ? "\n" : next === "r" ? "\r" : next === "t" ? "\t" : next!;
} else out += s[i];
}
return out;
}
interface SubRec { nuri: string; cb: (r: unknown) => void }
/**
* The stateful fake with a controllable doc_subscribe. `holdState: true` means a
* fresh subscription does NOT auto-fire its `State` — the test fires it via
* `fireState(nuri)`. `holdState: false` (default) auto-fires `State` on subscribe
* (synced immediately), which is the convenient mode for the reactivity/empty cases.
*/
function makeFake(opts?: { holdState?: boolean }) {
const quads: Quad[] = [];
let docCounter = 0;
const subs: SubRec[] = [];
const hold = opts?.holdState ?? false;
// Nuris whose barrier `State` has been released (auto-fire on future subscribe).
const released = new Set<string>();
// The shim ANCHOR (private-store-root) is ALWAYS loaded/synced on the real broker
// (the store repo is bootstrapped at connect), so its barrier `State` is always
// available. `resolveAccount`/`ensureAccount` now open it (the cold-start heal)
// before touching the shim — pre-release it here so `holdState` (which gates the
// per-ENTITY docs the tests control) never blocks the anchor open. This mirrors the
// real invariant the production heal relies on.
released.add(`did:ng:${SESSION.privateStoreId}`);
let releaseEverything = false;
const doc_create = mock(async () => `did:ng:o:doc${++docCounter}`);
const sparql_update = mock(async (...a: unknown[]) => {
const query = a[1] as string;
const anchor = a[2] as string | undefined;
const gm = query.match(/GRAPH <([^>]+)>\s*\{([\s\S]*)\}/);
let g: string;
let body: string;
if (gm) {
g = gm[1]!;
body = gm[2]!;
} else {
if (!anchor) return undefined;
g = anchor;
body = query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, "");
}
const sm = body.match(/<([^>]+)>/);
if (!sm) return undefined;
const s = sm[1]!;
const pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g;
let m: RegExpExecArray | null;
const after = body.slice(body.indexOf(sm[0]) + sm[0].length);
while ((m = pairRe.exec(after)) !== null) {
const p = m[1] ?? "urn:ng-eventually:shim:Account";
const o = m[2] !== undefined ? unescapeLiteral(m[2]) : (m[3] ?? "");
quads.push({ g, s, p, o });
// The doc-shim (named by the write-once pointer triple) is INFRASTRUCTURE, like
// the store-root: `doc_create` bootstrapped it into the session, so its barrier
// `State` is immediately available. Pre-release it so `holdState` (which gates the
// per-ENTITY docs the tests control) never blocks the doc-shim open. The pointer is
// published BEFORE the doc-shim barrier open (resolveShimDoc first-login order).
if (p === "urn:ng-eventually:shim:shimDoc") {
released.add(o);
for (const sub of subs) if (sub.nuri === o) sub.cb({ V0: { State: {} } });
}
}
// A write to a subscribed doc fires a Patch push (reactivity signal).
for (const sub of subs) {
if (sub.nuri === g) sub.cb({ V0: { Patch: {} } });
}
return undefined;
});
const sparql_query = mock(async (...a: unknown[]) => {
const query = a[1] as string;
const anchor = a[3] as string | undefined;
// Pointer SELECT (store-root -> doc-shim).
if (query.includes("<urn:ng-eventually:shim:shimDoc>")) {
const bindings = quads
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:shimDoc")
.map((q) => ({ shimDoc: { value: q.o } }));
return { results: { bindings } };
}
if (query.includes("<urn:ng-eventually:shim:id>")) {
const subjM = query.match(
/<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/,
);
const onlySubject = subjM ? subjM[1]! : null;
const bySubject = new Map<string, Record<string, string>>();
for (const q of quads) {
if (q.g !== anchor) continue;
if (onlySubject !== null && q.s !== onlySubject) continue;
const rec = bySubject.get(q.s) ?? {};
if (q.p === "urn:ng-eventually:shim:id") rec.id = q.o;
if (q.p === "urn:ng-eventually:shim:docPublic") rec.docPublic = q.o;
if (q.p === "urn:ng-eventually:shim:docProtected") rec.docProtected = q.o;
if (q.p === "urn:ng-eventually:shim:docPrivate") rec.docPrivate = q.o;
bySubject.set(q.s, rec);
}
const bindings = [...bySubject.values()]
.filter((r) => r.id)
.map((r) => ({
id: { value: r.id! },
docPublic: { value: r.docPublic ?? "" },
docProtected: { value: r.docProtected ?? "" },
docPrivate: { value: r.docPrivate ?? "" },
}));
return { results: { bindings } };
}
if (query.includes("<urn:ng-eventually:shim:contains>")) {
const bindings = quads
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:contains")
.map((q) => ({ e: { value: q.o } }));
return { results: { bindings } };
}
// Anchored per-doc read (readUnion `SELECT ?s ?p ?o`).
const bindings = quads
.filter((q) => q.g === anchor)
.map((q) => ({ s: { value: q.s }, p: { value: q.p }, o: { value: q.o } }));
return { results: { bindings } };
});
const doc_subscribe = mock(
async (nuri: string, _sid: string, cb: (r: unknown) => void) => {
subs.push({ nuri, cb });
// Platform pushes TabInfo FIRST (never the barrier).
setTimeout(() => cb({ V0: { TabInfo: {} } }), 0);
// Fire the barrier State if this fake auto-syncs, or if this nuri was already
// released (so a doc subscribed AFTER a release still crosses the barrier).
if (!hold || releaseEverything || released.has(nuri)) {
setTimeout(() => cb({ V0: { State: {} } }), 0);
}
return () => {};
},
);
/** Release the barrier for `nuri` (fire State now + auto-fire for future subs). */
function fireState(nuri: string): void {
released.add(nuri);
for (const sub of subs) if (sub.nuri === nuri) sub.cb({ V0: { State: {} } });
}
/** Release the barrier for EVERY doc, present and future. */
function releaseAll(): void {
releaseEverything = true;
for (const sub of subs) sub.cb({ V0: { State: {} } });
}
return {
doc_create,
sparql_update,
sparql_query,
doc_subscribe,
_quads: quads,
fireState,
releaseAll,
subs,
};
}
function inject(ng: ReturnType<typeof makeFake>) {
configure({ ng: ng as any, useShape: (() => {}) as any });
configureStoreRegistry({
getSession: async () => SESSION,
normalizeId: (u: string) => u.trim().replace(/^@+/, "").toLowerCase(),
});
resetRegistryCache();
resetOpenedRepos();
}
// Insert a triple straight into a doc's graph in the fake store (no push).
function seed(ng: ReturnType<typeof makeFake>, doc: string, p: string, o: string): void {
ng._quads.push({ g: doc, s: doc, p, o });
}
const tick = () => new Promise((r) => setTimeout(r, 5));
// A minimal SHEX ShapeType pinning rdf:type to `${FP}Event`.
const EventShape = {
shape: `${FP}EventShape`,
schema: {
[`${FP}EventShape`]: {
iri: `${FP}EventShape`,
predicates: [{ iri: TYPE, dataTypes: [{ literals: [`${FP}Event`], valType: "iri" }] }],
},
},
};
afterEach(() => {
setCurrentUser(null);
});
afterAll(() => {
resetConfig();
resetStoreRegistry();
resetRegistryCache();
resetOpenedRepos();
});
describe("watchShape", () => {
it("(a) isPending at first, then isSuccess after the first State (barrier)", async () => {
const ng = makeFake({ holdState: true });
inject(ng);
setCurrentUser("alice");
// One protected entity doc for alice, carrying an Event triple.
const doc = await createEntityDoc("alice", "protected");
seed(ng, doc, TYPE, `${FP}Event`);
seed(ng, doc, `${FP}title`, "Alpha");
const obs = watchShape(EventShape, "protected");
let notes = 0;
const unsub = obs.subscribe(() => {
notes += 1;
});
// Before the barrier: pending, no data.
await tick();
expect(obs.getSnapshot().isPending).toBe(true);
expect(obs.getSnapshot().isSuccess).toBe(false);
expect(obs.getSnapshot().data).toEqual([]);
// Release the barrier for every opened doc (present + future) → synced.
ng.releaseAll();
await tick();
await tick();
await tick();
const snap = obs.getSnapshot();
expect(snap.isPending).toBe(false);
expect(snap.isSuccess).toBe(true);
expect(snap.isError).toBe(false);
expect(snap.data.length).toBe(1);
expect(snap.data[0]!.props[`${FP}title`]).toEqual(["Alpha"]);
expect(notes).toBeGreaterThan(0);
unsub();
});
it("(b) isSuccess + data:[] on a synced-but-EMPTY scope (the key distinction)", async () => {
const ng = makeFake(); // auto-fires State → synced immediately
inject(ng);
setCurrentUser("bob");
// bob has NO entity docs in this scope — the scope is genuinely empty.
const obs = watchShape(EventShape, "protected");
const unsub = obs.subscribe(() => {});
await tick();
await tick();
const snap = obs.getSnapshot();
expect(snap.isPending).toBe(false);
expect(snap.isSuccess).toBe(true); // synced, NOT stuck pending
expect(snap.isError).toBe(false);
expect(snap.data).toEqual([]); // empty — distinguishable from "still syncing"
unsub();
});
it("(c) a write then push updates data (reactivity, no polling)", async () => {
const ng = makeFake(); // synced immediately
inject(ng);
setCurrentUser("carol");
const doc = await createEntityDoc("carol", "protected");
seed(ng, doc, TYPE, `${FP}Event`);
seed(ng, doc, `${FP}title`, "One");
const obs = watchShape(EventShape, "protected");
const unsub = obs.subscribe(() => {});
await tick();
await tick();
expect(obs.getSnapshot().data.length).toBe(1);
// Write a SECOND event doc + fire the push via a write to the ALREADY-subscribed
// doc. Because a new doc must appear in the set, write into the scope-INDEX
// (createEntityDoc appends to it, and the index is subscribed → re-resolve).
const doc2 = await createEntityDoc("carol", "protected");
seed(ng, doc2, TYPE, `${FP}Event`);
seed(ng, doc2, `${FP}title`, "Two");
// createEntityDoc's index append fired a Patch on the index doc → re-resolve.
await tick();
await tick();
const titles = obs
.getSnapshot()
.data.flatMap((s) => s.props[`${FP}title`] ?? [])
.sort();
expect(titles).toEqual(["One", "Two"]);
// No setInterval anywhere — reactivity was push-driven.
unsub();
});
it("(d) timed-out → isSuccess (best-effort), NOT isError", async () => {
// A doc whose subscription NEVER pushes a `State`: open-repo's bounded fallback
// fires and marks the nuri "timed-out" (NOT "synced"). We shrink the fallback to
// a few ms so this is fast, and assert the barrier is genuinely reached via
// timed-out (getSyncState === "timed-out") and that the snapshot maps that to
// isSuccess, never isError.
const ng = makeFake({ holdState: true }); // State is never released
inject(ng);
setOpenTimeoutForTests(20); // fallback fires quickly instead of after 8s
setCurrentUser("dave");
const doc = await createEntityDoc("dave", "protected");
seed(ng, doc, TYPE, `${FP}Event`);
seed(ng, doc, `${FP}title`, "Timed");
const obs = watchShape(EventShape, "protected");
const unsub = obs.subscribe(() => {});
await tick();
// Before the fallback fires: still pending (subscribed, no State).
expect(obs.getSnapshot().isPending).toBe(true);
// Let the bounded fallback elapse → open-repo marks each opened doc timed-out.
await new Promise((r) => setTimeout(r, 60));
await tick();
await tick();
// The entity doc's barrier resolved via timed-out (never a State).
expect(getSyncState(doc)).toBe("timed-out");
const snap = obs.getSnapshot();
expect(snap.isError).toBe(false);
expect(snap.isSuccess).toBe(true); // timed-out is best-effort success
expect(snap.isPending).toBe(false);
// The data still read (best-effort): the doc's triples resolved.
expect(snap.data.length).toBe(1);
unsub();
});
});
+939
View File
@@ -0,0 +1,939 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
devDependencies:
'@types/bun':
specifier: latest
version: 1.3.14
playwright:
specifier: ^1.61.1
version: 1.61.1
typescript:
specifier: ^5.6.0
version: 5.9.3
packages/client:
dependencies:
'@ng-org/orm':
specifier: '*'
version: 0.1.2-alpha.19
devDependencies:
'@ng-org/alien-deepsignals':
specifier: 0.1.2-alpha.11
version: 0.1.2-alpha.11
'@ng-org/shex-orm':
specifier: 0.1.2-alpha.8
version: 0.1.2-alpha.8(typescript@5.9.3)
'@ng-org/web':
specifier: 0.1.2-alpha.13
version: 0.1.2-alpha.13
packages:
'@ldo/traverser-shexj@1.0.0-alpha.28':
resolution: {integrity: sha512-N06+LOWhv6//unPRLbFMd56MqPf5lO2ihZgle9hNLmxt6QJmNrZM3oXzHCL3TfDu4OT1/NUZp3kj2HmztQIZkg==}
'@ldo/type-traverser@1.0.0-alpha.28':
resolution: {integrity: sha512-pGMIVxLzoLjYVhADuVhg6r5ZDNleXZ9DcyIvLXo1/ADEocLnysg/Xjk9D/7l/Rw3WtDJrTFOOtBv8OnH+VPgKA==}
'@ng-org/alien-deepsignals@0.1.2-alpha.11':
resolution: {integrity: sha512-nPgqOrheAda/pW5FHgSb45SrSZWuyMyEVqO683ijEsVPpD105bngfh92PPfcRoRnFzGSoKXa3CfuqUHi2+qVIQ==}
peerDependencies:
react: ^19.0.0 || ^18.0.0
svelte: ^5.0.0 || ^4.0.0
vue: ^3.0.0
peerDependenciesMeta:
react:
optional: true
svelte:
optional: true
vue:
optional: true
'@ng-org/alien-deepsignals@0.1.2-alpha.12':
resolution: {integrity: sha512-STIfMLJvwNDnAIdi/wFToLtnZDjbf0PaeYfshfMal2HbYirSPFEBue393nlYKv9rLlCrqjYOBNeSBK9FO7/Qng==}
peerDependencies:
react: ^19.0.0 || ^18.0.0
svelte: ^5.0.0 || ^4.0.0
vue: ^3.0.0
peerDependenciesMeta:
react:
optional: true
svelte:
optional: true
vue:
optional: true
'@ng-org/orm@0.1.2-alpha.19':
resolution: {integrity: sha512-wPDQ0OEitpoEKrvj4IJKIRxeTHr3X5+rApvZJOh54CNV1Q1b5MaT2XvyJ9YDvlgyUX2CxNkcmumV4k4ASmBXNA==}
peerDependencies:
react: ^19.0.0 || ^18.0.0
svelte: ^5.0.0 || ^4.0.0
vue: ^3.0.0
peerDependenciesMeta:
react:
optional: true
svelte:
optional: true
vue:
optional: true
'@ng-org/shex-orm@0.1.2-alpha.8':
resolution: {integrity: sha512-jogxiLfUtI3WkuF5O3PPwRBRWylpapk/JlvElGUqZnEavXXlEMmEGbK3VwqMUsbp1pweNA0sEKIFLIk3Lzt2Gg==}
hasBin: true
peerDependencies:
typescript: ^4.9.0 || ^5.0.0 || ^6.0.0
'@ng-org/web@0.1.2-alpha.13':
resolution: {integrity: sha512-/xO0c+3NTphnws5Do2LDqgZWmAf+aNnYdChJKdU0dnp1U1iVSgi/y3yb8AYryf0v9sooj0aYJxt08B6DpirFMQ==}
'@rdfjs/types@1.1.2':
resolution: {integrity: sha512-wqpOJK1QCbmsGNtyzYnojPU8gRDPid2JO0Q0kMtb4j65xhCK880cnKAfEOwC+dX85VJcCByQx5zOwyyfCjDJsg==}
'@shexjs/parser@1.0.0-alpha.28':
resolution: {integrity: sha512-eeVeHq/2JG9X+3h7y+7EmuBSWWl2EMj/EQBLk5CTRx4W4hWDdjWczsY8RWwKjkIzLwUS1+G0aiAI1u5LHCZ2Rw==}
engines: {node: '>=0.10.0'}
'@shexjs/term@1.0.0-alpha.27':
resolution: {integrity: sha512-+D7P7pglRPTZC2RkwaQuq+cgBZImx+61JZtcN77uEJVqcGaIscQK5hScsKhAPIo16/I+4jhIUCEFojXqw6otpg==}
engines: {node: '>=0.10.0'}
'@shexjs/util@1.0.0-alpha.28':
resolution: {integrity: sha512-L8pBokTU/5eNRJPkC8R9SIgPw6/JDh/bHKdV5TZzf8/FkOMNJwKIy6UDHXM1I8FJ+c8u2gOOHp2MA+7b+md+0A==}
engines: {node: '>=0.10.0'}
'@shexjs/visitor@1.0.0-alpha.27':
resolution: {integrity: sha512-9s67A+f0ZZNw/SNxqoi1483CqUca8dbnHM6WDWsRH4+eXlQpQqwOZDxA8uKEaWeX4VcDrDwzWpr0WvK6EyDWIQ==}
engines: {node: '>=0.10.0'}
'@ts-jison/common@0.4.1-alpha.1':
resolution: {integrity: sha512-SDbHzq+UMD+V3ciKVBHwCEgVqSeyQPTCjOsd/ZNTGySUVg4x3EauR9ZcEfdVFAsYRR38XWgDI+spq5LDY46KvQ==}
'@ts-jison/lexer@0.4.1-alpha.1':
resolution: {integrity: sha512-5C1Wr+wixAzn2MOFtgy7KbT6N6j9mhmbjAtyvOqZKsikKtNOQj22MM5HxT+ooRexG2NbtxnDSXYdhHR1Lg58ow==}
'@ts-jison/parser@0.4.1-alpha.1':
resolution: {integrity: sha512-xNj+qOez/7dju44LlYiTlCjxMzW5oek9EckUAElfln/GBK9vgMSk0swWcnacMr0TYbGjUQuXvL2wEgmDf5WajQ==}
'@types/bun@1.3.14':
resolution: {integrity: sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw==}
'@types/concat-stream@1.6.1':
resolution: {integrity: sha512-eHE4cQPoj6ngxBZMvVf6Hw7Mh4jMW4U9lpGmS5GBPB9RYxlFg+CHaVN7ErNY4W9XfLIEn20b4VDYaIrbq0q4uA==}
'@types/form-data@0.0.33':
resolution: {integrity: sha512-8BSvG1kGm83cyJITQMZSulnl6QV8jqAGreJsc5tPu1Jq0vTSOiY/k24Wx82JRpWwZSqrala6sd5rWi6aNXvqcw==}
'@types/http-link-header@1.0.7':
resolution: {integrity: sha512-snm5oLckop0K3cTDAiBnZDy6ncx9DJ3mCRDvs42C884MbVYPP74Tiq2hFsSDRTyjK6RyDYDIulPiW23ge+g5Lw==}
'@types/jsonld@1.5.15':
resolution: {integrity: sha512-PlAFPZjL+AuGYmwlqwKEL0IMP8M8RexH0NIPGfCVWSQ041H2rR/8OlyZSD7KsCVoN8vCfWdtWDBxX8yBVP+xow==}
'@types/node@10.17.60':
resolution: {integrity: sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==}
'@types/node@18.19.130':
resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==}
'@types/node@26.1.1':
resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==}
'@types/node@8.10.66':
resolution: {integrity: sha512-tktOkFUA4kXx2hhhrB8bIFb5TbwzS4uOhKEmwiD+NoiL0qtP2OQ9mFldbgD4dV1djrlBYP6eBuQZiWjuHUpqFw==}
'@types/qs@6.15.1':
resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==}
'@types/shexj@2.1.7':
resolution: {integrity: sha512-pu/0vIZxFTMPVjTlo5MJKFkBL/EbAuFhtCXpmBB7ZdUiyNpc6pt8GxfyRPqdf6q2SsWu71a/vbhvGK2IZN2Eug==}
alien-signals@2.0.8:
resolution: {integrity: sha512-844G1VLkk0Pe2SJjY0J8vp8ADI73IM4KliNu2OGlYzWpO28NexEUvjHTcFjFX3VXoiUtwTbHxLNI9ImkcoBqzA==}
asap@2.0.6:
resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==}
async-proxy@0.4.1:
resolution: {integrity: sha512-4e+zNtoGL4+cnqib8v169CnKcRfAsAubp2EsjBhAA5jyW7jjI3t36rVvuqLwmhtliwf8JvSnxinE4ecQN+DK4w==}
async@3.2.6:
resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
brace-expansion@2.1.2:
resolution: {integrity: sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==}
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
bun-types@1.3.14:
resolution: {integrity: sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ==}
call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
call-bound@1.0.4:
resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
engines: {node: '>= 0.4'}
caseless@0.12.0:
resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==}
colors-cli@1.0.33:
resolution: {integrity: sha512-PWGsmoJFdOB0t+BeHgmtuoRZUQucOLl5ii81NBzOOGVxlgE04muFNHlR5j8i8MKbOPELBl3243AI6lGBTj5ICQ==}
hasBin: true
combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
commander@14.0.3:
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
engines: {node: '>=20'}
concat-stream@1.6.2:
resolution: {integrity: sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==}
engines: {'0': node >= 0.8}
core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
cross-fetch@3.2.0:
resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==}
delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
dts-dom@3.6.0:
resolution: {integrity: sha512-on5jxTgt+A6r0Zyyz6ZRHXaAO7J1VPnOd6+AmvI1vH440AlAZZNc5rUHzgPuTjGlrVr1rOWQYNl7ZJK6rDohbw==}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
ejs@3.1.10:
resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==}
engines: {node: '>=0.10.0'}
hasBin: true
es-define-property@1.0.1:
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
engines: {node: '>= 0.4'}
es-errors@1.3.0:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'}
es-object-atoms@1.1.2:
resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
engines: {node: '>= 0.4'}
es-set-tostringtag@2.1.0:
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
engines: {node: '>= 0.4'}
filelist@1.0.6:
resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==}
form-data@2.5.6:
resolution: {integrity: sha512-Ogz/E85h9tlfJzpI6TuFpGcHZFhLrb9Gw8wq9v40CxSCPnv7ahKr6Xgtkn0KYCDQJ8DNn5VoMO8EXr9V5PadyA==}
engines: {node: '>= 0.12'}
fs-extra@10.1.0:
resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==}
engines: {node: '>=12'}
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'}
get-port@3.2.0:
resolution: {integrity: sha512-x5UJKlgeUiNT8nyo/AcnwLnZuZNcSjSw0kogRB+Whd1fjjFq4B1hySFxSFWWSn4mIBzg3sRNUDFYc4g5gjPoLg==}
engines: {node: '>=4'}
get-proto@1.0.1:
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
engines: {node: '>= 0.4'}
gopd@1.2.0:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
has-symbols@1.1.0:
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
engines: {node: '>= 0.4'}
has-tostringtag@1.0.2:
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
engines: {node: '>= 0.4'}
hasown@2.0.4:
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
engines: {node: '>= 0.4'}
hierarchy-closure@1.2.2:
resolution: {integrity: sha512-ZqZvsA6HyMqrmm49D3llYA8x8hqdyDDEkaTXcqwyO+fGQlzxoeXws/5ze11M40s4EoTw7GFxdTKIwj5YDOicLQ==}
http-basic@8.1.3:
resolution: {integrity: sha512-/EcDMwJZh3mABI2NhGfHOGOeOZITqfkEO4p/xK+l3NpyncIHUQBoMvCSF/b5GqvKtySC2srL/GGG3+EtlqlmCw==}
engines: {node: '>=6.0.0'}
http-link-header@1.1.4:
resolution: {integrity: sha512-xT3GPW6/ZbGuw4UvwHqErSCEjNUlwbQJuZn9/q5U4WEKfp2kENVCAlousG1zLxHeaQ/ffOHUNpWamvkbBW0eNw==}
engines: {node: '>=6.0.0'}
http-response-object@3.0.2:
resolution: {integrity: sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==}
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
isarray@1.0.0:
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
jake@10.9.4:
resolution: {integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==}
engines: {node: '>=10'}
hasBin: true
jsonfile@6.2.1:
resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==}
jsonld-context-parser@2.4.0:
resolution: {integrity: sha512-ZYOfvh525SdPd9ReYY58dxB3E2RUEU4DJ6ZibO8AitcowPeBH4L5rCAitE2om5G1P+HMEgYEYEr4EZKbVN4tpA==}
hasBin: true
jsonld2graphobject@0.0.5:
resolution: {integrity: sha512-5BqfXOq96+OBjjiJNG8gQH66pYt6hW88z2SJxdvFJo4XNoVMvqAcUz+JSm/KEWS5NLRnebApEzFrYP3HUiUmYw==}
loading-cli@1.1.2:
resolution: {integrity: sha512-M1ntfXHpdGoQxfaqKBOQPwSrTr9EIoTgj664Q9UVSbSnJvAFdribo+Ij//1jvACgrGHaTvfKoD9PG3NOxGj44g==}
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
mime-db@1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
mime-types@2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
minimatch@5.1.9:
resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==}
engines: {node: '>=10'}
node-fetch@2.7.0:
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
engines: {node: 4.x || >=6.0.0}
peerDependencies:
encoding: ^0.1.0
peerDependenciesMeta:
encoding:
optional: true
object-inspect@1.13.4:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
engines: {node: '>= 0.4'}
object-path-operator@3.0.0:
resolution: {integrity: sha512-Z7dlPUeXqRU/lLfGerP24dPC66n7ehyXaTM81k71EFlsaaEjOHkf4/uq1WGicfGfiO7snYShneE1YZZUkyRiLQ==}
parse-cache-control@1.0.1:
resolution: {integrity: sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg==}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
playwright-core@1.61.1:
resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==}
engines: {node: '>=18'}
hasBin: true
playwright@1.61.1:
resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==}
engines: {node: '>=18'}
hasBin: true
prettier@3.9.5:
resolution: {integrity: sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==}
engines: {node: '>=14'}
hasBin: true
process-nextick-args@2.0.1:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
promise@8.3.0:
resolution: {integrity: sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg==}
qs@6.15.3:
resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==}
engines: {node: '>=0.6'}
rdf-data-factory@1.1.3:
resolution: {integrity: sha512-ny6CI7m2bq4lfQQmDYvcb2l1F9KtGwz9chipX4oWu2aAtVoXjb7k3d8J1EsgAsEbMXnBipB/iuRen5H2fwRWWQ==}
readable-stream@2.3.8:
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
relative-to-absolute-iri@1.0.8:
resolution: {integrity: sha512-U1TmhrhCmXKkDL9mI8gBbF5TN6TKcuv28k5+H3gMCAjoz0TyyHAICHlaGDZsTEBSu2Y3HhDKc8e6X9n33qeIqA==}
relativize-url@0.1.0:
resolution: {integrity: sha512-YXet4a9wQP96Ru9MQSfoRUzsCaeboLPXj+rVG1ulH4t54zqFHiNmW6FPl7V2dTxk9uHlW3yb9+1jWO44AdWisw==}
safe-buffer@5.1.2:
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
side-channel-list@1.0.1:
resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==}
engines: {node: '>= 0.4'}
side-channel-map@1.0.1:
resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==}
engines: {node: '>= 0.4'}
side-channel-weakmap@1.0.2:
resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==}
engines: {node: '>= 0.4'}
side-channel@1.1.1:
resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==}
engines: {node: '>= 0.4'}
string_decoder@1.1.1:
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
sync-request@6.1.0:
resolution: {integrity: sha512-8fjNkrNlNCrVc/av+Jn+xxqfCjYaBoHqCsDz6mt030UMxJGr+GSfCV1dQt2gRtlL63+VPidwDVLr7V2OcTSdRw==}
engines: {node: '>=8.0.0'}
sync-rpc@1.3.7:
resolution: {integrity: sha512-YHciI7TUxL8EPqz/bg01sZfwuzQA0odao1wf1Ywdtw7j5vl30aQ6s+bLRTvgPPgzr94cg+WMm6Bxi/P7BJOxgw==}
then-request@6.0.2:
resolution: {integrity: sha512-3ZBiG7JvP3wbDzA9iNY5zJQcHL4jn/0BWtXIkagfz7QgOL/LqjCEOBQuJNZfu0XYnv5JhKh+cDxCPM4ILrqruA==}
engines: {node: '>=6.0.0'}
tr46@0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
typedarray@0.0.6:
resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==}
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
undici-types@5.26.5:
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
undici-types@8.3.0:
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
universalify@2.0.1:
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
engines: {node: '>= 10.0.0'}
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
uuid@8.3.2:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
snapshots:
'@ldo/traverser-shexj@1.0.0-alpha.28':
dependencies:
'@ldo/type-traverser': 1.0.0-alpha.28
'@ldo/type-traverser@1.0.0-alpha.28':
dependencies:
uuid: 8.3.2
'@ng-org/alien-deepsignals@0.1.2-alpha.11':
dependencies:
alien-signals: 2.0.8
'@ng-org/alien-deepsignals@0.1.2-alpha.12':
dependencies:
alien-signals: 2.0.8
'@ng-org/orm@0.1.2-alpha.19':
dependencies:
'@ng-org/alien-deepsignals': 0.1.2-alpha.12
'@ng-org/shex-orm@0.1.2-alpha.8(typescript@5.9.3)':
dependencies:
'@ldo/traverser-shexj': 1.0.0-alpha.28
'@ldo/type-traverser': 1.0.0-alpha.28
'@shexjs/parser': 1.0.0-alpha.28
commander: 14.0.3
dts-dom: 3.6.0
ejs: 3.1.10
fs-extra: 10.1.0
jsonld2graphobject: 0.0.5
loading-cli: 1.1.2
prettier: 3.9.5
typescript: 5.9.3
transitivePeerDependencies:
- encoding
'@ng-org/web@0.1.2-alpha.13':
dependencies:
async-proxy: 0.4.1
'@rdfjs/types@1.1.2':
dependencies:
'@types/node': 26.1.1
'@shexjs/parser@1.0.0-alpha.28':
dependencies:
'@shexjs/util': 1.0.0-alpha.28
'@ts-jison/parser': 0.4.1-alpha.1
'@shexjs/term@1.0.0-alpha.27':
dependencies:
'@types/shexj': 2.1.7
rdf-data-factory: 1.1.3
relativize-url: 0.1.0
'@shexjs/util@1.0.0-alpha.28':
dependencies:
'@shexjs/term': 1.0.0-alpha.27
'@shexjs/visitor': 1.0.0-alpha.27
'@types/shexj': 2.1.7
hierarchy-closure: 1.2.2
sync-request: 6.1.0
'@shexjs/visitor@1.0.0-alpha.27': {}
'@ts-jison/common@0.4.1-alpha.1': {}
'@ts-jison/lexer@0.4.1-alpha.1':
dependencies:
'@ts-jison/common': 0.4.1-alpha.1
'@ts-jison/parser@0.4.1-alpha.1':
dependencies:
'@ts-jison/common': 0.4.1-alpha.1
'@ts-jison/lexer': 0.4.1-alpha.1
'@types/bun@1.3.14':
dependencies:
bun-types: 1.3.14
'@types/concat-stream@1.6.1':
dependencies:
'@types/node': 8.10.66
'@types/form-data@0.0.33':
dependencies:
'@types/node': 8.10.66
'@types/http-link-header@1.0.7':
dependencies:
'@types/node': 18.19.130
'@types/jsonld@1.5.15': {}
'@types/node@10.17.60': {}
'@types/node@18.19.130':
dependencies:
undici-types: 5.26.5
'@types/node@26.1.1':
dependencies:
undici-types: 8.3.0
'@types/node@8.10.66': {}
'@types/qs@6.15.1': {}
'@types/shexj@2.1.7': {}
alien-signals@2.0.8: {}
asap@2.0.6: {}
async-proxy@0.4.1:
dependencies:
object-path-operator: 3.0.0
async@3.2.6: {}
asynckit@0.4.0: {}
balanced-match@1.0.2: {}
brace-expansion@2.1.2:
dependencies:
balanced-match: 1.0.2
buffer-from@1.1.2: {}
bun-types@1.3.14:
dependencies:
'@types/node': 26.1.1
call-bind-apply-helpers@1.0.2:
dependencies:
es-errors: 1.3.0
function-bind: 1.1.2
call-bound@1.0.4:
dependencies:
call-bind-apply-helpers: 1.0.2
get-intrinsic: 1.3.0
caseless@0.12.0: {}
colors-cli@1.0.33: {}
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
commander@14.0.3: {}
concat-stream@1.6.2:
dependencies:
buffer-from: 1.1.2
inherits: 2.0.4
readable-stream: 2.3.8
typedarray: 0.0.6
core-util-is@1.0.3: {}
cross-fetch@3.2.0:
dependencies:
node-fetch: 2.7.0
transitivePeerDependencies:
- encoding
delayed-stream@1.0.0: {}
dts-dom@3.6.0: {}
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
es-errors: 1.3.0
gopd: 1.2.0
ejs@3.1.10:
dependencies:
jake: 10.9.4
es-define-property@1.0.1: {}
es-errors@1.3.0: {}
es-object-atoms@1.1.2:
dependencies:
es-errors: 1.3.0
es-set-tostringtag@2.1.0:
dependencies:
es-errors: 1.3.0
get-intrinsic: 1.3.0
has-tostringtag: 1.0.2
hasown: 2.0.4
filelist@1.0.6:
dependencies:
minimatch: 5.1.9
form-data@2.5.6:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
hasown: 2.0.4
mime-types: 2.1.35
safe-buffer: 5.2.1
fs-extra@10.1.0:
dependencies:
graceful-fs: 4.2.11
jsonfile: 6.2.1
universalify: 2.0.1
fsevents@2.3.2:
optional: true
function-bind@1.1.2: {}
get-intrinsic@1.3.0:
dependencies:
call-bind-apply-helpers: 1.0.2
es-define-property: 1.0.1
es-errors: 1.3.0
es-object-atoms: 1.1.2
function-bind: 1.1.2
get-proto: 1.0.1
gopd: 1.2.0
has-symbols: 1.1.0
hasown: 2.0.4
math-intrinsics: 1.1.0
get-port@3.2.0: {}
get-proto@1.0.1:
dependencies:
dunder-proto: 1.0.1
es-object-atoms: 1.1.2
gopd@1.2.0: {}
graceful-fs@4.2.11: {}
has-symbols@1.1.0: {}
has-tostringtag@1.0.2:
dependencies:
has-symbols: 1.1.0
hasown@2.0.4:
dependencies:
function-bind: 1.1.2
hierarchy-closure@1.2.2: {}
http-basic@8.1.3:
dependencies:
caseless: 0.12.0
concat-stream: 1.6.2
http-response-object: 3.0.2
parse-cache-control: 1.0.1
http-link-header@1.1.4: {}
http-response-object@3.0.2:
dependencies:
'@types/node': 10.17.60
inherits@2.0.4: {}
isarray@1.0.0: {}
jake@10.9.4:
dependencies:
async: 3.2.6
filelist: 1.0.6
picocolors: 1.1.1
jsonfile@6.2.1:
dependencies:
universalify: 2.0.1
optionalDependencies:
graceful-fs: 4.2.11
jsonld-context-parser@2.4.0:
dependencies:
'@types/http-link-header': 1.0.7
'@types/node': 18.19.130
cross-fetch: 3.2.0
http-link-header: 1.1.4
relative-to-absolute-iri: 1.0.8
transitivePeerDependencies:
- encoding
jsonld2graphobject@0.0.5:
dependencies:
'@rdfjs/types': 1.1.2
'@types/jsonld': 1.5.15
jsonld-context-parser: 2.4.0
uuid: 8.3.2
transitivePeerDependencies:
- encoding
loading-cli@1.1.2:
dependencies:
colors-cli: 1.0.33
math-intrinsics@1.1.0: {}
mime-db@1.52.0: {}
mime-types@2.1.35:
dependencies:
mime-db: 1.52.0
minimatch@5.1.9:
dependencies:
brace-expansion: 2.1.2
node-fetch@2.7.0:
dependencies:
whatwg-url: 5.0.0
object-inspect@1.13.4: {}
object-path-operator@3.0.0: {}
parse-cache-control@1.0.1: {}
picocolors@1.1.1: {}
playwright-core@1.61.1: {}
playwright@1.61.1:
dependencies:
playwright-core: 1.61.1
optionalDependencies:
fsevents: 2.3.2
prettier@3.9.5: {}
process-nextick-args@2.0.1: {}
promise@8.3.0:
dependencies:
asap: 2.0.6
qs@6.15.3:
dependencies:
es-define-property: 1.0.1
side-channel: 1.1.1
rdf-data-factory@1.1.3:
dependencies:
'@rdfjs/types': 1.1.2
readable-stream@2.3.8:
dependencies:
core-util-is: 1.0.3
inherits: 2.0.4
isarray: 1.0.0
process-nextick-args: 2.0.1
safe-buffer: 5.1.2
string_decoder: 1.1.1
util-deprecate: 1.0.2
relative-to-absolute-iri@1.0.8: {}
relativize-url@0.1.0: {}
safe-buffer@5.1.2: {}
safe-buffer@5.2.1: {}
side-channel-list@1.0.1:
dependencies:
es-errors: 1.3.0
object-inspect: 1.13.4
side-channel-map@1.0.1:
dependencies:
call-bound: 1.0.4
es-errors: 1.3.0
get-intrinsic: 1.3.0
object-inspect: 1.13.4
side-channel-weakmap@1.0.2:
dependencies:
call-bound: 1.0.4
es-errors: 1.3.0
get-intrinsic: 1.3.0
object-inspect: 1.13.4
side-channel-map: 1.0.1
side-channel@1.1.1:
dependencies:
es-errors: 1.3.0
object-inspect: 1.13.4
side-channel-list: 1.0.1
side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2
string_decoder@1.1.1:
dependencies:
safe-buffer: 5.1.2
sync-request@6.1.0:
dependencies:
http-response-object: 3.0.2
sync-rpc: 1.3.7
then-request: 6.0.2
sync-rpc@1.3.7:
dependencies:
get-port: 3.2.0
then-request@6.0.2:
dependencies:
'@types/concat-stream': 1.6.1
'@types/form-data': 0.0.33
'@types/node': 8.10.66
'@types/qs': 6.15.1
caseless: 0.12.0
concat-stream: 1.6.2
form-data: 2.5.6
http-basic: 8.1.3
http-response-object: 3.0.2
promise: 8.3.0
qs: 6.15.3
tr46@0.0.3: {}
typedarray@0.0.6: {}
typescript@5.9.3: {}
undici-types@5.26.5: {}
undici-types@8.3.0: {}
universalify@2.0.1: {}
util-deprecate@1.0.2: {}
uuid@8.3.2: {}
webidl-conversions@3.0.1: {}
whatwg-url@5.0.0:
dependencies:
tr46: 0.0.3
webidl-conversions: 3.0.1
+2
View File
@@ -0,0 +1,2 @@
packages:
- "packages/*"