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,234 @@
1
+ export const fixtureScale = {
2
+ additive: { order: 0, tables: "1 table" },
3
+ "functions-policies": { order: 1, tables: "1 table" },
4
+ realistic: { order: 2, tables: "50 tables" },
5
+ xl: { order: 3, tables: "1,000 tables" },
6
+ xxl: { order: 4, tables: "2,500 tables" },
7
+ };
8
+
9
+ export const theme = {
10
+ accent: "#34d399",
11
+ accentDeep: "#059669",
12
+ amber: "#fbbf24",
13
+ bg: "#0b1220",
14
+ fail: "#f87171",
15
+ failStroke: "#ef4444",
16
+ grid: "#1e293b",
17
+ muted: "#64748b",
18
+ pass: "#34d399",
19
+ passStroke: "#10b981",
20
+ slateBar: "#526079",
21
+ slateBarDeep: "#3b4757",
22
+ subtitle: "#94a3b8",
23
+ text: "#e2e8f0",
24
+ title: "#f8fafc",
25
+ };
26
+
27
+ const fontStack = "ui-sans-serif, system-ui, 'Segoe UI', Helvetica, Arial, sans-serif";
28
+
29
+ export function formatSeconds(ms) {
30
+ const seconds = ms / 1000;
31
+ if (seconds < 10) {
32
+ return `${seconds.toFixed(2)}s`;
33
+ }
34
+ if (seconds < 100) {
35
+ return `${seconds.toFixed(1)}s`;
36
+ }
37
+ return `${Math.round(seconds)}s`;
38
+ }
39
+
40
+ export function isSupaschema(label) {
41
+ return label.startsWith("supaschema");
42
+ }
43
+
44
+ export function isWorkflow(label) {
45
+ return label.endsWith("-workflow");
46
+ }
47
+
48
+ export function logTicks(minMs, maxMs) {
49
+ const candidates = [
50
+ 10, 20, 50, 100, 200, 500, 1000, 2000, 5000, 10_000, 20_000, 50_000, 100_000, 200_000, 500_000,
51
+ 1_000_000,
52
+ ];
53
+ return candidates.filter((value) => value >= minMs && value <= maxMs * 1.05);
54
+ }
55
+
56
+ export function logDomain(values) {
57
+ const min = Math.min(...values);
58
+ const max = Math.max(...values);
59
+ const floor = 10 ** Math.floor(Math.log10(Math.max(1, min)));
60
+ const ceil = 10 ** Math.ceil(Math.log10(Math.max(floor * 10, max)));
61
+ return { ceil, floor };
62
+ }
63
+
64
+ export function logX(value, domain, x0, chartWidth) {
65
+ const span = Math.log10(domain.ceil) - Math.log10(domain.floor);
66
+ return x0 + ((Math.log10(Math.max(1, value)) - Math.log10(domain.floor)) / span) * chartWidth;
67
+ }
68
+
69
+ export function envFooter(environments, fixture) {
70
+ const env =
71
+ (fixture === undefined
72
+ ? environments[0]
73
+ : environments.find((item) => item.fixtures.includes(fixture))) ??
74
+ environments[0] ??
75
+ {};
76
+ const supabaseVersion = env.toolVersions?.supabase
77
+ ? `Supabase CLI ${env.toolVersions.supabase}`
78
+ : undefined;
79
+ const supaschemaVersion = env.toolVersions?.supaschema
80
+ ? `supaschema ${env.toolVersions.supaschema}`
81
+ : undefined;
82
+ return [
83
+ supaschemaVersion,
84
+ supabaseVersion,
85
+ env.node ? `Node ${env.node}` : undefined,
86
+ platformLabel(env.platform, env.arch),
87
+ iterationsLabel(fixture === undefined ? environments : [env]),
88
+ ]
89
+ .filter(Boolean)
90
+ .join(" · ");
91
+ }
92
+
93
+ function iterationsLabel(environments) {
94
+ const counts = [
95
+ ...new Set(environments.map((item) => item.iterations).filter((value) => value > 0)),
96
+ ].sort((left, right) => left - right);
97
+ if (counts.length === 0) {
98
+ return undefined;
99
+ }
100
+ if (counts.length === 1) {
101
+ return `${counts[0]} iteration${counts[0] === 1 ? "" : "s"}`;
102
+ }
103
+ return `${counts[0]}–${counts.at(-1)} iterations per fixture`;
104
+ }
105
+
106
+ function platformLabel(platform, arch) {
107
+ if (platform === "darwin") {
108
+ return arch === "arm64" ? "Apple Silicon" : "macOS";
109
+ }
110
+ return [platform, arch].filter(Boolean).join(" ") || undefined;
111
+ }
112
+
113
+ export function groupedStats(rows) {
114
+ const map = new Map();
115
+ for (const row of rows) {
116
+ const label = row.adapter;
117
+ const bucket = map.get(label) ?? [];
118
+ bucket.push(row.elapsedMs);
119
+ map.set(label, bucket);
120
+ }
121
+ return [...map.entries()]
122
+ .map(([label, values]) => ({
123
+ label,
124
+ median: percentile(values, 0.5),
125
+ p95: percentile(values, 0.95),
126
+ }))
127
+ .sort((left, right) => left.median - right.median || left.label.localeCompare(right.label));
128
+ }
129
+
130
+ export function groupedCorrectness(rows) {
131
+ const map = new Map();
132
+ for (const row of rows) {
133
+ const label = row.adapter;
134
+ const bucket = map.get(label) ?? [];
135
+ bucket.push(row);
136
+ map.set(label, bucket);
137
+ }
138
+ return [...map.entries()]
139
+ .map(([label, values]) => {
140
+ const measured = values.filter((item) => !item.skipped && !item.unsupported);
141
+ const scored = measured.filter((item) => typeof item.outputF1 === "number");
142
+ return {
143
+ f1:
144
+ scored.length > 0
145
+ ? scored.reduce((sum, item) => sum + item.outputF1, 0) / scored.length
146
+ : undefined,
147
+ label,
148
+ match: measured.filter((item) => item.matchesTargetAfterFirstApply).length,
149
+ once: measured.filter((item) => item.appliesOnce).length,
150
+ total: measured.length,
151
+ twice: measured.filter((item) => item.appliesTwice).length,
152
+ };
153
+ })
154
+ .sort(
155
+ (left, right) =>
156
+ Number(isSupaschema(right.label)) - Number(isSupaschema(left.label)) ||
157
+ left.label.localeCompare(right.label),
158
+ );
159
+ }
160
+
161
+ export function percentile(values, percentileValue) {
162
+ const sorted = [...values].sort((left, right) => left - right);
163
+ const index = Math.min(sorted.length - 1, Math.ceil(sorted.length * percentileValue) - 1);
164
+ return sorted[index] ?? 0;
165
+ }
166
+
167
+ export function svgHeader(width, height) {
168
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img">`;
169
+ }
170
+
171
+ export function defs() {
172
+ return `<defs>
173
+ <linearGradient id="supaGradient" x1="0" y1="0" x2="1" y2="0">
174
+ <stop offset="0%" stop-color="${theme.accentDeep}" />
175
+ <stop offset="100%" stop-color="${theme.accent}" />
176
+ </linearGradient>
177
+ <linearGradient id="slateGradient" x1="0" y1="0" x2="1" y2="0">
178
+ <stop offset="0%" stop-color="${theme.slateBarDeep}" />
179
+ <stop offset="100%" stop-color="${theme.slateBar}" />
180
+ </linearGradient>
181
+ </defs>`;
182
+ }
183
+
184
+ export function svgFooter() {
185
+ return "</svg>";
186
+ }
187
+
188
+ export function text(x, y, value, options = {}) {
189
+ const anchor = options.anchor ? ` text-anchor="${options.anchor}"` : "";
190
+ const weight = options.weight ?? "400";
191
+ const fill = options.fill ?? theme.text;
192
+ const size = options.size ?? 12;
193
+ return `<text x="${typeof x === "number" ? x.toFixed(1) : x}" y="${typeof y === "number" ? y.toFixed(1) : y}" fill="${fill}" font-family="${fontStack}" font-size="${size}" font-weight="${weight}"${anchor}>${escapeXml(value)}</text>`;
194
+ }
195
+
196
+ export function chip(x, y, label, kind) {
197
+ const palette = {
198
+ fail: { fill: "rgba(239,68,68,0.12)", stroke: theme.failStroke, text: theme.fail },
199
+ muted: { fill: "rgba(100,116,139,0.12)", stroke: theme.muted, text: theme.subtitle },
200
+ pass: { fill: "rgba(16,185,129,0.14)", stroke: theme.passStroke, text: theme.pass },
201
+ warn: { fill: "rgba(251,191,36,0.12)", stroke: theme.amber, text: theme.amber },
202
+ }[kind];
203
+ return [
204
+ `<rect x="${x}" y="${y}" width="120" height="23" rx="11.5" fill="${palette.fill}" stroke="${palette.stroke}" stroke-opacity="0.55" />`,
205
+ text(x + 60, y + 15.5, label, {
206
+ anchor: "middle",
207
+ fill: palette.text,
208
+ size: 11.5,
209
+ weight: "600",
210
+ }),
211
+ ].join("\n");
212
+ }
213
+
214
+ export function passFailChip(x, y, passed, total) {
215
+ if (passed === total) {
216
+ return chip(x, y, `✓ ${passed}/${total}`, "pass");
217
+ }
218
+ if (passed === 0) {
219
+ return chip(x, y, `✗ 0/${total}`, "fail");
220
+ }
221
+ return chip(x, y, `△ ${passed}/${total}`, "warn");
222
+ }
223
+
224
+ export function truncate(value, length) {
225
+ return value.length > length ? `${value.slice(0, length - 1)}...` : value;
226
+ }
227
+
228
+ export function escapeXml(value) {
229
+ return value
230
+ .replaceAll("&", "&amp;")
231
+ .replaceAll("<", "&lt;")
232
+ .replaceAll(">", "&gt;")
233
+ .replaceAll('"', "&quot;");
234
+ }
@@ -0,0 +1,339 @@
1
+ import {
2
+ chip,
3
+ defs,
4
+ envFooter,
5
+ fixtureScale,
6
+ formatSeconds,
7
+ groupedCorrectness,
8
+ groupedStats,
9
+ isSupaschema,
10
+ logDomain,
11
+ logTicks,
12
+ logX,
13
+ passFailChip,
14
+ percentile,
15
+ svgFooter,
16
+ svgHeader,
17
+ text,
18
+ theme,
19
+ truncate,
20
+ } from "./plot-lib.js";
21
+
22
+ export function renderLatencySvg(rows, fixture, environments) {
23
+ const groups = groupedStats(rows);
24
+ const width = 1200;
25
+ const rowHeight = 34;
26
+ const labelX = 36;
27
+ const x0 = 300;
28
+ const chartWidth = 660;
29
+ const valueX = width - 36;
30
+ const top = 108;
31
+ const height = top + groups.length * rowHeight + 64;
32
+ const domain = logDomain(groups.flatMap((item) => [item.median, item.p95]));
33
+ const ticks = logTicks(domain.floor, domain.ceil);
34
+ const tablesNote = fixtureScale[fixture]?.tables;
35
+ const parts = [
36
+ svgHeader(width, height),
37
+ defs(),
38
+ `<rect width="100%" height="100%" rx="14" fill="${theme.bg}" />`,
39
+ text(labelX, 46, `Diff latency — ${fixture} fixture${tablesNote ? ` (${tablesNote})` : ""}`, {
40
+ fill: theme.title,
41
+ size: 21,
42
+ weight: "700",
43
+ }),
44
+ text(labelX, 70, "Median seconds per diff, log scale · whisker marks p95 · lower is better", {
45
+ fill: theme.subtitle,
46
+ size: 12.5,
47
+ }),
48
+ ];
49
+ const plotBottom = top + groups.length * rowHeight - 10;
50
+ for (const tickValue of ticks) {
51
+ const tickX = logX(tickValue, domain, x0, chartWidth);
52
+ parts.push(
53
+ `<line x1="${tickX.toFixed(1)}" y1="${top - 14}" x2="${tickX.toFixed(1)}" y2="${plotBottom}" stroke="${theme.grid}" stroke-width="1" />`,
54
+ );
55
+ parts.push(
56
+ text(tickX, plotBottom + 22, formatSeconds(tickValue), {
57
+ anchor: "middle",
58
+ fill: theme.muted,
59
+ size: 11,
60
+ }),
61
+ );
62
+ }
63
+ for (const [index, group] of groups.entries()) {
64
+ const y = top + index * rowHeight;
65
+ const supa = isSupaschema(group.label);
66
+ const barFill = supa ? "url(#supaGradient)" : "url(#slateGradient)";
67
+ const barEnd = logX(group.median, domain, x0, chartWidth);
68
+ const p95X = logX(group.p95, domain, x0, chartWidth);
69
+ if (supa) {
70
+ parts.push(`<circle cx="${labelX + 5}" cy="${y + 9}" r="4" fill="${theme.accent}" />`);
71
+ }
72
+ parts.push(
73
+ text(labelX + 18, y + 13, truncate(group.label, 30), {
74
+ fill: supa ? theme.title : theme.text,
75
+ size: 13,
76
+ weight: supa ? "650" : "450",
77
+ }),
78
+ );
79
+ parts.push(
80
+ `<rect x="${x0}" y="${y}" width="${Math.max(2, barEnd - x0).toFixed(1)}" height="18" rx="4" fill="${barFill}" />`,
81
+ );
82
+ parts.push(
83
+ `<line x1="${p95X.toFixed(1)}" y1="${y - 3}" x2="${p95X.toFixed(1)}" y2="${y + 21}" stroke="${theme.title}" stroke-opacity="0.55" stroke-width="2" />`,
84
+ );
85
+ parts.push(
86
+ text(valueX, y + 13, `${formatSeconds(group.median)} · p95 ${formatSeconds(group.p95)}`, {
87
+ anchor: "end",
88
+ fill: supa ? theme.accent : theme.subtitle,
89
+ size: 12,
90
+ weight: supa ? "650" : "450",
91
+ }),
92
+ );
93
+ }
94
+ parts.push(
95
+ text(labelX, height - 22, envFooter(environments, fixture), { fill: theme.muted, size: 11 }),
96
+ svgFooter(),
97
+ );
98
+ return parts.join("\n");
99
+ }
100
+
101
+ export function renderCorrectnessSvg(rows, fixture, environments) {
102
+ const groups = groupedCorrectness(rows);
103
+ const width = 1200;
104
+ const rowHeight = 38;
105
+ const labelX = 36;
106
+ const top = 132;
107
+ const height = top + groups.length * rowHeight + 56;
108
+ const columns = [
109
+ { key: "once", label: "applies once", x: 430 },
110
+ { key: "twice", label: "applies twice", x: 610 },
111
+ { key: "match", label: "matches target", x: 790 },
112
+ { key: "f1", label: "output F1", x: 985 },
113
+ ];
114
+ const tablesNote = fixtureScale[fixture]?.tables;
115
+ const parts = [
116
+ svgHeader(width, height),
117
+ defs(),
118
+ `<rect width="100%" height="100%" rx="14" fill="${theme.bg}" />`,
119
+ text(
120
+ labelX,
121
+ 46,
122
+ `Verification & accuracy — ${fixture} fixture${tablesNote ? ` (${tablesNote})` : ""}`,
123
+ { fill: theme.title, size: 21, weight: "700" },
124
+ ),
125
+ text(
126
+ labelX,
127
+ 70,
128
+ "Each generated migration is applied in one transaction, applied again, and the catalog is fingerprinted against the target.",
129
+ { fill: theme.subtitle, size: 12.5 },
130
+ ),
131
+ text(
132
+ labelX,
133
+ 88,
134
+ "Output F1 scores generated SQL content against the fixture's ground-truth change manifest (1.000 = exact).",
135
+ { fill: theme.subtitle, size: 12.5 },
136
+ ),
137
+ ];
138
+ for (const column of columns) {
139
+ parts.push(
140
+ text(column.x + 60, top - 16, column.label, {
141
+ anchor: "middle",
142
+ fill: theme.muted,
143
+ size: 11.5,
144
+ weight: "600",
145
+ }),
146
+ );
147
+ }
148
+ for (const [index, group] of groups.entries()) {
149
+ const y = top + index * rowHeight;
150
+ const supa = isSupaschema(group.label);
151
+ if (supa) {
152
+ parts.push(`<circle cx="${labelX + 5}" cy="${y + 11}" r="4" fill="${theme.accent}" />`);
153
+ }
154
+ parts.push(
155
+ text(labelX + 18, y + 15, truncate(group.label, 30), {
156
+ fill: supa ? theme.title : theme.text,
157
+ size: 13,
158
+ weight: supa ? "650" : "450",
159
+ }),
160
+ );
161
+ if (group.total === 0) {
162
+ parts.push(chip(columns[0].x, y, "skipped", "muted"));
163
+ continue;
164
+ }
165
+ parts.push(passFailChip(columns[0].x, y, group.once, group.total));
166
+ parts.push(passFailChip(columns[1].x, y, group.twice, group.total));
167
+ parts.push(passFailChip(columns[2].x, y, group.match, group.total));
168
+ if (group.f1 === undefined) {
169
+ parts.push(chip(columns[3].x, y, "—", "muted"));
170
+ } else {
171
+ parts.push(chip(columns[3].x, y, group.f1.toFixed(3), group.f1 >= 0.9995 ? "pass" : "warn"));
172
+ }
173
+ }
174
+ parts.push(
175
+ text(labelX, height - 22, envFooter(environments, fixture), { fill: theme.muted, size: 11 }),
176
+ svgFooter(),
177
+ );
178
+ return parts.join("\n");
179
+ }
180
+
181
+ export function renderScalingSvg(allResults, fixtures, environments, options = {}) {
182
+ const title = options.title ?? "Diff latency vs schema size";
183
+ const subtitle =
184
+ options.subtitle ??
185
+ "Median seconds per diff at each fixture scale, log scale · lower is better";
186
+ const width = 1200;
187
+ const height = 560;
188
+ const left = 96;
189
+ const right = width - 290;
190
+ const top = 116;
191
+ const bottom = height - 86;
192
+ const adapters = [...new Set(allResults.map((item) => item.adapter))].sort(
193
+ (a, b) => Number(isSupaschema(b)) - Number(isSupaschema(a)) || a.localeCompare(b),
194
+ );
195
+ const series = adapters.map((adapter) => ({
196
+ adapter,
197
+ points: fixtures
198
+ .map((fixture, index) => {
199
+ const values = allResults
200
+ .filter(
201
+ (item) =>
202
+ item.adapter === adapter &&
203
+ item.fixture === fixture &&
204
+ !item.skipped &&
205
+ !item.unsupported,
206
+ )
207
+ .map((item) => item.elapsedMs);
208
+ return values.length > 0 ? { index, median: percentile(values, 0.5) } : undefined;
209
+ })
210
+ .filter(Boolean),
211
+ }));
212
+ const allMedians = series.flatMap((item) => item.points.map((point) => point.median));
213
+ const domain = logDomain(allMedians);
214
+ const ticks = logTicks(domain.floor, domain.ceil);
215
+ const xFor = (index) => left + (index / Math.max(1, fixtures.length - 1)) * (right - left);
216
+ const yFor = (value) => {
217
+ const span = Math.log10(domain.ceil) - Math.log10(domain.floor);
218
+ return (
219
+ bottom - ((Math.log10(Math.max(1, value)) - Math.log10(domain.floor)) / span) * (bottom - top)
220
+ );
221
+ };
222
+ const supaColors = [theme.accent, "#22d3ee"];
223
+ const engineColors = ["#94a3b8", "#7c8aa0", "#64748b", "#8896ab", "#566379"];
224
+ let supaIndex = 0;
225
+ let engineIndex = 0;
226
+ const parts = [
227
+ svgHeader(width, height),
228
+ defs(),
229
+ `<rect width="100%" height="100%" rx="14" fill="${theme.bg}" />`,
230
+ text(36, 46, title, { fill: theme.title, size: 21, weight: "700" }),
231
+ text(36, 70, subtitle, { fill: theme.subtitle, size: 12.5 }),
232
+ ];
233
+ for (const tickValue of ticks) {
234
+ const y = yFor(tickValue);
235
+ parts.push(
236
+ `<line x1="${left}" y1="${y.toFixed(1)}" x2="${right}" y2="${y.toFixed(1)}" stroke="${theme.grid}" stroke-width="1" />`,
237
+ );
238
+ parts.push(
239
+ text(left - 12, y + 4, formatSeconds(tickValue), {
240
+ anchor: "end",
241
+ fill: theme.muted,
242
+ size: 11,
243
+ }),
244
+ );
245
+ }
246
+ for (const [index, fixture] of fixtures.entries()) {
247
+ const x = xFor(index);
248
+ parts.push(
249
+ text(x, bottom + 26, fixture, {
250
+ anchor: "middle",
251
+ fill: theme.text,
252
+ size: 12,
253
+ weight: "550",
254
+ }),
255
+ );
256
+ const tables = fixtureScale[fixture]?.tables;
257
+ if (tables) {
258
+ parts.push(text(x, bottom + 44, tables, { anchor: "middle", fill: theme.muted, size: 11 }));
259
+ }
260
+ }
261
+ const endLabels = [];
262
+ for (const item of series) {
263
+ const supa = isSupaschema(item.adapter);
264
+ const color = supa
265
+ ? supaColors[supaIndex++ % supaColors.length]
266
+ : engineColors[engineIndex++ % engineColors.length];
267
+ const path = item.points
268
+ .map(
269
+ (point, order) =>
270
+ `${order === 0 ? "M" : "L"}${xFor(point.index).toFixed(1)},${yFor(point.median).toFixed(1)}`,
271
+ )
272
+ .join(" ");
273
+ parts.push(
274
+ `<path d="${path}" fill="none" stroke="${color}" stroke-width="${supa ? 3 : 2}" stroke-linecap="round" stroke-linejoin="round"${supa ? "" : ' stroke-opacity="0.75"'} />`,
275
+ );
276
+ for (const point of item.points) {
277
+ parts.push(
278
+ `<circle cx="${xFor(point.index).toFixed(1)}" cy="${yFor(point.median).toFixed(1)}" r="${supa ? 4.5 : 3.5}" fill="${color}" />`,
279
+ );
280
+ }
281
+ const last = item.points.at(-1);
282
+ if (last) {
283
+ endLabels.push({
284
+ anchorY: yFor(last.median),
285
+ color,
286
+ label: `${item.adapter} ${formatSeconds(last.median)}`,
287
+ supa,
288
+ y: yFor(last.median),
289
+ });
290
+ }
291
+ }
292
+ parts.push(...renderEndLabels(endLabels, { bottom, right, top }));
293
+ parts.push(
294
+ text(36, height - 22, envFooter(environments, undefined), {
295
+ fill: theme.muted,
296
+ size: 11,
297
+ }),
298
+ svgFooter(),
299
+ );
300
+ return parts.join("\n");
301
+ }
302
+
303
+ function renderEndLabels(endLabels, bounds) {
304
+ const minGap = 18;
305
+ endLabels.sort((left, right) => left.anchorY - right.anchorY);
306
+ for (let index = 1; index < endLabels.length; index += 1) {
307
+ if (endLabels[index].y - endLabels[index - 1].y < minGap) {
308
+ endLabels[index].y = endLabels[index - 1].y + minGap;
309
+ }
310
+ }
311
+ const lastLabel = endLabels.at(-1);
312
+ const overflow = lastLabel ? lastLabel.y - bounds.bottom : 0;
313
+ if (overflow > 0) {
314
+ for (const item of endLabels) {
315
+ item.y -= Math.min(overflow, Math.max(0, item.y - bounds.top));
316
+ }
317
+ for (let index = endLabels.length - 2; index >= 0; index -= 1) {
318
+ if (endLabels[index + 1].y - endLabels[index].y < minGap) {
319
+ endLabels[index].y = endLabels[index + 1].y - minGap;
320
+ }
321
+ }
322
+ }
323
+ const parts = [];
324
+ for (const item of endLabels) {
325
+ if (Math.abs(item.y - item.anchorY) > 4) {
326
+ parts.push(
327
+ `<line x1="${bounds.right + 6}" y1="${item.anchorY.toFixed(1)}" x2="${bounds.right + 24}" y2="${(item.y - 4).toFixed(1)}" stroke="${item.color}" stroke-opacity="0.45" stroke-width="1" />`,
328
+ );
329
+ }
330
+ parts.push(
331
+ text(bounds.right + 28, item.y, item.label, {
332
+ fill: item.supa ? item.color : theme.subtitle,
333
+ size: 12,
334
+ weight: item.supa ? "650" : "450",
335
+ }),
336
+ );
337
+ }
338
+ return parts;
339
+ }