Skip to content
60 linesCodeBlameRaw
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 */
9import { atLeast } from "../access.ts";
10import { forgetFolios, type IndexEnv } from "../indexer.ts";
11import type { Space } from "../who.ts";
12import { 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 */
20export 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 */
42export 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 */
58export 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}