@aventara/core 1.0.0-rc.11 → 1.0.0-rc.13

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.
Files changed (66) hide show
  1. package/CHANGELOG.md +157 -0
  2. package/ERRORS.md +8 -7
  3. package/README.md +3 -2
  4. package/dist/avz/avz-check.factory.d.ts +2 -0
  5. package/dist/avz/avz-check.factory.js +12 -0
  6. package/dist/avz/avz.d.ts +6 -0
  7. package/dist/avz/avz.js +1 -0
  8. package/dist/compiler/contract-compiler-diagnostic.d.ts +13 -2
  9. package/dist/compiler/contract-compiler-diagnostic.guidance.js +7 -2
  10. package/dist/compiler/contract.compiler.js +110 -15
  11. package/dist/compiler/create-requirements.projector.d.ts +45 -3
  12. package/dist/compiler/create-requirements.projector.js +239 -28
  13. package/dist/compiler/managed-elsewhere.projector.d.ts +3 -0
  14. package/dist/compiler/managed-elsewhere.projector.js +1 -1
  15. package/dist/config/computed-fields.d.ts +8 -0
  16. package/dist/config/computed-fields.js +1 -1
  17. package/dist/config/config-diagnostic.guidance.js +2 -2
  18. package/dist/config/config.resolver.d.ts +2 -2
  19. package/dist/config/config.validator.js +15 -6
  20. package/dist/config/exact-framework-config.d.ts +1 -0
  21. package/dist/config/field-pipeline-config.d.ts +2 -2
  22. package/dist/config/forced-operation.validator.js +3 -2
  23. package/dist/config/framework-config.d.ts +8 -3
  24. package/dist/config/framework-exemptions-config.d.ts +13 -8
  25. package/dist/config/framework-resources-config.d.ts +11 -3
  26. package/dist/contracts/client-contract-structure.validator.d.ts +4 -1
  27. package/dist/contracts/client-contract-structure.validator.js +3 -2
  28. package/dist/contracts/contract-limits.d.ts +8 -1
  29. package/dist/contracts/contract-limits.js +1 -0
  30. package/dist/contracts/contract.d.ts +7 -3
  31. package/dist/contracts/transaction-capability.d.ts +8 -0
  32. package/dist/contracts/transaction-capability.js +7 -0
  33. package/dist/framework/access-facade.js +14 -6
  34. package/dist/framework/contract-view.d.ts +104 -12
  35. package/dist/framework/operation-facade.d.ts +8 -3
  36. package/dist/internal.d.ts +1 -0
  37. package/dist/internal.js +1 -0
  38. package/dist/operations/validation-code.guidance.js +1 -1
  39. package/dist/policy/access-provider.interface.d.ts +7 -2
  40. package/dist/policy/access-question.runtime.js +2 -1
  41. package/dist/prebuilt/core-version.generated.d.ts +1 -1
  42. package/dist/prebuilt/core-version.generated.js +1 -1
  43. package/dist/protocol/absent-route.responder.d.ts +2 -2
  44. package/dist/protocol/absent-route.responder.js +2 -1
  45. package/dist/protocol/av-protocol.d.ts +1 -0
  46. package/dist/protocol/av-protocol.js +1 -0
  47. package/dist/protocol/protocol-surface.js +2 -1
  48. package/dist/protocol/protocol.handler.js +3 -1
  49. package/dist/runtime/context-executor.runtime.js +2 -1
  50. package/dist/runtime/create-requirement.d.ts +5 -1
  51. package/dist/runtime/create-requirement.js +10 -2
  52. package/dist/runtime/field-check.evaluator.js +39 -2
  53. package/dist/runtime/field-validator.runtime.d.ts +4 -3
  54. package/dist/runtime/field-validator.runtime.js +29 -0
  55. package/dist/runtime/mutation-planner.js +26 -9
  56. package/dist/runtime/operation-code.guidance.js +3 -3
  57. package/dist/runtime/operation-runtime.d.ts +10 -9
  58. package/dist/runtime/operation-runtime.js +20 -18
  59. package/dist/runtime/pipeline.d.ts +1 -15
  60. package/dist/runtime/state-refusal.mapper.d.ts +16 -0
  61. package/dist/runtime/state-refusal.mapper.js +40 -0
  62. package/dist/transactions/operation-fingerprint.d.ts +15 -2
  63. package/dist/transactions/transaction-plan-validator.js +31 -1
  64. package/dist/transactions/transaction-runtime.js +1 -1
  65. package/package.json +1 -1
  66. package/src/runtime/field-check.evaluator.ts +46 -2
@@ -8,7 +8,7 @@ import { AVENTARA_PROTOCOL_VERSION } from "../contracts/contract-protocol.js";
8
8
  import { advertisedDirectives, advertisedReducers, holdsInAnyScope, } from "../contracts/field.js";
9
9
  import { isOperationVariantAvailable, } from "../contracts/resource-operations.js";
10
10
  import { isRecord } from "../runtime/record.guard.js";
11
- import { checkCreateRequirements, checkHiddenRequirements, projectCreateRequirements, } from "./create-requirements.projector.js";
11
+ import { checkCreateRequirements, checkHiddenRequirements, checkReadOnlyNestedWrites, projectCreateRequirements, withheldKey, withholdReadOnlyNestedWrites, } from "./create-requirements.projector.js";
12
12
  import { applyValidators, bindFieldValidators, checkLayerValidators, compileValidatorTable, } from "./field-validator.compiler.js";
13
13
  import { explainManagedRefusals, hideManagedElsewhere, managedElsewhereOf, managedWriteReason, markCoreResources, pruneManagedEnums, withholdManagedWrites, } from "./managed-elsewhere.projector.js";
14
14
  const UNIQUE_VARIANTS = [
@@ -491,6 +491,9 @@ function capabilityAxisFor(kind, key) {
491
491
  function unavailableCapability(path, message, diagnostics) {
492
492
  diagnostics.push(diagnostic("COMPILER_UNAVAILABLE_CAPABILITY", path, message));
493
493
  }
494
+ function parentLayerOf(walk) {
495
+ return walk.layer === "agent" ? (walk.parent ?? "client") : "root";
496
+ }
494
497
  function otherOverlay(layer) {
495
498
  return layer === "client" ? "application" : "client";
496
499
  }
@@ -504,9 +507,23 @@ function exemptsWhole(entry) {
504
507
  }
505
508
  function exemptsAnything(entry) {
506
509
  return (entry === true ||
510
+ entry === READ ||
507
511
  (Array.isArray(entry) && entry.length > 0) ||
508
512
  isRecord(entry));
509
513
  }
514
+ const READ = "read";
515
+ const READ_CAPABILITIES = {
516
+ scalar: ["select", "filter", "order", "orderNulls"],
517
+ relation: ["select", "include", "filter", "order", "pagination", "reducers"],
518
+ };
519
+ const readCapabilitiesCovered = [true, true];
520
+ void readCapabilitiesCovered;
521
+ function readCapabilitiesOf(kind, parentCapabilities) {
522
+ return READ_CAPABILITIES[kind].filter((capability) => {
523
+ const axis = capabilityAxisFor(kind, capability);
524
+ return (axis !== undefined && memberOffered(axis, parentCapabilities[capability]));
525
+ });
526
+ }
510
527
  function listedNames(entry, path) {
511
528
  if (!Array.isArray(entry)) {
512
529
  return [];
@@ -585,6 +602,34 @@ function offeredTokens(walk, axis, universeValue, listed, capability, label) {
585
602
  }
586
603
  return tokens;
587
604
  }
605
+ const WITHHELD_LACKS = {
606
+ delete: ["has no delete operation", "a delete operation"],
607
+ update: [
608
+ "updates nothing",
609
+ "an update operation or a field writable on update",
610
+ ],
611
+ create: [
612
+ "creates nothing",
613
+ "a create operation or a field writable on create",
614
+ ],
615
+ };
616
+ function joined(names) {
617
+ return names.length <= 1
618
+ ? (names[0] ?? "")
619
+ : `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`;
620
+ }
621
+ function withheldWrite(walk, universeField, label, path, directives, member, withheld) {
622
+ const target = String(universeField.target);
623
+ const above = parentLayerOf(walk);
624
+ const reasons = [
625
+ ...new Set(directives.flatMap((name) => withheld.get(name) ?? [])),
626
+ ];
627
+ const named = member ?? joined(directives);
628
+ const message = reasons.length === 1 && reasons[0] === "delete"
629
+ ? `the ${walk.layer} layer lets relation ${label} delete rows of resource "${target}" ($delete), but "${target}" has no delete operation in ${above}, the layer ${walk.layer} starts from, so ${above} offers no $delete on ${label}${member === undefined ? "" : ` and no ${member} at all, $delete being all it had`}; take ${named} off ${label}, or give "${target}" a delete operation in ${above}`
630
+ : `the ${walk.layer} layer lets relation ${label} write rows of resource "${target}" (${joined(directives)}), but "${target}" ${reasons.map((reason) => WITHHELD_LACKS[reason][0]).join(", and ")} in ${above}, the layer ${walk.layer} starts from, so ${above} offers no ${joined(directives)} on ${label}${member === undefined ? "" : ` and no ${member} at all`}; take ${named} off ${label}, or give "${target}" ${reasons.map((reason) => WITHHELD_LACKS[reason][1]).join(", and ")} in ${above}`;
631
+ walk.diagnostics.push(diagnostic("COMPILER_READ_ONLY_NESTED_WRITE", path.replace(/(\[\d+\]|\.[^.[\]]+)$/, ""), message));
632
+ }
588
633
  function exemptCapability(walk, universeField, parentCapabilities, result, capability, entry, label, path) {
589
634
  if (entry === undefined) {
590
635
  return;
@@ -624,7 +669,19 @@ function exemptCapability(walk, universeField, parentCapabilities, result, capab
624
669
  if (tokens !== undefined && tokens.length === 0) {
625
670
  return;
626
671
  }
672
+ const withheldHere = new Map();
673
+ const prefix = withheldKey(label.slice(1, -1), capability, "");
674
+ for (const [key, reason] of walk.withheldWrites ?? []) {
675
+ if (key.startsWith(prefix)) {
676
+ withheldHere.set(key.slice(prefix.length), reason);
677
+ }
678
+ }
627
679
  if (!parentHas) {
680
+ if (withheldHere.size > 0 &&
681
+ (tokens === undefined || tokens.every((token) => withheldHere.has(token)))) {
682
+ withheldWrite(walk, universeField, label, path, tokens ?? [...withheldHere.keys()], tokens === undefined ? capability : undefined, withheldHere);
683
+ return;
684
+ }
628
685
  widening(walk, "COMPILER_UNAVAILABLE_CAPABILITY", path, `capability "${capability}" of field ${label}`);
629
686
  return;
630
687
  }
@@ -635,7 +692,14 @@ function exemptCapability(walk, universeField, parentCapabilities, result, capab
635
692
  const parentTokens = memberTokens(axis, parentValue) ?? [];
636
693
  const hidden = tokens.filter((token) => !parentTokens.includes(token));
637
694
  if (hidden.length > 0) {
638
- widening(walk, "COMPILER_UNAVAILABLE_CAPABILITY", path, `${hidden.map((token) => `"${token}"`).join(", ")} of capability "${capability}" of field ${label}`);
695
+ const withheld = hidden.filter((token) => withheldHere.has(token));
696
+ const widened = hidden.filter((token) => !withheldHere.has(token));
697
+ if (withheld.length > 0) {
698
+ withheldWrite(walk, universeField, label, path, withheld, undefined, withheldHere);
699
+ }
700
+ if (widened.length > 0) {
701
+ widening(walk, "COMPILER_UNAVAILABLE_CAPABILITY", path, `${widened.map((token) => `"${token}"`).join(", ")} of capability "${capability}" of field ${label}`);
702
+ }
639
703
  return;
640
704
  }
641
705
  result[capability] = withTokens(axis, parentValue, parentTokens.filter((token) => tokens.includes(token)));
@@ -650,7 +714,12 @@ function exemptField(walk, universeField, parentField, entry, label, path) {
650
714
  }
651
715
  }
652
716
  }
653
- if (Array.isArray(entry)) {
717
+ if (entry === READ) {
718
+ for (const capability of readCapabilitiesOf(universeField.kind, parentCapabilities)) {
719
+ exemptCapability(walk, universeField, parentCapabilities, result, capability, true, label, path);
720
+ }
721
+ }
722
+ else if (Array.isArray(entry)) {
654
723
  for (const [capability, capabilityPath] of listedNames(entry, path)) {
655
724
  exemptCapability(walk, universeField, parentCapabilities, result, capability, true, label, capabilityPath);
656
725
  }
@@ -670,7 +739,11 @@ function exemptField(walk, universeField, parentField, entry, label, path) {
670
739
  return { ...cloneValue(parentField), capabilities: result };
671
740
  }
672
741
  function exemptFields(walk, resourceName, universe, parent, entries, path) {
673
- const named = isRecord(entries) ? entries : {};
742
+ const named = entries === READ
743
+ ? Object.fromEntries(Object.keys(parent.fields).map((name) => [name, READ]))
744
+ : isRecord(entries)
745
+ ? entries
746
+ : {};
674
747
  for (const fieldName of Object.keys(named)) {
675
748
  if (universe.fields[fieldName] === undefined) {
676
749
  walk.diagnostics.push(diagnostic("COMPILER_UNKNOWN_FIELD", `${path}.${fieldName}`, `field "${fieldName}" is neither discovered by the adapter nor defined at the root`));
@@ -866,9 +939,14 @@ function pruneEmptyOperationFamilies(operations) {
866
939
  function identifierLabel(identifier) {
867
940
  return identifier.map((name) => `\`${name}\``).join(" and ");
868
941
  }
942
+ function isAddressable(field, parentField) {
943
+ return (field?.kind === "scalar" &&
944
+ (capabilityIsEnabled(field.capabilities.filter) ||
945
+ !capabilityIsEnabled(parentField?.capabilities?.filter)));
946
+ }
869
947
  function reconcileIdentifiers(walk, resource, parent, path) {
870
948
  resource.identifiers = parent.identifiers
871
- .filter((identifier) => identifier.every((name) => resource.fields[name]?.kind === "scalar"))
949
+ .filter((identifier) => identifier.every((name) => isAddressable(resource.fields[name], parent.fields[name])))
872
950
  .map((identifier) => [...identifier]);
873
951
  const first = parent.identifiers[0];
874
952
  if (resource.identifiers.length > 0 || first === undefined) {
@@ -882,8 +960,8 @@ function reconcileIdentifiers(walk, resource, parent, path) {
882
960
  }
883
961
  const variantPath = `${path}.operations.${family}.${variant}`;
884
962
  walk.diagnostics.push(diagnostic("COMPILER_MISSING_IDENTIFIER", variantPath, walk.style === "pick"
885
- ? `${variantPath} needs an identifier field; pick ${identifierLabel(first)} (or another complete identifier) too`
886
- : `${variantPath} needs an identifier field, and the exemptions hide every complete identifier; keep ${identifierLabel(first)} (or another complete identifier) exposed, or exempt the operation too`));
963
+ ? `${variantPath} needs an identifier field; pick ${identifierLabel(first)} with its filter (or another complete identifier) too`
964
+ : `${variantPath} needs an identifier field, and the exemptions hide (or stop filtering on) every complete identifier; keep ${identifierLabel(first)} (or another complete identifier) exposed and filterable, or exempt the operation too`));
887
965
  }
888
966
  }
889
967
  function validateScalarReference(projection, field, path, diagnostics) {
@@ -1020,8 +1098,8 @@ function resolveLimits(scope) {
1020
1098
  }
1021
1099
  function resolveTransactions(model, scopeName, scope, diagnostics) {
1022
1100
  const requested = scope.transactionsStyle ?? model.transactions;
1023
- if (requested === "interactive" && model.transactions === "none") {
1024
- diagnostics.push(diagnostic("COMPILER_UNAVAILABLE_CAPABILITY", `${scopeName}.transactionsStyle`, 'transaction capability "interactive" is not available in the canonical adapter model'));
1101
+ if (requested !== "none" && model.transactions === "none") {
1102
+ diagnostics.push(diagnostic("COMPILER_UNAVAILABLE_CAPABILITY", `${scopeName}.transactionsStyle`, `transaction capability "${requested}" is not available in the canonical adapter model`));
1025
1103
  }
1026
1104
  return requested;
1027
1105
  }
@@ -1031,6 +1109,11 @@ function semanticObject(contract) {
1031
1109
  function exposureStyle(layer) {
1032
1110
  return layer.exposed === false ? "pick" : "omit";
1033
1111
  }
1112
+ function withholdOmittedWrites(style, projection) {
1113
+ return style === "omit"
1114
+ ? withholdReadOnlyNestedWrites(projection.resources)
1115
+ : new Map();
1116
+ }
1034
1117
  function projectCore(model, config, diagnostics) {
1035
1118
  const scope = config.root;
1036
1119
  const managed = managedElsewhereOf(config.trees.root);
@@ -1049,13 +1132,15 @@ function projectCore(model, config, diagnostics) {
1049
1132
  const projection = projectExposure({ layer: "root", style: exposureStyle(scope), diagnostics }, baseline, baseline, scope.exemptions);
1050
1133
  qualifyFilterModes(projection);
1051
1134
  pruneHiddenPairings(projection, "shown");
1135
+ const withheldWrites = withholdOmittedWrites(exposureStyle(scope), projection);
1052
1136
  projectCreateRequirements(model, projection.resources);
1053
1137
  const rootChecked = diagnostics.length;
1054
1138
  checkHiddenRequirements(model, "root", baseline.resources, unwithheld.resources, projection.resources, diagnostics);
1139
+ checkReadOnlyNestedWrites("root", undefined, projection.resources, diagnostics);
1055
1140
  explainManagedRefusals(diagnostics, rootChecked, "root", baseline, managed);
1056
1141
  applyValidators(projection.resources, validators);
1057
1142
  validateProjection(projection, "root", diagnostics);
1058
- return { baseline, projection, validators };
1143
+ return { baseline, projection, validators, withheldWrites };
1059
1144
  }
1060
1145
  function markGovernedFields(baseline, governed) {
1061
1146
  if (governed === undefined) {
@@ -1078,7 +1163,7 @@ function markGovernedFields(baseline, governed) {
1078
1163
  }
1079
1164
  }
1080
1165
  }
1081
- function projectDerivedScope(model, scopeName, core, parentView, config, diagnostics) {
1166
+ function projectDerivedScope(model, scopeName, core, parentView, parentWithheld, config, diagnostics) {
1082
1167
  const scope = config[scopeName];
1083
1168
  const managed = managedElsewhereOf(config.trees.root);
1084
1169
  const baseline = cloneValue(core.baseline);
@@ -1105,6 +1190,7 @@ function projectDerivedScope(model, scopeName, core, parentView, config, diagnos
1105
1190
  layer: scopeName,
1106
1191
  style: exposureStyle(scope),
1107
1192
  diagnostics,
1193
+ withheldWrites: parentWithheld,
1108
1194
  ...(scopeName === "agent" ? { parent: config.agent.parent } : {}),
1109
1195
  }, baseline, parent, scope.exemptions);
1110
1196
  qualifyFilterModes(projection);
@@ -1112,13 +1198,18 @@ function projectDerivedScope(model, scopeName, core, parentView, config, diagnos
1112
1198
  if (unhidden !== parent) {
1113
1199
  pruneManagedEnums(projection, unhidden, managed);
1114
1200
  }
1201
+ const withheldWrites = new Map([
1202
+ ...parentWithheld,
1203
+ ...withholdOmittedWrites(exposureStyle(scope), projection),
1204
+ ]);
1115
1205
  projectCreateRequirements(model, projection.resources);
1116
1206
  const layerChecked = diagnostics.length;
1117
1207
  checkHiddenRequirements(model, scopeName, baseline.resources, unhidden.resources, projection.resources, diagnostics);
1208
+ checkReadOnlyNestedWrites(scopeName, unhidden.resources, projection.resources, diagnostics);
1118
1209
  explainManagedRefusals(diagnostics, layerChecked, scopeName, baseline, managed);
1119
1210
  applyValidators(projection.resources, validators);
1120
1211
  validateProjection(projection, scopeName, diagnostics);
1121
- return { projection, validators };
1212
+ return { projection, validators, withheldWrites };
1122
1213
  }
1123
1214
  function bindShapedFields(contract, definitions) {
1124
1215
  for (const [resourceName, resource] of Object.entries(contract.resources)) {
@@ -1140,14 +1231,18 @@ export async function compileContracts(model, config) {
1140
1231
  checkReservedResourceNames(model, diagnostics);
1141
1232
  checkCreateRequirements(model, diagnostics);
1142
1233
  const coreCompilation = projectCore(model, compilerConfig, diagnostics);
1143
- const applicationProjection = projectDerivedScope(model, "application", coreCompilation, coreCompilation.projection, compilerConfig, diagnostics);
1144
- const clientProjection = projectDerivedScope(model, "client", coreCompilation, coreCompilation.projection, compilerConfig, diagnostics);
1234
+ const applicationProjection = projectDerivedScope(model, "application", coreCompilation, coreCompilation.projection, coreCompilation.withheldWrites, compilerConfig, diagnostics);
1235
+ const clientProjection = projectDerivedScope(model, "client", coreCompilation, coreCompilation.projection, coreCompilation.withheldWrites, compilerConfig, diagnostics);
1145
1236
  const agentParent = compilerConfig.agent.parent;
1146
1237
  const agentProjection = projectDerivedScope(model, "agent", coreCompilation, agentParent === "root"
1147
1238
  ? coreCompilation.projection
1148
1239
  : agentParent === "application"
1149
1240
  ? applicationProjection.projection
1150
- : clientProjection.projection, compilerConfig, diagnostics);
1241
+ : clientProjection.projection, agentParent === "root"
1242
+ ? coreCompilation.withheldWrites
1243
+ : agentParent === "application"
1244
+ ? applicationProjection.withheldWrites
1245
+ : clientProjection.withheldWrites, compilerConfig, diagnostics);
1151
1246
  markCoreResources(coreCompilation.projection, model, managedElsewhereOf(compilerConfig.trees.root));
1152
1247
  if (diagnostics.length > 0) {
1153
1248
  return { ok: false, diagnostics };
@@ -12,11 +12,18 @@ type ProjectedResource = {
12
12
  readonly fields: Readonly<Record<string, ProjectedField>>;
13
13
  createRequirements?: AvCreateRequirements;
14
14
  };
15
+ /**
16
+ * The Adapter's statement with the layer's non-null added fields required too.
17
+ * The runtime reads it over the Core Contract (`recordWriteFactsOf`): what write
18
+ * compute must not leave missing.
19
+ */
20
+ export declare function withVirtualRequirements(stated: AvCreateRequirements | undefined, resource: ProjectedResource): AvCreateRequirements | undefined;
15
21
  /**
16
22
  * Sets every Resource's `createRequirements` in one layer's projection to the
17
- * Adapter's statement as that layer sees it, and removes it where nothing is
18
- * left. Reads the projection's final fields: its exposure, and the lifecycle
19
- * its compute stages gave them.
23
+ * Adapter's statement as that layer sees it, its non-null added (`VIRTUAL`)
24
+ * fields required too, and removes it where nothing is left. Reads the
25
+ * projection's final fields: its exposure, and the lifecycle its compute stages
26
+ * gave them.
20
27
  */
21
28
  export declare function projectCreateRequirements(model: AvAdapterModel, resources: Readonly<Record<string, ProjectedResource>>): void;
22
29
  /** A Resource's operations, as far as this check reads them. */
@@ -38,6 +45,41 @@ type ResourceWithOperations = ProjectedResource & {
38
45
  * its final view.
39
46
  */
40
47
  export declare function checkHiddenRequirements(model: AvAdapterModel, layer: AvConfigLayerName, universe: Readonly<Record<string, ProjectedResource>>, parent: Readonly<Record<string, ProjectedResource>>, projection: Readonly<Record<string, ResourceWithOperations>>, diagnostics: AvContractCompilerDiagnostic[]): void;
48
+ /**
49
+ * Refuses, for one layer, every relation that writes rows of a Resource the
50
+ * layer keeps read-only: a nested `$create`, `$connectOrCreate` or `$upsert`
51
+ * into a Resource with no create or upsert variant and no field writable on
52
+ * create, a nested `$update` or `$upsert` into one with no update or upsert
53
+ * variant and no field writable on update, or a nested `$delete` into one with
54
+ * no delete variant. Reported once, by the layer that makes it so: an overlay
55
+ * is not refused for a relation its parent was already refused for. `parent` is
56
+ * what the layer starts from; the root's is none, so an Adapter that offers such
57
+ * a write is refused at the root. An Omit layer has none left to refuse:
58
+ * {@link withholdReadOnlyNestedWrites} dropped them first.
59
+ */
60
+ export declare function checkReadOnlyNestedWrites(layer: AvConfigLayerName, parent: Readonly<Record<string, ResourceWithOperations>> | undefined, projection: Readonly<Record<string, ResourceWithOperations>>, diagnostics: AvContractCompilerDiagnostic[]): void;
61
+ /** Why the narrowing dropped a directive: its target deletes, updates or creates nothing in the layer. */
62
+ export type WithheldReason = "delete" | "update" | "create";
63
+ /**
64
+ * The directives an Omit layer's narrowing dropped, by relation, stage and
65
+ * directive (`"posts.comments.update.$delete"`), each with why.
66
+ */
67
+ export type WithheldWrites = ReadonlyMap<string, WithheldReason>;
68
+ /** The key of one dropped directive in {@link WithheldWrites}. */
69
+ export declare function withheldKey(relation: string, stage: string, directive: string): string;
70
+ /**
71
+ * Narrows an Omit layer's view so no relation writes rows of a Resource the
72
+ * view cannot write: `$create`, `$connectOrCreate` and `$upsert` into one that
73
+ * creates nothing (no create or upsert variant, no field writable on create),
74
+ * `$update` and `$upsert` into one that updates nothing (no update or upsert
75
+ * variant, no field writable on update), `$delete` into one that deletes
76
+ * nothing (no delete variant). A member emptied goes with its last directive.
77
+ * Dropping a directive can leave another Resource writing nothing (its only
78
+ * write capability was it), so the pass repeats to a fixed point. Returns
79
+ * every directive it dropped, with why. A Pick layer's view is not narrowed:
80
+ * {@link checkReadOnlyNestedWrites} refuses what it grants.
81
+ */
82
+ export declare function withholdReadOnlyNestedWrites(resources: Readonly<Record<string, ResourceWithOperations>>): WithheldWrites;
41
83
  /**
42
84
  * Every name in the Adapter's create requirements that is not what its member
43
85
  * says: a `required` name that is no field of the Resource, a group whose first
@@ -1,6 +1,7 @@
1
1
  import { advertisedDirectives, advertisedDirectivesAt, } from "../contracts/field.js";
2
2
  import { isOperationVariantAvailable, writeScopeOf, } from "../contracts/resource-operations.js";
3
3
  import { isRecord } from "../runtime/record.guard.js";
4
+ import { keptDirectives } from "./managed-elsewhere.projector.js";
4
5
  function own(record, key) {
5
6
  return Object.hasOwn(record, key) ? record[key] : undefined;
6
7
  }
@@ -32,6 +33,23 @@ function computedOnCreate(resource, name) {
32
33
  Array.isArray(field.lifecycle) &&
33
34
  field.lifecycle.includes("COMPUTED_ON_CREATE"));
34
35
  }
36
+ function virtualRequirements(resource) {
37
+ return Object.entries(resource.fields)
38
+ .filter(([, field]) => field.kind === "scalar" &&
39
+ field.nullable === false &&
40
+ Array.isArray(field.lifecycle) &&
41
+ field.lifecycle.includes("VIRTUAL"))
42
+ .map(([name]) => name);
43
+ }
44
+ export function withVirtualRequirements(stated, resource) {
45
+ const [first, ...rest] = [
46
+ ...(stated?.required ?? []),
47
+ ...virtualRequirements(resource),
48
+ ];
49
+ return first === undefined
50
+ ? stated
51
+ : { ...stated, required: [first, ...rest] };
52
+ }
35
53
  function projectOne(stated, resource, resources) {
36
54
  const required = [];
37
55
  const requireOnce = (name) => {
@@ -99,7 +117,7 @@ function projectOne(stated, resource, resources) {
99
117
  export function projectCreateRequirements(model, resources) {
100
118
  for (const [name, resource] of Object.entries(resources)) {
101
119
  delete resource.createRequirements;
102
- const stated = own(model.resources, name)?.createRequirements;
120
+ const stated = withVirtualRequirements(own(model.resources, name)?.createRequirements, resource);
103
121
  if (stated === undefined) {
104
122
  continue;
105
123
  }
@@ -118,6 +136,7 @@ const UPDATING_DIRECTIVES = [
118
136
  "$update",
119
137
  "$upsert",
120
138
  ];
139
+ const DELETING_DIRECTIVES = ["$delete"];
121
140
  function variantsOf(resource, families) {
122
141
  const variants = [];
123
142
  for (const family of families) {
@@ -159,6 +178,36 @@ function createVariantsOf(resource) {
159
178
  return variants;
160
179
  }
161
180
  function createPaths(resources) {
181
+ const { created, updated } = writtenResources(resources);
182
+ const paths = new Map();
183
+ const add = (name, path) => {
184
+ const list = paths.get(name) ?? [];
185
+ list.push(path);
186
+ paths.set(name, list);
187
+ };
188
+ for (const [name, resource] of Object.entries(resources)) {
189
+ const variants = createVariantsOf(resource);
190
+ if (variants.length > 0) {
191
+ add(name, { kind: "operation", variants });
192
+ }
193
+ }
194
+ for (const [parent, resource] of Object.entries(resources)) {
195
+ for (const [relation, field] of Object.entries(resource.fields)) {
196
+ const target = field.target;
197
+ if (field.kind !== "relation" || typeof target !== "string") {
198
+ continue;
199
+ }
200
+ if ((created.has(parent) &&
201
+ advertises(field, "create", CREATING_DIRECTIVES)) ||
202
+ (updated.has(parent) &&
203
+ advertises(field, "update", CREATING_DIRECTIVES))) {
204
+ add(target, { kind: "nested", parent, relation });
205
+ }
206
+ }
207
+ }
208
+ return paths;
209
+ }
210
+ function writtenResources(resources) {
162
211
  const created = new Set();
163
212
  const updated = new Set();
164
213
  for (const [name, resource] of Object.entries(resources)) {
@@ -197,33 +246,7 @@ function createPaths(resources) {
197
246
  }
198
247
  }
199
248
  }
200
- const paths = new Map();
201
- const add = (name, path) => {
202
- const list = paths.get(name) ?? [];
203
- list.push(path);
204
- paths.set(name, list);
205
- };
206
- for (const [name, resource] of Object.entries(resources)) {
207
- const variants = createVariantsOf(resource);
208
- if (variants.length > 0) {
209
- add(name, { kind: "operation", variants });
210
- }
211
- }
212
- for (const [parent, resource] of Object.entries(resources)) {
213
- for (const [relation, field] of Object.entries(resource.fields)) {
214
- const target = field.target;
215
- if (field.kind !== "relation" || typeof target !== "string") {
216
- continue;
217
- }
218
- if ((created.has(parent) &&
219
- advertises(field, "create", CREATING_DIRECTIVES)) ||
220
- (updated.has(parent) &&
221
- advertises(field, "update", CREATING_DIRECTIVES))) {
222
- add(target, { kind: "nested", parent, relation });
223
- }
224
- }
225
- }
226
- return paths;
249
+ return { created, updated };
227
250
  }
228
251
  function pathLabel(path) {
229
252
  return path.kind === "operation"
@@ -320,6 +343,7 @@ export function checkHiddenRequirements(model, layer, universe, parent, projecti
320
343
  const parentResource = own(parent, name);
321
344
  if (stated === undefined ||
322
345
  routes.length === 0 ||
346
+ createsNothing(resource) ||
323
347
  universeResource === undefined ||
324
348
  parentResource === undefined) {
325
349
  continue;
@@ -367,6 +391,193 @@ export function checkHiddenRequirements(model, layer, universe, parent, projecti
367
391
  }
368
392
  }
369
393
  }
394
+ function createsNothing(resource) {
395
+ return (variantsOf(resource, ["create", "upsert"]).length === 0 &&
396
+ !Object.keys(resource.fields).some((name) => creatable(resource, name)));
397
+ }
398
+ function updatesNothing(resource) {
399
+ return (variantsOf(resource, ["update", "upsert"]).length === 0 &&
400
+ !Object.values(resource.fields).some((field) => {
401
+ const capabilities = isRecord(field.capabilities)
402
+ ? field.capabilities
403
+ : undefined;
404
+ return field.kind === "scalar"
405
+ ? capabilities?.update !== undefined
406
+ : advertisedDirectives(capabilities?.update) !== undefined;
407
+ }));
408
+ }
409
+ function deletesNothing(resource) {
410
+ return variantsOf(resource, ["delete"]).length === 0;
411
+ }
412
+ function advertisedOf(field, stage, directives) {
413
+ const capabilities = isRecord(field.capabilities)
414
+ ? field.capabilities
415
+ : undefined;
416
+ const advertised = advertisedDirectives(capabilities?.[stage]) ?? [];
417
+ return directives.filter((name) => advertised.includes(name));
418
+ }
419
+ function readOnlyNestedWrites(resources) {
420
+ const { created, updated } = writtenResources(resources);
421
+ const writes = [];
422
+ for (const [parent, resource] of Object.entries(resources)) {
423
+ for (const [relation, field] of Object.entries(resource.fields)) {
424
+ const target = field.target;
425
+ const targetResource = field.kind === "relation" && typeof target === "string"
426
+ ? own(resources, target)
427
+ : undefined;
428
+ if (typeof target !== "string" || targetResource === undefined) {
429
+ continue;
430
+ }
431
+ const reached = (directives) => [
432
+ ...new Set([
433
+ ...(created.has(parent)
434
+ ? advertisedOf(field, "create", directives)
435
+ : []),
436
+ ...(updated.has(parent)
437
+ ? advertisedOf(field, "update", directives)
438
+ : []),
439
+ ]),
440
+ ];
441
+ const creating = createsNothing(targetResource)
442
+ ? reached(CREATING_DIRECTIVES)
443
+ : [];
444
+ const updating = updatesNothing(targetResource)
445
+ ? reached(UPDATING_DIRECTIVES)
446
+ : [];
447
+ const deleting = deletesNothing(targetResource)
448
+ ? reached(DELETING_DIRECTIVES)
449
+ : [];
450
+ if (creating.length > 0 || updating.length > 0 || deleting.length > 0) {
451
+ writes.push({
452
+ parent,
453
+ relation,
454
+ target,
455
+ creating,
456
+ updating,
457
+ deleting,
458
+ });
459
+ }
460
+ }
461
+ }
462
+ return writes;
463
+ }
464
+ export function checkReadOnlyNestedWrites(layer, parent, projection, diagnostics) {
465
+ const inherited = new Map((parent === undefined ? [] : readOnlyNestedWrites(parent)).map((write) => [
466
+ `${write.parent}.${write.relation}`,
467
+ write,
468
+ ]));
469
+ for (const found of readOnlyNestedWrites(projection)) {
470
+ const at = `${found.parent}.${found.relation}`;
471
+ const above = inherited.get(at);
472
+ const write = {
473
+ ...found,
474
+ creating: (above?.creating.length ?? 0) > 0 ? [] : found.creating,
475
+ updating: (above?.updating.length ?? 0) > 0 ? [] : found.updating,
476
+ deleting: (above?.deleting.length ?? 0) > 0 ? [] : found.deleting,
477
+ };
478
+ if (write.creating.length === 0 &&
479
+ write.updating.length === 0 &&
480
+ write.deleting.length === 0) {
481
+ continue;
482
+ }
483
+ const directives = [
484
+ ...new Set([...write.creating, ...write.updating, ...write.deleting]),
485
+ ];
486
+ const lacks = [
487
+ ...(write.creating.length === 0
488
+ ? []
489
+ : ["no create operation and no field writable on create"]),
490
+ ...(write.updating.length === 0
491
+ ? []
492
+ : ["no update operation and no field writable on update"]),
493
+ ...(write.deleting.length === 0 ? [] : ["no delete operation"]),
494
+ ];
495
+ const gives = [
496
+ ...(write.creating.length === 0
497
+ ? []
498
+ : ["a create operation or a field writable on create"]),
499
+ ...(write.updating.length === 0
500
+ ? []
501
+ : ["an update operation or a field writable on update"]),
502
+ ...(write.deleting.length === 0 ? [] : ["a delete operation"]),
503
+ ];
504
+ const keep = `take ${listed(directives)} off "${at}" (keep only the directives it may use, such as $connect)`;
505
+ diagnostics.push({
506
+ code: "COMPILER_READ_ONLY_NESTED_WRITE",
507
+ path: `${layer}.exemptions.resources.${write.parent}.fields.${write.relation}`,
508
+ message: write.creating.length === 0 && write.updating.length === 0
509
+ ? `the ${layer} layer lets relation "${at}" delete rows of resource "${write.target}" ($delete), but "${write.target}" has no delete operation in that layer; ${keep}, or give "${write.target}" a delete operation`
510
+ : `the ${layer} layer lets relation "${at}" write rows of resource "${write.target}" (${listed(directives)}), but "${write.target}" is read-only in that layer: it has ${lacks.join(", and ")}; ${keep}, or give "${write.target}" ${gives.join(", and ")}`,
511
+ });
512
+ }
513
+ }
514
+ function dropDirectives(field, stage, dropped) {
515
+ if (!isRecord(field.capabilities)) {
516
+ return [];
517
+ }
518
+ const capabilities = field.capabilities;
519
+ const advertised = advertisedDirectives(capabilities[stage]) ?? [];
520
+ const removed = advertised.filter((name) => dropped.includes(name));
521
+ if (removed.length === 0) {
522
+ return [];
523
+ }
524
+ const narrowed = keptDirectives(capabilities[stage], advertised.filter((name) => !dropped.includes(name)));
525
+ if (narrowed === undefined) {
526
+ delete capabilities[stage];
527
+ }
528
+ else {
529
+ capabilities[stage] = narrowed;
530
+ }
531
+ return removed;
532
+ }
533
+ function* relationsOf(resources) {
534
+ for (const [parent, resource] of Object.entries(resources)) {
535
+ for (const [relation, field] of Object.entries(resource.fields)) {
536
+ const target = field.target;
537
+ const targetResource = field.kind === "relation" && typeof target === "string"
538
+ ? own(resources, target)
539
+ : undefined;
540
+ if (typeof target === "string" && targetResource !== undefined) {
541
+ yield [parent, relation, field, target, targetResource];
542
+ }
543
+ }
544
+ }
545
+ }
546
+ export function withheldKey(relation, stage, directive) {
547
+ return `${relation}.${stage}.${directive}`;
548
+ }
549
+ export function withholdReadOnlyNestedWrites(resources) {
550
+ const withheld = new Map();
551
+ const drop = (at, field, stage, directives, reason) => {
552
+ const removed = dropDirectives(field, stage, directives);
553
+ for (const directive of removed) {
554
+ withheld.set(withheldKey(at, stage, directive), reason);
555
+ }
556
+ return removed.length > 0;
557
+ };
558
+ let changed = true;
559
+ while (changed) {
560
+ changed = false;
561
+ for (const [parent, relation, field, , target] of relationsOf(resources)) {
562
+ const at = `${parent}.${relation}`;
563
+ for (const stage of ["create", "update"]) {
564
+ if (deletesNothing(target)) {
565
+ changed =
566
+ drop(at, field, stage, DELETING_DIRECTIVES, "delete") || changed;
567
+ }
568
+ if (createsNothing(target)) {
569
+ changed =
570
+ drop(at, field, stage, CREATING_DIRECTIVES, "create") || changed;
571
+ }
572
+ }
573
+ if (updatesNothing(target)) {
574
+ changed =
575
+ drop(at, field, "update", UPDATING_DIRECTIVES, "update") || changed;
576
+ }
577
+ }
578
+ }
579
+ return withheld;
580
+ }
370
581
  function misnamed(path, message) {
371
582
  return { code: "COMPILER_UNKNOWN_FIELD", path, message };
372
583
  }
@@ -1,4 +1,5 @@
1
1
  import type { AvAdapterModel } from "../adapters/adapter-model.js";
2
+ import type { AvRelationMutationDirective } from "../contracts/capability.js";
2
3
  import type { AvContractCompilerDiagnostic } from "./contract-compiler-diagnostic.js";
3
4
  type ProjectedField = Record<string, unknown> & {
4
5
  kind: "scalar" | "relation";
@@ -14,6 +15,8 @@ type ProjectedContract = {
14
15
  };
15
16
  /** The Resources the root's `resources` tree marks `managedElsewhere: true`. */
16
17
  export declare function managedElsewhereOf(rootTree: unknown): ReadonlySet<string>;
18
+ /** A relation mutation capability with only `kept` of its directives (its scopes kept), or `undefined` when none is left. */
19
+ export declare function keptDirectives(capability: unknown, kept: readonly AvRelationMutationDirective[]): unknown;
17
20
  /**
18
21
  * Takes every write of a managed Resource away from `baseline`: its write
19
22
  * families, its fields' `create`/`update`, and the directives of every relation
@@ -22,7 +22,7 @@ export function managedElsewhereOf(rootTree) {
22
22
  }
23
23
  return managed;
24
24
  }
25
- function keptDirectives(capability, kept) {
25
+ export function keptDirectives(capability, kept) {
26
26
  if (Array.isArray(capability)) {
27
27
  const left = capability.filter((directive) => kept.includes(directive));
28
28
  return left.length > 0 ? left : undefined;