Task S2T05: Create Previous-Move Trigger Function + Attach¶
Master-Spec Alignment¶
The normative contract for this sprint is 00-SPRINT-INDEX.md plus the canonical Sprint 2 task set in docs/database-refactor/sprint-2/. ../00-SPEC-DRAFT-v1.obsolete.md is legacy context only and is not authoritative.
- Canonical previous-move function name:
geokrety.fn_set_previous_move_id_and_distance(). - Canonical
stats.daily_activitycolumn name:gk_created, notnew_geokrety_count. - Canonical
stats.daily_entity_countscolumn name:cnt, notcumulative_count. - Canonical
stats.daily_active_userscontract is presence-only:(activity_date, user_id)with no per-usermove_countcolumn. - The previous-move function must probe
geokrety.gk_geokrety.last_positionfirst, then fall back to ordered lookup ingeokrety.gk_moves. - Any lower sections that still use legacy names such as
fn_gk_moves_set_previous_move,new_geokrety_count,cumulative_count, ordistance_kmare obsolete draft text and are superseded by this alignment block.
Source¶
- Generated from sprint document step
2.5in02-sprint-2-counters-daily-activity.md.
Resolved Decision¶
- Canonical live contract for this task is
BEFORE INSERT OR UPDATE OR DELETEongeokrety.gk_moves, per Q-012/Q-019 user decision. INSERTandUPDATErecomputeprevious_move_idandkm_distancefor the current row.DELETEis accepted by the trigger contract and returnsOLD; downstream trigger families handle counter/date reversal on delete.- Guardrail: implementation must avoid self-recursive updates and infinite loops when move edits are replayed.
Step 2.5: Create Previous-Move Trigger Function + Attach¶
What this step does: Creates geokrety.fn_set_previous_move_id_and_distance() and attaches it as tr_gk_moves_before_prev_move on geokrety.gk_moves. This is the most critical trigger in the entire system. It sets NEW.previous_move_id and computes NEW.km_distance before the row is committed.
Key design decisions:
- BEFORE INSERT/UPDATE/DELETE — insert and update recompute the row's previous-move linkage and distance; delete is explicitly covered to keep trigger contracts consistent across move-trigger families.
- Location-bearing moves only — only processes
move_type IN (0, 1, 3, 5)(DROP, GRAB, SEEN, DIP) with non-NULL position. - Primary fast path via
geokrety.gk_geokrety.last_position— probe the GK row first, then fall back to ordered lookup ingeokrety.gk_movesonly if no fast-path candidate exists. - Sort by
(moved_on_datetime DESC, id DESC)— the fallback lookup uses canonical ordering when two moves share the same timestamp. - If no previous move found — both
previous_move_idandkm_distanceare left NULL (not set to 0).
Migration file name: 20260310200400_create_previous_move_trigger.php
Full SQL DDL¶
CREATE OR REPLACE FUNCTION geokrety.fn_set_previous_move_id_and_distance()
RETURNS TRIGGER LANGUAGE plpgsql AS $$
DECLARE
v_prev_move_id BIGINT;
BEGIN
IF TG_OP = 'DELETE' THEN
RETURN OLD;
END IF;
IF NEW.move_type NOT IN (0, 1, 3, 5) OR NEW.position IS NULL THEN
NEW.previous_move_id := NULL;
NEW.km_distance := NULL;
RETURN NEW;
END IF;
SELECT g.last_position INTO v_prev_move_id
FROM geokrety.gk_geokrety g
WHERE g.id = NEW.geokret;
IF v_prev_move_id IS NULL THEN
SELECT m.id
INTO v_prev_move_id
FROM geokrety.gk_moves m
WHERE m.geokret = NEW.geokret
AND m.position IS NOT NULL
AND m.move_type IN (0, 1, 3, 5)
AND (m.moved_on_datetime < NEW.moved_on_datetime
OR (m.moved_on_datetime = NEW.moved_on_datetime AND m.id < NEW.id))
ORDER BY m.moved_on_datetime DESC, m.id DESC
LIMIT 1;
END IF;
NEW.previous_move_id := v_prev_move_id;
IF NEW.previous_move_id IS NOT NULL THEN
NEW.km_distance := (
SELECT (public.ST_Distance(pm.position, NEW.position) / 1000.0)::NUMERIC(8,3)
FROM geokrety.gk_moves pm
WHERE pm.id = NEW.previous_move_id
AND pm.position IS NOT NULL
);
ELSE
NEW.km_distance := NULL;
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER tr_gk_moves_before_prev_move
BEFORE INSERT OR UPDATE OR DELETE ON geokrety.gk_moves
FOR EACH ROW EXECUTE FUNCTION geokrety.fn_set_previous_move_id_and_distance();
Full Phinx Migration PHP Code¶
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class CreatePreviousMoveTrigger extends AbstractMigration
{
public function up(): void
{
$this->execute(<<<'SQL'
CREATE OR REPLACE FUNCTION geokrety.fn_set_previous_move_id_and_distance()
RETURNS TRIGGER LANGUAGE plpgsql AS $$
DECLARE
v_prev_move_id BIGINT;
BEGIN
IF TG_OP = 'DELETE' THEN
RETURN OLD;
END IF;
IF NEW.move_type NOT IN (0, 1, 3, 5) OR NEW.position IS NULL THEN
NEW.previous_move_id := NULL;
NEW.km_distance := NULL;
RETURN NEW;
END IF;
SELECT g.last_position INTO v_prev_move_id
FROM geokrety.gk_geokrety g
WHERE g.id = NEW.geokret;
IF v_prev_move_id IS NULL THEN
SELECT m.id
INTO v_prev_move_id
FROM geokrety.gk_moves m
WHERE m.geokret = NEW.geokret
AND m.position IS NOT NULL
AND m.move_type IN (0, 1, 3, 5)
AND (m.moved_on_datetime < NEW.moved_on_datetime
OR (m.moved_on_datetime = NEW.moved_on_datetime AND m.id < NEW.id))
ORDER BY m.moved_on_datetime DESC, m.id DESC
LIMIT 1;
END IF;
NEW.previous_move_id := v_prev_move_id;
IF NEW.previous_move_id IS NOT NULL THEN
NEW.km_distance := (
SELECT (public.ST_Distance(pm.position, NEW.position) / 1000.0)::NUMERIC(8,3)
FROM geokrety.gk_moves pm
WHERE pm.id = NEW.previous_move_id
AND pm.position IS NOT NULL
);
ELSE
NEW.km_distance := NULL;
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER tr_gk_moves_before_prev_move
BEFORE INSERT OR UPDATE OR DELETE ON geokrety.gk_moves
FOR EACH ROW EXECUTE FUNCTION geokrety.fn_set_previous_move_id_and_distance();
SQL
);
}
public function down(): void
{
$this->execute('DROP TRIGGER IF EXISTS tr_gk_moves_before_prev_move ON geokrety.gk_moves;');
$this->execute('DROP FUNCTION IF EXISTS geokrety.fn_set_previous_move_id_and_distance() CASCADE;');
}
}
SQL Usage Examples¶
-- Verify trigger exists on gk_moves
SELECT tgname, tgtype, tgenabled
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 t.tgname = 'tr_gk_moves_before_prev_move';
-- Verify function exists
SELECT proname, pronamespace::regnamespace
FROM pg_proc
WHERE proname = 'fn_set_previous_move_id_and_distance';
-- Check km_distance was set on a recently inserted move
SELECT id, geokret, move_type, previous_move_id, km_distance
FROM geokrety.gk_moves
WHERE km_distance IS NOT NULL
ORDER BY id DESC
LIMIT 10;
-- Find the longest single move ever recorded
SELECT id, geokret, moved_on_datetime, km_distance
FROM geokrety.gk_moves
WHERE km_distance IS NOT NULL
ORDER BY km_distance DESC
LIMIT 5;
-- Verify COMMENT moves have km_distance = NULL (not set by trigger)
SELECT COUNT(*) FROM geokrety.gk_moves
WHERE move_type = 2 AND km_distance IS NOT NULL;
-- Expected: 0
Graph/Visualization Specification¶
Unlocked visualizations: This trigger does not directly unlock a chart, but it is the prerequisite for ALL distance-based visualizations:
- KM contributed timeline (Step 2.2 daily_activity)
- Country km statistics (Sprint 3 country_daily_stats.km_contributed)
- GK total km traveled (Sprint 5 user/GK stats)
- Gamification distance rewards (Sprint 4 points system)
ASCII Sample (distance data quality check):
GK #1 move 100→101: Warsaw→Berlin: 573.2 km
GK #1 move 101→102: Berlin→Prague: 280.4 km
GK #1 move 102→103: Prague→Vienna: 291.7 km
GK #42 move 200→201: Paris→London: 340.1 km
move 201→202: London→Dublin: 462.3 km
(COMMENT 203: no distance)
TimescaleDB Assessment¶
NOT applicable. This step creates a trigger function, not a table.
pgTAP Unit Tests¶
| Test ID | Test Name | Assertion |
|---|---|---|
| T-2.5.001 | Function fn_set_previous_move_id_and_distance exists | has_function('geokrety', 'fn_set_previous_move_id_and_distance', ARRAY[]::text[]) |
| T-2.5.002 | Function returns trigger | function_returns('geokrety', 'fn_set_previous_move_id_and_distance', ARRAY[]::text[], 'trigger') |
| T-2.5.003 | Trigger tr_gk_moves_before_prev_move exists | has_trigger('geokrety', 'gk_moves', 'tr_gk_moves_before_prev_move') |
| T-2.5.004 | DROP with prior DROP sets previous_move_id | Insert DROP then INSERT second DROP for same GK, verify previous_move_id is set to first move id |
| T-2.5.005 | DROP with prior DROP computes km_distance > 0 | Second DROP for different position — verify km_distance > 0 |
| T-2.5.006 | COMMENT move leaves previous_move_id NULL | Insert COMMENT (type 2), verify previous_move_id IS NULL |
| T-2.5.007 | ARCHIVE move leaves previous_move_id NULL | Insert ARCHIVE (type 4), verify previous_move_id IS NULL |
| T-2.5.008 | Move with NULL position leaves distance NULL | Insert DROP with position = NULL, verify km_distance IS NULL |
| T-2.5.009 | First move for a GK has NULL previous_move_id | Insert first DROP for new GK, verify previous_move_id IS NULL |
| T-2.5.010 | Distance is rounded to 3 decimal places | Insert two closely spaced points, verify km_distance scale is 3 |
| T-2.5.011 | UPDATE recomputes previous_move_id and km_distance | Update moved_on_datetime/position for a location-bearing move, verify linkage and distance are recalculated |
| T-2.5.012 | DELETE path returns OLD without recursion | Delete a move and verify trigger execution completes without recursive trigger-depth failures |
Implementation Checklist¶
- 1. Verify
geokrety.gk_moves.previous_move_idandkm_distancecolumns exist (Sprint 1, Step 1.5) - 2. Create migration file
20260310200400_create_previous_move_trigger.php - 3. Run
phinx migrate - 4. Verify function
geokrety.fn_set_previous_move_id_and_distance()exists - 5. Verify trigger
tr_gk_moves_before_prev_moveexists ongk_moves - 6. Test: INSERT drop after prior drop →
previous_move_idset,km_distance > 0 - 7. Test: COMMENT/ARCHIVE moves →
previous_move_idandkm_distanceremain NULL - 8. Test: Move with NULL position →
km_distanceremains NULL - 9. Test: First move for a GK →
previous_move_idremains NULL - 10. Test: UPDATE recalculates
previous_move_idandkm_distance - 11. Test: DELETE path executes safely (no infinite trigger loop)
- 12. Run pgTAP tests T-2.5.001 through T-2.5.012
Agent Loop Log¶
- 2026-03-10T17:56:26Z - Loop 1 -
dba: AcceptedBEFORE INSERT/UPDATE/DELETEcontract and required explicitTG_OP = 'DELETE'branch with loop-safety guardrail language. - 2026-03-10T17:56:26Z - Loop 1 -
critical-thinking: Confirmed this resolves Q-012/Q-019 contradiction; flagged that successor-row recomputation details remain an implementation hardening topic, not a contract blocker for this task. - 2026-03-10T17:56:26Z - Loop 1 -
specification: Updated canonical text, SQL/Phinx trigger event list, tests, and checklist to the agreed contract; consensus reached in loop 1.