feat: revamp analytics dashboards and harden database migrations

Add dashboard and overview analytics, health monitoring, provider expense tracking, and announcement updates across the gateway and frontend.

Keep schema migrations free of historical backfills while preserving automatic backfill execution. Bound migration deadlines, run schema preparation before Compose replacement, and anonymize deleted dashboard users.

Include the current documentation cleanup and regression coverage.
This commit is contained in:
elky
2026-10-01 11:48:17 +08:00
parent 60b89cc840
commit 066ea87d72
327 changed files with 31728 additions and 20645 deletions
@@ -109,3 +109,72 @@ remains byte-for-byte stable when composed:
The Rust migration tests compose these manifests too, so fragment drift is
caught during `cargo test -p aether-data split_baseline_sources_match_executable_migrations`.
## Statistics Migrations
The statistics release retains its applied migration history and includes
incremental upgrades for databases that ran the earlier overview and dashboard
definitions. Concurrent index operations remain separate because PostgreSQL
cannot run them inside a transaction.
| Version | Change |
|---|---|
| `20260911000000` | Overview facts, attribution, aggregate tables, and transaction-owned dirty-event queue. Attribution indexes are created while the new table is empty. |
| `20260917000000` | Original account-attribution migration, retained byte-for-byte for databases that already applied it. |
| `20260917000100` | Upgrade the original attribution trigger to the dirty-event queue before later concurrent index builds. |
| `20260918000000` | Create the replacement settlement covering index concurrently. |
| `20260918000100` | Drop the previous settlement covering index concurrently, after its replacement succeeds. |
| `20260919000000` | Dashboard aggregates, activation boundary, and retention support. |
| `20260920000000` | Create the credited-payment lookup index concurrently. |
| `20260920120000` | Provider expense records. |
| `20260921010000` | Add retention support to existing dashboard schemas; safe when the initial dashboard migration already includes it. |
| `20260921020000` | Add the attribution-owner lookup index concurrently on existing databases. |
| `20260921020100` | Create the usage metadata actor index concurrently. |
| `20261001000000` | Remove deleted-user attribution from dashboard activity on future user deletion; schema-only upgrade without rewriting historical rows. |
Do not remove an applied migration after folding its changes into an earlier
schema definition. Existing databases retain its version in `_sqlx_migrations`
and do not rerun earlier versions when their SQL changes. Preserve that history
and provide incremental migrations for any remaining schema differences.
These migrations do not backfill historical requests. Dashboard totals start at
the stored activation boundary. Background maintenance compacts dashboard minute
details older than 35 days in bounded batches, preserving cumulative totals and
the narrow activity counts; it does not delete source usage. JSONL backups include
the dashboard snapshot and its integrity manifest so retained totals can survive
restoration after source usage has expired.
Schema migrations and historical backfills remain separate phases. Normal `auto`
startup and `db prepare` still apply pending scripts from `backfills/postgres`
after schema migration; `verify-only` still requires both phases to be current.
The statistics schema migrations above do not embed a historical data rebuild.
The new dashboard's activation boundary is not moved by legacy backfills, so
they do not restore pre-activation dashboard totals.
Deleted users are excluded from dashboard active-user reads even when an older
version left orphan activity rows. The anonymization upgrade installs rules for
future deletions without cleaning old rows during migration; those old activity
rows age out through the existing 35-day retention task.
The overview worker can still rebuild a historical hour/day when normal writes
change facts in that bucket. That work runs after startup with bounded batches
and query deadlines; it is not a full historical rebuild during migration.
The migration runner defaults to a 1-second lock wait, a 10-second deadline per
transactional migration, and a 15-minute deadline per concurrent index migration.
Timeouts are configurable through `AETHER_POSTGRES_MIGRATION_LOCK_TIMEOUT_MS`,
`AETHER_POSTGRES_MIGRATION_TIMEOUT_MS`, and
`AETHER_POSTGRES_MIGRATION_CONCURRENT_TIMEOUT_MS`; none accepts zero. An independent
control connection attempts to terminate the migration session on failure or
cancellation. An interrupted concurrent index build can leave an invalid index;
the runner removes that index before retrying its migration.
For Compose deployments, `update.sh` applies schema migrations with the new image
before replacing the running app. A migration failure stops the update; already
committed migrations remain applied. Its `local-build` mode delegates to
`deploy.sh` and does not use this separate migration step. Allow for brief table
locks and I/O pressure from concurrent index scans during the upgrade. Keeping
historical backfills out of schema migrations does not make index creation
constant-time: concurrent indexes still scan existing rows and can take minutes
on a large database. The existing app stays running during the Compose migration
preflight.
@@ -18,10 +18,7 @@
-- Runs before 20260403000000_baseline.sql so that fresh databases have a
-- complete schema by the time baseline (a no-op handoff point) and all
-- later ADD COLUMN IF NOT EXISTS migrations execute.
SET statement_timeout = 0;
SET lock_timeout = 0;
-- Keep the migration runner's statement and lock deadlines in effect.
SET idle_in_transaction_session_timeout = 0;
SET client_encoding = 'UTF8';
@@ -0,0 +1,276 @@
ALTER TABLE public.usage ADD COLUMN IF NOT EXISTS failure_origin text;
ALTER TABLE public.usage ADD COLUMN IF NOT EXISTS failure_stage text;
ALTER TABLE public.usage ADD COLUMN IF NOT EXISTS failure_reason text;
ALTER TABLE public.usage ADD COLUMN IF NOT EXISTS failure_schema_version integer;
ALTER TABLE public.usage_settlement_snapshots
ADD COLUMN IF NOT EXISTS quota_covered_amount_usd numeric(20,8),
ADD COLUMN IF NOT EXISTS wallet_consumed_amount_usd numeric(20,8),
ADD COLUMN IF NOT EXISTS wallet_debit_amount_usd numeric(20,8),
ADD COLUMN IF NOT EXISTS wallet_recharge_debit_usd numeric(20,8),
ADD COLUMN IF NOT EXISTS wallet_gift_debit_usd numeric(20,8),
ADD COLUMN IF NOT EXISTS wallet_overdraft_usd numeric(20,8),
ADD COLUMN IF NOT EXISTS allocation_schema_version integer,
ADD COLUMN IF NOT EXISTS allocation_status text;
CREATE TABLE IF NOT EXISTS public.usage_attribution_snapshots (
request_id text PRIMARY KEY,
actor_user_id text,
credential_owner_id text,
attribution_kind text NOT NULL DEFAULT 'unknown',
attribution_source text NOT NULL DEFAULT 'unknown',
record_kind text NOT NULL DEFAULT 'request',
parent_request_id text,
schema_version integer NOT NULL DEFAULT 1,
attribution_revision bigint NOT NULL DEFAULT 1,
recorded_at timestamptz NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS ix_usage_attribution_actor_request
ON public.usage_attribution_snapshots(actor_user_id, request_id);
-- This attribution table is new and empty; build both lookup indexes here.
CREATE INDEX IF NOT EXISTS ix_usage_attribution_owner_request
ON public.usage_attribution_snapshots(credential_owner_id, request_id);
CREATE TABLE IF NOT EXISTS public.stats_bucket_state (
projection_version text NOT NULL,
granularity text NOT NULL,
bucket_start timestamptz NOT NULL,
source_revision bigint NOT NULL DEFAULT 0,
built_revision bigint NOT NULL DEFAULT -1,
coverage_status text NOT NULL DEFAULT 'unbuilt',
built_at timestamptz,
last_error text,
last_failed_at timestamptz,
PRIMARY KEY (projection_version, granularity, bucket_start)
);
CREATE INDEX IF NOT EXISTS ix_stats_bucket_state_dirty
ON public.stats_bucket_state(bucket_start)
WHERE source_revision > built_revision;
CREATE TABLE IF NOT EXISTS public.stats_overview_hourly (
projection_version text NOT NULL,
bucket_start timestamptz NOT NULL,
dimensions jsonb NOT NULL,
metrics jsonb NOT NULL,
PRIMARY KEY(projection_version, bucket_start, dimensions)
);
CREATE TABLE IF NOT EXISTS public.stats_overview_daily (
projection_version text NOT NULL,
bucket_start timestamptz NOT NULL,
dimensions jsonb NOT NULL,
metrics jsonb NOT NULL,
PRIMARY KEY(projection_version, bucket_start, dimensions)
);
-- These triggers cover old writers, delayed settlement, and maintenance in the fact transaction.
-- Only future fact mutations enqueue work; no historical rows are backfilled.
-- Each writer owns its transaction's keys, so unrelated requests never contend
-- on the current hour/day's stats_bucket_state row.
CREATE TABLE IF NOT EXISTS public.stats_overview_dirty_events (
transaction_id bigint NOT NULL,
projection_version text NOT NULL,
granularity text NOT NULL,
bucket_start timestamptz NOT NULL,
unrecoverable boolean NOT NULL DEFAULT false,
PRIMARY KEY (transaction_id, projection_version, granularity, bucket_start)
);
CREATE INDEX IF NOT EXISTS ix_stats_overview_dirty_events_bucket
ON public.stats_overview_dirty_events(projection_version, granularity, bucket_start);
CREATE OR REPLACE FUNCTION public.overview_mark_usage_bucket() RETURNS trigger
LANGUAGE plpgsql AS $$
DECLARE old_time timestamptz; new_time timestamptz; bucket record;
BEGIN
IF TG_TABLE_NAME = 'usage' THEN
IF TG_OP <> 'INSERT' THEN old_time := OLD.created_at; END IF;
IF TG_OP <> 'DELETE' THEN new_time := NEW.created_at; END IF;
ELSE
IF TG_OP <> 'INSERT' THEN
SELECT created_at INTO old_time FROM public.usage WHERE request_id = OLD.request_id;
END IF;
IF TG_OP <> 'DELETE' THEN
SELECT created_at INTO new_time FROM public.usage WHERE request_id = NEW.request_id;
END IF;
END IF;
FOR bucket IN
SELECT DISTINCT g, date_trunc(g, t AT TIME ZONE 'UTC') AT TIME ZONE 'UTC' AS starts
FROM unnest(ARRAY[old_time, new_time]) t CROSS JOIN unnest(ARRAY['day','hour']) g
WHERE t IS NOT NULL ORDER BY g, starts
LOOP
INSERT INTO public.stats_overview_dirty_events
(transaction_id, projection_version, granularity, bucket_start, unrecoverable)
VALUES (txid_current(), 'overview-v2', bucket.g, bucket.starts,
TG_TABLE_NAME = 'usage' AND TG_OP = 'DELETE')
ON CONFLICT (transaction_id, projection_version, granularity, bucket_start)
DO UPDATE SET unrecoverable = stats_overview_dirty_events.unrecoverable OR EXCLUDED.unrecoverable;
END LOOP;
RETURN NULL;
END $$;
DROP TRIGGER IF EXISTS overview_usage_dirty ON public.usage;
CREATE TRIGGER overview_usage_dirty AFTER INSERT OR UPDATE OR DELETE ON public.usage
FOR EACH ROW EXECUTE FUNCTION public.overview_mark_usage_bucket();
DROP TRIGGER IF EXISTS overview_settlement_dirty ON public.usage_settlement_snapshots;
CREATE TRIGGER overview_settlement_dirty AFTER INSERT OR UPDATE OR DELETE ON public.usage_settlement_snapshots
FOR EACH ROW EXECUTE FUNCTION public.overview_mark_usage_bucket();
DROP TRIGGER IF EXISTS overview_attribution_dirty ON public.usage_attribution_snapshots;
CREATE TRIGGER overview_attribution_dirty AFTER INSERT OR UPDATE OR DELETE ON public.usage_attribution_snapshots
FOR EACH ROW EXECUTE FUNCTION public.overview_mark_usage_bucket();
CREATE OR REPLACE FUNCTION public.overview_capture_usage_identity() RETURNS trigger
LANGUAGE plpgsql AS $$
DECLARE actor text; owner text; kind text; source text; standalone boolean;
BEGIN
SELECT id INTO owner FROM public.users WHERE id=NEW.user_id AND NOT is_deleted;
SELECT COALESCE(k.is_standalone,
CASE WHEN jsonb_typeof(NEW.request_metadata::jsonb #> '{analytics_attribution,is_standalone}')='boolean'
THEN (NEW.request_metadata #>> '{analytics_attribution,is_standalone}')::boolean END,
CASE WHEN jsonb_typeof(NEW.request_metadata::jsonb->'api_key_is_standalone')='boolean'
THEN (NEW.request_metadata->>'api_key_is_standalone')::boolean END,
CASE WHEN a.attribution_source='user_account' THEN false
WHEN a.attribution_source='standalone_key' THEN true END,
CASE WHEN NEW.api_key_id IS NULL THEN false END)
INTO standalone FROM (SELECT 1) seed
LEFT JOIN public.api_keys k ON k.id=NEW.api_key_id
LEFT JOIN public.usage_attribution_snapshots a ON a.request_id=NEW.request_id;
kind := CASE WHEN owner IS NULL THEN 'unknown' WHEN standalone THEN 'standalone'
WHEN NOT standalone THEN 'employee' ELSE 'unknown' END;
actor := CASE WHEN kind='employee' THEN owner END;
source := CASE kind WHEN 'employee' THEN 'user_account' WHEN 'standalone' THEN 'standalone_key' ELSE 'unknown' END;
INSERT INTO public.usage_attribution_snapshots(request_id, actor_user_id, credential_owner_id,
attribution_kind, attribution_source, record_kind, parent_request_id, attribution_revision)
VALUES (NEW.request_id, actor, owner, kind, source,
COALESCE(NEW.request_metadata #>> '{analytics_attribution,record_kind}', 'request'),
NEW.request_metadata #>> '{analytics_attribution,parent_request_id}', 2)
ON CONFLICT (request_id) DO UPDATE SET actor_user_id = EXCLUDED.actor_user_id,
credential_owner_id = EXCLUDED.credential_owner_id, attribution_kind = EXCLUDED.attribution_kind,
attribution_source = EXCLUDED.attribution_source,
record_kind = COALESCE(NEW.request_metadata #>> '{analytics_attribution,record_kind}', usage_attribution_snapshots.record_kind),
parent_request_id = COALESCE(EXCLUDED.parent_request_id, usage_attribution_snapshots.parent_request_id),
attribution_revision = EXCLUDED.attribution_revision, recorded_at = NOW()
WHERE usage_attribution_snapshots.attribution_revision <= EXCLUDED.attribution_revision;
RETURN NULL;
END $$;
DROP TRIGGER IF EXISTS overview_usage_identity ON public.usage;
CREATE TRIGGER overview_usage_identity AFTER INSERT OR UPDATE OF user_id, api_key_id, request_metadata ON public.usage
FOR EACH ROW EXECUTE FUNCTION public.overview_capture_usage_identity();
CREATE OR REPLACE FUNCTION public.overview_capture_failure() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
IF NEW.status = 'completed' THEN
NEW.failure_origin := NULL; NEW.failure_stage := NULL; NEW.failure_reason := NULL;
NEW.failure_schema_version := NULL; RETURN NEW;
END IF;
IF NEW.request_metadata #>> '{analytics_failure,origin}' IN ('client','gateway','upstream','transport','unknown') THEN
NEW.failure_origin := NEW.request_metadata #>> '{analytics_failure,origin}';
NEW.failure_stage := NEW.request_metadata #>> '{analytics_failure,stage}';
NEW.failure_reason := NEW.request_metadata #>> '{analytics_failure,reason}';
NEW.failure_schema_version := 1;
END IF;
RETURN NEW;
END $$;
DROP TRIGGER IF EXISTS overview_usage_failure ON public.usage;
CREATE TRIGGER overview_usage_failure BEFORE INSERT OR UPDATE OF request_metadata, status ON public.usage
FOR EACH ROW EXECUTE FUNCTION public.overview_capture_failure();
CREATE OR REPLACE FUNCTION public.overview_anonymize_user() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'DELETE' OR NEW.is_deleted THEN
UPDATE public.usage_attribution_snapshots SET actor_user_id = NULL, credential_owner_id = NULL,
attribution_kind = 'unknown', attribution_source = 'unknown', attribution_revision = attribution_revision + 10
WHERE actor_user_id = OLD.id OR credential_owner_id = OLD.id;
UPDATE public.usage SET request_metadata = (request_metadata::jsonb #- '{analytics_attribution,actor_user_id}')::json
WHERE request_metadata #>> '{analytics_attribution,actor_user_id}' = OLD.id;
DELETE FROM public.stats_overview_hourly WHERE dimensions->>'actor_user_id'=OLD.id OR dimensions->>'credential_owner_id'=OLD.id;
DELETE FROM public.stats_overview_daily WHERE dimensions->>'actor_user_id'=OLD.id OR dimensions->>'credential_owner_id'=OLD.id;
END IF;
RETURN NULL;
END $$;
DROP TRIGGER IF EXISTS overview_user_anonymize ON public.users;
CREATE TRIGGER overview_user_anonymize AFTER DELETE OR UPDATE OF is_deleted ON public.users
FOR EACH ROW EXECUTE FUNCTION public.overview_anonymize_user();
CREATE OR REPLACE FUNCTION public.overview_delete_attribution() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
DELETE FROM public.usage_attribution_snapshots WHERE request_id = OLD.request_id;
RETURN OLD;
END $$;
DROP TRIGGER IF EXISTS overview_usage_delete_attribution ON public.usage;
CREATE TRIGGER overview_usage_delete_attribution BEFORE DELETE ON public.usage
FOR EACH ROW EXECUTE FUNCTION public.overview_delete_attribution();
CREATE OR REPLACE VIEW public.usage_analytics_facts_v1 AS
SELECT u.request_id, COALESCE(u.id, u.request_id) AS id, u.created_at,
CASE WHEN identity.owner_id IS NOT NULL AND identity.is_standalone=false THEN identity.owner_id END AS actor_user_id,
identity.owner_id AS credential_owner_id,
CASE WHEN identity.owner_id IS NULL THEN 'unknown' WHEN identity.is_standalone THEN 'standalone'
WHEN NOT identity.is_standalone THEN 'employee' ELSE 'unknown' END AS attribution_kind,
CASE WHEN identity.owner_id IS NULL THEN 'unknown' WHEN identity.is_standalone THEN 'standalone_key'
WHEN NOT identity.is_standalone THEN 'user_account' ELSE 'unknown' END AS attribution_source,
COALESCE(a.record_kind, 'request') AS record_kind, a.parent_request_id,
u.api_key_id, u.model, u.target_model, u.provider_id, u.provider_name,
u.api_format, u.endpoint_kind, u.request_type, u.is_stream, u.has_format_conversion,
u.status, u.status_code, u.error_category, u.failure_origin, u.failure_stage, u.failure_reason,
u.failure_schema_version, u.response_time_ms, u.first_byte_time_ms,
COALESCE(s.billing_status, u.billing_status) AS settlement_status,
COALESCE(u.request_metadata::jsonb->'usage_available', 'true'::jsonb) <> 'false'::jsonb AS usage_available,
COALESCE(u.request_metadata::jsonb->'usage_pricing_available', 'true'::jsonb) <> 'false'::jsonb
AND (s.billing_total_cost_usd IS NOT NULL OR COALESCE(s.billing_status, u.billing_status) = 'settled') AS pricing_available,
CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_available', 'true'::jsonb) <> 'false'::jsonb
THEN b.input_tokens END AS input_tokens,
CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_available', 'true'::jsonb) <> 'false'::jsonb
THEN b.output_tokens END AS output_tokens,
CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_available', 'true'::jsonb) <> 'false'::jsonb
THEN b.total_tokens END AS total_tokens,
CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_available', 'true'::jsonb) <> 'false'::jsonb
THEN b.cache_read_input_tokens END AS cache_read_input_tokens,
CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_available', 'true'::jsonb) <> 'false'::jsonb
THEN b.cache_creation_input_tokens END AS cache_creation_input_tokens,
CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_pricing_available', 'true'::jsonb) <> 'false'::jsonb
AND (s.billing_total_cost_usd IS NOT NULL OR COALESCE(s.billing_status, u.billing_status) = 'settled')
THEN round(COALESCE(s.billing_total_cost_usd::numeric, u.total_cost_usd::numeric), 8) END AS rated_amount,
CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_pricing_available', 'true'::jsonb) <> 'false'::jsonb
AND (s.billing_actual_total_cost_usd IS NOT NULL OR COALESCE(s.billing_status, u.billing_status) = 'settled')
THEN round(COALESCE(s.billing_actual_total_cost_usd::numeric, u.actual_total_cost_usd::numeric), 8) END AS billable_amount,
s.quota_covered_amount_usd AS quota_covered_amount,
s.wallet_consumed_amount_usd AS wallet_consumed_amount,
s.wallet_debit_amount_usd AS wallet_debit_amount,
s.wallet_recharge_debit_usd AS wallet_recharge_debit_amount,
s.wallet_gift_debit_usd AS wallet_gift_debit_amount,
s.wallet_overdraft_usd AS wallet_overdraft_amount,
s.allocation_status, s.finalized_at AS settled_at,
CASE WHEN s.billing_total_cost_usd IS NOT NULL THEN 'settlement_snapshot' ELSE 'legacy_float' END AS amount_source,
b.upstream_is_stream,
CASE WHEN u.request_metadata #>> '{analytics_measurement,source}' IN ('reported','estimated','mixed')
THEN u.request_metadata #>> '{analytics_measurement,source}' ELSE 'unknown' END AS token_source,
CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_available','true'::jsonb) <> 'false'::jsonb
AND COALESCE(u.request_metadata::jsonb->'usage_pricing_available','true'::jsonb) <> 'false'::jsonb
AND s.input_price_per_1m IS NOT NULL AND s.billing_cache_read_cost_usd IS NOT NULL
THEN round(s.input_price_per_1m::numeric * b.cache_read_input_tokens::numeric / 1000000,8) END AS cache_estimated_full_cost_amount,
CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_available','true'::jsonb) <> 'false'::jsonb
AND COALESCE(u.request_metadata::jsonb->'usage_pricing_available','true'::jsonb) <> 'false'::jsonb
AND s.input_price_per_1m IS NOT NULL AND s.billing_cache_read_cost_usd IS NOT NULL
THEN round(s.billing_cache_read_cost_usd::numeric,8) END AS cache_read_cost_amount,
CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_available','true'::jsonb) <> 'false'::jsonb
AND COALESCE(u.request_metadata::jsonb->'usage_pricing_available','true'::jsonb) <> 'false'::jsonb
AND s.input_price_per_1m IS NOT NULL AND s.billing_cache_creation_cost_usd IS NOT NULL
THEN round(s.billing_cache_creation_cost_usd::numeric,8) END AS cache_creation_cost_amount
FROM public.usage u
LEFT JOIN public.usage_settlement_snapshots s USING (request_id)
LEFT JOIN public.usage_attribution_snapshots a USING (request_id)
JOIN public.usage_billing_facts b USING (request_id)
LEFT JOIN public.api_keys k ON k.id=u.api_key_id
CROSS JOIN LATERAL (
SELECT CASE WHEN a.request_id IS NOT NULL THEN a.credential_owner_id
WHEN EXISTS (SELECT 1 FROM public.users WHERE id=u.user_id AND NOT is_deleted) THEN u.user_id END AS owner_id,
COALESCE(k.is_standalone,
CASE WHEN jsonb_typeof(u.request_metadata::jsonb #> '{analytics_attribution,is_standalone}')='boolean'
THEN (u.request_metadata #>> '{analytics_attribution,is_standalone}')::boolean END,
CASE WHEN jsonb_typeof(u.request_metadata::jsonb->'api_key_is_standalone')='boolean'
THEN (u.request_metadata->>'api_key_is_standalone')::boolean END,
CASE WHEN a.attribution_source='user_account' THEN false
WHEN a.attribution_source='standalone_key' THEN true END,
CASE WHEN u.api_key_id IS NULL THEN false END) AS is_standalone
) identity;
@@ -0,0 +1,25 @@
-- Manual purchasing ledger; no foreign-key cascade may erase historical expenditures.
CREATE TABLE IF NOT EXISTS public.provider_expenses (
id text PRIMARY KEY,
client_request_id text NOT NULL UNIQUE,
provider_id text NOT NULL,
provider_name text NOT NULL,
kind text NOT NULL CHECK (kind IN ('recharge', 'subscription', 'other')),
amount numeric(20,8) NOT NULL CHECK (amount > 0),
currency text NOT NULL CHECK (currency ~ '^[A-Z]{3}$'),
paid_at timestamptz NOT NULL,
period_start timestamptz,
period_end timestamptz,
note text,
external_reference text,
created_by text,
created_at timestamptz NOT NULL DEFAULT NOW(),
voided_at timestamptz,
voided_by text,
CONSTRAINT provider_expenses_period_check CHECK (
(period_start IS NULL AND period_end IS NULL) OR
(period_start IS NOT NULL AND period_end IS NOT NULL AND period_start < period_end)
)
);
CREATE INDEX IF NOT EXISTS ix_provider_expenses_paid_at ON public.provider_expenses(paid_at, id);
CREATE INDEX IF NOT EXISTS ix_provider_expenses_provider_paid_at ON public.provider_expenses(provider_id, paid_at);
@@ -13,3 +13,4 @@
160_routing_profiles.sql
170_usage_legacy_body_ref_cleanup_index.sql
180_usage_stale_pending_cleanup_index.sql
210_provider_expenses.sql
@@ -18,10 +18,7 @@
-- Runs before 20260403000000_baseline.sql so that fresh databases have a
-- complete schema by the time baseline (a no-op handoff point) and all
-- later ADD COLUMN IF NOT EXISTS migrations execute.
SET statement_timeout = 0;
SET lock_timeout = 0;
-- Keep the migration runner's statement and lock deadlines in effect.
SET idle_in_transaction_session_timeout = 0;
SET client_encoding = 'UTF8';
@@ -104,6 +104,7 @@ ALTER TABLE ONLY public.payment_orders ADD CONSTRAINT uq_payment_orders_order_no
CREATE INDEX IF NOT EXISTS idx_payment_orders_wallet_created ON public.payment_orders USING btree (wallet_id, created_at);
CREATE INDEX IF NOT EXISTS idx_payment_orders_user_created ON public.payment_orders USING btree (user_id, created_at);
CREATE INDEX IF NOT EXISTS idx_payment_orders_status ON public.payment_orders USING btree (status);
CREATE INDEX IF NOT EXISTS idx_payment_orders_status_credited_user ON public.payment_orders USING btree (status, credited_at, user_id);
CREATE INDEX IF NOT EXISTS idx_payment_orders_gateway_order_id ON public.payment_orders USING btree (gateway_order_id);
CREATE UNIQUE INDEX IF NOT EXISTS uq_payment_orders_payment_method_gateway_order_id ON public.payment_orders USING btree (payment_method, gateway_order_id);
CREATE INDEX IF NOT EXISTS idx_payment_orders_kind_status ON public.payment_orders USING btree (order_kind, status);
@@ -99,7 +99,11 @@ CREATE TABLE IF NOT EXISTS public.usage (
wallet_gift_balance_after double precision,
finalized_at bigint,
created_at_unix_ms bigint DEFAULT 0 NOT NULL,
updated_at_unix_secs bigint DEFAULT 0 NOT NULL
updated_at_unix_secs bigint DEFAULT 0 NOT NULL,
failure_origin text,
failure_stage text,
failure_reason text,
failure_schema_version integer
);
ALTER TABLE ONLY public.usage ADD CONSTRAINT usage_pkey PRIMARY KEY (request_id);
@@ -204,6 +208,14 @@ CREATE INDEX IF NOT EXISTS ix_usage_counter_deltas_request_kind ON public.usage_
CREATE TABLE IF NOT EXISTS public.usage_settlement_snapshots (
request_id character varying(128) NOT NULL,
billing_status character varying(64) NOT NULL,
quota_covered_amount_usd numeric(20,8),
wallet_consumed_amount_usd numeric(20,8),
wallet_debit_amount_usd numeric(20,8),
wallet_recharge_debit_usd numeric(20,8),
wallet_gift_debit_usd numeric(20,8),
wallet_overdraft_usd numeric(20,8),
allocation_schema_version integer,
allocation_status text,
wallet_id character varying(64),
wallet_balance_before double precision,
wallet_balance_after double precision,
@@ -0,0 +1,63 @@
-- Generated by aether-data-schema from crates/aether-data/runtime/schema/logical/*.toml.
-- Do not edit generated files directly; edit logical schema or explicit overrides instead.
CREATE TABLE IF NOT EXISTS public.stats_bucket_state (
projection_version text NOT NULL,
granularity text NOT NULL,
bucket_start timestamp with time zone NOT NULL,
source_revision bigint DEFAULT 0 NOT NULL,
built_revision bigint DEFAULT -1 NOT NULL,
coverage_status text DEFAULT 'unbuilt' NOT NULL,
built_at timestamp with time zone,
last_error text,
last_failed_at timestamp with time zone
);
ALTER TABLE ONLY public.stats_bucket_state ADD CONSTRAINT stats_bucket_state_pkey PRIMARY KEY (projection_version, granularity, bucket_start);
CREATE TABLE IF NOT EXISTS public.stats_overview_daily (
projection_version text NOT NULL,
bucket_start timestamp with time zone NOT NULL,
dimensions jsonb NOT NULL,
metrics jsonb NOT NULL
);
ALTER TABLE ONLY public.stats_overview_daily ADD CONSTRAINT stats_overview_daily_pkey PRIMARY KEY (projection_version, bucket_start, dimensions);
CREATE TABLE IF NOT EXISTS public.stats_overview_dirty_events (
transaction_id bigint NOT NULL,
projection_version text NOT NULL,
granularity text NOT NULL,
bucket_start timestamp with time zone NOT NULL,
unrecoverable boolean DEFAULT false NOT NULL
);
ALTER TABLE ONLY public.stats_overview_dirty_events ADD CONSTRAINT stats_overview_dirty_events_pkey PRIMARY KEY (transaction_id, projection_version, granularity, bucket_start);
CREATE INDEX IF NOT EXISTS ix_stats_overview_dirty_events_bucket ON public.stats_overview_dirty_events USING btree (projection_version, granularity, bucket_start);
CREATE TABLE IF NOT EXISTS public.stats_overview_hourly (
projection_version text NOT NULL,
bucket_start timestamp with time zone NOT NULL,
dimensions jsonb NOT NULL,
metrics jsonb NOT NULL
);
ALTER TABLE ONLY public.stats_overview_hourly ADD CONSTRAINT stats_overview_hourly_pkey PRIMARY KEY (projection_version, bucket_start, dimensions);
CREATE TABLE IF NOT EXISTS public.usage_attribution_snapshots (
request_id text NOT NULL,
actor_user_id text,
credential_owner_id text,
attribution_kind text DEFAULT 'unknown' NOT NULL,
attribution_source text DEFAULT 'unknown' NOT NULL,
record_kind text DEFAULT 'request' NOT NULL,
parent_request_id text,
schema_version integer DEFAULT 1 NOT NULL,
attribution_revision bigint DEFAULT 1 NOT NULL,
recorded_at timestamp with time zone DEFAULT NOW() NOT NULL
);
ALTER TABLE ONLY public.usage_attribution_snapshots ADD CONSTRAINT usage_attribution_snapshots_pkey PRIMARY KEY (request_id);
CREATE INDEX IF NOT EXISTS ix_usage_attribution_actor_request ON public.usage_attribution_snapshots USING btree (actor_user_id, request_id);
CREATE INDEX IF NOT EXISTS ix_usage_attribution_owner_request ON public.usage_attribution_snapshots USING btree (credential_owner_id, request_id);
@@ -0,0 +1,84 @@
-- Generated by aether-data-schema from crates/aether-data/runtime/schema/logical/*.toml.
-- Do not edit generated files directly; edit logical schema or explicit overrides instead.
CREATE TABLE IF NOT EXISTS public.dashboard_activity_hour (
bucket_start timestamp with time zone NOT NULL,
shard smallint NOT NULL,
request_count bigint NOT NULL
);
ALTER TABLE ONLY public.dashboard_activity_hour ADD CONSTRAINT dashboard_activity_hour_pkey PRIMARY KEY (bucket_start, shard);
CREATE TABLE IF NOT EXISTS public.dashboard_activity_minute (
bucket_start timestamp with time zone NOT NULL,
shard smallint NOT NULL,
request_count bigint NOT NULL
);
ALTER TABLE ONLY public.dashboard_activity_minute ADD CONSTRAINT dashboard_activity_minute_pkey PRIMARY KEY (bucket_start, shard);
CREATE TABLE IF NOT EXISTS public.dashboard_actor_minute (
bucket_start timestamp with time zone NOT NULL,
shard smallint NOT NULL,
actor_user_id character varying(255) NOT NULL,
request_count bigint NOT NULL
);
ALTER TABLE ONLY public.dashboard_actor_minute ADD CONSTRAINT dashboard_actor_minute_pkey PRIMARY KEY (bucket_start, shard, actor_user_id);
CREATE TABLE IF NOT EXISTS public.dashboard_request_contributions (
request_id character varying(100) NOT NULL,
created_at timestamp with time zone NOT NULL,
actor_user_id character varying(255),
metrics jsonb NOT NULL
);
ALTER TABLE ONLY public.dashboard_request_contributions ADD CONSTRAINT dashboard_request_contributions_pkey PRIMARY KEY (request_id);
CREATE TABLE IF NOT EXISTS public.dashboard_stats_minute (
bucket_start timestamp with time zone NOT NULL,
shard smallint NOT NULL,
metrics jsonb DEFAULT '{}'::jsonb NOT NULL
);
ALTER TABLE ONLY public.dashboard_stats_minute ADD CONSTRAINT dashboard_stats_minute_pkey PRIMARY KEY (bucket_start, shard);
CREATE TABLE IF NOT EXISTS public.dashboard_stats_pending (
transaction_id bigint NOT NULL,
request_id character varying(100) NOT NULL,
deleted_fact jsonb
);
ALTER TABLE ONLY public.dashboard_stats_pending ADD CONSTRAINT dashboard_stats_pending_pkey PRIMARY KEY (transaction_id, request_id);
CREATE TABLE IF NOT EXISTS public.dashboard_stats_state (
singleton boolean DEFAULT true NOT NULL,
stats_since timestamp with time zone NOT NULL,
contributions_cleanup_cursor character varying(100)
);
ALTER TABLE ONLY public.dashboard_stats_state ADD CONSTRAINT dashboard_stats_state_pkey PRIMARY KEY (singleton);
CREATE TABLE IF NOT EXISTS public.dashboard_stats_total (
shard smallint NOT NULL,
metrics jsonb DEFAULT '{}'::jsonb NOT NULL
);
ALTER TABLE ONLY public.dashboard_stats_total ADD CONSTRAINT dashboard_stats_total_pkey PRIMARY KEY (shard);
CREATE TABLE IF NOT EXISTS public.dashboard_user_anonymization_pending (
transaction_id bigint NOT NULL,
user_id character varying(255) NOT NULL
);
ALTER TABLE ONLY public.dashboard_user_anonymization_pending ADD CONSTRAINT dashboard_user_anonymization_pending_pkey PRIMARY KEY (transaction_id, user_id);
CREATE TABLE IF NOT EXISTS public.dashboard_user_events_minute (
bucket_start timestamp with time zone NOT NULL,
shard smallint NOT NULL,
created_count bigint DEFAULT 0 NOT NULL,
deleted_count bigint DEFAULT 0 NOT NULL
);
ALTER TABLE ONLY public.dashboard_user_events_minute ADD CONSTRAINT dashboard_user_events_minute_pkey PRIMARY KEY (bucket_start, shard);
@@ -0,0 +1,27 @@
-- Generated by aether-data-schema from crates/aether-data/runtime/schema/logical/*.toml.
-- Do not edit generated files directly; edit logical schema or explicit overrides instead.
CREATE TABLE IF NOT EXISTS public.provider_expenses (
id text NOT NULL,
client_request_id text NOT NULL,
provider_id text NOT NULL,
provider_name text NOT NULL,
kind text NOT NULL,
amount numeric(20,8) NOT NULL,
currency text NOT NULL,
paid_at timestamp with time zone NOT NULL,
period_start timestamp with time zone,
period_end timestamp with time zone,
note text,
external_reference text,
created_by text,
created_at timestamp with time zone DEFAULT NOW() NOT NULL,
voided_at timestamp with time zone,
voided_by text
);
ALTER TABLE ONLY public.provider_expenses ADD CONSTRAINT provider_expenses_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.provider_expenses ADD CONSTRAINT provider_expenses_client_request_id_key UNIQUE (client_request_id);
CREATE INDEX IF NOT EXISTS ix_provider_expenses_paid_at ON public.provider_expenses USING btree (paid_at, id);
CREATE INDEX IF NOT EXISTS ix_provider_expenses_provider_paid_at ON public.provider_expenses USING btree (provider_id, paid_at);
@@ -9,3 +9,6 @@
006_usage.sql
007_stats.sql
008_background_tasks.sql
009_overview.sql
010_dashboard.sql
010_provider_expenses.sql
@@ -426,6 +426,10 @@ columns = ["user_id", "created_at"]
name = "idx_payment_orders_status"
columns = ["status"]
[[table.payment_orders.indexes]]
name = "idx_payment_orders_status_credited_user"
columns = ["status", "credited_at", "user_id"]
[[table.payment_orders.indexes]]
name = "idx_payment_orders_gateway_order_id"
columns = ["gateway_order_id"]
@@ -928,6 +928,26 @@ columns = ["processed_at", "created_at", "id"]
name = "ix_usage_counter_deltas_request_kind"
columns = ["request_id", "kind", "target_id"]
[[table.usage.columns]]
name = "failure_origin"
type = "text"
nullable = true
[[table.usage.columns]]
name = "failure_stage"
type = "text"
nullable = true
[[table.usage.columns]]
name = "failure_reason"
type = "text"
nullable = true
[[table.usage.columns]]
name = "failure_schema_version"
type = "int32"
nullable = true
[table.usage_settlement_snapshots]
domain = "usage"
order = 20
@@ -943,6 +963,52 @@ name = "billing_status"
type = "text"
length = 64
[[table.usage_settlement_snapshots.columns]]
name = "quota_covered_amount_usd"
type = "decimal_money"
nullable = true
driver.postgres.type = "numeric(20,8)"
[[table.usage_settlement_snapshots.columns]]
name = "wallet_consumed_amount_usd"
type = "decimal_money"
nullable = true
driver.postgres.type = "numeric(20,8)"
[[table.usage_settlement_snapshots.columns]]
name = "wallet_debit_amount_usd"
type = "decimal_money"
nullable = true
driver.postgres.type = "numeric(20,8)"
[[table.usage_settlement_snapshots.columns]]
name = "wallet_recharge_debit_usd"
type = "decimal_money"
nullable = true
driver.postgres.type = "numeric(20,8)"
[[table.usage_settlement_snapshots.columns]]
name = "wallet_gift_debit_usd"
type = "decimal_money"
nullable = true
driver.postgres.type = "numeric(20,8)"
[[table.usage_settlement_snapshots.columns]]
name = "wallet_overdraft_usd"
type = "decimal_money"
nullable = true
driver.postgres.type = "numeric(20,8)"
[[table.usage_settlement_snapshots.columns]]
name = "allocation_schema_version"
type = "int32"
nullable = true
[[table.usage_settlement_snapshots.columns]]
name = "allocation_status"
type = "text"
nullable = true
[[table.usage_settlement_snapshots.columns]]
name = "wallet_id"
type = "text_id"
@@ -0,0 +1,175 @@
[table.usage_attribution_snapshots]
domain = "usage"
primary_key = ["request_id"]
[[table.usage_attribution_snapshots.columns]]
name = "request_id"
type = "text"
[[table.usage_attribution_snapshots.columns]]
name = "actor_user_id"
type = "text"
nullable = true
[[table.usage_attribution_snapshots.columns]]
name = "credential_owner_id"
type = "text"
nullable = true
[[table.usage_attribution_snapshots.columns]]
name = "attribution_kind"
type = "text"
default = "unknown"
[[table.usage_attribution_snapshots.columns]]
name = "attribution_source"
type = "text"
default = "unknown"
[[table.usage_attribution_snapshots.columns]]
name = "record_kind"
type = "text"
default = "request"
[[table.usage_attribution_snapshots.columns]]
name = "parent_request_id"
type = "text"
nullable = true
[[table.usage_attribution_snapshots.columns]]
name = "schema_version"
type = "int32"
default = 1
[[table.usage_attribution_snapshots.columns]]
name = "attribution_revision"
type = "int64"
default = 1
[[table.usage_attribution_snapshots.columns]]
name = "recorded_at"
type = "timestamp"
default = { raw = "NOW()" }
[[table.usage_attribution_snapshots.indexes]]
name = "ix_usage_attribution_actor_request"
columns = ["actor_user_id", "request_id"]
[[table.usage_attribution_snapshots.indexes]]
name = "ix_usage_attribution_owner_request"
columns = ["credential_owner_id", "request_id"]
[table.stats_bucket_state]
domain = "stats"
primary_key = ["projection_version","granularity","bucket_start"]
[[table.stats_bucket_state.columns]]
name = "projection_version"
type = "text"
[[table.stats_bucket_state.columns]]
name = "granularity"
type = "text"
[[table.stats_bucket_state.columns]]
name = "bucket_start"
type = "timestamp"
[[table.stats_bucket_state.columns]]
name = "source_revision"
type = "int64"
default = 0
[[table.stats_bucket_state.columns]]
name = "built_revision"
type = "int64"
default = -1
[[table.stats_bucket_state.columns]]
name = "coverage_status"
type = "text"
default = "unbuilt"
[[table.stats_bucket_state.columns]]
name = "built_at"
type = "timestamp"
nullable = true
[[table.stats_bucket_state.columns]]
name = "last_error"
type = "text"
nullable = true
[[table.stats_bucket_state.columns]]
name = "last_failed_at"
type = "timestamp"
nullable = true
[table.stats_overview_hourly]
domain = "stats"
primary_key = ["projection_version","bucket_start","dimensions"]
[[table.stats_overview_hourly.columns]]
name = "projection_version"
type = "text"
[[table.stats_overview_hourly.columns]]
name = "bucket_start"
type = "timestamp"
[[table.stats_overview_hourly.columns]]
name = "dimensions"
type = "json"
[[table.stats_overview_hourly.columns]]
name = "metrics"
type = "json"
[table.stats_overview_daily]
domain = "stats"
primary_key = ["projection_version","bucket_start","dimensions"]
[[table.stats_overview_daily.columns]]
name = "projection_version"
type = "text"
[[table.stats_overview_daily.columns]]
name = "bucket_start"
type = "timestamp"
[[table.stats_overview_daily.columns]]
name = "dimensions"
type = "json"
[[table.stats_overview_daily.columns]]
name = "metrics"
type = "json"
[table.stats_overview_dirty_events]
domain = "stats"
primary_key = ["transaction_id", "projection_version", "granularity", "bucket_start"]
[[table.stats_overview_dirty_events.columns]]
name = "transaction_id"
type = "int64"
[[table.stats_overview_dirty_events.columns]]
name = "projection_version"
type = "text"
[[table.stats_overview_dirty_events.columns]]
name = "granularity"
type = "text"
[[table.stats_overview_dirty_events.columns]]
name = "bucket_start"
type = "timestamp"
[[table.stats_overview_dirty_events.columns]]
name = "unrecoverable"
type = "bool"
default = false
[[table.stats_overview_dirty_events.indexes]]
name = "ix_stats_overview_dirty_events_bucket"
columns = ["projection_version", "granularity", "bucket_start"]
@@ -0,0 +1,187 @@
# Future-only dashboard aggregates. Trigger functions and activation data live in
# migration 20260919000000_add_future_dashboard_summary.sql; no history is backfilled.
[table.dashboard_activity_hour]
domain = "stats"
primary_key = ["bucket_start", "shard"]
[[table.dashboard_activity_hour.columns]]
name = "bucket_start"
type = "timestamp"
[[table.dashboard_activity_hour.columns]]
name = "shard"
type = "int32"
driver.postgres.type = "smallint"
[[table.dashboard_activity_hour.columns]]
name = "request_count"
type = "int64"
[table.dashboard_stats_state]
domain = "stats"
primary_key = ["singleton"]
[[table.dashboard_stats_state.columns]]
name = "singleton"
type = "bool"
default = true
[[table.dashboard_stats_state.columns]]
name = "stats_since"
type = "timestamp"
[[table.dashboard_stats_state.columns]]
name = "contributions_cleanup_cursor"
type = "text"
length = 100
nullable = true
[table.dashboard_activity_minute]
domain = "stats"
primary_key = ["bucket_start", "shard"]
[[table.dashboard_activity_minute.columns]]
name = "bucket_start"
type = "timestamp"
[[table.dashboard_activity_minute.columns]]
name = "shard"
type = "int32"
driver.postgres.type = "smallint"
[[table.dashboard_activity_minute.columns]]
name = "request_count"
type = "int64"
[table.dashboard_request_contributions]
domain = "stats"
primary_key = ["request_id"]
[[table.dashboard_request_contributions.columns]]
name = "request_id"
type = "text"
length = 100
[[table.dashboard_request_contributions.columns]]
name = "created_at"
type = "timestamp"
[[table.dashboard_request_contributions.columns]]
name = "actor_user_id"
type = "text"
nullable = true
length = 255
[[table.dashboard_request_contributions.columns]]
name = "metrics"
type = "json"
[table.dashboard_stats_total]
domain = "stats"
primary_key = ["shard"]
[[table.dashboard_stats_total.columns]]
name = "shard"
type = "int32"
driver.postgres.type = "smallint"
[[table.dashboard_stats_total.columns]]
name = "metrics"
type = "json"
default = { raw = "'{}'::jsonb" }
[table.dashboard_stats_minute]
domain = "stats"
primary_key = ["bucket_start","shard"]
[[table.dashboard_stats_minute.columns]]
name = "bucket_start"
type = "timestamp"
[[table.dashboard_stats_minute.columns]]
name = "shard"
type = "int32"
driver.postgres.type = "smallint"
[[table.dashboard_stats_minute.columns]]
name = "metrics"
type = "json"
default = { raw = "'{}'::jsonb" }
[table.dashboard_actor_minute]
domain = "stats"
primary_key = ["bucket_start","shard","actor_user_id"]
[[table.dashboard_actor_minute.columns]]
name = "bucket_start"
type = "timestamp"
[[table.dashboard_actor_minute.columns]]
name = "shard"
type = "int32"
driver.postgres.type = "smallint"
[[table.dashboard_actor_minute.columns]]
name = "actor_user_id"
type = "text"
length = 255
[[table.dashboard_actor_minute.columns]]
name = "request_count"
type = "int64"
[table.dashboard_stats_pending]
domain = "stats"
primary_key = ["transaction_id","request_id"]
[[table.dashboard_stats_pending.columns]]
name = "transaction_id"
type = "int64"
[[table.dashboard_stats_pending.columns]]
name = "request_id"
type = "text"
length = 100
[[table.dashboard_stats_pending.columns]]
name = "deleted_fact"
type = "json"
nullable = true
[table.dashboard_user_events_minute]
domain = "stats"
primary_key = ["bucket_start","shard"]
[[table.dashboard_user_events_minute.columns]]
name = "bucket_start"
type = "timestamp"
[[table.dashboard_user_events_minute.columns]]
name = "shard"
type = "int32"
driver.postgres.type = "smallint"
[[table.dashboard_user_events_minute.columns]]
name = "created_count"
type = "int64"
default = 0
[[table.dashboard_user_events_minute.columns]]
name = "deleted_count"
type = "int64"
default = 0
# Transaction-local events only; no committed rows are exported or restored.
[table.dashboard_user_anonymization_pending]
domain = "stats"
primary_key = ["transaction_id", "user_id"]
[[table.dashboard_user_anonymization_pending.columns]]
name = "transaction_id"
type = "int64"
[[table.dashboard_user_anonymization_pending.columns]]
name = "user_id"
type = "text"
length = 255
@@ -0,0 +1,88 @@
[table.provider_expenses]
domain = "wallet_billing"
primary_key = ["id"]
[[table.provider_expenses.columns]]
name = "id"
type = "text"
[[table.provider_expenses.columns]]
name = "client_request_id"
type = "text"
[[table.provider_expenses.columns]]
name = "provider_id"
type = "text"
[[table.provider_expenses.columns]]
name = "provider_name"
type = "text"
[[table.provider_expenses.columns]]
name = "kind"
type = "text"
[[table.provider_expenses.columns]]
name = "amount"
type = "decimal_money"
driver.postgres.type = "numeric(20,8)"
[[table.provider_expenses.columns]]
name = "currency"
type = "text"
[[table.provider_expenses.columns]]
name = "paid_at"
type = "timestamp"
[[table.provider_expenses.columns]]
name = "period_start"
type = "timestamp"
nullable = true
[[table.provider_expenses.columns]]
name = "period_end"
type = "timestamp"
nullable = true
[[table.provider_expenses.columns]]
name = "note"
type = "text"
nullable = true
[[table.provider_expenses.columns]]
name = "external_reference"
type = "text"
nullable = true
[[table.provider_expenses.columns]]
name = "created_by"
type = "text"
nullable = true
[[table.provider_expenses.columns]]
name = "created_at"
type = "timestamp"
default = { raw = "NOW()" }
[[table.provider_expenses.columns]]
name = "voided_at"
type = "timestamp"
nullable = true
[[table.provider_expenses.columns]]
name = "voided_by"
type = "text"
nullable = true
[[table.provider_expenses.uniques]]
name = "provider_expenses_client_request_id_key"
columns = ["client_request_id"]
[[table.provider_expenses.indexes]]
name = "ix_provider_expenses_paid_at"
columns = ["paid_at", "id"]
[[table.provider_expenses.indexes]]
name = "ix_provider_expenses_provider_paid_at"
columns = ["provider_id", "paid_at"]