@jskit-ai/jskit-cli 0.2.70 → 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.70",
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.69",
24
- "@jskit-ai/kernel": "0.1.61",
25
- "@jskit-ai/shell-web": "0.1.60"
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,