turbodata 0.1.0

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 (104) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +27 -0
  3. package/README.md +215 -0
  4. package/dist/compression.d.ts +8 -0
  5. package/dist/compression.d.ts.map +1 -0
  6. package/dist/compression.js +19 -0
  7. package/dist/compression.js.map +1 -0
  8. package/dist/index.d.ts +16 -0
  9. package/dist/index.d.ts.map +1 -0
  10. package/dist/index.js +23 -0
  11. package/dist/index.js.map +1 -0
  12. package/dist/io.d.ts +37 -0
  13. package/dist/io.d.ts.map +1 -0
  14. package/dist/io.js +174 -0
  15. package/dist/io.js.map +1 -0
  16. package/dist/iterators/message_heap.d.ts +17 -0
  17. package/dist/iterators/message_heap.d.ts.map +1 -0
  18. package/dist/iterators/message_heap.js +83 -0
  19. package/dist/iterators/message_heap.js.map +1 -0
  20. package/dist/iterators/preloaded_topics_group_iterator.d.ts +38 -0
  21. package/dist/iterators/preloaded_topics_group_iterator.d.ts.map +1 -0
  22. package/dist/iterators/preloaded_topics_group_iterator.js +109 -0
  23. package/dist/iterators/preloaded_topics_group_iterator.js.map +1 -0
  24. package/dist/iterators/topics_group_iterator.d.ts +45 -0
  25. package/dist/iterators/topics_group_iterator.d.ts.map +1 -0
  26. package/dist/iterators/topics_group_iterator.js +132 -0
  27. package/dist/iterators/topics_group_iterator.js.map +1 -0
  28. package/dist/loaded_bytes.d.ts +12 -0
  29. package/dist/loaded_bytes.d.ts.map +1 -0
  30. package/dist/loaded_bytes.js +43 -0
  31. package/dist/loaded_bytes.js.map +1 -0
  32. package/dist/msgpack.d.ts +10 -0
  33. package/dist/msgpack.d.ts.map +1 -0
  34. package/dist/msgpack.js +44 -0
  35. package/dist/msgpack.js.map +1 -0
  36. package/dist/multi_reader.d.ts +56 -0
  37. package/dist/multi_reader.d.ts.map +1 -0
  38. package/dist/multi_reader.js +281 -0
  39. package/dist/multi_reader.js.map +1 -0
  40. package/dist/read_fetcher.d.ts +9 -0
  41. package/dist/read_fetcher.d.ts.map +1 -0
  42. package/dist/read_fetcher.js +59 -0
  43. package/dist/read_fetcher.js.map +1 -0
  44. package/dist/read_options.d.ts +40 -0
  45. package/dist/read_options.d.ts.map +1 -0
  46. package/dist/read_options.js +16 -0
  47. package/dist/read_options.js.map +1 -0
  48. package/dist/read_planner.d.ts +37 -0
  49. package/dist/read_planner.d.ts.map +1 -0
  50. package/dist/read_planner.js +110 -0
  51. package/dist/read_planner.js.map +1 -0
  52. package/dist/read_source.d.ts +19 -0
  53. package/dist/read_source.d.ts.map +1 -0
  54. package/dist/read_source.js +15 -0
  55. package/dist/read_source.js.map +1 -0
  56. package/dist/read_strategy.d.ts +52 -0
  57. package/dist/read_strategy.d.ts.map +1 -0
  58. package/dist/read_strategy.js +70 -0
  59. package/dist/read_strategy.js.map +1 -0
  60. package/dist/reader.d.ts +209 -0
  61. package/dist/reader.d.ts.map +1 -0
  62. package/dist/reader.js +649 -0
  63. package/dist/reader.js.map +1 -0
  64. package/dist/sample.d.ts +41 -0
  65. package/dist/sample.d.ts.map +1 -0
  66. package/dist/sample.js +528 -0
  67. package/dist/sample.js.map +1 -0
  68. package/dist/sort_and_filter.d.ts +25 -0
  69. package/dist/sort_and_filter.d.ts.map +1 -0
  70. package/dist/sort_and_filter.js +126 -0
  71. package/dist/sort_and_filter.js.map +1 -0
  72. package/dist/sources/blob.d.ts +8 -0
  73. package/dist/sources/blob.d.ts.map +1 -0
  74. package/dist/sources/blob.js +38 -0
  75. package/dist/sources/blob.js.map +1 -0
  76. package/dist/sources/http.d.ts +17 -0
  77. package/dist/sources/http.d.ts.map +1 -0
  78. package/dist/sources/http.js +90 -0
  79. package/dist/sources/http.js.map +1 -0
  80. package/dist/types.d.ts +75 -0
  81. package/dist/types.d.ts.map +1 -0
  82. package/dist/types.js +29 -0
  83. package/dist/types.js.map +1 -0
  84. package/package.json +61 -0
  85. package/src/compression.ts +28 -0
  86. package/src/index.ts +70 -0
  87. package/src/io.ts +214 -0
  88. package/src/iterators/message_heap.ts +104 -0
  89. package/src/iterators/preloaded_topics_group_iterator.ts +143 -0
  90. package/src/iterators/topics_group_iterator.ts +180 -0
  91. package/src/loaded_bytes.ts +61 -0
  92. package/src/msgpack.ts +46 -0
  93. package/src/multi_reader.ts +337 -0
  94. package/src/read_fetcher.ts +74 -0
  95. package/src/read_options.ts +58 -0
  96. package/src/read_planner.ts +153 -0
  97. package/src/read_source.ts +33 -0
  98. package/src/read_strategy.ts +111 -0
  99. package/src/reader.ts +910 -0
  100. package/src/sample.ts +715 -0
  101. package/src/sort_and_filter.ts +167 -0
  102. package/src/sources/blob.ts +52 -0
  103. package/src/sources/http.ts +121 -0
  104. package/src/types.ts +110 -0
package/src/io.ts ADDED
@@ -0,0 +1,214 @@
1
+ // Copyright 2026 Wanjia He
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+
15
+ // Binary readers for turbodata records. Mirrors go/io.go.
16
+ //
17
+ // All multi-byte fields are big-endian (Go's encoding/binary.BigEndian default).
18
+
19
+ import { decodeMetadata } from "./msgpack.js";
20
+ import {
21
+ FOOTER_LEN,
22
+ type Footer,
23
+ type IndexChunk,
24
+ type IndexChunkInfo,
25
+ type MessageIndex,
26
+ type Summary,
27
+ type TopicIndex,
28
+ type TopicMetadata,
29
+ type TopicsInfo,
30
+ } from "./types.js";
31
+
32
+ /** Mirrors go/io.go's maxStringLen / maxMapLen safety caps (64 MiB each). */
33
+ const MAX_STRING_LEN = 64 * 1024 * 1024;
34
+ const MAX_MAP_LEN = 64 * 1024 * 1024;
35
+
36
+ const UTF8 = new TextDecoder("utf-8", { fatal: false });
37
+
38
+ /**
39
+ * Forward-only cursor over a Uint8Array. The Go reader uses io.Reader+
40
+ * binary.Read; here we expose the equivalent calls. All reads are big-endian.
41
+ *
42
+ * We deliberately do NOT support seeking; everything in the format is
43
+ * sequentially decodable from a known start point.
44
+ */
45
+ export class BinaryReader {
46
+ private readonly view: DataView;
47
+ private readonly bytes: Uint8Array;
48
+ private off: number;
49
+
50
+ constructor(bytes: Uint8Array) {
51
+ this.bytes = bytes;
52
+ this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
53
+ this.off = 0;
54
+ }
55
+
56
+ get offset(): number {
57
+ return this.off;
58
+ }
59
+
60
+ get remaining(): number {
61
+ return this.bytes.byteLength - this.off;
62
+ }
63
+
64
+ private require(n: number): void {
65
+ if (this.off + n > this.bytes.byteLength) {
66
+ throw new Error(
67
+ `unexpected EOF: need ${n} bytes at offset ${this.off}, have ${this.bytes.byteLength - this.off}`,
68
+ );
69
+ }
70
+ }
71
+
72
+ readUint8(): number {
73
+ this.require(1);
74
+ const v = this.view.getUint8(this.off);
75
+ this.off += 1;
76
+ return v;
77
+ }
78
+
79
+ readUint16(): number {
80
+ this.require(2);
81
+ const v = this.view.getUint16(this.off, false);
82
+ this.off += 2;
83
+ return v;
84
+ }
85
+
86
+ readUint32(): number {
87
+ this.require(4);
88
+ const v = this.view.getUint32(this.off, false);
89
+ this.off += 4;
90
+ return v;
91
+ }
92
+
93
+ readInt64(): bigint {
94
+ this.require(8);
95
+ const v = this.view.getBigInt64(this.off, false);
96
+ this.off += 8;
97
+ return v;
98
+ }
99
+
100
+ readBytes(n: number): Uint8Array {
101
+ this.require(n);
102
+ const out = this.bytes.subarray(this.off, this.off + n);
103
+ this.off += n;
104
+ return out;
105
+ }
106
+ }
107
+
108
+ function readString(r: BinaryReader): string {
109
+ const strLen = r.readUint32();
110
+ if (strLen > MAX_STRING_LEN) {
111
+ throw new Error(
112
+ `string length ${strLen} exceeds maximum allowed ${MAX_STRING_LEN}`,
113
+ );
114
+ }
115
+ const bytes = r.readBytes(strLen);
116
+ return UTF8.decode(bytes);
117
+ }
118
+
119
+ function readMap(r: BinaryReader): Map<string, unknown> {
120
+ const mapLen = r.readUint32();
121
+ if (mapLen > MAX_MAP_LEN) {
122
+ throw new Error(
123
+ `map length ${mapLen} exceeds maximum allowed ${MAX_MAP_LEN}`,
124
+ );
125
+ }
126
+ const buf = r.readBytes(mapLen);
127
+ return decodeMetadata(buf);
128
+ }
129
+
130
+ /**
131
+ * Parse a fixed-size (13-byte) footer.
132
+ *
133
+ * Layout: 8-byte int64 SummaryLen, then 5-byte Magic.
134
+ */
135
+ export function readFooter(r: BinaryReader): Footer {
136
+ const summaryLen = r.readInt64();
137
+ const magic = r.readBytes(5);
138
+ // Defensive copy: r.readBytes returns a subarray view into the source buffer.
139
+ return { summaryLen, magic: new Uint8Array(magic) };
140
+ }
141
+
142
+ export function readTopicMetadata(r: BinaryReader): TopicMetadata {
143
+ const id = r.readUint16();
144
+ const name = readString(r);
145
+ const metadata = readMap(r);
146
+ const messageCount = r.readUint32();
147
+ return { id, name, metadata, messageCount };
148
+ }
149
+
150
+ export function readIndexChunkInfo(r: BinaryReader): IndexChunkInfo {
151
+ const startTimestamp = r.readInt64();
152
+ const endTimestamp = r.readInt64();
153
+ const offset = r.readInt64();
154
+ return { startTimestamp, endTimestamp, offset };
155
+ }
156
+
157
+ export function readTopicsInfo(r: BinaryReader): TopicsInfo {
158
+ const topicMetadataLen = r.readUint32();
159
+ const topicMetadatas: TopicMetadata[] = new Array(topicMetadataLen);
160
+ for (let i = 0; i < topicMetadataLen; i++) {
161
+ topicMetadatas[i] = readTopicMetadata(r);
162
+ }
163
+ const indexChunkInfoLen = r.readUint32();
164
+ const indexChunkInfoList: IndexChunkInfo[] = new Array(indexChunkInfoLen);
165
+ for (let i = 0; i < indexChunkInfoLen; i++) {
166
+ indexChunkInfoList[i] = readIndexChunkInfo(r);
167
+ }
168
+ const totalLen = r.readInt64();
169
+ return { topicMetadatas, indexChunkInfoList, totalLen };
170
+ }
171
+
172
+ export function readSummary(r: BinaryReader): Summary {
173
+ const topicsInfoLen = r.readUint32();
174
+ const topicsInfos: TopicsInfo[] = new Array(topicsInfoLen);
175
+ for (let i = 0; i < topicsInfoLen; i++) {
176
+ topicsInfos[i] = readTopicsInfo(r);
177
+ }
178
+ return { topicsInfos };
179
+ }
180
+
181
+ export function readMessageIndex(r: BinaryReader): MessageIndex {
182
+ const timestamp = r.readInt64();
183
+ const offsetInChunk = r.readInt64();
184
+ return { timestamp, offsetInChunk };
185
+ }
186
+
187
+ export function readTopicIndex(r: BinaryReader): TopicIndex {
188
+ const id = r.readUint16();
189
+ const messageIndexLen = r.readUint32();
190
+ const messageIndexes: MessageIndex[] = new Array(messageIndexLen);
191
+ for (let i = 0; i < messageIndexLen; i++) {
192
+ messageIndexes[i] = readMessageIndex(r);
193
+ }
194
+ const keyFrameIndexLen = r.readUint32();
195
+ const keyFrameIndexes = new Uint32Array(keyFrameIndexLen);
196
+ for (let i = 0; i < keyFrameIndexLen; i++) {
197
+ keyFrameIndexes[i] = r.readUint32();
198
+ }
199
+ return { id, messageIndexes, keyFrameIndexes };
200
+ }
201
+
202
+ export function readIndexChunk(r: BinaryReader): IndexChunk {
203
+ const topicIndexesLen = r.readUint32();
204
+ const topicIndexes: TopicIndex[] = new Array(topicIndexesLen);
205
+ for (let i = 0; i < topicIndexesLen; i++) {
206
+ topicIndexes[i] = readTopicIndex(r);
207
+ }
208
+ const chunkOffset = r.readInt64();
209
+ const chunkLen = r.readInt64();
210
+ const uncompressedLen = r.readInt64();
211
+ return { topicIndexes, chunkOffset, chunkLen, uncompressedLen };
212
+ }
213
+
214
+ export { FOOTER_LEN };
@@ -0,0 +1,104 @@
1
+ // Copyright 2026 Wanjia He
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+
15
+ // Tiny binary heap with bigint priorities, used to merge per-group iterators
16
+ // into a single time-ordered stream. Mirrors go/message_heap.go semantics
17
+ // (separate forward/reverse heaps replaced by a `reverse` flag).
18
+
19
+ interface HeapItem<T> {
20
+ priority: bigint;
21
+ value: T;
22
+ }
23
+
24
+ export class MessageHeap<T> {
25
+ private readonly items: HeapItem<T>[] = [];
26
+ private readonly cmpSign: bigint;
27
+
28
+ /** reverse=false: min-heap (time order). reverse=true: max-heap (reverse time). */
29
+ constructor(reverse: boolean) {
30
+ this.cmpSign = reverse ? -1n : 1n;
31
+ }
32
+
33
+ get size(): number {
34
+ return this.items.length;
35
+ }
36
+
37
+ push(priority: bigint, value: T): void {
38
+ this.items.push({ priority, value });
39
+ this.siftUp(this.items.length - 1);
40
+ }
41
+
42
+ pop(): { priority: bigint; value: T } | undefined {
43
+ if (this.items.length === 0) {
44
+ return undefined;
45
+ }
46
+ const top = this.items[0]!;
47
+ const last = this.items.pop()!;
48
+ if (this.items.length > 0) {
49
+ this.items[0] = last;
50
+ this.siftDown(0);
51
+ }
52
+ return top;
53
+ }
54
+
55
+ /** Return < 0 if a goes before b, > 0 if after, 0 if equal. */
56
+ private compare(a: bigint, b: bigint): bigint {
57
+ return (a - b) * this.cmpSign;
58
+ }
59
+
60
+ private siftUp(idx: number): void {
61
+ while (idx > 0) {
62
+ const parent = (idx - 1) >> 1;
63
+ if (
64
+ this.compare(this.items[idx]!.priority, this.items[parent]!.priority) <
65
+ 0n
66
+ ) {
67
+ const tmp = this.items[idx]!;
68
+ this.items[idx] = this.items[parent]!;
69
+ this.items[parent] = tmp;
70
+ idx = parent;
71
+ } else {
72
+ break;
73
+ }
74
+ }
75
+ }
76
+
77
+ private siftDown(idx: number): void {
78
+ const n = this.items.length;
79
+ while (true) {
80
+ const l = idx * 2 + 1;
81
+ const r = idx * 2 + 2;
82
+ let best = idx;
83
+ if (
84
+ l < n &&
85
+ this.compare(this.items[l]!.priority, this.items[best]!.priority) < 0n
86
+ ) {
87
+ best = l;
88
+ }
89
+ if (
90
+ r < n &&
91
+ this.compare(this.items[r]!.priority, this.items[best]!.priority) < 0n
92
+ ) {
93
+ best = r;
94
+ }
95
+ if (best === idx) {
96
+ return;
97
+ }
98
+ const tmp = this.items[idx]!;
99
+ this.items[idx] = this.items[best]!;
100
+ this.items[best] = tmp;
101
+ idx = best;
102
+ }
103
+ }
104
+ }
@@ -0,0 +1,143 @@
1
+ // Copyright 2026 Wanjia He
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+
15
+ // Preloaded topics-group iterator: all bytes pre-fetched into LoadedBytes;
16
+ // per-chunk kept-message lists pre-computed at setup time. Used by the
17
+ // cost-aware read path. Counterpart to TopicsGroupIterator (lazy default
18
+ // path). Mirrors go/preloaded_topics_group_iterator.go.
19
+
20
+ import type { Decompressor } from "../compression.js";
21
+ import type { LoadedBytes } from "../loaded_bytes.js";
22
+ import type { MessageRef } from "../sort_and_filter.js";
23
+ import type { IndexChunk } from "../types.js";
24
+
25
+ export class PreloadedTopicsGroupIterator {
26
+ private readonly isCompressed: boolean;
27
+ private readonly reverse: boolean;
28
+ private readonly indexChunks: IndexChunk[];
29
+ private readonly chunkMessages: MessageRef[][];
30
+ private readonly chunkMsgLens: bigint[][];
31
+ private readonly loadedData: LoadedBytes;
32
+ private readonly decompress: Decompressor;
33
+
34
+ // Decompressed buffer for the *current* compressed data chunk; undefined
35
+ // when isCompressed=false or no chunk has been materialized yet.
36
+ private currentDecompressed: Uint8Array | undefined;
37
+ private loadedChunkIdx = -1;
38
+
39
+ private chunkIdx: number;
40
+ private msgIdx: number;
41
+
42
+ constructor(args: {
43
+ isCompressed: boolean;
44
+ reverse: boolean;
45
+ indexChunks: IndexChunk[];
46
+ chunkMessages: MessageRef[][];
47
+ chunkMsgLens: bigint[][];
48
+ loadedData: LoadedBytes;
49
+ decompress: Decompressor;
50
+ }) {
51
+ this.isCompressed = args.isCompressed;
52
+ this.reverse = args.reverse;
53
+ this.indexChunks = args.indexChunks;
54
+ this.chunkMessages = args.chunkMessages;
55
+ this.chunkMsgLens = args.chunkMsgLens;
56
+ this.loadedData = args.loadedData;
57
+ this.decompress = args.decompress;
58
+
59
+ if (args.reverse) {
60
+ this.chunkIdx = args.indexChunks.length - 1;
61
+ this.msgIdx =
62
+ this.chunkIdx >= 0
63
+ ? args.chunkMessages[this.chunkIdx]!.length - 1
64
+ : 0;
65
+ } else {
66
+ this.chunkIdx = 0;
67
+ this.msgIdx = 0;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Next is synchronous (no I/O remaining). Returns undefined on EOF.
73
+ * `data` aliases either loadedData or the per-chunk decompressed buffer.
74
+ */
75
+ next():
76
+ | { timestamp: bigint; topicId: number; data: Uint8Array }
77
+ | undefined {
78
+ while (true) {
79
+ if (this.chunkIdx < 0 || this.chunkIdx >= this.indexChunks.length) {
80
+ return undefined;
81
+ }
82
+ const msgs = this.chunkMessages[this.chunkIdx]!;
83
+ if (this.msgIdx < 0 || this.msgIdx >= msgs.length) {
84
+ this.advanceChunk();
85
+ continue;
86
+ }
87
+
88
+ // Materialize this chunk's decompressed bytes on first touch.
89
+ if (this.isCompressed && this.loadedChunkIdx !== this.chunkIdx) {
90
+ const compressed = this.loadedData.get(
91
+ this.indexChunks[this.chunkIdx]!.chunkOffset,
92
+ );
93
+ if (compressed === undefined) {
94
+ throw new Error(
95
+ `preloaded iterator: missing compressed chunk at offset ${this.indexChunks[this.chunkIdx]!.chunkOffset}`,
96
+ );
97
+ }
98
+ this.currentDecompressed = this.decompress(compressed);
99
+ this.loadedChunkIdx = this.chunkIdx;
100
+ }
101
+
102
+ const msg = msgs[this.msgIdx]!;
103
+ const length = this.chunkMsgLens[this.chunkIdx]![this.msgIdx]!;
104
+ const chunkOffset = this.indexChunks[this.chunkIdx]!.chunkOffset;
105
+
106
+ let data: Uint8Array;
107
+ if (this.isCompressed) {
108
+ const start = Number(msg.offsetInChunk);
109
+ const end = start + Number(length);
110
+ data = this.currentDecompressed!.subarray(start, end);
111
+ } else {
112
+ const got = this.loadedData.get(chunkOffset + msg.offsetInChunk);
113
+ if (got === undefined) {
114
+ throw new Error(
115
+ `preloaded iterator: missing message bytes at offset ${chunkOffset + msg.offsetInChunk}`,
116
+ );
117
+ }
118
+ data = got;
119
+ }
120
+
121
+ this.advanceMessage();
122
+ return { timestamp: msg.timestamp, topicId: msg.topicId, data };
123
+ }
124
+ }
125
+
126
+ private advanceMessage(): void {
127
+ this.msgIdx += this.reverse ? -1 : 1;
128
+ }
129
+
130
+ private advanceChunk(): void {
131
+ if (this.reverse) {
132
+ this.chunkIdx -= 1;
133
+ if (this.chunkIdx >= 0) {
134
+ this.msgIdx = this.chunkMessages[this.chunkIdx]!.length - 1;
135
+ }
136
+ } else {
137
+ this.chunkIdx += 1;
138
+ if (this.chunkIdx < this.indexChunks.length) {
139
+ this.msgIdx = 0;
140
+ }
141
+ }
142
+ }
143
+ }
@@ -0,0 +1,180 @@
1
+ // Copyright 2026 Wanjia He
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+
15
+ // Default-path per-group iterator. Lazily reads one index chunk and one data
16
+ // chunk at a time; mirrors go/topics_group_iterator.go but uses readAt
17
+ // (offset/length) instead of Read+Seek state.
18
+
19
+ import type { Decompressor } from "../compression.js";
20
+ import { BinaryReader, readIndexChunk } from "../io.js";
21
+ import type { ReadSource } from "../read_source.js";
22
+ import { sortAndFilter, type MessageRef } from "../sort_and_filter.js";
23
+ import { META_KEY_COMPRESSED, type IndexChunkInfo, type TopicsInfo } from "../types.js";
24
+
25
+ export class TopicsGroupIterator {
26
+ private readonly rs: ReadSource;
27
+ private readonly decompress: Decompressor;
28
+ private readonly topicIds: Set<number>;
29
+ private readonly startTimestamp: bigint;
30
+ private readonly endTimestamp: bigint;
31
+ private readonly reverse: boolean;
32
+ private readonly isCompressed: boolean;
33
+ private readonly videoDecodable: boolean;
34
+
35
+ // Per-chunk metadata.
36
+ private readonly indexChunkInfoList: IndexChunkInfo[];
37
+ private readonly indexChunkInfoLens: bigint[];
38
+
39
+ // Iteration cursors.
40
+ private chunkIdx: number;
41
+ private msgIdx: number;
42
+ private currentMessages: MessageRef[] = [];
43
+ private currentLens: bigint[] = [];
44
+
45
+ // Most-recently-loaded data chunk (uncompressed bytes).
46
+ private dataBuffer: Uint8Array | undefined;
47
+
48
+ constructor(args: {
49
+ rs: ReadSource;
50
+ decompress: Decompressor;
51
+ topicIds: Set<number>;
52
+ startTimestamp: bigint;
53
+ endTimestamp: bigint;
54
+ reverse: boolean;
55
+ topicsInfo: TopicsInfo;
56
+ /** When true, snap the per-chunk lower bound back to the GOP key frame. */
57
+ videoDecodable?: boolean;
58
+ }) {
59
+ this.rs = args.rs;
60
+ this.decompress = args.decompress;
61
+ this.topicIds = args.topicIds;
62
+ this.startTimestamp = args.startTimestamp;
63
+ this.endTimestamp = args.endTimestamp;
64
+ this.reverse = args.reverse;
65
+ this.videoDecodable = args.videoDecodable === true;
66
+
67
+ const firstMeta = args.topicsInfo.topicMetadatas[0];
68
+ const flag = firstMeta?.metadata.get(META_KEY_COMPRESSED);
69
+ this.isCompressed = flag === true;
70
+
71
+ // Time-range filter at the chunk level + compute compressed lengths.
72
+ const filteredInfos: IndexChunkInfo[] = [];
73
+ const filteredLens: bigint[] = [];
74
+ const all = args.topicsInfo.indexChunkInfoList;
75
+ for (let i = 0; i < all.length; i++) {
76
+ const info = all[i]!;
77
+ if (info.endTimestamp < args.startTimestamp) {
78
+ continue;
79
+ }
80
+ if (info.startTimestamp > args.endTimestamp) {
81
+ break;
82
+ }
83
+ filteredInfos.push(info);
84
+ let ln: bigint;
85
+ if (i < all.length - 1) {
86
+ ln = all[i + 1]!.offset - info.offset;
87
+ } else {
88
+ // Last index chunk: its length is (group total) - (its offset) + (first chunk's offset).
89
+ ln = args.topicsInfo.totalLen - info.offset + all[0]!.offset;
90
+ }
91
+ filteredLens.push(ln);
92
+ }
93
+ this.indexChunkInfoList = filteredInfos;
94
+ this.indexChunkInfoLens = filteredLens;
95
+
96
+ if (args.reverse) {
97
+ this.chunkIdx = filteredInfos.length - 1;
98
+ } else {
99
+ this.chunkIdx = 0;
100
+ }
101
+ this.msgIdx = 0;
102
+ }
103
+
104
+ /** True if we have any in-scope index chunks. */
105
+ hasAny(): boolean {
106
+ return this.indexChunkInfoList.length > 0;
107
+ }
108
+
109
+ /**
110
+ * Next emits one (timestamp, topicId, data) record or returns undefined on
111
+ * EOF. The returned Uint8Array aliases an internal buffer; the caller must
112
+ * copy if persistence is needed.
113
+ */
114
+ async next(): Promise<{
115
+ timestamp: bigint;
116
+ topicId: number;
117
+ data: Uint8Array;
118
+ } | undefined> {
119
+ while (
120
+ this.msgIdx >= this.currentMessages.length ||
121
+ this.msgIdx < 0
122
+ ) {
123
+ if (
124
+ this.chunkIdx >= this.indexChunkInfoList.length ||
125
+ this.chunkIdx < 0
126
+ ) {
127
+ return undefined;
128
+ }
129
+ await this.loadCurrentChunk();
130
+ this.chunkIdx += this.reverse ? -1 : 1;
131
+ if (this.reverse) {
132
+ this.msgIdx = this.currentMessages.length - 1;
133
+ } else {
134
+ this.msgIdx = 0;
135
+ }
136
+ }
137
+
138
+ const ref = this.currentMessages[this.msgIdx]!;
139
+ const len = this.currentLens[this.msgIdx]!;
140
+ this.msgIdx += this.reverse ? -1 : 1;
141
+
142
+ const start = Number(ref.offsetInChunk);
143
+ const end = start + Number(len);
144
+ const data = this.dataBuffer!.subarray(start, end);
145
+ return { timestamp: ref.timestamp, topicId: ref.topicId, data };
146
+ }
147
+
148
+ private async loadCurrentChunk(): Promise<void> {
149
+ const info = this.indexChunkInfoList[this.chunkIdx]!;
150
+ const ln = this.indexChunkInfoLens[this.chunkIdx]!;
151
+
152
+ // Read + decompress the index chunk.
153
+ const compressedIndex = await this.rs.read(info.offset, ln);
154
+ const indexBytes = this.decompress(compressedIndex);
155
+ const indexChunk = readIndexChunk(new BinaryReader(indexBytes));
156
+
157
+ const filtered = sortAndFilter(
158
+ indexChunk.topicIndexes,
159
+ indexChunk.uncompressedLen,
160
+ this.topicIds,
161
+ this.startTimestamp,
162
+ this.endTimestamp,
163
+ this.videoDecodable,
164
+ );
165
+ this.currentMessages = filtered.msgs;
166
+ this.currentLens = filtered.lens;
167
+
168
+ if (filtered.msgs.length === 0) {
169
+ this.dataBuffer = undefined;
170
+ return;
171
+ }
172
+
173
+ // Read (and possibly decompress) the data chunk.
174
+ const dataBytes = await this.rs.read(
175
+ indexChunk.chunkOffset,
176
+ indexChunk.chunkLen,
177
+ );
178
+ this.dataBuffer = this.isCompressed ? this.decompress(dataBytes) : dataBytes;
179
+ }
180
+ }
@@ -0,0 +1,61 @@
1
+ // Copyright 2026 Wanjia He
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+
15
+ // LoadedBytes: bridge between the planner+fetcher (which think in coalesced /
16
+ // split byte ranges) and the consumers (which think in their original
17
+ // per-range terms). Mirrors go/loaded_bytes.go.
18
+
19
+ import type { Range, RangeLocation } from "./read_planner.js";
20
+
21
+ interface BytesLocation {
22
+ bufferIdx: number;
23
+ inBufOff: number;
24
+ length: number;
25
+ }
26
+
27
+ export class LoadedBytes {
28
+ private readonly buffers: Uint8Array[];
29
+ private readonly index: Map<bigint, BytesLocation>;
30
+
31
+ constructor(
32
+ ranges: Range[],
33
+ locations: RangeLocation[],
34
+ buffers: Uint8Array[],
35
+ ) {
36
+ this.buffers = buffers;
37
+ this.index = new Map();
38
+ for (let i = 0; i < ranges.length; i++) {
39
+ const r = ranges[i]!;
40
+ const loc = locations[i]!;
41
+ this.index.set(r.offset, {
42
+ bufferIdx: loc.opIndex,
43
+ inBufOff: loc.inOpOff,
44
+ length: loc.length,
45
+ });
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Return the slice for the range registered at offset. The returned
51
+ * Uint8Array aliases the underlying buffer; callers must not mutate it.
52
+ */
53
+ get(offset: bigint): Uint8Array | undefined {
54
+ const loc = this.index.get(offset);
55
+ if (loc === undefined) {
56
+ return undefined;
57
+ }
58
+ const buf = this.buffers[loc.bufferIdx]!;
59
+ return buf.subarray(loc.inBufOff, loc.inBufOff + loc.length);
60
+ }
61
+ }