diff --git a/.gitignore b/.gitignore index e560329..2c9a06f 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,6 @@ playwright/.auth/ storybook-static dist-staging/ *.ngw + +# per-developer contract source access (never committed) +.project/contracts.local.yaml diff --git a/.project/concepts/data-layer/activitypods-providers/contract_activitypods-providers.md b/.project/concepts/data-layer/activitypods-providers/contract_activitypods-providers.md new file mode 100644 index 0000000..4da4e7d --- /dev/null +++ b/.project/concepts/data-layer/activitypods-providers/contract_activitypods-providers.md @@ -0,0 +1,88 @@ +--- +type: contract +summary: The Refine data, auth and live providers @activitypods/refine-providers exposes to an application over a Solid Pod — what each operation does, what a record looks like, and what the package does not offer +built_from: https://github.com/activitypods/refine-providers.git/src +built_version: 66616484b96fb2227cd8cdf02321aacfb4811365 +built_at: 2026-10-02 +--- + +# contract_activitypods-providers — `@activitypods/refine-providers` + +**This is a RECONSTRUCTION, not the provider's promise.** The package publishes no engagement and no change policy, so this document is an observation made once, at adoption time, and pinned to the commit named in `built_version`. Nothing here is committed to by its authors: any release may move any of it. Re-observe at each upgrade — the pin is what makes a difference visible. + +## Scope + +Three Refine providers over a Solid Pod running ActivityPods: a `dataProvider` (CRUD over LDP containers), an `authProvider` (Solid-OIDC sign-in plus the app-registration dance ActivityPods requires), and a `liveProvider` (push over the Solid Notifications Protocol). Resources are not addressed by a path the caller chooses: each configured resource is mapped at runtime to an LDP container discovered in a WebID's Solid TypeIndex. + +Two Ant Design components also ship (`antd-auth-page`, `antd-background-checks`) and are outside this scope — `antd` and `@ant-design/icons` are optional peer dependencies, so an app with its own interface consumes the three providers without them. `react-router` is likewise an optional peer dependency. + +Package `@activitypods/refine-providers`, version 0.1.5 at the pinned commit. Peer dependency `@refinedev/core ^5.0.12`. Full typed shape: the package's shipped declarations, `./types` (`dist/types.d.ts`) and `./index` (`dist/index.d.ts`). + +## Surface + +```ts +dataProvider({ resources, authProvider, jsonContext? }): DataProvider +authProvider({ clientId, redirectUri?, allowAnonymous?, appStatusCheckInterval? }): AuthProvider +liveProvider({ resources, authProvider, jsonContext? }): LiveProvider + +type ResourceConfig = { shapeTreeUri?: string; types?: string | string[] } +type ResourcesConfig = Record // keyed by Refine resource name +type JsonContext = string | string[] | Record +type AuthSession = { token: string; webId: string } +``` + +`AuthProvider` is Refine's `AuthProvider` plus four members: `getSession(): AuthSession | undefined` (synchronous), `handleCallback(): Promise<{ webId, redirect }>`, `registerApp(webId?): Promise`, `getAppStatus(): Promise`. Its `login`/`register` take `{ issuer, redirect?, isSignup? }` — the Pod provider's base URL is a caller-supplied parameter, so the app must let the user pick one. + +`DataProvider` implements `getList`, `getOne`, `getMany`, `create`, `update`, `deleteOne` and `getApiUrl`. Nothing else. + +Utility functions are exported from the package root as well (notably the TypeIndex discovery and container resolution used internally); they are not part of what this document engages. + +## Guarantees + +Observed at the pinned commit. + +**A record is a JSON-LD resource with an `id`.** `id` is the resource's own URI — `id` or `@id` of the fetched document, whichever is present; a resource carrying neither raises. The record also carries `@context` (the container's, on a listed record) and every other predicate verbatim. There is exactly ONE identifier: `getOne`, `update` and `deleteOne` take that same `id` and use it directly as the URL they fetch. + +**`getList`** resolves the resource's container, fetches it whole, and maps `ldp:contains`. Refine's filters and sorters are then applied **in memory**, and pagination slices the result; `total` is the count before slicing. The filter set is broad (equality, `in`, substring, prefix/suffix, ordering, `between`, null checks, and `and`/`or` nesting), string comparison being case-insensitive unless the case-sensitive variant is used, and a filter field may be a dotted path. + +**`create`** POSTs `{ "@context": jsonContext, "@type": , ...variables }` to the container, requires a `Location` response header, and returns the record re-fetched from it. The `@type` is the resource's configured `types` expanded through `jsonContext`, or, when only `shapeTreeUri` is configured, the `sh:targetClass` of the shape the shape tree points at. + +**`update` merges — a partial write is safe.** It fetches the current resource, drops its `id` and `@context`, and PUTs `{ "@context": jsonContext, ...current, ...variables }`. So values the caller omits survive. The merge is **shallow**: a nested object in `variables` replaces its counterpart whole. It returns the record re-fetched after the write. + +**`deleteOne`** DELETEs the resource and returns `{ id }` only. + +**A configured resource's container is discovered, not configured.** Resolution reads the WebID document, then its `solid:publicTypeIndex`, then best-effort its `pim:preferencesFile` → `solid:privateTypeIndex`, and takes the first type registration whose `solid:forClass` matches the resource's resolved types and which carries a `solid:instanceContainer`. No match raises, naming the WebID and the types. A `shapeTreeUri` is fetched over plain HTTP, as is the shape it points at — so a shape tree the caller hosts itself works, provided it is reachable and carries `st:shape` → `sh:targetClass`. + +**`jsonContext`** is sent as `@context` on every create and update, and is what expands configured CURIE types. It defaults to the ActivityStreams 2 context; an application with its own vocabulary passes its own. + +**`liveProvider` pushes — the caller never polls.** It opens a `notify:WebSocketChannel2023` channel per topic, discovering the subscription endpoint from the Pod origin's `.well-known/solid`. Watching specific records subscribes to each record and reports `Update` → `updated`, `Delete`/`Remove` → `deleted`. Watching a list subscribes to the **container** and reports `Add` → `created`, `Remove` → `deleted`. Unsubscribing closes the socket and deletes the channel best-effort. A resource absent from `resources` yields a no-op unsubscribe rather than an error. + +**Auth is Solid-OIDC with PKCE.** `login` redirects to the issuer's authorization endpoint with scope `openid webid offline_access`; `handleCallback`, called from the redirect route, completes the code grant and stores the ID token. The session's `webId` is the token's `webid` claim, and an expired token reads as no session. `logout` clears local state. `check` reports unauthenticated with `redirectTo: "/login"` unless `allowAnonymous` is set. `getIdentity` returns `{ id: webId, name, avatar }`, name and avatar read from the WebID document and the profile it links — ActivityPods' own profile vocabulary, unrelated to any vocabulary the app defines for its own user records. `registerApp` returns the AppRegistration URI when the app is registered and its grants current, and otherwise redirects the browser to the user's authorization agent and resolves `undefined`. + +**Failures throw, and carry the HTTP status.** Every Pod call that returns non-2xx raises an `Error` with `status` and `body` attached, its message taken from the response body's error message when there is one. `onError` maps a 401 to logout. + +## Non-guarantees + +**Every read and write is scoped to the logged-in user's own Pod.** Container resolution always runs against the session's own WebID — in `getList`, in `create`, and in a list subscription alike. There is no parameter, filter or option through which an operation reaches another user's resources: `getList` returns what the current user holds, and nothing else. (`getOne`/`getMany` fetch a URI directly and so are not bound this way, but they require the caller to already hold that URI.) + +**No server-side querying.** Filtering, sorting and pagination are applied after the whole container has been fetched, so cost grows with the container, not with the page. + +**`getManyReference` is not implemented**, despite the data provider's own documentation mentioning it. Neither are `createMany`, `updateMany` and `deleteMany`. + +**`getMany` drops what it cannot fetch.** Failed fetches are filtered out silently, so the returned array may be shorter than `ids` with no indication of which are missing. + +**Caches are module-level and never invalidated** — shape trees by URI, type registrations by WebID, subscription endpoints by origin. A container that becomes available after a first lookup (the user granting access, for instance) is not seen until the page reloads, which is what the registration flow expects. + +**`authProvider` installs process-global timers it never removes.** Unless `appStatusCheckInterval: false` is passed, construction registers a `setInterval` and a `visibilitychange` listener, with no teardown; constructing the provider more than once per page accumulates them. + +**One session per page.** The token lives at a fixed `localStorage` key, so two concurrent sessions cannot coexist. + +**A list subscription does not report field changes.** A container notifies about membership only, so a member's own predicates changing is invisible to a list; the record has to be watched directly. + +**`types` wins over `shapeTreeUri` when both are set** — the opposite of what the configuration type's own documentation states. Configure exactly one. Likewise, when several type registrations match a resource's types, the first encountered wins, with no stated ordering. + +**Unregistered apps read and write nothing.** An ActivityPods app must hold an AppRegistration before any data operation succeeds, and keeping it current is the app's business, through `registerApp` and `getAppStatus`. + +## Change policy + +None is published. The package carries no engagement document, no stated versioning commitment and, at the pinned commit, no git tags — its released versions are visible only as `package.json` version bumps. Treat every upgrade as potentially breaking: re-read the surface against the new commit and re-stamp this document. Divergences found between this reconstruction and observed behaviour are defects in this document, to be fixed here. diff --git a/.project/concepts/data-layer/activitypods-providers/usage_festipod.md b/.project/concepts/data-layer/activitypods-providers/usage_festipod.md index 8e587b4..c4683f6 100644 --- a/.project/concepts/data-layer/activitypods-providers/usage_festipod.md +++ b/.project/concepts/data-layer/activitypods-providers/usage_festipod.md @@ -6,68 +6,66 @@ against: 66616484b96fb2227cd8cdf02321aacfb4811365 # usage_festipod — Festipod on `@activitypods/refine-providers` -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 Refine, behind a **backend seam** that exists so the app can support several Refine backends and let the user switch between them at runtime. `@activitypods/refine-providers` is one of the two backends targeted; the other is a Refine provider over NextGraph plus its polyfill. The seam's own contract — what a backend must answer before it can be added — lives in code, in `src/shared/refine/providers/types.ts`. +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 Refine, behind a **backend seam** that exists so the app can support several Refine backends and let the user switch between them at runtime. This package is one of the two backends targeted; the other is a Refine provider over NextGraph plus its polyfill. The seam's own contract — what a backend must answer before it can be added — lives in code, in `src/shared/refine/providers/types.ts`. -**Nothing is wired yet, and that is what this declaration is for.** The package is not installed and there are no call sites, so the surface below is **declared, not measured**: it is what Festipod will call, derived from the backend seam's requirements and from the only public specification available, this repository's README. Two of the seam's four required answers are not in that README, and they are structural enough that guessing them would produce a backend that fails silently rather than loudly — so they are in `## Needs` instead, and adoption waits on them. - -`against:` names the commit this repository's README resolved to on 2026-10-01, obtained with `git ls-remote` — an identity, not a pulled engagement. No engagement document is published for this interface, so nothing is pinned and nothing can drift; the pin is the best available anchor for *which* README these statements come from. +Nothing is wired yet, so the list below is **declared, not measured**: it is what Festipod will call, derived from the seam's requirements and from the observation of the package pinned beside this declaration. `against:` names that observation's commit. The package publishes no engagement, so there is nothing to pull and nothing that can drift — what is recorded here is read against our own reconstruction, and re-read when it is re-stamped. ## Consumed surface -What Festipod intends to call, and nothing else. Anything the package offers that is absent here is free to evolve without us. - ### Provider construction -- `authProvider({ clientId, redirectUri? })` — one call site, built once per backend selection. Solid-OIDC sign-in. +- `authProvider({ clientId, redirectUri, appStatusCheckInterval: false })` — one call site, per backend selection. `appStatusCheckInterval` is disabled deliberately, see friction 1; Festipod drives the check itself. - `dataProvider({ authProvider, resources, jsonContext })` — one call site, handed the `authProvider` above. -- `liveProvider` — mounted so Refine can subscribe behind every query. +- `liveProvider({ authProvider, resources, jsonContext })` — same, so Refine can subscribe behind every query. All three are built together, **per backend instance**, inside the seam's `build()`. -### Refine operations, through `@refinedev/core` v5 hooks +### Auth members -Festipod reaches the data provider only through Refine's hooks, never directly: `useList` (`getList`), `useCreate` (`create`), `useUpdate` (`update`), `useDelete` (`deleteOne`). `getOne` is reached by Refine's own cache plumbing rather than by an explicit app call. +`login({ issuer, redirect })` and `register(...)` — Festipod supplies its own Pod-provider picker. `handleCallback()` from its own callback route. `logout()`. `check()`. `getSession()` for the `webId` and the token. `registerApp()` and `getAppStatus()`, the second one on Festipod's own schedule since the built-in interval is off. -The app mounts Refine with `liveMode: 'auto'` and `mutationMode: 'optimistic'`. +### Resources and operations -### Resources +Four resources, one per persisted entity: **events**, **meeting points**, **participations** and **user profiles**. Each is configured with `types` naming Festipod's own class URIs, and `jsonContext` carries the context that expands them. -Four, one per persisted entity: **events**, **meeting points**, **participations** and **user profiles**. Each is declared through `resources` with a `shapeTreeUri`. +Through Refine's hooks only, never the provider directly: `useList` → `getList`, `useCreate` → `create`, `useUpdate` → `update`, `useDelete` → `deleteOne`. `getOne` is reached by Refine's cache plumbing rather than an explicit call. The app mounts Refine with `liveMode: 'auto'` and `mutationMode: 'optimistic'`. + +### Record handling + +A record's `id` — the resource's URI — is the **only** identifier Festipod uses: the seam's `recordKey` and `domainId` both return it. `update` merging the fields it is given means the seam declares this backend as patching, and sends only what changed. ### Not consumed -- The `types` fallback for a resource — Festipod intends `shapeTreeUri`, which the README presents as the preferred form. -- `appStatusCheckInterval` — no need identified for driving the app-status poll ourselves. +- `shapeTreeUri` — exactly one of the two resource identifiers must be configured, and `types` names Festipod's classes directly, without a shape tree to host and keep reachable. +- `getIdentity` — the signed-in identity and a Festipod user profile are two unrelated things in this app; the profile is read as an ordinary record, and only `getSession`'s `webId` is needed. +- `getMany`, `getApiUrl`, `allowAnonymous`, and both Ant Design entry points — Festipod has its own interface. ## Constraints -What must hold beyond the call list, for this backend to serve the app. +What must keep holding for this backend to serve the app. -1. **Festipod's own vocabulary, not ActivityStreams.** The four entities are `fp:Event`, `fp:MeetingPoint`, `fp:Participation` and `fp:UserProfile`, defined by SHEX shapes this repo already holds and generates ORM bindings from. They are domain types of this product, and none of them is an ActivityStreams type. A backend that forces records into an `as:` class cannot carry them. -2. **Two identifiers must stay distinguishable.** The app needs, per record, the key `update` and `getOne` address it by *and* the id other records reference it by. These are not always the same value — on the other targeted backend the address is the document URI while a participation points at the subject IRI — so the seam carries both (`recordKey`, `domainId`) and collapsing them breaks every cross-entity reference without any error surfacing. -3. **Update semantics must be known, not inferred.** A backend whose `update` replaces the resource requires the app to send the complete field set; one that patches requires only the changed fields, and sending a complete set would clobber a concurrent edit. The seam declares which it is (`updateReplaces`, `toPatch`) and behaves accordingly, so the wrong value silently drops data in one direction or the other. -4. **Every participation must be readable by every user.** Festipod's participant count is the number of participations of an event, counted with no filtering. What is confidential is the **link between a participation and a person** — only a connection can read the profile a participation points at and recognise who it is — never the participation record itself. So `getList` on participations must return all of an event's participations regardless of who created them. A backend that scopes participations to their author cannot produce the count. +1. **Festipod's own vocabulary, not ActivityStreams.** The four entities are Festipod domain types defined by SHEX shapes this repo holds; none is an ActivityStreams type. A caller-supplied `jsonContext` and caller-supplied `types` are what make that possible, and both must stay caller-supplied. +2. **The identifier a record is addressed by must stay the identifier other records reference.** The seam carries the two separately because they differ on the other targeted backend; here they coincide, and the app relies on that. Splitting them later would break every cross-entity reference silently. +3. **A partial update must stay partial.** The app sends only changed fields. A move to replacing the resource would drop every field omitted, with no error. +4. **Every participation must be readable by every user.** The participant count is the number of participations of an event, counted with no filtering. What is confidential is the **link between a participation and a person** — only a connection can read the profile a participation points at and recognise who it is — never the participation record itself. See need 1: this is the constraint the package does not currently let the app satisfy. 5. **The count is never a stored, incremented field.** Reading a counter, adding one and writing it back derives each write from a read of the previous one and races itself through any data provider. This is measured, not theoretical, and it is why the app derives the count from the participation list at read time. -6. **Changes must be pushed, not polled.** The app and its BDD suites wait for a change to arrive; neither has a polling loop, and adding one is ruled out by project doctrine. `liveMode: 'auto'` is mounted on that basis, so the live provider has to deliver a change to a subscribed query. -7. **Per-instance configuration, no process-global state.** Two backends coexist in one page and the user switches between them at runtime; switching remounts Refine to discard the cache of the backend being left. Building this backend's providers must therefore have no global side effect that outlives the remount or collides with the other backend's providers. +6. **Changes must be pushed, not polled.** Neither the app nor its BDD suites has a polling loop, and adding one is ruled out by project doctrine. `liveMode: 'auto'` is mounted on that basis. +7. **No process-global state that outlives a remount.** Two backends coexist in one page and the user switches between them at runtime; switching remounts Refine to discard the cache of the backend being left. Building this backend's providers must have no side effect that accumulates across switches — see friction 1, which is where this currently bites. 8. **Each entity carries a declared visibility** — public, protected or private, per the product's confidentiality model — and it is the backend that honours it. Refine has no notion of it, so the intent is declared at the seam and enforcement is the backend's. ## Frictions -none +1. **`authProvider` installs a timer and a listener it never removes.** Construction registers a `setInterval` and a `visibilitychange` listener with no teardown. Festipod builds providers once per backend selection, so every switch to this backend would accumulate another pair — directly against constraint 7. Workaround carried: pass `appStatusCheckInterval: false` and call `getAppStatus()` on the app's own schedule. That works, but it means the package's own staleness handling is unusable here, and the only way to opt out is to opt out entirely. +2. **`getList` fetches the whole container on every call.** Filtering, sorting and pagination run in memory afterwards, so the cost of listing an event's participations grows with the user's total number of participations rather than with the event. No workaround; it is why a filter on a `getList` buys nothing beyond convenience. +3. **A list subscription reports membership only.** A container channel delivers `Add`/`Remove`, not a member's own fields changing, so a list of events does not refresh when an event's title, place or time is edited. Watching each record instead would open one channel per row. The event list is exactly the screen where this is visible. +4. **`types` takes precedence over `shapeTreeUri`, the opposite of what the configuration type documents.** The declared behaviour is that `types` is "only used as a fallback when `shapeTreeUri` is not set"; a resource setting both is in fact resolved from `types`. Establishing which one actually wins cost a read of the resolution path. Configuring exactly one avoids it, but the documentation should say which wins. ## Needs -Two answers. Both are facts about this package that its README does not state, both are structural in Festipod's seam, and neither can be established from the outside without reading source — which this project does not do. +1. **A read that is not scoped to the logged-in user's own Pod.** Every container resolution — in `getList`, in `create`, and in a list subscription — runs against the session's own WebID. So `getList('events')` returns only the events the current user created, and `getList('participations')` only their own participations. -1. **Is a shape tree URI that Festipod hosts itself, over its own vocabulary, accepted at app registration?** + This blocks the product, not a detail of it. Festipod exists to let a user **discover other people's public events** and see **how many people have signed up** to a meeting point. Through this provider the app can show neither: no other user's event is reachable, and the participant count of constraint 4 cannot be computed from participations it cannot read. - The README says a resource is best declared by `shapeTreeUri`, that the `types` fallback is *"resolved against the Data Registrations' shape trees"*, and that *"Each declared shape tree gets its own Data Registration (and LDP container) on the user's Pod once they grant access"*. What it does not say is whether the URI may point at a shape tree **Festipod publishes and controls**, or whether it must resolve to an already-published vocabulary known to the Pod. + The request is modest in shape, because the capability already sits one layer down: the package's own TypeIndex discovery takes a WebID as a parameter, and notes that the public TypeIndex is readable without any special grant. What is missing is a way to reach it **through the data provider** — a way to scope a `getList` (and the matching list subscription) to a given WebID, or to a set of them. Festipod has no interest in reimplementing container resolution beside the provider; that would put the app back in the business the provider exists to hold. - This decides the whole adoption. Festipod's four entities are its own domain types, so if self-hosted shape trees are accepted it authors four of them from the SHEX shapes it already has, declares them, and each gets its container on the user's Pod — the mechanism as documented, used as designed. If they are not accepted, the app needs to know what the accepted path for an application-specific vocabulary is instead. - -2. **What is the shape of a record returned by `getList`, and does `update` replace or patch the resource?** - - Specifically: which field of a returned record is the key Refine addresses it by, which field is the id other records should reference, and whether the two are the same value. Then, for `update`: whether the values sent replace the resource or are merged into it. - - These are constraints 2 and 3 above — the two the seam cannot default. Both fail quietly when wrong: the wrong identifier produces references that resolve to nothing with no error anywhere, and the wrong update semantics either drops every field a write omits or overwrites a concurrent edit. A worked example of a returned record and a one-line statement of the update semantics would be enough. +2. **Releases that can be pinned, and a statement of what changes between them.** The package carries no git tags, so the only thing this repo can pin is a commit on a moving branch, and the only way to learn what an upgrade changed is to re-read the source. Tagged releases plus a note of what moved in each — in whatever form suits the project — would let a consumer upgrade deliberately instead of re-observing from scratch every time. diff --git a/.project/contracts.yaml b/.project/contracts.yaml index a5ed5c0..6070811 100644 --- a/.project/contracts.yaml +++ b/.project/contracts.yaml @@ -7,18 +7,30 @@ # separate interface, with its own folder inside `data-layer` — the concept that owns how # Festipod uses an external data surface. # -# NO `consume:` SECTION YET, and that is a fact about the providers rather than an omission. -# A `consume:` entry fetches a provider's published engagement; neither targeted backend -# publishes one today. For `@activitypods/refine-providers` the only public specification is -# a README, so the declaration beside it anchors on that repo's commit identity — resolved -# with `git ls-remote`, which names a state without fetching any content. Add the entry the -# moment an engagement exists to pull, or at adoption time through a bounded `reconstruct`. +# `@activitypods/refine-providers` publishes NO engagement: no contract document, no stated +# change policy, not even a git tag. So its entry is `type: reconstruct` — the one bounded +# crossing this protocol allows, made once at adoption time: the leaf under `into:` is OUR +# observation of that package, authored here and pinned to the commit it was read at. It is +# not a promise anyone made us, and every upgrade has to re-observe it. # -# The iron rule holds regardless: a gap in what a provider publishes is raised in the -# `## Needs` of our declaration, never worked around by reading its source (see -# `data-layer/rule_app-uses-sdk-surface-only` on the sibling branch, and AGENTS.md). +# Once reconstructed, that leaf IS the specification this app codes against. Reading the +# package's sources again at work time is not how a question gets answered — the leaf is +# fixed or the gap goes into our declaration's `## Needs`. publish: # Key qualified by interface: a second declaration is coming for the NextGraph+polyfill # backend, and publish keys must stay unique. activitypods-providers--festipod: concepts/data-layer/activitypods-providers/usage_festipod.md + +consume: + - contract: activitypods-providers + into: concepts/data-layer/activitypods-providers/ + type: reconstruct + # Canonical identity: the package's git remote plus the repo-relative folder the + # observation was made from. Per-developer access to a checkout of it lives in + # `.project/contracts.local.yaml`, which is never committed. + reconstructFrom: https://github.com/activitypods/refine-providers.git/src + # The branch the observation was read from. A tag would be the better pin, but this + # repository publishes none — the resolver turns this into the commit actually read, + # which is what `built_version` records. + ref: master