Skip to content

Commit 5cd7b67

Browse files
authored
Add database seed script and prod seeding job (#116)
Seeds catalog/lookup data (cities, locations, categories, tags, items, copies). Idempotent: skips when cities already exist unless forced. Adds npm scripts (seed, seed:prod) and scripts/seed-prod.sh to run the seed as a one-off Cloud Run Job against the deployed backend.
1 parent 2ebf246 commit 5cd7b67

3 files changed

Lines changed: 263 additions & 0 deletions

File tree

backend/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
"migrations:generate": "npm run typeorm -- migration:generate -d src/datasource.ts src/db/migrations/migration",
1313
"migrations:run": "npm run typeorm -- migration:run -d src/datasource.ts",
1414
"migrations:rollback": "npm run typeorm -- migration:revert -d src/datasource.ts",
15+
"seed": "ts-node -r tsconfig-paths/register src/db/seed.ts",
16+
"seed:prod": "node dist/db/seed.js",
1517
"start": "nest start",
1618
"start:dev": "nest start --watch",
1719
"start:debug": "nest start --debug --watch",

backend/scripts/seed-prod.sh

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
#!/usr/bin/env bash
2+
#
3+
# Seed the production database by running the seed script as a one-off
4+
# Cloud Run Job, reusing the exact image, Cloud SQL connection, env vars and
5+
# secrets of the already-deployed backend service.
6+
#
7+
# It does NOT touch the live service — it creates/updates a separate Job
8+
# (default name: seed-prod) and executes it once.
9+
#
10+
# Requirements: gcloud CLI, authenticated with access to the prod project.
11+
#
12+
# Usage:
13+
# ./scripts/seed-prod.sh # safe: seed only if DB is empty
14+
# FORCE=true ./scripts/seed-prod.sh # pass --force to the seed
15+
#
16+
# Override defaults via env:
17+
# PROJECT, REGION, SERVICE, JOB
18+
#
19+
set -euo pipefail
20+
21+
PROJECT="${PROJECT:-pq-reference-app-prod}"
22+
REGION="${REGION:-europe-west1}"
23+
SERVICE="${SERVICE:-pq-reference-backend-prod}"
24+
JOB="${JOB:-seed-prod}"
25+
FORCE="${FORCE:-false}"
26+
27+
echo "Project: $PROJECT | Region: $REGION | Service: $SERVICE | Job: $JOB"
28+
29+
describe() {
30+
gcloud run services describe "$SERVICE" \
31+
--project "$PROJECT" --region "$REGION" "$@"
32+
}
33+
34+
echo "Reading config from deployed service..."
35+
IMAGE="$(describe --format='value(spec.template.spec.containers[0].image)')"
36+
CLOUDSQL="$(describe --format='value(spec.template.metadata.annotations["run.googleapis.com/cloudsql-instances"])')"
37+
38+
if [[ -z "$IMAGE" ]]; then
39+
echo "ERROR: could not read image from service $SERVICE" >&2
40+
exit 1
41+
fi
42+
echo " image: $IMAGE"
43+
echo " cloudsql: ${CLOUDSQL:-<none>}"
44+
45+
# Plain env vars (name=value), comma-separated.
46+
ENV_VARS="$(describe --format=json \
47+
| node -e '
48+
const d = JSON.parse(require("fs").readFileSync(0, "utf8"));
49+
const env = d.spec.template.spec.containers[0].env || [];
50+
const plain = env.filter(e => e.value !== undefined).map(e => `${e.name}=${e.value}`);
51+
process.stdout.write(plain.join("@@"));
52+
')"
53+
54+
# Secret-backed env vars (name=secretName:version), comma-separated.
55+
SECRETS="$(describe --format=json \
56+
| node -e '
57+
const d = JSON.parse(require("fs").readFileSync(0, "utf8"));
58+
const env = d.spec.template.spec.containers[0].env || [];
59+
const secrets = env
60+
.filter(e => e.valueFrom && e.valueFrom.secretKeyRef)
61+
.map(e => `${e.name}=${e.valueFrom.secretKeyRef.name}:${e.valueFrom.secretKeyRef.key}`);
62+
process.stdout.write(secrets.join("@@"));
63+
')"
64+
65+
# Build the seed command (override the container entrypoint).
66+
ARGS="backend/dist/db/seed.js"
67+
if [[ "$FORCE" == "true" ]]; then
68+
ARGS="$ARGS,--force"
69+
fi
70+
71+
# Assemble gcloud flags.
72+
FLAGS=(
73+
--project "$PROJECT"
74+
--region "$REGION"
75+
--image "$IMAGE"
76+
--command node
77+
--args "$ARGS"
78+
--max-retries 0
79+
--task-timeout 600
80+
)
81+
[[ -n "$CLOUDSQL" ]] && FLAGS+=(--set-cloudsql-instances "$CLOUDSQL")
82+
[[ -n "$ENV_VARS" ]] && FLAGS+=(--set-env-vars "^@@^${ENV_VARS}")
83+
[[ -n "$SECRETS" ]] && FLAGS+=(--set-secrets "^@@^${SECRETS}")
84+
85+
if gcloud run jobs describe "$JOB" --project "$PROJECT" --region "$REGION" >/dev/null 2>&1; then
86+
echo "Updating existing job $JOB..."
87+
gcloud run jobs update "$JOB" "${FLAGS[@]}"
88+
else
89+
echo "Creating job $JOB..."
90+
gcloud run jobs create "$JOB" "${FLAGS[@]}"
91+
fi
92+
93+
echo "Executing job (this seeds the production DB)..."
94+
gcloud run jobs execute "$JOB" --project "$PROJECT" --region "$REGION" --wait
95+
96+
echo "Done. Check logs with:"
97+
echo " gcloud run jobs executions list --job $JOB --project $PROJECT --region $REGION"

backend/src/db/seed.ts

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { faker } from '@faker-js/faker';
2+
import { DataSource } from 'typeorm';
3+
import { dataSource } from '../datasource';
4+
import { City } from '../cities/entities/city.entity';
5+
import { Location } from '../locations/entities/location.entity';
6+
import { Category } from '../categories/entities/category.entity';
7+
import { Tag } from '../tags/entities/tag.entity';
8+
import { Item } from '../items/entities/item.entity';
9+
import {
10+
ItemCopy,
11+
ItemCondition,
12+
} from '../item-copies/entities/item-copy.entity';
13+
14+
// Deterministic data so repeated runs / environments produce the same set.
15+
faker.seed(20240601);
16+
17+
const CITY_NAMES = ['Praha', 'Brno', 'Ostrava', 'Plzeň'];
18+
19+
const CATEGORY_NAMES = [
20+
'Electronics',
21+
'Books',
22+
'Tools',
23+
'Office',
24+
'Sports',
25+
'Furniture',
26+
];
27+
28+
const TAG_NAMES = [
29+
'fragile',
30+
'heavy',
31+
'portable',
32+
'new',
33+
'refurbished',
34+
'shared',
35+
'high-demand',
36+
];
37+
38+
const CONDITIONS = [
39+
ItemCondition.Good,
40+
ItemCondition.Good,
41+
ItemCondition.Good,
42+
ItemCondition.Damaged,
43+
ItemCondition.Lost,
44+
];
45+
46+
/** Look up an existing row by a unique column, otherwise create it. */
47+
async function upsert<T extends object>(
48+
ds: DataSource,
49+
entity: new () => T,
50+
where: Partial<T>,
51+
data: Partial<T>
52+
): Promise<T> {
53+
const repo = ds.getRepository(entity);
54+
const existing = await repo.findOne({ where: where as never });
55+
if (existing) {
56+
return existing;
57+
}
58+
const created = repo.create({ ...where, ...data } as never) as T;
59+
return repo.save(created as never) as Promise<T>;
60+
}
61+
62+
export async function seed(ds: DataSource): Promise<void> {
63+
const force =
64+
process.argv.includes('--force') || process.env.SEED_FORCE === 'true';
65+
66+
const cityRepo = ds.getRepository(City);
67+
const existingCities = await cityRepo.count();
68+
if (existingCities > 0 && !force) {
69+
console.log(
70+
`Database already contains ${existingCities} cities. Skipping seed. ` +
71+
`Pass --force (or SEED_FORCE=true) to seed anyway.`
72+
);
73+
return;
74+
}
75+
76+
console.log('Seeding cities and locations...');
77+
const locations: Location[] = [];
78+
for (const name of CITY_NAMES) {
79+
const city = await upsert(ds, City, { name }, { archived_at: null });
80+
for (let i = 0; i < faker.number.int({ min: 1, max: 3 }); i++) {
81+
const locName = `${name} - ${faker.location.street()}`;
82+
const location = await upsert(
83+
ds,
84+
Location,
85+
{ name: locName },
86+
{ city_id: city.id, archived_at: null }
87+
);
88+
locations.push(location);
89+
}
90+
}
91+
92+
console.log('Seeding categories...');
93+
const categories: Category[] = [];
94+
for (const name of CATEGORY_NAMES) {
95+
categories.push(
96+
await upsert(ds, Category, { name }, { archived_at: null })
97+
);
98+
}
99+
100+
console.log('Seeding tags...');
101+
const tags: Tag[] = [];
102+
for (const name of TAG_NAMES) {
103+
tags.push(await upsert(ds, Tag, { name }, {}));
104+
}
105+
106+
console.log('Seeding items and copies...');
107+
const itemRepo = ds.getRepository(Item);
108+
const copyRepo = ds.getRepository(ItemCopy);
109+
let copyCountTotal = 0;
110+
for (let i = 0; i < 25; i++) {
111+
const item = itemRepo.create({
112+
name: faker.commerce.productName(),
113+
description: faker.commerce.productDescription(),
114+
image_url: null,
115+
default_loan_days: faker.helpers.arrayElement([7, 14, 30]),
116+
archived_at: null,
117+
categories: faker.helpers.arrayElements(
118+
categories,
119+
faker.number.int({ min: 1, max: 3 })
120+
),
121+
tags: faker.helpers.arrayElements(
122+
tags,
123+
faker.number.int({ min: 0, max: 3 })
124+
),
125+
});
126+
const savedItem = await itemRepo.save(item);
127+
128+
const copyCount = faker.number.int({ min: 1, max: 4 });
129+
for (let c = 0; c < copyCount; c++) {
130+
const copy = copyRepo.create({
131+
item_id: savedItem.id,
132+
location_id: faker.helpers.arrayElement(locations).id,
133+
condition: faker.helpers.arrayElement(CONDITIONS),
134+
archived_at: null,
135+
});
136+
await copyRepo.save(copy);
137+
copyCountTotal++;
138+
}
139+
}
140+
141+
console.log('Seed complete:');
142+
console.log(` cities: ${await cityRepo.count()}`);
143+
console.log(` locations: ${await ds.getRepository(Location).count()}`);
144+
console.log(` categories: ${await ds.getRepository(Category).count()}`);
145+
console.log(` tags: ${await ds.getRepository(Tag).count()}`);
146+
console.log(` items: ${await itemRepo.count()}`);
147+
console.log(` copies: ${copyCountTotal}`);
148+
}
149+
150+
async function main(): Promise<void> {
151+
await dataSource.initialize();
152+
try {
153+
await seed(dataSource);
154+
} finally {
155+
await dataSource.destroy();
156+
}
157+
}
158+
159+
if (require.main === module) {
160+
main().catch(err => {
161+
console.error('Seed failed:', err);
162+
process.exitCode = 1;
163+
});
164+
}

0 commit comments

Comments
 (0)