forgeos 0.1.0-alpha.34 → 0.1.0-alpha.36

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/AGENTS.md CHANGED
@@ -1,4 +1,4 @@
1
- // @forge-generated generator=0.1.0-alpha.34 input=ce9cf8b6c553d7de8113eb2a388d269cb8c90d4bb15c013a7ab907ef207619b9 content=721818a6f9a664aa898092d4aa5cd68cbbd8ad6e150c7d3ad3ef785a2e3fcf53
1
+ // @forge-generated generator=0.1.0-alpha.36 input=fed0c5202e23edeb9d4d7f31d0de594194b8a898741da3c1a74b8c2d54758aa5 content=721818a6f9a664aa898092d4aa5cd68cbbd8ad6e150c7d3ad3ef785a2e3fcf53
2
2
  # AGENTS.md
3
3
 
4
4
  <!-- forge-generated:start -->
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # forgeos
2
2
 
3
+ ## 0.1.0-alpha.36
4
+
5
+ ### Patch Changes
6
+
7
+ - Smooth the alpha field-test loop after the Vendor Access app exercise.
8
+
9
+ - Teach `forge deps upgrade-plan` to resolve npm alias dependencies such as `forge: npm:forgeos@alpha`, while preserving alias install specs like `forge@npm:forgeos@0.1.0-alpha.36` in generated upgrade plans.
10
+ - Normalize `auth.md` runtime risk rendering so Commands and Risk/Approval metadata agree that commands default to `write`.
11
+ - Add non-throwing command proof APIs through generated `client.commandResult(...)` and React `useCommandResult(...)`, so expected policy denials can be rendered as structured results instead of noisy unhandled browser failures.
12
+ - Update the basic example schema/docs to use `organizations` as the root tenant table instead of a self-referential `tenants` root.
13
+
14
+ ## 0.1.0-alpha.35
15
+
16
+ ### Patch Changes
17
+
18
+ - Harden field-test regressions found while building WorkOS-style multi-tenant apps.
19
+
20
+ - Resolve camelCase `ref:` targets such as `ref:accessRequests` to canonical SQL table names like `access_requests`, and report unknown refs before invalid SQL reaches PGlite.
21
+ - Allow WorkOS-like local dev auth headers through dev-server CORS, including organization, membership, permissions, roles, and claims headers.
22
+ - Teach the generated agent contract and capability map to recognize camelCase `ctx.db` aliases for snake_case tables.
23
+ - Avoid printing fake `http://127.0.0.1:0` API URLs in `forge dev --port 0` startup diagnostics.
24
+
3
25
  ## 0.1.0-alpha.34
4
26
 
5
27
  ### Patch Changes
package/docs/changelog.md CHANGED
@@ -6,6 +6,35 @@ The canonical source file in the repository is `CHANGELOG.md`.
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## 0.1.0-alpha.36
10
+
11
+ - Smoothed the alpha field-test loop after the Vendor Access app exercise.
12
+ - Taught `forge deps upgrade-plan` to resolve npm alias dependencies such as
13
+ `forge: npm:forgeos@alpha`, while preserving alias install specs like
14
+ `forge@npm:forgeos@0.1.0-alpha.36` in generated upgrade plans.
15
+ - Normalized `auth.md` runtime risk rendering so Commands and Risk/Approval
16
+ metadata agree that commands default to `write`.
17
+ - Added non-throwing command proof APIs through generated
18
+ `client.commandResult(...)` and React `useCommandResult(...)`, so expected
19
+ policy denials can be rendered as structured results instead of noisy
20
+ unhandled browser failures.
21
+ - Updated the basic example schema/docs to use `organizations` as the root
22
+ tenant table instead of a self-referential `tenants` root.
23
+
24
+ ## 0.1.0-alpha.35
25
+
26
+ - Hardened field-test regressions found while building WorkOS-style
27
+ multi-tenant apps.
28
+ - Resolved camelCase `ref:` targets such as `ref:accessRequests` to canonical
29
+ SQL table names like `access_requests`, and reported unknown refs before
30
+ invalid SQL reaches PGlite.
31
+ - Allowed WorkOS-like local dev auth headers through dev-server CORS,
32
+ including organization, membership, permissions, roles, and claims headers.
33
+ - Taught the generated agent contract and capability map to recognize
34
+ camelCase `ctx.db` aliases for snake_case tables.
35
+ - Avoided printing fake `http://127.0.0.1:0` API URLs in
36
+ `forge dev --port 0` startup diagnostics.
37
+
9
38
  ## 0.1.0-alpha.34
10
39
 
11
40
  - Hardened the ForgeOS app-building DX after the Vendor Access field test.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forgeos",
3
- "version": "0.1.0-alpha.34",
3
+ "version": "0.1.0-alpha.36",
4
4
  "description": "Agent-native application framework and compiler for building Forge apps without a mandatory dashboard.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1 +1 @@
1
- {"defaultProvider":"local","diagnostics":[],"env":{"deployEnv":"FORGE_DEPLOY_ENV","deployId":"FORGE_DEPLOY_ID","publicReleaseId":"NEXT_PUBLIC_FORGE_RELEASE_ID","releaseId":"FORGE_RELEASE_ID"},"gitSha":"unknown","optionalProviders":["local","sentry-compatible","sentry","glitchtip","bugsink","otel","custom"],"packageName":"forgeos","packageVersion":"0.1.0-alpha.34","releaseId":"forgeos@0.1.0-alpha.34+unknown","schemaVersion":"0.1.0"}
1
+ {"defaultProvider":"local","diagnostics":[],"env":{"deployEnv":"FORGE_DEPLOY_ENV","deployId":"FORGE_DEPLOY_ID","publicReleaseId":"NEXT_PUBLIC_FORGE_RELEASE_ID","releaseId":"FORGE_RELEASE_ID"},"gitSha":"unknown","optionalProviders":["local","sentry-compatible","sentry","glitchtip","bugsink","otel","custom"],"packageName":"forgeos","packageVersion":"0.1.0-alpha.36","releaseId":"forgeos@0.1.0-alpha.36+unknown","schemaVersion":"0.1.0"}
@@ -1,4 +1,4 @@
1
- // @forge-generated generator=0.1.0-alpha.34 input=ce9cf8b6c553d7de8113eb2a388d269cb8c90d4bb15c013a7ab907ef207619b9 content=1a3f239bc322349796830461349167bb154a27e57fb1f10868c788194b3be97e
1
+ // @forge-generated generator=0.1.0-alpha.36 input=fed0c5202e23edeb9d4d7f31d0de594194b8a898741da3c1a74b8c2d54758aa5 content=55e1e8090cce3a057473a21fe3382f8f159581327fa98ecee1a489f8de6965d0
2
2
  export const releaseManifest = {
3
3
  "defaultProvider": "local",
4
4
  "diagnostics": [],
@@ -19,7 +19,7 @@ export const releaseManifest = {
19
19
  "custom"
20
20
  ],
21
21
  "packageName": "forgeos",
22
- "packageVersion": "0.1.0-alpha.34",
23
- "releaseId": "forgeos@0.1.0-alpha.34+unknown",
22
+ "packageVersion": "0.1.0-alpha.36",
23
+ "releaseId": "forgeos@0.1.0-alpha.36+unknown",
24
24
  "schemaVersion": "0.1.0"
25
25
  } as const;
@@ -64,6 +64,8 @@ interface AgentToolsLike {
64
64
  }>;
65
65
  }
66
66
 
67
+ type RuntimeEntryKind = "command" | "query" | "liveQuery" | "action";
68
+
67
69
  function readGeneratedJson<T>(workspaceRoot: string, relativePath: string): T | null {
68
70
  const absolute = join(workspaceRoot, relativePath);
69
71
  if (!nodeFileSystem.exists(absolute)) {
@@ -76,11 +78,32 @@ function outputPath(options: AuthMdCommandOptions): string {
76
78
  return options.output?.trim() || "public/auth.md";
77
79
  }
78
80
 
79
- function listRuntimeEntries(title: string, entries: RuntimeEntry[] | undefined): string[] {
81
+ function runtimeEntryRisk(kind: RuntimeEntryKind, entry: RuntimeEntry): string {
82
+ if (entry.risk) {
83
+ return entry.risk;
84
+ }
85
+ if (kind === "command") {
86
+ return "write";
87
+ }
88
+ if (kind === "action") {
89
+ return "external";
90
+ }
91
+ return "read";
92
+ }
93
+
94
+ function runtimeEntryNeedsApproval(kind: RuntimeEntryKind, entry: RuntimeEntry): boolean {
95
+ return entry.needsApproval ?? kind === "action";
96
+ }
97
+
98
+ function listRuntimeEntries(
99
+ title: string,
100
+ kind: RuntimeEntryKind,
101
+ entries: RuntimeEntry[] | undefined,
102
+ ): string[] {
80
103
  const rows = (entries ?? []).map((entry) =>
81
104
  `| \`${entry.name}\` | ${entry.policy ? `\`${entry.policy}\`` : "none"} | ${
82
105
  entry.requiresAuth === false ? "no" : "yes"
83
- } | ${entry.risk ?? "read"} | ${String(entry.needsApproval ?? false)} |`
106
+ } | ${runtimeEntryRisk(kind, entry)} | ${String(runtimeEntryNeedsApproval(kind, entry))} |`
84
107
  );
85
108
  return [
86
109
  `## ${title}`,
@@ -115,16 +138,16 @@ function existingDocLinks(workspaceRoot: string): string[] {
115
138
 
116
139
  function riskMetadata(contract: AgentContractLike | null, tools: AgentToolsLike | null) {
117
140
  const runtime = [
118
- ...(contract?.commands ?? []).map((entry) => ({ kind: "command", ...entry, risk: entry.risk ?? "write" })),
119
- ...(contract?.queries ?? []).map((entry) => ({ kind: "query", ...entry, risk: entry.risk ?? "read" })),
120
- ...(contract?.liveQueries ?? []).map((entry) => ({ kind: "liveQuery", ...entry, risk: entry.risk ?? "read" })),
121
- ...(contract?.actions ?? []).map((entry) => ({ kind: "action", ...entry, risk: entry.risk ?? "external" })),
141
+ ...(contract?.commands ?? []).map((entry) => ({ kind: "command" as const, ...entry })),
142
+ ...(contract?.queries ?? []).map((entry) => ({ kind: "query" as const, ...entry })),
143
+ ...(contract?.liveQueries ?? []).map((entry) => ({ kind: "liveQuery" as const, ...entry })),
144
+ ...(contract?.actions ?? []).map((entry) => ({ kind: "action" as const, ...entry })),
122
145
  ].map((entry) => ({
123
146
  kind: entry.kind,
124
147
  name: entry.name,
125
148
  policy: entry.policy ?? null,
126
- risk: entry.risk ?? "read",
127
- needs_approval: entry.needsApproval ?? (entry.kind === "action"),
149
+ risk: runtimeEntryRisk(entry.kind, entry),
150
+ needs_approval: runtimeEntryNeedsApproval(entry.kind, entry),
128
151
  requires_auth: entry.requiresAuth !== false,
129
152
  }));
130
153
  const aiTools = (tools?.tools ?? []).map((tool) => ({
@@ -232,10 +255,10 @@ function renderAuthMd(workspaceRoot: string): AuthMdCommandResult & { content: s
232
255
  `- Roles: \`${auth?.claims?.roles ?? "roles"}\``,
233
256
  `- Permissions: \`${auth?.claims?.permissions ?? "permissions"}\``,
234
257
  "",
235
- ...listRuntimeEntries("Commands", contract?.commands),
236
- ...listRuntimeEntries("Queries", contract?.queries),
237
- ...listRuntimeEntries("Live Queries", contract?.liveQueries),
238
- ...listRuntimeEntries("Actions", contract?.actions),
258
+ ...listRuntimeEntries("Commands", "command", contract?.commands),
259
+ ...listRuntimeEntries("Queries", "query", contract?.queries),
260
+ ...listRuntimeEntries("Live Queries", "liveQuery", contract?.liveQueries),
261
+ ...listRuntimeEntries("Actions", "action", contract?.actions),
239
262
  "## Policies",
240
263
  "",
241
264
  "| Policy | Kind | Roles | Permissions |",
@@ -595,6 +595,17 @@ export async function resolveAvailableWebPort(input: {
595
595
  };
596
596
  }
597
597
 
598
+ export function devStartupConsoleUrlOptions(input: {
599
+ host: string;
600
+ port: number;
601
+ webUrl?: string;
602
+ }): { apiUrl?: string; webUrl?: string } {
603
+ return {
604
+ ...(input.port === 0 ? {} : { apiUrl: `http://${input.host}:${input.port}` }),
605
+ ...(input.webUrl ? { webUrl: input.webUrl } : {}),
606
+ };
607
+ }
608
+
598
609
  export interface DevWatchGenerateFailureEvent {
599
610
  schemaVersion: "0.1.0";
600
611
  event: "dev.generate_failed";
@@ -1138,8 +1149,7 @@ export async function runDevCommand(
1138
1149
  mode: "startup",
1139
1150
  strictSecrets: false,
1140
1151
  includeImpact: true,
1141
- apiUrl: `http://${host}:${port}`,
1142
- ...(webUrl ? { webUrl } : {}),
1152
+ ...devStartupConsoleUrlOptions({ host, port, ...(webUrl ? { webUrl } : {}) }),
1143
1153
  });
1144
1154
  if (options.json) {
1145
1155
  process.stdout.write(formatDevConsoleJson(startupCycle));
@@ -283,9 +283,33 @@ function escapeRegExp(value: string): string {
283
283
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
284
284
  }
285
285
 
286
+ function toSnakeCaseName(name: string): string {
287
+ return name
288
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
289
+ .replace(/[-\s]+/g, "_")
290
+ .toLowerCase();
291
+ }
292
+
293
+ function toCamelCaseName(name: string): string {
294
+ return name.replace(/[_-]([a-zA-Z0-9])/g, (_match, char: string) => char.toUpperCase());
295
+ }
296
+
297
+ function dbTableLookup(dataGraph: AgentContractInput["dataGraph"]): Map<string, string> {
298
+ const lookup = new Map<string, string>();
299
+ for (const table of dataGraph.tables) {
300
+ lookup.set(table.name, table.name);
301
+ lookup.set(table.exportName, table.name);
302
+ lookup.set(toSnakeCaseName(table.name), table.name);
303
+ lookup.set(toSnakeCaseName(table.exportName), table.name);
304
+ lookup.set(toCamelCaseName(table.name), table.name);
305
+ lookup.set(toCamelCaseName(table.exportName), table.name);
306
+ }
307
+ return lookup;
308
+ }
309
+
286
310
  function addDbAliasesForText(
287
311
  text: string,
288
- tableNames: Set<string>,
312
+ tableLookup: Map<string, string>,
289
313
  aliases: Map<string, string>,
290
314
  ): void {
291
315
  for (const match of text.matchAll(
@@ -293,8 +317,9 @@ function addDbAliasesForText(
293
317
  )) {
294
318
  const alias = match[1] ?? "";
295
319
  const table = match[2] ?? match[3] ?? "";
296
- if (alias && tableNames.has(table)) {
297
- aliases.set(alias, table);
320
+ const canonicalTable = tableLookup.get(table);
321
+ if (alias && canonicalTable) {
322
+ aliases.set(alias, canonicalTable);
298
323
  }
299
324
  }
300
325
 
@@ -304,8 +329,9 @@ function addDbAliasesForText(
304
329
  const [rawTable, rawAlias] = part.split(":").map((value) => value.trim());
305
330
  const table = rawTable?.replace(/["'`]/g, "") ?? "";
306
331
  const alias = (rawAlias ?? rawTable ?? "").replace(/\s*=.*$/, "").trim();
307
- if (tableNames.has(table) && alias) {
308
- aliases.set(alias, table);
332
+ const canonicalTable = tableLookup.get(table);
333
+ if (canonicalTable && alias) {
334
+ aliases.set(alias, canonicalTable);
309
335
  }
310
336
  }
311
337
  }
@@ -313,26 +339,28 @@ function addDbAliasesForText(
313
339
 
314
340
  function dbTablesForText(
315
341
  text: string,
316
- tableNames: Set<string>,
342
+ tableLookup: Map<string, string>,
317
343
  ops: Set<string>,
318
344
  ): string[] {
319
345
  const tables: string[] = [];
320
346
  for (const match of text.matchAll(/ctx\.db\.([A-Za-z_$][A-Za-z0-9_$]*)\s*\.\s*([A-Za-z_$][A-Za-z0-9_$]*)/g)) {
321
347
  const table = match[1] ?? "";
322
348
  const op = match[2] ?? "";
323
- if (tableNames.has(table) && ops.has(op)) {
324
- tables.push(table);
349
+ const canonicalTable = tableLookup.get(table);
350
+ if (canonicalTable && ops.has(op)) {
351
+ tables.push(canonicalTable);
325
352
  }
326
353
  }
327
354
  for (const match of text.matchAll(/ctx\.db\s*\[\s*["'`]([^"'`]+)["'`]\s*\]\s*\.\s*([A-Za-z_$][A-Za-z0-9_$]*)/g)) {
328
355
  const table = match[1] ?? "";
329
356
  const op = match[2] ?? "";
330
- if (tableNames.has(table) && ops.has(op)) {
331
- tables.push(table);
357
+ const canonicalTable = tableLookup.get(table);
358
+ if (canonicalTable && ops.has(op)) {
359
+ tables.push(canonicalTable);
332
360
  }
333
361
  }
334
362
  const aliases = new Map<string, string>();
335
- addDbAliasesForText(text, tableNames, aliases);
363
+ addDbAliasesForText(text, tableLookup, aliases);
336
364
  for (const [alias, table] of aliases) {
337
365
  const aliasPattern = new RegExp(`\\b${escapeRegExp(alias)}\\s*\\.\\s*([A-Za-z_$][A-Za-z0-9_$]*)`, "g");
338
366
  for (const match of text.matchAll(aliasPattern)) {
@@ -348,10 +376,10 @@ function dbTablesForText(
348
376
  function dbTablesForFile(
349
377
  workspaceRoot: string,
350
378
  file: string | undefined,
351
- tableNames: Set<string>,
379
+ tableLookup: Map<string, string>,
352
380
  ops: Set<string>,
353
381
  ): string[] {
354
- return dbTablesForText(sourceTextWithLocalImports(workspaceRoot, file), tableNames, ops);
382
+ return dbTablesForText(sourceTextWithLocalImports(workspaceRoot, file), tableLookup, ops);
355
383
  }
356
384
 
357
385
  function emittedEventsForFile(workspaceRoot: string, file: string | undefined): string[] {
@@ -1222,7 +1250,7 @@ export function buildAgentContractArtifacts(
1222
1250
  );
1223
1251
 
1224
1252
  const runtimeEntries = new Map(input.runtimeGraph.entries.map((entry) => [entry.name, entry]));
1225
- const tableNames = new Set(input.dataGraph.tables.map((table) => table.name));
1253
+ const tableLookup = dbTableLookup(input.dataGraph);
1226
1254
  const externalEntries = input.externalServices?.services.flatMap((service) => service.entries) ?? [];
1227
1255
  const localCommandInfos: AgentContract["commands"] = sorted(Object.keys(input.apiSurface.commands), (name) => name).map((name) => {
1228
1256
  const entry = runtimeEntries.get(name);
@@ -1232,8 +1260,8 @@ export function buildAgentContractArtifacts(
1232
1260
  file,
1233
1261
  source: "local" as const,
1234
1262
  policy: authPolicy(commandAuth.get(name)),
1235
- tablesRead: dbTablesForFile(input.workspaceRoot, file, tableNames, DB_READ_OPS),
1236
- tablesWritten: dbTablesForFile(input.workspaceRoot, file, tableNames, DB_WRITE_OPS),
1263
+ tablesRead: dbTablesForFile(input.workspaceRoot, file, tableLookup, DB_READ_OPS),
1264
+ tablesWritten: dbTablesForFile(input.workspaceRoot, file, tableLookup, DB_WRITE_OPS),
1237
1265
  emits: emittedEventsForFile(input.workspaceRoot, file),
1238
1266
  allowedPackages: entry ? packageNamesForModule(input.appGraph, entry.moduleId) : [],
1239
1267
  forbiddenCapabilities: forbiddenForContext(input.classified, "command"),
@@ -1268,7 +1296,7 @@ export function buildAgentContractArtifacts(
1268
1296
  policy: authPolicy(queryAuth.get(query.name)),
1269
1297
  readOnly: true,
1270
1298
  tenantScoped: input.tenantScope.tables.length > 0,
1271
- tablesRead: dbTablesForFile(input.workspaceRoot, query.file, tableNames, DB_READ_OPS),
1299
+ tablesRead: dbTablesForFile(input.workspaceRoot, query.file, tableLookup, DB_READ_OPS),
1272
1300
  allowedPackages: packageNamesForModule(input.appGraph, query.moduleId),
1273
1301
  forbiddenCapabilities: forbiddenForContext(input.classified, "query"),
1274
1302
  http: httpEndpointFor("query", query.name),
@@ -1296,7 +1324,7 @@ export function buildAgentContractArtifacts(
1296
1324
  );
1297
1325
  const liveQueryInfos: AgentContract["liveQueries"] = sorted(input.liveQueryRegistry.liveQueries, (liveQuery) => liveQuery.name).map(
1298
1326
  (liveQuery) => {
1299
- const tablesRead = dbTablesForFile(input.workspaceRoot, liveQuery.file, tableNames, DB_READ_OPS);
1327
+ const tablesRead = dbTablesForFile(input.workspaceRoot, liveQuery.file, tableLookup, DB_READ_OPS);
1300
1328
  return {
1301
1329
  name: liveQuery.name,
1302
1330
  file: liveQuery.file,
@@ -86,6 +86,22 @@ export type LiveSnapshot<T> = {
86
86
  traceId?: string;
87
87
  };
88
88
 
89
+ export type ForgeCommandResult<T = unknown> =
90
+ | {
91
+ ok: true;
92
+ result: T;
93
+ status: number;
94
+ traceId?: string;
95
+ diagnostics?: { code: string; message: string }[];
96
+ }
97
+ | {
98
+ ok: false;
99
+ error: { code: string; message: string; details?: unknown };
100
+ status: number;
101
+ traceId?: string;
102
+ diagnostics?: { code: string; message: string }[];
103
+ };
104
+
89
105
  export type LiveQueryOptions = {
90
106
  signal?: AbortSignal;
91
107
  };
@@ -96,6 +112,7 @@ export type ForgeClient = {
96
112
  readonly lastTraceId?: string;
97
113
  query<Name extends QueryName>(name: Name, args: unknown): Promise<unknown>;
98
114
  command<Name extends CommandName>(name: Name, args: unknown): Promise<unknown>;
115
+ commandResult<Name extends CommandName>(name: Name, args: unknown): Promise<ForgeCommandResult<unknown>>;
99
116
  externalQuery<Name extends ExternalQueryRef>(name: Name, args: unknown): Promise<unknown>;
100
117
  externalCommand<Name extends ExternalCommandRef>(name: Name, args: unknown): Promise<unknown>;
101
118
  liveQuery<Name extends LiveQueryName>(
@@ -113,6 +130,7 @@ export function renderClientTs(): string {
113
130
  return `import { api } from "./api.ts";
114
131
  import type {
115
132
  ForgeAuthProvider,
133
+ ForgeCommandResult,
116
134
  ForgeClient,
117
135
  ForgeClientConfig,
118
136
  ExternalCommandRef,
@@ -128,6 +146,7 @@ export type {
128
146
  ForgeAuthProvider,
129
147
  ForgeClient,
130
148
  ForgeClientConfig,
149
+ ForgeCommandResult,
131
150
  ForgeStaticAuth,
132
151
  QueryName,
133
152
  CommandName,
@@ -258,6 +277,10 @@ class ForgeHttpClient implements ForgeClient {
258
277
  return this.invoke("commands", name, args);
259
278
  }
260
279
 
280
+ commandResult(name: string, args: unknown): Promise<ForgeCommandResult<unknown>> {
281
+ return this.invokeResult("commands", name, args);
282
+ }
283
+
261
284
  externalQuery(name: ExternalQueryRef, args: unknown): Promise<unknown> {
262
285
  return this.invokeExternal("queries", name, args);
263
286
  }
@@ -297,27 +320,61 @@ class ForgeHttpClient implements ForgeClient {
297
320
  name: string,
298
321
  args: unknown,
299
322
  ): Promise<unknown> {
323
+ const result = await this.invokeResult(kind, name, args);
324
+ if (!result.ok) {
325
+ throw new ForgeError(result.error.message, {
326
+ code: result.error.code,
327
+ traceId: result.traceId,
328
+ status: result.status,
329
+ details: result.error.details ?? result.diagnostics,
330
+ });
331
+ }
332
+ return result.result;
333
+ }
334
+
335
+ private async invokeResult(
336
+ kind: "queries" | "commands",
337
+ name: string,
338
+ args: unknown,
339
+ ): Promise<ForgeCommandResult<unknown>> {
300
340
  const baseUrl = this.config.url.replace(/\\/$/, "");
301
341
  const url = \`\${baseUrl}/\${kind}/\${encodeURIComponent(name)}\`;
302
342
  const authHeaders = await resolveAuthHeaders(this.config.auth);
303
343
 
304
- const response = await fetch(url, {
305
- method: "POST",
306
- headers: {
307
- "Content-Type": "application/json",
308
- ...authHeaders,
309
- },
310
- body: JSON.stringify({ args }),
311
- });
344
+ let response: Response;
345
+ try {
346
+ response = await fetch(url, {
347
+ method: "POST",
348
+ headers: {
349
+ "Content-Type": "application/json",
350
+ ...authHeaders,
351
+ },
352
+ body: JSON.stringify({ args }),
353
+ });
354
+ } catch (error) {
355
+ return {
356
+ ok: false,
357
+ status: 0,
358
+ error: {
359
+ code: "FORGE_NETWORK_ERROR",
360
+ message: error instanceof Error ? error.message : "network request failed",
361
+ details: error,
362
+ },
363
+ };
364
+ }
312
365
 
313
366
  let body: unknown;
314
367
  try {
315
368
  body = await response.json();
316
369
  } catch {
317
- throw new ForgeError(\`HTTP \${response.status}\`, {
318
- code: "FORGE_HTTP_ERROR",
370
+ return {
371
+ ok: false,
319
372
  status: response.status,
320
- });
373
+ error: {
374
+ code: "FORGE_HTTP_ERROR",
375
+ message: \`HTTP \${response.status}\`,
376
+ },
377
+ };
321
378
  }
322
379
 
323
380
  const payload = parseJsonPayload(body);
@@ -331,15 +388,26 @@ class ForgeHttpClient implements ForgeClient {
331
388
  payload.error?.message ??
332
389
  diagnostic?.message ??
333
390
  \`Request failed with status \${response.status}\`;
334
- throw new ForgeError(message, {
335
- code,
336
- traceId: payload.traceId,
391
+ return {
392
+ ok: false,
337
393
  status: response.status,
338
- details: payload.error?.details ?? payload.diagnostics,
339
- });
394
+ traceId: payload.traceId,
395
+ diagnostics: payload.diagnostics,
396
+ error: {
397
+ code,
398
+ message,
399
+ details: payload.error?.details ?? payload.diagnostics,
400
+ },
401
+ };
340
402
  }
341
403
 
342
- return payload.result;
404
+ return {
405
+ ok: true,
406
+ result: payload.result,
407
+ status: response.status,
408
+ traceId: payload.traceId,
409
+ diagnostics: payload.diagnostics,
410
+ };
343
411
  }
344
412
 
345
413
  private async invokeExternal(
@@ -524,8 +592,11 @@ export type {
524
592
  ForgeReactAuthProvider,
525
593
  ForgeReactClient,
526
594
  ForgeReactError,
595
+ ForgeCommandCallResult,
527
596
  UseCommandOptions,
528
597
  UseCommandResult,
598
+ UseCommandResultHook,
599
+ UseCommandResultOptions,
529
600
  UseLiveQueryOptions,
530
601
  UseLiveQueryResult,
531
602
  UseQueryOptions,
@@ -539,6 +610,7 @@ export const useForgeClient = forgeReact.useForgeClient;
539
610
  export const useAuth = forgeReact.useAuth;
540
611
  export const useQuery = forgeReact.useQuery;
541
612
  export const useCommand = forgeReact.useCommand;
613
+ export const useCommandResult = forgeReact.useCommandResult;
542
614
  export const useLiveQuery = forgeReact.useLiveQuery;
543
615
  `;
544
616
  }
@@ -551,8 +623,11 @@ export function renderReactDts(): string {
551
623
  ForgeReactAuthProvider,
552
624
  ForgeReactClient,
553
625
  ForgeReactError,
626
+ ForgeCommandCallResult,
554
627
  UseCommandOptions,
555
628
  UseCommandResult,
629
+ UseCommandResultHook,
630
+ UseCommandResultOptions,
556
631
  UseLiveQueryOptions,
557
632
  UseLiveQueryResult,
558
633
  UseQueryOptions,
@@ -564,6 +639,7 @@ export declare const useForgeClient: import("forge/react").ForgeReactBindings["u
564
639
  export declare const useAuth: import("forge/react").ForgeReactBindings["useAuth"];
565
640
  export declare const useQuery: import("forge/react").ForgeReactBindings["useQuery"];
566
641
  export declare const useCommand: import("forge/react").ForgeReactBindings["useCommand"];
642
+ export declare const useCommandResult: import("forge/react").ForgeReactBindings["useCommandResult"];
567
643
  export declare const useLiveQuery: import("forge/react").ForgeReactBindings["useLiveQuery"];
568
644
  `;
569
645
  }
@@ -102,39 +102,41 @@ function parseFieldType(
102
102
  field: DataField,
103
103
  tableName: string,
104
104
  diagnostics: Diagnostic[],
105
+ refTables: Map<string, string>,
105
106
  ): ParsedFieldType | null {
106
107
  const declared = field.type.trim();
107
108
  const nullable = declared.endsWith("?");
108
- const raw = (nullable ? declared.slice(0, -1) : declared).trim().toLowerCase();
109
+ const raw = (nullable ? declared.slice(0, -1) : declared).trim();
110
+ const lowered = raw.toLowerCase();
109
111
 
110
112
  const withNullability = (parsed: Omit<ParsedFieldType, "nullable">): ParsedFieldType => ({
111
113
  ...parsed,
112
114
  ...(nullable ? { nullable: true } : {}),
113
115
  });
114
116
 
115
- if (raw === "uuid") {
117
+ if (lowered === "uuid") {
116
118
  return withNullability({ sqlType: "uuid" });
117
119
  }
118
- if (raw === "text" || raw === "string") {
120
+ if (lowered === "text" || lowered === "string") {
119
121
  return withNullability({ sqlType: "text" });
120
122
  }
121
- if (raw === "number") {
123
+ if (lowered === "number") {
122
124
  return withNullability({ sqlType: "double precision" });
123
125
  }
124
- if (raw === "integer" || raw === "int") {
126
+ if (lowered === "integer" || lowered === "int") {
125
127
  return withNullability({ sqlType: "integer" });
126
128
  }
127
- if (raw === "boolean" || raw === "bool") {
129
+ if (lowered === "boolean" || lowered === "bool") {
128
130
  return withNullability({ sqlType: "boolean" });
129
131
  }
130
- if (raw === "timestamp" || raw === "timestamptz") {
132
+ if (lowered === "timestamp" || lowered === "timestamptz") {
131
133
  return withNullability({ sqlType: "timestamptz" });
132
134
  }
133
- if (raw === "json") {
135
+ if (lowered === "json") {
134
136
  return withNullability({ sqlType: "jsonb" });
135
137
  }
136
138
 
137
- if (raw.startsWith("enum:")) {
139
+ if (lowered.startsWith("enum:")) {
138
140
  const values = raw
139
141
  .slice("enum:".length)
140
142
  .split(",")
@@ -158,11 +160,11 @@ function parseFieldType(
158
160
  };
159
161
  }
160
162
 
161
- if (raw.startsWith("enum_")) {
163
+ if (lowered.startsWith("enum_")) {
162
164
  return withNullability({ sqlType: "text" });
163
165
  }
164
166
 
165
- if (raw.startsWith("ref:")) {
167
+ if (lowered.startsWith("ref:")) {
166
168
  const refTable = raw.slice("ref:".length).trim();
167
169
  if (!refTable) {
168
170
  diagnostics.push(
@@ -174,14 +176,25 @@ function parseFieldType(
174
176
  );
175
177
  return null;
176
178
  }
179
+ const resolvedRefTable = refTables.get(refTable) ?? refTables.get(toSnakeCase(refTable));
180
+ if (!resolvedRefTable) {
181
+ diagnostics.push(
182
+ createDiagnostic({
183
+ severity: "error",
184
+ code: FORGE_DB_INVALID_SQL_PLAN,
185
+ message: `unknown ref target '${refTable}' on ${tableName}.${field.name}; expected one of ${[...new Set(refTables.values())].sort().join(", ")}`,
186
+ }),
187
+ );
188
+ return null;
189
+ }
177
190
  return {
178
191
  ...(nullable ? { nullable: true } : {}),
179
192
  sqlType: "uuid",
180
- references: { table: toSnakeCase(refTable), column: "id" },
193
+ references: { table: resolvedRefTable, column: "id" },
181
194
  };
182
195
  }
183
196
 
184
- if (raw === "ref") {
197
+ if (lowered === "ref") {
185
198
  return {
186
199
  ...(nullable ? { nullable: true } : {}),
187
200
  sqlType: "uuid",
@@ -199,6 +212,18 @@ function parseFieldType(
199
212
  return null;
200
213
  }
201
214
 
215
+ function buildReferenceTableLookup(tables: DataTable[]): Map<string, string> {
216
+ const lookup = new Map<string, string>();
217
+ for (const table of tables) {
218
+ const sqlTableName = toSnakeCase(table.name);
219
+ lookup.set(table.name, sqlTableName);
220
+ lookup.set(table.exportName, sqlTableName);
221
+ lookup.set(sqlTableName, sqlTableName);
222
+ lookup.set(toSnakeCase(table.exportName), sqlTableName);
223
+ }
224
+ return lookup;
225
+ }
226
+
202
227
  function defaultExprForField(field: DataField): string | undefined {
203
228
  const name = field.name;
204
229
  const type = field.type.trim().replace(/\?$/, "").toLowerCase();
@@ -217,7 +242,11 @@ function defaultExprForField(field: DataField): string | undefined {
217
242
  return undefined;
218
243
  }
219
244
 
220
- function buildColumns(table: DataTable, diagnostics: Diagnostic[]): ColumnDef[] {
245
+ function buildColumns(
246
+ table: DataTable,
247
+ diagnostics: Diagnostic[],
248
+ refTables: Map<string, string>,
249
+ ): ColumnDef[] {
221
250
  const columns: ColumnDef[] = [];
222
251
  const fields = [...table.fields].sort((a, b) =>
223
252
  a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
@@ -236,7 +265,7 @@ function buildColumns(table: DataTable, diagnostics: Diagnostic[]): ColumnDef[]
236
265
  }
237
266
 
238
267
  for (const field of fields) {
239
- const parsed = parseFieldType(field, table.name, diagnostics);
268
+ const parsed = parseFieldType(field, table.name, diagnostics, refTables);
240
269
  if (!parsed) {
241
270
  continue;
242
271
  }
@@ -311,9 +340,13 @@ function buildForeignKeyIndexes(tableName: string, columns: ColumnDef[]): IndexD
311
340
  return indexes;
312
341
  }
313
342
 
314
- function buildTableChange(table: DataTable, diagnostics: Diagnostic[]): SqlChange | null {
343
+ function buildTableChange(
344
+ table: DataTable,
345
+ diagnostics: Diagnostic[],
346
+ refTables: Map<string, string>,
347
+ ): SqlChange | null {
315
348
  const tableName = toSnakeCase(table.name);
316
- const columns = buildColumns(table, diagnostics);
349
+ const columns = buildColumns(table, diagnostics, refTables);
317
350
 
318
351
  if (columns.length === 0) {
319
352
  diagnostics.push(
@@ -351,6 +384,7 @@ export function buildSqlPlan(dataGraph: DataGraph): SqlPlan {
351
384
  const diagnostics: Diagnostic[] = [];
352
385
  const tables: SqlChange[] = [];
353
386
  const indexes: SqlChange[] = [];
387
+ const refTables = buildReferenceTableLookup(dataGraph.tables);
354
388
 
355
389
  const systemTables: SqlChange[] = [
356
390
  {
@@ -406,7 +440,7 @@ export function buildSqlPlan(dataGraph: DataGraph): SqlPlan {
406
440
  ];
407
441
 
408
442
  for (const table of stableSortTables(dataGraph.tables)) {
409
- const change = buildTableChange(table, diagnostics);
443
+ const change = buildTableChange(table, diagnostics, refTables);
410
444
  if (!change) {
411
445
  continue;
412
446
  }
@@ -102,7 +102,62 @@ function readPackageJson(workspaceRoot: string): {
102
102
  };
103
103
  }
104
104
 
105
- function dependencyType(workspaceRoot: string, packageName: string): "dependency" | "devDependency" {
105
+ type DependencyType = "dependency" | "devDependency";
106
+
107
+ interface NpmAliasSpec {
108
+ packageName: string;
109
+ versionOrRange?: string;
110
+ }
111
+
112
+ interface PackageResolution {
113
+ requestedName: string;
114
+ packageName: string;
115
+ dependencyName: string;
116
+ dependencySpec?: string;
117
+ dependencyType: DependencyType;
118
+ isNpmAlias: boolean;
119
+ current: PackageApi;
120
+ }
121
+
122
+ function parseNpmAliasSpec(spec: string): NpmAliasSpec | null {
123
+ if (!spec.startsWith("npm:")) {
124
+ return null;
125
+ }
126
+ const target = spec.slice("npm:".length);
127
+ if (!target) {
128
+ return null;
129
+ }
130
+ const separator =
131
+ target.startsWith("@")
132
+ ? target.indexOf("@", target.indexOf("/") + 1)
133
+ : target.indexOf("@");
134
+ if (separator === -1) {
135
+ return { packageName: target };
136
+ }
137
+ return {
138
+ packageName: target.slice(0, separator),
139
+ versionOrRange: target.slice(separator + 1),
140
+ };
141
+ }
142
+
143
+ function dependencyEntries(
144
+ pkg: ReturnType<typeof readPackageJson>,
145
+ ): Array<{ type: DependencyType; name: string; spec: string }> {
146
+ return [
147
+ ...Object.entries(pkg.dependencies ?? {}).map(([name, spec]) => ({
148
+ type: "dependency" as const,
149
+ name,
150
+ spec,
151
+ })),
152
+ ...Object.entries(pkg.devDependencies ?? {}).map(([name, spec]) => ({
153
+ type: "devDependency" as const,
154
+ name,
155
+ spec,
156
+ })),
157
+ ];
158
+ }
159
+
160
+ function dependencyType(workspaceRoot: string, packageName: string): DependencyType {
106
161
  const pkg = readPackageJson(workspaceRoot);
107
162
  if (packageName in (pkg.devDependencies ?? {})) {
108
163
  return "devDependency";
@@ -110,9 +165,59 @@ function dependencyType(workspaceRoot: string, packageName: string): "dependency
110
165
  return "dependency";
111
166
  }
112
167
 
113
- function currentPackageApi(workspaceRoot: string, packageName: string): PackageApi | null {
168
+ function resolvePackageForUpgrade(workspaceRoot: string, requestedName: string): PackageResolution | null {
114
169
  const graph = readGeneratedJson<PackageGraph>(workspaceRoot, `${GENERATED_DIR}/packageGraph.json`);
115
- return graph?.packages.find((pkg) => pkg.name === packageName) ?? null;
170
+ if (!graph) {
171
+ return null;
172
+ }
173
+ const pkg = readPackageJson(workspaceRoot);
174
+ const entries = dependencyEntries(pkg);
175
+
176
+ const aliasEntry = entries.find((entry) => {
177
+ const alias = parseNpmAliasSpec(entry.spec);
178
+ return entry.name === requestedName || alias?.packageName === requestedName;
179
+ });
180
+ if (aliasEntry) {
181
+ const alias = parseNpmAliasSpec(aliasEntry.spec);
182
+ const packageName = alias?.packageName ?? aliasEntry.name;
183
+ const current = graph.packages.find((candidate) => candidate.name === packageName);
184
+ if (current) {
185
+ return {
186
+ requestedName,
187
+ packageName,
188
+ dependencyName: aliasEntry.name,
189
+ dependencySpec: aliasEntry.spec,
190
+ dependencyType: aliasEntry.type,
191
+ isNpmAlias: Boolean(alias),
192
+ current,
193
+ };
194
+ }
195
+ }
196
+
197
+ const current = graph.packages.find((candidate) => candidate.name === requestedName);
198
+ if (!current) {
199
+ return null;
200
+ }
201
+ const directEntry = entries.find((entry) => entry.name === requestedName);
202
+ return {
203
+ requestedName,
204
+ packageName: requestedName,
205
+ dependencyName: requestedName,
206
+ dependencySpec: directEntry?.spec,
207
+ dependencyType: directEntry?.type ?? dependencyType(workspaceRoot, requestedName),
208
+ isNpmAlias: false,
209
+ current,
210
+ };
211
+ }
212
+
213
+ function packageSpecForDependency(
214
+ resolution: Pick<PackageResolution, "packageName" | "dependencyName" | "isNpmAlias">,
215
+ version: string,
216
+ ): string {
217
+ if (resolution.isNpmAlias) {
218
+ return `${resolution.dependencyName}@npm:${resolution.packageName}@${version}`;
219
+ }
220
+ return `${resolution.packageName}@${version}`;
116
221
  }
117
222
 
118
223
  function fixtureMetadata(registryDir: string | undefined, packageName: string): {
@@ -348,9 +453,9 @@ export async function createUpgradePlan(
348
453
  ): Promise<UpgradePlannerResult> {
349
454
  const diagnostics: Diagnostic[] = [];
350
455
  const packageManager = detectPackageManager(options.workspaceRoot);
351
- const current = currentPackageApi(options.workspaceRoot, options.packageName);
456
+ const resolution = resolvePackageForUpgrade(options.workspaceRoot, options.packageName);
352
457
 
353
- if (!current) {
458
+ if (!resolution) {
354
459
  return {
355
460
  ok: false,
356
461
  diagnostics: [
@@ -363,10 +468,11 @@ export async function createUpgradePlan(
363
468
  exitCode: 1,
364
469
  };
365
470
  }
471
+ const current = resolution.current;
366
472
 
367
473
  const targetInfo = await resolveTargetVersion({
368
474
  workspaceRoot: options.workspaceRoot,
369
- packageName: options.packageName,
475
+ packageName: resolution.packageName,
370
476
  currentVersion: current.version,
371
477
  target: options.target,
372
478
  registryDir: options.registryDir ?? process.env.FORGE_DEPS_REGISTRY_DIR,
@@ -388,7 +494,7 @@ export async function createUpgradePlan(
388
494
 
389
495
  const analyzed = await analyzeTargetPackage({
390
496
  workspaceRoot: options.workspaceRoot,
391
- packageName: options.packageName,
497
+ packageName: resolution.packageName,
392
498
  versionInfo: targetInfo,
393
499
  packageManager,
394
500
  registryDir: options.registryDir ?? process.env.FORGE_DEPS_REGISTRY_DIR,
@@ -398,7 +504,7 @@ export async function createUpgradePlan(
398
504
  return { ok: false, diagnostics, exitCode: 1 };
399
505
  }
400
506
 
401
- const recipe = resolveByPackageName(options.packageName) ?? undefined;
507
+ const recipe = resolveByPackageName(resolution.packageName) ?? undefined;
402
508
  const currentClassified = { api: current, classification: classify(current, recipe), recipe };
403
509
  const targetClassified = {
404
510
  api: analyzed.api,
@@ -425,7 +531,7 @@ export async function createUpgradePlan(
425
531
  }
426
532
 
427
533
  const affected = analyzeUpgradeImpact({
428
- packageName: options.packageName,
534
+ packageName: resolution.packageName,
429
535
  appGraph,
430
536
  runtimeGraph,
431
537
  queryRegistry,
@@ -436,21 +542,35 @@ export async function createUpgradePlan(
436
542
  const bump = semverBump(current.version, analyzed.api.version);
437
543
  const risk = buildRiskReport({ bump, apiDiff, runtimeDiff, affected });
438
544
  const commands = recommendedCommands(affected);
545
+ const toInfo = {
546
+ ...targetInfo,
547
+ version: analyzed.api.version,
548
+ spec: packageSpecForDependency(resolution, analyzed.api.version),
549
+ };
439
550
  const planDir = packagePlanDir(
440
551
  options.workspaceRoot,
441
- options.packageName,
552
+ resolution.dependencyName,
442
553
  current.version,
443
554
  analyzed.api.version,
444
555
  );
445
- const id = `${packageKey(options.packageName)}-${current.version}-to-${analyzed.api.version}`;
556
+ const id = `${packageKey(resolution.dependencyName)}-${current.version}-to-${analyzed.api.version}`;
446
557
  const plan: PackageUpgradePlan = {
447
558
  schemaVersion: "0.1.0",
448
559
  plannerVersion: GENERATOR_VERSION,
449
560
  id,
450
- packageName: options.packageName,
561
+ packageName: resolution.packageName,
562
+ ...(resolution.requestedName !== resolution.packageName
563
+ ? { requestedPackageName: resolution.requestedName }
564
+ : {}),
565
+ ...(resolution.isNpmAlias ? { dependencyAlias: resolution.dependencyName } : {}),
451
566
  ...(recipe?.alias ? { integrationAlias: recipe.alias } : {}),
452
- from: { version: current.version, spec: `${options.packageName}@${current.version}` },
453
- to: { ...targetInfo, version: analyzed.api.version },
567
+ from: {
568
+ version: current.version,
569
+ spec: resolution.isNpmAlias
570
+ ? packageSpecForDependency(resolution, current.version)
571
+ : `${resolution.packageName}@${current.version}`,
572
+ },
573
+ to: toInfo,
454
574
  packageManager,
455
575
  semver: {
456
576
  bump,
@@ -146,6 +146,8 @@ export interface PackageUpgradePlan {
146
146
  plannerVersion: string;
147
147
  id: string;
148
148
  packageName: string;
149
+ requestedPackageName?: string;
150
+ dependencyAlias?: string;
149
151
  integrationAlias?: string;
150
152
  from: PackageVersionInfo;
151
153
  to: PackageVersionInfo;
@@ -333,12 +333,27 @@ function markdownResponse(body: string, status = 200): Response {
333
333
  }
334
334
 
335
335
  function corsPreflight(): Response {
336
+ const allowedHeaders = [
337
+ "Authorization",
338
+ "Content-Type",
339
+ "WorkOS-Signature",
340
+ "x-forge-user-id",
341
+ "x-forge-tenant-id",
342
+ "x-forge-organization-id",
343
+ "x-forge-organization-membership-id",
344
+ "x-forge-role",
345
+ "x-forge-roles",
346
+ "x-forge-permissions",
347
+ "x-forge-claims",
348
+ "x-forge-auth-kind",
349
+ "x-forge-trace-id",
350
+ ];
336
351
  return new Response(null, {
337
352
  status: 204,
338
353
  headers: {
339
354
  "Access-Control-Allow-Origin": "*",
340
355
  "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
341
- "Access-Control-Allow-Headers": "Authorization, Content-Type, WorkOS-Signature, x-forge-user-id, x-forge-tenant-id, x-forge-role, x-forge-roles, x-forge-permissions",
356
+ "Access-Control-Allow-Headers": allowedHeaders.join(", "),
342
357
  },
343
358
  });
344
359
  }
@@ -45,6 +45,22 @@ export type ForgeReactError = Error & {
45
45
  details?: unknown;
46
46
  };
47
47
 
48
+ export type ForgeCommandCallResult<T = unknown> =
49
+ | {
50
+ ok: true;
51
+ result: T;
52
+ status: number;
53
+ traceId?: string;
54
+ diagnostics?: { code: string; message: string }[];
55
+ }
56
+ | {
57
+ ok: false;
58
+ error: { code: string; message: string; details?: unknown };
59
+ status: number;
60
+ traceId?: string;
61
+ diagnostics?: { code: string; message: string }[];
62
+ };
63
+
48
64
  export type LiveSnapshot<T> = {
49
65
  subscriptionId: string;
50
66
  revision: number;
@@ -56,6 +72,7 @@ export type ForgeReactClient = {
56
72
  lastTraceId?: string;
57
73
  query(name: string, args: unknown): Promise<unknown>;
58
74
  command(name: string, args: unknown): Promise<unknown>;
75
+ commandResult?(name: string, args: unknown): Promise<ForgeCommandCallResult<unknown>>;
59
76
  liveQuery(
60
77
  name: string,
61
78
  args: unknown,
@@ -96,6 +113,22 @@ export type UseCommandResult<TArgs, TResult> = {
96
113
  reset: () => void;
97
114
  };
98
115
 
116
+ export type UseCommandResultOptions<TResult> = {
117
+ onSuccess?: (result: TResult) => void;
118
+ onError?: (error: ForgeReactError) => void;
119
+ onSettled?: (result: ForgeCommandCallResult<TResult>) => void;
120
+ };
121
+
122
+ export type UseCommandResultHook<TArgs, TResult> = {
123
+ run: (args: TArgs) => Promise<ForgeCommandCallResult<TResult>>;
124
+ loading: boolean;
125
+ error: ForgeReactError | null;
126
+ result: ForgeCommandCallResult<TResult> | undefined;
127
+ data: TResult | undefined;
128
+ traceId?: string;
129
+ reset: () => void;
130
+ };
131
+
99
132
  export type UseLiveQueryOptions = {
100
133
  enabled?: boolean;
101
134
  };
@@ -123,6 +156,10 @@ export type ForgeReactBindings<TClient extends ForgeReactClient = ForgeReactClie
123
156
  name: string,
124
157
  options?: UseCommandOptions<TResult>,
125
158
  ) => UseCommandResult<TArgs, TResult>;
159
+ useCommandResult: <TArgs = unknown, TResult = unknown>(
160
+ name: string,
161
+ options?: UseCommandResultOptions<TResult>,
162
+ ) => UseCommandResultHook<TArgs, TResult>;
126
163
  useLiveQuery: <TResult = unknown>(
127
164
  name: string,
128
165
  args: unknown,
@@ -165,6 +202,29 @@ function toForgeError(error: unknown): ForgeReactError {
165
202
  return wrapped;
166
203
  }
167
204
 
205
+ function commandErrorToForgeError(result: Extract<ForgeCommandCallResult, { ok: false }>): ForgeReactError {
206
+ const error = new Error(result.error.message) as ForgeReactError;
207
+ error.code = result.error.code;
208
+ error.status = result.status;
209
+ error.traceId = result.traceId;
210
+ error.details = result.error.details ?? result.diagnostics;
211
+ return error;
212
+ }
213
+
214
+ function caughtErrorToCommandResult(error: unknown): ForgeCommandCallResult<never> {
215
+ const forgeError = toForgeError(error);
216
+ return {
217
+ ok: false,
218
+ status: forgeError.status ?? 0,
219
+ traceId: forgeError.traceId,
220
+ error: {
221
+ code: forgeError.code ?? "FORGE_REACT_COMMAND_FAILED",
222
+ message: forgeError.message,
223
+ details: forgeError.details,
224
+ },
225
+ };
226
+ }
227
+
168
228
  function resolveDevAuth(devAuth: ForgeDevAuthConfig | undefined): ForgeReactAuthProvider | undefined {
169
229
  if (!devAuth) {
170
230
  return undefined;
@@ -344,6 +404,86 @@ export function createForgeReactBindings<TClient extends ForgeReactClient>(
344
404
  };
345
405
  }
346
406
 
407
+ function useCommandResult<TArgs = unknown, TResult = unknown>(
408
+ name: string,
409
+ options?: UseCommandResultOptions<TResult>,
410
+ ): UseCommandResultHook<TArgs, TResult> {
411
+ const client = useForgeClient();
412
+ const [state, setState] = React.useState<{
413
+ loading: boolean;
414
+ error: ForgeReactError | null;
415
+ result: ForgeCommandCallResult<TResult> | undefined;
416
+ data: TResult | undefined;
417
+ traceId?: string;
418
+ }>({
419
+ loading: false,
420
+ error: null,
421
+ result: undefined,
422
+ data: undefined,
423
+ });
424
+
425
+ const run = React.useCallback(
426
+ async (args: TArgs): Promise<ForgeCommandCallResult<TResult>> => {
427
+ setState((prev) => ({ ...prev, loading: true, error: null }));
428
+ let result: ForgeCommandCallResult<TResult>;
429
+ if (typeof client.commandResult === "function") {
430
+ result = (await client.commandResult(name, args)) as ForgeCommandCallResult<TResult>;
431
+ } else {
432
+ try {
433
+ const data = (await client.command(name, args)) as TResult;
434
+ result = {
435
+ ok: true,
436
+ result: data,
437
+ status: 200,
438
+ traceId: client.lastTraceId,
439
+ };
440
+ } catch (error) {
441
+ result = caughtErrorToCommandResult(error);
442
+ }
443
+ }
444
+
445
+ if (result.ok) {
446
+ setState({
447
+ loading: false,
448
+ error: null,
449
+ result,
450
+ data: result.result,
451
+ traceId: result.traceId ?? client.lastTraceId,
452
+ });
453
+ options?.onSuccess?.(result.result);
454
+ } else {
455
+ const forgeError = commandErrorToForgeError(result);
456
+ setState({
457
+ loading: false,
458
+ error: forgeError,
459
+ result,
460
+ data: undefined,
461
+ traceId: result.traceId,
462
+ });
463
+ options?.onError?.(forgeError);
464
+ }
465
+ options?.onSettled?.(result);
466
+ return result;
467
+ },
468
+ [client, name, options?.onError, options?.onSettled, options?.onSuccess],
469
+ );
470
+
471
+ const reset = React.useCallback(() => {
472
+ setState({
473
+ loading: false,
474
+ error: null,
475
+ result: undefined,
476
+ data: undefined,
477
+ });
478
+ }, []);
479
+
480
+ return {
481
+ ...state,
482
+ run,
483
+ reset,
484
+ };
485
+ }
486
+
347
487
  function useLiveQuery<TResult = unknown>(
348
488
  name: string,
349
489
  args: unknown,
@@ -430,6 +570,7 @@ export function createForgeReactBindings<TClient extends ForgeReactClient>(
430
570
  useAuth,
431
571
  useQuery,
432
572
  useCommand,
573
+ useCommandResult,
433
574
  useLiveQuery,
434
575
  };
435
576
  }
@@ -1,3 +1,3 @@
1
- export const FORGEOS_VERSION = "0.1.0-alpha.34";
1
+ export const FORGEOS_VERSION = "0.1.0-alpha.36";
2
2
  export const GENERATOR_VERSION = FORGEOS_VERSION;
3
3
  export const CLI_VERSION = FORGEOS_VERSION;