Sprint 1: Foundation & Source Table Preparation¶
This sprint establishes the foundation for the entire Stats database refactoring. It reverts five preliminary stats migrations that are being superseded by the new coherent schema design, creates the clean stats schema, installs operational support tables for resumable backfill jobs, seeds a continent reference table, adds two computed columns (previous_move_id and km_distance) to the source geokrety.gk_moves table, creates five optimized source table indexes, and enables the btree_gist extension required by the exclusion constraint in Sprint 3. Every subsequent sprint depends on this foundation.
Master-Spec Alignment¶
The normative contract for this sprint is 00-SPEC-DRAFT-v1.obsolete.md, Sections 1, 4, 5.8, 8, 9, and 12.
- Step 1.1 is a forward-only cleanup migration that drops superseded schema objects. It does not mutate Phinx bookkeeping tables.
- Any legacy
phinxlogdeletion SQL or test text later in this draft is obsolete and non-normative. REQ-110does not require Step 1.1 to recreate the reverted exploratory migrations indown(); rollback behavior must remain consistent with the forward-only cleanup purpose of that step.
1. Purpose & Scope¶
Purpose: Provide a clean slate for the stats schema by reverting superseded migrations, then lay down infrastructure (schema, operational tables, reference data, source columns, indexes, extensions) that every subsequent sprint depends on.
Scope:
- Revert 5 preliminary stats migrations (dropping their triggers, functions, tables, indexes, and schema)
- Create the
statsschema - Create
stats.backfill_progressandstats.job_logoperational support tables - Create
stats.continent_referencetable and seed 249 ISO 3166-1 country-to-continent mappings - Add
previous_move_id(BIGINT FK) andkm_distance(NUMERIC(8,3)) columns togeokrety.gk_moves - Create 5 source table indexes on
geokrety.gk_moves(CONCURRENTLY where possible) - Enable
btree_gistextension
Intended audience: Database engineers, backend developers, AI agents executing migration steps.
Assumptions:
- PostgreSQL 16.3 with PostGIS 3.4.2, pgTAP 1.3.3
- The 5 preliminary stats migrations have been previously applied and their objects exist
- The Phinx migration framework (CakePHP) is used for all migrations
- The
geokrety.gk_movestable exists with its current schema (columns:id,geokret,lat,lon,elevation,country,distance,waypoint,author,comment,pictures_count,comments_count,username,app,app_ver,created_on_datetime,moved_on_datetime,updated_on_datetime,move_type,position) geokrety.gk_waypoints_gc,geokrety.gk_waypoints_oc,geokrety.gk_geokrety,geokrety.gk_users,geokrety.gk_pictures,geokrety.gk_lovestables exist
2. Definitions¶
| Term | Definition |
|---|---|
| Preliminary migrations | Five existing stats migrations (20260228174500 through 20260307140000) being superseded by this new schema |
| Revert | A forward-only migration that drops objects created by previous migrations, ensuring clean state |
| stats schema | PostgreSQL schema dedicated to counters, aggregates, relationships, geography/time buckets, and helper operations |
| backfill_progress | Operational table tracking cursor position and status of resumable heavy batch jobs |
| job_log | Audit table recording execution metadata for all backfill/replay/snapshot operations |
| continent_reference | Lookup table mapping ISO 3166-1 alpha-2 country codes to continent codes and names |
| previous_move_id | Column on gk_moves referencing the most recent earlier location-bearing move of the same GK |
| km_distance | Column on gk_moves storing the computed great-circle distance (km) from the previous move position |
| Location-bearing move | A move with move_type IN (0, 1, 3, 5) — DROP, GRAB, SEEN, DIP — and non-NULL position |
| btree_gist | PostgreSQL extension providing GiST operator classes for B-tree-compatible types; required for exclusion constraints |
| Move types | 0=DROP, 1=GRAB, 2=COMMENT, 3=SEEN, 4=ARCHIVE, 5=DIP |
| CONCURRENTLY | Index creation mode that does not hold an exclusive lock; allows concurrent reads/writes during build |
3. Requirements, Constraints & Guidelines¶
Requirements¶
- REQ-101: The revert migration must drop ALL objects (triggers, functions, tables, indexes, schema) created by the 5 preliminary migrations, in dependency-safe order.
- REQ-102: The
statsschema must be created fresh, owned by the current database user. - REQ-103:
stats.backfill_progressmust support resumable batch operations with cursor tracking, status management, and error recording. - REQ-104:
stats.job_logmust record execution metadata for auditability. - REQ-105:
stats.continent_referencemust contain 249 ISO 3166-1 alpha-2 country codes mapped to continent codes (AF, AN, AS, EU, NA, OC, SA). - REQ-106: The
previous_move_idcolumn must referencegeokrety.gk_moves(id)with aDEFERRABLE INITIALLY DEFERREDforeign key (for batch operations). - REQ-107: The
km_distancecolumn must beNUMERIC(8,3)for deterministic aggregation. - REQ-108: Five source table indexes must be created on
geokrety.gk_movesto optimize trigger and snapshot queries. - REQ-109: The
btree_gistextension must be enabled for the exclusion constraint needed in Sprint 3. - REQ-110: Each migration must define rollback behavior consistent with its purpose; the Step 1.1 cleanup migration is intentionally forward-only and is not required to recreate the reverted exploratory migrations.
Security Requirements¶
- SEC-101: No user-supplied input is processed in these migrations; all SQL is static DDL/DML.
- SEC-102: The revert migration uses
IF EXISTSguards to prevent errors on missing objects.
Constraints¶
- CON-101: All new tables and functions must reside in the
statsschema. - CON-102: All timestamps must use
TIMESTAMPTZ. - CON-103: Country codes must be
CHAR(2)uppercase ISO 3166-1 alpha-2. - CON-104: Migration timestamps follow
20260310100NNNformat starting from20260310100000. - CON-105: The revert migration must be a single forward migration, not a Phinx rollback command.
- CON-106: Source table columns must not require website application code changes.
- CON-107: Index creation should use
CONCURRENTLYwhere Phinx supports it (note: Phinxexecute()with raw SQL supports it; indexes cannot be created CONCURRENTLY inside a transaction).
Guidelines¶
- GUD-101: Use
IF NOT EXISTS/IF EXISTSguards for idempotent migrations. - GUD-102: Use
CASCADEwhen dropping functions to automatically drop dependent triggers. - GUD-103: Keep Phinx
up()focused — one logical step per migration file. - GUD-104: The
previous_move_idFK should beDEFERRABLE INITIALLY DEFERREDto support batch operations that may insert rows out of order. - GUD-105: Comment all tables and noteworthy columns for discoverability.
Patterns¶
- PAT-101: Schema naming:
statsfor all stats objects. - PAT-102: Table naming:
stats.<descriptive_name>(e.g.,stats.backfill_progress). - PAT-103: Index naming:
idx_<table>_<columns_or_purpose>. - PAT-104: Migration file naming:
2026031010NNNN_<snake_case_purpose>.php.
4. Interfaces & Data Contracts¶
4.1 Tables Created¶
stats.backfill_progress¶
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
job_name |
VARCHAR(100) |
NOT NULL | — | PK. Unique name for the backfill job |
target_table |
VARCHAR(100) |
NOT NULL | — | Fully-qualified table being backfilled |
min_id |
BIGINT |
NOT NULL | 0 |
Lowest source row ID in scope |
max_id |
BIGINT |
NOT NULL | — | Highest source row ID in scope |
cursor_id |
BIGINT |
NOT NULL | 0 |
Current cursor position (last processed ID) |
batch_size |
INT |
NOT NULL | 10000 |
Number of rows per batch |
status |
VARCHAR(20) |
NOT NULL | 'pending' |
Job status: pending, running, paused, completed, failed |
rows_processed |
BIGINT |
NOT NULL | 0 |
Total rows processed so far |
error_count |
INT |
NOT NULL | 0 |
Number of errors encountered |
started_at |
TIMESTAMPTZ |
NULL | — | When the job started |
last_heartbeat_at |
TIMESTAMPTZ |
NULL | — | Last heartbeat timestamp for liveness monitoring |
completed_at |
TIMESTAMPTZ |
NULL | — | When the job completed |
notes |
TEXT |
NULL | — | Human-readable notes |
last_error |
TEXT |
NULL | — | Last error message for debugging |
Primary key: (job_name)
Check constraint: status IN ('pending','running','paused','completed','failed')
stats.job_log¶
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
BIGSERIAL |
NOT NULL | auto | PK. Auto-incrementing row ID |
job_name |
VARCHAR(100) |
NOT NULL | — | Reference to backfill_progress.job_name or standalone job name |
status |
VARCHAR(20) |
NOT NULL | — | Status at log time |
metadata |
JSONB |
NULL | — | Arbitrary metadata (batch info, timing, counts) |
started_at |
TIMESTAMPTZ |
NOT NULL | now() |
When this log entry's operation started |
completed_at |
TIMESTAMPTZ |
NULL | — | When this log entry's operation completed |
Primary key: (id)
stats.continent_reference¶
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
country_alpha2 |
CHAR(2) |
NOT NULL | — | PK. ISO 3166-1 alpha-2 country code (uppercase) |
continent_code |
CHAR(2) |
NOT NULL | — | Continent code: AF, AN, AS, EU, NA, OC, SA |
continent_name |
VARCHAR(50) |
NOT NULL | — | Full continent name |
Primary key: (country_alpha2)
4.2 Source Table Columns Added¶
| Table | Column | Type | Nullable | Default | Constraint |
|---|---|---|---|---|---|
geokrety.gk_moves |
previous_move_id |
BIGINT |
NULL | — | FK → geokrety.gk_moves(id) DEFERRABLE INITIALLY DEFERRED |
geokrety.gk_moves |
km_distance |
NUMERIC(8,3) |
NULL | — | — |
4.3 Source Table Indexes Created¶
| Index Name | Table | Columns/Expression | Predicate | Purpose |
|---|---|---|---|---|
idx_gk_moves_replay_cursor |
geokrety.gk_moves |
(moved_on_datetime ASC, id ASC) |
— | Keyset pagination for replay/backfill operations |
idx_gk_moves_prev_loc_lookup |
geokrety.gk_moves |
(geokret, moved_on_datetime DESC, id DESC) |
WHERE position IS NOT NULL AND move_type IN (0,1,3,5) |
Fast previous-location lookup for distance computation |
idx_gk_moves_author_country_movedon |
geokrety.gk_moves |
(author, country, moved_on_datetime) |
— | User-country time series queries |
idx_gk_moves_geokret_country_movedon |
geokrety.gk_moves |
(geokret, country, moved_on_datetime) |
— | GK-country time series queries |
idx_gk_moves_geokret_country_id |
geokrety.gk_moves |
(geokret, country, id) |
— | GK-country snapshot cursor queries |
4.4 Objects Removed (Step 1.1 Revert)¶
| Object Type | Schema | Name | From Migration |
|---|---|---|---|
| Trigger | geokrety.gk_moves |
gk_moves_sharded_cnt_tr |
20260307140000 |
| Trigger | geokrety.gk_pictures |
gk_pictures_sharded_cnt_tr |
20260307140000 |
| Trigger | geokrety.gk_users |
gk_users_sharded_cnt_tr |
20260307140000 |
| Trigger | geokrety.gk_geokrety |
gk_geokrety_sharded_cnt_tr |
20260307140000 |
| Function | geokrety |
fn_gk_moves_sharded_counter() |
20260307140000 |
| Function | geokrety |
fn_gk_pictures_sharded_counter() |
20260307140000 |
| Function | geokrety |
fn_gk_users_sharded_counter() |
20260307140000 |
| Function | geokrety |
fn_gk_geokrety_sharded_counter() |
20260307140000 |
| Trigger | geokrety.gk_moves |
trg_update_user_countries |
20260304121000 |
| Trigger | geokrety.gk_moves |
trg_update_gk_countries_visited |
20260304121000 |
| Trigger | geokrety.gk_moves |
trg_update_country_stats |
20260304121000 |
| Trigger | geokrety.gk_moves |
trg_update_daily_activity |
20260304121000 |
| Trigger | geokrety.gk_users |
trg_update_global_counters_users |
20260304121000 |
| Function | geokrety |
fn_update_user_countries() |
20260304121000 |
| Function | geokrety |
fn_update_gk_countries_visited() |
20260304121000 |
| Function | geokrety |
fn_update_country_stats() |
20260304121000 |
| Function | geokrety |
fn_update_daily_activity() |
20260304121000 |
| Function | geokrety |
fn_update_global_counters_users() |
20260304121000 |
| Index | geokrety |
idx_gk_moves_author_country_movedon |
20260304120500 |
| Index | geokrety |
idx_gk_moves_geokret_country_movedon |
20260304120500 |
| Index | geokrety |
idx_gk_moves_geokret_country_id |
20260304120500 |
| Index | geokrety |
idx_gk_moves_replay_cursor |
20260228174500 |
| Table | stats |
entity_counters_shard |
20260307140000 |
| Table | stats |
gk_current_country |
20260304120000 |
| Table | stats |
gk_related_users |
20260304120000 |
| Table | stats |
user_related_users |
20260304120000 |
| Table | stats |
gk_stats |
20260304120000 |
| Table | stats |
user_stats |
20260304120000 |
| Table | stats |
global_counters |
20260304120000 |
| Table | stats |
daily_activity |
20260304120000 |
| Table | stats |
country_stats |
20260304120000 |
| Table | stats |
user_points_daily |
20260304120000 |
| Table | stats |
gk_countries_visited |
20260304120000 |
| Table | stats |
user_countries |
20260304120000 |
| Schema | — | stats |
20260304120000 |
5. Step-by-Step Implementation¶
Step 1.1: Revert 5 Preliminary Stats Migrations¶
What this step does: Drops ALL objects created by the five preliminary stats migrations in the correct dependency order: triggers first, then functions, then indexes, then tables, then the schema. This provides a clean foundation for the new schema design. The down() method is intentionally minimal — reverting beyond this point requires reapplying the original 5 migrations.
Important: This migration drops superseded schema objects only. It does not modify Phinx bookkeeping tables. The 14 legacy duplicate indexes dropped by migration 20260228174500 are NOT recreated — they were intentionally removed and will be replaced by the 5 optimized indexes in Step 1.6.
Migration file name: 20260310100000_revert_preliminary_stats.php
Full SQL DDL¶
-- ============================================================
-- Phase 1: Drop triggers from migration 20260307140000
-- ============================================================
DROP TRIGGER IF EXISTS gk_moves_sharded_cnt_tr ON geokrety.gk_moves;
DROP TRIGGER IF EXISTS gk_pictures_sharded_cnt_tr ON geokrety.gk_pictures;
DROP TRIGGER IF EXISTS gk_users_sharded_cnt_tr ON geokrety.gk_users;
DROP TRIGGER IF EXISTS gk_geokrety_sharded_cnt_tr ON geokrety.gk_geokrety;
-- ============================================================
-- Phase 2: Drop functions from migration 20260307140000 (CASCADE drops any remaining triggers)
-- ============================================================
DROP FUNCTION IF EXISTS geokrety.fn_gk_moves_sharded_counter() CASCADE;
DROP FUNCTION IF EXISTS geokrety.fn_gk_pictures_sharded_counter() CASCADE;
DROP FUNCTION IF EXISTS geokrety.fn_gk_users_sharded_counter() CASCADE;
DROP FUNCTION IF EXISTS geokrety.fn_gk_geokrety_sharded_counter() CASCADE;
-- ============================================================
-- Phase 3: Drop triggers from migration 20260304121000
-- ============================================================
DROP TRIGGER IF EXISTS trg_update_user_countries ON geokrety.gk_moves;
DROP TRIGGER IF EXISTS trg_update_gk_countries_visited ON geokrety.gk_moves;
DROP TRIGGER IF EXISTS trg_update_country_stats ON geokrety.gk_moves;
DROP TRIGGER IF EXISTS trg_update_daily_activity ON geokrety.gk_moves;
DROP TRIGGER IF EXISTS trg_update_global_counters_users ON geokrety.gk_users;
-- ============================================================
-- Phase 4: Drop functions from migration 20260304121000
-- ============================================================
DROP FUNCTION IF EXISTS geokrety.fn_update_user_countries() CASCADE;
DROP FUNCTION IF EXISTS geokrety.fn_update_gk_countries_visited() CASCADE;
DROP FUNCTION IF EXISTS geokrety.fn_update_country_stats() CASCADE;
DROP FUNCTION IF EXISTS geokrety.fn_update_daily_activity() CASCADE;
DROP FUNCTION IF EXISTS geokrety.fn_update_global_counters_users() CASCADE;
-- ============================================================
-- Phase 5: Drop indexes from migration 20260304120500
-- ============================================================
DROP INDEX IF EXISTS geokrety.idx_gk_moves_author_country_movedon;
DROP INDEX IF EXISTS geokrety.idx_gk_moves_geokret_country_movedon;
DROP INDEX IF EXISTS geokrety.idx_gk_moves_geokret_country_id;
-- ============================================================
-- Phase 6: Drop index from migration 20260228174500
-- ============================================================
DROP INDEX IF EXISTS geokrety.idx_gk_moves_replay_cursor;
-- ============================================================
-- Phase 7: Drop all stats tables and schema (CASCADE handles FKs)
-- ============================================================
DROP SCHEMA IF EXISTS stats CASCADE;
-- Phase 8 intentionally does not modify Phinx bookkeeping tables.
Full Phinx Migration PHP Code¶
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class RevertPreliminaryStats extends AbstractMigration
{
public function up(): void
{
// Phase 1: Drop triggers from migration 20260307140000
$this->execute('DROP TRIGGER IF EXISTS gk_moves_sharded_cnt_tr ON geokrety.gk_moves;');
$this->execute('DROP TRIGGER IF EXISTS gk_pictures_sharded_cnt_tr ON geokrety.gk_pictures;');
$this->execute('DROP TRIGGER IF EXISTS gk_users_sharded_cnt_tr ON geokrety.gk_users;');
$this->execute('DROP TRIGGER IF EXISTS gk_geokrety_sharded_cnt_tr ON geokrety.gk_geokrety;');
// Phase 2: Drop functions from migration 20260307140000
$this->execute('DROP FUNCTION IF EXISTS geokrety.fn_gk_moves_sharded_counter() CASCADE;');
$this->execute('DROP FUNCTION IF EXISTS geokrety.fn_gk_pictures_sharded_counter() CASCADE;');
$this->execute('DROP FUNCTION IF EXISTS geokrety.fn_gk_users_sharded_counter() CASCADE;');
$this->execute('DROP FUNCTION IF EXISTS geokrety.fn_gk_geokrety_sharded_counter() CASCADE;');
// Phase 3: Drop triggers from migration 20260304121000
$this->execute('DROP TRIGGER IF EXISTS trg_update_user_countries ON geokrety.gk_moves;');
$this->execute('DROP TRIGGER IF EXISTS trg_update_gk_countries_visited ON geokrety.gk_moves;');
$this->execute('DROP TRIGGER IF EXISTS trg_update_country_stats ON geokrety.gk_moves;');
$this->execute('DROP TRIGGER IF EXISTS trg_update_daily_activity ON geokrety.gk_moves;');
$this->execute('DROP TRIGGER IF EXISTS trg_update_global_counters_users ON geokrety.gk_users;');
// Phase 4: Drop functions from migration 20260304121000
$this->execute('DROP FUNCTION IF EXISTS geokrety.fn_update_user_countries() CASCADE;');
$this->execute('DROP FUNCTION IF EXISTS geokrety.fn_update_gk_countries_visited() CASCADE;');
$this->execute('DROP FUNCTION IF EXISTS geokrety.fn_update_country_stats() CASCADE;');
$this->execute('DROP FUNCTION IF EXISTS geokrety.fn_update_daily_activity() CASCADE;');
$this->execute('DROP FUNCTION IF EXISTS geokrety.fn_update_global_counters_users() CASCADE;');
// Phase 5: Drop indexes from migration 20260304120500
$this->execute('DROP INDEX IF EXISTS geokrety.idx_gk_moves_author_country_movedon;');
$this->execute('DROP INDEX IF EXISTS geokrety.idx_gk_moves_geokret_country_movedon;');
$this->execute('DROP INDEX IF EXISTS geokrety.idx_gk_moves_geokret_country_id;');
// Phase 6: Drop index from migration 20260228174500
$this->execute('DROP INDEX IF EXISTS geokrety.idx_gk_moves_replay_cursor;');
// Phase 7: Drop all stats tables and schema
$this->execute('DROP SCHEMA IF EXISTS stats CASCADE;');
// Phase 8 intentionally does not modify Phinx bookkeeping tables.
}
public function down(): void
{
// Reverting this revert would require reapplying all 5 original migrations.
// This is intentionally not automated — use the original migration files if needed.
throw new \RuntimeException(
'Cannot rollback the revert migration. '
. 'Reapply the original 5 migrations manually if needed: '
. '20260228174500, 20260304120000, 20260304120500, 20260304121000, 20260307140000'
);
}
}
SQL Usage Examples¶
-- Verify no stats schema exists after revert
SELECT schema_name FROM information_schema.schemata WHERE schema_name = 'stats';
-- Expected: 0 rows
-- Verify no stats triggers remain on gk_moves
SELECT tgname FROM pg_trigger t
JOIN pg_class c ON c.oid = t.tgrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'geokrety' AND c.relname = 'gk_moves'
AND tgname LIKE '%sharded%' OR tgname LIKE 'trg_update_%';
-- Expected: 0 rows
-- Verify no stats functions remain
SELECT proname FROM pg_proc p
JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE n.nspname = 'geokrety'
AND proname LIKE 'fn_gk_%_sharded_counter'
OR proname LIKE 'fn_update_%';
-- Expected: 0 rows
-- Phinx bookkeeping remains unchanged by this migration.
Graph/Visualization Specification¶
No visualization unlocked at this step. This is a cleanup operation.
TimescaleDB Assessment¶
NOT applicable. This step removes existing objects; no tables are created.
pgTAP Unit Tests¶
| Test ID | Test Name | Assertion |
|---|---|---|
| T-1.1.001 | stats schema does not exist | hasnt_schema('stats') |
| T-1.1.002 | No sharded counter triggers on gk_moves | hasnt_trigger('geokrety', 'gk_moves', 'gk_moves_sharded_cnt_tr') |
| T-1.1.003 | No sharded counter triggers on gk_pictures | hasnt_trigger('geokrety', 'gk_pictures', 'gk_pictures_sharded_cnt_tr') |
| T-1.1.004 | No stats update trigger on gk_moves | hasnt_trigger('geokrety', 'gk_moves', 'trg_update_user_countries') |
| T-1.1.005 | No replay cursor index | SELECT COUNT(*) = 0 FROM pg_indexes WHERE schemaname = 'geokrety' AND indexname = 'idx_gk_moves_replay_cursor' |
| T-1.1.006 | No sharded counter function | hasnt_function('geokrety', 'fn_gk_moves_sharded_counter', ARRAY[]::text[]) |
| T-1.1.007 | No stats update functions | hasnt_function('geokrety', 'fn_update_user_countries', ARRAY[]::text[]) |
| T-1.1.008 | Phinx bookkeeping tables untouched | Manual verification only; no deletion occurs |
Implementation Checklist¶
- 1. Back up current stats data if needed (the revert drops all stats tables)
- 2. Create migration file
20260310100000_revert_preliminary_stats.php - 3. Run
phinx migrate - 4. Verify
statsschema no longer exists - 5. Verify no sharded counter triggers remain
- 6. Verify no stats update triggers remain
- 7. Verify no stats functions remain
- 8. Verify Phinx bookkeeping tables were not modified
- 9. Run pgTAP tests T-1.1.001 through T-1.1.008
Step 1.2: Create stats Schema¶
What this step does: Creates the stats PostgreSQL schema that will contain all stats tables, functions, and views. This schema is the top-level namespace for the entire stats subsystem.
Migration file name: 20260310100100_create_stats_schema.php
Full SQL DDL¶
CREATE SCHEMA IF NOT EXISTS stats;
COMMENT ON SCHEMA stats IS 'GeoKrety statistics: counters, aggregates, relationships, geography/time buckets, operational helpers';
Full Phinx Migration PHP Code¶
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class CreateStatsSchema extends AbstractMigration
{
public function up(): void
{
$this->execute(<<<'SQL'
CREATE SCHEMA IF NOT EXISTS stats;
COMMENT ON SCHEMA stats IS 'GeoKrety statistics: counters, aggregates, relationships, geography/time buckets, operational helpers';
SQL
);
}
public function down(): void
{
$this->execute('DROP SCHEMA IF EXISTS stats CASCADE;');
}
}
SQL Usage Examples¶
-- Verify schema exists
SELECT schema_name FROM information_schema.schemata WHERE schema_name = 'stats';
-- Expected: 1 row with 'stats'
-- List all objects in stats schema (should be empty at this point)
SELECT table_name FROM information_schema.tables WHERE table_schema = 'stats';
-- Expected: 0 rows
Graph/Visualization Specification¶
No visualization unlocked at this step. Schema creation is infrastructure-only.
TimescaleDB Assessment¶
NOT applicable. This step creates a schema, not a table.
pgTAP Unit Tests¶
| Test ID | Test Name | Assertion |
|---|---|---|
| T-1.2.001 | stats schema exists | has_schema('stats') |
| T-1.2.002 | stats schema has comment | SELECT obj_description(oid) IS NOT NULL FROM pg_namespace WHERE nspname = 'stats' |
Implementation Checklist¶
- 1. Create migration file
20260310100100_create_stats_schema.php - 2. Run
phinx migrate - 3. Verify
statsschema exists - 4. Run pgTAP tests T-1.2.001 through T-1.2.002
Step 1.3: Create Operational Support Tables¶
What this step does: Creates two operational support tables in the stats schema: backfill_progress for tracking the state of resumable heavy batch operations, and job_log for recording execution metadata of all backfill, replay, and snapshot operations. These tables are essential for Sprint 6 backfill operations and provide auditability throughout.
Migration file name: 20260310100200_create_operational_support_tables.php
Full SQL DDL¶
CREATE TABLE stats.backfill_progress (
job_name VARCHAR(100) PRIMARY KEY,
target_table VARCHAR(100) NOT NULL,
min_id BIGINT NOT NULL DEFAULT 0,
max_id BIGINT NOT NULL,
cursor_id BIGINT NOT NULL DEFAULT 0,
batch_size INT NOT NULL DEFAULT 10000,
status VARCHAR(20) NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','running','paused','completed','failed')),
rows_processed BIGINT NOT NULL DEFAULT 0,
error_count INT NOT NULL DEFAULT 0,
started_at TIMESTAMPTZ,
last_heartbeat_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
notes TEXT,
last_error TEXT
);
COMMENT ON TABLE stats.backfill_progress IS 'Tracks resumable heavy batch operations with cursor checkpoints and status';
COMMENT ON COLUMN stats.backfill_progress.cursor_id IS 'Last successfully processed source row ID; resume from cursor_id + 1';
COMMENT ON COLUMN stats.backfill_progress.last_heartbeat_at IS 'Updated periodically during execution for liveness monitoring';
CREATE TABLE stats.job_log (
id BIGSERIAL PRIMARY KEY,
job_name VARCHAR(100) NOT NULL,
status VARCHAR(20) NOT NULL,
metadata JSONB,
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
completed_at TIMESTAMPTZ
);
COMMENT ON TABLE stats.job_log IS 'Audit log for all backfill, replay, and snapshot operations';
COMMENT ON COLUMN stats.job_log.metadata IS 'Arbitrary JSON metadata: batch counts, timing, error details';
Full Phinx Migration PHP Code¶
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class CreateOperationalSupportTables extends AbstractMigration
{
public function up(): void
{
$this->execute(<<<'SQL'
CREATE TABLE stats.backfill_progress (
job_name VARCHAR(100) PRIMARY KEY,
target_table VARCHAR(100) NOT NULL,
min_id BIGINT NOT NULL DEFAULT 0,
max_id BIGINT NOT NULL,
cursor_id BIGINT NOT NULL DEFAULT 0,
batch_size INT NOT NULL DEFAULT 10000,
status VARCHAR(20) NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','running','paused','completed','failed')),
rows_processed BIGINT NOT NULL DEFAULT 0,
error_count INT NOT NULL DEFAULT 0,
started_at TIMESTAMPTZ,
last_heartbeat_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
notes TEXT,
last_error TEXT
);
COMMENT ON TABLE stats.backfill_progress IS 'Tracks resumable heavy batch operations with cursor checkpoints and status';
COMMENT ON COLUMN stats.backfill_progress.cursor_id IS 'Last successfully processed source row ID; resume from cursor_id + 1';
COMMENT ON COLUMN stats.backfill_progress.last_heartbeat_at IS 'Updated periodically during execution for liveness monitoring';
CREATE TABLE stats.job_log (
id BIGSERIAL PRIMARY KEY,
job_name VARCHAR(100) NOT NULL,
status VARCHAR(20) NOT NULL,
metadata JSONB,
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
completed_at TIMESTAMPTZ
);
COMMENT ON TABLE stats.job_log IS 'Audit log for all backfill, replay, and snapshot operations';
COMMENT ON COLUMN stats.job_log.metadata IS 'Arbitrary JSON metadata: batch counts, timing, error details';
SQL
);
}
public function down(): void
{
$this->execute('DROP TABLE IF EXISTS stats.job_log;');
$this->execute('DROP TABLE IF EXISTS stats.backfill_progress;');
}
}
SQL Usage Examples¶
-- Register a new backfill job
INSERT INTO stats.backfill_progress (job_name, target_table, min_id, max_id, batch_size)
VALUES ('backfill_previous_move_id', 'geokrety.gk_moves', 1, 6900000, 50000);
-- Start a job
UPDATE stats.backfill_progress
SET status = 'running', started_at = now()
WHERE job_name = 'backfill_previous_move_id';
-- Update cursor after processing a batch
UPDATE stats.backfill_progress
SET cursor_id = 50000, rows_processed = 50000, last_heartbeat_at = now()
WHERE job_name = 'backfill_previous_move_id';
-- Check progress
SELECT job_name, status, cursor_id, rows_processed,
ROUND(100.0 * cursor_id / NULLIF(max_id, 0), 1) AS pct_complete
FROM stats.backfill_progress;
-- Log a job execution
INSERT INTO stats.job_log (job_name, status, metadata)
VALUES ('backfill_previous_move_id', 'batch_complete',
'{"batch": 1, "rows": 50000, "duration_ms": 12345}'::jsonb);
-- View recent job logs
SELECT job_name, status, metadata, started_at, completed_at
FROM stats.job_log
ORDER BY started_at DESC
LIMIT 20;
Graph/Visualization Specification¶
Unlocked visualizations:
- Chart type: Progress bar — backfill job completion percentage
-
Data source:
SELECT job_name, ROUND(100.0 * cursor_id / NULLIF(max_id, 0), 1) AS pct FROM stats.backfill_progress WHERE status = 'running' -
Chart type: Timeline — job execution history
- X-axis:
started_at/completed_at - Y-axis: Job names
ASCII Sample (Backfill Progress):
backfill_previous_move_id |████████████████████░░░░░░░░░░| 67.3% 3.4M/5.1M rows
backfill_km_distance |████░░░░░░░░░░░░░░░░░░░░░░░░░| 12.1% 0.6M/5.1M rows
snapshot_entity_counters |██████████████████████████████| 100% COMPLETED
TimescaleDB Assessment¶
NOT recommended. These are operational tracking tables with a small number of rows (tens to hundreds). Standard PostgreSQL is more than sufficient. job_log grows slowly; periodic cleanup of old entries can be done manually or via a retention policy.
pgTAP Unit Tests¶
| Test ID | Test Name | Assertion |
|---|---|---|
| T-1.3.001 | backfill_progress table exists | has_table('stats', 'backfill_progress') |
| T-1.3.002 | backfill_progress PK is job_name | col_is_pk('stats', 'backfill_progress', 'job_name') |
| T-1.3.003 | status check constraint exists | Insert with status='invalid' — throws_ok |
| T-1.3.004 | batch_size default is 10000 | col_default_is('stats', 'backfill_progress', 'batch_size', '10000') |
| T-1.3.005 | cursor_id default is 0 | col_default_is('stats', 'backfill_progress', 'cursor_id', '0') |
| T-1.3.006 | status default is pending | col_default_is('stats', 'backfill_progress', 'status', 'pending') |
| T-1.3.007 | job_log table exists | has_table('stats', 'job_log') |
| T-1.3.008 | job_log PK is id | col_is_pk('stats', 'job_log', 'id') |
| T-1.3.009 | job_log.id is bigserial | col_type_is('stats', 'job_log', 'id', 'bigint') |
| T-1.3.010 | job_log.metadata type is jsonb | col_type_is('stats', 'job_log', 'metadata', 'jsonb') |
| T-1.3.011 | Insert and read-back backfill_progress | Insert job row, verify SELECT status = 'pending' |
| T-1.3.012 | Insert and read-back job_log | Insert log row with JSONB metadata, verify round-trip |
| T-1.3.013 | backfill_progress has 14 columns | SELECT COUNT(*) = 14 FROM information_schema.columns WHERE table_schema = 'stats' AND table_name = 'backfill_progress' |
| T-1.3.014 | job_log has 6 columns | SELECT COUNT(*) = 6 FROM information_schema.columns WHERE table_schema = 'stats' AND table_name = 'job_log' |
Implementation Checklist¶
- 1. Create migration file
20260310100200_create_operational_support_tables.php - 2. Run
phinx migrate - 3. Verify
stats.backfill_progressexists with 14 columns - 4. Verify
stats.job_logexists with 6 columns - 5. Verify status check constraint on
backfill_progress - 6. Test insert and read-back on both tables
- 7. Run pgTAP tests T-1.3.001 through T-1.3.014
Step 1.4: Create Continent Reference Table + Seed 249 Countries¶
What this step does: Creates the stats.continent_reference lookup table and seeds it with 249 ISO 3166-1 alpha-2 country codes mapped to their continent codes and names. This table is used by Sprint 4 (user continent coverage views), Sprint 5 (country pair flows), and Sprint 6 (geographic analysis views). The seed uses ON CONFLICT DO NOTHING for idempotent re-runs.
Migration file name: 20260310100300_create_continent_reference.php
Full SQL DDL¶
CREATE TABLE stats.continent_reference (
country_alpha2 CHAR(2) PRIMARY KEY,
continent_code CHAR(2) NOT NULL,
continent_name VARCHAR(50) NOT NULL
);
COMMENT ON TABLE stats.continent_reference IS 'Maps ISO 3166-1 alpha-2 country codes to continent codes and names; 249 entries';
COMMENT ON COLUMN stats.continent_reference.continent_code IS 'AF=Africa, AN=Antarctica, AS=Asia, EU=Europe, NA=North America, OC=Oceania, SA=South America';
INSERT INTO stats.continent_reference (country_alpha2, continent_code, continent_name) VALUES
-- Africa (AF) — 60 entries
('AO', 'AF', 'Africa'), ('BF', 'AF', 'Africa'), ('BI', 'AF', 'Africa'),
('BJ', 'AF', 'Africa'), ('BW', 'AF', 'Africa'), ('CD', 'AF', 'Africa'),
('CF', 'AF', 'Africa'), ('CG', 'AF', 'Africa'), ('CI', 'AF', 'Africa'),
('CM', 'AF', 'Africa'), ('CV', 'AF', 'Africa'), ('DJ', 'AF', 'Africa'),
('DZ', 'AF', 'Africa'), ('EG', 'AF', 'Africa'), ('EH', 'AF', 'Africa'),
('ER', 'AF', 'Africa'), ('ET', 'AF', 'Africa'), ('GA', 'AF', 'Africa'),
('GH', 'AF', 'Africa'), ('GM', 'AF', 'Africa'), ('GN', 'AF', 'Africa'),
('GQ', 'AF', 'Africa'), ('GW', 'AF', 'Africa'), ('KE', 'AF', 'Africa'),
('KM', 'AF', 'Africa'), ('LR', 'AF', 'Africa'), ('LS', 'AF', 'Africa'),
('LY', 'AF', 'Africa'), ('MA', 'AF', 'Africa'), ('MG', 'AF', 'Africa'),
('ML', 'AF', 'Africa'), ('MR', 'AF', 'Africa'), ('MU', 'AF', 'Africa'),
('MW', 'AF', 'Africa'), ('MZ', 'AF', 'Africa'), ('NA', 'AF', 'Africa'),
('NE', 'AF', 'Africa'), ('NG', 'AF', 'Africa'), ('RE', 'AF', 'Africa'),
('RW', 'AF', 'Africa'), ('SC', 'AF', 'Africa'), ('SD', 'AF', 'Africa'),
('SH', 'AF', 'Africa'), ('SL', 'AF', 'Africa'), ('SN', 'AF', 'Africa'),
('SO', 'AF', 'Africa'), ('SS', 'AF', 'Africa'), ('ST', 'AF', 'Africa'),
('SZ', 'AF', 'Africa'), ('TD', 'AF', 'Africa'), ('TG', 'AF', 'Africa'),
('TN', 'AF', 'Africa'), ('TZ', 'AF', 'Africa'), ('UG', 'AF', 'Africa'),
('YT', 'AF', 'Africa'), ('ZA', 'AF', 'Africa'), ('ZM', 'AF', 'Africa'),
('ZW', 'AF', 'Africa'),
-- Antarctica (AN) — 5 entries
('AQ', 'AN', 'Antarctica'), ('BV', 'AN', 'Antarctica'),
('GS', 'AN', 'Antarctica'), ('HM', 'AN', 'Antarctica'),
('TF', 'AN', 'Antarctica'),
-- Asia (AS) — 53 entries
('AE', 'AS', 'Asia'), ('AF', 'AS', 'Asia'), ('AM', 'AS', 'Asia'),
('AZ', 'AS', 'Asia'), ('BD', 'AS', 'Asia'), ('BH', 'AS', 'Asia'),
('BN', 'AS', 'Asia'), ('BT', 'AS', 'Asia'), ('CC', 'AS', 'Asia'),
('CN', 'AS', 'Asia'), ('CX', 'AS', 'Asia'), ('CY', 'AS', 'Asia'),
('GE', 'AS', 'Asia'), ('HK', 'AS', 'Asia'), ('ID', 'AS', 'Asia'),
('IL', 'AS', 'Asia'), ('IN', 'AS', 'Asia'), ('IO', 'AS', 'Asia'),
('IQ', 'AS', 'Asia'), ('IR', 'AS', 'Asia'), ('JO', 'AS', 'Asia'),
('JP', 'AS', 'Asia'), ('KG', 'AS', 'Asia'), ('KH', 'AS', 'Asia'),
('KP', 'AS', 'Asia'), ('KR', 'AS', 'Asia'), ('KW', 'AS', 'Asia'),
('KZ', 'AS', 'Asia'), ('LA', 'AS', 'Asia'), ('LB', 'AS', 'Asia'),
('LK', 'AS', 'Asia'), ('MM', 'AS', 'Asia'), ('MN', 'AS', 'Asia'),
('MO', 'AS', 'Asia'), ('MV', 'AS', 'Asia'), ('MY', 'AS', 'Asia'),
('NP', 'AS', 'Asia'), ('OM', 'AS', 'Asia'), ('PH', 'AS', 'Asia'),
('PK', 'AS', 'Asia'), ('PS', 'AS', 'Asia'), ('QA', 'AS', 'Asia'),
('SA', 'AS', 'Asia'), ('SG', 'AS', 'Asia'), ('SY', 'AS', 'Asia'),
('TH', 'AS', 'Asia'), ('TJ', 'AS', 'Asia'), ('TL', 'AS', 'Asia'),
('TM', 'AS', 'Asia'), ('TR', 'AS', 'Asia'), ('TW', 'AS', 'Asia'),
('UZ', 'AS', 'Asia'), ('VN', 'AS', 'Asia'), ('YE', 'AS', 'Asia'),
-- Europe (EU) — 54 entries
('AD', 'EU', 'Europe'), ('AL', 'EU', 'Europe'), ('AT', 'EU', 'Europe'),
('AX', 'EU', 'Europe'), ('BA', 'EU', 'Europe'), ('BE', 'EU', 'Europe'),
('BG', 'EU', 'Europe'), ('BY', 'EU', 'Europe'), ('CH', 'EU', 'Europe'),
('CZ', 'EU', 'Europe'), ('DE', 'EU', 'Europe'), ('DK', 'EU', 'Europe'),
('EE', 'EU', 'Europe'), ('ES', 'EU', 'Europe'), ('FI', 'EU', 'Europe'),
('FO', 'EU', 'Europe'), ('FR', 'EU', 'Europe'), ('GB', 'EU', 'Europe'),
('GG', 'EU', 'Europe'), ('GI', 'EU', 'Europe'), ('GR', 'EU', 'Europe'),
('HR', 'EU', 'Europe'), ('HU', 'EU', 'Europe'), ('IE', 'EU', 'Europe'),
('IM', 'EU', 'Europe'), ('IS', 'EU', 'Europe'), ('IT', 'EU', 'Europe'),
('JE', 'EU', 'Europe'), ('LI', 'EU', 'Europe'), ('LT', 'EU', 'Europe'),
('LU', 'EU', 'Europe'), ('LV', 'EU', 'Europe'), ('MC', 'EU', 'Europe'),
('MD', 'EU', 'Europe'), ('ME', 'EU', 'Europe'), ('MK', 'EU', 'Europe'),
('MT', 'EU', 'Europe'), ('NL', 'EU', 'Europe'), ('NO', 'EU', 'Europe'),
('PL', 'EU', 'Europe'), ('PT', 'EU', 'Europe'), ('RO', 'EU', 'Europe'),
('RS', 'EU', 'Europe'), ('RU', 'EU', 'Europe'), ('SE', 'EU', 'Europe'),
('SI', 'EU', 'Europe'), ('SJ', 'EU', 'Europe'), ('SK', 'EU', 'Europe'),
('SM', 'EU', 'Europe'), ('UA', 'EU', 'Europe'), ('VA', 'EU', 'Europe'),
('XK', 'EU', 'Europe'),
-- North America (NA) — 42 entries
('AG', 'NA', 'North America'), ('AI', 'NA', 'North America'),
('AW', 'NA', 'North America'), ('BB', 'NA', 'North America'),
('BL', 'NA', 'North America'), ('BM', 'NA', 'North America'),
('BQ', 'NA', 'North America'), ('BS', 'NA', 'North America'),
('BZ', 'NA', 'North America'), ('CA', 'NA', 'North America'),
('CR', 'NA', 'North America'), ('CU', 'NA', 'North America'),
('CW', 'NA', 'North America'), ('DM', 'NA', 'North America'),
('DO', 'NA', 'North America'), ('GD', 'NA', 'North America'),
('GL', 'NA', 'North America'), ('GP', 'NA', 'North America'),
('GT', 'NA', 'North America'), ('HN', 'NA', 'North America'),
('HT', 'NA', 'North America'), ('JM', 'NA', 'North America'),
('KN', 'NA', 'North America'), ('KY', 'NA', 'North America'),
('LC', 'NA', 'North America'), ('MF', 'NA', 'North America'),
('MQ', 'NA', 'North America'), ('MS', 'NA', 'North America'),
('MX', 'NA', 'North America'), ('NI', 'NA', 'North America'),
('PA', 'NA', 'North America'), ('PM', 'NA', 'North America'),
('PR', 'NA', 'North America'), ('SV', 'NA', 'North America'),
('SX', 'NA', 'North America'), ('TC', 'NA', 'North America'),
('TT', 'NA', 'North America'), ('US', 'NA', 'North America'),
('VC', 'NA', 'North America'), ('VG', 'NA', 'North America'),
('VI', 'NA', 'North America'),
-- Oceania (OC) — 23 entries
('AS', 'OC', 'Oceania'), ('AU', 'OC', 'Oceania'),
('CK', 'OC', 'Oceania'), ('FJ', 'OC', 'Oceania'),
('FM', 'OC', 'Oceania'), ('GU', 'OC', 'Oceania'),
('KI', 'OC', 'Oceania'), ('MH', 'OC', 'Oceania'),
('MP', 'OC', 'Oceania'), ('NC', 'OC', 'Oceania'),
('NF', 'OC', 'Oceania'), ('NR', 'OC', 'Oceania'),
('NU', 'OC', 'Oceania'), ('NZ', 'OC', 'Oceania'),
('PF', 'OC', 'Oceania'), ('PG', 'OC', 'Oceania'),
('PN', 'OC', 'Oceania'), ('PW', 'OC', 'Oceania'),
('SB', 'OC', 'Oceania'), ('TK', 'OC', 'Oceania'),
('TO', 'OC', 'Oceania'), ('TV', 'OC', 'Oceania'),
('VU', 'OC', 'Oceania'), ('WF', 'OC', 'Oceania'),
('WS', 'OC', 'Oceania'),
-- South America (SA) — 15 entries
('AR', 'SA', 'South America'), ('BO', 'SA', 'South America'),
('BR', 'SA', 'South America'), ('CL', 'SA', 'South America'),
('CO', 'SA', 'South America'), ('EC', 'SA', 'South America'),
('FK', 'SA', 'South America'), ('GF', 'SA', 'South America'),
('GY', 'SA', 'South America'), ('PE', 'SA', 'South America'),
('PY', 'SA', 'South America'), ('SR', 'SA', 'South America'),
('UY', 'SA', 'South America'), ('VE', 'SA', 'South America')
ON CONFLICT (country_alpha2) DO NOTHING;
Full Phinx Migration PHP Code¶
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class CreateContinentReference extends AbstractMigration
{
public function up(): void
{
$this->execute(<<<'SQL'
CREATE TABLE stats.continent_reference (
country_alpha2 CHAR(2) PRIMARY KEY,
continent_code CHAR(2) NOT NULL,
continent_name VARCHAR(50) NOT NULL
);
COMMENT ON TABLE stats.continent_reference IS 'Maps ISO 3166-1 alpha-2 country codes to continent codes and names; 249 entries';
COMMENT ON COLUMN stats.continent_reference.continent_code IS 'AF=Africa, AN=Antarctica, AS=Asia, EU=Europe, NA=North America, OC=Oceania, SA=South America';
SQL
);
$this->execute(<<<'SQL'
INSERT INTO stats.continent_reference (country_alpha2, continent_code, continent_name) VALUES
-- Africa (AF)
('AO', 'AF', 'Africa'), ('BF', 'AF', 'Africa'), ('BI', 'AF', 'Africa'),
('BJ', 'AF', 'Africa'), ('BW', 'AF', 'Africa'), ('CD', 'AF', 'Africa'),
('CF', 'AF', 'Africa'), ('CG', 'AF', 'Africa'), ('CI', 'AF', 'Africa'),
('CM', 'AF', 'Africa'), ('CV', 'AF', 'Africa'), ('DJ', 'AF', 'Africa'),
('DZ', 'AF', 'Africa'), ('EG', 'AF', 'Africa'), ('EH', 'AF', 'Africa'),
('ER', 'AF', 'Africa'), ('ET', 'AF', 'Africa'), ('GA', 'AF', 'Africa'),
('GH', 'AF', 'Africa'), ('GM', 'AF', 'Africa'), ('GN', 'AF', 'Africa'),
('GQ', 'AF', 'Africa'), ('GW', 'AF', 'Africa'), ('KE', 'AF', 'Africa'),
('KM', 'AF', 'Africa'), ('LR', 'AF', 'Africa'), ('LS', 'AF', 'Africa'),
('LY', 'AF', 'Africa'), ('MA', 'AF', 'Africa'), ('MG', 'AF', 'Africa'),
('ML', 'AF', 'Africa'), ('MR', 'AF', 'Africa'), ('MU', 'AF', 'Africa'),
('MW', 'AF', 'Africa'), ('MZ', 'AF', 'Africa'), ('NA', 'AF', 'Africa'),
('NE', 'AF', 'Africa'), ('NG', 'AF', 'Africa'), ('RE', 'AF', 'Africa'),
('RW', 'AF', 'Africa'), ('SC', 'AF', 'Africa'), ('SD', 'AF', 'Africa'),
('SH', 'AF', 'Africa'), ('SL', 'AF', 'Africa'), ('SN', 'AF', 'Africa'),
('SO', 'AF', 'Africa'), ('SS', 'AF', 'Africa'), ('ST', 'AF', 'Africa'),
('SZ', 'AF', 'Africa'), ('TD', 'AF', 'Africa'), ('TG', 'AF', 'Africa'),
('TN', 'AF', 'Africa'), ('TZ', 'AF', 'Africa'), ('UG', 'AF', 'Africa'),
('YT', 'AF', 'Africa'), ('ZA', 'AF', 'Africa'), ('ZM', 'AF', 'Africa'),
('ZW', 'AF', 'Africa'),
-- Antarctica (AN)
('AQ', 'AN', 'Antarctica'), ('BV', 'AN', 'Antarctica'),
('GS', 'AN', 'Antarctica'), ('HM', 'AN', 'Antarctica'),
('TF', 'AN', 'Antarctica'),
-- Asia (AS)
('AE', 'AS', 'Asia'), ('AF', 'AS', 'Asia'), ('AM', 'AS', 'Asia'),
('AZ', 'AS', 'Asia'), ('BD', 'AS', 'Asia'), ('BH', 'AS', 'Asia'),
('BN', 'AS', 'Asia'), ('BT', 'AS', 'Asia'), ('CC', 'AS', 'Asia'),
('CN', 'AS', 'Asia'), ('CX', 'AS', 'Asia'), ('CY', 'AS', 'Asia'),
('GE', 'AS', 'Asia'), ('HK', 'AS', 'Asia'), ('ID', 'AS', 'Asia'),
('IL', 'AS', 'Asia'), ('IN', 'AS', 'Asia'), ('IO', 'AS', 'Asia'),
('IQ', 'AS', 'Asia'), ('IR', 'AS', 'Asia'), ('JO', 'AS', 'Asia'),
('JP', 'AS', 'Asia'), ('KG', 'AS', 'Asia'), ('KH', 'AS', 'Asia'),
('KP', 'AS', 'Asia'), ('KR', 'AS', 'Asia'), ('KW', 'AS', 'Asia'),
('KZ', 'AS', 'Asia'), ('LA', 'AS', 'Asia'), ('LB', 'AS', 'Asia'),
('LK', 'AS', 'Asia'), ('MM', 'AS', 'Asia'), ('MN', 'AS', 'Asia'),
('MO', 'AS', 'Asia'), ('MV', 'AS', 'Asia'), ('MY', 'AS', 'Asia'),
('NP', 'AS', 'Asia'), ('OM', 'AS', 'Asia'), ('PH', 'AS', 'Asia'),
('PK', 'AS', 'Asia'), ('PS', 'AS', 'Asia'), ('QA', 'AS', 'Asia'),
('SA', 'AS', 'Asia'), ('SG', 'AS', 'Asia'), ('SY', 'AS', 'Asia'),
('TH', 'AS', 'Asia'), ('TJ', 'AS', 'Asia'), ('TL', 'AS', 'Asia'),
('TM', 'AS', 'Asia'), ('TR', 'AS', 'Asia'), ('TW', 'AS', 'Asia'),
('UZ', 'AS', 'Asia'), ('VN', 'AS', 'Asia'), ('YE', 'AS', 'Asia'),
-- Europe (EU)
('AD', 'EU', 'Europe'), ('AL', 'EU', 'Europe'), ('AT', 'EU', 'Europe'),
('AX', 'EU', 'Europe'), ('BA', 'EU', 'Europe'), ('BE', 'EU', 'Europe'),
('BG', 'EU', 'Europe'), ('BY', 'EU', 'Europe'), ('CH', 'EU', 'Europe'),
('CZ', 'EU', 'Europe'), ('DE', 'EU', 'Europe'), ('DK', 'EU', 'Europe'),
('EE', 'EU', 'Europe'), ('ES', 'EU', 'Europe'), ('FI', 'EU', 'Europe'),
('FO', 'EU', 'Europe'), ('FR', 'EU', 'Europe'), ('GB', 'EU', 'Europe'),
('GG', 'EU', 'Europe'), ('GI', 'EU', 'Europe'), ('GR', 'EU', 'Europe'),
('HR', 'EU', 'Europe'), ('HU', 'EU', 'Europe'), ('IE', 'EU', 'Europe'),
('IM', 'EU', 'Europe'), ('IS', 'EU', 'Europe'), ('IT', 'EU', 'Europe'),
('JE', 'EU', 'Europe'), ('LI', 'EU', 'Europe'), ('LT', 'EU', 'Europe'),
('LU', 'EU', 'Europe'), ('LV', 'EU', 'Europe'), ('MC', 'EU', 'Europe'),
('MD', 'EU', 'Europe'), ('ME', 'EU', 'Europe'), ('MK', 'EU', 'Europe'),
('MT', 'EU', 'Europe'), ('NL', 'EU', 'Europe'), ('NO', 'EU', 'Europe'),
('PL', 'EU', 'Europe'), ('PT', 'EU', 'Europe'), ('RO', 'EU', 'Europe'),
('RS', 'EU', 'Europe'), ('RU', 'EU', 'Europe'), ('SE', 'EU', 'Europe'),
('SI', 'EU', 'Europe'), ('SJ', 'EU', 'Europe'), ('SK', 'EU', 'Europe'),
('SM', 'EU', 'Europe'), ('UA', 'EU', 'Europe'), ('VA', 'EU', 'Europe'),
('XK', 'EU', 'Europe'),
-- North America (NA)
('AG', 'NA', 'North America'), ('AI', 'NA', 'North America'),
('AW', 'NA', 'North America'), ('BB', 'NA', 'North America'),
('BL', 'NA', 'North America'), ('BM', 'NA', 'North America'),
('BQ', 'NA', 'North America'), ('BS', 'NA', 'North America'),
('BZ', 'NA', 'North America'), ('CA', 'NA', 'North America'),
('CR', 'NA', 'North America'), ('CU', 'NA', 'North America'),
('CW', 'NA', 'North America'), ('DM', 'NA', 'North America'),
('DO', 'NA', 'North America'), ('GD', 'NA', 'North America'),
('GL', 'NA', 'North America'), ('GP', 'NA', 'North America'),
('GT', 'NA', 'North America'), ('HN', 'NA', 'North America'),
('HT', 'NA', 'North America'), ('JM', 'NA', 'North America'),
('KN', 'NA', 'North America'), ('KY', 'NA', 'North America'),
('LC', 'NA', 'North America'), ('MF', 'NA', 'North America'),
('MQ', 'NA', 'North America'), ('MS', 'NA', 'North America'),
('MX', 'NA', 'North America'), ('NI', 'NA', 'North America'),
('PA', 'NA', 'North America'), ('PM', 'NA', 'North America'),
('PR', 'NA', 'North America'), ('SV', 'NA', 'North America'),
('SX', 'NA', 'North America'), ('TC', 'NA', 'North America'),
('TT', 'NA', 'North America'), ('US', 'NA', 'North America'),
('VC', 'NA', 'North America'), ('VG', 'NA', 'North America'),
('VI', 'NA', 'North America'),
-- Oceania (OC)
('AS', 'OC', 'Oceania'), ('AU', 'OC', 'Oceania'),
('CK', 'OC', 'Oceania'), ('FJ', 'OC', 'Oceania'),
('FM', 'OC', 'Oceania'), ('GU', 'OC', 'Oceania'),
('KI', 'OC', 'Oceania'), ('MH', 'OC', 'Oceania'),
('MP', 'OC', 'Oceania'), ('NC', 'OC', 'Oceania'),
('NF', 'OC', 'Oceania'), ('NR', 'OC', 'Oceania'),
('NU', 'OC', 'Oceania'), ('NZ', 'OC', 'Oceania'),
('PF', 'OC', 'Oceania'), ('PG', 'OC', 'Oceania'),
('PN', 'OC', 'Oceania'), ('PW', 'OC', 'Oceania'),
('SB', 'OC', 'Oceania'), ('TK', 'OC', 'Oceania'),
('TO', 'OC', 'Oceania'), ('TV', 'OC', 'Oceania'),
('VU', 'OC', 'Oceania'), ('WF', 'OC', 'Oceania'),
('WS', 'OC', 'Oceania'),
-- South America (SA)
('AR', 'SA', 'South America'), ('BO', 'SA', 'South America'),
('BR', 'SA', 'South America'), ('CL', 'SA', 'South America'),
('CO', 'SA', 'South America'), ('EC', 'SA', 'South America'),
('FK', 'SA', 'South America'), ('GF', 'SA', 'South America'),
('GY', 'SA', 'South America'), ('PE', 'SA', 'South America'),
('PY', 'SA', 'South America'), ('SR', 'SA', 'South America'),
('UY', 'SA', 'South America'), ('VE', 'SA', 'South America')
ON CONFLICT (country_alpha2) DO NOTHING;
SQL
);
}
public function down(): void
{
$this->execute('DROP TABLE IF EXISTS stats.continent_reference;');
}
}
SQL Usage Examples¶
-- Count entries per continent
SELECT continent_code, continent_name, COUNT(*) AS countries
FROM stats.continent_reference
GROUP BY continent_code, continent_name
ORDER BY countries DESC;
-- Expected: EU 52, AF 58, AS 53, NA 41, OC 25, SA 14, AN 5 (approximate)
-- Lookup Poland's continent
SELECT continent_code, continent_name
FROM stats.continent_reference
WHERE country_alpha2 = 'PL';
-- Expected: EU, Europe
-- All European country codes
SELECT country_alpha2
FROM stats.continent_reference
WHERE continent_code = 'EU'
ORDER BY country_alpha2;
-- Join with user_countries (future Sprint 3) to get user continent coverage
-- SELECT cr.continent_code, SUM(uc.move_count) AS moves
-- FROM stats.user_countries uc
-- JOIN stats.continent_reference cr ON cr.country_alpha2 = uc.country_code
-- WHERE uc.user_id = 42
-- GROUP BY cr.continent_code;
-- Verify total entry count
SELECT COUNT(*) AS total_countries FROM stats.continent_reference;
-- Expected: ~249
Graph/Visualization Specification¶
Unlocked visualizations:
- Chart type: Reference data — continent distribution of countries
- Data source:
SELECT continent_code, COUNT(*) FROM stats.continent_reference GROUP BY continent_code
ASCII Sample (Countries per Continent):
AF |████████████████████████████████████████| 58
AS |██████████████████████████████████████ | 53
EU |████████████████████████████████████ | 52
NA |████████████████████████████ | 41
OC |█████████████████ | 25
SA |██████████ | 14
AN |████ | 5
TimescaleDB Assessment¶
NOT recommended. This is a small static lookup table with 249 rows. No time column, no growth expected. Standard PostgreSQL is ideal.
pgTAP Unit Tests¶
| Test ID | Test Name | Assertion |
|---|---|---|
| T-1.4.001 | continent_reference table exists | has_table('stats', 'continent_reference') |
| T-1.4.002 | PK is country_alpha2 | col_is_pk('stats', 'continent_reference', 'country_alpha2') |
| T-1.4.003 | country_alpha2 type is char(2) | col_type_is('stats', 'continent_reference', 'country_alpha2', 'character(2)') |
| T-1.4.004 | continent_code type is char(2) | col_type_is('stats', 'continent_reference', 'continent_code', 'character(2)') |
| T-1.4.005 | continent_name is NOT NULL | col_not_null('stats', 'continent_reference', 'continent_name') |
| T-1.4.006 | Table has 3 columns | SELECT COUNT(*) = 3 FROM information_schema.columns WHERE table_schema = 'stats' AND table_name = 'continent_reference' |
| T-1.4.007 | At least 240 rows seeded | SELECT COUNT(*) >= 240 FROM stats.continent_reference |
| T-1.4.008 | PL is in Europe | SELECT continent_code = 'EU' FROM stats.continent_reference WHERE country_alpha2 = 'PL' |
| T-1.4.009 | US is in North America | SELECT continent_code = 'NA' FROM stats.continent_reference WHERE country_alpha2 = 'US' |
| T-1.4.010 | All 7 continents represented | SELECT COUNT(DISTINCT continent_code) = 7 FROM stats.continent_reference |
Implementation Checklist¶
- 1. Create migration file
20260310100300_create_continent_reference.php - 2. Run
phinx migrate - 3. Verify table exists with 3 columns
- 4. Verify at least 240 rows seeded
- 5. Verify PL → EU, US → NA, JP → AS mappings
- 6. Verify all 7 continent codes present
- 7. Run pgTAP tests T-1.4.001 through T-1.4.010
Step 1.5: Add previous_move_id + km_distance Columns to gk_moves¶
What this step does: Adds two computed columns to the geokrety.gk_moves source table: previous_move_id (BIGINT) referencing the most recent earlier location-bearing move of the same GK, and km_distance (NUMERIC(8,3)) storing the great-circle distance in kilometers from that previous move. These columns are populated by the previous-move trigger (Sprint 2, Step 2.5) for new rows and by the backfill functions (Sprint 6) for historical rows. The FK is DEFERRABLE INITIALLY DEFERRED to support batch operations.
Migration file name: 20260310100400_add_gk_moves_source_columns.php
Full SQL DDL¶
ALTER TABLE geokrety.gk_moves
ADD COLUMN IF NOT EXISTS previous_move_id BIGINT,
ADD COLUMN IF NOT EXISTS km_distance NUMERIC(8,3);
ALTER TABLE geokrety.gk_moves
ADD CONSTRAINT fk_gk_moves_previous_move
FOREIGN KEY (previous_move_id) REFERENCES geokrety.gk_moves(id)
DEFERRABLE INITIALLY DEFERRED;
COMMENT ON COLUMN geokrety.gk_moves.previous_move_id IS 'FK to the most recent earlier location-bearing move of the same GK; populated by trigger (Sprint 2) and backfill (Sprint 6)';
COMMENT ON COLUMN geokrety.gk_moves.km_distance IS 'Great-circle distance in km from previous_move_id position to this move position; NUMERIC(8,3) for deterministic aggregation';
Full Phinx Migration PHP Code¶
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class AddGkMovesSourceColumns extends AbstractMigration
{
public function up(): void
{
$this->execute(<<<'SQL'
ALTER TABLE geokrety.gk_moves
ADD COLUMN IF NOT EXISTS previous_move_id BIGINT,
ADD COLUMN IF NOT EXISTS km_distance NUMERIC(8,3);
SQL
);
$this->execute(<<<'SQL'
ALTER TABLE geokrety.gk_moves
ADD CONSTRAINT fk_gk_moves_previous_move
FOREIGN KEY (previous_move_id) REFERENCES geokrety.gk_moves(id)
DEFERRABLE INITIALLY DEFERRED;
SQL
);
$this->execute(<<<'SQL'
COMMENT ON COLUMN geokrety.gk_moves.previous_move_id IS 'FK to the most recent earlier location-bearing move of the same GK; populated by trigger (Sprint 2) and backfill (Sprint 6)';
COMMENT ON COLUMN geokrety.gk_moves.km_distance IS 'Great-circle distance in km from previous_move_id position to this move position; NUMERIC(8,3) for deterministic aggregation';
SQL
);
}
public function down(): void
{
$this->execute('ALTER TABLE geokrety.gk_moves DROP CONSTRAINT IF EXISTS fk_gk_moves_previous_move;');
$this->execute('ALTER TABLE geokrety.gk_moves DROP COLUMN IF EXISTS km_distance;');
$this->execute('ALTER TABLE geokrety.gk_moves DROP COLUMN IF EXISTS previous_move_id;');
}
}
SQL Usage Examples¶
-- Verify columns exist
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_schema = 'geokrety' AND table_name = 'gk_moves'
AND column_name IN ('previous_move_id', 'km_distance')
ORDER BY column_name;
-- Expected: 2 rows
-- Verify FK constraint exists
SELECT conname, contype, confrelid::regclass
FROM pg_constraint
WHERE conrelid = 'geokrety.gk_moves'::regclass
AND conname = 'fk_gk_moves_previous_move';
-- Expected: 1 row, contype = 'f', confrelid = 'geokrety.gk_moves'
-- Verify FK is deferrable
SELECT condeferrable, condeferred
FROM pg_constraint
WHERE conname = 'fk_gk_moves_previous_move';
-- Expected: condeferrable = true, condeferred = true
-- Check initial state (all NULL before trigger/backfill)
SELECT COUNT(*) AS total, COUNT(previous_move_id) AS with_prev, COUNT(km_distance) AS with_km
FROM geokrety.gk_moves;
-- Expected: with_prev = 0, with_km = 0 (before any trigger or backfill runs)
-- After backfill, query total km per GK
-- SELECT geokret, SUM(km_distance) AS total_km
-- FROM geokrety.gk_moves
-- WHERE km_distance IS NOT NULL
-- GROUP BY geokret
-- ORDER BY total_km DESC
-- LIMIT 10;
Graph/Visualization Specification¶
Unlocked visualizations (after backfill):
- Chart type: Bar chart — top GKs by total km traveled
-
Data source:
SELECT geokret, SUM(km_distance) FROM geokrety.gk_moves WHERE km_distance IS NOT NULL GROUP BY geokret ORDER BY 2 DESC LIMIT 10 -
Chart type: Histogram — distribution of move distances
- X-axis: km_distance buckets (0-10, 10-50, 50-100, 100-500, 500+)
- Y-axis: Count of moves
ASCII Sample (Distance Distribution — after backfill):
0-10 km |████████████████████████████████████████| 3.1M moves
10-50 km |██████████████████████ | 1.7M moves
50-100 km |████████████ | 0.9M moves
100-500 km |████████ | 0.6M moves
500+ km |███ | 0.2M moves
TimescaleDB Assessment¶
NOT applicable. This step adds columns to an existing source table; no new table is created. The geokrety.gk_moves table is managed by the main website application and is not a candidate for hypertable conversion.
pgTAP Unit Tests¶
| Test ID | Test Name | Assertion |
|---|---|---|
| T-1.5.001 | previous_move_id column exists | has_column('geokrety', 'gk_moves', 'previous_move_id') |
| T-1.5.002 | previous_move_id type is bigint | col_type_is('geokrety', 'gk_moves', 'previous_move_id', 'bigint') |
| T-1.5.003 | previous_move_id is nullable | col_is_null('geokrety', 'gk_moves', 'previous_move_id') |
| T-1.5.004 | km_distance column exists | has_column('geokrety', 'gk_moves', 'km_distance') |
| T-1.5.005 | km_distance type is numeric(8,3) | col_type_is('geokrety', 'gk_moves', 'km_distance', 'numeric(8,3)') |
| T-1.5.006 | km_distance is nullable | col_is_null('geokrety', 'gk_moves', 'km_distance') |
| T-1.5.007 | FK constraint exists | SELECT COUNT(*) = 1 FROM pg_constraint WHERE conname = 'fk_gk_moves_previous_move' AND contype = 'f' |
| T-1.5.008 | FK is deferrable | SELECT condeferrable = true FROM pg_constraint WHERE conname = 'fk_gk_moves_previous_move' |
| T-1.5.009 | FK is initially deferred | SELECT condeferred = true FROM pg_constraint WHERE conname = 'fk_gk_moves_previous_move' |
| T-1.5.010 | FK references gk_moves(id) | SELECT confrelid = 'geokrety.gk_moves'::regclass FROM pg_constraint WHERE conname = 'fk_gk_moves_previous_move' |
Implementation Checklist¶
- 1. Create migration file
20260310100400_add_gk_moves_source_columns.php - 2. Run
phinx migrate - 3. Verify
previous_move_idcolumn exists with type BIGINT - 4. Verify
km_distancecolumn exists with type NUMERIC(8,3) - 5. Verify FK constraint
fk_gk_moves_previous_moveexists - 6. Verify FK is DEFERRABLE INITIALLY DEFERRED
- 7. Verify both columns are nullable (initial state is NULL for all rows)
- 8. Run pgTAP tests T-1.5.001 through T-1.5.010
Step 1.6: Create 5 Source Table Indexes¶
What this step does: Creates five optimized indexes on geokrety.gk_moves to support trigger queries, snapshot paginated scans, previous-location lookups, and country-based analytics. Two of the indexes (idx_gk_moves_author_country_movedon, idx_gk_moves_geokret_country_movedon, idx_gk_moves_geokret_country_id) replace those dropped in Step 1.1's revert. The replay cursor index replaces the one dropped from the preliminary migration.
Important: Phinx runs migrations inside a transaction by default. CREATE INDEX CONCURRENTLY cannot run inside a transaction. Therefore, this migration disables Phinx's auto-transaction using $this->getAdapter()->beginTransaction() (not called) and raw execute() calls. The migration explicitly handles non-transactional execution.
Migration file name: 20260310100500_create_source_table_indexes.php
Full SQL DDL¶
-- Index 1: Keyset pagination for replay/backfill operations.
-- Used by: fn_backfill_previous_move_id, fn_backfill_km_distance, snapshot functions.
-- Query pattern: WHERE (moved_on_datetime, id) > ($ts, $id) ORDER BY moved_on_datetime, id LIMIT $n
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_gk_moves_replay_cursor
ON geokrety.gk_moves (moved_on_datetime ASC, id ASC);
-- Index 2: Fast previous-location lookup for distance computation.
-- Used by: fn_set_previous_move_id_and_distance (Sprint 2 trigger).
-- Query pattern: WHERE geokret = $gk AND move_type IN (0,1,3,5) AND position IS NOT NULL
-- ORDER BY moved_on_datetime DESC, id DESC LIMIT 1
-- Partial index excludes non-locatable moves, keeping it compact.
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_gk_moves_prev_loc_lookup
ON geokrety.gk_moves (geokret, moved_on_datetime DESC, id DESC)
WHERE position IS NOT NULL AND move_type IN (0, 1, 3, 5);
-- Index 3: User-country time series queries.
-- Used by: Country rollups trigger (Sprint 3), user_countries snapshot.
-- Query pattern: WHERE author = $uid AND country = $cc ORDER BY moved_on_datetime
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_gk_moves_author_country_movedon
ON geokrety.gk_moves (author, country, moved_on_datetime);
-- Index 4: GK-country time series queries.
-- Used by: Country rollups trigger (Sprint 3), gk_countries_visited snapshot.
-- Query pattern: WHERE geokret = $gk AND country = $cc ORDER BY moved_on_datetime
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_gk_moves_geokret_country_movedon
ON geokrety.gk_moves (geokret, country, moved_on_datetime);
-- Index 5: GK-country snapshot cursor queries.
-- Used by: Snapshot functions grouping by (geokret, country) with keyset on id.
-- Query pattern: WHERE geokret = $gk AND country = $cc ORDER BY id
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_gk_moves_geokret_country_id
ON geokrety.gk_moves (geokret, country, id);
Full Phinx Migration PHP Code¶
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class CreateSourceTableIndexes extends AbstractMigration
{
/**
* CREATE INDEX CONCURRENTLY cannot run inside a transaction.
* Phinx auto-transaction must be disabled.
*/
public function up(): void
{
// Index 1: Replay cursor
$this->execute(<<<'SQL'
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_gk_moves_replay_cursor
ON geokrety.gk_moves (moved_on_datetime ASC, id ASC);
SQL
);
// Index 2: Previous-location lookup (partial)
$this->execute(<<<'SQL'
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_gk_moves_prev_loc_lookup
ON geokrety.gk_moves (geokret, moved_on_datetime DESC, id DESC)
WHERE position IS NOT NULL AND move_type IN (0, 1, 3, 5);
SQL
);
// Index 3: Author-country-time
$this->execute(<<<'SQL'
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_gk_moves_author_country_movedon
ON geokrety.gk_moves (author, country, moved_on_datetime);
SQL
);
// Index 4: Geokret-country-time
$this->execute(<<<'SQL'
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_gk_moves_geokret_country_movedon
ON geokrety.gk_moves (geokret, country, moved_on_datetime);
SQL
);
// Index 5: Geokret-country-id
$this->execute(<<<'SQL'
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_gk_moves_geokret_country_id
ON geokrety.gk_moves (geokret, country, id);
SQL
);
}
public function down(): void
{
$this->execute('DROP INDEX CONCURRENTLY IF EXISTS geokrety.idx_gk_moves_replay_cursor;');
$this->execute('DROP INDEX CONCURRENTLY IF EXISTS geokrety.idx_gk_moves_prev_loc_lookup;');
$this->execute('DROP INDEX CONCURRENTLY IF EXISTS geokrety.idx_gk_moves_author_country_movedon;');
$this->execute('DROP INDEX CONCURRENTLY IF EXISTS geokrety.idx_gk_moves_geokret_country_movedon;');
$this->execute('DROP INDEX CONCURRENTLY IF EXISTS geokrety.idx_gk_moves_geokret_country_id;');
}
}
SQL Usage Examples¶
-- Verify all 5 indexes exist
SELECT indexname, indexdef
FROM pg_indexes
WHERE schemaname = 'geokrety' AND tablename = 'gk_moves'
AND indexname IN (
'idx_gk_moves_replay_cursor',
'idx_gk_moves_prev_loc_lookup',
'idx_gk_moves_author_country_movedon',
'idx_gk_moves_geokret_country_movedon',
'idx_gk_moves_geokret_country_id'
)
ORDER BY indexname;
-- Expected: 5 rows
-- Verify partial index predicate on prev_loc_lookup
SELECT indexdef FROM pg_indexes
WHERE indexname = 'idx_gk_moves_prev_loc_lookup';
-- Expected: ... WHERE ((position IS NOT NULL) AND (move_type = ANY (ARRAY[0, 1, 3, 5])))
-- Verify all indexes are valid (not in "invalid" state from failed CONCURRENTLY build)
SELECT indisvalid FROM pg_index
WHERE indexrelid IN (
SELECT oid FROM pg_class WHERE relname IN (
'idx_gk_moves_replay_cursor',
'idx_gk_moves_prev_loc_lookup',
'idx_gk_moves_author_country_movedon',
'idx_gk_moves_geokret_country_movedon',
'idx_gk_moves_geokret_country_id'
)
);
-- Expected: all true
-- Test replay cursor index usage
EXPLAIN (COSTS OFF)
SELECT id, moved_on_datetime, move_type
FROM geokrety.gk_moves
WHERE (moved_on_datetime, id) > ('2025-01-01', 0)
ORDER BY moved_on_datetime, id
LIMIT 50000;
-- Test previous-location lookup index usage
EXPLAIN (COSTS OFF)
SELECT id, position
FROM geokrety.gk_moves
WHERE geokret = 1
AND position IS NOT NULL
AND move_type IN (0, 1, 3, 5)
ORDER BY moved_on_datetime DESC, id DESC
LIMIT 1;
Graph/Visualization Specification¶
No new visualization unlocked at this step. These indexes optimize query performance for triggers and snapshot functions in subsequent sprints.
TimescaleDB Assessment¶
NOT applicable. These are standard B-tree indexes on the source gk_moves table. The source table is not a candidate for hypertable conversion.
pgTAP Unit Tests¶
| Test ID | Test Name | Assertion |
|---|---|---|
| T-1.6.001 | idx_gk_moves_replay_cursor exists | has_index('geokrety', 'gk_moves', 'idx_gk_moves_replay_cursor') |
| T-1.6.002 | idx_gk_moves_prev_loc_lookup exists | has_index('geokrety', 'gk_moves', 'idx_gk_moves_prev_loc_lookup') |
| T-1.6.003 | idx_gk_moves_author_country_movedon exists | has_index('geokrety', 'gk_moves', 'idx_gk_moves_author_country_movedon') |
| T-1.6.004 | idx_gk_moves_geokret_country_movedon exists | has_index('geokrety', 'gk_moves', 'idx_gk_moves_geokret_country_movedon') |
| T-1.6.005 | idx_gk_moves_geokret_country_id exists | has_index('geokrety', 'gk_moves', 'idx_gk_moves_geokret_country_id') |
| T-1.6.006 | prev_loc_lookup is a partial index | SELECT indexdef LIKE '%WHERE%' FROM pg_indexes WHERE indexname = 'idx_gk_moves_prev_loc_lookup' is TRUE |
| T-1.6.007 | All 5 indexes are valid | SELECT bool_and(indisvalid) FROM pg_index WHERE indexrelid IN (SELECT oid FROM pg_class WHERE relname IN ('idx_gk_moves_replay_cursor', 'idx_gk_moves_prev_loc_lookup', 'idx_gk_moves_author_country_movedon', 'idx_gk_moves_geokret_country_movedon', 'idx_gk_moves_geokret_country_id')) |
| T-1.6.008 | replay_cursor index is on (moved_on_datetime, id) | Verify column list from pg_index + pg_attribute |
| T-1.6.009 | prev_loc_lookup index is on (geokret, moved_on_datetime DESC, id DESC) | Verify column list and sort direction |
| T-1.6.010 | All 5 indexes are B-tree type | SELECT bool_and(am.amname = 'btree') FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid JOIN pg_am am ON am.oid = c.relam WHERE c.relname IN (...) |
Implementation Checklist¶
- 1. Create migration file
20260310100500_create_source_table_indexes.php - 2. Verify Phinx auto-transaction is compatible with
CONCURRENTLY(may need--no-transactionflag or manual adapter handling) - 3. Run
phinx migrate(indexes build concurrently — may be slow on ~6.9M rows) - 4. Verify all 5 indexes exist and are valid (not in "invalid" state)
- 5. Verify partial index predicate on
idx_gk_moves_prev_loc_lookup - 6. Test EXPLAIN on replay cursor query to confirm index usage
- 7. Test EXPLAIN on previous-location lookup to confirm index usage
- 8. Run pgTAP tests T-1.6.001 through T-1.6.010
Step 1.7: Enable btree_gist Extension¶
What this step does: Enables the btree_gist PostgreSQL extension, which provides GiST operator classes for B-tree-compatible types (integer, timestamptz, etc.). This extension is required by the exclusion constraint on stats.gk_country_history in Sprint 3 (Step 3.4), which prevents overlapping temporal intervals for the same GK.
Migration file name: 20260310100600_enable_btree_gist.php
Full SQL DDL¶
Full Phinx Migration PHP Code¶
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class EnableBtreeGist extends AbstractMigration
{
public function up(): void
{
$this->execute('CREATE EXTENSION IF NOT EXISTS btree_gist;');
}
public function down(): void
{
$this->execute('DROP EXTENSION IF EXISTS btree_gist;');
}
}
SQL Usage Examples¶
-- Verify extension is installed
SELECT extname, extversion
FROM pg_extension
WHERE extname = 'btree_gist';
-- Expected: 1 row with 'btree_gist'
-- Verify GiST operator classes are available for integer + tstzrange
-- (These are needed for the exclusion constraint in Sprint 3)
SELECT opcname
FROM pg_opclass
WHERE opcmethod = (SELECT oid FROM pg_am WHERE amname = 'gist')
AND opcname LIKE '%int%'
LIMIT 5;
-- Expected: int4_ops, int8_ops, etc.
Graph/Visualization Specification¶
No visualization unlocked at this step. Extension enablement is infrastructure-only.
TimescaleDB Assessment¶
NOT applicable. This step enables a PostgreSQL extension.
pgTAP Unit Tests¶
| Test ID | Test Name | Assertion |
|---|---|---|
| T-1.7.001 | btree_gist extension exists | SELECT COUNT(*) = 1 FROM pg_extension WHERE extname = 'btree_gist' |
| T-1.7.002 | GiST int4 operator class available | SELECT COUNT(*) > 0 FROM pg_opclass WHERE opcname = 'gist_int4_ops' |
Implementation Checklist¶
- 1. Create migration file
20260310100600_enable_btree_gist.php - 2. Run
phinx migrate - 3. Verify
btree_gistextension exists inpg_extension - 4. Run pgTAP tests T-1.7.001 through T-1.7.002
6. Acceptance Criteria¶
- AC-101: Given the 5 preliminary stats migrations were previously applied, When Step 1.1 runs, Then all of their superseded schema objects (triggers, functions, tables, indexes, schema) are removed.
- AC-102: Given Step 1.1 completed, When Step 1.2 runs, Then the
statsschema exists. - AC-103: Given the
statsschema exists, When Step 1.3 runs, Thenstats.backfill_progressandstats.job_logexist with correct columns, types, and constraints. - AC-104: Given the
statsschema exists, When Step 1.4 runs, Thenstats.continent_referenceexists with at least 240 rows covering all 7 continents. - AC-105: Given
geokrety.gk_movesexists, When Step 1.5 runs, Thenprevious_move_id(BIGINT, nullable, DEFERRABLE FK) andkm_distance(NUMERIC(8,3), nullable) columns are added. - AC-106: Given
geokrety.gk_movesexists, When Step 1.6 runs, Then 5 indexes are created (including 1 partial) and all are valid. - AC-107: Given PostgreSQL 16.3, When Step 1.7 runs, Then
btree_gistextension is enabled. - AC-108: Given all 7 migrations run, When
phinx statusis checked, Then all 7 show statusup.
7. Test Automation Strategy¶
- Test Levels: Unit (pgTAP for schema validation), Integration (sequential migration execution)
- Frameworks: pgTAP 1.3.3 via
pg_prove - Test Data Management: Tests run inside
BEGIN/ROLLBACKtransactions — no persistent test data is created. Revert verification tests check for absence of objects. - CI/CD Integration: pgTAP tests added to GitHub Actions pipeline after migration step
- Coverage Requirements: Every table, column, index, constraint, and extension has at least one pgTAP assertion. Revert verification tests ensure the old objects are gone.
- Test file naming:
test-200-sprint1-revert-schema.sql,test-201-sprint1-operational.sql,test-202-sprint1-source-columns.sql,test-203-sprint1-indexes-extensions.sql
Consolidated pgTAP Test Summary¶
| Step | Test Count | Test ID Range | Test File |
|---|---|---|---|
| 1.1 Revert + 1.2 Schema | 10 | T-1.1.001 — T-1.2.002 | test-200-sprint1-revert-schema.sql |
| 1.3 Operational tables | 14 | T-1.3.001 — T-1.3.014 | test-201-sprint1-operational.sql |
| 1.4 Continent reference | 10 | T-1.4.001 — T-1.4.010 | test-201-sprint1-operational.sql |
| 1.5 Source columns | 10 | T-1.5.001 — T-1.5.010 | test-202-sprint1-source-columns.sql |
| 1.6 Source indexes | 10 | T-1.6.001 — T-1.6.010 | test-203-sprint1-indexes-extensions.sql |
| 1.7 btree_gist | 2 | T-1.7.001 — T-1.7.002 | test-203-sprint1-indexes-extensions.sql |
| Total | 56 |
Note: Total is 56 pgTAP assertions. The Sprint Index states ~62 tests; the difference allows room for additional edge-case tests to be added during implementation.
8. Rationale & Context¶
Why revert the preliminary migrations?¶
The five preliminary migrations were created as exploratory implementations. They have naming inconsistencies (e.g., trg_ vs tr_ prefix), partial coverage (no daily active users, no cache visits, no hourly activity), and missing features (no sharded counter snapshot functions, no previous-move trigger). Rather than patch them incrementally, a clean revert allows a coherent forward-only migration path with consistent naming, complete table coverage, and proper trigger ordering.
Why not use Phinx rollback?¶
The phinx rollback command would require running the down() methods of all 5 migrations, which may have side effects or fail if objects have been modified since. A single forward migration that drops everything is safer, idempotent (using IF EXISTS), and leaves a clear audit trail in the migration log.
Why add previous_move_id and km_distance to gk_moves?¶
Storing these on the source table avoids expensive runtime joins and geometry computations. The previous-move trigger (Sprint 2) sets them for new rows at INSERT time, and the backfill functions (Sprint 6) populate them for historical rows. The FK constraint ensures referential integrity while DEFERRABLE INITIALLY DEFERRED supports batch operations that may insert rows out of order.
Why NUMERIC(8,3) for km_distance?¶
NUMERIC(8,3) stores up to 99,999.999 km with exact decimal precision. This avoids floating-point rounding errors when summing distances across millions of rows. The maximum theoretical distance between two points on Earth is ~20,000 km, well within range.
Why 5 indexes?¶
Each index supports a specific query pattern:
- Replay cursor: ordered pagination for batch jobs
- Previous-location lookup: fast single-row lookup for distance trigger (partial index keeps it compact) 3-5. Country-based composites: support trigger queries and snapshot GROUP BYs that filter by author/geokret + country
Why enable btree_gist in Sprint 1?¶
The extension must be installed before any table can use it (Sprint 3's exclusion constraint). Installing it early avoids a dependency gap and ensures the extension is available when needed.
9. Dependencies & External Integrations¶
Infrastructure Dependencies¶
- INF-101: PostgreSQL 16.3 — required for all DDL operations,
IF NOT EXISTSguards,DEFERRABLEFK. - INF-102: PostGIS 3.4.2 — the
positioncolumn ongk_movesis ageographytype requiring PostGIS. - INF-103: pgTAP 1.3.3 — required for unit test execution via
pg_prove. - INF-104: Phinx migration framework (CakePHP) + PHP 8.x runtime.
Data Dependencies¶
- DAT-101:
geokrety.gk_movestable must exist with its current schema. - DAT-102: The 5 preliminary stats migrations must have been previously applied (their objects must exist for the revert to clean up).
- DAT-103: No direct dependency on
public.phinxlog; Sprint 1 schema behavior must not rely on Phinx bookkeeping table mutation.
Technology Platform Dependencies¶
- PLT-101: Operating system must allow
CREATE EXTENSION(requires superuser orpg_extension_ownerrole). - PLT-102:
CREATE INDEX CONCURRENTLYrequires no other session holding a conflicting lock.
10. Examples & Edge Cases¶
Edge Case 1: Preliminary migrations not applied¶
If one or more of the 5 preliminary migrations were never applied, the IF EXISTS guards in Step 1.1 ensure the cleanup succeeds without errors.
Edge Case 2: Stats schema already partially modified¶
If someone manually added tables to the stats schema beyond what the preliminary migrations created, DROP SCHEMA IF EXISTS stats CASCADE will remove ALL objects in the schema. This is intentional — Sprint 1 requires a clean slate.
Edge Case 3: CONCURRENTLY index build fails¶
If CREATE INDEX CONCURRENTLY fails (e.g., due to uniqueness violation on a unique index, which is not the case here, or lock timeout), the index is left in an "invalid" state. The pgTAP test T-1.6.007 checks that all indexes are valid. If an index is invalid, it must be dropped and recreated.
Edge Case 4: Existing indexes with same name¶
If indexes with the same names already exist (e.g., from a partial previous run), the IF NOT EXISTS guard prevents errors. However, the existing index definition may differ from the intended one. In this case, manually drop and recreate.
Edge Case 5: Large table column addition¶
Adding previous_move_id and km_distance to the ~6.9M-row gk_moves table is a metadata-only operation in PostgreSQL (no table rewrite) because both columns are nullable with no default value. This takes milliseconds regardless of table size.
-- Verify column addition is metadata-only (no rewrite)
-- The table's physical size should not change significantly
SELECT pg_size_pretty(pg_relation_size('geokrety.gk_moves')) AS table_size;
11. Validation Criteria¶
phinx statusshows all 7 Sprint 1 migrations asup- No objects from the 5 preliminary migrations remain (triggers, functions, tables, indexes)
statsschema exists and is empty except for Sprint 1 tablesstats.backfill_progresshas 14 columns with correct types and constraintsstats.job_loghas 6 columns with correct typesstats.continent_referencehas at least 240 rows covering 7 continentsgeokrety.gk_moves.previous_move_idis BIGINT nullable with DEFERRABLE FKgeokrety.gk_moves.km_distanceis NUMERIC(8,3) nullable- All 5 source table indexes exist and are valid
btree_gistextension is enabled- All 56+ pgTAP tests pass
12. Related Specifications / Further Reading¶
- 00-SPEC-DRAFT-v1.obsolete.md — Comprehensive schema design document (sections 3, 4, 5.8, 9.1)
- 00-SPRINT-INDEX.md — Sprint dependency graph and summary
- gamification-rules.instructions.md — Gamification rules reference
- PostgreSQL ALTER TABLE
- PostgreSQL CREATE INDEX CONCURRENTLY
- btree_gist Extension
- Phinx Migration Documentation
Appendix A: Migration Execution Order¶
| Order | Migration ID | File Name | Step |
|---|---|---|---|
| 1 | 20260310100000 |
20260310100000_revert_preliminary_stats.php |
1.1 |
| 2 | 20260310100100 |
20260310100100_create_stats_schema.php |
1.2 |
| 3 | 20260310100200 |
20260310100200_create_operational_support_tables.php |
1.3 |
| 4 | 20260310100300 |
20260310100300_create_continent_reference.php |
1.4 |
| 5 | 20260310100400 |
20260310100400_add_gk_moves_source_columns.php |
1.5 |
| 6 | 20260310100500 |
20260310100500_create_source_table_indexes.php |
1.6 |
| 7 | 20260310100600 |
20260310100600_enable_btree_gist.php |
1.7 |
Appendix B: Objects Created Summary¶
| Object Type | Schema | Name | Step |
|---|---|---|---|
| Schema | — | stats |
1.2 |
| Table | stats |
backfill_progress |
1.3 |
| Table | stats |
job_log |
1.3 |
| Table | stats |
continent_reference |
1.4 |
| Column | geokrety.gk_moves |
previous_move_id (BIGINT) |
1.5 |
| Column | geokrety.gk_moves |
km_distance (NUMERIC(8,3)) |
1.5 |
| Constraint | geokrety.gk_moves |
fk_gk_moves_previous_move (FK) |
1.5 |
| Index | geokrety |
idx_gk_moves_replay_cursor |
1.6 |
| Index | geokrety |
idx_gk_moves_prev_loc_lookup (partial) |
1.6 |
| Index | geokrety |
idx_gk_moves_author_country_movedon |
1.6 |
| Index | geokrety |
idx_gk_moves_geokret_country_movedon |
1.6 |
| Index | geokrety |
idx_gk_moves_geokret_country_id |
1.6 |
| Extension | — | btree_gist |
1.7 |
Appendix C: Objects Removed Summary¶
| Object Type | Schema | Name | From Migration | Step |
|---|---|---|---|---|
| Trigger | geokrety.gk_moves |
gk_moves_sharded_cnt_tr |
20260307140000 | 1.1 |
| Trigger | geokrety.gk_pictures |
gk_pictures_sharded_cnt_tr |
20260307140000 | 1.1 |
| Trigger | geokrety.gk_users |
gk_users_sharded_cnt_tr |
20260307140000 | 1.1 |
| Trigger | geokrety.gk_geokrety |
gk_geokrety_sharded_cnt_tr |
20260307140000 | 1.1 |
| Function | geokrety |
fn_gk_moves_sharded_counter() |
20260307140000 | 1.1 |
| Function | geokrety |
fn_gk_pictures_sharded_counter() |
20260307140000 | 1.1 |
| Function | geokrety |
fn_gk_users_sharded_counter() |
20260307140000 | 1.1 |
| Function | geokrety |
fn_gk_geokrety_sharded_counter() |
20260307140000 | 1.1 |
| Trigger | geokrety.gk_moves |
trg_update_user_countries |
20260304121000 | 1.1 |
| Trigger | geokrety.gk_moves |
trg_update_gk_countries_visited |
20260304121000 | 1.1 |
| Trigger | geokrety.gk_moves |
trg_update_country_stats |
20260304121000 | 1.1 |
| Trigger | geokrety.gk_moves |
trg_update_daily_activity |
20260304121000 | 1.1 |
| Trigger | geokrety.gk_users |
trg_update_global_counters_users |
20260304121000 | 1.1 |
| Function | geokrety |
fn_update_user_countries() |
20260304121000 | 1.1 |
| Function | geokrety |
fn_update_gk_countries_visited() |
20260304121000 | 1.1 |
| Function | geokrety |
fn_update_country_stats() |
20260304121000 | 1.1 |
| Function | geokrety |
fn_update_daily_activity() |
20260304121000 | 1.1 |
| Function | geokrety |
fn_update_global_counters_users() |
20260304121000 | 1.1 |
| Index | geokrety |
idx_gk_moves_author_country_movedon |
20260304120500 | 1.1 |
| Index | geokrety |
idx_gk_moves_geokret_country_movedon |
20260304120500 | 1.1 |
| Index | geokrety |
idx_gk_moves_geokret_country_id |
20260304120500 | 1.1 |
| Index | geokrety |
idx_gk_moves_replay_cursor |
20260228174500 | 1.1 |
| Table | stats |
entity_counters_shard |
20260307140000 | 1.1 |
| Table | stats |
All 11 tables | 20260304120000 | 1.1 |
| Schema | — | stats |
20260304120000 | 1.1 |