Skip to main content

syrup_rail_postgres/
schema_contract.rs

1//! Read-only canonical schema conformance and feature-gated artifact support.
2
3use std::collections::BTreeSet;
4
5use sqlx::{PgConnection, PgPool};
6use thiserror::Error;
7
8/// The only PostgreSQL major version supported by this crate and schema
9/// contract.
10pub const SUPPORTED_POSTGRES_MAJOR_VERSION: u16 = 18;
11
12// These artifacts are intentionally unavailable to ordinary production
13// dependencies. Hosts materialize them through their own migration system;
14// the explicit feature is for contract fixtures and migration tests only.
15#[cfg(any(test, feature = "schema-contract-test-support"))]
16/// The immutable version-1 fresh-install artifact.
17pub const V1_INSTALL_SQL: &str = include_str!("../schema/v1/install.sql");
18#[cfg(any(test, feature = "schema-contract-test-support"))]
19/// The immutable version-2 fresh-install artifact.
20pub const V2_INSTALL_SQL: &str = include_str!("../schema/v2/install.sql");
21#[cfg(any(test, feature = "schema-contract-test-support"))]
22/// The immutable version-3 fresh-install artifact.
23pub const V3_INSTALL_SQL: &str = include_str!("../schema/v3/install.sql");
24#[cfg(any(test, feature = "schema-contract-test-support"))]
25/// The immutable version-4 fresh-install artifact.
26pub const V4_INSTALL_SQL: &str = include_str!("../schema/v4/install.sql");
27#[cfg(any(test, feature = "schema-contract-test-support"))]
28/// The read-only version-1-to-version-2 upgrade preflight.
29pub const V1_TO_V2_PREFLIGHT_SQL: &str = include_str!("../schema/v2/preflight_from_v1.sql");
30#[cfg(any(test, feature = "schema-contract-test-support"))]
31/// The read-only audit of v1 retry histories reclassified by version 2.
32pub const V1_TO_V2_RETRY_RECLASSIFICATION_AUDIT_SQL: &str =
33    include_str!("../schema/v2/audit_retry_reclassification_from_v1.sql");
34#[cfg(any(test, feature = "schema-contract-test-support"))]
35/// The immutable forward-only version-1-to-version-2 upgrade artifact.
36pub const V1_TO_V2_UPGRADE_SQL: &str = include_str!("../schema/v2/upgrade_from_v1.sql");
37#[cfg(any(test, feature = "schema-contract-test-support"))]
38/// The immutable forward-only version-2-to-version-3 upgrade artifact.
39pub const V2_TO_V3_UPGRADE_SQL: &str = include_str!("../schema/v3/upgrade_from_v2.sql");
40#[cfg(any(test, feature = "schema-contract-test-support"))]
41/// The first, fast-lock version-3-to-version-4 preparation artifact.
42pub const V3_TO_V4_PREPARE_SQL: &str = include_str!("../schema/v4/prepare_from_v3.sql");
43#[cfg(any(test, feature = "schema-contract-test-support"))]
44/// The separately committed, lower-lock version-3-to-version-4 validation artifact.
45pub const V3_TO_V4_VALIDATE_SQL: &str = include_str!("../schema/v4/validate_from_v3.sql");
46#[cfg(any(test, feature = "schema-contract-test-support"))]
47/// The non-transactional concurrent index stage for the version-3-to-version-4 cutover.
48pub const V3_TO_V4_INDEX_SQL: &str = include_str!("../schema/v4/index_from_v3.sql");
49#[cfg(any(test, feature = "schema-contract-test-support"))]
50/// The final version-3-to-version-4 cutover artifact.
51pub const V3_TO_V4_UPGRADE_SQL: &str = include_str!("../schema/v4/upgrade_from_v3.sql");
52
53// Non-cryptographic drift fingerprint over the canonical PostgreSQL catalog.
54// Host objects use host-prefixed names and are deliberately excluded.
55#[cfg(any(test, feature = "schema-contract-test-support"))]
56const V1_CATALOG_FINGERPRINT: u64 = 0xc949_7313_2b48_83d9;
57#[cfg(any(test, feature = "schema-contract-test-support"))]
58const V2_CATALOG_FINGERPRINT: u64 = 0x373b_9c1c_8b27_5be0;
59#[cfg(any(test, feature = "schema-contract-test-support"))]
60const V3_CATALOG_FINGERPRINT: u64 = 0x475d_91d1_6525_a966;
61const V4_CATALOG_FINGERPRINT: u64 = 0x0931_8e66_2d53_c5b6;
62const CONCURRENT_REINDEX_SHADOW_INDEX_PATTERN: &str = r"_cc(new|old)[0-9]*$";
63const REINDEX_TRANSITION_DETAIL: &str = "concurrent reindex state changed during schema validation";
64const REINDEX_TRANSITION_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(25);
65
66const REQUIRED_TABLES: &[&str] = &[
67    "billing_external_reversal_attestations",
68    "billing_gateway_accounts",
69    "billing_gateway_lifecycle_pending_updates",
70    "billing_gateway_lifecycle_quarantine_resolutions",
71    "billing_gateway_lifecycle_quarantines",
72    "billing_gateway_provider_rate_limits",
73    "billing_payment_attempts",
74    "billing_payment_methods",
75    "billing_processor_charges",
76    "billing_reconciliation_cursors",
77    "billing_subscription_discount_claims",
78    "billing_subscription_discount_codes",
79    "billing_subscription_discounts",
80    "billing_subscription_grants",
81    "billing_subscriptions",
82];
83
84const REQUIRED_VIEWS: &[&str] = &[
85    "billing_active_discount_facts",
86    "billing_current_subscriptions",
87    "billing_payment_facts",
88];
89
90const REQUIRED_FUNCTIONS: &[&str] = &[
91    "billing_canonical_gateway_transaction_id",
92    "billing_guard_processor_charge_evidence_update",
93    "billing_host_charge_ledger_admission",
94    "billing_set_attempt_review_required_at",
95    "billing_set_processor_charge_attempt_dimensions",
96];
97
98const REQUIRED_TRIGGERS: &[&str] = &[
99    "billing_payment_attempt_review_required_at",
100    "billing_processor_charge_attempt_dimensions",
101    "billing_processor_charge_evidence_immutable",
102];
103
104// Reader-facing indexes are semantic schema contracts, not incidental planner
105// hints. Keep these complete shapes aligned with the owning Rust queries.
106#[derive(Clone, Copy, Debug, Eq, PartialEq)]
107enum IndexKeyOrdering {
108    AscNullsLast,
109    DescNullsFirst,
110}
111
112impl IndexKeyOrdering {
113    const fn catalog_label(self) -> &'static str {
114        match self {
115            Self::AscNullsLast => "ASC NULLS LAST",
116            Self::DescNullsFirst => "DESC NULLS FIRST",
117        }
118    }
119}
120
121#[derive(Clone, Copy, Debug, Eq, PartialEq)]
122struct IndexKeyContract {
123    expression: &'static str,
124    ordering: IndexKeyOrdering,
125    opclass: &'static str,
126}
127
128#[derive(Clone, Copy, Debug, Eq, PartialEq)]
129struct IndexContract {
130    purpose: &'static str,
131    name: &'static str,
132    table: &'static str,
133    unique: bool,
134    keys: &'static [IndexKeyContract],
135    included_expressions: &'static [&'static str],
136    predicate: Option<&'static str>,
137}
138
139const GATEWAY_ORDER_INDEX_CONTRACT: IndexContract = IndexContract {
140    purpose: "gateway-order uniqueness",
141    name: "billing_payment_attempts_gateway_order_idx",
142    table: "billing_payment_attempts",
143    unique: true,
144    keys: &[
145        IndexKeyContract {
146            expression: "gateway_account_id",
147            ordering: IndexKeyOrdering::AscNullsLast,
148            opclass: "pg_catalog.uuid_ops",
149        },
150        IndexKeyContract {
151            expression: "gateway_order_id",
152            ordering: IndexKeyOrdering::AscNullsLast,
153            opclass: "pg_catalog.text_ops",
154        },
155    ],
156    included_expressions: &[],
157    predicate: None,
158};
159
160const RENEWAL_DISPATCH_INDEX_CONTRACT: IndexContract = IndexContract {
161    purpose: "renewal-dispatch keyset",
162    name: "billing_subscriptions_due_idx",
163    table: "billing_subscriptions",
164    unique: false,
165    keys: &[
166        IndexKeyContract {
167            expression: "next_payment_attempt_at",
168            ordering: IndexKeyOrdering::AscNullsLast,
169            opclass: "pg_catalog.timestamptz_ops",
170        },
171        IndexKeyContract {
172            expression: "id",
173            ordering: IndexKeyOrdering::AscNullsLast,
174            opclass: "pg_catalog.uuid_ops",
175        },
176    ],
177    included_expressions: &["billing_scope_id", "gateway_account_id", "next_renewal_at"],
178    predicate: Some(
179        "(status = ANY (ARRAY['active'::text, 'past_due'::text])) AND next_payment_attempt_at IS NOT NULL",
180    ),
181};
182
183const V4_RENEWAL_DISPATCH_INDEX_CONTRACT: IndexContract = IndexContract {
184    included_expressions: &[
185        "billing_scope_id",
186        "gateway_account_id",
187        "next_renewal_at",
188        "required_gateway_account_mode",
189    ],
190    ..RENEWAL_DISPATCH_INDEX_CONTRACT
191};
192
193const MODE_RENEWAL_DISPATCH_INDEX_CONTRACT: IndexContract = IndexContract {
194    purpose: "mode-specific renewal-dispatch keyset",
195    name: "billing_subscriptions_due_mode_idx",
196    table: "billing_subscriptions",
197    unique: false,
198    keys: &[
199        IndexKeyContract {
200            expression: "required_gateway_account_mode",
201            ordering: IndexKeyOrdering::AscNullsLast,
202            opclass: "pg_catalog.text_ops",
203        },
204        IndexKeyContract {
205            expression: "next_payment_attempt_at",
206            ordering: IndexKeyOrdering::AscNullsLast,
207            opclass: "pg_catalog.timestamptz_ops",
208        },
209        IndexKeyContract {
210            expression: "id",
211            ordering: IndexKeyOrdering::AscNullsLast,
212            opclass: "pg_catalog.uuid_ops",
213        },
214    ],
215    included_expressions: &["billing_scope_id", "gateway_account_id", "next_renewal_at"],
216    predicate: Some(
217        "(status = ANY (ARRAY['active'::text, 'past_due'::text])) AND next_payment_attempt_at IS NOT NULL",
218    ),
219};
220
221const SUBSCRIPTION_HISTORY_INDEX_CONTRACT: IndexContract = IndexContract {
222    purpose: "subscription-history keyset",
223    name: "billing_payment_attempts_subscription_history_idx",
224    table: "billing_payment_attempts",
225    unique: false,
226    keys: &[
227        IndexKeyContract {
228            expression: "billing_scope_id",
229            ordering: IndexKeyOrdering::AscNullsLast,
230            opclass: "pg_catalog.uuid_ops",
231        },
232        IndexKeyContract {
233            expression: "subscriber_id",
234            ordering: IndexKeyOrdering::AscNullsLast,
235            opclass: "pg_catalog.uuid_ops",
236        },
237        IndexKeyContract {
238            expression: "plan_key",
239            ordering: IndexKeyOrdering::AscNullsLast,
240            opclass: "pg_catalog.text_ops",
241        },
242        IndexKeyContract {
243            expression: "created_at",
244            ordering: IndexKeyOrdering::DescNullsFirst,
245            opclass: "pg_catalog.timestamptz_ops",
246        },
247        IndexKeyContract {
248            expression: "id",
249            ordering: IndexKeyOrdering::DescNullsFirst,
250            opclass: "pg_catalog.uuid_ops",
251        },
252    ],
253    included_expressions: &[],
254    predicate: Some("attempt_kind <> 'host_charge'::text"),
255};
256
257const PAYMENT_FACT_COLUMNS: &[&str] = &[
258    "attempt_id",
259    "billing_scope_id",
260    "subscriber_id",
261    "plan_key",
262    "host_charge_target_id",
263    "attempt_kind",
264    "status",
265    "amount_cents",
266    "currency",
267    "created_at",
268    "resolved_at",
269    "gateway_lifecycle_status",
270    "refunded_amount_cents",
271];
272
273#[cfg(any(test, feature = "schema-contract-test-support"))]
274const V1_CURRENT_SUBSCRIPTION_COLUMNS: &[&str] = &[
275    "id",
276    "billing_scope_id",
277    "gateway_account_id",
278    "subscriber_id",
279    "plan_key",
280    "status",
281    "payment_method_id",
282    "amount_cents",
283    "currency",
284    "current_period_start_at",
285    "current_period_end_at",
286    "next_renewal_at",
287    "initial_transaction_id",
288    "canceled_at",
289    "created_at",
290    "updated_at",
291    "current_subscription_rank",
292];
293
294#[cfg(any(test, feature = "schema-contract-test-support"))]
295const V2_CURRENT_SUBSCRIPTION_COLUMNS: &[&str] = &[
296    "id",
297    "billing_scope_id",
298    "gateway_account_id",
299    "subscriber_id",
300    "plan_key",
301    "status",
302    "payment_method_id",
303    "amount_cents",
304    "currency",
305    "current_period_start_at",
306    "current_period_end_at",
307    "next_renewal_at",
308    "initial_transaction_id",
309    "canceled_at",
310    "created_at",
311    "updated_at",
312    "current_subscription_rank",
313    "phase",
314    "recurring_period_kind",
315    "recurring_period_count",
316    "trial_amount_cents",
317    "trial_period_kind",
318    "trial_period_count",
319    "dunning_retry_delays_seconds",
320    "dunning_exhaustion",
321    "past_due_access",
322    "next_payment_attempt_at",
323    "unpaid_at",
324];
325
326#[cfg(any(test, feature = "schema-contract-test-support"))]
327const V3_CURRENT_SUBSCRIPTION_COLUMNS: &[&str] = V2_CURRENT_SUBSCRIPTION_COLUMNS;
328const V4_CURRENT_SUBSCRIPTION_COLUMNS: &[&str] = &[
329    "id",
330    "billing_scope_id",
331    "gateway_account_id",
332    "subscriber_id",
333    "plan_key",
334    "status",
335    "payment_method_id",
336    "amount_cents",
337    "currency",
338    "current_period_start_at",
339    "current_period_end_at",
340    "next_renewal_at",
341    "initial_transaction_id",
342    "canceled_at",
343    "created_at",
344    "updated_at",
345    "current_subscription_rank",
346    "phase",
347    "recurring_period_kind",
348    "recurring_period_count",
349    "trial_amount_cents",
350    "trial_period_kind",
351    "trial_period_count",
352    "dunning_retry_delays_seconds",
353    "dunning_exhaustion",
354    "past_due_access",
355    "next_payment_attempt_at",
356    "unpaid_at",
357    "required_gateway_account_mode",
358];
359
360/// Why a host database does not satisfy a canonical schema contract.
361///
362/// [`crate::assert_runtime_schema_v4_compatible`] reports version `4` in its
363/// [`Self::Contract`] diagnostic. Database failures include inability to begin
364/// or commit the read-only catalog snapshot.
365#[non_exhaustive]
366#[derive(Debug, Error)]
367pub enum SchemaConformanceError {
368    #[error("schema conformance query failed: {0}")]
369    Database(#[from] sqlx::Error),
370    #[error(
371        "PostgreSQL major version {required_major} is required; connected server reported server_version_num={actual_server_version_num}"
372    )]
373    UnsupportedPostgresVersion {
374        required_major: u16,
375        actual_server_version_num: i32,
376    },
377    #[error("schema version {version} does not conform: {detail}")]
378    Contract { version: u16, detail: String },
379}
380
381#[derive(Debug)]
382enum SchemaConformanceAttemptError {
383    Final(SchemaConformanceError),
384    RetryableReindexTransition { fallback: SchemaConformanceError },
385}
386
387impl From<SchemaConformanceError> for SchemaConformanceAttemptError {
388    fn from(error: SchemaConformanceError) -> Self {
389        Self::Final(error)
390    }
391}
392
393impl From<sqlx::Error> for SchemaConformanceAttemptError {
394    fn from(error: sqlx::Error) -> Self {
395        Self::Final(error.into())
396    }
397}
398
399/// Asserts that a historical host database matches the canonical schema-v3
400/// contract in tests or migration tooling built with the
401/// `schema-contract-test-support` feature.
402///
403/// This function is not part of the ordinary production facade because the
404/// 0.4 runtime requires schema v4. It does not install, upgrade, preflight,
405/// audit, or otherwise mutate the schema. It runs the full canonical v3 catalog conformance
406/// and fingerprint check used by the schema-contract tests in one
407/// `REPEATABLE READ READ ONLY` PostgreSQL transaction. PostgreSQL major version
408/// 18 is required; other majors are rejected before catalog comparison.
409/// A concurrent-reindex transition mismatch is retried once in a fresh
410/// transaction so a reindex that commits between catalog and live-operation
411/// observations cannot cause a stale result. Other contract failures are not
412/// retried.
413///
414/// Invalid `_ccnew` and `_ccold` shadows are tolerated only when the validating
415/// role can see matching non-initializing `REINDEX CONCURRENTLY` details in
416/// `pg_stat_progress_create_index` and the backend retains the expected table
417/// and index locks. PostgreSQL hides those details from unrelated roles without
418/// statistics privileges, for which this check deliberately fails closed.
419#[cfg(any(test, feature = "schema-contract-test-support"))]
420pub async fn assert_runtime_schema_v3_compatible(
421    pool: &PgPool,
422) -> Result<(), SchemaConformanceError> {
423    assert_schema_conforms_in_read_only_snapshot(
424        pool,
425        3,
426        V3_CURRENT_SUBSCRIPTION_COLUMNS,
427        V3_CATALOG_FINGERPRINT,
428    )
429    .await
430}
431
432/// Asserts that a host database is compatible with the canonical schema-v4
433/// contract before the host accepts billing work.
434///
435/// Call this after the host has applied its immutable Syrup Rail install or
436/// forward-only upgrade migration through its normal migration deployment.
437/// This function is read-only and requires PostgreSQL major version 18.
438pub async fn assert_runtime_schema_v4_compatible(
439    pool: &PgPool,
440) -> Result<(), SchemaConformanceError> {
441    assert_schema_conforms_in_read_only_snapshot(
442        pool,
443        4,
444        V4_CURRENT_SUBSCRIPTION_COLUMNS,
445        V4_CATALOG_FINGERPRINT,
446    )
447    .await
448}
449
450#[cfg(any(test, feature = "schema-contract-test-support"))]
451async fn assert_runtime_schema_v2_compatible(pool: &PgPool) -> Result<(), SchemaConformanceError> {
452    assert_schema_conforms_in_read_only_snapshot(
453        pool,
454        2,
455        V2_CURRENT_SUBSCRIPTION_COLUMNS,
456        V2_CATALOG_FINGERPRINT,
457    )
458    .await
459}
460
461#[cfg(any(test, feature = "schema-contract-test-support"))]
462/// Asserts that an already-migrated host database contains the canonical v2
463/// objects without exposing a production runtime migrator.
464pub async fn assert_v2_conforms(pool: &PgPool) -> Result<(), SchemaConformanceError> {
465    assert_runtime_schema_v2_compatible(pool).await
466}
467
468#[cfg(any(test, feature = "schema-contract-test-support"))]
469/// Asserts that an already-migrated host database contains the canonical v3
470/// objects without exposing a production runtime migrator.
471pub async fn assert_v3_conforms(pool: &PgPool) -> Result<(), SchemaConformanceError> {
472    assert_runtime_schema_v3_compatible(pool).await
473}
474
475#[cfg(any(test, feature = "schema-contract-test-support"))]
476/// Asserts that an already-migrated host database contains the canonical v4
477/// objects without exposing a production runtime migrator.
478pub async fn assert_v4_conforms(pool: &PgPool) -> Result<(), SchemaConformanceError> {
479    assert_runtime_schema_v4_compatible(pool).await
480}
481
482#[cfg(any(test, feature = "schema-contract-test-support"))]
483/// Asserts that an already-migrated host database contains the canonical v1
484/// objects without re-running or exposing a production migrator.
485///
486/// Separately named host objects are permitted. Canonical relations, views,
487/// functions, triggers, validated constraints, and bounded host read surfaces
488/// must remain present and retain their neutral vocabulary.
489pub async fn assert_v1_conforms(pool: &PgPool) -> Result<(), SchemaConformanceError> {
490    assert_schema_conforms_in_read_only_snapshot(
491        pool,
492        1,
493        V1_CURRENT_SUBSCRIPTION_COLUMNS,
494        V1_CATALOG_FINGERPRINT,
495    )
496    .await
497}
498
499async fn assert_schema_conforms_in_read_only_snapshot(
500    pool: &PgPool,
501    version: u16,
502    current_subscription_columns: &[&str],
503    expected_fingerprint: u64,
504) -> Result<(), SchemaConformanceError> {
505    retry_reindex_transition_once(|| {
506        assert_schema_conforms_in_one_read_only_snapshot(
507            pool,
508            version,
509            current_subscription_columns,
510            expected_fingerprint,
511        )
512    })
513    .await
514}
515
516async fn retry_reindex_transition_once<F, Fut>(
517    mut validate: F,
518) -> Result<(), SchemaConformanceError>
519where
520    F: FnMut() -> Fut,
521    Fut: std::future::Future<Output = Result<(), SchemaConformanceAttemptError>>,
522{
523    match validate().await {
524        Ok(()) => return Ok(()),
525        Err(SchemaConformanceAttemptError::Final(error)) => return Err(error),
526        Err(SchemaConformanceAttemptError::RetryableReindexTransition { .. }) => {}
527    }
528    tokio::time::sleep(REINDEX_TRANSITION_RETRY_DELAY).await;
529    match validate().await {
530        Ok(()) => Ok(()),
531        Err(SchemaConformanceAttemptError::Final(error)) => Err(error),
532        Err(SchemaConformanceAttemptError::RetryableReindexTransition { fallback }) => {
533            Err(fallback)
534        }
535    }
536}
537
538async fn assert_schema_conforms_in_one_read_only_snapshot(
539    pool: &PgPool,
540    version: u16,
541    current_subscription_columns: &[&str],
542    expected_fingerprint: u64,
543) -> Result<(), SchemaConformanceAttemptError> {
544    let mut transaction = pool
545        .begin_with("BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY")
546        .await?;
547    let result = async {
548        let server_version_num =
549            sqlx::query_scalar::<_, i32>("SELECT current_setting('server_version_num')::integer")
550                .fetch_one(&mut *transaction)
551                .await?;
552        require_supported_postgres_version_num(server_version_num)?;
553        assert_schema_conforms(
554            &mut transaction,
555            version,
556            current_subscription_columns,
557            expected_fingerprint,
558        )
559        .await
560    }
561    .await;
562    match result {
563        Ok(()) => transaction.commit().await?,
564        Err(error) => {
565            transaction.rollback().await?;
566            return Err(error);
567        }
568    }
569    Ok(())
570}
571
572fn require_supported_postgres_version_num(
573    actual_server_version_num: i32,
574) -> Result<(), SchemaConformanceError> {
575    if actual_server_version_num / 10_000 == i32::from(SUPPORTED_POSTGRES_MAJOR_VERSION) {
576        Ok(())
577    } else {
578        Err(SchemaConformanceError::UnsupportedPostgresVersion {
579            required_major: SUPPORTED_POSTGRES_MAJOR_VERSION,
580            actual_server_version_num,
581        })
582    }
583}
584
585async fn assert_schema_conforms(
586    connection: &mut PgConnection,
587    version: u16,
588    current_subscription_columns: &[&str],
589    expected_fingerprint: u64,
590) -> Result<(), SchemaConformanceAttemptError> {
591    let billing_indexes = load_billing_index_catalog(connection).await?;
592    require_relations(connection, version, 'r', REQUIRED_TABLES).await?;
593    require_relations(connection, version, 'v', REQUIRED_VIEWS).await?;
594    require_functions(connection, version).await?;
595    require_triggers(connection, version).await?;
596    require_view_columns(
597        connection,
598        version,
599        "billing_payment_facts",
600        PAYMENT_FACT_COLUMNS,
601    )
602    .await?;
603    require_view_columns(
604        connection,
605        version,
606        "billing_current_subscriptions",
607        current_subscription_columns,
608    )
609    .await?;
610    reject_legacy_columns(connection, version).await?;
611    require_validated_constraints(connection, version).await?;
612    require_ready_canonical_indexes(version, &billing_indexes)?;
613    require_index_contract(connection, version, GATEWAY_ORDER_INDEX_CONTRACT).await?;
614    if version >= 2 {
615        let renewal_dispatch_contract = if version >= 4 {
616            V4_RENEWAL_DISPATCH_INDEX_CONTRACT
617        } else {
618            RENEWAL_DISPATCH_INDEX_CONTRACT
619        };
620        require_index_contract(connection, version, renewal_dispatch_contract).await?;
621        require_index_contract(connection, version, SUBSCRIPTION_HISTORY_INDEX_CONTRACT).await?;
622    }
623    if version >= 4 {
624        require_index_contract(connection, version, MODE_RENEWAL_DISPATCH_INDEX_CONTRACT).await?;
625    }
626    require_catalog_fingerprint(connection, version, expected_fingerprint, &billing_indexes)
627        .await?;
628    require_unchanged_active_reindex_shadows(connection, version, &billing_indexes).await
629}
630
631async fn require_unchanged_active_reindex_shadows(
632    connection: &mut PgConnection,
633    version: u16,
634    initial_indexes: &[BillingIndexCatalogEntry],
635) -> Result<(), SchemaConformanceAttemptError> {
636    let initial_active_reindex_shadows = active_reindex_shadows(initial_indexes);
637    if initial_active_reindex_shadows.is_empty() {
638        return Ok(());
639    }
640    let rechecked_indexes = load_billing_index_catalog(connection).await?;
641    if initial_active_reindex_shadows == active_reindex_shadows(&rechecked_indexes) {
642        Ok(())
643    } else {
644        Err(SchemaConformanceAttemptError::RetryableReindexTransition {
645            fallback: contract_error(version, REINDEX_TRANSITION_DETAIL),
646        })
647    }
648}
649
650async fn require_relations(
651    connection: &mut PgConnection,
652    version: u16,
653    relation_kind: char,
654    expected: &[&str],
655) -> Result<(), SchemaConformanceError> {
656    let expected_names = expected
657        .iter()
658        .map(|name| (*name).to_owned())
659        .collect::<Vec<_>>();
660    let actual = sqlx::query_scalar::<_, String>(
661        r#"
662        SELECT relation.relname
663        FROM pg_catalog.pg_class AS relation
664        INNER JOIN pg_catalog.pg_namespace AS namespace
665            ON namespace.oid = relation.relnamespace
666        WHERE namespace.nspname = 'public'
667            AND relation.relkind = $1
668            AND relation.relname = ANY($2)
669        ORDER BY relation.relname
670        "#,
671    )
672    .bind(relation_kind.to_string())
673    .bind(&expected_names)
674    .fetch_all(&mut *connection)
675    .await?;
676    require_exact_set(version, "relations", expected, actual)
677}
678
679async fn require_functions(
680    connection: &mut PgConnection,
681    version: u16,
682) -> Result<(), SchemaConformanceError> {
683    let expected = REQUIRED_FUNCTIONS
684        .iter()
685        .map(|name| (*name).to_owned())
686        .collect::<Vec<_>>();
687    let actual = sqlx::query_scalar::<_, String>(
688        r#"
689        SELECT DISTINCT function.proname
690        FROM pg_catalog.pg_proc AS function
691        INNER JOIN pg_catalog.pg_namespace AS namespace
692            ON namespace.oid = function.pronamespace
693        WHERE namespace.nspname = 'public'
694            AND function.prokind = 'f'
695            AND function.proname = ANY($1)
696        ORDER BY function.proname
697        "#,
698    )
699    .bind(&expected)
700    .fetch_all(&mut *connection)
701    .await?;
702    require_exact_set(version, "functions", REQUIRED_FUNCTIONS, actual)
703}
704
705async fn require_triggers(
706    connection: &mut PgConnection,
707    version: u16,
708) -> Result<(), SchemaConformanceError> {
709    let expected = REQUIRED_TRIGGERS
710        .iter()
711        .map(|name| (*name).to_owned())
712        .collect::<Vec<_>>();
713    let actual = sqlx::query_scalar::<_, String>(
714        r#"
715        SELECT trigger.tgname
716        FROM pg_catalog.pg_trigger AS trigger
717        INNER JOIN pg_catalog.pg_class AS relation
718            ON relation.oid = trigger.tgrelid
719        INNER JOIN pg_catalog.pg_namespace AS namespace
720            ON namespace.oid = relation.relnamespace
721        WHERE namespace.nspname = 'public'
722            AND NOT trigger.tgisinternal
723            AND trigger.tgenabled = 'O'
724            AND trigger.tgname = ANY($1)
725        ORDER BY trigger.tgname
726        "#,
727    )
728    .bind(&expected)
729    .fetch_all(&mut *connection)
730    .await?;
731    require_exact_set(version, "triggers", REQUIRED_TRIGGERS, actual)
732}
733
734async fn require_view_columns(
735    connection: &mut PgConnection,
736    version: u16,
737    view: &str,
738    expected_columns: &[&str],
739) -> Result<(), SchemaConformanceError> {
740    let actual = sqlx::query_scalar::<_, String>(
741        r#"
742        SELECT column_name
743        FROM information_schema.columns
744        WHERE table_schema = 'public'
745            AND table_name = $1
746        ORDER BY ordinal_position
747        "#,
748    )
749    .bind(view)
750    .fetch_all(&mut *connection)
751    .await?;
752    let expected = expected_columns
753        .iter()
754        .map(|column| (*column).to_owned())
755        .collect::<Vec<_>>();
756    if actual == expected {
757        Ok(())
758    } else {
759        Err(contract_error(
760            version,
761            format!("{view} columns differ: expected {expected:?}, found {actual:?}"),
762        ))
763    }
764}
765
766async fn reject_legacy_columns(
767    connection: &mut PgConnection,
768    version: u16,
769) -> Result<(), SchemaConformanceError> {
770    let tables = REQUIRED_TABLES
771        .iter()
772        .map(|name| (*name).to_owned())
773        .collect::<Vec<_>>();
774    let legacy = sqlx::query_as::<_, (String, String)>(
775        r#"
776        SELECT table_name, column_name
777        FROM information_schema.columns
778        WHERE table_schema = 'public'
779            AND table_name = ANY($1)
780            AND (
781                column_name IN (
782                    'tenant_id',
783                    'user_id',
784                    'product',
785                    'admin_user_id',
786                    'granted_by_admin_user_id',
787                    'revoked_by_admin_user_id',
788                    'app_resolution_code',
789                    'acquisition_channel'
790                )
791                OR column_name LIKE 'nmi\_%' ESCAPE '\'
792                OR column_name LIKE 'base\_subscription\_%' ESCAPE '\'
793                OR column_name LIKE 'google\_ads\_%' ESCAPE '\'
794            )
795        ORDER BY table_name, column_name
796        "#,
797    )
798    .bind(&tables)
799    .fetch_all(&mut *connection)
800    .await?;
801    if legacy.is_empty() {
802        Ok(())
803    } else {
804        Err(contract_error(
805            version,
806            format!("legacy columns remain on canonical relations: {legacy:?}"),
807        ))
808    }
809}
810
811async fn require_validated_constraints(
812    connection: &mut PgConnection,
813    version: u16,
814) -> Result<(), SchemaConformanceError> {
815    let tables = REQUIRED_TABLES
816        .iter()
817        .map(|name| (*name).to_owned())
818        .collect::<Vec<_>>();
819    let invalid = sqlx::query_as::<_, (String, String)>(
820        r#"
821        SELECT relation.relname, catalog_constraint.conname
822        FROM pg_catalog.pg_constraint AS catalog_constraint
823        INNER JOIN pg_catalog.pg_class AS relation
824            ON relation.oid = catalog_constraint.conrelid
825        INNER JOIN pg_catalog.pg_namespace AS namespace
826            ON namespace.oid = relation.relnamespace
827        WHERE namespace.nspname = 'public'
828            AND relation.relname = ANY($1)
829            AND catalog_constraint.conname LIKE 'billing\_%' ESCAPE '\'
830            AND NOT catalog_constraint.convalidated
831        ORDER BY relation.relname, catalog_constraint.conname
832        "#,
833    )
834    .bind(&tables)
835    .fetch_all(&mut *connection)
836    .await?;
837    if invalid.is_empty() {
838        Ok(())
839    } else {
840        Err(contract_error(
841            version,
842            format!("canonical constraints are not validated: {invalid:?}"),
843        ))
844    }
845}
846
847fn require_ready_canonical_indexes(
848    version: u16,
849    billing_indexes: &[BillingIndexCatalogEntry],
850) -> Result<(), SchemaConformanceAttemptError> {
851    let unavailable = billing_indexes
852        .iter()
853        .filter(|index| {
854            index.active_concurrent_reindex_pid.is_none()
855                && !(index.is_valid && index.is_ready && index.is_live)
856        })
857        .collect::<Vec<_>>();
858    if unavailable.is_empty() {
859        return Ok(());
860    }
861    let retryable_reindex_transition = unavailable
862        .iter()
863        .all(|index| !index.is_valid && has_concurrent_reindex_shadow_suffix(&index.index_name));
864    let unavailable_detail = unavailable
865        .iter()
866        .map(|index| {
867            (
868                index.table_name.clone(),
869                index.index_name.clone(),
870                index.is_valid,
871                index.is_ready,
872                index.is_live,
873            )
874        })
875        .collect::<Vec<_>>();
876    let fallback = contract_error(
877        version,
878        format!(
879            "canonical indexes are not planner/write ready (table, index, valid, ready, live): {unavailable_detail:?}; invalid _ccnew/_ccold indexes are tolerated only while matching REINDEX CONCURRENTLY progress is visible to the validating role, and stale shadows left by failed maintenance must be dropped"
880        ),
881    );
882    if retryable_reindex_transition {
883        Err(SchemaConformanceAttemptError::RetryableReindexTransition { fallback })
884    } else {
885        Err(fallback.into())
886    }
887}
888
889fn has_concurrent_reindex_shadow_suffix(index_name: &str) -> bool {
890    ["_ccnew", "_ccold"].iter().any(|marker| {
891        index_name
892            .rsplit_once(marker)
893            .is_some_and(|(base, counter)| {
894                !base.is_empty() && counter.bytes().all(|byte| byte.is_ascii_digit())
895            })
896    })
897}
898
899#[derive(Clone, Debug, sqlx::FromRow)]
900struct BillingIndexCatalogEntry {
901    table_name: String,
902    index_name: String,
903    definition: String,
904    is_valid: bool,
905    is_ready: bool,
906    is_live: bool,
907    active_concurrent_reindex_pid: Option<i32>,
908}
909
910fn active_reindex_shadows(indexes: &[BillingIndexCatalogEntry]) -> Vec<(&str, &str, i32)> {
911    indexes
912        .iter()
913        .filter_map(|index| {
914            index
915                .active_concurrent_reindex_pid
916                .map(|pid| (index.table_name.as_str(), index.index_name.as_str(), pid))
917        })
918        .collect()
919}
920
921async fn load_billing_index_catalog(
922    connection: &mut PgConnection,
923) -> Result<Vec<BillingIndexCatalogEntry>, sqlx::Error> {
924    let tables = REQUIRED_TABLES
925        .iter()
926        .map(|name| (*name).to_owned())
927        .collect::<Vec<_>>();
928    sqlx::query_as::<_, BillingIndexCatalogEntry>(
929        r#"
930        SELECT
931            table_relation.relname AS table_name,
932            index_relation.relname AS index_name,
933            concat_ws(
934                '|',
935                table_relation.relname,
936                index_relation.relname,
937                pg_catalog.pg_get_indexdef(index_relation.oid)
938            ) AS definition,
939            catalog_index.indisvalid AS is_valid,
940            catalog_index.indisready AS is_ready,
941            catalog_index.indislive AS is_live,
942            CASE
943                WHEN NOT catalog_index.indisvalid
944                    AND index_relation.relname ~ $2
945                THEN (
946                    SELECT shadow_lock.pid
947                    FROM pg_catalog.pg_class AS canonical_index_relation
948                    INNER JOIN pg_catalog.pg_index AS canonical_index
949                        ON canonical_index.indexrelid = canonical_index_relation.oid
950                    INNER JOIN pg_catalog.pg_locks AS shadow_lock
951                        ON shadow_lock.locktype = 'relation'
952                        AND shadow_lock.relation = index_relation.oid
953                        AND shadow_lock.database = (
954                            SELECT database.oid
955                            FROM pg_catalog.pg_database AS database
956                            WHERE database.datname = current_database()
957                        )
958                        AND shadow_lock.mode = 'ShareUpdateExclusiveLock'
959                        AND shadow_lock.granted
960                    INNER JOIN pg_catalog.pg_stat_progress_create_index AS progress
961                        ON progress.pid = shadow_lock.pid
962                        AND progress.datid = shadow_lock.database
963                        AND progress.relid = table_relation.oid
964                        AND (
965                            -- PostgreSQL 18 reports the transient index before
966                            -- the swap and the new canonical index afterward.
967                            progress.index_relid IN (
968                                index_relation.oid,
969                                canonical_index_relation.oid
970                            )
971                            -- Table-wide reindex reports only its current
972                            -- index while retaining session locks for every
973                            -- index it is rebuilding on this table.
974                            OR 1 < (
975                                SELECT count(*)
976                                FROM pg_catalog.pg_index AS scope_index
977                                INNER JOIN pg_catalog.pg_locks AS scope_lock
978                                    ON scope_lock.pid = shadow_lock.pid
979                                    AND scope_lock.locktype = 'relation'
980                                    AND scope_lock.database = shadow_lock.database
981                                    AND scope_lock.relation = scope_index.indexrelid
982                                    AND scope_lock.mode = 'ShareUpdateExclusiveLock'
983                                    AND scope_lock.granted
984                                WHERE scope_index.indrelid = table_relation.oid
985                                    AND scope_index.indisvalid
986                                    AND scope_index.indisready
987                                    AND scope_index.indislive
988                            )
989                        )
990                        AND progress.command = 'REINDEX CONCURRENTLY'
991                        -- Table-wide reindex gathering locks skipped invalid
992                        -- indexes only before progress leaves initialization.
993                        AND progress.phase <> 'initializing'
994                    INNER JOIN pg_catalog.pg_locks AS canonical_index_lock
995                        ON canonical_index_lock.pid = shadow_lock.pid
996                        AND canonical_index_lock.locktype = 'relation'
997                        AND canonical_index_lock.database = shadow_lock.database
998                        AND canonical_index_lock.relation = canonical_index_relation.oid
999                        AND canonical_index_lock.mode = 'ShareUpdateExclusiveLock'
1000                        AND canonical_index_lock.granted
1001                    INNER JOIN pg_catalog.pg_locks AS table_lock
1002                        ON table_lock.pid = shadow_lock.pid
1003                        AND table_lock.locktype = 'relation'
1004                        AND table_lock.database = shadow_lock.database
1005                        AND table_lock.relation = table_relation.oid
1006                        AND table_lock.mode = 'ShareUpdateExclusiveLock'
1007                        AND table_lock.granted
1008                    WHERE canonical_index.indrelid = table_relation.oid
1009                        AND canonical_index.indisvalid
1010                        AND canonical_index.indisready
1011                        AND canonical_index.indislive
1012                        AND canonical_index_relation.relname !~ $2
1013                        AND pg_catalog.starts_with(
1014                            canonical_index_relation.relname,
1015                            pg_catalog.regexp_replace(
1016                                index_relation.relname,
1017                                $2,
1018                                ''
1019                            )
1020                        )
1021                    ORDER BY canonical_index_relation.oid
1022                    LIMIT 1
1023                )
1024            END AS active_concurrent_reindex_pid
1025        FROM pg_catalog.pg_index AS catalog_index
1026        INNER JOIN pg_catalog.pg_class AS table_relation
1027            ON table_relation.oid = catalog_index.indrelid
1028        INNER JOIN pg_catalog.pg_class AS index_relation
1029            ON index_relation.oid = catalog_index.indexrelid
1030        INNER JOIN pg_catalog.pg_namespace AS namespace
1031            ON namespace.oid = table_relation.relnamespace
1032        WHERE namespace.nspname = 'public'
1033            AND table_relation.relname = ANY($1)
1034            AND index_relation.relname LIKE 'billing\_%' ESCAPE '\'
1035        ORDER BY table_relation.relname, index_relation.relname
1036        "#,
1037    )
1038    .bind(&tables)
1039    .bind(CONCURRENT_REINDEX_SHADOW_INDEX_PATTERN)
1040    .fetch_all(&mut *connection)
1041    .await
1042}
1043
1044async fn require_index_contract(
1045    connection: &mut PgConnection,
1046    version: u16,
1047    contract: IndexContract,
1048) -> Result<(), SchemaConformanceError> {
1049    let actual = catalog_index_shape(connection, contract.name).await?;
1050    validate_index_contract(version, contract, actual.as_ref())
1051}
1052
1053fn validate_index_contract(
1054    version: u16,
1055    contract: IndexContract,
1056    actual: Option<&CatalogIndexShape>,
1057) -> Result<(), SchemaConformanceError> {
1058    let Some(actual) = actual else {
1059        return Err(index_contract_error(version, contract, "is missing"));
1060    };
1061    let expected_key_expressions = contract
1062        .keys
1063        .iter()
1064        .map(|key| key.expression.to_owned())
1065        .collect::<Vec<_>>();
1066    let expected_key_orderings = contract
1067        .keys
1068        .iter()
1069        .map(|key| key.ordering.catalog_label().to_owned())
1070        .collect::<Vec<_>>();
1071    let expected_key_opclasses = contract
1072        .keys
1073        .iter()
1074        .map(|key| key.opclass.to_owned())
1075        .collect::<Vec<_>>();
1076    let expected_included_expressions = contract
1077        .included_expressions
1078        .iter()
1079        .map(|expression| (*expression).to_owned())
1080        .collect::<Vec<_>>();
1081
1082    if actual.table_name != contract.table {
1083        return Err(index_contract_error(
1084            version,
1085            contract,
1086            format!(
1087                "belongs to table {:?}; expected {:?}",
1088                actual.table_name, contract.table
1089            ),
1090        ));
1091    }
1092    if actual.access_method != "btree" {
1093        return Err(index_contract_error(
1094            version,
1095            contract,
1096            format!(
1097                "uses access method {:?}; expected \"btree\"",
1098                actual.access_method
1099            ),
1100        ));
1101    }
1102    if actual.is_unique != contract.unique {
1103        return Err(index_contract_error(
1104            version,
1105            contract,
1106            format!(
1107                "has unique={}; expected unique={}",
1108                actual.is_unique, contract.unique
1109            ),
1110        ));
1111    }
1112    if actual.key_expressions != expected_key_expressions {
1113        return Err(index_contract_error(
1114            version,
1115            contract,
1116            format!(
1117                "has key expressions {:?}; expected {expected_key_expressions:?}",
1118                actual.key_expressions
1119            ),
1120        ));
1121    }
1122    if actual.key_orderings != expected_key_orderings {
1123        return Err(index_contract_error(
1124            version,
1125            contract,
1126            format!(
1127                "has key ordering {:?}; expected {expected_key_orderings:?}",
1128                actual.key_orderings
1129            ),
1130        ));
1131    }
1132    if actual.key_opclasses != expected_key_opclasses {
1133        return Err(index_contract_error(
1134            version,
1135            contract,
1136            format!(
1137                "has key operator classes {:?}; expected {expected_key_opclasses:?}",
1138                actual.key_opclasses
1139            ),
1140        ));
1141    }
1142    if actual.included_expressions != expected_included_expressions {
1143        return Err(index_contract_error(
1144            version,
1145            contract,
1146            format!(
1147                "has included expressions {:?}; expected {expected_included_expressions:?}",
1148                actual.included_expressions
1149            ),
1150        ));
1151    }
1152    if actual.predicate.as_deref() != contract.predicate {
1153        return Err(index_contract_error(
1154            version,
1155            contract,
1156            format!(
1157                "has predicate {:?}; expected {:?}",
1158                actual.predicate, contract.predicate
1159            ),
1160        ));
1161    }
1162    if !actual.is_valid || !actual.is_ready || !actual.is_live {
1163        return Err(index_contract_error(
1164            version,
1165            contract,
1166            format!(
1167                "is not planner/write ready (valid={}, ready={}, live={})",
1168                actual.is_valid, actual.is_ready, actual.is_live
1169            ),
1170        ));
1171    }
1172    Ok(())
1173}
1174
1175fn index_contract_error(
1176    version: u16,
1177    contract: IndexContract,
1178    detail: impl std::fmt::Display,
1179) -> SchemaConformanceError {
1180    contract_error(
1181        version,
1182        format!("{} index {} {detail}", contract.purpose, contract.name),
1183    )
1184}
1185
1186#[derive(Clone, Debug, sqlx::FromRow)]
1187struct CatalogIndexShape {
1188    table_name: String,
1189    access_method: String,
1190    key_expressions: Vec<String>,
1191    key_orderings: Vec<String>,
1192    key_opclasses: Vec<String>,
1193    included_expressions: Vec<String>,
1194    predicate: Option<String>,
1195    is_unique: bool,
1196    is_valid: bool,
1197    is_ready: bool,
1198    is_live: bool,
1199}
1200
1201async fn catalog_index_shape(
1202    connection: &mut PgConnection,
1203    index_name: &str,
1204) -> Result<Option<CatalogIndexShape>, sqlx::Error> {
1205    sqlx::query_as::<_, CatalogIndexShape>(
1206        r#"
1207        SELECT
1208        table_relation.relname AS table_name,
1209        access_method.amname AS access_method,
1210        ARRAY(
1211            SELECT pg_catalog.pg_get_indexdef(
1212                catalog_index.indexrelid,
1213                key_position.position,
1214                true
1215            )
1216            FROM generate_series(
1217                1,
1218                catalog_index.indnkeyatts
1219            ) AS key_position(position)
1220            ORDER BY key_position.position
1221        ) AS key_expressions,
1222        ARRAY(
1223            SELECT concat(
1224                CASE WHEN pg_catalog.pg_index_column_has_property(
1225                    catalog_index.indexrelid,
1226                    key_position.position,
1227                    'desc'
1228                ) THEN 'DESC' ELSE 'ASC' END,
1229                CASE WHEN pg_catalog.pg_index_column_has_property(
1230                    catalog_index.indexrelid,
1231                    key_position.position,
1232                    'nulls_first'
1233                ) THEN ' NULLS FIRST' ELSE ' NULLS LAST' END
1234            )
1235            FROM generate_series(
1236                1,
1237                catalog_index.indnkeyatts
1238            ) AS key_position(position)
1239            ORDER BY key_position.position
1240        ) AS key_orderings,
1241        ARRAY(
1242            SELECT concat(operator_class_namespace.nspname, '.', operator_class.opcname)
1243            FROM unnest(catalog_index.indclass::oid[]) WITH ORDINALITY
1244                AS key_operator_class(operator_class_oid, position)
1245            INNER JOIN pg_catalog.pg_opclass AS operator_class
1246                ON operator_class.oid = key_operator_class.operator_class_oid
1247            INNER JOIN pg_catalog.pg_namespace AS operator_class_namespace
1248                ON operator_class_namespace.oid = operator_class.opcnamespace
1249            WHERE key_operator_class.position <= catalog_index.indnkeyatts
1250            ORDER BY key_operator_class.position
1251        ) AS key_opclasses,
1252        ARRAY(
1253            SELECT pg_catalog.pg_get_indexdef(
1254                catalog_index.indexrelid,
1255                included_position.position,
1256                true
1257            )
1258            FROM generate_series(
1259                catalog_index.indnkeyatts::integer + 1,
1260                catalog_index.indnatts::integer
1261            ) AS included_position(position)
1262            ORDER BY included_position.position
1263        ) AS included_expressions,
1264        pg_catalog.pg_get_expr(
1265            catalog_index.indpred,
1266            catalog_index.indrelid,
1267            true
1268        ) AS predicate,
1269        catalog_index.indisunique AS is_unique,
1270        catalog_index.indisvalid AS is_valid,
1271        catalog_index.indisready AS is_ready,
1272        catalog_index.indislive AS is_live
1273        FROM pg_catalog.pg_class AS index_relation
1274        INNER JOIN pg_catalog.pg_index AS catalog_index
1275            ON catalog_index.indexrelid = index_relation.oid
1276        INNER JOIN pg_catalog.pg_class AS table_relation
1277            ON table_relation.oid = catalog_index.indrelid
1278        INNER JOIN pg_catalog.pg_namespace AS index_namespace
1279            ON index_namespace.oid = index_relation.relnamespace
1280        INNER JOIN pg_catalog.pg_namespace AS table_namespace
1281            ON table_namespace.oid = table_relation.relnamespace
1282        INNER JOIN pg_catalog.pg_am AS access_method
1283            ON access_method.oid = index_relation.relam
1284        WHERE index_namespace.nspname = 'public'
1285            AND table_namespace.nspname = 'public'
1286            AND index_relation.relkind = 'i'
1287            AND index_relation.relname = $1
1288        "#,
1289    )
1290    .bind(index_name)
1291    .fetch_optional(&mut *connection)
1292    .await
1293}
1294
1295async fn require_catalog_fingerprint(
1296    connection: &mut PgConnection,
1297    version: u16,
1298    expected: u64,
1299    billing_indexes: &[BillingIndexCatalogEntry],
1300) -> Result<(), SchemaConformanceError> {
1301    let actual = canonical_catalog_fingerprint(connection, billing_indexes).await?;
1302    if actual == expected {
1303        Ok(())
1304    } else {
1305        Err(contract_error(
1306            version,
1307            format!(
1308                "canonical catalog fingerprint differs: expected {expected:#018x}, found {actual:#018x}"
1309            ),
1310        ))
1311    }
1312}
1313
1314async fn canonical_catalog_fingerprint(
1315    connection: &mut PgConnection,
1316    billing_indexes: &[BillingIndexCatalogEntry],
1317) -> Result<u64, SchemaConformanceError> {
1318    let tables = REQUIRED_TABLES
1319        .iter()
1320        .map(|name| (*name).to_owned())
1321        .collect::<Vec<_>>();
1322    let views = REQUIRED_VIEWS
1323        .iter()
1324        .map(|name| (*name).to_owned())
1325        .collect::<Vec<_>>();
1326    let functions = REQUIRED_FUNCTIONS
1327        .iter()
1328        .map(|name| (*name).to_owned())
1329        .collect::<Vec<_>>();
1330    let triggers = REQUIRED_TRIGGERS
1331        .iter()
1332        .map(|name| (*name).to_owned())
1333        .collect::<Vec<_>>();
1334
1335    let columns = sqlx::query_scalar::<_, String>(
1336        r#"
1337        SELECT concat_ws(
1338            '|',
1339            table_name,
1340            ordinal_position::text,
1341            column_name,
1342            data_type,
1343            udt_name,
1344            is_nullable,
1345            COALESCE(column_default, '')
1346        )
1347        FROM information_schema.columns
1348        WHERE table_schema = 'public'
1349            AND table_name = ANY($1)
1350        ORDER BY table_name, ordinal_position
1351        "#,
1352    )
1353    .bind(&tables)
1354    .fetch_all(&mut *connection)
1355    .await?;
1356    let constraints = sqlx::query_scalar::<_, String>(
1357        r#"
1358        SELECT concat_ws(
1359            '|',
1360            relation.relname,
1361            catalog_constraint.conname,
1362            catalog_constraint.contype::text,
1363            pg_catalog.pg_get_constraintdef(catalog_constraint.oid, true)
1364        )
1365        FROM pg_catalog.pg_constraint AS catalog_constraint
1366        INNER JOIN pg_catalog.pg_class AS relation
1367            ON relation.oid = catalog_constraint.conrelid
1368        INNER JOIN pg_catalog.pg_namespace AS namespace
1369            ON namespace.oid = relation.relnamespace
1370        WHERE namespace.nspname = 'public'
1371            AND relation.relname = ANY($1)
1372            AND catalog_constraint.contype <> 'n'
1373            AND catalog_constraint.conname LIKE 'billing\_%' ESCAPE '\'
1374        ORDER BY relation.relname, catalog_constraint.conname
1375        "#,
1376    )
1377    .bind(&tables)
1378    .fetch_all(&mut *connection)
1379    .await?;
1380    let indexes = billing_indexes
1381        .iter()
1382        .filter(|index| index.active_concurrent_reindex_pid.is_none())
1383        .map(|index| index.definition.clone())
1384        .collect::<Vec<_>>();
1385    let view_definitions = sqlx::query_scalar::<_, String>(
1386        r#"
1387        SELECT concat_ws(
1388            '|',
1389            relation.relname,
1390            pg_catalog.pg_get_viewdef(relation.oid, true)
1391        )
1392        FROM pg_catalog.pg_class AS relation
1393        INNER JOIN pg_catalog.pg_namespace AS namespace
1394            ON namespace.oid = relation.relnamespace
1395        WHERE namespace.nspname = 'public'
1396            AND relation.relkind = 'v'
1397            AND relation.relname = ANY($1)
1398        ORDER BY relation.relname
1399        "#,
1400    )
1401    .bind(&views)
1402    .fetch_all(&mut *connection)
1403    .await?;
1404    let function_definitions = sqlx::query_scalar::<_, String>(
1405        r#"
1406        SELECT concat_ws(
1407            '|',
1408            catalog_function.proname,
1409            pg_catalog.pg_get_function_identity_arguments(
1410                catalog_function.oid
1411            ),
1412            pg_catalog.pg_get_functiondef(catalog_function.oid)
1413        )
1414        FROM pg_catalog.pg_proc AS catalog_function
1415        INNER JOIN pg_catalog.pg_namespace AS namespace
1416            ON namespace.oid = catalog_function.pronamespace
1417        WHERE namespace.nspname = 'public'
1418            AND catalog_function.prokind = 'f'
1419            AND catalog_function.proname = ANY($1)
1420        ORDER BY
1421            catalog_function.proname,
1422            pg_catalog.pg_get_function_identity_arguments(
1423                catalog_function.oid
1424            )
1425        "#,
1426    )
1427    .bind(&functions)
1428    .fetch_all(&mut *connection)
1429    .await?;
1430    let trigger_definitions = sqlx::query_scalar::<_, String>(
1431        r#"
1432        SELECT concat_ws(
1433            '|',
1434            relation.relname,
1435            catalog_trigger.tgname,
1436            catalog_trigger.tgenabled::text,
1437            pg_catalog.pg_get_triggerdef(catalog_trigger.oid, true)
1438        )
1439        FROM pg_catalog.pg_trigger AS catalog_trigger
1440        INNER JOIN pg_catalog.pg_class AS relation
1441            ON relation.oid = catalog_trigger.tgrelid
1442        INNER JOIN pg_catalog.pg_namespace AS namespace
1443            ON namespace.oid = relation.relnamespace
1444        WHERE namespace.nspname = 'public'
1445            AND NOT catalog_trigger.tgisinternal
1446            AND catalog_trigger.tgname = ANY($1)
1447        ORDER BY relation.relname, catalog_trigger.tgname
1448        "#,
1449    )
1450    .bind(&triggers)
1451    .fetch_all(&mut *connection)
1452    .await?;
1453
1454    Ok(catalog_fingerprint([
1455        ("columns", columns.as_slice()),
1456        ("constraints", constraints.as_slice()),
1457        ("indexes", indexes.as_slice()),
1458        ("views", view_definitions.as_slice()),
1459        ("functions", function_definitions.as_slice()),
1460        ("triggers", trigger_definitions.as_slice()),
1461    ]))
1462}
1463
1464#[cfg(test)]
1465async fn canonical_catalog_fingerprint_for_pool(
1466    pool: &PgPool,
1467) -> Result<u64, SchemaConformanceError> {
1468    let mut connection = pool.acquire().await?;
1469    let billing_indexes = load_billing_index_catalog(&mut connection).await?;
1470    canonical_catalog_fingerprint(&mut connection, &billing_indexes).await
1471}
1472
1473fn catalog_fingerprint<'a>(categories: impl IntoIterator<Item = (&'a str, &'a [String])>) -> u64 {
1474    const OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
1475    const PRIME: u64 = 0x0000_0100_0000_01b3;
1476
1477    fn add_bytes(mut state: u64, bytes: &[u8]) -> u64 {
1478        for byte in bytes {
1479            state ^= u64::from(*byte);
1480            state = state.wrapping_mul(PRIME);
1481        }
1482        state
1483    }
1484
1485    let mut state = OFFSET_BASIS;
1486    for (category, rows) in categories {
1487        state = add_bytes(state, category.as_bytes());
1488        state = add_bytes(state, &rows.len().to_be_bytes());
1489        for row in rows {
1490            state = add_bytes(state, &row.len().to_be_bytes());
1491            state = add_bytes(state, row.as_bytes());
1492        }
1493    }
1494    state
1495}
1496
1497fn require_exact_set(
1498    version: u16,
1499    category: &str,
1500    expected: &[&str],
1501    actual: Vec<String>,
1502) -> Result<(), SchemaConformanceError> {
1503    let expected = expected.iter().copied().collect::<BTreeSet<_>>();
1504    let actual = actual.iter().map(String::as_str).collect::<BTreeSet<_>>();
1505    if actual == expected {
1506        Ok(())
1507    } else {
1508        let missing = expected.difference(&actual).copied().collect::<Vec<_>>();
1509        Err(contract_error(
1510            version,
1511            format!("missing {category}: {missing:?}"),
1512        ))
1513    }
1514}
1515
1516fn contract_error(version: u16, detail: impl Into<String>) -> SchemaConformanceError {
1517    SchemaConformanceError::Contract {
1518        version,
1519        detail: detail.into(),
1520    }
1521}
1522
1523#[cfg(test)]
1524mod tests;