apache-iggy 0.10.0-edge.2 → 0.10.0-edge.3
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/README.md +7 -1
- package/dist/client/client.config.d.ts +7 -0
- package/dist/client/client.config.js +17 -0
- package/dist/client/client.config.test.js +18 -1
- package/dist/client/client.socket.js +3 -0
- package/dist/client/client.type.d.ts +6 -1
- package/dist/debug.js +2 -2
- package/dist/e2e/tcp.topic.e2e.js +1 -2
- package/dist/wire/command-set.d.ts +1 -0
- package/dist/wire/command-set.js +3 -1
- package/dist/wire/command.code.d.ts +1 -0
- package/dist/wire/command.code.js +1 -0
- package/dist/wire/message/header.type.d.ts +1 -1
- package/dist/wire/options.utils.d.ts +62 -0
- package/dist/wire/options.utils.js +144 -0
- package/dist/wire/options.utils.test.d.ts +2 -0
- package/dist/wire/options.utils.test.js +102 -0
- package/dist/wire/stream/stream.utils.d.ts +3 -0
- package/dist/wire/stream/stream.utils.js +4 -2
- package/dist/wire/system/describe-options.command.d.ts +33 -0
- package/dist/wire/system/describe-options.command.js +63 -0
- package/dist/wire/system/index.d.ts +1 -0
- package/dist/wire/system/index.js +1 -0
- package/dist/wire/topic/create-topic.command.d.ts +31 -5
- package/dist/wire/topic/create-topic.command.js +52 -10
- package/dist/wire/topic/create-topic.command.test.js +63 -12
- package/dist/wire/topic/topic.utils.d.ts +20 -4
- package/dist/wire/topic/topic.utils.js +35 -15
- package/dist/wire/topic/topic.utils.test.js +50 -13
- package/dist/wire/topic/update-topic.command.d.ts +17 -5
- package/dist/wire/topic/update-topic.command.js +27 -9
- package/dist/wire/user/user.utils.d.ts +3 -0
- package/dist/wire/user/user.utils.js +4 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -71,7 +71,13 @@ new session. Transient not-committed responses retry the exact encoded request
|
|
|
71
71
|
within one bounded deadline. A disconnected mutation is never replayed under a
|
|
72
72
|
new session.
|
|
73
73
|
|
|
74
|
-
|
|
74
|
+
The client pings every `heartbeatInterval` milliseconds, 5000 by default, which
|
|
75
|
+
keeps an idle session alive when the server's `[heartbeat]` eviction is enabled.
|
|
76
|
+
The server evicts a connection silent for 36 s, which is 1.2 x its 30 s
|
|
77
|
+
heartbeat interval. Raising the client interval past that window, or setting it
|
|
78
|
+
to 0 to disable client heartbeats, exposes an idle consumer-group member to
|
|
79
|
+
eviction; a connection holding no group membership is left alone. Any other
|
|
80
|
+
unusable value is rejected instead of silently disabling the heartbeat.
|
|
75
81
|
|
|
76
82
|
`sendBinaryRequest(code, payload)` sends an arbitrary command code. Known replicated commands use their registered operation, while unknown codes reach the server as non-replicated requests and are rejected by servers that do not register them.
|
|
77
83
|
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import type { ClientConfig } from './client.type.js';
|
|
2
2
|
export declare const DEFAULT_MAX_RESPONSE_FRAME_SIZE: number;
|
|
3
|
+
/**
|
|
4
|
+
* The server evicts a connection silent for 36 s (1.2 x its 30 s heartbeat
|
|
5
|
+
* interval). 5 s matches the other SDKs and survives several skipped pings.
|
|
6
|
+
*/
|
|
7
|
+
export declare const DEFAULT_HEARTBEAT_INTERVAL: number;
|
|
8
|
+
/** Node's largest timer delay: setInterval clamps anything above it to 1 ms. */
|
|
9
|
+
export declare const MAX_HEARTBEAT_INTERVAL = 2147483647;
|
|
3
10
|
export declare const normalizeClientConfig: (config: ClientConfig) => ClientConfig;
|
|
4
11
|
//# sourceMappingURL=client.config.d.ts.map
|
|
@@ -15,6 +15,13 @@
|
|
|
15
15
|
// specific language governing permissions and limitations
|
|
16
16
|
// under the License.
|
|
17
17
|
export const DEFAULT_MAX_RESPONSE_FRAME_SIZE = 64 * 1024 * 1024;
|
|
18
|
+
/**
|
|
19
|
+
* The server evicts a connection silent for 36 s (1.2 x its 30 s heartbeat
|
|
20
|
+
* interval). 5 s matches the other SDKs and survives several skipped pings.
|
|
21
|
+
*/
|
|
22
|
+
export const DEFAULT_HEARTBEAT_INTERVAL = 5 * 1000;
|
|
23
|
+
/** Node's largest timer delay: setInterval clamps anything above it to 1 ms. */
|
|
24
|
+
export const MAX_HEARTBEAT_INTERVAL = 2_147_483_647;
|
|
18
25
|
export const normalizeClientConfig = (config) => {
|
|
19
26
|
const maxResponseFrameSize = config.maxResponseFrameSize ?? DEFAULT_MAX_RESPONSE_FRAME_SIZE;
|
|
20
27
|
if (!Number.isSafeInteger(maxResponseFrameSize) ||
|
|
@@ -22,10 +29,20 @@ export const normalizeClientConfig = (config) => {
|
|
|
22
29
|
throw new TypeError('maxResponseFrameSize must be a safe integer of at least 256 bytes');
|
|
23
30
|
if ((config.poolSize?.min ?? 1) > 1 || (config.poolSize?.max ?? 1) > 1)
|
|
24
31
|
throw new TypeError('VSR clients currently support exactly one pooled connection');
|
|
32
|
+
// Only 0 disables the heartbeat. Anything else unusable has to throw here:
|
|
33
|
+
// the default below is nullish-only, so a value that is negative, fractional
|
|
34
|
+
// or above MAX_HEARTBEAT_INTERVAL reaches setInterval, which clamps it to
|
|
35
|
+
// 1 ms and floods the server.
|
|
36
|
+
const heartbeatInterval = config.heartbeatInterval ?? DEFAULT_HEARTBEAT_INTERVAL;
|
|
37
|
+
if (!Number.isSafeInteger(heartbeatInterval) ||
|
|
38
|
+
heartbeatInterval < 0 ||
|
|
39
|
+
heartbeatInterval > MAX_HEARTBEAT_INTERVAL)
|
|
40
|
+
throw new TypeError(`heartbeatInterval must be a safe integer of milliseconds between 0 and ${MAX_HEARTBEAT_INTERVAL} (0 disables heartbeats)`);
|
|
25
41
|
return {
|
|
26
42
|
...config,
|
|
27
43
|
options: { ...config.options },
|
|
28
44
|
maxResponseFrameSize,
|
|
45
|
+
heartbeatInterval,
|
|
29
46
|
poolSize: { min: 1, max: 1 }
|
|
30
47
|
};
|
|
31
48
|
};
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// under the License.
|
|
17
17
|
import assert from 'node:assert/strict';
|
|
18
18
|
import { describe, it } from 'node:test';
|
|
19
|
-
import { DEFAULT_MAX_RESPONSE_FRAME_SIZE, normalizeClientConfig } from './client.config.js';
|
|
19
|
+
import { DEFAULT_HEARTBEAT_INTERVAL, DEFAULT_MAX_RESPONSE_FRAME_SIZE, normalizeClientConfig } from './client.config.js';
|
|
20
20
|
const config = () => ({
|
|
21
21
|
transport: 'TCP',
|
|
22
22
|
options: { host: '127.0.0.1', port: 8090 },
|
|
@@ -27,6 +27,23 @@ describe('normalizeClientConfig', () => {
|
|
|
27
27
|
const normalized = normalizeClientConfig(config());
|
|
28
28
|
assert.equal(normalized.maxResponseFrameSize, DEFAULT_MAX_RESPONSE_FRAME_SIZE);
|
|
29
29
|
});
|
|
30
|
+
it('enables the heartbeat by default and honours an explicit interval', () => {
|
|
31
|
+
assert.equal(DEFAULT_HEARTBEAT_INTERVAL, 5000);
|
|
32
|
+
assert.equal(normalizeClientConfig(config()).heartbeatInterval, DEFAULT_HEARTBEAT_INTERVAL);
|
|
33
|
+
assert.equal(normalizeClientConfig({ ...config(), heartbeatInterval: 1000 })
|
|
34
|
+
.heartbeatInterval, 1000);
|
|
35
|
+
assert.equal(normalizeClientConfig({ ...config(), heartbeatInterval: 0 })
|
|
36
|
+
.heartbeatInterval, 0);
|
|
37
|
+
});
|
|
38
|
+
it('rejects unusable heartbeat intervals', () => {
|
|
39
|
+
for (const heartbeatInterval of [
|
|
40
|
+
-1, -5000, Number.NaN, 1.5, 2_147_483_648, 2 ** 32, Number.MAX_VALUE
|
|
41
|
+
])
|
|
42
|
+
assert.throws(() => normalizeClientConfig({
|
|
43
|
+
...config(),
|
|
44
|
+
heartbeatInterval
|
|
45
|
+
}), /heartbeatInterval/);
|
|
46
|
+
});
|
|
30
47
|
it('restricts the client to one pooled connection', () => {
|
|
31
48
|
const normalized = normalizeClientConfig(config());
|
|
32
49
|
assert.deepEqual(normalized.poolSize, { min: 1, max: 1 });
|
|
@@ -519,6 +519,9 @@ export class CommandResponseStream extends EventEmitter {
|
|
|
519
519
|
}
|
|
520
520
|
}
|
|
521
521
|
}, interval);
|
|
522
|
+
// A pending heartbeat must not be the reason the process stays up: a script
|
|
523
|
+
// that never calls destroy() would otherwise hang on exit.
|
|
524
|
+
this.heartbeatIntervalHandler.unref();
|
|
522
525
|
}
|
|
523
526
|
/**
|
|
524
527
|
* Returns the underlying socket as a readable stream.
|
|
@@ -125,7 +125,12 @@ export type ClientConfig = {
|
|
|
125
125
|
poolSize?: PoolSizeOption;
|
|
126
126
|
/** Automatic reconnection configuration */
|
|
127
127
|
reconnect?: ReconnectOption;
|
|
128
|
-
/**
|
|
128
|
+
/**
|
|
129
|
+
* Interval for sending heartbeat pings in milliseconds, as an integer
|
|
130
|
+
* between 0 and Node's timer ceiling. Defaults to 5000. Set to 0 to disable
|
|
131
|
+
* client heartbeats; any other unusable value is rejected rather than
|
|
132
|
+
* silently disabling them.
|
|
133
|
+
*/
|
|
129
134
|
heartbeatInterval?: number;
|
|
130
135
|
/** Maximum accepted response frame size in bytes */
|
|
131
136
|
maxResponseFrameSize?: number;
|
package/dist/debug.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// under the License.
|
|
17
17
|
//
|
|
18
18
|
import assert from 'node:assert/strict';
|
|
19
|
-
import { Client } from './client/index.js';
|
|
19
|
+
import { Client, DEFAULT_HEARTBEAT_INTERVAL } from './client/index.js';
|
|
20
20
|
import { uuidv7, uuidv4 } from 'uuidv7';
|
|
21
21
|
import { groupConsumerStream } from './stream/consumer-stream.js';
|
|
22
22
|
import { PollingStrategy } from './wire/index.js';
|
|
@@ -41,7 +41,7 @@ const opt = {
|
|
|
41
41
|
interval: 10 * 1000,
|
|
42
42
|
maxRetries: 10
|
|
43
43
|
},
|
|
44
|
-
heartbeatInterval:
|
|
44
|
+
heartbeatInterval: DEFAULT_HEARTBEAT_INTERVAL
|
|
45
45
|
};
|
|
46
46
|
const c = new Client(opt);
|
|
47
47
|
const cleanup = async () => {
|
|
@@ -82,6 +82,7 @@ type MessageAPI = ReturnType<typeof messageAPI>;
|
|
|
82
82
|
declare const systemAPI: (c: ClientProvider) => {
|
|
83
83
|
ping: (arg: void) => Promise<boolean>;
|
|
84
84
|
getStats: (arg: void) => Promise<import("./system/get-stats.command.js").Stats>;
|
|
85
|
+
describeOptions: (arg: import("./system/describe-options.command.js").DescribeOptions) => Promise<import("./system/describe-options.command.js").OptionSpec[]>;
|
|
85
86
|
};
|
|
86
87
|
type SystemAPI = ReturnType<typeof systemAPI>;
|
|
87
88
|
declare const clusterAPI: (c: ClientProvider) => {
|
package/dist/wire/command-set.js
CHANGED
|
@@ -53,6 +53,7 @@ import { ensureStream } from './stream/ensure-stream.virtual.command.js';
|
|
|
53
53
|
import { createPartition } from './partition/create-partition.command.js';
|
|
54
54
|
import { deletePartition } from './partition/delete-partition.command.js';
|
|
55
55
|
import { deleteSegments } from './segment/delete-segments.command.js';
|
|
56
|
+
import { describeOptions } from './system/describe-options.command.js';
|
|
56
57
|
import { getStats } from './system/get-stats.command.js';
|
|
57
58
|
import { ping } from './system/ping.command.js';
|
|
58
59
|
import { getClusterMetadata } from './cluster/get-cluster-metadata.command.js';
|
|
@@ -137,7 +138,8 @@ const messageAPI = (c) => ({
|
|
|
137
138
|
});
|
|
138
139
|
const systemAPI = (c) => ({
|
|
139
140
|
ping: ping(c),
|
|
140
|
-
getStats: getStats(c)
|
|
141
|
+
getStats: getStats(c),
|
|
142
|
+
describeOptions: describeOptions(c)
|
|
141
143
|
});
|
|
142
144
|
const clusterAPI = (c) => ({
|
|
143
145
|
getClusterMetadata: getClusterMetadata(c)
|
|
@@ -27,7 +27,7 @@ export type HeaderKindId = keyof HeaderKind;
|
|
|
27
27
|
/** Numeric values of header kinds */
|
|
28
28
|
export type HeaderKindValue = ValueOf<HeaderKind>;
|
|
29
29
|
/** Reverse mapping from numeric value to header kind name */
|
|
30
|
-
export declare const ReverseHeaderKind: Record<2 | 1 | 10 | 11 | 12 |
|
|
30
|
+
export declare const ReverseHeaderKind: Record<2 | 1 | 10 | 11 | 12 | 13 | 5 | 8 | 3 | 4 | 6 | 7 | 9 | 14 | 15, "Raw" | "String" | "Bool" | "Int8" | "Int16" | "Int32" | "Int64" | "Int128" | "Uint8" | "Uint16" | "Uint32" | "Uint64" | "Uint128" | "Float" | "Double">;
|
|
31
31
|
/** Returns expected byte size for a header kind, or -1 for variable-size kinds */
|
|
32
32
|
export declare const expectedSize: (kind: number) => number;
|
|
33
33
|
/** Raw binary header value */
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { type HeaderValue, type ParsedHeaderValue } from './message/header.utils.js';
|
|
2
|
+
/** Maximum number of key-value entries in one options block. */
|
|
3
|
+
export declare const MAX_OPTIONS = 1024;
|
|
4
|
+
/**
|
|
5
|
+
* Maximum total byte length of an encoded options block.
|
|
6
|
+
*
|
|
7
|
+
* Mirrors the Rust `MAX_OPTIONS_BYTES`, which in turn mirrors the user-headers
|
|
8
|
+
* budget: options ride that codec and inherit its limit.
|
|
9
|
+
*/
|
|
10
|
+
export declare const MAX_OPTIONS_BYTES: number;
|
|
11
|
+
/** A resource option entry: UTF-8 string key with a typed value. */
|
|
12
|
+
export type OptionEntry = {
|
|
13
|
+
key: string;
|
|
14
|
+
value: HeaderValue;
|
|
15
|
+
};
|
|
16
|
+
/** Deserialized options block keyed by option name. */
|
|
17
|
+
export type ParsedOptions = Record<string, ParsedHeaderValue>;
|
|
18
|
+
/** Result of deserializing a length-prefixed options block. */
|
|
19
|
+
export type OptionsDeserialized = {
|
|
20
|
+
/** Number of bytes consumed, length prefix included */
|
|
21
|
+
bytesRead: number;
|
|
22
|
+
/** Deserialized options */
|
|
23
|
+
options: ParsedOptions;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Keeps the last entry for each key, preserving first-seen order.
|
|
27
|
+
*
|
|
28
|
+
* A block carrying a key twice is refused whole by wire validation, so callers
|
|
29
|
+
* that append their own entries ahead of the typed ones rely on this to let the
|
|
30
|
+
* typed value win.
|
|
31
|
+
*/
|
|
32
|
+
export declare const dedupeOptions: (options: OptionEntry[]) => OptionEntry[];
|
|
33
|
+
/**
|
|
34
|
+
* Serializes resource options into a TLV block.
|
|
35
|
+
* Reuses the user-headers TLV encoding: each field is
|
|
36
|
+
* `[kind:u8][len:u32_le][bytes]`, alternating key, value.
|
|
37
|
+
* Empty options serialize to zero bytes.
|
|
38
|
+
*
|
|
39
|
+
* @param options - Option entries to serialize
|
|
40
|
+
* @returns Serialized options block
|
|
41
|
+
* @throws Error if an options constraint is violated
|
|
42
|
+
*/
|
|
43
|
+
export declare const serializeOptions: (options: OptionEntry[]) => Buffer;
|
|
44
|
+
/**
|
|
45
|
+
* Deserializes a bare options TLV block spanning `[pos, end)`.
|
|
46
|
+
*
|
|
47
|
+
* @param p - Buffer containing the options block
|
|
48
|
+
* @param pos - Starting position of the block
|
|
49
|
+
* @param end - End position of the block (exclusive)
|
|
50
|
+
* @returns Deserialized options keyed by option name
|
|
51
|
+
* @throws Error if a key is not a string or the block is malformed
|
|
52
|
+
*/
|
|
53
|
+
export declare const deserializeOptions: (p: Buffer, pos?: number, end?: number) => ParsedOptions;
|
|
54
|
+
/**
|
|
55
|
+
* Deserializes a `u32_le`-length-prefixed options block at `pos`.
|
|
56
|
+
*
|
|
57
|
+
* @param p - Buffer containing `[options_len:u32_le][options TLV]`
|
|
58
|
+
* @param pos - Starting position of the length prefix
|
|
59
|
+
* @returns Bytes consumed (prefix included) and deserialized options
|
|
60
|
+
*/
|
|
61
|
+
export declare const deserializePrefixedOptions: (p: Buffer, pos?: number) => OptionsDeserialized;
|
|
62
|
+
//# sourceMappingURL=options.utils.d.ts.map
|
|
@@ -0,0 +1,144 @@
|
|
|
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
|
+
//
|
|
18
|
+
import { HeaderKind } from './message/header.type.js';
|
|
19
|
+
import { serializeHeaders, serializeHeaderValue, deserializeHeaderValue, HeaderKeyFactory, } from './message/header.utils.js';
|
|
20
|
+
/** Maximum number of key-value entries in one options block. */
|
|
21
|
+
export const MAX_OPTIONS = 1024;
|
|
22
|
+
/**
|
|
23
|
+
* Maximum total byte length of an encoded options block.
|
|
24
|
+
*
|
|
25
|
+
* Mirrors the Rust `MAX_OPTIONS_BYTES`, which in turn mirrors the user-headers
|
|
26
|
+
* budget: options ride that codec and inherit its limit.
|
|
27
|
+
*/
|
|
28
|
+
export const MAX_OPTIONS_BYTES = 100 * 1000;
|
|
29
|
+
/**
|
|
30
|
+
* Key and value length bound, in encoded bytes rather than characters.
|
|
31
|
+
* Inherited from the header-field codec rather than being an options-specific
|
|
32
|
+
* rule: the server refuses a block carrying a field outside this range.
|
|
33
|
+
*/
|
|
34
|
+
const MAX_HEADER_FIELD_LENGTH = 255;
|
|
35
|
+
/**
|
|
36
|
+
* Keeps the last entry for each key, preserving first-seen order.
|
|
37
|
+
*
|
|
38
|
+
* A block carrying a key twice is refused whole by wire validation, so callers
|
|
39
|
+
* that append their own entries ahead of the typed ones rely on this to let the
|
|
40
|
+
* typed value win.
|
|
41
|
+
*/
|
|
42
|
+
export const dedupeOptions = (options) => {
|
|
43
|
+
const byKey = new Map();
|
|
44
|
+
for (const entry of options)
|
|
45
|
+
byKey.set(entry.key, entry);
|
|
46
|
+
return [...byKey.values()];
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Rejects a key or value the codec cannot express.
|
|
50
|
+
*
|
|
51
|
+
* `serializeHeaders` writes whatever field length it is handed, so without
|
|
52
|
+
* this the block leaves here well-formed and comes back as a generic server
|
|
53
|
+
* error naming neither the key nor the bound it broke.
|
|
54
|
+
*/
|
|
55
|
+
const checkFieldLength = (length, field) => {
|
|
56
|
+
if (length < 1 || length > MAX_HEADER_FIELD_LENGTH)
|
|
57
|
+
throw new Error(`Invalid option ${field} length: ${length} bytes, ` +
|
|
58
|
+
`must be between 1 and ${MAX_HEADER_FIELD_LENGTH}`);
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* Serializes resource options into a TLV block.
|
|
62
|
+
* Reuses the user-headers TLV encoding: each field is
|
|
63
|
+
* `[kind:u8][len:u32_le][bytes]`, alternating key, value.
|
|
64
|
+
* Empty options serialize to zero bytes.
|
|
65
|
+
*
|
|
66
|
+
* @param options - Option entries to serialize
|
|
67
|
+
* @returns Serialized options block
|
|
68
|
+
* @throws Error if an options constraint is violated
|
|
69
|
+
*/
|
|
70
|
+
export const serializeOptions = (options) => {
|
|
71
|
+
if (options.length > MAX_OPTIONS)
|
|
72
|
+
throw new Error(`Options block has ${options.length} entries, exceeds maximum ${MAX_OPTIONS}`);
|
|
73
|
+
for (const { key, value } of options) {
|
|
74
|
+
checkFieldLength(Buffer.byteLength(key), `key '${key}'`);
|
|
75
|
+
checkFieldLength(serializeHeaderValue(value).length, `value for key '${key}'`);
|
|
76
|
+
}
|
|
77
|
+
const block = serializeHeaders(options.map(({ key, value }) => ({ key: HeaderKeyFactory.String(key), value })));
|
|
78
|
+
if (block.length > MAX_OPTIONS_BYTES)
|
|
79
|
+
throw new Error(`Options block is ${block.length} bytes, exceeds maximum ${MAX_OPTIONS_BYTES}`);
|
|
80
|
+
return block;
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* Deserializes a bare options TLV block spanning `[pos, end)`.
|
|
84
|
+
*
|
|
85
|
+
* @param p - Buffer containing the options block
|
|
86
|
+
* @param pos - Starting position of the block
|
|
87
|
+
* @param end - End position of the block (exclusive)
|
|
88
|
+
* @returns Deserialized options keyed by option name
|
|
89
|
+
* @throws Error if a key is not a string or the block is malformed
|
|
90
|
+
*/
|
|
91
|
+
export const deserializeOptions = (p, pos = 0, end = p.length) => {
|
|
92
|
+
const options = {};
|
|
93
|
+
while (pos < end) {
|
|
94
|
+
const keyKind = p.readUInt8(pos);
|
|
95
|
+
if (keyKind !== HeaderKind.String)
|
|
96
|
+
throw new Error(`Option key kind ${keyKind} is not a string`);
|
|
97
|
+
const keyLength = p.readUInt32LE(pos + 1);
|
|
98
|
+
if (keyLength < 1 || keyLength > MAX_HEADER_FIELD_LENGTH)
|
|
99
|
+
throw new Error(`Invalid option key length: ${keyLength}, ` +
|
|
100
|
+
`must be between 1 and ${MAX_HEADER_FIELD_LENGTH}`);
|
|
101
|
+
if (pos + 5 + keyLength > end)
|
|
102
|
+
throw new Error('Option key overruns the block');
|
|
103
|
+
const key = p.subarray(pos + 5, pos + 5 + keyLength).toString();
|
|
104
|
+
pos += 5 + keyLength;
|
|
105
|
+
const valueKind = p.readUInt8(pos);
|
|
106
|
+
const valueLength = p.readUInt32LE(pos + 1);
|
|
107
|
+
if (pos + 5 + valueLength > end)
|
|
108
|
+
throw new Error(`Option value for key '${key}' overruns the block`);
|
|
109
|
+
const valueBytes = p.subarray(pos + 5, pos + 5 + valueLength);
|
|
110
|
+
pos += 5 + valueLength;
|
|
111
|
+
let value;
|
|
112
|
+
try {
|
|
113
|
+
value = deserializeHeaderValue(valueKind, valueBytes);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// Unknown value kinds stay raw bytes, mirroring the wire
|
|
117
|
+
// forward-compatibility contract for options.
|
|
118
|
+
value = valueBytes;
|
|
119
|
+
}
|
|
120
|
+
options[key] = value;
|
|
121
|
+
}
|
|
122
|
+
return options;
|
|
123
|
+
};
|
|
124
|
+
/**
|
|
125
|
+
* Deserializes a `u32_le`-length-prefixed options block at `pos`.
|
|
126
|
+
*
|
|
127
|
+
* @param p - Buffer containing `[options_len:u32_le][options TLV]`
|
|
128
|
+
* @param pos - Starting position of the length prefix
|
|
129
|
+
* @returns Bytes consumed (prefix included) and deserialized options
|
|
130
|
+
*/
|
|
131
|
+
export const deserializePrefixedOptions = (p, pos = 0) => {
|
|
132
|
+
const length = p.readUInt32LE(pos);
|
|
133
|
+
const end = pos + 4 + length;
|
|
134
|
+
// Without this, `subarray` clamps a truncated block silently: a known-kind
|
|
135
|
+
// value throws inside `deserializeHeaderValue`, the forward-compat catch
|
|
136
|
+
// swallows it and hands back raw bytes, and `bytesRead` over-reports so every
|
|
137
|
+
// later field decodes from the wrong offset.
|
|
138
|
+
if (end > p.length)
|
|
139
|
+
throw new Error(`Options block overruns the payload: ${length} bytes declared at ${pos}, ` +
|
|
140
|
+
`${p.length - pos - 4} available`);
|
|
141
|
+
const options = deserializeOptions(p, pos + 4, end);
|
|
142
|
+
return { bytesRead: 4 + length, options };
|
|
143
|
+
};
|
|
144
|
+
//# sourceMappingURL=options.utils.js.map
|
|
@@ -0,0 +1,102 @@
|
|
|
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
|
+
//
|
|
18
|
+
import { describe, it } from 'node:test';
|
|
19
|
+
import assert from 'node:assert/strict';
|
|
20
|
+
import { serializeOptions, deserializePrefixedOptions } from './options.utils.js';
|
|
21
|
+
import { HeaderValue } from './message/header.utils.js';
|
|
22
|
+
const prefixed = (block) => {
|
|
23
|
+
const length = Buffer.alloc(4);
|
|
24
|
+
length.writeUInt32LE(block.length, 0);
|
|
25
|
+
return Buffer.concat([length, block]);
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* The cross-SDK golden vector for an options block.
|
|
29
|
+
*
|
|
30
|
+
* Rust pins the identical bytes in `core/binary_protocol/src/primitives/options.rs`,
|
|
31
|
+
* as do the Go and Java SDKs. Round-tripping through this SDK's own decoder proves
|
|
32
|
+
* nothing about interoperability; these bytes are the contract.
|
|
33
|
+
*/
|
|
34
|
+
const GOLDEN_OPTIONS_BLOCK = Buffer.from([
|
|
35
|
+
2, 13, 0, 0, 0,
|
|
36
|
+
...Buffer.from('enforce_fsync'),
|
|
37
|
+
3, 1, 0, 0, 0, 1,
|
|
38
|
+
2, 12, 0, 0, 0,
|
|
39
|
+
...Buffer.from('segment_size'),
|
|
40
|
+
12, 8, 0, 0, 0,
|
|
41
|
+
0, 0, 0, 64, 0, 0, 0, 0
|
|
42
|
+
]);
|
|
43
|
+
describe('serializeOptions', () => {
|
|
44
|
+
it('encodes the cross-SDK golden vector byte for byte', () => {
|
|
45
|
+
const encoded = serializeOptions([
|
|
46
|
+
{ key: 'enforce_fsync', value: HeaderValue.Bool(true) },
|
|
47
|
+
{ key: 'segment_size', value: HeaderValue.Uint64(1073741824n) }
|
|
48
|
+
]);
|
|
49
|
+
assert.deepEqual(encoded, GOLDEN_OPTIONS_BLOCK);
|
|
50
|
+
});
|
|
51
|
+
it('refuses an empty key', () => {
|
|
52
|
+
assert.throws(() => serializeOptions([{ key: '', value: HeaderValue.Bool(true) }]), /Invalid option key '' length: 0 bytes/);
|
|
53
|
+
});
|
|
54
|
+
it('refuses a key past the field bound', () => {
|
|
55
|
+
assert.throws(() => serializeOptions([
|
|
56
|
+
{ key: 'k'.repeat(256), value: HeaderValue.Bool(true) }
|
|
57
|
+
]), /length: 256 bytes, must be between 1 and 255/);
|
|
58
|
+
});
|
|
59
|
+
it('bounds the key on encoded bytes, not characters', () => {
|
|
60
|
+
// 128 two-byte characters: a character count reads 128 and lets this
|
|
61
|
+
// through, leaving the server to refuse the whole block.
|
|
62
|
+
assert.throws(() => serializeOptions([
|
|
63
|
+
{ key: 'ключ'.repeat(32), value: HeaderValue.Bool(true) }
|
|
64
|
+
]), /length: 256 bytes/);
|
|
65
|
+
});
|
|
66
|
+
it('accepts a key exactly at the field bound', () => {
|
|
67
|
+
const encoded = serializeOptions([
|
|
68
|
+
{ key: 'k'.repeat(255), value: HeaderValue.Bool(true) }
|
|
69
|
+
]);
|
|
70
|
+
assert.equal(encoded.readUInt32LE(1), 255);
|
|
71
|
+
});
|
|
72
|
+
it('refuses a value past the field bound', () => {
|
|
73
|
+
assert.throws(() => serializeOptions([
|
|
74
|
+
{ key: 'segment_size', value: HeaderValue.String('v'.repeat(256)) }
|
|
75
|
+
]), /Invalid option value for key 'segment_size' length: 256 bytes/);
|
|
76
|
+
});
|
|
77
|
+
it('refuses an empty value', () => {
|
|
78
|
+
assert.throws(() => serializeOptions([
|
|
79
|
+
{ key: 'segment_size', value: HeaderValue.Raw(Buffer.alloc(0)) }
|
|
80
|
+
]), /Invalid option value for key 'segment_size' length: 0 bytes/);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
describe('deserializePrefixedOptions', () => {
|
|
84
|
+
it('reads a whole block and reports the bytes it consumed', () => {
|
|
85
|
+
const block = prefixed(serializeOptions([
|
|
86
|
+
{ key: 'segment_size', value: HeaderValue.Uint64(1048576n) }
|
|
87
|
+
]));
|
|
88
|
+
const { bytesRead, options } = deserializePrefixedOptions(block);
|
|
89
|
+
assert.equal(bytesRead, block.length);
|
|
90
|
+
assert.deepEqual(options, { segment_size: 1048576n });
|
|
91
|
+
});
|
|
92
|
+
it('rejects a block whose declared length runs past the payload', () => {
|
|
93
|
+
// `subarray` clamps instead of throwing, so without the bounds check the
|
|
94
|
+
// truncated value comes back as raw bytes through the forward-compat catch
|
|
95
|
+
// and `bytesRead` over-reports, shifting every later field.
|
|
96
|
+
const block = prefixed(serializeOptions([
|
|
97
|
+
{ key: 'segment_size', value: HeaderValue.Uint64(1048576n) }
|
|
98
|
+
]));
|
|
99
|
+
assert.throws(() => deserializePrefixedOptions(block.subarray(0, block.length - 4)), /overruns the payload/);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
//# sourceMappingURL=options.utils.test.js.map
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ParsedOptions } from '../options.utils.js';
|
|
1
2
|
/**
|
|
2
3
|
* Stream information returned from the server.
|
|
3
4
|
*/
|
|
@@ -14,6 +15,8 @@ export type Stream = {
|
|
|
14
15
|
messagesCount: bigint;
|
|
15
16
|
/** Stream creation timestamp */
|
|
16
17
|
createdAt: Date;
|
|
18
|
+
/** Options the client explicitly sent at create */
|
|
19
|
+
options: ParsedOptions;
|
|
17
20
|
};
|
|
18
21
|
/**
|
|
19
22
|
* Result of deserializing a stream.
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
// under the License.
|
|
17
17
|
//
|
|
18
18
|
import { toDate } from '../serialize.utils.js';
|
|
19
|
+
import { deserializePrefixedOptions } from '../options.utils.js';
|
|
19
20
|
/**
|
|
20
21
|
* Deserializes a stream from a buffer.
|
|
21
22
|
*
|
|
@@ -34,10 +35,11 @@ export const deserializeToStream = (r, pos = 0) => {
|
|
|
34
35
|
const messagesCount = r.readBigUint64LE(pos + 24);
|
|
35
36
|
const nameLength = r.readUInt8(pos + 32);
|
|
36
37
|
const name = r.subarray(pos + 33, pos + 33 + nameLength).toString();
|
|
38
|
+
const { bytesRead: optionsBytes, options } = deserializePrefixedOptions(r, pos + 33 + nameLength);
|
|
37
39
|
return {
|
|
38
|
-
bytesRead: 33 + nameLength,
|
|
40
|
+
bytesRead: 33 + nameLength + optionsBytes,
|
|
39
41
|
data: {
|
|
40
|
-
id, name, topicsCount, messagesCount, sizeBytes, createdAt
|
|
42
|
+
id, name, topicsCount, messagesCount, sizeBytes, createdAt, options
|
|
41
43
|
}
|
|
42
44
|
};
|
|
43
45
|
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { CommandResponse } from '../../client/index.js';
|
|
2
|
+
/** Resource whose option catalog the server serves. */
|
|
3
|
+
export declare const OptionsScope: {
|
|
4
|
+
readonly Topic: 1;
|
|
5
|
+
readonly Stream: 2;
|
|
6
|
+
readonly User: 3;
|
|
7
|
+
};
|
|
8
|
+
export type OptionsScope = typeof OptionsScope[keyof typeof OptionsScope];
|
|
9
|
+
/**
|
|
10
|
+
* One catalog entry: the key a create command accepts, the kind the server
|
|
11
|
+
* encodes its default under, that default, and what the option does.
|
|
12
|
+
*
|
|
13
|
+
* `kind` is this key's canonical kind: what the server encodes its default
|
|
14
|
+
* under, and what a value set at create is stored as whatever kind it was sent
|
|
15
|
+
* in, since create admission re-encodes the block from its own parse. An update
|
|
16
|
+
* stores the client's bytes verbatim and is the exception.
|
|
17
|
+
*/
|
|
18
|
+
export type OptionSpec = {
|
|
19
|
+
key: string;
|
|
20
|
+
kind: number;
|
|
21
|
+
defaultValue: Buffer;
|
|
22
|
+
description: string;
|
|
23
|
+
};
|
|
24
|
+
export type DescribeOptions = {
|
|
25
|
+
scope: OptionsScope;
|
|
26
|
+
};
|
|
27
|
+
export declare const DESCRIBE_OPTIONS: {
|
|
28
|
+
code: number;
|
|
29
|
+
serialize: ({ scope }: DescribeOptions) => Buffer<ArrayBuffer>;
|
|
30
|
+
deserialize: (r: CommandResponse) => OptionSpec[];
|
|
31
|
+
};
|
|
32
|
+
export declare const describeOptions: (getClient: import("../../client/client.type.js").ClientProvider) => (arg: DescribeOptions) => Promise<OptionSpec[]>;
|
|
33
|
+
//# sourceMappingURL=describe-options.command.d.ts.map
|
|
@@ -0,0 +1,63 @@
|
|
|
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
|
+
//
|
|
18
|
+
import { COMMAND_CODE } from '../command.code.js';
|
|
19
|
+
import { wrapCommand } from '../command.utils.js';
|
|
20
|
+
/** Resource whose option catalog the server serves. */
|
|
21
|
+
export const OptionsScope = {
|
|
22
|
+
Topic: 1,
|
|
23
|
+
Stream: 2,
|
|
24
|
+
User: 3
|
|
25
|
+
};
|
|
26
|
+
const deserializeDescribeOptions = (b) => {
|
|
27
|
+
const count = b.readUInt32LE(0);
|
|
28
|
+
let position = 4;
|
|
29
|
+
const specs = [];
|
|
30
|
+
for (let i = 0; i < count; i++) {
|
|
31
|
+
const keyLength = b.readUInt8(position);
|
|
32
|
+
position += 1;
|
|
33
|
+
if (position + keyLength > b.length)
|
|
34
|
+
throw new Error(`Option key overruns the response at offset ${position}`);
|
|
35
|
+
const key = b.subarray(position, position + keyLength).toString();
|
|
36
|
+
position += keyLength;
|
|
37
|
+
const kind = b.readUInt8(position);
|
|
38
|
+
position += 1;
|
|
39
|
+
const defaultLength = b.readUInt32LE(position);
|
|
40
|
+
position += 4;
|
|
41
|
+
if (position + defaultLength > b.length)
|
|
42
|
+
throw new Error(`Default value for '${key}' overruns the response`);
|
|
43
|
+
const defaultValue = Buffer.from(b.subarray(position, position + defaultLength));
|
|
44
|
+
position += defaultLength;
|
|
45
|
+
const descriptionLength = b.readUInt32LE(position);
|
|
46
|
+
position += 4;
|
|
47
|
+
if (position + descriptionLength > b.length)
|
|
48
|
+
throw new Error(`Description for '${key}' overruns the response`);
|
|
49
|
+
const description = b.subarray(position, position + descriptionLength).toString();
|
|
50
|
+
position += descriptionLength;
|
|
51
|
+
specs.push({ key, kind, defaultValue, description });
|
|
52
|
+
}
|
|
53
|
+
return specs;
|
|
54
|
+
};
|
|
55
|
+
export const DESCRIBE_OPTIONS = {
|
|
56
|
+
code: COMMAND_CODE.DescribeOptions,
|
|
57
|
+
serialize: ({ scope }) => {
|
|
58
|
+
return Buffer.from([scope]);
|
|
59
|
+
},
|
|
60
|
+
deserialize: (r) => deserializeDescribeOptions(r.data)
|
|
61
|
+
};
|
|
62
|
+
export const describeOptions = wrapCommand(DESCRIBE_OPTIONS);
|
|
63
|
+
//# sourceMappingURL=describe-options.command.js.map
|