@affordance/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +52 -0
- package/dist/engine/compute.d.ts +147 -0
- package/dist/engine/compute.js +152 -0
- package/dist/engine/compute.js.map +1 -0
- package/dist/engine/engine.d.ts +162 -0
- package/dist/engine/engine.js +115 -0
- package/dist/engine/engine.js.map +1 -0
- package/dist/engine/errors.d.ts +6 -0
- package/dist/engine/errors.js +11 -0
- package/dist/engine/errors.js.map +1 -0
- package/dist/engine/index.d.ts +16 -0
- package/dist/engine/index.js +15 -0
- package/dist/engine/index.js.map +1 -0
- package/dist/errors.d.ts +46 -0
- package/dist/errors.js +42 -0
- package/dist/errors.js.map +1 -0
- package/dist/execution/delta.d.ts +42 -0
- package/dist/execution/delta.js +93 -0
- package/dist/execution/delta.js.map +1 -0
- package/dist/execution/errors.d.ts +94 -0
- package/dist/execution/errors.js +132 -0
- package/dist/execution/errors.js.map +1 -0
- package/dist/execution/execute.d.ts +179 -0
- package/dist/execution/execute.js +356 -0
- package/dist/execution/execute.js.map +1 -0
- package/dist/execution/index.d.ts +25 -0
- package/dist/execution/index.js +20 -0
- package/dist/execution/index.js.map +1 -0
- package/dist/execution/journal.d.ts +190 -0
- package/dist/execution/journal.js +192 -0
- package/dist/execution/journal.js.map +1 -0
- package/dist/execution/port.d.ts +81 -0
- package/dist/execution/port.js +101 -0
- package/dist/execution/port.js.map +1 -0
- package/dist/execution/replay.d.ts +58 -0
- package/dist/execution/replay.js +59 -0
- package/dist/execution/replay.js.map +1 -0
- package/dist/execution/timers.d.ts +21 -0
- package/dist/execution/timers.js +26 -0
- package/dist/execution/timers.js.map +1 -0
- package/dist/execution/transaction.d.ts +24 -0
- package/dist/execution/transaction.js +49 -0
- package/dist/execution/transaction.js.map +1 -0
- package/dist/guards/condition.d.ts +86 -0
- package/dist/guards/condition.js +11 -0
- package/dist/guards/condition.js.map +1 -0
- package/dist/guards/evaluate.d.ts +143 -0
- package/dist/guards/evaluate.js +167 -0
- package/dist/guards/evaluate.js.map +1 -0
- package/dist/guards/guard.d.ts +136 -0
- package/dist/guards/guard.js +118 -0
- package/dist/guards/guard.js.map +1 -0
- package/dist/guards/index.d.ts +20 -0
- package/dist/guards/index.js +17 -0
- package/dist/guards/index.js.map +1 -0
- package/dist/guards/time.d.ts +32 -0
- package/dist/guards/time.js +45 -0
- package/dist/guards/time.js.map +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/ingestion/correlation.d.ts +54 -0
- package/dist/ingestion/correlation.js +78 -0
- package/dist/ingestion/correlation.js.map +1 -0
- package/dist/ingestion/index.d.ts +16 -0
- package/dist/ingestion/index.js +15 -0
- package/dist/ingestion/index.js.map +1 -0
- package/dist/ingestion/ingest.d.ts +196 -0
- package/dist/ingestion/ingest.js +275 -0
- package/dist/ingestion/ingest.js.map +1 -0
- package/dist/migration/index.d.ts +10 -0
- package/dist/migration/index.js +10 -0
- package/dist/migration/index.js.map +1 -0
- package/dist/migration/migrate.d.ts +119 -0
- package/dist/migration/migrate.js +195 -0
- package/dist/migration/migrate.js.map +1 -0
- package/dist/model/casetype.d.ts +53 -0
- package/dist/model/casetype.js +51 -0
- package/dist/model/casetype.js.map +1 -0
- package/dist/model/errors.d.ts +28 -0
- package/dist/model/errors.js +39 -0
- package/dist/model/errors.js.map +1 -0
- package/dist/model/handler.d.ts +120 -0
- package/dist/model/handler.js +16 -0
- package/dist/model/handler.js.map +1 -0
- package/dist/model/index.d.ts +24 -0
- package/dist/model/index.js +19 -0
- package/dist/model/index.js.map +1 -0
- package/dist/model/retry.d.ts +40 -0
- package/dist/model/retry.js +51 -0
- package/dist/model/retry.js.map +1 -0
- package/dist/model/scope.d.ts +67 -0
- package/dist/model/scope.js +19 -0
- package/dist/model/scope.js.map +1 -0
- package/dist/model/step.d.ts +232 -0
- package/dist/model/step.js +210 -0
- package/dist/model/step.js.map +1 -0
- package/dist/model/target.d.ts +175 -0
- package/dist/model/target.js +236 -0
- package/dist/model/target.js.map +1 -0
- package/dist/store/bootstrap.d.ts +57 -0
- package/dist/store/bootstrap.js +268 -0
- package/dist/store/bootstrap.js.map +1 -0
- package/dist/store/errors.d.ts +18 -0
- package/dist/store/errors.js +26 -0
- package/dist/store/errors.js.map +1 -0
- package/dist/store/ids.d.ts +15 -0
- package/dist/store/ids.js +12 -0
- package/dist/store/ids.js.map +1 -0
- package/dist/store/index.d.ts +12 -0
- package/dist/store/index.js +9 -0
- package/dist/store/index.js.map +1 -0
- package/dist/store/queryable.d.ts +60 -0
- package/dist/store/queryable.js +7 -0
- package/dist/store/queryable.js.map +1 -0
- package/dist/store/resolve.d.ts +66 -0
- package/dist/store/resolve.js +71 -0
- package/dist/store/resolve.js.map +1 -0
- package/dist/store/sql.d.ts +26 -0
- package/dist/store/sql.js +21 -0
- package/dist/store/sql.js.map +1 -0
- package/dist/store/store.d.ts +66 -0
- package/dist/store/store.js +108 -0
- package/dist/store/store.js.map +1 -0
- package/package.json +61 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mochicode LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# @affordance/core
|
|
2
|
+
|
|
3
|
+
Compute what a case can do now, for a particular actor. A case is a persisted
|
|
4
|
+
object with state and independently guarded steps. Steps become available
|
|
5
|
+
through state changes, without a predefined ordering.
|
|
6
|
+
|
|
7
|
+
Requires Node 22.12+ and Postgres. ESM JavaScript and TypeScript declarations
|
|
8
|
+
are included. Zod is one option for the Standard Schema validation interface.
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm install @affordance/core pg zod
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
import { actor, bootstrap, caseType, createEngine, stepsOf } from '@affordance/core'
|
|
16
|
+
import { Pool } from 'pg'
|
|
17
|
+
import { z } from 'zod'
|
|
18
|
+
|
|
19
|
+
const State = z.object({ ownerId: z.string(), approved: z.boolean() })
|
|
20
|
+
const step = stepsOf(State, actor<{ id: string }>())
|
|
21
|
+
const approval = caseType({
|
|
22
|
+
name: 'approval',
|
|
23
|
+
state: State,
|
|
24
|
+
steps: [step({
|
|
25
|
+
name: 'approve',
|
|
26
|
+
requires: { pending: (state) => !state.approved },
|
|
27
|
+
permits: { owner: (state, ctx) => state.ownerId === ctx.actor.id },
|
|
28
|
+
handler: async (state) => ({ ...state, approved: true }),
|
|
29
|
+
})],
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
const pool = new Pool({ connectionString: process.env.DATABASE_URL })
|
|
33
|
+
try {
|
|
34
|
+
await bootstrap(pool)
|
|
35
|
+
const engine = createEngine({ db: { pool }, caseTypes: [approval] })
|
|
36
|
+
const current = await engine.createCase('approval', { ownerId: 'alice', approved: false })
|
|
37
|
+
console.log(await engine.affordances(current.id, { id: 'alice' }))
|
|
38
|
+
await engine.execute(current.id, 'approve', { actor: { id: 'alice' } })
|
|
39
|
+
console.log(await engine.journal(current.id))
|
|
40
|
+
} finally {
|
|
41
|
+
await pool.end()
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The engine claims a case, runs its async handler outside a database transaction,
|
|
46
|
+
then commits state and journal together. Executions serialize per case;
|
|
47
|
+
external effects must tolerate retries.
|
|
48
|
+
|
|
49
|
+
Read the [introduction](https://github.com/mochicodecom/affordance/blob/main/docs/tutorial/README.md)
|
|
50
|
+
and [architecture](https://github.com/mochicodecom/affordance/blob/main/docs/architecture.md).
|
|
51
|
+
The optional [HTTP adapter](https://github.com/mochicodecom/affordance/tree/main/packages/http)
|
|
52
|
+
exposes available steps as links. Licensed under [MIT](./LICENSE).
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Affordance computation — the pure core of the engine.
|
|
3
|
+
*
|
|
4
|
+
* `computeAffordances` turns (case type, case snapshot, actor, asOf) into
|
|
5
|
+
* the framework's HATEOAS answer: every step's guard evaluated (with scope
|
|
6
|
+
* fan-out), split into available **affordances** and blocked steps with
|
|
7
|
+
* their unmet named conditions, "not possible" mechanically distinct from
|
|
8
|
+
* "not permitted for this actor". `computeExplanation` is the same
|
|
9
|
+
* machinery pointed at one step, returning the full per-condition record.
|
|
10
|
+
*
|
|
11
|
+
* Both are pure: no I/O, no clock — `asOf` is explicit (the
|
|
12
|
+
* *engine* defaults it to now at its boundary, never a condition). The
|
|
13
|
+
* records are plain JSON-serializable objects, deterministic in their
|
|
14
|
+
* inputs — the affordance JSON contract will serialize them
|
|
15
|
+
* verbatim.
|
|
16
|
+
*
|
|
17
|
+
* Addressing a step and binding its scope element is `../model/target.js`'s
|
|
18
|
+
* job rather than this module's: it is a fact about a step definition and a
|
|
19
|
+
* state, and every other consumer of it — the claim,
|
|
20
|
+
* audit replay — would otherwise have to import the engine to reach it.
|
|
21
|
+
*/
|
|
22
|
+
import type { StandardSchemaV1 } from '@standard-schema/spec';
|
|
23
|
+
import type { ConditionResult, GuardEvaluation, Instant } from '../guards/index.js';
|
|
24
|
+
import type { CaseTypeDefinition, ComputationContext } from '../model/index.js';
|
|
25
|
+
/**
|
|
26
|
+
* The slice of a case the pure computation needs: identity, state, dormancy.
|
|
27
|
+
* The engine builds it from a store handle; tests may construct one directly.
|
|
28
|
+
*/
|
|
29
|
+
export interface CaseSnapshot<TState> {
|
|
30
|
+
readonly id: string;
|
|
31
|
+
readonly state: TState;
|
|
32
|
+
/** Dormancy marker (`end()`): a dormant case still computes — dormancy is never a freeze. */
|
|
33
|
+
readonly endedAt: Date | string | null;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* One available affordance: a step (with its scope binding, if scoped) this
|
|
37
|
+
* actor can take now. Deliberately silent about the step's input — how an
|
|
38
|
+
* input is described to a caller is the adapter's translation
|
|
39
|
+
* (`Engine.inputSchemaFor` is the registry read it serializes from), and a
|
|
40
|
+
* second channel here could only drift from it.
|
|
41
|
+
*/
|
|
42
|
+
export interface Affordance {
|
|
43
|
+
readonly step: string;
|
|
44
|
+
/** The bound element's scope key — present iff the step is scoped. */
|
|
45
|
+
readonly scopeKey?: string;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* A step (× scope element, if scoped) that is currently not available, with
|
|
49
|
+
* the unmet named conditions saying why. `possible === false` means unmet
|
|
50
|
+
* `requires` — not possible on this case, for anyone; `permitted === false`
|
|
51
|
+
* means unmet `permits` — not permitted for this actor.
|
|
52
|
+
*/
|
|
53
|
+
export interface BlockedStep {
|
|
54
|
+
readonly step: string;
|
|
55
|
+
readonly scopeKey?: string;
|
|
56
|
+
readonly possible: boolean;
|
|
57
|
+
readonly permitted: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* The failed condition results, verbatim from guard evaluation — named,
|
|
60
|
+
* sectioned, with reasons and (for `after` conditions) basis/flip instants.
|
|
61
|
+
*/
|
|
62
|
+
readonly unmet: readonly ConditionResult[];
|
|
63
|
+
}
|
|
64
|
+
/** The serializable affordances record for one case, one actor, one instant. */
|
|
65
|
+
export interface CaseAffordances {
|
|
66
|
+
readonly caseId: string;
|
|
67
|
+
readonly caseTypeName: string;
|
|
68
|
+
/** The instant evaluated as of (ISO-8601 UTC). */
|
|
69
|
+
readonly asOf: string;
|
|
70
|
+
/** Dormancy marker (ISO-8601 UTC), `null` while the case is active. A dormant case still computes. */
|
|
71
|
+
readonly endedAt: string | null;
|
|
72
|
+
/** Available affordances, in step declaration order (scoped: selection order within a step). */
|
|
73
|
+
readonly affordances: readonly Affordance[];
|
|
74
|
+
/** Blocked steps (× scope element) with their unmet named conditions. */
|
|
75
|
+
readonly blocked: readonly BlockedStep[];
|
|
76
|
+
}
|
|
77
|
+
/** The full per-condition breakdown for one step (× scope element, if scoped). */
|
|
78
|
+
export interface AffordanceExplanation {
|
|
79
|
+
readonly caseId: string;
|
|
80
|
+
readonly caseTypeName: string;
|
|
81
|
+
readonly step: string;
|
|
82
|
+
/** The scope binding the explanation is about — present iff the step is scoped. */
|
|
83
|
+
readonly scopeKey?: string;
|
|
84
|
+
readonly asOf: string;
|
|
85
|
+
readonly endedAt: string | null;
|
|
86
|
+
/** The guards module's full evaluation record: every condition, passed and failed. */
|
|
87
|
+
readonly evaluation: GuardEvaluation;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Compute the affordances record for one case snapshot: every step's guard
|
|
91
|
+
* evaluated against (state, actor, asOf); scoped steps fan out to one
|
|
92
|
+
* independent evaluation per selected element. Handlers are never
|
|
93
|
+
* touched — this is a read (guards advise; enforcement is the claim's job).
|
|
94
|
+
*
|
|
95
|
+
* A scoped step whose selector is defective is absorbed into a blocked entry
|
|
96
|
+
* under the synthetic `$scope` condition (one defective selector must not
|
|
97
|
+
* take down the case's listing); scope-*key* integrity violations throw
|
|
98
|
+
* `ScopeKeyError` (identity corruption — `selectTargets` never absorbs it).
|
|
99
|
+
* A scoped step selecting zero elements contributes nothing to either list.
|
|
100
|
+
*/
|
|
101
|
+
export declare const computeAffordances: <S extends StandardSchemaV1, TActor>(definition: CaseTypeDefinition<S, TActor>, snapshot: CaseSnapshot<StandardSchemaV1.InferOutput<S>>, ctx: ComputationContext<TActor>) => CaseAffordances;
|
|
102
|
+
/** An `explain` request as a caller states it: everything optional. */
|
|
103
|
+
export interface ExplainRequest {
|
|
104
|
+
/** Required when the step is scoped; identifies the element. */
|
|
105
|
+
readonly scopeKey?: string;
|
|
106
|
+
/** The actor to evaluate `permits` against; omit to probe `requires` alone. */
|
|
107
|
+
readonly actor?: unknown;
|
|
108
|
+
/** The instant to evaluate as of; defaults through the supplied clock. */
|
|
109
|
+
readonly asOf?: Instant;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Normalize an {@link ExplainRequest} into the {@link ComputationContext}
|
|
113
|
+
* the pure computation runs on — the engine boundary's one normalization,
|
|
114
|
+
* stated here so the rule and {@link computeExplanation} share a test
|
|
115
|
+
* surface:
|
|
116
|
+
*
|
|
117
|
+
* - An **absent** `actor` key means the requires-only probe ({@link NO_ACTOR}
|
|
118
|
+
* — `permits` conditions are reported un-evaluated). A key that is
|
|
119
|
+
* *present but `undefined`* is an actor like any other: the caller said
|
|
120
|
+
* who is asking, and the answer is about them.
|
|
121
|
+
* - `asOf` defaults through `now` — the clock stops here; everything below
|
|
122
|
+
* is pure.
|
|
123
|
+
* - `scopeKey` is carried only when given, so "unscoped" stays an absent
|
|
124
|
+
* key rather than an `undefined` value.
|
|
125
|
+
*/
|
|
126
|
+
export declare const explainContext: <TActor = unknown>(request: ExplainRequest, now: () => Instant) => ComputationContext<TActor> & {
|
|
127
|
+
readonly scopeKey?: string;
|
|
128
|
+
};
|
|
129
|
+
/**
|
|
130
|
+
* The full per-condition breakdown for one step. Loud where the listing is
|
|
131
|
+
* lenient — `explain` is a targeted probe, so a step name the case type
|
|
132
|
+
* doesn't declare, a missing/unknown scope key on a scoped step, or a scope
|
|
133
|
+
* key on an unscoped step all throw with precise messages (including the
|
|
134
|
+
* currently-valid scope keys, where knowable).
|
|
135
|
+
*
|
|
136
|
+
* One deliberate exception: a scoped step whose selector is **defective**
|
|
137
|
+
* answers rather than throws. The listing publishes exactly that condition
|
|
138
|
+
* as a blocked `$scope` entry with an `explain` link, so the link must be
|
|
139
|
+
* followable — the explanation *is* the `$scope` failure, in the same shape
|
|
140
|
+
* the listing reported it.
|
|
141
|
+
*
|
|
142
|
+
* `actor` is whatever the caller supplies; to ask "why can't *this* actor",
|
|
143
|
+
* pass that actor — `permits` conditions are evaluated against it verbatim.
|
|
144
|
+
*/
|
|
145
|
+
export declare const computeExplanation: <S extends StandardSchemaV1, TActor>(definition: CaseTypeDefinition<S, TActor>, snapshot: CaseSnapshot<StandardSchemaV1.InferOutput<S>>, stepName: string, ctx: ComputationContext<TActor> & {
|
|
146
|
+
readonly scopeKey?: string;
|
|
147
|
+
}) => AffordanceExplanation;
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Affordance computation — the pure core of the engine.
|
|
3
|
+
*
|
|
4
|
+
* `computeAffordances` turns (case type, case snapshot, actor, asOf) into
|
|
5
|
+
* the framework's HATEOAS answer: every step's guard evaluated (with scope
|
|
6
|
+
* fan-out), split into available **affordances** and blocked steps with
|
|
7
|
+
* their unmet named conditions, "not possible" mechanically distinct from
|
|
8
|
+
* "not permitted for this actor". `computeExplanation` is the same
|
|
9
|
+
* machinery pointed at one step, returning the full per-condition record.
|
|
10
|
+
*
|
|
11
|
+
* Both are pure: no I/O, no clock — `asOf` is explicit (the
|
|
12
|
+
* *engine* defaults it to now at its boundary, never a condition). The
|
|
13
|
+
* records are plain JSON-serializable objects, deterministic in their
|
|
14
|
+
* inputs — the affordance JSON contract will serialize them
|
|
15
|
+
* verbatim.
|
|
16
|
+
*
|
|
17
|
+
* Addressing a step and binding its scope element is `../model/target.js`'s
|
|
18
|
+
* job rather than this module's: it is a fact about a step definition and a
|
|
19
|
+
* state, and every other consumer of it — the claim,
|
|
20
|
+
* audit replay — would otherwise have to import the engine to reach it.
|
|
21
|
+
*/
|
|
22
|
+
import { NO_ACTOR, toIso, unmetConditions } from '../guards/index.js';
|
|
23
|
+
import { addressTarget, evaluateTarget, scopeFailureEvaluation, selectTargets, } from '../model/index.js';
|
|
24
|
+
const toIsoOrNull = (value) => value === null ? null : toIso(value);
|
|
25
|
+
// The verdict lives with the model (`scopeFailureEvaluation`); this only
|
|
26
|
+
// reshapes it into a blocked entry.
|
|
27
|
+
const scopeFailureEntry = (stepName, asOf, reason) => {
|
|
28
|
+
const evaluation = scopeFailureEvaluation(asOf, { reason });
|
|
29
|
+
return {
|
|
30
|
+
step: stepName,
|
|
31
|
+
possible: evaluation.possible,
|
|
32
|
+
permitted: evaluation.permitted,
|
|
33
|
+
unmet: unmetConditions(evaluation),
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
const toAffordance = (stepName, scopeKey) => ({
|
|
37
|
+
step: stepName,
|
|
38
|
+
...(scopeKey !== undefined && { scopeKey }),
|
|
39
|
+
});
|
|
40
|
+
const toBlocked = (stepName, evaluation, scopeKey) => ({
|
|
41
|
+
step: stepName,
|
|
42
|
+
...(scopeKey !== undefined && { scopeKey }),
|
|
43
|
+
possible: evaluation.possible,
|
|
44
|
+
permitted: evaluation.permitted,
|
|
45
|
+
unmet: unmetConditions(evaluation),
|
|
46
|
+
});
|
|
47
|
+
/**
|
|
48
|
+
* Compute the affordances record for one case snapshot: every step's guard
|
|
49
|
+
* evaluated against (state, actor, asOf); scoped steps fan out to one
|
|
50
|
+
* independent evaluation per selected element. Handlers are never
|
|
51
|
+
* touched — this is a read (guards advise; enforcement is the claim's job).
|
|
52
|
+
*
|
|
53
|
+
* A scoped step whose selector is defective is absorbed into a blocked entry
|
|
54
|
+
* under the synthetic `$scope` condition (one defective selector must not
|
|
55
|
+
* take down the case's listing); scope-*key* integrity violations throw
|
|
56
|
+
* `ScopeKeyError` (identity corruption — `selectTargets` never absorbs it).
|
|
57
|
+
* A scoped step selecting zero elements contributes nothing to either list.
|
|
58
|
+
*/
|
|
59
|
+
export const computeAffordances = (definition, snapshot, ctx) => {
|
|
60
|
+
const asOf = toIso(ctx.asOf);
|
|
61
|
+
const affordances = [];
|
|
62
|
+
const blocked = [];
|
|
63
|
+
for (const stepDefinition of definition.steps) {
|
|
64
|
+
const { targets, failure } = selectTargets(stepDefinition, snapshot.state);
|
|
65
|
+
if (failure !== null) {
|
|
66
|
+
blocked.push(scopeFailureEntry(stepDefinition.name, asOf, failure.reason));
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
for (const target of targets) {
|
|
70
|
+
const evaluation = evaluateTarget(target, { actor: ctx.actor, asOf });
|
|
71
|
+
const scopeKey = target.binding?.key;
|
|
72
|
+
if (evaluation.available)
|
|
73
|
+
affordances.push(toAffordance(stepDefinition.name, scopeKey));
|
|
74
|
+
else
|
|
75
|
+
blocked.push(toBlocked(stepDefinition.name, evaluation, scopeKey));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
caseId: snapshot.id,
|
|
80
|
+
caseTypeName: definition.name,
|
|
81
|
+
asOf,
|
|
82
|
+
endedAt: toIsoOrNull(snapshot.endedAt),
|
|
83
|
+
affordances,
|
|
84
|
+
blocked,
|
|
85
|
+
};
|
|
86
|
+
};
|
|
87
|
+
/**
|
|
88
|
+
* Normalize an {@link ExplainRequest} into the {@link ComputationContext}
|
|
89
|
+
* the pure computation runs on — the engine boundary's one normalization,
|
|
90
|
+
* stated here so the rule and {@link computeExplanation} share a test
|
|
91
|
+
* surface:
|
|
92
|
+
*
|
|
93
|
+
* - An **absent** `actor` key means the requires-only probe ({@link NO_ACTOR}
|
|
94
|
+
* — `permits` conditions are reported un-evaluated). A key that is
|
|
95
|
+
* *present but `undefined`* is an actor like any other: the caller said
|
|
96
|
+
* who is asking, and the answer is about them.
|
|
97
|
+
* - `asOf` defaults through `now` — the clock stops here; everything below
|
|
98
|
+
* is pure.
|
|
99
|
+
* - `scopeKey` is carried only when given, so "unscoped" stays an absent
|
|
100
|
+
* key rather than an `undefined` value.
|
|
101
|
+
*/
|
|
102
|
+
export const explainContext = (request, now) => ({
|
|
103
|
+
// The request's actor is caller-supplied and untyped, and NO_ACTOR is the
|
|
104
|
+
// evaluator's own marker; the assertion papers over neither — `permits`
|
|
105
|
+
// conditions must be total over whatever an actor turns out to be.
|
|
106
|
+
actor: ('actor' in request ? request.actor : NO_ACTOR),
|
|
107
|
+
asOf: request.asOf ?? now(),
|
|
108
|
+
...(request.scopeKey !== undefined && { scopeKey: request.scopeKey }),
|
|
109
|
+
});
|
|
110
|
+
/**
|
|
111
|
+
* The full per-condition breakdown for one step. Loud where the listing is
|
|
112
|
+
* lenient — `explain` is a targeted probe, so a step name the case type
|
|
113
|
+
* doesn't declare, a missing/unknown scope key on a scoped step, or a scope
|
|
114
|
+
* key on an unscoped step all throw with precise messages (including the
|
|
115
|
+
* currently-valid scope keys, where knowable).
|
|
116
|
+
*
|
|
117
|
+
* One deliberate exception: a scoped step whose selector is **defective**
|
|
118
|
+
* answers rather than throws. The listing publishes exactly that condition
|
|
119
|
+
* as a blocked `$scope` entry with an `explain` link, so the link must be
|
|
120
|
+
* followable — the explanation *is* the `$scope` failure, in the same shape
|
|
121
|
+
* the listing reported it.
|
|
122
|
+
*
|
|
123
|
+
* `actor` is whatever the caller supplies; to ask "why can't *this* actor",
|
|
124
|
+
* pass that actor — `permits` conditions are evaluated against it verbatim.
|
|
125
|
+
*/
|
|
126
|
+
export const computeExplanation = (definition, snapshot, stepName, ctx) => {
|
|
127
|
+
const asOf = toIso(ctx.asOf);
|
|
128
|
+
const identity = {
|
|
129
|
+
caseId: snapshot.id,
|
|
130
|
+
caseTypeName: definition.name,
|
|
131
|
+
step: stepName,
|
|
132
|
+
asOf,
|
|
133
|
+
endedAt: toIsoOrNull(snapshot.endedAt),
|
|
134
|
+
};
|
|
135
|
+
const address = addressTarget(definition, snapshot.state, stepName, ctx.scopeKey);
|
|
136
|
+
if (address.failure !== null) {
|
|
137
|
+
if (address.failure.kind === 'defective-selector') {
|
|
138
|
+
return {
|
|
139
|
+
...identity,
|
|
140
|
+
evaluation: scopeFailureEvaluation(asOf, address.failure),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
throw address.failure.error;
|
|
144
|
+
}
|
|
145
|
+
const target = address.target;
|
|
146
|
+
return {
|
|
147
|
+
...identity,
|
|
148
|
+
...(target.binding !== null && { scopeKey: target.binding.key }),
|
|
149
|
+
evaluation: evaluateTarget(target, { actor: ctx.actor, asOf }),
|
|
150
|
+
};
|
|
151
|
+
};
|
|
152
|
+
//# sourceMappingURL=compute.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"compute.js","sourceRoot":"","sources":["../../src/engine/compute.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Affordance computation — the pure core of the engine.\n *\n * `computeAffordances` turns (case type, case snapshot, actor, asOf) into\n * the framework's HATEOAS answer: every step's guard evaluated (with scope\n * fan-out), split into available **affordances** and blocked steps with\n * their unmet named conditions, \"not possible\" mechanically distinct from\n * \"not permitted for this actor\". `computeExplanation` is the same\n * machinery pointed at one step, returning the full per-condition record.\n *\n * Both are pure: no I/O, no clock — `asOf` is explicit (the\n * *engine* defaults it to now at its boundary, never a condition). The\n * records are plain JSON-serializable objects, deterministic in their\n * inputs — the affordance JSON contract will serialize them\n * verbatim.\n *\n * Addressing a step and binding its scope element is `../model/target.js`'s\n * job rather than this module's: it is a fact about a step definition and a\n * state, and every other consumer of it — the claim,\n * audit replay — would otherwise have to import the engine to reach it.\n */\n\nimport type { StandardSchemaV1 } from '@standard-schema/spec'\nimport type {\n ConditionResult,\n GuardEvaluation,\n Instant,\n} from '../guards/index.js'\nimport { NO_ACTOR, toIso, unmetConditions } from '../guards/index.js'\nimport type { CaseTypeDefinition, ComputationContext } from '../model/index.js'\nimport {\n addressTarget,\n evaluateTarget,\n scopeFailureEvaluation,\n selectTargets,\n} from '../model/index.js'\n\n/**\n * The slice of a case the pure computation needs: identity, state, dormancy.\n * The engine builds it from a store handle; tests may construct one directly.\n */\nexport interface CaseSnapshot<TState> {\n readonly id: string\n readonly state: TState\n /** Dormancy marker (`end()`): a dormant case still computes — dormancy is never a freeze. */\n readonly endedAt: Date | string | null\n}\n\n/**\n * One available affordance: a step (with its scope binding, if scoped) this\n * actor can take now. Deliberately silent about the step's input — how an\n * input is described to a caller is the adapter's translation\n * (`Engine.inputSchemaFor` is the registry read it serializes from), and a\n * second channel here could only drift from it.\n */\nexport interface Affordance {\n readonly step: string\n /** The bound element's scope key — present iff the step is scoped. */\n readonly scopeKey?: string\n}\n\n/**\n * A step (× scope element, if scoped) that is currently not available, with\n * the unmet named conditions saying why. `possible === false` means unmet\n * `requires` — not possible on this case, for anyone; `permitted === false`\n * means unmet `permits` — not permitted for this actor.\n */\nexport interface BlockedStep {\n readonly step: string\n readonly scopeKey?: string\n readonly possible: boolean\n readonly permitted: boolean\n /**\n * The failed condition results, verbatim from guard evaluation — named,\n * sectioned, with reasons and (for `after` conditions) basis/flip instants.\n */\n readonly unmet: readonly ConditionResult[]\n}\n\n/** The serializable affordances record for one case, one actor, one instant. */\nexport interface CaseAffordances {\n readonly caseId: string\n readonly caseTypeName: string\n /** The instant evaluated as of (ISO-8601 UTC). */\n readonly asOf: string\n /** Dormancy marker (ISO-8601 UTC), `null` while the case is active. A dormant case still computes. */\n readonly endedAt: string | null\n /** Available affordances, in step declaration order (scoped: selection order within a step). */\n readonly affordances: readonly Affordance[]\n /** Blocked steps (× scope element) with their unmet named conditions. */\n readonly blocked: readonly BlockedStep[]\n}\n\n/** The full per-condition breakdown for one step (× scope element, if scoped). */\nexport interface AffordanceExplanation {\n readonly caseId: string\n readonly caseTypeName: string\n readonly step: string\n /** The scope binding the explanation is about — present iff the step is scoped. */\n readonly scopeKey?: string\n readonly asOf: string\n readonly endedAt: string | null\n /** The guards module's full evaluation record: every condition, passed and failed. */\n readonly evaluation: GuardEvaluation\n}\n\nconst toIsoOrNull = (value: Instant | null): string | null =>\n value === null ? null : toIso(value)\n\n// The verdict lives with the model (`scopeFailureEvaluation`); this only\n// reshapes it into a blocked entry.\nconst scopeFailureEntry = (\n stepName: string,\n asOf: string,\n reason: string,\n): BlockedStep => {\n const evaluation = scopeFailureEvaluation(asOf, { reason })\n return {\n step: stepName,\n possible: evaluation.possible,\n permitted: evaluation.permitted,\n unmet: unmetConditions(evaluation),\n }\n}\n\nconst toAffordance = (stepName: string, scopeKey?: string): Affordance => ({\n step: stepName,\n ...(scopeKey !== undefined && { scopeKey }),\n})\n\nconst toBlocked = (\n stepName: string,\n evaluation: GuardEvaluation,\n scopeKey?: string,\n): BlockedStep => ({\n step: stepName,\n ...(scopeKey !== undefined && { scopeKey }),\n possible: evaluation.possible,\n permitted: evaluation.permitted,\n unmet: unmetConditions(evaluation),\n})\n\n/**\n * Compute the affordances record for one case snapshot: every step's guard\n * evaluated against (state, actor, asOf); scoped steps fan out to one\n * independent evaluation per selected element. Handlers are never\n * touched — this is a read (guards advise; enforcement is the claim's job).\n *\n * A scoped step whose selector is defective is absorbed into a blocked entry\n * under the synthetic `$scope` condition (one defective selector must not\n * take down the case's listing); scope-*key* integrity violations throw\n * `ScopeKeyError` (identity corruption — `selectTargets` never absorbs it).\n * A scoped step selecting zero elements contributes nothing to either list.\n */\nexport const computeAffordances = <S extends StandardSchemaV1, TActor>(\n definition: CaseTypeDefinition<S, TActor>,\n snapshot: CaseSnapshot<StandardSchemaV1.InferOutput<S>>,\n ctx: ComputationContext<TActor>,\n): CaseAffordances => {\n const asOf = toIso(ctx.asOf)\n const affordances: Affordance[] = []\n const blocked: BlockedStep[] = []\n\n for (const stepDefinition of definition.steps) {\n const { targets, failure } = selectTargets(stepDefinition, snapshot.state)\n if (failure !== null) {\n blocked.push(scopeFailureEntry(stepDefinition.name, asOf, failure.reason))\n continue\n }\n for (const target of targets) {\n const evaluation = evaluateTarget(target, { actor: ctx.actor, asOf })\n const scopeKey = target.binding?.key\n if (evaluation.available)\n affordances.push(toAffordance(stepDefinition.name, scopeKey))\n else blocked.push(toBlocked(stepDefinition.name, evaluation, scopeKey))\n }\n }\n\n return {\n caseId: snapshot.id,\n caseTypeName: definition.name,\n asOf,\n endedAt: toIsoOrNull(snapshot.endedAt),\n affordances,\n blocked,\n }\n}\n\n/** An `explain` request as a caller states it: everything optional. */\nexport interface ExplainRequest {\n /** Required when the step is scoped; identifies the element. */\n readonly scopeKey?: string\n /** The actor to evaluate `permits` against; omit to probe `requires` alone. */\n readonly actor?: unknown\n /** The instant to evaluate as of; defaults through the supplied clock. */\n readonly asOf?: Instant\n}\n\n/**\n * Normalize an {@link ExplainRequest} into the {@link ComputationContext}\n * the pure computation runs on — the engine boundary's one normalization,\n * stated here so the rule and {@link computeExplanation} share a test\n * surface:\n *\n * - An **absent** `actor` key means the requires-only probe ({@link NO_ACTOR}\n * — `permits` conditions are reported un-evaluated). A key that is\n * *present but `undefined`* is an actor like any other: the caller said\n * who is asking, and the answer is about them.\n * - `asOf` defaults through `now` — the clock stops here; everything below\n * is pure.\n * - `scopeKey` is carried only when given, so \"unscoped\" stays an absent\n * key rather than an `undefined` value.\n */\nexport const explainContext = <TActor = unknown>(\n request: ExplainRequest,\n now: () => Instant,\n): ComputationContext<TActor> & { readonly scopeKey?: string } => ({\n // The request's actor is caller-supplied and untyped, and NO_ACTOR is the\n // evaluator's own marker; the assertion papers over neither — `permits`\n // conditions must be total over whatever an actor turns out to be.\n actor: ('actor' in request ? request.actor : NO_ACTOR) as TActor,\n asOf: request.asOf ?? now(),\n ...(request.scopeKey !== undefined && { scopeKey: request.scopeKey }),\n})\n\n/**\n * The full per-condition breakdown for one step. Loud where the listing is\n * lenient — `explain` is a targeted probe, so a step name the case type\n * doesn't declare, a missing/unknown scope key on a scoped step, or a scope\n * key on an unscoped step all throw with precise messages (including the\n * currently-valid scope keys, where knowable).\n *\n * One deliberate exception: a scoped step whose selector is **defective**\n * answers rather than throws. The listing publishes exactly that condition\n * as a blocked `$scope` entry with an `explain` link, so the link must be\n * followable — the explanation *is* the `$scope` failure, in the same shape\n * the listing reported it.\n *\n * `actor` is whatever the caller supplies; to ask \"why can't *this* actor\",\n * pass that actor — `permits` conditions are evaluated against it verbatim.\n */\nexport const computeExplanation = <S extends StandardSchemaV1, TActor>(\n definition: CaseTypeDefinition<S, TActor>,\n snapshot: CaseSnapshot<StandardSchemaV1.InferOutput<S>>,\n stepName: string,\n ctx: ComputationContext<TActor> & { readonly scopeKey?: string },\n): AffordanceExplanation => {\n const asOf = toIso(ctx.asOf)\n const identity = {\n caseId: snapshot.id,\n caseTypeName: definition.name,\n step: stepName,\n asOf,\n endedAt: toIsoOrNull(snapshot.endedAt),\n }\n\n const address = addressTarget(\n definition,\n snapshot.state,\n stepName,\n ctx.scopeKey,\n )\n if (address.failure !== null) {\n if (address.failure.kind === 'defective-selector') {\n return {\n ...identity,\n evaluation: scopeFailureEvaluation(asOf, address.failure),\n }\n }\n throw address.failure.error\n }\n const target = address.target\n return {\n ...identity,\n ...(target.binding !== null && { scopeKey: target.binding.key }),\n evaluation: evaluateTarget(target, { actor: ctx.actor, asOf }),\n }\n}\n"]}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The engine: the case-type registry bound to the case store.
|
|
3
|
+
*
|
|
4
|
+
* `createEngine({ db, caseTypes })` wires the halves together — persistence
|
|
5
|
+
* (`../store`), guard evaluation (`../guards`), the definition API
|
|
6
|
+
* (`../model`) and the execution lifecycle (`../execution`) — into the
|
|
7
|
+
* framework's public face: `affordances`, `explain`, `execute`, `journal`.
|
|
8
|
+
*
|
|
9
|
+
* The engine is where `asOf` defaults to now: conditions never read the
|
|
10
|
+
* clock, so `EngineOptions.now` — wall clock by default — is the one clock,
|
|
11
|
+
* threaded through the environment to everything below, and everything below
|
|
12
|
+
* it is pure and reconstructable.
|
|
13
|
+
*/
|
|
14
|
+
import type { StandardSchemaV1 } from '@standard-schema/spec';
|
|
15
|
+
import type { ExecuteOptions, ExecutionResult, JournalEntry, JournalFilter } from '../execution/index.js';
|
|
16
|
+
import type { Instant } from '../guards/index.js';
|
|
17
|
+
import type { Correlation, CorrelationRegistration, DeadLetter, DeadLetterFilter, ExternalEvent, IngestionOptions, IngestionResult } from '../ingestion/index.js';
|
|
18
|
+
import type { MigrationOptions, MigrationReport, MigrationTransform } from '../migration/index.js';
|
|
19
|
+
import type { AnyCaseType, StepMetadata } from '../model/index.js';
|
|
20
|
+
import type { CaseHandle, DatabaseAccess } from '../store/index.js';
|
|
21
|
+
import type { AffordanceExplanation, CaseAffordances, ExplainRequest } from './compute.js';
|
|
22
|
+
/** Options for {@link createEngine}. */
|
|
23
|
+
export interface EngineOptions {
|
|
24
|
+
/**
|
|
25
|
+
* The app brings its database, declaring which kind of handle it has:
|
|
26
|
+
* `{ pool }` for anything that checks out connections (`pg.Pool`, or any
|
|
27
|
+
* wrapper declaring `connect()`), `{ client }` for a single connection
|
|
28
|
+
* dedicated to the engine. The declaration is what lets `execute` run its
|
|
29
|
+
* transactions on one connection without guessing — see
|
|
30
|
+
* {@link DatabaseAccess}.
|
|
31
|
+
*/
|
|
32
|
+
readonly db: DatabaseAccess;
|
|
33
|
+
/** Every case type this engine serves; a loaded case's `case_type` must name one of them. */
|
|
34
|
+
readonly caseTypes: readonly AnyCaseType[];
|
|
35
|
+
/**
|
|
36
|
+
* How long an Execution's claim survives without a heartbeat (default 30s).
|
|
37
|
+
* The floor on how long a crashed handler can hold a case.
|
|
38
|
+
*/
|
|
39
|
+
readonly claimTtlMs?: number;
|
|
40
|
+
/** How often a running handler refreshes its claim (default 5s). */
|
|
41
|
+
readonly heartbeatMs?: number;
|
|
42
|
+
/**
|
|
43
|
+
* Event ingestion: how an external event's Actor is derived.
|
|
44
|
+
* Correlation needs no configuration — it is a registry, not a policy.
|
|
45
|
+
*/
|
|
46
|
+
readonly ingestion?: IngestionOptions;
|
|
47
|
+
/**
|
|
48
|
+
* The clock — every process-side "as of now" below the boundary reads
|
|
49
|
+
* through it: guard evaluation instants, journal timestamps, ingestion
|
|
50
|
+
* receipt times. Defaults to the wall clock. Two "nows" it deliberately
|
|
51
|
+
* does not govern: lease expiry is judged by the storage adapter's own
|
|
52
|
+
* clock (the one clock all competing processes share), and the retry
|
|
53
|
+
* delay / heartbeat cadence run on process timers (an internal seam the
|
|
54
|
+
* lifecycle's own tests drive virtually).
|
|
55
|
+
*/
|
|
56
|
+
readonly now?: () => Date;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Options for {@link Engine.explain}. Omit `actor` to probe `requires`
|
|
60
|
+
* alone: `permits` conditions are then reported un-evaluated (failed, with
|
|
61
|
+
* the stated reason `'not evaluated: no actor supplied'`) rather than run
|
|
62
|
+
* against nothing. The normalization rule is `explainContext`, stated and
|
|
63
|
+
* tested beside the pure computation it feeds.
|
|
64
|
+
*/
|
|
65
|
+
export type ExplainOptions = ExplainRequest;
|
|
66
|
+
export interface Engine {
|
|
67
|
+
/**
|
|
68
|
+
* Create a case of a registered case type. The initial state is validated
|
|
69
|
+
* against the type's schema.
|
|
70
|
+
*/
|
|
71
|
+
createCase(caseTypeName: string, initialState: unknown): Promise<CaseHandle<unknown>>;
|
|
72
|
+
/**
|
|
73
|
+
* Compute the affordances record for a case: load it, evaluate every
|
|
74
|
+
* step's guard for `actor` (with scope fan-out), and return the
|
|
75
|
+
* serializable available + blocked answer. Dormant (ended) cases compute
|
|
76
|
+
* like any other — dormancy is annotation, never a freeze.
|
|
77
|
+
*/
|
|
78
|
+
affordances(caseId: string, actor: unknown, asOf?: Instant): Promise<CaseAffordances>;
|
|
79
|
+
/**
|
|
80
|
+
* {@link Engine.affordances} for a case already in hand — no second load,
|
|
81
|
+
* no re-validation. Synchronous: a registry read plus the pure
|
|
82
|
+
* computation. The handle must be one this engine issued
|
|
83
|
+
* ({@link Engine.createCase}, {@link Engine.case}) — their `state` is the
|
|
84
|
+
* validated document; a hand-built handle carries no such guarantee.
|
|
85
|
+
* This is how a create route answers with the fresh case's affordances
|
|
86
|
+
* without re-reading what it just wrote.
|
|
87
|
+
*/
|
|
88
|
+
affordancesOf(handle: CaseHandle<unknown>, actor: unknown, asOf?: Instant): CaseAffordances;
|
|
89
|
+
/** The full per-condition breakdown for one step (× scope element) of a case. */
|
|
90
|
+
explain(caseId: string, stepName: string, options?: ExplainOptions): Promise<AffordanceExplanation>;
|
|
91
|
+
/**
|
|
92
|
+
* Execute a step on a case: claim → run → commit. The claim
|
|
93
|
+
* re-evaluates the guard transactionally — the enforcement moment — so an
|
|
94
|
+
* affordance that has since gone away rejects with `StepNotAvailableError`
|
|
95
|
+
* carrying the current unmet conditions, and a case with an Execution
|
|
96
|
+
* already in flight rejects with `CaseBusyError`.
|
|
97
|
+
*/
|
|
98
|
+
execute(caseId: string, stepName: string, options: ExecuteOptions): Promise<ExecutionResult>;
|
|
99
|
+
/**
|
|
100
|
+
* Read a case's journal, oldest first. Filter by `scopeKey` for
|
|
101
|
+
* a per-track audit — "everything that happened on buyer #7".
|
|
102
|
+
*/
|
|
103
|
+
journal(caseId: string, filter?: JournalFilter): Promise<readonly JournalEntry[]>;
|
|
104
|
+
/**
|
|
105
|
+
* Read one case as persisted: the row plus its Case State validated
|
|
106
|
+
* against the registered schema. Loud — an addressed read owes an answer,
|
|
107
|
+
* so an unknown case or a state its schema no longer accepts throws.
|
|
108
|
+
* The read ops surfaces, tests and consoles would otherwise each
|
|
109
|
+
* hand-write in SQL.
|
|
110
|
+
*/
|
|
111
|
+
case(caseId: string): Promise<CaseHandle<unknown>>;
|
|
112
|
+
/** Where an external identifier routes — the reverse of {@link Engine.correlations}. */
|
|
113
|
+
correlationOf(system: string, externalId: string): Promise<Correlation | null>;
|
|
114
|
+
/**
|
|
115
|
+
* The declared input schema of one step of a registered case type, or
|
|
116
|
+
* `null` when the step takes no input. Synchronous — a registry read. The
|
|
117
|
+
* engine owns the registry and a case names its type, so an adapter never
|
|
118
|
+
* needs to be handed the case types a second time to describe inputs.
|
|
119
|
+
*/
|
|
120
|
+
inputSchemaFor(caseTypeName: string, stepName: string): StandardSchemaV1 | null;
|
|
121
|
+
/**
|
|
122
|
+
* The declared human metadata of one step of a registered case type —
|
|
123
|
+
* `title` and `description`, each `null` when undeclared — or `null` for
|
|
124
|
+
* an unknown step. Synchronous, a registry read like
|
|
125
|
+
* {@link Engine.inputSchemaFor}, and for the same reason: adapters
|
|
126
|
+
* serialize step metadata from here rather than being handed the case
|
|
127
|
+
* types a second time.
|
|
128
|
+
*/
|
|
129
|
+
stepMetadataFor(caseTypeName: string, stepName: string): StepMetadata | null;
|
|
130
|
+
/**
|
|
131
|
+
* Ingest one external event: dedup, correlate, then an ordinary Execution
|
|
132
|
+
* with the external system as the actor. Never throws for an
|
|
133
|
+
* event's own sake — an event that could not be applied is dead-lettered
|
|
134
|
+
* with a reason, so a webhook endpoint can acknowledge and move on.
|
|
135
|
+
*/
|
|
136
|
+
ingest(event: ExternalEvent): Promise<IngestionResult>;
|
|
137
|
+
/**
|
|
138
|
+
* Register an external identifier against a case out of band. Handlers
|
|
139
|
+
* should prefer `ctx.correlate(...)`, which rides the same commit as the
|
|
140
|
+
* state recording that the interaction was started.
|
|
141
|
+
*/
|
|
142
|
+
correlate(registration: CorrelationRegistration): Promise<Correlation>;
|
|
143
|
+
/** Every external identifier registered against a case (× scope element). */
|
|
144
|
+
correlations(caseId: string, scopeKey?: string): Promise<readonly Correlation[]>;
|
|
145
|
+
/** The dead-letter surface: events that arrived and changed nothing, with why. */
|
|
146
|
+
deadLetters(filter?: DeadLetterFilter): Promise<readonly DeadLetter[]>;
|
|
147
|
+
/**
|
|
148
|
+
* Restructure the state of every case of a case type, as journaled system
|
|
149
|
+
* Executions (float's escape hatch). Idempotent: a case that
|
|
150
|
+
* already carries the migration's marker is skipped, so re-running is a
|
|
151
|
+
* no-op and an interrupted run resumes. Reach for it only when no total
|
|
152
|
+
* condition can read the old shape — see `docs/migration.md`.
|
|
153
|
+
*/
|
|
154
|
+
migrate(caseTypeName: string, name: string, transform: MigrationTransform, options?: MigrationOptions): Promise<MigrationReport>;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Build an engine from the app's database and its case type definitions.
|
|
158
|
+
* Throws at construction on duplicate case type names — the registry is
|
|
159
|
+
* keyed by name, which is all a case row records (definitions
|
|
160
|
+
* float; only the name is persisted).
|
|
161
|
+
*/
|
|
162
|
+
export declare const createEngine: (options: EngineOptions) => Engine;
|