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>
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>
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>
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>
Le polling est un anti-pattern NextGraph (par abonnement). La résolution de compte
retentait ×8 la lecture du shim tant qu'elle rendait 0 (lag de sync) — c'est du
polling. Remplacé par la BARRIÈRE d'abonnement, déjà le mécanisme de open-repo :
- `resolveAccountReliably` : `await ensureRepoOpen(did🆖${privateStoreId})`
(subscribe + attendre le 1er State — le shim vit dans le graphe du private store),
PUIS lecture UNIQUE. Après la barrière, 0 ligne = compte réellement inexistant →
provision 1×, lignes présentes = réutilisé (garantie NO-FORK préservée). Plus de
boucle de re-lecture.
- timed-out (barrière expirée) : throw explicite, NE provisionne PAS (un provision
sur sync incomplète re-forkerait). Le « trop long » est un signal, pas un feu vert.
- fake ng sans doc_subscribe : ensureRepoOpen no-op → lecture immédiate (unit intact).
- `_forceOpenedSyncState` : helper test-only (underscore, non ré-exporté).
anti-fork.test.ts réécrit (5 tests : no-fork, neuf→1 provision, idempotence, fake
no-op, timed-out→throw) ; plus aucun test de comptage de retry.
gate : tsc propre ; bun test 117. e2e À RE-VALIDER quand le broker répond (dégradé
ce jour : crash Chromium post-connexion) — la barrière ensureRepoOpen est déjà
validée e2e (CONTRAT 3 + reconnexion) en broker sain. provisionRetry devient un
champ mort de StoreRegistryDeps (nettoyage ultérieur).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Règle posée par l'utilisateur : la seule raison d'être du polyfill est de combler
un retard d'implémentation NextGraph ou un bug. Aucune fonctionnalité additionnelle
propre (pas de feature, d'observabilité, d'API de confort qui ne soit pas « NextGraph
le fera nativement plus tard »). Corollaire : une compensation dont le gap n'est PAS
exhibé sur le broker cible est du poids mort, pas du code défensif — à retirer.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Avant : `ensureRepoOpen` résolvait son attente sur le 1er push d'abonnement (=
TabInfo, ~1-3ms) — donc AVANT la vraie barrière de sync. Correct par chance sur ce
broker (State suit TabInfo d'~1ms), faux si State tarde.
Maintenant :
- `subscribe.ts` : `docChangeType(resp)` extrait le variant (`State`/`Patch`/
`TabInfo`/…) sans cast `any` ; `subscribeDoc`/`subscribeDocs` le passent en 2e
arg NON-cassant (les appelants 0-arg — discovery, inbox — inchangés).
- `ensureRepoOpen` n'agit que sur `type === "State"` → attend la vraie barrière.
- État de sync par-doc explicite : `getSyncState(nuri): "syncing"|"synced"|
"timed-out"|"unknown"`. Le fallback 8s marque `timed-out`, JAMAIS `synced` — les
deux ne sont plus confondus (base du futur signal app + du « trop long = signal »).
- Fake ng (sans doc_subscribe) : résolution immédiate préservée (bun test intact).
gate : tsc propre ; bun test 117 ; test:e2e 39 passed (CONTRAT 3 vert,
events=["TabInfo","State"] ; reconnexion cold-read 176ms/10.8s — l'attente du 1er
State se déclenche vite, pas de gonflement par timeout). Pas de régression app (le
rouge du test reconnexion est pré-existant et broker-lenteur, vérifié sur lib vierge).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Épingle empiriquement (broker réel) le contrat implicite sur lequel open-repo
repose : après le 1er événement `State` d'un `doc_subscribe`, la PRÉSENCE d'une
donnée est GARANTIE (le triple écrit est déjà dans ce State, sans attente
supplémentaire) et l'ABSENCE est DÉFINITIVE (doc vide reste vide, +5s de grâce).
Si ce contrat casse (changement de version broker), ce test le détecte.
Constats mesurés : l'abonnement pousse `TabInfo` (~1-3ms) PUIS `State` (~2-3ms)
— le State est le 2e événement, pas le 1er ; latence 1er State ~2-3ms sur profil
frais. IMPORTANT (à corriger phase 2) : open-repo résout son attente sur le 1er
push (= TabInfo), PAS sur le State → il rend la main avant la vraie barrière ; le
commentaire « resolves on the FIRST push (the initial State) » est faux.
gate : test:e2e 39 passed ; bun test 117 ; tsc propre. src/ non touché.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Les tests du polyfill doivent PROUVER son contrat contre le VRAI broker (pas un
fake). Ajout de deux tests e2e broker réel (wallet dédié), avec reconnexion
FIDÈLE : page fraîche sur le MÊME profil persistant + nouveau login broker
(helper `faithfulReconnect`), PAS export/réimport dans un profil vide (qui
resynchronise et masque le cold-open).
- Contrat reconnexion/cold-read : session 1 crée un doc (public + protected) ;
session fraîche fidèle ; relit → données présentes, en POLLANT une borne
généreuse (l'attente de récupération après souscription est le fonctionnement
normal).
- Contrat non-fork : re-résoudre le même identifiant après reconnexion rend les
MÊMES NURIs de docs de scope, pas un second provisioning.
Temps de sync réels observés (le SIGNAL) : protected relu en ~195ms (repo déjà
ouvert) ; PUBLIC relu en ~105s (ouverture repo + attente push + union ancrée à
l'échelle de la donnée publique) ; login de reconnexion ~2.5s. Le ~105s public
est un signal de PERFORMANCE À INVESTIGUER (probable scale/bloat de l'anchorless
union scan, cf. suivi bloat) — le contrat est REMPLI mais lent côté public.
Honnêteté : le « échoue-sans-le-fix » n'a PAS pu être montré via ce chemin de
login (le bootstrap du login broker ouvre déjà les repos → firstRawNoOpen=1).
Contrat prouvé REMPLI avec les fix ; nécessité non isolable via cette voie.
gate : test:e2e 33 passed (baseline 27 vert) ; bun test 117 ; tsc propre.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Les fix récents (anti-fork, open-repo, access-log) n'avaient AUCUN test de
comportement dans le domaine de la lib. Ajout de tests DÉTERMINISTES à base de `ng`
factice modélisant la condition d'échec (pas de broker réel — le harness e2e réel
réhydrate trop vite et ne reproduit pas ces cas).
- anti-fork.test.ts : fake avec lag de sync (0 lignes les K premières lectures du
record de compte, puis les vraies). Asserte le cœur du fix : compte retrouvé au
retry → réutilisé, 0 doc_create (pas de fork) ; compte réellement neuf → 1 seul
jeu de docs provisionné, budget de retry prouvé consommé ; idempotence de session ;
cas limite « trouvé à la dernière tentative ».
- open-repo.test.ts : fake où une requête ancrée rend vide tant que doc_subscribe
n'a pas ouvert le repo. Asserte subscribe-avant-read + idempotence (Set des repos
ouverts) + no-op si le ng n'a pas doc_subscribe.
- access-log.test.ts : off par défaut (silencieux), on via config ET via env
NG_EVENTUALLY_ACCESS_LOG, préfixe = identité active (suit setCurrentUser),
row-count sur READ. Restaure console.log/env fidèlement.
bun test : 91 → 117 pass, 0 fail. tsc --noEmit propre. src/ non touché.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cause racine mesurée (broker réel, access-log) : à la première resolveAccount
d'une session, le record de compte tout juste persisté (ou d'une session
antérieure) peut lire 0 lignes à cause du LAG DE SYNC broker. ensureAccount
interprétait ce 0 comme « compte inexistant » et RE-PROVISIONNAIT un second jeu
de docs de scope (docPublic/docProtected forkés) → les lectures d'une session
tombaient sur un jeu, celles d'une autre (ou après drop de cache) sur l'autre
jeu vide → données « perdues » à la reconnexion.
Fix : `resolveAccountReliably` (store-registry.ts) — retry borné (ouverture du
repo d'ancre shim + backoff plafonné, défaut 8 tentatives / ≲8.5s) AVANT que
ensureAccount ne décide qu'un compte est neuf. Provisionne seulement si, après
le budget, la lecture rend toujours 0 (compte réellement neuf). Budget injecté
via StoreRegistryDeps.provisionRetry (polyfill.ts), ON en prod ; tests unitaires
à provisionRetry synchrone (attempts:1, fake sans lag). Idempotence de session
préservée par accountCache (hit court-circuite, déterministe).
Portée : corrige le déterminisme de provisioning. NE suffit PAS à réparer la
reconnexion (le read public à 0 same-session subsiste, cause distincte encore à
mesurer de façon déterministe — le lag broker rend les mesures non-reproductibles).
Complémentaire du commit open-repo précédent, pas redondant.
gate : tsc --noEmit propre ; bun test 91 pass ; auth @data (vide/distinctes) verts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Défaut visé : sur une session verifier fraîche (reconnexion), la lecture ancrée
tape des repos pas encore ouverts dans self.repos → 0 ligne. Nouveau module
`open-repo.ts` : `ensureReposOpen`/`ensureRepoOpen` ouvrent/souscrivent un repo
via la primitive existante `subscribeDoc` (doc_subscribe) et attendent le push
d'état initial (borné, sans polling) AVANT la lecture ancrée. Câblé en amont de
`readUnion` (read-model) et `readScopeIndex` (store-registry). Idempotent par
session (Set des NURI ouverts + Map in-flight ; ré-ouverture si la session
injectée change). No-op si le `ng` injecté n'a pas doc_subscribe (fake unitaire).
État — NON MERGÉ, incomplet :
- AIDE le PROTECTED : la participation remonte au cold-start (mesuré app, timing
un peu bruité).
- N'ADRESSE PAS l'accueil PUBLIC : `readScopeIndex` de l'index public rend 0 même
côté écrivain même-session — le fix ouvre le repo mais l'index reste vide. Le
code d'index lib est prouvé scope-symétrique, donc la cause du public est
ailleurs (broker/store ou chemin app), À MESURER SOUS BROKER (actuellement
injoignable). Nécessité du fix elle-même non prouvée en e2e lib (broker down).
gate broker-indépendant : tsc --noEmit propre ; bun test 91 pass (worker).
Inclut le scaffolding e2e de repro reconnexion (broker/run/sdk-entry).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Observability probe for the shared-wallet isolation footgun: on one physical
wallet several virtual identities coexist, and a read must never surface a doc
scoped to another identity. When it does (B reading A's doc), the leak is
invisible in the data — it looks like a normal read. This makes it VISIBLE.
Every real read/write is logged, prefixed by the ACTIVE virtual identity
(getCurrentUser → the account the op is scoped under, NOT the constant shared
physical wallet id). Reads append the row count — a strong leak signal:
[urn:festipod:user:bob] READ did:ng:o:docA (readDoc) → 3 rows
Instrumented at the LOW common point in docs.ts: every read routes through
sparqlQuery, every write through sparqlUpdate, container creation through
docCreate. Callers pass a semantic label (readDoc|readUnion|listMyEntityDocs|
writeEntity|deposit|…) that is a lib-internal probe param, NOT forwarded to the
real `ng` (preserves docs.test.ts exact-forwarding assertions).
OFF by default → one boolean read on the hot path, zero output. On via
configure({ debugAccessLog: true }) or env NG_EVENTUALLY_ACCESS_LOG=1 (no code
change). Polyfill-era; removed at the real multi-store migration.
tsc --noEmit: 0 errors. bun test: 91 pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The e2e harness files (broker.ts/run.ts/sdk-entry.ts) live outside the client
tsconfig include (src/test), so an editor's broad tsc flagged process/node:*/
playwright as unresolved. Add packages/client/e2e/tsconfig.json (extends base,
types: bun) — tsc -p e2e is exit 0; the client tsconfig stays unchanged/clean.
Drop an unused getCurrentUser import in sdk-entry.ts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Append to the probe section: the anchorless GRAPH ?g SCAN spans every named graph
(O(wallet) union — the reason reads are per-doc anchored, preserved) is distinct
from a constant anchored GRAPH <D> WRITE, which round-trips to the same repo (no
phantom graph, verified by packages/client/e2e/). Re-verify via the harness if the
broker version changes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The lib e2e harness now characterizes all three SPARQL graph shapes against the real
broker (24/24): (a) no-GRAPH anchored write round-trips; (b) an explicit anchored
GRAPH <plainNuri> ALSO resolves to the same repo (no phantom graph) — the earlier
'targets a phantom graph, does NOT round-trip' claim is FALSE on @ng-org/web
0.1.2-alpha.13; (c) an anchorless GRAPH ?g scan spans every named graph (O(wallet)
union, 32 graphs) — TRUE, re-verified.
Reconcile the comments accordingly: inbox.ts / store-registry.ts drop the false
phantom-graph justification (no-GRAPH stays as the canonical, always-safe shape — a
simplicity choice, not a round-trip necessity; re-verify via e2e if the broker
version changes). read-model.md keeps the anchorless-O(wallet) rationale (the real
reason reads are per-doc anchored) and appends a note distinguishing the variable
GRAPH ?g SCAN from a constant GRAPH <D> WRITE. No new absolute introduced.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The polyfill had only fake-ng unit tests; real-broker behaviour (SPARQL graph
round-trip, doc_subscribe marshaling, inbox/index) was only ever exercised by
borrowing Festipod's harness. Add packages/client/e2e/ — a standalone Playwright
harness in the SDK's own domain: a minimal SDK page (configure() + the real
@ng-org/web ng, a window.__sdk bridge, zero Festipod domain) loaded in the broker
iframe on the real broker, authenticating with a DEDICATED wallet (name
ng-eventually-e2e, profile e2e/.playwright-profile-lib — gitignored, separate from
Festipod's). Runner: bun run e2e/run.ts (script test:e2e); not mixed into bun test.
Covers, against the real broker (23 checks, all green): docCreate; sparqlUpdate/
Query anchored default-graph round-trip; readUnion N-doc + per-doc tolerance + cap
gate; doc_subscribe initial+write+unsub and subscribeDocs per-doc isolation; inbox
post/read/watch + spoof guard; discovery submit/read/watchIndex + reserved-account
isolation; store-registry idempotency + bounded listMyEntityDocs + scope resolvers;
caps read-filter (in-memory, honestly scoped); IdentityStore.
Finding (reported, not a failure): on @ng-org/web 0.1.2-alpha.13 an anchored
INSERT DATA { GRAPH <plainNuri> {…} } DOES round-trip (no phantom graph) — several
lib comments assert the opposite; stale, to reconcile. Behaviour is unaffected (the
lib writes the always-safe no-GRAPH default-graph shape). bun test still 91 pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Expose subscribeDoc(nuri, onChange) / subscribeDocs(nuris, onChange) wrapping the
real ng.doc_subscribe — per-document, event-driven (initial State + a Patch per
commit, local or broker-synced from a remote peer), returning a sync unsubscribe.
subscribeDocs isolates per doc (a failing/unsynced doc never aborts the others),
so it sidesteps the ORM fan-out hang (never orm_start_graph(graphs:[…])).
Replace the setInterval polling in inbox.watch() and discovery.watchIndex() with
doc_subscribe — same public contract, now push not poll. NextGraph is subscription-
first; no polling remains.
Verified against the real broker (@data harness spike): the doc_subscribe callback
marshals across the iframe RPC (@ng-org/web strips the callback and drives it via a
MessagePort — no DataCloneError) and fires on the initial push and on a real write.
Lib bun test 91 pass, tsc clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Document the SDK's public read surface grounded in real NextGraph: the reactive
useShape hook (subscribe/push — a doc change, local or broker-synced from a remote
peer, propagates to every subscriber; no polling) is THE recommended read path;
one-shot sparqlQuery/readUnion is the exception. Includes the write surface,
identity/scope (per-document isolation, public = owner-writes-only), and a separate
'current emulation status' section flagging where the polyfill does not yet honor
the reactive contract (entity reads one-shot + polling inbox/index watchers) as
gaps to close. Shared reference: honored by the lib, used by Festipod.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Align the polyfill's surface and docs with the verified NextGraph reality and
remove application-level concepts:
- Identity is an ID, not a username: AccountRecord.id, shim predicate shim:id,
normalizeId; accounts core becomes IdentityStore (set/clear/get) — the faux
login/logout framing is gone (identity is set at wallet-import time).
- Relationship/connection is an application concept, not a platform primitive
(NextGraph has no bilateral-connection primitive: grantee is unpersisted
scaffolding, cap-send is unimplemented). Remove connections.ts; caps exposes
only a directed grantRead(doc, granteeId) + a read-only protectedDocsOf(owner).
Delete the now-dead isolation.ts social-visibility axis.
- Inbox docs: NextGraph has no separate curator — the recipient's own verifier
unseals and applies each queued sealed message inline (process_inbox);
inbox_post_link is a proposed/future API. Stop attributing the emulated
curator to the platform.
- Read isolation reframed around the outcome: no cap -> empty union read;
targeted read of an unheld repo -> RepoNotFound; cap introspection
(canRead/governsRead) is emulation-only with no NextGraph API behind it.
- read-model.md corrected: the listing path is per-doc ANCHORED default-graph
queries, never the anchorless GRAPH ?g union (that is O(wallet)); the probe
section no longer claims the opposite.
- README recap table restructured (target | current NextGraph status | current
emulation); INDEX_ACCOUNT documented as reservedAccount("index") in the
sentinel namespace; de-domained generic-layer comments; softened tone.
Consumer application (Festipod) rewired separately to own the relationship
concept and feed the lib an id. Lib gates: bun test 83 pass / 0 fail, tsc clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Inbox deposits and the per-(account,scope) index append were written into an
explicit GRAPH <plainNuri> named graph, which the real broker stores separately
from the repo's default graph (repo_graph_name with overlay suffix) — so an
anchored default-graph read (read-model.readDoc) never saw them and deposits
did not round-trip. Drop the GRAPH wrapper: the anchor scopes the write to the
repo's default graph, matching the read. Mocks updated accordingly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
loadShim() read EVERY account record in the shim (SELECT over the whole anchor
graph). On a shim that accumulates accounts (the shared wallet grows), that is
O(accounts) and hangs the hot path (~90s at ensureAccount → loadShim). Same
principle as per-doc reads: never scan a shared structure.
Add resolveAccount(username): a BOUNDED SELECT anchored on the single subject
accountSubject(username) → O(1), independent of account count. Cache in a
per-account Map (cleared by resetRegistryCache). Hot paths now use it:
ensureAccount (existence check), indexInboxNuri/@index (discovery), resolveInbox
Anchor, resolveWriteGraph, createEntityDoc, listMyEntityDocs. loadShim kept only
for genuine all-accounts needs (allAccounts, the listEntityDocs fan-out fallback).
The 90s ensureAccount/loadShim hang is gone. 93 tests pass; tsc rc=0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
README: new section with a recap table (11 rows) — for each emulated behavior:
what the consumer sees (SDK-shaped API), how it's emulated on one shared wallet,
the real NextGraph target, and the lib-only migration. Makes "emulated ≠ real,
migration is a lib-only swap" explicit.
simulation.md: opening banner that EVERYTHING in the file is emulation pending
real NextGraph; corrected the stale read-path paragraph (per-doc anchored, never
an anchorless union-scan). read-model.md: reactivity bullet aligned to per-doc.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a diagram + prose to simulation.md: a virtual wallet = one shim account keyed
by a virtual-wallet id; it has the 3 native stores (public/protected/private) but
EMULATED — each "store" is an index document (AccountRecord.docPublic/Protected/
Private) listing that scope's per-entity doc NURIs. Everything physical lives in
the ONE shared wallet's private store; the 3-store structure is the per-account
logical layer. At migration the index docs become real native stores.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The anchorless union query (`GRAPH ?g`) scanned EVERY named graph in the local
store (the whole shared physical wallet) → O(wallet size), slow/timeouts on a
bloated wallet. Rewrite `readUnion` to run ONE ANCHORED `sparql_query` per by-need
doc (in parallel, per-doc tolerant): an anchored query is restricted to that
repo's graph, so it is O(1) per doc, INDEPENDENT of physical-wallet size. Keep the
ReadCap defense-in-depth gate.
docs/simulation.md: new "Physical wallet vs virtual wallet" section — the physical
shared wallet is a substrate that accumulates and must NEVER be enumerated/scanned;
each user's VIRTUAL wallet (the account's scope index in the shim) is the bounded
thing you enumerate ("list my documents"), then read those docs per-doc anchored.
read-model.md / nextgraph-current-state.md updated to the per-doc anchored rule.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- read-model.ts `readUnion` now applies the emulated ReadCap gate (drops a
subject when its doc is governsRead && !canRead for the current identity), so
per-scope isolation holds by construction AND by filter.
- store-registry: `listMyEntityDocs(username, scope)` (current account only) vs
the all-accounts `listEntityDocs` fallback (documented as the enumeration to
avoid on the read path).
- docs: nextgraph-current-state / read-model — corrected to the SOURCE-VERIFIED
reality that the JS SDK exposes NO open/sync-by-cap primitive
(load_repo_from_read_cap is pub(crate)); in the mono-wallet all repos are
already local (same session), so the anchorless union spans them with no open
step. simulation.md: listEntityDocs+useShape({graphs}) is a fallback, not the
read path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Proven against the real broker (probe): opening docs then a single anchorless
`sparql_query` with a `GRAPH ?g { ... }` body reads the LOCAL UNION of all synced
named graphs — the fast, hang-free replacement for the reactive-ORM per-document
fan-out (which aborted on any unsynced/fresh repo → 75s never-fires). New
`read-model.ts` (readUnion) exposes this; there is no reactive union query, so
listing is one-shot and consumers re-query on change. docs/read-model.md refined
with the probe finding (an explicit `GRAPH ?g` body iterates all named graphs
regardless of the anchor; the anchor only bounds the default graph). 93 tests
pass; tsc rc=0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Source-verified against nextgraph-rs:
- nextgraph-current-state.md: NextGraph keeps ONE local oxigraph store per
session; each synced repo is a named graph. sparql_query with NO anchor
(UserSite/None) queries the UNION of all synced graphs (set_default_graph_as
_union); with an anchor it is restricted to one repo. Union is read-only
(updates need a doc anchor). No reactive SPARQL (one-shot). Root cause of the
ORM fan-out hang: orm_start_graph opens every graph in scope; a fresh/unsynced
per-entity doc → RepoNotFound aborts the subscription → the 75s never-fires.
- read-model.md (new): the read model — events via the global index (the one
enumeration hack); everything else by following a shared graph, opened/synced,
then listed via a single anchorless union sparql_query (never the ORM per-doc
fan-out); reactivity via re-query on a doc_subscribe/ORM change signal. Plus
the minimal broker probe to confirm the union behavior.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Real-broker validation of the per-document model surfaced round-trip breaks the
fake-ng unit tests missed:
- The inbox anchor was the shim graph itself, making loadShim ~60s; give the
inbox its own document so anchor resolution is fast and isolated.
- store-registry adjustments so per-entity documents created via the SDK are
indexed and readable back through the scope fan-out.
docs/simulation.md updated. 89 tests pass; tsc rc=0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The ReadCap filter now enforces on per-entity documents (consumers create one doc
per entity, so each has a declared policy — private→owner, protected→owner+
connections, public→all). Isolation is genuinely active, not dormant.
- connections.ts (new): a BILATERAL connection registry — a link grants protected
read only when BOTH sides have asserted it (each assertion bound to its author).
A unilateral/self-declared connection grants nothing (closes the confused-deputy
hole). declareConnections is authenticated to the current identity.
- inbox.post: `from` is bound to the current identity — a spoofed `from` throws.
- discovery.submitToIndex: PUBLIC-ONLY — a governed non-public doc is refused
(no protected/private leak into the world-readable index).
- docs/simulation.md: documents this as application-level emulated isolation on a
shared wallet (not crypto); at NextGraph maturity → real caps, consumer unchanged.
89 tests pass (+10 covering: active protected isolation via bilateral connect,
unilateral grants nothing, from-spoof rejected, non-public submit refused). tsc rc=0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The special discovery-index account was keyed as "@index", which the consumer's
normalizeUsername collapses to "index" — colliding with a real user named
"index" (who could then hijack/tamper the global index document). Introduce a
reserved-account namespace (a sentinel key unreachable by any typed username) so
the index account can never collide with user input. Test proves a user named
"index"/"@index" resolves to a DIFFERENT document than the reserved index
account. 80 tests pass; tsc rc=0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a generic discovery-index surface: submitToIndex(ref) deposits a reference
into the index document's inbox; readIndex() returns the materialized entries. A
reserved special account (@index) owns the index document; deposits flow through
the emulated inbox and are materialized by the emulated curator (the dedup/
moderation point). This replaces cross-account fan-out as the discovery path and
is more faithful to the target (a single owned index fed via its inbox). Generic
(the consumer supplies the reference to index). 79 tests pass; tsc rc=0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Isolation was dormant (no current identity ever set). Now: setCurrentUser
records who is reading; declareConnections(neighborsOf) grants each protected
document's read cap to owner + connections. Reads discriminate through the
ReadCap filter: private→owner, protected→owner+connections, public→all. Generic
(the consumer injects identity + connections). Write-guard coverage limits
documented honestly in docs/simulation.md (real write paths bypass the JS proxy;
full enforcement awaits native caps). isolation-active.test.ts proves the
protected+connections path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Expose a clean scope-based surface so consumers work by scope (public/protected/
private) and never see a physical store id — the library resolves placement and
performs the shared-wallet simulation internally. RegistrySession gains optional
protected/public store ids, supplied at the single injection point
(configureStoreRegistry). Zero domain knowledge. docs/simulation.md updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This library presents a mature-NextGraph SDK face to consumers while
compensating for the current SDK's gaps via a shared-wallet simulation. It
therefore OWNS all current-state + simulation knowledge — moved here out of the
Festipod app repo, which must treat this library as a finished SDK.
New docs/:
- nextgraph-current-state.md — what the current SDK/broker do and don't expose
(5 store types, document=repo, per-document ReadCap, inbox not exposed, iframe
RPC proxy, mono-user/no-global-data, wallet import constraint). Keeps the
nextgraph-rs source pointers.
- simulation.md — how the lib emulates the mature behaviour on one shared wallet
(shim, store!=document two axes, docCreate→private store, RepoNotFound scope
rule, @ng-org double-proxy DataCloneError, emulated ReadCap/inbox/curator).
- decisions/ — the current-SDK ADRs (private-store-nuri-scope, sparql-delete,
shared-wallet-login, discovery mechanism).
- fork-inbox-fallback.md — the Rust-patch/self-host route not taken.
- migration-guide.md — the checklist for when real NextGraph matures.
README: boundary framing from the lib's side + docs/ index; replaced the stale
"scaffold/stubbed" status with the actually-implemented mechanisms per source.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Polyfill capabilities landed for Festipod's T02 features (all generic,
zero-domain — the consumer injects the domain).
- inbox: implement the previously-stubbed namespace. post(target,{from?,
payload,ts?}) deposits {from,payload,ts} as RDF via docs.sparqlUpdate (the
real injected ng, never makeNg); read/materialize + watch emulate the curator
in-lib (deposits read via docs.sparqlQuery). `from` optional = anonymity.
- write-guard: caps.hasWritePolicy() + ng-proxy.sparql_update rejects when the
target doc is under a write policy and the current user lacks its write cap;
passthrough otherwise (no regression). Read-cap registry unchanged.
- sparql.ts (new): escapeLiteral / escapeIri / assertNuri, exported from index.
store-registry now escapes every literal and validates/encodes every IRI-
position value — closes a SPARQL-injection hole where an untrusted username
could inject triples into the shim (the account→doc-NURI trust root).
Tests: inbox, sparql (incl. injection), ng-proxy write-guard, isolation-active.
68 tests pass; tsc --noEmit rc=0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Port the shared-wallet shim mechanics from the Festipod app into the
generic @ng-eventually/client library — zero app-specific knowledge, the
consumer injects the domain (entity->scope mapping, connections, storage).
New namespaces exposed from src/index.ts:
- docs docCreate/sparqlUpdate/sparqlQuery via the REAL injected `ng`
(getConfig().ng), never the public makeNg proxy — the JS-over-
iframe double proxy breaks doc_create postMessage marshaling
(DataCloneError). Validated hard constraint.
- storeRegistry generic (account,scope)->NURI resolver, createEntityDoc/
listEntityDocs + per-scope index, sharedWalletShim in the
private_store, cache. Consumer wiring injected via
configureStoreRegistry({ getSession, normalizeUser }).
- isolation pure applyIsolation (public=all / protected=owner+connections
/ private=owner); accessors + connection graph injected.
- accounts AccountStore (localStorage-backed faux login, storage injected)
+ normalizeUsername. React wrapper intentionally NOT ported.
polyfill.ts gains configureStoreRegistry/getStoreRegistryDeps + resetConfig.
36/36 bun test, tsc --noEmit rc=0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Model the read filter on NextGraph's real ReadCap mechanism instead of an
invented per-item grant. Verified in nextgraph-rs: there is no Document type
(document = repo); a store is a container repo referencing other repos by RDF
overlay; holding a store's cap does NOT grant the repos it contains (each repo
needs its own cap; no read-cap inheritance). So the access unit is the
DOCUMENT = an item's `@graph`, never the item.
- caps.ts: CapRegistry (read/write caps per document NURI + public docs;
open/grantRead/grantWrite/makePublic/canRead/canWrite/governsRead/
hasReadPolicy). Replaces access.ts (Grant).
- read-filter.ts: filter keeps an item iff its `@graph` document is readable
(held cap or public); items with no `@graph` or in an ungoverned document are
kept. No injected grantOf — the filter reads `@graph` and consults the
registry (automatic, domain-agnostic).
- polyfill.ts: getCaps()/resetCaps() replace setGrantOf/getGrantOf; useShape
filters only when caps.hasReadPolicy() (else passthrough, no regression).
- tests: caps.test.ts (6) + read-filter.test.ts (4), incl. no-inheritance
between documents. 10 pass; tsc rc=0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
makeReadFilteredView wraps a DeepSignalSet in a Proxy whose iteration/size/forEach
yield only items the current user may read (canRead on the item's emulated grant), while
add/delete and the underlying reactivity pass through. filterReadable is the pure core.
useShape applies it only when a grantOf resolver is configured (else passthrough, so
ungranted apps are unaffected). grantOf/setGrantOf added to the polyfill surface. Mirrors
the broker delivering only authorized docs; removed at migration. 4 unit tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
lifecycle.ts forwards init (@ng-org/web) and initNg (@ng-org/orm) to the injected
SDK; index re-exports the SDK types (ShapeType, BaseType, Schema, DeepSignalSet, NG) so
consumers import everything from @ng-eventually/client. Type re-exports are erased at
build → no runtime @ng-org import added (no duplicate SDK copy). @ng-org added as
devDependencies (typecheck only) + peerDependencies. EventuallyConfig accepts init/initNg.
Typecheck + 4 tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Generic polyfill layer that makes a single NextGraph broker behave like the
not-yet-shipped multi-user NextGraph (emulated capabilities + inbox). Zero app domain.
@ng-eventually/client exposes an SDK-identical surface (ng, useShape, inbox); the
polyfill bootstrap (configure + capability helpers) is isolated under /polyfill, so
the main entry stays a drop-in for @ng-org/web|orm. The real SDK is injected at
configure() (no hard import → build-alias safe + testable).
Scaffold: NextGraph wiring stubbed with TODO; capability helpers implemented and
unit-tested (4 tests, typecheck clean). The global-index curator is deferred — in
NextGraph apps/services are mono-user with no global data.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>