apache-iggy 0.10.0 → 0.10.1-edge.2

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.
Files changed (157) hide show
  1. package/README.md +248 -28
  2. package/dist/client/client.config.d.ts +2 -0
  3. package/dist/client/client.config.js +13 -2
  4. package/dist/client/client.config.test.js +15 -2
  5. package/dist/client/client.connection-string.js +13 -1
  6. package/dist/client/client.connection-string.test.js +43 -1
  7. package/dist/client/client.connection.d.ts +33 -135
  8. package/dist/client/client.connection.js +190 -453
  9. package/dist/client/client.connection.test.js +244 -669
  10. package/dist/client/client.d.ts +4 -35
  11. package/dist/client/client.js +8 -101
  12. package/dist/client/client.operation.d.ts +120 -0
  13. package/dist/client/client.operation.js +428 -0
  14. package/dist/client/client.operation.test.d.ts +2 -0
  15. package/dist/client/client.operation.test.js +285 -0
  16. package/dist/client/client.socket.d.ts +92 -209
  17. package/dist/client/client.socket.js +1205 -981
  18. package/dist/client/client.socket.test.js +4865 -679
  19. package/dist/client/client.type.d.ts +65 -22
  20. package/dist/client/index.d.ts +1 -0
  21. package/dist/client/index.js +1 -0
  22. package/dist/client.e2e.js +3 -5
  23. package/dist/e2e/resource-options.e2e.d.ts +2 -0
  24. package/dist/{wire/stream/purge-stream.command.js → e2e/resource-options.e2e.js} +12 -20
  25. package/dist/e2e/resource-options.suite.d.ts +15 -0
  26. package/dist/e2e/resource-options.suite.js +272 -0
  27. package/dist/e2e/tcp.cluster.e2e.js +1 -0
  28. package/dist/e2e/tcp.consumer-stream.e2e.js +4 -0
  29. package/dist/e2e/tcp.parallel.e2e.js +5 -6
  30. package/dist/e2e/tcp.stream.e2e.js +0 -3
  31. package/dist/e2e/tcp.system.e2e.js +10 -1
  32. package/dist/e2e/tcp.token.e2e.js +8 -2
  33. package/dist/e2e/tcp.topic.e2e.js +0 -3
  34. package/dist/e2e/tcp.user.e2e.js +46 -3
  35. package/dist/e2e/tls.system.e2e.js +21 -12
  36. package/dist/index.d.ts +2 -1
  37. package/dist/index.js +1 -1
  38. package/dist/stream/consumer-stream.js +97 -12
  39. package/dist/wire/cluster/cluster.type.d.ts +2 -2
  40. package/dist/wire/cluster/cluster.utils.d.ts +1 -0
  41. package/dist/wire/cluster/cluster.utils.js +4 -1
  42. package/dist/wire/command-set.d.ts +8 -8
  43. package/dist/wire/command-set.js +18 -9
  44. package/dist/wire/command.code.d.ts +3 -10
  45. package/dist/wire/command.code.js +9 -11
  46. package/dist/wire/command.utils.d.ts +3 -2
  47. package/dist/wire/command.utils.js +3 -2
  48. package/dist/wire/error.code.js +17 -1
  49. package/dist/wire/error.code.test.js +28 -1
  50. package/dist/wire/error.utils.d.ts +4 -2
  51. package/dist/wire/error.utils.js +6 -3
  52. package/dist/wire/identifier.utils.d.ts +2 -0
  53. package/dist/wire/identifier.utils.js +2 -0
  54. package/dist/wire/index.d.ts +1 -0
  55. package/dist/wire/index.js +1 -0
  56. package/dist/wire/message/header.type.d.ts +1 -1
  57. package/dist/wire/message/message-batch.test.js +11 -5
  58. package/dist/wire/message/message.utils.d.ts +0 -10
  59. package/dist/wire/message/message.utils.js +1 -22
  60. package/dist/wire/message/poll-messages.command.d.ts +12 -1
  61. package/dist/wire/message/poll-messages.command.js +64 -16
  62. package/dist/wire/message/poll-messages.command.test.js +143 -5
  63. package/dist/wire/message/poll.utils.d.ts +4 -0
  64. package/dist/wire/message/poll.utils.js +6 -1
  65. package/dist/wire/message/send-messages.command.d.ts +3 -3
  66. package/dist/wire/message/send-messages.command.js +103 -3
  67. package/dist/wire/message/send-messages.command.test.js +113 -2
  68. package/dist/wire/offset/delete-offset.command.d.ts +11 -4
  69. package/dist/wire/offset/delete-offset.command.js +1 -1
  70. package/dist/wire/offset/get-offset.command.d.ts +1 -1
  71. package/dist/wire/offset/offset-write.command.test.d.ts +2 -0
  72. package/dist/wire/offset/offset-write.command.test.js +43 -0
  73. package/dist/wire/offset/offset.utils.js +10 -6
  74. package/dist/wire/offset/store-offset.command.d.ts +10 -0
  75. package/dist/wire/offset/store-offset.command.js +1 -1
  76. package/dist/wire/options.utils.d.ts +46 -0
  77. package/dist/wire/options.utils.js +57 -0
  78. package/dist/wire/partition/partition-route.d.ts +12 -0
  79. package/dist/wire/partition/partition-route.js +40 -0
  80. package/dist/wire/partition/partition-route.test.d.ts +2 -0
  81. package/dist/wire/partition/partition-route.test.js +42 -0
  82. package/dist/wire/session/login-with-token.command.d.ts +2 -2
  83. package/dist/wire/session/login-with-token.command.js +7 -2
  84. package/dist/wire/session/login.command.d.ts +2 -2
  85. package/dist/wire/session/login.command.js +7 -2
  86. package/dist/wire/stream/create-stream.command.d.ts +5 -2
  87. package/dist/wire/stream/create-stream.command.js +20 -2
  88. package/dist/wire/stream/index.d.ts +1 -1
  89. package/dist/wire/stream/index.js +1 -1
  90. package/dist/wire/stream/stream-options.d.ts +77 -0
  91. package/dist/wire/stream/stream-options.js +101 -0
  92. package/dist/wire/stream/stream-options.test.d.ts +2 -0
  93. package/dist/wire/stream/stream-options.test.js +103 -0
  94. package/dist/wire/stream/stream.utils.d.ts +9 -0
  95. package/dist/wire/stream/stream.utils.js +17 -3
  96. package/dist/wire/stream/update-stream.command.d.ts +23 -5
  97. package/dist/wire/stream/update-stream.command.js +10 -17
  98. package/dist/wire/system/describe-options.command.d.ts +23 -6
  99. package/dist/wire/system/describe-options.command.js +21 -2
  100. package/dist/wire/system/get-stats.command.d.ts +17 -0
  101. package/dist/wire/system/get-stats.command.js +69 -16
  102. package/dist/wire/system/get-stats.command.test.d.ts +2 -0
  103. package/dist/wire/system/get-stats.command.test.js +97 -0
  104. package/dist/wire/system/index.d.ts +2 -0
  105. package/dist/wire/system/index.js +2 -0
  106. package/dist/wire/system/snapshot.command.d.ts +64 -0
  107. package/dist/wire/system/snapshot.command.js +76 -0
  108. package/dist/wire/system/snapshot.command.test.d.ts +2 -0
  109. package/dist/wire/system/snapshot.command.test.js +86 -0
  110. package/dist/wire/system/update-resource.command.d.ts +53 -0
  111. package/dist/wire/system/update-resource.command.js +130 -0
  112. package/dist/wire/system/update-resource.command.test.d.ts +2 -0
  113. package/dist/wire/system/update-resource.command.test.js +497 -0
  114. package/dist/wire/token/create-token.command.d.ts +1 -1
  115. package/dist/wire/token/create-token.command.js +1 -1
  116. package/dist/wire/token/create-token.command.test.js +6 -0
  117. package/dist/wire/topic/create-topic.command.d.ts +20 -12
  118. package/dist/wire/topic/create-topic.command.js +11 -4
  119. package/dist/wire/topic/index.d.ts +1 -2
  120. package/dist/wire/topic/index.js +1 -2
  121. package/dist/wire/topic/topic.utils.d.ts +14 -5
  122. package/dist/wire/topic/topic.utils.js +26 -11
  123. package/dist/wire/topic/topic.utils.test.js +13 -13
  124. package/dist/wire/topic/update-topic.command.d.ts +22 -15
  125. package/dist/wire/topic/update-topic.command.js +12 -34
  126. package/dist/wire/user/create-user.command.d.ts +12 -2
  127. package/dist/wire/user/create-user.command.js +7 -3
  128. package/dist/wire/user/index.d.ts +1 -0
  129. package/dist/wire/user/index.js +1 -0
  130. package/dist/wire/user/update-user.command.d.ts +25 -7
  131. package/dist/wire/user/update-user.command.js +18 -31
  132. package/dist/wire/user/user.utils.d.ts +2 -0
  133. package/dist/wire/user/user.utils.js +5 -3
  134. package/dist/wire/vsr/header.d.ts +23 -8
  135. package/dist/wire/vsr/header.js +32 -10
  136. package/dist/wire/vsr/header.test.js +5 -1
  137. package/dist/wire/vsr/index.d.ts +8 -2
  138. package/dist/wire/vsr/index.js +44 -11
  139. package/dist/wire/vsr/operation.d.ts +18 -6
  140. package/dist/wire/vsr/operation.js +46 -16
  141. package/dist/wire/vsr/operation.test.js +32 -8
  142. package/dist/wire/vsr/register.d.ts +10 -6
  143. package/dist/wire/vsr/register.js +24 -7
  144. package/dist/wire/vsr/register.test.js +28 -8
  145. package/dist/wire/vsr/reply.d.ts +4 -1
  146. package/dist/wire/vsr/reply.js +42 -7
  147. package/dist/wire/vsr/reply.test.js +11 -1
  148. package/dist/wire/vsr/session.d.ts +9 -10
  149. package/dist/wire/vsr/session.js +32 -23
  150. package/dist/wire/vsr/session.test.js +33 -7
  151. package/dist/wire/vsr/vsr.test.js +57 -6
  152. package/package.json +6 -7
  153. package/dist/wire/message/flush-unsaved-buffers.command.d.ts +0 -26
  154. package/dist/wire/message/flush-unsaved-buffers.command.js +0 -36
  155. package/dist/wire/stream/purge-stream.command.d.ts +0 -22
  156. package/dist/wire/topic/purge-topic.command.d.ts +0 -24
  157. package/dist/wire/topic/purge-topic.command.js +0 -39
@@ -16,30 +16,50 @@
16
16
  // under the License.
17
17
  import assert from 'node:assert/strict';
18
18
  import { describe, it } from 'node:test';
19
- import { deserializeLoginRegister, IGGY_PROTOCOL_VERSION, serializeLoginRegister, serializeLoginRegisterWithPat } from './register.js';
19
+ import { deserializeLoginRegister, BIND_SECRET_BYTES, IGGY_PROTOCOL_VERSION, serializeBindSession, serializeLoginRegister, serializeLoginRegisterWithPat } from './register.js';
20
+ const BIND_SECRET = Buffer.alloc(BIND_SECRET_BYTES, 0x5a);
20
21
  describe('VSR register payloads', () => {
21
22
  it('encodes protocol, SDK, credentials, and empty context', () => {
22
- const body = serializeLoginRegister('iggy', 'secret', '0.8.1-edge.3');
23
+ const body = serializeLoginRegister('iggy', 'secret', '0.8.1-edge.3', BIND_SECRET);
23
24
  assert.equal(body.readUInt32LE(0), IGGY_PROTOCOL_VERSION);
24
25
  assert.match(body.toString('utf8'), /node-sdk/);
25
26
  assert.match(body.toString('utf8'), /0\.8\.1-edge\.3/);
26
27
  assert.match(body.toString('utf8'), /iggy/);
27
28
  assert.match(body.toString('utf8'), /secret/);
29
+ const secretOffset = 4 + 1 + Buffer.byteLength('node-sdk')
30
+ + 1 + Buffer.byteLength('0.8.1-edge.3');
31
+ assert.deepEqual(body.subarray(secretOffset, secretOffset + BIND_SECRET_BYTES), BIND_SECRET);
28
32
  assert.equal(body.readUInt32LE(body.length - 4), 0);
29
33
  });
30
34
  it('uses UTF-8 byte lengths and enforces one-byte bounds', () => {
31
- const body = serializeLoginRegister('żółw', 'hasło', '版本');
35
+ const body = serializeLoginRegister('żółw', 'hasło', '版本', BIND_SECRET);
32
36
  assert.equal(body.readUInt8(4), Buffer.byteLength('node-sdk'));
33
37
  assert.match(body.toString('utf8'), /żółw/);
34
- assert.throws(() => serializeLoginRegister('', 'secret', '1.0.0'), /1\.\.255 bytes/);
35
- assert.throws(() => serializeLoginRegister('x'.repeat(256), 'secret', '1.0.0'), /1\.\.255 bytes/);
36
- const maximum = serializeLoginRegister('x'.repeat(255), 'secret', '1.0.0');
38
+ assert.throws(() => serializeLoginRegister('', 'secret', '1.0.0', BIND_SECRET), /1\.\.255 bytes/);
39
+ assert.throws(() => serializeLoginRegister('x'.repeat(256), 'secret', '1.0.0', BIND_SECRET), /1\.\.255 bytes/);
40
+ const maximum = serializeLoginRegister('x'.repeat(255), 'secret', '1.0.0', BIND_SECRET);
37
41
  assert.match(maximum.toString('utf8'), /x{255}/);
38
42
  });
39
43
  it('encodes PAT registration and rejects oversized tokens', () => {
40
- const body = serializeLoginRegisterWithPat('token', '0.8.1-edge.3');
44
+ const body = serializeLoginRegisterWithPat('token', '0.8.1-edge.3', BIND_SECRET);
41
45
  assert.match(body.toString('utf8'), /token/);
42
- assert.throws(() => serializeLoginRegisterWithPat('x'.repeat(256), '0.8.1-edge.3'), /token exceeds/);
46
+ assert.throws(() => serializeLoginRegisterWithPat('x'.repeat(256), '0.8.1-edge.3', BIND_SECRET), /token exceeds/);
47
+ const secretOffset = 4 + 1 + Buffer.byteLength('node-sdk')
48
+ + 1 + Buffer.byteLength('0.8.1-edge.3');
49
+ assert.deepEqual(body.subarray(secretOffset, secretOffset + BIND_SECRET_BYTES), BIND_SECRET);
50
+ assert.equal(body.readUInt32LE(body.length - 4), 0);
51
+ });
52
+ it('encodes version, shared identity and proof and rejects invalid bounds', () => {
53
+ const identity = Buffer.alloc(32);
54
+ identity.writeBigUInt64LE(42n, 16);
55
+ const body = serializeBindSession(identity, '1.0.0', BIND_SECRET);
56
+ assert.equal(body.readUInt32LE(0), IGGY_PROTOCOL_VERSION);
57
+ assert.deepEqual(body.subarray(-64, -32), identity);
58
+ assert.deepEqual(body.subarray(-32), BIND_SECRET);
59
+ for (const length of [0, 31, 33]) {
60
+ assert.throws(() => serializeBindSession(Buffer.alloc(length), '1.0.0', BIND_SECRET), /identity must be 32/);
61
+ assert.throws(() => serializeLoginRegister('iggy', 'iggy', '1.0.0', Buffer.alloc(length)), /secret must be 32/);
62
+ }
43
63
  });
44
64
  it('decodes a complete login response', () => {
45
65
  const version = Buffer.from('0.10.3');
@@ -7,8 +7,11 @@
7
7
  */
8
8
  import { ResponseError } from '../error.utils.js';
9
9
  export declare class VsrEvictionError extends ResponseError {
10
- constructor(errorCode: number);
10
+ readonly reason?: number | undefined;
11
+ constructor(errorCode: number, reason?: number | undefined);
11
12
  }
13
+ /** Validate correlation before a marked receipt can affect operation or session state. */
14
+ export declare const validateReplyForRequest: (reply: Buffer, request: Buffer, command: number) => void;
12
15
  /**
13
16
  * Decodes one complete consensus frame into the reply body, stripping the
14
17
  * committed result section for result-framed operations and mapping every
@@ -22,8 +22,8 @@
22
22
  * result section for result-framed operations.
23
23
  */
24
24
  import { ResponseError, responseError } from '../error.utils.js';
25
- import { Command, EvictionReason, HEADER_SIZE, peekCommand, readEviction, readReplyOperation, readSize, readStatus } from './header.js';
26
- import { Operation, isKnownOperation, isResultFramed } from './operation.js';
25
+ import { Command, EvictionReason, HEADER_SIZE, peekCommand, readEviction, readReplyOperation, readSize, readStatus, hasCommittedResult, REQUEST_OFFSET, REPLY_OFFSET } from './header.js';
26
+ import { Operation, isKnownOperation, isResultFramed, requestOperation } from './operation.js';
27
27
  /** `IggyError` codes this funnel raises client-side. */
28
28
  const INVALID_COMMAND = 3;
29
29
  const UNAUTHENTICATED = 40;
@@ -33,15 +33,43 @@ const STALE_CLIENT = 30;
33
33
  const INVALID_FORMAT = 4;
34
34
  const EMPTY_RESPONSE = 304;
35
35
  const INCOMPATIBLE_PROTOCOL_VERSION = 14003;
36
+ const TRANSIENT_NOT_COMMITTED = 57;
37
+ const TRANSIENT_NOT_ACCEPTED = 58;
38
+ const NOT_PRIMARY = 89;
36
39
  const RESULT_COUNT_LEN = 4;
37
40
  const RESULT_ENTRY_LEN = 8;
38
41
  export class VsrEvictionError extends ResponseError {
39
- constructor(errorCode) {
42
+ reason;
43
+ constructor(errorCode, reason) {
40
44
  super(0, errorCode);
45
+ this.reason = reason;
41
46
  this.name = 'VsrEvictionError';
42
47
  Object.setPrototypeOf(this, VsrEvictionError.prototype);
43
48
  }
44
49
  }
50
+ const serverError = (command, code) => {
51
+ const error = responseError(command, code);
52
+ error.fromServer = true;
53
+ return error;
54
+ };
55
+ /** Validate correlation before a marked receipt can affect operation or session state. */
56
+ export const validateReplyForRequest = (reply, request, command) => {
57
+ const operation = request[REQUEST_OFFSET.operation];
58
+ const actual = readReplyOperation(reply);
59
+ if (reply.readBigUInt64LE(REPLY_OFFSET.request) !== request.readBigUInt64LE(REQUEST_OFFSET.request) ||
60
+ requestOperation(actual) !== operation)
61
+ throw responseError(command, INVALID_COMMAND, 'reply does not match request');
62
+ if (!hasCommittedResult(reply))
63
+ return;
64
+ const op = reply.readBigUInt64LE(REPLY_OFFSET.op);
65
+ if (!reply.subarray(REPLY_OFFSET.client, REPLY_OFFSET.client + 16)
66
+ .equals(request.subarray(REQUEST_OFFSET.client, REQUEST_OFFSET.client + 16)) ||
67
+ !reply.subarray(REPLY_OFFSET.requestChecksum, REPLY_OFFSET.requestChecksum + 16)
68
+ .equals(request.subarray(REQUEST_OFFSET.requestChecksum, REQUEST_OFFSET.requestChecksum + 16)) ||
69
+ reply.readBigUInt64LE(REPLY_OFFSET.partitionIncarnation) !== request.readBigUInt64LE(REQUEST_OFFSET.partitionIncarnation) ||
70
+ op === 0n || op !== reply.readBigUInt64LE(REPLY_OFFSET.commit))
71
+ throw responseError(command, INVALID_COMMAND, 'committed receipt does not match original request');
72
+ };
45
73
  /**
46
74
  * Decodes one complete consensus frame into the reply body, stripping the
47
75
  * committed result section for result-framed operations and mapping every
@@ -65,12 +93,19 @@ export const decodeResponse = (frame, commandCode = 0) => {
65
93
  // A pre-commit denial always ships an empty body; the status channel and
66
94
  // the committed result section are mutually exclusive.
67
95
  if (status !== 0)
68
- throw responseError(commandCode, status);
96
+ throw serverError(commandCode, status);
69
97
  const operation = readReplyOperation(frame);
70
98
  if (!isKnownOperation(operation))
71
99
  throw responseError(commandCode, INVALID_COMMAND);
72
100
  const body = frame.subarray(HEADER_SIZE, size);
73
- return splitMetadataResult(operation, body, commandCode);
101
+ try {
102
+ return splitMetadataResult(operation, body, commandCode);
103
+ }
104
+ catch (error) {
105
+ if (error instanceof ResponseError && error.fromServer)
106
+ error.committed = hasCommittedResult(frame) && ![TRANSIENT_NOT_COMMITTED, TRANSIENT_NOT_ACCEPTED, NOT_PRIMARY].includes(error.errorCode);
107
+ throw error;
108
+ }
74
109
  };
75
110
  /**
76
111
  * Strips the committed result section leading a result-framed reply body.
@@ -95,7 +130,7 @@ export const splitMetadataResult = (operation, body, commandCode = 0) => {
95
130
  // IggyError space.
96
131
  const code = body.readUInt32LE(RESULT_COUNT_LEN + 4);
97
132
  if (code !== 0)
98
- throw responseError(commandCode, code);
133
+ throw serverError(commandCode, code);
99
134
  }
100
135
  return body.subarray(sectionLength);
101
136
  };
@@ -145,6 +180,6 @@ export const evictionError = (frame) => {
145
180
  default:
146
181
  errorCode = UNAUTHENTICATED;
147
182
  }
148
- return new VsrEvictionError(errorCode);
183
+ return new VsrEvictionError(errorCode, eviction.reason);
149
184
  };
150
185
  //# sourceMappingURL=reply.js.map
@@ -53,7 +53,7 @@ describe('VSR reply decoding', () => {
53
53
  assert.throws(() => decodeResponse(frame), ResponseError);
54
54
  });
55
55
  it('rejects an undeclared reply operation discriminant', () => {
56
- for (const operation of [69, 150, 163, 255])
56
+ for (const operation of [69, 129, 133, 142, 163, 255])
57
57
  assert.throws(() => decodeResponse(reply(operation)), (error) => error instanceof ResponseError && error.errorCode === 3);
58
58
  });
59
59
  it('passes an empty register body through as a terminal failure', () => {
@@ -73,6 +73,16 @@ describe('VSR reply decoding', () => {
73
73
  const body = Buffer.concat([Buffer.alloc(4), Buffer.from('stream')]);
74
74
  assert.deepEqual(decodeResponse(reply(Operation.CreateStream, body)), Buffer.from('stream'));
75
75
  });
76
+ it('strips the result section of every resolved update reply', () => {
77
+ for (const operation of [Operation.UpdateStreamResolved, Operation.UpdateTopicResolved,
78
+ Operation.UpdateUserResolved]) {
79
+ assert.deepEqual(decodeResponse(reply(operation, Buffer.alloc(4))), Buffer.alloc(0));
80
+ const refused = Buffer.alloc(12);
81
+ refused.writeUInt32LE(1, 0);
82
+ refused.writeUInt32LE(4103, 8);
83
+ assert.throws(() => decodeResponse(reply(operation, refused), COMMAND_CODE.UpdateResource), (error) => error instanceof ResponseError && error.errorCode === 4103);
84
+ }
85
+ });
76
86
  it('strips a non-empty successful result section', () => {
77
87
  const body = Buffer.alloc(12 + 6);
78
88
  body.writeUInt32LE(1, 0);
@@ -3,16 +3,19 @@
3
3
  *
4
4
  * Each client instance generates an ephemeral random `clientId` (u128).
5
5
  * After a Register commits, the server assigns a `session` number (commit op
6
- * number). Every replicated request (metadata and partition) advances a
7
- * monotonic request watermark; non-replicated requests reuse the current
8
- * value because they bypass server-side request sequencing.
6
+ * number). Every request in a bound session advances its shared counter, including
7
+ * reads and requests on auxiliary connections.
9
8
  */
10
9
  export declare class ConsensusSession {
11
10
  private _clientId;
12
11
  private _session;
13
12
  private requestCounter;
14
13
  private registerConsumed;
14
+ private sessionRequestStart;
15
+ readonly bindSecret: Buffer;
16
+ readonly registerRequestIdentifier: Buffer;
15
17
  constructor(clientId?: bigint);
18
+ reset(): void;
16
19
  get clientId(): bigint;
17
20
  /** The bound session number, or null before Register commits. */
18
21
  get session(): bigint | null;
@@ -21,18 +24,14 @@ export declare class ConsensusSession {
21
24
  /** Binds the session after Register commits through consensus. */
22
25
  bind(session: bigint): void;
23
26
  /**
24
- * Begins a registration, re-arming the session for a re-login. A prior
25
- * consumed or bound session is replaced wholesale (fresh client id,
26
- * unbound), so a repeat login encodes a clean Register instead of tripping
27
- * the one-shot guard. Returns the register request id, always 0.
27
+ * Retains the identity and secret so a lost login reply can be retried.
28
+ * A new logical session is created only after explicit logout.
28
29
  */
29
30
  beginRegister(): bigint;
30
31
  /**
31
32
  * Next application request id, advancing the counter: 1, 2, 3, ...
32
33
  * Request 0 is reserved for Register.
33
34
  */
34
- nextRequestId(): bigint;
35
- /** Current counter value without advancing it. */
36
- currentRequestId(): bigint;
35
+ nextRequestId(requireBound?: boolean): bigint;
37
36
  }
38
37
  //# sourceMappingURL=session.d.ts.map
@@ -15,26 +15,40 @@
15
15
  // specific language governing permissions and limitations
16
16
  // under the License.
17
17
  import { randomBytes } from 'node:crypto';
18
+ import { BIND_SECRET_BYTES } from './register.js';
18
19
  const MAX_U64 = 0xffffffffffffffffn;
20
+ const CLIENT_ID_MASK = (1n << 127n) - 1n;
19
21
  /**
20
22
  * Consensus-level session state, ported from `core/sdk/src/session.rs`.
21
23
  *
22
24
  * Each client instance generates an ephemeral random `clientId` (u128).
23
25
  * After a Register commits, the server assigns a `session` number (commit op
24
- * number). Every replicated request (metadata and partition) advances a
25
- * monotonic request watermark; non-replicated requests reuse the current
26
- * value because they bypass server-side request sequencing.
26
+ * number). Every request in a bound session advances its shared counter, including
27
+ * reads and requests on auxiliary connections.
27
28
  */
28
29
  export class ConsensusSession {
29
30
  _clientId;
30
31
  _session;
31
32
  requestCounter;
32
33
  registerConsumed;
34
+ sessionRequestStart = 1n;
35
+ bindSecret;
36
+ registerRequestIdentifier;
33
37
  constructor(clientId) {
34
38
  this._clientId = clientId ?? generateClientId();
35
39
  this._session = null;
36
40
  this.requestCounter = 1n;
37
41
  this.registerConsumed = false;
42
+ this.bindSecret = randomBytes(BIND_SECRET_BYTES);
43
+ this.registerRequestIdentifier = nonzeroIdentifier();
44
+ }
45
+ reset() {
46
+ this._clientId = generateClientId();
47
+ this._session = null;
48
+ this.registerConsumed = false;
49
+ this.sessionRequestStart = this.requestCounter;
50
+ randomBytes(BIND_SECRET_BYTES).copy(this.bindSecret);
51
+ nonzeroIdentifier().copy(this.registerRequestIdentifier);
38
52
  }
39
53
  get clientId() {
40
54
  return this._clientId;
@@ -49,29 +63,21 @@ export class ConsensusSession {
49
63
  get hasActivity() {
50
64
  return this.registerConsumed ||
51
65
  this._session !== null ||
52
- this.requestCounter > 1n;
66
+ this.requestCounter > this.sessionRequestStart;
53
67
  }
54
68
  /** Binds the session after Register commits through consensus. */
55
69
  bind(session) {
56
- if (this._session !== null)
70
+ if (this._session !== null && this._session !== session)
57
71
  throw new Error(`session already bound (session=${this._session})`);
58
72
  if (session <= 0n)
59
73
  throw new Error('session must be > 0');
60
74
  this._session = session;
61
75
  }
62
76
  /**
63
- * Begins a registration, re-arming the session for a re-login. A prior
64
- * consumed or bound session is replaced wholesale (fresh client id,
65
- * unbound), so a repeat login encodes a clean Register instead of tripping
66
- * the one-shot guard. Returns the register request id, always 0.
77
+ * Retains the identity and secret so a lost login reply can be retried.
78
+ * A new logical session is created only after explicit logout.
67
79
  */
68
80
  beginRegister() {
69
- if (this.registerConsumed || this.isBound) {
70
- this._clientId = generateClientId();
71
- this._session = null;
72
- this.requestCounter = 1n;
73
- this.registerConsumed = false;
74
- }
75
81
  this.registerConsumed = true;
76
82
  return 0n;
77
83
  }
@@ -79,8 +85,8 @@ export class ConsensusSession {
79
85
  * Next application request id, advancing the counter: 1, 2, 3, ...
80
86
  * Request 0 is reserved for Register.
81
87
  */
82
- nextRequestId() {
83
- if (!this.isBound)
88
+ nextRequestId(requireBound = true) {
89
+ if (requireBound && !this.isBound)
84
90
  throw new Error('nextRequestId called before bind');
85
91
  if (this.requestCounter === MAX_U64)
86
92
  throw new RangeError('VSR request counter exhausted');
@@ -88,15 +94,18 @@ export class ConsensusSession {
88
94
  this.requestCounter += 1n;
89
95
  return id;
90
96
  }
91
- /** Current counter value without advancing it. */
92
- currentRequestId() {
93
- return this.requestCounter;
94
- }
95
97
  }
96
- /** Ephemeral random u128, non-zero (retry on the astronomically unlikely 0). */
98
+ function nonzeroIdentifier() {
99
+ let identifier;
100
+ do {
101
+ identifier = randomBytes(16);
102
+ } while (identifier.every(byte => byte === 0));
103
+ return identifier;
104
+ }
105
+ /** Keep the top bit clear so generated IDs cannot enter the server-only range. */
97
106
  const generateClientId = () => {
98
107
  for (;;) {
99
- const id = randomBytes(16).reduce((acc, byte) => (acc << 8n) | BigInt(byte), 0n);
108
+ const id = randomBytes(16).reduce((acc, byte) => (acc << 8n) | BigInt(byte), 0n) & CLIENT_ID_MASK;
100
109
  if (id !== 0n)
101
110
  return id;
102
111
  }
@@ -22,7 +22,15 @@ describe('ConsensusSession', () => {
22
22
  const session = new ConsensusSession();
23
23
  assert.notEqual(session.clientId, 0n);
24
24
  assert.equal(session.session, null);
25
- assert.equal(session.currentRequestId(), 1n);
25
+ assert.equal(session.nextRequestId(false), 1n);
26
+ });
27
+ it('generates client IDs with the top bit clear', () => {
28
+ const samples = 64;
29
+ const clientIdLimit = 1n << 127n;
30
+ for (let sample = 0; sample < samples; sample++) {
31
+ const session = new ConsensusSession();
32
+ assert.ok(session.clientId > 0n && session.clientId < clientIdLimit);
33
+ }
26
34
  });
27
35
  it('binds once to a positive server session', () => {
28
36
  const session = new ConsensusSession(7n);
@@ -30,6 +38,7 @@ describe('ConsensusSession', () => {
30
38
  assert.throws(() => session.bind(0n), /must be > 0/);
31
39
  session.bind(42n);
32
40
  assert.equal(session.session, 42n);
41
+ session.bind(42n);
33
42
  assert.throws(() => session.bind(43n), /already bound/);
34
43
  });
35
44
  it('advances metadata IDs monotonically', () => {
@@ -38,7 +47,18 @@ describe('ConsensusSession', () => {
38
47
  session.bind(42n);
39
48
  assert.equal(session.nextRequestId(), 1n);
40
49
  assert.equal(session.nextRequestId(), 2n);
41
- assert.equal(session.currentRequestId(), 3n);
50
+ assert.equal(session.nextRequestId(), 3n);
51
+ });
52
+ it('resets activity and identity while preserving the shared request counter', () => {
53
+ const session = new ConsensusSession(7n);
54
+ assert.equal(session.hasActivity, false);
55
+ assert.equal(session.nextRequestId(false), 1n);
56
+ assert.equal(session.hasActivity, true);
57
+ session.reset();
58
+ assert.equal(session.hasActivity, false);
59
+ assert.notEqual(session.clientId, 7n);
60
+ assert.equal(session.nextRequestId(false), 2n);
61
+ assert.equal(session.hasActivity, true);
42
62
  });
43
63
  it('rejects request IDs before binding and after exhaustion', () => {
44
64
  const session = new ConsensusSession(7n);
@@ -47,14 +67,20 @@ describe('ConsensusSession', () => {
47
67
  session.requestCounter = 0xffffffffffffffffn;
48
68
  assert.throws(() => session.nextRequestId(), /counter exhausted/);
49
69
  });
50
- it('rearms registration with a fresh client ID', () => {
70
+ it('retains identity, proof and request numbering across login retries', () => {
51
71
  const session = new ConsensusSession(7n);
72
+ const proof = Buffer.from(session.bindSecret);
52
73
  session.beginRegister();
53
- session.bind(42n);
54
74
  session.beginRegister();
55
- assert.equal(session.session, null);
56
- assert.equal(session.currentRequestId(), 1n);
57
- assert.notEqual(session.clientId, 7n);
75
+ assert.equal(session.clientId, 7n);
76
+ assert.deepEqual(session.bindSecret, proof);
77
+ session.bind(42n);
78
+ assert.equal(session.nextRequestId(), 1n);
79
+ assert.equal(session.beginRegister(), 0n);
80
+ assert.equal(session.session, 42n);
81
+ assert.equal(session.nextRequestId(), 2n);
82
+ assert.equal(session.clientId, 7n);
83
+ assert.deepEqual(session.bindSecret, proof);
58
84
  });
59
85
  });
60
86
  //# sourceMappingURL=session.test.js.map
@@ -17,13 +17,64 @@
17
17
  import { describe, it } from 'node:test';
18
18
  import assert from 'node:assert/strict';
19
19
  import { ResponseError } from '../error.utils.js';
20
- import { HEADER_SIZE, REQUEST_OFFSET } from './header.js';
20
+ import { Command, COMMITTED_RESULT_MARKER, HEADER_SIZE, REPLY_OFFSET, REQUEST_OFFSET } from './header.js';
21
21
  import { prepareVsrCommand, VsrSession } from './index.js';
22
22
  import { Operation } from './operation.js';
23
+ import { validateReplyForRequest } from './reply.js';
23
24
  import { COMMAND_CODE } from '../command.code.js';
24
25
  import { serializeSendMessages } from '../message/message.utils.js';
25
26
  import { Partitioning } from '../message/partitioning.utils.js';
27
+ const BIND_SECRET = Buffer.alloc(32, 0x5a);
26
28
  describe('VSR custom request framing', () => {
29
+ it('stamps distinct random identifiers for metadata and session requests', () => {
30
+ const payloads = [Buffer.alloc(0), Buffer.from([1, 2, 3]), Buffer.from('abc')];
31
+ for (const command of [COMMAND_CODE.LoginRegister, COMMAND_CODE.LoginRegisterWithAccessToken,
32
+ COMMAND_CODE.LogoutUser, COMMAND_CODE.CreateStream, COMMAND_CODE.DeleteSegments]) {
33
+ const session = new VsrSession(7n);
34
+ session.bind(42n);
35
+ for (const payload of payloads) {
36
+ const frame = session.encode(command, payload);
37
+ const another = session.encode(command, payload);
38
+ const identifier = frame.subarray(REQUEST_OFFSET.requestChecksum, REQUEST_OFFSET.requestChecksum + 16);
39
+ assert.notDeepEqual(identifier, Buffer.alloc(16));
40
+ const repeated = another.subarray(REQUEST_OFFSET.requestChecksum, REQUEST_OFFSET.requestChecksum + 16);
41
+ if (command === COMMAND_CODE.LoginRegister || command === COMMAND_CODE.LoginRegisterWithAccessToken)
42
+ assert.deepEqual(identifier, repeated);
43
+ else
44
+ assert.notDeepEqual(identifier, repeated);
45
+ assert.deepEqual(frame.subarray(HEADER_SIZE), payload);
46
+ }
47
+ }
48
+ });
49
+ it('leaves partition and non-replicated request checksums unstamped', () => {
50
+ const session = new VsrSession(7n);
51
+ session.bind(42n);
52
+ for (const command of [COMMAND_CODE.SendMessages, COMMAND_CODE.StoreOffset,
53
+ COMMAND_CODE.DeleteConsumerOffset, COMMAND_CODE.Ping, COMMAND_CODE.GetPartitionRoute,
54
+ COMMAND_CODE.BindSession, 60_000]) {
55
+ const frame = session.encode(command, Buffer.from([1, 2, 3]));
56
+ assert.deepEqual(frame.subarray(REQUEST_OFFSET.requestChecksum, REQUEST_OFFSET.requestChecksum + 16), Buffer.alloc(16), `command ${command}`);
57
+ }
58
+ });
59
+ it('checks marked metadata receipts against their original payload stamp', () => {
60
+ const session = new VsrSession(7n);
61
+ session.bind(42n);
62
+ const request = session.encode(COMMAND_CODE.CreateStream, Buffer.from([1, 2, 3]));
63
+ const differentPayload = session.encode(COMMAND_CODE.CreateStream, Buffer.from('abc'));
64
+ const receipt = Buffer.alloc(HEADER_SIZE);
65
+ receipt.writeUInt32LE(HEADER_SIZE, REPLY_OFFSET.size);
66
+ receipt[REPLY_OFFSET.command] = Command.Reply;
67
+ receipt[REPLY_OFFSET.operation] = Operation.CreateStream;
68
+ receipt[REPLY_OFFSET.reserved] = COMMITTED_RESULT_MARKER;
69
+ receipt.writeBigUInt64LE(1n, REPLY_OFFSET.op);
70
+ receipt.writeBigUInt64LE(1n, REPLY_OFFSET.commit);
71
+ receipt.writeBigUInt64LE(request.readBigUInt64LE(REQUEST_OFFSET.request), REPLY_OFFSET.request);
72
+ request.copy(receipt, REPLY_OFFSET.client, REQUEST_OFFSET.client, REQUEST_OFFSET.client + 16);
73
+ request.copy(receipt, REPLY_OFFSET.requestChecksum, REQUEST_OFFSET.requestChecksum, REQUEST_OFFSET.requestChecksum + 16);
74
+ assert.doesNotThrow(() => validateReplyForRequest(receipt, request, COMMAND_CODE.CreateStream));
75
+ differentPayload.copy(receipt, REPLY_OFFSET.requestChecksum, REQUEST_OFFSET.requestChecksum, REQUEST_OFFSET.requestChecksum + 16);
76
+ assert.throws(() => validateReplyForRequest(receipt, request, COMMAND_CODE.CreateStream), (error) => error instanceof ResponseError && error.commandCode === COMMAND_CODE.CreateStream && error.errorCode === 3);
77
+ });
27
78
  it('encodes a custom non-replicated code and opaque payload', () => {
28
79
  const session = new VsrSession();
29
80
  const payload = Buffer.from([0xAA, 0xBB, 0xCC]);
@@ -37,24 +88,24 @@ describe('VSR custom request framing', () => {
37
88
  assert.throws(() => session.encode(COMMAND_CODE.CreateStream, Buffer.alloc(0)), (error) => error instanceof ResponseError && error.errorCode === 40);
38
89
  });
39
90
  it('rejects a truncated legacy login payload locally', () => {
40
- assert.throws(() => prepareVsrCommand(COMMAND_CODE.LoginUser, Buffer.alloc(0)));
41
- assert.throws(() => prepareVsrCommand(COMMAND_CODE.LoginUser, Buffer.from([4, 0x69, 0x67])));
91
+ assert.throws(() => prepareVsrCommand(COMMAND_CODE.LoginUser, Buffer.alloc(0), BIND_SECRET));
92
+ assert.throws(() => prepareVsrCommand(COMMAND_CODE.LoginUser, Buffer.from([4, 0x69, 0x67]), BIND_SECRET));
42
93
  });
43
94
  it('converts legacy token login to VSR registration', () => {
44
95
  const token = Buffer.from('secret');
45
- const prepared = prepareVsrCommand(COMMAND_CODE.LoginWithAccessToken, Buffer.concat([Buffer.from([token.length]), token]));
96
+ const prepared = prepareVsrCommand(COMMAND_CODE.LoginWithAccessToken, Buffer.concat([Buffer.from([token.length]), token]), BIND_SECRET);
46
97
  assert.equal(prepared.command, COMMAND_CODE.LoginRegisterWithAccessToken);
47
98
  assert.ok(prepared.payload.includes(token));
48
99
  const frame = new VsrSession(7n).encode(prepared.command, prepared.payload);
49
100
  assert.equal(frame.readUInt8(REQUEST_OFFSET.operation), Operation.Register);
50
101
  });
51
- it('does not advance for non-replicated operations', () => {
102
+ it('assigns distinct request ids to bound reads and writes', () => {
52
103
  const session = new VsrSession(7n);
53
104
  session.bind(42n);
54
105
  const custom = session.encode(60_001, Buffer.alloc(0));
55
106
  assert.equal(custom.readBigUInt64LE(REQUEST_OFFSET.request), 1n);
56
107
  const metadata = session.encode(COMMAND_CODE.CreateStream, Buffer.alloc(0));
57
- assert.equal(metadata.readBigUInt64LE(REQUEST_OFFSET.request), 1n);
108
+ assert.equal(metadata.readBigUInt64LE(REQUEST_OFFSET.request), 2n);
58
109
  assert.equal(metadata.length, HEADER_SIZE);
59
110
  });
60
111
  it('partition operations consume a distinct id per send', () => {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "apache-iggy",
3
3
  "type": "module",
4
- "version": "0.10.0",
4
+ "version": "0.10.1-edge.2",
5
5
  "description": "Official Apache Iggy NodeJS SDK",
6
6
  "keywords": [
7
7
  "iggy",
@@ -50,18 +50,17 @@
50
50
  "author": "github.com/T1B0",
51
51
  "license": "Apache-2.0",
52
52
  "dependencies": {
53
- "@node-rs/xxhash": "1.7.7",
53
+ "@node-rs/xxhash": "1.7.8",
54
54
  "debug": "4.4.3",
55
- "generic-pool": "3.9.0",
56
55
  "uuidv7": "1.2.1"
57
56
  },
58
57
  "devDependencies": {
59
- "@commitlint/cli": "21.2.2",
60
- "@commitlint/config-conventional": "21.2.2",
61
- "@cucumber/cucumber": "13.2.1",
58
+ "@commitlint/cli": "21.2.3",
59
+ "@commitlint/config-conventional": "21.2.3",
60
+ "@cucumber/cucumber": "13.3.0",
62
61
  "@swc-node/register": "1.12.1",
63
62
  "@types/debug": "4.1.13",
64
- "@types/node": "26.4.1",
63
+ "@types/node": "26.6.4",
65
64
  "c8": "^12.0.0",
66
65
  "husky": "9.1.7",
67
66
  "typescript": "6.0.3",
@@ -1,26 +0,0 @@
1
- import { type Id } from '../identifier.utils.js';
2
- /**
3
- * Parameters for the flush unsaved buffers command.
4
- */
5
- export type FlushUnsavedBuffer = {
6
- /** Stream identifier */
7
- streamId: Id;
8
- /** Topic identifier */
9
- topicId: Id;
10
- /** Partition ID to flush */
11
- partitionId: number;
12
- };
13
- /**
14
- * Flush unsaved buffers command definition.
15
- * Forces buffered messages to be persisted to disk.
16
- */
17
- export declare const FLUSH_UNSAVED_BUFFERS: {
18
- code: number;
19
- serialize: ({ streamId, topicId, partitionId }: FlushUnsavedBuffer) => Buffer<ArrayBuffer>;
20
- deserialize: (r: import("../../index.js").CommandResponse) => boolean;
21
- };
22
- /**
23
- * Executable flush unsaved buffers command function.
24
- */
25
- export declare const flushUnsavedBuffers: (getClient: import("../../index.js").ClientProvider) => (arg: FlushUnsavedBuffer) => Promise<boolean>;
26
- //# sourceMappingURL=flush-unsaved-buffers.command.d.ts.map
@@ -1,36 +0,0 @@
1
- // Licensed to the Apache Software Foundation (ASF) under one
2
- // or more contributor license agreements. See the NOTICE file
3
- // distributed with this work for additional information
4
- // regarding copyright ownership. The ASF licenses this file
5
- // to you under the Apache License, Version 2.0 (the
6
- // "License"); you may not use this file except in compliance
7
- // with the License. You may obtain a copy of the License at
8
- //
9
- // http://www.apache.org/licenses/LICENSE-2.0
10
- //
11
- // Unless required by applicable law or agreed to in writing,
12
- // software distributed under the License is distributed on an
13
- // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14
- // KIND, either express or implied. See the License for the
15
- // specific language governing permissions and limitations
16
- // under the License.
17
- import { serializeFlushUnsavedBuffers } from './message.utils.js';
18
- import { deserializeVoidResponse } from '../../client/client.utils.js';
19
- import { wrapCommand } from '../command.utils.js';
20
- import { COMMAND_CODE } from '../command.code.js';
21
- /**
22
- * Flush unsaved buffers command definition.
23
- * Forces buffered messages to be persisted to disk.
24
- */
25
- export const FLUSH_UNSAVED_BUFFERS = {
26
- code: COMMAND_CODE.FlushUnsavedBuffers,
27
- serialize: ({ streamId, topicId, partitionId }) => {
28
- return serializeFlushUnsavedBuffers(streamId, topicId, partitionId);
29
- },
30
- deserialize: deserializeVoidResponse
31
- };
32
- /**
33
- * Executable flush unsaved buffers command function.
34
- */
35
- export const flushUnsavedBuffers = wrapCommand(FLUSH_UNSAVED_BUFFERS);
36
- //# sourceMappingURL=flush-unsaved-buffers.command.js.map
@@ -1,22 +0,0 @@
1
- import { type Id } from '../identifier.utils.js';
2
- /**
3
- * Parameters for the purge stream command.
4
- */
5
- export type PurgeStream = {
6
- /** Stream identifier (ID or name) */
7
- streamId: Id;
8
- };
9
- /**
10
- * Purge stream command definition.
11
- * Deletes all messages from a stream while keeping the stream itself.
12
- */
13
- export declare const PURGE_STREAM: {
14
- code: number;
15
- serialize: ({ streamId }: PurgeStream) => Buffer<ArrayBufferLike>;
16
- deserialize: (r: import("../../index.js").CommandResponse) => boolean;
17
- };
18
- /**
19
- * Executable purge stream command function.
20
- */
21
- export declare const purgeStream: (getClient: import("../../index.js").ClientProvider) => (arg: PurgeStream) => Promise<boolean>;
22
- //# sourceMappingURL=purge-stream.command.d.ts.map