@saasicat/cli 0.10.1 → 0.12.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.
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // `saas-platform` — bootstrap CLI for the SaaS platform.
2
+ // `saasicat` — bootstrap CLI for the SaaSiCat framework.
3
3
  //
4
4
  // Sub-commands:
5
5
  // schema apply [--prisma-schema=PATH] [--fragments=01,02,03]
@@ -7,6 +7,10 @@
7
7
  // Inserts missing platform Prisma models into your schema.prisma.
8
8
  // Idempotent; existing models are left untouched.
9
9
  //
10
+ // schema check [--prisma-schema=PATH] [--fragments=01,02,03]
11
+ // Reports what your schema is missing relative to the canonical
12
+ // fragments. Read-only; exits 1 on drift so CI can gate on it.
13
+ //
10
14
  // Spec: handoff/superadmin/QUICKSTART_SIMPLIFICATIONS.md §P5.
11
15
 
12
16
  import { readFile, writeFile, readdir } from 'node:fs/promises';
@@ -15,7 +19,7 @@ import { dirname, join, resolve } from 'node:path';
15
19
  import { createRequire } from 'node:module';
16
20
  import { spawn } from 'node:child_process';
17
21
 
18
- import { applyFragmentBlocks, extractModelBlocks } from '../dist/index.js';
22
+ import { applyFragmentBlocks, checkSchema, extractModelBlocks } from '../dist/index.js';
19
23
 
20
24
  const require_ = createRequire(import.meta.url);
21
25
 
@@ -46,14 +50,14 @@ function resolveFragmentsDir() {
46
50
  return candidate;
47
51
  }
48
52
 
49
- async function loadFragments(dir, filter) {
53
+ async function selectFragmentFiles(dir, filter) {
50
54
  const files = (await readdir(dir)).filter((f) => f.endsWith('.prisma')).sort();
51
- const selected = filter
52
- ? files.filter((f) => {
53
- const prefix = f.split('-')[0];
54
- return filter.includes(prefix);
55
- })
56
- : files;
55
+ if (!filter) return files;
56
+ return files.filter((f) => filter.includes(f.split('-')[0]));
57
+ }
58
+
59
+ async function loadFragments(dir, filter) {
60
+ const selected = await selectFragmentFiles(dir, filter);
57
61
  const blocks = new Map();
58
62
  for (const file of selected) {
59
63
  const content = await readFile(join(dir, file), 'utf8');
@@ -67,17 +71,24 @@ async function loadFragments(dir, filter) {
67
71
  return { files: selected, blocks };
68
72
  }
69
73
 
70
- async function cmdSchemaApply(args) {
74
+ function parseFragmentFilter(raw) {
75
+ return raw ? raw.split(',').map((s) => s.padStart(2, '0')) : null;
76
+ }
77
+
78
+ async function readSchemaOrExit(args) {
71
79
  const schemaPath = resolve(args['prisma-schema'] ?? 'prisma/schema.prisma');
72
80
  if (!existsSync(schemaPath)) {
73
81
  console.error(`✗ schema.prisma nicht gefunden: ${schemaPath}`);
74
82
  process.exit(1);
75
83
  }
84
+ return { schemaPath, schema: await readFile(schemaPath, 'utf8') };
85
+ }
86
+
87
+ async function cmdSchemaApply(args) {
88
+ const { schemaPath, schema } = await readSchemaOrExit(args);
76
89
 
77
90
  const fragmentsDir = resolveFragmentsDir();
78
- const filter = args.fragments
79
- ? args.fragments.split(',').map((s) => s.padStart(2, '0'))
80
- : null;
91
+ const filter = parseFragmentFilter(args.fragments);
81
92
  if (!filter && !args.all) {
82
93
  console.error(
83
94
  '✗ Entweder --fragments=01,02,03 oder --all übergeben. ' +
@@ -94,7 +105,6 @@ async function cmdSchemaApply(args) {
94
105
  process.exit(1);
95
106
  }
96
107
 
97
- const schema = await readFile(schemaPath, 'utf8');
98
108
  const result = applyFragmentBlocks(schema, blocks, {
99
109
  fragmentLabel: files.join(', '),
100
110
  });
@@ -125,6 +135,102 @@ async function cmdSchemaApply(args) {
125
135
  console.log(' 2. pnpm prisma migrate dev --name add_saas_platform');
126
136
  }
127
137
 
138
+ const MISMATCH_LABELS = {
139
+ type: 'Typ',
140
+ optionality: 'Optionalität',
141
+ list: 'Liste',
142
+ };
143
+
144
+ function printCheckReport(report) {
145
+ if (report.missingFields.length > 0) {
146
+ console.log(`✗ Fehlende Felder (${report.missingFields.length}):`);
147
+ for (const { model, field, type } of report.missingFields) {
148
+ console.log(` ${`${model}.${field}`.padEnd(44)} ${type}`);
149
+ }
150
+ console.log('');
151
+ }
152
+
153
+ if (report.missingEnumValues.length > 0) {
154
+ console.log(`✗ Fehlende Enum-Werte (${report.missingEnumValues.length}):`);
155
+ for (const entry of report.missingEnumValues) {
156
+ console.log(` ${entry.enum}.${entry.value}`);
157
+ }
158
+ console.log('');
159
+ }
160
+
161
+ if (report.fieldMismatches.length > 0) {
162
+ console.log(`✗ Abweichende Felder (${report.fieldMismatches.length}):`);
163
+ for (const { model, field, reason, expected, actual } of report.fieldMismatches) {
164
+ const location = `${model}.${field}`.padEnd(44);
165
+ console.log(
166
+ ` ${location} [${MISMATCH_LABELS[reason]}] erwartet ${expected}, vorhanden ${actual}`,
167
+ );
168
+ }
169
+ console.log('');
170
+ }
171
+
172
+ const breaking = report.missingBlockAttributes.filter((a) => a.kind !== 'index');
173
+ if (breaking.length > 0) {
174
+ console.log(`✗ Fehlende Constraints (${breaking.length}):`);
175
+ for (const { model, kind, expected, actual } of breaking) {
176
+ const suffix = kind === 'map' ? ` — vorhanden: @@map("${actual}")` : '';
177
+ console.log(` ${model.padEnd(28)} ${expected}${suffix}`);
178
+ }
179
+ console.log('');
180
+ }
181
+
182
+ const missingIndexes = report.missingBlockAttributes.filter((a) => a.kind === 'index');
183
+ if (missingIndexes.length > 0) {
184
+ console.log(`→ Fehlende Indizes (${missingIndexes.length}):`);
185
+ for (const { model, expected } of missingIndexes) {
186
+ console.log(` ${model.padEnd(28)} ${expected}`);
187
+ }
188
+ console.log(' Kein Fehler — kostet Query-Zeit, bricht aber nichts.');
189
+ console.log('');
190
+ }
191
+
192
+ const absent = [...report.absentModels, ...report.absentEnums];
193
+ if (absent.length > 0) {
194
+ console.log(`→ Nicht übernommen (${absent.length}): ${absent.join(', ')}`);
195
+ console.log(' Kein Fehler — diese Fragmente nutzt die App nicht.');
196
+ console.log('');
197
+ }
198
+ }
199
+
200
+ async function cmdSchemaCheck(args) {
201
+ const { schemaPath, schema } = await readSchemaOrExit(args);
202
+ const fragmentsDir = resolveFragmentsDir();
203
+ const filter = parseFragmentFilter(args.fragments);
204
+ const files = await selectFragmentFiles(fragmentsDir, filter);
205
+
206
+ if (files.length === 0) {
207
+ console.error('✗ Keine Fragmente ausgewählt.');
208
+ process.exit(1);
209
+ }
210
+
211
+ const fragments = [];
212
+ for (const file of files) {
213
+ fragments.push(await readFile(join(fragmentsDir, file), 'utf8'));
214
+ }
215
+
216
+ console.log(`→ Prüfe ${schemaPath} gegen ${files.length} Fragment(e) aus @saasicat/spec`);
217
+ console.log('');
218
+
219
+ const report = checkSchema(fragments.join('\n'), schema);
220
+ printCheckReport(report);
221
+
222
+ const checked = `${report.checkedModelCount} Model(s), ${report.checkedEnumCount} Enum(s)`;
223
+ if (report.ok) {
224
+ console.log(`✓ Kein Drift. ${checked} geprüft.`);
225
+ return;
226
+ }
227
+
228
+ console.log(`✗ Drift gefunden (${checked} geprüft).`);
229
+ console.log(' Fehlende Felder und Enum-Werte ergänzen, dann migrieren.');
230
+ console.log(' Abweichende Felder prüfen: Plattform-Code liest sie mit dem Spec-Typ.');
231
+ process.exit(1);
232
+ }
233
+
128
234
  function runChild(cmd, args, opts = {}) {
129
235
  return new Promise((resolve_, reject) => {
130
236
  const proc = spawn(cmd, args, { stdio: 'inherit', ...opts });
@@ -142,7 +248,7 @@ async function cmdSchemaMigrate(args) {
142
248
  process.exit(1);
143
249
  }
144
250
 
145
- console.log(`→ Schritt 1/2: saas-platform schema apply ${args['fragments'] ? `--fragments=${args['fragments']}` : '--all'}`);
251
+ console.log(`→ Schritt 1/2: saasicat schema apply ${args['fragments'] ? `--fragments=${args['fragments']}` : '--all'}`);
146
252
  await cmdSchemaApply({
147
253
  ...args,
148
254
  all: args['fragments'] ? undefined : true,
@@ -159,16 +265,21 @@ async function main() {
159
265
  if (cmd === 'schema' && sub === 'apply') {
160
266
  return cmdSchemaApply(parseArgs(rest));
161
267
  }
268
+ if (cmd === 'schema' && sub === 'check') {
269
+ return cmdSchemaCheck(parseArgs(rest));
270
+ }
162
271
  if (cmd === 'schema' && sub === 'migrate') {
163
272
  return cmdSchemaMigrate(parseArgs(rest));
164
273
  }
165
274
  if (cmd === '--help' || cmd === '-h' || !cmd) {
166
- console.log('Usage: saas-platform <command> [...args]');
275
+ console.log('Usage: saasicat <command> [...args]');
167
276
  console.log('');
168
277
  console.log('Commands:');
169
278
  console.log(' schema apply --all alle Plattform-Models einfügen');
170
279
  console.log(' schema apply --fragments=01,02 nur diese Fragmente einfügen');
171
280
  console.log(' schema apply --dry-run nur Diff ausgeben');
281
+ console.log(' schema check Drift gegen @saasicat/spec melden');
282
+ console.log(' schema check --fragments=01,02 nur diese Fragmente prüfen');
172
283
  console.log(' schema migrate --name=<name> apply --all + prisma migrate dev');
173
284
  console.log('');
174
285
  console.log('Optional --prisma-schema=PATH (default prisma/schema.prisma).');
package/dist/index.cjs CHANGED
@@ -67,8 +67,18 @@ __export(index_exports, {
67
67
  UserPortDoctorCheck: () => UserPortDoctorCheck,
68
68
  WhoAmIFlow: () => WhoAmIFlow,
69
69
  applyFragmentBlocks: () => applyFragmentBlocks,
70
+ blockBodyLines: () => blockBodyLines,
71
+ breaksContract: () => breaksContract,
72
+ checkSchema: () => checkSchema,
73
+ extractBlockNames: () => extractBlockNames,
74
+ extractBlocks: () => extractBlocks,
70
75
  extractModelBlocks: () => extractModelBlocks,
71
- extractModelNames: () => extractModelNames
76
+ extractModelNames: () => extractModelNames,
77
+ parseBlockAttributes: () => parseBlockAttributes,
78
+ parseEnumValues: () => parseEnumValues,
79
+ parseFields: () => parseFields,
80
+ parseSchema: () => parseSchema,
81
+ stripLineComment: () => stripLineComment
72
82
  });
73
83
  module.exports = __toCommonJS(index_exports);
74
84
 
@@ -1099,51 +1109,48 @@ var PLATFORM_DOCTOR_CHECK_PROVIDERS = [
1099
1109
  AdminManifestDoctorCheck
1100
1110
  ];
1101
1111
 
1102
- // src/schema-apply.ts
1112
+ // src/prisma-blocks.ts
1103
1113
  function stripLineComment(line) {
1104
1114
  const commentStart = line.indexOf("//");
1105
1115
  return commentStart === -1 ? line : line.slice(0, commentStart);
1106
1116
  }
1107
1117
  __name(stripLineComment, "stripLineComment");
1108
- function extractModelNames(schema) {
1118
+ function declarationPattern(keyword) {
1119
+ return new RegExp(`^\\s*${keyword}\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*\\{`);
1120
+ }
1121
+ __name(declarationPattern, "declarationPattern");
1122
+ function extractBlockNames(schema, keyword) {
1123
+ const pattern = declarationPattern(keyword);
1109
1124
  const names = [];
1110
- const lines = schema.split("\n");
1111
- for (const line of lines) {
1112
- const stripped = stripLineComment(line).trim();
1113
- const match = stripped.match(/^model\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{/);
1125
+ for (const line of schema.split("\n")) {
1126
+ const match = stripLineComment(line).match(pattern);
1114
1127
  if (match) names.push(match[1]);
1115
1128
  }
1116
1129
  return names;
1117
1130
  }
1118
- __name(extractModelNames, "extractModelNames");
1119
- function extractModelBlocks(fragment) {
1131
+ __name(extractBlockNames, "extractBlockNames");
1132
+ function extractBlocks(schema, keyword) {
1133
+ const pattern = declarationPattern(keyword);
1120
1134
  const blocks = /* @__PURE__ */ new Map();
1121
- const lines = fragment.split("\n");
1122
1135
  let current = null;
1123
- for (const rawLine of lines) {
1136
+ for (const rawLine of schema.split("\n")) {
1124
1137
  const stripped = stripLineComment(rawLine);
1138
+ const openCount = (stripped.match(/\{/g) ?? []).length;
1139
+ const closeCount = (stripped.match(/\}/g) ?? []).length;
1125
1140
  if (!current) {
1126
- const match = rawLine.match(/^\s*model\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{/);
1127
- if (match) {
1128
- const openCount = (stripped.match(/\{/g) ?? []).length;
1129
- const closeCount = (stripped.match(/\}/g) ?? []).length;
1130
- current = {
1131
- name: match[1],
1132
- lines: [
1133
- rawLine
1134
- ],
1135
- depth: openCount - closeCount
1136
- };
1137
- if (current.depth === 0) {
1138
- blocks.set(current.name, current.lines.join("\n"));
1139
- current = null;
1140
- }
1141
- }
1142
- continue;
1141
+ const match = stripped.match(pattern);
1142
+ if (!match) continue;
1143
+ current = {
1144
+ name: match[1],
1145
+ lines: [
1146
+ rawLine
1147
+ ],
1148
+ depth: openCount - closeCount
1149
+ };
1150
+ } else {
1151
+ current.lines.push(rawLine);
1152
+ current.depth += openCount - closeCount;
1143
1153
  }
1144
- current.lines.push(rawLine);
1145
- current.depth += (stripped.match(/\{/g) ?? []).length;
1146
- current.depth -= (stripped.match(/\}/g) ?? []).length;
1147
1154
  if (current.depth <= 0) {
1148
1155
  blocks.set(current.name, current.lines.join("\n"));
1149
1156
  current = null;
@@ -1151,6 +1158,23 @@ function extractModelBlocks(fragment) {
1151
1158
  }
1152
1159
  return blocks;
1153
1160
  }
1161
+ __name(extractBlocks, "extractBlocks");
1162
+ function blockBodyLines(block) {
1163
+ const bodyStart = block.indexOf("{");
1164
+ const bodyEnd = block.lastIndexOf("}");
1165
+ if (bodyStart === -1 || bodyEnd <= bodyStart) return [];
1166
+ return block.slice(bodyStart + 1, bodyEnd).split("\n").map((line) => stripLineComment(line).trim()).filter((line) => line.length > 0 && !line.startsWith("@@"));
1167
+ }
1168
+ __name(blockBodyLines, "blockBodyLines");
1169
+
1170
+ // src/schema-apply.ts
1171
+ function extractModelNames(schema) {
1172
+ return extractBlockNames(schema, "model");
1173
+ }
1174
+ __name(extractModelNames, "extractModelNames");
1175
+ function extractModelBlocks(fragment) {
1176
+ return extractBlocks(fragment, "model");
1177
+ }
1154
1178
  __name(extractModelBlocks, "extractModelBlocks");
1155
1179
  function applyFragmentBlocks(schema, fragmentBlocks, options = {}) {
1156
1180
  const existing = new Set(extractModelNames(schema));
@@ -1175,11 +1199,11 @@ function applyFragmentBlocks(schema, fragmentBlocks, options = {}) {
1175
1199
  const header = options.fragmentLabel ? `
1176
1200
 
1177
1201
  // ============================================================
1178
- // Eingef\xFCgt durch \`saas-platform schema apply\` aus ${options.fragmentLabel}
1202
+ // Eingef\xFCgt durch \`saasicat schema apply\` aus ${options.fragmentLabel}
1179
1203
  // ============================================================
1180
1204
  ` : `
1181
1205
 
1182
- // Eingef\xFCgt durch \`saas-platform schema apply\`
1206
+ // Eingef\xFCgt durch \`saasicat schema apply\`
1183
1207
  `;
1184
1208
  const trimmedSchema = schema.endsWith("\n") ? schema : schema + "\n";
1185
1209
  return {
@@ -1190,6 +1214,188 @@ function applyFragmentBlocks(schema, fragmentBlocks, options = {}) {
1190
1214
  }
1191
1215
  __name(applyFragmentBlocks, "applyFragmentBlocks");
1192
1216
 
1217
+ // src/schema-check.ts
1218
+ function renderType(signature) {
1219
+ return `${signature.type}${signature.list ? "[]" : ""}${signature.optional ? "?" : ""}`;
1220
+ }
1221
+ __name(renderType, "renderType");
1222
+ function parseFields(block) {
1223
+ const fields = /* @__PURE__ */ new Map();
1224
+ for (const line of blockBodyLines(block)) {
1225
+ const [name, rawType] = line.split(/\s+/);
1226
+ if (!rawType || name.startsWith("@")) continue;
1227
+ const list = rawType.includes("[]");
1228
+ const optional = rawType.endsWith("?");
1229
+ fields.set(name, {
1230
+ name,
1231
+ type: rawType.replace("[]", "").replace("?", ""),
1232
+ optional,
1233
+ list
1234
+ });
1235
+ }
1236
+ return fields;
1237
+ }
1238
+ __name(parseFields, "parseFields");
1239
+ function parseEnumValues(block) {
1240
+ const values = [];
1241
+ for (const line of blockBodyLines(block)) {
1242
+ for (const token of line.split(/\s+/)) {
1243
+ if (token.startsWith("@")) break;
1244
+ if (token.length > 0) values.push(token);
1245
+ }
1246
+ }
1247
+ return values;
1248
+ }
1249
+ __name(parseEnumValues, "parseEnumValues");
1250
+ function attributeFieldLists(block, attribute) {
1251
+ const pattern = new RegExp(`@@${attribute}\\(([^)]*\\])`, "g");
1252
+ return new Set([
1253
+ ...block.matchAll(pattern)
1254
+ ].map((match) => match[1].replace(/\s+/g, "")));
1255
+ }
1256
+ __name(attributeFieldLists, "attributeFieldLists");
1257
+ function parseBlockAttributes(name, block) {
1258
+ return {
1259
+ indexes: attributeFieldLists(block, "index"),
1260
+ uniques: attributeFieldLists(block, "unique"),
1261
+ map: block.match(/@@map\("([^"]+)"\)/)?.[1] ?? name
1262
+ };
1263
+ }
1264
+ __name(parseBlockAttributes, "parseBlockAttributes");
1265
+ function parseSchema(schema) {
1266
+ const models = /* @__PURE__ */ new Map();
1267
+ const modelAttributes = /* @__PURE__ */ new Map();
1268
+ for (const [name, block] of extractBlocks(schema, "model")) {
1269
+ models.set(name, parseFields(block));
1270
+ modelAttributes.set(name, parseBlockAttributes(name, block));
1271
+ }
1272
+ const enums = /* @__PURE__ */ new Map();
1273
+ for (const [name, block] of extractBlocks(schema, "enum")) {
1274
+ enums.set(name, parseEnumValues(block));
1275
+ }
1276
+ return {
1277
+ models,
1278
+ modelAttributes,
1279
+ enums
1280
+ };
1281
+ }
1282
+ __name(parseSchema, "parseSchema");
1283
+ function isDocumentedTypeSubstitution(spec, app, appEnums) {
1284
+ return spec.type === "String" && appEnums.has(app.type);
1285
+ }
1286
+ __name(isDocumentedTypeSubstitution, "isDocumentedTypeSubstitution");
1287
+ function compareFields(model, specFields, appFields, appEnums, missingFields, fieldMismatches) {
1288
+ for (const [name, spec] of specFields) {
1289
+ const app = appFields.get(name);
1290
+ if (!app) {
1291
+ missingFields.push({
1292
+ model,
1293
+ field: name,
1294
+ type: renderType(spec)
1295
+ });
1296
+ continue;
1297
+ }
1298
+ const mismatch = /* @__PURE__ */ __name((reason) => ({
1299
+ model,
1300
+ field: name,
1301
+ reason,
1302
+ expected: renderType(spec),
1303
+ actual: renderType(app)
1304
+ }), "mismatch");
1305
+ if (spec.type !== app.type && !isDocumentedTypeSubstitution(spec, app, appEnums)) {
1306
+ fieldMismatches.push(mismatch("type"));
1307
+ } else if (spec.list !== app.list) {
1308
+ fieldMismatches.push(mismatch("list"));
1309
+ } else if (!spec.optional && app.optional) {
1310
+ fieldMismatches.push(mismatch("optionality"));
1311
+ }
1312
+ }
1313
+ }
1314
+ __name(compareFields, "compareFields");
1315
+ function compareBlockAttributes(model, spec, app, out) {
1316
+ if (spec.map !== app.map) {
1317
+ out.push({
1318
+ model,
1319
+ kind: "map",
1320
+ expected: `@@map("${spec.map}")`,
1321
+ actual: app.map
1322
+ });
1323
+ }
1324
+ for (const fields of spec.uniques) {
1325
+ if (!app.uniques.has(fields)) {
1326
+ out.push({
1327
+ model,
1328
+ kind: "unique",
1329
+ expected: `@@unique(${fields})`
1330
+ });
1331
+ }
1332
+ }
1333
+ for (const fields of spec.indexes) {
1334
+ if (!app.indexes.has(fields)) {
1335
+ out.push({
1336
+ model,
1337
+ kind: "index",
1338
+ expected: `@@index(${fields})`
1339
+ });
1340
+ }
1341
+ }
1342
+ }
1343
+ __name(compareBlockAttributes, "compareBlockAttributes");
1344
+ function breaksContract(attribute) {
1345
+ return attribute.kind !== "index";
1346
+ }
1347
+ __name(breaksContract, "breaksContract");
1348
+ function checkSchema(specSchema, appSchema) {
1349
+ const spec = parseSchema(specSchema);
1350
+ const app = parseSchema(appSchema);
1351
+ const absentModels = [];
1352
+ const missingFields = [];
1353
+ const fieldMismatches = [];
1354
+ const missingBlockAttributes = [];
1355
+ for (const [model, specFields] of spec.models) {
1356
+ const appFields = app.models.get(model);
1357
+ if (!appFields) {
1358
+ absentModels.push(model);
1359
+ continue;
1360
+ }
1361
+ compareFields(model, specFields, appFields, app.enums, missingFields, fieldMismatches);
1362
+ const specAttrs = spec.modelAttributes.get(model);
1363
+ const appAttrs = app.modelAttributes.get(model);
1364
+ if (specAttrs && appAttrs) {
1365
+ compareBlockAttributes(model, specAttrs, appAttrs, missingBlockAttributes);
1366
+ }
1367
+ }
1368
+ const absentEnums = [];
1369
+ const missingEnumValues = [];
1370
+ for (const [name, specValues] of spec.enums) {
1371
+ const appValues = app.enums.get(name);
1372
+ if (!appValues) {
1373
+ absentEnums.push(name);
1374
+ continue;
1375
+ }
1376
+ for (const value of specValues) {
1377
+ if (!appValues.includes(value)) {
1378
+ missingEnumValues.push({
1379
+ enum: name,
1380
+ value
1381
+ });
1382
+ }
1383
+ }
1384
+ }
1385
+ return {
1386
+ absentModels,
1387
+ absentEnums,
1388
+ missingFields,
1389
+ missingEnumValues,
1390
+ fieldMismatches,
1391
+ missingBlockAttributes,
1392
+ checkedModelCount: spec.models.size - absentModels.length,
1393
+ checkedEnumCount: spec.enums.size - absentEnums.length,
1394
+ ok: missingFields.length === 0 && missingEnumValues.length === 0 && fieldMismatches.length === 0 && !missingBlockAttributes.some(breaksContract)
1395
+ };
1396
+ }
1397
+ __name(checkSchema, "checkSchema");
1398
+
1193
1399
  // src/module.ts
1194
1400
  var import_common8 = require("@nestjs/common");
1195
1401
  var import_nest5 = require("@saasicat/nest");
@@ -2279,6 +2485,16 @@ UserCommands = _ts_decorate14([
2279
2485
  UserPortDoctorCheck,
2280
2486
  WhoAmIFlow,
2281
2487
  applyFragmentBlocks,
2488
+ blockBodyLines,
2489
+ breaksContract,
2490
+ checkSchema,
2491
+ extractBlockNames,
2492
+ extractBlocks,
2282
2493
  extractModelBlocks,
2283
- extractModelNames
2494
+ extractModelNames,
2495
+ parseBlockAttributes,
2496
+ parseEnumValues,
2497
+ parseFields,
2498
+ parseSchema,
2499
+ stripLineComment
2284
2500
  });
package/dist/index.d.cts CHANGED
@@ -326,17 +326,38 @@ declare class AdminManifestDoctorCheck implements DoctorCheck {
326
326
  */
327
327
  declare const PLATFORM_DOCTOR_CHECK_PROVIDERS: Array<Type<DoctorCheck>>;
328
328
 
329
+ /**
330
+ * Cuts off a trailing `//` comment. Deliberately index-based instead of
331
+ * `replace(/\/\/.*$/, '')`: the regex backtracks quadratically on lines with
332
+ * many single slashes.
333
+ */
334
+ declare function stripLineComment(line: string): string;
335
+ /** Returns the names of all top-level `<keyword> X { … }` blocks. */
336
+ declare function extractBlockNames(schema: string, keyword: string): string[];
337
+ /**
338
+ * Returns all top-level `<keyword> X { … }` blocks as a map
339
+ * `name -> complete block text incl. opening/closing braces`.
340
+ *
341
+ * Delimiting logic: we search for `^<keyword> X {` (start of line, possibly
342
+ * with leading whitespace) and close as soon as the brace depth drops back
343
+ * to 0. Braces inside comments are not counted.
344
+ */
345
+ declare function extractBlocks(schema: string, keyword: string): Map<string, string>;
346
+ /**
347
+ * Returns a block's body lines — comments stripped, blank lines, block-level
348
+ * attributes (`@@index`, `@@map`) and the enclosing braces removed.
349
+ *
350
+ * Cuts between the first `{` and the last `}` rather than dropping the first
351
+ * and last line, so single-line blocks (`model X { id String @id }`) yield
352
+ * their body too.
353
+ */
354
+ declare function blockBodyLines(block: string): string[];
355
+
329
356
  /** Returns the names of all top-level `model X { ... }` blocks in the schema. */
330
357
  declare function extractModelNames(schema: string): string[];
331
358
  /**
332
359
  * Returns all `model X { ... }` blocks from a fragment as a map
333
360
  * `name -> complete block text incl. opening/closing braces`.
334
- *
335
- * Delimiting logic: we search for `^model X {` (start of line, possibly with
336
- * leading whitespace) and close as soon as the brace depth drops back to
337
- * 0. Strings/comments inside the block are, in simplified terms, not
338
- * counted, which is robust for Prisma schemas (no curly braces
339
- * in strings, comments only `//`-style).
340
361
  */
341
362
  declare function extractModelBlocks(fragment: string): Map<string, string>;
342
363
  interface ApplyResult {
@@ -355,6 +376,101 @@ declare function applyFragmentBlocks(schema: string, fragmentBlocks: Map<string,
355
376
  fragmentLabel?: string;
356
377
  }): ApplyResult;
357
378
 
379
+ interface FieldSignature {
380
+ name: string;
381
+ /** Type without `?`/`[]` modifiers, e.g. `String`, `BillingCycle`. */
382
+ type: string;
383
+ optional: boolean;
384
+ list: boolean;
385
+ }
386
+ interface BlockAttributes {
387
+ /** Field lists of `@@index`, normalised to `[a,b]` — attribute options dropped. */
388
+ indexes: Set<string>;
389
+ /** Same for `@@unique`. */
390
+ uniques: Set<string>;
391
+ /** `@@map` target, or the model name when unmapped. */
392
+ map: string;
393
+ }
394
+ interface ParsedSchema {
395
+ models: Map<string, Map<string, FieldSignature>>;
396
+ modelAttributes: Map<string, BlockAttributes>;
397
+ enums: Map<string, string[]>;
398
+ }
399
+ interface MissingField {
400
+ model: string;
401
+ field: string;
402
+ /** Rendered spec type incl. modifiers, e.g. `DateTime?`. */
403
+ type: string;
404
+ }
405
+ interface MissingEnumValue {
406
+ enum: string;
407
+ value: string;
408
+ }
409
+ type FieldMismatchReason = 'type' | 'optionality' | 'list';
410
+ interface FieldMismatch {
411
+ model: string;
412
+ field: string;
413
+ reason: FieldMismatchReason;
414
+ expected: string;
415
+ actual: string;
416
+ }
417
+ type BlockAttributeKind = 'index' | 'unique' | 'map';
418
+ interface MissingBlockAttribute {
419
+ model: string;
420
+ kind: BlockAttributeKind;
421
+ /** Rendered attribute, e.g. `@@index([planId, validFrom])`. */
422
+ expected: string;
423
+ /** For `map`: what the consumer maps to instead. */
424
+ actual?: string;
425
+ }
426
+ interface SchemaCheckReport {
427
+ /** Platform models the consumer does not carry — informational. */
428
+ absentModels: string[];
429
+ /** Platform enums the consumer does not carry — informational. */
430
+ absentEnums: string[];
431
+ missingFields: MissingField[];
432
+ missingEnumValues: MissingEnumValue[];
433
+ fieldMismatches: FieldMismatch[];
434
+ /**
435
+ * Block-level attributes the spec declares and the consumer lacks:
436
+ * `@@index`, `@@unique`, and a diverging `@@map`.
437
+ */
438
+ missingBlockAttributes: MissingBlockAttribute[];
439
+ /** Models present in both schemas, i.e. actually compared. */
440
+ checkedModelCount: number;
441
+ /** Enums present in both schemas, i.e. actually compared. */
442
+ checkedEnumCount: number;
443
+ /** True when nothing that breaks platform code was found. */
444
+ ok: boolean;
445
+ }
446
+ /** Parses the field lines of a `model` block into signatures, keyed by name. */
447
+ declare function parseFields(block: string): Map<string, FieldSignature>;
448
+ /**
449
+ * Parses the members of an `enum` block. Values may share a line
450
+ * (`enum Role { ADMIN USER }`); a trailing `@map(…)` attribute is not a value.
451
+ */
452
+ declare function parseEnumValues(block: string): string[];
453
+ /**
454
+ * Parses the block-level attributes of a `model`. Comparing these is what
455
+ * catches a missing index or unique constraint — differences the field-level
456
+ * comparison is blind to.
457
+ */
458
+ declare function parseBlockAttributes(name: string, block: string): BlockAttributes;
459
+ /** Parses every `model` and `enum` declaration of a Prisma schema. */
460
+ declare function parseSchema(schema: string): ParsedSchema;
461
+ /**
462
+ * A missing index costs query time; a missing `@@unique` or a diverging
463
+ * `@@map` breaks correctness — the platform relies on the constraint holding,
464
+ * and on finding the table under its canonical name. Only the latter two fail
465
+ * the check.
466
+ */
467
+ declare function breaksContract(attribute: MissingBlockAttribute): boolean;
468
+ /**
469
+ * Compares a consumer schema against the canonical fragments. `specSchema` is
470
+ * the concatenation of the fragments the check should cover.
471
+ */
472
+ declare function checkSchema(specSchema: string, appSchema: string): SchemaCheckReport;
473
+
358
474
  interface CliContextModuleOptions {
359
475
  config: CliContextConfig;
360
476
  userPort: ProviderSpec<UserPort>;
@@ -545,4 +661,4 @@ declare class UserCommands extends CommandRunner {
545
661
  parsePassword(val: string): string;
546
662
  }
547
663
 
548
- export { AUDIT_QUERY_PORT_TOKEN, AdminCommands, AdminManifestDoctorCheck, AdminMfaSetupCommand, AdminWhoamiCommand, type ApplyResult, AuditCommands, AuditTailCommand, AuditTailFlow, type AuditTailOptions, CLI_CONTEXT_CONFIG_TOKEN, type CheckSeverity, type CliContextConfig, CliContextModule, type CliContextModuleOptions, CliContextService, CliError, type CliIdentity, DEFAULT_MANIFEST_CHECKS, DOCTOR_CHECKS_TOKEN, DiscoveryCommands, DiscoveryScanCommand, DiscoverySnapshotDoctorCheck, type DoctorCheck, type DoctorCheckResult, DoctorCommands, DoctorFlow, type DoctorReport, MANIFEST_ACCESS_PORT_TOKEN, MANIFEST_CHECKS_TOKEN, type ManifestCheck, ManifestCheckCommand, type ManifestCheckReport, type ManifestCheckResult, ManifestCliFlow, ManifestCommands, type ManifestDiff, ManifestDumpCommand, ManifestHashCommand, ManifestValidateCommand, MfaSetupFlow, type MfaSetupOptions, type MfaSetupResult, PLATFORM_DOCTOR_CHECK_PROVIDERS, PlanCatalogDoctorCheck, USER_MANAGEMENT_PORT_TOKEN, USER_PORT_TOKEN, UserCommands, UserPortDoctorCheck, WhoAmIFlow, type WhoAmIResult, applyFragmentBlocks, extractModelBlocks, extractModelNames };
664
+ export { AUDIT_QUERY_PORT_TOKEN, AdminCommands, AdminManifestDoctorCheck, AdminMfaSetupCommand, AdminWhoamiCommand, type ApplyResult, AuditCommands, AuditTailCommand, AuditTailFlow, type AuditTailOptions, type BlockAttributeKind, type BlockAttributes, CLI_CONTEXT_CONFIG_TOKEN, type CheckSeverity, type CliContextConfig, CliContextModule, type CliContextModuleOptions, CliContextService, CliError, type CliIdentity, DEFAULT_MANIFEST_CHECKS, DOCTOR_CHECKS_TOKEN, DiscoveryCommands, DiscoveryScanCommand, DiscoverySnapshotDoctorCheck, type DoctorCheck, type DoctorCheckResult, DoctorCommands, DoctorFlow, type DoctorReport, type FieldMismatch, type FieldMismatchReason, type FieldSignature, MANIFEST_ACCESS_PORT_TOKEN, MANIFEST_CHECKS_TOKEN, type ManifestCheck, ManifestCheckCommand, type ManifestCheckReport, type ManifestCheckResult, ManifestCliFlow, ManifestCommands, type ManifestDiff, ManifestDumpCommand, ManifestHashCommand, ManifestValidateCommand, MfaSetupFlow, type MfaSetupOptions, type MfaSetupResult, type MissingBlockAttribute, type MissingEnumValue, type MissingField, PLATFORM_DOCTOR_CHECK_PROVIDERS, type ParsedSchema, PlanCatalogDoctorCheck, type SchemaCheckReport, USER_MANAGEMENT_PORT_TOKEN, USER_PORT_TOKEN, UserCommands, UserPortDoctorCheck, WhoAmIFlow, type WhoAmIResult, applyFragmentBlocks, blockBodyLines, breaksContract, checkSchema, extractBlockNames, extractBlocks, extractModelBlocks, extractModelNames, parseBlockAttributes, parseEnumValues, parseFields, parseSchema, stripLineComment };
package/dist/index.d.ts CHANGED
@@ -326,17 +326,38 @@ declare class AdminManifestDoctorCheck implements DoctorCheck {
326
326
  */
327
327
  declare const PLATFORM_DOCTOR_CHECK_PROVIDERS: Array<Type<DoctorCheck>>;
328
328
 
329
+ /**
330
+ * Cuts off a trailing `//` comment. Deliberately index-based instead of
331
+ * `replace(/\/\/.*$/, '')`: the regex backtracks quadratically on lines with
332
+ * many single slashes.
333
+ */
334
+ declare function stripLineComment(line: string): string;
335
+ /** Returns the names of all top-level `<keyword> X { … }` blocks. */
336
+ declare function extractBlockNames(schema: string, keyword: string): string[];
337
+ /**
338
+ * Returns all top-level `<keyword> X { … }` blocks as a map
339
+ * `name -> complete block text incl. opening/closing braces`.
340
+ *
341
+ * Delimiting logic: we search for `^<keyword> X {` (start of line, possibly
342
+ * with leading whitespace) and close as soon as the brace depth drops back
343
+ * to 0. Braces inside comments are not counted.
344
+ */
345
+ declare function extractBlocks(schema: string, keyword: string): Map<string, string>;
346
+ /**
347
+ * Returns a block's body lines — comments stripped, blank lines, block-level
348
+ * attributes (`@@index`, `@@map`) and the enclosing braces removed.
349
+ *
350
+ * Cuts between the first `{` and the last `}` rather than dropping the first
351
+ * and last line, so single-line blocks (`model X { id String @id }`) yield
352
+ * their body too.
353
+ */
354
+ declare function blockBodyLines(block: string): string[];
355
+
329
356
  /** Returns the names of all top-level `model X { ... }` blocks in the schema. */
330
357
  declare function extractModelNames(schema: string): string[];
331
358
  /**
332
359
  * Returns all `model X { ... }` blocks from a fragment as a map
333
360
  * `name -> complete block text incl. opening/closing braces`.
334
- *
335
- * Delimiting logic: we search for `^model X {` (start of line, possibly with
336
- * leading whitespace) and close as soon as the brace depth drops back to
337
- * 0. Strings/comments inside the block are, in simplified terms, not
338
- * counted, which is robust for Prisma schemas (no curly braces
339
- * in strings, comments only `//`-style).
340
361
  */
341
362
  declare function extractModelBlocks(fragment: string): Map<string, string>;
342
363
  interface ApplyResult {
@@ -355,6 +376,101 @@ declare function applyFragmentBlocks(schema: string, fragmentBlocks: Map<string,
355
376
  fragmentLabel?: string;
356
377
  }): ApplyResult;
357
378
 
379
+ interface FieldSignature {
380
+ name: string;
381
+ /** Type without `?`/`[]` modifiers, e.g. `String`, `BillingCycle`. */
382
+ type: string;
383
+ optional: boolean;
384
+ list: boolean;
385
+ }
386
+ interface BlockAttributes {
387
+ /** Field lists of `@@index`, normalised to `[a,b]` — attribute options dropped. */
388
+ indexes: Set<string>;
389
+ /** Same for `@@unique`. */
390
+ uniques: Set<string>;
391
+ /** `@@map` target, or the model name when unmapped. */
392
+ map: string;
393
+ }
394
+ interface ParsedSchema {
395
+ models: Map<string, Map<string, FieldSignature>>;
396
+ modelAttributes: Map<string, BlockAttributes>;
397
+ enums: Map<string, string[]>;
398
+ }
399
+ interface MissingField {
400
+ model: string;
401
+ field: string;
402
+ /** Rendered spec type incl. modifiers, e.g. `DateTime?`. */
403
+ type: string;
404
+ }
405
+ interface MissingEnumValue {
406
+ enum: string;
407
+ value: string;
408
+ }
409
+ type FieldMismatchReason = 'type' | 'optionality' | 'list';
410
+ interface FieldMismatch {
411
+ model: string;
412
+ field: string;
413
+ reason: FieldMismatchReason;
414
+ expected: string;
415
+ actual: string;
416
+ }
417
+ type BlockAttributeKind = 'index' | 'unique' | 'map';
418
+ interface MissingBlockAttribute {
419
+ model: string;
420
+ kind: BlockAttributeKind;
421
+ /** Rendered attribute, e.g. `@@index([planId, validFrom])`. */
422
+ expected: string;
423
+ /** For `map`: what the consumer maps to instead. */
424
+ actual?: string;
425
+ }
426
+ interface SchemaCheckReport {
427
+ /** Platform models the consumer does not carry — informational. */
428
+ absentModels: string[];
429
+ /** Platform enums the consumer does not carry — informational. */
430
+ absentEnums: string[];
431
+ missingFields: MissingField[];
432
+ missingEnumValues: MissingEnumValue[];
433
+ fieldMismatches: FieldMismatch[];
434
+ /**
435
+ * Block-level attributes the spec declares and the consumer lacks:
436
+ * `@@index`, `@@unique`, and a diverging `@@map`.
437
+ */
438
+ missingBlockAttributes: MissingBlockAttribute[];
439
+ /** Models present in both schemas, i.e. actually compared. */
440
+ checkedModelCount: number;
441
+ /** Enums present in both schemas, i.e. actually compared. */
442
+ checkedEnumCount: number;
443
+ /** True when nothing that breaks platform code was found. */
444
+ ok: boolean;
445
+ }
446
+ /** Parses the field lines of a `model` block into signatures, keyed by name. */
447
+ declare function parseFields(block: string): Map<string, FieldSignature>;
448
+ /**
449
+ * Parses the members of an `enum` block. Values may share a line
450
+ * (`enum Role { ADMIN USER }`); a trailing `@map(…)` attribute is not a value.
451
+ */
452
+ declare function parseEnumValues(block: string): string[];
453
+ /**
454
+ * Parses the block-level attributes of a `model`. Comparing these is what
455
+ * catches a missing index or unique constraint — differences the field-level
456
+ * comparison is blind to.
457
+ */
458
+ declare function parseBlockAttributes(name: string, block: string): BlockAttributes;
459
+ /** Parses every `model` and `enum` declaration of a Prisma schema. */
460
+ declare function parseSchema(schema: string): ParsedSchema;
461
+ /**
462
+ * A missing index costs query time; a missing `@@unique` or a diverging
463
+ * `@@map` breaks correctness — the platform relies on the constraint holding,
464
+ * and on finding the table under its canonical name. Only the latter two fail
465
+ * the check.
466
+ */
467
+ declare function breaksContract(attribute: MissingBlockAttribute): boolean;
468
+ /**
469
+ * Compares a consumer schema against the canonical fragments. `specSchema` is
470
+ * the concatenation of the fragments the check should cover.
471
+ */
472
+ declare function checkSchema(specSchema: string, appSchema: string): SchemaCheckReport;
473
+
358
474
  interface CliContextModuleOptions {
359
475
  config: CliContextConfig;
360
476
  userPort: ProviderSpec<UserPort>;
@@ -545,4 +661,4 @@ declare class UserCommands extends CommandRunner {
545
661
  parsePassword(val: string): string;
546
662
  }
547
663
 
548
- export { AUDIT_QUERY_PORT_TOKEN, AdminCommands, AdminManifestDoctorCheck, AdminMfaSetupCommand, AdminWhoamiCommand, type ApplyResult, AuditCommands, AuditTailCommand, AuditTailFlow, type AuditTailOptions, CLI_CONTEXT_CONFIG_TOKEN, type CheckSeverity, type CliContextConfig, CliContextModule, type CliContextModuleOptions, CliContextService, CliError, type CliIdentity, DEFAULT_MANIFEST_CHECKS, DOCTOR_CHECKS_TOKEN, DiscoveryCommands, DiscoveryScanCommand, DiscoverySnapshotDoctorCheck, type DoctorCheck, type DoctorCheckResult, DoctorCommands, DoctorFlow, type DoctorReport, MANIFEST_ACCESS_PORT_TOKEN, MANIFEST_CHECKS_TOKEN, type ManifestCheck, ManifestCheckCommand, type ManifestCheckReport, type ManifestCheckResult, ManifestCliFlow, ManifestCommands, type ManifestDiff, ManifestDumpCommand, ManifestHashCommand, ManifestValidateCommand, MfaSetupFlow, type MfaSetupOptions, type MfaSetupResult, PLATFORM_DOCTOR_CHECK_PROVIDERS, PlanCatalogDoctorCheck, USER_MANAGEMENT_PORT_TOKEN, USER_PORT_TOKEN, UserCommands, UserPortDoctorCheck, WhoAmIFlow, type WhoAmIResult, applyFragmentBlocks, extractModelBlocks, extractModelNames };
664
+ export { AUDIT_QUERY_PORT_TOKEN, AdminCommands, AdminManifestDoctorCheck, AdminMfaSetupCommand, AdminWhoamiCommand, type ApplyResult, AuditCommands, AuditTailCommand, AuditTailFlow, type AuditTailOptions, type BlockAttributeKind, type BlockAttributes, CLI_CONTEXT_CONFIG_TOKEN, type CheckSeverity, type CliContextConfig, CliContextModule, type CliContextModuleOptions, CliContextService, CliError, type CliIdentity, DEFAULT_MANIFEST_CHECKS, DOCTOR_CHECKS_TOKEN, DiscoveryCommands, DiscoveryScanCommand, DiscoverySnapshotDoctorCheck, type DoctorCheck, type DoctorCheckResult, DoctorCommands, DoctorFlow, type DoctorReport, type FieldMismatch, type FieldMismatchReason, type FieldSignature, MANIFEST_ACCESS_PORT_TOKEN, MANIFEST_CHECKS_TOKEN, type ManifestCheck, ManifestCheckCommand, type ManifestCheckReport, type ManifestCheckResult, ManifestCliFlow, ManifestCommands, type ManifestDiff, ManifestDumpCommand, ManifestHashCommand, ManifestValidateCommand, MfaSetupFlow, type MfaSetupOptions, type MfaSetupResult, type MissingBlockAttribute, type MissingEnumValue, type MissingField, PLATFORM_DOCTOR_CHECK_PROVIDERS, type ParsedSchema, PlanCatalogDoctorCheck, type SchemaCheckReport, USER_MANAGEMENT_PORT_TOKEN, USER_PORT_TOKEN, UserCommands, UserPortDoctorCheck, WhoAmIFlow, type WhoAmIResult, applyFragmentBlocks, blockBodyLines, breaksContract, checkSchema, extractBlockNames, extractBlocks, extractModelBlocks, extractModelNames, parseBlockAttributes, parseEnumValues, parseFields, parseSchema, stripLineComment };
package/dist/index.js CHANGED
@@ -1028,51 +1028,48 @@ var PLATFORM_DOCTOR_CHECK_PROVIDERS = [
1028
1028
  AdminManifestDoctorCheck
1029
1029
  ];
1030
1030
 
1031
- // src/schema-apply.ts
1031
+ // src/prisma-blocks.ts
1032
1032
  function stripLineComment(line) {
1033
1033
  const commentStart = line.indexOf("//");
1034
1034
  return commentStart === -1 ? line : line.slice(0, commentStart);
1035
1035
  }
1036
1036
  __name(stripLineComment, "stripLineComment");
1037
- function extractModelNames(schema) {
1037
+ function declarationPattern(keyword) {
1038
+ return new RegExp(`^\\s*${keyword}\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*\\{`);
1039
+ }
1040
+ __name(declarationPattern, "declarationPattern");
1041
+ function extractBlockNames(schema, keyword) {
1042
+ const pattern = declarationPattern(keyword);
1038
1043
  const names = [];
1039
- const lines = schema.split("\n");
1040
- for (const line of lines) {
1041
- const stripped = stripLineComment(line).trim();
1042
- const match = stripped.match(/^model\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{/);
1044
+ for (const line of schema.split("\n")) {
1045
+ const match = stripLineComment(line).match(pattern);
1043
1046
  if (match) names.push(match[1]);
1044
1047
  }
1045
1048
  return names;
1046
1049
  }
1047
- __name(extractModelNames, "extractModelNames");
1048
- function extractModelBlocks(fragment) {
1050
+ __name(extractBlockNames, "extractBlockNames");
1051
+ function extractBlocks(schema, keyword) {
1052
+ const pattern = declarationPattern(keyword);
1049
1053
  const blocks = /* @__PURE__ */ new Map();
1050
- const lines = fragment.split("\n");
1051
1054
  let current = null;
1052
- for (const rawLine of lines) {
1055
+ for (const rawLine of schema.split("\n")) {
1053
1056
  const stripped = stripLineComment(rawLine);
1057
+ const openCount = (stripped.match(/\{/g) ?? []).length;
1058
+ const closeCount = (stripped.match(/\}/g) ?? []).length;
1054
1059
  if (!current) {
1055
- const match = rawLine.match(/^\s*model\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{/);
1056
- if (match) {
1057
- const openCount = (stripped.match(/\{/g) ?? []).length;
1058
- const closeCount = (stripped.match(/\}/g) ?? []).length;
1059
- current = {
1060
- name: match[1],
1061
- lines: [
1062
- rawLine
1063
- ],
1064
- depth: openCount - closeCount
1065
- };
1066
- if (current.depth === 0) {
1067
- blocks.set(current.name, current.lines.join("\n"));
1068
- current = null;
1069
- }
1070
- }
1071
- continue;
1060
+ const match = stripped.match(pattern);
1061
+ if (!match) continue;
1062
+ current = {
1063
+ name: match[1],
1064
+ lines: [
1065
+ rawLine
1066
+ ],
1067
+ depth: openCount - closeCount
1068
+ };
1069
+ } else {
1070
+ current.lines.push(rawLine);
1071
+ current.depth += openCount - closeCount;
1072
1072
  }
1073
- current.lines.push(rawLine);
1074
- current.depth += (stripped.match(/\{/g) ?? []).length;
1075
- current.depth -= (stripped.match(/\}/g) ?? []).length;
1076
1073
  if (current.depth <= 0) {
1077
1074
  blocks.set(current.name, current.lines.join("\n"));
1078
1075
  current = null;
@@ -1080,6 +1077,23 @@ function extractModelBlocks(fragment) {
1080
1077
  }
1081
1078
  return blocks;
1082
1079
  }
1080
+ __name(extractBlocks, "extractBlocks");
1081
+ function blockBodyLines(block) {
1082
+ const bodyStart = block.indexOf("{");
1083
+ const bodyEnd = block.lastIndexOf("}");
1084
+ if (bodyStart === -1 || bodyEnd <= bodyStart) return [];
1085
+ return block.slice(bodyStart + 1, bodyEnd).split("\n").map((line) => stripLineComment(line).trim()).filter((line) => line.length > 0 && !line.startsWith("@@"));
1086
+ }
1087
+ __name(blockBodyLines, "blockBodyLines");
1088
+
1089
+ // src/schema-apply.ts
1090
+ function extractModelNames(schema) {
1091
+ return extractBlockNames(schema, "model");
1092
+ }
1093
+ __name(extractModelNames, "extractModelNames");
1094
+ function extractModelBlocks(fragment) {
1095
+ return extractBlocks(fragment, "model");
1096
+ }
1083
1097
  __name(extractModelBlocks, "extractModelBlocks");
1084
1098
  function applyFragmentBlocks(schema, fragmentBlocks, options = {}) {
1085
1099
  const existing = new Set(extractModelNames(schema));
@@ -1104,11 +1118,11 @@ function applyFragmentBlocks(schema, fragmentBlocks, options = {}) {
1104
1118
  const header = options.fragmentLabel ? `
1105
1119
 
1106
1120
  // ============================================================
1107
- // Eingef\xFCgt durch \`saas-platform schema apply\` aus ${options.fragmentLabel}
1121
+ // Eingef\xFCgt durch \`saasicat schema apply\` aus ${options.fragmentLabel}
1108
1122
  // ============================================================
1109
1123
  ` : `
1110
1124
 
1111
- // Eingef\xFCgt durch \`saas-platform schema apply\`
1125
+ // Eingef\xFCgt durch \`saasicat schema apply\`
1112
1126
  `;
1113
1127
  const trimmedSchema = schema.endsWith("\n") ? schema : schema + "\n";
1114
1128
  return {
@@ -1119,6 +1133,188 @@ function applyFragmentBlocks(schema, fragmentBlocks, options = {}) {
1119
1133
  }
1120
1134
  __name(applyFragmentBlocks, "applyFragmentBlocks");
1121
1135
 
1136
+ // src/schema-check.ts
1137
+ function renderType(signature) {
1138
+ return `${signature.type}${signature.list ? "[]" : ""}${signature.optional ? "?" : ""}`;
1139
+ }
1140
+ __name(renderType, "renderType");
1141
+ function parseFields(block) {
1142
+ const fields = /* @__PURE__ */ new Map();
1143
+ for (const line of blockBodyLines(block)) {
1144
+ const [name, rawType] = line.split(/\s+/);
1145
+ if (!rawType || name.startsWith("@")) continue;
1146
+ const list = rawType.includes("[]");
1147
+ const optional = rawType.endsWith("?");
1148
+ fields.set(name, {
1149
+ name,
1150
+ type: rawType.replace("[]", "").replace("?", ""),
1151
+ optional,
1152
+ list
1153
+ });
1154
+ }
1155
+ return fields;
1156
+ }
1157
+ __name(parseFields, "parseFields");
1158
+ function parseEnumValues(block) {
1159
+ const values = [];
1160
+ for (const line of blockBodyLines(block)) {
1161
+ for (const token of line.split(/\s+/)) {
1162
+ if (token.startsWith("@")) break;
1163
+ if (token.length > 0) values.push(token);
1164
+ }
1165
+ }
1166
+ return values;
1167
+ }
1168
+ __name(parseEnumValues, "parseEnumValues");
1169
+ function attributeFieldLists(block, attribute) {
1170
+ const pattern = new RegExp(`@@${attribute}\\(([^)]*\\])`, "g");
1171
+ return new Set([
1172
+ ...block.matchAll(pattern)
1173
+ ].map((match) => match[1].replace(/\s+/g, "")));
1174
+ }
1175
+ __name(attributeFieldLists, "attributeFieldLists");
1176
+ function parseBlockAttributes(name, block) {
1177
+ return {
1178
+ indexes: attributeFieldLists(block, "index"),
1179
+ uniques: attributeFieldLists(block, "unique"),
1180
+ map: block.match(/@@map\("([^"]+)"\)/)?.[1] ?? name
1181
+ };
1182
+ }
1183
+ __name(parseBlockAttributes, "parseBlockAttributes");
1184
+ function parseSchema(schema) {
1185
+ const models = /* @__PURE__ */ new Map();
1186
+ const modelAttributes = /* @__PURE__ */ new Map();
1187
+ for (const [name, block] of extractBlocks(schema, "model")) {
1188
+ models.set(name, parseFields(block));
1189
+ modelAttributes.set(name, parseBlockAttributes(name, block));
1190
+ }
1191
+ const enums = /* @__PURE__ */ new Map();
1192
+ for (const [name, block] of extractBlocks(schema, "enum")) {
1193
+ enums.set(name, parseEnumValues(block));
1194
+ }
1195
+ return {
1196
+ models,
1197
+ modelAttributes,
1198
+ enums
1199
+ };
1200
+ }
1201
+ __name(parseSchema, "parseSchema");
1202
+ function isDocumentedTypeSubstitution(spec, app, appEnums) {
1203
+ return spec.type === "String" && appEnums.has(app.type);
1204
+ }
1205
+ __name(isDocumentedTypeSubstitution, "isDocumentedTypeSubstitution");
1206
+ function compareFields(model, specFields, appFields, appEnums, missingFields, fieldMismatches) {
1207
+ for (const [name, spec] of specFields) {
1208
+ const app = appFields.get(name);
1209
+ if (!app) {
1210
+ missingFields.push({
1211
+ model,
1212
+ field: name,
1213
+ type: renderType(spec)
1214
+ });
1215
+ continue;
1216
+ }
1217
+ const mismatch = /* @__PURE__ */ __name((reason) => ({
1218
+ model,
1219
+ field: name,
1220
+ reason,
1221
+ expected: renderType(spec),
1222
+ actual: renderType(app)
1223
+ }), "mismatch");
1224
+ if (spec.type !== app.type && !isDocumentedTypeSubstitution(spec, app, appEnums)) {
1225
+ fieldMismatches.push(mismatch("type"));
1226
+ } else if (spec.list !== app.list) {
1227
+ fieldMismatches.push(mismatch("list"));
1228
+ } else if (!spec.optional && app.optional) {
1229
+ fieldMismatches.push(mismatch("optionality"));
1230
+ }
1231
+ }
1232
+ }
1233
+ __name(compareFields, "compareFields");
1234
+ function compareBlockAttributes(model, spec, app, out) {
1235
+ if (spec.map !== app.map) {
1236
+ out.push({
1237
+ model,
1238
+ kind: "map",
1239
+ expected: `@@map("${spec.map}")`,
1240
+ actual: app.map
1241
+ });
1242
+ }
1243
+ for (const fields of spec.uniques) {
1244
+ if (!app.uniques.has(fields)) {
1245
+ out.push({
1246
+ model,
1247
+ kind: "unique",
1248
+ expected: `@@unique(${fields})`
1249
+ });
1250
+ }
1251
+ }
1252
+ for (const fields of spec.indexes) {
1253
+ if (!app.indexes.has(fields)) {
1254
+ out.push({
1255
+ model,
1256
+ kind: "index",
1257
+ expected: `@@index(${fields})`
1258
+ });
1259
+ }
1260
+ }
1261
+ }
1262
+ __name(compareBlockAttributes, "compareBlockAttributes");
1263
+ function breaksContract(attribute) {
1264
+ return attribute.kind !== "index";
1265
+ }
1266
+ __name(breaksContract, "breaksContract");
1267
+ function checkSchema(specSchema, appSchema) {
1268
+ const spec = parseSchema(specSchema);
1269
+ const app = parseSchema(appSchema);
1270
+ const absentModels = [];
1271
+ const missingFields = [];
1272
+ const fieldMismatches = [];
1273
+ const missingBlockAttributes = [];
1274
+ for (const [model, specFields] of spec.models) {
1275
+ const appFields = app.models.get(model);
1276
+ if (!appFields) {
1277
+ absentModels.push(model);
1278
+ continue;
1279
+ }
1280
+ compareFields(model, specFields, appFields, app.enums, missingFields, fieldMismatches);
1281
+ const specAttrs = spec.modelAttributes.get(model);
1282
+ const appAttrs = app.modelAttributes.get(model);
1283
+ if (specAttrs && appAttrs) {
1284
+ compareBlockAttributes(model, specAttrs, appAttrs, missingBlockAttributes);
1285
+ }
1286
+ }
1287
+ const absentEnums = [];
1288
+ const missingEnumValues = [];
1289
+ for (const [name, specValues] of spec.enums) {
1290
+ const appValues = app.enums.get(name);
1291
+ if (!appValues) {
1292
+ absentEnums.push(name);
1293
+ continue;
1294
+ }
1295
+ for (const value of specValues) {
1296
+ if (!appValues.includes(value)) {
1297
+ missingEnumValues.push({
1298
+ enum: name,
1299
+ value
1300
+ });
1301
+ }
1302
+ }
1303
+ }
1304
+ return {
1305
+ absentModels,
1306
+ absentEnums,
1307
+ missingFields,
1308
+ missingEnumValues,
1309
+ fieldMismatches,
1310
+ missingBlockAttributes,
1311
+ checkedModelCount: spec.models.size - absentModels.length,
1312
+ checkedEnumCount: spec.enums.size - absentEnums.length,
1313
+ ok: missingFields.length === 0 && missingEnumValues.length === 0 && fieldMismatches.length === 0 && !missingBlockAttributes.some(breaksContract)
1314
+ };
1315
+ }
1316
+ __name(checkSchema, "checkSchema");
1317
+
1122
1318
  // src/module.ts
1123
1319
  import { Module } from "@nestjs/common";
1124
1320
  import { asProvider } from "@saasicat/nest";
@@ -2207,6 +2403,16 @@ export {
2207
2403
  UserPortDoctorCheck,
2208
2404
  WhoAmIFlow,
2209
2405
  applyFragmentBlocks,
2406
+ blockBodyLines,
2407
+ breaksContract,
2408
+ checkSchema,
2409
+ extractBlockNames,
2410
+ extractBlocks,
2210
2411
  extractModelBlocks,
2211
- extractModelNames
2412
+ extractModelNames,
2413
+ parseBlockAttributes,
2414
+ parseEnumValues,
2415
+ parseFields,
2416
+ parseSchema,
2417
+ stripLineComment
2212
2418
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saasicat/cli",
3
- "version": "0.10.1",
3
+ "version": "0.12.0",
4
4
  "description": "CLI helpers for SaaS platform consumers. Provides CliContextService (identity, MFA, production confirm, audit tag).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -23,13 +23,13 @@
23
23
  "bin"
24
24
  ],
25
25
  "bin": {
26
- "saas-platform": "./bin/saas-platform.js"
26
+ "saasicat": "./bin/saasicat.js"
27
27
  },
28
28
  "dependencies": {
29
29
  "qrcode-terminal": "^0.12.0",
30
- "@saasicat/nest": "^0.10.1",
31
- "@saasicat/spec": "^0.10.1",
32
- "@saasicat/types": "^0.10.1"
30
+ "@saasicat/nest": "^0.12.0",
31
+ "@saasicat/spec": "^0.12.0",
32
+ "@saasicat/types": "^0.12.0"
33
33
  },
34
34
  "peerDependencies": {
35
35
  "@nestjs/common": "^11.0.0",