@saasicat/cli 1.0.0-rc.14 → 1.0.0-rc.16
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 +10 -0
- package/dist/.build-stamp +1 -1
- package/dist/index.cjs +141 -112
- package/dist/index.d.cts +24 -1
- package/dist/index.d.ts +24 -1
- package/dist/index.js +140 -112
- package/package.json +4 -4
package/bin/saasicat.js
CHANGED
|
@@ -290,6 +290,16 @@ function printCheckReport(report) {
|
|
|
290
290
|
console.log('');
|
|
291
291
|
}
|
|
292
292
|
|
|
293
|
+
if (report.tenantCascades.length > 0) {
|
|
294
|
+
console.log(`✗ Deleted together with the tenant (${report.tenantCascades.length}):`);
|
|
295
|
+
for (const { model, field } of report.tenantCascades) {
|
|
296
|
+
console.log(` ${`${model}.${field}`.padEnd(44)} onDelete: Cascade`);
|
|
297
|
+
}
|
|
298
|
+
console.log(' These records outlive their tenant — a contract is kept for tax purposes.');
|
|
299
|
+
console.log(' Remove the relation; `tenantId` stays as a trace.');
|
|
300
|
+
console.log('');
|
|
301
|
+
}
|
|
302
|
+
|
|
293
303
|
const breaking = report.missingBlockAttributes.filter((a) => a.kind !== 'index');
|
|
294
304
|
if (breaking.length > 0) {
|
|
295
305
|
console.log(`✗ Missing constraints (${breaking.length}):`);
|
package/dist/.build-stamp
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
2c53f8cea2ea7de0b41c9b894cdc45c45c9173c816c239c3afa50d5b9326914e
|
package/dist/index.cjs
CHANGED
|
@@ -109,6 +109,7 @@ __export(index_exports, {
|
|
|
109
109
|
kebabCase: () => kebabCase,
|
|
110
110
|
migrationCreatedBy: () => migrationCreatedBy,
|
|
111
111
|
minimumQuotasPerPlan: () => minimumQuotasPerPlan,
|
|
112
|
+
modelsKeptPastTheTenant: () => modelsKeptPastTheTenant,
|
|
112
113
|
namedImports: () => namedImports,
|
|
113
114
|
parseBlockAttributes: () => parseBlockAttributes,
|
|
114
115
|
parseEnumValues: () => parseEnumValues,
|
|
@@ -1335,6 +1336,117 @@ function applyFragmentBlocks(schema2, fragmentBlocks, options = {}) {
|
|
|
1335
1336
|
}
|
|
1336
1337
|
__name(applyFragmentBlocks, "applyFragmentBlocks");
|
|
1337
1338
|
|
|
1339
|
+
// src/fk-pointers.ts
|
|
1340
|
+
var POINTER = /^(\s*)\/\/\s*(\w+)(\s+)(Tenant|User)(\??)(\s+)(@relation\(.*\))\s*$/;
|
|
1341
|
+
function findFkPointers(schema2) {
|
|
1342
|
+
const found = [];
|
|
1343
|
+
let model = "";
|
|
1344
|
+
schema2.split("\n").forEach((text, line) => {
|
|
1345
|
+
const opening = /^model\s+(\w+)\s*\{/.exec(text);
|
|
1346
|
+
if (opening) model = opening[1];
|
|
1347
|
+
const match = POINTER.exec(text);
|
|
1348
|
+
if (match) found.push({
|
|
1349
|
+
line,
|
|
1350
|
+
target: match[4],
|
|
1351
|
+
model,
|
|
1352
|
+
text
|
|
1353
|
+
});
|
|
1354
|
+
});
|
|
1355
|
+
return found;
|
|
1356
|
+
}
|
|
1357
|
+
__name(findFkPointers, "findFkPointers");
|
|
1358
|
+
function relationNameOf(relationAttribute) {
|
|
1359
|
+
const match = /@relation\(\s*"([^"]+)"/.exec(relationAttribute);
|
|
1360
|
+
return match ? match[1] : null;
|
|
1361
|
+
}
|
|
1362
|
+
__name(relationNameOf, "relationNameOf");
|
|
1363
|
+
function escapeForRegExp(value) {
|
|
1364
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1365
|
+
}
|
|
1366
|
+
__name(escapeForRegExp, "escapeForRegExp");
|
|
1367
|
+
function modelBody(schema2, model) {
|
|
1368
|
+
const name = escapeForRegExp(model);
|
|
1369
|
+
const block = new RegExp(`(^|\\n)model\\s+${name}\\s*\\{([\\s\\S]*?)\\n\\}`, "m").exec(schema2);
|
|
1370
|
+
return block ? block[2] : null;
|
|
1371
|
+
}
|
|
1372
|
+
__name(modelBody, "modelBody");
|
|
1373
|
+
function isOneToOne(schema2, model, foreignKey) {
|
|
1374
|
+
const body = modelBody(schema2, model);
|
|
1375
|
+
if (!body) return false;
|
|
1376
|
+
return new RegExp(`^\\s*${escapeForRegExp(foreignKey)}\\s+\\S+.*@unique`, "m").test(body);
|
|
1377
|
+
}
|
|
1378
|
+
__name(isOneToOne, "isOneToOne");
|
|
1379
|
+
function hasBackRelation(schema2, model, owner, relationName, singular = false) {
|
|
1380
|
+
const body = modelBody(schema2, owner);
|
|
1381
|
+
if (body === null) return false;
|
|
1382
|
+
const name = escapeForRegExp(model);
|
|
1383
|
+
const shape = singular ? `${name}\\??` : `${name}\\[\\]`;
|
|
1384
|
+
const candidates = [
|
|
1385
|
+
...body.matchAll(new RegExp(`^\\s*\\w+\\s+${shape}(\\s.*)?$`, "gm"))
|
|
1386
|
+
];
|
|
1387
|
+
return candidates.some((line) => relationNameOf(line[0]) === relationName);
|
|
1388
|
+
}
|
|
1389
|
+
__name(hasBackRelation, "hasBackRelation");
|
|
1390
|
+
function enableFkPointers(schema2, models) {
|
|
1391
|
+
const lines = schema2.split("\n");
|
|
1392
|
+
const enabled = [];
|
|
1393
|
+
const skipped = [];
|
|
1394
|
+
const needsBackRelation = [];
|
|
1395
|
+
for (const pointer of findFkPointers(schema2)) {
|
|
1396
|
+
const model = pointer.target === "Tenant" ? models.tenant : models.user;
|
|
1397
|
+
if (!model) {
|
|
1398
|
+
skipped.push({
|
|
1399
|
+
line: pointer.line,
|
|
1400
|
+
target: pointer.target
|
|
1401
|
+
});
|
|
1402
|
+
continue;
|
|
1403
|
+
}
|
|
1404
|
+
const match = POINTER.exec(pointer.text);
|
|
1405
|
+
const relationName = relationNameOf(match[7]);
|
|
1406
|
+
const foreignKey = foreignKeyOf(match[7]);
|
|
1407
|
+
const singular = foreignKey !== null && isOneToOne(schema2, pointer.model, foreignKey);
|
|
1408
|
+
if (!hasBackRelation(schema2, pointer.model, model, relationName, singular)) {
|
|
1409
|
+
needsBackRelation.push({
|
|
1410
|
+
line: pointer.line,
|
|
1411
|
+
owner: model,
|
|
1412
|
+
suggestion: backRelationSuggestion(pointer.model, relationName, singular)
|
|
1413
|
+
});
|
|
1414
|
+
continue;
|
|
1415
|
+
}
|
|
1416
|
+
const [, indent, field, gap1, , optional, gap2, relation] = match;
|
|
1417
|
+
lines[pointer.line] = `${indent}${field}${gap1}${model}${optional}${gap2}${relation}`;
|
|
1418
|
+
enabled.push({
|
|
1419
|
+
line: pointer.line,
|
|
1420
|
+
model
|
|
1421
|
+
});
|
|
1422
|
+
}
|
|
1423
|
+
return {
|
|
1424
|
+
schema: lines.join("\n"),
|
|
1425
|
+
enabled,
|
|
1426
|
+
skipped,
|
|
1427
|
+
needsBackRelation
|
|
1428
|
+
};
|
|
1429
|
+
}
|
|
1430
|
+
__name(enableFkPointers, "enableFkPointers");
|
|
1431
|
+
var lowerFirst = /* @__PURE__ */ __name((value) => value.charAt(0).toLowerCase() + value.slice(1), "lowerFirst");
|
|
1432
|
+
function foreignKeyOf(relationAttribute) {
|
|
1433
|
+
const match = /fields:\s*\[\s*(\w+)/.exec(relationAttribute);
|
|
1434
|
+
return match ? match[1] : null;
|
|
1435
|
+
}
|
|
1436
|
+
__name(foreignKeyOf, "foreignKeyOf");
|
|
1437
|
+
function backRelationSuggestion(model, relationName, singular) {
|
|
1438
|
+
const field = singular ? lowerFirst(model) : `${lowerFirst(model)}s`;
|
|
1439
|
+
const type = singular ? `${model}?` : `${model}[]`;
|
|
1440
|
+
return `${field} ${type}` + (relationName ? ` @relation("${relationName}")` : "");
|
|
1441
|
+
}
|
|
1442
|
+
__name(backRelationSuggestion, "backRelationSuggestion");
|
|
1443
|
+
function assertModelsExist(declaredModels, models) {
|
|
1444
|
+
const missing = Object.entries(models).filter(([, name]) => name && !declaredModels.includes(name)).map(([role, name]) => `--${role}-model=${name}`);
|
|
1445
|
+
if (missing.length === 0) return;
|
|
1446
|
+
throw new Error(`${missing.join(", ")} \u2014 no such model in this schema. It declares: ${declaredModels.slice().sort().join(", ")}.`);
|
|
1447
|
+
}
|
|
1448
|
+
__name(assertModelsExist, "assertModelsExist");
|
|
1449
|
+
|
|
1338
1450
|
// src/schema-check.ts
|
|
1339
1451
|
function renderType(signature) {
|
|
1340
1452
|
return `${signature.type}${signature.list ? "[]" : ""}${signature.optional ? "?" : ""}`;
|
|
@@ -1467,6 +1579,27 @@ function breaksContract(attribute) {
|
|
|
1467
1579
|
return attribute.kind !== "index";
|
|
1468
1580
|
}
|
|
1469
1581
|
__name(breaksContract, "breaksContract");
|
|
1582
|
+
function modelsKeptPastTheTenant(specSchema) {
|
|
1583
|
+
const pointed = new Set(findFkPointers(specSchema).filter((pointer) => pointer.target === "Tenant").map((pointer) => pointer.model));
|
|
1584
|
+
return [
|
|
1585
|
+
...parseSchema(specSchema).models
|
|
1586
|
+
].filter(([model, fields]) => fields.has("tenantId") && !pointed.has(model)).map(([model]) => model);
|
|
1587
|
+
}
|
|
1588
|
+
__name(modelsKeptPastTheTenant, "modelsKeptPastTheTenant");
|
|
1589
|
+
function cascadesFromTenant(model, block) {
|
|
1590
|
+
const found = [];
|
|
1591
|
+
for (const line of block.split("\n")) {
|
|
1592
|
+
const compact = structuralOnly(line).replace(/\s+/g, "");
|
|
1593
|
+
if (compact.includes("@relation(") && compact.includes("fields:[tenantId]") && compact.includes("onDelete:Cascade")) {
|
|
1594
|
+
found.push({
|
|
1595
|
+
model,
|
|
1596
|
+
field: line.trim().split(/\s/)[0]
|
|
1597
|
+
});
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
return found;
|
|
1601
|
+
}
|
|
1602
|
+
__name(cascadesFromTenant, "cascadesFromTenant");
|
|
1470
1603
|
function checkSchema(specSchema, appSchema) {
|
|
1471
1604
|
const spec = parseSchema(specSchema);
|
|
1472
1605
|
const app = parseSchema(appSchema);
|
|
@@ -1487,6 +1620,11 @@ function checkSchema(specSchema, appSchema) {
|
|
|
1487
1620
|
compareBlockAttributes(model, specAttrs, appAttrs, missingBlockAttributes);
|
|
1488
1621
|
}
|
|
1489
1622
|
}
|
|
1623
|
+
const appBlocks = extractBlocks(appSchema, "model");
|
|
1624
|
+
const tenantCascades = modelsKeptPastTheTenant(specSchema).flatMap((model) => {
|
|
1625
|
+
const block = appBlocks.get(model);
|
|
1626
|
+
return block ? cascadesFromTenant(model, block) : [];
|
|
1627
|
+
});
|
|
1490
1628
|
const absentEnums = [];
|
|
1491
1629
|
const missingEnumValues = [];
|
|
1492
1630
|
for (const [name, specValues] of spec.enums) {
|
|
@@ -1511,9 +1649,10 @@ function checkSchema(specSchema, appSchema) {
|
|
|
1511
1649
|
missingEnumValues,
|
|
1512
1650
|
fieldMismatches,
|
|
1513
1651
|
missingBlockAttributes,
|
|
1652
|
+
tenantCascades,
|
|
1514
1653
|
checkedModelCount: spec.models.size - absentModels.length,
|
|
1515
1654
|
checkedEnumCount: spec.enums.size - absentEnums.length,
|
|
1516
|
-
ok: missingFields.length === 0 && missingEnumValues.length === 0 && fieldMismatches.length === 0 && !missingBlockAttributes.some(breaksContract)
|
|
1655
|
+
ok: missingFields.length === 0 && missingEnumValues.length === 0 && fieldMismatches.length === 0 && tenantCascades.length === 0 && !missingBlockAttributes.some(breaksContract)
|
|
1517
1656
|
};
|
|
1518
1657
|
}
|
|
1519
1658
|
__name(checkSchema, "checkSchema");
|
|
@@ -1594,117 +1733,6 @@ function reportConstraints(outcome, context) {
|
|
|
1594
1733
|
}
|
|
1595
1734
|
__name(reportConstraints, "reportConstraints");
|
|
1596
1735
|
|
|
1597
|
-
// src/fk-pointers.ts
|
|
1598
|
-
var POINTER = /^(\s*)\/\/\s*(\w+)(\s+)(Tenant|User)(\??)(\s+)(@relation\(.*\))\s*$/;
|
|
1599
|
-
function findFkPointers(schema2) {
|
|
1600
|
-
const found = [];
|
|
1601
|
-
let model = "";
|
|
1602
|
-
schema2.split("\n").forEach((text, line) => {
|
|
1603
|
-
const opening = /^model\s+(\w+)\s*\{/.exec(text);
|
|
1604
|
-
if (opening) model = opening[1];
|
|
1605
|
-
const match = POINTER.exec(text);
|
|
1606
|
-
if (match) found.push({
|
|
1607
|
-
line,
|
|
1608
|
-
target: match[4],
|
|
1609
|
-
model,
|
|
1610
|
-
text
|
|
1611
|
-
});
|
|
1612
|
-
});
|
|
1613
|
-
return found;
|
|
1614
|
-
}
|
|
1615
|
-
__name(findFkPointers, "findFkPointers");
|
|
1616
|
-
function relationNameOf(relationAttribute) {
|
|
1617
|
-
const match = /@relation\(\s*"([^"]+)"/.exec(relationAttribute);
|
|
1618
|
-
return match ? match[1] : null;
|
|
1619
|
-
}
|
|
1620
|
-
__name(relationNameOf, "relationNameOf");
|
|
1621
|
-
function escapeForRegExp(value) {
|
|
1622
|
-
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1623
|
-
}
|
|
1624
|
-
__name(escapeForRegExp, "escapeForRegExp");
|
|
1625
|
-
function modelBody(schema2, model) {
|
|
1626
|
-
const name = escapeForRegExp(model);
|
|
1627
|
-
const block = new RegExp(`(^|\\n)model\\s+${name}\\s*\\{([\\s\\S]*?)\\n\\}`, "m").exec(schema2);
|
|
1628
|
-
return block ? block[2] : null;
|
|
1629
|
-
}
|
|
1630
|
-
__name(modelBody, "modelBody");
|
|
1631
|
-
function isOneToOne(schema2, model, foreignKey) {
|
|
1632
|
-
const body = modelBody(schema2, model);
|
|
1633
|
-
if (!body) return false;
|
|
1634
|
-
return new RegExp(`^\\s*${escapeForRegExp(foreignKey)}\\s+\\S+.*@unique`, "m").test(body);
|
|
1635
|
-
}
|
|
1636
|
-
__name(isOneToOne, "isOneToOne");
|
|
1637
|
-
function hasBackRelation(schema2, model, owner, relationName, singular = false) {
|
|
1638
|
-
const body = modelBody(schema2, owner);
|
|
1639
|
-
if (body === null) return false;
|
|
1640
|
-
const name = escapeForRegExp(model);
|
|
1641
|
-
const shape = singular ? `${name}\\??` : `${name}\\[\\]`;
|
|
1642
|
-
const candidates = [
|
|
1643
|
-
...body.matchAll(new RegExp(`^\\s*\\w+\\s+${shape}(\\s.*)?$`, "gm"))
|
|
1644
|
-
];
|
|
1645
|
-
return candidates.some((line) => relationNameOf(line[0]) === relationName);
|
|
1646
|
-
}
|
|
1647
|
-
__name(hasBackRelation, "hasBackRelation");
|
|
1648
|
-
function enableFkPointers(schema2, models) {
|
|
1649
|
-
const lines = schema2.split("\n");
|
|
1650
|
-
const enabled = [];
|
|
1651
|
-
const skipped = [];
|
|
1652
|
-
const needsBackRelation = [];
|
|
1653
|
-
for (const pointer of findFkPointers(schema2)) {
|
|
1654
|
-
const model = pointer.target === "Tenant" ? models.tenant : models.user;
|
|
1655
|
-
if (!model) {
|
|
1656
|
-
skipped.push({
|
|
1657
|
-
line: pointer.line,
|
|
1658
|
-
target: pointer.target
|
|
1659
|
-
});
|
|
1660
|
-
continue;
|
|
1661
|
-
}
|
|
1662
|
-
const match = POINTER.exec(pointer.text);
|
|
1663
|
-
const relationName = relationNameOf(match[7]);
|
|
1664
|
-
const foreignKey = foreignKeyOf(match[7]);
|
|
1665
|
-
const singular = foreignKey !== null && isOneToOne(schema2, pointer.model, foreignKey);
|
|
1666
|
-
if (!hasBackRelation(schema2, pointer.model, model, relationName, singular)) {
|
|
1667
|
-
needsBackRelation.push({
|
|
1668
|
-
line: pointer.line,
|
|
1669
|
-
owner: model,
|
|
1670
|
-
suggestion: backRelationSuggestion(pointer.model, relationName, singular)
|
|
1671
|
-
});
|
|
1672
|
-
continue;
|
|
1673
|
-
}
|
|
1674
|
-
const [, indent, field, gap1, , optional, gap2, relation] = match;
|
|
1675
|
-
lines[pointer.line] = `${indent}${field}${gap1}${model}${optional}${gap2}${relation}`;
|
|
1676
|
-
enabled.push({
|
|
1677
|
-
line: pointer.line,
|
|
1678
|
-
model
|
|
1679
|
-
});
|
|
1680
|
-
}
|
|
1681
|
-
return {
|
|
1682
|
-
schema: lines.join("\n"),
|
|
1683
|
-
enabled,
|
|
1684
|
-
skipped,
|
|
1685
|
-
needsBackRelation
|
|
1686
|
-
};
|
|
1687
|
-
}
|
|
1688
|
-
__name(enableFkPointers, "enableFkPointers");
|
|
1689
|
-
var lowerFirst = /* @__PURE__ */ __name((value) => value.charAt(0).toLowerCase() + value.slice(1), "lowerFirst");
|
|
1690
|
-
function foreignKeyOf(relationAttribute) {
|
|
1691
|
-
const match = /fields:\s*\[\s*(\w+)/.exec(relationAttribute);
|
|
1692
|
-
return match ? match[1] : null;
|
|
1693
|
-
}
|
|
1694
|
-
__name(foreignKeyOf, "foreignKeyOf");
|
|
1695
|
-
function backRelationSuggestion(model, relationName, singular) {
|
|
1696
|
-
const field = singular ? lowerFirst(model) : `${lowerFirst(model)}s`;
|
|
1697
|
-
const type = singular ? `${model}?` : `${model}[]`;
|
|
1698
|
-
return `${field} ${type}` + (relationName ? ` @relation("${relationName}")` : "");
|
|
1699
|
-
}
|
|
1700
|
-
__name(backRelationSuggestion, "backRelationSuggestion");
|
|
1701
|
-
function assertModelsExist(declaredModels, models) {
|
|
1702
|
-
const missing = Object.entries(models).filter(([, name]) => name && !declaredModels.includes(name)).map(([role, name]) => `--${role}-model=${name}`);
|
|
1703
|
-
if (missing.length === 0) return;
|
|
1704
|
-
throw new Error(`${missing.join(", ")} \u2014 no such model in this schema. It declares: ${declaredModels.slice().sort().join(", ")}.`);
|
|
1705
|
-
}
|
|
1706
|
-
__name(assertModelsExist, "assertModelsExist");
|
|
1707
|
-
|
|
1708
1736
|
// src/init/catalog-keys.ts
|
|
1709
1737
|
var import_spec = require("@saasicat/spec");
|
|
1710
1738
|
var schema = import_spec.planCatalogSchema;
|
|
@@ -3788,6 +3816,7 @@ UserCommands = _ts_decorate14([
|
|
|
3788
3816
|
kebabCase,
|
|
3789
3817
|
migrationCreatedBy,
|
|
3790
3818
|
minimumQuotasPerPlan,
|
|
3819
|
+
modelsKeptPastTheTenant,
|
|
3791
3820
|
namedImports,
|
|
3792
3821
|
parseBlockAttributes,
|
|
3793
3822
|
parseEnumValues,
|
package/dist/index.d.cts
CHANGED
|
@@ -467,6 +467,15 @@ interface MissingBlockAttribute {
|
|
|
467
467
|
/** For `map`: what the consumer maps to instead. */
|
|
468
468
|
actual?: string;
|
|
469
469
|
}
|
|
470
|
+
/**
|
|
471
|
+
* A relation that deletes a record the platform keeps past its tenant, together
|
|
472
|
+
* with the tenant.
|
|
473
|
+
*/
|
|
474
|
+
interface TenantCascade {
|
|
475
|
+
model: string;
|
|
476
|
+
/** The relation field that cascades, e.g. `tenant`. */
|
|
477
|
+
field: string;
|
|
478
|
+
}
|
|
470
479
|
interface SchemaCheckReport {
|
|
471
480
|
/** Platform models the consumer does not carry — informational. */
|
|
472
481
|
absentModels: string[];
|
|
@@ -480,6 +489,11 @@ interface SchemaCheckReport {
|
|
|
480
489
|
* `@@index`, `@@unique`, and a diverging `@@map`.
|
|
481
490
|
*/
|
|
482
491
|
missingBlockAttributes: MissingBlockAttribute[];
|
|
492
|
+
/**
|
|
493
|
+
* Relations that cascade from the tenant onto a model the platform keeps
|
|
494
|
+
* after the tenant is gone, such as a contract.
|
|
495
|
+
*/
|
|
496
|
+
tenantCascades: TenantCascade[];
|
|
483
497
|
/** Models present in both schemas, i.e. actually compared. */
|
|
484
498
|
checkedModelCount: number;
|
|
485
499
|
/** Enums present in both schemas, i.e. actually compared. */
|
|
@@ -514,6 +528,15 @@ declare function parseSchema(schema: string): ParsedSchema;
|
|
|
514
528
|
* the check.
|
|
515
529
|
*/
|
|
516
530
|
declare function breaksContract(attribute: MissingBlockAttribute): boolean;
|
|
531
|
+
/**
|
|
532
|
+
* The platform models that outlive their tenant: the ones whose fragment
|
|
533
|
+
* declares `tenantId` and deliberately comments no relation to `Tenant`.
|
|
534
|
+
*
|
|
535
|
+
* Read off the fragments rather than listed, so the next model kept past its
|
|
536
|
+
* tenant is covered by being written that way. Every other model with a
|
|
537
|
+
* `tenantId` ships the pointer a consumer enables, cascade and all.
|
|
538
|
+
*/
|
|
539
|
+
declare function modelsKeptPastTheTenant(specSchema: string): string[];
|
|
517
540
|
/**
|
|
518
541
|
* Compares a consumer schema against the canonical fragments. `specSchema` is
|
|
519
542
|
* the concatenation of the fragments the check should cover.
|
|
@@ -1379,4 +1402,4 @@ declare class UserCommands extends CommandRunner {
|
|
|
1379
1402
|
parsePassword(val: string): string;
|
|
1380
1403
|
}
|
|
1381
1404
|
|
|
1382
|
-
export { AUDIT_QUERY_PORT_TOKEN, AdminCommands, AdminManifestDoctorCheck, AdminMfaSetupCommand, AdminWhoamiCommand, type ApplyResult, AuditCommands, AuditTailCommand, AuditTailFlow, type AuditTailOptions, type BlockAttributeKind, type BlockAttributes, CLI_CONTEXT_CONFIG_TOKEN, CONSTRAINTS_MARKER, type CheckSeverity, type CliContextConfig, CliContextModule, type CliContextModuleOptions, CliContextService, CliError, type CliIdentity, type ConstraintsOutcome, type ConstraintsReport, DEFAULT_MANIFEST_CHECKS, DOCTOR_CHECKS_TOKEN, type DbCatalogOccurrence, type DbCatalogResult, type DbCatalogShape, DiscoveryCommands, DiscoveryScanCommand, DiscoverySnapshotDoctorCheck, type DoctorCheck, type DoctorCheckResult, DoctorCommands, DoctorFlow, type DoctorReport, type EnableFkResult, type FieldMismatch, type FieldMismatchReason, type FieldSignature, type FkModelNames, type FkPointer, type FragmentBlocks, type InitOptions, type InitPlan, LIMIT_FILTER_IMPORTS, LIMIT_FILTER_PROVIDER, MANIFEST_ACCESS_PORT_TOKEN, MANIFEST_CHECKS_TOKEN, type ManifestCheck, ManifestCheckCommand, type ManifestCheckReport, type ManifestCheckResult, ManifestCliFlow, ManifestCommands, type ManifestDiff, ManifestDumpCommand, ManifestHashCommand, type ManifestRewriteOptions, ManifestValidateCommand, MfaSetupFlow, type MfaSetupOptions, type MfaSetupResult, type MissingBlockAttribute, type MissingEnumValue, type MissingField, type ModuleResolutionVerdict, type MoveTable, type MovedSetting, type MovedSettingOccurrence, type MovedSettingsResult, PLATFORM_DOCTOR_CHECK_PROVIDERS, type ParsedSchema, type PatchAppModuleOptions, type PatchOptionsFromPlan, type PatchResult, PlanCatalogDoctorCheck, type PlannedFile, type ProjectKeyResult, type QuotaProviderFile, type QuotaSpec, type RenameResult, type RenameTable, type RewriteResult, SCANNED_FOR_DB_CATALOG, SCANNED_FOR_MOVED_SETTINGS, SETTINGS_THAT_MOVED, type SchemaCheckReport, UI_VUE_SPECIFIER, USER_MANAGEMENT_PORT_TOKEN, USER_PORT_TOKEN, UserCommands, UserPortDoctorCheck, WHERE_DB_CATALOG_GOES, WHERE_IT_GOES, WhoAmIFlow, type WhoAmIResult, type WrittenSetting, appKeyPattern, appendConstraints, applyFragmentBlocks, applyTokens, assertModelsExist, assertValidAppKey, assertValidQuotaKey, blankStringLiterals, blockBodyLines, breaksContract, buildImportMap, checkSchema, constraintsFor, describeDbCatalogOccurrence, enableFkPointers, extractBlockNames, extractBlocks, extractEnumBlocks, extractEnumNames, extractFragmentBlocks, extractModelBlocks, extractModelNames, findDbCatalogBlocks, findFkPointers, findMovedSettings, foreignKeyOf, hasBackRelation, hasConstraints, isNoLongerPublic, isOneToOne, judgeModuleResolution, kebabCase, migrationCreatedBy, minimumQuotasPerPlan, namedImports, parseBlockAttributes, parseEnumValues, parseFields, parseQuota, parseSchema, pascalCase, patchAppModule, patchOptionsFor, planInit, quotaKeyPattern, readEffectiveModuleResolution, relationNameOf, removeProjectKey, reportConstraints, rewriteImports, rewriteManifest, rewriteNames, rewriteSubpath, settingsWrittenTo, stripLineComment, structuralOnly, tablesAddressedBy };
|
|
1405
|
+
export { AUDIT_QUERY_PORT_TOKEN, AdminCommands, AdminManifestDoctorCheck, AdminMfaSetupCommand, AdminWhoamiCommand, type ApplyResult, AuditCommands, AuditTailCommand, AuditTailFlow, type AuditTailOptions, type BlockAttributeKind, type BlockAttributes, CLI_CONTEXT_CONFIG_TOKEN, CONSTRAINTS_MARKER, type CheckSeverity, type CliContextConfig, CliContextModule, type CliContextModuleOptions, CliContextService, CliError, type CliIdentity, type ConstraintsOutcome, type ConstraintsReport, DEFAULT_MANIFEST_CHECKS, DOCTOR_CHECKS_TOKEN, type DbCatalogOccurrence, type DbCatalogResult, type DbCatalogShape, DiscoveryCommands, DiscoveryScanCommand, DiscoverySnapshotDoctorCheck, type DoctorCheck, type DoctorCheckResult, DoctorCommands, DoctorFlow, type DoctorReport, type EnableFkResult, type FieldMismatch, type FieldMismatchReason, type FieldSignature, type FkModelNames, type FkPointer, type FragmentBlocks, type InitOptions, type InitPlan, LIMIT_FILTER_IMPORTS, LIMIT_FILTER_PROVIDER, MANIFEST_ACCESS_PORT_TOKEN, MANIFEST_CHECKS_TOKEN, type ManifestCheck, ManifestCheckCommand, type ManifestCheckReport, type ManifestCheckResult, ManifestCliFlow, ManifestCommands, type ManifestDiff, ManifestDumpCommand, ManifestHashCommand, type ManifestRewriteOptions, ManifestValidateCommand, MfaSetupFlow, type MfaSetupOptions, type MfaSetupResult, type MissingBlockAttribute, type MissingEnumValue, type MissingField, type ModuleResolutionVerdict, type MoveTable, type MovedSetting, type MovedSettingOccurrence, type MovedSettingsResult, PLATFORM_DOCTOR_CHECK_PROVIDERS, type ParsedSchema, type PatchAppModuleOptions, type PatchOptionsFromPlan, type PatchResult, PlanCatalogDoctorCheck, type PlannedFile, type ProjectKeyResult, type QuotaProviderFile, type QuotaSpec, type RenameResult, type RenameTable, type RewriteResult, SCANNED_FOR_DB_CATALOG, SCANNED_FOR_MOVED_SETTINGS, SETTINGS_THAT_MOVED, type SchemaCheckReport, type TenantCascade, UI_VUE_SPECIFIER, USER_MANAGEMENT_PORT_TOKEN, USER_PORT_TOKEN, UserCommands, UserPortDoctorCheck, WHERE_DB_CATALOG_GOES, WHERE_IT_GOES, WhoAmIFlow, type WhoAmIResult, type WrittenSetting, appKeyPattern, appendConstraints, applyFragmentBlocks, applyTokens, assertModelsExist, assertValidAppKey, assertValidQuotaKey, blankStringLiterals, blockBodyLines, breaksContract, buildImportMap, checkSchema, constraintsFor, describeDbCatalogOccurrence, enableFkPointers, extractBlockNames, extractBlocks, extractEnumBlocks, extractEnumNames, extractFragmentBlocks, extractModelBlocks, extractModelNames, findDbCatalogBlocks, findFkPointers, findMovedSettings, foreignKeyOf, hasBackRelation, hasConstraints, isNoLongerPublic, isOneToOne, judgeModuleResolution, kebabCase, migrationCreatedBy, minimumQuotasPerPlan, modelsKeptPastTheTenant, namedImports, parseBlockAttributes, parseEnumValues, parseFields, parseQuota, parseSchema, pascalCase, patchAppModule, patchOptionsFor, planInit, quotaKeyPattern, readEffectiveModuleResolution, relationNameOf, removeProjectKey, reportConstraints, rewriteImports, rewriteManifest, rewriteNames, rewriteSubpath, settingsWrittenTo, stripLineComment, structuralOnly, tablesAddressedBy };
|
package/dist/index.d.ts
CHANGED
|
@@ -467,6 +467,15 @@ interface MissingBlockAttribute {
|
|
|
467
467
|
/** For `map`: what the consumer maps to instead. */
|
|
468
468
|
actual?: string;
|
|
469
469
|
}
|
|
470
|
+
/**
|
|
471
|
+
* A relation that deletes a record the platform keeps past its tenant, together
|
|
472
|
+
* with the tenant.
|
|
473
|
+
*/
|
|
474
|
+
interface TenantCascade {
|
|
475
|
+
model: string;
|
|
476
|
+
/** The relation field that cascades, e.g. `tenant`. */
|
|
477
|
+
field: string;
|
|
478
|
+
}
|
|
470
479
|
interface SchemaCheckReport {
|
|
471
480
|
/** Platform models the consumer does not carry — informational. */
|
|
472
481
|
absentModels: string[];
|
|
@@ -480,6 +489,11 @@ interface SchemaCheckReport {
|
|
|
480
489
|
* `@@index`, `@@unique`, and a diverging `@@map`.
|
|
481
490
|
*/
|
|
482
491
|
missingBlockAttributes: MissingBlockAttribute[];
|
|
492
|
+
/**
|
|
493
|
+
* Relations that cascade from the tenant onto a model the platform keeps
|
|
494
|
+
* after the tenant is gone, such as a contract.
|
|
495
|
+
*/
|
|
496
|
+
tenantCascades: TenantCascade[];
|
|
483
497
|
/** Models present in both schemas, i.e. actually compared. */
|
|
484
498
|
checkedModelCount: number;
|
|
485
499
|
/** Enums present in both schemas, i.e. actually compared. */
|
|
@@ -514,6 +528,15 @@ declare function parseSchema(schema: string): ParsedSchema;
|
|
|
514
528
|
* the check.
|
|
515
529
|
*/
|
|
516
530
|
declare function breaksContract(attribute: MissingBlockAttribute): boolean;
|
|
531
|
+
/**
|
|
532
|
+
* The platform models that outlive their tenant: the ones whose fragment
|
|
533
|
+
* declares `tenantId` and deliberately comments no relation to `Tenant`.
|
|
534
|
+
*
|
|
535
|
+
* Read off the fragments rather than listed, so the next model kept past its
|
|
536
|
+
* tenant is covered by being written that way. Every other model with a
|
|
537
|
+
* `tenantId` ships the pointer a consumer enables, cascade and all.
|
|
538
|
+
*/
|
|
539
|
+
declare function modelsKeptPastTheTenant(specSchema: string): string[];
|
|
517
540
|
/**
|
|
518
541
|
* Compares a consumer schema against the canonical fragments. `specSchema` is
|
|
519
542
|
* the concatenation of the fragments the check should cover.
|
|
@@ -1379,4 +1402,4 @@ declare class UserCommands extends CommandRunner {
|
|
|
1379
1402
|
parsePassword(val: string): string;
|
|
1380
1403
|
}
|
|
1381
1404
|
|
|
1382
|
-
export { AUDIT_QUERY_PORT_TOKEN, AdminCommands, AdminManifestDoctorCheck, AdminMfaSetupCommand, AdminWhoamiCommand, type ApplyResult, AuditCommands, AuditTailCommand, AuditTailFlow, type AuditTailOptions, type BlockAttributeKind, type BlockAttributes, CLI_CONTEXT_CONFIG_TOKEN, CONSTRAINTS_MARKER, type CheckSeverity, type CliContextConfig, CliContextModule, type CliContextModuleOptions, CliContextService, CliError, type CliIdentity, type ConstraintsOutcome, type ConstraintsReport, DEFAULT_MANIFEST_CHECKS, DOCTOR_CHECKS_TOKEN, type DbCatalogOccurrence, type DbCatalogResult, type DbCatalogShape, DiscoveryCommands, DiscoveryScanCommand, DiscoverySnapshotDoctorCheck, type DoctorCheck, type DoctorCheckResult, DoctorCommands, DoctorFlow, type DoctorReport, type EnableFkResult, type FieldMismatch, type FieldMismatchReason, type FieldSignature, type FkModelNames, type FkPointer, type FragmentBlocks, type InitOptions, type InitPlan, LIMIT_FILTER_IMPORTS, LIMIT_FILTER_PROVIDER, MANIFEST_ACCESS_PORT_TOKEN, MANIFEST_CHECKS_TOKEN, type ManifestCheck, ManifestCheckCommand, type ManifestCheckReport, type ManifestCheckResult, ManifestCliFlow, ManifestCommands, type ManifestDiff, ManifestDumpCommand, ManifestHashCommand, type ManifestRewriteOptions, ManifestValidateCommand, MfaSetupFlow, type MfaSetupOptions, type MfaSetupResult, type MissingBlockAttribute, type MissingEnumValue, type MissingField, type ModuleResolutionVerdict, type MoveTable, type MovedSetting, type MovedSettingOccurrence, type MovedSettingsResult, PLATFORM_DOCTOR_CHECK_PROVIDERS, type ParsedSchema, type PatchAppModuleOptions, type PatchOptionsFromPlan, type PatchResult, PlanCatalogDoctorCheck, type PlannedFile, type ProjectKeyResult, type QuotaProviderFile, type QuotaSpec, type RenameResult, type RenameTable, type RewriteResult, SCANNED_FOR_DB_CATALOG, SCANNED_FOR_MOVED_SETTINGS, SETTINGS_THAT_MOVED, type SchemaCheckReport, UI_VUE_SPECIFIER, USER_MANAGEMENT_PORT_TOKEN, USER_PORT_TOKEN, UserCommands, UserPortDoctorCheck, WHERE_DB_CATALOG_GOES, WHERE_IT_GOES, WhoAmIFlow, type WhoAmIResult, type WrittenSetting, appKeyPattern, appendConstraints, applyFragmentBlocks, applyTokens, assertModelsExist, assertValidAppKey, assertValidQuotaKey, blankStringLiterals, blockBodyLines, breaksContract, buildImportMap, checkSchema, constraintsFor, describeDbCatalogOccurrence, enableFkPointers, extractBlockNames, extractBlocks, extractEnumBlocks, extractEnumNames, extractFragmentBlocks, extractModelBlocks, extractModelNames, findDbCatalogBlocks, findFkPointers, findMovedSettings, foreignKeyOf, hasBackRelation, hasConstraints, isNoLongerPublic, isOneToOne, judgeModuleResolution, kebabCase, migrationCreatedBy, minimumQuotasPerPlan, namedImports, parseBlockAttributes, parseEnumValues, parseFields, parseQuota, parseSchema, pascalCase, patchAppModule, patchOptionsFor, planInit, quotaKeyPattern, readEffectiveModuleResolution, relationNameOf, removeProjectKey, reportConstraints, rewriteImports, rewriteManifest, rewriteNames, rewriteSubpath, settingsWrittenTo, stripLineComment, structuralOnly, tablesAddressedBy };
|
|
1405
|
+
export { AUDIT_QUERY_PORT_TOKEN, AdminCommands, AdminManifestDoctorCheck, AdminMfaSetupCommand, AdminWhoamiCommand, type ApplyResult, AuditCommands, AuditTailCommand, AuditTailFlow, type AuditTailOptions, type BlockAttributeKind, type BlockAttributes, CLI_CONTEXT_CONFIG_TOKEN, CONSTRAINTS_MARKER, type CheckSeverity, type CliContextConfig, CliContextModule, type CliContextModuleOptions, CliContextService, CliError, type CliIdentity, type ConstraintsOutcome, type ConstraintsReport, DEFAULT_MANIFEST_CHECKS, DOCTOR_CHECKS_TOKEN, type DbCatalogOccurrence, type DbCatalogResult, type DbCatalogShape, DiscoveryCommands, DiscoveryScanCommand, DiscoverySnapshotDoctorCheck, type DoctorCheck, type DoctorCheckResult, DoctorCommands, DoctorFlow, type DoctorReport, type EnableFkResult, type FieldMismatch, type FieldMismatchReason, type FieldSignature, type FkModelNames, type FkPointer, type FragmentBlocks, type InitOptions, type InitPlan, LIMIT_FILTER_IMPORTS, LIMIT_FILTER_PROVIDER, MANIFEST_ACCESS_PORT_TOKEN, MANIFEST_CHECKS_TOKEN, type ManifestCheck, ManifestCheckCommand, type ManifestCheckReport, type ManifestCheckResult, ManifestCliFlow, ManifestCommands, type ManifestDiff, ManifestDumpCommand, ManifestHashCommand, type ManifestRewriteOptions, ManifestValidateCommand, MfaSetupFlow, type MfaSetupOptions, type MfaSetupResult, type MissingBlockAttribute, type MissingEnumValue, type MissingField, type ModuleResolutionVerdict, type MoveTable, type MovedSetting, type MovedSettingOccurrence, type MovedSettingsResult, PLATFORM_DOCTOR_CHECK_PROVIDERS, type ParsedSchema, type PatchAppModuleOptions, type PatchOptionsFromPlan, type PatchResult, PlanCatalogDoctorCheck, type PlannedFile, type ProjectKeyResult, type QuotaProviderFile, type QuotaSpec, type RenameResult, type RenameTable, type RewriteResult, SCANNED_FOR_DB_CATALOG, SCANNED_FOR_MOVED_SETTINGS, SETTINGS_THAT_MOVED, type SchemaCheckReport, type TenantCascade, UI_VUE_SPECIFIER, USER_MANAGEMENT_PORT_TOKEN, USER_PORT_TOKEN, UserCommands, UserPortDoctorCheck, WHERE_DB_CATALOG_GOES, WHERE_IT_GOES, WhoAmIFlow, type WhoAmIResult, type WrittenSetting, appKeyPattern, appendConstraints, applyFragmentBlocks, applyTokens, assertModelsExist, assertValidAppKey, assertValidQuotaKey, blankStringLiterals, blockBodyLines, breaksContract, buildImportMap, checkSchema, constraintsFor, describeDbCatalogOccurrence, enableFkPointers, extractBlockNames, extractBlocks, extractEnumBlocks, extractEnumNames, extractFragmentBlocks, extractModelBlocks, extractModelNames, findDbCatalogBlocks, findFkPointers, findMovedSettings, foreignKeyOf, hasBackRelation, hasConstraints, isNoLongerPublic, isOneToOne, judgeModuleResolution, kebabCase, migrationCreatedBy, minimumQuotasPerPlan, modelsKeptPastTheTenant, namedImports, parseBlockAttributes, parseEnumValues, parseFields, parseQuota, parseSchema, pascalCase, patchAppModule, patchOptionsFor, planInit, quotaKeyPattern, readEffectiveModuleResolution, relationNameOf, removeProjectKey, reportConstraints, rewriteImports, rewriteManifest, rewriteNames, rewriteSubpath, settingsWrittenTo, stripLineComment, structuralOnly, tablesAddressedBy };
|
package/dist/index.js
CHANGED
|
@@ -1201,6 +1201,117 @@ function applyFragmentBlocks(schema2, fragmentBlocks, options = {}) {
|
|
|
1201
1201
|
}
|
|
1202
1202
|
__name(applyFragmentBlocks, "applyFragmentBlocks");
|
|
1203
1203
|
|
|
1204
|
+
// src/fk-pointers.ts
|
|
1205
|
+
var POINTER = /^(\s*)\/\/\s*(\w+)(\s+)(Tenant|User)(\??)(\s+)(@relation\(.*\))\s*$/;
|
|
1206
|
+
function findFkPointers(schema2) {
|
|
1207
|
+
const found = [];
|
|
1208
|
+
let model = "";
|
|
1209
|
+
schema2.split("\n").forEach((text, line) => {
|
|
1210
|
+
const opening = /^model\s+(\w+)\s*\{/.exec(text);
|
|
1211
|
+
if (opening) model = opening[1];
|
|
1212
|
+
const match = POINTER.exec(text);
|
|
1213
|
+
if (match) found.push({
|
|
1214
|
+
line,
|
|
1215
|
+
target: match[4],
|
|
1216
|
+
model,
|
|
1217
|
+
text
|
|
1218
|
+
});
|
|
1219
|
+
});
|
|
1220
|
+
return found;
|
|
1221
|
+
}
|
|
1222
|
+
__name(findFkPointers, "findFkPointers");
|
|
1223
|
+
function relationNameOf(relationAttribute) {
|
|
1224
|
+
const match = /@relation\(\s*"([^"]+)"/.exec(relationAttribute);
|
|
1225
|
+
return match ? match[1] : null;
|
|
1226
|
+
}
|
|
1227
|
+
__name(relationNameOf, "relationNameOf");
|
|
1228
|
+
function escapeForRegExp(value) {
|
|
1229
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1230
|
+
}
|
|
1231
|
+
__name(escapeForRegExp, "escapeForRegExp");
|
|
1232
|
+
function modelBody(schema2, model) {
|
|
1233
|
+
const name = escapeForRegExp(model);
|
|
1234
|
+
const block = new RegExp(`(^|\\n)model\\s+${name}\\s*\\{([\\s\\S]*?)\\n\\}`, "m").exec(schema2);
|
|
1235
|
+
return block ? block[2] : null;
|
|
1236
|
+
}
|
|
1237
|
+
__name(modelBody, "modelBody");
|
|
1238
|
+
function isOneToOne(schema2, model, foreignKey) {
|
|
1239
|
+
const body = modelBody(schema2, model);
|
|
1240
|
+
if (!body) return false;
|
|
1241
|
+
return new RegExp(`^\\s*${escapeForRegExp(foreignKey)}\\s+\\S+.*@unique`, "m").test(body);
|
|
1242
|
+
}
|
|
1243
|
+
__name(isOneToOne, "isOneToOne");
|
|
1244
|
+
function hasBackRelation(schema2, model, owner, relationName, singular = false) {
|
|
1245
|
+
const body = modelBody(schema2, owner);
|
|
1246
|
+
if (body === null) return false;
|
|
1247
|
+
const name = escapeForRegExp(model);
|
|
1248
|
+
const shape = singular ? `${name}\\??` : `${name}\\[\\]`;
|
|
1249
|
+
const candidates = [
|
|
1250
|
+
...body.matchAll(new RegExp(`^\\s*\\w+\\s+${shape}(\\s.*)?$`, "gm"))
|
|
1251
|
+
];
|
|
1252
|
+
return candidates.some((line) => relationNameOf(line[0]) === relationName);
|
|
1253
|
+
}
|
|
1254
|
+
__name(hasBackRelation, "hasBackRelation");
|
|
1255
|
+
function enableFkPointers(schema2, models) {
|
|
1256
|
+
const lines = schema2.split("\n");
|
|
1257
|
+
const enabled = [];
|
|
1258
|
+
const skipped = [];
|
|
1259
|
+
const needsBackRelation = [];
|
|
1260
|
+
for (const pointer of findFkPointers(schema2)) {
|
|
1261
|
+
const model = pointer.target === "Tenant" ? models.tenant : models.user;
|
|
1262
|
+
if (!model) {
|
|
1263
|
+
skipped.push({
|
|
1264
|
+
line: pointer.line,
|
|
1265
|
+
target: pointer.target
|
|
1266
|
+
});
|
|
1267
|
+
continue;
|
|
1268
|
+
}
|
|
1269
|
+
const match = POINTER.exec(pointer.text);
|
|
1270
|
+
const relationName = relationNameOf(match[7]);
|
|
1271
|
+
const foreignKey = foreignKeyOf(match[7]);
|
|
1272
|
+
const singular = foreignKey !== null && isOneToOne(schema2, pointer.model, foreignKey);
|
|
1273
|
+
if (!hasBackRelation(schema2, pointer.model, model, relationName, singular)) {
|
|
1274
|
+
needsBackRelation.push({
|
|
1275
|
+
line: pointer.line,
|
|
1276
|
+
owner: model,
|
|
1277
|
+
suggestion: backRelationSuggestion(pointer.model, relationName, singular)
|
|
1278
|
+
});
|
|
1279
|
+
continue;
|
|
1280
|
+
}
|
|
1281
|
+
const [, indent, field, gap1, , optional, gap2, relation] = match;
|
|
1282
|
+
lines[pointer.line] = `${indent}${field}${gap1}${model}${optional}${gap2}${relation}`;
|
|
1283
|
+
enabled.push({
|
|
1284
|
+
line: pointer.line,
|
|
1285
|
+
model
|
|
1286
|
+
});
|
|
1287
|
+
}
|
|
1288
|
+
return {
|
|
1289
|
+
schema: lines.join("\n"),
|
|
1290
|
+
enabled,
|
|
1291
|
+
skipped,
|
|
1292
|
+
needsBackRelation
|
|
1293
|
+
};
|
|
1294
|
+
}
|
|
1295
|
+
__name(enableFkPointers, "enableFkPointers");
|
|
1296
|
+
var lowerFirst = /* @__PURE__ */ __name((value) => value.charAt(0).toLowerCase() + value.slice(1), "lowerFirst");
|
|
1297
|
+
function foreignKeyOf(relationAttribute) {
|
|
1298
|
+
const match = /fields:\s*\[\s*(\w+)/.exec(relationAttribute);
|
|
1299
|
+
return match ? match[1] : null;
|
|
1300
|
+
}
|
|
1301
|
+
__name(foreignKeyOf, "foreignKeyOf");
|
|
1302
|
+
function backRelationSuggestion(model, relationName, singular) {
|
|
1303
|
+
const field = singular ? lowerFirst(model) : `${lowerFirst(model)}s`;
|
|
1304
|
+
const type = singular ? `${model}?` : `${model}[]`;
|
|
1305
|
+
return `${field} ${type}` + (relationName ? ` @relation("${relationName}")` : "");
|
|
1306
|
+
}
|
|
1307
|
+
__name(backRelationSuggestion, "backRelationSuggestion");
|
|
1308
|
+
function assertModelsExist(declaredModels, models) {
|
|
1309
|
+
const missing = Object.entries(models).filter(([, name]) => name && !declaredModels.includes(name)).map(([role, name]) => `--${role}-model=${name}`);
|
|
1310
|
+
if (missing.length === 0) return;
|
|
1311
|
+
throw new Error(`${missing.join(", ")} \u2014 no such model in this schema. It declares: ${declaredModels.slice().sort().join(", ")}.`);
|
|
1312
|
+
}
|
|
1313
|
+
__name(assertModelsExist, "assertModelsExist");
|
|
1314
|
+
|
|
1204
1315
|
// src/schema-check.ts
|
|
1205
1316
|
function renderType(signature) {
|
|
1206
1317
|
return `${signature.type}${signature.list ? "[]" : ""}${signature.optional ? "?" : ""}`;
|
|
@@ -1333,6 +1444,27 @@ function breaksContract(attribute) {
|
|
|
1333
1444
|
return attribute.kind !== "index";
|
|
1334
1445
|
}
|
|
1335
1446
|
__name(breaksContract, "breaksContract");
|
|
1447
|
+
function modelsKeptPastTheTenant(specSchema) {
|
|
1448
|
+
const pointed = new Set(findFkPointers(specSchema).filter((pointer) => pointer.target === "Tenant").map((pointer) => pointer.model));
|
|
1449
|
+
return [
|
|
1450
|
+
...parseSchema(specSchema).models
|
|
1451
|
+
].filter(([model, fields]) => fields.has("tenantId") && !pointed.has(model)).map(([model]) => model);
|
|
1452
|
+
}
|
|
1453
|
+
__name(modelsKeptPastTheTenant, "modelsKeptPastTheTenant");
|
|
1454
|
+
function cascadesFromTenant(model, block) {
|
|
1455
|
+
const found = [];
|
|
1456
|
+
for (const line of block.split("\n")) {
|
|
1457
|
+
const compact = structuralOnly(line).replace(/\s+/g, "");
|
|
1458
|
+
if (compact.includes("@relation(") && compact.includes("fields:[tenantId]") && compact.includes("onDelete:Cascade")) {
|
|
1459
|
+
found.push({
|
|
1460
|
+
model,
|
|
1461
|
+
field: line.trim().split(/\s/)[0]
|
|
1462
|
+
});
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
return found;
|
|
1466
|
+
}
|
|
1467
|
+
__name(cascadesFromTenant, "cascadesFromTenant");
|
|
1336
1468
|
function checkSchema(specSchema, appSchema) {
|
|
1337
1469
|
const spec = parseSchema(specSchema);
|
|
1338
1470
|
const app = parseSchema(appSchema);
|
|
@@ -1353,6 +1485,11 @@ function checkSchema(specSchema, appSchema) {
|
|
|
1353
1485
|
compareBlockAttributes(model, specAttrs, appAttrs, missingBlockAttributes);
|
|
1354
1486
|
}
|
|
1355
1487
|
}
|
|
1488
|
+
const appBlocks = extractBlocks(appSchema, "model");
|
|
1489
|
+
const tenantCascades = modelsKeptPastTheTenant(specSchema).flatMap((model) => {
|
|
1490
|
+
const block = appBlocks.get(model);
|
|
1491
|
+
return block ? cascadesFromTenant(model, block) : [];
|
|
1492
|
+
});
|
|
1356
1493
|
const absentEnums = [];
|
|
1357
1494
|
const missingEnumValues = [];
|
|
1358
1495
|
for (const [name, specValues] of spec.enums) {
|
|
@@ -1377,9 +1514,10 @@ function checkSchema(specSchema, appSchema) {
|
|
|
1377
1514
|
missingEnumValues,
|
|
1378
1515
|
fieldMismatches,
|
|
1379
1516
|
missingBlockAttributes,
|
|
1517
|
+
tenantCascades,
|
|
1380
1518
|
checkedModelCount: spec.models.size - absentModels.length,
|
|
1381
1519
|
checkedEnumCount: spec.enums.size - absentEnums.length,
|
|
1382
|
-
ok: missingFields.length === 0 && missingEnumValues.length === 0 && fieldMismatches.length === 0 && !missingBlockAttributes.some(breaksContract)
|
|
1520
|
+
ok: missingFields.length === 0 && missingEnumValues.length === 0 && fieldMismatches.length === 0 && tenantCascades.length === 0 && !missingBlockAttributes.some(breaksContract)
|
|
1383
1521
|
};
|
|
1384
1522
|
}
|
|
1385
1523
|
__name(checkSchema, "checkSchema");
|
|
@@ -1460,117 +1598,6 @@ function reportConstraints(outcome, context) {
|
|
|
1460
1598
|
}
|
|
1461
1599
|
__name(reportConstraints, "reportConstraints");
|
|
1462
1600
|
|
|
1463
|
-
// src/fk-pointers.ts
|
|
1464
|
-
var POINTER = /^(\s*)\/\/\s*(\w+)(\s+)(Tenant|User)(\??)(\s+)(@relation\(.*\))\s*$/;
|
|
1465
|
-
function findFkPointers(schema2) {
|
|
1466
|
-
const found = [];
|
|
1467
|
-
let model = "";
|
|
1468
|
-
schema2.split("\n").forEach((text, line) => {
|
|
1469
|
-
const opening = /^model\s+(\w+)\s*\{/.exec(text);
|
|
1470
|
-
if (opening) model = opening[1];
|
|
1471
|
-
const match = POINTER.exec(text);
|
|
1472
|
-
if (match) found.push({
|
|
1473
|
-
line,
|
|
1474
|
-
target: match[4],
|
|
1475
|
-
model,
|
|
1476
|
-
text
|
|
1477
|
-
});
|
|
1478
|
-
});
|
|
1479
|
-
return found;
|
|
1480
|
-
}
|
|
1481
|
-
__name(findFkPointers, "findFkPointers");
|
|
1482
|
-
function relationNameOf(relationAttribute) {
|
|
1483
|
-
const match = /@relation\(\s*"([^"]+)"/.exec(relationAttribute);
|
|
1484
|
-
return match ? match[1] : null;
|
|
1485
|
-
}
|
|
1486
|
-
__name(relationNameOf, "relationNameOf");
|
|
1487
|
-
function escapeForRegExp(value) {
|
|
1488
|
-
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1489
|
-
}
|
|
1490
|
-
__name(escapeForRegExp, "escapeForRegExp");
|
|
1491
|
-
function modelBody(schema2, model) {
|
|
1492
|
-
const name = escapeForRegExp(model);
|
|
1493
|
-
const block = new RegExp(`(^|\\n)model\\s+${name}\\s*\\{([\\s\\S]*?)\\n\\}`, "m").exec(schema2);
|
|
1494
|
-
return block ? block[2] : null;
|
|
1495
|
-
}
|
|
1496
|
-
__name(modelBody, "modelBody");
|
|
1497
|
-
function isOneToOne(schema2, model, foreignKey) {
|
|
1498
|
-
const body = modelBody(schema2, model);
|
|
1499
|
-
if (!body) return false;
|
|
1500
|
-
return new RegExp(`^\\s*${escapeForRegExp(foreignKey)}\\s+\\S+.*@unique`, "m").test(body);
|
|
1501
|
-
}
|
|
1502
|
-
__name(isOneToOne, "isOneToOne");
|
|
1503
|
-
function hasBackRelation(schema2, model, owner, relationName, singular = false) {
|
|
1504
|
-
const body = modelBody(schema2, owner);
|
|
1505
|
-
if (body === null) return false;
|
|
1506
|
-
const name = escapeForRegExp(model);
|
|
1507
|
-
const shape = singular ? `${name}\\??` : `${name}\\[\\]`;
|
|
1508
|
-
const candidates = [
|
|
1509
|
-
...body.matchAll(new RegExp(`^\\s*\\w+\\s+${shape}(\\s.*)?$`, "gm"))
|
|
1510
|
-
];
|
|
1511
|
-
return candidates.some((line) => relationNameOf(line[0]) === relationName);
|
|
1512
|
-
}
|
|
1513
|
-
__name(hasBackRelation, "hasBackRelation");
|
|
1514
|
-
function enableFkPointers(schema2, models) {
|
|
1515
|
-
const lines = schema2.split("\n");
|
|
1516
|
-
const enabled = [];
|
|
1517
|
-
const skipped = [];
|
|
1518
|
-
const needsBackRelation = [];
|
|
1519
|
-
for (const pointer of findFkPointers(schema2)) {
|
|
1520
|
-
const model = pointer.target === "Tenant" ? models.tenant : models.user;
|
|
1521
|
-
if (!model) {
|
|
1522
|
-
skipped.push({
|
|
1523
|
-
line: pointer.line,
|
|
1524
|
-
target: pointer.target
|
|
1525
|
-
});
|
|
1526
|
-
continue;
|
|
1527
|
-
}
|
|
1528
|
-
const match = POINTER.exec(pointer.text);
|
|
1529
|
-
const relationName = relationNameOf(match[7]);
|
|
1530
|
-
const foreignKey = foreignKeyOf(match[7]);
|
|
1531
|
-
const singular = foreignKey !== null && isOneToOne(schema2, pointer.model, foreignKey);
|
|
1532
|
-
if (!hasBackRelation(schema2, pointer.model, model, relationName, singular)) {
|
|
1533
|
-
needsBackRelation.push({
|
|
1534
|
-
line: pointer.line,
|
|
1535
|
-
owner: model,
|
|
1536
|
-
suggestion: backRelationSuggestion(pointer.model, relationName, singular)
|
|
1537
|
-
});
|
|
1538
|
-
continue;
|
|
1539
|
-
}
|
|
1540
|
-
const [, indent, field, gap1, , optional, gap2, relation] = match;
|
|
1541
|
-
lines[pointer.line] = `${indent}${field}${gap1}${model}${optional}${gap2}${relation}`;
|
|
1542
|
-
enabled.push({
|
|
1543
|
-
line: pointer.line,
|
|
1544
|
-
model
|
|
1545
|
-
});
|
|
1546
|
-
}
|
|
1547
|
-
return {
|
|
1548
|
-
schema: lines.join("\n"),
|
|
1549
|
-
enabled,
|
|
1550
|
-
skipped,
|
|
1551
|
-
needsBackRelation
|
|
1552
|
-
};
|
|
1553
|
-
}
|
|
1554
|
-
__name(enableFkPointers, "enableFkPointers");
|
|
1555
|
-
var lowerFirst = /* @__PURE__ */ __name((value) => value.charAt(0).toLowerCase() + value.slice(1), "lowerFirst");
|
|
1556
|
-
function foreignKeyOf(relationAttribute) {
|
|
1557
|
-
const match = /fields:\s*\[\s*(\w+)/.exec(relationAttribute);
|
|
1558
|
-
return match ? match[1] : null;
|
|
1559
|
-
}
|
|
1560
|
-
__name(foreignKeyOf, "foreignKeyOf");
|
|
1561
|
-
function backRelationSuggestion(model, relationName, singular) {
|
|
1562
|
-
const field = singular ? lowerFirst(model) : `${lowerFirst(model)}s`;
|
|
1563
|
-
const type = singular ? `${model}?` : `${model}[]`;
|
|
1564
|
-
return `${field} ${type}` + (relationName ? ` @relation("${relationName}")` : "");
|
|
1565
|
-
}
|
|
1566
|
-
__name(backRelationSuggestion, "backRelationSuggestion");
|
|
1567
|
-
function assertModelsExist(declaredModels, models) {
|
|
1568
|
-
const missing = Object.entries(models).filter(([, name]) => name && !declaredModels.includes(name)).map(([role, name]) => `--${role}-model=${name}`);
|
|
1569
|
-
if (missing.length === 0) return;
|
|
1570
|
-
throw new Error(`${missing.join(", ")} \u2014 no such model in this schema. It declares: ${declaredModels.slice().sort().join(", ")}.`);
|
|
1571
|
-
}
|
|
1572
|
-
__name(assertModelsExist, "assertModelsExist");
|
|
1573
|
-
|
|
1574
1601
|
// src/init/catalog-keys.ts
|
|
1575
1602
|
import { planCatalogSchema } from "@saasicat/spec";
|
|
1576
1603
|
var schema = planCatalogSchema;
|
|
@@ -3653,6 +3680,7 @@ export {
|
|
|
3653
3680
|
kebabCase,
|
|
3654
3681
|
migrationCreatedBy,
|
|
3655
3682
|
minimumQuotasPerPlan,
|
|
3683
|
+
modelsKeptPastTheTenant,
|
|
3656
3684
|
namedImports,
|
|
3657
3685
|
parseBlockAttributes,
|
|
3658
3686
|
parseEnumValues,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@saasicat/cli",
|
|
3
|
-
"version": "1.0.0-rc.
|
|
3
|
+
"version": "1.0.0-rc.16",
|
|
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",
|
|
@@ -30,9 +30,9 @@
|
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"qrcode-terminal": "^0.12.0",
|
|
33
|
-
"@saasicat/nest": "^1.0.0-rc.
|
|
34
|
-
"@saasicat/spec": "^1.0.0-rc.
|
|
35
|
-
"@saasicat/core": "^1.0.0-rc.
|
|
33
|
+
"@saasicat/nest": "^1.0.0-rc.16",
|
|
34
|
+
"@saasicat/spec": "^1.0.0-rc.16",
|
|
35
|
+
"@saasicat/core": "^1.0.0-rc.16"
|
|
36
36
|
},
|
|
37
37
|
"peerDependencies": {
|
|
38
38
|
"@nestjs/common": "^11.0.0",
|