Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions docs/deck-library.md
Original file line number Diff line number Diff line change
Expand Up @@ -842,12 +842,15 @@ Such a release is made from a draft in the creator's pod
but a next version's draft copies the series, the publisher and the
earlier versions from the index (`readLinked`, `linkedDescription`).
It copies only what a release needs: what an index says of a series,
and a publisher's class and name. A release downloaded for `decks/`
from a next version carries those statements.
and a publisher's class and name. Of the earlier versions, it copies
only those in the release's folder, and the version it follows. A
release downloaded for `decks/` from a next version carries those
statements.
- A `decks/` release made a draft and published again comes back as it
was, but for what publishing sets (a test of its own,
[testing.md](testing.md#strategy-per-layer)). Imported from a file in
the Studio, it starts a series of its own instead of the index's.
the Studio, it starts a series of its own instead of the index's,
and follows no version.

## In the app

Expand Down
27 changes: 24 additions & 3 deletions docs/studio.md
Original file line number Diff line number Diff line change
Expand Up @@ -1577,17 +1577,38 @@ A new draft starts from
release only claims: on another origin, or in another pod on the
same server. The release check reports it (`seriesNotPrevious`),
and publishing makes the new release no version of it.
- The series lists only the release's own versions
(`withOwnVersions`): the release, the version it follows,
and earlier versions in its folder (`<name>/v<N>.ttl`). Any other
version the series lists is dropped.
- **a release saved as a file**, Turtle or JSON-LD, as it is
(`releaseToDraft` in
[releaseToDraft.ts](../packages/domain/src/release/releaseToDraft.ts)),
but for its series (`withOwnSeries`). A series that is one of the
file's own subjects (`<release>#id`) moves with it. One anywhere
but for its series and the version it follows (`withOwnSeries`). It
follows none (no `dcat:prev` or `dcat:previousVersion`): the
version the file follows isn't the creator's, and what only that
link leads to is dropped too. With nothing to compare it with, all
of it is new, and there's no **Changes** tab. A series that is one
of the file's own subjects (`<release>#id`) moves with it. One anywhere
else, a library's index or another release, belongs to other
releases, and the draft, once published, would be their newest
version. The draft starts a series of its own (`#series`, or
`#series-2`, … when that id is taken) in its place, as `dcat:inSeries`
and `dcat:isVersionOf`, and what the file states of the other series
is dropped. The release screen shows the series and the version.
is dropped. Either way, the series lists no version but the release:
the file's other versions aren't the creator's. What only those
versions lead to is dropped too. A series the file describes keeps
a first version: the release, when the file names no other. The
release screen shows the series and the version.

In both cases, the draft keeps nothing the release states of its
default place (`unplaced` in `releaseDrafts.ts`). That's the
instance's `releases/<name>/v<M>.ttl`, by the draft's name, with M its
version or higher: where the draft and its next versions are published
unless the creator picks another folder. That place is the draft's
wherever the release is, in the library, in another pod or in a file.
Publishing the draft or a next version of it there would otherwise be
refused (`releaseForeignStatement`).

Either way, the draft doesn't keep a statement that one of its
documents is `dcterms:isPartOf` anything, which to a deck would say the
Expand Down
152 changes: 152 additions & 0 deletions packages/application/src/releaseDrafts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,158 @@ describe("createReleaseDraft", () => {
expect(named).not.toContain("_:b0");
});

it("keeps no version a release's own series lists but its own, nor what only those lead to (GHSA-jp29-vf56-5g4g)", async () => {
const { useCases, repository, created } = setUp();
const DCAT = "http://www.w3.org/ns/dcat#";
const SERIES = `${V1}#series`;
// The address its next version is published at, and one elsewhere, each listed with notes of the file's choosing.
const NEXT = `${INSTANCE}releases/solid/v2.ttl`;
const ELSEWHERE = "https://attacker.example/x/v2.ttl";
const notes = (subject: string): DraftTriple => ({
subject,
predicate: "http://www.w3.org/ns/adms#versionNotes",
object: { kind: "literal", value: "Planted", language: "", datatype: XSD_STRING },
});
const crafted: ReleaseDraft = {
...v1,
triples: [
...v1.triples,
{ subject: SERIES, predicate: `${DCAT}hasVersion`, object: { kind: "iri", value: V1 } },
{ subject: SERIES, predicate: `${DCAT}hasVersion`, object: { kind: "iri", value: NEXT } },
{
subject: SERIES,
predicate: `${DCAT}hasVersion`,
object: { kind: "iri", value: ELSEWHERE },
},
{ subject: SERIES, predicate: `${DCAT}first`, object: { kind: "iri", value: ELSEWHERE } },
notes(NEXT),
notes(`${NEXT}#x`),
notes(ELSEWHERE),
{
subject: ELSEWHERE,
predicate: "http://purl.org/dc/terms/publisher",
object: { kind: "blank", value: "b0" },
},
notes("_:b0"),
{
subject: `${V1}#q-a-1a`,
predicate: "http://www.w3.org/2000/01/rdf-schema#seeAlso",
object: { kind: "iri", value: `${NEXT}#x` },
},
],
};
const planted = (draft: ReleaseDraft) =>
draft.triples.filter(
(triple) =>
[NEXT, `${NEXT}#x`, ELSEWHERE, "_:b0"].includes(triple.subject) ||
(triple.subject.endsWith("#series") && [NEXT, ELSEWHERE].includes(triple.object.value)),
);
repository.parseRelease.mockResolvedValueOnce(crafted);
await useCases.createReleaseDraft(INSTANCE, { kind: "fromFile" }, WEB_ID);
const imported = created();
expect(planted(imported)).toEqual([]);
expect(imported.triples.filter((triple) => triple.predicate === `${DCAT}hasVersion`)).toEqual([
{
subject: `${imported.url}#series`,
predicate: `${DCAT}hasVersion`,
object: { kind: "iri", value: imported.url },
},
]);
// A release published with them carries none into its next version, which its own place takes.
repository.readRelease.mockResolvedValueOnce(crafted);
await useCases.createReleaseDraft(INSTANCE, { kind: "nextVersionOf", url: V1 }, WEB_ID);
const next = created();
expect(planted(next)).toEqual([]);
expect(next.triples).toContainEqual({
subject: SERIES,
predicate: `${DCAT}hasVersion`,
object: { kind: "iri", value: V1 },
});
expect(next.triples).toContainEqual({
...crafted.triples.at(-1)!,
subject: `${next.url}#q-a-1a`,
});
});

it("keeps nothing a release states of where its draft is published, wherever the release is (GHSA-jp29-vf56-5g4g)", async () => {
const { useCases, repository, created } = setUp();
const notes = (subject: string): DraftTriple => ({
subject,
predicate: "http://www.w3.org/ns/adms#versionNotes",
object: { kind: "literal", value: "Planted", language: "", datatype: XSD_STRING },
});
/** The course at `url`, stating what it may of the instance's `releases/solid/v<from>.ttl` and later. */
const stating = (url: string, from: number): ReleaseDraft => {
const at = (version: number) => `${INSTANCE}releases/solid/v${version}.ttl`;
const release = rebaseDraft(courseDraft(), url);
return {
...release,
triples: [
...release.triples,
{
subject: `${url}#q-a-1a`,
predicate: "http://www.w3.org/2000/01/rdf-schema#seeAlso",
object: { kind: "iri", value: `${at(from)}#x` },
},
notes(at(from)),
notes(`${at(from)}#x`),
notes(at(from + 1)),
{
subject: at(from),
predicate: "http://purl.org/dc/terms/publisher",
object: { kind: "blank", value: "b0" },
},
notes("_:b0"),
{
subject: `${url}#q-a-1a`,
predicate: "http://www.w3.org/2000/01/rdf-schema#seeAlso",
object: { kind: "iri", value: at(from - 1) },
},
notes(at(from - 1)),
],
};
};
const placed = (draft: ReleaseDraft) =>
draft.triples.filter(
({ subject }) =>
(subject.startsWith(`${INSTANCE}releases/solid/`) &&
!subject.includes("/v0.ttl") &&
!subject.endsWith("/v1.ttl")) ||
subject === "_:b0",
);
// The next version of a release in another pod is published in the instance's releases/ all the same.
repository.readRelease.mockResolvedValueOnce(
stating("https://elsewhere.example/releases/solid/v1.ttl", 2),
);
await useCases.createReleaseDraft(
INSTANCE,
{ kind: "nextVersionOf", url: "https://elsewhere.example/releases/solid/v1.ttl" },
WEB_ID,
);
const next = created();
expect(next.url).toBe(`${INSTANCE}drafts/solid/v2/release.ttl`);
expect(placed(next)).toEqual([]);
// What it states of an address before its place, and a link to its place, are kept.
expect(next.triples).toContainEqual(notes(`${INSTANCE}releases/solid/v1.ttl`));
expect(next.triples).toContainEqual({
subject: `${next.url}#q-a-1a`,
predicate: "http://www.w3.org/2000/01/rdf-schema#seeAlso",
object: { kind: "iri", value: `${INSTANCE}releases/solid/v2.ttl#x` },
});
// So is a file's, imported as version 1, published at the instance's releases/solid/v1.ttl.
repository.parseRelease.mockResolvedValueOnce(
stating("https://elsewhere.example/x/solid/v1.ttl", 1),
);
await useCases.createReleaseDraft(INSTANCE, { kind: "fromFile" }, WEB_ID);
const imported = created();
expect(imported.url).toBe(`${INSTANCE}drafts/solid/v1/release.ttl`);
expect(
imported.triples.filter(
({ subject }) => subject.startsWith(`${INSTANCE}releases/solid/v`) || subject === "_:b0",
),
).toEqual([notes(`${INSTANCE}releases/solid/v0.ttl`)]);
});

it("refuses a release that names a subject by an id a draft cannot keep, writing nothing", async () => {
const { useCases, repository } = setUp();
const odd = {
Expand Down
47 changes: 35 additions & 12 deletions packages/application/src/releaseDrafts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,13 @@ import {
type ReleaseCheck,
} from "@solid-memo/domain/release/releaseCheck";
import { FOAF_NAME, type ReleaseModel } from "@solid-memo/domain/release/releaseModel";
import { releasePlaceOf } from "@solid-memo/domain/release/releasePlace";
import { isDraftsPlaceOnward, releasePlaceOf } from "@solid-memo/domain/release/releasePlace";
import {
rebaseDraft,
releaseToDraft,
withOwnSeries,
} from "@solid-memo/domain/release/releaseToDraft";
import { nextVersionDraft } from "@solid-memo/domain/release/releaseVersion";
import { nextVersionDraft, withOwnVersions } from "@solid-memo/domain/release/releaseVersion";
import type {
DeckLibrary,
DeckRepository,
Expand Down Expand Up @@ -100,7 +100,7 @@ export type NewDraft =
* otherwise.
*/
| { kind: "nextVersionOf"; url: string }
/** A release in a Turtle or JSON-LD file the user picks, with only its own statements and in a series of its own (ownStatements, withOwnSeries, releaseToDraft). */
/** A release in a Turtle or JSON-LD file the user picks, with only its own statements, in a series of its own and following no version (ownStatements, withOwnSeries, releaseToDraft). */
| { kind: "fromFile" };

/** A draft made, and for one made from a copy of a release, that release: to start the next version of instead. */
Expand Down Expand Up @@ -253,8 +253,10 @@ function placeTaken(error: unknown, url: string): boolean {
* outside it (releaseForeignPart): no editor or check of a draft would
* see that, but its release would publish it (GHSA-67w8-qjpm-jhwp). Nor
* one that links to the guest's pod by an IRI (releaseLinksGuestPod): a
* guest's draft of it could not be kept (GHSA-hc3p-fpwj-fvjp). Of its
* other statements, it keeps only its own (ownStatements).
* guest's draft of it could not be kept (GHSA-hc3p-fpwj-fvjp). Its
* series lists only its own versions (withOwnVersions), and of its other
* statements, it keeps only its own (ownStatements): nothing of a
* version it no longer lists (GHSA-jp29-vf56-5g4g).
*/
function draftable(read: ReleaseDraft): ReleaseDraft {
if (draftAddressesGuest(read)) throw new AppError("releaseLinksGuestPod", { url: read.url });
Expand All @@ -266,7 +268,7 @@ function draftable(read: ReleaseDraft): ReleaseDraft {
};
const foreign = strayPartsOf(release).find((stray) => idIn(release, stray) === null);
if (foreign !== undefined) throw new AppError("releaseForeignPart", { part: foreign });
return ownStatements(release);
return ownStatements(withOwnVersions(release));
}

/**
Expand Down Expand Up @@ -398,15 +400,36 @@ function untaken(triples: readonly DraftTriple[], taken: (iri: string) => boolea
);
}

/**
* The statements, but those of an address where the draft at `url` or a
* next version of it is published unless the creator chooses another
* folder (isDraftsPlaceOnward), other than the source's own, which move
* to the draft's. Publishing the draft there would be refused
* (releaseForeignStatement), or its next version would be, and published
* elsewhere, it would say of that release what no draft of it does
* (GHSA-jp29-vf56-5g4g). The place is the draft's, wherever its source
* is: the instance's releases/, by the draft's name.
*/
function unplaced(
triples: readonly DraftTriple[],
source: ReleaseDraft,
url: string,
): DraftTriple[] {
const theirs = (iri: string) =>
isDraftsPlaceOnward(url, iri) && iri !== source.url && idIn(source, iri) === null;
return untaken(triples, takenBy(source, url)).filter(({ subject }) => !theirs(subject));
}

/**
* The release, or draft, to be made the draft at `url`, before it is
* moved there: without a statement of or naming an address in the new
* draft's container (takenBy), and what only such a statement led to
* (ownStatements). Before the move, the draft's own address is one of
* these too: a statement of it is not one of the source's.
* draft's container (takenBy), or of where it is published (unplaced),
* and what only such a statement led to (ownStatements). Before the
* move, the draft's own address is one of these too: a statement of it
* is not one of the source's.
*/
function inItsPlace(source: ReleaseDraft, url: string): ReleaseDraft {
const triples = untaken(source.triples, takenBy(source, url));
const triples = unplaced(source.triples, source, url);
return triples.length === source.triples.length ? source : ownStatements({ ...source, triples });
}

Expand Down Expand Up @@ -653,7 +676,7 @@ export function createReleaseDraftUseCases({
const draft = nextVersionDraft(
inItsPlace(release, url),
url,
untaken(linked, takenBy(release, url)),
unplaced(linked, release, url),
);
// What the linked documents state is checked as the draft has it, as the release was (draftable).
if (linksGuestPodBeyond(draft))
Expand All @@ -666,7 +689,7 @@ export function createReleaseDraftUseCases({
case "fromFile": {
const file = await fileExchange.open(DECK_FILE_ACCEPT);
if (file === null) return null;
// In a series of its own, unless the file's is one of its subjects (GHSA-38m7-8r5j-q79f).
// In a series of its own, unless the file's is one of its subjects (GHSA-38m7-8r5j-q79f), following no version (GHSA-jp29-vf56-5g4g).
const release = draftable(
withOwnSeries(
await releaseDraftRepository.parseRelease(
Expand Down
41 changes: 41 additions & 0 deletions packages/domain/src/release/releasePlace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

import { describe, expect, it } from "vitest";
import {
isDraftsPlaceOnward,
isEarlierVersionBeside,
isPublishedReleaseUrl,
isReleaseContainer,
releaseFileName,
Expand Down Expand Up @@ -77,3 +79,42 @@ describe("isPublishedReleaseUrl", () => {
}
});
});

describe("a version beside a release", () => {
const V2 = "https://pod.example/shared/capitals/v2.ttl";

it("is earlier at a release's address in its folder, of a lower version", () => {
expect(isEarlierVersionBeside(V2, "https://pod.example/shared/capitals/v1.ttl")).toBe(true);
for (const iri of [
V2,
"https://pod.example/shared/capitals/v3.ttl",
"https://pod.example/shared/capitals/v1.ttl#x",
"https://pod.example/other/capitals/v1.ttl",
"https://pod.example/shared/capitals/one.ttl",
])
expect(isEarlierVersionBeside(V2, iri)).toBe(false);
expect(isEarlierVersionBeside("https://pod.example/shared/capitals.ttl", V2)).toBe(false);
});
});

describe("isDraftsPlaceOnward", () => {
const DRAFT = "https://pod.example/memo/drafts/capitals/v2/release.ttl";
const at = (path: string) => `https://pod.example/memo/releases/capitals/${path}`;

it("is where the draft is published by default, a later version there, or a subject of one", () => {
for (const iri of [at("v2.ttl"), at("v2.ttl#x"), at("v3.ttl"), at("v10.ttl#series")])
expect(isDraftsPlaceOnward(DRAFT, iri)).toBe(true);
for (const iri of [
at("v1.ttl"),
at("v1.ttl#x"),
at("v02.ttl"),
at("v3.ttl/x"),
at("other.ttl"),
"https://pod.example/memo/releases/other/v3.ttl",
"https://elsewhere.example/memo/releases/capitals/v3.ttl",
"_:b0",
])
expect(isDraftsPlaceOnward(DRAFT, iri)).toBe(false);
expect(isDraftsPlaceOnward(at("v1.ttl"), at("v2.ttl"))).toBe(false);
});
});
Loading
Loading