@intentius/sql-yodeler 0.2.0 → 0.4.0

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 (308) hide show
  1. package/README.md +3 -3
  2. package/dist/apply-steps/index.d.ts +65 -0
  3. package/dist/apply-steps/index.js +106 -0
  4. package/dist/apply-steps/index.js.map +1 -0
  5. package/dist/approval-mode/index.d.ts +78 -0
  6. package/dist/approval-mode/index.js +105 -0
  7. package/dist/approval-mode/index.js.map +1 -0
  8. package/dist/chant/access.d.ts +87 -0
  9. package/dist/chant/access.js +169 -0
  10. package/dist/chant/access.js.map +1 -0
  11. package/dist/chant/build.js +7 -0
  12. package/dist/chant/build.js.map +1 -1
  13. package/dist/chant/drift.d.ts +17 -1
  14. package/dist/chant/drift.js +55 -2
  15. package/dist/chant/drift.js.map +1 -1
  16. package/dist/chant/plan.d.ts +18 -2
  17. package/dist/chant/plan.js +21 -3
  18. package/dist/chant/plan.js.map +1 -1
  19. package/dist/ci/render.d.ts +333 -0
  20. package/dist/ci/render.js +885 -0
  21. package/dist/ci/render.js.map +1 -0
  22. package/dist/ci/replay.d.ts +17 -0
  23. package/dist/ci/replay.js +69 -0
  24. package/dist/ci/replay.js.map +1 -0
  25. package/dist/cleanup/index.d.ts +86 -0
  26. package/dist/cleanup/index.js +207 -0
  27. package/dist/cleanup/index.js.map +1 -0
  28. package/dist/commands/apply.d.ts +113 -0
  29. package/dist/commands/apply.js +370 -30
  30. package/dist/commands/apply.js.map +1 -1
  31. package/dist/commands/approve.d.ts +13 -0
  32. package/dist/commands/approve.js +239 -13
  33. package/dist/commands/approve.js.map +1 -1
  34. package/dist/commands/checkpoint.d.ts +3 -0
  35. package/dist/commands/checkpoint.js +78 -0
  36. package/dist/commands/checkpoint.js.map +1 -0
  37. package/dist/commands/ci.d.ts +3 -0
  38. package/dist/commands/ci.js +130 -0
  39. package/dist/commands/ci.js.map +1 -0
  40. package/dist/commands/cleanup.d.ts +13 -0
  41. package/dist/commands/cleanup.js +138 -0
  42. package/dist/commands/cleanup.js.map +1 -0
  43. package/dist/commands/config.d.ts +3 -0
  44. package/dist/commands/config.js +136 -0
  45. package/dist/commands/config.js.map +1 -0
  46. package/dist/commands/docs.d.ts +11 -0
  47. package/dist/commands/docs.js +108 -0
  48. package/dist/commands/docs.js.map +1 -0
  49. package/dist/commands/drift.d.ts +13 -1
  50. package/dist/commands/drift.js +52 -5
  51. package/dist/commands/drift.js.map +1 -1
  52. package/dist/commands/index.js +16 -0
  53. package/dist/commands/index.js.map +1 -1
  54. package/dist/commands/init.js +1 -1
  55. package/dist/commands/init.js.map +1 -1
  56. package/dist/commands/lint.js +39 -7
  57. package/dist/commands/lint.js.map +1 -1
  58. package/dist/commands/new.js +133 -34
  59. package/dist/commands/new.js.map +1 -1
  60. package/dist/commands/override.js +48 -16
  61. package/dist/commands/override.js.map +1 -1
  62. package/dist/commands/plan.js +47 -6
  63. package/dist/commands/plan.js.map +1 -1
  64. package/dist/commands/report.d.ts +13 -0
  65. package/dist/commands/report.js +121 -0
  66. package/dist/commands/report.js.map +1 -0
  67. package/dist/commands/revert.d.ts +20 -0
  68. package/dist/commands/revert.js +246 -0
  69. package/dist/commands/revert.js.map +1 -0
  70. package/dist/commands/status.js +32 -5
  71. package/dist/commands/status.js.map +1 -1
  72. package/dist/commands/test.d.ts +3 -0
  73. package/dist/commands/test.js +164 -0
  74. package/dist/commands/test.js.map +1 -0
  75. package/dist/config/base.d.ts +69 -0
  76. package/dist/config/base.js +121 -0
  77. package/dist/config/base.js.map +1 -0
  78. package/dist/config/define.d.ts +240 -3
  79. package/dist/config/define.js +4 -0
  80. package/dist/config/define.js.map +1 -1
  81. package/dist/config/index.d.ts +2 -0
  82. package/dist/config/index.js +2 -0
  83. package/dist/config/index.js.map +1 -1
  84. package/dist/config/load.d.ts +1 -1
  85. package/dist/config/load.js +156 -7
  86. package/dist/config/load.js.map +1 -1
  87. package/dist/config/settings.d.ts +22 -1
  88. package/dist/config/settings.js +34 -2
  89. package/dist/config/settings.js.map +1 -1
  90. package/dist/credentials.d.ts +119 -0
  91. package/dist/credentials.js +119 -0
  92. package/dist/credentials.js.map +1 -0
  93. package/dist/exit.js +1 -1
  94. package/dist/exit.js.map +1 -1
  95. package/dist/history/postgres/ddl.d.ts +18 -0
  96. package/dist/history/postgres/ddl.js +27 -2
  97. package/dist/history/postgres/ddl.js.map +1 -1
  98. package/dist/history/postgres/store.d.ts +2 -0
  99. package/dist/history/postgres/store.js +7 -1
  100. package/dist/history/postgres/store.js.map +1 -1
  101. package/dist/history/rows.d.ts +16 -3
  102. package/dist/history/rows.js +30 -1
  103. package/dist/history/rows.js.map +1 -1
  104. package/dist/history/store.d.ts +2 -0
  105. package/dist/history/store.js +2 -2
  106. package/dist/history/store.js.map +1 -1
  107. package/dist/index.d.ts +8 -1
  108. package/dist/index.js +3 -0
  109. package/dist/index.js.map +1 -1
  110. package/dist/init/adopt.js +1 -1
  111. package/dist/init/adopt.js.map +1 -1
  112. package/dist/init/clickhouse-access.d.ts +64 -0
  113. package/dist/init/clickhouse-access.js +211 -0
  114. package/dist/init/clickhouse-access.js.map +1 -0
  115. package/dist/init/live.d.ts +4 -0
  116. package/dist/init/live.js +16 -0
  117. package/dist/init/live.js.map +1 -1
  118. package/dist/lint/access.d.ts +18 -0
  119. package/dist/lint/access.js +34 -0
  120. package/dist/lint/access.js.map +1 -0
  121. package/dist/lint/check.js +38 -6
  122. package/dist/lint/check.js.map +1 -1
  123. package/dist/lint/config.d.ts +7 -1
  124. package/dist/lint/config.js +11 -5
  125. package/dist/lint/config.js.map +1 -1
  126. package/dist/lint/index.d.ts +2 -0
  127. package/dist/lint/index.js +2 -0
  128. package/dist/lint/index.js.map +1 -1
  129. package/dist/lint/naming.d.ts +62 -0
  130. package/dist/lint/naming.js +138 -0
  131. package/dist/lint/naming.js.map +1 -0
  132. package/dist/lint/report.js +1 -1
  133. package/dist/lint/report.js.map +1 -1
  134. package/dist/lint/routines.d.ts +26 -0
  135. package/dist/lint/routines.js +65 -0
  136. package/dist/lint/routines.js.map +1 -0
  137. package/dist/lint/rules.d.ts +51 -6
  138. package/dist/lint/rules.js +23 -6
  139. package/dist/lint/rules.js.map +1 -1
  140. package/dist/lint/schema-checks.d.ts +63 -0
  141. package/dist/lint/schema-checks.js +128 -0
  142. package/dist/lint/schema-checks.js.map +1 -0
  143. package/dist/lint/statements.d.ts +22 -1
  144. package/dist/lint/statements.js +40 -1
  145. package/dist/lint/statements.js.map +1 -1
  146. package/dist/mcp/tools.d.ts +1 -1
  147. package/dist/mcp/tools.js +1 -1
  148. package/dist/mcp/tools.js.map +1 -1
  149. package/dist/migrations/checkpoint.d.ts +45 -0
  150. package/dist/migrations/checkpoint.js +108 -0
  151. package/dist/migrations/checkpoint.js.map +1 -0
  152. package/dist/migrations/checks.d.ts +19 -1
  153. package/dist/migrations/checks.js +15 -0
  154. package/dist/migrations/checks.js.map +1 -1
  155. package/dist/migrations/create.d.ts +13 -4
  156. package/dist/migrations/create.js +24 -7
  157. package/dist/migrations/create.js.map +1 -1
  158. package/dist/migrations/document.d.ts +10 -0
  159. package/dist/migrations/document.js +1 -0
  160. package/dist/migrations/document.js.map +1 -1
  161. package/dist/migrations/index.d.ts +2 -0
  162. package/dist/migrations/index.js +2 -0
  163. package/dist/migrations/index.js.map +1 -1
  164. package/dist/migrations/routines.d.ts +21 -0
  165. package/dist/migrations/routines.js +30 -0
  166. package/dist/migrations/routines.js.map +1 -0
  167. package/dist/notify/index.d.ts +124 -0
  168. package/dist/notify/index.js +210 -0
  169. package/dist/notify/index.js.map +1 -0
  170. package/dist/notify/verify.d.ts +53 -0
  171. package/dist/notify/verify.js +71 -0
  172. package/dist/notify/verify.js.map +1 -0
  173. package/dist/pr/model.d.ts +7 -1
  174. package/dist/pr/model.js +29 -7
  175. package/dist/pr/model.js.map +1 -1
  176. package/dist/pr/render.d.ts +4 -1
  177. package/dist/pr/render.js +49 -9
  178. package/dist/pr/render.js.map +1 -1
  179. package/dist/pr/tenants.d.ts +37 -0
  180. package/dist/pr/tenants.js +138 -0
  181. package/dist/pr/tenants.js.map +1 -0
  182. package/dist/probe.d.ts +27 -0
  183. package/dist/probe.js +106 -0
  184. package/dist/probe.js.map +1 -0
  185. package/dist/project.d.ts +35 -2
  186. package/dist/project.js +62 -2
  187. package/dist/project.js.map +1 -1
  188. package/dist/replay/clickhouse.d.ts +31 -0
  189. package/dist/replay/clickhouse.js +103 -6
  190. package/dist/replay/clickhouse.js.map +1 -1
  191. package/dist/replay/replay.d.ts +14 -3
  192. package/dist/replay/replay.js +62 -31
  193. package/dist/replay/replay.js.map +1 -1
  194. package/dist/replay/report.js +1 -1
  195. package/dist/replay/report.js.map +1 -1
  196. package/dist/report/audit.d.ts +138 -0
  197. package/dist/report/audit.js +219 -0
  198. package/dist/report/audit.js.map +1 -0
  199. package/dist/report/jsonl.d.ts +21 -0
  200. package/dist/report/jsonl.js +52 -0
  201. package/dist/report/jsonl.js.map +1 -0
  202. package/dist/report/model.d.ts +68 -0
  203. package/dist/report/model.js +127 -0
  204. package/dist/report/model.js.map +1 -0
  205. package/dist/report/render.d.ts +18 -0
  206. package/dist/report/render.js +127 -0
  207. package/dist/report/render.js.map +1 -0
  208. package/dist/schema-docs/render.d.ts +49 -0
  209. package/dist/schema-docs/render.js +359 -0
  210. package/dist/schema-docs/render.js.map +1 -0
  211. package/dist/schemas.d.ts +7 -2
  212. package/dist/schemas.js +7 -2
  213. package/dist/schemas.js.map +1 -1
  214. package/dist/sources/ddl.d.ts +48 -0
  215. package/dist/sources/ddl.js +53 -0
  216. package/dist/sources/ddl.js.map +1 -0
  217. package/dist/sources/index.d.ts +69 -0
  218. package/dist/sources/index.js +170 -0
  219. package/dist/sources/index.js.map +1 -0
  220. package/dist/steps/backfill.d.ts +40 -5
  221. package/dist/steps/backfill.js +128 -8
  222. package/dist/steps/backfill.js.map +1 -1
  223. package/dist/steps/index.d.ts +19 -1
  224. package/dist/steps/index.js +34 -7
  225. package/dist/steps/index.js.map +1 -1
  226. package/dist/steps/kinds.d.ts +42 -4
  227. package/dist/steps/kinds.js +88 -14
  228. package/dist/steps/kinds.js.map +1 -1
  229. package/dist/steps/postgres-migration.d.ts +38 -25
  230. package/dist/steps/postgres-migration.js +49 -32
  231. package/dist/steps/postgres-migration.js.map +1 -1
  232. package/dist/steps/rebuild.d.ts +15 -6
  233. package/dist/steps/rebuild.js +12 -9
  234. package/dist/steps/rebuild.js.map +1 -1
  235. package/dist/tenants/index.d.ts +41 -0
  236. package/dist/tenants/index.js +121 -0
  237. package/dist/tenants/index.js.map +1 -0
  238. package/dist/tenants/target.d.ts +23 -0
  239. package/dist/tenants/target.js +29 -0
  240. package/dist/tenants/target.js.map +1 -0
  241. package/dist/tenants/wave.d.ts +128 -0
  242. package/dist/tenants/wave.js +254 -0
  243. package/dist/tenants/wave.js.map +1 -0
  244. package/dist/testing/define.d.ts +62 -0
  245. package/dist/testing/define.js +32 -0
  246. package/dist/testing/define.js.map +1 -0
  247. package/dist/testing/files.d.ts +5 -0
  248. package/dist/testing/files.js +18 -0
  249. package/dist/testing/files.js.map +1 -0
  250. package/dist/testing/report.d.ts +14 -0
  251. package/dist/testing/report.js +83 -0
  252. package/dist/testing/report.js.map +1 -0
  253. package/dist/testing/run.d.ts +104 -0
  254. package/dist/testing/run.js +328 -0
  255. package/dist/testing/run.js.map +1 -0
  256. package/dist/versioned/approval.d.ts +6 -0
  257. package/dist/versioned/approval.js +11 -5
  258. package/dist/versioned/approval.js.map +1 -1
  259. package/dist/versioned/checks.d.ts +8 -2
  260. package/dist/versioned/checks.js +34 -13
  261. package/dist/versioned/checks.js.map +1 -1
  262. package/dist/versioned/driver.d.ts +2 -2
  263. package/dist/versioned/engine.d.ts +16 -0
  264. package/dist/versioned/engine.js +20 -6
  265. package/dist/versioned/engine.js.map +1 -1
  266. package/dist/versioned/explain.d.ts +2 -0
  267. package/dist/versioned/explain.js +3 -1
  268. package/dist/versioned/explain.js.map +1 -1
  269. package/dist/versioned/index.d.ts +1 -0
  270. package/dist/versioned/index.js +1 -0
  271. package/dist/versioned/index.js.map +1 -1
  272. package/dist/versioned/op.d.ts +6 -1
  273. package/dist/versioned/op.js +7 -2
  274. package/dist/versioned/op.js.map +1 -1
  275. package/dist/versioned/policy.d.ts +33 -2
  276. package/dist/versioned/policy.js +59 -8
  277. package/dist/versioned/policy.js.map +1 -1
  278. package/dist/versioned/report.js +9 -0
  279. package/dist/versioned/report.js.map +1 -1
  280. package/dist/versioned/revert.d.ts +128 -0
  281. package/dist/versioned/revert.js +251 -0
  282. package/dist/versioned/revert.js.map +1 -0
  283. package/dist/versioned/state.d.ts +25 -0
  284. package/dist/versioned/state.js +37 -4
  285. package/dist/versioned/state.js.map +1 -1
  286. package/dist/waves/index.d.ts +224 -0
  287. package/dist/waves/index.js +353 -0
  288. package/dist/waves/index.js.map +1 -0
  289. package/dist/waves/promotion.d.ts +56 -0
  290. package/dist/waves/promotion.js +108 -0
  291. package/dist/waves/promotion.js.map +1 -0
  292. package/dist/waves/record.d.ts +34 -0
  293. package/dist/waves/record.js +20 -0
  294. package/dist/waves/record.js.map +1 -0
  295. package/package.json +11 -7
  296. package/schemas/apply.schema.json +162 -3
  297. package/schemas/checkpoint.schema.json +19 -0
  298. package/schemas/cleanup.schema.json +40 -0
  299. package/schemas/drift.schema.json +26 -1
  300. package/schemas/event.schema.json +36 -0
  301. package/schemas/lint-rules.schema.json +4 -0
  302. package/schemas/lint.schema.json +4 -0
  303. package/schemas/new.schema.json +41 -3
  304. package/schemas/plan.schema.json +157 -0
  305. package/schemas/report.schema.json +114 -0
  306. package/schemas/revert.schema.json +69 -0
  307. package/schemas/status.schema.json +64 -3
  308. package/schemas/test.schema.json +41 -0
@@ -1,16 +1,24 @@
1
1
  import { parseArgs } from "node:util";
2
+ import { accessReview, clickhouseAccessRefusal, renderAccessReview } from "../chant/access.js";
2
3
  import { guarded } from "../env-args.js";
3
4
  import { loadProject, resolveEnvironment, YodelError } from "../project.js";
4
5
  import { buildSchema } from "../chant/build.js";
5
- import { declaredChanges, planEnvironment, planExitCode, planJson, renderPlan, PLAN_NEEDS_OP } from "../chant/plan.js";
6
+ import { declaredChanges, planEnvironment, planExitCode, planJson, renderPlan, unreadableOf, PLAN_NEEDS_OP } from "../chant/plan.js";
6
7
  import { findApplyOp, GATED, runApplyOp } from "../chant/apply.js";
7
8
  import { runnable } from "../chant/runnable.js";
8
9
  import { migrationSilences } from "../lint/index.js";
9
10
  import { askedAgainLine, describeMove, explainAgainst, latestApproval } from "../versioned/explain.js";
10
- import { declarativeTopologyRefusal, LOCK_WAIT_ENV, parseDuration } from "../config/settings.js";
11
+ import { declarativeAccessRefusal, declarativeTopologyRefusal, LOCK_WAIT_ENV, parseDuration } from "../config/settings.js";
12
+ import { policyAtBase } from "../config/base.js";
11
13
  import { newerCommit, STAND_DOWN_ENV, standDownFromEnv, standDownLine } from "../versioned/stand-down.js";
12
14
  import { describeServer, probeServer } from "../server.js";
13
- import { ALLOW_OUT_OF_ORDER_ENV, APPROVED_PLAN_ENV, checksDue, checksRefusal, describeResults, judgePlan, policyRefusal, runChecks, currentDigest, executeApply, findMigrateOp, gitLedger, verifyApproval, hasMigrations, inspect, readChain, recordStepExit, closeVersioned, openVersioned, renderStatus, runMigrateOp, selectToApply, statusJson, statusReport, } from "../versioned/index.js";
15
+ import { eventFromRun, sendEvent } from "../notify/index.js";
16
+ import { EXIT_OUTSTANDING, EXIT_REFUSED } from "../exit.js";
17
+ import { describeStep, heldSteps, runApplySteps, stepsAtBase, stepsRefusal } from "../apply-steps/index.js";
18
+ import { migrationsApprovalAtBase, portFor } from "../approval-mode/index.js";
19
+ import { enforcePromotion, forecastWave, planWave, RECORD_PLANS_ENV, readWavePlan, verifyWaveDecision, WavePlanMoved, WAVES_SPEC_FILE, writeWavePlan } from "../waves/index.js";
20
+ import { applySlot, splitSlot, writeSlotPlan } from "../tenants/wave.js";
21
+ import { ALLOW_OUT_OF_ORDER_ENV, APPROVED_PLAN_ENV, checksDue, checksRefusal, describeResults, judgePlan, judgeRevert, policyRefusal, runChecks, currentDigest, currentRevert, executeApply, executeRevert, revertFromEnv, findMigrateOp, gitLedger, verifyApproval, hasMigrations, inspect, readChain, recordStepExit, closeVersioned, openVersioned, renderStatus, runMigrateOp, selectToApply, statusJson, statusReport, } from "../versioned/index.js";
14
22
  /** When the gate's newest approval does not hold: what moved since (or that approval was asked for again), in one line. */
15
23
  async function movedSinceApproval(project, venv, ordered, op, digest) {
16
24
  try {
@@ -33,7 +41,18 @@ async function movedSinceApproval(project, venv, ordered, op, digest) {
33
41
  return undefined;
34
42
  }
35
43
  }
44
+ /** The document `yodel apply --json` prints (schemas/apply.schema.json). */
45
+ export function applyJson(doc) {
46
+ if (doc.mode === "versioned")
47
+ return { ...statusJson(doc.report), mode: "versioned", ...doc.outcome };
48
+ if (doc.mode === "execute")
49
+ return { environment: doc.environment, status: doc.result.stoodDown ? "stood-down" : "applied", planDigest: doc.planDigest, ...(doc.wavePlanDigest ? { wavePlanDigest: doc.wavePlanDigest } : {}), ...doc.result };
50
+ if (doc.mode === "wave-plan")
51
+ return { ...doc.plan };
52
+ return { environment: doc.environment, dialect: doc.dialect, ...(doc.server ? { server: doc.server } : {}), op: doc.op, ...doc.outcome };
53
+ }
36
54
  const usage = "usage: yodel apply <env> [--json] [--dir <project>] [--op <name>] [--allow-out-of-order] [--plan] [--lock-wait <duration>] [--stand-down]";
55
+ const waveModes = ["wave-plan", "wave-apply"];
37
56
  const help = `${usage}
38
57
 
39
58
  Two paths, chosen by the project:
@@ -75,6 +94,14 @@ Two paths, chosen by the project:
75
94
  statement. One that fails refuses (exit 4), naming it, and nothing of that
76
95
  migration is applied.
77
96
 
97
+ <env> can be one tenant of a tenant set, <env>/<tenant> (yodel.config.ts
98
+ environments.<env>.tenants): the environment's profile on the tenant's
99
+ database or schema, each statement with the template's name replaced by
100
+ the tenant's, and a history of its own. A tenant set is applied tenant by
101
+ tenant, or by its wave, which gates once for every tenant. The wave of a
102
+ set given by a query runs slots, <env>/@<i>of<n>, each every tenant the
103
+ query lists when it runs whose name hashes to it.
104
+
78
105
  - Declarative: no migrations. Plans the declared schema against the live
79
106
  database, then runs the project's ApplyOp for the environment behind its
80
107
  plan-bound gate.
@@ -103,6 +130,16 @@ The migrations Op runs two steps of its own, not for direct use:
103
130
  ${APPROVED_PLAN_ENV} and chant's gate ledger holds an approval
104
131
  of the Op's gate for it (the Op's Apply step)
105
132
 
133
+ The apply pipeline's wave jobs (chant run wave, ${WAVES_SPEC_FILE}) run two more:
134
+ --wave-plan <file> write the environment's wave plan to <file>: the
135
+ plan digest, the gate policy at the base commit, and
136
+ whether a pending step is destructive
137
+ --wave-apply <file> apply the plan in <file> under the lock, if it is
138
+ still the plan now and chant's decision for the
139
+ wave lets it through (an approval of the wave's
140
+ digest, or a policy at the base commit that needs none)
141
+ --spec <file> the waves spec (default ${WAVES_SPEC_FILE})
142
+
106
143
  Exit codes:
107
144
  0 applied, or nothing to apply, or (--stand-down) stood down for a newer
108
145
  commit, applying nothing
@@ -114,8 +151,11 @@ Exit codes:
114
151
  3 stopped at the gate, waiting for approval of this plan
115
152
  4 versioned: refused, nothing applied: a checksum mismatch, an applied
116
153
  migration gone from the directory, an out-of-order migration
117
- without --allow-out-of-order, a pre-migration check that failed, or a
118
- policy rule that denies the plan with no override
154
+ without --allow-out-of-order, a pre-migration check that failed, a
155
+ policy rule that denies the plan with no override, or a pending
156
+ migration the environment this one requires (yodel.config.ts
157
+ waves[].requires, default the wave before) does not record as applied
158
+ with the same checksum
119
159
  5 versioned: another apply holds the lock (after --lock-wait, if given)
120
160
  1 anything else failed: a statement (the history records where; rerun to
121
161
  resume), a plan that moved since approval, a bad config
@@ -137,6 +177,9 @@ function parseApplyArgs(args) {
137
177
  execute: { type: "boolean" },
138
178
  "lock-wait": { type: "string" },
139
179
  "stand-down": { type: "boolean" },
180
+ "wave-plan": { type: "string" },
181
+ "wave-apply": { type: "string" },
182
+ spec: { type: "string" },
140
183
  },
141
184
  });
142
185
  }
@@ -148,7 +191,8 @@ function parseApplyArgs(args) {
148
191
  if (env === undefined || rest.length > 0)
149
192
  throw new YodelError(usage);
150
193
  const flag = (k) => values[k] === true;
151
- const modes = ["plan", "digest", "execute"].filter(flag);
194
+ const str = (k) => (typeof values[k] === "string" ? values[k] : undefined);
195
+ const modes = [...["plan", "digest", "execute"].filter(flag), ...waveModes.filter((k) => str(k) !== undefined)];
152
196
  if (modes.length > 1)
153
197
  throw new YodelError(`--${modes.join(" and --")} cannot be combined\n${usage}`);
154
198
  return {
@@ -162,6 +206,9 @@ function parseApplyArgs(args) {
162
206
  execute: flag("execute"),
163
207
  lockWaitMs: typeof values["lock-wait"] === "string" ? parseDuration(values["lock-wait"], "--lock-wait") : undefined,
164
208
  standDown: flag("stand-down") || standDownFromEnv(process.env),
209
+ wavePlan: str("wave-plan"),
210
+ waveApply: str("wave-apply"),
211
+ spec: str("spec") ?? WAVES_SPEC_FILE,
165
212
  };
166
213
  }
167
214
  /** `yodel apply <env>`; the exit codes are in `help`. */
@@ -173,13 +220,18 @@ export const apply = {
173
220
  const code = await guarded("apply", io, args, help, async () => {
174
221
  const a = parseApplyArgs(args);
175
222
  const project = await loadProject(a.dir);
223
+ if (splitSlot(a.env))
224
+ return slotWave(project, a, io);
176
225
  const env = resolveEnvironment(project, a.env);
226
+ if (!env.tenant && project.yodel?.config.environments?.[env.name]?.tenants) {
227
+ throw new YodelError(`${env.name} is a tenant set (yodel.config.ts environments.${env.name}.tenants): apply a tenant, yodel apply ${env.name}/<tenant>, or every tenant through ${env.name}'s wave in the apply pipeline`);
228
+ }
177
229
  if (hasMigrations(project))
178
230
  return versionedApply(project, env, a, io);
179
- if (a.plan || a.digest || a.execute || a.allowOutOfOrder || a.lockWaitMs !== undefined || args.includes("--stand-down")) {
180
- throw new YodelError(`--plan, --digest, --execute, --allow-out-of-order, --lock-wait and --stand-down are for versioned migrations, and ${project.dir} has no migrations/ directory with a migration`);
231
+ if (a.plan || a.digest || a.execute || a.allowOutOfOrder || a.lockWaitMs !== undefined || args.includes("--stand-down") || a.wavePlan !== undefined || a.waveApply !== undefined) {
232
+ throw new YodelError(`--plan, --digest, --execute, --wave-plan, --wave-apply, --allow-out-of-order, --lock-wait and --stand-down are for versioned migrations, and ${project.dir} has no migrations/ directory with a migration`);
181
233
  }
182
- const refusal = project.dialect === "clickhouse" ? declarativeTopologyRefusal(project, env.name) : undefined;
234
+ const refusal = project.dialect === "clickhouse" ? declarativeTopologyRefusal(project, env.name) : declarativeAccessRefusal(project, env.name);
183
235
  if (refusal)
184
236
  throw refusal;
185
237
  return declarativeApply(project, env, a, io);
@@ -189,45 +241,242 @@ export const apply = {
189
241
  return code;
190
242
  },
191
243
  };
244
+ /**
245
+ * A slot of a tenant set's wave read at run time (#56), `<env>/@<i>of<n>`:
246
+ * every tenant the set's query lists now that hashes to the slot, planned
247
+ * or applied each against its own history and lock.
248
+ */
249
+ async function slotWave(project, a, io) {
250
+ if (!hasMigrations(project))
251
+ throw new YodelError(`${a.env} is a slot of a tenant set's wave, for versioned migrations, and ${project.dir} has no migrations/ directory with a migration`);
252
+ const ordered = readChain(project);
253
+ const log = (l) => { io.err(`${l}\n`); };
254
+ if (a.wavePlan !== undefined) {
255
+ const forReview = process.env[RECORD_PLANS_ENV] === "1";
256
+ const plan = await writeSlotPlan(project, a.env, ordered, a.wavePlan, { allowOutOfOrder: a.allowOutOfOrder, forReview }, log);
257
+ const forecast = forReview ? undefined : await forecastWave(process.cwd(), a.spec, plan);
258
+ if (forecast?.waits) {
259
+ const set = splitSlot(a.env)?.env ?? a.env;
260
+ log(`${set}: wave ${String(forecast.wave)} waits for an approval of ${forecast.digest} (gate ${forecast.policy} at ${forecast.base.slice(0, 12)}${forecast.approval === "ledger" ? "" : `, approval ${forecast.approval}`}) for every tenant. Approve it with yodel approve ${set}${forecast.approval === "sealed" ? " --sign" : ""}, or: ${forecast.approve}`);
261
+ }
262
+ if (a.json)
263
+ io.out(`${JSON.stringify(plan, null, 2)}\n`);
264
+ return 0;
265
+ }
266
+ if (a.waveApply !== undefined) {
267
+ return applySlot(a.env, a.waveApply, (tenant, file, decidedAs) => versionedApply(project, resolveEnvironment(project, tenant), { ...a, env: tenant, waveApply: file, decidedAs }, io), log);
268
+ }
269
+ throw new YodelError(`${a.env} is a slot of ${splitSlot(a.env)?.env ?? ""}'s wave, which chant run wave plans and applies with --wave-plan and --wave-apply; apply a tenant with yodel apply <env>/<tenant>`);
270
+ }
192
271
  async function versionedApply(project, env, a, io) {
193
272
  const ordered = readChain(project);
194
273
  const venv = await openVersioned(project, env);
274
+ const outcome = {};
275
+ let code;
195
276
  try {
196
- return await versionedApplyWith(project, env, a, io, ordered, venv);
277
+ code = await versionedApplyWith(project, env, a, io, ordered, venv, outcome);
197
278
  }
198
- finally {
279
+ catch (e) {
280
+ // The wave modes run inside chant run wave, which keeps no outcome of its own: a refusal or failure there is told here (#87).
281
+ if (a.wavePlan !== undefined || a.waveApply !== undefined)
282
+ outcome.doc = waveFailure(env.name, e);
199
283
  await closeVersioned(venv);
284
+ if (outcome.doc)
285
+ await sendEvent(project.yodel?.config.notify, eventFromRun("apply", outcome.doc), { io, verb: "apply" });
286
+ throw e;
200
287
  }
288
+ await closeVersioned(venv);
289
+ // Waiting, refused or failed: the webhook in yodel.config.ts notify hears of it (#61).
290
+ if (outcome.doc)
291
+ await sendEvent(project.yodel?.config.notify, eventFromRun("apply", outcome.doc), { io, verb: "apply" });
292
+ return code;
293
+ }
294
+ /** The outcome a wave mode's error is: refused (exit 4, or 3 for a wave with no decision that lets it through; nothing applied) or failed. */
295
+ export function waveFailure(environment, e) {
296
+ const message = e instanceof Error ? e.message : String(e);
297
+ const exitCode = e instanceof YodelError ? e.exitCode : 1;
298
+ return exitCode === EXIT_REFUSED || exitCode === EXIT_OUTSTANDING ? { environment, status: "refused", refusal: { message } } : { environment, status: "failed", exitCode, reason: message };
201
299
  }
202
- async function versionedApplyWith(project, env, a, io, ordered, venv) {
300
+ async function versionedApplyWith(project, env, a, io, ordered, venv, outcome = {}) {
301
+ // yodel revert (#55) runs this Op with the migration to revert in the environment: its Plan and Apply steps plan and run that instead.
302
+ const revert = a.digest || a.execute ? revertFromEnv(process.env, project.dir) : undefined;
303
+ if (revert && a.digest) {
304
+ io.out(`${(await currentRevert(venv, ordered, revert.id, revert.step)).digest}\n`);
305
+ return 0;
306
+ }
307
+ if (revert && a.execute) {
308
+ const approved = process.env[APPROVED_PLAN_ENV];
309
+ if (!approved)
310
+ throw new YodelError(`--execute reverts only the plan its Op's gate approved, and ${APPROVED_PLAN_ENV} is not set; run yodel revert ${env.name} ${revert.id}`);
311
+ const op = await findMigrateOp(project, env, a.op);
312
+ const ledger = await migrationsLedger(project, env.name, io);
313
+ // The policy over the revert (#97), as the base commit has it (#73), as yodel revert judged it before the gate.
314
+ const { policy: basePolicy, note: policyNote } = await policyAtBase(project);
315
+ if (policyNote)
316
+ io.err(`${policyNote}\n`);
317
+ const plan = await executeRevert(venv, ordered, {
318
+ id: revert.id,
319
+ step: revert.step,
320
+ approvedDigest: approved,
321
+ ...(a.lockWaitMs !== undefined ? { lockWaitMs: a.lockWaitMs } : {}),
322
+ approve: async (digest) => {
323
+ const by = await verifyApproval(ledger, op, digest);
324
+ io.err(`approval: ${op.name} / ${op.gate} for ${digest}, by ${by.resolvedBy} at ${by.timestamp}\n`);
325
+ },
326
+ ...(basePolicy
327
+ ? {
328
+ policy: async (digest, planned) => {
329
+ const results = await judgeRevert({ policy: basePolicy, environment: env.name, dialect: project.dialect, plan: planned, digest, ledger: () => Promise.resolve({ port: ledger, op }) });
330
+ const refusal = policyRefusal(results, env.name, digest, "under the lock", { id: revert.id, step: revert.step?.file });
331
+ if (refusal)
332
+ throw refusal;
333
+ for (const line of describeResults(results))
334
+ io.err(`${line}\n`);
335
+ return results.flatMap((r) => (r.override ? [r.override] : []));
336
+ },
337
+ }
338
+ : {}),
339
+ log: (l) => { io.err(`${l}\n`); },
340
+ });
341
+ io.out(`Reverted: ${plan.migration.id}, back to ${plan.to ?? "an empty schema"}\n`);
342
+ return 0;
343
+ }
203
344
  if (a.digest) {
204
345
  const { status } = await inspect(venv, ordered);
205
346
  const toApply = selectToApply(status, a.allowOutOfOrder);
206
347
  io.out(`${await currentDigest(venv, status, toApply)}\n`);
207
348
  return 0;
208
349
  }
350
+ if (a.wavePlan !== undefined) {
351
+ // In the pull request job that records the plans a review approves (#48), the wave as it will be after the merge.
352
+ const forReview = process.env[RECORD_PLANS_ENV] === "1";
353
+ const plan = await writeWavePlan(project, venv, ordered, a.wavePlan, { allowOutOfOrder: a.allowOutOfOrder, forReview });
354
+ io.err(`${env.name}: ${plan.empty ? "nothing to apply" : `${String(plan.pending.length)} pending (${plan.pending.join(", ")})`}; gate ${plan.gate} at ${plan.base.slice(0, 12)}` +
355
+ `${plan.destructive ? `; destructive: ${plan.destructiveSteps.map((d) => `${d.migration} step ${String(d.step)} (${d.why})`).join(", ")}` : ""}; plan ${plan.planDigest}\n`);
356
+ // The before steps (#64): the SQL checks as they ran, and each command as one that will run under the lock (#107).
357
+ for (const o of plan.steps ?? [])
358
+ io.err(`${describeStep(o)}\n`);
359
+ // chant decides the wave right after this; tell the webhook now when it will wait (#87), since chant sends nothing.
360
+ // Recorded for a review (#48), nothing waits now: the wave is decided after the merge.
361
+ const forecast = forReview ? undefined : await forecastWave(process.cwd(), a.spec, plan);
362
+ if (forecast?.waits) {
363
+ // A tenant set's wave (#56) is approved once for every tenant.
364
+ const sign = forecast.approval === "sealed" ? " --sign" : "";
365
+ const how = env.tenant ? `for every tenant of ${env.tenant.env}. Approve it with yodel approve ${env.tenant.env}${sign}, or: ` : `. Approve it with yodel approve ${env.name}${sign}, or: `;
366
+ const mode = forecast.approval === "ledger" ? "" : `, approval ${forecast.approval}`;
367
+ io.err(`${env.name}: wave ${String(forecast.wave)} waits for an approval of ${forecast.digest} (gate ${forecast.policy} at ${forecast.base.slice(0, 12)}${mode})${how}${forecast.approve}\n`);
368
+ if (forecast.approval === "pr-review")
369
+ io.err(`${env.name}: under pr-review, an approving review of the merged pull request's head by a writer other than its author approves it too, when that head's pipeline recorded this plan (chant run wave --record-plans).\n`);
370
+ outcome.doc = { environment: env.name, status: "gated", pending: plan.pending.map((id) => ({ id })), gatePlanDigest: forecast.digest, approve: forecast.approve };
371
+ }
372
+ if (a.json)
373
+ io.out(`${JSON.stringify(applyJson({ mode: "wave-plan", plan }), null, 2)}\n`);
374
+ return 0;
375
+ }
376
+ if (a.waveApply !== undefined) {
377
+ const planned = readWavePlan(a.waveApply);
378
+ const { policy: basePolicy, note: policyNote } = await policyAtBase(project);
379
+ if (policyNote)
380
+ io.err(`${policyNote}\n`);
381
+ if (planned.environment !== env.name)
382
+ throw new YodelError(`refused: ${a.waveApply} is ${planned.environment}'s plan, not ${env.name}'s; nothing was applied`, EXIT_REFUSED);
383
+ const cwd = process.cwd();
384
+ let wave;
385
+ let before = [];
386
+ const steps = await stepsAtBase(project, env.name);
387
+ if (steps.note)
388
+ io.err(`${steps.note}\n`);
389
+ const result = await executeApply(venv, ordered, {
390
+ runNote: () => (wave || before.length > 0 ? { ...(wave ? { wave } : {}), ...(before.length > 0 ? { steps: before } : {}) } : undefined),
391
+ ...afterSteps(project, venv, env, steps.steps?.after, io),
392
+ approvedDigest: planned.migrations,
393
+ allowOutOfOrder: a.allowOutOfOrder,
394
+ ...(a.lockWaitMs !== undefined ? { lockWaitMs: a.lockWaitMs } : {}),
395
+ ...(a.standDown ? { standDown: standingDown(project, env) } : {}),
396
+ ...(basePolicy
397
+ ? {
398
+ policy: async (digest, toApply) => {
399
+ const ledger = async () => {
400
+ try {
401
+ return { port: await gitLedger(project.dir), op: await findMigrateOp(project, env, a.op) };
402
+ }
403
+ catch {
404
+ return undefined;
405
+ }
406
+ };
407
+ const results = await judgePlan({ policy: basePolicy, environment: env.name, dialect: project.dialect, toApply, digest, ledger });
408
+ const refusal = policyRefusal(results, env.name, digest, "under the lock");
409
+ if (refusal)
410
+ throw refusal;
411
+ for (const line of describeResults(results))
412
+ io.err(`${line}\n`);
413
+ return results.flatMap((r) => (r.override ? [r.override] : []));
414
+ },
415
+ }
416
+ : {}),
417
+ approve: async () => {
418
+ // Under the lock: the wave plan now, and chant's decision for it.
419
+ const now = await planWave(project, venv, ordered, { allowOutOfOrder: a.allowOutOfOrder });
420
+ if (now.planDigest !== planned.planDigest) {
421
+ throw new WavePlanMoved(`refused: ${env.name}'s wave plan moved since the wave was decided (decided ${planned.planDigest}, now ${now.planDigest}); nothing was applied. Run the wave again.`);
422
+ }
423
+ // The before steps' SQL checks (#64) ran again in that plan, under the lock; a held one needs a person's approval whatever the gate.
424
+ const checked = (now.steps ?? []).filter((o) => !o.deferred);
425
+ for (const o of checked)
426
+ io.err(`${describeStep(o)}\n`);
427
+ let verified = await verifyWaveDecision(cwd, a.spec, a.decidedAs?.target ?? env.name, a.decidedAs?.planDigest ?? planned.planDigest, now.held ?? []);
428
+ // The commands (#107) run once, here, once the wave may apply: one that fails refuses; a held one needs the approval too.
429
+ const commands = await runBeforeSteps(project, venv, env, steps.steps?.before?.filter((s) => s.sql === undefined), "under the lock", io);
430
+ before = [...checked, ...commands];
431
+ if (heldSteps(commands).length > 0 && verified.wave.status !== "approved")
432
+ verified = await verifyWaveDecision(cwd, a.spec, a.decidedAs?.target ?? env.name, a.decidedAs?.planDigest ?? planned.planDigest, [...(now.held ?? []), ...heldSteps(commands)]);
433
+ wave = verified.wave;
434
+ io.err(`${verified.line}\n`);
435
+ },
436
+ log: (l) => { io.err(`${l}\n`); },
437
+ silences: migrationSilences,
438
+ });
439
+ if (a.json)
440
+ io.out(`${JSON.stringify(applyJson({ mode: "execute", environment: env.name, planDigest: planned.migrations, wavePlanDigest: planned.planDigest, result }), null, 2)}\n`);
441
+ else
442
+ io.out(result.stoodDown ? `${result.stoodDown}\n` : result.applied.length === 0 ? "Nothing to apply.\n" : `Applied: ${result.applied.join(", ")}\n`);
443
+ return 0;
444
+ }
209
445
  if (a.execute) {
210
446
  const approved = process.env[APPROVED_PLAN_ENV];
447
+ const { policy: basePolicy, note: policyNote } = await policyAtBase(project);
448
+ if (policyNote)
449
+ io.err(`${policyNote}\n`);
211
450
  if (!approved)
212
451
  throw new YodelError(`--execute applies only the plan its Op's gate approved, and ${APPROVED_PLAN_ENV} is not set; run yodel apply ${env.name}`);
213
452
  // The env var is only the Op's hand-off; the approval itself is checked in chant's gate ledger, under the lock.
214
453
  const op = await findMigrateOp(project, env, a.op);
215
- const ledger = await gitLedger(project.dir);
454
+ const ledger = await migrationsLedger(project, env.name, io);
455
+ const steps = await stepsAtBase(project, env.name);
456
+ if (steps.note)
457
+ io.err(`${steps.note}\n`);
458
+ let before = [];
216
459
  const result = await executeApply(venv, ordered, {
460
+ runNote: () => (before.length > 0 ? { steps: before } : undefined),
461
+ ...afterSteps(project, venv, env, steps.steps?.after, io),
217
462
  approvedDigest: approved,
218
463
  allowOutOfOrder: a.allowOutOfOrder,
219
464
  ...(a.lockWaitMs !== undefined ? { lockWaitMs: a.lockWaitMs } : {}),
220
465
  ...(a.standDown ? { standDown: standingDown(project, env) } : {}),
221
466
  approve: async (digest) => {
467
+ // Under the lock: every pending migration ran first where this environment requires (exit 4 otherwise).
468
+ await enforcePromotion(project, env.name, ordered, (await inspect(venv, ordered)).status.pending);
222
469
  const by = await verifyApproval(ledger, op, digest);
223
- io.err(`approval: ${op.name} / ${op.gate} for ${digest}, by ${by.resolvedBy} at ${by.timestamp}\n`);
470
+ io.err(`approval: ${op.name} / ${op.gate} for ${digest}, by ${by.resolvedBy} at ${by.timestamp}${by.signer !== undefined ? `, sealed by ${by.signer}` : ""}\n`);
471
+ // The before steps (#64), under the lock: one that fails refuses; a held one is let through by the Op's approval, which every run of it asks for.
472
+ before = await runBeforeSteps(project, venv, env, steps.steps?.before, "under the lock", io);
224
473
  },
225
474
  log: (l) => { io.err(`${l}\n`); },
226
475
  silences: migrationSilences,
227
- ...(project.yodel?.config.policy
476
+ ...(basePolicy
228
477
  ? {
229
478
  policy: async (digest, toApply) => {
230
- const results = await judgePlan({ policy: project.yodel?.config.policy, environment: env.name, dialect: project.dialect, toApply, digest, ledger: () => Promise.resolve({ port: ledger, op }) });
479
+ const results = await judgePlan({ policy: basePolicy, environment: env.name, dialect: project.dialect, toApply, digest, ledger: () => Promise.resolve({ port: ledger, op }) });
231
480
  const refusal = policyRefusal(results, env.name, digest, "under the lock");
232
481
  if (refusal)
233
482
  throw refusal;
@@ -239,15 +488,16 @@ async function versionedApplyWith(project, env, a, io, ordered, venv) {
239
488
  : {}),
240
489
  });
241
490
  if (a.json)
242
- io.out(`${JSON.stringify({ environment: env.name, status: result.stoodDown ? "stood-down" : "applied", planDigest: approved, ...result }, null, 2)}\n`);
491
+ io.out(`${JSON.stringify(applyJson({ mode: "execute", environment: env.name, planDigest: approved, result }), null, 2)}\n`);
243
492
  else
244
493
  io.out(result.stoodDown ? `${result.stoodDown}\n` : result.applied.length === 0 ? "Nothing to apply.\n" : `Applied: ${result.applied.join(", ")}\n`);
245
494
  return 0;
246
495
  }
247
496
  const report = await statusReport(venv, ordered, { allowOutOfOrder: a.allowOutOfOrder });
248
497
  const emit = (doc, code) => {
498
+ outcome.doc = applyJson({ mode: "versioned", report, outcome: doc });
249
499
  if (a.json)
250
- io.out(`${JSON.stringify({ ...statusJson(report), mode: "versioned", ...doc }, null, 2)}\n`);
500
+ io.out(`${JSON.stringify(outcome.doc, null, 2)}\n`);
251
501
  return code;
252
502
  };
253
503
  if (a.plan) {
@@ -272,6 +522,17 @@ async function versionedApplyWith(project, env, a, io, ordered, venv) {
272
522
  io.out(`${stood}\n`);
273
523
  return emit({ status: "stood-down", stoodDown: stood }, 0);
274
524
  }
525
+ // Before the gate: a pending migration the required environment has not applied refuses, and nothing is asked.
526
+ try {
527
+ await enforcePromotion(project, env.name, ordered, report.status.pending);
528
+ }
529
+ catch (e) {
530
+ if (!(e instanceof YodelError) || e.exitCode !== EXIT_REFUSED)
531
+ throw e;
532
+ if (!a.json)
533
+ io.err(`yodel apply: ${e.message}\n`);
534
+ return emit({ status: "refused", refusal: { exitCode: EXIT_REFUSED, message: e.message } }, EXIT_REFUSED);
535
+ }
275
536
  // The first pending migration's checks (#52) read the data as it is now: one that fails refuses before anyone is asked to approve. The Apply step runs every migration's again under the lock.
276
537
  const first = report.status.pending[0];
277
538
  let checked;
@@ -285,10 +546,30 @@ async function versionedApplyWith(project, env, a, io, ordered, venv) {
285
546
  return emit({ status: "refused", preChecks: checked }, refusal.exitCode);
286
547
  }
287
548
  }
549
+ // The before steps (#64), as the base commit has them: a SQL check that fails refuses before anyone is asked; the Apply step runs them again under the lock.
550
+ // A command runs there alone (#107), once per apply: here the plan only says it will.
551
+ const steps = await stepsAtBase(project, env.name);
552
+ if (steps.note && !a.json)
553
+ io.err(`${steps.note}\n`);
554
+ try {
555
+ const outcomes = await runBeforeSteps(project, venv, env, steps.steps?.before, "before the gate", a.json ? undefined : io, "plan");
556
+ if (!a.json && heldSteps(outcomes).length > 0)
557
+ io.out(`before step ${heldSteps(outcomes).join(", ")} failed with onFailure approve: approving this plan lets it through\n`);
558
+ }
559
+ catch (e) {
560
+ if (!(e instanceof YodelError) || e.exitCode !== EXIT_REFUSED)
561
+ throw e;
562
+ if (!a.json)
563
+ io.err(`yodel apply: ${e.message}\n`);
564
+ return emit({ status: "refused", refusal: { exitCode: EXIT_REFUSED, message: e.message } }, EXIT_REFUSED);
565
+ }
288
566
  const op = await findMigrateOp(project, env, a.op);
289
- // The policy (#53): a rule that denies this plan, with no override recorded for its digest, refuses before the gate.
567
+ // The policy (#53), as the base commit has it (#73): a rule that denies this plan, with no override recorded for its digest, refuses before the gate.
568
+ const basePolicy = await policyAtBase(project);
569
+ if (basePolicy.note && !a.json)
570
+ io.err(`${basePolicy.note}\n`);
290
571
  const policy = await judgePlan({
291
- policy: project.yodel?.config.policy,
572
+ policy: basePolicy.policy,
292
573
  environment: env.name,
293
574
  dialect: project.dialect,
294
575
  toApply: report.status.pending,
@@ -325,8 +606,11 @@ async function versionedApplyWith(project, env, a, io, ordered, venv) {
325
606
  if (!a.json) {
326
607
  io.out(`\n${op.name} is waiting at gate "${op.gate}" for approval of this plan${run.planDigest ? ` (${run.planDigest})` : ""}.\n`);
327
608
  if (run.approve) {
328
- io.out(`Approve it with:\n ${runnable(run.approve, project.dir)}\n` +
329
- `or, at a terminal, with ${runnable(`yodel approve ${env.name}`, project.dir)}, which shows the plan and asks you first;\n` +
609
+ // A sealed gate (#48) counts only a sealed approval, which --execute checks.
610
+ const sealed = (await migrationsApprovalAtBase(project, env.name)).mode === "sealed";
611
+ const approve = sealed && !/ --sign\b/.test(run.approve) ? `${run.approve} --sign` : run.approve;
612
+ io.out(`Approve it with:\n ${runnable(approve, project.dir)}\n` +
613
+ `or, at a terminal, with ${runnable(`yodel approve ${env.name}${sealed ? " --sign" : ""}`, project.dir)}, which shows the plan and asks you first;\n` +
330
614
  `then run ${runnable(`yodel apply ${env.name}`, project.dir)} again.\n`);
331
615
  }
332
616
  }
@@ -340,8 +624,9 @@ async function versionedApplyWith(project, env, a, io, ordered, venv) {
340
624
  io.err(`yodel apply: chant run ${op.name} failed (exit ${String(run.code)})${run.stepExit ? `: its Apply step, yodel apply ${env.name} --execute, exited ${String(run.stepExit)}` : ""}\n`);
341
625
  io.out(`\n${renderStatus(after)}`);
342
626
  }
627
+ outcome.doc = applyJson({ mode: "versioned", report: after, outcome: { status: "failed", op: op.name, exitCode: code, chantExitCode: run.code } });
343
628
  if (a.json)
344
- io.out(`${JSON.stringify({ ...statusJson(after), mode: "versioned", status: "failed", op: op.name, exitCode: code, chantExitCode: run.code }, null, 2)}\n`);
629
+ io.out(`${JSON.stringify(outcome.doc, null, 2)}\n`);
345
630
  return code;
346
631
  }
347
632
  // The Apply step stood down (a newer commit came while it waited for the lock): what it applied is nothing, and that is not a failure.
@@ -352,7 +637,7 @@ async function versionedApplyWith(project, env, a, io, ordered, venv) {
352
637
  return emit({ status: "stood-down", stoodDown: stoodLate, op: op.name }, 0);
353
638
  }
354
639
  if (a.json) {
355
- io.out(`${JSON.stringify({ ...statusJson(after), mode: "versioned", status: "applied", op: op.name, appliedNow: report.status.pending.map((p) => p.id) }, null, 2)}\n`);
640
+ io.out(`${JSON.stringify(applyJson({ mode: "versioned", report: after, outcome: { status: "applied", op: op.name, appliedNow: report.status.pending.map((p) => p.id) } }), null, 2)}\n`);
356
641
  }
357
642
  else {
358
643
  io.out(`\nApplied: ${report.status.pending.map((p) => p.id).join(", ")}.\n`);
@@ -360,23 +645,40 @@ async function versionedApplyWith(project, env, a, io, ordered, venv) {
360
645
  return after.status.pending.length === 0 ? 0 : 1;
361
646
  }
362
647
  async function declarativeApply(project, env, a, io) {
363
- const json = a.json;
364
648
  const op = await findApplyOp(project, env, a.op);
365
649
  let server;
650
+ let printed;
366
651
  const report = (doc, code) => {
367
- if (json)
368
- io.out(`${JSON.stringify({ environment: env.name, dialect: project.dialect, ...(server ? { server } : {}), op: op.name, ...doc }, null, 2)}\n`);
652
+ printed = applyJson({ mode: "declarative", environment: env.name, dialect: project.dialect, server, op: op.name, outcome: doc });
653
+ if (a.json)
654
+ io.out(`${JSON.stringify(printed, null, 2)}\n`);
369
655
  return code;
370
656
  };
657
+ const code = await declarativeRun(project, env, a, io, op, report, (s) => (server = s));
658
+ if (printed)
659
+ await sendEvent(project.yodel?.config.notify, eventFromRun("apply", printed), { io, verb: "apply" });
660
+ return code;
661
+ }
662
+ async function declarativeRun(project, env, a, io, op, report, setServer) {
663
+ const json = a.json;
371
664
  const build = await buildSchema(project);
372
665
  try {
666
+ const chRefusal = project.dialect === "clickhouse" ? clickhouseAccessRefusal(project, env.name, build.output) : undefined;
667
+ if (chRefusal)
668
+ throw chRefusal;
373
669
  const before = await planEnvironment(project, env, build);
374
- server = await probeServer(project, env);
375
- if (!json)
670
+ const server = await probeServer(project, env);
671
+ setServer(server);
672
+ if (!json) {
376
673
  io.out(`${await renderPlan(before, `Plan for ${env.name} (${server ? describeServer(server) : project.dialect}, sql.profiles.${env.name})`)}\n`);
674
+ const review = renderAccessReview(await accessReview(project, env.name, before.diff.changes, build.output), env.name);
675
+ if (review.length > 0)
676
+ io.out(`\n${review.join("\n")}\n`);
677
+ }
377
678
  if (planExitCode(before) === PLAN_NEEDS_OP) {
679
+ const unreadable = unreadableOf(before);
378
680
  if (!json)
379
- io.err(`yodel apply: refused: ${String(before.refused.length)} change(s) need their own Op; nothing was applied\n`);
681
+ io.err(`yodel apply: refused: ${unreadable.length > 0 ? `${unreadable.map((u) => u.object).join(", ")} could not be read` : `${String(before.refused.length)} change(s) need their own Op`}; nothing was applied\n`);
380
682
  return report({ status: "refused", plan: planJson(before) }, PLAN_NEEDS_OP);
381
683
  }
382
684
  if (declaredChanges(before, build).length === 0) {
@@ -423,4 +725,42 @@ function standingDown(project, env) {
423
725
  return n ? standDownLine(env.name, n) : undefined;
424
726
  };
425
727
  }
728
+ /** Runs `env`'s before steps (#64); throws the refusal for one that fails with onFailure fail. */
729
+ async function runBeforeSteps(project, venv, env, list, when, io, commands = "run") {
730
+ if (!list || list.length === 0)
731
+ return [];
732
+ const outcomes = await runApplySteps(venv.driver, list, "before", { dir: project.dir, env: env.name, commands });
733
+ for (const o of outcomes)
734
+ io?.err(`${describeStep(o)}\n`);
735
+ const refusal = stepsRefusal(env.name, outcomes, when);
736
+ if (refusal)
737
+ throw refusal;
738
+ return outcomes;
739
+ }
740
+ /** executeApply's `after` (#64), when `env` has after steps. */
741
+ function afterSteps(project, venv, env, list, io) {
742
+ if (!list || list.length === 0)
743
+ return {};
744
+ return {
745
+ after: async () => {
746
+ const outcomes = await runApplySteps(venv.driver, list, "after", { dir: project.dir, env: env.name });
747
+ for (const o of outcomes)
748
+ io.err(`${describeStep(o)}\n`);
749
+ const error = stepsRefusal(env.name, outcomes, "under the lock");
750
+ return { note: { steps: outcomes }, ...(error ? { error } : {}) };
751
+ },
752
+ };
753
+ }
754
+ /**
755
+ * chant's gate ledger as the migrations Op's gate counts approvals for
756
+ * `env` (#48): sealed when yodel.config.ts at the base commit says so, so
757
+ * `--execute` applies nothing on an approval without a seal a signer at
758
+ * base made.
759
+ */
760
+ async function migrationsLedger(project, env, io) {
761
+ const mode = await migrationsApprovalAtBase(project, env);
762
+ if (mode.note)
763
+ io.err(`${mode.note}\n`);
764
+ return portFor(await gitLedger(project.dir), mode.mode, project.dir, mode.base);
765
+ }
426
766
  //# sourceMappingURL=apply.js.map