septum 0.1.3 → 0.1.5

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.
Files changed (74) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +105 -102
  3. package/bin/septum.ts +4 -4
  4. package/package.json +63 -63
  5. package/src/cli/commands/check.ts +161 -161
  6. package/src/cli/commands/hook.ts +188 -188
  7. package/src/cli/commands/ingest.ts +29 -29
  8. package/src/cli/commands/init.ts +211 -211
  9. package/src/cli/commands/locate.ts +61 -61
  10. package/src/cli/commands/query.ts +64 -64
  11. package/src/cli/commands/serve.ts +5 -5
  12. package/src/cli/commands/slice.ts +48 -90
  13. package/src/cli/commands/sync.ts +31 -31
  14. package/src/cli/commands/topology.ts +119 -0
  15. package/src/cli/index.ts +210 -199
  16. package/src/cli/templates/hooks.ts +75 -75
  17. package/src/cli/templates/rules.ts +38 -38
  18. package/src/cli/templates/skill.ts +69 -69
  19. package/src/core/boundary/evaluator.ts +232 -210
  20. package/src/core/config/loader.ts +97 -97
  21. package/src/core/config/schema.ts +39 -39
  22. package/src/core/database/client.ts +44 -44
  23. package/src/core/database/repositories/dependency.repository.ts +366 -349
  24. package/src/core/database/repositories/domain.repository.ts +218 -198
  25. package/src/core/database/repositories/environment.repository.ts +207 -0
  26. package/src/core/database/repositories/file.repository.ts +116 -106
  27. package/src/core/database/repositories/meta.repository.ts +33 -33
  28. package/src/core/database/repositories/symbol.repository.ts +443 -424
  29. package/src/core/database/repository.ts +264 -255
  30. package/src/core/database/schema.ts +34 -1
  31. package/src/core/discovery/topology-detector.ts +486 -486
  32. package/src/core/ingestion/hasher.ts +5 -5
  33. package/src/core/ingestion/pipeline.ts +584 -535
  34. package/src/core/parser/behavioral-analyzer.ts +119 -0
  35. package/src/core/parser/boundary-tracker.ts +288 -128
  36. package/src/core/parser/extractors/base.ts +62 -61
  37. package/src/core/parser/extractors/frontend.ts +194 -194
  38. package/src/core/parser/extractors/go.ts +229 -176
  39. package/src/core/parser/extractors/laravel-semantic.ts +1039 -585
  40. package/src/core/parser/extractors/mcp-cli-semantic.ts +199 -199
  41. package/src/core/parser/extractors/nestjs-semantic.ts +119 -119
  42. package/src/core/parser/extractors/php.ts +153 -149
  43. package/src/core/parser/extractors/python.ts +228 -226
  44. package/src/core/parser/extractors/registry.ts +70 -70
  45. package/src/core/parser/extractors/semantic-extractor.interface.ts +21 -21
  46. package/src/core/parser/extractors/typescript.ts +181 -177
  47. package/src/core/parser/schema/providers/laravel-migration-provider.ts +215 -0
  48. package/src/core/parser/schema/providers/prisma-schema-provider.ts +89 -0
  49. package/src/core/parser/schema/schema-provider-registry.ts +43 -0
  50. package/src/core/parser/schema/schema-provider.interface.ts +33 -0
  51. package/src/core/parser/tree-sitter.ts +41 -41
  52. package/src/core/resolver/call-graph-tracer.ts +666 -668
  53. package/src/core/resolver/module-resolver.ts +408 -342
  54. package/src/core/resolver/symbol-locator.ts +543 -543
  55. package/src/core/resolver/vertical-slice-tracer.ts +301 -252
  56. package/src/core/session/session-manager.ts +56 -56
  57. package/src/core/telemetry/telemetry.ts +86 -86
  58. package/src/core/topology/environment-detector.ts +860 -0
  59. package/src/core/topology/types.ts +83 -0
  60. package/src/index.ts +21 -18
  61. package/src/mcp/schemas.ts +578 -238
  62. package/src/mcp/server.ts +875 -656
  63. package/src/mcp/tools/check-boundary.ts +171 -152
  64. package/src/mcp/tools/get-domain-catalog.ts +145 -121
  65. package/src/mcp/tools/get-environment-topology.ts +141 -0
  66. package/src/mcp/tools/get-feature-context.ts +212 -116
  67. package/src/mcp/tools/get-symbol-hotspots.ts +60 -49
  68. package/src/mcp/tools/get-symbol-impact.ts +28 -26
  69. package/src/mcp/tools/get-symbol.ts +65 -63
  70. package/src/mcp/tools/locate-symbol.ts +171 -190
  71. package/src/mcp/tools/register-domain.ts +68 -64
  72. package/src/mcp/tools/register-feature.ts +100 -0
  73. package/src/mcp/tools/trace-vertical-slice.ts +74 -103
  74. package/src/types/index.ts +335 -299
@@ -1,198 +1,218 @@
1
- import type { Database } from "bun:sqlite";
2
- import type {
3
- DomainCatalogResponse,
4
- DomainConfig,
5
- DomainRecord,
6
- FileRecord,
7
- SymbolKind,
8
- SymbolRecord,
9
- } from "../../../types/index.ts";
10
-
11
- export class DomainRepository {
12
- constructor(private db: Database) {}
13
-
14
- public upsertDomain(name: string, config: DomainConfig): number {
15
- const normalizedName = name.trim();
16
- const existing = this.getDomainByName(normalizedName);
17
-
18
- // Merge dependencies and archetypes if existing and incoming is empty
19
- let allowed = config.allowed_dependencies ?? [];
20
- let forbidden = config.forbidden_dependencies ?? [];
21
- let archetypes = config.archetypes ?? {};
22
-
23
- if (existing) {
24
- if (allowed.length === 0 && existing.allowed_deps_json && existing.allowed_deps_json !== "[]") {
25
- try {
26
- allowed = JSON.parse(existing.allowed_deps_json);
27
- } catch {}
28
- }
29
- if (forbidden.length === 0 && existing.forbidden_deps_json && existing.forbidden_deps_json !== "[]") {
30
- try {
31
- forbidden = JSON.parse(existing.forbidden_deps_json);
32
- } catch {}
33
- }
34
- if (Object.keys(archetypes).length === 0 && existing.archetypes_json && existing.archetypes_json !== "{}") {
35
- try {
36
- archetypes = JSON.parse(existing.archetypes_json);
37
- } catch {}
38
- }
39
- }
40
-
41
- const allowedJson = JSON.stringify(allowed);
42
- const forbiddenJson = JSON.stringify(forbidden);
43
- const archetypesJson = JSON.stringify(archetypes);
44
-
45
- const query = `
46
- INSERT INTO domains (name, root_path, allowed_deps_json, forbidden_deps_json, archetypes_json)
47
- VALUES (?, ?, ?, ?, ?)
48
- ON CONFLICT(name) DO UPDATE SET
49
- root_path = excluded.root_path,
50
- allowed_deps_json = excluded.allowed_deps_json,
51
- forbidden_deps_json = excluded.forbidden_deps_json,
52
- archetypes_json = excluded.archetypes_json,
53
- updated_at = CURRENT_TIMESTAMP
54
- `;
55
- this.db.query(query).run(normalizedName, config.root, allowedJson, forbiddenJson, archetypesJson);
56
-
57
- const record = this.getDomainByName(normalizedName);
58
- return record?.id ?? 0;
59
- }
60
-
61
- public getDomainByName(name: string): DomainRecord | null {
62
- return this.db
63
- .query<DomainRecord, [string]>("SELECT * FROM domains WHERE LOWER(name) = LOWER(?)")
64
- .get(name.trim());
65
- }
66
-
67
- public getAllDomains(): DomainRecord[] {
68
- return this.db.query<DomainRecord, []>("SELECT * FROM domains ORDER BY name ASC").all();
69
- }
70
-
71
- public getDomainCatalog(
72
- domainName: string,
73
- archetypeFilter?: string
74
- ): DomainCatalogResponse | null {
75
- const domain = this.getDomainByName(domainName);
76
- if (!domain) return null;
77
-
78
- const allowedDeps: string[] = JSON.parse(domain.allowed_deps_json || "[]");
79
- const forbiddenDeps: string[] = JSON.parse(domain.forbidden_deps_json || "[]");
80
-
81
- let filesQuery = "SELECT * FROM files WHERE domain_id = ?";
82
- const params: [number, string?] = [domain.id];
83
-
84
- if (archetypeFilter) {
85
- filesQuery += " AND archetype = ?";
86
- params.push(archetypeFilter);
87
- }
88
- filesQuery += " ORDER BY path ASC";
89
-
90
- const files = this.db.query<FileRecord, any>(filesQuery).all(...params);
91
-
92
- const catalogFiles: DomainCatalogResponse["files"] = [];
93
-
94
- for (const file of files) {
95
- const symbols = this.db
96
- .query<SymbolRecord, [number]>(
97
- "SELECT * FROM symbols WHERE file_id = ? ORDER BY line_start ASC"
98
- )
99
- .all(file.id);
100
-
101
- const symbolMap = new Map<
102
- string,
103
- {
104
- name: string;
105
- kind: SymbolKind;
106
- properties: string[];
107
- methods: string[];
108
- signatures: string[];
109
- }
110
- >();
111
-
112
- for (const sym of symbols) {
113
- if (
114
- sym.kind === "class" ||
115
- sym.kind === "interface" ||
116
- sym.kind === "trait" ||
117
- sym.kind === "enum"
118
- ) {
119
- if (!symbolMap.has(sym.name)) {
120
- symbolMap.set(sym.name, {
121
- name: sym.name,
122
- kind: sym.kind,
123
- properties: [],
124
- methods: [],
125
- signatures: [sym.signature],
126
- });
127
- }
128
- } else if (sym.kind === "method") {
129
- let targetContainer = Array.from(symbolMap.values()).pop();
130
- if (sym.name.includes("::")) {
131
- const containerName = sym.name.split("::")[0];
132
- if (symbolMap.has(containerName)) {
133
- targetContainer = symbolMap.get(containerName);
134
- }
135
- }
136
- if (targetContainer) {
137
- targetContainer.methods.push(sym.signature);
138
- } else {
139
- symbolMap.set(sym.name, {
140
- name: sym.name,
141
- kind: sym.kind,
142
- properties: [],
143
- methods: [],
144
- signatures: [sym.signature],
145
- });
146
- }
147
- } else if (sym.kind === "property") {
148
- let targetContainer = Array.from(symbolMap.values()).pop();
149
- if (sym.name.includes("::")) {
150
- const containerName = sym.name.split("::")[0];
151
- if (symbolMap.has(containerName)) {
152
- targetContainer = symbolMap.get(containerName);
153
- }
154
- }
155
- if (targetContainer) {
156
- targetContainer.properties.push(sym.signature);
157
- } else {
158
- symbolMap.set(sym.name, {
159
- name: sym.name,
160
- kind: sym.kind,
161
- properties: [sym.signature],
162
- methods: [],
163
- signatures: [sym.signature],
164
- });
165
- }
166
- } else {
167
- symbolMap.set(sym.name, {
168
- name: sym.name,
169
- kind: sym.kind,
170
- properties: [],
171
- methods: [],
172
- signatures: [sym.signature],
173
- });
174
- }
175
- }
176
-
177
- catalogFiles.push({
178
- path: file.path,
179
- archetype: file.archetype,
180
- symbols: Array.from(symbolMap.values()).map((s) => ({
181
- name: s.name,
182
- kind: s.kind,
183
- properties: s.properties.length > 0 ? s.properties : undefined,
184
- methods: s.methods.length > 0 ? s.methods : undefined,
185
- signatures: s.signatures.length > 0 ? s.signatures : undefined,
186
- })),
187
- });
188
- }
189
-
190
- return {
191
- domain: domain.name,
192
- root: domain.root_path,
193
- allowed_dependencies: allowedDeps,
194
- forbidden_dependencies: forbiddenDeps,
195
- files: catalogFiles,
196
- };
197
- }
198
- }
1
+ import type { Database } from "bun:sqlite";
2
+ import type {
3
+ DomainCatalogResponse,
4
+ DomainConfig,
5
+ DomainRecord,
6
+ FileRecord,
7
+ SymbolKind,
8
+ SymbolRecord,
9
+ } from "../../../types/index.ts";
10
+
11
+ export class DomainRepository {
12
+ constructor(private db: Database) {}
13
+
14
+ public upsertDomain(name: string, config: DomainConfig): number {
15
+ const normalizedName = name.trim();
16
+ const existing = this.getDomainByName(normalizedName);
17
+
18
+ // Merge dependencies and archetypes if existing and incoming is empty
19
+ let allowed = config.allowed_dependencies ?? [];
20
+ let forbidden = config.forbidden_dependencies ?? [];
21
+ let archetypes = config.archetypes ?? {};
22
+
23
+ if (existing) {
24
+ if (allowed.length === 0 && existing.allowed_deps_json && existing.allowed_deps_json !== "[]") {
25
+ try {
26
+ allowed = JSON.parse(existing.allowed_deps_json);
27
+ } catch {}
28
+ }
29
+ if (forbidden.length === 0 && existing.forbidden_deps_json && existing.forbidden_deps_json !== "[]") {
30
+ try {
31
+ forbidden = JSON.parse(existing.forbidden_deps_json);
32
+ } catch {}
33
+ }
34
+ if (Object.keys(archetypes).length === 0 && existing.archetypes_json && existing.archetypes_json !== "{}") {
35
+ try {
36
+ archetypes = JSON.parse(existing.archetypes_json);
37
+ } catch {}
38
+ }
39
+ }
40
+
41
+ const allowedJson = JSON.stringify(allowed);
42
+ const forbiddenJson = JSON.stringify(forbidden);
43
+ const archetypesJson = JSON.stringify(archetypes);
44
+
45
+ const query = `
46
+ INSERT INTO domains (name, root_path, allowed_deps_json, forbidden_deps_json, archetypes_json)
47
+ VALUES (?, ?, ?, ?, ?)
48
+ ON CONFLICT(name) DO UPDATE SET
49
+ root_path = excluded.root_path,
50
+ allowed_deps_json = excluded.allowed_deps_json,
51
+ forbidden_deps_json = excluded.forbidden_deps_json,
52
+ archetypes_json = excluded.archetypes_json,
53
+ updated_at = CURRENT_TIMESTAMP
54
+ `;
55
+ this.db.query(query).run(normalizedName, config.root, allowedJson, forbiddenJson, archetypesJson);
56
+
57
+ const record = this.getDomainByName(normalizedName);
58
+ return record?.id ?? 0;
59
+ }
60
+
61
+ public getDomainByName(name: string): DomainRecord | null {
62
+ return this.db
63
+ .query<DomainRecord, [string]>("SELECT * FROM domains WHERE LOWER(name) = LOWER(?)")
64
+ .get(name.trim());
65
+ }
66
+
67
+ public getAllDomains(): DomainRecord[] {
68
+ return this.db.query<DomainRecord, []>("SELECT * FROM domains ORDER BY name ASC").all();
69
+ }
70
+
71
+ public getDomainCatalog(
72
+ domainName: string,
73
+ archetypeFilter?: string
74
+ ): DomainCatalogResponse | null {
75
+ const domain = this.getDomainByName(domainName);
76
+ if (!domain) return null;
77
+
78
+ const allowedDeps: string[] = JSON.parse(domain.allowed_deps_json || "[]");
79
+ const forbiddenDeps: string[] = JSON.parse(domain.forbidden_deps_json || "[]");
80
+
81
+ let filesQuery = "SELECT * FROM files WHERE domain_id = ?";
82
+ const params: [number, string?] = [domain.id];
83
+
84
+ if (archetypeFilter) {
85
+ filesQuery += " AND archetype = ?";
86
+ params.push(archetypeFilter);
87
+ }
88
+ filesQuery += " ORDER BY path ASC";
89
+
90
+ const files = this.db.query<FileRecord, any>(filesQuery).all(...params);
91
+
92
+ const catalogFiles: DomainCatalogResponse["files"] = [];
93
+
94
+ // Batch retrieve symbols for all files in one query to eliminate N+1 roundtrips
95
+ const symbolsByFileId = new Map<number, SymbolRecord[]>();
96
+ for (const f of files) {
97
+ symbolsByFileId.set(f.id, []);
98
+ }
99
+
100
+ if (files.length > 0) {
101
+ const allDomainSymbols = this.db
102
+ .query<SymbolRecord, [number]>(
103
+ `SELECT s.* FROM symbols s
104
+ JOIN files f ON s.file_id = f.id
105
+ WHERE f.domain_id = ?
106
+ ORDER BY s.file_id ASC, s.line_start ASC`
107
+ )
108
+ .all(domain.id);
109
+
110
+ for (const sym of allDomainSymbols) {
111
+ const list = symbolsByFileId.get(sym.file_id);
112
+ if (list) {
113
+ list.push(sym);
114
+ }
115
+ }
116
+ }
117
+
118
+ for (const file of files) {
119
+ const symbols = symbolsByFileId.get(file.id) ?? [];
120
+
121
+ const symbolMap = new Map<
122
+ string,
123
+ {
124
+ name: string;
125
+ kind: SymbolKind;
126
+ properties: string[];
127
+ methods: string[];
128
+ signatures: string[];
129
+ }
130
+ >();
131
+
132
+ for (const sym of symbols) {
133
+ if (
134
+ sym.kind === "class" ||
135
+ sym.kind === "interface" ||
136
+ sym.kind === "trait" ||
137
+ sym.kind === "enum"
138
+ ) {
139
+ if (!symbolMap.has(sym.name)) {
140
+ symbolMap.set(sym.name, {
141
+ name: sym.name,
142
+ kind: sym.kind,
143
+ properties: [],
144
+ methods: [],
145
+ signatures: [sym.signature],
146
+ });
147
+ }
148
+ } else if (sym.kind === "method") {
149
+ let targetContainer = Array.from(symbolMap.values()).pop();
150
+ if (sym.name.includes("::")) {
151
+ const containerName = sym.name.split("::")[0];
152
+ if (symbolMap.has(containerName)) {
153
+ targetContainer = symbolMap.get(containerName);
154
+ }
155
+ }
156
+ if (targetContainer) {
157
+ targetContainer.methods.push(sym.signature);
158
+ } else {
159
+ symbolMap.set(sym.name, {
160
+ name: sym.name,
161
+ kind: sym.kind,
162
+ properties: [],
163
+ methods: [],
164
+ signatures: [sym.signature],
165
+ });
166
+ }
167
+ } else if (sym.kind === "property") {
168
+ let targetContainer = Array.from(symbolMap.values()).pop();
169
+ if (sym.name.includes("::")) {
170
+ const containerName = sym.name.split("::")[0];
171
+ if (symbolMap.has(containerName)) {
172
+ targetContainer = symbolMap.get(containerName);
173
+ }
174
+ }
175
+ if (targetContainer) {
176
+ targetContainer.properties.push(sym.signature);
177
+ } else {
178
+ symbolMap.set(sym.name, {
179
+ name: sym.name,
180
+ kind: sym.kind,
181
+ properties: [sym.signature],
182
+ methods: [],
183
+ signatures: [sym.signature],
184
+ });
185
+ }
186
+ } else {
187
+ symbolMap.set(sym.name, {
188
+ name: sym.name,
189
+ kind: sym.kind,
190
+ properties: [],
191
+ methods: [],
192
+ signatures: [sym.signature],
193
+ });
194
+ }
195
+ }
196
+
197
+ catalogFiles.push({
198
+ path: file.path,
199
+ archetype: file.archetype,
200
+ symbols: Array.from(symbolMap.values()).map((s) => ({
201
+ name: s.name,
202
+ kind: s.kind,
203
+ properties: s.properties.length > 0 ? s.properties : undefined,
204
+ methods: s.methods.length > 0 ? s.methods : undefined,
205
+ signatures: s.signatures.length > 0 ? s.signatures : undefined,
206
+ })),
207
+ });
208
+ }
209
+
210
+ return {
211
+ domain: domain.name,
212
+ root: domain.root_path,
213
+ allowed_dependencies: allowedDeps,
214
+ forbidden_dependencies: forbiddenDeps,
215
+ files: catalogFiles,
216
+ };
217
+ }
218
+ }
@@ -0,0 +1,207 @@
1
+ import type { Database } from "bun:sqlite";
2
+ import type { CanonicalPlatformTag, EnvironmentLayer, EnvironmentLayerInfo, TopologyNode } from "../../topology/types.ts";
3
+
4
+ export class EnvironmentRepository {
5
+ constructor(private db: Database) {}
6
+
7
+ public getLayer(layer: EnvironmentLayer): EnvironmentLayerInfo | null {
8
+ const row = this.db
9
+ .query<{
10
+ layer: string;
11
+ platform: string | null;
12
+ status: string;
13
+ detected_from_json: string;
14
+ constraints_json: string;
15
+ unresolved_inquiry: string | null;
16
+ }, [string]>("SELECT * FROM environment_topology WHERE layer = ?")
17
+ .get(layer);
18
+
19
+ if (!row) return null;
20
+ return {
21
+ layer: row.layer as EnvironmentLayer,
22
+ platform: row.platform ?? undefined,
23
+ status: row.status as any,
24
+ detected_from: JSON.parse(row.detected_from_json || "[]"),
25
+ constraints: JSON.parse(row.constraints_json || "[]"),
26
+ unresolved_inquiry: row.unresolved_inquiry ?? undefined,
27
+ };
28
+ }
29
+
30
+ public getAllLayers(): Record<EnvironmentLayer, EnvironmentLayerInfo> {
31
+ const rows = this.db
32
+ .query<{
33
+ layer: string;
34
+ platform: string | null;
35
+ status: string;
36
+ detected_from_json: string;
37
+ constraints_json: string;
38
+ unresolved_inquiry: string | null;
39
+ }, []>("SELECT * FROM environment_topology")
40
+ .all();
41
+
42
+ const result: Partial<Record<EnvironmentLayer, EnvironmentLayerInfo>> = {};
43
+ for (const row of rows) {
44
+ result[row.layer as EnvironmentLayer] = {
45
+ layer: row.layer as EnvironmentLayer,
46
+ platform: row.platform ?? undefined,
47
+ status: row.status as any,
48
+ detected_from: JSON.parse(row.detected_from_json || "[]"),
49
+ constraints: JSON.parse(row.constraints_json || "[]"),
50
+ unresolved_inquiry: row.unresolved_inquiry ?? undefined,
51
+ };
52
+ }
53
+ return result as Record<EnvironmentLayer, EnvironmentLayerInfo>;
54
+ }
55
+
56
+ public saveLayer(info: EnvironmentLayerInfo): void {
57
+ this.db
58
+ .query(
59
+ `INSERT INTO environment_topology (
60
+ layer, platform, status, detected_from_json, constraints_json, unresolved_inquiry, updated_at
61
+ ) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
62
+ ON CONFLICT(layer) DO UPDATE SET
63
+ platform = excluded.platform,
64
+ status = excluded.status,
65
+ detected_from_json = excluded.detected_from_json,
66
+ constraints_json = excluded.constraints_json,
67
+ unresolved_inquiry = excluded.unresolved_inquiry,
68
+ updated_at = CURRENT_TIMESTAMP`
69
+ )
70
+ .run(
71
+ info.layer,
72
+ info.platform ?? null,
73
+ info.status,
74
+ JSON.stringify(info.detected_from ?? []),
75
+ JSON.stringify(info.constraints ?? []),
76
+ info.unresolved_inquiry ?? null
77
+ );
78
+ }
79
+
80
+ public clearAll(): void {
81
+ this.db.run("DELETE FROM environment_topology");
82
+ this.db.run("DELETE FROM environment_nodes");
83
+ }
84
+
85
+ // Node CRUD operations for Multi-Node Topology
86
+ public getNode(id: string): TopologyNode | null {
87
+ const row = this.db
88
+ .query<{
89
+ id: string;
90
+ layer: string;
91
+ name: string;
92
+ platform: string;
93
+ canonical_tag: string;
94
+ domain_or_ip: string | null;
95
+ role: string | null;
96
+ status: string;
97
+ detected_from_json: string;
98
+ constraints_json: string;
99
+ connected_to_json: string;
100
+ unresolved_inquiry: string | null;
101
+ }, [string]>("SELECT * FROM environment_nodes WHERE id = ?")
102
+ .get(id);
103
+
104
+ if (!row) return null;
105
+ return this.mapNodeRow(row);
106
+ }
107
+
108
+ public getAllNodes(): TopologyNode[] {
109
+ const rows = this.db
110
+ .query<{
111
+ id: string;
112
+ layer: string;
113
+ name: string;
114
+ platform: string;
115
+ canonical_tag: string;
116
+ domain_or_ip: string | null;
117
+ role: string | null;
118
+ status: string;
119
+ detected_from_json: string;
120
+ constraints_json: string;
121
+ connected_to_json: string;
122
+ unresolved_inquiry: string | null;
123
+ }, []>("SELECT * FROM environment_nodes ORDER BY layer, id")
124
+ .all();
125
+
126
+ return rows.map((r) => this.mapNodeRow(r));
127
+ }
128
+
129
+ public getNodesByLayer(layer: EnvironmentLayer): TopologyNode[] {
130
+ const rows = this.db
131
+ .query<{
132
+ id: string;
133
+ layer: string;
134
+ name: string;
135
+ platform: string;
136
+ canonical_tag: string;
137
+ domain_or_ip: string | null;
138
+ role: string | null;
139
+ status: string;
140
+ detected_from_json: string;
141
+ constraints_json: string;
142
+ connected_to_json: string;
143
+ unresolved_inquiry: string | null;
144
+ }, [string]>("SELECT * FROM environment_nodes WHERE layer = ? ORDER BY id")
145
+ .all(layer);
146
+
147
+ return rows.map((r) => this.mapNodeRow(r));
148
+ }
149
+
150
+ public saveNode(node: TopologyNode): void {
151
+ this.db
152
+ .query(
153
+ `INSERT INTO environment_nodes (
154
+ id, layer, name, platform, canonical_tag, domain_or_ip, role, status,
155
+ detected_from_json, constraints_json, connected_to_json, unresolved_inquiry, updated_at
156
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
157
+ ON CONFLICT(id) DO UPDATE SET
158
+ layer = excluded.layer,
159
+ name = excluded.name,
160
+ platform = excluded.platform,
161
+ canonical_tag = excluded.canonical_tag,
162
+ domain_or_ip = excluded.domain_or_ip,
163
+ role = excluded.role,
164
+ status = excluded.status,
165
+ detected_from_json = excluded.detected_from_json,
166
+ constraints_json = excluded.constraints_json,
167
+ connected_to_json = excluded.connected_to_json,
168
+ unresolved_inquiry = excluded.unresolved_inquiry,
169
+ updated_at = CURRENT_TIMESTAMP`
170
+ )
171
+ .run(
172
+ node.id,
173
+ node.layer,
174
+ node.name,
175
+ node.platform,
176
+ node.canonical_tag || "unspecified",
177
+ node.domain_or_ip ?? null,
178
+ node.role ?? null,
179
+ node.status,
180
+ JSON.stringify(node.detected_from ?? []),
181
+ JSON.stringify(node.constraints ?? []),
182
+ JSON.stringify(node.connected_to ?? []),
183
+ node.unresolved_inquiry ?? null
184
+ );
185
+ }
186
+
187
+ public deleteNode(id: string): void {
188
+ this.db.query("DELETE FROM environment_nodes WHERE id = ?").run(id);
189
+ }
190
+
191
+ private mapNodeRow(row: any): TopologyNode {
192
+ return {
193
+ id: row.id,
194
+ layer: row.layer as EnvironmentLayer,
195
+ name: row.name,
196
+ platform: row.platform,
197
+ canonical_tag: (row.canonical_tag as CanonicalPlatformTag) || "unspecified",
198
+ domain_or_ip: row.domain_or_ip ?? undefined,
199
+ role: row.role ?? undefined,
200
+ status: row.status as any,
201
+ detected_from: JSON.parse(row.detected_from_json || "[]"),
202
+ constraints: JSON.parse(row.constraints_json || "[]"),
203
+ connected_to: JSON.parse(row.connected_to_json || "[]"),
204
+ unresolved_inquiry: row.unresolved_inquiry ?? undefined,
205
+ };
206
+ }
207
+ }