-- Sense Control API v1 durable concurrency, idempotency and batch-operation state. ALTER TABLE sense.devices ADD COLUMN IF NOT EXISTS profile_token text NOT NULL DEFAULT ''; ALTER TABLE sense.devices ADD COLUMN IF NOT EXISTS resource_version bigint NOT NULL DEFAULT 1; DO $area_preflight$ BEGIN IF EXISTS (SELECT 1 FROM sense.devices WHERE area_id IS NULL OR btrim(area_id) = '') THEN RAISE EXCEPTION 'assign every legacy Sense device to a valid Area before installing Control API v4'; END IF; END $area_preflight$; ALTER TABLE sense.devices ALTER COLUMN area_id SET NOT NULL; DO $constraints$ BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_constraint WHERE conrelid = 'sense.devices'::regclass AND conname = 'sense_devices_resource_version_positive' ) THEN ALTER TABLE sense.devices ADD CONSTRAINT sense_devices_resource_version_positive CHECK (resource_version >= 1); END IF; END $constraints$; CREATE TABLE IF NOT EXISTS sense.control_idempotency_receipts ( scope_hash bytea PRIMARY KEY, request_hash bytea NOT NULL, operation_name text NOT NULL, principal_id text NOT NULL, tenant_id text NOT NULL, site_id text NOT NULL, response_status integer NOT NULL, response_body jsonb NOT NULL, response_etag text, response_location text, trace_id text NOT NULL, created_at timestamptz NOT NULL, expires_at timestamptz NOT NULL, CONSTRAINT sense_control_receipt_hash_lengths CHECK ( octet_length(scope_hash) = 32 AND octet_length(request_hash) = 32 ), CONSTRAINT sense_control_receipt_identity_not_blank CHECK ( btrim(operation_name) <> '' AND btrim(principal_id) <> '' AND btrim(tenant_id) <> '' AND btrim(site_id) <> '' AND btrim(trace_id) <> '' ), CONSTRAINT sense_control_receipt_status CHECK (response_status BETWEEN 200 AND 299), CONSTRAINT sense_control_receipt_operation CHECK ( operation_name IN ('createDevice', 'batchSetDeviceDesiredState') ), CONSTRAINT sense_control_receipt_lengths CHECK ( char_length(principal_id) <= 200 AND char_length(trace_id) <= 128 AND (response_etag IS NULL OR char_length(response_etag) <= 128) AND (response_location IS NULL OR char_length(response_location) <= 512) ), CONSTRAINT sense_control_receipt_body_object CHECK (jsonb_typeof(response_body) = 'object'), CONSTRAINT sense_control_receipt_ttl CHECK (expires_at >= created_at + interval '24 hours') ); ALTER TABLE sense.control_idempotency_receipts OWNER TO sense_app; CREATE INDEX IF NOT EXISTS sense_control_receipts_expiry_idx ON sense.control_idempotency_receipts(expires_at, scope_hash); CREATE TABLE IF NOT EXISTS sense.batch_operations ( id text PRIMARY KEY, tenant_id text NOT NULL, site_id text NOT NULL, principal_id text NOT NULL, status text NOT NULL, trace_id text NOT NULL, submitted_at timestamptz NOT NULL, completed_at timestamptz, CONSTRAINT sense_batch_operation_id_format CHECK ( id ~ '^op_[0-9A-HJKMNP-TV-Z]{26}$' ), CONSTRAINT sense_batch_operation_identity_not_blank CHECK ( btrim(tenant_id) <> '' AND btrim(site_id) <> '' AND btrim(principal_id) <> '' AND btrim(trace_id) <> '' ), CONSTRAINT sense_batch_operation_status CHECK ( status IN ('queued', 'running', 'succeeded', 'partially_succeeded', 'failed') ), CONSTRAINT sense_batch_operation_completion CHECK ( (status IN ('queued', 'running') AND completed_at IS NULL) OR (status IN ('succeeded', 'partially_succeeded', 'failed') AND completed_at IS NOT NULL) ) ); ALTER TABLE sense.batch_operations OWNER TO sense_app; CREATE TABLE IF NOT EXISTS sense.batch_operation_items ( operation_id text NOT NULL REFERENCES sense.batch_operations(id) ON DELETE CASCADE, ordinal integer NOT NULL, device_id text NOT NULL, status text NOT NULL, error_code text, message text, generation bigint, PRIMARY KEY (operation_id, ordinal), CONSTRAINT sense_batch_item_ordinal CHECK (ordinal BETWEEN 0 AND 127), CONSTRAINT sense_batch_item_device_not_blank CHECK (btrim(device_id) <> ''), CONSTRAINT sense_batch_item_status CHECK ( status IN ('accepted', 'rejected', 'succeeded', 'failed') ), CONSTRAINT sense_batch_item_error_pair CHECK ( (status IN ('accepted', 'succeeded') AND error_code IS NULL AND message IS NULL) OR (status IN ('rejected', 'failed') AND error_code IS NOT NULL AND message IS NOT NULL) ), CONSTRAINT sense_batch_item_error_code CHECK ( error_code IS NULL OR error_code IN ( 'invalid_request', 'unauthenticated', 'forbidden', 'not_found', 'conflict', 'precondition_required', 'etag_mismatch', 'idempotency_conflict', 'duplicate_serial_number', 'quota_exceeded', 'quota_projection_unavailable', 'quota_projection_invalid', 'area_policy_denied', 'area_policy_unavailable', 'adapter_not_ready', 'authentication_failed', 'endpoint_credentials_forbidden', 'batch_too_large', 'service_unavailable', 'internal_error' ) ), CONSTRAINT sense_batch_item_message_length CHECK ( message IS NULL OR char_length(message) <= 500 ), CONSTRAINT sense_batch_item_generation CHECK (generation IS NULL OR generation >= 1) ); ALTER TABLE sense.batch_operation_items OWNER TO sense_app; CREATE INDEX IF NOT EXISTS sense_batch_operations_scope_idx ON sense.batch_operations(tenant_id, site_id, submitted_at DESC, id); ALTER TABLE sense.device_operation_outbox DROP CONSTRAINT IF EXISTS sense_outbox_event_type; ALTER TABLE sense.device_operation_outbox ADD CONSTRAINT sense_outbox_event_type CHECK ( event_type IN ( 'device.created', 'device.desired_state.accepted', 'device.configuration.accepted' ) ); ALTER TABLE sense.device_operation_outbox DROP CONSTRAINT IF EXISTS sense_outbox_payload_kind; ALTER TABLE sense.device_operation_outbox ADD CONSTRAINT sense_outbox_payload_kind CHECK ( (event_type = 'device.created' AND payload ->> 'kind' = 'device_created') OR (event_type = 'device.desired_state.accepted' AND payload ->> 'kind' = 'desired_state_accepted') OR (event_type = 'device.configuration.accepted' AND payload ->> 'kind' = 'configuration_accepted') ); INSERT INTO sense.schema_migrations(version) VALUES (4) ON CONFLICT (version) DO NOTHING;