@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 +21 -0
- package/README.md +82 -0
- package/package.json +38 -0
- package/src/EventStore.ts +337 -0
- package/src/Stores.ts +115 -0
- package/src/envelope.ts +86 -0
- package/src/index.ts +44 -0
- package/src/layer.ts +137 -0
- package/src/protocol/batch.ts +126 -0
- package/src/protocol/client.ts +340 -0
- package/src/protocol/connection.ts +225 -0
- package/src/protocol/errors.ts +63 -0
- package/src/protocol/primitives.ts +251 -0
- package/src/relay.ts +77 -0
- package/src/schemas.ts +47 -0
- package/src/sidecar.ts +81 -0
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
|
+
);
|