@supacloud/compiler 0.48.0 → 0.50.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/cli.js CHANGED
@@ -249,6 +249,10 @@ function findClosestMatch(target, candidates) {
249
249
  var REQUEST_CONTEXT_TOKEN_NAME = "supacloud.request-context", JOB_CONTEXT_TOKEN_NAME = "supacloud.job-context";
250
250
 
251
251
  // src/feature.ts
252
+ function featureTransitionForRoute(module, controller, route) {
253
+ const routePath = `${route.method} ${joinRoutePaths(controller.path, route.path)}`;
254
+ return module.featureSpec?.transitions.find((transition) => transition.route === routePath);
255
+ }
252
256
  function validateFeatureSpec(spec, module) {
253
257
  const diagnostics = [];
254
258
  const error = (code, message) => {
@@ -266,6 +270,21 @@ function validateFeatureSpec(spec, module) {
266
270
  if (spec.tenantField !== undefined && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(spec.tenantField)) {
267
271
  error("feature-tenant-boundary-invalid", `Feature ${spec.name} declares an unsafe tenant field.`);
268
272
  }
273
+ const databaseTransition = spec.transitions.some((transition) => transition.persistence?.boundary === "database");
274
+ if (databaseTransition && spec.database === undefined && spec.file !== undefined) {
275
+ error("feature-database-source-required", `Database Feature ${spec.name} must declare its database source, table and rowType.`);
276
+ }
277
+ if (spec.database !== undefined) {
278
+ if (spec.database.source.startsWith("/") || spec.database.source.includes("\\") || spec.database.source.split("/").includes("..") || !/^[A-Za-z0-9._/-]+$/.test(spec.database.source)) {
279
+ error("feature-database-source-invalid", `Feature ${spec.name} declares an unsafe database source.`);
280
+ }
281
+ if (!/^[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*$/.test(spec.database.table)) {
282
+ error("feature-database-table-invalid", `Feature ${spec.name} must use a schema-qualified database table.`);
283
+ }
284
+ if (!/^[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*$/.test(spec.database.rowType)) {
285
+ error("feature-database-row-type-invalid", `Feature ${spec.name} declares an unsafe database row type.`);
286
+ }
287
+ }
269
288
  const names = new Set;
270
289
  for (const transition of spec.transitions) {
271
290
  if (!transition.name.trim() || names.has(transition.name)) {
@@ -278,6 +297,11 @@ function validateFeatureSpec(spec, module) {
278
297
  if (transition.permission !== undefined && !transition.permission.trim()) {
279
298
  error("feature-governance-drift", `Transition ${transition.name} declares an empty permission.`);
280
299
  }
300
+ if (transition.invalidateTags !== undefined) {
301
+ if (transition.invalidateTags.length === 0 || transition.invalidateTags.some((tag) => !tag.trim()) || new Set(transition.invalidateTags).size !== transition.invalidateTags.length) {
302
+ error("feature-invalidation-tags-invalid", `Transition ${transition.name} must declare unique, non-empty invalidateTags.`);
303
+ }
304
+ }
281
305
  if (transition.persistence) {
282
306
  const persistence = transition.persistence;
283
307
  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")) {
@@ -370,7 +394,7 @@ function validateFeatureSpec(spec, module) {
370
394
  const routes = module.controllers.flatMap((controller) => controller.routes.filter((route) => `${route.method} ${joinRoutePaths(controller.path, route.path)}` === transition.route));
371
395
  if (routes.length !== 1) {
372
396
  error("feature-route-unresolved", `Transition ${transition.name} must reference exactly one route '${transition.route}' in module ${module.name}.`);
373
- } else if (command && transition.routeBinding !== "controller" && routes[0]?.command !== command.className) {
397
+ } else if (command && transition.routeBinding !== "controller" && routes[0]?.command !== command.className && routes[0]?.command !== command.name) {
374
398
  error("feature-route-drift", `Route ${transition.route} is not bound to ${command.className}.`);
375
399
  } else if (transition.routeBinding === "controller" && routes[0]?.command !== undefined) {
376
400
  error("feature-route-drift", `Controller-bound route ${transition.route} must not also declare a framework command binding.`);
@@ -397,6 +421,7 @@ function generateFeatureSource(spec) {
397
421
  const sourceSpec = {
398
422
  name: spec.name,
399
423
  states: spec.states,
424
+ ...spec.database === undefined ? {} : { database: spec.database },
400
425
  tenantField: spec.tenantField ?? "tenant_id",
401
426
  transitions: Object.fromEntries(transitions.map((transition) => {
402
427
  const { name, ...options } = transition;
@@ -615,13 +640,17 @@ function validateGraph(graph, options = false) {
615
640
  handler: route.handler,
616
641
  ...route.redirectTo === undefined ? {} : { redirectTo: route.redirectTo }
617
642
  });
618
- if (route.command && !module.commands.some((command) => command.className === route.command)) {
619
- error("route-command-unresolved", `路由 ${key} 绑定的 command 类 ${route.command} 未在模块 ${module.name} 声明`, controller.file);
643
+ const featureTransition = featureTransitionForRoute(module, controller, route);
644
+ const effectiveCommandRef = route.command ?? featureTransition?.command;
645
+ const effectiveCommand = effectiveCommandRef === undefined ? undefined : module.commands.find((command) => command.className === effectiveCommandRef || command.name === effectiveCommandRef);
646
+ const effectiveIdempotency = route.idempotency ?? featureTransition?.idempotency;
647
+ if (route.command && !effectiveCommand) {
648
+ error("route-command-unresolved", `路由 ${key} 绑定的 command ${route.command} 未在模块 ${module.name} 声明`, controller.file);
620
649
  }
621
- if ((route.method === "GET" || route.method === "HEAD") && route.command) {
622
- const boundCommand = module.commands.find((c) => c.className === route.command);
650
+ if ((route.method === "GET" || route.method === "HEAD") && effectiveCommand) {
651
+ const boundCommand = effectiveCommand;
623
652
  if (boundCommand && boundCommand.transaction === "required") {
624
- 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'.`);
653
+ 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'.`);
625
654
  }
626
655
  }
627
656
  if (typeof options === "object" && options.allowRouteCommandBindings === false && route.command) {
@@ -692,6 +721,9 @@ function validateGraph(graph, options = false) {
692
721
  if (route.allowDeleteBody && (route.method !== "DELETE" || !route.body)) {
693
722
  error("invalid-delete-body-opt-in", `Route ${controller.className}.${route.handler} allowDeleteBody requires DELETE and an explicit body schema.`, controller.file);
694
723
  }
724
+ if (effectiveIdempotency === "required" && (route.method === "GET" || route.method === "HEAD" || route.method === "OPTIONS")) {
725
+ 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.`);
726
+ }
695
727
  if (route.parse === "none" && (route.body || route.hasBodyBinding || route.contract?.body !== "domain" || !route.contract.evidence?.trim())) {
696
728
  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);
697
729
  }
@@ -1366,6 +1398,7 @@ var init_validate = __esm(() => {
1366
1398
  "command-transaction-unsupported": { code: "SC4026", docsUrl: "https://supacloud.dev/errors/SC4026" },
1367
1399
  "command-reconcile-required": { code: "SC4028", docsUrl: "https://supacloud.dev/errors/SC4028" },
1368
1400
  "invalid-route-contract": { code: "SC3020", docsUrl: "https://supacloud.dev/errors/SC3020" },
1401
+ "invalid-route-idempotency": { code: "SC3028", docsUrl: "https://supacloud.dev/errors/SC3028" },
1369
1402
  "route-contract-required": { code: "SC3021", docsUrl: "https://supacloud.dev/errors/SC3021" },
1370
1403
  "route-contract-unverified": { code: "SC3022", docsUrl: "https://supacloud.dev/errors/SC3022" },
1371
1404
  "route-contract-evidence-required": { code: "SC3023", docsUrl: "https://supacloud.dev/errors/SC3023" },
@@ -1417,6 +1450,10 @@ var init_validate = __esm(() => {
1417
1450
  "feature-job-mode": { code: "SC6119", docsUrl: "https://supacloud.dev/errors/SC6119" },
1418
1451
  "feature-job-contract": { code: "SC6120", docsUrl: "https://supacloud.dev/errors/SC6120" },
1419
1452
  "feature-job-context": { code: "SC6121", docsUrl: "https://supacloud.dev/errors/SC6121" },
1453
+ "feature-database-source-required": { code: "SC6122", docsUrl: "https://supacloud.dev/errors/SC6122" },
1454
+ "feature-database-source-invalid": { code: "SC6123", docsUrl: "https://supacloud.dev/errors/SC6123" },
1455
+ "feature-database-table-invalid": { code: "SC6124", docsUrl: "https://supacloud.dev/errors/SC6124" },
1456
+ "feature-database-row-type-invalid": { code: "SC6125", docsUrl: "https://supacloud.dev/errors/SC6125" },
1420
1457
  "feature-route-unresolved": { code: "SC6106", docsUrl: "https://supacloud.dev/errors/SC6106" },
1421
1458
  "feature-route-drift": { code: "SC6107", docsUrl: "https://supacloud.dev/errors/SC6107" },
1422
1459
  "invalid-feature-spec": { code: "SC6108", docsUrl: "https://supacloud.dev/errors/SC6108" }
@@ -1497,6 +1534,7 @@ function buildContractManifest(graph, artifacts) {
1497
1534
  features.push({
1498
1535
  name: module.featureSpec.name,
1499
1536
  states: [...module.featureSpec.states],
1537
+ ...module.featureSpec.database === undefined ? {} : { database: module.featureSpec.database },
1500
1538
  ...module.featureSpec.tenantField === undefined ? {} : { tenantField: module.featureSpec.tenantField },
1501
1539
  transitions: module.featureSpec.transitions.map((transition) => {
1502
1540
  const job = transition.job === undefined ? undefined : (module.jobs ?? []).find((candidate) => candidate.className === transition.job || candidate.name === transition.job);
@@ -1512,6 +1550,7 @@ function buildContractManifest(graph, artifacts) {
1512
1550
  ...transition.idempotency === undefined ? {} : { idempotency: transition.idempotency },
1513
1551
  ...transition.audit === undefined ? {} : { audit: transition.audit },
1514
1552
  ...transition.job === undefined ? {} : { job: transition.job },
1553
+ ...transition.invalidateTags === undefined ? {} : { invalidateTags: [...transition.invalidateTags] },
1515
1554
  ...job === undefined ? {} : {
1516
1555
  jobDetails: {
1517
1556
  name: job.name,
@@ -1532,9 +1571,14 @@ function buildContractManifest(graph, artifacts) {
1532
1571
  }
1533
1572
  for (const controller of module.controllers) {
1534
1573
  for (const route of controller.routes) {
1535
- const command = route.command ? module.commands.find((candidate) => candidate.className === route.command) : undefined;
1536
- if (command?.permission)
1537
- permissions.add(command.permission);
1574
+ const command = route.command ? module.commands.find((candidate) => candidate.className === route.command || candidate.name === route.command) : undefined;
1575
+ const featureTransition = featureTransitionForRoute(module, controller, route);
1576
+ const featureCommand = featureTransition?.command === undefined ? undefined : module.commands.find((candidate) => candidate.className === featureTransition.command || candidate.name === featureTransition.command);
1577
+ const governedCommand = command ?? featureCommand;
1578
+ const effectiveCommand = route.command ?? featureTransition?.command;
1579
+ const idempotency = route.idempotency ?? command?.idempotency ?? featureTransition?.idempotency;
1580
+ if (governedCommand?.permission)
1581
+ permissions.add(governedCommand.permission);
1538
1582
  if (route.contract?.evidence)
1539
1583
  fixtures.add(route.contract.evidence);
1540
1584
  routes.push({
@@ -1543,8 +1587,10 @@ function buildContractManifest(graph, artifacts) {
1543
1587
  module: module.name,
1544
1588
  controller: controller.className,
1545
1589
  handler: route.handler,
1546
- ...route.command === undefined ? {} : { command: route.command },
1547
- ...command?.permission === undefined ? {} : { permission: command.permission },
1590
+ ...effectiveCommand === undefined ? {} : { command: effectiveCommand },
1591
+ ...governedCommand?.permission === undefined ? {} : { permission: governedCommand.permission },
1592
+ ...idempotency === undefined ? {} : { idempotency },
1593
+ ...route.tags === undefined ? {} : { tags: [...route.tags] },
1548
1594
  requestSchemas: Object.fromEntries(["body", "params", "query", "headers", "cookie"].flatMap((key) => route[key] === undefined ? [] : [[key, route[key]]])),
1549
1595
  ...route.response === undefined ? {} : { responseSchema: route.response },
1550
1596
  ...route.responses === undefined ? {} : {
@@ -1583,7 +1629,9 @@ function buildContractManifest(graph, artifacts) {
1583
1629
  }
1584
1630
  };
1585
1631
  }
1586
- var init_contract_manifest = () => {};
1632
+ var init_contract_manifest = __esm(() => {
1633
+ init_feature();
1634
+ });
1587
1635
 
1588
1636
  // src/generate.ts
1589
1637
  import { createHash as createHash4 } from "node:crypto";
@@ -1995,6 +2043,9 @@ class ModuleGenerator {
1995
2043
  return "[]";
1996
2044
  const items = this.module.controllers.map((controller) => {
1997
2045
  const routes = controller.routes.map((route) => {
2046
+ const command = route.command === undefined ? undefined : this.module.commands.find((candidate) => candidate.className === route.command || candidate.name === route.command);
2047
+ const featureTransition = featureTransitionForRoute(this.module, controller, route);
2048
+ const idempotency = route.idempotency ?? command?.idempotency ?? featureTransition?.idempotency;
1998
2049
  const responseEntries = normalizedResponseEntries(route.responses);
1999
2050
  const hasResponseMap = responseEntries.length > 0;
2000
2051
  const fields = [
@@ -2021,6 +2072,9 @@ class ModuleGenerator {
2021
2072
  if (route.effect !== undefined) {
2022
2073
  fields.push(`effect: ${JSON.stringify(route.effect)}`);
2023
2074
  }
2075
+ if (idempotency !== undefined) {
2076
+ fields.push(`idempotency: ${JSON.stringify(idempotency)}`);
2077
+ }
2024
2078
  if (route.command)
2025
2079
  fields.push(`command: ${JSON.stringify(route.command)}`);
2026
2080
  if (route.guards && route.guards.length > 0) {
@@ -2503,7 +2557,18 @@ function renderClient(graph, options) {
2503
2557
  const usedTypeNames = new Set;
2504
2558
  const allRoutes = [];
2505
2559
  const allProcedures = [];
2506
- const commandByClassName = new Map(graph.modules.flatMap((module) => module.commands.map((command) => [command.className, command])));
2560
+ const commandByClassName = new Map(graph.modules.flatMap((module) => module.commands.flatMap((command) => [
2561
+ [command.className, command],
2562
+ [command.name, command]
2563
+ ])));
2564
+ const featureInvalidationTags = new Map;
2565
+ for (const module of graph.modules) {
2566
+ for (const transition of module.featureSpec?.transitions ?? []) {
2567
+ if (transition.route && transition.invalidateTags) {
2568
+ featureInvalidationTags.set(`${module.name}:${transition.route}`, [...transition.invalidateTags]);
2569
+ }
2570
+ }
2571
+ }
2507
2572
  let usesStatic = false;
2508
2573
  let usesValue = false;
2509
2574
  const uniqueTypeName = (candidate) => {
@@ -2581,6 +2646,13 @@ function renderClient(graph, options) {
2581
2646
  const requestType = `ClientRequestOptions<${paramsType}, ${queryType}, ${bodyType}, ${headersType}, ${cookieType}>${requiredRequestFields.length > 0 ? ` & { ${requiredRequestFields.join("; ")} }` : ""}`;
2582
2647
  routeTypes.push(`export type ${requestTypeName} = ${requestType};`);
2583
2648
  routeTypes.push(`export type ${responseTypeName} = ${responseType === "never" ? "unknown" : responseType};`);
2649
+ const command = route.command === undefined ? undefined : commandByClassName.get(route.command);
2650
+ const featureTransition = featureTransitionForRoute(module, controller, route);
2651
+ const featureCommand = featureTransition?.command === undefined ? undefined : commandByClassName.get(featureTransition.command);
2652
+ const governedCommand = command ?? featureCommand;
2653
+ const idempotencyMode = route.idempotency ?? command?.idempotency ?? featureTransition?.idempotency ?? "none";
2654
+ const transitionRoute = `${route.method} ${fullPath}`;
2655
+ const invalidationTags = featureInvalidationTags.get(`${module.name}:${transitionRoute}`);
2584
2656
  const routeFields = [
2585
2657
  `method: ${JSON.stringify(route.method)}`,
2586
2658
  `path: ${JSON.stringify(fullPath)}`,
@@ -2588,7 +2660,7 @@ function renderClient(graph, options) {
2588
2660
  `handler: ${JSON.stringify(route.handler)}`
2589
2661
  ];
2590
2662
  for (const [key, value] of [
2591
- ["command", route.command],
2663
+ ["command", route.command ?? featureTransition?.command],
2592
2664
  ["guards", route.guards],
2593
2665
  ["canMatch", route.canMatch],
2594
2666
  ["canDeactivate", route.canDeactivate],
@@ -2602,7 +2674,10 @@ function renderClient(graph, options) {
2602
2674
  ["title", route.title],
2603
2675
  ["data", route.data],
2604
2676
  ["parse", route.parse],
2605
- ["allowDeleteBody", route.allowDeleteBody]
2677
+ ["allowDeleteBody", route.allowDeleteBody],
2678
+ ["idempotency", idempotencyMode === "none" ? undefined : idempotencyMode],
2679
+ ["tags", route.tags],
2680
+ ["invalidateTags", invalidationTags]
2606
2681
  ]) {
2607
2682
  if (value !== undefined)
2608
2683
  routeFields.push(`${key}: ${JSON.stringify(value)}`);
@@ -2635,9 +2710,7 @@ function renderClient(graph, options) {
2635
2710
  const responseSchemaEntries = hasResponseMap ? Object.entries(responseSchemas).map(([status, ref]) => [status, ref.local]) : responseSchema ? [["200", responseSchema.local]] : [];
2636
2711
  const responseSchemaArgument = responseSchemaEntries.length > 0 ? `{ ${responseSchemaEntries.map(([status, local]) => `${JSON.stringify(status)}: ${local}`).join(", ")} }` : "undefined";
2637
2712
  const responseKindArgument = responseKind === undefined ? "undefined" : JSON.stringify(responseKind);
2638
- const command = route.command === undefined ? undefined : commandByClassName.get(route.command);
2639
2713
  const procedureKind = route.method === "GET" || route.method === "HEAD" ? "query" : "mutation";
2640
- const idempotencyMode = command?.idempotency ?? "none";
2641
2714
  const procedureMetadata = {
2642
2715
  key: `${controller.className}.${route.handler}`,
2643
2716
  method: route.method,
@@ -2645,7 +2718,9 @@ function renderClient(graph, options) {
2645
2718
  controller: controller.className,
2646
2719
  handler: route.handler,
2647
2720
  kind: procedureKind,
2648
- idempotency: idempotencyMode
2721
+ idempotency: idempotencyMode,
2722
+ ...route.tags === undefined ? {} : { tags: route.tags },
2723
+ ...invalidationTags === undefined ? {} : { invalidateTags: invalidationTags }
2649
2724
  };
2650
2725
  allProcedures.push({
2651
2726
  method: route.method,
@@ -2653,9 +2728,11 @@ function renderClient(graph, options) {
2653
2728
  controller: controller.className,
2654
2729
  handler: route.handler,
2655
2730
  kind: procedureKind,
2656
- ...command?.name === undefined ? {} : { command: command.name },
2657
- ...command?.permission === undefined ? {} : { permission: command.permission },
2658
- idempotency: idempotencyMode
2731
+ ...governedCommand?.name === undefined ? {} : { command: governedCommand.name },
2732
+ ...governedCommand?.permission === undefined ? {} : { permission: governedCommand.permission },
2733
+ idempotency: idempotencyMode,
2734
+ ...route.tags === undefined ? {} : { tags: [...route.tags] },
2735
+ ...invalidationTags === undefined ? {} : { invalidateTags: [...invalidationTags] }
2659
2736
  });
2660
2737
  routeMethods.push(`
2661
2738
  ${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)}),`);
@@ -2709,6 +2786,8 @@ ${procedureMethods.join(`
2709
2786
  " handler: string;",
2710
2787
  " kind: Kind;",
2711
2788
  " idempotency: Idempotency;",
2789
+ " tags?: readonly string[];",
2790
+ " invalidateTags?: readonly string[];",
2712
2791
  "}",
2713
2792
  "",
2714
2793
  "export interface ProcedureExecutionOptions {",
@@ -2814,6 +2893,8 @@ ${procedureMethods.join(`
2814
2893
  " command?: string;",
2815
2894
  " permission?: string;",
2816
2895
  " idempotency: IdempotencyMode;",
2896
+ " tags?: readonly string[];",
2897
+ " invalidateTags?: readonly string[];",
2817
2898
  "}",
2818
2899
  "",
2819
2900
  ...routeTypes,
@@ -3313,7 +3394,10 @@ function renderOpenApi(graph, options) {
3313
3394
  }
3314
3395
  return schemaComponents.get(key);
3315
3396
  };
3316
- const commandByClass = new Map(graph.modules.flatMap((module) => module.commands.map((command) => [command.className, command])));
3397
+ const commandByClass = new Map(graph.modules.flatMap((module) => module.commands.flatMap((command) => [
3398
+ [command.className, command],
3399
+ [command.name, command]
3400
+ ])));
3317
3401
  for (const module of graph.modules) {
3318
3402
  for (const controller of module.controllers) {
3319
3403
  for (const route of controller.routes) {
@@ -3335,7 +3419,12 @@ function renderOpenApi(graph, options) {
3335
3419
  const operationBase = pascalName(`${module.name}_${controller.className}_${route.handler}`);
3336
3420
  const operationId = uniqueName(operationBase, operationIds);
3337
3421
  const command = route.command ? commandByClass.get(route.command) : undefined;
3338
- const requiresAuth = (route.guards?.length ?? 0) > 0 || command !== undefined;
3422
+ const featureTransition = featureTransitionForRoute(module, controller, route);
3423
+ const featureCommand = featureTransition?.command === undefined ? undefined : commandByClass.get(featureTransition.command);
3424
+ const governedCommand = command ?? featureCommand;
3425
+ const idempotencyMode = route.idempotency ?? command?.idempotency ?? featureTransition?.idempotency ?? "none";
3426
+ const permission = governedCommand?.permission ?? featureTransition?.permission;
3427
+ const requiresAuth = (route.guards?.length ?? 0) > 0 || permission !== undefined || governedCommand !== undefined;
3339
3428
  const responseKind = route.contract?.response;
3340
3429
  const responseContentType = responseKind === "binary" || responseKind === "stream" ? "application/octet-stream" : "application/json";
3341
3430
  const definition = {
@@ -3360,11 +3449,12 @@ function renderOpenApi(graph, options) {
3360
3449
  responseKind: responseKind ?? (route.nativeResponse ? "native-response" : undefined),
3361
3450
  ...route.parse === undefined ? {} : { parse: route.parse },
3362
3451
  ...route.allowDeleteBody === undefined ? {} : { allowDeleteBody: route.allowDeleteBody },
3452
+ ...idempotencyMode === "none" ? {} : { idempotency: idempotencyMode },
3363
3453
  module: module.name,
3364
3454
  controller: controller.className,
3365
3455
  handler: route.handler,
3366
- ...route.command === undefined ? {} : { command: route.command },
3367
- ...command?.permission === undefined ? {} : { permission: command.permission },
3456
+ ...route.command === undefined && featureTransition?.command === undefined ? {} : { command: route.command ?? featureTransition?.command },
3457
+ ...permission === undefined ? {} : { permission },
3368
3458
  ...route.guards && route.guards.length > 0 ? { guards: route.guards } : {},
3369
3459
  ...route.canMatch && route.canMatch.length > 0 ? { canMatch: route.canMatch } : {},
3370
3460
  ...route.canDeactivate && route.canDeactivate.length > 0 ? { canDeactivate: route.canDeactivate } : {},
@@ -3439,6 +3529,7 @@ function renderOpenApi(graph, options) {
3439
3529
  " handler: string;",
3440
3530
  " command?: string;",
3441
3531
  " permission?: string;",
3532
+ ' idempotency?: "required" | "none";',
3442
3533
  " guards?: readonly string[];",
3443
3534
  " canMatch?: readonly string[];",
3444
3535
  " canDeactivate?: readonly string[];",
@@ -3691,6 +3782,18 @@ function renderOpenApi(graph, options) {
3691
3782
  " for (const name of headersSchema ? schemaPropertyNames(headersSchema) : []) parameters.push({",
3692
3783
  ' name, in: "header", required: schemaRequired(headersSchema, name), schema: schemaProperty(headersSchema, name),',
3693
3784
  " });",
3785
+ ' if (definition.idempotency === "required") {',
3786
+ ' const keySchema = { type: "string", minLength: 1, maxLength: 512, pattern: "^[A-Za-z0-9._:-]{1,512}$" };',
3787
+ ' const declaredHeaders = parameters.filter((parameter) => parameter["in"] === "header" && typeof parameter["name"] === "string" && parameter["name"].toLowerCase() === "idempotency-key");',
3788
+ " const header = declaredHeaders[0];",
3789
+ " if (header) {",
3790
+ ' header["required"] = true;',
3791
+ ' header["schema"] = { allOf: [...declaredHeaders.map((parameter) => parameter["schema"]), keySchema] };',
3792
+ " for (const duplicate of declaredHeaders.slice(1)) parameters.splice(parameters.indexOf(duplicate), 1);",
3793
+ " } else {",
3794
+ ' parameters.push({ name: "Idempotency-Key", in: "header", required: true, schema: keySchema });',
3795
+ " }",
3796
+ " }",
3694
3797
  " const cookieSchema = definition.cookieSchema ? OPENAPI_SCHEMA_REGISTRY[definition.cookieSchema as keyof typeof OPENAPI_SCHEMA_REGISTRY] : undefined;",
3695
3798
  " for (const name of cookieSchema ? schemaPropertyNames(cookieSchema) : []) parameters.push({",
3696
3799
  ' name, in: "cookie", required: schemaRequired(cookieSchema, name), schema: schemaProperty(cookieSchema, name),',
@@ -3713,6 +3816,7 @@ function renderOpenApi(graph, options) {
3713
3816
  ' responses["500"] = { description: "Internal server error", content: { "application/json": { schema: { $ref: "#/components/schemas/SupaCloudError" } } } };',
3714
3817
  ' responses["200"] = { description: "Successful response" };',
3715
3818
  " }",
3819
+ ' 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" } } } };',
3716
3820
  " const operation: Record<string, unknown> = {",
3717
3821
  " operationId: definition.operationId, tags: [...definition.tags], summary: definition.summary, responses,",
3718
3822
  " ...(parameters.length === 0 ? {} : { parameters }),",
@@ -3723,6 +3827,7 @@ function renderOpenApi(graph, options) {
3723
3827
  " module: definition.module, controller: definition.controller, handler: definition.handler,",
3724
3828
  " ...(definition.command ? { command: definition.command } : {}),",
3725
3829
  " ...(definition.permission ? { permission: definition.permission } : {}),",
3830
+ " ...(definition.idempotency ? { idempotency: definition.idempotency } : {}),",
3726
3831
  " ...(definition.guards ? { guards: [...definition.guards] } : {}),",
3727
3832
  " ...(definition.canMatch ? { canMatch: [...definition.canMatch] } : {}),",
3728
3833
  " ...(definition.canDeactivate ? { canDeactivate: [...definition.canDeactivate] } : {}),",
@@ -3773,11 +3878,13 @@ function renderPermissions(graph) {
3773
3878
  }
3774
3879
  for (const controller of module.controllers) {
3775
3880
  for (const route of controller.routes) {
3776
- let perm;
3777
- if (route.command) {
3778
- const cmd = module.commands.find((c) => c.className === route.command);
3779
- perm = cmd?.permission;
3780
- }
3881
+ const featureTransition = featureTransitionForRoute(module, controller, route);
3882
+ const command = route.command === undefined ? undefined : module.commands.find((candidate) => candidate.className === route.command || candidate.name === route.command);
3883
+ const featureCommand = featureTransition?.command === undefined ? undefined : module.commands.find((candidate) => candidate.className === featureTransition.command || candidate.name === featureTransition.command);
3884
+ const governedCommand = command ?? featureCommand;
3885
+ const effectiveCommand = route.command ?? featureTransition?.command;
3886
+ const perm = governedCommand?.permission ?? featureTransition?.permission;
3887
+ const idempotency = route.idempotency ?? command?.idempotency ?? featureTransition?.idempotency;
3781
3888
  if (perm)
3782
3889
  permissions.add(perm);
3783
3890
  bindings.push({
@@ -3785,8 +3892,9 @@ function renderPermissions(graph) {
3785
3892
  path: joinRoutePaths(controller.path, route.path),
3786
3893
  controller: controller.className,
3787
3894
  handler: route.handler,
3788
- ...route.command === undefined ? {} : { command: route.command },
3789
- ...perm === undefined ? {} : { permission: perm }
3895
+ ...effectiveCommand === undefined ? {} : { command: effectiveCommand },
3896
+ ...perm === undefined ? {} : { permission: perm },
3897
+ ...idempotency === undefined ? {} : { idempotency }
3790
3898
  });
3791
3899
  }
3792
3900
  }
@@ -3812,6 +3920,7 @@ function renderPermissions(graph) {
3812
3920
  " handler: string;",
3813
3921
  " command?: string;",
3814
3922
  " permission?: string;",
3923
+ ' idempotency?: "required" | "none";',
3815
3924
  "}",
3816
3925
  "",
3817
3926
  "export const RoutePermissions: RoutePermissionBinding[] = " + JSON.stringify(bindings, null, 2) + ";",
@@ -3855,6 +3964,8 @@ var HEADER = "// GENERATED BY @supacloud/compiler — do not edit", INTERFACES =
3855
3964
  /** True when the handler returns a native Response rather than a framework value. */
3856
3965
  nativeResponse?: boolean;
3857
3966
  command?: string;
3967
+ /** Transport-level idempotency contract, independent of route command binding. */
3968
+ idempotency?: "required" | "none";
3858
3969
  guards?: string[];
3859
3970
  canMatch?: string[];
3860
3971
  canDeactivate?: string[];
@@ -4092,6 +4203,7 @@ async function destroyServiceInstances(
4092
4203
  `;
4093
4204
  var init_generate = __esm(() => {
4094
4205
  init_contract_manifest();
4206
+ init_feature();
4095
4207
  });
4096
4208
 
4097
4209
  // src/route-contracts.ts
@@ -4945,7 +5057,9 @@ function createExecutionPlans(graph) {
4945
5057
  const aspects = (boundary, refs = []) => refs.map((ref, index) => `${boundary}.aspect[${index}]:${ref.name}`);
4946
5058
  return graph.modules.flatMap((module) => [
4947
5059
  ...module.controllers.flatMap((controller) => controller.routes.map((route) => {
4948
- const command = module.commands.find((item) => item.className === route.command);
5060
+ const featureTransition = featureTransitionForRoute(module, controller, route);
5061
+ const commandRef = route.command ?? featureTransition?.command;
5062
+ const command = module.commands.find((item) => item.className === commandRef || item.name === commandRef);
4949
5063
  const path = `${controller.path}/${route.path}`.replace(/\/+/g, "/");
4950
5064
  return {
4951
5065
  module: module.name,
@@ -5095,6 +5209,7 @@ function exportGraphDot(graph) {
5095
5209
  }
5096
5210
  var init_inspect = __esm(() => {
5097
5211
  init_route_contracts();
5212
+ init_feature();
5098
5213
  });
5099
5214
 
5100
5215
  // node_modules/typebox/build/system/memory/metrics.mjs
@@ -15662,6 +15777,7 @@ var init_application_development = __esm(() => {
15662
15777
  controller: text2,
15663
15778
  handler: text2,
15664
15779
  command: Optional(text2),
15780
+ idempotency: Optional(mode),
15665
15781
  aspects: texts,
15666
15782
  schemaKinds: Optional(_Object_({
15667
15783
  body: schemaKind,
@@ -15784,7 +15900,11 @@ function createApplicationDevelopmentContext(graph, options = {}) {
15784
15900
  for (const controller of module.controllers) {
15785
15901
  for (const route of controller.routes) {
15786
15902
  const candidates = module.commands.filter((command) => command.className === route.command || command.name === route.command);
15787
- const command = candidates.length === 1 ? candidates[0]?.name : undefined;
15903
+ const commandNode = candidates.length === 1 ? candidates[0] : undefined;
15904
+ const featureTransition = featureTransitionForRoute(module, controller, route);
15905
+ const featureCommandNode = featureTransition?.command === undefined ? undefined : module.commands.find((candidate) => candidate.className === featureTransition.command || candidate.name === featureTransition.command);
15906
+ const command = commandNode?.name ?? featureCommandNode?.name;
15907
+ const idempotency = route.idempotency ?? commandNode?.idempotency ?? featureTransition?.idempotency;
15788
15908
  const kinds = schemaKinds(route);
15789
15909
  routes.push({
15790
15910
  module: module.name,
@@ -15793,6 +15913,7 @@ function createApplicationDevelopmentContext(graph, options = {}) {
15793
15913
  controller: controller.className,
15794
15914
  handler: route.handler,
15795
15915
  ...command === undefined ? {} : { command },
15916
+ ...idempotency === undefined ? {} : { idempotency },
15796
15917
  aspects: (route.aspects ?? []).map((aspect) => aspect.name),
15797
15918
  ...kinds === undefined ? {} : { schemaKinds: kinds }
15798
15919
  });
@@ -15913,6 +16034,7 @@ var init_application_development2 = __esm(() => {
15913
16034
  init_application_development();
15914
16035
  init_inspect();
15915
16036
  init_execution_snapshot();
16037
+ init_feature();
15916
16038
  APPLICATION_DEVELOPMENT_LIMITS2 = APPLICATION_DEVELOPMENT_LIMITS;
15917
16039
  ApplicationDevelopmentError = class ApplicationDevelopmentError extends Error {
15918
16040
  code;
@@ -16014,7 +16136,7 @@ function parseDeliveryPlanResult(value) {
16014
16136
  throw new Error("Invalid delivery plan result.");
16015
16137
  return value;
16016
16138
  }
16017
- var objectOptions, reference2, references, kind2, isolation, DeliveryFilePathSchema, functionTarget, DeliveryOptionsSchema, DeliveryDiagnosticSchema, routeSchema, jobSchema, DeliveryTargetSchema, DeliveryPlanSchema, DeliveryPlanResultSchema, DeliveryConfigurationError;
16139
+ var objectOptions, reference2, references, kind2, isolation, mode2, DeliveryFilePathSchema, functionTarget, DeliveryOptionsSchema, DeliveryDiagnosticSchema, routeSchema, jobSchema, DeliveryTargetSchema, DeliveryPlanSchema, DeliveryPlanResultSchema, DeliveryConfigurationError;
16018
16140
  var init_delivery_schema = __esm(() => {
16019
16141
  init_build();
16020
16142
  init_value2();
@@ -16024,6 +16146,7 @@ var init_delivery_schema = __esm(() => {
16024
16146
  references = _Array_(reference2, { uniqueItems: true });
16025
16147
  kind2 = Union([Literal("api"), Literal("jobs"), Literal("webhook"), Literal("function")]);
16026
16148
  isolation = Union([Literal("shared"), Literal("process")]);
16149
+ mode2 = Union([Literal("required"), Literal("none")]);
16027
16150
  DeliveryFilePathSchema = String2({
16028
16151
  maxLength: 512,
16029
16152
  pattern: "^(?:[A-Za-z0-9_-][A-Za-z0-9_.-]*/)*[A-Za-z0-9_-][A-Za-z0-9_.-]*$"
@@ -16097,6 +16220,7 @@ var init_delivery_schema = __esm(() => {
16097
16220
  ]),
16098
16221
  path: String2(),
16099
16222
  command: Optional(String2()),
16223
+ idempotency: Optional(mode2),
16100
16224
  reason: Union([Literal("default-http"), Literal("explicit-module")])
16101
16225
  }, objectOptions);
16102
16226
  jobSchema = _Object_({
@@ -16355,7 +16479,12 @@ function parseDeliveryBuildManifest(value) {
16355
16479
  throw new Error("Invalid delivery object inventory.");
16356
16480
  }
16357
16481
  }
16358
- const routes = value.plan.targets.flatMap((target) => target.routes.map((route) => ({ method: route.method, path: route.path, target: target.name })));
16482
+ const routes = value.plan.targets.flatMap((target) => target.routes.map((route) => ({
16483
+ method: route.method,
16484
+ path: route.path,
16485
+ target: target.name,
16486
+ ...route.idempotency === undefined ? {} : { idempotency: route.idempotency }
16487
+ })));
16359
16488
  const jobs = value.plan.targets.flatMap((target) => target.jobs.map((job) => ({ name: job.name, target: target.name })));
16360
16489
  if (canonical2(value.routes) !== canonical2(routes) || canonical2(value.jobs) !== canonical2(jobs)) {
16361
16490
  throw new Error("Delivery mappings do not match planned ownership.");
@@ -16406,7 +16535,8 @@ var init_delivery_build_schema = __esm(() => {
16406
16535
  routes: _Array_(_Object_({
16407
16536
  method: String2(),
16408
16537
  path: String2(),
16409
- target: String2()
16538
+ target: String2(),
16539
+ idempotency: Optional(Union([Literal("required"), Literal("none")]))
16410
16540
  }, closed4)),
16411
16541
  jobs: _Array_(_Object_({ name: String2(), target: String2() }, closed4))
16412
16542
  }, closed4);
@@ -19504,6 +19634,10 @@ function parseFeatureSpec(input, ctx, seen = new Set) {
19504
19634
  if (!ts7.isObjectLiteralExpression(input))
19505
19635
  return invalid();
19506
19636
  const name = stringLiteralProp(input, "name");
19637
+ const databaseExpr = getProp(input, "database");
19638
+ const database = databaseExpr && ts7.isObjectLiteralExpression(databaseExpr) ? parseFeatureDatabaseSource(databaseExpr) : undefined;
19639
+ if (databaseExpr !== undefined && database === undefined)
19640
+ return invalid();
19507
19641
  const tenantField = stringLiteralProp(input, "tenantField");
19508
19642
  const statesExpr = getProp(input, "states");
19509
19643
  const transitionObject = getProp(input, "transitions");
@@ -19536,6 +19670,10 @@ function parseFeatureSpec(input, ctx, seen = new Set) {
19536
19670
  const idempotency = commandModeProp(options, "idempotency");
19537
19671
  const audit = stringLiteralProp(options, "audit");
19538
19672
  const job = stringLiteralProp(options, "job");
19673
+ const invalidateTagsExpr = getProp(options, "invalidateTags");
19674
+ 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;
19675
+ if (invalidateTagsExpr !== undefined && invalidateTags === undefined)
19676
+ return invalid();
19539
19677
  const persistenceExpr = getProp(options, "persistence");
19540
19678
  const persistence = persistenceExpr && ts7.isObjectLiteralExpression(persistenceExpr) ? parseFeaturePersistence(persistenceExpr) : undefined;
19541
19679
  if (persistenceExpr && !persistence)
@@ -19552,18 +19690,36 @@ function parseFeatureSpec(input, ctx, seen = new Set) {
19552
19690
  ...idempotency === undefined ? {} : { idempotency },
19553
19691
  ...audit === undefined ? {} : { audit },
19554
19692
  ...job === undefined ? {} : { job },
19693
+ ...invalidateTags === undefined ? {} : { invalidateTags },
19555
19694
  ...persistence === undefined ? {} : { persistence }
19556
19695
  });
19557
19696
  }
19558
19697
  return {
19559
19698
  name,
19560
19699
  states,
19700
+ ...database === undefined ? {} : { database },
19561
19701
  ...tenantField === undefined ? {} : { tenantField },
19562
19702
  transitions,
19563
19703
  file: sourcePath(ctx.rootDir, input.getSourceFile().fileName),
19564
19704
  line: lineOf(input)
19565
19705
  };
19566
19706
  }
19707
+ function parseFeatureDatabaseSource(input) {
19708
+ if (input.properties.some((property) => !ts7.isPropertyAssignment(property)))
19709
+ return;
19710
+ const source = stringLiteralProp(input, "source");
19711
+ const table = stringLiteralProp(input, "table");
19712
+ const rowType = stringLiteralProp(input, "rowType");
19713
+ if (!source || !table || !rowType)
19714
+ return;
19715
+ if (source.startsWith("/") || source.includes("\\") || source.split("/").includes("..") || !/^[A-Za-z0-9._/-]+$/.test(source))
19716
+ return;
19717
+ if (!/^[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*$/.test(table))
19718
+ return;
19719
+ if (!/^[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*$/.test(rowType))
19720
+ return;
19721
+ return { source, table, rowType };
19722
+ }
19567
19723
  function parseFeaturePersistence(input) {
19568
19724
  if (input.properties.some((property) => !ts7.isPropertyAssignment(property)))
19569
19725
  return;
@@ -19654,6 +19810,54 @@ function checkedCommandMode(object, property, ctx, command) {
19654
19810
  }
19655
19811
  return value;
19656
19812
  }
19813
+ function checkedRouteIdempotency(object, ctx, route) {
19814
+ const expression = getProp(object, "idempotency");
19815
+ const value = stringLiteralProp(object, "idempotency");
19816
+ if (expression && value !== "required" && value !== "none") {
19817
+ const file = sourcePath(ctx.rootDir, expression.getSourceFile().fileName);
19818
+ ctx.diagnostics.push({
19819
+ severity: "error",
19820
+ code: "invalid-route-idempotency",
19821
+ errorCode: "SC3028",
19822
+ docsUrl: "https://supacloud.dev/errors/SC3028",
19823
+ message: `${route}.idempotency must be the explicit literal "required" or "none".`,
19824
+ file,
19825
+ line: lineOf(expression),
19826
+ suggestion: `Choose the intended idempotency policy explicitly; use "required" for replayable mutations or "none" for routes without that contract.`
19827
+ });
19828
+ }
19829
+ return value === "required" || value === "none" ? value : undefined;
19830
+ }
19831
+ function checkedRouteTags(expression, ctx, route) {
19832
+ if (expression === undefined)
19833
+ return;
19834
+ if (!ts7.isArrayLiteralExpression(expression) || expression.elements.some((element) => !ts7.isStringLiteral(element) || element.text.trim().length === 0)) {
19835
+ ctx.diagnostics.push({
19836
+ severity: "error",
19837
+ code: "invalid-route-tags",
19838
+ errorCode: "SC3029",
19839
+ docsUrl: "https://supacloud.dev/errors/SC3029",
19840
+ message: `${route}.tags must be a static array of non-empty string literals.`,
19841
+ file: sourcePath(ctx.rootDir, expression.getSourceFile().fileName),
19842
+ line: lineOf(expression),
19843
+ suggestion: 'Declare stable cache tags explicitly, for example tags: ["cases.list"].'
19844
+ });
19845
+ return;
19846
+ }
19847
+ const tags = expression.elements.map((element) => element.text);
19848
+ if (new Set(tags).size !== tags.length) {
19849
+ ctx.diagnostics.push({
19850
+ severity: "error",
19851
+ code: "duplicate-route-tags",
19852
+ errorCode: "SC3030",
19853
+ docsUrl: "https://supacloud.dev/errors/SC3030",
19854
+ message: `${route}.tags must not contain duplicate values.`,
19855
+ file: sourcePath(ctx.rootDir, expression.getSourceFile().fileName),
19856
+ line: lineOf(expression)
19857
+ });
19858
+ }
19859
+ return tags;
19860
+ }
19657
19861
  function parseProvider(el, exportsSet, ctx) {
19658
19862
  const file = sourcePath(ctx.rootDir, el.getSourceFile().fileName);
19659
19863
  const line = lineOf(el);
@@ -20433,7 +20637,11 @@ function parseController(input, ctx) {
20433
20637
  if (optionsObject) {
20434
20638
  const parsePolicy = routePolicyProperty(optionsObject, "parse", ctx);
20435
20639
  const deletePolicy = routePolicyProperty(optionsObject, "allowDeleteBody", ctx);
20436
- if (parsePolicy.unresolved || deletePolicy.unresolved) {
20640
+ const idempotency = checkedRouteIdempotency(optionsObject, ctx, route.handler);
20641
+ if (idempotency !== undefined)
20642
+ route.idempotency = idempotency;
20643
+ const tagsPolicy = routePolicyProperty(optionsObject, "tags", ctx);
20644
+ if (parsePolicy.unresolved || deletePolicy.unresolved || tagsPolicy.unresolved) {
20437
20645
  ctx.diagnostics.push({
20438
20646
  severity: "error",
20439
20647
  code: "unresolved-route-body-policy",
@@ -20441,6 +20649,9 @@ function parseController(input, ctx) {
20441
20649
  message: `Route ${route.handler} body policy must be statically resolvable; dynamic spreads, computed keys and shorthand policies are unsupported.`
20442
20650
  });
20443
20651
  }
20652
+ const tags = checkedRouteTags(tagsPolicy.value, ctx, route.handler);
20653
+ if (tags !== undefined)
20654
+ route.tags = tags;
20444
20655
  const parse = parsePolicy.value;
20445
20656
  if (parse) {
20446
20657
  if (ts7.isStringLiteral(parse) && parse.text === "none")
@@ -22289,6 +22500,7 @@ function validateClientSchemaBoundaries(graph, options) {
22289
22500
 
22290
22501
  // src/effect-governance.ts
22291
22502
  init_validate();
22503
+ init_feature();
22292
22504
  function diagnostic(code, message, file, suggestion) {
22293
22505
  const metadata = COMPILER_DIAGNOSTIC_CODES[code];
22294
22506
  return {
@@ -22326,8 +22538,10 @@ function validateEffectPolicies(graph, options = {}) {
22326
22538
  if (effect.retry === "explicit" && effect.maxAttempts === undefined) {
22327
22539
  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."));
22328
22540
  }
22329
- if (route.command && effect.retry === "explicit") {
22330
- const command = commands.get(route.command);
22541
+ if (effect.retry === "explicit") {
22542
+ const featureTransition = featureTransitionForRoute(module, controller, route);
22543
+ const commandRef = route.command ?? featureTransition?.command;
22544
+ const command = commandRef === undefined ? undefined : commands.get(commandRef) ?? [...commands.values()].find((candidate) => candidate.name === commandRef);
22331
22545
  if (command && command.idempotency !== "required") {
22332
22546
  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".'));
22333
22547
  }
@@ -23544,6 +23758,7 @@ init_graphql_options();
23544
23758
 
23545
23759
  // src/delivery-plan.ts
23546
23760
  import { createHash as createHash10 } from "node:crypto";
23761
+ init_feature();
23547
23762
  init_validate();
23548
23763
  var compare2 = (a, b) => a < b ? -1 : a > b ? 1 : 0;
23549
23764
  var sorted = (values) => [...new Set(values)].sort(compare2);
@@ -23663,6 +23878,10 @@ function createDeliveryPlan(graph, input, validation) {
23663
23878
  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.");
23664
23879
  }
23665
23880
  publicRoutes.set(key, target.name);
23881
+ const command = route.command === undefined ? undefined : module.commands.find((candidate) => candidate.className === route.command || candidate.name === route.command);
23882
+ const featureTransition = featureTransitionForRoute(module, controller, route);
23883
+ const effectiveCommand = route.command ?? featureTransition?.command;
23884
+ const idempotency = route.idempotency ?? command?.idempotency ?? featureTransition?.idempotency;
23666
23885
  target.roots.push(module.name);
23667
23886
  target.routes.push({
23668
23887
  module: module.name,
@@ -23670,7 +23889,8 @@ function createDeliveryPlan(graph, input, validation) {
23670
23889
  handler: route.handler,
23671
23890
  method: route.method,
23672
23891
  path,
23673
- ...route.command === undefined ? {} : { command: route.command },
23892
+ ...effectiveCommand === undefined ? {} : { command: effectiveCommand },
23893
+ ...idempotency === undefined ? {} : { idempotency },
23674
23894
  reason: owner === undefined ? "default-http" : "explicit-module"
23675
23895
  });
23676
23896
  }
@@ -24702,7 +24922,12 @@ async function buildDeliveryProject(options, delivery) {
24702
24922
  deploymentReady: false,
24703
24923
  plan: planned.plan,
24704
24924
  objects,
24705
- routes: planned.plan.targets.flatMap((target) => target.routes.map((route) => ({ method: route.method, path: route.path, target: target.name }))),
24925
+ routes: planned.plan.targets.flatMap((target) => target.routes.map((route) => ({
24926
+ method: route.method,
24927
+ path: route.path,
24928
+ target: target.name,
24929
+ ...route.idempotency === undefined ? {} : { idempotency: route.idempotency }
24930
+ }))),
24706
24931
  jobs: planned.plan.targets.flatMap((target) => target.jobs.map((job) => ({ name: job.name, target: target.name })))
24707
24932
  });
24708
24933
  phase = "artifact-integrity";
@@ -24768,10 +24993,10 @@ import { readFileSync as readFileSync4 } from "node:fs";
24768
24993
  import { resolve as resolve18 } from "node:path";
24769
24994
 
24770
24995
  // src/migration-app-version.ts
24771
- var MIGRATION_APP_VERSION = "0.29.0";
24996
+ var MIGRATION_APP_VERSION = "0.31.0";
24772
24997
 
24773
24998
  // src/migration-elysia-version.ts
24774
- var MIGRATION_ELYSIA_VERSION = "0.34.0";
24999
+ var MIGRATION_ELYSIA_VERSION = "0.35.1";
24775
25000
 
24776
25001
  // src/migration-policy.ts
24777
25002
  var cachedCompilerVersion;
@@ -25807,8 +26032,8 @@ function validFeatureInventory(value, jobs, commands, routes, rpc) {
25807
26032
  return false;
25808
26033
  }
25809
26034
  const commandName = transition["command"];
26035
+ const command = commandName === undefined ? undefined : commands.find((candidate) => isRecord2(candidate) && (candidate["name"] === commandName || candidate["className"] === commandName));
25810
26036
  if (commandName !== undefined) {
25811
- const command = commands.find((candidate) => isRecord2(candidate) && (candidate["name"] === commandName || candidate["className"] === commandName));
25812
26037
  if (!isRecord2(command))
25813
26038
  return false;
25814
26039
  if (transition["permission"] !== undefined && command["permission"] !== transition["permission"])
@@ -25839,9 +26064,21 @@ function validFeatureInventory(value, jobs, commands, routes, rpc) {
25839
26064
  if (routeBinding === "controller") {
25840
26065
  if (route["command"] !== undefined)
25841
26066
  return false;
25842
- } else if (route["command"] !== commandName) {
25843
- return false;
26067
+ } else {
26068
+ if (typeof route["command"] !== "string")
26069
+ return false;
26070
+ const routeCommand = commands.find((candidate) => isRecord2(candidate) && (candidate["name"] === route["command"] || candidate["className"] === route["command"]));
26071
+ if (!isRecord2(routeCommand) || !isRecord2(command) || routeCommand["name"] !== command["name"])
26072
+ return false;
25844
26073
  }
26074
+ if (transition["permission"] !== undefined && route["permission"] !== transition["permission"])
26075
+ return false;
26076
+ if (transition["idempotency"] !== undefined && route["idempotency"] !== transition["idempotency"])
26077
+ return false;
26078
+ if (persistence !== undefined && route["idempotency"] !== "required")
26079
+ return false;
26080
+ if (route["idempotency"] === "required" && (route["method"] === "GET" || route["method"] === "HEAD" || route["method"] === "OPTIONS"))
26081
+ return false;
25845
26082
  }
25846
26083
  const job = transition["job"];
25847
26084
  if (job === undefined)