@jskit-ai/jskit-cli 0.2.69 → 0.2.71
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/jskit-cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.71",
|
|
4
4
|
"description": "Bundle and package orchestration CLI for JSKIT apps.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -20,9 +20,9 @@
|
|
|
20
20
|
"test": "node --test"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@jskit-ai/jskit-catalog": "0.1.
|
|
24
|
-
"@jskit-ai/kernel": "0.1.
|
|
25
|
-
"@jskit-ai/shell-web": "0.1.
|
|
23
|
+
"@jskit-ai/jskit-catalog": "0.1.70",
|
|
24
|
+
"@jskit-ai/kernel": "0.1.62",
|
|
25
|
+
"@jskit-ai/shell-web": "0.1.61"
|
|
26
26
|
},
|
|
27
27
|
"engines": {
|
|
28
28
|
"node": "20.x"
|
|
@@ -2,6 +2,8 @@ import {
|
|
|
2
2
|
readdir,
|
|
3
3
|
readFile
|
|
4
4
|
} from "node:fs/promises";
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
import { importFreshModuleFromAbsolutePath } from "@jskit-ai/kernel/server/support";
|
|
5
7
|
import {
|
|
6
8
|
ensureArray,
|
|
7
9
|
ensureObject,
|
|
@@ -81,12 +83,39 @@ function createHealthCommands(ctx = {}) {
|
|
|
81
83
|
"useCrudAddEdit"
|
|
82
84
|
]);
|
|
83
85
|
const FEATURE_SERVER_SCAFFOLD_SHAPE = "feature-server-v1";
|
|
86
|
+
const CRUD_SERVER_SCAFFOLD_SHAPE = "crud-server-v1";
|
|
87
|
+
const USERS_CORE_BASELINE_CRUD_SCAFFOLD_SHAPE = "users-core-crud-v1";
|
|
84
88
|
const FEATURE_SERVER_DEFAULT_LANE = "default";
|
|
85
89
|
const FEATURE_SERVER_JSON_REST_MODE = "json-rest";
|
|
86
90
|
const FEATURE_SERVER_PERSISTENT_MODES = new Set([
|
|
87
91
|
"json-rest",
|
|
88
92
|
"custom-knex"
|
|
89
93
|
]);
|
|
94
|
+
const TABLE_OWNERSHIP_RELATIVE_PATH = ".jskit/table-ownership.json";
|
|
95
|
+
const TABLE_OWNERSHIP_VERSION = 1;
|
|
96
|
+
const TABLE_OWNERSHIP_EXCEPTION_CATEGORIES = new Set([
|
|
97
|
+
"audit-log",
|
|
98
|
+
"join-table",
|
|
99
|
+
"outbox",
|
|
100
|
+
"projection-cache",
|
|
101
|
+
"workflow-state"
|
|
102
|
+
]);
|
|
103
|
+
const TABLE_OWNERSHIP_AUXILIARY_INHERITED_OWNER_EXCEPTION_CATEGORIES = new Set([
|
|
104
|
+
"audit-log",
|
|
105
|
+
"join-table",
|
|
106
|
+
"outbox",
|
|
107
|
+
"projection-cache"
|
|
108
|
+
]);
|
|
109
|
+
const KNEX_SYSTEM_TABLE_NAMES = new Set([
|
|
110
|
+
"knex_migrations",
|
|
111
|
+
"knex_migrations_lock"
|
|
112
|
+
]);
|
|
113
|
+
const DIRECT_OWNER_COLUMNS = Object.freeze({
|
|
114
|
+
user: "user_id",
|
|
115
|
+
workspace: "workspace_id"
|
|
116
|
+
});
|
|
117
|
+
const DIRECT_OWNER_KINDS = Object.freeze(Object.keys(DIRECT_OWNER_COLUMNS));
|
|
118
|
+
const CRUD_OWNERSHIP_FILTER_LITERAL_PATTERN = /\bownershipFilter\s*:\s*"([A-Za-z_]+)"/u;
|
|
90
119
|
const FEATURE_SERVER_COMPLEX_MARKER_RELATIVE_PATHS = Object.freeze([
|
|
91
120
|
"src/server/inputSchemas.js",
|
|
92
121
|
"src/server/actions.js",
|
|
@@ -108,6 +137,13 @@ function createHealthCommands(ctx = {}) {
|
|
|
108
137
|
]);
|
|
109
138
|
const MAIN_SERVER_DOMAIN_FILE_PATTERN =
|
|
110
139
|
/^src\/server\/(?!(?:index|MainServiceProvider|loadAppConfig)\.[A-Za-z0-9]+$).+/u;
|
|
140
|
+
const APP_LOCAL_DIRECT_KNEX_PATTERN_ENTRIES = Object.freeze([
|
|
141
|
+
{ pattern: /\bjskit\.database\.knex\b/u, label: "jskit.database.knex" },
|
|
142
|
+
{ pattern: /\bcreateWithTransaction\s*\(/u, label: "createWithTransaction(...)" },
|
|
143
|
+
{ pattern: /\bknex\s*\(/u, label: "knex(...)" },
|
|
144
|
+
{ pattern: /\bknex\./u, label: "knex." },
|
|
145
|
+
{ pattern: /from\s+["']knex["']/u, label: 'import "knex"' }
|
|
146
|
+
]);
|
|
111
147
|
|
|
112
148
|
function collectDescriptorContainerTokens({ packageId, side, values, issues }) {
|
|
113
149
|
const declaredTokens = new Set();
|
|
@@ -545,6 +581,516 @@ function createHealthCommands(ctx = {}) {
|
|
|
545
581
|
};
|
|
546
582
|
}
|
|
547
583
|
|
|
584
|
+
function normalizeJskitMetadata(descriptor = {}) {
|
|
585
|
+
return ensureObject(ensureObject(ensureObject(descriptor).metadata).jskit);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function normalizeOwnedTableEntries(packageEntry) {
|
|
589
|
+
const packageId = String(packageEntry?.packageId || "").trim();
|
|
590
|
+
const packagePath = resolvePackageDisplayPath(packageEntry);
|
|
591
|
+
const metadata = normalizeJskitMetadata(packageEntry?.descriptor);
|
|
592
|
+
const tableOwnership = ensureObject(metadata.tableOwnership);
|
|
593
|
+
const normalized = [];
|
|
594
|
+
|
|
595
|
+
for (const rawEntry of ensureArray(tableOwnership.tables)) {
|
|
596
|
+
const entry = ensureObject(rawEntry);
|
|
597
|
+
const tableName = String(entry.tableName || "").trim().toLowerCase();
|
|
598
|
+
if (!tableName) {
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
normalized.push({
|
|
602
|
+
packageId,
|
|
603
|
+
packagePath,
|
|
604
|
+
tableName,
|
|
605
|
+
provenance: String(entry.provenance || "").trim().toLowerCase(),
|
|
606
|
+
ownerKind: String(entry.ownerKind || "").trim().toLowerCase()
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
return normalized;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function packageAllowsDirectKnexUsage(packageEntry) {
|
|
614
|
+
const featureMetadata = normalizeFeatureLaneMetadata(packageEntry?.descriptor);
|
|
615
|
+
if (
|
|
616
|
+
featureMetadata.scaffoldShape === FEATURE_SERVER_SCAFFOLD_SHAPE &&
|
|
617
|
+
featureMetadata.scaffoldMode === "custom-knex" &&
|
|
618
|
+
featureMetadata.lane === "weird-custom"
|
|
619
|
+
) {
|
|
620
|
+
return true;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
const jskitMetadata = normalizeJskitMetadata(packageEntry?.descriptor);
|
|
624
|
+
const scaffoldShape = String(jskitMetadata.scaffoldShape || "").trim();
|
|
625
|
+
if (
|
|
626
|
+
scaffoldShape === CRUD_SERVER_SCAFFOLD_SHAPE ||
|
|
627
|
+
scaffoldShape === USERS_CORE_BASELINE_CRUD_SCAFFOLD_SHAPE
|
|
628
|
+
) {
|
|
629
|
+
return true;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
return normalizeOwnedTableEntries(packageEntry).some((entry) =>
|
|
633
|
+
entry.provenance === "crud-server-generator" || entry.provenance === "users-core-template"
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function packageRequiresCrudOwnershipProvenance(packageEntry) {
|
|
638
|
+
const descriptor = ensureObject(packageEntry?.descriptor);
|
|
639
|
+
const providedCapabilities = ensureArray(ensureObject(descriptor.capabilities).provides)
|
|
640
|
+
.map((value) => String(value || "").trim())
|
|
641
|
+
.filter(Boolean);
|
|
642
|
+
return providedCapabilities.some((value) => value.startsWith("crud."));
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function normalizeTableOwnershipExceptionEntries(rawValue = {}) {
|
|
646
|
+
const source = ensureObject(rawValue);
|
|
647
|
+
const exceptions = [];
|
|
648
|
+
for (const rawEntry of ensureArray(source.exceptions)) {
|
|
649
|
+
const entry = ensureObject(rawEntry);
|
|
650
|
+
const tableName = String(entry.tableName || "").trim().toLowerCase();
|
|
651
|
+
const category = String(entry.category || "").trim().toLowerCase();
|
|
652
|
+
const owner = String(entry.owner || "").trim();
|
|
653
|
+
const reason = String(entry.reason || "").trim();
|
|
654
|
+
exceptions.push({
|
|
655
|
+
tableName,
|
|
656
|
+
category,
|
|
657
|
+
owner,
|
|
658
|
+
reason
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
return {
|
|
662
|
+
version: Number(source.version || 0),
|
|
663
|
+
exceptions
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
async function loadTableOwnershipExceptionConfig({ appRoot, issues }) {
|
|
668
|
+
const absolutePath = path.join(appRoot, TABLE_OWNERSHIP_RELATIVE_PATH);
|
|
669
|
+
if (!(await fileExists(absolutePath))) {
|
|
670
|
+
return {
|
|
671
|
+
exists: false,
|
|
672
|
+
exceptions: []
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
let parsed = null;
|
|
677
|
+
try {
|
|
678
|
+
parsed = JSON.parse(await readFile(absolutePath, "utf8"));
|
|
679
|
+
} catch (error) {
|
|
680
|
+
issues.push(
|
|
681
|
+
`[table-ownership:invalid-exception-file] ${TABLE_OWNERSHIP_RELATIVE_PATH} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`
|
|
682
|
+
);
|
|
683
|
+
return {
|
|
684
|
+
exists: true,
|
|
685
|
+
exceptions: []
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const config = normalizeTableOwnershipExceptionEntries(parsed);
|
|
690
|
+
if (config.version !== TABLE_OWNERSHIP_VERSION) {
|
|
691
|
+
issues.push(
|
|
692
|
+
`[table-ownership:invalid-exception-file] ${TABLE_OWNERSHIP_RELATIVE_PATH} must declare version ${TABLE_OWNERSHIP_VERSION}.`
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
const seenTables = new Set();
|
|
697
|
+
for (const entry of config.exceptions) {
|
|
698
|
+
if (!entry.tableName) {
|
|
699
|
+
issues.push(
|
|
700
|
+
`[table-ownership:invalid-exception-file] ${TABLE_OWNERSHIP_RELATIVE_PATH} contains an exception without tableName.`
|
|
701
|
+
);
|
|
702
|
+
continue;
|
|
703
|
+
}
|
|
704
|
+
if (!TABLE_OWNERSHIP_EXCEPTION_CATEGORIES.has(entry.category)) {
|
|
705
|
+
issues.push(
|
|
706
|
+
`[table-ownership:invalid-exception-file] ${TABLE_OWNERSHIP_RELATIVE_PATH} table "${entry.tableName}" must use one of: ${sortStrings([...TABLE_OWNERSHIP_EXCEPTION_CATEGORIES]).join(", ")}.`
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
if (!entry.owner) {
|
|
710
|
+
issues.push(
|
|
711
|
+
`[table-ownership:invalid-exception-file] ${TABLE_OWNERSHIP_RELATIVE_PATH} table "${entry.tableName}" must include owner.`
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
if (!entry.reason) {
|
|
715
|
+
issues.push(
|
|
716
|
+
`[table-ownership:invalid-exception-file] ${TABLE_OWNERSHIP_RELATIVE_PATH} table "${entry.tableName}" must include reason.`
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
if (seenTables.has(entry.tableName)) {
|
|
720
|
+
issues.push(
|
|
721
|
+
`[table-ownership:invalid-exception-file] ${TABLE_OWNERSHIP_RELATIVE_PATH} declares table "${entry.tableName}" more than once.`
|
|
722
|
+
);
|
|
723
|
+
continue;
|
|
724
|
+
}
|
|
725
|
+
seenTables.add(entry.tableName);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
return {
|
|
729
|
+
exists: true,
|
|
730
|
+
exceptions: config.exceptions
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function normalizeKnexRawRows(rawResult = null) {
|
|
735
|
+
if (Array.isArray(rawResult)) {
|
|
736
|
+
if (rawResult.length > 0 && Array.isArray(rawResult[0])) {
|
|
737
|
+
return rawResult[0];
|
|
738
|
+
}
|
|
739
|
+
return rawResult;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
if (Array.isArray(rawResult?.rows)) {
|
|
743
|
+
return rawResult.rows;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
return [];
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function normalizeDbIdentifier(value = "") {
|
|
750
|
+
return String(value || "").trim().toLowerCase();
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function setMapValue(map, key, defaultValueFactory) {
|
|
754
|
+
if (!map.has(key)) {
|
|
755
|
+
map.set(key, defaultValueFactory());
|
|
756
|
+
}
|
|
757
|
+
return map.get(key);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
async function loadAppKnexConfig(appRoot) {
|
|
761
|
+
const knexfilePath = path.join(appRoot, "knexfile.js");
|
|
762
|
+
if (!(await fileExists(knexfilePath))) {
|
|
763
|
+
return null;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const moduleNamespace = await importFreshModuleFromAbsolutePath(knexfilePath);
|
|
767
|
+
const exported =
|
|
768
|
+
moduleNamespace?.default ??
|
|
769
|
+
moduleNamespace?.config ??
|
|
770
|
+
moduleNamespace;
|
|
771
|
+
const config =
|
|
772
|
+
typeof exported === "function"
|
|
773
|
+
? await exported()
|
|
774
|
+
: ensureObject(exported);
|
|
775
|
+
|
|
776
|
+
return {
|
|
777
|
+
config: ensureObject(config),
|
|
778
|
+
path: knexfilePath
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function loadAppKnexFactory(appRoot) {
|
|
783
|
+
const requireFromApp = createRequire(path.join(appRoot, "package.json"));
|
|
784
|
+
const moduleValue = requireFromApp("knex");
|
|
785
|
+
const knexFactory =
|
|
786
|
+
typeof moduleValue === "function"
|
|
787
|
+
? moduleValue
|
|
788
|
+
: typeof moduleValue?.default === "function"
|
|
789
|
+
? moduleValue.default
|
|
790
|
+
: null;
|
|
791
|
+
if (!knexFactory) {
|
|
792
|
+
throw new Error("App-local knex package resolved but did not expose a callable factory.");
|
|
793
|
+
}
|
|
794
|
+
return knexFactory;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
async function resolveLiveDatabaseSchema(appRoot) {
|
|
798
|
+
const loadedKnexConfig = await loadAppKnexConfig(appRoot);
|
|
799
|
+
if (!loadedKnexConfig) {
|
|
800
|
+
return {
|
|
801
|
+
applicable: false,
|
|
802
|
+
tableNames: [],
|
|
803
|
+
columnsByTable: new Map(),
|
|
804
|
+
foreignKeysByTable: new Map()
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
const knexFactory = loadAppKnexFactory(appRoot);
|
|
809
|
+
const knex = knexFactory(loadedKnexConfig.config);
|
|
810
|
+
|
|
811
|
+
try {
|
|
812
|
+
const clientId = String(loadedKnexConfig.config.client || "").trim().toLowerCase();
|
|
813
|
+
let tableRows = [];
|
|
814
|
+
let columnRows = [];
|
|
815
|
+
let foreignKeyRows = [];
|
|
816
|
+
if (clientId.startsWith("mysql")) {
|
|
817
|
+
tableRows = normalizeKnexRawRows(await knex.raw(
|
|
818
|
+
"SELECT TABLE_NAME AS tableName FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_TYPE = 'BASE TABLE' ORDER BY TABLE_NAME"
|
|
819
|
+
));
|
|
820
|
+
columnRows = normalizeKnexRawRows(await knex.raw(
|
|
821
|
+
"SELECT TABLE_NAME AS tableName, COLUMN_NAME AS columnName FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() ORDER BY TABLE_NAME, ORDINAL_POSITION"
|
|
822
|
+
));
|
|
823
|
+
foreignKeyRows = normalizeKnexRawRows(await knex.raw(
|
|
824
|
+
"SELECT TABLE_NAME AS tableName, COLUMN_NAME AS columnName, REFERENCED_TABLE_NAME AS referencedTableName, REFERENCED_COLUMN_NAME AS referencedColumnName FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = DATABASE() AND REFERENCED_TABLE_NAME IS NOT NULL ORDER BY TABLE_NAME, CONSTRAINT_NAME, ORDINAL_POSITION"
|
|
825
|
+
));
|
|
826
|
+
} else if (clientId === "pg" || clientId.startsWith("postgres")) {
|
|
827
|
+
tableRows = normalizeKnexRawRows(await knex.raw(
|
|
828
|
+
'SELECT tablename AS "tableName" FROM pg_tables WHERE schemaname = current_schema() ORDER BY tablename'
|
|
829
|
+
));
|
|
830
|
+
columnRows = normalizeKnexRawRows(await knex.raw(
|
|
831
|
+
'SELECT table_name AS "tableName", column_name AS "columnName" FROM information_schema.columns WHERE table_schema = current_schema() ORDER BY table_name, ordinal_position'
|
|
832
|
+
));
|
|
833
|
+
foreignKeyRows = normalizeKnexRawRows(await knex.raw(
|
|
834
|
+
'SELECT kcu.table_name AS "tableName", kcu.column_name AS "columnName", ccu.table_name AS "referencedTableName", ccu.column_name AS "referencedColumnName" FROM information_schema.table_constraints tc JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema JOIN information_schema.constraint_column_usage ccu ON ccu.constraint_name = tc.constraint_name AND ccu.table_schema = tc.table_schema WHERE tc.constraint_type = \'FOREIGN KEY\' AND tc.table_schema = current_schema() ORDER BY kcu.table_name, kcu.constraint_name, kcu.ordinal_position'
|
|
835
|
+
));
|
|
836
|
+
} else {
|
|
837
|
+
throw new Error(`Unsupported knex client for doctor table ownership audit: ${clientId || "<empty>"}.`);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
const tableNames = sortStrings(
|
|
841
|
+
tableRows
|
|
842
|
+
.map((row) => normalizeDbIdentifier(
|
|
843
|
+
ensureObject(row).tableName || ensureObject(row).TABLE_NAME || ensureObject(row).tablename
|
|
844
|
+
))
|
|
845
|
+
.filter(Boolean)
|
|
846
|
+
.filter((tableName) => !KNEX_SYSTEM_TABLE_NAMES.has(tableName))
|
|
847
|
+
);
|
|
848
|
+
const liveTableNameSet = new Set(tableNames);
|
|
849
|
+
const columnsByTable = new Map();
|
|
850
|
+
const foreignKeysByTable = new Map();
|
|
851
|
+
|
|
852
|
+
for (const tableName of tableNames) {
|
|
853
|
+
columnsByTable.set(tableName, new Set());
|
|
854
|
+
foreignKeysByTable.set(tableName, []);
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
for (const rawRow of columnRows) {
|
|
858
|
+
const row = ensureObject(rawRow);
|
|
859
|
+
const tableName = normalizeDbIdentifier(row.tableName || row.TABLE_NAME || row.tablename);
|
|
860
|
+
const columnName = normalizeDbIdentifier(row.columnName || row.COLUMN_NAME || row.columnname);
|
|
861
|
+
if (!tableName || !columnName || !liveTableNameSet.has(tableName)) {
|
|
862
|
+
continue;
|
|
863
|
+
}
|
|
864
|
+
setMapValue(columnsByTable, tableName, () => new Set()).add(columnName);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
for (const rawRow of foreignKeyRows) {
|
|
868
|
+
const row = ensureObject(rawRow);
|
|
869
|
+
const tableName = normalizeDbIdentifier(row.tableName || row.TABLE_NAME || row.tablename);
|
|
870
|
+
const columnName = normalizeDbIdentifier(row.columnName || row.COLUMN_NAME || row.columnname);
|
|
871
|
+
const referencedTableName = normalizeDbIdentifier(
|
|
872
|
+
row.referencedTableName || row.REFERENCED_TABLE_NAME || row.referencedtablename
|
|
873
|
+
);
|
|
874
|
+
const referencedColumnName = normalizeDbIdentifier(
|
|
875
|
+
row.referencedColumnName || row.REFERENCED_COLUMN_NAME || row.referencedcolumnname
|
|
876
|
+
);
|
|
877
|
+
if (!tableName || !referencedTableName || !liveTableNameSet.has(tableName)) {
|
|
878
|
+
continue;
|
|
879
|
+
}
|
|
880
|
+
setMapValue(foreignKeysByTable, tableName, () => []).push({
|
|
881
|
+
columnName,
|
|
882
|
+
referencedTableName,
|
|
883
|
+
referencedColumnName
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
return {
|
|
888
|
+
applicable: true,
|
|
889
|
+
tableNames,
|
|
890
|
+
columnsByTable,
|
|
891
|
+
foreignKeysByTable
|
|
892
|
+
};
|
|
893
|
+
} finally {
|
|
894
|
+
if (knex && typeof knex.destroy === "function") {
|
|
895
|
+
await knex.destroy();
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function collectInstalledOwnedTables({ installedPackageIds = [], packageRegistry, issues }) {
|
|
901
|
+
const ownersByTable = new Map();
|
|
902
|
+
|
|
903
|
+
for (const packageId of ensureArray(installedPackageIds)) {
|
|
904
|
+
const packageEntry = packageRegistry.get(packageId) || null;
|
|
905
|
+
if (!packageEntry) {
|
|
906
|
+
continue;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
for (const ownershipEntry of normalizeOwnedTableEntries(packageEntry)) {
|
|
910
|
+
const existing = ownersByTable.get(ownershipEntry.tableName);
|
|
911
|
+
if (existing) {
|
|
912
|
+
issues.push(
|
|
913
|
+
`[table-ownership:duplicate-owner] table "${ownershipEntry.tableName}" is claimed by both ${existing.packagePath} and ${ownershipEntry.packagePath}.`
|
|
914
|
+
);
|
|
915
|
+
continue;
|
|
916
|
+
}
|
|
917
|
+
ownersByTable.set(ownershipEntry.tableName, ownershipEntry);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
return ownersByTable;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
function normalizeDirectOwnerKinds(columnNames = new Set()) {
|
|
925
|
+
const normalizedColumns = columnNames instanceof Set ? columnNames : new Set();
|
|
926
|
+
const ownerKinds = new Set();
|
|
927
|
+
for (const ownerKind of DIRECT_OWNER_KINDS) {
|
|
928
|
+
if (normalizedColumns.has(DIRECT_OWNER_COLUMNS[ownerKind])) {
|
|
929
|
+
ownerKinds.add(ownerKind);
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
return ownerKinds;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
function resolveRequiredOwnerKindsFromOwnershipFilter(ownershipFilter = "") {
|
|
936
|
+
const normalizedFilter = normalizeDbIdentifier(ownershipFilter);
|
|
937
|
+
if (normalizedFilter === "user") {
|
|
938
|
+
return new Set(["user"]);
|
|
939
|
+
}
|
|
940
|
+
if (normalizedFilter === "workspace") {
|
|
941
|
+
return new Set(["workspace"]);
|
|
942
|
+
}
|
|
943
|
+
if (normalizedFilter === "workspace_user") {
|
|
944
|
+
return new Set(["workspace", "user"]);
|
|
945
|
+
}
|
|
946
|
+
return new Set();
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
function subtractStringSet(source = new Set(), valuesToSubtract = new Set()) {
|
|
950
|
+
const result = new Set();
|
|
951
|
+
const sourceSet = source instanceof Set ? source : new Set();
|
|
952
|
+
const subtractSet = valuesToSubtract instanceof Set ? valuesToSubtract : new Set();
|
|
953
|
+
for (const value of sourceSet) {
|
|
954
|
+
if (!subtractSet.has(value)) {
|
|
955
|
+
result.add(value);
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
return result;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
function formatOwnerColumns(ownerKinds = new Set()) {
|
|
962
|
+
const normalizedKinds = ownerKinds instanceof Set ? ownerKinds : new Set();
|
|
963
|
+
return sortStrings(
|
|
964
|
+
[...normalizedKinds]
|
|
965
|
+
.map((ownerKind) => DIRECT_OWNER_COLUMNS[ownerKind] || "")
|
|
966
|
+
.filter(Boolean)
|
|
967
|
+
)
|
|
968
|
+
.map((columnName) => `"${columnName}"`)
|
|
969
|
+
.join(", ");
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
async function resolveAppLocalCrudOwnershipFilters({ appRoot, appLocalRegistry }) {
|
|
973
|
+
const ownershipByTable = new Map();
|
|
974
|
+
const packageEntries = sortStrings([...appLocalRegistry.keys()])
|
|
975
|
+
.map((packageId) => appLocalRegistry.get(packageId))
|
|
976
|
+
.filter(Boolean);
|
|
977
|
+
|
|
978
|
+
for (const packageEntry of packageEntries) {
|
|
979
|
+
if (!packageRequiresCrudOwnershipProvenance(packageEntry)) {
|
|
980
|
+
continue;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
const providerPath = resolvePrimaryServerProviderPath(packageEntry);
|
|
984
|
+
if (!providerPath || !(await fileExists(providerPath))) {
|
|
985
|
+
continue;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
const sourceText = await readFile(providerPath, "utf8");
|
|
989
|
+
const match = CRUD_OWNERSHIP_FILTER_LITERAL_PATTERN.exec(sourceText);
|
|
990
|
+
if (!match) {
|
|
991
|
+
continue;
|
|
992
|
+
}
|
|
993
|
+
const ownershipFilter = normalizeDbIdentifier(match[1]);
|
|
994
|
+
const requiredOwnerKinds = resolveRequiredOwnerKindsFromOwnershipFilter(ownershipFilter);
|
|
995
|
+
|
|
996
|
+
for (const ownershipEntry of normalizeOwnedTableEntries(packageEntry)) {
|
|
997
|
+
ownershipByTable.set(ownershipEntry.tableName, {
|
|
998
|
+
tableName: ownershipEntry.tableName,
|
|
999
|
+
ownershipFilter,
|
|
1000
|
+
requiredOwnerKinds,
|
|
1001
|
+
packagePath: resolvePackageDisplayPath(packageEntry),
|
|
1002
|
+
providerPath: normalizeRelativePath(appRoot, providerPath)
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
return ownershipByTable;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
function resolveReachableOwnerKinds(tableName, {
|
|
1011
|
+
directOwnerKindsByTable,
|
|
1012
|
+
foreignKeysByTable,
|
|
1013
|
+
memo = new Map(),
|
|
1014
|
+
visiting = new Set()
|
|
1015
|
+
} = {}) {
|
|
1016
|
+
const normalizedTableName = normalizeDbIdentifier(tableName);
|
|
1017
|
+
if (!normalizedTableName) {
|
|
1018
|
+
return new Set();
|
|
1019
|
+
}
|
|
1020
|
+
if (memo.has(normalizedTableName)) {
|
|
1021
|
+
return memo.get(normalizedTableName);
|
|
1022
|
+
}
|
|
1023
|
+
if (visiting.has(normalizedTableName)) {
|
|
1024
|
+
return new Set();
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
visiting.add(normalizedTableName);
|
|
1028
|
+
const resolved = new Set(directOwnerKindsByTable.get(normalizedTableName) || []);
|
|
1029
|
+
for (const foreignKey of ensureArray(foreignKeysByTable.get(normalizedTableName))) {
|
|
1030
|
+
const parentTableName = normalizeDbIdentifier(foreignKey?.referencedTableName);
|
|
1031
|
+
if (!parentTableName) {
|
|
1032
|
+
continue;
|
|
1033
|
+
}
|
|
1034
|
+
for (const ownerKind of resolveReachableOwnerKinds(parentTableName, {
|
|
1035
|
+
directOwnerKindsByTable,
|
|
1036
|
+
foreignKeysByTable,
|
|
1037
|
+
memo,
|
|
1038
|
+
visiting
|
|
1039
|
+
})) {
|
|
1040
|
+
resolved.add(ownerKind);
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
visiting.delete(normalizedTableName);
|
|
1044
|
+
memo.set(normalizedTableName, resolved);
|
|
1045
|
+
return resolved;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
function findInheritedOwnerChain(tableName, ownerKind, {
|
|
1049
|
+
directOwnerKindsByTable,
|
|
1050
|
+
foreignKeysByTable
|
|
1051
|
+
} = {}) {
|
|
1052
|
+
const normalizedTableName = normalizeDbIdentifier(tableName);
|
|
1053
|
+
const normalizedOwnerKind = normalizeDbIdentifier(ownerKind);
|
|
1054
|
+
if (!normalizedTableName || !normalizedOwnerKind) {
|
|
1055
|
+
return [];
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
const visited = new Set([normalizedTableName]);
|
|
1059
|
+
const queue = [{
|
|
1060
|
+
tableName: normalizedTableName,
|
|
1061
|
+
path: [normalizedTableName]
|
|
1062
|
+
}];
|
|
1063
|
+
|
|
1064
|
+
while (queue.length > 0) {
|
|
1065
|
+
const current = queue.shift();
|
|
1066
|
+
const currentTableName = normalizeDbIdentifier(current?.tableName);
|
|
1067
|
+
if (!currentTableName) {
|
|
1068
|
+
continue;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
for (const foreignKey of ensureArray(foreignKeysByTable.get(currentTableName))) {
|
|
1072
|
+
const parentTableName = normalizeDbIdentifier(foreignKey?.referencedTableName);
|
|
1073
|
+
if (!parentTableName || visited.has(parentTableName)) {
|
|
1074
|
+
continue;
|
|
1075
|
+
}
|
|
1076
|
+
const nextPath = [
|
|
1077
|
+
...ensureArray(current?.path),
|
|
1078
|
+
parentTableName
|
|
1079
|
+
];
|
|
1080
|
+
if ((directOwnerKindsByTable.get(parentTableName) || new Set()).has(normalizedOwnerKind)) {
|
|
1081
|
+
return nextPath;
|
|
1082
|
+
}
|
|
1083
|
+
visited.add(parentTableName);
|
|
1084
|
+
queue.push({
|
|
1085
|
+
tableName: parentTableName,
|
|
1086
|
+
path: nextPath
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
return [];
|
|
1092
|
+
}
|
|
1093
|
+
|
|
548
1094
|
function isFeatureLanePersistenceImportSource(sourcePath = "") {
|
|
549
1095
|
const normalizedSourcePath = String(sourcePath || "").trim();
|
|
550
1096
|
if (!normalizedSourcePath) {
|
|
@@ -843,6 +1389,229 @@ function createHealthCommands(ctx = {}) {
|
|
|
843
1389
|
});
|
|
844
1390
|
}
|
|
845
1391
|
|
|
1392
|
+
async function collectCrudOwnershipMetadataIssues({ appLocalRegistry, issues }) {
|
|
1393
|
+
const packageEntries = sortStrings([...appLocalRegistry.keys()])
|
|
1394
|
+
.map((packageId) => appLocalRegistry.get(packageId))
|
|
1395
|
+
.filter(Boolean);
|
|
1396
|
+
|
|
1397
|
+
for (const packageEntry of packageEntries) {
|
|
1398
|
+
if (!packageRequiresCrudOwnershipProvenance(packageEntry)) {
|
|
1399
|
+
continue;
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
const packagePath = resolvePackageDisplayPath(packageEntry);
|
|
1403
|
+
const metadata = normalizeJskitMetadata(packageEntry?.descriptor);
|
|
1404
|
+
const scaffoldShape = String(metadata.scaffoldShape || "").trim();
|
|
1405
|
+
const ownedTables = normalizeOwnedTableEntries(packageEntry);
|
|
1406
|
+
if (ownedTables.length < 1) {
|
|
1407
|
+
issues.push(
|
|
1408
|
+
`${packagePath}: [crud-ownership:missing-metadata] CRUD package is missing metadata.jskit.tableOwnership.tables. App-owned CRUD tables must be claimed by generator/baseline ownership metadata so doctor can audit live DB tables.`
|
|
1409
|
+
);
|
|
1410
|
+
continue;
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
const allowedProvenances = String(packageEntry?.packageId || "").trim() === "@local/users"
|
|
1414
|
+
? new Set(["crud-server-generator", "users-core-template"])
|
|
1415
|
+
: new Set(["crud-server-generator"]);
|
|
1416
|
+
if (
|
|
1417
|
+
scaffoldShape &&
|
|
1418
|
+
scaffoldShape !== CRUD_SERVER_SCAFFOLD_SHAPE &&
|
|
1419
|
+
scaffoldShape !== USERS_CORE_BASELINE_CRUD_SCAFFOLD_SHAPE
|
|
1420
|
+
) {
|
|
1421
|
+
issues.push(
|
|
1422
|
+
`${packagePath}: [crud-ownership:unsupported-shape] CRUD package declares unsupported metadata.jskit.scaffoldShape="${scaffoldShape}". Use crud-server-generator for app-owned CRUDs, or the JSKIT baseline users scaffold where applicable.`
|
|
1423
|
+
);
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
for (const ownedTable of ownedTables) {
|
|
1427
|
+
if (!allowedProvenances.has(ownedTable.provenance)) {
|
|
1428
|
+
issues.push(
|
|
1429
|
+
`${packagePath}: [crud-ownership:unsupported-provenance] table "${ownedTable.tableName}" is claimed with provenance "${ownedTable.provenance || "<empty>"}". App-owned CRUD tables must come from crud-server-generator (or users-core-template for @local/users).`
|
|
1430
|
+
);
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
async function collectAppLocalDirectKnexIssues({ appRoot, appLocalRegistry, issues }) {
|
|
1437
|
+
const packageEntries = sortStrings([...appLocalRegistry.keys()])
|
|
1438
|
+
.map((packageId) => appLocalRegistry.get(packageId))
|
|
1439
|
+
.filter(Boolean);
|
|
1440
|
+
|
|
1441
|
+
for (const packageEntry of packageEntries) {
|
|
1442
|
+
const featureMetadata = normalizeFeatureLaneMetadata(packageEntry?.descriptor);
|
|
1443
|
+
if (featureMetadata.scaffoldShape === FEATURE_SERVER_SCAFFOLD_SHAPE) {
|
|
1444
|
+
continue;
|
|
1445
|
+
}
|
|
1446
|
+
if (packageAllowsDirectKnexUsage(packageEntry)) {
|
|
1447
|
+
continue;
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
const rootDir = String(packageEntry?.rootDir || "").trim();
|
|
1451
|
+
if (!rootDir) {
|
|
1452
|
+
continue;
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
const serverFilePaths = [];
|
|
1456
|
+
await collectAppSourceFiles(path.join(rootDir, "src", "server"), undefined, serverFilePaths);
|
|
1457
|
+
serverFilePaths.sort((left, right) => left.localeCompare(right));
|
|
1458
|
+
|
|
1459
|
+
for (const absolutePath of serverFilePaths) {
|
|
1460
|
+
const sourceText = await readFile(absolutePath, "utf8");
|
|
1461
|
+
const match = findFirstPatternMatch(sourceText, APP_LOCAL_DIRECT_KNEX_PATTERN_ENTRIES);
|
|
1462
|
+
if (!match) {
|
|
1463
|
+
continue;
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
const relativePath = normalizeRelativePath(appRoot, absolutePath);
|
|
1467
|
+
issues.push(
|
|
1468
|
+
`${relativePath}:${match.lineNumber}: [persistence-lane:direct-knex] app-owned runtime code must stay on generated CRUD or internal json-rest-api by default. Direct knex is only allowed in explicit weird-custom feature-server lanes and approved baseline CRUD packages.`
|
|
1469
|
+
);
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
async function collectTableOwnershipDoctorIssues({
|
|
1475
|
+
appRoot,
|
|
1476
|
+
installedPackageIds,
|
|
1477
|
+
packageRegistry,
|
|
1478
|
+
appLocalRegistry,
|
|
1479
|
+
issues
|
|
1480
|
+
}) {
|
|
1481
|
+
await collectCrudOwnershipMetadataIssues({
|
|
1482
|
+
appLocalRegistry,
|
|
1483
|
+
issues
|
|
1484
|
+
});
|
|
1485
|
+
await collectAppLocalDirectKnexIssues({
|
|
1486
|
+
appRoot,
|
|
1487
|
+
appLocalRegistry,
|
|
1488
|
+
issues
|
|
1489
|
+
});
|
|
1490
|
+
|
|
1491
|
+
const exceptionConfig = await loadTableOwnershipExceptionConfig({
|
|
1492
|
+
appRoot,
|
|
1493
|
+
issues
|
|
1494
|
+
});
|
|
1495
|
+
|
|
1496
|
+
let liveSchema = null;
|
|
1497
|
+
try {
|
|
1498
|
+
const resolved = await resolveLiveDatabaseSchema(appRoot);
|
|
1499
|
+
if (!resolved.applicable) {
|
|
1500
|
+
return;
|
|
1501
|
+
}
|
|
1502
|
+
liveSchema = resolved;
|
|
1503
|
+
} catch (error) {
|
|
1504
|
+
issues.push(
|
|
1505
|
+
`[table-ownership:db-introspection-unavailable] doctor could not inspect live database tables via knexfile.js: ${error instanceof Error ? error.message : String(error)}`
|
|
1506
|
+
);
|
|
1507
|
+
return;
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
const liveTables = ensureArray(liveSchema?.tableNames);
|
|
1511
|
+
const columnsByTable = liveSchema?.columnsByTable instanceof Map ? liveSchema.columnsByTable : new Map();
|
|
1512
|
+
const foreignKeysByTable = liveSchema?.foreignKeysByTable instanceof Map ? liveSchema.foreignKeysByTable : new Map();
|
|
1513
|
+
const ownedTablesByName = collectInstalledOwnedTables({
|
|
1514
|
+
installedPackageIds,
|
|
1515
|
+
packageRegistry,
|
|
1516
|
+
issues
|
|
1517
|
+
});
|
|
1518
|
+
const crudOwnershipByTable = await resolveAppLocalCrudOwnershipFilters({
|
|
1519
|
+
appRoot,
|
|
1520
|
+
appLocalRegistry
|
|
1521
|
+
});
|
|
1522
|
+
const exceptionEntriesByName = new Map();
|
|
1523
|
+
for (const entry of exceptionConfig.exceptions) {
|
|
1524
|
+
if (entry.tableName) {
|
|
1525
|
+
exceptionEntriesByName.set(entry.tableName, entry);
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
const directOwnerKindsByTable = new Map(
|
|
1529
|
+
liveTables.map((tableName) => [
|
|
1530
|
+
tableName,
|
|
1531
|
+
normalizeDirectOwnerKinds(columnsByTable.get(tableName) || new Set())
|
|
1532
|
+
])
|
|
1533
|
+
);
|
|
1534
|
+
const reachableOwnerKindsMemo = new Map();
|
|
1535
|
+
|
|
1536
|
+
for (const tableName of liveTables) {
|
|
1537
|
+
if (ownedTablesByName.has(tableName)) {
|
|
1538
|
+
if (exceptionEntriesByName.has(tableName)) {
|
|
1539
|
+
issues.push(
|
|
1540
|
+
`[table-ownership:duplicate-exception] table "${tableName}" is both package-owned and listed in ${TABLE_OWNERSHIP_RELATIVE_PATH}. Remove the exception entry.`
|
|
1541
|
+
);
|
|
1542
|
+
}
|
|
1543
|
+
continue;
|
|
1544
|
+
}
|
|
1545
|
+
if (exceptionEntriesByName.has(tableName)) {
|
|
1546
|
+
continue;
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
issues.push(
|
|
1550
|
+
`[table-ownership:missing-owner] live database table "${tableName}" has no declared owner. Create a server CRUD with jskit generate crud-server-generator scaffold ..., or add a narrow explicit exception in ${TABLE_OWNERSHIP_RELATIVE_PATH}.`
|
|
1551
|
+
);
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
for (const tableName of liveTables) {
|
|
1555
|
+
if (!ownedTablesByName.has(tableName) && !exceptionEntriesByName.has(tableName)) {
|
|
1556
|
+
continue;
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
const directOwnerKinds = directOwnerKindsByTable.get(tableName) || new Set();
|
|
1560
|
+
const reachableOwnerKinds = resolveReachableOwnerKinds(tableName, {
|
|
1561
|
+
directOwnerKindsByTable,
|
|
1562
|
+
foreignKeysByTable,
|
|
1563
|
+
memo: reachableOwnerKindsMemo
|
|
1564
|
+
});
|
|
1565
|
+
const inheritedOwnerKinds = subtractStringSet(reachableOwnerKinds, directOwnerKinds);
|
|
1566
|
+
const exceptionEntry = exceptionEntriesByName.get(tableName) || null;
|
|
1567
|
+
const allowsAuxiliaryInheritedOwnership =
|
|
1568
|
+
exceptionEntry &&
|
|
1569
|
+
TABLE_OWNERSHIP_AUXILIARY_INHERITED_OWNER_EXCEPTION_CATEGORIES.has(exceptionEntry.category);
|
|
1570
|
+
|
|
1571
|
+
const crudOwnership = crudOwnershipByTable.get(tableName) || null;
|
|
1572
|
+
if (crudOwnership) {
|
|
1573
|
+
const missingRequiredOwnerKinds = subtractStringSet(crudOwnership.requiredOwnerKinds, directOwnerKinds);
|
|
1574
|
+
if (missingRequiredOwnerKinds.size > 0) {
|
|
1575
|
+
issues.push(
|
|
1576
|
+
`${crudOwnership.providerPath}: [crud-ownership:missing-owner-columns] ownershipFilter "${crudOwnership.ownershipFilter}" requires live table "${tableName}" to carry direct owner column(s) ${formatOwnerColumns(missingRequiredOwnerKinds)}. JSKIT CRUD ownership must be materialized on the row, not recovered through joins.`
|
|
1577
|
+
);
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
if (inheritedOwnerKinds.size < 1 || allowsAuxiliaryInheritedOwnership) {
|
|
1582
|
+
continue;
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
for (const ownerKind of sortStrings([...inheritedOwnerKinds])) {
|
|
1586
|
+
const inheritedPath = findInheritedOwnerChain(tableName, ownerKind, {
|
|
1587
|
+
directOwnerKindsByTable,
|
|
1588
|
+
foreignKeysByTable
|
|
1589
|
+
});
|
|
1590
|
+
const formattedPath = inheritedPath.length > 1
|
|
1591
|
+
? inheritedPath.join(" -> ")
|
|
1592
|
+
: tableName;
|
|
1593
|
+
const ownerColumnName = DIRECT_OWNER_COLUMNS[ownerKind];
|
|
1594
|
+
const exceptionSuffix = exceptionEntry
|
|
1595
|
+
? ` ${TABLE_OWNERSHIP_RELATIVE_PATH} category "${exceptionEntry.category}" does not exempt inherited ownership.`
|
|
1596
|
+
: "";
|
|
1597
|
+
issues.push(
|
|
1598
|
+
`[table-ownership:inherited-owner] live database table "${tableName}" reaches ${ownerKind} ownership only via foreign-key chain ${formattedPath} but lacks direct owner column "${ownerColumnName}". Materialize the owner on the row instead of filtering through parent relationships.${exceptionSuffix}`
|
|
1599
|
+
);
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
for (const exceptionEntry of exceptionConfig.exceptions) {
|
|
1604
|
+
if (!exceptionEntry.tableName) {
|
|
1605
|
+
continue;
|
|
1606
|
+
}
|
|
1607
|
+
if (!liveTables.includes(exceptionEntry.tableName)) {
|
|
1608
|
+
issues.push(
|
|
1609
|
+
`[table-ownership:stale-exception] ${TABLE_OWNERSHIP_RELATIVE_PATH} declares table "${exceptionEntry.tableName}" but that table does not exist in the live database.`
|
|
1610
|
+
);
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
|
|
846
1615
|
function hasTopLevelObjectProperty(sourceText = "", propertyName = "") {
|
|
847
1616
|
const normalizedPropertyName = String(propertyName || "").trim();
|
|
848
1617
|
const normalizedSourceText = String(sourceText || "").trim();
|
|
@@ -1321,6 +2090,13 @@ function createHealthCommands(ctx = {}) {
|
|
|
1321
2090
|
issues,
|
|
1322
2091
|
warnings
|
|
1323
2092
|
});
|
|
2093
|
+
await collectTableOwnershipDoctorIssues({
|
|
2094
|
+
appRoot,
|
|
2095
|
+
installedPackageIds: Object.keys(installed),
|
|
2096
|
+
packageRegistry: combinedPackageRegistry,
|
|
2097
|
+
appLocalRegistry,
|
|
2098
|
+
issues
|
|
2099
|
+
});
|
|
1324
2100
|
|
|
1325
2101
|
const payload = {
|
|
1326
2102
|
appRoot,
|