diff --git a/docs/style.md b/docs/style.md index 2c0c5f73..db044f19 100644 --- a/docs/style.md +++ b/docs/style.md @@ -97,11 +97,11 @@ With `verbatimModuleSyntax`, a type is imported with `import type` or - **Many subjects are written at once.** A write of many subjects to a dataset sets them with `setThings` or one edit (`editOf` in [datasetEdit.ts](../packages/solid/src/datasetEdit.ts)), and takes - them out with `removeThings`. A `setThing` or `removeThing` at each - copies the whole document every time. A subject's many values for one - property are set in one call of its builder (`setIriAll`, - `setStringAll`), not an add or a remove at each, which copies the - values so far. + them out with `removeThings`. Setting or removing the subjects one + call at a time copies the whole document every time. A subject's + many values for one property are set in one call of its builder + (`setIriAll`, `setStringAll`), not an add or a remove at each, which + copies the values so far. - **Avoid `any`**, and use a non-null assertion (`!`) only where the code has checked the value right before, or in a test. - **Tests** sit beside their module, as `name.test.ts`, and keep every diff --git a/docs/testing.md b/docs/testing.md index e47525fa..911dfeec 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1123,7 +1123,7 @@ Dependency inversion gives every layer a seam for its fakes: | Layer | Seam | Technique | |---|---|---| -| Domain | none needed | Pure functions; inputs (including `now: Date`) passed as parameters. Plain assertions. What runs over a whole deck or log is timed at two sizes in its own process's CPU time: eight times the input must take under four times as long as an eighth of it done eight times (a linear pass gives about one, work growing with the input about eight). The growth tests (`growth` in `src/testing/growth.ts`, `growthAsync` for work that awaits, `growthAfterSetup` for work that changes what it works on, set up afresh before each run and not timed) time `queryCards` over 5,000 cards, `lapseIndex`, `chainIndexOf` and `statisticsOf` over years of answers, `instanceGraphOf` over an instance's cards and answers and over a document thousands of folders deep, a course's outline (`courseOutlineFromRecords`) over its chapters and steps, the learner's progress in it (`courseProgress`) over a chapter's steps, the release check's course rules (`courseProblems`), a draft's outline (`draftOutline`) over the chapters and steps, a draft's cards with their wrong options, as the Studio's outline and its step, chapter and question pages show them (`draftCardOf`), over many cards and many wrong options of one card, and the changes to a card's many wrong options (`distractorChanges`), the deck health's cards that say the same (`duplicateCardsOf`) over many cards alike, a JSON-LD copy (`jsonLdOf` in `solid`) over many values of one property, the search of a Turtle text for its base and prefix declarations (`writeTurtle` in `solid`), the shape check's report on a pod document and its check of a write (`createShaclShapeValidator` in `solid`, over an engine that answers at once) over a catalog's many links that aren't members and many dates it can't read, the notes on a release's making (`makingNotesOf`) over many in one language, a merge of drafts (`mergedDraft`) over many statements of one subject, the blank nodes a removal takes along (`withOnlyTheirBlankNodes` in `solid`) over many of one subject and many subjects naming one, a catalog's decks (`toDecks` in `solid`) and a deck file's refusal (`readDeckFile`) over many decks and agents, a catalog written over many licenses it no longer states (`withCatalog` in `solid`), a deck's cards added from a release, removed and brought to this app's format, one card's many distractors added and changed, a deck removed from a cards document another app wrote in, catalog entries and review states brought to this app's format, review states removed, a day's answers removed, many month summaries dropped from the digest, and many review states' snapshots dropped, decks removed and one deck's unreadable dates dropped by the instance check's repair, against a pod in memory (`importDeck`, `applyCardChanges`, `removeDeck`, `upgradeCards`, `upgradeDecks`, `upgradeReviewStates`, `applyReviewChanges`, `removeDay`, `updateDigest` and `applyRepairs` in `solid`), the repairs planned from a check of many subjects (`planRepair`), a subject's many values set or removed in one call (`setIriAll`, `setStringAll` and `removeLiteralAll`, in the client modules' contract tests), the Studio's graph layout and routes over a deck's cards, and its cut of a label (`drawnLabel`), which must not grow with the label at all, over one millions of characters long. | +| Domain | none needed | Pure functions; inputs (including `now: Date`) passed as parameters. Plain assertions. What runs over a whole deck or log is timed at two sizes in its own process's CPU time: eight times the input must take under four times as long as an eighth of it done eight times (a linear pass gives about one, work growing with the input about eight). The growth tests (`growth` in `src/testing/growth.ts`, `growthAsync` for work that awaits, `growthAfterSetup` for work that changes what it works on, set up afresh before each run and not timed) time `queryCards` over 5,000 cards, `lapseIndex`, `chainIndexOf` and `statisticsOf` over years of answers, `instanceGraphOf` over an instance's cards and answers and over a document thousands of folders deep, a course's outline (`courseOutlineFromRecords`) over its chapters and steps, the learner's progress in it (`courseProgress`) over a chapter's steps, the release check's course rules (`courseProblems`), a draft's outline (`draftOutline`) over the chapters and steps, a draft's cards with their wrong options, as the Studio's outline and its step, chapter and question pages show them (`draftCardOf`), over many cards and many wrong options of one card, and the changes to a card's many wrong options (`distractorChanges`), the deck health's cards that say the same (`duplicateCardsOf`) over many cards alike, a JSON-LD copy (`jsonLdOf` in `solid`) over many values of one property, the search of a Turtle text for its base and prefix declarations (`writeTurtle` in `solid`), the shape check's report on a pod document and its check of a write (`createShaclShapeValidator` in `solid`, over an engine that answers at once) over a catalog's many links that aren't members and many dates it can't read, the notes on a release's making (`makingNotesOf`) over many in one language, a merge of drafts (`mergedDraft`) over many statements of one subject, the blank nodes a removal takes along (`withOnlyTheirBlankNodes` in `solid`) over many of one subject and many subjects naming one, a catalog's decks (`toDecks` in `solid`) and a deck file's refusal (`readDeckFile`) over many decks and agents, a catalog written over many licenses it no longer states (`withCatalog` in `solid`), a deck's cards added from a release, removed and brought to this app's format, one card's many distractors added and changed, a deck removed from a cards document another app wrote in, catalog entries and review states brought to this app's format, review states removed, a day's answers removed, many month summaries dropped from the digest, and many review states' snapshots dropped, decks removed and one deck's unreadable dates dropped by the instance check's repair, and many registrations of an instance renamed and removed in a type index, against a pod in memory (`importDeck`, `applyCardChanges`, `removeDeck`, `upgradeCards`, `upgradeDecks`, `upgradeReviewStates`, `applyReviewChanges`, `removeDay`, `updateDigest`, `applyRepairs`, `renameInstanceRegistrations` and `removeInstanceRegistrations` in `solid`), the repairs planned from a check of many subjects (`planRepair`), a subject's many values set or removed in one call (`setIriAll`, `setStringAll` and `removeLiteralAll`, in the client modules' contract tests), the Studio's graph layout and routes over a deck's cards, and its cut of a label (`drawnLabel`), which must not grow with the label at all, over one millions of characters long. | | Application | ports | Fake ports are injected (`vi.fn` per method): the fixtures and fake ports in [fakePorts.ts](../packages/application/src/testing/fakePorts.ts), and `NO_PORTS` ([noPorts.ts](../packages/application/src/testing/noPorts.ts)), which other packages' tests spread too, for the ports a test doesn't use. Each slice is tested in its own `.test.ts`, the stores the slices share in theirs. No module mocking. | | UI | `UseCases` prop | Screens render with a fake `UseCases`: `makeUseCasesFake` ([useCasesFake.ts](../packages/ui/src/test/useCasesFake.ts)) makes every slice's fake ([test/fakes/](../packages/ui/src/test/fakes/)) in one object, each calling the others through it. A screen that takes a slice (the trial's) can be given that slice's fake alone. Assertions use testing-library queries. Query-dependent components get a fresh `QueryClient` (retries off). | | Infrastructure mappers | none needed | Pure `Dataset`/`Subject` → domain functions, fed in-memory datasets built with `datasetFrom`/`buildThing` ([datasets.ts](../packages/solid/src/testing/datasets.ts)). | diff --git a/packages/solid-memo-client/src/dataset.ts b/packages/solid-memo-client/src/dataset.ts index 80b3652b..d6e5f985 100644 --- a/packages/solid-memo-client/src/dataset.ts +++ b/packages/solid-memo-client/src/dataset.ts @@ -19,8 +19,8 @@ import type { Changes, Dataset, Subject } from "@solid-memo/solid/client/interfa * - A dataset holds the subjects of its default graph by IRI (`_:label` * for a blank node), the quads of any named graph as they were read, the * document it came from, and its edits as a chain of steps (setThing, - * removeThing), each pointing to the one before: changesOf replays them - * when it is asked, so an edit costs only what it changes. + * setThings, removeThings), each pointing to the one before: changesOf + * replays them when it is asked, so an edit costs only what it changes. */ const { blankNode, literal, namedNode, quad: makeQuad, defaultGraph } = DataFactory; diff --git a/packages/solid-memo-client/src/rdf.test.ts b/packages/solid-memo-client/src/rdf.test.ts index 406f0495..9bc72286 100644 --- a/packages/solid-memo-client/src/rdf.test.ts +++ b/packages/solid-memo-client/src/rdf.test.ts @@ -132,7 +132,7 @@ describe("getThing and createThing", () => { }); }); -describe("removeThing", () => { +describe("removeThings, of one subject", () => { const thing = rdf .buildThing(rdf.createThing({ url: `${URL_}#a` })) .addStringNoLocale(P, "x") @@ -141,7 +141,7 @@ describe("removeThing", () => { it("removes a subject given as text or as itself, its quads deleted", () => { const dataset = rdf.withQuads(rdf.datasetAt(URL_), [quadOf(`${URL_}#a`, term("x"))]); for (const subject of [`${URL_}#a`, thing]) { - const removed = rdf.removeThing(dataset, subject); + const removed = rdf.removeThings(dataset, [subject]); expect(rdf.isEmptyDataset(removed)).toBe(true); expect(rdf.changesOf(removed)!.deletions.map((one) => one.object.value)).toEqual(["x"]); } @@ -153,15 +153,15 @@ describe("removeThing", () => { .addStringNoLocale(P, "x") .build(); const dataset = rdf.withQuads(rdf.datasetAt(URL_), [quadOf(`${URL_}#a`, term("x"))]); - expect(rdf.isEmptyDataset(rdf.removeThing(dataset, named.url))).toBe(true); - expect(rdf.isEmptyDataset(rdf.removeThing(dataset, named))).toBe(false); + expect(rdf.isEmptyDataset(rdf.removeThings(dataset, [named.url]))).toBe(true); + expect(rdf.isEmptyDataset(rdf.removeThings(dataset, [named]))).toBe(false); const local = rdf.setThing(rdf.createSolidDataset(), named); - expect(rdf.isEmptyDataset(rdf.removeThing(local, named))).toBe(true); + expect(rdf.isEmptyDataset(rdf.removeThings(local, [named]))).toBe(true); }); it("changes nothing for a subject the dataset says nothing of", () => { const dataset = rdf.datasetAt(URL_); - expect(rdf.changesOf(rdf.removeThing(dataset, thing))).toEqual({ + expect(rdf.changesOf(rdf.removeThings(dataset, [thing]))).toEqual({ additions: [], deletions: [], }); @@ -169,9 +169,12 @@ describe("removeThing", () => { it("makes no change of a subject set and then removed, nor of one removed and then set again", () => { const added = rdf.setThing(rdf.datasetAt(URL_), thing); - expect(rdf.changesOf(rdf.removeThing(added, thing))).toEqual({ additions: [], deletions: [] }); + expect(rdf.changesOf(rdf.removeThings(added, [thing]))).toEqual({ + additions: [], + deletions: [], + }); const read = rdf.withQuads(rdf.datasetAt(URL_), [quadOf(`${URL_}#a`, term("x"))]); - const back = rdf.setThing(rdf.removeThing(read, thing), thing); + const back = rdf.setThing(rdf.removeThings(read, [thing]), thing); expect(rdf.changesOf(back)).toEqual({ additions: [], deletions: [] }); }); }); diff --git a/packages/solid-memo-client/src/rdf.ts b/packages/solid-memo-client/src/rdf.ts index 47621ee3..e03982fe 100644 --- a/packages/solid-memo-client/src/rdf.ts +++ b/packages/solid-memo-client/src/rdf.ts @@ -73,7 +73,7 @@ function expectUrl(value: string, what: string): void { } /** - * The changes made to a dataset, as setThing and removeThing record + * The changes made to a dataset, as setThings and removeThings record * them; undefined for a dataset no change was ever made to. */ export function changesOf(dataset: Dataset): Changes | undefined { @@ -204,15 +204,9 @@ export function setThings(dataset: T, subjects: Iterable(dataset: T, subject: string | Subject): T { - return removeThings(dataset, [subject]); -} - /** - * The dataset without what it said of each of `subjects`, as removeThing - * removes one, each change recorded: the subjects are copied once, not - * once a subject. + * The dataset without what it said of each of `subjects`, each change + * recorded: the subjects are copied once, not once a subject. */ export function removeThings( dataset: T, diff --git a/packages/solid/src/client/rdf.test.ts b/packages/solid/src/client/rdf.test.ts index 10c09dfc..d5282fbc 100644 --- a/packages/solid/src/client/rdf.test.ts +++ b/packages/solid/src/client/rdf.test.ts @@ -51,7 +51,7 @@ describe("propertiesOf, of the library's subjects", () => { }); }); -describe("setThing and removeThing, beside the library's own", () => { +describe("setThing and removeThings, beside the library's own", () => { /** A dataset's quads and its changes, each as sorted lines. */ const stateOf = (dataset: rdf.Dataset) => { const lines = (quads: Iterable) => @@ -85,7 +85,7 @@ describe("setThing and removeThing, beside the library's own", () => { .buildThing(rdf.createThing({ name: "n" })) .addIri(`${EX}p`, a) .build(); - const ours = rdf.removeThing(rdf.setThing(rdf.setThing(read, changed), named), `${URL_}#a`); + const ours = rdf.removeThings(rdf.setThing(rdf.setThing(read, changed), named), [`${URL_}#a`]); const data = read as unknown as SolidDataset; const theirs = libraryRemoveThing( librarySetThing(librarySetThing(data, changed as never), named as never), diff --git a/packages/solid/src/client/rdf.ts b/packages/solid/src/client/rdf.ts index a28189d5..7e34316d 100644 --- a/packages/solid/src/client/rdf.ts +++ b/packages/solid/src/client/rdf.ts @@ -98,7 +98,7 @@ export type { } from "./interface"; /** - * The changes made to a dataset, as setThing and removeThing record + * The changes made to a dataset, as setThings and removeThings record * them; undefined for a dataset no change was ever made to. */ export function changesOf(dataset: Dataset): Changes | undefined { @@ -219,11 +219,6 @@ export function setThing(dataset: T, subject: Subject): T { return setThings(dataset, [subject]); } -/** The dataset without what it said of `subject`, the change recorded (changesOf), as removeThings removes one. */ -export function removeThing(dataset: T, subject: string | Subject): T { - return removeThings(dataset, [subject]); -} - /** * The dataset with each of `subjects` set in turn, as setThing sets one, * each change recorded (changesOf). The library's setThing copies the @@ -248,9 +243,10 @@ export function setThings(dataset: T, subjects: Iterable( dataset: T, diff --git a/packages/solid/src/datasets.patch.test.ts b/packages/solid/src/datasets.patch.test.ts index 388d3f26..f1757b17 100644 --- a/packages/solid/src/datasets.patch.test.ts +++ b/packages/solid/src/datasets.patch.test.ts @@ -12,7 +12,7 @@ import { datasetFromQuads, getThing, localNameOf, - removeThing, + removeThings, setThing, toRdfJsDataset, withQuads, @@ -58,7 +58,7 @@ function edit(before: Quad[], after: Quad[]): Dataset { ); for (const id of ids) { const subject = getThing(next, id); - dataset = subject === null ? removeThing(dataset, id) : setThing(dataset, subject); + dataset = subject === null ? removeThings(dataset, [id]) : setThing(dataset, subject); } return dataset; } diff --git a/packages/solid/src/mappers/deckMapper.test.ts b/packages/solid/src/mappers/deckMapper.test.ts index 834fb1e5..e3339843 100644 --- a/packages/solid/src/mappers/deckMapper.test.ts +++ b/packages/solid/src/mappers/deckMapper.test.ts @@ -18,7 +18,7 @@ import { getThingAll, getUrlAll, type NamedSubject, - removeThing, + removeThings, setThing, setThings, type SubjectBuilder, @@ -862,7 +862,7 @@ describe("the catalog node", () => { expect(typeOf(dataset, CC0)).toEqual([DCTERMS.LicenseDocument]); // One another app stated without describing it: nothing to let go of. - dataset = removeThing(withCatalog(dataset, CATALOG, { ...catalog, license: BY }), BY); + dataset = removeThings(withCatalog(dataset, CATALOG, { ...catalog, license: BY }), [BY]); dataset = withCatalog(dataset, CATALOG, catalog); expect(typeOf(dataset, BY)).toBeNull(); expect(toCatalog(dataset, CATALOG)).toEqual(catalog); @@ -935,7 +935,7 @@ describe("the catalog node", () => { const untyped = buildThing(node).removeUrl(RDF.type, DCTERMS.LicenseDocument).build(); updated = getPropertyAll(untyped).length === 0 - ? removeThing(updated, license) + ? removeThings(updated, [license]) : setThing(updated, untyped); } if (catalog.license !== undefined) { diff --git a/packages/solid/src/solidRepairRepository.ts b/packages/solid/src/solidRepairRepository.ts index dd0d4e6b..0c694f49 100644 --- a/packages/solid/src/solidRepairRepository.ts +++ b/packages/solid/src/solidRepairRepository.ts @@ -88,12 +88,13 @@ const MEMBERSHIP = [ * The document with its repairs applied in turn, each to the subject as * the repairs before it left it, in one edit of the document (editOf) * and one removal of the subjects removed (removeThings): many repairs - * take time that grows with them and the document, where a setThing or - * removeThing at each would copy the whole document every time, in the - * square of its subjects. A removal makes the other apps' subjects be - * found again at the next drop-dangling-members repair, so the two - * kinds alternating in one call take longer; the instance check never - * plans a removal, and each removal the user confirms comes alone. + * take time that grows with them and the document, where setting or + * removing the subjects one call at a time would copy the whole document + * every time, in the square of its subjects. A removal makes the other + * apps' subjects be found again at the next drop-dangling-members + * repair, so the two kinds alternating in one call take longer; the + * instance check never plans a removal, and each removal the user + * confirms comes alone. */ function repaired(dataset: Dataset, documentUrl: string, repairs: readonly Repair[]): Dataset { const edit = editOf(dataset); diff --git a/packages/solid/src/testing/contract/rdf.ts b/packages/solid/src/testing/contract/rdf.ts index 4ae0f810..99e45add 100644 --- a/packages/solid/src/testing/contract/rdf.ts +++ b/packages/solid/src/testing/contract/rdf.ts @@ -85,7 +85,6 @@ export function rdfContract({ rdf, resources }: DataClient): void { isEmptyDataset, localNameOf, propertiesOf, - removeThing, removeThings, setThing, setThings, @@ -195,7 +194,7 @@ export function rdfContract({ rdf, resources }: DataClient): void { const front = (subject: Rdf.Subject, value: string) => buildThing(subject).addStringNoLocale(`${EX}front`, value).build(); - it("set and remove subjects as setThing and removeThing do, one at a time", async () => { + it("set and remove subjects in one call as one call per subject does", async () => { const base = await read(); const a = getThing(base, `${URL_}#a`)!; const c = front(createThing({ url: `${URL_}#c` }), "Finland"); @@ -204,7 +203,7 @@ export function rdfContract({ rdf, resources }: DataClient): void { const removed = [c, `${URL_}#b`, `${URL_}#absent`, named.url]; const once = removeThings(setThings(base, set), removed); const fold = removed.reduce( - (dataset, subject) => removeThing(dataset, subject), + (dataset, subject) => removeThings(dataset, [subject]), set.reduce((dataset, subject) => setThing(dataset, subject), base), ); expect(stateOf(once)).toEqual(stateOf(fold)); @@ -212,7 +211,7 @@ export function rdfContract({ rdf, resources }: DataClient): void { // Edited again: what was removed and is set again, and what was set and is removed, is no change. const b = front(createThing({ url: `${URL_}#b` }), "Norway"); const again = removeThings(setThings(once, [b]), [`${URL_}#a`]); - expect(stateOf(again)).toEqual(stateOf(removeThing(setThing(fold, b), `${URL_}#a`))); + expect(stateOf(again)).toEqual(stateOf(removeThings(setThing(fold, b), [`${URL_}#a`]))); }); it("give the dataset itself for no subject", async () => { @@ -224,9 +223,6 @@ export function rdfContract({ rdf, resources }: DataClient): void { it("look for a subject given by its name's IRI as text only in a dataset of a document", () => { const named = front(createThing({ name: "d" }), "Denmark"); const built = setThing(createSolidDataset(), named); - expect(stateOf(removeThings(built, [named.url]))).toEqual( - stateOf(removeThing(built, named.url)), - ); expect(getThingAll(removeThings(built, [named.url]))).toEqual([]); }); }); diff --git a/packages/solid/src/typeIndex.growth.test.ts b/packages/solid/src/typeIndex.growth.test.ts new file mode 100644 index 00000000..06ebce06 --- /dev/null +++ b/packages/solid/src/typeIndex.growth.test.ts @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: 2026 Anton Wiklund and Solid Memo contributors +// +// SPDX-License-Identifier: MIT + +/** + * An instance's registrations in a type index are renamed and removed + * in time that grows with them, not their square, against a pod in + * memory: many registrations of the instance, half of them naming + * another app's folder besides. Eight times as many must take less than + * four times as long as an eighth of them done eight times over + * (testing.md); what puts the index in place is not timed + * (growthAfterSetup). + */ +import { expect, it } from "vitest"; +import { growthAfterSetup } from "@solid-memo/domain/testing/growth"; +import { fakePod } from "./testing/fakePod"; +import { removeInstanceRegistrations, renameInstanceRegistrations } from "./typeIndex"; + +const INDEX = "https://alice.example/settings/privateTypeIndex.ttl"; +const INSTANCE = "https://alice.example/solid-memo/main/"; +const OTHER = "https://alice.example/other-app/"; + +/** A type index of `count` registrations of the instance, every second one naming another folder too. */ +function index(count: number): string { + let ttl = `@prefix solid: . +@prefix sm: . +@prefix dcterms: . +`; + for (let i = 0; i < count; i++) { + const also = i % 2 === 0 ? `, <${OTHER}${i}/>` : ""; + ttl += `<#r${i}> a solid:TypeRegistration ; solid:forClass sm:Instance ; + solid:instanceContainer <${INSTANCE}>${also} ; dcterms:title "Main" .\n`; + } + return ttl; +} + +/** Times `work` on a type index of `count` registrations of the instance. */ +function onIndex(work: (fetch: typeof globalThis.fetch) => Promise) { + return (count: number) => async () => { + const pod = fakePod(); + await pod.put(INDEX, index(count)); + return () => work(pod.fetch); + }; +} + +it("renames many registrations of an instance in time that grows with them, not their square", async () => { + const ratio = await growthAfterSetup( + onIndex((fetch) => + renameInstanceRegistrations(INDEX, { containerUrl: INSTANCE, title: "Renamed" }, fetch), + ), + 300, + 8, + ); + expect(ratio).toBeLessThan(4); +}, 120_000); + +it("removes many registrations of an instance in time that grows with them, not their square", async () => { + const ratio = await growthAfterSetup( + onIndex((fetch) => removeInstanceRegistrations(INDEX, INSTANCE, fetch)), + 300, + 8, + ); + expect(ratio).toBeLessThan(4); +}, 120_000); diff --git a/packages/solid/src/typeIndex.ts b/packages/solid/src/typeIndex.ts index fb2baa78..28417dc6 100644 --- a/packages/solid/src/typeIndex.ts +++ b/packages/solid/src/typeIndex.ts @@ -15,8 +15,9 @@ import { getThingAll, getUrl, getUrlAll, - removeThing, + removeThings, setThing, + setThings, } from "./client/rdf"; import { getSolidDatasetOrNull, @@ -485,7 +486,10 @@ export async function addRegistrations( * `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. - * Saves only when something was removed. + * 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( indexUrl: string, @@ -494,12 +498,18 @@ export async function removeInstanceRegistrations( ): Promise { const container = ensureTrailingSlash(containerUrl); await changeIndex(indexUrl, fetch, (dataset) => { - let updated: Dataset = 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 = DATA_CLASSES.flatMap((dataClass) => registeredOf(thing, dataClass, container)); - updated = withoutRegistered(updated, thing, ours); + if (ours.length === 0) continue; + const kept = withoutRegistered(thing, ours); + if (kept === null) removed.push(thing); + else changed.push(kept); } - return { dataset: updated, result: undefined }; + if (changed.length === 0 && removed.length === 0) return { dataset, result: undefined }; + return { dataset: removeThings(setThings(dataset, changed), removed), result: undefined }; }); } @@ -511,28 +521,28 @@ function registeredUrls(registration: Subject): string[] { ]; } -/** The index without the registration's links to `urls`: the whole registration when it registers nothing else. */ -function withoutRegistered( - dataset: Dataset, - registration: Subject, - urls: readonly string[], -): Dataset { - if (urls.length === 0) return dataset; - if (registeredUrls(registration).every((url) => urls.includes(url))) - return removeThing(dataset, registration); - const builder = urls.reduce( - (current, url) => - current.removeUrl(SOLID.instanceContainer, url).removeUrl(SOLID.instance, url), - buildThing(registration), - ); - return setThing(dataset, builder.build()); +/** + * The registration without its links to `urls`; null when it registers + * nothing else, and so is to be removed whole. + */ +function withoutRegistered(registration: Subject, urls: readonly string[]): Subject | null { + const gone = new Set(urls); + if (registeredUrls(registration).every((url) => gone.has(url))) return null; + return urls + .reduce( + (current, url) => + current.removeUrl(SOLID.instanceContainer, url).removeUrl(SOLID.instance, url), + buildThing(registration), + ) + .build(); } /** * 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). Saves nothing - * when no registration needs the title. + * class's registration, 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. */ export async function renameInstanceRegistrations( indexUrl: string, @@ -541,19 +551,16 @@ export async function renameInstanceRegistrations( ): Promise { const container = ensureTrailingSlash(containerUrl); await changeIndex(indexUrl, fetch, (dataset) => { - let updated: Dataset = dataset; - for (const thing of getThingAll(dataset)) { - const ours = DATA_CLASSES.some( - (dataClass) => registeredOf(thing, dataClass, container).length > 0, - ); - if (ours && getStringNoLocale(thing, DCTERMS.title) !== title) { - updated = setThing( - updated, - buildThing(thing).setStringNoLocale(DCTERMS.title, title).build(), - ); - } - } - return { dataset: updated, result: undefined }; + // Every registration retitled set at once (setThings). + const renamed = getThingAll(dataset) + .filter( + (thing) => + getStringNoLocale(thing, DCTERMS.title) !== title && + DATA_CLASSES.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 }; + return { dataset: setThings(dataset, renamed), result: undefined }; }); }