Files
opengroupware/scripts/seed/generators.ts
Ken Yasue 571b8ae0c4 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.
2026-06-25 14:35:56 +02:00

204 lines
6.0 KiB
TypeScript

/**
* シードデータ生成のオーケストレータ。
* ユーザーとプロジェクトを生成し、各プロジェクト単位の生成器(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 };
}