@cynodia/axiom-core 0.7.0-alpha.2 → 0.8.0-alpha.1

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/README.md CHANGED
@@ -9,14 +9,18 @@ The Application Graph and its semantic model: nodes, fields, structured types,
9
9
  expressions, **locations** (addressable writable positions), edge derivation, validation
10
10
  and type inference. It also owns the presentation and UX layer — semantic roles, layout and
11
11
  spacing tokens, device classes, value formats and the `Theme` — with presentation
12
- resolution and validation.
12
+ resolution and validation. As of 0.8 it also owns the vocabulary for external
13
+ integrations, effects, triggers and events: `IntegrationDef`, `IntegrationOperationDef`,
14
+ `EventDef`, `TriggerDef`, and the `integration-query`/`integration-effect` operation
15
+ kinds — see [`docs/INTEGRATIONS.md`](https://github.com/cynodia/axiom/blob/main/docs/INTEGRATIONS.md).
13
16
 
14
17
  Presentation lives here because it is part of the canonical graph: it is intent, not
15
18
  styling, and it names no colour, length or CSS property.
16
19
 
17
20
  Main exports: `ApplicationGraph`, `TypeRef` builders, expression builders, `Location`
18
21
  builders, `Presentation`, `Theme`, `resolvePresentationMap`, `validateGraph`,
19
- `VALIDATION_CODES`, `ApplicationIR`.
22
+ `VALIDATION_CODES`, `ApplicationIR`, `ServerIR`, `IntegrationDef`, `IntegrationOperationDef`,
23
+ `EventDef`, `TriggerDef`.
20
24
 
21
25
  ## Installation
22
26
 
@@ -43,6 +43,8 @@ export interface AuthorityContext {
43
43
  export declare function authorityContext(nodes: readonly AnyNode[], principalEntityId?: NodeId): AuthorityContext;
44
44
  /** Every state a set of expressions reads, following derived state transitively. */
45
45
  export declare function statesReadBy(expressions: readonly Expression[], context: AuthorityContext): Set<NodeId>;
46
+ /** Whether an action calls out to an integration anywhere in its top-level operations. */
47
+ export declare function actionUsesIntegration(action: ActionDef): boolean;
46
48
  /** The states an action writes, following `for-each`, `invoke` and declared native effects. */
47
49
  export declare function statesWrittenBy(action: ActionDef, context: AuthorityContext, visited?: Set<NodeId>): Set<NodeId>;
48
50
  /** The states an action reads: its guards, its values, its selectors, its authorization. */
package/dist/authority.js CHANGED
@@ -112,10 +112,23 @@ function operationExpressions(operation) {
112
112
  found.push(...locationExpressions(operation.resultTarget));
113
113
  }
114
114
  break;
115
+ case 'integration-query':
116
+ found.push(...Object.values(operation.arguments ?? {}));
117
+ break;
118
+ case 'integration-effect':
119
+ found.push(...Object.values(operation.arguments ?? {}));
120
+ if (operation.idempotencyKey) {
121
+ found.push(operation.idempotencyKey);
122
+ }
123
+ break;
115
124
  default:
116
125
  }
117
126
  return found;
118
127
  }
128
+ /** Whether an action calls out to an integration anywhere in its top-level operations. */
129
+ export function actionUsesIntegration(action) {
130
+ return (action.operations ?? []).some((operation) => operation.kind === 'integration-query' || operation.kind === 'integration-effect');
131
+ }
119
132
  /** The states an action writes, following `for-each`, `invoke` and declared native effects. */
120
133
  export function statesWrittenBy(action, context, visited = new Set()) {
121
134
  const found = new Set();
@@ -152,6 +165,12 @@ export function statesWrittenBy(action, context, visited = new Set()) {
152
165
  }
153
166
  }
154
167
  break;
168
+ case 'integration-query':
169
+ case 'integration-effect':
170
+ // Neither writes Axiom state directly: a query's result is a transaction-local
171
+ // scope binding, and an effect's outcome reaches state only through a follow-up
172
+ // action invoked from its success/failure event.
173
+ break;
155
174
  default:
156
175
  }
157
176
  }
@@ -194,6 +213,12 @@ export function statesReadByAction(action, context, visited = new Set()) {
194
213
  * cannot disagree with what the action actually does.
195
214
  */
196
215
  export function actionAuthority(action, context) {
216
+ // Integrations default server-only (spec §65: secrets, trust, CORS, auditability,
217
+ // deterministic authority), so an action that calls one is unconditionally server —
218
+ // independent of what it writes.
219
+ if (actionUsesIntegration(action)) {
220
+ return 'server';
221
+ }
197
222
  for (const stateId of statesWrittenBy(action, context)) {
198
223
  const state = context.states.get(stateId);
199
224
  if (state && stateAuthority(state) === 'server') {
@@ -143,6 +143,27 @@ export function deriveEdges(nodes) {
143
143
  // bindings, no caller scope. Its parameters resolve to nothing here by design.
144
144
  reads(node.id, node.expression, new Map());
145
145
  break;
146
+ case 'integration-operation':
147
+ link(node.id, node.integrationId, 'references');
148
+ break;
149
+ case 'trigger':
150
+ link(node.id, node.actionId, 'invokes');
151
+ if (node.when.kind === 'event') {
152
+ link(node.id, node.when.eventId, 'references');
153
+ }
154
+ if (node.when.kind === 'lifecycle' && node.when.routeId) {
155
+ link(node.id, node.when.routeId, 'depends-on');
156
+ }
157
+ for (const argument of Object.values(node.arguments ?? {})) {
158
+ reads(node.id, argument, rootScope);
159
+ }
160
+ if (node.enabledWhen) {
161
+ reads(node.id, node.enabledWhen, rootScope);
162
+ }
163
+ break;
164
+ case 'integration':
165
+ case 'event':
166
+ break;
146
167
  default:
147
168
  }
148
169
  }
@@ -415,6 +436,27 @@ function linkOperations(actionId, operations, linker, scope) {
415
436
  }
416
437
  }
417
438
  break;
439
+ case 'integration-query':
440
+ linker.link(actionId, operation.operationId, 'references');
441
+ for (const argument of Object.values(operation.arguments ?? {})) {
442
+ linker.reads(actionId, argument, scope);
443
+ }
444
+ break;
445
+ case 'integration-effect':
446
+ linker.link(actionId, operation.operationId, 'references');
447
+ for (const argument of Object.values(operation.arguments ?? {})) {
448
+ linker.reads(actionId, argument, scope);
449
+ }
450
+ if (operation.idempotencyKey) {
451
+ linker.reads(actionId, operation.idempotencyKey, scope);
452
+ }
453
+ if (operation.succeededEventId) {
454
+ linker.link(actionId, operation.succeededEventId, 'references');
455
+ }
456
+ if (operation.failedEventId) {
457
+ linker.link(actionId, operation.failedEventId, 'references');
458
+ }
459
+ break;
418
460
  default:
419
461
  }
420
462
  }
@@ -88,5 +88,18 @@ export declare const VALIDATION_CODES: {
88
88
  readonly serverOnlyStateObserved: "SERVER_ONLY_STATE_OBSERVED";
89
89
  readonly invalidPrincipalEntity: "INVALID_PRINCIPAL_ENTITY";
90
90
  readonly missingActionArgument: "MISSING_ACTION_ARGUMENT";
91
+ /** An `IntegrationOperationDef.integrationId`, or an operation's `operationId`, that does not resolve. */
92
+ readonly unknownIntegration: "UNKNOWN_INTEGRATION";
93
+ readonly unknownIntegrationOperation: "UNKNOWN_INTEGRATION_OPERATION";
94
+ /** An `integration-query` operation naming an effect, or an `integration-effect` naming a query. */
95
+ readonly integrationOperationModeMismatch: "INTEGRATION_OPERATION_MODE_MISMATCH";
96
+ /** A missing required argument, or an argument the operation declares no parameter for. */
97
+ readonly integrationArgumentMismatch: "INTEGRATION_ARGUMENT_MISMATCH";
98
+ readonly triggerActionNotFound: "TRIGGER_ACTION_NOT_FOUND";
99
+ readonly triggerIntervalNotPositive: "TRIGGER_INTERVAL_NOT_POSITIVE";
100
+ /** An event id that does not resolve to an `EventDef` — a trigger's `eventId`, or an effect's success/failure event. */
101
+ readonly unknownEvent: "UNKNOWN_EVENT";
102
+ /** An `event` trigger targeting a client-authority action, or a `route-enter`/`route-leave` trigger targeting a server-authority one. */
103
+ readonly triggerWrongAuthority: "TRIGGER_WRONG_AUTHORITY";
91
104
  };
92
105
  //# sourceMappingURL=diagnostics.d.ts.map
@@ -76,4 +76,18 @@ export const VALIDATION_CODES = {
76
76
  serverOnlyStateObserved: 'SERVER_ONLY_STATE_OBSERVED',
77
77
  invalidPrincipalEntity: 'INVALID_PRINCIPAL_ENTITY',
78
78
  missingActionArgument: 'MISSING_ACTION_ARGUMENT',
79
+ // Integrations, effects, triggers and events.
80
+ /** An `IntegrationOperationDef.integrationId`, or an operation's `operationId`, that does not resolve. */
81
+ unknownIntegration: 'UNKNOWN_INTEGRATION',
82
+ unknownIntegrationOperation: 'UNKNOWN_INTEGRATION_OPERATION',
83
+ /** An `integration-query` operation naming an effect, or an `integration-effect` naming a query. */
84
+ integrationOperationModeMismatch: 'INTEGRATION_OPERATION_MODE_MISMATCH',
85
+ /** A missing required argument, or an argument the operation declares no parameter for. */
86
+ integrationArgumentMismatch: 'INTEGRATION_ARGUMENT_MISMATCH',
87
+ triggerActionNotFound: 'TRIGGER_ACTION_NOT_FOUND',
88
+ triggerIntervalNotPositive: 'TRIGGER_INTERVAL_NOT_POSITIVE',
89
+ /** An event id that does not resolve to an `EventDef` — a trigger's `eventId`, or an effect's success/failure event. */
90
+ unknownEvent: 'UNKNOWN_EVENT',
91
+ /** An `event` trigger targeting a client-authority action, or a `route-enter`/`route-leave` trigger targeting a server-authority one. */
92
+ triggerWrongAuthority: 'TRIGGER_WRONG_AUTHORITY',
79
93
  };
@@ -0,0 +1,13 @@
1
+ import type { NodeBase } from './nodes.js';
2
+ import type { TypeRef } from './type-ref.js';
3
+ /**
4
+ * A semantic fact that occurred — a webhook delivery, an effect's outcome. An event is
5
+ * not work: it names something that happened, with a typed payload, and it is `TriggerDef`
6
+ * that says what happens next. Nothing resolves an event payload as `unknown`; it is
7
+ * validated against `payloadType` before it reaches any action.
8
+ */
9
+ export interface EventDef extends NodeBase {
10
+ kind: 'event';
11
+ payloadType: TypeRef;
12
+ }
13
+ //# sourceMappingURL=events.d.ts.map
package/dist/events.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/dist/graph.js CHANGED
@@ -23,7 +23,7 @@ export class ApplicationGraph {
23
23
  /** Bumped by every change, so the derived edge index can never serve stale data. */
24
24
  revision = 0;
25
25
  semanticIndex;
26
- constructor(id, name, version = '0.7.0') {
26
+ constructor(id, name, version = '0.8.0') {
27
27
  this.data = { id, name, version, nodes: {}, edges: {} };
28
28
  }
29
29
  get id() {
package/dist/index.d.ts CHANGED
@@ -7,6 +7,9 @@ export * from './type-ref.js';
7
7
  export * from './group.js';
8
8
  export * from './expressions.js';
9
9
  export * from './nodes.js';
10
+ export * from './integrations.js';
11
+ export * from './events.js';
12
+ export * from './triggers.js';
10
13
  export * from './renderer-capabilities.js';
11
14
  export * from './authoring-metadata.js';
12
15
  export * from './ui.js';
package/dist/index.js CHANGED
@@ -7,6 +7,9 @@ export * from './type-ref.js';
7
7
  export * from './group.js';
8
8
  export * from './expressions.js';
9
9
  export * from './nodes.js';
10
+ export * from './integrations.js';
11
+ export * from './events.js';
12
+ export * from './triggers.js';
10
13
  export * from './renderer-capabilities.js';
11
14
  export * from './authoring-metadata.js';
12
15
  export * from './ui.js';
@@ -0,0 +1,57 @@
1
+ import type { NodeId } from './ids.js';
2
+ import type { NodeBase } from './nodes.js';
3
+ import type { TypeRef } from './type-ref.js';
4
+ /**
5
+ * An external capability domain — a shipping provider, a device fleet, a payments
6
+ * processor. It names the capability, never the SDK, host name, secret or HTTP client
7
+ * that eventually implements it: those are supplied by a host adapter, never by the
8
+ * graph.
9
+ */
10
+ export interface IntegrationDef extends NodeBase {
11
+ kind: 'integration';
12
+ }
13
+ /**
14
+ * A query observes an external system and does not intentionally mutate it. An effect
15
+ * may mutate or otherwise cause an irreversible external consequence. The distinction is
16
+ * load-bearing: a query's result may be used mid-transaction, while an effect is never
17
+ * rollback-capable and is dispatched only after the transaction that requested it commits.
18
+ */
19
+ export type IntegrationOperationMode = 'query' | 'effect';
20
+ export declare const INTEGRATION_OPERATION_MODES: readonly IntegrationOperationMode[];
21
+ export type RetryPolicyKind = 'none' | 'fixed' | 'exponential';
22
+ export declare const RETRY_POLICY_KINDS: readonly RetryPolicyKind[];
23
+ export interface RetryPolicy {
24
+ policy: RetryPolicyKind;
25
+ maxAttempts?: number;
26
+ delayMs?: number;
27
+ }
28
+ export interface IntegrationOperationParameter {
29
+ id: NodeId;
30
+ name?: string;
31
+ valueType: TypeRef;
32
+ required?: boolean;
33
+ }
34
+ /**
35
+ * A typed semantic operation an integration exposes.
36
+ *
37
+ * Its result must conform to `resultType` — a provider response that does not is rejected
38
+ * at the adapter/runtime boundary, never handed to the application as `unknown`.
39
+ */
40
+ export interface IntegrationOperationDef extends NodeBase {
41
+ kind: 'integration-operation';
42
+ integrationId: NodeId;
43
+ mode: IntegrationOperationMode;
44
+ parameters?: IntegrationOperationParameter[];
45
+ resultType: TypeRef;
46
+ /**
47
+ * Whether this operation may be invoked from client-executed code. Absent means
48
+ * server-only, which is the default for every integration (spec §65): client safety is
49
+ * never inferred from the absence of a declared secret, only declared explicitly.
50
+ */
51
+ clientSafe?: boolean;
52
+ /** Whether invoking this effect twice with the same idempotency key has one effect. */
53
+ idempotent?: boolean;
54
+ /** Retry policy for a failed effect. Absent means `'none'`. Meaningless for a query. */
55
+ retry?: RetryPolicy;
56
+ }
57
+ //# sourceMappingURL=integrations.d.ts.map
@@ -0,0 +1,2 @@
1
+ export const INTEGRATION_OPERATION_MODES = ['query', 'effect'];
2
+ export const RETRY_POLICY_KINDS = ['none', 'fixed', 'exponential'];
package/dist/ir.d.ts CHANGED
@@ -7,6 +7,7 @@ import type { TypeRef } from './type-ref.js';
7
7
  import type { Authority } from './authority.js';
8
8
  import type { ResolvedPresentation } from './presentation.js';
9
9
  import type { Theme } from './theme.js';
10
+ import type { TriggerDef } from './triggers.js';
10
11
  export interface RouteSegment {
11
12
  kind: 'static' | 'parameter';
12
13
  value: string;
@@ -92,5 +93,13 @@ export interface ApplicationIR {
92
93
  * second renderer remains possible.
93
94
  */
94
95
  presentation: Record<NodeId, ResolvedPresentation>;
96
+ /**
97
+ * Client-authority triggers only — `interval`/`delay`/`lifecycle('application-start' |
98
+ * 'runtime-ready')` triggers whose target action executes locally, plus
99
+ * `lifecycle('route-enter' | 'route-leave')` triggers, which are inherently a client
100
+ * concept. No `event`-kind trigger, no integration, and no secret ever reaches this IR
101
+ * (spec §80).
102
+ */
103
+ triggers: TriggerDef[];
95
104
  }
96
105
  //# sourceMappingURL=ir.d.ts.map
package/dist/nodes.d.ts CHANGED
@@ -216,7 +216,7 @@ export interface ActionDef extends NodeBase {
216
216
  * condition with its failure; the older parallel arrays are matched by position.
217
217
  */
218
218
  export declare function actionGuards(action: ActionDef): ActionGuard[];
219
- export type Operation = SetOperation | InsertOperation | RemoveOperation | ForEachOperation | InvokeOperation | NavigateOperation | NativeOperation;
219
+ export type Operation = SetOperation | InsertOperation | RemoveOperation | ForEachOperation | InvokeOperation | NavigateOperation | NativeOperation | IntegrationQueryOperation | IntegrationEffectOperation;
220
220
  export type OperationKind = Operation['kind'];
221
221
  /** Every operation kind the runtime is required to execute. */
222
222
  export declare const OPERATION_KINDS: readonly OperationKind[];
@@ -290,6 +290,44 @@ export interface NavigateOperation {
290
290
  /** Keyed by route parameter id. */
291
291
  parameters?: Record<string, Expression>;
292
292
  }
293
+ /**
294
+ * Executes an integration query and binds its result into scope for later operations in
295
+ * the same action.
296
+ *
297
+ * A query is awaited mid-transaction — its result may inform the mutations that follow —
298
+ * but evaluating it is not an ordinary Expression, because it is not deterministic over
299
+ * semantic state alone. `bindAs` introduces a scope the way a `for-each`'s `scopeId` does:
300
+ * later operations refer to the whole result as `ref(bindAs)`. Never legal inside
301
+ * `for-each`.
302
+ */
303
+ export interface IntegrationQueryOperation {
304
+ kind: 'integration-query';
305
+ /** The `IntegrationOperationDef` to call. Must declare `mode: 'query'`. */
306
+ operationId: NodeId;
307
+ arguments?: Record<string, Expression>;
308
+ bindAs: NodeId;
309
+ timeoutMs?: number;
310
+ }
311
+ /**
312
+ * Records intent to perform an external effect. It never calls the adapter during the
313
+ * transaction: reaching this operation only appends an effect intent, discarded on
314
+ * rollback exactly like a mutation is. Dispatch to the adapter happens only after the
315
+ * surrounding transaction commits — the outbox invariant (spec §18) — so an effect is
316
+ * never rollback-capable and never mid-transaction. Never legal inside `for-each`.
317
+ *
318
+ * `succeededEventId`/`failedEventId`, when declared, are dispatched through the ordinary
319
+ * event pipeline once the effect's outcome is known — an effect's result is never folded
320
+ * back into the transaction that requested it.
321
+ */
322
+ export interface IntegrationEffectOperation {
323
+ kind: 'integration-effect';
324
+ /** The `IntegrationOperationDef` to call. Must declare `mode: 'effect'`. */
325
+ operationId: NodeId;
326
+ arguments?: Record<string, Expression>;
327
+ idempotencyKey?: Expression;
328
+ succeededEventId?: NodeId;
329
+ failedEventId?: NodeId;
330
+ }
293
331
  export type NativeEffect = {
294
332
  kind: 'reads-state';
295
333
  stateId: NodeId;
package/dist/nodes.js CHANGED
@@ -20,6 +20,8 @@ export const OPERATION_KINDS = [
20
20
  'invoke',
21
21
  'navigate',
22
22
  'native',
23
+ 'integration-query',
24
+ 'integration-effect',
23
25
  ];
24
26
  export function isMutationOperation(operation) {
25
27
  return operation.kind === 'set' || operation.kind === 'insert' || operation.kind === 'remove';
@@ -2,6 +2,9 @@ import type { FieldId, NodeId } from './ids.js';
2
2
  import type { ActionDef, ConstraintDef, EntityDef, ExpressionDef, StateDef, TransitionConstraintDef } from './nodes.js';
3
3
  import type { Expression } from './expressions.js';
4
4
  import type { FieldIndexEntry } from './graph.js';
5
+ import type { EventDef } from './events.js';
6
+ import type { IntegrationDef, IntegrationOperationDef } from './integrations.js';
7
+ import type { TriggerDef } from './triggers.js';
5
8
  /**
6
9
  * The contracts a Server IR may declare. A runtime that does not recognize the value MUST
7
10
  * refuse the IR rather than interpret it partially.
@@ -9,13 +12,15 @@ import type { FieldIndexEntry } from './graph.js';
9
12
  * `axiom.server.v1` is frozen and stays frozen. 0.7 adds two constructs to the expression
10
13
  * vocabulary — `group` and `expression-ref`, with the `expressionDefs` they resolve against
11
14
  * — and a document that uses them is **not** a v1 document: a conforming v1 runtime has
12
- * never heard of them and must refuse it rather than execute half of it. So the vocabulary a
13
- * document actually uses decides its label.
15
+ * never heard of them and must refuse it rather than execute half of it. 0.8 adds
16
+ * integrations, effects, triggers and events, which is a third, independent reason a
17
+ * document may not be a v1 (or v2) document. So the vocabulary a document actually uses
18
+ * decides its label.
14
19
  *
15
20
  * Every existing application therefore still compiles to a byte-identical
16
21
  * `axiom.server.v1` document, and the frozen conformance fixtures stay frozen.
17
22
  */
18
- export declare const SERVER_IR_CONTRACTS: readonly ["axiom.server.v1", "axiom.server.v2"];
23
+ export declare const SERVER_IR_CONTRACTS: readonly ["axiom.server.v1", "axiom.server.v2", "axiom.server.v3"];
19
24
  export type ServerIRContract = (typeof SERVER_IR_CONTRACTS)[number];
20
25
  /** The oldest contract, and the one a document declares unless it needs more. */
21
26
  export declare const SERVER_IR_CONTRACT: ServerIRContract;
@@ -31,6 +36,18 @@ export declare const SERVER_IR_V2_EXPRESSION_KINDS: readonly string[];
31
36
  * document breaks.
32
37
  */
33
38
  export declare function requiredServerContract(expressions: readonly Expression[]): ServerIRContract;
39
+ /**
40
+ * Whether a document's integration/trigger/event vocabulary requires `axiom.server.v3` —
41
+ * none of it exists in v1 or v2, so any of it present is enough.
42
+ */
43
+ export declare function usesIntegrationVocabulary(ir: {
44
+ integrations?: readonly unknown[];
45
+ integrationOperations?: Record<string, unknown>;
46
+ events?: readonly unknown[];
47
+ triggers?: readonly unknown[];
48
+ }): boolean;
49
+ /** The higher of two contracts, ordered by `SERVER_IR_CONTRACTS`. */
50
+ export declare function maxContract(a: ServerIRContract, b: ServerIRContract): ServerIRContract;
34
51
  /** Every expression a Server IR document contains, in no particular order. */
35
52
  export declare function serverIRExpressions(ir: {
36
53
  states: readonly StateDef[];
@@ -38,6 +55,7 @@ export declare function serverIRExpressions(ir: {
38
55
  constraints: readonly ConstraintDef[];
39
56
  transitionConstraints: readonly TransitionConstraintDef[];
40
57
  expressionDefs?: Record<NodeId, ExpressionDef>;
58
+ triggers?: readonly TriggerDef[];
41
59
  }): Expression[];
42
60
  /**
43
61
  * The normalized form an authority executes: everything required to decide a mutation, and
@@ -80,5 +98,17 @@ export interface ServerIR {
80
98
  principalEntityId?: NodeId;
81
99
  /** The states a client is permitted to observe, in declaration order. */
82
100
  observableStateIds: NodeId[];
101
+ /**
102
+ * External capability domains this document calls out to. Absent in `axiom.server.v1`
103
+ * and `v2` documents, which have no way to reference one. Never carries a secret,
104
+ * host name or SDK — only the semantic operation shape (spec §5).
105
+ */
106
+ integrations?: IntegrationDef[];
107
+ /** Typed operations the integrations above expose, by id — dispatched by id constantly. */
108
+ integrationOperations?: Record<NodeId, IntegrationOperationDef>;
109
+ /** Semantic facts this document's triggers react to. */
110
+ events?: EventDef[];
111
+ /** Server-authority triggers only — interval, delay, lifecycle and event triggers whose target action executes here. */
112
+ triggers?: TriggerDef[];
83
113
  }
84
114
  //# sourceMappingURL=server-ir.d.ts.map
package/dist/server-ir.js CHANGED
@@ -6,17 +6,19 @@ import { walkExpression } from './expressions.js';
6
6
  * `axiom.server.v1` is frozen and stays frozen. 0.7 adds two constructs to the expression
7
7
  * vocabulary — `group` and `expression-ref`, with the `expressionDefs` they resolve against
8
8
  * — and a document that uses them is **not** a v1 document: a conforming v1 runtime has
9
- * never heard of them and must refuse it rather than execute half of it. So the vocabulary a
10
- * document actually uses decides its label.
9
+ * never heard of them and must refuse it rather than execute half of it. 0.8 adds
10
+ * integrations, effects, triggers and events, which is a third, independent reason a
11
+ * document may not be a v1 (or v2) document. So the vocabulary a document actually uses
12
+ * decides its label.
11
13
  *
12
14
  * Every existing application therefore still compiles to a byte-identical
13
15
  * `axiom.server.v1` document, and the frozen conformance fixtures stay frozen.
14
16
  */
15
- export const SERVER_IR_CONTRACTS = ['axiom.server.v1', 'axiom.server.v2'];
17
+ export const SERVER_IR_CONTRACTS = ['axiom.server.v1', 'axiom.server.v2', 'axiom.server.v3'];
16
18
  /** The oldest contract, and the one a document declares unless it needs more. */
17
19
  export const SERVER_IR_CONTRACT = 'axiom.server.v1';
18
20
  /** The newest contract this implementation produces and executes. */
19
- export const SERVER_IR_LATEST_CONTRACT = 'axiom.server.v2';
21
+ export const SERVER_IR_LATEST_CONTRACT = 'axiom.server.v3';
20
22
  /** Expression kinds that `axiom.server.v1` does not contain. */
21
23
  export const SERVER_IR_V2_EXPRESSION_KINDS = ['group', 'expression-ref'];
22
24
  /**
@@ -37,6 +39,20 @@ export function requiredServerContract(expressions) {
37
39
  }
38
40
  return required;
39
41
  }
42
+ /**
43
+ * Whether a document's integration/trigger/event vocabulary requires `axiom.server.v3` —
44
+ * none of it exists in v1 or v2, so any of it present is enough.
45
+ */
46
+ export function usesIntegrationVocabulary(ir) {
47
+ return ((ir.integrations?.length ?? 0) > 0 ||
48
+ Object.keys(ir.integrationOperations ?? {}).length > 0 ||
49
+ (ir.events?.length ?? 0) > 0 ||
50
+ (ir.triggers?.length ?? 0) > 0);
51
+ }
52
+ /** The higher of two contracts, ordered by `SERVER_IR_CONTRACTS`. */
53
+ export function maxContract(a, b) {
54
+ return SERVER_IR_CONTRACTS.indexOf(b) > SERVER_IR_CONTRACTS.indexOf(a) ? b : a;
55
+ }
40
56
  /** Every expression a Server IR document contains, in no particular order. */
41
57
  export function serverIRExpressions(ir) {
42
58
  const found = [];
@@ -57,6 +73,12 @@ export function serverIRExpressions(ir) {
57
73
  for (const definition of Object.values(ir.expressionDefs ?? {})) {
58
74
  found.push(definition.expression);
59
75
  }
76
+ for (const trigger of ir.triggers ?? []) {
77
+ found.push(...Object.values(trigger.arguments ?? {}));
78
+ if (trigger.enabledWhen) {
79
+ found.push(trigger.enabledWhen);
80
+ }
81
+ }
60
82
  return found;
61
83
  }
62
84
  function actionExpressions(action) {
@@ -86,6 +108,15 @@ function actionExpressions(action) {
86
108
  case 'native':
87
109
  found.push(...Object.values(operation.inputs ?? {}));
88
110
  break;
111
+ case 'integration-query':
112
+ found.push(...Object.values(operation.arguments ?? {}));
113
+ break;
114
+ case 'integration-effect':
115
+ found.push(...Object.values(operation.arguments ?? {}));
116
+ if (operation.idempotencyKey) {
117
+ found.push(operation.idempotencyKey);
118
+ }
119
+ break;
89
120
  default:
90
121
  }
91
122
  }
@@ -0,0 +1,53 @@
1
+ import type { Expression } from './expressions.js';
2
+ import type { NodeId } from './ids.js';
3
+ import type { NodeBase } from './nodes.js';
4
+ /**
5
+ * What happens when a tick fires while the previous invocation of the same trigger is
6
+ * still running. `'skip'` (the default) no-ops the tick; `'queue'` runs one pending tick
7
+ * immediately after the in-flight one finishes. Neither ever runs two invocations of the
8
+ * same trigger concurrently — overlap is never accidental.
9
+ */
10
+ export type TriggerOverlapPolicy = 'skip' | 'queue';
11
+ export declare const TRIGGER_OVERLAP_POLICIES: readonly TriggerOverlapPolicy[];
12
+ export type LifecycleEvent = 'application-start' | 'runtime-ready' | 'route-enter' | 'route-leave';
13
+ export declare const LIFECYCLE_EVENTS: readonly LifecycleEvent[];
14
+ export type TriggerKind = 'interval' | 'delay' | 'lifecycle' | 'event';
15
+ export declare const TRIGGER_KINDS: readonly TriggerKind[];
16
+ /**
17
+ * When a trigger fires. `everyMs`/`afterMs` are plain numbers, never expressions —
18
+ * scheduling stays static; only `TriggerDef.enabledWhen` is dynamic.
19
+ */
20
+ export type TriggerSpec = {
21
+ kind: 'interval';
22
+ everyMs: number;
23
+ overlap?: TriggerOverlapPolicy;
24
+ } | {
25
+ kind: 'delay';
26
+ afterMs: number;
27
+ } | {
28
+ kind: 'lifecycle';
29
+ event: LifecycleEvent;
30
+ routeId?: NodeId;
31
+ } | {
32
+ kind: 'event';
33
+ eventId: NodeId;
34
+ };
35
+ /**
36
+ * Describes when an action should be invoked, without embedding callback code.
37
+ *
38
+ * A trigger invokes the target action through the same semantics as any other caller —
39
+ * the same guards, constraints, transition constraints and authorization apply. For an
40
+ * `event`-kind trigger, `arguments` expressions may `ref` the trigger's own id to read the
41
+ * event's payload, the same way a `for-each`/`map` scope id lets a body read the current
42
+ * member.
43
+ */
44
+ export interface TriggerDef extends NodeBase {
45
+ kind: 'trigger';
46
+ actionId: NodeId;
47
+ when: TriggerSpec;
48
+ /** Keyed by the target action's parameter id. */
49
+ arguments?: Record<string, Expression>;
50
+ /** Dynamic enablement — evaluated each time the trigger would otherwise fire. */
51
+ enabledWhen?: Expression;
52
+ }
53
+ //# sourceMappingURL=triggers.d.ts.map
@@ -0,0 +1,8 @@
1
+ export const TRIGGER_OVERLAP_POLICIES = ['skip', 'queue'];
2
+ export const LIFECYCLE_EVENTS = [
3
+ 'application-start',
4
+ 'runtime-ready',
5
+ 'route-enter',
6
+ 'route-leave',
7
+ ];
8
+ export const TRIGGER_KINDS = ['interval', 'delay', 'lifecycle', 'event'];
package/dist/types.d.ts CHANGED
@@ -2,11 +2,14 @@ import type { ActionDef, ConstraintDef, EntityDef, ExpressionDef, GraphEdge, Rou
2
2
  import type { NodeId } from './ids.js';
3
3
  import type { UINode, UINodeKind } from './ui.js';
4
4
  import type { ThemeInput } from './theme.js';
5
- export type SemanticNodeKind = 'entity' | 'state' | 'action' | 'constraint' | 'transition-constraint' | 'route' | 'expression';
5
+ import type { EventDef } from './events.js';
6
+ import type { IntegrationDef, IntegrationOperationDef } from './integrations.js';
7
+ import type { TriggerDef } from './triggers.js';
8
+ export type SemanticNodeKind = 'entity' | 'state' | 'action' | 'constraint' | 'transition-constraint' | 'route' | 'expression' | 'integration' | 'integration-operation' | 'event' | 'trigger';
6
9
  /** Every semantic node kind, enumerated so tests can walk them. */
7
10
  export declare const SEMANTIC_NODE_KINDS: readonly SemanticNodeKind[];
8
11
  export type NodeKind = SemanticNodeKind | UINodeKind;
9
- export type AnyNode = EntityDef | StateDef | ActionDef | ConstraintDef | TransitionConstraintDef | RouteDef | ExpressionDef | UINode;
12
+ export type AnyNode = EntityDef | StateDef | ActionDef | ConstraintDef | TransitionConstraintDef | RouteDef | ExpressionDef | IntegrationDef | IntegrationOperationDef | EventDef | TriggerDef | UINode;
10
13
  export type NodeOfKind<K extends NodeKind> = Extract<AnyNode, {
11
14
  kind: K;
12
15
  }>;
package/dist/types.js CHANGED
@@ -7,4 +7,8 @@ export const SEMANTIC_NODE_KINDS = [
7
7
  'transition-constraint',
8
8
  'route',
9
9
  'expression',
10
+ 'integration',
11
+ 'integration-operation',
12
+ 'event',
13
+ 'trigger',
10
14
  ];
@@ -136,6 +136,35 @@ export function validateAuthority(nodes, principalEntityId) {
136
136
  }
137
137
  }
138
138
  }
139
+ // An `event` trigger only ever fires where the server dispatches an event, so its
140
+ // target action must be server-authority. A `route-enter`/`route-leave` trigger only
141
+ // ever fires from the client router, so its target must be client-authority.
142
+ for (const node of nodes) {
143
+ if (node.kind !== 'trigger') {
144
+ continue;
145
+ }
146
+ const target = context.actions.get(node.actionId);
147
+ if (!target) {
148
+ continue;
149
+ }
150
+ const authority = actionAuthority(target, context);
151
+ if (node.when.kind === 'event' && authority !== 'server') {
152
+ errors.push({
153
+ code: VALIDATION_CODES.triggerWrongAuthority,
154
+ message: `Trigger ${node.name ?? node.id} fires on an event, which only the server dispatches, but ${target.name ?? target.id} is client-authority`,
155
+ nodeId: node.id,
156
+ details: { actionId: target.id, authority },
157
+ });
158
+ }
159
+ if (node.when.kind === 'lifecycle' && (node.when.event === 'route-enter' || node.when.event === 'route-leave') && authority !== 'client') {
160
+ errors.push({
161
+ code: VALIDATION_CODES.triggerWrongAuthority,
162
+ message: `Trigger ${node.name ?? node.id} fires on ${node.when.event}, which only the client router dispatches, but ${target.name ?? target.id} is server-authority`,
163
+ nodeId: node.id,
164
+ details: { actionId: target.id, authority },
165
+ });
166
+ }
167
+ }
139
168
  // The principal exists only where an authority evaluates. Reading it anywhere a client
140
169
  // evaluates would be a rule the client could simply not apply.
141
170
  reportPrincipalOnClient(nodes, context, errors);
package/dist/validate.js CHANGED
@@ -134,6 +134,18 @@ function validateNode(node, context) {
134
134
  case 'expression':
135
135
  validateExpressionDef(node, context);
136
136
  return;
137
+ case 'integration':
138
+ validateIntegrationDef(node, context);
139
+ return;
140
+ case 'integration-operation':
141
+ validateIntegrationOperation(node, context);
142
+ return;
143
+ case 'event':
144
+ validateEvent(node, context);
145
+ return;
146
+ case 'trigger':
147
+ validateTrigger(node, context);
148
+ return;
137
149
  default:
138
150
  context.errors.push({
139
151
  code: VALIDATION_CODES.danglingNodeRef,
@@ -238,8 +250,9 @@ function validateAction(action, context) {
238
250
  for (const postcondition of action.postconditions ?? []) {
239
251
  validateExpression(postcondition, action.id, context, local);
240
252
  }
253
+ let scoped = local;
241
254
  for (const operation of action.operations ?? []) {
242
- validateOperation(operation, action, context, local);
255
+ scoped = validateOperation(operation, action, context, scoped);
243
256
  }
244
257
  }
245
258
  function validateOperation(operation, action, context, local) {
@@ -266,13 +279,13 @@ function validateOperation(operation, action, context, local) {
266
279
  }
267
280
  validateOperation(nested, action, context, scoped);
268
281
  }
269
- return;
282
+ return local;
270
283
  }
271
284
  case 'set': {
272
285
  checkLocation(operation.target, action.id, context, local, true);
273
286
  validateExpression(operation.value, action.id, context, local);
274
287
  checkAssignment(operation.target, operation.value, action.id, context, local);
275
- return;
288
+ return local;
276
289
  }
277
290
  case 'insert': {
278
291
  checkLocation(operation.target, action.id, context, local, true);
@@ -284,16 +297,16 @@ function validateOperation(operation, action, context, local) {
284
297
  message: `Action ${action.id} inserts into a ${target.kind} location, which is not a collection`,
285
298
  nodeId: action.id,
286
299
  });
287
- return;
300
+ return local;
288
301
  }
289
302
  if (target?.kind === 'collection') {
290
303
  reportIncompatible(target.itemType, inferExpressionType(operation.value, context.semantics, local.types), action.id, context);
291
304
  }
292
- return;
305
+ return local;
293
306
  }
294
307
  case 'remove':
295
308
  checkLocation(operation.target, action.id, context, local, true);
296
- return;
309
+ return local;
297
310
  case 'invoke': {
298
311
  requireKind(operation.actionId, 'action', action.id, context, VALIDATION_CODES.invalidActionRef);
299
312
  const target = context.nodes.get(operation.actionId);
@@ -307,7 +320,7 @@ function validateOperation(operation, action, context, local) {
307
320
  });
308
321
  }
309
322
  }
310
- return;
323
+ return local;
311
324
  }
312
325
  case 'navigate':
313
326
  if (operation.routeId) {
@@ -323,7 +336,7 @@ function validateOperation(operation, action, context, local) {
323
336
  for (const argument of Object.values(operation.parameters ?? {})) {
324
337
  validateExpression(argument, action.id, context, local);
325
338
  }
326
- return;
339
+ return local;
327
340
  case 'native':
328
341
  for (const input of Object.values(operation.inputs ?? {})) {
329
342
  validateExpression(input, action.id, context, local);
@@ -336,13 +349,61 @@ function validateOperation(operation, action, context, local) {
336
349
  requireKind(effect.stateId, 'state', action.id, context, VALIDATION_CODES.invalidStateRef);
337
350
  }
338
351
  }
339
- return;
352
+ return local;
353
+ case 'integration-query': {
354
+ requireKind(operation.operationId, 'integration-operation', action.id, context, VALIDATION_CODES.unknownIntegrationOperation);
355
+ const target = context.nodes.get(operation.operationId);
356
+ let resultType;
357
+ if (target?.kind === 'integration-operation') {
358
+ if (target.mode !== 'query') {
359
+ context.errors.push({
360
+ code: VALIDATION_CODES.integrationOperationModeMismatch,
361
+ message: `Action ${action.id} uses integration-query with ${operation.operationId}, which is an effect operation`,
362
+ nodeId: action.id,
363
+ });
364
+ }
365
+ resultType = target.resultType;
366
+ checkIntegrationArguments(target, operation.arguments ?? {}, action.id, context);
367
+ }
368
+ for (const argument of Object.values(operation.arguments ?? {})) {
369
+ validateExpression(argument, action.id, context, local);
370
+ }
371
+ return resultScope(local, operation.bindAs, resultType, context, action.id);
372
+ }
373
+ case 'integration-effect': {
374
+ requireKind(operation.operationId, 'integration-operation', action.id, context, VALIDATION_CODES.unknownIntegrationOperation);
375
+ const target = context.nodes.get(operation.operationId);
376
+ if (target?.kind === 'integration-operation') {
377
+ if (target.mode !== 'effect') {
378
+ context.errors.push({
379
+ code: VALIDATION_CODES.integrationOperationModeMismatch,
380
+ message: `Action ${action.id} uses integration-effect with ${operation.operationId}, which is a query operation`,
381
+ nodeId: action.id,
382
+ });
383
+ }
384
+ checkIntegrationArguments(target, operation.arguments ?? {}, action.id, context);
385
+ }
386
+ for (const argument of Object.values(operation.arguments ?? {})) {
387
+ validateExpression(argument, action.id, context, local);
388
+ }
389
+ if (operation.idempotencyKey) {
390
+ validateExpression(operation.idempotencyKey, action.id, context, local);
391
+ }
392
+ if (operation.succeededEventId) {
393
+ requireKind(operation.succeededEventId, 'event', action.id, context, VALIDATION_CODES.unknownEvent);
394
+ }
395
+ if (operation.failedEventId) {
396
+ requireKind(operation.failedEventId, 'event', action.id, context, VALIDATION_CODES.unknownEvent);
397
+ }
398
+ return local;
399
+ }
340
400
  default:
341
401
  context.errors.push({
342
402
  code: VALIDATION_CODES.danglingNodeRef,
343
403
  message: `Unknown operation kind in action ${action.id}`,
344
404
  nodeId: action.id,
345
405
  });
406
+ return local;
346
407
  }
347
408
  }
348
409
  function resolveKnownType(type) {
@@ -530,6 +591,122 @@ function validateRoute(route, context) {
530
591
  }
531
592
  }
532
593
  }
594
+ function validateIntegrationDef(_integration, _context) {
595
+ // A capability-domain marker with no fields to check beyond the shared node identity
596
+ // checks already applied — declared explicitly so a new node kind never falls to the
597
+ // erroring `default:` branch of `validateNode`.
598
+ }
599
+ function validateIntegrationOperation(operation, context) {
600
+ requireKind(operation.integrationId, 'integration', operation.id, context, VALIDATION_CODES.unknownIntegration);
601
+ for (const parameter of operation.parameters ?? []) {
602
+ validateTypeRef(parameter.valueType, operation.id, context);
603
+ }
604
+ validateTypeRef(operation.resultType, operation.id, context);
605
+ }
606
+ function validateEvent(event, context) {
607
+ validateTypeRef(event.payloadType, event.id, context);
608
+ }
609
+ function validateTrigger(trigger, context) {
610
+ requireKind(trigger.actionId, 'action', trigger.id, context, VALIDATION_CODES.triggerActionNotFound);
611
+ const action = context.nodes.get(trigger.actionId);
612
+ if (trigger.when.kind === 'interval' && !(trigger.when.everyMs > 0)) {
613
+ context.errors.push({
614
+ code: VALIDATION_CODES.triggerIntervalNotPositive,
615
+ message: `Trigger ${trigger.id} declares a non-positive interval`,
616
+ nodeId: trigger.id,
617
+ });
618
+ }
619
+ if (trigger.when.kind === 'delay' && !(trigger.when.afterMs > 0)) {
620
+ context.errors.push({
621
+ code: VALIDATION_CODES.triggerIntervalNotPositive,
622
+ message: `Trigger ${trigger.id} declares a non-positive delay`,
623
+ nodeId: trigger.id,
624
+ });
625
+ }
626
+ if (trigger.when.kind === 'lifecycle' && trigger.when.routeId) {
627
+ requireKind(trigger.when.routeId, 'route', trigger.id, context, VALIDATION_CODES.danglingNodeRef);
628
+ }
629
+ // An `event` trigger's arguments/enabledWhen may `ref` the trigger's own id to read the
630
+ // event payload — the same mechanism a `for-each`/`map` scopeId provides.
631
+ let local = emptyScope();
632
+ if (trigger.when.kind === 'event') {
633
+ requireKind(trigger.when.eventId, 'event', trigger.id, context, VALIDATION_CODES.unknownEvent);
634
+ const event = context.nodes.get(trigger.when.eventId);
635
+ local = emptyScope(new Set([trigger.id]));
636
+ if (event?.kind === 'event') {
637
+ local.types.set(trigger.id, event.payloadType);
638
+ }
639
+ }
640
+ if (trigger.enabledWhen) {
641
+ validateExpression(trigger.enabledWhen, trigger.id, context, local);
642
+ }
643
+ if (action?.kind === 'action') {
644
+ requireArguments(trigger.actionId, trigger.arguments ?? {}, trigger.id, context, `Trigger ${trigger.id} supplies`);
645
+ for (const [parameterId, argument] of Object.entries(trigger.arguments ?? {})) {
646
+ validateExpression(argument, trigger.id, context, local);
647
+ if (!(action.parameters ?? []).some((p) => p.id === parameterId)) {
648
+ context.errors.push({
649
+ code: VALIDATION_CODES.danglingNodeRef,
650
+ message: `Trigger ${trigger.id} passes unknown parameter ${parameterId} to ${trigger.actionId}`,
651
+ nodeId: trigger.id,
652
+ });
653
+ }
654
+ }
655
+ }
656
+ }
657
+ /** Missing required arguments, and arguments the operation declares no parameter for. */
658
+ function checkIntegrationArguments(operation, args, ownerId, context) {
659
+ const declared = new Set((operation.parameters ?? []).map((parameter) => String(parameter.id)));
660
+ const missing = (operation.parameters ?? [])
661
+ .filter((parameter) => parameter.required && !(String(parameter.id) in args))
662
+ .map((parameter) => String(parameter.id));
663
+ if (missing.length > 0) {
664
+ context.errors.push({
665
+ code: VALIDATION_CODES.integrationArgumentMismatch,
666
+ message: `${ownerId} calls ${operation.id} without ${missing.join(', ')}`,
667
+ nodeId: ownerId,
668
+ details: { operationId: String(operation.id), missing },
669
+ });
670
+ }
671
+ for (const key of Object.keys(args)) {
672
+ if (!declared.has(key)) {
673
+ context.errors.push({
674
+ code: VALIDATION_CODES.integrationArgumentMismatch,
675
+ message: `${ownerId} supplies unknown argument ${key} to ${operation.id}`,
676
+ nodeId: ownerId,
677
+ details: { operationId: String(operation.id), argument: key },
678
+ });
679
+ }
680
+ }
681
+ }
682
+ /**
683
+ * Extends a scope with an integration query's whole result type — unlike `iterationScope`,
684
+ * the bound value is not unwrapped to a collection member.
685
+ */
686
+ function resultScope(scope, scopeId, resultType, context, ownerId) {
687
+ if (scope.ids.has(scopeId)) {
688
+ context.errors.push({
689
+ code: VALIDATION_CODES.scopeShadowing,
690
+ message: `Scope ${scopeId} in ${ownerId} is already bound by an enclosing scope`,
691
+ nodeId: ownerId,
692
+ });
693
+ }
694
+ if (context.nodes.has(scopeId)) {
695
+ context.errors.push({
696
+ code: VALIDATION_CODES.scopeCollidesWithNode,
697
+ message: `Scope ${scopeId} in ${ownerId} has the same id as a graph node`,
698
+ nodeId: ownerId,
699
+ });
700
+ }
701
+ const types = new Map(scope.types);
702
+ if (resultType) {
703
+ types.set(scopeId, resultType);
704
+ }
705
+ else {
706
+ types.delete(scopeId);
707
+ }
708
+ return { ids: new Set([...scope.ids, scopeId]), types };
709
+ }
533
710
  function validateUiNode(node, context) {
534
711
  if (node.visibleWhen) {
535
712
  validateExpression(node.visibleWhen, node.id, context, new Set());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cynodia/axiom-core",
3
- "version": "0.7.0-alpha.2",
3
+ "version": "0.8.0-alpha.1",
4
4
  "description": "Application Graph, semantic types, locations and validation for Axiom.",
5
5
  "license": "MIT",
6
6
  "author": "AskTech AS",