diff --git a/.project/concepts/nextgraph-platform/decision_2026-06-17_eventually-library.md b/.project/concepts/nextgraph-platform/decision_2026-06-17_eventually-library.md index db333ce..6df7f27 100644 --- a/.project/concepts/nextgraph-platform/decision_2026-06-17_eventually-library.md +++ b/.project/concepts/nextgraph-platform/decision_2026-06-17_eventually-library.md @@ -51,9 +51,10 @@ Tout le polyfill qui compense l'immaturité de NextGraph (pas de lecture cross-w - **Point d'injection unique (option 1)** : dans l'app, **seul `ngSession`** importe le vrai SDK au runtime — uniquement pour `configure(...)`. Tout le reste de l'app (data, lifecycle, login, types) passe par la lib. - **Pourquoi pas « lib importe le SDK elle-même »** : la lib étant dans un **repo séparé** (arbre `node_modules` distinct), si elle importait `@ng-org` au runtime, le bundle aurait **deux copies** d'`@ng-org` → l'ORM (signaux mono-instance) casserait. L'injection garantit **un seul exemplaire** (celui de Festipod). *(Le « zéro accès direct » exigerait la lib en workspace dans le repo — écarté pour la garder externe ; cf. options 2/3 discutées.)* - **Exceptions assumées** (hors « app ») : `src/shared/test-harness/auth-setup.tsx` (bootstrap wallet de test) et `src/shared/test-harness/harness.tsx` (harness **mock**, `deepSignal`) gardent un import direct `@ng-org`. Les **bindings ORM générés** (`festipodShapes.*`) aussi (types générés). -- **Validé** : build Festipod · `@ui` 4/4 · **`@data` 8/8 (43 steps) contre le broker** · lib (typecheck + 4 tests). +- **Filtre de lecture — IMPLÉMENTÉ & validé (2026-06-25)** : `read-filter.ts` — `makeReadFilteredView` (un **Proxy** sur le set réactif : itération/`size`/`forEach` filtrés par `canRead(grant, utilisateur)`, mutations forwardées) + `filterReadable` (pur). `useShape` l'applique **uniquement si un `grantOf` est configuré** (sinon passthrough → pas de régression). Grant = donnée portée par le document (résolveur `grantOf` injecté ; domaine-agnostique). Validé : **4 tests unitaires** (logique + Proxy + utilisateur dynamique) **et un scénario `@data`** qui l'exerce sur le **vrai `DeepSignalSet`** contre le broker (filtre actif → seules les participations de l'utilisateur ciblé). *Piège rencontré : ne pas cibler `td.currentUserId` (vide au build du bridge) — viser un vrai utilisateur des données.* +- **Validé (global)** : build Festipod · `@ui` 4/4 · **`@data` 9/9 (47 steps) contre le broker** · lib (typecheck + **8 tests**). -Reste à implémenter dans la lib (les stubs `TODO`, nécessitent la couche comptes/grants) : filtre de lecture sur l'ORM réactif, garde d'écriture, `inbox.post`, login wallet partagé. +Reste à implémenter dans la lib (stubs `TODO`, nécessitent la couche comptes/grants pour être *actifs* dans l'app) : **garde d'écriture**, **`inbox.post`** + matérialisation, **login wallet partagé**. ## Open Questions diff --git a/src/modules/workshop/features/read-filter.feature b/src/modules/workshop/features/read-filter.feature new file mode 100644 index 0000000..18a6e69 --- /dev/null +++ b/src/modules/workshop/features/read-filter.feature @@ -0,0 +1,14 @@ +# language: fr +@WORKSHOP @priority-1 +Fonctionnalité: Filtre de lecture (ng-eventually) + En tant que développeur + Je veux valider, contre le vrai broker, que le filtre de lecture de la lib + ne renvoie que les données autorisées pour l'utilisateur courant, sur le vrai + set réactif de l'ORM. + + @data + Scénario: Le filtre ne renvoie que les participations autorisées + Étant donné le wallet contient des participations de plusieurs utilisateurs + Quand j'active le filtre de lecture pour l'utilisateur courant + Alors je ne vois que les participations de l'utilisateur courant + Et le filtre a masqué au moins une participation d'un autre utilisateur diff --git a/src/modules/workshop/steps/data/read-filter.steps.ts b/src/modules/workshop/steps/data/read-filter.steps.ts new file mode 100644 index 0000000..62b23a1 --- /dev/null +++ b/src/modules/workshop/steps/data/read-filter.steps.ts @@ -0,0 +1,62 @@ +import { Given, When, Then } from '@cucumber/cucumber'; +import { expect } from 'chai'; +import type { FestipodWorld } from '../../../../shared/support/world'; + +// Validates ng-eventually's READ FILTER on the REAL ORM set, against the broker. +// The harness grants each participation to its own `user`; with the filter on +// for a chosen user, useShape must yield only that user's participations. + +Given('le wallet contient des participations de plusieurs utilisateurs', async function (this: FestipodWorld) { + // Deterministic: create two participations for two synthetic users on a + // synthetic event (isolated from real-event counts; joinEvent is idempotent on + // event+user, so this doesn't accumulate across runs). + await this.appFrame!.evaluate(async () => { + const td = (window as any).__testData; + await td.joinEvent('urn:rf:event', 'urn:rf:alice'); + await td.joinEvent('urn:rf:event', 'urn:rf:bob'); + }); + await this.appFrame!.waitForFunction( + () => { + const ps = [...(window as any).__testData.participations]; + return ps.some((p: any) => p.user === 'urn:rf:alice') && ps.some((p: any) => p.user === 'urn:rf:bob'); + }, + null, + { timeout: 15000 }, + ); + const data = await this.appFrame!.evaluate(() => { + const td = (window as any).__testData; + const parts = [...td.participations]; + const targetUser = 'urn:rf:alice'; + return { + targetUser, + total: parts.length, + ownCount: parts.filter((p: any) => p.user === targetUser).length, + }; + }); + (this as any).rf = data; + expect(data.ownCount, 'exactly one participation for the target user').to.equal(1); + expect(data.total, 'other users have participations too').to.be.greaterThan(data.ownCount); +}); + +When('j\'active le filtre de lecture pour l\'utilisateur courant', async function (this: FestipodWorld) { + const { targetUser } = (this as any).rf; + await this.appFrame!.evaluate((u: string) => (window as any).__testData.setupReadFilter(u), targetUser); + await this.appFrame!.waitForFunction( + () => (window as any).__readFilter?.ready === true, + null, + { timeout: 15000 }, + ); +}); + +Then('je ne vois que les participations de l\'utilisateur courant', async function (this: FestipodWorld) { + const r = await this.appFrame!.evaluate(() => (window as any).__readFilter); + const { targetUser, ownCount } = (this as any).rf; + expect(r.users.every((u: string) => u === targetUser), 'all filtered participations belong to the target user').to.be.true; + expect(r.count, 'filtered count equals the target user own participations').to.equal(ownCount); +}); + +Then('le filtre a masqué au moins une participation d\'un autre utilisateur', async function (this: FestipodWorld) { + const r = await this.appFrame!.evaluate(() => (window as any).__readFilter); + const { total } = (this as any).rf; + expect(total, 'the filter hid at least one other-user participation').to.be.greaterThan(r.count); +}); diff --git a/src/shared/test-harness/harness-ng.tsx b/src/shared/test-harness/harness-ng.tsx index cd20a25..7263832 100644 --- a/src/shared/test-harness/harness-ng.tsx +++ b/src/shared/test-harness/harness-ng.tsx @@ -12,6 +12,7 @@ import { createRoot } from 'react-dom/client'; import { NextGraphProvider, useNextGraph } from '../context/NextGraphContext'; import { FestipodDataProvider, useFestipodData } from '../context/FestipodDataContext'; import { useShape } from '@ng-eventually/client'; +import { setGrantOf, setCurrentUser } from '@ng-eventually/client/polyfill'; import type { DeepSignalSet } from '@ng-eventually/client'; import { FpEventShapeType, @@ -67,6 +68,8 @@ function ConnectedHarness() { const participations = useShape(FpParticipationShapeType, privateNuri) as DeepSignalSet; const [bridgeReady, setBridgeReady] = useState(false); + // Read-filter validation: when set, mounts a filtered useShape. + const [filterUser, setFilterUser] = useState(null); useEffect(() => { // Small delay for useShape to populate @@ -146,6 +149,19 @@ function ConnectedHarness() { loadTestData() { return bootstrapWallet(events as any, users as any, participations as any); }, + + /** + * Enable the lib's READ FILTER on the real ORM set: each participation is + * granted to its own `user`, and the current user is `user`. + * then exposes window.__readFilter with the filtered participations. + */ + setupReadFilter(user: string) { + setGrantOf((item: any) => + item && item.user ? { read: [item.user], write: [item.user] } : undefined, + ); + setCurrentUser(user); + setFilterUser(user); + }, }; console.log('[HarnessNG] Ready — events:', events.size, 'users:', users.size, @@ -157,7 +173,30 @@ function ConnectedHarness() { return () => clearTimeout(timer); }, [events, users, participations, ngCtx, appData]); - return
{bridgeReady ? 'READY' : 'LOADING_SHAPES'}
; + return ( + <> +
{bridgeReady ? 'READY' : 'LOADING_SHAPES'}
+ {filterUser && privateNuri && } + + ); +} + +// ============================================================================ +// FilterProbe — subscribes participations AFTER the read filter is enabled, so +// useShape returns a filtered view. Exposes window.__readFilter for the @data +// scenario validating the read filter on the real ORM set. +// ============================================================================ + +function FilterProbe({ privateNuri }: { privateNuri: string }) { + const set = useShape(FpParticipationShapeType, privateNuri) as DeepSignalSet; + useEffect(() => { + (window as any).__readFilter = { + ready: true, + count: set.size, + users: [...set].map(p => p.user), + }; + }, [set]); + return null; } // ============================================================================