@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
package/AGENTS.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# @house-rules/event-log: notes for agents
|
|
2
|
+
|
|
3
|
+
EventLog is a generic domain-event transport. It owns no kinds. It is not call-audit, which records feature starts and refusals. Read `README.md` for mounting.
|
|
4
|
+
|
|
5
|
+
## Mount and record
|
|
6
|
+
|
|
7
|
+
Mount `{ ...EventLogMount, layer: EventLog.layerPostgres(options) }` in composition/modules.ts. Provide the shared PgClient and `sqlUnitOfWork` over that same client. Tests may use `layerMemory(options)` with `memoryUnitOfWork`. Raw `EventLog.layer(options)` needs EventStore. Register `{ "workspace": "packages/event-log" }` in migrations.json, or the installed package entry. The package ships its migrations.
|
|
8
|
+
|
|
9
|
+
Options are `{ registry, consumers }`. The registry maps kind strings to plain payload Schemas with no service requirements. There are no built-in event kinds or consumers. Consumer names must be unique and their kinds must exist in the registry. Registration is static for the mount lifetime. Keep the same registration order across processes.
|
|
10
|
+
|
|
11
|
+
Producer modules own fact Schemas in their contract door. Operations return facts alongside their result, such as `{ task, events: [...] }`. A producer never emits, depends on EventLog or reads CurrentActor. A feature chooses which returned facts to record through its declared EventLog.record edge. App-wide facts belong to an app-owned package contract, not a feature-local value export.
|
|
12
|
+
|
|
13
|
+
A fact is `{ kind, subject: { kind, id }, payload, scopeId }`. Pass null when scope is absent. Record decodes payload against the mounted registry, validates its JSON encoding and stores that encoding. Reads and delivery decode it again. Unknown kinds and malformed payloads fail EventInputInvalid. Preserve old kind Schemas while their history exists; use new versioned kind names when changing a payload incompatibly.
|
|
14
|
+
|
|
15
|
+
The binder passes the person's Actor explicitly, or null for a system caller, and the available CallRequestId. The module never reads CurrentActor. Clock supplies occurredAt as epoch milliseconds. No secrets belong in facts, envelopes or dead-letter errors. Do not store tokens, credentials, private headers or raw driver errors. Pass a fixed, safe summary to settleFailure and keep causes in internal logs.
|
|
16
|
+
|
|
17
|
+
Record requires UnitOfWork.required and fails NoOpenUnit outside a live unit. The event table IS the outbox. Producer writes and recording commit together in the feature's unit. The module never opens a unit. Memory registers undo before mutation; Postgres joins the caller's SQL transaction.
|
|
18
|
+
|
|
19
|
+
## Delivery units and event ways
|
|
20
|
+
|
|
21
|
+
Consumer config is `{ name, kinds, maxAttempts?, backoff?: { baseMs, capMs } }`, without a handler. Mount the source plug beside EventLog: `EventLog.source.pipe(Layer.provideMerge(EventLog.layerPostgres(options)))`, or layerMemory for tests. It provides the application-owned EventSource service from EventLog's claimDue/takeNext/settle methods, consumer coverage and wake stream. Application never imports event-log. Optional map metadata on the module plan is `{ name: "EventLog.source", package: "@house-rules/event-log", fills: "EventSource" }`, with no ways list.
|
|
22
|
+
|
|
23
|
+
E9 is one system feature per consumer. Declare `defineWay({ protocol: "event", name, on: kinds, feature, mean: Mean.no })` in the app's ways-in/events/. The way name IS the consumer name. The way's on kinds must match the consumer's kinds exactly. Missing or extra kinds and duplicate consumer ways fail acquisition with GraphInvalid. The feature must declare a system start with reason and startedBy. Composition's explicit system permissions must cover its closure.
|
|
24
|
+
|
|
25
|
+
Use EventInput from application/contract: `{ event: fullStoredEnvelope, causedBy: actorIdOrNull }`. causedBy is an identifier, never an Actor or authority. The event author does not supply the handler's permissions; even revoking that person's roles cannot change the system's explicit authority. System-only reads require their own explicit operations. Event trace steps retain causedBy as data.
|
|
26
|
+
|
|
27
|
+
```text
|
|
28
|
+
application's scoped event driver
|
|
29
|
+
claimDue(consumer) in its own unit
|
|
30
|
+
COMMIT attempt count + retry time
|
|
31
|
+
per token, one unit:
|
|
32
|
+
takeNext(token): lock consumer cursor
|
|
33
|
+
runFeature(system feature, { event, causedBy })
|
|
34
|
+
settleSuccess(token)
|
|
35
|
+
COMMIT success or ROLLBACK failure
|
|
36
|
+
after rollback: settleFailure(token, fixed safe summary) in a separate unit
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The module still never opens a unit. Composition's kit driver owns these units. Transactional feature execution joins the delivery unit. A failed handler cannot erase the committed claim, and failure bookkeeping cannot accidentally commit handler writes. At or after the retry deadline, the next source wake or sweep lets claimDue start another attempt. After maxAttempts, the next due claim dead-letters and advances instead of returning another token. A crashed claim-to-delivery handoff consumes an attempt too.
|
|
40
|
+
|
|
41
|
+
claimDue returns at most one token per consumer per call; the driver claims again after each batch until empty. Empty may mean busy, future retry or a dead-letter transition, so periodic sweeps remain necessary. takeNext returns null for stale/busy tokens and the driver skips the reaction. Settlement stays guarded by attempt and replay generation. The source keeps retry/backoff/dead-letter policy, not the kit.
|
|
42
|
+
|
|
43
|
+
Drivers start after hooks and merge the source wake stream with a periodic sweep. composition.eventSweepMs is a positive integer, default 30000ms. Startup claims only source-reported due work. Listener failures log and restart after a sweep interval. Fresh execution uses May.system, a new request id, event:<way> channel and root span, without inherited person, caller services or network context. Disposal stops drivers before resources close.
|
|
44
|
+
|
|
45
|
+
## Retry choices and replay
|
|
46
|
+
|
|
47
|
+
O1: defaults are five attempts and `min(1000 * 2 ** (attempt - 1), 300000)` milliseconds between claims. The sequence begins 1s, 2s, 4s, 8s, 16s. The cap is five minutes; there is no jitter. Config permits 1 to 100 attempts and base/cap values from 1ms to five minutes, with base <= cap. The last attempt also gets its backoff window before dead-lettering.
|
|
48
|
+
|
|
49
|
+
A dead letter is per `(consumer, eventId)` and contains lastError, attempts and failedAt. Failure for one consumer does not block another. Dead-lettering skips that reaction until manual replay, so a consumer may be out of date. The next selector can continue with the following event. There is no automatic replay.
|
|
50
|
+
|
|
51
|
+
O2: replay has its own `event-log:replay` permission and needsApproval. It is in-process only by default. The package exposes no HTTP or MCP way; adding one is an app-owner decision. Use an admin feature and explicit human approval. Replay returns ReplayResult: `enqueued` when queued, `busy` when the consumer lock is held elsewhere, or `already_queued` when pending. A busy result may be retried later; it does not mean the event is queued. Replay queues the dead letter without rewinding the normal cursor. Replay items run before normal items, in event-id order, with a fresh retry budget and generation. Success removes the dead letter; exhausted replay updates it. Old attempt and replay-generation tokens cannot advance a newer attempt.
|
|
52
|
+
|
|
53
|
+
O3: keep consumer delivery policy in module mount config and reaction Feature values in application event ways. EventLog.source fills the kit's caller-owned port. This package creates no runtime or way. Removing event ways and the plug leaves the kit and unrelated app features building and passing.
|
|
54
|
+
|
|
55
|
+
## Ordering, signals and limits
|
|
56
|
+
|
|
57
|
+
Delivery is at least once. Handlers must be idempotent and deduplicate by event id or a natural key. A rollback after an outside effect ran can redeliver the event. Outside effects belong in post-commit consumers, never in the original producer feature's unit; no transaction can unsend a message.
|
|
58
|
+
|
|
59
|
+
One global increasing cursor orders event history. IDs are decimal strings to preserve PostgreSQL bigint precision over JSON. A transactional sequence row serializes appends until producer commit, so a higher ID cannot commit ahead of an earlier, still-hidden event. Per-aggregate ordering is not implemented. Consumer cursors do not move backwards; adding kinds later does not replay events below an existing cursor.
|
|
60
|
+
|
|
61
|
+
There is no distributed lease. Postgres row locks and memory live-unit ownership serialize one consumer during the handler unit. nextDue is a retry timestamp, not a lease or a promise that a process owns an event between selector and item. A delayed item can become stale if another selector starts its next attempt. Multiple app processes still need idempotent reactions, particularly for effects outside the database. Memory must share one memoryUnitOfWork with its producers and consumers.
|
|
62
|
+
|
|
63
|
+
Postgres wakeups use LISTEN/NOTIFY on the fixed house_rules_event_log channel. NOTIFY joins the producer transaction and arrives after commit. The LISTEN stream reserves a pool connection until its scope closes, so size the pool for it plus active units. Memory uses a sliding in-process signal; its signal may arrive before commit or for a rolled-back append. Signals carry no event payload and are hints, never durable delivery. The application's event driver restarts failed listeners, claims due work at startup and keeps a periodic sweep as the safety net for missed signals. Dispose listeners before closing the SQL pool.
|
|
64
|
+
|
|
65
|
+
read(query) returns oldest-first history, default 100, maximum 1000. after is an exclusive event ID; kinds, subject, scopeId, requestId and inclusive time bounds filter history. listDeadLetters uses an exclusive `{ eventId, consumer }` cursor so pagination cannot skip another consumer's dead letter for the same event. It accepts an optional consumer filter and the same default 100 / maximum 1000 limit. The module publishes event_log_history for optional read models. It has no retention policy, projections or external webhook transport. Read permission grants access to all matching events; apps must not expose this global history to a person without their own scope policy.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jakub Szwajka
|
|
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
CHANGED
|
@@ -1,3 +1,61 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @house-rules/event-log
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Transactional domain-event history with in-process delivery, bounded retries and manual dead-letter replay. The app supplies every event kind and payload Schema. EventLog owns none.
|
|
4
|
+
|
|
5
|
+
## Mount
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
const options = {
|
|
9
|
+
registry: { ...TaskEvents, ...AppEvents },
|
|
10
|
+
consumers: [
|
|
11
|
+
{ name: "notifications", kinds: ["task_moved"], maxAttempts: 5 },
|
|
12
|
+
],
|
|
13
|
+
};
|
|
14
|
+
const mount = {
|
|
15
|
+
...EventLogMount,
|
|
16
|
+
layer: EventLog.source.pipe(Layer.provideMerge(EventLog.layerPostgres(options))),
|
|
17
|
+
};
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Composition provides a shared PgClient and sqlUnitOfWork over that client. Register `{ "workspace": "packages/event-log" }` in migrations.json, or `{ "package": "@house-rules/event-log" }` in an installed app. Memory uses `EventLog.layerMemory(options)` and memoryUnitOfWork.
|
|
21
|
+
|
|
22
|
+
Producer modules declare event facts in their contract and return them alongside operation results. A feature records selected facts through an explicit EventLog.record edge. Recording requires an open unit. Producer changes and events commit or roll back together because the event table is the outbox.
|
|
23
|
+
|
|
24
|
+
## Operations
|
|
25
|
+
|
|
26
|
+
| Operation | Permission | Purpose |
|
|
27
|
+
| --- | --- | --- |
|
|
28
|
+
| record | event-log:record | Validate a fact and stamp its actor, Clock time and request ID |
|
|
29
|
+
| read | event-log:read | Read filtered history in global cursor order |
|
|
30
|
+
| due | event-log:deliver | Read consumer names whose next event is due |
|
|
31
|
+
| claimDue | event-log:deliver | Commit attempts and retry times; return tokens for handler items |
|
|
32
|
+
| takeNext | event-log:deliver | Lock the consumer cursor and load the token's event |
|
|
33
|
+
| settleSuccess | event-log:deliver | Advance the cursor in the successful handler's unit |
|
|
34
|
+
| settleFailure | event-log:deliver | Optionally save a safe failure summary in a separate unit |
|
|
35
|
+
| listDeadLetters | event-log:dead-letters | Read dead letters |
|
|
36
|
+
| replay | event-log:replay | Manually queue one dead letter; requires approval |
|
|
37
|
+
|
|
38
|
+
replay returns the typed ReplayResult: `enqueued`, `busy` or `already_queued`. Retry `busy` later; it does not confirm a queued replay.
|
|
39
|
+
|
|
40
|
+
The binder passes Actor explicitly from the caller, or null for a system caller. EventLog never reads CurrentActor. Facts contain kind, subject, payload and nullable scopeId. No secrets belong in events or error summaries.
|
|
41
|
+
|
|
42
|
+
## Event ways
|
|
43
|
+
|
|
44
|
+
The application kit owns the event way and caller-owned EventSource port. `EventLog.source` fills it from this module's consumer registrations, claim/take/settle operations and wake stream. Keep it beside the mount layer with Layer.provideMerge, as above, so both EventLog and EventSource are available. The memory variant uses the same pattern with layerMemory. Optional map metadata is `{ name: "EventLog.source", package: "@house-rules/event-log", fills: "EventSource" }` on the module plan's plugs, with no ways list.
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
defineWay({
|
|
48
|
+
protocol: "event", name: "notifications", on: ["task_moved"],
|
|
49
|
+
feature: Features.notifyTaskMoved, mean: Mean.no,
|
|
50
|
+
});
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Each consumer name equals one event way name, and its kinds must match the way's on kinds exactly. Acquisition rejects missing or extra kinds and duplicate consumer ways with GraphInvalid. E9 is one system feature per consumer, now expressed directly as an event way, not two app driver features. The feature uses `EventInput` from application/contract and declares a system start. Input is `{ event: fullStoredEnvelope, causedBy: actorIdOrNull }`. causedBy records who caused the event; it grants nothing. System permissions come from composition, never from the author's roles.
|
|
54
|
+
|
|
55
|
+
Composition owns delivery: claimDue commits the attempt and retry deadline in its own unit, then takeNext, runFeature and settleSuccess share one new unit per token. Failure rolls that unit back before settleFailure saves a fixed safe summary in another unit. The committed attempt survives. After a batch the driver claims again until none are due. Once attempts are exhausted, a later due claim dead-letters the event and advances the cursor.
|
|
56
|
+
|
|
57
|
+
The driver listens to adapter wakes, drains source-reported due work at startup, and sweeps periodically. Configure composition.eventSweepMs, default 30000. Postgres uses transactional LISTEN/NOTIFY; memory uses an in-process hint. Failed listeners restart; disposal stops drivers before pools close. Each execution has fresh system permissions, request id, event:<way> channel and root span, with no inherited person or network context. Application never imports event-log. Removing event ways, EventLog.source and its mount leaves apps that do not need events independent of this package.
|
|
58
|
+
|
|
59
|
+
Default retry policy is five attempts, exponential delays starting at one second, capped at five minutes. A deadline makes a retry eligible; the driver runs it on the next source wake or sweep at or after that time. With the default sweep, a one-second backoff can wait about 30 seconds when no wake arrives. Delivery is at least once. Handlers must be idempotent. Postgres next-event lookups use the `(kind, id)` index even for sparse or missing kinds. Cursor locks cover handler units; there is no distributed lease. Outside effects run in consumers after the producer commits, and cannot be undone by database rollback.
|
|
60
|
+
|
|
61
|
+
See [AGENTS.md](AGENTS.md) for the exact unit protocol, O1-O3 choices, permissions and concurrency limits.
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { CurrentUnit, UnitOfWork } from "@house-rules/domain";
|
|
2
|
+
import { Effect, Layer, PubSub, Stream } from "effect";
|
|
3
|
+
import { EventStore } from "../../storage.js";
|
|
4
|
+
const order = (a, b) => BigInt(a) < BigInt(b) ? -1 : BigInt(a) === BigInt(b) ? 0 : 1;
|
|
5
|
+
export const memoryEventStore = () => Layer.effect(EventStore, Effect.gen(function* () {
|
|
6
|
+
const signal = yield* PubSub.sliding(1);
|
|
7
|
+
yield* Effect.addFinalizer(() => PubSub.shutdown(signal));
|
|
8
|
+
const rows = new Map();
|
|
9
|
+
const positions = new Map();
|
|
10
|
+
const pending = new Map();
|
|
11
|
+
const dead = new Map();
|
|
12
|
+
const owners = new Map();
|
|
13
|
+
let sequence = 0n;
|
|
14
|
+
const change = Effect.fn("EventStore.memory.change")(function* (run) {
|
|
15
|
+
yield* UnitOfWork.required;
|
|
16
|
+
const before = structuredClone({ rows, positions, pending, dead, sequence });
|
|
17
|
+
yield* UnitOfWork.onRollback(Effect.sync(() => {
|
|
18
|
+
rows.clear();
|
|
19
|
+
for (const [key, value] of before.rows)
|
|
20
|
+
rows.set(key, value);
|
|
21
|
+
positions.clear();
|
|
22
|
+
for (const [key, value] of before.positions)
|
|
23
|
+
positions.set(key, value);
|
|
24
|
+
pending.clear();
|
|
25
|
+
for (const [key, value] of before.pending)
|
|
26
|
+
pending.set(key, value);
|
|
27
|
+
dead.clear();
|
|
28
|
+
for (const [key, value] of before.dead)
|
|
29
|
+
dead.set(key, value);
|
|
30
|
+
sequence = before.sequence;
|
|
31
|
+
}));
|
|
32
|
+
run();
|
|
33
|
+
});
|
|
34
|
+
return EventStore.of({
|
|
35
|
+
append: Effect.fn("EventStore.memory.append")(function* (event) {
|
|
36
|
+
let stored = { ...event, id: "1" };
|
|
37
|
+
yield* change(() => {
|
|
38
|
+
sequence += 1n;
|
|
39
|
+
stored = structuredClone({ ...event, id: sequence.toString() });
|
|
40
|
+
rows.set(stored.id, stored);
|
|
41
|
+
});
|
|
42
|
+
yield* PubSub.publish(signal, undefined);
|
|
43
|
+
return structuredClone(stored);
|
|
44
|
+
}),
|
|
45
|
+
read: (query) => Effect.sync(() => [...rows.values()]
|
|
46
|
+
.filter((event) => (query.after === undefined || order(event.id, query.after) > 0) &&
|
|
47
|
+
(query.kinds === undefined || query.kinds.includes(event.kind)) &&
|
|
48
|
+
(query.subject === undefined ||
|
|
49
|
+
(event.subject.kind === query.subject.kind &&
|
|
50
|
+
event.subject.id === query.subject.id)) &&
|
|
51
|
+
(query.scopeId === undefined || event.scopeId === query.scopeId) &&
|
|
52
|
+
(query.requestId === undefined || event.requestId === query.requestId) &&
|
|
53
|
+
(query.from === undefined || event.occurredAt >= query.from) &&
|
|
54
|
+
(query.to === undefined || event.occurredAt <= query.to))
|
|
55
|
+
.sort((a, b) => order(a.id, b.id))
|
|
56
|
+
.slice(0, query.limit ?? 100)
|
|
57
|
+
.map((row) => structuredClone(row))),
|
|
58
|
+
lock: Effect.fn("EventStore.memory.lock")(function* (consumer) {
|
|
59
|
+
yield* UnitOfWork.required;
|
|
60
|
+
const unit = yield* CurrentUnit;
|
|
61
|
+
if (unit === undefined)
|
|
62
|
+
return false;
|
|
63
|
+
const owner = owners.get(consumer);
|
|
64
|
+
if (owner !== undefined && owner !== unit) {
|
|
65
|
+
const active = yield* Effect.isSuccess(UnitOfWork.required.pipe(Effect.provideService(CurrentUnit, owner)));
|
|
66
|
+
if (active)
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
owners.set(consumer, unit);
|
|
70
|
+
yield* UnitOfWork.onRollback(Effect.sync(() => {
|
|
71
|
+
if (owners.get(consumer) === unit)
|
|
72
|
+
owners.delete(consumer);
|
|
73
|
+
}));
|
|
74
|
+
return true;
|
|
75
|
+
}),
|
|
76
|
+
candidate: (consumer, kinds) => Effect.sync(() => {
|
|
77
|
+
const retries = pending.get(consumer);
|
|
78
|
+
const replay = [...(retries?.values() ?? [])]
|
|
79
|
+
.filter((row) => row.replay)
|
|
80
|
+
.sort((a, b) => order(a.event.id, b.event.id))[0];
|
|
81
|
+
if (replay !== undefined)
|
|
82
|
+
return structuredClone(replay);
|
|
83
|
+
const cursor = positions.get(consumer) ?? "0";
|
|
84
|
+
const event = [...rows.values()]
|
|
85
|
+
.filter((row) => order(row.id, cursor) > 0 && kinds.includes(row.kind))
|
|
86
|
+
.sort((a, b) => order(a.id, b.id))[0];
|
|
87
|
+
return event === undefined
|
|
88
|
+
? undefined
|
|
89
|
+
: structuredClone(retries?.get(event.id) ?? {
|
|
90
|
+
event,
|
|
91
|
+
attempts: 0,
|
|
92
|
+
nextDue: 0,
|
|
93
|
+
replay: false,
|
|
94
|
+
generation: "0",
|
|
95
|
+
lastError: "Delivery failed. See internal logs.",
|
|
96
|
+
});
|
|
97
|
+
}),
|
|
98
|
+
retry: (consumer, candidate) => change(() => {
|
|
99
|
+
const entries = pending.get(consumer) ?? new Map();
|
|
100
|
+
entries.set(candidate.event.id, structuredClone(candidate));
|
|
101
|
+
pending.set(consumer, entries);
|
|
102
|
+
}),
|
|
103
|
+
enqueueReplay: Effect.fn("EventStore.memory.enqueueReplay")(function* (consumer, candidate) {
|
|
104
|
+
yield* UnitOfWork.required;
|
|
105
|
+
if (pending.get(consumer)?.has(candidate.event.id))
|
|
106
|
+
return false;
|
|
107
|
+
yield* change(() => {
|
|
108
|
+
const entries = pending.get(consumer) ?? new Map();
|
|
109
|
+
entries.set(candidate.event.id, structuredClone(candidate));
|
|
110
|
+
pending.set(consumer, entries);
|
|
111
|
+
});
|
|
112
|
+
return true;
|
|
113
|
+
}),
|
|
114
|
+
advance: (consumer, eventId, replay) => change(() => {
|
|
115
|
+
pending.get(consumer)?.delete(eventId);
|
|
116
|
+
if (!replay)
|
|
117
|
+
positions.set(consumer, eventId);
|
|
118
|
+
}),
|
|
119
|
+
deadLetter: (row) => change(() => {
|
|
120
|
+
const entries = dead.get(row.consumer) ?? new Map();
|
|
121
|
+
entries.set(row.eventId, structuredClone(row));
|
|
122
|
+
dead.set(row.consumer, entries);
|
|
123
|
+
}),
|
|
124
|
+
listDeadLetters: (query) => Effect.sync(() => [...dead.values()]
|
|
125
|
+
.flatMap((entries) => [...entries.values()])
|
|
126
|
+
.filter((row) => (query.consumer === undefined || row.consumer === query.consumer) &&
|
|
127
|
+
(query.after === undefined ||
|
|
128
|
+
order(row.eventId, query.after.eventId) > 0 ||
|
|
129
|
+
(row.eventId === query.after.eventId && row.consumer > query.after.consumer)))
|
|
130
|
+
.sort((a, b) => order(a.eventId, b.eventId) ||
|
|
131
|
+
(a.consumer < b.consumer ? -1 : a.consumer === b.consumer ? 0 : 1))
|
|
132
|
+
.slice(0, query.limit ?? 100)
|
|
133
|
+
.map((row) => structuredClone(row))),
|
|
134
|
+
findDeadLetter: (consumer, eventId) => Effect.sync(() => {
|
|
135
|
+
const row = dead.get(consumer)?.get(eventId);
|
|
136
|
+
return row === undefined ? undefined : structuredClone(row);
|
|
137
|
+
}),
|
|
138
|
+
clearDeadLetter: (consumer, eventId) => change(() => {
|
|
139
|
+
dead.get(consumer)?.delete(eventId);
|
|
140
|
+
}),
|
|
141
|
+
wakeups: Stream.fromPubSub(signal),
|
|
142
|
+
});
|
|
143
|
+
}));
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { PgClient } from "@effect/sql-pg/PgClient";
|
|
2
|
+
import { UnitOfWork } from "@house-rules/domain";
|
|
3
|
+
import { Effect, Layer, Schema, Stream } from "effect";
|
|
4
|
+
import { Candidate, DeadLetter } from "../../event/delivery.js";
|
|
5
|
+
import { EventStoreUnavailable, StoredEvent } from "../../event/event.js";
|
|
6
|
+
import { EventStore } from "../../storage.js";
|
|
7
|
+
const unavailable = (cause) => new EventStoreUnavailable({ cause });
|
|
8
|
+
export const postgresEventStore = Layer.effect(EventStore, Effect.gen(function* () {
|
|
9
|
+
const sql = yield* PgClient;
|
|
10
|
+
const columns = sql.literal(`id::text as id, kind, json_build_object('kind', subject_kind, 'id', subject_id) as subject, payload, scope_id as "scopeId", actor_id as "actorId", occurred_at::float8 as "occurredAt", request_id as "requestId"`);
|
|
11
|
+
const candidateColumns = sql.literal(`row_to_json(e) as event, coalesce(p.attempts, 0) as attempts, coalesce(p.next_due, 0)::float8 as "nextDue", coalesce(p.replay, false) as replay, coalesce(p.generation, '0') as generation, coalesce(p.last_error, 'Delivery failed. See internal logs.') as "lastError"`);
|
|
12
|
+
const deadColumns = sql.literal(`consumer, event_id::text as "eventId", last_error as "lastError", attempts, failed_at::float8 as "failedAt"`);
|
|
13
|
+
const read = (schema, query) => query.pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.Array(schema))), Effect.mapError(unavailable));
|
|
14
|
+
const write = Effect.fn("EventStore.postgres.write")(function* (query) {
|
|
15
|
+
yield* UnitOfWork.required;
|
|
16
|
+
yield* query.pipe(Effect.mapError(unavailable));
|
|
17
|
+
});
|
|
18
|
+
return EventStore.of({
|
|
19
|
+
append: Effect.fn("EventStore.postgres.append")(function* (event) {
|
|
20
|
+
yield* UnitOfWork.required;
|
|
21
|
+
// A sequence alone can commit out of order and let a consumer skip a late commit.
|
|
22
|
+
const ids = yield* read(Schema.Struct({ id: Schema.String }), sql `update event_log_sequence set cursor = cursor + 1 where singleton = true returning cursor::text as id`);
|
|
23
|
+
const id = ids[0]?.id;
|
|
24
|
+
if (id === undefined)
|
|
25
|
+
return yield* unavailable("Missing event cursor.");
|
|
26
|
+
yield* write(sql `insert into event_log_events (id, kind, subject_kind, subject_id, payload, scope_id, actor_id, occurred_at, request_id)
|
|
27
|
+
values (${id}::bigint, ${event.kind}, ${event.subject.kind}, ${event.subject.id}, ${sql.json(event.payload)}, ${event.scopeId}, ${event.actorId}, ${event.occurredAt}, ${event.requestId})`);
|
|
28
|
+
yield* write(sql `select pg_notify('house_rules_event_log', '')`);
|
|
29
|
+
return { ...event, id };
|
|
30
|
+
}),
|
|
31
|
+
read: (query) => read(StoredEvent, sql `select ${columns} from event_log_events where
|
|
32
|
+
id > ${query.after ?? "0"}::bigint
|
|
33
|
+
and (${query.kinds === undefined} or kind in (select jsonb_array_elements_text(${sql.json(query.kinds ?? [])})))
|
|
34
|
+
and (${query.subject === undefined} or (subject_kind = ${query.subject?.kind ?? ""} and subject_id = ${query.subject?.id ?? ""}))
|
|
35
|
+
and (${query.scopeId === undefined} or scope_id = ${query.scopeId ?? ""})
|
|
36
|
+
and (${query.requestId === undefined} or request_id = ${query.requestId ?? ""})
|
|
37
|
+
and (${query.from === undefined} or occurred_at >= ${query.from ?? 0})
|
|
38
|
+
and (${query.to === undefined} or occurred_at <= ${query.to ?? 0})
|
|
39
|
+
order by id limit ${query.limit ?? 100}`),
|
|
40
|
+
lock: Effect.fn("EventStore.postgres.lock")(function* (consumer) {
|
|
41
|
+
yield* UnitOfWork.required;
|
|
42
|
+
yield* write(sql `insert into event_log_consumers (name) values (${consumer}) on conflict (name) do nothing`);
|
|
43
|
+
const rows = yield* read(Schema.Struct({ name: Schema.String }), sql `select name from event_log_consumers where name = ${consumer} for update skip locked`);
|
|
44
|
+
return rows.length > 0;
|
|
45
|
+
}),
|
|
46
|
+
candidate: Effect.fn("EventStore.postgres.candidate")(function* (consumer, kinds) {
|
|
47
|
+
// Keep replay separate: an OR across pending and history prevents cursor range scans.
|
|
48
|
+
const replays = yield* read(Candidate, sql `select ${candidateColumns}
|
|
49
|
+
from (select ${columns} from event_log_events where id = (
|
|
50
|
+
select event_id from event_log_pending where consumer = ${consumer} and replay = true
|
|
51
|
+
order by event_id limit 1
|
|
52
|
+
)) e
|
|
53
|
+
join event_log_pending p on p.consumer = ${consumer} and p.event_id = e.id::bigint`);
|
|
54
|
+
if (replays[0] !== undefined)
|
|
55
|
+
return replays[0];
|
|
56
|
+
// A row range keeps rare/missing kinds on (kind, id), not a filtered primary-key walk.
|
|
57
|
+
const rows = yield* read(Candidate, sql `select ${candidateColumns}
|
|
58
|
+
from (select ${columns} from event_log_events where id = (
|
|
59
|
+
select next.id from jsonb_array_elements_text(${sql.json(kinds)}) k(kind)
|
|
60
|
+
cross join lateral (
|
|
61
|
+
select id from event_log_events where kind = k.kind
|
|
62
|
+
and (kind, id) > (k.kind, coalesce((select cursor from event_log_consumers where name = ${consumer}), 0))
|
|
63
|
+
order by event_log_events.kind, id limit 1
|
|
64
|
+
) next
|
|
65
|
+
order by next.id limit 1
|
|
66
|
+
)) e
|
|
67
|
+
left join event_log_pending p on p.consumer = ${consumer} and p.event_id = e.id::bigint`);
|
|
68
|
+
return rows[0];
|
|
69
|
+
}),
|
|
70
|
+
retry: (consumer, candidate) => write(sql `insert into event_log_pending (consumer, event_id, attempts, next_due, replay, generation, last_error)
|
|
71
|
+
values (${consumer}, ${candidate.event.id}::bigint, ${candidate.attempts}, ${candidate.nextDue}, ${candidate.replay}, ${candidate.generation}, ${candidate.lastError})
|
|
72
|
+
on conflict (consumer, event_id) do update set attempts = excluded.attempts, next_due = excluded.next_due, replay = excluded.replay, generation = excluded.generation, last_error = excluded.last_error`),
|
|
73
|
+
enqueueReplay: Effect.fn("EventStore.postgres.enqueueReplay")(function* (consumer, candidate) {
|
|
74
|
+
yield* UnitOfWork.required;
|
|
75
|
+
const rows = yield* read(Schema.Struct({ id: Schema.String }), sql `insert into event_log_pending (consumer, event_id, attempts, next_due, replay, generation, last_error)
|
|
76
|
+
values (${consumer}, ${candidate.event.id}::bigint, 0, 0, true, ${candidate.generation}, ${candidate.lastError})
|
|
77
|
+
on conflict (consumer, event_id) do nothing returning event_id::text as id`);
|
|
78
|
+
return rows.length > 0;
|
|
79
|
+
}),
|
|
80
|
+
advance: Effect.fn("EventStore.postgres.advance")(function* (consumer, eventId, replay) {
|
|
81
|
+
yield* write(sql `delete from event_log_pending where consumer = ${consumer} and event_id = ${eventId}::bigint`);
|
|
82
|
+
if (!replay)
|
|
83
|
+
yield* write(sql `update event_log_consumers set cursor = ${eventId}::bigint where name = ${consumer}`);
|
|
84
|
+
}),
|
|
85
|
+
deadLetter: (row) => write(sql `insert into event_log_dead_letters (consumer, event_id, last_error, attempts, failed_at)
|
|
86
|
+
values (${row.consumer}, ${row.eventId}::bigint, ${row.lastError}, ${row.attempts}, ${row.failedAt})
|
|
87
|
+
on conflict (consumer, event_id) do update set last_error = excluded.last_error, attempts = excluded.attempts, failed_at = excluded.failed_at`),
|
|
88
|
+
listDeadLetters: (query) => read(DeadLetter, sql `select ${deadColumns} from event_log_dead_letters
|
|
89
|
+
where (${query.consumer === undefined} or consumer = ${query.consumer ?? ""}) and (event_id, consumer collate "C") > (${query.after?.eventId ?? "0"}::bigint, ${query.after?.consumer ?? ""})
|
|
90
|
+
order by event_id, consumer collate "C" limit ${query.limit ?? 100}`),
|
|
91
|
+
findDeadLetter: (consumer, eventId) => read(DeadLetter, sql `select ${deadColumns} from event_log_dead_letters where consumer = ${consumer} and event_id = ${eventId}::bigint`).pipe(Effect.map((rows) => rows[0])),
|
|
92
|
+
clearDeadLetter: (consumer, eventId) => write(sql `delete from event_log_dead_letters where consumer = ${consumer} and event_id = ${eventId}::bigint`),
|
|
93
|
+
wakeups: Stream.unwrap(sql.listen("house_rules_event_log").pipe(Effect.map(Stream.fromQueue))).pipe(Stream.as(undefined), Stream.mapError(unavailable)),
|
|
94
|
+
});
|
|
95
|
+
}));
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { EventId, EventFact, StoredEvent, EventQuery, EventInputInvalid, EventStoreUnavailable, DeadLetterNotFound, } from "./event/event.js";
|
|
2
|
+
export { DeliveryToken, ReplayResult, Delivery, DeadLetter, DeadLetterQuery, DeadLetterCursor, ConsumerOptions, } from "./event/delivery.js";
|
|
3
|
+
export { EventLogOperations } from "./event/operations.js";
|
|
4
|
+
export { EventLogPermissions, type EventLogPermission } from "./event/permissions.js";
|
|
5
|
+
export type { EventLogOptions } from "./event/options.js";
|
package/dist/contract.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { EventId, EventFact, StoredEvent, EventQuery, EventInputInvalid, EventStoreUnavailable, DeadLetterNotFound, } from "./event/event.js";
|
|
2
|
+
export { DeliveryToken, ReplayResult, Delivery, DeadLetter, DeadLetterQuery, DeadLetterCursor, ConsumerOptions, } from "./event/delivery.js";
|
|
3
|
+
export { EventLogOperations } from "./event/operations.js";
|
|
4
|
+
export { EventLogPermissions } from "./event/permissions.js";
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { bindOperation, CallRequestId } from "@house-rules/application";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
import { EventLogOperations as operations } from "./operations.js";
|
|
4
|
+
export const bindEventLog = (service) => [
|
|
5
|
+
bindOperation(operations.record, (fact, { caller }) => Effect.gen(function* () {
|
|
6
|
+
const requestId = yield* CallRequestId;
|
|
7
|
+
return yield* service.record(caller.kind === "person" ? caller.actor : null, fact, requestId || null);
|
|
8
|
+
})),
|
|
9
|
+
bindOperation(operations.read, (query) => service.read(query)),
|
|
10
|
+
bindOperation(operations.due, () => service.due()),
|
|
11
|
+
bindOperation(operations.claimDue, (query) => service.claimDue(query)),
|
|
12
|
+
bindOperation(operations.takeNext, (token) => service.takeNext(token)),
|
|
13
|
+
bindOperation(operations.settleSuccess, (token) => service.settleSuccess(token)),
|
|
14
|
+
bindOperation(operations.settleFailure, ({ token, lastError }) => service.settleFailure(token, lastError)),
|
|
15
|
+
bindOperation(operations.listDeadLetters, (query) => service.listDeadLetters(query)),
|
|
16
|
+
bindOperation(operations.replay, ({ consumer, eventId }) => service.replay(consumer, eventId)),
|
|
17
|
+
];
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
export declare const DeliveryToken: Schema.Struct<{
|
|
3
|
+
readonly consumer: Schema.NonEmptyString;
|
|
4
|
+
readonly eventId: Schema.String;
|
|
5
|
+
readonly attempt: Schema.Int;
|
|
6
|
+
readonly replay: Schema.Boolean;
|
|
7
|
+
readonly generation: Schema.String;
|
|
8
|
+
}>;
|
|
9
|
+
export type DeliveryToken = typeof DeliveryToken.Type;
|
|
10
|
+
export declare const ReplayResult: Schema.Literals<readonly ["enqueued", "busy", "already_queued"]>;
|
|
11
|
+
export type ReplayResult = typeof ReplayResult.Type;
|
|
12
|
+
export declare const Delivery: Schema.Struct<{
|
|
13
|
+
readonly token: Schema.Struct<{
|
|
14
|
+
readonly consumer: Schema.NonEmptyString;
|
|
15
|
+
readonly eventId: Schema.String;
|
|
16
|
+
readonly attempt: Schema.Int;
|
|
17
|
+
readonly replay: Schema.Boolean;
|
|
18
|
+
readonly generation: Schema.String;
|
|
19
|
+
}>;
|
|
20
|
+
readonly event: Schema.Struct<{
|
|
21
|
+
readonly kind: Schema.NonEmptyString;
|
|
22
|
+
readonly subject: Schema.Struct<{
|
|
23
|
+
readonly kind: Schema.NonEmptyString;
|
|
24
|
+
readonly id: Schema.NonEmptyString;
|
|
25
|
+
}>;
|
|
26
|
+
readonly payload: Schema.Unknown;
|
|
27
|
+
readonly scopeId: Schema.NullOr<Schema.NonEmptyString>;
|
|
28
|
+
readonly id: Schema.String;
|
|
29
|
+
readonly actorId: Schema.NullOr<Schema.NonEmptyString>;
|
|
30
|
+
readonly occurredAt: Schema.Int;
|
|
31
|
+
readonly requestId: Schema.NullOr<Schema.String>;
|
|
32
|
+
}>;
|
|
33
|
+
}>;
|
|
34
|
+
export type Delivery = typeof Delivery.Type;
|
|
35
|
+
export declare const DeadLetter: Schema.Struct<{
|
|
36
|
+
readonly consumer: Schema.NonEmptyString;
|
|
37
|
+
readonly eventId: Schema.String;
|
|
38
|
+
readonly lastError: Schema.String;
|
|
39
|
+
readonly attempts: Schema.Int;
|
|
40
|
+
readonly failedAt: Schema.Int;
|
|
41
|
+
}>;
|
|
42
|
+
export type DeadLetter = typeof DeadLetter.Type;
|
|
43
|
+
export declare const DeadLetterCursor: Schema.Struct<{
|
|
44
|
+
readonly eventId: Schema.String;
|
|
45
|
+
readonly consumer: Schema.NonEmptyString;
|
|
46
|
+
}>;
|
|
47
|
+
export type DeadLetterCursor = typeof DeadLetterCursor.Type;
|
|
48
|
+
export declare const DeadLetterQuery: Schema.Struct<{
|
|
49
|
+
readonly consumer: Schema.optionalKey<Schema.NonEmptyString>;
|
|
50
|
+
readonly after: Schema.optionalKey<Schema.Struct<{
|
|
51
|
+
readonly eventId: Schema.String;
|
|
52
|
+
readonly consumer: Schema.NonEmptyString;
|
|
53
|
+
}>>;
|
|
54
|
+
readonly limit: Schema.optionalKey<Schema.Int>;
|
|
55
|
+
}>;
|
|
56
|
+
export type DeadLetterQuery = typeof DeadLetterQuery.Type;
|
|
57
|
+
export declare const Candidate: Schema.Struct<{
|
|
58
|
+
readonly event: Schema.Struct<{
|
|
59
|
+
readonly kind: Schema.NonEmptyString;
|
|
60
|
+
readonly subject: Schema.Struct<{
|
|
61
|
+
readonly kind: Schema.NonEmptyString;
|
|
62
|
+
readonly id: Schema.NonEmptyString;
|
|
63
|
+
}>;
|
|
64
|
+
readonly payload: Schema.Unknown;
|
|
65
|
+
readonly scopeId: Schema.NullOr<Schema.NonEmptyString>;
|
|
66
|
+
readonly id: Schema.String;
|
|
67
|
+
readonly actorId: Schema.NullOr<Schema.NonEmptyString>;
|
|
68
|
+
readonly occurredAt: Schema.Int;
|
|
69
|
+
readonly requestId: Schema.NullOr<Schema.String>;
|
|
70
|
+
}>;
|
|
71
|
+
readonly attempts: Schema.Int;
|
|
72
|
+
readonly nextDue: Schema.Int;
|
|
73
|
+
readonly replay: Schema.Boolean;
|
|
74
|
+
readonly generation: Schema.String;
|
|
75
|
+
readonly lastError: Schema.String;
|
|
76
|
+
}>;
|
|
77
|
+
export type Candidate = typeof Candidate.Type;
|
|
78
|
+
export declare const ConsumerOptions: Schema.Struct<{
|
|
79
|
+
readonly name: Schema.NonEmptyString;
|
|
80
|
+
readonly kinds: Schema.$Array<Schema.NonEmptyString>;
|
|
81
|
+
readonly maxAttempts: Schema.optionalKey<Schema.Int>;
|
|
82
|
+
readonly backoff: Schema.optionalKey<Schema.Struct<{
|
|
83
|
+
readonly baseMs: Schema.Int;
|
|
84
|
+
readonly capMs: Schema.Int;
|
|
85
|
+
}>>;
|
|
86
|
+
}>;
|
|
87
|
+
export type ConsumerOptions = typeof ConsumerOptions.Type;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
import { EventId, StoredEvent } from "./event.js";
|
|
3
|
+
export const DeliveryToken = Schema.Struct({
|
|
4
|
+
consumer: Schema.NonEmptyString,
|
|
5
|
+
eventId: EventId,
|
|
6
|
+
attempt: Schema.Int.check(Schema.isGreaterThan(0)),
|
|
7
|
+
replay: Schema.Boolean,
|
|
8
|
+
generation: Schema.String,
|
|
9
|
+
});
|
|
10
|
+
export const ReplayResult = Schema.Literals(["enqueued", "busy", "already_queued"]);
|
|
11
|
+
export const Delivery = Schema.Struct({ token: DeliveryToken, event: StoredEvent });
|
|
12
|
+
export const DeadLetter = Schema.Struct({
|
|
13
|
+
consumer: Schema.NonEmptyString,
|
|
14
|
+
eventId: EventId,
|
|
15
|
+
lastError: Schema.String,
|
|
16
|
+
attempts: Schema.Int.check(Schema.isGreaterThan(0)),
|
|
17
|
+
failedAt: Schema.Int,
|
|
18
|
+
});
|
|
19
|
+
export const DeadLetterCursor = Schema.Struct({
|
|
20
|
+
eventId: EventId,
|
|
21
|
+
consumer: Schema.NonEmptyString,
|
|
22
|
+
});
|
|
23
|
+
export const DeadLetterQuery = Schema.Struct({
|
|
24
|
+
consumer: Schema.optionalKey(Schema.NonEmptyString),
|
|
25
|
+
after: Schema.optionalKey(DeadLetterCursor),
|
|
26
|
+
limit: Schema.optionalKey(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 1000 }))),
|
|
27
|
+
});
|
|
28
|
+
export const Candidate = Schema.Struct({
|
|
29
|
+
event: StoredEvent,
|
|
30
|
+
attempts: Schema.Int,
|
|
31
|
+
nextDue: Schema.Int,
|
|
32
|
+
replay: Schema.Boolean,
|
|
33
|
+
generation: Schema.String,
|
|
34
|
+
lastError: Schema.String,
|
|
35
|
+
});
|
|
36
|
+
export const ConsumerOptions = Schema.Struct({
|
|
37
|
+
name: Schema.NonEmptyString,
|
|
38
|
+
kinds: Schema.Array(Schema.NonEmptyString).check(Schema.isMinLength(1)),
|
|
39
|
+
maxAttempts: Schema.optionalKey(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 100 }))),
|
|
40
|
+
backoff: Schema.optionalKey(Schema.Struct({
|
|
41
|
+
baseMs: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 300000 })),
|
|
42
|
+
capMs: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 300000 })),
|
|
43
|
+
})),
|
|
44
|
+
});
|