Ng eventually #1
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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<FpParticipation>;
|
||||
|
||||
const [bridgeReady, setBridgeReady] = useState(false);
|
||||
// Read-filter validation: when set, <FilterProbe> mounts a filtered useShape.
|
||||
const [filterUser, setFilterUser] = useState<string | null>(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`. <FilterProbe>
|
||||
* 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 <div id="harness-status">{bridgeReady ? 'READY' : 'LOADING_SHAPES'}</div>;
|
||||
return (
|
||||
<>
|
||||
<div id="harness-status">{bridgeReady ? 'READY' : 'LOADING_SHAPES'}</div>
|
||||
{filterUser && privateNuri && <FilterProbe privateNuri={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<FpParticipation>;
|
||||
useEffect(() => {
|
||||
(window as any).__readFilter = {
|
||||
ready: true,
|
||||
count: set.size,
|
||||
users: [...set].map(p => p.user),
|
||||
};
|
||||
}, [set]);
|
||||
return null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user