13 KiB
13 KiB
Database Subagent Reference
Quick Navigation
| Resource | Path |
|---|---|
| Master Schema | sql/master_schema_rollup.sql |
| Initial Schema | sql/initial_schema.sql |
| Migrations | sql/migrations/*.sql |
| Triggers/Functions | sql/Initial_triggers_and_functions.sql |
| Initial Data | sql/initial_data.sql |
| Drop Script | sql/drop_tables.sql |
| Repositories | src/services/db/*.db.ts |
| Connection | src/services/db/connection.db.ts |
| Errors | src/services/db/errors.db.ts |
Database Credentials
Environments
| Environment | User | Database | Host |
|---|---|---|---|
| Production | flyer_crawler_prod |
flyer-crawler-prod |
DB_HOST secret |
| Test | flyer_crawler_test |
flyer-crawler-test |
DB_HOST secret |
| Dev Container | postgres |
flyer_crawler_dev |
postgres (container name) |
Connection (Dev Container)
# Inside container
psql -U postgres -d flyer_crawler_dev
# From Windows via Podman
podman exec -it flyer-crawler-dev psql -U postgres -d flyer_crawler_dev
Connection (Production/Test via SSH)
# SSH to server, then:
PGPASSWORD=$DB_PASSWORD psql -h $DB_HOST -U $DB_USER -d $DB_NAME
Schema Tables (Core)
| Table | Purpose | Key Columns |
|---|---|---|
users |
Authentication | user_id (UUID PK), email, password_hash |
profiles |
User data | user_id (FK), full_name, role, points |
addresses |
Normalized addresses | address_id, address_line_1, city, latitude, longitude |
stores |
Store chains | store_id, name, logo_url |
store_locations |
Physical locations | store_location_id, store_id (FK), address_id (FK) |
flyers |
Uploaded flyers | flyer_id, store_id (FK), image_url, status |
flyer_items |
Extracted deals | flyer_item_id, flyer_id (FK), name, price |
categories |
Item categories | category_id, name |
master_items |
Canonical items | master_item_id, name, category_id (FK) |
shopping_lists |
User lists | shopping_list_id, user_id (FK), name |
shopping_list_items |
List items | shopping_list_item_id, shopping_list_id (FK) |
watchlist |
Price alerts | watchlist_id, user_id (FK), search_term |
activity_log |
Audit trail | activity_log_id, user_id, action, details |
Schema Sync Rule (CRITICAL)
Both files MUST stay synchronized:
sql/master_schema_rollup.sql- Used by test DB setupsql/initial_schema.sql- Used for fresh installs
When adding columns:
- Add migration in
sql/migrations/NNN_description.sql - Add column to
master_schema_rollup.sql - Add column to
initial_schema.sql - Test DB uses
master_schema_rollup.sql- out-of-sync = test failures
Migration Pattern
Creating a Migration
-- sql/migrations/NNN_descriptive_name.sql
-- Add column with default
ALTER TABLE public.flyers
ADD COLUMN IF NOT EXISTS new_column TEXT DEFAULT 'value';
-- Add index
CREATE INDEX IF NOT EXISTS idx_flyers_new_column
ON public.flyers(new_column);
-- Update schema_info
UPDATE public.schema_info
SET schema_hash = 'new_hash', updated_at = now()
WHERE environment = 'production';
Running Migrations
# Via psql
PGPASSWORD=$DB_PASSWORD psql -h $DB_HOST -U $DB_USER -d $DB_NAME -f sql/migrations/NNN_description.sql
# In CI/CD - migrations are checked via schema hash
Repository Pattern (ADR-034)
Method Naming Convention
| Prefix | Behavior | Return Type |
|---|---|---|
get* |
Throws NotFoundError if missing |
Entity |
find* |
Returns null if missing |
Entity | null |
list* |
Returns empty array if none | Entity[] |
create* |
Creates new record | Entity |
update* |
Updates existing record | Entity |
delete* |
Removes record | void |
count* |
Returns count | number |
Repository Template
// src/services/db/entity.db.ts
import { getPool } from './connection.db';
import { handleDbError, NotFoundError } from './errors.db';
import type { PoolClient } from 'pg';
import type { Logger } from 'pino';
export async function getEntityById(
id: string,
logger: Logger,
client?: PoolClient,
): Promise<Entity> {
const pool = client || getPool();
try {
const result = await pool.query('SELECT * FROM public.entities WHERE entity_id = $1', [id]);
if (result.rows.length === 0) {
throw new NotFoundError('Entity not found');
}
return result.rows[0];
} catch (error) {
handleDbError(error, logger, 'Failed to get entity', { id });
}
}
export async function findEntityByName(
name: string,
logger: Logger,
client?: PoolClient,
): Promise<Entity | null> {
const pool = client || getPool();
try {
const result = await pool.query('SELECT * FROM public.entities WHERE name = $1', [name]);
return result.rows[0] || null;
} catch (error) {
handleDbError(error, logger, 'Failed to find entity', { name });
}
}
export async function listEntities(logger: Logger, client?: PoolClient): Promise<Entity[]> {
const pool = client || getPool();
try {
const result = await pool.query('SELECT * FROM public.entities ORDER BY name');
return result.rows;
} catch (error) {
handleDbError(error, logger, 'Failed to list entities', {});
}
}
Transaction Pattern (ADR-002)
import { withTransaction } from './connection.db';
const result = await withTransaction(async (client) => {
// All queries use same client
const user = await userRepo.createUser(data, logger, client);
const profile = await profileRepo.createProfile(user.user_id, profileData, logger, client);
await activityRepo.logActivity('user_created', user.user_id, logger, client);
return { user, profile };
});
// Commits on success, rolls back on any error
Error Handling (ADR-001)
Error Types
| Error | PostgreSQL Code | HTTP Status | Use Case |
|---|---|---|---|
UniqueConstraintError |
23505 |
409 | Duplicate record |
ForeignKeyConstraintError |
23503 |
400 | Referenced record missing |
NotNullConstraintError |
23502 |
400 | Required field null |
CheckConstraintError |
23514 |
400 | Check constraint violated |
InvalidTextRepresentationError |
22P02 |
400 | Invalid type format |
NumericValueOutOfRangeError |
22003 |
400 | Number out of range |
NotFoundError |
- | 404 | Record not found |
ForbiddenError |
- | 403 | Access denied |
Using handleDbError
import { handleDbError, NotFoundError } from './errors.db';
try {
const result = await pool.query('INSERT INTO ...', [data]);
if (result.rows.length === 0) throw new NotFoundError('Entity not found');
return result.rows[0];
} catch (error) {
handleDbError(
error,
logger,
'Failed to create entity',
{ data },
{
uniqueMessage: 'Entity with this name already exists',
fkMessage: 'Referenced category does not exist',
defaultMessage: 'Failed to create entity',
},
);
}
Connection Pool
import { getPool, getPoolStatus } from './connection.db';
// Get pool (singleton)
const pool = getPool();
// Check pool status
const status = getPoolStatus();
// { totalCount: 20, idleCount: 15, waitingCount: 0 }
Pool Configuration
| Setting | Value | Purpose |
|---|---|---|
max |
20 | Max clients in pool |
idleTimeoutMillis |
30000 | Idle client timeout |
connectionTimeoutMillis |
2000 | Connection timeout |
Common Queries
Paginated List
const result = await pool.query(
`SELECT * FROM public.flyers
ORDER BY created_at DESC
LIMIT $1 OFFSET $2`,
[limit, (page - 1) * limit],
);
const countResult = await pool.query('SELECT COUNT(*) FROM public.flyers');
const total = parseInt(countResult.rows[0].count, 10);
Spatial Query (Find Nearby)
const result = await pool.query(
`SELECT sl.*, a.*,
ST_Distance(a.location, ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography) as distance
FROM public.store_locations sl
JOIN public.addresses a ON sl.address_id = a.address_id
WHERE ST_DWithin(a.location, ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography, $3)
ORDER BY distance`,
[longitude, latitude, radiusMeters],
);
Upsert Pattern
const result = await pool.query(
`INSERT INTO public.stores (name, logo_url)
VALUES ($1, $2)
ON CONFLICT (name) DO UPDATE SET
logo_url = EXCLUDED.logo_url,
updated_at = now()
RETURNING *`,
[name, logoUrl],
);
Database Reset Commands
Dev Container
# Reset dev database (runs seed script)
podman exec -it flyer-crawler-dev npm run db:reset:dev
# Reset test database
podman exec -it flyer-crawler-dev npm run db:reset:test
Manual SQL
# Drop all tables
podman exec -it flyer-crawler-dev psql -U postgres -d flyer_crawler_dev -f /app/sql/drop_tables.sql
# Recreate schema
podman exec -it flyer-crawler-dev psql -U postgres -d flyer_crawler_dev -f /app/sql/master_schema_rollup.sql
# Load initial data
podman exec -it flyer-crawler-dev psql -U postgres -d flyer_crawler_dev -f /app/sql/initial_data.sql
Database Users Setup
-- Create database and user (as postgres superuser)
CREATE DATABASE "flyer-crawler-test";
CREATE USER flyer_crawler_test WITH PASSWORD 'password';
ALTER DATABASE "flyer-crawler-test" OWNER TO flyer_crawler_test;
-- Grant permissions
\c "flyer-crawler-test"
ALTER SCHEMA public OWNER TO flyer_crawler_test;
GRANT CREATE, USAGE ON SCHEMA public TO flyer_crawler_test;
-- Required extensions
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "postgis";
-- Verify permissions
\dn+ public
-- Should show 'UC' for the user
Repository Files
| Repository | Domain | Path |
|---|---|---|
user.db.ts |
Users, profiles | src/services/db/user.db.ts |
flyer.db.ts |
Flyers, processing | src/services/db/flyer.db.ts |
store.db.ts |
Stores | src/services/db/store.db.ts |
storeLocation.db.ts |
Store locations | src/services/db/storeLocation.db.ts |
address.db.ts |
Addresses | src/services/db/address.db.ts |
category.db.ts |
Categories | src/services/db/category.db.ts |
shopping.db.ts |
Shopping lists, watchlists | src/services/db/shopping.db.ts |
price.db.ts |
Price history | src/services/db/price.db.ts |
gamification.db.ts |
Achievements, points | src/services/db/gamification.db.ts |
notification.db.ts |
Notifications | src/services/db/notification.db.ts |
recipe.db.ts |
Recipes | src/services/db/recipe.db.ts |
receipt.db.ts |
Receipts | src/services/db/receipt.db.ts |
admin.db.ts |
Admin operations | src/services/db/admin.db.ts |