| 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 | } |