Skip to content

Commit 74c589a

Browse files
authored
Merge pull request #4376 from Northeastern-Electric-Racing/#4375-migration-deletion
#4375 Migration And Deletion
2 parents 1ca9ca0 + 6cfc52d commit 74c589a

15 files changed

Lines changed: 255 additions & 8653 deletions

package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020
"prisma:reset:force": "yarn workspace shared build; cd src/backend; npx prisma migrate reset --force",
2121
"prisma:reset": "yarn workspace shared build; cd src/backend; npx prisma migrate reset",
2222
"prisma:reset:no-seed": "yarn workspace shared build; cd src/backend; npx prisma migrate reset --skip-seed",
23-
"prisma:dev-seed": "yarn workspace shared build && cd src/backend && npx prisma migrate reset --force --skip-seed && npx tsx --import dotenv/config ./src/prisma/dev-seed.ts",
2423
"docker:prisma:reset": "cd devContainerization && docker compose -f docker-compose.dev.yml exec -T backend sh -c \"cd /src/backend && npx prisma migrate reset --force\"",
2524
"prisma:migrate": "yarn prisma:migrate:dev",
2625
"docker:prisma:migrate": "cd devContainerization && docker compose -f docker-compose.dev.yml exec -it -w /src/backend backend npx prisma migrate dev",

src/backend/package.json

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,7 @@
77
"test": "vitest",
88
"build": "NODE_OPTIONS='--max-old-space-size=8192' tsc --noEmit false",
99
"start": "node -r dotenv/config dist/backend/index.js",
10-
"prisma:manual": "tsx --import dotenv/config ./src/prisma/manual.ts",
11-
"prisma:dev-seed": "tsx --import dotenv/config ./src/prisma/dev-seed.ts",
12-
"prisma:dev-setup": "docker compose up -d --no-recreate database && yarn prisma:reset:no-seed --force && yarn workspace backend prisma:dev-seed"
10+
"prisma:manual": "tsx --import dotenv/config ./src/prisma/manual.ts"
1311
},
1412
"dependencies": {
1513
"@prisma/client": "^6.2.1",
@@ -39,9 +37,9 @@
3937
"multer": "^1.4.5-lts.1",
4038
"node-ical": "^0.26.1",
4139
"nodemailer": "^6.9.1",
42-
"ora": "^9.4.1",
4340
"prisma": "^6.2.1",
44-
"shared": "1.0.0"
41+
"shared": "1.0.0",
42+
"twisters": "^1.1.0"
4543
},
4644
"devDependencies": {
4745
"@types/express-jwt": "^6.0.4",

src/backend/src/prisma/dev-seed.ts

Lines changed: 0 additions & 58 deletions
This file was deleted.

src/backend/src/prisma/processes/seed-process.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,18 @@ export type SeedProcessConstructor<TInput, TOutput> = new (...args: any[]) => Se
66
export const GLOBAL_SEED = 1;
77

88
export abstract class SeedProcess<TInput, TOutput> {
9-
protected faker: Faker;
9+
public faker: Faker;
1010
public prisma!: PrismaClient;
1111

1212
constructor() {
1313
this.faker = new Faker({ locale: [en, base] });
1414
this.faker.seed(GLOBAL_SEED);
1515
}
1616

17+
reseed(seed: number) {
18+
this.faker.seed(seed);
19+
}
20+
1721
abstract dependencies(): SeedProcessConstructor<any, any>[];
1822
abstract run(deps: TInput): Promise<TOutput>;
1923
}
Lines changed: 153 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,73 @@
11
import { PrismaClient } from '@prisma/client';
2-
import { SeedProcess } from './seed-process.js';
3-
import ora from 'ora';
2+
import { SeedProcess, GLOBAL_SEED } from './seed-process.js';
3+
import { Twisters } from 'twisters';
4+
5+
type AnyProcess = SeedProcess<any, any>;
6+
7+
const DEFAULT_MAX_CONCURRENCY = 4;
8+
9+
const deriveSeed = (baseSeed: number, processName: string): number => {
10+
let hash = baseSeed >>> 0;
11+
for (let i = 0; i < processName.length; i++) {
12+
hash = (Math.imul(hash, 31) + processName.charCodeAt(i)) >>> 0;
13+
}
14+
return hash;
15+
};
416

517
export class SeedRunner {
6-
private instances: SeedProcess<any, any>[] = [];
18+
private instances: AnyProcess[] = [];
719
private prisma!: PrismaClient;
20+
private baseSeed = GLOBAL_SEED;
21+
private maxConcurrency = DEFAULT_MAX_CONCURRENCY;
822

923
withPrisma(prisma: PrismaClient) {
1024
this.prisma = prisma;
1125
return this;
1226
}
1327

14-
register(...processes: SeedProcess<any, any>[]) {
28+
withSeed(seed: number) {
29+
this.baseSeed = seed;
30+
return this;
31+
}
32+
33+
withMaxConcurrency(max: number) {
34+
this.maxConcurrency = Math.max(1, max);
35+
return this;
36+
}
37+
38+
register(...processes: AnyProcess[]) {
1539
this.instances.push(...processes);
1640
return this;
1741
}
1842

1943
async run() {
2044
if (!this.prisma) throw new Error('SeedRunner requires a PrismaClient. Call withPrisma() before run().');
2145

22-
const outputs = new Map<string, any>();
23-
const context: Record<string, any> = {};
2446
const total = this.instances.length;
47+
const byName = new Map<string, AnyProcess>();
48+
for (const instance of this.instances) {
49+
const { name } = instance.constructor;
50+
if (byName.has(name)) throw new Error(`Duplicate process registered: ${name}`);
51+
byName.set(name, instance);
52+
}
53+
54+
const dependencyNames = new Map<string, string[]>();
55+
for (const instance of this.instances) {
56+
const { name } = instance.constructor;
57+
const deps = instance.dependencies().map((depClass) => depClass.name);
58+
for (const dep of deps) {
59+
if (!byName.has(dep)) {
60+
throw new Error(`Process ${name} depends on ${dep}, which was not registered.`);
61+
}
62+
}
63+
dependencyNames.set(name, deps);
64+
}
65+
66+
this.assertNoCycles(dependencyNames);
67+
2568
const maxNameLength = Math.max(...this.instances.map((i) => i.constructor.name.length));
69+
const outputs = new Map<string, any>();
70+
const context: Record<string, any> = {};
2671

2772
const mergeOutputs = (target: Record<string, any>, source: Record<string, any>, sourceName: string) => {
2873
const duplicateKeys = Object.keys(source).filter((key) => key in target);
@@ -32,53 +77,123 @@ export class SeedRunner {
3277
return Object.assign(target, source);
3378
};
3479

35-
const totalStart = Date.now();
80+
const remaining = new Set(byName.keys());
81+
const inFlight = new Map<string, Promise<void>>();
82+
const completed = new Set<string>();
83+
const startTimes = new Map<string, number>();
84+
let launchedCount = 0;
85+
let failed: unknown = null;
3686

87+
const totalStart = Date.now();
88+
const twisters = new Twisters();
3789
console.log();
38-
console.log(` 🌱 Starting seed — ${total} processes\n`);
90+
console.log(` 🌱 Starting seed — ${total} processes (max ${this.maxConcurrency} concurrent)\n`);
91+
92+
const depsSatisfied = (name: string) => (dependencyNames.get(name) ?? []).every((dep) => completed.has(dep));
93+
94+
const launch = (name: string) => {
95+
const instance = byName.get(name)!;
96+
remaining.delete(name);
97+
launchedCount += 1;
98+
const index = `[${String(launchedCount).padStart(String(total).length)}/${total}]`;
99+
const label = instance.constructor.name.padEnd(maxNameLength);
100+
101+
twisters.put(name, { text: ` ${index} ${label}` });
102+
startTimes.set(name, Date.now());
39103

40-
for (let i = 0; i < this.instances.length; i++) {
41-
const instance = this.instances[i];
42104
instance.prisma = this.prisma;
43-
const start = Date.now();
44-
const index = `[${String(i + 1).padStart(String(total).length)}/${total}]`;
45-
const name = instance.constructor.name.padEnd(maxNameLength);
46-
47-
const spinner = ora({
48-
text: `${index} ${name}`,
49-
color: 'cyan'
50-
}).start();
51-
52-
try {
53-
const depOutputs = instance.dependencies().reduce<Record<string, any>>((acc, depClass) => {
54-
const output = outputs.get(depClass.name);
55-
if (!output) throw new Error(`Missing output for dependency: ${depClass.name}`);
56-
return mergeOutputs(acc, output, depClass.name);
57-
}, {});
105+
instance.reseed(deriveSeed(this.baseSeed, name));
58106

107+
const depOutputs = (dependencyNames.get(name) ?? []).reduce<Record<string, any>>((acc, depName) => {
108+
const output = outputs.get(depName);
109+
if (!output) throw new Error(`Missing output for dependency: ${depName}`);
110+
return mergeOutputs(acc, output, depName);
111+
}, {});
112+
113+
const task = (async () => {
59114
const output = await instance.run(depOutputs);
115+
outputs.set(name, output);
116+
mergeOutputs(context, output, name);
117+
})()
118+
.then(() => {
119+
const elapsed = `${((Date.now() - startTimes.get(name)!) / 1000).toFixed(2)}s`;
120+
twisters.put(name, { active: false, text: ` ✔ ${index} ${label} ${elapsed}` });
121+
completed.add(name);
122+
})
123+
.catch((e) => {
124+
const elapsed = `${((Date.now() - startTimes.get(name)!) / 1000).toFixed(2)}s`;
125+
twisters.put(name, { active: false, text: ` ✖ ${index} ${label} ${elapsed}` });
126+
if (!failed) failed = e;
127+
})
128+
.finally(() => {
129+
inFlight.delete(name);
130+
});
131+
132+
inFlight.set(name, task);
133+
};
134+
135+
while ((remaining.size > 0 || inFlight.size > 0) && !failed) {
136+
const ready = [...remaining].filter(depsSatisfied);
60137

61-
outputs.set(instance.constructor.name, output);
62-
mergeOutputs(context, output, instance.constructor.name);
63-
64-
const elapsed = `${((Date.now() - start) / 1000).toFixed(2)}s`;
65-
spinner.succeed(`${index} ${name} ${elapsed}`);
66-
} catch (e) {
67-
const elapsed = `${((Date.now() - start) / 1000).toFixed(2)}s`;
68-
spinner.fail(`${index} ${name} ${elapsed}`);
69-
const totalElapsed = `${((Date.now() - totalStart) / 1000).toFixed(2)}s`;
70-
console.log();
71-
console.log(` ❌ Seed failed on ${instance.constructor.name.trim()} failure(s) in ${totalElapsed}`);
72-
console.log();
73-
throw e;
138+
while (ready.length > 0 && inFlight.size < this.maxConcurrency) {
139+
launch(ready.shift()!);
140+
}
141+
142+
if (inFlight.size === 0 && remaining.size > 0) {
143+
break;
144+
}
145+
146+
if (inFlight.size > 0) {
147+
await Promise.race(inFlight.values());
74148
}
75149
}
76150

151+
await Promise.allSettled(inFlight.values());
152+
153+
twisters.forEachMessage((_message, messageName) => twisters.remove(messageName));
154+
twisters.flush();
155+
156+
if (failed) {
157+
const totalElapsed = `${((Date.now() - totalStart) / 1000).toFixed(2)}s`;
158+
console.log();
159+
console.log(` ❌ Seed failed in ${totalElapsed}`);
160+
console.log();
161+
throw failed;
162+
}
163+
164+
if (completed.size < total) {
165+
const stuck = [...byName.keys()].filter((name) => !completed.has(name));
166+
throw new Error(`Seed could not complete. Unreachable processes (dependency issue): ${stuck.join(', ')}`);
167+
}
168+
77169
const totalElapsed = `${((Date.now() - totalStart) / 1000).toFixed(2)}s`;
78170
console.log();
79171
console.log(` ✅ Seed complete — ${total} processes finished in ${totalElapsed}`);
80172
console.log();
81173

82174
return context;
83175
}
176+
177+
private assertNoCycles(dependencyNames: Map<string, string[]>) {
178+
const visiting = new Set<string>();
179+
const visited = new Set<string>();
180+
const stack: string[] = [];
181+
182+
const visit = (name: string) => {
183+
if (visited.has(name)) return;
184+
if (visiting.has(name)) {
185+
const cycleStart = stack.indexOf(name);
186+
const cycle = [...stack.slice(cycleStart), name].join(' → ');
187+
throw new Error(`Dependency cycle detected: ${cycle}`);
188+
}
189+
visiting.add(name);
190+
stack.push(name);
191+
for (const dep of dependencyNames.get(name) ?? []) visit(dep);
192+
stack.pop();
193+
visiting.delete(name);
194+
visited.add(name);
195+
};
196+
197+
for (const name of dependencyNames.keys()) visit(name);
198+
}
84199
}

0 commit comments

Comments
 (0)