@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/src/index.ts ADDED
@@ -0,0 +1,44 @@
1
+ export { type EventStoreOptions, eventStoreLayer } from "./EventStore.js";
2
+ export {
3
+ decodeWireEvent,
4
+ encodeWireEvent,
5
+ validateWireEvent,
6
+ type WireEvent,
7
+ } from "./envelope.js";
8
+ export {
9
+ layer,
10
+ layerPg,
11
+ type NisshiAdaptersConfig,
12
+ type NisshiPgConfig,
13
+ type StoreServices,
14
+ storesLayer,
15
+ } from "./layer.js";
16
+ export {
17
+ decodeRecordBatch,
18
+ encodeRecordBatch,
19
+ type FetchedRecord,
20
+ type RecordToProduce,
21
+ } from "./protocol/batch.js";
22
+ export {
23
+ type FetchPage,
24
+ NisshiClient,
25
+ type NisshiClientService,
26
+ nisshiClientLayer,
27
+ } from "./protocol/client.js";
28
+ export {
29
+ ApiKey,
30
+ type NisshiConnection,
31
+ openConnection,
32
+ PinnedVersion,
33
+ } from "./protocol/connection.js";
34
+ export {
35
+ NisshiApiError,
36
+ NisshiConnectionError,
37
+ NisshiProduceError,
38
+ NisshiProtocolError,
39
+ NisshiTopicConfigurationError,
40
+ } from "./protocol/errors.js";
41
+ export { drainPending, type RelayOptions, runPendingRelay } from "./relay.js";
42
+ export { checkpointStoreLayer, inboxLayer, snapshotStoreLayer } from "./Stores.js";
43
+ export { envelopeJsonSchema, writeSchemaFiles } from "./schemas.js";
44
+ export { migrate, type SidecarOptions, type SidecarTables, sidecarTables } from "./sidecar.js";
package/src/layer.ts ADDED
@@ -0,0 +1,137 @@
1
+ import type * as SqlClient from "@effect/sql/SqlClient";
2
+ import type { SqlError } from "@effect/sql/SqlError";
3
+ import { PgClient } from "@effect/sql-pg";
4
+ import { SqliteClient } from "@effect/sql-sqlite-bun";
5
+ import type {
6
+ CheckpointStore,
7
+ EventStore,
8
+ Inbox,
9
+ SnapshotStore,
10
+ } from "@structure-ai/eventsourcing";
11
+ import { Effect, Layer, Redacted } from "effect";
12
+ import type { ConfigError } from "effect/ConfigError";
13
+ import { type EventStoreOptions, eventStoreLayer } from "./EventStore.js";
14
+ import { NisshiClient, nisshiClientLayer } from "./protocol/client.js";
15
+ import type { NisshiConnectionError } from "./protocol/errors.js";
16
+ import { checkpointStoreLayer, inboxLayer, snapshotStoreLayer } from "./Stores.js";
17
+ import { migrate } from "./sidecar.js";
18
+
19
+ /**
20
+ * The four ports this package implements. There is no `Outbox` by design:
21
+ * the event topic itself is the publication (ADR-0015); apps needing
22
+ * arbitrary notifications run consumers that produce derived topics.
23
+ */
24
+ export type StoreServices = EventStore | SnapshotStore | CheckpointStore | Inbox;
25
+
26
+ /**
27
+ * All adapters over an existing `SqlClient` (the sidecar) and an existing
28
+ * `NisshiClient`. Run `migrate` first, or use `layer` or `layerPg`, which do.
29
+ */
30
+ export const storesLayer = (
31
+ options?: EventStoreOptions,
32
+ ): Layer.Layer<StoreServices, never, SqlClient.SqlClient | NisshiClient> =>
33
+ Layer.mergeAll(
34
+ eventStoreLayer(options),
35
+ snapshotStoreLayer(options),
36
+ checkpointStoreLayer(options),
37
+ inboxLayer(options),
38
+ );
39
+
40
+ /** Configuration for the all-in-one `layer`: broker plus sqlite sidecar. */
41
+ export interface NisshiAdaptersConfig extends EventStoreOptions {
42
+ /** Broker listener, e.g. `tcp://127.0.0.1:9092`. Must equal the broker's advertised listener. */
43
+ readonly brokerUrl: string;
44
+ /** Client id sent with every request (default `structure-nisshi`). */
45
+ readonly clientId?: string;
46
+ /** Per-request timeout in millis (default 10 000). */
47
+ readonly timeoutMillis?: number;
48
+ /** Sidecar database file, or `":memory:"` for an in-memory sidecar. */
49
+ readonly filename: string;
50
+ /** Create the topic (single partition) at layer start when missing (default true). */
51
+ readonly createTopic?: boolean;
52
+ }
53
+
54
+ /** Configuration for the all-in-one `layerPg`: broker plus PostgreSQL sidecar. */
55
+ export interface NisshiPgConfig extends EventStoreOptions {
56
+ /** Broker listener, e.g. `tcp://127.0.0.1:9092`. Must equal the broker's advertised listener. */
57
+ readonly brokerUrl: string;
58
+ /** Client id sent with every request (default `structure-nisshi`). */
59
+ readonly clientId?: string;
60
+ /** Per-request timeout in millis (default 10 000). */
61
+ readonly timeoutMillis?: number;
62
+ /**
63
+ * Postgres connection URL. Defaults to the `DATABASE_URL` environment
64
+ * variable; when neither is set, the client falls back to libpq-style
65
+ * defaults (localhost:5432, OS user).
66
+ */
67
+ readonly url?: string;
68
+ /** Maximum pool connections (driver default when omitted). */
69
+ readonly maxConnections?: number;
70
+ /** `application_name` reported to the server. */
71
+ readonly applicationName?: string;
72
+ /** Create the topic (single partition) at layer start when missing (default true). */
73
+ readonly createTopic?: boolean;
74
+ }
75
+
76
+ /**
77
+ * Everything in one layer over a PostgreSQL sidecar: broker connection, pg
78
+ * `SqlClient` (schema migration runs at layer build), topic verification,
79
+ * and the four port adapters. The sidecar client is exposed too.
80
+ */
81
+ export const layerPg = (
82
+ options: NisshiPgConfig,
83
+ ): Layer.Layer<
84
+ StoreServices | PgClient.PgClient | SqlClient.SqlClient | NisshiClient,
85
+ SqlError | NisshiConnectionError
86
+ > => {
87
+ const client = nisshiClientLayer({
88
+ brokerUrl: options.brokerUrl,
89
+ clientId: options.clientId,
90
+ timeoutMillis: options.timeoutMillis,
91
+ });
92
+ const url = options.url ?? process.env.DATABASE_URL;
93
+ const sidecar = PgClient.layer({
94
+ ...(url !== undefined ? { url: Redacted.make(url) } : {}),
95
+ ...(options.maxConnections !== undefined ? { maxConnections: options.maxConnections } : {}),
96
+ ...(options.applicationName !== undefined ? { applicationName: options.applicationName } : {}),
97
+ });
98
+ const migrated = Layer.effectDiscard(migrate(options)).pipe(Layer.provideMerge(sidecar));
99
+ const ensured =
100
+ options.createTopic === false
101
+ ? client
102
+ : Layer.effectDiscard(
103
+ Effect.flatMap(NisshiClient, (c) =>
104
+ c.ensureTopic(options.topic ?? "events").pipe(Effect.orDie),
105
+ ),
106
+ ).pipe(Layer.provideMerge(client));
107
+ return storesLayer(options).pipe(Layer.provideMerge(migrated), Layer.provideMerge(ensured));
108
+ };
109
+ /**
110
+ * Everything in one layer over a sqlite sidecar: broker connection, sqlite
111
+ * `SqlClient` (schema migration runs at layer build), topic verification,
112
+ * and the four port adapters. The sidecar `SqlClient` is exposed for
113
+ * callers' own queries. For a PostgreSQL sidecar use `layerPg`.
114
+ */
115
+ export const layer = (
116
+ options: NisshiAdaptersConfig,
117
+ ): Layer.Layer<
118
+ StoreServices | SqliteClient.SqliteClient | SqlClient.SqlClient | NisshiClient,
119
+ ConfigError | SqlError | NisshiConnectionError
120
+ > => {
121
+ const client = nisshiClientLayer({
122
+ brokerUrl: options.brokerUrl,
123
+ clientId: options.clientId,
124
+ timeoutMillis: options.timeoutMillis,
125
+ });
126
+ const sidecar = SqliteClient.layer({ filename: options.filename });
127
+ const migrated = Layer.effectDiscard(migrate(options)).pipe(Layer.provideMerge(sidecar));
128
+ const ensured =
129
+ options.createTopic === false
130
+ ? client
131
+ : Layer.effectDiscard(
132
+ Effect.flatMap(NisshiClient, (c) =>
133
+ c.ensureTopic(options.topic ?? "events").pipe(Effect.orDie),
134
+ ),
135
+ ).pipe(Layer.provideMerge(client));
136
+ return storesLayer(options).pipe(Layer.provideMerge(migrated), Layer.provideMerge(ensured));
137
+ };
@@ -0,0 +1,126 @@
1
+ import { crc32c, Reader, Writer } from "./primitives.js";
2
+
3
+ /** One record to produce: byte key (may be null) and byte value. */
4
+ export interface RecordToProduce {
5
+ readonly key: Uint8Array | null;
6
+ readonly value: Uint8Array;
7
+ }
8
+
9
+ /** One record read back: its absolute offset plus key and value bytes. */
10
+ export interface FetchedRecord {
11
+ readonly offset: bigint;
12
+ readonly key: Uint8Array | null;
13
+ readonly value: Uint8Array;
14
+ }
15
+
16
+ /**
17
+ * Encodes one uncompressed message-format-v2 record batch. Offsets inside the
18
+ * batch are `0..n-1` deltas; the broker assigns the base offset. The CRC
19
+ * covers everything from attributes to the end of the batch.
20
+ */
21
+ export const encodeRecordBatch = (records: ReadonlyArray<RecordToProduce>): Uint8Array => {
22
+ if (records.length === 0) {
23
+ throw new Error("kafka protocol: empty record batch");
24
+ }
25
+ const now = BigInt(Date.now());
26
+ const encoded = new Writer();
27
+ for (let i = 0; i < records.length; i++) {
28
+ const record = records[i];
29
+ if (record === undefined) {
30
+ continue;
31
+ }
32
+ const body = new Writer()
33
+ .i8(0) // record attributes (unused, must be 0)
34
+ .varint(0) // timestamp delta
35
+ .varint(i); // offset delta
36
+ if (record.key === null) {
37
+ body.varint(-1);
38
+ } else {
39
+ body.varint(record.key.length).raw(record.key);
40
+ }
41
+ body.varint(record.value.length).raw(record.value).varint(0); // headers
42
+ const bodyBytes = body.out();
43
+ encoded.varint(bodyBytes.length).raw(bodyBytes);
44
+ }
45
+
46
+ const batchBody = new Writer()
47
+ .i16(0) // attributes: no compression, no flags
48
+ .i32(records.length - 1) // lastOffsetDelta
49
+ .i64(now) // firstTimestamp
50
+ .i64(now) // maxTimestamp
51
+ .i64(-1) // producerId
52
+ .i16(-1) // producerEpoch
53
+ .i32(-1) // baseSequence
54
+ .i32(records.length)
55
+ .raw(encoded.out());
56
+ const bodyBytes = batchBody.out();
57
+
58
+ return new Writer()
59
+ .i64(0) // baseOffset (assigned by the broker)
60
+ .i32(4 + 1 + 4 + bodyBytes.length) // batchLength: from partitionLeaderEpoch on
61
+ .i32(-1) // partitionLeaderEpoch
62
+ .i8(2) // magic
63
+ .i32(crc32c(bodyBytes))
64
+ .raw(bodyBytes)
65
+ .out();
66
+ };
67
+
68
+ /**
69
+ * Decodes every record of one message-format-v2 batch starting at `buf[0]`,
70
+ * projecting each record's absolute offset from the batch base offset.
71
+ * Batches from Nisshi are always uncompressed; a compressed batch is an
72
+ * error rather than a silent skip.
73
+ */
74
+ export const decodeRecordBatch = (buf: Uint8Array): ReadonlyArray<FetchedRecord> => {
75
+ if (buf.length === 0) {
76
+ return []; // Nisshi encodes "no records" as a zero-length field
77
+ }
78
+ const r = new Reader(buf);
79
+ const baseOffset = r.i64();
80
+ r.i32(); // batchLength
81
+ r.i32(); // partitionLeaderEpoch
82
+ const magic = r.i8();
83
+ if (magic !== 2) {
84
+ throw new Error(`kafka protocol: unsupported record batch magic ${magic}`);
85
+ }
86
+ r.u32(); // crc
87
+ const attributes = r.i16();
88
+ if ((attributes & 0x07) !== 0) {
89
+ throw new Error("kafka protocol: compressed record batches are not supported");
90
+ }
91
+ r.i32(); // lastOffsetDelta
92
+ r.i64(); // firstTimestamp
93
+ r.i64(); // maxTimestamp
94
+ r.i64(); // producerId
95
+ r.i16(); // producerEpoch
96
+ r.i32(); // baseSequence
97
+ const count = r.i32();
98
+ const out: FetchedRecord[] = [];
99
+ for (let i = 0; i < count; i++) {
100
+ const bodyLength = r.varint();
101
+ const end = r.pos + bodyLength;
102
+ r.i8(); // attributes
103
+ r.varint(); // timestamp delta
104
+ const offsetDelta = r.varint();
105
+ const keyLength = r.varint();
106
+ const key = keyLength < 0 ? null : buf.subarray(r.pos, r.pos + keyLength);
107
+ if (keyLength >= 0) {
108
+ r.pos += keyLength;
109
+ }
110
+ const valueLength = r.varint();
111
+ const value = buf.subarray(r.pos, r.pos + valueLength);
112
+ r.pos += valueLength;
113
+ const headers = r.varint();
114
+ for (let h = 0; h < headers; h++) {
115
+ const headerKeyLength = r.varint();
116
+ r.pos += headerKeyLength;
117
+ const headerValueLength = r.varint();
118
+ if (headerValueLength >= 0) {
119
+ r.pos += headerValueLength;
120
+ }
121
+ }
122
+ out.push({ offset: baseOffset + BigInt(offsetDelta), key, value });
123
+ r.pos = end;
124
+ }
125
+ return out;
126
+ };
@@ -0,0 +1,340 @@
1
+ import { Context, Effect, Layer } from "effect";
2
+ import {
3
+ decodeRecordBatch,
4
+ encodeRecordBatch,
5
+ type FetchedRecord,
6
+ type RecordToProduce,
7
+ } from "./batch.js";
8
+ import { ApiKey, type NisshiConnection, openConnection, PinnedVersion } from "./connection.js";
9
+ import {
10
+ apiError,
11
+ NisshiApiError,
12
+ type NisshiConnectionError,
13
+ NisshiTopicConfigurationError,
14
+ } from "./errors.js";
15
+ import { Reader } from "./primitives.js";
16
+
17
+ /** Kafka error codes this client interprets specially. */
18
+ const ErrorCode = {
19
+ none: 0,
20
+ unknownTopicOrPartition: 3,
21
+ topicAlreadyExists: 36,
22
+ } as const;
23
+
24
+ /** One page of fetch results plus the partition high watermark (log end offset). */
25
+ export interface FetchPage {
26
+ readonly records: ReadonlyArray<FetchedRecord>;
27
+ /** Offset of the next record to be written — read up to `highWatermark - 1`. */
28
+ readonly highWatermark: bigint;
29
+ }
30
+
31
+ /** The wire-level client: single broker, single partition per topic. */
32
+ export interface NisshiClientService {
33
+ /** Creates `topic` with one partition; an existing topic is fine if it has exactly one. */
34
+ readonly ensureTopic: (
35
+ topic: string,
36
+ ) => Effect.Effect<void, NisshiTopicConfigurationError | NisshiApiError | NisshiConnectionError>;
37
+ /** Lists topic names known to the broker. */
38
+ readonly listTopics: () => Effect.Effect<
39
+ ReadonlyArray<string>,
40
+ NisshiApiError | NisshiConnectionError
41
+ >;
42
+ /**
43
+ * Produces `records` in one batch with acks=all; returns the base offset the
44
+ * broker assigned. Records are ordered within the batch.
45
+ */
46
+ readonly produce: (
47
+ topic: string,
48
+ records: ReadonlyArray<RecordToProduce>,
49
+ ) => Effect.Effect<bigint, NisshiApiError | NisshiConnectionError>;
50
+ /**
51
+ * Reads one page of records starting at `offset` (inclusive), up to
52
+ * `maxBytes` of batch payload. Returns an empty page when caught up.
53
+ */
54
+ readonly fetch: (
55
+ topic: string,
56
+ offset: bigint,
57
+ maxBytes: number,
58
+ ) => Effect.Effect<FetchPage, NisshiApiError | NisshiConnectionError>;
59
+ /**
60
+ * The log end offset: the offset the *next* record will get. Note Nisshi's
61
+ * ListOffsets(LATEST) reports the last written record's offset — this
62
+ * method normalizes the difference (and is covered by tests).
63
+ */
64
+ readonly endOffset: (
65
+ topic: string,
66
+ ) => Effect.Effect<bigint, NisshiApiError | NisshiConnectionError>;
67
+ }
68
+
69
+ /** Service tag for the Nisshi wire client. */
70
+ export class NisshiClient extends Context.Tag("@structure-ai/eventsourcing-nisshi/NisshiClient")<
71
+ NisshiClient,
72
+ NisshiClientService
73
+ >() {}
74
+
75
+ const make = (brokerUrl: string, clientId: string, timeoutMillis: number) =>
76
+ Effect.gen(function* () {
77
+ const connection: NisshiConnection = yield* openConnection(brokerUrl, clientId);
78
+ const { request } = connection;
79
+
80
+ const partitionCount = (topic: string) =>
81
+ Effect.map(
82
+ request(
83
+ ApiKey.metadata,
84
+ PinnedVersion.metadata,
85
+ (w) => {
86
+ w.i32(1); // one requested topic
87
+ w.str(topic);
88
+ },
89
+ timeoutMillis,
90
+ ),
91
+ (body): number => {
92
+ const r = new Reader(body);
93
+ const brokers = r.i32();
94
+ for (let i = 0; i < brokers; i++) {
95
+ r.i32();
96
+ r.str();
97
+ r.i32();
98
+ }
99
+ const topics = r.i32();
100
+ for (let i = 0; i < topics; i++) {
101
+ const errorCode = r.i16();
102
+ const name = r.str();
103
+ const partitions = r.i32();
104
+ const count = partitions;
105
+ for (let p = 0; p < partitions; p++) {
106
+ r.i16(); // partition_error_code
107
+ r.i32(); // partition_id
108
+ r.i32(); // leader_id
109
+ const replicas = r.i32();
110
+ r.pos += replicas * 4;
111
+ const isr = r.i32();
112
+ r.pos += isr * 4;
113
+ }
114
+ if (errorCode === ErrorCode.none && name === topic) {
115
+ return count;
116
+ }
117
+ }
118
+ return -1;
119
+ },
120
+ );
121
+
122
+ const ensureTopic: NisshiClientService["ensureTopic"] = (topic) =>
123
+ Effect.gen(function* () {
124
+ // Create FIRST, verify second: requesting Metadata for an unknown
125
+ // topic makes Nisshi auto-create it — with FOUR partitions. Never
126
+ // probe for a topic that may not exist.
127
+ const body = yield* request(
128
+ ApiKey.createTopics,
129
+ PinnedVersion.createTopics,
130
+ (w) => {
131
+ w.i32(1); // one topic
132
+ w.str(topic);
133
+ w.i32(1); // num_partitions
134
+ w.i16(1); // replication_factor
135
+ w.i32(0); // assignments
136
+ w.i32(0); // configs
137
+ w.i32(timeoutMillis); // timeout
138
+ w.i8(0); // validate_only = false
139
+ },
140
+ timeoutMillis,
141
+ );
142
+ const r = new Reader(body);
143
+ // Nisshi's CreateTopics v4 response leads with throttle_time_ms.
144
+ r.i32(); // throttle_time_ms
145
+ const count = r.i32();
146
+ for (let i = 0; i < count; i++) {
147
+ const name = r.str();
148
+ const code = r.i16();
149
+ r.str(); // message
150
+ if (name === topic && code !== ErrorCode.none && code !== ErrorCode.topicAlreadyExists) {
151
+ return yield* Effect.fail(
152
+ apiError(code, `CreateTopics(${topic}) failed with code ${code}`),
153
+ );
154
+ }
155
+ }
156
+ const created = yield* partitionCount(topic);
157
+ if (created !== 1) {
158
+ return yield* Effect.fail(
159
+ new NisshiTopicConfigurationError({
160
+ topic,
161
+ reason: `topic reports ${created} partitions; the single-partition contract requires exactly 1 (see ADR-0015)`,
162
+ }),
163
+ );
164
+ }
165
+ });
166
+
167
+ const listTopics: NisshiClientService["listTopics"] = () =>
168
+ Effect.map(
169
+ request(
170
+ ApiKey.metadata,
171
+ PinnedVersion.metadata,
172
+ (w) => {
173
+ w.i32(-1); // all topics
174
+ },
175
+ timeoutMillis,
176
+ ),
177
+ (body): ReadonlyArray<string> => {
178
+ const r = new Reader(body);
179
+ const brokers = r.i32();
180
+ for (let i = 0; i < brokers; i++) {
181
+ r.i32();
182
+ r.str();
183
+ r.i32();
184
+ }
185
+ const topics = r.i32();
186
+ const names: string[] = [];
187
+ for (let i = 0; i < topics; i++) {
188
+ const errorCode = r.i16();
189
+ const name = r.str() ?? "?";
190
+ const partitions = r.i32();
191
+ for (let p = 0; p < partitions; p++) {
192
+ r.i16(); // partition_error_code
193
+ r.i32(); // partition_id
194
+ r.i32(); // leader_id
195
+ const replicas = r.i32();
196
+ r.pos += replicas * 4;
197
+ const isr = r.i32();
198
+ r.pos += isr * 4;
199
+ }
200
+ if (errorCode === ErrorCode.none) {
201
+ names.push(name);
202
+ }
203
+ }
204
+ return names;
205
+ },
206
+ );
207
+
208
+ const produce: NisshiClientService["produce"] = (topic, records) =>
209
+ Effect.gen(function* () {
210
+ const batch = encodeRecordBatch(records);
211
+ const body = yield* request(
212
+ ApiKey.produce,
213
+ PinnedVersion.produce,
214
+ (w) => {
215
+ w.str(null); // transactional id
216
+ w.i16(-1); // acks = all
217
+ w.i32(timeoutMillis);
218
+ w.i32(1); // one topic
219
+ w.str(topic);
220
+ w.i32(1); // one partition
221
+ w.i32(0); // partition 0 — single-partition contract
222
+ w.bytes(batch);
223
+ },
224
+ timeoutMillis,
225
+ );
226
+ // Produce v3 response: [topics [partitions]] then trailing throttle.
227
+ const r = new Reader(body);
228
+ const topics = r.i32();
229
+ for (let i = 0; i < topics; i++) {
230
+ const name = r.str();
231
+ const partitions = r.i32();
232
+ for (let p = 0; p < partitions; p++) {
233
+ r.i32(); // partition index
234
+ const code = r.i16();
235
+ const baseOffset = r.i64();
236
+ r.i64(); // log_append_time
237
+ if (name === topic) {
238
+ if (code !== ErrorCode.none) {
239
+ return yield* Effect.fail(
240
+ apiError(code, `Produce(${topic}) failed with code ${code}`),
241
+ );
242
+ }
243
+ return baseOffset;
244
+ }
245
+ }
246
+ }
247
+ return yield* Effect.fail(
248
+ new NisshiApiError({
249
+ code: -1,
250
+ message: `Produce(${topic}): no partition in response`,
251
+ retriable: true,
252
+ }),
253
+ );
254
+ });
255
+
256
+ const fetch: NisshiClientService["fetch"] = (topic, offset, maxBytes) =>
257
+ Effect.gen(function* () {
258
+ const body = yield* request(
259
+ ApiKey.fetch,
260
+ PinnedVersion.fetch,
261
+ (w) => {
262
+ w.i32(-1); // replica id
263
+ w.i32(250); // max wait (ms)
264
+ w.i32(1); // min bytes
265
+ w.i32(maxBytes); // max bytes
266
+ w.i8(0); // isolation level
267
+ w.i32(1); // one topic
268
+ w.str(topic);
269
+ w.i32(1); // one partition
270
+ w.i32(0); // partition 0
271
+ w.i64(offset);
272
+ w.i32(maxBytes);
273
+ },
274
+ timeoutMillis,
275
+ );
276
+ // Fetch v4 response: throttle, [topics [partitions]], records last.
277
+ const r = new Reader(body);
278
+ r.i32(); // throttle_time_ms
279
+ const topics = r.i32();
280
+ for (let i = 0; i < topics; i++) {
281
+ const name = r.str();
282
+ const partitions = r.i32();
283
+ for (let p = 0; p < partitions; p++) {
284
+ r.i32(); // partition index
285
+ const code = r.i16();
286
+ const highWatermark = r.i64();
287
+ r.i64(); // last stable offset
288
+ const aborted = r.i32();
289
+ if (aborted > 0) {
290
+ for (let a = 0; a < aborted; a++) {
291
+ r.i64();
292
+ r.i32();
293
+ }
294
+ }
295
+ const records = r.bytes();
296
+ if (name !== topic) {
297
+ continue;
298
+ }
299
+ if (code === ErrorCode.unknownTopicOrPartition) {
300
+ return { records: [], highWatermark: 0n };
301
+ }
302
+ if (code !== ErrorCode.none) {
303
+ return yield* Effect.fail(apiError(code, `Fetch(${topic}) failed with code ${code}`));
304
+ }
305
+ // Kafka returns the WHOLE batch containing the requested offset;
306
+ // drop the leading records below it.
307
+ const decoded = records === null ? [] : decodeRecordBatch(records);
308
+ return {
309
+ highWatermark,
310
+ records: decoded.filter((record) => record.offset >= offset),
311
+ };
312
+ }
313
+ }
314
+ return { records: [], highWatermark: 0n };
315
+ });
316
+
317
+ const endOffset: NisshiClientService["endOffset"] = (topic) =>
318
+ Effect.map(fetch(topic, 0n, 1), (page) =>
319
+ // An empty partition reports a high watermark of 1 on Nisshi — treat
320
+ // "no records at offset 0" as the true log end (0).
321
+ page.records.length === 0 ? 0n : page.highWatermark,
322
+ );
323
+
324
+ return { ensureTopic, listTopics, produce, fetch, endOffset } satisfies NisshiClientService;
325
+ });
326
+
327
+ /** Layer over one broker connection; the socket closes with the layer scope. */
328
+ export const nisshiClientLayer = (options: {
329
+ readonly brokerUrl: string;
330
+ readonly clientId?: string | undefined;
331
+ readonly timeoutMillis?: number | undefined;
332
+ }): Layer.Layer<NisshiClient, NisshiConnectionError> =>
333
+ Layer.scoped(
334
+ NisshiClient,
335
+ make(
336
+ options.brokerUrl,
337
+ options.clientId ?? "structure-nisshi",
338
+ options.timeoutMillis ?? 10_000,
339
+ ),
340
+ );