Skip to content
171 linesCodeBlameRaw
1import assert from "node:assert/strict";
2import { readFileSync, readdirSync } from "node:fs";
3import { DatabaseSync } from "node:sqlite";
4import { test } from "node:test";
5
6import type { Space } from "../who.ts";
7import { 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. */
10function 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
31const AT = "2026-10-11T12:00:00.000Z";
32
33function 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
45function 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
53const ids = (db: DatabaseSync, sql: string) => (db.prepare(sql).all() as { id: string }[]).map((r) => r.id).sort();
54
55test("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
74test("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
84test("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
91test("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
118test("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
133function 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
162test("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});