Skip to main content

notification/
operator.rs

1use lenso_postgres_kit::{
2    OwnedPostgres, PostgresKitError, SchemaOperator, SetupOutcome, UpgradeOutcome,
3};
4use sha2::{Digest as _, Sha256};
5use sqlx::postgres::PgPoolOptions;
6use thiserror::Error;
7
8use crate::migrations::{NOTIFICATION_MIGRATIONS, schema_plan};
9
10const LEGACY_SCHEMA_PREFIX: &str = "create schema if not exists notification;";
11const LEGACY_HOST_MIGRATION_NAME: &str = "notification/0001_create_notification_schema";
12const GLOBAL_MAINTENANCE_LOCK_SQL: &str =
13    "select pg_advisory_xact_lock(hashtextextended(current_database() || ':lenso-maintenance', 0))";
14const NOTIFICATION_OPERATOR_LOCK_SQL: &str =
15    "select pg_advisory_xact_lock(hashtextextended(current_database() || ':notification', 0))";
16const MANAGED_LEDGER_REFERENCE_SQL: &str = r#"
17create table pg_temp._lenso_schema_migrations (
18    version bigint primary key check (version > 0),
19    name text not null,
20    checksum text not null,
21    applied_at timestamptz not null default transaction_timestamp()
22);
23"#;
24const LEGACY_TABLES: &[&str] = &[
25    "attempts",
26    "consumed_events",
27    "deliveries",
28    "intents",
29    "receipts",
30    "render_snapshots",
31    "retry_requests",
32    "source_lifecycle_events",
33    "template_releases",
34];
35const LEGACY_LOCK_SQL: &str = r#"
36lock table platform.schema_migrations in share mode;
37lock table notification.attempts,
38           notification.consumed_events,
39           notification.deliveries,
40           notification.intents,
41           notification.receipts,
42           notification.render_snapshots,
43           notification.retry_requests,
44           notification.source_lifecycle_events,
45           notification.template_releases
46    in access exclusive mode;
47"#;
48
49/// Explicit schema administration for one Notification Plugin Instance.
50#[derive(Clone, Debug)]
51pub struct NotificationOperator {
52    postgres: OwnedPostgres,
53}
54
55impl NotificationOperator {
56    pub async fn setup(database_url: &str) -> Result<SetupOutcome, NotificationOperatorError> {
57        Ok(
58            SchemaOperator::connect(database_url, schema_plan("notification")?)
59                .await?
60                .setup()
61                .await?,
62        )
63    }
64
65    pub async fn upgrade(database_url: &str) -> Result<UpgradeOutcome, NotificationOperatorError> {
66        Ok(
67            SchemaOperator::connect(database_url, schema_plan("notification")?)
68                .await?
69                .upgrade()
70                .await?,
71        )
72    }
73
74    /// One-time, fail-closed adoption of the exact pre-Plugin Notification
75    /// schema. This records migration 1 without rewriting any ledger rows or
76    /// replaying the immutable legacy SQL against the owned schema. Adoption
77    /// is a mandatory maintenance-window operation: all Notification writers
78    /// and DDL actors must be stopped. It locks the shared legacy ledger in
79    /// share mode and all legacy Notification tables in access-exclusive mode
80    /// until the catalog proof and Plugin migration record commit. The advisory
81    /// shared maintenance advisory lock coordinates cooperating schema
82    /// operators, followed by the Notification-specific advisory lock.
83    /// `PostgreSQL` has no schema namespace lock here, so these locks do not make
84    /// arbitrary concurrent `CREATE` statements atomic with adoption.
85    pub async fn adopt_legacy(
86        database_url: &str,
87    ) -> Result<LegacyAdoptionOutcome, NotificationOperatorError> {
88        let pool = PgPoolOptions::new()
89            .max_connections(1)
90            .connect(database_url)
91            .await?;
92        let mut transaction = pool.begin().await?;
93        // All adopting operators take the shared maintenance key first. This
94        // ordering prevents cross-schema operator deadlocks; direct owner DDL
95        // remains outside the protocol and must be quiesced by the operator.
96        sqlx::query(GLOBAL_MAINTENANCE_LOCK_SQL)
97            .execute(transaction.as_mut())
98            .await?;
99        sqlx::query(NOTIFICATION_OPERATOR_LOCK_SQL)
100            .execute(transaction.as_mut())
101            .await?;
102
103        let owner: Option<String> = sqlx::query_scalar(
104            r#"
105            select roles.rolname::text
106            from pg_namespace namespaces
107            join pg_roles roles on roles.oid = namespaces.nspowner
108            where namespaces.nspname = 'notification'
109            "#,
110        )
111        .fetch_optional(transaction.as_mut())
112        .await?;
113        let Some(owner) = owner else {
114            return Err(NotificationOperatorError::LegacySchemaMissing);
115        };
116        let current_role: String = sqlx::query_scalar("select current_user::text")
117            .fetch_one(transaction.as_mut())
118            .await?;
119        if owner != current_role {
120            return Err(NotificationOperatorError::LegacyOwnershipMismatch {
121                owner,
122                current_role,
123            });
124        }
125        if unsafe_owner_default_acl(transaction.as_mut()).await? {
126            return Err(NotificationOperatorError::LegacySchemaMismatch);
127        }
128        if unsafe_publication_scope(transaction.as_mut(), "notification").await?
129            || unsafe_publication_scope(transaction.as_mut(), "platform").await?
130        {
131            return Err(NotificationOperatorError::LegacySchemaMismatch);
132        }
133        let managed: bool = sqlx::query_scalar(
134            r#"
135            select exists (
136                select 1
137                from pg_class relations
138                join pg_namespace namespaces on namespaces.oid = relations.relnamespace
139                where namespaces.nspname = 'notification'
140                  and relations.relname = '_lenso_schema_migrations'
141                  and relations.relkind = 'r'
142            )
143            "#,
144        )
145        .fetch_one(transaction.as_mut())
146        .await?;
147        if managed {
148            return Err(NotificationOperatorError::LegacySchemaAlreadyManaged);
149        }
150        let (host_ledger_exists, target_tables_match) =
151            legacy_lock_targets_exist(transaction.as_mut()).await?;
152        if !host_ledger_exists {
153            return Err(NotificationOperatorError::LegacyHostLedgerMismatch);
154        }
155        if !target_tables_match {
156            return Err(NotificationOperatorError::LegacySchemaMismatch);
157        }
158        sqlx::raw_sql(LEGACY_LOCK_SQL)
159            .execute(transaction.as_mut())
160            .await?;
161        if !legacy_host_ledger_matches(transaction.as_mut()).await? {
162            return Err(NotificationOperatorError::LegacyHostLedgerMismatch);
163        }
164        if !legacy_schema_matches(transaction.as_mut()).await? {
165            return Err(NotificationOperatorError::LegacySchemaMismatch);
166        }
167
168        sqlx::query(
169            r#"
170            create table notification._lenso_schema_migrations (
171                version bigint primary key check (version > 0),
172                name text not null,
173                checksum text not null,
174                applied_at timestamptz not null default transaction_timestamp()
175            )
176            "#,
177        )
178        .execute(transaction.as_mut())
179        .await?;
180        if !plugin_ledger_is_private(transaction.as_mut()).await? {
181            return Err(NotificationOperatorError::LegacySchemaMismatch);
182        }
183        let migration = &NOTIFICATION_MIGRATIONS[0];
184        sqlx::query(
185            "insert into notification._lenso_schema_migrations (version, name, checksum) values ($1, $2, $3)",
186        )
187        .bind(i64::try_from(migration.version()).expect("migration version fits bigint"))
188        .bind(migration.name())
189        .bind(migration_checksum(migration))
190        .execute(transaction.as_mut())
191        .await?;
192        if unsafe_owner_default_acl(transaction.as_mut()).await?
193            || unsafe_publication_scope(transaction.as_mut(), "notification").await?
194            || unsafe_publication_scope(transaction.as_mut(), "platform").await?
195            || !managed_schema_matches_existing_reference(transaction.as_mut()).await?
196        {
197            return Err(NotificationOperatorError::LegacySchemaMismatch);
198        }
199        transaction.commit().await?;
200        pool.close().await;
201        Ok(LegacyAdoptionOutcome { version: 1 })
202    }
203
204    pub async fn connect(database_url: &str) -> Result<Self, NotificationOperatorError> {
205        let postgres = OwnedPostgres::prepare(database_url, schema_plan("notification")?).await?;
206        verify_managed_catalog(postgres.pool()).await?;
207        Ok(Self { postgres })
208    }
209
210    pub fn schema(&self) -> &str {
211        self.postgres.schema()
212    }
213}
214
215async fn unsafe_owner_default_acl(
216    connection: &mut sqlx::PgConnection,
217) -> Result<bool, sqlx::Error> {
218    sqlx::query_scalar(
219        r#"
220        select exists (
221            select 1
222            from pg_default_acl defaults
223            join pg_roles roles on roles.oid = defaults.defaclrole
224            left join pg_namespace namespaces on namespaces.oid = defaults.defaclnamespace
225            where roles.rolname = current_user
226              and (
227                  defaults.defaclnamespace = 0
228                  or namespaces.nspname in ('notification', 'platform')
229              )
230        )
231        "#,
232    )
233    .fetch_one(&mut *connection)
234    .await
235}
236
237async fn unsafe_publication_scope(
238    connection: &mut sqlx::PgConnection,
239    schema: &str,
240) -> Result<bool, sqlx::Error> {
241    sqlx::query_scalar(
242        r#"
243        select exists (
244            select 1 from pg_publication publications
245            where publications.puballtables
246        ) or exists (
247            select 1
248            from pg_publication_namespace memberships
249            join pg_namespace namespaces on namespaces.oid = memberships.pnnspid
250            where namespaces.nspname = $1
251        )
252        "#,
253    )
254    .bind(schema)
255    .fetch_one(&mut *connection)
256    .await
257}
258
259async fn unsupported_schema_object_count(
260    connection: &mut sqlx::PgConnection,
261    schema: &str,
262) -> Result<i64, sqlx::Error> {
263    sqlx::query_scalar(
264        r#"
265        select
266              (select count(*) from pg_collation objects where objects.collnamespace = namespaces.oid)
267            + (select count(*) from pg_conversion objects where objects.connamespace = namespaces.oid)
268            + (select count(*) from pg_operator objects where objects.oprnamespace = namespaces.oid)
269            + (select count(*) from pg_opclass objects where objects.opcnamespace = namespaces.oid)
270            + (select count(*) from pg_opfamily objects where objects.opfnamespace = namespaces.oid)
271            + (select count(*) from pg_statistic_ext objects where objects.stxnamespace = namespaces.oid)
272            + (select count(*) from pg_ts_config objects where objects.cfgnamespace = namespaces.oid)
273            + (select count(*) from pg_ts_dict objects where objects.dictnamespace = namespaces.oid)
274            + (select count(*) from pg_ts_parser objects where objects.prsnamespace = namespaces.oid)
275            + (select count(*) from pg_ts_template objects where objects.tmplnamespace = namespaces.oid)
276            + (select count(*) from pg_extension objects where objects.extnamespace = namespaces.oid)
277            + (select count(*) from pg_constraint objects
278               where objects.connamespace = namespaces.oid and objects.conrelid = 0)
279        from pg_namespace namespaces
280        where namespaces.nspname = $1
281        "#,
282    )
283    .bind(schema)
284    .fetch_one(&mut *connection)
285    .await
286}
287
288pub(crate) async fn verify_managed_catalog(
289    pool: &sqlx::PgPool,
290) -> Result<(), NotificationOperatorError> {
291    let mut connection = pool.acquire().await?;
292    if unsafe_owner_default_acl(&mut connection).await?
293        || !managed_schema_matches(&mut connection).await?
294    {
295        return Err(NotificationOperatorError::ManagedSchemaMismatch);
296    }
297    Ok(())
298}
299
300async fn plugin_ledger_is_private(
301    connection: &mut sqlx::PgConnection,
302) -> Result<bool, sqlx::Error> {
303    sqlx::query_scalar(
304        r#"
305        select relations.relkind = 'r'
306           and pg_get_userbyid(relations.relowner) = current_user
307           and relations.relacl is null
308           and not exists (
309               select 1
310               from pg_attribute attributes
311               where attributes.attrelid = relations.oid
312                 and attributes.attnum > 0
313                 and not attributes.attisdropped
314                 and attributes.attacl is not null
315           )
316        from pg_class relations
317        join pg_namespace namespaces on namespaces.oid = relations.relnamespace
318        where namespaces.nspname = 'notification'
319          and relations.relname = '_lenso_schema_migrations'
320        "#,
321    )
322    .fetch_one(&mut *connection)
323    .await
324}
325
326async fn legacy_lock_targets_exist(
327    connection: &mut sqlx::PgConnection,
328) -> Result<(bool, bool), sqlx::Error> {
329    let host_ledger_exists: bool = sqlx::query_scalar(
330        r#"
331        select exists (
332            select 1
333            from pg_class relations
334            join pg_namespace namespaces on namespaces.oid = relations.relnamespace
335            where namespaces.nspname = 'platform'
336              and relations.relname = 'schema_migrations'
337              and relations.relkind = 'r'
338        )
339        "#,
340    )
341    .fetch_one(&mut *connection)
342    .await?;
343    let target_tables = sqlx::query_scalar::<_, String>(
344        r#"
345        select relations.relname::text
346        from pg_class relations
347        join pg_namespace namespaces on namespaces.oid = relations.relnamespace
348        where namespaces.nspname = 'notification'
349          and relations.relkind = 'r'
350        order by relations.relname
351        "#,
352    )
353    .fetch_all(&mut *connection)
354    .await?;
355    Ok((
356        host_ledger_exists,
357        target_tables
358            == LEGACY_TABLES
359                .iter()
360                .map(|table| (*table).to_owned())
361                .collect::<Vec<_>>(),
362    ))
363}
364
365#[derive(Clone, Copy, Debug, Eq, PartialEq)]
366pub struct LegacyAdoptionOutcome {
367    pub version: u64,
368}
369
370#[derive(Debug, Error)]
371pub enum NotificationOperatorError {
372    #[error(transparent)]
373    Plan(#[from] lenso_postgres_kit::PlanError),
374    #[error(transparent)]
375    Postgres(#[from] PostgresKitError),
376    #[error("legacy Notification schema does not exist")]
377    LegacySchemaMissing,
378    #[error("Notification schema is already managed; use setup or upgrade")]
379    LegacySchemaAlreadyManaged,
380    #[error("legacy Notification schema does not match the immutable v1 fingerprint")]
381    LegacySchemaMismatch,
382    #[error("managed Notification schema does not match the exact Plugin catalog fingerprint")]
383    ManagedSchemaMismatch,
384    #[error("legacy Host migration ledger does not prove the Notification v1 migration")]
385    LegacyHostLedgerMismatch,
386    #[error("legacy Notification schema is owned by `{owner}`, not current role `{current_role}`")]
387    LegacyOwnershipMismatch { owner: String, current_role: String },
388    #[error("legacy Notification schema adoption failed")]
389    Database(#[from] sqlx::Error),
390}
391
392async fn legacy_schema_matches(connection: &mut sqlx::PgConnection) -> Result<bool, sqlx::Error> {
393    if unsafe_publication_scope(connection, "notification").await?
394        || unsupported_schema_object_count(connection, "notification").await? != 0
395    {
396        return Ok(false);
397    }
398    let actual = legacy_schema_fingerprint(connection, "notification").await?;
399    let Some(reference_sql) = legacy_reference_sql() else {
400        return Ok(false);
401    };
402    // Audited source: this string is derived only from the immutable, compiled-in v1
403    // migration and substitutes a fixed `pg_temp` qualifier. It contains no caller input.
404    sqlx::raw_sql(sqlx::AssertSqlSafe(reference_sql))
405        .execute(&mut *connection)
406        .await?;
407    let reference_schema: String = sqlx::query_scalar(
408        r#"
409        select namespaces.nspname::text
410        from pg_namespace namespaces
411        where namespaces.oid = pg_my_temp_schema()
412        "#,
413    )
414    .fetch_one(&mut *connection)
415    .await?;
416    let expected = legacy_schema_fingerprint(connection, &reference_schema).await?;
417    let actual = actual.normalized("notification");
418    let expected = expected.normalized(&reference_schema);
419    Ok(actual == expected)
420}
421
422async fn managed_schema_matches(connection: &mut sqlx::PgConnection) -> Result<bool, sqlx::Error> {
423    // Runtime pools are fresh, but clearing connection-local temporary objects
424    // keeps repeated operator verification deterministic on a reused connection.
425    sqlx::query("discard temp")
426        .execute(&mut *connection)
427        .await?;
428    let Some(reference_sql) = managed_reference_sql() else {
429        return Ok(false);
430    };
431    sqlx::raw_sql(sqlx::AssertSqlSafe(reference_sql))
432        .execute(&mut *connection)
433        .await?;
434    managed_schema_matches_existing_reference(connection).await
435}
436
437async fn managed_schema_matches_existing_reference(
438    connection: &mut sqlx::PgConnection,
439) -> Result<bool, sqlx::Error> {
440    if unsafe_publication_scope(connection, "notification").await?
441        || unsupported_schema_object_count(connection, "notification").await? != 0
442    {
443        return Ok(false);
444    }
445    let actual = legacy_schema_fingerprint(connection, "notification").await?;
446    sqlx::raw_sql(MANAGED_LEDGER_REFERENCE_SQL)
447        .execute(&mut *connection)
448        .await?;
449    let reference_schema: String = sqlx::query_scalar(
450        r#"
451        select namespaces.nspname::text
452        from pg_namespace namespaces
453        where namespaces.oid = pg_my_temp_schema()
454        "#,
455    )
456    .fetch_one(&mut *connection)
457    .await?;
458    let expected = legacy_schema_fingerprint(connection, &reference_schema).await?;
459    Ok(actual.normalized("notification") == expected.normalized(&reference_schema))
460}
461
462async fn legacy_host_ledger_matches(
463    connection: &mut sqlx::PgConnection,
464) -> Result<bool, sqlx::Error> {
465    if unsafe_publication_scope(connection, "platform").await? {
466        return Ok(false);
467    }
468    let relation = sqlx::query_as::<
469        _,
470        (
471            String,
472            String,
473            String,
474            Option<String>,
475            Option<String>,
476            bool,
477            bool,
478            String,
479            String,
480            String,
481            bool,
482            String,
483            String,
484            Option<String>,
485        ),
486    >(
487        r#"
488        select relations.relkind::text,
489               pg_get_userbyid(namespaces.nspowner)::text,
490               pg_get_userbyid(relations.relowner)::text,
491               namespaces.nspacl::text,
492               relations.relacl::text,
493               relations.relrowsecurity,
494               relations.relforcerowsecurity,
495               relations.relpersistence::text,
496               access_methods.amname::text,
497               coalesce((
498                   select jsonb_agg(option order by option)::text
499                   from unnest(relations.reloptions) option
500               ), '[]'),
501               relations.relispartition,
502               relations.relreplident::text,
503               coalesce(tablespaces.spcname::text, ''),
504               obj_description(relations.oid, 'pg_class')::text
505        from pg_class relations
506        join pg_namespace namespaces on namespaces.oid = relations.relnamespace
507        left join pg_am access_methods on access_methods.oid = relations.relam
508        left join pg_tablespace tablespaces on tablespaces.oid = relations.reltablespace
509        where namespaces.nspname = 'platform'
510          and relations.relname = 'schema_migrations'
511        "#,
512    )
513    .fetch_optional(&mut *connection)
514    .await?;
515    let current_role: String = sqlx::query_scalar("select current_user::text")
516        .fetch_one(&mut *connection)
517        .await?;
518    let Some((
519        relation_kind,
520        schema_owner,
521        table_owner,
522        schema_acl,
523        table_acl,
524        row_security,
525        force_row_security,
526        persistence,
527        access_method,
528        relation_options,
529        is_partition,
530        replica_identity,
531        tablespace,
532        comment,
533    )) = relation
534    else {
535        return Ok(false);
536    };
537    if relation_kind != "r"
538        || schema_owner != current_role
539        || table_owner != current_role
540        || schema_acl.is_some()
541        || table_acl.is_some()
542        || row_security
543        || force_row_security
544        || persistence != "p"
545        || access_method != "heap"
546        || relation_options != "[]"
547        || is_partition
548        || replica_identity != "d"
549        || !tablespace.is_empty()
550        || comment.is_some()
551    {
552        return Ok(false);
553    }
554
555    let columns = sqlx::query_scalar::<_, sqlx::types::Json<serde_json::Value>>(
556        r#"
557        select jsonb_build_array(
558               attributes.attnum,
559               attributes.attname,
560               format_type(attributes.atttypid, attributes.atttypmod),
561               attributes.attnotnull,
562               coalesce(pg_get_expr(defaults.adbin, defaults.adrelid, false), ''),
563               attributes.attidentity::text,
564               attributes.attgenerated::text,
565               attributes.attndims,
566               attributes.attislocal,
567               attributes.attinhcount,
568               attributes.atthasmissing,
569               coalesce(attributes.attmissingval::text, ''),
570               attributes.attstorage::text,
571               attributes.attcompression::text,
572               coalesce(attributes.attstattarget, -1),
573               coalesce((
574                   select jsonb_agg(option order by option)
575                   from unnest(attributes.attoptions) option
576               ), '[]'::jsonb),
577               coalesce((
578                   select jsonb_agg(option order by option)
579                   from unnest(attributes.attfdwoptions) option
580               ), '[]'::jsonb),
581               coalesce(collations.collname, ''),
582               coalesce(attributes.attacl::text, ''),
583               coalesce(col_description(relations.oid, attributes.attnum), '')
584        )
585        from pg_attribute attributes
586        join pg_class relations on relations.oid = attributes.attrelid
587        join pg_namespace namespaces on namespaces.oid = relations.relnamespace
588        left join pg_attrdef defaults
589          on defaults.adrelid = relations.oid and defaults.adnum = attributes.attnum
590        left join pg_collation collations on collations.oid = attributes.attcollation
591        where namespaces.nspname = 'platform'
592          and relations.relname = 'schema_migrations'
593          and attributes.attnum > 0
594          and not attributes.attisdropped
595        order by attributes.attnum
596        "#,
597    )
598    .fetch_all(&mut *connection)
599    .await?
600    .into_iter()
601    .map(|value| value.0)
602    .collect::<Vec<_>>();
603    let expected_columns = vec![
604        serde_json::json!([
605            1,
606            "name",
607            "text",
608            true,
609            "",
610            "",
611            "",
612            0,
613            true,
614            0,
615            false,
616            "",
617            "x",
618            "",
619            -1,
620            [],
621            [],
622            "default",
623            "",
624            ""
625        ]),
626        serde_json::json!([
627            2,
628            "applied_at",
629            "timestamp with time zone",
630            true,
631            "now()",
632            "",
633            "",
634            0,
635            true,
636            0,
637            false,
638            "",
639            "p",
640            "",
641            -1,
642            [],
643            [],
644            "",
645            "",
646            ""
647        ]),
648    ];
649    if columns != expected_columns {
650        return Ok(false);
651    }
652
653    let relation_types = sqlx::query_as::<_, (String, String, Option<String>, Option<String>)>(
654        r#"
655        with ledger as (
656            select relations.reltype
657            from pg_class relations
658            join pg_namespace namespaces on namespaces.oid = relations.relnamespace
659            where namespaces.nspname = 'platform'
660              and relations.relname = 'schema_migrations'
661        ), ledger_types as (
662            select composite_types.oid
663            from pg_type composite_types, ledger
664            where composite_types.oid = ledger.reltype
665            union all
666            select composite_types.typarray
667            from pg_type composite_types, ledger
668            where composite_types.oid = ledger.reltype
669        )
670        select types.typname::text,
671               pg_get_userbyid(types.typowner)::text,
672               types.typacl::text,
673               obj_description(types.oid, 'pg_type')::text
674        from pg_type types
675        join ledger_types on ledger_types.oid = types.oid
676        order by types.typname
677        "#,
678    )
679    .fetch_all(&mut *connection)
680    .await?;
681    if relation_types
682        != vec![
683            (
684                "_schema_migrations".to_owned(),
685                current_role.clone(),
686                None,
687                None,
688            ),
689            (
690                "schema_migrations".to_owned(),
691                current_role.clone(),
692                None,
693                None,
694            ),
695        ]
696    {
697        return Ok(false);
698    }
699
700    let constraints = sqlx::query_as::<
701        _,
702        (
703            String,
704            String,
705            String,
706            bool,
707            bool,
708            bool,
709            bool,
710            Option<String>,
711        ),
712    >(
713        r#"
714        select constraints.conname::text,
715               constraints.contype::text,
716               pg_get_constraintdef(constraints.oid, false)::text,
717               constraints.condeferrable,
718               constraints.condeferred,
719               constraints.convalidated,
720               constraints.connoinherit,
721               obj_description(constraints.oid, 'pg_constraint')::text
722        from pg_constraint constraints
723        join pg_class relations on relations.oid = constraints.conrelid
724        join pg_namespace namespaces on namespaces.oid = relations.relnamespace
725        where namespaces.nspname = 'platform'
726          and relations.relname = 'schema_migrations'
727          and constraints.contype <> 'n'
728        order by constraints.conname
729        "#,
730    )
731    .fetch_all(&mut *connection)
732    .await?;
733    if constraints
734        != vec![(
735            "schema_migrations_pkey".to_owned(),
736            "p".to_owned(),
737            "PRIMARY KEY (name)".to_owned(),
738            false,
739            false,
740            true,
741            true,
742            None,
743        )]
744    {
745        return Ok(false);
746    }
747
748    let indexes = sqlx::query_scalar::<_, sqlx::types::Json<serde_json::Value>>(
749        r#"
750        select jsonb_build_array(
751               indexes.relname,
752               catalog.indisunique,
753               catalog.indisprimary,
754               catalog.indisexclusion,
755               catalog.indimmediate,
756               catalog.indisvalid,
757               catalog.indisready,
758               catalog.indislive,
759               catalog.indisreplident,
760               catalog.indcheckxmin,
761               indexes.relpersistence::text,
762               pg_get_userbyid(indexes.relowner),
763               access_methods.amname,
764               coalesce((
765                   select jsonb_agg(option order by option)
766                   from unnest(indexes.reloptions) option
767               ), '[]'::jsonb),
768               coalesce(tablespaces.spcname, ''),
769               coalesce(indexes.relacl::text, ''),
770               coalesce(obj_description(indexes.oid, 'pg_class'), ''),
771               pg_get_indexdef(indexes.oid, 0, false)
772        )
773        from pg_index catalog
774        join pg_class indexes on indexes.oid = catalog.indexrelid
775        join pg_class relations on relations.oid = catalog.indrelid
776        join pg_namespace namespaces on namespaces.oid = relations.relnamespace
777        join pg_am access_methods on access_methods.oid = indexes.relam
778        left join pg_tablespace tablespaces on tablespaces.oid = indexes.reltablespace
779        where namespaces.nspname = 'platform'
780          and relations.relname = 'schema_migrations'
781        order by indexes.relname
782        "#,
783    )
784    .fetch_all(&mut *connection)
785    .await?
786    .into_iter()
787    .map(|value| value.0)
788    .collect::<Vec<_>>();
789    if indexes
790        != vec![serde_json::json!([
791            "schema_migrations_pkey",
792            true,
793            true,
794            false,
795            true,
796            true,
797            true,
798            true,
799            false,
800            false,
801            "p",
802            current_role,
803            "btree",
804            [],
805            "",
806            "",
807            "",
808            "CREATE UNIQUE INDEX schema_migrations_pkey ON platform.schema_migrations USING btree (name)"
809        ])]
810    {
811        return Ok(false);
812    }
813
814    let has_extras: bool = sqlx::query_scalar(
815        r#"
816        with ledger as (
817            select relations.oid, relations.reltype
818            from pg_class relations
819            join pg_namespace namespaces on namespaces.oid = relations.relnamespace
820            where namespaces.nspname = 'platform'
821              and relations.relname = 'schema_migrations'
822        )
823        select exists (
824            select 1 from pg_trigger triggers, ledger
825            where triggers.tgrelid = ledger.oid and not triggers.tgisinternal
826        ) or exists (
827            select 1 from pg_policy policies, ledger
828            where policies.polrelid = ledger.oid
829        ) or exists (
830            select 1 from pg_rewrite rules, ledger
831            where rules.ev_class = ledger.oid and rules.rulename <> '_RETURN'
832        ) or exists (
833            select 1 from pg_seclabel labels, ledger
834            where labels.classoid = 'pg_class'::regclass and labels.objoid = ledger.oid
835        ) or exists (
836            select 1
837            from pg_seclabel labels, ledger
838            join pg_type composite_types on composite_types.oid = ledger.reltype
839            where labels.classoid = 'pg_type'::regclass
840              and labels.objoid in (composite_types.oid, composite_types.typarray)
841        ) or exists (
842            select 1 from pg_publication_rel memberships, ledger
843            where memberships.prrelid = ledger.oid
844        ) or exists (
845            select 1 from pg_inherits inheritance, ledger
846            where inheritance.inhrelid = ledger.oid or inheritance.inhparent = ledger.oid
847        )
848        "#,
849    )
850    .fetch_one(&mut *connection)
851    .await?;
852    if has_extras {
853        return Ok(false);
854    }
855
856    sqlx::query_scalar("select exists(select 1 from platform.schema_migrations where name = $1)")
857        .bind(LEGACY_HOST_MIGRATION_NAME)
858        .fetch_one(&mut *connection)
859        .await
860}
861
862fn legacy_reference_sql() -> Option<String> {
863    NOTIFICATION_MIGRATIONS[0]
864        .sql()
865        .strip_prefix(LEGACY_SCHEMA_PREFIX)
866        .map(|body| body.replace("notification.", "pg_temp."))
867}
868
869fn managed_reference_sql() -> Option<String> {
870    let mut reference = legacy_reference_sql()?;
871    for migration in &NOTIFICATION_MIGRATIONS[1..] {
872        reference.push('\n');
873        reference.push_str(&migration.sql().replace("notification.", "pg_temp."));
874    }
875    Some(reference)
876}
877
878#[derive(Debug, Eq, PartialEq)]
879struct LegacySchemaFingerprint {
880    schema: String,
881    relations: Vec<String>,
882    columns: Vec<String>,
883    constraints: Vec<String>,
884    indexes: Vec<String>,
885    triggers: Vec<String>,
886    types: Vec<String>,
887    routines: Vec<String>,
888    policies: Vec<String>,
889    inheritance: Vec<String>,
890    rules: Vec<String>,
891    security_labels: Vec<String>,
892    publication_memberships: Vec<String>,
893}
894
895impl LegacySchemaFingerprint {
896    fn normalized(mut self, schema: &str) -> Self {
897        normalize_sql_fields(&mut self.columns, &[3, 5], schema);
898        normalize_sql_fields(&mut self.constraints, &[7], schema);
899        normalize_sql_fields(&mut self.indexes, &[18], schema);
900        normalize_sql_fields(&mut self.triggers, &[3], schema);
901        normalize_sql_fields(&mut self.routines, &[1, 10], schema);
902        normalize_sql_fields(&mut self.policies, &[5, 6], schema);
903        normalize_schema_name_fields(&mut self.inheritance, &[0, 2], schema);
904        normalize_sql_fields(&mut self.rules, &[3], schema);
905        normalize_sql_fields(&mut self.publication_memberships, &[2], schema);
906        self
907    }
908}
909
910fn normalize_sql_fields(rows: &mut [String], fields: &[usize], schema: &str) {
911    for row in rows {
912        let Ok(mut value) = serde_json::from_str::<serde_json::Value>(row) else {
913            continue;
914        };
915        let Some(values) = value.as_array_mut() else {
916            continue;
917        };
918        for field in fields {
919            let Some(value) = values.get_mut(*field) else {
920                continue;
921            };
922            let Some(sql) = value.as_str() else {
923                continue;
924            };
925            *value = serde_json::Value::String(normalize_sql_qualifiers(sql, schema));
926        }
927        if let Ok(normalized) = serde_json::to_string(&value) {
928            *row = normalized;
929        }
930    }
931}
932
933fn normalize_schema_name_fields(rows: &mut [String], fields: &[usize], schema: &str) {
934    for row in rows {
935        let Ok(mut value) = serde_json::from_str::<serde_json::Value>(row) else {
936            continue;
937        };
938        let Some(values) = value.as_array_mut() else {
939            continue;
940        };
941        for field in fields {
942            let Some(value) = values.get_mut(*field) else {
943                continue;
944            };
945            if value.as_str() == Some(schema) {
946                *value = serde_json::Value::String("__lenso_owned_schema__".to_owned());
947            }
948        }
949        if let Ok(normalized) = serde_json::to_string(&value) {
950            *row = normalized;
951        }
952    }
953}
954
955fn normalize_sql_qualifiers(sql: &str, schema: &str) -> String {
956    let mut qualifiers = vec![format!("{schema}."), format!("\"{schema}\".")];
957    if schema.starts_with("pg_temp_") {
958        qualifiers.push("pg_temp.".to_owned());
959        qualifiers.push("\"pg_temp\".".to_owned());
960    }
961    let bytes = sql.as_bytes();
962    let mut normalized = String::with_capacity(sql.len());
963    let mut index = 0;
964    let mut quote: Option<String> = None;
965
966    while index < bytes.len() {
967        if let Some(delimiter) = quote.as_deref() {
968            if delimiter == "'" {
969                let character = sql[index..].chars().next().expect("valid SQL character");
970                normalized.push(character);
971                index += character.len_utf8();
972                if character == '\\' && index < bytes.len() {
973                    let escaped = sql[index..]
974                        .chars()
975                        .next()
976                        .expect("valid escaped SQL character");
977                    normalized.push(escaped);
978                    index += escaped.len_utf8();
979                } else if character == '\'' {
980                    if sql[index..].starts_with('\'') {
981                        normalized.push('\'');
982                        index += 1;
983                    } else {
984                        quote = None;
985                    }
986                }
987                continue;
988            }
989            if sql[index..].starts_with(delimiter) {
990                normalized.push_str(delimiter);
991                index += delimiter.len();
992                quote = None;
993            } else {
994                let character = sql[index..].chars().next().expect("valid SQL character");
995                normalized.push(character);
996                index += character.len_utf8();
997            }
998            continue;
999        }
1000
1001        if sql[index..].starts_with('\'') {
1002            normalized.push('\'');
1003            index += 1;
1004            quote = Some("'".to_owned());
1005            continue;
1006        }
1007        if let Some(delimiter) = dollar_quote_delimiter(&sql[index..]) {
1008            normalized.push_str(delimiter);
1009            index += delimiter.len();
1010            quote = Some(delimiter.to_owned());
1011            continue;
1012        }
1013        let token_boundary = index == 0
1014            || !matches!(bytes[index - 1], b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_' | b'$');
1015        if let Some(qualifier) = token_boundary
1016            .then(|| {
1017                qualifiers
1018                    .iter()
1019                    .find(|qualifier| sql[index..].starts_with(qualifier.as_str()))
1020            })
1021            .flatten()
1022        {
1023            index += qualifier.len();
1024            continue;
1025        }
1026        if sql[index..].starts_with('"') {
1027            normalized.push('"');
1028            index += 1;
1029            quote = Some("\"".to_owned());
1030            continue;
1031        }
1032        let character = sql[index..].chars().next().expect("valid SQL character");
1033        normalized.push(character);
1034        index += character.len_utf8();
1035    }
1036    normalized
1037}
1038
1039fn dollar_quote_delimiter(value: &str) -> Option<&str> {
1040    let bytes = value.as_bytes();
1041    if bytes.first() != Some(&b'$') {
1042        return None;
1043    }
1044    let end = bytes[1..].iter().position(|byte| *byte == b'$')? + 1;
1045    let tag = &bytes[1..end];
1046    if tag.is_empty()
1047        || (tag[0].is_ascii_alphabetic() || tag[0] == b'_')
1048            && tag[1..]
1049                .iter()
1050                .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'_')
1051    {
1052        Some(&value[..=end])
1053    } else {
1054        None
1055    }
1056}
1057
1058async fn legacy_schema_fingerprint(
1059    connection: &mut sqlx::PgConnection,
1060    schema: &str,
1061) -> Result<LegacySchemaFingerprint, sqlx::Error> {
1062    let schema_fingerprint = sqlx::query_scalar::<_, String>(
1063        r#"
1064        select jsonb_build_array(
1065            pg_get_userbyid(namespaces.nspowner),
1066            coalesce(namespaces.nspacl::text, ''),
1067            coalesce(obj_description(namespaces.oid, 'pg_namespace'), '')
1068        )::text
1069        from pg_namespace namespaces
1070        where namespaces.nspname = $1
1071        "#,
1072    )
1073    .bind(schema)
1074    .fetch_one(&mut *connection)
1075    .await?;
1076
1077    let relations = sqlx::query_scalar::<_, String>(
1078        r#"
1079        select jsonb_build_array(
1080            relations.relname,
1081            relations.relkind::text,
1082            pg_get_userbyid(relations.relowner),
1083            coalesce(relations.relacl::text, ''),
1084            relations.relrowsecurity,
1085            relations.relforcerowsecurity,
1086            case
1087                when namespaces.oid = pg_my_temp_schema()
1088                    and relations.relpersistence = 't'
1089                    then 'p'
1090                else relations.relpersistence::text
1091            end,
1092            coalesce(access_methods.amname, ''),
1093            coalesce((
1094                select jsonb_agg(option order by option)
1095                from unnest(relations.reloptions) option
1096            ), '[]'::jsonb),
1097            relations.relispartition,
1098            relations.relreplident::text,
1099            case
1100                when namespaces.oid = pg_my_temp_schema() then ''
1101                else coalesce(tablespaces.spcname, '')
1102            end,
1103            coalesce(obj_description(relations.oid, 'pg_class'), '')
1104        )::text
1105        from pg_class relations
1106        join pg_namespace namespaces on namespaces.oid = relations.relnamespace
1107        left join pg_am access_methods on access_methods.oid = relations.relam
1108        left join pg_tablespace tablespaces on tablespaces.oid = relations.reltablespace
1109        where namespaces.nspname = $1
1110          and relations.relkind not in ('i', 'I')
1111        order by relations.relname
1112        "#,
1113    )
1114    .bind(schema)
1115    .fetch_all(&mut *connection)
1116    .await?;
1117
1118    let columns = sqlx::query_scalar::<_, String>(
1119        r#"
1120        select jsonb_build_array(
1121            relations.relname,
1122            attributes.attnum,
1123            attributes.attname,
1124            format_type(attributes.atttypid, attributes.atttypmod),
1125            attributes.attnotnull,
1126            coalesce(pg_get_expr(defaults.adbin, defaults.adrelid, false), ''),
1127            attributes.attidentity::text,
1128            attributes.attgenerated::text,
1129            attributes.attndims,
1130            attributes.attislocal,
1131            attributes.attinhcount,
1132            attributes.atthasmissing,
1133            coalesce(attributes.attmissingval::text, ''),
1134            attributes.attstorage::text,
1135            attributes.attcompression::text,
1136            attributes.attstattarget,
1137            coalesce((
1138                select jsonb_agg(option order by option)
1139                from unnest(attributes.attoptions) option
1140            ), '[]'::jsonb),
1141            coalesce((
1142                select jsonb_agg(option order by option)
1143                from unnest(attributes.attfdwoptions) option
1144            ), '[]'::jsonb),
1145            coalesce(collations.collname, ''),
1146            coalesce(attributes.attacl::text, ''),
1147            coalesce(col_description(relations.oid, attributes.attnum), '')
1148        )::text
1149        from pg_attribute attributes
1150        join pg_class relations on relations.oid = attributes.attrelid
1151        join pg_namespace namespaces on namespaces.oid = relations.relnamespace
1152        left join pg_attrdef defaults
1153          on defaults.adrelid = relations.oid and defaults.adnum = attributes.attnum
1154        left join pg_collation collations on collations.oid = attributes.attcollation
1155        where namespaces.nspname = $1
1156          and relations.relkind in ('r', 'p', 'v', 'm', 'f')
1157          and attributes.attnum > 0
1158          and not attributes.attisdropped
1159        order by relations.relname, attributes.attnum
1160        "#,
1161    )
1162    .bind(schema)
1163    .fetch_all(&mut *connection)
1164    .await?;
1165
1166    let constraints = sqlx::query_scalar::<_, String>(
1167        r#"
1168        select jsonb_build_array(
1169            relations.relname,
1170            constraints.conname,
1171            constraints.contype::text,
1172            constraints.condeferrable,
1173            constraints.condeferred,
1174            constraints.convalidated,
1175            constraints.connoinherit,
1176            pg_get_constraintdef(constraints.oid, false),
1177            coalesce(obj_description(constraints.oid, 'pg_constraint'), '')
1178        )::text
1179        from pg_constraint constraints
1180        join pg_class relations on relations.oid = constraints.conrelid
1181        join pg_namespace namespaces on namespaces.oid = relations.relnamespace
1182        where namespaces.nspname = $1
1183        order by relations.relname, constraints.conname
1184        "#,
1185    )
1186    .bind(schema)
1187    .fetch_all(&mut *connection)
1188    .await?;
1189
1190    let indexes = sqlx::query_scalar::<_, String>(
1191        r#"
1192        select jsonb_build_array(
1193            relations.relname,
1194            indexes.relname,
1195            catalog.indisunique,
1196            catalog.indisprimary,
1197            catalog.indisexclusion,
1198            catalog.indimmediate,
1199            catalog.indisvalid,
1200            catalog.indisready,
1201            catalog.indislive,
1202            catalog.indisreplident,
1203            catalog.indcheckxmin,
1204            case
1205                when namespaces.oid = pg_my_temp_schema()
1206                    and indexes.relpersistence = 't'
1207                    then 'p'
1208                else indexes.relpersistence::text
1209            end,
1210            pg_get_userbyid(indexes.relowner),
1211            access_methods.amname,
1212            coalesce((
1213                select jsonb_agg(option order by option)
1214                from unnest(indexes.reloptions) option
1215            ), '[]'::jsonb),
1216            case
1217                when namespaces.oid = pg_my_temp_schema() then ''
1218                else coalesce(tablespaces.spcname, '')
1219            end,
1220            coalesce(indexes.relacl::text, ''),
1221            coalesce(obj_description(indexes.oid, 'pg_class'), ''),
1222            pg_get_indexdef(indexes.oid, 0, false)
1223        )::text
1224        from pg_index catalog
1225        join pg_class indexes on indexes.oid = catalog.indexrelid
1226        join pg_class relations on relations.oid = catalog.indrelid
1227        join pg_namespace namespaces on namespaces.oid = relations.relnamespace
1228        join pg_am access_methods on access_methods.oid = indexes.relam
1229        left join pg_tablespace tablespaces on tablespaces.oid = indexes.reltablespace
1230        where namespaces.nspname = $1
1231        order by relations.relname, indexes.relname
1232        "#,
1233    )
1234    .bind(schema)
1235    .fetch_all(&mut *connection)
1236    .await?;
1237
1238    let triggers = sqlx::query_scalar::<_, String>(
1239        r#"
1240        select jsonb_build_array(
1241            relations.relname,
1242            triggers.tgname,
1243            triggers.tgenabled::text,
1244            pg_get_triggerdef(triggers.oid, false),
1245            coalesce(obj_description(triggers.oid, 'pg_trigger'), '')
1246        )::text
1247        from pg_trigger triggers
1248        join pg_class relations on relations.oid = triggers.tgrelid
1249        join pg_namespace namespaces on namespaces.oid = relations.relnamespace
1250        where namespaces.nspname = $1
1251          and not triggers.tgisinternal
1252        order by relations.relname, triggers.tgname
1253        "#,
1254    )
1255    .bind(schema)
1256    .fetch_all(&mut *connection)
1257    .await?;
1258
1259    let types = sqlx::query_scalar::<_, String>(
1260        r#"
1261        select jsonb_build_array(
1262            types.typname,
1263            pg_get_userbyid(types.typowner),
1264            types.typtype::text,
1265            types.typcategory::text,
1266            types.typispreferred,
1267            types.typnotnull,
1268            coalesce(types.typdefault, ''),
1269            coalesce(relations.relname, ''),
1270            coalesce(elements.typname, ''),
1271            coalesce(base_types.typname, ''),
1272            coalesce(array_types.typname, ''),
1273            coalesce(types.typacl::text, ''),
1274            coalesce(obj_description(types.oid, 'pg_type'), '')
1275        )::text
1276        from pg_type types
1277        join pg_namespace namespaces on namespaces.oid = types.typnamespace
1278        left join pg_class relations on relations.oid = types.typrelid
1279        left join pg_type elements on elements.oid = types.typelem
1280        left join pg_type base_types on base_types.oid = types.typbasetype
1281        left join pg_type array_types on array_types.oid = types.typarray
1282        where namespaces.nspname = $1
1283        order by types.typname
1284        "#,
1285    )
1286    .bind(schema)
1287    .fetch_all(&mut *connection)
1288    .await?;
1289
1290    let routines = sqlx::query_scalar::<_, String>(
1291        r#"
1292        select jsonb_build_array(
1293            routines.proname,
1294            pg_get_function_identity_arguments(routines.oid),
1295            pg_get_userbyid(routines.proowner),
1296            routines.prokind::text,
1297            routines.prosecdef,
1298            routines.proleakproof,
1299            routines.provolatile::text,
1300            routines.proparallel::text,
1301            coalesce(routines.proacl::text, ''),
1302            coalesce(obj_description(routines.oid, 'pg_proc'), ''),
1303            pg_get_functiondef(routines.oid)
1304        )::text
1305        from pg_proc routines
1306        join pg_namespace namespaces on namespaces.oid = routines.pronamespace
1307        where namespaces.nspname = $1
1308        order by routines.proname, pg_get_function_identity_arguments(routines.oid)
1309        "#,
1310    )
1311    .bind(schema)
1312    .fetch_all(&mut *connection)
1313    .await?;
1314
1315    let policies = sqlx::query_scalar::<_, String>(
1316        r#"
1317        select jsonb_build_array(
1318            relations.relname,
1319            policies.polname,
1320            policies.polcmd::text,
1321            policies.polpermissive,
1322            coalesce(policies.polroles::text, ''),
1323            coalesce(pg_get_expr(policies.polqual, policies.polrelid, false), ''),
1324            coalesce(pg_get_expr(policies.polwithcheck, policies.polrelid, false), '')
1325        )::text
1326        from pg_policy policies
1327        join pg_class relations on relations.oid = policies.polrelid
1328        join pg_namespace namespaces on namespaces.oid = relations.relnamespace
1329        where namespaces.nspname = $1
1330        order by relations.relname, policies.polname
1331        "#,
1332    )
1333    .bind(schema)
1334    .fetch_all(&mut *connection)
1335    .await?;
1336
1337    let inheritance = sqlx::query_scalar::<_, String>(
1338        r#"
1339        select jsonb_build_array(
1340            child_namespaces.nspname,
1341            children.relname,
1342            parent_namespaces.nspname,
1343            parents.relname,
1344            inheritance.inhseqno,
1345            inheritance.inhdetachpending
1346        )::text
1347        from pg_inherits inheritance
1348        join pg_class children on children.oid = inheritance.inhrelid
1349        join pg_namespace child_namespaces on child_namespaces.oid = children.relnamespace
1350        join pg_class parents on parents.oid = inheritance.inhparent
1351        join pg_namespace parent_namespaces on parent_namespaces.oid = parents.relnamespace
1352        where child_namespaces.nspname = $1 or parent_namespaces.nspname = $1
1353        order by child_namespaces.nspname, children.relname,
1354                 parent_namespaces.nspname, parents.relname, inheritance.inhseqno
1355        "#,
1356    )
1357    .bind(schema)
1358    .fetch_all(&mut *connection)
1359    .await?;
1360
1361    let rules = sqlx::query_scalar::<_, String>(
1362        r#"
1363        select jsonb_build_array(
1364            relations.relname,
1365            rules.rulename,
1366            rules.ev_enabled::text,
1367            pg_get_ruledef(rules.oid, false)
1368        )::text
1369        from pg_rewrite rules
1370        join pg_class relations on relations.oid = rules.ev_class
1371        join pg_namespace namespaces on namespaces.oid = relations.relnamespace
1372        where namespaces.nspname = $1
1373          and rules.rulename <> '_RETURN'
1374        order by relations.relname, rules.rulename
1375        "#,
1376    )
1377    .bind(schema)
1378    .fetch_all(&mut *connection)
1379    .await?;
1380
1381    let security_labels = sqlx::query_scalar::<_, String>(
1382        r#"
1383        select jsonb_build_array(
1384            labels.classoid::regclass::text,
1385            labels.objoid,
1386            labels.objsubid,
1387            labels.provider,
1388            labels.label
1389        )::text
1390        from pg_seclabel labels
1391        where (
1392            labels.classoid = 'pg_namespace'::regclass
1393            and labels.objoid = (select oid from pg_namespace where nspname = $1)
1394        ) or (
1395            labels.classoid = 'pg_class'::regclass
1396            and labels.objoid in (
1397                select relations.oid
1398                from pg_class relations
1399                join pg_namespace namespaces on namespaces.oid = relations.relnamespace
1400                where namespaces.nspname = $1
1401            )
1402        ) or (
1403            labels.classoid = 'pg_proc'::regclass
1404            and labels.objoid in (
1405                select routines.oid
1406                from pg_proc routines
1407                join pg_namespace namespaces on namespaces.oid = routines.pronamespace
1408                where namespaces.nspname = $1
1409            )
1410        ) or (
1411            labels.classoid = 'pg_type'::regclass
1412            and labels.objoid in (
1413                select types.oid
1414                from pg_type types
1415                join pg_namespace namespaces on namespaces.oid = types.typnamespace
1416                where namespaces.nspname = $1
1417            )
1418        ) or (
1419            labels.classoid = 'pg_constraint'::regclass
1420            and labels.objoid in (
1421                select constraints.oid
1422                from pg_constraint constraints
1423                where constraints.connamespace = (
1424                    select oid from pg_namespace where nspname = $1
1425                )
1426            )
1427        )
1428        order by labels.classoid, labels.objoid, labels.objsubid, labels.provider
1429        "#,
1430    )
1431    .bind(schema)
1432    .fetch_all(&mut *connection)
1433    .await?;
1434
1435    let publication_memberships = sqlx::query_scalar::<_, String>(
1436        r#"
1437        select jsonb_build_array(
1438            publications.pubname,
1439            relations.relname,
1440            coalesce(pg_get_expr(members.prqual, members.prrelid, false), ''),
1441            coalesce(members.prattrs::text, '')
1442        )::text
1443        from pg_publication_rel members
1444        join pg_publication publications on publications.oid = members.prpubid
1445        join pg_class relations on relations.oid = members.prrelid
1446        join pg_namespace namespaces on namespaces.oid = relations.relnamespace
1447        where namespaces.nspname = $1
1448        order by publications.pubname, relations.relname
1449        "#,
1450    )
1451    .bind(schema)
1452    .fetch_all(&mut *connection)
1453    .await?;
1454
1455    Ok(LegacySchemaFingerprint {
1456        schema: schema_fingerprint,
1457        relations,
1458        columns,
1459        constraints,
1460        indexes,
1461        triggers,
1462        types,
1463        routines,
1464        policies,
1465        inheritance,
1466        rules,
1467        security_labels,
1468        publication_memberships,
1469    })
1470}
1471
1472fn migration_checksum(migration: &lenso_postgres_kit::Migration) -> String {
1473    let mut digest = Sha256::new();
1474    digest.update(migration.version().to_be_bytes());
1475    digest.update([0]);
1476    digest.update(migration.name().as_bytes());
1477    digest.update([0]);
1478    digest.update(migration.sql().as_bytes());
1479    hex::encode(digest.finalize())
1480}
1481
1482#[cfg(test)]
1483mod tests {
1484    use super::*;
1485
1486    #[test]
1487    fn catalog_normalization_changes_only_identifier_qualifiers_in_sql_fields() {
1488        let mut rows = vec![serde_json::json!([
1489            "deliveries",
1490            "notification_delivery_status",
1491            "c",
1492            false,
1493            false,
1494            true,
1495            true,
1496            "CHECK (status = 'notification.delivery_unknown' AND notification.revision > 0 AND \"notification\".attempt_count >= 0 AND \"notification.keep\" = \"notification.keep\" AND note = $$notification.keep$$)",
1497            "notification."
1498        ])
1499        .to_string()];
1500
1501        normalize_sql_fields(&mut rows, &[7], "notification");
1502        let normalized: serde_json::Value =
1503            serde_json::from_str(&rows[0]).expect("normalized catalog row");
1504        let definition = normalized[7].as_str().expect("constraint definition");
1505        assert!(definition.contains("'notification.delivery_unknown'"));
1506        assert!(definition.contains("$$notification.keep$$"));
1507        assert_eq!(definition.matches("\"notification.keep\"").count(), 2);
1508        assert!(!definition.contains("notification.revision"));
1509        assert!(!definition.contains("\"notification\".attempt_count"));
1510        assert!(definition.contains("revision > 0"));
1511        assert!(definition.contains("attempt_count >= 0"));
1512        assert_eq!(normalized[8], "notification.");
1513    }
1514}