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,348 @@
1
+ import { watch } from "node:fs";
2
+ import { mkdir, open, writeFile } from "node:fs/promises";
3
+ import { dirname, resolve } from "node:path";
4
+ import { performance } from "node:perf_hooks";
5
+ import { defaultMigrationName, resolveMigrationsDir, resolveSourceDefaults, } from "./cli-defaults.js";
6
+ import { colorizeSummaryLine } from "./cli-tools.js";
7
+ import { diagnostic, hasErrors } from "./diagnostics.js";
8
+ import { latestLineage } from "./lineage.js";
9
+ import { planSchemaDiff } from "./planner.js";
10
+ import { renderMigrationSplit } from "./render.js";
11
+ import { extractSourceModel, filterModelBySchemas } from "./source.js";
12
+ import { generateDatabaseTypes } from "./typegen.js";
13
+ import { generateZodSchemas } from "./typegen-zod.js";
14
+ export function registerDiffCommands(program, context) {
15
+ program
16
+ .command("plan")
17
+ .option("--from <source>", "source model before the change (default: database, then git:HEAD)")
18
+ .option("--to <target>", "source model after the change (default: the config schema tree)")
19
+ .option("--schema <names>", "comma-separated schema filter")
20
+ .option("--timing", "print extract/plan phase timings to stderr")
21
+ .description("Print the planned object-level schema diff as JSON (use `diff` to render SQL).")
22
+ .action(async (options) => {
23
+ const config = await context.loadCliConfig();
24
+ const plan = await buildPlan(await withSourceDefaults(options, config, context), config);
25
+ context.printDiagnostics(plan.diagnostics);
26
+ process.stdout.write(`${JSON.stringify(plan, null, 2)}\n`);
27
+ if (hasErrors(plan.diagnostics)) {
28
+ process.exitCode = 2;
29
+ }
30
+ });
31
+ program
32
+ .command("diff")
33
+ .option("--from <source>", "source model before the change (default: database, then git:HEAD)")
34
+ .option("--to <target>", "source model after the change (default: the config schema tree)")
35
+ .option("--out <file>", "output file path or stdout (default: <migrations-dir>/<UTC timestamp>_<name>.sql)")
36
+ .option("--name <snake_case>", "migration name (default: derived from the planned operations)")
37
+ .option("--migrations-dir <dir>", "migrations directory (default: config.migrationsDir)")
38
+ .option("--schema <names>", "comma-separated schema filter")
39
+ .option("--dry-run", "print the migration and target path without writing")
40
+ .option("--json", "print a JSON payload (fingerprint, operations, sql) instead of raw SQL")
41
+ .option("--fail-on-diff", "exit with code 3 when the plan contains operations (CI drift gate)")
42
+ .option("--no-check-chain", "skip the lineage chain gate against pending supaschema migrations in the output directory")
43
+ .option("--summary", "print a drift report (operation and diagnostic counts grouped by kind and schema) before any error exit")
44
+ .option("--write-hints <file>", "write the gated destructive object keys as a hints.destructive skeleton for review (never overwrites)")
45
+ .option("--timing", "print extract/plan/render phase timings to stderr")
46
+ .option("--watch", "re-print the drift summary whenever a dir: source changes (editor loop; implies --dry-run --summary)")
47
+ .description("Render a replay-safe migration from the planned schema diff (zero flags: database/git:HEAD → schema tree → migrations directory).")
48
+ .action(async (options) => {
49
+ const config = await context.loadCliConfig();
50
+ const resolved = await withSourceDefaults(options, config, context);
51
+ if (resolved.watch) {
52
+ await watchDiff(resolved, config, context);
53
+ return;
54
+ }
55
+ await runDiff(resolved, config, context);
56
+ });
57
+ }
58
+ async function withSourceDefaults(options, config, context) {
59
+ const resolved = await resolveSourceDefaults(options, config, () => context.resolveCliDatabaseUrl());
60
+ if (resolved.notice !== undefined) {
61
+ process.stderr.write(resolved.notice);
62
+ }
63
+ return { ...options, from: resolved.from, to: resolved.to };
64
+ }
65
+ async function runDiff(options, config, context) {
66
+ const plan = await buildPlan(options, config);
67
+ context.printDiagnostics(plan.diagnostics);
68
+ if (options.summary) {
69
+ process.stdout.write(renderPlanSummary(plan));
70
+ }
71
+ if (options.writeHints) {
72
+ const keys = [
73
+ ...new Set(plan.operations
74
+ .filter((operation) => operation.blocked && operation.destructive)
75
+ .map((operation) => operation.key)),
76
+ ].sort((left, right) => left.localeCompare(right));
77
+ const hintsPath = resolve(process.cwd(), options.writeHints);
78
+ await writeFile(hintsPath, `${JSON.stringify({ hints: { destructive: keys } }, null, 2)}\n`, {
79
+ flag: "wx",
80
+ });
81
+ process.stderr.write(`wrote ${keys.length} gated object keys to ${hintsPath}; review each before merging into hints.destructive\n`);
82
+ }
83
+ if (hasErrors(plan.diagnostics)) {
84
+ process.exitCode = 2;
85
+ return;
86
+ }
87
+ const renderStart = performance.now();
88
+ const rendered = renderMigrationSplit(plan, { config, version: context.cliVersion });
89
+ if (options.timing) {
90
+ process.stderr.write(`timing: render ${Math.round(performance.now() - renderStart)}ms\n`);
91
+ }
92
+ const migrationsDir = resolveMigrationsDir(options.migrationsDir, config);
93
+ const defaultedOut = options.name === undefined && options.out === undefined;
94
+ if (defaultedOut && plan.operations.length === 0 && !options.json) {
95
+ process.stderr.write("no schema changes\n");
96
+ return;
97
+ }
98
+ const outPath = options.name !== undefined || defaultedOut
99
+ ? resolve(process.cwd(), migrationsDir, `${migrationTimestamp()}_${options.name ?? defaultMigrationName(plan)}.sql`)
100
+ : options.out === "stdout" || options.out === undefined
101
+ ? undefined
102
+ : resolve(process.cwd(), options.out);
103
+ if (outPath !== undefined && options.checkChain) {
104
+ const chainDiagnostics = await checkLineageChain(plan, dirname(outPath));
105
+ if (chainDiagnostics.length > 0) {
106
+ context.printDiagnostics(chainDiagnostics);
107
+ process.exitCode = 2;
108
+ return;
109
+ }
110
+ }
111
+ const concurrentPath = rendered.concurrentSql !== undefined && outPath !== undefined
112
+ ? `${outPath.replace(/\.sql$/u, "")}.concurrent.sql`
113
+ : undefined;
114
+ const payload = options.json
115
+ ? `${JSON.stringify({
116
+ concurrentOut: concurrentPath,
117
+ concurrentSql: rendered.concurrentSql,
118
+ fingerprint: plan.fingerprint,
119
+ operations: plan.operations.map((operation) => ({
120
+ blocked: operation.blocked,
121
+ destructive: operation.destructive,
122
+ key: operation.key,
123
+ kind: operation.kind,
124
+ })),
125
+ out: outPath ?? "stdout",
126
+ sql: rendered.sql,
127
+ }, null, 2)}\n`
128
+ : rendered.concurrentSql !== undefined && outPath === undefined
129
+ ? `${rendered.sql}\n${rendered.concurrentSql}`
130
+ : rendered.sql;
131
+ if (options.dryRun || outPath === undefined) {
132
+ if (outPath !== undefined) {
133
+ process.stderr.write(`dry-run: would write ${outPath}\n`);
134
+ }
135
+ process.stdout.write(payload);
136
+ }
137
+ else {
138
+ try {
139
+ await mkdir(dirname(outPath), { recursive: true });
140
+ await writeFile(outPath, rendered.sql, { flag: "wx" });
141
+ if (rendered.concurrentSql !== undefined && concurrentPath !== undefined) {
142
+ await writeFile(concurrentPath, rendered.concurrentSql, { flag: "wx" });
143
+ }
144
+ }
145
+ catch (error) {
146
+ if (error instanceof Error && "code" in error && error.code === "EEXIST") {
147
+ context.printDiagnostics([
148
+ diagnostic("SUPA_DIFF_OUTPUT_EXISTS", "error", "the output migration file already exists; supaschema never overwrites migrations", {
149
+ file: outPath,
150
+ hint: "Choose a new --out path or --name, or remove the stale file.",
151
+ }),
152
+ ]);
153
+ process.exitCode = 2;
154
+ return;
155
+ }
156
+ throw error;
157
+ }
158
+ process.stdout.write(options.json
159
+ ? payload
160
+ : `${outPath}\n${concurrentPath === undefined ? "" : `${concurrentPath}\n`}`);
161
+ await refreshTypesFile(options.to, config, options.schema);
162
+ }
163
+ if (options.failOnDiff && plan.operations.length > 0) {
164
+ process.exitCode = 3;
165
+ }
166
+ }
167
+ async function refreshTypesFile(toSource, config, schemaFilter) {
168
+ const targets = [
169
+ { generate: generateDatabaseTypes, relative: config.typesFile },
170
+ { generate: generateZodSchemas, relative: config.zodFile },
171
+ ];
172
+ let model;
173
+ for (const target of targets) {
174
+ let handle;
175
+ try {
176
+ handle = await open(resolve(process.cwd(), target.relative), "r+");
177
+ }
178
+ catch (error) {
179
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
180
+ continue;
181
+ }
182
+ throw error;
183
+ }
184
+ try {
185
+ model = model ?? filterModel(await extractSourceModel(toSource, { config }), schemaFilter);
186
+ if (hasErrors(model.diagnostics)) {
187
+ return;
188
+ }
189
+ const generated = await target.generate(model);
190
+ await handle.truncate(0);
191
+ await handle.write(generated, 0);
192
+ process.stderr.write(`types: ${target.relative} refreshed from ${toSource}\n`);
193
+ }
194
+ finally {
195
+ await handle.close();
196
+ }
197
+ }
198
+ }
199
+ /**
200
+ * Editor loop: re-print the drift summary whenever a dir: source changes.
201
+ * Watch never writes files — it forces dry-run summary mode.
202
+ */
203
+ async function watchDiff(options, config, context) {
204
+ const watchedDirs = [options.from, options.to]
205
+ .filter((source) => source.startsWith("dir:"))
206
+ .map((source) => resolve(process.cwd(), source.slice("dir:".length)));
207
+ if (watchedDirs.length === 0) {
208
+ process.stderr.write("--watch requires at least one dir: source to watch\n");
209
+ process.exitCode = 1;
210
+ return;
211
+ }
212
+ const watchedOptions = {
213
+ ...options,
214
+ dryRun: true,
215
+ summary: true,
216
+ watch: false,
217
+ };
218
+ let running = false;
219
+ let queued = false;
220
+ const run = async () => {
221
+ if (running) {
222
+ queued = true;
223
+ return;
224
+ }
225
+ running = true;
226
+ try {
227
+ const startedAt = new Date().toISOString();
228
+ process.stdout.write(`\nwatch: diff at ${startedAt}\n`);
229
+ await runDiff(watchedOptions, config, context);
230
+ process.exitCode = 0;
231
+ }
232
+ catch (error) {
233
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
234
+ }
235
+ finally {
236
+ running = false;
237
+ if (queued) {
238
+ queued = false;
239
+ await run();
240
+ }
241
+ }
242
+ };
243
+ await run();
244
+ let timer;
245
+ for (const dir of watchedDirs) {
246
+ watch(dir, { recursive: true }, () => {
247
+ clearTimeout(timer);
248
+ timer = setTimeout(() => {
249
+ void run();
250
+ }, 250);
251
+ });
252
+ }
253
+ process.stdout.write(`watch: watching ${watchedDirs.join(", ")} (ctrl-c to exit)\n`);
254
+ await new Promise(() => undefined);
255
+ }
256
+ async function buildPlan(options, config) {
257
+ const extractStart = performance.now();
258
+ const from = filterModel(await extractSourceModel(options.from, { config }), options.schema);
259
+ const fromMs = performance.now() - extractStart;
260
+ const toStart = performance.now();
261
+ const to = filterModel(await extractSourceModel(options.to, { config }), options.schema);
262
+ const toMs = performance.now() - toStart;
263
+ const planStart = performance.now();
264
+ const plan = planSchemaDiff(from, to, { config });
265
+ if (options.timing) {
266
+ process.stderr.write(`timing: extract-from ${Math.round(fromMs)}ms · extract-to ${Math.round(toMs)}ms · plan ${Math.round(performance.now() - planStart)}ms\n`);
267
+ }
268
+ return plan;
269
+ }
270
+ export function filterModel(model, schemaFilter) {
271
+ if (!schemaFilter) {
272
+ return model;
273
+ }
274
+ const schemas = new Set(schemaFilter
275
+ .split(",")
276
+ .map((name) => name.trim().toLowerCase())
277
+ .filter(Boolean));
278
+ return filterModelBySchemas(model, schemas);
279
+ }
280
+ function migrationTimestamp() {
281
+ const now = new Date();
282
+ const pad = (value) => String(value).padStart(2, "0");
283
+ return [
284
+ now.getUTCFullYear(),
285
+ pad(now.getUTCMonth() + 1),
286
+ pad(now.getUTCDate()),
287
+ pad(now.getUTCHours()),
288
+ pad(now.getUTCMinutes()),
289
+ pad(now.getUTCSeconds()),
290
+ ].join("");
291
+ }
292
+ async function checkLineageChain(plan, directory) {
293
+ const latest = await latestLineage(directory);
294
+ if (!latest) {
295
+ return [];
296
+ }
297
+ if (latest.from === plan.fromFingerprint && latest.to === plan.toFingerprint) {
298
+ return [
299
+ diagnostic("SUPA_DIFF_LINEAGE_DUPLICATE", "error", "a pending supaschema migration already covers this exact from/to transition", {
300
+ file: latest.file,
301
+ hint: "Apply or remove the pending migration, or pass --no-check-chain to bypass.",
302
+ }),
303
+ ];
304
+ }
305
+ if (latest.to !== plan.fromFingerprint) {
306
+ return [
307
+ diagnostic("SUPA_DIFF_LINEAGE_BROKEN", "error", "the plan's from-state does not continue the newest pending supaschema migration", {
308
+ file: latest.file,
309
+ hint: `Pending migration ends at model ${latest.to.slice(0, 12)}… but this plan starts from ${plan.fromFingerprint.slice(0, 12)}…; diff from the post-migration state (e.g. --from database:<applied-db>) or pass --no-check-chain.`,
310
+ }),
311
+ ];
312
+ }
313
+ return [];
314
+ }
315
+ export function renderPlanSummary(plan) {
316
+ const lines = ["plan summary:"];
317
+ const operationCounts = new Map();
318
+ for (const operation of plan.operations) {
319
+ const label = `${operation.kind} ${operation.ref.kind}${operation.blocked ? " (blocked)" : ""}`;
320
+ const tone = operation.blocked
321
+ ? "blocked"
322
+ : operation.kind === "drop"
323
+ ? "drop"
324
+ : operation.kind === "create"
325
+ ? "create"
326
+ : "plain";
327
+ const entry = operationCounts.get(label) ?? { count: 0, tone };
328
+ entry.count += 1;
329
+ operationCounts.set(label, entry);
330
+ }
331
+ lines.push(` operations: ${plan.operations.length}`);
332
+ for (const [label, entry] of [...operationCounts.entries()].sort()) {
333
+ lines.push(colorizeSummaryLine(` ${entry.count} ${label}`, entry.tone));
334
+ }
335
+ const diagnosticCounts = new Map();
336
+ for (const item of plan.diagnostics) {
337
+ const scope = item.ref
338
+ ? ` ${item.ref.kind}${item.ref.schema ? ` ${item.ref.schema}` : ""}`
339
+ : "";
340
+ const label = `${item.severity.toUpperCase()} ${item.code}${scope}`;
341
+ diagnosticCounts.set(label, (diagnosticCounts.get(label) ?? 0) + 1);
342
+ }
343
+ lines.push(` diagnostics: ${plan.diagnostics.length}`);
344
+ for (const [label, count] of [...diagnosticCounts.entries()].sort()) {
345
+ lines.push(` ${count} ${label}`);
346
+ }
347
+ return `${lines.join("\n")}\n`;
348
+ }
@@ -0,0 +1,9 @@
1
+ import type { Command } from "commander";
2
+ import type { Diagnostic, SupaschemaConfig } from "./core.js";
3
+ export interface ReportCommandContext {
4
+ loadCliConfig: () => Promise<SupaschemaConfig>;
5
+ printDiagnostics: (diagnostics: Diagnostic[]) => void;
6
+ resolveCliDatabaseUrl: (explicit?: string) => Promise<string | undefined>;
7
+ }
8
+ export declare function registerReportCommands(program: Command, context: ReportCommandContext): void;
9
+ //# sourceMappingURL=cli-reports.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli-reports.d.ts","sourceRoot":"","sources":["../src/cli-reports.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAEzC,OAAO,KAAK,EAAE,UAAU,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAO9D,MAAM,WAAW,oBAAoB;IACnC,aAAa,EAAE,MAAM,OAAO,CAAC,gBAAgB,CAAC,CAAC;IAC/C,gBAAgB,EAAE,CAAC,WAAW,EAAE,UAAU,EAAE,KAAK,IAAI,CAAC;IACtD,qBAAqB,EAAE,CAAC,QAAQ,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;CAC3E;AAED,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,oBAAoB,GAAG,IAAI,CAwH5F"}
@@ -0,0 +1,90 @@
1
+ import { resolve } from "node:path";
2
+ import { auditModel, renderAuditReport } from "./audit.js";
3
+ import { renderCorpusReport, runCorpus } from "./corpus.js";
4
+ import { hasErrors } from "./diagnostics.js";
5
+ import { migrationsStatus, renderMigrationsStatus } from "./migrations-status.js";
6
+ import { extractSourceModel } from "./source.js";
7
+ import { syncMigrations } from "./sync.js";
8
+ export function registerReportCommands(program, context) {
9
+ program
10
+ .command("audit")
11
+ .requiredOption("--from <source>", "source to audit against the support matrix")
12
+ .option("--json", "print the report as JSON")
13
+ .description("Report support-matrix coverage: modeled objects and statements outside the contract.")
14
+ .action(async (options) => {
15
+ const config = await context.loadCliConfig();
16
+ const model = await extractSourceModel(options.from, { config });
17
+ const report = auditModel(model);
18
+ process.stdout.write(options.json === true ? `${JSON.stringify(report, null, 2)}\n` : renderAuditReport(report));
19
+ if (!report.supported) {
20
+ process.exitCode = 2;
21
+ }
22
+ });
23
+ program
24
+ .command("migrations")
25
+ .option("--migrations-dir <dir>", "migration files directory", "supabase/migrations")
26
+ .option("--database-url <url>", "target whose applied history to compare (default: SUPASCHEMA_DATABASE_URL, then the local Supabase stack); run once per target to compare local and remote")
27
+ .option("--history-table <schema.table>", "migration history table", undefined)
28
+ .option("--json", "print the report as JSON")
29
+ .description("Reconcile migration files on disk against a target's applied history.")
30
+ .action(async (options) => {
31
+ const databaseUrl = await context.resolveCliDatabaseUrl(options.databaseUrl);
32
+ const { diagnostics, report } = await migrationsStatus({
33
+ directory: resolve(process.cwd(), options.migrationsDir),
34
+ ...(databaseUrl === undefined ? {} : { databaseUrl }),
35
+ ...(options.historyTable === undefined ? {} : { historyTable: options.historyTable }),
36
+ });
37
+ context.printDiagnostics(diagnostics);
38
+ process.stdout.write(options.json === true
39
+ ? `${JSON.stringify(report, null, 2)}\n`
40
+ : renderMigrationsStatus(report));
41
+ if (hasErrors(diagnostics)) {
42
+ process.exitCode = 2;
43
+ }
44
+ });
45
+ program
46
+ .command("corpus")
47
+ .option("--corpus-dir <dir>", "corpus directory", "corpus/supabase-style")
48
+ .option("--database-url <url>", "admin URL for disposable corpus databases (default: SUPASCHEMA_DATABASE_URL, then the local Supabase stack)")
49
+ .option("--json", "print the report as JSON")
50
+ .description("Run the corpus oracle: replay the dirty-real migrations corpus, diff against its tree, apply the reconciliation twice, and require reconvergence to zero.")
51
+ .action(async (options) => {
52
+ const databaseUrl = await context.resolveCliDatabaseUrl(options.databaseUrl);
53
+ if (databaseUrl === undefined) {
54
+ process.stdout.write("corpus: skipped (no database URL resolved)\n");
55
+ return;
56
+ }
57
+ const { diagnostics, report } = await runCorpus({
58
+ corpusDir: resolve(process.cwd(), options.corpusDir),
59
+ databaseUrl,
60
+ });
61
+ context.printDiagnostics(diagnostics);
62
+ process.stdout.write(options.json === true ? `${JSON.stringify(report, null, 2)}\n` : renderCorpusReport(report));
63
+ if (hasErrors(diagnostics)) {
64
+ process.exitCode = 2;
65
+ }
66
+ });
67
+ program
68
+ .command("sync")
69
+ .option("--migrations-dir <dir>", "migration files directory", "supabase/migrations")
70
+ .option("--database-url <url>", "target whose applied history gates the sync (default: SUPASCHEMA_DATABASE_URL, then the local Supabase stack)")
71
+ .option("--local", "apply pending migrations to the target via `supabase migration up`")
72
+ .option("--remote", "push pending migrations to the linked project via `supabase db push`")
73
+ .description("Gate and apply pending migrations: status + replay-safety checks, then the Supabase CLI runs the actual apply/deploy. Dry run without --local/--remote.")
74
+ .action(async (options) => {
75
+ const config = await context.loadCliConfig();
76
+ const databaseUrl = await context.resolveCliDatabaseUrl(options.databaseUrl);
77
+ const result = await syncMigrations({
78
+ config,
79
+ directory: resolve(process.cwd(), options.migrationsDir),
80
+ ...(databaseUrl === undefined ? {} : { databaseUrl }),
81
+ ...(options.local === true ? { local: true } : {}),
82
+ ...(options.remote === true ? { remote: true } : {}),
83
+ });
84
+ context.printDiagnostics(result.diagnostics);
85
+ process.stdout.write(result.report);
86
+ if (hasErrors(result.diagnostics)) {
87
+ process.exitCode = 2;
88
+ }
89
+ });
90
+ }
@@ -0,0 +1,17 @@
1
+ import type { Command } from "commander";
2
+ import type { SupaschemaConfig } from "./config.js";
3
+ import type { Diagnostic } from "./core.js";
4
+ export interface ToolCommandContext {
5
+ loadCliConfig: () => Promise<SupaschemaConfig>;
6
+ printDiagnostics: (diagnostics: Diagnostic[]) => void;
7
+ resolveCliDatabaseUrl: (explicit?: string) => Promise<string | undefined>;
8
+ }
9
+ export declare function registerToolCommands(program: Command, context: ToolCommandContext): void;
10
+ export declare function colorEnabled(): boolean;
11
+ export type SummaryTone = "create" | "drop" | "blocked" | "plain";
12
+ /**
13
+ * Tone comes from the structured operation (kind/blocked), never from
14
+ * re-classifying rendered text.
15
+ */
16
+ export declare function colorizeSummaryLine(line: string, tone: SummaryTone): string;
17
+ //# sourceMappingURL=cli-tools.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli-tools.d.ts","sourceRoot":"","sources":["../src/cli-tools.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAEzC,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AACpD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AAO5C,MAAM,WAAW,kBAAkB;IACjC,aAAa,EAAE,MAAM,OAAO,CAAC,gBAAgB,CAAC,CAAC;IAC/C,gBAAgB,EAAE,CAAC,WAAW,EAAE,UAAU,EAAE,KAAK,IAAI,CAAC;IACtD,qBAAqB,EAAE,CAAC,QAAQ,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;CAC3E;AAED,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,kBAAkB,GAAG,IAAI,CAoFxF;AA8CD,wBAAgB,YAAY,IAAI,OAAO,CAEtC;AAED,MAAM,MAAM,WAAW,GAAG,QAAQ,GAAG,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC;AAElE;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,WAAW,GAAG,MAAM,CAM3E"}
@@ -0,0 +1,136 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { dirname, resolve } from "node:path";
3
+ import { defaultTreeSource } from "./cli-defaults.js";
4
+ import { hasErrors } from "./diagnostics.js";
5
+ import { renderDoctorReport, runDoctor } from "./doctor.js";
6
+ import { extractSourceModel } from "./source.js";
7
+ import { generateDatabaseTypes } from "./typegen.js";
8
+ import { generateZodSchemas } from "./typegen-zod.js";
9
+ export function registerToolCommands(program, context) {
10
+ program
11
+ .command("doctor")
12
+ .option("--database-url <url>", "database URL to probe (default: normal resolution order)")
13
+ .option("--json", "print the report as JSON")
14
+ .description("Diagnose the environment: Node version, parser, config, URL resolution, database reachability, CREATEDB capability, migrations history, and the declarative tree.")
15
+ .action(async (options) => {
16
+ const config = await context.loadCliConfig();
17
+ const report = await runDoctor(config, {
18
+ ...(options.databaseUrl === undefined ? {} : { databaseUrl: options.databaseUrl }),
19
+ });
20
+ process.stdout.write(options.json === true ? `${JSON.stringify(report, null, 2)}\n` : renderDoctorReport(report));
21
+ if (!report.healthy) {
22
+ process.exitCode = 2;
23
+ }
24
+ });
25
+ program
26
+ .command("types")
27
+ .option("--from <source>", "source to type (default: the config schema tree)")
28
+ .option("--out <file|stdout>", "TypeScript output path (default: config.typesFile)")
29
+ .description("Generate Supabase-compatible TypeScript types and Zod validators straight from the schema tree — no database, no introspection, no applied migrations required.")
30
+ .action(async (options) => {
31
+ const config = await context.loadCliConfig();
32
+ const source = options.from ?? defaultTreeSource(config);
33
+ const model = await extractSourceModel(source, { config });
34
+ context.printDiagnostics(model.diagnostics);
35
+ if (hasErrors(model.diagnostics)) {
36
+ process.exitCode = 2;
37
+ return;
38
+ }
39
+ const types = await generateDatabaseTypes(model);
40
+ const target = options.out ?? config.typesFile;
41
+ if (target === "stdout") {
42
+ process.stdout.write(types);
43
+ return;
44
+ }
45
+ const outPath = resolve(process.cwd(), target);
46
+ await mkdir(dirname(outPath), { recursive: true });
47
+ await writeFile(outPath, types);
48
+ const zodPath = resolve(process.cwd(), config.zodFile);
49
+ await mkdir(dirname(zodPath), { recursive: true });
50
+ await writeFile(zodPath, await generateZodSchemas(model));
51
+ process.stdout.write(`${outPath}\n${zodPath}\n`);
52
+ });
53
+ program
54
+ .command("fingerprint")
55
+ .requiredOption("--from <source>", "source to fingerprint")
56
+ .description("Print only the model fingerprint for a source — two sources with equal fingerprints have identical schemas.")
57
+ .action(async (options) => {
58
+ const config = await context.loadCliConfig();
59
+ const model = await extractSourceModel(options.from, { config });
60
+ context.printDiagnostics(model.diagnostics);
61
+ if (hasErrors(model.diagnostics)) {
62
+ process.exitCode = 2;
63
+ return;
64
+ }
65
+ process.stdout.write(`${model.fingerprint}\n`);
66
+ });
67
+ program
68
+ .command("completion")
69
+ .argument("<shell>", "bash, zsh, or fish")
70
+ .description("Print a shell completion script (eval or save it into your shell's completion path).")
71
+ .action((shell) => {
72
+ const script = completionScript(shell, program);
73
+ if (script === undefined) {
74
+ process.stderr.write(`unsupported shell "${shell}"; use bash, zsh, or fish\n`);
75
+ process.exitCode = 1;
76
+ return;
77
+ }
78
+ process.stdout.write(script);
79
+ });
80
+ }
81
+ function completionScript(shell, program) {
82
+ const commands = program.commands.map((command) => command.name()).sort();
83
+ const list = commands.join(" ");
84
+ switch (shell) {
85
+ case "bash":
86
+ return [
87
+ "_supaschema_completions() {",
88
+ ' if [ "$COMP_CWORD" -eq 1 ]; then',
89
+ ` COMPREPLY=($(compgen -W "${list}" -- "\${COMP_WORDS[1]}"))`,
90
+ " fi",
91
+ "}",
92
+ "complete -F _supaschema_completions supaschema",
93
+ "",
94
+ ].join("\n");
95
+ case "zsh":
96
+ return [
97
+ "#compdef supaschema",
98
+ "_supaschema() {",
99
+ " local -a commands",
100
+ ` commands=(${commands.map((name) => `'${name}'`).join(" ")})`,
101
+ " if (( CURRENT == 2 )); then",
102
+ " _describe 'command' commands",
103
+ " fi",
104
+ "}",
105
+ '_supaschema "$@"',
106
+ "",
107
+ ].join("\n");
108
+ case "fish":
109
+ return `${commands
110
+ .map((name) => `complete -c supaschema -n "__fish_use_subcommand" -a ${name}`)
111
+ .join("\n")}\n`;
112
+ default:
113
+ return undefined;
114
+ }
115
+ }
116
+ const esc = String.fromCharCode(27);
117
+ const ansi = {
118
+ green: `${esc}[32m`,
119
+ red: `${esc}[31m`,
120
+ reset: `${esc}[0m`,
121
+ yellow: `${esc}[33m`,
122
+ };
123
+ export function colorEnabled() {
124
+ return process.env.NO_COLOR === undefined && process.stdout.isTTY === true;
125
+ }
126
+ /**
127
+ * Tone comes from the structured operation (kind/blocked), never from
128
+ * re-classifying rendered text.
129
+ */
130
+ export function colorizeSummaryLine(line, tone) {
131
+ if (!colorEnabled() || tone === "plain") {
132
+ return line;
133
+ }
134
+ const color = tone === "blocked" ? ansi.yellow : tone === "drop" ? ansi.red : ansi.green;
135
+ return `${color}${line}${ansi.reset}`;
136
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=cli.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":""}