supaschema 0.1.0-rc.1

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 (257) hide show
  1. package/.agents/skills/supaschema/SKILL.md +61 -0
  2. package/.claude/hooks/block-generated-migration-edits.mjs +32 -0
  3. package/.claude/rules/supaschema.md +22 -0
  4. package/.claude/settings.json +16 -0
  5. package/.claude/skills/supaschema/SKILL.md +61 -0
  6. package/.codex/hooks/supaschema-tool-gate.mjs +73 -0
  7. package/.codex/hooks.json +16 -0
  8. package/.codex/rules/supaschema.rules +22 -0
  9. package/AGENTS.md +40 -0
  10. package/LICENSE +661 -0
  11. package/LICENSE-COMMERCIAL.md +35 -0
  12. package/README.md +249 -0
  13. package/benchmarks/README.md +104 -0
  14. package/benchmarks/compare.js +489 -0
  15. package/benchmarks/fixtures/additive/from.sql +8 -0
  16. package/benchmarks/fixtures/additive/manifest.json +1 -0
  17. package/benchmarks/fixtures/additive/to.sql +9 -0
  18. package/benchmarks/fixtures/functions-policies/from.sql +24 -0
  19. package/benchmarks/fixtures/functions-policies/manifest.json +5 -0
  20. package/benchmarks/fixtures/functions-policies/to.sql +24 -0
  21. package/benchmarks/plot-lib.js +234 -0
  22. package/benchmarks/plot-svg.js +339 -0
  23. package/benchmarks/plot.js +154 -0
  24. package/benchmarks/tools/bench-all.sh +49 -0
  25. package/benchmarks/tools/build-project-fixture.mjs +245 -0
  26. package/benchmarks/tools/compare-db.mjs +101 -0
  27. package/benchmarks/tools/compare-fixtures.mjs +84 -0
  28. package/benchmarks/tools/compare-report.mjs +90 -0
  29. package/benchmarks/tools/compare-supabase.mjs +67 -0
  30. package/benchmarks/tools/registry.js +266 -0
  31. package/benchmarks/tools/run-workflow.mjs +77 -0
  32. package/bin/postinstall.mjs +26 -0
  33. package/bin/supaschema +2 -0
  34. package/config-schema.json +208 -0
  35. package/corpus/supabase-style/corpus.json +6 -0
  36. package/corpus/supabase-style/migrations/20260101000000_init.sql +28 -0
  37. package/corpus/supabase-style/migrations/20260102000000_noise.sql +13 -0
  38. package/corpus/supabase-style/migrations/20260103000000_churn.sql +4 -0
  39. package/corpus/supabase-style/migrations/20260104000000_triggers.sql +17 -0
  40. package/corpus/supabase-style/roles.sql +13 -0
  41. package/corpus/supabase-style/tree/functions.sql +26 -0
  42. package/corpus/supabase-style/tree/policies.sql +4 -0
  43. package/corpus/supabase-style/tree/schema.sql +4 -0
  44. package/corpus/supabase-style/tree/tables.sql +20 -0
  45. package/corpus/supabase-style/tree/triggers.sql +3 -0
  46. package/corpus/supabase-style/tree/types.sql +2 -0
  47. package/corpus/supabase-style/tree/views.sql +6 -0
  48. package/dist/audit.d.ts +20 -0
  49. package/dist/audit.d.ts.map +1 -0
  50. package/dist/audit.js +68 -0
  51. package/dist/benchmark-db.d.ts +5 -0
  52. package/dist/benchmark-db.d.ts.map +1 -0
  53. package/dist/benchmark-db.js +71 -0
  54. package/dist/benchmark-fixtures.d.ts +10 -0
  55. package/dist/benchmark-fixtures.d.ts.map +1 -0
  56. package/dist/benchmark-fixtures.js +201 -0
  57. package/dist/benchmark.d.ts +2 -0
  58. package/dist/benchmark.d.ts.map +1 -0
  59. package/dist/benchmark.js +308 -0
  60. package/dist/catalog-comments.d.ts +9 -0
  61. package/dist/catalog-comments.d.ts.map +1 -0
  62. package/dist/catalog-comments.js +194 -0
  63. package/dist/catalog-extras.d.ts +12 -0
  64. package/dist/catalog-extras.d.ts.map +1 -0
  65. package/dist/catalog-extras.js +408 -0
  66. package/dist/catalog-foreign.d.ts +15 -0
  67. package/dist/catalog-foreign.d.ts.map +1 -0
  68. package/dist/catalog-foreign.js +114 -0
  69. package/dist/catalog-tables.d.ts +9 -0
  70. package/dist/catalog-tables.d.ts.map +1 -0
  71. package/dist/catalog-tables.js +114 -0
  72. package/dist/catalog.d.ts +8 -0
  73. package/dist/catalog.d.ts.map +1 -0
  74. package/dist/catalog.js +351 -0
  75. package/dist/check-hazards.d.ts +7 -0
  76. package/dist/check-hazards.d.ts.map +1 -0
  77. package/dist/check-hazards.js +83 -0
  78. package/dist/check-reporters.d.ts +8 -0
  79. package/dist/check-reporters.d.ts.map +1 -0
  80. package/dist/check-reporters.js +76 -0
  81. package/dist/check.d.ts +3 -0
  82. package/dist/check.d.ts.map +1 -0
  83. package/dist/check.js +229 -0
  84. package/dist/cli-defaults.d.ts +24 -0
  85. package/dist/cli-defaults.d.ts.map +1 -0
  86. package/dist/cli-defaults.js +65 -0
  87. package/dist/cli-diff.d.ts +13 -0
  88. package/dist/cli-diff.d.ts.map +1 -0
  89. package/dist/cli-diff.js +348 -0
  90. package/dist/cli-reports.d.ts +9 -0
  91. package/dist/cli-reports.d.ts.map +1 -0
  92. package/dist/cli-reports.js +90 -0
  93. package/dist/cli-tools.d.ts +17 -0
  94. package/dist/cli-tools.d.ts.map +1 -0
  95. package/dist/cli-tools.js +136 -0
  96. package/dist/cli.d.ts +2 -0
  97. package/dist/cli.d.ts.map +1 -0
  98. package/dist/cli.js +239 -0
  99. package/dist/config-schema-gen.d.ts +2 -0
  100. package/dist/config-schema-gen.d.ts.map +1 -0
  101. package/dist/config-schema-gen.js +11 -0
  102. package/dist/config.d.ts +58 -0
  103. package/dist/config.d.ts.map +1 -0
  104. package/dist/config.js +132 -0
  105. package/dist/core.d.ts +115 -0
  106. package/dist/core.d.ts.map +1 -0
  107. package/dist/core.js +1 -0
  108. package/dist/corpus.d.ts +26 -0
  109. package/dist/corpus.d.ts.map +1 -0
  110. package/dist/corpus.js +112 -0
  111. package/dist/database-url.d.ts +8 -0
  112. package/dist/database-url.d.ts.map +1 -0
  113. package/dist/database-url.js +74 -0
  114. package/dist/db-admin.d.ts +23 -0
  115. package/dist/db-admin.d.ts.map +1 -0
  116. package/dist/db-admin.js +147 -0
  117. package/dist/diagnostics.d.ts +16 -0
  118. package/dist/diagnostics.d.ts.map +1 -0
  119. package/dist/diagnostics.js +155 -0
  120. package/dist/diff-score.d.ts +12 -0
  121. package/dist/diff-score.d.ts.map +1 -0
  122. package/dist/diff-score.js +339 -0
  123. package/dist/doctor.d.ts +17 -0
  124. package/dist/doctor.d.ts.map +1 -0
  125. package/dist/doctor.js +110 -0
  126. package/dist/hash.d.ts +7 -0
  127. package/dist/hash.d.ts.map +1 -0
  128. package/dist/hash.js +34 -0
  129. package/dist/index.d.ts +24 -0
  130. package/dist/index.d.ts.map +1 -0
  131. package/dist/index.js +17 -0
  132. package/dist/lineage.d.ts +23 -0
  133. package/dist/lineage.d.ts.map +1 -0
  134. package/dist/lineage.js +61 -0
  135. package/dist/migrations-status.d.ts +35 -0
  136. package/dist/migrations-status.d.ts.map +1 -0
  137. package/dist/migrations-status.js +131 -0
  138. package/dist/plan-order.d.ts +4 -0
  139. package/dist/plan-order.d.ts.map +1 -0
  140. package/dist/plan-order.js +178 -0
  141. package/dist/planner-replace.d.ts +4 -0
  142. package/dist/planner-replace.d.ts.map +1 -0
  143. package/dist/planner-replace.js +76 -0
  144. package/dist/planner-table.d.ts +3 -0
  145. package/dist/planner-table.d.ts.map +1 -0
  146. package/dist/planner-table.js +165 -0
  147. package/dist/planner.d.ts +5 -0
  148. package/dist/planner.d.ts.map +1 -0
  149. package/dist/planner.js +385 -0
  150. package/dist/render-guards.d.ts +12 -0
  151. package/dist/render-guards.d.ts.map +1 -0
  152. package/dist/render-guards.js +159 -0
  153. package/dist/render.d.ts +7 -0
  154. package/dist/render.d.ts.map +1 -0
  155. package/dist/render.js +325 -0
  156. package/dist/selfcheck.d.ts +11 -0
  157. package/dist/selfcheck.d.ts.map +1 -0
  158. package/dist/selfcheck.js +43 -0
  159. package/dist/source-normalize.d.ts +14 -0
  160. package/dist/source-normalize.d.ts.map +1 -0
  161. package/dist/source-normalize.js +420 -0
  162. package/dist/source.d.ts +4 -0
  163. package/dist/source.d.ts.map +1 -0
  164. package/dist/source.js +233 -0
  165. package/dist/sql/ast.d.ts +42 -0
  166. package/dist/sql/ast.d.ts.map +1 -0
  167. package/dist/sql/ast.js +241 -0
  168. package/dist/sql/canonical-nodes.d.ts +5 -0
  169. package/dist/sql/canonical-nodes.d.ts.map +1 -0
  170. package/dist/sql/canonical-nodes.js +101 -0
  171. package/dist/sql/extract-helpers.d.ts +18 -0
  172. package/dist/sql/extract-helpers.d.ts.map +1 -0
  173. package/dist/sql/extract-helpers.js +127 -0
  174. package/dist/sql/extract.d.ts +13 -0
  175. package/dist/sql/extract.d.ts.map +1 -0
  176. package/dist/sql/extract.js +323 -0
  177. package/dist/sql/facts.d.ts +34 -0
  178. package/dist/sql/facts.d.ts.map +1 -0
  179. package/dist/sql/facts.js +392 -0
  180. package/dist/sql/identifiers.d.ts +13 -0
  181. package/dist/sql/identifiers.d.ts.map +1 -0
  182. package/dist/sql/identifiers.js +83 -0
  183. package/dist/sql/normalize-deparse.d.ts +25 -0
  184. package/dist/sql/normalize-deparse.d.ts.map +1 -0
  185. package/dist/sql/normalize-deparse.js +96 -0
  186. package/dist/sql/object-hash.d.ts +5 -0
  187. package/dist/sql/object-hash.d.ts.map +1 -0
  188. package/dist/sql/object-hash.js +24 -0
  189. package/dist/sql/parser.d.ts +8 -0
  190. package/dist/sql/parser.d.ts.map +1 -0
  191. package/dist/sql/parser.js +89 -0
  192. package/dist/sql/privileges.d.ts +33 -0
  193. package/dist/sql/privileges.d.ts.map +1 -0
  194. package/dist/sql/privileges.js +379 -0
  195. package/dist/sql/split.d.ts +3 -0
  196. package/dist/sql/split.d.ts.map +1 -0
  197. package/dist/sql/split.js +182 -0
  198. package/dist/sql/statements.d.ts +17 -0
  199. package/dist/sql/statements.d.ts.map +1 -0
  200. package/dist/sql/statements.js +284 -0
  201. package/dist/sql/table-constraints.d.ts +15 -0
  202. package/dist/sql/table-constraints.d.ts.map +1 -0
  203. package/dist/sql/table-constraints.js +304 -0
  204. package/dist/sql/table-shape.d.ts +38 -0
  205. package/dist/sql/table-shape.d.ts.map +1 -0
  206. package/dist/sql/table-shape.js +287 -0
  207. package/dist/sync.d.ts +27 -0
  208. package/dist/sync.d.ts.map +1 -0
  209. package/dist/sync.js +86 -0
  210. package/dist/typegen-model.d.ts +78 -0
  211. package/dist/typegen-model.d.ts.map +1 -0
  212. package/dist/typegen-model.js +338 -0
  213. package/dist/typegen-views.d.ts +7 -0
  214. package/dist/typegen-views.d.ts.map +1 -0
  215. package/dist/typegen-views.js +92 -0
  216. package/dist/typegen-zod.d.ts +3 -0
  217. package/dist/typegen-zod.d.ts.map +1 -0
  218. package/dist/typegen-zod.js +149 -0
  219. package/dist/typegen.d.ts +4 -0
  220. package/dist/typegen.d.ts.map +1 -0
  221. package/dist/typegen.js +184 -0
  222. package/dist/validators.d.ts +3 -0
  223. package/dist/validators.d.ts.map +1 -0
  224. package/dist/validators.js +104 -0
  225. package/dist/verify-environment.d.ts +5 -0
  226. package/dist/verify-environment.d.ts.map +1 -0
  227. package/dist/verify-environment.js +92 -0
  228. package/dist/verify.d.ts +3 -0
  229. package/dist/verify.d.ts.map +1 -0
  230. package/dist/verify.js +261 -0
  231. package/docs/benchmarks/additive-correctness.svg +86 -0
  232. package/docs/benchmarks/additive-latency.svg +60 -0
  233. package/docs/benchmarks/functions-policies-correctness.svg +86 -0
  234. package/docs/benchmarks/functions-policies-latency.svg +60 -0
  235. package/docs/benchmarks/realistic-correctness.svg +86 -0
  236. package/docs/benchmarks/realistic-latency.svg +60 -0
  237. package/docs/benchmarks/scaling-latency.svg +106 -0
  238. package/docs/benchmarks/workflow-latency.svg +98 -0
  239. package/docs/benchmarks/xl-correctness.svg +86 -0
  240. package/docs/benchmarks/xl-latency.svg +60 -0
  241. package/docs/benchmarks/xxl-correctness.svg +86 -0
  242. package/docs/benchmarks/xxl-latency.svg +66 -0
  243. package/docs/case-study-anilize.md +51 -0
  244. package/docs/ci-gate.md +44 -0
  245. package/docs/ci.md +68 -0
  246. package/docs/commands.md +35 -0
  247. package/docs/config.md +72 -0
  248. package/docs/corpus.md +33 -0
  249. package/docs/diagnostics.md +77 -0
  250. package/docs/hints.md +92 -0
  251. package/docs/release.md +19 -0
  252. package/docs/support-matrix.md +57 -0
  253. package/examples/postgres/schemas/001_app.sql +17 -0
  254. package/examples/supabase/schemas/001_app.sql +13 -0
  255. package/examples/supabase/schemas-next/001_app.sql +21 -0
  256. package/examples/supabase/supaschema.config.json +15 -0
  257. package/package.json +99 -0
@@ -0,0 +1,385 @@
1
+ import { resolveConfig } from "./config.js";
2
+ import { diagnostic } from "./diagnostics.js";
3
+ import { sha256, stableJson } from "./hash.js";
4
+ import { sortOperations } from "./plan-order.js";
5
+ import { isDestructiveAllowed, refineReplaceOperation } from "./planner-replace.js";
6
+ import { makeTableAlterOperation } from "./planner-table.js";
7
+ export function planSchemaDiff(from, to, options = {}) {
8
+ const config = resolveConfig(options.config);
9
+ const operations = [];
10
+ const diagnostics = [...from.diagnostics, ...to.diagnostics];
11
+ const fromMap = objectMap(from.objects);
12
+ const toMap = objectMap(to.objects);
13
+ const consumedFrom = new Set();
14
+ const consumedTo = new Set();
15
+ if (config.renameDetection === "hints-only") {
16
+ for (const hint of config.hints.renames ?? []) {
17
+ const before = fromMap.get(hint.from);
18
+ const after = toMap.get(hint.to);
19
+ if (!before || !after) {
20
+ diagnostics.push(diagnostic("SUPA_PLAN_RENAME_HINT_UNMATCHED", "error", "rename hint does not match both source and target objects", {
21
+ hint: `from=${hint.from} to=${hint.to}`,
22
+ }));
23
+ continue;
24
+ }
25
+ const operation = makeRenameOperation(before, after);
26
+ operations.push(operation);
27
+ consumedFrom.add(before.key);
28
+ consumedTo.add(after.key);
29
+ }
30
+ }
31
+ for (const [key, before] of fromMap) {
32
+ if (consumedFrom.has(key)) {
33
+ continue;
34
+ }
35
+ const after = toMap.get(key);
36
+ if (!after) {
37
+ operations.push(makeOperation("drop", key, before, undefined, config));
38
+ continue;
39
+ }
40
+ if (before.hash !== after.hash) {
41
+ operations.push(makeEnumAddValuesOperation(before, after) ??
42
+ makeTableAlterOperation(before, after, config) ??
43
+ refineReplaceOperation(makeOperation("replace", key, before, after, config), config));
44
+ }
45
+ }
46
+ for (const [key, after] of toMap) {
47
+ if (consumedTo.has(key)) {
48
+ continue;
49
+ }
50
+ if (!fromMap.has(key)) {
51
+ operations.push(makeOperation("create", key, undefined, after, config));
52
+ }
53
+ }
54
+ appendReplacedRelationDependents(operations, to, config);
55
+ const sortedOperations = sortOperations(operations, diagnostics);
56
+ for (const operation of operations) {
57
+ diagnostics.push(...operation.diagnostics);
58
+ }
59
+ if (sortedOperations.length === 0 && from.fingerprint !== to.fingerprint) {
60
+ diagnostics.push(emptyPlanDriftDiagnostic(fromMap, toMap, from, to));
61
+ }
62
+ return {
63
+ diagnostics,
64
+ fingerprint: sha256(stableJson({
65
+ from: from.fingerprint,
66
+ operations: sortedOperations.map((operation) => ({
67
+ key: operation.key,
68
+ kind: operation.kind,
69
+ })),
70
+ to: to.fingerprint,
71
+ })),
72
+ from: from.source,
73
+ fromFingerprint: from.fingerprint,
74
+ operations: sortedOperations,
75
+ to: to.source,
76
+ toFingerprint: to.fingerprint,
77
+ };
78
+ }
79
+ function objectMap(objects) {
80
+ return new Map(objects.map((object) => [object.key, object]));
81
+ }
82
+ const replacedRelationKinds = new Set(["table", "materialized-view"]);
83
+ const relationDependentKinds = new Set([
84
+ "constraint",
85
+ "index",
86
+ "rls",
87
+ "policy",
88
+ "trigger",
89
+ ]);
90
+ /**
91
+ * A relation replace renders DROP + CREATE, which destroys every dependent
92
+ * object in the target database even when that dependent is unchanged
93
+ * between the two models (equal hashes produce no operation). Re-create the
94
+ * to-state dependents alongside the replace so the rebuilt relation keeps
95
+ * its constraints, indexes, RLS state, policies, triggers, and grants.
96
+ */
97
+ function appendReplacedRelationDependents(operations, to, config) {
98
+ const replacedRelations = operations
99
+ .filter((operation) => operation.kind === "replace" && replacedRelationKinds.has(operation.ref.kind))
100
+ .map((operation) => operation.ref);
101
+ if (replacedRelations.length === 0) {
102
+ return;
103
+ }
104
+ const operationKeys = new Set(operations.map((operation) => operation.key));
105
+ for (const relation of replacedRelations) {
106
+ for (const object of to.objects) {
107
+ if (operationKeys.has(object.key) || !isRelationDependent(object, relation)) {
108
+ continue;
109
+ }
110
+ operations.push(makeOperation("create", object.key, undefined, object, config));
111
+ operationKeys.add(object.key);
112
+ }
113
+ }
114
+ }
115
+ function isRelationDependent(object, relation) {
116
+ const schema = relation.schema ?? "public";
117
+ if (relationDependentKinds.has(object.ref.kind) &&
118
+ object.ref.table === relation.name &&
119
+ (object.ref.schema ?? "public") === schema) {
120
+ return true;
121
+ }
122
+ return (object.ref.kind === "grant" &&
123
+ typeof object.metadata.targetIdentity === "string" &&
124
+ object.metadata.targetIdentity === `${schema}.${relation.name}`);
125
+ }
126
+ /**
127
+ * A diff engine's worst failure mode is an empty plan over states that
128
+ * actually differ. Zero operations must imply equal model fingerprints; when
129
+ * it does not, fail loud with the divergence instead of rendering a no-op.
130
+ */
131
+ function emptyPlanDriftDiagnostic(fromMap, toMap, from, to) {
132
+ const differing = [];
133
+ for (const [key, before] of fromMap) {
134
+ const after = toMap.get(key);
135
+ if (!after) {
136
+ differing.push(`missing in target: ${key}`);
137
+ }
138
+ else if (before.hash !== after.hash) {
139
+ differing.push(`hash drift: ${key}`);
140
+ }
141
+ }
142
+ for (const key of toMap.keys()) {
143
+ if (!fromMap.has(key)) {
144
+ differing.push(`missing in source: ${key}`);
145
+ }
146
+ }
147
+ const sample = differing.slice(0, 12).join("; ");
148
+ return diagnostic("SUPA_PLAN_EMPTY_WITH_DRIFT", "error", "plan contains no operations but the model fingerprints differ", {
149
+ hint: differing.length > 0
150
+ ? `${differing.length} differing object(s): ${sample}`
151
+ : `object sets are identical; fingerprint basis differs (from=${from.fingerprint} to=${to.fingerprint}) — check model format versions`,
152
+ });
153
+ }
154
+ function makeOperation(kind, key, before, after, config) {
155
+ const object = after ?? before;
156
+ if (!object) {
157
+ throw new Error(`operation ${kind} for ${key} has no object`);
158
+ }
159
+ const diagnostics = [];
160
+ const destructive = isDestructive(kind, object.ref.kind);
161
+ let blocked = false;
162
+ if (destructive && !isDestructiveAllowed(key, config)) {
163
+ blocked = true;
164
+ const difference = kind === "replace" ? describeReplaceDifference(before, after) : undefined;
165
+ diagnostics.push(diagnostic("SUPA_PLAN_DESTRUCTIVE_HINT_REQUIRED", "error", `${kind} of ${object.ref.kind} requires an explicit destructive-change hint${difference ? ` — ${difference}` : ""}`, {
166
+ hint: `Add "${key}" to hints.destructive only after reviewing the migration.`,
167
+ ref: object.ref,
168
+ }));
169
+ }
170
+ if (kind === "replace" && object.ref.kind === "view") {
171
+ diagnostics.push(diagnostic("SUPA_PLAN_VIEW_REPLACE_VERIFY_REQUIRED", "warning", "PostgreSQL only permits CREATE OR REPLACE VIEW when the replacement shape is compatible", {
172
+ hint: "Run supaschema verify against a disposable PostgreSQL database before release.",
173
+ ref: object.ref,
174
+ }));
175
+ }
176
+ if ((kind === "create" || kind === "replace") &&
177
+ object.ref.kind === "index" &&
178
+ object.metadata.concurrent === true &&
179
+ config.adapter === "supabase-auto") {
180
+ blocked = true;
181
+ diagnostics.push(diagnostic("SUPA_PLAN_CONCURRENT_INDEX_UNSUPPORTED", "error", "CREATE INDEX CONCURRENTLY cannot run inside the transaction Supabase db push uses", {
182
+ hint: "Create the index without CONCURRENTLY, or run it through an explicit out-of-transaction operational lane.",
183
+ ref: object.ref,
184
+ }));
185
+ }
186
+ const operation = {
187
+ blocked,
188
+ destructive,
189
+ diagnostics,
190
+ key,
191
+ kind,
192
+ metadata: {},
193
+ ref: object.ref,
194
+ };
195
+ if (before) {
196
+ operation.before = before;
197
+ }
198
+ if (after) {
199
+ operation.after = after;
200
+ }
201
+ return operation;
202
+ }
203
+ function makeRenameOperation(before, after) {
204
+ const diagnostics = [];
205
+ let blocked = false;
206
+ if (before.ref.kind !== after.ref.kind) {
207
+ blocked = true;
208
+ diagnostics.push(diagnostic("SUPA_PLAN_RENAME_KIND_MISMATCH", "error", "rename hint changes object kind", {
209
+ hint: `${before.key} -> ${after.key}`,
210
+ ref: after.ref,
211
+ }));
212
+ }
213
+ if (!isSupportedRenameKind(after.ref.kind)) {
214
+ blocked = true;
215
+ diagnostics.push(diagnostic("SUPA_PLAN_RENAME_UNSUPPORTED", "error", `${after.ref.kind} renames are not yet rendered safely`, {
216
+ hint: "Keep this change hand-authored or model it as an explicit create/drop with hints.",
217
+ ref: after.ref,
218
+ }));
219
+ }
220
+ if (!sameRenameNamespace(before.ref, after.ref)) {
221
+ blocked = true;
222
+ diagnostics.push(diagnostic("SUPA_PLAN_RENAME_SET_SCHEMA_UNSUPPORTED", "error", "rename hints cannot move an object between schemas", {
223
+ hint: `${before.key} -> ${after.key}`,
224
+ ref: after.ref,
225
+ }));
226
+ }
227
+ diagnostics.push(diagnostic("SUPA_PLAN_RENAME_VERIFY_REQUIRED", "warning", "explicit rename hints must be verified against a disposable PostgreSQL database", {
228
+ hint: `${before.key} -> ${after.key}`,
229
+ ref: after.ref,
230
+ }));
231
+ return {
232
+ after,
233
+ before,
234
+ blocked,
235
+ destructive: false,
236
+ diagnostics,
237
+ key: `${before.key}->${after.key}`,
238
+ kind: "rename",
239
+ metadata: {},
240
+ ref: after.ref,
241
+ };
242
+ }
243
+ function makeEnumAddValuesOperation(before, after) {
244
+ if (before.ref.kind !== "enum" || after.ref.kind !== "enum") {
245
+ return undefined;
246
+ }
247
+ const beforeValues = enumValues(before);
248
+ const afterValues = enumValues(after);
249
+ if (!beforeValues || !afterValues || afterValues.length <= beforeValues.length) {
250
+ return undefined;
251
+ }
252
+ const isPrefix = beforeValues.every((value, index) => afterValues[index] === value);
253
+ if (!isPrefix) {
254
+ return undefined;
255
+ }
256
+ return {
257
+ after,
258
+ before,
259
+ blocked: false,
260
+ destructive: false,
261
+ diagnostics: [],
262
+ key: after.key,
263
+ kind: "alter",
264
+ metadata: { addEnumValues: afterValues.slice(beforeValues.length) },
265
+ ref: after.ref,
266
+ };
267
+ }
268
+ function enumValues(object) {
269
+ const values = object.metadata.values;
270
+ if (!Array.isArray(values)) {
271
+ return undefined;
272
+ }
273
+ const strings = values.filter((value) => typeof value === "string");
274
+ return strings.length === values.length ? strings : undefined;
275
+ }
276
+ /**
277
+ * Names what actually differs between the two definitions so a gated replace
278
+ * is reviewable without manually diffing SQL. Tables get a per-column report
279
+ * from their canonical shapes; other kinds report a definition change.
280
+ */
281
+ function describeReplaceDifference(before, after) {
282
+ if (!before || !after) {
283
+ return undefined;
284
+ }
285
+ const beforeShape = asShape(before.metadata.canonicalShape);
286
+ const afterShape = asShape(after.metadata.canonicalShape);
287
+ if (!beforeShape || !afterShape) {
288
+ return "definition differs";
289
+ }
290
+ const parts = [];
291
+ const beforeColumns = shapeColumns(beforeShape);
292
+ const afterColumns = shapeColumns(afterShape);
293
+ for (const [name, column] of beforeColumns) {
294
+ const other = afterColumns.get(name);
295
+ if (!other) {
296
+ parts.push(`column "${name}" only in current state`);
297
+ continue;
298
+ }
299
+ if (stableJson(column) !== stableJson(other)) {
300
+ const changed = Object.keys({ ...column, ...other }).filter((field) => stableJson(column[field]) !== stableJson(other[field]));
301
+ parts.push(`column "${name}" differs (${changed.join(", ")})`);
302
+ }
303
+ }
304
+ for (const name of afterColumns.keys()) {
305
+ if (!beforeColumns.has(name)) {
306
+ parts.push(`column "${name}" only in target state`);
307
+ }
308
+ }
309
+ const beforeRest = stableJson({ ...beforeShape, columns: undefined });
310
+ const afterRest = stableJson({ ...afterShape, columns: undefined });
311
+ if (beforeRest !== afterRest) {
312
+ const keys = new Set([...Object.keys(beforeShape), ...Object.keys(afterShape)]);
313
+ keys.delete("columns");
314
+ const changed = [...keys].filter((field) => stableJson(beforeShape[field]) !== stableJson(afterShape[field]));
315
+ parts.push(`table options differ (${changed.join(", ")})`);
316
+ }
317
+ return parts.length > 0 ? parts.join("; ") : "definition differs";
318
+ }
319
+ function asShape(value) {
320
+ return value && typeof value === "object" && !Array.isArray(value)
321
+ ? value
322
+ : undefined;
323
+ }
324
+ function shapeColumns(shape) {
325
+ const columns = Array.isArray(shape.columns) ? shape.columns : [];
326
+ const byName = new Map();
327
+ for (const column of columns) {
328
+ const record = asShape(column);
329
+ if (record && typeof record.name === "string") {
330
+ byName.set(record.name, record);
331
+ }
332
+ }
333
+ return byName;
334
+ }
335
+ function isDestructive(kind, objectKind) {
336
+ if (kind === "alter" || kind === "create" || kind === "rename") {
337
+ return false;
338
+ }
339
+ if (kind === "drop") {
340
+ return [
341
+ "schema",
342
+ "table",
343
+ "foreign-data-wrapper",
344
+ "foreign-server",
345
+ "foreign-table",
346
+ "type",
347
+ "domain",
348
+ "enum",
349
+ "sequence",
350
+ "materialized-view",
351
+ "grant",
352
+ "default-privilege",
353
+ "rls",
354
+ ].includes(objectKind);
355
+ }
356
+ return [
357
+ "table",
358
+ "foreign-data-wrapper",
359
+ "foreign-server",
360
+ "foreign-table",
361
+ "type",
362
+ "domain",
363
+ "enum",
364
+ "materialized-view",
365
+ "rls",
366
+ ].includes(objectKind);
367
+ }
368
+ function isSupportedRenameKind(kind) {
369
+ return [
370
+ "schema",
371
+ "table",
372
+ "sequence",
373
+ "index",
374
+ "function",
375
+ "procedure",
376
+ "view",
377
+ "materialized-view",
378
+ ].includes(kind);
379
+ }
380
+ function sameRenameNamespace(before, after) {
381
+ if (before.kind === "schema" && after.kind === "schema") {
382
+ return true;
383
+ }
384
+ return (before.schema ?? "public") === (after.schema ?? "public");
385
+ }
@@ -0,0 +1,12 @@
1
+ import type { MigrationOperation, ObjectRef, SchemaObject } from "./core.js";
2
+ export declare function ensureSemicolon(sql: string): string;
3
+ export declare function quoteLiteral(value: string): string;
4
+ export declare function qualifiedRef(ref: ObjectRef): string;
5
+ export declare function qualifiedTableRef(ref: ObjectRef): string;
6
+ export declare function renderRename(operation: MigrationOperation): string;
7
+ export declare function renderTypeGuard(object: SchemaObject): string;
8
+ export declare function renderFdwGuard(object: SchemaObject): string;
9
+ export declare function renderConstraintGuard(object: SchemaObject): string;
10
+ export declare function renderGrantDrop(object: SchemaObject): string;
11
+ export declare function renderDefaultPrivilegeDrop(object: SchemaObject): string;
12
+ //# sourceMappingURL=render-guards.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"render-guards.d.ts","sourceRoot":"","sources":["../src/render-guards.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,SAAS,EAAE,YAAY,EAAE,MAAM,WAAW,CAAC;AAG7E,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAGnD;AAED,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAElD;AAED,wBAAgB,YAAY,CAAC,GAAG,EAAE,SAAS,GAAG,MAAM,CAEnD;AAED,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,SAAS,GAAG,MAAM,CAExD;AAED,wBAAgB,YAAY,CAAC,SAAS,EAAE,kBAAkB,GAAG,MAAM,CAkBlE;AAsCD,wBAAgB,eAAe,CAAC,MAAM,EAAE,YAAY,GAAG,MAAM,CAW5D;AAID,wBAAgB,cAAc,CAAC,MAAM,EAAE,YAAY,GAAG,MAAM,CAS3D;AAED,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,YAAY,GAAG,MAAM,CAmBlE;AAED,wBAAgB,eAAe,CAAC,MAAM,EAAE,YAAY,GAAG,MAAM,CAiB5D;AAED,wBAAgB,0BAA0B,CAAC,MAAM,EAAE,YAAY,GAAG,MAAM,CAuBvE"}
@@ -0,0 +1,159 @@
1
+ import { formatQualifiedName, quoteIdent } from "./sql/identifiers.js";
2
+ export function ensureSemicolon(sql) {
3
+ const trimmed = sql.trim();
4
+ return trimmed.endsWith(";") ? trimmed : `${trimmed};`;
5
+ }
6
+ export function quoteLiteral(value) {
7
+ return `'${value.replaceAll("'", "''")}'`;
8
+ }
9
+ export function qualifiedRef(ref) {
10
+ return formatQualifiedName(ref.schema, ref.name);
11
+ }
12
+ export function qualifiedTableRef(ref) {
13
+ return formatQualifiedName(ref.schema, ref.table ?? ref.name);
14
+ }
15
+ export function renderRename(operation) {
16
+ const before = requiredBefore(operation);
17
+ const after = requiredAfter(operation);
18
+ const oldExists = existsExpression(before);
19
+ const newExists = existsExpression(after);
20
+ const renameSql = renderRenameStatement(before.ref, after.ref);
21
+ const conflict = quoteLiteral(`supaschema rename conflict: both ${before.key} and ${after.key} exist`);
22
+ return `DO $supaschema$
23
+ BEGIN
24
+ IF ${oldExists} AND ${newExists} THEN
25
+ RAISE EXCEPTION ${conflict};
26
+ ELSIF ${oldExists} THEN
27
+ ${renameSql}
28
+ END IF;
29
+ END
30
+ $supaschema$;`;
31
+ }
32
+ function renderRenameStatement(before, after) {
33
+ switch (after.kind) {
34
+ case "schema":
35
+ return `ALTER SCHEMA ${quoteIdent(before.name)} RENAME TO ${quoteIdent(after.name)};`;
36
+ case "table":
37
+ return `ALTER TABLE ${qualifiedRef(before)} RENAME TO ${quoteIdent(after.name)};`;
38
+ case "sequence":
39
+ return `ALTER SEQUENCE ${qualifiedRef(before)} RENAME TO ${quoteIdent(after.name)};`;
40
+ case "index":
41
+ return `ALTER INDEX ${qualifiedRef(before)} RENAME TO ${quoteIdent(after.name)};`;
42
+ case "view":
43
+ return `ALTER VIEW ${qualifiedRef(before)} RENAME TO ${quoteIdent(after.name)};`;
44
+ case "materialized-view":
45
+ return `ALTER MATERIALIZED VIEW ${qualifiedRef(before)} RENAME TO ${quoteIdent(after.name)};`;
46
+ case "function":
47
+ return `ALTER FUNCTION ${qualifiedRef(before)}(${before.signature ?? ""}) RENAME TO ${quoteIdent(after.name)};`;
48
+ case "procedure":
49
+ return `ALTER PROCEDURE ${qualifiedRef(before)}(${before.signature ?? ""}) RENAME TO ${quoteIdent(after.name)};`;
50
+ default:
51
+ throw new Error(`unsupported rename operation for ${after.kind}`);
52
+ }
53
+ }
54
+ // Guard bodies schema-qualify every catalog reference so a hostile or
55
+ // unusual search_path cannot redirect the existence check.
56
+ function existsExpression(object) {
57
+ const ref = object.ref;
58
+ if (ref.kind === "schema") {
59
+ return `EXISTS (SELECT 1 FROM pg_catalog.pg_namespace WHERE nspname = ${quoteLiteral(ref.name)})`;
60
+ }
61
+ if (ref.kind === "function" || ref.kind === "procedure") {
62
+ return `pg_catalog.to_regprocedure(${quoteLiteral(`${qualifiedRef(ref)}(${ref.signature ?? ""})`)}) IS NOT NULL`;
63
+ }
64
+ return `pg_catalog.to_regclass(${quoteLiteral(qualifiedRef(ref))}) IS NOT NULL`;
65
+ }
66
+ export function renderTypeGuard(object) {
67
+ const schema = object.ref.schema ?? "public";
68
+ const name = object.ref.name;
69
+ const catalogCheck = `SELECT 1 FROM pg_catalog.pg_type t JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace WHERE n.nspname = ${quoteLiteral(schema)} AND t.typname = ${quoteLiteral(name)}`;
70
+ return `DO $supaschema$
71
+ BEGIN
72
+ IF NOT EXISTS (${catalogCheck}) THEN
73
+ ${ensureSemicolon(object.sql)}
74
+ END IF;
75
+ END
76
+ $supaschema$;`;
77
+ }
78
+ // CREATE FOREIGN DATA WRAPPER has no IF NOT EXISTS form, so replay safety
79
+ // comes from a catalog-guarded DO block like types use.
80
+ export function renderFdwGuard(object) {
81
+ const catalogCheck = `SELECT 1 FROM pg_catalog.pg_foreign_data_wrapper WHERE fdwname = ${quoteLiteral(object.ref.name)}`;
82
+ return `DO $supaschema$
83
+ BEGIN
84
+ IF NOT EXISTS (${catalogCheck}) THEN
85
+ ${ensureSemicolon(object.sql)}
86
+ END IF;
87
+ END
88
+ $supaschema$;`;
89
+ }
90
+ export function renderConstraintGuard(object) {
91
+ const schema = object.ref.schema ?? "public";
92
+ const table = object.ref.table ?? object.ref.name;
93
+ const name = object.ref.name;
94
+ return `DO $supaschema$
95
+ BEGIN
96
+ IF NOT EXISTS (
97
+ SELECT 1
98
+ FROM pg_catalog.pg_constraint c
99
+ JOIN pg_catalog.pg_class r ON r.oid = c.conrelid
100
+ JOIN pg_catalog.pg_namespace n ON n.oid = r.relnamespace
101
+ WHERE n.nspname = ${quoteLiteral(schema)}
102
+ AND r.relname = ${quoteLiteral(table)}
103
+ AND c.conname = ${quoteLiteral(name)}
104
+ ) THEN
105
+ ${ensureSemicolon(object.sql)}
106
+ END IF;
107
+ END
108
+ $supaschema$;`;
109
+ }
110
+ export function renderGrantDrop(object) {
111
+ const verb = object.metadata.verb;
112
+ const privileges = object.metadata.privileges;
113
+ const kindPhrase = object.metadata.kindPhrase;
114
+ const target = object.metadata.target;
115
+ const grantee = object.metadata.grantee;
116
+ if (verb !== "GRANT" ||
117
+ !Array.isArray(privileges) ||
118
+ typeof kindPhrase !== "string" ||
119
+ typeof target !== "string" ||
120
+ typeof grantee !== "string") {
121
+ return `-- Manual privilege removal required for ${object.key}`;
122
+ }
123
+ const role = grantee === "PUBLIC" ? "PUBLIC" : quoteIdent(grantee);
124
+ return `REVOKE ${privileges.map(String).join(", ")} ON ${kindPhrase} ${target} FROM ${role};`;
125
+ }
126
+ export function renderDefaultPrivilegeDrop(object) {
127
+ const verb = object.metadata.verb;
128
+ const privileges = object.metadata.privileges;
129
+ const objectType = object.metadata.objectType;
130
+ const grantee = object.metadata.grantee;
131
+ if (verb !== "GRANT" ||
132
+ !Array.isArray(privileges) ||
133
+ typeof objectType !== "string" ||
134
+ typeof grantee !== "string") {
135
+ return `-- Manual privilege removal required for ${object.key}`;
136
+ }
137
+ const forRole = typeof object.metadata.forRole === "string" ? object.metadata.forRole : undefined;
138
+ const schema = typeof object.metadata.schema === "string" ? object.metadata.schema : undefined;
139
+ const role = grantee === "PUBLIC" ? "PUBLIC" : quoteIdent(grantee);
140
+ const clauses = [
141
+ "ALTER DEFAULT PRIVILEGES",
142
+ forRole ? `FOR ROLE ${quoteIdent(forRole)}` : "",
143
+ schema ? `IN SCHEMA ${quoteIdent(schema)}` : "",
144
+ `REVOKE ${privileges.map(String).join(", ")} ON ${objectType} FROM ${role}`,
145
+ ].filter(Boolean);
146
+ return `${clauses.join(" ")};`;
147
+ }
148
+ function requiredBefore(operation) {
149
+ if (!operation.before) {
150
+ throw new Error(`operation ${operation.key} has no before object`);
151
+ }
152
+ return operation.before;
153
+ }
154
+ function requiredAfter(operation) {
155
+ if (!operation.after) {
156
+ throw new Error(`operation ${operation.key} has no after object`);
157
+ }
158
+ return operation.after;
159
+ }
@@ -0,0 +1,7 @@
1
+ import type { MigrationPlan, RenderOptions } from "./core.js";
2
+ export declare function renderMigrationSplit(plan: MigrationPlan, options?: RenderOptions): {
3
+ concurrentSql?: string;
4
+ sql: string;
5
+ };
6
+ export declare function renderMigration(plan: MigrationPlan, options?: RenderOptions): string;
7
+ //# sourceMappingURL=render.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"render.d.ts","sourceRoot":"","sources":["../src/render.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAGV,aAAa,EACb,aAAa,EAId,MAAM,WAAW,CAAC;AAgBnB,wBAAgB,oBAAoB,CAClC,IAAI,EAAE,aAAa,EACnB,OAAO,GAAE,aAAkB,GAC1B;IAAE,aAAa,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAezC;AAED,wBAAgB,eAAe,CAAC,IAAI,EAAE,aAAa,EAAE,OAAO,GAAE,aAAkB,GAAG,MAAM,CAcxF"}