@sekiban/dcb-domain 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 +92 -0
- package/README.md +104 -0
- package/dist/bridge.d.ts +162 -0
- package/dist/bridge.js +250 -0
- package/dist/command.d.ts +53 -0
- package/dist/command.js +64 -0
- package/dist/domain.d.ts +27 -0
- package/dist/domain.js +79 -0
- package/dist/event.d.ts +49 -0
- package/dist/event.js +178 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +15692 -0
- package/dist/parse.d.ts +24 -0
- package/dist/parse.js +71 -0
- package/dist/session.d.ts +108 -0
- package/dist/session.js +381 -0
- package/dist/state.d.ts +81 -0
- package/dist/state.js +112 -0
- package/dist/testing.d.ts +32 -0
- package/dist/testing.js +416 -0
- package/dist/types.d.ts +150 -0
- package/dist/types.js +90 -0
- package/package.json +54 -0
package/dist/state.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { DomainAuthoringError, DomainRegistrationError, } from "./types";
|
|
3
|
+
const projectorFamilyInvariant = Symbol("projector-family-invariant");
|
|
4
|
+
export function stateUnion(schema, options) {
|
|
5
|
+
const discriminator = options.discriminator ?? "kind";
|
|
6
|
+
return Object.freeze({
|
|
7
|
+
kind: "state-union",
|
|
8
|
+
discriminator,
|
|
9
|
+
schema,
|
|
10
|
+
parse: (value) => schema.parse(value),
|
|
11
|
+
initial: options.initial,
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
export const state = stateUnion;
|
|
15
|
+
export function states(variants, options) {
|
|
16
|
+
const discriminator = options.discriminator ?? "kind";
|
|
17
|
+
// Keep the discriminator visible to Zod as well as to the authoring type.
|
|
18
|
+
// The cast is limited to the public variadic tuple surface; callers are
|
|
19
|
+
// expected to provide object variants carrying the selected discriminator.
|
|
20
|
+
const schema = z.discriminatedUnion(discriminator, variants);
|
|
21
|
+
return stateUnion(schema, { discriminator, initial: options.initial });
|
|
22
|
+
}
|
|
23
|
+
export function validationReject(rejectKind, reason, details) {
|
|
24
|
+
return Object.freeze({ kind: "reject", rejectKind, reason, ...(details === undefined ? {} : { details }) });
|
|
25
|
+
}
|
|
26
|
+
export function validate(fn) {
|
|
27
|
+
return fn;
|
|
28
|
+
}
|
|
29
|
+
export function evolve(fn) {
|
|
30
|
+
return fn;
|
|
31
|
+
}
|
|
32
|
+
export function decider(module) {
|
|
33
|
+
return Object.freeze({ validate: module.validate, evolve: module.evolve });
|
|
34
|
+
}
|
|
35
|
+
function initialStateOf(value) {
|
|
36
|
+
if (value === undefined)
|
|
37
|
+
throw new DomainAuthoringError("PROJECTOR_INITIAL_STATE_REQUIRED", "Projector initial state is required");
|
|
38
|
+
return typeof value === "function" ? value() : value;
|
|
39
|
+
}
|
|
40
|
+
function eventNameFromType(eventType) {
|
|
41
|
+
if (eventType.length === 0 || eventType.includes(":")) {
|
|
42
|
+
throw new DomainAuthoringError("CANONICAL_EVENT_IDENTITY_INVALID", `Event type ${eventType} is not an event payload name`);
|
|
43
|
+
}
|
|
44
|
+
return eventType;
|
|
45
|
+
}
|
|
46
|
+
export function projector(options) {
|
|
47
|
+
if (options.id.length === 0)
|
|
48
|
+
throw new DomainAuthoringError("PROJECTOR_ID_REQUIRED", "Projector id is required");
|
|
49
|
+
const events = options.events ?? options.source?.events ?? [];
|
|
50
|
+
const eventTypes = Object.freeze(events.map((definition) => definition.eventType));
|
|
51
|
+
const handlers = options.handlers;
|
|
52
|
+
for (const eventType of eventTypes) {
|
|
53
|
+
if (handlers[eventType] === undefined && handlers[eventNameFromType(eventType)] === undefined) {
|
|
54
|
+
throw new DomainRegistrationError(`Projector ${options.id} is missing handler for ${eventType}`, [eventType]);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const version = options.version ?? 1;
|
|
58
|
+
if (!Number.isSafeInteger(version) || version < 1)
|
|
59
|
+
throw new DomainAuthoringError("PROJECTOR_VERSION_INVALID", "Projector version must be positive");
|
|
60
|
+
const initialState = options.initialState ?? options.initial;
|
|
61
|
+
const validateState = options.state === undefined
|
|
62
|
+
? (value) => value
|
|
63
|
+
: (value) => options.state.parse(value);
|
|
64
|
+
const serializeState = options.serializeState ?? ((state) => JSON.stringify(state));
|
|
65
|
+
const deserializeState = options.deserializeState ?? ((serialized) => JSON.parse(serialized));
|
|
66
|
+
const initial = initialStateOf(initialState);
|
|
67
|
+
const byType = new Map(events.map((definition) => [definition.eventType, definition]));
|
|
68
|
+
const apply = (state, event) => {
|
|
69
|
+
const definition = byType.get(event.eventType);
|
|
70
|
+
if (definition === undefined) {
|
|
71
|
+
throw new DomainAuthoringError("EVENT_TYPE_UNREGISTERED", `Projector ${options.id} does not subscribe to ${event.eventType}`);
|
|
72
|
+
}
|
|
73
|
+
if (!event.tags.some((tag) => tag.family === options.tag.family))
|
|
74
|
+
return state;
|
|
75
|
+
const payload = definition.make(event.payload);
|
|
76
|
+
const handler = handlers[definition.eventType] ?? handlers[definition.name];
|
|
77
|
+
if (handler === undefined)
|
|
78
|
+
throw new DomainRegistrationError(`Projector ${options.id} has no handler for ${definition.eventType}`, [definition.eventType]);
|
|
79
|
+
const next = handler(state, {
|
|
80
|
+
definition,
|
|
81
|
+
eventType: definition.eventType,
|
|
82
|
+
payload,
|
|
83
|
+
tags: event.tags,
|
|
84
|
+
});
|
|
85
|
+
return validateState(next);
|
|
86
|
+
};
|
|
87
|
+
const subscribes = (eventType) => byType.has(eventType);
|
|
88
|
+
return Object.freeze({
|
|
89
|
+
id: options.id,
|
|
90
|
+
version,
|
|
91
|
+
tag: options.tag,
|
|
92
|
+
[projectorFamilyInvariant]: (value) => value,
|
|
93
|
+
events,
|
|
94
|
+
eventTypes,
|
|
95
|
+
initialState: initial,
|
|
96
|
+
handlers,
|
|
97
|
+
subscribes,
|
|
98
|
+
apply,
|
|
99
|
+
reduce: apply,
|
|
100
|
+
validateState,
|
|
101
|
+
serializeState,
|
|
102
|
+
deserializeState,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
export function projectorInitialState(definition) {
|
|
106
|
+
return typeof definition.initialState === "function"
|
|
107
|
+
? definition.initialState()
|
|
108
|
+
: definition.initialState;
|
|
109
|
+
}
|
|
110
|
+
export function projectorFamily(definition) {
|
|
111
|
+
return definition.tag;
|
|
112
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { EventRecord, FixedNow, JsonValue, Tag } from "./types.js";
|
|
2
|
+
import type { CommandDefinition } from "./command.js";
|
|
3
|
+
import type { EventDefinition } from "./event.js";
|
|
4
|
+
import type { ProjectorDefinition } from "./state.js";
|
|
5
|
+
export interface TestingEvent extends EventRecord {
|
|
6
|
+
readonly payload: JsonValue;
|
|
7
|
+
}
|
|
8
|
+
export interface Expectation {
|
|
9
|
+
readonly kind: "done" | "none" | "reject" | "accepted" | "discarded" | "rejected";
|
|
10
|
+
readonly code?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface GivenWhen {
|
|
13
|
+
readonly expect: (expectation: Expectation | Expectation["kind"]) => Promise<unknown>;
|
|
14
|
+
}
|
|
15
|
+
export interface GivenBuilder {
|
|
16
|
+
readonly when: (command: CommandDefinition, input: unknown) => GivenWhen;
|
|
17
|
+
}
|
|
18
|
+
export declare function given<State, Family extends string, Events extends readonly EventDefinition[]>(projector: ProjectorDefinition<State, Family, Events>, events?: readonly TestingEvent[], options?: {
|
|
19
|
+
readonly now?: FixedNow;
|
|
20
|
+
readonly tag?: Tag;
|
|
21
|
+
}): GivenBuilder;
|
|
22
|
+
export interface EvolveTableCase<State> {
|
|
23
|
+
readonly name: string;
|
|
24
|
+
readonly state: State;
|
|
25
|
+
readonly event: TestingEvent;
|
|
26
|
+
readonly expected: State;
|
|
27
|
+
}
|
|
28
|
+
export declare function evolveTable<State, Family extends string, Events extends readonly EventDefinition[]>(projector: ProjectorDefinition<State, Family, Events>, cases: readonly EvolveTableCase<NoInfer<State>>[]): readonly {
|
|
29
|
+
readonly name: string;
|
|
30
|
+
readonly state: State;
|
|
31
|
+
}[];
|
|
32
|
+
export declare const evolve: typeof evolveTable;
|
package/dist/testing.js
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
// src/types.ts
|
|
2
|
+
var DomainAuthoringError = class extends Error {
|
|
3
|
+
code;
|
|
4
|
+
constructor(code, message, options) {
|
|
5
|
+
super(message, options);
|
|
6
|
+
this.name = "DomainAuthoringError";
|
|
7
|
+
this.code = code;
|
|
8
|
+
}
|
|
9
|
+
};
|
|
10
|
+
function tagFamily(family) {
|
|
11
|
+
if (family.length === 0) throw new DomainAuthoringError("TAG_FAMILY_INVALID", "Tag family must not be empty");
|
|
12
|
+
const make = (value) => {
|
|
13
|
+
if (value.length === 0) throw new DomainAuthoringError("TAG_VALUE_INVALID", "Tag value must not be empty");
|
|
14
|
+
const id = `${family}:${value}`;
|
|
15
|
+
return Object.freeze({
|
|
16
|
+
family,
|
|
17
|
+
group: family,
|
|
18
|
+
value,
|
|
19
|
+
content: value,
|
|
20
|
+
id,
|
|
21
|
+
tag: id
|
|
22
|
+
});
|
|
23
|
+
};
|
|
24
|
+
return Object.freeze({ family, group: family, of: make, create: make });
|
|
25
|
+
}
|
|
26
|
+
function normalizeTag(input) {
|
|
27
|
+
if (typeof input === "string") {
|
|
28
|
+
const separator = input.indexOf(":");
|
|
29
|
+
if (separator <= 0 || separator === input.length - 1) {
|
|
30
|
+
throw new DomainAuthoringError("TAG_INVALID", "A tag string must be family:value");
|
|
31
|
+
}
|
|
32
|
+
return tagFamily(input.slice(0, separator)).of(input.slice(separator + 1));
|
|
33
|
+
}
|
|
34
|
+
if (input.id.length === 0 || input.family.length === 0 || input.value.length === 0) {
|
|
35
|
+
throw new DomainAuthoringError("TAG_INVALID", "Tag family, value, and id are required");
|
|
36
|
+
}
|
|
37
|
+
return input;
|
|
38
|
+
}
|
|
39
|
+
var V1_REJECT_ERROR_CODES = Object.freeze({
|
|
40
|
+
validation: "validation_error",
|
|
41
|
+
"not-found": "not_found",
|
|
42
|
+
conflict: "consistency_conflict",
|
|
43
|
+
forbidden: "forbidden",
|
|
44
|
+
"invalid-state": "invalid_state",
|
|
45
|
+
internal: "internal_error"
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// src/command.ts
|
|
49
|
+
function none(reason) {
|
|
50
|
+
return Object.freeze({ kind: "none", ...reason === void 0 ? {} : { reason } });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// src/session.ts
|
|
54
|
+
var SessionStateError = class extends DomainAuthoringError {
|
|
55
|
+
constructor(message) {
|
|
56
|
+
super("SESSION_STATE_INVALID", message);
|
|
57
|
+
this.name = "SessionStateError";
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
var UndeclaredReadError = class extends DomainAuthoringError {
|
|
61
|
+
projectorId;
|
|
62
|
+
tag;
|
|
63
|
+
constructor(projectorId, tag) {
|
|
64
|
+
super("UNDECLARED_DYNAMIC_READ", `Read of ${projectorId ?? "exists"}/${tag.id} was not declared`);
|
|
65
|
+
this.name = "UndeclaredReadError";
|
|
66
|
+
this.projectorId = projectorId;
|
|
67
|
+
this.tag = tag;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
var IncoherentSnapshotError = class extends DomainAuthoringError {
|
|
71
|
+
constructor(tag, firstHead, secondHead) {
|
|
72
|
+
super("INCOHERENT_SNAPSHOT", `Tag ${tag.id} was supplied with heads ${firstHead ?? "null"} and ${secondHead ?? "null"}`);
|
|
73
|
+
this.name = "IncoherentSnapshotError";
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
function cellKey(projector, tag) {
|
|
77
|
+
return `${projector.id}\0${tag.id}`;
|
|
78
|
+
}
|
|
79
|
+
function initialState(projector) {
|
|
80
|
+
return typeof projector.initialState === "function" ? projector.initialState() : projector.initialState;
|
|
81
|
+
}
|
|
82
|
+
function eventEligible(projector, cellTag, event) {
|
|
83
|
+
return event.tags.some((tag) => tag.id === cellTag.id && tag.family === projector.tag.family) && projector.subscribes(event.eventType);
|
|
84
|
+
}
|
|
85
|
+
function asRecord(event) {
|
|
86
|
+
return Object.freeze({
|
|
87
|
+
eventType: event.eventType,
|
|
88
|
+
eventName: event.event.eventPayloadName,
|
|
89
|
+
payload: event.payload,
|
|
90
|
+
tags: Object.freeze([...event.tags]),
|
|
91
|
+
ordinal: event.ordinal
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
var Session = class {
|
|
95
|
+
now;
|
|
96
|
+
statusValue = "OPEN";
|
|
97
|
+
readSet;
|
|
98
|
+
snapshots;
|
|
99
|
+
onPropagation;
|
|
100
|
+
snapshotByCell = /* @__PURE__ */ new Map();
|
|
101
|
+
overlayByCell = /* @__PURE__ */ new Map();
|
|
102
|
+
headByTag = /* @__PURE__ */ new Map();
|
|
103
|
+
claimsByKey = /* @__PURE__ */ new Map();
|
|
104
|
+
staged = [];
|
|
105
|
+
observations = [];
|
|
106
|
+
constructor(options) {
|
|
107
|
+
this.now = options.now;
|
|
108
|
+
this.readSet = options.readSet;
|
|
109
|
+
this.snapshots = options.snapshots;
|
|
110
|
+
this.onPropagation = options.onPropagation;
|
|
111
|
+
}
|
|
112
|
+
get status() {
|
|
113
|
+
return this.statusValue;
|
|
114
|
+
}
|
|
115
|
+
get stagedEvents() {
|
|
116
|
+
return Object.freeze([...this.staged]);
|
|
117
|
+
}
|
|
118
|
+
get propagation() {
|
|
119
|
+
return Object.freeze([...this.observations]);
|
|
120
|
+
}
|
|
121
|
+
get readClaims() {
|
|
122
|
+
return Object.freeze([...this.claimsByKey.values()]);
|
|
123
|
+
}
|
|
124
|
+
observe(observation) {
|
|
125
|
+
const frozen = Object.freeze({ ...observation, tags: Object.freeze([...observation.tags]) });
|
|
126
|
+
this.observations.push(frozen);
|
|
127
|
+
this.onPropagation?.(frozen);
|
|
128
|
+
}
|
|
129
|
+
assertOpen() {
|
|
130
|
+
if (this.statusValue !== "OPEN") throw new SessionStateError(`Session is ${this.statusValue}`);
|
|
131
|
+
}
|
|
132
|
+
assertDeclared(kind, projectorId, tag) {
|
|
133
|
+
if (!this.readSet.has(kind, projectorId, tag)) throw new UndeclaredReadError(projectorId, tag);
|
|
134
|
+
}
|
|
135
|
+
rememberClaim(declaration, head) {
|
|
136
|
+
const key = `${declaration.kind}\0${declaration.projectorId ?? ""}\0${declaration.tag.id}`;
|
|
137
|
+
this.claimsByKey.set(key, Object.freeze({
|
|
138
|
+
kind: declaration.kind,
|
|
139
|
+
...declaration.projectorId === void 0 ? {} : { projectorId: declaration.projectorId },
|
|
140
|
+
tag: declaration.tag,
|
|
141
|
+
head
|
|
142
|
+
}));
|
|
143
|
+
}
|
|
144
|
+
async loadSnapshot(projector, tag) {
|
|
145
|
+
const key = cellKey(projector, tag);
|
|
146
|
+
const cached = this.snapshotByCell.get(key);
|
|
147
|
+
if (cached !== void 0) return cached;
|
|
148
|
+
const supplied = this.snapshots === void 0 ? { projectorId: projector.id, tag, head: null, state: initialState(projector), exists: false } : await this.snapshots.read(projector, tag);
|
|
149
|
+
const suppliedTag = normalizeTag(supplied.tag);
|
|
150
|
+
if (suppliedTag.id !== tag.id || supplied.projectorId !== projector.id) {
|
|
151
|
+
throw new DomainAuthoringError("SNAPSHOT_IDENTITY_INVALID", `Snapshot identity did not match ${projector.id}/${tag.id}`);
|
|
152
|
+
}
|
|
153
|
+
const suppliedHead = supplied.head ?? "";
|
|
154
|
+
const existingHead = this.headByTag.get(tag.id);
|
|
155
|
+
if (existingHead !== void 0 && existingHead !== suppliedHead) {
|
|
156
|
+
throw new IncoherentSnapshotError(tag, existingHead, suppliedHead);
|
|
157
|
+
}
|
|
158
|
+
this.headByTag.set(tag.id, suppliedHead);
|
|
159
|
+
this.snapshotByCell.set(key, supplied);
|
|
160
|
+
return supplied;
|
|
161
|
+
}
|
|
162
|
+
async stateFor(projector, tag) {
|
|
163
|
+
this.assertOpen();
|
|
164
|
+
this.assertDeclared("state", projector.id, tag);
|
|
165
|
+
this.attachProjector(projector);
|
|
166
|
+
const key = cellKey(projector, tag);
|
|
167
|
+
const overlay = this.overlayByCell.get(key);
|
|
168
|
+
if (overlay !== void 0) return overlay;
|
|
169
|
+
const snapshot = await this.loadSnapshot(projector, tag);
|
|
170
|
+
let state = snapshot.state;
|
|
171
|
+
for (const staged of this.staged) {
|
|
172
|
+
const record = asRecord(staged);
|
|
173
|
+
if (!eventEligible(projector, tag, record)) continue;
|
|
174
|
+
state = projector.apply(state, record);
|
|
175
|
+
this.observe({ point: "eligible-cells", eventType: record.eventType, tags: record.tags });
|
|
176
|
+
}
|
|
177
|
+
this.overlayByCell.set(key, state);
|
|
178
|
+
this.rememberClaim({ kind: "state", projectorId: projector.id, tag }, snapshot.head ?? "");
|
|
179
|
+
return state;
|
|
180
|
+
}
|
|
181
|
+
async existsFor(tag) {
|
|
182
|
+
this.assertOpen();
|
|
183
|
+
this.assertDeclared("exists", void 0, tag);
|
|
184
|
+
const snapshotExists = this.snapshots?.exists === void 0 ? void 0 : await this.snapshots.exists(tag);
|
|
185
|
+
const stagedExists = this.staged.some((event) => event.tags.some((candidate) => candidate.id === tag.id));
|
|
186
|
+
const result = (snapshotExists ?? this.snapshotByCellHasTag(tag)) || stagedExists;
|
|
187
|
+
let head = this.headByTag.get(tag.id);
|
|
188
|
+
if (snapshotExists === false) {
|
|
189
|
+
head = "";
|
|
190
|
+
this.headByTag.set(tag.id, head);
|
|
191
|
+
} else if (snapshotExists === true && this.snapshots?.head !== void 0) {
|
|
192
|
+
head = await this.snapshots.head(tag);
|
|
193
|
+
this.headByTag.set(tag.id, head);
|
|
194
|
+
}
|
|
195
|
+
this.rememberClaim({ kind: "exists", tag }, head ?? null);
|
|
196
|
+
return result;
|
|
197
|
+
}
|
|
198
|
+
snapshotByCellHasTag(tag) {
|
|
199
|
+
return [...this.snapshotByCell.values()].some((snapshot) => snapshot.tag.id === tag.id && snapshot.exists);
|
|
200
|
+
}
|
|
201
|
+
async preload() {
|
|
202
|
+
this.assertOpen();
|
|
203
|
+
for (const declaration of this.readSet.claims) {
|
|
204
|
+
if (declaration.kind === "state") {
|
|
205
|
+
const projector = this.findProjector(declaration.projectorId);
|
|
206
|
+
await this.stateFor(projector, declaration.tag);
|
|
207
|
+
} else {
|
|
208
|
+
await this.existsFor(declaration.tag);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
findProjector(projectorId) {
|
|
213
|
+
if (projectorId === void 0) throw new DomainAuthoringError("PROJECTOR_ID_REQUIRED", "A state read requires a projector");
|
|
214
|
+
const declaration = this.readSet.claims.find((claim) => claim.projectorId === projectorId && claim.projector !== void 0);
|
|
215
|
+
if (declaration?.projector !== void 0) return declaration.projector;
|
|
216
|
+
throw new DomainAuthoringError("PROJECTOR_NOT_IN_READ_SET", `Projector ${projectorId} was not attached to this session`);
|
|
217
|
+
}
|
|
218
|
+
readSetProjectors = /* @__PURE__ */ new Map();
|
|
219
|
+
attachProjector(projector) {
|
|
220
|
+
this.readSetProjectors.set(projector.id, {
|
|
221
|
+
id: projector.id,
|
|
222
|
+
tag: projector.tag,
|
|
223
|
+
subscribes: projector.subscribes,
|
|
224
|
+
apply: (state, event) => projector.apply(state, event)
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
attachProjectors(projectors) {
|
|
228
|
+
for (const projector of projectors) this.attachProjector(projector);
|
|
229
|
+
}
|
|
230
|
+
context(projectors = []) {
|
|
231
|
+
this.attachProjectors(projectors);
|
|
232
|
+
return Object.freeze({
|
|
233
|
+
state: (projector, tag) => this.stateFor(projector, tag),
|
|
234
|
+
exists: (tag) => this.existsFor(tag),
|
|
235
|
+
now: () => this.now,
|
|
236
|
+
append: (event, payload) => this.append(event, payload)
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
append(event, payload) {
|
|
240
|
+
this.assertOpen();
|
|
241
|
+
const parsed = event.make(payload);
|
|
242
|
+
const derivedTags = event.tags(parsed).map(normalizeTag);
|
|
243
|
+
const ordinal = String(this.staged.length);
|
|
244
|
+
const staged = Object.freeze({
|
|
245
|
+
event,
|
|
246
|
+
eventType: event.eventType,
|
|
247
|
+
payload: parsed,
|
|
248
|
+
tags: Object.freeze(derivedTags),
|
|
249
|
+
ordinal
|
|
250
|
+
});
|
|
251
|
+
this.staged.push(staged);
|
|
252
|
+
this.observe({ point: "staged-log", eventType: event.eventType, tags: derivedTags });
|
|
253
|
+
const record = asRecord(staged);
|
|
254
|
+
for (const [key, snapshot] of this.snapshotByCell) {
|
|
255
|
+
const separator = key.indexOf("\0");
|
|
256
|
+
const projectorId = key.slice(0, separator);
|
|
257
|
+
const tagId = key.slice(separator + 1);
|
|
258
|
+
if (!record.tags.some((tag) => tag.id === tagId)) continue;
|
|
259
|
+
const projector = this.readSetProjectors.get(projectorId);
|
|
260
|
+
if (projector === void 0 || !eventEligible(projector, snapshot.tag, record)) continue;
|
|
261
|
+
const previous = this.overlayByCell.get(key) ?? snapshot.state;
|
|
262
|
+
const next = projector.apply(previous, record);
|
|
263
|
+
this.overlayByCell.set(key, next);
|
|
264
|
+
this.observe({ point: "eligible-cells", eventType: event.eventType, tags: record.tags });
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
candidateTags() {
|
|
268
|
+
const tags = /* @__PURE__ */ new Map();
|
|
269
|
+
for (const event of this.staged) for (const tag of event.tags) tags.set(tag.id, tag);
|
|
270
|
+
for (const tag of this.readSet.tags) tags.set(tag.id, tag);
|
|
271
|
+
return Object.freeze([...tags.values()]);
|
|
272
|
+
}
|
|
273
|
+
seal(decision) {
|
|
274
|
+
this.assertOpen();
|
|
275
|
+
const tags = this.candidateTags();
|
|
276
|
+
this.observe({ point: "claim-candidate-preflight", tags });
|
|
277
|
+
const envelope = Object.freeze({
|
|
278
|
+
kind: "candidate-envelope",
|
|
279
|
+
now: this.now,
|
|
280
|
+
events: Object.freeze(this.staged.map(asRecord)),
|
|
281
|
+
tags,
|
|
282
|
+
readClaims: this.readClaims,
|
|
283
|
+
decision
|
|
284
|
+
});
|
|
285
|
+
this.observe({ point: "sealed-envelope", tags });
|
|
286
|
+
this.statusValue = "SEALED";
|
|
287
|
+
return envelope;
|
|
288
|
+
}
|
|
289
|
+
discard(reason = "discarded") {
|
|
290
|
+
this.assertOpen();
|
|
291
|
+
this.staged.splice(0, this.staged.length);
|
|
292
|
+
this.overlayByCell.clear();
|
|
293
|
+
this.statusValue = "DISCARDED";
|
|
294
|
+
return reason.length === 0 ? none() : none(reason);
|
|
295
|
+
}
|
|
296
|
+
finish(decision) {
|
|
297
|
+
if (decision.kind === "done") return this.seal(decision);
|
|
298
|
+
this.discard(decision.kind === "none" ? decision.reason ?? "none" : decision.reason);
|
|
299
|
+
return void 0;
|
|
300
|
+
}
|
|
301
|
+
decisionLog(decision) {
|
|
302
|
+
return Object.freeze({
|
|
303
|
+
now: this.now,
|
|
304
|
+
events: Object.freeze(this.staged.map(asRecord)),
|
|
305
|
+
readClaims: this.readClaims,
|
|
306
|
+
terminal: decision
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
function classifyCommitResult(value) {
|
|
311
|
+
if (value === void 0 || value === true) return { kind: "accepted" };
|
|
312
|
+
if (typeof value !== "object" || value === null) return { kind: "accepted" };
|
|
313
|
+
const record = value;
|
|
314
|
+
if (record.kind === "consistency-conflict" || record.kind === "conflict" || record.code === "consistency_conflict") {
|
|
315
|
+
return { kind: "consistency-conflict" };
|
|
316
|
+
}
|
|
317
|
+
if (record.kind === "unknown" || record.kind === "timeout" || record.code === "unknown_outcome") {
|
|
318
|
+
return { kind: "unknown", error: value };
|
|
319
|
+
}
|
|
320
|
+
if (record.kind === "rejected" || record.kind === "invalid") return { kind: "rejected", error: value };
|
|
321
|
+
return { kind: "accepted" };
|
|
322
|
+
}
|
|
323
|
+
async function executeCommand(command, input, options = {}) {
|
|
324
|
+
const fixedNow = options.timeProvider?.now() ?? 0;
|
|
325
|
+
const maxRetries = Math.max(0, Math.floor(options.maxConflictRetries ?? 1));
|
|
326
|
+
const parsed = command.parseInput(input);
|
|
327
|
+
let attempts = 0;
|
|
328
|
+
for (; ; ) {
|
|
329
|
+
attempts += 1;
|
|
330
|
+
const readSet = command.reads(parsed);
|
|
331
|
+
const session = new Session({
|
|
332
|
+
now: fixedNow,
|
|
333
|
+
readSet,
|
|
334
|
+
snapshots: options.snapshots,
|
|
335
|
+
onPropagation: options.onPropagation
|
|
336
|
+
});
|
|
337
|
+
try {
|
|
338
|
+
await session.preload();
|
|
339
|
+
const context = session.context([]);
|
|
340
|
+
const decision = await command.handle(parsed, context);
|
|
341
|
+
const envelope = session.finish(decision);
|
|
342
|
+
const log = session.decisionLog(decision);
|
|
343
|
+
if (envelope === void 0) {
|
|
344
|
+
return Object.freeze({ status: decision.kind === "reject" ? "rejected" : "discarded", attempts, now: fixedNow, decision, log, session });
|
|
345
|
+
}
|
|
346
|
+
if (options.commit === void 0) {
|
|
347
|
+
return Object.freeze({ status: "accepted", attempts, now: fixedNow, decision, envelope, log, session });
|
|
348
|
+
}
|
|
349
|
+
const commitResult = classifyCommitResult(await options.commit(envelope));
|
|
350
|
+
if (commitResult.kind === "consistency-conflict" && attempts <= maxRetries) continue;
|
|
351
|
+
if (commitResult.kind === "unknown") return Object.freeze({ status: "unknown", attempts, now: fixedNow, decision, envelope, log, session, error: commitResult.error });
|
|
352
|
+
if (commitResult.kind === "rejected") return Object.freeze({ status: "rejected", attempts, now: fixedNow, decision, envelope, log, session, error: commitResult.error });
|
|
353
|
+
return Object.freeze({ status: "accepted", attempts, now: fixedNow, decision, envelope, log, session });
|
|
354
|
+
} catch (error) {
|
|
355
|
+
if (session.status === "OPEN") session.discard("throw");
|
|
356
|
+
throw error;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// src/testing.ts
|
|
362
|
+
function stateFor(projector, events) {
|
|
363
|
+
let state = typeof projector.initialState === "function" ? projector.initialState() : projector.initialState;
|
|
364
|
+
for (const event of events) state = projector.apply(state, event);
|
|
365
|
+
return state;
|
|
366
|
+
}
|
|
367
|
+
function expectationMatches(result, expectation) {
|
|
368
|
+
const expected = typeof expectation === "string" ? { kind: expectation } : expectation;
|
|
369
|
+
if (expected.kind === "done") return result.decision.kind === "done";
|
|
370
|
+
if (expected.kind === "none") return result.decision.kind === "none";
|
|
371
|
+
if (expected.kind === "reject") return result.decision.kind === "reject" && (expected.code === void 0 || result.decision.code === expected.code);
|
|
372
|
+
if (expected.kind === "accepted") return result.status === "accepted";
|
|
373
|
+
if (expected.kind === "discarded") return result.status === "discarded";
|
|
374
|
+
return result.status === "rejected";
|
|
375
|
+
}
|
|
376
|
+
function given(projector, events = [], options = {}) {
|
|
377
|
+
const tag = options.tag ?? projector.tag.of("test");
|
|
378
|
+
const state = stateFor(projector, events);
|
|
379
|
+
return {
|
|
380
|
+
when: (command, input) => ({
|
|
381
|
+
expect: async (expectation) => {
|
|
382
|
+
const result = await executeCommand(command, input, {
|
|
383
|
+
timeProvider: { now: () => options.now ?? 0 },
|
|
384
|
+
snapshots: {
|
|
385
|
+
read: (requestedProjector, requestedTag) => ({
|
|
386
|
+
projectorId: requestedProjector.id,
|
|
387
|
+
tag: requestedTag,
|
|
388
|
+
head: "test-head",
|
|
389
|
+
state: requestedProjector.id === projector.id && requestedTag.id === tag.id ? state : requestedProjector.initialState,
|
|
390
|
+
exists: events.length > 0
|
|
391
|
+
})
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
if (!expectationMatches(result, expectation)) {
|
|
395
|
+
throw new Error(`Expected ${typeof expectation === "string" ? expectation : expectation.kind}, received ${result.decision.kind}/${result.status}`);
|
|
396
|
+
}
|
|
397
|
+
return result;
|
|
398
|
+
}
|
|
399
|
+
})
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
function evolveTable(projector, cases) {
|
|
403
|
+
return Object.freeze(cases.map((testCase) => {
|
|
404
|
+
const actual = projector.apply(testCase.state, testCase.event);
|
|
405
|
+
if (JSON.stringify(actual) !== JSON.stringify(testCase.expected)) {
|
|
406
|
+
throw new Error(`Evolve table case ${testCase.name} failed`);
|
|
407
|
+
}
|
|
408
|
+
return Object.freeze({ name: testCase.name, state: actual });
|
|
409
|
+
}));
|
|
410
|
+
}
|
|
411
|
+
var evolve = evolveTable;
|
|
412
|
+
export {
|
|
413
|
+
evolve,
|
|
414
|
+
evolveTable,
|
|
415
|
+
given
|
|
416
|
+
};
|