@topogram/cli 0.3.58 → 0.3.60

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": "@topogram/cli",
3
- "version": "0.3.58",
3
+ "version": "0.3.60",
4
4
  "description": "Topogram CLI for checking Topogram workspaces and generating app bundles.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -182,7 +182,8 @@ function buildContractsForContext(context) {
182
182
  }
183
183
  if (surface === "native" || surface === "ios_surface" || surface === "android_surface") {
184
184
  return {
185
- uiSurface: generateUiSurfaceContract(context.graph, { ...(context.options || {}), projectionId })
185
+ uiSurface: generateUiSurfaceContract(context.graph, { ...(context.options || {}), projectionId }),
186
+ api: generateApiContractGraph(context.graph, {})
186
187
  };
187
188
  }
188
189
  return {};
@@ -1,4 +1,5 @@
1
1
  import {
2
+ generateNativeBundle,
2
3
  generateServerBundle,
3
4
  generateWebBundle,
4
5
  getDefaultEnvironmentProjections,
@@ -49,6 +50,12 @@ function buildCompileCheckPlan(graph, options = {}) {
49
50
  command: "npm run build"
50
51
  }
51
52
  ]);
53
+ const nativeChecks = topology.nativeRuntimes.map((component, index) => ({
54
+ id: index === 0 ? "native_swift_build" : `native_swift_build_${component.id}`,
55
+ cwd: topology.nativeDir(component),
56
+ install: null,
57
+ command: "swift build"
58
+ }));
52
59
  return {
53
60
  type: "compile_check_plan",
54
61
  name: compileCheckName(graph, options),
@@ -65,7 +72,7 @@ function buildCompileCheckPlan(graph, options = {}) {
65
72
  generator: runtime.generator
66
73
  }))
67
74
  },
68
- checks: [...apiChecks, ...webChecks]
75
+ checks: [...apiChecks, ...webChecks, ...nativeChecks]
69
76
  };
70
77
  }
71
78
 
@@ -92,13 +99,14 @@ ${runtimeReference.environment.envExample || ""}
92
99
  function renderCompileCheckReadme(graph, options = {}) {
93
100
  return `# ${compileCheckName(graph, options).replace("Plan", "Bundle")}
94
101
 
95
- This bundle verifies that the generated server and web projects typecheck and build.
102
+ This bundle verifies that generated server, web, and native projects compile.
96
103
 
97
104
  ## Checks
98
105
 
99
106
  - server TypeScript check
100
107
  - web TypeScript check
101
108
  - web production build
109
+ - native Swift build
102
110
 
103
111
  ## Usage
104
112
 
@@ -124,15 +132,17 @@ function renderCompileCheckScript(plan) {
124
132
  ""
125
133
  ];
126
134
  if (plan.checks.length === 0) {
127
- lines.push('echo "No API or web runtimes are configured; compile check is a no-op."');
135
+ lines.push('echo "No API, web, or native runtimes are configured; compile check is a no-op."');
128
136
  }
129
137
  for (const check of plan.checks) {
130
138
  const label = check.id.includes("web")
131
139
  ? check.id.includes("build") ? "Building generated web" : "Checking generated web"
132
- : "Checking generated server";
140
+ : check.id.includes("native") ? "Building generated native app" : "Checking generated server";
133
141
  lines.push(`echo "${label} (${check.cwd})..."`);
134
- lines.push(`echo "Installing dependencies (${check.cwd})..."`);
135
- lines.push(`(cd "$ROOT_DIR/${check.cwd}" && ${check.install})`);
142
+ if (check.install) {
143
+ lines.push(`echo "Installing dependencies (${check.cwd})..."`);
144
+ lines.push(`(cd "$ROOT_DIR/${check.cwd}" && ${check.install})`);
145
+ }
136
146
  lines.push(`echo "Running ${check.command} (${check.cwd})..."`);
137
147
  lines.push(`(cd "$ROOT_DIR/${check.cwd}" && ${check.command})`);
138
148
  lines.push("");
@@ -158,6 +168,10 @@ export function generateCompileCheckBundle(graph, options = {}) {
158
168
  const webBundle = generateWebBundle(graph, component.projection.id, { ...options, component });
159
169
  mergeBundleFiles(files, topology.webDir(component), webBundle);
160
170
  }
171
+ for (const component of topology.nativeRuntimes) {
172
+ const nativeBundle = generateNativeBundle(graph, component.projection.id, { ...options, component });
173
+ mergeBundleFiles(files, topology.nativeDir(component), nativeBundle);
174
+ }
161
175
  return files;
162
176
  }
163
177
 
@@ -2,6 +2,7 @@ import { generateDbLifecyclePlan } from "../surfaces/databases/lifecycle-shared.
2
2
  import { getExampleImplementation } from "../../example-implementation.js";
3
3
  import {
4
4
  generateDbBundle,
5
+ generateNativeBundle,
5
6
  generateServerBundle,
6
7
  generateWebBundle,
7
8
  dbEnvVarsForComponent,
@@ -110,6 +111,7 @@ function buildEnvironmentPlan(graph, options = {}) {
110
111
  server: topology.primaryApi ? topology.serviceDir(topology.primaryApi) : null,
111
112
  web: topology.primaryWeb ? topology.webDir(topology.primaryWeb) : null,
112
113
  db: topology.primaryDb ? topology.dbDir(topology.primaryDb) : null,
114
+ native: topology.nativeRuntimes[0] ? topology.nativeDir(topology.nativeRuntimes[0]) : null,
113
115
  scripts: "scripts"
114
116
  },
115
117
  runtimes: {
@@ -130,6 +132,12 @@ function buildEnvironmentPlan(graph, options = {}) {
130
132
  dir: topology.webDir(component),
131
133
  uses_api: component.api
132
134
  })),
135
+ natives: topology.nativeRuntimes.map((component) => ({
136
+ id: component.id,
137
+ projection: component.projection.id,
138
+ dir: topology.nativeDir(component),
139
+ uses_api: component.api
140
+ })),
133
141
  databases: topology.dbRuntimes.map((component) => ({
134
142
  id: component.id,
135
143
  projection: component.projection.id,
@@ -273,6 +281,7 @@ function renderEnvironmentReadme(plan) {
273
281
  const hasDb = plan.runtimes.databases.length > 0;
274
282
  const hasApi = plan.runtimes.apis.length > 0;
275
283
  const hasWeb = plan.runtimes.webs.length > 0;
284
+ const hasNative = plan.runtimes.natives.length > 0;
276
285
  const localProcessNotes = !hasDb
277
286
  ? "- This bundle has no generated database surface."
278
287
  : plan.projections.db.engine === "sqlite"
@@ -294,7 +303,7 @@ ${localProcessNotes}
294
303
 
295
304
  This bundle packages the generated runtime into one local environment:
296
305
 
297
- ${hasApi ? "- `services/<api-id>/`: generated API service scaffolds\n" : ""}${hasWeb ? `- \`web/<web-id>/\`: generated ${plan.runtimeProfiles.web === "react" ? "Vite + React Router" : plan.runtimeProfiles.web === "vanilla" ? "vanilla HTML/CSS/JS" : "SvelteKit"} web scaffolds\n` : ""}${hasDb ? "- `db/<db-id>/`: generated DB lifecycle bundles\n" : ""}${plan.files.dockerCompose ? `- \`${plan.files.dockerCompose}\`: local Postgres container` : hasDb ? (plan.projections.db.engine === "sqlite" ? "- local SQLite file orchestration (no Docker files generated)" : "- local-process Postgres orchestration (no Docker files generated)") : "- no DB orchestration is generated"}
306
+ ${hasApi ? "- `services/<api-id>/`: generated API service scaffolds\n" : ""}${hasWeb ? `- \`web/<web-id>/\`: generated ${plan.runtimeProfiles.web === "react" ? "Vite + React Router" : plan.runtimeProfiles.web === "vanilla" ? "vanilla HTML/CSS/JS" : "SvelteKit"} web scaffolds\n` : ""}${hasNative ? "- `native/<native-id>/`: generated native app scaffolds\n" : ""}${hasDb ? "- `db/<db-id>/`: generated DB lifecycle bundles\n" : ""}${plan.files.dockerCompose ? `- \`${plan.files.dockerCompose}\`: local Postgres container` : hasDb ? (plan.projections.db.engine === "sqlite" ? "- local SQLite file orchestration (no Docker files generated)" : "- local-process Postgres orchestration (no Docker files generated)") : "- no DB orchestration is generated"}
298
307
 
299
308
  ## Quick Start
300
309
 
@@ -318,6 +327,7 @@ ${dockerSection}
318
327
 
319
328
  - ${hasApi && hasDb ? `The generated server expects ${plan.projections.db.engine === "sqlite" ? "SQLite plus Prisma." : "Postgres plus Prisma."}` : hasApi ? "The generated server is stateless." : "No server surface is generated."}
320
329
  - ${hasWeb && hasApi ? "The generated web app talks to `PUBLIC_TOPOGRAM_API_BASE_URL`." : hasWeb ? "The generated web app is standalone." : "No web surface is generated."}
330
+ - ${hasNative ? "Native app scaffolds use the same UI surface contracts as web surfaces." : "No native surface is generated."}
321
331
  - If \`.env\` is missing, generated scripts fall back to \`.env.example\`.
322
332
  - The DB lifecycle scripts remain the source of truth for greenfield bootstrap and brownfield migration.
323
333
  `;
@@ -635,6 +645,12 @@ export function generateEnvironmentBundle(graph, options = {}) {
635
645
  [topology.webDir(component)]: webBundle
636
646
  });
637
647
  }
648
+ for (const component of topology.nativeRuntimes) {
649
+ const nativeBundle = generateNativeBundle(graph, component.projection.id, { ...options, component });
650
+ mergeNamedBundles(files, {
651
+ [topology.nativeDir(component)]: nativeBundle
652
+ });
653
+ }
638
654
  for (const component of topology.dbRuntimes) {
639
655
  const dbBundle = generateDbBundle(graph, component.projection.id, { ...options, component });
640
656
  mergeNamedBundles(files, {
@@ -43,16 +43,19 @@ import { defaultProjectConfigForGraph, validateProjectConfig } from "../../proje
43
43
  * @property {RuntimeComponent[]} runtimes
44
44
  * @property {RuntimeComponent[]} apiRuntimes
45
45
  * @property {RuntimeComponent[]} webRuntimes
46
+ * @property {RuntimeComponent[]} nativeRuntimes
46
47
  * @property {RuntimeComponent[]} dbRuntimes
47
48
  * @property {RuntimeComponent[]} components Legacy alias for runtimes.
48
49
  * @property {RuntimeComponent[]} apiComponents Legacy alias for apiRuntimes.
49
50
  * @property {RuntimeComponent[]} webComponents Legacy alias for webRuntimes.
51
+ * @property {RuntimeComponent[]} nativeComponents Legacy alias for nativeRuntimes.
50
52
  * @property {RuntimeComponent[]} dbComponents Legacy alias for dbRuntimes.
51
53
  * @property {RuntimeComponent|null} primaryApi
52
54
  * @property {RuntimeComponent|null} primaryWeb
53
55
  * @property {RuntimeComponent|null} primaryDb
54
56
  * @property {(component: RuntimeComponent) => string} serviceDir
55
57
  * @property {(component: RuntimeComponent) => string} webDir
58
+ * @property {(component: RuntimeComponent) => string} nativeDir
56
59
  * @property {(component: RuntimeComponent) => string} dbDir
57
60
  */
58
61
 
@@ -371,6 +374,29 @@ export function generateWebBundle(graph, projectionId, options = {}) {
371
374
  }).files;
372
375
  }
373
376
 
377
+ /**
378
+ * @param {ResolvedGraph} graph
379
+ * @param {string} projectionId
380
+ * @param {RuntimeGenerationOptions} [options]
381
+ * @returns {any}
382
+ */
383
+ export function generateNativeBundle(graph, projectionId, options = {}) {
384
+ const topology = resolveRuntimeTopology(graph, options);
385
+ const runtime = options.runtime || options.component || topology.nativeRuntimes.find((entry) => entry.projection.id === projectionId);
386
+ if (!runtime) {
387
+ throw new Error(`No native runtime found for projection '${projectionId}'`);
388
+ }
389
+ return generateWithComponentGenerator({
390
+ graph,
391
+ projection: runtime.projection,
392
+ runtime,
393
+ component: runtime,
394
+ topology,
395
+ implementation: options.implementation || null,
396
+ options: { ...options, projectionId }
397
+ }).files;
398
+ }
399
+
374
400
  /**
375
401
  * @param {ResolvedGraph} graph
376
402
  * @param {string} projectionId
@@ -450,7 +476,7 @@ function decorateRuntimes(graph, config) {
450
476
  runtime.databaseRuntime = byId.get(runtime.database) || null;
451
477
  runtime.databaseComponent = runtime.databaseRuntime;
452
478
  }
453
- if (runtime.kind === "web_surface" && runtime.api) {
479
+ if (["web_surface", "ios_surface", "android_surface"].includes(runtime.kind) && runtime.api) {
454
480
  runtime.apiRuntime = byId.get(runtime.api) || null;
455
481
  runtime.apiComponent = runtime.apiRuntime;
456
482
  }
@@ -475,6 +501,7 @@ export function resolveRuntimeTopology(graph, options = {}) {
475
501
  const runtimes = decorateRuntimes(graph, config);
476
502
  const apiRuntimes = runtimes.filter((runtime) => runtime.kind === "api_service");
477
503
  const webRuntimes = runtimes.filter((runtime) => runtime.kind === "web_surface");
504
+ const nativeRuntimes = runtimes.filter((runtime) => runtime.kind === "ios_surface" || runtime.kind === "android_surface");
478
505
  const dbRuntimes = runtimes.filter((runtime) => runtime.kind === "database");
479
506
  const primaryApi = apiRuntimes[0] || null;
480
507
  const primaryWeb = webRuntimes[0] || null;
@@ -486,9 +513,11 @@ export function resolveRuntimeTopology(graph, options = {}) {
486
513
  components: runtimes,
487
514
  apiRuntimes,
488
515
  webRuntimes,
516
+ nativeRuntimes,
489
517
  dbRuntimes,
490
518
  apiComponents: apiRuntimes,
491
519
  webComponents: webRuntimes,
520
+ nativeComponents: nativeRuntimes,
492
521
  dbComponents: dbRuntimes,
493
522
  primaryApi,
494
523
  primaryWeb,
@@ -499,6 +528,9 @@ export function resolveRuntimeTopology(graph, options = {}) {
499
528
  webDir(component) {
500
529
  return `web/${component.id}`;
501
530
  },
531
+ nativeDir(component) {
532
+ return `native/${component.id}`;
533
+ },
502
534
  dbDir(component) {
503
535
  return `db/${component.id}`;
504
536
  }
@@ -26,13 +26,13 @@ export function generateSwiftUiApp(graph, options = {}) {
26
26
  const files = {};
27
27
 
28
28
  for (const name of swiftFiles) {
29
- files[`Sources/TodoSwiftUIApp/${name}`] = fs.readFileSync(path.join(runtimeDir, name), "utf8");
29
+ files[`Sources/TopogramSwiftUIApp/${name}`] = fs.readFileSync(path.join(runtimeDir, name), "utf8");
30
30
  }
31
31
 
32
32
  files["Package.swift"] = fs.readFileSync(path.join(__dirname, "swiftui-templates", "Package.swift.txt"), "utf8");
33
33
  files["README.md"] = fs.readFileSync(path.join(__dirname, "swiftui-templates", "README.generated.md"), "utf8");
34
- files["Sources/TodoSwiftUIApp/Resources/ui-surface-contract.json"] = contractJson;
35
- files["Sources/TodoSwiftUIApp/Resources/api-contracts.json"] = apiJson;
34
+ files["Sources/TopogramSwiftUIApp/Resources/ui-surface-contract.json"] = contractJson;
35
+ files["Sources/TopogramSwiftUIApp/Resources/api-contracts.json"] = apiJson;
36
36
 
37
37
  return files;
38
38
  }
@@ -2,15 +2,15 @@
2
2
  import PackageDescription
3
3
 
4
4
  let package = Package(
5
- name: "TodoSwiftUIApp",
5
+ name: "TopogramSwiftUIApp",
6
6
  platforms: [.iOS(.v17), .macOS(.v14)],
7
7
  products: [
8
- .executable(name: "TodoSwiftUIApp", targets: ["TodoSwiftUIApp"])
8
+ .executable(name: "TopogramSwiftUIApp", targets: ["TopogramSwiftUIApp"])
9
9
  ],
10
10
  targets: [
11
11
  .executableTarget(
12
- name: "TodoSwiftUIApp",
13
- path: "Sources/TodoSwiftUIApp",
12
+ name: "TopogramSwiftUIApp",
13
+ path: "Sources/TopogramSwiftUIApp",
14
14
  resources: [
15
15
  .copy("Resources/api-contracts.json"),
16
16
  .copy("Resources/ui-surface-contract.json")
@@ -1,4 +1,4 @@
1
- # Todo SwiftUI (generated)
1
+ # Topogram SwiftUI (generated)
2
2
 
3
3
  Apple SwiftUI client generated from the same **`buildWebRealization`** routed UI contract as the web stacks. Prefer the **`proj_ios_surface__swiftui`** projection when present; otherwise the generator falls back to a **`proj_web_surface__*`** projection (often **`proj_web_surface__sveltekit`**).
4
4
 
@@ -9,12 +9,12 @@ Apple SwiftUI client generated from the same **`buildWebRealization`** routed UI
9
9
 
10
10
  ## Run
11
11
 
12
- Open **`Package.swift`** in Xcode 15+ and run the **`TodoSwiftUIApp`** scheme on an iOS Simulator.
12
+ Open **`Package.swift`** in Xcode 15+ and run the **`TopogramSwiftUIApp`** scheme on an iOS Simulator.
13
13
 
14
- Configure the API base URL and demo auth token via scheme environment variables (mirror web):
14
+ Configure the API base URL and optional auth token via scheme environment variables (mirror web):
15
15
 
16
16
  - `PUBLIC_TOPOGRAM_API_BASE_URL` (default `http://localhost:3000`)
17
- - `PUBLIC_TOPOGRAM_DEMO_AUTH_TOKEN`
17
+ - `PUBLIC_TOPOGRAM_AUTH_TOKEN`
18
18
  - Optional JWT / permission env vars matching web `visibility.ts` (`PUBLIC_TOPOGRAM_AUTH_*`).
19
19
 
20
20
  ## Regenerate
@@ -1,12 +1,12 @@
1
1
  import SwiftUI
2
2
 
3
3
  /// Holds decoded ui-surface-contract.json for runtime-driven navigation (parity with web bundle).
4
- public final class TodoUiContract: ObservableObject {
4
+ public final class TopogramUiContract: ObservableObject {
5
5
  public let raw: [String: Any]
6
6
 
7
7
  public init(data: Data) throws {
8
8
  guard let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
9
- throw NSError(domain: "TodoUiContract", code: 1)
9
+ throw NSError(domain: "TopogramUiContract", code: 1)
10
10
  }
11
11
  self.raw = obj
12
12
  }
@@ -14,7 +14,7 @@ public final class TodoUiContract: ObservableObject {
14
14
  public static func loadBundled() throws -> Data {
15
15
  guard let url = Bundle.module.url(forResource: "ui-surface-contract", withExtension: "json"),
16
16
  let data = try? Data(contentsOf: url) else {
17
- throw NSError(domain: "TodoUiContract", code: 2, userInfo: [NSLocalizedDescriptionKey: "Missing ui-surface-contract.json"])
17
+ throw NSError(domain: "TopogramUiContract", code: 2, userInfo: [NSLocalizedDescriptionKey: "Missing ui-surface-contract.json"])
18
18
  }
19
19
  return data
20
20
  }
@@ -27,6 +27,12 @@ public final class TodoUiContract: ObservableObject {
27
27
  screens.first { ($0["id"] as? String) == id }
28
28
  }
29
29
 
30
+ public func firstScreen(kind: String, excluding excludedId: String? = nil) -> [String: Any]? {
31
+ screens.first {
32
+ ($0["kind"] as? String) == kind && ($0["id"] as? String) != excludedId
33
+ }
34
+ }
35
+
30
36
  public var tabItems: [NavigationItem] {
31
37
  guard let nav = raw["navigation"] as? [String: Any],
32
38
  let items = nav["items"] as? [[String: Any]] else {
@@ -63,10 +69,10 @@ public struct NavigationItem: Identifiable {
63
69
  }
64
70
 
65
71
  public struct RootTabView: View {
66
- @ObservedObject var contract: TodoUiContract
67
- let client: TodoAPIClient
72
+ @ObservedObject var contract: TopogramUiContract
73
+ let client: TopogramAPIClient
68
74
 
69
- public init(contract: TodoUiContract, client: TodoAPIClient) {
75
+ public init(contract: TopogramUiContract, client: TopogramAPIClient) {
70
76
  self.contract = contract
71
77
  self.client = client
72
78
  }
@@ -85,11 +91,11 @@ public struct RootTabView: View {
85
91
  }
86
92
 
87
93
  public struct DynamicScreenView: View {
88
- @ObservedObject var contract: TodoUiContract
89
- let client: TodoAPIClient
94
+ @ObservedObject var contract: TopogramUiContract
95
+ let client: TopogramAPIClient
90
96
  let rootScreenId: String
91
97
 
92
- public init(contract: TodoUiContract, client: TodoAPIClient, rootScreenId: String) {
98
+ public init(contract: TopogramUiContract, client: TopogramAPIClient, rootScreenId: String) {
93
99
  self.contract = contract
94
100
  self.client = client
95
101
  self.rootScreenId = rootScreenId
@@ -105,8 +111,8 @@ public struct DynamicScreenView: View {
105
111
  }
106
112
 
107
113
  struct ScreenSwitcher: View {
108
- @ObservedObject var contract: TodoUiContract
109
- let client: TodoAPIClient
114
+ @ObservedObject var contract: TopogramUiContract
115
+ let client: TopogramAPIClient
110
116
  let screen: [String: Any]
111
117
  let baseParams: [String: String]
112
118
 
@@ -136,8 +142,8 @@ struct ScreenSwitcher: View {
136
142
  // MARK: - List
137
143
 
138
144
  struct ListScreen: View {
139
- @ObservedObject var contract: TodoUiContract
140
- let client: TodoAPIClient
145
+ @ObservedObject var contract: TopogramUiContract
146
+ let client: TopogramAPIClient
141
147
  let screen: [String: Any]
142
148
  let baseParams: [String: String]
143
149
  @State private var rows: [[String: Any]] = []
@@ -148,10 +154,11 @@ struct ListScreen: View {
148
154
  let title = screen["title"] as? String ?? "List"
149
155
  let loadCap = (screen["loadCapability"] as? [String: Any])?["id"] as? String
150
156
  let screenId = screen["id"] as? String ?? ""
157
+ let filterFields = loadCap.map { client.queryFieldNames(for: $0) } ?? []
151
158
 
152
159
  VStack(alignment: .leading, spacing: 8) {
153
- if screenId == "task_list" {
154
- filterBar
160
+ if let loadCap, !filterFields.isEmpty {
161
+ filterBar(loadCap: loadCap, fields: filterFields)
155
162
  }
156
163
  if let errorText {
157
164
  Text(errorText).foregroundStyle(.red)
@@ -161,7 +168,7 @@ struct ListScreen: View {
161
168
  List(0 ..< rows.count, id: \.self) { i in
162
169
  let row = rows[i]
163
170
  NavigationLink {
164
- linkedDetail(row: row, listScreenId: screenId)
171
+ linkedDetail(row: row)
165
172
  } label: {
166
173
  VStack(alignment: .leading) {
167
174
  Text(rowTitle(row))
@@ -181,15 +188,10 @@ struct ListScreen: View {
181
188
  Visibility.canShowAction(pid, screen: screen, resource: nil) {
182
189
  ToolbarItem(placement: .primaryAction) {
183
190
  NavigationLink("Add") {
184
- linkedCreate(listScreenId: screenId)
191
+ linkedCreate(currentScreenId: screenId)
185
192
  }
186
193
  }
187
194
  }
188
- if screenId == "task_list", Visibility.canShowAction("cap_export_tasks", screen: screen, resource: nil) {
189
- ToolbarItem(placement: .automatic) {
190
- ExportTasksButton(client: client, contract: contract)
191
- }
192
- }
193
195
  }
194
196
  .task {
195
197
  if let loadCap {
@@ -199,18 +201,18 @@ struct ListScreen: View {
199
201
  }
200
202
 
201
203
  @ViewBuilder
202
- private var filterBar: some View {
204
+ private func filterBar(loadCap: String, fields: [String]) -> some View {
203
205
  VStack(alignment: .leading) {
204
206
  Text("Filters").font(.caption).foregroundStyle(.secondary)
205
207
  HStack {
206
- TextField("project_id", text: bindingFilter("project_id"))
207
- TextField("owner_id", text: bindingFilter("owner_id"))
208
- TextField("status", text: bindingFilter("status"))
208
+ ForEach(fields, id: \.self) { field in
209
+ TextField(field, text: bindingFilter(field))
210
+ }
209
211
  }
210
212
  .textFieldStyle(.roundedBorder)
211
213
  Button("Apply") {
212
214
  Task {
213
- await reload(loadCap: "cap_list_tasks")
215
+ await reload(loadCap: loadCap)
214
216
  }
215
217
  }
216
218
  }
@@ -237,29 +239,19 @@ struct ListScreen: View {
237
239
  }
238
240
 
239
241
  @ViewBuilder
240
- private func linkedDetail(row: [String: Any], listScreenId: String) -> some View {
241
- let rid = row["id"] as? String ?? ""
242
- if listScreenId.contains("task") {
243
- DetailScreen(contract: contract, client: client, screen: contract.screen(id: "task_detail") ?? [:], params: ["task_id": rid])
244
- } else if listScreenId.contains("project") {
245
- DetailScreen(contract: contract, client: client, screen: contract.screen(id: "project_detail") ?? [:], params: ["project_id": rid])
246
- } else if listScreenId.contains("user") {
247
- DetailScreen(contract: contract, client: client, screen: contract.screen(id: "user_detail") ?? [:], params: ["user_id": rid])
242
+ private func linkedDetail(row: [String: Any]) -> some View {
243
+ if let detail = contract.firstScreen(kind: "detail") {
244
+ DetailScreen(contract: contract, client: client, screen: detail, params: navigationParams(for: detail, row: row, client: client))
248
245
  } else {
249
- Text("Detail")
246
+ RowDetailFallback(row: row)
250
247
  }
251
248
  }
252
249
 
253
250
  @ViewBuilder
254
- private func linkedCreate(listScreenId: String) -> some View {
255
- switch listScreenId {
256
- case "project_list":
257
- FormScreen(client: client, contract: contract, screen: contract.screen(id: "project_create") ?? [:], params: [:])
258
- case "user_list":
259
- FormScreen(client: client, contract: contract, screen: contract.screen(id: "user_create") ?? [:], params: [:])
260
- case "task_list", "task_board", "task_calendar":
261
- FormScreen(client: client, contract: contract, screen: contract.screen(id: "task_create") ?? [:], params: [:])
262
- default:
251
+ private func linkedCreate(currentScreenId: String) -> some View {
252
+ if let form = contract.firstScreen(kind: "form", excluding: currentScreenId) ?? contract.firstScreen(kind: "wizard", excluding: currentScreenId) {
253
+ FormScreen(client: client, contract: contract, screen: form, params: [:])
254
+ } else {
263
255
  Text("Create")
264
256
  }
265
257
  }
@@ -279,8 +271,8 @@ private func rowSubtitle(_ row: [String: Any]) -> String {
279
271
  // MARK: - Detail
280
272
 
281
273
  struct DetailScreen: View {
282
- @ObservedObject var contract: TodoUiContract
283
- let client: TodoAPIClient
274
+ @ObservedObject var contract: TopogramUiContract
275
+ let client: TopogramAPIClient
284
276
  let screen: [String: Any]
285
277
  let params: [String: String]
286
278
  @State private var payload: [String: Any]?
@@ -310,7 +302,7 @@ struct DetailScreen: View {
310
302
  }
311
303
  .navigationTitle((screen["title"] as? String) ?? "Detail")
312
304
  .toolbar {
313
- ToolbarItemGroup(placement: .bottomBar) {
305
+ ToolbarItemGroup(placement: .automatic) {
314
306
  if let primary = (screen["actions"] as? [String: Any])?["primary"] as? [String: Any],
315
307
  let cid = primary["id"] as? String {
316
308
  NavigationLink("Edit") {
@@ -353,19 +345,12 @@ struct DetailScreen: View {
353
345
 
354
346
  @MainActor
355
347
  private func runSecondary(_ capabilityId: String) async {
356
- guard capabilityId == "cap_complete_task",
357
- let taskId = params["task_id"] ?? params["id"],
358
- let detail = payload else { return }
359
- let etag = String(describing: detail["updated_at"] ?? "")
360
348
  var headers: [String: String] = [:]
361
- if !etag.isEmpty { headers["If-Match"] = etag }
362
- let body: [String: Any] = [
363
- "task_id": taskId,
364
- "completed": true,
365
- "idempotency_key": UUID().uuidString
366
- ]
349
+ if let detail = payload, let updatedAt = detail["updated_at"] {
350
+ headers["If-Match"] = String(describing: updatedAt)
351
+ }
367
352
  do {
368
- _ = try await client.requestCapability(capabilityId, input: body, extraHeaders: headers)
353
+ _ = try await client.requestCapability(capabilityId, input: params, extraHeaders: headers)
369
354
  await load()
370
355
  } catch {
371
356
  errorText = String(describing: error)
@@ -374,13 +359,8 @@ struct DetailScreen: View {
374
359
 
375
360
  @MainActor
376
361
  private func runDestructive(_ capabilityId: String) async {
377
- guard let rid = params["task_id"] ?? params["project_id"] ?? params["user_id"] ?? params["id"] else { return }
378
- var input: [String: Any] = [:]
379
- if capabilityId.contains("task") { input["task_id"] = rid }
380
- if capabilityId.contains("project") { input["project_id"] = rid }
381
- if capabilityId.contains("user") { input["user_id"] = rid }
382
362
  do {
383
- _ = try await client.requestCapability(capabilityId, input: input)
363
+ _ = try await client.requestCapability(capabilityId, input: params)
384
364
  } catch {
385
365
  errorText = String(describing: error)
386
366
  }
@@ -388,16 +368,22 @@ struct DetailScreen: View {
388
368
 
389
369
  @ViewBuilder
390
370
  private func editForm(cid: String) -> some View {
391
- let sid = cid.contains("project") ? "project_edit" : cid.contains("user") ? "user_edit" : "task_edit"
392
- FormScreen(client: client, contract: contract, screen: contract.screen(id: sid) ?? [:], params: params)
371
+ if let form = contract.screens.first(where: { screen in
372
+ guard let submit = (screen["submitCapability"] as? [String: Any])?["id"] as? String else { return false }
373
+ return submit == cid
374
+ }) ?? contract.firstScreen(kind: "form") {
375
+ FormScreen(client: client, contract: contract, screen: form, params: params)
376
+ } else {
377
+ Text("Edit")
378
+ }
393
379
  }
394
380
  }
395
381
 
396
382
  // MARK: - Form
397
383
 
398
384
  struct FormScreen: View {
399
- let client: TodoAPIClient
400
- @ObservedObject var contract: TodoUiContract
385
+ let client: TopogramAPIClient
386
+ @ObservedObject var contract: TopogramUiContract
401
387
  let screen: [String: Any]
402
388
  let params: [String: String]
403
389
  @State private var values: [String: String] = [:]
@@ -434,23 +420,16 @@ struct FormScreen: View {
434
420
  }
435
421
 
436
422
  private func preload() async {
437
- guard let sid = screen["id"] as? String else { return }
438
- if sid.contains("edit") {
439
- let getCap = sid.contains("task") ? "cap_get_task" : sid.contains("project") ? "cap_get_project" : "cap_get_user"
440
- var p = params
441
- if sid.contains("task") { p["task_id"] = params["task_id"] ?? params["id"] ?? "" }
442
- if sid.contains("project") { p["project_id"] = params["project_id"] ?? params["id"] ?? "" }
443
- if sid.contains("user") { p["user_id"] = params["user_id"] ?? params["id"] ?? "" }
444
- do {
445
- let json = try await client.requestCapability(getCap, input: p)
446
- if let dict = json as? [String: Any] {
447
- for key in dict.keys {
448
- values[key] = String(describing: dict[key] ?? "")
449
- }
423
+ guard let loadCap = (screen["loadCapability"] as? [String: Any])?["id"] as? String else { return }
424
+ do {
425
+ let json = try await client.requestCapability(loadCap, input: params)
426
+ if let dict = json as? [String: Any] {
427
+ for key in dict.keys {
428
+ values[key] = String(describing: dict[key] ?? "")
450
429
  }
451
- } catch {
452
- message = String(describing: error)
453
430
  }
431
+ } catch {
432
+ message = String(describing: error)
454
433
  }
455
434
  }
456
435
 
@@ -466,7 +445,7 @@ struct FormScreen: View {
466
445
  input[k] = v
467
446
  }
468
447
  var headers: [String: String] = [:]
469
- if submitId == "cap_update_task", let etag = values["updated_at"] {
448
+ if let etag = values["updated_at"], !etag.isEmpty {
470
449
  headers["If-Match"] = etag
471
450
  }
472
451
  do {
@@ -478,59 +457,11 @@ struct FormScreen: View {
478
457
  }
479
458
  }
480
459
 
481
- extension TodoAPIClient {
482
- func bodyFieldNames(for capabilityId: String) -> [String] {
483
- guard let contract = contracts[capabilityId] as? [String: Any],
484
- let rc = contract["requestContract"] as? [String: Any],
485
- let transport = rc["transport"] as? [String: Any],
486
- let body = transport["body"] as? [[String: Any]] else {
487
- return []
488
- }
489
- return body.compactMap { $0["name"] as? String }
490
- }
491
- }
492
-
493
- struct ExportTasksButton: View {
494
- let client: TodoAPIClient
495
- @ObservedObject var contract: TodoUiContract
496
- @State private var jobId: String?
497
-
498
- var body: some View {
499
- Group {
500
- if let jobId {
501
- NavigationLink("Export job") {
502
- JobStatusScreen(
503
- client: client,
504
- contract: contract,
505
- screen: contract.screen(id: "task_exports") ?? [:],
506
- params: ["job_id": jobId]
507
- )
508
- }
509
- } else {
510
- Button("Export") {
511
- Task { await run() }
512
- }
513
- }
514
- }
515
- }
516
-
517
- private func run() async {
518
- do {
519
- let json = try await client.requestCapability("cap_export_tasks", input: [:])
520
- if let dict = json as? [String: Any], let id = dict["job_id"] as? String {
521
- jobId = id
522
- }
523
- } catch {
524
- jobId = nil
525
- }
526
- }
527
- }
528
-
529
460
  // MARK: - Board & Calendar
530
461
 
531
462
  struct BoardScreen: View {
532
- @ObservedObject var contract: TodoUiContract
533
- let client: TodoAPIClient
463
+ @ObservedObject var contract: TopogramUiContract
464
+ let client: TopogramAPIClient
534
465
  let screen: [String: Any]
535
466
  let baseParams: [String: String]
536
467
  @State private var rows: [[String: Any]] = []
@@ -549,8 +480,8 @@ struct BoardScreen: View {
549
480
  DetailScreen(
550
481
  contract: contract,
551
482
  client: client,
552
- screen: contract.screen(id: "task_detail") ?? [:],
553
- params: ["task_id": row["id"] as? String ?? ""]
483
+ screen: contract.firstScreen(kind: "detail") ?? [:],
484
+ params: navigationParams(for: contract.firstScreen(kind: "detail") ?? [:], row: row, client: client)
554
485
  )
555
486
  } label: {
556
487
  Text(rowTitle(row))
@@ -578,8 +509,8 @@ struct BoardScreen: View {
578
509
  }
579
510
 
580
511
  struct CalendarScreen: View {
581
- @ObservedObject var contract: TodoUiContract
582
- let client: TodoAPIClient
512
+ @ObservedObject var contract: TopogramUiContract
513
+ let client: TopogramAPIClient
583
514
  let screen: [String: Any]
584
515
  let baseParams: [String: String]
585
516
  @State private var rows: [[String: Any]] = []
@@ -602,8 +533,8 @@ struct CalendarScreen: View {
602
533
  DetailScreen(
603
534
  contract: contract,
604
535
  client: client,
605
- screen: contract.screen(id: "task_detail") ?? [:],
606
- params: ["task_id": row["id"] as? String ?? ""]
536
+ screen: contract.firstScreen(kind: "detail") ?? [:],
537
+ params: navigationParams(for: contract.firstScreen(kind: "detail") ?? [:], row: row, client: client)
607
538
  )
608
539
  } label: {
609
540
  Text(rowTitle(row))
@@ -630,8 +561,8 @@ struct CalendarScreen: View {
630
561
  }
631
562
 
632
563
  struct JobStatusScreen: View {
633
- let client: TodoAPIClient
634
- @ObservedObject var contract: TodoUiContract
564
+ let client: TopogramAPIClient
565
+ @ObservedObject var contract: TopogramUiContract
635
566
  let screen: [String: Any]
636
567
  let params: [String: String]
637
568
  @State private var job: [String: Any]?
@@ -672,11 +603,45 @@ struct JobStatusScreen: View {
672
603
  }
673
604
 
674
605
  private func download(_ capabilityId: String) async {
675
- let jobId = params["job_id"] ?? ""
676
606
  do {
677
- _ = try await client.requestCapability(capabilityId, input: ["job_id": jobId])
607
+ _ = try await client.requestCapability(capabilityId, input: params)
678
608
  } catch {
679
609
  // ignore download errors in stub
680
610
  }
681
611
  }
682
612
  }
613
+
614
+ struct RowDetailFallback: View {
615
+ let row: [String: Any]
616
+
617
+ var body: some View {
618
+ List(Array(row.keys.sorted()), id: \.self) { key in
619
+ HStack {
620
+ Text(key).foregroundStyle(.secondary)
621
+ Spacer()
622
+ Text(String(describing: row[key] ?? ""))
623
+ }
624
+ }
625
+ .navigationTitle(rowTitle(row))
626
+ }
627
+ }
628
+
629
+ private func navigationParams(for screen: [String: Any], row: [String: Any], client: TopogramAPIClient) -> [String: String] {
630
+ guard let cap = (screen["loadCapability"] as? [String: Any])?["id"] as? String else {
631
+ return row.compactMapValues { $0 as? String }
632
+ }
633
+ let pathFields = client.pathFieldNames(for: cap)
634
+ if pathFields.isEmpty {
635
+ return row.compactMapValues { $0 as? String }
636
+ }
637
+ var params: [String: String] = [:]
638
+ let fallbackId = row["id"].map { String(describing: $0) } ?? ""
639
+ for field in pathFields {
640
+ if let value = row[field] {
641
+ params[field] = String(describing: value)
642
+ } else if !fallbackId.isEmpty {
643
+ params[field] = fallbackId
644
+ }
645
+ }
646
+ return params
647
+ }
@@ -1,13 +1,13 @@
1
1
  import Foundation
2
2
 
3
3
  /// Dynamic capability client mirroring `src/lib/api/client.ts` (requestCapability over bundled api-contracts.json).
4
- public final class TodoAPIClient: @unchecked Sendable {
4
+ public final class TopogramAPIClient: @unchecked Sendable {
5
5
  private let session: URLSession
6
6
  let contracts: [String: Any]
7
7
  public init(session: URLSession = .shared, contractsData: Data) throws {
8
8
  self.session = session
9
9
  guard let root = try JSONSerialization.jsonObject(with: contractsData) as? [String: Any] else {
10
- throw TodoAPIError.invalidContracts
10
+ throw TopogramAPIError.invalidContracts
11
11
  }
12
12
  self.contracts = root
13
13
  }
@@ -15,7 +15,7 @@ public final class TodoAPIClient: @unchecked Sendable {
15
15
  public static func loadBundledContracts() throws -> Data {
16
16
  guard let url = Bundle.module.url(forResource: "api-contracts", withExtension: "json"),
17
17
  let data = try? Data(contentsOf: url) else {
18
- throw TodoAPIError.missingResource("api-contracts.json")
18
+ throw TopogramAPIError.missingResource("api-contracts.json")
19
19
  }
20
20
  return data
21
21
  }
@@ -26,13 +26,34 @@ public final class TodoAPIClient: @unchecked Sendable {
26
26
  }
27
27
 
28
28
  private func authToken() -> String {
29
- ProcessInfo.processInfo.environment["PUBLIC_TOPOGRAM_DEMO_AUTH_TOKEN"] ?? ""
29
+ ProcessInfo.processInfo.environment["PUBLIC_TOPOGRAM_AUTH_TOKEN"] ?? ""
30
+ }
31
+
32
+ private func transportFields(for capabilityId: String, section: String) -> [[String: Any]] {
33
+ guard let contract = contracts[capabilityId] as? [String: Any],
34
+ let requestContract = contract["requestContract"] as? [String: Any],
35
+ let transport = requestContract["transport"] as? [String: Any] else {
36
+ return []
37
+ }
38
+ return transport[section] as? [[String: Any]] ?? []
39
+ }
40
+
41
+ public func pathFieldNames(for capabilityId: String) -> [String] {
42
+ transportFields(for: capabilityId, section: "path").compactMap { $0["name"] as? String }
43
+ }
44
+
45
+ public func queryFieldNames(for capabilityId: String) -> [String] {
46
+ transportFields(for: capabilityId, section: "query").compactMap { $0["name"] as? String }
47
+ }
48
+
49
+ public func bodyFieldNames(for capabilityId: String) -> [String] {
50
+ transportFields(for: capabilityId, section: "body").compactMap { $0["name"] as? String }
30
51
  }
31
52
 
32
53
  private func buildPath(contract: [String: Any], input: [String: Any]) throws -> String {
33
54
  guard let endpoint = contract["endpoint"] as? [String: Any],
34
55
  let rawPath = endpoint["path"] as? String else {
35
- throw TodoAPIError.invalidContracts
56
+ throw TopogramAPIError.invalidContracts
36
57
  }
37
58
  var path = rawPath
38
59
  let requestContract = contract["requestContract"] as? [String: Any]
@@ -78,15 +99,15 @@ public final class TodoAPIClient: @unchecked Sendable {
78
99
  extraHeaders: [String: String] = [:]
79
100
  ) async throws -> Any {
80
101
  guard let contract = contracts[capabilityId] as? [String: Any] else {
81
- throw TodoAPIError.unknownCapability(capabilityId)
102
+ throw TopogramAPIError.unknownCapability(capabilityId)
82
103
  }
83
104
  guard let endpoint = contract["endpoint"] as? [String: Any],
84
105
  let method = endpoint["method"] as? String else {
85
- throw TodoAPIError.invalidContracts
106
+ throw TopogramAPIError.invalidContracts
86
107
  }
87
108
  let path = try buildPath(contract: contract, input: input)
88
109
  guard let url = URL(string: path, relativeTo: URL(string: apiBase()))?.absoluteURL else {
89
- throw TodoAPIError.badURL(path)
110
+ throw TopogramAPIError.badURL(path)
90
111
  }
91
112
  var request = URLRequest(url: url)
92
113
  request.httpMethod = method
@@ -121,11 +142,11 @@ public final class TodoAPIClient: @unchecked Sendable {
121
142
  let downloadable = endpoint["download"] as? [[String: Any]] ?? []
122
143
  let (data, response) = try await session.data(for: request)
123
144
  guard let http = response as? HTTPURLResponse else {
124
- throw TodoAPIError.invalidResponse
145
+ throw TopogramAPIError.invalidResponse
125
146
  }
126
147
  guard (200 ..< 300).contains(http.statusCode) else {
127
148
  let text = String(data: data, encoding: .utf8) ?? ""
128
- throw TodoAPIError.http(http.statusCode, text)
149
+ throw TopogramAPIError.http(http.statusCode, text)
129
150
  }
130
151
  if http.statusCode == 204 {
131
152
  return NSNull()
@@ -146,7 +167,7 @@ public final class TodoAPIClient: @unchecked Sendable {
146
167
  }
147
168
  }
148
169
 
149
- public enum TodoAPIError: Error {
170
+ public enum TopogramAPIError: Error {
150
171
  case invalidContracts
151
172
  case missingResource(String)
152
173
  case unknownCapability(String)
@@ -1,7 +1,7 @@
1
1
  import SwiftUI
2
2
 
3
3
  @main
4
- struct TodoSwiftUIApp: App {
4
+ struct TopogramSwiftUIApp: App {
5
5
  @StateObject private var contractHolder = ContractHolder()
6
6
 
7
7
  var body: some Scene {
@@ -13,7 +13,7 @@ struct TodoSwiftUIApp: App {
13
13
  Text("Failed to load Topogram UI contract: \(err)")
14
14
  .padding()
15
15
  } else {
16
- ProgressView("Loading Topogram Todo…")
16
+ ProgressView("Loading Topogram UI…")
17
17
  }
18
18
  }
19
19
  .task {
@@ -25,16 +25,16 @@ struct TodoSwiftUIApp: App {
25
25
 
26
26
  @MainActor
27
27
  final class ContractHolder: ObservableObject {
28
- @Published var contract: TodoUiContract?
29
- @Published var client: TodoAPIClient?
28
+ @Published var contract: TopogramUiContract?
29
+ @Published var client: TopogramAPIClient?
30
30
  @Published var error: String?
31
31
 
32
32
  func bootstrap() async {
33
33
  do {
34
- let uiData = try TodoUiContract.loadBundled()
35
- let apiData = try TodoAPIClient.loadBundledContracts()
36
- let ui = try TodoUiContract(data: uiData)
37
- let cli = try TodoAPIClient(contractsData: apiData)
34
+ let uiData = try TopogramUiContract.loadBundled()
35
+ let apiData = try TopogramAPIClient.loadBundledContracts()
36
+ let ui = try TopogramUiContract(data: uiData)
37
+ let cli = try TopogramAPIClient(contractsData: apiData)
38
38
  contract = ui
39
39
  client = cli
40
40
  } catch {
@@ -108,11 +108,12 @@ public enum Visibility {
108
108
  }
109
109
 
110
110
  private static func currentPrincipal(overrides: PrincipalOverride?) -> Principal? {
111
- let token = env("PUBLIC_TOPOGRAM_DEMO_AUTH_TOKEN")
111
+ let token = env("PUBLIC_TOPOGRAM_AUTH_TOKEN")
112
112
  let jwtPrincipal = token.isEmpty ? nil : principalFromJwt(token)
113
113
  let envClaims = parseClaims(env("PUBLIC_TOPOGRAM_AUTH_CLAIMS"))
114
+ let envUserId = env("PUBLIC_TOPOGRAM_AUTH_USER_ID")
114
115
  let userId = overrides?.userId
115
- ?? env("PUBLIC_TOPOGRAM_AUTH_USER_ID")
116
+ ?? (!envUserId.isEmpty ? envUserId : nil)
116
117
  ?? jwtPrincipal?.userId
117
118
  ?? ""
118
119
 
@@ -305,128 +305,128 @@ function validateOutputConfig(errors, config) {
305
305
  }
306
306
 
307
307
  /**
308
- * @param {any} component
308
+ * @param {any} runtime
309
309
  * @returns {string}
310
310
  */
311
- function componentLabel(component) {
312
- return component?.id ? `Runtime '${component.id}'` : "Topology runtime";
311
+ function runtimeLabel(runtime) {
312
+ return runtime?.id ? `Runtime '${runtime.id}'` : "Topology runtime";
313
313
  }
314
314
 
315
315
  /**
316
316
  * @param {ValidationError[]} errors
317
- * @param {any} component
317
+ * @param {any} runtime
318
318
  * @param {Set<string>} seenIds
319
319
  * @returns {boolean}
320
320
  */
321
- function validateComponentShape(errors, component, seenIds) {
322
- if (!component || typeof component !== "object" || Array.isArray(component)) {
321
+ function validateRuntimeShape(errors, runtime, seenIds) {
322
+ if (!runtime || typeof runtime !== "object" || Array.isArray(runtime)) {
323
323
  pushError(errors, "Topology runtime must be an object");
324
324
  return false;
325
325
  }
326
- if (typeof component.id !== "string" || !IDENTIFIER_PATTERN.test(component.id)) {
327
- pushError(errors, `${componentLabel(component)} id must match ${IDENTIFIER_PATTERN}`);
328
- } else if (seenIds.has(component.id)) {
329
- pushError(errors, `Duplicate topology runtime id '${component.id}'`);
326
+ if (typeof runtime.id !== "string" || !IDENTIFIER_PATTERN.test(runtime.id)) {
327
+ pushError(errors, `${runtimeLabel(runtime)} id must match ${IDENTIFIER_PATTERN}`);
328
+ } else if (seenIds.has(runtime.id)) {
329
+ pushError(errors, `Duplicate topology runtime id '${runtime.id}'`);
330
330
  } else {
331
- seenIds.add(component.id);
331
+ seenIds.add(runtime.id);
332
332
  }
333
- if (component.type != null) {
334
- pushError(errors, `${componentLabel(component)} ${renameDiagnostic("'type'", "'kind'", `"kind": "api_service"`)}`);
333
+ if (runtime.type != null) {
334
+ pushError(errors, `${runtimeLabel(runtime)} ${renameDiagnostic("'type'", "'kind'", `"kind": "api_service"`)}`);
335
335
  }
336
- if (component.database != null) {
337
- pushError(errors, `${componentLabel(component)} ${renameDiagnostic("'database'", "'uses_database'", `"uses_database": "app_db"`)}`);
336
+ if (runtime.database != null) {
337
+ pushError(errors, `${runtimeLabel(runtime)} ${renameDiagnostic("'database'", "'uses_database'", `"uses_database": "app_db"`)}`);
338
338
  }
339
- if (component.api != null) {
340
- pushError(errors, `${componentLabel(component)} ${renameDiagnostic("'api'", "'uses_api'", `"uses_api": "app_api"`)}`);
339
+ if (runtime.api != null) {
340
+ pushError(errors, `${runtimeLabel(runtime)} ${renameDiagnostic("'api'", "'uses_api'", `"uses_api": "app_api"`)}`);
341
341
  }
342
- if (!["api_service", "web_surface", "ios_surface", "android_surface", "database"].includes(component.kind)) {
343
- pushError(errors, `${componentLabel(component)} kind must be api_service, web_surface, ios_surface, android_surface, or database`);
342
+ if (!["api_service", "web_surface", "ios_surface", "android_surface", "database"].includes(runtime.kind)) {
343
+ pushError(errors, `${runtimeLabel(runtime)} kind must be api_service, web_surface, ios_surface, android_surface, or database`);
344
344
  }
345
- if (typeof component.projection !== "string" || component.projection.length === 0) {
346
- pushError(errors, `${componentLabel(component)} projection must be a non-empty string`);
345
+ if (typeof runtime.projection !== "string" || runtime.projection.length === 0) {
346
+ pushError(errors, `${runtimeLabel(runtime)} projection must be a non-empty string`);
347
347
  }
348
- if (!component.generator || typeof component.generator !== "object") {
349
- pushError(errors, `${componentLabel(component)} generator must be an object`);
348
+ if (!runtime.generator || typeof runtime.generator !== "object") {
349
+ pushError(errors, `${runtimeLabel(runtime)} generator must be an object`);
350
350
  } else {
351
- if (typeof component.generator.id !== "string" || component.generator.id.length === 0) {
352
- pushError(errors, `${componentLabel(component)} generator.id must be a non-empty string`);
351
+ if (typeof runtime.generator.id !== "string" || runtime.generator.id.length === 0) {
352
+ pushError(errors, `${runtimeLabel(runtime)} generator.id must be a non-empty string`);
353
353
  }
354
- if (typeof component.generator.version !== "string" || component.generator.version.length === 0) {
355
- pushError(errors, `${componentLabel(component)} generator.version must be a non-empty string`);
354
+ if (typeof runtime.generator.version !== "string" || runtime.generator.version.length === 0) {
355
+ pushError(errors, `${runtimeLabel(runtime)} generator.version must be a non-empty string`);
356
356
  }
357
- if (component.generator.package != null && (typeof component.generator.package !== "string" || component.generator.package.length === 0)) {
358
- pushError(errors, `${componentLabel(component)} generator.package must be a non-empty string when provided`);
357
+ if (runtime.generator.package != null && (typeof runtime.generator.package !== "string" || runtime.generator.package.length === 0)) {
358
+ pushError(errors, `${runtimeLabel(runtime)} generator.package must be a non-empty string when provided`);
359
359
  }
360
360
  }
361
- if (component.port != null && (!Number.isInteger(component.port) || component.port <= 0 || component.port > 65535)) {
362
- pushError(errors, `${componentLabel(component)} port must be an integer from 1 to 65535`);
361
+ if (runtime.port != null && (!Number.isInteger(runtime.port) || runtime.port <= 0 || runtime.port > 65535)) {
362
+ pushError(errors, `${runtimeLabel(runtime)} port must be an integer from 1 to 65535`);
363
363
  }
364
364
  return true;
365
365
  }
366
366
 
367
367
  /**
368
368
  * @param {ValidationError[]} errors
369
- * @param {RuntimeTopologyRuntime} component
369
+ * @param {RuntimeTopologyRuntime} runtime
370
370
  * @param {Map<string, Record<string, any>>} projections
371
371
  * @param {{ configDir?: string|null, rootDir?: string|null }} [options]
372
372
  * @returns {void}
373
373
  */
374
- function validateComponentCompatibility(errors, component, projections, options = {}) {
375
- const projection = projections.get(component.projection);
374
+ function validateRuntimeCompatibility(errors, runtime, projections, options = {}) {
375
+ const projection = projections.get(runtime.projection);
376
376
  if (!projection) {
377
- pushError(errors, `${componentLabel(component)} references missing projection '${component.projection}'`);
377
+ pushError(errors, `${runtimeLabel(runtime)} references missing projection '${runtime.projection}'`);
378
378
  return;
379
379
  }
380
380
 
381
- const resolvedManifest = resolveGeneratorManifestForBinding(component.generator, options);
381
+ const resolvedManifest = resolveGeneratorManifestForBinding(runtime.generator, options);
382
382
  const manifest = resolvedManifest.manifest;
383
383
  if (!manifest) {
384
384
  const details = resolvedManifest.errors.length > 0 ? `: ${resolvedManifest.errors.join("; ")}` : "";
385
- pushError(errors, `${componentLabel(component)} for projection '${projection.id}' uses unknown generator '${component.generator?.id}' version '${component.generator?.version || "unknown"}'${details}`);
385
+ pushError(errors, `${runtimeLabel(runtime)} for projection '${projection.id}' uses unknown generator '${runtime.generator?.id}' version '${runtime.generator?.version || "unknown"}'${details}`);
386
386
  return;
387
387
  }
388
388
  const manifestValidation = validateGeneratorManifest(manifest);
389
389
  if (!manifestValidation.ok) {
390
390
  for (const message of manifestValidation.errors) {
391
- pushError(errors, `${componentLabel(component)} generator manifest invalid: ${message}`);
391
+ pushError(errors, `${runtimeLabel(runtime)} generator manifest invalid: ${message}`);
392
392
  }
393
393
  }
394
394
  if (manifest.planned) {
395
- pushError(errors, `${componentLabel(component)} for projection '${projection.id}' uses planned generator '${manifest.id}@${manifest.version}', which is not implemented yet`);
395
+ pushError(errors, `${runtimeLabel(runtime)} for projection '${projection.id}' uses planned generator '${manifest.id}@${manifest.version}', which is not implemented yet`);
396
396
  }
397
- if (manifest.version !== component.generator.version) {
398
- pushError(errors, `${componentLabel(component)} for projection '${projection.id}' generator '${manifest.id}' version '${component.generator.version}' is unsupported; expected '${manifest.version}'`);
397
+ if (manifest.version !== runtime.generator.version) {
398
+ pushError(errors, `${runtimeLabel(runtime)} for projection '${projection.id}' generator '${manifest.id}' version '${runtime.generator.version}' is unsupported; expected '${manifest.version}'`);
399
399
  }
400
- if (!isGeneratorCompatible(manifest, component.kind, projection)) {
401
- pushError(errors, `${componentLabel(component)} for projection '${projection.id}' generator '${manifest.id}@${manifest.version}' is incompatible with runtime kind '${component.kind}' and projection type '${projection.type || "api_contract"}'`);
400
+ if (!isGeneratorCompatible(manifest, runtime.kind, projection)) {
401
+ pushError(errors, `${runtimeLabel(runtime)} for projection '${projection.id}' generator '${manifest.id}@${manifest.version}' is incompatible with runtime kind '${runtime.kind}' and projection type '${projection.type || "api_contract"}'`);
402
402
  }
403
403
  }
404
404
 
405
405
  /**
406
406
  * @param {ValidationError[]} errors
407
- * @param {RuntimeTopologyRuntime[]} components
407
+ * @param {RuntimeTopologyRuntime[]} runtimes
408
408
  * @returns {void}
409
409
  */
410
- function validateTopologyReferences(errors, components) {
411
- const byId = new Map(components.map((component) => [component.id, component]));
410
+ function validateTopologyReferences(errors, runtimes) {
411
+ const byId = new Map(runtimes.map((runtime) => [runtime.id, runtime]));
412
412
  const usedPorts = new Map();
413
- for (const component of components) {
414
- if (component.port != null) {
415
- const existing = usedPorts.get(component.port);
413
+ for (const runtime of runtimes) {
414
+ if (runtime.port != null) {
415
+ const existing = usedPorts.get(runtime.port);
416
416
  if (existing) {
417
- pushError(errors, `Port ${component.port} is used by both '${existing}' and '${component.id}'`);
417
+ pushError(errors, `Port ${runtime.port} is used by both '${existing}' and '${runtime.id}'`);
418
418
  } else {
419
- usedPorts.set(component.port, component.id);
419
+ usedPorts.set(runtime.port, runtime.id);
420
420
  }
421
421
  }
422
- if (component.kind === "api_service") {
423
- if (component.uses_database && byId.get(component.uses_database)?.kind !== "database") {
424
- pushError(errors, `${componentLabel(component)} references missing database runtime '${component.uses_database}'`);
422
+ if (runtime.kind === "api_service") {
423
+ if (runtime.uses_database && byId.get(runtime.uses_database)?.kind !== "database") {
424
+ pushError(errors, `${runtimeLabel(runtime)} references missing database runtime '${runtime.uses_database}'`);
425
425
  }
426
426
  }
427
- if (["web_surface", "ios_surface", "android_surface"].includes(component.kind)) {
428
- if (component.uses_api && byId.get(component.uses_api)?.kind !== "api_service") {
429
- pushError(errors, `${componentLabel(component)} references missing api runtime '${component.uses_api}'`);
427
+ if (["web_surface", "ios_surface", "android_surface"].includes(runtime.kind)) {
428
+ if (runtime.uses_api && byId.get(runtime.uses_api)?.kind !== "api_service") {
429
+ pushError(errors, `${runtimeLabel(runtime)} references missing api runtime '${runtime.uses_api}'`);
430
430
  }
431
431
  }
432
432
  }
@@ -455,8 +455,8 @@ export function validateProjectConfig(config, graph = null, options = {}) {
455
455
  pushError(errors, "topogram.project.json topology.runtimes must be an array");
456
456
  } else {
457
457
  const seenIds = new Set();
458
- for (const component of config.topology.runtimes) {
459
- validateComponentShape(errors, component, seenIds);
458
+ for (const runtime of config.topology.runtimes) {
459
+ validateRuntimeShape(errors, runtime, seenIds);
460
460
  }
461
461
  const generatorPolicy = validateProjectGeneratorPolicy(config, options);
462
462
  for (const error of generatorPolicy.errors) {
@@ -464,8 +464,8 @@ export function validateProjectConfig(config, graph = null, options = {}) {
464
464
  }
465
465
  if (graph) {
466
466
  const projections = projectionById(graph);
467
- for (const component of config.topology.runtimes) {
468
- validateComponentCompatibility(errors, component, projections, options);
467
+ for (const runtime of config.topology.runtimes) {
468
+ validateRuntimeCompatibility(errors, runtime, projections, options);
469
469
  }
470
470
  validateTopologyReferences(errors, config.topology.runtimes);
471
471
  }