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,392 @@
1
+ import { deparseSync } from "pgsql-deparser";
2
+ import { diagnostic } from "../diagnostics.js";
3
+ import { asRecord, astStatements, readArray, readBoolean, readNumber, readString, stringList, stringValue, typeNameToSql, } from "./ast.js";
4
+ import { canonicalPolicyNode, canonicalViewNode } from "./canonical-nodes.js";
5
+ import { normalizeObjectSql } from "./normalize-deparse.js";
6
+ import { astObjectHash, shapeHash } from "./object-hash.js";
7
+ import { parseSqlAst } from "./parser.js";
8
+ import { canonicalConstraintShape, canonicalSequenceShape, canonicalTableShape, } from "./table-shape.js";
9
+ const ifNotExistsSteps = {
10
+ CreateExtensionStmt: [{ words: ["CREATE"] }, { words: ["EXTENSION"] }],
11
+ CreateForeignServerStmt: [{ words: ["CREATE"] }, { words: ["SERVER"] }],
12
+ CreateForeignTableStmt: [{ words: ["CREATE"] }, { words: ["FOREIGN"] }, { words: ["TABLE"] }],
13
+ CreateSchemaStmt: [{ words: ["CREATE"] }, { words: ["SCHEMA"] }],
14
+ CreateSeqStmt: [
15
+ { words: ["CREATE"] },
16
+ { optional: true, words: ["UNLOGGED", "TEMP", "TEMPORARY"] },
17
+ { words: ["SEQUENCE"] },
18
+ ],
19
+ CreateStmt: [
20
+ { words: ["CREATE"] },
21
+ { optional: true, words: ["GLOBAL", "LOCAL"] },
22
+ { optional: true, words: ["UNLOGGED", "TEMP", "TEMPORARY"] },
23
+ { words: ["TABLE"] },
24
+ ],
25
+ CreateTableAsStmt: [{ words: ["CREATE"] }, { words: ["MATERIALIZED"] }, { words: ["VIEW"] }],
26
+ IndexStmt: [
27
+ { words: ["CREATE"] },
28
+ { optional: true, words: ["UNIQUE"] },
29
+ { words: ["INDEX"] },
30
+ { optional: true, words: ["CONCURRENTLY"] },
31
+ ],
32
+ };
33
+ const orReplaceTags = new Set(["CreateFunctionStmt", "ViewStmt"]);
34
+ const finalizeConcurrency = 8;
35
+ export async function finalizeObjects(objects, options = {}) {
36
+ const diagnostics = [];
37
+ for (let start = 0; start < objects.length; start += finalizeConcurrency) {
38
+ const batch = objects.slice(start, start + finalizeConcurrency);
39
+ const results = await Promise.all(batch.map((object) => finalizeObject(object, options)));
40
+ for (const result of results) {
41
+ diagnostics.push(...result);
42
+ }
43
+ }
44
+ return diagnostics;
45
+ }
46
+ export async function finalizeObject(object, options = {}) {
47
+ const parsed = await parseSqlAst(object.sql, object.file);
48
+ let statements = parsed.ast === undefined ? [] : astStatements(parsed.ast, object.sql);
49
+ if (!statements[0]) {
50
+ return [
51
+ ...parsed.diagnostics,
52
+ diagnostic("SUPA_OBJECT_PARSE_FAILED", "error", `object SQL for ${object.key} did not parse; object identity fell back to text`, { file: object.file, ref: object.ref, statement: object.sql }),
53
+ ];
54
+ }
55
+ const diagnostics = [];
56
+ if (options.normalize === true) {
57
+ // Deparse-normalization is fidelity-gated: the canonical text is used
58
+ // only when it reparses to the identical location-stripped tree, so
59
+ // hashes are unchanged and a deparser gap degrades to the source text.
60
+ const normalized = await normalizeObjectSql(object, parsed.ast);
61
+ diagnostics.push(...normalized.diagnostics);
62
+ if (normalized.sql !== undefined && normalized.statements !== undefined) {
63
+ object.sql = normalized.sql;
64
+ statements = normalized.statements;
65
+ }
66
+ }
67
+ const first = statements[0];
68
+ if (!first) {
69
+ return diagnostics;
70
+ }
71
+ object.hash = canonicalHash(object, statements);
72
+ Object.assign(object.metadata, statementFacts(first.tag, first.node, object.sql));
73
+ return diagnostics;
74
+ }
75
+ function canonicalHash(object, statements) {
76
+ const first = statements[0];
77
+ if (first?.tag === "CreateStmt") {
78
+ const createStmt = asRecord(first.node.CreateStmt);
79
+ if (createStmt) {
80
+ const shape = canonicalTableShape(createStmt);
81
+ // Carried so the planner can diff per-column instead of replacing the
82
+ // whole table when only column facts change.
83
+ object.metadata.canonicalShape = shape;
84
+ return shapeHash(shape, object.key, object.ref);
85
+ }
86
+ }
87
+ if (first?.tag === "CreateSeqStmt") {
88
+ const createSeqStmt = asRecord(first.node.CreateSeqStmt);
89
+ if (createSeqStmt) {
90
+ const shape = canonicalSequenceShape(createSeqStmt);
91
+ // Carried so a standalone ALTER SEQUENCE ... OWNED BY (the pg_dump
92
+ // serial decomposition) can fold into the sequence's identity.
93
+ object.metadata.canonicalShape = shape;
94
+ return shapeHash(shape, object.key, object.ref);
95
+ }
96
+ }
97
+ if (object.ref.kind === "constraint" && first?.tag === "AlterTableStmt") {
98
+ const constraintNode = addConstraintNode(asRecord(first.node.AlterTableStmt));
99
+ if (constraintNode) {
100
+ return shapeHash(canonicalConstraintShape(constraintNode, {
101
+ name: object.ref.table ?? "",
102
+ schema: object.ref.schema ?? "public",
103
+ }), object.key, object.ref);
104
+ }
105
+ }
106
+ if (object.ref.kind === "default-privilege") {
107
+ // Hash from the builder-normalized shape, not the statement AST: the
108
+ // FOR ROLE clause names the executing role and must not affect identity
109
+ // or content equality across lanes.
110
+ return shapeHash({
111
+ grantee: String(object.metadata.grantee ?? ""),
112
+ objectType: String(object.metadata.objectType ?? ""),
113
+ privileges: Array.isArray(object.metadata.privileges) ? object.metadata.privileges : [],
114
+ schema: String(object.metadata.schema ?? ""),
115
+ verb: String(object.metadata.verb ?? ""),
116
+ }, object.key, object.ref);
117
+ }
118
+ if (object.ref.kind === "rls") {
119
+ // ALTER TABLE [ONLY] for RLS flags does not recurse to children, so the
120
+ // ONLY spelling (relation.inh) is semantically inert and must not split
121
+ // cross-lane identity: catalogs reconstruct without ONLY while trees
122
+ // often write it.
123
+ return astObjectHash(statements.map((item) => {
124
+ const cloned = structuredClone(item.node);
125
+ const alterTable = asRecord(cloned.AlterTableStmt);
126
+ const relation = asRecord(alterTable?.relation);
127
+ if (relation) {
128
+ relation.inh = true;
129
+ }
130
+ return cloned;
131
+ }), object.key, object.ref);
132
+ }
133
+ if (object.ref.kind === "policy") {
134
+ // pg_get_expr renders ANALYZED expressions: subquery targets gain
135
+ // auto-aliases (`SELECT auth.uid() AS uid`) and constants gain implicit
136
+ // casts (`(0)::numeric`) that raw declarative text lacks. Both are
137
+ // semantically inert inside a policy expression, so hashing strips them
138
+ // on both lanes.
139
+ return astObjectHash(statements.map((item) => canonicalPolicyNode(item.node)), object.key, object.ref);
140
+ }
141
+ if (object.ref.kind === "view" || object.ref.kind === "materialized-view") {
142
+ // pg_get_viewdef qualifies every column with its relation name on PG 15
143
+ // (`upper(accounts.name)`) where PG 16+ and declarative sources write the
144
+ // bare column (`upper(name)`). Stripping the qualifier is provably safe
145
+ // only when it names the sole plain relation of the innermost SELECT
146
+ // scope, so both lanes converge to that form and everything else (joins,
147
+ // correlated outer refs) keeps its written qualification.
148
+ return astObjectHash(statements.map((item) => canonicalViewNode(item.node, [])), object.key, object.ref);
149
+ }
150
+ return astObjectHash(statements.map((item) => item.node), object.key, object.ref);
151
+ }
152
+ function addConstraintNode(alterTableStmt) {
153
+ for (const item of readArray(alterTableStmt?.cmds)) {
154
+ const command = asRecord(asRecord(item)?.AlterTableCmd);
155
+ if (readString(command?.subtype) !== "AT_AddConstraint") {
156
+ continue;
157
+ }
158
+ const constraint = asRecord(asRecord(command?.def)?.Constraint);
159
+ if (constraint) {
160
+ return constraint;
161
+ }
162
+ }
163
+ return undefined;
164
+ }
165
+ export function statementFacts(tag, statementNode, sql) {
166
+ const node = asRecord(statementNode[tag]) ?? {};
167
+ const facts = {};
168
+ const render = renderGuardFacts(tag, node, sql);
169
+ if (render) {
170
+ facts.render = render;
171
+ }
172
+ if (tag === "CreateFunctionStmt") {
173
+ Object.assign(facts, functionFacts(node));
174
+ }
175
+ if (tag === "ViewStmt") {
176
+ Object.assign(facts, viewFacts(node));
177
+ }
178
+ if (tag === "CommentStmt") {
179
+ const dropSql = commentDropSql(node);
180
+ if (dropSql !== undefined) {
181
+ facts.commentDropSql = dropSql;
182
+ }
183
+ }
184
+ return facts;
185
+ }
186
+ /**
187
+ * A comment drop is the same statement with a NULL value; deparsing the
188
+ * comment-stripped node quotes the target correctly (descriptor text is an
189
+ * identity label, not renderable SQL — `extension uuid-ossp` must render as
190
+ * `COMMENT ON EXTENSION "uuid-ossp"`).
191
+ */
192
+ function commentDropSql(node) {
193
+ try {
194
+ const stripped = structuredClone(node);
195
+ delete stripped.comment;
196
+ return deparseSync({
197
+ stmts: [{ stmt: { CommentStmt: stripped } }],
198
+ version: 170004,
199
+ });
200
+ }
201
+ catch {
202
+ return undefined;
203
+ }
204
+ }
205
+ function renderGuardFacts(tag, node, sql) {
206
+ if (tag === "CreateTableAsStmt" && readString(node.objtype) !== "OBJECT_MATVIEW") {
207
+ return undefined;
208
+ }
209
+ const steps = ifNotExistsSteps[tag];
210
+ if (steps) {
211
+ // CreateForeignTableStmt nests the flag on its embedded base CreateStmt.
212
+ const flagNode = tag === "CreateForeignTableStmt" ? (asRecord(node.base) ?? node) : node;
213
+ const facts = {
214
+ guard: "ifNotExists",
215
+ present: readBoolean(flagNode.if_not_exists),
216
+ };
217
+ const offset = keywordOffset(sql, steps);
218
+ if (offset !== undefined) {
219
+ facts.offset = offset;
220
+ }
221
+ return facts;
222
+ }
223
+ if (orReplaceTags.has(tag)) {
224
+ const facts = {
225
+ guard: "orReplace",
226
+ present: readBoolean(node.replace),
227
+ };
228
+ const offset = keywordOffset(sql, [{ words: ["CREATE"] }]);
229
+ if (offset !== undefined) {
230
+ facts.offset = offset;
231
+ }
232
+ return facts;
233
+ }
234
+ return undefined;
235
+ }
236
+ function functionFacts(node) {
237
+ const facts = {};
238
+ const returnType = asRecord(node.returnType);
239
+ if (returnType) {
240
+ const returns = {
241
+ setof: readBoolean(returnType.setof),
242
+ type: typeNameToSql(node.returnType),
243
+ };
244
+ facts.returns = returns;
245
+ }
246
+ const outParams = [];
247
+ for (const item of readArray(node.parameters)) {
248
+ const parameter = asRecord(asRecord(item)?.FunctionParameter);
249
+ if (!parameter) {
250
+ continue;
251
+ }
252
+ const mode = readString(parameter.mode) ?? "FUNC_PARAM_DEFAULT";
253
+ if (mode !== "FUNC_PARAM_OUT" && mode !== "FUNC_PARAM_INOUT" && mode !== "FUNC_PARAM_TABLE") {
254
+ continue;
255
+ }
256
+ outParams.push({
257
+ mode,
258
+ name: readString(parameter.name) ?? "",
259
+ type: typeNameToSql(parameter.argType),
260
+ });
261
+ }
262
+ if (outParams.length > 0) {
263
+ facts.outParams = outParams;
264
+ }
265
+ return facts;
266
+ }
267
+ function viewFacts(node) {
268
+ const facts = {};
269
+ const aliases = stringList(node.aliases);
270
+ const columns = aliases.length > 0 ? aliases : viewTargetColumns(node.query);
271
+ if (columns !== undefined) {
272
+ facts.viewColumns = columns;
273
+ }
274
+ const securityInvoker = viewSecurityInvoker(node.options);
275
+ if (securityInvoker !== undefined) {
276
+ facts.securityInvoker = securityInvoker;
277
+ }
278
+ return facts;
279
+ }
280
+ function viewTargetColumns(query) {
281
+ const select = asRecord(asRecord(query)?.SelectStmt);
282
+ if (!select || asRecord(select.larg) || asRecord(select.rarg)) {
283
+ return undefined;
284
+ }
285
+ const columns = [];
286
+ for (const item of readArray(select.targetList)) {
287
+ const target = asRecord(asRecord(item)?.ResTarget);
288
+ if (!target) {
289
+ return undefined;
290
+ }
291
+ const explicit = readString(target.name);
292
+ if (explicit) {
293
+ columns.push(explicit);
294
+ continue;
295
+ }
296
+ const fields = readArray(asRecord(asRecord(target.val)?.ColumnRef)?.fields);
297
+ const name = stringValue(fields.at(-1));
298
+ if (!name) {
299
+ return undefined;
300
+ }
301
+ columns.push(name);
302
+ }
303
+ return columns;
304
+ }
305
+ function viewSecurityInvoker(options) {
306
+ for (const item of readArray(options)) {
307
+ const defElem = asRecord(asRecord(item)?.DefElem);
308
+ if (readString(defElem?.defname)?.toLowerCase() !== "security_invoker") {
309
+ continue;
310
+ }
311
+ return defElemBoolean(defElem?.arg);
312
+ }
313
+ return undefined;
314
+ }
315
+ function defElemBoolean(arg) {
316
+ if (arg === undefined || arg === null) {
317
+ return true;
318
+ }
319
+ if (readBoolean(arg)) {
320
+ return true;
321
+ }
322
+ const integer = asRecord(asRecord(arg)?.Integer);
323
+ if (integer) {
324
+ return (readNumber(integer.ival) ?? 0) !== 0;
325
+ }
326
+ const text = (stringValue(arg) ?? "").toLowerCase();
327
+ return text === "true" || text === "on" || text === "1" || text === "yes";
328
+ }
329
+ /**
330
+ * Walks the leading keyword sequence of a statement (skipping whitespace and
331
+ * comments) and returns the offset of the token that follows it — the splice
332
+ * point for a replay guard. Character scanning only; classification stays AST.
333
+ */
334
+ export function keywordOffset(sql, steps) {
335
+ let index = skipNonTokens(sql, 0);
336
+ for (const step of steps) {
337
+ const word = readWord(sql, index);
338
+ if (word && step.words.some((candidate) => candidate === word.text.toUpperCase())) {
339
+ index = skipNonTokens(sql, word.end);
340
+ continue;
341
+ }
342
+ if (step.optional) {
343
+ continue;
344
+ }
345
+ return undefined;
346
+ }
347
+ return index;
348
+ }
349
+ function isWordStartChar(char) {
350
+ return (char >= "a" && char <= "z") || (char >= "A" && char <= "Z") || char === "_";
351
+ }
352
+ function isWordContinuationChar(char) {
353
+ return (char >= "0" && char <= "9") || char === "$";
354
+ }
355
+ function readWord(sql, start) {
356
+ let end = start;
357
+ while (end < sql.length) {
358
+ const char = sql[end] ?? "";
359
+ if (!(isWordStartChar(char) || (end > start && isWordContinuationChar(char)))) {
360
+ break;
361
+ }
362
+ end += 1;
363
+ }
364
+ if (end === start) {
365
+ return undefined;
366
+ }
367
+ return { end, text: sql.slice(start, end) };
368
+ }
369
+ function skipNonTokens(sql, start) {
370
+ let index = start;
371
+ while (index < sql.length) {
372
+ const char = sql[index] ?? "";
373
+ const next = sql[index + 1] ?? "";
374
+ if (char === " " || char === "\t" || char === "\n" || char === "\r") {
375
+ index += 1;
376
+ continue;
377
+ }
378
+ if (char === "-" && next === "-") {
379
+ while (index < sql.length && sql[index] !== "\n") {
380
+ index += 1;
381
+ }
382
+ continue;
383
+ }
384
+ if (char === "/" && next === "*") {
385
+ const close = sql.indexOf("*/", index + 2);
386
+ index = close === -1 ? sql.length : close + 2;
387
+ continue;
388
+ }
389
+ break;
390
+ }
391
+ return index;
392
+ }
@@ -0,0 +1,13 @@
1
+ import type { ObjectRef } from "../core.js";
2
+ export declare function splitQualifiedIdentifier(input: string): string[];
3
+ export declare function parseQualifiedIdentifier(input: string, defaultSchema?: string): {
4
+ name: string;
5
+ schema: string;
6
+ };
7
+ export declare function normalizeIdentifier(input: string): string;
8
+ export declare function quoteIdent(identifier: string): string;
9
+ export declare function stripOuterDoubleQuotes(value: string): string;
10
+ export declare function formatQualifiedName(schema: string | undefined, name: string): string;
11
+ export declare function objectKey(ref: ObjectRef): string;
12
+ export declare function normalizeSql(sql: string): string;
13
+ //# sourceMappingURL=identifiers.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"identifiers.d.ts","sourceRoot":"","sources":["../../src/sql/identifiers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAE5C,wBAAgB,wBAAwB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CA4BhE;AACD,wBAAgB,wBAAwB,CACtC,KAAK,EAAE,MAAM,EACb,aAAa,SAAW,GACvB;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAYlC;AACD,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAMzD;AACD,wBAAgB,UAAU,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAErD;AACD,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAG5D;AACD,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAEpF;AACD,wBAAgB,SAAS,CAAC,GAAG,EAAE,SAAS,GAAG,MAAM,CAKhD;AACD,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAiBhD"}
@@ -0,0 +1,83 @@
1
+ export function splitQualifiedIdentifier(input) {
2
+ const parts = [];
3
+ let start = 0;
4
+ let inQuote = false;
5
+ for (let index = 0; index < input.length; index += 1) {
6
+ const char = input[index] ?? "";
7
+ const next = input[index + 1] ?? "";
8
+ if (inQuote) {
9
+ if (char === '"' && next === '"') {
10
+ index += 1;
11
+ continue;
12
+ }
13
+ if (char === '"') {
14
+ inQuote = false;
15
+ }
16
+ continue;
17
+ }
18
+ if (char === '"') {
19
+ inQuote = true;
20
+ continue;
21
+ }
22
+ if (char === ".") {
23
+ parts.push(input.slice(start, index).trim());
24
+ start = index + 1;
25
+ }
26
+ }
27
+ parts.push(input.slice(start).trim());
28
+ return parts.filter(Boolean);
29
+ }
30
+ export function parseQualifiedIdentifier(input, defaultSchema = "public") {
31
+ const parts = splitQualifiedIdentifier(input);
32
+ if (parts.length === 1) {
33
+ return {
34
+ name: normalizeIdentifier(parts[0] ?? ""),
35
+ schema: defaultSchema,
36
+ };
37
+ }
38
+ return {
39
+ name: normalizeIdentifier(parts.at(-1) ?? ""),
40
+ schema: normalizeIdentifier(parts.at(-2) ?? defaultSchema),
41
+ };
42
+ }
43
+ export function normalizeIdentifier(input) {
44
+ const trimmed = input.trim();
45
+ if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
46
+ return trimmed.slice(1, -1).replaceAll('""', '"');
47
+ }
48
+ return trimmed.toLowerCase();
49
+ }
50
+ export function quoteIdent(identifier) {
51
+ return `"${identifier.replaceAll('"', '""')}"`;
52
+ }
53
+ export function stripOuterDoubleQuotes(value) {
54
+ const withoutLeading = value.startsWith('"') ? value.slice(1) : value;
55
+ return withoutLeading.endsWith('"') ? withoutLeading.slice(0, -1) : withoutLeading;
56
+ }
57
+ export function formatQualifiedName(schema, name) {
58
+ return schema ? `${quoteIdent(schema)}.${quoteIdent(name)}` : quoteIdent(name);
59
+ }
60
+ export function objectKey(ref) {
61
+ const schema = ref.schema ? `${ref.schema}.` : "";
62
+ const table = ref.table ? `:${ref.table}` : "";
63
+ const signature = ref.signature !== undefined ? `(${ref.signature})` : "";
64
+ return `${ref.kind}:${schema}${ref.name}${signature}${table}`;
65
+ }
66
+ export function normalizeSql(sql) {
67
+ const lines = sql.replaceAll("\r\n", "\n").split("\n");
68
+ const collapsed = [];
69
+ let blankRun = 0;
70
+ for (const line of lines) {
71
+ const trimmedEnd = line.trimEnd();
72
+ blankRun = trimmedEnd.length === 0 ? blankRun + 1 : 0;
73
+ if (blankRun <= 1) {
74
+ collapsed.push(trimmedEnd);
75
+ }
76
+ }
77
+ const text = collapsed.join("\n").trim();
78
+ let end = text.length;
79
+ while (end > 0 && text[end - 1] === ";") {
80
+ end -= 1;
81
+ }
82
+ return text.slice(0, end).trimEnd();
83
+ }
@@ -0,0 +1,25 @@
1
+ import type { Diagnostic, SchemaObject } from "../core.js";
2
+ import type { AstStatement } from "./ast.js";
3
+ export interface NormalizeResult {
4
+ diagnostics: Diagnostic[];
5
+ sql?: string;
6
+ statements?: AstStatement[];
7
+ }
8
+ /**
9
+ * Canonical-output normalization: the parse tree supaschema already computed
10
+ * is deparsed back to SQL through PostgreSQL's grammar (pgsql-deparser, the
11
+ * pure-TypeScript companion of the installed libpg-query binding). The
12
+ * normalized text is accepted only when reparsing it yields a
13
+ * location-stripped parse tree identical to the original — a deparser
14
+ * infidelity therefore falls back to the author's text with a warning
15
+ * instead of silently changing semantics.
16
+ */
17
+ export declare function normalizeObjectSql(object: SchemaObject, ast: unknown): Promise<NormalizeResult>;
18
+ /**
19
+ * Round-trip fidelity proof over rendered migration SQL: every statement
20
+ * must deparse and reparse back to an identical location-stripped parse
21
+ * tree. This is the always-on telemetry that makes `normalize: "deparse"`
22
+ * trustworthy exactly where it would be used.
23
+ */
24
+ export declare function deparseFidelityDiagnostics(sql: string): Promise<Diagnostic[]>;
25
+ //# sourceMappingURL=normalize-deparse.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"normalize-deparse.d.ts","sourceRoot":"","sources":["../../src/sql/normalize-deparse.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAG3D,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AAM7C,MAAM,WAAW,eAAe;IAC9B,WAAW,EAAE,UAAU,EAAE,CAAC;IAC1B,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,UAAU,CAAC,EAAE,YAAY,EAAE,CAAC;CAC7B;AAED;;;;;;;;GAQG;AACH,wBAAsB,kBAAkB,CACtC,MAAM,EAAE,YAAY,EACpB,GAAG,EAAE,OAAO,GACX,OAAO,CAAC,eAAe,CAAC,CAgC1B;AAED;;;;;GAKG;AACH,wBAAsB,0BAA0B,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,EAAE,CAAC,CAsCnF"}
@@ -0,0 +1,96 @@
1
+ import { deparseSync } from "pgsql-deparser";
2
+ import { diagnostic } from "../diagnostics.js";
3
+ import { sha256, stableJson } from "../hash.js";
4
+ import { astStatements } from "./ast.js";
5
+ import { normalizeSql } from "./identifiers.js";
6
+ import { stripLocations } from "./object-hash.js";
7
+ import { parseSqlAst } from "./parser.js";
8
+ /**
9
+ * Canonical-output normalization: the parse tree supaschema already computed
10
+ * is deparsed back to SQL through PostgreSQL's grammar (pgsql-deparser, the
11
+ * pure-TypeScript companion of the installed libpg-query binding). The
12
+ * normalized text is accepted only when reparsing it yields a
13
+ * location-stripped parse tree identical to the original — a deparser
14
+ * infidelity therefore falls back to the author's text with a warning
15
+ * instead of silently changing semantics.
16
+ */
17
+ export async function normalizeObjectSql(object, ast) {
18
+ let text;
19
+ try {
20
+ text = deparseSync(ast);
21
+ }
22
+ catch (error) {
23
+ return {
24
+ diagnostics: [
25
+ diagnostic("SUPA_NORMALIZE_UNSUPPORTED", "warning", `deparser cannot render ${object.key}; keeping the source text (${errorMessage(error)})`, { file: object.file, ref: object.ref }),
26
+ ],
27
+ };
28
+ }
29
+ const cleaned = normalizeSql(text);
30
+ const reparsed = await parseSqlAst(cleaned, object.file);
31
+ const statements = reparsed.ast === undefined ? [] : astStatements(reparsed.ast, cleaned);
32
+ if (statements.length === 0 || !astEquals(ast, reparsed.ast)) {
33
+ return {
34
+ diagnostics: [
35
+ diagnostic("SUPA_NORMALIZE_FIDELITY", "warning", `deparsed SQL for ${object.key} does not reparse to the same parse tree; keeping the source text`, { file: object.file, ref: object.ref }),
36
+ ],
37
+ };
38
+ }
39
+ return { diagnostics: [], sql: cleaned, statements };
40
+ }
41
+ /**
42
+ * Round-trip fidelity proof over rendered migration SQL: every statement
43
+ * must deparse and reparse back to an identical location-stripped parse
44
+ * tree. This is the always-on telemetry that makes `normalize: "deparse"`
45
+ * trustworthy exactly where it would be used.
46
+ */
47
+ export async function deparseFidelityDiagnostics(sql) {
48
+ const parsed = await parseSqlAst(sql);
49
+ if (parsed.ast === undefined) {
50
+ return [];
51
+ }
52
+ const diagnostics = [];
53
+ for (const statement of astStatements(parsed.ast, sql)) {
54
+ let text;
55
+ try {
56
+ text = deparseSync({
57
+ stmts: [{ stmt: statement.node }],
58
+ version: 170004,
59
+ });
60
+ }
61
+ catch (error) {
62
+ diagnostics.push(diagnostic("SUPA_CHECK_DEPARSE_UNSUPPORTED", "warning", `statement cannot be deparsed for round-trip proof (${errorMessage(error)})`, { statement: statement.text }));
63
+ continue;
64
+ }
65
+ const reparsed = await parseSqlAst(text);
66
+ const second = reparsed.ast === undefined ? [] : astStatements(reparsed.ast, text);
67
+ if (second.length !== 1 || !nodeEquals(statement.node, second[0]?.node)) {
68
+ diagnostics.push(diagnostic("SUPA_CHECK_DEPARSE_MISMATCH", "warning", "statement does not round-trip through the deparser to an identical parse tree", { statement: statement.text }));
69
+ }
70
+ }
71
+ return diagnostics;
72
+ }
73
+ function astEquals(left, right) {
74
+ const leftStatements = statementNodes(left);
75
+ const rightStatements = statementNodes(right);
76
+ if (leftStatements.length !== rightStatements.length) {
77
+ return false;
78
+ }
79
+ return leftStatements.every((node, index) => nodeEquals(node, rightStatements[index]));
80
+ }
81
+ function nodeEquals(left, right) {
82
+ return sha256(stableJson(stripLocations(left))) === sha256(stableJson(stripLocations(right)));
83
+ }
84
+ function statementNodes(ast) {
85
+ if (typeof ast !== "object" || ast === null) {
86
+ return [];
87
+ }
88
+ const stmts = ast.stmts;
89
+ if (!Array.isArray(stmts)) {
90
+ return [];
91
+ }
92
+ return stmts.map((item) => item.stmt);
93
+ }
94
+ function errorMessage(error) {
95
+ return error instanceof Error ? error.message : String(error);
96
+ }
@@ -0,0 +1,5 @@
1
+ import type { ObjectRef } from "../core.js";
2
+ export declare function stripLocations(value: unknown): unknown;
3
+ export declare function astObjectHash(statementNodes: unknown[], key: string, ref: ObjectRef): string;
4
+ export declare function shapeHash(shape: unknown, key: string, ref: ObjectRef): string;
5
+ //# sourceMappingURL=object-hash.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"object-hash.d.ts","sourceRoot":"","sources":["../../src/sql/object-hash.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAK5C,wBAAgB,cAAc,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAetD;AAED,wBAAgB,aAAa,CAAC,cAAc,EAAE,OAAO,EAAE,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,SAAS,GAAG,MAAM,CAE5F;AAED,wBAAgB,SAAS,CAAC,KAAK,EAAE,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,SAAS,GAAG,MAAM,CAE7E"}
@@ -0,0 +1,24 @@
1
+ import { sha256, stableJson } from "../hash.js";
2
+ const strippedKeys = new Set(["if_not_exists", "location", "replace", "stmt_len", "stmt_location"]);
3
+ export function stripLocations(value) {
4
+ if (Array.isArray(value)) {
5
+ return value.map(stripLocations);
6
+ }
7
+ if (value && typeof value === "object") {
8
+ const result = {};
9
+ for (const [key, child] of Object.entries(value)) {
10
+ if (strippedKeys.has(key)) {
11
+ continue;
12
+ }
13
+ result[key] = stripLocations(child);
14
+ }
15
+ return result;
16
+ }
17
+ return value;
18
+ }
19
+ export function astObjectHash(statementNodes, key, ref) {
20
+ return shapeHash(stripLocations(statementNodes), key, ref);
21
+ }
22
+ export function shapeHash(shape, key, ref) {
23
+ return sha256(stableJson({ ast: shape, key, ref }));
24
+ }
@@ -0,0 +1,8 @@
1
+ import type { Diagnostic } from "../core.js";
2
+ export type ParsedSqlAst = {
3
+ ast?: unknown;
4
+ diagnostics: Diagnostic[];
5
+ };
6
+ export declare function parseSql(sql: string, file?: string): Promise<Diagnostic[]>;
7
+ export declare function parseSqlAst(sql: string, file?: string): Promise<ParsedSqlAst>;
8
+ //# sourceMappingURL=parser.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"parser.d.ts","sourceRoot":"","sources":["../../src/sql/parser.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAa7C,MAAM,MAAM,YAAY,GAAG;IACzB,GAAG,CAAC,EAAE,OAAO,CAAC;IACd,WAAW,EAAE,UAAU,EAAE,CAAC;CAC3B,CAAC;AAMF,wBAAsB,QAAQ,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,EAAE,CAAC,CAEhF;AAED,wBAAsB,WAAW,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,CAYnF"}