Some checks failed
Deploy production / deploy (push) Failing after 9s
Schema math (parts, chapters, sections, activity), seeded with all 100 chapters (Ch 0-94, appendix A-E), 647 sections, PDF pages, exercise counts and approximate prereqs. Triggers stamp started_at/completed_at and log status/exercise/note/section changes; math.apply_progress(jsonb) applies the JSON format bots send. Private by default via MATH_PRIVATE in #lib/math (owner-only prefix + lock in every tracker list). Overall + per-part progress, current chapter, prereq hints, quick status buttons, exercises stepper, notes, optional section ticks, activity log, dependency chart. Deploy preflight checks the math schema (4 tables, 100 chapters).
383 lines
15 KiB
TypeScript
383 lines
15 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) !== 5 ||
|
|
Number(tasks.app_rw) !== 5 ||
|
|
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 (
|
|
// 10 = stores, items, item_prices, groups, import_runs, categories, manual_prices, synonyms,
|
|
// stats_snapshots, item_sizes (more is fine as long as the app can read/write all of them).
|
|
Number(groceries.tables) < 10 ||
|
|
Number(groceries.app_rw) !== Number(groceries.tables) ||
|
|
Number(groceries.ro_read) !== 0 ||
|
|
groceries.public_usage
|
|
)
|
|
throw new Error(`groceries schema grants are wrong: ${JSON.stringify(groceries)}`);
|
|
// Public /stats section: open data, so the app role (and the host-side stats collector, which
|
|
// uses it) reads and writes, and personal_ro may read. Bump the count with every new stats table.
|
|
const STATS_TABLES = 55;
|
|
const [stats] = await client`
|
|
select
|
|
count(*) filter (where has_table_privilege(c.oid, 'select, insert, update, delete')) as app_rw,
|
|
count(*) as tables
|
|
from pg_class c join pg_namespace n on n.oid = c.relnamespace
|
|
where n.nspname = 'stats' and c.relkind = 'r'
|
|
`;
|
|
if (Number(stats.tables) !== STATS_TABLES || Number(stats.app_rw) !== STATS_TABLES)
|
|
throw new Error(`stats schema tables/grants are wrong: ${JSON.stringify(stats)}`);
|
|
// Private-by-default AI tracker (news public in the app; bots and usage owner-only): app role
|
|
// read/write, personal_ro none. 5 = news_items, bot_status, usage_entries,
|
|
// provider_setup_providers, provider_setup_entries (setup tab, owner-only).
|
|
const AI_TABLES = 5;
|
|
const [ai] = 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', 'ai', 'usage') as public_usage
|
|
from pg_class c join pg_namespace n on n.oid = c.relnamespace
|
|
where n.nspname = 'ai' and c.relkind = 'r'
|
|
`;
|
|
if (
|
|
Number(ai.tables) !== AI_TABLES ||
|
|
Number(ai.app_rw) !== AI_TABLES ||
|
|
Number(ai.ro_read) !== 0 ||
|
|
ai.public_usage
|
|
)
|
|
throw new Error(`ai schema tables/grants are wrong: ${JSON.stringify(ai)}`);
|
|
// Movies & TV tracker (public pages, owner-only edits in the app): app role read/write,
|
|
// personal_ro none. 5 = titles, episodes, plays, recs, settings.
|
|
const MOVIES_TABLES = 5;
|
|
const [movies] = 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', 'movies', 'usage') as public_usage
|
|
from pg_class c join pg_namespace n on n.oid = c.relnamespace
|
|
where n.nspname = 'movies' and c.relkind = 'r'
|
|
`;
|
|
if (
|
|
Number(movies.tables) !== MOVIES_TABLES ||
|
|
Number(movies.app_rw) !== MOVIES_TABLES ||
|
|
Number(movies.ro_read) !== 0 ||
|
|
movies.public_usage
|
|
)
|
|
throw new Error(`movies schema tables/grants are wrong: ${JSON.stringify(movies)}`);
|
|
// Worldwide todo list (public pages, requests and owner approvals go through the app): app
|
|
// role read/write, personal_ro none. 3 = categories, tasks, change_requests.
|
|
const WORLDTODO_TABLES = 3;
|
|
const [worldtodo] = 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', 'worldtodo', 'usage') as public_usage
|
|
from pg_class c join pg_namespace n on n.oid = c.relnamespace
|
|
where n.nspname = 'worldtodo' and c.relkind = 'r'
|
|
`;
|
|
if (
|
|
Number(worldtodo.tables) !== WORLDTODO_TABLES ||
|
|
Number(worldtodo.app_rw) !== WORLDTODO_TABLES ||
|
|
Number(worldtodo.ro_read) !== 0 ||
|
|
worldtodo.public_usage
|
|
)
|
|
throw new Error(`worldtodo schema tables/grants are wrong: ${JSON.stringify(worldtodo)}`);
|
|
const [{ n: worldtodoTasks }] = await client`select count(*)::int as n from worldtodo.tasks`;
|
|
if (worldtodoTasks < 68)
|
|
throw new Error(`worldtodo.tasks has ${worldtodoTasks} rows, expected the 68 seed tasks`);
|
|
// Notes (always private; only a search/link index of the Obsidian vault): app role read/write,
|
|
// personal_ro none. 2 = files, links.
|
|
const NOTES_TABLES = 2;
|
|
const [notes] = 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', 'notes', 'usage') as public_usage
|
|
from pg_class c join pg_namespace n on n.oid = c.relnamespace
|
|
where n.nspname = 'notes' and c.relkind = 'r'
|
|
`;
|
|
if (
|
|
Number(notes.tables) !== NOTES_TABLES ||
|
|
Number(notes.app_rw) !== NOTES_TABLES ||
|
|
Number(notes.ro_read) !== 0 ||
|
|
notes.public_usage
|
|
)
|
|
throw new Error(`notes schema tables/grants are wrong: ${JSON.stringify(notes)}`);
|
|
// Math (the Napkin tracker, private by default): app role read/write, personal_ro none.
|
|
// 4 = parts, chapters, sections, activity; the migration seeds all 100 chapters.
|
|
const MATH_TABLES = 4;
|
|
const [math] = 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', 'math', 'usage') as public_usage
|
|
from pg_class c join pg_namespace n on n.oid = c.relnamespace
|
|
where n.nspname = 'math' and c.relkind = 'r'
|
|
`;
|
|
if (
|
|
Number(math.tables) !== MATH_TABLES ||
|
|
Number(math.app_rw) !== MATH_TABLES ||
|
|
Number(math.ro_read) !== 0 ||
|
|
math.public_usage
|
|
)
|
|
throw new Error(`math schema tables/grants are wrong: ${JSON.stringify(math)}`);
|
|
const [{ n: mathChapters }] = await client`select count(*)::int as n from math.chapters`;
|
|
if (mathChapters !== 100)
|
|
throw new Error(`math.chapters has ${mathChapters} rows, expected the 100 Napkin chapters`);
|
|
// 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, stats: ${stats.tables} tables, ai: ${ai.tables} tables, movies: ${movies.tables} tables, worldtodo: ${worldtodo.tables} tables (${worldtodoTasks} tasks), notes: ${notes.tables} tables, math: ${math.tables} tables (${mathChapters} chapters), 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'}`
|
|
);
|