All checks were successful
Deploy production / deploy (push) Successful in 2m48s
242 lines
9.1 KiB
TypeScript
242 lines
9.1 KiB
TypeScript
import postgres from 'postgres';
|
|
|
|
const expectedOrigin = process.argv[2];
|
|
if (
|
|
!expectedOrigin ||
|
|
!['https://stanrunge.dev', 'https://staging.stanrunge.dev'].includes(expectedOrigin)
|
|
) {
|
|
throw new Error('Expected a known production or staging origin');
|
|
}
|
|
if (!process.env.DB_URL) throw new Error('DB_URL is missing');
|
|
if (!process.env.BETTER_AUTH_SECRET || process.env.BETTER_AUTH_SECRET.length < 32) {
|
|
throw new Error('BETTER_AUTH_SECRET must be at least 32 characters');
|
|
}
|
|
if (process.env.ORIGIN !== expectedOrigin || process.env.BETTER_AUTH_URL !== expectedOrigin) {
|
|
throw new Error('ORIGIN and BETTER_AUTH_URL must both match the public site origin');
|
|
}
|
|
const client = postgres(process.env.DB_URL, { max: 1, connect_timeout: 10 });
|
|
try {
|
|
await client`select 1`;
|
|
// Schema layout after the reorg (auth/money/house/ref/games/cs/animaldex/site/meta/lists).
|
|
const required = [
|
|
'auth.user',
|
|
'auth.session',
|
|
'auth.account',
|
|
'auth.verification',
|
|
'money.accounts',
|
|
'money.transactions',
|
|
'money.recurring',
|
|
'money.forecasts',
|
|
'money.utility_settings',
|
|
'money.utility_readings',
|
|
'money.wishlist',
|
|
'money.insurances',
|
|
'money.projection_settings',
|
|
'house.listings',
|
|
'house.price_history',
|
|
'house.alert_rules',
|
|
'house.worker_runs',
|
|
'ref.pokemon',
|
|
'ref.pokemon_games',
|
|
'ref.pokemon_game_entries',
|
|
'ref.pokemon_locations',
|
|
'ref.pokemon_encounters',
|
|
'ref.pokemon_evolutions',
|
|
'ref.pokemon_research',
|
|
'ref.pokemon_dex_numbers',
|
|
'ref.pokemon_moves',
|
|
'ref.pokemon_learnsets',
|
|
'ref.pokemon_game_research',
|
|
'ref.tracker_games',
|
|
'ref.tracker_items',
|
|
'ref.tracker_challenges',
|
|
'games.catch_em_all_entries',
|
|
'games.catch_em_all_games',
|
|
'games.catch_em_all_settings',
|
|
'games.pokemon_journeys',
|
|
'games.pokemon_catches',
|
|
'games.pokemon_milestones',
|
|
'games.pokemon_seen',
|
|
'games.tracker_progress',
|
|
'games.tracker_ranks',
|
|
'games.tracker_weapon_ranks',
|
|
'games.tracker_challenge_progress',
|
|
'games.osu_entries',
|
|
'cs.items',
|
|
'cs.price_observations',
|
|
'cs.item_dimensions',
|
|
'cs.price_streams',
|
|
'animaldex.animals',
|
|
'animaldex.sightings',
|
|
'animaldex.sighting_photos',
|
|
'site.blog_articles',
|
|
'site.tasks',
|
|
'site.topics',
|
|
'site.task_topics',
|
|
'meta.tables',
|
|
'meta.columns'
|
|
];
|
|
const missing = await client<{ name: string }[]>`
|
|
select name from unnest(${required}::text[]) as name where to_regclass(name) is null`;
|
|
if (missing.length)
|
|
throw new Error(`Schema reorg/migrations missing: ${missing.map((m) => m.name).join(', ')}`);
|
|
const [schema] = await client`
|
|
select
|
|
(select count(*) from information_schema.columns
|
|
where table_schema = 'auth' and table_name = 'user' and column_name = 'role') as user_role,
|
|
(select count(*) from information_schema.columns
|
|
where table_schema = 'money' and table_name = 'projection_settings'
|
|
and column_name = 'include_recurring' and data_type = 'boolean') as include_recurring_bool,
|
|
(select count(*) from information_schema.columns
|
|
where table_schema = 'games' and table_name = 'osu_entries'
|
|
and column_name in ('accuracy', 'pp', 'rate') and data_type = 'numeric') as osu_numeric,
|
|
(select count(*) from meta.tables where kind = 'list') as lists,
|
|
has_function_privilege('meta.create_list(text, text, text, boolean, jsonb, text, text, text, integer)', 'execute') as can_create_list,
|
|
has_schema_privilege('money', 'create') as app_has_ddl
|
|
`;
|
|
if (
|
|
!Number(schema.user_role) ||
|
|
!Number(schema.include_recurring_bool) ||
|
|
Number(schema.osu_numeric) !== 3 ||
|
|
!Number(schema.lists) ||
|
|
!schema.can_create_list
|
|
) {
|
|
throw new Error(
|
|
'The schema reorg has not been applied to this database (or grants are missing)'
|
|
);
|
|
}
|
|
if (schema.app_has_ddl)
|
|
throw new Error('DB_URL must use the app role (personal_app), not the schema owner');
|
|
// Private health schema: the app role reads and writes it, nobody else (not even personal_ro).
|
|
const [health] = await client`
|
|
select
|
|
count(*) filter (where has_table_privilege(c.oid, 'select, insert, update, delete')) as app_rw,
|
|
count(*) filter (
|
|
where exists (
|
|
select 1 from aclexplode(c.relacl) a join pg_roles r on r.oid = a.grantee
|
|
where r.rolname = 'personal_ro'
|
|
)
|
|
) as ro_read,
|
|
count(*) as tables,
|
|
has_schema_privilege('public', 'health', 'usage') as public_usage
|
|
from pg_class c join pg_namespace n on n.oid = c.relnamespace
|
|
where n.nspname = 'health' and c.relkind = 'r'
|
|
`;
|
|
if (
|
|
Number(health.tables) !== 7 ||
|
|
Number(health.app_rw) !== 7 ||
|
|
Number(health.ro_read) !== 0 ||
|
|
health.public_usage
|
|
)
|
|
throw new Error(`health schema grants are wrong: ${JSON.stringify(health)}`);
|
|
// Private task tracker: same rules as health (app role read/write, personal_ro none).
|
|
const [tasks] = await client`
|
|
select
|
|
count(*) filter (where has_table_privilege(c.oid, 'select, insert, update, delete')) as app_rw,
|
|
count(*) filter (
|
|
where exists (
|
|
select 1 from aclexplode(c.relacl) a join pg_roles r on r.oid = a.grantee
|
|
where r.rolname = 'personal_ro'
|
|
)
|
|
) as ro_read,
|
|
count(*) as tables,
|
|
has_schema_privilege('public', 'tasks', 'usage') as public_usage
|
|
from pg_class c join pg_namespace n on n.oid = c.relnamespace
|
|
where n.nspname = 'tasks' and c.relkind = 'r'
|
|
`;
|
|
if (
|
|
Number(tasks.tables) !== 4 ||
|
|
Number(tasks.app_rw) !== 4 ||
|
|
Number(tasks.ro_read) !== 0 ||
|
|
tasks.public_usage
|
|
)
|
|
throw new Error(`tasks schema grants are wrong: ${JSON.stringify(tasks)}`);
|
|
// Private grocery tracker: same rules (app role read/write, personal_ro none).
|
|
const [groceries] = await client`
|
|
select
|
|
count(*) filter (where has_table_privilege(c.oid, 'select, insert, update, delete')) as app_rw,
|
|
count(*) filter (
|
|
where exists (
|
|
select 1 from aclexplode(c.relacl) a join pg_roles r on r.oid = a.grantee
|
|
where r.rolname = 'personal_ro'
|
|
)
|
|
) as ro_read,
|
|
count(*) as tables,
|
|
has_schema_privilege('public', 'groceries', 'usage') as public_usage
|
|
from pg_class c join pg_namespace n on n.oid = c.relnamespace
|
|
where n.nspname = 'groceries' and c.relkind = 'r'
|
|
`;
|
|
if (
|
|
Number(groceries.tables) !== 5 ||
|
|
Number(groceries.app_rw) !== 5 ||
|
|
Number(groceries.ro_read) !== 0 ||
|
|
groceries.public_usage
|
|
)
|
|
throw new Error(`groceries schema grants are wrong: ${JSON.stringify(groceries)}`);
|
|
// Energy tracker reads the ned-collector tables (SELECT only).
|
|
const [energy] = await client`
|
|
select count(*) as hours, max(valid_from) as latest from energy.ned_utilization
|
|
where granularity = 'Hour' and classification = 'Current'
|
|
and valid_from > now() - interval '7 days'
|
|
`;
|
|
if (!Number(energy.hours))
|
|
console.warn('Warning: energy.ned_utilization has no hourly data from the last 7 days');
|
|
console.log(
|
|
`health: ${health.tables} tables, tasks: ${tasks.tables} tables, groceries: ${groceries.tables} tables, app read/write, personal_ro none; energy: ${energy.hours} hourly rows, latest ${energy.latest ? new Date(energy.latest).toISOString() : 'none'}`
|
|
);
|
|
const [catalog] = await client`
|
|
select
|
|
(select count(*) from ref.pokemon) as pokemon,
|
|
(select count(*) from ref.pokemon_games where source_id is not null) as games,
|
|
(select count(*) from ref.pokemon_game_entries) as entries,
|
|
(select count(*) from ref.pokemon_encounters) as encounters,
|
|
(select count(*) from ref.tracker_games where franchise = 'call-of-duty') as cod_games,
|
|
(select count(*) from ref.pokemon_research) as pokemon_research,
|
|
(select count(*) from ref.pokemon_moves) as pokemon_moves,
|
|
(select count(*) from ref.pokemon_learnsets) as pokemon_learnsets,
|
|
(select count(*) from ref.pokemon_dex_numbers) as pokemon_dex_numbers,
|
|
(select count(*) from ref.tracker_challenges) as tracker_challenges
|
|
`;
|
|
if (
|
|
Number(catalog.pokemon) < 1025 ||
|
|
Number(catalog.games) < 53 ||
|
|
Number(catalog.entries) < 27267 ||
|
|
Number(catalog.encounters) < 60000
|
|
) {
|
|
throw new Error('Pokémon catalog has not been seeded in this database');
|
|
}
|
|
if (
|
|
Number(catalog.pokemon_research) < 1025 ||
|
|
Number(catalog.pokemon_moves) < 937 ||
|
|
Number(catalog.pokemon_learnsets) < 11000 ||
|
|
Number(catalog.pokemon_dex_numbers) < 16000 ||
|
|
Number(catalog.tracker_challenges) < 3600
|
|
)
|
|
throw new Error(
|
|
'Run db:seed:research and db:seed:challenges before deploying the tracker expansion'
|
|
);
|
|
if (Number(catalog.cod_games) < 7) {
|
|
throw new Error('Call of Duty catalog has not been seeded in this database');
|
|
}
|
|
const [bo2] = await client`select rules,
|
|
(select count(*)::int from ref.tracker_items i where i.game_id=g.id and i.kind='weapon') as weapons,
|
|
(select count(*)::int from ref.tracker_items i where i.game_id=g.id and i.kind='weapon' and i.icon is not null) as pictures
|
|
from ref.tracker_games g where slug='black-ops-2'`;
|
|
if (
|
|
!bo2 ||
|
|
bo2.rules.rankCaps?.weaponPrestige !== 2 ||
|
|
bo2.rules.rankCaps?.weaponLevelsByClass?.['Assault Rifles'] !== 17 ||
|
|
bo2.weapons < 40 ||
|
|
bo2.pictures < 39
|
|
) {
|
|
throw new Error('Seed the updated BO2 catalog and artwork before deploying');
|
|
}
|
|
} finally {
|
|
await client.end();
|
|
}
|
|
const google = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
|
|
const osu = !!(process.env.OSU_CLIENT_ID && process.env.OSU_CLIENT_SECRET);
|
|
console.log(
|
|
`Preflight passed for ${expectedOrigin}; Google ${google ? 'configured' : 'disabled'}, osu! ${osu ? 'configured' : 'disabled'}`
|
|
);
|