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
12 changes: 9 additions & 3 deletions docs/data-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -499,9 +499,15 @@ No document Solid Memo names has any.
and `isDraftDocument` in
[deckPlace.ts](../packages/solid/src/deckPlace.ts)).
- **An older document that doesn't say so** (from an earlier version, a
repair or a copy) is the deck's in the instance's own folder, unless
that folder is the root of a pod or a storage (typed `pim:Storage`).
There the next write states it. One that names any other catalog's
repair or a copy) is the deck's in the instance's own folder. That
folder isn't the instance's own when it's the root of a pod or a
storage, or a folder above one. Such a root is one of the user's
storages (as above), which a server may leave untyped, or a root the
server shows (typed `pim:Storage`, or named by a Linked Web Storage
server). In the instance's own folder the next write states it. When
the profile can't be read, a write fails and can be retried. The data
check and a removal then read the document, and take it for unclaimed
unless it says it's the deck's. One that names any other catalog's
entry (such as a nested instance's) isn't the deck's: the app never
adds a second owner on its own, and refuses the write
(`deckDocumentUnclaimed`). The data check doesn't read a document in
Expand Down
79 changes: 79 additions & 0 deletions packages/solid/src/deckPlace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1074,6 +1074,85 @@ describe("an instance at the pod's root, laid out flat", () => {
).resolves.toEqual([]);
});

describe("on a server that holds the pods of many on one origin and types none of their roots", () => {
const pod = `${ROOT}alice/`;
const SECRET = `<#entry> <https://example.org/says> "secret" .`;
const at = (instance: string, document = DIARY) => ({
url: `${instance}catalog.ttl#deck-1`,
title: { en: "Spanish" },
cardsDocumentUrl: document,
reviewsDocumentUrl: document,
});

/** A guard over a server that types no folder and holds `documents` (address to Turtle), each request kept in `asked`. */
function untyped(
userStorages: () => Promise<readonly string[]>,
documents: Record<string, string> = { [DIARY]: SECRET },
) {
const asked: string[] = [];
const fetch = (async (input, init) => {
const method = init?.method ?? "GET";
asked.push(`${method} ${String(input)}`);
const body = documents[String(input)];
if (method === "HEAD" || body === undefined) return new Response(null, { status: 200 });
return new Response(body, { headers: { "Content-Type": "text/turtle", ETag: '"e1"' } });
}) as typeof globalThis.fetch;
return { guard: createDeckDocumentGuard(fetch, userStorages), asked };
}

it("never marks an older document as a deck's by itself at a pod's root the user's storages name", async () => {
const diary = await datasetOfTurtle(DIARY, SECRET);
// However the storage is spelled: host case, default port, no trailing slash.
for (const storage of [pod, "https://POD.example:443/alice"]) {
const { guard, asked } = untyped(async () => [storage]);
await expect(guard.standing(at(pod), DIARY, diary)).resolves.toBe("unclaimed");
await expect(guard.writable(at(pod), DIARY, diary)).rejects.toMatchObject({
code: "deckDocumentUnclaimed",
});
await expect(guard.removal(at(pod), DIARY)).resolves.toBe("unclaimed");
await expect(guard.unclaimed(at(pod))).resolves.toEqual([DIARY]);
// The user's storages decide it: the folder is never asked.
expect(asked.filter((one) => one.startsWith("HEAD"))).toEqual([]);
}
});

it("never marks one as a deck's in a folder above the user's storage either", async () => {
const diary = await datasetOfTurtle(DIARY, SECRET);
const { guard } = untyped(async () => [pod]);
const above = `${ROOT}pods/`;
const inside = `${above}alice/private/diary.ttl`;
const read = await datasetOfTurtle(inside, `<#entry> <https://example.org/says> "x" .`);
const nested = untyped(async () => [`${above}alice/`]);
await expect(nested.guard.standing(at(above, inside), inside, read)).resolves.toBe(
"unclaimed",
);
// An instance in its own folder in the user's pod still marks an older document as the deck's.
const own = `${pod}memo/`;
const mine = `${own}decks/deck-1.ttl`;
const older = await datasetOfTurtle(mine, cardTurtle("card-1", "uno"));
await expect(guard.standing(at(own, mine), mine, older)).resolves.toBe("adoptable");
await expect(guard.removal(at(own, mine), mine)).resolves.toBe("deletable");
await expect(guard.unclaimed(at(own, mine))).resolves.toEqual([]);
await expect(guard.standing(at(pod), DIARY, diary)).resolves.toBe("unclaimed");
});

it("leaves an older document no deck's when the user's storages cannot be found, and refuses a write for now", async () => {
const own = `${pod}memo/`;
const mine = `${own}decks/deck-1.ttl`;
const older = await datasetOfTurtle(mine, cardTurtle("card-1", "uno"));
const { guard } = untyped(() => Promise.reject(new Error("profile unreadable")), {
[mine]: cardTurtle("card-1", "uno"),
});
await expect(guard.standing(at(own, mine), mine, older)).resolves.toBe("unclaimed");
await expect(guard.writable(at(own, mine), mine, older)).rejects.toMatchObject({
code: "cannotCheck",
vars: { url: own, status: 0 },
});
await expect(guard.removal(at(own, mine), mine)).resolves.toBe("unclaimed");
await expect(guard.unclaimed(at(own, mine))).resolves.toEqual([mine]);
});
});

it("asks again a folder whose storage description can't be read", async () => {
const p = await pod();
let heads = 0;
Expand Down
28 changes: 22 additions & 6 deletions packages/solid/src/deckPlace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,10 +177,25 @@ export function createDeckDocumentGuard(

/**
* Whether the folder is an instance's own: not a pod's root (`/`), nor
* a storage's (isStorageRoot), which hold the user's other data too.
* a storage's root or a folder above one, which hold the user's other
* data too. A storage's root is one of the user's storages
* (userStorages), which a server that holds many users' pods on one
* origin may leave untyped, or one the server shows (isStorageRoot).
* A storage list the pod does not answer for leaves it not shown to
* be: `lenient`, so the instance's documents are read and kept as
* they are; otherwise cannotCheck, so that a write may be tried again.
*/
async function isDedicatedFolder(folder: string): Promise<boolean> {
return new URL(folder).pathname !== "/" && !(await isStorageRoot(folder));
async function isDedicatedFolder(folder: string, lenient: boolean): Promise<boolean> {
if (new URL(folder).pathname === "/") return false;
let storages: readonly string[];
try {
storages = await userStorages();
} catch {
if (lenient) return false;
throw new AppError("cannotCheck", { url: folder, status: 0 });
}
if (storages.some((storage) => isInFolder(ensureTrailingSlash(storage), folder))) return false;
return !(await isStorageRoot(folder));
}

/**
Expand Down Expand Up @@ -279,7 +294,7 @@ export function createDeckDocumentGuard(
if (dataset !== null && ownsDocument(partOfOf(dataset, url), deck.url)) return "owned";
if (dataset === null) return "adoptable";
if (deckBackLinksOf(dataset, url).length > 0) return "unclaimed";
return (await isDedicatedFolder(instance)) ? "adoptable" : "unclaimed";
return (await isDedicatedFolder(instance, lenient)) ? "adoptable" : "unclaimed";
}

/**
Expand All @@ -299,7 +314,7 @@ export function createDeckDocumentGuard(
const found: string[] = [];
for (const url of [deck.cardsDocumentUrl, deck.reviewsDocumentUrl]) {
if (found.some((other) => isSameUrl(other, url))) continue;
if (isDocumentIn(url, instance) && (await isDedicatedFolder(instance))) continue;
if (isDocumentIn(url, instance) && (await isDedicatedFolder(instance, true))) continue;
if (await unclaimedAsRead([deck], url)) found.push(url);
}
return found;
Expand Down Expand Up @@ -383,7 +398,8 @@ export function createDeckDocumentGuard(
async removal(deck: DeckPlace, url: string): Promise<"deletable" | "unclaimed" | "unreadable"> {
const instance = instanceOf(deck);
if (isDraftDocument(url)) return "unclaimed";
if (isDocumentIn(url, instance) && (await isDedicatedFolder(instance))) return "deletable";
if (isDocumentIn(url, instance) && (await isDedicatedFolder(instance, true)))
return "deletable";
const dataset = await readToRemove(deck, url);
if (dataset === UNREADABLE) return "unreadable";
return (await standing(deck, url, dataset)) === "unclaimed" ? "unclaimed" : "deletable";
Expand Down
Loading