Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh

Commit

Moving an artifact to the trash from its own page takes it out of the sidebar at once: the trip back Home used to cancel the sidebar's reload, so the Artifacts layout now remembers that something changed and reloads with the navigation. The Trash page gets Empty trash, which deletes for good everything in it the viewer has full access to (`empty_folio_trash`, telling a `folio.deleted` event per artifact, as a single delete now does too) and says how many go and how many stay. And a space can be deleted, General included (`delete_space`): everything in it goes to the trash together, the space's row stays marked so the trash keeps its name and rules and General is never made again, an artifact restored from it lands in General or its owner's Private, archived spaces are listed on the Spaces page with Restore and Delete, and a workspace may have no spaces at all. The artifacts guide says all of this.

syntaqxcommitted Parent3dd024dBrowse files
20 files+670−900/20 viewed
+35−8
6767 | **Shared** | The highest artifact of each tree someone shared with you. |
6868 | **Possibly out of date** | Docs whose cited code changed. Shown when there are any. |
6969 | **Projects' docs** | Repositories' `docs/` folders, read-only. |
70−| **Trash** | What was moved to the trash. |
70+| **Trash** | What was moved to the trash, with how many. |
71+
72+The sidebar follows every change at once: an artifact you move to the trash
73+leaves it, one you restore comes back, and the count beside **Trash** moves
74+with them, without reloading the page.
7175
7276 Drag a row onto a doc to put it inside, or onto the top edge of a row to put
7377 it before it. Only a doc can hold other artifacts: a doc with others inside
137141
138142 ## Spaces
139143
140−Every workspace has a **General** space for what everyone should know. Make
141−more from **+** beside **Spaces** in the sidebar, then **New space**.
144+Every workspace starts with a **General** space for what everyone should
145+know. Make more from **+** beside **Spaces** in the sidebar, then **New
146+space**. No space has to exist: a workspace can have none at all, and
147+artifacts in no space live in their owners' **Private**.
142148
143149 | Who it's for | Who can open it |
144150 | --- | --- |
155161 the projects it's about. **Editors can share**, off by default, lets people
156162 who can edit what's in the space share it too, up to **Can edit**. Only
157163 people with full access give full access, change general access, or change
158−someone else's full access. **Archive this space** takes it out of the
159−sidebar and search; what's in it is kept.
164+someone else's full access.
165+
166+### Archiving and deleting a space
160167
168+Both are at the bottom of a space's **Settings**, for people with full access
169+to the space; workspace owners can also delete any space they can open.
170+
171+| | What happens |
172+| --- | --- |
173+| **Archive space** | It leaves the sidebar and search. Everything in it is kept. **Browse spaces** lists it under **Archived**, where **Restore** brings it back as it was, or **Delete** deletes it. |
174+| **Delete space** | Everything in it goes to the trash together, where anyone who could edit an artifact restores it as usual for 30 days. The space itself, its members and settings are gone for good. |
175+
176+Any space can be archived or deleted, **General** included. A workspace whose
177+General space is deleted has no General space; none takes its place, and
178+new spaces are made as usual. An artifact restored from a deleted space
179+comes back at the top of **General** when there is one you can add to, else
180+in its owner's **Private**.
181+
161182 ## Docs
162183
163184 Make a doc, give it a title, and press **Enter** to start writing. Type `/`
305326 ## Trash
306327
307328 **⋯ → Move to trash** puts an artifact, and everything inside it, in the
308−**Trash**. Anyone who can edit it can restore it from there, with what was
309−inside. After 30 days in the trash it's deleted for good. Deleting it sooner
310−needs full access.
329+**Trash**; it leaves the sidebar at once. Anyone who can edit it can restore
330+it from there, with what was inside. After 30 days in the trash it's deleted
331+for good. Deleting it sooner needs full access: **Delete** beside one
332+artifact, or **Empty trash** at the top of the page.
333+
334+**Empty trash** deletes for good every artifact in the trash you have full
335+access to, with its history, comments and everything inside it. Artifacts
336+you can only edit stay; the page says how many go and how many stay before
337+you confirm. Emptying the trash can't be undone.
311338
312339 ## Projects' docs
313340
+24−1
4141 return { ok: false, error: { code: "conflict", message } };
4242 }
4343
44+/**
45+ * Whether something changed since the layout last loaded its sidebar. A
46+ * change (a `foliosRequest` that succeeded) sets it; the layout's
47+ * `shouldRevalidate` reads it, so a page that changes something and then
48+ * navigates (trashing an artifact from its own page, making one from the
49+ * sidebar) reloads the sidebar with the navigation, instead of the
50+ * navigation cancelling the reload the change asked for. The layout
51+ * clears it once its data arrives.
52+ */
53+let sidebarChanged = false;
54+
55+/** For the layout's `shouldRevalidate`: whether a change happened since its data last arrived. */
56+export function sidebarChangedSinceLoad(): boolean {
57+ return sidebarChanged;
58+}
59+
60+/** The layout's data arrived: the sidebar shows every change made before it. */
61+export function sidebarLoaded(): void {
62+ sidebarChanged = false;
63+}
64+
4465 export async function foliosRequest<T>(slug: string, intent: string, body: Record<string, unknown> = {}): Promise<Result<T>> {
4566 try {
4667 const response = await fetch(`/${slug}/-/artifacts/api`, {
4970 body: JSON.stringify({ intent, ...body }),
5071 signal: AbortSignal.timeout(FOLIOS_TIMEOUT_MS),
5172 });
52− return (await response.json()) as Result<T>;
73+ const result = (await response.json()) as Result<T>;
74+ if (result.ok) sidebarChanged = true;
75+ return result;
5376 } catch (error) {
5477 return foliosFailure(error);
5578 }
+5−0
116116 return folios.restore(slug, viewer, id);
117117 case "delete":
118118 return folios.delete(slug, viewer, id);
119+ case "empty_trash":
120+ return folios.emptyTrash(slug, viewer);
119121 case "favorite":
120122 return folios.favorite(slug, viewer, id, !!sent.on);
121123 case "access":
142144 return docs.createSpace(slug, viewer, sent.space ?? ({ name: "", kind: "workspace" } as NewDocSpace));
143145 case "update_space":
144146 return docs.updateSpace(slug, sent.space_id ?? "", viewer, sent.space_change ?? {});
147+ // Deleting one trashes its artifacts, which is the folio code's.
148+ case "delete_space":
149+ return folios.deleteSpace(slug, viewer, sent.space_id ?? "");
145150 case "set_member":
146151 return docs.setSpaceMember(slug, sent.space_id ?? "", viewer, sent.member ?? "", sent.role ?? null);
147152 case "add_repo_space":
+12−4
1+import { useEffect } from "react";
12 import { Outlet, data, type ShouldRevalidateFunctionArgs } from "react-router";
23
34 import { type FoliosSidebar as Sidebar, shownUsername } from "@g1t/contracts";
45
56 import type { Route } from "./+types/layout";
7+import { sidebarChangedSinceLoad, sidebarLoaded } from "../../../components/folios/actions";
68 import type { DocMentionable } from "../../../components/folios/doc/editor";
79 import { workspacePeople } from "../../../lib/chat.server";
810 import { readCookie } from "../../../lib/mission";
6062 }
6163
6264 /**
63− * The sidebar changes when something is made, renamed, moved, shared or
64− * trashed (a submission, or a page asking again with `useRevalidator`);
65− * not when moving between pages.
65+ * The sidebar changes when something is made, renamed, moved, shared,
66+ * trashed, restored or deleted: a submission, a page asking again with
67+ * `useRevalidator`, or a change followed by a navigation (which
68+ * `takeSidebarChange` remembers, since a navigation cancels a reload in
69+ * flight, so the sidebar reloads with it); not when moving between
70+ * pages.
6671 */
6772 export function shouldRevalidate({ currentParams, nextParams, currentUrl, nextUrl, formMethod, defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) {
73+ if (sidebarChangedSinceLoad()) return true;
6874 if (formMethod && formMethod !== "GET") return defaultShouldRevalidate;
6975 if (currentUrl.href === nextUrl.href) return defaultShouldRevalidate;
7076 return currentParams.owner !== nextParams.owner;
7177 }
7278
73−export default function FoliosLayout() {
79+export default function FoliosLayout({ loaderData }: Route.ComponentProps) {
80+ // Fresh sidebar data: every change made before it is shown.
81+ useEffect(() => sidebarLoaded(), [loaderData]);
7482 return <Outlet />;
7583 }
+41−11
11 import { DOC_ROLES, DOC_ROLE_LABELS, DOC_ROLE_SUMMARIES, type DocRole, type DocSpace, type DocSpaceMember } from "@g1t/contracts";
2−import { Archive, Share2 } from "lucide-react";
2+import { Archive, Share2, Trash2 } from "lucide-react";
33 import { useState } from "react";
44 import { Link, data, useNavigate } from "react-router";
55
4747 const [adding, setAdding] = useState("");
4848 const [addRole, setAddRole] = useState<DocRole>("edit");
4949 const [archiving, setArchiving] = useState(false);
50+ const [deleting, setDeleting] = useState(false);
51+ // How many artifacts the sidebar shows in it, when it shows the space.
52+ const inside = layout?.sidebar?.spaces.find((s) => s.id === space.id)?.page_count ?? null;
5053 const listed = new Set(members.map((m) => m.key));
5154 const candidates = [
5255 ...(layout?.mentionables ?? []).map((m) => ({ value: m.kind === "agent" ? `agent:${m.id}` : `username:${m.name}`, label: m.kind === "agent" ? `${m.display_name} (agent)` : `${m.display_name} (@${m.name})` })),
155158 )}
156159 </section>
157160
158− {!space.is_default && (
159− <section className="mt-12 rounded-xl border border-danger/30 p-5">
160− <h2 className="text-sm font-semibold">Archive this space</h2>
161− <p className="mt-1 text-xs leading-relaxed text-muted">It leaves the sidebar and search. What's in it, its history and comments are kept.</p>
162− <Button type="button" variant="destructive" onClick={() => setArchiving(true)} className="mt-3">
163− <Archive size={14} /> Archive space
164− </Button>
165− </section>
166− )}
161+ <section className="mt-12 rounded-xl border border-danger/30 p-5">
162+ <h2 className="text-sm font-semibold">Archive this space</h2>
163+ <p className="mt-1 text-xs leading-relaxed text-muted">It leaves the sidebar and search. What's in it, its history and comments are kept, and it comes back from the Spaces page.</p>
164+ <Button type="button" variant="destructive" onClick={() => setArchiving(true)} className="mt-3">
165+ <Archive size={14} /> Archive space
166+ </Button>
167+ <h2 className="mt-6 text-sm font-semibold">Delete this space</h2>
168+ <p className="mt-1 text-xs leading-relaxed text-muted">
169+ Everything in it goes to the trash, where anyone who could edit it can restore it for 30 days. The space itself, its members and settings are gone for good.
170+ {space.is_default ? " A workspace needs no General space; none takes its place." : ""}
171+ </p>
172+ <Button type="button" variant="destructive" onClick={() => setDeleting(true)} className="mt-3">
173+ <Trash2 size={14} /> Delete space
174+ </Button>
175+ </section>
176+ <AlertDialog open={deleting} onOpenChange={setDeleting}>
177+ <AlertDialogContent>
178+ <AlertDialogHeader>
179+ <AlertDialogTitle>Delete {space.name}?</AlertDialogTitle>
180+ <AlertDialogDescription>
181+ {inside === null ? "Everything in it goes" : inside === 1 ? "The artifact in it goes" : `The ${inside} artifacts in it go`} to the trash, where anyone who could edit it can restore it for 30 days. The space can&apos;t be brought back.
182+ </AlertDialogDescription>
183+ </AlertDialogHeader>
184+ <AlertDialogFooter>
185+ <AlertDialogCancel>Keep it</AlertDialogCancel>
186+ <AlertDialogAction
187+ onClick={async () => {
188+ const done = await settings.send("delete_space", { space_id: space.id });
189+ if (done.ok) navigate(`/${slug}/-/artifacts`);
190+ }}
191+ >
192+ Delete space
193+ </AlertDialogAction>
194+ </AlertDialogFooter>
195+ </AlertDialogContent>
196+ </AlertDialog>
167197 <AlertDialog open={archiving} onOpenChange={setArchiving}>
168198 <AlertDialogContent>
169199 <AlertDialogHeader>
170200 <AlertDialogTitle>Archive {space.name}?</AlertDialogTitle>
171− <AlertDialogDescription>Nobody will see it in Artifacts until it is brought back.</AlertDialogDescription>
201+ <AlertDialogDescription>Nobody will see it in Artifacts until it is brought back from the Spaces page.</AlertDialogDescription>
172202 </AlertDialogHeader>
173203 <AlertDialogFooter>
174204 <AlertDialogCancel>Keep it</AlertDialogCancel>
+84−19
11 import type { DocSpace } from "@g1t/contracts";
2−import { Check, Plus } from "lucide-react";
2+import { Archive, Check, Plus, RotateCcw, Trash2 } from "lucide-react";
3+import { useState } from "react";
34 import { Link, data } from "react-router";
45
56 import type { Route } from "./+types/spaces";
67 import { useFoliosAction, useFoliosData } from "../../../components/folios/actions";
78 import { SpaceIcon, spaceKindLabel } from "../../../components/folios/parts";
8−import { EmptyState, ErrorText } from "../../../components/ui";
9+import { EmptyState, ErrorText, TimeAgo } from "../../../components/ui";
10+import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "../../../components/ui/alert-dialog";
11+import { Button } from "../../../components/ui/button";
912 import { Card } from "../../../components/ui/card";
10−import { spacePath } from "../../../lib/folios";
13+import { canDo, spacePath } from "../../../lib/folios";
1114 import { page } from "../../../lib/meta";
1215 import { docs } from "../../../lib/services.server";
1316 import { requireUser, roleIn } from "../../../lib/session.server";
1922 export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ spaces: DocSpace[] | null }> {
2023 const viewer = requireUser(context, request);
2124 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
22− const found = await docs.sidebar(params.owner.toLowerCase(), viewer).catch(() => null);
23− return { spaces: found?.ok ? found.value.spaces.filter((s) => !s.archived_at).map(({ pages: _pages, ...space }) => space) : null };
25+ const found = await docs.spaces(params.owner.toLowerCase(), viewer).catch(() => null);
26+ return { spaces: found?.ok ? found.value : null };
2427 }
2528
2629 /**
2730 * Every space the viewer can open. Open spaces are joined to show in the
2831 * sidebar, so it doesn't fill up; team and members-only spaces show
29− * there already.
32+ * there already. Archived spaces are listed apart at the bottom, where
33+ * they are brought back or deleted.
3034 */
3135 export default function BrowseSpaces({ loaderData, params }: Route.ComponentProps) {
3236 const slug = params.owner.toLowerCase();
3337 const layout = useFoliosData();
3438 const { send, busy, error } = useFoliosAction(slug);
3539 const shown = new Set((layout?.sidebar?.spaces ?? []).map((s) => s.id));
40+ const owner = layout?.role === "owner";
41+ const [deleting, setDeleting] = useState<DocSpace | null>(null);
3642 const spaces = loaderData.spaces;
43+ const live = (spaces ?? []).filter((s) => !s.archived_at);
44+ const archived = (spaces ?? []).filter((s) => s.archived_at);
3745 return (
3846 <div className="mx-auto max-w-4xl">
3947 <div className="flex flex-wrap items-end justify-between gap-4">
4149 <h1 className="text-xl font-semibold tracking-tight">Spaces</h1>
4250 <p className="mt-1 text-sm text-muted">A space holds artifacts for a team, a project or a topic. Join an open space to see it in your sidebar.</p>
4351 </div>
44− <Link to={`/${slug}/-/artifacts/spaces/new`} className="inline-flex h-9 items-center gap-1.5 rounded-md border border-line px-3 text-sm text-fg/80 hover:border-line-strong hover:bg-surface hover:text-fg">
45− <Plus size={15} /> New space
46− </Link>
52+ <Button asChild variant="outline">
53+ <Link to={`/${slug}/-/artifacts/spaces/new`}>
54+ <Plus size={15} /> New space
55+ </Link>
56+ </Button>
4757 </div>
4858 {error && (
4959 <div className="mt-3">
5363 <div className="mt-6">
5464 {spaces === null ? (
5565 <EmptyState title="Artifacts didn't answer">Try again in a moment.</EmptyState>
56− ) : spaces.length === 0 ? (
57− <EmptyState title="No spaces yet">Make one for a team, a project or a topic.</EmptyState>
66+ ) : live.length === 0 ? (
67+ <EmptyState title="No spaces yet">Make one for a team, a project or a topic. Artifacts in no space live in their owners&apos; Private.</EmptyState>
5868 ) : (
5969 <Card asChild divided className="overflow-hidden">
6070 <ul>
61− {spaces.map((s) => {
71+ {live.map((s) => {
6272 const joined = shown.has(s.id);
6373 const joinable = s.kind === "workspace" && !s.is_default;
6474 return (
7686 </span>
7787 </span>
7888 {joinable ? (
79− <button
80− type="button"
81− disabled={busy}
82− onClick={() => send(joined ? "leave_space" : "join_space", { space_id: s.id })}
83− className={`inline-flex h-8 shrink-0 items-center gap-1.5 rounded-md px-3 text-xs font-medium disabled:opacity-60 ${joined ? "border border-line text-muted hover:border-danger/40 hover:text-danger" : "bg-accent text-bg hover:bg-accent-hover"}`}
84− >
89+ <Button type="button" size="sm" variant={joined ? "outline" : "accent"} disabled={busy} onClick={() => send(joined ? "leave_space" : "join_space", { space_id: s.id })} className="shrink-0">
8590 {joined ? "Leave" : "Join"}
86− </button>
91+ </Button>
8792 ) : (
8893 <span className="inline-flex shrink-0 items-center gap-1 text-xs text-faint">
8994 <Check size={13} /> In your sidebar
96101 </Card>
97102 )}
98103 </div>
104+ {archived.length > 0 && (
105+ <section className="mt-10">
106+ <h2 className="flex items-center gap-2 text-sm font-semibold">
107+ <Archive size={14} className="text-faint" /> Archived
108+ </h2>
109+ <p className="mt-1 mb-4 text-xs leading-relaxed text-faint">Out of the sidebar and search, with everything in them kept. Bring one back, or delete it: its artifacts go to the trash.</p>
110+ <Card asChild divided className="overflow-hidden">
111+ <ul>
112+ {archived.map((s) => {
113+ const manages = canDo(s.viewer_role, "manage") || owner;
114+ return (
115+ <li key={s.id} className="flex items-center gap-3 px-4 py-3">
116+ <span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-raised text-base opacity-70">
117+ <SpaceIcon space={s} size={16} />
118+ </span>
119+ <span className="min-w-0 grow">
120+ <span className="block truncate text-sm font-medium text-fg">{s.name}</span>
121+ <span className="block truncate text-xs text-faint">
122+ {spaceKindLabel(s.kind)} · archived {s.archived_at ? <TimeAgo at={s.archived_at} /> : null}
123+ </span>
124+ </span>
125+ {manages && (
126+ <>
127+ <Button type="button" size="sm" variant="outline" disabled={busy} onClick={() => send("update_space", { space_id: s.id, space_change: { archived: false } })} className="shrink-0 text-xs font-normal">
128+ <RotateCcw size={13} /> Restore
129+ </Button>
130+ <Button type="button" size="sm" variant="destructive" disabled={busy} onClick={() => setDeleting(s)} className="shrink-0 text-xs font-normal">
131+ <Trash2 size={13} /> <span className="max-sm:hidden">Delete</span>
132+ </Button>
133+ </>
134+ )}
135+ </li>
136+ );
137+ })}
138+ </ul>
139+ </Card>
140+ </section>
141+ )}
142+ <AlertDialog open={!!deleting} onOpenChange={(open) => !open && setDeleting(null)}>
143+ <AlertDialogContent>
144+ <AlertDialogHeader>
145+ <AlertDialogTitle>Delete {deleting?.name}?</AlertDialogTitle>
146+ <AlertDialogDescription>
147+ Everything in it goes to the trash, where anyone who could edit it can restore it for 30 days. The space itself, its members and settings are gone for good.
148+ {deleting?.is_default ? " The workspace will have no General space; none takes its place." : ""}
149+ </AlertDialogDescription>
150+ </AlertDialogHeader>
151+ <AlertDialogFooter>
152+ <AlertDialogCancel>Keep it</AlertDialogCancel>
153+ <AlertDialogAction
154+ onClick={() => {
155+ if (deleting) void send("delete_space", { space_id: deleting.id });
156+ setDeleting(null);
157+ }}
158+ >
159+ Delete space
160+ </AlertDialogAction>
161+ </AlertDialogFooter>
162+ </AlertDialogContent>
163+ </AlertDialog>
99164 </div>
100165 );
101166 }
+47−6
2626 return { items: found?.ok ? found.value : null };
2727 }
2828
29−/** Artifacts moved to the trash that the viewer can change: restore them, or delete them for good. After 30 days they go on their own. */
29+const count = (n: number) => `${n} ${n === 1 ? "artifact" : "artifacts"}`;
30+
31+/**
32+ * Artifacts moved to the trash that the viewer can change: restore them,
33+ * delete one for good, or empty the trash (everything they have full
34+ * access to goes). After 30 days they go on their own. Every change
35+ * reloads the sidebar's count with the page.
36+ */
3037 export default function FoliosTrash({ loaderData, params }: Route.ComponentProps) {
3138 const slug = params.owner.toLowerCase();
32− const { send, error } = useFoliosAction(slug);
39+ const { send, busy, error } = useFoliosAction(slug);
3340 const [forever, setForever] = useState<Folio | null>(null);
41+ const [emptying, setEmptying] = useState(false);
3442 const items = loaderData.items;
43+ const deletable = (items ?? []).filter((f) => canDo(f.viewer_role, "manage"));
44+ const kept = (items?.length ?? 0) - deletable.length;
3545 return (
3646 <div className="mx-auto max-w-3xl">
37− <h1 className="text-xl font-semibold tracking-tight">Trash</h1>
38− <p className="mt-1 text-sm text-muted">Artifacts come back with what was inside them. After 30 days in the trash they are deleted for good.</p>
47+ <div className="flex flex-wrap items-end justify-between gap-4">
48+ <div>
49+ <h1 className="text-xl font-semibold tracking-tight">Trash</h1>
50+ <p className="mt-1 text-sm text-muted">Artifacts come back with what was inside them. After 30 days in the trash they are deleted for good.</p>
51+ </div>
52+ {deletable.length > 0 && (
53+ <Button type="button" variant="destructive" disabled={busy} onClick={() => setEmptying(true)}>
54+ <Trash2 size={14} /> Empty trash
55+ </Button>
56+ )}
57+ </div>
3958 {error && (
4059 <div className="mt-3">
4160 <ErrorText>{error}</ErrorText>
5877 {f.space?.name ?? "Private"} · trashed {f.trashed_at ? <TimeAgo at={f.trashed_at} /> : null}
5978 </span>
6079 </span>
61− <Button type="button" onClick={() => send("restore", { folio_id: f.id })} variant="outline" size="sm" className="text-xs text-fg hover:bg-raised font-normal">
80+ <Button type="button" disabled={busy} onClick={() => send("restore", { folio_id: f.id })} variant="outline" size="sm" className="text-xs text-fg hover:bg-raised font-normal">
6281 <RotateCcw size={13} /> Restore
6382 </Button>
6483 {canDo(f.viewer_role, "manage") && (
65− <Button type="button" onClick={() => setForever(f)} variant="destructive" size="sm" className="text-xs font-normal">
84+ <Button type="button" disabled={busy} onClick={() => setForever(f)} variant="destructive" size="sm" className="text-xs font-normal">
6685 <Trash2 size={13} /> <span className="max-sm:hidden">Delete</span>
6786 </Button>
6887 )}
91110 </AlertDialogFooter>
92111 </AlertDialogContent>
93112 </AlertDialog>
113+ <AlertDialog open={emptying} onOpenChange={setEmptying}>
114+ <AlertDialogContent>
115+ <AlertDialogHeader>
116+ <AlertDialogTitle>Empty the trash?</AlertDialogTitle>
117+ <AlertDialogDescription>
118+ {count(deletable.length)} you have full access to {deletable.length === 1 ? "goes" : "go"} for good, with {deletable.length === 1 ? "its" : "their"} history, comments and everything inside.
119+ {kept > 0 ? ` The ${count(kept)} you can only edit ${kept === 1 ? "stays" : "stay"}.` : ""} This can&apos;t be undone.
120+ </AlertDialogDescription>
121+ </AlertDialogHeader>
122+ <AlertDialogFooter>
123+ <AlertDialogCancel>Keep everything</AlertDialogCancel>
124+ <AlertDialogAction
125+ onClick={() => {
126+ void send("empty_trash");
127+ setEmptying(false);
128+ }}
129+ >
130+ Delete {count(deletable.length)} for good
131+ </AlertDialogAction>
132+ </AlertDialogFooter>
133+ </AlertDialogContent>
134+ </AlertDialog>
94135 </div>
95136 );
96137 }
+4−2
499499
500500 /// Every method the artifacts service answers for folios, as `FOLIO_RPC_METHODS`
501501 /// in folios.ts.
502−pub const FOLIO_RPC_METHODS: [&str; 48] = [
502+pub const FOLIO_RPC_METHODS: [&str; 50] = [
503503 "folio_list",
504504 "folio_sidebar",
505505 "folio",
512512 "restore_folio",
513513 "delete_folio",
514514 "folio_trash",
515+ "empty_folio_trash",
515516 "favorite_folio",
516517 "folio_content",
517518 "edit_folio",
521522 "request_folio_access",
522523 "join_space",
523524 "leave_space",
525+ "delete_space",
524526 "search_folios",
525527 "folio_versions",
526528 "folio_version",
669671 /// The `folio.*` types the artifacts service publishes, with no `repoId` on
670672 /// the event: a folio may be private, so it never reaches a repository's
671673 /// timeline or webhooks. Nothing subscribes to them yet.
672−pub const FOLIO_EVENTS: [&str; 6] = ["folio.created", "folio.updated", "folio.trashed", "folio.restored", "folio.shared", "folio.stale"];
674+pub const FOLIO_EVENTS: [&str; 7] = ["folio.created", "folio.updated", "folio.trashed", "folio.restored", "folio.deleted", "folio.shared", "folio.stale"];
673675
674676 /// What every `folio.*` event carries (`FolioEventData` in events.ts).
675677 /// Event data is camelCase, as every event on the bus.
+3−1
237237 use super::*;
238238
239239 /// Types published on the bus that hooks are not offered.
240− const UNOFFERED: [&str; 28] = [
240+ const UNOFFERED: [&str; 30] = [
241241 "pull.mergecheck",
242242 "pull.mergeability",
243243 "deployment.review_requested",
261261 "doc.page.updated",
262262 "doc.page.archived",
263263 "doc.page.stale",
264+ "doc.space.deleted",
264265 // Artifacts (folios): no repository either, and a folio may be private.
265266 "folio.created",
266267 "folio.updated",
267268 "folio.trashed",
268269 "folio.restored",
270+ "folio.deleted",
269271 "folio.shared",
270272 "folio.stale",
271273 ];
+10−2
8585 agent_mode: DocAgentMode;
8686 /** People with edit access may share what is in it (Artifacts), as people with full access can. Off unless turned on. */
8787 editors_can_share: boolean;
88− /** The workspace's General space, made the first time Docs is opened. Can't be archived. */
88+ /**
89+ * The workspace's General space, made once when Artifacts is first
90+ * opened and shown to every member without joining. It can be archived
91+ * and deleted like any other; once deleted it is not made again, and no
92+ * other space takes its place.
93+ */
8994 is_default: boolean;
9095 /** Projects (repositories, `owner/name`) the space is about: Docs filters by them. */
9196 projects: string[];
589594 sidebar(workspace: string, viewer: User): Promise<Result<DocsSidebar>>;
590595 home(workspace: string, viewer: User, options?: { project?: string | null }): Promise<Result<DocsHome>>;
591596 space(workspace: string, spaceSlug: string, viewer: User): Promise<Result<{ space: DocSpace; members: DocSpaceMember[]; pages: DocPage[] }>>;
597+ /** Every space the viewer can open, archived ones included (never deleted ones), General first. */
598+ spaces(workspace: string, viewer: User): Promise<Result<DocSpace[]>>;
592599 createSpace(workspace: string, viewer: User, input: NewDocSpace): Promise<Result<DocSpace>>;
593− /** Manage role only. */
600+ /** Manage role only. `archived` true or false archives a space or brings it back, the General space too. */
594601 updateSpace(workspace: string, spaceId: string, viewer: User, change: DocSpaceChange): Promise<Result<DocSpace>>;
595602 /** Adds, changes (`role`) or removes (`role` null) a member. Manage role only; the last manager stays. */
596603 setSpaceMember(workspace: string, spaceId: string, viewer: User, member: DocMemberKey, role: DocRole | null): Promise<Result<DocSpaceMember[]>>;
750757 sidebar: (workspace, viewer) => call("sidebar", { workspace, viewer }),
751758 home: (workspace, viewer, options) => call("home", { workspace, viewer, project: options?.project ?? null }),
752759 space: (workspace, spaceSlug, viewer) => call("space", { workspace, space: spaceSlug, viewer }),
760+ spaces: (workspace, viewer) => call("spaces", { workspace, viewer }),
753761 createSpace: (workspace, viewer, input) => call("create_space", { workspace, viewer, input }),
754762 updateSpace: (workspace, spaceId, viewer, change) => call("update_space", { workspace, space_id: spaceId, viewer, change }),
755763 setSpaceMember: (workspace, spaceId, viewer, member, role) => call("set_space_member", { workspace, space_id: spaceId, viewer, member, role }),
+13−0
540540 /** A folio went to the trash (with everything under it; one event for the folio asked about). */
541541 "folio.trashed": FolioEventData;
542542 "folio.restored": FolioEventData;
543+ /**
544+ * A folio was deleted for good from the trash (with everything under it;
545+ * one event for each top that went): by someone with full access, by
546+ * emptying the trash, or by the daily purge (`actor` null then).
547+ */
548+ "folio.deleted": FolioEventData;
543549 /** Someone was given access: who (member keys) and the role. Never content. */
544550 "folio.shared": FolioEventData & { principals: string[]; role: "view" | "comment" | "edit" | "manage" };
545551 /** Code a folio cites changed. The repository is in `data` as `owner/name` only. */
546552 "folio.stale": FolioEventData & { repo: string; commit: string; pull: number | null; paths: string[]; owners: string[] };
553+ /**
554+ * A space was deleted (services/artifacts): everything in it went to the
555+ * trash (`trashed` counts the artifacts; this one event stands for them
556+ * all). Like `folio.*`, published with no `repoId` and never offered to
557+ * webhooks; `name` is null for a members-only space.
558+ */
559+ "doc.space.deleted": { workspace: string; workspaceId: string; spaceId: string; name: string | null; kind: "workspace" | "team" | "private"; trashed: number };
547560 };
548561
549562 /**
+20−0
630630 "restore_folio",
631631 "delete_folio",
632632 "folio_trash",
633+ "empty_folio_trash",
633634 "favorite_folio",
634635 // A person's (or a token's) own read and edit in the agent form.
635636 "folio_content",
641642 "request_folio_access",
642643 "join_space",
643644 "leave_space",
645+ "delete_space",
644646 // Search, history, templates and export.
645647 "search_folios",
646648 "folio_versions",
699701 restore(workspace: string, viewer: User, folioId: string): Promise<Result<Folio>>;
700702 /** For good: only from the trash, manage role. */
701703 delete(workspace: string, viewer: User, folioId: string): Promise<Result<boolean>>;
704+ /** What is in the trash that the viewer may restore: the tops of what went there together. */
702705 trashed(workspace: string, viewer: User): Promise<Result<Folio[]>>;
706+ /**
707+ * Deletes for good everything in the trash that the viewer has manage
708+ * role on (what `trashed` lists for them with full access), with
709+ * everything under it. What they may only edit stays. `deleted` counts
710+ * the tops that went.
711+ */
712+ emptyTrash(workspace: string, viewer: User): Promise<Result<{ deleted: number }>>;
703713 favorite(workspace: string, viewer: User, folioId: string, on: boolean): Promise<Result<boolean>>;
704714 /** The folio in its agent form, for a person or their token. */
705715 content(workspace: string, viewer: User, folioId: string): Promise<Result<FolioAgentRead>>;
713723 requestAccess(workspace: string, viewer: User, folioId: string, message?: string | null): Promise<Result<boolean>>;
714724 joinSpace(workspace: string, viewer: User, spaceId: string): Promise<Result<boolean>>;
715725 leaveSpace(workspace: string, viewer: User, spaceId: string): Promise<Result<boolean>>;
726+ /**
727+ * Deletes a space, the General space included: everything in it goes to
728+ * the trash (`trashed` counts the artifacts), where whoever could edit
729+ * it restores it as usual, and the space itself is gone for good. Full
730+ * access to the space, or a workspace owner. A workspace may end up with
731+ * no spaces; General is never made again once deleted.
732+ */
733+ deleteSpace(workspace: string, viewer: User, spaceId: string): Promise<Result<{ trashed: number }>>;
716734
717735 search(workspace: string, viewer: User, query: { q: string; kinds?: FolioKind[] | null; space_id?: string | null; project?: string | null; owner?: string | null; mode?: "words" | "hybrid" | null; limit?: number | null }): Promise<Result<FolioSearchHit[]>>;
718736 versions(workspace: string, viewer: User, folioId: string): Promise<Result<FolioVersion[]>>;
802820 restore: (workspace, viewer, folioId) => call("restore_folio", { workspace, viewer, folio_id: folioId }),
803821 delete: (workspace, viewer, folioId) => call("delete_folio", { workspace, viewer, folio_id: folioId }),
804822 trashed: (workspace, viewer) => call("folio_trash", { workspace, viewer }),
823+ emptyTrash: (workspace, viewer) => call("empty_folio_trash", { workspace, viewer }),
805824 favorite: (workspace, viewer, folioId, on) => call("favorite_folio", { workspace, viewer, folio_id: folioId, on }),
806825 content: (workspace, viewer, folioId) => call("folio_content", { workspace, viewer, folio_id: folioId }),
807826 edit: (workspace, viewer, folioId, edit) => call("edit_folio", { workspace, viewer, folio_id: folioId, edit }),
811830 requestAccess: (workspace, viewer, folioId, message) => call("request_folio_access", { workspace, viewer, folio_id: folioId, message: message ?? null }),
812831 joinSpace: (workspace, viewer, spaceId) => call("join_space", { workspace, viewer, space_id: spaceId }),
813832 leaveSpace: (workspace, viewer, spaceId) => call("leave_space", { workspace, viewer, space_id: spaceId }),
833+ deleteSpace: (workspace, viewer, spaceId) => call("delete_space", { workspace, viewer, space_id: spaceId }),
814834 search: (workspace, viewer, query) => call("search_folios", { workspace, viewer, query }),
815835 versions: (workspace, viewer, folioId) => call("folio_versions", { workspace, viewer, folio_id: folioId }),
816836 version: (workspace, viewer, folioId, versionId) => call("folio_version", { workspace, viewer, folio_id: folioId, version_id: versionId }),
+9−0
1+-- A space can be deleted, the General space too (a workspace may have no
2+-- spaces). Its row stays, marked: the trash's rows that point at it keep
3+-- their space's name and rules, so whoever could edit them still restores
4+-- them for the usual 30 days; and General, once deleted, is never made
5+-- again (`ensureDefault` finds the row). Deleting frees the address for a
6+-- new space by renaming the row's slug to `<slug>~<id>`, which no address
7+-- can be. Listings leave deleted spaces out (src/who.ts, src/index.ts).
8+ALTER TABLE spaces ADD COLUMN deleted_at TEXT;
9+ALTER TABLE spaces ADD COLUMN deleted_by TEXT;
+11−1
1010
1111 import { actorOf } from "../events.ts";
1212
13−export type FolioEventType = "folio.created" | "folio.updated" | "folio.trashed" | "folio.restored" | "folio.shared" | "folio.stale";
13+export type FolioEventType = "folio.created" | "folio.updated" | "folio.trashed" | "folio.restored" | "folio.deleted" | "folio.shared" | "folio.stale";
1414
1515 export async function publishFolioEvent<T extends FolioEventType>(events: ServiceBinding | undefined, type: T, data: EventPayloads[T] & FolioEventData, actor: string | null): Promise<void> {
1616 if (!events) return;
2020 console.error("folios could not publish", type, String(error));
2121 }
2222 }
23+
24+/** A space was deleted: the one event that stands for everything in it going to the trash. Never throws. */
25+export async function publishSpaceDeleted(events: ServiceBinding | undefined, data: EventPayloads["doc.space.deleted"], actor: string | null): Promise<void> {
26+ if (!events) return;
27+ try {
28+ await eventsClient(events).publish([{ type: "doc.space.deleted", source: "docs", repoId: null, actor: actorOf(actor), data } as never]);
29+ } catch (error) {
30+ console.error("folios could not publish doc.space.deleted", String(error));
31+ }
32+}
+2−0
2525 restore_folio: (s, a) => s.restore(a),
2626 delete_folio: (s, a) => s.delete(a),
2727 folio_trash: (s, a) => s.trashed(a),
28+ empty_folio_trash: (s, a) => s.emptyTrash(a),
2829 favorite_folio: (s, a) => s.favorite(a),
2930 folio_content: (s, a) => s.content(a),
3031 edit_folio: (s, a) => s.edit(a),
3435 request_folio_access: (s, a) => s.requestAccess(a),
3536 join_space: (s, a) => s.joinSpace(a),
3637 leave_space: (s, a) => s.leaveSpace(a),
38+ delete_space: (s, a) => s.deleteSpace(a),
3739 search_folios: (s, a) => s.search(a),
3840 folio_versions: (s, a) => s.versions(a),
3941 folio_version: (s, a) => s.version(a),
+88−27
109109 type ReaderContext,
110110 } from "./access-store.ts";
111111 import { agentMayFind, agentReach, audienceRule, type AgentReach, type AudienceRule } from "./agents.ts";
112−import { publishFolioEvent } from "./events.ts";
112+import { publishFolioEvent, publishSpaceDeleted } from "./events.ts";
113113 import { MAX_DEPTH, MAX_PAGE_STATE, cleanCover, cleanIcon, cleanNote, cleanSource, cleanTarget, cleanTitle, decodeCursor, depthOf, encodeCursor, listLimit, pageState, sharedTops, slugOf, subtreeHeight, treeNodes } from "./list.ts";
114114 import { REQUEST_RECIPIENTS, claimAccessRequest } from "./requests.ts";
115115 import type { FolioRoom } from "./room.ts";
116116 import { builtinFolioTemplate, builtinFolioTemplates } from "./templates.ts";
117+import { deleteSpaceRows, eraseFolios, landingFor } from "./trash.ts";
117118
118119 export type FoliosEnv = WhoEnv & {
119120 FOLIOS: DurableObjectNamespace<FolioRoom>;
453454 if (!found.ok) return found;
454455 const ctx = found.value;
455456 const joins = new Set(joined.results.map((r) => r.space_id));
456− // Joined open spaces (General always), team spaces of theirs, Members-only spaces they're in.
457− const shown = ctx.spaces.filter((s) => s.role && !s.row.archived_at && (s.row.kind !== "workspace" || s.row.is_default || joins.has(s.row.id)));
457+ // Joined open spaces (General always), team spaces of theirs, Members-only spaces they're in; never archived or deleted ones.
458+ const shown = ctx.spaces.filter((s) => s.role && !s.row.archived_at && !s.row.deleted_at && (s.row.kind !== "workspace" || s.row.is_default || joins.has(s.row.id)));
458459 const keys = personKeys(ctx.person);
459460 const [spaceRows, privateRows, sharedRows, favoriteRows, repos, trashed] = await Promise.all([
460461 shown.length
633634 }
634635 if (input.space_id) {
635636 const space = ctx.spaceById.get(input.space_id);
636− if (!space?.role || space.row.archived_at) return fail("not_found", "No such space.");
637+ if (!space?.role || space.row.archived_at || space.row.deleted_at) return fail("not_found", "No such space.");
637638 if (!atLeast(space.role, "edit")) return fail("forbidden", `You can read ${space.row.name} but not add to it.`);
638639 return ok({ space_id: space.row.id, parent: null });
639640 }
866867 spaceId = parent.space_id;
867868 } else if (move.space_id) {
868869 const space = ctx.spaceById.get(move.space_id);
869− if (!space?.role || space.row.archived_at) return fail("not_found", "No such space.");
870+ if (!space?.role || space.row.archived_at || space.row.deleted_at) return fail("not_found", "No such space.");
870871 if (!atLeast(space.role, "edit")) return fail("forbidden", `You can't add to ${space.row.name}.`);
871872 spaceId = space.row.id;
872873 } else {
10321033 // Its parent is gone or still in the trash: it comes back at the top of where it was.
10331034 const detach = !!row.parent_id && (!parent || !!parent.trashed_at);
10341035 if (detach) statements.push(this.db.prepare("UPDATE folios SET parent_id = NULL WHERE id = ?").bind(row.id));
1036+ // Its space was deleted: a top comes back at the end of General (when
1037+ // there is one the restorer may add to), else of its owner's Private; a
1038+ // child whose parent lives follows its parent's place (rebuilt below).
1039+ const gone = !!row.space_id && !!ctx.spaceById.get(row.space_id)?.row.deleted_at;
1040+ const relocate = gone && (detach || !row.parent_id);
1041+ let spaceId = row.space_id;
1042+ if (relocate) {
1043+ spaceId = landingFor(ctx.spaces)?.row.id ?? null;
1044+ const last = spaceId
1045+ ? await this.db.prepare("SELECT MAX(position) AS p FROM folios WHERE space_id = ? AND parent_id IS NULL AND trashed_at IS NULL").bind(spaceId).first<{ p: number | null }>()
1046+ : await this.db.prepare("SELECT MAX(position) AS p FROM folios WHERE workspace_id = ? AND space_id IS NULL AND parent_id IS NULL AND owner = ? AND trashed_at IS NULL").bind(ctx.workspace.id, row.owner).first<{ p: number | null }>();
1047+ statements.push(this.db.prepare("UPDATE folios SET space_id = ?, parent_id = NULL, position = ?, updated_at = ?, updated_by = ? WHERE id = ?").bind(spaceId, (last?.p ?? 0) + 1, now(), ctx.key, row.id));
1048+ }
10351049 await runBatches(this.db, statements);
1036− if (detach) await this.afterAccessChange(ctx, row.id, below.length);
1050+ if (detach || gone) await this.afterAccessChange(ctx, row.id, below.length);
10371051 else this.defer((async () => { for (const id of ids.slice(0, 2000)) await indexFolio(this.env, id); })());
10381052 const open = await workspaceReadable(this.db, row.id).catch(() => false);
1039− this.defer(publishFolioEvent(this.env.EVENTS, "folio.restored", { workspace: ctx.workspace.slug, workspaceId: ctx.workspace.id, folioId: row.id, kind: row.kind, spaceId: row.space_id, title: open ? row.title : null }, ctx.key));
1053+ this.defer(publishFolioEvent(this.env.EVENTS, "folio.restored", { workspace: ctx.workspace.slug, workspaceId: ctx.workspace.id, folioId: row.id, kind: row.kind, spaceId, title: open ? row.title : null }, ctx.key));
10401054 return ok(await this.folioOf(ctx, row));
10411055 }
10421056
10481062 if (!opened.ok) return opened;
10491063 const { row } = opened.value;
10501064 if (!row.trashed_at) return fail("invalid", "Move it to the trash first.");
1051− // Deepest first, so no parent goes before its children.
1052− const ids = (await subtree(this.db, row)).sort((x, y) => y.path.length - x.path.length).map((r) => r.id);
1053− await forgetFolios(this.env, ids);
1054− await runBatches(
1055− this.db,
1056− ids.flatMap((id) => [this.db.prepare("DELETE FROM folios_fts WHERE folio_id = ?").bind(id), this.db.prepare("DELETE FROM folios WHERE id = ?").bind(id)]),
1057− );
1065+ const open = await workspaceReadable(this.db, row.id).catch(() => false);
1066+ const ids = await eraseFolios(this.env, await subtree(this.db, row));
10581067 for (const id of ids.slice(0, INLINE_ROOMS)) this.defer(this.room(id).destroy().catch(() => undefined));
1068+ this.defer(publishFolioEvent(this.env.EVENTS, "folio.deleted", { workspace: ctx.workspace.slug, workspaceId: ctx.workspace.id, folioId: row.id, kind: row.kind, spaceId: row.space_id, title: open ? row.title : null }, ctx.key));
10591069 return ok(true);
10601070 }
10611071
1062− /** Trashed folios the viewer may restore: the tops of what went to the trash together. */
1063− private async trashedFor(ctx: Ctx, limit: number): Promise<FolioRow[]> {
1072+ /**
1073+ * Empties the viewer's trash: everything `trashed` lists for them that
1074+ * they have full access to goes for good, with everything under it, as
1075+ * `delete` does one at a time. What they may only edit stays. Tells one
1076+ * `folio.deleted` per top, with no title (many folios, each a chain to
1077+ * check). Answers how many tops went.
1078+ */
1079+ async emptyTrash(a: Args): Promise<Result<{ deleted: number }>> {
1080+ const found = await this.ctx(a.workspace, a.viewer);
1081+ if (!found.ok) return found;
1082+ const ctx = found.value;
1083+ let deleted = 0;
1084+ for (;;) {
1085+ const tops = await this.trashedFor(ctx, 500, "manage");
1086+ if (!tops.length) break;
1087+ const rows: FolioRow[] = [];
1088+ for (const top of tops) rows.push(...(await subtree(this.db, top)));
1089+ const ids = await eraseFolios(this.env, rows);
1090+ for (const id of ids.slice(0, INLINE_ROOMS)) this.defer(this.room(id).destroy().catch(() => undefined));
1091+ for (const top of tops) {
1092+ this.defer(publishFolioEvent(this.env.EVENTS, "folio.deleted", { workspace: ctx.workspace.slug, workspaceId: ctx.workspace.id, folioId: top.id, kind: top.kind, spaceId: top.space_id, title: null }, ctx.key));
1093+ }
1094+ deleted += tops.length;
1095+ if (tops.length < 500) break;
1096+ }
1097+ return ok({ deleted });
1098+ }
1099+
1100+ /** Trashed folios the viewer has `need` role on (edit: may restore; manage: may delete): the tops of what went to the trash together. */
1101+ private async trashedFor(ctx: Ctx, limit: number, need: DocRole = "edit"): Promise<FolioRow[]> {
10641102 const filter = this.filterOf(ctx);
10651103 const rows = (
10661104 await this.db
10701108 ).results;
10711109 const { roles } = await this.roles(ctx, rows);
10721110 const byId = new Map(rows.map((r) => [r.id, r]));
1073− return rows.filter((r) => atLeast(roles.get(r.id), "edit") && !(r.parent_id && byId.get(r.parent_id)?.trashed_at === r.trashed_at)).slice(0, limit);
1111+ return rows.filter((r) => atLeast(roles.get(r.id), need) && !(r.parent_id && byId.get(r.parent_id)?.trashed_at === r.trashed_at)).slice(0, limit);
10741112 }
10751113
10761114 async trashed(a: Args): Promise<Result<Folio[]>> {
10791117 return ok(await this.toFolios(found.value, await this.trashedFor(found.value, 200)));
10801118 }
10811119
1120+ /**
1121+ * Deletes a space, the General space too: everything in it goes to the
1122+ * trash together (whoever could edit an artifact restores it as usual;
1123+ * it then lands in General, or in its owner's Private), and the space is
1124+ * gone for good, its row kept marked (SpaceRow.deleted_at), so General
1125+ * is never made again and a workspace may have no spaces. Full access to
1126+ * the space, or a workspace owner who can open it.
1127+ */
1128+ async deleteSpace(a: Args & { space_id: string }): Promise<Result<{ trashed: number }>> {
1129+ const found = await this.ctx(a.workspace, a.viewer);
1130+ if (!found.ok) return found;
1131+ const ctx = found.value;
1132+ const space = ctx.spaceById.get(String(a.space_id ?? ""));
1133+ if (!space?.role || space.row.deleted_at) return fail("not_found", "No such space.");
1134+ if (!atLeast(space.role, "manage") && !ctx.owner) return fail("forbidden", "Only people with full access to a space, or a workspace owner, can delete it.");
1135+ const ids = await deleteSpaceRows(this.db, space.row, now(), ctx.key);
1136+ for (const id of ids.slice(0, INLINE_ROOMS)) this.defer(this.room(id).closeAll("Moved to the trash").catch(() => undefined));
1137+ this.defer(forgetFolios(this.env, ids));
1138+ this.who.forgetSpaces();
1139+ this.defer(
1140+ publishSpaceDeleted(
1141+ this.env.EVENTS,
1142+ { workspace: ctx.workspace.slug, workspaceId: ctx.workspace.id, spaceId: space.row.id, name: space.row.kind === "private" ? null : space.row.name, kind: space.row.kind, trashed: ids.length },
1143+ ctx.key,
1144+ ),
1145+ );
1146+ return ok({ trashed: ids.length });
1147+ }
1148+
10821149 async favorite(a: Args & { folio_id: string; on: boolean }): Promise<Result<boolean>> {
10831150 const found = await this.ctx(a.workspace, a.viewer);
10841151 if (!found.ok) return found;
13741441 if (!found.ok) return found;
13751442 const ctx = found.value;
13761443 const space = ctx.spaceById.get(String(a.space_id ?? ""));
1377− if (!space?.role || space.row.archived_at) return fail("not_found", "No such space.");
1444+ if (!space?.role || space.row.archived_at || space.row.deleted_at) return fail("not_found", "No such space.");
13781445 if (space.row.kind !== "workspace") return fail("invalid", "Only open spaces are joined; you're in team and members-only spaces already.");
13791446 await this.db
13801447 .prepare("INSERT OR IGNORE INTO space_joins (space_id, user_id, position, joined_at) VALUES (?, ?, (SELECT COALESCE(MAX(position), 0) + 1024 FROM space_joins WHERE user_id = ?), ?)")
25592626
25602627 /**
25612628 * The daily cron: folios in the trash for over TRASH_DAYS are deleted for
2562− * good, deepest first, at most 500 a run (the rest go the next day).
2629+ * good (`eraseFolios`, as emptying the trash does), at most 500 a run
2630+ * (the rest go the next day).
25632631 */
25642632 export async function purgeTrash(env: FoliosEnv, at = new Date()): Promise<number> {
25652633 const cutoff = new Date(at.getTime() - TRASH_DAYS * 24 * 60 * 60 * 1000).toISOString();
25662634 const rows = (await env.DB.prepare("SELECT id, path FROM folios WHERE trashed_at IS NOT NULL AND trashed_at < ? ORDER BY length(path) DESC LIMIT 500").bind(cutoff).all<{ id: string; path: string }>()).results;
25672635 if (!rows.length) return 0;
2568− // Children still alive under one being purged go to the top of where they were.
2569− const ids = rows.map((r) => r.id);
2570− await env.DB.prepare("UPDATE folios SET parent_id = NULL WHERE parent_id IN (SELECT value FROM json_each(?)) AND id NOT IN (SELECT value FROM json_each(?))").bind(json(ids), json(ids)).run();
2571− await forgetFolios(env, ids);
2572− await runBatches(
2573− env.DB,
2574− ids.flatMap((id) => [env.DB.prepare("DELETE FROM folios_fts WHERE folio_id = ?").bind(id), env.DB.prepare("DELETE FROM folios WHERE id = ?").bind(id)]),
2575− );
2636+ const ids = await eraseFolios(env, rows);
25762637 if (env.FOLIOS) for (const id of ids) await env.FOLIOS.get(env.FOLIOS.idFromName(id)).destroy().catch(() => undefined);
25772638 return ids.length;
25782639 }
+171−0
1+import assert from "node:assert/strict";
2+import { readFileSync, readdirSync } from "node:fs";
3+import { DatabaseSync } from "node:sqlite";
4+import { test } from "node:test";
5+
6+import type { Space } from "../who.ts";
7+import { deleteSpaceRows, eraseFolios, landingFor } from "./trash.ts";
8+
9+/** D1, as far as the trash uses it, over node's SQLite with the service's migrations. */
10+function fakeD1(): D1Database & { raw: DatabaseSync } {
11+ const db = new DatabaseSync(":memory:");
12+ const dir = new URL("../../migrations/", import.meta.url);
13+ for (const file of readdirSync(dir).sort()) db.exec(readFileSync(new URL(file, dir), "utf8"));
14+ db.exec("PRAGMA foreign_keys = OFF");
15+ type Statement = { sql: string; params: unknown[] };
16+ const statement = (sql: string, params: unknown[] = []): Statement & Record<string, unknown> => ({
17+ sql,
18+ params,
19+ bind: (...values: unknown[]) => statement(sql, values),
20+ first: async () => (db.prepare(sql).get(...(params as never[])) as unknown) ?? null,
21+ run: async () => db.prepare(sql).run(...(params as never[])),
22+ all: async () => ({ results: db.prepare(sql).all(...(params as never[])) }),
23+ });
24+ return {
25+ raw: db,
26+ prepare: (sql: string) => statement(sql),
27+ batch: async (statements: Statement[]) => statements.map((s) => db.prepare(s.sql).run(...(s.params as never[]))),
28+ } as unknown as D1Database & { raw: DatabaseSync };
29+}
30+
31+const AT = "2026-10-11T12:00:00.000Z";
32+
33+function space(db: DatabaseSync, id: string, over: Partial<{ slug: string; is_default: number; archived_at: string | null; deleted_at: string | null }> = {}) {
34+ db.prepare("INSERT INTO spaces (id, workspace_id, slug, name, kind, default_role, is_default, created_by, created_at, archived_at, deleted_at) VALUES (?, 'wsp_1', ?, ?, 'workspace', 'edit', ?, 'user:ana', ?, ?, ?)").run(
35+ id,
36+ over.slug ?? id,
37+ id,
38+ over.is_default ?? 0,
39+ AT,
40+ over.archived_at ?? null,
41+ over.deleted_at ?? null,
42+ );
43+}
44+
45+function folio(db: DatabaseSync, id: string, over: Partial<{ space_id: string | null; parent_id: string | null; path: string; trashed_at: string | null }> = {}) {
46+ const path = over.path ?? `/${id}/`;
47+ db.prepare(
48+ "INSERT INTO folios (id, workspace_id, kind, title, owner, space_id, parent_id, position, acl_root, path, created_by, created_at, updated_at, edited_at, trashed_at) VALUES (?, 'wsp_1', 'doc', ?, 'user:ana', ?, ?, 1, ?, ?, 'user:ana', ?, ?, ?, ?)",
49+ ).run(id, id, over.space_id ?? null, over.parent_id ?? null, path.split("/").filter(Boolean)[0]!, path, AT, AT, AT, over.trashed_at ?? null);
50+ db.prepare("INSERT INTO folios_fts (folio_id, kind, title, body) VALUES (?, 'doc', ?, '')").run(id, id);
51+}
52+
53+const ids = (db: DatabaseSync, sql: string) => (db.prepare(sql).all() as { id: string }[]).map((r) => r.id).sort();
54+
55+test("erasing folios takes their rows and search rows, deepest first, and leaves the rest", async () => {
56+ const d1 = fakeD1();
57+ const db = d1.raw;
58+ folio(db, "top", { trashed_at: AT });
59+ folio(db, "kid", { parent_id: "top", path: "/top/kid/", trashed_at: AT });
60+ folio(db, "grandkid", { parent_id: "kid", path: "/top/kid/grandkid/", trashed_at: AT });
61+ folio(db, "other");
62+ const gone = await eraseFolios({ DB: d1 }, [
63+ { id: "top", path: "/top/" },
64+ { id: "kid", path: "/top/kid/" },
65+ { id: "grandkid", path: "/top/kid/grandkid/" },
66+ // Asked twice: goes once.
67+ { id: "kid", path: "/top/kid/" },
68+ ]);
69+ assert.deepEqual(gone, ["grandkid", "kid", "top"]);
70+ assert.deepEqual(ids(db, "SELECT id FROM folios"), ["other"]);
71+ assert.deepEqual(ids(db, "SELECT folio_id AS id FROM folios_fts"), ["other"]);
72+});
73+
74+test("a child still alive under an erased folio goes to the top of where it was", async () => {
75+ const d1 = fakeD1();
76+ const db = d1.raw;
77+ folio(db, "top", { trashed_at: AT });
78+ folio(db, "alive", { parent_id: "top", path: "/top/alive/" });
79+ await eraseFolios({ DB: d1 }, [{ id: "top", path: "/top/" }]);
80+ const alive = db.prepare("SELECT parent_id FROM folios WHERE id = 'alive'").get() as { parent_id: string | null };
81+ assert.equal(alive.parent_id, null);
82+});
83+
84+test("erasing nothing does nothing", async () => {
85+ const d1 = fakeD1();
86+ folio(d1.raw, "a");
87+ assert.deepEqual(await eraseFolios({ DB: d1 }, []), []);
88+ assert.deepEqual(ids(d1.raw, "SELECT id FROM folios"), ["a"]);
89+});
90+
91+test("deleting a space trashes what is in it together, frees its address and keeps its row marked", async () => {
92+ const d1 = fakeD1();
93+ const db = d1.raw;
94+ space(db, "spc_eng", { slug: "engineering" });
95+ space(db, "spc_other", { slug: "other" });
96+ folio(db, "a", { space_id: "spc_eng" });
97+ folio(db, "b", { space_id: "spc_eng", parent_id: "a", path: "/a/b/" });
98+ folio(db, "old", { space_id: "spc_eng", trashed_at: "2026-10-01T00:00:00.000Z" });
99+ folio(db, "elsewhere", { space_id: "spc_other" });
100+ folio(db, "private");
101+ db.prepare("INSERT INTO space_joins (space_id, user_id, position, joined_at) VALUES ('spc_eng', 'bo', 1, ?), ('spc_other', 'bo', 2, ?)").run(AT, AT);
102+ const trashed = await deleteSpaceRows(d1, { id: "spc_eng", slug: "engineering" }, AT, "user:ana");
103+ assert.deepEqual(trashed.sort(), ["a", "b"]);
104+ // Together: one trashed_at, so each top restores the whole of what went with it.
105+ assert.deepEqual(ids(db, `SELECT id FROM folios WHERE trashed_at = '${AT}' AND trashed_by = 'user:ana'`), ["a", "b"]);
106+ // What was already in the trash keeps its own time; other spaces and Private are untouched.
107+ assert.equal((db.prepare("SELECT trashed_at FROM folios WHERE id = 'old'").get() as { trashed_at: string }).trashed_at, "2026-10-01T00:00:00.000Z");
108+ assert.deepEqual(ids(db, "SELECT id FROM folios WHERE trashed_at IS NULL"), ["elsewhere", "private"]);
109+ // The row stays, marked, its address freed; the other space's joins stay.
110+ const row = db.prepare("SELECT slug, deleted_at, deleted_by FROM spaces WHERE id = 'spc_eng'").get() as { slug: string; deleted_at: string; deleted_by: string };
111+ assert.deepEqual({ ...row }, { slug: "engineering~spc_eng", deleted_at: AT, deleted_by: "user:ana" });
112+ assert.deepEqual(ids(db, "SELECT space_id AS id FROM space_joins"), ["spc_other"]);
113+ // A new space takes the address.
114+ space(db, "spc_new", { slug: "engineering" });
115+ assert.deepEqual(ids(db, "SELECT id FROM spaces WHERE slug = 'engineering'"), ["spc_new"]);
116+});
117+
118+test("a deleted General is still the workspace's default row, so it is never made again", async () => {
119+ const d1 = fakeD1();
120+ const db = d1.raw;
121+ space(db, "spc_general", { slug: "general", is_default: 1 });
122+ await deleteSpaceRows(d1, { id: "spc_general", slug: "general" }, AT, "user:ana");
123+ // What ensureDefault asks (src/who.ts, src/index.ts).
124+ const found = db.prepare("SELECT id FROM spaces WHERE workspace_id = 'wsp_1' AND is_default = 1").get() as { id: string } | undefined;
125+ assert.equal(found?.id, "spc_general");
126+ // And no second default can be made: the one-default index holds.
127+ assert.throws(() => space(db, "spc_again", { slug: "general", is_default: 1 }));
128+ // Deleting it again changes nothing.
129+ assert.deepEqual(await deleteSpaceRows(d1, { id: "spc_general", slug: "general~spc_general" }, "2026-10-12T00:00:00.000Z", "user:bo"), []);
130+ assert.equal((db.prepare("SELECT deleted_at FROM spaces WHERE id = 'spc_general'").get() as { deleted_at: string }).deleted_at, AT);
131+});
132+
133+function aSpace(over: Partial<Space["row"]> & { role?: Space["role"] }): Space {
134+ const { role = "edit", ...row } = over;
135+ return {
136+ row: {
137+ id: "spc",
138+ workspace_id: "wsp_1",
139+ slug: "spc",
140+ name: "Space",
141+ description: null,
142+ icon: null,
143+ kind: "workspace",
144+ team: null,
145+ default_role: "edit",
146+ agent_mode: "suggest",
147+ editors_can_share: 0,
148+ is_default: 0,
149+ created_by: "user:ana",
150+ created_at: AT,
151+ archived_at: null,
152+ deleted_at: null,
153+ deleted_by: null,
154+ ...row,
155+ },
156+ members: [],
157+ projects: [],
158+ role,
159+ };
160+}
161+
162+test("a folio restored from a deleted space lands in General when it is live and the restorer may add to it", () => {
163+ const general = aSpace({ id: "spc_general", is_default: 1 });
164+ assert.equal(landingFor([aSpace({ id: "spc_gone", deleted_at: AT }), general])?.row.id, "spc_general");
165+ // No General, an archived or deleted one, or one they can only read: its owner's Private.
166+ assert.equal(landingFor([aSpace({ id: "spc_other" })]), null);
167+ assert.equal(landingFor([aSpace({ id: "spc_general", is_default: 1, archived_at: AT })]), null);
168+ assert.equal(landingFor([aSpace({ id: "spc_general", is_default: 1, deleted_at: AT })]), null);
169+ assert.equal(landingFor([aSpace({ id: "spc_general", is_default: 1, role: "view" })]), null);
170+ assert.equal(landingFor([]), null);
171+});
+60−0
1+/**
2+ * The trash's two ends, over D1 alone so they are tested on SQLite
3+ * (trash.test.ts): what deleting folios for good does to the database
4+ * (`eraseFolios`: one artifact, the whole trash, the daily purge), what
5+ * deleting a space does to its rows (`deleteSpaceRows`), and where a folio
6+ * restored from the trash lands when its space is gone (`landingFor`).
7+ * Rooms, events and the index's vectors are the callers' (service.ts).
8+ */
9+import { atLeast } from "../access.ts";
10+import { forgetFolios, type IndexEnv } from "../indexer.ts";
11+import type { Space } from "../who.ts";
12+import { json, runBatches } from "./access-store.ts";
13+
14+/**
15+ * Deletes folios for good: their passages leave the index, children still
16+ * alive under them go to the top of where they were, and their search rows
17+ * and rows go, deepest first, so no parent goes before its children. Returns
18+ * the ids that went, in that order. Callers destroy the rooms.
19+ */
20+export async function eraseFolios(env: IndexEnv, rows: readonly { id: string; path: string }[]): Promise<string[]> {
21+ if (!rows.length) return [];
22+ const unique = [...new Map(rows.map((r) => [r.id, r])).values()];
23+ const ids = unique.sort((x, y) => y.path.length - x.path.length).map((r) => r.id);
24+ await env.DB.prepare("UPDATE folios SET parent_id = NULL WHERE parent_id IN (SELECT value FROM json_each(?)) AND id NOT IN (SELECT value FROM json_each(?))").bind(json(ids), json(ids)).run();
25+ await forgetFolios(env, ids);
26+ await runBatches(
27+ env.DB,
28+ ids.flatMap((id) => [env.DB.prepare("DELETE FROM folios_fts WHERE folio_id = ?").bind(id), env.DB.prepare("DELETE FROM folios WHERE id = ?").bind(id)]),
29+ );
30+ return ids;
31+}
32+
33+/**
34+ * What deleting a space does to the database, at `at` by `by`: every folio
35+ * in it not already in the trash goes there together (so each top restores
36+ * as usual), the space's pages (Docs' older model) too, members' sidebars
37+ * drop it, and its row is marked deleted with its address freed for a new
38+ * space (`<slug>~<id>`, which no address can be; see migration 0007). The
39+ * row stays, so the trash keeps the space's name and rules and a deleted
40+ * General is never made again. Returns the ids of the folios trashed.
41+ */
42+export async function deleteSpaceRows(db: D1Database, space: { id: string; slug: string }, at: string, by: string): Promise<string[]> {
43+ const rows = (await db.prepare("SELECT id FROM folios WHERE space_id = ? AND trashed_at IS NULL").bind(space.id).all<{ id: string }>()).results;
44+ await db.batch([
45+ db.prepare("UPDATE folios SET trashed_at = ?, trashed_by = ? WHERE space_id = ? AND trashed_at IS NULL").bind(at, by, space.id),
46+ db.prepare("UPDATE pages SET archived_at = ?, archived_by = ? WHERE space_id = ? AND archived_at IS NULL").bind(at, by, space.id),
47+ db.prepare("DELETE FROM space_joins WHERE space_id = ?").bind(space.id),
48+ db.prepare("UPDATE spaces SET deleted_at = ?, deleted_by = ?, slug = ? WHERE id = ? AND deleted_at IS NULL").bind(at, by, `${space.slug}~${space.id}`, space.id),
49+ ]);
50+ return rows.map((r) => r.id);
51+}
52+
53+/**
54+ * Where a folio restored from the trash lands when the space it was in has
55+ * been deleted: the General space, when there is one that is live and the
56+ * restorer may add to; else null, its owner's Private.
57+ */
58+export function landingFor(spaces: readonly Space[]): Space | null {
59+ return spaces.find((s) => s.row.is_default && !s.row.deleted_at && !s.row.archived_at && atLeast(s.role, "edit")) ?? null;
60+}
+18−7
152152 created_by: string;
153153 created_at: string;
154154 archived_at: string | null;
155+ /** Set when the space was deleted (migration 0007; src/who.ts). Docs leaves deleted spaces out everywhere. */
156+ deleted_at: string | null;
157+ deleted_by: string | null;
155158 };
156159
157160 type PageRow = {
421424 return !!viewer.workspaces?.some((m) => m.slug === slug.toLowerCase() && m.role === "owner");
422425 }
423426
424− /** Every space in the workspace (archived ones too when asked), with members and projects. */
427+ /** Every space in the workspace (archived ones too when asked; never deleted ones), with members and projects. */
425428 private async allSpaces(workspace: Workspace, archived = false): Promise<Omit<Space, "role">[]> {
426429 const [spaces, members, projects] = await Promise.all([
427430 this.db
428− .prepare(`SELECT * FROM spaces WHERE workspace_id = ? ${archived ? "" : "AND archived_at IS NULL"} ORDER BY is_default DESC, name COLLATE NOCASE`)
431+ .prepare(`SELECT * FROM spaces WHERE workspace_id = ? AND deleted_at IS NULL ${archived ? "" : "AND archived_at IS NULL"} ORDER BY is_default DESC, name COLLATE NOCASE`)
429432 .bind(workspace.id)
430433 .all<SpaceRow>(),
431434 this.db
456459 return spaces.map((s) => ({ ...s, role: roleOf(rulesOf({ ...s, role: null }), person) }));
457460 }
458461
459− /** Makes the workspace's General space, once. */
462+ /** Makes the workspace's General space, once; a deleted General (its row kept, `deleted_at` set) is not made again. */
460463 private async ensureDefault(workspace: Workspace, viewer: User): Promise<boolean> {
461464 const found = await this.db.prepare("SELECT id FROM spaces WHERE workspace_id = ? AND is_default = 1").bind(workspace.id).first<{ id: string }>();
462465 if (found) return false;
855858 return ok({ space: this.toSpace(space, rows.length), members, pages });
856859 }
857860
861+ /** Every space the viewer can open, archived ones included (the Spaces page lists those apart), General first. */
862+ async spaces(a: { workspace: string; viewer: Viewer }): Promise<Result<DocSpace[]>> {
863+ const found = await this.viewerWorkspace(a.workspace, a.viewer);
864+ if (!found.ok) return found;
865+ const spaces = await this.spacesFor(found.value, a.viewer!, true);
866+ return ok(spaces.filter((s) => s.role).map((s) => this.toSpace(s)));
867+ }
868+
858869 async createSpace(a: { workspace: string; viewer: Viewer; input: NewDocSpace }): Promise<Result<DocSpace>> {
859870 const found = await this.viewerWorkspace(a.workspace, a.viewer);
860871 if (!found.ok) return found;
946957 if (c.default_role !== undefined && (c.kind ?? space.row.kind) !== "private") set("default_role", isRole(c.default_role) ? c.default_role : null);
947958 if (c.agent_mode !== undefined) set("agent_mode", c.agent_mode === "edit" ? "edit" : "suggest");
948959 if (c.editors_can_share !== undefined) set("editors_can_share", c.editors_can_share ? 1 : 0);
949− if (c.archived !== undefined) {
950− if (space.row.is_default && c.archived) return fail("invalid", "The General space can't be archived.");
951− set("archived_at", c.archived ? now() : null);
952− }
960+ // Any space archives, General too: a space is not required to exist.
961+ if (c.archived !== undefined) set("archived_at", c.archived ? now() : null);
953962 const statements: D1PreparedStatement[] = [];
954963 if (sets.length) statements.push(this.db.prepare(`UPDATE spaces SET ${sets.join(", ")} WHERE id = ?`).bind(...values, space.row.id));
955964 if (c.projects !== undefined) {
26242633 return Response.json(await service.home(args));
26252634 case "space":
26262635 return Response.json(await service.space(args));
2636+ case "spaces":
2637+ return Response.json(await service.spaces(args));
26272638 case "create_space":
26282639 return Response.json(await service.createSpace(args));
26292640 case "update_space":
+13−1
5050 created_by: string;
5151 created_at: string;
5252 archived_at: string | null;
53+ /**
54+ * Set when the space was deleted (migration 0007). The row stays, so the
55+ * trash's rows that point at it keep their space's name and rules (whoever
56+ * could edit them still restores them) and General, once deleted, is not
57+ * made again; listings leave deleted spaces out.
58+ */
59+ deleted_at: string | null;
60+ deleted_by: string | null;
5361 };
5462
5563 /** A space, with who is in it and the viewer's role (null: they can't read it). */
271279 return spaces.map((s) => ({ ...s, role: roleOf(rulesOf(s), person) }));
272280 }
273281
274− /** Makes the workspace's General space, once. */
282+ /**
283+ * Makes the workspace's General space, once. A deleted General still
284+ * has its row (`deleted_at` set), so it is never made again: a
285+ * workspace may have no spaces.
286+ */
275287 async ensureDefault(workspace: Workspace, viewer: User): Promise<void> {
276288 if (defaultsMade.has(workspace.id)) return;
277289 const db = this.env.DB;

Comments

Sign in to comment.