Ng eventually #1

Open
Sylvain wants to merge 110 commits from ng-eventually into main
4 changed files with 119 additions and 3 deletions
Showing only changes of commit aec338441c - Show all commits
@@ -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);
});
+40 -1
View File
@@ -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;
}
// ============================================================================