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/msgpack.ts ADDED
@@ -0,0 +1,46 @@
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
+ // msgpack decoder for TopicMetadata.Metadata. Uses @msgpack/msgpack with
16
+ // useBigInt64 so int64 values round-trip exactly.
17
+
18
+ import { decode } from "@msgpack/msgpack";
19
+
20
+ /**
21
+ * Decode a msgpack-encoded map into a Map<string, unknown>.
22
+ *
23
+ * - `useBigInt64: true` so any int64 values round-trip exactly as bigint.
24
+ * Smaller integers (int32 and below) decode as number.
25
+ * - The Go writer always msgpack-encodes a `map[string]any`. We coerce the
26
+ * decoded plain object into a `Map` for an unambiguous string-keyed view.
27
+ */
28
+ export function decodeMetadata(buf: Uint8Array): Map<string, unknown> {
29
+ const decoded = decode(buf, { useBigInt64: true });
30
+ const out = new Map<string, unknown>();
31
+ if (decoded === null || typeof decoded !== "object") {
32
+ return out;
33
+ }
34
+ if (decoded instanceof Map) {
35
+ for (const [k, v] of decoded as Map<unknown, unknown>) {
36
+ if (typeof k === "string") {
37
+ out.set(k, v);
38
+ }
39
+ }
40
+ return out;
41
+ }
42
+ for (const k of Object.keys(decoded as Record<string, unknown>)) {
43
+ out.set(k, (decoded as Record<string, unknown>)[k]);
44
+ }
45
+ return out;
46
+ }
@@ -0,0 +1,337 @@
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
+ // MultiReader: present several single-file Readers as one time-ordered stream.
16
+ // Mirrors go/multi_reader.go and py/turbodata/multi_reader.py.
17
+ //
18
+ // readMessages merges every reader's stream in timestamp order; sample resolves
19
+ // floor messages across the union of readers. Both operate entirely in each
20
+ // Reader's exposed-name space. Topic-name collisions across files are the
21
+ // caller's responsibility: configure per-Reader topicRemap so that names meant
22
+ // to union share an exposed name and names meant to stay distinct do not.
23
+
24
+ import { MessageHeap } from "./iterators/message_heap.js";
25
+ import type { ReadOptions } from "./read_options.js";
26
+ import {
27
+ Reader,
28
+ SampleValidationError,
29
+ type Message,
30
+ type SampleOptions,
31
+ type SampleQuery,
32
+ type SampleResult,
33
+ type TopicBound,
34
+ } from "./reader.js";
35
+
36
+ /**
37
+ * Thrown by MultiReader.sample/readMessages under videoDecodable when a queried
38
+ * video topic is provided by more than one reader whose time ranges overlap.
39
+ * Decodable multi-file video requires each video topic's source files to be
40
+ * time-disjoint; merging overlapping decodable sources would interleave frames
41
+ * from different GOP chains into an undecodable stream. Mirrors go
42
+ * ErrVideoSourcesOverlap / py VideoSourcesOverlapError.
43
+ */
44
+ export class VideoSourcesOverlapError extends Error {
45
+ readonly topic: string;
46
+ readonly first: readonly [bigint, bigint];
47
+ readonly second: readonly [bigint, bigint];
48
+ constructor(
49
+ topic: string,
50
+ first: readonly [bigint, bigint],
51
+ second: readonly [bigint, bigint],
52
+ ) {
53
+ super(
54
+ `turbodata: video topic "${topic}" sources [${first[0]},${first[1]}] and [${second[0]},${second[1]}] overlap in time`,
55
+ );
56
+ this.name = "VideoSourcesOverlapError";
57
+ this.topic = topic;
58
+ this.first = first;
59
+ this.second = second;
60
+ }
61
+ }
62
+
63
+ export class MultiReader {
64
+ private readonly readers: Reader[];
65
+
66
+ /**
67
+ * Group readers into one merged view. At least one reader is required.
68
+ * Per-file behavior (remap, etc.) lives on each underlying Reader.
69
+ */
70
+ constructor(readers: Reader[]) {
71
+ if (readers.length === 0) {
72
+ throw new Error("turbodata: MultiReader requires at least one reader");
73
+ }
74
+ this.readers = readers;
75
+ }
76
+
77
+ /**
78
+ * Read every reader's messages as one merged, time-ordered stream. Options
79
+ * pass through to each underlying Reader.readMessages unchanged, so order,
80
+ * time bounds, topic filtering (by exposed name), and strategy apply per file
81
+ * before the merge.
82
+ *
83
+ * When videoDecodable is set, any in-scope video topic supplied by more than
84
+ * one reader must have time-disjoint source ranges; overlapping sources throw
85
+ * VideoSourcesOverlapError (merging them would interleave frames from
86
+ * different GOP chains into an undecodable stream). As with the single
87
+ * Reader, all I/O (and this check) is deferred to the first iteration step.
88
+ */
89
+ readMessages(opts: ReadOptions = {}): AsyncIterableIterator<Message> {
90
+ const readers = this.readers;
91
+ const reverse = opts.order === "reverse-time";
92
+ const videoDecodable = opts.videoDecodable === true;
93
+ const wantedNames = opts.topicNames;
94
+
95
+ let initialized = false;
96
+ let exhausted = false;
97
+ const subs: AsyncIterableIterator<Message>[] = [];
98
+ const heap = new MessageHeap<{ subIdx: number; msg: Message }>(reverse);
99
+
100
+ const init = async (): Promise<void> => {
101
+ for (const r of readers) {
102
+ subs.push(r.readMessages(opts));
103
+ }
104
+
105
+ if (videoDecodable) {
106
+ const bounds = await Promise.all(readers.map((r) => r.topicBounds()));
107
+ enforceVideoDisjoint(readScopeTopics(wantedNames, bounds), bounds);
108
+ }
109
+
110
+ // Prime the heap with one message from each reader's stream.
111
+ for (let i = 0; i < subs.length; i++) {
112
+ const res = await subs[i]!.next();
113
+ if (res.done === true) {
114
+ continue;
115
+ }
116
+ heap.push(res.value.timestamp, { subIdx: i, msg: res.value });
117
+ }
118
+ };
119
+
120
+ const iter: AsyncIterableIterator<Message> = {
121
+ [Symbol.asyncIterator]() {
122
+ return iter;
123
+ },
124
+ async next(): Promise<IteratorResult<Message>> {
125
+ if (exhausted) {
126
+ return { value: undefined, done: true };
127
+ }
128
+ if (!initialized) {
129
+ await init();
130
+ initialized = true;
131
+ }
132
+ const top = heap.pop();
133
+ if (top === undefined) {
134
+ exhausted = true;
135
+ return { value: undefined, done: true };
136
+ }
137
+ const out = top.value.msg;
138
+ const nm = await subs[top.value.subIdx]!.next();
139
+ if (nm.done !== true) {
140
+ heap.push(nm.value.timestamp, {
141
+ subIdx: top.value.subIdx,
142
+ msg: nm.value,
143
+ });
144
+ }
145
+ return { value: out, done: false };
146
+ },
147
+ async return(): Promise<IteratorResult<Message>> {
148
+ exhausted = true;
149
+ for (const s of subs) {
150
+ if (s.return !== undefined) {
151
+ await s.return();
152
+ }
153
+ }
154
+ return { value: undefined, done: true };
155
+ },
156
+ };
157
+
158
+ return iter;
159
+ }
160
+
161
+ /**
162
+ * Resolve floor messages across every reader. For each
163
+ * (queries[i].topic, queries[i].timestamps[j]) pair, out[i][j] is the floor
164
+ * result with the latest found timestamp among all readers that hold the
165
+ * topic (by exposed name) - the true floor over the union. Ties resolve
166
+ * arbitrarily.
167
+ *
168
+ * Preconditions, validated before any data I/O (aggregated into a single
169
+ * SampleValidationError):
170
+ * - each queries[i].timestamps must be strictly increasing
171
+ * - each queries[i].topic must be unique across all i
172
+ * - each queries[i].topic must exist in at least one reader
173
+ *
174
+ * When videoDecodable is set, a video topic supplied by more than one reader
175
+ * must have time-disjoint source ranges; overlapping sources throw
176
+ * VideoSourcesOverlapError.
177
+ */
178
+ async sample(
179
+ queries: SampleQuery[],
180
+ opts: SampleOptions = {},
181
+ ): Promise<SampleResult[][]> {
182
+ const videoDecodable = opts.videoDecodable === true;
183
+ const bounds = await Promise.all(this.readers.map((r) => r.topicBounds()));
184
+
185
+ validateMultiSampleQueries(queries, bounds);
186
+
187
+ if (videoDecodable) {
188
+ enforceVideoDisjoint(
189
+ queries.map((q) => q.topic),
190
+ bounds,
191
+ );
192
+ }
193
+
194
+ const out: SampleResult[][] = queries.map((q) =>
195
+ q.timestamps.map(() => ({
196
+ found: false,
197
+ timestamp: 0n,
198
+ data: new Uint8Array(0),
199
+ isVideo: false,
200
+ frames: [],
201
+ resetDecoder: false,
202
+ })),
203
+ );
204
+
205
+ // Dispatch to each reader only the queries whose topic it holds (avoids
206
+ // the engine's unknown-topic error), then merge by latest found floor.
207
+ for (let ri = 0; ri < this.readers.length; ri++) {
208
+ const subset: SampleQuery[] = [];
209
+ const origIdx: number[] = [];
210
+ for (let qi = 0; qi < queries.length; qi++) {
211
+ if (bounds[ri]!.has(queries[qi]!.topic)) {
212
+ subset.push(queries[qi]!);
213
+ origIdx.push(qi);
214
+ }
215
+ }
216
+ if (subset.length === 0) {
217
+ continue;
218
+ }
219
+ const res = await this.readers[ri]!.sample(subset, opts);
220
+ for (let sub = 0; sub < origIdx.length; sub++) {
221
+ const qi = origIdx[sub]!;
222
+ const row = res[sub]!;
223
+ for (let j = 0; j < row.length; j++) {
224
+ const cand = row[j]!;
225
+ if (!cand.found) {
226
+ continue;
227
+ }
228
+ const cur = out[qi]![j]!;
229
+ if (!cur.found || cand.timestamp > cur.timestamp) {
230
+ out[qi]![j] = cand;
231
+ }
232
+ }
233
+ }
234
+ }
235
+ return out;
236
+ }
237
+ }
238
+
239
+ /**
240
+ * The exposed topic names a readMessages call covers: the requested
241
+ * topicNames, or every exposed topic across all readers when unset.
242
+ */
243
+ function readScopeTopics(
244
+ topicNames: string[] | undefined,
245
+ bounds: Map<string, TopicBound>[],
246
+ ): string[] {
247
+ if (topicNames !== undefined && topicNames.length > 0) {
248
+ return topicNames;
249
+ }
250
+ const seen = new Set<string>();
251
+ const out: string[] = [];
252
+ for (const b of bounds) {
253
+ for (const name of b.keys()) {
254
+ if (!seen.has(name)) {
255
+ seen.add(name);
256
+ out.push(name);
257
+ }
258
+ }
259
+ }
260
+ return out;
261
+ }
262
+
263
+ /**
264
+ * Mirror the single-reader checks but treat a topic as known if any reader
265
+ * holds it. All violations are aggregated into one SampleValidationError.
266
+ * Mirrors go validateMultiSampleQueries.
267
+ */
268
+ function validateMultiSampleQueries(
269
+ queries: SampleQuery[],
270
+ bounds: Map<string, TopicBound>[],
271
+ ): void {
272
+ const seen = new Map<string, number>();
273
+ const violations: string[] = [];
274
+ for (let i = 0; i < queries.length; i++) {
275
+ const q = queries[i]!;
276
+ const known = bounds.some((b) => b.has(q.topic));
277
+ if (!known) {
278
+ violations.push(
279
+ `queries[${i}]: unknown topic "${q.topic}" (not in any reader)`,
280
+ );
281
+ }
282
+ const prev = seen.get(q.topic);
283
+ if (prev !== undefined) {
284
+ violations.push(
285
+ `queries[${i}]: duplicate topic "${q.topic}" already used by queries[${prev}]`,
286
+ );
287
+ } else {
288
+ seen.set(q.topic, i);
289
+ }
290
+ for (let j = 1; j < q.timestamps.length; j++) {
291
+ if (q.timestamps[j]! <= q.timestamps[j - 1]!) {
292
+ violations.push(
293
+ `queries[${i}].timestamps not strictly increasing at position ${j} (${q.timestamps[j]} <= ${q.timestamps[j - 1]})`,
294
+ );
295
+ }
296
+ }
297
+ }
298
+ if (violations.length > 0) {
299
+ throw new SampleValidationError(violations);
300
+ }
301
+ }
302
+
303
+ /**
304
+ * Throw VideoSourcesOverlapError when any of the given exposed video topics is
305
+ * provided by readers with overlapping inclusive time ranges. Topics that are
306
+ * not video, or are held by fewer than two readers, are skipped. Mirrors go
307
+ * enforceVideoDisjoint.
308
+ */
309
+ function enforceVideoDisjoint(
310
+ topics: string[],
311
+ bounds: Map<string, TopicBound>[],
312
+ ): void {
313
+ for (const topic of topics) {
314
+ const ranges: [bigint, bigint][] = [];
315
+ let isVideo = false;
316
+ for (const b of bounds) {
317
+ const bound = b.get(topic);
318
+ if (bound === undefined) {
319
+ continue;
320
+ }
321
+ if (bound.isVideo) {
322
+ isVideo = true;
323
+ }
324
+ ranges.push([bound.minTs, bound.maxTs]);
325
+ }
326
+ if (!isVideo || ranges.length < 2) {
327
+ continue;
328
+ }
329
+ for (let a = 0; a < ranges.length; a++) {
330
+ for (let c = a + 1; c < ranges.length; c++) {
331
+ if (ranges[a]![0] <= ranges[c]![1] && ranges[c]![0] <= ranges[a]![1]) {
332
+ throw new VideoSourcesOverlapError(topic, ranges[a]!, ranges[c]!);
333
+ }
334
+ }
335
+ }
336
+ }
337
+ }
@@ -0,0 +1,74 @@
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
+ // Read fetcher: executes ReadOps with bounded concurrency. Mirrors
16
+ // go/read_fetcher.go (promise-pool instead of goroutines + token channel).
17
+
18
+ import type { ReadOp } from "./read_planner.js";
19
+ import type { ReadSource } from "./read_source.js";
20
+
21
+ /**
22
+ * Execute ops against rs with at most maxConcurrency in-flight reads. Returns
23
+ * one Uint8Array per op, in input order. Throws on the first error
24
+ * encountered; in-flight reads may still settle in the background.
25
+ */
26
+ export async function fetchAll(
27
+ rs: ReadSource,
28
+ ops: ReadOp[],
29
+ maxConcurrency: number,
30
+ ): Promise<Uint8Array[]> {
31
+ if (ops.length === 0) {
32
+ return [];
33
+ }
34
+ const workers = maxConcurrency > 0 ? maxConcurrency : 1;
35
+ const out = new Array<Uint8Array>(ops.length);
36
+
37
+ let nextIdx = 0;
38
+ let firstErr: unknown = undefined;
39
+
40
+ const runOne = async (): Promise<void> => {
41
+ while (firstErr === undefined) {
42
+ const i = nextIdx++;
43
+ if (i >= ops.length) {
44
+ return;
45
+ }
46
+ const op = ops[i]!;
47
+ try {
48
+ const buf = await rs.read(op.offset, op.length);
49
+ if (BigInt(buf.byteLength) !== op.length) {
50
+ throw new Error(
51
+ `fetcher: short read for op ${i}: got ${buf.byteLength} want ${op.length}`,
52
+ );
53
+ }
54
+ out[i] = buf;
55
+ } catch (err) {
56
+ if (firstErr === undefined) {
57
+ firstErr = err;
58
+ }
59
+ return;
60
+ }
61
+ }
62
+ };
63
+
64
+ const pool: Promise<void>[] = new Array(Math.min(workers, ops.length));
65
+ for (let w = 0; w < pool.length; w++) {
66
+ pool[w] = runOne();
67
+ }
68
+ await Promise.all(pool);
69
+
70
+ if (firstErr !== undefined) {
71
+ throw firstErr;
72
+ }
73
+ return out;
74
+ }
@@ -0,0 +1,58 @@
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
+ // Read options for Reader.readMessages(). Mirrors go/read_option.go.
16
+
17
+ import type { ReadStrategy } from "./read_strategy.js";
18
+
19
+ export type Order = "time" | "reverse-time";
20
+
21
+ export interface ReadOptions {
22
+ /** When set, only messages from these topic names are emitted. */
23
+ topicNames?: string[];
24
+ /** int64. Inclusive lower bound on message timestamp. Default: 0n. */
25
+ startTimestamp?: bigint;
26
+ /** int64. Inclusive upper bound on message timestamp. Default: int64-max. */
27
+ endTimestamp?: bigint;
28
+ /** Iteration order. Default: "time". */
29
+ order?: Order;
30
+ /**
31
+ * When set, switches Reader.readMessages onto the cost-aware path. Without
32
+ * a strategy, the reader uses the default lazy path (one chunk at a time).
33
+ */
34
+ strategy?: ReadStrategy;
35
+ /**
36
+ * int64. Bytes to read speculatively from the file tail when loading the
37
+ * summary. When the (footer + compressed summary) fits within the prefetch
38
+ * window, summary loading costs one read instead of two. Default: 0n
39
+ * (footer-only read).
40
+ */
41
+ tailPrefetch?: bigint;
42
+ /**
43
+ * If true, the iterator hands out a freshly-allocated Uint8Array per
44
+ * message (safe to retain). If false (default), `data` aliases an internal
45
+ * reusable buffer that becomes invalid on the next iteration step.
46
+ */
47
+ copy?: boolean;
48
+ /**
49
+ * If true, for any video topic in scope the effective per-group
50
+ * startTimestamp is snapped back to the latest key frame whose timestamp is
51
+ * <= startTimestamp, so the emitted sequence can be fed to a decoder cold.
52
+ * Non-video topics are unaffected. Default: false.
53
+ */
54
+ videoDecodable?: boolean;
55
+ }
56
+
57
+ /** Default value for endTimestamp: matches Go's math.MaxInt64. */
58
+ export const MAX_INT64 = 0x7fffffffffffffffn;
@@ -0,0 +1,153 @@
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
+ // Read planner: groups Ranges into ReadOps according to a ReadStrategy.
16
+ // Mirrors go/read_planner.go.
17
+
18
+ import type { ReadStrategy } from "./read_strategy.js";
19
+
20
+ export interface Range {
21
+ /** int64. Absolute file offset. */
22
+ offset: bigint;
23
+ /** int64. Length in bytes. */
24
+ length: bigint;
25
+ }
26
+
27
+ export interface ReadOp {
28
+ offset: bigint;
29
+ length: bigint;
30
+ }
31
+
32
+ export interface RangeLocation {
33
+ /** Index into the produced ReadOp[]. */
34
+ opIndex: number;
35
+ /** Byte offset within that op's buffer (must fit in number; ops are bounded). */
36
+ inOpOff: number;
37
+ /** Length of this original range, in bytes. */
38
+ length: number;
39
+ }
40
+
41
+ interface CoalesceGroup {
42
+ offset: bigint;
43
+ end: bigint; // exclusive
44
+ members: number[]; // indices into input ranges, in offset order
45
+ }
46
+
47
+ /**
48
+ * Plan groups Ranges into ReadOps.
49
+ *
50
+ * Precondition: ranges must be in non-decreasing offset order. All call sites
51
+ * in this package satisfy this by construction (writer lays out groups and
52
+ * chunks at monotonically increasing file offsets).
53
+ *
54
+ * Algorithm:
55
+ * 1. Coalesce: merge adjacent ranges whose gap is < coalesceGap.
56
+ * 2. Split: a coalesced op whose length exceeds splitThreshold is sliced at
57
+ * internal range boundaries via greedy packing. A single range larger
58
+ * than splitThreshold stays as one oversize op (atomic units never break).
59
+ */
60
+ export function plan(
61
+ ranges: Range[],
62
+ s: ReadStrategy,
63
+ ): { ops: ReadOp[]; locations: RangeLocation[] } {
64
+ const locations: RangeLocation[] = new Array(ranges.length);
65
+ if (ranges.length === 0) {
66
+ return { ops: [], locations };
67
+ }
68
+
69
+ const groups: CoalesceGroup[] = [];
70
+ for (let idx = 0; idx < ranges.length; idx++) {
71
+ const r = ranges[idx]!;
72
+ if (groups.length === 0) {
73
+ groups.push({
74
+ offset: r.offset,
75
+ end: r.offset + r.length,
76
+ members: [idx],
77
+ });
78
+ continue;
79
+ }
80
+ const cur = groups[groups.length - 1]!;
81
+ const gap = r.offset - cur.end;
82
+ if (gap >= 0n && gap < s.coalesceGap) {
83
+ const newEnd = r.offset + r.length;
84
+ if (newEnd > cur.end) {
85
+ cur.end = newEnd;
86
+ }
87
+ cur.members.push(idx);
88
+ continue;
89
+ }
90
+ groups.push({
91
+ offset: r.offset,
92
+ end: r.offset + r.length,
93
+ members: [idx],
94
+ });
95
+ }
96
+
97
+ const ops: ReadOp[] = [];
98
+ const threshold = s.splitThreshold;
99
+ const splitDisabled = threshold <= 0n;
100
+
101
+ for (const g of groups) {
102
+ if (splitDisabled || g.end - g.offset <= threshold) {
103
+ const opIndex = ops.length;
104
+ ops.push({ offset: g.offset, length: g.end - g.offset });
105
+ for (const mi of g.members) {
106
+ const r = ranges[mi]!;
107
+ locations[mi] = {
108
+ opIndex,
109
+ inOpOff: Number(r.offset - g.offset),
110
+ length: Number(r.length),
111
+ };
112
+ }
113
+ continue;
114
+ }
115
+
116
+ // Greedy split at range boundaries.
117
+ let opStartIdx = 0;
118
+ let opStartOffset = ranges[g.members[0]!]!.offset;
119
+ let opEndOffset =
120
+ ranges[g.members[0]!]!.offset + ranges[g.members[0]!]!.length;
121
+
122
+ const flushOp = (lastMember: number): void => {
123
+ const opIndex = ops.length;
124
+ ops.push({ offset: opStartOffset, length: opEndOffset - opStartOffset });
125
+ for (let k = opStartIdx; k <= lastMember; k++) {
126
+ const mi = g.members[k]!;
127
+ const r = ranges[mi]!;
128
+ locations[mi] = {
129
+ opIndex,
130
+ inOpOff: Number(r.offset - opStartOffset),
131
+ length: Number(r.length),
132
+ };
133
+ }
134
+ };
135
+
136
+ for (let k = 1; k < g.members.length; k++) {
137
+ const mi = g.members[k]!;
138
+ const r = ranges[mi]!;
139
+ const nextEnd = r.offset + r.length;
140
+ if (nextEnd - opStartOffset > threshold) {
141
+ flushOp(k - 1);
142
+ opStartIdx = k;
143
+ opStartOffset = r.offset;
144
+ opEndOffset = nextEnd;
145
+ continue;
146
+ }
147
+ opEndOffset = nextEnd;
148
+ }
149
+ flushOp(g.members.length - 1);
150
+ }
151
+
152
+ return { ops, locations };
153
+ }
@@ -0,0 +1,33 @@
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
+ /**
16
+ * ReadSource is the storage abstraction the Reader uses. It is intentionally
17
+ * minimal: every Go ReadAt/Read+Seek call site can be expressed as `read(off,
18
+ * len)`.
19
+ *
20
+ * Concurrency contract: implementations MUST be safe for concurrent `read`
21
+ * calls. The cost-aware reader path issues many in-flight reads bounded only
22
+ * by `maxConcurrency`.
23
+ */
24
+ export interface ReadSource {
25
+ /** Total length of the underlying file/blob/resource, in bytes. */
26
+ size(): Promise<bigint>;
27
+
28
+ /**
29
+ * Read exactly `length` bytes starting at `offset`. Implementations should
30
+ * throw if fewer bytes are available; the Reader assumes a full read.
31
+ */
32
+ read(offset: bigint, length: bigint): Promise<Uint8Array>;
33
+ }