pg-logical-repl 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. pg_logical_repl/__init__.py +3 -0
  2. pg_logical_repl/__main__.py +6 -0
  3. pg_logical_repl/capabilities.py +136 -0
  4. pg_logical_repl/catalog.py +320 -0
  5. pg_logical_repl/cli.py +277 -0
  6. pg_logical_repl/component.py +66 -0
  7. pg_logical_repl/config.py +356 -0
  8. pg_logical_repl/coverage.py +117 -0
  9. pg_logical_repl/cutover.py +239 -0
  10. pg_logical_repl/db.py +131 -0
  11. pg_logical_repl/ddl.py +352 -0
  12. pg_logical_repl/ddl_queue.py +461 -0
  13. pg_logical_repl/emergency.py +296 -0
  14. pg_logical_repl/errors.py +44 -0
  15. pg_logical_repl/health.py +194 -0
  16. pg_logical_repl/integrity.py +185 -0
  17. pg_logical_repl/logging_config.py +45 -0
  18. pg_logical_repl/monitoring.py +277 -0
  19. pg_logical_repl/plans.py +167 -0
  20. pg_logical_repl/preflight.py +290 -0
  21. pg_logical_repl/process.py +150 -0
  22. pg_logical_repl/recovery.py +188 -0
  23. pg_logical_repl/resync.py +267 -0
  24. pg_logical_repl/roles.py +123 -0
  25. pg_logical_repl/schema.py +89 -0
  26. pg_logical_repl/security.py +117 -0
  27. pg_logical_repl/service.py +773 -0
  28. pg_logical_repl/sql/base.sql +67 -0
  29. pg_logical_repl/sql/capture.sql +215 -0
  30. pg_logical_repl/sql/ddl_support.sql +109 -0
  31. pg_logical_repl/sql/freeze.sql +117 -0
  32. pg_logical_repl/sql/replay.sql +48 -0
  33. pg_logical_repl/state.py +160 -0
  34. pg_logical_repl/validation.py +353 -0
  35. pg_logical_repl-0.2.0.data/data/share/doc/pg-logical-repl/IMPLEMENTATION_STATUS.md +207 -0
  36. pg_logical_repl-0.2.0.data/data/share/doc/pg-logical-repl/README.md +406 -0
  37. pg_logical_repl-0.2.0.data/data/share/doc/pg-logical-repl/docs/DDL_IMPLEMENTATION.md +170 -0
  38. pg_logical_repl-0.2.0.data/data/share/doc/pg-logical-repl/docs/RELEASING.md +92 -0
  39. pg_logical_repl-0.2.0.data/data/share/doc/pg-logical-repl/docs/TESTING.md +142 -0
  40. pg_logical_repl-0.2.0.data/data/share/doc/pg-logical-repl/examples/local.yaml +55 -0
  41. pg_logical_repl-0.2.0.dist-info/METADATA +425 -0
  42. pg_logical_repl-0.2.0.dist-info/RECORD +45 -0
  43. pg_logical_repl-0.2.0.dist-info/WHEEL +5 -0
  44. pg_logical_repl-0.2.0.dist-info/entry_points.txt +2 -0
  45. pg_logical_repl-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,3 @@
1
+ """PostgreSQL native logical replication orchestration."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,6 @@
1
+ """Run the CLI with python -m pg_logical_repl."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
@@ -0,0 +1,136 @@
1
+ """Machine-readable implemented surface; planned features are explicit."""
2
+
3
+
4
+ def describe():
5
+ commands = {}
6
+ for name in [
7
+ "validate",
8
+ "inspect-config",
9
+ "check",
10
+ "plan",
11
+ "status",
12
+ "events",
13
+ "verify",
14
+ "report",
15
+ "ddl check",
16
+ "ddl log",
17
+ "ddl queue list",
18
+ ]:
19
+ commands[name] = dict(
20
+ mutates_source=False,
21
+ mutates_target=False,
22
+ mutates_global_roles=False,
23
+ requires_plan=False,
24
+ machine_output=True,
25
+ )
26
+ for name in [
27
+ "prepare",
28
+ "start",
29
+ "abort",
30
+ "cutover finalize",
31
+ "cutover commit",
32
+ "ddl exec",
33
+ "ddl queue skip",
34
+ "pause",
35
+ "resume",
36
+ "resolve",
37
+ "resync-table",
38
+ "cleanup",
39
+ ]:
40
+ commands[name] = dict(
41
+ mutates_source=True,
42
+ mutates_target=True,
43
+ mutates_global_roles=name in ["prepare", "abort", "cutover commit", "cleanup"],
44
+ requires_plan=True,
45
+ machine_output=True,
46
+ )
47
+ commands["run"] = dict(
48
+ mutates_source=True,
49
+ mutates_target=True,
50
+ mutates_global_roles=False,
51
+ requires_plan="authorization persisted by start",
52
+ machine_output=True,
53
+ long_running=True,
54
+ )
55
+ commands["cutover abort"] = dict(
56
+ mutates_source=True,
57
+ mutates_target=True,
58
+ mutates_global_roles=False,
59
+ requires_plan=False,
60
+ requires_reason=True,
61
+ machine_output=True,
62
+ )
63
+ commands["ddl queue retry"] = dict(
64
+ mutates_source=True,
65
+ mutates_target=True,
66
+ mutates_global_roles=False,
67
+ requires_plan=False,
68
+ machine_output=True,
69
+ )
70
+ return dict(
71
+ schema_version="pg_logical_connector/capabilities-v2",
72
+ maturity="development",
73
+ same_major_versions=[15, 16, 17, 18],
74
+ data_plane="PostgreSQL pgoutput/publication/subscription",
75
+ validation=dict(
76
+ execution="postgresql_aggregates",
77
+ levels=["counts", "checksums", "full"],
78
+ primary_key_chunks=True,
79
+ composite_primary_keys=True,
80
+ replication_lsn_wait=True,
81
+ payload_rows_returned=False,
82
+ continuous_background=True,
83
+ resumable_background_cursor=True,
84
+ incremental_cold_chunk_cache=False,
85
+ ),
86
+ commands=commands,
87
+ integrity=dict(
88
+ roles=True,
89
+ password_verifiers=True,
90
+ membership=True,
91
+ schema=True,
92
+ installed_resources=True,
93
+ journal=True,
94
+ baseline_required=True,
95
+ ),
96
+ recovery=dict(
97
+ planned=True,
98
+ pause_requires_wal_budget=True,
99
+ resume_requires_validation=True,
100
+ resync_profile="ordinary logged tables; preserves referencing foreign keys",
101
+ source_offline_abort=True,
102
+ source_offline_finalized_detach=True,
103
+ deferred_cleanup_requires_plan=True,
104
+ ),
105
+ metrics="prometheus_textfile",
106
+ ddl_profile=[
107
+ "logged tables/partitions, CTAS structure + native tablesync, automatic publication refresh/READY",
108
+ "columns, deterministic type changes/defaults, bounded rewrites and SET NOT NULL",
109
+ "CHECK/FK NOT VALID + queued VALIDATE; UNIQUE/PK concurrent index + constraint attachment",
110
+ "ordinary and partitioned index queues, durable intents, retries, planned/evidenced skip",
111
+ "views/materialized-view definitions, trusted functions/procedures, application triggers",
112
+ "sequence DDL (except RESTART), enum/domain, ownership, ACL/default privileges and comments",
113
+ ],
114
+ ddl_queue=dict(
115
+ order="serialized",
116
+ retry_limit=True,
117
+ partition_index_tree=True,
118
+ concurrent_source_reconciliation=True,
119
+ skip_requires_evidence=True,
120
+ ),
121
+ sql_resource_version=2,
122
+ ddl_restrictions=[
123
+ "one top-level command and one ALTER action; no DO/CASCADE/global/extension DDL",
124
+ "function calls in ADD COLUMN defaults/USING are conservatively rejected",
125
+ "pending queue jobs block subsequent source DDL; DML continues",
126
+ "custom object classes outside coverage remain blocked; no automatic SQL resource upgrade",
127
+ ],
128
+ not_implemented=[
129
+ "takeover",
130
+ "doctor bundle",
131
+ "incremental cold-chunk cache",
132
+ "partition-tree resync",
133
+ "automatic failover",
134
+ "full SPEC v2 object coverage",
135
+ ],
136
+ )
@@ -0,0 +1,320 @@
1
+ """Version-aware inventory; SQL definitions and verifiers remain private."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncpg
6
+
7
+ from .config import digest
8
+ from .coverage import unsupported_objects
9
+ from .db import identity
10
+
11
+
12
+ def acl(expression):
13
+ return (
14
+ """(SELECT jsonb_agg(jsonb_build_object('grantor',pg_get_userbyid(a.grantor),
15
+ 'grantee',CASE WHEN a.grantee=0 THEN 'PUBLIC' ELSE pg_get_userbyid(a.grantee) END,
16
+ 'privilege',a.privilege_type,'grantable',a.is_grantable)
17
+ ORDER BY pg_get_userbyid(a.grantor),CASE WHEN a.grantee=0 THEN 'PUBLIC' ELSE pg_get_userbyid(a.grantee) END,a.privilege_type)
18
+ FROM aclexplode("""
19
+ + expression
20
+ + ") a)"
21
+ )
22
+
23
+
24
+ RELATIONS = """
25
+ SELECT c.oid::int AS oid, n.nspname AS schema, c.relname AS name, c.relkind::text AS kind,
26
+ c.relpersistence::text AS persistence, c.relreplident::text AS replica_identity,
27
+ c.relispartition AS is_partition, pg_get_userbyid(c.relowner) AS owner,
28
+ c.relrowsecurity AS row_security, c.relforcerowsecurity AS force_row_security,
29
+ c.relispopulated AS populated, c.relacl::text AS acl,
30
+ c.reloptions, pg_get_expr(c.relpartbound,c.oid) AS partition_bound,
31
+ CASE WHEN c.relkind='p' THEN pg_get_partkeydef(c.oid) END AS partition_key,
32
+ (SELECT array_agg(format('%I.%I',pn.nspname,pc.relname) ORDER BY i.inhseqno)
33
+ FROM pg_inherits i JOIN pg_class pc ON pc.oid=i.inhparent
34
+ JOIN pg_namespace pn ON pn.oid=pc.relnamespace WHERE i.inhrelid=c.oid) AS parents,
35
+ (SELECT jsonb_build_object('type',format_type(s.seqtypid,NULL),'start',s.seqstart,
36
+ 'increment',s.seqincrement,'min',s.seqmin,'max',s.seqmax,'cache',s.seqcache,'cycle',s.seqcycle)
37
+ FROM pg_sequence s WHERE s.seqrelid=c.oid) AS sequence_definition,
38
+ obj_description(c.oid,'pg_class') AS comment,
39
+ EXISTS(SELECT FROM pg_index i WHERE i.indrelid=c.oid AND i.indisprimary AND i.indisvalid) AS has_pk,
40
+ EXISTS(SELECT FROM pg_index i WHERE i.indrelid=c.oid AND i.indisreplident AND i.indisvalid) AS has_identity_index,
41
+ (SELECT e.extname FROM pg_depend d JOIN pg_extension e ON e.oid=d.refobjid
42
+ WHERE d.classid='pg_class'::regclass AND d.objid=c.oid AND d.deptype='e' LIMIT 1) AS extension,
43
+ CASE WHEN c.relkind IN ('v','m') THEN pg_get_viewdef(c.oid,true) ELSE NULL END AS view_definition,
44
+ pg_total_relation_size(c.oid)::bigint AS bytes
45
+ FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace
46
+ WHERE n.nspname <> 'information_schema' AND n.nspname !~ '^pg_' AND n.nspname <> 'lc'
47
+ AND c.relkind IN ('r','p','v','m','S','f') ORDER BY n.nspname,c.relname
48
+ """
49
+ RELATIONS = RELATIONS.replace(
50
+ "c.relacl::text AS acl",
51
+ acl("COALESCE(c.relacl,acldefault(CASE WHEN c.relkind='S' THEN 's' ELSE 'r' END::\"char\",c.relowner))")
52
+ + " AS acl",
53
+ )
54
+
55
+
56
+ async def inventory(c, *, include_passwords=False):
57
+ ident = await identity(c)
58
+ major = ident["major"]
59
+ relations = [dict(r) for r in await c.fetch(RELATIONS)]
60
+ columns = await c.fetch("""SELECT a.attrelid::int AS relid,a.attnum,a.attname,
61
+ format_type(a.atttypid,a.atttypmod) AS type,a.attnotnull,
62
+ a.attidentity::text,a.attgenerated::text,pg_get_expr(d.adbin,d.adrelid) AS default,
63
+ col_description(a.attrelid,a.attnum) AS comment,a.attacl::text AS acl,
64
+ a.attstorage::text,a.attcompression::text,
65
+ CASE WHEN a.attcollation<>0 THEN
66
+ (SELECT format('%I.%I',cn.nspname,cl.collname) FROM pg_collation cl
67
+ JOIN pg_namespace cn ON cn.oid=cl.collnamespace WHERE cl.oid=a.attcollation)
68
+ END AS collation,
69
+ EXISTS(SELECT FROM pg_opclass oc JOIN pg_am am ON am.oid=oc.opcmethod
70
+ WHERE oc.opcdefault AND oc.opcintype=a.atttypid AND am.amname IN ('btree','hash')) AS full_identity_equality
71
+ FROM pg_attribute a JOIN pg_class c ON c.oid=a.attrelid
72
+ JOIN pg_namespace n ON n.oid=c.relnamespace
73
+ LEFT JOIN pg_attrdef d ON d.adrelid=a.attrelid AND d.adnum=a.attnum
74
+ WHERE a.attnum>0 AND NOT a.attisdropped AND n.nspname !~ '^pg_'
75
+ AND n.nspname NOT IN ('information_schema','lc') ORDER BY a.attrelid,a.attnum""")
76
+ constraints = await c.fetch("""SELECT conrelid::int AS relid,conname,contype::text,convalidated,
77
+ pg_get_constraintdef(oid,true) AS definition,obj_description(oid,'pg_constraint') AS comment FROM pg_constraint
78
+ WHERE conrelid<>0 ORDER BY conrelid,conname""")
79
+ indexes = await c.fetch("""SELECT i.indrelid::int AS relid,ci.relname AS name,
80
+ i.indisvalid,pg_get_indexdef(i.indexrelid) AS definition,obj_description(ci.oid,'pg_class') AS comment
81
+ FROM pg_index i JOIN pg_class ci ON ci.oid=i.indexrelid ORDER BY i.indrelid,ci.relname""")
82
+ triggers = await c.fetch("""SELECT t.tgrelid::int AS relid,t.tgname,t.tgenabled::text,
83
+ pg_get_triggerdef(t.oid,true) AS definition,obj_description(t.oid,'pg_trigger') AS comment FROM pg_trigger t
84
+ WHERE NOT t.tgisinternal ORDER BY t.tgrelid,t.tgname""")
85
+ for rel in relations:
86
+ for name, rows in [
87
+ ("columns", columns),
88
+ ("constraints", constraints),
89
+ ("indexes", indexes),
90
+ ("triggers", triggers),
91
+ ]:
92
+ rel[name] = [
93
+ {k: v for k, v in dict(row).items() if k != "relid"}
94
+ for row in rows
95
+ if row["relid"] == rel["oid"]
96
+ ]
97
+ extensions = [
98
+ dict(r)
99
+ for r in await c.fetch("""SELECT e.extname AS name,e.extversion AS version,
100
+ n.nspname AS schema,pg_get_userbyid(e.extowner) AS owner,e.extconfig::text,e.extcondition::text
101
+ FROM pg_extension e JOIN pg_namespace n ON n.oid=e.extnamespace ORDER BY e.extname""")
102
+ ]
103
+ role_columns = "rolname,rolsuper,rolinherit,rolcreaterole,rolcreatedb,rolcanlogin,rolreplication,rolbypassrls,rolconnlimit,rolvaliduntil::text,rolconfig"
104
+ roles = [
105
+ dict(r)
106
+ for r in await c.fetch(
107
+ "SELECT " + role_columns + " FROM pg_roles WHERE rolname !~ '^pg_' ORDER BY rolname"
108
+ )
109
+ ]
110
+ password_access = False
111
+ if include_passwords:
112
+ try:
113
+ hashes = {
114
+ r["rolname"]: r["rolpassword"]
115
+ for r in await c.fetch("SELECT rolname,rolpassword FROM pg_authid WHERE rolname !~ '^pg_'")
116
+ }
117
+ for role in roles:
118
+ role["rolpassword"] = hashes[role["rolname"]]
119
+ password_access = True
120
+ except asyncpg.InsufficientPrivilegeError:
121
+ pass
122
+ options = ",inherit_option,set_option" if major >= 16 else ""
123
+ memberships = [
124
+ dict(r)
125
+ for r in await c.fetch(
126
+ "SELECT pg_get_userbyid(roleid) AS role,pg_get_userbyid(member) AS member,pg_get_userbyid(grantor) AS grantor,admin_option"
127
+ + options
128
+ + " FROM pg_auth_members ORDER BY roleid,member,grantor"
129
+ )
130
+ ]
131
+ functions = [
132
+ dict(r)
133
+ for r in await c.fetch(
134
+ """SELECT n.nspname AS schema,p.proname AS name,
135
+ pg_get_function_identity_arguments(p.oid) AS arguments,pg_get_functiondef(p.oid) AS definition,
136
+ obj_description(p.oid,'pg_proc') AS comment,pg_get_userbyid(p.proowner) AS owner,p.proacl::text AS acl
137
+ FROM pg_proc p JOIN pg_namespace n ON n.oid=p.pronamespace
138
+ WHERE n.nspname !~ '^pg_' AND n.nspname NOT IN ('lc','information_schema')
139
+ AND p.prokind IN ('f','p') AND NOT EXISTS(SELECT FROM pg_depend d
140
+ WHERE d.classid='pg_proc'::regclass AND d.objid=p.oid AND d.deptype='e')
141
+ ORDER BY n.nspname,p.proname,pg_get_function_identity_arguments(p.oid)""".replace(
142
+ "p.proacl::text AS acl", acl("COALESCE(p.proacl,acldefault('f',p.proowner))") + " AS acl"
143
+ )
144
+ )
145
+ ]
146
+ schemas = [
147
+ dict(r)
148
+ for r in await c.fetch(
149
+ """SELECT obj_description(oid,'pg_namespace') AS comment,nspname AS name,pg_get_userbyid(nspowner) AS owner,nspacl::text AS acl
150
+ FROM pg_namespace WHERE nspname !~ '^pg_' AND nspname NOT IN ('lc','information_schema') ORDER BY nspname""".replace(
151
+ "nspacl::text AS acl", acl("COALESCE(nspacl,acldefault('n',nspowner))") + " AS acl"
152
+ )
153
+ )
154
+ ]
155
+ settings = {
156
+ r["name"]: r["setting"]
157
+ for r in await c.fetch(
158
+ "SELECT name,setting FROM pg_settings WHERE name IN ('wal_level','max_replication_slots','max_wal_senders','max_logical_replication_workers','max_worker_processes','max_sync_workers_per_subscription','max_active_replication_origins','fsync','synchronized_standby_slots','idle_replication_slot_timeout')"
159
+ )
160
+ }
161
+ sessions = [
162
+ dict(r)
163
+ for r in await c.fetch("""SELECT pid,usename,application_name,client_addr::text,state,backend_type
164
+ FROM pg_stat_activity WHERE datname=current_database() AND pid<>pg_backend_pid()
165
+ AND application_name NOT LIKE 'pg-logical-repl/%' ORDER BY pid""")
166
+ ]
167
+ slots = [
168
+ dict(r)
169
+ for r in await c.fetch(
170
+ "SELECT slot_name,slot_type,plugin,database,active FROM pg_replication_slots ORDER BY slot_name"
171
+ )
172
+ ]
173
+ role_settings = [
174
+ dict(r)
175
+ for r in await c.fetch("""SELECT CASE WHEN setrole=0 THEN NULL ELSE pg_get_userbyid(setrole) END AS role, setconfig
176
+ FROM pg_db_role_setting WHERE setdatabase=(SELECT oid FROM pg_database WHERE datname=current_database())
177
+ ORDER BY 1 NULLS FIRST""")
178
+ ]
179
+ types = [
180
+ dict(r)
181
+ for r in await c.fetch(
182
+ """SELECT obj_description(t.oid,'pg_type') AS comment,n.nspname AS schema,t.typname AS name,t.typtype::text AS kind,
183
+ pg_get_userbyid(t.typowner) AS owner,t.typnotnull,t.typdefault,t.typacl::text AS acl,
184
+ CASE WHEN t.typbasetype<>0 THEN format_type(t.typbasetype,t.typtypmod) END AS base_type,
185
+ (SELECT array_agg(enumlabel ORDER BY enumsortorder) FROM pg_enum WHERE enumtypid=t.oid) AS labels,
186
+ (SELECT jsonb_agg(jsonb_build_object('name',conname,'definition',pg_get_constraintdef(oid,true)) ORDER BY conname)
187
+ FROM pg_constraint WHERE contypid=t.oid) AS constraints
188
+ FROM pg_type t JOIN pg_namespace n ON n.oid=t.typnamespace
189
+ WHERE n.nspname !~ '^pg_' AND n.nspname NOT IN ('lc','information_schema')
190
+ AND t.typtype IN ('d','e','r','m','c') AND t.typelem=0
191
+ AND (t.typrelid=0 OR EXISTS(SELECT FROM pg_class c WHERE c.oid=t.typrelid AND c.relkind='c'))
192
+ AND NOT EXISTS(SELECT FROM pg_depend d WHERE d.classid='pg_type'::regclass AND d.objid=t.oid AND d.deptype='e')
193
+ ORDER BY n.nspname,t.typname""".replace(
194
+ "t.typacl::text AS acl", acl("COALESCE(t.typacl,acldefault('T',t.typowner))") + " AS acl"
195
+ )
196
+ )
197
+ ]
198
+ default_acl = [
199
+ dict(r)
200
+ for r in await c.fetch("""SELECT pg_get_userbyid(d.defaclrole) AS role,
201
+ n.nspname AS schema,d.defaclobjtype::text AS kind,d.defaclacl::text AS acl FROM pg_default_acl d
202
+ LEFT JOIN pg_namespace n ON n.oid=d.defaclnamespace ORDER BY 1,2,3""")
203
+ ]
204
+ database_acl = [
205
+ dict(r)
206
+ for r in await c.fetch("""SELECT pg_get_userbyid(a.grantor) AS grantor,
207
+ CASE WHEN a.grantee=0 THEN 'PUBLIC' ELSE pg_get_userbyid(a.grantee) END AS grantee,
208
+ a.privilege_type AS privilege,a.is_grantable AS grantable
209
+ FROM pg_database d,LATERAL aclexplode(COALESCE(d.datacl,acldefault('d',d.datdba))) a
210
+ WHERE d.datname=current_database() ORDER BY 1,2,3""")
211
+ ]
212
+ database = dict(
213
+ await c.fetchrow("""SELECT pg_get_userbyid(datdba) AS owner,datconnlimit,
214
+ shobj_description(oid,'pg_database') AS comment FROM pg_database WHERE datname=current_database()""")
215
+ )
216
+ database["acl"] = database_acl
217
+ return dict(
218
+ identity=ident,
219
+ relations=relations,
220
+ extensions=extensions,
221
+ schemas=schemas,
222
+ roles=roles,
223
+ memberships=memberships,
224
+ password_access=password_access,
225
+ functions=functions,
226
+ settings=settings,
227
+ sessions=sessions,
228
+ slots=slots,
229
+ role_settings=role_settings,
230
+ types=types,
231
+ default_acl=default_acl,
232
+ database=database,
233
+ unsupported_objects=await unsupported_objects(c),
234
+ large_objects=await c.fetchval("SELECT count(*) FROM pg_largeobject_metadata"),
235
+ publications=[
236
+ dict(x) for x in await c.fetch("SELECT pubname,puballtables FROM pg_publication ORDER BY pubname")
237
+ ],
238
+ subscriptions=[
239
+ dict(x)
240
+ for x in await c.fetch(
241
+ "SELECT subname,subenabled FROM pg_subscription WHERE subdbid=(SELECT oid FROM pg_database WHERE datname=current_database()) ORDER BY subname"
242
+ )
243
+ ],
244
+ event_triggers=[
245
+ dict(x)
246
+ for x in await c.fetch("SELECT evtname,evtenabled::text FROM pg_event_trigger ORDER BY evtname")
247
+ ],
248
+ )
249
+
250
+
251
+ def schema_manifest(inv):
252
+ relations = []
253
+ for row in inv["relations"]:
254
+ if row["extension"]:
255
+ continue
256
+ relations.append({k: v for k, v in row.items() if k not in {"oid", "bytes", "populated"}})
257
+ return dict(
258
+ relations=relations,
259
+ functions=inv["functions"],
260
+ extensions=inv["extensions"],
261
+ schemas=inv["schemas"],
262
+ types=inv["types"],
263
+ default_acl=inv["default_acl"],
264
+ database=inv["database"],
265
+ role_settings=inv["role_settings"],
266
+ unsupported_objects=inv["unsupported_objects"],
267
+ )
268
+
269
+
270
+ def fingerprint(inv):
271
+ return digest(schema_manifest(inv))
272
+
273
+
274
+ def difference(left, right, path=""):
275
+ if type(left) is not type(right):
276
+ return [path]
277
+ if isinstance(left, dict):
278
+ return [
279
+ p
280
+ for k in sorted(set(left) | set(right))
281
+ for p in difference(left.get(k), right.get(k), path + "/" + str(k))
282
+ ]
283
+ if isinstance(left, list):
284
+ if len(left) != len(right):
285
+ return [path + "/length"]
286
+ return [p for i, (a, b) in enumerate(zip(left, right)) for p in difference(a, b, path + "/" + str(i))]
287
+ return [] if left == right else [path]
288
+
289
+
290
+ def role_fingerprint(inv):
291
+ return digest(
292
+ dict(
293
+ roles=[{k: v for k, v in r.items() if k != "rolpassword"} for r in inv["roles"]],
294
+ memberships=inv["memberships"],
295
+ )
296
+ )
297
+
298
+
299
+ def public_inventory(inv):
300
+ return dict(
301
+ identity=inv["identity"],
302
+ schema_fingerprint=fingerprint(inv),
303
+ role_fingerprint=role_fingerprint(inv),
304
+ password_access=inv["password_access"],
305
+ objects=[
306
+ dict(
307
+ schema=r["schema"],
308
+ name=r["name"],
309
+ kind=r["kind"],
310
+ owner=r["owner"],
311
+ bytes=r["bytes"],
312
+ extension=r["extension"],
313
+ )
314
+ for r in inv["relations"]
315
+ ],
316
+ roles=[r["rolname"] for r in inv["roles"]],
317
+ extensions=inv["extensions"],
318
+ settings=inv["settings"],
319
+ unsupported_objects=inv["unsupported_objects"],
320
+ )