@tangen/db 0.0.0-stage → 0.1.54

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 (127) hide show
  1. package/README.md +73 -2
  2. package/drizzle/0000_flimsy_shotgun.sql +339 -0
  3. package/drizzle/0001_app_data.sql +11 -0
  4. package/drizzle/0002_add_indexes.sql +36 -0
  5. package/drizzle/0003_extension_data_rename.sql +3 -0
  6. package/drizzle/0004_extension_data_published_globals.sql +12 -0
  7. package/drizzle/0005_auth.sql +51 -0
  8. package/drizzle/0006_auth_identities.sql +13 -0
  9. package/drizzle/0007_ai.sql +18 -0
  10. package/drizzle/0008_ai_chat_threads.sql +5 -0
  11. package/drizzle/0009_forms_source_slug.sql +11 -0
  12. package/drizzle/0010_rbac_roles_status.sql +14 -0
  13. package/drizzle/0011_jobs_v2.sql +67 -0
  14. package/drizzle/0012_search_platform.sql +36 -0
  15. package/drizzle/0013_search_synonym_label.sql +2 -0
  16. package/drizzle/0014_object_types_disabled.sql +2 -0
  17. package/drizzle/0015_playground_collections.sql +25 -0
  18. package/drizzle/0016_catchup_activity_promotion.sql +36 -0
  19. package/drizzle/0017_fields_disabled.sql +3 -0
  20. package/drizzle/0018_object_types_plural.sql +1 -0
  21. package/drizzle/0019_member_profile.sql +2 -0
  22. package/drizzle/0020_paused_jobs.sql +7 -0
  23. package/drizzle/0021_form_submission_notes.sql +2 -0
  24. package/drizzle/0022_paused_jobs_env_reason.sql +4 -0
  25. package/drizzle/0023_scale_indexes.sql +3 -0
  26. package/drizzle/0024_migration_runs.sql +24 -0
  27. package/drizzle/0025_porter_transfer.sql +30 -0
  28. package/drizzle/0026_cron_timezone.sql +1 -0
  29. package/drizzle/0027_rate_limit_counters.sql +6 -0
  30. package/drizzle/0028_realtime_events.sql +10 -0
  31. package/drizzle/0030_delivery_artifacts.sql +10 -0
  32. package/drizzle/0031_realtime_presence.sql +8 -0
  33. package/drizzle/0032_schema_pushes.sql +15 -0
  34. package/drizzle/0033_studio_design_capability.sql +37 -0
  35. package/drizzle/0034_object_types_provided_by.sql +12 -0
  36. package/drizzle/0035_custom_table_owners.sql +24 -0
  37. package/drizzle/0036_revision_version_unique.sql +42 -0
  38. package/drizzle/0037_job_queue_dedupe.sql +20 -0
  39. package/drizzle/0038_search_documents.sql +87 -0
  40. package/drizzle/0039_job_prune_indexes.sql +25 -0
  41. package/drizzle/0040_extension_data_object_idx.sql +10 -0
  42. package/drizzle/0041_knowledge.sql +79 -0
  43. package/drizzle/0042_schema_pushes_checksum.sql +7 -0
  44. package/drizzle/0043_guard_adoption.sql +12 -0
  45. package/drizzle/0044_retention_indexes.sql +7 -0
  46. package/drizzle/0045_extension_install_per_env.sql +40 -0
  47. package/drizzle/0046_extension_migrations.sql +18 -0
  48. package/drizzle/0047_environment_role.sql +25 -0
  49. package/drizzle/0048_environment_role_adoption.sql +30 -0
  50. package/drizzle/0049_release_lifecycle.sql +55 -0
  51. package/drizzle/0050_member_env_membership.sql +21 -0
  52. package/drizzle/0051_remove_environments.sql +350 -0
  53. package/drizzle/0052_release_actor_columns.sql +9 -0
  54. package/drizzle/0053_kernel_event_record.sql +26 -0
  55. package/drizzle/0054_kernel_run_identity.sql +13 -0
  56. package/drizzle/0055_kernel_identities.sql +19 -0
  57. package/drizzle/0056_forms_leave_core.sql +7 -0
  58. package/drizzle/0057_auth_leave_core.sql +8 -0
  59. package/drizzle/0058_member_grants.sql +11 -0
  60. package/drizzle/0059_extension_granted_grants.sql +7 -0
  61. package/drizzle/0060_connections.sql +21 -0
  62. package/drizzle/meta/0000_snapshot.json +1692 -0
  63. package/drizzle/meta/0002_snapshot.json +1935 -0
  64. package/drizzle/meta/0003_snapshot.json +1935 -0
  65. package/drizzle/meta/_journal.json +426 -0
  66. package/package.json +85 -4
  67. package/src/cache-keys.ts +43 -0
  68. package/src/client.ts +136 -0
  69. package/src/content-ddl.ts +70 -0
  70. package/src/content-tables.ts +110 -0
  71. package/src/custom-tables-loader.ts +65 -0
  72. package/src/ensure-system-schema.ts +195 -0
  73. package/src/extension-install-state.ts +220 -0
  74. package/src/extension-migrations.ts +270 -0
  75. package/src/generated/content-tables.ts +271 -0
  76. package/src/generated/custom-tables.ts +18 -0
  77. package/src/index.ts +24 -0
  78. package/src/instrument.ts +139 -0
  79. package/src/knowledge-schema.ts +232 -0
  80. package/src/migrate.ts +67 -0
  81. package/src/prepare-policy.ts +118 -0
  82. package/src/reconcile.ts +115 -0
  83. package/src/schema/ai-chats.ts +36 -0
  84. package/src/schema/ai-settings.ts +24 -0
  85. package/src/schema/api-keys.ts +37 -0
  86. package/src/schema/audit-log.ts +54 -0
  87. package/src/schema/connections.ts +35 -0
  88. package/src/schema/custom-table-owners.ts +32 -0
  89. package/src/schema/delivery-artifacts.ts +33 -0
  90. package/src/schema/entity-tags.ts +33 -0
  91. package/src/schema/extension-data.ts +38 -0
  92. package/src/schema/extension-migrations.ts +42 -0
  93. package/src/schema/fields.ts +37 -0
  94. package/src/schema/globals.ts +35 -0
  95. package/src/schema/identities.ts +27 -0
  96. package/src/schema/index.ts +51 -0
  97. package/src/schema/jobs.ts +104 -0
  98. package/src/schema/knowledge.ts +85 -0
  99. package/src/schema/media.ts +52 -0
  100. package/src/schema/member-grants.ts +34 -0
  101. package/src/schema/members.ts +49 -0
  102. package/src/schema/migration-runs.ts +46 -0
  103. package/src/schema/object-types.ts +41 -0
  104. package/src/schema/objects.ts +38 -0
  105. package/src/schema/paused-jobs.ts +25 -0
  106. package/src/schema/playground.ts +43 -0
  107. package/src/schema/porter-transfer.ts +47 -0
  108. package/src/schema/realtime.ts +45 -0
  109. package/src/schema/releases.ts +95 -0
  110. package/src/schema/roles.ts +27 -0
  111. package/src/schema/saved-views.ts +33 -0
  112. package/src/schema/schema-pushes.ts +43 -0
  113. package/src/schema/search-documents.ts +47 -0
  114. package/src/schema/search-query-log.ts +24 -0
  115. package/src/schema/search-recommendations.ts +27 -0
  116. package/src/schema/search-synonyms.ts +23 -0
  117. package/src/schema/webhook-deliveries.ts +37 -0
  118. package/src/schema/webhooks.ts +36 -0
  119. package/src/schema/workspace-activity.ts +35 -0
  120. package/src/schema/workspace-domains.ts +24 -0
  121. package/src/schema/workspace-installed-apps.ts +48 -0
  122. package/src/schema/workspaces.ts +24 -0
  123. package/src/schema-kit.ts +64 -0
  124. package/src/sealed.ts +84 -0
  125. package/src/seed/skeleton.ts +77 -0
  126. package/src/sql/apply-gin-indexes.ts +31 -0
  127. package/src/sql/gin-indexes.sql +11 -0
@@ -0,0 +1,350 @@
1
+ -- Remove first-class Environments (tasks/remove-environments-programme.md, R6 + R7).
2
+ --
3
+ -- One deployment has one database context; a workspace is the content
4
+ -- boundary. This migration keeps ONE environment's rows per workspace and
5
+ -- drops every env_id column, the environment tables and the promotion
6
+ -- bookkeeping. It is generic over information_schema, so content tables,
7
+ -- custom tables and extension tables that carry env_id are handled the same
8
+ -- way as the platform's own tables.
9
+ --
10
+ -- Which environment is kept, per workspace:
11
+ -- 1. TANGEN_KEEP_ENVIRONMENT (run as `SET tangen.keep_environment = '<slug>'`
12
+ -- by the migration runner) names it explicitly.
13
+ -- 2. Otherwise, when exactly one environment holds data rows, that one.
14
+ -- 3. Otherwise, when no environment holds data rows, the production-role one.
15
+ -- 4. Otherwise the migration REFUSES with the per-environment row counts and
16
+ -- the variable to set. Nothing is deleted on refusal.
17
+ -- Data rows are counted in every env-scoped table except bookkeeping and
18
+ -- history (registry, ledgers, queues, caches, logs), which follow the decision.
19
+ --
20
+ -- History tables (audit_log, workspace_activity, job_runs, schema_pushes,
21
+ -- migration_runs, release_history, webhook_deliveries, search_query_log,
22
+ -- knowledge_queries) keep every row and only lose the column.
23
+
24
+ -- ── 1. Decide, and delete the rows of the environments that are not kept ────
25
+ DO $tangen$
26
+ DECLARE
27
+ has_env boolean;
28
+ keep_slug text := nullif(trim(current_setting('tangen.keep_environment', true)), '');
29
+ ws record;
30
+ env record;
31
+ t record;
32
+ k record;
33
+ n bigint;
34
+ counts jsonb;
35
+ data_total bigint;
36
+ with_data int;
37
+ kept text;
38
+ prod text;
39
+ first_env text;
40
+ refusal text := '';
41
+ primary_env text;
42
+ primary_rows bigint := -1;
43
+ bookkeeping text[] := ARRAY[
44
+ 'environments', 'object_types', 'fields', 'member_env_roles', 'member_env_membership',
45
+ 'workspace_installed_apps', 'custom_table_owners', 'extension_migrations', 'scheduler_state',
46
+ 'paused_jobs', 'job_queue', 'cron_jobs', 'delivery_artifacts', 'search_documents',
47
+ 'knowledge_chunks', 'knowledge_meta', 'porter_transfer_codes', 'porter_transfer_tokens',
48
+ 'auth_sessions', 'playground_collections', 'playground_variables', 'saved_views', 'ai_chats',
49
+ 'audit_log', 'workspace_activity', 'job_runs', 'schema_pushes', 'migration_runs',
50
+ 'release_history', 'webhook_deliveries', 'search_query_log', 'knowledge_queries', 'promotion_log'
51
+ ];
52
+ history text[] := ARRAY[
53
+ 'audit_log', 'workspace_activity', 'job_runs', 'schema_pushes', 'migration_runs',
54
+ 'release_history', 'webhook_deliveries', 'search_query_log', 'knowledge_queries'
55
+ ];
56
+ BEGIN
57
+ -- The decision, per workspace, survives into step 3 (same transaction).
58
+ CREATE TEMP TABLE tangen_kept (workspace_id text, env_id text) ON COMMIT DROP;
59
+
60
+ SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = 'environments') INTO has_env;
61
+
62
+ -- Every (workspace, environment) pair that exists: the environments rows,
63
+ -- plus the pairs the data itself names for a workspace the table never
64
+ -- registered (its rows would otherwise survive as several environments in
65
+ -- one workspace and collide once env_id is gone).
66
+ CREATE TEMP TABLE tangen_env_scope (workspace_id text, env_id text, slug text, role text) ON COMMIT DROP;
67
+ IF has_env THEN
68
+ INSERT INTO tangen_env_scope SELECT workspace_id, id, slug, role FROM environments;
69
+ END IF;
70
+ FOR t IN
71
+ SELECT c.table_name FROM information_schema.columns c
72
+ WHERE c.table_schema = current_schema() AND c.column_name = 'env_id' AND c.table_name <> 'environments'
73
+ AND EXISTS (SELECT 1 FROM information_schema.columns w WHERE w.table_schema = c.table_schema AND w.table_name = c.table_name AND w.column_name = 'workspace_id')
74
+ LOOP
75
+ EXECUTE format(
76
+ 'INSERT INTO tangen_env_scope SELECT DISTINCT r.workspace_id, r.env_id, regexp_replace(r.env_id, ''^env_'', ''''), NULL FROM %I r ' ||
77
+ 'WHERE r.env_id IS NOT NULL AND r.workspace_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM tangen_env_scope s WHERE s.workspace_id = r.workspace_id AND s.env_id = r.env_id)',
78
+ t.table_name);
79
+ END LOOP;
80
+
81
+ IF NOT EXISTS (SELECT 1 FROM tangen_env_scope) THEN
82
+ RAISE NOTICE 'tangen: no environment rows anywhere; dropping env_id columns without selecting rows';
83
+ RETURN;
84
+ END IF;
85
+
86
+ FOR ws IN SELECT DISTINCT workspace_id FROM tangen_env_scope ORDER BY workspace_id LOOP
87
+ counts := '{}'::jsonb;
88
+ data_total := 0;
89
+ with_data := 0;
90
+ prod := NULL;
91
+ first_env := NULL;
92
+ kept := NULL;
93
+
94
+ FOR env IN SELECT env_id AS id, slug, role FROM tangen_env_scope WHERE workspace_id = ws.workspace_id ORDER BY slug LOOP
95
+ n := 0;
96
+ FOR t IN
97
+ SELECT c.table_name
98
+ FROM information_schema.columns c
99
+ WHERE c.table_schema = current_schema() AND c.column_name = 'env_id'
100
+ AND c.table_name <> ALL (bookkeeping)
101
+ AND EXISTS (SELECT 1 FROM information_schema.columns w WHERE w.table_schema = c.table_schema AND w.table_name = c.table_name AND w.column_name = 'workspace_id')
102
+ LOOP
103
+ EXECUTE format('SELECT count(*) FROM %I WHERE env_id = $1 AND workspace_id = $2', t.table_name) INTO n USING env.id, ws.workspace_id;
104
+ counts := counts || jsonb_build_object(env.slug, coalesce((counts ->> env.slug)::bigint, 0) + n);
105
+ END LOOP;
106
+ IF coalesce((counts ->> env.slug)::bigint, 0) > 0 THEN with_data := with_data + 1; END IF;
107
+ data_total := data_total + coalesce((counts ->> env.slug)::bigint, 0);
108
+ IF env.role = 'production' THEN prod := env.id; END IF;
109
+ IF first_env IS NULL THEN first_env := env.id; END IF;
110
+ END LOOP;
111
+
112
+ -- TANGEN_KEEP_ENVIRONMENT decides where it names one of the workspace's
113
+ -- environments; a workspace it does not fit is decided by its data like
114
+ -- any other (one environment with rows, else the production one), and
115
+ -- refused only when that is ambiguous too.
116
+ IF keep_slug IS NOT NULL THEN
117
+ SELECT env_id INTO kept FROM tangen_env_scope WHERE workspace_id = ws.workspace_id AND (slug = keep_slug OR env_id = keep_slug) LIMIT 1;
118
+ END IF;
119
+ IF kept IS NOT NULL THEN
120
+ NULL;
121
+ ELSIF with_data = 1 THEN
122
+ SELECT e.env_id INTO kept FROM tangen_env_scope e WHERE e.workspace_id = ws.workspace_id AND coalesce((counts ->> e.slug)::bigint, 0) > 0 LIMIT 1;
123
+ ELSIF with_data = 0 THEN
124
+ kept := coalesce(prod, first_env);
125
+ ELSE
126
+ refusal := refusal || format(E'\n workspace %s holds data in %s environments: %s. Set TANGEN_KEEP_ENVIRONMENT=<slug> to the one to keep (it has: %s); the others are deleted.',
127
+ ws.workspace_id, with_data,
128
+ (SELECT string_agg(format('%s (%s rows)', key, value), ', ' ORDER BY key) FROM jsonb_each_text(counts)),
129
+ (SELECT string_agg(slug, ', ' ORDER BY slug) FROM tangen_env_scope WHERE workspace_id = ws.workspace_id));
130
+ CONTINUE;
131
+ END IF;
132
+ INSERT INTO tangen_kept VALUES (ws.workspace_id, kept);
133
+ RAISE NOTICE 'tangen: workspace % keeps environment % (% data rows)', ws.workspace_id, kept, data_total;
134
+ -- Tables without a workspace column (the schema registry: object_types,
135
+ -- fields) hold ONE environment's rows afterwards: the named one, else the
136
+ -- one kept by the workspace with the most data.
137
+ IF keep_slug IS NOT NULL AND (kept = keep_slug OR kept = 'env_' || keep_slug) THEN
138
+ primary_env := kept; primary_rows := 9223372036854775807;
139
+ ELSIF data_total > primary_rows THEN
140
+ primary_env := kept; primary_rows := data_total;
141
+ END IF;
142
+ END LOOP;
143
+
144
+ IF refusal <> '' THEN
145
+ RAISE EXCEPTION E'Environment removal refused - the environment to keep is ambiguous:%\nNothing was changed. Run the migration again with TANGEN_KEEP_ENVIRONMENT set (the API boot, or db:migrate in packages/db).', refusal
146
+ USING ERRCODE = 'P0001';
147
+ END IF;
148
+
149
+ INSERT INTO tangen_kept VALUES ('*', primary_env);
150
+ RAISE NOTICE 'tangen: the schema registry and every other workspace-less table keep environment %', primary_env;
151
+
152
+ -- Delete the rows of every other environment: workspace-scoped tables by
153
+ -- their workspace's decision, workspace-less tables by the primary one.
154
+ -- A row with no environment (env_id NULL) predates the model and stays.
155
+ -- Children before parents: a table whose rows reference another
156
+ -- environment-scoped table (fields -> object_types) empties first, so no
157
+ -- foreign key is violated half-way.
158
+ FOR t IN
159
+ WITH RECURSIVE scoped AS (
160
+ SELECT c.table_name::text AS table_name,
161
+ EXISTS (SELECT 1 FROM information_schema.columns w WHERE w.table_schema = c.table_schema AND w.table_name = c.table_name AND w.column_name = 'workspace_id') AS has_ws
162
+ FROM information_schema.columns c
163
+ WHERE c.table_schema = current_schema() AND c.column_name = 'env_id'
164
+ AND c.table_name <> ALL (history) AND c.table_name <> 'environments'
165
+ ), refs AS (
166
+ SELECT con.conrelid::regclass::text AS child, con.confrelid::regclass::text AS parent
167
+ FROM pg_constraint con WHERE con.contype = 'f' AND con.conrelid <> con.confrelid
168
+ ), depth AS (
169
+ SELECT table_name::text AS table_name, 0 AS d FROM scoped
170
+ UNION ALL
171
+ SELECT r.child::text, depth.d + 1 FROM depth JOIN refs r ON r.parent = depth.table_name JOIN scoped s ON s.table_name = r.child WHERE depth.d < 8
172
+ )
173
+ SELECT s.table_name, s.has_ws FROM scoped s
174
+ ORDER BY (SELECT max(d) FROM depth WHERE depth.table_name = s.table_name) DESC, s.table_name
175
+ LOOP
176
+ IF t.table_name = 'paused_jobs' THEN
177
+ -- '*' pauses every environment; keep it unless the kept environment has its own row.
178
+ EXECUTE 'DELETE FROM paused_jobs p WHERE p.env_id IS NOT NULL AND p.env_id <> ''*'' AND NOT EXISTS (SELECT 1 FROM tangen_kept k WHERE k.workspace_id = p.workspace_id AND k.env_id = p.env_id)';
179
+ EXECUTE 'DELETE FROM paused_jobs p WHERE p.env_id = ''*'' AND EXISTS (SELECT 1 FROM paused_jobs q WHERE q.workspace_id = p.workspace_id AND q.job_id = p.job_id AND q.env_id <> ''*'')';
180
+ ELSIF t.has_ws THEN
181
+ -- A child table that carries env_id but no workspace_id (fields ->
182
+ -- object_types) cannot be scoped by workspace itself: its rows go
183
+ -- with the parent rows they reference.
184
+ FOR k IN
185
+ SELECT con.conrelid::regclass::text AS child, a.attname AS fkcol, pa.attname AS pkcol
186
+ FROM pg_constraint con
187
+ JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = con.conkey[1]
188
+ JOIN pg_attribute pa ON pa.attrelid = con.confrelid AND pa.attnum = con.confkey[1]
189
+ WHERE con.contype = 'f' AND con.confrelid = quote_ident(t.table_name)::regclass AND con.conrelid <> con.confrelid
190
+ AND array_length(con.conkey, 1) = 1
191
+ AND EXISTS (SELECT 1 FROM information_schema.columns e WHERE e.table_schema = current_schema() AND e.table_name = con.conrelid::regclass::text AND e.column_name = 'env_id')
192
+ LOOP
193
+ EXECUTE format('DELETE FROM %I c USING %I p WHERE p.%I = c.%I AND p.env_id IS NOT NULL AND EXISTS (SELECT 1 FROM tangen_kept k WHERE k.workspace_id = p.workspace_id) AND NOT EXISTS (SELECT 1 FROM tangen_kept k WHERE k.workspace_id = p.workspace_id AND k.env_id = p.env_id)',
194
+ k.child, t.table_name, k.pkcol, k.fkcol);
195
+ END LOOP;
196
+ EXECUTE format('DELETE FROM %I r WHERE r.env_id IS NOT NULL AND EXISTS (SELECT 1 FROM tangen_kept k WHERE k.workspace_id = r.workspace_id) AND NOT EXISTS (SELECT 1 FROM tangen_kept k WHERE k.workspace_id = r.workspace_id AND k.env_id = r.env_id)', t.table_name);
197
+ ELSE
198
+ FOR k IN
199
+ SELECT con.conrelid::regclass::text AS child, a.attname AS fkcol, pa.attname AS pkcol
200
+ FROM pg_constraint con
201
+ JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = con.conkey[1]
202
+ JOIN pg_attribute pa ON pa.attrelid = con.confrelid AND pa.attnum = con.confkey[1]
203
+ WHERE con.contype = 'f' AND con.confrelid = quote_ident(t.table_name)::regclass AND con.conrelid <> con.confrelid
204
+ AND array_length(con.conkey, 1) = 1
205
+ AND EXISTS (SELECT 1 FROM information_schema.columns e WHERE e.table_schema = current_schema() AND e.table_name = con.conrelid::regclass::text AND e.column_name = 'env_id')
206
+ LOOP
207
+ EXECUTE format('DELETE FROM %I c USING %I p WHERE p.%I = c.%I AND p.env_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM tangen_kept k WHERE k.workspace_id = ''*'' AND k.env_id = p.env_id)',
208
+ k.child, t.table_name, k.pkcol, k.fkcol);
209
+ END LOOP;
210
+ EXECUTE format('DELETE FROM %I r WHERE r.env_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM tangen_kept k WHERE k.workspace_id = ''*'' AND k.env_id = r.env_id)', t.table_name);
211
+ END IF;
212
+ END LOOP;
213
+ END
214
+ $tangen$;
215
+
216
+ -- ── 2. Drop the environment tables and the promotion bookkeeping ────────────
217
+ DROP TABLE IF EXISTS "member_env_roles" CASCADE;
218
+ DROP TABLE IF EXISTS "member_env_membership" CASCADE;
219
+ DROP TABLE IF EXISTS "promotion_log" CASCADE;
220
+ DROP TABLE IF EXISTS "environments" CASCADE;
221
+
222
+ -- ── 3. Drop every env_id column, re-keying what it was part of ──────────────
223
+ DO $tangen$
224
+ DECLARE
225
+ t record;
226
+ k record;
227
+ pk record;
228
+ cols text;
229
+ def text;
230
+ newname text;
231
+ BEGIN
232
+ IF to_regclass('pg_temp.tangen_kept') IS NULL THEN
233
+ CREATE TEMP TABLE tangen_kept (workspace_id text, env_id text) ON COMMIT DROP;
234
+ END IF;
235
+ FOR t IN
236
+ SELECT c.table_name FROM information_schema.columns c
237
+ WHERE c.table_schema = current_schema() AND c.column_name = 'env_id'
238
+ ORDER BY c.table_name
239
+ LOOP
240
+ -- Rows that differ only by env_id would collide on a unique key once the
241
+ -- column is gone: a workspace the environments table never registered, a
242
+ -- pre-model row with no environment beside an adopted one. Keep one per
243
+ -- key - the kept environment's row, else the one with no environment,
244
+ -- else the first by env_id - and say how many went.
245
+ FOR k IN
246
+ SELECT string_agg(quote_ident(a.attname), ', ' ORDER BY a.attnum) AS rest, con.conname AS name
247
+ FROM pg_constraint con JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = ANY (con.conkey)
248
+ WHERE con.conrelid = quote_ident(t.table_name)::regclass AND con.contype IN ('p', 'u')
249
+ AND EXISTS (SELECT 1 FROM pg_attribute e WHERE e.attrelid = con.conrelid AND e.attnum = ANY (con.conkey) AND e.attname = 'env_id')
250
+ AND a.attname <> 'env_id'
251
+ GROUP BY con.conname
252
+ UNION ALL
253
+ SELECT string_agg(quote_ident(a.attname), ', ' ORDER BY a.attnum), i.indexrelid::regclass::text
254
+ FROM pg_index i JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY (i.indkey)
255
+ WHERE i.indrelid = quote_ident(t.table_name)::regclass AND i.indisunique AND NOT i.indisprimary AND i.indpred IS NULL
256
+ AND NOT EXISTS (SELECT 1 FROM pg_constraint c WHERE c.conindid = i.indexrelid)
257
+ AND EXISTS (SELECT 1 FROM pg_attribute e WHERE e.attrelid = i.indrelid AND e.attnum = ANY (i.indkey) AND e.attname = 'env_id')
258
+ AND a.attname <> 'env_id'
259
+ GROUP BY i.indexrelid
260
+ LOOP
261
+ IF k.rest IS NULL THEN CONTINUE; END IF;
262
+ DROP TABLE IF EXISTS tangen_dupes;
263
+ EXECUTE format(
264
+ 'CREATE TEMP TABLE tangen_dupes ON COMMIT DROP AS WITH ranked AS (SELECT ctid AS row_ctid, row_number() OVER (PARTITION BY %s ORDER BY (EXISTS (SELECT 1 FROM tangen_kept kk WHERE kk.env_id = r.env_id)) DESC, (r.env_id IS NULL) DESC, r.env_id) AS rn FROM %I r WHERE %s) SELECT row_ctid FROM ranked WHERE rn > 1',
265
+ k.rest, t.table_name, (SELECT string_agg(x || ' IS NOT NULL', ' AND ') FROM unnest(string_to_array(k.rest, ', ')) AS x));
266
+ -- Rows another table references follow their parent out.
267
+ FOR pk IN
268
+ SELECT con.conrelid::regclass::text AS child, a.attname AS fkcol, pa.attname AS pkcol
269
+ FROM pg_constraint con
270
+ JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = con.conkey[1]
271
+ JOIN pg_attribute pa ON pa.attrelid = con.confrelid AND pa.attnum = con.confkey[1]
272
+ WHERE con.contype = 'f' AND con.confrelid = quote_ident(t.table_name)::regclass AND con.conrelid <> con.confrelid AND array_length(con.conkey, 1) = 1
273
+ LOOP
274
+ EXECUTE format('DELETE FROM %I c USING %I p, tangen_dupes x WHERE p.ctid = x.row_ctid AND p.%I = c.%I', pk.child, t.table_name, pk.pkcol, pk.fkcol);
275
+ END LOOP;
276
+ EXECUTE format('DELETE FROM %I d USING tangen_dupes x WHERE d.ctid = x.row_ctid', t.table_name);
277
+ GET DIAGNOSTICS cols = ROW_COUNT;
278
+ IF cols::bigint > 0 THEN
279
+ RAISE NOTICE 'tangen: % rows of % differed only by environment on key % and are gone', cols, t.table_name, k.name;
280
+ END IF;
281
+ END LOOP;
282
+
283
+ -- Primary keys and unique constraints that include env_id: remember them without it.
284
+ FOR k IN
285
+ SELECT conname, contype,
286
+ (SELECT string_agg(quote_ident(a.attname), ', ' ORDER BY array_position(con.conkey, a.attnum))
287
+ FROM pg_attribute a WHERE a.attrelid = con.conrelid AND a.attnum = ANY (con.conkey) AND a.attname <> 'env_id') AS rest
288
+ FROM pg_constraint con
289
+ WHERE con.conrelid = quote_ident(t.table_name)::regclass AND con.contype IN ('p', 'u')
290
+ AND EXISTS (SELECT 1 FROM pg_attribute a WHERE a.attrelid = con.conrelid AND a.attnum = ANY (con.conkey) AND a.attname = 'env_id')
291
+ LOOP
292
+ EXECUTE format('ALTER TABLE %I DROP CONSTRAINT %I', t.table_name, k.conname);
293
+ IF k.rest IS NOT NULL THEN
294
+ newname := regexp_replace(regexp_replace(k.conname, '_env_id_', '_'), '_env_', '_');
295
+ IF k.contype = 'p' THEN
296
+ EXECUTE format('ALTER TABLE %I ADD PRIMARY KEY (%s)', t.table_name, k.rest);
297
+ ELSE
298
+ EXECUTE format('ALTER TABLE %I ADD CONSTRAINT %I UNIQUE (%s)', t.table_name, newname, k.rest);
299
+ END IF;
300
+ END IF;
301
+ END LOOP;
302
+
303
+ -- Indexes that include env_id: recreate without it, under the environment-free name.
304
+ FOR k IN
305
+ SELECT indexname, indexdef FROM pg_indexes
306
+ WHERE schemaname = current_schema() AND tablename = t.table_name
307
+ AND indexdef ~ '[(, ]env_id[,) ]'
308
+ AND indexname NOT IN (SELECT conname FROM pg_constraint)
309
+ LOOP
310
+ EXECUTE format('DROP INDEX IF EXISTS %I', k.indexname);
311
+ def := regexp_replace(k.indexdef, 'env_id,\s*', '');
312
+ def := regexp_replace(def, ',\s*env_id(?=[,)])', '');
313
+ newname := regexp_replace(regexp_replace(regexp_replace(k.indexname, '_workspace_env_', '_workspace_'), '_ws_env_', '_ws_'), '_env_', '_');
314
+ newname := regexp_replace(newname, '_env$', '');
315
+ def := replace(def, 'INDEX ' || quote_ident(k.indexname) || ' ON', 'INDEX ' || quote_ident(newname) || ' ON');
316
+ def := replace(def, 'INDEX ' || k.indexname || ' ON', 'INDEX ' || quote_ident(newname) || ' ON');
317
+ IF EXISTS (SELECT 1 FROM pg_indexes WHERE schemaname = current_schema() AND indexname = newname) THEN
318
+ -- An environment-free index of that name already exists (a later
319
+ -- migration or a heal created it beside the environment-keyed one):
320
+ -- it is the one that stays, and the environment-keyed one is gone.
321
+ RAISE NOTICE 'tangen: index % already exists; % dropped', newname, k.indexname;
322
+ ELSIF def !~ '\(\s*\)' AND def !~ 'USING \w+ \(\s*\)' THEN
323
+ EXECUTE replace(replace(def, 'CREATE UNIQUE INDEX', 'CREATE UNIQUE INDEX IF NOT EXISTS'), 'CREATE INDEX', 'CREATE INDEX IF NOT EXISTS');
324
+ END IF;
325
+ END LOOP;
326
+
327
+ EXECUTE format('ALTER TABLE %I DROP COLUMN env_id', t.table_name);
328
+ END LOOP;
329
+ END
330
+ $tangen$;
331
+
332
+ -- ── 4. Releases change in place; runs and history keep their rows ───────────
333
+ DO $tangen$
334
+ DECLARE
335
+ pair record;
336
+ BEGIN
337
+ FOR pair IN
338
+ SELECT * FROM (VALUES
339
+ ('releases', 'source_env_id'), ('releases', 'target_env_id'),
340
+ ('release_history', 'source_env_id'), ('release_history', 'target_env_id'),
341
+ ('release_history', 'source_role'), ('release_history', 'target_role'),
342
+ ('migration_runs', 'source_env_id'), ('migration_runs', 'target_env_id')
343
+ ) AS v(table_name, column_name)
344
+ LOOP
345
+ IF EXISTS (SELECT 1 FROM information_schema.columns c WHERE c.table_schema = current_schema() AND c.table_name = pair.table_name AND c.column_name = pair.column_name) THEN
346
+ EXECUTE format('ALTER TABLE %I DROP COLUMN %I', pair.table_name, pair.column_name);
347
+ END IF;
348
+ END LOOP;
349
+ END
350
+ $tangen$;
@@ -0,0 +1,9 @@
1
+ -- Releases publish in place (tasks/remove-environments-programme.md, R4):
2
+ -- who created and who applied a release, and the review state each item
3
+ -- carries, existed in the schema and on developer databases that drizzle-kit
4
+ -- pushed, but no migration had ever created them. The first fresh database
5
+ -- of the three-deployment proof (scripts/verify-deployments-pg.ts) found out.
6
+ ALTER TABLE "releases" ADD COLUMN IF NOT EXISTS "created_by" text;--> statement-breakpoint
7
+ ALTER TABLE "releases" ADD COLUMN IF NOT EXISTS "applied_by" text;--> statement-breakpoint
8
+ ALTER TABLE "release_items" ADD COLUMN IF NOT EXISTS "comparison_state" text;--> statement-breakpoint
9
+ ALTER TABLE "release_items" ADD COLUMN IF NOT EXISTS "resolved_values" jsonb;
@@ -0,0 +1,26 @@
1
+ -- The framework kernel's durable event record and attribution kinds
2
+ -- (tasks/plan-framework-kernel.md K5, §10, §18).
3
+ --
4
+ -- audit_log becomes the event record the domain operations write INSIDE
5
+ -- their transaction: who acted (actor kind and id, on behalf of whom), in
6
+ -- which execution (kind, id, correlation, causation), to which resource, in
7
+ -- which locale, through which transport. The columns are additive; rows the
8
+ -- bus subscriber wrote before this migration keep their shape.
9
+ ALTER TABLE "audit_log" ADD COLUMN IF NOT EXISTS "actor_kind" text;--> statement-breakpoint
10
+ ALTER TABLE "audit_log" ADD COLUMN IF NOT EXISTS "initiator_id" text;--> statement-breakpoint
11
+ ALTER TABLE "audit_log" ADD COLUMN IF NOT EXISTS "initiator_kind" text;--> statement-breakpoint
12
+ ALTER TABLE "audit_log" ADD COLUMN IF NOT EXISTS "execution_kind" text;--> statement-breakpoint
13
+ ALTER TABLE "audit_log" ADD COLUMN IF NOT EXISTS "execution_id" text;--> statement-breakpoint
14
+ ALTER TABLE "audit_log" ADD COLUMN IF NOT EXISTS "correlation_id" text;--> statement-breakpoint
15
+ ALTER TABLE "audit_log" ADD COLUMN IF NOT EXISTS "causation_id" text;--> statement-breakpoint
16
+ ALTER TABLE "audit_log" ADD COLUMN IF NOT EXISTS "resource_kind" text;--> statement-breakpoint
17
+ ALTER TABLE "audit_log" ADD COLUMN IF NOT EXISTS "resource_type" text;--> statement-breakpoint
18
+ ALTER TABLE "audit_log" ADD COLUMN IF NOT EXISTS "resource_id" text;--> statement-breakpoint
19
+ ALTER TABLE "audit_log" ADD COLUMN IF NOT EXISTS "locale" text;--> statement-breakpoint
20
+ ALTER TABLE "audit_log" ADD COLUMN IF NOT EXISTS "source" text;--> statement-breakpoint
21
+ ALTER TABLE "audit_log" ADD COLUMN IF NOT EXISTS "recorded_in_transaction" boolean NOT NULL DEFAULT false;--> statement-breakpoint
22
+ CREATE INDEX IF NOT EXISTS "audit_log_resource" ON "audit_log" ("workspace_id", "resource_id", "created_at");--> statement-breakpoint
23
+ CREATE INDEX IF NOT EXISTS "audit_log_correlation" ON "audit_log" ("correlation_id");--> statement-breakpoint
24
+ -- A revision knows which locale it captured (null: the base row), and the
25
+ -- published revision carries the translations map the snapshot froze.
26
+ ALTER TABLE "_revisions" ADD COLUMN IF NOT EXISTS "locale" text;
@@ -0,0 +1,13 @@
1
+ -- Jobs and flows run as someone (tasks/plan-framework-kernel.md K6, §12):
2
+ -- a queued job carries who enqueued it and whose authority it runs with;
3
+ -- a job run records the actor it ran as, the initiator, its execution id and
4
+ -- its correlation, and the attempt. Additive.
5
+ ALTER TABLE "job_queue" ADD COLUMN IF NOT EXISTS "execution" jsonb;--> statement-breakpoint
6
+ ALTER TABLE "job_runs" ADD COLUMN IF NOT EXISTS "actor_kind" text;--> statement-breakpoint
7
+ ALTER TABLE "job_runs" ADD COLUMN IF NOT EXISTS "actor_id" text;--> statement-breakpoint
8
+ ALTER TABLE "job_runs" ADD COLUMN IF NOT EXISTS "initiator_kind" text;--> statement-breakpoint
9
+ ALTER TABLE "job_runs" ADD COLUMN IF NOT EXISTS "initiator_id" text;--> statement-breakpoint
10
+ ALTER TABLE "job_runs" ADD COLUMN IF NOT EXISTS "execution_id" text;--> statement-breakpoint
11
+ ALTER TABLE "job_runs" ADD COLUMN IF NOT EXISTS "correlation_id" text;--> statement-breakpoint
12
+ ALTER TABLE "job_runs" ADD COLUMN IF NOT EXISTS "attempt" integer;--> statement-breakpoint
13
+ CREATE INDEX IF NOT EXISTS "job_runs_correlation" ON "job_runs" ("correlation_id");
@@ -0,0 +1,19 @@
1
+ -- Service and agent identities (tasks/plan-framework-kernel.md K8): a record
2
+ -- that holds authority (grants and capabilities) and owns API keys as its
3
+ -- credentials. A key without an identity keeps acting as itself. Additive.
4
+ CREATE TABLE IF NOT EXISTS "identities" (
5
+ "id" text PRIMARY KEY NOT NULL,
6
+ "workspace_id" text NOT NULL,
7
+ "kind" text NOT NULL,
8
+ "name" text NOT NULL,
9
+ "description" text,
10
+ "status" text DEFAULT 'active' NOT NULL,
11
+ "permissions" jsonb DEFAULT '{}' NOT NULL,
12
+ "capabilities" jsonb DEFAULT '[]' NOT NULL,
13
+ "owner_id" text,
14
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
15
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
16
+ );--> statement-breakpoint
17
+ CREATE INDEX IF NOT EXISTS "identities_workspace_idx" ON "identities" USING btree ("workspace_id");--> statement-breakpoint
18
+ ALTER TABLE "api_keys" ADD COLUMN IF NOT EXISTS "identity_id" text;--> statement-breakpoint
19
+ CREATE INDEX IF NOT EXISTS "api_keys_identity_idx" ON "api_keys" USING btree ("identity_id");
@@ -0,0 +1,7 @@
1
+ -- Forms leaves core (tasks/plan-core-slimming.md S3, 2026-09-13): the three
2
+ -- tables belong to @tangen/ext-forms, whose migrations create them where the
3
+ -- extension is installed. Nothing outside this repository holds form data yet,
4
+ -- so the core copies are dropped rather than migrated.
5
+ DROP TABLE IF EXISTS "form_submissions" CASCADE;
6
+ DROP TABLE IF EXISTS "form_app_settings" CASCADE;
7
+ DROP TABLE IF EXISTS "forms" CASCADE;
@@ -0,0 +1,8 @@
1
+ -- Site users leaves core (tasks/plan-core-slimming.md S4, 2026-09-13): the
2
+ -- four auth tables belong to @tangen/ext-auth, whose migrations create them
3
+ -- where the extension is installed. Nothing outside this repository holds
4
+ -- site user data yet, so the core copies are dropped rather than migrated.
5
+ DROP TABLE IF EXISTS "auth_identities" CASCADE;
6
+ DROP TABLE IF EXISTS "auth_sso_accounts" CASCADE;
7
+ DROP TABLE IF EXISTS "auth_sessions" CASCADE;
8
+ DROP TABLE IF EXISTS "auth_users" CASCADE;
@@ -0,0 +1,11 @@
1
+ CREATE TABLE IF NOT EXISTS "member_grants" (
2
+ "workspace_id" text NOT NULL,
3
+ "member_id" text NOT NULL,
4
+ "capabilities" jsonb DEFAULT '[]'::jsonb NOT NULL,
5
+ "extensions" jsonb DEFAULT '[]'::jsonb NOT NULL,
6
+ "granted_by" text,
7
+ "reason" text,
8
+ "expires_at" timestamp with time zone,
9
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL,
10
+ CONSTRAINT "member_grants_workspace_id_member_id_pk" PRIMARY KEY("workspace_id","member_id")
11
+ );
@@ -0,0 +1,7 @@
1
+ -- Extension Kernel K5.2a: the grants an operator approved for an installation.
2
+ -- NULL = not reviewed (an installation that predates review): the runtime
3
+ -- binds to the manifest's required grants, as before. A JSON array = the
4
+ -- approval; the runtime binds to it intersected with what the current
5
+ -- manifest declares, so a new required grant waits for re-review and a
6
+ -- grant the manifest dropped is revoked without touching the record.
7
+ ALTER TABLE "workspace_installed_apps" ADD COLUMN IF NOT EXISTS "granted_grants" jsonb;
@@ -0,0 +1,21 @@
1
+ -- Extension Kernel K6.1: an operator's credentials for a connector, per
2
+ -- workspace, sealed at rest (AES-256-GCM under the deployment's connections
3
+ -- key). `secret` is the envelope, never plaintext; `connector_id` is
4
+ -- `ext:<extension>/<connector>`.
5
+ CREATE TABLE IF NOT EXISTS "connections" (
6
+ "id" text PRIMARY KEY NOT NULL,
7
+ "workspace_id" text NOT NULL,
8
+ "connector_id" text NOT NULL,
9
+ "label" text NOT NULL,
10
+ "kind" text NOT NULL,
11
+ "secret" text NOT NULL,
12
+ "meta" jsonb DEFAULT '{}'::jsonb NOT NULL,
13
+ "status" text DEFAULT 'active' NOT NULL,
14
+ "health" jsonb,
15
+ "created_by" text,
16
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
17
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL,
18
+ "last_checked_at" timestamp with time zone
19
+ );
20
+ --> statement-breakpoint
21
+ CREATE INDEX IF NOT EXISTS "connections_workspace_connector_idx" ON "connections" ("workspace_id", "connector_id");