@saasicat/cli 0.11.0 → 0.12.1

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.
package/bin/saasicat.js CHANGED
@@ -53,6 +53,19 @@ function resolveFragmentsDir() {
53
53
  async function selectFragmentFiles(dir, filter) {
54
54
  const files = (await readdir(dir)).filter((f) => f.endsWith('.prisma')).sort();
55
55
  if (!filter) return files;
56
+
57
+ // A selector that matches nothing is a typo, and silently dropping it would
58
+ // leave part of the requested schema surface unchecked while the command
59
+ // still exits 0 — the worst outcome for something used as a CI gate.
60
+ const available = new Set(files.map((f) => f.split('-')[0]));
61
+ const unknown = filter.filter((prefix) => !available.has(prefix));
62
+ if (unknown.length > 0) {
63
+ console.error(
64
+ `✗ Unbekannte Fragmente: ${unknown.join(', ')}. ` +
65
+ `Verfügbar: ${[...available].join(', ')}`,
66
+ );
67
+ process.exit(1);
68
+ }
56
69
  return files.filter((f) => filter.includes(f.split('-')[0]));
57
70
  }
58
71
 
@@ -169,6 +182,26 @@ function printCheckReport(report) {
169
182
  console.log('');
170
183
  }
171
184
 
185
+ const breaking = report.missingBlockAttributes.filter((a) => a.kind !== 'index');
186
+ if (breaking.length > 0) {
187
+ console.log(`✗ Fehlende Constraints (${breaking.length}):`);
188
+ for (const { model, kind, expected, actual } of breaking) {
189
+ const suffix = kind === 'map' ? ` — vorhanden: @@map("${actual}")` : '';
190
+ console.log(` ${model.padEnd(28)} ${expected}${suffix}`);
191
+ }
192
+ console.log('');
193
+ }
194
+
195
+ const missingIndexes = report.missingBlockAttributes.filter((a) => a.kind === 'index');
196
+ if (missingIndexes.length > 0) {
197
+ console.log(`→ Fehlende Indizes (${missingIndexes.length}):`);
198
+ for (const { model, expected } of missingIndexes) {
199
+ console.log(` ${model.padEnd(28)} ${expected}`);
200
+ }
201
+ console.log(' Kein Fehler — kostet Query-Zeit, bricht aber nichts.');
202
+ console.log('');
203
+ }
204
+
172
205
  const absent = [...report.absentModels, ...report.absentEnums];
173
206
  if (absent.length > 0) {
174
207
  console.log(`→ Nicht übernommen (${absent.length}): ${absent.join(', ')}`);
package/dist/index.cjs CHANGED
@@ -67,16 +67,20 @@ __export(index_exports, {
67
67
  UserPortDoctorCheck: () => UserPortDoctorCheck,
68
68
  WhoAmIFlow: () => WhoAmIFlow,
69
69
  applyFragmentBlocks: () => applyFragmentBlocks,
70
+ blankStringLiterals: () => blankStringLiterals,
70
71
  blockBodyLines: () => blockBodyLines,
72
+ breaksContract: () => breaksContract,
71
73
  checkSchema: () => checkSchema,
72
74
  extractBlockNames: () => extractBlockNames,
73
75
  extractBlocks: () => extractBlocks,
74
76
  extractModelBlocks: () => extractModelBlocks,
75
77
  extractModelNames: () => extractModelNames,
78
+ parseBlockAttributes: () => parseBlockAttributes,
76
79
  parseEnumValues: () => parseEnumValues,
77
80
  parseFields: () => parseFields,
78
81
  parseSchema: () => parseSchema,
79
- stripLineComment: () => stripLineComment
82
+ stripLineComment: () => stripLineComment,
83
+ structuralOnly: () => structuralOnly
80
84
  });
81
85
  module.exports = __toCommonJS(index_exports);
82
86
 
@@ -1109,7 +1113,7 @@ var PLATFORM_DOCTOR_CHECK_PROVIDERS = [
1109
1113
 
1110
1114
  // src/prisma-blocks.ts
1111
1115
  function stripLineComment(line) {
1112
- const commentStart = line.indexOf("//");
1116
+ const commentStart = blankStringLiterals(line).indexOf("//");
1113
1117
  return commentStart === -1 ? line : line.slice(0, commentStart);
1114
1118
  }
1115
1119
  __name(stripLineComment, "stripLineComment");
@@ -1117,11 +1121,45 @@ function declarationPattern(keyword) {
1117
1121
  return new RegExp(`^\\s*${keyword}\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*\\{`);
1118
1122
  }
1119
1123
  __name(declarationPattern, "declarationPattern");
1124
+ function blankStringLiterals(line) {
1125
+ let out = "";
1126
+ let inString = false;
1127
+ let escaped = false;
1128
+ for (const char of line) {
1129
+ if (!inString) {
1130
+ out += char;
1131
+ if (char === '"') inString = true;
1132
+ continue;
1133
+ }
1134
+ if (escaped) {
1135
+ out += " ";
1136
+ escaped = false;
1137
+ continue;
1138
+ }
1139
+ if (char === "\\") {
1140
+ out += " ";
1141
+ escaped = true;
1142
+ continue;
1143
+ }
1144
+ if (char === '"') {
1145
+ out += '"';
1146
+ inString = false;
1147
+ continue;
1148
+ }
1149
+ out += " ";
1150
+ }
1151
+ return out;
1152
+ }
1153
+ __name(blankStringLiterals, "blankStringLiterals");
1154
+ function structuralOnly(line) {
1155
+ return stripLineComment(blankStringLiterals(line));
1156
+ }
1157
+ __name(structuralOnly, "structuralOnly");
1120
1158
  function extractBlockNames(schema, keyword) {
1121
1159
  const pattern = declarationPattern(keyword);
1122
1160
  const names = [];
1123
1161
  for (const line of schema.split("\n")) {
1124
- const match = stripLineComment(line).match(pattern);
1162
+ const match = structuralOnly(line).match(pattern);
1125
1163
  if (match) names.push(match[1]);
1126
1164
  }
1127
1165
  return names;
@@ -1132,7 +1170,7 @@ function extractBlocks(schema, keyword) {
1132
1170
  const blocks = /* @__PURE__ */ new Map();
1133
1171
  let current = null;
1134
1172
  for (const rawLine of schema.split("\n")) {
1135
- const stripped = stripLineComment(rawLine);
1173
+ const stripped = structuralOnly(rawLine);
1136
1174
  const openCount = (stripped.match(/\{/g) ?? []).length;
1137
1175
  const closeCount = (stripped.match(/\}/g) ?? []).length;
1138
1176
  if (!current) {
@@ -1161,7 +1199,7 @@ function blockBodyLines(block) {
1161
1199
  const bodyStart = block.indexOf("{");
1162
1200
  const bodyEnd = block.lastIndexOf("}");
1163
1201
  if (bodyStart === -1 || bodyEnd <= bodyStart) return [];
1164
- return block.slice(bodyStart + 1, bodyEnd).split("\n").map((line) => stripLineComment(line).trim()).filter((line) => line.length > 0 && !line.startsWith("@@"));
1202
+ return block.slice(bodyStart + 1, bodyEnd).split("\n").map((line) => structuralOnly(line).trim()).filter((line) => line.length > 0 && !line.startsWith("@@"));
1165
1203
  }
1166
1204
  __name(blockBodyLines, "blockBodyLines");
1167
1205
 
@@ -1245,10 +1283,28 @@ function parseEnumValues(block) {
1245
1283
  return values;
1246
1284
  }
1247
1285
  __name(parseEnumValues, "parseEnumValues");
1286
+ function attributeFieldLists(block, attribute) {
1287
+ const pattern = new RegExp(`@@${attribute}\\(\\s*(\\[[^\\]]*\\])`, "g");
1288
+ return new Set([
1289
+ ...block.matchAll(pattern)
1290
+ ].map((match) => match[1].replace(/\s+/g, "")));
1291
+ }
1292
+ __name(attributeFieldLists, "attributeFieldLists");
1293
+ function parseBlockAttributes(name, block) {
1294
+ const active = block.split("\n").map(stripLineComment).join("\n");
1295
+ return {
1296
+ indexes: attributeFieldLists(active, "index"),
1297
+ uniques: attributeFieldLists(active, "unique"),
1298
+ map: active.match(/@@map\("([^"]+)"\)/)?.[1] ?? name
1299
+ };
1300
+ }
1301
+ __name(parseBlockAttributes, "parseBlockAttributes");
1248
1302
  function parseSchema(schema) {
1249
1303
  const models = /* @__PURE__ */ new Map();
1304
+ const modelAttributes = /* @__PURE__ */ new Map();
1250
1305
  for (const [name, block] of extractBlocks(schema, "model")) {
1251
1306
  models.set(name, parseFields(block));
1307
+ modelAttributes.set(name, parseBlockAttributes(name, block));
1252
1308
  }
1253
1309
  const enums = /* @__PURE__ */ new Map();
1254
1310
  for (const [name, block] of extractBlocks(schema, "enum")) {
@@ -1256,6 +1312,7 @@ function parseSchema(schema) {
1256
1312
  }
1257
1313
  return {
1258
1314
  models,
1315
+ modelAttributes,
1259
1316
  enums
1260
1317
  };
1261
1318
  }
@@ -1292,12 +1349,46 @@ function compareFields(model, specFields, appFields, appEnums, missingFields, fi
1292
1349
  }
1293
1350
  }
1294
1351
  __name(compareFields, "compareFields");
1352
+ function compareBlockAttributes(model, spec, app, out) {
1353
+ if (spec.map !== app.map) {
1354
+ out.push({
1355
+ model,
1356
+ kind: "map",
1357
+ expected: `@@map("${spec.map}")`,
1358
+ actual: app.map
1359
+ });
1360
+ }
1361
+ for (const fields of spec.uniques) {
1362
+ if (!app.uniques.has(fields)) {
1363
+ out.push({
1364
+ model,
1365
+ kind: "unique",
1366
+ expected: `@@unique(${fields})`
1367
+ });
1368
+ }
1369
+ }
1370
+ for (const fields of spec.indexes) {
1371
+ if (!app.indexes.has(fields)) {
1372
+ out.push({
1373
+ model,
1374
+ kind: "index",
1375
+ expected: `@@index(${fields})`
1376
+ });
1377
+ }
1378
+ }
1379
+ }
1380
+ __name(compareBlockAttributes, "compareBlockAttributes");
1381
+ function breaksContract(attribute) {
1382
+ return attribute.kind !== "index";
1383
+ }
1384
+ __name(breaksContract, "breaksContract");
1295
1385
  function checkSchema(specSchema, appSchema) {
1296
1386
  const spec = parseSchema(specSchema);
1297
1387
  const app = parseSchema(appSchema);
1298
1388
  const absentModels = [];
1299
1389
  const missingFields = [];
1300
1390
  const fieldMismatches = [];
1391
+ const missingBlockAttributes = [];
1301
1392
  for (const [model, specFields] of spec.models) {
1302
1393
  const appFields = app.models.get(model);
1303
1394
  if (!appFields) {
@@ -1305,6 +1396,11 @@ function checkSchema(specSchema, appSchema) {
1305
1396
  continue;
1306
1397
  }
1307
1398
  compareFields(model, specFields, appFields, app.enums, missingFields, fieldMismatches);
1399
+ const specAttrs = spec.modelAttributes.get(model);
1400
+ const appAttrs = app.modelAttributes.get(model);
1401
+ if (specAttrs && appAttrs) {
1402
+ compareBlockAttributes(model, specAttrs, appAttrs, missingBlockAttributes);
1403
+ }
1308
1404
  }
1309
1405
  const absentEnums = [];
1310
1406
  const missingEnumValues = [];
@@ -1329,9 +1425,10 @@ function checkSchema(specSchema, appSchema) {
1329
1425
  missingFields,
1330
1426
  missingEnumValues,
1331
1427
  fieldMismatches,
1428
+ missingBlockAttributes,
1332
1429
  checkedModelCount: spec.models.size - absentModels.length,
1333
1430
  checkedEnumCount: spec.enums.size - absentEnums.length,
1334
- ok: missingFields.length === 0 && missingEnumValues.length === 0 && fieldMismatches.length === 0
1431
+ ok: missingFields.length === 0 && missingEnumValues.length === 0 && fieldMismatches.length === 0 && !missingBlockAttributes.some(breaksContract)
1335
1432
  };
1336
1433
  }
1337
1434
  __name(checkSchema, "checkSchema");
@@ -2425,14 +2522,18 @@ UserCommands = _ts_decorate14([
2425
2522
  UserPortDoctorCheck,
2426
2523
  WhoAmIFlow,
2427
2524
  applyFragmentBlocks,
2525
+ blankStringLiterals,
2428
2526
  blockBodyLines,
2527
+ breaksContract,
2429
2528
  checkSchema,
2430
2529
  extractBlockNames,
2431
2530
  extractBlocks,
2432
2531
  extractModelBlocks,
2433
2532
  extractModelNames,
2533
+ parseBlockAttributes,
2434
2534
  parseEnumValues,
2435
2535
  parseFields,
2436
2536
  parseSchema,
2437
- stripLineComment
2537
+ stripLineComment,
2538
+ structuralOnly
2438
2539
  });
package/dist/index.d.cts CHANGED
@@ -327,11 +327,32 @@ declare class AdminManifestDoctorCheck implements DoctorCheck {
327
327
  declare const PLATFORM_DOCTOR_CHECK_PROVIDERS: Array<Type<DoctorCheck>>;
328
328
 
329
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.
330
+ * Cuts off a trailing `//` comment, ignoring `//` inside string literals — a
331
+ * `@default("http://x")` must survive intact. The position is found on a
332
+ * string-blanked copy, but the original line is what gets cut, so quoted
333
+ * content is preserved.
334
+ *
335
+ * Deliberately index-based instead of `replace(/\/\/.*$/, '')`: the regex
336
+ * backtracks quadratically on lines with many single slashes.
333
337
  */
334
338
  declare function stripLineComment(line: string): string;
339
+ /**
340
+ * Blanks the contents of double-quoted strings, keeping the quotes and the
341
+ * line length so column positions stay put. Brace counting must not see
342
+ * `@default("}")` as the end of a model — it would close the block early and
343
+ * every field below it would look missing.
344
+ *
345
+ * A linear scan rather than `"(?:[^"\\]|\\.)*"`: that pattern backtracks
346
+ * quadratically on many repeated `\"` (CodeQL `js/polynomial-redos`), and this
347
+ * runs over every line of a consumer schema.
348
+ */
349
+ declare function blankStringLiterals(line: string): string;
350
+ /**
351
+ * Comment- and string-safe view of a line, for structural scanning. Strings are
352
+ * blanked first: `@default("http://x")` contains `//`, and stripping comments
353
+ * before that would cut the line in half.
354
+ */
355
+ declare function structuralOnly(line: string): string;
335
356
  /** Returns the names of all top-level `<keyword> X { … }` blocks. */
336
357
  declare function extractBlockNames(schema: string, keyword: string): string[];
337
358
  /**
@@ -383,8 +404,17 @@ interface FieldSignature {
383
404
  optional: boolean;
384
405
  list: boolean;
385
406
  }
407
+ interface BlockAttributes {
408
+ /** Field lists of `@@index`, normalised to `[a,b]` — attribute options dropped. */
409
+ indexes: Set<string>;
410
+ /** Same for `@@unique`. */
411
+ uniques: Set<string>;
412
+ /** `@@map` target, or the model name when unmapped. */
413
+ map: string;
414
+ }
386
415
  interface ParsedSchema {
387
416
  models: Map<string, Map<string, FieldSignature>>;
417
+ modelAttributes: Map<string, BlockAttributes>;
388
418
  enums: Map<string, string[]>;
389
419
  }
390
420
  interface MissingField {
@@ -405,6 +435,15 @@ interface FieldMismatch {
405
435
  expected: string;
406
436
  actual: string;
407
437
  }
438
+ type BlockAttributeKind = 'index' | 'unique' | 'map';
439
+ interface MissingBlockAttribute {
440
+ model: string;
441
+ kind: BlockAttributeKind;
442
+ /** Rendered attribute, e.g. `@@index([planId, validFrom])`. */
443
+ expected: string;
444
+ /** For `map`: what the consumer maps to instead. */
445
+ actual?: string;
446
+ }
408
447
  interface SchemaCheckReport {
409
448
  /** Platform models the consumer does not carry — informational. */
410
449
  absentModels: string[];
@@ -413,6 +452,11 @@ interface SchemaCheckReport {
413
452
  missingFields: MissingField[];
414
453
  missingEnumValues: MissingEnumValue[];
415
454
  fieldMismatches: FieldMismatch[];
455
+ /**
456
+ * Block-level attributes the spec declares and the consumer lacks:
457
+ * `@@index`, `@@unique`, and a diverging `@@map`.
458
+ */
459
+ missingBlockAttributes: MissingBlockAttribute[];
416
460
  /** Models present in both schemas, i.e. actually compared. */
417
461
  checkedModelCount: number;
418
462
  /** Enums present in both schemas, i.e. actually compared. */
@@ -427,8 +471,26 @@ declare function parseFields(block: string): Map<string, FieldSignature>;
427
471
  * (`enum Role { ADMIN USER }`); a trailing `@map(…)` attribute is not a value.
428
472
  */
429
473
  declare function parseEnumValues(block: string): string[];
474
+ /**
475
+ * Parses the block-level attributes of a `model`. Comparing these is what
476
+ * catches a missing index or unique constraint — differences the field-level
477
+ * comparison is blind to.
478
+ *
479
+ * The block is reduced to its structural content first. A consumer who
480
+ * comments out `// @@unique([tenantId])` has removed the constraint, and
481
+ * reading the raw text would count it as present — passing the very check that
482
+ * exists to catch its absence.
483
+ */
484
+ declare function parseBlockAttributes(name: string, block: string): BlockAttributes;
430
485
  /** Parses every `model` and `enum` declaration of a Prisma schema. */
431
486
  declare function parseSchema(schema: string): ParsedSchema;
487
+ /**
488
+ * A missing index costs query time; a missing `@@unique` or a diverging
489
+ * `@@map` breaks correctness — the platform relies on the constraint holding,
490
+ * and on finding the table under its canonical name. Only the latter two fail
491
+ * the check.
492
+ */
493
+ declare function breaksContract(attribute: MissingBlockAttribute): boolean;
432
494
  /**
433
495
  * Compares a consumer schema against the canonical fragments. `specSchema` is
434
496
  * the concatenation of the fragments the check should cover.
@@ -625,4 +687,4 @@ declare class UserCommands extends CommandRunner {
625
687
  parsePassword(val: string): string;
626
688
  }
627
689
 
628
- 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, 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 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, checkSchema, extractBlockNames, extractBlocks, extractModelBlocks, extractModelNames, parseEnumValues, parseFields, parseSchema, stripLineComment };
690
+ 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, blankStringLiterals, blockBodyLines, breaksContract, checkSchema, extractBlockNames, extractBlocks, extractModelBlocks, extractModelNames, parseBlockAttributes, parseEnumValues, parseFields, parseSchema, stripLineComment, structuralOnly };
package/dist/index.d.ts CHANGED
@@ -327,11 +327,32 @@ declare class AdminManifestDoctorCheck implements DoctorCheck {
327
327
  declare const PLATFORM_DOCTOR_CHECK_PROVIDERS: Array<Type<DoctorCheck>>;
328
328
 
329
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.
330
+ * Cuts off a trailing `//` comment, ignoring `//` inside string literals — a
331
+ * `@default("http://x")` must survive intact. The position is found on a
332
+ * string-blanked copy, but the original line is what gets cut, so quoted
333
+ * content is preserved.
334
+ *
335
+ * Deliberately index-based instead of `replace(/\/\/.*$/, '')`: the regex
336
+ * backtracks quadratically on lines with many single slashes.
333
337
  */
334
338
  declare function stripLineComment(line: string): string;
339
+ /**
340
+ * Blanks the contents of double-quoted strings, keeping the quotes and the
341
+ * line length so column positions stay put. Brace counting must not see
342
+ * `@default("}")` as the end of a model — it would close the block early and
343
+ * every field below it would look missing.
344
+ *
345
+ * A linear scan rather than `"(?:[^"\\]|\\.)*"`: that pattern backtracks
346
+ * quadratically on many repeated `\"` (CodeQL `js/polynomial-redos`), and this
347
+ * runs over every line of a consumer schema.
348
+ */
349
+ declare function blankStringLiterals(line: string): string;
350
+ /**
351
+ * Comment- and string-safe view of a line, for structural scanning. Strings are
352
+ * blanked first: `@default("http://x")` contains `//`, and stripping comments
353
+ * before that would cut the line in half.
354
+ */
355
+ declare function structuralOnly(line: string): string;
335
356
  /** Returns the names of all top-level `<keyword> X { … }` blocks. */
336
357
  declare function extractBlockNames(schema: string, keyword: string): string[];
337
358
  /**
@@ -383,8 +404,17 @@ interface FieldSignature {
383
404
  optional: boolean;
384
405
  list: boolean;
385
406
  }
407
+ interface BlockAttributes {
408
+ /** Field lists of `@@index`, normalised to `[a,b]` — attribute options dropped. */
409
+ indexes: Set<string>;
410
+ /** Same for `@@unique`. */
411
+ uniques: Set<string>;
412
+ /** `@@map` target, or the model name when unmapped. */
413
+ map: string;
414
+ }
386
415
  interface ParsedSchema {
387
416
  models: Map<string, Map<string, FieldSignature>>;
417
+ modelAttributes: Map<string, BlockAttributes>;
388
418
  enums: Map<string, string[]>;
389
419
  }
390
420
  interface MissingField {
@@ -405,6 +435,15 @@ interface FieldMismatch {
405
435
  expected: string;
406
436
  actual: string;
407
437
  }
438
+ type BlockAttributeKind = 'index' | 'unique' | 'map';
439
+ interface MissingBlockAttribute {
440
+ model: string;
441
+ kind: BlockAttributeKind;
442
+ /** Rendered attribute, e.g. `@@index([planId, validFrom])`. */
443
+ expected: string;
444
+ /** For `map`: what the consumer maps to instead. */
445
+ actual?: string;
446
+ }
408
447
  interface SchemaCheckReport {
409
448
  /** Platform models the consumer does not carry — informational. */
410
449
  absentModels: string[];
@@ -413,6 +452,11 @@ interface SchemaCheckReport {
413
452
  missingFields: MissingField[];
414
453
  missingEnumValues: MissingEnumValue[];
415
454
  fieldMismatches: FieldMismatch[];
455
+ /**
456
+ * Block-level attributes the spec declares and the consumer lacks:
457
+ * `@@index`, `@@unique`, and a diverging `@@map`.
458
+ */
459
+ missingBlockAttributes: MissingBlockAttribute[];
416
460
  /** Models present in both schemas, i.e. actually compared. */
417
461
  checkedModelCount: number;
418
462
  /** Enums present in both schemas, i.e. actually compared. */
@@ -427,8 +471,26 @@ declare function parseFields(block: string): Map<string, FieldSignature>;
427
471
  * (`enum Role { ADMIN USER }`); a trailing `@map(…)` attribute is not a value.
428
472
  */
429
473
  declare function parseEnumValues(block: string): string[];
474
+ /**
475
+ * Parses the block-level attributes of a `model`. Comparing these is what
476
+ * catches a missing index or unique constraint — differences the field-level
477
+ * comparison is blind to.
478
+ *
479
+ * The block is reduced to its structural content first. A consumer who
480
+ * comments out `// @@unique([tenantId])` has removed the constraint, and
481
+ * reading the raw text would count it as present — passing the very check that
482
+ * exists to catch its absence.
483
+ */
484
+ declare function parseBlockAttributes(name: string, block: string): BlockAttributes;
430
485
  /** Parses every `model` and `enum` declaration of a Prisma schema. */
431
486
  declare function parseSchema(schema: string): ParsedSchema;
487
+ /**
488
+ * A missing index costs query time; a missing `@@unique` or a diverging
489
+ * `@@map` breaks correctness — the platform relies on the constraint holding,
490
+ * and on finding the table under its canonical name. Only the latter two fail
491
+ * the check.
492
+ */
493
+ declare function breaksContract(attribute: MissingBlockAttribute): boolean;
432
494
  /**
433
495
  * Compares a consumer schema against the canonical fragments. `specSchema` is
434
496
  * the concatenation of the fragments the check should cover.
@@ -625,4 +687,4 @@ declare class UserCommands extends CommandRunner {
625
687
  parsePassword(val: string): string;
626
688
  }
627
689
 
628
- 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, 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 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, checkSchema, extractBlockNames, extractBlocks, extractModelBlocks, extractModelNames, parseEnumValues, parseFields, parseSchema, stripLineComment };
690
+ 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, blankStringLiterals, blockBodyLines, breaksContract, checkSchema, extractBlockNames, extractBlocks, extractModelBlocks, extractModelNames, parseBlockAttributes, parseEnumValues, parseFields, parseSchema, stripLineComment, structuralOnly };
package/dist/index.js CHANGED
@@ -1030,7 +1030,7 @@ var PLATFORM_DOCTOR_CHECK_PROVIDERS = [
1030
1030
 
1031
1031
  // src/prisma-blocks.ts
1032
1032
  function stripLineComment(line) {
1033
- const commentStart = line.indexOf("//");
1033
+ const commentStart = blankStringLiterals(line).indexOf("//");
1034
1034
  return commentStart === -1 ? line : line.slice(0, commentStart);
1035
1035
  }
1036
1036
  __name(stripLineComment, "stripLineComment");
@@ -1038,11 +1038,45 @@ function declarationPattern(keyword) {
1038
1038
  return new RegExp(`^\\s*${keyword}\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*\\{`);
1039
1039
  }
1040
1040
  __name(declarationPattern, "declarationPattern");
1041
+ function blankStringLiterals(line) {
1042
+ let out = "";
1043
+ let inString = false;
1044
+ let escaped = false;
1045
+ for (const char of line) {
1046
+ if (!inString) {
1047
+ out += char;
1048
+ if (char === '"') inString = true;
1049
+ continue;
1050
+ }
1051
+ if (escaped) {
1052
+ out += " ";
1053
+ escaped = false;
1054
+ continue;
1055
+ }
1056
+ if (char === "\\") {
1057
+ out += " ";
1058
+ escaped = true;
1059
+ continue;
1060
+ }
1061
+ if (char === '"') {
1062
+ out += '"';
1063
+ inString = false;
1064
+ continue;
1065
+ }
1066
+ out += " ";
1067
+ }
1068
+ return out;
1069
+ }
1070
+ __name(blankStringLiterals, "blankStringLiterals");
1071
+ function structuralOnly(line) {
1072
+ return stripLineComment(blankStringLiterals(line));
1073
+ }
1074
+ __name(structuralOnly, "structuralOnly");
1041
1075
  function extractBlockNames(schema, keyword) {
1042
1076
  const pattern = declarationPattern(keyword);
1043
1077
  const names = [];
1044
1078
  for (const line of schema.split("\n")) {
1045
- const match = stripLineComment(line).match(pattern);
1079
+ const match = structuralOnly(line).match(pattern);
1046
1080
  if (match) names.push(match[1]);
1047
1081
  }
1048
1082
  return names;
@@ -1053,7 +1087,7 @@ function extractBlocks(schema, keyword) {
1053
1087
  const blocks = /* @__PURE__ */ new Map();
1054
1088
  let current = null;
1055
1089
  for (const rawLine of schema.split("\n")) {
1056
- const stripped = stripLineComment(rawLine);
1090
+ const stripped = structuralOnly(rawLine);
1057
1091
  const openCount = (stripped.match(/\{/g) ?? []).length;
1058
1092
  const closeCount = (stripped.match(/\}/g) ?? []).length;
1059
1093
  if (!current) {
@@ -1082,7 +1116,7 @@ function blockBodyLines(block) {
1082
1116
  const bodyStart = block.indexOf("{");
1083
1117
  const bodyEnd = block.lastIndexOf("}");
1084
1118
  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("@@"));
1119
+ return block.slice(bodyStart + 1, bodyEnd).split("\n").map((line) => structuralOnly(line).trim()).filter((line) => line.length > 0 && !line.startsWith("@@"));
1086
1120
  }
1087
1121
  __name(blockBodyLines, "blockBodyLines");
1088
1122
 
@@ -1166,10 +1200,28 @@ function parseEnumValues(block) {
1166
1200
  return values;
1167
1201
  }
1168
1202
  __name(parseEnumValues, "parseEnumValues");
1203
+ function attributeFieldLists(block, attribute) {
1204
+ const pattern = new RegExp(`@@${attribute}\\(\\s*(\\[[^\\]]*\\])`, "g");
1205
+ return new Set([
1206
+ ...block.matchAll(pattern)
1207
+ ].map((match) => match[1].replace(/\s+/g, "")));
1208
+ }
1209
+ __name(attributeFieldLists, "attributeFieldLists");
1210
+ function parseBlockAttributes(name, block) {
1211
+ const active = block.split("\n").map(stripLineComment).join("\n");
1212
+ return {
1213
+ indexes: attributeFieldLists(active, "index"),
1214
+ uniques: attributeFieldLists(active, "unique"),
1215
+ map: active.match(/@@map\("([^"]+)"\)/)?.[1] ?? name
1216
+ };
1217
+ }
1218
+ __name(parseBlockAttributes, "parseBlockAttributes");
1169
1219
  function parseSchema(schema) {
1170
1220
  const models = /* @__PURE__ */ new Map();
1221
+ const modelAttributes = /* @__PURE__ */ new Map();
1171
1222
  for (const [name, block] of extractBlocks(schema, "model")) {
1172
1223
  models.set(name, parseFields(block));
1224
+ modelAttributes.set(name, parseBlockAttributes(name, block));
1173
1225
  }
1174
1226
  const enums = /* @__PURE__ */ new Map();
1175
1227
  for (const [name, block] of extractBlocks(schema, "enum")) {
@@ -1177,6 +1229,7 @@ function parseSchema(schema) {
1177
1229
  }
1178
1230
  return {
1179
1231
  models,
1232
+ modelAttributes,
1180
1233
  enums
1181
1234
  };
1182
1235
  }
@@ -1213,12 +1266,46 @@ function compareFields(model, specFields, appFields, appEnums, missingFields, fi
1213
1266
  }
1214
1267
  }
1215
1268
  __name(compareFields, "compareFields");
1269
+ function compareBlockAttributes(model, spec, app, out) {
1270
+ if (spec.map !== app.map) {
1271
+ out.push({
1272
+ model,
1273
+ kind: "map",
1274
+ expected: `@@map("${spec.map}")`,
1275
+ actual: app.map
1276
+ });
1277
+ }
1278
+ for (const fields of spec.uniques) {
1279
+ if (!app.uniques.has(fields)) {
1280
+ out.push({
1281
+ model,
1282
+ kind: "unique",
1283
+ expected: `@@unique(${fields})`
1284
+ });
1285
+ }
1286
+ }
1287
+ for (const fields of spec.indexes) {
1288
+ if (!app.indexes.has(fields)) {
1289
+ out.push({
1290
+ model,
1291
+ kind: "index",
1292
+ expected: `@@index(${fields})`
1293
+ });
1294
+ }
1295
+ }
1296
+ }
1297
+ __name(compareBlockAttributes, "compareBlockAttributes");
1298
+ function breaksContract(attribute) {
1299
+ return attribute.kind !== "index";
1300
+ }
1301
+ __name(breaksContract, "breaksContract");
1216
1302
  function checkSchema(specSchema, appSchema) {
1217
1303
  const spec = parseSchema(specSchema);
1218
1304
  const app = parseSchema(appSchema);
1219
1305
  const absentModels = [];
1220
1306
  const missingFields = [];
1221
1307
  const fieldMismatches = [];
1308
+ const missingBlockAttributes = [];
1222
1309
  for (const [model, specFields] of spec.models) {
1223
1310
  const appFields = app.models.get(model);
1224
1311
  if (!appFields) {
@@ -1226,6 +1313,11 @@ function checkSchema(specSchema, appSchema) {
1226
1313
  continue;
1227
1314
  }
1228
1315
  compareFields(model, specFields, appFields, app.enums, missingFields, fieldMismatches);
1316
+ const specAttrs = spec.modelAttributes.get(model);
1317
+ const appAttrs = app.modelAttributes.get(model);
1318
+ if (specAttrs && appAttrs) {
1319
+ compareBlockAttributes(model, specAttrs, appAttrs, missingBlockAttributes);
1320
+ }
1229
1321
  }
1230
1322
  const absentEnums = [];
1231
1323
  const missingEnumValues = [];
@@ -1250,9 +1342,10 @@ function checkSchema(specSchema, appSchema) {
1250
1342
  missingFields,
1251
1343
  missingEnumValues,
1252
1344
  fieldMismatches,
1345
+ missingBlockAttributes,
1253
1346
  checkedModelCount: spec.models.size - absentModels.length,
1254
1347
  checkedEnumCount: spec.enums.size - absentEnums.length,
1255
- ok: missingFields.length === 0 && missingEnumValues.length === 0 && fieldMismatches.length === 0
1348
+ ok: missingFields.length === 0 && missingEnumValues.length === 0 && fieldMismatches.length === 0 && !missingBlockAttributes.some(breaksContract)
1256
1349
  };
1257
1350
  }
1258
1351
  __name(checkSchema, "checkSchema");
@@ -2345,14 +2438,18 @@ export {
2345
2438
  UserPortDoctorCheck,
2346
2439
  WhoAmIFlow,
2347
2440
  applyFragmentBlocks,
2441
+ blankStringLiterals,
2348
2442
  blockBodyLines,
2443
+ breaksContract,
2349
2444
  checkSchema,
2350
2445
  extractBlockNames,
2351
2446
  extractBlocks,
2352
2447
  extractModelBlocks,
2353
2448
  extractModelNames,
2449
+ parseBlockAttributes,
2354
2450
  parseEnumValues,
2355
2451
  parseFields,
2356
2452
  parseSchema,
2357
- stripLineComment
2453
+ stripLineComment,
2454
+ structuralOnly
2358
2455
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saasicat/cli",
3
- "version": "0.11.0",
3
+ "version": "0.12.1",
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",
@@ -27,9 +27,9 @@
27
27
  },
28
28
  "dependencies": {
29
29
  "qrcode-terminal": "^0.12.0",
30
- "@saasicat/nest": "^0.11.0",
31
- "@saasicat/spec": "^0.11.0",
32
- "@saasicat/types": "^0.11.0"
30
+ "@saasicat/nest": "^0.12.1",
31
+ "@saasicat/spec": "^0.12.1",
32
+ "@saasicat/types": "^0.12.1"
33
33
  },
34
34
  "peerDependencies": {
35
35
  "@nestjs/common": "^11.0.0",