@supacloud/compiler 0.48.0 → 0.49.0

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/dist/index.js CHANGED
@@ -248,6 +248,10 @@ function findClosestMatch(target, candidates) {
248
248
  var REQUEST_CONTEXT_TOKEN_NAME = "supacloud.request-context", JOB_CONTEXT_TOKEN_NAME = "supacloud.job-context";
249
249
 
250
250
  // src/feature.ts
251
+ function featureTransitionForRoute(module, controller, route) {
252
+ const routePath = `${route.method} ${joinRoutePaths(controller.path, route.path)}`;
253
+ return module.featureSpec?.transitions.find((transition) => transition.route === routePath);
254
+ }
251
255
  function validateFeatureSpec(spec, module) {
252
256
  const diagnostics = [];
253
257
  const error = (code, message) => {
@@ -277,6 +281,11 @@ function validateFeatureSpec(spec, module) {
277
281
  if (transition.permission !== undefined && !transition.permission.trim()) {
278
282
  error("feature-governance-drift", `Transition ${transition.name} declares an empty permission.`);
279
283
  }
284
+ if (transition.invalidateTags !== undefined) {
285
+ if (transition.invalidateTags.length === 0 || transition.invalidateTags.some((tag) => !tag.trim()) || new Set(transition.invalidateTags).size !== transition.invalidateTags.length) {
286
+ error("feature-invalidation-tags-invalid", `Transition ${transition.name} must declare unique, non-empty invalidateTags.`);
287
+ }
288
+ }
280
289
  if (transition.persistence) {
281
290
  const persistence = transition.persistence;
282
291
  if (persistence.boundary === "database" && (persistence.optimisticLock !== "required" || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(persistence.versionField ?? "") || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(persistence.tenantField ?? "") || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(persistence.stateField ?? "") || new Set([persistence.tenantField, persistence.versionField, persistence.stateField]).size !== 3 || persistence.receipt !== "required" || persistence.reconciliation !== "none")) {
@@ -369,7 +378,7 @@ function validateFeatureSpec(spec, module) {
369
378
  const routes = module.controllers.flatMap((controller) => controller.routes.filter((route) => `${route.method} ${joinRoutePaths(controller.path, route.path)}` === transition.route));
370
379
  if (routes.length !== 1) {
371
380
  error("feature-route-unresolved", `Transition ${transition.name} must reference exactly one route '${transition.route}' in module ${module.name}.`);
372
- } else if (command && transition.routeBinding !== "controller" && routes[0]?.command !== command.className) {
381
+ } else if (command && transition.routeBinding !== "controller" && routes[0]?.command !== command.className && routes[0]?.command !== command.name) {
373
382
  error("feature-route-drift", `Route ${transition.route} is not bound to ${command.className}.`);
374
383
  } else if (transition.routeBinding === "controller" && routes[0]?.command !== undefined) {
375
384
  error("feature-route-drift", `Controller-bound route ${transition.route} must not also declare a framework command binding.`);
@@ -614,13 +623,17 @@ function validateGraph(graph, options = false) {
614
623
  handler: route.handler,
615
624
  ...route.redirectTo === undefined ? {} : { redirectTo: route.redirectTo }
616
625
  });
617
- if (route.command && !module.commands.some((command) => command.className === route.command)) {
618
- error("route-command-unresolved", `路由 ${key} 绑定的 command 类 ${route.command} 未在模块 ${module.name} 声明`, controller.file);
626
+ const featureTransition = featureTransitionForRoute(module, controller, route);
627
+ const effectiveCommandRef = route.command ?? featureTransition?.command;
628
+ const effectiveCommand = effectiveCommandRef === undefined ? undefined : module.commands.find((command) => command.className === effectiveCommandRef || command.name === effectiveCommandRef);
629
+ const effectiveIdempotency = route.idempotency ?? featureTransition?.idempotency;
630
+ if (route.command && !effectiveCommand) {
631
+ error("route-command-unresolved", `路由 ${key} 绑定的 command ${route.command} 未在模块 ${module.name} 声明`, controller.file);
619
632
  }
620
- if ((route.method === "GET" || route.method === "HEAD") && route.command) {
621
- const boundCommand = module.commands.find((c) => c.className === route.command);
633
+ if ((route.method === "GET" || route.method === "HEAD") && effectiveCommand) {
634
+ const boundCommand = effectiveCommand;
622
635
  if (boundCommand && boundCommand.transaction === "required") {
623
- error("command-transaction-readonly", `GET route '${route.path}' in '${controller.className}.${route.handler}' binds mutating command '${route.command}' with transaction: 'required'. Mutating transactions are not permitted on read-only HTTP GET requests.`, controller.file, undefined, `Use POST, PUT, or PATCH for mutating command routes, or set transaction: 'none'.`);
636
+ error("command-transaction-readonly", `GET route '${route.path}' in '${controller.className}.${route.handler}' binds mutating command '${boundCommand.name}' with transaction: 'required'. Mutating transactions are not permitted on read-only HTTP GET requests.`, controller.file, undefined, `Use POST, PUT, or PATCH for mutating command routes, or set transaction: 'none'.`);
624
637
  }
625
638
  }
626
639
  if (typeof options === "object" && options.allowRouteCommandBindings === false && route.command) {
@@ -691,6 +704,9 @@ function validateGraph(graph, options = false) {
691
704
  if (route.allowDeleteBody && (route.method !== "DELETE" || !route.body)) {
692
705
  error("invalid-delete-body-opt-in", `Route ${controller.className}.${route.handler} allowDeleteBody requires DELETE and an explicit body schema.`, controller.file);
693
706
  }
707
+ if (effectiveIdempotency === "required" && (route.method === "GET" || route.method === "HEAD" || route.method === "OPTIONS")) {
708
+ error("invalid-route-idempotency", `Route ${controller.className}.${route.handler} cannot require idempotency on HTTP ${route.method}.`, controller.file, undefined, `Use idempotency: "required" only on a mutation route.`);
709
+ }
694
710
  if (route.parse === "none" && (route.body || route.hasBodyBinding || route.contract?.body !== "domain" || !route.contract.evidence?.trim())) {
695
711
  error("invalid-raw-body-contract", `Route ${controller.className}.${route.handler} parse: "none" requires domain body ownership and evidence, without a parsed body schema or @Body binding.`, controller.file);
696
712
  }
@@ -1365,6 +1381,7 @@ var init_validate = __esm(() => {
1365
1381
  "command-transaction-unsupported": { code: "SC4026", docsUrl: "https://supacloud.dev/errors/SC4026" },
1366
1382
  "command-reconcile-required": { code: "SC4028", docsUrl: "https://supacloud.dev/errors/SC4028" },
1367
1383
  "invalid-route-contract": { code: "SC3020", docsUrl: "https://supacloud.dev/errors/SC3020" },
1384
+ "invalid-route-idempotency": { code: "SC3028", docsUrl: "https://supacloud.dev/errors/SC3028" },
1368
1385
  "route-contract-required": { code: "SC3021", docsUrl: "https://supacloud.dev/errors/SC3021" },
1369
1386
  "route-contract-unverified": { code: "SC3022", docsUrl: "https://supacloud.dev/errors/SC3022" },
1370
1387
  "route-contract-evidence-required": { code: "SC3023", docsUrl: "https://supacloud.dev/errors/SC3023" },
@@ -1511,6 +1528,7 @@ function buildContractManifest(graph, artifacts) {
1511
1528
  ...transition.idempotency === undefined ? {} : { idempotency: transition.idempotency },
1512
1529
  ...transition.audit === undefined ? {} : { audit: transition.audit },
1513
1530
  ...transition.job === undefined ? {} : { job: transition.job },
1531
+ ...transition.invalidateTags === undefined ? {} : { invalidateTags: [...transition.invalidateTags] },
1514
1532
  ...job === undefined ? {} : {
1515
1533
  jobDetails: {
1516
1534
  name: job.name,
@@ -1531,9 +1549,14 @@ function buildContractManifest(graph, artifacts) {
1531
1549
  }
1532
1550
  for (const controller of module.controllers) {
1533
1551
  for (const route of controller.routes) {
1534
- const command = route.command ? module.commands.find((candidate) => candidate.className === route.command) : undefined;
1535
- if (command?.permission)
1536
- permissions.add(command.permission);
1552
+ const command = route.command ? module.commands.find((candidate) => candidate.className === route.command || candidate.name === route.command) : undefined;
1553
+ const featureTransition = featureTransitionForRoute(module, controller, route);
1554
+ const featureCommand = featureTransition?.command === undefined ? undefined : module.commands.find((candidate) => candidate.className === featureTransition.command || candidate.name === featureTransition.command);
1555
+ const governedCommand = command ?? featureCommand;
1556
+ const effectiveCommand = route.command ?? featureTransition?.command;
1557
+ const idempotency = route.idempotency ?? command?.idempotency ?? featureTransition?.idempotency;
1558
+ if (governedCommand?.permission)
1559
+ permissions.add(governedCommand.permission);
1537
1560
  if (route.contract?.evidence)
1538
1561
  fixtures.add(route.contract.evidence);
1539
1562
  routes.push({
@@ -1542,8 +1565,10 @@ function buildContractManifest(graph, artifacts) {
1542
1565
  module: module.name,
1543
1566
  controller: controller.className,
1544
1567
  handler: route.handler,
1545
- ...route.command === undefined ? {} : { command: route.command },
1546
- ...command?.permission === undefined ? {} : { permission: command.permission },
1568
+ ...effectiveCommand === undefined ? {} : { command: effectiveCommand },
1569
+ ...governedCommand?.permission === undefined ? {} : { permission: governedCommand.permission },
1570
+ ...idempotency === undefined ? {} : { idempotency },
1571
+ ...route.tags === undefined ? {} : { tags: [...route.tags] },
1547
1572
  requestSchemas: Object.fromEntries(["body", "params", "query", "headers", "cookie"].flatMap((key) => route[key] === undefined ? [] : [[key, route[key]]])),
1548
1573
  ...route.response === undefined ? {} : { responseSchema: route.response },
1549
1574
  ...route.responses === undefined ? {} : {
@@ -1582,7 +1607,9 @@ function buildContractManifest(graph, artifacts) {
1582
1607
  }
1583
1608
  };
1584
1609
  }
1585
- var init_contract_manifest = () => {};
1610
+ var init_contract_manifest = __esm(() => {
1611
+ init_feature();
1612
+ });
1586
1613
 
1587
1614
  // src/generate.ts
1588
1615
  import { createHash as createHash4 } from "node:crypto";
@@ -1994,6 +2021,9 @@ class ModuleGenerator {
1994
2021
  return "[]";
1995
2022
  const items = this.module.controllers.map((controller) => {
1996
2023
  const routes = controller.routes.map((route) => {
2024
+ const command = route.command === undefined ? undefined : this.module.commands.find((candidate) => candidate.className === route.command || candidate.name === route.command);
2025
+ const featureTransition = featureTransitionForRoute(this.module, controller, route);
2026
+ const idempotency = route.idempotency ?? command?.idempotency ?? featureTransition?.idempotency;
1997
2027
  const responseEntries = normalizedResponseEntries(route.responses);
1998
2028
  const hasResponseMap = responseEntries.length > 0;
1999
2029
  const fields = [
@@ -2020,6 +2050,9 @@ class ModuleGenerator {
2020
2050
  if (route.effect !== undefined) {
2021
2051
  fields.push(`effect: ${JSON.stringify(route.effect)}`);
2022
2052
  }
2053
+ if (idempotency !== undefined) {
2054
+ fields.push(`idempotency: ${JSON.stringify(idempotency)}`);
2055
+ }
2023
2056
  if (route.command)
2024
2057
  fields.push(`command: ${JSON.stringify(route.command)}`);
2025
2058
  if (route.guards && route.guards.length > 0) {
@@ -2502,7 +2535,18 @@ function renderClient(graph, options) {
2502
2535
  const usedTypeNames = new Set;
2503
2536
  const allRoutes = [];
2504
2537
  const allProcedures = [];
2505
- const commandByClassName = new Map(graph.modules.flatMap((module) => module.commands.map((command) => [command.className, command])));
2538
+ const commandByClassName = new Map(graph.modules.flatMap((module) => module.commands.flatMap((command) => [
2539
+ [command.className, command],
2540
+ [command.name, command]
2541
+ ])));
2542
+ const featureInvalidationTags = new Map;
2543
+ for (const module of graph.modules) {
2544
+ for (const transition of module.featureSpec?.transitions ?? []) {
2545
+ if (transition.route && transition.invalidateTags) {
2546
+ featureInvalidationTags.set(`${module.name}:${transition.route}`, [...transition.invalidateTags]);
2547
+ }
2548
+ }
2549
+ }
2506
2550
  let usesStatic = false;
2507
2551
  let usesValue = false;
2508
2552
  const uniqueTypeName = (candidate) => {
@@ -2580,6 +2624,13 @@ function renderClient(graph, options) {
2580
2624
  const requestType = `ClientRequestOptions<${paramsType}, ${queryType}, ${bodyType}, ${headersType}, ${cookieType}>${requiredRequestFields.length > 0 ? ` & { ${requiredRequestFields.join("; ")} }` : ""}`;
2581
2625
  routeTypes.push(`export type ${requestTypeName} = ${requestType};`);
2582
2626
  routeTypes.push(`export type ${responseTypeName} = ${responseType === "never" ? "unknown" : responseType};`);
2627
+ const command = route.command === undefined ? undefined : commandByClassName.get(route.command);
2628
+ const featureTransition = featureTransitionForRoute(module, controller, route);
2629
+ const featureCommand = featureTransition?.command === undefined ? undefined : commandByClassName.get(featureTransition.command);
2630
+ const governedCommand = command ?? featureCommand;
2631
+ const idempotencyMode = route.idempotency ?? command?.idempotency ?? featureTransition?.idempotency ?? "none";
2632
+ const transitionRoute = `${route.method} ${fullPath}`;
2633
+ const invalidationTags = featureInvalidationTags.get(`${module.name}:${transitionRoute}`);
2583
2634
  const routeFields = [
2584
2635
  `method: ${JSON.stringify(route.method)}`,
2585
2636
  `path: ${JSON.stringify(fullPath)}`,
@@ -2587,7 +2638,7 @@ function renderClient(graph, options) {
2587
2638
  `handler: ${JSON.stringify(route.handler)}`
2588
2639
  ];
2589
2640
  for (const [key, value] of [
2590
- ["command", route.command],
2641
+ ["command", route.command ?? featureTransition?.command],
2591
2642
  ["guards", route.guards],
2592
2643
  ["canMatch", route.canMatch],
2593
2644
  ["canDeactivate", route.canDeactivate],
@@ -2601,7 +2652,10 @@ function renderClient(graph, options) {
2601
2652
  ["title", route.title],
2602
2653
  ["data", route.data],
2603
2654
  ["parse", route.parse],
2604
- ["allowDeleteBody", route.allowDeleteBody]
2655
+ ["allowDeleteBody", route.allowDeleteBody],
2656
+ ["idempotency", idempotencyMode === "none" ? undefined : idempotencyMode],
2657
+ ["tags", route.tags],
2658
+ ["invalidateTags", invalidationTags]
2605
2659
  ]) {
2606
2660
  if (value !== undefined)
2607
2661
  routeFields.push(`${key}: ${JSON.stringify(value)}`);
@@ -2634,9 +2688,7 @@ function renderClient(graph, options) {
2634
2688
  const responseSchemaEntries = hasResponseMap ? Object.entries(responseSchemas).map(([status, ref]) => [status, ref.local]) : responseSchema ? [["200", responseSchema.local]] : [];
2635
2689
  const responseSchemaArgument = responseSchemaEntries.length > 0 ? `{ ${responseSchemaEntries.map(([status, local]) => `${JSON.stringify(status)}: ${local}`).join(", ")} }` : "undefined";
2636
2690
  const responseKindArgument = responseKind === undefined ? "undefined" : JSON.stringify(responseKind);
2637
- const command = route.command === undefined ? undefined : commandByClassName.get(route.command);
2638
2691
  const procedureKind = route.method === "GET" || route.method === "HEAD" ? "query" : "mutation";
2639
- const idempotencyMode = command?.idempotency ?? "none";
2640
2692
  const procedureMetadata = {
2641
2693
  key: `${controller.className}.${route.handler}`,
2642
2694
  method: route.method,
@@ -2644,7 +2696,9 @@ function renderClient(graph, options) {
2644
2696
  controller: controller.className,
2645
2697
  handler: route.handler,
2646
2698
  kind: procedureKind,
2647
- idempotency: idempotencyMode
2699
+ idempotency: idempotencyMode,
2700
+ ...route.tags === undefined ? {} : { tags: route.tags },
2701
+ ...invalidationTags === undefined ? {} : { invalidateTags: invalidationTags }
2648
2702
  };
2649
2703
  allProcedures.push({
2650
2704
  method: route.method,
@@ -2652,9 +2706,11 @@ function renderClient(graph, options) {
2652
2706
  controller: controller.className,
2653
2707
  handler: route.handler,
2654
2708
  kind: procedureKind,
2655
- ...command?.name === undefined ? {} : { command: command.name },
2656
- ...command?.permission === undefined ? {} : { permission: command.permission },
2657
- idempotency: idempotencyMode
2709
+ ...governedCommand?.name === undefined ? {} : { command: governedCommand.name },
2710
+ ...governedCommand?.permission === undefined ? {} : { permission: governedCommand.permission },
2711
+ idempotency: idempotencyMode,
2712
+ ...route.tags === undefined ? {} : { tags: [...route.tags] },
2713
+ ...invalidationTags === undefined ? {} : { invalidateTags: [...invalidationTags] }
2658
2714
  });
2659
2715
  routeMethods.push(`
2660
2716
  ${route.handler}: makeRoute<${requestTypeName}, ${responseType === "never" ? "never" : responseTypeName}, ${JSON.stringify(procedureKind)}, ${JSON.stringify(idempotencyMode)}>(${JSON.stringify(route.method)}, ${JSON.stringify(fullPath)}, ${responseSchemaArgument}, ${responseKindArgument}, ${route.parse === "none"}, ${JSON.stringify(procedureKind)}, ${JSON.stringify(idempotencyMode)}, ${JSON.stringify(procedureMetadata)}),`);
@@ -2708,6 +2764,8 @@ ${procedureMethods.join(`
2708
2764
  " handler: string;",
2709
2765
  " kind: Kind;",
2710
2766
  " idempotency: Idempotency;",
2767
+ " tags?: readonly string[];",
2768
+ " invalidateTags?: readonly string[];",
2711
2769
  "}",
2712
2770
  "",
2713
2771
  "export interface ProcedureExecutionOptions {",
@@ -2813,6 +2871,8 @@ ${procedureMethods.join(`
2813
2871
  " command?: string;",
2814
2872
  " permission?: string;",
2815
2873
  " idempotency: IdempotencyMode;",
2874
+ " tags?: readonly string[];",
2875
+ " invalidateTags?: readonly string[];",
2816
2876
  "}",
2817
2877
  "",
2818
2878
  ...routeTypes,
@@ -3312,7 +3372,10 @@ function renderOpenApi(graph, options) {
3312
3372
  }
3313
3373
  return schemaComponents.get(key);
3314
3374
  };
3315
- const commandByClass = new Map(graph.modules.flatMap((module) => module.commands.map((command) => [command.className, command])));
3375
+ const commandByClass = new Map(graph.modules.flatMap((module) => module.commands.flatMap((command) => [
3376
+ [command.className, command],
3377
+ [command.name, command]
3378
+ ])));
3316
3379
  for (const module of graph.modules) {
3317
3380
  for (const controller of module.controllers) {
3318
3381
  for (const route of controller.routes) {
@@ -3334,7 +3397,12 @@ function renderOpenApi(graph, options) {
3334
3397
  const operationBase = pascalName(`${module.name}_${controller.className}_${route.handler}`);
3335
3398
  const operationId = uniqueName(operationBase, operationIds);
3336
3399
  const command = route.command ? commandByClass.get(route.command) : undefined;
3337
- const requiresAuth = (route.guards?.length ?? 0) > 0 || command !== undefined;
3400
+ const featureTransition = featureTransitionForRoute(module, controller, route);
3401
+ const featureCommand = featureTransition?.command === undefined ? undefined : commandByClass.get(featureTransition.command);
3402
+ const governedCommand = command ?? featureCommand;
3403
+ const idempotencyMode = route.idempotency ?? command?.idempotency ?? featureTransition?.idempotency ?? "none";
3404
+ const permission = governedCommand?.permission ?? featureTransition?.permission;
3405
+ const requiresAuth = (route.guards?.length ?? 0) > 0 || permission !== undefined || governedCommand !== undefined;
3338
3406
  const responseKind = route.contract?.response;
3339
3407
  const responseContentType = responseKind === "binary" || responseKind === "stream" ? "application/octet-stream" : "application/json";
3340
3408
  const definition = {
@@ -3359,11 +3427,12 @@ function renderOpenApi(graph, options) {
3359
3427
  responseKind: responseKind ?? (route.nativeResponse ? "native-response" : undefined),
3360
3428
  ...route.parse === undefined ? {} : { parse: route.parse },
3361
3429
  ...route.allowDeleteBody === undefined ? {} : { allowDeleteBody: route.allowDeleteBody },
3430
+ ...idempotencyMode === "none" ? {} : { idempotency: idempotencyMode },
3362
3431
  module: module.name,
3363
3432
  controller: controller.className,
3364
3433
  handler: route.handler,
3365
- ...route.command === undefined ? {} : { command: route.command },
3366
- ...command?.permission === undefined ? {} : { permission: command.permission },
3434
+ ...route.command === undefined && featureTransition?.command === undefined ? {} : { command: route.command ?? featureTransition?.command },
3435
+ ...permission === undefined ? {} : { permission },
3367
3436
  ...route.guards && route.guards.length > 0 ? { guards: route.guards } : {},
3368
3437
  ...route.canMatch && route.canMatch.length > 0 ? { canMatch: route.canMatch } : {},
3369
3438
  ...route.canDeactivate && route.canDeactivate.length > 0 ? { canDeactivate: route.canDeactivate } : {},
@@ -3438,6 +3507,7 @@ function renderOpenApi(graph, options) {
3438
3507
  " handler: string;",
3439
3508
  " command?: string;",
3440
3509
  " permission?: string;",
3510
+ ' idempotency?: "required" | "none";',
3441
3511
  " guards?: readonly string[];",
3442
3512
  " canMatch?: readonly string[];",
3443
3513
  " canDeactivate?: readonly string[];",
@@ -3690,6 +3760,18 @@ function renderOpenApi(graph, options) {
3690
3760
  " for (const name of headersSchema ? schemaPropertyNames(headersSchema) : []) parameters.push({",
3691
3761
  ' name, in: "header", required: schemaRequired(headersSchema, name), schema: schemaProperty(headersSchema, name),',
3692
3762
  " });",
3763
+ ' if (definition.idempotency === "required") {',
3764
+ ' const keySchema = { type: "string", minLength: 1, maxLength: 512, pattern: "^[A-Za-z0-9._:-]{1,512}$" };',
3765
+ ' const declaredHeaders = parameters.filter((parameter) => parameter["in"] === "header" && typeof parameter["name"] === "string" && parameter["name"].toLowerCase() === "idempotency-key");',
3766
+ " const header = declaredHeaders[0];",
3767
+ " if (header) {",
3768
+ ' header["required"] = true;',
3769
+ ' header["schema"] = { allOf: [...declaredHeaders.map((parameter) => parameter["schema"]), keySchema] };',
3770
+ " for (const duplicate of declaredHeaders.slice(1)) parameters.splice(parameters.indexOf(duplicate), 1);",
3771
+ " } else {",
3772
+ ' parameters.push({ name: "Idempotency-Key", in: "header", required: true, schema: keySchema });',
3773
+ " }",
3774
+ " }",
3693
3775
  " const cookieSchema = definition.cookieSchema ? OPENAPI_SCHEMA_REGISTRY[definition.cookieSchema as keyof typeof OPENAPI_SCHEMA_REGISTRY] : undefined;",
3694
3776
  " for (const name of cookieSchema ? schemaPropertyNames(cookieSchema) : []) parameters.push({",
3695
3777
  ' name, in: "cookie", required: schemaRequired(cookieSchema, name), schema: schemaProperty(cookieSchema, name),',
@@ -3712,6 +3794,7 @@ function renderOpenApi(graph, options) {
3712
3794
  ' responses["500"] = { description: "Internal server error", content: { "application/json": { schema: { $ref: "#/components/schemas/SupaCloudError" } } } };',
3713
3795
  ' responses["200"] = { description: "Successful response" };',
3714
3796
  " }",
3797
+ ' if (definition.idempotency === "required" && (!definition.responseSchemas || !responseMapCoversStatus(definition.responseSchemas, 400))) responses["400"] = { description: "Idempotency-Key is missing or invalid", content: { "application/json": { schema: { $ref: "#/components/schemas/SupaCloudError" } } } };',
3715
3798
  " const operation: Record<string, unknown> = {",
3716
3799
  " operationId: definition.operationId, tags: [...definition.tags], summary: definition.summary, responses,",
3717
3800
  " ...(parameters.length === 0 ? {} : { parameters }),",
@@ -3722,6 +3805,7 @@ function renderOpenApi(graph, options) {
3722
3805
  " module: definition.module, controller: definition.controller, handler: definition.handler,",
3723
3806
  " ...(definition.command ? { command: definition.command } : {}),",
3724
3807
  " ...(definition.permission ? { permission: definition.permission } : {}),",
3808
+ " ...(definition.idempotency ? { idempotency: definition.idempotency } : {}),",
3725
3809
  " ...(definition.guards ? { guards: [...definition.guards] } : {}),",
3726
3810
  " ...(definition.canMatch ? { canMatch: [...definition.canMatch] } : {}),",
3727
3811
  " ...(definition.canDeactivate ? { canDeactivate: [...definition.canDeactivate] } : {}),",
@@ -3772,11 +3856,13 @@ function renderPermissions(graph) {
3772
3856
  }
3773
3857
  for (const controller of module.controllers) {
3774
3858
  for (const route of controller.routes) {
3775
- let perm;
3776
- if (route.command) {
3777
- const cmd = module.commands.find((c) => c.className === route.command);
3778
- perm = cmd?.permission;
3779
- }
3859
+ const featureTransition = featureTransitionForRoute(module, controller, route);
3860
+ const command = route.command === undefined ? undefined : module.commands.find((candidate) => candidate.className === route.command || candidate.name === route.command);
3861
+ const featureCommand = featureTransition?.command === undefined ? undefined : module.commands.find((candidate) => candidate.className === featureTransition.command || candidate.name === featureTransition.command);
3862
+ const governedCommand = command ?? featureCommand;
3863
+ const effectiveCommand = route.command ?? featureTransition?.command;
3864
+ const perm = governedCommand?.permission ?? featureTransition?.permission;
3865
+ const idempotency = route.idempotency ?? command?.idempotency ?? featureTransition?.idempotency;
3780
3866
  if (perm)
3781
3867
  permissions.add(perm);
3782
3868
  bindings.push({
@@ -3784,8 +3870,9 @@ function renderPermissions(graph) {
3784
3870
  path: joinRoutePaths(controller.path, route.path),
3785
3871
  controller: controller.className,
3786
3872
  handler: route.handler,
3787
- ...route.command === undefined ? {} : { command: route.command },
3788
- ...perm === undefined ? {} : { permission: perm }
3873
+ ...effectiveCommand === undefined ? {} : { command: effectiveCommand },
3874
+ ...perm === undefined ? {} : { permission: perm },
3875
+ ...idempotency === undefined ? {} : { idempotency }
3789
3876
  });
3790
3877
  }
3791
3878
  }
@@ -3811,6 +3898,7 @@ function renderPermissions(graph) {
3811
3898
  " handler: string;",
3812
3899
  " command?: string;",
3813
3900
  " permission?: string;",
3901
+ ' idempotency?: "required" | "none";',
3814
3902
  "}",
3815
3903
  "",
3816
3904
  "export const RoutePermissions: RoutePermissionBinding[] = " + JSON.stringify(bindings, null, 2) + ";",
@@ -3854,6 +3942,8 @@ var HEADER = "// GENERATED BY @supacloud/compiler — do not edit", INTERFACES =
3854
3942
  /** True when the handler returns a native Response rather than a framework value. */
3855
3943
  nativeResponse?: boolean;
3856
3944
  command?: string;
3945
+ /** Transport-level idempotency contract, independent of route command binding. */
3946
+ idempotency?: "required" | "none";
3857
3947
  guards?: string[];
3858
3948
  canMatch?: string[];
3859
3949
  canDeactivate?: string[];
@@ -4091,6 +4181,7 @@ async function destroyServiceInstances(
4091
4181
  `;
4092
4182
  var init_generate = __esm(() => {
4093
4183
  init_contract_manifest();
4184
+ init_feature();
4094
4185
  });
4095
4186
 
4096
4187
  // src/route-contracts.ts
@@ -14813,7 +14904,7 @@ function parseDeliveryPlanResult(value) {
14813
14904
  throw new Error("Invalid delivery plan result.");
14814
14905
  return value;
14815
14906
  }
14816
- var objectOptions, reference, references, kind, isolation, DeliveryFilePathSchema, functionTarget, DeliveryOptionsSchema, DeliveryDiagnosticSchema, routeSchema, jobSchema, DeliveryTargetSchema, DeliveryPlanSchema, DeliveryPlanResultSchema, DeliveryConfigurationError;
14907
+ var objectOptions, reference, references, kind, isolation, mode, DeliveryFilePathSchema, functionTarget, DeliveryOptionsSchema, DeliveryDiagnosticSchema, routeSchema, jobSchema, DeliveryTargetSchema, DeliveryPlanSchema, DeliveryPlanResultSchema, DeliveryConfigurationError;
14817
14908
  var init_delivery_schema = __esm(() => {
14818
14909
  init_build();
14819
14910
  init_value2();
@@ -14823,6 +14914,7 @@ var init_delivery_schema = __esm(() => {
14823
14914
  references = _Array_(reference, { uniqueItems: true });
14824
14915
  kind = Union([Literal("api"), Literal("jobs"), Literal("webhook"), Literal("function")]);
14825
14916
  isolation = Union([Literal("shared"), Literal("process")]);
14917
+ mode = Union([Literal("required"), Literal("none")]);
14826
14918
  DeliveryFilePathSchema = String2({
14827
14919
  maxLength: 512,
14828
14920
  pattern: "^(?:[A-Za-z0-9_-][A-Za-z0-9_.-]*/)*[A-Za-z0-9_-][A-Za-z0-9_.-]*$"
@@ -14896,6 +14988,7 @@ var init_delivery_schema = __esm(() => {
14896
14988
  ]),
14897
14989
  path: String2(),
14898
14990
  command: Optional(String2()),
14991
+ idempotency: Optional(mode),
14899
14992
  reason: Union([Literal("default-http"), Literal("explicit-module")])
14900
14993
  }, objectOptions);
14901
14994
  jobSchema = _Object_({
@@ -15154,7 +15247,12 @@ function parseDeliveryBuildManifest(value) {
15154
15247
  throw new Error("Invalid delivery object inventory.");
15155
15248
  }
15156
15249
  }
15157
- const routes = value.plan.targets.flatMap((target) => target.routes.map((route) => ({ method: route.method, path: route.path, target: target.name })));
15250
+ const routes = value.plan.targets.flatMap((target) => target.routes.map((route) => ({
15251
+ method: route.method,
15252
+ path: route.path,
15253
+ target: target.name,
15254
+ ...route.idempotency === undefined ? {} : { idempotency: route.idempotency }
15255
+ })));
15158
15256
  const jobs = value.plan.targets.flatMap((target) => target.jobs.map((job) => ({ name: job.name, target: target.name })));
15159
15257
  if (canonical2(value.routes) !== canonical2(routes) || canonical2(value.jobs) !== canonical2(jobs)) {
15160
15258
  throw new Error("Delivery mappings do not match planned ownership.");
@@ -15205,7 +15303,8 @@ var init_delivery_build_schema = __esm(() => {
15205
15303
  routes: _Array_(_Object_({
15206
15304
  method: String2(),
15207
15305
  path: String2(),
15208
- target: String2()
15306
+ target: String2(),
15307
+ idempotency: Optional(Union([Literal("required"), Literal("none")]))
15209
15308
  }, closed2)),
15210
15309
  jobs: _Array_(_Object_({ name: String2(), target: String2() }, closed2))
15211
15310
  }, closed2);
@@ -15424,7 +15523,9 @@ function createExecutionPlans(graph) {
15424
15523
  const aspects = (boundary, refs = []) => refs.map((ref, index) => `${boundary}.aspect[${index}]:${ref.name}`);
15425
15524
  return graph.modules.flatMap((module) => [
15426
15525
  ...module.controllers.flatMap((controller) => controller.routes.map((route) => {
15427
- const command = module.commands.find((item) => item.className === route.command);
15526
+ const featureTransition = featureTransitionForRoute(module, controller, route);
15527
+ const commandRef = route.command ?? featureTransition?.command;
15528
+ const command = module.commands.find((item) => item.className === commandRef || item.name === commandRef);
15428
15529
  const path = `${controller.path}/${route.path}`.replace(/\/+/g, "/");
15429
15530
  return {
15430
15531
  module: module.name,
@@ -15574,6 +15675,7 @@ function exportGraphDot(graph) {
15574
15675
  }
15575
15676
  var init_inspect = __esm(() => {
15576
15677
  init_route_contracts();
15678
+ init_feature();
15577
15679
  });
15578
15680
 
15579
15681
  // src/repair-plan.ts
@@ -15827,7 +15929,7 @@ function parseDevelopmentContext(input) {
15827
15929
  throw invalid();
15828
15930
  }
15829
15931
  }
15830
- var APPLICATION_DEVELOPMENT_SCHEMA = "supacloud.application-development.v1", APPLICATION_DEVELOPMENT_ARCHIVE_MAX_BYTES = 524288, APPLICATION_DEVELOPMENT_LIMITS, DevelopmentContractError, closed4, text2, texts, count, mode, operations2, resourceUse, schemaKind, stage, ContextSchema;
15932
+ var APPLICATION_DEVELOPMENT_SCHEMA = "supacloud.application-development.v1", APPLICATION_DEVELOPMENT_ARCHIVE_MAX_BYTES = 524288, APPLICATION_DEVELOPMENT_LIMITS, DevelopmentContractError, closed4, text2, texts, count, mode2, operations2, resourceUse, schemaKind, stage, ContextSchema;
15831
15933
  var init_application_development = __esm(() => {
15832
15934
  init_build();
15833
15935
  init_value2();
@@ -15855,7 +15957,7 @@ var init_application_development = __esm(() => {
15855
15957
  text2 = String2({ minLength: 1, maxLength: 512, pattern: "^[^\\u0000-\\u001f\\u007f]+$" });
15856
15958
  texts = _Array_(text2);
15857
15959
  count = Integer({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER });
15858
- mode = Union([Literal("required"), Literal("none")]);
15960
+ mode2 = Union([Literal("required"), Literal("none")]);
15859
15961
  operations2 = _Array_(Union([Literal("read"), Literal("write"), Literal("publish"), Literal("consume")]));
15860
15962
  resourceUse = _Object_({ resource: text2, operations: operations2 }, closed4);
15861
15963
  schemaKind = Optional(Union([Literal("opaque"), Literal("declared")]));
@@ -15890,6 +15992,7 @@ var init_application_development = __esm(() => {
15890
15992
  controller: text2,
15891
15993
  handler: text2,
15892
15994
  command: Optional(text2),
15995
+ idempotency: Optional(mode2),
15893
15996
  aspects: texts,
15894
15997
  schemaKinds: Optional(_Object_({
15895
15998
  body: schemaKind,
@@ -15904,8 +16007,8 @@ var init_application_development = __esm(() => {
15904
16007
  module: text2,
15905
16008
  name: text2,
15906
16009
  permission: Optional(text2),
15907
- transaction: mode,
15908
- idempotency: mode,
16010
+ transaction: mode2,
16011
+ idempotency: mode2,
15909
16012
  audit: Optional(text2),
15910
16013
  resources: _Array_(resourceUse)
15911
16014
  }, closed4), { maxItems: APPLICATION_DEVELOPMENT_LIMITS.commands }),
@@ -16012,7 +16115,11 @@ function createApplicationDevelopmentContext(graph, options = {}) {
16012
16115
  for (const controller of module.controllers) {
16013
16116
  for (const route of controller.routes) {
16014
16117
  const candidates = module.commands.filter((command) => command.className === route.command || command.name === route.command);
16015
- const command = candidates.length === 1 ? candidates[0]?.name : undefined;
16118
+ const commandNode = candidates.length === 1 ? candidates[0] : undefined;
16119
+ const featureTransition = featureTransitionForRoute(module, controller, route);
16120
+ const featureCommandNode = featureTransition?.command === undefined ? undefined : module.commands.find((candidate) => candidate.className === featureTransition.command || candidate.name === featureTransition.command);
16121
+ const command = commandNode?.name ?? featureCommandNode?.name;
16122
+ const idempotency = route.idempotency ?? commandNode?.idempotency ?? featureTransition?.idempotency;
16016
16123
  const kinds = schemaKinds(route);
16017
16124
  routes.push({
16018
16125
  module: module.name,
@@ -16021,6 +16128,7 @@ function createApplicationDevelopmentContext(graph, options = {}) {
16021
16128
  controller: controller.className,
16022
16129
  handler: route.handler,
16023
16130
  ...command === undefined ? {} : { command },
16131
+ ...idempotency === undefined ? {} : { idempotency },
16024
16132
  aspects: (route.aspects ?? []).map((aspect) => aspect.name),
16025
16133
  ...kinds === undefined ? {} : { schemaKinds: kinds }
16026
16134
  });
@@ -16141,6 +16249,7 @@ var init_application_development2 = __esm(() => {
16141
16249
  init_application_development();
16142
16250
  init_inspect();
16143
16251
  init_execution_snapshot();
16252
+ init_feature();
16144
16253
  APPLICATION_DEVELOPMENT_LIMITS2 = APPLICATION_DEVELOPMENT_LIMITS;
16145
16254
  ApplicationDevelopmentError = class ApplicationDevelopmentError extends Error {
16146
16255
  code;
@@ -19495,6 +19604,10 @@ function parseFeatureSpec(input, ctx, seen = new Set) {
19495
19604
  const idempotency = commandModeProp(options, "idempotency");
19496
19605
  const audit = stringLiteralProp(options, "audit");
19497
19606
  const job = stringLiteralProp(options, "job");
19607
+ const invalidateTagsExpr = getProp(options, "invalidateTags");
19608
+ const invalidateTags = invalidateTagsExpr === undefined ? undefined : ts7.isArrayLiteralExpression(invalidateTagsExpr) && invalidateTagsExpr.elements.every((element) => ts7.isStringLiteral(element) && element.text.trim().length > 0) ? invalidateTagsExpr.elements.map((element) => element.text) : undefined;
19609
+ if (invalidateTagsExpr !== undefined && invalidateTags === undefined)
19610
+ return invalid();
19498
19611
  const persistenceExpr = getProp(options, "persistence");
19499
19612
  const persistence = persistenceExpr && ts7.isObjectLiteralExpression(persistenceExpr) ? parseFeaturePersistence(persistenceExpr) : undefined;
19500
19613
  if (persistenceExpr && !persistence)
@@ -19511,6 +19624,7 @@ function parseFeatureSpec(input, ctx, seen = new Set) {
19511
19624
  ...idempotency === undefined ? {} : { idempotency },
19512
19625
  ...audit === undefined ? {} : { audit },
19513
19626
  ...job === undefined ? {} : { job },
19627
+ ...invalidateTags === undefined ? {} : { invalidateTags },
19514
19628
  ...persistence === undefined ? {} : { persistence }
19515
19629
  });
19516
19630
  }
@@ -19613,6 +19727,54 @@ function checkedCommandMode(object, property, ctx, command) {
19613
19727
  }
19614
19728
  return value;
19615
19729
  }
19730
+ function checkedRouteIdempotency(object, ctx, route) {
19731
+ const expression = getProp(object, "idempotency");
19732
+ const value = stringLiteralProp(object, "idempotency");
19733
+ if (expression && value !== "required" && value !== "none") {
19734
+ const file = sourcePath(ctx.rootDir, expression.getSourceFile().fileName);
19735
+ ctx.diagnostics.push({
19736
+ severity: "error",
19737
+ code: "invalid-route-idempotency",
19738
+ errorCode: "SC3028",
19739
+ docsUrl: "https://supacloud.dev/errors/SC3028",
19740
+ message: `${route}.idempotency must be the explicit literal "required" or "none".`,
19741
+ file,
19742
+ line: lineOf(expression),
19743
+ suggestion: `Choose the intended idempotency policy explicitly; use "required" for replayable mutations or "none" for routes without that contract.`
19744
+ });
19745
+ }
19746
+ return value === "required" || value === "none" ? value : undefined;
19747
+ }
19748
+ function checkedRouteTags(expression, ctx, route) {
19749
+ if (expression === undefined)
19750
+ return;
19751
+ if (!ts7.isArrayLiteralExpression(expression) || expression.elements.some((element) => !ts7.isStringLiteral(element) || element.text.trim().length === 0)) {
19752
+ ctx.diagnostics.push({
19753
+ severity: "error",
19754
+ code: "invalid-route-tags",
19755
+ errorCode: "SC3029",
19756
+ docsUrl: "https://supacloud.dev/errors/SC3029",
19757
+ message: `${route}.tags must be a static array of non-empty string literals.`,
19758
+ file: sourcePath(ctx.rootDir, expression.getSourceFile().fileName),
19759
+ line: lineOf(expression),
19760
+ suggestion: 'Declare stable cache tags explicitly, for example tags: ["cases.list"].'
19761
+ });
19762
+ return;
19763
+ }
19764
+ const tags = expression.elements.map((element) => element.text);
19765
+ if (new Set(tags).size !== tags.length) {
19766
+ ctx.diagnostics.push({
19767
+ severity: "error",
19768
+ code: "duplicate-route-tags",
19769
+ errorCode: "SC3030",
19770
+ docsUrl: "https://supacloud.dev/errors/SC3030",
19771
+ message: `${route}.tags must not contain duplicate values.`,
19772
+ file: sourcePath(ctx.rootDir, expression.getSourceFile().fileName),
19773
+ line: lineOf(expression)
19774
+ });
19775
+ }
19776
+ return tags;
19777
+ }
19616
19778
  function parseProvider(el, exportsSet, ctx) {
19617
19779
  const file = sourcePath(ctx.rootDir, el.getSourceFile().fileName);
19618
19780
  const line = lineOf(el);
@@ -20392,7 +20554,11 @@ function parseController(input, ctx) {
20392
20554
  if (optionsObject) {
20393
20555
  const parsePolicy = routePolicyProperty(optionsObject, "parse", ctx);
20394
20556
  const deletePolicy = routePolicyProperty(optionsObject, "allowDeleteBody", ctx);
20395
- if (parsePolicy.unresolved || deletePolicy.unresolved) {
20557
+ const idempotency = checkedRouteIdempotency(optionsObject, ctx, route.handler);
20558
+ if (idempotency !== undefined)
20559
+ route.idempotency = idempotency;
20560
+ const tagsPolicy = routePolicyProperty(optionsObject, "tags", ctx);
20561
+ if (parsePolicy.unresolved || deletePolicy.unresolved || tagsPolicy.unresolved) {
20396
20562
  ctx.diagnostics.push({
20397
20563
  severity: "error",
20398
20564
  code: "unresolved-route-body-policy",
@@ -20400,6 +20566,9 @@ function parseController(input, ctx) {
20400
20566
  message: `Route ${route.handler} body policy must be statically resolvable; dynamic spreads, computed keys and shorthand policies are unsupported.`
20401
20567
  });
20402
20568
  }
20569
+ const tags = checkedRouteTags(tagsPolicy.value, ctx, route.handler);
20570
+ if (tags !== undefined)
20571
+ route.tags = tags;
20403
20572
  const parse = parsePolicy.value;
20404
20573
  if (parse) {
20405
20574
  if (ts7.isStringLiteral(parse) && parse.text === "none")
@@ -22250,6 +22419,7 @@ function validateClientSchemaBoundaries(graph, options) {
22250
22419
 
22251
22420
  // src/effect-governance.ts
22252
22421
  init_validate();
22422
+ init_feature();
22253
22423
  function diagnostic(code, message, file, suggestion) {
22254
22424
  const metadata = COMPILER_DIAGNOSTIC_CODES[code];
22255
22425
  return {
@@ -22287,8 +22457,10 @@ function validateEffectPolicies(graph, options = {}) {
22287
22457
  if (effect.retry === "explicit" && effect.maxAttempts === undefined) {
22288
22458
  diagnostics.push(diagnostic("invalid-effect-retry", `Route ${label} enables explicit Effect retry without maxAttempts.`, controller.file, "Declare a positive maxAttempts value including the initial attempt."));
22289
22459
  }
22290
- if (route.command && effect.retry === "explicit") {
22291
- const command = commands.get(route.command);
22460
+ if (effect.retry === "explicit") {
22461
+ const featureTransition = featureTransitionForRoute(module, controller, route);
22462
+ const commandRef = route.command ?? featureTransition?.command;
22463
+ const command = commandRef === undefined ? undefined : commands.get(commandRef) ?? [...commands.values()].find((candidate) => candidate.name === commandRef);
22292
22464
  if (command && command.idempotency !== "required") {
22293
22465
  diagnostics.push(diagnostic("effect-command-retry-forbidden", `Route ${label} enables explicit Effect retry for non-idempotent command "${command.name}".`, controller.file, 'Set command idempotency to "required" or use effect.retry: "none".'));
22294
22466
  }
@@ -22543,6 +22715,7 @@ function resolveTypeSafety(options) {
22543
22715
  init_delivery_schema();
22544
22716
 
22545
22717
  // src/delivery-plan.ts
22718
+ init_feature();
22546
22719
  init_validate();
22547
22720
  var compare2 = (a, b) => a < b ? -1 : a > b ? 1 : 0;
22548
22721
  var sorted = (values) => [...new Set(values)].sort(compare2);
@@ -22662,6 +22835,10 @@ function createDeliveryPlan(graph, input, validation) {
22662
22835
  fail("delivery-route-conflict", `Public route "${key}" is owned by both "${existing}" and "${target.name}".`, "Give public routes unambiguous method/path contracts before splitting targets.");
22663
22836
  }
22664
22837
  publicRoutes.set(key, target.name);
22838
+ const command = route.command === undefined ? undefined : module.commands.find((candidate) => candidate.className === route.command || candidate.name === route.command);
22839
+ const featureTransition = featureTransitionForRoute(module, controller, route);
22840
+ const effectiveCommand = route.command ?? featureTransition?.command;
22841
+ const idempotency = route.idempotency ?? command?.idempotency ?? featureTransition?.idempotency;
22665
22842
  target.roots.push(module.name);
22666
22843
  target.routes.push({
22667
22844
  module: module.name,
@@ -22669,7 +22846,8 @@ function createDeliveryPlan(graph, input, validation) {
22669
22846
  handler: route.handler,
22670
22847
  method: route.method,
22671
22848
  path,
22672
- ...route.command === undefined ? {} : { command: route.command },
22849
+ ...effectiveCommand === undefined ? {} : { command: effectiveCommand },
22850
+ ...idempotency === undefined ? {} : { idempotency },
22673
22851
  reason: owner === undefined ? "default-http" : "explicit-module"
22674
22852
  });
22675
22853
  }
@@ -23700,7 +23878,12 @@ async function buildDeliveryProject(options, delivery) {
23700
23878
  deploymentReady: false,
23701
23879
  plan: planned.plan,
23702
23880
  objects,
23703
- routes: planned.plan.targets.flatMap((target) => target.routes.map((route) => ({ method: route.method, path: route.path, target: target.name }))),
23881
+ routes: planned.plan.targets.flatMap((target) => target.routes.map((route) => ({
23882
+ method: route.method,
23883
+ path: route.path,
23884
+ target: target.name,
23885
+ ...route.idempotency === undefined ? {} : { idempotency: route.idempotency }
23886
+ }))),
23704
23887
  jobs: planned.plan.targets.flatMap((target) => target.jobs.map((job) => ({ name: job.name, target: target.name })))
23705
23888
  });
23706
23889
  phase = "artifact-integrity";
@@ -24450,10 +24633,10 @@ import { readFileSync as readFileSync4 } from "node:fs";
24450
24633
  import { resolve as resolve17 } from "node:path";
24451
24634
 
24452
24635
  // src/migration-app-version.ts
24453
- var MIGRATION_APP_VERSION = "0.29.0";
24636
+ var MIGRATION_APP_VERSION = "0.30.0";
24454
24637
 
24455
24638
  // src/migration-elysia-version.ts
24456
- var MIGRATION_ELYSIA_VERSION = "0.34.0";
24639
+ var MIGRATION_ELYSIA_VERSION = "0.35.0";
24457
24640
 
24458
24641
  // src/migration-policy.ts
24459
24642
  var cachedCompilerVersion;
@@ -25488,8 +25671,8 @@ function validFeatureInventory(value, jobs, commands, routes, rpc) {
25488
25671
  return false;
25489
25672
  }
25490
25673
  const commandName = transition["command"];
25674
+ const command = commandName === undefined ? undefined : commands.find((candidate) => isRecord2(candidate) && (candidate["name"] === commandName || candidate["className"] === commandName));
25491
25675
  if (commandName !== undefined) {
25492
- const command = commands.find((candidate) => isRecord2(candidate) && (candidate["name"] === commandName || candidate["className"] === commandName));
25493
25676
  if (!isRecord2(command))
25494
25677
  return false;
25495
25678
  if (transition["permission"] !== undefined && command["permission"] !== transition["permission"])
@@ -25520,9 +25703,21 @@ function validFeatureInventory(value, jobs, commands, routes, rpc) {
25520
25703
  if (routeBinding === "controller") {
25521
25704
  if (route["command"] !== undefined)
25522
25705
  return false;
25523
- } else if (route["command"] !== commandName) {
25524
- return false;
25706
+ } else {
25707
+ if (typeof route["command"] !== "string")
25708
+ return false;
25709
+ const routeCommand = commands.find((candidate) => isRecord2(candidate) && (candidate["name"] === route["command"] || candidate["className"] === route["command"]));
25710
+ if (!isRecord2(routeCommand) || !isRecord2(command) || routeCommand["name"] !== command["name"])
25711
+ return false;
25525
25712
  }
25713
+ if (transition["permission"] !== undefined && route["permission"] !== transition["permission"])
25714
+ return false;
25715
+ if (transition["idempotency"] !== undefined && route["idempotency"] !== transition["idempotency"])
25716
+ return false;
25717
+ if (persistence !== undefined && route["idempotency"] !== "required")
25718
+ return false;
25719
+ if (route["idempotency"] === "required" && (route["method"] === "GET" || route["method"] === "HEAD" || route["method"] === "OPTIONS"))
25720
+ return false;
25526
25721
  }
25527
25722
  const job = transition["job"];
25528
25723
  if (job === undefined)