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.

173 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(
35 "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', ?, ?, ?)",
36 ).run(id, over.slug ?? id, id, over.is_default ?? 0, AT, over.archived_at ?? null, over.deleted_at ?? null);
37}
38
39function folio(
40 db: DatabaseSync,
41 id: string,
42 over: Partial<{ space_id: string | null; parent_id: string | null; path: string; trashed_at: string | null }> = {},
43) {
44 const path = over.path ?? `/${id}/`;
45 db.prepare(
46 "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', ?, ?, ?, ?)",
47 ).run(id, id, over.space_id ?? null, over.parent_id ?? null, path.split("/").filter(Boolean)[0]!, path, AT, AT, AT, over.trashed_at ?? null);
48 db.prepare("INSERT INTO folios_fts (folio_id, kind, title, body) VALUES (?, 'doc', ?, '')").run(id, id);
49}
50
51const ids = (db: DatabaseSync, sql: string) => (db.prepare(sql).all() as { id: string }[]).map((r) => r.id).sort();
52
53test("erasing folios takes their rows and search rows, deepest first, and leaves the rest", async () => {
54 const d1 = fakeD1();
55 const db = d1.raw;
56 folio(db, "top", { trashed_at: AT });
57 folio(db, "kid", { parent_id: "top", path: "/top/kid/", trashed_at: AT });
58 folio(db, "grandkid", { parent_id: "kid", path: "/top/kid/grandkid/", trashed_at: AT });
59 folio(db, "other");
60 const gone = await eraseFolios({ DB: d1 }, [
61 { id: "top", path: "/top/" },
62 { id: "kid", path: "/top/kid/" },
63 { id: "grandkid", path: "/top/kid/grandkid/" },
64 // Asked twice: goes once.
65 { id: "kid", path: "/top/kid/" },
66 ]);
67 assert.deepEqual(gone, ["grandkid", "kid", "top"]);
68 assert.deepEqual(ids(db, "SELECT id FROM folios"), ["other"]);
69 assert.deepEqual(ids(db, "SELECT folio_id AS id FROM folios_fts"), ["other"]);
70});
71
72test("a child still alive under an erased folio goes to the top of where it was", async () => {
73 const d1 = fakeD1();
74 const db = d1.raw;
75 folio(db, "top", { trashed_at: AT });
76 folio(db, "alive", { parent_id: "top", path: "/top/alive/" });
77 await eraseFolios({ DB: d1 }, [{ id: "top", path: "/top/" }]);
78 const alive = db.prepare("SELECT parent_id FROM folios WHERE id = 'alive'").get() as { parent_id: string | null };
79 assert.equal(alive.parent_id, null);
80});
81
82test("erasing nothing does nothing", async () => {
83 const d1 = fakeD1();
84 folio(d1.raw, "a");
85 assert.deepEqual(await eraseFolios({ DB: d1 }, []), []);
86 assert.deepEqual(ids(d1.raw, "SELECT id FROM folios"), ["a"]);
87});
88
89test("deleting a space trashes what is in it together, frees its address and keeps its row marked", async () => {
90 const d1 = fakeD1();
91 const db = d1.raw;
92 space(db, "spc_eng", { slug: "engineering" });
93 space(db, "spc_other", { slug: "other" });
94 folio(db, "a", { space_id: "spc_eng" });
95 folio(db, "b", { space_id: "spc_eng", parent_id: "a", path: "/a/b/" });
96 folio(db, "old", { space_id: "spc_eng", trashed_at: "2026-10-01T00:00:00.000Z" });
97 folio(db, "elsewhere", { space_id: "spc_other" });
98 folio(db, "private");
99 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);
100 const trashed = await deleteSpaceRows(d1, { id: "spc_eng", slug: "engineering" }, AT, "user:ana");
101 assert.deepEqual(trashed.sort(), ["a", "b"]);
102 // Together: one trashed_at, so each top restores the whole of what went with it.
103 assert.deepEqual(ids(db, `SELECT id FROM folios WHERE trashed_at = '${AT}' AND trashed_by = 'user:ana'`), ["a", "b"]);
104 // What was already in the trash keeps its own time; other spaces and Private are untouched.
105 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");
106 assert.deepEqual(ids(db, "SELECT id FROM folios WHERE trashed_at IS NULL"), ["elsewhere", "private"]);
107 // The row stays, marked, its address freed; the other space's joins stay.
108 const row = db.prepare("SELECT slug, deleted_at, deleted_by FROM spaces WHERE id = 'spc_eng'").get() as {
109 slug: string;
110 deleted_at: string;
111 deleted_by: string;
112 };
113 assert.deepEqual({ ...row }, { slug: "engineering~spc_eng", deleted_at: AT, deleted_by: "user:ana" });
114 assert.deepEqual(ids(db, "SELECT space_id AS id FROM space_joins"), ["spc_other"]);
115 // A new space takes the address.
116 space(db, "spc_new", { slug: "engineering" });
117 assert.deepEqual(ids(db, "SELECT id FROM spaces WHERE slug = 'engineering'"), ["spc_new"]);
118});
119
120test("a deleted General is still the workspace's default row, so it is never made again", async () => {
121 const d1 = fakeD1();
122 const db = d1.raw;
123 space(db, "spc_general", { slug: "general", is_default: 1 });
124 await deleteSpaceRows(d1, { id: "spc_general", slug: "general" }, AT, "user:ana");
125 // What ensureDefault asks (src/who.ts, src/index.ts).
126 const found = db.prepare("SELECT id FROM spaces WHERE workspace_id = 'wsp_1' AND is_default = 1").get() as { id: string } | undefined;
127 assert.equal(found?.id, "spc_general");
128 // And no second default can be made: the one-default index holds.
129 assert.throws(() => space(db, "spc_again", { slug: "general", is_default: 1 }));
130 // Deleting it again changes nothing.
131 assert.deepEqual(await deleteSpaceRows(d1, { id: "spc_general", slug: "general~spc_general" }, "2026-10-12T00:00:00.000Z", "user:bo"), []);
132 assert.equal((db.prepare("SELECT deleted_at FROM spaces WHERE id = 'spc_general'").get() as { deleted_at: string }).deleted_at, AT);
133});
134
135function aSpace(over: Partial<Space["row"]> & { role?: Space["role"] }): Space {
136 const { role = "edit", ...row } = over;
137 return {
138 row: {
139 id: "spc",
140 workspace_id: "wsp_1",
141 slug: "spc",
142 name: "Space",
143 description: null,
144 icon: null,
145 kind: "workspace",
146 team: null,
147 default_role: "edit",
148 agent_mode: "suggest",
149 editors_can_share: 0,
150 is_default: 0,
151 created_by: "user:ana",
152 created_at: AT,
153 archived_at: null,
154 deleted_at: null,
155 deleted_by: null,
156 ...row,
157 },
158 members: [],
159 projects: [],
160 role,
161 };
162}
163
164test("a folio restored from a deleted space lands in General when it is live and the restorer may add to it", () => {
165 const general = aSpace({ id: "spc_general", is_default: 1 });
166 assert.equal(landingFor([aSpace({ id: "spc_gone", deleted_at: AT }), general])?.row.id, "spc_general");
167 // No General, an archived or deleted one, or one they can only read: its owner's Private.
168 assert.equal(landingFor([aSpace({ id: "spc_other" })]), null);
169 assert.equal(landingFor([aSpace({ id: "spc_general", is_default: 1, archived_at: AT })]), null);
170 assert.equal(landingFor([aSpace({ id: "spc_general", is_default: 1, deleted_at: AT })]), null);
171 assert.equal(landingFor([aSpace({ id: "spc_general", is_default: 1, role: "view" })]), null);
172 assert.equal(landingFor([]), null);
173});