Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c7cda38235 | |||
| 7124750874 | |||
| 319e7082cc | |||
| 279faa4541 | |||
| 9e4374b678 | |||
| 32c2302c91 | |||
| 61cbe6905d | |||
| 95479ebe77 | |||
| c3d64555d9 | |||
| ac29735d20 | |||
| e780c5246c | |||
| 0d925c7cb9 | |||
| ff26f26e60 | |||
| ac55dc96a4 |
+12
-3
@@ -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=
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
type: caveat
|
||||
summary: Two values a screen must not read as data — currentUserId is EMPTY until my profile document resolves, and an ownership answer can be UNKNOWN; both look like ordinary values, neither means "no"
|
||||
last_checked: 2026-08-16
|
||||
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: "not answered yet" looks exactly like an answer
|
||||
@@ -28,3 +28,9 @@ Rendering it as "not yours" silently denies an owner their own event. Rendering
|
||||
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.
|
||||
|
||||
@@ -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.) |
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ A **probe** is a one-off Playwright script, outside Cucumber — no World, no ho
|
||||
|
||||
## 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 — two still open ([[bug_signup-breaks-the-next-connection]], [[bug_participant-count-stays-at-zero]] in `data-layer`) and one shipped as a fix.
|
||||
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]]).
|
||||
|
||||
|
||||
@@ -12,9 +12,14 @@ 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; the generated ORM names carry no `Fp` prefix and are aliased at the import sites
|
||||
@@ -27,14 +32,9 @@ How Festipod **persists its data** through NextGraph (P2P, local-first, end-to-e
|
||||
- [[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
|
||||
|
||||
## Open defects — the sign-up flow does not deliver
|
||||
|
||||
- [[bug_signup-breaks-the-next-connection]] — after a sign-up, the next connection fails; a **provider-side gap**, raised with the provider, nothing to work around here
|
||||
- [[bug_participant-count-stays-at-zero]] — the count never moves in the session that signs up; whether it converges later is **unknown**, and unmeasurable while the bug above holds
|
||||
|
||||
## 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,31 +0,0 @@
|
||||
---
|
||||
type: bug
|
||||
severity: major
|
||||
opened: 2026-08-16
|
||||
last_checked: 2026-08-16
|
||||
summary: After a sign-up the count stays at 0 for the rest of the session while the button reads « ✓ Je participe » — observed with the event's owner (the counter's only writer) present and connected. Whether it converges at the next connection is UNKNOWN, not known-good.
|
||||
---
|
||||
|
||||
# The participant count does not converge in the same session
|
||||
|
||||
## What happens, VERIFIED
|
||||
|
||||
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** while the button reads « ✓ Je participe ». **VERIFIED 2 runs out of 2**, the count still 0 **120 s** and **75 s** after the sign-up.
|
||||
|
||||
The count starting at 0 on creation is correct and is not the defect ([[knowledge_context-internals]] §participantCount: no host baseline). The defect is that it never moves afterwards.
|
||||
|
||||
**The "owner offline" explanation does not apply.** In this flow the signer **is** the event's owner, so the counter's only writer is present, connected, and watching the inbox it deposited into. Eventual delivery to an absent owner explains nothing here.
|
||||
|
||||
## What is NOT established
|
||||
|
||||
**Whether the count converges at the next connection is UNKNOWN** — it could not be measured, because [[bug_signup-breaks-the-next-connection]] makes the next connection fail. Do not write it down as converging, and do not treat "it will settle on reload" as a known behaviour: nobody has seen a reload.
|
||||
|
||||
**Which side is at fault is also open** — never written, or written and not re-read. The pair of measurement points laid down for exactly this question (the owner's materializer logging `participantCount` before → after its write, and the display read logging the value as exposed to the render — [[knowledge_context-internals]] §logging) is where a diagnosis starts; the probe read the value as displayed and did not settle the pair.
|
||||
|
||||
## 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.
|
||||
|
||||
Method: `bdd-testing` → [[cookbook_live-probe]]. Watching *after* the confirmation, over a real interval, is what makes this visible at all — every individual step reports success.
|
||||
@@ -1,43 +0,0 @@
|
||||
---
|
||||
type: bug
|
||||
severity: major
|
||||
opened: 2026-08-16
|
||||
last_checked: 2026-08-16
|
||||
summary: After a sign-up, the NEXT connection fails — ensureIdentity() rejects inside the data layer's own inbox processing and AuthGate renders « Connexion impossible ». A provider-side gap, raised with the provider; the app names no document to any call and has nothing to fix here.
|
||||
---
|
||||
|
||||
# Signing up locks the account out of its next connection
|
||||
|
||||
## What happens, VERIFIED
|
||||
|
||||
Drive the create-and-participate flow (declare an event, sign up to it), then reconnect. `ensureIdentity()` **rejects**, and the app renders its named error panel, « Connexion impossible ». The rejection comes from **inside the data layer's own inbox processing** — a call the app never made.
|
||||
|
||||
The message, verbatim:
|
||||
|
||||
```
|
||||
docs.sparqlQuery: refused — the connected user does not hold this document's cap.
|
||||
Naming a document does not grant access to it
|
||||
```
|
||||
|
||||
**VERIFIED 3 runs out of 3**, including one on a **brand-new origin with a brand-new identity** — so this is not accumulated state from an old wallet ([[caveat_wallet-bloat-hang]] in `bdd-testing` is a different phenomenon and does not explain it).
|
||||
|
||||
**VERIFIED**: the symptom and the sequence — a sign-up, then a failing reconnection.
|
||||
**INFERRED**: that the deposit is what causes it. The sign-up is the only thing between a connection that works and the next one that does not, but nothing observed names the failing document.
|
||||
|
||||
## Why there is nothing to work around here
|
||||
|
||||
The app deposits through the **published** `inbox.postToDocument(doc, …)` ([[contract_polyfill-surface]]) and **names no document to any call** that could refuse one — it holds no inbox address at all ([[caveat_event-fields-not-persisted]] on why the vestigial `inbox` field must stay unused). The refusal is raised by a query the data layer issues for itself while draining what it was given.
|
||||
|
||||
So this is a **provider-side gap**, raised with the provider — not an app-side problem with a clever fix ([[rule_app-uses-sdk-surface-only]] §2: a workaround is a doctrine violation even when it works). There is no app-side recovery either: a rejected `ensureIdentity()` is the contract's own instruction *not to render past it*, because a session that failed looks exactly like an account that owns nothing.
|
||||
|
||||
## Reproduce
|
||||
|
||||
1. Connect, declare an event, sign up to it (the button reaches « ✓ Je participe »).
|
||||
2. Reconnect — a new page load through the broker, same identity.
|
||||
3. The barrier resolves, then the app shows « Connexion impossible » with the message above in the console.
|
||||
|
||||
A **fresh origin and a fresh identity** is what separates this defect from accumulated wallet state; run it that way before reporting anything new about it. Method: `bdd-testing` → [[cookbook_live-probe]].
|
||||
|
||||
## Blast radius
|
||||
|
||||
Every account is one sign-up away from being locked out, and the lock-out is permanent for that identity as far as anything observed goes. It also **blocks measurement of other defects**: whether the participant count converges across connections cannot be established while this holds — [[bug_participant-count-stays-at-zero]].
|
||||
@@ -1,21 +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-08-16
|
||||
---
|
||||
|
||||
# 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: `title, description, date, location, distance, participantCount, coverImage`, plus an optional `inbox`.
|
||||
|
||||
> **No host field, and none is missing.** The shape used to carry `hostName`/`hostInitials`, and every created event was written with a fabricated value. They are gone — from the shape, the bindings, the type, the adapters, the writes and the screens — because **an event has no host**: it is only the anchor, and its declarer is not required to attend (concept `functional-domain`, [[knowledge_actors-and-concepts]]). `fp:MeetingPoint.host` stays; that one is real. Do not "restore" a host on the event.
|
||||
|
||||
> 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,7 +1,7 @@
|
||||
---
|
||||
type: knowledge
|
||||
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-16
|
||||
last_checked: 2026-08-17
|
||||
---
|
||||
|
||||
# Internals & pitfalls of `FestipodDataContext`
|
||||
@@ -70,12 +70,14 @@ The counter is **not** incremented by whoever joins: only a document's owner wri
|
||||
|
||||
- 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`.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
> ⚠️ **This section describes the design, and the design is not what a live run does.** Driven end to end with the owner present and connected — the signer *being* the owner — the count stayed at **0** for the rest of the session after a sign-up. Read the convergence properties above as intent to be re-established, not as observed behaviour: [[bug_participant-count-stays-at-zero]].
|
||||
- The counter is an **aggregate**, not the list of named participants — `getEventParticipants` is governed by what the protected scope hands back.
|
||||
|
||||
> 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]].
|
||||
|
||||
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.
|
||||
|
||||
## Logging convention — identity-first, and the counter before→after
|
||||
|
||||
@@ -27,4 +27,4 @@ The app has **two modes**, both consumed through the `useFestipodData()` hook:
|
||||
|
||||
> 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, and the sign-up flow driven end to end still fails — [[bug_participant-count-stays-at-zero]] and [[bug_signup-breaks-the-next-connection]]. Do not read the paragraph above as "signing up works".
|
||||
> **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,7 +1,7 @@
|
||||
---
|
||||
type: knowledge
|
||||
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-16
|
||||
last_checked: 2026-08-17
|
||||
---
|
||||
|
||||
# Data entities
|
||||
@@ -10,7 +10,7 @@ last_checked: 2026-08-16
|
||||
|
||||
| Type | Persistence | Key fields |
|
||||
|---|---|---|
|
||||
| `FpEventData` | SDK (Event shape) | title, date, location, distance, participantCount, coverImage |
|
||||
| `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 |
|
||||
@@ -31,4 +31,4 @@ The generator emits `Event`, `UserProfile`, `Participation`, `MeetingPoint`, `No
|
||||
|
||||
Alias at the import; never rename in the generated files, which `bun run build:orm` overwrites ([[knowledge_nextgraph-stack]]).
|
||||
|
||||
> Pitfall: several fields of `FpEventData` are **not** in the shape and are lost when connected — [[caveat_event-fields-not-persisted]].
|
||||
> `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,7 +17,7 @@ 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, date, location, distance, participantCount, coverImage. **No host**: an event is only the anchor ([[knowledge_entities]]).
|
||||
- **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 (event, host, title, place, time)
|
||||
@@ -25,7 +25,7 @@ The reactive ORM (`useShape`) is built on **SHEX shapes**: `src/shared/shapes/sh
|
||||
|
||||
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 is not a no-op, even with an unchanged `.shex`.** The committed bindings had drifted from what the generator emits, so a regeneration produces a diff beyond your own change — read it rather than assuming it is yours. 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.
|
||||
> **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.
|
||||
|
||||
|
||||
+1
-1
@@ -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,7 +42,7 @@ 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 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]].
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: The product model of confidentiality and discovery — every entity lives in the SCOPE matching who must read it (events & meeting points public, network profile & participations protected, settings private, bilateral connections dialog); discovery is 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,7 +31,11 @@ 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.
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ 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 is wired step by step, and the flow as a whole does not deliver.** 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 sign-up nonetheless **announces a success it does not obtain**: the user is told they participate, the count never moves ([[bug_participant-count-stays-at-zero]]), and their next connection fails ([[bug_signup-breaks-the-next-connection]]). Honest steps do not add up to an honest flow, and nothing short of exercising the whole thing shows it (concept `bdd-testing`, [[cookbook_live-probe]]). Treat the bullet above as *screens reachable*, not as a working journey. 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]]).
|
||||
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
**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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Generated
+19
-6
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
+53
-3
@@ -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 });
|
||||
},
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ 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
|
||||
@@ -918,36 +919,77 @@ function useNgData(): FestipodDataContextValue {
|
||||
// failed, or this event arrived after the last one. Never 'not-mine' here.
|
||||
return 'unknown';
|
||||
}, [ownedCanonicalIds, ruledOutEventIds]);
|
||||
// Last count written per owned event, so we only persist a genuine change.
|
||||
const materializedCountRef = useRef<Map<string, number>>(new Map());
|
||||
// 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)'}`,
|
||||
);
|
||||
@@ -957,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
|
||||
@@ -976,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) {
|
||||
@@ -988,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);
|
||||
@@ -1015,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
|
||||
@@ -1028,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]);
|
||||
@@ -1110,6 +1178,12 @@ 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
|
||||
@@ -1163,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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -37,6 +37,15 @@ 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'),
|
||||
|
||||
@@ -54,6 +54,50 @@ export const festipodShapesSchema = {
|
||||
iri: "http://festipod.org/date",
|
||||
readablePredicate: "date",
|
||||
},
|
||||
{
|
||||
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: [
|
||||
{
|
||||
|
||||
@@ -40,6 +40,30 @@ export interface Event {
|
||||
* 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
|
||||
*
|
||||
|
||||
@@ -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 ?
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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']]);
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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)`),
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user