feat(seed): ~5x richer demo data via deterministic generators
Rewrite scripts/seed-demo.ts as a thin orchestrator over a new scripts/seed/ module (rng, pools, helpers, content, schedule, logs, generators). Scales demo data from 6 users/3 projects to ~30 users/15 projects with ~5-8x per-table content (~6.8x total rows) via a seeded mulberry32 PRNG for full reproducibility (incl. bcrypt salts and file/URL identifiers). FK-safe ordering, real captured IDs for activity logs, enum pools typed against lib/types. Preserves canonical login credentials and projects.
This commit is contained in:
203
scripts/seed/generators.ts
Normal file
203
scripts/seed/generators.ts
Normal file
@ -0,0 +1,203 @@
|
||||
/**
|
||||
* シードデータ生成のオーケストレータ。
|
||||
* ユーザーとプロジェクトを生成し、各プロジェクト単位の生成器(content.ts)をFK順に呼び出す。
|
||||
* 生成内容は固定シード(mulberry32)で決定論的。
|
||||
*/
|
||||
import bcrypt from 'bcrypt';
|
||||
import type { SqliteDatabase } from '@/lib/db/sqlite';
|
||||
import { createRng, pick, pickN, randInt, chance, type Rng } from './rng';
|
||||
import {
|
||||
FAMILY_NAMES,
|
||||
GIVEN_NAMES,
|
||||
PROJECT_DESCRIPTIONS,
|
||||
PROJECT_NAMES,
|
||||
} from './pools';
|
||||
import {
|
||||
CANONICAL_PROJECTS,
|
||||
CANONICAL_USERS,
|
||||
SEED,
|
||||
TARGET_PROJECTS,
|
||||
TARGET_USERS,
|
||||
type UserSeed,
|
||||
addMember,
|
||||
bcryptSalt,
|
||||
insert,
|
||||
now,
|
||||
type ProjectSpec,
|
||||
} from './helpers';
|
||||
import {
|
||||
seedBoard,
|
||||
seedChat,
|
||||
seedFiles,
|
||||
seedNotes,
|
||||
seedTodos,
|
||||
} from './content';
|
||||
import { seedCalendar, seedMeetings, seedMilestones } from './schedule';
|
||||
import { seedActivityLogs, seedNotifications } from './logs';
|
||||
|
||||
function buildUserSeeds(rng: Rng): UserSeed[] {
|
||||
const seeds = [...CANONICAL_USERS];
|
||||
const usedEmails = new Set(seeds.map((u) => u.email));
|
||||
const usedNames = new Set(seeds.map((u) => u.name));
|
||||
while (seeds.length < TARGET_USERS) {
|
||||
const given = pick(rng, GIVEN_NAMES);
|
||||
const family = pick(rng, FAMILY_NAMES);
|
||||
const name = `${given} ${family}`;
|
||||
if (usedNames.has(name)) continue;
|
||||
let email = `${given.toLowerCase()}.${family.toLowerCase()}@example.com`;
|
||||
let suffix = 1;
|
||||
while (usedEmails.has(email)) {
|
||||
email = `${given.toLowerCase()}.${family.toLowerCase()}${suffix}@example.com`;
|
||||
suffix++;
|
||||
}
|
||||
usedEmails.add(email);
|
||||
usedNames.add(name);
|
||||
const status = chance(rng, 0.1) ? 'inactive' : 'active';
|
||||
seeds.push({ name, email, password: 'password', role: 'member', status });
|
||||
}
|
||||
return seeds;
|
||||
}
|
||||
|
||||
function seedUsers(
|
||||
db: SqliteDatabase,
|
||||
rng: Rng
|
||||
): { emailToId: Map<string, number>; activeMemberIds: number[] } {
|
||||
const seeds = buildUserSeeds(rng);
|
||||
const emailToId = new Map<string, number>();
|
||||
const activeMemberIds: number[] = [];
|
||||
for (const u of seeds) {
|
||||
const id = insert(
|
||||
db,
|
||||
`INSERT INTO users (name, email, password_hash, avatar_url, role, status, created_at, updated_at)
|
||||
VALUES (@name, @email, @passwordHash, NULL, @role, @status, @createdAt, @updatedAt)`,
|
||||
{
|
||||
name: u.name,
|
||||
email: u.email,
|
||||
passwordHash: bcrypt.hashSync(u.password, bcryptSalt(rng)),
|
||||
role: u.role,
|
||||
status: u.status,
|
||||
createdAt: now(),
|
||||
updatedAt: now(),
|
||||
}
|
||||
);
|
||||
emailToId.set(u.email, id);
|
||||
if (u.role === 'member' && u.status === 'active') activeMemberIds.push(id);
|
||||
}
|
||||
return { emailToId, activeMemberIds };
|
||||
}
|
||||
|
||||
function buildProjectSpecs(rng: Rng): ProjectSpec[] {
|
||||
const specs: ProjectSpec[] = CANONICAL_PROJECTS.map((p) => ({
|
||||
name: p.name,
|
||||
description: p.description,
|
||||
status: p.status,
|
||||
ownerEmail: p.ownerEmail,
|
||||
memberEmails: p.memberEmails,
|
||||
}));
|
||||
const usedNames = new Set(specs.map((s) => s.name));
|
||||
const names = pickN(rng, PROJECT_NAMES, PROJECT_NAMES.length).filter(
|
||||
(n) => !usedNames.has(n)
|
||||
);
|
||||
const descs = pickN(rng, PROJECT_DESCRIPTIONS, PROJECT_DESCRIPTIONS.length);
|
||||
let ni = 0;
|
||||
let di = 0;
|
||||
while (specs.length < TARGET_PROJECTS) {
|
||||
const name = names[ni] ?? `Internal Project ${specs.length + 1}`;
|
||||
ni++;
|
||||
const description = descs[di % descs.length] ?? 'デモ用プロジェクトです。';
|
||||
di++;
|
||||
const r = rng();
|
||||
const status: ProjectSpec['status'] =
|
||||
r < 0.55
|
||||
? 'active'
|
||||
: r < 0.75
|
||||
? 'on_hold'
|
||||
: r < 0.9
|
||||
? 'completed'
|
||||
: 'archived';
|
||||
specs.push({
|
||||
name,
|
||||
description,
|
||||
status,
|
||||
ownerEmail: null,
|
||||
memberEmails: [],
|
||||
});
|
||||
}
|
||||
return specs;
|
||||
}
|
||||
|
||||
export function seedAll(
|
||||
db: SqliteDatabase,
|
||||
uploadsDir: string
|
||||
): { userCount: number; projectCount: number } {
|
||||
const rng = createRng(SEED);
|
||||
const { emailToId, activeMemberIds } = seedUsers(db, rng);
|
||||
|
||||
const specs = buildProjectSpecs(rng);
|
||||
for (const spec of specs) {
|
||||
const ownerId = spec.ownerEmail
|
||||
? (emailToId.get(spec.ownerEmail) ?? activeMemberIds[0]!)
|
||||
: pick(rng, activeMemberIds);
|
||||
const projectId = insert(
|
||||
db,
|
||||
`INSERT INTO projects (name, description, status, owner_id, created_at, updated_at)
|
||||
VALUES (@name, @description, @status, @ownerId, @createdAt, @updatedAt)`,
|
||||
{
|
||||
name: spec.name,
|
||||
description: spec.description,
|
||||
status: spec.status,
|
||||
ownerId,
|
||||
createdAt: now(),
|
||||
updatedAt: now(),
|
||||
}
|
||||
);
|
||||
addMember(db, projectId, ownerId, 'admin');
|
||||
const memberIds = [ownerId];
|
||||
const extras =
|
||||
spec.memberEmails.length > 0
|
||||
? spec.memberEmails.map((m) => ({
|
||||
id: emailToId.get(m.email)!,
|
||||
role: m.role,
|
||||
}))
|
||||
: pickN(
|
||||
rng,
|
||||
activeMemberIds.filter((id) => id !== ownerId),
|
||||
randInt(rng, 2, 4)
|
||||
).map((id) => ({ id, role: 'member' as const }));
|
||||
for (const m of extras) {
|
||||
if (!memberIds.includes(m.id)) {
|
||||
addMember(db, projectId, m.id, m.role);
|
||||
memberIds.push(m.id);
|
||||
}
|
||||
}
|
||||
|
||||
const milestoneIds = seedMilestones(db, rng, projectId);
|
||||
const { threadIds, commentIds } = seedBoard(db, rng, projectId, memberIds);
|
||||
seedChat(db, rng, projectId, memberIds);
|
||||
const todoIds = seedTodos(db, rng, projectId, memberIds, milestoneIds);
|
||||
const noteIds = seedNotes(db, rng, projectId, memberIds);
|
||||
const fileIds = seedFiles(db, rng, projectId, ownerId, uploadsDir);
|
||||
const meetingIds = seedMeetings(db, rng, projectId, ownerId, memberIds);
|
||||
seedCalendar(
|
||||
db,
|
||||
rng,
|
||||
projectId,
|
||||
ownerId,
|
||||
todoIds,
|
||||
milestoneIds,
|
||||
meetingIds
|
||||
);
|
||||
seedNotifications(db, rng, projectId, memberIds);
|
||||
seedActivityLogs(db, rng, projectId, memberIds, {
|
||||
threadIds,
|
||||
commentIds,
|
||||
todoIds,
|
||||
fileIds,
|
||||
meetingIds,
|
||||
noteIds,
|
||||
milestoneIds,
|
||||
});
|
||||
}
|
||||
|
||||
return { userCount: emailToId.size, projectCount: specs.length };
|
||||
}
|
||||
Reference in New Issue
Block a user