@onlineapps/conn-orch-validator 7.0.0 → 8.1.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 (102) hide show
  1. package/CHANGELOG.md +2582 -2
  2. package/README.md +1038 -4
  3. package/docs/DESIGN.md +3 -1
  4. package/manifests/biz-service.manifest.json +658 -0
  5. package/manifests/library.manifest.json +324 -0
  6. package/package.json +12 -6
  7. package/src/CookbookTestRunner.js +408 -101
  8. package/src/CookbookTestUtils.js +7 -8
  9. package/src/ServiceReadinessValidator.js +10 -35
  10. package/src/ValidationOrchestrator.js +219 -71
  11. package/src/cli/biz-ci-gate.js +176 -33
  12. package/src/cli/oa-lint-scripts.js +221 -0
  13. package/src/cli/oa-sync-template.js +1020 -0
  14. package/src/cli/oa-validate.js +474 -0
  15. package/src/helpers/README.md +2 -1
  16. package/src/helpers/createServiceReadinessTests.js +60 -4
  17. package/src/index.js +33 -3
  18. package/src/lint/scripts/lintScripts.js +298 -0
  19. package/src/manifest/checks/composeRunnerBlock.js +222 -0
  20. package/src/manifest/checks/composeShape.js +165 -0
  21. package/src/manifest/checks/contractBridge.js +181 -0
  22. package/src/manifest/checks/discoveryOrphan.js +50 -0
  23. package/src/manifest/checks/docsLintBridge.js +553 -0
  24. package/src/manifest/checks/fileAbsent.js +35 -0
  25. package/src/manifest/checks/gitTracked.js +204 -0
  26. package/src/manifest/checks/index.js +111 -0
  27. package/src/manifest/checks/libraryContext.js +226 -0
  28. package/src/manifest/checks/libraryDocs.js +75 -0
  29. package/src/manifest/checks/libraryPackage.js +272 -0
  30. package/src/manifest/checks/librarySource.js +274 -0
  31. package/src/manifest/checks/libraryTests.js +121 -0
  32. package/src/manifest/checks/libraryWorkspace.js +293 -0
  33. package/src/manifest/checks/readmeRegion.js +135 -0
  34. package/src/manifest/checks/scriptHeaders.js +79 -0
  35. package/src/manifest/checks/serviceConfig.js +390 -0
  36. package/src/manifest/checks/serviceConnectors.js +81 -0
  37. package/src/manifest/checks/serviceDb.js +388 -0
  38. package/src/manifest/checks/serviceFiles.js +754 -0
  39. package/src/manifest/checks/serviceIdentityRows.js +351 -0
  40. package/src/manifest/checks/serviceRuntime.js +295 -0
  41. package/src/manifest/checks/serviceScripts.js +213 -0
  42. package/src/manifest/deployabilitySignal.js +121 -0
  43. package/src/manifest/discovery.js +386 -0
  44. package/src/manifest/loadManifest.js +62 -0
  45. package/src/manifest/manifestShape.js +446 -0
  46. package/src/manifest/report.js +245 -0
  47. package/src/manifest/runManifest.js +449 -0
  48. package/src/manifest/serviceIdentity.js +140 -0
  49. package/src/manifest/walk.js +74 -0
  50. package/src/manifest/workspaceRoot.js +242 -0
  51. package/src/mocks/MockMQClient.js +13 -30
  52. package/src/mocks/MockRegistry.js +4 -2
  53. package/src/mocks/MockStorage.js +4 -2
  54. package/src/sync/docsRegion.js +463 -0
  55. package/src/sync/generatedRegion.js +228 -0
  56. package/src/sync/readmeLocation.js +182 -0
  57. package/src/sync/readmePointer.js +477 -0
  58. package/src/sync/serviceTemplate.js +583 -0
  59. package/src/sync/sharedEnv.js +162 -0
  60. package/src/sync/uniformFiles.js +474 -0
  61. package/src/utils/bizCiGateContract.js +131 -7
  62. package/src/utils/connectorContract.js +97 -7
  63. package/src/utils/cookbookFormat.js +81 -40
  64. package/src/utils/deployContract.js +140 -9
  65. package/src/utils/envContract.js +57 -1
  66. package/src/utils/handlerRef.js +181 -0
  67. package/src/utils/installContract.js +287 -41
  68. package/src/utils/libCompat.js +29 -7
  69. package/src/utils/migrationOrder.js +163 -0
  70. package/src/utils/preValidation.js +20 -7
  71. package/src/utils/setupDatabase.js +194 -13
  72. package/src/utils/testCoverageContract.js +539 -0
  73. package/src/utils/testNamespace.js +247 -23
  74. package/src/utils/throwawaySchema.js +207 -0
  75. package/src/validators/ServiceStructureValidator.js +2 -1
  76. package/templates/business-service/.dockerignore +42 -0
  77. package/templates/business-service/.gitlab-ci.yml +409 -0
  78. package/templates/business-service/Dockerfile +27 -0
  79. package/templates/business-service/README.md +213 -0
  80. package/templates/business-service/config/biz-docs-lint.tree.json +10 -0
  81. package/templates/business-service/config/env-templates/__SERVICE_NAME__.env +22 -0
  82. package/templates/business-service/config/env-templates/shared.env +65 -0
  83. package/templates/business-service/config/service/config.json +14 -0
  84. package/templates/business-service/config/service/integration-contract.json +12 -0
  85. package/templates/business-service/config/service/operations.json +41 -0
  86. package/templates/business-service/docker-compose.production.yml +60 -0
  87. package/templates/business-service/docker-compose.yml +93 -0
  88. package/templates/business-service/docs/80-setup/INSTALL.md +123 -0
  89. package/templates/business-service/docs/80-setup/PLATFORM_MATRIX.md +65 -0
  90. package/templates/business-service/docs/80-setup/README.md +18 -0
  91. package/templates/business-service/docs/80-setup/VALIDATION.md +78 -0
  92. package/templates/business-service/docs/README.md +18 -0
  93. package/templates/business-service/gitignore +42 -0
  94. package/templates/business-service/index.js +10 -0
  95. package/templates/business-service/init.sh +54 -0
  96. package/templates/business-service/jest.config.js +6 -0
  97. package/templates/business-service/package.json.template +31 -0
  98. package/templates/business-service/scripts/verify-deploy-uniform.sh +180 -0
  99. package/templates/business-service/src/handlers/v3/echo.js +39 -0
  100. package/templates/business-service/tests/cookbooks/echo.json +36 -0
  101. package/templates/business-service/tests/unit/handler.test.js +78 -0
  102. package/src/WorkflowTestRunner.js +0 -402
@@ -74,15 +74,23 @@ function assertV3Operations(serviceRoot) {
74
74
  }
75
75
 
76
76
  /**
77
+ * A run narrowed by `operationFilter` writes NO proof, and that is the whole
78
+ * reason the flag can exist. A validation proof is the claim that the service's
79
+ * cookbooks passed; a run of some of them cannot make that claim, and a proof
80
+ * written from one would let the registry trust a run that never happened —
81
+ * the same reason a FAILED run writes none.
82
+ *
77
83
  * @param {object} params
78
84
  * @param {string} params.serviceRoot service repository root
79
- * @param {string} params.serviceUrl resolved by the caller from the service's own config
80
85
  * @param {Function} params.RunnerClass CookbookTestRunner (injected so the module stays testable)
81
86
  * @param {Function} params.ProofGeneratorClass ValidationProofGenerator
82
87
  * @param {{log: Function, error: Function}} params.logger
83
- * @returns {Promise<{ok: boolean, results: object, failedSteps: Array, proof: object|null, proofPath: string|null}>}
88
+ * @param {string} [params.operationFilter] JavaScript regular expression; only the
89
+ * cookbooks running a matching operation are executed
90
+ * @returns {Promise<{ok: boolean, results: object, failedSteps: Array, proof: object|null,
91
+ * proofPath: string|null, filteredBy: string|null}>}
84
92
  */
85
- async function runPreValidation({ serviceRoot, serviceUrl, RunnerClass, ProofGeneratorClass, logger }) {
93
+ async function runPreValidation({ serviceRoot, RunnerClass, ProofGeneratorClass, logger, operationFilter }) {
86
94
  const cookbooksDir = path.join(serviceRoot, COOKBOOKS_RELATIVE_DIR);
87
95
  if (!fs.existsSync(cookbooksDir)) {
88
96
  throw new Error(`[PreValidation] Missing cookbooks directory - ${cookbooksDir} is required. `
@@ -95,14 +103,15 @@ async function runPreValidation({ serviceRoot, serviceUrl, RunnerClass, ProofGen
95
103
 
96
104
  const runner = new RunnerClass({
97
105
  serviceName: packageJson.name,
98
- serviceUrl,
99
106
  servicePath: serviceRoot,
100
107
  mockInfrastructure: true,
101
108
  timeout: COOKBOOK_TIMEOUT_MS,
102
109
  logger
103
110
  });
104
111
 
105
- const results = await runner.runCookbooks(cookbooksDir);
112
+ const results = await runner.runCookbooks(cookbooksDir,
113
+ operationFilter === undefined ? {} : { operation: operationFilter });
114
+ const filteredBy = operationFilter === undefined ? null : operationFilter;
106
115
 
107
116
  if (results.failed > 0) {
108
117
  // No proof on a failed run. A proof is a claim that the cookbooks passed,
@@ -115,7 +124,11 @@ async function runPreValidation({ serviceRoot, serviceUrl, RunnerClass, ProofGen
115
124
  validationErrors: step.validationErrors || []
116
125
  }));
117
126
 
118
- return { ok: false, results, failedSteps, proof: null, proofPath: null };
127
+ return { ok: false, results, failedSteps, proof: null, proofPath: null, filteredBy };
128
+ }
129
+
130
+ if (filteredBy !== null) {
131
+ return { ok: true, results, failedSteps: [], proof: null, proofPath: null, filteredBy };
119
132
  }
120
133
 
121
134
  const proofGenerator = new ProofGeneratorClass({
@@ -131,7 +144,7 @@ async function runPreValidation({ serviceRoot, serviceUrl, RunnerClass, ProofGen
131
144
  fs.mkdirSync(path.dirname(proofPath), { recursive: true });
132
145
  fs.writeFileSync(proofPath, JSON.stringify(proof, null, 2));
133
146
 
134
- return { ok: true, results, failedSteps: [], proof, proofPath };
147
+ return { ok: true, results, failedSteps: [], proof, proofPath, filteredBy };
135
148
  }
136
149
 
137
150
  module.exports = { runPreValidation, PROOF_RELATIVE_PATH, COOKBOOKS_RELATIVE_DIR };
@@ -17,11 +17,13 @@
17
17
  *
18
18
  * Two invariants, both from ADR 0006:
19
19
  *
20
- * §2 every .sql file in the declared directory is applied, in sorted order.
21
- * No list lives in this file — the set grows with each schema change, and a
22
- * list would drift until a missing table surfaced in an unrelated test.
20
+ * §2 every .sql file in the declared directory is applied, in the order the
21
+ * operator runner applies them (`migrationOrder.js` — `sort -V`, the one
22
+ * definition of that order). No list lives in this file: the set grows with
23
+ * each schema change, and a list would drift until a missing table
24
+ * surfaced in an unrelated test.
23
25
  *
24
- * §3 foreign key checks stay ON. Deferring them would let a migration
26
+ * §3 the target is EMPTY and foreign key checks stay ON. Deferring them would let a migration
25
27
  * reference a table a later file creates and still report success, which is
26
28
  * the exact defect class building from empty exists to catch.
27
29
  *
@@ -35,6 +37,8 @@ const fs = require('fs');
35
37
  const path = require('path');
36
38
  const { spawnSync } = require('child_process');
37
39
 
40
+ const { compareVersion } = require('./migrationOrder');
41
+
38
42
  function requireValue(value, name) {
39
43
  if (value === undefined || value === null || value === '') {
40
44
  throw new Error(`[SetupDatabase] Missing ${name} - Expected a value. `
@@ -63,7 +67,9 @@ function resolveMigrationPlan(serviceRoot, database) {
63
67
  const migrations = fs.readdirSync(dir, { withFileTypes: true })
64
68
  .filter((entry) => entry.isFile() && entry.name.endsWith('.sql'))
65
69
  .map((entry) => entry.name)
66
- .sort()
70
+ // Version order, because that is what the operator runner uses to build the
71
+ // real installation — CI has to prove that artefact, not one of its own.
72
+ .sort(compareVersion)
67
73
  .map((name) => path.join(dir, name));
68
74
 
69
75
  if (migrations.length === 0) {
@@ -83,13 +89,26 @@ function resolveMigrationPlan(serviceRoot, database) {
83
89
  return { dir, migrations, seeds };
84
90
  }
85
91
 
86
- /** Run one statement or file through the CLI client. Injectable for tests. */
87
- function defaultExec({ connection, schema, sql, file }) {
92
+ /**
93
+ * Run one statement or file through the CLI client. Injectable for tests.
94
+ *
95
+ * `capture: true` asks for the client's stdout as well, in the bare shape a
96
+ * caller can read — one value per line, no column names and no box drawing.
97
+ * Only the emptiness probe uses it; a migration produces no output to read.
98
+ */
99
+ function defaultExec({ connection, schema, sql, file, capture = false }) {
88
100
  const client = ['mariadb', 'mysql'].find((bin) => !spawnSync(bin, ['--version'], { encoding: 'utf8' }).error);
89
101
  if (!client) {
90
102
  return {
91
103
  status: 1,
92
- stderr: '[SetupDatabase] No mysql/mariadb client on PATH - required because migrations use DELIMITER. '
104
+ // The reason is unconditional, and it says so: every statement of this
105
+ // build goes through the CLI, the first CREATE DATABASE included, and
106
+ // there is no driver path here. It used to read "required because
107
+ // migrations use DELIMITER", which BIZ-hello met on 2026-09-14 with no
108
+ // DELIMITER anywhere in its migrations — a service without triggers read
109
+ // it as "this does not apply to me" (`architecture-principles.md` §5).
110
+ stderr: '[SetupDatabase] No mysql/mariadb client on PATH - every statement of this build runs through '
111
+ + 'the CLI, the first CREATE DATABASE included, and there is no driver path. '
93
112
  + 'Fix: install mariadb-client in the CI job image.'
94
113
  };
95
114
  }
@@ -99,6 +118,7 @@ function defaultExec({ connection, schema, sql, file }) {
99
118
  // MariaDB Connector/C 3.x requires TLS by default and the CI database is a
100
119
  // sidecar with none; the connection never leaves the job's private network.
101
120
  if (connection.requireTls !== true) args.push('--skip-ssl');
121
+ if (capture === true) args.push('--batch', '--skip-column-names');
102
122
  if (connection.password) args.push(`-p${connection.password}`);
103
123
  if (schema) args.push(schema);
104
124
 
@@ -109,11 +129,160 @@ function defaultExec({ connection, schema, sql, file }) {
109
129
 
110
130
  return {
111
131
  status: result.error ? 1 : result.status,
132
+ stdout: result.error ? '' : (result.stdout || ''),
112
133
  stderr: result.error ? result.error.message
113
134
  : (result.stderr || '').split('\n').filter((l) => !l.includes('Using a password')).join('\n').trim()
114
135
  };
115
136
  }
116
137
 
138
+ /**
139
+ * Refuse a target that is not a throwaway.
140
+ *
141
+ * `database.schema` is the REAL name — `oagen_emailer`, the same string on the
142
+ * dev server and in production — so the name distinguishes nothing, and neither
143
+ * does "does the schema exist": every biz `.gitlab-ci.yml` has the sidecar
144
+ * create it empty through `MARIADB_DATABASE` before the job starts.
145
+ *
146
+ * What does distinguish them is the premise this whole build rests on: the
147
+ * migration set is proved by applying it to an EMPTY schema (ADR 0006 §3). A
148
+ * target that already holds tables breaks that premise twice over — the run
149
+ * proves nothing, because the tables it would create are already there, and it
150
+ * writes into somebody's data, because a declared seed carries no
151
+ * `IF NOT EXISTS`. Both halves are the same answer: stop.
152
+ *
153
+ * Reported as UNCERTAIN by BIZ-emailer on 2026-09-04 — "we found nothing that
154
+ * blocks such a run outside CI" — and there was nothing.
155
+ */
156
+ function assertTargetIsEmpty({ exec, connection, schema }) {
157
+ const probe = exec({
158
+ connection,
159
+ capture: true,
160
+ sql: `SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = '${schema}';`
161
+ });
162
+
163
+ if (probe.status !== 0) {
164
+ throw new Error(`[SetupDatabase] Could not ask ${connection.host}:${connection.port} what ${schema} holds:\n`
165
+ + `${probe.stderr}\n`
166
+ + ' Fix: the build refuses to touch a schema it cannot inspect — give this account SELECT on '
167
+ + 'information_schema, or point DB_HOST at the CI database.');
168
+ }
169
+
170
+ const count = Number.parseInt(String(probe.stdout ?? '').trim(), 10);
171
+ if (!Number.isFinite(count)) {
172
+ throw new Error(`[SetupDatabase] The table count for ${schema} came back unreadable: `
173
+ + `"${String(probe.stdout ?? '').trim()}"\n`
174
+ + ' Fix: the executor must return the client stdout for a capture call; without a count '
175
+ + 'this build cannot tell a throwaway schema from a live one.');
176
+ }
177
+
178
+ if (count > 0) {
179
+ throw new Error(`[SetupDatabase] Schema ${schema} on ${connection.host}:${connection.port} already holds `
180
+ + `${count} table(s) - refusing to build.\n`
181
+ + ' The migration set is proved by applying it to an EMPTY schema (ADR 0006 §3); against a '
182
+ + 'populated one it proves nothing, and a seed would write into whatever is already there.\n'
183
+ + ' Fix: point DB_HOST at the throwaway database of the CI job (the sidecar creates it empty), '
184
+ + `or drop ${schema} first if this really is a disposable instance. Never aim this at a dev or `
185
+ + 'production server.');
186
+ }
187
+ }
188
+
189
+ /**
190
+ * The collation the schema must carry, or a refusal.
191
+ *
192
+ * Owner decision 2026-09-14 (`db-collation-declaration` 001): the collation is
193
+ * the service's own declaration, `database.collation` in the integration
194
+ * contract, and the same key the production runbook reads. There is no default
195
+ * and no server fallback — a DB-owning service that declares none stops the
196
+ * build (`architecture-principles.md` §3).
197
+ *
198
+ * Whether the VALUE is a collation name is the contract validator's rule
199
+ * (`bizCiGateContract.js`, which every gate reads the contract through), and
200
+ * whether a repository DECLARED one is uniform row `D-DB-COLLATION`. What
201
+ * belongs here is neither of those: this function cannot build without the
202
+ * value, so it refuses at its own entry (§4), the way `DB_HOST` is refused.
203
+ *
204
+ * @param {{collation?: string}} database the contract declaration
205
+ * @returns {string}
206
+ */
207
+ function requireCollation(database) {
208
+ const declared = database.collation;
209
+ if (typeof declared === 'string' && declared.trim() !== '') return declared.trim();
210
+
211
+ throw new Error('[SetupDatabase] Missing database.collation - the schema is created with the collation the '
212
+ + 'service declares, and there is no default.\n'
213
+ + ' Fix: add "collation" to the "database" block of config/service/integration-contract.json '
214
+ + '(e.g. "utf8mb4_bin" — the collation this service\'s tables already use); '
215
+ + 'api/docs/biz/70-contracts/database-contract.md §1 owns the key, '
216
+ + 'api/docs/governance/confirmations/db-collation-declaration.md 001 the decision.');
217
+ }
218
+
219
+ /**
220
+ * What the server says the schema was created with, or null when there is none.
221
+ *
222
+ * This probe is the reason `CREATE DATABASE IF NOT EXISTS` is not enough, and
223
+ * the reason is measured rather than assumed (BIZ-DOCS, 2026-09-14, throwaway
224
+ * `mariadb:10.5.29`): over an existing database, `IF NOT EXISTS … COLLATE`
225
+ * changes nothing and reports success. Six biz pipelines have the sidecar
226
+ * create the schema from `MARIADB_DATABASE` before the job starts, so in the
227
+ * environment this gate actually runs in the database is already there — with
228
+ * the image default `utf8mb4_general_ci`. Creating "if not exists" would
229
+ * therefore report a schema built to the declaration while the server holds
230
+ * another one, which is the false guarantee `automation-gates.md` §5 names.
231
+ *
232
+ * @returns {string|null}
233
+ */
234
+ function existingCollation({ exec, connection, schema }) {
235
+ const probe = exec({
236
+ connection,
237
+ capture: true,
238
+ sql: 'SELECT DEFAULT_COLLATION_NAME FROM information_schema.SCHEMATA '
239
+ + `WHERE SCHEMA_NAME = '${schema}';`
240
+ });
241
+
242
+ if (probe.status !== 0) {
243
+ throw new Error(`[SetupDatabase] Could not ask ${connection.host}:${connection.port} what collation `
244
+ + `${schema} was created with:\n${probe.stderr}\n`
245
+ + ' Fix: the build refuses to create or reuse a schema whose collation it cannot read — give this '
246
+ + 'account SELECT on information_schema, or point DB_HOST at the CI database.');
247
+ }
248
+
249
+ const answer = String(probe.stdout ?? '').trim();
250
+ return answer === '' ? null : answer;
251
+ }
252
+
253
+ /**
254
+ * Create the schema with the declared collation, or refuse a schema that
255
+ * already carries another one.
256
+ *
257
+ * Never `IF NOT EXISTS`: see `existingCollation` above for what that silence
258
+ * costs.
259
+ */
260
+ function createDatabaseSql(schema, collation) {
261
+ return `CREATE DATABASE \`${schema}\` COLLATE ${collation};`;
262
+ }
263
+
264
+ function createSchema({ exec, connection, schema, collation }) {
265
+ const present = existingCollation({ exec, connection, schema });
266
+
267
+ if (present === collation) return;
268
+
269
+ if (present !== null) {
270
+ throw new Error(`[SetupDatabase] Schema ${schema} on ${connection.host}:${connection.port} was created `
271
+ + `with collation ${present}, and this service declares ${collation} - refusing to build.\n`
272
+ + ' A CREATE DATABASE IF NOT EXISTS over an existing database changes nothing and reports '
273
+ + 'success, so the run would prove a schema it never built.\n'
274
+ + ` Fix: create the database with the declared collation — in CI give the database service `
275
+ + `MARIADB_COLLATION=${collation} beside MARIADB_DATABASE (or drop MARIADB_DATABASE and let this `
276
+ + `gate create it); on an existing instance, ALTER DATABASE \`${schema}\` COLLATE ${collation}. `
277
+ + 'The key itself is api/docs/biz/70-contracts/database-contract.md §1.');
278
+ }
279
+
280
+ const created = exec({ connection, sql: createDatabaseSql(schema, collation) });
281
+ if (created.status !== 0) {
282
+ throw new Error(`[SetupDatabase] Could not create schema ${schema}:\n${created.stderr}`);
283
+ }
284
+ }
285
+
117
286
  /**
118
287
  * Build the schema from the declared migrations.
119
288
  *
@@ -125,12 +294,11 @@ function buildSchema({ serviceRoot, database, connection, exec = defaultExec })
125
294
  const port = connection.port ?? 3306;
126
295
 
127
296
  const conn = { ...connection, port };
297
+ const collation = requireCollation(database);
128
298
  const plan = resolveMigrationPlan(serviceRoot, database);
129
299
 
130
- const created = exec({ connection: conn, sql: `CREATE DATABASE IF NOT EXISTS \`${database.schema}\`;` });
131
- if (created.status !== 0) {
132
- throw new Error(`[SetupDatabase] Could not create schema ${database.schema}:\n${created.stderr}`);
133
- }
300
+ assertTargetIsEmpty({ exec, connection: conn, schema: database.schema });
301
+ createSchema({ exec, connection: conn, schema: database.schema, collation });
134
302
 
135
303
  const apply = (file, kind) => {
136
304
  const result = exec({ connection: conn, schema: database.schema, file });
@@ -151,4 +319,17 @@ function buildSchema({ serviceRoot, database, connection, exec = defaultExec })
151
319
  };
152
320
  }
153
321
 
154
- module.exports = { resolveMigrationPlan, buildSchema };
322
+ // `requireCollation`, `createDatabaseSql` and `defaultExec` are exported for
323
+ // `throwawaySchema.js`, the build an integration test uses. The two builds
324
+ // differ in exactly one decision — the CI gate refuses a target it did not
325
+ // create, the test one drops and recreates its own — and in nothing else: the
326
+ // migration set and its order, the collation requirement, the CREATE statement
327
+ // and the client invocation have one definition each, here
328
+ // (`change-discipline.md` § One rail per concern).
329
+ module.exports = {
330
+ resolveMigrationPlan,
331
+ buildSchema,
332
+ requireCollation,
333
+ createDatabaseSql,
334
+ defaultExec
335
+ };