@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/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) => {
@@ -265,6 +269,21 @@ function validateFeatureSpec(spec, module) {
265
269
  if (spec.tenantField !== undefined && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(spec.tenantField)) {
266
270
  error("feature-tenant-boundary-invalid", `Feature ${spec.name} declares an unsafe tenant field.`);
267
271
  }
272
+ const databaseTransition = spec.transitions.some((transition) => transition.persistence?.boundary === "database");
273
+ if (databaseTransition && spec.database === undefined && spec.file !== undefined) {
274
+ error("feature-database-source-required", `Database Feature ${spec.name} must declare its database source, table and rowType.`);
275
+ }
276
+ if (spec.database !== undefined) {
277
+ if (spec.database.source.startsWith("/") || spec.database.source.includes("\\") || spec.database.source.split("/").includes("..") || !/^[A-Za-z0-9._/-]+$/.test(spec.database.source)) {
278
+ error("feature-database-source-invalid", `Feature ${spec.name} declares an unsafe database source.`);
279
+ }
280
+ if (!/^[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*$/.test(spec.database.table)) {
281
+ error("feature-database-table-invalid", `Feature ${spec.name} must use a schema-qualified database table.`);
282
+ }
283
+ if (!/^[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*$/.test(spec.database.rowType)) {
284
+ error("feature-database-row-type-invalid", `Feature ${spec.name} declares an unsafe database row type.`);
285
+ }
286
+ }
268
287
  const names = new Set;
269
288
  for (const transition of spec.transitions) {
270
289
  if (!transition.name.trim() || names.has(transition.name)) {
@@ -277,6 +296,11 @@ function validateFeatureSpec(spec, module) {
277
296
  if (transition.permission !== undefined && !transition.permission.trim()) {
278
297
  error("feature-governance-drift", `Transition ${transition.name} declares an empty permission.`);
279
298
  }
299
+ if (transition.invalidateTags !== undefined) {
300
+ if (transition.invalidateTags.length === 0 || transition.invalidateTags.some((tag) => !tag.trim()) || new Set(transition.invalidateTags).size !== transition.invalidateTags.length) {
301
+ error("feature-invalidation-tags-invalid", `Transition ${transition.name} must declare unique, non-empty invalidateTags.`);
302
+ }
303
+ }
280
304
  if (transition.persistence) {
281
305
  const persistence = transition.persistence;
282
306
  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 +393,7 @@ function validateFeatureSpec(spec, module) {
369
393
  const routes = module.controllers.flatMap((controller) => controller.routes.filter((route) => `${route.method} ${joinRoutePaths(controller.path, route.path)}` === transition.route));
370
394
  if (routes.length !== 1) {
371
395
  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) {
396
+ } else if (command && transition.routeBinding !== "controller" && routes[0]?.command !== command.className && routes[0]?.command !== command.name) {
373
397
  error("feature-route-drift", `Route ${transition.route} is not bound to ${command.className}.`);
374
398
  } else if (transition.routeBinding === "controller" && routes[0]?.command !== undefined) {
375
399
  error("feature-route-drift", `Controller-bound route ${transition.route} must not also declare a framework command binding.`);
@@ -396,6 +420,7 @@ function generateFeatureSource(spec) {
396
420
  const sourceSpec = {
397
421
  name: spec.name,
398
422
  states: spec.states,
423
+ ...spec.database === undefined ? {} : { database: spec.database },
399
424
  tenantField: spec.tenantField ?? "tenant_id",
400
425
  transitions: Object.fromEntries(transitions.map((transition) => {
401
426
  const { name, ...options } = transition;
@@ -614,13 +639,17 @@ function validateGraph(graph, options = false) {
614
639
  handler: route.handler,
615
640
  ...route.redirectTo === undefined ? {} : { redirectTo: route.redirectTo }
616
641
  });
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);
642
+ const featureTransition = featureTransitionForRoute(module, controller, route);
643
+ const effectiveCommandRef = route.command ?? featureTransition?.command;
644
+ const effectiveCommand = effectiveCommandRef === undefined ? undefined : module.commands.find((command) => command.className === effectiveCommandRef || command.name === effectiveCommandRef);
645
+ const effectiveIdempotency = route.idempotency ?? featureTransition?.idempotency;
646
+ if (route.command && !effectiveCommand) {
647
+ error("route-command-unresolved", `路由 ${key} 绑定的 command ${route.command} 未在模块 ${module.name} 声明`, controller.file);
619
648
  }
620
- if ((route.method === "GET" || route.method === "HEAD") && route.command) {
621
- const boundCommand = module.commands.find((c) => c.className === route.command);
649
+ if ((route.method === "GET" || route.method === "HEAD") && effectiveCommand) {
650
+ const boundCommand = effectiveCommand;
622
651
  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'.`);
652
+ 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
653
  }
625
654
  }
626
655
  if (typeof options === "object" && options.allowRouteCommandBindings === false && route.command) {
@@ -691,6 +720,9 @@ function validateGraph(graph, options = false) {
691
720
  if (route.allowDeleteBody && (route.method !== "DELETE" || !route.body)) {
692
721
  error("invalid-delete-body-opt-in", `Route ${controller.className}.${route.handler} allowDeleteBody requires DELETE and an explicit body schema.`, controller.file);
693
722
  }
723
+ if (effectiveIdempotency === "required" && (route.method === "GET" || route.method === "HEAD" || route.method === "OPTIONS")) {
724
+ 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.`);
725
+ }
694
726
  if (route.parse === "none" && (route.body || route.hasBodyBinding || route.contract?.body !== "domain" || !route.contract.evidence?.trim())) {
695
727
  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
728
  }
@@ -1365,6 +1397,7 @@ var init_validate = __esm(() => {
1365
1397
  "command-transaction-unsupported": { code: "SC4026", docsUrl: "https://supacloud.dev/errors/SC4026" },
1366
1398
  "command-reconcile-required": { code: "SC4028", docsUrl: "https://supacloud.dev/errors/SC4028" },
1367
1399
  "invalid-route-contract": { code: "SC3020", docsUrl: "https://supacloud.dev/errors/SC3020" },
1400
+ "invalid-route-idempotency": { code: "SC3028", docsUrl: "https://supacloud.dev/errors/SC3028" },
1368
1401
  "route-contract-required": { code: "SC3021", docsUrl: "https://supacloud.dev/errors/SC3021" },
1369
1402
  "route-contract-unverified": { code: "SC3022", docsUrl: "https://supacloud.dev/errors/SC3022" },
1370
1403
  "route-contract-evidence-required": { code: "SC3023", docsUrl: "https://supacloud.dev/errors/SC3023" },
@@ -1416,6 +1449,10 @@ var init_validate = __esm(() => {
1416
1449
  "feature-job-mode": { code: "SC6119", docsUrl: "https://supacloud.dev/errors/SC6119" },
1417
1450
  "feature-job-contract": { code: "SC6120", docsUrl: "https://supacloud.dev/errors/SC6120" },
1418
1451
  "feature-job-context": { code: "SC6121", docsUrl: "https://supacloud.dev/errors/SC6121" },
1452
+ "feature-database-source-required": { code: "SC6122", docsUrl: "https://supacloud.dev/errors/SC6122" },
1453
+ "feature-database-source-invalid": { code: "SC6123", docsUrl: "https://supacloud.dev/errors/SC6123" },
1454
+ "feature-database-table-invalid": { code: "SC6124", docsUrl: "https://supacloud.dev/errors/SC6124" },
1455
+ "feature-database-row-type-invalid": { code: "SC6125", docsUrl: "https://supacloud.dev/errors/SC6125" },
1419
1456
  "feature-route-unresolved": { code: "SC6106", docsUrl: "https://supacloud.dev/errors/SC6106" },
1420
1457
  "feature-route-drift": { code: "SC6107", docsUrl: "https://supacloud.dev/errors/SC6107" },
1421
1458
  "invalid-feature-spec": { code: "SC6108", docsUrl: "https://supacloud.dev/errors/SC6108" }
@@ -1496,6 +1533,7 @@ function buildContractManifest(graph, artifacts) {
1496
1533
  features.push({
1497
1534
  name: module.featureSpec.name,
1498
1535
  states: [...module.featureSpec.states],
1536
+ ...module.featureSpec.database === undefined ? {} : { database: module.featureSpec.database },
1499
1537
  ...module.featureSpec.tenantField === undefined ? {} : { tenantField: module.featureSpec.tenantField },
1500
1538
  transitions: module.featureSpec.transitions.map((transition) => {
1501
1539
  const job = transition.job === undefined ? undefined : (module.jobs ?? []).find((candidate) => candidate.className === transition.job || candidate.name === transition.job);
@@ -1511,6 +1549,7 @@ function buildContractManifest(graph, artifacts) {
1511
1549
  ...transition.idempotency === undefined ? {} : { idempotency: transition.idempotency },
1512
1550
  ...transition.audit === undefined ? {} : { audit: transition.audit },
1513
1551
  ...transition.job === undefined ? {} : { job: transition.job },
1552
+ ...transition.invalidateTags === undefined ? {} : { invalidateTags: [...transition.invalidateTags] },
1514
1553
  ...job === undefined ? {} : {
1515
1554
  jobDetails: {
1516
1555
  name: job.name,
@@ -1531,9 +1570,14 @@ function buildContractManifest(graph, artifacts) {
1531
1570
  }
1532
1571
  for (const controller of module.controllers) {
1533
1572
  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);
1573
+ const command = route.command ? module.commands.find((candidate) => candidate.className === route.command || candidate.name === route.command) : undefined;
1574
+ const featureTransition = featureTransitionForRoute(module, controller, route);
1575
+ const featureCommand = featureTransition?.command === undefined ? undefined : module.commands.find((candidate) => candidate.className === featureTransition.command || candidate.name === featureTransition.command);
1576
+ const governedCommand = command ?? featureCommand;
1577
+ const effectiveCommand = route.command ?? featureTransition?.command;
1578
+ const idempotency = route.idempotency ?? command?.idempotency ?? featureTransition?.idempotency;
1579
+ if (governedCommand?.permission)
1580
+ permissions.add(governedCommand.permission);
1537
1581
  if (route.contract?.evidence)
1538
1582
  fixtures.add(route.contract.evidence);
1539
1583
  routes.push({
@@ -1542,8 +1586,10 @@ function buildContractManifest(graph, artifacts) {
1542
1586
  module: module.name,
1543
1587
  controller: controller.className,
1544
1588
  handler: route.handler,
1545
- ...route.command === undefined ? {} : { command: route.command },
1546
- ...command?.permission === undefined ? {} : { permission: command.permission },
1589
+ ...effectiveCommand === undefined ? {} : { command: effectiveCommand },
1590
+ ...governedCommand?.permission === undefined ? {} : { permission: governedCommand.permission },
1591
+ ...idempotency === undefined ? {} : { idempotency },
1592
+ ...route.tags === undefined ? {} : { tags: [...route.tags] },
1547
1593
  requestSchemas: Object.fromEntries(["body", "params", "query", "headers", "cookie"].flatMap((key) => route[key] === undefined ? [] : [[key, route[key]]])),
1548
1594
  ...route.response === undefined ? {} : { responseSchema: route.response },
1549
1595
  ...route.responses === undefined ? {} : {
@@ -1582,7 +1628,9 @@ function buildContractManifest(graph, artifacts) {
1582
1628
  }
1583
1629
  };
1584
1630
  }
1585
- var init_contract_manifest = () => {};
1631
+ var init_contract_manifest = __esm(() => {
1632
+ init_feature();
1633
+ });
1586
1634
 
1587
1635
  // src/generate.ts
1588
1636
  import { createHash as createHash4 } from "node:crypto";
@@ -1994,6 +2042,9 @@ class ModuleGenerator {
1994
2042
  return "[]";
1995
2043
  const items = this.module.controllers.map((controller) => {
1996
2044
  const routes = controller.routes.map((route) => {
2045
+ const command = route.command === undefined ? undefined : this.module.commands.find((candidate) => candidate.className === route.command || candidate.name === route.command);
2046
+ const featureTransition = featureTransitionForRoute(this.module, controller, route);
2047
+ const idempotency = route.idempotency ?? command?.idempotency ?? featureTransition?.idempotency;
1997
2048
  const responseEntries = normalizedResponseEntries(route.responses);
1998
2049
  const hasResponseMap = responseEntries.length > 0;
1999
2050
  const fields = [
@@ -2020,6 +2071,9 @@ class ModuleGenerator {
2020
2071
  if (route.effect !== undefined) {
2021
2072
  fields.push(`effect: ${JSON.stringify(route.effect)}`);
2022
2073
  }
2074
+ if (idempotency !== undefined) {
2075
+ fields.push(`idempotency: ${JSON.stringify(idempotency)}`);
2076
+ }
2023
2077
  if (route.command)
2024
2078
  fields.push(`command: ${JSON.stringify(route.command)}`);
2025
2079
  if (route.guards && route.guards.length > 0) {
@@ -2502,7 +2556,18 @@ function renderClient(graph, options) {
2502
2556
  const usedTypeNames = new Set;
2503
2557
  const allRoutes = [];
2504
2558
  const allProcedures = [];
2505
- const commandByClassName = new Map(graph.modules.flatMap((module) => module.commands.map((command) => [command.className, command])));
2559
+ const commandByClassName = new Map(graph.modules.flatMap((module) => module.commands.flatMap((command) => [
2560
+ [command.className, command],
2561
+ [command.name, command]
2562
+ ])));
2563
+ const featureInvalidationTags = new Map;
2564
+ for (const module of graph.modules) {
2565
+ for (const transition of module.featureSpec?.transitions ?? []) {
2566
+ if (transition.route && transition.invalidateTags) {
2567
+ featureInvalidationTags.set(`${module.name}:${transition.route}`, [...transition.invalidateTags]);
2568
+ }
2569
+ }
2570
+ }
2506
2571
  let usesStatic = false;
2507
2572
  let usesValue = false;
2508
2573
  const uniqueTypeName = (candidate) => {
@@ -2580,6 +2645,13 @@ function renderClient(graph, options) {
2580
2645
  const requestType = `ClientRequestOptions<${paramsType}, ${queryType}, ${bodyType}, ${headersType}, ${cookieType}>${requiredRequestFields.length > 0 ? ` & { ${requiredRequestFields.join("; ")} }` : ""}`;
2581
2646
  routeTypes.push(`export type ${requestTypeName} = ${requestType};`);
2582
2647
  routeTypes.push(`export type ${responseTypeName} = ${responseType === "never" ? "unknown" : responseType};`);
2648
+ const command = route.command === undefined ? undefined : commandByClassName.get(route.command);
2649
+ const featureTransition = featureTransitionForRoute(module, controller, route);
2650
+ const featureCommand = featureTransition?.command === undefined ? undefined : commandByClassName.get(featureTransition.command);
2651
+ const governedCommand = command ?? featureCommand;
2652
+ const idempotencyMode = route.idempotency ?? command?.idempotency ?? featureTransition?.idempotency ?? "none";
2653
+ const transitionRoute = `${route.method} ${fullPath}`;
2654
+ const invalidationTags = featureInvalidationTags.get(`${module.name}:${transitionRoute}`);
2583
2655
  const routeFields = [
2584
2656
  `method: ${JSON.stringify(route.method)}`,
2585
2657
  `path: ${JSON.stringify(fullPath)}`,
@@ -2587,7 +2659,7 @@ function renderClient(graph, options) {
2587
2659
  `handler: ${JSON.stringify(route.handler)}`
2588
2660
  ];
2589
2661
  for (const [key, value] of [
2590
- ["command", route.command],
2662
+ ["command", route.command ?? featureTransition?.command],
2591
2663
  ["guards", route.guards],
2592
2664
  ["canMatch", route.canMatch],
2593
2665
  ["canDeactivate", route.canDeactivate],
@@ -2601,7 +2673,10 @@ function renderClient(graph, options) {
2601
2673
  ["title", route.title],
2602
2674
  ["data", route.data],
2603
2675
  ["parse", route.parse],
2604
- ["allowDeleteBody", route.allowDeleteBody]
2676
+ ["allowDeleteBody", route.allowDeleteBody],
2677
+ ["idempotency", idempotencyMode === "none" ? undefined : idempotencyMode],
2678
+ ["tags", route.tags],
2679
+ ["invalidateTags", invalidationTags]
2605
2680
  ]) {
2606
2681
  if (value !== undefined)
2607
2682
  routeFields.push(`${key}: ${JSON.stringify(value)}`);
@@ -2634,9 +2709,7 @@ function renderClient(graph, options) {
2634
2709
  const responseSchemaEntries = hasResponseMap ? Object.entries(responseSchemas).map(([status, ref]) => [status, ref.local]) : responseSchema ? [["200", responseSchema.local]] : [];
2635
2710
  const responseSchemaArgument = responseSchemaEntries.length > 0 ? `{ ${responseSchemaEntries.map(([status, local]) => `${JSON.stringify(status)}: ${local}`).join(", ")} }` : "undefined";
2636
2711
  const responseKindArgument = responseKind === undefined ? "undefined" : JSON.stringify(responseKind);
2637
- const command = route.command === undefined ? undefined : commandByClassName.get(route.command);
2638
2712
  const procedureKind = route.method === "GET" || route.method === "HEAD" ? "query" : "mutation";
2639
- const idempotencyMode = command?.idempotency ?? "none";
2640
2713
  const procedureMetadata = {
2641
2714
  key: `${controller.className}.${route.handler}`,
2642
2715
  method: route.method,
@@ -2644,7 +2717,9 @@ function renderClient(graph, options) {
2644
2717
  controller: controller.className,
2645
2718
  handler: route.handler,
2646
2719
  kind: procedureKind,
2647
- idempotency: idempotencyMode
2720
+ idempotency: idempotencyMode,
2721
+ ...route.tags === undefined ? {} : { tags: route.tags },
2722
+ ...invalidationTags === undefined ? {} : { invalidateTags: invalidationTags }
2648
2723
  };
2649
2724
  allProcedures.push({
2650
2725
  method: route.method,
@@ -2652,9 +2727,11 @@ function renderClient(graph, options) {
2652
2727
  controller: controller.className,
2653
2728
  handler: route.handler,
2654
2729
  kind: procedureKind,
2655
- ...command?.name === undefined ? {} : { command: command.name },
2656
- ...command?.permission === undefined ? {} : { permission: command.permission },
2657
- idempotency: idempotencyMode
2730
+ ...governedCommand?.name === undefined ? {} : { command: governedCommand.name },
2731
+ ...governedCommand?.permission === undefined ? {} : { permission: governedCommand.permission },
2732
+ idempotency: idempotencyMode,
2733
+ ...route.tags === undefined ? {} : { tags: [...route.tags] },
2734
+ ...invalidationTags === undefined ? {} : { invalidateTags: [...invalidationTags] }
2658
2735
  });
2659
2736
  routeMethods.push(`
2660
2737
  ${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 +2785,8 @@ ${procedureMethods.join(`
2708
2785
  " handler: string;",
2709
2786
  " kind: Kind;",
2710
2787
  " idempotency: Idempotency;",
2788
+ " tags?: readonly string[];",
2789
+ " invalidateTags?: readonly string[];",
2711
2790
  "}",
2712
2791
  "",
2713
2792
  "export interface ProcedureExecutionOptions {",
@@ -2813,6 +2892,8 @@ ${procedureMethods.join(`
2813
2892
  " command?: string;",
2814
2893
  " permission?: string;",
2815
2894
  " idempotency: IdempotencyMode;",
2895
+ " tags?: readonly string[];",
2896
+ " invalidateTags?: readonly string[];",
2816
2897
  "}",
2817
2898
  "",
2818
2899
  ...routeTypes,
@@ -3312,7 +3393,10 @@ function renderOpenApi(graph, options) {
3312
3393
  }
3313
3394
  return schemaComponents.get(key);
3314
3395
  };
3315
- const commandByClass = new Map(graph.modules.flatMap((module) => module.commands.map((command) => [command.className, command])));
3396
+ const commandByClass = new Map(graph.modules.flatMap((module) => module.commands.flatMap((command) => [
3397
+ [command.className, command],
3398
+ [command.name, command]
3399
+ ])));
3316
3400
  for (const module of graph.modules) {
3317
3401
  for (const controller of module.controllers) {
3318
3402
  for (const route of controller.routes) {
@@ -3334,7 +3418,12 @@ function renderOpenApi(graph, options) {
3334
3418
  const operationBase = pascalName(`${module.name}_${controller.className}_${route.handler}`);
3335
3419
  const operationId = uniqueName(operationBase, operationIds);
3336
3420
  const command = route.command ? commandByClass.get(route.command) : undefined;
3337
- const requiresAuth = (route.guards?.length ?? 0) > 0 || command !== undefined;
3421
+ const featureTransition = featureTransitionForRoute(module, controller, route);
3422
+ const featureCommand = featureTransition?.command === undefined ? undefined : commandByClass.get(featureTransition.command);
3423
+ const governedCommand = command ?? featureCommand;
3424
+ const idempotencyMode = route.idempotency ?? command?.idempotency ?? featureTransition?.idempotency ?? "none";
3425
+ const permission = governedCommand?.permission ?? featureTransition?.permission;
3426
+ const requiresAuth = (route.guards?.length ?? 0) > 0 || permission !== undefined || governedCommand !== undefined;
3338
3427
  const responseKind = route.contract?.response;
3339
3428
  const responseContentType = responseKind === "binary" || responseKind === "stream" ? "application/octet-stream" : "application/json";
3340
3429
  const definition = {
@@ -3359,11 +3448,12 @@ function renderOpenApi(graph, options) {
3359
3448
  responseKind: responseKind ?? (route.nativeResponse ? "native-response" : undefined),
3360
3449
  ...route.parse === undefined ? {} : { parse: route.parse },
3361
3450
  ...route.allowDeleteBody === undefined ? {} : { allowDeleteBody: route.allowDeleteBody },
3451
+ ...idempotencyMode === "none" ? {} : { idempotency: idempotencyMode },
3362
3452
  module: module.name,
3363
3453
  controller: controller.className,
3364
3454
  handler: route.handler,
3365
- ...route.command === undefined ? {} : { command: route.command },
3366
- ...command?.permission === undefined ? {} : { permission: command.permission },
3455
+ ...route.command === undefined && featureTransition?.command === undefined ? {} : { command: route.command ?? featureTransition?.command },
3456
+ ...permission === undefined ? {} : { permission },
3367
3457
  ...route.guards && route.guards.length > 0 ? { guards: route.guards } : {},
3368
3458
  ...route.canMatch && route.canMatch.length > 0 ? { canMatch: route.canMatch } : {},
3369
3459
  ...route.canDeactivate && route.canDeactivate.length > 0 ? { canDeactivate: route.canDeactivate } : {},
@@ -3438,6 +3528,7 @@ function renderOpenApi(graph, options) {
3438
3528
  " handler: string;",
3439
3529
  " command?: string;",
3440
3530
  " permission?: string;",
3531
+ ' idempotency?: "required" | "none";',
3441
3532
  " guards?: readonly string[];",
3442
3533
  " canMatch?: readonly string[];",
3443
3534
  " canDeactivate?: readonly string[];",
@@ -3690,6 +3781,18 @@ function renderOpenApi(graph, options) {
3690
3781
  " for (const name of headersSchema ? schemaPropertyNames(headersSchema) : []) parameters.push({",
3691
3782
  ' name, in: "header", required: schemaRequired(headersSchema, name), schema: schemaProperty(headersSchema, name),',
3692
3783
  " });",
3784
+ ' if (definition.idempotency === "required") {',
3785
+ ' const keySchema = { type: "string", minLength: 1, maxLength: 512, pattern: "^[A-Za-z0-9._:-]{1,512}$" };',
3786
+ ' const declaredHeaders = parameters.filter((parameter) => parameter["in"] === "header" && typeof parameter["name"] === "string" && parameter["name"].toLowerCase() === "idempotency-key");',
3787
+ " const header = declaredHeaders[0];",
3788
+ " if (header) {",
3789
+ ' header["required"] = true;',
3790
+ ' header["schema"] = { allOf: [...declaredHeaders.map((parameter) => parameter["schema"]), keySchema] };',
3791
+ " for (const duplicate of declaredHeaders.slice(1)) parameters.splice(parameters.indexOf(duplicate), 1);",
3792
+ " } else {",
3793
+ ' parameters.push({ name: "Idempotency-Key", in: "header", required: true, schema: keySchema });',
3794
+ " }",
3795
+ " }",
3693
3796
  " const cookieSchema = definition.cookieSchema ? OPENAPI_SCHEMA_REGISTRY[definition.cookieSchema as keyof typeof OPENAPI_SCHEMA_REGISTRY] : undefined;",
3694
3797
  " for (const name of cookieSchema ? schemaPropertyNames(cookieSchema) : []) parameters.push({",
3695
3798
  ' name, in: "cookie", required: schemaRequired(cookieSchema, name), schema: schemaProperty(cookieSchema, name),',
@@ -3712,6 +3815,7 @@ function renderOpenApi(graph, options) {
3712
3815
  ' responses["500"] = { description: "Internal server error", content: { "application/json": { schema: { $ref: "#/components/schemas/SupaCloudError" } } } };',
3713
3816
  ' responses["200"] = { description: "Successful response" };',
3714
3817
  " }",
3818
+ ' 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
3819
  " const operation: Record<string, unknown> = {",
3716
3820
  " operationId: definition.operationId, tags: [...definition.tags], summary: definition.summary, responses,",
3717
3821
  " ...(parameters.length === 0 ? {} : { parameters }),",
@@ -3722,6 +3826,7 @@ function renderOpenApi(graph, options) {
3722
3826
  " module: definition.module, controller: definition.controller, handler: definition.handler,",
3723
3827
  " ...(definition.command ? { command: definition.command } : {}),",
3724
3828
  " ...(definition.permission ? { permission: definition.permission } : {}),",
3829
+ " ...(definition.idempotency ? { idempotency: definition.idempotency } : {}),",
3725
3830
  " ...(definition.guards ? { guards: [...definition.guards] } : {}),",
3726
3831
  " ...(definition.canMatch ? { canMatch: [...definition.canMatch] } : {}),",
3727
3832
  " ...(definition.canDeactivate ? { canDeactivate: [...definition.canDeactivate] } : {}),",
@@ -3772,11 +3877,13 @@ function renderPermissions(graph) {
3772
3877
  }
3773
3878
  for (const controller of module.controllers) {
3774
3879
  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
- }
3880
+ const featureTransition = featureTransitionForRoute(module, controller, route);
3881
+ const command = route.command === undefined ? undefined : module.commands.find((candidate) => candidate.className === route.command || candidate.name === route.command);
3882
+ const featureCommand = featureTransition?.command === undefined ? undefined : module.commands.find((candidate) => candidate.className === featureTransition.command || candidate.name === featureTransition.command);
3883
+ const governedCommand = command ?? featureCommand;
3884
+ const effectiveCommand = route.command ?? featureTransition?.command;
3885
+ const perm = governedCommand?.permission ?? featureTransition?.permission;
3886
+ const idempotency = route.idempotency ?? command?.idempotency ?? featureTransition?.idempotency;
3780
3887
  if (perm)
3781
3888
  permissions.add(perm);
3782
3889
  bindings.push({
@@ -3784,8 +3891,9 @@ function renderPermissions(graph) {
3784
3891
  path: joinRoutePaths(controller.path, route.path),
3785
3892
  controller: controller.className,
3786
3893
  handler: route.handler,
3787
- ...route.command === undefined ? {} : { command: route.command },
3788
- ...perm === undefined ? {} : { permission: perm }
3894
+ ...effectiveCommand === undefined ? {} : { command: effectiveCommand },
3895
+ ...perm === undefined ? {} : { permission: perm },
3896
+ ...idempotency === undefined ? {} : { idempotency }
3789
3897
  });
3790
3898
  }
3791
3899
  }
@@ -3811,6 +3919,7 @@ function renderPermissions(graph) {
3811
3919
  " handler: string;",
3812
3920
  " command?: string;",
3813
3921
  " permission?: string;",
3922
+ ' idempotency?: "required" | "none";',
3814
3923
  "}",
3815
3924
  "",
3816
3925
  "export const RoutePermissions: RoutePermissionBinding[] = " + JSON.stringify(bindings, null, 2) + ";",
@@ -3854,6 +3963,8 @@ var HEADER = "// GENERATED BY @supacloud/compiler — do not edit", INTERFACES =
3854
3963
  /** True when the handler returns a native Response rather than a framework value. */
3855
3964
  nativeResponse?: boolean;
3856
3965
  command?: string;
3966
+ /** Transport-level idempotency contract, independent of route command binding. */
3967
+ idempotency?: "required" | "none";
3857
3968
  guards?: string[];
3858
3969
  canMatch?: string[];
3859
3970
  canDeactivate?: string[];
@@ -4091,6 +4202,7 @@ async function destroyServiceInstances(
4091
4202
  `;
4092
4203
  var init_generate = __esm(() => {
4093
4204
  init_contract_manifest();
4205
+ init_feature();
4094
4206
  });
4095
4207
 
4096
4208
  // src/route-contracts.ts
@@ -14813,7 +14925,7 @@ function parseDeliveryPlanResult(value) {
14813
14925
  throw new Error("Invalid delivery plan result.");
14814
14926
  return value;
14815
14927
  }
14816
- var objectOptions, reference, references, kind, isolation, DeliveryFilePathSchema, functionTarget, DeliveryOptionsSchema, DeliveryDiagnosticSchema, routeSchema, jobSchema, DeliveryTargetSchema, DeliveryPlanSchema, DeliveryPlanResultSchema, DeliveryConfigurationError;
14928
+ var objectOptions, reference, references, kind, isolation, mode, DeliveryFilePathSchema, functionTarget, DeliveryOptionsSchema, DeliveryDiagnosticSchema, routeSchema, jobSchema, DeliveryTargetSchema, DeliveryPlanSchema, DeliveryPlanResultSchema, DeliveryConfigurationError;
14817
14929
  var init_delivery_schema = __esm(() => {
14818
14930
  init_build();
14819
14931
  init_value2();
@@ -14823,6 +14935,7 @@ var init_delivery_schema = __esm(() => {
14823
14935
  references = _Array_(reference, { uniqueItems: true });
14824
14936
  kind = Union([Literal("api"), Literal("jobs"), Literal("webhook"), Literal("function")]);
14825
14937
  isolation = Union([Literal("shared"), Literal("process")]);
14938
+ mode = Union([Literal("required"), Literal("none")]);
14826
14939
  DeliveryFilePathSchema = String2({
14827
14940
  maxLength: 512,
14828
14941
  pattern: "^(?:[A-Za-z0-9_-][A-Za-z0-9_.-]*/)*[A-Za-z0-9_-][A-Za-z0-9_.-]*$"
@@ -14896,6 +15009,7 @@ var init_delivery_schema = __esm(() => {
14896
15009
  ]),
14897
15010
  path: String2(),
14898
15011
  command: Optional(String2()),
15012
+ idempotency: Optional(mode),
14899
15013
  reason: Union([Literal("default-http"), Literal("explicit-module")])
14900
15014
  }, objectOptions);
14901
15015
  jobSchema = _Object_({
@@ -15154,7 +15268,12 @@ function parseDeliveryBuildManifest(value) {
15154
15268
  throw new Error("Invalid delivery object inventory.");
15155
15269
  }
15156
15270
  }
15157
- const routes = value.plan.targets.flatMap((target) => target.routes.map((route) => ({ method: route.method, path: route.path, target: target.name })));
15271
+ const routes = value.plan.targets.flatMap((target) => target.routes.map((route) => ({
15272
+ method: route.method,
15273
+ path: route.path,
15274
+ target: target.name,
15275
+ ...route.idempotency === undefined ? {} : { idempotency: route.idempotency }
15276
+ })));
15158
15277
  const jobs = value.plan.targets.flatMap((target) => target.jobs.map((job) => ({ name: job.name, target: target.name })));
15159
15278
  if (canonical2(value.routes) !== canonical2(routes) || canonical2(value.jobs) !== canonical2(jobs)) {
15160
15279
  throw new Error("Delivery mappings do not match planned ownership.");
@@ -15205,7 +15324,8 @@ var init_delivery_build_schema = __esm(() => {
15205
15324
  routes: _Array_(_Object_({
15206
15325
  method: String2(),
15207
15326
  path: String2(),
15208
- target: String2()
15327
+ target: String2(),
15328
+ idempotency: Optional(Union([Literal("required"), Literal("none")]))
15209
15329
  }, closed2)),
15210
15330
  jobs: _Array_(_Object_({ name: String2(), target: String2() }, closed2))
15211
15331
  }, closed2);
@@ -15424,7 +15544,9 @@ function createExecutionPlans(graph) {
15424
15544
  const aspects = (boundary, refs = []) => refs.map((ref, index) => `${boundary}.aspect[${index}]:${ref.name}`);
15425
15545
  return graph.modules.flatMap((module) => [
15426
15546
  ...module.controllers.flatMap((controller) => controller.routes.map((route) => {
15427
- const command = module.commands.find((item) => item.className === route.command);
15547
+ const featureTransition = featureTransitionForRoute(module, controller, route);
15548
+ const commandRef = route.command ?? featureTransition?.command;
15549
+ const command = module.commands.find((item) => item.className === commandRef || item.name === commandRef);
15428
15550
  const path = `${controller.path}/${route.path}`.replace(/\/+/g, "/");
15429
15551
  return {
15430
15552
  module: module.name,
@@ -15574,6 +15696,7 @@ function exportGraphDot(graph) {
15574
15696
  }
15575
15697
  var init_inspect = __esm(() => {
15576
15698
  init_route_contracts();
15699
+ init_feature();
15577
15700
  });
15578
15701
 
15579
15702
  // src/repair-plan.ts
@@ -15827,7 +15950,7 @@ function parseDevelopmentContext(input) {
15827
15950
  throw invalid();
15828
15951
  }
15829
15952
  }
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;
15953
+ 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
15954
  var init_application_development = __esm(() => {
15832
15955
  init_build();
15833
15956
  init_value2();
@@ -15855,7 +15978,7 @@ var init_application_development = __esm(() => {
15855
15978
  text2 = String2({ minLength: 1, maxLength: 512, pattern: "^[^\\u0000-\\u001f\\u007f]+$" });
15856
15979
  texts = _Array_(text2);
15857
15980
  count = Integer({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER });
15858
- mode = Union([Literal("required"), Literal("none")]);
15981
+ mode2 = Union([Literal("required"), Literal("none")]);
15859
15982
  operations2 = _Array_(Union([Literal("read"), Literal("write"), Literal("publish"), Literal("consume")]));
15860
15983
  resourceUse = _Object_({ resource: text2, operations: operations2 }, closed4);
15861
15984
  schemaKind = Optional(Union([Literal("opaque"), Literal("declared")]));
@@ -15890,6 +16013,7 @@ var init_application_development = __esm(() => {
15890
16013
  controller: text2,
15891
16014
  handler: text2,
15892
16015
  command: Optional(text2),
16016
+ idempotency: Optional(mode2),
15893
16017
  aspects: texts,
15894
16018
  schemaKinds: Optional(_Object_({
15895
16019
  body: schemaKind,
@@ -15904,8 +16028,8 @@ var init_application_development = __esm(() => {
15904
16028
  module: text2,
15905
16029
  name: text2,
15906
16030
  permission: Optional(text2),
15907
- transaction: mode,
15908
- idempotency: mode,
16031
+ transaction: mode2,
16032
+ idempotency: mode2,
15909
16033
  audit: Optional(text2),
15910
16034
  resources: _Array_(resourceUse)
15911
16035
  }, closed4), { maxItems: APPLICATION_DEVELOPMENT_LIMITS.commands }),
@@ -16012,7 +16136,11 @@ function createApplicationDevelopmentContext(graph, options = {}) {
16012
16136
  for (const controller of module.controllers) {
16013
16137
  for (const route of controller.routes) {
16014
16138
  const candidates = module.commands.filter((command) => command.className === route.command || command.name === route.command);
16015
- const command = candidates.length === 1 ? candidates[0]?.name : undefined;
16139
+ const commandNode = candidates.length === 1 ? candidates[0] : undefined;
16140
+ const featureTransition = featureTransitionForRoute(module, controller, route);
16141
+ const featureCommandNode = featureTransition?.command === undefined ? undefined : module.commands.find((candidate) => candidate.className === featureTransition.command || candidate.name === featureTransition.command);
16142
+ const command = commandNode?.name ?? featureCommandNode?.name;
16143
+ const idempotency = route.idempotency ?? commandNode?.idempotency ?? featureTransition?.idempotency;
16016
16144
  const kinds = schemaKinds(route);
16017
16145
  routes.push({
16018
16146
  module: module.name,
@@ -16021,6 +16149,7 @@ function createApplicationDevelopmentContext(graph, options = {}) {
16021
16149
  controller: controller.className,
16022
16150
  handler: route.handler,
16023
16151
  ...command === undefined ? {} : { command },
16152
+ ...idempotency === undefined ? {} : { idempotency },
16024
16153
  aspects: (route.aspects ?? []).map((aspect) => aspect.name),
16025
16154
  ...kinds === undefined ? {} : { schemaKinds: kinds }
16026
16155
  });
@@ -16141,6 +16270,7 @@ var init_application_development2 = __esm(() => {
16141
16270
  init_application_development();
16142
16271
  init_inspect();
16143
16272
  init_execution_snapshot();
16273
+ init_feature();
16144
16274
  APPLICATION_DEVELOPMENT_LIMITS2 = APPLICATION_DEVELOPMENT_LIMITS;
16145
16275
  ApplicationDevelopmentError = class ApplicationDevelopmentError extends Error {
16146
16276
  code;
@@ -19463,6 +19593,10 @@ function parseFeatureSpec(input, ctx, seen = new Set) {
19463
19593
  if (!ts7.isObjectLiteralExpression(input))
19464
19594
  return invalid();
19465
19595
  const name = stringLiteralProp(input, "name");
19596
+ const databaseExpr = getProp(input, "database");
19597
+ const database = databaseExpr && ts7.isObjectLiteralExpression(databaseExpr) ? parseFeatureDatabaseSource(databaseExpr) : undefined;
19598
+ if (databaseExpr !== undefined && database === undefined)
19599
+ return invalid();
19466
19600
  const tenantField = stringLiteralProp(input, "tenantField");
19467
19601
  const statesExpr = getProp(input, "states");
19468
19602
  const transitionObject = getProp(input, "transitions");
@@ -19495,6 +19629,10 @@ function parseFeatureSpec(input, ctx, seen = new Set) {
19495
19629
  const idempotency = commandModeProp(options, "idempotency");
19496
19630
  const audit = stringLiteralProp(options, "audit");
19497
19631
  const job = stringLiteralProp(options, "job");
19632
+ const invalidateTagsExpr = getProp(options, "invalidateTags");
19633
+ 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;
19634
+ if (invalidateTagsExpr !== undefined && invalidateTags === undefined)
19635
+ return invalid();
19498
19636
  const persistenceExpr = getProp(options, "persistence");
19499
19637
  const persistence = persistenceExpr && ts7.isObjectLiteralExpression(persistenceExpr) ? parseFeaturePersistence(persistenceExpr) : undefined;
19500
19638
  if (persistenceExpr && !persistence)
@@ -19511,18 +19649,36 @@ function parseFeatureSpec(input, ctx, seen = new Set) {
19511
19649
  ...idempotency === undefined ? {} : { idempotency },
19512
19650
  ...audit === undefined ? {} : { audit },
19513
19651
  ...job === undefined ? {} : { job },
19652
+ ...invalidateTags === undefined ? {} : { invalidateTags },
19514
19653
  ...persistence === undefined ? {} : { persistence }
19515
19654
  });
19516
19655
  }
19517
19656
  return {
19518
19657
  name,
19519
19658
  states,
19659
+ ...database === undefined ? {} : { database },
19520
19660
  ...tenantField === undefined ? {} : { tenantField },
19521
19661
  transitions,
19522
19662
  file: sourcePath(ctx.rootDir, input.getSourceFile().fileName),
19523
19663
  line: lineOf(input)
19524
19664
  };
19525
19665
  }
19666
+ function parseFeatureDatabaseSource(input) {
19667
+ if (input.properties.some((property) => !ts7.isPropertyAssignment(property)))
19668
+ return;
19669
+ const source = stringLiteralProp(input, "source");
19670
+ const table = stringLiteralProp(input, "table");
19671
+ const rowType = stringLiteralProp(input, "rowType");
19672
+ if (!source || !table || !rowType)
19673
+ return;
19674
+ if (source.startsWith("/") || source.includes("\\") || source.split("/").includes("..") || !/^[A-Za-z0-9._/-]+$/.test(source))
19675
+ return;
19676
+ if (!/^[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*$/.test(table))
19677
+ return;
19678
+ if (!/^[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*$/.test(rowType))
19679
+ return;
19680
+ return { source, table, rowType };
19681
+ }
19526
19682
  function parseFeaturePersistence(input) {
19527
19683
  if (input.properties.some((property) => !ts7.isPropertyAssignment(property)))
19528
19684
  return;
@@ -19613,6 +19769,54 @@ function checkedCommandMode(object, property, ctx, command) {
19613
19769
  }
19614
19770
  return value;
19615
19771
  }
19772
+ function checkedRouteIdempotency(object, ctx, route) {
19773
+ const expression = getProp(object, "idempotency");
19774
+ const value = stringLiteralProp(object, "idempotency");
19775
+ if (expression && value !== "required" && value !== "none") {
19776
+ const file = sourcePath(ctx.rootDir, expression.getSourceFile().fileName);
19777
+ ctx.diagnostics.push({
19778
+ severity: "error",
19779
+ code: "invalid-route-idempotency",
19780
+ errorCode: "SC3028",
19781
+ docsUrl: "https://supacloud.dev/errors/SC3028",
19782
+ message: `${route}.idempotency must be the explicit literal "required" or "none".`,
19783
+ file,
19784
+ line: lineOf(expression),
19785
+ suggestion: `Choose the intended idempotency policy explicitly; use "required" for replayable mutations or "none" for routes without that contract.`
19786
+ });
19787
+ }
19788
+ return value === "required" || value === "none" ? value : undefined;
19789
+ }
19790
+ function checkedRouteTags(expression, ctx, route) {
19791
+ if (expression === undefined)
19792
+ return;
19793
+ if (!ts7.isArrayLiteralExpression(expression) || expression.elements.some((element) => !ts7.isStringLiteral(element) || element.text.trim().length === 0)) {
19794
+ ctx.diagnostics.push({
19795
+ severity: "error",
19796
+ code: "invalid-route-tags",
19797
+ errorCode: "SC3029",
19798
+ docsUrl: "https://supacloud.dev/errors/SC3029",
19799
+ message: `${route}.tags must be a static array of non-empty string literals.`,
19800
+ file: sourcePath(ctx.rootDir, expression.getSourceFile().fileName),
19801
+ line: lineOf(expression),
19802
+ suggestion: 'Declare stable cache tags explicitly, for example tags: ["cases.list"].'
19803
+ });
19804
+ return;
19805
+ }
19806
+ const tags = expression.elements.map((element) => element.text);
19807
+ if (new Set(tags).size !== tags.length) {
19808
+ ctx.diagnostics.push({
19809
+ severity: "error",
19810
+ code: "duplicate-route-tags",
19811
+ errorCode: "SC3030",
19812
+ docsUrl: "https://supacloud.dev/errors/SC3030",
19813
+ message: `${route}.tags must not contain duplicate values.`,
19814
+ file: sourcePath(ctx.rootDir, expression.getSourceFile().fileName),
19815
+ line: lineOf(expression)
19816
+ });
19817
+ }
19818
+ return tags;
19819
+ }
19616
19820
  function parseProvider(el, exportsSet, ctx) {
19617
19821
  const file = sourcePath(ctx.rootDir, el.getSourceFile().fileName);
19618
19822
  const line = lineOf(el);
@@ -20392,7 +20596,11 @@ function parseController(input, ctx) {
20392
20596
  if (optionsObject) {
20393
20597
  const parsePolicy = routePolicyProperty(optionsObject, "parse", ctx);
20394
20598
  const deletePolicy = routePolicyProperty(optionsObject, "allowDeleteBody", ctx);
20395
- if (parsePolicy.unresolved || deletePolicy.unresolved) {
20599
+ const idempotency = checkedRouteIdempotency(optionsObject, ctx, route.handler);
20600
+ if (idempotency !== undefined)
20601
+ route.idempotency = idempotency;
20602
+ const tagsPolicy = routePolicyProperty(optionsObject, "tags", ctx);
20603
+ if (parsePolicy.unresolved || deletePolicy.unresolved || tagsPolicy.unresolved) {
20396
20604
  ctx.diagnostics.push({
20397
20605
  severity: "error",
20398
20606
  code: "unresolved-route-body-policy",
@@ -20400,6 +20608,9 @@ function parseController(input, ctx) {
20400
20608
  message: `Route ${route.handler} body policy must be statically resolvable; dynamic spreads, computed keys and shorthand policies are unsupported.`
20401
20609
  });
20402
20610
  }
20611
+ const tags = checkedRouteTags(tagsPolicy.value, ctx, route.handler);
20612
+ if (tags !== undefined)
20613
+ route.tags = tags;
20403
20614
  const parse = parsePolicy.value;
20404
20615
  if (parse) {
20405
20616
  if (ts7.isStringLiteral(parse) && parse.text === "none")
@@ -22250,6 +22461,7 @@ function validateClientSchemaBoundaries(graph, options) {
22250
22461
 
22251
22462
  // src/effect-governance.ts
22252
22463
  init_validate();
22464
+ init_feature();
22253
22465
  function diagnostic(code, message, file, suggestion) {
22254
22466
  const metadata = COMPILER_DIAGNOSTIC_CODES[code];
22255
22467
  return {
@@ -22287,8 +22499,10 @@ function validateEffectPolicies(graph, options = {}) {
22287
22499
  if (effect.retry === "explicit" && effect.maxAttempts === undefined) {
22288
22500
  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
22501
  }
22290
- if (route.command && effect.retry === "explicit") {
22291
- const command = commands.get(route.command);
22502
+ if (effect.retry === "explicit") {
22503
+ const featureTransition = featureTransitionForRoute(module, controller, route);
22504
+ const commandRef = route.command ?? featureTransition?.command;
22505
+ const command = commandRef === undefined ? undefined : commands.get(commandRef) ?? [...commands.values()].find((candidate) => candidate.name === commandRef);
22292
22506
  if (command && command.idempotency !== "required") {
22293
22507
  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
22508
  }
@@ -22543,6 +22757,7 @@ function resolveTypeSafety(options) {
22543
22757
  init_delivery_schema();
22544
22758
 
22545
22759
  // src/delivery-plan.ts
22760
+ init_feature();
22546
22761
  init_validate();
22547
22762
  var compare2 = (a, b) => a < b ? -1 : a > b ? 1 : 0;
22548
22763
  var sorted = (values) => [...new Set(values)].sort(compare2);
@@ -22662,6 +22877,10 @@ function createDeliveryPlan(graph, input, validation) {
22662
22877
  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
22878
  }
22664
22879
  publicRoutes.set(key, target.name);
22880
+ const command = route.command === undefined ? undefined : module.commands.find((candidate) => candidate.className === route.command || candidate.name === route.command);
22881
+ const featureTransition = featureTransitionForRoute(module, controller, route);
22882
+ const effectiveCommand = route.command ?? featureTransition?.command;
22883
+ const idempotency = route.idempotency ?? command?.idempotency ?? featureTransition?.idempotency;
22665
22884
  target.roots.push(module.name);
22666
22885
  target.routes.push({
22667
22886
  module: module.name,
@@ -22669,7 +22888,8 @@ function createDeliveryPlan(graph, input, validation) {
22669
22888
  handler: route.handler,
22670
22889
  method: route.method,
22671
22890
  path,
22672
- ...route.command === undefined ? {} : { command: route.command },
22891
+ ...effectiveCommand === undefined ? {} : { command: effectiveCommand },
22892
+ ...idempotency === undefined ? {} : { idempotency },
22673
22893
  reason: owner === undefined ? "default-http" : "explicit-module"
22674
22894
  });
22675
22895
  }
@@ -23700,7 +23920,12 @@ async function buildDeliveryProject(options, delivery) {
23700
23920
  deploymentReady: false,
23701
23921
  plan: planned.plan,
23702
23922
  objects,
23703
- routes: planned.plan.targets.flatMap((target) => target.routes.map((route) => ({ method: route.method, path: route.path, target: target.name }))),
23923
+ routes: planned.plan.targets.flatMap((target) => target.routes.map((route) => ({
23924
+ method: route.method,
23925
+ path: route.path,
23926
+ target: target.name,
23927
+ ...route.idempotency === undefined ? {} : { idempotency: route.idempotency }
23928
+ }))),
23704
23929
  jobs: planned.plan.targets.flatMap((target) => target.jobs.map((job) => ({ name: job.name, target: target.name })))
23705
23930
  });
23706
23931
  phase = "artifact-integrity";
@@ -24450,10 +24675,10 @@ import { readFileSync as readFileSync4 } from "node:fs";
24450
24675
  import { resolve as resolve17 } from "node:path";
24451
24676
 
24452
24677
  // src/migration-app-version.ts
24453
- var MIGRATION_APP_VERSION = "0.29.0";
24678
+ var MIGRATION_APP_VERSION = "0.31.0";
24454
24679
 
24455
24680
  // src/migration-elysia-version.ts
24456
- var MIGRATION_ELYSIA_VERSION = "0.34.0";
24681
+ var MIGRATION_ELYSIA_VERSION = "0.35.1";
24457
24682
 
24458
24683
  // src/migration-policy.ts
24459
24684
  var cachedCompilerVersion;
@@ -25488,8 +25713,8 @@ function validFeatureInventory(value, jobs, commands, routes, rpc) {
25488
25713
  return false;
25489
25714
  }
25490
25715
  const commandName = transition["command"];
25716
+ const command = commandName === undefined ? undefined : commands.find((candidate) => isRecord2(candidate) && (candidate["name"] === commandName || candidate["className"] === commandName));
25491
25717
  if (commandName !== undefined) {
25492
- const command = commands.find((candidate) => isRecord2(candidate) && (candidate["name"] === commandName || candidate["className"] === commandName));
25493
25718
  if (!isRecord2(command))
25494
25719
  return false;
25495
25720
  if (transition["permission"] !== undefined && command["permission"] !== transition["permission"])
@@ -25520,9 +25745,21 @@ function validFeatureInventory(value, jobs, commands, routes, rpc) {
25520
25745
  if (routeBinding === "controller") {
25521
25746
  if (route["command"] !== undefined)
25522
25747
  return false;
25523
- } else if (route["command"] !== commandName) {
25524
- return false;
25748
+ } else {
25749
+ if (typeof route["command"] !== "string")
25750
+ return false;
25751
+ const routeCommand = commands.find((candidate) => isRecord2(candidate) && (candidate["name"] === route["command"] || candidate["className"] === route["command"]));
25752
+ if (!isRecord2(routeCommand) || !isRecord2(command) || routeCommand["name"] !== command["name"])
25753
+ return false;
25525
25754
  }
25755
+ if (transition["permission"] !== undefined && route["permission"] !== transition["permission"])
25756
+ return false;
25757
+ if (transition["idempotency"] !== undefined && route["idempotency"] !== transition["idempotency"])
25758
+ return false;
25759
+ if (persistence !== undefined && route["idempotency"] !== "required")
25760
+ return false;
25761
+ if (route["idempotency"] === "required" && (route["method"] === "GET" || route["method"] === "HEAD" || route["method"] === "OPTIONS"))
25762
+ return false;
25526
25763
  }
25527
25764
  const job = transition["job"];
25528
25765
  if (job === undefined)