@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 +6 -2
- package/dist/authority.d.ts +2 -0
- package/dist/authority.js +25 -0
- package/dist/derive-edges.js +42 -0
- package/dist/diagnostics.d.ts +13 -0
- package/dist/diagnostics.js +14 -0
- package/dist/events.d.ts +13 -0
- package/dist/events.js +1 -0
- package/dist/graph.js +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/integrations.d.ts +57 -0
- package/dist/integrations.js +2 -0
- package/dist/ir.d.ts +9 -0
- package/dist/nodes.d.ts +39 -1
- package/dist/nodes.js +2 -0
- package/dist/server-ir.d.ts +33 -3
- package/dist/server-ir.js +35 -4
- package/dist/triggers.d.ts +53 -0
- package/dist/triggers.js +8 -0
- package/dist/types.d.ts +5 -2
- package/dist/types.js +4 -0
- package/dist/validate-authority.js +29 -0
- package/dist/validate.js +186 -9
- package/package.json +1 -1
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
|
|
package/dist/authority.d.ts
CHANGED
|
@@ -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') {
|
package/dist/derive-edges.js
CHANGED
|
@@ -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
|
}
|
package/dist/diagnostics.d.ts
CHANGED
|
@@ -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
|
package/dist/diagnostics.js
CHANGED
|
@@ -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
|
};
|
package/dist/events.d.ts
ADDED
|
@@ -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.
|
|
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
|
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';
|
package/dist/server-ir.d.ts
CHANGED
|
@@ -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.
|
|
13
|
-
*
|
|
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.
|
|
10
|
-
*
|
|
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.
|
|
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
|
package/dist/triggers.js
ADDED
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
|
-
|
|
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
|
@@ -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,
|
|
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());
|