diff --git a/apps/studio/src/ui/draftEditor.test.tsx b/apps/studio/src/ui/draftEditor.test.tsx index 76092b95..fbf1acea 100644 --- a/apps/studio/src/ui/draftEditor.test.tsx +++ b/apps/studio/src/ui/draftEditor.test.tsx @@ -132,6 +132,36 @@ describe("useDraftEditor", () => { expect(useCases.editReleaseDraft).toHaveBeenCalledWith(DRAFT_URL, [rename("A"), rename("Ap")]); }); + it("writes the last of a run of keystrokes in the making notes or the contact, in its place", async () => { + const { useCases } = await renderEditor({ + editReleaseDraft: vi.fn(async (_url, changes) => made(courseDraft(), changes)), + }); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const notes = (en: string): DraftChange => ({ kind: "setMakingNotes", notes: { en } }); + const contact = (url: string): DraftChange => ({ kind: "setContact", url }); + const typed = [ + ...["N", "No", "Not", "Note"].map(notes), + ...["https://a.example/", "https://ab.example/"].map(contact), + notes("Notes"), + // Notes of no text leave the making the notes before them made: both are written. + notes(" "), + ]; + for (const change of typed) + act(() => { + editor.edit([change], { debounce: true }); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(DEBOUNCE_MS); + }); + expect(useCases.editReleaseDraft).toHaveBeenCalledTimes(1); + expect(useCases.editReleaseDraft).toHaveBeenCalledWith(DRAFT_URL, [ + notes("Note"), + contact("https://ab.example/"), + notes("Notes"), + notes(" "), + ]); + }); + it("writes what waits before a change that is not typing, and when the screen goes", async () => { const { useCases, view } = await renderEditor({ editReleaseDraft: vi.fn(async (_url, changes) => made(courseDraft(), changes)), diff --git a/apps/studio/src/ui/draftEditor.ts b/apps/studio/src/ui/draftEditor.ts index d4e3c360..55a52299 100644 --- a/apps/studio/src/ui/draftEditor.ts +++ b/apps/studio/src/ui/draftEditor.ts @@ -92,8 +92,9 @@ export interface DraftEditor { * Make changes: at once on the screen, then in the pod. With * `debounce` (typing), the write waits until the document has had no * other for DEBOUNCE_MS; the changes of that time are written - * together. A change refused at once (an id taken, say) is not made, - * and its refusal returned; else null. + * together, a field's change that only replaces the one typed just + * before it (lastWins) in its place. A change refused at once (an id + * taken, say) is not made, and its refusal returned; else null. */ edit: (changes: readonly DraftChange[], options?: { debounce?: boolean }) => DraftRefusal | null; /** Changes are waiting to be written, or being written. */ @@ -102,6 +103,22 @@ export interface DraftEditor { failure: DraftFailure | null; } +/** + * Whether `change` makes `before`, the change just before it, as if it + * had never been made, so that only `change` need be written: the + * contact set again, or the notes of the release's making set again to + * some text. Notes with none are not: notes typed before them may have + * made the release's own making, which they leave. + */ +function lastWins(before: DraftChange | undefined, change: DraftChange): boolean { + if (before?.kind !== change.kind) return false; + if (change.kind === "setContact") return true; + return ( + change.kind === "setMakingNotes" && + Object.values(change.notes).some((text) => text.trim() !== "") + ); +} + /** Changes waiting for their document to be still for a moment. */ interface Waiting { changes: DraftChange[]; @@ -255,8 +272,11 @@ export function useDraftEditor(useCases: UseCases, draftUrl: string): DraftEdito const documentUrl = documents[0]!; const held = waiting.documents.get(documentUrl); if (held !== undefined) clearTimeout(held.timer); + const waited = held?.changes ?? []; + // A keystroke's change that only replaces the one before it takes its place: a write makes each change it is given. + const replaced = changes.length === 1 && lastWins(waited.at(-1), changes[0]!); waiting.documents.set(documentUrl, { - changes: [...(held?.changes ?? []), ...changes], + changes: [...(replaced ? waited.slice(0, -1) : waited), ...changes], timer: setTimeout(() => flush(documentUrl), DEBOUNCE_MS), }); told(); diff --git a/docs/studio.md b/docs/studio.md index 6c99231a..4e31cc93 100644 --- a/docs/studio.md +++ b/docs/studio.md @@ -1781,6 +1781,16 @@ A draft changes by `DraftChange`s card deleted takes along only the wrong options no other card names; - its sources, and the checks it had. +A blank node only the statements a change took out named goes with +them. A change keeps each statement it leaves alone as it was, so what +it took out is found by identity, without comparing every statement by +its value, and only the blank nodes those name are looked at. Whether +it rewrites an activity carried from an earlier release is asked only +of the activities among the subjects of what it took out or put in. +What a change still does over the whole draft is a few cheap passes +over its statements, so a keystroke in the release's notes or contact +grows with the draft only by those. + A change is refused, writing nothing, for: - deleting what an earlier release published (it's retired instead); @@ -1822,7 +1832,12 @@ Every edit is made at once on the screen, then written in the pod (`changedDocuments` in [draftLayout.ts](../packages/domain/src/release/draftLayout.ts)) has been still for 0.8 seconds (`DEBOUNCE_MS`). Changes typed in that - time are written together. The documents a change writes are found + time are written together. A keystroke in the contact replaces the + change before it when that is of the same field: the last one is what + stays, so the write makes it once, not once per keystroke. So does one + in the notes of the release's making that leaves some text. Notes left + empty don't, as notes typed before may have given the release a making + of its own, which they keep. The documents a change writes are found from the records it replaced, not from the whole draft. Where the draft's records go (its layout) is worked out again only when a change moves one, and once for each draft read from the pod, the one diff --git a/docs/testing.md b/docs/testing.md index adc0ce04..8ef55d8c 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1389,7 +1389,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 statistics of many courses (`getCourseStatistics`) over as many of their answers, the release check's course rules (`courseProblems`) and its rules on text written in Markdown (`markdownProblems`) over many cards, steps and chapters and their text formats and over one distractor that many cards name in many languages, 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, many wrong options of one card and one wrong option that many cards name in many languages, 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 and over cards in Markdown whose plain text is longer than the bound on a term, which of many decks with long release links are copies of a library of as many decks (`libraryCopyTest`) and which of the library's decks they copy (`copiedTest`), a JSON-LD copy (`jsonLdOf` in `solid`) over many values of one property, a document the guest's pod on the device stores and changes (`createLocalPod` in `solid`) over many lines past the bound on a term, alike but for their end, the search of a Turtle text for its base and prefix declarations, its long IRIs and its nesting (`writeTurtle` in `solid`, and the refusal of a long IRI with dot segments in the client modules' contract tests), 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 SHACL engine's check of a graph and of each of its subjects (`createEngine` in `shacl`) over many subjects linking to a node with many types, the notes on a release's making (`makingNotesOf`) over many in one language, a draft's listing (`draftLibraryDeck`) and its release diff (`releaseDiff` and `simulateLearnerUpgrade`) over many sources, the draft as a release (`draftLibraryContent`) and a learner's upgrade to it over one wrong option that many cards name in many languages, 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, many decks taken out of a catalog whose deck group lists many links, one of them no URL the app can write (`withoutDecks` in `solid`), the agent nodes of many authors that share a slug and a hash, alone and beside as many nodes other authors hold (`agentUrlsOf`), authors whose agent URLs are longer than any term, from a long subject or long names, at each place that keeps them (`TextMap`, `agentUrlsOf`, `deckAgents`, `withProvenance`, `deckReport`, `otherAuthorsOf`, a draft's listing, and `toDecks`, `deckSubjects` and `toLibraryDeckContent` in `solid`) and refused before anything is keyed by them (`deckToDraft`, and `withDeck`, a draft's entries, moved past the bound too, and a release assembled at a longer address, in `solid`), a catalog's decks (`toDecks` in `solid`) and a deck file's refusal (`readDeckFile`) over many decks and agents, a document's cards (`toCards` in `solid`) and a release's, read and refused (`toLibraryDeckContent` in `solid`), over many cards that name one distractor in many languages, a catalog written over many licenses it no longer states (`withCatalog` in `solid`), a palette's color sets placed past many that another palette names (`withPalette` in `solid`), a deck's cards added from a release, or refused for a distractor many of them name, 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, many decks of a catalog changed and removed at once, 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 as many receipts noted in it at once as it holds, and many review states' snapshots dropped, decks removed and one deck's unreadable dates dropped by the instance check's repair, many registrations of an instance renamed and removed in a type index, and the storages found in a profile that names a large extended profile document, against a pod in memory (`importDeck`, `applyCardChanges`, `removeDeck`, `saveDecks`, `removeDecks`, `upgradeCards`, `upgradeDecks`, `upgradeReviewStates`, `applyReviewChanges`, `removeDay`, `updateDigest`, `applyRepairs`, `renameInstanceRegistrations`, `removeInstanceRegistrations` and `discoverStorages` in `solid`), the receipts a visit notes in the digest it keeps (`createDigestStore`) over a digest of as many, 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 credential gate over as many reads as instances registered, each on a server of its own (`createOwnPodGate` in `solid`), the Studio's graph layout and routes over a deck's cards, its card History tab and the learner's card page's history over many answers that name one of a card's many wrong options, and the Studio's 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 statistics of many courses (`getCourseStatistics`) over as many of their answers, the release check's course rules (`courseProblems`) and its rules on text written in Markdown (`markdownProblems`) over many cards, steps and chapters and their text formats and over one distractor that many cards name in many languages, 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, many wrong options of one card and one wrong option that many cards name in many languages, 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 and over cards in Markdown whose plain text is longer than the bound on a term, which of many decks with long release links are copies of a library of as many decks (`libraryCopyTest`) and which of the library's decks they copy (`copiedTest`), a JSON-LD copy (`jsonLdOf` in `solid`) over many values of one property, a document the guest's pod on the device stores and changes (`createLocalPod` in `solid`) over many lines past the bound on a term, alike but for their end, the search of a Turtle text for its base and prefix declarations, its long IRIs and its nesting (`writeTurtle` in `solid`, and the refusal of a long IRI with dot segments in the client modules' contract tests), 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 SHACL engine's check of a graph and of each of its subjects (`createEngine` in `shacl`) over many subjects linking to a node with many types, the notes on a release's making (`makingNotesOf`) over many in one language, a draft's listing (`draftLibraryDeck`) and its release diff (`releaseDiff` and `simulateLearnerUpgrade`) over many sources, the draft as a release (`draftLibraryContent`) and a learner's upgrade to it over one wrong option that many cards name in many languages, 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, many decks taken out of a catalog whose deck group lists many links, one of them no URL the app can write (`withoutDecks` in `solid`), the agent nodes of many authors that share a slug and a hash, alone and beside as many nodes other authors hold (`agentUrlsOf`), authors whose agent URLs are longer than any term, from a long subject or long names, at each place that keeps them (`TextMap`, `agentUrlsOf`, `deckAgents`, `withProvenance`, `deckReport`, `otherAuthorsOf`, a draft's listing, and `toDecks`, `deckSubjects` and `toLibraryDeckContent` in `solid`) and refused before anything is keyed by them (`deckToDraft`, and `withDeck`, a draft's entries, moved past the bound too, and a release assembled at a longer address, in `solid`), a catalog's decks (`toDecks` in `solid`) and a deck file's refusal (`readDeckFile`) over many decks and agents, a document's cards (`toCards` in `solid`) and a release's, read and refused (`toLibraryDeckContent` in `solid`), over many cards that name one distractor in many languages, a catalog written over many licenses it no longer states (`withCatalog` in `solid`), a palette's color sets placed past many that another palette names (`withPalette` in `solid`), a deck's cards added from a release, or refused for a distractor many of them name, 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, many decks of a catalog changed and removed at once, 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 as many receipts noted in it at once as it holds, and many review states' snapshots dropped, decks removed and one deck's unreadable dates dropped by the instance check's repair, many registrations of an instance renamed and removed in a type index, and the storages found in a profile that names a large extended profile document, against a pod in memory (`importDeck`, `applyCardChanges`, `removeDeck`, `saveDecks`, `removeDecks`, `upgradeCards`, `upgradeDecks`, `upgradeReviewStates`, `applyReviewChanges`, `removeDay`, `updateDigest`, `applyRepairs`, `renameInstanceRegistrations`, `removeInstanceRegistrations` and `discoverStorages` in `solid`), the receipts a visit notes in the digest it keeps (`createDigestStore`) over a digest of as many, 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 credential gate over as many reads as instances registered, each on a server of its own (`createOwnPodGate` in `solid`), the Studio's graph layout and routes over a deck's cards, its card History tab and the learner's card page's history over many answers that name one of a card's many wrong options, and the Studio's cut of a label (`drawnLabel`), which must not grow with the label at all, over one millions of characters long. A draft's change of the notes of its making or its contact, beside many other statements, is timed against one pass over them (`idsInUse`): it must take under six times as long, where comparing every statement by its value took many times that. | | 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/domain/src/release/releaseDraft.test.ts b/packages/domain/src/release/releaseDraft.test.ts index d1accdc8..156d61d5 100644 --- a/packages/domain/src/release/releaseDraft.test.ts +++ b/packages/domain/src/release/releaseDraft.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest"; import { SM } from "@solid-memo/vocab/vocab.generated"; -import { growth } from "../testing/growth.ts"; +import { cpuTime, growth } from "../testing/growth.ts"; import { card, courseDraft, @@ -1216,6 +1216,17 @@ describe("the contact", () => { ]); }); + it("takes an id after the ones a record has or a statement names", () => { + const draft = courseDraft(); + const taken = { + ...draft, + agents: [...draft.agents, { ...draft.agents[0]!, id: "contact" }], + triples: [...draft.triples, link(DRAFT, RDF_TYPE, of("contact-2"))], + }; + const set = made(taken, { kind: "setContact", url: "https://a.example/" }); + expect(set.triples.slice(-3)).toEqual(contact("contact-3", "https://a.example/")); + }); + it("is the page of the first contact point that names one", () => { const draft = courseDraft(); const named = { @@ -1720,6 +1731,30 @@ describe("blank nodes", () => { ]); }); + it("stay when the change puts back an equal statement naming them", () => { + const draft = courseDraft(); + const said = { + predicate: "https://p.example/sum", + object: { kind: "blank" as const, value: "b0" }, + }; + const inner = { + subject: "_:b0", + predicate: "https://p.example/value", + object: { kind: "iri" as const, value: "https://p.example/v" }, + }; + const withBlank = { + ...draft, + triples: [...draft.triples, { subject: "https://source.example/", ...said }, inner], + }; + const again = made(withBlank, { + kind: "setSource", + iri: "https://source.example/", + source: { statements: [said], derivedFrom: true, used: false }, + }); + expect(again.triples).toContainEqual(inner); + expect(again.triples).toContainEqual({ subject: "https://source.example/", ...said }); + }); + it("go with a long chain, however its statements are ordered, in time that grows with it", () => { const draft = courseDraft(); const chained = (depth: number) => { @@ -1754,6 +1789,7 @@ describe("blank nodes", () => { }); describe("how an earlier release was made", () => { + const XSD_STRING = "http://www.w3.org/2001/XMLSchema#string"; const V1 = "https://pod.example/solid-memo/main/releases/solid/v1.ttl"; /** The next version of the course fixture, whose compilation was associated with `#alice` and used ``. */ function next(): ReleaseDraft { @@ -1824,6 +1860,63 @@ describe("how an earlier release was made", () => { ).toEqual([of("compilation"), of("revision-1")]); }); + it("is refused only for the activity a change rewrites, not one it puts back as it was", () => { + const draft = next(); + const two = { + ...draft, + published: { ...draft.published, activities: ["revision-0", ...draft.published.activities] }, + }; + expect(applyDraftChange(two, { kind: "setAgent", id: "alice", agent: null })).toEqual({ + refused: "carriedActivity", + id: "compilation", + }); + expect( + applyDraftChange(draft, { kind: "setSource", iri: of("compilation"), source: null }), + ).toEqual({ refused: "carriedActivity", id: "compilation" }); + const statements = compilation(draft) + .reverse() + .map(({ predicate, object }) => ({ predicate, object })); + const again = made(draft, { + kind: "setSource", + iri: of("compilation"), + source: { statements, derivedFrom: true, used: false }, + }); + expect(compilation(again)).toEqual(compilation(draft).reverse()); + }); + + it("is checked, with the notes or the contact set, in a few cheap passes over the draft's statements, however many it has", () => { + // A keystroke in the notes or the contact goes over the draft's + // statements a few times, as idsInUse goes over them once. Finding + // what it took out by its value, or comparing every statement of an + // activity carried from an earlier release, takes many times as + // long; a ratio, not a budget in milliseconds, holds on any machine + // (testing.md). + const base = next(); + const draft: ReleaseDraft = { + ...base, + triples: [ + ...base.triples, + ...Array.from({ length: 20_000 }, (_, n) => ({ + subject: of(`x${n}`), + predicate: "http://www.w3.org/2000/01/rdf-schema#comment", + object: { kind: "literal" as const, value: `c${n}`, language: "", datatype: XSD_STRING }, + })), + ], + }; + const best = (work: () => unknown) => { + let took = Infinity; + for (let round = 0; round < 5; round++) took = Math.min(took, cpuTime(work)); + return took; + }; + const reference = best(() => idsInUse(draft)); + for (const change of [ + { kind: "setMakingNotes", notes: { en: "A note" } }, + { kind: "setContact", url: "https://contact.example/" }, + ] satisfies DraftChange[]) { + expect(best(() => made(draft, change)) / reference).toBeLessThan(6); + } + }); + it("keeps what an earlier activity used and was associated with, refusing to remove an agent it names", () => { const draft = next(); const removed = made(draft, { diff --git a/packages/domain/src/release/releaseDraft.ts b/packages/domain/src/release/releaseDraft.ts index bb317d45..a228561b 100644 --- a/packages/domain/src/release/releaseDraft.ts +++ b/packages/domain/src/release/releaseDraft.ts @@ -14,7 +14,7 @@ import type { } from "@solid-memo/vocab/types.generated"; import { SM } from "@solid-memo/vocab/vocab.generated"; import { EDUCATION_THEME, namedWithin, TURTLE_MEDIA_TYPE } from "../dcat.ts"; -import { jsonPath, PathMap, pathKeys } from "../pathKeys.ts"; +import { PathMap, pathKeys } from "../pathKeys.ts"; import type { ReleaseKind, ReleaseTerm, ReleaseText } from "./releaseModel.ts"; /** @@ -176,10 +176,18 @@ export function turtleDistribution(url: string): DistributionV1 { /** The fragment id of every subject of the draft, of a record or a triple, and of every IRI of it a statement names. */ export function idsInUse(draft: ReleaseDraft): Set { + return idsStartingWith(draft, ""); +} + +/** + * The ids of idsInUse that start with `start`, gathered without taking + * the fragment of any other IRI apart. + */ +function idsStartingWith(draft: ReleaseDraft, start: string): Set { const ids = new Set(); + const prefix = iriIn(draft, start); const note = (iri: string) => { - const id = idIn(draft, iri); - if (id !== null) ids.add(id); + if (iri.startsWith(prefix)) ids.add(iri.slice(draft.url.length + 1)); }; for (const nodes of [ draft.agents, @@ -189,7 +197,7 @@ export function idsInUse(draft: ReleaseDraft): Set { draft.cards, draft.distractors, ]) { - for (const node of nodes) ids.add(node.id); + for (const node of nodes) if (node.id.startsWith(start)) ids.add(node.id); } for (const triple of draft.triples) { note(triple.subject); @@ -371,6 +379,12 @@ export function isRefusal(result: ReleaseDraft | DraftRefusal): result is DraftR * that only statements the change took out named goes with them. What * an activity carried from an earlier release states is never changed: * a change that would is refused (carriedActivity). + * + * A change keeps each statement it leaves alone as the same object, so + * what it took out is found by identity, and only the blank nodes that + * names are looked at further: no statement is compared by its value. + * What remains of the cost beyond the change is a few cheap passes over + * the statements. */ export function applyDraftChange( draft: ReleaseDraft, @@ -378,11 +392,17 @@ export function applyDraftChange( ): ReleaseDraft | DraftRefusal { const next = changedDraft(draft, change); if (isRefusal(next) || next.triples === draft.triples) return next; - const triples = withoutOrphans(next.triples, draft.triples); + const triples = withoutOrphans(next.triples, removedFrom(draft.triples, next.triples)); const rewritten = rewrittenActivity(draft, triples); return rewritten === null ? { ...next, triples } : { refused: "carriedActivity", id: rewritten }; } +/** The statements of `before` that `after` no longer has, the same object, in their order. */ +function removedFrom(before: readonly DraftTriple[], after: readonly DraftTriple[]): DraftTriple[] { + const kept = new Set(after); + return before.filter((triple) => !kept.has(triple)); +} + /** * The statements of each of `subjects` among `triples`, in their order, * gathered in one pass; a subject none is of is left out. @@ -401,16 +421,32 @@ export function triplesBySubject( return of; } -/** The first activity carried from an earlier release whose statements `triples` changes, by id; null when none. */ +/** + * The first activity carried from an earlier release whose statements + * `triples` changes, by id; null when none. Only the activities that are + * subjects of a statement taken out or put in are compared, and none + * when the draft carries none. + */ function rewrittenActivity(draft: ReleaseDraft, triples: readonly DraftTriple[]): string | null { - const activities = new Set(draft.published.activities.map((id) => iriIn(draft, id))); + if (draft.published.activities.length === 0) return null; + const touched = new Set( + [...removedFrom(draft.triples, triples), ...removedFrom(triples, draft.triples)].map( + (triple) => triple.subject, + ), + ); + const activities = new Set( + draft.published.activities.map((id) => iriIn(draft, id)).filter((iri) => touched.has(iri)), + ); + if (activities.size === 0) return null; const was = triplesBySubject(draft.triples, activities); const now = triplesBySubject(triples, activities); const said = (of: Map, iri: string) => JSON.stringify((of.get(iri) ?? []).map(keyOfTriple).sort()); return ( draft.published.activities.find( - (id) => said(was, iriIn(draft, id)) !== said(now, iriIn(draft, id)), + (id) => + activities.has(iriIn(draft, id)) && + said(was, iriIn(draft, id)) !== said(now, iriIn(draft, id)), ) ?? null ); } @@ -420,15 +456,6 @@ function keyOfTriple(triple: DraftTriple): string { return JSON.stringify([triple.subject, triple.predicate, triple.object]); } -/** - * A statement as a path of its subject, predicate and object, each part - * a key of its own (PathMap), to look it up by: a statement as one - * string (keyOfTriple) could be longer than a term may be. - */ -function pathOfTriple(triple: DraftTriple): string[] { - return [triple.subject, triple.predicate, ...jsonPath(triple.object)]; -} - /** The blank node a term is, by its `_:` label; null for any other term. */ function blankOf(term: ReleaseTerm): string | null { return term.kind === "blank" ? `_:${term.value}` : null; @@ -436,17 +463,19 @@ function blankOf(term: ReleaseTerm): string | null { /** * `kept` without the statements of blank nodes that only statements of - * `before` it lacks named, however deep. Each blank node is looked at - * once a statement naming it goes: it goes too when no statement kept - * names it any more, and the blank nodes its statements name are looked - * at in turn. + * `removed` named, however deep. Each blank node is looked at once a + * statement naming it goes: it goes too when no statement kept names it + * any more, and the blank nodes its statements name are looked at in + * turn. When `removed` names none, `kept` is all there is to it. */ function withoutOrphans( kept: readonly DraftTriple[], - before: readonly DraftTriple[], -): DraftTriple[] { - const keys = new PathMap(); - for (const triple of kept) keys.set(pathOfTriple(triple), true); + removed: readonly DraftTriple[], +): readonly DraftTriple[] { + const named = (triples: readonly DraftTriple[]) => + triples.map((triple) => blankOf(triple.object)).filter((blank) => blank !== null); + const looked = named(removed); + if (looked.length === 0) return kept; const naming = new Map(); const subjects = new Set(); for (const triple of kept) { @@ -455,9 +484,6 @@ function withoutOrphans( subjects.add(triple.subject); } const of = triplesBySubject(kept, subjects); - const named = (triples: readonly DraftTriple[]) => - triples.map((triple) => blankOf(triple.object)).filter((blank) => blank !== null); - const looked = named(before.filter((triple) => !keys.has(pathOfTriple(triple)))); const orphans = new Set(); for (let blank = looked.pop(); blank !== undefined; blank = looked.pop()) { if (orphans.has(blank) || (naming.get(blank) ?? 0) > 0) continue; @@ -1577,7 +1603,8 @@ export function contactOf(draft: ReleaseDraft): string | null { /** * The draft with its contact set: what it said of a contact before goes, * with what it stated of each contact point; the new one is `#contact`, - * or the first free id after it. + * or the first free id after it. Only the ids in use that start as + * `contact` does are gathered, as only those can stand in its way. */ function setContact(draft: ReleaseDraft, url: string | null): ReleaseDraft { const points = new Set(contactPointsOf(draft)); @@ -1587,7 +1614,7 @@ function setContact(draft: ReleaseDraft, url: string | null): ReleaseDraft { !points.has(triple.subject), ); if (url === null) return { ...draft, triples }; - const used = idsInUse({ ...draft, triples }); + const used = idsStartingWith({ ...draft, triples }, CONTACT_ID); let id = CONTACT_ID; for (let n = 2; used.has(id); n++) id = `${CONTACT_ID}-${n}`; const point = iriIn(draft, id); diff --git a/packages/domain/src/testing/growth.ts b/packages/domain/src/testing/growth.ts index 1487b5e7..5ec7c394 100644 --- a/packages/domain/src/testing/growth.ts +++ b/packages/domain/src/testing/growth.ts @@ -108,7 +108,7 @@ async function cpuTimeAsync(work: () => Promise): Promise { } /** The CPU time `work` takes, in microseconds. */ -function cpuTime(work: () => unknown): number { +export function cpuTime(work: () => unknown): number { const started = process.cpuUsage(); work(); const { user, system } = process.cpuUsage(started);