@supacloud/compiler 0.48.0 → 0.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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) => {
@@ -278,6 +282,11 @@ function validateFeatureSpec(spec, module) {
278
282
  if (transition.permission !== undefined && !transition.permission.trim()) {
279
283
  error("feature-governance-drift", `Transition ${transition.name} declares an empty permission.`);
280
284
  }
285
+ if (transition.invalidateTags !== undefined) {
286
+ if (transition.invalidateTags.length === 0 || transition.invalidateTags.some((tag) => !tag.trim()) || new Set(transition.invalidateTags).size !== transition.invalidateTags.length) {
287
+ error("feature-invalidation-tags-invalid", `Transition ${transition.name} must declare unique, non-empty invalidateTags.`);
288
+ }
289
+ }
281
290
  if (transition.persistence) {
282
291
  const persistence = transition.persistence;
283
292
  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 +379,7 @@ function validateFeatureSpec(spec, module) {
370
379
  const routes = module.controllers.flatMap((controller) => controller.routes.filter((route) => `${route.method} ${joinRoutePaths(controller.path, route.path)}` === transition.route));
371
380
  if (routes.length !== 1) {
372
381
  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) {
382
+ } else if (command && transition.routeBinding !== "controller" && routes[0]?.command !== command.className && routes[0]?.command !== command.name) {
374
383
  error("feature-route-drift", `Route ${transition.route} is not bound to ${command.className}.`);
375
384
  } else if (transition.routeBinding === "controller" && routes[0]?.command !== undefined) {
376
385
  error("feature-route-drift", `Controller-bound route ${transition.route} must not also declare a framework command binding.`);
@@ -615,13 +624,17 @@ function validateGraph(graph, options = false) {
615
624
  handler: route.handler,
616
625
  ...route.redirectTo === undefined ? {} : { redirectTo: route.redirectTo }
617
626
  });
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);
627
+ const featureTransition = featureTransitionForRoute(module, controller, route);
628
+ const effectiveCommandRef = route.command ?? featureTransition?.command;
629
+ const effectiveCommand = effectiveCommandRef === undefined ? undefined : module.commands.find((command) => command.className === effectiveCommandRef || command.name === effectiveCommandRef);
630
+ const effectiveIdempotency = route.idempotency ?? featureTransition?.idempotency;
631
+ if (route.command && !effectiveCommand) {
632
+ error("route-command-unresolved", `路由 ${key} 绑定的 command ${route.command} 未在模块 ${module.name} 声明`, controller.file);
620
633
  }
621
- if ((route.method === "GET" || route.method === "HEAD") && route.command) {
622
- const boundCommand = module.commands.find((c) => c.className === route.command);
634
+ if ((route.method === "GET" || route.method === "HEAD") && effectiveCommand) {
635
+ const boundCommand = effectiveCommand;
623
636
  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'.`);
637
+ 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
638
  }
626
639
  }
627
640
  if (typeof options === "object" && options.allowRouteCommandBindings === false && route.command) {
@@ -692,6 +705,9 @@ function validateGraph(graph, options = false) {
692
705
  if (route.allowDeleteBody && (route.method !== "DELETE" || !route.body)) {
693
706
  error("invalid-delete-body-opt-in", `Route ${controller.className}.${route.handler} allowDeleteBody requires DELETE and an explicit body schema.`, controller.file);
694
707
  }
708
+ if (effectiveIdempotency === "required" && (route.method === "GET" || route.method === "HEAD" || route.method === "OPTIONS")) {
709
+ 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.`);
710
+ }
695
711
  if (route.parse === "none" && (route.body || route.hasBodyBinding || route.contract?.body !== "domain" || !route.contract.evidence?.trim())) {
696
712
  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
713
  }
@@ -1366,6 +1382,7 @@ var init_validate = __esm(() => {
1366
1382
  "command-transaction-unsupported": { code: "SC4026", docsUrl: "https://supacloud.dev/errors/SC4026" },
1367
1383
  "command-reconcile-required": { code: "SC4028", docsUrl: "https://supacloud.dev/errors/SC4028" },
1368
1384
  "invalid-route-contract": { code: "SC3020", docsUrl: "https://supacloud.dev/errors/SC3020" },
1385
+ "invalid-route-idempotency": { code: "SC3028", docsUrl: "https://supacloud.dev/errors/SC3028" },
1369
1386
  "route-contract-required": { code: "SC3021", docsUrl: "https://supacloud.dev/errors/SC3021" },
1370
1387
  "route-contract-unverified": { code: "SC3022", docsUrl: "https://supacloud.dev/errors/SC3022" },
1371
1388
  "route-contract-evidence-required": { code: "SC3023", docsUrl: "https://supacloud.dev/errors/SC3023" },
@@ -1512,6 +1529,7 @@ function buildContractManifest(graph, artifacts) {
1512
1529
  ...transition.idempotency === undefined ? {} : { idempotency: transition.idempotency },
1513
1530
  ...transition.audit === undefined ? {} : { audit: transition.audit },
1514
1531
  ...transition.job === undefined ? {} : { job: transition.job },
1532
+ ...transition.invalidateTags === undefined ? {} : { invalidateTags: [...transition.invalidateTags] },
1515
1533
  ...job === undefined ? {} : {
1516
1534
  jobDetails: {
1517
1535
  name: job.name,
@@ -1532,9 +1550,14 @@ function buildContractManifest(graph, artifacts) {
1532
1550
  }
1533
1551
  for (const controller of module.controllers) {
1534
1552
  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);
1553
+ const command = route.command ? module.commands.find((candidate) => candidate.className === route.command || candidate.name === route.command) : undefined;
1554
+ const featureTransition = featureTransitionForRoute(module, controller, route);
1555
+ const featureCommand = featureTransition?.command === undefined ? undefined : module.commands.find((candidate) => candidate.className === featureTransition.command || candidate.name === featureTransition.command);
1556
+ const governedCommand = command ?? featureCommand;
1557
+ const effectiveCommand = route.command ?? featureTransition?.command;
1558
+ const idempotency = route.idempotency ?? command?.idempotency ?? featureTransition?.idempotency;
1559
+ if (governedCommand?.permission)
1560
+ permissions.add(governedCommand.permission);
1538
1561
  if (route.contract?.evidence)
1539
1562
  fixtures.add(route.contract.evidence);
1540
1563
  routes.push({
@@ -1543,8 +1566,10 @@ function buildContractManifest(graph, artifacts) {
1543
1566
  module: module.name,
1544
1567
  controller: controller.className,
1545
1568
  handler: route.handler,
1546
- ...route.command === undefined ? {} : { command: route.command },
1547
- ...command?.permission === undefined ? {} : { permission: command.permission },
1569
+ ...effectiveCommand === undefined ? {} : { command: effectiveCommand },
1570
+ ...governedCommand?.permission === undefined ? {} : { permission: governedCommand.permission },
1571
+ ...idempotency === undefined ? {} : { idempotency },
1572
+ ...route.tags === undefined ? {} : { tags: [...route.tags] },
1548
1573
  requestSchemas: Object.fromEntries(["body", "params", "query", "headers", "cookie"].flatMap((key) => route[key] === undefined ? [] : [[key, route[key]]])),
1549
1574
  ...route.response === undefined ? {} : { responseSchema: route.response },
1550
1575
  ...route.responses === undefined ? {} : {
@@ -1583,7 +1608,9 @@ function buildContractManifest(graph, artifacts) {
1583
1608
  }
1584
1609
  };
1585
1610
  }
1586
- var init_contract_manifest = () => {};
1611
+ var init_contract_manifest = __esm(() => {
1612
+ init_feature();
1613
+ });
1587
1614
 
1588
1615
  // src/generate.ts
1589
1616
  import { createHash as createHash4 } from "node:crypto";
@@ -1995,6 +2022,9 @@ class ModuleGenerator {
1995
2022
  return "[]";
1996
2023
  const items = this.module.controllers.map((controller) => {
1997
2024
  const routes = controller.routes.map((route) => {
2025
+ const command = route.command === undefined ? undefined : this.module.commands.find((candidate) => candidate.className === route.command || candidate.name === route.command);
2026
+ const featureTransition = featureTransitionForRoute(this.module, controller, route);
2027
+ const idempotency = route.idempotency ?? command?.idempotency ?? featureTransition?.idempotency;
1998
2028
  const responseEntries = normalizedResponseEntries(route.responses);
1999
2029
  const hasResponseMap = responseEntries.length > 0;
2000
2030
  const fields = [
@@ -2021,6 +2051,9 @@ class ModuleGenerator {
2021
2051
  if (route.effect !== undefined) {
2022
2052
  fields.push(`effect: ${JSON.stringify(route.effect)}`);
2023
2053
  }
2054
+ if (idempotency !== undefined) {
2055
+ fields.push(`idempotency: ${JSON.stringify(idempotency)}`);
2056
+ }
2024
2057
  if (route.command)
2025
2058
  fields.push(`command: ${JSON.stringify(route.command)}`);
2026
2059
  if (route.guards && route.guards.length > 0) {
@@ -2503,7 +2536,18 @@ function renderClient(graph, options) {
2503
2536
  const usedTypeNames = new Set;
2504
2537
  const allRoutes = [];
2505
2538
  const allProcedures = [];
2506
- const commandByClassName = new Map(graph.modules.flatMap((module) => module.commands.map((command) => [command.className, command])));
2539
+ const commandByClassName = new Map(graph.modules.flatMap((module) => module.commands.flatMap((command) => [
2540
+ [command.className, command],
2541
+ [command.name, command]
2542
+ ])));
2543
+ const featureInvalidationTags = new Map;
2544
+ for (const module of graph.modules) {
2545
+ for (const transition of module.featureSpec?.transitions ?? []) {
2546
+ if (transition.route && transition.invalidateTags) {
2547
+ featureInvalidationTags.set(`${module.name}:${transition.route}`, [...transition.invalidateTags]);
2548
+ }
2549
+ }
2550
+ }
2507
2551
  let usesStatic = false;
2508
2552
  let usesValue = false;
2509
2553
  const uniqueTypeName = (candidate) => {
@@ -2581,6 +2625,13 @@ function renderClient(graph, options) {
2581
2625
  const requestType = `ClientRequestOptions<${paramsType}, ${queryType}, ${bodyType}, ${headersType}, ${cookieType}>${requiredRequestFields.length > 0 ? ` & { ${requiredRequestFields.join("; ")} }` : ""}`;
2582
2626
  routeTypes.push(`export type ${requestTypeName} = ${requestType};`);
2583
2627
  routeTypes.push(`export type ${responseTypeName} = ${responseType === "never" ? "unknown" : responseType};`);
2628
+ const command = route.command === undefined ? undefined : commandByClassName.get(route.command);
2629
+ const featureTransition = featureTransitionForRoute(module, controller, route);
2630
+ const featureCommand = featureTransition?.command === undefined ? undefined : commandByClassName.get(featureTransition.command);
2631
+ const governedCommand = command ?? featureCommand;
2632
+ const idempotencyMode = route.idempotency ?? command?.idempotency ?? featureTransition?.idempotency ?? "none";
2633
+ const transitionRoute = `${route.method} ${fullPath}`;
2634
+ const invalidationTags = featureInvalidationTags.get(`${module.name}:${transitionRoute}`);
2584
2635
  const routeFields = [
2585
2636
  `method: ${JSON.stringify(route.method)}`,
2586
2637
  `path: ${JSON.stringify(fullPath)}`,
@@ -2588,7 +2639,7 @@ function renderClient(graph, options) {
2588
2639
  `handler: ${JSON.stringify(route.handler)}`
2589
2640
  ];
2590
2641
  for (const [key, value] of [
2591
- ["command", route.command],
2642
+ ["command", route.command ?? featureTransition?.command],
2592
2643
  ["guards", route.guards],
2593
2644
  ["canMatch", route.canMatch],
2594
2645
  ["canDeactivate", route.canDeactivate],
@@ -2602,7 +2653,10 @@ function renderClient(graph, options) {
2602
2653
  ["title", route.title],
2603
2654
  ["data", route.data],
2604
2655
  ["parse", route.parse],
2605
- ["allowDeleteBody", route.allowDeleteBody]
2656
+ ["allowDeleteBody", route.allowDeleteBody],
2657
+ ["idempotency", idempotencyMode === "none" ? undefined : idempotencyMode],
2658
+ ["tags", route.tags],
2659
+ ["invalidateTags", invalidationTags]
2606
2660
  ]) {
2607
2661
  if (value !== undefined)
2608
2662
  routeFields.push(`${key}: ${JSON.stringify(value)}`);
@@ -2635,9 +2689,7 @@ function renderClient(graph, options) {
2635
2689
  const responseSchemaEntries = hasResponseMap ? Object.entries(responseSchemas).map(([status, ref]) => [status, ref.local]) : responseSchema ? [["200", responseSchema.local]] : [];
2636
2690
  const responseSchemaArgument = responseSchemaEntries.length > 0 ? `{ ${responseSchemaEntries.map(([status, local]) => `${JSON.stringify(status)}: ${local}`).join(", ")} }` : "undefined";
2637
2691
  const responseKindArgument = responseKind === undefined ? "undefined" : JSON.stringify(responseKind);
2638
- const command = route.command === undefined ? undefined : commandByClassName.get(route.command);
2639
2692
  const procedureKind = route.method === "GET" || route.method === "HEAD" ? "query" : "mutation";
2640
- const idempotencyMode = command?.idempotency ?? "none";
2641
2693
  const procedureMetadata = {
2642
2694
  key: `${controller.className}.${route.handler}`,
2643
2695
  method: route.method,
@@ -2645,7 +2697,9 @@ function renderClient(graph, options) {
2645
2697
  controller: controller.className,
2646
2698
  handler: route.handler,
2647
2699
  kind: procedureKind,
2648
- idempotency: idempotencyMode
2700
+ idempotency: idempotencyMode,
2701
+ ...route.tags === undefined ? {} : { tags: route.tags },
2702
+ ...invalidationTags === undefined ? {} : { invalidateTags: invalidationTags }
2649
2703
  };
2650
2704
  allProcedures.push({
2651
2705
  method: route.method,
@@ -2653,9 +2707,11 @@ function renderClient(graph, options) {
2653
2707
  controller: controller.className,
2654
2708
  handler: route.handler,
2655
2709
  kind: procedureKind,
2656
- ...command?.name === undefined ? {} : { command: command.name },
2657
- ...command?.permission === undefined ? {} : { permission: command.permission },
2658
- idempotency: idempotencyMode
2710
+ ...governedCommand?.name === undefined ? {} : { command: governedCommand.name },
2711
+ ...governedCommand?.permission === undefined ? {} : { permission: governedCommand.permission },
2712
+ idempotency: idempotencyMode,
2713
+ ...route.tags === undefined ? {} : { tags: [...route.tags] },
2714
+ ...invalidationTags === undefined ? {} : { invalidateTags: [...invalidationTags] }
2659
2715
  });
2660
2716
  routeMethods.push(`
2661
2717
  ${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 +2765,8 @@ ${procedureMethods.join(`
2709
2765
  " handler: string;",
2710
2766
  " kind: Kind;",
2711
2767
  " idempotency: Idempotency;",
2768
+ " tags?: readonly string[];",
2769
+ " invalidateTags?: readonly string[];",
2712
2770
  "}",
2713
2771
  "",
2714
2772
  "export interface ProcedureExecutionOptions {",
@@ -2814,6 +2872,8 @@ ${procedureMethods.join(`
2814
2872
  " command?: string;",
2815
2873
  " permission?: string;",
2816
2874
  " idempotency: IdempotencyMode;",
2875
+ " tags?: readonly string[];",
2876
+ " invalidateTags?: readonly string[];",
2817
2877
  "}",
2818
2878
  "",
2819
2879
  ...routeTypes,
@@ -3313,7 +3373,10 @@ function renderOpenApi(graph, options) {
3313
3373
  }
3314
3374
  return schemaComponents.get(key);
3315
3375
  };
3316
- const commandByClass = new Map(graph.modules.flatMap((module) => module.commands.map((command) => [command.className, command])));
3376
+ const commandByClass = new Map(graph.modules.flatMap((module) => module.commands.flatMap((command) => [
3377
+ [command.className, command],
3378
+ [command.name, command]
3379
+ ])));
3317
3380
  for (const module of graph.modules) {
3318
3381
  for (const controller of module.controllers) {
3319
3382
  for (const route of controller.routes) {
@@ -3335,7 +3398,12 @@ function renderOpenApi(graph, options) {
3335
3398
  const operationBase = pascalName(`${module.name}_${controller.className}_${route.handler}`);
3336
3399
  const operationId = uniqueName(operationBase, operationIds);
3337
3400
  const command = route.command ? commandByClass.get(route.command) : undefined;
3338
- const requiresAuth = (route.guards?.length ?? 0) > 0 || command !== undefined;
3401
+ const featureTransition = featureTransitionForRoute(module, controller, route);
3402
+ const featureCommand = featureTransition?.command === undefined ? undefined : commandByClass.get(featureTransition.command);
3403
+ const governedCommand = command ?? featureCommand;
3404
+ const idempotencyMode = route.idempotency ?? command?.idempotency ?? featureTransition?.idempotency ?? "none";
3405
+ const permission = governedCommand?.permission ?? featureTransition?.permission;
3406
+ const requiresAuth = (route.guards?.length ?? 0) > 0 || permission !== undefined || governedCommand !== undefined;
3339
3407
  const responseKind = route.contract?.response;
3340
3408
  const responseContentType = responseKind === "binary" || responseKind === "stream" ? "application/octet-stream" : "application/json";
3341
3409
  const definition = {
@@ -3360,11 +3428,12 @@ function renderOpenApi(graph, options) {
3360
3428
  responseKind: responseKind ?? (route.nativeResponse ? "native-response" : undefined),
3361
3429
  ...route.parse === undefined ? {} : { parse: route.parse },
3362
3430
  ...route.allowDeleteBody === undefined ? {} : { allowDeleteBody: route.allowDeleteBody },
3431
+ ...idempotencyMode === "none" ? {} : { idempotency: idempotencyMode },
3363
3432
  module: module.name,
3364
3433
  controller: controller.className,
3365
3434
  handler: route.handler,
3366
- ...route.command === undefined ? {} : { command: route.command },
3367
- ...command?.permission === undefined ? {} : { permission: command.permission },
3435
+ ...route.command === undefined && featureTransition?.command === undefined ? {} : { command: route.command ?? featureTransition?.command },
3436
+ ...permission === undefined ? {} : { permission },
3368
3437
  ...route.guards && route.guards.length > 0 ? { guards: route.guards } : {},
3369
3438
  ...route.canMatch && route.canMatch.length > 0 ? { canMatch: route.canMatch } : {},
3370
3439
  ...route.canDeactivate && route.canDeactivate.length > 0 ? { canDeactivate: route.canDeactivate } : {},
@@ -3439,6 +3508,7 @@ function renderOpenApi(graph, options) {
3439
3508
  " handler: string;",
3440
3509
  " command?: string;",
3441
3510
  " permission?: string;",
3511
+ ' idempotency?: "required" | "none";',
3442
3512
  " guards?: readonly string[];",
3443
3513
  " canMatch?: readonly string[];",
3444
3514
  " canDeactivate?: readonly string[];",
@@ -3691,6 +3761,18 @@ function renderOpenApi(graph, options) {
3691
3761
  " for (const name of headersSchema ? schemaPropertyNames(headersSchema) : []) parameters.push({",
3692
3762
  ' name, in: "header", required: schemaRequired(headersSchema, name), schema: schemaProperty(headersSchema, name),',
3693
3763
  " });",
3764
+ ' if (definition.idempotency === "required") {',
3765
+ ' const keySchema = { type: "string", minLength: 1, maxLength: 512, pattern: "^[A-Za-z0-9._:-]{1,512}$" };',
3766
+ ' const declaredHeaders = parameters.filter((parameter) => parameter["in"] === "header" && typeof parameter["name"] === "string" && parameter["name"].toLowerCase() === "idempotency-key");',
3767
+ " const header = declaredHeaders[0];",
3768
+ " if (header) {",
3769
+ ' header["required"] = true;',
3770
+ ' header["schema"] = { allOf: [...declaredHeaders.map((parameter) => parameter["schema"]), keySchema] };',
3771
+ " for (const duplicate of declaredHeaders.slice(1)) parameters.splice(parameters.indexOf(duplicate), 1);",
3772
+ " } else {",
3773
+ ' parameters.push({ name: "Idempotency-Key", in: "header", required: true, schema: keySchema });',
3774
+ " }",
3775
+ " }",
3694
3776
  " const cookieSchema = definition.cookieSchema ? OPENAPI_SCHEMA_REGISTRY[definition.cookieSchema as keyof typeof OPENAPI_SCHEMA_REGISTRY] : undefined;",
3695
3777
  " for (const name of cookieSchema ? schemaPropertyNames(cookieSchema) : []) parameters.push({",
3696
3778
  ' name, in: "cookie", required: schemaRequired(cookieSchema, name), schema: schemaProperty(cookieSchema, name),',
@@ -3713,6 +3795,7 @@ function renderOpenApi(graph, options) {
3713
3795
  ' responses["500"] = { description: "Internal server error", content: { "application/json": { schema: { $ref: "#/components/schemas/SupaCloudError" } } } };',
3714
3796
  ' responses["200"] = { description: "Successful response" };',
3715
3797
  " }",
3798
+ ' 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
3799
  " const operation: Record<string, unknown> = {",
3717
3800
  " operationId: definition.operationId, tags: [...definition.tags], summary: definition.summary, responses,",
3718
3801
  " ...(parameters.length === 0 ? {} : { parameters }),",
@@ -3723,6 +3806,7 @@ function renderOpenApi(graph, options) {
3723
3806
  " module: definition.module, controller: definition.controller, handler: definition.handler,",
3724
3807
  " ...(definition.command ? { command: definition.command } : {}),",
3725
3808
  " ...(definition.permission ? { permission: definition.permission } : {}),",
3809
+ " ...(definition.idempotency ? { idempotency: definition.idempotency } : {}),",
3726
3810
  " ...(definition.guards ? { guards: [...definition.guards] } : {}),",
3727
3811
  " ...(definition.canMatch ? { canMatch: [...definition.canMatch] } : {}),",
3728
3812
  " ...(definition.canDeactivate ? { canDeactivate: [...definition.canDeactivate] } : {}),",
@@ -3773,11 +3857,13 @@ function renderPermissions(graph) {
3773
3857
  }
3774
3858
  for (const controller of module.controllers) {
3775
3859
  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
- }
3860
+ const featureTransition = featureTransitionForRoute(module, controller, route);
3861
+ const command = route.command === undefined ? undefined : module.commands.find((candidate) => candidate.className === route.command || candidate.name === route.command);
3862
+ const featureCommand = featureTransition?.command === undefined ? undefined : module.commands.find((candidate) => candidate.className === featureTransition.command || candidate.name === featureTransition.command);
3863
+ const governedCommand = command ?? featureCommand;
3864
+ const effectiveCommand = route.command ?? featureTransition?.command;
3865
+ const perm = governedCommand?.permission ?? featureTransition?.permission;
3866
+ const idempotency = route.idempotency ?? command?.idempotency ?? featureTransition?.idempotency;
3781
3867
  if (perm)
3782
3868
  permissions.add(perm);
3783
3869
  bindings.push({
@@ -3785,8 +3871,9 @@ function renderPermissions(graph) {
3785
3871
  path: joinRoutePaths(controller.path, route.path),
3786
3872
  controller: controller.className,
3787
3873
  handler: route.handler,
3788
- ...route.command === undefined ? {} : { command: route.command },
3789
- ...perm === undefined ? {} : { permission: perm }
3874
+ ...effectiveCommand === undefined ? {} : { command: effectiveCommand },
3875
+ ...perm === undefined ? {} : { permission: perm },
3876
+ ...idempotency === undefined ? {} : { idempotency }
3790
3877
  });
3791
3878
  }
3792
3879
  }
@@ -3812,6 +3899,7 @@ function renderPermissions(graph) {
3812
3899
  " handler: string;",
3813
3900
  " command?: string;",
3814
3901
  " permission?: string;",
3902
+ ' idempotency?: "required" | "none";',
3815
3903
  "}",
3816
3904
  "",
3817
3905
  "export const RoutePermissions: RoutePermissionBinding[] = " + JSON.stringify(bindings, null, 2) + ";",
@@ -3855,6 +3943,8 @@ var HEADER = "// GENERATED BY @supacloud/compiler — do not edit", INTERFACES =
3855
3943
  /** True when the handler returns a native Response rather than a framework value. */
3856
3944
  nativeResponse?: boolean;
3857
3945
  command?: string;
3946
+ /** Transport-level idempotency contract, independent of route command binding. */
3947
+ idempotency?: "required" | "none";
3858
3948
  guards?: string[];
3859
3949
  canMatch?: string[];
3860
3950
  canDeactivate?: string[];
@@ -4092,6 +4182,7 @@ async function destroyServiceInstances(
4092
4182
  `;
4093
4183
  var init_generate = __esm(() => {
4094
4184
  init_contract_manifest();
4185
+ init_feature();
4095
4186
  });
4096
4187
 
4097
4188
  // src/route-contracts.ts
@@ -4945,7 +5036,9 @@ function createExecutionPlans(graph) {
4945
5036
  const aspects = (boundary, refs = []) => refs.map((ref, index) => `${boundary}.aspect[${index}]:${ref.name}`);
4946
5037
  return graph.modules.flatMap((module) => [
4947
5038
  ...module.controllers.flatMap((controller) => controller.routes.map((route) => {
4948
- const command = module.commands.find((item) => item.className === route.command);
5039
+ const featureTransition = featureTransitionForRoute(module, controller, route);
5040
+ const commandRef = route.command ?? featureTransition?.command;
5041
+ const command = module.commands.find((item) => item.className === commandRef || item.name === commandRef);
4949
5042
  const path = `${controller.path}/${route.path}`.replace(/\/+/g, "/");
4950
5043
  return {
4951
5044
  module: module.name,
@@ -5095,6 +5188,7 @@ function exportGraphDot(graph) {
5095
5188
  }
5096
5189
  var init_inspect = __esm(() => {
5097
5190
  init_route_contracts();
5191
+ init_feature();
5098
5192
  });
5099
5193
 
5100
5194
  // node_modules/typebox/build/system/memory/metrics.mjs
@@ -15662,6 +15756,7 @@ var init_application_development = __esm(() => {
15662
15756
  controller: text2,
15663
15757
  handler: text2,
15664
15758
  command: Optional(text2),
15759
+ idempotency: Optional(mode),
15665
15760
  aspects: texts,
15666
15761
  schemaKinds: Optional(_Object_({
15667
15762
  body: schemaKind,
@@ -15784,7 +15879,11 @@ function createApplicationDevelopmentContext(graph, options = {}) {
15784
15879
  for (const controller of module.controllers) {
15785
15880
  for (const route of controller.routes) {
15786
15881
  const candidates = module.commands.filter((command) => command.className === route.command || command.name === route.command);
15787
- const command = candidates.length === 1 ? candidates[0]?.name : undefined;
15882
+ const commandNode = candidates.length === 1 ? candidates[0] : undefined;
15883
+ const featureTransition = featureTransitionForRoute(module, controller, route);
15884
+ const featureCommandNode = featureTransition?.command === undefined ? undefined : module.commands.find((candidate) => candidate.className === featureTransition.command || candidate.name === featureTransition.command);
15885
+ const command = commandNode?.name ?? featureCommandNode?.name;
15886
+ const idempotency = route.idempotency ?? commandNode?.idempotency ?? featureTransition?.idempotency;
15788
15887
  const kinds = schemaKinds(route);
15789
15888
  routes.push({
15790
15889
  module: module.name,
@@ -15793,6 +15892,7 @@ function createApplicationDevelopmentContext(graph, options = {}) {
15793
15892
  controller: controller.className,
15794
15893
  handler: route.handler,
15795
15894
  ...command === undefined ? {} : { command },
15895
+ ...idempotency === undefined ? {} : { idempotency },
15796
15896
  aspects: (route.aspects ?? []).map((aspect) => aspect.name),
15797
15897
  ...kinds === undefined ? {} : { schemaKinds: kinds }
15798
15898
  });
@@ -15913,6 +16013,7 @@ var init_application_development2 = __esm(() => {
15913
16013
  init_application_development();
15914
16014
  init_inspect();
15915
16015
  init_execution_snapshot();
16016
+ init_feature();
15916
16017
  APPLICATION_DEVELOPMENT_LIMITS2 = APPLICATION_DEVELOPMENT_LIMITS;
15917
16018
  ApplicationDevelopmentError = class ApplicationDevelopmentError extends Error {
15918
16019
  code;
@@ -16014,7 +16115,7 @@ function parseDeliveryPlanResult(value) {
16014
16115
  throw new Error("Invalid delivery plan result.");
16015
16116
  return value;
16016
16117
  }
16017
- var objectOptions, reference2, references, kind2, isolation, DeliveryFilePathSchema, functionTarget, DeliveryOptionsSchema, DeliveryDiagnosticSchema, routeSchema, jobSchema, DeliveryTargetSchema, DeliveryPlanSchema, DeliveryPlanResultSchema, DeliveryConfigurationError;
16118
+ var objectOptions, reference2, references, kind2, isolation, mode2, DeliveryFilePathSchema, functionTarget, DeliveryOptionsSchema, DeliveryDiagnosticSchema, routeSchema, jobSchema, DeliveryTargetSchema, DeliveryPlanSchema, DeliveryPlanResultSchema, DeliveryConfigurationError;
16018
16119
  var init_delivery_schema = __esm(() => {
16019
16120
  init_build();
16020
16121
  init_value2();
@@ -16024,6 +16125,7 @@ var init_delivery_schema = __esm(() => {
16024
16125
  references = _Array_(reference2, { uniqueItems: true });
16025
16126
  kind2 = Union([Literal("api"), Literal("jobs"), Literal("webhook"), Literal("function")]);
16026
16127
  isolation = Union([Literal("shared"), Literal("process")]);
16128
+ mode2 = Union([Literal("required"), Literal("none")]);
16027
16129
  DeliveryFilePathSchema = String2({
16028
16130
  maxLength: 512,
16029
16131
  pattern: "^(?:[A-Za-z0-9_-][A-Za-z0-9_.-]*/)*[A-Za-z0-9_-][A-Za-z0-9_.-]*$"
@@ -16097,6 +16199,7 @@ var init_delivery_schema = __esm(() => {
16097
16199
  ]),
16098
16200
  path: String2(),
16099
16201
  command: Optional(String2()),
16202
+ idempotency: Optional(mode2),
16100
16203
  reason: Union([Literal("default-http"), Literal("explicit-module")])
16101
16204
  }, objectOptions);
16102
16205
  jobSchema = _Object_({
@@ -16355,7 +16458,12 @@ function parseDeliveryBuildManifest(value) {
16355
16458
  throw new Error("Invalid delivery object inventory.");
16356
16459
  }
16357
16460
  }
16358
- const routes = value.plan.targets.flatMap((target) => target.routes.map((route) => ({ method: route.method, path: route.path, target: target.name })));
16461
+ const routes = value.plan.targets.flatMap((target) => target.routes.map((route) => ({
16462
+ method: route.method,
16463
+ path: route.path,
16464
+ target: target.name,
16465
+ ...route.idempotency === undefined ? {} : { idempotency: route.idempotency }
16466
+ })));
16359
16467
  const jobs = value.plan.targets.flatMap((target) => target.jobs.map((job) => ({ name: job.name, target: target.name })));
16360
16468
  if (canonical2(value.routes) !== canonical2(routes) || canonical2(value.jobs) !== canonical2(jobs)) {
16361
16469
  throw new Error("Delivery mappings do not match planned ownership.");
@@ -16406,7 +16514,8 @@ var init_delivery_build_schema = __esm(() => {
16406
16514
  routes: _Array_(_Object_({
16407
16515
  method: String2(),
16408
16516
  path: String2(),
16409
- target: String2()
16517
+ target: String2(),
16518
+ idempotency: Optional(Union([Literal("required"), Literal("none")]))
16410
16519
  }, closed4)),
16411
16520
  jobs: _Array_(_Object_({ name: String2(), target: String2() }, closed4))
16412
16521
  }, closed4);
@@ -19536,6 +19645,10 @@ function parseFeatureSpec(input, ctx, seen = new Set) {
19536
19645
  const idempotency = commandModeProp(options, "idempotency");
19537
19646
  const audit = stringLiteralProp(options, "audit");
19538
19647
  const job = stringLiteralProp(options, "job");
19648
+ const invalidateTagsExpr = getProp(options, "invalidateTags");
19649
+ 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;
19650
+ if (invalidateTagsExpr !== undefined && invalidateTags === undefined)
19651
+ return invalid();
19539
19652
  const persistenceExpr = getProp(options, "persistence");
19540
19653
  const persistence = persistenceExpr && ts7.isObjectLiteralExpression(persistenceExpr) ? parseFeaturePersistence(persistenceExpr) : undefined;
19541
19654
  if (persistenceExpr && !persistence)
@@ -19552,6 +19665,7 @@ function parseFeatureSpec(input, ctx, seen = new Set) {
19552
19665
  ...idempotency === undefined ? {} : { idempotency },
19553
19666
  ...audit === undefined ? {} : { audit },
19554
19667
  ...job === undefined ? {} : { job },
19668
+ ...invalidateTags === undefined ? {} : { invalidateTags },
19555
19669
  ...persistence === undefined ? {} : { persistence }
19556
19670
  });
19557
19671
  }
@@ -19654,6 +19768,54 @@ function checkedCommandMode(object, property, ctx, command) {
19654
19768
  }
19655
19769
  return value;
19656
19770
  }
19771
+ function checkedRouteIdempotency(object, ctx, route) {
19772
+ const expression = getProp(object, "idempotency");
19773
+ const value = stringLiteralProp(object, "idempotency");
19774
+ if (expression && value !== "required" && value !== "none") {
19775
+ const file = sourcePath(ctx.rootDir, expression.getSourceFile().fileName);
19776
+ ctx.diagnostics.push({
19777
+ severity: "error",
19778
+ code: "invalid-route-idempotency",
19779
+ errorCode: "SC3028",
19780
+ docsUrl: "https://supacloud.dev/errors/SC3028",
19781
+ message: `${route}.idempotency must be the explicit literal "required" or "none".`,
19782
+ file,
19783
+ line: lineOf(expression),
19784
+ suggestion: `Choose the intended idempotency policy explicitly; use "required" for replayable mutations or "none" for routes without that contract.`
19785
+ });
19786
+ }
19787
+ return value === "required" || value === "none" ? value : undefined;
19788
+ }
19789
+ function checkedRouteTags(expression, ctx, route) {
19790
+ if (expression === undefined)
19791
+ return;
19792
+ if (!ts7.isArrayLiteralExpression(expression) || expression.elements.some((element) => !ts7.isStringLiteral(element) || element.text.trim().length === 0)) {
19793
+ ctx.diagnostics.push({
19794
+ severity: "error",
19795
+ code: "invalid-route-tags",
19796
+ errorCode: "SC3029",
19797
+ docsUrl: "https://supacloud.dev/errors/SC3029",
19798
+ message: `${route}.tags must be a static array of non-empty string literals.`,
19799
+ file: sourcePath(ctx.rootDir, expression.getSourceFile().fileName),
19800
+ line: lineOf(expression),
19801
+ suggestion: 'Declare stable cache tags explicitly, for example tags: ["cases.list"].'
19802
+ });
19803
+ return;
19804
+ }
19805
+ const tags = expression.elements.map((element) => element.text);
19806
+ if (new Set(tags).size !== tags.length) {
19807
+ ctx.diagnostics.push({
19808
+ severity: "error",
19809
+ code: "duplicate-route-tags",
19810
+ errorCode: "SC3030",
19811
+ docsUrl: "https://supacloud.dev/errors/SC3030",
19812
+ message: `${route}.tags must not contain duplicate values.`,
19813
+ file: sourcePath(ctx.rootDir, expression.getSourceFile().fileName),
19814
+ line: lineOf(expression)
19815
+ });
19816
+ }
19817
+ return tags;
19818
+ }
19657
19819
  function parseProvider(el, exportsSet, ctx) {
19658
19820
  const file = sourcePath(ctx.rootDir, el.getSourceFile().fileName);
19659
19821
  const line = lineOf(el);
@@ -20433,7 +20595,11 @@ function parseController(input, ctx) {
20433
20595
  if (optionsObject) {
20434
20596
  const parsePolicy = routePolicyProperty(optionsObject, "parse", ctx);
20435
20597
  const deletePolicy = routePolicyProperty(optionsObject, "allowDeleteBody", ctx);
20436
- if (parsePolicy.unresolved || deletePolicy.unresolved) {
20598
+ const idempotency = checkedRouteIdempotency(optionsObject, ctx, route.handler);
20599
+ if (idempotency !== undefined)
20600
+ route.idempotency = idempotency;
20601
+ const tagsPolicy = routePolicyProperty(optionsObject, "tags", ctx);
20602
+ if (parsePolicy.unresolved || deletePolicy.unresolved || tagsPolicy.unresolved) {
20437
20603
  ctx.diagnostics.push({
20438
20604
  severity: "error",
20439
20605
  code: "unresolved-route-body-policy",
@@ -20441,6 +20607,9 @@ function parseController(input, ctx) {
20441
20607
  message: `Route ${route.handler} body policy must be statically resolvable; dynamic spreads, computed keys and shorthand policies are unsupported.`
20442
20608
  });
20443
20609
  }
20610
+ const tags = checkedRouteTags(tagsPolicy.value, ctx, route.handler);
20611
+ if (tags !== undefined)
20612
+ route.tags = tags;
20444
20613
  const parse = parsePolicy.value;
20445
20614
  if (parse) {
20446
20615
  if (ts7.isStringLiteral(parse) && parse.text === "none")
@@ -22289,6 +22458,7 @@ function validateClientSchemaBoundaries(graph, options) {
22289
22458
 
22290
22459
  // src/effect-governance.ts
22291
22460
  init_validate();
22461
+ init_feature();
22292
22462
  function diagnostic(code, message, file, suggestion) {
22293
22463
  const metadata = COMPILER_DIAGNOSTIC_CODES[code];
22294
22464
  return {
@@ -22326,8 +22496,10 @@ function validateEffectPolicies(graph, options = {}) {
22326
22496
  if (effect.retry === "explicit" && effect.maxAttempts === undefined) {
22327
22497
  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
22498
  }
22329
- if (route.command && effect.retry === "explicit") {
22330
- const command = commands.get(route.command);
22499
+ if (effect.retry === "explicit") {
22500
+ const featureTransition = featureTransitionForRoute(module, controller, route);
22501
+ const commandRef = route.command ?? featureTransition?.command;
22502
+ const command = commandRef === undefined ? undefined : commands.get(commandRef) ?? [...commands.values()].find((candidate) => candidate.name === commandRef);
22331
22503
  if (command && command.idempotency !== "required") {
22332
22504
  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
22505
  }
@@ -23544,6 +23716,7 @@ init_graphql_options();
23544
23716
 
23545
23717
  // src/delivery-plan.ts
23546
23718
  import { createHash as createHash10 } from "node:crypto";
23719
+ init_feature();
23547
23720
  init_validate();
23548
23721
  var compare2 = (a, b) => a < b ? -1 : a > b ? 1 : 0;
23549
23722
  var sorted = (values) => [...new Set(values)].sort(compare2);
@@ -23663,6 +23836,10 @@ function createDeliveryPlan(graph, input, validation) {
23663
23836
  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
23837
  }
23665
23838
  publicRoutes.set(key, target.name);
23839
+ const command = route.command === undefined ? undefined : module.commands.find((candidate) => candidate.className === route.command || candidate.name === route.command);
23840
+ const featureTransition = featureTransitionForRoute(module, controller, route);
23841
+ const effectiveCommand = route.command ?? featureTransition?.command;
23842
+ const idempotency = route.idempotency ?? command?.idempotency ?? featureTransition?.idempotency;
23666
23843
  target.roots.push(module.name);
23667
23844
  target.routes.push({
23668
23845
  module: module.name,
@@ -23670,7 +23847,8 @@ function createDeliveryPlan(graph, input, validation) {
23670
23847
  handler: route.handler,
23671
23848
  method: route.method,
23672
23849
  path,
23673
- ...route.command === undefined ? {} : { command: route.command },
23850
+ ...effectiveCommand === undefined ? {} : { command: effectiveCommand },
23851
+ ...idempotency === undefined ? {} : { idempotency },
23674
23852
  reason: owner === undefined ? "default-http" : "explicit-module"
23675
23853
  });
23676
23854
  }
@@ -24702,7 +24880,12 @@ async function buildDeliveryProject(options, delivery) {
24702
24880
  deploymentReady: false,
24703
24881
  plan: planned.plan,
24704
24882
  objects,
24705
- routes: planned.plan.targets.flatMap((target) => target.routes.map((route) => ({ method: route.method, path: route.path, target: target.name }))),
24883
+ routes: planned.plan.targets.flatMap((target) => target.routes.map((route) => ({
24884
+ method: route.method,
24885
+ path: route.path,
24886
+ target: target.name,
24887
+ ...route.idempotency === undefined ? {} : { idempotency: route.idempotency }
24888
+ }))),
24706
24889
  jobs: planned.plan.targets.flatMap((target) => target.jobs.map((job) => ({ name: job.name, target: target.name })))
24707
24890
  });
24708
24891
  phase = "artifact-integrity";
@@ -24768,10 +24951,10 @@ import { readFileSync as readFileSync4 } from "node:fs";
24768
24951
  import { resolve as resolve18 } from "node:path";
24769
24952
 
24770
24953
  // src/migration-app-version.ts
24771
- var MIGRATION_APP_VERSION = "0.29.0";
24954
+ var MIGRATION_APP_VERSION = "0.30.0";
24772
24955
 
24773
24956
  // src/migration-elysia-version.ts
24774
- var MIGRATION_ELYSIA_VERSION = "0.34.0";
24957
+ var MIGRATION_ELYSIA_VERSION = "0.35.0";
24775
24958
 
24776
24959
  // src/migration-policy.ts
24777
24960
  var cachedCompilerVersion;
@@ -25807,8 +25990,8 @@ function validFeatureInventory(value, jobs, commands, routes, rpc) {
25807
25990
  return false;
25808
25991
  }
25809
25992
  const commandName = transition["command"];
25993
+ const command = commandName === undefined ? undefined : commands.find((candidate) => isRecord2(candidate) && (candidate["name"] === commandName || candidate["className"] === commandName));
25810
25994
  if (commandName !== undefined) {
25811
- const command = commands.find((candidate) => isRecord2(candidate) && (candidate["name"] === commandName || candidate["className"] === commandName));
25812
25995
  if (!isRecord2(command))
25813
25996
  return false;
25814
25997
  if (transition["permission"] !== undefined && command["permission"] !== transition["permission"])
@@ -25839,9 +26022,21 @@ function validFeatureInventory(value, jobs, commands, routes, rpc) {
25839
26022
  if (routeBinding === "controller") {
25840
26023
  if (route["command"] !== undefined)
25841
26024
  return false;
25842
- } else if (route["command"] !== commandName) {
25843
- return false;
26025
+ } else {
26026
+ if (typeof route["command"] !== "string")
26027
+ return false;
26028
+ const routeCommand = commands.find((candidate) => isRecord2(candidate) && (candidate["name"] === route["command"] || candidate["className"] === route["command"]));
26029
+ if (!isRecord2(routeCommand) || !isRecord2(command) || routeCommand["name"] !== command["name"])
26030
+ return false;
25844
26031
  }
26032
+ if (transition["permission"] !== undefined && route["permission"] !== transition["permission"])
26033
+ return false;
26034
+ if (transition["idempotency"] !== undefined && route["idempotency"] !== transition["idempotency"])
26035
+ return false;
26036
+ if (persistence !== undefined && route["idempotency"] !== "required")
26037
+ return false;
26038
+ if (route["idempotency"] === "required" && (route["method"] === "GET" || route["method"] === "HEAD" || route["method"] === "OPTIONS"))
26039
+ return false;
25845
26040
  }
25846
26041
  const job = transition["job"];
25847
26042
  if (job === undefined)