apache-iggy 0.8.1-edge.4 → 0.9.0-edge.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -27,7 +27,6 @@ export declare const handleResponseTransform: () => Transform;
27
27
  * @returns True if the response indicates success with no data
28
28
  */
29
29
  export declare const deserializeVoidResponse: (r: CommandResponse) => boolean;
30
- export declare const deserializeStatusResponse: (r: CommandResponse) => boolean;
31
30
  /**
32
31
  * Serializes a command and its payload into a buffer for sending to the server.
33
32
  * Creates the wire format: [payload_size (4 bytes)][command (4 bytes)][payload]
@@ -59,7 +59,6 @@ export const handleResponseTransform = () => new Transform({
59
59
  * @returns True if the response indicates success with no data
60
60
  */
61
61
  export const deserializeVoidResponse = (r) => r.status === 0 && r.data.length === 0;
62
- export const deserializeStatusResponse = (r) => r.status === 0;
63
62
  /** Length of the command code in bytes */
64
63
  const COMMAND_LENGTH = 4;
65
64
  /**
@@ -17,9 +17,8 @@
17
17
  //
18
18
  import { describe, it } from 'node:test';
19
19
  import assert from 'node:assert/strict';
20
- import { handleResponse, deserializeVoidResponse, deserializeStatusResponse } from './client.utils.js';
20
+ import { handleResponse, deserializeVoidResponse } from './client.utils.js';
21
21
  const SUCCESS = 0;
22
- const ERROR = 1;
23
22
  describe('handleResponse', () => {
24
23
  it('bounds data to the length field, not the full buffer', () => {
25
24
  // Server says: status=0, length=0, no payload.
@@ -40,19 +39,4 @@ describe('handleResponse', () => {
40
39
  assert.equal(deserializeVoidResponse(r), true);
41
40
  });
42
41
  });
43
- describe('deserializeStatusResponse', () => {
44
- it('returns true when status is SUCCESS and data is empty', () => {
45
- const r = { status: SUCCESS, length: 0, data: Buffer.alloc(0) };
46
- assert.equal(deserializeStatusResponse(r), true);
47
- });
48
- it('returns true when status is SUCCESS and data is non-empty (e.g. SendMessages server payload)', () => {
49
- // Key difference from deserializeVoidResponse: non-empty data is accepted.
50
- const r = { status: SUCCESS, length: 4, data: Buffer.from([1, 2, 3, 4]) };
51
- assert.equal(deserializeStatusResponse(r), true);
52
- });
53
- it('returns false when status is an error code', () => {
54
- const r = { status: ERROR, length: 0, data: Buffer.alloc(0) };
55
- assert.equal(deserializeStatusResponse(r), false);
56
- });
57
- });
58
42
  //# sourceMappingURL=client.utils.test.js.map
@@ -22,6 +22,9 @@ import { generateMessages } from '../tcp.sm.utils.js';
22
22
  import { getTestClient } from './test-client.utils.js';
23
23
  describe('e2e -> message', async () => {
24
24
  const c = getTestClient();
25
+ // Only the VSR lane reaches a server that reports offsets. The classic lane
26
+ // runs against the legacy server, which commits without confirming.
27
+ const vsr = process.env.IGGY_TEST_PROTOCOL === 'vsr';
25
28
  const streamName = 'e2e-stream-934';
26
29
  const topicName = 'e2e-topic-832';
27
30
  const partitionId = 0;
@@ -40,7 +43,13 @@ describe('e2e -> message', async () => {
40
43
  partition: Partitioning.PartitionId(partitionId)
41
44
  };
42
45
  it('e2e -> message::send', async () => {
43
- assert.ok(await c.message.send(msg));
46
+ const { confirmations } = await c.message.send(msg);
47
+ if (!vsr) {
48
+ assert.equal(confirmations.length, 0);
49
+ return;
50
+ }
51
+ assert.equal(confirmations.length, 1);
52
+ assert.equal(confirmations[0].partitionId, partitionId);
44
53
  });
45
54
  it('e2e -> message::poll/last', async () => {
46
55
  const pollReq = {
@@ -143,6 +152,20 @@ describe('e2e -> message', async () => {
143
152
  });
144
153
  assert.deepEqual(offset, { partitionId: 0, currentOffset: 5n, storedOffset: 2n });
145
154
  });
155
+ it('e2e -> message::send/next batch', async () => {
156
+ const { confirmations } = await c.message.send({
157
+ ...msg,
158
+ messages: generateMessages(3)
159
+ });
160
+ if (!vsr) {
161
+ assert.equal(confirmations.length, 0);
162
+ return;
163
+ }
164
+ // Landing behind the already committed batch is the part no placeholder
165
+ // confirmation could reproduce.
166
+ assert.equal(confirmations.length, 1);
167
+ assert.equal(confirmations[0].baseOffset, BigInt(msg.messages.length));
168
+ });
146
169
  it('e2e -> message::cleanup', async () => {
147
170
  assert.ok(await c.stream.delete({ streamId: streamName }));
148
171
  assert.ok(await c.session.logout());
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { type Id, PollingStrategy, Consumer, Partitioning, HeaderValue, HeaderKeyFactory, } from "./wire/index.js";
1
+ export { type Id, PollingStrategy, Consumer, Partitioning, HeaderValue, HeaderKeyFactory, type SendMessagesConfirmation, type SendMessagesResponse, } from "./wire/index.js";
2
2
  export * from "./client/index.js";
3
3
  export * from "./stream/index.js";
4
4
  export { DeserializeError, ResponseError } from './wire/error.utils.js';
@@ -55,7 +55,7 @@ export declare const generateMessages: (count?: number) => ({
55
55
  }[];
56
56
  id: string;
57
57
  })[];
58
- export declare const sendSomeMessages: (s: ClientProvider) => (streamId: Id, topicId: Id, partition: Partitioning) => Promise<true>;
58
+ export declare const sendSomeMessages: (s: ClientProvider) => (streamId: Id, topicId: Id, partition: Partitioning) => Promise<import("./wire/index.js").SendMessagesResponse>;
59
59
  export declare const formatPolledMessages: (msgs: Message[]) => {
60
60
  id: string | bigint;
61
61
  offset: bigint;
@@ -75,7 +75,7 @@ declare const offsetAPI: (c: ClientProvider) => {
75
75
  type OffsetAPI = ReturnType<typeof offsetAPI>;
76
76
  declare const messageAPI: (c: ClientProvider) => {
77
77
  poll: (request: import("./message/poll-messages.command.js").PollMessages) => Promise<import("./index.js").PollMessagesResponse>;
78
- send: (arg: import("./message/send-messages.command.js").SendMessages) => Promise<boolean>;
78
+ send: (arg: import("./message/send-messages.command.js").SendMessages) => Promise<import("./message/send-messages.command.js").SendMessagesResponse>;
79
79
  flushUnsavedBuffers: (arg: import("./message/flush-unsaved-buffers.command.js").FlushUnsavedBuffer) => Promise<boolean>;
80
80
  };
81
81
  type MessageAPI = ReturnType<typeof messageAPI>;
@@ -1,6 +1,7 @@
1
1
  import { type Id } from '../identifier.utils.js';
2
2
  import { type CreateMessage } from './message.utils.js';
3
3
  import type { Partitioning } from './partitioning.utils.js';
4
+ import type { CommandResponse } from '../../client/client.type.js';
4
5
  /**
5
6
  * Parameters for the send messages command.
6
7
  */
@@ -14,6 +15,33 @@ export type SendMessages = {
14
15
  /** Optional partitioning strategy */
15
16
  partition?: Partitioning;
16
17
  };
18
+ /** Commit confirmation for one partition written by a send. */
19
+ export type SendMessagesConfirmation = {
20
+ /** Numeric id of the stream the batch was written to */
21
+ streamId: number;
22
+ /** Numeric id of the topic the batch was written to */
23
+ topicId: number;
24
+ /** Partition the batch was written to */
25
+ partitionId: number;
26
+ /**
27
+ * Offset assigned to the first message of the batch in that partition.
28
+ *
29
+ * Delivery is at-least-once, so an earlier retry of the same batch may
30
+ * already have committed at a lower offset: this never identifies a batch
31
+ * uniquely. A batch is confirmed once it is committed in memory, not once it
32
+ * is fsynced, so a crash-restart can stamp a later batch with an offset a
33
+ * client has already recorded.
34
+ */
35
+ baseOffset: bigint;
36
+ };
37
+ /**
38
+ * Outcome of a successful send, one confirmation per written partition. The
39
+ * legacy server returns an empty list: it commits without reporting offsets.
40
+ */
41
+ export type SendMessagesResponse = {
42
+ /** Commit confirmations, one per partition the batch was written to */
43
+ confirmations: SendMessagesConfirmation[];
44
+ };
17
45
  /**
18
46
  * Send messages command definition.
19
47
  * Publishes messages to a topic.
@@ -21,10 +49,11 @@ export type SendMessages = {
21
49
  export declare const SEND_MESSAGES: {
22
50
  code: number;
23
51
  serialize: ({ streamId, topicId, messages, partition }: SendMessages) => Buffer<ArrayBuffer>;
24
- deserialize: (r: import("../../index.js").CommandResponse) => boolean;
52
+ deserialize: (r: CommandResponse) => SendMessagesResponse;
25
53
  };
26
54
  /**
27
- * Executable send messages command function.
55
+ * Executable send messages command function. Resolves to the commit
56
+ * confirmations of the written partitions, empty against the legacy server.
28
57
  */
29
- export declare const sendMessages: (getClient: import("../../index.js").ClientProvider) => (arg: SendMessages) => Promise<boolean>;
58
+ export declare const sendMessages: (getClient: import("../../client/client.type.js").ClientProvider) => (arg: SendMessages) => Promise<SendMessagesResponse>;
30
59
  //# sourceMappingURL=send-messages.command.d.ts.map
@@ -16,9 +16,46 @@
16
16
  // under the License.
17
17
  //
18
18
  import { serializeSendMessages } from './message.utils.js';
19
- import { deserializeStatusResponse } from '../../client/client.utils.js';
19
+ import { DeserializeError } from '../error.utils.js';
20
20
  import { wrapCommand } from '../command.utils.js';
21
21
  import { COMMAND_CODE } from '../command.code.js';
22
+ /** Size of the confirmation count prefixing the list. */
23
+ const CONFIRMATIONS_COUNT_SIZE = 4;
24
+ /**
25
+ * Size of one confirmation entry:
26
+ * `stream_id(4) + topic_id(4) + partition_id(4) + base_offset(8)`.
27
+ */
28
+ const CONFIRMATION_SIZE = 20;
29
+ /**
30
+ * Decodes the reply body of a send: `[confirmations_count:4]` then that many
31
+ * `[stream_id:4 topic_id:4 partition_id:4 base_offset:8]` entries.
32
+ *
33
+ * The legacy server reports a commit by sending no body at all, so absence
34
+ * decodes to no confirmations instead of surfacing as a decode failure.
35
+ */
36
+ const deserializeSendMessages = (data) => {
37
+ if (data.length === 0)
38
+ return { confirmations: [] };
39
+ if (data.length < CONFIRMATIONS_COUNT_SIZE)
40
+ throw new DeserializeError('send messages confirmation count is truncated');
41
+ const count = data.readUInt32LE(0);
42
+ const expected = CONFIRMATIONS_COUNT_SIZE + count * CONFIRMATION_SIZE;
43
+ if (expected > data.length)
44
+ throw new DeserializeError('send messages confirmation list is truncated');
45
+ if (expected !== data.length)
46
+ throw new DeserializeError('send messages confirmations have trailing bytes');
47
+ const confirmations = new Array(count);
48
+ for (let index = 0; index < count; index += 1) {
49
+ const at = CONFIRMATIONS_COUNT_SIZE + index * CONFIRMATION_SIZE;
50
+ confirmations[index] = {
51
+ streamId: data.readUInt32LE(at),
52
+ topicId: data.readUInt32LE(at + 4),
53
+ partitionId: data.readUInt32LE(at + 8),
54
+ baseOffset: data.readBigUInt64LE(at + 12)
55
+ };
56
+ }
57
+ return { confirmations };
58
+ };
22
59
  /**
23
60
  * Send messages command definition.
24
61
  * Publishes messages to a topic.
@@ -28,10 +65,11 @@ export const SEND_MESSAGES = {
28
65
  serialize: ({ streamId, topicId, messages, partition }) => {
29
66
  return serializeSendMessages(streamId, topicId, messages, partition);
30
67
  },
31
- deserialize: deserializeStatusResponse
68
+ deserialize: (r) => deserializeSendMessages(r.data)
32
69
  };
33
70
  /**
34
- * Executable send messages command function.
71
+ * Executable send messages command function. Resolves to the commit
72
+ * confirmations of the written partitions, empty against the legacy server.
35
73
  */
36
74
  export const sendMessages = wrapCommand(SEND_MESSAGES);
37
75
  //# sourceMappingURL=send-messages.command.js.map
@@ -18,10 +18,34 @@
18
18
  import { describe, it } from "node:test";
19
19
  import assert from "node:assert/strict";
20
20
  import { uuidv7, uuidv4 } from "uuidv7";
21
- import { SEND_MESSAGES } from "./send-messages.command.js";
21
+ import { SEND_MESSAGES, } from "./send-messages.command.js";
22
22
  import { HeaderValue, HeaderKeyFactory } from "./header.utils.js";
23
+ import { DeserializeError } from "../error.utils.js";
23
24
  const SUCCESS = 0;
24
- const ERROR = 1;
25
+ const CONFIRMATION_SIZE = 20;
26
+ const confirmation = (partitionId) => ({
27
+ streamId: 1,
28
+ topicId: 2,
29
+ partitionId,
30
+ baseOffset: 42n,
31
+ });
32
+ const serializeConfirmations = (confirmations) => {
33
+ const b = Buffer.allocUnsafe(4 + confirmations.length * CONFIRMATION_SIZE);
34
+ b.writeUInt32LE(confirmations.length, 0);
35
+ confirmations.forEach((c, index) => {
36
+ const at = 4 + index * CONFIRMATION_SIZE;
37
+ b.writeUInt32LE(c.streamId, at);
38
+ b.writeUInt32LE(c.topicId, at + 4);
39
+ b.writeUInt32LE(c.partitionId, at + 8);
40
+ b.writeBigUInt64LE(c.baseOffset, at + 12);
41
+ });
42
+ return b;
43
+ };
44
+ const response = (data) => ({
45
+ status: SUCCESS,
46
+ length: data.length,
47
+ data,
48
+ });
25
49
  describe("SendMessages", () => {
26
50
  describe("serialize", () => {
27
51
  const t1 = {
@@ -150,19 +174,54 @@ describe("SendMessages", () => {
150
174
  });
151
175
  });
152
176
  describe('deserialize', () => {
153
- it('returns true when status is SUCCESS with empty data', () => {
154
- const r = { status: SUCCESS, length: 0, data: Buffer.alloc(0) };
155
- assert.equal(SEND_MESSAGES.deserialize(r), true);
156
- });
157
- it('returns true when status is SUCCESS with non-empty server payload', () => {
158
- // SendMessages server response includes data (e.g. partition/offset info).
159
- // The deserializer must accept non-empty data unlike deserializeVoidResponse.
160
- const r = { status: SUCCESS, length: 4, data: Buffer.from([1, 2, 3, 4]) };
161
- assert.equal(SEND_MESSAGES.deserialize(r), true);
162
- });
163
- it('returns false when status is an error code', () => {
164
- const r = { status: ERROR, length: 0, data: Buffer.alloc(0) };
165
- assert.equal(SEND_MESSAGES.deserialize(r), false);
177
+ it('reads one confirmation', () => {
178
+ const confirmations = [confirmation(3)];
179
+ const r = response(serializeConfirmations(confirmations));
180
+ assert.deepEqual(SEND_MESSAGES.deserialize(r), { confirmations });
181
+ });
182
+ it('reads every confirmation of a multi-partition send', () => {
183
+ const confirmations = [confirmation(0), confirmation(1), confirmation(2)];
184
+ const r = response(serializeConfirmations(confirmations));
185
+ assert.deepEqual(SEND_MESSAGES.deserialize(r), { confirmations });
186
+ });
187
+ it('reads the wire layout of a confirmation', () => {
188
+ const r = response(Buffer.from([
189
+ 0x01, 0x00, 0x00, 0x00, // count
190
+ 0x01, 0x00, 0x00, 0x00, // streamId
191
+ 0x02, 0x00, 0x00, 0x00, // topicId
192
+ 0x03, 0x00, 0x00, 0x00, // partitionId
193
+ 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // baseOffset
194
+ ]));
195
+ assert.deepEqual(SEND_MESSAGES.deserialize(r), {
196
+ confirmations: [
197
+ { streamId: 1, topicId: 2, partitionId: 3, baseOffset: 4n }
198
+ ]
199
+ });
200
+ });
201
+ it('reads a committed send that reports no offsets as an empty list', () => {
202
+ const r = response(serializeConfirmations([]));
203
+ assert.deepEqual(SEND_MESSAGES.deserialize(r), { confirmations: [] });
204
+ });
205
+ it('reads the bodiless legacy server reply as an empty list', () => {
206
+ const r = response(Buffer.alloc(0));
207
+ assert.deepEqual(SEND_MESSAGES.deserialize(r), { confirmations: [] });
208
+ });
209
+ it('throws on a truncated body', () => {
210
+ const data = serializeConfirmations([confirmation(0), confirmation(1)]);
211
+ for (let i = 1; i < data.length; i += 1)
212
+ assert.throws(() => SEND_MESSAGES.deserialize(response(data.subarray(0, i))), DeserializeError, `expected error for truncation at byte ${i}`);
213
+ });
214
+ it('throws on trailing bytes', () => {
215
+ const data = Buffer.concat([
216
+ serializeConfirmations([confirmation(1)]),
217
+ Buffer.from([0xFF])
218
+ ]);
219
+ assert.throws(() => SEND_MESSAGES.deserialize(response(data)), DeserializeError);
220
+ });
221
+ it('throws on a count no body could hold', () => {
222
+ const data = Buffer.alloc(4);
223
+ data.writeUInt32LE(0xFFFF_FFFF, 0);
224
+ assert.throws(() => SEND_MESSAGES.deserialize(response(data)), DeserializeError);
166
225
  });
167
226
  });
168
227
  });
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Packed protocol semver of the wire contract this port implements,
3
- * `pack(0, 10, 3)` per `core/binary_protocol/src/version.rs`. Bump together
3
+ * `pack(0, 11, 0)` per `core/binary_protocol/src/version.rs`. Bump together
4
4
  * with the Rust `IGGY_PROTOCOL_VERSION` on any wire-incompatible change.
5
5
  */
6
6
  export declare const IGGY_PROTOCOL_VERSION: number;
@@ -25,10 +25,10 @@
25
25
  import { DeserializeError } from '../error.utils.js';
26
26
  /**
27
27
  * Packed protocol semver of the wire contract this port implements,
28
- * `pack(0, 10, 3)` per `core/binary_protocol/src/version.rs`. Bump together
28
+ * `pack(0, 11, 0)` per `core/binary_protocol/src/version.rs`. Bump together
29
29
  * with the Rust `IGGY_PROTOCOL_VERSION` on any wire-incompatible change.
30
30
  */
31
- export const IGGY_PROTOCOL_VERSION = (0 << 20) | (10 << 10) | 3;
31
+ export const IGGY_PROTOCOL_VERSION = (0 << 20) | (11 << 10) | 0;
32
32
  const SDK_NAME = 'node-sdk';
33
33
  const wireName = (value) => {
34
34
  const bytes = Buffer.from(value, 'utf8');
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "apache-iggy",
3
3
  "type": "module",
4
- "version": "0.8.1-edge.4",
4
+ "version": "0.9.0-edge.1",
5
5
  "description": "Official Apache Iggy NodeJS SDK",
6
6
  "keywords": [
7
7
  "iggy",