Compare commits

...

21 Commits

Author SHA1 Message Date
Sylvain Duchesne c7cda38235 Name the script for what it does, now that it serves two providers
`link-polyfill.ts` handled one provider. It now handles two through a registry,
one of which is not a polyfill, so both the filename and the `link:` verb said
something false — and a name that is short but wrong misleads more than an opaque
one would.

It becomes `overlay-local-checkout.ts`, which is the operation: it copy-overlays a
local package checkout into node_modules for development. "link" also implied a
symlink, which is precisely what the script exists NOT to do — its own header
spends a paragraph on why.

No behaviour changed: same registry, same copy, same `--once`, same watch loop,
same assertion. Both providers exercised afterwards, each reporting the singletons
it must preserve — one package for the data layer, two for the indexing package,
whose own checkout carries `file:` links and would otherwise smuggle in a second
verifier.

Seven references repaired by rewriting the sentence that carried them rather than
swapping a path, across the script, `package.json`, `.env.example` and four
doctrine leaves. No compatibility alias left behind: an old name kept "just in
case" would restore exactly the ambiguity being removed.
2026-08-17 12:35:28 +02:00
Sylvain Duchesne 7124750874 The indexing package installs, and there is still one verifier
`1.0.1` moves the data layer from `dependencies` to `peerDependencies`, satisfied
by the application's own copy. The path that broke `1.0.0` survives only in
devDependencies, which pnpm never installs for a git dependency -- verified at the
tag's commit rather than taken on trust.

One verifier, proven: the data layer and `@ng-org/web` each resolve to the same
realpath from the application and from inside the new package, and exactly one
`@ng-org/web` exists in the whole store. No overrides, no resolutions, no hoisting
flag, no `.npmrc` -- the peer dependency did it alone, which is the point: the
singleton becomes structural instead of something checked afterwards.

The runtime surface matches the engagement exactly -- five values exported, every
other published name type-only and erased. Nothing undeclared, nothing missing.

Local wiring stays ONE script rather than a sibling: a provider registry drives
the same copy-overlay, the same `--once`, the same watch loop, only the paths
differ. Its single-instance assertion became a loop over every package a provider
shares with the application, which matters here because that provider's own
checkout carries `file:` links -- the very symlink hazard the script exists for.

Its header no longer promises what the old one did: a running dev server never
picks up a refreshed node_modules package, not even across a real rebuild.
2026-08-17 12:30:25 +02:00
Sylvain Duchesne 319e7082cc Re-pin the indexing engagement on the version that can be installed
`1.0.0` declared the data layer as a dependency resolved through a path that
existed only in its author's working copy, so no application could install it --
not partially, but at the install itself, which is why none ever ran it. `1.0.1`
declares that package a PEER, supplied by the application. That is the shape that
makes a single verifier structural rather than something verified afterwards.

Nothing exported moved, which is what makes it a patch: the only thing that
changed for a caller is a requirement it could never have satisfied.

Two facts the new engagement adds, both worth having before wiring anything. The
package is published to no registry and distributed as TypeScript source, so
whatever builds the application compiles it -- our image installs from git and
Bun compiles at serve time, which fits, but a JavaScript-only toolchain could not
consume it. And `1.0.0` is superseded rather than withdrawn: the tag stays and
keeps resolving, because a pinned reference is never taken away from under a
consumer -- a policy the provider holds even for a version that never worked.
2026-08-17 12:26:21 +02:00
Sylvain Duchesne 279faa4541 Retire a caveat the fix made false, and turn a stale warning into a technique
Four of the five fields the caveat said were silently lost are now persisted, so
the caveat is retired rather than trimmed. What was genuinely unique in it -- the
inbox predicate being a vestige that must stay unused -- moved into the Event
bullet where the shape is described; what merely duplicated the entity leaf went.
Only `themes` remains unpersisted, and since nothing reads it back it is a note
rather than a hazard.

The regeneration warning is reframed rather than deleted. It said the ORM
generator had drifted from the committed bindings; that no longer reproduces --
regenerating from the unchanged shape gives byte-identical output. What survives
is the technique that established it: run the generator on unchanged input first,
so an ORM diff separates cleanly into the author's change and the generator's own.
A warning became a way of working. The same stale claim sat independently in the
commands leaf and was found by grep, not by the briefing.

Recorded where the screen hazards live: the edit form seeds its inputs on first
render, so mounted before its event lands in the reactive set it stays blank --
the same class as a value that is empty until a read arrives.

Verified rather than assumed: the misleading French date example flagged for
correction exists nowhere in the doctrine. It lives in a shape comment and the
binding generated from it, both outside this pass, and is noted as such.
2026-08-17 11:40:33 +02:00
Sylvain Duchesne 9e4374b678 Persist the dates the form always collected and always threw away
The creation form has always offered real date and time pickers, and the write
kept only the label derived from them. The user chose a date; the broker never
saw it. Five fields went that way, on create and on update alike, because the
shape defined nowhere for them to land.

`startDate`, `endDate`, `startTime` and `endTime` are now on the Event shape and
carried end to end. `date` is untouched -- it is the label the lists display, and
nothing derives one from the other. `themes` is deliberately left out: it is a
repeated value, so it needs a cardinality decision rather than one more optional
string, and nothing consumes it.

Proven on the running application, both paths, asserted only after a reconnect
because the optimistic overlay shows the values before anything is durable:
written 2026-12-05 / 09:15, read back identical; edited to 2027-01-22 / 08:05,
read back identical. An event missing the fields renders clean -- the time line
simply absent, nothing thrown.

The recorded generator drift no longer reproduces. Regenerating from the
UNCHANGED shape produced bytes identical to the committed bindings, which is how
the additive diff was separated from the generator's own; that separation was the
point of running it twice, and it turned a warning into a measurement. The
doctrine still carries the old warning and is now wrong about it.

Also worth having: the form composes its label from the ISO values joined, not
from the French wording the doctrine gives as its example.
2026-08-17 11:30:57 +02:00
Sylvain Duchesne 32c2302c91 Deployed, and it signs people in
First deployment carrying the injected wallet and the external data layer. A user
signs in and sees their own data.

That settles the one link in the chain nothing here had ever exercised: a
deployed origin CAN be embedded in the hosted broker's iframe. The identity flow
runs the application inside that iframe, so data reaching a signed-in user is
proof the embedding works for this origin.

It is settled by the deployment, not by a test, and the doctrine now says so:
no scenario covers it, so a change of origin, of proxy, or of the broker's
embedding policy would be found by a person rather than by the suite.

Worth stating plainly what this deployment is: the first one able to sign anybody
in at all. The production bundle never asked for its runtime configuration under
NODE_ENV=production, so it had neither password nor wallet to hand the data
layer, and the failure was invisible from outside — the server answered, the page
rendered, and nothing was wrong except that nobody could get in.
2026-08-17 10:48:35 +02:00
Sylvain Duchesne 61cbe6905d Trigger a redeploy: first run with the injected wallet and the external data layer
No change. This commit exists to give the deployment something to pick up.

What it deploys, already committed above: the data layer as a real external
dependency pinned to a commit and installed from Gitea, and the wallet arriving
through FESTIPOD_SHARED_WALLET_FILE_BASE64 instead of a file nothing ever put in
the image. Both were verified in a locally built image before this push.

The deployment environment must carry FESTIPOD_SHARED_WALLET_PASSWORD and
FESTIPOD_SHARED_WALLET_FILE_BASE64, and must NOT carry
FESTIPOD_SHARED_WALLET_FILE -- a path always wins over the base64 form and does
not fall through, so a leftover path variable would serve 404 for the wallet and
no one could sign in, with nothing saying why.
2026-08-17 10:34:49 +02:00
Sylvain Duchesne 95479ebe77 The image builds, serves the injected wallet, and asks for its configuration
Built and run for the first time in this shape. `pnpm install --frozen-lockfile`
fetches the external data layer from Gitea inside the install stage and succeeds
-- the step that refused an hour ago. Run with only the password and the base64
wallet set, and no path variable, the container serves 810 bytes at
/shared-wallet.ngw with the same sha256 as the original file.

The check that mattered most: the production bundle contains exactly one call to
/festipod-config.json. That call being absent under NODE_ENV=production is what
made the deployed application unable to sign anybody in, and its absence is
invisible from outside -- the server answers, the page renders, the endpoint
works, and nothing asks it anything.

Two doctrine claims the build disproved, corrected rather than left standing:

The `bun` peer shim the deployment notes describe as a live hazard does not
appear at all in a built image -- `bun` is absent from node_modules/.bin and
resolves to the base image's binary. The `onlyBuiltDependencies` approval is
currently inert. Kept, since it costs nothing and the shape can return, but the
paragraph now says dormant instead of describing a mechanism that is not running.

Asset paths are emitted as `/../chunk-*.js`. Browsers normalise that at the root
and the existing deployment already passes it through its proxy, so it works --
but a proxy that treats `..` segments differently would break asset loading, and
the symptom would be a blank page with 404s on chunks, naming nothing.
2026-08-17 10:22:07 +02:00
Sylvain Duchesne c3d64555d9 The data layer is a real external dependency, pinned to a commit
`package.json` named `#main`, where the renamed `packages/polyfill` does not
exist, and `pnpm-lock.yaml` still named the package as it was called in July.
`pnpm install --frozen-lockfile` -- the Dockerfile's install step -- refused on
exactly that mismatch, so the image could not build at all. Both now name the
commit the branch was pushed at, and the frozen install passes.

The dependency is now genuinely external: installed from git into pnpm's store,
with no local overlay anywhere in the resolution. The invariant that justified
the overlay in the first place holds -- `@ng-org/web` resolves to the same
realpath from the application and from the installed package, so one instance
and one verifier. A git-installed package does not carry its own `@ng-org`,
where a symlink to the checkout would have.

`.project/contracts.yaml` names the same commit as the dependency, so the surface
the app is coded against and the package it installs designate one state, and a
drift between them becomes visible instead of silent. A tag will replace the
commit once the provider settles a convention; the invariant is that the two
agree, whatever the ref.

Deployment doctrine now carries what a deployer needs and did not have: the
pinning discipline, that the frozen install only verifies, the wallet arriving
through the environment with its one-directional precedence and its loud failure
on a malformed value, that neither the wallet nor its password is a secret, that
tailwindcss is a devDependency the server needs at serve time, and that a
deployed origin's embedding in the broker's iframe is covered by no test.

Nothing has been deployed in this shape yet, and the doctrine says so.
2026-08-17 10:17:53 +02:00
Sylvain Duchesne ac29735d20 A deployment can carry the wallet in its environment, not on a mounted disk
The contract requires the application to serve a wallet file and hand its URL
and password to the data layer. Until now the only source was a filesystem
path, which no container has: `*.ngw` is gitignored, `COPY . .` brings none, and
nothing mounts one -- so a deployed instance served 404 where the contract
expects bytes.

`FESTIPOD_SHARED_WALLET_FILE_BASE64` carries the file itself. It is 810 bytes,
and it is not a secret: by design the application hands it to every user who
opens the app, so provisioning persistent storage would be guarding something
public. One channel for both values, nothing to mount, and a new host needs only
its variables.

Precedence is deliberate and one-directional: the path always wins when set, and
an unreadable path does NOT fall through to the base64 form. A deployment sets
exactly one; both set is a leftover, not a fallback chain. Local development and
the test harness only ever set the path, so they are untouched.

A malformed value answers 500 and names the variable. Answering 404 would have
made "configured wrong" indistinguishable from "not configured at all" -- the
confusion this codebase has spent two days removing.

Proven by serving the same wallet from each source in turn and comparing:
identical size, identical sha256. The first attempt at that proof silently
exercised the path branch, because Bun auto-loads `.env` and the variable was
already there; the checksums matched for the wrong reason. Caught, cleared, and
measured again.
2026-08-17 09:34:19 +02:00
Sylvain Duchesne e780c5246c The count defect was two things; only the one that is not ours is left
`bug_participant-count-stays-at-zero` described a failure that no longer exists
and an open question that turned out to be the same failure seen from the other
side. It becomes `caveat_participant-count-one-connection-lag`, and the type
change is the point: what remains is a bounded delay whose cause sits entirely
outside the application, not a defect this repo can act on.

Gone from it: the multi-inbox race, "never converges", and the withdrawal
asymmetry recorded as unexplained. That asymmetry WAS the race — with one inbox
per document both paths now share the identical one-connection lag, so nothing
is left unaccounted for.

What it says now: the count needs one connection more than the write that
produced it, because the layer does not notify you of your own actions — a
deposit into an inbox you watch raises no push, and a write to your own document
is not re-read in the writing session. Both measured, both raised with the
provider, and neither compensated here: a retry or a poll is precisely what the
doctrine forbids.

Repaired alongside: the leaves that still described DELETE-then-INSERT, the
materializer's old shape, and the probe cookbook's tally of open defects. The
two new primitives are recorded where the shared utilities are listed, marked
unit-tested.
2026-08-17 00:10:16 +02:00
Sylvain Duchesne 0d925c7cb9 One inbox per document: the sign-up's deposit now lands where the owner is looking
Creating an event resolved its inbox four times at once -- from `createEvent`,
from the materializer, from the watch callback and from the watch wiring --
with nothing serialising them. Three inboxes were registered for that one
document inside 0.3 s, so the owner watched one while the sign-up deposited into
another. That is the whole of the asymmetry: on any later connection nothing
re-registers, both sides agree, and withdrawal converged immediately while a
sign-up never did.

Measured before: 2 of 3 fresh sign-ups NEVER converged, the deposit unfindable
on every later connection. Measured after, twice: one inbox, one caller joining
the in-flight resolution instead of opening a second, the deposit read back, and
nothing failing to converge.

The fix is two primitives rather than a lock in the middle of the data context,
each unit-tested on its own: a resolve-once-per-key whose rejection is NOT
memoized (unknown is not absent), and a serial task whose mid-run requests
coalesce into one follow-up and which a failure cannot wedge. The single-flight
wrapper is now the only caller of the underlying entry, so every call site is
covered without touching any of them.

Also closed on the same path: the write guard carries a monotonic cycle number,
so a cycle from an earlier effect run cannot overwrite a fresher count; the
field update is one statement instead of DELETE-then-INSERT, closing the window
where a reader saw the field absent and read zero; and the materializer's
before-value comes from a ref instead of a stale closure.

What is NOT fixed, deliberately: the count still takes one connection to appear.
A deposit you make into an inbox you watch produces no push, and neither does a
write to your own document -- both are questions for the provider, and any
app-side substitute would be the polling the doctrine forbids.
2026-08-17 00:02:27 +02:00
Sylvain Duchesne ff26f26e60 Close the fixed blocker, and record what withdrawal converging faster does NOT mean
Six variations against the real application, one thing varied at a time: the
sign-up-breaks-the-next-connection defect is gone. New identity, immediate
reload or delayed; an identity already used for several cycles; and the very
identity poisoned by the pre-fix code, which now reconnects cleanly three times
out of three. So nothing needed healing -- the fix does not merely stop writing
the bad state, it makes what was already written harmless. The leaf is deleted
rather than graduated: its reproduction method, the stale-server trap it led to,
and "a provider gap is not worked around here" all live elsewhere already.

The count defect stays open, with its shape now known: it does converge across
connections, but takes one more than expected -- the first reconnect still shows
zero, the second shows the value.

And a correction worth keeping. The explanation offered for why withdrawal
converges in a single connection -- that it writes straight to the owner's
document instead of depositing -- is contradicted by the code: it deposits its
own marker into the same inbox, drained by the same owner routine, "symmetric"
by the code's own comment. The asymmetry is real and recorded as verified; its
cause is recorded as unknown rather than filled with a plausible story. Same
mechanism, same inbox, two connections against one, is a lead worth having
honestly.

The dev-server caveat is upgraded from inferred to verified, and it is worse
than it read: an edit to application source triggers a genuine rebuild, new
bundle hash and all, and that rebuild still carries the stale dependency. Only a
restart works, and "touch a file to force a rebuild" is now explicitly ruled out
as a substitute.
2026-08-16 22:40:08 +02:00
Sylvain Duchesne ac55dc96a4 A refreshed data-layer package never reaches a running dev server
`link:polyfill` overlays the package into node_modules and keeps it current.
That is all it does. A `bun run dev` already running goes on serving what it
loaded at startup, however many times the overlay is rewritten underneath it:
node_modules is excluded by file watchers, so the change lands in the one place
nothing is looking.

The script promised the opposite -- that `bun --hot` would reload the copied
file live. That promise is now removed, and replaced by the instruction to
restart.

It cost about an hour. A defect had been fixed upstream, the overlay refreshed,
and a probe on a freshly launched server confirmed the fix 3 runs out of 3. The
same sequence by hand reproduced the defect at once. The two observations looked
irreconcilable and the hunt went to the wallet, to prior state, to timing.

The dev server had been up for six days. It predated the package rename and the
whole migration; the browser was running a different application from the one
under test. Nothing warned: a stale server looks exactly like a current one, and
the script's own header ruled out the true cause for anyone who trusted it.

The reflex is written down with the leaf: when a fix seems not to take, or when
a hand-run and an automated run disagree, check how long the server has been up
before anything else. One command, cheapest hypothesis first.
2026-08-16 22:06:52 +02:00
Sylvain Duchesne 4148df8fcb Record what running the flow proved, and fix the leaf that caused one defect
The create-and-participate flow was driven in a real browser for the first time.
It had been called correct by construction -- typecheck, build, reading -- and
the probe found three defects none of those could see.

Two open bugs, both major, both filed rather than worked around:
signing up to your own event makes the NEXT connection fail outright
(`ensureIdentity()` rejects inside the data layer's own inbox processing, 3/3,
reproduced on a fresh origin and identity), and the participant count does not
converge in the same session (2/2, 120 s and 75 s). Whether it converges at the
next connection is recorded as UNKNOWN and unmeasurable, because the first bug
stops the app from getting there.

The doctrine defect is the one worth the trouble. `knowledge_build-pipeline`
said production builds into `dist/`; `knowledge_deployment` said the container
runs `bun run start` from `src/`. Both were written down, they contradicted each
other, and the code followed the wrong one -- which is how a deployed app that
could sign nobody in was shipped. The three paths now live in one table whose
discriminating column is what is actually served: dev `src/`, production `src/`,
and `dist/` served by nothing at all. A build artefact nobody serves is a trap
for the next reader who assumes otherwise.

The probe method itself is written down: the suite cannot run, a targeted probe
can, and the difference is worth knowing before concluding that nothing is
measurable.

Stated once where a reader meets it: honest steps do not add up to an honest
flow. Every gesture in the sign-up reports correctly, and the user is still told
they participate while the count never moves and the next connection fails.
2026-08-16 16:46:10 +02:00
Sylvain Duchesne fa934ccdc6 Production serves from source, so it must fetch its config like everything else
The deployed application could never sign anybody in.

`loadRuntimeConfig` skipped fetching `/festipod-config.json` under
`NODE_ENV=production`, reasoning that a production build has the value inlined
by `build.ts`'s `define`. This project's production does not build: the
container copies the sources and runs `bun run start`
(= `NODE_ENV=production bun src/index.ts`), serving from `src/` exactly as dev
does. Nothing serves `dist/` at all. So the one step that could supply a wallet
was skipped, `ensureIdentity()` threw for want of one, and the endpoint sat
there — served, and never asked.

The condition tested the wrong thing. "Was the value inlined?" is a question the
global itself answers; "am I in production?" only ever stood in for it, and the
stand-in was false on the very path that matters.

Verified on the served bundle rather than on the endpoint: under
`NODE_ENV=production` it now carries the fetch, where it carried none.

The same combination is what `hooks.ts` spawns for the @e2e app server, so that
layer could not sign in either.
2026-08-16 16:27:00 +02:00
Sylvain Duchesne cebd54c978 The doctrine says what the code does again
Eighteen leaves had drifted behind today's changes, and several taught the exact
mistakes that were just removed.

Corrected, among others: the identity and the profile were conflated, and
`knowledge_context-internals` still described the impersonation fallback and the
principal-to-username join as current mechanisms. `caveat_identity-ids-in-screens`
and `knowledge_data-modes` still had `joinEvent` logging and returning where it
now throws. The shape listings still carried the event host. And
`knowledge_screen-pattern`'s canonical sample taught a toast written beside the
call rather than after the write -- the very bug fixed this afternoon, sitting in
the file a new screen is copied from.

New leaves for what had no home: write rights read from the owned-document
listing, with its three states and its deliberate residual; the owner's ruling
that no "may I write this?" call is coming, so the list is the answer for good;
and the `@data` suite losing its fixtures now that the seed writes nothing into a
connected wallet.

Four doc-debt files settled, including one the hook opened mid-pass.

Worth recording how one leaf died: a caveat was written for the unguarded edit
screen exactly as briefed, then deleted on finding the fix had landed while the
pass ran. Doctrine tracks the tree, not the instructions it was given.
2026-08-16 15:25:34 +02:00
Sylvain Duchesne 13eb2c4a15 Waits that say ten seconds now wait ten seconds, and editing checks you may
Two things that announced what they had not verified.

Seventeen `waitForFunction` calls passed their timeout in Playwright's ARGUMENT
slot instead of its options slot, so every one of them silently used the 30 s
default while the code read 5, 10, 15 or 60. The inventory said sixteen: one was
a false positive and two more were found that it never listed.

All seventeen are corrected, including the nine whose written value is SHORTER
than the default. Honouring the author's number is the point: a wait that is too
short fails loudly and names its step, where thirty seconds obtained by accident
hides a real slowness and reads as a lie in the source. Which of them need
raising is a question for the day the suite can run again -- it will be answered
on an honest number.

The event edit screen awaited nothing: the success toast fired and the screen
navigated away whether or not the write resolved. It now confirms after the
write, keeps the user on their edits when it fails, and says so.

That route was also unguarded -- anyone reaching the URL got the form, for any
event. It is now decided by ownership, read from the list of my own documents,
with the same three-state answer the pencil icon uses. UNKNOWN renders neither
the form nor a bounce: both would present a guess as a fact, and the guess that
matters here is telling a genuine owner their event is not theirs.
2026-08-16 15:16:46 +02:00
Sylvain Duchesne db3dbba294 No fixture seed, no event host, and the edit affordance stops lying
Three changes the product model asked for.

The fixture seed no longer writes anything into a connected wallet, by any
route. `bootstrapWallet` is the single enforcement point -- both call sites
funnel through it -- so the switch cannot be walked around by a screen or a
bridge. The fixtures, the seeding code, the demo path and the rendering tests
are untouched; a unit test now fails if a document is created after all.

An event has no host. The domain says so -- the meeting point has a host, the
event is only the anchor -- while the shape carried `hostName`/`hostInitials`
and every created event was written with the fabricated `'Moi'` / `'MD'`. Gone
from the shape, the ORM bindings, the type, the adapters, the writes and the
screens. `fp:MeetingPoint.host` stays: that one is real.

Regenerating the ORM revealed the committed bindings had drifted from what the
generator emits -- stylistic, verified predicate by predicate, plus the loss of
the `Fp` prefix. The prefix cannot be restored at the generator: the name comes
from the shape IRI, and those IRIs are the persisted RDF classes. Aliased at the
three import sites instead, so nothing downstream moved and the DOM `Event` and
`Notification` types are never shadowed.

Write rights are ownership, read from the list
The contract leaves no other reading -- only an owner writes, and no call adds a
writer -- so `listMyEntityDocs('public')` is what says which events are mine.
The hard-coded `isOwner = true` is replaced by a three-state answer, and the
UNKNOWN state renders neither a pencil nor a greyed one: a disabled look-alike
invites a dead click.

Two adversarial passes refuted the first attempt and both defects are fixed. A
latched boolean denied an owner their own event forever once a listing had
missed it; the ruling is now rebuilt rather than accumulated, so a later listing
overturns an earlier one.

Residual, deliberate and commented: "not mine" is inferred from absence, and the
reactive read and the listing are separate mechanisms, so a freshly arrived
event is ruled out for the window between them. Closing it needs a timer, which
the doctrine forbids.
2026-08-16 14:50:34 +02:00
Sylvain Duchesne 9740841820 A demo wallet keeps working: pick one of my own profiles, and say it is arbitrary
Resolving "my profile" by ownership left one case refusing: several profile
documents are mine and none was created by this session, which is exactly a
reloaded wallet carrying the fixture seed. Sign-up then rejected — in the very
flow being built.

The first by document reference is now used, stable across reloads and openly
arbitrary. While the profile is not a built feature, "which of my fixtures am
I" has no true answer and does not need one.

This is not the impersonation that was removed. That one reached for a profile
by NAME and could land on a document belonging to somebody else; every candidate
here is a document I own. The invariant that matters holds: the app never
presents another person's profile as mine.

Downgraded to a warning, and reworded: the log now says the name shown as yours
is demo data rather than announcing a refusal that no longer happens.
2026-08-16 13:53:00 +02:00
Sylvain Duchesne df971df135 Who I am comes from signing in; my profile is the document I own
The app derived its identity from a profile lookup and, when nothing matched,
picked somebody else. That is backwards: signing in returns who I am, and the
profile is looked up by it.

- Identity and profile are now two things. The identity is what
  `ensureIdentity()` returns: opaque, never rendered, never written, never
  passed to a data-layer call. The profile is Festipod's own object -- pseudo,
  name, initials -- in a document we create and write.
- "My profile" is the profile document I own, resolved through
  `listMyEntityDocs('protected')`. No username matching, no positional pick. A
  failed listing leaves the answer UNKNOWN rather than collapsing to "none".
- Having no profile now resolves to having no profile. Two impersonation
  fallbacks are gone, including one in `updateProfile` that would have written
  your pseudo into a stranger's document.
- A profile is created at sign-in when none exists. The shape makes name,
  initials and username mandatory, so it is written with placeholders that read
  as instructions -- never a plausible human name, never anything derived from
  the opaque identity.

Nothing succeeds in silence any more
`joinEvent` used to return without writing and without throwing when it could
not attribute the participation, while the screen announced success. It rejects
now, and the confirmation follows the write. Withdrawal likewise -- the doctrine
requires it to be authoritative. The host notification stops being written into
the joiner's own store, where its recipient could never read it, and the
optimistic notice shown to the wrong person goes with it.

The creator signs up through the common path: no owner branch anywhere, no
special case, the same deposit and the same derived count.
2026-08-16 13:50:50 +02:00
86 changed files with 2545 additions and 606 deletions
+12 -3
View File
@@ -18,10 +18,19 @@
FESTIPOD_SHARED_WALLET_PASSWORD=
# Chemin vers le fichier portefeuille partagé (.ngw), absolu ou relatif à la
# racine. Servi en téléchargement à /shared-wallet.ngw — par le build de l'app,
# et par le serveur du harness pendant les tests.
# racine. Servi en téléchargement à /shared-wallet.ngw. C'est la forme du DEV
# LOCAL et du harness de TESTS : le fichier vit sur le disque de la machine.
FESTIPOD_SHARED_WALLET_FILE=/chemin/absolu/vers/festipod-wallet.ngw
# Contenu du portefeuille partagé (.ngw), encodé en base64 — deuxième source
# pour le même fichier. C'est la forme des DÉPLOIEMENTS (conteneur) : *.ngw est
# gitignoré, donc `COPY . .` n'en embarque aucun et rien n'en monte un ; le
# fichier n'étant pas un secret (l'app le sert à quiconque ouvre l'app), il
# voyage comme une variable de config. `FESTIPOD_SHARED_WALLET_FILE` est
# prioritaire quand les deux sont renseignées — voir le commentaire dans
# src/index.ts. Générer la valeur avec, p.ex., `base64 -w0 festipod-wallet.ngw`.
FESTIPOD_SHARED_WALLET_FILE_BASE64=
# ── Seed automatique (opt-in) ──────────────────────────────────────────────
# Non vide => l'app amorce des données de démo dans un wallet VIDE au 1er login.
# OFF par défaut : laisser vide en usage normal.
@@ -36,6 +45,6 @@ NODE_ENV=
# ── Outillage dev (facultatif) ─────────────────────────────────────────────
# Override du chemin local du polyfill @ng-eventually/sdk pour `pnpm run
# link:polyfill` (lien local réactif). Défaut = ../nextgraph/ng-eventually-js/packages/sdk.
# overlay:polyfill` (overlay local réactif). Défaut = ../nextgraph/ng-eventually-js/packages/sdk.
NG_EVENTUALLY_LOCAL=
@@ -19,7 +19,7 @@ How the app's code is **structured** and **assembled**. *Feature-based* architec
- [[knowledge_routing]] — path-based routing (History API), route table, hooks
- [[knowledge_screens]] — screen inventory, registry, component library
- [[knowledge_screen-pattern]] — canonical anatomy of a screen (no props, flex layout, showToast)
- [[caveat_identity-ids-in-screens]] — `currentUserId` is the profile document's NURI and is **empty until the protected read lands**; empty reads like "no data"
- [[caveat_identity-ids-in-screens]] — `currentUserId` is empty until my profile document resolves, and an ownership answer can be UNKNOWN; neither means "no"
- [[caveat_boot-unverified-outside-broker]] — the unconditional `ensureIdentity()` await is verified inside the broker iframe; standalone/top-level boot is unverified
- [[knowledge_styling-system]] — `src/index.css`, `app-*` classes, vars, pitfalls (Tailwind unused, `user-content` inert)
- [[cookbook_add-screen]] — procedure for wiring up a new screen (registry + router + shell)
@@ -20,6 +20,6 @@ The fix: the session starts unconditionally, in the iframe and standalone alike,
**No test exercises this path.** `@data` runs the harness inside the broker iframe; `@e2e` drives the real app inside the broker iframe too. The standalone top-level boot — the one a developer uses every day with `bun run dev`, and the one a first-time visitor hits — is covered by nothing.
So: a change to `AuthGate`, to `NextGraphProvider`, or to what `configure()` receives can break the app's entry completely while every suite stays green. If you touch any of them, **open the app standalone yourself** before believing the tests.
So: a change to `AuthGate`, to `NextGraphProvider`, or to what `configure()` receives can break the app's entry completely while every suite stays green. If you touch any of them, **open the app yourself** before believing the tests — and drive a whole flow rather than looking at the page, which is what a throwaway probe is for (`bdd-testing` → [[cookbook_live-probe]]).
Two related pieces: [[caveat_first-time-entry-untested]] (the wallet-import journey, same blind spot seen from the user's side) and [[caveat_shared-wallet-global-before-gate-import]] (a missing wallet password now makes `ensureIdentity()` throw, which at least fails loudly).
@@ -1,24 +1,36 @@
---
type: caveat
summary: currentUserId is now the profile document's NURI — the same value as currentUser.id — so the old two-id-spaces pitfall is gone; the live hazard is that it is EMPTY until the protected profile read lands, and nothing raises when a screen keys on it too early
last_checked: 2026-08-10
summary: Values a screen must not read as data once — currentUserId is EMPTY until my profile document resolves, an ownership answer can be UNKNOWN, and a useState seeded from an unresolved read (UpdateEventScreen) freezes blank; all look like ordinary values, none mean "no"
last_checked: 2026-08-17
---
# Pitfall: the current user arrives late, and empty reads like a value
# Pitfall: "not answered yet" looks exactly like an answer
## What is true now — one id, not two
Two things a screen receives can be *unresolved*, and in both cases the unresolved form reads like an ordinary value. Nothing throws.
`currentUserId` **is** `currentUser?.id`: the **NURI of the profile document** the app reads back in its own protected scope. The two are no longer distinct spaces, because the app no longer derives a principal from anything it was told — it stopped naming its own identity altogether (concept `app-security`, [[decision_2026-08-10_the-barrier-names-no-identity]]). A participation written today carries that same NURI in `fp:user`.
## `currentUserId` is empty until my profile resolves
> The earlier pitfall — a stable principal `urn:festipod:user:<key>` on one side and a profile NURI on the other, never equal in connected mode — **no longer applies to values written today**. The provider still resolves the older principal form on read (`resolveParticipantUser`, concept `data-layer` → [[knowledge_context-internals]]); a screen never sees it.
`currentUserId` **is** `currentUser?.id`: the NURI of **the profile document I own**. It is not derived from the signed-in identity and shares no id space with it — the identity the session signed in as is opaque, is never rendered, and never travels into a data call (`data-layer` → [[knowledge_context-internals]]). A screen never sees it except as an attribution string.
## The live hazard: `''` before the read lands
Until that profile document resolves — the owned-document listing has to land, and a profile may have to be created — `currentUserId` is **`''`**, a perfectly ordinary empty string.
`currentUserId` is **empty** until the protected profile read resolves — and empty is a perfectly ordinary string. Nothing throws.
- A **query** keyed on it (`getUserEvents`, `isParticipating`, `getFriends`, all defaulting to it) returns an **empty result**, which renders as "you have nothing" instead of "not ready yet".
- A **mutation** that needs it now **rejects** rather than writing a malformed entity: `joinEvent` and `leaveEvent` throw, naming the cause. A caller must therefore *await* them and handle the rejection — the confirmation belongs **after** the write, never beside the call. A screen that fires and forgets shows a success it did not get.
- A **query** keyed on it (`getUserEvents`, `isParticipating`, `getFriends` — all defaulting to `currentUserId`) returns an **empty result** rather than an error, which renders as "you have nothing" instead of "not ready yet".
- A **mutation** that needs it refuses rather than writing a malformed entity: `joinEvent` logs `empty user principal — refusing to write a participation with no fp:user` and returns. A screen that assumed the write happened shows a success it did not get.
**The rule**: treat an empty `currentUserId` as *not ready*, never as *no data*.
**The rule**: treat an empty `currentUserId` as *not ready*, never as *no data*. Gate on it before rendering an emptiness verdict or firing a mutation that stores it.
## An ownership answer can be UNKNOWN
The participation→profile join itself is **not** the screen's business: it is done in the provider (`resolveParticipantUser`). Full mechanics and the write/read invariant: concept `data-layer`, [[knowledge_context-internals]].
`getEventOwnership(eventId)` returns `'mine' | 'not-mine' | 'unknown'`, and `unknown` is a **real third answer** — the listing has not landed, or it failed (`data-layer` [[knowledge_write-rights-are-ownership]]).
Rendering it as "not yours" silently denies an owner their own event. Rendering it as a **disabled twin** of the real control is no better: a greyed pencil reads as "edit, broken" and invites a dead click. The slot stays occupied by a distinct *pending* mark, so the layout does not jump and nobody is told a wrong verdict — `app-security` → [[decision_2026-08-16_write-rights-are-the-owned-list]].
Never derive permission from `unknown` either. A screen that opens an editor because the answer "was not a refusal" is editing on a guess; the edit route consults the same three-state answer the control does, and renders `unknown` as its own pending state — [[knowledge_screen-pattern]].
> The participation→profile join is **not** the screen's business — it is done in the provider (`resolveParticipantUser`). Full mechanics: `data-layer` → [[knowledge_context-internals]].
## A `useState` seed freezes on whatever the first render saw
`UpdateEventScreen` reads `const event = eventId ? getEvent(eventId) : undefined;` from the reactive data plane, then seeds every editable field from it: `useState(event?.title ?? '')`, and likewise for `startDate`, `endDate`, `startTime`, `endTime`, `location`, `description`. A `useState` initializer runs **once**, at mount — unlike a value read directly in the render body, it does not track `event` afterwards.
If the screen mounts before the event has landed in the reactive set — a direct navigation to the edit route, a slow reconnect — every field seeds to `''` and **stays blank**: the later, successful read of `event` never reaches state that already initialized. Nothing throws and nothing looks wrong; the form is simply empty. Same hazard as `currentUserId` and the ownership answer above — "not ready yet" reads as an ordinary value — just caught by `useState` instead of by a query result. Pre-existing, not fixed.
@@ -39,6 +39,6 @@ ThemeProvider
|---|---|
| `src/index.ts` | `Bun.serve()` — HTTP server, serves `index.html` + the cucumber report |
| `src/index.html` | HTML entry point, loads `src/app/frontend.tsx` |
| `src/app/frontend.tsx` | React root, renders `<App />` |
| `src/app/frontend.tsx` | React root — pulls the runtime config, sets the wallet global, **then** dynamically imports and renders `<App />`; the order is the point (`tech-stack` → [[knowledge_build-pipeline]], `app-security` → [[caveat_shared-wallet-global-before-gate-import]]) |
The build and the bundler (Bun + Tailwind, alias `@/* → ./src/*`) are documented in the `tech-stack` concept.
@@ -34,7 +34,7 @@ Each module may contain:
| `data/` | User stories, `features.ts` (auto-generated), `seedData.ts`, `types.ts` |
| `hooks/` | empty — the reactive read binding lives in `data/useShapeQuery.ts` (concept `data-layer`) |
| `shapes/` | SHEX + ORM bindings (see concept `data-layer`) |
| `utils/` | `ngSession.ts`, `ngBootstrap.ts`, `ngGraph.ts`, `storeRegistry.ts`, `connections.ts`, `identifier.ts` |
| `utils/` | `ngSession.ts`, `ngBootstrap.ts`, `ngGraph.ts`, `storeRegistry.ts`, `connections.ts`, `identifier.ts`, `resolveOnce.ts` (single-flight resolution per key, unit-tested), `serialTask.ts` (a task that never runs concurrently with itself, unit-tested) |
| `steps/`, `support/` | Shared Cucumber step definitions and hooks (concept `bdd-testing`) |
| `lib/` | Helpers (`cn`, etc.) |
@@ -1,7 +1,7 @@
---
type: knowledge
summary: Canonical anatomy of a screen — named function with no props, reads everything through useFestipodData/useNavigate/useParams, flex column layout (Header / scrollable content / BottomNav), feedback via showToast, hard-coded French labels; zero-prop rule has no exception left
last_checked: 2026-08-10
summary: Canonical anatomy of a screen — named function with no props, everything through useFestipodData/useNavigate/useParams, flex column layout, hard-coded French labels; the confirmation FOLLOWS the write, and a write affordance is decided by the three-state ownership answer
last_checked: 2026-08-16
---
# Canonical screen pattern
@@ -18,9 +18,10 @@ export function MyScreen() { // named function, NEVER any props
const [local, setLocal] = useState(); // screen-local state (steps, selections)
const handleAction = () => {
// …mutate through useFestipodData
showToast('Message', 'success'); // feedback
navigate('/path');
// THE CONFIRMATION FOLLOWS THE WRITE — never beside the call.
void Promise.resolve(mutate())
.then(() => { showToast('Message', 'success'); navigate('/path'); })
.catch((err: unknown) => { console.error(); showToast('Échec…', 'error'); });
};
return (
@@ -36,7 +37,9 @@ export function MyScreen() { // named function, NEVER any props
## Invariants
- **Zero props, no exception left**: every registered screen receives nothing; everything comes from context/hooks (`useFestipodData`, `useNavigate`, `useParams`). `WelcomeScreen` does not use `useFestipodData` (intro) — but still takes no props. (`LoginScreen`/`ConnexionScreen`/`AccessGateScreen` no longer exist — Festipod renders no access screen of its own; see [[knowledge_screens]] and [[knowledge_app-shell]].)
- **Identity: the current user may not be there yet.** `currentUserId` is the profile document the app reads back in its own protected scope, so it is **empty until that read lands** — see [[caveat_identity-ids-in-screens]] before keying anything on it.
- **Identity: the current user may not be there yet.** `currentUserId` is the NURI of the profile document this session **owns**, so it is **empty until that document resolves** — see [[caveat_identity-ids-in-screens]] before keying anything on it.
- **The confirmation FOLLOWS the write.** Mutations reject rather than returning quietly, so a screen must `await` (or `.then`) before announcing anything: success toast and navigation on resolve, an error toast on reject, and the user kept on their edits. A toast fired beside the call announces a write that may never have happened — the pattern that had to be corrected on the event and profile edit screens.
- **A write affordance is decided by ownership, in three states.** Both the control that *offers* the write and the screen that *performs* it consult the same answer (`data-layer` → [[knowledge_write-rights-are-ownership]]). `unknown` is never folded into either side: showing the form lets a non-owner edit on a guess, and bouncing them out tells a genuine owner their thing is not theirs. Render it as its own pending state.
- **Layout**: full-height flex column; `Header` at the top, content at `flex:1; overflow:auto`, `BottomNav` at the bottom **only for hub screens** (Home, Events, Profile, Friends). Flow screens (creation, editing, detail) have no `BottomNav`.
- **Feedback**: `showToast(message, 'success'|'info'|'error')` (`ToastContainer` mechanism exported by `sketchy/`).
- **Labels**: **French, hard-coded** — no i18n, no translation keys anywhere in the project.
@@ -11,6 +11,7 @@ triggers:
Festipod's **security, privacy and authorization** model.
- **Enforced model** — **isolation between scopes** (public / protected / private) is **enforced by the data SDK** (`@ng-eventually/polyfill`), which exposes to each user only what they are entitled to. The app **trusts** the SDK: no screen carries authorization logic. See [[knowledge_trust-model]].
- **Write rights** — only a document's owner writes it, and the owned-document listing is the whole answer; the app reads it to decide what to *offer*, never to enforce. Settled: [[decision_2026-08-16_write-rights-are-the-owned-list]].
- **Target authorization matrix** — the detail of *who may do what* per actor × verb (personal data = network, anonymity through the notification inbox): [[brief_2026-05-18_authorization-matrix]]. **Incubating.** It will graduate into `rule_`/`behavior_` as the product settles.
## Pitfalls
@@ -25,5 +26,6 @@ Festipod's **security, privacy and authorization** model.
- [[knowledge_authentication]] — wallet-based auth, everyone authenticated, no anonymous access, no screen of Festipod's own
- [[decision_2026-08-10_the-barrier-names-no-identity]] — the app names no identity: the barrier takes nothing, signing in is one `ensureIdentity()`
- [[decision_2026-08-10_sdk-renders-the-barrier]] — Festipod renders no access screen of its own; the SDK draws whatever a first-time device needs to see
- [[decision_2026-08-16_write-rights-are-the-owned-list]] — may-I-write is the owned-document listing and nothing else; no capability probe is planned
- [[brief_2026-05-18_authorization-matrix]] — target authorization matrix (incubating)
- Concept `functional-domain` → [[knowledge_data-scopes-and-discovery]] — which scope for which entity (product fact)
@@ -1,6 +1,6 @@
---
type: brief
summary: Target authorization matrix per data type (meeting point, registration, event, profile, connection) expressed as public/protected/private + dialog scopes; settled framing decisions (everyone authenticated, public meeting points, personal data = network, notification through an identified-or-anonymous inbox); open questions on the event write model and on host identity
summary: Target authorization matrix per data type (meeting point, registration, event, profile, connection) mapped onto the public/protected/private/dialog scopes; framing decisions settled and event update now settled as owner-only; host identity and event deletion still open
last_updated: 2026-05-18
---
@@ -68,10 +68,10 @@ Notes: no `C` differentiation (connections are a UI display filter, not a right,
|---|---|---|---|
| create | ✓ (becomes declarer) | — | ✓ (becomes declarer) |
| read / subscribe | ✓ | ✓ | ✓ |
| update | ? **to be decided** | ? **to be decided** | ? **to be decided** |
| update | ✓ (owner, sole writer) | ✗ | ✗ |
| delete | ? **to be decided** | ✗ | ✗ |
**Open questions:** who may **update** a declared event — the declarer alone (owner)? every user (wiki)? nobody (immutable)? Central to deduplication (concept `functional-domain`, [[brief_2026-06-15_event-deduplication]]). Who may **delete** it, and what becomes of the grafted meeting points (orphaned/cascade/marked deleted)?
**Update is settled — owner only**, and forced rather than chosen: only a document's owner writes it and no call adds a writer, so "wiki" is not expressible ([[decision_2026-08-16_write-rights-are-the-owned-list]]). It constrains deduplication (concept `functional-domain`, [[brief_2026-06-15_event-deduplication]]). **Open:** who may **delete** an event, and what becomes of the grafted meeting points (orphaned/cascade/marked deleted)?
### User profile
@@ -1,7 +1,7 @@
---
type: caveat
summary: The wallet password is captured at the EVALUATION of src/shared/utils/sharedWallet.ts; a value set after that first import is never re-read — a missing one used to yield AccessGateScreen's error block, now it makes ensureIdentity() throw and the app render nothing at all, silently
last_checked: 2026-08-10
summary: The wallet password is captured at the EVALUATION of src/shared/utils/sharedWallet.ts a value set after that first import is never re-read, and the fetch that sets it must never be gated on NODE_ENV (production serves from src/). Missing it: ensureIdentity() throws, AuthGate shows the reason.
last_checked: 2026-08-16
---
# Pitfall: set the wallet-password global BEFORE the module is first imported
@@ -15,8 +15,8 @@ The contract requires a deployment to **serve a wallet file and pass its URL and
## Impact — if I touch X, Y breaks
- **Static import = trap.** A static `import` reaching `ngSession.ts` (hence `sharedWallet.ts`) from an entry point that sets the global itself is **hoisted above the assignment** → empty password → the failure mode above, with no JS error at the import site to signal it. The remedy is a **dynamic import** (`await import(...)`) executed after setting the global.
- **The real entry point that must get this right**: the frontend served from `src/` (`src/app/frontend.tsx` fetches `/festipod-config.json`, sets the global, then imports `App` dynamically — mechanics in `tech-stack` → [[knowledge_build-pipeline]]). A bundle produced by `build.ts` is **not** concerned: there the value is inlined by `define`.
- **The real entry point that must get this right**: the frontend served from `src/` (`src/app/frontend.tsx` fetches `/festipod-config.json`, sets the global, then imports `App` dynamically — mechanics in `tech-stack` → [[knowledge_build-pipeline]]). That fetch may be skipped on **one** condition: the global is already set, which only a `build.ts` bundle's `define` does. **Never on an `NODE_ENV` test** — this project's production serves from `src/` exactly like dev, so gating the fetch on "production" is what removed the wallet from the deployed app and left it unable to sign anybody in.
- **`@ui` reaches the module too, but harmlessly today.** `screens/index.ts` eagerly imports every screen including `SettingsScreen`, which imports `ngSession.ts` — so any `@ui` test already evaluates `sharedWallet.ts` with the global unset. This does not currently break anything because no `@ui` path calls `ensureIdentity()` (`renderScreen()` bypasses `AuthGate`/`NextGraphProvider` entirely); see `bdd-testing` → [[knowledge_ui-layer]] for the detail and for what would make it stop being harmless.
- **Operations**: a server without `FESTIPOD_SHARED_WALLET_PASSWORD` now fails **silently** (blank page, console-only) rather than with a screen saying so — worth knowing when diagnosing "the app shows nothing."
- **Operations**: a server without `FESTIPOD_SHARED_WALLET_PASSWORD` cannot sign anyone in, and it **says so**`ensureIdentity()` rejects and `AuthGate` renders its named error panel carrying the reason. Verified live on a rejected sign-in: a refusal shows the reason, not a blank page (`bdd-testing` → [[cookbook_live-probe]]). The failure mode still worth fearing is the **silent** one: a promise that never settles either way renders nothing at all and logs nothing — `app-architecture` → [[caveat_boot-unverified-outside-broker]].
**Verified (2026-08-10)**: capture at evaluation time in `src/shared/utils/sharedWallet.ts`; the `sharedWallet: hasSharedWallet() ? {...} : undefined` branch in `ngSession.ts`'s `configure()` call; the `throw` in `ensureIdentity()` when no `sharedWallet` config is present; `AuthGate`'s `.catch(err => console.error(...))` with no fallback UI.
@@ -0,0 +1,30 @@
---
type: decision
summary: May-I-write is answered by the list of documents this session owns, and by nothing else — no "may I write this?" call is planned, now or later; the residual window this leaves open is accepted rather than closed
---
# Decision (2026-08-16): write rights are the owned list, permanently
## Context
Screens need to know whether this session may **write** an event's document, in order to offer an edit affordance at all. [[contract_polyfill-surface]] leaves exactly one reading of write rights: *"Only a document's owner writes to it. Holding its read key never grants a write"*, and, under non-guarantees, *"No delegated writing. A received key never grants a write, and no call adds a writer to a document."* Owning a document and being able to write it are therefore the same fact, and `storeRegistry.listMyEntityDocs(scope)` is the only call that reports it. No call answers "may I write this?" — the surface publishes none.
## Decision
**Ownership, read from the owned-document listing, IS the write right — and that is the permanent answer.** The project owner has ruled that **no capability probe is planned**: Festipod will not ask the provider for a "may I write this?" call, and no future one is being waited on. `listMyEntityDocs('public')` says which events are this session's, and a screen asks nothing else.
The answer a screen receives is **three-state**`mine` / `not-mine` / `unknown` — never a boolean. A rejected or not-yet-landed listing means **UNKNOWN**, and the contract is explicit that *"a rejection means 'unknown', never 'absent'"*. Collapsing it into "not mine" is how an owner gets silently told their own event is not theirs.
## Consequences accepted with it
- **UNKNOWN renders neither the control nor a greyed twin of it.** A disabled look-alike reads as "edit, broken" and invites a dead click; the slot stays occupied by a distinct pending mark, so an owner is never silently told the event is not theirs. Screen-side rule: `app-architecture` → [[caveat_identity-ids-in-screens]].
- **"Not mine" is inferred from ABSENCE**, and absence is not authoritative. The reactive read and the listing are two separate mechanisms, so an event can be on screen a moment before a listing can see it; ruled out in that window, it is only re-examined when some other unclassified event triggers a fresh listing. This residual is **deliberate and stated**, not an oversight.
- **The window is not closed**, because closing it needs either a timer — polling, forbidden by `bdd-testing` → [[rule_no-broker-polling]] — or the probe call this decision rules out. Accepting a bounded wrong answer is the arbitration; do not "fix" it with a poll.
## Rejected alternative
**Raise the missing probe as a contract gap and wait for it.** Rejected by the project owner: the contract's ownership rule is not an omission, it is the model — a document has one writer, and a list of one's own documents is a complete answer to who that is. Treating it as a gap would keep an affordance permanently provisional against a call that is not coming.
## Scope
Applies to every write-affordance question the app asks, not only the event edit pencil. How the answer is derived and where it lives: concept `data-layer` → [[knowledge_write-rights-are-ownership]].
@@ -20,9 +20,13 @@ last_checked: 2026-08-10
**Signing out.** The only logout left is the **wallet session** one (`logoutNg`, offered as « Quitter l'environnement de test » in the settings screen): it stops the shared-wallet session so the next access goes back through the broker. There is no app-level sign-out, because there is no app-level identity to sign out of.
## Who the current user IS, seen from the app
## Who the current user IS — the identity and the profile are two things
The app does not derive an identity from anything it was told; **what it is, is the profile document it reads back in its own protected scope**. That value is therefore empty until the protected read lands — the mechanics and the hazard that follows live in concept `data-layer`, [[knowledge_context-internals]] and `app-architecture` → [[caveat_identity-ids-in-screens]].
**The identity** is what `ensureIdentity()` returns, and nothing else derives it. The contract calls it **opaque**: do not parse it, split it, or render it as a readable name. Festipod holds it for display attribution and logging only, and **never passes it to a data-layer call** — no call takes one.
**The profile** — pseudo, name, initials — is **Festipod's own object**, not something the SDK knows about. "My profile" is the profile **document I own**, resolved from the owned-document listing; a failed listing leaves the answer UNKNOWN, never "none", and the app never presents somebody else's profile as mine. When a person has no profile, one is created at sign-in with placeholders that read as unset — never a plausible name, never anything derived from the identity.
The two share no id space and there is **no join between them**. The profile value is therefore empty until that document resolves — the mechanics and the hazard that follows live in concept `data-layer`, [[knowledge_context-internals]] and `app-architecture` → [[caveat_identity-ids-in-screens]].
**Vocabulary.** `username` designates the profile handle `UserProfile.username` and nothing else. `normalizeIdentifier` (`src/shared/utils/identifier.ts`) is a **pure string normalization** of that handle, applied only to `UserProfile.username` — the join between a profile and the person it belongs to, and the name given when sharing a document with a neighbour. It is never applied to the identity: normalising an identity belongs to the data layer, which the contract states outright, and no configuration hook takes it from us. It names no space, account or session.
@@ -12,6 +12,8 @@ Principles:
1. **Isolation is delegated to the SDK.** Every entity lives in the store of its **scope** (public / protected / private, see concept `functional-domain` → [[knowledge_data-scopes-and-discovery]]); the SDK **exposes to the current user only what they are entitled to**. The app assumes that whatever it receives is already authorized — privacy rests on the SDK, not on Festipod code.
2. **Screens carry no access rules.** No "is this user allowed to see this data" check in the components, nor in the data context. The public / network / private separation is a property of **placement by scope**, not of an application-level filter.
The one thing the app *does* read is **write rights**, and it reads them to decide what to **offer**, never to enforce: only a document's owner writes it, so the owned-document listing is the whole answer, in three states ([[decision_2026-08-16_write-rights-are-the-owned-list]]). Enforcement stays below — a screen that got the affordance wrong offers a doomed action, it does not open a hole.
3. **The relationship between users ("connections") is an application-level notion.** The contract publishes no connection or friendship primitive: it models reading as **key possession**, and giving someone that key is **one act**`inbox.share(doc, toUser)`, naming the document and the person. The app therefore **owns** its relationship graph (`src/shared/utils/connections.ts`) and, once a link is two-sided, **shares its own protected documents** with that neighbour. It does not delegate the notion of a relationship, only the **enforcement** of the isolation that follows from it.
What the app declares to the SDK is now **only those shares**: it declares **no identity** ([[decision_2026-08-10_the-barrier-names-no-identity]]), and it **never handles a key or an inbox address** — neither exists in app code. Sharing is also **irreversible**: the contract publishes no revocation, so an act of sharing is permanent ([[contract_polyfill-surface]]).
@@ -33,8 +33,10 @@ BDD tests written in **Cucumber/Gherkin in French** (`Etant donné`, `Quand`, `A
- [[knowledge_e2e-layer]] — the `@e2e` layer: the real app inside the iframe
- [[knowledge_multibrowser-harness]] — several isolated browsers on the shared wallet (storageState injection); the only way multi-user is exercised
- [[caveat_data-scenarios-share-one-wallet]] — a scenario cannot choose its identity, so all of them share one wallet that nothing empties: no per-scenario isolation
- [[caveat_data-suite-has-no-fixtures]] — **known, not fixed**: the fixture seed writes nothing into a connected wallet, so `@data` scenarios that assumed seeded data have none
- [[caveat_reconnexion-froide-local-vs-broker]] — a "fresh page" is not a cold start: which setup proves broker durability, and which one just re-reads local
- [[caveat_first-time-entry-untested]] — **open**: no test proves a first-time device can get into Festipod any more; the SDK's replacement barrier publishes nothing to test against
- [[decision_2026-03-12_headless-wallet-creation]] — why the test wallet is created through a headless UI
- [[caveat_source-grep-vestiges]] — leftovers from the "source analysis" era in `world.ts`
- [[cookbook_add-scenario]] — adding a scenario/step (layers, `evaluate` serialization pitfall, `@wip`)
- [[cookbook_live-probe]] — verifying a flow for real when the suite cannot answer: a throwaway Playwright probe on the real app, what it must collect, and why its findings must be written down the same day
@@ -0,0 +1,29 @@
---
type: caveat
summary: Known, not fixed — the fixture seed is off for connected wallets, so every @data scenario that assumed seeded events or profiles now runs against whatever the shared wallet happens to hold; "load test data" is a no-op that reports success
last_checked: 2026-08-16
---
# Caveat: the `@data` suite lost its fixtures
## What changed under it
No fixture is written into a connected wallet any more, by any route — a product decision enforced in one place (`concept data-layer`, [[knowledge_seed-data]]). The `@data` layer did not ask for that and was not adapted to it.
## What that does to the suite
The bridge's `loadTestData()` still resolves, and it reports **`seeded: false`** with no documents created. So:
- Scenarios that **load test data and then assert on it** (`auth/connexion-nextgraph.feature`: loading the fixtures, the "not reloaded twice" idempotence check, "the events have NextGraph identifiers") no longer have anything to assert on. The call succeeds; nothing is written.
- Scenarios whose background **assumes a seeded wallet** ("le portefeuille contient des données de test", "un événement {string} existe" — which seeds on demand when the wallet reads empty) now depend entirely on what the shared wallet happens to already hold.
- Nothing raises. A no-op seed reports success, which is the failure mode to expect: a green step followed by an assertion that finds nothing.
## What NOT to do about it
**Do not re-enable the seed for the tests, and do not add a test-only bypass of the enforcement point.** The switch is enforced at `bootstrapWallet` precisely so no caller can walk around it, and a harness is a caller like any other. **Do not weaken the affected scenarios into something that passes** either.
The suite needs scenarios that **create what they need through the app's own path** (the same `createEvent` / `joinEvent` a user drives), rather than a background that assumes a pre-populated wallet. That is the direction; it is not done.
## Related
This compounds [[caveat_data-scenarios-share-one-wallet]] — scenarios already could not choose their identity or start from a clean slate, and now they cannot furnish that slate either. Both are open.
@@ -22,8 +22,15 @@ summary: How to add a BDD scenario/step — a tagged French .feature, steps per
```
Always `await` (forgetting it means asserting before the promise resolves).
6. **If you add a data operation**: expose the helper on `window.__testData` in **both** harnesses (`src/shared/test-harness/harness.tsx` AND `harness-ng.tsx`) — otherwise the mock fallback drifts away from the real broker.
6. **⚠️ `waitForFunction` timeout goes in the THIRD slot, not the second.** Playwright's signature is `waitForFunction(pageFunction, arg, options)`. Passing `{ timeout: N }` where `arg` belongs is **not an error**: it is accepted as the page function's *argument*, no options are supplied, and the wait silently uses the **30 s default** while the source reads 5, 10 or 60. When there is no argument to pass, the slot must be filled explicitly:
```ts
// ❌ await frame.waitForFunction(fn, { timeout: 10000 }) // waits 30 s
// ✅ await frame.waitForFunction(fn, undefined, { timeout: 10000 }) // waits 10 s
```
This had gone unnoticed on **seventeen** calls at once, nine of which meant to wait *less* than the default. It is worth honouring the written number: a wait that is too short fails loudly and names its step, whereas thirty seconds obtained by accident hides a real slowness and makes the source lie. Same family as the pitfall above — both are Playwright argument slots that accept the wrong thing without complaining.
7. **Wire up a screen under test**: if the French screen name does not resolve to its `id`, add an alias in `screenNameMap` (`src/shared/steps/ui/navigation.steps.ts`).
7. **If you add a data operation**: expose the helper on `window.__testData` in **both** harnesses (`src/shared/test-harness/harness.tsx` AND `harness-ng.tsx`) — otherwise the mock fallback drifts away from the real broker.
8. **Run**: `bun run test:cucumber` (everything) or `bun run test:data` (@data). Report: `reports/cucumber-report.html`. `@data`/`@e2e` require the test wallet (`bun run test:auth-setup` on the first go if needed, otherwise it is created automatically — see [[decision_2026-03-12_headless-wallet-creation]]).
8. **Wire up a screen under test**: if the French screen name does not resolve to its `id`, add an alias in `screenNameMap` (`src/shared/steps/ui/navigation.steps.ts`).
9. **Run**: `bun run test:cucumber` (everything) or `bun run test:data` (@data). Report: `reports/cucumber-report.html`. `@data`/`@e2e` require the test wallet (`bun run test:auth-setup` on the first go if needed, otherwise it is created automatically — see [[decision_2026-03-12_headless-wallet-creation]]).
@@ -0,0 +1,30 @@
---
type: cookbook
summary: How to verify a flow for real when the suite cannot answer — a throwaway Playwright probe that boots the REAL app in a real browser against the real broker and drives the UI as a user does; what it must collect, and why its findings must land in doctrine the same day.
last_checked: 2026-08-16
---
# Driving the real app with a throwaway probe
A **probe** is a one-off Playwright script, outside Cucumber — no World, no hooks, no fixtures — that boots the **real app** in a real browser against the **real broker** and drives its interface the way a user does. You write it, you run it, you read it, you delete it.
## When to reach for one
Before believing a flow works. The create-and-participate flow had been declared *correct by construction* on typecheck, build and reading; the first probe ever run against it found **three defects** none of those could see — all three now fixed, though one left a residual one-connection display lag whose cause sits outside the app ([[caveat_participant-count-one-connection-lag]] in `data-layer`).
Reach for it when the suite cannot answer the question: the `@data` run dies silently from around its sixth scenario ([[caveat_wallet-bloat-hang]]), its scenarios have no fixtures ([[caveat_data-suite-has-no-fixtures]]), and entry paths are covered by nothing ([[caveat_first-time-entry-untested]], `app-architecture` → [[caveat_boot-unverified-outside-broker]]).
## Method
1. **Drive the app's own interface, never a bridge.** A probe that calls into the data context proves the data context. The whole point is the collaboration between the layers, so the only inputs are the ones a user gives — clicks, typing, waiting — and the only outputs are the ones a user sees.
2. **Reuse the boot the `@e2e` layer already documents** ([[knowledge_e2e-layer]]) rather than inventing one: the app server on its own port, the broker round-trip, the app in its iframe. Do not build a second way in.
3. **Collect `pageerror` and `console` from the first navigation.** The findings that matter surface as a rejection raised *inside a layer you never called* — invisible on screen except as a panel saying something failed.
4. **Time the steps you assert on.** "The toast landed after the write" and "1.8 s" are two different findings; the second is what makes a later regression legible.
5. **Keep watching after the confirmation, then reconnect.** A step that reports honestly can still leave the flow wrong. Give the state a real interval (minutes, not a tick), then come back through a fresh load — most of what a probe finds lives after the point where a test would have asserted green.
6. **Say what state you started from.** A **brand-new origin with a brand-new identity** is what separates a real defect from accumulated wallet state, and a finding reported without it is not yet a finding. Report the run count too (*"3 of 3"*).
## What a probe is not
It is **not a regression guard**: nothing re-runs it, and a deleted script protects nothing. Its whole value is converted at the end of the run, into doctrine or a `bug_` leaf, the same day — a probe run that is not written down bought nothing. Recording an observation, mark **VERIFIED** (seen, with the run count) apart from **INFERRED** (the explanation you reached for); a real symptom does not certify its diagnosis.
> **The lesson that pays for the method: honest steps do not add up to an honest flow.** Every step of the sign-up reports truthfully — the mutation rejects rather than lying, the confirmation follows the write — and the flow as a whole still announces a success it does not obtain. No layer can see that from inside itself; only exercising the whole thing end to end shows it.
@@ -1,7 +1,7 @@
---
type: knowledge
summary: The @data layer — Playwright drives Chromium (persistent profile) into the real broker, which loads harness-ng.tsx in an iframe; automated wallet lifecycle, window.__testData bridge, mock fallback; the harness signs in exactly as the app does, and per-scenario isolation is currently ABSENT
last_checked: 2026-08-10
summary: The @data layer — Playwright drives Chromium (persistent profile) into the real broker, which loads harness-ng.tsx in an iframe; automated wallet lifecycle, window.__testData bridge, mock fallback; the harness signs in as the app does, per-scenario isolation is ABSENT and the seed now writes nothing
last_checked: 2026-08-16
---
# The `@data` layer (real broker)
@@ -35,8 +35,8 @@ Cucumber → Playwright (Chromium, persistent profile)
- **HTTP server** started in `BeforeAll` (auto-assigned port), serving the HTML plus `/harness.js` (separate files — an inline script breaks because of special characters in the bundle).
- **The bridge is the real app path (per entity).** Since the move to *one document per entity* (concept `data-layer`, [[rule_document-per-entity]]), the `window.__testData` bridge (`events`/`users`/`participations`, `joinEvent`/`leaveEvent`/`isParticipating`/`getEventParticipants`, `loadTestData`) **delegates to the app's data context** (`appData` through `FestipodDataProvider`) — this is the real per-entity path the screens use, not a read at root-store level. It reads `appData` through a **live ref** (a captured snapshot goes stale after a seed re-render).
- **The harness signs in exactly as the app does.** It mounts `NextGraphProvider > FestipodDataProvider`**no identity provider, no default login** — and awaits the single `ensureIdentity()` before exposing the bridge, mirroring the order `AuthGate` imposes (concept `app-security`, [[decision_2026-08-10_the-barrier-names-no-identity]]). Nothing may read before it resolves. The low-level probes that used to reach past the app path are **gone**, along with the scenarios whose subject was the SDK rather than Festipod ([[rule_tests-validate-festipod-not-the-sdk]]).
- **Identity before writing.** A `Participation` has a mandatory `fp:user`; the current user is **the profile document read back in the protected scope**, so it lags behind the public events. Steps wait for `ensureCurrentUser()` before `joinEvent` (otherwise the mutation refuses, or writes a participation with no user → dropped on read) and then wait (`waitForFunction`) for the participation to be read back.
- **Identity before writing.** A `Participation` has a mandatory `fp:user`, and what goes in it is **the profile document this session owns** — resolved from the owned-document listing, and created at sign-in when there is none, so it lags behind the public events. Steps wait for `ensureCurrentUser()` before `joinEvent`, then wait (`waitForFunction`) for the participation to be read back. Waiting is no longer optional politeness: `joinEvent` and `leaveEvent` now **reject** when the profile is unresolved, so a step that fires too early fails loudly instead of passing over a write that never happened.
- **Per-scenario isolation is currently ABSENT — read [[caveat_data-scenarios-share-one-wallet]] before trusting a green run.** The `Before` hook still mints `this.freshIdentifier` and injects it into `localStorage['festipod.account.identifier']`, and several steps re-inject it, but **nothing reads that key any more**: no published call takes an identifier. Every scenario therefore runs as the same identity on one accumulating wallet. That machinery is inert, not load-bearing — do not build new setup on it, and do not "repair" it by making the app honour the key again.
- The old per-scenario reset (`resetDataState()`, a SPARQL DELETE on the anchor graph) was dropped for cost (up to 10 s of the `Before` hook's 60 s budget, already eaten by the broker login) and its helper is gone too.
- The **physical** growth of the shared wallet was never bounded by any of this — see [[caveat_wallet-bloat-hang]] (profile to be moved aside when reads start to hang).
- The connected seed stays **lightweight** (few docs): creating a document is a serial round trip, so the seed's cost is linear in the number of documents it writes.
- **The bridge's `loadTestData` no longer writes anything.** No fixture reaches a connected wallet by any route, and the enforcement point is deliberately un-bypassable — so the call resolves, reports nothing seeded, and every scenario that assumed seeded events or profiles is now running on whatever the shared wallet already holds. Read [[caveat_data-suite-has-no-fixtures]] before diagnosing an empty assertion, and do not re-enable the seed for the tests.
@@ -10,6 +10,7 @@ last_checked: 2026-08-10
- Helper: `src/shared/test-harness/renderHelper.tsx` (installs the happy-dom globals, wraps the screen). Invoked from `world.ts:renderCurrentScreen()` on every `navigateTo(...)`.
- Deterministic fixtures (`src/shared/data/seedData.ts`, see concept `data-layer`): `Marie Dupont`/`@mariedupont` = currentUser, `Jean Durand`/`@jeandurand` exists, 5 events, and so on.
- **`@ui` is untouched by the connected-wallet seed switch.** No fixture may be written into a *wallet* any more, but `@ui` renders the fixtures straight into React state through `LocalDataProvider` and writes to nothing — so these fixtures are unchanged and stay the layer's ground ([[caveat_data-suite-has-no-fixtures]] is a `@data` problem only).
## Good assertion patterns
+13 -7
View File
@@ -12,23 +12,29 @@ How Festipod **persists its data** through NextGraph (P2P, local-first, end-to-e
> **SDK boundary.** `@ng-eventually/polyfill` is injected **exactly once** through `ngSession.configure(...)`. The pulled contract is the whole of what this repo knows about it: never describe here how the data layer is implemented underneath. See [[rule_app-uses-sdk-surface-only]].
## Model & data
## Interfaces (one folder per interface, engagement + our declaration)
- [[contract_polyfill-surface]] — **the data contract, PULLED from the provider and version-pinned**: the `@ng-eventually/polyfill` surface the app codes against, what it guarantees and what it refuses to promise. The ONLY reference — never open the provider's own sources.
Each external interface this concept consumes lives in **its own folder**, holding the provider's engagement (pulled, version-pinned) and — once Festipod actually consumes it — our own declaration beside it.
- `polyfill-surface/` — [[contract_polyfill-surface]], **the data contract, PULLED from the provider and version-pinned**: the `@ng-eventually/polyfill` surface the app codes against, what it guarantees and what it refuses to promise. The ONLY reference — never open the provider's own sources. Beside it, [[usage_festipod]] — what the app *actually* calls, the conditions it needs, and the frictions measured against the engagement. **Frictions are how a need reaches the provider**: put it there, then signal it out of band.
- `indexing-layer/` — [[contract_indexing-layer]], the `@ng-helpers/indexing` engagement, PULLED and pinned on `v1.0.0`: creating an index, depositing references into it, curating it, reading it back. **Nothing consumes it yet** — no declaration is authored beside it, deliberately, since an empty one would say nothing.
## Model & data
- [[knowledge_nextgraph-stack]] — the SHEX shapes, the reactive ORM bindings, `build:orm`, injection through `ngSession`
- [[knowledge_data-modes]] — connected (SDK) vs disconnected/demo (seeded local state), how the provider is chosen
- [[knowledge_entities]] — the `Fp*` types and their SHEX shapes
- [[knowledge_seed-data]] — seed data, `CURRENT_USER_ID`
- [[knowledge_context-internals]] — pitfalls of `FestipodDataContext` (who the current user is and when it arrives, the legacy principal space, dev auto-seed, `participantCount`, local no-op)
- [[knowledge_entities]] — the `Fp*` types and their SHEX shapes; the generated ORM names carry no `Fp` prefix and are aliased at the import sites
- [[knowledge_seed-data]] — the fixtures, and the master switch that keeps them out of any connected wallet
- [[knowledge_context-internals]] — pitfalls of `FestipodDataContext` (identity vs profile, which profile is mine and when it arrives, no silent success, the legacy participation id space, `participantCount`, local no-op)
- [[knowledge_write-rights-are-ownership]] — may I write this? is answered by the owned-document listing, in three states
## Write rules
- [[rule_document-per-entity]] — every entity gets **its own document** (per scope), never one at store level; access is granted per document, so this is what makes isolation possible
- [[rule_app-uses-sdk-surface-only]] — the pulled contract is the only reference; a gap in it is raised with the provider, never worked around here
## Pitfalls (read before touching deletions / event fields)
## Pitfalls (read before touching deletions / the participant count)
- [[caveat_participation-deletion]] — withdrawal must be **authoritative** and must not come back
- [[caveat_event-fields-not-persisted]] — `startTime`/`themes`… not covered by the Event shape → lost when connected
- [[caveat_participant-count-one-connection-lag]] — `participantCount` lags one connection behind the write that produced it; cause is outside the app, no app-side compensation
> Confidentiality (scope isolation, trusting the SDK): concept `app-security`. Product scopes per entity + discovery: concept `functional-domain`.
@@ -1,19 +0,0 @@
---
type: caveat
summary: The FpEventData type and the seed carry startDate/endDate/startTime/endTime/themes, but the Event SHEX does not define them — these fields are silently lost in connected mode (NextGraph)
last_checked: 2026-06-15
---
# Caveat: event fields not persisted in connected mode
The app type `FpEventData` (`src/shared/data/types.ts`) and the seed (`seedData.ts`) carry the fields **`startDate`, `endDate`, `startTime`, `endTime`, `themes`** — but the **SHEX `Event` shape** (`src/shared/shapes/shex/festipodShapes.shex`) does **not** define them. The shape covers exactly (verified 2026-08-10 in the `.shex`): `title, description, date, location, distance, participantCount, coverImage, hostName, hostInitials`, plus an optional `inbox`.
> That `inbox` field is a **vestige, and it must stay unused**: it was there to publish an event's inbox address so others could deposit into it. The app no longer handles an inbox address anywhere — a deposit **names the document** (`inbox.postToDocument(doc, …)`) and the owner opens its own with `openDocumentInbox(doc)`. Writing an address into the entity would put back exactly what the surface removed ([[rule_document-per-entity]]).
## Consequence
In **connected mode** (SDK), the mapping (`mapEvent` in `FestipodDataContext.tsx`) only reads/writes the shape's fields. Fields outside the shape are **silently lost**: filled with defaults, or empty. Yet screens **do display them** (e.g. `startTime`/`endTime` in `EventDetailScreen`) — so in demo mode (the local seed) they show up, but when connected they vanish. The discrepancy is only observable in actual use.
## To fix it (if we want them persisted)
Add the fields to `festipodShapes.shex`, then `bun run build:orm`, and extend `mapEvent`. Until that is done, **do not rely on the date/time/theme fields in connected mode**.
@@ -0,0 +1,37 @@
---
type: caveat
summary: After a sign-up (or a withdrawal) the participantCount a bystander sees needs one connection more than the write itself — written on the first reconnect, displayed on the second. Cause is outside the app, in the layer not notifying you of your own actions; no app-side compensation, deliberately.
last_checked: 2026-08-17
---
# Caveat: the participant count lags one connection behind the write that produced it
In the create-and-participate flow — declare an event, sign up to it — the event's `participantCount` **stays at 0 for the rest of the session**, VERIFIED over two-minute intervals, while the button already reads « ✓ Je participe ». The count starting at 0 on creation is correct and is not this caveat ([[knowledge_context-internals]] §participantCount: no host baseline).
## The convergence, VERIFIED
The count does converge, but **one connection later than the write**: the first reconnect after the sign-up still reads 0; the count only reaches the true value on the **second** reconnect. The same one-connection lag applies to a withdrawal — earlier it looked like withdrawal converged immediately while sign-up never did, but that asymmetry was the multi-inbox race below, not a separate mechanism: with one inbox per document, both paths share this same lag.
## Two measured causes, both about the layer not notifying you of your own actions
- A deposit you make into an inbox **you watch** produces no push — so the owner's own materializer, sitting on its own inbox, is not woken by its own sign-up.
- A write to **your own document** is not re-read by `watchShape` in the writing session — so the materializer's own count write does not come back on the load that made it, only on the one after.
Both are gaps in [[contract_polyfill-surface]], raised with the provider ([[rule_app-uses-sdk-surface-only]] in this concept) — not something to work around in the app.
## Why nothing is done about it here
Any retry or short-interval poll to paper over the gap is exactly what `bdd-testing` → [[rule_no-broker-polling]] forbids. The count is not lost — the materializer fires directly on connection, not only on a push, so it always catches up on the second reconnect — so there is nothing to compensate for beyond the one connection of delay.
## What this is not
Not data loss, not a race: [[knowledge_context-internals]] §participantCount describes the concurrency-safety the flow now has (one inbox per document, one materialize cycle at a time, a monotonic guard against a stale write). This caveat is the residual display delay that mechanism does not close, because its cause sits below it.
## Reproduce
1. Connect, declare an event (the count shows 0 — correct).
2. Sign up to it; the button reaches « ✓ Je participe ».
3. Stay on the page and watch the count for a couple of minutes — it stays at 0.
4. Reconnect once — still 0. Reconnect a second time — now correct.
Method: `bdd-testing` → [[cookbook_live-probe]]. Watching *after* the confirmation, over a real interval, and across two reconnects, is what makes this visible at all.
@@ -0,0 +1,33 @@
---
type: decision
summary: Public events become findable through a shared index (@ng-helpers/indexing) rather than a direct read of the public scope, which never actually unioned every user's store; the package's append-only, curation-gated, never-refreshed shape is accepted as-is, with four costs named rather than solved
---
# Decision (2026-08-17): discovery through a shared index
## Context
[[knowledge_data-scopes-and-discovery]] (concept `functional-domain`) named "reading the `public` scope" as the primary discovery axis. [[contract_polyfill-surface]] shows why that never delivered cross-user discovery: `storeRegistry` places and lists documents **per session** (`listMyEntityDocs`, `resolveScopeGraph` — both scoped to "this session's own"), and no published call unions every user's public store into one readable set. A declared event was therefore reachable by its own declarer only, and the whole cross-user sign-up flow — the product's premise — was unreachable.
## Decision
Festipod adopts **`@ng-helpers/indexing`**, pinned at `1.0.0` ([[contract_indexing-layer]]), as the mechanism that makes a public event findable by someone other than its declarer.
An index is an ordinary public document that the package builds on top of the polyfill: nothing marks it as one, so Festipod will hardcode its reference in the app's own source. Depositing a reference to an event into the index (`refer`) is open to anyone; only the index's owner turns deposits into visible entries (`curate`); `read` returns those entries ordered by one declared field, compared **as strings**. Festipod indexes on the event's **ISO-8601 start date** specifically because string comparison then sorts entries chronologically for free — that field is being added to the event shape by other work in parallel and is not yet written by any create/update path.
**No code consumes the index today.** This decision records the arbitration and its accepted costs ahead of the wiring: which identity owns and curates Festipod's index, and where `refer`/`curate`/`read` are called from, are not yet decided.
## Consequences accepted with it
- **Curation is a role, not a line of code.** Nothing lands in the index until its owner curates the deposits, and the package schedules no curation run — there is "no timing and no delivery promise" ([[contract_indexing-layer]] → Non-guarantees). Someone, or something, must be relied on to curate; that is an operator commitment this decision takes on, not a gap left for later code to close.
- **An event declared before its document could carry the indexed field can never be indexed.** `read` refuses a document that declares no field at all, and curating a reference to an object missing the field reports `skipped: "no-field"` — every run, forever, since a deposit is never consumed and an already-written document does not retroactively gain a field it was not written with. There is no way back into the index for those events short of a fresh index.
- **A withdrawn or corrected event stays listed.** The package removes nothing "at any level, ever" — the only answer to a bad entry is a fresh index, not a fix to this one. Whatever eventually reads Festipod's index must tolerate an entry whose object no longer resolves, or resolves to something changed; that tolerance is the app's to build, the package provides none of it.
- **An entry's position is frozen at the moment it was curated.** The index never re-reads an already-indexed object, so the value it sorts by is whatever that object held at curation time — a later correction to the real event's start date does not move its entry. `read`'s ordering is faithful to the index, not to the live object.
## Rejected alternative
**Wait for the polyfill to publish a cross-store read** — a call that would union every user's `public` scope into one set, restoring the assumption the app started on. Rejected: nothing in [[contract_polyfill-surface]] offers this and none is signalled as coming, and the app cannot leave its central discovery flow unreachable while waiting on a capability nobody has committed to.
## Scope
Applies to **event** discovery only — the axis this decision replaces. Meeting-point and profile discovery are unaffected. Product framing and the four costs restated for a domain reader: concept `functional-domain` → [[knowledge_data-scopes-and-discovery]]. Package surface and guarantees: [[contract_indexing-layer]].
@@ -0,0 +1,158 @@
---
type: contract
summary: The API @ng-helpers/indexing exposes to an application — creating an index, depositing references into it, curating it, and reading it back
pulled_from: https://gitea.reconnexion.apps.gueraud.net/Sylvain/ng-helpers.git/.project/concepts/indexing/indexing-layer/contract_indexing-layer.md
pulled_version: 2ce21131575f66af09f28ee109ad712aa41173ae
pulled_at: 2026-08-17
---
# contract_indexing-layer — `@ng-helpers/indexing`
## Scope
This package builds an **index** on top of NextGraph: an ordinary public document that holds one entry per indexed object, keyed by that object's NURI and carrying its value for a single declared field.
It covers creating an index, handing an index a reference to an object (open to anyone), the owner resolving those references and adding what it can, and reading the entries back in order.
It does not cover NextGraph itself — documents, identity, sharing, inboxes, transport — all of which reach it through a port you supply. It does not cover search, filtering, pagination, or querying by anything but the index's own field. It **never removes anything**, from anywhere, and that is a property of the engagement rather than a missing feature.
### Deployment requirements
An application using this package must:
- have a NextGraph session already open under the identity it wants to act as, and build the port from it — `polyfillPort({ sessionId })`, where `sessionId` is what `@ng-eventually/polyfill`'s own `init(…)` hands its callback;
- reach a broker, since every operation here is a document read, a document write, or an inbox deposit;
- **supply `@ng-eventually/polyfill` itself.** This package declares it a *peer*, not a dependency: the application names it among its own dependencies and decides which copy it gets. That copy must be the very one the application's own code calls, because everything this package does passes through it — and that package requires exactly one instance of itself in an application, for reasons its own contract states.
- **hardcode the index's NURI in its own source.** Nothing marks a document as an index; the reference is what makes it one, and it is the only way anyone reaches it.
One handle is one identity: the port carries a session and no call takes an identifier. Two users mean two handles.
**Obtaining it.** This package is not published to npm, nor to any other package host, and it is not distributed as built output: its published entry point is TypeScript source, so whatever builds the application is what compiles it, and a toolchain that accepts only JavaScript cannot consume it as it stands. `@ng-eventually/polyfill` is distributed the same way. By which channel the source reaches a given application is agreed with that application rather than fixed here; what this contract fixes is the version you pin and what you must provide alongside it.
## Surface
Full typed shape: the package's `types` entry, `@ng-helpers/indexing`. The load-bearing signatures:
```ts
// ── wiring: one handle, one identity ─────────────────────────────────────────
export function polyfillPort(options: PolyfillPortOptions): NextGraphPort;
export interface PolyfillPortOptions { readonly sessionId: string | number }
export function indexing(port: NextGraphPort): Indexing;
// ── addressing (re-exported so you import them from here) ────────────────────
export type Nuri = `did:ng:${string}`;
export type NuriLike = Nuri | string;
export type { PrincipalId, UnionSubject, NextGraphPort, IncomingDeposit, ObjectResolution };
// ── everything this package does ─────────────────────────────────────────────
export interface Indexing {
/** Creates an index in THIS identity's public store and opens its inbox. Any user may.
* `field` is the predicate an indexed object must carry, declared once and for good;
* an empty or blank one throws. Returns the NURI to hardcode. */
createIndex(field: string): Promise<Nuri>;
/** Deposits a bare reference into the index's inbox. Open to ANYONE. Nothing lands in
* the index until its owner curates. Throws if the index has no inbox. */
refer(index: NuriLike, object: NuriLike): Promise<void>;
/** OWNER only — resolves the references received and adds what it can. */
curate(index: NuriLike): Promise<CurationReport>;
/** The entries, ordered by value. Sugar over `readUnion([index])`. */
read(index: NuriLike): Promise<IndexEntry[]>;
}
// ── what an index holds ──────────────────────────────────────────────────────
export interface IndexEntry { readonly object: Nuri; readonly value: string }
export interface IndexDescriptor { readonly field: string }
// ── what curating reports ────────────────────────────────────────────────────
export type CurationOutcome =
| { readonly result: "indexed"; readonly object: Nuri; readonly value: string }
| { readonly result: "unchanged"; readonly object: Nuri }
| { readonly result: "skipped"; readonly object: Nuri; readonly reason: SkipReason }
| { readonly result: "unresolved"; readonly object: Nuri; readonly reason: string }
| { readonly result: "foreign"; readonly reason: string };
export type SkipReason = "no-field" | "several-values" | "self-reference";
export interface CurationReport {
readonly index: Nuri;
readonly outcomes: readonly CurationOutcome[]; // one per deposit, in deposit order
}
// ── what travels from a depositor to a curator ───────────────────────────────
export type IndexDeposit = Nuri; // the reference IS the whole payload
export function decodeReference(payload: unknown): Nuri | null; // untrusted input
// ── the IRIs, for a reader going straight to `readUnion` ─────────────────────
export const INDEX_FIELD: string; // on the index's own subject: the field it indexes by
export const ENTRY_VALUE: string; // on an entry: that object's value for the field
```
## Guarantees
**An index is an ordinary public document, and nothing marks it as one.** It lives in its creator's public store, so any reader opens it from the reference alone; its creator owns it, and any user may create one.
**The field is declared once, inside the document, and cannot be changed.** `createIndex` refuses an empty or blank field at the door, because nothing here deletes and an index created on a useless field is useless for good. Declaring it in the document rather than in an application's source is what stops two applications curating the same index on two different fields.
**`createIndex` opens the index's inbox itself.** Only the owner can, and creation is the one moment the owner is present, so it is not left to a later call to remember.
**Depositing is open to anyone; writing is the owner's alone.** `refer` is a deposit into the index document's inbox — not a write — so a stranger can contribute to an index they do not own. `curate` reads that inbox and writes the document, and both are refused to anyone but the owner. The deposit is a **bare reference**: it carries no operation, no index reference (the inbox address already identifies the index), and no copy of the indexed value. What the object itself says is what goes in.
**An index ONLY EVER GROWS.** There is no call that removes an entry, for anyone including the owner, and none is planned. This package cannot express a removal at all. The only answer to "this entry must go" is a fresh index.
**Curation is convergent and order-independent.** Deposits are never consumed, so every run sees every deposit again; re-applying one re-resolves the reference and lands on the same result. An already-indexed object is skipped outright as `unchanged`. Nothing depends on the order references arrived in.
**A reference that does not resolve costs nothing and is reported.** It comes back as `unresolved`, nothing is written for it, and nothing already in the index is touched — a later deposit adds it. Every unresolved reference appears in `CurationReport.outcomes`: harmless is not the same as invisible.
**Reading is per-entry tolerant.** `read` returns entries ordered by value, ties broken on the object NURI, so two readers of the same index always see the same order. Values are compared **as strings** — an index whose field holds ISO-8601 dates therefore comes out in chronological order. A subject that is not a NURI is skipped, never thrown on, and only own properties are read: one stray triple cannot make every real entry unreadable.
**An entry carrying several values keeps the smallest, deterministically** — which two curation runs racing each other can produce. The entry stays visible and every reader agrees on it.
**`read` refuses a document that declares no field at all**, rather than answering "an empty index". An unreadable document and an empty one arrive as the same empty result, so an empty answer would be a failure wearing the shape of a fact. Retry before concluding the document is malformed.
**An index declaring SEVERAL fields refuses to CURATE, loudly and permanently — and stays readable.** Picking one would leave a single list ordered by two different properties, because entries already written are never re-read. Existing entries stay visible and correct; nothing new is added. The refusal cannot be undone, and it says so instead of suggesting a retry.
**Reading needs nothing from this package.** An application that knows the NURI can call the polyfill's `readUnion([index])` and get the entries as subjects — one per indexed object, keyed by its NURI — plus the index's own subject declaring its field, which `read` drops. `INDEX_FIELD` and `ENTRY_VALUE` are published for exactly that reader.
**Every inbox payload is untrusted.** Anyone may deposit anything; `decodeReference` returns `null` for everything that is not a reference, and such a payload is reported as `foreign` rather than crashing curation.
## Non-guarantees
**No removal, at any level, ever.** Not an oversight and not "not yet": it was deliberately never built. Do not design around a future delete.
**No refresh.** An already-indexed object is never re-read, so an object whose field value changes later keeps its original value in the index, indefinitely.
**No private data.** Indexing is limited to objects the curator can open itself. An object the index's owner cannot read is simply `unresolved`.
**`unresolved` does not tell you why.** Gone, unreadable, and "the read failed" arrive identically and are deliberately not distinguished. Never read it as "the object does not exist".
**The narrow behaviours are open questions, not promises.** An object carrying nothing for the field is `skipped: "no-field"`; one carrying several values is `skipped: "several-values"`; a raced entry keeps the smallest value. Each is implemented in its narrowest form and reported rather than generalised, and each may change.
**No stable error text.** What a throw or an `unresolved` reason reads is for a human reading a report. Do not parse it or branch on it.
**No timing and no delivery promise.** A deposit is not in the index until the owner curates, and nothing here schedules curation. There is no notification, no queue depth, and no ordering between a deposit and a read.
**The report grows with the inbox.** Since deposits are never retired, `CurationReport.outcomes` has one entry per deposit ever made, not per change.
**No cross-broker reach.** A NURI resolves for users of the same broker.
**No depositor authentication or rate limit.** Anyone may deposit any number of payloads into any index's inbox.
## Change policy
**Semver, and majors are the normal case.** This layer sits on a polyfill that is itself converging on a NextGraph that does not ship yet, and several of its own behaviours are declared above as open questions. Settling one of them narrows this surface — the major number will move often, and that frequency is the honest signal about this package, not an apology. Refusing to version would not slow the churn down; it would only take away the one tool you have for managing it. Pin a version, upgrade deliberately, and re-pull this contract each time.
What each level means here, in this package's own terms:
- **major** — an exported symbol is removed or renamed, **or** an existing call narrows: it now throws where it returned, or reports a state you did not have to handle before. Settling an open question counts, and so does adding a `CurationOutcome` variant or a `SkipReason` — an exhaustive `switch` in your code stops being exhaustive. A signature change a caller must react to counts; one that only accepts more than before does not.
- **minor** — a symbol is added and nothing existing moves: a new read helper, a new optional option.
- **patch** — a fix that changes neither the exported surface nor anything above under `## Guarantees`, including the text of a throw, which is explicitly disclaimed above.
**A tag says where it comes from.** A release cut on `main` carries a **full version** (`1.0.0`), and the three rules above govern what changes between two full versions. Work still on a branch carries a **pre-release** of the version it is heading for (`1.0.0-dev.3`), which sorts *below* that version by construction — so you can pin what exists today while the tag itself tells you the surface has not been released and may still move before it is. Between two pre-releases of the same version nothing is promised: re-pull and read this leaf again. When the branch lands, the full version appears alongside; the pre-release keeps resolving, so no reference you pinned is ever withdrawn from under you.
**The tag is bare — `v1.0.1` — because this repository publishes exactly one engagement**, so there is nothing for a prefix to disambiguate. Should a second one ever ship here, tags take the package name from that point on (`indexing/v…`), because a bare tag stops saying which surface it froze the day two versions move independently. Bare tags already laid stay valid as history.
`1.0.0` was a baseline, not a claim of maturity: it was the number that made your pin mean something. Nothing was released before it. **It could not be installed, however**, and `1.0.1` supersedes it. `1.0.0` declared `@ng-eventually/polyfill` as a dependency resolved through a path that existed only in one working copy, so every attempt to install it from anywhere else failed outright — not on some operations but at the install itself, which is why no application ever ran it. `1.0.1` declares that package a peer, which the application supplies. Nothing exported moved, which is what makes this a patch and not a major: the only thing that changed for a caller is a requirement it could never have satisfied before, so there is no working arrangement for it to break.
**`1.0.0` is superseded, not withdrawn.** The tag stays where it is and keeps resolving, because no pinned reference is ever taken away from under you — this contract's policy holds even for a version that never worked. Nothing forces an upgrade; it is simply that an installation pinned there cannot have succeeded, so there is nothing to migrate.
This engagement is cut on `main`, so `1.0.1` is what you pin, and your `usage_` leaf anchors `against:` on that exact string — `against: @ng-helpers/indexing@1.0.1`. Had you pinned a pre-release, `against:` would carry that string, pre-release suffix included.
There is no changelog file and no deprecation window: **the sections above are the release note.** A removal or a narrowing lands in `## Surface` and `## Guarantees` in the same version that ships it. Diff this leaf between two pulls — `## Guarantees` and `## Non-guarantees` before `## Surface`, because that is where a narrowing shows up first.
@@ -1,94 +1,95 @@
---
type: knowledge
summary: Internal pitfalls of FestipodDataContext — currentUserId is the profile document read back in the protected scope (empty until it lands), the legacy principal space resolveParticipantUser still resolves on read, OPT-IN auto-seed, owner-derived participantCount, local-mode no-op mutations
last_checked: 2026-08-10
summary: Internal pitfalls of FestipodDataContext — the signed-in identity and the profile are two unrelated things, "my profile" is the profile document I own, mutations reject instead of succeeding silently, participantCount is derived by the event's owner, and local mode is a no-op
last_checked: 2026-08-17
---
# Internals & pitfalls of `FestipodDataContext`
Non-obvious behaviours of `src/shared/context/FestipodDataContext.tsx` to know about before touching the data context.
## Who am I — `currentUserId` is a document you read back, not a value you were given
## Identity and profile are TWO things — never join them
**The app names no identity of its own** (concept `app-security`, [[decision_2026-08-10_the-barrier-names-no-identity]]): `ensureIdentity()` takes nothing, and nothing switches identity afterwards. So the provider cannot *derive* a principal from an input. What the current user **is**, is the **profile document it reads back in its own protected scope**: `currentUserId` = that profile's `@id`, a doc NURI — the very same value as `currentUser?.id`.
**The identity** is what `ensureIdentity()` returns: an opaque value, published to the tree by `src/shared/utils/currentPrincipal.ts` (a module store, not a context — the component that awaits sits *inside* the data provider, so a context it published would be invisible to its own consumer). It is **for display and log attribution only**. It is never parsed, never rendered as a name, never written into an entity, and **never passed to a data-layer call** — placement is named by scope alone, so handing it back would recreate the parameter the surface deliberately removed ([[contract_polyfill-surface]]).
**The pitfall that follows**: it is **empty until the protected read lands**, and empty is an ordinary string that raises nothing. Mutations needing it **refuse** (`joinEvent` logs `empty user principal — refusing to write a participation with no fp:user` rather than writing an entity that would be dropped on read); queries keyed on it return **empty results** that render as "you have nothing". Treat `''` as *not ready*, never as *no data* — see `app-architecture`, [[caveat_identity-ids-in-screens]].
**The profile** — pseudo, name, initials — is **Festipod's own object**, in a document the app creates and writes. `currentUserId` is that document's NURI, the same value as `currentUser?.id`, and the only value a mutation may write into a `Participation`'s `fp:user`.
> The `currentUser` object is picked with a **fallback** (`@mariedupont`, then `users[0]`) — a leftover of the demo seed, and a silent one: on a wallet holding several profiles it can settle on the wrong person. Worth a look whenever "the app thinks I am someone else".
**There is no join between the two, and there must not be one.** The identity says nothing about the profile. Never compare the principal to an entity id, and never match it against a profile field to decide who the current user is.
## The legacy principal space — resolved on READ only
## "My profile" is the profile document I OWN
A `Participation` written **today** carries `currentUserId` in `fp:user`, i.e. a profile doc NURI, so a direct `participation.userId === profile.id` join matches. Participations written under the **earlier** scheme carry a principal of the form `urn:festipod:user:<normalized-handle>`, which matches nothing directly.
`listMyEntityDocs('protected')` answers *which documents are mine*, and the UserProfile among them is mine. **No field of any profile takes part**: no username comparison, no normalization, no positional pick.
**`resolveParticipantUser`** (`FestipodDataContext`) is the single join point, and it tries, in order: (1) a **direct match** `u.id === userId` — today's writes, and the demo seed's bare `user-1` space; (2) failing that, strip `USER_PRINCIPAL_PREFIX` and match the remainder against `normalizeIdentifier(profile.username)` — the legacy space. Never join by direct comparison at a call site: the symptom of getting it wrong is every participant rendering as « participant inconnu », which shipped once already.
A failed listing is **UNKNOWN, never "none"** — the set stays unresolved, no profile is chosen and none is created, and the failure is retried then said loudly. Reading a rejection as "I own nothing" would create a second profile for someone who already has one.
`USER_PRINCIPAL_PREFIX` is now **read-side only** — nothing mints it any more. It is kept so old data still resolves; it is not a shape to write against.
Four outcomes, and *somebody else's profile* is not one of them:
A further id space takes **no** part in this join: the inbox deposit `uid` (`mint…`) — it identifies a **deposit** for the counter, never a user.
> **Horizon.** This paragraph describes **what is implemented**. The target model drops the plaintext `userId` and routes identity resolution through **reading the profile** — see [[brief_2026-07-20_attendance-set-model]], whose implementation is gated. The id-space fix is explicitly noted there as **still valid**: do not undo it in anticipation of the target.
### Which space each query expects (the `buildQueries` contract)
| Query | What it expects / returns |
| Owned profiles | Answer |
|---|---|
| `getUserEvents(userId)`, `isParticipating(eventId, userId?)`, `getFriends(userId?)` | filter on `participation.userId` / `friendship.userId`; their default is `currentUserId`, which is correct |
| `getEventParticipants(eventId)` | **returns profiles** (`FpUserData``id` = NURI), the join being done internally |
| listing unresolved | UNKNOWN — nothing resolved, nothing created |
| none | I have no profile yet → one is created (below) |
| exactly one | that is me |
| several, none created by this session | the **first by document reference** — stable across reloads, openly arbitrary, warned about once |
**Screen-side impact**: `currentUserId` and `currentUser?.id` are now the same value, so filtering yourself out of a participant list works either way. What still bites is passing an id **before it resolves** — see `app-architecture`, [[caveat_identity-ids-in-screens]].
The last row is a reloaded wallet carrying a fixture seed. The pick carries no meaning, so it is logged as demo data rather than presented as you; **every candidate is a document I own**, which is what separates it from the impersonation that was removed — that one reached for a profile by *name* and could land on a stranger's document. Delete the branch the day a profile is really created and known.
## Reads = `watchShape` (the SDK surface), no more bespoke machinery
> **Two impersonation fallbacks are gone**, including one in `updateProfile` that would have written your pseudo into a stranger's document. Having no profile now resolves to *having no profile*. Do not reintroduce a "pick something plausible" fallback anywhere on this path.
**Since 2026-07-10**: `useNgData` reads through `useShapeQuery(shape, scope)` (a `useSyncExternalStore` binding over `watchShape`) — THREE useQuery-shaped reads (events/public, users/protected, participations/protected) + Fp adapters (`shapeAdapters.ts`). Removed: `readEntities`, `subscribeDocs`+`bumpRead`+`readTick`, the manual listing (`publicDocs`/`protectedDocs`/`registerDoc` for reads), and `relist`. `ready` = the combination of the `isSuccess` flags. See [[rule_app-uses-sdk-surface-only]].
## A profile is created at sign-in when there is none
**Immediate visibility of mutations = an OPTIMISTIC overlay** (no `registerDoc`): `createEvent`/`joinEvent`/`leaveEvent` feed `pendingAddEvents`/`pendingAddParticipations`/`pendingRemoveIds`; the exposed state = merge(reactive, adds) minus removes, deduped by id (id = the doc's NURI). Reconciliation happens automatically on push (an add that shows up in the reactive state, or a remove that disappears from it, is dropped) — never a poll ([[rule_no-broker-polling]]).
Gated on **both** the protected read having settled (`isSuccess` — synced-and-empty, not still-syncing) **and** the owned-document set being known, because "I have no profile" is only true when both have answered. Single-shot per session; on failure the guard is released so a later change retries.
## Dev auto-seed
The UserProfile shape makes `name`, `initials` and `username` **mandatory**, so the profile cannot be written empty. The three fields carry **placeholders that read on screen as "not filled in yet"** — never a plausible human name, never a handle, and **never anything derived from the opaque identity**. The user replaces them through `updateProfile`.
**Since 2026-07-13 the auto-seed is OPT-IN and OFF by default**: it only fires if the `FESTIPOD_AUTO_SEED` env var is set (`=1`), no longer off `NODE_ENV`. Var absent → **no automatic seed at all**, even in dev (`autoSeedEnabled()`/`shouldAutoSeed()`, `src/shared/utils/autoSeed.ts`; delivered in dev through the `/festipod-config.json` runtime route + a compile-time `define` in `build.ts`, the same mechanism as the shared wallet — see `tech-stack/knowledge_build-pipeline`). The **explicit** seed (`loadTestData()`, @data tests) is unchanged. Rationale: the repeated auto-seed was bloating the wallet (slow reads, see [[caveat_wallet-bloat-hang]]).
## Nothing succeeds in silence
When it is enabled, the auto-seed fires if events AND users are both empty — **gated on `isSuccess`** (`watchShape`'s readiness), NO LONGER on a 3s `setTimeout`: we only decide "the wallet is empty" once the sync is **confirmed** (`isSuccess`), otherwise a not-yet-finished read was taken for an empty wallet → a re-seed on every reconnection (bug fixed). Remaining pitfalls:
- **One seed at a time**: `loadTestData()` sets `hasTriedAutoSeed`, and the auto-seed re-checks it → an explicit load cancels the pending auto-seed (otherwise two concurrent `bootstrapWallet` calls write everything twice).
- The seed writes under the **connected session**, so the session that seeds **holds** what it seeded and its protected fixtures round-trip. Seeded users are fixtures, not accounts — nobody has signed in as them, which matters because `inbox.share` refuses a recipient nobody has ever been. Only **events** get an inbox opened at seed time (`openDocumentInbox`), because events are what people deposit into.
- **No retry**: if the seed fails, you get an empty screen + a `console.error`.
Mutations on the create/participate path **reject** rather than returning quietly, and the screen's confirmation **follows** the write:
## `participantCount` — derived and owned by the owner
- `joinEvent` refuses when no profile of mine is resolved: a `Participation` needs `fp:user`, and one written without it is dropped on read — a sign-up that wrote nothing, threw nothing, and let the screen congratulate the user. It now throws, naming the cause. `leaveEvent` likewise, because withdrawal must be authoritative ([[caveat_participation-deletion]]).
- Idempotence is checked **authoritatively against the broker**, not against the reactive set, which can lag a just-written participation. A **failed** count is UNKNOWN and is deliberately *not* swallowed — reading it as zero is exactly how a duplicate gets written.
- **The deposit IS the delivery.** A host-facing notification is no longer minted at join time. It used to be written into the *joiner's* own protected scope with `recipient` set to the event — a document the host can never read — and pushed into the joiner's own list, so the joiner saw a "new participant" notice addressed to someone else. Both are gone: `inbox.postToDocument(doc, …)` carries the news, and the owner builds the notification from the deposits it reads on its own event's inbox.
- The creator signs up through the **common path** — no owner branch, no special case, the same deposit and the same derived count.
> ✅ **CORRECTED (2026-07-13).** The requirement is **"reliable at the owner's NEXT CONNECTION"** (the creator processes their inbox when they connect), NOT a live real-time cross-user notification. The bug was: the owner-materializer materialized **too early** (before the participant's deposit had synced) → read `active=0` → wrote 0 → **memoized that 0** → never re-processed. Fix: (1) read through `inbox.readSynced` instead of `inbox.read` — the two differ by contract, and only the former is the synced view ([[contract_polyfill-surface]]); (2) the materializer fires **directly on connection** (`[ready, ownedKey]`), no longer only on a push; (3) `materializedCountRef` no longer locks in a premature 0 (its sole role = loop guard: only write when the derived value changes); (4) **the single source of the NUMBER = `event.participantCount`** (the `participantCount: 1` literal in `CreateEventScreen` is removed → it starts at 0; the display no longer computes a local number). Kept GREEN (on a fresh profile) by `event/e2e-multibrowser.feature` « Le compteur converge chez le propriétaire à sa prochaine connexion » (un-`@wip`'d). No polling ([[rule_no-broker-polling]]).
## The legacy participation id space — resolved on READ only
**Since 2026-07-07**: `participantCount` is no longer mutated in place by the participant. The flow is inbox-deposit → owner-materialization:
- `joinEvent`/`leaveEvent` **no longer** write `participantCount` on the event's doc — **only a document's owner writes to it**, so a participant cannot touch someone else's. The participant only writes their **own** participation doc (protected), then **deposits** a marker into the event's inbox (`depositRegistration` on join, `depositLeave` on leave, `src/shared/data/registration.ts`).
- The event **owner's** session does the materializing: it watches (`inbox.watch`, no polling) the inbox of the events it owns (`ownedEventIds` = `listMyEntityDocs('public')` + freshly created events), and on every deposit it **recomputes** `participantCount` on **its own** event doc (`updateEntityField` on its own doc). It is the counter's only writer.
- **The counter is DERIVED, not incremented**: `materializeAttendance` (registration.ts) reads the inbox and computes the **set** of distinct active sign-ups (`new-participant` deposits deduped by `uid`, MINUS those cancelled by a `leave-participant` — by exact `regUid` or by the `(eventId, userId)` fallback). `participantCount = |active set|`**no host baseline**: the creator does not attend automatically (there is no notion of host, see concept `functional-domain`), so the counter starts at **0** on creation and only moves on real sign-ups. `createEvent` **no longer writes** a participation at creation time (it used to write a host participation and set the counter to 1); the creator sees « J'y serai » and can join/leave their own event like anyone else. Because it is a **pure function of the inbox**, a replay converges — never double-counting nor a phantom decrement (idempotence). The write is guarded (it only writes when the value changes), a loop guard. Covered by the `@data` scenario « Le créateur ne participe pas automatiquement à son événement » (us-13): counter 0 + `isParticipating(E)===false` at creation, then join→true / leave→false.
- **Owner offline = eventual**: only the owner's session materializes; while they are disconnected, the counter does not move for anyone else (the participations and deposits stay persisted — nothing is lost).
- The counter nevertheless remains an **aggregate**, not the list of named participants: `getEventParticipants` (named identity) is still governed by what the protected scope hands back ([[caveat_participation-deletion]] for the authoritative deletion, unchanged).
A `Participation` written **today** carries the profile document's NURI in `fp:user`, so the direct join `u.id === userId` matches. Participations written under the **earlier** scheme carry `urn:festipod:user:<normalized-handle>`, which matches nothing directly.
### Id-form invariant: match on the CANONICAL form of the event id
**`resolveParticipantUser`** is the single join point and tries, in order: (1) the direct id match — today's writes, and the demo seed's bare `user-1` space; (2) failing that, strip `USER_PRINCIPAL_PREFIX` and compare the remainder to `normalizeIdentifier(profile.username)`. Never join by direct comparison at a call site: getting it wrong renders every participant as « participant inconnu », which shipped once. `USER_PRINCIPAL_PREFIX` is **read-side only** — nothing mints it any more; it is not a shape to write against. The inbox deposit `uid` (`mint…`) is a third space that takes **no** part: it identifies a deposit for the counter, never a user.
An event's `@id` **is** its document NURI (`did:ng:o:<repo>[:v:<overlay>]`). The owner's materializer matches the inbox **deposits** to the owned events **by event id**: `ownedEventIds` (what the materializer iterates over), the **deposit key** (`payload.eventId`, what the participant deposits under) and the counter's **write target** must all designate the same event.
> **Horizon.** The target model drops the plaintext `userId` and resolves identity by reading the profile — [[brief_2026-07-20_attendance-set-model]], gated. The id-space fix is noted there as still valid: do not undo it in anticipation.
**Measured finding (2026-07-07)**: on the current tree these three paths carry the **same** NURI (the `:v:<overlay>` suffix included) — create-time, `listMyEntityDocs` and the `@id` read back all coincide, because `readUnion` **pins the subject to the input NURI** (lib `read-model.ts`, `63ecfee`). So matching already works, **including** for an owned event reached through `listMyEntityDocs` (validated by the @data scenario « …fait converger le compteur dérivé »). The canonicalization below is **defensive**, not the fix for an active bug. (The mismatch one investigation thought it had seen was the **seeded-but-not-owned** artifact: on a persistent wallet, the seed belonged to a `test-*` identity from an earlier run → the current session reaches it through discovery, not through `ownedEventIds` — correct behaviour.)
## Reads = `watchShape`, writes = an optimistic overlay
**Rule**: match the event id on its **canonical form** — the base repo id, with any `:v:<overlay>` suffix stripped (`canonicalEventId`, `src/shared/data/registration.ts`). This canonical form is used for **matching** in `materializeAttendance` / `readRegistrationNotifications`, and for **deduplicating** `ownedEventIds` (`ownedKey`, FestipodDataContext) so that one and the same event reached through two paths is not materialized twice. **Careful**: only the **matching** uses the stripped form; the counter is always **written** to the real owned NURI (a live, openable doc) — a stripped id must never serve as a write target or an anchor. This is an **app-side** invariant (not a NextGraph detail): however the lib makes the overlay vary, the app matches on the common base.
Reads go through `useShapeQuery(shape, scope)` — three scoped reads (events/public, profiles/protected, participations/protected) mapped by `shapeAdapters.ts`; `ready` combines their `isSuccess` flags. The app resolves, lists and re-queries nothing ([[rule_document-per-entity]] §Reads).
## There is no identity switch any more
**Immediate visibility of a mutation is a pure optimistic overlay**: `createEvent`/`joinEvent`/`leaveEvent`/profile creation feed `pendingAdd*` / `pendingRemoveIds`; the exposed state is merge(reactive, adds) minus removes, deduped by id. Reconciliation is automatic on push — never a poll ([[rule_no-broker-polling]]).
The app settles its identity **once**, before anything renders (`ensureIdentity()` in `AuthGate`), and offers no way to change it — the surface stopped publishing one (concept `app-security`, [[decision_2026-08-10_the-barrier-names-no-identity]]). So the provider carries **no identity-change reset**: no `useEffect([identifier])`, no cap reset, no registry-cache reset. Those symbols are gone; do not reintroduce a reset for a transition that cannot happen.
## `participantCount` — derived, and written only by the event's owner
> **Why there is nothing left to reset.** An identity-change reset only made sense while a single React tree could outlive a change of identity. It cannot: one page hosts exactly one identity for its whole life, so session state (the read set, the optimistic overlay, the owner-materializer's doc set) has no second identity to leak into.
>
> The **cross-identity isolation** behaviour is still a real Festipod requirement, but proving it needs **two genuinely separate browser contexts**, each signing in for itself. `event/isolation-deux-identites.feature` is `@wip` for exactly that reason (concept `bdd-testing`, [[rule_tests-validate-festipod-not-the-sdk]]).
The counter is **not** incremented by whoever joins: only a document's owner writes to it. The flow is deposit → owner-materialization.
## `useShapeQuery` instrumentation — global spinner + timing
- A participant writes their **own** participation document (protected), then **deposits** a marker into the event's inbox (`depositRegistration` / `depositLeave`, `src/shared/data/registration.ts`).
- The event **owner's** session watches the inboxes of the events it owns (`inbox.watch`, no polling) and **recomputes** `participantCount` on its own event document. It is the counter's only writer, and it reads through `inbox.readSynced` — the synced view — not `inbox.read`.
- **One inbox per document, whatever the concurrency.** `openDocumentInbox` (`storeRegistry.ts`) resolves at most once per document per session (`resolveOncePerKey`, `src/shared/utils/`, unit-tested): several callers racing for the same event's inbox — create, materializer, watch wiring, watch callback — all await the same in-flight resolution instead of minting a second address. A rejection is not memoized (unknown, not absent), so a later call genuinely retries.
- **One materialize cycle at a time.** The owner's connection trigger and its inbox-push trigger both call into a `createSerialTask` (`src/shared/utils/`, `serialTask.ts`, unit-tested): a cycle in flight absorbs every request that arrives during it into a single follow-up, so two read-derive-write passes never race on the same document. Each cycle carries a monotonic sequence number, and a write only lands if no fresher cycle has already written — a stale cycle can no longer clobber a newer value.
- **Derived, not incremented**: `materializeAttendance` computes the set of distinct active sign-ups (deposits deduped by `uid`, minus those cancelled). `participantCount = |active set|`. There is **no host baseline** — an event has no host, the declarer is not required to attend, so the counter starts at **0** on creation and moves only on real sign-ups. Being a pure function of the inbox, a replay is *designed* to converge: no double count, no phantom decrement. The write is guarded so it only fires on a genuine change, and lands in **one** SPARQL statement (`updateEntityField`: `DELETE … INSERT … WHERE`), closing a window where a reader could see the field briefly absent and read zero.
- **Owner offline = eventual.** While the owner is disconnected the count does not move for anyone else; nothing is lost. The materializer fires directly on connection, not only on a push, and it never locks in a premature 0.
- The counter is an **aggregate**, not the list of named participants — `getEventParticipants` is governed by what the protected scope hands back.
`useShapeQuery` (a `useSyncExternalStore` binding over `watchShape`) instruments **every query cycle**: at the start of a cycle it registers itself in a module-level store `src/shared/data/pendingQueries.ts` (`beginQuery`/`resolveQuery`, a Set of ids — idempotent, safe under StrictMode), and on the first `isPending → isSuccess|isError` transition (the "first result", the readPromise equivalent) it resolves AND logs the delay: `[FestipodData] <shape>/<scope> premier résultat en <N>ms (n=<len>)` (so the delay for Event/public events is visible by name). The `cycleId` is memoized on `[shapeKey, scope]` → an identity/scope switch recreates the observable AND starts a new cycle (a fresh `beginQuery`), and the cleanup resolves on unmount (never stuck). The `usePendingQueries()` hook exposes the number of pending queries; `HomeScreen` renders a `Spinner` (sketchy, `.app-spinner` + `@keyframes app-spin` in `index.css`) next to the « Festipod » title as long as the count is > 0 → it only stops once **all** in-flight queries have received their first result. Any future `useShapeQuery` contributes to it automatically. The measurement lives on the app side (React-perceived delay), **not** in the polyfill.
> A live run still shows the count **one connection later** than this design implies — not a race, not data loss, a layer that does not notify you of your own actions: [[caveat_participant-count-one-connection-lag]].
## Logging convention — identity-first prefix, and counter before→after
Which event a deposit belongs to is matched on the **canonical id-form** — see [[knowledge_write-rights-are-ownership]] §Matching, which governs every event-id comparison in this file.
Every DATA log from the provider goes through **`logPrefix`**: `[<currentUserId>][app][data]` when the principal is resolved, `[app][data]` otherwise (a transient connection state). Reason: a run often drives **several sessions at once** (two tabs, a multi-browser scenario) and their lines end up read side by side — an unprefixed line does not say *whose* it is and becomes useless for diagnosing a leak or a stuck counter. **Adding a DATA log = reusing `logPrefix`**, not a bare `console.log`.
## Logging convention — identity-first, and the counter before→after
Two measurement points are laid down **as a pair** and serve together: the owner's materializer logs `participantCount` **before → after** its write, and the display read logs the value **as exposed to the render**. Comparing them tells a stuck counter apart between a **DATA** problem (never incremented) and a **DISPLAY** problem (incremented but not re-read until the next session). Do not remove one without the other — on their own they diagnose nothing.
Every DATA log goes through **`logPrefix`**: `[<currentUserId or principal>][app][data]`. A run often drives several sessions at once and their lines are read side by side, so a line must say *whose* it is. Adding a DATA log means reusing `logPrefix`, not a bare `console.log`.
Two measurement points are laid down **as a pair**: the owner's materializer logs `participantCount` before → after its write, and the display read logs the value as exposed to the render. Together they separate a **data** problem (never incremented) from a **display** problem (incremented but not re-read). Do not remove one without the other — alone they diagnose nothing.
## There is no identity switch, and nothing to reset
The app settles its identity once, before anything renders, and offers no way to change it (`app-security` → [[decision_2026-08-10_the-barrier-names-no-identity]]). One page hosts exactly one identity for its whole life, so there is no identity-change reset: no `useEffect([identifier])`, no cap reset, no registry-cache reset. Do not reintroduce a reset for a transition that cannot happen. Cross-identity **isolation** is still a real requirement, but proving it needs two genuinely separate browser contexts (`bdd-testing` → [[rule_tests-validate-festipod-not-the-sdk]]).
## Mutations are no-ops in local mode
In local/demo mode (`useLocalData`), `createEvent`/`joinEvent`/`leaveEvent`/`updateEvent` are **no-ops** (a `console.log`, the state does not change) — yet the screens still show a **success toast** (« Tu participes »). Potentially misleading UX: the user believes they signed up when nothing has changed. See [[knowledge_data-modes]] for how the provider is chosen based on status.
In local/demo mode (`useLocalData`), `createEvent`/`joinEvent`/`leaveEvent`/`updateEvent` are **no-ops** (a log, no state change) — yet the screens still show a success toast. Misleading UX, unchanged. See [[knowledge_data-modes]].
@@ -25,4 +25,6 @@ The app has **two modes**, both consumed through the `useFestipodData()` hook:
- `connected``NgDataProvider` (real wallet data)
- `error``LocalDataProvider` with the seed (graceful fallback)
> Mutations are **genuinely persisted** in connected mode (`joinEvent` writes a Participation and notifies the meeting point's host, `leaveEvent` deletes authoritatively see [[caveat_participation-deletion]]). In local/demo mode they are no-ops (see [[knowledge_context-internals]]).
> Mutations are **genuinely persisted** in connected mode: `joinEvent` writes a Participation into its own document and **deposits** into the event's inbox (the deposit is the delivery — no notification is written for the host), `leaveEvent` deletes authoritatively (see [[caveat_participation-deletion]]). Both **reject** rather than returning quietly when they cannot write, and the screen's confirmation follows the write. In local/demo mode they are **no-ops that still show a success toast** — see [[knowledge_context-internals]].
>
> **Per-call honesty is not flow-level honesty.** Every one of those calls tells the truth about itself; the sign-up flow driven end to end still shows a bystander a stale `participantCount` for one connection longer than the write itself — not a lie, a layer that neither pushes you your own deposit nor re-reads your own write in the same session, see [[caveat_participant-count-one-connection-lag]]. Do not read the paragraph above as "the count updates instantly".
@@ -1,24 +1,34 @@
---
type: knowledge
summary: The Fp* data types — Event, UserProfile, Participation, MeetingPoint and Notification are persisted in NextGraph (SHEX shapes + ORM); only Friendship stays local-only (app-TS)
last_checked: 2026-07-03
summary: The Fp* app types and their SHEX shapes — Event (no host), UserProfile, Participation, MeetingPoint and Notification are persisted, Friendship stays local-only; the generated ORM names carry NO Fp prefix and are aliased at the import sites
last_checked: 2026-08-17
---
# Data entities
`src/shared/data/types.ts`:
`src/shared/data/types.ts` holds the app's own types; `src/shared/shapes/shex/festipodShapes.shex` holds what is actually persisted.
| Type | Persistence | Key fields |
|---|---|---|
| `FpEventData` | SDK (Event shape) | id, title, date, location, distance, themes |
| `FpUserData` | SDK (UserProfile shape) | id, name, username, bio, city, counts |
| `FpParticipationData` | SDK (Participation shape) | eventId + userId + confirmed |
| `FpMeetingPointData` | SDK (MeetingPoint shape) | eventId, location, time, host |
| `FpNotificationData` | SDK (Notification shape) | kind, target, source |
| `FpEventData` | SDK (Event shape) | title, date, startDate, endDate, startTime, endTime, location, distance, participantCount, coverImage |
| `FpUserData` | SDK (UserProfile shape) | name, initials, username, role, isPublic |
| `FpParticipationData` | SDK (Participation shape) | event + user + isConfirmed |
| `FpMeetingPointData` | SDK (MeetingPoint shape) | event, host, title, place, time |
| `FpNotificationData` | SDK (Notification shape) | recipient, type, ref, payload, timestamp, isRead |
| `FpFriendshipData` | **local-only** | userId + friendId |
`MeetingPoint` and `Notification` do have real **SHEX shapes** (`src/shared/shapes/shex/festipodShapes.shex`) with generated ORM bindings (`festipodShapes.shapeTypes.ts`: `FpMeetingPointShapeType`, `FpNotificationShapeType`) and **are persisted**. A `Notification` is created in particular when signing up to a meeting point (`joinEvent`).
**An event has no host.** `hostName`/`hostInitials` are gone from the type and the shape alike — the event is only the anchor, and the host lives one level down on the meeting point (`FpMeetingPointData.hostId`, SHEX `fp:MeetingPoint.host`). See concept `functional-domain`, [[knowledge_actors-and-concepts]].
`Friendship` has **no** SHEX shape and no persistence — it stays app-TS-only (see [[knowledge_nextgraph-stack]]).
**A Notification is no longer created when someone signs up.** The joiner deposits into the event's inbox and the **owner** builds the notification from what it reads there — see [[knowledge_context-internals]] §Nothing succeeds in silence.
> Pitfall: even for `FpEvent` (which is persisted), several fields of the app type are **not** in the shape and are lost when connected — see [[caveat_event-fields-not-persisted]].
`Friendship` has **no** SHEX shape and no persistence — it stays app-TS-only ([[knowledge_nextgraph-stack]]).
## The generated ORM names carry no `Fp` prefix
The generator emits `Event`, `UserProfile`, `Participation`, `MeetingPoint`, `Notification` (and `EventShapeType`, `UserProfileShapeType`, …) — **without** the `Fp` prefix earlier bindings had.
**It cannot be restored at the generator.** The emitted name derives from the shape IRI, and those IRIs are the **persisted RDF classes**: renaming them to regain a prefix would rename the data. So the app **aliases at its import sites** (`… as FpEvent`, `… as FpEventShapeType`) — three of them, in the data context and the two test harnesses. That keeps the downstream names unchanged and, just as importantly, stops the DOM's own `Event` and `Notification` from being shadowed.
Alias at the import; never rename in the generated files, which `bun run build:orm` overwrites ([[knowledge_nextgraph-stack]]).
> `themes` is on `FpEventData` and the seed but **not** on the Event shape: a repeated value needing a cardinality decision before it can be one more optional string. Nothing reads it back today, in any mode, so its absence in connected mode is not yet observable — see [[knowledge_nextgraph-stack]] for the shape's actual field list.
@@ -17,14 +17,16 @@ Festipod persists through **`@ng-eventually/polyfill`**. What that surface offer
The reactive ORM (`useShape`) is built on **SHEX shapes**: `src/shared/shapes/shex/festipodShapes.shex` defines:
- **Event** — title, description, dates, location, themes, participants
- **UserProfile** — name, username, bio, city, visibility
- **Event** — title, description, date, startDate, endDate, startTime, endTime, location, distance, participantCount, coverImage, plus an **inbox** field. **No host**: an event is only the anchor ([[knowledge_entities]]). `startDate`/`endDate`/`startTime`/`endTime` are the ISO/HH:MM values the form collects, carried end to end alongside `date` (the display label); they are all optional, so an event written before these fields existed reads as one without them rather than one with blank strings. `themes` is **not** on the shape: a repeated value needing a cardinality decision before it can be one more optional string, and nothing reads it back today. `inbox` is a **vestige** and must stay unused: a deposit **names the document** (`inbox.postToDocument(doc, …)`) and the owner opens its own with `openDocumentInbox(doc)` — writing an address into the entity would put back exactly what the surface removed ([[rule_document-per-entity]]).
- **UserProfile** — name, initials, username, role, isPublic. The first three are **mandatory**, which is why a new profile is written with placeholders rather than empty.
- **Participation** — links an event and a user, confirmation status
- **MeetingPoint** — a meeting point (location, time, host)
- **Notification** — a notification (created in particular when signing up to a meeting point)
- **MeetingPoint** — a meeting point (event, host, title, place, time)
- **Notification** — recipient, type, ref, payload, timestamp, isRead
The ORM bindings are generated in `src/shared/shapes/orm/` (`*.schema.ts`, `*.shapeTypes.ts`, `*.typings.ts`). **Regenerate** with `bun run build:orm` after any `.shex` change.
> **Regenerating an unchanged `.shex` reproduces the committed bindings byte-for-byte** — verified by running the generator twice: once before touching the shape, to confirm a no-op diff, then again after the shape edit, so what shows up is the shape change alone. Run it that way on every `.shex` change — it is what keeps an ORM diff reviewable, since nothing separates your edit from a generator side effect if you only ever run it once. And the emitted names carry **no `Fp` prefix**; the app aliases at its import sites instead, because the name derives from the shape IRI and those IRIs are the persisted RDF classes ([[knowledge_entities]]). Never hand-edit the generated files.
> **The canonical way to read is the reactive hook.** `useShape`/`watchShape`: you subscribe to a shape on a scope, you get the current value, and the component re-renders on every change — subscription/push, never polling; one-shot reads are the exception. The read/reactivity contract is [[contract_polyfill-surface]] and nothing else.
> `Friendship` has **no** SHEX shape and no persistence — it stays app-TS-only (see [[knowledge_entities]]).
@@ -1,17 +1,26 @@
---
type: knowledge
summary: seedData.ts provides deterministic fixtures (10 users, events, participations) with CURRENT_USER_ID = 'user-1' (Marie Dupont); used in demo mode and by the @ui tests
summary: seedData.ts holds deterministic fixtures (14 users with CURRENT_USER_ID = 'user-1', 5 events) used by demo mode and the @ui tests; no fixture reaches a CONNECTED wallet by any route any more — bootstrapWallet is the single enforcement point of that master switch
last_checked: 2026-08-16
---
# Seed data
`src/shared/data/seedData.ts` provides **deterministic** fixtures:
`src/shared/data/seedData.ts` holds **deterministic** fixtures: 14 users (`CURRENT_USER_ID = 'user-1'`, Marie Dupont), 5 events, participations, meeting points and friendships.
- 10 users — **Marie Dupont = the current user**, `user-1`
- Several events (dates, locations, themes)
- Participations, meeting points, friendships
- `CURRENT_USER_ID = 'user-1'`
## Where they are still used
These fixtures serve (a) **demo mode** (`LocalDataProvider`, see [[knowledge_data-modes]]) and (b) the **`@ui`** tests, which render the screens against this predictable data (`Marie Dupont`/`@mariedupont` = currentUser, `Jean Durand`/`@jeandurand` exists, etc. — see concept `bdd-testing`).
- **Demo / disconnected mode** — `LocalDataProvider` reads them straight into React state ([[knowledge_data-modes]]).
- **The `@ui` rendering tests** — they render screens against this predictable data (`Marie Dupont`/`@mariedupont` is the current user, `Jean Durand`/`@jeandurand` exists…). Concept `bdd-testing`.
> `bootstrapWallet()` (`src/shared/utils/ngBootstrap.ts`) seeds this data into the wallet in connected mode — triggered only by an explicit user action (« Charger données de test »).
Neither path writes to a wallet, which is why both are untouched by the switch below.
## No fixture reaches a CONNECTED wallet, by any route
A **master switch**`fixtureSeedEnabled()` in `src/shared/utils/autoSeed.ts` — is **off**, a product decision: no fixture is written into a connected wallet at all, neither by the opt-in automatic seed nor by an explicit "load test data" action.
**`bootstrapWallet` (`src/shared/utils/ngBootstrap.ts`) is the single enforcement point.** Every route into a wallet funnels through that one function, so the switch cannot be walked around by a screen, a bridge or a test harness; a caller simply gets the ordinary "nothing was seeded" answer, which is exactly true. Call sites consult the switch too, but only so they neither log nor await work that will not happen — the enforcement is not theirs. A unit test fails if a document is created after all.
**Off, not deleted.** The fixtures and the seeding code stay, because the two paths above need them and neither writes to a wallet. If the switch is ever turned back on, what follows still applies: the seed is **linear in the number of documents** (one document per entity, each a serial round trip), so the connected seed writes only what is needed — all events, a few profiles, and no participations, which the sign-up scenarios create live. Events are the only entities whose inbox is opened at seed time, because events are what people deposit into.
> **Consequence, live now**: the `@data` suite has lost its fixtures — concept `bdd-testing`, [[caveat_data-suite-has-no-fixtures]].
@@ -0,0 +1,35 @@
---
type: knowledge
summary: getEventOwnership answers mine / not-mine / unknown from listMyEntityDocs('public') — owning a document IS being able to write it, the ruling is rebuilt on every listing rather than accumulated, and UNKNOWN is a real third answer callers must handle
last_checked: 2026-08-16
---
# Write rights are ownership, read from the owned list
The app never asks whether it may write a document; it asks whether it **owns** one, because [[contract_polyfill-surface]] makes those the same fact. Only an owner writes, a read key never grants a write, and no call adds a writer — so `listMyEntityDocs('public')` is the whole answer, and no probe call will be added (`app-security` → [[decision_2026-08-16_write-rights-are-the-owned-list]]).
## The answer is three-state
`getEventOwnership(eventId)` (`FestipodDataContext`) returns `'mine' | 'not-mine' | 'unknown'`:
- **`mine`** — the event is in the owned set, either because a listing returned it or because this session created it and claimed it directly. Checked **first**, so a fresh creation is authoritative before any listing has answered and never loses to a stale miss.
- **`not-mine`** — a listing has *resolved* and did not return this event, so it was genuinely looked past.
- **`unknown`** — everything else: no listing has landed, the listing failed, or the event arrived after the last one. A rejection means UNKNOWN, never "this session owns nothing"; reading it as `not-mine` is how an owner is silently denied their own event.
**Callers must treat `unknown` as its own case.** It is not a polite `not-mine`, and it is not a boolean waiting to settle.
## The ruling is REBUILT, never accumulated
Every listing **re-adjudicates every visible event**: the ruled-out set is recomputed from scratch, so a later listing can overturn an earlier one. An earlier version latched the verdict into a boolean, which denied an owner their own event forever once a single listing had missed it. Do not reintroduce accumulation — add to the owned set, but rebuild the ruled-out set.
Re-listing is driven by **arrivals, not by time**: while some visible event is neither owned nor ruled out, one more listing is taken; the set then empties and the effect falls silent. That is a push-driven retry, not a poll ([[rule_no-broker-polling]] in `bdd-testing`).
## Known residual — accepted, do not paper over
"Not mine" is inferred from **absence**, and the reactive read and the listing are **separate mechanisms**. An event can therefore be on screen a moment before a listing can see it, and it is ruled out for exactly that window; it is re-examined only if some other unclassified event later triggers a listing. Closing the window needs a timer (forbidden) or a capability probe (ruled out). It is left visible and stated on purpose.
## Matching is on the canonical id-form
An event's `@id` is its document NURI, and the same event can be reached under two overlays (`:v:<overlay>`). Every ownership comparison — the owned set, the ruled-out set, the lookup — runs on the **canonical** form (`canonicalEventId`, `src/shared/data/registration.ts`): the base repo id with any overlay suffix stripped. **Matching only.** A stripped id is never a write target nor an anchor; the counter is always written to the real owned NURI.
> Two screen-side consumers, one answer: the control that **offers** the write and the route that **performs** it ask the same question and treat `unknown` the same way — `app-architecture` → [[knowledge_screen-pattern]]. Why the answer is this and will stay this: `app-security` → [[decision_2026-08-16_write-rights-are-the-owned-list]].
@@ -2,7 +2,7 @@
type: contract
summary: The API @ng-eventually/polyfill exposes to an application — signatures, guaranteed behaviour, and what it does not offer
pulled_from: https://gitea.reconnexion.apps.gueraud.net/Reconnexion/ng-eventually.git/.project/concepts/app-contract/contract_polyfill-surface.md
pulled_version: 1ecf511e9d8de8e0feb007f3a88f2c0d56ce455a
pulled_version: a33fb8a21464194227668fd703edd35f685bb3c1
pulled_at: 2026-08-16
---
@@ -0,0 +1,84 @@
---
type: usage
summary: What the Festipod application actually calls in @ng-eventually/polyfill, the conditions it needs beyond the call list, and the five frictions it has measured against the engagement
against: a33fb8a21464194227668fd703edd35f685bb3c1
---
# usage_festipod — Festipod on `@ng-eventually/polyfill`
Festipod is a mobile-first web application: users create **meeting points** grafted onto public events, and sign up to them. Its entire persistence goes through this package — there is no second data path, no server of its own, and no direct use of the SDK underneath. Two kinds of caller live in this repo and both are declared here: the **application** (screens, data context, write helpers) and the **test harness** (a browser-side bridge the BDD suites drive). The harness is a caller like any other; what it calls is part of what we consume.
The list below is what we actually call, derived from the call sites, not from what the engagement offers. Anything not listed is offered-but-unused and free to evolve without us.
## Consumed surface
### Bootstrap and session
- `configure(c)`**one call site**, once per page load, with every published field: `ng`, `useShape`, `init`, `initNg`, `debugAccessLog`, and `sharedWallet: { fileUrl, password, importUrl }`. All three `sharedWallet` fields are supplied, `importUrl` included.
- `init(callback, true, [])` — this package's `init`, not the one handed to `configure`. We read `event.session` off the callback and keep it for the whole page.
- From that session object we read **two** members: `session_id`, relayed unconverted (`string | number`) into every `docs` call, and **`session.user`**, a string user id passed to `ng.session_stop`. `session.user` reaches us only through the session's open index signature — the engagement names `session_id` and nothing else, so this is a **declared dependency on an unpublished member**: if the session stops carrying `user`, our sign-out breaks.
- `initNg(ng, session)` — called from inside that same callback.
- `ng` — exactly one member: `ng.session_stop(userId)`. Nothing else of the 88 is touched.
- `ensureIdentity()` — awaited before the interface renders (auth gate and app entry), and again by the data context, the principal resolver, and the harness. Its return is treated as opaque: never parsed, split, or rendered.
### Placement — `storeRegistry`
- `createEntityDoc(scope)` — one document per entity, on create.
- `listMyEntityDocs(scope)` — the owned-document listing; it is also **how we answer "may I write this?"**, since no call answers that question.
- `resolveScopeGraph(scope)` — the anchor for every SPARQL call.
- `openDocumentInbox(doc)` — through **one app-side wrapper** that collapses concurrent calls for the same document into a single resolution, keyed on the document's canonical form, for the session's lifetime. The raw entry is deliberately not re-exported, so no call site can reach it directly. That wrapper exists only because of friction 1.
- `resolveWriteGraph`**imported and re-exported, never called.** Declared because the import is real: removing the symbol breaks our build even though no behaviour depends on it.
### Reading
- `watchShape<T>(shapeType, scope)`**two positional arguments plus a type parameter** (see friction 5). Wrapped once, in the single React binding that couples the app to the reactive read; every screen reads through that binding. `ShapeObservable`'s `getSnapshot`, `subscribe` and the `ShapeQuery` state it yields are all consumed.
- `useShape(shapeType, scope)` — the read-filtered view, in the write path and in the `@data` step definitions.
- `UnionSubject` — its `subject`, `graph` and `props` are read and adapted into the app's own entity types.
- **Not consumed:** `readUnion`, `subscribeDoc`, `subscribeDocs`.
### Low-level document / SPARQL primitives
- `docs.sparqlUpdate(sessionId, query, anchor, label)` — every write the app makes, always anchored, always labelled.
- `docs.sparqlQuery(sessionId, query, base, anchor, label)` — authoritative re-reads on the write path (what a reactive read must not be asked to settle) and in the harness.
- **Not consumed:** `docs.docCreate` — documents are created through `storeRegistry.createEntityDoc`.
### Inbox
- `inbox.share(doc, toUser)` — granting a connection the read of a protected document.
- `inbox.postToDocument(doc, { from, payload, ts })` — reaching a document's owner. We pass `from: null` **deliberately** (a sign-up is unnamed unless the host is already a connection), a structured `payload`, and our own `ts`.
- `inbox.read(targetInbox)` and `inbox.readSynced(targetInbox)` — the owner materialising its deposits; `readSynced` is what the count path uses, because a read before the sync barrier returns a premature empty.
- `inbox.watch(targetInbox, onDeposits)` — subscribed by the owner; the returned unsubscribe is called on teardown.
- `inbox.readForDocument(doc)` — harness only.
- `Deposit`**all three fields** consumed: `payload`, `ts` (sorting and identity), `from`.
- **Not consumed:** `inbox.post` (we always address a document, never a raw inbox), `inbox.processInbox`.
### Types imported
`Nuri`, `NuriLike`, `PrincipalId`, `NG`, `UnionSubject`, `ShapeQuery`, `ShapeObservable`, `DeepSignalSet`.
Two of these are not underwritten by the engagement document as it stands. `ShapeQuery` and `ShapeObservable` are *named* by `watchShape`'s published signature but never defined there, and we use both **generically** (`ShapeQuery<T>`, `ShapeObservable<T>`) while the published signature is not generic. `DeepSignalSet` is named by **no** published signature at all — the harness imports it on the strength of the package exporting it, which by the engagement's own rule ("a type is published only when a published signature uses it") means we depend on something unpublished.
## Constraints
- **The session is the package's, and there is exactly one identity per page.** No call of ours takes an identifier, and we never build a session. Anything that made a page carry two identities would break the whole app, starting with the inbox wrapper's session-long memo.
- **`ensureIdentity()` must reject rather than resolve early.** We render the entire interface past that await. A resolve that did not actually finish restoring what was shared would show a signed-in user an empty account — worse than an error — so we rely on the rejection being real and we never render past one.
- **The barrier is the package's to mount and take down.** The app renders nothing of its own around sign-in and does not reload its own page; a barrier that leaked past the broker round-trip, or one the app had to dismiss itself, would need app-side machinery we deliberately do not have.
- **A rejection means "unknown", never "absent".** Every place we ask whether something exists (a document's record, a document's inbox) treats a throw as unknown and retries or surfaces it. A call that quietly returned "nothing" instead of throwing would make us provision a second set of documents for a user who already has them.
- **`sessionId` is relayed, never converted.** We pass through whatever the session carries, `string | number`, because stringifying it fails for real downstream.
- **Isolation is the package's, not ours.** No screen and no data helper implements an access check: we place each entity in its scope and trust the scope. If reading stopped being "possession of the key", the app would have no barrier of its own to fall back on.
- **Writes must be authoritative on our own document.** A withdrawal must not come back. We re-read with `sparqlQuery` rather than trusting a reactive read to settle it — the reactive surface is a view, not the authority.
- **We do not poll the broker.** No retry loop and no short-interval re-read papers over a missing push. So every gap in the reactive path stays visible as a delay in the product, which is why the frictions below matter rather than being absorbed.
- **A public store must serve its read key to whoever asks.** Discovery of other people's events is a plain read of the public scope, with no grant step. If that stopped holding, the product's primary discovery axis would be gone.
- **One deployment parameter is ours, not yours:** the wallet file we serve and its password. We pass them; the package reads no environment of its own.
## Frictions
**1. Resolving a document's inbox is not idempotent under concurrency.** The engagement states that resolving an inbox "throws rather than handing back a second one". It does not. **Four concurrent calls for one document produced three inboxes.** The four are ordinary and unavoidable: creating an event opens its inbox, the materialiser opens it to read, the watch opens it to subscribe, and the watch callback re-enters the materialiser — all within a fraction of a second, none aware of the others. The consequence is silent and total: the owner watches one inbox while sign-ups land in another, and a sign-up is simply never seen. We now funnel every call through one wrapper that de-duplicates in-flight resolutions per document for the session's lifetime. That wrapper is compensation for this friction, not a design of ours, and it only protects a single session — two sessions racing are still unprotected, because nothing on this surface makes the resolution idempotent where it actually lives.
**2. A deposit into an inbox you watch yourself produces no push. Verified twice.** The depositor's own session never materialises it. This is the normal case for us, not an edge: the host of a meeting point is often also the actor whose deposit must be processed, and its materialiser sits on its own inbox. So the owner is not woken by its own action, and the deposit waits for the next connection.
**3. A write to your own document is not re-read by the reactive read in the writing session.** Three observations of sixty seconds each: the value stays stale for the whole session. Combined with friction 2, this is what makes a participant count lag **one full connection** behind the write that produced it — the first reconnect after a sign-up still reads the old value, and only the second reads the true one. We compensate nowhere: papering over it would mean polling, which we forbid.
**4. There is no way to reset a test wallet.** The suite's data lives in the wallet file the deployment serves; nothing on this surface empties it, and recreating the browser profile does not touch it — two runs "on a fresh profile" measure the same accumulated state. Every scenario writes into that wallet and nothing removes what it wrote, so per-scenario duration climbs monotonically within a run and later scenarios die in their setup hook at its cap, silently, with nothing in the console. **The suite degrades to zero passing scenarios.** No reset primitive is published — no teardown call, no throwaway wallet — so there is nothing to call, and we will not fake one by bypassing our own enforcement point. This is the friction that costs us the most: it makes the `@data` layer's results non-reproducible, which is a property of the harness we cannot fix from here.
**5. The published signature of `watchShape` does not match the call that works.** It is published as `watchShape(query: ShapeQuery): ShapeObservable` — one argument, non-generic, and naming two types (`ShapeQuery`, `ShapeObservable`) that the engagement document never defines. What works, and what every read in the app goes through, is the **two-positional-argument** form with a type parameter: `watchShape<T>(shapeType, scope)`. Lower than the four above — we have a working call — but the document as written cannot be coded against for the single most-used read on the surface.
@@ -42,8 +42,8 @@ So: **write = direct SPARQL into the entity's document** (immediate, per-documen
**Graph convention (write into the anchored default graph).** A write passes the document's NURI as the **anchor** of `docs.sparqlUpdate` and writes the SPARQL body **without** an explicit `GRAPH <…>` clause; the shape read queries that same anchored default graph. This is the **canonical** form — to be kept for `writeEntity`, `updateEntityField` and `registration.ts`. It is a choice of **simplicity and uniformity**, not a round-trip necessity: an explicit `GRAPH` wrapper anchored to the same document does round-trip, so a "0 entities" symptom is never evidence of a graph mismatch — look at the test wallet first (`bdd-testing/caveat_wallet-bloat-hang`).
The same goes for **mutating an existing field** (e.g. `participantCount`): mutating a value in memory does not hold — the reactive read re-reads the **persisted** value from the broker (reverting to the old value) → persist through SPARQL (`updateEntityField`: DELETE then INSERT of the triple) so that the change sticks and the re-read agrees. Each field is written with the **right RDF term** according to the SHEX shape (xsd:integer / float / boolean, or an IRI for the `Participation.event`/`.user` references) — a missing or mistyped mandatory field makes the read **discard the entity** (it never round-trips). The entity's **subject** = its document's **NURI** (one entity = one document), which yields an `@id` of the form `did:ng:…`.
The same goes for **mutating an existing field** (e.g. `participantCount`): mutating a value in memory does not hold — the reactive read re-reads the **persisted** value from the broker (reverting to the old value) → persist through SPARQL (`updateEntityField`: one `DELETE … INSERT … WHERE` update, not a DELETE followed by a separate INSERT — the latter left a window where a reader could see the field briefly absent) so that the change sticks and the re-read agrees. Each field is written with the **right RDF term** according to the SHEX shape (xsd:integer / float / boolean, or an IRI for the `Participation.event`/`.user` references) — a missing or mistyped mandatory field makes the read **discard the entity** (it never round-trips). The entity's **subject** = its document's **NURI** (one entity = one document), which yields an `@id` of the form `did:ng:…`.
Identity corollary: a `Participation` carries a **mandatory** `fp:user` — never write it with an empty value (the entity would be discarded on read). What goes in it is `currentUserId`, i.e. the **NURI of the profile document the app read back in its own protected scope**; the app derives it from nothing, because it names no identity ([[decision_2026-08-10_the-barrier-names-no-identity]] in `app-security`). It therefore **arrives late**: a mutation fired before the protected read lands must refuse rather than write, which is what `joinEvent` does. See [[knowledge_context-internals]].
Identity corollary: a `Participation` carries a **mandatory** `fp:user` — never write it with an empty value (the entity would be discarded on read). What goes in it is `currentUserId`, i.e. the NURI of **the profile document this session OWNS** — never the identity it signed in as, which is opaque and never written into an entity ([[decision_2026-08-10_the-barrier-names-no-identity]] in `app-security`). It therefore **arrives late**: a mutation fired before that document resolves must **reject** rather than write, which is what `joinEvent` and `leaveEvent` do — they throw, and the screen's confirmation follows the write. See [[knowledge_context-internals]].
Sharing keys off a different space: `inbox.share(doc, toUser)` names a **person**, so bilateral connections (`declareConnections`) are declared with **normalized profile handles**, not document NURIs — the data context maps each peer IRI to that key before declaring, and skips peers whose profile it cannot read (they cannot be named).
@@ -13,7 +13,7 @@ Vocabulary reference. Every actor is a specialization of an authenticated **user
|---|---|
| **User** | Anyone with an account (a NextGraph wallet). The root of all the others. |
| **Connection ("friend")** | Another user I am connected to. Used to scope lists ("my friends who are attending…") and trust. Bilateral (accepted on both sides). |
| **Declarer of an event** | The user who inserted the event into Festipod. *Not necessarily the real organizer*: just whoever references it. **There is NO notion of "event host"**: the event is public, merely flagged by its declarer, who **is NOT required to attend** at creation no participation is written, the counter starts at 0, and the declarer can join/leave like anyone else (a product decision; on the data side see data-layer/[[knowledge_context-internals]] §participantCount). The "host" remains an actor at the **meeting point** level (next row), not at the event level. |
| **Declarer of an event** | The user who inserted the event into Festipod. *Not necessarily the real organizer*: just whoever references it. **There is NO notion of "event host"** — and this now holds all the way down: the event carries no host field at all, in the shape or in the app type (`data-layer` → [[knowledge_entities]]). The event is public, merely flagged by its declarer, who **is NOT required to attend**: at creation no participation is written, the counter starts at 0, and the declarer signs up and withdraws through the same path as anyone else. The declarer is nonetheless the event document's **owner**, hence its only writer (`app-security` → [[decision_2026-08-16_write-rights-are-the-owned-list]]). The "host" is an actor at the **meeting point** level (next row), never at the event level. |
| **Host of a meeting point** | The user who created a meeting point attached to an event. |
| **Participant in a meeting point** | A user signed up to a meeting point; in effect they become an attendee of the parent event. |
| **Member of an interest community** | A user subscribed to a community in order to discover the events it references. |
@@ -1,6 +1,6 @@
---
type: knowledge
summary: The product model of confidentiality and discovery — every entity lives in a SCOPE (public / protected / private) depending on who must see it; events & meeting points = public, network profile & participations = protected (network), settings = private; bilateral connections = the dialog scope; discovery = reading the public scope
summary: The product model of confidentiality and discovery — every entity lives in the SCOPE matching who must read it; other users' events are found through a shared index, not by reading the public scope
---
# Data scopes and discovery
@@ -31,13 +31,22 @@ Festipod **places each entity in its scope**; isolation between scopes is **hand
## Event discovery
A user discovers the events they did not create simply by **reading the `public` scope**: the app names the shape and the scope, and gets back everyone's public events, not just its own. That is the **primary** discovery axis; a **secondary**, relational one is layered on top (the connections' *protected* participations: "my friends are attending…").
Reading the `public` scope only ever returns **this session's own** public documents — there is no call that unions every user's public store (concept `data-layer`, [[contract_polyfill-surface]]). A declared event was therefore reachable by its declarer alone, which made the whole cross-user sign-up flow — the product's premise — unreachable.
**A user discovers events they did not create through a shared index**: an ordinary public document, indistinguishable from any other, that the app reaches by a reference it hardcodes. Anyone may deposit a reference to their event into it; only the index's owner curates those deposits into visible entries, ordered by the event's start date. This is the **primary** discovery axis, settled as [[decision_2026-08-17_discovery-through-a-shared-index]] (concept `data-layer`) — **no code consumes the index yet.** A **secondary**, relational axis stays layered on top: the connections' *protected* participations ("my friends are attending…").
Four costs come with it, accepted rather than solved: an event becomes findable only once **someone curates** the index, on no fixed schedule — curation is an operator role, not a feature that runs itself; an event declared before its document could carry the field the index reads never becomes findable through it, permanently; a withdrawn or later-corrected event **stays listed** — nothing here removes an entry, so a reader of the index must tolerate a reference that no longer resolves, or resolves to something changed; and an event's position in the list is **frozen at the moment it was curated** — correcting its date afterwards does not move it. Full mechanics and the rejected alternative: [[decision_2026-08-17_discovery-through-a-shared-index]].
> **Sign-up notification (product intent).** Signing up to a meeting point notifies its host: identified if the participant is one of the host's connections, **unnamed otherwise**. This "identified if known, unnamed otherwise" falls out of scope placement — the host can read the sign-up, but not the *protected* profile it points at unless they are connected. The app states the intent; it implements no filter of its own.
## Settled: the event write model is OWNER-ONLY
Who may update a declared event was long open — owner, wiki, or immutable. It is **owner: the declarer alone**, and not as a free product choice. The data model leaves no other reading: only a document's owner writes it, a read key never grants a write, and no call adds a writer, so "wiki" is not expressible at all. The declarer's own listing of their documents is what says which events are theirs, permanently (`app-security` → [[decision_2026-08-16_write-rights-are-the-owned-list]]).
This constrains **deduplication**: two declarations of the same real-world event cannot be merged by one declarer editing the other's document ([[brief_2026-06-15_event-deduplication]]).
## Open questions (business)
- **Event write model**: owner (the declarer alone) / wiki (everyone) / immutable? Central to deduplication ([[brief_2026-06-15_event-deduplication]]).
- **The host's identity towards an ordinary user**: a meeting point is readable by all, but should its host be identifiable? (pseudonym by default, a business card per meeting point, or anonymity lifted only for connections.)
- **Which fields of a sign-up can be edited**; **"friends of friends" discoverability**.
@@ -15,7 +15,9 @@ summary: What is implemented today (event + meeting point lifecycle, profiles, c
- User profile, profile update, profile sharing
- Friends list (connections), another user's profile
> Signing up to / withdrawing from a meeting point is **genuinely wired** on the data side: `joinEvent` persists a Participation, notifies the meeting point's host and creates a Notification; `leaveEvent` deletes the Participation authoritatively (see concept `data-layer`, [[caveat_participation-deletion]] on the data-layer side). Public discovery — a user seeing another user's public event — works too.
> **Signing up is wired step by step, and the count a bystander sees lags the flow by one connection.** Each step is honest: `joinEvent` persists a Participation and deposits into the event's inbox, where its owner reads it; `leaveEvent` deletes the Participation authoritatively (concept `data-layer`, [[caveat_participation-deletion]]); neither succeeds in silence, and the confirmation the user sees follows the write. Driven end to end in a real browser, the signer's own confirmation is instant and correct, but the `participantCount` a bystander sees stays at 0 through the session and the first reconnect, only catching up on the second — a known layer limitation, not data loss (concept `data-layer`, [[caveat_participant-count-one-connection-lag]]). Nothing short of exercising the whole thing end to end shows this kind of gap (concept `bdd-testing`, [[cookbook_live-probe]]). Treat the bullet above as *screens reachable*, not as an instantly-consistent journey. **Public discovery does not work yet**: a user sees another user's public event only once a shared index exists and is curated (concept `data-layer`, [[decision_2026-08-17_discovery-through-a-shared-index]]; concept `functional-domain`, [[knowledge_data-scopes-and-discovery]]) — no code consumes it today.
> **Updating an event is reserved to its declarer**, and the interface says so rather than discovering it late: the edit route is decided by ownership, and the confirmation follows the write instead of preceding it (concept `app-architecture`, [[knowledge_screen-pattern]]). Owner-only is not a policy choice here — it is the only reading the data model allows ([[knowledge_data-scopes-and-discovery]]).
> **The reconnection promise is guarded, not assumed.** "I come back later and my events and sign-ups are still there" is a product promise like any other, and it is the one whose failure would be least visible — nothing on screen distinguishes "you have nothing" from "it did not come back". The `src/modules/event/features/reconnexion-*.feature` scenarios of the `event` module are its non-regression guard; keep them meaningful, and read [[caveat_reconnexion-froide-local-vs-broker]] (concept `bdd-testing`) before trusting one of them green, because the natural setup proves less than it looks.
+1 -1
View File
@@ -3,7 +3,7 @@ type: _overview
summary: Stack and tooling — Bun-first (runtime, bundler, native APIs), build pipeline, and the project's commands
triggers:
keywords: [bun, bunx, build, bundler, vite, webpack, jest, npm, storybook, "bun.serve", hmr, tailwind, package.json]
paths: ["build.ts", "package.json", "bunfig.toml", "tsconfig.json", "src/index.ts", "src/index.html", ".storybook/**", "scripts/**"]
paths: ["build.ts", "package.json", "pnpm-lock.yaml", "Dockerfile", ".env.example", "bunfig.toml", "tsconfig.json", "src/index.ts", "src/index.html", ".storybook/**", "scripts/**"]
---
# Tech stack
@@ -0,0 +1,33 @@
---
type: caveat
summary: A running `bun run dev` never picks up a refreshed data-layer package — VERIFIED, not even across a real rebuild (new bundle hash, still the stale dependency). Only a restart serves the fresh copy; suspect a stale server before suspecting the code.
last_checked: 2026-08-16
---
# Pitfall: refreshing the data-layer package never reaches a running dev server, not even across a rebuild
`pnpm run overlay:polyfill` (or `overlay:indexing`, for the other provider) overlays the local checkout into `node_modules/<package>/` as real files, and keeps them current. **That is all it does.** A `bun run dev` already running goes on serving the package it loaded at startup, however many times the overlay is rewritten underneath it, and however many rebuilds happen in between.
**VERIFIED, controlled sandbox test with this project's own bun.** A dependency resolving to copy A, overlaid with copy B: the running server still serves A at +3 s and +13 s after the overlay. An edit to **application source** then triggers a genuine rebuild — a new bundle hash confirms it — and the rebuilt bundle **still serves A**. Only a restart serves B. So the mechanism is not "the watcher never fires because `node_modules` is excluded" — a rebuild the watcher DOES trigger still carries the stale dependency forward; the server's resolution of that import is pinned at process start, and a rebuild does not re-resolve it.
**So: restart `bun run dev` after every refresh of the package — a rebuild is not a substitute, even a real one.** There is no signal that you needed to; a stale server looks exactly like a current one.
## Why this is worth a leaf
VERIFIED 2026-08-16, and it cost about an hour. A defect had been fixed on the provider's side, the overlay was refreshed, and an automated probe on a freshly launched server confirmed the fix — 3 runs out of 3, clean. The same sequence performed by hand in a browser reproduced the defect immediately. The two observations looked irreconcilable, and the search went to the wallet, to prior state, to timing.
The dev server had been running for **six days**. It predated the package rename and the whole migration, and it was serving code from before the fix. The browser was running a different application from the one under test.
Two things made it hard to see. The failure mode is **silence** — nothing warns that the served code is old. And `scripts/overlay-local-checkout.ts` explicitly promised the opposite, that `bun --hot` would reload the copied file live; that claim is now corrected in the script, but a reader who trusted it would rule out the true cause first, which is exactly what happened.
## The reflex to build
When a fix does not appear to take effect, or when a hand-run and an automated run disagree, **check how long the server has been up before anything else**. It is one command, and it eliminates the cheapest hypothesis first:
```bash
ps -o lstart= -p $(pgrep -f 'bun --hot src/index.ts' | head -1)
```
Do not reach for "touch a source file to force a rebuild" as a lighter alternative to restarting — it does trigger a real rebuild, and the rebuild still serves the stale dependency. The same reasoning applies to anything else served out of `node_modules` — the trap is the location, not this package.
Related: [[cookbook_live-probe]] (bdd-testing) — a probe answers only for the code the server actually holds, so a stale server invalidates the probe's conclusion, not the product's behaviour.
@@ -1,13 +1,21 @@
---
type: knowledge
summary: Dev runs on bun --hot, prod builds through build.ts (Bun bundler + Tailwind plugin) into dist/, path alias @/* → ./src/*
last_checked: 2026-08-10
summary: Three run paths — dev AND production both serve from src/ (bun --hot / bun run start), while bun run build produces a dist/ that nothing serves; NODE_ENV therefore never means "I am a bundle"; path alias @/* → ./src/*
last_checked: 2026-08-16
---
# Build pipeline
- **Dev**: `bun --hot src/index.ts` (through `bun run dev`) — HMR, port 3000.
- **Prod**: `bun run build``build.ts` (Bun bundler + Tailwind plugin) → `dist/`.
## Three paths, and only two of them ever run
| Path | Command | What is served |
|---|---|---|
| **Dev** | `bun run dev``bun --hot src/index.ts` | **`src/`** — HMR, port 3000 |
| **Production** | `bun run start``NODE_ENV=production bun src/index.ts` | **`src/` as well** — Bun transpiles on the fly |
| Bundle | `bun run build``build.ts` (Bun bundler + Tailwind plugin) → `dist/` | **nothing** |
> ⚠️ **`dist/` has no consumer, and `NODE_ENV=production` does not mean "built".** The container copies the sources and runs `bun run start`, serving from `src/` exactly as dev does ([[knowledge_deployment]]) — **nothing ever serves `dist/`**, here or anywhere else. So any code that branches on `NODE_ENV` to answer *"am I a bundle?"* is wrong in the one place it matters: in production the answer is **no**. That inference shipped once, on the runtime-config fetch below, and the deployed app could sign nobody in. Ask the artifact you care about, never the environment.
- **Path alias**: `@/* → ./src/*` (declared in `tsconfig.json`, resolved relative to that file — `paths` has needed no `baseUrl` since TS 4.4).
> ⚠️ **Never put `baseUrl` back in `tsconfig.json`.** TypeScript 6 reports it as an **error that aborts the whole compilation**, and the failure is silent where it hurts: `tsc --noEmit` then exits **0 having checked nothing**, so the typecheck gate goes green over any amount of broken code. A green typecheck is only meaningful if `tsc` actually ran — treat an instant, output-free `tsc` as a red flag, not a fast pass.
@@ -23,7 +31,11 @@ The server serves `src/index.html`, which loads `src/app/frontend.tsx` (see `app
`build.ts` injects **compile-time globals** through `define`: `__FESTIPOD_SHARED_WALLET_PASSWORD__` from `FESTIPOD_SHARED_WALLET_PASSWORD`, and `__FESTIPOD_AUTO_SEED__` from `FESTIPOD_AUTO_SEED` — the dev auto-seed, OFF when absent. **Pitfall**: the `src/index.ts` server (used by `bun run dev` AND `bun run start`) bundles `index.html` through Bun's HTML import, which **applies no `define`** — neither `bun --define` nor `process.env` propagates there (verified). So an environment variable passed to `bun run dev` never reaches the frontend bundle along that path.
For those paths served from `src/`, the configuration therefore goes through the **runtime**: `src/index.ts` exposes `/festipod-config.json` (read from the environment), and the `src/app/frontend.tsx` entry **fetches it first**, sets the global, **then imports the app dynamically** (`await import('./App')`) — so that `sharedWallet.ts` reads the value when it is evaluated. In a `build.ts` bundle the value is already inlined by `define`, so the fetch is skipped (`NODE_ENV === 'production'`). Practical consequence: to exercise the "shared wallet" flow in dev **end to end** (download plus a working import), pass the REAL password of the e2e wallet **and** the file — the password shown on screen must match the imported `.ngw`, otherwise the import fails (a dummy value such as `1` merely makes the screen appear):
Everything served from `src/`**dev and production alike** — therefore takes its configuration from the **runtime**: `src/index.ts` exposes `/festipod-config.json` (read from the environment), and the `src/app/frontend.tsx` entry **fetches it first**, sets the global, **then imports the app dynamically** (`await import('./App')`) — so that `sharedWallet.ts` reads the value when it is evaluated.
**The fetch is skipped on one condition only: the global is already set** (which is what a `build.ts` bundle's `define` does, and nothing else does). The entry reads it through bracket access, so `define` — which rewrites the dotted form — leaves that read alone. The question is *"was the value inlined?"*, asked of the global itself; it was once asked as *"is `NODE_ENV` production?"*, which in this project means the opposite of what it looks like (see above) — the deployed app then skipped the only step that could give it a wallet, `ensureIdentity()` threw for want of one, and `/festipod-config.json` sat there served and unasked (`app-security` → [[caveat_shared-wallet-global-before-gate-import]]).
Practical consequence: to exercise the "shared wallet" flow in dev **end to end** (download plus a working import), pass the REAL password of the e2e wallet **and** the file — the password shown on screen must match the imported `.ngw`, otherwise the import fails (a dummy value such as `1` merely makes the screen appear):
```
FESTIPOD_SHARED_WALLET_PASSWORD=festipod-e2e-tests \
@@ -1,32 +1,61 @@
---
type: knowledge
summary: Deployment — multi-stage Bun Alpine Dockerfile; install through pnpm (git+node inside the image) but bun at runtime; runs `bun run start` from src/ (not dist/), EXPOSE 3000, env PORT/NODE_ENV; no CI/CD committed; dev goes through the portless wrapper
last_checked: 2026-07-14
summary: Deployment — multi-stage Bun Alpine Dockerfile; install through pnpm (git+node inside the image) but bun at runtime; runs `bun run start` from src/ (not dist/), EXPOSE 3000; the data-layer git dependency must be pinned to a tag/commit and match `contracts.yaml`'s ref; the shared wallet reaches the container through env vars, not a mount, because it isn't a secret; no CI/CD committed; dev goes through the portless wrapper
last_checked: 2026-08-17
---
# Deployment & infra
Nothing has actually been deployed with this shape yet — this leaf states what a deployment needs to line up, verified against the code and manifests, not a procedure that has been run end to end.
## Dockerfile
A `Dockerfile` exists (multi-stage Bun Alpine). **Installation goes through pnpm, but runtime/build/test stay on bun** (see [[knowledge_stack-and-commands]]):
- `FROM oven/bun:1-alpine`, `install` stage: `apk add --no-cache git nodejs npm` then `npm install -g pnpm@10.26.0` (the bun image has neither Node nor pnpm; Alpine's `apk nodejs` does not ship corepack), `COPY package.json pnpm-lock.yaml`, then `pnpm install --frozen-lockfile`. `git` is required because `@ng-eventually/polyfill` is a public **git+https** dependency (Gitea, no auth). `release` stage: copies `node_modules` plus the source.
- `ENV NODE_ENV=production`, `USER bun`, `EXPOSE 3000/tcp`, `ENTRYPOINT ["bun","run","start"]`.
**`bun` peer pitfall**: `bun-plugin-tailwind` declares `bun` as a peerDependency pnpm materializes the npm `bun` package and **creates a `node_modules/.bin/bun` shim** that shadows the `bun` from the PATH under `bun run`/`pnpm run`. Its postinstall is ignored by default broken shim `bun run start` fails. Fixed by approving the build: `pnpm.onlyBuiltDependencies: ["bun"]` in `package.json` (the postinstall then downloads the real binary). Without that, the whole pnpm migration breaks startup.
**`bun` peer pitfall — historical, and no longer reproducing.** `bun-plugin-tailwind` declares `bun` as a peerDependency, and pnpm used to materialize the npm `bun` package with a `node_modules/.bin/bun` shim that shadowed the real binary under `bun run`; its postinstall being skipped left a broken shim and `bun run start` failed. `pnpm.onlyBuiltDependencies: ["bun"]` in `package.json` was added for that. **VERIFIED 2026-08-17 in a built image: the shim does not appear at all**`bun` is absent from `node_modules/.bin`, and `which bun` resolves to the base image's `/usr/local/bin/bun`. So the approval is currently inert in this dependency shape. Keep it (it costs nothing and the shape can come back), but do not trust the mechanism as described without re-checking the built image — this paragraph described a live hazard and now describes a dormant one.
**Quirk**: `start` = `NODE_ENV=production bun src/index.ts` the container **runs the TypeScript source directly** (Bun transpiles on the fly), it **does not use `dist/`**. `bun run build` (→ `dist/`) is therefore **not** on the default production path. Serving the build would require changing the entrypoint.
**Asset paths are written `/../chunk-*.js`.** Verified in the built image's entry HTML. Browsers normalise that to `/chunk-*.js` at the root and it serves correctly, and the existing deployment already passes it through its proxy — so it works. It is still an odd literal: a proxy or CDN that rejects or rewrites `..` segments differently would break asset loading, and the symptom would be a blank page with 404s on chunks rather than anything naming the cause.
**`tailwindcss` is a devDependency the server needs at serve time, not only at build time.** `bunfig.toml`'s `[serve.static] plugins = ["bun-plugin-tailwind"]` applies to `Bun.serve`'s HTML-import serving — the path both `bun run dev` and `bun run start` use ([[knowledge_build-pipeline]]) — not only to `bun run build.ts`. The install stage must therefore keep installing devDependencies: no `--prod`, and `NODE_ENV` stays unset until the `release` stage, after `pnpm install --frozen-lockfile` has already run. Moving `ENV NODE_ENV=production` earlier, or adding `--prod` to the install, would drop `tailwindcss` and break every serve, dev included.
**Production runs the sources, and this is the normal path, not a quirk**: `start` = `NODE_ENV=production bun src/index.ts` → the container **runs the TypeScript directly** (Bun transpiles on the fly). `bun run build` (→ `dist/`) is on **no** path at all — nothing serves that directory, in this container or anywhere else; serving it would mean changing the entrypoint. Consequence for the code: in this deployment `NODE_ENV=production` says *how* the sources run, never *that they were bundled* — [[knowledge_build-pipeline]].
## The data-layer git dependency must stay pinned, and the pin must be checkable
`package.json` resolves `@ng-eventually/polyfill` from `git+https://…/ng-eventually.git#<ref>&path:/packages/polyfill` — the `path:` selector is what lets a subdirectory of the provider's repo be installed as the package. Two things follow, ahead of any real deployment:
- **`<ref>` must name a tag or a commit, never a branch.** A branch moves: the image was built against whatever commit the branch pointed to at build time, and the branch head can advance afterwards without the image changing — so "the same deployment" silently starts drifting from what it was actually built against. The tag-naming convention itself is the provider's call and is not settled yet; the requirement is only that the ref be immutable.
- **The same `<ref>` should also be the `ref:` of the `polyfill-surface` entry in `.project/contracts.yaml`.** That manifest pins the version of [[contract_polyfill-surface]] the app is coded against; when it names the same ref as `package.json`'s specifier, the contract the app was written for and the package actually installed name the same state, and a difference between the two becomes visible instead of silent. Both now name the **same commit**, which is the state a deployment can ship on. A tag is expected to replace that commit once the provider settles a naming convention — a one-line change in each of the two files, with the invariant unchanged: whatever the ref is, the two must agree.
**`pnpm install --frozen-lockfile` (the Dockerfile's install step) never regenerates — it only verifies.** `pnpm-lock.yaml` must already reproduce `package.json` exactly, so any change to the git specifier (ref, path, or package name) needs `pnpm install` run and the regenerated lockfile committed *before* the image can build; skipping that step fails the build outright, not silently. This has bitten once: the lockfile still named the old package and path after the dependency was renamed, so `--frozen-lockfile` refused and the image could not build until it was regenerated.
## CI/CD
**No** pipeline is committed (`.github/workflows/` absent, no Coolify config in the repo). A knowingly accepted blind spot. To host the Bun app, the `coolify-hosting` skill applies.
**A deployed origin IS embeddable in the hosted broker's iframe — VERIFIED 2026-08-17 in production**, on the first deployment carrying the injected wallet and the external data layer: a user signed in and saw their own data, which is only reachable through that iframe. The question had been open because nothing in this repo exercises it; it is settled for this origin, and it is settled by the deployment rather than by a test — **no scenario covers it**, so a change of origin, of proxy, or of the broker's embedding policy would be found by a person, not by the suite. [[caveat_firefox-lna-blocks-broker-iframe]] remains the one recorded failure mode, and it is a local-dev-origin one (`127.0.0.1` blocked by Firefox LNA).
## Environment variables
- `PORT` (default 3000), `NODE_ENV` (enables/disables HMR and the dev auto-seed — see concept `data-layer`).
- No `.env*` is committed (`.env` is gitignored). No secret management in the repo.
- No `.env*` is committed (`.env` is gitignored).
### The shared wallet: config, not a secret, not a mount
[[contract_polyfill-surface]] requires the app to serve a wallet file (`.ngw`) and pass its URL and password to `configure` as `sharedWallet: { fileUrl, password }`. `*.ngw` is gitignored and no deployment mounts one, so `src/index.ts` serves it from environment variables, read fresh on every request:
- `FESTIPOD_SHARED_WALLET_PASSWORD` — the password, always read this way (dev, tests, and deployments alike).
- `FESTIPOD_SHARED_WALLET_FILE` — a filesystem path to the `.ngw` file. The form local dev and the test harness use: the file sits on the machine's disk.
- `FESTIPOD_SHARED_WALLET_FILE_BASE64` — the file's bytes, base64-encoded. The form a deployment uses instead, since nothing mounts a `.ngw` into the container.
**Precedence is one-directional and does not fall through.** `FESTIPOD_SHARED_WALLET_FILE` wins whenever it is set, *even if the path turns out unreadable* — an unreadable path answers 404, it does **not** fall back to the base64 form. A deployment must set exactly one of the two; leaving a leftover `FESTIPOD_SHARED_WALLET_FILE` pointing nowhere in a deployment environment silently 404s instead of serving the base64 value that was actually intended. A malformed base64 value answers 500 naming the variable — never a 404, which would be indistinguishable from "not configured at all".
**Neither the password nor the wallet file is a secret**, and that is deliberate, not an oversight: the contract has the app hand both to every user who opens it — that is how a first-time device without its own wallet onboards. Provisioning them as protected/mounted storage would guard something the app already gives away by design; they travel as plain configuration instead, and a new host needs only its environment variables, nothing to mount.
## Dev
`bun run dev` = **`portless festipod bun --hot src/index.ts`** — it goes through the **`portless`** wrapper (an external port-management tool), not a bare `bun --hot`. HMR is active outside production.
**Reactive local link to the SDK**: in production the `@ng-eventually/polyfill` dependency comes from Gitea (git+https, pinned by `pnpm-lock.yaml`). When the provider's package has to be exercised from a local checkout, `pnpm run link:polyfill` (script `scripts/link-polyfill.ts`) replaces `node_modules/@ng-eventually/polyfill` with a **real copy** of that checkout (location overridable with `NG_EVENTUALLY_LOCAL`) — **without** its own `node_modules/@ng-org` — and resyncs on every edit. Copying rather than symlinking is what keeps **a single `@ng-org/*` instance** installed: a symlink would drag in a second one and the SDK would stop working. To go back to the committed state: `pnpm install`.
**Reactive local overlay for the SDK**: in production the `@ng-eventually/polyfill` dependency comes from Gitea (git+https, pinned by `pnpm-lock.yaml`), and `@ng-helpers/indexing` likewise. When a provider's package has to be exercised from a local checkout, `pnpm run overlay:polyfill` or `pnpm run overlay:indexing` (script `scripts/overlay-local-checkout.ts`, one provider per run) replaces `node_modules/<package>` with a **real copy** of that checkout (location overridable with `NG_EVENTUALLY_LOCAL` / `NG_HELPERS_LOCAL`) — **without** its own `node_modules/*` — and resyncs on every edit. Copying rather than symlinking is what keeps a **single instance** of every package the provider shares with Festipod installed (`@ng-org/*`, and for `indexing`, `@ng-eventually/polyfill` itself): a symlink would drag in a second one and the SDK would stop working. To go back to the committed state: `pnpm install`.
@@ -1,6 +1,6 @@
---
type: knowledge
summary: Stack components (Bun runtime/build/test, install through pnpm, React, NextGraph, Storybook, Cucumber, Tailwind-inside-the-build) and the real list of package.json scripts, quirks included (cucumber through node+tsx, link:polyfill for the reactive local link)
summary: Stack components (Bun runtime/build/test, install through pnpm, React, NextGraph, Storybook, Cucumber, Tailwind-inside-the-build) and the real list of package.json scripts, quirks included (cucumber through node+tsx, overlay:polyfill/overlay:indexing for the reactive local overlay)
---
# Stack & commands
@@ -23,7 +23,7 @@ summary: Stack components (Bun runtime/build/test, install through pnpm, React,
|---|---|
| `dev` | `portless festipod bun --hot src/index.ts` — dev with HMR through the `portless` wrapper (see [[knowledge_deployment]]) |
| `start` | `NODE_ENV=production bun src/index.ts` — production, served from `src/` (not `dist/`) |
| `build` | `bun run build.ts` — Bun bundler + Tailwind → `dist/` ([[knowledge_build-pipeline]]) |
| `build` | `bun run build.ts` — Bun bundler + Tailwind → `dist/`, **which nothing serves**: production runs `start`, from `src/` ([[knowledge_build-pipeline]]) |
| `test:cucumber` | chains `cucumber:run``cucumber:report``features:parse``steps:extract` |
| `cucumber:run` | `node --import tsx/esm node_modules/@cucumber/cucumber/bin/cucumber.js`**through Node+tsx, not Bun** (Playwright/happy-dom plugin compatibility), and through the package's **actual JS entry**, not the `.bin/` shim (see Pitfalls) |
| `test:data` | same, with `--tags @data` |
@@ -33,11 +33,11 @@ summary: Stack components (Bun runtime/build/test, install through pnpm, React,
| `steps:extract` | `bun scripts/extract-step-definitions.ts` |
| `build:orm` | `rdf-orm build --input ./src/shared/shapes/shex --output ./src/shared/shapes/orm` |
| `build:ng` | `bash scripts/build-ng-packages.sh` — (re)builds the NextGraph packages from a local source (optional tool) |
| `link:polyfill` | `bun scripts/link-polyfill.ts`**reactive** local link to `@ng-eventually/polyfill` (copy-overlay + watcher). Details in [[knowledge_deployment]]. |
| `overlay:polyfill` / `overlay:indexing` | `bun scripts/overlay-local-checkout.ts <provider>`**reactive** local overlay of a provider's checkout (`@ng-eventually/polyfill` or `@ng-helpers/indexing`; copy-overlay + watcher, `--once` for a single pass). Details in [[knowledge_deployment]]. |
| `storybook` / `build-storybook` | Storybook dev (6006) / static build |
## Pitfalls
- **`cucumber:run`/`test:data` run under Node+tsx**, not Bun — the test plugins do not load under a native Bun import. Do not "bunify" these scripts.
- **Never point a script at `node_modules/.bin/*`.** Installation goes through pnpm ([[rule_bun-first]] §exception), which puts **shell shims** there rather than JS entries: `node --import tsx/esm node_modules/.bin/cucumber-js` fails. Invoke the package's **actual JS entry** (`node_modules/@cucumber/cucumber/bin/cucumber.js`). This holds for any npm script that would launch a dependency's binary under `node`.
- **`build:orm` was broken until 2026-07-28**: it targeted `./src/shapes/`, which does not exist (the shapes live under `src/shared/shapes/`), so the command exited with an error. **Fixed in `package.json`** — it now runs. Beware of a side effect: the generator has moved on since the committed bindings were produced, so a run reformats them and drops the `: Schema` annotation. That regeneration is a **tool-version bump, not a content fix** — treat it as its own validated change, do not let it ride along.
- **`build:orm` was broken until 2026-07-28**: it targeted `./src/shapes/`, which does not exist (the shapes live under `src/shared/shapes/`), so the command exited with an error. **Fixed in `package.json`** — it now runs, and reproducibly: regenerating from an *unchanged* `.shex` reproduces the committed bindings byte-for-byte. Verify that before trusting an ORM diff as your own — run the generator once on the shape untouched, then again after your edit, so the diff shown is the edit alone (concept `data-layer` → [[knowledge_nextgraph-stack]]).
@@ -30,7 +30,7 @@ API details: [[knowledge_bun-apis]].
**Dependencies are installed with `pnpm install`, not `bun install`.** Everything else stays on Bun: **runtime, build, test, scripts** (`bun run dev`, `bun build`, `bun test`, `bunx`). Only the installation step changes package manager.
**Why.** The data SDK is installed from a Gitea repository as a **subdirectory** git dependency: `git+https://…/ng-eventually.git#main&path:/packages/polyfill`. pnpm (≥ 10.26) resolves that `#<ref>&path:/…` format and guarantees a **single** instance of `@ng-org/*`; `bun install` does not handle this workflow cleanly. The reference lockfile is therefore `pnpm-lock.yaml`, and the reactive local link goes through `pnpm run link:polyfill` (see [[knowledge_deployment]]).
**Why.** The data SDK is installed from a Gitea repository as a **subdirectory** git dependency: `git+https://…/ng-eventually.git#main&path:/packages/polyfill`. pnpm (≥ 10.26) resolves that `#<ref>&path:/…` format and guarantees a **single** instance of `@ng-org/*`; `bun install` does not handle this workflow cleanly. The reference lockfile is therefore `pnpm-lock.yaml`, and the reactive local overlay goes through `pnpm run overlay:polyfill` (`overlay:indexing` for the other provider; see [[knowledge_deployment]]).
**Practical consequence.** npm scripts that relied on `node_modules/.bin/*` may break (pnpm puts shell shims there, not JS entries) — call the package's actual JS entry (e.g. `node_modules/@cucumber/cucumber/bin/cucumber.js`) rather than the `.bin/` shim.
+27 -4
View File
@@ -1,11 +1,19 @@
# Inter-repo contracts. Festipod is a CONSUMER only: it publishes no interface of its own,
# and it consumes exactly one — the SDK surface `@ng-eventually/polyfill` engages toward the
# applications built on it.
# and it consumes two — the SDK surface `@ng-eventually/polyfill` engages toward the
# applications built on it, and the indexing layer `ng-helpers` engages toward the
# applications that need to make things findable.
#
# The pulled copy under `into:` IS the specification Festipod codes against. An agent
# working here reads that copy and never opens the provider's own source: a gap is raised
# upstream (see `data-layer/rule_app-uses-sdk-surface-only`), never peeked around.
#
# Each interface gets its own FOLDER inside the concept that owns it, holding the pulled
# engagement and — once Festipod actually consumes the interface — the `usage_festipod.md`
# declaration beside it. Both interfaces land in `data-layer`: it is the concept that owns
# how Festipod uses an external data surface, including the machinery behind discovery
# (`functional-domain` owns the product intent of discovery and explicitly delegates its
# technical how to the data SDK).
#
# `pullFrom:` names the canonical identity of the provider (its git remote URL + the
# repo-relative path of the leaf), so the manifest travels with the branch. Per-developer
# access to a local checkout lives in `.project/contracts.local.yaml`, which is never
@@ -13,10 +21,25 @@
consume:
- contract: polyfill-surface
into: concepts/data-layer
into: concepts/data-layer/polyfill-surface/
type: git
# BLOCKED on the provider: it has moved this leaf into its own interface folder
# (`.../app-contract/polyfill-surface/contract_polyfill-surface.md`) and has NOT pushed
# that move. The path below is the only one that resolves at a pushed commit, and it is
# the path the local copy's stamp came from — so it stays until the move is pushed.
# Until then `pull` and `check` both fail on this entry (the file no longer exists at
# this path in a working copy that has the move). Adopt the new path and re-pull the
# moment the provider pushes; the pulled copy's basename does not change.
pullFrom: https://gitea.reconnexion.apps.gueraud.net/Reconnexion/ng-eventually.git/.project/concepts/app-contract/contract_polyfill-surface.md
# The contract is published from the branch that carries it while that branch is still
# in flight; it moves to `main` once the provider lands it there. Flip this line then,
# and re-pull — the stamp records which commit the local copy actually came from.
ref: caps-p1a-and-virtual-user-boundary
ref: a8d53010c227462cc9317e9be499c2100ca8d533
- contract: indexing-layer
into: concepts/data-layer/indexing-layer/
type: git
pullFrom: https://gitea.reconnexion.apps.gueraud.net/Sylvain/ng-helpers.git/.project/concepts/indexing/indexing-layer/contract_indexing-layer.md
# Pinned on the TAG, never on a branch: a branch moves under us and the pin would stop
# naming a state anyone can go back to. Re-pin to the next tag at each upgrade.
ref: v1.0.1
+1 -1
View File
@@ -3,7 +3,7 @@ FROM oven/bun:1-alpine AS base
WORKDIR /app
# Install dependencies with pnpm.
# - git: the @ng-eventually/client polyfill is a git+https (public Gitea) dependency → no auth.
# - git: @ng-eventually/polyfill is a git+https (public Gitea) dependency → no auth.
# - nodejs + npm: pnpm is a Node CLI; we pin the exact pnpm version via `npm i -g`
# (Alpine's nodejs package does not bundle corepack).
# The `bun` npm peer (pulled by bun-plugin-tailwind) is approved to build in package.json
+4 -2
View File
@@ -17,12 +17,14 @@
"build:orm": "rdf-orm build --input ./src/shared/shapes/shex --output ./src/shared/shapes/orm",
"validate": "bun scripts/validate.ts",
"build:ng": "bash scripts/build-ng-packages.sh",
"link:polyfill": "bun scripts/link-polyfill.ts",
"overlay:polyfill": "bun scripts/overlay-local-checkout.ts polyfill",
"overlay:indexing": "bun scripts/overlay-local-checkout.ts indexing",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
"dependencies": {
"@ng-eventually/polyfill": "git+https://gitea.reconnexion.apps.gueraud.net/Reconnexion/ng-eventually.git#main&path:/packages/polyfill",
"@ng-eventually/polyfill": "git+https://gitea.reconnexion.apps.gueraud.net/Reconnexion/ng-eventually.git#a8d53010c227462cc9317e9be499c2100ca8d533&path:/packages/polyfill",
"@ng-helpers/indexing": "git+https://gitea.reconnexion.apps.gueraud.net/Sylvain/ng-helpers.git#v1.0.1",
"@ng-org/alien-deepsignals": "0.1.2-alpha.11",
"@ng-org/orm": "0.1.2-alpha.18",
"@ng-org/shex-orm": "0.1.2-alpha.8",
+19 -6
View File
@@ -8,9 +8,12 @@ importers:
.:
dependencies:
'@ng-eventually/client':
specifier: git+https://gitea.reconnexion.apps.gueraud.net/Reconnexion/ng-eventually.git#main&path:/packages/client
version: git+https://gitea.reconnexion.apps.gueraud.net/Reconnexion/ng-eventually.git#1f0bae461e461c9fddd7215f972418acb2b4a989&path:/packages/client(@ng-org/alien-deepsignals@0.1.2-alpha.11(react@19.2.7))(@ng-org/orm@0.1.2-alpha.18(react@19.2.7))(@ng-org/shex-orm@0.1.2-alpha.8(typescript@6.0.3))(@ng-org/web@0.1.2-alpha.13)
'@ng-eventually/polyfill':
specifier: git+https://gitea.reconnexion.apps.gueraud.net/Reconnexion/ng-eventually.git#a8d53010c227462cc9317e9be499c2100ca8d533&path:/packages/polyfill
version: git+https://gitea.reconnexion.apps.gueraud.net/Reconnexion/ng-eventually.git#a8d53010c227462cc9317e9be499c2100ca8d533&path:/packages/polyfill(@ng-org/alien-deepsignals@0.1.2-alpha.11(react@19.2.7))(@ng-org/orm@0.1.2-alpha.18(react@19.2.7))(@ng-org/shex-orm@0.1.2-alpha.8(typescript@6.0.3))(@ng-org/web@0.1.2-alpha.13)
'@ng-helpers/indexing':
specifier: git+https://gitea.reconnexion.apps.gueraud.net/Sylvain/ng-helpers.git#v1.0.1
version: git+https://gitea.reconnexion.apps.gueraud.net/Sylvain/ng-helpers.git#d615a72775cc9110de64b9b7fcc1d0d6c6d127ea(@ng-eventually/polyfill@git+https://gitea.reconnexion.apps.gueraud.net/Reconnexion/ng-eventually.git#a8d53010c227462cc9317e9be499c2100ca8d533&path:/packages/polyfill(@ng-org/alien-deepsignals@0.1.2-alpha.11(react@19.2.7))(@ng-org/orm@0.1.2-alpha.18(react@19.2.7))(@ng-org/shex-orm@0.1.2-alpha.8(typescript@6.0.3))(@ng-org/web@0.1.2-alpha.13))
'@ng-org/alien-deepsignals':
specifier: 0.1.2-alpha.11
version: 0.1.2-alpha.11(react@19.2.7)
@@ -488,8 +491,8 @@ packages:
'@emnapi/core': ^1.7.1
'@emnapi/runtime': ^1.7.1
'@ng-eventually/client@git+https://gitea.reconnexion.apps.gueraud.net/Reconnexion/ng-eventually.git#1f0bae461e461c9fddd7215f972418acb2b4a989&path:/packages/client':
resolution: {commit: 1f0bae461e461c9fddd7215f972418acb2b4a989, path: /packages/client, repo: https://gitea.reconnexion.apps.gueraud.net/Reconnexion/ng-eventually.git, type: git}
'@ng-eventually/polyfill@git+https://gitea.reconnexion.apps.gueraud.net/Reconnexion/ng-eventually.git#a8d53010c227462cc9317e9be499c2100ca8d533&path:/packages/polyfill':
resolution: {commit: a8d53010c227462cc9317e9be499c2100ca8d533, path: /packages/polyfill, repo: https://gitea.reconnexion.apps.gueraud.net/Reconnexion/ng-eventually.git, type: git}
version: 0.0.0
peerDependencies:
'@ng-org/alien-deepsignals': '*'
@@ -506,6 +509,12 @@ packages:
'@ng-org/web':
optional: true
'@ng-helpers/indexing@git+https://gitea.reconnexion.apps.gueraud.net/Sylvain/ng-helpers.git#d615a72775cc9110de64b9b7fcc1d0d6c6d127ea':
resolution: {commit: d615a72775cc9110de64b9b7fcc1d0d6c6d127ea, repo: https://gitea.reconnexion.apps.gueraud.net/Sylvain/ng-helpers.git, type: git}
version: 1.0.1
peerDependencies:
'@ng-eventually/polyfill': '*'
'@ng-org/alien-deepsignals@0.1.2-alpha.11':
resolution: {integrity: sha512-nPgqOrheAda/pW5FHgSb45SrSZWuyMyEVqO683ijEsVPpD105bngfh92PPfcRoRnFzGSoKXa3CfuqUHi2+qVIQ==}
peerDependencies:
@@ -3727,13 +3736,17 @@ snapshots:
'@tybys/wasm-util': 0.10.3
optional: true
'@ng-eventually/client@git+https://gitea.reconnexion.apps.gueraud.net/Reconnexion/ng-eventually.git#1f0bae461e461c9fddd7215f972418acb2b4a989&path:/packages/client(@ng-org/alien-deepsignals@0.1.2-alpha.11(react@19.2.7))(@ng-org/orm@0.1.2-alpha.18(react@19.2.7))(@ng-org/shex-orm@0.1.2-alpha.8(typescript@6.0.3))(@ng-org/web@0.1.2-alpha.13)':
'@ng-eventually/polyfill@git+https://gitea.reconnexion.apps.gueraud.net/Reconnexion/ng-eventually.git#a8d53010c227462cc9317e9be499c2100ca8d533&path:/packages/polyfill(@ng-org/alien-deepsignals@0.1.2-alpha.11(react@19.2.7))(@ng-org/orm@0.1.2-alpha.18(react@19.2.7))(@ng-org/shex-orm@0.1.2-alpha.8(typescript@6.0.3))(@ng-org/web@0.1.2-alpha.13)':
optionalDependencies:
'@ng-org/alien-deepsignals': 0.1.2-alpha.11(react@19.2.7)
'@ng-org/orm': 0.1.2-alpha.18(react@19.2.7)
'@ng-org/shex-orm': 0.1.2-alpha.8(typescript@6.0.3)
'@ng-org/web': 0.1.2-alpha.13
'@ng-helpers/indexing@git+https://gitea.reconnexion.apps.gueraud.net/Sylvain/ng-helpers.git#d615a72775cc9110de64b9b7fcc1d0d6c6d127ea(@ng-eventually/polyfill@git+https://gitea.reconnexion.apps.gueraud.net/Reconnexion/ng-eventually.git#a8d53010c227462cc9317e9be499c2100ca8d533&path:/packages/polyfill(@ng-org/alien-deepsignals@0.1.2-alpha.11(react@19.2.7))(@ng-org/orm@0.1.2-alpha.18(react@19.2.7))(@ng-org/shex-orm@0.1.2-alpha.8(typescript@6.0.3))(@ng-org/web@0.1.2-alpha.13))':
dependencies:
'@ng-eventually/polyfill': git+https://gitea.reconnexion.apps.gueraud.net/Reconnexion/ng-eventually.git#a8d53010c227462cc9317e9be499c2100ca8d533&path:/packages/polyfill(@ng-org/alien-deepsignals@0.1.2-alpha.11(react@19.2.7))(@ng-org/orm@0.1.2-alpha.18(react@19.2.7))(@ng-org/shex-orm@0.1.2-alpha.8(typescript@6.0.3))(@ng-org/web@0.1.2-alpha.13)
'@ng-org/alien-deepsignals@0.1.2-alpha.11(react@19.2.7)':
dependencies:
alien-signals: 2.0.8
-106
View File
@@ -1,106 +0,0 @@
#!/usr/bin/env bun
/**
* link-polyfill.ts Reactive local link for the @ng-eventually/polyfill polyfill.
*
* WHY a copy-overlay and not a symlink:
* The committed prod dependency installs @ng-eventually/polyfill from Gitea (git+https)
* into pnpm's store WITHOUT its own node_modules/@ng-org @ng-org/web resolves up to
* Festipod ONE @ng-org instance (one verifier). The local polyfill CHECKOUT, however,
* carries its own node_modules/@ng-org/* (symlinks into the ng-eventually-js monorepo
* store). Symlinking node_modules/@ng-eventually/polyfill to that checkout puts the
* checkout's @ng-org in the resolution path a SECOND @ng-org instance broken SDK
* (two verifiers). So we overlay a real directory that contains ONLY the polyfill's
* source (no node_modules) and keep it in sync by copying @ng-org still resolves to
* Festipod, single instance preserved.
*
* WHAT IT DOES:
* 1. Replaces node_modules/@ng-eventually/polyfill (the pnpm store symlink) with a real
* directory holding the local polyfill's package.json + src (NO node_modules).
* 2. Asserts the single-instance invariant (same @ng-org/web realpath from Festipod and
* from the overlay) aborts if it would break.
* 3. Watches the local polyfill src and copies each change into the overlay, so
* `bun --hot` (bun run dev) reloads the edited file live.
*
* USAGE (reactive dev):
* Terminal 1: pnpm run link:polyfill # overlays local source, then watches
* Terminal 2: bun run dev # portless festipod bun --hot src/index.ts
* Edit files under packages/polyfill/src they land in node_modules bun --hot reloads.
*
* pnpm run link:polyfill --once # overlay + verify, no watch (CI / one-shot)
* Return to the committed git-installed dependency: pnpm install
*
* Override the local checkout path with NG_EVENTUALLY_LOCAL=/path/to/packages/polyfill.
*/
import { existsSync, lstatSync, mkdirSync, rmSync, cpSync, copyFileSync, realpathSync } from "node:fs";
import { watch } from "node:fs";
import { join, dirname } from "node:path";
const FESTIPOD = realpathSync(join(import.meta.dir, ".."));
const LOCAL =
process.env.NG_EVENTUALLY_LOCAL ??
"/home/sylvain/projects/nextgraph/ng-eventually-js/packages/polyfill";
const TARGET = join(FESTIPOD, "node_modules", "@ng-eventually", "polyfill");
const SRC_LOCAL = join(LOCAL, "src");
const SRC_TARGET = join(TARGET, "src");
const ONCE = process.argv.includes("--once");
function fail(msg: string): never {
console.error(`✖ link:polyfill — ${msg}`);
process.exit(1);
}
if (!existsSync(join(LOCAL, "package.json"))) {
fail(`local polyfill not found at ${LOCAL} (set NG_EVENTUALLY_LOCAL to override)`);
}
// 1. Replace the pnpm store symlink with a real overlay dir (metadata + src, NO node_modules).
console.log(`→ overlaying local polyfill: ${LOCAL}`);
if (existsSync(TARGET) || lstatSync(TARGET, { throwIfNoEntry: false })) {
rmSync(TARGET, { recursive: true, force: true });
}
mkdirSync(TARGET, { recursive: true });
for (const meta of ["package.json", "tsconfig.json", "README.md"]) {
const from = join(LOCAL, meta);
if (existsSync(from)) copyFileSync(from, join(TARGET, meta));
}
// Copy src fresh (NEVER a node_modules dir — that is what guarantees single @ng-org instance).
cpSync(SRC_LOCAL, SRC_TARGET, { recursive: true });
// 2. Assert the single-instance invariant.
const fromFestipod = realpathSync(Bun.resolveSync("@ng-org/web", FESTIPOD));
const overlayReal = realpathSync(TARGET);
const fromPolyfill = realpathSync(Bun.resolveSync("@ng-org/web", overlayReal));
console.log(` @ng-org/web (Festipod): ${fromFestipod}`);
console.log(` @ng-org/web (overlay) : ${fromPolyfill}`);
if (fromFestipod !== fromPolyfill) {
fail(
"single-instance invariant BROKEN — @ng-org/web resolves to two different realpaths.\n" +
" The overlay must not contain its own node_modules/@ng-org. Aborting.",
);
}
console.log("✓ single @ng-org/web instance preserved");
if (ONCE) {
console.log("✓ overlay ready (--once, not watching)");
process.exit(0);
}
// 3. Watch and copy on change so `bun --hot` sees live edits.
console.log(`👀 watching ${SRC_LOCAL}${SRC_TARGET} (Ctrl-C to stop)`);
watch(SRC_LOCAL, { recursive: true }, (_event, filename) => {
if (!filename) return;
const from = join(SRC_LOCAL, filename);
const to = join(SRC_TARGET, filename);
try {
if (existsSync(from)) {
mkdirSync(dirname(to), { recursive: true });
copyFileSync(from, to);
console.log(`${filename}`);
} else if (existsSync(to)) {
rmSync(to, { force: true });
console.log(`${filename} (removed)`);
}
} catch (err) {
console.error(` ! failed to sync ${filename}:`, err);
}
});
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env bun
/**
* overlay-local-checkout.ts Reactive local overlay of a data-layer PROVIDER's checkout
* into node_modules. One script, one provider per run; only the paths differ between them.
*
* PROVIDERS (first non-flag argument; defaults to `polyfill`):
* polyfill node_modules/@ng-eventually/polyfill override: NG_EVENTUALLY_LOCAL
* indexing node_modules/@ng-helpers/indexing override: NG_HELPERS_LOCAL
*
* WHY a copy-overlay and not a symlink (identical for every provider):
* A committed prod dependency installs from Gitea (git+https) into pnpm's store WITHOUT
* its own node_modules/@ng-org, so @ng-org/web resolves UP to Festipod ONE @ng-org
* instance (one verifier). A local CHECKOUT, however, carries its own node_modules/*
* (links into that provider's own dev tree). Symlinking node_modules/<pkg> to the
* checkout would put the checkout's copies in the resolution path a SECOND @ng-org
* (and, for `indexing`, a second @ng-eventually/polyfill) broken SDK, two verifiers.
* So we overlay a real directory containing ONLY the provider's source (no node_modules):
* shared packages still resolve up to Festipod, single instance preserved.
*
* WHAT IT DOES:
* 1. Replaces node_modules/<package> (the pnpm store symlink) with a real directory
* holding the local checkout's package.json + src (NO node_modules).
* 2. Asserts the single-instance invariant every package this provider SHARES with
* Festipod must resolve to the same realpath from Festipod and from the overlay
* and aborts if it would break.
* 3. Watches the local checkout's src and copies each change into the overlay.
*
* RESTART `bun run dev` AFTER THIS SCRIPT WRITES a rebuild is NOT a substitute.
* A running dev server NEVER picks up a package refreshed inside node_modules, not even
* across a genuine rebuild: VERIFIED in a controlled test, an application-source edit
* produced a new bundle hash and the rebuilt bundle STILL carried the stale dependency.
* The server's resolution of that import is pinned at process start and a rebuild does not
* re-resolve it. Only restarting serves the fresh copy, and nothing warns you a stale
* server looks exactly like a current one. See
* .project/concepts/tech-stack/caveat_polyfill-overlay-needs-a-dev-restart.md, which cost
* an hour to learn. Watching copies the files; it does not make anything reload them.
*
* USAGE (reactive dev):
* Terminal 1: pnpm run overlay:polyfill # or: pnpm run overlay:indexing
* Terminal 2: bun run dev # portless festipod bun --hot src/index.ts
* Edit the checkout's src it lands in node_modules RESTART dev to pick it up.
*
* pnpm run overlay:indexing --once # overlay + verify, no watch (CI / one-shot)
* Return to the committed git-installed dependencies: pnpm install
*/
import { existsSync, lstatSync, mkdirSync, rmSync, cpSync, copyFileSync, realpathSync } from "node:fs";
import { watch } from "node:fs";
import { join, dirname } from "node:path";
interface Provider {
/** Package as installed, e.g. "@ng-eventually/polyfill" — also its node_modules path. */
readonly packageName: string;
/** Local checkout used when the env override is unset. */
readonly defaultLocal: string;
/** Env var overriding the local checkout path. */
readonly envOverride: string;
/**
* Packages this provider SHARES with Festipod and that must stay single-instance.
* Each is resolved from Festipod and from the overlay; the realpaths must match.
*/
readonly singletons: readonly string[];
}
const PROVIDERS: Record<string, Provider> = {
polyfill: {
packageName: "@ng-eventually/polyfill",
defaultLocal: "/home/sylvain/projects/nextgraph/ng-eventually-js/packages/polyfill",
envOverride: "NG_EVENTUALLY_LOCAL",
singletons: ["@ng-org/web"],
},
indexing: {
packageName: "@ng-helpers/indexing",
defaultLocal: "/home/sylvain/projects/nextgraph/ng-helpers",
envOverride: "NG_HELPERS_LOCAL",
// Consumes the polyfill, so BOTH it and the verifier underneath must stay single.
singletons: ["@ng-eventually/polyfill", "@ng-org/web"],
},
};
const FESTIPOD = realpathSync(join(import.meta.dir, ".."));
const args = process.argv.slice(2);
const ONCE = args.includes("--once");
const KEY = args.find((a) => !a.startsWith("-")) ?? "polyfill";
function fail(msg: string): never {
console.error(`✖ overlay:${KEY}${msg}`);
process.exit(1);
}
const provider = PROVIDERS[KEY];
if (!provider) {
fail(`unknown provider "${KEY}" — expected one of: ${Object.keys(PROVIDERS).join(", ")}`);
}
const LOCAL = process.env[provider.envOverride] ?? provider.defaultLocal;
const TARGET = join(FESTIPOD, "node_modules", ...provider.packageName.split("/"));
const SRC_LOCAL = join(LOCAL, "src");
const SRC_TARGET = join(TARGET, "src");
if (!existsSync(join(LOCAL, "package.json"))) {
fail(`local checkout not found at ${LOCAL} (set ${provider.envOverride} to override)`);
}
if (!existsSync(SRC_LOCAL)) {
fail(`local checkout has no src/ at ${SRC_LOCAL}`);
}
// 1. Replace the pnpm store symlink with a real overlay dir (metadata + src, NO node_modules).
console.log(`→ overlaying local ${provider.packageName}: ${LOCAL}`);
if (existsSync(TARGET) || lstatSync(TARGET, { throwIfNoEntry: false })) {
rmSync(TARGET, { recursive: true, force: true });
}
mkdirSync(TARGET, { recursive: true });
for (const meta of ["package.json", "tsconfig.json", "README.md"]) {
const from = join(LOCAL, meta);
if (existsSync(from)) copyFileSync(from, join(TARGET, meta));
}
// Copy src fresh (NEVER a node_modules dir — that is what guarantees single instances).
cpSync(SRC_LOCAL, SRC_TARGET, { recursive: true });
// 2. Assert the single-instance invariant for every package shared with Festipod.
const overlayReal = realpathSync(TARGET);
for (const spec of provider.singletons) {
const fromFestipod = realpathSync(Bun.resolveSync(spec, FESTIPOD));
const fromOverlay = realpathSync(Bun.resolveSync(spec, overlayReal));
console.log(` ${spec} (Festipod): ${fromFestipod}`);
console.log(` ${spec} (overlay) : ${fromOverlay}`);
if (fromFestipod !== fromOverlay) {
fail(
`single-instance invariant BROKEN — ${spec} resolves to two different realpaths.\n` +
" The overlay must not contain its own node_modules. Aborting.",
);
}
}
console.log(`✓ single instance preserved for: ${provider.singletons.join(", ")}`);
if (ONCE) {
console.log("✓ overlay ready (--once, not watching)");
process.exit(0);
}
// 3. Watch and copy on change. This keeps the overlay CURRENT; it does NOT make a running
// dev server notice — not even across a rebuild (see the header). Restart it.
console.log(`👀 watching ${SRC_LOCAL}${SRC_TARGET} (Ctrl-C to stop)`);
watch(SRC_LOCAL, { recursive: true }, (_event, filename) => {
if (!filename) return;
const from = join(SRC_LOCAL, filename);
const to = join(SRC_TARGET, filename);
try {
if (existsSync(from)) {
mkdirSync(dirname(to), { recursive: true });
copyFileSync(from, to);
console.log(`${filename}`);
} else if (existsSync(to)) {
rmSync(to, { force: true });
console.log(`${filename} (removed)`);
}
} catch (err) {
console.error(` ! failed to sync ${filename}:`, err);
}
});
+20 -7
View File
@@ -4,19 +4,32 @@
*
* It is included in `src/index.html`.
*
* Before loading the app tree it pulls the RUNTIME shared-wallet config (dev
* server + `bun run start`, which serve from src/ and so miss build.ts's
* compile-time `define`), sets the global, then dynamically imports `App` so
* `sharedWallet.ts` reads the value on evaluation. In a build.ts bundle the
* password is already inlined via `define`, so this step is skipped (NODE_ENV).
* Before loading the app tree it pulls the RUNTIME shared-wallet config, sets
* the global, then dynamically imports `App` so `sharedWallet.ts` reads the
* value on evaluation. A `build.ts` bundle already carries the value inlined by
* `define`, and then there is nothing to fetch.
*
* WHICH ONE APPLIES IS NOT `NODE_ENV`. This used to skip the fetch under
* `NODE_ENV=production`, on the reasoning "production means built". This
* project's production does NOT build: the container copies the sources and
* runs `bun run start` (= `NODE_ENV=production bun src/index.ts`), serving from
* src/ exactly as dev does. Nothing ever serves `dist/`. So the deployed app
* skipped the only step that could give it a wallet, `ensureIdentity()` threw
* for want of one, and it could never sign anybody in while `/festipod-config.json`
* sat there, served and unasked.
*
* The question is therefore "was the value inlined?", never "am I in
* production?" ask the global itself.
*/
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
/** Fetch the runtime shared-wallet config and set the global (dev/start only). */
/** Fetch the runtime shared-wallet config and set the global, unless it is already there. */
async function loadRuntimeConfig(): Promise<void> {
if (process.env.NODE_ENV === "production") return; // build.ts define provides it
// Already inlined by `build.ts`'s `define` → nothing to fetch. Bracket access,
// so that same `define` (which rewrites the dotted global) leaves this read alone.
if ((globalThis as Record<string, unknown>)["__FESTIPOD_SHARED_WALLET_PASSWORD__"] != null) return;
try {
const res = await fetch("/festipod-config.json");
if (!res.ok) return;
+53 -3
View File
@@ -3,6 +3,24 @@ import index from "./index.html";
const port = process.env.PORT ? parseInt(process.env.PORT) : 3000;
// Strict base64 check (not a mere `Buffer.from` attempt, which silently drops invalid
// characters instead of failing): reject anything that is not a well-formed base64 body
// before decoding, so a typo'd env var is reported instead of served as 810 garbage bytes.
const BASE64_SHAPE = /^[A-Za-z0-9+/]+={0,2}$/;
function decodeBase64WalletOrThrow(raw: string) {
const trimmed = raw.trim();
if (trimmed.length === 0 || trimmed.length % 4 !== 0 || !BASE64_SHAPE.test(trimmed)) {
throw new Error("not valid base64 (bad characters, or length not a multiple of 4)");
}
// Web `atob` (not Node's `Buffer`, whose `ArrayBufferLike` generic doesn't line up
// with `Response`'s `BodyInit`) — decodes to a binary string, rebuilt into bytes below.
const binary = atob(trimmed);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
}
const server = serve({
port,
routes: {
@@ -54,12 +72,44 @@ const server = serve({
}),
// The shared wallet file — the `fileUrl` the app hands the SDK, when configured.
//
// Two sources, read fresh on every request (never captured at module evaluation):
// - FESTIPOD_SHARED_WALLET_FILE: a filesystem path. What local dev and the test
// harness set today — a file sitting at the working-copy root.
// - FESTIPOD_SHARED_WALLET_FILE_BASE64: the file's bytes, base64-encoded. What a
// container sets instead, since *.ngw is gitignored and nothing mounts one there.
//
// Precedence: FILE wins whenever it is set, even if the path turns out unreadable —
// it is NOT "whichever resolves". This keeps dev/test behaviour byte-for-byte
// unchanged (they set only FILE, never BASE64) and makes the rule predictable: a
// deployment picks exactly one variable to set, and setting both is a leftover, not
// an intentional fallback chain.
"/shared-wallet.ngw": async () => {
const p = process.env.FESTIPOD_SHARED_WALLET_FILE;
if (p) {
const file = Bun.file(p);
const path = process.env.FESTIPOD_SHARED_WALLET_FILE;
if (path) {
const file = Bun.file(path);
if (await file.exists()) return new Response(file);
return new Response("No shared wallet file configured.", { status: 404 });
}
const encoded = process.env.FESTIPOD_SHARED_WALLET_FILE_BASE64;
if (encoded) {
// Malformed must fail loudly: a 404 here would look identical to "not
// configured", which is exactly the confusion this project is removing.
try {
const bytes = decodeBase64WalletOrThrow(encoded);
return new Response(bytes, {
headers: { "Content-Type": "application/octet-stream" },
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return new Response(
`FESTIPOD_SHARED_WALLET_FILE_BASE64 is set but ${message}.`,
{ status: 500 }
);
}
}
return new Response("No shared wallet file configured.", { status: 404 });
},
@@ -136,6 +136,7 @@ When('l\'utilisateur attend la fin du chargement', async function (this: Festipo
const buttons = Array.from(document.querySelectorAll('button'));
return !buttons.some(b => b.textContent?.includes('Chargement...'));
},
undefined,
{ timeout: 60000 },
);
await this.appFrame!.waitForTimeout(2000);
@@ -152,6 +153,7 @@ Then('l\'écran d\'accueil affiche des événements', async function (this: Fest
const appeared = await this.appFrame!.waitForFunction(
() => document.querySelectorAll('.app-card').length > 0,
undefined,
{ timeout: 15000 },
).then(() => true).catch(() => false);
@@ -101,8 +101,6 @@ export function CreateEventScreen() {
// truth (and every other viewer) was 0.
participantCount: 0,
themes: ['Social'],
hostName: 'Moi',
hostInitials: 'MD',
});
} catch (err) {
console.error('[CreateEvent] createEvent failed:', err);
@@ -238,11 +236,6 @@ export function CreateEventScreen() {
<Text style={{ margin: '4px 0', fontSize: 13, color: '#888' }}>
{ev.date} · {ev.location}
</Text>
{ev.hostName && (
<Text style={{ margin: '0 0 10px 0', fontSize: 12, color: '#888' }}>
Relayé par {ev.hostName}
</Text>
)}
<Button
variant="primary"
style={{ width: '100%', padding: 10, fontSize: 13 }}
+40 -10
View File
@@ -14,6 +14,7 @@ export function EventDetailScreen() {
leaveEvent,
getEventParticipants,
getEventMeetingPoints,
getEventOwnership,
} = useFestipodData();
const event = eventId ? getEvent(eventId) : undefined;
@@ -29,7 +30,19 @@ export function EventDetailScreen() {
lieu: mp.location,
}));
const isOwner = true;
// EDITING IS OWNING. Only a document's owner writes to it, and nothing delegates
// a write, so "may I edit this event" is answered entirely by whether this
// session owns the event's document — no permission call, no extra read.
//
// The third answer, `'unknown'`, is rendered as a DISABLED affordance rather
// than resolved either way. Hiding it would be the worst outcome: an owner
// would be told, silently and wrongly, that their own event is not theirs, with
// nothing on screen to suggest otherwise. Showing it live would be the opposite
// lie — a control that promises an edit it may not be able to make, and only
// says so after the person has typed. Visible-but-inert says the true thing:
// the answer is still coming.
const ownership = eventId ? getEventOwnership(eventId) : 'unknown';
const canEdit = ownership === 'mine';
// Preview list shows the OTHER participants (deliberate — the total is in the
// header count; the full list at "Voir tous les participants" shows everyone).
// Compare on the PROFILE id: `currentUser.id` is the resolved profile NURI, the
@@ -40,23 +53,23 @@ export function EventDetailScreen() {
const handleToggleJoin = () => {
if (!eventId) return;
// The optimistic toast stays immediate (the overlay already reflects the
// change), but the write can genuinely FAIL — a participation document that
// cannot be recorded throws instead of reading empty forever, and an
// unconfirmed withdrawal throws too. Surface it rather than leave the user
// with a success message and nothing written.
// THE CONFIRMATION FOLLOWS THE WRITE. It used to be shown on the spot, before
// the call had settled, so a sign-up that wrote nothing still read as
// « Tu participes ». The screen's own list flips immediately anyway (the data
// layer's optimistic overlay), so nothing is lost by waiting for the truth.
const confirmed = (message: string, tone: 'success' | 'info') => () => showToast(message, tone);
const failed = (message: string) => (err: unknown) => {
console.error('[EventDetail] participation write failed:', err);
showToast(message, 'error');
};
if (joined) {
void Promise.resolve(leaveEvent(eventId))
.then(confirmed('Participation annulée', 'info'))
.catch(failed("La désinscription n'a pas pu être enregistrée"));
showToast('Participation annulée', 'info');
} else {
void Promise.resolve(joinEvent(eventId))
.then(confirmed('Tu participes à cet événement', 'success'))
.catch(failed("L'inscription n'a pas pu être enregistrée"));
showToast('Tu participes à cet événement', 'success');
}
};
@@ -85,8 +98,25 @@ export function EventDetailScreen() {
<div style={{ fontSize: 12, color: '#888' }}>{event.distance} km</div>
)}
</div>
{isOwner && (
<span onClick={() => navigate(`/events/${eventId}/edit`)} style={{ cursor: 'pointer', fontSize: 18, color: '#888' }}></span>
{canEdit && (
<span
onClick={() => navigate(`/events/${eventId}/edit`)}
title="Modifier l'événement"
style={{ cursor: 'pointer', fontSize: 18, color: '#888' }}
></span>
)}
{ownership === 'unknown' && (
// DELIBERATELY NOT A ✎. The slot stays occupied, so an owner is never
// silently told the event is not theirs — but a pending control must
// not look like the actionable one it is not: a greyed-out twin of the
// pencil reads as "edit, broken" and invites a click that does nothing.
// A distinct mark reads as "still working it out", which is the truth.
<span
aria-busy="true"
aria-label="Vérification de vos droits de modification"
title="Vérification de vos droits de modification…"
style={{ cursor: 'default', fontSize: 18, color: '#ddd' }}
></span>
)}
</div>
@@ -26,8 +26,6 @@ export function MeetingPointsScreen() {
eventId,
location: title || lieu || 'Point de rencontre',
time: when || duration,
hostName: currentUser?.name?.split(' ')[0] ?? 'Moi',
hostInitials: currentUser?.initials ?? '?',
});
showToast(title ? `Point de rencontre créé : ${title}` : 'Point de rencontre créé', 'success');
navigate(`/events/${eventId}`);
@@ -6,8 +6,16 @@ import { useNavigate, useParams } from '../../../app/router';
export function UpdateEventScreen() {
const navigate = useNavigate();
const { eventId } = useParams();
const { getEvent, updateEvent } = useFestipodData();
const { getEvent, updateEvent, getEventOwnership } = useFestipodData();
const event = eventId ? getEvent(eventId) : undefined;
// THE ROUTE IS GUARDED BY THE SAME ANSWER EventDetailScreen uses for its pencil
// icon — "editing is owning", so there is nothing else to ask. `'unknown'` is a
// real third case (the owned-document listing may not have landed yet), and it
// is rendered as its OWN pending state below rather than folded into either
// side: showing the form would let a non-owner edit on a still-resolving
// guess, and bouncing the user out would tell an actual owner, wrongly, that
// the event is not theirs.
const ownership = eventId ? getEventOwnership(eventId) : 'unknown';
const [title, setTitle] = useState(event?.title ?? '');
const [startDate, setStartDate] = useState(event?.startDate ?? '');
@@ -22,7 +30,11 @@ export function UpdateEventScreen() {
const dateLabel = startDate
? (endDate ? `${startDate} - ${endDate}` : startDate)
: event?.date ?? '';
updateEvent(eventId, {
// THE CONFIRMATION FOLLOWS THE WRITE — same idiom as EventDetailScreen's
// participation toggle. Showing the toast and navigating away before
// `updateEvent` has settled announced success whether or not anything was
// actually written; a rejection must be told as a failure, not swallowed.
void Promise.resolve(updateEvent(eventId, {
title,
date: dateLabel,
startDate,
@@ -31,11 +43,54 @@ export function UpdateEventScreen() {
endTime,
location,
description,
});
showToast('Événement mis à jour', 'success');
navigate(`/events/${eventId}`);
}))
.then(() => {
showToast('Événement mis à jour', 'success');
navigate(`/events/${eventId}`);
})
.catch((err: unknown) => {
console.error('[UpdateEvent] event update failed:', err);
showToast("La modification n'a pas pu être enregistrée", 'error');
});
};
if (ownership === 'not-mine') {
// A resolved, definitive answer — not a guess. Block the form outright
// rather than let a non-owner type into a write that will only ever reject.
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Header
title="Modifier l'événement"
left={<span onClick={() => navigate(`/events/${eventId}`)} style={{ cursor: 'pointer', fontSize: 18 }}></span>}
/>
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}>
<Text style={{ textAlign: 'center', color: '#888' }}>
Vous ne pouvez pas modifier cet événement.
</Text>
</div>
</div>
);
}
if (ownership === 'unknown') {
// The listing hasn't landed yet — neither "mine" nor "not mine" is true, so
// neither the form nor a bounce-out is shown. Same wording as
// EventDetailScreen's pending pencil affordance.
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Header
title="Modifier l'événement"
left={<span onClick={() => navigate(`/events/${eventId}`)} style={{ cursor: 'pointer', fontSize: 18 }}></span>}
/>
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}>
<Text aria-busy="true" style={{ textAlign: 'center', color: '#888' }}>
Vérification de vos droits de modification
</Text>
</div>
</div>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Header
@@ -61,6 +61,7 @@ When(
const freshFrame = await pool.setupBrokerPage!(freshPage, pool.harnessUrl!);
await freshFrame.waitForFunction(
() => (window as any).__testData?.ready === true,
undefined,
{ timeout: 60000 },
);
// Resolve A's principal (profile read hydrated) before the reactive Then reads.
@@ -82,7 +82,7 @@ Then('l\'événement {string} finit par apparaître sur la page fraîche A en la
console.log(`[LongPoll] t=${elapsed}ms still ABSENT — forcing a full reload (#${reloadIdx}) to re-attempt the barrier…`);
try {
freshFrame = await pool.setupBrokerPage!(freshPage, pool.harnessUrl!);
await freshFrame.waitForFunction(() => (window as any).__testData?.ready === true, { timeout: 60000 });
await freshFrame.waitForFunction(() => (window as any).__testData?.ready === true, undefined, { timeout: 60000 });
await freshFrame.evaluate(async () => { await (window as any).__testData.ensureCurrentUser(); });
(this as any).recoFreshFrame = freshFrame;
} catch (e) {
@@ -113,7 +113,7 @@ When('une page fraîche pour la MÊME identité A recharge sur le même wallet',
freshPage.on('console', (msg) => { console.log(`[FreshApage:${msg.type()}]`, msg.text()); });
// New broker login → fresh verifier session on the SAME persistent wallet.
const freshFrame = await pool.setupBrokerPage!(freshPage, pool.harnessUrl!);
await freshFrame.waitForFunction(() => (window as any).__testData?.ready === true, { timeout: 60000 });
await freshFrame.waitForFunction(() => (window as any).__testData?.ready === true, undefined, { timeout: 60000 });
// Let A's listing effect + anchored union read run on the fresh session (this is
// exactly the cold-start read path the fix heals).
await freshFrame.evaluate(async () => {
@@ -20,6 +20,7 @@ Given('le portefeuille contient des données de test', async function (this: Fes
// EventsScreen renders Card components (class app-card) when events load.
const hasData = await this.appFrame!.waitForFunction(
() => document.querySelectorAll('.app-card').length > 0,
undefined,
{ timeout: 30000 },
).then(() => true).catch(() => false);
@@ -69,6 +70,7 @@ When('l\'utilisateur remplit le formulaire de création d\'événement:', async
const formReady = await this.appFrame!.waitForFunction(
() => !!document.querySelector('input[placeholder="Donnez un nom à votre événement"]'),
undefined,
{ timeout: 10000 },
).then(() => true).catch(() => false);
@@ -131,6 +133,7 @@ When('l\'utilisateur modifie le champ lieu avec {string}', async function (this:
// adjacent to that label.
await this.appFrame!.waitForFunction(
() => document.getElementById('root')?.textContent?.includes('Lieu') ?? false,
undefined,
{ timeout: 10000 },
);
await this.appFrame!.evaluate((val: string) => {
@@ -164,6 +167,7 @@ When('l\'utilisateur clique sur un événement de l\'accueil', async function (t
});
const homeHasCards = await this.appFrame!.waitForFunction(
() => document.querySelectorAll('.app-card').length > 0,
undefined,
{ timeout: 5000 },
).then(() => true).catch(() => false);
if (!homeHasCards) {
@@ -173,6 +177,7 @@ When('l\'utilisateur clique sur un événement de l\'accueil', async function (t
});
await this.appFrame!.waitForFunction(
() => document.querySelectorAll('.app-card').length > 0,
undefined,
{ timeout: 10000 },
);
}
@@ -211,6 +216,7 @@ When('l\'utilisateur clique sur un événement de la liste', async function (thi
// EventsScreen also uses Card with .app-card class.
await this.appFrame!.waitForFunction(
() => document.querySelectorAll('.app-card').length > 0,
undefined,
{ timeout: 10000 },
);
const clicked = await this.appFrame!.evaluate(() => {
@@ -93,6 +93,7 @@ Given('l\'utilisateur crée un événement {string} via le vrai formulaire', { t
});
const formReady = await frame.waitForFunction(
() => !!document.querySelector('input[placeholder="Donnez un nom à votre événement"]'),
undefined,
{ timeout: 15000 },
).then(() => true).catch(() => false);
if (!formReady) {
@@ -189,6 +190,7 @@ When('l\'utilisateur ferme et rouvre l\'app sous la même identité dans une ses
const root = document.getElementById('root');
return !!root && root.innerHTML.length > 100;
},
undefined,
{ timeout: 60000 },
);
// Let NG connect + the cold-start read path run.
@@ -42,6 +42,7 @@ Then("l'accueil rend un contenu d'application réel", async function (this: Fest
document.querySelector('[aria-label="Relayer un événement"]') !== null;
return hasNavbar && hasRelayer;
},
undefined,
{ timeout: 15000 },
).then(() => true).catch(() => false);
@@ -18,9 +18,18 @@ export function UpdateProfileScreen() {
const handleSave = () => {
const fullName = `${firstName} ${lastName}`.trim();
const initials = `${firstName[0] ?? ''}${lastName[0] ?? ''}`.toUpperCase();
updateProfile({ name: fullName, initials, username, city, bio });
showToast('Profil mis à jour', 'success');
navigate('/profile');
// The edit REJECTS when no profile of mine is resolved — it refuses to write
// into someone else's document. So confirm and leave the screen only once the
// write has settled, never before.
void Promise.resolve(updateProfile({ name: fullName, initials, username, city, bio }))
.then(() => {
showToast('Profil mis à jour', 'success');
navigate('/profile');
})
.catch((err: unknown) => {
console.error('[UpdateProfile] profile write failed:', err);
showToast("Le profil n'a pas pu être enregistré", 'error');
});
};
return (
@@ -27,6 +27,7 @@ Then('le navigateur {string} est connecté à NextGraph', async function (this:
// __testData.ready flips true only once the NG session is connected.
await handle.appFrame!.waitForFunction(
() => (window as any).__testData?.ready === true,
undefined,
{ timeout: 30000 },
);
});
+553 -129
View File
@@ -10,8 +10,6 @@ import type {
import {
depositRegistration,
depositLeave,
buildNotification,
insertNotification,
readRegistrationNotifications,
materializeAttendance,
deleteParticipation,
@@ -39,21 +37,44 @@ import {
openDocumentInbox,
} from '../utils/storeRegistry';
import { useCurrentPrincipal } from '../utils/currentPrincipal';
import { createSerialTask } from '../utils/serialTask';
import { useShapeQuery } from '../data/useShapeQuery';
import { adaptEvents, adaptUsers, adaptParticipations } from '../data/shapeAdapters';
// The ORM generator emits BARE shape names (`EventShapeType`, `Event`, …), taken
// from each shape's IRI local name. They are aliased back to the `Fp*` spelling
// HERE, at the import, so nothing downstream depends on the generator's naming:
// `Event` and `Notification` are DOM globals, and the bare names collide with the
// app's own vocabulary (`FpEventData` & co. in `data/types.ts`). The prefix cannot
// be restored at the generator — the emitted name follows the shape IRI, and those
// IRIs are the persisted RDF classes (`ENTITY_TYPE`), not ours to rename.
import {
FpEventShapeType,
FpUserProfileShapeType,
FpParticipationShapeType,
EventShapeType as FpEventShapeType,
UserProfileShapeType as FpUserProfileShapeType,
ParticipationShapeType as FpParticipationShapeType,
} from '../shapes/orm/festipodShapes.shapeTypes';
import { writeEntity, updateEntityField, ENTITY_TYPE, str, int, flt, bool, iri } from '../data/entityWrites';
import { bootstrapWallet, type BootstrapResult } from '../utils/ngBootstrap';
import { autoSeedEnabled, shouldAutoSeed } from '../utils/autoSeed';
import { autoSeedEnabled, fixtureSeedEnabled, shouldAutoSeed } from '../utils/autoSeed';
// ============================================================================
// Context interface
// ============================================================================
/**
* Whether this session may WRITE an event's document which, by the SDK
* contract, is exactly whether it OWNS that document: *"Only a document's owner
* writes to it. Holding its read key never grants a write"*, and *"No delegated
* writing. A received key never grants a write, and no call adds a writer to a
* document"*. So there is nothing extra to ask: owning it IS being able to write
* it, and the owned-document listing already answers that.
*
* `'unknown'` is a REAL third answer, never a polite `'not-mine'`. The listing
* may not have landed yet, or may have failed and a rejection means UNKNOWN,
* never "this session owns nothing". Collapsing it into `'not-mine'` is how you
* end up telling an owner, silently and wrongly, that the thing is not theirs.
*/
export type EventOwnership = 'mine' | 'not-mine' | 'unknown';
interface FestipodDataContextValue {
currentUserId: string;
currentUser: FpUserData | undefined;
@@ -80,6 +101,11 @@ interface FestipodDataContextValue {
isParticipating(eventId: string, userId?: string): boolean;
getFriends(userId?: string): FpUserData[];
getEventMeetingPoints(eventId: string): FpMeetingPointData[];
/**
* May this session WRITE this event's document? Callers MUST treat `'unknown'`
* as its own case see {@link EventOwnership}.
*/
getEventOwnership(eventId: string): EventOwnership;
selectedEventId: string;
setSelectedEventId(id: string): void;
@@ -116,24 +142,44 @@ function nextId(prefix: string): string {
// old form, or those participations render as "participant inconnu".
const USER_PRINCIPAL_PREFIX = 'urn:festipod:user:';
/** Waits between attempts at resolving the owned-event set (see its effect). */
/** Waits between attempts at resolving an owned-document set (see the effects). */
const OWNED_RETRY_BACKOFF_MS = [500, 1500, 4000];
/**
* Resolve a Participation's `fp:user` to its UserProfile across the TWO id spaces
* that meet at this join (the root cause of the "unknown participant" bug):
* a Participation stores `urn:festipod:user:<normalized-identifier>` (the stable
* principal = `currentUserId`), while
* a UserProfile's `id` is its `did:ng:` document NURI never that principal.
* The bridge is the NORMALIZED IDENTIFIER, which equals `normalizeIdentifier(username)`
* for the matching profile (the exact equality `currentUser` resolution already uses).
* So: strip the principal prefix off the participation's userId, and compare the
* remainder to `normalizeIdentifier(profile.username)`. In demo/local mode both sides
* are the bare seed id (`user-1`), matched directly by `u.id === userId` which is
* why the direct match is tried FIRST (the seed username `@mariedupont` would not
* normalize to `user-1`). A per-deposit materializer uid (`mint...`, e.g.
* `mrktnoke-rzd699dk`) is a THIRD, unrelated space: it identifies an inbox deposit
* for the count, never a user it does not participate in this join.
* What a BRAND-NEW profile is created with.
*
* A profile is entirely Festipod's own object the data layer knows nothing of
* pseudos or display names but the UserProfile SHEX shape makes `fp:name`,
* `fp:initials` and `fp:username` MANDATORY, so a profile cannot be written
* empty. And nothing about the person is known at sign-in: the identity the
* session signed in as is OPAQUE (no display name; it is never parsed, never
* rendered, never written into an entity). So the three required fields carry a
* PLACEHOLDER that reads on screen as "not filled in yet": none of them is a
* person's name or handle, and none is derived from the identity. The user
* replaces them through `updateProfile` (UpdateProfileScreen).
*/
const UNSET_PROFILE = {
name: 'Profil à compléter',
initials: '?',
username: '(pseudo non défini)',
} as const;
/**
* Resolve a Participation's `fp:user` to its UserProfile.
*
* TODAY'S WRITES need no resolving: `fp:user` carries the profile's own document
* NURI, so the DIRECT match `u.id === userId` answers and it is tried first.
* The demo/local fixtures coincide there too (both sides are the bare `user-1`).
*
* LEGACY ONLY: participations written under the earlier scheme carry
* `urn:festipod:user:<normalized-handle>`, which matches no profile id. For those
* and ONLY those the handle is stripped off and compared to
* `normalizeIdentifier(profile.username)`. This username bridge is a READ-side
* survival for old data; it plays NO part in deciding who the current user is
* (my profile is the profile document I own see the "WHO AM I" block below).
*
* A per-deposit materializer uid (`mint...`, e.g. `mrktnoke-rzd699dk`) is a third,
* unrelated space: it identifies an inbox deposit for the count, never a user.
*/
function resolveParticipantUser(userId: string, users: FpUserData[]): FpUserData | undefined {
// 1) Direct id match — demo/local seed space (`user-1`), or any coincident space.
@@ -265,6 +311,13 @@ function useLocalData(empty?: boolean): FestipodDataContextValue {
console.log(`${logPrefix} loadTestData (local, no-op)`);
return { seeded: false, userIdMap: new Map(), eventIdMap: new Map(), createdDocs: { public: [], protected: [] } };
}, []);
// Demo mode owns its whole fixture world. There is no document, no wallet and
// no writing here — every mutation above is a local no-op — so there is no
// ownership to look up and no listing that could be pending: the answer is
// settled, and it is the same one demo mode gives everywhere else, which is
// "yes, the app is yours to drive". `'unknown'` would leave the affordance
// pending forever on a path where nothing is ever going to resolve.
const getEventOwnership = useCallback((): EventOwnership => 'mine', []);
return {
currentUserId, currentUser, currentPrincipal,
@@ -273,6 +326,7 @@ function useLocalData(empty?: boolean): FestipodDataContextValue {
selectedEventId, setSelectedEventId, selectedEvent,
selectedUserId, setSelectedUserId, selectedUser,
...queries,
getEventOwnership,
createEvent, updateEvent, joinEvent, leaveEvent,
addMeetingPoint, addFriend, updateProfile, loadTestData,
};
@@ -304,10 +358,10 @@ function useNgData(): FestipodDataContextValue {
const eventQuery = useShapeQuery(FpEventShapeType, 'public');
const userQuery = useShapeQuery(FpUserProfileShapeType, 'protected');
const partQuery = useShapeQuery(FpParticipationShapeType, 'protected');
const users = React.useMemo(() => adaptUsers(userQuery.data), [userQuery.data]);
// The RAW reactive sets straight from `watchShape` (before the optimistic
// overlay). The exposed `events`/`participations` merge these with the pending
// overlay below — see the "OPTIMISTIC OVERLAY" block.
// overlay). The exposed `events`/`users`/`participations` merge these with the
// pending overlay below — see the "OPTIMISTIC OVERLAY" block.
const reactiveUsers = React.useMemo(() => adaptUsers(userQuery.data), [userQuery.data]);
const reactiveEvents = React.useMemo(() => adaptEvents(eventQuery.data), [eventQuery.data]);
const reactiveParticipations = React.useMemo(
() => adaptParticipations(partQuery.data),
@@ -331,9 +385,17 @@ function useNgData(): FestipodDataContextValue {
// dropped from pendingRemoves. No re-query, no interval — the overlay only reacts
// to `watchShape`'s own pushes.
const [pendingAddEvents, setPendingAddEvents] = useState<FpEventData[]>([]);
const [pendingAddUsers, setPendingAddUsers] = useState<FpUserData[]>([]);
const [pendingAddParticipations, setPendingAddParticipations] = useState<FpParticipationData[]>([]);
const [pendingRemoveIds, setPendingRemoveIds] = useState<Set<string>>(() => new Set());
const users = React.useMemo(() => {
if (pendingAddUsers.length === 0) return reactiveUsers;
const seen = new Set(reactiveUsers.map(u => u.id));
const extra = pendingAddUsers.filter(u => !seen.has(u.id));
return extra.length ? [...reactiveUsers, ...extra] : reactiveUsers;
}, [reactiveUsers, pendingAddUsers]);
const events = React.useMemo(() => {
if (pendingAddEvents.length === 0) return reactiveEvents;
const seen = new Set(reactiveEvents.map(e => e.id));
@@ -365,6 +427,15 @@ function useNgData(): FestipodDataContextValue {
});
}, [reactiveEvents, pendingAddEvents]);
useEffect(() => {
if (pendingAddUsers.length === 0) return;
const live = new Set(reactiveUsers.map(u => u.id));
setPendingAddUsers(prev => {
const next = prev.filter(u => !live.has(u.id));
return next.length === prev.length ? prev : next;
});
}, [reactiveUsers, pendingAddUsers]);
useEffect(() => {
if (pendingAddParticipations.length === 0) return;
const live = new Set(reactiveParticipations.map(p => p.id));
@@ -405,6 +476,35 @@ function useNgData(): FestipodDataContextValue {
// as the subject). The owner-materializer subscribes to each owned event's inbox
// and writes `participantCount` on THAT (owned) doc — never on someone else's.
const [ownedEventIds, setOwnedEventIds] = useState<Nuri[]>([]);
// Events the listing has RULED OUT — canonical ids that were already visible in
// the reactive read when a listing RESOLVED, and were absent from its result.
// ONLY these may be shown as "not yours".
//
// Why a set and not a "the listing answered" boolean: a listing is a SNAPSHOT,
// and it can only ever UNDER-report ownership, because a document created after
// it was taken cannot possibly be in it. So a miss is authoritative for what
// existed at the time and says NOTHING about anything that arrived since. With a
// boolean, a second tab on the same wallet that creates an event has it pushed
// here, misses the stale snapshot, and its owner is told the event is not theirs
// — permanently. That is the exact failure this whole answer exists to avoid.
const [ruledOutEventIds, setRuledOutEventIds] = useState<ReadonlySet<string>>(new Set());
// The owned set in canonical id-form — the form every event-id comparison here
// uses, so an event reached under a different overlay is still the same event.
const ownedCanonicalIds = React.useMemo(
() => new Set(ownedEventIds.map(canonicalEventId)),
[ownedEventIds],
);
// WHICH PROTECTED DOCUMENTS ARE MINE — the ground on which "my profile" rests.
// `null` means NOT ANSWERED YET (the listing has not landed, or it failed):
// "unknown" and "I own nothing" are indistinguishable as an empty array, and
// only one of them may lead to creating a profile. Nothing downstream may read
// `null` as an empty set.
const [myProtectedDocs, setMyProtectedDocs] = useState<Nuri[] | null>(null);
// The profile document THIS session created for the signed-in person. Set once,
// by the creation effect; it settles "which of my profile documents is mine"
// without looking at any field of any profile.
const [myProfileDocId, setMyProfileDocId] = useState<string>('');
/**
* Fold documents THIS SESSION just created into the owned set.
@@ -434,8 +534,30 @@ function useNgData(): FestipodDataContextValue {
// materializer, so every event this session hosts stops converging. So the
// failure is RETRIED, and if it still will not answer, it is said loudly instead
// of leaving a plausible-looking empty set behind.
// Read through refs inside the async listing below, so classification uses what
// is on screen when the listing RESOLVES rather than what was there when it
// started — the two differ by exactly the push that prompted a re-listing.
const eventsRef = useRef(events);
eventsRef.current = events;
const ownedCanonicalIdsRef = useRef(ownedCanonicalIds);
ownedCanonicalIdsRef.current = ownedCanonicalIds;
// Events the snapshot cannot speak about: neither owned nor ruled out. While
// this is non-empty the listing is re-taken — ONCE per new arrival, never on a
// timer ([[rule_no-broker-polling]]): a listing classifies everything visible at
// the moment it resolves, so this empties and the effect falls silent until a
// genuinely new event turns up.
const unclassifiedEventKey = React.useMemo(() => {
const pending = events
.map(e => canonicalEventId(e.id))
.filter(c => !ownedCanonicalIds.has(c) && !ruledOutEventIds.has(c));
return [...new Set(pending)].sort().join('|');
}, [events, ownedCanonicalIds, ruledOutEventIds]);
const listingTaken = useRef(false);
useEffect(() => {
if (!ready) return;
// Nothing left to learn: a listing has been taken and every visible event is
// classified. Without this the effect would re-list on its own state change.
if (listingTaken.current && unclassifiedEventKey === '') return;
let cancelled = false;
(async () => {
for (let attempt = 0; !cancelled; attempt++) {
@@ -443,6 +565,35 @@ function useNgData(): FestipodDataContextValue {
const myPublic = await listMyEntityDocs('public');
if (cancelled) return;
setOwnedEventIds(prev => [...new Set([...prev, ...myPublic])]);
listingTaken.current = true;
// The listing ANSWERED — so every event visible RIGHT NOW that it did
// not return is genuinely not this session's, and may be shown as such.
// Anything arriving after this point stays unclassified and triggers a
// fresh listing rather than inheriting this one's silence. Events already
// claimed as mine (created here, possibly racing this listing) are never
// ruled out.
// REBUILT, never accumulated: every listing RE-ADJUDICATES everything
// visible, so a later listing can overturn an earlier ruling instead of
// being outvoted by it.
//
// KNOWN RESIDUAL, and it needs the provider — not more code here. This
// infers "not yours" from ABSENCE, and absence is not authoritative: the
// reactive read and this listing are two different mechanisms, so an
// event can be on screen a moment before the listing can see it. Ruled
// out in that window, it is only re-examined if some OTHER unclassified
// event later triggers a listing. Self-healing would need either a timer
// (polling — forbidden) or the "may I write this?" call the surface does
// not publish. Per `rule_app-uses-sdk-surface-only` that is a GAP to
// raise, not one to paper over here, so it is left visible and stated.
const ownedNow = new Set(myPublic.map(canonicalEventId));
setRuledOutEventIds(() => {
const next = new Set<string>();
for (const e of eventsRef.current) {
const c = canonicalEventId(e.id);
if (!ownedNow.has(c) && !ownedCanonicalIdsRef.current.has(c)) next.add(c);
}
return next;
});
return;
} catch (err) {
const wait = OWNED_RETRY_BACKOFF_MS[attempt];
@@ -464,7 +615,7 @@ function useNgData(): FestipodDataContextValue {
}
})();
return () => { cancelled = true; };
}, [ready]);
}, [ready, unclassifiedEventKey]);
// Not in SHEX shapes yet
const [meetingPoints, setMeetingPoints] = useState<FpMeetingPointData[]>([]);
@@ -495,13 +646,25 @@ function useNgData(): FestipodDataContextValue {
// (the test-data action + every @data test) is UNAFFECTED (it seeds directly).
// `hasTriedAutoSeed` keeps it single-shot (also suppressed by an explicit
// `loadTestData`).
//
// ALL OF THAT IS CURRENTLY MOOT: the fixture seed is switched OFF outright
// (`fixtureSeedEnabled`), so this effect returns on its first line and no
// fixture reaches the wallet. `bootstrapWallet` enforces the same switch, so
// the check here buys only silence — no misleading "bootstrapping…" log, and
// no work started that would write nothing.
const hasTriedAutoSeed = useRef(false);
useEffect(() => {
if (!fixtureSeedEnabled()) return;
if (!autoSeedEnabled()) return;
if (hasTriedAutoSeed.current) return;
if (!ready) return;
if (!readReady) return; // still syncing — do NOT mistake pending for empty
const walletHasData = events.length > 0 || users.length > 0;
// MY OWN PROFILE IS NOT "DATA IN THE WALLET". It is created at sign-in on a
// brand-new wallet, so counting it here would permanently disable the seed on
// exactly the wallets it exists for. The question the gate asks is "does this
// wallet already hold something worth preserving", and my own empty profile
// does not.
const walletHasData = events.length > 0 || users.some(u => u.id !== myProfileDocId);
if (!shouldAutoSeed(walletHasData)) {
console.log(`${logPrefix} Auto-seed (FESTIPOD_AUTO_SEED): wallet already has data — skip`);
return;
@@ -516,40 +679,63 @@ function useNgData(): FestipodDataContextValue {
// The reactive `watchShape` reads pick the seeded per-entity docs up on their
// own (each createEntityDoc appends to the scope index → the container-index
// subscription re-resolves → the new docs enter the read). No registerDoc/relist.
}, [ready, readReady, events.length, users.length, claimOwnedEventDocs]);
}, [ready, readReady, events.length, users, myProfileDocId, claimOwnedEventDocs]);
// --- Derived ---
// WHO AM I — answered in TWO id spaces that must not be confused.
//
// (1) `currentPrincipal` — what signing in returned. Known as soon as the one
// identity await settles, i.e. before any document has been read. It names
// a PERSON. It is for display and log attribution; no data call takes it,
// and it is never written into an entity.
// (2) `currentUserId` — the app's own entity space: the `@id` of the profile
// DOCUMENT read back in the protected scope (a doc NURI). This is what a
// Participation's `fp:user` carries and what `resolveParticipantUser`
// matches directly, so it is the only value a mutation may write. It stays
// empty until the protected read lands — mutations that need it refuse
// rather than write an entity the read would drop.
// a PERSON, OPAQUELY: it is never parsed, never rendered as a name, never
// written into an entity and never handed to a data call. It exists here
// for display of "am I signed in" and for log attribution.
// (2) `currentUserId` — the app's own entity space: the `@id` of MY PROFILE
// DOCUMENT (a doc NURI). This is what a Participation's `fp:user` carries
// and what `resolveParticipantUser` matches directly, so it is the only
// value a mutation may write.
//
// THE JOIN between the two is explicit and lives HERE, in one place: a profile
// belongs to the signed-in person when its username normalizes to the
// principal — the same bridge `resolveParticipantUser` uses for the legacy
// `urn:festipod:user:` space. Nothing merges the spaces: the principal selects
// a profile, it never stands in for one.
// THERE IS NO JOIN BETWEEN THE TWO, and there must not be one. A profile is
// Festipod's own object; the identity says nothing about it. **My profile is
// the profile document I OWN** — `listMyEntityDocs('protected')` answers "which
// documents are mine", and the UserProfile among them is mine. No field of any
// profile takes part in the answer: no username comparison, no normalization,
// no positional pick.
//
// THREE OUTCOMES, and "somebody else's profile" is not one of them:
// • exactly one owned profile → that is me;
// • none → I have no profile yet, and the creation effect below makes one
// (until it lands, `currentUserId` is '' and the mutations that need it
// REJECT — they never write an entity keyed on nobody);
// • several, none of them created by this session → genuinely AMBIGUOUS (the
// opt-in fixture seed writes its profiles into my own protected scope), so
// the answer is NO PROFILE, said loudly. Picking one would be picking a
// person at random and calling them "you".
const currentPrincipal = useCurrentPrincipal();
const currentUser =
(currentPrincipal
? users.find(u => u.username && normalizeIdentifier(u.username) === currentPrincipal)
: undefined)
// No profile answers to the signed-in person (the wallet holds fixtures, or
// the profile read has not landed): fall back to the demo-seed pick. KNOWN
// HAZARD — this GUESSES a profile, so the app can show the wrong person as
// "you" while the real answer has simply not been read yet. Note what is and
// is not guessed: the identity itself never is (it is exactly what
// `ensureIdentity()` returned); only the profile it selects can be wrong.
|| users.find(u => u.username === '@mariedupont')
|| users[0];
/** The profiles I own: the reactive profiles whose document is one of mine. */
const myOwnedProfiles = React.useMemo<FpUserData[] | null>(() => {
if (myProtectedDocs === null) return null; // UNKNOWN — never "none"
const mine = new Set<string>(myProtectedDocs);
return users.filter(u => mine.has(u.id));
}, [users, myProtectedDocs]);
const currentUser = React.useMemo<FpUserData | undefined>(() => {
// This session created it → no ambiguity possible, whatever else is owned.
if (myProfileDocId) return users.find(u => u.id === myProfileDocId);
if (myOwnedProfiles === null) return undefined; // not answered yet
if (myOwnedProfiles.length === 1) return myOwnedProfiles[0];
if (myOwnedProfiles.length === 0) return undefined; // none — one gets created
// SEVERAL profile documents are mine, and none was created by this session:
// the fixture seed writes its profiles into my own protected scope, so a
// reloaded demo wallet lands here. Pick the first by document reference —
// stable across reloads, and arbitrary, which is honest: while the profile
// is not a built feature, "which of my fixtures am I" has no true answer.
//
// This is NOT the impersonation that was removed. That one reached for a
// profile by NAME and could land on a document belonging to somebody else.
// Every candidate here is a document I own, so the invariant that holds is
// the one that matters: the app never presents another person's profile as
// mine. Delete this branch the day a profile is really created and known.
return [...myOwnedProfiles].sort((a, b) => a.id.localeCompare(b.id))[0];
}, [users, myProfileDocId, myOwnedProfiles]);
const currentUserId = currentUser?.id || '';
// Identity-first log prefix, reused by every DATA log below (including the
// closures defined earlier in this function body — they only execute after
@@ -573,6 +759,107 @@ function useNgData(): FestipodDataContextValue {
}
const selectedUser = users.find(u => u.id === selectedUserId);
// --- MY PROFILE: which documents are mine ----------------------------------
// Resolve the PROTECTED documents this identity owns. That set is the whole
// basis of "which profile is mine", and it is also what decides whether a
// profile has to be CREATED — so a failure here must never look like an answer:
// a rejection means UNKNOWN, and reading it as "I own nothing" would create a
// second profile for someone who already has one. Retried; if it still will not
// answer, the set stays `null` (no profile resolved, no profile created) and it
// is said loudly.
useEffect(() => {
if (!ready) return;
let cancelled = false;
(async () => {
for (let attempt = 0; !cancelled; attempt++) {
try {
const mine = await listMyEntityDocs('protected');
if (cancelled) return;
setMyProtectedDocs(prev => [...new Set([...(prev ?? []), ...mine])]);
return;
} catch (err) {
const wait = OWNED_RETRY_BACKOFF_MS[attempt];
if (wait === undefined) {
console.error(
`${logPrefix} my-protected-documents resolution FAILED after ` +
`${OWNED_RETRY_BACKOFF_MS.length + 1} attempts — which profile is mine is UNKNOWN, ` +
`not absent: no profile will be resolved and none will be created until it is known:`,
err,
);
return;
}
console.warn(
`${logPrefix} my-protected-documents resolution failed (attempt ${attempt + 1}) — ` +
`retrying in ${wait}ms:`,
err,
);
await new Promise(r => setTimeout(r, wait));
}
}
})();
return () => { cancelled = true; };
}, [ready]);
// --- MY PROFILE: create one when I have none -------------------------------
// A profile is Festipod's own object, and signing in produces none — so the
// first time a person signs in, the app makes theirs. Gated on BOTH the
// protected read having settled (`userQuery.isSuccess`: synced-and-empty, not
// still-syncing) and the owned-document set being KNOWN, because "I have no
// profile" is only true when both have answered. Single-shot per session; on
// failure the guard is released, so a later change to the owned set retries.
const hasTriedProfileCreate = useRef(false);
useEffect(() => {
if (!ready) return;
if (hasTriedProfileCreate.current) return;
if (!userQuery.isSuccess) return;
if (myOwnedProfiles === null) return; // UNKNOWN — never read as "none"
if (myOwnedProfiles.length > 0) return; // I already have one (or more)
hasTriedProfileCreate.current = true;
(async () => {
console.log(`${logPrefix} no profile of mine — creating one (fields left visibly unset)`);
const graph = await createEntityDoc('protected');
// The three fields the UserProfile shape makes mandatory, written with the
// "not filled in yet" placeholders — nothing here comes from the identity.
await writeEntity(graph, ENTITY_TYPE.user, {
name: str(UNSET_PROFILE.name),
initials: str(UNSET_PROFILE.initials),
username: str(UNSET_PROFILE.username),
});
// This document is MINE — claimed explicitly, so it is recognized as my
// profile whatever else the protected scope holds (fixtures included).
setMyProfileDocId(graph);
setMyProtectedDocs(prev => [...new Set([...(prev ?? []), graph])]);
// OPTIMISTIC OVERLAY, same pattern as events/participations: surface the
// profile immediately so `currentUserId` resolves without waiting for the
// broker push; the reconciliation effect drops it once the read carries it.
const optimisticProfile: FpUserData = { id: graph, ...UNSET_PROFILE };
setPendingAddUsers(prev => (prev.some(u => u.id === graph) ? prev : [...prev, optimisticProfile]));
})().catch(err => {
hasTriedProfileCreate.current = false;
console.error(`${logPrefix} creating my profile FAILED — this session has no profile:`, err);
});
}, [ready, userQuery.isSuccess, myOwnedProfiles]);
// --- MY PROFILE: say it when the answer is arbitrary ------------------------
// Several profile documents are mine and none was created by this session (the
// opt-in fixture seed writes its profiles into my own protected scope). One is
// picked deterministically so the app stays usable on a demo wallet, but the
// pick carries no meaning — say so once, or a fixture person silently becomes
// "you" and nobody wonders why.
const warnedAmbiguousProfile = useRef(false);
useEffect(() => {
if (myProfileDocId) return;
if (myOwnedProfiles === null || myOwnedProfiles.length <= 1) return;
if (warnedAmbiguousProfile.current) return;
warnedAmbiguousProfile.current = true;
console.warn(
`${logPrefix} ${myOwnedProfiles.length} profile documents are mine and none was created by ` +
`this session (a fixture seed run on this wallet is the usual cause). The first by document ` +
`reference is used as mine — a stable but ARBITRARY pick, so the name shown as yours is ` +
`demo data, not you.`,
);
}, [myOwnedProfiles, myProfileDocId]);
// --- OWNER MATERIALIZER (Option B, brief §B.2 + T02.c notifications) -------
// The event OWNER's session materializes its OWN events' inbox deposits into
// (1) the correct `participantCount` on its OWN event doc, and
@@ -613,36 +900,96 @@ function useNgData(): FestipodDataContextValue {
// Primitive identity of the set above — the effect's dependency (an array is a
// new reference on every render).
const ownedKey = ownedEvents.join('|');
// Last count written per owned event, so we only persist a genuine change.
const materializedCountRef = useRef<Map<string, number>>(new Map());
// WHICH EVENTS MAY THIS SESSION WRITE — the same owned set, asked the other way
// round. Nothing new is called: the contract makes writing a document and owning
// it the same thing, so `listMyEntityDocs('public')` (already resolved above for
// the materializer) is the whole answer. Matched on the CANONICAL id-form, like
// every other event-id comparison here, so an event reached under a different
// overlay is still recognized as the same event.
const getEventOwnership = useCallback((eventId: string): EventOwnership => {
const canon = canonicalEventId(eventId);
// A hit is authoritative EVEN BEFORE any listing has answered: an event this
// session just created was claimed directly, and it is mine whatever a
// listing later says. Checked FIRST, so ownership always beats a stale miss.
if (ownedCanonicalIds.has(canon)) return 'mine';
// A miss counts only for events a resolved listing actually looked past.
if (ruledOutEventIds.has(canon)) return 'not-mine';
// Otherwise the answer is genuinely still coming — a listing is pending, has
// failed, or this event arrived after the last one. Never 'not-mine' here.
return 'unknown';
}, [ownedCanonicalIds, ruledOutEventIds]);
// Last count WRITTEN per owned event (keyed by canonical id), stamped with the
// cycle that wrote it. The stamp is what makes a STALE cycle harmless — see the
// guard below.
const materializedCountRef = useRef<Map<string, { count: number; seq: number }>>(new Map());
// Monotonic cycle number, shared by every materialize cycle of this session. It
// has to outlive the effect: when the owned set changes the effect re-runs, and
// a cycle started by the PREVIOUS run can still be in flight.
const cycleSeqRef = useRef(0);
useEffect(() => {
if (!ready) return;
const owned = ownedEvents;
if (owned.length === 0) return;
let cancelled = false;
const materialize = async (trigger: string) => {
// ONE inbox address per owned event, resolved ONCE here and shared by the two
// things that must not disagree: the cycle that READS the inbox and the watch
// that SUBSCRIBES to it. Resolving them apart is how the owner ends up watching
// one address while a deposit lands in another — the sign-up is then never seen
// in the session that made it. `openDocumentInbox` is itself single-flight per
// document (utils/storeRegistry), so the app can no longer open a second inbox
// at all; this map is the narrower promise that the reader and the watcher hold
// the very same value.
//
// Resolved on first need and kept — but a FAILED resolution is released, not
// kept: it means the address is UNKNOWN, so the next trigger must really ask
// again instead of inheriting a permanent "no inbox".
let resolvingInboxes: Promise<Map<Nuri, Nuri>> | null = null;
const inboxesForOwnedEvents = (): Promise<Map<Nuri, Nuri>> => {
if (resolvingInboxes) return resolvingInboxes;
const attempt = (async () => {
const byEvent = new Map<Nuri, Nuri>();
for (const evId of owned) byEvent.set(evId, await openDocumentInbox(evId));
return byEvent;
})().catch(err => {
if (resolvingInboxes === attempt) resolvingInboxes = null;
throw err;
});
resolvingInboxes = attempt;
return attempt;
};
const runCycle = async (trigger: string) => {
if (cancelled) return;
const seq = ++cycleSeqRef.current;
try {
const inboxes = await inboxesForOwnedEvents();
console.log(
`${logPrefix} owner participation materialize START (trigger=${trigger}) — ${owned.length} owned ` +
`event(s)`,
`${logPrefix} owner participation materialize START (trigger=${trigger}, cycle=${seq}) — ` +
`${owned.length} owned event(s)`,
);
const notifs: FpNotificationData[] = [];
for (const evId of owned) {
// Each event has its OWN inbox, and only its OWNER can open it. This
// call returns the address the owner reads and watches; a depositor
// never sees it (they name the document instead).
const targetInbox = await openDocumentInbox(evId);
// Each event has its OWN inbox, and only its OWNER can open it. The address
// comes from the shared map above — the same one the watch subscribed; a
// depositor never sees it (they name the document instead).
for (const [evId, targetInbox] of inboxes) {
const canonId = canonicalEventId(evId);
// BEFORE — the event's readable detail (short id + title) and the
// participantCount value as currently READ/exposed (the app-side `events`
// state), captured before this cycle's derive+write. Comparing this to the
// AFTER log below tells whether the counter is a DATA problem (never
// incremented) or a DISPLAY/read problem (incremented but not re-read).
const knownEvent = events.find(e => e.id === evId);
//
// Read through the REF, never the closure: this effect only re-runs on
// [ready, ownedKey], so the `events` it captured is the snapshot from the
// render that wired the watch — which is why this line printed `(unknown)`
// for the whole session and told the last investigation nothing. Matched
// on the canonical id-form, like every other event-id comparison here.
const knownEvent = eventsRef.current.find(e => canonicalEventId(e.id) === canonId);
const knownCount = knownEvent?.participantCount;
console.log(
`${logPrefix} participation materialize — event=${canonicalEventId(evId)}` +
`${logPrefix} participation materialize — event=${canonId}` +
(knownEvent?.title ? ` "${knownEvent.title}"` : '') +
` — participantCount before write (as currently read) = ${knownCount ?? '(unknown)'}`,
);
@@ -652,18 +999,35 @@ function useNgData(): FestipodDataContextValue {
// registrant's deposit is visible even on a cold session.
const active = await materializeAttendance(targetInbox, evId);
const nextCount = active.length; // no host baseline (creator not auto-in)
const prevCount = materializedCountRef.current.get(evId);
// Write ONLY when the derived value actually changes (anti-loop). This
// memo does NOT lock in a premature 0: the barrier-gated read above makes
// the first post-connection materialize see the real deposits, so once the
// set becomes non-empty `nextCount !== prevCount` and the correct count is
// written. A transient write failure reverts the memo so the next trigger
// retries. The guard's sole job is to avoid re-writing an UNCHANGED value.
if (prevCount !== nextCount) {
materializedCountRef.current.set(evId, nextCount);
const written = materializedCountRef.current.get(canonId);
// THE VALUE A CYCLE CARRIES IS ONLY AS FRESH AS THE READ IT CAME FROM.
// Cycles of this effect can no longer interleave (they are serialized
// below), but the effect re-runs whenever the owned set changes, and a
// cycle from the previous run can still be in flight — holding a count it
// derived BEFORE the fresher one's. Writing it would put the stale value
// back on the document, which is a count that goes backwards for no
// visible reason. So a cycle may only overwrite what an OLDER cycle wrote.
if (written && written.seq > seq) {
console.log(
`${logPrefix} owner participation materialize — event=${canonicalEventId(evId)}: ` +
`participantCount ${prevCount ?? '(none)'} ${nextCount} (writing own doc)`,
`${logPrefix} owner participation materialize — event=${canonId}: cycle ${seq} is STALE ` +
`(cycle ${written.seq} already wrote ${written.count}) — not writing ${nextCount}`,
);
} else if (written?.count === nextCount) {
// Write ONLY when the derived value actually changes (anti-loop). This
// memo does NOT lock in a premature 0: the barrier-gated read above makes
// the first post-connection materialize see the real deposits, so once the
// set becomes non-empty the value differs and the correct count is
// written. A transient write failure reverts the memo so the next trigger
// retries. The guard's sole job is to avoid re-writing an UNCHANGED value.
console.log(
`${logPrefix} owner participation materialize — event=${canonId}: ` +
`participantCount unchanged (${nextCount}) — no write`,
);
} else if (!cancelled) {
materializedCountRef.current.set(canonId, { count: nextCount, seq });
console.log(
`${logPrefix} owner participation materialize — event=${canonId}: ` +
`participantCount ${written?.count ?? '(none)'}${nextCount} (writing own doc, cycle=${seq})`,
);
// The write lands on the owned event doc, which `watchShape('public')`
// already subscribes → the reactive read re-renders the new count on
@@ -671,9 +1035,11 @@ function useNgData(): FestipodDataContextValue {
let writeOk = true;
await updateEntityField(evId, evId, 'participantCount', int(nextCount))
.catch(err => {
// Revert the memo so a transient write failure retries next trigger.
// Revert the memo so a transient write failure retries next trigger
// — but only if it is still OURS. A fresher cycle's value stands.
writeOk = false;
materializedCountRef.current.delete(evId);
const current = materializedCountRef.current.get(canonId);
if (current && current.seq === seq) materializedCountRef.current.delete(canonId);
console.error(`${logPrefix} owner participation materialize count WRITE FAILED:`, err);
});
if (writeOk) {
@@ -683,15 +1049,10 @@ function useNgData(): FestipodDataContextValue {
// file) still showing the old N after this fires means the counter data
// is fine and it is the read side that lags.
console.log(
`${logPrefix} participation materialize — event=${canonicalEventId(evId)}: ` +
`${logPrefix} participation materialize — event=${canonId}: ` +
`participantCount AFTER write = ${knownCount ?? '(unknown)'}${nextCount}`,
);
}
} else {
console.log(
`${logPrefix} owner participation materialize — event=${canonicalEventId(evId)}: ` +
`participantCount unchanged (${nextCount}) — no write`,
);
}
// (2) NOTIFICATIONS — surface "new participant" deposits (unchanged T02.c).
const evNotifs = await readRegistrationNotifications(targetInbox, evId);
@@ -710,12 +1071,22 @@ function useNgData(): FestipodDataContextValue {
}
};
// A CYCLE IS A READ-DERIVE-WRITE, AND TWO OF THEM MUST NOT OVERLAP. The two
// triggers below fire within the same instant on the connection that creates an
// event, and run concurrently they both read the inbox before either writes —
// so the one that finishes last puts its own, older reading back on the
// document. Serialized, a request arriving mid-cycle is served by ONE follow-up
// cycle once the current one has finished (a cycle re-derives everything from
// the inbox, so one follow-up covers however many requests it coalesces).
const materialize = createSerialTask(runCycle);
// (A) RELIABLE-AT-CONNECTION: run one materialization directly on this trigger
// ([ready, ownedKey]). This is the spec's core — the owner, at its NEXT
// CONNECTION, deterministically processes its owned events' inbox, reading
// through the synced-view contract. It does NOT depend on a cross-session
// inbox push arriving.
void materialize('connection');
void materialize('connection').catch(err =>
console.error(`${logPrefix} owner participation materialize cycle rejected:`, err));
// (B) SAME-SESSION LIVE: `inbox.watch` fires on the initial state push and on
// every later deposit visible to THIS verifier (a local deposit, or a remote one
@@ -723,16 +1094,18 @@ function useNgData(): FestipodDataContextValue {
// stays live when a deposit does push. Cross-session convergence does NOT rely on
// this (it relies on (A) at the owner's next connection); this only sharpens the
// same-session/live case. One watch PER owned event — each event has its OWN
// inbox document — resolved async, so wire them inside an IIFE and stash the
// unsubscribes for cleanup.
// inbox document — and the address watched is the one taken from the SHARED map
// above, so what is watched is exactly what the cycle reads.
const unsubscribes: Array<() => void> = [];
(async () => {
for (const evId of owned) {
const targetInbox = await openDocumentInbox(evId);
if (cancelled) return;
unsubscribes.push(inbox.watch(targetInbox, () => void materialize('inbox-push')));
void (async () => {
const inboxes = await inboxesForOwnedEvents();
if (cancelled) return;
for (const targetInbox of inboxes.values()) {
unsubscribes.push(inbox.watch(targetInbox, () =>
void materialize('inbox-push').catch(err =>
console.error(`${logPrefix} owner participation materialize cycle rejected:`, err))));
}
})();
})().catch(err => console.error(`${logPrefix} owner inbox watch wiring failed:`, err));
return () => { cancelled = true; for (const stop of unsubscribes) stop(); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ready, ownedKey]);
@@ -805,12 +1178,18 @@ function useNgData(): FestipodDataContextValue {
await openDocumentInbox(eventGraph);
const eventId = await writeEntity(eventGraph, ENTITY_TYPE.event, {
title: str(event.title), description: str(event.description), date: str(event.date),
// `date` is the human LABEL the form composed; these four are the machine
// values it collected, written UNALTERED as the form's own ISO forms
// (`YYYY-MM-DD`, `HH:MM`). They are stored alongside the label, never
// derived from it — the label is display text and cannot be parsed back.
startDate: str(event.startDate), endDate: str(event.endDate),
startTime: str(event.startTime), endTime: str(event.endTime),
location: str(event.location), distance: flt(event.distance),
// No host notion: the creator merely SIGNALS a public event and is NOT
// obliged to participate, so the count starts at 0 (the owner-materializer
// derives it from the active-registration set — |active|, no host baseline).
participantCount: int(event.participantCount || 0),
coverImage: str(event.coverImage), hostName: str(event.hostName), hostInitials: str(event.hostInitials),
coverImage: str(event.coverImage),
});
// OPTION B: this event's doc is MINE (I just created it), so track it as owned
// → the owner-materializer subscribes to its inbox and maintains its count.
@@ -858,6 +1237,14 @@ function useNgData(): FestipodDataContextValue {
if (updates.title !== undefined) persists.push(updateEntityField(graph, id, 'title', str(updates.title)));
if (updates.description !== undefined) persists.push(updateEntityField(graph, id, 'description', str(updates.description)));
if (updates.date !== undefined) persists.push(updateEntityField(graph, id, 'date', str(updates.date)));
// The machine dates/times travel with the label, not instead of it. A field
// arriving as `''` (the form's empty date input) CLEARS the triple rather than
// leaving a stale value behind — that is `updateEntityField`'s empty handling,
// and it is the right reading here: the user emptied the input.
if (updates.startDate !== undefined) persists.push(updateEntityField(graph, id, 'startDate', str(updates.startDate)));
if (updates.endDate !== undefined) persists.push(updateEntityField(graph, id, 'endDate', str(updates.endDate)));
if (updates.startTime !== undefined) persists.push(updateEntityField(graph, id, 'startTime', str(updates.startTime)));
if (updates.endTime !== undefined) persists.push(updateEntityField(graph, id, 'endTime', str(updates.endTime)));
if (updates.location !== undefined) persists.push(updateEntityField(graph, id, 'location', str(updates.location)));
if (updates.distance !== undefined) persists.push(updateEntityField(graph, id, 'distance', flt(updates.distance)));
await Promise.all(persists).catch(err => console.error(`${logPrefix} persist event update failed:`, err));
@@ -866,14 +1253,19 @@ function useNgData(): FestipodDataContextValue {
const joinEvent = useCallback(async (eventId: string, userId?: string) => {
const uid = userId || currentUserId;
console.log(`${logPrefix} joinEvent (NG):`, eventId, 'user:', uid);
// A Participation MUST carry a user principal (SHEX `fp:user` is mandatory) —
// writing one without it produces an entity the ORM drops on read (the
// participation silently never round-trips). Refuse an empty principal rather
// than persist a broken participation. The caller resolves a real user id (the
// current user's IRI) before joining.
// A Participation MUST carry a user reference (SHEX `fp:user` is mandatory) —
// writing one without it produces an entity the read drops (the participation
// silently never round-trips). REJECT rather than return: returning here made
// the sign-up a no-op that wrote nothing, threw nothing and let the screen
// congratulate the user. The cause is named, because it is actionable: no
// profile of mine is resolved yet.
if (!uid) {
console.error(`${logPrefix} joinEvent: empty user principal — refusing to write a participation with no fp:user.`);
return;
const msg =
`joinEvent refused for event=${canonicalEventId(eventId)}: no profile of mine is resolved, ` +
`so a Participation would carry no fp:user and would never round-trip. ` +
`Wait for the profile to be created/read, or resolve the ambiguity reported above.`;
console.error(`${logPrefix} ${msg}`);
throw new Error(msg);
}
// IDEMPOTENCE — check AUTHORITATIVELY against the broker, not the reactive set.
// The reactive participation set can lag a just-written participation, so a
@@ -921,16 +1313,24 @@ function useNgData(): FestipodDataContextValue {
// The joiner's sole writes are: their OWN participation doc (above) + the
// inbox DEPOSIT (below). While the owner is offline the count doesn't advance
// for others — accepted eventual behaviour (brief §E.2); nothing is lost.
// 2) Notify the host: deposit into the event/host inbox via the GENERIC lib
// inbox (T02.b) + mint the host FpNotification (T02.a). `from` = registrant
// when connected, anonymous (null) otherwise. Best-effort: a failed deposit
// must not roll back a successful registration.
// 2) Tell the host. THE DEPOSIT IS THE DELIVERY: `inbox.postToDocument(doc, …)`
// is what the surface publishes for reaching a document's owner — anyone
// may deposit, only the owner reads. The host's notification is then built
// on the OWNER's side, out of the deposits it reads from its own event's
// inbox (`readRegistrationNotifications`, in the materializer above).
//
// Nothing else is written here. A host FpNotification used to be minted at
// this point into the JOINER's own protected scope with `recipient` set to
// the event — a document the host cannot read, and never will: the joiner
// owns it and the surface has no way to hand it over. It also pushed that
// notification into THIS session's own list, so the joiner saw a
// "new participant" notice addressed to someone else. Both are gone; the
// deposit alone carries the news, and it reaches its reader.
//
// Best-effort: a failed deposit must not roll back a successful
// registration (the participation document is already written).
try {
const registrantId = uid || null; // no current user → anonymous deposit
// Recipient = the event host. The Event shape carries no host IRI yet, so
// we key the host inbox/notification on the eventId (the host of THAT
// event). This is the domain injection the generic lib deliberately omits.
const recipientId = eventId;
// The event's `@id` IS its document NURI, and a deposit NAMES that document
// — the joiner resolves no inbox and holds no address.
// Carry the joiner's participation-doc NURI so the owner (if a connection)
@@ -940,24 +1340,11 @@ function useNgData(): FestipodDataContextValue {
`event=${canonicalEventId(eventId)} user=${uid} (count now moves via the OWNER ` +
`materializing this deposit on its own doc, at its next connection)`,
);
const { ts, uid: depositUid } = await depositRegistration(eventId, registrantId, partGraph);
const { uid: depositUid } = await depositRegistration(eventId, registrantId, partGraph);
// Remember the join uid so a same-session leave can cancel it precisely.
joinUidsRef.current.set(`${eventId}|${uid}`, depositUid);
const notif = buildNotification(recipientId, eventId, registrantId, ts);
// The host FpNotification is its OWN document in the PROTECTED scope (one
// doc per entity). The inbox materialization remains the source of truth;
// this direct write only pre-warms the reactive read — but a FAILED write is
// not swallowed: it used to be dropped silently, and the line below then
// surfaced a notification nothing had recorded. The rejection reaches the
// catch under this block, which names it.
const notifGraph = await createEntityDoc('protected');
await insertNotification(notifGraph, notif);
// Surface immediately in reactive state (materialization also refreshes it).
// Use the stable per-deposit uid for the id (F5 dedup) so it matches the
// notification id from the inbox and same-ms/anon deposits never collide.
setNotifications(prev => [...prev, { ...notif, id: `notif-${depositUid}` }]);
} catch (err) {
console.error(`${logPrefix} joinEvent inbox/notify failed:`, err);
console.error(`${logPrefix} joinEvent inbox deposit failed:`, err);
}
}, [events, currentUserId]);
@@ -968,7 +1355,22 @@ function useNgData(): FestipodDataContextValue {
// document (writeEntity uses the doc NURI as the subject), so `part.id` is BOTH
// the subject IRI AND the graph NURI it lives in.
const part = participations.find(p => p.eventId === eventId && p.userId === uid);
if (!part) return;
// REJECT rather than pretend. Withdrawal is AUTHORITATIVE (see
// caveat_participation-deletion): the caller only reaches here because it
// believes a participation exists, so finding none is a real disagreement
// about the state — either no profile of mine is resolved (`uid` empty), or
// the participation the screen showed is not in the set. Returning silently
// deleted nothing while the screen announced a withdrawal, and the sign-up
// came back on the next read.
if (!part) {
const msg = uid
? `leaveEvent refused for event=${canonicalEventId(eventId)}: no participation of user=${uid} ` +
`in the participation set — nothing was deleted, so the withdrawal must not be announced.`
: `leaveEvent refused for event=${canonicalEventId(eventId)}: no profile of mine is resolved, ` +
`so the participation to withdraw cannot even be named.`;
console.error(`${logPrefix} ${msg}`);
throw new Error(msg);
}
// DÉSINSCRIPTION FIX (caveat_participation-deletion): the AUTHORITATIVE deletion
// is a SPARQL DELETE via the real injected `ng` (docs.sparqlUpdate), which
// removes the Participation server-side so it does NOT resurrect after re-sync.
@@ -1054,9 +1456,19 @@ function useNgData(): FestipodDataContextValue {
const updateProfile = useCallback(async (updates: Partial<FpUserData>) => {
console.log(`${logPrefix} updateProfile (NG):`, updates);
// The current user's profile is its own document (subject IRI = doc NURI).
const target = currentUser ?? users[0];
if (!target) return;
// MY profile is its own document (subject IRI = doc NURI), and it is the ONLY
// document these edits may land in. There used to be a `?? users[0]` fallback
// here: with no profile resolved, a person editing their own pseudo wrote it
// into a stranger's profile document. Refuse instead — and say which of the
// two reasons it is, since only one of them clears up on its own.
const target = currentUser;
if (!target) {
const msg =
`updateProfile refused: no profile of mine is resolved, and these edits must never land in ` +
`someone else's profile document.`;
console.error(`${logPrefix} ${msg}`);
throw new Error(msg);
}
const graph = target.id;
const persists: Promise<void>[] = [];
if (updates.name !== undefined) persists.push(updateEntityField(graph, graph, 'name', str(updates.name)));
@@ -1069,11 +1481,19 @@ function useNgData(): FestipodDataContextValue {
const loadTestData = useCallback(async (): Promise<BootstrapResult> => {
console.log(`${logPrefix} loadTestData (NG)`);
// THIS NO LONGER SEEDS. `bootstrapWallet` refuses while the fixture seed is
// switched off (`fixtureSeedEnabled`), so what comes back is `seeded: false`
// with no documents, and everything below is a no-op on that empty result.
// The call is kept, and kept honest, rather than removed: the switch is what
// decides, in one place, and flipping it back restores this path unchanged.
// An EXPLICIT load is authoritative — SUPPRESS the dev auto-seed so only ONE
// seed runs (marking the guard at the START, before the awaited seed, closes
// the window where the auto-seed effect could also fire on a still-empty read).
hasTriedAutoSeed.current = true;
const walletHasData = events.length > 0 || users.length > 0;
// Same reading as the auto-seed gate: my own profile is not "data in the
// wallet" — it exists on every wallet from the first sign-in, so counting it
// would make an explicit load a permanent no-op.
const walletHasData = events.length > 0 || users.some(u => u.id !== myProfileDocId);
const result = await bootstrapWallet(walletHasData, createEntityDoc);
// The seeded per-entity docs are appended to their scope indices, which
// `watchShape` subscribes → they enter the reactive reads on the push. No
@@ -1083,8 +1503,11 @@ function useNgData(): FestipodDataContextValue {
// them — otherwise the owner-materializer never opens their inboxes and their
// `participantCount` is never derived.
claimOwnedEventDocs(result.createdDocs.public);
// The seeded PROTECTED docs are deliberately NOT claimed as mine: they are
// FIXTURES (nobody signed in as them), and folding them into the owned-profile
// set is exactly what would make "which profile is mine" ambiguous.
return result;
}, [events.length, users.length, claimOwnedEventDocs]);
}, [events.length, users, myProfileDocId, claimOwnedEventDocs]);
return {
currentUserId, currentUser, currentPrincipal,
@@ -1096,6 +1519,7 @@ function useNgData(): FestipodDataContextValue {
selectedEventId, setSelectedEventId, selectedEvent,
selectedUserId, setSelectedUserId, selectedUser,
...queries,
getEventOwnership,
createEvent, updateEvent, joinEvent, leaveEvent,
addMeetingPoint, addFriend, updateProfile, loadTestData,
};
+23 -5
View File
@@ -102,12 +102,30 @@ export async function updateEntityField(
// write shape (same as writeEntity / registration.ts); SDK graph details live in
// `@ng-eventually/polyfill`, not here. `docs.sparqlUpdate` validates the anchor at
// its own door — `subject` only needs escaping, as it lands in an IRI position.
const del = `DELETE WHERE { <${s}> <${pred}> ?o }`;
await docs.sparqlUpdate(sid, del, graphNuri);
if (obj !== null) {
const ins = `INSERT DATA { <${s}> <${pred}> ${obj} }`;
await docs.sparqlUpdate(sid, ins, graphNuri);
if (obj === null) {
// Clearing the field: there is nothing to put back, so the removal stands alone.
await docs.sparqlUpdate(sid, `DELETE WHERE { <${s}> <${pred}> ?o }`, graphNuri);
return;
}
// ONE update, not a DELETE followed by an INSERT. Sent as two, the field is
// ABSENT between them, and a read landing in that window does not see "the old
// value" — it sees NO value, which the read side turns into the field's empty
// reading (0 for `participantCount`). The window is small and the reads are
// pushed, so it shows up as a count that flickers to 0 for no reason anyone can
// reproduce on demand.
//
// `DELETE … INSERT … WHERE` is ONE SPARQL modify operation — the surface takes
// an update string and this is a single one, so nothing here invents a
// transaction the SDK does not offer. The `OPTIONAL` is what makes it work on a
// field that is not there yet: the WHERE still yields one solution (with `?o`
// unbound, so the DELETE template drops out) and the INSERT applies. When the
// field is present, every one of its triples is removed and the new one written
// in the same operation.
const update = `
DELETE { <${s}> <${pred}> ?o }
INSERT { <${s}> <${pred}> ${obj} }
WHERE { OPTIONAL { <${s}> <${pred}> ?o } }`;
await docs.sparqlUpdate(sid, update, graphNuri);
}
/**
+6 -6
View File
@@ -20,6 +20,7 @@ import { inbox, docs } from '@ng-eventually/polyfill';
import type { Nuri, NuriLike } from '@ng-eventually/polyfill';
import { sessionPromise } from '../utils/ngSession';
import { listMyEntityDocs } from '../utils/storeRegistry';
import { canonicalDocumentId } from '../utils/documentNuri';
import { escapeLiteral, escapeIri } from './sparqlEscape';
import type { FpNotificationData } from './types';
@@ -105,13 +106,12 @@ function mintDepositUid(): string {
* target).
*
* A NURI with no `:v:` overlay (or a non-`did:ng:o:` id) passes through unchanged.
*
* The canonicalization itself is `canonicalDocumentId` (utils/documentNuri): the
* SAME invariant also keys the app's one-inbox-per-document resolution, and one
* document must not have two canonical forms depending on who is asking.
*/
export function canonicalEventId(id: string): string {
// did:ng:o:<repo>:v:<overlay> → did:ng:o:<repo>. The overlay segment is the
// LAST `:v:`-introduced part; a base id (`did:ng:o:<repo>`) has no `:v:`.
const i = id.indexOf(':v:');
return i === -1 ? id : id.slice(0, i);
}
export const canonicalEventId = canonicalDocumentId;
/**
* Build the host-facing notification from a registration deposit. The recipient
-14
View File
@@ -130,8 +130,6 @@ export const seedEvents: FpEventData[] = [
distance: 142,
participantCount: 24,
description: 'Une semaine collaborative pour se rencontrer, co-créer et faire avancer le projet de Réseau Social Universel. Au programme : sessions plénières en intelligence collective, ateliers en forum ouvert, et randonnée au Cirque de Navacelles. Hébergement sur place au Revel, écolieu à Rogues dans le Gard.',
hostName: 'Reconnexion',
hostInitials: 'RC',
themes: ['Social'],
},
{
@@ -145,8 +143,6 @@ export const seedEvents: FpEventData[] = [
distance: 3,
participantCount: 12,
description: 'Un atelier pratique pour découvrir les low-tech et apprendre à fabriquer des objets du quotidien.',
hostName: 'La Maison du Vélo',
hostInitials: 'MV',
themes: ['Tech', 'Nature'],
},
{
@@ -160,8 +156,6 @@ export const seedEvents: FpEventData[] = [
distance: 89,
participantCount: 45,
description: "Un forum ouvert sur la transition écologique et sociale, dans le tiers-lieu L'Hermitage.",
hostName: "L'Hermitage",
hostInitials: 'LH',
themes: ['Social', 'Nature'],
},
{
@@ -175,8 +169,6 @@ export const seedEvents: FpEventData[] = [
distance: 5,
participantCount: 16,
description: 'Initiation à la Communication Non Violente. Venez découvrir les bases de la CNV pour améliorer vos relations.',
hostName: 'MJC Montplaisir',
hostInitials: 'MJ',
themes: ['Social'],
},
{
@@ -190,8 +182,6 @@ export const seedEvents: FpEventData[] = [
distance: 7,
participantCount: 30,
description: "Rencontre mensuelle du groupe local des Colibris pour échanger sur les projets en cours.",
hostName: 'Les Colibris',
hostInitials: 'LC',
themes: ['Social', 'Nature'],
},
];
@@ -214,16 +204,12 @@ export const seedMeetingPoints: FpMeetingPointData[] = [
eventId: 'event-1',
location: 'Café de la Place',
time: '30 min avant',
hostName: 'Marie',
hostInitials: 'MD',
},
{
id: 'mp-2',
eventId: 'event-1',
location: 'Station de métro Bellecour',
time: '15h30',
hostName: 'Jean',
hostInitials: 'JD',
},
];
+9 -2
View File
@@ -37,12 +37,19 @@ export function adaptEvent(s: UnionSubject): FpEventData {
title: one(s, 'title'),
description: one(s, 'description'),
date: one(s, 'date'),
// The four machine values, read back exactly as stored (`YYYY-MM-DD`, `HH:MM`).
// `undefined` — not `''` — when the triple is absent: the app type declares them
// optional and the screens branch on their PRESENCE (`event.startTime && …`), so
// an event written before these fields existed reads as an event without them
// rather than one whose dates are blank strings.
startDate: one(s, 'startDate') || undefined,
endDate: one(s, 'endDate') || undefined,
startTime: one(s, 'startTime') || undefined,
endTime: one(s, 'endTime') || undefined,
location: one(s, 'location'),
distance: s.props[`${FP}distance`] ? num(s, 'distance') : undefined,
participantCount: num(s, 'participantCount'),
coverImage: one(s, 'coverImage') || undefined,
hostName: one(s, 'hostName') || undefined,
hostInitials: one(s, 'hostInitials') || undefined,
};
}
+23 -23
View File
@@ -126,7 +126,7 @@ export const stepDefinitions: StepDefinitionInfo[] = [
"pattern": "l'utilisateur attend la fin du chargement",
"keyword": "When",
"file": "connexion.steps.ts",
"sourceCode": "When('l\\'utilisateur attend la fin du chargement', async function (this: FestipodWorld) {\n await this.appFrame!.waitForFunction(\n () => {\n const buttons = Array.from(document.querySelectorAll('button'));\n return !buttons.some(b => b.textContent?.includes('Chargement...'));\n },\n { timeout: 60000 },\n );\n await this.appFrame!.waitForTimeout(2000);\n});",
"sourceCode": "When('l\\'utilisateur attend la fin du chargement', async function (this: FestipodWorld) {\n await this.appFrame!.waitForFunction(\n () => {\n const buttons = Array.from(document.querySelectorAll('button'));\n return !buttons.some(b => b.textContent?.includes('Chargement...'));\n },\n undefined,\n { timeout: 60000 },\n );\n await this.appFrame!.waitForTimeout(2000);\n});",
"lineNumber": 133
},
{
@@ -134,7 +134,7 @@ export const stepDefinitions: StepDefinitionInfo[] = [
"keyword": "Then",
"file": "connexion.steps.ts",
"sourceCode": "Then('l\\'écran d\\'accueil affiche des événements', async function (this: FestipodWorld) {\n // Navigate to the events screen (path-based) and verify cards are rendered.\n // Home shows only events the current user participates in, which depends\n // on participations hydrating from NG — flaky for a basic data check.\n await this.appFrame!.evaluate(() => {\n window.history.pushState(null, '', '/events');\n window.dispatchEvent(new PopStateEvent('popstate'));\n });",
"lineNumber": 144
"lineNumber": 145
},
{
"pattern": "le créateur relaie l'événement {string}",
@@ -399,7 +399,7 @@ export const stepDefinitions: StepDefinitionInfo[] = [
"pattern": "l'événement {string} finit par apparaître sur la page fraîche A en laissant jusqu'à 60 secondes à la barrière avec rechargements",
"keyword": "Then",
"file": "reconnexion.steps.ts",
"sourceCode": "Then('l\\'événement {string} finit par apparaître sur la page fraîche A en laissant jusqu\\'à 60 secondes à la barrière avec rechargements', { timeout: 120000 }, async function (this: FestipodWorld, title: string) {\n const freshPage = (this as any).recoFreshPage as import('playwright').Page;\n let freshFrame = (this as any).recoFreshFrame as import('playwright').Frame;\n const startedAt = Date.now();\n const BUDGET_MS = 60000;\n const reloadAtMs = [20000, 40000]; // force a fresh barrier attempt at these marks\n let reloadIdx = 0;\n let appearedAtMs = -1;\n\n const readHome = async (): Promise<string[]> => {\n try {\n return await freshFrame.evaluate((t: string) => {\n const td = (window as any).__testData;\n return td && td.homeEventTitles ? td.homeEventTitles() : [];\n }, title);\n } catch { return []; }\n };\n\n while (Date.now() - startedAt < BUDGET_MS) {\n const elapsed = Date.now() - startedAt;\n const titles = await readHome();\n if (titles.includes(title)) { appearedAtMs = elapsed; break; }\n // At each reload mark, do a FULL reload → new NgDataProvider mount → new barrier.\n const reloadMark = reloadAtMs[reloadIdx];\n if (reloadMark !== undefined && elapsed >= reloadMark) {\n reloadIdx++;\n console.log(`[LongPoll] t=${elapsed}ms still ABSENT — forcing a full reload (#${reloadIdx}) to re-attempt the barrier…`);\n try {\n freshFrame = await pool.setupBrokerPage!(freshPage, pool.harnessUrl!);\n await freshFrame.waitForFunction(() => (window as any).__testData?.ready === true, { timeout: 60000 });",
"sourceCode": "Then('l\\'événement {string} finit par apparaître sur la page fraîche A en laissant jusqu\\'à 60 secondes à la barrière avec rechargements', { timeout: 120000 }, async function (this: FestipodWorld, title: string) {\n const freshPage = (this as any).recoFreshPage as import('playwright').Page;\n let freshFrame = (this as any).recoFreshFrame as import('playwright').Frame;\n const startedAt = Date.now();\n const BUDGET_MS = 60000;\n const reloadAtMs = [20000, 40000]; // force a fresh barrier attempt at these marks\n let reloadIdx = 0;\n let appearedAtMs = -1;\n\n const readHome = async (): Promise<string[]> => {\n try {\n return await freshFrame.evaluate((t: string) => {\n const td = (window as any).__testData;\n return td && td.homeEventTitles ? td.homeEventTitles() : [];\n }, title);\n } catch { return []; }\n };\n\n while (Date.now() - startedAt < BUDGET_MS) {\n const elapsed = Date.now() - startedAt;\n const titles = await readHome();\n if (titles.includes(title)) { appearedAtMs = elapsed; break; }\n // At each reload mark, do a FULL reload → new NgDataProvider mount → new barrier.\n const reloadMark = reloadAtMs[reloadIdx];\n if (reloadMark !== undefined && elapsed >= reloadMark) {\n reloadIdx++;\n console.log(`[LongPoll] t=${elapsed}ms still ABSENT — forcing a full reload (#${reloadIdx}) to re-attempt the barrier…`);\n try {\n freshFrame = await pool.setupBrokerPage!(freshPage, pool.harnessUrl!);\n await freshFrame.waitForFunction(() => (window as any).__testData?.ready === true, undefined, { timeout: 60000 });",
"lineNumber": 56
},
{
@@ -442,70 +442,70 @@ export const stepDefinitions: StepDefinitionInfo[] = [
"keyword": "When",
"file": "evenement.steps.ts",
"sourceCode": "When('l\\'utilisateur attend que l\\'écran {string} soit affiché', async function (this: FestipodWorld, screenId: string) {\n // We match on pathname prefix to allow for dynamic ids (event-detail etc.).\n const expectedPath = screenId === 'event-detail' ? '/events/' :\n screenId === 'update-event' ? '/edit' :\n screenId === 'create-event' ? '/events/new' :\n screenId === 'home' ? '/home' :\n screenId === 'events' ? '/events' :\n '/' + screenId;\n\n await this.appFrame!.waitForFunction(\n (path: string) => {\n const current = window.location.pathname;\n if (path === '/edit') return current.endsWith('/edit');\n return current.startsWith(path);\n },\n expectedPath,\n { timeout: 10000 },\n );\n await this.appFrame!.waitForTimeout(1000);\n});",
"lineNumber": 37
"lineNumber": 38
},
{
"pattern": "l'utilisateur remplit le formulaire de création d'événement:",
"keyword": "When",
"file": "evenement.steps.ts",
"sourceCode": "When('l\\'utilisateur remplit le formulaire de création d\\'événement:', async function (this: FestipodWorld, dataTable: any) {\n const rows = dataTable.hashes() as { champ: string; valeur: string }[];\n\n // The new CreateEventScreen is a 3-step wizard:\n // Step 1: name + dates\n // Step 2: similar-event warning (skipped if none)\n // Step 3: location + description + times\n //\n // We'll fill Step 1 fields first, click Next, then fill remaining fields.\n\n const formReady = await this.appFrame!.waitForFunction(\n () => !!document.querySelector('input[placeholder=\"Donnez un nom à votre événement\"]'),\n { timeout: 10000 },\n ).then(() => true).catch(() => false);\n\n if (!formReady) {\n const debug = await this.appFrame!.evaluate(() => ({\n pathname: window.location.pathname,\n inputs: Array.from(document.querySelectorAll('input')).map(i => i.placeholder),\n rootText: document.getElementById('root')?.textContent?.substring(0, 300),\n }));\n throw new Error(`Create form not found. Path: ${debug.pathname}, inputs: ${JSON.stringify(debug.inputs)}, content: ${debug.rootText}`);\n }\n\n const byChamp: Record<string, string> = {};\n for (const { champ, valeur } of rows) byChamp[champ] = valeur;\n\n // Step 1: name + start/end date\n if (byChamp['Nom de l\\'événement']) {\n const input = this.appFrame!.locator('input[placeholder=\"Donnez un nom à votre événement\"]');\n await input.fill(byChamp['Nom de l\\'événement']);\n }\n if (byChamp['Date de début']) {\n await this.appFrame!.locator('input[type=\"date\"]').first().fill(byChamp['Date de début']);\n }\n if (byChamp['Date de fin']) {\n await this.appFrame!.locator('input[type=\"date\"]').nth(1).fill(byChamp['Date de fin']);\n }\n\n // Advance to step 3 (may pass through step 2 if a similar event matches)\n let stepBtn = this.appFrame!.locator('button', { hasText: 'Suivant' });",
"lineNumber": 60
"sourceCode": "When('l\\'utilisateur remplit le formulaire de création d\\'événement:', async function (this: FestipodWorld, dataTable: any) {\n const rows = dataTable.hashes() as { champ: string; valeur: string }[];\n\n // The new CreateEventScreen is a 3-step wizard:\n // Step 1: name + dates\n // Step 2: similar-event warning (skipped if none)\n // Step 3: location + description + times\n //\n // We'll fill Step 1 fields first, click Next, then fill remaining fields.\n\n const formReady = await this.appFrame!.waitForFunction(\n () => !!document.querySelector('input[placeholder=\"Donnez un nom à votre événement\"]'),\n undefined,\n { timeout: 10000 },\n ).then(() => true).catch(() => false);\n\n if (!formReady) {\n const debug = await this.appFrame!.evaluate(() => ({\n pathname: window.location.pathname,\n inputs: Array.from(document.querySelectorAll('input')).map(i => i.placeholder),\n rootText: document.getElementById('root')?.textContent?.substring(0, 300),\n }));\n throw new Error(`Create form not found. Path: ${debug.pathname}, inputs: ${JSON.stringify(debug.inputs)}, content: ${debug.rootText}`);\n }\n\n const byChamp: Record<string, string> = {};\n for (const { champ, valeur } of rows) byChamp[champ] = valeur;\n\n // Step 1: name + start/end date\n if (byChamp['Nom de l\\'événement']) {\n const input = this.appFrame!.locator('input[placeholder=\"Donnez un nom à votre événement\"]');\n await input.fill(byChamp['Nom de l\\'événement']);\n }\n if (byChamp['Date de début']) {\n await this.appFrame!.locator('input[type=\"date\"]').first().fill(byChamp['Date de début']);\n }\n if (byChamp['Date de fin']) {\n await this.appFrame!.locator('input[type=\"date\"]').nth(1).fill(byChamp['Date de fin']);\n }\n\n // Advance to step 3 (may pass through step 2 if a similar event matches)\n let stepBtn = this.appFrame!.locator('button', { hasText: 'Suivant' });",
"lineNumber": 61
},
{
"pattern": "l'utilisateur modifie le champ lieu avec {string}",
"keyword": "When",
"file": "evenement.steps.ts",
"sourceCode": "When('l\\'utilisateur modifie le champ lieu avec {string}', async function (this: FestipodWorld, valeur: string) {\n // UpdateEventScreen has a \"Lieu *\" label followed by an Input. Find the input\n // adjacent to that label.\n await this.appFrame!.waitForFunction(\n () => document.getElementById('root')?.textContent?.includes('Lieu') ?? false,\n { timeout: 10000 },\n );\n await this.appFrame!.evaluate((val: string) => {\n const labels = document.querySelectorAll('*');\n for (const el of labels) {\n if (el.textContent?.trim() === 'Lieu *' && el.tagName !== 'DIV') {\n const parent = el.parentElement;\n const input = parent?.querySelector('input');\n if (input) {\n const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!;\n nativeInputValueSetter.call(input, val);\n input.dispatchEvent(new Event('input', { bubbles: true }));\n input.dispatchEvent(new Event('change', { bubbles: true }));\n return;\n }\n }\n }\n }, valeur);\n await this.appFrame!.waitForTimeout(500);\n});",
"lineNumber": 129
"sourceCode": "When('l\\'utilisateur modifie le champ lieu avec {string}', async function (this: FestipodWorld, valeur: string) {\n // UpdateEventScreen has a \"Lieu *\" label followed by an Input. Find the input\n // adjacent to that label.\n await this.appFrame!.waitForFunction(\n () => document.getElementById('root')?.textContent?.includes('Lieu') ?? false,\n undefined,\n { timeout: 10000 },\n );\n await this.appFrame!.evaluate((val: string) => {\n const labels = document.querySelectorAll('*');\n for (const el of labels) {\n if (el.textContent?.trim() === 'Lieu *' && el.tagName !== 'DIV') {\n const parent = el.parentElement;\n const input = parent?.querySelector('input');\n if (input) {\n const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!;\n nativeInputValueSetter.call(input, val);\n input.dispatchEvent(new Event('input', { bubbles: true }));\n input.dispatchEvent(new Event('change', { bubbles: true }));\n return;\n }\n }\n }\n }, valeur);\n await this.appFrame!.waitForTimeout(500);\n});",
"lineNumber": 131
},
{
"pattern": "l'utilisateur clique sur un événement de l'accueil",
"keyword": "When",
"file": "evenement.steps.ts",
"sourceCode": "When('l\\'utilisateur clique sur un événement de l\\'accueil', async function (this: FestipodWorld) {\n // HomeScreen renders only events the current user participates in. If\n // participations haven't hydrated from NG yet, the screen is empty — fall\n // back to /events (no participation filter).\n await this.appFrame!.evaluate(() => {\n window.history.pushState(null, '', '/home');\n window.dispatchEvent(new PopStateEvent('popstate'));\n });",
"lineNumber": 157
"lineNumber": 160
},
{
"pattern": "l'utilisateur clique sur un événement de la liste",
"keyword": "When",
"file": "evenement.steps.ts",
"sourceCode": "When('l\\'utilisateur clique sur un événement de la liste', async function (this: FestipodWorld) {\n // EventsScreen also uses Card with .app-card class.\n await this.appFrame!.waitForFunction(\n () => document.querySelectorAll('.app-card').length > 0,\n { timeout: 10000 },\n );\n const clicked = await this.appFrame!.evaluate(() => {\n const cards = document.querySelectorAll('.app-card');\n for (const card of cards) {\n const el = card as HTMLElement;\n if (el.style.cursor === 'pointer' || window.getComputedStyle(el).cursor === 'pointer') {\n el.click();\n return true;\n }\n }\n return false;\n });",
"lineNumber": 210
"sourceCode": "When('l\\'utilisateur clique sur un événement de la liste', async function (this: FestipodWorld) {\n // EventsScreen also uses Card with .app-card class.\n await this.appFrame!.waitForFunction(\n () => document.querySelectorAll('.app-card').length > 0,\n undefined,\n { timeout: 10000 },\n );\n const clicked = await this.appFrame!.evaluate(() => {\n const cards = document.querySelectorAll('.app-card');\n for (const card of cards) {\n const el = card as HTMLElement;\n if (el.style.cursor === 'pointer' || window.getComputedStyle(el).cursor === 'pointer') {\n el.click();\n return true;\n }\n }\n return false;\n });",
"lineNumber": 215
},
{
"pattern": "l'utilisateur clique sur le bouton de modification",
"keyword": "When",
"file": "evenement.steps.ts",
"sourceCode": "When('l\\'utilisateur clique sur le bouton de modification', async function (this: FestipodWorld) {\n // The edit button shows \"✎\" in the header — only visible if user is event owner\n const editBtn = this.appFrame!.locator('text=✎').first();\n await editBtn.click();\n await this.appFrame!.waitForTimeout(1500);\n});",
"lineNumber": 233
"lineNumber": 239
},
{
"pattern": "l'utilisateur clique sur le bouton {string} si visible",
"keyword": "When",
"file": "evenement.steps.ts",
"sourceCode": "When('l\\'utilisateur clique sur le bouton {string} si visible', async function (this: FestipodWorld, buttonText: string) {\n const button = this.appFrame!.locator('button', { hasText: buttonText }).first();\n if (await button.isVisible({ timeout: 3000 }).catch(() => false)) {\n await button.click();\n await this.appFrame!.waitForTimeout(1000);\n }\n});",
"lineNumber": 240
"lineNumber": 246
},
{
"pattern": "l'écran contient le texte {string}",
"keyword": "Then",
"file": "evenement.steps.ts",
"sourceCode": "Then('l\\'écran contient le texte {string}', async function (this: FestipodWorld, expectedText: string) {\n const appeared = await this.appFrame!.waitForFunction(\n (text: string) => document.getElementById('root')?.textContent?.includes(text) ?? false,\n expectedText,\n { timeout: 10000 },\n ).then(() => true).catch(() => false);\n\n if (!appeared) {\n const debug = await this.appFrame!.evaluate(() => ({\n pathname: window.location.pathname,\n rootText: document.getElementById('root')?.textContent?.substring(0, 500),\n }));\n expect.fail(\n `Expected text \"${expectedText}\" not found. Path: \"${debug.pathname}\", content: \"${debug.rootText}\"`,\n );\n }\n});",
"lineNumber": 250
"lineNumber": 256
},
{
"pattern": "l'écran ne contient pas le texte {string}",
"keyword": "Then",
"file": "evenement.steps.ts",
"sourceCode": "Then('l\\'écran ne contient pas le texte {string}', async function (this: FestipodWorld, unexpectedText: string) {\n await this.appFrame!.waitForTimeout(500);\n const found = await this.appFrame!.evaluate(\n (text: string) => document.getElementById('root')?.textContent?.includes(text) ?? false,\n unexpectedText,\n );\n expect(found, `Text \"${unexpectedText}\" should NOT be present`).to.be.false;\n});",
"lineNumber": 268
"lineNumber": 274
},
{
"pattern": "l'écran d'accueil contient le texte {string}",
"keyword": "Then",
"file": "evenement.steps.ts",
"sourceCode": "Then('l\\'écran d\\'accueil contient le texte {string}', async function (this: FestipodWorld, expectedText: string) {\n await this.appFrame!.evaluate(() => {\n window.history.pushState(null, '', '/home');\n window.dispatchEvent(new PopStateEvent('popstate'));\n });",
"lineNumber": 277
"lineNumber": 283
},
{
"pattern": "le navigateur {string} crée l'événement {string}",
@@ -624,21 +624,21 @@ export const stepDefinitions: StepDefinitionInfo[] = [
"keyword": "Given",
"file": "reconnexion-persistance.steps.ts",
"sourceCode": "Given('l\\'événement {string} apparaît sur l\\'accueil de l\\'utilisateur', { timeout: 60000 }, async function (this: FestipodWorld, title: string) {\n const frame = this.appFrame!;\n // Navigate home; if home (participation-filtered) is empty, fall back to\n // /events (Découvrir, no participation filter) — the created event is public.\n await frame.evaluate(() => {\n window.history.pushState(null, '', '/home');\n window.dispatchEvent(new PopStateEvent('popstate'));\n });",
"lineNumber": 137
"lineNumber": 138
},
{
"pattern": "l'utilisateur ferme et rouvre l'app sous la même identité dans une session broker fraîche",
"keyword": "When",
"file": "reconnexion-persistance.steps.ts",
"sourceCode": "When('l\\'utilisateur ferme et rouvre l\\'app sous la même identité dans une session broker fraîche', { timeout: 180000 }, async function (this: FestipodWorld) {\n // SAME browser context → same wallet → same person. The reopened app asks\n // `ensureIdentity()` who it is, exactly as the first page did.\n const ctx = this.page!.context();\n\n const freshPage = await ctx.newPage();\n\n const freshLogs: StampedLog[] = [];\n (this as any).recoFreshLogs = freshLogs;\n attachConsoleCapture(freshPage, freshLogs);\n freshPage.on('pageerror', (err) => freshLogs.push({ t: Date.now(), text: `pageerror: ${err.message}` }));\n\n // NEW broker login → fresh verifier session on the SAME persistent wallet.\n const freshFrame = await pool.setupBrokerPage!(freshPage, pool.appUrl!);\n // Wait for the real app to render.\n await freshFrame.waitForFunction(\n () => {\n const root = document.getElementById('root');\n return !!root && root.innerHTML.length > 100;\n },\n { timeout: 60000 },\n );\n // Let NG connect + the cold-start read path run.\n await freshFrame.waitForTimeout(6000);\n (this as any).recoFreshFrame = freshFrame;\n (this as any).recoFreshPage = freshPage;\n});",
"lineNumber": 172
"sourceCode": "When('l\\'utilisateur ferme et rouvre l\\'app sous la même identité dans une session broker fraîche', { timeout: 180000 }, async function (this: FestipodWorld) {\n // SAME browser context → same wallet → same person. The reopened app asks\n // `ensureIdentity()` who it is, exactly as the first page did.\n const ctx = this.page!.context();\n\n const freshPage = await ctx.newPage();\n\n const freshLogs: StampedLog[] = [];\n (this as any).recoFreshLogs = freshLogs;\n attachConsoleCapture(freshPage, freshLogs);\n freshPage.on('pageerror', (err) => freshLogs.push({ t: Date.now(), text: `pageerror: ${err.message}` }));\n\n // NEW broker login → fresh verifier session on the SAME persistent wallet.\n const freshFrame = await pool.setupBrokerPage!(freshPage, pool.appUrl!);\n // Wait for the real app to render.\n await freshFrame.waitForFunction(\n () => {\n const root = document.getElementById('root');\n return !!root && root.innerHTML.length > 100;\n },\n undefined,\n { timeout: 60000 },\n );\n // Let NG connect + the cold-start read path run.\n await freshFrame.waitForTimeout(6000);\n (this as any).recoFreshFrame = freshFrame;\n (this as any).recoFreshPage = freshPage;\n});",
"lineNumber": 173
},
{
"pattern": "l'événement {string} est toujours présent après reconnexion",
"keyword": "Then",
"file": "reconnexion-persistance.steps.ts",
"sourceCode": "Then('l\\'événement {string} est toujours présent après reconnexion', { timeout: 90000 }, async function (this: FestipodWorld, title: string) {\n const freshFrame = (this as any).recoFreshFrame as import('playwright').Frame;\n\n // Poll BOTH home (participation-filtered) and /events (Découvrir, public list),\n // re-navigating each attempt so the cold-start union read has time to converge.\n // This is NOT broker-polling (rule_no-broker-polling): the app is reactive; we\n // re-read the RENDERED DOM until the reactive set settles, bounded by timeout.\n const deadline = Date.now() + 60000;\n let found = false;\n while (Date.now() < deadline && !found) {\n for (const path of ['/home', '/events']) {\n await freshFrame.evaluate((p: string) => {\n window.history.pushState(null, '', p);\n window.dispatchEvent(new PopStateEvent('popstate'));\n }, path);\n found = await freshFrame.waitForFunction(\n (t: string) => document.getElementById('root')?.textContent?.includes(t) ?? false,\n title,\n { timeout: 6000 },\n ).then(() => true).catch(() => false);\n if (found) break;\n }\n }\n\n // --- Report console evidence from BOTH pages regardless of pass/fail ---\n const mainLogs = ((this as any).recoMainLogs ?? []) as StampedLog[];\n const freshLogs = ((this as any).recoFreshLogs ?? []) as StampedLog[];\n const report =\n summarizeLogs('MAIN PAGE (creator)', mainLogs) + '\\n\\n' +\n summarizeLogs('FRESH PAGE (reconnect)', freshLogs) + '\\n\\n' +\n `RESULT: event \"${title}\" ${found ? 'SURVIVED (visible after reconnect)' : 'DISAPPEARED (NOT visible after reconnect)'}`;\n this.attach(report, 'text/plain');\n // Also echo to stdout so it lands in the raw run output.\n console.log('\\n' + report + '\\n');\n\n // Opt-in RAW dump of connection/sync lines (RECO_RAW_DUMP=1) — the evidence\n // that the FRESH page is a genuine cold boot (own WASM worker + own broker\n // handshake), used to argue reconnection FIDELITY. Off by default (noise).\n if (process.env.RECO_RAW_DUMP === '1') {\n const dumpRaw = (label: string, logs: StampedLog[]) => {\n const t0 = logs.length ? logs[0]!.t : Date.now();\n const hits = logs.filter((l) => /peer|CONNECTION|ESTABLISHED|REPLAY|broker|verifier|worker|bootstrap|open_repo|\\bsync\\b/i.test(l.text));\n console.log(`\\n### RAW (${label}) — ${hits.length} connection/sync lines ###`);\n for (const l of hits) console.log(`+${((l.t - t0) / 1000).toFixed(2)}s ${l.text.slice(0, 200)}`);\n };\n dumpRaw('MAIN', mainLogs);\n dumpRaw('FRESH', freshLogs);\n }\n\n if (!found) {\n const debug = await freshFrame.evaluate(() => ({\n pathname: window.location.pathname,\n rootText: document.getElementById('root')?.textContent?.substring(0, 500),\n }));\n expect.fail(`Reconnected fresh page for the SAME identity did NOT show \"${title}\". Path: ${debug.pathname}, content: ${debug.rootText}`);\n }\n});",
"lineNumber": 200
"lineNumber": 202
},
{
"pattern": "je clique sur un événement",
@@ -735,7 +735,7 @@ export const stepDefinitions: StepDefinitionInfo[] = [
"pattern": "l'accueil rend un contenu d'application réel",
"keyword": "Then",
"file": "accueil-connecte-rend.steps.ts",
"sourceCode": "Then(\"l'accueil rend un contenu d'application réel\", async function (this: FestipodWorld) {\n // Marqueurs FORTS et propres à HomeScreen (absents de WelcomeScreen / d'un\n // simple spinner / du bandeau broker) :\n // - .app-navbar : la barre de navigation basse (BottomNav) — rendue par\n // HomeScreen, pas par l'écran d'onboarding ;\n // - le bouton « Relayer » (aria-label=\"Relayer un événement\") propre à\n // l'en-tête de l'accueil.\n // Si un throw dans HomeScreen (ou un provider monté après connexion) blanchit\n // le rendu, React démonte l'arbre (aucun ErrorBoundary) et ces marqueurs\n // disparaissent → l'attente échoue.\n const rendered = await this.appFrame!.waitForFunction(\n () => {\n const root = document.getElementById('root');\n if (!root) return false;\n const hasNavbar = document.querySelector('.app-navbar') !== null;\n const hasRelayer =\n document.querySelector('[aria-label=\"Relayer un événement\"]') !== null;\n return hasNavbar && hasRelayer;\n },\n { timeout: 15000 },\n ).then(() => true).catch(() => false);\n\n if (!rendered) {\n const debug = await this.appFrame!.evaluate(() => ({\n pathname: window.location.pathname,\n hasNavbar: document.querySelector('.app-navbar') !== null,\n hasRelayer: document.querySelector('[aria-label=\"Relayer un événement\"]') !== null,\n rootLen: document.getElementById('root')?.innerHTML.length ?? 0,\n rootText: document.getElementById('root')?.textContent?.substring(0, 400),\n }));\n expect.fail(\n `L'accueil connecté n'a pas rendu de contenu d'app réel (page blanche ?). ` +\n `path=\"${debug.pathname}\", .app-navbar=${debug.hasNavbar}, ` +\n `bouton Relayer=${debug.hasRelayer}, #root length=${debug.rootLen}, ` +\n `texte: \"${debug.rootText}\"`,\n );\n }\n});",
"sourceCode": "Then(\"l'accueil rend un contenu d'application réel\", async function (this: FestipodWorld) {\n // Marqueurs FORTS et propres à HomeScreen (absents de WelcomeScreen / d'un\n // simple spinner / du bandeau broker) :\n // - .app-navbar : la barre de navigation basse (BottomNav) — rendue par\n // HomeScreen, pas par l'écran d'onboarding ;\n // - le bouton « Relayer » (aria-label=\"Relayer un événement\") propre à\n // l'en-tête de l'accueil.\n // Si un throw dans HomeScreen (ou un provider monté après connexion) blanchit\n // le rendu, React démonte l'arbre (aucun ErrorBoundary) et ces marqueurs\n // disparaissent → l'attente échoue.\n const rendered = await this.appFrame!.waitForFunction(\n () => {\n const root = document.getElementById('root');\n if (!root) return false;\n const hasNavbar = document.querySelector('.app-navbar') !== null;\n const hasRelayer =\n document.querySelector('[aria-label=\"Relayer un événement\"]') !== null;\n return hasNavbar && hasRelayer;\n },\n undefined,\n { timeout: 15000 },\n ).then(() => true).catch(() => false);\n\n if (!rendered) {\n const debug = await this.appFrame!.evaluate(() => ({\n pathname: window.location.pathname,\n hasNavbar: document.querySelector('.app-navbar') !== null,\n hasRelayer: document.querySelector('[aria-label=\"Relayer un événement\"]') !== null,\n rootLen: document.getElementById('root')?.innerHTML.length ?? 0,\n rootText: document.getElementById('root')?.textContent?.substring(0, 400),\n }));\n expect.fail(\n `L'accueil connecté n'a pas rendu de contenu d'app réel (page blanche ?). ` +\n `path=\"${debug.pathname}\", .app-navbar=${debug.hasNavbar}, ` +\n `bouton Relayer=${debug.hasRelayer}, #root length=${debug.rootLen}, ` +\n `texte: \"${debug.rootText}\"`,\n );\n }\n});",
"lineNumber": 26
},
{
@@ -743,7 +743,7 @@ export const stepDefinitions: StepDefinitionInfo[] = [
"keyword": "Then",
"file": "accueil-connecte-rend.steps.ts",
"sourceCode": "Then('aucune erreur runtime n\\'a été émise pendant le boot connecté', function (this: FestipodWorld) {\n // this.pageErrors est peuplé par le hook Before (pageerror + console.error de\n // la page app), réinitialisé à chaque scénario. Un crash de rendu connecté\n // (throw non attrapé dans un composant/provider) émet un `pageerror` et\n // atterrit ici → assertion rouge avec la liste exacte.\n expect(\n this.pageErrors,\n `Des erreurs runtime ont été émises pendant le boot connecté :\\n` +\n this.pageErrors.map((e, i) => ` [${i + 1}] ${e}`).join('\\n'),\n ).to.be.empty;\n});",
"lineNumber": 65
"lineNumber": 66
},
{
"pattern": "je peux configurer mes notifications",
@@ -840,7 +840,7 @@ export const stepDefinitions: StepDefinitionInfo[] = [
"pattern": "le navigateur {string} est connecté à NextGraph",
"keyword": "Then",
"file": "multibrowser.steps.ts",
"sourceCode": "Then('le navigateur {string} est connecté à NextGraph', async function (this: FestipodWorld, name: string) {\n const handle = this.browser(name);\n expect(handle.appFrame, `le navigateur ${name} doit avoir chargé l'app`).to.not.equal(null);\n // __testData.ready flips true only once the NG session is connected.\n await handle.appFrame!.waitForFunction(\n () => (window as any).__testData?.ready === true,\n { timeout: 30000 },\n );\n});",
"sourceCode": "Then('le navigateur {string} est connecté à NextGraph', async function (this: FestipodWorld, name: string) {\n const handle = this.browser(name);\n expect(handle.appFrame, `le navigateur ${name} doit avoir chargé l'app`).to.not.equal(null);\n // __testData.ready flips true only once the NG session is connected.\n await handle.appFrame!.waitForFunction(\n () => (window as any).__testData?.ready === true,\n undefined,\n { timeout: 30000 },\n );\n});",
"lineNumber": 24
},
{
+4 -4
View File
@@ -17,8 +17,10 @@ export interface FpEventData {
distance?: number;
participantCount: number;
coverImage?: string;
hostName?: string;
hostInitials?: string;
// NO HOST. An event is only the anchor — a public event someone referenced so
// meeting points can be grafted onto it. Its declarer is not a host, is not
// required to attend, and is not named on it. The host lives one level down, on
// the meeting point (`FpMeetingPointData.hostId` / SHEX `fp:MeetingPoint.host`).
themes?: string[];
}
@@ -54,8 +56,6 @@ export interface FpMeetingPointData {
place?: string;
location: string;
time: string;
hostName: string;
hostInitials: string;
/** NURI of the meeting point's inbox (wired in T02.b/c). */
inbox?: string;
}
+193 -40
View File
@@ -5,7 +5,7 @@ import type { Schema } from "@ng-org/shex-orm";
* festipodShapesSchema: Schema for festipodShapes
* =============================================================================
*/
export const festipodShapesSchema: Schema = {
export const festipodShapesSchema = {
"http://festipod.org/Event": {
iri: "http://festipod.org/Event",
predicates: [
@@ -22,67 +22,136 @@ export const festipodShapesSchema: Schema = {
readablePredicate: "@type",
},
{
dataTypes: [{ valType: "string" }],
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 1,
iri: "http://festipod.org/title",
readablePredicate: "title",
},
{
dataTypes: [{ valType: "string" }],
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 0,
iri: "http://festipod.org/description",
readablePredicate: "description",
},
{
dataTypes: [{ valType: "string" }],
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 1,
iri: "http://festipod.org/date",
readablePredicate: "date",
},
{
dataTypes: [{ valType: "string" }],
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 0,
iri: "http://festipod.org/startDate",
readablePredicate: "startDate",
},
{
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 0,
iri: "http://festipod.org/endDate",
readablePredicate: "endDate",
},
{
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 0,
iri: "http://festipod.org/startTime",
readablePredicate: "startTime",
},
{
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 0,
iri: "http://festipod.org/endTime",
readablePredicate: "endTime",
},
{
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 1,
iri: "http://festipod.org/location",
readablePredicate: "location",
},
{
dataTypes: [{ valType: "number" }],
dataTypes: [
{
valType: "number",
},
],
maxCardinality: 1,
minCardinality: 0,
iri: "http://festipod.org/distance",
readablePredicate: "distance",
},
{
dataTypes: [{ valType: "number" }],
dataTypes: [
{
valType: "number",
},
],
maxCardinality: 1,
minCardinality: 1,
iri: "http://festipod.org/participantCount",
readablePredicate: "participantCount",
},
{
dataTypes: [{ valType: "string" }],
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 0,
iri: "http://festipod.org/coverImage",
readablePredicate: "coverImage",
},
{
dataTypes: [{ valType: "string" }],
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 0,
iri: "http://festipod.org/hostName",
readablePredicate: "hostName",
},
{
dataTypes: [{ valType: "string" }],
maxCardinality: 1,
minCardinality: 0,
iri: "http://festipod.org/hostInitials",
readablePredicate: "hostInitials",
iri: "http://festipod.org/inbox",
readablePredicate: "inbox",
},
],
},
@@ -102,35 +171,55 @@ export const festipodShapesSchema: Schema = {
readablePredicate: "@type",
},
{
dataTypes: [{ valType: "string" }],
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 1,
iri: "http://festipod.org/name",
readablePredicate: "name",
},
{
dataTypes: [{ valType: "string" }],
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 1,
iri: "http://festipod.org/initials",
readablePredicate: "initials",
},
{
dataTypes: [{ valType: "string" }],
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 1,
iri: "http://festipod.org/username",
readablePredicate: "username",
},
{
dataTypes: [{ valType: "string" }],
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 0,
iri: "http://festipod.org/role",
readablePredicate: "role",
},
{
dataTypes: [{ valType: "boolean" }],
dataTypes: [
{
valType: "boolean",
},
],
maxCardinality: 1,
minCardinality: 0,
iri: "http://festipod.org/isPublic",
@@ -154,21 +243,33 @@ export const festipodShapesSchema: Schema = {
readablePredicate: "@type",
},
{
dataTypes: [{ valType: "iri" }],
dataTypes: [
{
valType: "iri",
},
],
maxCardinality: 1,
minCardinality: 1,
iri: "http://festipod.org/event",
readablePredicate: "event",
},
{
dataTypes: [{ valType: "iri" }],
dataTypes: [
{
valType: "iri",
},
],
maxCardinality: 1,
minCardinality: 1,
iri: "http://festipod.org/user",
readablePredicate: "user",
},
{
dataTypes: [{ valType: "boolean" }],
dataTypes: [
{
valType: "boolean",
},
],
maxCardinality: 1,
minCardinality: 1,
iri: "http://festipod.org/isConfirmed",
@@ -192,49 +293,77 @@ export const festipodShapesSchema: Schema = {
readablePredicate: "@type",
},
{
dataTypes: [{ valType: "iri" }],
dataTypes: [
{
valType: "iri",
},
],
maxCardinality: 1,
minCardinality: 1,
iri: "http://festipod.org/event",
readablePredicate: "event",
},
{
dataTypes: [{ valType: "iri" }],
dataTypes: [
{
valType: "iri",
},
],
maxCardinality: 1,
minCardinality: 1,
iri: "http://festipod.org/host",
readablePredicate: "host",
},
{
dataTypes: [{ valType: "string" }],
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 1,
iri: "http://festipod.org/title",
readablePredicate: "title",
},
{
dataTypes: [{ valType: "string" }],
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 0,
iri: "http://festipod.org/description",
readablePredicate: "description",
},
{
dataTypes: [{ valType: "string" }],
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 0,
iri: "http://festipod.org/place",
readablePredicate: "place",
},
{
dataTypes: [{ valType: "string" }],
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 0,
iri: "http://festipod.org/time",
readablePredicate: "time",
},
{
dataTypes: [{ valType: "string" }],
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 0,
iri: "http://festipod.org/inbox",
@@ -258,42 +387,66 @@ export const festipodShapesSchema: Schema = {
readablePredicate: "@type",
},
{
dataTypes: [{ valType: "iri" }],
dataTypes: [
{
valType: "iri",
},
],
maxCardinality: 1,
minCardinality: 1,
iri: "http://festipod.org/recipient",
readablePredicate: "recipient",
},
{
dataTypes: [{ valType: "string" }],
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 1,
iri: "http://festipod.org/type",
readablePredicate: "type",
},
{
dataTypes: [{ valType: "iri" }],
dataTypes: [
{
valType: "iri",
},
],
maxCardinality: 1,
minCardinality: 0,
iri: "http://festipod.org/ref",
readablePredicate: "ref",
},
{
dataTypes: [{ valType: "string" }],
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 0,
iri: "http://festipod.org/payload",
readablePredicate: "payload",
},
{
dataTypes: [{ valType: "string" }],
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 1,
iri: "http://festipod.org/timestamp",
readablePredicate: "timestamp",
},
{
dataTypes: [{ valType: "boolean" }],
dataTypes: [
{
valType: "boolean",
},
],
maxCardinality: 1,
minCardinality: 1,
iri: "http://festipod.org/isRead",
@@ -301,4 +454,4 @@ export const festipodShapesSchema: Schema = {
},
],
},
};
} as const satisfies Schema;
@@ -1,31 +1,35 @@
import type { ShapeType } from "@ng-org/shex-orm";
import { festipodShapesSchema } from "./festipodShapes.schema";
import { festipodShapesSchema } from "./festipodShapes.schema.ts";
import type {
FpEvent,
FpUserProfile,
FpParticipation,
FpMeetingPoint,
FpNotification,
} from "./festipodShapes.typings";
Event,
UserProfile,
Participation,
MeetingPoint,
Notification,
} from "./festipodShapes.typings.ts";
// ShapeTypes for festipodShapes
export const FpEventShapeType: ShapeType<FpEvent> = {
export const EventShapeType = {
schema: festipodShapesSchema,
shape: "http://festipod.org/Event",
};
export const FpUserProfileShapeType: ShapeType<FpUserProfile> = {
} as const satisfies ShapeType<Event>;
export const UserProfileShapeType = {
schema: festipodShapesSchema,
shape: "http://festipod.org/UserProfile",
};
export const FpParticipationShapeType: ShapeType<FpParticipation> = {
} as const satisfies ShapeType<UserProfile>;
export const ParticipationShapeType = {
schema: festipodShapesSchema,
shape: "http://festipod.org/Participation",
};
export const FpMeetingPointShapeType: ShapeType<FpMeetingPoint> = {
} as const satisfies ShapeType<Participation>;
export const MeetingPointShapeType = {
schema: festipodShapesSchema,
shape: "http://festipod.org/MeetingPoint",
};
export const FpNotificationShapeType: ShapeType<FpNotification> = {
} as const satisfies ShapeType<MeetingPoint>;
export const NotificationShapeType = {
schema: festipodShapesSchema,
shape: "http://festipod.org/Notification",
};
} as const satisfies ShapeType<Notification>;
+37 -19
View File
@@ -9,9 +9,9 @@ export type IRI = string;
/**
* Event Type
*/
export interface FpEvent {
export interface Event {
/**
* The graph IRI.
* The graph NURI.
*/
readonly "@graph": IRI;
/**
@@ -40,6 +40,30 @@ export interface FpEvent {
* Original IRI: http://festipod.org/date
*/
date: string;
/**
* The day the event starts, as the ISO calendar date the form collects (YYYY-MM-DD) stored unaltered, never derived from fp:date
*
* Original IRI: http://festipod.org/startDate
*/
startDate?: string;
/**
* The day the event ends, as an ISO calendar date (YYYY-MM-DD); absent for a single-day event
*
* Original IRI: http://festipod.org/endDate
*/
endDate?: string;
/**
* The time of day the event starts, as the form collects it (HH:MM)
*
* Original IRI: http://festipod.org/startTime
*/
startTime?: string;
/**
* The time of day the event ends (HH:MM)
*
* Original IRI: http://festipod.org/endTime
*/
endTime?: string;
/**
* The location of the event
*
@@ -65,25 +89,19 @@ export interface FpEvent {
*/
coverImage?: string;
/**
* Name of the event host or relay
* NURI of this event's own inbox, published so that anyone holding the event can deposit into it (an inbox belongs to someone and its address must be given, never derived)
*
* Original IRI: http://festipod.org/hostName
* Original IRI: http://festipod.org/inbox
*/
hostName?: string;
/**
* Initials of the event host
*
* Original IRI: http://festipod.org/hostInitials
*/
hostInitials?: string;
inbox?: string;
}
/**
* UserProfile Type
*/
export interface FpUserProfile {
export interface UserProfile {
/**
* The graph IRI.
* The graph NURI.
*/
readonly "@graph": IRI;
/**
@@ -129,9 +147,9 @@ export interface FpUserProfile {
/**
* Participation Type
*/
export interface FpParticipation {
export interface Participation {
/**
* The graph IRI.
* The graph NURI.
*/
readonly "@graph": IRI;
/**
@@ -165,9 +183,9 @@ export interface FpParticipation {
/**
* MeetingPoint Type
*/
export interface FpMeetingPoint {
export interface MeetingPoint {
/**
* The graph IRI.
* The graph NURI.
*/
readonly "@graph": IRI;
/**
@@ -225,9 +243,9 @@ export interface FpMeetingPoint {
/**
* Notification Type
*/
export interface FpNotification {
export interface Notification {
/**
* The graph IRI.
* The graph NURI.
*/
readonly "@graph": IRI;
/**
+8 -4
View File
@@ -10,6 +10,14 @@ fp:Event {
// rdfs:comment "A description of the event" ;
fp:date xsd:string
// rdfs:comment "The display date of the event (e.g. 'Lun. 16 - Ven. 20 fév.')" ;
fp:startDate xsd:string ?
// rdfs:comment "The day the event starts, as the ISO calendar date the form collects (YYYY-MM-DD) — stored unaltered, never derived from fp:date" ;
fp:endDate xsd:string ?
// rdfs:comment "The day the event ends, as an ISO calendar date (YYYY-MM-DD); absent for a single-day event" ;
fp:startTime xsd:string ?
// rdfs:comment "The time of day the event starts, as the form collects it (HH:MM)" ;
fp:endTime xsd:string ?
// rdfs:comment "The time of day the event ends (HH:MM)" ;
fp:location xsd:string
// rdfs:comment "The location of the event" ;
fp:distance xsd:float ?
@@ -18,10 +26,6 @@ fp:Event {
// rdfs:comment "Number of participants" ;
fp:coverImage xsd:string ?
// rdfs:comment "URL of the cover image" ;
fp:hostName xsd:string ?
// rdfs:comment "Name of the event host or relay" ;
fp:hostInitials xsd:string ?
// rdfs:comment "Initials of the event host" ;
fp:inbox xsd:string ?
// rdfs:comment "NURI of this event's own inbox, published so that anyone holding the event can deposit into it (an inbox belongs to someone and its address must be given, never derived)" ;
}
+2
View File
@@ -608,6 +608,7 @@ Before({ timeout: 60000 }, async function (this: FestipodWorld, scenario) {
this.appFrame = this.page!.mainFrame();
await this.appFrame.waitForFunction(
() => (window as any).__testData?.ready === true,
undefined,
{ timeout: 10000 },
);
}
@@ -627,6 +628,7 @@ Before({ timeout: 60000 }, async function (this: FestipodWorld, scenario) {
const root = document.getElementById('root');
return root && root.innerHTML.length > 100;
},
undefined,
{ timeout: 30000 },
);
+10 -4
View File
@@ -21,12 +21,18 @@ import { listMyEntityDocs, openDocumentInbox, resolveScopeGraph } from '../utils
import { setCurrentPrincipal } from '../utils/currentPrincipal';
import { materializeAttendance, NOTIF_TYPE_NEW_PARTICIPANT } from '../data/registration';
import type { RegistrationPayload } from '../data/registration';
// Bare generated names aliased back to the `Fp*` spelling at the import — see the
// same note in `shared/context/FestipodDataContext.tsx`.
import {
FpEventShapeType,
FpUserProfileShapeType,
FpParticipationShapeType,
EventShapeType as FpEventShapeType,
UserProfileShapeType as FpUserProfileShapeType,
ParticipationShapeType as FpParticipationShapeType,
} from '../shapes/orm/festipodShapes.shapeTypes';
import type { FpEvent, FpUserProfile, FpParticipation } from '../shapes/orm/festipodShapes.typings';
import type {
Event as FpEvent,
UserProfile as FpUserProfile,
Participation as FpParticipation,
} from '../shapes/orm/festipodShapes.typings';
// ============================================================================
// App — uses real providers (same tree as the real app)
+7 -1
View File
@@ -12,7 +12,13 @@ import React, { useEffect, useRef } from 'react';
import { createRoot } from 'react-dom/client';
import { deepSignal } from '@ng-org/alien-deepsignals';
import type { DeepSignalSet } from '@ng-org/alien-deepsignals';
import type { FpEvent, FpUserProfile, FpParticipation } from '../shapes/orm/festipodShapes.typings';
// Bare generated names aliased back to the `Fp*` spelling at the import — see the
// same note in `shared/context/FestipodDataContext.tsx`.
import type {
Event as FpEvent,
UserProfile as FpUserProfile,
Participation as FpParticipation,
} from '../shapes/orm/festipodShapes.typings';
// ============================================================================
// Seed data — same events/users as the app's seedData.ts
+22 -3
View File
@@ -13,11 +13,30 @@
* `/festipod-config.json` (read by frontend.tsx, which sets the global before the
* app tree loads). Absent env global undefined seed OFF.
*
* NOTE: this gate only affects the app's AUTOMATIC seed. Explicit seeding via
* `loadTestData()` (the "Load test data" action + every @data test through the
* harness bridge) is UNAFFECTED it calls bootstrapWallet directly.
* NOTE: this gate only governs the app's AUTOMATIC seed. It is subordinate to
* `fixtureSeedEnabled()` below, which currently switches EVERY route off.
*/
/**
* MASTER SWITCH may a fixture seed be written into the connected wallet AT ALL?
*
* Currently OFF, a product decision: no fixture reaches the connected wallet by
* any route neither the opt-in auto-seed (`FESTIPOD_AUTO_SEED`, whose gate
* below is subordinate to this one) nor an explicit `loadTestData()`.
*
* OFF, not deleted. The fixtures (`data/seedData.ts`) and the seeding code stay,
* because two paths still need them and NEITHER writes to a wallet: the
* demo/disconnected provider (`LocalDataProvider`) reads the fixtures straight
* into React state, and the rendering-layer tests read them directly too. Only
* the connected wallet is closed off.
*
* Enforced at the single place fixtures enter a wallet `bootstrapWallet`
* (`utils/ngBootstrap.ts`) so no caller can bypass it by construction; the
* call sites consult it too, only so they neither log nor await work that will
* not happen.
*/
export const fixtureSeedEnabled = (): boolean => false;
// Build-injected global (not `process.env`, absent in the browser); any path that
// doesn't inject it reads `undefined` → false safely (no ReferenceError).
declare global {
+5 -4
View File
@@ -14,10 +14,11 @@
*
* WHAT IT IS NOT it is NOT the id space the app's own entities live in. A
* UserProfile's id is its document NURI, and a Participation's `fp:user` carries
* that NURI; this principal is a third space. The join between the two is
* explicit and lives in one place (`FestipodDataContext`, where the principal
* selects the current user's profile through `normalizeIdentifier(username)`).
* Never compare this value to an entity id directly.
* that NURI; this principal is a third space, and there is NO join between them.
* A profile is Festipod's own object and this identity says nothing about it:
* **my profile is the profile document I own** (`listMyEntityDocs('protected')`,
* resolved in `FestipodDataContext`). Never compare this value to an entity id,
* and never match it against a profile field to decide who the current user is.
*
* WHY A MODULE STORE and not a React context: `AuthGate` the component that
* makes the await is mounted INSIDE `FestipodDataProvider`, so a context it
+23
View File
@@ -0,0 +1,23 @@
/**
* The CANONICAL form of a document reference the one form every comparison and
* every per-document memo in the app keys on.
*
* A document reference is a `did:ng:o:<repo>[:v:<overlay>]`. The SAME document can
* legitimately be named with or without its `:v:<overlay>` suffix depending on
* which boundary handed it over (a create, a listing, a read subject). Two forms
* of one document must never become two entries anywhere: two owned events, two
* counts or, the defect this file was extracted for, two INBOXES for one
* document, one of which nobody reads.
*
* A reference with no `:v:` overlay (or one that is not a `did:ng:o:` id at all)
* passes through unchanged.
*
* `canonicalEventId` (data/registration) is this function under the name the event
* call sites use; it is re-exported there, not reimplemented.
*/
export function canonicalDocumentId(id: string): string {
// did:ng:o:<repo>:v:<overlay> → did:ng:o:<repo>. The overlay segment is the
// LAST `:v:`-introduced part; a base id (`did:ng:o:<repo>`) has no `:v:`.
const i = id.indexOf(':v:');
return i === -1 ? id : id.slice(0, i);
}
+44
View File
@@ -0,0 +1,44 @@
import { expect, test } from 'bun:test';
import { bootstrapWallet } from './ngBootstrap';
// THE SEED IS OFF, AND THIS IS WHAT HOLDS IT OFF.
//
// `bootstrapWallet` is the one place fixtures enter a wallet — the opt-in
// auto-seed and an explicit `loadTestData()` both funnel through it — so proving
// it writes nothing proves no route writes. The proof is not "it returned
// seeded: false" (a seed that half-ran could say that too) but that it never
// asked for a document at all: the `createEntityDoc` handed in below FAILS the
// test if it is called even once.
test('the fixture seed is OFF — bootstrapWallet creates no document, on an empty wallet', async () => {
let createCalls = 0;
const createEntityDoc = async () => {
createCalls++;
throw new Error('createEntityDoc must not be called while the fixture seed is disabled');
};
// `walletHasData: false` is the ONE input that used to make the seed run. Even
// there — a genuinely empty wallet, the case the seed existed for — nothing is
// created.
const result = await bootstrapWallet(false, createEntityDoc);
expect(createCalls).toBe(0);
expect(result.seeded).toBe(false);
expect(result.createdDocs.public).toEqual([]);
expect(result.createdDocs.protected).toEqual([]);
expect(result.userIdMap.size).toBe(0);
expect(result.eventIdMap.size).toBe(0);
});
test('the fixture seed is OFF — bootstrapWallet writes nothing on a populated wallet either', async () => {
let createCalls = 0;
const createEntityDoc = async () => {
createCalls++;
throw new Error('createEntityDoc must not be called while the fixture seed is disabled');
};
const result = await bootstrapWallet(true, createEntityDoc);
expect(createCalls).toBe(0);
expect(result.seeded).toBe(false);
});
+11 -1
View File
@@ -18,6 +18,7 @@ import {
seedUsers,
} from '../data/seedData';
import { writeEntity, ENTITY_TYPE, str, int, flt, bool } from '../data/entityWrites';
import { fixtureSeedEnabled } from './autoSeed';
/** Scope of a seed entity + how to create its own document (SDK create). */
export type Scope = 'public' | 'protected' | 'private';
@@ -46,6 +47,15 @@ export async function bootstrapWallet(
createEntityDoc: CreateEntityDoc,
): Promise<BootstrapResult> {
const createdDocs: { public: Nuri[]; protected: Nuri[] } = { public: [], protected: [] };
// THE SEED IS OFF (see `fixtureSeedEnabled`). This is the one place fixtures
// enter a wallet, so the switch is enforced HERE rather than at each caller:
// every route — the opt-in auto-seed, an explicit `loadTestData()` — funnels
// through this function, and none of them can write around it. Callers get the
// ordinary "nothing was seeded" answer, which is exactly true.
if (!fixtureSeedEnabled()) {
console.log('[Bootstrap] Fixture seed is disabled — nothing written to the wallet');
return { seeded: false, userIdMap: new Map(), eventIdMap: new Map(), createdDocs };
}
// Already has data → returning user, nothing to seed
if (walletHasData) {
console.log('[Bootstrap] Wallet already has data — skipping seed');
@@ -110,7 +120,7 @@ export async function bootstrapWallet(
title: str(e.title), description: str(e.description), date: str(e.date),
location: str(e.location), distance: flt(e.distance),
participantCount: int(e.participantCount),
coverImage: str(e.coverImage), hostName: str(e.hostName), hostInitials: str(e.hostInitials),
coverImage: str(e.coverImage),
});
eventIdMap.set(e.id, id);
// The seeded event is NOT announced anywhere: there is no discovery index to
+106
View File
@@ -0,0 +1,106 @@
import { expect, test } from 'bun:test';
import { resolveOncePerKey } from './resolveOnce';
/** A resolution the test controls: it settles when the test says so. */
function controllable() {
let calls = 0;
const gates: Array<{ resolve(v: string): void; reject(e: unknown): void }> = [];
const resolve = (arg: string) => {
calls++;
return new Promise<string>((res, rej) => {
gates.push({ resolve: res, reject: rej });
}).then(v => `${v}:${arg}`);
};
return { get calls() { return calls; }, gates, resolve };
}
test('simultaneous callers for one key start ONE resolution and share its value', async () => {
const c = controllable();
const resolveOnce = resolveOncePerKey<string, string>(k => k, c.resolve);
const a = resolveOnce('doc');
const b = resolveOnce('doc');
const d = resolveOnce('doc');
expect(c.calls).toBe(1); // the second and third joined the one in flight
c.gates[0]!.resolve('inbox-1');
expect(await a).toBe('inbox-1:doc');
expect(await b).toBe('inbox-1:doc');
expect(await d).toBe('inbox-1:doc');
expect(c.calls).toBe(1);
});
test('the answer is kept — a later caller never starts a second resolution', async () => {
const c = controllable();
const resolveOnce = resolveOncePerKey<string, string>(k => k, c.resolve);
const first = resolveOnce('doc');
c.gates[0]!.resolve('inbox-1');
await first;
expect(await resolveOnce('doc')).toBe('inbox-1:doc');
expect(c.calls).toBe(1);
});
test('distinct keys resolve independently', async () => {
const c = controllable();
const resolveOnce = resolveOncePerKey<string, string>(k => k, c.resolve);
const a = resolveOnce('doc-a');
const b = resolveOnce('doc-b');
expect(c.calls).toBe(2);
c.gates[0]!.resolve('inbox-a');
c.gates[1]!.resolve('inbox-b');
expect(await a).toBe('inbox-a:doc-a');
expect(await b).toBe('inbox-b:doc-b');
});
test('two spellings of one key are ONE resolution (the overlay case)', async () => {
const c = controllable();
const resolveOnce = resolveOncePerKey<string, string>(
arg => arg.split(':v:')[0]!,
c.resolve,
);
const bare = resolveOnce('did:ng:o:repo');
const overlaid = resolveOnce('did:ng:o:repo:v:overlay');
expect(c.calls).toBe(1);
c.gates[0]!.resolve('inbox-1');
expect(await bare).toBe('inbox-1:did:ng:o:repo');
expect(await overlaid).toBe('inbox-1:did:ng:o:repo');
});
test('a rejection reaches every waiting caller and is NOT memoized', async () => {
const c = controllable();
const resolveOnce = resolveOncePerKey<string, string>(k => k, c.resolve);
const a = resolveOnce('doc');
const b = resolveOnce('doc');
c.gates[0]!.reject(new Error('unknown'));
await expect(a).rejects.toThrow('unknown');
await expect(b).rejects.toThrow('unknown');
// UNKNOWN is not "there is none": the next caller really retries.
const retry = resolveOnce('doc');
expect(c.calls).toBe(2);
c.gates[1]!.resolve('inbox-1');
expect(await retry).toBe('inbox-1:doc');
});
test('the hooks report a join and the one resolution', async () => {
const c = controllable();
const joined: string[] = [];
const resolved: Array<[string, string]> = [];
const resolveOnce = resolveOncePerKey<string, string>(k => k, c.resolve, {
onJoined: key => joined.push(key),
onResolved: (key, value) => resolved.push([key, value]),
});
const a = resolveOnce('doc');
resolveOnce('doc');
c.gates[0]!.resolve('inbox-1');
await a;
expect(joined).toEqual(['doc']);
expect(resolved).toEqual([['doc', 'inbox-1:doc']]);
});
+63
View File
@@ -0,0 +1,63 @@
/**
* ONE resolution per key, whatever the concurrency a single-flight memo.
*
* WHY THIS EXISTS. Some resolutions are not idempotent from the outside: asking
* twice does not hand back the same thing twice, it CREATES a second thing. A
* document's inbox is exactly that "open the inbox of this document" answers
* with an address, and two callers racing each other end up with two addresses
* for one document, so the side that watches one never sees what was deposited
* in the other. Nothing about the calling code looks wrong: four independent
* call sites, each perfectly reasonable on its own, all firing within the same
* few hundred milliseconds.
*
* So the guarantee is not "we call it less often" (a cache) but "the application
* can never be the reason a second one exists": while a resolution is in flight,
* every other caller for the same key AWAITS THAT SAME PROMISE instead of
* starting its own, and once it has settled they all read the one value.
*
* A REJECTION IS NOT MEMOIZED. It means UNKNOWN, never "there is none": every
* caller waiting on it sees the failure, and the key is released so a later
* caller genuinely retries rather than inheriting a permanent "no".
*
* The memo lives as long as the returned function does. For a per-session
* resolution (a browser context is one identity for its whole life) that is the
* intended lifetime: keep the function at module scope and the answer is settled
* once for the session.
*/
export interface ResolveOnceHooks<Value> {
/** A caller joined a resolution already in flight — nothing new was started. */
onJoined?(key: string): void;
/** A resolution completed and became the key's one answer. */
onResolved?(key: string, value: Value): void;
}
export function resolveOncePerKey<Arg, Value>(
keyOf: (arg: Arg) => string,
resolve: (arg: Arg) => Promise<Value>,
hooks: ResolveOnceHooks<Value> = {},
): (arg: Arg) => Promise<Value> {
const byKey = new Map<string, Promise<Value>>();
return (arg: Arg): Promise<Value> => {
const key = keyOf(arg);
const known = byKey.get(key);
if (known) {
hooks.onJoined?.(key);
return known;
}
const resolving = resolve(arg).then(
value => {
hooks.onResolved?.(key, value);
return value;
},
err => {
// UNKNOWN, not "none" — release the key so a later caller can retry.
byKey.delete(key);
throw err;
},
);
byKey.set(key, resolving);
return resolving;
};
}
+110
View File
@@ -0,0 +1,110 @@
import { expect, test } from 'bun:test';
import { createSerialTask } from './serialTask';
/** A run the test releases by hand, recording overlap as it goes. */
function controllable() {
const started: string[] = [];
const releases: Array<() => void> = [];
const rejects: Array<(err: unknown) => void> = [];
let inFlight = 0;
let maxInFlight = 0;
const run = async (reason: string) => {
started.push(reason);
inFlight++;
maxInFlight = Math.max(maxInFlight, inFlight);
try {
await new Promise<void>((res, rej) => {
releases.push(res);
rejects.push(rej);
});
} finally {
inFlight--;
}
};
return { started, releases, rejects, run, get maxInFlight() { return maxInFlight; } };
}
const tick = () => new Promise<void>(r => setTimeout(r, 0));
test('a request made while a run is in flight does not start a second run', async () => {
const c = controllable();
const task = createSerialTask(c.run);
void task('connection').catch(() => {});
void task('inbox-push').catch(() => {});
await tick();
expect(c.started).toEqual(['connection']);
expect(c.maxInFlight).toBe(1);
});
test('the follow-up runs once the first has finished, and only once for N requests', async () => {
const c = controllable();
const task = createSerialTask(c.run);
void task('connection').catch(() => {});
void task('inbox-push').catch(() => {});
void task('inbox-push').catch(() => {});
void task('inbox-push').catch(() => {});
c.releases[0]!(); // first run completes
await tick();
expect(c.started).toEqual(['connection', 'inbox-push']); // ONE follow-up
expect(c.maxInFlight).toBe(1);
c.releases[1]!();
await tick();
expect(c.started).toEqual(['connection', 'inbox-push']);
});
test('every request coalesced into one follow-up settles when that run does', async () => {
const c = controllable();
const task = createSerialTask(c.run);
const first = task('connection');
const joinA = task('inbox-push');
const joinB = task('inbox-push');
c.releases[0]!();
await first;
c.releases[1]!();
await joinA;
await joinB; // same run — both are served
expect(c.started.length).toBe(2);
});
test('requests made when idle each get their own run, in order', async () => {
const c = controllable();
const task = createSerialTask(c.run);
const a = task('connection');
c.releases[0]!();
await a;
const b = task('inbox-push');
c.releases[1]!();
await b;
expect(c.started).toEqual(['connection', 'inbox-push']);
expect(c.maxInFlight).toBe(1);
});
test('a failed run rejects its requesters and does not wedge the task', async () => {
const c = controllable();
const task = createSerialTask(c.run);
const failing = task('connection');
const queued = task('inbox-push');
c.rejects[0]!(new Error('materialize failed'));
await expect(failing).rejects.toThrow('materialize failed');
// The follow-up still ran, and a later request is still served.
await tick();
expect(c.started).toEqual(['connection', 'inbox-push']);
c.releases[1]!();
await queued;
const later = task('connection');
c.releases[2]!();
await later;
expect(c.started).toEqual(['connection', 'inbox-push', 'connection']);
expect(c.maxInFlight).toBe(1);
});
+75
View File
@@ -0,0 +1,75 @@
/**
* A task that NEVER runs concurrently with itself.
*
* WHY THIS EXISTS. A read-derive-write cycle (read an inbox, derive a value,
* write it) is only correct if nothing else is doing the same thing at the same
* time on the same target: two cycles started a few milliseconds apart both read
* before either writes, and the one that finishes last puts ITS (older) reading
* back on the document. The owner's participation materializer had two
* independent triggers the connection and the inbox push and nothing between
* them.
*
* COALESCING. A request made while a run is in flight does not queue behind an
* unbounded chain: at most ONE follow-up run is scheduled, and every request
* made during the current run shares it. That is sound precisely because a run
* re-derives everything from the current state one follow-up observes whatever
* the N requests were about. What a run must NOT be is incremental (a `+1`); the
* caller keeps that property, this primitive assumes it.
*
* WHAT A CALLER GETS BACK. The promise of the run that will serve its request
* the running one when it started idle, the coalesced follow-up otherwise. It
* settles with that run's outcome, so a failure is never swallowed here; a
* caller that does not await must attach its own rejection handler.
*/
interface Settle {
promise: Promise<void>;
resolve(): void;
reject(err: unknown): void;
}
function settleLater(): Settle {
let resolve!: () => void;
let reject!: (err: unknown) => void;
const promise = new Promise<void>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
export function createSerialTask<Reason>(
run: (reason: Reason) => Promise<void>,
): (reason: Reason) => Promise<void> {
let busy = false;
let queued: { reason: Reason; settle: Settle } | null = null;
const pump = async (reason: Reason, settle: Settle): Promise<void> => {
busy = true;
try {
await run(reason);
settle.resolve();
} catch (err) {
settle.reject(err);
} finally {
busy = false;
const next = queued;
queued = null;
// A failed run must not wedge the task: the follow-up starts either way.
if (next) void pump(next.reason, next.settle);
}
};
return (reason: Reason): Promise<void> => {
if (!busy) {
const settle = settleLater();
void pump(reason, settle);
return settle.promise;
}
// Already running: one follow-up is enough for every request made meanwhile.
// The FIRST such request names it — the ones that join it are, by definition,
// asking for the same thing.
if (!queued) queued = { reason, settle: settleLater() };
return queued.settle.promise;
};
}
+37 -4
View File
@@ -11,6 +11,9 @@
// a fact of the module graph rather than a convention.
import './ngSession';
import { storeRegistry as sdkStoreRegistry } from '@ng-eventually/polyfill';
import type { Nuri, NuriLike } from '@ng-eventually/polyfill';
import { canonicalDocumentId } from './documentNuri';
import { resolveOncePerKey } from './resolveOnce';
export type Scope = 'public' | 'protected' | 'private';
@@ -42,12 +45,42 @@ export const {
// SDK-shaped scope resolvers — the app asks by scope, the SDK resolves
// placement (no store id ever crosses the boundary).
resolveScopeGraph,
// A document only HAS an inbox if its owner opened one. The app opens one on
// the documents meant to RECEIVE deposits (its events), and the address this
// returns is what the owner reads and watches.
openDocumentInbox,
// Per-entity document creation. The SDK itself files the creator's key on
// create, so the app declares NO access policy here: reading is possession,
// and the creator holds what it created.
createEntityDoc,
} = sdkStoreRegistry;
// --- A document's inbox: opened ONCE, by this session, whatever the concurrency -
// A document only HAS an inbox if its owner opened one. The app opens one on the
// documents meant to RECEIVE deposits (its events), and the address this returns
// is what the owner reads and watches.
//
// ASKING TWICE IS NOT FREE. "Open the inbox of this document" answers with an
// address; two callers racing each other get two, and then the owner watches one
// while a deposit lands in the other — the sign-up is never seen. Nothing looked
// wrong at any single call site: creating an event opens its inbox, the
// materializer opens it to read, the watch opens it to subscribe, and the watch
// callback re-enters the materializer — four calls within a fraction of a second,
// none of them aware of the others.
//
// So the app resolves it exactly ONCE PER DOCUMENT for the whole session, and
// simultaneous callers AWAIT THAT SAME RESOLUTION instead of starting their own
// (`resolveOncePerKey`). This wrapper is the ONLY place the SDK call is made — the
// raw entry is not re-exported, so no call site can bypass it. The key is the
// document's CANONICAL form, so the same document named with and without its
// overlay suffix is one document here too.
//
// A session is one identity for its whole life, so a session-long memo can never
// hand one person another's address. A REJECTION is not memoized: it means the
// answer is UNKNOWN, so the next caller genuinely retries.
export const openDocumentInbox: (doc: NuriLike) => Promise<Nuri> = resolveOncePerKey(
(doc: NuriLike) => canonicalDocumentId(doc),
(doc: NuriLike) => sdkStoreRegistry.openDocumentInbox(doc),
{
onResolved: (doc, address) =>
console.log(`[app][inbox] doc=${doc} → inbox=${address} (this session's only one)`),
onJoined: doc =>
console.log(`[app][inbox] doc=${doc} — joined the resolution already in flight (no second inbox)`),
},
);