@supacloud/compiler 0.47.3 → 0.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -248,6 +248,10 @@ function findClosestMatch(target, candidates) {
248
248
  var REQUEST_CONTEXT_TOKEN_NAME = "supacloud.request-context", JOB_CONTEXT_TOKEN_NAME = "supacloud.job-context";
249
249
 
250
250
  // src/feature.ts
251
+ function featureTransitionForRoute(module, controller, route) {
252
+ const routePath = `${route.method} ${joinRoutePaths(controller.path, route.path)}`;
253
+ return module.featureSpec?.transitions.find((transition) => transition.route === routePath);
254
+ }
251
255
  function validateFeatureSpec(spec, module) {
252
256
  const diagnostics = [];
253
257
  const error = (code, message) => {
@@ -262,6 +266,9 @@ function validateFeatureSpec(spec, module) {
262
266
  if (!spec.name.trim() || spec.states.length === 0 || spec.states.some((state) => !state.trim()) || new Set(spec.states).size !== spec.states.length) {
263
267
  error("invalid-feature-states", "Feature name and states must be non-empty; states must be unique.");
264
268
  }
269
+ if (spec.tenantField !== undefined && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(spec.tenantField)) {
270
+ error("feature-tenant-boundary-invalid", `Feature ${spec.name} declares an unsafe tenant field.`);
271
+ }
265
272
  const names = new Set;
266
273
  for (const transition of spec.transitions) {
267
274
  if (!transition.name.trim() || names.has(transition.name)) {
@@ -274,8 +281,46 @@ function validateFeatureSpec(spec, module) {
274
281
  if (transition.permission !== undefined && !transition.permission.trim()) {
275
282
  error("feature-governance-drift", `Transition ${transition.name} declares an empty permission.`);
276
283
  }
284
+ if (transition.invalidateTags !== undefined) {
285
+ if (transition.invalidateTags.length === 0 || transition.invalidateTags.some((tag) => !tag.trim()) || new Set(transition.invalidateTags).size !== transition.invalidateTags.length) {
286
+ error("feature-invalidation-tags-invalid", `Transition ${transition.name} must declare unique, non-empty invalidateTags.`);
287
+ }
288
+ }
289
+ if (transition.persistence) {
290
+ const persistence = transition.persistence;
291
+ if (persistence.boundary === "database" && (persistence.optimisticLock !== "required" || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(persistence.versionField ?? "") || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(persistence.tenantField ?? "") || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(persistence.stateField ?? "") || new Set([persistence.tenantField, persistence.versionField, persistence.stateField]).size !== 3 || persistence.receipt !== "required" || persistence.reconciliation !== "none")) {
292
+ error("feature-persistence-invalid", `Database transition ${transition.name} requires distinct safe tenant/version/state fields, optimistic locking, and a receipt, without reconciliation.`);
293
+ }
294
+ if (persistence.boundary === "database" && spec.tenantField !== persistence.tenantField) {
295
+ error("feature-tenant-boundary-drift", `Database transition ${transition.name} tenant field must match Feature ${spec.name} tenantField.`);
296
+ }
297
+ if (persistence.boundary === "external" && (persistence.optimisticLock !== "none" || persistence.versionField !== undefined || persistence.tenantField !== undefined || persistence.stateField !== undefined || persistence.receipt !== "required" || persistence.reconciliation !== "required")) {
298
+ error("feature-persistence-invalid", `External transition ${transition.name} requires a receipt and reconciliation, without database optimistic locking.`);
299
+ }
300
+ if (persistence.boundary === "external" && !transition.job) {
301
+ error("feature-job-unresolved", `External transition ${transition.name} must bind a reconciliation Job.`);
302
+ }
303
+ if (!transition.command) {
304
+ error("feature-command-unresolved", `Persistent transition ${transition.name} must reference a command.`);
305
+ }
306
+ if (!transition.permission?.trim()) {
307
+ error("feature-governance-drift", `Persistent transition ${transition.name} must declare a permission.`);
308
+ }
309
+ }
277
310
  if (!module)
278
311
  continue;
312
+ if (transition.persistence?.boundary === "database") {
313
+ for (const query of module.queries) {
314
+ if (query.tenantScope !== "required") {
315
+ error("feature-query-tenant-boundary", `Query ${query.className} in Feature ${spec.name} must declare tenantScope: "required".`);
316
+ }
317
+ }
318
+ for (const job of module.jobs ?? []) {
319
+ if (job.tenantScope !== "required") {
320
+ error("feature-job-tenant-boundary", `Job ${job.className} in Feature ${spec.name} must declare tenantScope: "required".`);
321
+ }
322
+ }
323
+ }
279
324
  const commands = module.commands.filter((command) => command.className === transition.command || command.name === transition.command);
280
325
  const command = commands[0];
281
326
  if (transition.command && commands.length !== 1) {
@@ -290,23 +335,59 @@ function validateFeatureSpec(spec, module) {
290
335
  error("feature-governance-drift", `Transition ${transition.name} ${key} differs from command ${command.className}.`);
291
336
  }
292
337
  }
338
+ if (transition.persistence) {
339
+ if (transition.persistence.boundary === "database" && command.transaction !== "required") {
340
+ error("feature-persistence-drift", `Database transition ${transition.name} requires command ${command.className} to declare transaction: "required".`);
341
+ }
342
+ if (command.tenantScope !== "required") {
343
+ error("feature-command-tenant-boundary", `Command ${command.className} in Feature ${spec.name} must declare tenantScope: "required".`);
344
+ }
345
+ if (transition.persistence.boundary === "external" && command.transaction !== "none") {
346
+ error("feature-persistence-drift", `External transition ${transition.name} requires command ${command.className} to declare transaction: "none".`);
347
+ }
348
+ if (command.idempotency !== "required" || !command.audit) {
349
+ error("feature-persistence-drift", `Persistent transition ${transition.name} requires command ${command.className} to declare idempotency and audit.`);
350
+ }
351
+ }
352
+ }
353
+ if (transition.job) {
354
+ const jobs = (module.jobs ?? []).filter((job) => job.className === transition.job || job.name === transition.job);
355
+ if (jobs.length !== 1) {
356
+ error("feature-job-unresolved", `Transition ${transition.name} must reference exactly one Job in module ${module.name}.`);
357
+ } else {
358
+ const job = jobs[0];
359
+ if (job.tenantScope !== "required") {
360
+ error("feature-job-tenant-boundary", `Job ${job.className} in Feature ${spec.name} must declare tenantScope: "required".`);
361
+ }
362
+ if (job.idempotency !== "required") {
363
+ error("feature-job-idempotency", `Job ${job.className} in Feature ${spec.name} must declare idempotency: "required".`);
364
+ }
365
+ if (job.mode === undefined) {
366
+ error("feature-job-mode", `Job ${job.className} in Feature ${spec.name} must declare mode: "task" or "workflow".`);
367
+ }
368
+ if (job.input === undefined || job.schemaKinds?.input !== "declared") {
369
+ error("feature-job-contract", `Job ${job.className} in Feature ${spec.name} must declare a non-opaque input schema.`);
370
+ }
371
+ const provider = module.providers.find((candidate) => candidate.token === job.className || candidate.useClass === job.className);
372
+ if (job.tenantScope === "required" && provider && !provider.deps.some((token) => isJobContextToken(token))) {
373
+ error("feature-job-context", `Job ${job.className} in Feature ${spec.name} must inject JOB_CONTEXT for trusted tenant execution.`);
374
+ }
375
+ }
293
376
  }
294
377
  if (transition.route) {
295
378
  const routes = module.controllers.flatMap((controller) => controller.routes.filter((route) => `${route.method} ${joinRoutePaths(controller.path, route.path)}` === transition.route));
296
379
  if (routes.length !== 1) {
297
380
  error("feature-route-unresolved", `Transition ${transition.name} must reference exactly one route '${transition.route}' in module ${module.name}.`);
298
- } else if (command && routes[0]?.command !== command.className) {
381
+ } else if (command && transition.routeBinding !== "controller" && routes[0]?.command !== command.className && routes[0]?.command !== command.name) {
299
382
  error("feature-route-drift", `Route ${transition.route} is not bound to ${command.className}.`);
383
+ } else if (transition.routeBinding === "controller" && routes[0]?.command !== undefined) {
384
+ error("feature-route-drift", `Controller-bound route ${transition.route} must not also declare a framework command binding.`);
300
385
  }
301
386
  }
302
387
  }
303
388
  return diagnostics;
304
389
  }
305
390
  function generateFeatureSource(spec) {
306
- const errors = validateFeatureSpec(spec);
307
- if (errors.length)
308
- throw new Error(errors.map((error) => error.message).join(`
309
- `));
310
391
  if (spec.transitions.some((transition) => !transition.permission)) {
311
392
  throw new Error("Spec-to-Code requires an explicit permission for every transition.");
312
393
  }
@@ -314,13 +395,34 @@ function generateFeatureSource(spec) {
314
395
  ...transition,
315
396
  command: `Transition${index + 1}Command`
316
397
  }));
398
+ const errors = validateFeatureSpec({ ...spec, transitions });
399
+ if (errors.length)
400
+ throw new Error(errors.map((error) => error.message).join(`
401
+ `));
317
402
  if (transitions.some((transition) => transition.route)) {
318
403
  throw new Error("Generate the command slice first; existing HTTP routes require explicit schema and handler implementations.");
319
404
  }
320
- const sourceSpec = { name: spec.name, states: spec.states, transitions: Object.fromEntries(transitions.map((transition) => {
321
- const { name, ...options } = transition;
322
- return [name, options];
323
- })) };
405
+ const sourceSpec = {
406
+ name: spec.name,
407
+ states: spec.states,
408
+ tenantField: spec.tenantField ?? "tenant_id",
409
+ transitions: Object.fromEntries(transitions.map((transition) => {
410
+ const { name, ...options } = transition;
411
+ const persistence = transition.persistence ?? {
412
+ boundary: "database",
413
+ optimisticLock: "required",
414
+ versionField: "version",
415
+ tenantField: "tenant_id",
416
+ stateField: "state",
417
+ receipt: "required",
418
+ reconciliation: "none"
419
+ };
420
+ return [name, {
421
+ ...options,
422
+ persistence
423
+ }];
424
+ }))
425
+ };
324
426
  return [
325
427
  'import { Command, defineFeatureSlice, defineFeatureSpec } from "@supacloud/app";',
326
428
  "",
@@ -331,9 +433,10 @@ function generateFeatureSource(spec) {
331
433
  `@Command(${JSON.stringify({
332
434
  name: `${spec.name}.${transition.name}`,
333
435
  permission: transition.permission,
334
- transaction: transition.transaction ?? "none",
335
- idempotency: transition.idempotency ?? "none",
336
- audit: transition.audit
436
+ transaction: transition.transaction ?? (transition.persistence?.boundary === "external" ? "none" : "required"),
437
+ idempotency: transition.idempotency ?? "required",
438
+ audit: transition.audit ?? `${spec.name}.${transition.name}`,
439
+ tenantScope: "required"
337
440
  })})`,
338
441
  `export class ${transition.command} {`,
339
442
  ` execute(state: FeatureState): never {`,
@@ -520,13 +623,17 @@ function validateGraph(graph, options = false) {
520
623
  handler: route.handler,
521
624
  ...route.redirectTo === undefined ? {} : { redirectTo: route.redirectTo }
522
625
  });
523
- if (route.command && !module.commands.some((command) => command.className === route.command)) {
524
- error("route-command-unresolved", `路由 ${key} 绑定的 command 类 ${route.command} 未在模块 ${module.name} 声明`, controller.file);
626
+ const featureTransition = featureTransitionForRoute(module, controller, route);
627
+ const effectiveCommandRef = route.command ?? featureTransition?.command;
628
+ const effectiveCommand = effectiveCommandRef === undefined ? undefined : module.commands.find((command) => command.className === effectiveCommandRef || command.name === effectiveCommandRef);
629
+ const effectiveIdempotency = route.idempotency ?? featureTransition?.idempotency;
630
+ if (route.command && !effectiveCommand) {
631
+ error("route-command-unresolved", `路由 ${key} 绑定的 command ${route.command} 未在模块 ${module.name} 声明`, controller.file);
525
632
  }
526
- if ((route.method === "GET" || route.method === "HEAD") && route.command) {
527
- const boundCommand = module.commands.find((c) => c.className === route.command);
633
+ if ((route.method === "GET" || route.method === "HEAD") && effectiveCommand) {
634
+ const boundCommand = effectiveCommand;
528
635
  if (boundCommand && boundCommand.transaction === "required") {
529
- error("command-transaction-readonly", `GET route '${route.path}' in '${controller.className}.${route.handler}' binds mutating command '${route.command}' with transaction: 'required'. Mutating transactions are not permitted on read-only HTTP GET requests.`, controller.file, undefined, `Use POST, PUT, or PATCH for mutating command routes, or set transaction: 'none'.`);
636
+ error("command-transaction-readonly", `GET route '${route.path}' in '${controller.className}.${route.handler}' binds mutating command '${boundCommand.name}' with transaction: 'required'. Mutating transactions are not permitted on read-only HTTP GET requests.`, controller.file, undefined, `Use POST, PUT, or PATCH for mutating command routes, or set transaction: 'none'.`);
530
637
  }
531
638
  }
532
639
  if (typeof options === "object" && options.allowRouteCommandBindings === false && route.command) {
@@ -597,6 +704,9 @@ function validateGraph(graph, options = false) {
597
704
  if (route.allowDeleteBody && (route.method !== "DELETE" || !route.body)) {
598
705
  error("invalid-delete-body-opt-in", `Route ${controller.className}.${route.handler} allowDeleteBody requires DELETE and an explicit body schema.`, controller.file);
599
706
  }
707
+ if (effectiveIdempotency === "required" && (route.method === "GET" || route.method === "HEAD" || route.method === "OPTIONS")) {
708
+ error("invalid-route-idempotency", `Route ${controller.className}.${route.handler} cannot require idempotency on HTTP ${route.method}.`, controller.file, undefined, `Use idempotency: "required" only on a mutation route.`);
709
+ }
600
710
  if (route.parse === "none" && (route.body || route.hasBodyBinding || route.contract?.body !== "domain" || !route.contract.evidence?.trim())) {
601
711
  error("invalid-raw-body-contract", `Route ${controller.className}.${route.handler} parse: "none" requires domain body ownership and evidence, without a parsed body schema or @Body binding.`, controller.file);
602
712
  }
@@ -801,13 +911,40 @@ function validateGraph(graph, options = false) {
801
911
  permission: `${module.name}.${command.name}`
802
912
  });
803
913
  }
914
+ if (command.rpc && command.branches) {
915
+ error("command-rpc-branches-conflict", `Command ${command.name} cannot declare both rpc and branches.`, module.file, module.line);
916
+ }
917
+ const branchAdapters = command.branches ?? [];
918
+ for (const branch of branchAdapters) {
919
+ if (typeof options !== "object" || !Object.hasOwn(options.commandCapabilities?.rpc ?? {}, branch.rpc)) {
920
+ error("command-rpc-unavailable", `Command ${command.name} requires configured RPC adapter '${branch.rpc}'.`, module.file, module.line);
921
+ }
922
+ }
804
923
  if (command.rpc && (typeof options !== "object" || !Object.hasOwn(options.commandCapabilities?.rpc ?? {}, command.rpc))) {
805
924
  error("command-rpc-unavailable", `Command ${command.name} requires configured RPC adapter '${command.rpc}'.`, module.file, module.line);
806
925
  }
807
926
  if (typeof options === "object" && options.commandCapabilities) {
808
927
  const hostCaps = options.commandCapabilities;
809
928
  const rpcCaps = command.rpc && Object.hasOwn(hostCaps.rpc ?? {}, command.rpc) ? hostCaps.rpc?.[command.rpc] : undefined;
810
- if (hostCaps.requirePersistentAdapters && (command.rpc !== undefined && (!rpcCaps?.boundary || rpcCaps.audit !== true || rpcCaps.idempotency !== true) || hostCaps.permission !== true || !command.permission || !command.audit || command.idempotency !== "required" || command.rpc !== undefined && rpcCaps?.boundary === "database" && (rpcCaps.transaction !== true || command.transaction !== "required") || command.rpc === undefined && (hostCaps.audit !== true || hostCaps.idempotency !== true || hostCaps.transaction !== true || command.transaction !== "required") || command.rpc !== undefined && rpcCaps?.boundary === "external" && rpcCaps.reconcile !== true)) {
929
+ const branchCaps = branchAdapters.map((branch) => ({
930
+ branch,
931
+ capabilities: Object.hasOwn(hostCaps.rpc ?? {}, branch.rpc) ? hostCaps.rpc?.[branch.rpc] : undefined
932
+ }));
933
+ for (const { branch, capabilities } of branchCaps) {
934
+ if (capabilities?.boundary === "external" && (branch.transaction === "required" || capabilities.transaction === true)) {
935
+ error("command-external-transaction", `Command ${command.name} branch '${branch.when}' calls an external service; use transaction: 'none'.`, module.file, module.line, "Use transaction: 'none' with durable intent and reconciliation.");
936
+ }
937
+ if (branch.audit && capabilities?.audit !== true) {
938
+ error("command-audit-unsupported", `Command ${command.name} branch '${branch.when}' declares audit, but runtime audit persistence is unavailable.`, module.file, module.line);
939
+ }
940
+ if (branch.idempotency === "required" && capabilities?.idempotency !== true) {
941
+ error("command-idempotency-unsupported", `Command ${command.name} branch '${branch.when}' declares idempotency, but receipt persistence is unavailable.`, module.file, module.line);
942
+ }
943
+ if (branch.transaction === "required" && capabilities?.transaction !== true) {
944
+ error("command-transaction-unsupported", `Command ${command.name} branch '${branch.when}' declares transaction, but transaction support is unavailable.`, module.file, module.line);
945
+ }
946
+ }
947
+ if (hostCaps.requirePersistentAdapters && (command.rpc !== undefined && (!rpcCaps?.boundary || rpcCaps.audit !== true || rpcCaps.idempotency !== true) || branchCaps.length > 0 && branchCaps.some(({ branch, capabilities }) => !capabilities?.boundary || capabilities.audit !== true || capabilities.idempotency !== true || capabilities.boundary === "database" && (capabilities.transaction !== true || branch.transaction !== "required")) || hostCaps.permission !== true || !command.permission || branchCaps.length === 0 && (!command.audit || command.idempotency !== "required") || branchCaps.length > 0 && branchCaps.some(({ branch }) => !branch.audit || branch.idempotency !== "required") || branchCaps.length > 0 && branchCaps.some(({ capabilities }) => capabilities?.boundary === "external" && capabilities.reconcile !== true) || command.rpc !== undefined && rpcCaps?.boundary === "database" && (rpcCaps.transaction !== true || command.transaction !== "required") || command.rpc === undefined && branchCaps.length === 0 && (hostCaps.audit !== true || hostCaps.idempotency !== true || hostCaps.transaction !== true || command.transaction !== "required") || command.rpc !== undefined && rpcCaps?.boundary === "external" && rpcCaps.reconcile !== true)) {
811
948
  error("command-persistence-required", `Command ${command.name} requires an explicit persistent adapter, permission, audit and idempotency policy.`, module.file, module.line, "Register a named database/external adapter, enable permission checks and declare permission, audit and required idempotency on the command.");
812
949
  }
813
950
  if (rpcCaps?.boundary === "external" && (command.transaction === "required" || rpcCaps.transaction === true)) {
@@ -1244,6 +1381,7 @@ var init_validate = __esm(() => {
1244
1381
  "command-transaction-unsupported": { code: "SC4026", docsUrl: "https://supacloud.dev/errors/SC4026" },
1245
1382
  "command-reconcile-required": { code: "SC4028", docsUrl: "https://supacloud.dev/errors/SC4028" },
1246
1383
  "invalid-route-contract": { code: "SC3020", docsUrl: "https://supacloud.dev/errors/SC3020" },
1384
+ "invalid-route-idempotency": { code: "SC3028", docsUrl: "https://supacloud.dev/errors/SC3028" },
1247
1385
  "route-contract-required": { code: "SC3021", docsUrl: "https://supacloud.dev/errors/SC3021" },
1248
1386
  "route-contract-unverified": { code: "SC3022", docsUrl: "https://supacloud.dev/errors/SC3022" },
1249
1387
  "route-contract-evidence-required": { code: "SC3023", docsUrl: "https://supacloud.dev/errors/SC3023" },
@@ -1283,6 +1421,18 @@ var init_validate = __esm(() => {
1283
1421
  "invalid-feature-transition": { code: "SC6103", docsUrl: "https://supacloud.dev/errors/SC6103" },
1284
1422
  "feature-command-unresolved": { code: "SC6104", docsUrl: "https://supacloud.dev/errors/SC6104" },
1285
1423
  "feature-governance-drift": { code: "SC6105", docsUrl: "https://supacloud.dev/errors/SC6105" },
1424
+ "feature-persistence-invalid": { code: "SC6110", docsUrl: "https://supacloud.dev/errors/SC6110" },
1425
+ "feature-persistence-drift": { code: "SC6111", docsUrl: "https://supacloud.dev/errors/SC6111" },
1426
+ "feature-tenant-boundary-invalid": { code: "SC6112", docsUrl: "https://supacloud.dev/errors/SC6112" },
1427
+ "feature-tenant-boundary-drift": { code: "SC6113", docsUrl: "https://supacloud.dev/errors/SC6113" },
1428
+ "feature-query-tenant-boundary": { code: "SC6114", docsUrl: "https://supacloud.dev/errors/SC6114" },
1429
+ "feature-job-tenant-boundary": { code: "SC6115", docsUrl: "https://supacloud.dev/errors/SC6115" },
1430
+ "feature-command-tenant-boundary": { code: "SC6116", docsUrl: "https://supacloud.dev/errors/SC6116" },
1431
+ "feature-job-unresolved": { code: "SC6117", docsUrl: "https://supacloud.dev/errors/SC6117" },
1432
+ "feature-job-idempotency": { code: "SC6118", docsUrl: "https://supacloud.dev/errors/SC6118" },
1433
+ "feature-job-mode": { code: "SC6119", docsUrl: "https://supacloud.dev/errors/SC6119" },
1434
+ "feature-job-contract": { code: "SC6120", docsUrl: "https://supacloud.dev/errors/SC6120" },
1435
+ "feature-job-context": { code: "SC6121", docsUrl: "https://supacloud.dev/errors/SC6121" },
1286
1436
  "feature-route-unresolved": { code: "SC6106", docsUrl: "https://supacloud.dev/errors/SC6106" },
1287
1437
  "feature-route-drift": { code: "SC6107", docsUrl: "https://supacloud.dev/errors/SC6107" },
1288
1438
  "invalid-feature-spec": { code: "SC6108", docsUrl: "https://supacloud.dev/errors/SC6108" }
@@ -1293,6 +1443,8 @@ var init_validate = __esm(() => {
1293
1443
  function buildContractManifest(graph, artifacts) {
1294
1444
  const commands = [];
1295
1445
  const queries = [];
1446
+ const jobs = [];
1447
+ const features = [];
1296
1448
  const routes = [];
1297
1449
  const permissions = new Set;
1298
1450
  const rpc = [];
@@ -1304,27 +1456,107 @@ function buildContractManifest(graph, artifacts) {
1304
1456
  permissions.add(command.permission);
1305
1457
  if (command.rpc)
1306
1458
  rpc.push({ command: command.name, adapter: command.rpc });
1459
+ for (const branch of command.branches ?? []) {
1460
+ rpc.push({ command: `${command.name} [${branch.when}]`, adapter: branch.rpc });
1461
+ }
1307
1462
  if (command.audit)
1308
1463
  events.push({ name: command.audit, source: "command.audit", command: command.name });
1464
+ for (const branch of command.branches ?? []) {
1465
+ if (branch.audit)
1466
+ events.push({ name: branch.audit, source: "command.branch.audit", command: command.name });
1467
+ }
1309
1468
  commands.push({
1310
1469
  name: command.name,
1311
1470
  className: command.className,
1312
1471
  module: module.name,
1313
1472
  ...command.permission === undefined ? {} : { permission: command.permission },
1314
1473
  ...command.rpc === undefined ? {} : { rpc: command.rpc },
1474
+ ...command.branches === undefined ? {} : {
1475
+ branches: command.branches.map((branch) => ({
1476
+ when: branch.when,
1477
+ adapter: branch.rpc,
1478
+ transaction: branch.transaction,
1479
+ idempotency: branch.idempotency,
1480
+ ...branch.audit === undefined ? {} : { auditEvent: branch.audit },
1481
+ ...branch.boundary === undefined ? {} : { boundary: branch.boundary }
1482
+ }))
1483
+ },
1315
1484
  transaction: command.transaction,
1316
1485
  idempotency: command.idempotency,
1486
+ ...command.tenantScope === undefined ? {} : { tenantScope: command.tenantScope },
1317
1487
  ...command.audit === undefined ? {} : { auditEvent: command.audit }
1318
1488
  });
1319
1489
  }
1320
1490
  for (const query of module.queries) {
1321
- queries.push({ name: query.name, className: query.className, module: module.name });
1491
+ queries.push({
1492
+ name: query.name,
1493
+ className: query.className,
1494
+ module: module.name,
1495
+ ...query.tenantScope === undefined ? {} : { tenantScope: query.tenantScope }
1496
+ });
1497
+ }
1498
+ for (const job of module.jobs ?? []) {
1499
+ jobs.push({
1500
+ name: job.name,
1501
+ className: job.className,
1502
+ module: module.name,
1503
+ ...job.input === undefined ? {} : { input: job.input },
1504
+ ...job.schemaKinds?.input === undefined ? {} : { inputKind: job.schemaKinds.input },
1505
+ ...job.output === undefined ? {} : { output: job.output },
1506
+ ...job.schemaKinds?.output === undefined ? {} : { outputKind: job.schemaKinds.output },
1507
+ ...job.mode === undefined ? {} : { mode: job.mode },
1508
+ ...job.idempotency === undefined ? {} : { idempotency: job.idempotency },
1509
+ ...job.tenantScope === undefined ? {} : { tenantScope: job.tenantScope }
1510
+ });
1511
+ }
1512
+ if (module.featureSpec) {
1513
+ features.push({
1514
+ name: module.featureSpec.name,
1515
+ states: [...module.featureSpec.states],
1516
+ ...module.featureSpec.tenantField === undefined ? {} : { tenantField: module.featureSpec.tenantField },
1517
+ transitions: module.featureSpec.transitions.map((transition) => {
1518
+ const job = transition.job === undefined ? undefined : (module.jobs ?? []).find((candidate) => candidate.className === transition.job || candidate.name === transition.job);
1519
+ return {
1520
+ name: transition.name,
1521
+ from: transition.from,
1522
+ to: transition.to,
1523
+ ...transition.permission === undefined ? {} : { permission: transition.permission },
1524
+ ...transition.command === undefined ? {} : { command: transition.command },
1525
+ ...transition.route === undefined ? {} : { route: transition.route },
1526
+ ...transition.routeBinding === undefined ? {} : { routeBinding: transition.routeBinding },
1527
+ ...transition.transaction === undefined ? {} : { transaction: transition.transaction },
1528
+ ...transition.idempotency === undefined ? {} : { idempotency: transition.idempotency },
1529
+ ...transition.audit === undefined ? {} : { audit: transition.audit },
1530
+ ...transition.job === undefined ? {} : { job: transition.job },
1531
+ ...transition.invalidateTags === undefined ? {} : { invalidateTags: [...transition.invalidateTags] },
1532
+ ...job === undefined ? {} : {
1533
+ jobDetails: {
1534
+ name: job.name,
1535
+ className: job.className,
1536
+ ...job.input === undefined ? {} : { input: job.input },
1537
+ ...job.schemaKinds?.input === undefined ? {} : { inputKind: job.schemaKinds.input },
1538
+ ...job.output === undefined ? {} : { output: job.output },
1539
+ ...job.schemaKinds?.output === undefined ? {} : { outputKind: job.schemaKinds.output },
1540
+ ...job.mode === undefined ? {} : { mode: job.mode },
1541
+ ...job.idempotency === undefined ? {} : { idempotency: job.idempotency },
1542
+ ...job.tenantScope === undefined ? {} : { tenantScope: job.tenantScope }
1543
+ }
1544
+ },
1545
+ ...transition.persistence === undefined ? {} : { persistence: transition.persistence }
1546
+ };
1547
+ })
1548
+ });
1322
1549
  }
1323
1550
  for (const controller of module.controllers) {
1324
1551
  for (const route of controller.routes) {
1325
- const command = route.command ? module.commands.find((candidate) => candidate.className === route.command) : undefined;
1326
- if (command?.permission)
1327
- permissions.add(command.permission);
1552
+ const command = route.command ? module.commands.find((candidate) => candidate.className === route.command || candidate.name === route.command) : undefined;
1553
+ const featureTransition = featureTransitionForRoute(module, controller, route);
1554
+ const featureCommand = featureTransition?.command === undefined ? undefined : module.commands.find((candidate) => candidate.className === featureTransition.command || candidate.name === featureTransition.command);
1555
+ const governedCommand = command ?? featureCommand;
1556
+ const effectiveCommand = route.command ?? featureTransition?.command;
1557
+ const idempotency = route.idempotency ?? command?.idempotency ?? featureTransition?.idempotency;
1558
+ if (governedCommand?.permission)
1559
+ permissions.add(governedCommand.permission);
1328
1560
  if (route.contract?.evidence)
1329
1561
  fixtures.add(route.contract.evidence);
1330
1562
  routes.push({
@@ -1333,8 +1565,10 @@ function buildContractManifest(graph, artifacts) {
1333
1565
  module: module.name,
1334
1566
  controller: controller.className,
1335
1567
  handler: route.handler,
1336
- ...route.command === undefined ? {} : { command: route.command },
1337
- ...command?.permission === undefined ? {} : { permission: command.permission },
1568
+ ...effectiveCommand === undefined ? {} : { command: effectiveCommand },
1569
+ ...governedCommand?.permission === undefined ? {} : { permission: governedCommand.permission },
1570
+ ...idempotency === undefined ? {} : { idempotency },
1571
+ ...route.tags === undefined ? {} : { tags: [...route.tags] },
1338
1572
  requestSchemas: Object.fromEntries(["body", "params", "query", "headers", "cookie"].flatMap((key) => route[key] === undefined ? [] : [[key, route[key]]])),
1339
1573
  ...route.response === undefined ? {} : { responseSchema: route.response },
1340
1574
  ...route.responses === undefined ? {} : {
@@ -1349,6 +1583,8 @@ function buildContractManifest(graph, artifacts) {
1349
1583
  }
1350
1584
  commands.sort((left, right) => left.name.localeCompare(right.name));
1351
1585
  queries.sort((left, right) => left.name.localeCompare(right.name));
1586
+ jobs.sort((left, right) => left.name.localeCompare(right.name));
1587
+ features.sort((left, right) => left.name.localeCompare(right.name));
1352
1588
  routes.sort((left, right) => `${left.method} ${left.path}`.localeCompare(`${right.method} ${right.path}`));
1353
1589
  rpc.sort((left, right) => left.command.localeCompare(right.command));
1354
1590
  events.sort((left, right) => left.name.localeCompare(right.name));
@@ -1356,6 +1592,8 @@ function buildContractManifest(graph, artifacts) {
1356
1592
  version: 1,
1357
1593
  commands,
1358
1594
  queries,
1595
+ jobs,
1596
+ features,
1359
1597
  routes,
1360
1598
  permissions: [...permissions].sort(),
1361
1599
  rpc,
@@ -1369,7 +1607,9 @@ function buildContractManifest(graph, artifacts) {
1369
1607
  }
1370
1608
  };
1371
1609
  }
1372
- var init_contract_manifest = () => {};
1610
+ var init_contract_manifest = __esm(() => {
1611
+ init_feature();
1612
+ });
1373
1613
 
1374
1614
  // src/generate.ts
1375
1615
  import { createHash as createHash4 } from "node:crypto";
@@ -1503,6 +1743,7 @@ function renderApplication(graph, options) {
1503
1743
  name: command.name,
1504
1744
  permission: command.permission ?? null,
1505
1745
  rpc: command.rpc ?? null,
1746
+ branches: command.branches ?? [],
1506
1747
  transaction: command.transaction ?? null,
1507
1748
  audit: command.audit ?? null,
1508
1749
  idempotency: command.idempotency ?? null
@@ -1780,6 +2021,9 @@ class ModuleGenerator {
1780
2021
  return "[]";
1781
2022
  const items = this.module.controllers.map((controller) => {
1782
2023
  const routes = controller.routes.map((route) => {
2024
+ const command = route.command === undefined ? undefined : this.module.commands.find((candidate) => candidate.className === route.command || candidate.name === route.command);
2025
+ const featureTransition = featureTransitionForRoute(this.module, controller, route);
2026
+ const idempotency = route.idempotency ?? command?.idempotency ?? featureTransition?.idempotency;
1783
2027
  const responseEntries = normalizedResponseEntries(route.responses);
1784
2028
  const hasResponseMap = responseEntries.length > 0;
1785
2029
  const fields = [
@@ -1806,6 +2050,9 @@ class ModuleGenerator {
1806
2050
  if (route.effect !== undefined) {
1807
2051
  fields.push(`effect: ${JSON.stringify(route.effect)}`);
1808
2052
  }
2053
+ if (idempotency !== undefined) {
2054
+ fields.push(`idempotency: ${JSON.stringify(idempotency)}`);
2055
+ }
1809
2056
  if (route.command)
1810
2057
  fields.push(`command: ${JSON.stringify(route.command)}`);
1811
2058
  if (route.guards && route.guards.length > 0) {
@@ -1929,7 +2176,9 @@ ${indent(item, 2)}`).join(",")}
1929
2176
  `transaction: ${JSON.stringify(command.transaction)}`,
1930
2177
  ...command.audit ? [`audit: ${JSON.stringify(command.audit)}`] : [],
1931
2178
  `idempotency: ${JSON.stringify(command.idempotency)}`,
2179
+ ...command.tenantScope ? [`tenantScope: ${JSON.stringify(command.tenantScope)}`] : [],
1932
2180
  ...command.rpc ? [`rpc: ${JSON.stringify(command.rpc)}`] : [],
2181
+ ...command.branches && command.branches.length > 0 ? [`branches: ${JSON.stringify(command.branches)}`] : [],
1933
2182
  ...command.standalone ? ["standalone: true"] : [],
1934
2183
  ...command.aspects && command.aspects.length > 0 ? [`aspects: ${this.renderAspects(command.aspects)}`, `aspectPipeline: ${this.renderAspectPipeline(command.aspects)}`] : []
1935
2184
  ];
@@ -1962,6 +2211,8 @@ ${indent(item, 2)}`).join(",")}
1962
2211
  fields.push(`maxAttempts: ${job.maxAttempts}`);
1963
2212
  if (job.idempotency !== undefined)
1964
2213
  fields.push(`idempotency: ${JSON.stringify(job.idempotency)}`);
2214
+ if (job.tenantScope !== undefined)
2215
+ fields.push(`tenantScope: ${JSON.stringify(job.tenantScope)}`);
1965
2216
  if (job.aspects && job.aspects.length > 0) {
1966
2217
  fields.push(`aspects: ${this.renderAspects(job.aspects)}`);
1967
2218
  fields.push(`aspectPipeline: ${this.renderAspectPipeline(job.aspects)}`);
@@ -2284,7 +2535,18 @@ function renderClient(graph, options) {
2284
2535
  const usedTypeNames = new Set;
2285
2536
  const allRoutes = [];
2286
2537
  const allProcedures = [];
2287
- const commandByClassName = new Map(graph.modules.flatMap((module) => module.commands.map((command) => [command.className, command])));
2538
+ const commandByClassName = new Map(graph.modules.flatMap((module) => module.commands.flatMap((command) => [
2539
+ [command.className, command],
2540
+ [command.name, command]
2541
+ ])));
2542
+ const featureInvalidationTags = new Map;
2543
+ for (const module of graph.modules) {
2544
+ for (const transition of module.featureSpec?.transitions ?? []) {
2545
+ if (transition.route && transition.invalidateTags) {
2546
+ featureInvalidationTags.set(`${module.name}:${transition.route}`, [...transition.invalidateTags]);
2547
+ }
2548
+ }
2549
+ }
2288
2550
  let usesStatic = false;
2289
2551
  let usesValue = false;
2290
2552
  const uniqueTypeName = (candidate) => {
@@ -2362,6 +2624,13 @@ function renderClient(graph, options) {
2362
2624
  const requestType = `ClientRequestOptions<${paramsType}, ${queryType}, ${bodyType}, ${headersType}, ${cookieType}>${requiredRequestFields.length > 0 ? ` & { ${requiredRequestFields.join("; ")} }` : ""}`;
2363
2625
  routeTypes.push(`export type ${requestTypeName} = ${requestType};`);
2364
2626
  routeTypes.push(`export type ${responseTypeName} = ${responseType === "never" ? "unknown" : responseType};`);
2627
+ const command = route.command === undefined ? undefined : commandByClassName.get(route.command);
2628
+ const featureTransition = featureTransitionForRoute(module, controller, route);
2629
+ const featureCommand = featureTransition?.command === undefined ? undefined : commandByClassName.get(featureTransition.command);
2630
+ const governedCommand = command ?? featureCommand;
2631
+ const idempotencyMode = route.idempotency ?? command?.idempotency ?? featureTransition?.idempotency ?? "none";
2632
+ const transitionRoute = `${route.method} ${fullPath}`;
2633
+ const invalidationTags = featureInvalidationTags.get(`${module.name}:${transitionRoute}`);
2365
2634
  const routeFields = [
2366
2635
  `method: ${JSON.stringify(route.method)}`,
2367
2636
  `path: ${JSON.stringify(fullPath)}`,
@@ -2369,7 +2638,7 @@ function renderClient(graph, options) {
2369
2638
  `handler: ${JSON.stringify(route.handler)}`
2370
2639
  ];
2371
2640
  for (const [key, value] of [
2372
- ["command", route.command],
2641
+ ["command", route.command ?? featureTransition?.command],
2373
2642
  ["guards", route.guards],
2374
2643
  ["canMatch", route.canMatch],
2375
2644
  ["canDeactivate", route.canDeactivate],
@@ -2383,7 +2652,10 @@ function renderClient(graph, options) {
2383
2652
  ["title", route.title],
2384
2653
  ["data", route.data],
2385
2654
  ["parse", route.parse],
2386
- ["allowDeleteBody", route.allowDeleteBody]
2655
+ ["allowDeleteBody", route.allowDeleteBody],
2656
+ ["idempotency", idempotencyMode === "none" ? undefined : idempotencyMode],
2657
+ ["tags", route.tags],
2658
+ ["invalidateTags", invalidationTags]
2387
2659
  ]) {
2388
2660
  if (value !== undefined)
2389
2661
  routeFields.push(`${key}: ${JSON.stringify(value)}`);
@@ -2416,9 +2688,7 @@ function renderClient(graph, options) {
2416
2688
  const responseSchemaEntries = hasResponseMap ? Object.entries(responseSchemas).map(([status, ref]) => [status, ref.local]) : responseSchema ? [["200", responseSchema.local]] : [];
2417
2689
  const responseSchemaArgument = responseSchemaEntries.length > 0 ? `{ ${responseSchemaEntries.map(([status, local]) => `${JSON.stringify(status)}: ${local}`).join(", ")} }` : "undefined";
2418
2690
  const responseKindArgument = responseKind === undefined ? "undefined" : JSON.stringify(responseKind);
2419
- const command = route.command === undefined ? undefined : commandByClassName.get(route.command);
2420
2691
  const procedureKind = route.method === "GET" || route.method === "HEAD" ? "query" : "mutation";
2421
- const idempotencyMode = command?.idempotency ?? "none";
2422
2692
  const procedureMetadata = {
2423
2693
  key: `${controller.className}.${route.handler}`,
2424
2694
  method: route.method,
@@ -2426,7 +2696,9 @@ function renderClient(graph, options) {
2426
2696
  controller: controller.className,
2427
2697
  handler: route.handler,
2428
2698
  kind: procedureKind,
2429
- idempotency: idempotencyMode
2699
+ idempotency: idempotencyMode,
2700
+ ...route.tags === undefined ? {} : { tags: route.tags },
2701
+ ...invalidationTags === undefined ? {} : { invalidateTags: invalidationTags }
2430
2702
  };
2431
2703
  allProcedures.push({
2432
2704
  method: route.method,
@@ -2434,9 +2706,11 @@ function renderClient(graph, options) {
2434
2706
  controller: controller.className,
2435
2707
  handler: route.handler,
2436
2708
  kind: procedureKind,
2437
- ...command?.name === undefined ? {} : { command: command.name },
2438
- ...command?.permission === undefined ? {} : { permission: command.permission },
2439
- idempotency: idempotencyMode
2709
+ ...governedCommand?.name === undefined ? {} : { command: governedCommand.name },
2710
+ ...governedCommand?.permission === undefined ? {} : { permission: governedCommand.permission },
2711
+ idempotency: idempotencyMode,
2712
+ ...route.tags === undefined ? {} : { tags: [...route.tags] },
2713
+ ...invalidationTags === undefined ? {} : { invalidateTags: [...invalidationTags] }
2440
2714
  });
2441
2715
  routeMethods.push(`
2442
2716
  ${route.handler}: makeRoute<${requestTypeName}, ${responseType === "never" ? "never" : responseTypeName}, ${JSON.stringify(procedureKind)}, ${JSON.stringify(idempotencyMode)}>(${JSON.stringify(route.method)}, ${JSON.stringify(fullPath)}, ${responseSchemaArgument}, ${responseKindArgument}, ${route.parse === "none"}, ${JSON.stringify(procedureKind)}, ${JSON.stringify(idempotencyMode)}, ${JSON.stringify(procedureMetadata)}),`);
@@ -2490,6 +2764,8 @@ ${procedureMethods.join(`
2490
2764
  " handler: string;",
2491
2765
  " kind: Kind;",
2492
2766
  " idempotency: Idempotency;",
2767
+ " tags?: readonly string[];",
2768
+ " invalidateTags?: readonly string[];",
2493
2769
  "}",
2494
2770
  "",
2495
2771
  "export interface ProcedureExecutionOptions {",
@@ -2595,6 +2871,8 @@ ${procedureMethods.join(`
2595
2871
  " command?: string;",
2596
2872
  " permission?: string;",
2597
2873
  " idempotency: IdempotencyMode;",
2874
+ " tags?: readonly string[];",
2875
+ " invalidateTags?: readonly string[];",
2598
2876
  "}",
2599
2877
  "",
2600
2878
  ...routeTypes,
@@ -3094,7 +3372,10 @@ function renderOpenApi(graph, options) {
3094
3372
  }
3095
3373
  return schemaComponents.get(key);
3096
3374
  };
3097
- const commandByClass = new Map(graph.modules.flatMap((module) => module.commands.map((command) => [command.className, command])));
3375
+ const commandByClass = new Map(graph.modules.flatMap((module) => module.commands.flatMap((command) => [
3376
+ [command.className, command],
3377
+ [command.name, command]
3378
+ ])));
3098
3379
  for (const module of graph.modules) {
3099
3380
  for (const controller of module.controllers) {
3100
3381
  for (const route of controller.routes) {
@@ -3116,7 +3397,12 @@ function renderOpenApi(graph, options) {
3116
3397
  const operationBase = pascalName(`${module.name}_${controller.className}_${route.handler}`);
3117
3398
  const operationId = uniqueName(operationBase, operationIds);
3118
3399
  const command = route.command ? commandByClass.get(route.command) : undefined;
3119
- const requiresAuth = (route.guards?.length ?? 0) > 0 || command !== undefined;
3400
+ const featureTransition = featureTransitionForRoute(module, controller, route);
3401
+ const featureCommand = featureTransition?.command === undefined ? undefined : commandByClass.get(featureTransition.command);
3402
+ const governedCommand = command ?? featureCommand;
3403
+ const idempotencyMode = route.idempotency ?? command?.idempotency ?? featureTransition?.idempotency ?? "none";
3404
+ const permission = governedCommand?.permission ?? featureTransition?.permission;
3405
+ const requiresAuth = (route.guards?.length ?? 0) > 0 || permission !== undefined || governedCommand !== undefined;
3120
3406
  const responseKind = route.contract?.response;
3121
3407
  const responseContentType = responseKind === "binary" || responseKind === "stream" ? "application/octet-stream" : "application/json";
3122
3408
  const definition = {
@@ -3141,11 +3427,12 @@ function renderOpenApi(graph, options) {
3141
3427
  responseKind: responseKind ?? (route.nativeResponse ? "native-response" : undefined),
3142
3428
  ...route.parse === undefined ? {} : { parse: route.parse },
3143
3429
  ...route.allowDeleteBody === undefined ? {} : { allowDeleteBody: route.allowDeleteBody },
3430
+ ...idempotencyMode === "none" ? {} : { idempotency: idempotencyMode },
3144
3431
  module: module.name,
3145
3432
  controller: controller.className,
3146
3433
  handler: route.handler,
3147
- ...route.command === undefined ? {} : { command: route.command },
3148
- ...command?.permission === undefined ? {} : { permission: command.permission },
3434
+ ...route.command === undefined && featureTransition?.command === undefined ? {} : { command: route.command ?? featureTransition?.command },
3435
+ ...permission === undefined ? {} : { permission },
3149
3436
  ...route.guards && route.guards.length > 0 ? { guards: route.guards } : {},
3150
3437
  ...route.canMatch && route.canMatch.length > 0 ? { canMatch: route.canMatch } : {},
3151
3438
  ...route.canDeactivate && route.canDeactivate.length > 0 ? { canDeactivate: route.canDeactivate } : {},
@@ -3220,6 +3507,7 @@ function renderOpenApi(graph, options) {
3220
3507
  " handler: string;",
3221
3508
  " command?: string;",
3222
3509
  " permission?: string;",
3510
+ ' idempotency?: "required" | "none";',
3223
3511
  " guards?: readonly string[];",
3224
3512
  " canMatch?: readonly string[];",
3225
3513
  " canDeactivate?: readonly string[];",
@@ -3472,6 +3760,18 @@ function renderOpenApi(graph, options) {
3472
3760
  " for (const name of headersSchema ? schemaPropertyNames(headersSchema) : []) parameters.push({",
3473
3761
  ' name, in: "header", required: schemaRequired(headersSchema, name), schema: schemaProperty(headersSchema, name),',
3474
3762
  " });",
3763
+ ' if (definition.idempotency === "required") {',
3764
+ ' const keySchema = { type: "string", minLength: 1, maxLength: 512, pattern: "^[A-Za-z0-9._:-]{1,512}$" };',
3765
+ ' const declaredHeaders = parameters.filter((parameter) => parameter["in"] === "header" && typeof parameter["name"] === "string" && parameter["name"].toLowerCase() === "idempotency-key");',
3766
+ " const header = declaredHeaders[0];",
3767
+ " if (header) {",
3768
+ ' header["required"] = true;',
3769
+ ' header["schema"] = { allOf: [...declaredHeaders.map((parameter) => parameter["schema"]), keySchema] };',
3770
+ " for (const duplicate of declaredHeaders.slice(1)) parameters.splice(parameters.indexOf(duplicate), 1);",
3771
+ " } else {",
3772
+ ' parameters.push({ name: "Idempotency-Key", in: "header", required: true, schema: keySchema });',
3773
+ " }",
3774
+ " }",
3475
3775
  " const cookieSchema = definition.cookieSchema ? OPENAPI_SCHEMA_REGISTRY[definition.cookieSchema as keyof typeof OPENAPI_SCHEMA_REGISTRY] : undefined;",
3476
3776
  " for (const name of cookieSchema ? schemaPropertyNames(cookieSchema) : []) parameters.push({",
3477
3777
  ' name, in: "cookie", required: schemaRequired(cookieSchema, name), schema: schemaProperty(cookieSchema, name),',
@@ -3494,6 +3794,7 @@ function renderOpenApi(graph, options) {
3494
3794
  ' responses["500"] = { description: "Internal server error", content: { "application/json": { schema: { $ref: "#/components/schemas/SupaCloudError" } } } };',
3495
3795
  ' responses["200"] = { description: "Successful response" };',
3496
3796
  " }",
3797
+ ' if (definition.idempotency === "required" && (!definition.responseSchemas || !responseMapCoversStatus(definition.responseSchemas, 400))) responses["400"] = { description: "Idempotency-Key is missing or invalid", content: { "application/json": { schema: { $ref: "#/components/schemas/SupaCloudError" } } } };',
3497
3798
  " const operation: Record<string, unknown> = {",
3498
3799
  " operationId: definition.operationId, tags: [...definition.tags], summary: definition.summary, responses,",
3499
3800
  " ...(parameters.length === 0 ? {} : { parameters }),",
@@ -3504,6 +3805,7 @@ function renderOpenApi(graph, options) {
3504
3805
  " module: definition.module, controller: definition.controller, handler: definition.handler,",
3505
3806
  " ...(definition.command ? { command: definition.command } : {}),",
3506
3807
  " ...(definition.permission ? { permission: definition.permission } : {}),",
3808
+ " ...(definition.idempotency ? { idempotency: definition.idempotency } : {}),",
3507
3809
  " ...(definition.guards ? { guards: [...definition.guards] } : {}),",
3508
3810
  " ...(definition.canMatch ? { canMatch: [...definition.canMatch] } : {}),",
3509
3811
  " ...(definition.canDeactivate ? { canDeactivate: [...definition.canDeactivate] } : {}),",
@@ -3554,11 +3856,13 @@ function renderPermissions(graph) {
3554
3856
  }
3555
3857
  for (const controller of module.controllers) {
3556
3858
  for (const route of controller.routes) {
3557
- let perm;
3558
- if (route.command) {
3559
- const cmd = module.commands.find((c) => c.className === route.command);
3560
- perm = cmd?.permission;
3561
- }
3859
+ const featureTransition = featureTransitionForRoute(module, controller, route);
3860
+ const command = route.command === undefined ? undefined : module.commands.find((candidate) => candidate.className === route.command || candidate.name === route.command);
3861
+ const featureCommand = featureTransition?.command === undefined ? undefined : module.commands.find((candidate) => candidate.className === featureTransition.command || candidate.name === featureTransition.command);
3862
+ const governedCommand = command ?? featureCommand;
3863
+ const effectiveCommand = route.command ?? featureTransition?.command;
3864
+ const perm = governedCommand?.permission ?? featureTransition?.permission;
3865
+ const idempotency = route.idempotency ?? command?.idempotency ?? featureTransition?.idempotency;
3562
3866
  if (perm)
3563
3867
  permissions.add(perm);
3564
3868
  bindings.push({
@@ -3566,8 +3870,9 @@ function renderPermissions(graph) {
3566
3870
  path: joinRoutePaths(controller.path, route.path),
3567
3871
  controller: controller.className,
3568
3872
  handler: route.handler,
3569
- ...route.command === undefined ? {} : { command: route.command },
3570
- ...perm === undefined ? {} : { permission: perm }
3873
+ ...effectiveCommand === undefined ? {} : { command: effectiveCommand },
3874
+ ...perm === undefined ? {} : { permission: perm },
3875
+ ...idempotency === undefined ? {} : { idempotency }
3571
3876
  });
3572
3877
  }
3573
3878
  }
@@ -3593,6 +3898,7 @@ function renderPermissions(graph) {
3593
3898
  " handler: string;",
3594
3899
  " command?: string;",
3595
3900
  " permission?: string;",
3901
+ ' idempotency?: "required" | "none";',
3596
3902
  "}",
3597
3903
  "",
3598
3904
  "export const RoutePermissions: RoutePermissionBinding[] = " + JSON.stringify(bindings, null, 2) + ";",
@@ -3636,6 +3942,8 @@ var HEADER = "// GENERATED BY @supacloud/compiler — do not edit", INTERFACES =
3636
3942
  /** True when the handler returns a native Response rather than a framework value. */
3637
3943
  nativeResponse?: boolean;
3638
3944
  command?: string;
3945
+ /** Transport-level idempotency contract, independent of route command binding. */
3946
+ idempotency?: "required" | "none";
3639
3947
  guards?: string[];
3640
3948
  canMatch?: string[];
3641
3949
  canDeactivate?: string[];
@@ -3665,17 +3973,28 @@ var HEADER = "// GENERATED BY @supacloud/compiler — do not edit", INTERFACES =
3665
3973
 
3666
3974
  export interface CompiledCommand {
3667
3975
  rpc?: string;
3976
+ branches?: readonly CompiledCommandBranch[];
3668
3977
  className: string;
3669
3978
  name: string;
3670
3979
  permission: string;
3671
3980
  transaction: "required" | "none";
3672
3981
  audit?: string;
3673
3982
  idempotency: "required" | "none";
3983
+ tenantScope?: "required" | "none";
3674
3984
  standalone?: boolean;
3675
3985
  aspects?: CompiledAspect[];
3676
3986
  aspectPipeline?: CompiledAspectPipeline;
3677
3987
  }
3678
3988
 
3989
+ export interface CompiledCommandBranch {
3990
+ when: string;
3991
+ rpc: string;
3992
+ transaction?: "required" | "none" | string;
3993
+ audit?: string;
3994
+ idempotency?: "required" | "none" | string;
3995
+ boundary?: "database" | "external";
3996
+ }
3997
+
3679
3998
  export interface CompiledJob {
3680
3999
  className: string;
3681
4000
  name: string;
@@ -3687,6 +4006,7 @@ export interface CompiledJob {
3687
4006
  timeoutSec?: number;
3688
4007
  maxAttempts?: number;
3689
4008
  idempotency?: "required" | "none";
4009
+ tenantScope?: "required" | "none";
3690
4010
  aspects?: CompiledAspect[];
3691
4011
  aspectPipeline?: CompiledAspectPipeline;
3692
4012
  }
@@ -3861,6 +4181,7 @@ async function destroyServiceInstances(
3861
4181
  `;
3862
4182
  var init_generate = __esm(() => {
3863
4183
  init_contract_manifest();
4184
+ init_feature();
3864
4185
  });
3865
4186
 
3866
4187
  // src/route-contracts.ts
@@ -14583,7 +14904,7 @@ function parseDeliveryPlanResult(value) {
14583
14904
  throw new Error("Invalid delivery plan result.");
14584
14905
  return value;
14585
14906
  }
14586
- var objectOptions, reference, references, kind, isolation, DeliveryFilePathSchema, functionTarget, DeliveryOptionsSchema, DeliveryDiagnosticSchema, routeSchema, jobSchema, DeliveryTargetSchema, DeliveryPlanSchema, DeliveryPlanResultSchema, DeliveryConfigurationError;
14907
+ var objectOptions, reference, references, kind, isolation, mode, DeliveryFilePathSchema, functionTarget, DeliveryOptionsSchema, DeliveryDiagnosticSchema, routeSchema, jobSchema, DeliveryTargetSchema, DeliveryPlanSchema, DeliveryPlanResultSchema, DeliveryConfigurationError;
14587
14908
  var init_delivery_schema = __esm(() => {
14588
14909
  init_build();
14589
14910
  init_value2();
@@ -14593,6 +14914,7 @@ var init_delivery_schema = __esm(() => {
14593
14914
  references = _Array_(reference, { uniqueItems: true });
14594
14915
  kind = Union([Literal("api"), Literal("jobs"), Literal("webhook"), Literal("function")]);
14595
14916
  isolation = Union([Literal("shared"), Literal("process")]);
14917
+ mode = Union([Literal("required"), Literal("none")]);
14596
14918
  DeliveryFilePathSchema = String2({
14597
14919
  maxLength: 512,
14598
14920
  pattern: "^(?:[A-Za-z0-9_-][A-Za-z0-9_.-]*/)*[A-Za-z0-9_-][A-Za-z0-9_.-]*$"
@@ -14666,6 +14988,7 @@ var init_delivery_schema = __esm(() => {
14666
14988
  ]),
14667
14989
  path: String2(),
14668
14990
  command: Optional(String2()),
14991
+ idempotency: Optional(mode),
14669
14992
  reason: Union([Literal("default-http"), Literal("explicit-module")])
14670
14993
  }, objectOptions);
14671
14994
  jobSchema = _Object_({
@@ -14924,7 +15247,12 @@ function parseDeliveryBuildManifest(value) {
14924
15247
  throw new Error("Invalid delivery object inventory.");
14925
15248
  }
14926
15249
  }
14927
- const routes = value.plan.targets.flatMap((target) => target.routes.map((route) => ({ method: route.method, path: route.path, target: target.name })));
15250
+ const routes = value.plan.targets.flatMap((target) => target.routes.map((route) => ({
15251
+ method: route.method,
15252
+ path: route.path,
15253
+ target: target.name,
15254
+ ...route.idempotency === undefined ? {} : { idempotency: route.idempotency }
15255
+ })));
14928
15256
  const jobs = value.plan.targets.flatMap((target) => target.jobs.map((job) => ({ name: job.name, target: target.name })));
14929
15257
  if (canonical2(value.routes) !== canonical2(routes) || canonical2(value.jobs) !== canonical2(jobs)) {
14930
15258
  throw new Error("Delivery mappings do not match planned ownership.");
@@ -14975,7 +15303,8 @@ var init_delivery_build_schema = __esm(() => {
14975
15303
  routes: _Array_(_Object_({
14976
15304
  method: String2(),
14977
15305
  path: String2(),
14978
- target: String2()
15306
+ target: String2(),
15307
+ idempotency: Optional(Union([Literal("required"), Literal("none")]))
14979
15308
  }, closed2)),
14980
15309
  jobs: _Array_(_Object_({ name: String2(), target: String2() }, closed2))
14981
15310
  }, closed2);
@@ -15194,7 +15523,9 @@ function createExecutionPlans(graph) {
15194
15523
  const aspects = (boundary, refs = []) => refs.map((ref, index) => `${boundary}.aspect[${index}]:${ref.name}`);
15195
15524
  return graph.modules.flatMap((module) => [
15196
15525
  ...module.controllers.flatMap((controller) => controller.routes.map((route) => {
15197
- const command = module.commands.find((item) => item.className === route.command);
15526
+ const featureTransition = featureTransitionForRoute(module, controller, route);
15527
+ const commandRef = route.command ?? featureTransition?.command;
15528
+ const command = module.commands.find((item) => item.className === commandRef || item.name === commandRef);
15198
15529
  const path = `${controller.path}/${route.path}`.replace(/\/+/g, "/");
15199
15530
  return {
15200
15531
  module: module.name,
@@ -15344,6 +15675,7 @@ function exportGraphDot(graph) {
15344
15675
  }
15345
15676
  var init_inspect = __esm(() => {
15346
15677
  init_route_contracts();
15678
+ init_feature();
15347
15679
  });
15348
15680
 
15349
15681
  // src/repair-plan.ts
@@ -15597,7 +15929,7 @@ function parseDevelopmentContext(input) {
15597
15929
  throw invalid();
15598
15930
  }
15599
15931
  }
15600
- var APPLICATION_DEVELOPMENT_SCHEMA = "supacloud.application-development.v1", APPLICATION_DEVELOPMENT_ARCHIVE_MAX_BYTES = 524288, APPLICATION_DEVELOPMENT_LIMITS, DevelopmentContractError, closed4, text2, texts, count, mode, operations2, resourceUse, schemaKind, stage, ContextSchema;
15932
+ var APPLICATION_DEVELOPMENT_SCHEMA = "supacloud.application-development.v1", APPLICATION_DEVELOPMENT_ARCHIVE_MAX_BYTES = 524288, APPLICATION_DEVELOPMENT_LIMITS, DevelopmentContractError, closed4, text2, texts, count, mode2, operations2, resourceUse, schemaKind, stage, ContextSchema;
15601
15933
  var init_application_development = __esm(() => {
15602
15934
  init_build();
15603
15935
  init_value2();
@@ -15625,7 +15957,7 @@ var init_application_development = __esm(() => {
15625
15957
  text2 = String2({ minLength: 1, maxLength: 512, pattern: "^[^\\u0000-\\u001f\\u007f]+$" });
15626
15958
  texts = _Array_(text2);
15627
15959
  count = Integer({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER });
15628
- mode = Union([Literal("required"), Literal("none")]);
15960
+ mode2 = Union([Literal("required"), Literal("none")]);
15629
15961
  operations2 = _Array_(Union([Literal("read"), Literal("write"), Literal("publish"), Literal("consume")]));
15630
15962
  resourceUse = _Object_({ resource: text2, operations: operations2 }, closed4);
15631
15963
  schemaKind = Optional(Union([Literal("opaque"), Literal("declared")]));
@@ -15660,6 +15992,7 @@ var init_application_development = __esm(() => {
15660
15992
  controller: text2,
15661
15993
  handler: text2,
15662
15994
  command: Optional(text2),
15995
+ idempotency: Optional(mode2),
15663
15996
  aspects: texts,
15664
15997
  schemaKinds: Optional(_Object_({
15665
15998
  body: schemaKind,
@@ -15674,8 +16007,8 @@ var init_application_development = __esm(() => {
15674
16007
  module: text2,
15675
16008
  name: text2,
15676
16009
  permission: Optional(text2),
15677
- transaction: mode,
15678
- idempotency: mode,
16010
+ transaction: mode2,
16011
+ idempotency: mode2,
15679
16012
  audit: Optional(text2),
15680
16013
  resources: _Array_(resourceUse)
15681
16014
  }, closed4), { maxItems: APPLICATION_DEVELOPMENT_LIMITS.commands }),
@@ -15782,7 +16115,11 @@ function createApplicationDevelopmentContext(graph, options = {}) {
15782
16115
  for (const controller of module.controllers) {
15783
16116
  for (const route of controller.routes) {
15784
16117
  const candidates = module.commands.filter((command) => command.className === route.command || command.name === route.command);
15785
- const command = candidates.length === 1 ? candidates[0]?.name : undefined;
16118
+ const commandNode = candidates.length === 1 ? candidates[0] : undefined;
16119
+ const featureTransition = featureTransitionForRoute(module, controller, route);
16120
+ const featureCommandNode = featureTransition?.command === undefined ? undefined : module.commands.find((candidate) => candidate.className === featureTransition.command || candidate.name === featureTransition.command);
16121
+ const command = commandNode?.name ?? featureCommandNode?.name;
16122
+ const idempotency = route.idempotency ?? commandNode?.idempotency ?? featureTransition?.idempotency;
15786
16123
  const kinds = schemaKinds(route);
15787
16124
  routes.push({
15788
16125
  module: module.name,
@@ -15791,6 +16128,7 @@ function createApplicationDevelopmentContext(graph, options = {}) {
15791
16128
  controller: controller.className,
15792
16129
  handler: route.handler,
15793
16130
  ...command === undefined ? {} : { command },
16131
+ ...idempotency === undefined ? {} : { idempotency },
15794
16132
  aspects: (route.aspects ?? []).map((aspect) => aspect.name),
15795
16133
  ...kinds === undefined ? {} : { schemaKinds: kinds }
15796
16134
  });
@@ -15911,6 +16249,7 @@ var init_application_development2 = __esm(() => {
15911
16249
  init_application_development();
15912
16250
  init_inspect();
15913
16251
  init_execution_snapshot();
16252
+ init_feature();
15914
16253
  APPLICATION_DEVELOPMENT_LIMITS2 = APPLICATION_DEVELOPMENT_LIMITS;
15915
16254
  ApplicationDevelopmentError = class ApplicationDevelopmentError extends Error {
15916
16255
  code;
@@ -15943,7 +16282,7 @@ async function readDeliverySelection(manifestPath, target) {
15943
16282
  return { root, object, planned };
15944
16283
  }
15945
16284
  async function readVerifiedDeliveryFiles(root, object, target, selectedPaths) {
15946
- if (object.files.length > 1024 || object.files.some((file) => file.bytes > 64 * 1024 * 1024) || object.files.reduce((bytes, file) => bytes + file.bytes, 0) > 128 * 1024 * 1024) {
16285
+ if (object.files.length > 1024 || object.files.some((file) => file.bytes > DELIVERY_ARTIFACT_MAX_FILE_BYTES) || object.files.reduce((bytes, file) => bytes + file.bytes, 0) > DELIVERY_ARTIFACT_MAX_TOTAL_BYTES) {
15947
16286
  throw new Error("Artifact inventory exceeds limits.");
15948
16287
  }
15949
16288
  const objectRoot = join8(root, "objects", object.objectId);
@@ -15982,10 +16321,12 @@ async function readVerifiedDeliveryFiles(root, object, target, selectedPaths) {
15982
16321
  }
15983
16322
  return retained;
15984
16323
  }
15985
- var JSON_LIMIT = 1048576;
16324
+ var JSON_LIMIT = 1048576, DELIVERY_ARTIFACT_MAX_FILE_BYTES, DELIVERY_ARTIFACT_MAX_TOTAL_BYTES;
15986
16325
  var init_delivery_artifact = __esm(() => {
15987
16326
  init_delivery_build_schema();
15988
16327
  init_delivery_files();
16328
+ DELIVERY_ARTIFACT_MAX_FILE_BYTES = 500 * 1024 * 1024;
16329
+ DELIVERY_ARTIFACT_MAX_TOTAL_BYTES = 500 * 1024 * 1024;
15989
16330
  });
15990
16331
 
15991
16332
  // node_modules/@supacloud/delivery/src/application-release.ts
@@ -16844,6 +17185,7 @@ var init_delivery_migration_plan = () => {};
16844
17185
 
16845
17186
  // node_modules/@supacloud/delivery/src/index.ts
16846
17187
  var init_src = __esm(() => {
17188
+ init_delivery_artifact();
16847
17189
  init_delivery_artifact();
16848
17190
  init_worker_execution();
16849
17191
  init_worker_execution();
@@ -18766,6 +19108,7 @@ async function analyzeProject(rootDir, include, cache, changedPaths, effectOptio
18766
19108
  if (meta && booleanProp(meta, "standalone")) {
18767
19109
  const permission = stringLiteralProp(meta, "permission");
18768
19110
  const rpc = checkedRpc(meta, ctx);
19111
+ const branches = checkedBranches(meta, ctx);
18769
19112
  const audit = stringLiteralProp(meta, "audit");
18770
19113
  const standaloneName = stringLiteralProp(meta, "name") ?? classInfo.decl.name?.text ?? name;
18771
19114
  const standaloneUses = parseInfraResourceUseRefs(infraResourceProp(meta, "uses", ctx), ctx, `command ${standaloneName}`, sourcePath(ctx.rootDir, classInfo.file), lineOf(classInfo.decl));
@@ -18775,6 +19118,7 @@ async function analyzeProject(rootDir, include, cache, changedPaths, effectOptio
18775
19118
  name: standaloneName,
18776
19119
  ...permission === undefined ? {} : { permission },
18777
19120
  ...rpc === undefined ? {} : { rpc },
19121
+ ...branches === undefined ? {} : { branches },
18778
19122
  transaction: checkedCommandMode(meta, "transaction", ctx, classInfo.decl.name?.text ?? name) ?? "none",
18779
19123
  ...audit === undefined ? {} : { audit },
18780
19124
  idempotency: checkedCommandMode(meta, "idempotency", ctx, classInfo.decl.name?.text ?? name) ?? "none",
@@ -19106,18 +19450,22 @@ function parseModule(candidate, nameByNode, ctx) {
19106
19450
  const aspects = parseAspectRefs(getProp(meta, "aspects"), ctx, `command ${cls.name?.text ?? "<anonymous>"}`);
19107
19451
  const permission = stringLiteralProp(meta, "permission");
19108
19452
  const rpc = checkedRpc(meta, ctx);
19453
+ const branches = checkedBranches(meta, ctx);
19109
19454
  const audit = stringLiteralProp(meta, "audit");
19110
19455
  const commandName = stringLiteralProp(meta, "name") ?? cls.name?.text ?? "<anonymous>";
19111
19456
  const uses = parseInfraResourceUseRefs(infraResourceProp(meta, "uses", ctx), ctx, `command ${commandName}`, sourcePath(ctx.rootDir, cls.getSourceFile().fileName), lineOf(cls));
19112
19457
  rejectUndeclaredResourceUses(uses, moduleResourceNames, ctx, `command ${commandName}`, sourcePath(ctx.rootDir, cls.getSourceFile().fileName), lineOf(cls));
19458
+ const tenantScope = commandModeProp(meta, "tenantScope");
19113
19459
  commands.push({
19114
19460
  className: cls.name?.text ?? "<anonymous>",
19115
19461
  name: commandName,
19116
19462
  ...permission === undefined ? {} : { permission },
19117
19463
  ...rpc === undefined ? {} : { rpc },
19464
+ ...branches === undefined ? {} : { branches },
19118
19465
  transaction: checkedCommandMode(meta, "transaction", ctx, cls.name?.text ?? "<anonymous>") ?? "none",
19119
19466
  ...audit === undefined ? {} : { audit },
19120
19467
  idempotency: checkedCommandMode(meta, "idempotency", ctx, cls.name?.text ?? "<anonymous>") ?? "none",
19468
+ ...tenantScope === undefined ? {} : { tenantScope },
19121
19469
  ...booleanProp(meta, "standalone") ? { standalone: true } : {},
19122
19470
  ...uses.length > 0 ? { uses } : {},
19123
19471
  ...aspects.length > 0 ? { aspects } : {}
@@ -19148,12 +19496,14 @@ function parseModule(candidate, nameByNode, ctx) {
19148
19496
  const contract = parseJobOptions(meta, className, ctx);
19149
19497
  const jobUses = parseInfraResourceUseRefs(infraResourceProp(meta, "uses", ctx), ctx, `job ${stringLiteralProp(meta, "name") ?? className}`, sourcePath(ctx.rootDir, cls.getSourceFile().fileName), lineOf(cls));
19150
19498
  rejectUndeclaredResourceUses(jobUses, moduleResourceNames, ctx, `job ${stringLiteralProp(meta, "name") ?? className}`, sourcePath(ctx.rootDir, cls.getSourceFile().fileName), lineOf(cls));
19499
+ const tenantScope = commandModeProp(meta, "tenantScope");
19151
19500
  jobs.push({
19152
19501
  className,
19153
19502
  name: stringLiteralProp(meta, "name") ?? className,
19154
19503
  serviceKey: camelName(provider?.token ?? className),
19155
19504
  scope,
19156
19505
  ...contract,
19506
+ ...tenantScope === undefined ? {} : { tenantScope },
19157
19507
  ...jobUses.length > 0 ? { uses: jobUses } : {},
19158
19508
  ...aspects.length > 0 ? { aspects } : {}
19159
19509
  });
@@ -19163,9 +19513,11 @@ function parseModule(candidate, nameByNode, ctx) {
19163
19513
  if (queryDec) {
19164
19514
  const meta = decoratorObjectArg(queryDec);
19165
19515
  if (meta) {
19516
+ const tenantScope = commandModeProp(meta, "tenantScope");
19166
19517
  queries.push({
19167
19518
  className: cls.name?.text ?? "<anonymous>",
19168
- name: stringLiteralProp(meta, "name") ?? cls.name?.text ?? "<anonymous>"
19519
+ name: stringLiteralProp(meta, "name") ?? cls.name?.text ?? "<anonymous>",
19520
+ ...tenantScope === undefined ? {} : { tenantScope }
19169
19521
  });
19170
19522
  }
19171
19523
  }
@@ -19220,6 +19572,7 @@ function parseFeatureSpec(input, ctx, seen = new Set) {
19220
19572
  if (!ts7.isObjectLiteralExpression(input))
19221
19573
  return invalid();
19222
19574
  const name = stringLiteralProp(input, "name");
19575
+ const tenantField = stringLiteralProp(input, "tenantField");
19223
19576
  const statesExpr = getProp(input, "states");
19224
19577
  const transitionObject = getProp(input, "transitions");
19225
19578
  if (!name || !statesExpr || !ts7.isArrayLiteralExpression(statesExpr) || statesExpr.elements.some((state) => !ts7.isStringLiteral(state)) || !transitionObject || !ts7.isObjectLiteralExpression(transitionObject) || input.properties.some((property) => !ts7.isPropertyAssignment(property))) {
@@ -19238,15 +19591,27 @@ function parseFeatureSpec(input, ctx, seen = new Set) {
19238
19591
  const options = property.initializer;
19239
19592
  const from = stringLiteralProp(options, "from");
19240
19593
  const to = stringLiteralProp(options, "to");
19241
- if (!from || !to || options.properties.some((prop) => !ts7.isPropertyAssignment(prop)) || ["permission", "command", "route", "audit"].some((key) => getProp(options, key) && !stringLiteralProp(options, key)) || ["transaction", "idempotency"].some((key) => getProp(options, key) && !commandModeProp(options, key))) {
19594
+ if (!from || !to || options.properties.some((prop) => !ts7.isPropertyAssignment(prop)) || ["permission", "command", "route", "routeBinding", "audit", "job"].some((key) => getProp(options, key) && !stringLiteralProp(options, key)) || ["transaction", "idempotency"].some((key) => getProp(options, key) && !commandModeProp(options, key))) {
19242
19595
  return invalid();
19243
19596
  }
19244
19597
  const permission = stringLiteralProp(options, "permission");
19245
19598
  const command = stringLiteralProp(options, "command");
19246
19599
  const route = stringLiteralProp(options, "route");
19600
+ const routeBinding = stringLiteralProp(options, "routeBinding");
19601
+ if (routeBinding !== undefined && routeBinding !== "command" && routeBinding !== "controller")
19602
+ return invalid();
19247
19603
  const transaction = commandModeProp(options, "transaction");
19248
19604
  const idempotency = commandModeProp(options, "idempotency");
19249
19605
  const audit = stringLiteralProp(options, "audit");
19606
+ const job = stringLiteralProp(options, "job");
19607
+ const invalidateTagsExpr = getProp(options, "invalidateTags");
19608
+ const invalidateTags = invalidateTagsExpr === undefined ? undefined : ts7.isArrayLiteralExpression(invalidateTagsExpr) && invalidateTagsExpr.elements.every((element) => ts7.isStringLiteral(element) && element.text.trim().length > 0) ? invalidateTagsExpr.elements.map((element) => element.text) : undefined;
19609
+ if (invalidateTagsExpr !== undefined && invalidateTags === undefined)
19610
+ return invalid();
19611
+ const persistenceExpr = getProp(options, "persistence");
19612
+ const persistence = persistenceExpr && ts7.isObjectLiteralExpression(persistenceExpr) ? parseFeaturePersistence(persistenceExpr) : undefined;
19613
+ if (persistenceExpr && !persistence)
19614
+ return invalid();
19250
19615
  transitions.push({
19251
19616
  name: propertyName(property.name),
19252
19617
  from,
@@ -19254,12 +19619,45 @@ function parseFeatureSpec(input, ctx, seen = new Set) {
19254
19619
  ...permission === undefined ? {} : { permission },
19255
19620
  ...command === undefined ? {} : { command },
19256
19621
  ...route === undefined ? {} : { route },
19622
+ ...routeBinding === undefined ? {} : { routeBinding },
19257
19623
  ...transaction === undefined ? {} : { transaction },
19258
19624
  ...idempotency === undefined ? {} : { idempotency },
19259
- ...audit === undefined ? {} : { audit }
19625
+ ...audit === undefined ? {} : { audit },
19626
+ ...job === undefined ? {} : { job },
19627
+ ...invalidateTags === undefined ? {} : { invalidateTags },
19628
+ ...persistence === undefined ? {} : { persistence }
19260
19629
  });
19261
19630
  }
19262
- return { name, states, transitions, file: sourcePath(ctx.rootDir, input.getSourceFile().fileName), line: lineOf(input) };
19631
+ return {
19632
+ name,
19633
+ states,
19634
+ ...tenantField === undefined ? {} : { tenantField },
19635
+ transitions,
19636
+ file: sourcePath(ctx.rootDir, input.getSourceFile().fileName),
19637
+ line: lineOf(input)
19638
+ };
19639
+ }
19640
+ function parseFeaturePersistence(input) {
19641
+ if (input.properties.some((property) => !ts7.isPropertyAssignment(property)))
19642
+ return;
19643
+ const boundary = stringLiteralProp(input, "boundary");
19644
+ const optimisticLock = commandModeProp(input, "optimisticLock");
19645
+ const versionField = stringLiteralProp(input, "versionField");
19646
+ const tenantField = stringLiteralProp(input, "tenantField");
19647
+ const stateField = stringLiteralProp(input, "stateField");
19648
+ const receipt = commandModeProp(input, "receipt");
19649
+ const reconciliation = commandModeProp(input, "reconciliation");
19650
+ if (boundary !== "database" && boundary !== "external" || optimisticLock !== "required" && optimisticLock !== "none" || receipt !== "required" && receipt !== "none" || reconciliation !== "required" && reconciliation !== "none")
19651
+ return;
19652
+ return {
19653
+ boundary,
19654
+ optimisticLock,
19655
+ ...versionField === undefined ? {} : { versionField },
19656
+ ...tenantField === undefined ? {} : { tenantField },
19657
+ ...stateField === undefined ? {} : { stateField },
19658
+ receipt,
19659
+ reconciliation
19660
+ };
19263
19661
  }
19264
19662
  function resolveStaticObjectLiteral(input, ctx, seen = new Set) {
19265
19663
  if (!input || seen.has(input))
@@ -19329,6 +19727,54 @@ function checkedCommandMode(object, property, ctx, command) {
19329
19727
  }
19330
19728
  return value;
19331
19729
  }
19730
+ function checkedRouteIdempotency(object, ctx, route) {
19731
+ const expression = getProp(object, "idempotency");
19732
+ const value = stringLiteralProp(object, "idempotency");
19733
+ if (expression && value !== "required" && value !== "none") {
19734
+ const file = sourcePath(ctx.rootDir, expression.getSourceFile().fileName);
19735
+ ctx.diagnostics.push({
19736
+ severity: "error",
19737
+ code: "invalid-route-idempotency",
19738
+ errorCode: "SC3028",
19739
+ docsUrl: "https://supacloud.dev/errors/SC3028",
19740
+ message: `${route}.idempotency must be the explicit literal "required" or "none".`,
19741
+ file,
19742
+ line: lineOf(expression),
19743
+ suggestion: `Choose the intended idempotency policy explicitly; use "required" for replayable mutations or "none" for routes without that contract.`
19744
+ });
19745
+ }
19746
+ return value === "required" || value === "none" ? value : undefined;
19747
+ }
19748
+ function checkedRouteTags(expression, ctx, route) {
19749
+ if (expression === undefined)
19750
+ return;
19751
+ if (!ts7.isArrayLiteralExpression(expression) || expression.elements.some((element) => !ts7.isStringLiteral(element) || element.text.trim().length === 0)) {
19752
+ ctx.diagnostics.push({
19753
+ severity: "error",
19754
+ code: "invalid-route-tags",
19755
+ errorCode: "SC3029",
19756
+ docsUrl: "https://supacloud.dev/errors/SC3029",
19757
+ message: `${route}.tags must be a static array of non-empty string literals.`,
19758
+ file: sourcePath(ctx.rootDir, expression.getSourceFile().fileName),
19759
+ line: lineOf(expression),
19760
+ suggestion: 'Declare stable cache tags explicitly, for example tags: ["cases.list"].'
19761
+ });
19762
+ return;
19763
+ }
19764
+ const tags = expression.elements.map((element) => element.text);
19765
+ if (new Set(tags).size !== tags.length) {
19766
+ ctx.diagnostics.push({
19767
+ severity: "error",
19768
+ code: "duplicate-route-tags",
19769
+ errorCode: "SC3030",
19770
+ docsUrl: "https://supacloud.dev/errors/SC3030",
19771
+ message: `${route}.tags must not contain duplicate values.`,
19772
+ file: sourcePath(ctx.rootDir, expression.getSourceFile().fileName),
19773
+ line: lineOf(expression)
19774
+ });
19775
+ }
19776
+ return tags;
19777
+ }
19332
19778
  function parseProvider(el, exportsSet, ctx) {
19333
19779
  const file = sourcePath(ctx.rootDir, el.getSourceFile().fileName);
19334
19780
  const line = lineOf(el);
@@ -20108,7 +20554,11 @@ function parseController(input, ctx) {
20108
20554
  if (optionsObject) {
20109
20555
  const parsePolicy = routePolicyProperty(optionsObject, "parse", ctx);
20110
20556
  const deletePolicy = routePolicyProperty(optionsObject, "allowDeleteBody", ctx);
20111
- if (parsePolicy.unresolved || deletePolicy.unresolved) {
20557
+ const idempotency = checkedRouteIdempotency(optionsObject, ctx, route.handler);
20558
+ if (idempotency !== undefined)
20559
+ route.idempotency = idempotency;
20560
+ const tagsPolicy = routePolicyProperty(optionsObject, "tags", ctx);
20561
+ if (parsePolicy.unresolved || deletePolicy.unresolved || tagsPolicy.unresolved) {
20112
20562
  ctx.diagnostics.push({
20113
20563
  severity: "error",
20114
20564
  code: "unresolved-route-body-policy",
@@ -20116,6 +20566,9 @@ function parseController(input, ctx) {
20116
20566
  message: `Route ${route.handler} body policy must be statically resolvable; dynamic spreads, computed keys and shorthand policies are unsupported.`
20117
20567
  });
20118
20568
  }
20569
+ const tags = checkedRouteTags(tagsPolicy.value, ctx, route.handler);
20570
+ if (tags !== undefined)
20571
+ route.tags = tags;
20119
20572
  const parse = parsePolicy.value;
20120
20573
  if (parse) {
20121
20574
  if (ts7.isStringLiteral(parse) && parse.text === "none")
@@ -20686,6 +21139,75 @@ function checkedRpc(meta, ctx) {
20686
21139
  });
20687
21140
  return;
20688
21141
  }
21142
+ function checkedBranches(meta, ctx) {
21143
+ const expression = getProp(meta, "branches");
21144
+ if (!expression)
21145
+ return;
21146
+ if (!ts7.isArrayLiteralExpression(expression)) {
21147
+ ctx.diagnostics.push({
21148
+ severity: "error",
21149
+ code: "invalid-command-branches",
21150
+ file: sourcePath(ctx.rootDir, expression.getSourceFile().fileName),
21151
+ line: lineOf(expression),
21152
+ message: "Command branches must be an explicit object array.",
21153
+ suggestion: "Declare each branch as { when, rpc, transaction, audit, idempotency, boundary }."
21154
+ });
21155
+ return;
21156
+ }
21157
+ const branches = [];
21158
+ for (const item of expression.elements) {
21159
+ if (!ts7.isObjectLiteralExpression(item)) {
21160
+ ctx.diagnostics.push({
21161
+ severity: "error",
21162
+ code: "invalid-command-branches",
21163
+ file: sourcePath(ctx.rootDir, item.getSourceFile().fileName),
21164
+ line: lineOf(item),
21165
+ message: "Command branches entries must be object literals."
21166
+ });
21167
+ continue;
21168
+ }
21169
+ const when = stringLiteralProp(item, "when");
21170
+ const rpc = stringLiteralProp(item, "rpc");
21171
+ const transaction = checkedCommandMode(item, "transaction", ctx, "command branch") ?? "none";
21172
+ const audit = stringLiteralProp(item, "audit");
21173
+ const idempotency = checkedCommandMode(item, "idempotency", ctx, "command branch") ?? "none";
21174
+ const boundary = stringLiteralProp(item, "boundary");
21175
+ const validBoundary = boundary === undefined || boundary === "database" || boundary === "external";
21176
+ const validWhen = when !== undefined && /^(?:[A-Za-z_$][\w$]*)(?:\.[A-Za-z_$][\w$]*)*\s*===\s*(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')$/.test(when);
21177
+ if (!when || !validWhen || !rpc?.trim() || !validBoundary) {
21178
+ ctx.diagnostics.push({
21179
+ severity: "error",
21180
+ code: "invalid-command-branches",
21181
+ file: sourcePath(ctx.rootDir, item.getSourceFile().fileName),
21182
+ line: lineOf(item),
21183
+ message: "Command branches require a restricted equality selector, a non-empty rpc, and a valid boundary.",
21184
+ suggestion: `Use when: 'resource === "value"' (or body.resource/params.resource/query.resource), plus a named RPC adapter.`
21185
+ });
21186
+ continue;
21187
+ }
21188
+ branches.push({
21189
+ when,
21190
+ rpc,
21191
+ transaction,
21192
+ ...audit === undefined ? {} : { audit },
21193
+ idempotency,
21194
+ ...boundary === undefined ? {} : { boundary }
21195
+ });
21196
+ }
21197
+ if (branches.length === 0)
21198
+ return [];
21199
+ if (new Set(branches.map((branch) => branch.when)).size !== branches.length) {
21200
+ ctx.diagnostics.push({
21201
+ severity: "error",
21202
+ code: "duplicate-command-branch",
21203
+ file: sourcePath(ctx.rootDir, expression.getSourceFile().fileName),
21204
+ line: lineOf(expression),
21205
+ message: "Command branches must use distinct selectors.",
21206
+ suggestion: "Make each branch selector mutually distinct."
21207
+ });
21208
+ }
21209
+ return branches;
21210
+ }
20689
21211
  function classDeps(cls, ctx) {
20690
21212
  const injectable = parseInjectableOptions(cls, ctx);
20691
21213
  const ctor = cls.members.find(ts7.isConstructorDeclaration);
@@ -21897,6 +22419,7 @@ function validateClientSchemaBoundaries(graph, options) {
21897
22419
 
21898
22420
  // src/effect-governance.ts
21899
22421
  init_validate();
22422
+ init_feature();
21900
22423
  function diagnostic(code, message, file, suggestion) {
21901
22424
  const metadata = COMPILER_DIAGNOSTIC_CODES[code];
21902
22425
  return {
@@ -21934,8 +22457,10 @@ function validateEffectPolicies(graph, options = {}) {
21934
22457
  if (effect.retry === "explicit" && effect.maxAttempts === undefined) {
21935
22458
  diagnostics.push(diagnostic("invalid-effect-retry", `Route ${label} enables explicit Effect retry without maxAttempts.`, controller.file, "Declare a positive maxAttempts value including the initial attempt."));
21936
22459
  }
21937
- if (route.command && effect.retry === "explicit") {
21938
- const command = commands.get(route.command);
22460
+ if (effect.retry === "explicit") {
22461
+ const featureTransition = featureTransitionForRoute(module, controller, route);
22462
+ const commandRef = route.command ?? featureTransition?.command;
22463
+ const command = commandRef === undefined ? undefined : commands.get(commandRef) ?? [...commands.values()].find((candidate) => candidate.name === commandRef);
21939
22464
  if (command && command.idempotency !== "required") {
21940
22465
  diagnostics.push(diagnostic("effect-command-retry-forbidden", `Route ${label} enables explicit Effect retry for non-idempotent command "${command.name}".`, controller.file, 'Set command idempotency to "required" or use effect.retry: "none".'));
21941
22466
  }
@@ -22190,6 +22715,7 @@ function resolveTypeSafety(options) {
22190
22715
  init_delivery_schema();
22191
22716
 
22192
22717
  // src/delivery-plan.ts
22718
+ init_feature();
22193
22719
  init_validate();
22194
22720
  var compare2 = (a, b) => a < b ? -1 : a > b ? 1 : 0;
22195
22721
  var sorted = (values) => [...new Set(values)].sort(compare2);
@@ -22309,6 +22835,10 @@ function createDeliveryPlan(graph, input, validation) {
22309
22835
  fail("delivery-route-conflict", `Public route "${key}" is owned by both "${existing}" and "${target.name}".`, "Give public routes unambiguous method/path contracts before splitting targets.");
22310
22836
  }
22311
22837
  publicRoutes.set(key, target.name);
22838
+ const command = route.command === undefined ? undefined : module.commands.find((candidate) => candidate.className === route.command || candidate.name === route.command);
22839
+ const featureTransition = featureTransitionForRoute(module, controller, route);
22840
+ const effectiveCommand = route.command ?? featureTransition?.command;
22841
+ const idempotency = route.idempotency ?? command?.idempotency ?? featureTransition?.idempotency;
22312
22842
  target.roots.push(module.name);
22313
22843
  target.routes.push({
22314
22844
  module: module.name,
@@ -22316,7 +22846,8 @@ function createDeliveryPlan(graph, input, validation) {
22316
22846
  handler: route.handler,
22317
22847
  method: route.method,
22318
22848
  path,
22319
- ...route.command === undefined ? {} : { command: route.command },
22849
+ ...effectiveCommand === undefined ? {} : { command: effectiveCommand },
22850
+ ...idempotency === undefined ? {} : { idempotency },
22320
22851
  reason: owner === undefined ? "default-http" : "explicit-module"
22321
22852
  });
22322
22853
  }
@@ -23347,7 +23878,12 @@ async function buildDeliveryProject(options, delivery) {
23347
23878
  deploymentReady: false,
23348
23879
  plan: planned.plan,
23349
23880
  objects,
23350
- routes: planned.plan.targets.flatMap((target) => target.routes.map((route) => ({ method: route.method, path: route.path, target: target.name }))),
23881
+ routes: planned.plan.targets.flatMap((target) => target.routes.map((route) => ({
23882
+ method: route.method,
23883
+ path: route.path,
23884
+ target: target.name,
23885
+ ...route.idempotency === undefined ? {} : { idempotency: route.idempotency }
23886
+ }))),
23351
23887
  jobs: planned.plan.targets.flatMap((target) => target.jobs.map((job) => ({ name: job.name, target: target.name })))
23352
23888
  });
23353
23889
  phase = "artifact-integrity";
@@ -24097,10 +24633,10 @@ import { readFileSync as readFileSync4 } from "node:fs";
24097
24633
  import { resolve as resolve17 } from "node:path";
24098
24634
 
24099
24635
  // src/migration-app-version.ts
24100
- var MIGRATION_APP_VERSION = "0.28.3";
24636
+ var MIGRATION_APP_VERSION = "0.30.0";
24101
24637
 
24102
24638
  // src/migration-elysia-version.ts
24103
- var MIGRATION_ELYSIA_VERSION = "0.33.5";
24639
+ var MIGRATION_ELYSIA_VERSION = "0.35.0";
24104
24640
 
24105
24641
  // src/migration-policy.ts
24106
24642
  var cachedCompilerVersion;
@@ -25069,6 +25605,175 @@ function isRecord2(value) {
25069
25605
  function arrayLength(value) {
25070
25606
  return Array.isArray(value) ? value.length : undefined;
25071
25607
  }
25608
+ function safeField(value) {
25609
+ return typeof value === "string" && /^[A-Za-z_][A-Za-z0-9_]*$/.test(value);
25610
+ }
25611
+ function validFeaturePersistence(value) {
25612
+ if (!isRecord2(value))
25613
+ return false;
25614
+ const boundary = value["boundary"];
25615
+ const receipt = value["receipt"];
25616
+ const reconciliation = value["reconciliation"];
25617
+ if (boundary === "database") {
25618
+ const fields = [value["tenantField"], value["versionField"], value["stateField"]];
25619
+ return value["optimisticLock"] === "required" && fields.every(safeField) && new Set(fields).size === 3 && receipt === "required" && reconciliation === "none";
25620
+ }
25621
+ if (boundary === "external") {
25622
+ return value["optimisticLock"] === "none" && value["versionField"] === undefined && value["tenantField"] === undefined && value["stateField"] === undefined && receipt === "required" && reconciliation === "required";
25623
+ }
25624
+ return false;
25625
+ }
25626
+ function validCommandInventory(value) {
25627
+ const names = new Set;
25628
+ const classes = new Set;
25629
+ return Array.isArray(value) && value.every((command) => {
25630
+ if (!isRecord2(command) || typeof command["name"] !== "string" || command["name"].trim() === "" || typeof command["className"] !== "string" || command["className"].trim() === "" || typeof command["module"] !== "string" || command["module"].trim() === "" || typeof command["permission"] !== "string" || command["permission"].trim() === "" || command["transaction"] !== "required" && command["transaction"] !== "none" || command["idempotency"] !== "required" && command["idempotency"] !== "none" || command["tenantScope"] !== undefined && command["tenantScope"] !== "required" && command["tenantScope"] !== "none")
25631
+ return false;
25632
+ if (names.has(command["name"]) || classes.has(command["className"]))
25633
+ return false;
25634
+ if (command["rpc"] !== undefined && (typeof command["rpc"] !== "string" || command["rpc"].trim() === ""))
25635
+ return false;
25636
+ if (command["auditEvent"] !== undefined && (typeof command["auditEvent"] !== "string" || command["auditEvent"].trim() === ""))
25637
+ return false;
25638
+ names.add(command["name"]);
25639
+ classes.add(command["className"]);
25640
+ return true;
25641
+ });
25642
+ }
25643
+ function validFeatureInventory(value, jobs, commands, routes, rpc) {
25644
+ const featureNames = new Set;
25645
+ return Array.isArray(value) && value.every((feature) => {
25646
+ if (!isRecord2(feature) || typeof feature["name"] !== "string" || feature["name"].trim() === "")
25647
+ return false;
25648
+ if (featureNames.has(feature["name"]))
25649
+ return false;
25650
+ featureNames.add(feature["name"]);
25651
+ if (feature["tenantField"] !== undefined && !safeField(feature["tenantField"]))
25652
+ return false;
25653
+ const states = feature["states"];
25654
+ const transitions = feature["transitions"];
25655
+ if (!Array.isArray(states) || states.length === 0 || states.some((state) => typeof state !== "string" || state.trim() === "") || new Set(states).size !== states.length || !Array.isArray(transitions))
25656
+ return false;
25657
+ const transitionNames = new Set;
25658
+ return transitions.every((transition) => {
25659
+ if (!isRecord2(transition) || typeof transition["name"] !== "string" || transition["name"].trim() === "" || typeof transition["from"] !== "string" || !states.includes(transition["from"]) || typeof transition["to"] !== "string" || !states.includes(transition["to"]))
25660
+ return false;
25661
+ if (transitionNames.has(transition["name"]))
25662
+ return false;
25663
+ transitionNames.add(transition["name"]);
25664
+ const persistence = transition["persistence"];
25665
+ if (persistence !== undefined) {
25666
+ if (!isRecord2(persistence) || !validFeaturePersistence(persistence))
25667
+ return false;
25668
+ if (typeof transition["command"] !== "string" || transition["command"].trim() === "" || typeof transition["permission"] !== "string" || transition["permission"].trim() === "")
25669
+ return false;
25670
+ if (persistence["boundary"] === "database" && feature["tenantField"] !== persistence["tenantField"])
25671
+ return false;
25672
+ }
25673
+ const commandName = transition["command"];
25674
+ const command = commandName === undefined ? undefined : commands.find((candidate) => isRecord2(candidate) && (candidate["name"] === commandName || candidate["className"] === commandName));
25675
+ if (commandName !== undefined) {
25676
+ if (!isRecord2(command))
25677
+ return false;
25678
+ if (transition["permission"] !== undefined && command["permission"] !== transition["permission"])
25679
+ return false;
25680
+ if (transition["transaction"] !== undefined && command["transaction"] !== transition["transaction"])
25681
+ return false;
25682
+ if (transition["idempotency"] !== undefined && command["idempotency"] !== transition["idempotency"])
25683
+ return false;
25684
+ if (transition["audit"] !== undefined && command["auditEvent"] !== transition["audit"])
25685
+ return false;
25686
+ if (persistence !== undefined) {
25687
+ const adapter = command["rpc"];
25688
+ if (typeof adapter !== "string" || adapter.trim() === "" || !rpc.some((candidate) => isRecord2(candidate) && candidate["command"] === command["name"] && candidate["adapter"] === adapter) || command["idempotency"] !== "required" || command["tenantScope"] !== "required" || typeof command["auditEvent"] !== "string" || command["auditEvent"].trim() === "")
25689
+ return false;
25690
+ const expectedTransaction = persistence["boundary"] === "database" ? "required" : "none";
25691
+ if (command["transaction"] !== expectedTransaction)
25692
+ return false;
25693
+ }
25694
+ }
25695
+ const routeName = transition["route"];
25696
+ const routeBinding = transition["routeBinding"];
25697
+ if (routeBinding !== undefined && routeBinding !== "command" && routeBinding !== "controller")
25698
+ return false;
25699
+ if (routeName !== undefined) {
25700
+ const route = routes.find((candidate) => isRecord2(candidate) && `${candidate["method"]} ${candidate["path"]}` === routeName);
25701
+ if (!isRecord2(route))
25702
+ return false;
25703
+ if (routeBinding === "controller") {
25704
+ if (route["command"] !== undefined)
25705
+ return false;
25706
+ } else {
25707
+ if (typeof route["command"] !== "string")
25708
+ return false;
25709
+ const routeCommand = commands.find((candidate) => isRecord2(candidate) && (candidate["name"] === route["command"] || candidate["className"] === route["command"]));
25710
+ if (!isRecord2(routeCommand) || !isRecord2(command) || routeCommand["name"] !== command["name"])
25711
+ return false;
25712
+ }
25713
+ if (transition["permission"] !== undefined && route["permission"] !== transition["permission"])
25714
+ return false;
25715
+ if (transition["idempotency"] !== undefined && route["idempotency"] !== transition["idempotency"])
25716
+ return false;
25717
+ if (persistence !== undefined && route["idempotency"] !== "required")
25718
+ return false;
25719
+ if (route["idempotency"] === "required" && (route["method"] === "GET" || route["method"] === "HEAD" || route["method"] === "OPTIONS"))
25720
+ return false;
25721
+ }
25722
+ const job = transition["job"];
25723
+ if (job === undefined)
25724
+ return true;
25725
+ const details = transition["jobDetails"];
25726
+ const inventory = jobs.find((candidate) => isRecord2(candidate) && (candidate["name"] === job || candidate["className"] === job));
25727
+ const contractFields = ["input", "inputKind", "output", "outputKind", "mode", "idempotency", "tenantScope"];
25728
+ return typeof job === "string" && job.trim() !== "" && isRecord2(details) && isRecord2(inventory) && (details["name"] === job || details["className"] === job) && typeof details["className"] === "string" && details["className"].trim() !== "" && details["inputKind"] === "declared" && (details["mode"] === "task" || details["mode"] === "workflow") && details["idempotency"] === "required" && details["tenantScope"] === "required" && contractFields.every((field) => details[field] === inventory[field]);
25729
+ });
25730
+ });
25731
+ }
25732
+ function validJobInventory(value) {
25733
+ const names = new Set;
25734
+ const classes = new Set;
25735
+ return Array.isArray(value) && value.every((job) => {
25736
+ if (!isRecord2(job) || typeof job["name"] !== "string" || job["name"].trim() === "" || typeof job["className"] !== "string" || job["className"].trim() === "" || typeof job["module"] !== "string" || job["module"].trim() === "")
25737
+ return false;
25738
+ if (names.has(job["name"]) || classes.has(job["className"]))
25739
+ return false;
25740
+ names.add(job["name"]);
25741
+ classes.add(job["className"]);
25742
+ if (job["input"] !== undefined && (typeof job["input"] !== "string" || job["input"].trim() === ""))
25743
+ return false;
25744
+ if (job["inputKind"] !== undefined && job["inputKind"] !== "opaque" && job["inputKind"] !== "declared")
25745
+ return false;
25746
+ if (job["output"] !== undefined && (typeof job["output"] !== "string" || job["output"].trim() === ""))
25747
+ return false;
25748
+ if (job["outputKind"] !== undefined && job["outputKind"] !== "opaque" && job["outputKind"] !== "declared")
25749
+ return false;
25750
+ if (job["mode"] !== undefined && job["mode"] !== "task" && job["mode"] !== "workflow")
25751
+ return false;
25752
+ if (job["idempotency"] !== undefined && job["idempotency"] !== "required" && job["idempotency"] !== "none")
25753
+ return false;
25754
+ return job["tenantScope"] === undefined || job["tenantScope"] === "required" || job["tenantScope"] === "none";
25755
+ });
25756
+ }
25757
+ function validRpcInventory(value, commandInventory) {
25758
+ const commandNames = new Set;
25759
+ const adapters = new Set;
25760
+ return Array.isArray(value) && value.every((rpc) => {
25761
+ if (!isRecord2(rpc) || typeof rpc["command"] !== "string" || rpc["command"].trim() === "" || typeof rpc["adapter"] !== "string" || rpc["adapter"].trim() === "")
25762
+ return false;
25763
+ if (commandNames.has(rpc["command"]) || adapters.has(rpc["adapter"]))
25764
+ return false;
25765
+ const command = commandInventory.find((candidate) => isRecord2(candidate) && candidate["name"] === rpc["command"] && candidate["rpc"] === rpc["adapter"]);
25766
+ if (!isRecord2(command))
25767
+ return false;
25768
+ commandNames.add(rpc["command"]);
25769
+ adapters.add(rpc["adapter"]);
25770
+ return true;
25771
+ }) && commandInventory.every((command) => {
25772
+ if (!isRecord2(command) || command["rpc"] === undefined)
25773
+ return true;
25774
+ return value.some((rpc) => isRecord2(rpc) && rpc["command"] === command["name"] && rpc["adapter"] === command["rpc"]);
25775
+ });
25776
+ }
25072
25777
  async function sha2566(path) {
25073
25778
  return createHash11("sha256").update(await readFile11(path)).digest("hex");
25074
25779
  }
@@ -25138,19 +25843,24 @@ async function assessMigration(options) {
25138
25843
  };
25139
25844
  try {
25140
25845
  const parsed = JSON.parse(await readFile11(contractsManifestPath, "utf8"));
25141
- if (!isRecord2(parsed) || parsed["version"] !== 1)
25846
+ if (!isRecord2(parsed) || parsed["version"] !== 1 || !validJobInventory(parsed["jobs"]) || !validCommandInventory(parsed["commands"]) || !Array.isArray(parsed["routes"]) || !validRpcInventory(parsed["rpc"], parsed["commands"]) || !validFeatureInventory(parsed["features"], parsed["jobs"], parsed["commands"], parsed["routes"], parsed["rpc"])) {
25142
25847
  throw new Error("invalid manifest");
25848
+ }
25143
25849
  const commands = arrayLength(parsed["commands"]);
25850
+ const jobs = arrayLength(parsed["jobs"]);
25144
25851
  const routes = arrayLength(parsed["routes"]);
25145
25852
  const permissions = arrayLength(parsed["permissions"]);
25853
+ const features = arrayLength(parsed["features"]);
25146
25854
  contractsManifest = {
25147
25855
  path: contractsManifestPath,
25148
25856
  present: true,
25149
25857
  sha256: await sha2566(contractsManifestPath),
25150
25858
  version: 1,
25151
25859
  ...commands === undefined ? {} : { commands },
25860
+ ...jobs === undefined ? {} : { jobs },
25152
25861
  ...routes === undefined ? {} : { routes },
25153
- ...permissions === undefined ? {} : { permissions }
25862
+ ...permissions === undefined ? {} : { permissions },
25863
+ ...features === undefined ? {} : { features }
25154
25864
  };
25155
25865
  } catch {
25156
25866
  findings.push({