@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
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { Socket } from "node:net";
|
|
2
|
+
import { Deferred, Effect, Ref } from "effect";
|
|
3
|
+
import type { Scope } from "effect/Scope";
|
|
4
|
+
import { NisshiConnectionError } from "./errors.js";
|
|
5
|
+
import { Reader, Writer } from "./primitives.js";
|
|
6
|
+
|
|
7
|
+
/** Kafka API keys this client speaks. */
|
|
8
|
+
export const ApiKey = {
|
|
9
|
+
produce: 0,
|
|
10
|
+
fetch: 1,
|
|
11
|
+
listOffsets: 2,
|
|
12
|
+
metadata: 3,
|
|
13
|
+
createTopics: 19,
|
|
14
|
+
apiVersions: 18,
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Protocol versions we pin — non-flexible framing, the simplest shape per
|
|
19
|
+
* API. Verified against Nisshi v0.7.0-pre.2. Notably CreateTopics v0 wedges
|
|
20
|
+
* the broker (missing assignments array); v4 is the safe choice.
|
|
21
|
+
*/
|
|
22
|
+
export const PinnedVersion = {
|
|
23
|
+
produce: 3,
|
|
24
|
+
fetch: 4,
|
|
25
|
+
listOffsets: 1,
|
|
26
|
+
metadata: 0,
|
|
27
|
+
createTopics: 4,
|
|
28
|
+
apiVersions: 0,
|
|
29
|
+
} as const;
|
|
30
|
+
|
|
31
|
+
/** A live, handshaked connection. Requests are serialized; responses match by correlation id. */
|
|
32
|
+
export interface NisshiConnection {
|
|
33
|
+
/** API key → [minVersion, maxVersion] as reported by the broker. */
|
|
34
|
+
readonly versions: ReadonlyMap<number, readonly [number, number]>;
|
|
35
|
+
/** Sends one request, awaits its response body (correlation id stripped). */
|
|
36
|
+
readonly request: (
|
|
37
|
+
apiKey: number,
|
|
38
|
+
apiVersion: number,
|
|
39
|
+
build: (writer: Writer) => void,
|
|
40
|
+
timeoutMillis: number,
|
|
41
|
+
) => Effect.Effect<Uint8Array, NisshiConnectionError>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface HostPort {
|
|
45
|
+
readonly host: string;
|
|
46
|
+
readonly port: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const parseUrl = (url: string): HostPort => {
|
|
50
|
+
const parsed = new URL(url);
|
|
51
|
+
const port = Number(parsed.port);
|
|
52
|
+
if (parsed.protocol !== "tcp:" || !parsed.hostname || Number.isNaN(port)) {
|
|
53
|
+
throw new Error(`nisshi: broker url must be tcp://host:port, got ${url}`);
|
|
54
|
+
}
|
|
55
|
+
return { host: parsed.hostname, port };
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const assertVersion = (
|
|
59
|
+
versions: ReadonlyMap<number, readonly [number, number]>,
|
|
60
|
+
name: string,
|
|
61
|
+
key: number,
|
|
62
|
+
pinned: number,
|
|
63
|
+
): void => {
|
|
64
|
+
const range = versions.get(key);
|
|
65
|
+
if (range === undefined) {
|
|
66
|
+
throw new NisshiConnectionError({ reason: `broker does not expose ${name} (api key ${key})` });
|
|
67
|
+
}
|
|
68
|
+
if (pinned < range[0] || pinned > range[1]) {
|
|
69
|
+
throw new NisshiConnectionError({
|
|
70
|
+
reason: `broker supports ${name} v${range[0]}-${range[1]}, client pins v${pinned}`,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Connects to `brokerUrl`, handshakes with ApiVersions, verifies every
|
|
77
|
+
* pinned version is offered, and returns the connection. The socket lives
|
|
78
|
+
* for the surrounding scope.
|
|
79
|
+
*/
|
|
80
|
+
export const openConnection = (
|
|
81
|
+
brokerUrl: string,
|
|
82
|
+
clientId: string,
|
|
83
|
+
): Effect.Effect<NisshiConnection, NisshiConnectionError, Scope> =>
|
|
84
|
+
Effect.gen(function* () {
|
|
85
|
+
const { host, port } = yield* Effect.sync(() => parseUrl(brokerUrl)).pipe(
|
|
86
|
+
Effect.mapError(
|
|
87
|
+
(cause) => new NisshiConnectionError({ reason: `invalid broker url ${brokerUrl}`, cause }),
|
|
88
|
+
),
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const socket: Socket = yield* Effect.acquireRelease(
|
|
92
|
+
Effect.async<Socket, NisshiConnectionError>((resume, signal) => {
|
|
93
|
+
const s = new Socket();
|
|
94
|
+
const onAbort = () => s.destroy();
|
|
95
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
96
|
+
s.once("connect", () => {
|
|
97
|
+
signal.removeEventListener("abort", onAbort);
|
|
98
|
+
resume(Effect.succeed(s));
|
|
99
|
+
});
|
|
100
|
+
s.once("error", (err) => {
|
|
101
|
+
signal.removeEventListener("abort", onAbort);
|
|
102
|
+
resume(Effect.fail(new NisshiConnectionError({ reason: "connect failed", cause: err })));
|
|
103
|
+
});
|
|
104
|
+
s.connect(port, host);
|
|
105
|
+
}),
|
|
106
|
+
(s) => Effect.sync(() => s.destroy()),
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const pending = yield* Ref.make<
|
|
110
|
+
Map<number, Deferred.Deferred<Uint8Array, NisshiConnectionError>>
|
|
111
|
+
>(new Map());
|
|
112
|
+
let correlation = 0;
|
|
113
|
+
|
|
114
|
+
// Responses may split across TCP chunks; buffer until whole frames.
|
|
115
|
+
let receive = Buffer.alloc(0);
|
|
116
|
+
socket.on("data", (chunk: Buffer) => {
|
|
117
|
+
receive = Buffer.concat([receive, chunk]);
|
|
118
|
+
for (;;) {
|
|
119
|
+
if (receive.length < 4) {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const size = receive.readInt32BE(0);
|
|
123
|
+
if (size < 4 || receive.length < 4 + size) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const body = new Uint8Array(receive.subarray(4, 4 + size));
|
|
127
|
+
receive = receive.subarray(4 + size);
|
|
128
|
+
const corr =
|
|
129
|
+
body.length >= 4 ? new DataView(body.buffer, body.byteOffset, 4).getInt32(0) : -1;
|
|
130
|
+
if (corr >= 0) {
|
|
131
|
+
takePending(pending, corr, (deferred) => {
|
|
132
|
+
Effect.runFork(Deferred.succeed(deferred, body.subarray(4)));
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
socket.on("close", () => failAll(pending, "connection closed"));
|
|
138
|
+
socket.on("error", (err) => failAll(pending, String(err)));
|
|
139
|
+
|
|
140
|
+
const request = (
|
|
141
|
+
apiKey: number,
|
|
142
|
+
apiVersion: number,
|
|
143
|
+
build: (writer: Writer) => void,
|
|
144
|
+
timeoutMillis: number,
|
|
145
|
+
): Effect.Effect<Uint8Array, NisshiConnectionError> =>
|
|
146
|
+
Effect.gen(function* () {
|
|
147
|
+
const deferred = yield* Deferred.make<Uint8Array, NisshiConnectionError>();
|
|
148
|
+
correlation += 1;
|
|
149
|
+
const corr = correlation;
|
|
150
|
+
yield* Ref.update(pending, (map) => new Map(map).set(corr, deferred));
|
|
151
|
+
|
|
152
|
+
const writer = new Writer().i16(apiKey).i16(apiVersion).i32(corr).str(clientId);
|
|
153
|
+
build(writer);
|
|
154
|
+
const payload = writer.out();
|
|
155
|
+
// `false` only signals backpressure (data is still queued); real
|
|
156
|
+
// failures surface through the close/error handlers.
|
|
157
|
+
connection_socket_write(socket, payload);
|
|
158
|
+
|
|
159
|
+
return yield* Deferred.await(deferred).pipe(
|
|
160
|
+
Effect.timeoutFail({
|
|
161
|
+
duration: timeoutMillis,
|
|
162
|
+
onTimeout: () =>
|
|
163
|
+
new NisshiConnectionError({
|
|
164
|
+
reason: `request timed out (api ${apiKey} v${apiVersion})`,
|
|
165
|
+
}),
|
|
166
|
+
}),
|
|
167
|
+
Effect.ensuring(Effect.sync(() => takePending(pending, corr, () => {}))),
|
|
168
|
+
);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// Handshake: ApiVersions v0, then verify every pinned version is offered.
|
|
172
|
+
const body = yield* request(ApiKey.apiVersions, PinnedVersion.apiVersions, () => {}, 5000);
|
|
173
|
+
const reader = new Reader(body);
|
|
174
|
+
const errorCode = reader.i16();
|
|
175
|
+
if (errorCode !== 0) {
|
|
176
|
+
return yield* Effect.fail(
|
|
177
|
+
new NisshiConnectionError({ reason: `ApiVersions rejected with code ${errorCode}` }),
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
const count = reader.i32();
|
|
181
|
+
const versions = new Map<number, readonly [number, number]>();
|
|
182
|
+
for (let i = 0; i < count; i++) {
|
|
183
|
+
versions.set(reader.i16(), [reader.i16(), reader.i16()]);
|
|
184
|
+
}
|
|
185
|
+
assertVersion(versions, "Produce", ApiKey.produce, PinnedVersion.produce);
|
|
186
|
+
assertVersion(versions, "Fetch", ApiKey.fetch, PinnedVersion.fetch);
|
|
187
|
+
assertVersion(versions, "Metadata", ApiKey.metadata, PinnedVersion.metadata);
|
|
188
|
+
assertVersion(versions, "CreateTopics", ApiKey.createTopics, PinnedVersion.createTopics);
|
|
189
|
+
assertVersion(versions, "ListOffsets", ApiKey.listOffsets, PinnedVersion.listOffsets);
|
|
190
|
+
|
|
191
|
+
return { versions, request };
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// --- helpers running on the socket thread (outside Effect) ---
|
|
195
|
+
type PendingMap = Ref.Ref<Map<number, Deferred.Deferred<Uint8Array, NisshiConnectionError>>>;
|
|
196
|
+
|
|
197
|
+
function connection_socket_write(socket: Socket, payload: Uint8Array): void {
|
|
198
|
+
socket.write(new Writer().i32(payload.length).raw(payload).out());
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const takePending = (
|
|
202
|
+
pending: PendingMap,
|
|
203
|
+
corr: number,
|
|
204
|
+
use: (deferred: Deferred.Deferred<Uint8Array, NisshiConnectionError>) => void,
|
|
205
|
+
): void => {
|
|
206
|
+
let found: Deferred.Deferred<Uint8Array, NisshiConnectionError> | undefined;
|
|
207
|
+
Effect.runSync(
|
|
208
|
+
Ref.modify(pending, (map) => {
|
|
209
|
+
found = map.get(corr);
|
|
210
|
+
const next = new Map(map);
|
|
211
|
+
next.delete(corr);
|
|
212
|
+
return [found, next];
|
|
213
|
+
}),
|
|
214
|
+
);
|
|
215
|
+
if (found !== undefined) {
|
|
216
|
+
use(found);
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const failAll = (pending: PendingMap, reason: string): void => {
|
|
221
|
+
const all = Effect.runSync(Ref.getAndSet(pending, new Map()));
|
|
222
|
+
for (const deferred of all.values()) {
|
|
223
|
+
Effect.runFork(Deferred.fail(deferred, new NisshiConnectionError({ reason })));
|
|
224
|
+
}
|
|
225
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { Data } from "effect";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Connection-level failure: socket closed, handshake rejected, or request
|
|
5
|
+
* timed out. Always transient — a stateless Nisshi broker comes back and a
|
|
6
|
+
* retry re-establishes everything.
|
|
7
|
+
*/
|
|
8
|
+
export class NisshiConnectionError extends Data.TaggedError("NisshiConnectionError")<{
|
|
9
|
+
readonly reason: string;
|
|
10
|
+
readonly cause?: unknown;
|
|
11
|
+
}> {
|
|
12
|
+
readonly classification = "transient" as const;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Broker answered a request with a nonzero Kafka error code. */
|
|
16
|
+
export class NisshiApiError extends Data.TaggedError("NisshiApiError")<{
|
|
17
|
+
readonly code: number;
|
|
18
|
+
readonly message: string;
|
|
19
|
+
readonly retriable: boolean;
|
|
20
|
+
}> {
|
|
21
|
+
readonly classification: "transient" | "permanent" = this.retriable ? "transient" : "permanent";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The topic exists but does not match the single-partition contract. */
|
|
25
|
+
export class NisshiTopicConfigurationError extends Data.TaggedError(
|
|
26
|
+
"NisshiTopicConfigurationError",
|
|
27
|
+
)<{
|
|
28
|
+
readonly topic: string;
|
|
29
|
+
readonly reason: string;
|
|
30
|
+
}> {
|
|
31
|
+
readonly classification = "permanent" as const;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A produce attempt failed after versions were reserved — see the ADR. */
|
|
35
|
+
export class NisshiProduceError extends Data.TaggedError("NisshiProduceError")<{
|
|
36
|
+
readonly topic: string;
|
|
37
|
+
readonly cause: unknown;
|
|
38
|
+
}> {
|
|
39
|
+
readonly classification = "transient" as const;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Response bytes violate the wire protocol or the envelope contract. */
|
|
43
|
+
export class NisshiProtocolError extends Data.TaggedError("NisshiProtocolError")<{
|
|
44
|
+
readonly reason: string;
|
|
45
|
+
}> {
|
|
46
|
+
readonly classification = "permanent" as const;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Kafka error codes we care about. Everything unlisted maps to retriable:
|
|
50
|
+
// on a stateless broker most odd failures clear by the next attempt.
|
|
51
|
+
const NON_RETRIABLE_CODES = new Set([
|
|
52
|
+
1, // OFFSET_OUT_OF_RANGE
|
|
53
|
+
3, // UNKNOWN_TOPIC_OR_PARTITION
|
|
54
|
+
17, // INVALID_TOPIC_EXCEPTION
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
/** Maps a Kafka error code to a `NisshiApiError`. */
|
|
58
|
+
export const apiError = (code: number, message: string): NisshiApiError =>
|
|
59
|
+
new NisshiApiError({
|
|
60
|
+
code,
|
|
61
|
+
message,
|
|
62
|
+
retriable: !NON_RETRIABLE_CODES.has(code),
|
|
63
|
+
});
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kafka wire-protocol primitives: big-endian writer/reader, zigzag varints,
|
|
3
|
+
* and CRC32-Castagnoli. The record-batch format and request/response framing
|
|
4
|
+
* are layered on top (see `batch.ts` and `connection.ts`).
|
|
5
|
+
*
|
|
6
|
+
* Length fields inside record batches are *signed* zigzag varints per the
|
|
7
|
+
* Kafka message-format-v2 spec (`null` is -1); arrays on the request/response
|
|
8
|
+
* level are int32-counted. Verified against Nisshi v0.7.0-pre.2.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Appends big-endian and varint fields; call `out()` once at the end. */
|
|
12
|
+
export class Writer {
|
|
13
|
+
private readonly parts: Uint8Array[] = [];
|
|
14
|
+
private length = 0;
|
|
15
|
+
|
|
16
|
+
private push(bytes: Uint8Array): void {
|
|
17
|
+
this.parts.push(bytes);
|
|
18
|
+
this.length += bytes.length;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** One byte. */
|
|
22
|
+
i8(value: number): this {
|
|
23
|
+
this.push(new Uint8Array([value & 0xff]));
|
|
24
|
+
return this;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
i16(value: number): this {
|
|
28
|
+
const b = new Uint8Array(2);
|
|
29
|
+
b[0] = (value >> 8) & 0xff;
|
|
30
|
+
b[1] = value & 0xff;
|
|
31
|
+
this.push(b);
|
|
32
|
+
return this;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
i32(value: number): this {
|
|
36
|
+
const b = new Uint8Array(4);
|
|
37
|
+
const v = value >>> 0;
|
|
38
|
+
b[0] = (v >>> 24) & 0xff;
|
|
39
|
+
b[1] = (v >>> 16) & 0xff;
|
|
40
|
+
b[2] = (v >>> 8) & 0xff;
|
|
41
|
+
b[3] = v & 0xff;
|
|
42
|
+
this.push(b);
|
|
43
|
+
return this;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
i64(value: bigint | number): this {
|
|
47
|
+
let v = BigInt(value);
|
|
48
|
+
const b = new Uint8Array(8);
|
|
49
|
+
for (let i = 7; i >= 0; i--) {
|
|
50
|
+
b[i] = Number(v & 0xffn);
|
|
51
|
+
v >>= 8n;
|
|
52
|
+
}
|
|
53
|
+
this.push(b);
|
|
54
|
+
return this;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** int32-length-prefixed raw bytes (request/response level). */
|
|
58
|
+
bytes(value: Uint8Array): this {
|
|
59
|
+
this.i32(value.length);
|
|
60
|
+
this.push(value);
|
|
61
|
+
return this;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** int16-length-prefixed string; `null` becomes -1. */
|
|
65
|
+
str(value: string | null): this {
|
|
66
|
+
if (value === null) {
|
|
67
|
+
return this.i16(-1);
|
|
68
|
+
}
|
|
69
|
+
const b = new TextEncoder().encode(value);
|
|
70
|
+
this.i16(b.length);
|
|
71
|
+
this.push(b);
|
|
72
|
+
return this;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
raw(value: Uint8Array): this {
|
|
76
|
+
this.push(value);
|
|
77
|
+
return this;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Unsigned protobuf varint (used only inside zigzag encoding). */
|
|
81
|
+
uvarint(value: number): this {
|
|
82
|
+
let v = value;
|
|
83
|
+
for (;;) {
|
|
84
|
+
let byte = v & 0x7f;
|
|
85
|
+
v = Math.floor(v / 128);
|
|
86
|
+
if (v !== 0) {
|
|
87
|
+
byte |= 0x80;
|
|
88
|
+
}
|
|
89
|
+
this.push(new Uint8Array([byte]));
|
|
90
|
+
if (v === 0) {
|
|
91
|
+
return this;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Signed zigzag varint (record-batch lengths, deltas; `null` is -1). */
|
|
97
|
+
varint(value: number): this {
|
|
98
|
+
return this.uvarint((value << 1) ^ (value >> 31));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
out(): Uint8Array {
|
|
102
|
+
const out = new Uint8Array(this.length);
|
|
103
|
+
let at = 0;
|
|
104
|
+
for (const part of this.parts) {
|
|
105
|
+
out.set(part, at);
|
|
106
|
+
at += part.length;
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Reads big-endian and varint fields sequentially over one response body. */
|
|
113
|
+
export class Reader {
|
|
114
|
+
/** Byte cursor — public for `batch.ts` record parsing. */
|
|
115
|
+
public pos = 0;
|
|
116
|
+
|
|
117
|
+
constructor(private readonly buf: Uint8Array) {}
|
|
118
|
+
|
|
119
|
+
private need(count: number): void {
|
|
120
|
+
if (this.pos + count > this.buf.length) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`kafka protocol: truncated response (need ${count} bytes at ${this.pos}, have ${this.buf.length - this.pos})`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Indexed byte access that refuses to silently yield `undefined`. */
|
|
128
|
+
private at(index: number): number {
|
|
129
|
+
const byte = this.buf[index];
|
|
130
|
+
if (byte === undefined) {
|
|
131
|
+
throw new Error(`kafka protocol: byte read at ${index} out of bounds`);
|
|
132
|
+
}
|
|
133
|
+
return byte;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
i8(): number {
|
|
137
|
+
this.need(1);
|
|
138
|
+
const v = this.at(this.pos);
|
|
139
|
+
this.pos += 1;
|
|
140
|
+
return v >= 128 ? v - 256 : v;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
i16(): number {
|
|
144
|
+
this.need(2);
|
|
145
|
+
const v = (this.at(this.pos) << 8) | this.at(this.pos + 1);
|
|
146
|
+
this.pos += 2;
|
|
147
|
+
return v >= 0x8000 ? v - 0x10000 : v;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
u16(): number {
|
|
151
|
+
this.need(2);
|
|
152
|
+
const v = (this.at(this.pos) << 8) | this.at(this.pos + 1);
|
|
153
|
+
this.pos += 2;
|
|
154
|
+
return v;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
i32(): number {
|
|
158
|
+
this.need(4);
|
|
159
|
+
const v =
|
|
160
|
+
((this.at(this.pos) << 24) |
|
|
161
|
+
(this.at(this.pos + 1) << 16) |
|
|
162
|
+
(this.at(this.pos + 2) << 8) |
|
|
163
|
+
this.at(this.pos + 3)) >>>
|
|
164
|
+
0;
|
|
165
|
+
this.pos += 4;
|
|
166
|
+
return v | 0;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
i64(): bigint {
|
|
170
|
+
this.need(8);
|
|
171
|
+
let v = 0n;
|
|
172
|
+
for (let i = 0; i < 8; i++) {
|
|
173
|
+
v = (v << 8n) | BigInt(this.at(this.pos + i));
|
|
174
|
+
}
|
|
175
|
+
this.pos += 8;
|
|
176
|
+
return BigInt.asIntN(64, v);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
u32(): number {
|
|
180
|
+
this.need(4);
|
|
181
|
+
const v = this.i32();
|
|
182
|
+
return v >>> 0;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
str(): string | null {
|
|
186
|
+
const len = this.i16();
|
|
187
|
+
if (len < 0) {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
this.need(len);
|
|
191
|
+
const s = new TextDecoder().decode(this.buf.subarray(this.pos, this.pos + len));
|
|
192
|
+
this.pos += len;
|
|
193
|
+
return s;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** int32-length-prefixed bytes; `null` length yields null. */
|
|
197
|
+
bytes(): Uint8Array | null {
|
|
198
|
+
const len = this.i32();
|
|
199
|
+
if (len < 0) {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
this.need(len);
|
|
203
|
+
const b = this.buf.slice(this.pos, this.pos + len);
|
|
204
|
+
this.pos += len;
|
|
205
|
+
return b;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
uvarint(): number {
|
|
209
|
+
let v = 0;
|
|
210
|
+
let shift = 0;
|
|
211
|
+
for (;;) {
|
|
212
|
+
this.need(1);
|
|
213
|
+
const byte = this.at(this.pos);
|
|
214
|
+
this.pos += 1;
|
|
215
|
+
v |= (byte & 0x7f) << shift;
|
|
216
|
+
if ((byte & 0x80) === 0) {
|
|
217
|
+
return v >>> 0;
|
|
218
|
+
}
|
|
219
|
+
shift += 7;
|
|
220
|
+
if (shift > 31) {
|
|
221
|
+
throw new Error("kafka protocol: varint overflow");
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
varint(): number {
|
|
227
|
+
const v = this.uvarint();
|
|
228
|
+
return (v >>> 1) ^ -(v & 1);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const CRC32C_TABLE = (() => {
|
|
233
|
+
const table = new Uint32Array(256);
|
|
234
|
+
for (let i = 0; i < 256; i++) {
|
|
235
|
+
let c = i;
|
|
236
|
+
for (let k = 0; k < 8; k++) {
|
|
237
|
+
c = c & 1 ? 0x82f63b78 ^ (c >>> 1) : c >>> 1;
|
|
238
|
+
}
|
|
239
|
+
table[i] = c >>> 0;
|
|
240
|
+
}
|
|
241
|
+
return table;
|
|
242
|
+
})();
|
|
243
|
+
|
|
244
|
+
/** CRC32-Castagnoli over `value` (record-batch v2 integrity field). */
|
|
245
|
+
export const crc32c = (value: Uint8Array): number => {
|
|
246
|
+
let c = 0xffffffff;
|
|
247
|
+
for (const byte of value) {
|
|
248
|
+
c = (CRC32C_TABLE[(c ^ byte) & 0xff] ?? 0) ^ (c >>> 8);
|
|
249
|
+
}
|
|
250
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
251
|
+
};
|
package/src/relay.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import * as SqlClient from "@effect/sql/SqlClient";
|
|
2
|
+
import type { SqlError } from "@effect/sql/SqlError";
|
|
3
|
+
import { Effect } from "effect";
|
|
4
|
+
import { NisshiClient } from "./protocol/client.js";
|
|
5
|
+
import type { NisshiApiError, NisshiConnectionError } from "./protocol/errors.js";
|
|
6
|
+
import { type SidecarOptions, sidecarTables } from "./sidecar.js";
|
|
7
|
+
|
|
8
|
+
export interface RelayOptions extends SidecarOptions {
|
|
9
|
+
/** Entries fetched per poll (default 32). */
|
|
10
|
+
readonly batchSize?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface PendingRow {
|
|
14
|
+
readonly stream_name: string;
|
|
15
|
+
readonly version: number | bigint | string;
|
|
16
|
+
readonly topic: string;
|
|
17
|
+
readonly record_value: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Produces every pending row whose append crashed between reservation and
|
|
22
|
+
* confirmation, then removes it. There are no dead letters here: pending
|
|
23
|
+
* rows are domain facts that already passed the ledger — the relay retries
|
|
24
|
+
* until the topic accepts them. A crash after produce but before delete
|
|
25
|
+
* re-produces a duplicate; readers dedupe by `(stream, version)`.
|
|
26
|
+
*/
|
|
27
|
+
export const drainPending = (
|
|
28
|
+
options?: RelayOptions,
|
|
29
|
+
): Effect.Effect<
|
|
30
|
+
void,
|
|
31
|
+
NisshiApiError | NisshiConnectionError | SqlError,
|
|
32
|
+
SqlClient.SqlClient | NisshiClient
|
|
33
|
+
> =>
|
|
34
|
+
Effect.gen(function* () {
|
|
35
|
+
const sql = yield* SqlClient.SqlClient;
|
|
36
|
+
const client = yield* NisshiClient;
|
|
37
|
+
const tables = sidecarTables(options);
|
|
38
|
+
|
|
39
|
+
for (;;) {
|
|
40
|
+
const rows = yield* sql<PendingRow>`
|
|
41
|
+
SELECT stream_name, version, topic, record_value
|
|
42
|
+
FROM ${sql(tables.pending)}
|
|
43
|
+
ORDER BY stream_name, version
|
|
44
|
+
LIMIT ${options?.batchSize ?? 32}
|
|
45
|
+
`;
|
|
46
|
+
if (rows.length === 0) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
for (const row of rows) {
|
|
50
|
+
yield* client.produce(row.topic, [
|
|
51
|
+
{
|
|
52
|
+
key: new TextEncoder().encode(row.stream_name),
|
|
53
|
+
value: new TextEncoder().encode(row.record_value),
|
|
54
|
+
},
|
|
55
|
+
]);
|
|
56
|
+
yield* Effect.asVoid(sql`
|
|
57
|
+
DELETE FROM ${sql(tables.pending)}
|
|
58
|
+
WHERE stream_name = ${row.stream_name} AND version = ${row.version}
|
|
59
|
+
`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/** Runs `drainPending` forever, sleeping `pollInterval` between passes. */
|
|
65
|
+
export const runPendingRelay = (
|
|
66
|
+
options?: RelayOptions & { readonly pollInterval?: number },
|
|
67
|
+
): Effect.Effect<never, never, SqlClient.SqlClient | NisshiClient> =>
|
|
68
|
+
Effect.gen(function* () {
|
|
69
|
+
const interval = options?.pollInterval ?? 500;
|
|
70
|
+
for (;;) {
|
|
71
|
+
yield* drainPending(options).pipe(
|
|
72
|
+
Effect.ignore,
|
|
73
|
+
Effect.catchAllDefect(() => Effect.void),
|
|
74
|
+
);
|
|
75
|
+
yield* Effect.sleep(interval);
|
|
76
|
+
}
|
|
77
|
+
});
|
package/src/schemas.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { Effect } from "effect";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* JSON Schema for the event envelope, matching `WireEvent`. Mount the
|
|
7
|
+
* generated file in the broker's schema registry (`--schema-registry
|
|
8
|
+
* file://<dir>` with one `<topic>.json` per topic) to get broker-side
|
|
9
|
+
* rejection of malformed records; see the package README for wiring.
|
|
10
|
+
*/
|
|
11
|
+
export const envelopeJsonSchema = {
|
|
12
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
13
|
+
$id: "https://structure.dev/nisshi/event-envelope.json",
|
|
14
|
+
title: "Event envelope",
|
|
15
|
+
type: "object",
|
|
16
|
+
required: ["type", "schemaVersion", "version", "payload", "metadata"],
|
|
17
|
+
additionalProperties: false,
|
|
18
|
+
properties: {
|
|
19
|
+
type: { type: "string", minLength: 1 },
|
|
20
|
+
schemaVersion: { type: "integer", minimum: 1 },
|
|
21
|
+
version: { type: "integer", minimum: 1 },
|
|
22
|
+
payload: {},
|
|
23
|
+
metadata: {
|
|
24
|
+
type: "object",
|
|
25
|
+
required: ["occurredAt"],
|
|
26
|
+
properties: {
|
|
27
|
+
occurredAt: { type: "string", minLength: 1 },
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
} as const;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Writes one `<topic>.json` per topic into `dir` (created if missing).
|
|
35
|
+
* Point the Nisshi broker at the directory to enforce the envelope
|
|
36
|
+
* server-side.
|
|
37
|
+
*/
|
|
38
|
+
export const writeSchemaFiles = (dir: string, topics: ReadonlyArray<string>): Effect.Effect<void> =>
|
|
39
|
+
Effect.forEach(topics, (topic) =>
|
|
40
|
+
Effect.gen(function* () {
|
|
41
|
+
const file = `${dir.replace(/\/$/, "")}/${topic}.json`;
|
|
42
|
+
yield* Effect.promise(() => mkdir(dirname(file), { recursive: true }));
|
|
43
|
+
yield* Effect.promise(() =>
|
|
44
|
+
writeFile(file, `${JSON.stringify(envelopeJsonSchema, null, 2)}\n`, "utf8"),
|
|
45
|
+
);
|
|
46
|
+
}),
|
|
47
|
+
).pipe(Effect.asVoid);
|