@onlineapps/conn-orch-validator 7.0.0 → 8.0.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 +2558 -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 +290 -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 +4 -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 +101 -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
@@ -23,7 +23,23 @@
23
23
  * rather than guessing: a test that does not know which namespace it owns must
24
24
  * not write anywhere at all.
25
25
  *
26
- * Enforced by deploy-contract requirement R8 (see utils/deployContract.js).
26
+ * Two namespaces come out of it: getTestNamespace() is the one the test owns and
27
+ * writes into, getForeignTestNamespace() the one it must not see rows from when
28
+ * it proves a handler filters on tenant AND workspace. Both are read from the
29
+ * same env and constrained to the same allowed classes.
30
+ *
31
+ * assertAllowedTenant() is the same boundary asked about an id the caller
32
+ * already holds — an operational script is handed a tenant on the command line
33
+ * and must know whether it may touch it at all. It exists because the whitelist
34
+ * had exactly one consumer inside this file, so a script that needed the answer
35
+ * kept its own guard instead: biz-property refused one declared live id and let
36
+ * every customer tenant from 101 up through (api/shared/TODO.md, "Od
37
+ * BIZ-PROPERTY (2026-09-11)"). The list itself is NOT exported — a copy of the
38
+ * boundary is a second boundary — and the refusal names the allowed classes so
39
+ * a caller never has to enumerate them.
40
+ *
41
+ * Enforced by deploy-contract requirement R8 (see utils/deployContract.js), which
42
+ * accepts a call to either namespace helper and nothing else.
27
43
  */
28
44
 
29
45
  /**
@@ -50,50 +66,214 @@
50
66
  */
51
67
  const ALLOWED_TENANT_CLASSES = Object.freeze([
52
68
  96, // CI
53
- 97, // TESTING
54
69
  98, // DEVEL
55
70
  99 // VALIDATION
56
71
  ]);
57
72
 
58
- function readNamespaceId(key) {
59
- const raw = process.env[key];
60
- if (raw === undefined || raw === null || String(raw).trim() === '') {
61
- throw new Error(`[TestNamespace] Missing environment variable - ${key} is required. `
62
- + 'Fix: set it in config/env-active/shared.env (the platform default is the '
63
- + 'non-production namespace). An integration test may not pick a namespace itself.');
73
+ /**
74
+ * Where the platform's own namespace values are set — the `setBy` half of every
75
+ * message this module raises for itself.
76
+ *
77
+ * It is a property of the ENV reader, not of the boundary: a script told to
78
+ * correct `config/env-active/shared.env` because it was handed `--to-tenant 101`
79
+ * would be sent to a file that has nothing to do with the value it typed. So the
80
+ * caller says where its own value comes from, and the boundary says the rest.
81
+ */
82
+ const ENV_SET_BY = 'locally in config/env-active/shared.env, in CI in the pipeline variables';
83
+
84
+ /**
85
+ * Both halves of the caller's origin are required, and each is named when it is
86
+ * missing: they are printed in the refusal, so a wrong one misdirects an
87
+ * operator who is being stopped from touching real data.
88
+ */
89
+ function requireOriginText(value, key) {
90
+ if (typeof value !== 'string' || value.trim() === '') {
91
+ throw new Error(`[TestNamespace] Missing required argument - assertAllowedTenant(id, { purpose, setBy }) `
92
+ + `has no "${key}". Fix: pass a non-empty string - "purpose" is the name the caller knows the id by `
93
+ + '(a CLI flag such as "--to-tenant", an env key such as "TESTING_TENANT_ID"), "setBy" says where that '
94
+ + 'name is given a value ("on the command line"); both are printed in the refusal so the operator '
95
+ + 'knows what to change.');
64
96
  }
65
- const value = Number(String(raw).trim());
97
+ }
98
+
99
+ /** No value at all: absent, null, or whitespace. One condition, two callers. */
100
+ function isBlank(raw) {
101
+ return raw === undefined || raw === null || String(raw).trim() === '';
102
+ }
103
+
104
+ /**
105
+ * The one rail from a raw value to an integer namespace id. Every id this module
106
+ * accepts — the env pair and the argument assertAllowedTenant() is handed —
107
+ * comes through here, so "what is a valid id" is stated once.
108
+ *
109
+ * The ABSENCE message is the one thing the env reader keeps for itself: a
110
+ * missing environment variable and an omitted argument are different failures
111
+ * with different fixes, and pointing a script operator at an env file it never
112
+ * touched would misdirect the person being stopped from reaching real data.
113
+ *
114
+ * @param {number|string} raw
115
+ * @param {{purpose: string, setBy: string}} origin
116
+ * @returns {number}
117
+ */
118
+ function normalizeNamespaceId(raw, { purpose, setBy }) {
119
+ if (isBlank(raw)) {
120
+ throw new Error(`[TestNamespace] Missing namespace id - ${purpose} is required. `
121
+ + `Fix: set it ${setBy} - see api/docs/standards/tenant-allocation.md § Env variables. `
122
+ + 'A run that does not know which namespace it owns may not write anywhere at all.');
123
+ }
124
+ // A number or a string holding one, and nothing else. `[99]` stringifies to
125
+ // "99" and would pass the Number() rail below; an id that arrives as an array
126
+ // or an object is a caller defect, and a guard whose whole job is to refuse
127
+ // must not guess past one.
128
+ const value = (typeof raw === 'number' || typeof raw === 'string')
129
+ ? Number(String(raw).trim())
130
+ : Number.NaN;
66
131
  if (!Number.isInteger(value)) {
67
- throw new Error(`[TestNamespace] Invalid ${key}="${raw}" - Expected an integer id. `
68
- + 'Fix: correct the value in config/env-active/shared.env.');
132
+ throw new Error(`[TestNamespace] Invalid ${purpose}="${raw}" - Expected an integer id. `
133
+ + `Fix: correct the value ${setBy} - see api/docs/standards/tenant-allocation.md § Env variables.`);
134
+ }
135
+ return value;
136
+ }
137
+
138
+ /**
139
+ * The refusal — one sentence, one place.
140
+ *
141
+ * It was written inline in getTestNamespace() while the whitelist had a single
142
+ * consumer. A second consumer copying it would be the drift that let 97 survive
143
+ * in the list months after the allocation retired it: the list and the sentence
144
+ * naming it must move together, so they are rendered from the same function.
145
+ */
146
+ function refuseTenant(value, { purpose, setBy }) {
147
+ return new Error(`[TestNamespace] ${purpose}=${value} is not an allowed `
148
+ + 'environment class - test and validation runs may never write into production data '
149
+ + '(100 = LIVE, 101+ = CUSTOMER tenants, both refused). Expected one of 96 (CI), '
150
+ + `98 (DEVEL), 99 (VALIDATION). Fix: set ${purpose} to one of those classes - `
151
+ + `${setBy} - and seed that namespace from migrations/ - see `
152
+ + 'api/docs/standards/tenant-allocation.md § The allocation and § Env variables.');
153
+ }
154
+
155
+ /**
156
+ * May this tenant id be written into at all?
157
+ *
158
+ * The question a script asks about an id it was handed, as opposed to
159
+ * getTestNamespace(), which asks which namespace the platform env gives a test.
160
+ * Same whitelist, same refusal; only the name the value carries differs, and the
161
+ * caller supplies it so the message tells the operator what THEY must change.
162
+ *
163
+ * Named for what it enforces (`ALLOWED_TENANT_CLASSES`) the way getTestNamespace
164
+ * is named for what it returns. `assert`, not `is`: a boolean can be ignored by
165
+ * the caller that most needs it, and the value coming back normalized means the
166
+ * check cannot be done and then bypassed with the unchecked original.
167
+ *
168
+ * @param {number|string} tenantId the id to check — an integer, or a string
169
+ * holding one (what `Number(argv[i])` and a config file respectively hand
170
+ * over); no other shape is coerced.
171
+ * @param {{purpose: string, setBy: string}} options
172
+ * `purpose` — the name the caller knows the id by ("--to-tenant");
173
+ * `setBy` — where that name is given a value ("on the command line").
174
+ * @returns {number} the normalized id, safe to use as the namespace.
175
+ * @throws {Error} when the id is absent, not an integer, or not an allowed
176
+ * environment class.
177
+ */
178
+ function assertAllowedTenant(tenantId, options) {
179
+ if (options === null || typeof options !== 'object') {
180
+ requireOriginText(undefined, 'purpose');
181
+ }
182
+ const { purpose, setBy } = options;
183
+ requireOriginText(purpose, 'purpose');
184
+ requireOriginText(setBy, 'setBy');
185
+
186
+ const value = normalizeNamespaceId(tenantId, { purpose, setBy });
187
+ if (!ALLOWED_TENANT_CLASSES.includes(value)) {
188
+ throw refuseTenant(value, { purpose, setBy });
69
189
  }
70
190
  return value;
71
191
  }
72
192
 
193
+ /**
194
+ * The same rail for a WORKSPACE id that did not come from the environment.
195
+ *
196
+ * A Tier-1 cookbook declares the workspace its steps run in
197
+ * (`defaults.workspace_id`, and the per-step override —
198
+ * `.claude/rules/workspace-architecture.md`), so that value reaches ctx without
199
+ * passing `readNamespaceId()`. It goes through this function instead, for one
200
+ * reason: a declared value that is not an id must be REFUSED, naming the key
201
+ * that carries it, and must never slide back to the environment value. A
202
+ * fallback there would hide exactly the misconfiguration the boundary exists to
203
+ * surface (architecture-principles.md §3).
204
+ *
205
+ * There is deliberately NO class allowlist here, and that is not an omission:
206
+ *
207
+ * - `api/docs/standards/tenant-allocation.md` allocates environment classes to
208
+ * TENANTS ("Tenants are environment classes; that is the whole scheme"), and
209
+ * its § Adding a class says a new purpose gets a **workspace inside** the
210
+ * class — so a workspace id is a purpose, with no allocated range to check;
211
+ * - the platform's own default is `TESTING_WORKSPACE_ID=200`, which is not a
212
+ * member of `ALLOWED_TENANT_CLASSES` — applying the tenant list to a
213
+ * workspace would refuse the value the standard itself prescribes;
214
+ * - the boundary the guard has to hold is reachability of production data, and
215
+ * that is held by the tenant, which a cookbook cannot influence at all
216
+ * (`getTestNamespace()` remains the only source of `tenant_id` at Tier-1).
217
+ * Every workspace inside an allowed class is inside the test namespace.
218
+ *
219
+ * @param {number|string} workspaceId the declared id — an integer, or a string
220
+ * holding one; no other shape is coerced.
221
+ * @param {{purpose: string, setBy: string}} options as for assertAllowedTenant:
222
+ * `purpose` is the name the caller knows the id by (here, the cookbook key
223
+ * that carries it), `setBy` where that name is given a value.
224
+ * @returns {number} the normalized id.
225
+ * @throws {Error} when the id is absent or not an integer.
226
+ */
227
+ function assertWorkspaceId(workspaceId, options) {
228
+ if (options === null || typeof options !== 'object') {
229
+ requireOriginText(undefined, 'purpose');
230
+ }
231
+ const { purpose, setBy } = options;
232
+ requireOriginText(purpose, 'purpose');
233
+ requireOriginText(setBy, 'setBy');
234
+
235
+ return normalizeNamespaceId(workspaceId, { purpose, setBy });
236
+ }
237
+
238
+ /**
239
+ * The env half of the boundary. The absence message says what an absent
240
+ * environment variable means and where that variable belongs; the rest of the
241
+ * rail — the integer check and, for the tenant, the whitelist — is the shared
242
+ * one. Six suites in this package assert this sentence verbatim, because it is
243
+ * what a service prints when its namespace is not configured at all.
244
+ */
245
+ function readNamespaceId(key) {
246
+ const raw = process.env[key];
247
+ if (isBlank(raw)) {
248
+ throw new Error(`[TestNamespace] Missing environment variable - ${key} is required. `
249
+ + 'Fix: set it locally in config/env-active/shared.env (the platform default is the '
250
+ + 'non-production namespace); in CI it comes from the pipeline variables, not from '
251
+ + 'a file in the repository - see api/docs/standards/tenant-allocation.md § Env variables. '
252
+ + 'An integration test may not pick a namespace itself.');
253
+ }
254
+ return normalizeNamespaceId(raw, { purpose: key, setBy: ENV_SET_BY });
255
+ }
256
+
73
257
  /**
74
258
  * @returns {{tenant_id: number, workspace_id: number}} ctx-shaped, so it can be
75
259
  * spread straight into a handler call.
76
260
  */
77
261
  function getTestNamespace() {
78
- const tenantId = readNamespaceId('TESTING_TENANT_ID');
79
-
80
262
  // The runtime half of the protection, and the reason the tenant is decided
81
263
  // before anything else is read. R8 rejects a per-service override before it
82
264
  // ships; this refuses the accident that ships anyway, so a startup probe can
83
- // never reach real data even if the configuration is wrong.
265
+ // never reach real data even if the configuration is wrong. The check itself
266
+ // is assertAllowedTenant() — the env is one caller of the boundary, not its
267
+ // owner.
84
268
  //
85
269
  // The workspace id is deliberately NOT class-checked: tenant-allocation.md
86
270
  // allocates environment classes to TENANTS, and calls a workspace "the purpose
87
271
  // within the class" without naming any range for it. A restriction here would
88
272
  // be one this library invented.
89
- if (!ALLOWED_TENANT_CLASSES.includes(tenantId)) {
90
- throw new Error(`[TestNamespace] TESTING_TENANT_ID=${tenantId} is not an allowed `
91
- + 'environment class - test and validation runs may never write into production data '
92
- + '(100 = LIVE, 101+ = CUSTOMER tenants, both refused). Expected one of 96 (CI), '
93
- + '97 (TESTING), 98 (DEVEL), 99 (VALIDATION). Fix: set TESTING_TENANT_ID to one of '
94
- + 'those classes in config/env-active/shared.env and seed that namespace from '
95
- + 'migrations/ - see api/docs/standards/tenant-allocation.md.');
96
- }
273
+ const tenantId = assertAllowedTenant(readNamespaceId('TESTING_TENANT_ID'), {
274
+ purpose: 'TESTING_TENANT_ID',
275
+ setBy: ENV_SET_BY
276
+ });
97
277
 
98
278
  return {
99
279
  tenant_id: tenantId,
@@ -101,4 +281,48 @@ function getTestNamespace() {
101
281
  };
102
282
  }
103
283
 
104
- module.exports = { getTestNamespace };
284
+ /**
285
+ * A namespace an integration test may write into that is NOT its own.
286
+ *
287
+ * The isolation half of the same boundary: a handler filters on tenant_id AND
288
+ * workspace_id, so proving it filters at all takes a second namespace whose rows
289
+ * must stay invisible. Two ways of getting one are banned, and both were in use:
290
+ *
291
+ * a literal — biz-hello asserted against tenant 101, a live CUSTOMER tenant
292
+ * (Meditest), so the isolation test wrote into real data;
293
+ *
294
+ * arithmetic — `TESTING_TENANT_ID + 1` reads as harmless until the platform
295
+ * default 99 makes it 100, the LIVE tenant, which is the accident this module
296
+ * exists to prevent.
297
+ *
298
+ * So the foreign tenant is another member of ALLOWED_TENANT_CLASSES, picked by
299
+ * POSITION in that list and wrapping at its end. Every value it can return is
300
+ * therefore a class the standard allocated to a non-production environment, and
301
+ * adding or retiring a class moves this helper with it — one list, one owner.
302
+ *
303
+ * The workspace is the caller's own: tenant-allocation.md allocates environment
304
+ * classes to TENANTS and names no range for a workspace, so a second workspace
305
+ * would be a value this library invented. The pair already differs, because the
306
+ * tenant does.
307
+ *
308
+ * Deterministic: same environment, same answer, so a fixture seeded for the
309
+ * foreign namespace in one run is the one the next run queries.
310
+ *
311
+ * @returns {{tenant_id: number, workspace_id: number}} ctx-shaped, like
312
+ * getTestNamespace(), and never equal to it.
313
+ */
314
+ function getForeignTestNamespace() {
315
+ // Reuses getTestNamespace() rather than reading the env a second time: the
316
+ // whitelist refusal, the missing-variable message and the integer check are
317
+ // one rail, and a foreign namespace is never chosen for an own value that is
318
+ // itself refused.
319
+ const own = getTestNamespace();
320
+ const next = (ALLOWED_TENANT_CLASSES.indexOf(own.tenant_id) + 1) % ALLOWED_TENANT_CLASSES.length;
321
+
322
+ return {
323
+ tenant_id: ALLOWED_TENANT_CLASSES[next],
324
+ workspace_id: own.workspace_id
325
+ };
326
+ }
327
+
328
+ module.exports = { getTestNamespace, getForeignTestNamespace, assertAllowedTenant, assertWorkspaceId };
@@ -0,0 +1,207 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The one throwaway schema an integration test builds.
5
+ *
6
+ * WHY IT LIVES IN THIS PACKAGE. Every DB-owning biz service has integration
7
+ * suites that need a schema of their own — built from the service's migrations,
8
+ * written into, and dropped at the end. Each repository solved that for itself:
9
+ * biz-meta has `tests/helpers/schemaFixture.js` (three suites use it), and the
10
+ * shape before that was one baseline-only copy per test file. A second
11
+ * implementation of one concern is a defect by default
12
+ * (`change-discipline.md` § One rail per concern), and the concern here is not
13
+ * service-specific at all: the service declares WHAT its database is in
14
+ * `config/service/integration-contract.json`, and HOW a schema gets built from
15
+ * that declaration is uniform (`docs/biz/00-model/uniformity-principle.md`).
16
+ *
17
+ * WHAT IT SHARES WITH THE CI BUILD. Everything except one decision. The
18
+ * migration set and its order (`resolveMigrationPlan`, `migrationOrder.js`), the
19
+ * collation requirement (`requireCollation`), the `CREATE DATABASE … COLLATE`
20
+ * statement (`createDatabaseSql`) and the client invocation (`defaultExec`) all
21
+ * come from `setupDatabase.js`. The one difference is deliberate and is the
22
+ * whole point of the two names:
23
+ *
24
+ * `buildSchema` builds the service's REAL schema in CI, so it
25
+ * refuses a target it did not create — non-empty, or
26
+ * carrying another collation.
27
+ * `createThrowawaySchema` builds a schema the test owns, so it DROPS and
28
+ * recreates it, and refuses only one name: the one
29
+ * the service declares.
30
+ *
31
+ * WHAT IT IS TO DEPLOY-CONTRACT R8. R8 asks an integration test where its
32
+ * namespace comes from, because an integration test writes into a real
33
+ * database. A test that builds its own throwaway targets no shared namespace,
34
+ * and until now it earned its permit by containing the string `CREATE DATABASE`
35
+ * plus a `DB_NAME` redirect — a text heuristic that says nothing about where the
36
+ * statements actually land. Using this helper says it by construction: the
37
+ * schema is created here, every statement is executed against it by name, a
38
+ * `USE` line inside a migration cannot redirect them (§ below), and the declared
39
+ * schema is refused outright. So R8 reads the import instead of the string.
40
+ *
41
+ * THE SCHEMA IS CHOSEN BY THE CALLER, NEVER BY THE FILE. A migration written
42
+ * for the install runner commonly opens with `USE \`oagen_<service>\`;` — the
43
+ * production schema by name. Applied verbatim against a throwaway, that one line
44
+ * sends the whole file into the live schema instead. Those lines are therefore
45
+ * stripped, and a schema select that survives the strip stops the build rather
46
+ * than being applied.
47
+ *
48
+ * @see src/utils/setupDatabase.js — the CI build, and the shared decisions
49
+ * @see src/utils/deployContract.js — R8, which permits a file that imports this
50
+ * @see api/docs/standards/tenant-allocation.md § Enforcement
51
+ */
52
+
53
+ const fs = require('fs');
54
+ const path = require('path');
55
+
56
+ const {
57
+ resolveMigrationPlan,
58
+ requireCollation,
59
+ createDatabaseSql,
60
+ defaultExec
61
+ } = require('./setupDatabase');
62
+
63
+ /** `USE \`x\`;` at the start of a line — the install runner's schema select. */
64
+ const LEADING_SCHEMA_SELECT = /^\s*USE\s+/i;
65
+ /** A schema select that survived the strip: at the start of a line, or after a `;`. */
66
+ const SURVIVING_SCHEMA_SELECT = /(?:^|;)\s*USE\s+/i;
67
+ /** An SQL comment line — prose cannot select a schema. */
68
+ const SQL_COMMENT = /^\s*(?:--|#|\/\*|\*)/;
69
+
70
+ function require_(value, name, fix) {
71
+ if (value === undefined || value === null || value === '') {
72
+ throw new Error(`[ThrowawaySchema] Missing ${name} - Expected a value. Fix: ${fix}`);
73
+ }
74
+ return value;
75
+ }
76
+
77
+ /**
78
+ * The SQL of one migration, with the install runner's schema select removed.
79
+ *
80
+ * @param {string} file absolute path to the .sql file
81
+ * @returns {string}
82
+ */
83
+ function sqlWithoutSchemaSelect(file) {
84
+ const stripped = fs.readFileSync(file, 'utf8')
85
+ .split('\n')
86
+ .map((line) => (LEADING_SCHEMA_SELECT.test(line) ? '' : line))
87
+ .join('\n');
88
+
89
+ const selects = stripped.split('\n')
90
+ .some((line) => !SQL_COMMENT.test(line) && SURVIVING_SCHEMA_SELECT.test(line));
91
+
92
+ if (selects) {
93
+ throw new Error(`[ThrowawaySchema] ${path.basename(file)} still selects a schema after the leading `
94
+ + 'USE lines were stripped - refusing to apply it.\n'
95
+ + ' A throwaway schema is chosen by the test; a USE inside the file would send its statements '
96
+ + 'somewhere else, and that somewhere is the live schema in every migration written for the install '
97
+ + 'runner.\n'
98
+ + ' Fix: put the schema select on a line of its own, or remove it — the runner selects the '
99
+ + 'schema for the file.');
100
+ }
101
+
102
+ return stripped;
103
+ }
104
+
105
+ /**
106
+ * Build a throwaway schema from the service's declared migrations.
107
+ *
108
+ * @param {object} options
109
+ * @param {string} options.serviceRoot repository root of the service
110
+ * @param {object} options.database the `database` block of the integration contract
111
+ * @param {string} options.schema the throwaway name; never `database.schema`
112
+ * @param {object} options.connection `{host, port, user, password}`
113
+ * @param {Function} [options.exec] the SQL executor; defaults to the mysql/mariadb CLI,
114
+ * the same one the CI build uses. May be async — a service whose test image carries
115
+ * no client injects an executor over its own driver connection.
116
+ * @param {string|null} [options.before] apply only the migrations that precede this
117
+ * file name, which is the state that migration is applied to
118
+ * @param {boolean} [options.seeds] also apply the declared seeds, after the migrations
119
+ * @returns {Promise<{schema: string, migrationsApplied: number, seedsApplied: number,
120
+ * migrations: string[], readMigration: Function, applyMigration: Function, dispose: Function}>}
121
+ */
122
+ async function createThrowawaySchema({
123
+ serviceRoot, database, schema, connection, exec = defaultExec, before = null, seeds = false
124
+ }) {
125
+ require_(serviceRoot, 'serviceRoot', 'pass the repository root of the service under test.');
126
+ require_(database, 'database', 'pass the "database" block of config/service/integration-contract.json.');
127
+ require_(schema, 'schema', 'pass the throwaway schema name this suite owns.');
128
+ require_(connection?.host, 'connection.host', 'pass DB_HOST.');
129
+ require_(connection?.user, 'connection.user', 'pass DB_USER.');
130
+
131
+ if (schema === database.schema) {
132
+ throw new Error(`[ThrowawaySchema] Refusing to build ${schema} - that is the schema the service `
133
+ + 'declares, and this build DROPS the schema it is given.\n'
134
+ + ' Fix: pass a name of this suite\'s own, derived from the declared one '
135
+ + `(e.g. "${database.schema}_<suite>test").`);
136
+ }
137
+
138
+ const conn = { ...connection, port: connection.port ?? 3306 };
139
+ const collation = requireCollation(database);
140
+ const plan = resolveMigrationPlan(serviceRoot, database);
141
+
142
+ const fileOf = (name) => {
143
+ const found = plan.migrations.find((file) => path.basename(file) === name);
144
+ if (found === undefined) {
145
+ throw new Error(`[ThrowawaySchema] No migration named "${name}" in ${plan.dir}.\n`
146
+ + ' Fix: name a file the declaration carries; the directory contents are the set, and '
147
+ + 'nothing here keeps a list of them.');
148
+ }
149
+ return found;
150
+ };
151
+
152
+ let toApply = plan.migrations;
153
+ if (before !== null) {
154
+ const stop = plan.migrations.indexOf(fileOf(before));
155
+ if (stop === 0) {
156
+ throw new Error(`[ThrowawaySchema] Nothing is applied before "${before}" - refusing to call an empty `
157
+ + 'schema built.\n'
158
+ + ' Fix: name a migration that has predecessors in the declared set.');
159
+ }
160
+ toApply = plan.migrations.slice(0, stop);
161
+ }
162
+
163
+ const run = async (sql) => {
164
+ const result = await exec({ connection: conn, sql });
165
+ if (result.status !== 0) {
166
+ throw new Error(`[ThrowawaySchema] ${sql} failed:\n${result.stderr}\n`
167
+ + ` Fix: the account must be allowed to create and drop ${schema} on `
168
+ + `${conn.host}:${conn.port}; point DB_HOST at the throwaway database, never at a live server.`);
169
+ }
170
+ };
171
+
172
+ const apply = async (file, kind) => {
173
+ const result = await exec({
174
+ connection: conn, schema, file, sql: sqlWithoutSchemaSelect(file)
175
+ });
176
+ if (result.status !== 0) {
177
+ throw new Error(`[ThrowawaySchema] ${kind} ${path.basename(file)} failed:\n${result.stderr}\n`
178
+ + ' Fix: the set must apply to an empty schema in file order. Either the SQL is wrong, or '
179
+ + 'the file is numbered before something it depends on.');
180
+ }
181
+ };
182
+
183
+ const dropSql = `DROP DATABASE IF EXISTS \`${schema}\`;`;
184
+
185
+ // Dropped first, not "if absent": a suite that died mid-run left its schema
186
+ // behind, and a build onto those leftovers would prove the migration set
187
+ // against a shape it never produced.
188
+ await run(dropSql);
189
+ await run(createDatabaseSql(schema, collation));
190
+
191
+ for (const file of toApply) await apply(file, 'Migration');
192
+
193
+ const seedFiles = seeds === true ? plan.seeds : [];
194
+ for (const file of seedFiles) await apply(file, 'Seed');
195
+
196
+ return {
197
+ schema,
198
+ migrationsApplied: toApply.length,
199
+ seedsApplied: seedFiles.length,
200
+ migrations: [...plan.migrations],
201
+ readMigration: (name) => sqlWithoutSchemaSelect(fileOf(name)),
202
+ applyMigration: async (name) => apply(fileOf(name), 'Migration'),
203
+ dispose: async () => run(dropSql)
204
+ };
205
+ }
206
+
207
+ module.exports = { createThrowawaySchema, sqlWithoutSchemaSelect };
@@ -17,6 +17,7 @@
17
17
 
18
18
  const fs = require('fs');
19
19
  const path = require('path');
20
+ const { HANDLER_REF_PATTERN } = require('../utils/handlerRef');
20
21
 
21
22
  /**
22
23
  * Read a configuration file from the ONE place a service may keep it:
@@ -704,7 +705,7 @@ class ServiceStructureValidator {
704
705
  }
705
706
  }
706
707
 
707
- if (spec.handler && !/^handlers\/[a-zA-Z0-9_\/-]+#[a-zA-Z_][a-zA-Z0-9_]*$/.test(spec.handler)) {
708
+ if (spec.handler && !HANDLER_REF_PATTERN.test(spec.handler)) {
708
709
  this.errors.push({
709
710
  type: 'INVALID_HANDLER_REF',
710
711
  path: 'config/service/operations.json',
@@ -0,0 +1,42 @@
1
+ # --- oa-dockerignore v1
2
+ # Derived from templates/business-service/gitignore, the declaration .gitignore reads too —
3
+ # what a LOCAL production build must not copy into the image (being ignored by git
4
+ # excludes nothing from COPY . .). Everything above this block is this repository's own.
5
+ node_modules
6
+ **/node_modules
7
+ config/runtime
8
+ config/env-active
9
+ conn-runtime
10
+ **/conn-runtime
11
+ ci
12
+ **/ci
13
+ logs
14
+ **/logs
15
+ *.log
16
+ **/*.log
17
+ npm-debug.log*
18
+ **/npm-debug.log*
19
+ coverage
20
+ **/coverage
21
+ .env
22
+ **/.env
23
+ .env.local
24
+ **/.env.local
25
+ .vscode
26
+ **/.vscode
27
+ .idea
28
+ **/.idea
29
+ *.swp
30
+ **/*.swp
31
+ *.swo
32
+ **/*.swo
33
+ *~
34
+ **/*~
35
+ .DS_Store
36
+ **/.DS_Store
37
+ Thumbs.db
38
+ **/Thumbs.db
39
+ .oa_drive_deps_hash
40
+ **/.oa_drive_deps_hash
41
+ .git
42
+ # --- end oa-dockerignore v1