1
18import { PrismaClient } from "@prisma/client";
19import { comparePositions, isValidPosition } from "../packages/shared/src/positions.js";
20
21const DEMO_EMAIL = "demo@factory.local";
22const ADMIN_EMAIL = "admin@factory.local";
23
24export interface CheckResult {
25 name: string;
26 expected: string;
27 actual: string;
28 ok: boolean;
29}
30
31interface ModelCounts {
32 users: number;
33 workspaces: number;
34 teams: number;
35 pages: number;
36 databases: number;
37 records: number;
38 comments: number;
39 favorites: number;
40}
41
42
47export async function verifySeedData(prisma: PrismaClient): Promise<CheckResult[]> {
48 const checks: CheckResult[] = [];
49 const add = (name: string, expected: string, actual: string, ok: boolean): void => {
50 checks.push({ name, expected, actual, ok });
51 };
52 const count = (name: string, expected: number, actual: number): void => {
53 add(name, String(expected), String(actual), expected === actual);
54 };
55
56
57 const counts: ModelCounts = {
58 users: await prisma.user.count(),
59 workspaces: await prisma.workspace.count(),
60 teams: await prisma.team.count(),
61 pages: await prisma.page.count(),
62 databases: await prisma.database.count(),
63 records: await prisma.databaseRecord.count(),
64 comments: await prisma.comment.count(),
65 favorites: await prisma.pageFavorite.count()
66 };
67
68
69 for (const email of [DEMO_EMAIL, ADMIN_EMAIL]) {
70 const user = await prisma.user.findUnique({ where: { email } });
71 add(
72 `user ${email} exists VERIFIED/ACTIVE with passwordHash`,
73 "user, emailVerification=VERIFIED, status=ACTIVE, passwordHash non-null",
74 user
75 ? `emailVerification=${user.emailVerification}, status=${user.status}, passwordHash=${
76 user.passwordHash ? "set" : "null"
77 }`
78 : "missing",
79 Boolean(user && user.emailVerification === "VERIFIED" && user.status === "ACTIVE" && user.passwordHash)
80 );
81 }
82 const demoUser = await prisma.user.findUnique({ where: { email: DEMO_EMAIL } });
83 const adminUser = await prisma.user.findUnique({ where: { email: ADMIN_EMAIL } });
84
85
86 const demoWs = await prisma.workspace.findUnique({ where: { slug: "demo" } });
87 add(
88 "workspace slug `demo` non-personal, owned by demo user",
89 "workspace, isPersonal=false, ownerId=demo user",
90 demoWs
91 ? `isPersonal=${demoWs.isPersonal}, ownerId=${demoWs.ownerId ?? "null"}`
92 : "missing",
93 Boolean(demoWs && !demoWs.isPersonal && demoUser && demoWs.ownerId === demoUser.id)
94 );
95
96
97 const personalWs = await prisma.workspace.findMany({
98 where: { isPersonal: true },
99 select: { slug: true, ownerId: true }
100 });
101 add(
102 "exactly 2 personal workspaces (one per demo user)",
103 "2, each matching the `personal-<4 random chars>` shape",
104 personalWs.map((w) => `${w.slug}→${w.ownerId}`).join(", "),
105 personalWs.length === 2 &&
106 personalWs.every((w) => /^personal-[a-z0-9]{4}$/.test(w.slug)) &&
107 new Set(personalWs.map((w) => w.ownerId)).size === 2
108 );
109
110
111 const owned = await prisma.workspace.findMany({
112 where: { ownerId: { not: null } },
113 select: { id: true, slug: true, ownerId: true, members: { select: { userId: true, role: true } } }
114 });
115 const ownerViolations = owned.filter(
116 (w) => !w.members.some((m) => m.userId === w.ownerId && m.role === "OWNER")
117 );
118 add(
119 "every workspace ownerId has an OWNER WorkspaceMember row",
120 "0 violations",
121 ownerViolations.map((w) => w.slug).join(", "),
122 ownerViolations.length === 0
123 );
124
125 if (!demoWs) {
126 failTable(checks, counts);
127 return checks;
128 }
129 const wsId = demoWs.id;
130
131
132 const teams = await prisma.team.findMany({ where: { workspaceId: wsId } });
133 const teamSlugs = teams.map((t) => t.slug);
134 add(
135 "demo ws: exactly 2 teams (unique slugs, valid fractional positions)",
136 "2 teams, unique slugs, valid positions",
137 teams
138 .map((t) => `${t.slug}:${t.position}${isValidPosition(t.position) ? "" : "(INVALID)"}`)
139 .join(", "),
140 teams.length === 2 &&
141 new Set(teamSlugs).size === 2 &&
142 teams.every((t) => isValidPosition(t.position))
143 );
144
145
146
147
148
149
150 const allPages = await prisma.page.findMany({ where: { workspaceId: wsId } });
151 count("demo ws: exactly 6 pages (3 content + 3 record)", 6, allPages.length);
152
153 const recordPages = new Set<string>(
154 (await prisma.databaseRecord.findMany({
155 where: { workspaceId: wsId },
156 select: { pageId: true }
157 })).map((r) => r.pageId)
158 .filter((p): p is string => p !== null)
159 );
160 const contentPages = allPages.filter((p) => !recordPages.has(p.id));
161 count("demo ws: 3 record pages (1:1 with the 3 Tasks records)", 3, recordPages.size);
162
163
164 const pageIds = new Set(allPages.map((p) => p.id));
165 const parentViolations = allPages.filter((p) => p.parentId !== null && !pageIds.has(p.parentId));
166 add(
167 "every page parentId is a same-workspace page",
168 "0 violations",
169 parentViolations.map((p) => `${p.title}→${p.parentId}`).join(", "),
170 parentViolations.length === 0
171 );
172
173
174 const byParent = new Map<string, typeof allPages>();
175 for (const p of allPages) {
176 const key = p.parentId ?? "__root__";
177 const list = byParent.get(key) ?? [];
178 list.push(p);
179 byParent.set(key, list);
180 }
181 let positionDupes = 0;
182 let positionInvalid = 0;
183 for (const list of byParent.values()) {
184 for (const p of list) {
185 if (!isValidPosition(p.position)) positionInvalid += 1;
186 }
187 const sorted = [...list].sort((a, b) => comparePositions(a.position, b.position));
188 for (let i = 1; i < sorted.length; i += 1) {
189 if (comparePositions(sorted[i - 1]!.position, sorted[i]!.position) === 0) positionDupes += 1;
190 }
191 }
192 add(
193 "positions unique per parent (shared comparator) and valid",
194 "0 duplicates, 0 invalid",
195 `${positionDupes} duplicates, ${positionInvalid} invalid`,
196 positionDupes === 0 && positionInvalid === 0
197 );
198
199 const gettingStarted = contentPages.find((p) => p.title === "Getting started");
200 const gsChildren = gettingStarted
201 ? contentPages.filter((p) => p.parentId === gettingStarted.id)
202 : [];
203 add(
204 `"Getting started" has exactly 2 direct children`,
205 "2",
206 gsChildren.map((p) => p.title).join(", "),
207 gettingStarted !== undefined && gsChildren.length === 2
208 );
209
210
211 const dbs = await prisma.database.findMany({ where: { workspaceId: wsId } });
212 const tasksDb = dbs.find((d) => d.title === "Tasks");
213 add(
214 "demo ws: exactly 1 database titled `Tasks`",
215 "1",
216 dbs.map((d) => d.title).join(", "),
217 tasksDb !== undefined && dbs.length === 1
218 );
219
220 if (tasksDb) {
221 const props = await prisma.databaseProperty.findMany({ where: { databaseId: tasksDb.id } });
222 const shape = {
223 title: props.filter((p) => p.type === "TITLE").length,
224 select: props.filter((p) => p.type === "SELECT").length,
225 date: props.filter((p) => p.type === "DATE").length,
226 person: props.filter((p) => p.type === "PERSON").length
227 };
228 add(
229 "Tasks db: exactly 5 properties (title, 2× select, date, person) — order-insensitive",
230 "title=1 select=2 date=1 person=1 (total 5)",
231 `title=${shape.title} select=${shape.select} date=${shape.date} person=${shape.person} (total ${props.length})`,
232 shape.title === 1 && shape.select === 2 && shape.date === 1 && shape.person === 1 && props.length === 5
233 );
234
235 const records = await prisma.databaseRecord.findMany({
236 where: { databaseId: tasksDb.id },
237 include: { page: true }
238 });
239 const nonEmptyTitles = records.filter((r) => r.page && r.page.title.trim().length > 0);
240 add(
241 "Tasks db: exactly 3 records with non-empty titles (each 1:1 with a page)",
242 "3 records, each with a non-empty page title",
243 `${records.length} records, ${nonEmptyTitles.length} with non-empty page titles`,
244 records.length === 3 && nonEmptyTitles.length === 3
245 );
246
247 const tableViews = await prisma.databaseView.findMany({
248 where: { databaseId: tasksDb.id, type: "TABLE" }
249 });
250 count("Tasks db: exactly 1 TABLE view", 1, tableViews.length);
251 }
252
253
254 if (gettingStarted && adminUser) {
255 const comments = await prisma.comment.findMany({ where: { pageId: gettingStarted.id } });
256 add(
257 `"Getting started": exactly 1 comment, authored by the admin user`,
258 "1 comment by admin",
259 `${comments.length} comments (${comments
260 .map((c) => c.createdBy)
261 .join(", ")})`,
262 comments.length === 1 && comments[0]?.createdBy === adminUser.id
263 );
264 } else {
265 add(`"Getting started": exactly 1 comment, authored by the admin user`, "1 comment by admin", "page or admin user missing", false);
266 }
267
268
269 if (gettingStarted && demoUser) {
270 const favs = await prisma.pageFavorite.findMany({
271 where: { workspaceId: wsId, userId: demoUser.id, pageId: gettingStarted.id }
272 });
273 count("demo user favorites `Getting started` (1 PageFavorite row)", 1, favs.length);
274 } else {
275 add("demo user favorites `Getting started` (1 PageFavorite row)", "1", "page or demo user missing", false);
276 }
277
278 failTable(checks, counts);
279 return checks;
280}
281
282
286function failTable(checks: CheckResult[], counts: ModelCounts): void {
287 const failed = checks.filter((c) => !c.ok);
288
289 console.log("\n📋 Seed verification report");
290 console.log(
291 " counts: " +
292 ["users", "workspaces", "teams", "pages", "databases", "records", "comments", "favorites"]
293 .map((k) => `${k}=${counts[k as keyof ModelCounts]}`)
294 .join(" ")
295 );
296 for (const c of checks) {
297 console.log(` [${c.ok ? "ok" : "FAIL"}] ${c.name}`);
298 if (!c.ok) {
299 console.log(` expected: ${c.expected}`);
300 console.log(` actual: ${c.actual}`);
301 }
302 }
303
304 if (failed.length > 0) {
305 console.error(`\n❌ Seed verification FAILED: ${failed.length}/${checks.length} checks`);
306 const table: string[] = ["| check | expected | actual |", "| --- | --- | --- |"];
307 for (const c of failed) {
308 table.push(`| ${c.name} | ${c.expected} | ${c.actual} |`);
309 }
310 console.error(table.join("\n"));
311 throw new Error(`seed verification failed: ${failed.length} check(s) (see report above)`);
312 }
313 console.log(`\n✅ Seed verification passed: ${checks.length}/${checks.length} checks`);
314}
315
316
317const isMain =
318 typeof process.argv[1] === "string" &&
319 process.argv[1].length > 0 &&
320 (process.argv[1].endsWith("verify.ts") || process.argv[1].endsWith("verify.js"));
321
322if (isMain) {
323 const prisma = new PrismaClient();
324 verifySeedData(prisma)
325 .catch((e) => {
326 console.error(e instanceof Error ? e.message : e);
327 process.exitCode = 1;
328 })
329 .finally(() => prisma.$disconnect());
330}
331