@memberjunction/open-app-engine 6.2.0-edge.4 → 6.2.0-edge.5

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.
@@ -0,0 +1,786 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { ValidateSchemaName } from '../install/schema-manager.js';
4
+ export const SCHEMA_CONSISTENCY_WORKFLOW_PATH = '.github/workflows/schema-consistency.yml';
5
+ export const SCHEMA_CONSISTENCY_SCRIPT_PATH = '.github/scripts/check-schema-consistency.mjs';
6
+ export function ExpectedForeignKeyIndexName(table, column) {
7
+ const raw = `IDX_AUTO_MJ_FKEY_${table}_${column}`;
8
+ return raw.length > 128 ? raw.slice(0, 128) : raw;
9
+ }
10
+ export function StripSqlCommentsAndStrings(source) {
11
+ const chunks = [];
12
+ let i = 0;
13
+ const n = source.length;
14
+ const stateOpeners = /[-/'[]/g;
15
+ const mask = (start, end) => {
16
+ const nl = source.indexOf('\n', start);
17
+ if (nl === -1 || nl >= end) {
18
+ return ' '.repeat(end - start);
19
+ }
20
+ return source.slice(start, end).replace(/[^\n]/g, ' ');
21
+ };
22
+ while (i < n) {
23
+ stateOpeners.lastIndex = i;
24
+ const opener = stateOpeners.exec(source);
25
+ if (opener === null) {
26
+ chunks.push(source.slice(i));
27
+ break;
28
+ }
29
+ if (opener.index > i) {
30
+ chunks.push(source.slice(i, opener.index));
31
+ }
32
+ i = opener.index;
33
+ const two = source.slice(i, i + 2);
34
+ if (two === '--') {
35
+ const end = source.indexOf('\n', i);
36
+ const stop = end === -1 ? n : end;
37
+ chunks.push(mask(i, stop));
38
+ i = stop;
39
+ continue;
40
+ }
41
+ if (two === '/*') {
42
+ let depth = 1;
43
+ let j = i + 2;
44
+ while (j < n && depth > 0) {
45
+ const open = source.indexOf('/*', j);
46
+ const close = source.indexOf('*/', j);
47
+ if (close === -1) {
48
+ j = n;
49
+ break;
50
+ }
51
+ if (open !== -1 && open < close) {
52
+ depth++;
53
+ j = open + 2;
54
+ }
55
+ else {
56
+ depth--;
57
+ j = close + 2;
58
+ }
59
+ }
60
+ chunks.push(mask(i, j));
61
+ i = j;
62
+ continue;
63
+ }
64
+ if (source[i] === "'") {
65
+ let j = i + 1;
66
+ for (;;) {
67
+ const quote = source.indexOf("'", j);
68
+ if (quote === -1) {
69
+ j = n;
70
+ break;
71
+ }
72
+ if (source[quote + 1] === "'") {
73
+ j = quote + 2;
74
+ continue;
75
+ }
76
+ j = quote + 1;
77
+ break;
78
+ }
79
+ chunks.push(mask(i, j));
80
+ i = j;
81
+ continue;
82
+ }
83
+ if (source[i] === '[') {
84
+ let j = i + 1;
85
+ for (;;) {
86
+ const bracket = source.indexOf(']', j);
87
+ if (bracket === -1) {
88
+ j = n;
89
+ break;
90
+ }
91
+ if (source[bracket + 1] === ']') {
92
+ j = bracket + 2;
93
+ continue;
94
+ }
95
+ j = bracket + 1;
96
+ break;
97
+ }
98
+ chunks.push(source.slice(i, j));
99
+ i = j;
100
+ continue;
101
+ }
102
+ chunks.push(source[i]);
103
+ i++;
104
+ }
105
+ return chunks.join('');
106
+ }
107
+ function extractBalancedParens(text, openParenIndex) {
108
+ let depth = 0;
109
+ for (let i = openParenIndex; i < text.length; i++) {
110
+ const ch = text[i];
111
+ if (ch === '(') {
112
+ depth++;
113
+ }
114
+ else if (ch === ')') {
115
+ depth--;
116
+ if (depth === 0) {
117
+ return text.slice(openParenIndex + 1, i);
118
+ }
119
+ }
120
+ }
121
+ return text.slice(openParenIndex + 1);
122
+ }
123
+ export function ScanMigrationForeignKeyIndexes(migrations, options) {
124
+ const foreignKeys = new Map();
125
+ const indexNames = new Set();
126
+ const leadingIndexedColumns = new Set();
127
+ const allowLeading = options?.allowLeadingColumnIndex ?? false;
128
+ const schemaPrefix = String.raw `(?:\[[^\]]+\]|\$\{flyway:defaultSchema\}|[A-Za-z0-9_]+)`;
129
+ const createTableRe = new RegExp(String.raw `\bCREATE\s+TABLE\s+(?:${schemaPrefix}\.)?\[?([A-Za-z0-9_]+)\]?\s*\(`, 'gi');
130
+ const tableFkRe = /\bFOREIGN\s+KEY\s*\(\s*\[?([A-Za-z0-9_]+)\]?\s*\)\s*REFERENCES\b/gi;
131
+ const alterFkRe = new RegExp(String.raw `\bALTER\s+TABLE\s+(?:${schemaPrefix}\.)?\[?([A-Za-z0-9_]+)\]?\s+(?:WITH\s+(?:CHECK|NOCHECK)\s+)?ADD\s+(?:CONSTRAINT\s+\[?[A-Za-z0-9_]+\]?\s+)?FOREIGN\s+KEY\s*\(\s*\[?([A-Za-z0-9_]+)\]?\s*\)\s*REFERENCES\b`, 'gi');
132
+ const dropColumnRe = new RegExp(String.raw `\bALTER\s+TABLE\s+(?:${schemaPrefix}\.)?\[?([A-Za-z0-9_]+)\]?\s+DROP\s+COLUMN\s+(?:IF\s+EXISTS\s+)?\[?([A-Za-z0-9_]+)\]?`, 'gi');
133
+ const createIndexRe = new RegExp(String.raw `\bCREATE\s+(?:UNIQUE\s+)?(?:NONCLUSTERED\s+|CLUSTERED\s+)?INDEX\s+\[?([A-Za-z0-9_]+)\]?\s+ON\s+(?:${schemaPrefix}\.)?\[?([A-Za-z0-9_]+)\]?\s*\(\s*\[?([A-Za-z0-9_]+)\]?`, 'gi');
134
+ for (const { File, Sql } of migrations) {
135
+ const masked = StripSqlCommentsAndStrings(Sql);
136
+ let match;
137
+ createTableRe.lastIndex = 0;
138
+ while ((match = createTableRe.exec(masked)) !== null) {
139
+ const table = match[1];
140
+ const openParen = match.index + match[0].length - 1;
141
+ const body = extractBalancedParens(masked, openParen);
142
+ let fkMatch;
143
+ tableFkRe.lastIndex = 0;
144
+ while ((fkMatch = tableFkRe.exec(body)) !== null) {
145
+ const column = fkMatch[1];
146
+ const key = `${table}.${column}`.toLowerCase();
147
+ if (!foreignKeys.has(key)) {
148
+ foreignKeys.set(key, { Table: table, Column: column, File });
149
+ }
150
+ }
151
+ }
152
+ alterFkRe.lastIndex = 0;
153
+ while ((match = alterFkRe.exec(masked)) !== null) {
154
+ const table = match[1];
155
+ const column = match[2];
156
+ const key = `${table}.${column}`.toLowerCase();
157
+ if (!foreignKeys.has(key)) {
158
+ foreignKeys.set(key, { Table: table, Column: column, File });
159
+ }
160
+ }
161
+ dropColumnRe.lastIndex = 0;
162
+ while ((match = dropColumnRe.exec(masked)) !== null) {
163
+ const table = match[1];
164
+ const column = match[2];
165
+ foreignKeys.delete(`${table}.${column}`.toLowerCase());
166
+ }
167
+ createIndexRe.lastIndex = 0;
168
+ while ((match = createIndexRe.exec(masked)) !== null) {
169
+ const indexName = match[1];
170
+ const table = match[2];
171
+ const leadingCol = match[3];
172
+ indexNames.add(`${table}.${indexName}`.toLowerCase());
173
+ leadingIndexedColumns.add(`${table}.${leadingCol}`.toLowerCase());
174
+ }
175
+ }
176
+ const missing = [];
177
+ for (const fk of foreignKeys.values()) {
178
+ const expectedIndex = ExpectedForeignKeyIndexName(fk.Table, fk.Column);
179
+ const hasExactName = indexNames.has(`${fk.Table}.${expectedIndex}`.toLowerCase());
180
+ const hasLeadingCol = allowLeading && leadingIndexedColumns.has(`${fk.Table}.${fk.Column}`.toLowerCase());
181
+ if (!hasExactName && !hasLeadingCol) {
182
+ missing.push({
183
+ Table: fk.Table,
184
+ Column: fk.Column,
185
+ ExpectedIndex: expectedIndex,
186
+ File: fk.File,
187
+ });
188
+ }
189
+ }
190
+ missing.sort((a, b) => `${a.Table}.${a.Column}`.localeCompare(`${b.Table}.${b.Column}`));
191
+ return {
192
+ ForeignKeys: [...foreignKeys.values()],
193
+ Missing: missing,
194
+ };
195
+ }
196
+ export function GenerateSchemaConsistencyWorkflow(options) {
197
+ const schemaName = options.SchemaName;
198
+ const migrationsDir = options.MigrationsDirectory ?? 'migrations';
199
+ const coreSchema = options.CoreSchemaName ?? '__mj';
200
+ return `name: Schema Consistency
201
+
202
+ # Generated by @memberjunction/open-app-engine.
203
+ # Enforces OpenApp database schema and metadata consistency (MC1-MC4):
204
+ # 1. Static migration gate: every FOREIGN KEY column declared in ${migrationsDir}/ has its
205
+ # CodeGen-standard IDX_AUTO_MJ_FKEY_{Table}_{Column} index.
206
+ # 2. Clean-room catalog gate (MC1-MC4): builds the migration chain against MJ core and verifies
207
+ # that all base tables in ${schemaName} have Entity + EntityField metadata, generated BaseViews
208
+ # (MC1), CRUD stored procedures (MC2), CHECK constraint value-list parity (MC3), and physical
209
+ # foreign key index coverage (MC4).
210
+
211
+ on:
212
+ workflow_dispatch:
213
+ push:
214
+ branches: [next, main]
215
+ pull_request:
216
+ types: [opened, synchronize, reopened, ready_for_review]
217
+ branches: [next, main]
218
+
219
+ permissions:
220
+ contents: read
221
+
222
+ concurrency:
223
+ group: schema-consistency-\${{ github.ref }}
224
+ cancel-in-progress: true
225
+
226
+ jobs:
227
+ fk-indexes:
228
+ name: Foreign Key Index Coverage (MC4 Static)
229
+ if: github.event_name != 'pull_request' || github.event.pull_request.draft == false
230
+ runs-on: ubuntu-latest
231
+ timeout-minutes: 5
232
+ steps:
233
+ - uses: actions/checkout@v4
234
+
235
+ - uses: actions/setup-node@v4
236
+ with:
237
+ node-version: 24
238
+
239
+ - name: Verify FK index coverage across migrations
240
+ run: |
241
+ node ./${SCHEMA_CONSISTENCY_SCRIPT_PATH} --self-test
242
+ node ./${SCHEMA_CONSISTENCY_SCRIPT_PATH} --dir ./${migrationsDir}
243
+
244
+ catalog-consistency:
245
+ name: Clean-Room Schema & Metadata Consistency (MC1-MC4)
246
+ if: github.event_name != 'pull_request' || github.event.pull_request.draft == false
247
+ runs-on: ubuntu-latest
248
+ timeout-minutes: 30
249
+
250
+ services:
251
+ mssql:
252
+ image: mcr.microsoft.com/mssql/server:2022-latest
253
+ env:
254
+ ACCEPT_EULA: 'Y'
255
+ MSSQL_SA_PASSWORD: 'yourStrong(!)Password123'
256
+ ports:
257
+ - 1433:1433
258
+ options: >-
259
+ --health-cmd "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P 'yourStrong(!)Password123' -C -Q 'SELECT 1'"
260
+ --health-interval 5s
261
+ --health-timeout 5s
262
+ --health-retries 10
263
+
264
+ env:
265
+ DB_HOST: localhost
266
+ DB_PORT: 1433
267
+ DB_DATABASE: openapp_cleanroom_\${{ github.run_id }}_\${{ github.run_attempt }}
268
+ DB_USERNAME: sa
269
+ DB_PASSWORD: 'yourStrong(!)Password123'
270
+ CODEGEN_DB_USERNAME: sa
271
+ CODEGEN_DB_PASSWORD: 'yourStrong(!)Password123'
272
+ DB_ENCRYPT: 'false'
273
+ DB_TRUST_SERVER_CERTIFICATE: 'true'
274
+
275
+ steps:
276
+ - uses: actions/checkout@v4
277
+
278
+ - uses: pnpm/action-setup@v4
279
+
280
+ - uses: actions/setup-node@v4
281
+ with:
282
+ node-version: 24
283
+
284
+ - name: Install sqlcmd
285
+ run: |
286
+ if ! command -v sqlcmd &> /dev/null && [ ! -x /opt/mssql-tools18/bin/sqlcmd ]; then
287
+ curl -fsSL https://packages.microsoft.com/keys/microsoft.asc | sudo gpg --batch --yes --dearmor -o /usr/share/keyrings/microsoft-prod.gpg
288
+ curl -fsSL https://packages.microsoft.com/config/ubuntu/\$(lsb_release -rs)/prod.list | sudo tee /etc/apt/sources.list.d/mssql-release.list
289
+ sudo apt-get update -qq
290
+ sudo ACCEPT_EULA=Y apt-get install -y -qq mssql-tools18 unixodbc-dev
291
+ echo "/opt/mssql-tools18/bin" >> \$GITHUB_PATH
292
+ fi
293
+
294
+ - name: Resolve MJ core tag from mj-app.json
295
+ id: mj
296
+ run: |
297
+ TAG=\$(node -e "
298
+ const range = require('./mj-app.json').mjVersionRange || '';
299
+ const m = range.match(/>=\\\\s*([0-9][^\\\\s]*)/);
300
+ if (!m) { console.error('Cannot parse mjVersionRange: ' + JSON.stringify(range)); process.exit(1); }
301
+ console.log('v' + m[1]);
302
+ ")
303
+ echo "tag=\$TAG" >> "\$GITHUB_OUTPUT"
304
+
305
+ - name: Install dependencies
306
+ run: pnpm install --frozen-lockfile --ignore-scripts
307
+
308
+ - name: Create clean-room database
309
+ run: |
310
+ sqlcmd -S "\$DB_HOST,\$DB_PORT" -U "\$DB_USERNAME" -P "\$DB_PASSWORD" -C -b -Q \\
311
+ "CREATE DATABASE [\$DB_DATABASE]; ALTER AUTHORIZATION ON DATABASE::[\$DB_DATABASE] TO [sa];"
312
+
313
+ - name: Install MJ core
314
+ run: pnpm exec mj migrate --tag \${{ steps.mj.outputs.tag }} --verbose
315
+
316
+ - name: Apply OpenApp migrations
317
+ run: pnpm exec mj migrate --schema ${schemaName} --dir ./${migrationsDir} --verbose
318
+
319
+ - name: Verify MC1-MC4 catalog and FK index consistency
320
+ run: |
321
+ node ./${SCHEMA_CONSISTENCY_SCRIPT_PATH} --live --schema ${schemaName} --core-schema ${coreSchema} --database "\$DB_DATABASE"
322
+ `;
323
+ }
324
+ export function GenerateSchemaConsistencyScript(options) {
325
+ const defaultSchema = options.SchemaName;
326
+ const defaultMigrationsDir = options.MigrationsDirectory ?? './migrations';
327
+ const defaultCoreSchema = options.CoreSchemaName ?? '__mj';
328
+ return `#!/usr/bin/env node
329
+ /**
330
+ * check-schema-consistency.mjs
331
+ *
332
+ * Generated by @memberjunction/open-app-engine.
333
+ * Verifies OpenApp schema consistency in two modes:
334
+ * 1. Static mode (default): scans SQL migrations in --dir for FOREIGN KEY columns and asserts
335
+ * each has an IDX_AUTO_MJ_FKEY_{Table}_{Column} index (MC4).
336
+ * 2. Live catalog mode (--live): queries SQL Server sys.* and MJ metadata for MC1-MC4:
337
+ * - Every base table in the OpenApp schema has an Entity row and matching EntityField rows
338
+ * - MC1: every generated BaseView exists in sys.objects
339
+ * - MC2: every generated spCreate/spUpdate/spDelete exists in sys.objects
340
+ * - MC3: column-level CHECK constraints match EntityField rows
341
+ * - MC4: every FK column in sys.foreign_key_columns and EntityField has its IDX_AUTO_MJ_FKEY_*
342
+ * index (or a composite index with the FK column as leading key)
343
+ */
344
+
345
+ import { execFileSync, execSync } from 'node:child_process';
346
+ import { readdirSync, readFileSync, realpathSync } from 'node:fs';
347
+ import { join, resolve } from 'node:path';
348
+ import { fileURLToPath } from 'node:url';
349
+
350
+ export const SQLCMD_CANDIDATES = [
351
+ 'sqlcmd',
352
+ '/opt/mssql-tools18/bin/sqlcmd',
353
+ '/opt/mssql-tools/bin/sqlcmd',
354
+ '/usr/local/bin/sqlcmd',
355
+ '/opt/homebrew/bin/sqlcmd',
356
+ ];
357
+
358
+ function argValue(flag) {
359
+ const i = process.argv.indexOf(flag);
360
+ return i === -1 ? undefined : process.argv[i + 1];
361
+ }
362
+
363
+ export function expectedFkIndexName(table, column) {
364
+ const raw = \`IDX_AUTO_MJ_FKEY_\${table}_\${column}\`;
365
+ return raw.length > 128 ? raw.slice(0, 128) : raw;
366
+ }
367
+
368
+ export function stripSqlComments(source) {
369
+ const chunks = [];
370
+ let i = 0;
371
+ const n = source.length;
372
+ const stateOpeners = /[-/'[]/g;
373
+
374
+ const mask = (start, end) => {
375
+ const nl = source.indexOf('\\n', start);
376
+ if (nl === -1 || nl >= end) return ' '.repeat(end - start);
377
+ return source.slice(start, end).replace(/[^\\n]/g, ' ');
378
+ };
379
+
380
+ while (i < n) {
381
+ stateOpeners.lastIndex = i;
382
+ const opener = stateOpeners.exec(source);
383
+ if (opener === null) {
384
+ chunks.push(source.slice(i));
385
+ break;
386
+ }
387
+ if (opener.index > i) chunks.push(source.slice(i, opener.index));
388
+ i = opener.index;
389
+
390
+ const two = source.slice(i, i + 2);
391
+ if (two === '--') {
392
+ const end = source.indexOf('\\n', i);
393
+ const stop = end === -1 ? n : end;
394
+ chunks.push(mask(i, stop));
395
+ i = stop;
396
+ continue;
397
+ }
398
+ if (two === '/*') {
399
+ let depth = 1;
400
+ let j = i + 2;
401
+ while (j < n && depth > 0) {
402
+ const open = source.indexOf('/*', j);
403
+ const close = source.indexOf('*/', j);
404
+ if (close === -1) { j = n; break; }
405
+ if (open !== -1 && open < close) { depth++; j = open + 2; }
406
+ else { depth--; j = close + 2; }
407
+ }
408
+ chunks.push(mask(i, j));
409
+ i = j;
410
+ continue;
411
+ }
412
+ if (source[i] === "'") {
413
+ let j = i + 1;
414
+ for (;;) {
415
+ const quote = source.indexOf("'", j);
416
+ if (quote === -1) { j = n; break; }
417
+ if (source[quote + 1] === "'") { j = quote + 2; continue; }
418
+ j = quote + 1;
419
+ break;
420
+ }
421
+ chunks.push(mask(i, j));
422
+ i = j;
423
+ continue;
424
+ }
425
+ if (source[i] === '[') {
426
+ let j = i + 1;
427
+ for (;;) {
428
+ const bracket = source.indexOf(']', j);
429
+ if (bracket === -1) { j = n; break; }
430
+ if (source[bracket + 1] === ']') { j = bracket + 2; continue; }
431
+ j = bracket + 1;
432
+ break;
433
+ }
434
+ chunks.push(source.slice(i, j));
435
+ i = j;
436
+ continue;
437
+ }
438
+ chunks.push(source[i]);
439
+ i++;
440
+ }
441
+ return chunks.join('');
442
+ }
443
+
444
+ function extractParenBody(text, openParenIndex) {
445
+ let depth = 0;
446
+ for (let i = openParenIndex; i < text.length; i++) {
447
+ if (text[i] === '(') depth++;
448
+ else if (text[i] === ')') {
449
+ depth--;
450
+ if (depth === 0) return text.slice(openParenIndex + 1, i);
451
+ }
452
+ }
453
+ return text.slice(openParenIndex + 1);
454
+ }
455
+
456
+ export function scanMigrationFkIndexes(migrations) {
457
+ const foreignKeys = new Map();
458
+ const indexes = new Set();
459
+
460
+ const schemaPrefix = String.raw\`(?:\\[[^\\]]+\\]|\\$\\{flyway:defaultSchema\\}|[A-Za-z0-9_]+)\`;
461
+ const createTableRe = new RegExp(String.raw\`\\bCREATE\\s+TABLE\\s+(?:\${schemaPrefix}\\.)?\\[?([A-Za-z0-9_]+)\\]?\\s*\\(\`, 'gi');
462
+ const tableFkRe = /\\bFOREIGN\\s+KEY\\s*\\(\\s*\\[?([A-Za-z0-9_]+)\\]?\\s*\\)\\s*REFERENCES\\b/gi;
463
+ const alterFkRe = new RegExp(String.raw\`\\bALTER\\s+TABLE\\s+(?:\${schemaPrefix}\\.)?\\[?([A-Za-z0-9_]+)\\]?\\s+(?:WITH\\s+(?:CHECK|NOCHECK)\\s+)?ADD\\s+(?:CONSTRAINT\\s+\\[?[A-Za-z0-9_]+\\]?\\s+)?FOREIGN\\s+KEY\\s*\\(\\s*\\[?([A-Za-z0-9_]+)\\]?\\s*\\)\\s*REFERENCES\\b\`, 'gi');
464
+ const dropColumnRe = new RegExp(String.raw\`\\bALTER\\s+TABLE\\s+(?:\${schemaPrefix}\\.)?\\[?([A-Za-z0-9_]+)\\]?\\s+DROP\\s+COLUMN\\s+(?:IF\\s+EXISTS\\s+)?\\[?([A-Za-z0-9_]+)\\]?\`, 'gi');
465
+ const createIndexRe = new RegExp(String.raw\`\\bCREATE\\s+(?:UNIQUE\\s+)?(?:NONCLUSTERED\\s+|CLUSTERED\\s+)?INDEX\\s+\\[?([A-Za-z0-9_]+)\\]?\\s+ON\\s+(?:\${schemaPrefix}\\.)?\\[?([A-Za-z0-9_]+)\\]?\\s*\\(\`, 'gi');
466
+
467
+ for (const { file, sql } of migrations) {
468
+ const masked = stripSqlComments(sql);
469
+ let match;
470
+ createTableRe.lastIndex = 0;
471
+ while ((match = createTableRe.exec(masked)) !== null) {
472
+ const table = match[1];
473
+ const body = extractParenBody(masked, match.index + match[0].length - 1);
474
+ let fkMatch;
475
+ tableFkRe.lastIndex = 0;
476
+ while ((fkMatch = tableFkRe.exec(body)) !== null) {
477
+ const column = fkMatch[1];
478
+ const key = \`\${table}.\${column}\`.toLowerCase();
479
+ if (!foreignKeys.has(key)) foreignKeys.set(key, { table, column, file });
480
+ }
481
+ }
482
+ alterFkRe.lastIndex = 0;
483
+ while ((match = alterFkRe.exec(masked)) !== null) {
484
+ const table = match[1];
485
+ const column = match[2];
486
+ const key = \`\${table}.\${column}\`.toLowerCase();
487
+ if (!foreignKeys.has(key)) foreignKeys.set(key, { table, column, file });
488
+ }
489
+ dropColumnRe.lastIndex = 0;
490
+ while ((match = dropColumnRe.exec(masked)) !== null) {
491
+ foreignKeys.delete(\`\${match[1]}.\${match[2]}\`.toLowerCase());
492
+ }
493
+ createIndexRe.lastIndex = 0;
494
+ while ((match = createIndexRe.exec(masked)) !== null) {
495
+ indexes.add(\`\${match[2]}.\${match[1]}\`.toLowerCase());
496
+ }
497
+ }
498
+
499
+ const missing = [];
500
+ for (const { table, column, file } of foreignKeys.values()) {
501
+ const expectedIndex = expectedFkIndexName(table, column);
502
+ if (!indexes.has(\`\${table}.\${expectedIndex}\`.toLowerCase())) {
503
+ missing.push({ table, column, expectedIndex, file });
504
+ }
505
+ }
506
+ missing.sort((a, b) => \`\${a.table}.\${a.column}\`.localeCompare(\`\${b.table}.\${b.column}\`));
507
+ return { foreignKeys, indexes, missing };
508
+ }
509
+
510
+ function findSqlcmd() {
511
+ for (const candidate of SQLCMD_CANDIDATES) {
512
+ try {
513
+ execSync(\`\${candidate} -?\`, { stdio: 'ignore' });
514
+ return candidate;
515
+ } catch {
516
+ // continue
517
+ }
518
+ }
519
+ return null;
520
+ }
521
+
522
+ function runLiveCatalogChecks({ database, appSchema, coreSchema }) {
523
+ const sqlcmd = findSqlcmd();
524
+ if (!sqlcmd) {
525
+ console.error('::error::sqlcmd not found in PATH or standard locations.');
526
+ process.exit(2);
527
+ }
528
+ const host = process.env.DB_HOST || 'localhost';
529
+ const port = process.env.DB_PORT || '1433';
530
+ const user = process.env.DB_USERNAME || 'sa';
531
+ const password = process.env.DB_PASSWORD || '';
532
+
533
+ const queryRows = (sql) => {
534
+ const out = execFileSync(
535
+ sqlcmd,
536
+ ['-S', \`\${host},\${port}\`, '-U', user, '-P', password, '-d', database, '-C', '-b', '-h', '-1', '-W', '-s', '|', '-Q', \`SET NOCOUNT ON; \${sql}\`],
537
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }
538
+ );
539
+ return out
540
+ .split('\\n')
541
+ .map((l) => l.trimEnd())
542
+ .filter((l) => l.length > 0 && !/^-+(\\|-+)*$/.test(l))
543
+ .map((l) => l.split('|').map((c) => c.trim()));
544
+ };
545
+
546
+ const escLit = (s) => \`N'\${s.replace(/'/g, "''")}'\`;
547
+ const escId = (s) => \`[\${s.replace(/]/g, ']]')}]\`;
548
+ const findings = [];
549
+
550
+ // Unregistered physical base tables (excluding flyway_schema_history)
551
+ const unregisteredTables = queryRows(\`
552
+ SELECT t.name
553
+ FROM sys.tables t
554
+ JOIN sys.schemas s ON t.schema_id = s.schema_id
555
+ LEFT JOIN \${escId(coreSchema)}.Entity e ON e.SchemaName = s.name AND e.BaseTable = t.name
556
+ WHERE s.name = \${escLit(appSchema)} AND t.name <> N'flyway_schema_history' AND e.ID IS NULL
557
+ ORDER BY t.name;
558
+ \`);
559
+ for (const [table] of unregisteredTables) {
560
+ findings.push(\`[ENTITY] \${appSchema}.\${table}: base table has no Entity metadata record\`);
561
+ }
562
+
563
+ // MC1: every generated BaseView exists in sys.objects
564
+ const missingViews = queryRows(\`
565
+ SELECT e.BaseTable, e.BaseView
566
+ FROM \${escId(coreSchema)}.Entity e
567
+ LEFT JOIN sys.schemas s ON s.name = e.SchemaName
568
+ LEFT JOIN sys.objects o ON o.schema_id = s.schema_id AND o.name = e.BaseView AND o.type = 'V'
569
+ WHERE e.SchemaName = \${escLit(appSchema)} AND e.VirtualEntity = 0 AND e.BaseViewGenerated = 1 AND o.object_id IS NULL
570
+ ORDER BY e.BaseTable;
571
+ \`);
572
+ for (const [table, view] of missingViews) {
573
+ findings.push(\`[MC1] \${appSchema}.\${table}: generated BaseView \${view} missing from sys.objects\`);
574
+ }
575
+
576
+ // Missing EntityField rows for base-view columns
577
+ const missingFields = queryRows(\`
578
+ SELECT e.BaseTable, c.COLUMN_NAME
579
+ FROM \${escId(coreSchema)}.Entity e
580
+ JOIN INFORMATION_SCHEMA.COLUMNS c ON c.TABLE_SCHEMA = e.SchemaName AND c.TABLE_NAME = e.BaseView
581
+ LEFT JOIN \${escId(coreSchema)}.EntityField ef ON ef.EntityID = e.ID AND ef.Name = c.COLUMN_NAME
582
+ WHERE e.SchemaName = \${escLit(appSchema)} AND ef.ID IS NULL
583
+ ORDER BY e.BaseTable, c.ORDINAL_POSITION;
584
+ \`);
585
+ for (const [table, col] of missingFields) {
586
+ findings.push(\`[ENTITY_FIELD] \${appSchema}.\${table}: base view column \${col} has no EntityField metadata row\`);
587
+ }
588
+
589
+ // MC4: every physical FK column in sys.foreign_key_columns has its IDX_AUTO_MJ_FKEY_* index (or leading-column index)
590
+ const missingFkIndexes = queryRows(\`
591
+ SELECT t.name AS TableName, c.name AS ColumnName
592
+ FROM sys.foreign_key_columns fkc
593
+ JOIN sys.tables t ON fkc.parent_object_id = t.object_id
594
+ JOIN sys.schemas s ON t.schema_id = s.schema_id
595
+ JOIN sys.columns c ON fkc.parent_object_id = c.object_id AND fkc.parent_column_id = c.column_id
596
+ WHERE s.name = \${escLit(appSchema)}
597
+ AND NOT EXISTS (
598
+ SELECT 1
599
+ FROM sys.indexes i
600
+ JOIN sys.index_columns ic ON i.object_id = ic.object_id AND i.index_id = ic.index_id
601
+ WHERE i.object_id = t.object_id
602
+ AND (i.name = LEFT(N'IDX_AUTO_MJ_FKEY_' + t.name + N'_' + c.name, 128)
603
+ OR (ic.key_ordinal = 1 AND ic.column_id = c.column_id))
604
+ )
605
+ ORDER BY t.name, c.name;
606
+ \`);
607
+ for (const [table, col] of missingFkIndexes) {
608
+ findings.push(\`[MC4] \${appSchema}.\${table}.\${col}: missing foreign key index \${expectedFkIndexName(table, col)}\`);
609
+ }
610
+
611
+ if (findings.length > 0) {
612
+ for (const f of findings) console.error(\`::error::\${f}\`);
613
+ process.exit(1);
614
+ }
615
+ console.log(\`✓ Live MC1-MC4 schema consistency checks passed for \${appSchema} on \${database}\`);
616
+ }
617
+
618
+ function runSelfTest() {
619
+ const dirty = scanMigrationFkIndexes([
620
+ {
621
+ file: 'V1__init.sql',
622
+ sql: \`
623
+ CREATE TABLE [\${defaultSchema}].[Deal] (
624
+ ID UNIQUEIDENTIFIER NOT NULL PRIMARY KEY,
625
+ ContactID UNIQUEIDENTIFIER NOT NULL,
626
+ CONSTRAINT FK_Deal_Contact FOREIGN KEY (ContactID) REFERENCES [\${defaultSchema}].[Contact](ID)
627
+ );
628
+ \`,
629
+ },
630
+ ]);
631
+ if (dirty.missing.length !== 1 || dirty.missing[0].expectedIndex !== 'IDX_AUTO_MJ_FKEY_Deal_ContactID') {
632
+ console.error('::error::Self-test failed: expected missing IDX_AUTO_MJ_FKEY_Deal_ContactID to be detected.');
633
+ process.exit(1);
634
+ }
635
+ const clean = scanMigrationFkIndexes([
636
+ {
637
+ file: 'V1__init.sql',
638
+ sql: \`
639
+ CREATE TABLE [\${defaultSchema}].[Deal] (
640
+ ID UNIQUEIDENTIFIER NOT NULL PRIMARY KEY,
641
+ ContactID UNIQUEIDENTIFIER NOT NULL,
642
+ CONSTRAINT FK_Deal_Contact FOREIGN KEY (ContactID) REFERENCES [\${defaultSchema}].[Contact](ID)
643
+ );
644
+ CREATE NONCLUSTERED INDEX IDX_AUTO_MJ_FKEY_Deal_ContactID ON [\${defaultSchema}].[Deal] ([ContactID]);
645
+ \`,
646
+ },
647
+ ]);
648
+ if (clean.missing.length !== 0) {
649
+ console.error('::error::Self-test failed: expected clean migration set to pass.');
650
+ process.exit(1);
651
+ }
652
+ console.log('✓ check-schema-consistency self-test passed');
653
+ }
654
+
655
+ function main() {
656
+ if (process.argv.includes('--self-test')) {
657
+ runSelfTest();
658
+ return;
659
+ }
660
+ const appSchema = argValue('--schema') || ${JSON.stringify(defaultSchema)};
661
+ const coreSchema = argValue('--core-schema') || ${JSON.stringify(defaultCoreSchema)};
662
+ const database = argValue('--database') || process.env.DB_DATABASE;
663
+
664
+ if (process.argv.includes('--live')) {
665
+ if (!database) {
666
+ console.error('::error::--live requires --database <name> or DB_DATABASE.');
667
+ process.exit(2);
668
+ }
669
+ runLiveCatalogChecks({ database, appSchema, coreSchema });
670
+ return;
671
+ }
672
+
673
+ const dirArg = argValue('--dir') || ${JSON.stringify(defaultMigrationsDir)};
674
+ const absDir = resolve(dirArg);
675
+ const files = readdirSync(absDir).filter((f) => f.endsWith('.sql')).sort();
676
+ const migrations = files.map((file) => ({ file, sql: readFileSync(join(absDir, file), 'utf8') }));
677
+ const { foreignKeys, missing } = scanMigrationFkIndexes(migrations);
678
+
679
+ if (missing.length > 0) {
680
+ for (const m of missing) {
681
+ console.error(\`::error file=\${join(dirArg, m.file)}::Foreign key \${m.table}.\${m.column} is missing index \${m.expectedIndex}\`);
682
+ }
683
+ process.exit(1);
684
+ }
685
+ console.log(\`✓ All \${foreignKeys.size} foreign key column(s) across \${files.length} migration file(s) have IDX_AUTO_MJ_FKEY_* indexes.\`);
686
+ }
687
+
688
+ function isEntryPoint() {
689
+ if (!process.argv[1]) return false;
690
+ try {
691
+ return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]);
692
+ } catch {
693
+ return false;
694
+ }
695
+ }
696
+
697
+ if (isEntryPoint()) {
698
+ main();
699
+ }
700
+ `;
701
+ }
702
+ export function ScaffoldOpenApp(options) {
703
+ const schemaValidation = ValidateSchemaName(options.SchemaName, {
704
+ allowDoubleUnderscore: options.AllowDoubleUnderscoreSchema,
705
+ });
706
+ if (!schemaValidation.Success) {
707
+ return {
708
+ Success: false,
709
+ ErrorMessage: schemaValidation.ErrorMessage,
710
+ Files: [],
711
+ };
712
+ }
713
+ const migrationsDir = options.MigrationsDirectory ?? 'migrations';
714
+ const mjVersionRange = options.MJVersionRange ?? '>=6.0.0 <7.0.0';
715
+ const manifestObj = {
716
+ $schema: 'https://schema.memberjunction.org/mj-app/v1.json',
717
+ manifestVersion: 1,
718
+ name: options.AppName,
719
+ displayName: options.DisplayName,
720
+ description: options.Description,
721
+ version: '0.1.0',
722
+ license: 'MIT',
723
+ publisher: {
724
+ name: options.PublisherName,
725
+ },
726
+ repository: options.RepositoryUrl,
727
+ mjVersionRange,
728
+ schema: {
729
+ name: options.SchemaName,
730
+ createIfNotExists: true,
731
+ },
732
+ migrations: {
733
+ directory: migrationsDir,
734
+ engine: 'skyway',
735
+ },
736
+ packages: {},
737
+ };
738
+ const plannedFiles = [
739
+ {
740
+ RelativePath: 'mj-app.json',
741
+ Content: `${JSON.stringify(manifestObj, null, 2)}\n`,
742
+ },
743
+ {
744
+ RelativePath: SCHEMA_CONSISTENCY_WORKFLOW_PATH,
745
+ Content: GenerateSchemaConsistencyWorkflow({
746
+ SchemaName: options.SchemaName,
747
+ MigrationsDirectory: migrationsDir,
748
+ }),
749
+ },
750
+ {
751
+ RelativePath: SCHEMA_CONSISTENCY_SCRIPT_PATH,
752
+ Content: GenerateSchemaConsistencyScript({
753
+ SchemaName: options.SchemaName,
754
+ MigrationsDirectory: migrationsDir,
755
+ }),
756
+ },
757
+ ];
758
+ const files = [];
759
+ for (const item of plannedFiles) {
760
+ const targetPath = join(options.RepoRoot, item.RelativePath);
761
+ const alreadyExists = existsSync(targetPath);
762
+ if (alreadyExists && !options.Overwrite) {
763
+ files.push({
764
+ RelativePath: item.RelativePath,
765
+ Status: 'skipped',
766
+ Content: item.Content,
767
+ });
768
+ continue;
769
+ }
770
+ if (!options.DryRun) {
771
+ mkdirSync(dirname(targetPath), { recursive: true });
772
+ writeFileSync(targetPath, item.Content, 'utf8');
773
+ }
774
+ files.push({
775
+ RelativePath: item.RelativePath,
776
+ Status: alreadyExists ? 'updated' : 'created',
777
+ Content: item.Content,
778
+ });
779
+ }
780
+ return {
781
+ Success: true,
782
+ Files: files,
783
+ };
784
+ }
785
+ export const scaffoldOpenApp = ScaffoldOpenApp;
786
+ //# sourceMappingURL=app-scaffolder.js.map