-- 002_positions_hypertable.sql
-- Creates the positions hypertable. This is the canonical positions schema
-- as of Phase 1. Future shape changes go through NEW numbered migration files;
-- never edit this file after it has been applied (the runner's checksum guard
-- will detect the edit and halt boot with exit code 2).
--
-- Schema authority: after this migration lands, directus/db-init/ is the
--   canonical definition of this table for operational purposes. The processor
--   service is the sole writer; Directus reads positions and manages the faulty
--   flag (added in migration 003). Do NOT alter this table from the Directus
--   admin UI — hypertable DDL must go through db-init migrations.
--
-- Cross-checked against:
--   processor/src/db/migrations/0001_positions.sql
--   Cross-check date: 2026-05-01
--
--   The processor migration is the ground truth for column names, types, and
--   nullability. Discrepancies between that file and the task spec
--   (03-initial-migrations.md) are documented below. The processor file wins
--   in all cases.
--
-- DIVERGENCES FROM 03-initial-migrations.md (task spec) — read before review:
--
--   1. ingested_at (timestamptz NOT NULL DEFAULT now())
--      Spec: column not listed.
--      Processor migration: present, NOT NULL, DEFAULT now().
--      Resolution: included here to match what processor writes. Omitting it
--      would cause NOT NULL violations on every processor insert.
--      RECOMMENDATION: add ingested_at to 03-initial-migrations.md deliverables.
--
--   2. altitude / angle / speed — type and nullability
--      Spec: DOUBLE PRECISION, nullable.
--      Processor migration: real NOT NULL (float32, not float64; NOT NULL).
--      Resolution: real NOT NULL matches the processor writer. Using
--      DOUBLE PRECISION would not cause failures (Postgres widens silently on
--      insert) but would waste storage for no gain. Nullable columns would
--      allow NULLs processor never writes and complicate query plans.
--      RECOMMENDATION: update spec to real NOT NULL for these three columns.
--
--   3. satellites / priority — nullability
--      Spec: SMALLINT (nullable).
--      Processor migration: smallint NOT NULL.
--      Resolution: NOT NULL matches what the processor always writes.
--      RECOMMENDATION: update spec to NOT NULL for these columns.
--
--   4. codec (text NOT NULL)
--      Spec: column not listed.
--      Processor migration: present, NOT NULL.
--      Resolution: included here. Omitting it causes NOT NULL failures on
--      processor inserts (codec is always populated — e.g. "codec8",
--      "codec8ext", "codec16").
--      RECOMMENDATION: add codec to 03-initial-migrations.md deliverables.
--
--   5. attributes DEFAULT
--      Spec: JSONB NOT NULL DEFAULT '{}'::jsonb.
--      Processor migration: jsonb NOT NULL (no DEFAULT).
--      Resolution: the task brief says "spec and processor disagree → processor
--      wins." Processor always writes attributes (never relies on a default),
--      so the column is declared NOT NULL with no DEFAULT here. A DEFAULT is
--      harmless but misleading; the processor writer never omits this field.
--      RECOMMENDATION: remove the default from the spec or leave it and
--      acknowledge it is unused by the writer.
--
--   6. PRIMARY KEY (device_id, ts) vs. UNIQUE INDEX
--      Spec: PRIMARY KEY (device_id, ts).
--      Processor migration: NO primary key; uses a separate
--      CREATE UNIQUE INDEX IF NOT EXISTS positions_device_ts ON positions (device_id, ts).
--      Resolution: TimescaleDB strongly discourages PRIMARY KEY on the
--      partition column (ts) — it requires ts to be part of every unique
--      constraint, which is true here, but the physical enforcement in
--      TimescaleDB is via unique index per chunk, not a table-level PK
--      constraint. The processor migration's approach (unique index, no PK)
--      is idiomatic for hypertables. This migration follows the processor:
--      no PRIMARY KEY, unique index instead.
--      RECOMMENDATION: change spec to use UNIQUE INDEX, not PRIMARY KEY.
--
--   7. Chunk interval: INTERVAL '1 day' vs. INTERVAL '7 days'
--      Spec: INTERVAL '7 days'.
--      Processor migration: INTERVAL '1 day'.
--      Resolution: processor migration wins. GPS telemetry at 1-60 second
--      intervals from hundreds of devices makes 1-day chunks a better fit
--      for range queries that span hours-to-days. 7-day chunks would create
--      much larger per-chunk indexes and slower chunk exclusion.
--      RECOMMENDATION: change spec to INTERVAL '1 day'.
--
--   8. Index shape
--      Spec: positions_device_ts_idx ON positions (device_id, ts DESC).
--      Processor migration: positions_device_ts ON positions (device_id, ts)
--        [ascending, no DESC] + positions_ts ON positions (ts DESC).
--      Resolution: two indexes are created here matching the processor
--      migration. The spec's single (device_id, ts DESC) composite is
--      not equivalent — it does not cover the (ts DESC) range-scan pattern
--      used by global timestamp queries.
--      RECOMMENDATION: update spec to list both indexes.

CREATE TABLE IF NOT EXISTS positions (
  device_id    text             NOT NULL,
  ts           timestamptz      NOT NULL,
  ingested_at  timestamptz      NOT NULL DEFAULT now(),
  latitude     double precision NOT NULL,
  longitude    double precision NOT NULL,
  altitude     real             NOT NULL,
  angle        real             NOT NULL,
  speed        real             NOT NULL,
  satellites   smallint         NOT NULL,
  priority     smallint         NOT NULL,
  codec        text             NOT NULL,
  attributes   jsonb            NOT NULL
);

-- Convert to a TimescaleDB hypertable partitioned by event time (ts).
--   chunk_time_interval = 1 day: appropriate for GPS telemetry where queries
--   span hours-to-days and devices send at 1-60 second intervals. Tunable
--   in a future migration but NOT via editing this file (checksum guard).
--   if_not_exists = TRUE: no-op if a stage environment already has the
--   hypertable; the chunk interval cannot be retroactively changed via this
--   call — that is an accepted known divergence documented in 03-initial-
--   migrations.md under Risks.
SELECT create_hypertable(
  'positions',
  'ts',
  if_not_exists => TRUE,
  chunk_time_interval => INTERVAL '1 day'
);

-- Unique index enforcing the natural key for idempotent upserts.
-- The processor writer uses ON CONFLICT (device_id, ts) DO NOTHING.
-- TimescaleDB's idiomatic pattern is a unique index (not a PRIMARY KEY
-- constraint) on the hypertable partition column — see divergence note 6.
CREATE UNIQUE INDEX IF NOT EXISTS positions_device_ts
  ON positions (device_id, ts);

-- Descending ts index for range queries scanning the most recent positions
-- first (e.g. "latest N positions" queries and time-bounded aggregations).
CREATE INDEX IF NOT EXISTS positions_ts
  ON positions (ts DESC);

-- -------------------------------------------------------------------------
-- Assertion block: verify the table and its shape after the statements above.
-- If any assertion fails, RAISE EXCEPTION halts boot immediately. The operator
-- gets an actionable error message naming the offending column/constraint.
-- This catches the case where a stage environment has the table but with
-- subtly different column types (the CREATE TABLE IF NOT EXISTS above is a
-- no-op against an existing table — silent drift without this block).
-- -------------------------------------------------------------------------
DO $$ DECLARE
  _hypertable_count int;
  _col_type         text;
BEGIN

  -- 1. Table exists
  IF NOT EXISTS (
    SELECT 1 FROM information_schema.tables
    WHERE table_schema = 'public' AND table_name = 'positions'
  ) THEN
    RAISE EXCEPTION 'positions table does not exist after migration 002';
  END IF;

  -- 2. Hypertable registered
  SELECT count(*) INTO _hypertable_count
  FROM timescaledb_information.hypertables
  WHERE hypertable_schema = 'public' AND hypertable_name = 'positions';
  IF _hypertable_count = 0 THEN
    RAISE EXCEPTION 'positions is not a hypertable — create_hypertable() may have failed silently';
  END IF;

  -- 3. Column assertions — one per critical column

  SELECT data_type INTO _col_type
  FROM information_schema.columns
  WHERE table_schema = 'public' AND table_name = 'positions' AND column_name = 'device_id';
  IF _col_type IS DISTINCT FROM 'text' THEN
    RAISE EXCEPTION 'positions.device_id expected type text, found %', coalesce(_col_type, 'MISSING');
  END IF;

  SELECT data_type INTO _col_type
  FROM information_schema.columns
  WHERE table_schema = 'public' AND table_name = 'positions' AND column_name = 'ts';
  IF _col_type IS DISTINCT FROM 'timestamp with time zone' THEN
    RAISE EXCEPTION 'positions.ts expected type "timestamp with time zone", found %', coalesce(_col_type, 'MISSING');
  END IF;

  SELECT data_type INTO _col_type
  FROM information_schema.columns
  WHERE table_schema = 'public' AND table_name = 'positions' AND column_name = 'ingested_at';
  IF _col_type IS DISTINCT FROM 'timestamp with time zone' THEN
    RAISE EXCEPTION 'positions.ingested_at expected type "timestamp with time zone", found %', coalesce(_col_type, 'MISSING');
  END IF;

  SELECT data_type INTO _col_type
  FROM information_schema.columns
  WHERE table_schema = 'public' AND table_name = 'positions' AND column_name = 'latitude';
  IF _col_type IS DISTINCT FROM 'double precision' THEN
    RAISE EXCEPTION 'positions.latitude expected type "double precision", found %', coalesce(_col_type, 'MISSING');
  END IF;

  SELECT data_type INTO _col_type
  FROM information_schema.columns
  WHERE table_schema = 'public' AND table_name = 'positions' AND column_name = 'longitude';
  IF _col_type IS DISTINCT FROM 'double precision' THEN
    RAISE EXCEPTION 'positions.longitude expected type "double precision", found %', coalesce(_col_type, 'MISSING');
  END IF;

  SELECT data_type INTO _col_type
  FROM information_schema.columns
  WHERE table_schema = 'public' AND table_name = 'positions' AND column_name = 'altitude';
  IF _col_type IS DISTINCT FROM 'real' THEN
    RAISE EXCEPTION 'positions.altitude expected type real, found %', coalesce(_col_type, 'MISSING');
  END IF;

  SELECT data_type INTO _col_type
  FROM information_schema.columns
  WHERE table_schema = 'public' AND table_name = 'positions' AND column_name = 'angle';
  IF _col_type IS DISTINCT FROM 'real' THEN
    RAISE EXCEPTION 'positions.angle expected type real, found %', coalesce(_col_type, 'MISSING');
  END IF;

  SELECT data_type INTO _col_type
  FROM information_schema.columns
  WHERE table_schema = 'public' AND table_name = 'positions' AND column_name = 'speed';
  IF _col_type IS DISTINCT FROM 'real' THEN
    RAISE EXCEPTION 'positions.speed expected type real, found %', coalesce(_col_type, 'MISSING');
  END IF;

  SELECT data_type INTO _col_type
  FROM information_schema.columns
  WHERE table_schema = 'public' AND table_name = 'positions' AND column_name = 'satellites';
  IF _col_type IS DISTINCT FROM 'smallint' THEN
    RAISE EXCEPTION 'positions.satellites expected type smallint, found %', coalesce(_col_type, 'MISSING');
  END IF;

  SELECT data_type INTO _col_type
  FROM information_schema.columns
  WHERE table_schema = 'public' AND table_name = 'positions' AND column_name = 'priority';
  IF _col_type IS DISTINCT FROM 'smallint' THEN
    RAISE EXCEPTION 'positions.priority expected type smallint, found %', coalesce(_col_type, 'MISSING');
  END IF;

  SELECT data_type INTO _col_type
  FROM information_schema.columns
  WHERE table_schema = 'public' AND table_name = 'positions' AND column_name = 'codec';
  IF _col_type IS DISTINCT FROM 'text' THEN
    RAISE EXCEPTION 'positions.codec expected type text, found %', coalesce(_col_type, 'MISSING');
  END IF;

  SELECT data_type INTO _col_type
  FROM information_schema.columns
  WHERE table_schema = 'public' AND table_name = 'positions' AND column_name = 'attributes';
  IF _col_type IS DISTINCT FROM 'jsonb' THEN
    RAISE EXCEPTION 'positions.attributes expected type jsonb, found %', coalesce(_col_type, 'MISSING');
  END IF;

  -- 4. Unique index on (device_id, ts)
  IF NOT EXISTS (
    SELECT 1 FROM pg_indexes
    WHERE schemaname = 'public'
      AND tablename = 'positions'
      AND indexname = 'positions_device_ts'
  ) THEN
    RAISE EXCEPTION 'unique index positions_device_ts is missing from positions';
  END IF;

  -- 5. Descending ts index
  IF NOT EXISTS (
    SELECT 1 FROM pg_indexes
    WHERE schemaname = 'public'
      AND tablename = 'positions'
      AND indexname = 'positions_ts'
  ) THEN
    RAISE EXCEPTION 'index positions_ts is missing from positions';
  END IF;

END $$;
