@structure-ai/eventsourcing-nisshi 0.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ligerian Labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,82 @@
1
+ # @structure-ai/eventsourcing-nisshi
2
+
3
+ [Nisshi](https://github.com/nisshi-io/nisshi) (Kafka-API compatible broker) as the event store, with a small SQL sidecar (SQLite or PostgreSQL via `@effect/sql`) for everything a log cannot do on its own: optimistic concurrency, snapshots, checkpoints, and inbox dedupe.
4
+
5
+ Implements **four** ports: `EventStore`, `SnapshotStore`, `CheckpointStore`, `Inbox`. There is **no `Outbox`** by design — see [ADR-0015](../../docs/decisions/0015-nisshi-event-store.md): the event topic itself is the publication; cross-context consumers read it directly and dedupe via `Inbox`.
6
+
7
+ ## How it works
8
+
9
+ - **Events** live in one single-partition topic (default `events`), key = stream name, value = a JSON envelope `{type, schemaVersion, version, payload, metadata}`. Infinite retention, no compaction — the topic *is* the raw history.
10
+ - **Positions** are Kafka offsets + 1 — a true global total order (single partition).
11
+ - **Optimistic concurrency** via the sidecar ledger: `append(stream, expectedVersion, events)` reserves `expectedVersion+1..n` in one SQL transaction (conditional UPDATE / unique INSERT), produces with `acks=all`, then confirms. A lost race fails with `ConcurrencyConflict` before anything is written. A crash between reservation and produce leaves pending rows that `drainPending` re-produces (at-least-once; readers dedupe by `(stream, version)`).
12
+ - **Snapshots / checkpoints / inbox** are sidecar tables (pure cache and dedupe state; losing them costs performance, never correctness).
13
+ - **Protocol**: an in-package minimal Kafka wire client (no runtime dependencies) pinned to non-flexible API versions — `Produce` v3, `Fetch` v4, `Metadata` v0, `CreateTopics` v4 — negotiated and verified at connect. No consumer groups, no transactions: reads are positioned fetches, progress tracking is the sidecar checkpoint.
14
+
15
+ ## Usage
16
+
17
+ SQLite sidecar:
18
+
19
+ ```ts
20
+ import { layer, runPendingRelay } from "@structure-ai/eventsourcing-nisshi";
21
+
22
+ const durable = layer({
23
+ brokerUrl: "tcp://127.0.0.1:9092", // must equal the broker's advertised listener
24
+ filename: "./sidecar.db", // ":memory:" for tests
25
+ topic: "events", // single partition, created when missing
26
+ });
27
+ ```
28
+
29
+ PostgreSQL sidecar:
30
+
31
+ ```ts
32
+ import { layerPg } from "@structure-ai/eventsourcing-nisshi";
33
+
34
+ const durable = layerPg({
35
+ brokerUrl: "tcp://127.0.0.1:9092",
36
+ url: "postgres://app:secret@db:5432/app", // defaults to DATABASE_URL
37
+ applicationName: "orders",
38
+ maxConnections: 10,
39
+ topic: "events",
40
+ });
41
+ ```
42
+
43
+ On an existing SQLite or PostgreSQL `SqlClient` plus `NisshiClient`, use `storesLayer(options)` and run `migrate(options)` first.
44
+
45
+ Run the orphan relay in a worker (or every app instance, it is idempotent):
46
+
47
+ ```ts
48
+ Effect.runFork(runPendingRelay({ pollInterval: 500 }));
49
+ ```
50
+
51
+ ## Options
52
+
53
+ | Option | Default | Notes |
54
+ | --- | --- | --- |
55
+ | `brokerUrl` | — | Broker listener; must match `--kafka-advertised-listener-url`. |
56
+ | `filename` (`layer`) | — | Sidecar SQLite file (`":memory:"` works). |
57
+ | `url` (`layerPg`) | `DATABASE_URL` / libpq defaults | Sidecar PostgreSQL connection URL. |
58
+ | `applicationName` / `maxConnections` (`layerPg`) | driver defaults | PostgreSQL pool options. |
59
+ | `topic` | `"events"` | Must have exactly one partition (verified; ADR-0015). |
60
+ | `createTopic` | `true` | Create the topic at layer start when missing. |
61
+ | `schemaValidation` | `true` | Validate envelopes client-side before produce. |
62
+ | `tablePrefix` | — | Namespace sidecar tables. |
63
+
64
+ ## Schema files (broker-side validation, forward path)
65
+
66
+ `writeSchemaFiles(dir, topics)` writes one JSON Schema (`<topic>.json`) per topic describing the envelope. Mount the directory with the broker's `--schema-registry file://./<dir>` (relative paths only — Nisshi collapses leading slashes). Note: broker-side enforcement did **not** engage on Nisshi v0.7.0-pre.2 (its own CLI accepts records violating its own sample schemas); client-side validation is the effective guard until a Nisshi release enforces it.
67
+
68
+ ## Production topologies
69
+
70
+ The **sidecar** may be SQLite (`layer`) or PostgreSQL (`layerPg`). Use PostgreSQL when multiple app instances can command the same aggregate: the concurrency ledger must be shared. A process-local SQLite sidecar is only safe for a single writer instance.
71
+
72
+ Separately, Nisshi backs the broker with pluggable storage:
73
+
74
+ - **PostgreSQL** — `nisshi --storage-engine postgres://user:pass@host:5432/db`
75
+ - **S3-compatible** — `nisshi --storage-engine s3://bucket/prefix` (11-nines durability class)
76
+ - **libSQL/SQLite** — `nisshi --storage-engine sqlite://nisshi.db` (single host)
77
+
78
+ The sidecar and broker storage may share one PostgreSQL server, but use separate schemas/credentials and backup policies. Run one broker process per availability need — brokers are stateless, all are leaders; durability comes from the storage engine, not broker replication. Set `--kafka-advertised-listener-url` to the address clients dial.
79
+
80
+ ## Tests
81
+
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`.
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@structure-ai/eventsourcing-nisshi",
3
+ "version": "0.0.10",
4
+ "description": "Nisshi (Kafka-API broker) event store with a SQL sidecar for optimistic concurrency, snapshots, checkpoints, and inbox dedupe.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/Ligerian-labs/structure.git",
10
+ "directory": "packages/eventsourcing-nisshi"
11
+ },
12
+ "exports": {
13
+ ".": "./src/index.ts"
14
+ },
15
+ "files": [
16
+ "src",
17
+ "README.md"
18
+ ],
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "scripts": {
23
+ "typecheck": "tsc --noEmit",
24
+ "test": "bun test"
25
+ },
26
+ "dependencies": {
27
+ "effect": "^3.22.1",
28
+ "@effect/sql": "^0.52.1",
29
+ "@effect/sql-sqlite-bun": "^0.53.0",
30
+ "@effect/sql-pg": "^0.53.0",
31
+ "@structure-ai/domain": "0.0.10",
32
+ "@structure-ai/eventsourcing": "0.0.10"
33
+ },
34
+ "devDependencies": {
35
+ "typescript": "^5.9.2",
36
+ "@types/bun": "^1.3.14"
37
+ }
38
+ }
@@ -0,0 +1,337 @@
1
+ import * as SqlClient from "@effect/sql/SqlClient";
2
+ import type { SqlError } from "@effect/sql/SqlError";
3
+ import { ConcurrencyConflict } from "@structure-ai/domain";
4
+ import {
5
+ type AppendEvent,
6
+ type AppendResult,
7
+ EventStore,
8
+ type EventStoreService,
9
+ type StoredEvent,
10
+ type StoredEventMetadata,
11
+ } from "@structure-ai/eventsourcing";
12
+ import { Effect, Layer, Stream } from "effect";
13
+ import { decodeWireEvent, encodeWireEvent, validateWireEvent } from "./envelope.js";
14
+ import { NisshiClient } from "./protocol/client.js";
15
+ import { NisshiProduceError } from "./protocol/errors.js";
16
+ import { type SidecarOptions, type SidecarTables, sidecarTables } from "./sidecar.js";
17
+
18
+ /** Options for the Nisshi event-store adapter. */
19
+ export interface EventStoreOptions extends SidecarOptions {
20
+ /** Topic holding all streams' events (single partition; default `events`). */
21
+ readonly topic?: string;
22
+ /** Validate the envelope client-side before produce (default: true). */
23
+ readonly schemaValidation?: boolean;
24
+ }
25
+
26
+ /** Whether a `SqlError` is a unique-constraint violation (both dialects). */
27
+ const isUniqueViolation = (error: unknown): boolean => {
28
+ if (typeof error !== "object" || error === null || !("cause" in error)) {
29
+ return false;
30
+ }
31
+ const cause: unknown = error.cause;
32
+ if (typeof cause !== "object" || cause === null || !("message" in cause)) {
33
+ return false;
34
+ }
35
+ const message = cause.message;
36
+ return (
37
+ typeof message === "string" &&
38
+ (message.includes("UNIQUE constraint failed") || message.includes("duplicate key value"))
39
+ );
40
+ };
41
+
42
+ const toNumber = (value: number | bigint | string | null | undefined): number =>
43
+ value === null || value === undefined ? 0 : Number(value);
44
+
45
+ /** Splits a stream name into the conflict's entity/id at the first `-`. */
46
+ const conflictIdentity = (streamName: string): { entity: string; id: string } => {
47
+ const separator = streamName.indexOf("-");
48
+ return separator === -1
49
+ ? { entity: streamName, id: streamName }
50
+ : { entity: streamName.slice(0, separator), id: streamName.slice(separator + 1) };
51
+ };
52
+
53
+ const conflict = (
54
+ streamName: string,
55
+ expectedVersion: number,
56
+ actualVersion: number,
57
+ ): ConcurrencyConflict => {
58
+ const { entity, id } = conflictIdentity(streamName);
59
+ return new ConcurrencyConflict({ entity, id, expectedVersion, actualVersion });
60
+ };
61
+
62
+ const storedEvent = (
63
+ offset: bigint,
64
+ streamName: string,
65
+ envelope: {
66
+ readonly type: string;
67
+ readonly schemaVersion: number;
68
+ readonly version: number;
69
+ readonly payload: unknown;
70
+ readonly metadata: StoredEventMetadata;
71
+ },
72
+ ): StoredEvent => ({
73
+ position: offset + 1n,
74
+ streamName,
75
+ version: envelope.version,
76
+ type: envelope.type,
77
+ schemaVersion: envelope.schemaVersion,
78
+ payload: envelope.payload,
79
+ metadata: envelope.metadata,
80
+ });
81
+
82
+ const make = (
83
+ options: EventStoreOptions,
84
+ ): Effect.Effect<EventStoreService, never, SqlClient.SqlClient | NisshiClient> =>
85
+ Effect.gen(function* () {
86
+ const sql = yield* SqlClient.SqlClient;
87
+ const client = yield* NisshiClient;
88
+ const tables: SidecarTables = sidecarTables(options);
89
+ const topic = options.topic ?? "events";
90
+ const validate = options.schemaValidation ?? true;
91
+ const maxBytes = 4 * 1024 * 1024;
92
+
93
+ const currentVersion = (streamName: string): Effect.Effect<number, SqlError> =>
94
+ Effect.map(
95
+ sql<{ readonly last_version: number | bigint | string | null }>`
96
+ SELECT last_version FROM ${sql(tables.streams)} WHERE stream_name = ${streamName}
97
+ `,
98
+ (rows) => toNumber(rows[0]?.last_version),
99
+ );
100
+
101
+ /**
102
+ * Reserves `expectedVersion + 1 .. + n` for the stream and stages the
103
+ * wire events as pending rows — one transaction. The unique PK on
104
+ * `(stream_name, version)` plus the conditional UPDATE make double
105
+ * reservations impossible; both surface as `ConcurrencyConflict`.
106
+ */
107
+ const reserve = (
108
+ streamName: string,
109
+ expectedVersion: number,
110
+ events: ReadonlyArray<AppendEvent>,
111
+ ): Effect.Effect<AppendResult, ConcurrencyConflict | SqlError> =>
112
+ sql
113
+ .withTransaction(
114
+ Effect.gen(function* () {
115
+ if (events.length === 0) {
116
+ const actual = yield* currentVersion(streamName);
117
+ if (actual !== expectedVersion) {
118
+ return yield* Effect.fail(conflict(streamName, expectedVersion, actual));
119
+ }
120
+ return { firstVersion: expectedVersion, lastVersion: expectedVersion };
121
+ }
122
+ if (expectedVersion === 0) {
123
+ yield* sql`
124
+ INSERT INTO ${sql(tables.streams)} (stream_name, last_version)
125
+ VALUES (${streamName}, ${events.length})
126
+ `;
127
+ } else {
128
+ // Conditional CAS: bumps only when the observed version matches.
129
+ yield* sql`
130
+ UPDATE ${sql(tables.streams)}
131
+ SET last_version = last_version + ${events.length}
132
+ WHERE stream_name = ${streamName} AND last_version = ${expectedVersion}
133
+ `;
134
+ }
135
+ // Authoritative CAS check: re-read after the write attempt.
136
+ const actual = yield* currentVersion(streamName);
137
+ const intended = expectedVersion + events.length;
138
+ if (actual !== intended) {
139
+ return yield* Effect.fail(conflict(streamName, expectedVersion, actual));
140
+ }
141
+ yield* Effect.forEach(
142
+ events,
143
+ (event, index) => {
144
+ const wire = {
145
+ type: event.type,
146
+ schemaVersion: event.schemaVersion,
147
+ version: expectedVersion + index + 1,
148
+ payload: event.payload,
149
+ metadata: event.metadata,
150
+ };
151
+ if (validate) {
152
+ validateWireEvent(wire, streamName);
153
+ }
154
+ return sql`
155
+ INSERT INTO ${sql(tables.pending)} (stream_name, version, topic, record_value)
156
+ VALUES (${streamName}, ${expectedVersion + index + 1}, ${topic}, ${JSON.stringify(wire)})
157
+ `;
158
+ },
159
+ { discard: true },
160
+ );
161
+ return {
162
+ firstVersion: expectedVersion + 1,
163
+ lastVersion: expectedVersion + events.length,
164
+ };
165
+ }),
166
+ )
167
+ .pipe(
168
+ Effect.catchIf(
169
+ (error): error is SqlError => isUniqueViolation(error),
170
+ () =>
171
+ Effect.flatMap(Effect.orDie(currentVersion(streamName)), (actual) =>
172
+ Effect.fail(conflict(streamName, expectedVersion, actual)),
173
+ ),
174
+ ),
175
+ );
176
+
177
+ /** Best-effort reservation rollback: only succeeds if nobody built on top. Never masks the original failure. */
178
+ const rollback = (
179
+ streamName: string,
180
+ expectedVersion: number,
181
+ count: number,
182
+ ): Effect.Effect<void> =>
183
+ sql
184
+ .withTransaction(
185
+ Effect.gen(function* () {
186
+ yield* sql`
187
+ UPDATE ${sql(tables.streams)}
188
+ SET last_version = ${expectedVersion}
189
+ WHERE stream_name = ${streamName} AND last_version = ${expectedVersion + count}
190
+ `;
191
+ yield* sql`
192
+ DELETE FROM ${sql(tables.pending)}
193
+ WHERE stream_name = ${streamName}
194
+ AND version > ${expectedVersion} AND version <= ${expectedVersion + count}
195
+ `;
196
+ }),
197
+ )
198
+ .pipe(
199
+ Effect.ignore,
200
+ Effect.catchAllDefect(() => Effect.void),
201
+ );
202
+
203
+ const append: EventStoreService["append"] = (streamName, expectedVersion, events) =>
204
+ Effect.gen(function* () {
205
+ const result = yield* reserve(streamName, expectedVersion, events).pipe(
206
+ Effect.catchTag("SqlError", (error) => Effect.die(error)),
207
+ );
208
+ if (events.length === 0) {
209
+ return result;
210
+ }
211
+ const records = events.map((event, index) => ({
212
+ key: new TextEncoder().encode(streamName),
213
+ value: encodeWireEvent({
214
+ type: event.type,
215
+ schemaVersion: event.schemaVersion,
216
+ version: expectedVersion + index + 1,
217
+ payload: event.payload,
218
+ metadata: event.metadata,
219
+ }),
220
+ }));
221
+ yield* client.produce(topic, records).pipe(
222
+ Effect.catchAllCause((cause) =>
223
+ Effect.gen(function* () {
224
+ yield* rollback(streamName, expectedVersion, events.length);
225
+ return yield* Effect.die(new NisshiProduceError({ topic, cause }));
226
+ }),
227
+ ),
228
+ );
229
+ // Confirm: events are durable in the topic. A failure here leaves the
230
+ // pending rows for the relay; a re-produce duplicates are tolerated
231
+ // downstream (readers dedupe by version).
232
+ yield* sql`
233
+ DELETE FROM ${sql(tables.pending)}
234
+ WHERE stream_name = ${streamName}
235
+ AND version > ${expectedVersion} AND version <= ${expectedVersion + events.length}
236
+ `.pipe(
237
+ Effect.catchTag("SqlError", (error) =>
238
+ Effect.logWarning(`pending confirm failed: ${error.message}`),
239
+ ),
240
+ );
241
+ return result;
242
+ });
243
+
244
+ /** Reads every committed record of the topic from `offset` on. */
245
+ interface TopicRecords {
246
+ readonly records: ReadonlyArray<{
247
+ readonly offset: bigint;
248
+ readonly streamName: string;
249
+ readonly value: Uint8Array;
250
+ }>;
251
+ }
252
+ const readTopic = (fromOffset: bigint): Effect.Effect<TopicRecords> =>
253
+ Effect.gen(function* () {
254
+ const out: { offset: bigint; streamName: string; value: Uint8Array }[] = [];
255
+ let offset = fromOffset;
256
+ for (;;) {
257
+ const page = yield* client.fetch(topic, offset, maxBytes).pipe(Effect.orDie);
258
+ if (page.records.length === 0) {
259
+ return { records: out };
260
+ }
261
+ for (const record of page.records) {
262
+ out.push({
263
+ offset: record.offset,
264
+ streamName: record.key === null ? "" : new TextDecoder().decode(record.key),
265
+ value: record.value,
266
+ });
267
+ }
268
+ const last = page.records[page.records.length - 1];
269
+ if (last === undefined || last.offset + 1n >= page.highWatermark) {
270
+ return { records: out };
271
+ }
272
+ offset = last.offset + 1n;
273
+ }
274
+ });
275
+
276
+ const service: EventStoreService = {
277
+ append,
278
+ read: (streamName, readOptions) =>
279
+ Stream.unwrap(
280
+ Effect.map(readTopic(0n), ({ records }) => {
281
+ const fromVersion = readOptions?.fromVersion ?? 1;
282
+ const seen = new Set<number>();
283
+ const events: StoredEvent[] = [];
284
+ for (const record of records) {
285
+ if (record.streamName !== streamName) {
286
+ continue;
287
+ }
288
+ const envelope = decodeWireEvent(record.value, streamName);
289
+ if (envelope.version < fromVersion || seen.has(envelope.version)) {
290
+ continue;
291
+ }
292
+ seen.add(envelope.version);
293
+ events.push(storedEvent(record.offset, streamName, envelope));
294
+ }
295
+ events.sort((a, b) => a.version - b.version);
296
+ return Stream.fromIterable(events);
297
+ }),
298
+ ),
299
+ readAll: (readOptions) =>
300
+ Stream.unwrap(
301
+ Effect.gen(function* () {
302
+ const events: StoredEvent[] = [];
303
+ let offset = (readOptions?.fromPosition ?? 1n) - 1n;
304
+ const limit = readOptions?.batchSize;
305
+ for (;;) {
306
+ if (limit !== undefined && events.length >= limit) {
307
+ break;
308
+ }
309
+ const page = yield* client.fetch(topic, offset, maxBytes).pipe(Effect.orDie);
310
+ if (page.records.length === 0) {
311
+ break;
312
+ }
313
+ for (const record of page.records) {
314
+ const streamName = record.key === null ? "" : new TextDecoder().decode(record.key);
315
+ events.push(
316
+ storedEvent(record.offset, streamName, decodeWireEvent(record.value, streamName)),
317
+ );
318
+ }
319
+ const last = page.records[page.records.length - 1];
320
+ if (last === undefined || last.offset + 1n >= page.highWatermark) {
321
+ break;
322
+ }
323
+ offset = last.offset + 1n;
324
+ }
325
+ const bounded = limit === undefined ? events : events.slice(0, limit);
326
+ return Stream.fromIterable(bounded);
327
+ }),
328
+ ),
329
+ };
330
+ return service;
331
+ });
332
+
333
+ /** `EventStore` over Nisshi + the sidecar ledger. */
334
+ export const eventStoreLayer = (
335
+ options?: EventStoreOptions,
336
+ ): Layer.Layer<EventStore, never, SqlClient.SqlClient | NisshiClient> =>
337
+ Layer.effect(EventStore, make(options ?? {}));
package/src/Stores.ts ADDED
@@ -0,0 +1,115 @@
1
+ import * as SqlClient from "@effect/sql/SqlClient";
2
+ import {
3
+ CheckpointStore,
4
+ type CheckpointStoreService,
5
+ Inbox,
6
+ type InboxService,
7
+ SnapshotStore,
8
+ type SnapshotStoreService,
9
+ } from "@structure-ai/eventsourcing";
10
+ import { Effect, Layer, Option } from "effect";
11
+ import { type SidecarOptions, sidecarTables } from "./sidecar.js";
12
+
13
+ const toBigInt = (value: number | bigint | string | null | undefined): bigint =>
14
+ value === null || value === undefined ? 0n : BigInt(value);
15
+
16
+ interface SnapshotRow {
17
+ readonly state: string;
18
+ readonly version: number | bigint | string;
19
+ }
20
+
21
+ /** `SnapshotStore` over the sidecar table: latest state per stream, replacing on save. */
22
+ export const snapshotStoreLayer = (
23
+ options?: SidecarOptions,
24
+ ): Layer.Layer<SnapshotStore, never, SqlClient.SqlClient> => {
25
+ const tables = sidecarTables(options);
26
+ return Layer.effect(
27
+ SnapshotStore,
28
+ Effect.gen(function* () {
29
+ const sql = yield* SqlClient.SqlClient;
30
+ const service: SnapshotStoreService = {
31
+ load: (streamName) =>
32
+ Effect.map(
33
+ sql<SnapshotRow>`
34
+ SELECT state, version FROM ${sql(tables.snapshots)} WHERE stream_name = ${streamName}
35
+ `.pipe(Effect.orDie),
36
+ (rows): Option.Option<{ state: unknown; version: number }> => {
37
+ const row = rows[0];
38
+ return row === undefined
39
+ ? Option.none()
40
+ : Option.some({
41
+ state: JSON.parse(row.state) as unknown,
42
+ version: Number(row.version),
43
+ });
44
+ },
45
+ ),
46
+ save: (streamName, snapshot) =>
47
+ Effect.asVoid(sql`
48
+ INSERT INTO ${sql(tables.snapshots)} (stream_name, state, version)
49
+ VALUES (${streamName}, ${JSON.stringify(snapshot.state ?? null)}, ${snapshot.version})
50
+ ON CONFLICT (stream_name) DO UPDATE SET state = excluded.state, version = excluded.version
51
+ `).pipe(Effect.orDie),
52
+ };
53
+ return SnapshotStore.of(service);
54
+ }),
55
+ );
56
+ };
57
+
58
+ /** `CheckpointStore` over the sidecar table: one bigint position per consumer name. */
59
+ export const checkpointStoreLayer = (
60
+ options?: SidecarOptions,
61
+ ): Layer.Layer<CheckpointStore, never, SqlClient.SqlClient> => {
62
+ const tables = sidecarTables(options);
63
+ return Layer.effect(
64
+ CheckpointStore,
65
+ Effect.gen(function* () {
66
+ const sql = yield* SqlClient.SqlClient;
67
+ const service: CheckpointStoreService = {
68
+ load: (name) =>
69
+ Effect.map(
70
+ sql<{ readonly position: number | bigint | string | null }>`
71
+ SELECT position FROM ${sql(tables.checkpoints)} WHERE name = ${name}
72
+ `.pipe(Effect.orDie),
73
+ (rows) => toBigInt(rows[0]?.position),
74
+ ),
75
+ save: (name, position) =>
76
+ Effect.asVoid(sql`
77
+ INSERT INTO ${sql(tables.checkpoints)} (name, position)
78
+ VALUES (${name}, ${position})
79
+ ON CONFLICT (name) DO UPDATE SET position = excluded.position
80
+ `).pipe(Effect.orDie),
81
+ };
82
+ return CheckpointStore.of(service);
83
+ }),
84
+ );
85
+ };
86
+
87
+ /** `Inbox` over the sidecar table: processed `(consumer, message)` pairs. */
88
+ export const inboxLayer = (
89
+ options?: SidecarOptions,
90
+ ): Layer.Layer<Inbox, never, SqlClient.SqlClient> => {
91
+ const tables = sidecarTables(options);
92
+ return Layer.effect(
93
+ Inbox,
94
+ Effect.gen(function* () {
95
+ const sql = yield* SqlClient.SqlClient;
96
+ const service: InboxService = {
97
+ seen: (consumerId, messageId) =>
98
+ Effect.map(
99
+ sql<{ readonly one: number }>`
100
+ SELECT 1 AS one FROM ${sql(tables.inbox)}
101
+ WHERE consumer_id = ${consumerId} AND message_id = ${messageId}
102
+ `.pipe(Effect.orDie),
103
+ (rows) => rows.length > 0,
104
+ ),
105
+ markProcessed: (consumerId, messageId) =>
106
+ Effect.asVoid(sql`
107
+ INSERT INTO ${sql(tables.inbox)} (consumer_id, message_id)
108
+ VALUES (${consumerId}, ${messageId})
109
+ ON CONFLICT DO NOTHING
110
+ `).pipe(Effect.orDie),
111
+ };
112
+ return Inbox.of(service);
113
+ }),
114
+ );
115
+ };
@@ -0,0 +1,86 @@
1
+ import type { StoredEventMetadata } from "@structure-ai/eventsourcing";
2
+ import { NisshiProtocolError } from "./protocol/errors.js";
3
+
4
+ /**
5
+ * The JSON envelope persisted as the record value; the stream name is the
6
+ * record key. `version` travels inside the envelope because offsets are
7
+ * global per topic while versions number one stream's events.
8
+ */
9
+ export interface WireEvent {
10
+ readonly type: string;
11
+ readonly schemaVersion: number;
12
+ readonly version: number;
13
+ readonly payload: unknown;
14
+ readonly metadata: StoredEventMetadata;
15
+ }
16
+
17
+ const encoder = new TextEncoder();
18
+ const decoder = new TextDecoder();
19
+
20
+ /** Serializes the envelope; `undefined` payload collapses to `null`. */
21
+ export const encodeWireEvent = (event: WireEvent): Uint8Array =>
22
+ encoder.encode(JSON.stringify({ ...event, payload: event.payload ?? null }));
23
+
24
+ /** Parses a record value into an envelope, failing on malformed JSON. */
25
+ export const decodeWireEvent = (value: Uint8Array, streamName: string): WireEvent => {
26
+ let parsed: unknown;
27
+ try {
28
+ parsed = JSON.parse(decoder.decode(value));
29
+ } catch (cause) {
30
+ throw new NisshiProtocolError({
31
+ reason: `malformed event envelope for ${streamName}: ${String(cause)}`,
32
+ });
33
+ }
34
+ if (typeof parsed !== "object" || parsed === null) {
35
+ throw new NisshiProtocolError({ reason: `event envelope for ${streamName} is not an object` });
36
+ }
37
+ const record = parsed as Record<string, unknown>;
38
+ const { type, schemaVersion, version, payload, metadata } = record;
39
+ if (
40
+ typeof type !== "string" ||
41
+ typeof schemaVersion !== "number" ||
42
+ typeof version !== "number"
43
+ ) {
44
+ throw new NisshiProtocolError({
45
+ reason: `event envelope for ${streamName} misses typed fields`,
46
+ });
47
+ }
48
+ if (typeof metadata !== "object" || metadata === null) {
49
+ throw new NisshiProtocolError({
50
+ reason: `event envelope for ${streamName} has no metadata object`,
51
+ });
52
+ }
53
+ return {
54
+ type,
55
+ schemaVersion,
56
+ version,
57
+ payload: payload ?? null,
58
+ metadata: metadata as StoredEventMetadata,
59
+ };
60
+ };
61
+
62
+ /**
63
+ * Minimal client-side envelope validation (defense in depth before produce).
64
+ * Broker-side validation is stronger and optional — see `writeSchemaFiles`.
65
+ */
66
+ export const validateWireEvent = (event: WireEvent, streamName: string): void => {
67
+ if (event.type.length === 0) {
68
+ throw new NisshiProtocolError({ reason: `event for ${streamName} has an empty type` });
69
+ }
70
+ if (!Number.isSafeInteger(event.schemaVersion) || event.schemaVersion < 1) {
71
+ throw new NisshiProtocolError({
72
+ reason: `event ${event.type} for ${streamName} has schemaVersion ${event.schemaVersion}`,
73
+ });
74
+ }
75
+ if (!Number.isSafeInteger(event.version) || event.version < 1) {
76
+ throw new NisshiProtocolError({
77
+ reason: `event ${event.type} for ${streamName} has version ${event.version}`,
78
+ });
79
+ }
80
+ const occurredAt = (event.metadata as Record<string, unknown>).occurredAt;
81
+ if (typeof occurredAt !== "string" || occurredAt.length === 0) {
82
+ throw new NisshiProtocolError({
83
+ reason: `event ${event.type} for ${streamName} misses metadata.occurredAt`,
84
+ });
85
+ }
86
+ };