@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/cli.js CHANGED
@@ -249,6 +249,10 @@ function findClosestMatch(target, candidates) {
249
249
  var REQUEST_CONTEXT_TOKEN_NAME = "supacloud.request-context", JOB_CONTEXT_TOKEN_NAME = "supacloud.job-context";
250
250
 
251
251
  // src/feature.ts
252
+ function featureTransitionForRoute(module, controller, route) {
253
+ const routePath = `${route.method} ${joinRoutePaths(controller.path, route.path)}`;
254
+ return module.featureSpec?.transitions.find((transition) => transition.route === routePath);
255
+ }
252
256
  function validateFeatureSpec(spec, module) {
253
257
  const diagnostics = [];
254
258
  const error = (code, message) => {
@@ -263,6 +267,9 @@ function validateFeatureSpec(spec, module) {
263
267
  if (!spec.name.trim() || spec.states.length === 0 || spec.states.some((state) => !state.trim()) || new Set(spec.states).size !== spec.states.length) {
264
268
  error("invalid-feature-states", "Feature name and states must be non-empty; states must be unique.");
265
269
  }
270
+ if (spec.tenantField !== undefined && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(spec.tenantField)) {
271
+ error("feature-tenant-boundary-invalid", `Feature ${spec.name} declares an unsafe tenant field.`);
272
+ }
266
273
  const names = new Set;
267
274
  for (const transition of spec.transitions) {
268
275
  if (!transition.name.trim() || names.has(transition.name)) {
@@ -275,8 +282,46 @@ function validateFeatureSpec(spec, module) {
275
282
  if (transition.permission !== undefined && !transition.permission.trim()) {
276
283
  error("feature-governance-drift", `Transition ${transition.name} declares an empty permission.`);
277
284
  }
285
+ if (transition.invalidateTags !== undefined) {
286
+ if (transition.invalidateTags.length === 0 || transition.invalidateTags.some((tag) => !tag.trim()) || new Set(transition.invalidateTags).size !== transition.invalidateTags.length) {
287
+ error("feature-invalidation-tags-invalid", `Transition ${transition.name} must declare unique, non-empty invalidateTags.`);
288
+ }
289
+ }
290
+ if (transition.persistence) {
291
+ const persistence = transition.persistence;
292
+ if (persistence.boundary === "database" && (persistence.optimisticLock !== "required" || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(persistence.versionField ?? "") || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(persistence.tenantField ?? "") || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(persistence.stateField ?? "") || new Set([persistence.tenantField, persistence.versionField, persistence.stateField]).size !== 3 || persistence.receipt !== "required" || persistence.reconciliation !== "none")) {
293
+ error("feature-persistence-invalid", `Database transition ${transition.name} requires distinct safe tenant/version/state fields, optimistic locking, and a receipt, without reconciliation.`);
294
+ }
295
+ if (persistence.boundary === "database" && spec.tenantField !== persistence.tenantField) {
296
+ error("feature-tenant-boundary-drift", `Database transition ${transition.name} tenant field must match Feature ${spec.name} tenantField.`);
297
+ }
298
+ if (persistence.boundary === "external" && (persistence.optimisticLock !== "none" || persistence.versionField !== undefined || persistence.tenantField !== undefined || persistence.stateField !== undefined || persistence.receipt !== "required" || persistence.reconciliation !== "required")) {
299
+ error("feature-persistence-invalid", `External transition ${transition.name} requires a receipt and reconciliation, without database optimistic locking.`);
300
+ }
301
+ if (persistence.boundary === "external" && !transition.job) {
302
+ error("feature-job-unresolved", `External transition ${transition.name} must bind a reconciliation Job.`);
303
+ }
304
+ if (!transition.command) {
305
+ error("feature-command-unresolved", `Persistent transition ${transition.name} must reference a command.`);
306
+ }
307
+ if (!transition.permission?.trim()) {
308
+ error("feature-governance-drift", `Persistent transition ${transition.name} must declare a permission.`);
309
+ }
310
+ }
278
311
  if (!module)
279
312
  continue;
313
+ if (transition.persistence?.boundary === "database") {
314
+ for (const query of module.queries) {
315
+ if (query.tenantScope !== "required") {
316
+ error("feature-query-tenant-boundary", `Query ${query.className} in Feature ${spec.name} must declare tenantScope: "required".`);
317
+ }
318
+ }
319
+ for (const job of module.jobs ?? []) {
320
+ if (job.tenantScope !== "required") {
321
+ error("feature-job-tenant-boundary", `Job ${job.className} in Feature ${spec.name} must declare tenantScope: "required".`);
322
+ }
323
+ }
324
+ }
280
325
  const commands = module.commands.filter((command) => command.className === transition.command || command.name === transition.command);
281
326
  const command = commands[0];
282
327
  if (transition.command && commands.length !== 1) {
@@ -291,23 +336,59 @@ function validateFeatureSpec(spec, module) {
291
336
  error("feature-governance-drift", `Transition ${transition.name} ${key} differs from command ${command.className}.`);
292
337
  }
293
338
  }
339
+ if (transition.persistence) {
340
+ if (transition.persistence.boundary === "database" && command.transaction !== "required") {
341
+ error("feature-persistence-drift", `Database transition ${transition.name} requires command ${command.className} to declare transaction: "required".`);
342
+ }
343
+ if (command.tenantScope !== "required") {
344
+ error("feature-command-tenant-boundary", `Command ${command.className} in Feature ${spec.name} must declare tenantScope: "required".`);
345
+ }
346
+ if (transition.persistence.boundary === "external" && command.transaction !== "none") {
347
+ error("feature-persistence-drift", `External transition ${transition.name} requires command ${command.className} to declare transaction: "none".`);
348
+ }
349
+ if (command.idempotency !== "required" || !command.audit) {
350
+ error("feature-persistence-drift", `Persistent transition ${transition.name} requires command ${command.className} to declare idempotency and audit.`);
351
+ }
352
+ }
353
+ }
354
+ if (transition.job) {
355
+ const jobs = (module.jobs ?? []).filter((job) => job.className === transition.job || job.name === transition.job);
356
+ if (jobs.length !== 1) {
357
+ error("feature-job-unresolved", `Transition ${transition.name} must reference exactly one Job in module ${module.name}.`);
358
+ } else {
359
+ const job = jobs[0];
360
+ if (job.tenantScope !== "required") {
361
+ error("feature-job-tenant-boundary", `Job ${job.className} in Feature ${spec.name} must declare tenantScope: "required".`);
362
+ }
363
+ if (job.idempotency !== "required") {
364
+ error("feature-job-idempotency", `Job ${job.className} in Feature ${spec.name} must declare idempotency: "required".`);
365
+ }
366
+ if (job.mode === undefined) {
367
+ error("feature-job-mode", `Job ${job.className} in Feature ${spec.name} must declare mode: "task" or "workflow".`);
368
+ }
369
+ if (job.input === undefined || job.schemaKinds?.input !== "declared") {
370
+ error("feature-job-contract", `Job ${job.className} in Feature ${spec.name} must declare a non-opaque input schema.`);
371
+ }
372
+ const provider = module.providers.find((candidate) => candidate.token === job.className || candidate.useClass === job.className);
373
+ if (job.tenantScope === "required" && provider && !provider.deps.some((token) => isJobContextToken(token))) {
374
+ error("feature-job-context", `Job ${job.className} in Feature ${spec.name} must inject JOB_CONTEXT for trusted tenant execution.`);
375
+ }
376
+ }
294
377
  }
295
378
  if (transition.route) {
296
379
  const routes = module.controllers.flatMap((controller) => controller.routes.filter((route) => `${route.method} ${joinRoutePaths(controller.path, route.path)}` === transition.route));
297
380
  if (routes.length !== 1) {
298
381
  error("feature-route-unresolved", `Transition ${transition.name} must reference exactly one route '${transition.route}' in module ${module.name}.`);
299
- } else if (command && routes[0]?.command !== command.className) {
382
+ } else if (command && transition.routeBinding !== "controller" && routes[0]?.command !== command.className && routes[0]?.command !== command.name) {
300
383
  error("feature-route-drift", `Route ${transition.route} is not bound to ${command.className}.`);
384
+ } else if (transition.routeBinding === "controller" && routes[0]?.command !== undefined) {
385
+ error("feature-route-drift", `Controller-bound route ${transition.route} must not also declare a framework command binding.`);
301
386
  }
302
387
  }
303
388
  }
304
389
  return diagnostics;
305
390
  }
306
391
  function generateFeatureSource(spec) {
307
- const errors = validateFeatureSpec(spec);
308
- if (errors.length)
309
- throw new Error(errors.map((error) => error.message).join(`
310
- `));
311
392
  if (spec.transitions.some((transition) => !transition.permission)) {
312
393
  throw new Error("Spec-to-Code requires an explicit permission for every transition.");
313
394
  }
@@ -315,13 +396,34 @@ function generateFeatureSource(spec) {
315
396
  ...transition,
316
397
  command: `Transition${index + 1}Command`
317
398
  }));
399
+ const errors = validateFeatureSpec({ ...spec, transitions });
400
+ if (errors.length)
401
+ throw new Error(errors.map((error) => error.message).join(`
402
+ `));
318
403
  if (transitions.some((transition) => transition.route)) {
319
404
  throw new Error("Generate the command slice first; existing HTTP routes require explicit schema and handler implementations.");
320
405
  }
321
- const sourceSpec = { name: spec.name, states: spec.states, transitions: Object.fromEntries(transitions.map((transition) => {
322
- const { name, ...options } = transition;
323
- return [name, options];
324
- })) };
406
+ const sourceSpec = {
407
+ name: spec.name,
408
+ states: spec.states,
409
+ tenantField: spec.tenantField ?? "tenant_id",
410
+ transitions: Object.fromEntries(transitions.map((transition) => {
411
+ const { name, ...options } = transition;
412
+ const persistence = transition.persistence ?? {
413
+ boundary: "database",
414
+ optimisticLock: "required",
415
+ versionField: "version",
416
+ tenantField: "tenant_id",
417
+ stateField: "state",
418
+ receipt: "required",
419
+ reconciliation: "none"
420
+ };
421
+ return [name, {
422
+ ...options,
423
+ persistence
424
+ }];
425
+ }))
426
+ };
325
427
  return [
326
428
  'import { Command, defineFeatureSlice, defineFeatureSpec } from "@supacloud/app";',
327
429
  "",
@@ -332,9 +434,10 @@ function generateFeatureSource(spec) {
332
434
  `@Command(${JSON.stringify({
333
435
  name: `${spec.name}.${transition.name}`,
334
436
  permission: transition.permission,
335
- transaction: transition.transaction ?? "none",
336
- idempotency: transition.idempotency ?? "none",
337
- audit: transition.audit
437
+ transaction: transition.transaction ?? (transition.persistence?.boundary === "external" ? "none" : "required"),
438
+ idempotency: transition.idempotency ?? "required",
439
+ audit: transition.audit ?? `${spec.name}.${transition.name}`,
440
+ tenantScope: "required"
338
441
  })})`,
339
442
  `export class ${transition.command} {`,
340
443
  ` execute(state: FeatureState): never {`,
@@ -521,13 +624,17 @@ function validateGraph(graph, options = false) {
521
624
  handler: route.handler,
522
625
  ...route.redirectTo === undefined ? {} : { redirectTo: route.redirectTo }
523
626
  });
524
- if (route.command && !module.commands.some((command) => command.className === route.command)) {
525
- error("route-command-unresolved", `路由 ${key} 绑定的 command 类 ${route.command} 未在模块 ${module.name} 声明`, controller.file);
627
+ const featureTransition = featureTransitionForRoute(module, controller, route);
628
+ const effectiveCommandRef = route.command ?? featureTransition?.command;
629
+ const effectiveCommand = effectiveCommandRef === undefined ? undefined : module.commands.find((command) => command.className === effectiveCommandRef || command.name === effectiveCommandRef);
630
+ const effectiveIdempotency = route.idempotency ?? featureTransition?.idempotency;
631
+ if (route.command && !effectiveCommand) {
632
+ error("route-command-unresolved", `路由 ${key} 绑定的 command ${route.command} 未在模块 ${module.name} 声明`, controller.file);
526
633
  }
527
- if ((route.method === "GET" || route.method === "HEAD") && route.command) {
528
- const boundCommand = module.commands.find((c) => c.className === route.command);
634
+ if ((route.method === "GET" || route.method === "HEAD") && effectiveCommand) {
635
+ const boundCommand = effectiveCommand;
529
636
  if (boundCommand && boundCommand.transaction === "required") {
530
- error("command-transaction-readonly", `GET route '${route.path}' in '${controller.className}.${route.handler}' binds mutating command '${route.command}' with transaction: 'required'. Mutating transactions are not permitted on read-only HTTP GET requests.`, controller.file, undefined, `Use POST, PUT, or PATCH for mutating command routes, or set transaction: 'none'.`);
637
+ error("command-transaction-readonly", `GET route '${route.path}' in '${controller.className}.${route.handler}' binds mutating command '${boundCommand.name}' with transaction: 'required'. Mutating transactions are not permitted on read-only HTTP GET requests.`, controller.file, undefined, `Use POST, PUT, or PATCH for mutating command routes, or set transaction: 'none'.`);
531
638
  }
532
639
  }
533
640
  if (typeof options === "object" && options.allowRouteCommandBindings === false && route.command) {
@@ -598,6 +705,9 @@ function validateGraph(graph, options = false) {
598
705
  if (route.allowDeleteBody && (route.method !== "DELETE" || !route.body)) {
599
706
  error("invalid-delete-body-opt-in", `Route ${controller.className}.${route.handler} allowDeleteBody requires DELETE and an explicit body schema.`, controller.file);
600
707
  }
708
+ if (effectiveIdempotency === "required" && (route.method === "GET" || route.method === "HEAD" || route.method === "OPTIONS")) {
709
+ error("invalid-route-idempotency", `Route ${controller.className}.${route.handler} cannot require idempotency on HTTP ${route.method}.`, controller.file, undefined, `Use idempotency: "required" only on a mutation route.`);
710
+ }
601
711
  if (route.parse === "none" && (route.body || route.hasBodyBinding || route.contract?.body !== "domain" || !route.contract.evidence?.trim())) {
602
712
  error("invalid-raw-body-contract", `Route ${controller.className}.${route.handler} parse: "none" requires domain body ownership and evidence, without a parsed body schema or @Body binding.`, controller.file);
603
713
  }
@@ -802,13 +912,40 @@ function validateGraph(graph, options = false) {
802
912
  permission: `${module.name}.${command.name}`
803
913
  });
804
914
  }
915
+ if (command.rpc && command.branches) {
916
+ error("command-rpc-branches-conflict", `Command ${command.name} cannot declare both rpc and branches.`, module.file, module.line);
917
+ }
918
+ const branchAdapters = command.branches ?? [];
919
+ for (const branch of branchAdapters) {
920
+ if (typeof options !== "object" || !Object.hasOwn(options.commandCapabilities?.rpc ?? {}, branch.rpc)) {
921
+ error("command-rpc-unavailable", `Command ${command.name} requires configured RPC adapter '${branch.rpc}'.`, module.file, module.line);
922
+ }
923
+ }
805
924
  if (command.rpc && (typeof options !== "object" || !Object.hasOwn(options.commandCapabilities?.rpc ?? {}, command.rpc))) {
806
925
  error("command-rpc-unavailable", `Command ${command.name} requires configured RPC adapter '${command.rpc}'.`, module.file, module.line);
807
926
  }
808
927
  if (typeof options === "object" && options.commandCapabilities) {
809
928
  const hostCaps = options.commandCapabilities;
810
929
  const rpcCaps = command.rpc && Object.hasOwn(hostCaps.rpc ?? {}, command.rpc) ? hostCaps.rpc?.[command.rpc] : undefined;
811
- 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)) {
930
+ const branchCaps = branchAdapters.map((branch) => ({
931
+ branch,
932
+ capabilities: Object.hasOwn(hostCaps.rpc ?? {}, branch.rpc) ? hostCaps.rpc?.[branch.rpc] : undefined
933
+ }));
934
+ for (const { branch, capabilities } of branchCaps) {
935
+ if (capabilities?.boundary === "external" && (branch.transaction === "required" || capabilities.transaction === true)) {
936
+ 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.");
937
+ }
938
+ if (branch.audit && capabilities?.audit !== true) {
939
+ error("command-audit-unsupported", `Command ${command.name} branch '${branch.when}' declares audit, but runtime audit persistence is unavailable.`, module.file, module.line);
940
+ }
941
+ if (branch.idempotency === "required" && capabilities?.idempotency !== true) {
942
+ error("command-idempotency-unsupported", `Command ${command.name} branch '${branch.when}' declares idempotency, but receipt persistence is unavailable.`, module.file, module.line);
943
+ }
944
+ if (branch.transaction === "required" && capabilities?.transaction !== true) {
945
+ error("command-transaction-unsupported", `Command ${command.name} branch '${branch.when}' declares transaction, but transaction support is unavailable.`, module.file, module.line);
946
+ }
947
+ }
948
+ 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)) {
812
949
  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.");
813
950
  }
814
951
  if (rpcCaps?.boundary === "external" && (command.transaction === "required" || rpcCaps.transaction === true)) {
@@ -1245,6 +1382,7 @@ var init_validate = __esm(() => {
1245
1382
  "command-transaction-unsupported": { code: "SC4026", docsUrl: "https://supacloud.dev/errors/SC4026" },
1246
1383
  "command-reconcile-required": { code: "SC4028", docsUrl: "https://supacloud.dev/errors/SC4028" },
1247
1384
  "invalid-route-contract": { code: "SC3020", docsUrl: "https://supacloud.dev/errors/SC3020" },
1385
+ "invalid-route-idempotency": { code: "SC3028", docsUrl: "https://supacloud.dev/errors/SC3028" },
1248
1386
  "route-contract-required": { code: "SC3021", docsUrl: "https://supacloud.dev/errors/SC3021" },
1249
1387
  "route-contract-unverified": { code: "SC3022", docsUrl: "https://supacloud.dev/errors/SC3022" },
1250
1388
  "route-contract-evidence-required": { code: "SC3023", docsUrl: "https://supacloud.dev/errors/SC3023" },
@@ -1284,6 +1422,18 @@ var init_validate = __esm(() => {
1284
1422
  "invalid-feature-transition": { code: "SC6103", docsUrl: "https://supacloud.dev/errors/SC6103" },
1285
1423
  "feature-command-unresolved": { code: "SC6104", docsUrl: "https://supacloud.dev/errors/SC6104" },
1286
1424
  "feature-governance-drift": { code: "SC6105", docsUrl: "https://supacloud.dev/errors/SC6105" },
1425
+ "feature-persistence-invalid": { code: "SC6110", docsUrl: "https://supacloud.dev/errors/SC6110" },
1426
+ "feature-persistence-drift": { code: "SC6111", docsUrl: "https://supacloud.dev/errors/SC6111" },
1427
+ "feature-tenant-boundary-invalid": { code: "SC6112", docsUrl: "https://supacloud.dev/errors/SC6112" },
1428
+ "feature-tenant-boundary-drift": { code: "SC6113", docsUrl: "https://supacloud.dev/errors/SC6113" },
1429
+ "feature-query-tenant-boundary": { code: "SC6114", docsUrl: "https://supacloud.dev/errors/SC6114" },
1430
+ "feature-job-tenant-boundary": { code: "SC6115", docsUrl: "https://supacloud.dev/errors/SC6115" },
1431
+ "feature-command-tenant-boundary": { code: "SC6116", docsUrl: "https://supacloud.dev/errors/SC6116" },
1432
+ "feature-job-unresolved": { code: "SC6117", docsUrl: "https://supacloud.dev/errors/SC6117" },
1433
+ "feature-job-idempotency": { code: "SC6118", docsUrl: "https://supacloud.dev/errors/SC6118" },
1434
+ "feature-job-mode": { code: "SC6119", docsUrl: "https://supacloud.dev/errors/SC6119" },
1435
+ "feature-job-contract": { code: "SC6120", docsUrl: "https://supacloud.dev/errors/SC6120" },
1436
+ "feature-job-context": { code: "SC6121", docsUrl: "https://supacloud.dev/errors/SC6121" },
1287
1437
  "feature-route-unresolved": { code: "SC6106", docsUrl: "https://supacloud.dev/errors/SC6106" },
1288
1438
  "feature-route-drift": { code: "SC6107", docsUrl: "https://supacloud.dev/errors/SC6107" },
1289
1439
  "invalid-feature-spec": { code: "SC6108", docsUrl: "https://supacloud.dev/errors/SC6108" }
@@ -1294,6 +1444,8 @@ var init_validate = __esm(() => {
1294
1444
  function buildContractManifest(graph, artifacts) {
1295
1445
  const commands = [];
1296
1446
  const queries = [];
1447
+ const jobs = [];
1448
+ const features = [];
1297
1449
  const routes = [];
1298
1450
  const permissions = new Set;
1299
1451
  const rpc = [];
@@ -1305,27 +1457,107 @@ function buildContractManifest(graph, artifacts) {
1305
1457
  permissions.add(command.permission);
1306
1458
  if (command.rpc)
1307
1459
  rpc.push({ command: command.name, adapter: command.rpc });
1460
+ for (const branch of command.branches ?? []) {
1461
+ rpc.push({ command: `${command.name} [${branch.when}]`, adapter: branch.rpc });
1462
+ }
1308
1463
  if (command.audit)
1309
1464
  events.push({ name: command.audit, source: "command.audit", command: command.name });
1465
+ for (const branch of command.branches ?? []) {
1466
+ if (branch.audit)
1467
+ events.push({ name: branch.audit, source: "command.branch.audit", command: command.name });
1468
+ }
1310
1469
  commands.push({
1311
1470
  name: command.name,
1312
1471
  className: command.className,
1313
1472
  module: module.name,
1314
1473
  ...command.permission === undefined ? {} : { permission: command.permission },
1315
1474
  ...command.rpc === undefined ? {} : { rpc: command.rpc },
1475
+ ...command.branches === undefined ? {} : {
1476
+ branches: command.branches.map((branch) => ({
1477
+ when: branch.when,
1478
+ adapter: branch.rpc,
1479
+ transaction: branch.transaction,
1480
+ idempotency: branch.idempotency,
1481
+ ...branch.audit === undefined ? {} : { auditEvent: branch.audit },
1482
+ ...branch.boundary === undefined ? {} : { boundary: branch.boundary }
1483
+ }))
1484
+ },
1316
1485
  transaction: command.transaction,
1317
1486
  idempotency: command.idempotency,
1487
+ ...command.tenantScope === undefined ? {} : { tenantScope: command.tenantScope },
1318
1488
  ...command.audit === undefined ? {} : { auditEvent: command.audit }
1319
1489
  });
1320
1490
  }
1321
1491
  for (const query of module.queries) {
1322
- queries.push({ name: query.name, className: query.className, module: module.name });
1492
+ queries.push({
1493
+ name: query.name,
1494
+ className: query.className,
1495
+ module: module.name,
1496
+ ...query.tenantScope === undefined ? {} : { tenantScope: query.tenantScope }
1497
+ });
1498
+ }
1499
+ for (const job of module.jobs ?? []) {
1500
+ jobs.push({
1501
+ name: job.name,
1502
+ className: job.className,
1503
+ module: module.name,
1504
+ ...job.input === undefined ? {} : { input: job.input },
1505
+ ...job.schemaKinds?.input === undefined ? {} : { inputKind: job.schemaKinds.input },
1506
+ ...job.output === undefined ? {} : { output: job.output },
1507
+ ...job.schemaKinds?.output === undefined ? {} : { outputKind: job.schemaKinds.output },
1508
+ ...job.mode === undefined ? {} : { mode: job.mode },
1509
+ ...job.idempotency === undefined ? {} : { idempotency: job.idempotency },
1510
+ ...job.tenantScope === undefined ? {} : { tenantScope: job.tenantScope }
1511
+ });
1512
+ }
1513
+ if (module.featureSpec) {
1514
+ features.push({
1515
+ name: module.featureSpec.name,
1516
+ states: [...module.featureSpec.states],
1517
+ ...module.featureSpec.tenantField === undefined ? {} : { tenantField: module.featureSpec.tenantField },
1518
+ transitions: module.featureSpec.transitions.map((transition) => {
1519
+ const job = transition.job === undefined ? undefined : (module.jobs ?? []).find((candidate) => candidate.className === transition.job || candidate.name === transition.job);
1520
+ return {
1521
+ name: transition.name,
1522
+ from: transition.from,
1523
+ to: transition.to,
1524
+ ...transition.permission === undefined ? {} : { permission: transition.permission },
1525
+ ...transition.command === undefined ? {} : { command: transition.command },
1526
+ ...transition.route === undefined ? {} : { route: transition.route },
1527
+ ...transition.routeBinding === undefined ? {} : { routeBinding: transition.routeBinding },
1528
+ ...transition.transaction === undefined ? {} : { transaction: transition.transaction },
1529
+ ...transition.idempotency === undefined ? {} : { idempotency: transition.idempotency },
1530
+ ...transition.audit === undefined ? {} : { audit: transition.audit },
1531
+ ...transition.job === undefined ? {} : { job: transition.job },
1532
+ ...transition.invalidateTags === undefined ? {} : { invalidateTags: [...transition.invalidateTags] },
1533
+ ...job === undefined ? {} : {
1534
+ jobDetails: {
1535
+ name: job.name,
1536
+ className: job.className,
1537
+ ...job.input === undefined ? {} : { input: job.input },
1538
+ ...job.schemaKinds?.input === undefined ? {} : { inputKind: job.schemaKinds.input },
1539
+ ...job.output === undefined ? {} : { output: job.output },
1540
+ ...job.schemaKinds?.output === undefined ? {} : { outputKind: job.schemaKinds.output },
1541
+ ...job.mode === undefined ? {} : { mode: job.mode },
1542
+ ...job.idempotency === undefined ? {} : { idempotency: job.idempotency },
1543
+ ...job.tenantScope === undefined ? {} : { tenantScope: job.tenantScope }
1544
+ }
1545
+ },
1546
+ ...transition.persistence === undefined ? {} : { persistence: transition.persistence }
1547
+ };
1548
+ })
1549
+ });
1323
1550
  }
1324
1551
  for (const controller of module.controllers) {
1325
1552
  for (const route of controller.routes) {
1326
- const command = route.command ? module.commands.find((candidate) => candidate.className === route.command) : undefined;
1327
- if (command?.permission)
1328
- permissions.add(command.permission);
1553
+ const command = route.command ? module.commands.find((candidate) => candidate.className === route.command || candidate.name === route.command) : undefined;
1554
+ const featureTransition = featureTransitionForRoute(module, controller, route);
1555
+ const featureCommand = featureTransition?.command === undefined ? undefined : module.commands.find((candidate) => candidate.className === featureTransition.command || candidate.name === featureTransition.command);
1556
+ const governedCommand = command ?? featureCommand;
1557
+ const effectiveCommand = route.command ?? featureTransition?.command;
1558
+ const idempotency = route.idempotency ?? command?.idempotency ?? featureTransition?.idempotency;
1559
+ if (governedCommand?.permission)
1560
+ permissions.add(governedCommand.permission);
1329
1561
  if (route.contract?.evidence)
1330
1562
  fixtures.add(route.contract.evidence);
1331
1563
  routes.push({
@@ -1334,8 +1566,10 @@ function buildContractManifest(graph, artifacts) {
1334
1566
  module: module.name,
1335
1567
  controller: controller.className,
1336
1568
  handler: route.handler,
1337
- ...route.command === undefined ? {} : { command: route.command },
1338
- ...command?.permission === undefined ? {} : { permission: command.permission },
1569
+ ...effectiveCommand === undefined ? {} : { command: effectiveCommand },
1570
+ ...governedCommand?.permission === undefined ? {} : { permission: governedCommand.permission },
1571
+ ...idempotency === undefined ? {} : { idempotency },
1572
+ ...route.tags === undefined ? {} : { tags: [...route.tags] },
1339
1573
  requestSchemas: Object.fromEntries(["body", "params", "query", "headers", "cookie"].flatMap((key) => route[key] === undefined ? [] : [[key, route[key]]])),
1340
1574
  ...route.response === undefined ? {} : { responseSchema: route.response },
1341
1575
  ...route.responses === undefined ? {} : {
@@ -1350,6 +1584,8 @@ function buildContractManifest(graph, artifacts) {
1350
1584
  }
1351
1585
  commands.sort((left, right) => left.name.localeCompare(right.name));
1352
1586
  queries.sort((left, right) => left.name.localeCompare(right.name));
1587
+ jobs.sort((left, right) => left.name.localeCompare(right.name));
1588
+ features.sort((left, right) => left.name.localeCompare(right.name));
1353
1589
  routes.sort((left, right) => `${left.method} ${left.path}`.localeCompare(`${right.method} ${right.path}`));
1354
1590
  rpc.sort((left, right) => left.command.localeCompare(right.command));
1355
1591
  events.sort((left, right) => left.name.localeCompare(right.name));
@@ -1357,6 +1593,8 @@ function buildContractManifest(graph, artifacts) {
1357
1593
  version: 1,
1358
1594
  commands,
1359
1595
  queries,
1596
+ jobs,
1597
+ features,
1360
1598
  routes,
1361
1599
  permissions: [...permissions].sort(),
1362
1600
  rpc,
@@ -1370,7 +1608,9 @@ function buildContractManifest(graph, artifacts) {
1370
1608
  }
1371
1609
  };
1372
1610
  }
1373
- var init_contract_manifest = () => {};
1611
+ var init_contract_manifest = __esm(() => {
1612
+ init_feature();
1613
+ });
1374
1614
 
1375
1615
  // src/generate.ts
1376
1616
  import { createHash as createHash4 } from "node:crypto";
@@ -1504,6 +1744,7 @@ function renderApplication(graph, options) {
1504
1744
  name: command.name,
1505
1745
  permission: command.permission ?? null,
1506
1746
  rpc: command.rpc ?? null,
1747
+ branches: command.branches ?? [],
1507
1748
  transaction: command.transaction ?? null,
1508
1749
  audit: command.audit ?? null,
1509
1750
  idempotency: command.idempotency ?? null
@@ -1781,6 +2022,9 @@ class ModuleGenerator {
1781
2022
  return "[]";
1782
2023
  const items = this.module.controllers.map((controller) => {
1783
2024
  const routes = controller.routes.map((route) => {
2025
+ const command = route.command === undefined ? undefined : this.module.commands.find((candidate) => candidate.className === route.command || candidate.name === route.command);
2026
+ const featureTransition = featureTransitionForRoute(this.module, controller, route);
2027
+ const idempotency = route.idempotency ?? command?.idempotency ?? featureTransition?.idempotency;
1784
2028
  const responseEntries = normalizedResponseEntries(route.responses);
1785
2029
  const hasResponseMap = responseEntries.length > 0;
1786
2030
  const fields = [
@@ -1807,6 +2051,9 @@ class ModuleGenerator {
1807
2051
  if (route.effect !== undefined) {
1808
2052
  fields.push(`effect: ${JSON.stringify(route.effect)}`);
1809
2053
  }
2054
+ if (idempotency !== undefined) {
2055
+ fields.push(`idempotency: ${JSON.stringify(idempotency)}`);
2056
+ }
1810
2057
  if (route.command)
1811
2058
  fields.push(`command: ${JSON.stringify(route.command)}`);
1812
2059
  if (route.guards && route.guards.length > 0) {
@@ -1930,7 +2177,9 @@ ${indent(item, 2)}`).join(",")}
1930
2177
  `transaction: ${JSON.stringify(command.transaction)}`,
1931
2178
  ...command.audit ? [`audit: ${JSON.stringify(command.audit)}`] : [],
1932
2179
  `idempotency: ${JSON.stringify(command.idempotency)}`,
2180
+ ...command.tenantScope ? [`tenantScope: ${JSON.stringify(command.tenantScope)}`] : [],
1933
2181
  ...command.rpc ? [`rpc: ${JSON.stringify(command.rpc)}`] : [],
2182
+ ...command.branches && command.branches.length > 0 ? [`branches: ${JSON.stringify(command.branches)}`] : [],
1934
2183
  ...command.standalone ? ["standalone: true"] : [],
1935
2184
  ...command.aspects && command.aspects.length > 0 ? [`aspects: ${this.renderAspects(command.aspects)}`, `aspectPipeline: ${this.renderAspectPipeline(command.aspects)}`] : []
1936
2185
  ];
@@ -1963,6 +2212,8 @@ ${indent(item, 2)}`).join(",")}
1963
2212
  fields.push(`maxAttempts: ${job.maxAttempts}`);
1964
2213
  if (job.idempotency !== undefined)
1965
2214
  fields.push(`idempotency: ${JSON.stringify(job.idempotency)}`);
2215
+ if (job.tenantScope !== undefined)
2216
+ fields.push(`tenantScope: ${JSON.stringify(job.tenantScope)}`);
1966
2217
  if (job.aspects && job.aspects.length > 0) {
1967
2218
  fields.push(`aspects: ${this.renderAspects(job.aspects)}`);
1968
2219
  fields.push(`aspectPipeline: ${this.renderAspectPipeline(job.aspects)}`);
@@ -2285,7 +2536,18 @@ function renderClient(graph, options) {
2285
2536
  const usedTypeNames = new Set;
2286
2537
  const allRoutes = [];
2287
2538
  const allProcedures = [];
2288
- const commandByClassName = new Map(graph.modules.flatMap((module) => module.commands.map((command) => [command.className, command])));
2539
+ const commandByClassName = new Map(graph.modules.flatMap((module) => module.commands.flatMap((command) => [
2540
+ [command.className, command],
2541
+ [command.name, command]
2542
+ ])));
2543
+ const featureInvalidationTags = new Map;
2544
+ for (const module of graph.modules) {
2545
+ for (const transition of module.featureSpec?.transitions ?? []) {
2546
+ if (transition.route && transition.invalidateTags) {
2547
+ featureInvalidationTags.set(`${module.name}:${transition.route}`, [...transition.invalidateTags]);
2548
+ }
2549
+ }
2550
+ }
2289
2551
  let usesStatic = false;
2290
2552
  let usesValue = false;
2291
2553
  const uniqueTypeName = (candidate) => {
@@ -2363,6 +2625,13 @@ function renderClient(graph, options) {
2363
2625
  const requestType = `ClientRequestOptions<${paramsType}, ${queryType}, ${bodyType}, ${headersType}, ${cookieType}>${requiredRequestFields.length > 0 ? ` & { ${requiredRequestFields.join("; ")} }` : ""}`;
2364
2626
  routeTypes.push(`export type ${requestTypeName} = ${requestType};`);
2365
2627
  routeTypes.push(`export type ${responseTypeName} = ${responseType === "never" ? "unknown" : responseType};`);
2628
+ const command = route.command === undefined ? undefined : commandByClassName.get(route.command);
2629
+ const featureTransition = featureTransitionForRoute(module, controller, route);
2630
+ const featureCommand = featureTransition?.command === undefined ? undefined : commandByClassName.get(featureTransition.command);
2631
+ const governedCommand = command ?? featureCommand;
2632
+ const idempotencyMode = route.idempotency ?? command?.idempotency ?? featureTransition?.idempotency ?? "none";
2633
+ const transitionRoute = `${route.method} ${fullPath}`;
2634
+ const invalidationTags = featureInvalidationTags.get(`${module.name}:${transitionRoute}`);
2366
2635
  const routeFields = [
2367
2636
  `method: ${JSON.stringify(route.method)}`,
2368
2637
  `path: ${JSON.stringify(fullPath)}`,
@@ -2370,7 +2639,7 @@ function renderClient(graph, options) {
2370
2639
  `handler: ${JSON.stringify(route.handler)}`
2371
2640
  ];
2372
2641
  for (const [key, value] of [
2373
- ["command", route.command],
2642
+ ["command", route.command ?? featureTransition?.command],
2374
2643
  ["guards", route.guards],
2375
2644
  ["canMatch", route.canMatch],
2376
2645
  ["canDeactivate", route.canDeactivate],
@@ -2384,7 +2653,10 @@ function renderClient(graph, options) {
2384
2653
  ["title", route.title],
2385
2654
  ["data", route.data],
2386
2655
  ["parse", route.parse],
2387
- ["allowDeleteBody", route.allowDeleteBody]
2656
+ ["allowDeleteBody", route.allowDeleteBody],
2657
+ ["idempotency", idempotencyMode === "none" ? undefined : idempotencyMode],
2658
+ ["tags", route.tags],
2659
+ ["invalidateTags", invalidationTags]
2388
2660
  ]) {
2389
2661
  if (value !== undefined)
2390
2662
  routeFields.push(`${key}: ${JSON.stringify(value)}`);
@@ -2417,9 +2689,7 @@ function renderClient(graph, options) {
2417
2689
  const responseSchemaEntries = hasResponseMap ? Object.entries(responseSchemas).map(([status, ref]) => [status, ref.local]) : responseSchema ? [["200", responseSchema.local]] : [];
2418
2690
  const responseSchemaArgument = responseSchemaEntries.length > 0 ? `{ ${responseSchemaEntries.map(([status, local]) => `${JSON.stringify(status)}: ${local}`).join(", ")} }` : "undefined";
2419
2691
  const responseKindArgument = responseKind === undefined ? "undefined" : JSON.stringify(responseKind);
2420
- const command = route.command === undefined ? undefined : commandByClassName.get(route.command);
2421
2692
  const procedureKind = route.method === "GET" || route.method === "HEAD" ? "query" : "mutation";
2422
- const idempotencyMode = command?.idempotency ?? "none";
2423
2693
  const procedureMetadata = {
2424
2694
  key: `${controller.className}.${route.handler}`,
2425
2695
  method: route.method,
@@ -2427,7 +2697,9 @@ function renderClient(graph, options) {
2427
2697
  controller: controller.className,
2428
2698
  handler: route.handler,
2429
2699
  kind: procedureKind,
2430
- idempotency: idempotencyMode
2700
+ idempotency: idempotencyMode,
2701
+ ...route.tags === undefined ? {} : { tags: route.tags },
2702
+ ...invalidationTags === undefined ? {} : { invalidateTags: invalidationTags }
2431
2703
  };
2432
2704
  allProcedures.push({
2433
2705
  method: route.method,
@@ -2435,9 +2707,11 @@ function renderClient(graph, options) {
2435
2707
  controller: controller.className,
2436
2708
  handler: route.handler,
2437
2709
  kind: procedureKind,
2438
- ...command?.name === undefined ? {} : { command: command.name },
2439
- ...command?.permission === undefined ? {} : { permission: command.permission },
2440
- idempotency: idempotencyMode
2710
+ ...governedCommand?.name === undefined ? {} : { command: governedCommand.name },
2711
+ ...governedCommand?.permission === undefined ? {} : { permission: governedCommand.permission },
2712
+ idempotency: idempotencyMode,
2713
+ ...route.tags === undefined ? {} : { tags: [...route.tags] },
2714
+ ...invalidationTags === undefined ? {} : { invalidateTags: [...invalidationTags] }
2441
2715
  });
2442
2716
  routeMethods.push(`
2443
2717
  ${route.handler}: makeRoute<${requestTypeName}, ${responseType === "never" ? "never" : responseTypeName}, ${JSON.stringify(procedureKind)}, ${JSON.stringify(idempotencyMode)}>(${JSON.stringify(route.method)}, ${JSON.stringify(fullPath)}, ${responseSchemaArgument}, ${responseKindArgument}, ${route.parse === "none"}, ${JSON.stringify(procedureKind)}, ${JSON.stringify(idempotencyMode)}, ${JSON.stringify(procedureMetadata)}),`);
@@ -2491,6 +2765,8 @@ ${procedureMethods.join(`
2491
2765
  " handler: string;",
2492
2766
  " kind: Kind;",
2493
2767
  " idempotency: Idempotency;",
2768
+ " tags?: readonly string[];",
2769
+ " invalidateTags?: readonly string[];",
2494
2770
  "}",
2495
2771
  "",
2496
2772
  "export interface ProcedureExecutionOptions {",
@@ -2596,6 +2872,8 @@ ${procedureMethods.join(`
2596
2872
  " command?: string;",
2597
2873
  " permission?: string;",
2598
2874
  " idempotency: IdempotencyMode;",
2875
+ " tags?: readonly string[];",
2876
+ " invalidateTags?: readonly string[];",
2599
2877
  "}",
2600
2878
  "",
2601
2879
  ...routeTypes,
@@ -3095,7 +3373,10 @@ function renderOpenApi(graph, options) {
3095
3373
  }
3096
3374
  return schemaComponents.get(key);
3097
3375
  };
3098
- const commandByClass = new Map(graph.modules.flatMap((module) => module.commands.map((command) => [command.className, command])));
3376
+ const commandByClass = new Map(graph.modules.flatMap((module) => module.commands.flatMap((command) => [
3377
+ [command.className, command],
3378
+ [command.name, command]
3379
+ ])));
3099
3380
  for (const module of graph.modules) {
3100
3381
  for (const controller of module.controllers) {
3101
3382
  for (const route of controller.routes) {
@@ -3117,7 +3398,12 @@ function renderOpenApi(graph, options) {
3117
3398
  const operationBase = pascalName(`${module.name}_${controller.className}_${route.handler}`);
3118
3399
  const operationId = uniqueName(operationBase, operationIds);
3119
3400
  const command = route.command ? commandByClass.get(route.command) : undefined;
3120
- const requiresAuth = (route.guards?.length ?? 0) > 0 || command !== undefined;
3401
+ const featureTransition = featureTransitionForRoute(module, controller, route);
3402
+ const featureCommand = featureTransition?.command === undefined ? undefined : commandByClass.get(featureTransition.command);
3403
+ const governedCommand = command ?? featureCommand;
3404
+ const idempotencyMode = route.idempotency ?? command?.idempotency ?? featureTransition?.idempotency ?? "none";
3405
+ const permission = governedCommand?.permission ?? featureTransition?.permission;
3406
+ const requiresAuth = (route.guards?.length ?? 0) > 0 || permission !== undefined || governedCommand !== undefined;
3121
3407
  const responseKind = route.contract?.response;
3122
3408
  const responseContentType = responseKind === "binary" || responseKind === "stream" ? "application/octet-stream" : "application/json";
3123
3409
  const definition = {
@@ -3142,11 +3428,12 @@ function renderOpenApi(graph, options) {
3142
3428
  responseKind: responseKind ?? (route.nativeResponse ? "native-response" : undefined),
3143
3429
  ...route.parse === undefined ? {} : { parse: route.parse },
3144
3430
  ...route.allowDeleteBody === undefined ? {} : { allowDeleteBody: route.allowDeleteBody },
3431
+ ...idempotencyMode === "none" ? {} : { idempotency: idempotencyMode },
3145
3432
  module: module.name,
3146
3433
  controller: controller.className,
3147
3434
  handler: route.handler,
3148
- ...route.command === undefined ? {} : { command: route.command },
3149
- ...command?.permission === undefined ? {} : { permission: command.permission },
3435
+ ...route.command === undefined && featureTransition?.command === undefined ? {} : { command: route.command ?? featureTransition?.command },
3436
+ ...permission === undefined ? {} : { permission },
3150
3437
  ...route.guards && route.guards.length > 0 ? { guards: route.guards } : {},
3151
3438
  ...route.canMatch && route.canMatch.length > 0 ? { canMatch: route.canMatch } : {},
3152
3439
  ...route.canDeactivate && route.canDeactivate.length > 0 ? { canDeactivate: route.canDeactivate } : {},
@@ -3221,6 +3508,7 @@ function renderOpenApi(graph, options) {
3221
3508
  " handler: string;",
3222
3509
  " command?: string;",
3223
3510
  " permission?: string;",
3511
+ ' idempotency?: "required" | "none";',
3224
3512
  " guards?: readonly string[];",
3225
3513
  " canMatch?: readonly string[];",
3226
3514
  " canDeactivate?: readonly string[];",
@@ -3473,6 +3761,18 @@ function renderOpenApi(graph, options) {
3473
3761
  " for (const name of headersSchema ? schemaPropertyNames(headersSchema) : []) parameters.push({",
3474
3762
  ' name, in: "header", required: schemaRequired(headersSchema, name), schema: schemaProperty(headersSchema, name),',
3475
3763
  " });",
3764
+ ' if (definition.idempotency === "required") {',
3765
+ ' const keySchema = { type: "string", minLength: 1, maxLength: 512, pattern: "^[A-Za-z0-9._:-]{1,512}$" };',
3766
+ ' const declaredHeaders = parameters.filter((parameter) => parameter["in"] === "header" && typeof parameter["name"] === "string" && parameter["name"].toLowerCase() === "idempotency-key");',
3767
+ " const header = declaredHeaders[0];",
3768
+ " if (header) {",
3769
+ ' header["required"] = true;',
3770
+ ' header["schema"] = { allOf: [...declaredHeaders.map((parameter) => parameter["schema"]), keySchema] };',
3771
+ " for (const duplicate of declaredHeaders.slice(1)) parameters.splice(parameters.indexOf(duplicate), 1);",
3772
+ " } else {",
3773
+ ' parameters.push({ name: "Idempotency-Key", in: "header", required: true, schema: keySchema });',
3774
+ " }",
3775
+ " }",
3476
3776
  " const cookieSchema = definition.cookieSchema ? OPENAPI_SCHEMA_REGISTRY[definition.cookieSchema as keyof typeof OPENAPI_SCHEMA_REGISTRY] : undefined;",
3477
3777
  " for (const name of cookieSchema ? schemaPropertyNames(cookieSchema) : []) parameters.push({",
3478
3778
  ' name, in: "cookie", required: schemaRequired(cookieSchema, name), schema: schemaProperty(cookieSchema, name),',
@@ -3495,6 +3795,7 @@ function renderOpenApi(graph, options) {
3495
3795
  ' responses["500"] = { description: "Internal server error", content: { "application/json": { schema: { $ref: "#/components/schemas/SupaCloudError" } } } };',
3496
3796
  ' responses["200"] = { description: "Successful response" };',
3497
3797
  " }",
3798
+ ' if (definition.idempotency === "required" && (!definition.responseSchemas || !responseMapCoversStatus(definition.responseSchemas, 400))) responses["400"] = { description: "Idempotency-Key is missing or invalid", content: { "application/json": { schema: { $ref: "#/components/schemas/SupaCloudError" } } } };',
3498
3799
  " const operation: Record<string, unknown> = {",
3499
3800
  " operationId: definition.operationId, tags: [...definition.tags], summary: definition.summary, responses,",
3500
3801
  " ...(parameters.length === 0 ? {} : { parameters }),",
@@ -3505,6 +3806,7 @@ function renderOpenApi(graph, options) {
3505
3806
  " module: definition.module, controller: definition.controller, handler: definition.handler,",
3506
3807
  " ...(definition.command ? { command: definition.command } : {}),",
3507
3808
  " ...(definition.permission ? { permission: definition.permission } : {}),",
3809
+ " ...(definition.idempotency ? { idempotency: definition.idempotency } : {}),",
3508
3810
  " ...(definition.guards ? { guards: [...definition.guards] } : {}),",
3509
3811
  " ...(definition.canMatch ? { canMatch: [...definition.canMatch] } : {}),",
3510
3812
  " ...(definition.canDeactivate ? { canDeactivate: [...definition.canDeactivate] } : {}),",
@@ -3555,11 +3857,13 @@ function renderPermissions(graph) {
3555
3857
  }
3556
3858
  for (const controller of module.controllers) {
3557
3859
  for (const route of controller.routes) {
3558
- let perm;
3559
- if (route.command) {
3560
- const cmd = module.commands.find((c) => c.className === route.command);
3561
- perm = cmd?.permission;
3562
- }
3860
+ const featureTransition = featureTransitionForRoute(module, controller, route);
3861
+ const command = route.command === undefined ? undefined : module.commands.find((candidate) => candidate.className === route.command || candidate.name === route.command);
3862
+ const featureCommand = featureTransition?.command === undefined ? undefined : module.commands.find((candidate) => candidate.className === featureTransition.command || candidate.name === featureTransition.command);
3863
+ const governedCommand = command ?? featureCommand;
3864
+ const effectiveCommand = route.command ?? featureTransition?.command;
3865
+ const perm = governedCommand?.permission ?? featureTransition?.permission;
3866
+ const idempotency = route.idempotency ?? command?.idempotency ?? featureTransition?.idempotency;
3563
3867
  if (perm)
3564
3868
  permissions.add(perm);
3565
3869
  bindings.push({
@@ -3567,8 +3871,9 @@ function renderPermissions(graph) {
3567
3871
  path: joinRoutePaths(controller.path, route.path),
3568
3872
  controller: controller.className,
3569
3873
  handler: route.handler,
3570
- ...route.command === undefined ? {} : { command: route.command },
3571
- ...perm === undefined ? {} : { permission: perm }
3874
+ ...effectiveCommand === undefined ? {} : { command: effectiveCommand },
3875
+ ...perm === undefined ? {} : { permission: perm },
3876
+ ...idempotency === undefined ? {} : { idempotency }
3572
3877
  });
3573
3878
  }
3574
3879
  }
@@ -3594,6 +3899,7 @@ function renderPermissions(graph) {
3594
3899
  " handler: string;",
3595
3900
  " command?: string;",
3596
3901
  " permission?: string;",
3902
+ ' idempotency?: "required" | "none";',
3597
3903
  "}",
3598
3904
  "",
3599
3905
  "export const RoutePermissions: RoutePermissionBinding[] = " + JSON.stringify(bindings, null, 2) + ";",
@@ -3637,6 +3943,8 @@ var HEADER = "// GENERATED BY @supacloud/compiler — do not edit", INTERFACES =
3637
3943
  /** True when the handler returns a native Response rather than a framework value. */
3638
3944
  nativeResponse?: boolean;
3639
3945
  command?: string;
3946
+ /** Transport-level idempotency contract, independent of route command binding. */
3947
+ idempotency?: "required" | "none";
3640
3948
  guards?: string[];
3641
3949
  canMatch?: string[];
3642
3950
  canDeactivate?: string[];
@@ -3666,17 +3974,28 @@ var HEADER = "// GENERATED BY @supacloud/compiler — do not edit", INTERFACES =
3666
3974
 
3667
3975
  export interface CompiledCommand {
3668
3976
  rpc?: string;
3977
+ branches?: readonly CompiledCommandBranch[];
3669
3978
  className: string;
3670
3979
  name: string;
3671
3980
  permission: string;
3672
3981
  transaction: "required" | "none";
3673
3982
  audit?: string;
3674
3983
  idempotency: "required" | "none";
3984
+ tenantScope?: "required" | "none";
3675
3985
  standalone?: boolean;
3676
3986
  aspects?: CompiledAspect[];
3677
3987
  aspectPipeline?: CompiledAspectPipeline;
3678
3988
  }
3679
3989
 
3990
+ export interface CompiledCommandBranch {
3991
+ when: string;
3992
+ rpc: string;
3993
+ transaction?: "required" | "none" | string;
3994
+ audit?: string;
3995
+ idempotency?: "required" | "none" | string;
3996
+ boundary?: "database" | "external";
3997
+ }
3998
+
3680
3999
  export interface CompiledJob {
3681
4000
  className: string;
3682
4001
  name: string;
@@ -3688,6 +4007,7 @@ export interface CompiledJob {
3688
4007
  timeoutSec?: number;
3689
4008
  maxAttempts?: number;
3690
4009
  idempotency?: "required" | "none";
4010
+ tenantScope?: "required" | "none";
3691
4011
  aspects?: CompiledAspect[];
3692
4012
  aspectPipeline?: CompiledAspectPipeline;
3693
4013
  }
@@ -3862,6 +4182,7 @@ async function destroyServiceInstances(
3862
4182
  `;
3863
4183
  var init_generate = __esm(() => {
3864
4184
  init_contract_manifest();
4185
+ init_feature();
3865
4186
  });
3866
4187
 
3867
4188
  // src/route-contracts.ts
@@ -4715,7 +5036,9 @@ function createExecutionPlans(graph) {
4715
5036
  const aspects = (boundary, refs = []) => refs.map((ref, index) => `${boundary}.aspect[${index}]:${ref.name}`);
4716
5037
  return graph.modules.flatMap((module) => [
4717
5038
  ...module.controllers.flatMap((controller) => controller.routes.map((route) => {
4718
- const command = module.commands.find((item) => item.className === route.command);
5039
+ const featureTransition = featureTransitionForRoute(module, controller, route);
5040
+ const commandRef = route.command ?? featureTransition?.command;
5041
+ const command = module.commands.find((item) => item.className === commandRef || item.name === commandRef);
4719
5042
  const path = `${controller.path}/${route.path}`.replace(/\/+/g, "/");
4720
5043
  return {
4721
5044
  module: module.name,
@@ -4865,6 +5188,7 @@ function exportGraphDot(graph) {
4865
5188
  }
4866
5189
  var init_inspect = __esm(() => {
4867
5190
  init_route_contracts();
5191
+ init_feature();
4868
5192
  });
4869
5193
 
4870
5194
  // node_modules/typebox/build/system/memory/metrics.mjs
@@ -15432,6 +15756,7 @@ var init_application_development = __esm(() => {
15432
15756
  controller: text2,
15433
15757
  handler: text2,
15434
15758
  command: Optional(text2),
15759
+ idempotency: Optional(mode),
15435
15760
  aspects: texts,
15436
15761
  schemaKinds: Optional(_Object_({
15437
15762
  body: schemaKind,
@@ -15554,7 +15879,11 @@ function createApplicationDevelopmentContext(graph, options = {}) {
15554
15879
  for (const controller of module.controllers) {
15555
15880
  for (const route of controller.routes) {
15556
15881
  const candidates = module.commands.filter((command) => command.className === route.command || command.name === route.command);
15557
- const command = candidates.length === 1 ? candidates[0]?.name : undefined;
15882
+ const commandNode = candidates.length === 1 ? candidates[0] : undefined;
15883
+ const featureTransition = featureTransitionForRoute(module, controller, route);
15884
+ const featureCommandNode = featureTransition?.command === undefined ? undefined : module.commands.find((candidate) => candidate.className === featureTransition.command || candidate.name === featureTransition.command);
15885
+ const command = commandNode?.name ?? featureCommandNode?.name;
15886
+ const idempotency = route.idempotency ?? commandNode?.idempotency ?? featureTransition?.idempotency;
15558
15887
  const kinds = schemaKinds(route);
15559
15888
  routes.push({
15560
15889
  module: module.name,
@@ -15563,6 +15892,7 @@ function createApplicationDevelopmentContext(graph, options = {}) {
15563
15892
  controller: controller.className,
15564
15893
  handler: route.handler,
15565
15894
  ...command === undefined ? {} : { command },
15895
+ ...idempotency === undefined ? {} : { idempotency },
15566
15896
  aspects: (route.aspects ?? []).map((aspect) => aspect.name),
15567
15897
  ...kinds === undefined ? {} : { schemaKinds: kinds }
15568
15898
  });
@@ -15683,6 +16013,7 @@ var init_application_development2 = __esm(() => {
15683
16013
  init_application_development();
15684
16014
  init_inspect();
15685
16015
  init_execution_snapshot();
16016
+ init_feature();
15686
16017
  APPLICATION_DEVELOPMENT_LIMITS2 = APPLICATION_DEVELOPMENT_LIMITS;
15687
16018
  ApplicationDevelopmentError = class ApplicationDevelopmentError extends Error {
15688
16019
  code;
@@ -15784,7 +16115,7 @@ function parseDeliveryPlanResult(value) {
15784
16115
  throw new Error("Invalid delivery plan result.");
15785
16116
  return value;
15786
16117
  }
15787
- var objectOptions, reference2, references, kind2, isolation, DeliveryFilePathSchema, functionTarget, DeliveryOptionsSchema, DeliveryDiagnosticSchema, routeSchema, jobSchema, DeliveryTargetSchema, DeliveryPlanSchema, DeliveryPlanResultSchema, DeliveryConfigurationError;
16118
+ var objectOptions, reference2, references, kind2, isolation, mode2, DeliveryFilePathSchema, functionTarget, DeliveryOptionsSchema, DeliveryDiagnosticSchema, routeSchema, jobSchema, DeliveryTargetSchema, DeliveryPlanSchema, DeliveryPlanResultSchema, DeliveryConfigurationError;
15788
16119
  var init_delivery_schema = __esm(() => {
15789
16120
  init_build();
15790
16121
  init_value2();
@@ -15794,6 +16125,7 @@ var init_delivery_schema = __esm(() => {
15794
16125
  references = _Array_(reference2, { uniqueItems: true });
15795
16126
  kind2 = Union([Literal("api"), Literal("jobs"), Literal("webhook"), Literal("function")]);
15796
16127
  isolation = Union([Literal("shared"), Literal("process")]);
16128
+ mode2 = Union([Literal("required"), Literal("none")]);
15797
16129
  DeliveryFilePathSchema = String2({
15798
16130
  maxLength: 512,
15799
16131
  pattern: "^(?:[A-Za-z0-9_-][A-Za-z0-9_.-]*/)*[A-Za-z0-9_-][A-Za-z0-9_.-]*$"
@@ -15867,6 +16199,7 @@ var init_delivery_schema = __esm(() => {
15867
16199
  ]),
15868
16200
  path: String2(),
15869
16201
  command: Optional(String2()),
16202
+ idempotency: Optional(mode2),
15870
16203
  reason: Union([Literal("default-http"), Literal("explicit-module")])
15871
16204
  }, objectOptions);
15872
16205
  jobSchema = _Object_({
@@ -16125,7 +16458,12 @@ function parseDeliveryBuildManifest(value) {
16125
16458
  throw new Error("Invalid delivery object inventory.");
16126
16459
  }
16127
16460
  }
16128
- const routes = value.plan.targets.flatMap((target) => target.routes.map((route) => ({ method: route.method, path: route.path, target: target.name })));
16461
+ const routes = value.plan.targets.flatMap((target) => target.routes.map((route) => ({
16462
+ method: route.method,
16463
+ path: route.path,
16464
+ target: target.name,
16465
+ ...route.idempotency === undefined ? {} : { idempotency: route.idempotency }
16466
+ })));
16129
16467
  const jobs = value.plan.targets.flatMap((target) => target.jobs.map((job) => ({ name: job.name, target: target.name })));
16130
16468
  if (canonical2(value.routes) !== canonical2(routes) || canonical2(value.jobs) !== canonical2(jobs)) {
16131
16469
  throw new Error("Delivery mappings do not match planned ownership.");
@@ -16176,7 +16514,8 @@ var init_delivery_build_schema = __esm(() => {
16176
16514
  routes: _Array_(_Object_({
16177
16515
  method: String2(),
16178
16516
  path: String2(),
16179
- target: String2()
16517
+ target: String2(),
16518
+ idempotency: Optional(Union([Literal("required"), Literal("none")]))
16180
16519
  }, closed4)),
16181
16520
  jobs: _Array_(_Object_({ name: String2(), target: String2() }, closed4))
16182
16521
  }, closed4);
@@ -16219,7 +16558,7 @@ async function readDeliverySelection(manifestPath, target) {
16219
16558
  return { root, object, planned };
16220
16559
  }
16221
16560
  async function readVerifiedDeliveryFiles(root, object, target, selectedPaths) {
16222
- 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) {
16561
+ 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) {
16223
16562
  throw new Error("Artifact inventory exceeds limits.");
16224
16563
  }
16225
16564
  const objectRoot = join7(root, "objects", object.objectId);
@@ -16258,10 +16597,12 @@ async function readVerifiedDeliveryFiles(root, object, target, selectedPaths) {
16258
16597
  }
16259
16598
  return retained;
16260
16599
  }
16261
- var JSON_LIMIT = 1048576;
16600
+ var JSON_LIMIT = 1048576, DELIVERY_ARTIFACT_MAX_FILE_BYTES, DELIVERY_ARTIFACT_MAX_TOTAL_BYTES;
16262
16601
  var init_delivery_artifact = __esm(() => {
16263
16602
  init_delivery_build_schema();
16264
16603
  init_delivery_files();
16604
+ DELIVERY_ARTIFACT_MAX_FILE_BYTES = 500 * 1024 * 1024;
16605
+ DELIVERY_ARTIFACT_MAX_TOTAL_BYTES = 500 * 1024 * 1024;
16265
16606
  });
16266
16607
 
16267
16608
  // src/delivery-artifact.ts
@@ -17426,6 +17767,7 @@ var init_delivery_migration_plan = () => {};
17426
17767
 
17427
17768
  // node_modules/@supacloud/delivery/src/index.ts
17428
17769
  var init_src = __esm(() => {
17770
+ init_delivery_artifact();
17429
17771
  init_delivery_artifact();
17430
17772
  init_worker_execution();
17431
17773
  init_worker_execution();
@@ -18807,6 +19149,7 @@ async function analyzeProject(rootDir, include, cache, changedPaths, effectOptio
18807
19149
  if (meta && booleanProp(meta, "standalone")) {
18808
19150
  const permission = stringLiteralProp(meta, "permission");
18809
19151
  const rpc = checkedRpc(meta, ctx);
19152
+ const branches = checkedBranches(meta, ctx);
18810
19153
  const audit = stringLiteralProp(meta, "audit");
18811
19154
  const standaloneName = stringLiteralProp(meta, "name") ?? classInfo.decl.name?.text ?? name;
18812
19155
  const standaloneUses = parseInfraResourceUseRefs(infraResourceProp(meta, "uses", ctx), ctx, `command ${standaloneName}`, sourcePath(ctx.rootDir, classInfo.file), lineOf(classInfo.decl));
@@ -18816,6 +19159,7 @@ async function analyzeProject(rootDir, include, cache, changedPaths, effectOptio
18816
19159
  name: standaloneName,
18817
19160
  ...permission === undefined ? {} : { permission },
18818
19161
  ...rpc === undefined ? {} : { rpc },
19162
+ ...branches === undefined ? {} : { branches },
18819
19163
  transaction: checkedCommandMode(meta, "transaction", ctx, classInfo.decl.name?.text ?? name) ?? "none",
18820
19164
  ...audit === undefined ? {} : { audit },
18821
19165
  idempotency: checkedCommandMode(meta, "idempotency", ctx, classInfo.decl.name?.text ?? name) ?? "none",
@@ -19147,18 +19491,22 @@ function parseModule(candidate, nameByNode, ctx) {
19147
19491
  const aspects = parseAspectRefs(getProp(meta, "aspects"), ctx, `command ${cls.name?.text ?? "<anonymous>"}`);
19148
19492
  const permission = stringLiteralProp(meta, "permission");
19149
19493
  const rpc = checkedRpc(meta, ctx);
19494
+ const branches = checkedBranches(meta, ctx);
19150
19495
  const audit = stringLiteralProp(meta, "audit");
19151
19496
  const commandName = stringLiteralProp(meta, "name") ?? cls.name?.text ?? "<anonymous>";
19152
19497
  const uses = parseInfraResourceUseRefs(infraResourceProp(meta, "uses", ctx), ctx, `command ${commandName}`, sourcePath(ctx.rootDir, cls.getSourceFile().fileName), lineOf(cls));
19153
19498
  rejectUndeclaredResourceUses(uses, moduleResourceNames, ctx, `command ${commandName}`, sourcePath(ctx.rootDir, cls.getSourceFile().fileName), lineOf(cls));
19499
+ const tenantScope = commandModeProp(meta, "tenantScope");
19154
19500
  commands.push({
19155
19501
  className: cls.name?.text ?? "<anonymous>",
19156
19502
  name: commandName,
19157
19503
  ...permission === undefined ? {} : { permission },
19158
19504
  ...rpc === undefined ? {} : { rpc },
19505
+ ...branches === undefined ? {} : { branches },
19159
19506
  transaction: checkedCommandMode(meta, "transaction", ctx, cls.name?.text ?? "<anonymous>") ?? "none",
19160
19507
  ...audit === undefined ? {} : { audit },
19161
19508
  idempotency: checkedCommandMode(meta, "idempotency", ctx, cls.name?.text ?? "<anonymous>") ?? "none",
19509
+ ...tenantScope === undefined ? {} : { tenantScope },
19162
19510
  ...booleanProp(meta, "standalone") ? { standalone: true } : {},
19163
19511
  ...uses.length > 0 ? { uses } : {},
19164
19512
  ...aspects.length > 0 ? { aspects } : {}
@@ -19189,12 +19537,14 @@ function parseModule(candidate, nameByNode, ctx) {
19189
19537
  const contract = parseJobOptions(meta, className, ctx);
19190
19538
  const jobUses = parseInfraResourceUseRefs(infraResourceProp(meta, "uses", ctx), ctx, `job ${stringLiteralProp(meta, "name") ?? className}`, sourcePath(ctx.rootDir, cls.getSourceFile().fileName), lineOf(cls));
19191
19539
  rejectUndeclaredResourceUses(jobUses, moduleResourceNames, ctx, `job ${stringLiteralProp(meta, "name") ?? className}`, sourcePath(ctx.rootDir, cls.getSourceFile().fileName), lineOf(cls));
19540
+ const tenantScope = commandModeProp(meta, "tenantScope");
19192
19541
  jobs.push({
19193
19542
  className,
19194
19543
  name: stringLiteralProp(meta, "name") ?? className,
19195
19544
  serviceKey: camelName(provider?.token ?? className),
19196
19545
  scope,
19197
19546
  ...contract,
19547
+ ...tenantScope === undefined ? {} : { tenantScope },
19198
19548
  ...jobUses.length > 0 ? { uses: jobUses } : {},
19199
19549
  ...aspects.length > 0 ? { aspects } : {}
19200
19550
  });
@@ -19204,9 +19554,11 @@ function parseModule(candidate, nameByNode, ctx) {
19204
19554
  if (queryDec) {
19205
19555
  const meta = decoratorObjectArg(queryDec);
19206
19556
  if (meta) {
19557
+ const tenantScope = commandModeProp(meta, "tenantScope");
19207
19558
  queries.push({
19208
19559
  className: cls.name?.text ?? "<anonymous>",
19209
- name: stringLiteralProp(meta, "name") ?? cls.name?.text ?? "<anonymous>"
19560
+ name: stringLiteralProp(meta, "name") ?? cls.name?.text ?? "<anonymous>",
19561
+ ...tenantScope === undefined ? {} : { tenantScope }
19210
19562
  });
19211
19563
  }
19212
19564
  }
@@ -19261,6 +19613,7 @@ function parseFeatureSpec(input, ctx, seen = new Set) {
19261
19613
  if (!ts7.isObjectLiteralExpression(input))
19262
19614
  return invalid();
19263
19615
  const name = stringLiteralProp(input, "name");
19616
+ const tenantField = stringLiteralProp(input, "tenantField");
19264
19617
  const statesExpr = getProp(input, "states");
19265
19618
  const transitionObject = getProp(input, "transitions");
19266
19619
  if (!name || !statesExpr || !ts7.isArrayLiteralExpression(statesExpr) || statesExpr.elements.some((state) => !ts7.isStringLiteral(state)) || !transitionObject || !ts7.isObjectLiteralExpression(transitionObject) || input.properties.some((property) => !ts7.isPropertyAssignment(property))) {
@@ -19279,15 +19632,27 @@ function parseFeatureSpec(input, ctx, seen = new Set) {
19279
19632
  const options = property.initializer;
19280
19633
  const from = stringLiteralProp(options, "from");
19281
19634
  const to = stringLiteralProp(options, "to");
19282
- 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))) {
19635
+ 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))) {
19283
19636
  return invalid();
19284
19637
  }
19285
19638
  const permission = stringLiteralProp(options, "permission");
19286
19639
  const command = stringLiteralProp(options, "command");
19287
19640
  const route = stringLiteralProp(options, "route");
19641
+ const routeBinding = stringLiteralProp(options, "routeBinding");
19642
+ if (routeBinding !== undefined && routeBinding !== "command" && routeBinding !== "controller")
19643
+ return invalid();
19288
19644
  const transaction = commandModeProp(options, "transaction");
19289
19645
  const idempotency = commandModeProp(options, "idempotency");
19290
19646
  const audit = stringLiteralProp(options, "audit");
19647
+ const job = stringLiteralProp(options, "job");
19648
+ const invalidateTagsExpr = getProp(options, "invalidateTags");
19649
+ const invalidateTags = invalidateTagsExpr === undefined ? undefined : ts7.isArrayLiteralExpression(invalidateTagsExpr) && invalidateTagsExpr.elements.every((element) => ts7.isStringLiteral(element) && element.text.trim().length > 0) ? invalidateTagsExpr.elements.map((element) => element.text) : undefined;
19650
+ if (invalidateTagsExpr !== undefined && invalidateTags === undefined)
19651
+ return invalid();
19652
+ const persistenceExpr = getProp(options, "persistence");
19653
+ const persistence = persistenceExpr && ts7.isObjectLiteralExpression(persistenceExpr) ? parseFeaturePersistence(persistenceExpr) : undefined;
19654
+ if (persistenceExpr && !persistence)
19655
+ return invalid();
19291
19656
  transitions.push({
19292
19657
  name: propertyName(property.name),
19293
19658
  from,
@@ -19295,12 +19660,45 @@ function parseFeatureSpec(input, ctx, seen = new Set) {
19295
19660
  ...permission === undefined ? {} : { permission },
19296
19661
  ...command === undefined ? {} : { command },
19297
19662
  ...route === undefined ? {} : { route },
19663
+ ...routeBinding === undefined ? {} : { routeBinding },
19298
19664
  ...transaction === undefined ? {} : { transaction },
19299
19665
  ...idempotency === undefined ? {} : { idempotency },
19300
- ...audit === undefined ? {} : { audit }
19666
+ ...audit === undefined ? {} : { audit },
19667
+ ...job === undefined ? {} : { job },
19668
+ ...invalidateTags === undefined ? {} : { invalidateTags },
19669
+ ...persistence === undefined ? {} : { persistence }
19301
19670
  });
19302
19671
  }
19303
- return { name, states, transitions, file: sourcePath(ctx.rootDir, input.getSourceFile().fileName), line: lineOf(input) };
19672
+ return {
19673
+ name,
19674
+ states,
19675
+ ...tenantField === undefined ? {} : { tenantField },
19676
+ transitions,
19677
+ file: sourcePath(ctx.rootDir, input.getSourceFile().fileName),
19678
+ line: lineOf(input)
19679
+ };
19680
+ }
19681
+ function parseFeaturePersistence(input) {
19682
+ if (input.properties.some((property) => !ts7.isPropertyAssignment(property)))
19683
+ return;
19684
+ const boundary = stringLiteralProp(input, "boundary");
19685
+ const optimisticLock = commandModeProp(input, "optimisticLock");
19686
+ const versionField = stringLiteralProp(input, "versionField");
19687
+ const tenantField = stringLiteralProp(input, "tenantField");
19688
+ const stateField = stringLiteralProp(input, "stateField");
19689
+ const receipt = commandModeProp(input, "receipt");
19690
+ const reconciliation = commandModeProp(input, "reconciliation");
19691
+ if (boundary !== "database" && boundary !== "external" || optimisticLock !== "required" && optimisticLock !== "none" || receipt !== "required" && receipt !== "none" || reconciliation !== "required" && reconciliation !== "none")
19692
+ return;
19693
+ return {
19694
+ boundary,
19695
+ optimisticLock,
19696
+ ...versionField === undefined ? {} : { versionField },
19697
+ ...tenantField === undefined ? {} : { tenantField },
19698
+ ...stateField === undefined ? {} : { stateField },
19699
+ receipt,
19700
+ reconciliation
19701
+ };
19304
19702
  }
19305
19703
  function resolveStaticObjectLiteral(input, ctx, seen = new Set) {
19306
19704
  if (!input || seen.has(input))
@@ -19370,6 +19768,54 @@ function checkedCommandMode(object, property, ctx, command) {
19370
19768
  }
19371
19769
  return value;
19372
19770
  }
19771
+ function checkedRouteIdempotency(object, ctx, route) {
19772
+ const expression = getProp(object, "idempotency");
19773
+ const value = stringLiteralProp(object, "idempotency");
19774
+ if (expression && value !== "required" && value !== "none") {
19775
+ const file = sourcePath(ctx.rootDir, expression.getSourceFile().fileName);
19776
+ ctx.diagnostics.push({
19777
+ severity: "error",
19778
+ code: "invalid-route-idempotency",
19779
+ errorCode: "SC3028",
19780
+ docsUrl: "https://supacloud.dev/errors/SC3028",
19781
+ message: `${route}.idempotency must be the explicit literal "required" or "none".`,
19782
+ file,
19783
+ line: lineOf(expression),
19784
+ suggestion: `Choose the intended idempotency policy explicitly; use "required" for replayable mutations or "none" for routes without that contract.`
19785
+ });
19786
+ }
19787
+ return value === "required" || value === "none" ? value : undefined;
19788
+ }
19789
+ function checkedRouteTags(expression, ctx, route) {
19790
+ if (expression === undefined)
19791
+ return;
19792
+ if (!ts7.isArrayLiteralExpression(expression) || expression.elements.some((element) => !ts7.isStringLiteral(element) || element.text.trim().length === 0)) {
19793
+ ctx.diagnostics.push({
19794
+ severity: "error",
19795
+ code: "invalid-route-tags",
19796
+ errorCode: "SC3029",
19797
+ docsUrl: "https://supacloud.dev/errors/SC3029",
19798
+ message: `${route}.tags must be a static array of non-empty string literals.`,
19799
+ file: sourcePath(ctx.rootDir, expression.getSourceFile().fileName),
19800
+ line: lineOf(expression),
19801
+ suggestion: 'Declare stable cache tags explicitly, for example tags: ["cases.list"].'
19802
+ });
19803
+ return;
19804
+ }
19805
+ const tags = expression.elements.map((element) => element.text);
19806
+ if (new Set(tags).size !== tags.length) {
19807
+ ctx.diagnostics.push({
19808
+ severity: "error",
19809
+ code: "duplicate-route-tags",
19810
+ errorCode: "SC3030",
19811
+ docsUrl: "https://supacloud.dev/errors/SC3030",
19812
+ message: `${route}.tags must not contain duplicate values.`,
19813
+ file: sourcePath(ctx.rootDir, expression.getSourceFile().fileName),
19814
+ line: lineOf(expression)
19815
+ });
19816
+ }
19817
+ return tags;
19818
+ }
19373
19819
  function parseProvider(el, exportsSet, ctx) {
19374
19820
  const file = sourcePath(ctx.rootDir, el.getSourceFile().fileName);
19375
19821
  const line = lineOf(el);
@@ -20149,7 +20595,11 @@ function parseController(input, ctx) {
20149
20595
  if (optionsObject) {
20150
20596
  const parsePolicy = routePolicyProperty(optionsObject, "parse", ctx);
20151
20597
  const deletePolicy = routePolicyProperty(optionsObject, "allowDeleteBody", ctx);
20152
- if (parsePolicy.unresolved || deletePolicy.unresolved) {
20598
+ const idempotency = checkedRouteIdempotency(optionsObject, ctx, route.handler);
20599
+ if (idempotency !== undefined)
20600
+ route.idempotency = idempotency;
20601
+ const tagsPolicy = routePolicyProperty(optionsObject, "tags", ctx);
20602
+ if (parsePolicy.unresolved || deletePolicy.unresolved || tagsPolicy.unresolved) {
20153
20603
  ctx.diagnostics.push({
20154
20604
  severity: "error",
20155
20605
  code: "unresolved-route-body-policy",
@@ -20157,6 +20607,9 @@ function parseController(input, ctx) {
20157
20607
  message: `Route ${route.handler} body policy must be statically resolvable; dynamic spreads, computed keys and shorthand policies are unsupported.`
20158
20608
  });
20159
20609
  }
20610
+ const tags = checkedRouteTags(tagsPolicy.value, ctx, route.handler);
20611
+ if (tags !== undefined)
20612
+ route.tags = tags;
20160
20613
  const parse = parsePolicy.value;
20161
20614
  if (parse) {
20162
20615
  if (ts7.isStringLiteral(parse) && parse.text === "none")
@@ -20727,6 +21180,75 @@ function checkedRpc(meta, ctx) {
20727
21180
  });
20728
21181
  return;
20729
21182
  }
21183
+ function checkedBranches(meta, ctx) {
21184
+ const expression = getProp(meta, "branches");
21185
+ if (!expression)
21186
+ return;
21187
+ if (!ts7.isArrayLiteralExpression(expression)) {
21188
+ ctx.diagnostics.push({
21189
+ severity: "error",
21190
+ code: "invalid-command-branches",
21191
+ file: sourcePath(ctx.rootDir, expression.getSourceFile().fileName),
21192
+ line: lineOf(expression),
21193
+ message: "Command branches must be an explicit object array.",
21194
+ suggestion: "Declare each branch as { when, rpc, transaction, audit, idempotency, boundary }."
21195
+ });
21196
+ return;
21197
+ }
21198
+ const branches = [];
21199
+ for (const item of expression.elements) {
21200
+ if (!ts7.isObjectLiteralExpression(item)) {
21201
+ ctx.diagnostics.push({
21202
+ severity: "error",
21203
+ code: "invalid-command-branches",
21204
+ file: sourcePath(ctx.rootDir, item.getSourceFile().fileName),
21205
+ line: lineOf(item),
21206
+ message: "Command branches entries must be object literals."
21207
+ });
21208
+ continue;
21209
+ }
21210
+ const when = stringLiteralProp(item, "when");
21211
+ const rpc = stringLiteralProp(item, "rpc");
21212
+ const transaction = checkedCommandMode(item, "transaction", ctx, "command branch") ?? "none";
21213
+ const audit = stringLiteralProp(item, "audit");
21214
+ const idempotency = checkedCommandMode(item, "idempotency", ctx, "command branch") ?? "none";
21215
+ const boundary = stringLiteralProp(item, "boundary");
21216
+ const validBoundary = boundary === undefined || boundary === "database" || boundary === "external";
21217
+ const validWhen = when !== undefined && /^(?:[A-Za-z_$][\w$]*)(?:\.[A-Za-z_$][\w$]*)*\s*===\s*(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')$/.test(when);
21218
+ if (!when || !validWhen || !rpc?.trim() || !validBoundary) {
21219
+ ctx.diagnostics.push({
21220
+ severity: "error",
21221
+ code: "invalid-command-branches",
21222
+ file: sourcePath(ctx.rootDir, item.getSourceFile().fileName),
21223
+ line: lineOf(item),
21224
+ message: "Command branches require a restricted equality selector, a non-empty rpc, and a valid boundary.",
21225
+ suggestion: `Use when: 'resource === "value"' (or body.resource/params.resource/query.resource), plus a named RPC adapter.`
21226
+ });
21227
+ continue;
21228
+ }
21229
+ branches.push({
21230
+ when,
21231
+ rpc,
21232
+ transaction,
21233
+ ...audit === undefined ? {} : { audit },
21234
+ idempotency,
21235
+ ...boundary === undefined ? {} : { boundary }
21236
+ });
21237
+ }
21238
+ if (branches.length === 0)
21239
+ return [];
21240
+ if (new Set(branches.map((branch) => branch.when)).size !== branches.length) {
21241
+ ctx.diagnostics.push({
21242
+ severity: "error",
21243
+ code: "duplicate-command-branch",
21244
+ file: sourcePath(ctx.rootDir, expression.getSourceFile().fileName),
21245
+ line: lineOf(expression),
21246
+ message: "Command branches must use distinct selectors.",
21247
+ suggestion: "Make each branch selector mutually distinct."
21248
+ });
21249
+ }
21250
+ return branches;
21251
+ }
20730
21252
  function classDeps(cls, ctx) {
20731
21253
  const injectable = parseInjectableOptions(cls, ctx);
20732
21254
  const ctor = cls.members.find(ts7.isConstructorDeclaration);
@@ -21936,6 +22458,7 @@ function validateClientSchemaBoundaries(graph, options) {
21936
22458
 
21937
22459
  // src/effect-governance.ts
21938
22460
  init_validate();
22461
+ init_feature();
21939
22462
  function diagnostic(code, message, file, suggestion) {
21940
22463
  const metadata = COMPILER_DIAGNOSTIC_CODES[code];
21941
22464
  return {
@@ -21973,8 +22496,10 @@ function validateEffectPolicies(graph, options = {}) {
21973
22496
  if (effect.retry === "explicit" && effect.maxAttempts === undefined) {
21974
22497
  diagnostics.push(diagnostic("invalid-effect-retry", `Route ${label} enables explicit Effect retry without maxAttempts.`, controller.file, "Declare a positive maxAttempts value including the initial attempt."));
21975
22498
  }
21976
- if (route.command && effect.retry === "explicit") {
21977
- const command = commands.get(route.command);
22499
+ if (effect.retry === "explicit") {
22500
+ const featureTransition = featureTransitionForRoute(module, controller, route);
22501
+ const commandRef = route.command ?? featureTransition?.command;
22502
+ const command = commandRef === undefined ? undefined : commands.get(commandRef) ?? [...commands.values()].find((candidate) => candidate.name === commandRef);
21978
22503
  if (command && command.idempotency !== "required") {
21979
22504
  diagnostics.push(diagnostic("effect-command-retry-forbidden", `Route ${label} enables explicit Effect retry for non-idempotent command "${command.name}".`, controller.file, 'Set command idempotency to "required" or use effect.retry: "none".'));
21980
22505
  }
@@ -23191,6 +23716,7 @@ init_graphql_options();
23191
23716
 
23192
23717
  // src/delivery-plan.ts
23193
23718
  import { createHash as createHash10 } from "node:crypto";
23719
+ init_feature();
23194
23720
  init_validate();
23195
23721
  var compare2 = (a, b) => a < b ? -1 : a > b ? 1 : 0;
23196
23722
  var sorted = (values) => [...new Set(values)].sort(compare2);
@@ -23310,6 +23836,10 @@ function createDeliveryPlan(graph, input, validation) {
23310
23836
  fail("delivery-route-conflict", `Public route "${key}" is owned by both "${existing}" and "${target.name}".`, "Give public routes unambiguous method/path contracts before splitting targets.");
23311
23837
  }
23312
23838
  publicRoutes.set(key, target.name);
23839
+ const command = route.command === undefined ? undefined : module.commands.find((candidate) => candidate.className === route.command || candidate.name === route.command);
23840
+ const featureTransition = featureTransitionForRoute(module, controller, route);
23841
+ const effectiveCommand = route.command ?? featureTransition?.command;
23842
+ const idempotency = route.idempotency ?? command?.idempotency ?? featureTransition?.idempotency;
23313
23843
  target.roots.push(module.name);
23314
23844
  target.routes.push({
23315
23845
  module: module.name,
@@ -23317,7 +23847,8 @@ function createDeliveryPlan(graph, input, validation) {
23317
23847
  handler: route.handler,
23318
23848
  method: route.method,
23319
23849
  path,
23320
- ...route.command === undefined ? {} : { command: route.command },
23850
+ ...effectiveCommand === undefined ? {} : { command: effectiveCommand },
23851
+ ...idempotency === undefined ? {} : { idempotency },
23321
23852
  reason: owner === undefined ? "default-http" : "explicit-module"
23322
23853
  });
23323
23854
  }
@@ -24349,7 +24880,12 @@ async function buildDeliveryProject(options, delivery) {
24349
24880
  deploymentReady: false,
24350
24881
  plan: planned.plan,
24351
24882
  objects,
24352
- routes: planned.plan.targets.flatMap((target) => target.routes.map((route) => ({ method: route.method, path: route.path, target: target.name }))),
24883
+ routes: planned.plan.targets.flatMap((target) => target.routes.map((route) => ({
24884
+ method: route.method,
24885
+ path: route.path,
24886
+ target: target.name,
24887
+ ...route.idempotency === undefined ? {} : { idempotency: route.idempotency }
24888
+ }))),
24353
24889
  jobs: planned.plan.targets.flatMap((target) => target.jobs.map((job) => ({ name: job.name, target: target.name })))
24354
24890
  });
24355
24891
  phase = "artifact-integrity";
@@ -24415,10 +24951,10 @@ import { readFileSync as readFileSync4 } from "node:fs";
24415
24951
  import { resolve as resolve18 } from "node:path";
24416
24952
 
24417
24953
  // src/migration-app-version.ts
24418
- var MIGRATION_APP_VERSION = "0.28.3";
24954
+ var MIGRATION_APP_VERSION = "0.30.0";
24419
24955
 
24420
24956
  // src/migration-elysia-version.ts
24421
- var MIGRATION_ELYSIA_VERSION = "0.33.5";
24957
+ var MIGRATION_ELYSIA_VERSION = "0.35.0";
24422
24958
 
24423
24959
  // src/migration-policy.ts
24424
24960
  var cachedCompilerVersion;
@@ -25388,6 +25924,175 @@ function isRecord2(value) {
25388
25924
  function arrayLength(value) {
25389
25925
  return Array.isArray(value) ? value.length : undefined;
25390
25926
  }
25927
+ function safeField(value) {
25928
+ return typeof value === "string" && /^[A-Za-z_][A-Za-z0-9_]*$/.test(value);
25929
+ }
25930
+ function validFeaturePersistence(value) {
25931
+ if (!isRecord2(value))
25932
+ return false;
25933
+ const boundary = value["boundary"];
25934
+ const receipt = value["receipt"];
25935
+ const reconciliation = value["reconciliation"];
25936
+ if (boundary === "database") {
25937
+ const fields = [value["tenantField"], value["versionField"], value["stateField"]];
25938
+ return value["optimisticLock"] === "required" && fields.every(safeField) && new Set(fields).size === 3 && receipt === "required" && reconciliation === "none";
25939
+ }
25940
+ if (boundary === "external") {
25941
+ return value["optimisticLock"] === "none" && value["versionField"] === undefined && value["tenantField"] === undefined && value["stateField"] === undefined && receipt === "required" && reconciliation === "required";
25942
+ }
25943
+ return false;
25944
+ }
25945
+ function validCommandInventory(value) {
25946
+ const names = new Set;
25947
+ const classes = new Set;
25948
+ return Array.isArray(value) && value.every((command) => {
25949
+ 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")
25950
+ return false;
25951
+ if (names.has(command["name"]) || classes.has(command["className"]))
25952
+ return false;
25953
+ if (command["rpc"] !== undefined && (typeof command["rpc"] !== "string" || command["rpc"].trim() === ""))
25954
+ return false;
25955
+ if (command["auditEvent"] !== undefined && (typeof command["auditEvent"] !== "string" || command["auditEvent"].trim() === ""))
25956
+ return false;
25957
+ names.add(command["name"]);
25958
+ classes.add(command["className"]);
25959
+ return true;
25960
+ });
25961
+ }
25962
+ function validFeatureInventory(value, jobs, commands, routes, rpc) {
25963
+ const featureNames = new Set;
25964
+ return Array.isArray(value) && value.every((feature) => {
25965
+ if (!isRecord2(feature) || typeof feature["name"] !== "string" || feature["name"].trim() === "")
25966
+ return false;
25967
+ if (featureNames.has(feature["name"]))
25968
+ return false;
25969
+ featureNames.add(feature["name"]);
25970
+ if (feature["tenantField"] !== undefined && !safeField(feature["tenantField"]))
25971
+ return false;
25972
+ const states = feature["states"];
25973
+ const transitions = feature["transitions"];
25974
+ if (!Array.isArray(states) || states.length === 0 || states.some((state) => typeof state !== "string" || state.trim() === "") || new Set(states).size !== states.length || !Array.isArray(transitions))
25975
+ return false;
25976
+ const transitionNames = new Set;
25977
+ return transitions.every((transition) => {
25978
+ 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"]))
25979
+ return false;
25980
+ if (transitionNames.has(transition["name"]))
25981
+ return false;
25982
+ transitionNames.add(transition["name"]);
25983
+ const persistence = transition["persistence"];
25984
+ if (persistence !== undefined) {
25985
+ if (!isRecord2(persistence) || !validFeaturePersistence(persistence))
25986
+ return false;
25987
+ if (typeof transition["command"] !== "string" || transition["command"].trim() === "" || typeof transition["permission"] !== "string" || transition["permission"].trim() === "")
25988
+ return false;
25989
+ if (persistence["boundary"] === "database" && feature["tenantField"] !== persistence["tenantField"])
25990
+ return false;
25991
+ }
25992
+ const commandName = transition["command"];
25993
+ const command = commandName === undefined ? undefined : commands.find((candidate) => isRecord2(candidate) && (candidate["name"] === commandName || candidate["className"] === commandName));
25994
+ if (commandName !== undefined) {
25995
+ if (!isRecord2(command))
25996
+ return false;
25997
+ if (transition["permission"] !== undefined && command["permission"] !== transition["permission"])
25998
+ return false;
25999
+ if (transition["transaction"] !== undefined && command["transaction"] !== transition["transaction"])
26000
+ return false;
26001
+ if (transition["idempotency"] !== undefined && command["idempotency"] !== transition["idempotency"])
26002
+ return false;
26003
+ if (transition["audit"] !== undefined && command["auditEvent"] !== transition["audit"])
26004
+ return false;
26005
+ if (persistence !== undefined) {
26006
+ const adapter = command["rpc"];
26007
+ 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() === "")
26008
+ return false;
26009
+ const expectedTransaction = persistence["boundary"] === "database" ? "required" : "none";
26010
+ if (command["transaction"] !== expectedTransaction)
26011
+ return false;
26012
+ }
26013
+ }
26014
+ const routeName = transition["route"];
26015
+ const routeBinding = transition["routeBinding"];
26016
+ if (routeBinding !== undefined && routeBinding !== "command" && routeBinding !== "controller")
26017
+ return false;
26018
+ if (routeName !== undefined) {
26019
+ const route = routes.find((candidate) => isRecord2(candidate) && `${candidate["method"]} ${candidate["path"]}` === routeName);
26020
+ if (!isRecord2(route))
26021
+ return false;
26022
+ if (routeBinding === "controller") {
26023
+ if (route["command"] !== undefined)
26024
+ return false;
26025
+ } else {
26026
+ if (typeof route["command"] !== "string")
26027
+ return false;
26028
+ const routeCommand = commands.find((candidate) => isRecord2(candidate) && (candidate["name"] === route["command"] || candidate["className"] === route["command"]));
26029
+ if (!isRecord2(routeCommand) || !isRecord2(command) || routeCommand["name"] !== command["name"])
26030
+ return false;
26031
+ }
26032
+ if (transition["permission"] !== undefined && route["permission"] !== transition["permission"])
26033
+ return false;
26034
+ if (transition["idempotency"] !== undefined && route["idempotency"] !== transition["idempotency"])
26035
+ return false;
26036
+ if (persistence !== undefined && route["idempotency"] !== "required")
26037
+ return false;
26038
+ if (route["idempotency"] === "required" && (route["method"] === "GET" || route["method"] === "HEAD" || route["method"] === "OPTIONS"))
26039
+ return false;
26040
+ }
26041
+ const job = transition["job"];
26042
+ if (job === undefined)
26043
+ return true;
26044
+ const details = transition["jobDetails"];
26045
+ const inventory = jobs.find((candidate) => isRecord2(candidate) && (candidate["name"] === job || candidate["className"] === job));
26046
+ const contractFields = ["input", "inputKind", "output", "outputKind", "mode", "idempotency", "tenantScope"];
26047
+ 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]);
26048
+ });
26049
+ });
26050
+ }
26051
+ function validJobInventory(value) {
26052
+ const names = new Set;
26053
+ const classes = new Set;
26054
+ return Array.isArray(value) && value.every((job) => {
26055
+ if (!isRecord2(job) || typeof job["name"] !== "string" || job["name"].trim() === "" || typeof job["className"] !== "string" || job["className"].trim() === "" || typeof job["module"] !== "string" || job["module"].trim() === "")
26056
+ return false;
26057
+ if (names.has(job["name"]) || classes.has(job["className"]))
26058
+ return false;
26059
+ names.add(job["name"]);
26060
+ classes.add(job["className"]);
26061
+ if (job["input"] !== undefined && (typeof job["input"] !== "string" || job["input"].trim() === ""))
26062
+ return false;
26063
+ if (job["inputKind"] !== undefined && job["inputKind"] !== "opaque" && job["inputKind"] !== "declared")
26064
+ return false;
26065
+ if (job["output"] !== undefined && (typeof job["output"] !== "string" || job["output"].trim() === ""))
26066
+ return false;
26067
+ if (job["outputKind"] !== undefined && job["outputKind"] !== "opaque" && job["outputKind"] !== "declared")
26068
+ return false;
26069
+ if (job["mode"] !== undefined && job["mode"] !== "task" && job["mode"] !== "workflow")
26070
+ return false;
26071
+ if (job["idempotency"] !== undefined && job["idempotency"] !== "required" && job["idempotency"] !== "none")
26072
+ return false;
26073
+ return job["tenantScope"] === undefined || job["tenantScope"] === "required" || job["tenantScope"] === "none";
26074
+ });
26075
+ }
26076
+ function validRpcInventory(value, commandInventory) {
26077
+ const commandNames = new Set;
26078
+ const adapters = new Set;
26079
+ return Array.isArray(value) && value.every((rpc) => {
26080
+ if (!isRecord2(rpc) || typeof rpc["command"] !== "string" || rpc["command"].trim() === "" || typeof rpc["adapter"] !== "string" || rpc["adapter"].trim() === "")
26081
+ return false;
26082
+ if (commandNames.has(rpc["command"]) || adapters.has(rpc["adapter"]))
26083
+ return false;
26084
+ const command = commandInventory.find((candidate) => isRecord2(candidate) && candidate["name"] === rpc["command"] && candidate["rpc"] === rpc["adapter"]);
26085
+ if (!isRecord2(command))
26086
+ return false;
26087
+ commandNames.add(rpc["command"]);
26088
+ adapters.add(rpc["adapter"]);
26089
+ return true;
26090
+ }) && commandInventory.every((command) => {
26091
+ if (!isRecord2(command) || command["rpc"] === undefined)
26092
+ return true;
26093
+ return value.some((rpc) => isRecord2(rpc) && rpc["command"] === command["name"] && rpc["adapter"] === command["rpc"]);
26094
+ });
26095
+ }
25391
26096
  async function sha2566(path) {
25392
26097
  return createHash11("sha256").update(await readFile11(path)).digest("hex");
25393
26098
  }
@@ -25457,19 +26162,24 @@ async function assessMigration(options) {
25457
26162
  };
25458
26163
  try {
25459
26164
  const parsed = JSON.parse(await readFile11(contractsManifestPath, "utf8"));
25460
- if (!isRecord2(parsed) || parsed["version"] !== 1)
26165
+ 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"])) {
25461
26166
  throw new Error("invalid manifest");
26167
+ }
25462
26168
  const commands = arrayLength(parsed["commands"]);
26169
+ const jobs = arrayLength(parsed["jobs"]);
25463
26170
  const routes = arrayLength(parsed["routes"]);
25464
26171
  const permissions = arrayLength(parsed["permissions"]);
26172
+ const features = arrayLength(parsed["features"]);
25465
26173
  contractsManifest = {
25466
26174
  path: contractsManifestPath,
25467
26175
  present: true,
25468
26176
  sha256: await sha2566(contractsManifestPath),
25469
26177
  version: 1,
25470
26178
  ...commands === undefined ? {} : { commands },
26179
+ ...jobs === undefined ? {} : { jobs },
25471
26180
  ...routes === undefined ? {} : { routes },
25472
- ...permissions === undefined ? {} : { permissions }
26181
+ ...permissions === undefined ? {} : { permissions },
26182
+ ...features === undefined ? {} : { features }
25473
26183
  };
25474
26184
  } catch {
25475
26185
  findings.push({