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#[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 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 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 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 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}