@structure-ai/eventsourcing-nisshi 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/package.json +3 -3
- package/src/EventStore.ts +137 -78
- package/src/Stores.ts +53 -18
- package/src/layer.ts +17 -4
- package/src/relay.ts +15 -4
package/README.md
CHANGED
|
@@ -80,3 +80,9 @@ The sidecar and broker storage may share one PostgreSQL server, but use separate
|
|
|
80
80
|
## Tests
|
|
81
81
|
|
|
82
82
|
`bun test` skips broker suites unless `NISSHI_URL` is set; PostgreSQL-sidecar scenarios additionally require `DATABASE_URL`. CI installs a pinned Nisshi release, starts PostgreSQL, and runs both sidecar suites. The wire quirks this client encodes (trailing `throttle_time_ms` in Produce, metadata-triggered topic auto-creation, empty-topic high watermark of 1, whole-batch fetch granularity) are pinned by tests in `test/protocol.test.ts`.
|
|
83
|
+
|
|
84
|
+
## Persistence failures
|
|
85
|
+
|
|
86
|
+
The event store and sidecar adapters expose `PersistenceError` for SQL, broker and envelope failures, retaining the operation and diagnostic cause. Version races remain `ConcurrencyConflict`. See the [shared error contract](../eventsourcing/README.md#persistence-failures).
|
|
87
|
+
|
|
88
|
+
`runPendingRelay` retries only connection failures and broker errors marked retriable. SQL and permanent broker failures stop the relay with a typed error; supervise the returned effect. Produce failures wrap `NisshiProduceError` in `PersistenceError`. Pending rows may still be published later, so a failed append is not proof that no events will appear. See [ADR-0020](../../docs/decisions/0020-typed-persistence-and-cause-boundaries.md).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@structure-ai/eventsourcing-nisshi",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Nisshi (Kafka-API broker) event store with a SQL sidecar for optimistic concurrency, snapshots, checkpoints, and inbox dedupe.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -28,8 +28,8 @@
|
|
|
28
28
|
"@effect/sql": "^0.52.1",
|
|
29
29
|
"@effect/sql-sqlite-bun": "^0.53.0",
|
|
30
30
|
"@effect/sql-pg": "^0.53.0",
|
|
31
|
-
"@structure-ai/domain": "0.2.
|
|
32
|
-
"@structure-ai/eventsourcing": "0.2.
|
|
31
|
+
"@structure-ai/domain": "0.2.1",
|
|
32
|
+
"@structure-ai/eventsourcing": "0.2.1"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"typescript": "^5.9.2",
|
package/src/EventStore.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as SqlClient from "@effect/sql/SqlClient";
|
|
2
|
-
import
|
|
3
|
-
import { ConcurrencyConflict } from "@structure-ai/domain";
|
|
2
|
+
import { SqlError } from "@effect/sql/SqlError";
|
|
3
|
+
import { ConcurrencyConflict, PersistenceError } from "@structure-ai/domain";
|
|
4
4
|
import {
|
|
5
5
|
type AppendEvent,
|
|
6
6
|
type AppendResult,
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
type StoredEvent,
|
|
11
11
|
type StoredEventMetadata,
|
|
12
12
|
} from "@structure-ai/eventsourcing";
|
|
13
|
-
import { Effect, Layer, Stream } from "effect";
|
|
13
|
+
import { Cause, Effect, Layer, Stream } from "effect";
|
|
14
14
|
import { decodeWireEvent, encodeWireEvent, validateWireEvent } from "./envelope.js";
|
|
15
15
|
import { NisshiClient } from "./protocol/client.js";
|
|
16
16
|
import { NisshiProduceError } from "./protocol/errors.js";
|
|
@@ -109,7 +109,7 @@ const make = (
|
|
|
109
109
|
streamName: string,
|
|
110
110
|
expectedVersion: number,
|
|
111
111
|
events: ReadonlyArray<AppendEvent>,
|
|
112
|
-
): Effect.Effect<AppendResult, ConcurrencyConflict | SqlError> =>
|
|
112
|
+
): Effect.Effect<AppendResult, ConcurrencyConflict | SqlError | PersistenceError> =>
|
|
113
113
|
sql
|
|
114
114
|
.withTransaction(
|
|
115
115
|
Effect.gen(function* () {
|
|
@@ -141,22 +141,27 @@ const make = (
|
|
|
141
141
|
}
|
|
142
142
|
yield* Effect.forEach(
|
|
143
143
|
events,
|
|
144
|
-
(event, index) =>
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
144
|
+
(event, index) =>
|
|
145
|
+
Effect.gen(function* () {
|
|
146
|
+
const wire = {
|
|
147
|
+
type: event.type,
|
|
148
|
+
schemaVersion: event.schemaVersion,
|
|
149
|
+
version: expectedVersion + index + 1,
|
|
150
|
+
payload: event.payload,
|
|
151
|
+
metadata: event.metadata,
|
|
152
|
+
};
|
|
153
|
+
const encoded = yield* Effect.try({
|
|
154
|
+
try: () => {
|
|
155
|
+
if (validate) validateWireEvent(wire, streamName);
|
|
156
|
+
return JSON.stringify(wire);
|
|
157
|
+
},
|
|
158
|
+
catch: (cause) => new PersistenceError({ operation: "nisshi.encode", cause }),
|
|
159
|
+
});
|
|
160
|
+
return yield* sql`
|
|
156
161
|
INSERT INTO ${sql(tables.pending)} (stream_name, version, topic, record_value)
|
|
157
|
-
VALUES (${streamName}, ${expectedVersion + index + 1}, ${topic}, ${
|
|
162
|
+
VALUES (${streamName}, ${expectedVersion + index + 1}, ${topic}, ${encoded})
|
|
158
163
|
`;
|
|
159
|
-
|
|
164
|
+
}),
|
|
160
165
|
{ discard: true },
|
|
161
166
|
);
|
|
162
167
|
return {
|
|
@@ -166,66 +171,95 @@ const make = (
|
|
|
166
171
|
}),
|
|
167
172
|
)
|
|
168
173
|
.pipe(
|
|
169
|
-
Effect.
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
174
|
+
Effect.catchAllCause((cause) => {
|
|
175
|
+
if (
|
|
176
|
+
!Cause.isFailType(cause) ||
|
|
177
|
+
!(cause.error instanceof SqlError) ||
|
|
178
|
+
!isUniqueViolation(cause.error)
|
|
179
|
+
)
|
|
180
|
+
return Effect.failCause(cause);
|
|
181
|
+
return Effect.flatMap(currentVersion(streamName), (actual) =>
|
|
182
|
+
Effect.fail(conflict(streamName, expectedVersion, actual)),
|
|
183
|
+
);
|
|
184
|
+
}),
|
|
176
185
|
);
|
|
177
186
|
|
|
178
|
-
/**
|
|
187
|
+
/** Conditional rollback; its failure is retained alongside the produce failure. */
|
|
179
188
|
const rollback = (
|
|
180
189
|
streamName: string,
|
|
181
190
|
expectedVersion: number,
|
|
182
191
|
count: number,
|
|
183
|
-
): Effect.Effect<void> =>
|
|
184
|
-
sql
|
|
185
|
-
.
|
|
186
|
-
|
|
187
|
-
yield* sql`
|
|
192
|
+
): Effect.Effect<void, SqlError> =>
|
|
193
|
+
sql.withTransaction(
|
|
194
|
+
Effect.gen(function* () {
|
|
195
|
+
yield* sql`
|
|
188
196
|
UPDATE ${sql(tables.streams)}
|
|
189
197
|
SET last_version = ${expectedVersion}
|
|
190
198
|
WHERE stream_name = ${streamName} AND last_version = ${expectedVersion + count}
|
|
191
199
|
`;
|
|
192
|
-
|
|
200
|
+
yield* sql`
|
|
193
201
|
DELETE FROM ${sql(tables.pending)}
|
|
194
202
|
WHERE stream_name = ${streamName}
|
|
195
203
|
AND version > ${expectedVersion} AND version <= ${expectedVersion + count}
|
|
196
204
|
`;
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
.pipe(
|
|
200
|
-
Effect.ignore,
|
|
201
|
-
Effect.catchAllDefect(() => Effect.void),
|
|
202
|
-
);
|
|
205
|
+
}),
|
|
206
|
+
);
|
|
203
207
|
|
|
204
208
|
const append: EventStoreService["append"] = (streamName, expectedVersion, events) =>
|
|
205
209
|
Effect.gen(function* () {
|
|
210
|
+
const records = yield* Effect.try({
|
|
211
|
+
try: () =>
|
|
212
|
+
events.map((event, index) => ({
|
|
213
|
+
key: new TextEncoder().encode(streamName),
|
|
214
|
+
value: encodeWireEvent({
|
|
215
|
+
type: event.type,
|
|
216
|
+
schemaVersion: event.schemaVersion,
|
|
217
|
+
version: expectedVersion + index + 1,
|
|
218
|
+
payload: event.payload,
|
|
219
|
+
metadata: event.metadata,
|
|
220
|
+
}),
|
|
221
|
+
})),
|
|
222
|
+
catch: (cause) => new PersistenceError({ operation: "nisshi.encode", cause }),
|
|
223
|
+
});
|
|
206
224
|
const result = yield* reserve(streamName, expectedVersion, events).pipe(
|
|
207
|
-
Effect.
|
|
225
|
+
Effect.mapError((cause) =>
|
|
226
|
+
cause instanceof SqlError
|
|
227
|
+
? new PersistenceError({ operation: "nisshi.reserve", cause })
|
|
228
|
+
: cause,
|
|
229
|
+
),
|
|
208
230
|
);
|
|
209
231
|
if (events.length === 0) {
|
|
210
232
|
return result;
|
|
211
233
|
}
|
|
212
|
-
const records = events.map((event, index) => ({
|
|
213
|
-
key: new TextEncoder().encode(streamName),
|
|
214
|
-
value: encodeWireEvent({
|
|
215
|
-
type: event.type,
|
|
216
|
-
schemaVersion: event.schemaVersion,
|
|
217
|
-
version: expectedVersion + index + 1,
|
|
218
|
-
payload: event.payload,
|
|
219
|
-
metadata: event.metadata,
|
|
220
|
-
}),
|
|
221
|
-
}));
|
|
222
234
|
yield* client.produce(topic, records).pipe(
|
|
223
|
-
Effect.catchAllCause((cause) =>
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
235
|
+
Effect.catchAllCause((cause) => {
|
|
236
|
+
const original = Cause.map(
|
|
237
|
+
cause,
|
|
238
|
+
(error) =>
|
|
239
|
+
new PersistenceError({
|
|
240
|
+
operation: "nisshi.produce",
|
|
241
|
+
cause: new NisshiProduceError({ topic, cause: error }),
|
|
242
|
+
}),
|
|
243
|
+
);
|
|
244
|
+
if (!Cause.isFailType(cause)) return Effect.failCause(original);
|
|
245
|
+
return rollback(streamName, expectedVersion, events.length).pipe(
|
|
246
|
+
Effect.uninterruptible,
|
|
247
|
+
Effect.matchCauseEffect({
|
|
248
|
+
onSuccess: () => Effect.failCause(original),
|
|
249
|
+
onFailure: (cleanup) =>
|
|
250
|
+
Effect.failCause(
|
|
251
|
+
Cause.sequential(
|
|
252
|
+
original,
|
|
253
|
+
Cause.map(
|
|
254
|
+
cleanup,
|
|
255
|
+
(error) =>
|
|
256
|
+
new PersistenceError({ operation: "nisshi.rollback", cause: error }),
|
|
257
|
+
),
|
|
258
|
+
),
|
|
259
|
+
),
|
|
260
|
+
}),
|
|
261
|
+
);
|
|
262
|
+
}),
|
|
229
263
|
);
|
|
230
264
|
// Confirm: events are durable in the topic. A failure here leaves the
|
|
231
265
|
// pending rows for the relay; a re-produce duplicates are tolerated
|
|
@@ -235,8 +269,13 @@ const make = (
|
|
|
235
269
|
WHERE stream_name = ${streamName}
|
|
236
270
|
AND version > ${expectedVersion} AND version <= ${expectedVersion + events.length}
|
|
237
271
|
`.pipe(
|
|
238
|
-
Effect.
|
|
239
|
-
|
|
272
|
+
Effect.mapError((cause) => new PersistenceError({ operation: "nisshi.confirm", cause })),
|
|
273
|
+
Effect.catchAllCause((cause) =>
|
|
274
|
+
Cause.isFailType(cause)
|
|
275
|
+
? Effect.logWarning(
|
|
276
|
+
"Nisshi pending confirmation failed; relay will recover pending rows",
|
|
277
|
+
)
|
|
278
|
+
: Effect.failCause(cause),
|
|
240
279
|
),
|
|
241
280
|
);
|
|
242
281
|
return result;
|
|
@@ -250,12 +289,18 @@ const make = (
|
|
|
250
289
|
readonly value: Uint8Array;
|
|
251
290
|
}>;
|
|
252
291
|
}
|
|
253
|
-
const readTopic = (fromOffset: bigint): Effect.Effect<TopicRecords> =>
|
|
292
|
+
const readTopic = (fromOffset: bigint): Effect.Effect<TopicRecords, PersistenceError> =>
|
|
254
293
|
Effect.gen(function* () {
|
|
255
294
|
const out: { offset: bigint; streamName: string; value: Uint8Array }[] = [];
|
|
256
295
|
let offset = fromOffset;
|
|
257
296
|
for (;;) {
|
|
258
|
-
const page = yield* client
|
|
297
|
+
const page = yield* client
|
|
298
|
+
.fetch(topic, offset, maxBytes)
|
|
299
|
+
.pipe(
|
|
300
|
+
Effect.mapError(
|
|
301
|
+
(cause) => new PersistenceError({ operation: "nisshi.fetch", cause }),
|
|
302
|
+
),
|
|
303
|
+
);
|
|
259
304
|
if (page.records.length === 0) {
|
|
260
305
|
return { records: out };
|
|
261
306
|
}
|
|
@@ -278,24 +323,29 @@ const make = (
|
|
|
278
323
|
append,
|
|
279
324
|
read: (streamName, readOptions) =>
|
|
280
325
|
Stream.unwrap(
|
|
281
|
-
Effect.
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
326
|
+
Effect.flatMap(readTopic(0n), ({ records }) =>
|
|
327
|
+
Effect.gen(function* () {
|
|
328
|
+
const fromVersion = readOptions?.fromVersion ?? 1;
|
|
329
|
+
const seen = new Set<number>();
|
|
330
|
+
const events: StoredEvent[] = [];
|
|
331
|
+
for (const record of records) {
|
|
332
|
+
if (record.streamName !== streamName) {
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
const envelope = yield* Effect.try({
|
|
336
|
+
try: () => decodeWireEvent(record.value, streamName),
|
|
337
|
+
catch: (cause) => new PersistenceError({ operation: "nisshi.decode", cause }),
|
|
338
|
+
});
|
|
339
|
+
if (envelope.version < fromVersion || seen.has(envelope.version)) {
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
seen.add(envelope.version);
|
|
343
|
+
events.push(storedEvent(record.offset, streamName, envelope));
|
|
292
344
|
}
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
return Stream.fromIterable(events);
|
|
298
|
-
}),
|
|
345
|
+
events.sort((a, b) => a.version - b.version);
|
|
346
|
+
return Stream.fromIterable(events);
|
|
347
|
+
}),
|
|
348
|
+
),
|
|
299
349
|
),
|
|
300
350
|
readAll: (readOptions) =>
|
|
301
351
|
Stream.unwrap(
|
|
@@ -315,7 +365,13 @@ const make = (
|
|
|
315
365
|
if (limit !== undefined && events.length >= limit) {
|
|
316
366
|
break;
|
|
317
367
|
}
|
|
318
|
-
const page = yield* client
|
|
368
|
+
const page = yield* client
|
|
369
|
+
.fetch(topic, offset, maxBytes)
|
|
370
|
+
.pipe(
|
|
371
|
+
Effect.mapError(
|
|
372
|
+
(cause) => new PersistenceError({ operation: "nisshi.fetch", cause }),
|
|
373
|
+
),
|
|
374
|
+
);
|
|
319
375
|
if (page.records.length === 0) {
|
|
320
376
|
break;
|
|
321
377
|
}
|
|
@@ -324,7 +380,10 @@ const make = (
|
|
|
324
380
|
const event = storedEvent(
|
|
325
381
|
record.offset,
|
|
326
382
|
streamName,
|
|
327
|
-
|
|
383
|
+
yield* Effect.try({
|
|
384
|
+
try: () => decodeWireEvent(record.value, streamName),
|
|
385
|
+
catch: (cause) => new PersistenceError({ operation: "nisshi.decode", cause }),
|
|
386
|
+
}),
|
|
328
387
|
);
|
|
329
388
|
if (keep(event)) {
|
|
330
389
|
events.push(event);
|
package/src/Stores.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as SqlClient from "@effect/sql/SqlClient";
|
|
2
|
+
import { PersistenceError } from "@structure-ai/domain";
|
|
2
3
|
import {
|
|
3
4
|
CheckpointStore,
|
|
4
5
|
type CheckpointStoreService,
|
|
@@ -29,26 +30,44 @@ export const snapshotStoreLayer = (
|
|
|
29
30
|
const sql = yield* SqlClient.SqlClient;
|
|
30
31
|
const service: SnapshotStoreService = {
|
|
31
32
|
load: (streamName) =>
|
|
32
|
-
Effect.
|
|
33
|
+
Effect.flatMap(
|
|
33
34
|
sql<SnapshotRow>`
|
|
34
35
|
SELECT state, version FROM ${sql(tables.snapshots)} WHERE stream_name = ${streamName}
|
|
35
|
-
`.pipe(
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
36
|
+
`.pipe(
|
|
37
|
+
Effect.mapError(
|
|
38
|
+
(cause) => new PersistenceError({ operation: "nisshi-sidecar", cause }),
|
|
39
|
+
),
|
|
40
|
+
),
|
|
41
|
+
(rows) =>
|
|
42
|
+
Effect.try({
|
|
43
|
+
try: (): Option.Option<{ state: unknown; version: number }> => {
|
|
44
|
+
const row = rows[0];
|
|
45
|
+
return row === undefined
|
|
46
|
+
? Option.none()
|
|
47
|
+
: Option.some({
|
|
48
|
+
state: JSON.parse(row.state) as unknown,
|
|
49
|
+
version: Number(row.version),
|
|
50
|
+
});
|
|
51
|
+
},
|
|
52
|
+
catch: (cause) => new PersistenceError({ operation: "snapshot.decode", cause }),
|
|
53
|
+
}),
|
|
45
54
|
),
|
|
46
55
|
save: (streamName, snapshot) =>
|
|
47
|
-
Effect.
|
|
56
|
+
Effect.gen(function* () {
|
|
57
|
+
const encoded = yield* Effect.try({
|
|
58
|
+
try: () => JSON.stringify(snapshot.state ?? null),
|
|
59
|
+
catch: (cause) => new PersistenceError({ operation: "snapshot.encode", cause }),
|
|
60
|
+
});
|
|
61
|
+
return yield* Effect.asVoid(sql`
|
|
48
62
|
INSERT INTO ${sql(tables.snapshots)} (stream_name, state, version)
|
|
49
|
-
VALUES (${streamName}, ${
|
|
63
|
+
VALUES (${streamName}, ${encoded}, ${snapshot.version})
|
|
50
64
|
ON CONFLICT (stream_name) DO UPDATE SET state = excluded.state, version = excluded.version
|
|
51
|
-
`).pipe(
|
|
65
|
+
`).pipe(
|
|
66
|
+
Effect.mapError(
|
|
67
|
+
(cause) => new PersistenceError({ operation: "nisshi-sidecar", cause }),
|
|
68
|
+
),
|
|
69
|
+
);
|
|
70
|
+
}),
|
|
52
71
|
};
|
|
53
72
|
return SnapshotStore.of(service);
|
|
54
73
|
}),
|
|
@@ -69,7 +88,11 @@ export const checkpointStoreLayer = (
|
|
|
69
88
|
Effect.map(
|
|
70
89
|
sql<{ readonly position: number | bigint | string | null }>`
|
|
71
90
|
SELECT position FROM ${sql(tables.checkpoints)} WHERE name = ${name}
|
|
72
|
-
`.pipe(
|
|
91
|
+
`.pipe(
|
|
92
|
+
Effect.mapError(
|
|
93
|
+
(cause) => new PersistenceError({ operation: "nisshi-sidecar", cause }),
|
|
94
|
+
),
|
|
95
|
+
),
|
|
73
96
|
(rows) => toBigInt(rows[0]?.position),
|
|
74
97
|
),
|
|
75
98
|
save: (name, position) =>
|
|
@@ -77,7 +100,11 @@ export const checkpointStoreLayer = (
|
|
|
77
100
|
INSERT INTO ${sql(tables.checkpoints)} (name, position)
|
|
78
101
|
VALUES (${name}, ${position})
|
|
79
102
|
ON CONFLICT (name) DO UPDATE SET position = excluded.position
|
|
80
|
-
`).pipe(
|
|
103
|
+
`).pipe(
|
|
104
|
+
Effect.mapError(
|
|
105
|
+
(cause) => new PersistenceError({ operation: "nisshi-sidecar", cause }),
|
|
106
|
+
),
|
|
107
|
+
),
|
|
81
108
|
};
|
|
82
109
|
return CheckpointStore.of(service);
|
|
83
110
|
}),
|
|
@@ -99,7 +126,11 @@ export const inboxLayer = (
|
|
|
99
126
|
sql<{ readonly one: number }>`
|
|
100
127
|
SELECT 1 AS one FROM ${sql(tables.inbox)}
|
|
101
128
|
WHERE consumer_id = ${consumerId} AND message_id = ${messageId}
|
|
102
|
-
`.pipe(
|
|
129
|
+
`.pipe(
|
|
130
|
+
Effect.mapError(
|
|
131
|
+
(cause) => new PersistenceError({ operation: "nisshi-sidecar", cause }),
|
|
132
|
+
),
|
|
133
|
+
),
|
|
103
134
|
(rows) => rows.length > 0,
|
|
104
135
|
),
|
|
105
136
|
markProcessed: (consumerId, messageId) =>
|
|
@@ -107,7 +138,11 @@ export const inboxLayer = (
|
|
|
107
138
|
INSERT INTO ${sql(tables.inbox)} (consumer_id, message_id)
|
|
108
139
|
VALUES (${consumerId}, ${messageId})
|
|
109
140
|
ON CONFLICT DO NOTHING
|
|
110
|
-
`).pipe(
|
|
141
|
+
`).pipe(
|
|
142
|
+
Effect.mapError(
|
|
143
|
+
(cause) => new PersistenceError({ operation: "nisshi-sidecar", cause }),
|
|
144
|
+
),
|
|
145
|
+
),
|
|
111
146
|
};
|
|
112
147
|
return Inbox.of(service);
|
|
113
148
|
}),
|
package/src/layer.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type * as SqlClient from "@effect/sql/SqlClient";
|
|
|
2
2
|
import type { SqlError } from "@effect/sql/SqlError";
|
|
3
3
|
import { PgClient } from "@effect/sql-pg";
|
|
4
4
|
import { SqliteClient } from "@effect/sql-sqlite-bun";
|
|
5
|
+
import { PersistenceError } from "@structure-ai/domain";
|
|
5
6
|
import type {
|
|
6
7
|
CheckpointStore,
|
|
7
8
|
EventStore,
|
|
@@ -82,7 +83,7 @@ export const layerPg = (
|
|
|
82
83
|
options: NisshiPgConfig,
|
|
83
84
|
): Layer.Layer<
|
|
84
85
|
StoreServices | PgClient.PgClient | SqlClient.SqlClient | NisshiClient,
|
|
85
|
-
SqlError | NisshiConnectionError
|
|
86
|
+
SqlError | NisshiConnectionError | PersistenceError
|
|
86
87
|
> => {
|
|
87
88
|
const client = nisshiClientLayer({
|
|
88
89
|
brokerUrl: options.brokerUrl,
|
|
@@ -101,7 +102,13 @@ export const layerPg = (
|
|
|
101
102
|
? client
|
|
102
103
|
: Layer.effectDiscard(
|
|
103
104
|
Effect.flatMap(NisshiClient, (c) =>
|
|
104
|
-
c
|
|
105
|
+
c
|
|
106
|
+
.ensureTopic(options.topic ?? "events")
|
|
107
|
+
.pipe(
|
|
108
|
+
Effect.mapError(
|
|
109
|
+
(cause) => new PersistenceError({ operation: "nisshi.ensure-topic", cause }),
|
|
110
|
+
),
|
|
111
|
+
),
|
|
105
112
|
),
|
|
106
113
|
).pipe(Layer.provideMerge(client));
|
|
107
114
|
return storesLayer(options).pipe(Layer.provideMerge(migrated), Layer.provideMerge(ensured));
|
|
@@ -116,7 +123,7 @@ export const layer = (
|
|
|
116
123
|
options: NisshiAdaptersConfig,
|
|
117
124
|
): Layer.Layer<
|
|
118
125
|
StoreServices | SqliteClient.SqliteClient | SqlClient.SqlClient | NisshiClient,
|
|
119
|
-
ConfigError | SqlError | NisshiConnectionError
|
|
126
|
+
ConfigError | SqlError | NisshiConnectionError | PersistenceError
|
|
120
127
|
> => {
|
|
121
128
|
const client = nisshiClientLayer({
|
|
122
129
|
brokerUrl: options.brokerUrl,
|
|
@@ -130,7 +137,13 @@ export const layer = (
|
|
|
130
137
|
? client
|
|
131
138
|
: Layer.effectDiscard(
|
|
132
139
|
Effect.flatMap(NisshiClient, (c) =>
|
|
133
|
-
c
|
|
140
|
+
c
|
|
141
|
+
.ensureTopic(options.topic ?? "events")
|
|
142
|
+
.pipe(
|
|
143
|
+
Effect.mapError(
|
|
144
|
+
(cause) => new PersistenceError({ operation: "nisshi.ensure-topic", cause }),
|
|
145
|
+
),
|
|
146
|
+
),
|
|
134
147
|
),
|
|
135
148
|
).pipe(Layer.provideMerge(client));
|
|
136
149
|
return storesLayer(options).pipe(Layer.provideMerge(migrated), Layer.provideMerge(ensured));
|
package/src/relay.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as SqlClient from "@effect/sql/SqlClient";
|
|
2
2
|
import type { SqlError } from "@effect/sql/SqlError";
|
|
3
|
-
import { Effect } from "effect";
|
|
3
|
+
import { Cause, Effect } from "effect";
|
|
4
4
|
import { NisshiClient } from "./protocol/client.js";
|
|
5
5
|
import type { NisshiApiError, NisshiConnectionError } from "./protocol/errors.js";
|
|
6
6
|
import { type SidecarOptions, sidecarTables } from "./sidecar.js";
|
|
@@ -64,13 +64,24 @@ export const drainPending = (
|
|
|
64
64
|
/** Runs `drainPending` forever, sleeping `pollInterval` between passes. */
|
|
65
65
|
export const runPendingRelay = (
|
|
66
66
|
options?: RelayOptions & { readonly pollInterval?: number },
|
|
67
|
-
): Effect.Effect<
|
|
67
|
+
): Effect.Effect<
|
|
68
|
+
never,
|
|
69
|
+
NisshiApiError | NisshiConnectionError | SqlError,
|
|
70
|
+
SqlClient.SqlClient | NisshiClient
|
|
71
|
+
> =>
|
|
68
72
|
Effect.gen(function* () {
|
|
69
73
|
const interval = options?.pollInterval ?? 500;
|
|
70
74
|
for (;;) {
|
|
71
75
|
yield* drainPending(options).pipe(
|
|
72
|
-
Effect.
|
|
73
|
-
|
|
76
|
+
Effect.catchAllCause((cause) => {
|
|
77
|
+
if (
|
|
78
|
+
Cause.isFailType(cause) &&
|
|
79
|
+
(cause.error._tag === "NisshiConnectionError" ||
|
|
80
|
+
(cause.error._tag === "NisshiApiError" && cause.error.retriable))
|
|
81
|
+
)
|
|
82
|
+
return Effect.logWarning("Nisshi relay will retry a transient broker failure");
|
|
83
|
+
return Effect.failCause(cause);
|
|
84
|
+
}),
|
|
74
85
|
);
|
|
75
86
|
yield* Effect.sleep(interval);
|
|
76
87
|
}
|