From 6a3bb50d2f12552082557b4cf56bd7131a08ecd7 Mon Sep 17 00:00:00 2001 From: Anton Wiklund Date: Sun, 8 Nov 2026 21:55:23 +0100 Subject: [PATCH 1/2] fix(solid)!: register only the instance in the type index Solid Memo wrote up to seven type index registrations per instance: its own sm:Instance one, and one for each class of its data (the catalog, decks, cards, review states, answers and drafts). Solid apps register the library a user owns and reach what is inside through its own links; the type index proposal asks for coarse library types, not every class an app writes. Nothing read the extra registrations, and users were left to tidy them by hand. Creating an instance, a format update and a guest's study moved into the pod now write only the sm:Instance registration. The format update writes nothing outside the instance, so it no longer adds back a registration the user removed. "Findable by other apps" in Preferences goes with them. Removing, deleting and renaming an instance still recognize the registrations earlier versions wrote, so those go when the instance does. Closes: #644 BREAKING CHANGE: Solid Memo no longer registers the classes of an instance's data in the type indexes (dcat:Catalog, sm:Deck, sm:Card, sm:ReviewState, sm:Answer, and sm:Deck for the drafts). A new instance has only its sm:Instance registration, and a format update no longer adds the others. An app that found the data by those classes finds the instance by sm:Instance instead, and its data at the places the instance's folder gives it (docs/data-model.md). Those earlier versions wrote stay until the user removes them in Preferences or removes the instance. --- api/pod-layout.txt | 12 - docs/data-model.md | 153 ++++-------- docs/guest-mode.md | 14 +- docs/migrations.md | 35 ++- docs/privacy.md | 4 +- docs/standard-for-public-code.md | 6 +- docs/studio.md | 9 +- docs/user-guide.md | 5 - docs/vocab.md | 4 +- e2e/pod/servers/jss/expected-failures.json | 6 +- .../servers/nextcloud/expected-failures.json | 6 +- e2e/pod/src/foreignData.integration.test.ts | 2 +- .../src/instanceUpdate.integration.test.ts | 45 +--- e2e/pod/src/metadata.integration.test.ts | 10 +- e2e/pod/src/typeIndex.integration.test.ts | 165 +++++++++---- packages/application/src/instances.test.ts | 63 +---- packages/application/src/instances.ts | 55 +---- packages/application/src/ports.ts | 35 +-- packages/application/src/session.test.ts | 25 +- packages/application/src/session.ts | 8 +- packages/application/src/testing/fakePorts.ts | 6 - packages/domain/src/instance.test.ts | 14 +- packages/domain/src/instance.ts | 44 ---- packages/domain/src/instanceUpdate.test.ts | 2 +- packages/domain/src/instanceUpdate.ts | 6 +- .../solid/src/solidInstanceRepository.test.ts | 231 +++--------------- packages/solid/src/solidInstanceRepository.ts | 153 +----------- packages/solid/src/typeIndex.test.ts | 208 +++++----------- packages/solid/src/typeIndex.ts | 162 +++++------- packages/ui/src/i18n/de.json | 28 +-- packages/ui/src/i18n/en.json | 28 +-- packages/ui/src/i18n/es.json | 28 +-- packages/ui/src/i18n/fr.json | 28 +-- packages/ui/src/i18n/ko.json | 28 +-- packages/ui/src/i18n/sv.json | 28 +-- packages/ui/src/test/fakes/instances.ts | 12 - packages/ui/src/ui/FindableContainer.test.tsx | 146 ----------- packages/ui/src/ui/FindableContainer.tsx | 80 ------ packages/ui/src/ui/InstanceUpdate.tsx | 1 - .../ui/src/ui/MigrationContainer.test.tsx | 11 +- packages/ui/src/ui/Workspace.test.tsx | 28 --- packages/ui/src/ui/Workspace.tsx | 21 +- packages/ui/src/ui/queryKeys.test.ts | 2 - packages/ui/src/ui/queryKeys.ts | 3 - 44 files changed, 430 insertions(+), 1530 deletions(-) delete mode 100644 packages/ui/src/ui/FindableContainer.test.tsx delete mode 100644 packages/ui/src/ui/FindableContainer.tsx diff --git a/api/pod-layout.txt b/api/pod-layout.txt index b5e7fc745..c838d4c24 100644 --- a/api/pod-layout.txt +++ b/api/pod-layout.txt @@ -15,19 +15,7 @@ digest: kept in digest.ttl draft: its chapter "intro" in drafts/solid/v2/chapter-intro.ttl draft: the cards no chapter asks in drafts/solid/v2/cards.ttl draft: version 2 of the draft "solid" in drafts/solid/v2/release.ttl -instance registered in the private index: the private type index registers dcat:Catalog at solid:instance catalog.ttl#catalog, titled with the instance's name -instance registered in the private index: the private type index registers sm:Answer at solid:instanceContainer history/, titled with the instance's name -instance registered in the private index: the private type index registers sm:Card at solid:instanceContainer decks/, titled with the instance's name -instance registered in the private index: the private type index registers sm:Deck at solid:instance catalog.ttl, titled with the instance's name -instance registered in the private index: the private type index registers sm:Deck at solid:instanceContainer drafts/, titled with the instance's name instance registered in the private index: the private type index registers sm:Instance at solid:instanceContainer , titled with the instance's name -instance registered in the private index: the private type index registers sm:ReviewState at solid:instanceContainer reviews/, titled with the instance's name -instance registered in the public index: the private type index registers sm:Answer at solid:instanceContainer history/, titled with the instance's name -instance registered in the public index: the private type index registers sm:Deck at solid:instanceContainer drafts/, titled with the instance's name -instance registered in the public index: the private type index registers sm:ReviewState at solid:instanceContainer reviews/, titled with the instance's name -instance registered in the public index: the public type index registers dcat:Catalog at solid:instance catalog.ttl#catalog, titled with the instance's name -instance registered in the public index: the public type index registers sm:Card at solid:instanceContainer decks/, titled with the instance's name -instance registered in the public index: the public type index registers sm:Deck at solid:instance catalog.ttl, titled with the instance's name instance registered in the public index: the public type index registers sm:Instance at solid:instanceContainer , titled with the instance's name instance: described by meta.ttl#it, a sm:Instance instances: found by a sm:Instance registration at solid:instance in the private type index diff --git a/docs/data-model.md b/docs/data-model.md index 37e785dec..db2f53446 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -50,14 +50,14 @@ flowchart LR ```mermaid flowchart LR accTitle: The discovery chain - accDescr: From the WebID, the app follows the storage and the private and public type indexes to the instance container, and to the registrations other apps read. + accDescr: From the WebID, the app follows the storage and the private and public type indexes to the instance container, and from the instance's folder to the rest of its data. W[WebID] -->|pim:storage| S[Storage root] W -->|solid:privateTypeIndex
directly or via pim:preferencesFile| PTI[Private type index] W -->|solid:publicTypeIndex| PUB[Public type index] PTI -->|solid:TypeRegistration
forClass sm:Instance| I1[Instance container] - PUB -->|solid:TypeRegistration| I2[Instance container] - PTI -.->|"forClass dcat:Catalog, sm:Deck,
sm:Card, sm:ReviewState, sm:Answer,
sm:Deck (the drafts)"| D1[The instance's data,
for other apps] - PUB -.->|"forClass dcat:Catalog, sm:Deck,
sm:Card"| D1 + PUB -->|solid:TypeRegistration
forClass sm:Instance| I2[Instance container] + I1 -->|the folder's layout| D1["catalog.ttl, decks/, reviews/,
history/, drafts/"] + I2 -->|the folder's layout| D1 ``` - **Storage**: `pim:storage` triples in the (extended) profile. That's @@ -93,49 +93,22 @@ flowchart LR [urls.ts](../packages/solid/src/urls.ts)). A registration that still can't be read as an instance is left out, and the rest of the list still loads. -- **Each class of an instance's data** has a registration of its own, - titled with the instance's name, for other apps ([examples - below](#registrations)): - - the catalog: `dcat:Catalog`, `solid:instance` its subject - `catalog.ttl#catalog`; - - the decks: `sm:Deck`, `solid:instance` `catalog.ttl`; - - the cards: `sm:Card`, `solid:instanceContainer` `decks/`; - - the review states: `sm:ReviewState`, `reviews/`; - - the answers: `sm:Answer`, `history/`; - - the drafts of releases: `sm:Deck` again, `solid:instanceContainer` - `drafts/` (a draft's release document is a deck too, told from the - instance's decks by where it is). - - Per the Type Indexes spec, `solid:instance` names the one resource - holding the class's subjects, and `solid:instanceContainer` a - container whose documents hold them. The catalog's, decks' and cards' - registrations go in each index that registers the instance. The - review states', answers' and drafts' go in the private index only, - even for an instance registered publicly, and without one nowhere. - They're written: - - when an instance is created; - - by a [format update](migrations.md#the-pod-migration) once it - updated every document (a failure there leaves the update done); - - when a guest's study moves into the pod as a new instance - ([guest-mode.md](guest-mode.md#as-a-new-instance)); - - by **Register what is missing** under **Findable by other apps** in - Preferences (not offered to a guest), which lists each registration - that belongs in an index and whether it's there. A linked index that - can't be read is left out of the list and said to be unreadable, and - nothing is added to it. - - They're never written on opening an instance, which would fight other - editors of the index and add back what the user removed. Attaching an - instance by URL writes only its `sm:Instance` registration; adding a - guest's study to an instance writes none. +- **The rest of an instance's data** has no registration: the catalog, + the decks, cards, review states, answers and drafts are reached from + the instance, at the places its folder's layout gives them + ([Instance layout](#instance-layout)). Earlier versions of the app + wrote one registration per class of that data beside the + `sm:Instance` one ([examples below](#registrations)). The app no + longer writes them, and still recognizes them: removing or deleting + an instance removes them, and renaming it retitles them. - **Writing a type index** saves it with `If-Match` the version read; on a 412 it tries again, three attempts in all (`changeIndex` in - [typeIndex.ts](../packages/solid/src/typeIndex.ts)). A registration is - added only when no equal one is there: the same class, naming the same - resource under either predicate, the container with or without its - trailing slash. + [typeIndex.ts](../packages/solid/src/typeIndex.ts)). The registration + is added only when no equal one is there: `sm:Instance`, naming the + container under either predicate, with or without its trailing slash + (`addInstanceRegistration`). [typeIndex.integration.test.ts](../e2e/pod/src/typeIndex.integration.test.ts) - reads every registration back from real servers. + reads the registration back from real servers. - **Type index links** are read from the WebID subject in the WebID document and in its extended profile documents (`rdfs:seeAlso` / `foaf:isPrimaryTopicOf`), plus `pim:preferencesFile` for the private @@ -164,15 +137,13 @@ flowchart LR writable). An index document already at the target URL is adopted, not overwritten. If registration fails, instance creation fails loudly, with no local fallback, and the new container is cleaned up. - For an instance registered publicly, the review states' and answers' - registrations go to the private index afterward; a refusal there - doesn't fail the creation, and leaves them for **Register what is - missing**. + An instance is registered in the index the user chose, and in no + other. - **Deleting an instance** deletes what Solid Memo wrote in its container and nothing else (`deleteInstanceData` in [instanceData.ts](../packages/solid/src/instanceData.ts)), then - removes its registrations of every class from both type indexes - (below). In order: + removes its registrations from both type indexes: its `sm:Instance` + one and any an earlier version wrote for its data (below). In order: 1. each deck's cards and reviews documents as the catalog names them, if they're the deck's: they say so, or they're in the instance's own folder (below); @@ -236,9 +207,9 @@ flowchart LR and the use case refuses it (`instanceOutsideStorages`) with nothing sent there. - **Removing an instance from the list** (`detachInstance`) removes its - registrations of every class from both type indexes, as deleting does - (below), and nothing else. It reads and writes only those indexes, so - no request goes to the instance's server and it works whatever that + registrations from both type indexes, as deleting does (below), and + nothing else. It reads and writes only those indexes, so no request + goes to the instance's server and it works whatever that server answers. Each index is tried whatever the other answers, and one that's missing has nothing to remove; a failure is reported once both were tried, and the list is read again either way. @@ -258,7 +229,7 @@ flowchart LR ### Registrations -In the type index, the instance's own registration, by which Solid Memo +In the type index, the instance's one registration, by which Solid Memo finds it: ```turtle @@ -268,56 +239,35 @@ finds it: dcterms:title "Japanese study" . ``` -Beside it, one registration for each class of its data: its catalog as a -DCAT catalog, its decks, and the containers of its cards, review states -and answers. +Earlier versions of the app wrote one more for each class of the +instance's data, titled with the instance's name, so a pod may still +hold them. Each index that registered the instance got `dcat:Catalog` +naming `catalog.ttl#catalog`, `sm:Deck` naming `catalog.ttl` (both +with `solid:instance`) and `sm:Card` naming `decks/` (with +`solid:instanceContainer`). The private index alone got +`sm:ReviewState` naming `reviews/`, `sm:Answer` naming `history/` and +`sm:Deck` naming `drafts/`. Their ids start `#sm-cat-`, `#sm-deck-`, +`#sm-card-`, `#sm-review-`, `#sm-answer-` and `#sm-draft-`: ```turtle -<#sm-cat-4b7d2e> a solid:TypeRegistration ; - solid:forClass dcat:Catalog ; - solid:instance ; - dcterms:title "Japanese study" . - -<#sm-deck-1c8e40> a solid:TypeRegistration ; - solid:forClass sm:Deck ; - solid:instance ; - dcterms:title "Japanese study" . - <#sm-card-7a21d9> a solid:TypeRegistration ; solid:forClass sm:Card ; solid:instanceContainer ; dcterms:title "Japanese study" . ``` -and, in the private type index only: - -```turtle -<#sm-review-52f0b3> a solid:TypeRegistration ; - solid:forClass sm:ReviewState ; - solid:instanceContainer ; - dcterms:title "Japanese study" . - -<#sm-answer-e9d417> a solid:TypeRegistration ; - solid:forClass sm:Answer ; - solid:instanceContainer ; - dcterms:title "Japanese study" . - -<#sm-draft-3d90c6> a solid:TypeRegistration ; - solid:forClass sm:Deck ; - solid:instanceContainer ; - dcterms:title "Japanese study" . -``` - `meta.ttl` makes a container self-describing: attach-by-URL reads it to recover an instance that lost its registration. Deleting an instance, or removing it from the list, removes the `sm:Instance` registration by -its container, and each other +its container, and each one an earlier version wrote by the exact IRI it names (`catalog.ttl#catalog`, `catalog.ttl`, `decks/`, `reviews/`, `history/`, `drafts/`). None is removed by what lies under the container, where another app may have registered its own data. A registration that registers something else too keeps it, and -loses only the link to the instance's data. +loses only the link to the instance's data. Those an earlier version +wrote for an instance whose `sm:Instance` registration went some other +way stay, as the app no longer lists that instance to remove. ## Instance layout @@ -867,8 +817,9 @@ graph LR An instance's URL is permanent, and so is every document's and subject's in it: the [format update](migrations.md#the-pod-migration) and the library upgrade write the documents where they are, and change no -registration (the format update only adds those missing). Another app -may keep a link to the instance, a deck, a card or a review state. +registration: the format update writes nothing outside the instance. +Another app may keep a link to the instance, a deck, a card or a review +state. ## The catalog @@ -906,19 +857,19 @@ limit holds for its links to drafts and releases The catalog is written when an instance is created, and by the [format update](migrations.md) for an instance made before there were catalogs; -its registration as the [Discovery chain](#discovery-chain) says. The -whole document conforms to DCAT-AP, a course's deck typed -`schema:Course` too ([Courses](#courses)), and a test holds what the app -writes to it. +it has no registration of its own ([Discovery +chain](#discovery-chain)). The whole document conforms to DCAT-AP, a +course's deck typed `schema:Course` too ([Courses](#courses)), and a +test holds what the app writes to it. The instance's name is in the meta document's `dcterms:title`, the -catalog's `dcterms:title`, and the `dcterms:title` of every registration -of the instance's data in each type index (its `sm:Instance` one names -it in the instance list). Renaming it in the Studio (`renameInstance`, -[studio.md](studio.md#the-instance)) writes them all, each document -read and written again on a 412. A catalog description the old name -gave (`defaultCatalogDescription`) follows the new name; one the user -wrote stays. +catalog's `dcterms:title`, and the `dcterms:title` of its registrations +in each type index. Its `sm:Instance` one names it in the instance +list, beside any an earlier version wrote for its data. Renaming it in +the Studio (`renameInstance`, [studio.md](studio.md#the-instance)) +writes them all, each document read and written again on a 412. A +catalog description the old name gave (`defaultCatalogDescription`) +follows the new name; one the user wrote stays. A deck's description, topics and keywords are edited in its Browser ("Describe deck") and on its about screen in the Studio, the keywords as diff --git a/docs/guest-mode.md b/docs/guest-mode.md index 184e7dc55..89e91499a 100644 --- a/docs/guest-mode.md +++ b/docs/guest-mode.md @@ -206,8 +206,8 @@ It runs these steps: guest's pod, in any spelling of its origin (`guestDeckUrlsLeft`, which names the deck, so it can be left out). Text the reader reads, such as titles, card text and notes, may name any address and is copied - as it is. Nothing is registered: the instance's registered catalog - and folders hold the added decks. + as it is. Nothing is registered: the instance's catalog and folders + hold the added decks. 3. **arrange:** each of the guest's deck groups becomes a new group (`#group-`) with its name, holding the added decks and new groups it held, an empty one empty. They go, in the guest's order, @@ -309,12 +309,10 @@ instance goes). It runs these steps: other open tabs too. 6. **register:** `attachInstance` in the type index the user chose. From here on the study is theirs. -7. **tidy:** register each class of the instance's data (its - catalog, decks, cards, review states, answers and drafts, - [data-model.md](data-model.md#discovery-chain)), then delete as the - merge's **tidy** does. A registration that fails is left for - **Register what is missing** in Preferences; the delete goes ahead - anyway. +7. **tidy:** delete as the merge's **tidy** does. Nothing more is + registered: the instance's `sm:Instance` registration leads to the + rest of its data + ([data-model.md](data-model.md#discovery-chain)). A failure before **register** deletes the copy, whole, and leaves the guest's study as it was. The target's container, created where nothing diff --git a/docs/migrations.md b/docs/migrations.md index e51c3d19f..cce1907ea 100644 --- a/docs/migrations.md +++ b/docs/migrations.md @@ -491,10 +491,10 @@ the shape it moves to. The format update brings each outdated document up to this app's formats on its own, where it is, in one write the pod makes whole or not at all. No document, subject or folder changes its address, so bookmarks and -other apps' links still hold, and no type index registration changes; -only missing ones are added. No copy or backup is kept. A document that -can't be updated now is left as it is, readable, and the update goes on; -run again, it updates what's still outdated +other apps' links still hold. Nothing outside the instance is written: +no type index registration is added or changed. No copy or backup is +kept. A document that can't be updated now is left as it is, readable, +and the update goes on; run again, it updates what's still outdated ([instanceUpdate.ts](../packages/domain/src/instanceUpdate.ts), `updateInstance` in [instances.ts](../packages/application/src/instances.ts)). @@ -502,7 +502,7 @@ run again, it updates what's still outdated ```mermaid flowchart TD accTitle: Updating an instance - accDescr: Once the learner confirms, the update reads what is outdated, writes one document after another, and registers what the type index lacks once nothing failed. + accDescr: Once the learner confirms, the update reads what is outdated and writes one document after another, then says what is done or left. open["Instance opened"] --> plan["planMigration
read meta, preferences,
every deck's cards and reviews"] plan -->|nothing outdated| quiet["(nothing shown)"] plan -->|outdated| notice["Notice: what will change"] @@ -512,8 +512,7 @@ flowchart TD write -->|written| updated["Updated"] write -->|refused or failed| left["Left as it is,
named with why"] updated & left -->|the next document| write - updated & left -->|none left| register["3 register: once nothing failed,
the registrations missing
from the type index"] - register --> done["Done; or what is left, and why:
Try again updates only that"] + updated & left -->|none left| done["Done; or what is left, and why:
Try again updates only that"] ``` - **One document at a time is enough.** No fact of the update spans @@ -581,15 +580,13 @@ flowchart TD catalog of the decks, or a deck's cards or review states — with why, and how many it did update. **Try again** plans afresh and writes only what's still outdated. Closed, the notice shows what's left. -- **Then the registrations.** Once nothing failed, the update adds each - registration by class that belongs in a type index and is missing - there ([data-model.md](data-model.md#discovery-chain)), in one write - per index, with `If-Match`. An instance made before there were such - registrations gains them; existing ones are left as they are. This - is the one write outside the instance; a failure there leaves the - update done, and **Register what is missing** in Preferences adds them - later. An update that left a document adds none: it would register - a catalog it couldn't write. +- **Nothing outside the instance.** The update writes no type index + registration. The instance's data is reached from its folder + ([data-model.md](data-model.md#discovery-chain)), so an instance made + before there were catalogs needs no more than its `sm:Instance` + registration once updated. Earlier versions also added a + registration per class of the instance's data once nothing failed; + those already in a pod are left as they are. - **A closed tab** part-way leaves an instance that works, and its next opening offers the rest. Nothing is noted in the browser. - **Other tabs, other devices.** Nothing is held while the update runs. @@ -627,10 +624,8 @@ file and shared access, and checks that: - each outdated document is written once, in order, held to the version it was read at (`If-Match` the ETag of its last read where the server - enforces it, else right after a read of it). The last write is one - edit of the type index (with `If-Match` where enforced), which keeps - every triple and adds the registrations of the instance's catalog, - decks, cards, review states and answers. No access control is + enforces it, else right after a read of it). None goes to the type + index, which is served byte for byte as before. No access control is written, and no resource comes or goes; - the instance is updated, conforms and has nothing left to update, its decks and documents at the addresses they had; the other app's triple, diff --git a/docs/privacy.md b/docs/privacy.md index cb68f81e3..2e3489628 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -24,8 +24,8 @@ release or a card's picture, as they are, and never fetches them, in the pod or not. The pod's server decides who else may read them. Solid Memo registers -the review states, the answers and the drafts only in the learner's -**private** type index +only the instance in a type index, the **private** one unless the +learner chooses the public one, and none of its data by itself ([data-model.md](data-model.md#discovery-chain)). Only a release the learner publishes from the Studio is public; it carries the authors' names, and their email addresses when given. diff --git a/docs/standard-for-public-code.md b/docs/standard-for-public-code.md index 71e20f061..0c52d042c 100644 --- a/docs/standard-for-public-code.md +++ b/docs/standard-for-public-code.md @@ -500,9 +500,9 @@ They need checking again before anything relies on them. - Undoing an answer, resetting the day, reporting a mistake, updating a library copy, a deck's link and the error screen each have a journey. - - A release's themes, attaching an instance by its address and - "Findable by other apps" have neither a journey nor a worked - example. No journey shows text written right to left. + - A release's themes and attaching an instance by its address have + neither a journey nor a worked example. No journey shows text + written right to left. - SHOULD, met — the components and their relations, in [architecture.md](architecture.md). - SHOULD, met — CI checks the docs: markdownlint, cspell, lychee and the diff --git a/docs/studio.md b/docs/studio.md index 1547e0578..f48691be1 100644 --- a/docs/studio.md +++ b/docs/studio.md @@ -1243,7 +1243,8 @@ shows where the instance is, links to its study preferences in Solid Memo, and has two forms: - **Name**: `renameInstance` writes it everywhere it's kept: the meta - document, the catalog and the type index registrations + document, the catalog and the instance's type index registrations, + with any an earlier version wrote for its data ([data-model.md](data-model.md#the-catalog)), and the instances are read afresh. An empty name is refused. - **Catalog**: its description, required, and its license, from the @@ -1484,8 +1485,8 @@ are read afresh. The deck is written as any deck Solid Memo writes: wrong options named with `sm:distractor` and `schema:suggestedAnswer`, states naming their -card in the new deck. The instance's type-index registrations of its -decks, cards and review states cover it (`data-model.md`). While the data +card in the new deck. It needs no type-index registration: the +instance's own leads to it (`data-model.md`). While the data check is under way, or the catalog is set aside, the import is held ([below](#data-set-aside)); a file can still be read. @@ -1508,7 +1509,7 @@ instance, before publishing it. The drafts screen the instance's drafts, each with its kind (deck or course), the version it drafts and whether it's being written, was released, or can't be read. One is deleted, with its documents, once the user confirms. A -draft is private, in the private type index alone +draft is registered in no type index ([data-model.md](data-model.md#drafts-and-releases)). The drafts are read a few at a time (`mapAtOnce` in diff --git a/docs/user-guide.md b/docs/user-guide.md index ee12dc946..60c157bc7 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -358,11 +358,6 @@ Press **Save**. A deck's own preferences can set daily limits for that deck, where an empty field follows these, and show its cards in another language ([Languages](#languages)). -Under the settings (not for a guest), **Findable by other apps** shows -whether other Solid apps can find your decks and study through your -Pod's type indexes, and registers what's missing when you ask -([data-model.md](data-model.md#discovery-chain)). - ## Languages Solid Memo speaks English, Swedish, Korean, German, Spanish and French. diff --git a/docs/vocab.md b/docs/vocab.md index 9988ee80e..e5bb9e592 100644 --- a/docs/vocab.md +++ b/docs/vocab.md @@ -391,8 +391,8 @@ SM-2 ones ([#191](https://github.com/solid-memo/website/issues/191)): - **An FSRS-6 state is a review state.** It reuses the class and `sm:due`, `sm:lastReviewedAt`, `sm:reviewOf` and `sm:scheduler` - (frozen domain `sm:ReviewState`), and the reviews document's type - index registration. `sm:scheduler sm:fsrs6`, which its shape fixes, + (frozen domain `sm:ReviewState`) and the reviews document. + `sm:scheduler sm:fsrs6`, which its shape fixes, tells it from an SM-2 state ([shapes.md](shapes.md#conventions)). Its snapshot reuses `sm:previousDue` and `sm:previousLastReviewedAt`; SM-2's `sm:easeFactor` and `sm:repetitions` aren't reused. diff --git a/e2e/pod/servers/jss/expected-failures.json b/e2e/pod/servers/jss/expected-failures.json index ae0acc318..4b70c6eac 100644 --- a/e2e/pod/servers/jss/expected-failures.json +++ b/e2e/pod/servers/jss/expected-failures.json @@ -75,7 +75,7 @@ "typeIndex.integration.test.ts > links it from an extended profile when the WebID document is read-only": "jss-acl-turtle", "typeIndex.integration.test.ts > makes a type index in the instance's storage, linked from a WebID document that has none": "jss-no-extension", "typeIndex.integration.test.ts > refuses, saying where it tried, and keeps no instance, when no profile document can be written": "jss-acl-turtle", - "typeIndex.integration.test.ts > registers an instance publicly, its review states, answers and drafts in the private index alone, and unregisters it from both": "jss-no-extension", - "typeIndex.integration.test.ts > registers each class of the instance's data in it, where Solid Memo still finds the instance by its own": "jss-no-extension", - "typeIndex.integration.test.ts > says a registration the user removed is missing, and adds it back only when asked": "jss-no-extension" + "typeIndex.integration.test.ts > registers an instance publicly alone, and removes it and an earlier version's registrations of its data from both indexes when deleted": "jss-no-extension", + "typeIndex.integration.test.ts > registers the instance alone in it, by which Solid Memo finds it, and unregisters it when deleted": "jss-no-extension", + "typeIndex.integration.test.ts > renames and removes the registrations an earlier version wrote for each class of the instance's data, and keeps another app's": "jss-no-extension" } diff --git a/e2e/pod/servers/nextcloud/expected-failures.json b/e2e/pod/servers/nextcloud/expected-failures.json index 85e74b4d3..77a2f8047 100644 --- a/e2e/pod/servers/nextcloud/expected-failures.json +++ b/e2e/pod/servers/nextcloud/expected-failures.json @@ -23,7 +23,7 @@ "typeIndex.integration.test.ts > links it from an extended profile when the WebID document is read-only": "nc-reported-privately", "typeIndex.integration.test.ts > makes a type index in the instance's storage, linked from a WebID document that has none": "nc-put-type", "typeIndex.integration.test.ts > refuses, saying where it tried, and keeps no instance, when no profile document can be written": "nc-reported-privately", - "typeIndex.integration.test.ts > registers an instance publicly, its review states, answers and drafts in the private index alone, and unregisters it from both": "nc-put-type", - "typeIndex.integration.test.ts > registers each class of the instance's data in it, where Solid Memo still finds the instance by its own": "nc-put-type", - "typeIndex.integration.test.ts > says a registration the user removed is missing, and adds it back only when asked": "nc-put-type" + "typeIndex.integration.test.ts > registers an instance publicly alone, and removes it and an earlier version's registrations of its data from both indexes when deleted": "nc-put-type", + "typeIndex.integration.test.ts > registers the instance alone in it, by which Solid Memo finds it, and unregisters it when deleted": "nc-put-type", + "typeIndex.integration.test.ts > renames and removes the registrations an earlier version wrote for each class of the instance's data, and keeps another app's": "nc-put-type" } diff --git a/e2e/pod/src/foreignData.integration.test.ts b/e2e/pod/src/foreignData.integration.test.ts index 2dc67568c..26a426497 100644 --- a/e2e/pod/src/foreignData.integration.test.ts +++ b/e2e/pod/src/foreignData.integration.test.ts @@ -197,7 +197,7 @@ describe.each(SERVERS)("what another app wrote, on $name", ({ url: server }) => expect(await status(url), url).toBe(404); } expect(rivers.cardsDocumentUrl.startsWith(`${instance.url}decks/`)).toBe(true); - // Unregistered, the catalog too. + // Unregistered. expect(await page().listInstances(session)).toEqual([]); const index = await triples(typeIndex); expect(index).not.toContain(instance.url); diff --git a/e2e/pod/src/instanceUpdate.integration.test.ts b/e2e/pod/src/instanceUpdate.integration.test.ts index 92597562c..3fcf35828 100644 --- a/e2e/pod/src/instanceUpdate.integration.test.ts +++ b/e2e/pod/src/instanceUpdate.integration.test.ts @@ -45,7 +45,6 @@ import { } from "./serverTraits"; const SERVERS = inject("solidServers"); -const SM = "https://solid-memo.com/ns/vocab/v1.ttl#"; const READS = new Set(["GET", "HEAD", "OPTIONS"]); const PREFIXES = `@prefix sm: . @prefix dcterms: . @@ -557,10 +556,6 @@ async function triples(url: string): Promise { ); } -async function registeredContainers(pod: Pod): Promise { - return triples(pod.typeIndex); -} - /** The documents of the seeded pod the update changes, in the order it writes them. */ const UPDATED = [ "meta.ttl", @@ -594,7 +589,7 @@ describe.each(SERVERS)("the format update on $name", ({ url: server }) => { const pod = await seedPod(server); const documents = UPDATED.map((path) => `${pod.source}${path}`); const before = await snapshot(pod.source); - const indexBefore = await registeredContainers(pod); + const indexBefore = await servedBytes([pod.typeIndex]); const { useCases, attempts, sent, session } = app(pod); expect((await useCases.planMigration(pod.source)).deckCount).toBe(1); @@ -604,12 +599,9 @@ describe.each(SERVERS)("the format update on $name", ({ url: server }) => { failed: [], }); - // Every write went to one of the documents, once each, in order, but the last: the type index, once updated, - // given the registrations of the instance's data it lacked, If-Match where the server enforces it. + // Every write went to one of the documents, once each, in order; none to the type index. const writes = sent.filter(isWrite); - expect(writes.map((request) => request.url)).toEqual([...documents, pod.typeIndex]); - expect(writes.at(-1)).toMatchObject({ method: "PATCH", url: pod.typeIndex }); - if (conditional.edits) expect(writes.at(-1)!.ifMatch).toMatch(/^"/); + expect(writes.map((request) => request.url)).toEqual(documents); // Each write of a document held to the version it was read at. expectConditional(sent, documents, conditional); // Nothing is kept beside the documents: no copy, no backup. @@ -618,29 +610,8 @@ describe.each(SERVERS)("the format update on $name", ({ url: server }) => { writes.map((request) => request.url), ); - // The instance: updated, conforming, at its address; the type index as it was, and each class of its data registered. - const indexAfter = await registeredContainers(pod); - for (const triple of indexBefore.split("\n").filter((line) => line !== "")) - expect(indexAfter).toContain(triple); - for (const [forClass, predicate, target] of [ - ["http://www.w3.org/ns/dcat#Catalog", "instance", `${pod.source}catalog.ttl#catalog`], - [`${SM}Deck`, "instance", `${pod.source}catalog.ttl`], - [`${SM}Card`, "instanceContainer", `${pod.source}decks/`], - [`${SM}ReviewState`, "instanceContainer", `${pod.source}reviews/`], - [`${SM}Answer`, "instanceContainer", `${pod.source}history/`], - ]) { - // Found as text, not as a pattern, whose dots would take any character for those of the IRIs. - const registration = ` <${forClass}> .`; - const subject = indexAfter - .split("\n") - .find((line) => line.startsWith("<") && line.endsWith(registration)) - ?.slice(0, -registration.length); - expect(subject, forClass).toBeDefined(); - expect(indexAfter).toContain( - `${subject} <${target}> .`, - ); - expect(indexAfter).toContain(`${subject} "Main" .`); - } + // The instance: updated, conforming, at its address; the type index byte for byte as it was. + expect(await servedBytes([pod.typeIndex])).toEqual(indexBefore); expect(await useCases.planMigration(pod.source)).toMatchObject({ deckCount: 0, cardCount: 0, @@ -829,10 +800,8 @@ describe.each(SERVERS)("the format update on $name", ({ url: server }) => { updated: [{ url: seeded.cards }], failed: [], }); - expect(second.sent.filter(isWrite).map((request) => request.url)).toEqual([ - seeded.cards, - seeded.pod.typeIndex, - ]); + expect(second.sent.filter(isWrite).map((request) => request.url)).toEqual([seeded.cards]); + expect(await servedBytes([seeded.pod.typeIndex])).toEqual(seeded.index); expect(await second.useCases.planMigration(seeded.pod.source)).toMatchObject({ deckCount: 0, cardCount: 0, diff --git a/e2e/pod/src/metadata.integration.test.ts b/e2e/pod/src/metadata.integration.test.ts index 06d557644..c749df15c 100644 --- a/e2e/pod/src/metadata.integration.test.ts +++ b/e2e/pod/src/metadata.integration.test.ts @@ -7,7 +7,7 @@ /** * The Studio's edits of metadata against a real Solid server: an * instance renamed everywhere its name is kept (its meta document, its - * catalog and both of its type index registrations), its catalog + * catalog and its type index registration), its catalog * described, a deck given authors and a license, and a course's * chapters completed changed. Every write is checked against the shapes, * and the instance stays valid. A change made elsewhere meanwhile is @@ -213,18 +213,18 @@ describe.each(SERVERS)("metadata on $name", ({ url: server }) => { everyEdit = await etagMarksEveryEdit(server); }); - it("renames an instance in its meta document, its catalog and every type index registration of its data", async () => { + it("renames an instance in its meta document, its catalog and its type index registration", async () => { const { session, instance, typeIndex } = await seed(server); const useCases = page(); - // One registration for each class of the instance's data, all in the private index. - expect(await titlesIn(typeIndex)).toEqual(Array(7).fill("Main")); + // The instance's registration alone, in the private index. + expect(await titlesIn(typeIndex)).toEqual(["Main"]); await expect(useCases.renameInstance(session, instance, " Languages ")).resolves.toEqual({ ...instance, name: "Languages", }); - expect(await titlesIn(typeIndex)).toEqual(Array(7).fill("Languages")); + expect(await titlesIn(typeIndex)).toEqual(["Languages"]); await expect(useCases.listInstances(session)).resolves.toEqual([ { ...instance, name: "Languages" }, ]); diff --git a/e2e/pod/src/typeIndex.integration.test.ts b/e2e/pod/src/typeIndex.integration.test.ts index 0116a8c1a..551bdcd00 100644 --- a/e2e/pod/src/typeIndex.integration.test.ts +++ b/e2e/pod/src/typeIndex.integration.test.ts @@ -10,18 +10,20 @@ * made in the storage the instance is in (not at the server's root, which * may be another pod's), linked from the WebID document, or, where that * cannot be written (as on Inrupt PodSpaces), from an extended profile it - * names. Beside the instance, each class of its data is registered, its - * review states, answers and drafts in the private index alone, read back from - * the index as written, and every registration goes when the instance is - * deleted. Runs against each server globalSetup.ts starts. Every run here - * shares the storage's one type index; a file's tests run one at a time. + * names. The instance alone is registered, by which Solid Memo finds it; + * its data gets no registration of its own. The registrations earlier + * versions wrote for each class of its data, which an index may still + * hold, take the instance's new name with it and go when the instance is + * removed, while another app's stay. Runs against each server + * globalSetup.ts starts. Every run here shares the storage's one type + * index; a file's tests run one at a time. */ import { Parser, Writer } from "n3"; import { describe, expect, inject, it } from "vitest"; import { SHAPE_SOURCES, shapesFetch } from "@solid-memo/vocab/tooling/testing/shapeSources"; import { createShaclShapeValidator } from "@solid-memo/solid/shaclShapeValidator"; import { createSolidInstanceRepository } from "@solid-memo/solid/solidInstanceRepository"; -import { aclOf, ETAG_OUTLIVES_EDITS, etagMarksEveryEdit, storageOf } from "./serverTraits"; +import { aclOf, storageOf } from "./serverTraits"; const SERVERS = inject("solidServers"); const PRIVATE_TYPE_INDEX = "http://www.w3.org/ns/solid/terms#privateTypeIndex"; @@ -140,11 +142,11 @@ async function registrationsIn(index: string, container: string) { .sort((a, b) => a.target.localeCompare(b.target)); } +/** The classes of an instance's data earlier versions registered, beside the instance itself. */ +type DataClass = "instance" | "catalog" | "deck" | "card" | "reviewState" | "answer" | "draft"; + /** What each class of an instance's data is registered as, in the order registrationsIn sorts them. */ -function registered( - container: string, - classes: ("instance" | "catalog" | "deck" | "card" | "reviewState" | "answer" | "draft")[], -) { +function registered(container: string, classes: DataClass[], title = "Main") { const all = { instance: { forClass: [`${SM}Instance`], predicate: "instanceContainer", target: container }, catalog: { @@ -171,10 +173,62 @@ function registered( }, }; return classes - .map((dataClass) => ({ ...all[dataClass], subject: expect.any(String), title: ["Main"] })) + .map((dataClass) => ({ ...all[dataClass], subject: expect.any(String), title: [title] })) .sort((a, b) => a.target.localeCompare(b.target)); } +/** The id prefix earlier versions gave each class's registration. */ +const LEGACY_PREFIX: Record, string> = { + catalog: "sm-cat", + deck: "sm-deck", + card: "sm-card", + reviewState: "sm-review", + answer: "sm-answer", + draft: "sm-draft", +}; + +/** + * Add to a type index the registrations an earlier version wrote for + * classes of the instance's data, titled "Main", as it wrote them; and, + * when given, another app's registration of a folder of its own inside the + * instance's container, which is not the instance's data. + */ +async function seedLegacyRegistrations( + index: string, + container: string, + classes: Exclude[], + foreignFolder?: string, +): Promise { + const statements = classes.map((dataClass) => { + const [registration] = registered(container, [dataClass]); + const subject = `<${index}#${LEGACY_PREFIX[dataClass]}-${crypto.randomUUID()}>`; + // A statement each: Solid-Nextcloud stores ` a ;

` as `

`. + return [ + `${subject} <${SOLID_TERMS}TypeRegistration> .`, + `${subject} <${SOLID_TERMS}forClass> <${registration!.forClass[0]}> .`, + `${subject} <${SOLID_TERMS}${registration!.predicate}> <${registration!.target}> .`, + `${subject} "Main" .`, + ].join("\n"); + }); + if (foreignFolder !== undefined) { + const subject = `<${index}#other-app>`; + statements.push( + [ + `${subject} <${SOLID_TERMS}TypeRegistration> .`, + `${subject} <${SOLID_TERMS}forClass> <${SM}Card> .`, + `${subject} <${SOLID_TERMS}instanceContainer> <${foreignFolder}> .`, + `${subject} "Other" .`, + ].join("\n"), + ); + } + const insertion = await fetch(index, { + method: "PATCH", + headers: { "content-type": "application/sparql-update" }, + body: `INSERT DATA { ${statements.join("\n")} }`, + }); + expect(insertion.ok, `PATCH ${index}: ${insertion.status}`).toBe(true); +} + describe.each(SERVERS)("registering an instance on $name", ({ url: server }) => { it("makes a type index in the instance's storage, linked from a WebID document that has none", async () => { const { base, card, webId } = await profile(server); @@ -194,7 +248,7 @@ describe.each(SERVERS)("registering an instance on $name", ({ url: server }) => ); }); - it("registers each class of the instance's data in it, where Solid Memo still finds the instance by its own", async () => { + it("registers the instance alone in it, by which Solid Memo finds it, and unregisters it when deleted", async () => { const { base, webId } = await profile(server); const repository = instances(); @@ -206,27 +260,21 @@ describe.each(SERVERS)("registering an instance on $name", ({ url: server }) => }); const index = `${await storageOf(base)}settings/privateTypeIndex.ttl`; - const all = ["instance", "catalog", "deck", "card", "reviewState", "answer", "draft"] as const; - expect(await registrationsIn(index, instance.url)).toEqual(registered(instance.url, [...all])); + expect(await registrationsIn(index, instance.url)).toEqual( + registered(instance.url, ["instance"]), + ); expect(await repository.listInstances(webId)).toContainEqual({ url: instance.url, name: "Main", }); - expect( - await repository.readDataClassRegistrations({ webId, instanceUrl: instance.url }), - ).toEqual({ - registrations: all.map((dataClass) => ({ dataClass, index: "private", registered: true })), - privateIndexMissing: false, - unreadableIndexes: [], - }); - // Deleting the instance removes every one of them. await repository.deleteInstance({ webId, instance }); expect(await registrationsIn(index, instance.url)).toEqual([]); }); - it("says a registration the user removed is missing, and adds it back only when asked", async (context) => { - const { base, webId } = await profile(server); + it("renames and removes the registrations an earlier version wrote for each class of the instance's data, and keeps another app's", async () => { + const { base, webId } = await profile(server, () => [], { privateIndex: true }); + const index = `${base}settings/privateTypeIndex.ttl`; const repository = instances(); const instance = await repository.createInstance({ webId, @@ -234,37 +282,38 @@ describe.each(SERVERS)("registering an instance on $name", ({ url: server }) => name: "Main", registrationTarget: "private", }); - const index = `${await storageOf(base)}settings/privateTypeIndex.ttl`; - // The removal is made in the same second as the index was read, which such a server's ETag does not tell apart. - if (!(await etagMarksEveryEdit(server))) context.skip(ETAG_OUTLIVES_EDITS); - const all = ["instance", "catalog", "deck", "card", "reviewState", "answer", "draft"] as const; - - const cards = (await registrationsIn(index, instance.url)).find((registration) => - registration.target.endsWith("decks/"), - )!; - const removal = await fetch(index, { - method: "PATCH", - headers: { "content-type": "application/sparql-update" }, - body: `DELETE DATA { <${cards.subject}> <${SOLID_TERMS}TypeRegistration> ; <${SOLID_TERMS}forClass> <${SM}Card> ; - <${SOLID_TERMS}instanceContainer> <${cards.target}> ; "Main" . }`, + const legacy = ["catalog", "deck", "card", "reviewState", "answer", "draft"] as const; + const foreign = { + subject: `${index}#other-app`, + forClass: [`${SM}Card`], + predicate: "instanceContainer", + target: `${instance.url}other-app/`, + title: ["Other"], + }; + await seedLegacyRegistrations(index, instance.url, [...legacy], foreign.target); + const all = [...registered(instance.url, ["instance", ...legacy]), foreign].sort((a, b) => + a.target.localeCompare(b.target), + ); + expect(await registrationsIn(index, instance.url)).toEqual(all); + expect(await repository.listInstances(webId)).toContainEqual({ + url: instance.url, + name: "Main", }); - expect(removal.ok, `PATCH ${index}: ${removal.status}`).toBe(true); + + // Renamed, the instance's registrations take the new name, earlier versions' too; another app's keeps its own. + await repository.renameRegistrations({ webId, instanceUrl: instance.url, title: "Renamed" }); expect(await registrationsIn(index, instance.url)).toEqual( - registered(instance.url, ["instance", "catalog", "deck", "reviewState", "answer", "draft"]), + [...registered(instance.url, ["instance", ...legacy], "Renamed"), foreign].sort((a, b) => + a.target.localeCompare(b.target), + ), ); - expect( - (await repository.readDataClassRegistrations({ webId, instanceUrl: instance.url })) - .registrations, - ).toContainEqual({ - dataClass: "card", - index: "private", - registered: false, - }); - await repository.registerDataClasses({ webId, instanceUrl: instance.url, title: "Main" }); - expect(await registrationsIn(index, instance.url)).toEqual(registered(instance.url, [...all])); + + // Taken off the list, every one of the instance's goes; another app's stays. + await repository.detachInstance({ webId, instance }); + expect(await registrationsIn(index, instance.url)).toEqual([foreign]); }); - it("registers an instance publicly, its review states, answers and drafts in the private index alone, and unregisters it from both", async () => { + it("registers an instance publicly alone, and removes it and an earlier version's registrations of its data from both indexes when deleted", async () => { const { base, card, webId } = await profile(server, () => [], { privateIndex: true }); const privateIndex = `${base}settings/privateTypeIndex.ttl`; const repository = instances(); @@ -281,16 +330,24 @@ describe.each(SERVERS)("registering an instance on $name", ({ url: server }) => `<${webId}> <${SOLID_TERMS}publicTypeIndex> <${publicIndex}> .`, ); expect(await registrationsIn(publicIndex, instance.url)).toEqual( - registered(instance.url, ["instance", "catalog", "deck", "card"]), - ); - expect(await registrationsIn(privateIndex, instance.url)).toEqual( - registered(instance.url, ["reviewState", "answer", "draft"]), + registered(instance.url, ["instance"]), ); + expect(await registrationsIn(privateIndex, instance.url)).toEqual([]); expect(await repository.listInstances(webId)).toContainEqual({ url: instance.url, name: "Main", }); + // As an earlier version registered a public instance's data: its review states, answers and drafts privately. + await seedLegacyRegistrations(publicIndex, instance.url, ["catalog", "deck", "card"]); + await seedLegacyRegistrations(privateIndex, instance.url, ["reviewState", "answer", "draft"]); + expect(await registrationsIn(publicIndex, instance.url)).toEqual( + registered(instance.url, ["instance", "catalog", "deck", "card"]), + ); + expect(await registrationsIn(privateIndex, instance.url)).toEqual( + registered(instance.url, ["reviewState", "answer", "draft"]), + ); + await repository.deleteInstance({ webId, instance }); expect(await registrationsIn(publicIndex, instance.url)).toEqual([]); expect(await registrationsIn(privateIndex, instance.url)).toEqual([]); diff --git a/packages/application/src/instances.test.ts b/packages/application/src/instances.test.ts index 2d39da2e6..7b6377a83 100644 --- a/packages/application/src/instances.test.ts +++ b/packages/application/src/instances.test.ts @@ -198,29 +198,6 @@ describe("instances", () => { expect(deps.instanceRepository.detachInstance).toHaveBeenCalledTimes(2); }); - it("dataClassRegistrations and registerDataClasses pass the WebID and the instance, titled with its name", async () => { - const deps = makeDeps(); - const registrations = { - registrations: [{ dataClass: "deck" as const, index: "private" as const, registered: false }], - privateIndexMissing: false, - unreadableIndexes: [], - }; - vi.mocked(deps.instanceRepository.readDataClassRegistrations).mockResolvedValue(registrations); - const useCases = createUseCases(deps); - await expect(useCases.dataClassRegistrations(session, instance)).resolves.toEqual( - registrations, - ); - expect(deps.instanceRepository.readDataClassRegistrations).toHaveBeenCalledWith({ - webId: session.webId, - instanceUrl: instance.url, - }); - await useCases.registerDataClasses(session, instance); - expect(deps.instanceRepository.registerDataClasses).toHaveBeenCalledWith({ - webId: session.webId, - instanceUrl: instance.url, - title: instance.name, - }); - }); describe("format migration", () => { const other: Deck = { ...deck, @@ -359,7 +336,7 @@ describe("instances", () => { { url: CATALOG, holds: "catalog" }, ]; - it("updateInstance brings each outdated document up to date on its own, one after another, then adds the missing registrations", async () => { + it("updateInstance brings each outdated document up to date on its own, one after another, and registers nothing", async () => { const { deps, written } = outdated(); const progress: string[] = []; const outcome = await createUseCases(deps).updateInstance(session, instance, (p) => @@ -380,22 +357,18 @@ describe("instances", () => { expect(deps.instanceCopier.copyResource).not.toHaveBeenCalled(); expect(deps.shapeValidator.validateDocument).not.toHaveBeenCalled(); expect(deps.updateJournal.begin).not.toHaveBeenCalled(); - // Once updated, the registrations of its data that are missing are added. - expect(deps.instanceRepository.registerDataClasses).toHaveBeenCalledExactlyOnceWith({ - webId: session.webId, - instanceUrl: instance.url, - title: instance.name, - }); + // Nothing outside the instance: no type index registration is added. + expect(deps.instanceRepository.attachInstance).not.toHaveBeenCalled(); + expect(deps.instanceRepository.renameRegistrations).not.toHaveBeenCalled(); expect(progress).toEqual([ - "read 0/3", - "write 1/3 (0 of 5)", - ...[1, 2, 3, 4].map((n) => `write 1/3 (${n} of 5)`), - "register 2/3", - "register 3/3", + "read 0/2", + "write 1/2 (0 of 5)", + ...[1, 2, 3, 4].map((n) => `write 1/2 (${n} of 5)`), + "write 2/2", ]); }); - it("updateInstance goes on past a document it cannot update, naming it with why, and adds no registration until all are", async () => { + it("updateInstance goes on past a document it cannot update, naming it with why", async () => { const { deps, written } = outdated(); const refused = new AppError("changedElsewhere", { url: CARDS }); const offline = new TypeError("Failed to fetch"); @@ -411,7 +384,6 @@ describe("instances", () => { }); // Nothing is put back: each document written stays written, each failed one as it was. expect(written).toEqual([META, REVIEWS, CATALOG]); - expect(deps.instanceRepository.registerDataClasses).not.toHaveBeenCalled(); }); it("updateInstance runs again on what is still outdated, as it finds it then, and finishes", async () => { @@ -435,7 +407,6 @@ describe("instances", () => { }); expect(deps.instanceRepository.upgradeMeta).not.toHaveBeenCalled(); expect(deps.deckRepository.upgradeDecks).not.toHaveBeenCalled(); - expect(deps.instanceRepository.registerDataClasses).toHaveBeenCalledOnce(); }); it("updateInstance counts a document brought up to date meanwhile, elsewhere, as neither updated nor failed", async () => { @@ -446,7 +417,7 @@ describe("instances", () => { updated: [UPDATED[1], UPDATED[3], UPDATED[4]], failed: [], }); - // With nothing outdated left at all, it writes nothing, and adds the missing registrations. + // With nothing outdated left at all, it writes nothing. const done = makeDeps(); vi.mocked(done.deckRepository.listCards).mockResolvedValue([current("a")]); const progress: string[] = []; @@ -459,8 +430,7 @@ describe("instances", () => { failed: [], }); expect(done.deckRepository.upgradeCards).not.toHaveBeenCalled(); - expect(done.instanceRepository.registerDataClasses).toHaveBeenCalledOnce(); - expect(progress).toEqual(["read ", "write ", "register ", "register "]); + expect(progress).toEqual(["read ", "write ", "write "]); }); it("updateInstance writes a document two decks share as one, each deck's part in turn, failed when either part fails", async () => { @@ -555,17 +525,6 @@ describe("instances", () => { expect(deps.instanceRepository.upgradeMeta).not.toHaveBeenCalled(); expect(deps.deckRepository.upgradeCards).not.toHaveBeenCalled(); }); - - it("updateInstance is done even when the registrations of the instance's data cannot be added", async () => { - const { deps } = outdated(); - vi.mocked(deps.instanceRepository.registerDataClasses).mockRejectedValue( - new Error("index refused"), - ); - expect(await createUseCases(deps).updateInstance(session, instance)).toEqual({ - updated: UPDATED, - failed: [], - }); - }); }); describe("renameInstance", () => { const meta = { name: "Main", createdAt: "2026-09-21T10:00:00.000Z", formatVersion: 2 }; diff --git a/packages/application/src/instances.ts b/packages/application/src/instances.ts index 40f956aa1..ba4bb05e5 100644 --- a/packages/application/src/instances.ts +++ b/packages/application/src/instances.ts @@ -20,7 +20,6 @@ import { } from "@solid-memo/domain/instanceUpdate"; import { instanceName, - type DataClassRegistrations, type Instance, type InstanceDeletion, type RegistrationOptions, @@ -105,31 +104,15 @@ export interface InstanceUseCases { * folder it would still hold, so it can only be deleted. */ detachInstance(session: Session, instance: Instance): Promise; - /** - * The registrations of the instance's data, one per class, that belong - * in the user's type indexes, and whether each is there - * (docs/data-model.md "Discovery chain"): what lets other apps find - * each kind of it. Reads only. - */ - dataClassRegistrations(session: Session, instance: Instance): Promise; - /** - * Add the registrations dataClassRegistrations finds missing, titled - * with the instance's name. Only on the user's say (or with a new - * instance, a format update, a guest's study kept as a new instance; - * adding one to an instance registers nothing): never on opening an - * instance, where it would add back what the user, or another app, - * removed. - */ - registerDataClasses(session: Session, instance: Instance): Promise; /** * Give an instance a new name (instanceName: trimmed, not empty) * everywhere it is kept: its meta document, its catalog's title (and * the description, while it is the one the old name gave), and its - * registrations in the type indexes, the instance's and the - * catalog's, which name it in the instance list. Each write is made - * of its document as it is then, and made again from a fresh read - * when it changed meanwhile, three times in all. The instance as - * renamed. + * registrations in the type indexes: the instance's, which names it in + * the instance list, and those of its data's classes earlier versions + * wrote. Each write is made of its document as it is then, and made + * again from a fresh read when it changed meanwhile, three times in + * all. The instance as renamed. */ renameInstance(session: Session, instance: Instance, name: string): Promise; /** The instance's catalog; null when it has none (an instance not updated yet). */ @@ -158,10 +141,10 @@ export interface InstanceUseCases { * left as it is — as it was, or updated when the answer to its write * was lost — and named with why; the others go on. Nothing is put back: * every document is readable, updated or not, and running it again - * updates what is still outdated. Once nothing failed, the - * registrations of the instance's data that are missing are added - * (registerDataClasses); a failure there leaves the update done. Throws - * when what is outdated cannot be read: nothing was written. + * updates what is still outdated. Nothing is written outside the + * instance: no type index registration is added, so none the user, or + * another app, removed comes back. Throws when what is outdated cannot + * be read: nothing was written. */ updateInstance( session: Session, @@ -393,19 +376,6 @@ export function createInstanceUseCases({ instance, }); }, - dataClassRegistrations(session, instance) { - return instanceRepository.readDataClassRegistrations({ - webId: session.webId, - instanceUrl: instance.url, - }); - }, - registerDataClasses(session, instance) { - return instanceRepository.registerDataClasses({ - webId: session.webId, - instanceUrl: instance.url, - title: instance.name, - }); - }, async renameInstance(session, instance, typed) { const name = instanceName(typed); await again(async () => { @@ -458,13 +428,6 @@ export function createInstanceUseCases({ } progress.stepped(); } - progress.finished("register"); - // Other apps find each kind of the instance's data by its class; the instance works without, and Preferences adds them later. - if (failed.length === 0) { - await instanceRepository - .registerDataClasses({ webId: session.webId, instanceUrl, title: instance.name }) - .catch(() => undefined); - } progress.finished(); return { updated, failed }; }, diff --git a/packages/application/src/ports.ts b/packages/application/src/ports.ts index 8c11d5f15..e7263ec2d 100644 --- a/packages/application/src/ports.ts +++ b/packages/application/src/ports.ts @@ -16,7 +16,6 @@ import type { CompletedChaptersEdit, CourseOutline } from "@solid-memo/domain/co import type { StatedLanguages } from "@solid-memo/domain/deckLanguages"; import type { DeckTree, DeckTreeEdit } from "@solid-memo/domain/deckTree"; import type { - DataClassRegistrations, Instance, InstanceDeletion, KeptDocuments, @@ -93,13 +92,11 @@ export interface InstanceRepository { getRegistrationOptions(webId: string): Promise; /** * Create the instance container + meta document and register it in the - * chosen type index (creating the index if needed), with its - * catalog, decks and cards; its review states and answers in the - * private index, whichever is chosen, and nowhere when there is none - * (docs/data-model.md "Discovery chain"). Fails loudly and cleans up - * the container when registration in the chosen index fails; the - * review states' and answers' registrations of an instance registered - * publicly are left out when the private index refuses them. + * chosen type index (creating the index if needed): its sm:Instance + * registration, and none for the classes of its data, which are + * reached from the instance (docs/data-model.md "Discovery chain"). + * Fails loudly and cleans up the container when the registration + * fails. */ createInstance(args: { webId: string; @@ -134,29 +131,11 @@ export interface InstanceRepository { * as it is, to attach again by its address. */ detachInstance(args: { webId: string; instance: Instance }): Promise; - /** - * The registrations of the instance's data, one per class, that - * belong in the type indexes there are, and whether each is there: - * Solid Memo's own, the catalog's, the decks' and the cards' in each - * index that registers the instance, the review states' and answers' - * in the private index only. An index that cannot be read is left - * out, and said to be unreadable. - */ - readDataClassRegistrations(args: { - webId: string; - instanceUrl: string; - }): Promise; - /** - * Add each registration readDataClassRegistrations finds missing, - * titled `title`, one save per index (If-Match, read again and - * retried when the index changed meanwhile), none where an equal one - * is there, and none in an index that cannot be read. - */ - registerDataClasses(args: { webId: string; instanceUrl: string; title: string }): Promise; /** * Give the instance's registrations a new title, in every type index * that registers it: its sm:Instance registration, which names it in - * the instance list, and its catalog's dcat:Catalog registration. + * the instance list, and those of its data's classes earlier versions + * wrote. * One write per index, made only if the index is as it was read * (If-Match), else read and written again, a few times. An index * already saying so is not written. diff --git a/packages/application/src/session.test.ts b/packages/application/src/session.test.ts index 9261b2a44..bd8857d74 100644 --- a/packages/application/src/session.test.ts +++ b/packages/application/src/session.test.ts @@ -433,16 +433,12 @@ describe("guests", () => { [`${TARGET}history/2026-10.ttl`, GUEST_ORIGIN], ]); expect(deps.shapeValidator.validateDocument).toHaveBeenCalledWith(`${TARGET}meta.ttl`, "pod"); - expect(deps.instanceRepository.attachInstance).toHaveBeenCalledWith({ + // Its sm:Instance registration is the one written: none for the classes of its data. + expect(deps.instanceRepository.attachInstance).toHaveBeenCalledExactlyOnceWith({ webId: session.webId, instanceUrl: TARGET, registrationTarget: "private", }); - expect(deps.instanceRepository.registerDataClasses).toHaveBeenCalledWith({ - webId: session.webId, - instanceUrl: TARGET, - title: "My study", - }); expect(deps.instanceRepository.deleteInstance).toHaveBeenCalledWith({ webId: GUEST_WEBID, instance: guestInstance, @@ -814,21 +810,6 @@ describe("guests", () => { ).resolves.toMatchObject({ ok: true }); }); - it("transferGuestStudy tidies the guest's study away even when its data could not be registered", async () => { - const { deps } = guestDeps(); - vi.mocked(deps.instanceRepository.registerDataClasses).mockRejectedValue(new Error("offline")); - expect( - await createUseCases(deps).transferGuestStudy(session, guestInstance, { - containerUrl: TARGET, - registrationTarget: "private", - }), - ).toEqual({ ok: true, instance: { url: TARGET, name: "My study" }, tidied: true }); - expect(deps.instanceRepository.deleteInstance).toHaveBeenCalledWith({ - webId: GUEST_WEBID, - instance: guestInstance, - }); - }); - it("transferGuestStudy says the study moved even when the guest's could not be tidied away", async () => { const { deps } = guestDeps(); vi.mocked(deps.instanceRepository.deleteInstance).mockRejectedValue(new Error("offline")); @@ -1308,7 +1289,7 @@ describe("guests", () => { }); // The target's preferences stay; the guest's are not read, nor anything registered. expect(deps.preferencesRepository.savePreferences).not.toHaveBeenCalled(); - expect(deps.instanceRepository.registerDataClasses).not.toHaveBeenCalled(); + expect(deps.instanceRepository.attachInstance).not.toHaveBeenCalled(); expect(deps.instanceRepository.deleteInstance).toHaveBeenCalledWith({ webId: GUEST_WEBID, instance: guestInstance, diff --git a/packages/application/src/session.ts b/packages/application/src/session.ts index 0bb3b3e1a..29750280b 100644 --- a/packages/application/src/session.ts +++ b/packages/application/src/session.ts @@ -666,13 +666,7 @@ export function createSessionUseCases({ // The study is the user's now: what follows only tidies. progress.finished("tidy"); updateJournal.end(source); - const tidied = await (async () => { - // The registrations of each class of its data let other apps find it; the instance works without, and Preferences adds them later. - await instanceRepository - .registerDataClasses({ webId: session.webId, instanceUrl: target, title: instance.name }) - .catch(() => undefined); - await removeGuestInstance(guestInstance); - })().then( + const tidied = await removeGuestInstance(guestInstance).then( () => true, () => false, ); diff --git a/packages/application/src/testing/fakePorts.ts b/packages/application/src/testing/fakePorts.ts index 246468e07..64e598cc0 100644 --- a/packages/application/src/testing/fakePorts.ts +++ b/packages/application/src/testing/fakePorts.ts @@ -139,12 +139,6 @@ export function makeDeps() { saveMeta: vi.fn(async () => undefined), renameRegistrations: vi.fn(async () => undefined), upgradeMeta: vi.fn(async () => true), - readDataClassRegistrations: vi.fn(async () => ({ - registrations: [], - privateIndexMissing: false, - unreadableIndexes: [], - })), - registerDataClasses: vi.fn(async () => undefined), }; const deckRepository: DeckRepository = { listDecks: vi.fn(async () => [deck]), diff --git a/packages/domain/src/instance.test.ts b/packages/domain/src/instance.test.ts index 95ef499a6..41edaef8a 100644 --- a/packages/domain/src/instance.test.ts +++ b/packages/domain/src/instance.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest"; import { AppError } from "./appError"; -import { DATA_CLASSES, instanceName, isPrivateOnly } from "./instance"; +import { instanceName } from "./instance"; describe("instanceName", () => { it("trims the name, and refuses an empty one", () => { @@ -12,15 +12,3 @@ describe("instanceName", () => { expect(() => instanceName(" ")).toThrow(new AppError("instanceNameEmpty")); }); }); - -describe("data classes", () => { - it("keep review states, answers and drafts to the private type index alone", () => { - expect(DATA_CLASSES.filter(isPrivateOnly)).toEqual(["reviewState", "answer", "draft"]); - expect(DATA_CLASSES.filter((dataClass) => !isPrivateOnly(dataClass))).toEqual([ - "instance", - "catalog", - "deck", - "card", - ]); - }); -}); diff --git a/packages/domain/src/instance.ts b/packages/domain/src/instance.ts index 6df9aff37..a529761ed 100644 --- a/packages/domain/src/instance.ts +++ b/packages/domain/src/instance.ts @@ -71,47 +71,3 @@ export interface InstanceDeletion extends KeptDocuments { /** Whether what the kept folder holds includes the releases published from the instance (`releases/`), which are kept. */ keptReleases: boolean; } - -/** - * The kinds of an instance's data registered in the type indexes, one - * class each (docs/data-model.md "Discovery chain"): the instance - * itself, its catalog, its decks, cards, review states and answers, - * and the drafts of releases its owner writes. - */ -export const DATA_CLASSES = [ - "instance", - "catalog", - "deck", - "card", - "reviewState", - "answer", - "draft", -] as const; -export type DataClass = (typeof DATA_CLASSES)[number]; - -/** - * Review states and answers say what the user studied, and how well, - * and a draft is its author's until it is published: they are - * registered in the private type index only, never in the public one, - * even for an instance registered publicly. - */ -export function isPrivateOnly(dataClass: DataClass): boolean { - return dataClass === "reviewState" || dataClass === "answer" || dataClass === "draft"; -} - -/** One registration an instance's data has, or is missing, in one type index. */ -export interface DataClassRegistration { - dataClass: DataClass; - index: RegistrationTarget; - registered: boolean; -} - -/** The registrations an instance's data has and is missing, by class. */ -export interface DataClassRegistrations { - /** In DATA_CLASSES order, the private index's before the public one's. */ - registrations: DataClassRegistration[]; - /** No private type index: review states and answers, which go only there, are not registered. */ - privateIndexMissing: boolean; - /** The type indexes the profile links that could not be read: what they register is not known, and nothing is added to them. */ - unreadableIndexes: RegistrationTarget[]; -} diff --git a/packages/domain/src/instanceUpdate.test.ts b/packages/domain/src/instanceUpdate.test.ts index b6e9bbab2..d9c52c021 100644 --- a/packages/domain/src/instanceUpdate.test.ts +++ b/packages/domain/src/instanceUpdate.test.ts @@ -27,6 +27,6 @@ describe("rebaseIri", () => { describe("UPDATE_STEPS", () => { it("are the steps the user sees, in order", () => { - expect(UPDATE_STEPS).toEqual(["read", "write", "register"]); + expect(UPDATE_STEPS).toEqual(["read", "write"]); }); }); diff --git a/packages/domain/src/instanceUpdate.ts b/packages/domain/src/instanceUpdate.ts index e29a0defc..280d2ec22 100644 --- a/packages/domain/src/instanceUpdate.ts +++ b/packages/domain/src/instanceUpdate.ts @@ -19,10 +19,10 @@ import type { LangText } from "./langText"; * address. */ -/** The steps the user sees: finding what to update, updating each document, making the instance's data findable. */ -export type UpdateStep = "read" | "write" | "register"; +/** The steps the user sees: finding what to update, and updating each document. */ +export type UpdateStep = "read" | "write"; -export const UPDATE_STEPS: readonly UpdateStep[] = ["read", "write", "register"]; +export const UPDATE_STEPS: readonly UpdateStep[] = ["read", "write"]; export interface UpdateProgress { step: UpdateStep; diff --git a/packages/solid/src/solidInstanceRepository.test.ts b/packages/solid/src/solidInstanceRepository.test.ts index 1e2c6db1c..fa9907ce2 100644 --- a/packages/solid/src/solidInstanceRepository.test.ts +++ b/packages/solid/src/solidInstanceRepository.test.ts @@ -21,12 +21,11 @@ import { import { datasetFrom, readOf } from "./testing/datasets"; import { createSolidInstanceRepository } from "./solidInstanceRepository"; import { - addRegistrations, + addInstanceRegistration, createTypeIndex, ensureTypeIndex, locateTypeIndexes, readInstanceRegistrations, - readRegisteredClasses, removeInstanceRegistrations, renameInstanceRegistrations, } from "./typeIndex"; @@ -89,8 +88,7 @@ beforeEach(() => { vi.mocked(ensureTypeIndex).mockReset(); vi.mocked(readInstanceRegistrations).mockReset(); vi.mocked(createTypeIndex).mockReset(); - vi.mocked(readRegisteredClasses).mockReset(); - vi.mocked(addRegistrations).mockReset(); + vi.mocked(addInstanceRegistration).mockReset(); vi.mocked(removeInstanceRegistrations).mockReset(); vi.mocked(deleteInstanceData).mockReset(); }); @@ -180,145 +178,6 @@ describe("getRegistrationOptions", () => { }); }); -const ALL = ["instance", "catalog", "deck", "card", "reviewState", "answer", "draft"]; -const SHARED = ["instance", "catalog", "deck", "card"]; -const PRIVATE_ONLY = ["reviewState", "answer", "draft"]; - -describe("readDataClassRegistrations and registerDataClasses", () => { - const args = { webId: WEBID, instanceUrl: CONTAINER }; - - it("want the instance's data of every class in each index that registers it, review states, answers and drafts in the private one only", async () => { - vi.mocked(locateTypeIndexes).mockResolvedValue({ - fetch: indexFetch, - inPlaces: () => true, - privateIndexUrl: PRIVATE_INDEX, - publicIndexUrl: PUBLIC_INDEX, - }); - vi.mocked(readRegisteredClasses).mockImplementation(async (indexUrl) => - indexUrl === PRIVATE_INDEX ? ["instance", "catalog", "answer"] : ["instance", "deck"], - ); - const repository = makeRepository(); - - await expect(repository.readDataClassRegistrations(args)).resolves.toEqual({ - registrations: [ - { dataClass: "instance", index: "private", registered: true }, - { dataClass: "instance", index: "public", registered: true }, - { dataClass: "catalog", index: "private", registered: true }, - { dataClass: "catalog", index: "public", registered: false }, - { dataClass: "deck", index: "private", registered: false }, - { dataClass: "deck", index: "public", registered: true }, - { dataClass: "card", index: "private", registered: false }, - { dataClass: "card", index: "public", registered: false }, - { dataClass: "reviewState", index: "private", registered: false }, - { dataClass: "answer", index: "private", registered: true }, - { dataClass: "draft", index: "private", registered: false }, - ], - privateIndexMissing: false, - unreadableIndexes: [], - }); - expect(readRegisteredClasses).toHaveBeenCalledWith(PRIVATE_INDEX, CONTAINER, indexFetch); - - await repository.registerDataClasses({ ...args, title: "Main" }); - expect(vi.mocked(addRegistrations).mock.calls.map((call) => [call[0], call[1]])).toEqual([ - [ - PRIVATE_INDEX, - { - instanceUrl: CONTAINER, - title: "Main", - classes: ["deck", "card", "reviewState", "draft"], - }, - ], - [PUBLIC_INDEX, { instanceUrl: CONTAINER, title: "Main", classes: ["catalog", "card"] }], - ]); - }); - - it("want review states and answers in the private index when only the public one registers the instance", async () => { - vi.mocked(locateTypeIndexes).mockResolvedValue({ - fetch: indexFetch, - inPlaces: () => true, - privateIndexUrl: PRIVATE_INDEX, - publicIndexUrl: PUBLIC_INDEX, - }); - vi.mocked(readRegisteredClasses).mockImplementation(async (indexUrl) => - indexUrl === PRIVATE_INDEX ? [] : (SHARED as never), - ); - const repository = makeRepository(); - - await expect(repository.readDataClassRegistrations(args)).resolves.toEqual({ - registrations: [ - ...SHARED.map((dataClass) => ({ dataClass, index: "public", registered: true })), - ...PRIVATE_ONLY.map((dataClass) => ({ dataClass, index: "private", registered: false })), - ], - privateIndexMissing: false, - unreadableIndexes: [], - }); - await repository.registerDataClasses({ ...args, title: "Main" }); - expect(addRegistrations).toHaveBeenCalledExactlyOnceWith( - PRIVATE_INDEX, - { instanceUrl: CONTAINER, title: "Main", classes: PRIVATE_ONLY }, - expect.any(Function), - indexFetch, - ); - }); - - it("leave out an index that cannot be read, and say so", async () => { - vi.mocked(locateTypeIndexes).mockResolvedValue({ - fetch: indexFetch, - inPlaces: () => true, - privateIndexUrl: PRIVATE_INDEX, - publicIndexUrl: PUBLIC_INDEX, - }); - vi.mocked(readRegisteredClasses).mockImplementation(async (indexUrl) => { - if (indexUrl === PUBLIC_INDEX) throw new Error("404 Not Found"); - return []; - }); - const repository = makeRepository(); - - await expect(repository.readDataClassRegistrations(args)).resolves.toEqual({ - registrations: PRIVATE_ONLY.map((dataClass) => ({ - dataClass, - index: "private", - registered: false, - })), - privateIndexMissing: false, - unreadableIndexes: ["public"], - }); - await repository.registerDataClasses({ ...args, title: "Main" }); - expect(addRegistrations).toHaveBeenCalledExactlyOnceWith( - PRIVATE_INDEX, - { instanceUrl: CONTAINER, title: "Main", classes: PRIVATE_ONLY }, - expect.any(Function), - indexFetch, - ); - - vi.mocked(readRegisteredClasses).mockRejectedValue(new Error("403 Forbidden")); - await expect(repository.readDataClassRegistrations(args)).resolves.toEqual({ - registrations: [], - privateIndexMissing: false, - unreadableIndexes: ["private", "public"], - }); - }); - - it("never want review states or answers in the public index, without a private one", async () => { - vi.mocked(locateTypeIndexes).mockResolvedValue({ - fetch: indexFetch, - inPlaces: () => true, - privateIndexUrl: null, - publicIndexUrl: PUBLIC_INDEX, - }); - vi.mocked(readRegisteredClasses).mockResolvedValue(SHARED as never); - const repository = makeRepository(); - - await expect(repository.readDataClassRegistrations(args)).resolves.toEqual({ - registrations: SHARED.map((dataClass) => ({ dataClass, index: "public", registered: true })), - privateIndexMissing: true, - unreadableIndexes: [], - }); - await repository.registerDataClasses({ ...args, title: "Main" }); - expect(addRegistrations).not.toHaveBeenCalled(); - }); -}); - describe("createInstance", () => { const create = (registrationTarget: "private" | "public") => makeRepository().createInstance({ @@ -328,7 +187,7 @@ describe("createInstance", () => { registrationTarget, }); - it("writes meta.ttl, registers every class of its data in the private index, and returns the instance", async () => { + it("writes meta.ttl, registers the instance in the private index, and returns the instance", async () => { vi.mocked(locateTypeIndexes).mockResolvedValue({ fetch: indexFetch, inPlaces: () => true, @@ -347,13 +206,13 @@ describe("createInstance", () => { expect(getInteger(meta, SM.formatVersion)).toBe(2); expect(createTypeIndex).not.toHaveBeenCalled(); - expect(addRegistrations).toHaveBeenCalledExactlyOnceWith( + expect(addInstanceRegistration).toHaveBeenCalledExactlyOnceWith( PRIVATE_INDEX, - { instanceUrl: CONTAINER, title: "Main", classes: ALL }, + { instanceUrl: CONTAINER, title: "Main" }, expect.any(Function), indexFetch, ); - expect(vi.mocked(addRegistrations).mock.calls[0][2]()).toBe("fixed-id"); + expect(vi.mocked(addInstanceRegistration).mock.calls[0][2]()).toBe("fixed-id"); }); it("makes the instance at its address as a request sends it", async () => { @@ -386,15 +245,15 @@ describe("createInstance", () => { await create("private"); expect(createTypeIndex).toHaveBeenCalledWith("private", WEBID, CONTAINER, expect.anything()); - expect(addRegistrations).toHaveBeenCalledExactlyOnceWith( + expect(addInstanceRegistration).toHaveBeenCalledExactlyOnceWith( PRIVATE_INDEX, - expect.objectContaining({ classes: ALL }), + { instanceUrl: CONTAINER, title: "Main" }, expect.any(Function), indexFetch, ); }); - it("registers it publicly, its review states and answers in the private index", async () => { + it("registers a public instance in the public index alone, nothing in the private one", async () => { vi.mocked(locateTypeIndexes).mockResolvedValue({ fetch: indexFetch, inPlaces: () => true, @@ -402,19 +261,20 @@ describe("createInstance", () => { publicIndexUrl: PUBLIC_INDEX, }); - await create("public"); + await expect(create("public")).resolves.toEqual({ url: CONTAINER, name: "Main" }); - expect( - vi.mocked(addRegistrations).mock.calls.map((call) => [call[0], call[1].classes]), - ).toEqual([ - [PUBLIC_INDEX, SHARED], - [PRIVATE_INDEX, PRIVATE_ONLY], - ]); + expect(createTypeIndex).not.toHaveBeenCalled(); + expect(addInstanceRegistration).toHaveBeenCalledExactlyOnceWith( + PUBLIC_INDEX, + { instanceUrl: CONTAINER, title: "Main" }, + expect.any(Function), + indexFetch, + ); }); - it("registers its review states and answers nowhere when it is registered publicly without a private index", async () => { + it("creates the public index for a public instance when there is none", async () => { vi.mocked(locateTypeIndexes).mockResolvedValue({ - privateIndexUrl: null, + privateIndexUrl: PRIVATE_INDEX, publicIndexUrl: null, fetch: indexFetch, inPlaces: () => true, @@ -424,35 +284,27 @@ describe("createInstance", () => { await create("public"); expect(createTypeIndex).toHaveBeenCalledWith("public", WEBID, CONTAINER, expect.anything()); - expect(addRegistrations).toHaveBeenCalledExactlyOnceWith( + expect(addInstanceRegistration).toHaveBeenCalledExactlyOnceWith( PUBLIC_INDEX, - expect.objectContaining({ classes: SHARED }), + { instanceUrl: CONTAINER, title: "Main" }, expect.any(Function), indexFetch, ); }); - it("still creates a public instance when the private index refuses its review states and answers", async () => { + it("takes the instance back and rethrows when the index refuses its registration", async () => { vi.mocked(locateTypeIndexes).mockResolvedValue({ fetch: indexFetch, inPlaces: () => true, privateIndexUrl: PRIVATE_INDEX, publicIndexUrl: PUBLIC_INDEX, }); - vi.mocked(addRegistrations).mockImplementation(async (indexUrl) => { - if (indexUrl === PRIVATE_INDEX) throw new Error("private index refused"); - return []; - }); + vi.mocked(addInstanceRegistration).mockRejectedValue(new Error("index refused")); + vi.mocked(readDocument).mockResolvedValue(readOf(metaWith())); - await expect(create("public")).resolves.toEqual({ url: CONTAINER, name: "Main" }); - expect( - vi.mocked(addRegistrations).mock.calls.map((call) => [call[0], call[1].classes]), - ).toEqual([ - [PUBLIC_INDEX, SHARED], - [PRIVATE_INDEX, PRIVATE_ONLY], - ]); - expect(removeInstanceRegistrations).not.toHaveBeenCalled(); - expect(deleteContainer).not.toHaveBeenCalled(); + await expect(create("public")).rejects.toThrow("index refused"); + expect(addInstanceRegistration).toHaveBeenCalledOnce(); + expect(deleteContainer).toHaveBeenCalledWith(CONTAINER, expect.anything()); }); it("cleans up the container and rethrows when registration fails", async () => { @@ -567,9 +419,9 @@ describe("attachInstance", () => { expect.anything(), expect.anything(), ); - expect(addRegistrations).toHaveBeenCalledExactlyOnceWith( + expect(addInstanceRegistration).toHaveBeenCalledExactlyOnceWith( PRIVATE_INDEX, - { instanceUrl: CONTAINER, title: "Attached", classes: ["instance"] }, + { instanceUrl: CONTAINER, title: "Attached" }, expect.any(Function), indexFetch, ); @@ -617,7 +469,7 @@ describe("attachInstance", () => { ).rejects.toMatchObject({ code: "notAnInstanceNoMeta" }); } expect(readDocument).not.toHaveBeenCalled(); - expect(addRegistrations).not.toHaveBeenCalled(); + expect(addInstanceRegistration).not.toHaveBeenCalled(); }); it("rejects a container without a readable meta.ttl", async () => { @@ -1104,32 +956,11 @@ describe("a type index outside the profile's own places", () => { }); expect(createTypeIndex).toHaveBeenCalledWith("public", WEBID, CONTAINER, expect.anything()); - expect(vi.mocked(addRegistrations).mock.calls.map((call) => call[0])).toEqual([ + expect(vi.mocked(addInstanceRegistration).mock.calls.map((call) => call[0])).toEqual([ OWN_PUBLIC, - PRIVATE_INDEX, ]); }); - it("shows no registration of the instance's data, and gets none", async () => { - publicElsewhere(); - vi.mocked(readRegisteredClasses).mockResolvedValue(["instance"]); - const args = { webId: WEBID, instanceUrl: CONTAINER }; - - const registrations = await makeRepository().readDataClassRegistrations(args); - expect(new Set(registrations.registrations.map((entry) => entry.index))).toEqual( - new Set(["private"]), - ); - expect(registrations.unreadableIndexes).toEqual([]); - - await makeRepository().registerDataClasses({ ...args, title: "Main" }); - expect(readRegisteredClasses).not.toHaveBeenCalledWith( - PUBLIC_INDEX, - expect.anything(), - expect.anything(), - ); - expect(vi.mocked(addRegistrations).mock.calls.map((call) => call[0])).toEqual([PRIVATE_INDEX]); - }); - it("is neither renamed in nor unregistered from", async () => { publicElsewhere(); vi.mocked(renameInstanceRegistrations).mockReset().mockResolvedValue(undefined); diff --git a/packages/solid/src/solidInstanceRepository.ts b/packages/solid/src/solidInstanceRepository.ts index a4110b56e..3718dff07 100644 --- a/packages/solid/src/solidInstanceRepository.ts +++ b/packages/solid/src/solidInstanceRepository.ts @@ -8,14 +8,9 @@ import { FOREIGN_LIMITS, limitedFetch } from "./client/readLimits"; import { createSolidDataset, getThing, setThing } from "./client/rdf"; import type { InstanceRepository } from "@solid-memo/application/ports"; import { - DATA_CLASSES, INSTANCE_FORMAT_VERSION, - isPrivateOnly, - type DataClass, - type DataClassRegistrations, type Instance, type InstanceMeta, - type RegistrationTarget, } from "@solid-memo/domain/instance"; import { instanceAddressOf, isInstanceAddress, metaUrlOf } from "@solid-memo/domain/instanceLayout"; import { isInstanceOutdated } from "@solid-memo/domain/migration"; @@ -25,12 +20,11 @@ import { deleteInstanceData } from "./instanceData"; import { noWriteCheck, type WriteCheck } from "./writeCheck"; import { toInstance, toInstanceMeta, toInstanceMetaThing } from "./mappers/instanceMapper"; import { - addRegistrations, + addInstanceRegistration, createTypeIndex, ensureTypeIndex, locateTypeIndexes, readInstanceRegistrations, - readRegisteredClasses, removeInstanceRegistrations, renameInstanceRegistrations, type InstanceRegistration, @@ -122,42 +116,23 @@ export function createSolidInstanceRepository({ ? new AppError("instanceAlreadyThere", { url }) : error; }); - // The private index, for the review states' and answers' registrations of an instance registered elsewhere. - let privateIndexElsewhere: string | null; - let indexFetch: typeof globalThis.fetch; try { const locations = await ownTypeIndexes(webId, fetch, publicFetch); - indexFetch = locations.fetch; const indexUrl = (registrationTarget === "private" ? locations.privateIndexUrl : locations.publicIndexUrl) ?? (await createTypeIndex(registrationTarget, webId, url, fetch)); - privateIndexElsewhere = - locations.privateIndexUrl === indexUrl ? null : locations.privateIndexUrl; - await addRegistrations( + await addInstanceRegistration( indexUrl, - { instanceUrl: url, title: name, classes: wantedIn(registrationTarget, true) }, + { instanceUrl: url, title: name }, randomId, - indexFetch, + locations.fetch, ); } catch (error) { await bestEffortCleanup(metaUrl, url, fetch); throw error; } - // The instance works without them, and Preferences adds them later: a private index that refuses them does not fail the creation. - if (privateIndexElsewhere !== null) { - try { - await addRegistrations( - privateIndexElsewhere, - { instanceUrl: url, title: name, classes: wantedIn("private", false) }, - randomId, - indexFetch, - ); - } catch { - // Left for Preferences to add. - } - } return { url, name }; }, @@ -168,33 +143,15 @@ export function createSolidInstanceRepository({ if (!isInstanceAddress(url)) throw new AppError("notAnInstanceNoMeta", { url: instanceUrl }); const name = await readInstanceName(url, metaFetch); const index = await ensureTypeIndex(registrationTarget, webId, url, fetch, publicFetch); - await addRegistrations( + await addInstanceRegistration( index.url, - { instanceUrl: url, title: name, classes: ["instance"] }, + { instanceUrl: url, title: name }, randomId, index.fetch, ); return { url, name }; }, - async readDataClassRegistrations({ webId, instanceUrl }) { - return registrationsOf(await indexesOf(webId, instanceUrl, fetch, publicFetch)); - }, - - async registerDataClasses({ webId, instanceUrl, title }) { - const { indexes, indexFetch } = await indexesOf(webId, instanceUrl, fetch, publicFetch); - for (const index of indexes) { - const missing = index.wanted.filter((dataClass) => !index.registered.includes(dataClass)); - if (missing.length === 0) continue; - await addRegistrations( - index.url, - { instanceUrl, title, classes: missing }, - randomId, - indexFetch, - ); - } - }, - async renameRegistrations({ webId, instanceUrl, title }) { const locations = await ownTypeIndexes(webId, fetch, publicFetch); for (const indexUrl of [locations.privateIndexUrl, locations.publicIndexUrl]) { @@ -299,104 +256,6 @@ async function ownTypeIndexes( }; } -/** - * The classes of an instance's data that belong in a type index of a - * kind: Solid Memo's own registration, the catalog's, the decks' and - * the cards' in each index that registers the instance (`holding`), and - * the review states' and answers' in the private index only, whatever - * index registers the instance. - */ -function wantedIn(kind: RegistrationTarget, holding: boolean): DataClass[] { - return DATA_CLASSES.filter((dataClass) => - isPrivateOnly(dataClass) ? kind === "private" : holding, - ); -} - -interface IndexRegistrations { - kind: RegistrationTarget; - url: string; - /** The classes of the instance's data it registers. */ - registered: DataClass[]; - /** The classes of the instance's data that belong in it (wantedIn). */ - wanted: DataClass[]; -} - -/** - * Each of the user's type indexes (ownTypeIndexes), private first, with - * what it registers of the instance's data and what belongs in it; one - * that cannot be read is left out, and said to be unreadable. - */ -async function indexesOf( - webId: string, - instanceUrl: string, - fetch: typeof globalThis.fetch, - publicFetch: typeof globalThis.fetch, -): Promise<{ - indexes: IndexRegistrations[]; - /** What the indexes are read and written through (TypeIndexes). */ - indexFetch: typeof globalThis.fetch; - privateIndexMissing: boolean; - unreadableIndexes: RegistrationTarget[]; -}> { - const { - privateIndexUrl, - publicIndexUrl, - fetch: indexFetch, - } = await ownTypeIndexes(webId, fetch, publicFetch); - const indexes: IndexRegistrations[] = []; - const unreadableIndexes: RegistrationTarget[] = []; - for (const [kind, url] of [ - ["private", privateIndexUrl], - ["public", publicIndexUrl], - ] as const) { - if (url === null) continue; - let registered: DataClass[]; - try { - registered = await readRegisteredClasses(url, instanceUrl, indexFetch); - } catch { - unreadableIndexes.push(kind); - continue; - } - indexes.push({ - kind, - url, - registered, - wanted: wantedIn(kind, registered.includes("instance")), - }); - } - return { - indexes, - indexFetch, - privateIndexMissing: privateIndexUrl === null, - unreadableIndexes, - }; -} - -/** Every registration that belongs in an index, by class, the private index's first, and whether it is there. */ -function registrationsOf({ - indexes, - privateIndexMissing, - unreadableIndexes, -}: { - indexes: IndexRegistrations[]; - privateIndexMissing: boolean; - unreadableIndexes: RegistrationTarget[]; -}): DataClassRegistrations { - return { - registrations: DATA_CLASSES.flatMap((dataClass) => - indexes - .filter((index) => index.wanted.includes(dataClass)) - .map((index) => ({ - dataClass, - index: index.kind, - registered: index.registered.includes(dataClass), - })), - ), - privateIndexMissing, - unreadableIndexes, - }; -} - async function readRegistrationsSafely( indexUrl: string, fetch: typeof globalThis.fetch, diff --git a/packages/solid/src/typeIndex.test.ts b/packages/solid/src/typeIndex.test.ts index 2d58187ad..6059702dc 100644 --- a/packages/solid/src/typeIndex.test.ts +++ b/packages/solid/src/typeIndex.test.ts @@ -19,12 +19,11 @@ import { import { readDocument, writeDocument } from "./client/resources"; import { datasetFrom, readOf, writtenAsGiven } from "./testing/datasets"; import { - addRegistrations, + addInstanceRegistration, createTypeIndex, ensureTypeIndex, locateTypeIndexes, readInstanceRegistrations, - readRegisteredClasses, removeInstanceRegistrations, renameInstanceRegistrations, } from "./typeIndex"; @@ -799,59 +798,17 @@ function privateIndex(...things: NamedSubject[]): Dataset { const preconditionFailed = () => Object.assign(new Error("412 Precondition Failed"), { statusCode: 412 }); -describe("readRegisteredClasses", () => { - const MAIN = "https://alice.example/solid-memo/main/"; - - it("says which classes of the instance's data the index registers, each by what it names exactly", async () => { - mockDatasets({ - [PRIVATE_INDEX]: privateIndex( - // The container under either predicate, a missing slash ignored. - registered("inst", SM.Instance, SOLID.instance, "https://alice.example/solid-memo/main"), - registered("deck", SM.Deck, SOLID.instance, `${MAIN}catalog.ttl`), - registered("card", SM.Card, SOLID.instance, `${MAIN}decks/`), - // Not the instance's: its answers' container without the slash, another instance's review states, - // a catalog elsewhere in the folder, and a registration that is not one. - registered("answer", SM.Answer, SOLID.instanceContainer, `${MAIN}history`), - registered( - "review", - SM.ReviewState, - SOLID.instanceContainer, - "https://alice.example/solid-memo/b/reviews/", - ), - registered("cat", DCAT.Catalog, SOLID.instance, `${MAIN}attachments/index.ttl#catalog`), - buildThing(createThing({ url: `${PRIVATE_INDEX}#note` })) - .addIri(SOLID.forClass, DCAT.Catalog) - .addIri(SOLID.instance, `${MAIN}catalog.ttl#catalog`) - .build(), - ), - }); - await expect( - readRegisteredClasses(PRIVATE_INDEX, "https://alice.example/solid-memo/main", noFetch), - ).resolves.toEqual(["instance", "deck", "card"]); - }); -}); - -describe("addRegistrations", () => { +describe("addInstanceRegistration", () => { const MAIN = "https://alice.example/solid-memo/main/"; const ids = () => { let next = 0; return () => `id${++next}`; }; + const main = { instanceUrl: "https://alice.example/solid-memo/main", title: "Main" }; - it("registers each class of the instance's data, titled, in one save", async () => { + it("registers the instance alone, titled, naming its container, in one save", async () => { mockDatasets({ [PRIVATE_INDEX]: privateIndex() }); - await expect( - addRegistrations( - PRIVATE_INDEX, - { - instanceUrl: "https://alice.example/solid-memo/main", - title: "Main", - classes: ["instance", "catalog", "deck", "card", "reviewState", "answer"], - }, - ids(), - noFetch, - ), - ).resolves.toEqual(["instance", "catalog", "deck", "card", "reviewState", "answer"]); + await addInstanceRegistration(PRIVATE_INDEX, main, ids(), noFetch); expect(writeDocument).toHaveBeenCalledOnce(); const [saveUrl, saved] = vi.mocked(writeDocument).mock.calls[0]; expect(saveUrl).toBe(PRIVATE_INDEX); @@ -863,135 +820,96 @@ describe("addRegistrations", () => { instanceContainer: getUrlAll(thing, SOLID.instanceContainer), title: getStringNoLocale(thing, DCTERMS.title), })); - const common = { types: [SOLID.TypeRegistration], title: "Main" }; + // No registration of the catalog, decks, cards, review states, answers or drafts. expect(registrations).toEqual([ { - ...common, url: `${PRIVATE_INDEX}#sm-inst-id1`, + types: [SOLID.TypeRegistration], forClass: [SM.Instance], instance: [], instanceContainer: [MAIN], - }, - { - ...common, - url: `${PRIVATE_INDEX}#sm-cat-id2`, - forClass: [DCAT.Catalog], - instance: [`${MAIN}catalog.ttl#catalog`], - instanceContainer: [], - }, - { - ...common, - url: `${PRIVATE_INDEX}#sm-deck-id3`, - forClass: [SM.Deck], - instance: [`${MAIN}catalog.ttl`], - instanceContainer: [], - }, - { - ...common, - url: `${PRIVATE_INDEX}#sm-card-id4`, - forClass: [SM.Card], - instance: [], - instanceContainer: [`${MAIN}decks/`], - }, - { - ...common, - url: `${PRIVATE_INDEX}#sm-review-id5`, - forClass: [SM.ReviewState], - instance: [], - instanceContainer: [`${MAIN}reviews/`], - }, - { - ...common, - url: `${PRIVATE_INDEX}#sm-answer-id6`, - forClass: [SM.Answer], - instance: [], - instanceContainer: [`${MAIN}history/`], + title: "Main", }, ]); }); - it("adds only what the index does not register already, and saves nothing when it registers it all", async () => { - const index = privateIndex( - registered("inst", SM.Instance, SOLID.instance, "https://alice.example/solid-memo/main"), - registered("cat", DCAT.Catalog, SOLID.instance, `${MAIN}catalog.ttl#catalog`), - ); - mockDatasets({ [PRIVATE_INDEX]: index }); - await expect( - addRegistrations( - PRIVATE_INDEX, - { instanceUrl: MAIN, title: "Main", classes: ["instance", "catalog", "card"] }, - ids(), - noFetch, + it("saves nothing when the index registers the instance already, its container with or without the slash", async () => { + for (const [predicate, url] of [ + [SOLID.instanceContainer, MAIN], + [SOLID.instance, "https://alice.example/solid-memo/main"], + ]) { + mockDatasets({ + [PRIVATE_INDEX]: privateIndex(registered("inst", SM.Instance, predicate, url)), + }); + await addInstanceRegistration(PRIVATE_INDEX, main, ids(), noFetch); + await addInstanceRegistration(PRIVATE_INDEX, { ...main, instanceUrl: MAIN }, ids(), noFetch); + } + expect(writeDocument).not.toHaveBeenCalled(); + }); + + it("adds it beside other registrations of the folder that are not the instance's", async () => { + mockDatasets({ + [PRIVATE_INDEX]: privateIndex( + // An earlier version's registration of the cards, and another class naming the container. + registered("card", SM.Card, SOLID.instanceContainer, `${MAIN}decks/`), + registered("recipes", "https://schema.org/Recipe", SOLID.instanceContainer, MAIN), ), - ).resolves.toEqual(["card"]); + }); + await addInstanceRegistration(PRIVATE_INDEX, main, ids(), noFetch); const saved = vi.mocked(writeDocument).mock.calls[0][1] as Dataset; expect(getThingAll(saved).map((thing) => thing.url)).toEqual([ - `${PRIVATE_INDEX}#inst`, - `${PRIVATE_INDEX}#cat`, - `${PRIVATE_INDEX}#sm-card-id1`, + `${PRIVATE_INDEX}#card`, + `${PRIVATE_INDEX}#recipes`, + `${PRIVATE_INDEX}#sm-inst-id1`, ]); - - vi.mocked(writeDocument).mockClear(); - await expect( - addRegistrations( - PRIVATE_INDEX, - { instanceUrl: MAIN, title: "Main", classes: ["instance", "catalog"] }, - ids(), - noFetch, - ), - ).resolves.toEqual([]); - expect(writeDocument).not.toHaveBeenCalled(); }); - it("reads the index again and adds what is still missing when it changed meanwhile", async () => { + it("reads the index again when it changed meanwhile, and adds nothing when the instance is registered by then", async () => { let reads = 0; vi.mocked(readDocument).mockImplementation((async () => readOf( - // Meanwhile another tab registered the cards. + // Meanwhile another tab registered the notes, then the instance. ++reads === 1 ? privateIndex() - : privateIndex(registered("theirs", SM.Card, SOLID.instanceContainer, `${MAIN}decks/`)), + : reads === 2 + ? privateIndex( + registered( + "notes", + "https://schema.org/Note", + SOLID.instanceContainer, + "https://alice.example/notes/", + ), + ) + : privateIndex(registered("theirs", SM.Instance, SOLID.instanceContainer, MAIN)), )) as never); vi.mocked(writeDocument).mockRejectedValueOnce(preconditionFailed()); - await expect( - addRegistrations( - PRIVATE_INDEX, - { instanceUrl: MAIN, title: "Main", classes: ["deck", "card"] }, - ids(), - noFetch, - ), - ).resolves.toEqual(["deck"]); + const newId = ids(); + await addInstanceRegistration(PRIVATE_INDEX, main, newId, noFetch); expect(writeDocument).toHaveBeenCalledTimes(2); const saved = vi.mocked(writeDocument).mock.calls[1][1] as Dataset; expect(getThingAll(saved).map((thing) => thing.url)).toEqual([ - `${PRIVATE_INDEX}#theirs`, - `${PRIVATE_INDEX}#sm-deck-id3`, + `${PRIVATE_INDEX}#notes`, + `${PRIVATE_INDEX}#sm-inst-id2`, ]); + + vi.mocked(writeDocument).mockClear(); + await addInstanceRegistration(PRIVATE_INDEX, main, newId, noFetch); + expect(writeDocument).not.toHaveBeenCalled(); }); it("gives up after three attempts, and rethrows any other failure at once", async () => { mockDatasets({ [PRIVATE_INDEX]: privateIndex() }); vi.mocked(writeDocument).mockRejectedValue(preconditionFailed()); await expect( - addRegistrations( - PRIVATE_INDEX, - { instanceUrl: MAIN, title: "Main", classes: ["deck"] }, - ids(), - noFetch, - ), + addInstanceRegistration(PRIVATE_INDEX, main, ids(), noFetch), ).rejects.toMatchObject({ code: "changedElsewhere" }); expect(writeDocument).toHaveBeenCalledTimes(3); vi.mocked(writeDocument).mockReset(); vi.mocked(writeDocument).mockRejectedValue(new Error("403 Forbidden")); - await expect( - addRegistrations( - PRIVATE_INDEX, - { instanceUrl: MAIN, title: "Main", classes: ["deck"] }, - ids(), - noFetch, - ), - ).rejects.toThrow("403 Forbidden"); + await expect(addInstanceRegistration(PRIVATE_INDEX, main, ids(), noFetch)).rejects.toThrow( + "403 Forbidden", + ); expect(writeDocument).toHaveBeenCalledOnce(); }); }); @@ -1105,11 +1023,13 @@ describe("removeInstanceRegistrations", () => { ).toEqual(["https://alice.example/recipes/index.ttl#catalog"]); }); - it("removes the registration of each class of the instance's data by exact IRI, never another app's below the folder", async () => { + it("removes the registration earlier versions wrote of each class of the instance's data by exact IRI, never another app's below the folder", async () => { const MAIN = "https://alice.example/solid-memo/a/"; mockDatasets({ [PRIVATE_INDEX]: privateIndex( + registered("cat", DCAT.Catalog, SOLID.instance, `${MAIN}catalog.ttl#catalog`), registered("deck", SM.Deck, SOLID.instance, `${MAIN}catalog.ttl`), + registered("draft", SM.Deck, SOLID.instanceContainer, `${MAIN}drafts/`), registered("card", SM.Card, SOLID.instanceContainer, `${MAIN}decks/`), registered("review", SM.ReviewState, SOLID.instanceContainer, `${MAIN}reviews/`), // Under the other predicate: still the instance's answers' container. @@ -1219,13 +1139,16 @@ describe("renameInstanceRegistrations", () => { const titleOf = (saved: Dataset, id: string) => getStringNoLocale(getThing(saved, `${PRIVATE_INDEX}#${id}`)!, DCTERMS.title); - it("renames the registration of each class of the instance's data, in one save, and no other", async () => { + it("renames the instance's registration and each one earlier versions wrote for its data, in one save, and no other", async () => { mockDatasets({ [PRIVATE_INDEX]: index( registration("inst", SM.Instance, SOLID.instance, "https://alice.example/solid-memo/main"), registration("cat", DCAT.Catalog, SOLID.instance, `${MAIN}catalog.ttl#catalog`), registration("deck", SM.Deck, SOLID.instance, `${MAIN}catalog.ttl`), registration("card", SM.Card, SOLID.instanceContainer, `${MAIN}decks/`), + registration("review", SM.ReviewState, SOLID.instanceContainer, `${MAIN}reviews/`), + registration("answer", SM.Answer, SOLID.instanceContainer, `${MAIN}history/`), + registration("draft", SM.Deck, SOLID.instanceContainer, `${MAIN}drafts/`), registration( "other", SM.Instance, @@ -1250,6 +1173,9 @@ describe("renameInstanceRegistrations", () => { expect(titleOf(saved, "cat")).toBe("Languages"); expect(titleOf(saved, "deck")).toBe("Languages"); expect(titleOf(saved, "card")).toBe("Languages"); + expect(titleOf(saved, "review")).toBe("Languages"); + expect(titleOf(saved, "answer")).toBe("Languages"); + expect(titleOf(saved, "draft")).toBe("Languages"); expect(titleOf(saved, "other")).toBe("Main"); expect(titleOf(saved, "other-cat")).toBe("Main"); expect(titleOf(saved, "note")).toBe("x"); diff --git a/packages/solid/src/typeIndex.ts b/packages/solid/src/typeIndex.ts index c954fd6b6..b2c336c2c 100644 --- a/packages/solid/src/typeIndex.ts +++ b/packages/solid/src/typeIndex.ts @@ -31,7 +31,6 @@ import { DCAT, DCTERMS, FOAF, PIM, RDF, RDFS, SM, SOLID } from "./vocab"; import { createPlacesFetch, isInPlaces, profilePlacesOf } from "./profilePlaces"; import { createSolidStorageGateway } from "./solidStorageGateway"; import { AppError } from "@solid-memo/domain/appError"; -import { DATA_CLASSES, type DataClass } from "@solid-memo/domain/instance"; import { cardsContainerOf, catalogNodeUrlOf, @@ -353,55 +352,25 @@ export async function readInstanceRegistrations( } /** - * How each class of an instance's data is registered - * (docs/data-model.md "Discovery chain"): the class, the predicate and - * what it names, and the start of the registration's fragment id. - * `solid:instance` names the one resource that holds the class's - * subjects, `solid:instanceContainer` a container whose documents do. + * The registrations of an instance's data Solid Memo recognizes as its + * own (docs/data-model.md "Discovery chain"): the class and what it + * names. Only the instance's own is written. Earlier versions of the + * app wrote the others too, one per class of the instance's data; they + * are known here so that removing or renaming an instance still finds + * them in the user's type indexes. */ -const DATA_CLASS_TARGETS: Record< - DataClass, - { forClass: string; predicate: string; target: (container: string) => string; idPrefix: string } -> = { - instance: { - forClass: SM.Instance, - predicate: SOLID.instanceContainer, - target: (container) => container, - idPrefix: "sm-inst", - }, - catalog: { - forClass: DCAT.Catalog, - predicate: SOLID.instance, - target: catalogNodeUrlOf, - idPrefix: "sm-cat", - }, - deck: { forClass: SM.Deck, predicate: SOLID.instance, target: catalogUrlOf, idPrefix: "sm-deck" }, - card: { - forClass: SM.Card, - predicate: SOLID.instanceContainer, - target: cardsContainerOf, - idPrefix: "sm-card", - }, - reviewState: { - forClass: SM.ReviewState, - predicate: SOLID.instanceContainer, - target: reviewsContainerOf, - idPrefix: "sm-review", - }, - answer: { - forClass: SM.Answer, - predicate: SOLID.instanceContainer, - target: historyContainerOf, - idPrefix: "sm-answer", - }, - // A draft's release document is an sm:Deck too: the drafts are told from the decks by where they are. - draft: { - forClass: SM.Deck, - predicate: SOLID.instanceContainer, - target: draftsContainerOf, - idPrefix: "sm-draft", - }, -}; +const REGISTERED_CLASSES = { + instance: { forClass: SM.Instance, target: (container: string) => container }, + catalog: { forClass: DCAT.Catalog, target: catalogNodeUrlOf }, + deck: { forClass: SM.Deck, target: catalogUrlOf }, + card: { forClass: SM.Card, target: cardsContainerOf }, + reviewState: { forClass: SM.ReviewState, target: reviewsContainerOf }, + answer: { forClass: SM.Answer, target: historyContainerOf }, + // A draft's release document is an sm:Deck too: the drafts were told from the decks by where they are. + draft: { forClass: SM.Deck, target: draftsContainerOf }, +} satisfies Record string }>; +type RegisteredClass = keyof typeof REGISTERED_CLASSES; +const REGISTERED_CLASS_NAMES = Object.keys(REGISTERED_CLASSES) as RegisteredClass[]; /** * What a registration registers of the instance's data of one class, @@ -410,8 +379,12 @@ const DATA_CLASS_TARGETS: Record< * exact IRI, so a resource anywhere else below the container, another * app's, is never taken for the instance's. */ -function registeredOf(registration: Subject, dataClass: DataClass, container: string): string[] { - const { forClass, target } = DATA_CLASS_TARGETS[dataClass]; +function registeredOf( + registration: Subject, + dataClass: RegisteredClass, + container: string, +): string[] { + const { forClass, target } = REGISTERED_CLASSES[dataClass]; if (!getUrlAll(registration, RDF.type).includes(SOLID.TypeRegistration)) return []; if (!getUrlAll(registration, SOLID.forClass).includes(forClass)) return []; const ours = target(container); @@ -451,55 +424,35 @@ async function changeIndex( } } -/** Which classes of the instance's data a type index document registers. */ -export async function readRegisteredClasses( - indexUrl: string, - instanceUrl: string, - fetch: Fetch, -): Promise { - const container = ensureTrailingSlash(instanceUrl); - const things = getThingAll(await readDataset(indexUrl, fetch)); - return DATA_CLASSES.filter((dataClass) => - things.some((thing) => registeredOf(thing, dataClass, container).length > 0), - ); -} - /** - * Register the instance's data of each class given in a type index - * document, each a solid:TypeRegistration titled with the instance's - * name, in one save (see changeIndex); a class the index already - * registers (the same class, naming the same resource) is not added - * again. The classes added. + * Register the instance in a type index document: a + * solid:TypeRegistration for sm:Instance naming its container, titled + * with the instance's name, by which Solid Memo finds it. Its other data + * is reached from the instance, and gets no registration of its own. + * Saves nothing when the index already registers it (see changeIndex): + * the same class, naming the container with or without its trailing + * slash. */ -export async function addRegistrations( +export async function addInstanceRegistration( indexUrl: string, - { - instanceUrl, - title, - classes, - }: { instanceUrl: string; title: string; classes: readonly DataClass[] }, + { instanceUrl, title }: { instanceUrl: string; title: string }, newId: () => string, fetch: Fetch, -): Promise { +): Promise { const container = ensureTrailingSlash(instanceUrl); - return changeIndex(indexUrl, fetch, (dataset) => { - const things = getThingAll(dataset); - const missing = classes.filter( - (dataClass) => !things.some((thing) => registeredOf(thing, dataClass, container).length > 0), - ); - const updated = missing.reduce((current, dataClass) => { - const { forClass, predicate, target, idPrefix } = DATA_CLASS_TARGETS[dataClass]; - return setThing( - current, - buildThing(createThing({ url: `${indexUrl}#${idPrefix}-${newId()}` })) - .addIri(RDF.type, SOLID.TypeRegistration) - .addIri(SOLID.forClass, forClass) - .addIri(predicate, target(container)) - .addStringNoLocale(DCTERMS.title, title) - .build(), - ); - }, dataset); - return { dataset: updated, result: missing }; + await changeIndex(indexUrl, fetch, (dataset) => { + if ( + getThingAll(dataset).some((thing) => registeredOf(thing, "instance", container).length > 0) + ) { + return { dataset, result: undefined }; + } + const registration = buildThing(createThing({ url: `${indexUrl}#sm-inst-${newId()}` })) + .addIri(RDF.type, SOLID.TypeRegistration) + .addIri(SOLID.forClass, SM.Instance) + .addIri(SOLID.instanceContainer, container) + .addStringNoLocale(DCTERMS.title, title) + .build(); + return { dataset: setThing(dataset, registration), result: undefined }; }); } @@ -507,11 +460,11 @@ export async function addRegistrations( * Remove every registration of an instance's data from a type index * document (see changeIndex), by what each class's registration names * (registeredOf): the container for its sm:Instance registration, and - * for the others exactly the instance's catalog + * for those earlier versions wrote exactly the instance's catalog * (`catalog.ttl#catalog`), catalog document, `decks/`, - * `reviews/` and `history/`, never anything else below the container, - * which may be another app's. A registration that names something else - * besides keeps it, and loses only the links to the instance's data. + * `reviews/`, `history/` and `drafts/`, never anything else below the + * container, which may be another app's. A registration that names + * something else besides keeps it, and loses only the links to the instance's data. * Saves only when something was removed. The registrations changed are * set in one edit and those removed taken out in one (setThings, * removeThings), so many take time that grows with them, not their @@ -528,7 +481,9 @@ export async function removeInstanceRegistrations( const changed: Subject[] = []; const removed: Subject[] = []; for (const thing of getThingAll(dataset)) { - const ours = DATA_CLASSES.flatMap((dataClass) => registeredOf(thing, dataClass, container)); + const ours = REGISTERED_CLASS_NAMES.flatMap((dataClass) => + registeredOf(thing, dataClass, container), + ); if (ours.length === 0) continue; const kept = withoutRegistered(thing, ours); if (kept === null) removed.push(thing); @@ -565,8 +520,9 @@ function withoutRegistered(registration: Subject, urls: readonly string[]): Subj /** * Give every registration of an instance's data in a type index document - * a new title (dcterms:title), in one save (see changeIndex): each - * class's registration, by what it names (registeredOf), all set in one + * a new title (dcterms:title), in one save (see changeIndex): the + * instance's, and each one earlier versions wrote for a class of + * its data, by what it names (registeredOf), all set in one * edit (setThings), so many take time that grows with them, not their * square. Saves nothing when no registration needs the title. */ @@ -582,7 +538,9 @@ export async function renameInstanceRegistrations( .filter( (thing) => getStringNoLocale(thing, DCTERMS.title) !== title && - DATA_CLASSES.some((dataClass) => registeredOf(thing, dataClass, container).length > 0), + REGISTERED_CLASS_NAMES.some( + (dataClass) => registeredOf(thing, dataClass, container).length > 0, + ), ) .map((thing) => buildThing(thing).setStringNoLocale(DCTERMS.title, title).build()); if (renamed.length === 0) return { dataset, result: undefined }; diff --git a/packages/ui/src/i18n/de.json b/packages/ui/src/i18n/de.json index afeaed6bc..96253d81a 100644 --- a/packages/ui/src/i18n/de.json +++ b/packages/ui/src/i18n/de.json @@ -794,8 +794,7 @@ "keepOpen": "Lass diese Seite geöffnet, bis die Aktualisierung fertig ist. Falls sie abbricht, bleibt jedes Dokument lesbar, aktualisiert oder nicht, und du kannst den Rest später aktualisieren.", "step": { "read": "Zu aktualisierende Dokumente werden ermittelt", - "write": "Deine Dokumente werden aktualisiert", - "register": "Deine Daten werden für andere Apps auffindbar gemacht" + "write": "Deine Dokumente werden aktualisiert" }, "count": { "one": "{done} von {count} Dokument", @@ -1427,31 +1426,6 @@ "series": "Diagrammstufe {step}" } }, - "findable": { - "heading": "Für andere Apps auffindbar", - "intro": "Andere Solid-Apps finden deine Daten über die Typindizes in deinem Pod: ein Eintrag für jede Art von Daten, der angibt, wo sie liegen. Solid Memo fügt sie hinzu, wenn es eine Instanz erstellt oder ihr Format aktualisiert, und hier, wenn du darum bittest. Einen Eintrag, den du oder eine andere App entfernt hat, fügt es nie von selbst wieder hinzu.", - "loading": "Deine Typindizes werden gelesen…", - "class": { - "instance": "Diese Instanz (der Ordner von Solid Memo)", - "catalog": "Der Stapelkatalog dieser Instanz", - "deck": "Stapel", - "card": "Karten", - "reviewState": "Wiederholungsstände", - "answer": "Antworten", - "draft": "Entwürfe für Ausgaben" - }, - "index": { - "private": "privater Typindex", - "public": "öffentlicher Typindex" - }, - "registered": "eingetragen ({index})", - "missing": "fehlt ({index})", - "noPrivateIndex": "Wiederholungsstände und Antworten verraten, was du gelernt hast und wie gut. Deshalb werden sie nur in deinem privaten Typindex eingetragen. Du hast keinen, also sind sie nicht eingetragen.", - "unreadable": "Dein {index} konnte nicht gelesen werden. Was darin eingetragen ist, wird deshalb hier nicht angezeigt, und es wird nichts hinzugefügt.", - "add": "Fehlende Einträge hinzufügen", - "adding": "Wird eingetragen…", - "complete": "Alles ist eingetragen." - }, "registrationTargetChooser": { "legend": "Eintragen in", "intro": "Ein Typindex ist eine Liste in deinem Profil, die Apps sagt, wo deine Daten liegen. Einen privaten Typindex kannst nur du sehen.", diff --git a/packages/ui/src/i18n/en.json b/packages/ui/src/i18n/en.json index c1d05d155..08908f938 100644 --- a/packages/ui/src/i18n/en.json +++ b/packages/ui/src/i18n/en.json @@ -794,8 +794,7 @@ "keepOpen": "Keep this page open until the update is done. Should it be cut off, every document can still be read, updated or not, and you can update the rest later.", "step": { "read": "Finding what to update", - "write": "Updating your documents", - "register": "Making your data findable by other apps" + "write": "Updating your documents" }, "count": { "one": "{done} of {count} document", @@ -1427,31 +1426,6 @@ "series": "Chart step {step}" } }, - "findable": { - "heading": "Findable by other apps", - "intro": "Other Solid apps find your data through the type indexes in your Pod: one registration for each kind of data, saying where it is. Solid Memo adds them when it creates an instance or updates its format, and here when you ask. It never adds back by itself one that you or another app removed.", - "loading": "Reading your type indexes…", - "class": { - "instance": "This instance (Solid Memo's folder)", - "catalog": "Its catalog of decks", - "deck": "Decks", - "card": "Cards", - "reviewState": "Review states", - "answer": "Answers", - "draft": "Drafts of releases" - }, - "index": { - "private": "private type index", - "public": "public type index" - }, - "registered": "in your {index}", - "missing": "missing from your {index}", - "noPrivateIndex": "Review states and answers say what you studied and how well, so they are only ever registered in your private type index. You have none, so they are not registered.", - "unreadable": "Your {index} could not be read, so what it registers is not shown here, and nothing is added to it.", - "add": "Register what is missing", - "adding": "Registering…", - "complete": "Everything is registered." - }, "registrationTargetChooser": { "legend": "Register in", "intro": "A type index is a list in your profile that tells apps where your data is. Only you can see a private one.", diff --git a/packages/ui/src/i18n/es.json b/packages/ui/src/i18n/es.json index 7f9205db2..686366124 100644 --- a/packages/ui/src/i18n/es.json +++ b/packages/ui/src/i18n/es.json @@ -827,8 +827,7 @@ "keepOpen": "Mantén esta página abierta hasta que termine la actualización. Si se interrumpe, todos los documentos se podrán seguir leyendo, actualizados o no, y podrás actualizar el resto más tarde.", "step": { "read": "Buscando qué actualizar", - "write": "Actualizando tus documentos", - "register": "Haciendo que otras aplicaciones puedan localizar tus datos" + "write": "Actualizando tus documentos" }, "count": { "one": "{done} de {count} documento", @@ -1496,31 +1495,6 @@ "series": "Paso de gráfico {step}" } }, - "findable": { - "heading": "Localizable por otras aplicaciones", - "intro": "Otras aplicaciones Solid encuentran tus datos a través de los índices de tipos de tu Pod: un registro por cada tipo de datos, que dice dónde están. Solid Memo los añade cuando crea una instancia o actualiza su formato, y aquí cuando lo pides. Nunca vuelve a añadir por su cuenta un registro que hayas quitado tú o que haya quitado otra aplicación.", - "loading": "Leyendo tus índices de tipos…", - "class": { - "instance": "Esta instancia (la carpeta de Solid Memo)", - "catalog": "Su catálogo de mazos", - "deck": "Mazos", - "card": "Tarjetas", - "reviewState": "Estados de repaso", - "answer": "Respuestas", - "draft": "Borradores de publicaciones" - }, - "index": { - "private": "índice de tipos privado", - "public": "índice de tipos público" - }, - "registered": "en tu {index}", - "missing": "falta en tu {index}", - "noPrivateIndex": "Los estados de repaso y las respuestas dicen qué has estudiado y lo bien que lo has hecho, así que solo se registran en tu índice de tipos privado. No tienes ninguno, así que no están registrados.", - "unreadable": "No se pudo leer tu {index}, así que lo que registra no se muestra aquí, y no se le añade nada.", - "add": "Registrar lo que falta", - "adding": "Registrando…", - "complete": "Todo está registrado." - }, "registrationTargetChooser": { "legend": "Registrar en", "intro": "Un índice de tipos es una lista en tu perfil que indica a las aplicaciones dónde están tus datos. Solo tú puedes ver uno privado.", diff --git a/packages/ui/src/i18n/fr.json b/packages/ui/src/i18n/fr.json index b2897dfd5..4102bc141 100644 --- a/packages/ui/src/i18n/fr.json +++ b/packages/ui/src/i18n/fr.json @@ -827,8 +827,7 @@ "keepOpen": "Gardez cette page ouverte jusqu’à la fin de la mise à jour. Si elle est interrompue, chaque document reste lisible, mis à jour ou non, et vous pourrez mettre à jour le reste plus tard.", "step": { "read": "Recherche de ce qu’il faut mettre à jour", - "write": "Mise à jour de vos documents", - "register": "Référencement de vos données pour les autres applications" + "write": "Mise à jour de vos documents" }, "count": { "one": "Documents : {done} sur {count}", @@ -1496,31 +1495,6 @@ "series": "Palier de graphique {step}" } }, - "findable": { - "heading": "Repérable par d’autres applications", - "intro": "Les autres applications Solid trouvent vos données grâce aux index de types de votre Pod : un référencement par type de données, qui indique où elles se trouvent. Solid Memo ajoute ces référencements quand il crée une instance ou met à jour son format, et ici quand vous le demandez. Il ne rajoute jamais de lui-même un référencement que vous ou une autre application avez supprimé.", - "loading": "Lecture de vos index de types…", - "class": { - "instance": "Cette instance (dossier de Solid Memo)", - "catalog": "Son catalogue de paquets", - "deck": "Paquets", - "card": "Cartes", - "reviewState": "États de révision", - "answer": "Réponses", - "draft": "Brouillons de publications" - }, - "index": { - "private": "index de types privé", - "public": "index de types public" - }, - "registered": "dans votre {index}", - "missing": "absent de votre {index}", - "noPrivateIndex": "Les états de révision et les réponses indiquent ce que vous avez étudié et avec quel succès : ils ne sont donc jamais référencés ailleurs que dans votre index de types privé. Comme vous n’en avez pas, ils ne sont pas référencés.", - "unreadable": "Impossible de lire votre {index} : ce qu’il référence n’est donc pas affiché ici, et rien n’y est ajouté.", - "add": "Référencer ce qui manque", - "adding": "Référencement…", - "complete": "Tout est référencé." - }, "registrationTargetChooser": { "legend": "Référencer dans", "intro": "Un index de types est une liste dans votre profil qui indique aux applications où se trouvent vos données. Un index privé n’est visible que par vous.", diff --git a/packages/ui/src/i18n/ko.json b/packages/ui/src/i18n/ko.json index c7e544495..817b60aad 100644 --- a/packages/ui/src/i18n/ko.json +++ b/packages/ui/src/i18n/ko.json @@ -761,8 +761,7 @@ "keepOpen": "업데이트가 끝날 때까지 이 페이지를 열어 두세요. 중간에 끊기더라도 모든 문서는 업데이트 여부와 관계없이 읽을 수 있고, 나머지는 나중에 업데이트할 수 있어요.", "step": { "read": "업데이트할 문서 찾기", - "write": "문서 업데이트하기", - "register": "다른 앱에서 데이터를 찾을 수 있게 하기" + "write": "문서 업데이트하기" }, "count": { "other": "문서 {count}개 중 {done}개" @@ -1358,31 +1357,6 @@ "series": "차트 단계 {step}" } }, - "findable": { - "heading": "다른 앱에서 찾을 수 있음", - "intro": "다른 Solid 앱은 Pod의 타입 인덱스를 통해 데이터를 찾아요. 타입 인덱스에는 데이터 종류마다 그 위치를 알려 주는 등록이 하나씩 있어요. Solid Memo는 인스턴스를 만들거나 형식을 업데이트할 때, 그리고 여기서 요청할 때 등록을 추가해요. 직접 제거했거나 다른 앱이 제거한 등록을 스스로 다시 추가하지는 않아요.", - "loading": "타입 인덱스를 읽는 중…", - "class": { - "instance": "이 인스턴스(Solid Memo의 폴더)", - "catalog": "이 인스턴스의 덱 카탈로그", - "deck": "덱", - "card": "카드", - "reviewState": "복습 상태", - "answer": "답변", - "draft": "릴리스 초안" - }, - "index": { - "private": "비공개 타입 인덱스", - "public": "공개 타입 인덱스" - }, - "registered": "{index}에 있음", - "missing": "{index}에 없음", - "noPrivateIndex": "복습 상태와 답변에는 무엇을 얼마나 잘 학습했는지가 담겨 있어서 비공개 타입 인덱스에만 등록돼요. 비공개 타입 인덱스가 없어서 등록되지 않았어요.", - "unreadable": "{index}을(를) 읽지 못해서 여기에 등록 내용을 표시하지 않고, 아무것도 추가하지 않아요.", - "add": "없는 항목 등록", - "adding": "등록하는 중…", - "complete": "모두 등록되어 있어요." - }, "registrationTargetChooser": { "legend": "등록 위치", "intro": "타입 인덱스는 프로필에 있는 목록으로, 데이터가 어디에 있는지 앱에 알려 줘요. 비공개 타입 인덱스는 본인만 볼 수 있어요.", diff --git a/packages/ui/src/i18n/sv.json b/packages/ui/src/i18n/sv.json index ba208b1f8..6c16ee140 100644 --- a/packages/ui/src/i18n/sv.json +++ b/packages/ui/src/i18n/sv.json @@ -794,8 +794,7 @@ "keepOpen": "Håll sidan öppen tills uppdateringen är klar. Skulle den avbrytas går varje dokument ändå att läsa, uppdaterat eller inte, och du kan uppdatera resten senare.", "step": { "read": "Tar reda på vad som ska uppdateras", - "write": "Uppdaterar dina dokument", - "register": "Gör dina data hittbara för andra appar" + "write": "Uppdaterar dina dokument" }, "count": { "one": "{done} av {count} dokument", @@ -1427,31 +1426,6 @@ "series": "Diagramsteg {step}" } }, - "findable": { - "heading": "Hittbar för andra appar", - "intro": "Andra Solid-appar hittar dina data genom typindexen i din Pod: en registrering för varje sorts data, som talar om var den finns. Solid Memo lägger till dem när det skapar en instans eller uppdaterar dess format, och här när du ber om det. Det lägger aldrig av sig självt tillbaka en som du eller en annan app har tagit bort.", - "loading": "Läser dina typindex…", - "class": { - "instance": "Den här instansen (Solid Memos mapp)", - "catalog": "Dess katalog över kortlekar", - "deck": "Kortlekar", - "card": "Kort", - "reviewState": "Repetitionslägen", - "answer": "Svar", - "draft": "Utkast till utgåvor" - }, - "index": { - "private": "privata typindex", - "public": "publika typindex" - }, - "registered": "finns i ditt {index}", - "missing": "saknas i ditt {index}", - "noPrivateIndex": "Repetitionslägen och svar visar vad du har studerat och hur väl, så de registreras aldrig någon annanstans än i ditt privata typindex. Du har inget, så de är inte registrerade.", - "unreadable": "Ditt {index} gick inte att läsa, så det som registreras där visas inte här, och inget läggs till i det.", - "add": "Registrera det som saknas", - "adding": "Registrerar…", - "complete": "Allt är registrerat." - }, "registrationTargetChooser": { "legend": "Registrera i", "intro": "Ett typindex är en lista i din profil som talar om för appar var dina data finns. Bara du kan se ett privat typindex.", diff --git a/packages/ui/src/test/fakes/instances.ts b/packages/ui/src/test/fakes/instances.ts index 9ebffbd9b..c9699ff57 100644 --- a/packages/ui/src/test/fakes/instances.ts +++ b/packages/ui/src/test/fakes/instances.ts @@ -47,18 +47,6 @@ export function makeInstanceUseCasesFake( keptDocuments: [], })), detachInstance: vi.fn(async () => undefined), - dataClassRegistrations: vi.fn(async () => ({ - registrations: ( - ["instance", "catalog", "deck", "card", "reviewState", "answer", "draft"] as const - ).map((dataClass) => ({ - dataClass, - index: "private" as const, - registered: true, - })), - privateIndexMissing: false, - unreadableIndexes: [], - })), - registerDataClasses: vi.fn(async () => undefined), planMigration: vi.fn(async () => ({ decks: [], deckCount: 0, diff --git a/packages/ui/src/ui/FindableContainer.test.tsx b/packages/ui/src/ui/FindableContainer.test.tsx deleted file mode 100644 index 5682b315d..000000000 --- a/packages/ui/src/ui/FindableContainer.test.tsx +++ /dev/null @@ -1,146 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Anton Wiklund and Solid Memo contributors -// -// SPDX-License-Identifier: MIT - -import { describe, expect, it, vi } from "vitest"; -import { fireEvent, render, screen, waitFor, within } from "@testing-library/preact"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import type { UseCases } from "@solid-memo/application/useCases"; -import type { DataClassRegistrations, Instance } from "@solid-memo/domain/instance"; -import { FindableContainer } from "./FindableContainer"; -import { makeUseCasesFake } from "../test/useCasesFake"; - -const instance: Instance = { url: "https://pod.example/solid-memo/main/", name: "Main" }; -const session = { webId: "https://alice.example/profile/card#me" }; - -function renderContainer(useCases: UseCases) { - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - return render( - - - , - ); -} - -const someMissing: DataClassRegistrations = { - registrations: [ - { dataClass: "instance", index: "public", registered: true }, - { dataClass: "catalog", index: "public", registered: true }, - { dataClass: "deck", index: "public", registered: false }, - { dataClass: "card", index: "public", registered: false }, - { dataClass: "reviewState", index: "private", registered: false }, - { dataClass: "answer", index: "private", registered: true }, - ], - privateIndexMissing: false, - unreadableIndexes: [], -}; - -describe("FindableContainer", () => { - it("says which registrations of the instance's data are there and which are missing, and adds those on the user's say", async () => { - const registerDataClasses = vi.fn(async () => undefined); - const useCases = makeUseCasesFake({ - dataClassRegistrations: vi - .fn() - .mockResolvedValueOnce(someMissing) - .mockResolvedValue({ - registrations: someMissing.registrations.map((registration) => ({ - ...registration, - registered: true, - })), - privateIndexMissing: false, - unreadableIndexes: [], - }), - registerDataClasses, - }); - renderContainer(useCases); - const section = await screen.findByRole("region", { name: "Findable by other apps" }); - const items = (await within(section).findAllByRole("listitem")).map((item) => item.textContent); - expect(items).toEqual([ - "This instance (Solid Memo's folder): in your public type index", - "Its catalog of decks: in your public type index", - "Decks: missing from your public type index", - "Cards: missing from your public type index", - "Review states: missing from your private type index", - "Answers: in your private type index", - ]); - expect(useCases.dataClassRegistrations).toHaveBeenCalledWith(session, instance); - // Nothing is added until asked. - expect(registerDataClasses).not.toHaveBeenCalled(); - - fireEvent.click(within(section).getByRole("button", { name: "Register what is missing" })); - expect(await within(section).findByText("Everything is registered.")).toBeInTheDocument(); - expect(registerDataClasses).toHaveBeenCalledExactlyOnceWith(session, instance); - expect(within(section).queryByRole("button")).toBeNull(); - expect(within(section).queryByText(/missing from/)).toBeNull(); - }); - - it("says why review states and answers are not registered without a private type index", async () => { - const useCases = makeUseCasesFake({ - dataClassRegistrations: vi.fn(async () => ({ - registrations: [ - { dataClass: "instance" as const, index: "public" as const, registered: true }, - ], - privateIndexMissing: true, - unreadableIndexes: [], - })), - }); - renderContainer(useCases); - expect( - await screen.findByText( - /so they are only ever registered in your private type index. You have none/, - ), - ).toBeInTheDocument(); - expect(screen.getByText("Everything is registered.")).toBeInTheDocument(); - }); - - it("says which type index could not be read", async () => { - const useCases = makeUseCasesFake({ - dataClassRegistrations: vi.fn(async () => ({ - registrations: [ - { dataClass: "instance" as const, index: "public" as const, registered: true }, - ], - privateIndexMissing: false, - unreadableIndexes: ["private" as const], - })), - }); - renderContainer(useCases); - expect( - await screen.findByText( - "Your private type index could not be read, so what it registers is not shown here, and nothing is added to it.", - ), - ).toBeInTheDocument(); - expect(screen.queryByText(/You have none/)).toBeNull(); - }); - - it("says it is reading, and then registering, and shows what went wrong", async () => { - let fail: (error: Error) => void = () => undefined; - const useCases = makeUseCasesFake({ - dataClassRegistrations: vi.fn(async () => someMissing), - registerDataClasses: vi.fn( - () => - new Promise((_, reject) => { - fail = reject; - }), - ), - }); - renderContainer(useCases); - expect(screen.getByRole("status")).toHaveTextContent("Reading your type indexes…"); - fireEvent.click(await screen.findByRole("button", { name: "Register what is missing" })); - expect(await screen.findByRole("button", { name: "Registering…" })).toBeDisabled(); - fail(new Error("The index refused")); - await waitFor(() => expect(screen.getByRole("alert")).toHaveTextContent("The index refused")); - expect(screen.getByRole("button", { name: "Register what is missing" })).toBeEnabled(); - }); - - it("shows why the registrations cannot be read, and no list", async () => { - const useCases = makeUseCasesFake({ - dataClassRegistrations: vi.fn(async () => { - throw new Error("403 Forbidden"); - }), - }); - renderContainer(useCases); - await waitFor(() => expect(screen.getByRole("alert")).toHaveTextContent("403 Forbidden")); - expect(screen.queryByRole("status")).toBeNull(); - expect(screen.queryByRole("list")).toBeNull(); - }); -}); diff --git a/packages/ui/src/ui/FindableContainer.tsx b/packages/ui/src/ui/FindableContainer.tsx deleted file mode 100644 index 68537fdde..000000000 --- a/packages/ui/src/ui/FindableContainer.tsx +++ /dev/null @@ -1,80 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Anton Wiklund and Solid Memo contributors -// -// SPDX-License-Identifier: MIT - -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import type { UseCases } from "@solid-memo/application/useCases"; -import type { Instance } from "@solid-memo/domain/instance"; -import type { Session } from "@solid-memo/domain/session"; -import { ErrorMessage } from "./ErrorMessage"; -import { Loading } from "./Loading"; -import { useI18n } from "./i18n"; -import { queryKeys } from "./queryKeys"; - -/** - * "Findable by other apps" in Preferences (docs/data-model.md "Discovery - * chain"): the type index registrations of the instance's data, one per - * class and index, each said to be there or missing (an index that - * cannot be read said to be so), and a button that - * adds the missing ones. Nothing is added unless the user asks: the user, - * or another app, may have removed a registration on purpose. - */ -export function FindableContainer({ - useCases, - session, - instance, -}: { - useCases: UseCases; - session: Session; - instance: Instance; -}) { - const { t, errorText } = useI18n(); - const queryClient = useQueryClient(); - const queryKey = queryKeys.dataClassRegistrations(instance.url); - const registrationsQuery = useQuery({ - queryKey, - queryFn: () => useCases.dataClassRegistrations(session, instance), - }); - const registerMutation = useMutation({ - mutationFn: () => useCases.registerDataClasses(session, instance), - onSettled: () => queryClient.invalidateQueries({ queryKey }), - }); - const data = registrationsQuery.data; - const missing = data?.registrations.some((registration) => !registration.registered) ?? false; - return ( -

-

{t("findable.heading")}

-

{t("findable.intro")}

- {data === undefined ? ( - registrationsQuery.error === null && - ) : ( - <> -
    - {data.registrations.map((registration) => ( -
  • - {t(`findable.class.${registration.dataClass}`)}:{" "} - {t(registration.registered ? "findable.registered" : "findable.missing", { - index: t(`findable.index.${registration.index}`), - })} -
  • - ))} -
- {data.privateIndexMissing &&

{t("findable.noPrivateIndex")}

} - {data.unreadableIndexes.map((index) => ( -

- {t("findable.unreadable", { index: t(`findable.index.${index}`) })} -

- ))} - {missing ? ( - - ) : ( -

{t("findable.complete")}

- )} - - )} - -
- ); -} diff --git a/packages/ui/src/ui/InstanceUpdate.tsx b/packages/ui/src/ui/InstanceUpdate.tsx index dcb6a5737..dcf6c600f 100644 --- a/packages/ui/src/ui/InstanceUpdate.tsx +++ b/packages/ui/src/ui/InstanceUpdate.tsx @@ -18,7 +18,6 @@ function stepLabels(t: I18n["t"]): Record { return { read: t("instanceUpdate.step.read"), write: t("instanceUpdate.step.write"), - register: t("instanceUpdate.step.register"), }; } diff --git a/packages/ui/src/ui/MigrationContainer.test.tsx b/packages/ui/src/ui/MigrationContainer.test.tsx index 1bf06047d..2ad9f0753 100644 --- a/packages/ui/src/ui/MigrationContainer.test.tsx +++ b/packages/ui/src/ui/MigrationContainer.test.tsx @@ -170,19 +170,12 @@ describe("MigrationContainer", () => { ); act(() => report!({ step: "write", done: 1, total: 3, part: { done: 0, total: 1 } })); expect(screen.getByRole("region", { name: "Updating" })).toHaveTextContent("0 of 1 document"); - act(() => report!({ step: "register", done: 2, total: 3 })); - expect(screen.getByRole("status")).toHaveTextContent( - /^Making your data findable by other apps…$/, - ); + expect(screen.getByRole("status")).toHaveTextContent(/^Updating your documents…$/); expect( within(screen.getByRole("region", { name: "Updating" })) .getAllByRole("listitem") .map((step) => step.textContent), - ).toEqual([ - "✓Finding what to update (done)", - "✓Updating your documents (done)", - "➜Making your data findable by other apps (in progress)", - ]); + ).toEqual(["✓Finding what to update (done)", "➜Updating your documents (in progress)"]); expect(screen.getByRole("region", { name: "Updating" })).toHaveTextContent( "Should it be cut off, every document can still be read, updated or not, and you can update the rest later.", ); diff --git a/packages/ui/src/ui/Workspace.test.tsx b/packages/ui/src/ui/Workspace.test.tsx index 426886c59..02f3033a5 100644 --- a/packages/ui/src/ui/Workspace.test.tsx +++ b/packages/ui/src/ui/Workspace.test.tsx @@ -1100,34 +1100,6 @@ describe("Workspace", () => { expect(await screen.findByRole("heading", { name: "Decks" })).toBeInTheDocument(); }); - it("shows in the preferences how other apps find the instance's data, to a user and not to a guest", async () => { - window.history.replaceState( - null, - "", - routeToHash({ screen: "preferences", instanceUrl: instanceA.url }), - ); - const useCases = makeUseCases({ listInstances: vi.fn(async () => [instanceA]) }); - const view = renderWorkspace(useCases); - expect( - await screen.findByRole("region", { name: "Findable by other apps" }), - ).toBeInTheDocument(); - await waitFor(() => - expect(useCases.dataClassRegistrations).toHaveBeenCalledWith(session, instanceA), - ); - view.unmount(); - - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - const guest = makeUseCases({ listInstances: vi.fn(async () => [instanceA]) }); - render( - - - , - ); - expect(await screen.findByRole("heading", { name: "Study preferences" })).toBeInTheDocument(); - expect(screen.queryByRole("region", { name: "Findable by other apps" })).toBeNull(); - expect(guest.dataClassRegistrations).not.toHaveBeenCalled(); - }); - it("opens the statistics from the instance bar", async () => { renderWorkspace(makeUseCases({ listInstances: vi.fn(async () => [instanceA]) })); fireEvent.click(await screen.findByRole("link", { name: "Statistics" })); diff --git a/packages/ui/src/ui/Workspace.tsx b/packages/ui/src/ui/Workspace.tsx index d4825daed..5d091034b 100644 --- a/packages/ui/src/ui/Workspace.tsx +++ b/packages/ui/src/ui/Workspace.tsx @@ -40,7 +40,6 @@ import { GuestStudyOffer } from "./GuestStudyOffer"; import { InstanceBar } from "./InstanceBar"; import { InstanceCreator } from "./InstanceCreator"; import { InstancePickerContainer } from "./InstancePickerContainer"; -import { FindableContainer } from "./FindableContainer"; import { DataCheckNotice } from "./DataCheckNotice"; import { UnclaimedDecksNotice } from "./UnclaimedDecksNotice"; import { type DataCheck, useDataCheck } from "./dataCheck"; @@ -1137,19 +1136,13 @@ export function Workspace({ ); case "preferences": return ( - <> - navigate({ screen: "home", instanceUrl: instanceUrl! })} - /> - {/* A guest's pod is on this device: no other app looks there. */} - {session.guest !== true && ( - - )} - + navigate({ screen: "home", instanceUrl: instanceUrl! })} + /> ); case "palette": return ( diff --git a/packages/ui/src/ui/queryKeys.test.ts b/packages/ui/src/ui/queryKeys.test.ts index b4dd93f56..ed4564e04 100644 --- a/packages/ui/src/ui/queryKeys.test.ts +++ b/packages/ui/src/ui/queryKeys.test.ts @@ -55,7 +55,6 @@ describe("queryKeys", () => { queryKeys.interruptedMove(INSTANCE), queryKeys.guestStudy(), queryKeys.guestMergePlan("https://guest.example/", INSTANCE), - queryKeys.dataClassRegistrations(INSTANCE), queryKeys.library(), queryKeys.libraryCards(DECK), queryKeys.libraryUpgrade(DECK), @@ -109,7 +108,6 @@ describe("queryKeys", () => { ["interruptedMove", INSTANCE], ["guestStudy"], ["guestMergePlan", "https://guest.example/", INSTANCE], - ["dataClassRegistrations", INSTANCE], ["library"], ["libraryCards", DECK], ["libraryUpgrade", DECK], diff --git a/packages/ui/src/ui/queryKeys.ts b/packages/ui/src/ui/queryKeys.ts index 9f422feb4..97cb14baa 100644 --- a/packages/ui/src/ui/queryKeys.ts +++ b/packages/ui/src/ui/queryKeys.ts @@ -104,9 +104,6 @@ export const queryKeys = { guestInstanceUrl, targetUrl, ], - /** Whether an instance's data classes are registered in the type index. */ - dataClassRegistrations: (instanceUrl: string) => ["dataClassRegistrations", instanceUrl], - /** The deck library's index. */ library: () => ["library"], /** A library deck's cards. */ From c21ab91eeaa66df95834ae8d208394a83f9b4daa Mon Sep 17 00:00:00 2001 From: Anton Wiklund Date: Sun, 8 Nov 2026 22:32:18 +0100 Subject: [PATCH 2/2] feat(ui): let the user remove old type index registrations Earlier versions registered each class of an instance's data in the user's type indexes. The app no longer writes them, but they stay in the pods of instances users keep, and cleaning a type index by hand is hard. Preferences now lists those still there for the open instance and removes them when the user asks; it shows nothing when there are none, and is not offered to a guest. Only what is surely Solid Memo's goes: a registration of one of those classes, naming exactly that class's place in the instance, with the id the app gave it in the index document itself. The instance's own registration and other apps' stay, and a registration that also names something else keeps it. Nothing is removed without the user's say, on opening an instance or in a format update. Refs: #644 --- docs/data-model.md | 25 ++- docs/user-guide.md | 8 + e2e/pod/servers/jss/expected-failures.json | 1 + .../servers/nextcloud/expected-failures.json | 1 + e2e/pod/src/typeIndex.integration.test.ts | 64 +++++++- packages/application/src/instances.test.ts | 21 +++ packages/application/src/instances.ts | 25 +++ packages/application/src/ports.ts | 18 +++ packages/application/src/testing/fakePorts.ts | 2 + packages/domain/src/instance.ts | 26 ++++ .../solid/src/solidInstanceRepository.test.ts | 136 ++++++++++++++++ packages/solid/src/solidInstanceRepository.ts | 70 +++++++++ packages/solid/src/typeIndex.test.ts | 147 ++++++++++++++++++ packages/solid/src/typeIndex.ts | 113 ++++++++++++-- packages/ui/src/i18n/de.json | 21 +++ packages/ui/src/i18n/en.json | 21 +++ packages/ui/src/i18n/es.json | 21 +++ packages/ui/src/i18n/fr.json | 21 +++ packages/ui/src/i18n/ko.json | 21 +++ packages/ui/src/i18n/sv.json | 21 +++ packages/ui/src/test/fakes/instances.ts | 2 + .../src/ui/OldRegistrationsContainer.test.tsx | 135 ++++++++++++++++ .../ui/src/ui/OldRegistrationsContainer.tsx | 85 ++++++++++ packages/ui/src/ui/Workspace.test.tsx | 31 ++++ packages/ui/src/ui/Workspace.tsx | 25 ++- packages/ui/src/ui/queryKeys.test.ts | 2 + packages/ui/src/ui/queryKeys.ts | 7 + 27 files changed, 1044 insertions(+), 26 deletions(-) create mode 100644 packages/ui/src/ui/OldRegistrationsContainer.test.tsx create mode 100644 packages/ui/src/ui/OldRegistrationsContainer.tsx diff --git a/docs/data-model.md b/docs/data-model.md index db2f53446..0f70c7a2d 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -100,7 +100,8 @@ flowchart LR wrote one registration per class of that data beside the `sm:Instance` one ([examples below](#registrations)). The app no longer writes them, and still recognizes them: removing or deleting - an instance removes them, and renaming it retitles them. + an instance removes them, renaming it retitles them, and the user can + remove them from Preferences, keeping the instance. - **Writing a type index** saves it with `If-Match` the version read; on a 412 it tries again, three attempts in all (`changeIndex` in [typeIndex.ts](../packages/solid/src/typeIndex.ts)). The registration @@ -269,6 +270,28 @@ loses only the link to the instance's data. Those an earlier version wrote for an instance whose `sm:Instance` registration went some other way stay, as the app no longer lists that instance to remove. +From Preferences, the user can remove those an earlier version wrote +for the open instance on the list, keeping the instance. "Old +registrations" is offered when an index holds any, never to a guest +and never on its own (`removeOldRegistrations` in +[typeIndex.ts](../packages/solid/src/typeIndex.ts)). It reads and writes +only the user's own type indexes, and takes a registration for one of +the app's old ones only when all of these hold: + +- it's a `solid:TypeRegistration` for one of the classes above; +- it names that class's exact IRI for this instance, under either + predicate; +- its id is in the index document, with a fragment that starts with + that class's prefix and a dash, such as `#sm-card-`. + +A registration that names the same IRI under another id stays: it may +be another app's. An `sm:Instance` registration is never removed or +changed, even one that also registers a class above. A registration +that names something else too loses only the link to the instance's +data. Each index is written once, with `If-Match` as above, and only +when it holds one. An index that can't be read is said to be so, and +isn't written. + ## Instance layout ```text diff --git a/docs/user-guide.md b/docs/user-guide.md index 60c157bc7..405e82d62 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -358,6 +358,14 @@ Press **Save**. A deck's own preferences can set daily limits for that deck, where an empty field follows these, and show its cards in another language ([Languages](#languages)). +If you used Solid Memo before it registered only the instance, **Old +registrations** may show under the settings (not for a guest). It lists +the entries earlier versions added to your Pod's type indexes for each +kind of this instance's data. Press **Remove old registrations** to +take them out. Your data doesn't change, the instance stays on your +list, and entries other apps added stay where they are +([data-model.md](data-model.md#registrations)). + ## Languages Solid Memo speaks English, Swedish, Korean, German, Spanish and French. diff --git a/e2e/pod/servers/jss/expected-failures.json b/e2e/pod/servers/jss/expected-failures.json index 4b70c6eac..41e08f792 100644 --- a/e2e/pod/servers/jss/expected-failures.json +++ b/e2e/pod/servers/jss/expected-failures.json @@ -73,6 +73,7 @@ "reviewStateEdits.integration.test.ts > sets a card due on a day, its snapshot dropped, so a reset of the day makes it due today": "jss-lang-tag", "signedIn.integration.test.ts > changes what the stranger gets when the app makes a document public, and nothing else about it": "jss-own-iri", "typeIndex.integration.test.ts > links it from an extended profile when the WebID document is read-only": "jss-acl-turtle", + "typeIndex.integration.test.ts > lists and removes, on the user's say, the registrations an earlier version wrote for the instance's data and nothing else": "jss-no-extension", "typeIndex.integration.test.ts > makes a type index in the instance's storage, linked from a WebID document that has none": "jss-no-extension", "typeIndex.integration.test.ts > refuses, saying where it tried, and keeps no instance, when no profile document can be written": "jss-acl-turtle", "typeIndex.integration.test.ts > registers an instance publicly alone, and removes it and an earlier version's registrations of its data from both indexes when deleted": "jss-no-extension", diff --git a/e2e/pod/servers/nextcloud/expected-failures.json b/e2e/pod/servers/nextcloud/expected-failures.json index 77a2f8047..86337a0d8 100644 --- a/e2e/pod/servers/nextcloud/expected-failures.json +++ b/e2e/pod/servers/nextcloud/expected-failures.json @@ -21,6 +21,7 @@ "releases.integration.test.ts > finishes a publishing cut short by publishing again, and keeps the release when the instance is deleted": "nc-acl-link", "releases.integration.test.ts > publishes a draft as one document anyone can read, lists it, freezes the draft, and never writes over it": "nc-acl-link", "typeIndex.integration.test.ts > links it from an extended profile when the WebID document is read-only": "nc-reported-privately", + "typeIndex.integration.test.ts > lists and removes, on the user's say, the registrations an earlier version wrote for the instance's data and nothing else": "nc-put-type", "typeIndex.integration.test.ts > makes a type index in the instance's storage, linked from a WebID document that has none": "nc-put-type", "typeIndex.integration.test.ts > refuses, saying where it tried, and keeps no instance, when no profile document can be written": "nc-reported-privately", "typeIndex.integration.test.ts > registers an instance publicly alone, and removes it and an earlier version's registrations of its data from both indexes when deleted": "nc-put-type", diff --git a/e2e/pod/src/typeIndex.integration.test.ts b/e2e/pod/src/typeIndex.integration.test.ts index 551bdcd00..3d3917d13 100644 --- a/e2e/pod/src/typeIndex.integration.test.ts +++ b/e2e/pod/src/typeIndex.integration.test.ts @@ -14,7 +14,7 @@ * its data gets no registration of its own. The registrations earlier * versions wrote for each class of its data, which an index may still * hold, take the instance's new name with it and go when the instance is - * removed, while another app's stay. Runs against each server + * removed, or on the user's say, while another app's stay. Runs against each server * globalSetup.ts starts. Every run here shares the storage's one type * index; a file's tests run one at a time. */ @@ -313,6 +313,68 @@ describe.each(SERVERS)("registering an instance on $name", ({ url: server }) => expect(await registrationsIn(index, instance.url)).toEqual([foreign]); }); + it("lists and removes, on the user's say, the registrations an earlier version wrote for the instance's data and nothing else", async () => { + const { base, webId } = await profile(server, () => [], { privateIndex: true }); + const index = `${base}settings/privateTypeIndex.ttl`; + const repository = instances(); + const instance = await repository.createInstance({ + webId, + containerUrl: `${base}solid-memo/`, + name: "Main", + registrationTarget: "private", + }); + const legacy = ["catalog", "deck", "card", "reviewState", "answer", "draft"] as const; + const foreign = { + subject: `${index}#other-app`, + forClass: [`${SM}Card`], + predicate: "instanceContainer", + target: `${instance.url}other-app/`, + title: ["Other"], + }; + await seedLegacyRegistrations(index, instance.url, [...legacy], foreign.target); + // The instance's cards registered as an earlier version did, but not by its name for them: maybe another app's. + const lookalike = { + subject: `${index}#cards`, + forClass: [`${SM}Card`], + predicate: "instanceContainer", + target: `${instance.url}decks/`, + title: ["Lookalike"], + }; + const insertion = await fetch(index, { + method: "PATCH", + headers: { "content-type": "application/sparql-update" }, + body: `INSERT DATA { + <${lookalike.subject}> <${SOLID_TERMS}TypeRegistration> . + <${lookalike.subject}> <${SOLID_TERMS}forClass> <${SM}Card> . + <${lookalike.subject}> <${SOLID_TERMS}instanceContainer> <${lookalike.target}> . + <${lookalike.subject}> "Lookalike" . + }`, + }); + expect(insertion.ok, `PATCH ${index}: ${insertion.status}`).toBe(true); + const args = { webId, instanceUrl: instance.url }; + + expect(await repository.readOldRegistrations(args)).toEqual({ + registrations: legacy.map((dataClass) => ({ dataClass, index: "private" })), + unreadableIndexes: [], + }); + + await repository.removeOldRegistrations(args); + // The instance's registration stays, and so do another app's and the lookalike. + expect(await registrationsIn(index, instance.url)).toEqual( + [...registered(instance.url, ["instance"]), foreign, lookalike].sort((a, b) => + a.target.localeCompare(b.target), + ), + ); + expect(await repository.readOldRegistrations(args)).toEqual({ + registrations: [], + unreadableIndexes: [], + }); + expect(await repository.listInstances(webId)).toContainEqual({ + url: instance.url, + name: "Main", + }); + }); + it("registers an instance publicly alone, and removes it and an earlier version's registrations of its data from both indexes when deleted", async () => { const { base, card, webId } = await profile(server, () => [], { privateIndex: true }); const privateIndex = `${base}settings/privateTypeIndex.ttl`; diff --git a/packages/application/src/instances.test.ts b/packages/application/src/instances.test.ts index 7b6377a83..d3c4b9a5f 100644 --- a/packages/application/src/instances.test.ts +++ b/packages/application/src/instances.test.ts @@ -198,6 +198,26 @@ describe("instances", () => { expect(deps.instanceRepository.detachInstance).toHaveBeenCalledTimes(2); }); + it("oldRegistrations and removeOldRegistrations pass the WebID and the instance's address", async () => { + const deps = makeDeps(); + const old = { + registrations: [{ dataClass: "card" as const, index: "private" as const }], + unreadableIndexes: ["public" as const], + }; + vi.mocked(deps.instanceRepository.readOldRegistrations).mockResolvedValue(old); + const useCases = createUseCases(deps); + await expect(useCases.oldRegistrations(session, instance)).resolves.toEqual(old); + expect(deps.instanceRepository.readOldRegistrations).toHaveBeenCalledWith({ + webId: session.webId, + instanceUrl: instance.url, + }); + expect(deps.instanceRepository.removeOldRegistrations).not.toHaveBeenCalled(); + await useCases.removeOldRegistrations(session, instance); + expect(deps.instanceRepository.removeOldRegistrations).toHaveBeenCalledExactlyOnceWith({ + webId: session.webId, + instanceUrl: instance.url, + }); + }); describe("format migration", () => { const other: Deck = { ...deck, @@ -360,6 +380,7 @@ describe("instances", () => { // Nothing outside the instance: no type index registration is added. expect(deps.instanceRepository.attachInstance).not.toHaveBeenCalled(); expect(deps.instanceRepository.renameRegistrations).not.toHaveBeenCalled(); + expect(deps.instanceRepository.removeOldRegistrations).not.toHaveBeenCalled(); expect(progress).toEqual([ "read 0/2", "write 1/2 (0 of 5)", diff --git a/packages/application/src/instances.ts b/packages/application/src/instances.ts index ba4bb05e5..96791c9cc 100644 --- a/packages/application/src/instances.ts +++ b/packages/application/src/instances.ts @@ -22,6 +22,7 @@ import { instanceName, type Instance, type InstanceDeletion, + type OldRegistrations, type RegistrationOptions, type RegistrationTarget, } from "@solid-memo/domain/instance"; @@ -104,6 +105,18 @@ export interface InstanceUseCases { * folder it would still hold, so it can only be deleted. */ detachInstance(session: Session, instance: Instance): Promise; + /** + * The registrations earlier versions wrote for the instance's data + * that are still in the user's own type indexes (docs/data-model.md + * "Registrations"), and which index could not be read. Reads only. + */ + oldRegistrations(session: Session, instance: Instance): Promise; + /** + * Remove the registrations oldRegistrations finds, and nothing else: + * never the instance's own. Only on the user's say, never on opening + * an instance or in a format update. + */ + removeOldRegistrations(session: Session, instance: Instance): Promise; /** * Give an instance a new name (instanceName: trimmed, not empty) * everywhere it is kept: its meta document, its catalog's title (and @@ -376,6 +389,18 @@ export function createInstanceUseCases({ instance, }); }, + oldRegistrations(session, instance) { + return instanceRepository.readOldRegistrations({ + webId: session.webId, + instanceUrl: instance.url, + }); + }, + removeOldRegistrations(session, instance) { + return instanceRepository.removeOldRegistrations({ + webId: session.webId, + instanceUrl: instance.url, + }); + }, async renameInstance(session, instance, typed) { const name = instanceName(typed); await again(async () => { diff --git a/packages/application/src/ports.ts b/packages/application/src/ports.ts index e7263ec2d..1d972e871 100644 --- a/packages/application/src/ports.ts +++ b/packages/application/src/ports.ts @@ -20,6 +20,7 @@ import type { InstanceDeletion, KeptDocuments, InstanceMeta, + OldRegistrations, RegistrationOptions, RegistrationTarget, } from "@solid-memo/domain/instance"; @@ -131,6 +132,23 @@ export interface InstanceRepository { * as it is, to attach again by its address. */ detachInstance(args: { webId: string; instance: Instance }): Promise; + /** + * Which registrations earlier versions wrote for the instance's data + * are still in the user's own type indexes (docs/data-model.md + * "Registrations"): only those surely the app's, by their class, what + * they name and the app's own name for them; never an sm:Instance + * one. An index that cannot be read is left out, and said to be + * unreadable; one that is not there holds none. Reads only. + */ + readOldRegistrations(args: { webId: string; instanceUrl: string }): Promise; + /** + * Remove the registrations readOldRegistrations finds, and nothing + * else: one save per index that holds any (If-Match, read again and + * retried when the index changed meanwhile), none in an index that + * cannot be read. Each index is tried whatever the other answers; the + * first failure is thrown once both were tried. + */ + removeOldRegistrations(args: { webId: string; instanceUrl: string }): Promise; /** * Give the instance's registrations a new title, in every type index * that registers it: its sm:Instance registration, which names it in diff --git a/packages/application/src/testing/fakePorts.ts b/packages/application/src/testing/fakePorts.ts index 64e598cc0..021452f7e 100644 --- a/packages/application/src/testing/fakePorts.ts +++ b/packages/application/src/testing/fakePorts.ts @@ -139,6 +139,8 @@ export function makeDeps() { saveMeta: vi.fn(async () => undefined), renameRegistrations: vi.fn(async () => undefined), upgradeMeta: vi.fn(async () => true), + readOldRegistrations: vi.fn(async () => ({ registrations: [], unreadableIndexes: [] })), + removeOldRegistrations: vi.fn(async () => undefined), }; const deckRepository: DeckRepository = { listDecks: vi.fn(async () => [deck]), diff --git a/packages/domain/src/instance.ts b/packages/domain/src/instance.ts index a529761ed..0f21f6b75 100644 --- a/packages/domain/src/instance.ts +++ b/packages/domain/src/instance.ts @@ -71,3 +71,29 @@ export interface InstanceDeletion extends KeptDocuments { /** Whether what the kept folder holds includes the releases published from the instance (`releases/`), which are kept. */ keptReleases: boolean; } + +/** + * The classes of an instance's data earlier versions of Solid Memo + * registered in the user's type indexes, one registration each, beside + * the instance's own (docs/data-model.md "Registrations"): its catalog, + * decks, cards, review states, answers and the drafts of releases. Only + * the instance is registered now; these are known so that the user can + * remove them. + */ +export const OLD_REGISTRATION_CLASSES = [ + "catalog", + "deck", + "card", + "reviewState", + "answer", + "draft", +] as const; +export type OldRegistrationClass = (typeof OLD_REGISTRATION_CLASSES)[number]; + +/** The registrations earlier versions wrote for an instance's data that are still in the user's type indexes. */ +export interface OldRegistrations { + /** The private index's first, each index's in OLD_REGISTRATION_CLASSES order. */ + registrations: { dataClass: OldRegistrationClass; index: RegistrationTarget }[]; + /** The user's type indexes that could not be read: what they hold is not known, and nothing is removed from them. */ + unreadableIndexes: RegistrationTarget[]; +} diff --git a/packages/solid/src/solidInstanceRepository.test.ts b/packages/solid/src/solidInstanceRepository.test.ts index fa9907ce2..cb882f05e 100644 --- a/packages/solid/src/solidInstanceRepository.test.ts +++ b/packages/solid/src/solidInstanceRepository.test.ts @@ -26,7 +26,9 @@ import { ensureTypeIndex, locateTypeIndexes, readInstanceRegistrations, + readOldRegistrations, removeInstanceRegistrations, + removeOldRegistrations, renameInstanceRegistrations, } from "./typeIndex"; import { DCTERMS, SM } from "./vocab"; @@ -90,6 +92,8 @@ beforeEach(() => { vi.mocked(createTypeIndex).mockReset(); vi.mocked(addInstanceRegistration).mockReset(); vi.mocked(removeInstanceRegistrations).mockReset(); + vi.mocked(readOldRegistrations).mockReset(); + vi.mocked(removeOldRegistrations).mockReset(); vi.mocked(deleteInstanceData).mockReset(); }); @@ -736,6 +740,138 @@ describe("removing registrations when an index fails", () => { } }); +describe("old registrations", () => { + const bothIndexes = (inPlaces: (url: string) => boolean = () => true) => + vi.mocked(locateTypeIndexes).mockResolvedValue({ + fetch: indexFetch, + inPlaces, + privateIndexUrl: PRIVATE_INDEX, + publicIndexUrl: PUBLIC_INDEX, + }); + const args = { webId: WEBID, instanceUrl: CONTAINER }; + + it("says which each of the user's own indexes holds, the private one's first", async () => { + bothIndexes(); + vi.mocked(readOldRegistrations).mockImplementation(async (index) => + index === PRIVATE_INDEX ? ["card", "answer"] : ["catalog"], + ); + + await expect(makeRepository().readOldRegistrations(args)).resolves.toEqual({ + registrations: [ + { dataClass: "card", index: "private" }, + { dataClass: "answer", index: "private" }, + { dataClass: "catalog", index: "public" }, + ], + unreadableIndexes: [], + }); + expect(readOldRegistrations).toHaveBeenCalledWith(PRIVATE_INDEX, CONTAINER, indexFetch); + expect(readOldRegistrations).toHaveBeenCalledWith(PUBLIC_INDEX, CONTAINER, indexFetch); + expect(removeOldRegistrations).not.toHaveBeenCalled(); + }); + + it("says which index could not be read, takes one that is not there as holding none, and reads none outside the profile's own places", async () => { + bothIndexes(); + vi.mocked(readOldRegistrations).mockImplementation(async (index) => { + throw index === PRIVATE_INDEX ? new Error("403 Forbidden") : { statusCode: 404 }; + }); + await expect(makeRepository().readOldRegistrations(args)).resolves.toEqual({ + registrations: [], + unreadableIndexes: ["private"], + }); + + vi.mocked(readOldRegistrations).mockReset().mockResolvedValue(["deck"]); + bothIndexes((url) => url === PRIVATE_INDEX); + await expect(makeRepository().readOldRegistrations(args)).resolves.toEqual({ + registrations: [{ dataClass: "deck", index: "private" }], + unreadableIndexes: [], + }); + expect(readOldRegistrations).toHaveBeenCalledOnce(); + + // One document named as both indexes is read once, as the private one. + vi.mocked(readOldRegistrations).mockReset().mockResolvedValue(["deck"]); + vi.mocked(locateTypeIndexes).mockResolvedValue({ + fetch: indexFetch, + inPlaces: () => true, + privateIndexUrl: PRIVATE_INDEX, + publicIndexUrl: PRIVATE_INDEX, + }); + await expect(makeRepository().readOldRegistrations(args)).resolves.toEqual({ + registrations: [{ dataClass: "deck", index: "private" }], + unreadableIndexes: [], + }); + expect(readOldRegistrations).toHaveBeenCalledOnce(); + }); + + it("removes them from each index that holds any, and writes to none that holds none or cannot be read", async () => { + vi.mocked(locateTypeIndexes).mockResolvedValue({ + fetch: indexFetch, + inPlaces: () => true, + privateIndexUrl: PRIVATE_INDEX, + publicIndexUrl: null, + }); + vi.mocked(readOldRegistrations).mockResolvedValue(["card"]); + await makeRepository().removeOldRegistrations(args); + expect(removeOldRegistrations).toHaveBeenCalledExactlyOnceWith( + PRIVATE_INDEX, + CONTAINER, + indexFetch, + ); + + vi.mocked(removeOldRegistrations).mockReset(); + bothIndexes(); + vi.mocked(readOldRegistrations).mockImplementation(async (index) => { + if (index === PRIVATE_INDEX) return []; + throw new Error("403 Forbidden"); + }); + await makeRepository().removeOldRegistrations(args); + expect(removeOldRegistrations).not.toHaveBeenCalled(); + + // Nor outside the profile's own places. + bothIndexes((url) => url === PRIVATE_INDEX); + vi.mocked(readOldRegistrations).mockResolvedValue(["card"]); + await makeRepository().removeOldRegistrations(args); + expect(vi.mocked(removeOldRegistrations).mock.calls.map((call) => call[0])).toEqual([ + PRIVATE_INDEX, + ]); + }); + + it("tries both indexes when one fails, and then says so", async () => { + bothIndexes(); + vi.mocked(readOldRegistrations).mockResolvedValue(["card"]); + const refused = new AppError("addFailed", { url: PRIVATE_INDEX, status: 500 }); + vi.mocked(removeOldRegistrations).mockImplementation(async (index) => { + if (index === PRIVATE_INDEX) throw refused; + }); + + await expect(makeRepository().removeOldRegistrations(args)).rejects.toBe(refused); + expect(removeOldRegistrations).toHaveBeenCalledTimes(2); + }); + + it("says the first failure when both indexes fail", async () => { + bothIndexes(); + vi.mocked(readOldRegistrations).mockResolvedValue(["card"]); + const privateRefused = new AppError("addFailed", { url: PRIVATE_INDEX, status: 500 }); + const publicRefused = new AppError("addFailed", { url: PUBLIC_INDEX, status: 500 }); + vi.mocked(removeOldRegistrations).mockImplementation(async (index) => { + throw index === PRIVATE_INDEX ? privateRefused : publicRefused; + }); + + await expect(makeRepository().removeOldRegistrations(args)).rejects.toBe(privateRefused); + expect(removeOldRegistrations).toHaveBeenCalledTimes(2); + }); + + it("takes an index gone since it was read as nothing left to remove", async () => { + bothIndexes(); + vi.mocked(readOldRegistrations).mockResolvedValue(["card"]); + vi.mocked(removeOldRegistrations).mockImplementation(async (index) => { + if (index === PRIVATE_INDEX) throw { statusCode: 404 }; + }); + + await expect(makeRepository().removeOldRegistrations(args)).resolves.toBeUndefined(); + expect(removeOldRegistrations).toHaveBeenCalledTimes(2); + }); +}); + describe("readMeta and upgradeMeta", () => { const META = `${CONTAINER}meta.ttl`; const meta = { name: "Main", createdAt: "2026-09-21T10:00:00.000Z", formatVersion: 1 }; diff --git a/packages/solid/src/solidInstanceRepository.ts b/packages/solid/src/solidInstanceRepository.ts index 3718dff07..de96b4052 100644 --- a/packages/solid/src/solidInstanceRepository.ts +++ b/packages/solid/src/solidInstanceRepository.ts @@ -11,6 +11,9 @@ import { INSTANCE_FORMAT_VERSION, type Instance, type InstanceMeta, + type OldRegistrationClass, + type OldRegistrations, + type RegistrationTarget, } from "@solid-memo/domain/instance"; import { instanceAddressOf, isInstanceAddress, metaUrlOf } from "@solid-memo/domain/instanceLayout"; import { isInstanceOutdated } from "@solid-memo/domain/migration"; @@ -25,7 +28,9 @@ import { ensureTypeIndex, locateTypeIndexes, readInstanceRegistrations, + readOldRegistrations, removeInstanceRegistrations, + removeOldRegistrations, renameInstanceRegistrations, type InstanceRegistration, type TypeIndexes, @@ -152,6 +157,34 @@ export function createSolidInstanceRepository({ return { url, name }; }, + async readOldRegistrations({ webId, instanceUrl }): Promise { + const registrations: OldRegistrations["registrations"] = []; + const unreadableIndexes: RegistrationTarget[] = []; + for (const index of await oldRegistrationsOf(webId, instanceUrl)) { + if (index.classes === null) unreadableIndexes.push(index.kind); + else + registrations.push( + ...index.classes.map((dataClass) => ({ dataClass, index: index.kind })), + ); + } + return { registrations, unreadableIndexes }; + }, + + async removeOldRegistrations({ webId, instanceUrl }) { + const failures: unknown[] = []; + for (const index of await oldRegistrationsOf(webId, instanceUrl)) { + // One that could not be read is not written to; one that holds none is not written either. + if (index.classes === null || index.classes.length === 0) continue; + try { + await removeOldRegistrations(index.url, instanceUrl, index.fetch); + } catch (error) { + // One gone since it was read has nothing left to remove. + if ((error as { statusCode?: number }).statusCode !== 404) failures.push(error); + } + } + if (failures.length > 0) throw failures[0]; + }, + async renameRegistrations({ webId, instanceUrl, title }) { const locations = await ownTypeIndexes(webId, fetch, publicFetch); for (const indexUrl of [locations.privateIndexUrl, locations.publicIndexUrl]) { @@ -213,6 +246,35 @@ export function createSolidInstanceRepository({ }, }; + /** + * Each of the user's own type indexes (ownTypeIndexes), the private + * one first, with the classes of the instance's data it holds a + * registration of that earlier versions wrote (readOldRegistrations): + * none when the index is not there (404), null when it cannot be read. + */ + async function oldRegistrationsOf( + webId: string, + instanceUrl: string, + ): Promise { + const locations = await ownTypeIndexes(webId, fetch, publicFetch); + const indexes: OldRegistrationsIndex[] = []; + for (const [kind, url] of [ + ["private", locations.privateIndexUrl], + ["public", locations.publicIndexUrl], + ] as const) { + // A profile that names one document as both indexes has it read once, as the private one. + if (url === null || indexes.some((index) => index.url === url)) continue; + let classes: OldRegistrationClass[] | null; + try { + classes = await readOldRegistrations(url, instanceUrl, locations.fetch); + } catch (error) { + classes = (error as { statusCode?: number }).statusCode === 404 ? [] : null; + } + indexes.push({ kind, url, fetch: locations.fetch, classes }); + } + return indexes; + } + /** * Drop the registrations of the instance's data from the user's own * type indexes, reading and writing only those. Each is tried whatever @@ -235,6 +297,14 @@ export function createSolidInstanceRepository({ } } +/** One of the user's own type indexes, with the old registrations it holds (null: unreadable). */ +type OldRegistrationsIndex = { + kind: RegistrationTarget; + url: string; + fetch: typeof globalThis.fetch; + classes: OldRegistrationClass[] | null; +}; + /** * The type indexes (locateTypeIndexes), but for one outside the * profile's own places, taken as none: it may be someone else's, so diff --git a/packages/solid/src/typeIndex.test.ts b/packages/solid/src/typeIndex.test.ts index 6059702dc..1d3717029 100644 --- a/packages/solid/src/typeIndex.test.ts +++ b/packages/solid/src/typeIndex.test.ts @@ -24,7 +24,9 @@ import { ensureTypeIndex, locateTypeIndexes, readInstanceRegistrations, + readOldRegistrations, removeInstanceRegistrations, + removeOldRegistrations, renameInstanceRegistrations, } from "./typeIndex"; import { DCAT, DCTERMS, FOAF, PIM, RDF, RDFS, SM, SOLID } from "./vocab"; @@ -1113,6 +1115,151 @@ describe("removeInstanceRegistrations", () => { }); }); +describe("readOldRegistrations and removeOldRegistrations", () => { + const MAIN = "https://alice.example/solid-memo/a/"; + /** One registration of each class's data earlier versions wrote for the instance, as they named them. */ + const ours = () => [ + registered("sm-cat-1", DCAT.Catalog, SOLID.instance, `${MAIN}catalog.ttl#catalog`), + registered("sm-deck-1", SM.Deck, SOLID.instance, `${MAIN}catalog.ttl`), + registered("sm-card-1", SM.Card, SOLID.instanceContainer, `${MAIN}decks/`), + registered("sm-review-1", SM.ReviewState, SOLID.instanceContainer, `${MAIN}reviews/`), + registered("sm-answer-1", SM.Answer, SOLID.instanceContainer, `${MAIN}history/`), + registered("sm-draft-1", SM.Deck, SOLID.instanceContainer, `${MAIN}drafts/`), + ]; + const instance = () => registered("sm-inst-1", SM.Instance, SOLID.instanceContainer, MAIN); + /** What is not the app's old registration of this instance's data, and is kept as it is. */ + const others = () => [ + // The same class and target, but not as the app named its own: maybe another app's. + registered("cards", SM.Card, SOLID.instanceContainer, `${MAIN}decks/`), + buildThing(createThing({ url: "https://alice.example/registrations/cards" })) + .addIri(RDF.type, SOLID.TypeRegistration) + .addIri(SOLID.forClass, SM.Card) + .addIri(SOLID.instanceContainer, `${MAIN}decks/`) + .build(), + // The app's prefix, but on a subject of another document than the index. + buildThing(createThing({ url: "https://alice.example/registrations#sm-card-3" })) + .addIri(RDF.type, SOLID.TypeRegistration) + .addIri(SOLID.forClass, SM.Card) + .addIri(SOLID.instanceContainer, `${MAIN}decks/`) + .build(), + // The app's prefix, but another class's, another instance's, or no registration. + registered("sm-card-2", SM.ReviewState, SOLID.instanceContainer, `${MAIN}reviews/`), + registered("sm-deck-2", SM.Deck, SOLID.instanceContainer, `${MAIN}drafts/`), + registered( + "sm-review-2", + SM.ReviewState, + SOLID.instanceContainer, + "https://alice.example/solid-memo/b/reviews/", + ), + buildThing(createThing({ url: `${PRIVATE_INDEX}#sm-answer-2` })) + .addIri(SOLID.forClass, SM.Answer) + .addIri(SOLID.instanceContainer, `${MAIN}history/`) + .build(), + // A prefix with no dash after it. + registered("sm-cards", SM.Card, SOLID.instanceContainer, `${MAIN}decks/`), + ]; + + it("finds each class's old registration of the instance's data in order, and only by the app's own names", async () => { + mockDatasets({ [PRIVATE_INDEX]: privateIndex(instance(), ...ours().reverse(), ...others()) }); + expect( + await readOldRegistrations(PRIVATE_INDEX, "https://alice.example/solid-memo/a", noFetch), + ).toEqual(["catalog", "deck", "card", "reviewState", "answer", "draft"]); + + mockDatasets({ [PRIVATE_INDEX]: privateIndex(instance(), ...others()) }); + expect(await readOldRegistrations(PRIVATE_INDEX, MAIN, noFetch)).toEqual([]); + expect(writeDocument).not.toHaveBeenCalled(); + }); + + it("removes them in one save, and keeps the instance's registration and everything not surely the app's", async () => { + mockDatasets({ [PRIVATE_INDEX]: privateIndex(instance(), ...ours(), ...others()) }); + + await removeOldRegistrations(PRIVATE_INDEX, "https://alice.example/solid-memo/a", noFetch); + + expect(writeDocument).toHaveBeenCalledOnce(); + const [saveUrl, saved] = vi.mocked(writeDocument).mock.calls[0]; + expect(saveUrl).toBe(PRIVATE_INDEX); + expect(getThingAll(saved as Dataset)).toEqual( + getThingAll(privateIndex(instance(), ...others())), + ); + }); + + it("never touches an sm:Instance registration, even one named and registering as the app's old ones", async () => { + const both = buildThing( + registered("sm-card-1", SM.Card, SOLID.instanceContainer, `${MAIN}decks/`), + ) + .addIri(SOLID.forClass, SM.Instance) + .addIri(SOLID.instanceContainer, MAIN) + .build(); + mockDatasets({ [PRIVATE_INDEX]: privateIndex(instance(), both) }); + + expect(await readOldRegistrations(PRIVATE_INDEX, MAIN, noFetch)).toEqual([]); + await removeOldRegistrations(PRIVATE_INDEX, MAIN, noFetch); + + expect(writeDocument).not.toHaveBeenCalled(); + }); + + it("of an old registration naming something else besides, removes only the link to the instance's data", async () => { + const shared = buildThing( + registered("sm-card-1", SM.Card, SOLID.instanceContainer, `${MAIN}decks/`), + ) + .addIri(SOLID.instanceContainer, "https://alice.example/flashcards/") + .build(); + mockDatasets({ [PRIVATE_INDEX]: privateIndex(instance(), shared) }); + + await removeOldRegistrations(PRIVATE_INDEX, MAIN, noFetch); + + const saved = vi.mocked(writeDocument).mock.calls[0][1] as Dataset; + expect(getThingAll(saved).map((thing) => thing.url)).toEqual([ + `${PRIVATE_INDEX}#sm-inst-1`, + `${PRIVATE_INDEX}#sm-card-1`, + ]); + expect( + getUrlAll(getThing(saved, `${PRIVATE_INDEX}#sm-card-1`)!, SOLID.instanceContainer), + ).toEqual(["https://alice.example/flashcards/"]); + expect(getUrlAll(getThing(saved, `${PRIVATE_INDEX}#sm-card-1`)!, SOLID.forClass)).toEqual([ + SM.Card, + ]); + }); + + it("reads the index again and removes what is still there when it changed meanwhile", async () => { + let reads = 0; + vi.mocked(readDocument).mockImplementation((async () => + readOf( + // Meanwhile another app registered its notes. + ++reads === 1 + ? privateIndex(instance(), ...ours()) + : privateIndex( + instance(), + ...ours(), + registered( + "notes", + "https://schema.org/NoteDigitalDocument", + SOLID.instanceContainer, + "https://alice.example/notes/", + ), + ), + )) as never); + vi.mocked(writeDocument).mockRejectedValueOnce(preconditionFailed()); + + await removeOldRegistrations(PRIVATE_INDEX, MAIN, noFetch); + + expect(writeDocument).toHaveBeenCalledTimes(2); + const saved = vi.mocked(writeDocument).mock.calls[1][1] as Dataset; + expect(getThingAll(saved).map((thing) => thing.url)).toEqual([ + `${PRIVATE_INDEX}#sm-inst-1`, + `${PRIVATE_INDEX}#notes`, + ]); + }); + + it("saves nothing when the index holds none", async () => { + mockDatasets({ [PRIVATE_INDEX]: privateIndex(instance(), ...others()) }); + + await removeOldRegistrations(PRIVATE_INDEX, MAIN, noFetch); + + expect(writeDocument).not.toHaveBeenCalled(); + }); +}); + describe("renameInstanceRegistrations", () => { const MAIN = "https://alice.example/solid-memo/main/"; const args = { containerUrl: MAIN, title: "Languages" }; diff --git a/packages/solid/src/typeIndex.ts b/packages/solid/src/typeIndex.ts index b2c336c2c..5caf57ad7 100644 --- a/packages/solid/src/typeIndex.ts +++ b/packages/solid/src/typeIndex.ts @@ -31,6 +31,8 @@ import { DCAT, DCTERMS, FOAF, PIM, RDF, RDFS, SM, SOLID } from "./vocab"; import { createPlacesFetch, isInPlaces, profilePlacesOf } from "./profilePlaces"; import { createSolidStorageGateway } from "./solidStorageGateway"; import { AppError } from "@solid-memo/domain/appError"; +import { documentUrlOf } from "@solid-memo/domain/subjectUrl"; +import { OLD_REGISTRATION_CLASSES, type OldRegistrationClass } from "@solid-memo/domain/instance"; import { cardsContainerOf, catalogNodeUrlOf, @@ -355,20 +357,25 @@ export async function readInstanceRegistrations( * The registrations of an instance's data Solid Memo recognizes as its * own (docs/data-model.md "Discovery chain"): the class and what it * names. Only the instance's own is written. Earlier versions of the - * app wrote the others too, one per class of the instance's data; they - * are known here so that removing or renaming an instance still finds - * them in the user's type indexes. + * app wrote the others too, one per class of the instance's data, each + * with a subject whose fragment starts with the class's `idPrefix` and + * a dash (`#sm-card-…`); they are known here so that removing or + * renaming an instance still finds them in the user's type indexes, and + * so that the user can remove them (removeOldRegistrations). */ const REGISTERED_CLASSES = { instance: { forClass: SM.Instance, target: (container: string) => container }, - catalog: { forClass: DCAT.Catalog, target: catalogNodeUrlOf }, - deck: { forClass: SM.Deck, target: catalogUrlOf }, - card: { forClass: SM.Card, target: cardsContainerOf }, - reviewState: { forClass: SM.ReviewState, target: reviewsContainerOf }, - answer: { forClass: SM.Answer, target: historyContainerOf }, + catalog: { forClass: DCAT.Catalog, target: catalogNodeUrlOf, idPrefix: "sm-cat" }, + deck: { forClass: SM.Deck, target: catalogUrlOf, idPrefix: "sm-deck" }, + card: { forClass: SM.Card, target: cardsContainerOf, idPrefix: "sm-card" }, + reviewState: { forClass: SM.ReviewState, target: reviewsContainerOf, idPrefix: "sm-review" }, + answer: { forClass: SM.Answer, target: historyContainerOf, idPrefix: "sm-answer" }, // A draft's release document is an sm:Deck too: the drafts were told from the decks by where they are. - draft: { forClass: SM.Deck, target: draftsContainerOf }, -} satisfies Record string }>; + draft: { forClass: SM.Deck, target: draftsContainerOf, idPrefix: "sm-draft" }, +} satisfies Record< + "instance" | OldRegistrationClass, + { forClass: string; target: (container: string) => string; idPrefix?: string } +>; type RegisteredClass = keyof typeof REGISTERED_CLASSES; const REGISTERED_CLASS_NAMES = Object.keys(REGISTERED_CLASSES) as RegisteredClass[]; @@ -464,26 +471,98 @@ export async function addInstanceRegistration( * (`catalog.ttl#catalog`), catalog document, `decks/`, * `reviews/`, `history/` and `drafts/`, never anything else below the * container, which may be another app's. A registration that names - * something else besides keeps it, and loses only the links to the instance's data. + * something else besides keeps it, and loses only the links to the + * instance's data. Saves only when something was removed + * (removeRegistered). + */ +export async function removeInstanceRegistrations( + indexUrl: string, + containerUrl: string, + fetch: Fetch, +): Promise { + const container = ensureTrailingSlash(containerUrl); + await removeRegistered(indexUrl, fetch, (thing) => + REGISTERED_CLASS_NAMES.flatMap((dataClass) => registeredOf(thing, dataClass, container)), + ); +} + +/** + * Which of the registrations earlier versions wrote for an instance's + * data a type index document holds (oldRegisteredOf), by class, in + * OLD_REGISTRATION_CLASSES order. Reads only. + */ +export async function readOldRegistrations( + indexUrl: string, + containerUrl: string, + fetch: Fetch, +): Promise { + const container = ensureTrailingSlash(containerUrl); + const things = getThingAll(await readDataset(indexUrl, fetch)); + return OLD_REGISTRATION_CLASSES.filter((dataClass) => + things.some((thing) => oldRegisteredOf(thing, dataClass, indexUrl, container).length > 0), + ); +} + +/** + * Remove from a type index document the registrations earlier versions + * wrote for an instance's data (oldRegisteredOf), and nothing else: + * never an sm:Instance registration, and of a registration that names + * something else besides, only the link to the instance's data. One + * save, made only when something was removed (removeRegistered). + */ +export async function removeOldRegistrations( + indexUrl: string, + containerUrl: string, + fetch: Fetch, +): Promise { + const container = ensureTrailingSlash(containerUrl); + await removeRegistered(indexUrl, fetch, (thing) => + OLD_REGISTRATION_CLASSES.flatMap((dataClass) => + oldRegisteredOf(thing, dataClass, indexUrl, container), + ), + ); +} + +/** + * What a registration earlier versions wrote for the instance's data of + * one class registers (registeredOf), when it is surely one of theirs: + * its subject is in the index document, with a fragment that starts with + * the class's idPrefix and a dash, as they named it, and it is no + * sm:Instance registration, which is kept whole. Another registration + * naming the same, which may be another app's, registers nothing here. + */ +function oldRegisteredOf( + registration: Subject, + dataClass: OldRegistrationClass, + indexUrl: string, + container: string, +): string[] { + const ownId = `${documentUrlOf(indexUrl)}#${REGISTERED_CLASSES[dataClass].idPrefix}-`; + if (!registration.url.startsWith(ownId)) return []; + if (getUrlAll(registration, SOLID.forClass).includes(SM.Instance)) return []; + return registeredOf(registration, dataClass, container); +} + +/** + * Take out of a type index document (see changeIndex) what each + * registration registers of the instance's data (`registered`): the + * registration whole when it registers nothing else (withoutRegistered). * Saves only when something was removed. The registrations changed are * set in one edit and those removed taken out in one (setThings, * removeThings), so many take time that grows with them, not their * square. */ -export async function removeInstanceRegistrations( +async function removeRegistered( indexUrl: string, - containerUrl: string, fetch: Fetch, + registered: (registration: Subject) => string[], ): Promise { - const container = ensureTrailingSlash(containerUrl); await changeIndex(indexUrl, fetch, (dataset) => { // Every registration changed set at once, and every one removed taken out at once. const changed: Subject[] = []; const removed: Subject[] = []; for (const thing of getThingAll(dataset)) { - const ours = REGISTERED_CLASS_NAMES.flatMap((dataClass) => - registeredOf(thing, dataClass, container), - ); + const ours = registered(thing); if (ours.length === 0) continue; const kept = withoutRegistered(thing, ours); if (kept === null) removed.push(thing); diff --git a/packages/ui/src/i18n/de.json b/packages/ui/src/i18n/de.json index 96253d81a..e987bc478 100644 --- a/packages/ui/src/i18n/de.json +++ b/packages/ui/src/i18n/de.json @@ -1426,6 +1426,27 @@ "series": "Diagrammstufe {step}" } }, + "oldRegistrations": { + "heading": "Alte Einträge", + "intro": "Frühere Versionen von Solid Memo haben jede Art von Daten dieser Instanz in deinen Typindizes eingetragen. Jetzt wird nur noch die Instanz eingetragen, und der Rest wird über sie gefunden. Das Entfernen ändert keine deiner Daten, und es werden nur die eigenen Einträge von Solid Memo entfernt.", + "class": { + "catalog": "Der Stapelkatalog dieser Instanz", + "deck": "Stapel", + "card": "Karten", + "reviewState": "Wiederholungsstände", + "answer": "Antworten", + "draft": "Entwürfe für Ausgaben" + }, + "index": { + "private": "privater Typindex", + "public": "öffentlicher Typindex" + }, + "in": "eingetragen ({index})", + "unreadable": "Dein {index} konnte nicht gelesen werden. Was darin steht, wird deshalb hier nicht angezeigt, und es wird nichts daraus entfernt.", + "remove": "Alte Einträge entfernen", + "removing": "Wird entfernt…", + "removed": "Entfernt." + }, "registrationTargetChooser": { "legend": "Eintragen in", "intro": "Ein Typindex ist eine Liste in deinem Profil, die Apps sagt, wo deine Daten liegen. Einen privaten Typindex kannst nur du sehen.", diff --git a/packages/ui/src/i18n/en.json b/packages/ui/src/i18n/en.json index 08908f938..88762ebb5 100644 --- a/packages/ui/src/i18n/en.json +++ b/packages/ui/src/i18n/en.json @@ -1426,6 +1426,27 @@ "series": "Chart step {step}" } }, + "oldRegistrations": { + "heading": "Old registrations", + "intro": "Earlier versions of Solid Memo registered each kind of this instance's data in your type indexes. It now registers only the instance, and reaches the rest from it. Removing these changes none of your data, and removes only Solid Memo's own entries.", + "class": { + "catalog": "This instance's catalog of decks", + "deck": "Decks", + "card": "Cards", + "reviewState": "Review states", + "answer": "Answers", + "draft": "Drafts of releases" + }, + "index": { + "private": "private type index", + "public": "public type index" + }, + "in": "in your {index}", + "unreadable": "Your {index} could not be read, so what it holds is not shown here, and nothing is removed from it.", + "remove": "Remove old registrations", + "removing": "Removing…", + "removed": "Removed." + }, "registrationTargetChooser": { "legend": "Register in", "intro": "A type index is a list in your profile that tells apps where your data is. Only you can see a private one.", diff --git a/packages/ui/src/i18n/es.json b/packages/ui/src/i18n/es.json index 686366124..5f12b502b 100644 --- a/packages/ui/src/i18n/es.json +++ b/packages/ui/src/i18n/es.json @@ -1495,6 +1495,27 @@ "series": "Paso de gráfico {step}" } }, + "oldRegistrations": { + "heading": "Registros antiguos", + "intro": "Las versiones anteriores de Solid Memo registraban cada tipo de datos de esta instancia en tus índices de tipos. Ahora solo registra la instancia, y llega al resto desde ella. Quitarlos no cambia ninguno de tus datos, y solo se quitan las entradas propias de Solid Memo.", + "class": { + "catalog": "El catálogo de mazos de esta instancia", + "deck": "Mazos", + "card": "Tarjetas", + "reviewState": "Estados de repaso", + "answer": "Respuestas", + "draft": "Borradores de publicaciones" + }, + "index": { + "private": "índice de tipos privado", + "public": "índice de tipos público" + }, + "in": "en tu {index}", + "unreadable": "No se pudo leer tu {index}, así que lo que contiene no se muestra aquí, y no se le quita nada.", + "remove": "Quitar los registros antiguos", + "removing": "Quitando…", + "removed": "Quitados." + }, "registrationTargetChooser": { "legend": "Registrar en", "intro": "Un índice de tipos es una lista en tu perfil que indica a las aplicaciones dónde están tus datos. Solo tú puedes ver uno privado.", diff --git a/packages/ui/src/i18n/fr.json b/packages/ui/src/i18n/fr.json index 4102bc141..be0546ad6 100644 --- a/packages/ui/src/i18n/fr.json +++ b/packages/ui/src/i18n/fr.json @@ -1495,6 +1495,27 @@ "series": "Palier de graphique {step}" } }, + "oldRegistrations": { + "heading": "Anciens référencements", + "intro": "Les versions précédentes de Solid Memo référençaient chaque type de données de cette instance dans vos index de types. Désormais, seule l’instance est référencée, et le reste est trouvé à partir d’elle. Les supprimer ne modifie aucune de vos données, et seules les entrées de Solid Memo sont supprimées.", + "class": { + "catalog": "Le catalogue de paquets de cette instance", + "deck": "Paquets", + "card": "Cartes", + "reviewState": "États de révision", + "answer": "Réponses", + "draft": "Brouillons de publications" + }, + "index": { + "private": "index de types privé", + "public": "index de types public" + }, + "in": "dans votre {index}", + "unreadable": "Impossible de lire votre {index} : ce qu’il contient n’est donc pas affiché ici, et rien n’en est supprimé.", + "remove": "Supprimer les anciens référencements", + "removing": "Suppression…", + "removed": "Supprimés." + }, "registrationTargetChooser": { "legend": "Référencer dans", "intro": "Un index de types est une liste dans votre profil qui indique aux applications où se trouvent vos données. Un index privé n’est visible que par vous.", diff --git a/packages/ui/src/i18n/ko.json b/packages/ui/src/i18n/ko.json index 817b60aad..533fe133f 100644 --- a/packages/ui/src/i18n/ko.json +++ b/packages/ui/src/i18n/ko.json @@ -1357,6 +1357,27 @@ "series": "차트 단계 {step}" } }, + "oldRegistrations": { + "heading": "이전 등록", + "intro": "이전 버전의 Solid Memo는 이 인스턴스의 데이터 종류마다 타입 인덱스에 등록했어요. 지금은 인스턴스만 등록하고, 나머지는 인스턴스에서 찾아요. 이 등록을 제거해도 데이터는 바뀌지 않으며, Solid Memo가 만든 항목만 제거돼요.", + "class": { + "catalog": "이 인스턴스의 덱 카탈로그", + "deck": "덱", + "card": "카드", + "reviewState": "복습 상태", + "answer": "답변", + "draft": "릴리스 초안" + }, + "index": { + "private": "비공개 타입 인덱스", + "public": "공개 타입 인덱스" + }, + "in": "{index}에 있음", + "unreadable": "{index}을(를) 읽지 못해서 여기에 내용을 표시하지 않고, 아무것도 제거하지 않아요.", + "remove": "이전 등록 제거", + "removing": "제거하는 중…", + "removed": "제거했어요." + }, "registrationTargetChooser": { "legend": "등록 위치", "intro": "타입 인덱스는 프로필에 있는 목록으로, 데이터가 어디에 있는지 앱에 알려 줘요. 비공개 타입 인덱스는 본인만 볼 수 있어요.", diff --git a/packages/ui/src/i18n/sv.json b/packages/ui/src/i18n/sv.json index 6c16ee140..c41d64e94 100644 --- a/packages/ui/src/i18n/sv.json +++ b/packages/ui/src/i18n/sv.json @@ -1426,6 +1426,27 @@ "series": "Diagramsteg {step}" } }, + "oldRegistrations": { + "heading": "Gamla registreringar", + "intro": "Tidigare versioner av Solid Memo registrerade varje sorts data i den här instansen i dina typindex. Nu registreras bara instansen, och resten nås från den. Att ta bort dem ändrar ingenting i dina data, och bara Solid Memos egna poster tas bort.", + "class": { + "catalog": "Den här instansens katalog över kortlekar", + "deck": "Kortlekar", + "card": "Kort", + "reviewState": "Repetitionslägen", + "answer": "Svar", + "draft": "Utkast till utgåvor" + }, + "index": { + "private": "privata typindex", + "public": "publika typindex" + }, + "in": "i ditt {index}", + "unreadable": "Ditt {index} gick inte att läsa, så det som finns där visas inte här, och inget tas bort ur det.", + "remove": "Ta bort gamla registreringar", + "removing": "Tar bort…", + "removed": "Borttagna." + }, "registrationTargetChooser": { "legend": "Registrera i", "intro": "Ett typindex är en lista i din profil som talar om för appar var dina data finns. Bara du kan se ett privat typindex.", diff --git a/packages/ui/src/test/fakes/instances.ts b/packages/ui/src/test/fakes/instances.ts index c9699ff57..96263ceb5 100644 --- a/packages/ui/src/test/fakes/instances.ts +++ b/packages/ui/src/test/fakes/instances.ts @@ -47,6 +47,8 @@ export function makeInstanceUseCasesFake( keptDocuments: [], })), detachInstance: vi.fn(async () => undefined), + oldRegistrations: vi.fn(async () => ({ registrations: [], unreadableIndexes: [] })), + removeOldRegistrations: vi.fn(async () => undefined), planMigration: vi.fn(async () => ({ decks: [], deckCount: 0, diff --git a/packages/ui/src/ui/OldRegistrationsContainer.test.tsx b/packages/ui/src/ui/OldRegistrationsContainer.test.tsx new file mode 100644 index 000000000..eb98276e1 --- /dev/null +++ b/packages/ui/src/ui/OldRegistrationsContainer.test.tsx @@ -0,0 +1,135 @@ +// SPDX-FileCopyrightText: 2026 Anton Wiklund and Solid Memo contributors +// +// SPDX-License-Identifier: MIT + +import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/preact"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { UseCases } from "@solid-memo/application/useCases"; +import type { Instance, OldRegistrations } from "@solid-memo/domain/instance"; +import { OldRegistrationsContainer } from "./OldRegistrationsContainer"; +import { makeUseCasesFake } from "../test/useCasesFake"; + +const instance: Instance = { url: "https://pod.example/solid-memo/main/", name: "Main" }; +const session = { webId: "https://alice.example/profile/card#me" }; + +function renderContainer(useCases: UseCases) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + , + ); +} + +const some: OldRegistrations = { + registrations: [ + { dataClass: "catalog", index: "private" }, + { dataClass: "reviewState", index: "private" }, + { dataClass: "draft", index: "private" }, + { dataClass: "deck", index: "public" }, + { dataClass: "card", index: "public" }, + { dataClass: "answer", index: "public" }, + ], + unreadableIndexes: [], +}; +const none: OldRegistrations = { registrations: [], unreadableIndexes: [] }; + +describe("OldRegistrationsContainer", () => { + it("lists the registrations earlier versions wrote, and removes them on the user's say, then says so", async () => { + const removeOldRegistrations = vi.fn(async () => undefined); + const useCases = makeUseCasesFake({ + oldRegistrations: vi + .fn() + .mockResolvedValueOnce(some) + .mockResolvedValue(none), + removeOldRegistrations, + }); + renderContainer(useCases); + const section = await screen.findByRole("region", { name: "Old registrations" }); + expect(section).toHaveTextContent( + "It now registers only the instance, and reaches the rest from it. Removing these changes none of your data, and removes only Solid Memo's own entries.", + ); + expect( + within(section) + .getAllByRole("listitem") + .map((item) => item.textContent), + ).toEqual([ + "This instance's catalog of decks: in your private type index", + "Review states: in your private type index", + "Drafts of releases: in your private type index", + "Decks: in your public type index", + "Cards: in your public type index", + "Answers: in your public type index", + ]); + expect(useCases.oldRegistrations).toHaveBeenCalledWith(session, instance); + // Nothing is removed until asked. + expect(removeOldRegistrations).not.toHaveBeenCalled(); + + fireEvent.click(within(section).getByRole("button", { name: "Remove old registrations" })); + const status = await within(section).findByText("Removed."); + expect(status).toHaveAttribute("role", "status"); + // The button that had the focus is gone; the note takes it. + await waitFor(() => expect(status).toHaveFocus()); + expect(removeOldRegistrations).toHaveBeenCalledExactlyOnceWith(session, instance); + expect(within(section).queryByRole("list")).toBeNull(); + expect(within(section).queryByRole("button")).toBeNull(); + expect(section).not.toHaveTextContent("Earlier versions"); + expect(screen.getByRole("region", { name: "Old registrations" })).toBe(section); + }); + + it("shows nothing while reading, nor when there are none, nor when they cannot be read", async () => { + for (const oldRegistrations of [ + vi.fn(async () => none), + vi.fn(async (): Promise => { + throw new Error("403 Forbidden"); + }), + ]) { + const useCases = makeUseCasesFake({ oldRegistrations }); + const { container, unmount } = renderContainer(useCases); + expect(container).toBeEmptyDOMElement(); + await waitFor(() => expect(oldRegistrations).toHaveBeenCalled()); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(container).toBeEmptyDOMElement(); + unmount(); + } + }); + + it("says which type index could not be read, with nothing to remove from it", async () => { + const useCases = makeUseCasesFake({ + oldRegistrations: vi.fn(async () => ({ + registrations: [], + unreadableIndexes: ["public" as const], + })), + }); + renderContainer(useCases); + const section = await screen.findByRole("region", { name: "Old registrations" }); + expect( + within(section).getByText( + "Your public type index could not be read, so what it holds is not shown here, and nothing is removed from it.", + ), + ).toBeInTheDocument(); + expect(within(section).queryByRole("list")).toBeNull(); + expect(within(section).queryByRole("button")).toBeNull(); + }); + + it("says it is removing, and shows what went wrong", async () => { + let fail: (error: Error) => void = () => undefined; + const useCases = makeUseCasesFake({ + oldRegistrations: vi.fn(async () => ({ ...some, unreadableIndexes: ["public" as const] })), + removeOldRegistrations: vi.fn( + () => + new Promise((_, reject) => { + fail = reject; + }), + ), + }); + renderContainer(useCases); + fireEvent.click(await screen.findByRole("button", { name: "Remove old registrations" })); + expect(await screen.findByRole("button", { name: "Removing…" })).toBeDisabled(); + fail(new Error("The index refused")); + await waitFor(() => expect(screen.getByRole("alert")).toHaveTextContent("The index refused")); + expect(screen.getByRole("button", { name: "Remove old registrations" })).toBeEnabled(); + expect(screen.getByText(/Your public type index could not be read/)).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/ui/OldRegistrationsContainer.tsx b/packages/ui/src/ui/OldRegistrationsContainer.tsx new file mode 100644 index 000000000..7b195fd5a --- /dev/null +++ b/packages/ui/src/ui/OldRegistrationsContainer.tsx @@ -0,0 +1,85 @@ +// SPDX-FileCopyrightText: 2026 Anton Wiklund and Solid Memo contributors +// +// SPDX-License-Identifier: MIT + +import { useEffect, useRef } from "preact/hooks"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { UseCases } from "@solid-memo/application/useCases"; +import type { Instance } from "@solid-memo/domain/instance"; +import type { Session } from "@solid-memo/domain/session"; +import { ErrorMessage } from "./ErrorMessage"; +import { useI18n } from "./i18n"; +import { queryKeys } from "./queryKeys"; + +/** + * "Old registrations" in Preferences (docs/data-model.md + * "Registrations"): the type index registrations earlier versions wrote + * for the instance's data, by class and index, any index that could not + * be read said to be so, and a button that removes them. Nothing is + * removed unless the user asks. With none to show, and while reading, + * it shows nothing at all: most users never had any. Once removed, it + * says so, and that note takes the focus, as the button that had it is + * gone. + */ +export function OldRegistrationsContainer({ + useCases, + session, + instance, +}: { + useCases: UseCases; + session: Session; + instance: Instance; +}) { + const { t, errorText } = useI18n(); + const queryClient = useQueryClient(); + const queryKey = queryKeys.oldRegistrations(session.webId, instance.url); + const statusRef = useRef(null); + const { data } = useQuery({ + queryKey, + queryFn: () => useCases.oldRegistrations(session, instance), + }); + const removeMutation = useMutation({ + mutationFn: () => useCases.removeOldRegistrations(session, instance), + onSettled: () => queryClient.invalidateQueries({ queryKey }), + }); + const removed = removeMutation.isSuccess; + useEffect(() => { + if (removed) statusRef.current?.focus(); + }, [removed]); + const anything = + data !== undefined && (data.registrations.length > 0 || data.unreadableIndexes.length > 0); + // Nothing read, or nothing to say: no section, so a user who never had any sees nothing. + if (!anything && !removed) return null; + return ( +
+

{t("oldRegistrations.heading")}

+ {anything &&

{t("oldRegistrations.intro")}

} + {data !== undefined && data.registrations.length > 0 && ( +
    + {data.registrations.map((registration) => ( +
  • + {t(`oldRegistrations.class.${registration.dataClass}`)}:{" "} + {t("oldRegistrations.in", { + index: t(`oldRegistrations.index.${registration.index}`), + })} +
  • + ))} +
+ )} + {data?.unreadableIndexes.map((index) => ( +

+ {t("oldRegistrations.unreadable", { index: t(`oldRegistrations.index.${index}`) })} +

+ ))} + {data !== undefined && data.registrations.length > 0 && ( + + )} +

+ {removed ? t("oldRegistrations.removed") : ""} +

+ +
+ ); +} diff --git a/packages/ui/src/ui/Workspace.test.tsx b/packages/ui/src/ui/Workspace.test.tsx index 02f3033a5..a2d9a8e55 100644 --- a/packages/ui/src/ui/Workspace.test.tsx +++ b/packages/ui/src/ui/Workspace.test.tsx @@ -1100,6 +1100,37 @@ describe("Workspace", () => { expect(await screen.findByRole("heading", { name: "Decks" })).toBeInTheDocument(); }); + it("offers in the preferences to remove the registrations earlier versions wrote, to a user and not to a guest", async () => { + window.history.replaceState( + null, + "", + routeToHash({ screen: "preferences", instanceUrl: instanceA.url }), + ); + const old = vi.fn(async () => ({ + registrations: [{ dataClass: "card" as const, index: "private" as const }], + unreadableIndexes: [], + })); + const useCases = makeUseCases({ + listInstances: vi.fn(async () => [instanceA]), + oldRegistrations: old, + }); + const view = renderWorkspace(useCases); + expect(await screen.findByRole("region", { name: "Old registrations" })).toBeInTheDocument(); + expect(old).toHaveBeenCalledWith(session, instanceA); + view.unmount(); + + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const guest = makeUseCases({ listInstances: vi.fn(async () => [instanceA]) }); + render( + + + , + ); + expect(await screen.findByRole("heading", { name: "Study preferences" })).toBeInTheDocument(); + expect(screen.queryByRole("region", { name: "Old registrations" })).toBeNull(); + expect(guest.oldRegistrations).not.toHaveBeenCalled(); + }); + it("opens the statistics from the instance bar", async () => { renderWorkspace(makeUseCases({ listInstances: vi.fn(async () => [instanceA]) })); fireEvent.click(await screen.findByRole("link", { name: "Statistics" })); diff --git a/packages/ui/src/ui/Workspace.tsx b/packages/ui/src/ui/Workspace.tsx index 5d091034b..bbb7e7e63 100644 --- a/packages/ui/src/ui/Workspace.tsx +++ b/packages/ui/src/ui/Workspace.tsx @@ -40,6 +40,7 @@ import { GuestStudyOffer } from "./GuestStudyOffer"; import { InstanceBar } from "./InstanceBar"; import { InstanceCreator } from "./InstanceCreator"; import { InstancePickerContainer } from "./InstancePickerContainer"; +import { OldRegistrationsContainer } from "./OldRegistrationsContainer"; import { DataCheckNotice } from "./DataCheckNotice"; import { UnclaimedDecksNotice } from "./UnclaimedDecksNotice"; import { type DataCheck, useDataCheck } from "./dataCheck"; @@ -1136,13 +1137,23 @@ export function Workspace({ ); case "preferences": return ( - navigate({ screen: "home", instanceUrl: instanceUrl! })} - /> + <> + navigate({ screen: "home", instanceUrl: instanceUrl! })} + /> + {/* A guest's pod is on this device: no other app looks there, so nothing there needs tidying. */} + {session.guest !== true && ( + + )} + ); case "palette": return ( diff --git a/packages/ui/src/ui/queryKeys.test.ts b/packages/ui/src/ui/queryKeys.test.ts index ed4564e04..e02d34934 100644 --- a/packages/ui/src/ui/queryKeys.test.ts +++ b/packages/ui/src/ui/queryKeys.test.ts @@ -55,6 +55,7 @@ describe("queryKeys", () => { queryKeys.interruptedMove(INSTANCE), queryKeys.guestStudy(), queryKeys.guestMergePlan("https://guest.example/", INSTANCE), + queryKeys.oldRegistrations(WEB_ID, INSTANCE), queryKeys.library(), queryKeys.libraryCards(DECK), queryKeys.libraryUpgrade(DECK), @@ -108,6 +109,7 @@ describe("queryKeys", () => { ["interruptedMove", INSTANCE], ["guestStudy"], ["guestMergePlan", "https://guest.example/", INSTANCE], + ["oldRegistrations", WEB_ID, INSTANCE], ["library"], ["libraryCards", DECK], ["libraryUpgrade", DECK], diff --git a/packages/ui/src/ui/queryKeys.ts b/packages/ui/src/ui/queryKeys.ts index 97cb14baa..3428085c0 100644 --- a/packages/ui/src/ui/queryKeys.ts +++ b/packages/ui/src/ui/queryKeys.ts @@ -104,6 +104,13 @@ export const queryKeys = { guestInstanceUrl, targetUrl, ], + /** The registrations earlier versions wrote for an instance's data, still in the type indexes. */ + oldRegistrations: (webId: string, instanceUrl: string) => [ + "oldRegistrations", + webId, + instanceUrl, + ], + /** The deck library's index. */ library: () => ["library"], /** A library deck's cards. */