@house-rules/event-log 0.0.0-stage → 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/AGENTS.md +65 -0
- package/LICENSE +21 -0
- package/README.md +60 -2
- package/dist/adapters/memory/event.d.ts +3 -0
- package/dist/adapters/memory/event.js +143 -0
- package/dist/adapters/postgres/event.d.ts +4 -0
- package/dist/adapters/postgres/event.js +95 -0
- package/dist/contract.d.ts +5 -0
- package/dist/contract.js +4 -0
- package/dist/event/bind-event-log.d.ts +3 -0
- package/dist/event/bind-event-log.js +17 -0
- package/dist/event/delivery.d.ts +87 -0
- package/dist/event/delivery.js +44 -0
- package/dist/event/event-log.d.ts +136 -0
- package/dist/event/event-log.js +172 -0
- package/dist/event/event.d.ts +60 -0
- package/dist/event/event.js +46 -0
- package/dist/event/operations.d.ts +126 -0
- package/dist/event/operations.js +94 -0
- package/dist/event/options.d.ts +33 -0
- package/dist/event/options.js +28 -0
- package/dist/event/permissions.d.ts +8 -0
- package/dist/event/permissions.js +7 -0
- package/dist/event/source.d.ts +4 -0
- package/dist/event/source.js +15 -0
- package/dist/facade.d.ts +278 -0
- package/dist/facade.js +19 -0
- package/dist/index.d.ts +131 -0
- package/dist/index.js +15 -0
- package/dist/storage.d.ts +22 -0
- package/dist/storage.js +3 -0
- package/migrations/0001_event_log.sql +49 -0
- package/package.json +58 -3
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { Effect, Schema } from "effect";
|
|
2
|
+
import type { EventStore } from "../storage.js";
|
|
3
|
+
import { DeadLetterNotFound, EventInputInvalid, EventStoreUnavailable } from "./event.js";
|
|
4
|
+
import type { EventLogSettings } from "./options.js";
|
|
5
|
+
declare const ClaimQuery: Schema.Struct<{
|
|
6
|
+
readonly consumer: Schema.optionalKey<Schema.NonEmptyString>;
|
|
7
|
+
readonly limit: Schema.optionalKey<Schema.Int>;
|
|
8
|
+
}>;
|
|
9
|
+
export type ClaimQuery = typeof ClaimQuery.Type;
|
|
10
|
+
export declare const makeEventLog: (store: EventStore["Service"], options: EventLogSettings) => {
|
|
11
|
+
consumers: () => Effect.Effect<{
|
|
12
|
+
name: string;
|
|
13
|
+
kinds: string[];
|
|
14
|
+
}[], never, never>;
|
|
15
|
+
record: (actor: {
|
|
16
|
+
readonly id: string & import("effect/Brand").Brand<"ActorId">;
|
|
17
|
+
} | null, fact: {
|
|
18
|
+
readonly kind: string;
|
|
19
|
+
readonly subject: {
|
|
20
|
+
readonly kind: string;
|
|
21
|
+
readonly id: string;
|
|
22
|
+
};
|
|
23
|
+
readonly payload: unknown;
|
|
24
|
+
readonly scopeId: string | null;
|
|
25
|
+
}, requestId?: string | null | undefined) => Effect.Effect<{
|
|
26
|
+
kind: string;
|
|
27
|
+
subject: {
|
|
28
|
+
readonly kind: string;
|
|
29
|
+
readonly id: string;
|
|
30
|
+
};
|
|
31
|
+
scopeId: string | null;
|
|
32
|
+
id: string;
|
|
33
|
+
actorId: string | null;
|
|
34
|
+
occurredAt: number;
|
|
35
|
+
requestId: string | null;
|
|
36
|
+
payload: unknown;
|
|
37
|
+
}, EventInputInvalid | EventStoreUnavailable | import("@house-rules/domain").NoOpenUnit, never>;
|
|
38
|
+
read: (query?: {
|
|
39
|
+
readonly after?: string;
|
|
40
|
+
readonly kinds?: readonly string[];
|
|
41
|
+
readonly subject?: {
|
|
42
|
+
readonly kind: string;
|
|
43
|
+
readonly id: string;
|
|
44
|
+
};
|
|
45
|
+
readonly scopeId?: string;
|
|
46
|
+
readonly requestId?: string;
|
|
47
|
+
readonly from?: number;
|
|
48
|
+
readonly to?: number;
|
|
49
|
+
readonly limit?: number;
|
|
50
|
+
} | undefined) => Effect.Effect<{
|
|
51
|
+
kind: string;
|
|
52
|
+
subject: {
|
|
53
|
+
readonly kind: string;
|
|
54
|
+
readonly id: string;
|
|
55
|
+
};
|
|
56
|
+
scopeId: string | null;
|
|
57
|
+
id: string;
|
|
58
|
+
actorId: string | null;
|
|
59
|
+
occurredAt: number;
|
|
60
|
+
requestId: string | null;
|
|
61
|
+
payload: unknown;
|
|
62
|
+
}[], EventInputInvalid | EventStoreUnavailable, never>;
|
|
63
|
+
due: () => Effect.Effect<{
|
|
64
|
+
consumer: string;
|
|
65
|
+
}[], EventStoreUnavailable, never>;
|
|
66
|
+
claimDue: (query?: {
|
|
67
|
+
readonly consumer?: string;
|
|
68
|
+
readonly limit?: number;
|
|
69
|
+
} | undefined) => Effect.Effect<{
|
|
70
|
+
readonly consumer: string;
|
|
71
|
+
readonly eventId: string;
|
|
72
|
+
readonly attempt: number;
|
|
73
|
+
readonly replay: boolean;
|
|
74
|
+
readonly generation: string;
|
|
75
|
+
}[], EventInputInvalid | EventStoreUnavailable | import("@house-rules/domain").NoOpenUnit, never>;
|
|
76
|
+
takeNext: (token: {
|
|
77
|
+
readonly consumer: string;
|
|
78
|
+
readonly eventId: string;
|
|
79
|
+
readonly attempt: number;
|
|
80
|
+
readonly replay: boolean;
|
|
81
|
+
readonly generation: string;
|
|
82
|
+
}) => Effect.Effect<{
|
|
83
|
+
event: {
|
|
84
|
+
kind: string;
|
|
85
|
+
subject: {
|
|
86
|
+
readonly kind: string;
|
|
87
|
+
readonly id: string;
|
|
88
|
+
};
|
|
89
|
+
scopeId: string | null;
|
|
90
|
+
id: string;
|
|
91
|
+
actorId: string | null;
|
|
92
|
+
occurredAt: number;
|
|
93
|
+
requestId: string | null;
|
|
94
|
+
payload: unknown;
|
|
95
|
+
};
|
|
96
|
+
token: {
|
|
97
|
+
readonly consumer: string;
|
|
98
|
+
readonly eventId: string;
|
|
99
|
+
readonly attempt: number;
|
|
100
|
+
readonly replay: boolean;
|
|
101
|
+
readonly generation: string;
|
|
102
|
+
};
|
|
103
|
+
} | null, EventInputInvalid | EventStoreUnavailable | import("@house-rules/domain").NoOpenUnit, never>;
|
|
104
|
+
settleSuccess: (token: {
|
|
105
|
+
readonly consumer: string;
|
|
106
|
+
readonly eventId: string;
|
|
107
|
+
readonly attempt: number;
|
|
108
|
+
readonly replay: boolean;
|
|
109
|
+
readonly generation: string;
|
|
110
|
+
}) => Effect.Effect<boolean, EventInputInvalid | EventStoreUnavailable | import("@house-rules/domain").NoOpenUnit, never>;
|
|
111
|
+
settleFailure: (token: {
|
|
112
|
+
readonly consumer: string;
|
|
113
|
+
readonly eventId: string;
|
|
114
|
+
readonly attempt: number;
|
|
115
|
+
readonly replay: boolean;
|
|
116
|
+
readonly generation: string;
|
|
117
|
+
}, lastError: string) => Effect.Effect<boolean, EventInputInvalid | EventStoreUnavailable | import("@house-rules/domain").NoOpenUnit, never>;
|
|
118
|
+
listDeadLetters: (query?: {
|
|
119
|
+
readonly consumer?: string;
|
|
120
|
+
readonly after?: {
|
|
121
|
+
readonly eventId: string;
|
|
122
|
+
readonly consumer: string;
|
|
123
|
+
};
|
|
124
|
+
readonly limit?: number;
|
|
125
|
+
} | undefined) => Effect.Effect<readonly {
|
|
126
|
+
readonly consumer: string;
|
|
127
|
+
readonly eventId: string;
|
|
128
|
+
readonly lastError: string;
|
|
129
|
+
readonly attempts: number;
|
|
130
|
+
readonly failedAt: number;
|
|
131
|
+
}[], EventInputInvalid | EventStoreUnavailable, never>;
|
|
132
|
+
replay: (consumer: string, eventId: string) => Effect.Effect<"already_queued" | "busy" | "enqueued", DeadLetterNotFound | EventInputInvalid | EventStoreUnavailable | import("@house-rules/domain").NoOpenUnit, never>;
|
|
133
|
+
wakeups: () => Effect.Effect<import("effect/Stream").Stream<void, EventStoreUnavailable, never>, never, never>;
|
|
134
|
+
};
|
|
135
|
+
export type EventLogService = ReturnType<typeof makeEventLog>;
|
|
136
|
+
export {};
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { UnitOfWork } from "@house-rules/domain";
|
|
2
|
+
import { DateTime, Effect, Random, Schema } from "effect";
|
|
3
|
+
import { DeadLetterQuery, DeliveryToken } from "./delivery.js";
|
|
4
|
+
import { DeadLetterNotFound, EventFact, EventId, EventInputInvalid, EventQuery, EventStoreUnavailable, } from "./event.js";
|
|
5
|
+
const invalid = () => new EventInputInvalid({ message: "Invalid event log input." });
|
|
6
|
+
const now = DateTime.now.pipe(Effect.map(DateTime.toEpochMillis));
|
|
7
|
+
const ClaimQuery = Schema.Struct({
|
|
8
|
+
consumer: Schema.optionalKey(Schema.NonEmptyString),
|
|
9
|
+
limit: Schema.optionalKey(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 1000 }))),
|
|
10
|
+
});
|
|
11
|
+
export const makeEventLog = (store, options) => {
|
|
12
|
+
const registration = (name) => {
|
|
13
|
+
const consumer = options.consumers.find((entry) => entry.name === name);
|
|
14
|
+
return consumer === undefined ? Effect.fail(invalid()) : Effect.succeed(consumer);
|
|
15
|
+
};
|
|
16
|
+
const decodePayload = Effect.fn("EventLog.decodePayload")(function* (fact) {
|
|
17
|
+
const schema = Object.hasOwn(options.registry, fact.kind)
|
|
18
|
+
? options.registry[fact.kind]
|
|
19
|
+
: undefined;
|
|
20
|
+
if (schema === undefined)
|
|
21
|
+
return yield* invalid();
|
|
22
|
+
return yield* Schema.decodeUnknownEffect(schema)(fact.payload).pipe(Effect.mapError(invalid));
|
|
23
|
+
});
|
|
24
|
+
const lockedCandidate = Effect.fn("EventLog.lockedCandidate")(function* (consumer) {
|
|
25
|
+
yield* UnitOfWork.required;
|
|
26
|
+
const config = yield* registration(consumer);
|
|
27
|
+
if (!(yield* store.lock(consumer)))
|
|
28
|
+
return undefined;
|
|
29
|
+
return yield* store.candidate(consumer, config.kinds);
|
|
30
|
+
});
|
|
31
|
+
const matching = Effect.fn("EventLog.matching")(function* (token) {
|
|
32
|
+
yield* Schema.decodeEffect(DeliveryToken)(token).pipe(Effect.mapError(invalid));
|
|
33
|
+
const candidate = yield* lockedCandidate(token.consumer);
|
|
34
|
+
return candidate !== undefined &&
|
|
35
|
+
candidate.event.id === token.eventId &&
|
|
36
|
+
candidate.attempts === token.attempt &&
|
|
37
|
+
candidate.replay === token.replay &&
|
|
38
|
+
candidate.generation === token.generation
|
|
39
|
+
? candidate
|
|
40
|
+
: undefined;
|
|
41
|
+
});
|
|
42
|
+
return {
|
|
43
|
+
consumers: () => Effect.succeed(options.consumers.map(({ name, kinds }) => ({ name, kinds }))),
|
|
44
|
+
record: Effect.fn("EventLog.record")(function* (actor, fact, requestId = null) {
|
|
45
|
+
yield* UnitOfWork.required;
|
|
46
|
+
const decoded = yield* Schema.decodeEffect(EventFact)(fact).pipe(Effect.mapError(invalid));
|
|
47
|
+
const payload = yield* decodePayload(decoded);
|
|
48
|
+
const schema = options.registry[decoded.kind];
|
|
49
|
+
if (schema === undefined)
|
|
50
|
+
return yield* invalid();
|
|
51
|
+
const encoded = yield* Schema.encodeUnknownEffect(schema)(payload).pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.Json)), Effect.mapError(invalid));
|
|
52
|
+
const stored = yield* store.append({
|
|
53
|
+
...decoded,
|
|
54
|
+
payload: encoded,
|
|
55
|
+
actorId: actor?.id ?? null,
|
|
56
|
+
occurredAt: yield* now,
|
|
57
|
+
requestId,
|
|
58
|
+
});
|
|
59
|
+
return { ...stored, payload };
|
|
60
|
+
}),
|
|
61
|
+
read: Effect.fn("EventLog.read")(function* (query = {}) {
|
|
62
|
+
const decoded = yield* Schema.decodeEffect(EventQuery)(query).pipe(Effect.mapError(invalid));
|
|
63
|
+
const events = yield* store.read(decoded);
|
|
64
|
+
return yield* Effect.forEach(events, (event) => decodePayload(event).pipe(Effect.map((payload) => ({ ...event, payload })), Effect.mapError((cause) => new EventStoreUnavailable({ cause }))));
|
|
65
|
+
}),
|
|
66
|
+
due: Effect.fn("EventLog.due")(function* () {
|
|
67
|
+
const at = yield* now;
|
|
68
|
+
const due = [];
|
|
69
|
+
for (const consumer of options.consumers) {
|
|
70
|
+
const next = yield* store.candidate(consumer.name, consumer.kinds);
|
|
71
|
+
if (next !== undefined && next.nextDue <= at)
|
|
72
|
+
due.push({ consumer: consumer.name });
|
|
73
|
+
}
|
|
74
|
+
return due;
|
|
75
|
+
}),
|
|
76
|
+
claimDue: Effect.fn("EventLog.claimDue")(function* (query = {}) {
|
|
77
|
+
yield* UnitOfWork.required;
|
|
78
|
+
yield* Schema.decodeEffect(ClaimQuery)(query).pipe(Effect.mapError(invalid));
|
|
79
|
+
if (query.consumer !== undefined)
|
|
80
|
+
yield* registration(query.consumer);
|
|
81
|
+
const tokens = [];
|
|
82
|
+
const at = yield* now;
|
|
83
|
+
for (const consumer of options.consumers) {
|
|
84
|
+
if (query.consumer !== undefined && consumer.name !== query.consumer)
|
|
85
|
+
continue;
|
|
86
|
+
if (tokens.length >= (query.limit ?? 100))
|
|
87
|
+
break;
|
|
88
|
+
const candidate = yield* lockedCandidate(consumer.name);
|
|
89
|
+
if (candidate === undefined || candidate.nextDue > at)
|
|
90
|
+
continue;
|
|
91
|
+
if (candidate.attempts >= consumer.maxAttempts) {
|
|
92
|
+
yield* store.deadLetter({
|
|
93
|
+
consumer: consumer.name,
|
|
94
|
+
eventId: candidate.event.id,
|
|
95
|
+
lastError: candidate.lastError,
|
|
96
|
+
attempts: candidate.attempts,
|
|
97
|
+
failedAt: at,
|
|
98
|
+
});
|
|
99
|
+
yield* store.advance(consumer.name, candidate.event.id, candidate.replay);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const attempt = candidate.attempts + 1;
|
|
103
|
+
const delay = Math.min(consumer.backoff.baseMs * 2 ** (attempt - 1), consumer.backoff.capMs);
|
|
104
|
+
yield* store.retry(consumer.name, { ...candidate, attempts: attempt, nextDue: at + delay });
|
|
105
|
+
tokens.push({
|
|
106
|
+
consumer: consumer.name,
|
|
107
|
+
eventId: candidate.event.id,
|
|
108
|
+
attempt,
|
|
109
|
+
replay: candidate.replay,
|
|
110
|
+
generation: candidate.generation,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
return tokens;
|
|
114
|
+
}),
|
|
115
|
+
takeNext: Effect.fn("EventLog.takeNext")(function* (token) {
|
|
116
|
+
const candidate = yield* matching(token);
|
|
117
|
+
if (candidate === undefined)
|
|
118
|
+
return null;
|
|
119
|
+
return {
|
|
120
|
+
event: { ...candidate.event, payload: yield* decodePayload(candidate.event) },
|
|
121
|
+
token,
|
|
122
|
+
};
|
|
123
|
+
}),
|
|
124
|
+
settleSuccess: Effect.fn("EventLog.settleSuccess")(function* (token) {
|
|
125
|
+
const candidate = yield* matching(token);
|
|
126
|
+
if (candidate === undefined)
|
|
127
|
+
return false;
|
|
128
|
+
yield* store.advance(token.consumer, token.eventId, token.replay);
|
|
129
|
+
if (token.replay)
|
|
130
|
+
yield* store.clearDeadLetter(token.consumer, token.eventId);
|
|
131
|
+
return true;
|
|
132
|
+
}),
|
|
133
|
+
settleFailure: Effect.fn("EventLog.settleFailure")(function* (token, lastError) {
|
|
134
|
+
const candidate = yield* matching(token);
|
|
135
|
+
if (candidate === undefined)
|
|
136
|
+
return false;
|
|
137
|
+
yield* store.retry(token.consumer, { ...candidate, lastError });
|
|
138
|
+
return true;
|
|
139
|
+
}),
|
|
140
|
+
listDeadLetters: Effect.fn("EventLog.listDeadLetters")(function* (query = {}) {
|
|
141
|
+
const decoded = yield* Schema.decodeEffect(DeadLetterQuery)(query).pipe(Effect.mapError(invalid));
|
|
142
|
+
return yield* store.listDeadLetters(decoded);
|
|
143
|
+
}),
|
|
144
|
+
replay: Effect.fn("EventLog.replay")(function* (consumer, eventId) {
|
|
145
|
+
yield* UnitOfWork.required;
|
|
146
|
+
const config = yield* registration(consumer);
|
|
147
|
+
yield* Schema.decodeEffect(EventId)(eventId).pipe(Effect.mapError(invalid));
|
|
148
|
+
if (!(yield* store.lock(consumer)))
|
|
149
|
+
return "busy";
|
|
150
|
+
const dead = yield* store.findDeadLetter(consumer, eventId);
|
|
151
|
+
if (dead === undefined)
|
|
152
|
+
return yield* new DeadLetterNotFound();
|
|
153
|
+
const [event] = yield* store.read({
|
|
154
|
+
...(BigInt(eventId) > 1n ? { after: (BigInt(eventId) - 1n).toString() } : {}),
|
|
155
|
+
limit: 1,
|
|
156
|
+
});
|
|
157
|
+
if (event === undefined || event.id !== eventId || !config.kinds.includes(event.kind))
|
|
158
|
+
return yield* invalid();
|
|
159
|
+
const generation = yield* Effect.all(Array.from({ length: 4 }, () => Random.nextIntBetween(0, 0xffff_ffff))).pipe(Effect.map((words) => words.map((word) => word.toString(16).padStart(8, "0")).join("")));
|
|
160
|
+
const enqueued = yield* store.enqueueReplay(consumer, {
|
|
161
|
+
event,
|
|
162
|
+
attempts: 0,
|
|
163
|
+
nextDue: 0,
|
|
164
|
+
replay: true,
|
|
165
|
+
generation,
|
|
166
|
+
lastError: "Delivery failed. See internal logs.",
|
|
167
|
+
});
|
|
168
|
+
return (enqueued ? "enqueued" : "already_queued");
|
|
169
|
+
}),
|
|
170
|
+
wakeups: () => Effect.succeed(store.wakeups),
|
|
171
|
+
};
|
|
172
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
export declare const EventId: Schema.String;
|
|
3
|
+
export declare const EventFact: Schema.Struct<{
|
|
4
|
+
readonly kind: Schema.NonEmptyString;
|
|
5
|
+
readonly subject: Schema.Struct<{
|
|
6
|
+
readonly kind: Schema.NonEmptyString;
|
|
7
|
+
readonly id: Schema.NonEmptyString;
|
|
8
|
+
}>;
|
|
9
|
+
readonly payload: Schema.Unknown;
|
|
10
|
+
readonly scopeId: Schema.NullOr<Schema.NonEmptyString>;
|
|
11
|
+
}>;
|
|
12
|
+
export type EventFact = typeof EventFact.Type;
|
|
13
|
+
export declare const StoredEvent: Schema.Struct<{
|
|
14
|
+
readonly kind: Schema.NonEmptyString;
|
|
15
|
+
readonly subject: Schema.Struct<{
|
|
16
|
+
readonly kind: Schema.NonEmptyString;
|
|
17
|
+
readonly id: Schema.NonEmptyString;
|
|
18
|
+
}>;
|
|
19
|
+
readonly payload: Schema.Unknown;
|
|
20
|
+
readonly scopeId: Schema.NullOr<Schema.NonEmptyString>;
|
|
21
|
+
readonly id: Schema.String;
|
|
22
|
+
readonly actorId: Schema.NullOr<Schema.NonEmptyString>;
|
|
23
|
+
readonly occurredAt: Schema.Int;
|
|
24
|
+
readonly requestId: Schema.NullOr<Schema.String>;
|
|
25
|
+
}>;
|
|
26
|
+
export type StoredEvent = typeof StoredEvent.Type;
|
|
27
|
+
export declare const EventQuery: Schema.Struct<{
|
|
28
|
+
readonly after: Schema.optionalKey<Schema.String>;
|
|
29
|
+
readonly kinds: Schema.optionalKey<Schema.$Array<Schema.NonEmptyString>>;
|
|
30
|
+
readonly subject: Schema.optionalKey<Schema.Struct<{
|
|
31
|
+
readonly kind: Schema.NonEmptyString;
|
|
32
|
+
readonly id: Schema.NonEmptyString;
|
|
33
|
+
}>>;
|
|
34
|
+
readonly scopeId: Schema.optionalKey<Schema.NonEmptyString>;
|
|
35
|
+
readonly requestId: Schema.optionalKey<Schema.String>;
|
|
36
|
+
readonly from: Schema.optionalKey<Schema.Int>;
|
|
37
|
+
readonly to: Schema.optionalKey<Schema.Int>;
|
|
38
|
+
readonly limit: Schema.optionalKey<Schema.Int>;
|
|
39
|
+
}>;
|
|
40
|
+
export type EventQuery = typeof EventQuery.Type;
|
|
41
|
+
declare const EventInputInvalid_base: Schema.Class<EventInputInvalid, Schema.TaggedStruct<"EventInputInvalid", {
|
|
42
|
+
readonly kind: Schema.tag<"invalid">;
|
|
43
|
+
readonly message: Schema.String;
|
|
44
|
+
}>, import("effect/Cause").YieldableError>;
|
|
45
|
+
export declare class EventInputInvalid extends EventInputInvalid_base {
|
|
46
|
+
}
|
|
47
|
+
declare const EventStoreUnavailable_base: Schema.Class<EventStoreUnavailable, Schema.TaggedStruct<"EventStoreUnavailable", {
|
|
48
|
+
readonly kind: Schema.tag<"unavailable">;
|
|
49
|
+
readonly cause: Schema.Defect;
|
|
50
|
+
}>, import("effect/Cause").YieldableError>;
|
|
51
|
+
export declare class EventStoreUnavailable extends EventStoreUnavailable_base {
|
|
52
|
+
get message(): string;
|
|
53
|
+
}
|
|
54
|
+
declare const DeadLetterNotFound_base: Schema.Class<DeadLetterNotFound, Schema.TaggedStruct<"DeadLetterNotFound", {
|
|
55
|
+
readonly kind: Schema.tag<"not_found">;
|
|
56
|
+
}>, import("effect/Cause").YieldableError>;
|
|
57
|
+
export declare class DeadLetterNotFound extends DeadLetterNotFound_base {
|
|
58
|
+
get message(): string;
|
|
59
|
+
}
|
|
60
|
+
export {};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { errorKind } from "@house-rules/domain/contract";
|
|
2
|
+
import { Schema } from "effect";
|
|
3
|
+
export const EventId = Schema.String.check(Schema.isPattern(/^[1-9][0-9]*$/), Schema.isMaxLength(19), Schema.makeFilter((id) => id.length < 19 || id <= "9223372036854775807"));
|
|
4
|
+
export const EventFact = Schema.Struct({
|
|
5
|
+
kind: Schema.NonEmptyString,
|
|
6
|
+
subject: Schema.Struct({ kind: Schema.NonEmptyString, id: Schema.NonEmptyString }),
|
|
7
|
+
payload: Schema.Unknown,
|
|
8
|
+
scopeId: Schema.NullOr(Schema.NonEmptyString),
|
|
9
|
+
});
|
|
10
|
+
export const StoredEvent = Schema.Struct({
|
|
11
|
+
...EventFact.fields,
|
|
12
|
+
id: EventId,
|
|
13
|
+
actorId: Schema.NullOr(Schema.NonEmptyString),
|
|
14
|
+
occurredAt: Schema.Int,
|
|
15
|
+
requestId: Schema.NullOr(Schema.String),
|
|
16
|
+
});
|
|
17
|
+
export const EventQuery = Schema.Struct({
|
|
18
|
+
after: Schema.optionalKey(EventId),
|
|
19
|
+
kinds: Schema.optionalKey(Schema.Array(Schema.NonEmptyString)),
|
|
20
|
+
subject: Schema.optionalKey(EventFact.fields.subject),
|
|
21
|
+
scopeId: Schema.optionalKey(Schema.NonEmptyString),
|
|
22
|
+
requestId: Schema.optionalKey(Schema.String),
|
|
23
|
+
from: Schema.optionalKey(Schema.Int),
|
|
24
|
+
to: Schema.optionalKey(Schema.Int),
|
|
25
|
+
limit: Schema.optionalKey(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 1000 }))),
|
|
26
|
+
});
|
|
27
|
+
export class EventInputInvalid extends Schema.TaggedError()("EventInputInvalid", {
|
|
28
|
+
kind: errorKind("invalid"),
|
|
29
|
+
message: Schema.String,
|
|
30
|
+
}) {
|
|
31
|
+
}
|
|
32
|
+
export class EventStoreUnavailable extends Schema.TaggedError()("EventStoreUnavailable", {
|
|
33
|
+
kind: errorKind("unavailable"),
|
|
34
|
+
cause: Schema.Defect(),
|
|
35
|
+
}) {
|
|
36
|
+
get message() {
|
|
37
|
+
return "Event log is unavailable. Try again shortly.";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export class DeadLetterNotFound extends Schema.TaggedError()("DeadLetterNotFound", {
|
|
41
|
+
kind: errorKind("not_found"),
|
|
42
|
+
}) {
|
|
43
|
+
get message() {
|
|
44
|
+
return "Dead letter not found.";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
import { DeadLetterNotFound, EventInputInvalid, EventStoreUnavailable } from "./event.js";
|
|
3
|
+
export declare const EventLogOperations: {
|
|
4
|
+
readonly record: import("@house-rules/domain/contract").Operation<"EventLog.record", Schema.Struct<{
|
|
5
|
+
readonly kind: Schema.NonEmptyString;
|
|
6
|
+
readonly subject: Schema.Struct<{
|
|
7
|
+
readonly kind: Schema.NonEmptyString;
|
|
8
|
+
readonly id: Schema.NonEmptyString;
|
|
9
|
+
}>;
|
|
10
|
+
readonly payload: Schema.Unknown;
|
|
11
|
+
readonly scopeId: Schema.NullOr<Schema.NonEmptyString>;
|
|
12
|
+
}>, Schema.Struct<{
|
|
13
|
+
readonly kind: Schema.NonEmptyString;
|
|
14
|
+
readonly subject: Schema.Struct<{
|
|
15
|
+
readonly kind: Schema.NonEmptyString;
|
|
16
|
+
readonly id: Schema.NonEmptyString;
|
|
17
|
+
}>;
|
|
18
|
+
readonly payload: Schema.Unknown;
|
|
19
|
+
readonly scopeId: Schema.NullOr<Schema.NonEmptyString>;
|
|
20
|
+
readonly id: Schema.String;
|
|
21
|
+
readonly actorId: Schema.NullOr<Schema.NonEmptyString>;
|
|
22
|
+
readonly occurredAt: Schema.Int;
|
|
23
|
+
readonly requestId: Schema.NullOr<Schema.String>;
|
|
24
|
+
}>, Schema.Union<readonly [typeof EventInputInvalid, typeof EventStoreUnavailable]>, NoInfer<readonly []>, false, false, true, false>;
|
|
25
|
+
readonly read: import("@house-rules/domain/contract").Operation<"EventLog.read", Schema.Struct<{
|
|
26
|
+
readonly after: Schema.optionalKey<Schema.String>;
|
|
27
|
+
readonly kinds: Schema.optionalKey<Schema.$Array<Schema.NonEmptyString>>;
|
|
28
|
+
readonly subject: Schema.optionalKey<Schema.Struct<{
|
|
29
|
+
readonly kind: Schema.NonEmptyString;
|
|
30
|
+
readonly id: Schema.NonEmptyString;
|
|
31
|
+
}>>;
|
|
32
|
+
readonly scopeId: Schema.optionalKey<Schema.NonEmptyString>;
|
|
33
|
+
readonly requestId: Schema.optionalKey<Schema.String>;
|
|
34
|
+
readonly from: Schema.optionalKey<Schema.Int>;
|
|
35
|
+
readonly to: Schema.optionalKey<Schema.Int>;
|
|
36
|
+
readonly limit: Schema.optionalKey<Schema.Int>;
|
|
37
|
+
}>, Schema.$Array<Schema.Struct<{
|
|
38
|
+
readonly kind: Schema.NonEmptyString;
|
|
39
|
+
readonly subject: Schema.Struct<{
|
|
40
|
+
readonly kind: Schema.NonEmptyString;
|
|
41
|
+
readonly id: Schema.NonEmptyString;
|
|
42
|
+
}>;
|
|
43
|
+
readonly payload: Schema.Unknown;
|
|
44
|
+
readonly scopeId: Schema.NullOr<Schema.NonEmptyString>;
|
|
45
|
+
readonly id: Schema.String;
|
|
46
|
+
readonly actorId: Schema.NullOr<Schema.NonEmptyString>;
|
|
47
|
+
readonly occurredAt: Schema.Int;
|
|
48
|
+
readonly requestId: Schema.NullOr<Schema.String>;
|
|
49
|
+
}>>, Schema.Union<readonly [typeof EventInputInvalid, typeof EventStoreUnavailable]>, NoInfer<readonly []>, true, false, false, false>;
|
|
50
|
+
readonly due: import("@house-rules/domain/contract").Operation<"EventLog.due", Schema.Struct<{}>, Schema.$Array<Schema.Struct<{
|
|
51
|
+
readonly consumer: Schema.NonEmptyString;
|
|
52
|
+
}>>, Schema.Union<readonly [typeof EventInputInvalid, typeof EventStoreUnavailable]>, NoInfer<readonly []>, true, false, false, false>;
|
|
53
|
+
readonly claimDue: import("@house-rules/domain/contract").Operation<"EventLog.claimDue", Schema.Struct<{
|
|
54
|
+
readonly consumer: Schema.optionalKey<Schema.NonEmptyString>;
|
|
55
|
+
readonly limit: Schema.optionalKey<Schema.Int>;
|
|
56
|
+
}>, Schema.$Array<Schema.Struct<{
|
|
57
|
+
readonly consumer: Schema.NonEmptyString;
|
|
58
|
+
readonly eventId: Schema.String;
|
|
59
|
+
readonly attempt: Schema.Int;
|
|
60
|
+
readonly replay: Schema.Boolean;
|
|
61
|
+
readonly generation: Schema.String;
|
|
62
|
+
}>>, Schema.Union<readonly [typeof EventInputInvalid, typeof EventStoreUnavailable]>, NoInfer<readonly []>, false, false, true, false>;
|
|
63
|
+
readonly takeNext: import("@house-rules/domain/contract").Operation<"EventLog.takeNext", Schema.Struct<{
|
|
64
|
+
readonly consumer: Schema.NonEmptyString;
|
|
65
|
+
readonly eventId: Schema.String;
|
|
66
|
+
readonly attempt: Schema.Int;
|
|
67
|
+
readonly replay: Schema.Boolean;
|
|
68
|
+
readonly generation: Schema.String;
|
|
69
|
+
}>, Schema.NullOr<Schema.Struct<{
|
|
70
|
+
readonly token: Schema.Struct<{
|
|
71
|
+
readonly consumer: Schema.NonEmptyString;
|
|
72
|
+
readonly eventId: Schema.String;
|
|
73
|
+
readonly attempt: Schema.Int;
|
|
74
|
+
readonly replay: Schema.Boolean;
|
|
75
|
+
readonly generation: Schema.String;
|
|
76
|
+
}>;
|
|
77
|
+
readonly event: Schema.Struct<{
|
|
78
|
+
readonly kind: Schema.NonEmptyString;
|
|
79
|
+
readonly subject: Schema.Struct<{
|
|
80
|
+
readonly kind: Schema.NonEmptyString;
|
|
81
|
+
readonly id: Schema.NonEmptyString;
|
|
82
|
+
}>;
|
|
83
|
+
readonly payload: Schema.Unknown;
|
|
84
|
+
readonly scopeId: Schema.NullOr<Schema.NonEmptyString>;
|
|
85
|
+
readonly id: Schema.String;
|
|
86
|
+
readonly actorId: Schema.NullOr<Schema.NonEmptyString>;
|
|
87
|
+
readonly occurredAt: Schema.Int;
|
|
88
|
+
readonly requestId: Schema.NullOr<Schema.String>;
|
|
89
|
+
}>;
|
|
90
|
+
}>>, Schema.Union<readonly [typeof EventInputInvalid, typeof EventStoreUnavailable]>, NoInfer<readonly []>, false, false, true, false>;
|
|
91
|
+
readonly settleSuccess: import("@house-rules/domain/contract").Operation<"EventLog.settleSuccess", Schema.Struct<{
|
|
92
|
+
readonly consumer: Schema.NonEmptyString;
|
|
93
|
+
readonly eventId: Schema.String;
|
|
94
|
+
readonly attempt: Schema.Int;
|
|
95
|
+
readonly replay: Schema.Boolean;
|
|
96
|
+
readonly generation: Schema.String;
|
|
97
|
+
}>, Schema.Boolean, Schema.Union<readonly [typeof EventInputInvalid, typeof EventStoreUnavailable]>, NoInfer<readonly []>, false, false, true, false>;
|
|
98
|
+
readonly settleFailure: import("@house-rules/domain/contract").Operation<"EventLog.settleFailure", Schema.Struct<{
|
|
99
|
+
readonly token: Schema.Struct<{
|
|
100
|
+
readonly consumer: Schema.NonEmptyString;
|
|
101
|
+
readonly eventId: Schema.String;
|
|
102
|
+
readonly attempt: Schema.Int;
|
|
103
|
+
readonly replay: Schema.Boolean;
|
|
104
|
+
readonly generation: Schema.String;
|
|
105
|
+
}>;
|
|
106
|
+
readonly lastError: Schema.String;
|
|
107
|
+
}>, Schema.Boolean, Schema.Union<readonly [typeof EventInputInvalid, typeof EventStoreUnavailable]>, NoInfer<readonly []>, false, false, true, false>;
|
|
108
|
+
readonly listDeadLetters: import("@house-rules/domain/contract").Operation<"EventLog.listDeadLetters", Schema.Struct<{
|
|
109
|
+
readonly consumer: Schema.optionalKey<Schema.NonEmptyString>;
|
|
110
|
+
readonly after: Schema.optionalKey<Schema.Struct<{
|
|
111
|
+
readonly eventId: Schema.String;
|
|
112
|
+
readonly consumer: Schema.NonEmptyString;
|
|
113
|
+
}>>;
|
|
114
|
+
readonly limit: Schema.optionalKey<Schema.Int>;
|
|
115
|
+
}>, Schema.$Array<Schema.Struct<{
|
|
116
|
+
readonly consumer: Schema.NonEmptyString;
|
|
117
|
+
readonly eventId: Schema.String;
|
|
118
|
+
readonly lastError: Schema.String;
|
|
119
|
+
readonly attempts: Schema.Int;
|
|
120
|
+
readonly failedAt: Schema.Int;
|
|
121
|
+
}>>, Schema.Union<readonly [typeof EventInputInvalid, typeof EventStoreUnavailable]>, NoInfer<readonly []>, true, false, false, false>;
|
|
122
|
+
readonly replay: import("@house-rules/domain/contract").Operation<"EventLog.replay", Schema.Struct<{
|
|
123
|
+
readonly consumer: Schema.NonEmptyString;
|
|
124
|
+
readonly eventId: Schema.String;
|
|
125
|
+
}>, Schema.Literals<readonly ["enqueued", "busy", "already_queued"]>, Schema.Union<readonly [typeof EventInputInvalid, typeof EventStoreUnavailable, typeof DeadLetterNotFound]>, NoInfer<readonly []>, false, false, true, true>;
|
|
126
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { defineOperation } from "@house-rules/domain/contract";
|
|
2
|
+
import { Schema } from "effect";
|
|
3
|
+
import { DeadLetter, DeadLetterQuery, Delivery, DeliveryToken, ReplayResult } from "./delivery.js";
|
|
4
|
+
import { DeadLetterNotFound, EventFact, EventInputInvalid, EventQuery, EventStoreUnavailable, StoredEvent, } from "./event.js";
|
|
5
|
+
import { EventLogPermissions } from "./permissions.js";
|
|
6
|
+
const failure = Schema.Union([EventInputInvalid, EventStoreUnavailable]);
|
|
7
|
+
const consumer = Schema.Struct({ consumer: Schema.NonEmptyString });
|
|
8
|
+
const record = defineOperation("EventLog.record", {
|
|
9
|
+
description: "Validate a domain fact and append its envelope to the transactional outbox.",
|
|
10
|
+
input: EventFact,
|
|
11
|
+
output: StoredEvent,
|
|
12
|
+
failure,
|
|
13
|
+
permission: EventLogPermissions.record,
|
|
14
|
+
transactional: true,
|
|
15
|
+
});
|
|
16
|
+
const read = defineOperation("EventLog.read", {
|
|
17
|
+
description: "Read event history in global cursor order.",
|
|
18
|
+
input: EventQuery,
|
|
19
|
+
output: Schema.Array(StoredEvent),
|
|
20
|
+
failure,
|
|
21
|
+
permission: EventLogPermissions.read,
|
|
22
|
+
readOnly: true,
|
|
23
|
+
});
|
|
24
|
+
const due = defineOperation("EventLog.due", {
|
|
25
|
+
description: "Select consumers whose next delivery is due. Results are hints, not claims.",
|
|
26
|
+
input: Schema.Struct({}),
|
|
27
|
+
output: Schema.Array(consumer),
|
|
28
|
+
failure,
|
|
29
|
+
permission: EventLogPermissions.deliver,
|
|
30
|
+
readOnly: true,
|
|
31
|
+
});
|
|
32
|
+
const claimDue = defineOperation("EventLog.claimDue", {
|
|
33
|
+
description: "Commit delivery attempts and exponential retry times in the selector unit before running handler items.",
|
|
34
|
+
input: Schema.Struct({
|
|
35
|
+
consumer: Schema.optionalKey(Schema.NonEmptyString),
|
|
36
|
+
limit: Schema.optionalKey(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 1000 }))),
|
|
37
|
+
}),
|
|
38
|
+
output: Schema.Array(DeliveryToken),
|
|
39
|
+
failure,
|
|
40
|
+
permission: EventLogPermissions.deliver,
|
|
41
|
+
transactional: true,
|
|
42
|
+
});
|
|
43
|
+
const takeNext = defineOperation("EventLog.takeNext", {
|
|
44
|
+
description: "Lock a consumer until this handler unit ends. Ignore stale claimed attempts.",
|
|
45
|
+
input: DeliveryToken,
|
|
46
|
+
output: Schema.NullOr(Delivery),
|
|
47
|
+
failure,
|
|
48
|
+
permission: EventLogPermissions.deliver,
|
|
49
|
+
transactional: true,
|
|
50
|
+
});
|
|
51
|
+
const settleSuccess = defineOperation("EventLog.settleSuccess", {
|
|
52
|
+
description: "Advance a delivery cursor in the successful handler's unit.",
|
|
53
|
+
input: DeliveryToken,
|
|
54
|
+
output: Schema.Boolean,
|
|
55
|
+
failure,
|
|
56
|
+
permission: EventLogPermissions.deliver,
|
|
57
|
+
transactional: true,
|
|
58
|
+
});
|
|
59
|
+
const settleFailure = defineOperation("EventLog.settleFailure", {
|
|
60
|
+
description: "Optionally attach a safe failure summary in a fresh unit after handler rollback. Attempts and backoff already committed at claim.",
|
|
61
|
+
input: Schema.Struct({ token: DeliveryToken, lastError: Schema.String }),
|
|
62
|
+
output: Schema.Boolean,
|
|
63
|
+
failure,
|
|
64
|
+
permission: EventLogPermissions.deliver,
|
|
65
|
+
transactional: true,
|
|
66
|
+
});
|
|
67
|
+
const listDeadLetters = defineOperation("EventLog.listDeadLetters", {
|
|
68
|
+
description: "List dead letters per consumer, ordered by event cursor and consumer name.",
|
|
69
|
+
input: DeadLetterQuery,
|
|
70
|
+
output: Schema.Array(DeadLetter),
|
|
71
|
+
failure,
|
|
72
|
+
permission: EventLogPermissions.deadLetters,
|
|
73
|
+
readOnly: true,
|
|
74
|
+
});
|
|
75
|
+
const replay = defineOperation("EventLog.replay", {
|
|
76
|
+
description: "Manually enqueue one dead letter without rewinding the consumer cursor.",
|
|
77
|
+
input: Schema.Struct({ consumer: Schema.NonEmptyString, eventId: DeliveryToken.fields.eventId }),
|
|
78
|
+
output: ReplayResult,
|
|
79
|
+
failure: Schema.Union([EventInputInvalid, EventStoreUnavailable, DeadLetterNotFound]),
|
|
80
|
+
permission: EventLogPermissions.replay,
|
|
81
|
+
transactional: true,
|
|
82
|
+
needsApproval: true,
|
|
83
|
+
});
|
|
84
|
+
export const EventLogOperations = {
|
|
85
|
+
record,
|
|
86
|
+
read,
|
|
87
|
+
due,
|
|
88
|
+
claimDue,
|
|
89
|
+
takeNext,
|
|
90
|
+
settleSuccess,
|
|
91
|
+
settleFailure,
|
|
92
|
+
listDeadLetters,
|
|
93
|
+
replay,
|
|
94
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { PlainSchema } from "@house-rules/domain/contract";
|
|
2
|
+
import { ConsumerOptions } from "./delivery.js";
|
|
3
|
+
import { Effect } from "effect";
|
|
4
|
+
import { EventInputInvalid } from "./event.js";
|
|
5
|
+
export type EventLogOptions = Readonly<{
|
|
6
|
+
registry: Readonly<Record<string, PlainSchema>>;
|
|
7
|
+
consumers: ReadonlyArray<ConsumerOptions>;
|
|
8
|
+
}>;
|
|
9
|
+
export declare const deliveryDefaults: {
|
|
10
|
+
readonly maxAttempts: 5;
|
|
11
|
+
readonly backoff: {
|
|
12
|
+
readonly baseMs: 1000;
|
|
13
|
+
readonly capMs: 300000;
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
export declare const validateOptions: (options: Readonly<{
|
|
17
|
+
registry: Readonly<Record<string, PlainSchema>>;
|
|
18
|
+
consumers: ReadonlyArray<ConsumerOptions>;
|
|
19
|
+
}>) => Effect.Effect<{
|
|
20
|
+
registry: {
|
|
21
|
+
[x: string]: PlainSchema;
|
|
22
|
+
};
|
|
23
|
+
consumers: {
|
|
24
|
+
name: string;
|
|
25
|
+
kinds: string[];
|
|
26
|
+
maxAttempts: number;
|
|
27
|
+
backoff: {
|
|
28
|
+
readonly baseMs: number;
|
|
29
|
+
readonly capMs: number;
|
|
30
|
+
};
|
|
31
|
+
}[];
|
|
32
|
+
}, EventInputInvalid, never>;
|
|
33
|
+
export type EventLogSettings = Effect.Success<ReturnType<typeof validateOptions>>;
|