@saunos/ndjson-index 0.0.0-stage → 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.
- package/LICENSE +21 -0
- package/README.md +74 -2
- package/dist/BoundedLineBatcher.d.ts +39 -0
- package/dist/BoundedLineBatcher.d.ts.map +1 -0
- package/dist/ByteStringSearchIndex.d.ts +71 -0
- package/dist/ByteStringSearchIndex.d.ts.map +1 -0
- package/dist/GrowableInt32Array.d.ts +33 -0
- package/dist/GrowableInt32Array.d.ts.map +1 -0
- package/dist/LineBatcher.d.ts +47 -0
- package/dist/LineBatcher.d.ts.map +1 -0
- package/dist/LineStore.d.ts +93 -0
- package/dist/LineStore.d.ts.map +1 -0
- package/dist/bounded-batches.d.ts +29 -0
- package/dist/bounded-batches.d.ts.map +1 -0
- package/dist/bytes.d.ts +23 -0
- package/dist/bytes.d.ts.map +1 -0
- package/dist/errors.d.ts +27 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +386 -0
- package/dist/read-first-line.d.ts +29 -0
- package/dist/read-first-line.d.ts.map +1 -0
- package/dist/yield-to-main.d.ts +14 -0
- package/dist/yield-to-main.d.ts.map +1 -0
- package/package.json +53 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Saunos
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,75 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @saunos/ndjson-index
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Stream, batch and index huge NDJSON files in the browser without holding them on the JS heap.
|
|
4
|
+
|
|
5
|
+
Built for files of hundreds of MB, such as a diff of 500k records, that a page must page through and search without a database and without freezing:
|
|
6
|
+
|
|
7
|
+
- **Backpressure.** The download is read only when you ask for the next batch.
|
|
8
|
+
- **Off-heap storage.** Lines live in `Blob`s; the heap holds three integers per line.
|
|
9
|
+
- **Compact search.** Text is kept as one-byte-per-char "byte strings" and searched with the native `indexOf`.
|
|
10
|
+
- **Never blocks input.** Long work yields to the event loop between ~1 MB slices.
|
|
11
|
+
|
|
12
|
+
No runtime dependencies. Needs ES2024 (resizable `ArrayBuffer`); uses `scheduler.yield()` where available.
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
npm install @saunos/ndjson-index
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Example
|
|
19
|
+
|
|
20
|
+
A file whose first line is a header declaring how many bytes of records follow:
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import {
|
|
24
|
+
ByteStringSearchIndex,
|
|
25
|
+
LineStore,
|
|
26
|
+
boundedBatches,
|
|
27
|
+
readFirstLine,
|
|
28
|
+
} from '@saunos/ndjson-index';
|
|
29
|
+
|
|
30
|
+
const reader = (await fetch(url)).body!.getReader();
|
|
31
|
+
const { line, rest } = await readFirstLine(reader, { maxBytes: 1 << 20 });
|
|
32
|
+
const header = JSON.parse(line);
|
|
33
|
+
|
|
34
|
+
const lines = new LineStore();
|
|
35
|
+
const search = new ByteStringSearchIndex();
|
|
36
|
+
|
|
37
|
+
for await (const batch of boundedBatches(reader, {
|
|
38
|
+
byteLength: header.bytes,
|
|
39
|
+
batchBytes: 8 << 20,
|
|
40
|
+
leading: rest,
|
|
41
|
+
})) {
|
|
42
|
+
await lines.append(batch, {
|
|
43
|
+
line: (text) => search.append(valuesOf(JSON.parse(text))),
|
|
44
|
+
sliceEnd: () => search.seal(),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const hits = await search.search('иванов'); // ascending line numbers
|
|
49
|
+
const first = JSON.parse(await lines.read(hits[0]));
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## API
|
|
53
|
+
|
|
54
|
+
| Export | What it does |
|
|
55
|
+
| --- | --- |
|
|
56
|
+
| `readFirstLine(reader, { maxBytes })` | Reads and decodes line 1 only; returns it and the bytes read past it. |
|
|
57
|
+
| `boundedBatches(reader, { byteLength, batchBytes, leading })` | Yields the next `byteLength` bytes as newline-aligned batches, then cancels the reader. |
|
|
58
|
+
| `LineBatcher` / `BoundedLineBatcher` | The synchronous chunk-to-batch cutters behind it. |
|
|
59
|
+
| `LineStore` | Blob-backed line storage: `append(batch, visitor)`, `read(n)`, `length`, `dispose()`. |
|
|
60
|
+
| `ByteStringSearchIndex` | Case-insensitive substring index: `append(values)`, `seal()`, `search(needle)`. |
|
|
61
|
+
| `GrowableInt32Array` | Push-only `Int32Array` on a resizable `ArrayBuffer`. |
|
|
62
|
+
| `toByteString(bytes)` | Bytes → one-byte-per-char string. |
|
|
63
|
+
| `yieldToMain()` | Yields to the event loop (`scheduler.yield`, else `MessageChannel`). |
|
|
64
|
+
| `NdjsonError` | Thrown with a `code` when the stream's shape is wrong. |
|
|
65
|
+
|
|
66
|
+
## Development
|
|
67
|
+
|
|
68
|
+
```sh
|
|
69
|
+
bun install
|
|
70
|
+
bun run tsc && bun run lint && bun run fmt && bun test && bun run build
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## License
|
|
74
|
+
|
|
75
|
+
MIT
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cuts exactly the first `byteLength` bytes of a stream into line-aligned
|
|
3
|
+
* batches, for a file whose header declares how long its next part is.
|
|
4
|
+
*
|
|
5
|
+
* Feeds those bytes into a {@link LineBatcher} and reports when the part is
|
|
6
|
+
* complete, so the reader can stop downloading instead of fetching what
|
|
7
|
+
* follows. A chunk that straddles the boundary is cut there, and the batcher
|
|
8
|
+
* is flushed, so the last batch ends exactly at the part's last newline and
|
|
9
|
+
* never carries a line of the next part.
|
|
10
|
+
*/
|
|
11
|
+
export declare class BoundedLineBatcher {
|
|
12
|
+
private readonly byteLength;
|
|
13
|
+
private readonly batcher;
|
|
14
|
+
private seen;
|
|
15
|
+
/**
|
|
16
|
+
* @param byteLength the part's length; it must end on a newline
|
|
17
|
+
* @param batchBytes batch size goal, see {@link LineBatcher}
|
|
18
|
+
*/
|
|
19
|
+
constructor(byteLength: number, batchBytes: number);
|
|
20
|
+
/** Whether every byte of the part has been consumed and flushed. */
|
|
21
|
+
get complete(): boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Consumes the next chunk.
|
|
24
|
+
*
|
|
25
|
+
* @returns the batches it completed, in order: none, one, or two when the
|
|
26
|
+
* chunk crosses the part's end (a cut, then the final flush)
|
|
27
|
+
* @throws {NdjsonError} `not-on-line-boundary` if the byte before the part's
|
|
28
|
+
* end is not a newline: then the declared length does not match the
|
|
29
|
+
* file, and every line after it would be misattributed
|
|
30
|
+
*/
|
|
31
|
+
push(chunk: Uint8Array): Uint8Array<ArrayBuffer>[];
|
|
32
|
+
/**
|
|
33
|
+
* Called when the stream ends.
|
|
34
|
+
*
|
|
35
|
+
* @throws {NdjsonError} `truncated` if it ended inside the part
|
|
36
|
+
*/
|
|
37
|
+
end(): void;
|
|
38
|
+
}
|
|
39
|
+
//# sourceMappingURL=BoundedLineBatcher.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"BoundedLineBatcher.d.ts","sourceRoot":"","sources":["../src/BoundedLineBatcher.ts"],"names":[],"mappings":"AAIA;;;;;;;;;GASG;AACH,qBAAa,kBAAkB;IAS3B,OAAO,CAAC,QAAQ,CAAC,UAAU;IAR7B,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAc;IACtC,OAAO,CAAC,IAAI,CAAK;IAEjB;;;OAGG;gBAEgB,UAAU,EAAE,MAAM,EACnC,UAAU,EAAE,MAAM;IAKpB,oEAAoE;IACpE,IAAI,QAAQ,IAAI,OAAO,CAEtB;IAED;;;;;;;;OAQG;IACH,IAAI,CAAC,KAAK,EAAE,UAAU,GAAG,UAAU,CAAC,WAAW,CAAC,EAAE;IAoBlD;;;;OAIG;IACH,GAAG,IAAI,IAAI;CASZ"}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A case-insensitive substring index over many small documents (such as the
|
|
3
|
+
* values of each NDJSON line), held compactly on the JS heap.
|
|
4
|
+
*
|
|
5
|
+
* Documents are kept as "byte strings": their UTF-8 bytes, one char per byte.
|
|
6
|
+
* A plain JS string costs two bytes per char as soon as one char is outside
|
|
7
|
+
* Latin-1 (a name in Cyrillic, a curly quote), and most real text has one; a
|
|
8
|
+
* byte string stays at one byte per char and can still be searched with the
|
|
9
|
+
* native `String.indexOf`. A needle's UTF-8 bytes occur exactly where the
|
|
10
|
+
* needle does, so the answers are the same.
|
|
11
|
+
*
|
|
12
|
+
* Documents are grouped into segments, each one flat string. Call
|
|
13
|
+
* {@link seal} every MB or so of input (for example from
|
|
14
|
+
* `LineVisitor.sliceEnd`), so no single string gets huge and a search can
|
|
15
|
+
* yield between segments.
|
|
16
|
+
*/
|
|
17
|
+
export declare class ByteStringSearchIndex {
|
|
18
|
+
private segments;
|
|
19
|
+
private docs;
|
|
20
|
+
/** Id of the first document not yet in a segment. */
|
|
21
|
+
private pendingFirst;
|
|
22
|
+
/**
|
|
23
|
+
* Reused space the pending documents are encoded into before a trimmed copy
|
|
24
|
+
* becomes their segment, so appending does not allocate per document.
|
|
25
|
+
*/
|
|
26
|
+
private scratch;
|
|
27
|
+
private written;
|
|
28
|
+
/** Where each pending document starts in {@link scratch}. */
|
|
29
|
+
private pendingStarts;
|
|
30
|
+
private readonly encoder;
|
|
31
|
+
private disposed;
|
|
32
|
+
/** How many documents were appended. */
|
|
33
|
+
get size(): number;
|
|
34
|
+
/**
|
|
35
|
+
* Adds one document; ids count up from zero.
|
|
36
|
+
*
|
|
37
|
+
* @param values matched separately: a needle never matches across two of
|
|
38
|
+
* them. Lowercased here, as {@link search} lowercases the needle.
|
|
39
|
+
* @returns the document's id
|
|
40
|
+
*/
|
|
41
|
+
append(values: readonly string[]): number;
|
|
42
|
+
/**
|
|
43
|
+
* Closes the pending documents into a segment. Cheap when nothing is
|
|
44
|
+
* pending, and called by {@link search} itself, so it only decides where
|
|
45
|
+
* segments end.
|
|
46
|
+
*/
|
|
47
|
+
seal(): void;
|
|
48
|
+
/**
|
|
49
|
+
* The ids of documents any of whose values contain `needle`, ignoring case,
|
|
50
|
+
* ascending. An empty needle matches every document.
|
|
51
|
+
*
|
|
52
|
+
* Each segment is scanned for the needle's bytes. A hit is mapped to its
|
|
53
|
+
* document by binary search over the segment's starts, and the scan resumes
|
|
54
|
+
* at the next document, so a document is reported once however many of its
|
|
55
|
+
* values match. Yields to the event loop every {@link SCAN_BUDGET_MS}.
|
|
56
|
+
*/
|
|
57
|
+
search(needle: string): Promise<Int32Array>;
|
|
58
|
+
/**
|
|
59
|
+
* Drops every segment, so their text can be collected. Safe to call more
|
|
60
|
+
* than once; a later {@link append} or {@link search} throws.
|
|
61
|
+
*/
|
|
62
|
+
dispose(): void;
|
|
63
|
+
/**
|
|
64
|
+
* Writes `text` as UTF-8 into {@link scratch}, then a separator byte,
|
|
65
|
+
* growing the scratch space first if `text` might not fit.
|
|
66
|
+
*/
|
|
67
|
+
private encode;
|
|
68
|
+
/** @throws {Error} once disposed */
|
|
69
|
+
private assertOpen;
|
|
70
|
+
}
|
|
71
|
+
//# sourceMappingURL=ByteStringSearchIndex.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ByteStringSearchIndex.d.ts","sourceRoot":"","sources":["../src/ByteStringSearchIndex.ts"],"names":[],"mappings":"AA4BA;;;;;;;;;;;;;;;GAeG;AACH,qBAAa,qBAAqB;IAChC,OAAO,CAAC,QAAQ,CAAiB;IACjC,OAAO,CAAC,IAAI,CAAK;IACjB,qDAAqD;IACrD,OAAO,CAAC,YAAY,CAAK;IACzB;;;OAGG;IACH,OAAO,CAAC,OAAO,CAA2B;IAC1C,OAAO,CAAC,OAAO,CAAK;IACpB,6DAA6D;IAC7D,OAAO,CAAC,aAAa,CAAwB;IAC7C,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAqB;IAC7C,OAAO,CAAC,QAAQ,CAAS;IAEzB,wCAAwC;IACxC,IAAI,IAAI,IAAI,MAAM,CAEjB;IAED;;;;;;OAMG;IACH,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM;IAazC;;;;OAIG;IACH,IAAI,IAAI,IAAI;IAaZ;;;;;;;;OAQG;IACG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC;IAuBjD;;;OAGG;IACH,OAAO,IAAI,IAAI;IAUf;;;OAGG;IACH,OAAO,CAAC,MAAM;IAad,oCAAoC;IACpC,OAAO,CAAC,UAAU;CAGnB"}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An `Int32Array` that grows as values are pushed, doubling its capacity
|
|
3
|
+
* (amortised O(1) push, like a vector). Per-line indexes over hundreds of
|
|
4
|
+
* thousands of lines stay a few contiguous typed arrays instead of plain
|
|
5
|
+
* arrays of boxed numbers, which keeps the JS heap small and reads fast.
|
|
6
|
+
*
|
|
7
|
+
* Backed by a resizable `ArrayBuffer` (ES2024), which grows in place: no copy
|
|
8
|
+
* into a bigger array on each doubling, and the view tracks the buffer's
|
|
9
|
+
* length.
|
|
10
|
+
*/
|
|
11
|
+
export declare class GrowableInt32Array {
|
|
12
|
+
private readonly buffer;
|
|
13
|
+
/** Length-tracking: always spans the whole buffer, spare capacity included. */
|
|
14
|
+
private readonly values;
|
|
15
|
+
private size;
|
|
16
|
+
/** @param maxLength how many values it can ever hold */
|
|
17
|
+
constructor(maxLength?: number);
|
|
18
|
+
get length(): number;
|
|
19
|
+
/** @throws {RangeError} if the array already holds its maximum length */
|
|
20
|
+
push(value: number): void;
|
|
21
|
+
/**
|
|
22
|
+
* The value at `index`.
|
|
23
|
+
*
|
|
24
|
+
* @throws {RangeError} if `index` is outside `[0, length)`
|
|
25
|
+
*/
|
|
26
|
+
at(index: number): number;
|
|
27
|
+
/**
|
|
28
|
+
* A view of the pushed values. The buffer only ever grows, so the view
|
|
29
|
+
* stays valid, but it does not show values pushed after it was taken.
|
|
30
|
+
*/
|
|
31
|
+
toArray(): Int32Array;
|
|
32
|
+
}
|
|
33
|
+
//# sourceMappingURL=GrowableInt32Array.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"GrowableInt32Array.d.ts","sourceRoot":"","sources":["../src/GrowableInt32Array.ts"],"names":[],"mappings":"AAOA;;;;;;;;;GASG;AACH,qBAAa,kBAAkB;IAC7B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAc;IACrC,+EAA+E;IAC/E,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAa;IACpC,OAAO,CAAC,IAAI,CAAK;IAEjB,wDAAwD;gBAC5C,SAAS,SAAqB;IAO1C,IAAI,MAAM,IAAI,MAAM,CAEnB;IAED,yEAAyE;IACzE,IAAI,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI;IAUzB;;;;OAIG;IACH,EAAE,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM;IAOzB;;;OAGG;IACH,OAAO,IAAI,UAAU;CAGtB"}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Accumulates stream chunks and cuts them into batches that end on a line
|
|
3
|
+
* boundary.
|
|
4
|
+
*
|
|
5
|
+
* NDJSON makes line boundaries trivial: any JSON writer escapes a newline
|
|
6
|
+
* inside a value as `\n`, so the byte 0x0A only ever ends a line. Cutting a
|
|
7
|
+
* batch is "find the last newline once enough bytes are pending".
|
|
8
|
+
*
|
|
9
|
+
* `target` is a size goal, not a limit: a single line longer than the target
|
|
10
|
+
* is held until its newline arrives. A batch that ended mid-line would make a
|
|
11
|
+
* consumer's JSON parse fail on a truncated object, so the boundary is the
|
|
12
|
+
* one thing that cannot be approximate.
|
|
13
|
+
*
|
|
14
|
+
* Every batch is a fresh copy that owns its `ArrayBuffer` (hence the
|
|
15
|
+
* `Uint8Array<ArrayBuffer>` type, which `Blob` requires), so a consumer can
|
|
16
|
+
* keep it without also keeping a network chunk alive, and the carried-over
|
|
17
|
+
* tail never aliases it.
|
|
18
|
+
*/
|
|
19
|
+
export declare class LineBatcher {
|
|
20
|
+
private readonly target;
|
|
21
|
+
private chunks;
|
|
22
|
+
private pending;
|
|
23
|
+
/** @param target batch size goal in bytes */
|
|
24
|
+
constructor(target: number);
|
|
25
|
+
/**
|
|
26
|
+
* Buffers `chunk` and, once at least `target` bytes are pending, cuts
|
|
27
|
+
* everything up to the last newline.
|
|
28
|
+
*
|
|
29
|
+
* Keeps `chunk` by reference until the next cut, so the caller must not
|
|
30
|
+
* mutate it afterwards.
|
|
31
|
+
*
|
|
32
|
+
* @returns the batch that was cut, or null when fewer than `target` bytes
|
|
33
|
+
* are pending or they hold no newline yet
|
|
34
|
+
*/
|
|
35
|
+
push(chunk: Uint8Array): Uint8Array<ArrayBuffer> | null;
|
|
36
|
+
/**
|
|
37
|
+
* Flushes whatever is pending, including a last line without a newline.
|
|
38
|
+
*
|
|
39
|
+
* @returns the final batch, or null when nothing is pending
|
|
40
|
+
*/
|
|
41
|
+
end(): Uint8Array<ArrayBuffer> | null;
|
|
42
|
+
/** Cuts at the last pending newline and carries the rest over. */
|
|
43
|
+
private cut;
|
|
44
|
+
/** Coalesces the pending chunks into one buffer (no copy for one chunk). */
|
|
45
|
+
private join;
|
|
46
|
+
}
|
|
47
|
+
//# sourceMappingURL=LineBatcher.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"LineBatcher.d.ts","sourceRoot":"","sources":["../src/LineBatcher.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;GAiBG;AACH,qBAAa,WAAW;IAKV,OAAO,CAAC,QAAQ,CAAC,MAAM;IAJnC,OAAO,CAAC,MAAM,CAAoB;IAClC,OAAO,CAAC,OAAO,CAAK;IAEpB,6CAA6C;gBAChB,MAAM,EAAE,MAAM;IAE3C;;;;;;;;;OASG;IACH,IAAI,CAAC,KAAK,EAAE,UAAU,GAAG,UAAU,CAAC,WAAW,CAAC,GAAG,IAAI;IAMvD;;;;OAIG;IACH,GAAG,IAAI,UAAU,CAAC,WAAW,CAAC,GAAG,IAAI;IAQrC,kEAAkE;IAClE,OAAO,CAAC,GAAG;IAcX,4EAA4E;IAC5E,OAAO,CAAC,IAAI;CAKb"}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/** What {@link LineStore.append} reports while it indexes a batch. */
|
|
2
|
+
export interface LineVisitor {
|
|
3
|
+
/**
|
|
4
|
+
* Called once per non-empty line, in order.
|
|
5
|
+
*
|
|
6
|
+
* @param text the line, decoded, without its newline
|
|
7
|
+
* @param index the line's number in the store, counted from zero
|
|
8
|
+
* @throws to reject the batch; see {@link LineStore.append}
|
|
9
|
+
*/
|
|
10
|
+
line(text: string, index: number): void;
|
|
11
|
+
/**
|
|
12
|
+
* Called after the lines of each slice (about `sliceBytes` of input), just
|
|
13
|
+
* before the store yields to the event loop. A natural point to close a
|
|
14
|
+
* `ByteStringSearchIndex` segment.
|
|
15
|
+
*/
|
|
16
|
+
sliceEnd?(): void;
|
|
17
|
+
}
|
|
18
|
+
interface LineStoreOptions {
|
|
19
|
+
/** Bytes decoded between yields; see {@link DEFAULT_SLICE_BYTES}. */
|
|
20
|
+
sliceBytes?: number;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Holds many NDJSON lines outside the JS heap and reads any one of them back
|
|
24
|
+
* by number.
|
|
25
|
+
*
|
|
26
|
+
* Each appended batch of lines moves into a `Blob`, which the browser keeps
|
|
27
|
+
* outside the JS heap (and may page to disk). Per line the store keeps only
|
|
28
|
+
* three integers in typed arrays: which blob holds it, and where it starts and
|
|
29
|
+
* ends. So a 500 MB file costs the heap a few MB of indexes, and reading a line
|
|
30
|
+
* back is one `Blob.slice().text()`.
|
|
31
|
+
*
|
|
32
|
+
* Lines are numbered by arrival, counting only non-empty ones.
|
|
33
|
+
*/
|
|
34
|
+
export declare class LineStore {
|
|
35
|
+
private readonly sliceBytes;
|
|
36
|
+
/** The batches as appended; a line is read back out of one of them. */
|
|
37
|
+
private batches;
|
|
38
|
+
/** Per line, indexed by line number: which of {@link batches} holds it. */
|
|
39
|
+
private lineBatch;
|
|
40
|
+
/** Per line: where it starts in its batch. */
|
|
41
|
+
private lineStart;
|
|
42
|
+
/** Per line: where it ends in its batch (exclusive, before the newline). */
|
|
43
|
+
private lineEnd;
|
|
44
|
+
private disposed;
|
|
45
|
+
constructor({ sliceBytes }?: LineStoreOptions);
|
|
46
|
+
/** How many lines the store holds. */
|
|
47
|
+
get length(): number;
|
|
48
|
+
/**
|
|
49
|
+
* Indexes the next batch of lines, about `sliceBytes` at a time, yielding to
|
|
50
|
+
* the event loop between slices, then moves the batch into a `Blob`.
|
|
51
|
+
*
|
|
52
|
+
* Lines are numbered by arrival, so each call must be awaited before the
|
|
53
|
+
* next.
|
|
54
|
+
*
|
|
55
|
+
* @param bytes whole lines (the last may lack its newline); copied into the
|
|
56
|
+
* `Blob`, so the caller may reuse them afterwards
|
|
57
|
+
* @param visitor told about every line, see {@link LineVisitor}
|
|
58
|
+
* @throws whatever `visitor` throws. The lines indexed before it threw stay
|
|
59
|
+
* numbered but can no longer be read, so a store that rejected a batch
|
|
60
|
+
* should be disposed.
|
|
61
|
+
* @throws {Error} once disposed
|
|
62
|
+
*/
|
|
63
|
+
append(bytes: Uint8Array<ArrayBuffer>, visitor: LineVisitor): Promise<void>;
|
|
64
|
+
/**
|
|
65
|
+
* Reads one line back. Reading from a `Blob` is asynchronous, so read the
|
|
66
|
+
* lines of a page in parallel.
|
|
67
|
+
*
|
|
68
|
+
* @throws {RangeError} if no line has that number
|
|
69
|
+
* @throws {Error} once disposed
|
|
70
|
+
*/
|
|
71
|
+
read(index: number): Promise<string>;
|
|
72
|
+
/**
|
|
73
|
+
* Drops every reference, so the bytes and indexes can be collected. Safe to
|
|
74
|
+
* call more than once; any later call to the store throws.
|
|
75
|
+
*/
|
|
76
|
+
dispose(): void;
|
|
77
|
+
/**
|
|
78
|
+
* Records each line of `slice`, which starts at byte `offset` of batch
|
|
79
|
+
* number `batch`, and hands it to `visitor`.
|
|
80
|
+
*
|
|
81
|
+
* The slice is decoded to one string and split on newlines; UTF-8 never
|
|
82
|
+
* uses the newline byte inside a multi-byte character, so the n-th string
|
|
83
|
+
* piece is the n-th byte range found by scanning for `\n`.
|
|
84
|
+
*/
|
|
85
|
+
private indexSlice;
|
|
86
|
+
/**
|
|
87
|
+
* @throws {Error} once disposed, so a late call fails instead of answering
|
|
88
|
+
* from emptied indexes
|
|
89
|
+
*/
|
|
90
|
+
private assertOpen;
|
|
91
|
+
}
|
|
92
|
+
export {};
|
|
93
|
+
//# sourceMappingURL=LineStore.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"LineStore.d.ts","sourceRoot":"","sources":["../src/LineStore.ts"],"names":[],"mappings":"AAYA,sEAAsE;AACtE,MAAM,WAAW,WAAW;IAC1B;;;;;;OAMG;IACH,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACxC;;;;OAIG;IACH,QAAQ,CAAC,IAAI,IAAI,CAAC;CACnB;AAED,UAAU,gBAAgB;IACxB,qEAAqE;IACrE,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED;;;;;;;;;;;GAWG;AACH,qBAAa,SAAS;IACpB,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IACpC,uEAAuE;IACvE,OAAO,CAAC,OAAO,CAAc;IAC7B,2EAA2E;IAC3E,OAAO,CAAC,SAAS,CAA4B;IAC7C,8CAA8C;IAC9C,OAAO,CAAC,SAAS,CAA4B;IAC7C,4EAA4E;IAC5E,OAAO,CAAC,OAAO,CAA4B;IAC3C,OAAO,CAAC,QAAQ,CAAS;gBAEb,EAAE,UAAgC,EAAE,GAAE,gBAAqB;IAIvE,sCAAsC;IACtC,IAAI,MAAM,IAAI,MAAM,CAEnB;IAED;;;;;;;;;;;;;;OAcG;IACG,MAAM,CAAC,KAAK,EAAE,UAAU,CAAC,WAAW,CAAC,EAAE,OAAO,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;IAsBjF;;;;;;OAMG;IACG,IAAI,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAU1C;;;OAGG;IACH,OAAO,IAAI,IAAI;IAQf;;;;;;;OAOG;IACH,OAAO,CAAC,UAAU;IAsBlB;;;OAGG;IACH,OAAO,CAAC,UAAU;CAGnB"}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
interface BoundedBatchesOptions {
|
|
2
|
+
/** How many bytes the part has; it must end on a newline. */
|
|
3
|
+
byteLength: number;
|
|
4
|
+
/** Batch size goal in bytes; see `LineBatcher`. */
|
|
5
|
+
batchBytes: number;
|
|
6
|
+
/**
|
|
7
|
+
* Bytes of the part already read from `reader`, such as the `rest` that
|
|
8
|
+
* `readFirstLine` returns. They come first.
|
|
9
|
+
*/
|
|
10
|
+
leading?: Uint8Array | null;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Reads the next `byteLength` bytes of a stream and yields them as batches of
|
|
14
|
+
* whole lines, in order; they add up to exactly `byteLength`. Each batch owns
|
|
15
|
+
* its buffer, so it may be kept or transferred.
|
|
16
|
+
*
|
|
17
|
+
* Reading is pull-based: the stream is read only when the caller asks for the
|
|
18
|
+
* next batch, so a caller that is still processing the last batch holds the
|
|
19
|
+
* download back. That is the backpressure; nothing else bounds memory.
|
|
20
|
+
*
|
|
21
|
+
* However it ends (done, thrown, or the caller stopping early), the reader is
|
|
22
|
+
* cancelled, so whatever follows the part is never fetched.
|
|
23
|
+
*
|
|
24
|
+
* @throws {NdjsonError} `not-on-line-boundary` if the part does not end on a
|
|
25
|
+
* newline, or `truncated` if the stream ends inside it
|
|
26
|
+
*/
|
|
27
|
+
export declare function boundedBatches(reader: ReadableStreamDefaultReader<Uint8Array>, { byteLength, batchBytes, leading }: BoundedBatchesOptions): AsyncGenerator<Uint8Array<ArrayBuffer>, void, undefined>;
|
|
28
|
+
export {};
|
|
29
|
+
//# sourceMappingURL=bounded-batches.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"bounded-batches.d.ts","sourceRoot":"","sources":["../src/bounded-batches.ts"],"names":[],"mappings":"AAEA,UAAU,qBAAqB;IAC7B,6DAA6D;IAC7D,UAAU,EAAE,MAAM,CAAC;IACnB,mDAAmD;IACnD,UAAU,EAAE,MAAM,CAAC;IACnB;;;OAGG;IACH,OAAO,CAAC,EAAE,UAAU,GAAG,IAAI,CAAC;CAC7B;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAuB,cAAc,CACnC,MAAM,EAAE,2BAA2B,CAAC,UAAU,CAAC,EAC/C,EAAE,UAAU,EAAE,UAAU,EAAE,OAAc,EAAE,EAAE,qBAAqB,GAChE,cAAc,CAAC,UAAU,CAAC,WAAW,CAAC,EAAE,IAAI,EAAE,SAAS,CAAC,CAa1D"}
|
package/dist/bytes.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** The byte that ends every NDJSON line. */
|
|
2
|
+
export declare const NEWLINE = 10;
|
|
3
|
+
/**
|
|
4
|
+
* Copies `chunks` end to end into one new buffer.
|
|
5
|
+
*
|
|
6
|
+
* @param byteLength the chunks' total length, which callers already track
|
|
7
|
+
*/
|
|
8
|
+
export declare function concat(chunks: Uint8Array[], byteLength: number): Uint8Array;
|
|
9
|
+
/**
|
|
10
|
+
* The "byte string" of `bytes`: one char per byte, with the byte's value as
|
|
11
|
+
* its char code.
|
|
12
|
+
*
|
|
13
|
+
* Every char is at most 0xFF, so V8 stores the string at one byte per char,
|
|
14
|
+
* where a string with any char outside Latin-1 costs two. It can still be
|
|
15
|
+
* searched with the native `String.indexOf`, which is several times faster
|
|
16
|
+
* than scanning a `Uint8Array` in JS. Encode a needle the same way and it
|
|
17
|
+
* occurs exactly where its bytes do.
|
|
18
|
+
*
|
|
19
|
+
* Built in chunks because `fromCharCode` takes one argument per char; `join`
|
|
20
|
+
* then yields one flat string, which `indexOf` scans without first copying it.
|
|
21
|
+
*/
|
|
22
|
+
export declare function toByteString(bytes: Uint8Array): string;
|
|
23
|
+
//# sourceMappingURL=bytes.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"bytes.d.ts","sourceRoot":"","sources":["../src/bytes.ts"],"names":[],"mappings":"AAAA,4CAA4C;AAC5C,eAAO,MAAM,OAAO,KAAO,CAAC;AAE5B;;;;GAIG;AACH,wBAAgB,MAAM,CAAC,MAAM,EAAE,UAAU,EAAE,EAAE,UAAU,EAAE,MAAM,GAAG,UAAU,CAQ3E;AAKD;;;;;;;;;;;;GAYG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,UAAU,GAAG,MAAM,CAqBtD"}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Why a stream could not be read as NDJSON:
|
|
3
|
+
*
|
|
4
|
+
* - `no-newline`: the stream ended before the line being read did
|
|
5
|
+
* - `line-too-long`: a line grew past the caller's cap
|
|
6
|
+
* - `not-on-line-boundary`: a declared byte length does not end on a newline
|
|
7
|
+
* - `truncated`: the stream ended before a declared byte length was reached
|
|
8
|
+
*/
|
|
9
|
+
export type NdjsonErrorCode = 'no-newline' | 'line-too-long' | 'not-on-line-boundary' | 'truncated';
|
|
10
|
+
/** The stream's bytes do not have the shape the reader was told to expect. */
|
|
11
|
+
export declare class NdjsonError extends Error {
|
|
12
|
+
readonly code: NdjsonErrorCode;
|
|
13
|
+
/**
|
|
14
|
+
* For `not-on-line-boundary` and `truncated`: the byte of the part where
|
|
15
|
+
* the problem shows, so a caller can report it in its own terms. The
|
|
16
|
+
* declared length for the first, the bytes received for the second.
|
|
17
|
+
*/
|
|
18
|
+
readonly offset?: number | undefined;
|
|
19
|
+
constructor(code: NdjsonErrorCode, message: string,
|
|
20
|
+
/**
|
|
21
|
+
* For `not-on-line-boundary` and `truncated`: the byte of the part where
|
|
22
|
+
* the problem shows, so a caller can report it in its own terms. The
|
|
23
|
+
* declared length for the first, the bytes received for the second.
|
|
24
|
+
*/
|
|
25
|
+
offset?: number | undefined);
|
|
26
|
+
}
|
|
27
|
+
//# sourceMappingURL=errors.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,MAAM,MAAM,eAAe,GAAG,YAAY,GAAG,eAAe,GAAG,sBAAsB,GAAG,WAAW,CAAC;AAEpG,8EAA8E;AAC9E,qBAAa,WAAY,SAAQ,KAAK;IAElC,QAAQ,CAAC,IAAI,EAAE,eAAe;IAE9B;;;;OAIG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM;gBAPf,IAAI,EAAE,eAAe,EAC9B,OAAO,EAAE,MAAM;IACf;;;;OAIG;IACM,MAAM,CAAC,EAAE,MAAM,YAAA;CAK3B"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stream, batch and index huge NDJSON files in the browser without holding
|
|
3
|
+
* them on the JS heap.
|
|
4
|
+
*
|
|
5
|
+
* @packageDocumentation
|
|
6
|
+
*/
|
|
7
|
+
export { NEWLINE, concat, toByteString } from './bytes';
|
|
8
|
+
export { NdjsonError, type NdjsonErrorCode } from './errors';
|
|
9
|
+
export { GrowableInt32Array } from './GrowableInt32Array';
|
|
10
|
+
export { yieldToMain } from './yield-to-main';
|
|
11
|
+
export { LineBatcher } from './LineBatcher';
|
|
12
|
+
export { BoundedLineBatcher } from './BoundedLineBatcher';
|
|
13
|
+
export { readFirstLine } from './read-first-line';
|
|
14
|
+
export { boundedBatches } from './bounded-batches';
|
|
15
|
+
export { LineStore, type LineVisitor } from './LineStore';
|
|
16
|
+
export { ByteStringSearchIndex } from './ByteStringSearchIndex';
|
|
17
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AACxD,OAAO,EAAE,WAAW,EAAE,KAAK,eAAe,EAAE,MAAM,UAAU,CAAC;AAC7D,OAAO,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AAC1D,OAAO,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAC9C,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAC5C,OAAO,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AAC1D,OAAO,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAClD,OAAO,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AACnD,OAAO,EAAE,SAAS,EAAE,KAAK,WAAW,EAAE,MAAM,aAAa,CAAC;AAC1D,OAAO,EAAE,qBAAqB,EAAE,MAAM,yBAAyB,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
// src/bytes.ts
|
|
2
|
+
var NEWLINE = 10;
|
|
3
|
+
function concat(chunks, byteLength) {
|
|
4
|
+
const out = new Uint8Array(byteLength);
|
|
5
|
+
let at = 0;
|
|
6
|
+
for (const chunk of chunks) {
|
|
7
|
+
out.set(chunk, at);
|
|
8
|
+
at += chunk.byteLength;
|
|
9
|
+
}
|
|
10
|
+
return out;
|
|
11
|
+
}
|
|
12
|
+
var CHARS_PER_CALL = 32 * 1024;
|
|
13
|
+
function toByteString(bytes) {
|
|
14
|
+
const parts = [];
|
|
15
|
+
for (let at = 0;at < bytes.length; at += CHARS_PER_CALL) {
|
|
16
|
+
parts.push(String(Reflect.apply(String.fromCharCode, undefined, bytes.subarray(at, at + CHARS_PER_CALL))));
|
|
17
|
+
}
|
|
18
|
+
return parts.join("");
|
|
19
|
+
}
|
|
20
|
+
// src/errors.ts
|
|
21
|
+
class NdjsonError extends Error {
|
|
22
|
+
code;
|
|
23
|
+
offset;
|
|
24
|
+
constructor(code, message, offset) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.code = code;
|
|
27
|
+
this.offset = offset;
|
|
28
|
+
this.name = "NdjsonError";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// src/GrowableInt32Array.ts
|
|
32
|
+
var DEFAULT_MAX_LENGTH = 1 << 26;
|
|
33
|
+
|
|
34
|
+
class GrowableInt32Array {
|
|
35
|
+
buffer;
|
|
36
|
+
values;
|
|
37
|
+
size = 0;
|
|
38
|
+
constructor(maxLength = DEFAULT_MAX_LENGTH) {
|
|
39
|
+
this.buffer = new ArrayBuffer(Math.min(1024, maxLength) * 4, {
|
|
40
|
+
maxByteLength: maxLength * 4
|
|
41
|
+
});
|
|
42
|
+
this.values = new Int32Array(this.buffer);
|
|
43
|
+
}
|
|
44
|
+
get length() {
|
|
45
|
+
return this.size;
|
|
46
|
+
}
|
|
47
|
+
push(value) {
|
|
48
|
+
if (this.size === this.values.length) {
|
|
49
|
+
this.buffer.resize(Math.min(this.buffer.byteLength * 2, this.buffer.maxByteLength));
|
|
50
|
+
}
|
|
51
|
+
if (this.size === this.values.length) {
|
|
52
|
+
throw new RangeError(`More than ${this.size} values`);
|
|
53
|
+
}
|
|
54
|
+
this.values[this.size++] = value;
|
|
55
|
+
}
|
|
56
|
+
at(index) {
|
|
57
|
+
if (index < 0 || index >= this.size) {
|
|
58
|
+
throw new RangeError(`Index ${index} outside [0, ${this.size})`);
|
|
59
|
+
}
|
|
60
|
+
return this.values[index];
|
|
61
|
+
}
|
|
62
|
+
toArray() {
|
|
63
|
+
return this.values.subarray(0, this.size);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// src/yield-to-main.ts
|
|
67
|
+
function yieldToMain() {
|
|
68
|
+
const { scheduler } = globalThis;
|
|
69
|
+
if (typeof scheduler?.yield === "function")
|
|
70
|
+
return scheduler.yield();
|
|
71
|
+
if (typeof MessageChannel === "function") {
|
|
72
|
+
return new Promise((resolve) => {
|
|
73
|
+
const channel = new MessageChannel;
|
|
74
|
+
channel.port1.onmessage = () => {
|
|
75
|
+
channel.port1.close();
|
|
76
|
+
resolve();
|
|
77
|
+
};
|
|
78
|
+
channel.port2.postMessage(null);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return new Promise((resolve) => setTimeout(resolve, 0));
|
|
82
|
+
}
|
|
83
|
+
// src/LineBatcher.ts
|
|
84
|
+
class LineBatcher {
|
|
85
|
+
target;
|
|
86
|
+
chunks = [];
|
|
87
|
+
pending = 0;
|
|
88
|
+
constructor(target) {
|
|
89
|
+
this.target = target;
|
|
90
|
+
}
|
|
91
|
+
push(chunk) {
|
|
92
|
+
this.chunks.push(chunk);
|
|
93
|
+
this.pending += chunk.byteLength;
|
|
94
|
+
return this.pending >= this.target ? this.cut() : null;
|
|
95
|
+
}
|
|
96
|
+
end() {
|
|
97
|
+
if (this.pending === 0)
|
|
98
|
+
return null;
|
|
99
|
+
const rest = this.join().slice();
|
|
100
|
+
this.chunks = [];
|
|
101
|
+
this.pending = 0;
|
|
102
|
+
return rest;
|
|
103
|
+
}
|
|
104
|
+
cut() {
|
|
105
|
+
const buf = this.join();
|
|
106
|
+
const at = buf.lastIndexOf(NEWLINE);
|
|
107
|
+
if (at === -1) {
|
|
108
|
+
this.chunks = [buf];
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
const tail = buf.subarray(at + 1);
|
|
112
|
+
this.chunks = tail.byteLength > 0 ? [tail.slice()] : [];
|
|
113
|
+
this.pending = tail.byteLength;
|
|
114
|
+
return buf.subarray(0, at + 1).slice();
|
|
115
|
+
}
|
|
116
|
+
join() {
|
|
117
|
+
const [only] = this.chunks;
|
|
118
|
+
if (this.chunks.length === 1 && only != null)
|
|
119
|
+
return only;
|
|
120
|
+
return concat(this.chunks, this.pending);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
// src/BoundedLineBatcher.ts
|
|
124
|
+
class BoundedLineBatcher {
|
|
125
|
+
byteLength;
|
|
126
|
+
batcher;
|
|
127
|
+
seen = 0;
|
|
128
|
+
constructor(byteLength, batchBytes) {
|
|
129
|
+
this.byteLength = byteLength;
|
|
130
|
+
this.batcher = new LineBatcher(batchBytes);
|
|
131
|
+
}
|
|
132
|
+
get complete() {
|
|
133
|
+
return this.seen === this.byteLength;
|
|
134
|
+
}
|
|
135
|
+
push(chunk) {
|
|
136
|
+
if (this.complete)
|
|
137
|
+
return [];
|
|
138
|
+
const left = this.byteLength - this.seen;
|
|
139
|
+
if (chunk.byteLength < left) {
|
|
140
|
+
this.seen += chunk.byteLength;
|
|
141
|
+
return present(this.batcher.push(chunk));
|
|
142
|
+
}
|
|
143
|
+
const head = chunk.subarray(0, left);
|
|
144
|
+
this.seen = this.byteLength;
|
|
145
|
+
if (head[head.byteLength - 1] !== NEWLINE) {
|
|
146
|
+
throw new NdjsonError("not-on-line-boundary", `byte ${this.byteLength} of the part is not a newline: ` + "the declared length does not fall on a line boundary", this.byteLength);
|
|
147
|
+
}
|
|
148
|
+
return [...present(this.batcher.push(head)), ...present(this.batcher.end())];
|
|
149
|
+
}
|
|
150
|
+
end() {
|
|
151
|
+
if (!this.complete) {
|
|
152
|
+
throw new NdjsonError("truncated", `stream ended after ${this.seen} bytes of the part, ` + `which declares ${this.byteLength}`, this.seen);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function present(batch) {
|
|
157
|
+
return batch == null ? [] : [batch];
|
|
158
|
+
}
|
|
159
|
+
// src/read-first-line.ts
|
|
160
|
+
async function readFirstLine(reader, { maxBytes }) {
|
|
161
|
+
const chunks = [];
|
|
162
|
+
let length = 0;
|
|
163
|
+
for (;; ) {
|
|
164
|
+
const { done, value } = await reader.read();
|
|
165
|
+
if (done) {
|
|
166
|
+
throw new NdjsonError("no-newline", "stream ended before line 1 did");
|
|
167
|
+
}
|
|
168
|
+
const at = value.indexOf(NEWLINE);
|
|
169
|
+
if (length + (at === -1 ? value.byteLength : at) > maxBytes) {
|
|
170
|
+
throw new NdjsonError("line-too-long", `line 1 is over ${maxBytes} bytes`);
|
|
171
|
+
}
|
|
172
|
+
if (at === -1) {
|
|
173
|
+
chunks.push(value);
|
|
174
|
+
length += value.byteLength;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
chunks.push(value.subarray(0, at));
|
|
178
|
+
length += at;
|
|
179
|
+
const rest = value.subarray(at + 1);
|
|
180
|
+
return {
|
|
181
|
+
line: new TextDecoder().decode(concat(chunks, length)),
|
|
182
|
+
rest: rest.byteLength > 0 ? rest : null
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
// src/bounded-batches.ts
|
|
187
|
+
async function* boundedBatches(reader, { byteLength, batchBytes, leading = null }) {
|
|
188
|
+
const part = new BoundedLineBatcher(byteLength, batchBytes);
|
|
189
|
+
try {
|
|
190
|
+
if (leading != null)
|
|
191
|
+
yield* part.push(leading);
|
|
192
|
+
while (!part.complete) {
|
|
193
|
+
const { done, value } = await reader.read();
|
|
194
|
+
if (done)
|
|
195
|
+
break;
|
|
196
|
+
yield* part.push(value);
|
|
197
|
+
}
|
|
198
|
+
part.end();
|
|
199
|
+
} finally {
|
|
200
|
+
await reader.cancel().catch(() => {
|
|
201
|
+
return;
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// src/LineStore.ts
|
|
206
|
+
var DEFAULT_SLICE_BYTES = 1 << 20;
|
|
207
|
+
|
|
208
|
+
class LineStore {
|
|
209
|
+
sliceBytes;
|
|
210
|
+
batches = [];
|
|
211
|
+
lineBatch = new GrowableInt32Array;
|
|
212
|
+
lineStart = new GrowableInt32Array;
|
|
213
|
+
lineEnd = new GrowableInt32Array;
|
|
214
|
+
disposed = false;
|
|
215
|
+
constructor({ sliceBytes = DEFAULT_SLICE_BYTES } = {}) {
|
|
216
|
+
this.sliceBytes = sliceBytes;
|
|
217
|
+
}
|
|
218
|
+
get length() {
|
|
219
|
+
return this.lineStart.length;
|
|
220
|
+
}
|
|
221
|
+
async append(bytes, visitor) {
|
|
222
|
+
this.assertOpen();
|
|
223
|
+
const batch = this.batches.length;
|
|
224
|
+
const decoder = new TextDecoder;
|
|
225
|
+
let sliceStart = 0;
|
|
226
|
+
while (sliceStart < bytes.byteLength) {
|
|
227
|
+
const newline = bytes.indexOf(NEWLINE, Math.min(sliceStart + this.sliceBytes, bytes.byteLength) - 1);
|
|
228
|
+
const sliceEnd = newline === -1 ? bytes.byteLength : newline + 1;
|
|
229
|
+
this.indexSlice(bytes.subarray(sliceStart, sliceEnd), batch, sliceStart, decoder, visitor);
|
|
230
|
+
visitor.sliceEnd?.();
|
|
231
|
+
sliceStart = sliceEnd;
|
|
232
|
+
if (sliceStart < bytes.byteLength)
|
|
233
|
+
await yieldToMain();
|
|
234
|
+
}
|
|
235
|
+
this.batches.push(new Blob([bytes]));
|
|
236
|
+
}
|
|
237
|
+
async read(index) {
|
|
238
|
+
this.assertOpen();
|
|
239
|
+
const batch = this.batches[this.lineBatch.at(index)];
|
|
240
|
+
if (batch == null) {
|
|
241
|
+
throw new RangeError(`Line ${index} belongs to a rejected batch`);
|
|
242
|
+
}
|
|
243
|
+
return batch.slice(this.lineStart.at(index), this.lineEnd.at(index)).text();
|
|
244
|
+
}
|
|
245
|
+
dispose() {
|
|
246
|
+
this.disposed = true;
|
|
247
|
+
this.batches = [];
|
|
248
|
+
this.lineBatch = new GrowableInt32Array(0);
|
|
249
|
+
this.lineStart = new GrowableInt32Array(0);
|
|
250
|
+
this.lineEnd = new GrowableInt32Array(0);
|
|
251
|
+
}
|
|
252
|
+
indexSlice(slice, batch, offset, decoder, visitor) {
|
|
253
|
+
let lineStart = 0;
|
|
254
|
+
for (const line of decoder.decode(slice).split(`
|
|
255
|
+
`)) {
|
|
256
|
+
const newline = slice.indexOf(NEWLINE, lineStart);
|
|
257
|
+
const lineEnd = newline === -1 ? slice.byteLength : newline;
|
|
258
|
+
if (line.length > 0) {
|
|
259
|
+
const index = this.lineStart.length;
|
|
260
|
+
this.lineBatch.push(batch);
|
|
261
|
+
this.lineStart.push(offset + lineStart);
|
|
262
|
+
this.lineEnd.push(offset + lineEnd);
|
|
263
|
+
visitor.line(line, index);
|
|
264
|
+
}
|
|
265
|
+
lineStart = lineEnd + 1;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
assertOpen() {
|
|
269
|
+
if (this.disposed)
|
|
270
|
+
throw new Error("LineStore disposed");
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
// src/ByteStringSearchIndex.ts
|
|
274
|
+
var SCAN_BUDGET_MS = 30;
|
|
275
|
+
var SEPARATOR = "\x00";
|
|
276
|
+
|
|
277
|
+
class ByteStringSearchIndex {
|
|
278
|
+
segments = [];
|
|
279
|
+
docs = 0;
|
|
280
|
+
pendingFirst = 0;
|
|
281
|
+
scratch = new Uint8Array(1 << 21);
|
|
282
|
+
written = 0;
|
|
283
|
+
pendingStarts = new Int32Array(1024);
|
|
284
|
+
encoder = new TextEncoder;
|
|
285
|
+
disposed = false;
|
|
286
|
+
get size() {
|
|
287
|
+
return this.docs;
|
|
288
|
+
}
|
|
289
|
+
append(values) {
|
|
290
|
+
this.assertOpen();
|
|
291
|
+
const pending = this.docs - this.pendingFirst;
|
|
292
|
+
if (pending === this.pendingStarts.length) {
|
|
293
|
+
const grown = new Int32Array(pending * 2);
|
|
294
|
+
grown.set(this.pendingStarts);
|
|
295
|
+
this.pendingStarts = grown;
|
|
296
|
+
}
|
|
297
|
+
this.pendingStarts[pending] = this.written;
|
|
298
|
+
this.encode(values.map((value) => value.toLowerCase()).join(SEPARATOR));
|
|
299
|
+
return this.docs++;
|
|
300
|
+
}
|
|
301
|
+
seal() {
|
|
302
|
+
const pending = this.docs - this.pendingFirst;
|
|
303
|
+
if (pending === 0)
|
|
304
|
+
return;
|
|
305
|
+
this.segments.push({
|
|
306
|
+
firstDoc: this.pendingFirst,
|
|
307
|
+
text: toByteString(this.scratch.subarray(0, this.written)),
|
|
308
|
+
starts: this.pendingStarts.slice(0, pending)
|
|
309
|
+
});
|
|
310
|
+
this.pendingFirst = this.docs;
|
|
311
|
+
this.written = 0;
|
|
312
|
+
}
|
|
313
|
+
async search(needle) {
|
|
314
|
+
this.assertOpen();
|
|
315
|
+
this.seal();
|
|
316
|
+
const bytes = toByteString(new TextEncoder().encode(needle.toLowerCase()));
|
|
317
|
+
const found = new GrowableInt32Array;
|
|
318
|
+
let sliceStarted = performance.now();
|
|
319
|
+
for (const { firstDoc, text, starts } of this.segments) {
|
|
320
|
+
let at = text.indexOf(bytes);
|
|
321
|
+
while (at !== -1) {
|
|
322
|
+
const doc = lastAtOrBefore(starts, at);
|
|
323
|
+
found.push(firstDoc + doc);
|
|
324
|
+
const next = starts[doc + 1];
|
|
325
|
+
if (next == null)
|
|
326
|
+
break;
|
|
327
|
+
at = text.indexOf(bytes, next);
|
|
328
|
+
}
|
|
329
|
+
if (performance.now() - sliceStarted > SCAN_BUDGET_MS) {
|
|
330
|
+
await yieldToMain();
|
|
331
|
+
sliceStarted = performance.now();
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return found.toArray();
|
|
335
|
+
}
|
|
336
|
+
dispose() {
|
|
337
|
+
this.disposed = true;
|
|
338
|
+
this.segments = [];
|
|
339
|
+
this.scratch = new Uint8Array(0);
|
|
340
|
+
this.pendingStarts = new Int32Array(0);
|
|
341
|
+
this.docs = 0;
|
|
342
|
+
this.pendingFirst = 0;
|
|
343
|
+
this.written = 0;
|
|
344
|
+
}
|
|
345
|
+
encode(text) {
|
|
346
|
+
const needed = this.written + text.length * 3 + 1;
|
|
347
|
+
if (needed > this.scratch.length) {
|
|
348
|
+
const grown = new Uint8Array(Math.max(needed, this.scratch.length * 2));
|
|
349
|
+
grown.set(this.scratch.subarray(0, this.written));
|
|
350
|
+
this.scratch = grown;
|
|
351
|
+
}
|
|
352
|
+
const { written } = this.encoder.encodeInto(text, this.scratch.subarray(this.written));
|
|
353
|
+
this.scratch[this.written + written] = 0;
|
|
354
|
+
this.written += written + 1;
|
|
355
|
+
}
|
|
356
|
+
assertOpen() {
|
|
357
|
+
if (this.disposed)
|
|
358
|
+
throw new Error("ByteStringSearchIndex disposed");
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
function lastAtOrBefore(sorted, value) {
|
|
362
|
+
let low = 0;
|
|
363
|
+
let high = sorted.length - 1;
|
|
364
|
+
while (low < high) {
|
|
365
|
+
const mid = low + high + 1 >> 1;
|
|
366
|
+
if ((sorted[mid] ?? 0) <= value)
|
|
367
|
+
low = mid;
|
|
368
|
+
else
|
|
369
|
+
high = mid - 1;
|
|
370
|
+
}
|
|
371
|
+
return low;
|
|
372
|
+
}
|
|
373
|
+
export {
|
|
374
|
+
BoundedLineBatcher,
|
|
375
|
+
ByteStringSearchIndex,
|
|
376
|
+
GrowableInt32Array,
|
|
377
|
+
LineBatcher,
|
|
378
|
+
LineStore,
|
|
379
|
+
NEWLINE,
|
|
380
|
+
NdjsonError,
|
|
381
|
+
boundedBatches,
|
|
382
|
+
concat,
|
|
383
|
+
readFirstLine,
|
|
384
|
+
toByteString,
|
|
385
|
+
yieldToMain
|
|
386
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
interface ReadFirstLineOptions {
|
|
2
|
+
/**
|
|
3
|
+
* The longest line this reader buffers. A header line is usually a few
|
|
4
|
+
* kilobytes; a stream with no newline in its first `maxBytes` is not what
|
|
5
|
+
* the caller expects, and buffering it whole would put the entire download
|
|
6
|
+
* in the JS heap before failing.
|
|
7
|
+
*/
|
|
8
|
+
maxBytes: number;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Reads up to the first newline and decodes that line, typically a header
|
|
12
|
+
* that describes the rest of the stream.
|
|
13
|
+
*
|
|
14
|
+
* Chunks are held until the newline arrives, so a line split across network
|
|
15
|
+
* reads is put back together. Only this line is ever decoded to a JS string;
|
|
16
|
+
* the rest of the stream stays bytes, and is left unread.
|
|
17
|
+
*
|
|
18
|
+
* @returns the line without its newline, and the bytes read past it (a view
|
|
19
|
+
* into the last chunk; null when the newline was its last byte)
|
|
20
|
+
* @throws {NdjsonError} `no-newline` if the stream ends before any newline,
|
|
21
|
+
* or `line-too-long` if the line grows past `maxBytes`. The reader is not
|
|
22
|
+
* cancelled; that is the caller's call.
|
|
23
|
+
*/
|
|
24
|
+
export declare function readFirstLine(reader: ReadableStreamDefaultReader<Uint8Array>, { maxBytes }: ReadFirstLineOptions): Promise<{
|
|
25
|
+
line: string;
|
|
26
|
+
rest: Uint8Array | null;
|
|
27
|
+
}>;
|
|
28
|
+
export {};
|
|
29
|
+
//# sourceMappingURL=read-first-line.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"read-first-line.d.ts","sourceRoot":"","sources":["../src/read-first-line.ts"],"names":[],"mappings":"AAGA,UAAU,oBAAoB;IAC5B;;;;;OAKG;IACH,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAsB,aAAa,CACjC,MAAM,EAAE,2BAA2B,CAAC,UAAU,CAAC,EAC/C,EAAE,QAAQ,EAAE,EAAE,oBAAoB,GACjC,OAAO,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,UAAU,GAAG,IAAI,CAAA;CAAE,CAAC,CA2BpD"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ends the current task and resumes in a new one, so the browser can paint
|
|
3
|
+
* and handle input in between: the "yield to the main thread" pattern for
|
|
4
|
+
* splitting long work.
|
|
5
|
+
*
|
|
6
|
+
* Uses `scheduler.yield()` where it exists: it resumes ahead of other queued
|
|
7
|
+
* tasks, so a long parse that yields hundreds of times is not slowed by
|
|
8
|
+
* unrelated work queued behind it. Elsewhere it falls back to a
|
|
9
|
+
* `MessageChannel` message, which starts a new task without the 4 ms clamp
|
|
10
|
+
* that nested `setTimeout(0)` calls get, and to `setTimeout` where there is
|
|
11
|
+
* no `MessageChannel` either.
|
|
12
|
+
*/
|
|
13
|
+
export declare function yieldToMain(): Promise<void>;
|
|
14
|
+
//# sourceMappingURL=yield-to-main.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"yield-to-main.d.ts","sourceRoot":"","sources":["../src/yield-to-main.ts"],"names":[],"mappings":"AAKA;;;;;;;;;;;GAWG;AACH,wBAAgB,WAAW,IAAI,OAAO,CAAC,IAAI,CAAC,CAgB3C"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,55 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@saunos/ndjson-index",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Stream, batch and index huge NDJSON files in the browser without holding them on the JS heap",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/saunos/ndjson-index"
|
|
9
|
+
},
|
|
10
|
+
"keywords": [
|
|
11
|
+
"ndjson",
|
|
12
|
+
"jsonl",
|
|
13
|
+
"stream",
|
|
14
|
+
"search",
|
|
15
|
+
"blob",
|
|
16
|
+
"browser"
|
|
17
|
+
],
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"LICENSE",
|
|
21
|
+
"README.md"
|
|
22
|
+
],
|
|
23
|
+
"type": "module",
|
|
24
|
+
"sideEffects": false,
|
|
25
|
+
"main": "./dist/index.js",
|
|
26
|
+
"types": "./dist/index.d.ts",
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"import": "./dist/index.js"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build:lib": "bun build ./src/index.ts --outdir ./dist --target browser --packages external",
|
|
35
|
+
"build:types": "tsc -p tsconfig.build.json",
|
|
36
|
+
"build": "rm -rf dist && bun run build:lib && bun run build:types",
|
|
37
|
+
"docs": "typedoc",
|
|
38
|
+
"prepublishOnly": "bun run tsc && bun run lint && bun run fmt && bun run test && bun run build",
|
|
39
|
+
"tsc": "tsc --noEmit",
|
|
40
|
+
"test": "bun test --coverage",
|
|
41
|
+
"lint": "oxlint src",
|
|
42
|
+
"fmt:write": "oxfmt src",
|
|
43
|
+
"fmt": "oxfmt --check src"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@types/bun": "latest",
|
|
47
|
+
"oxfmt": "^0.67.0",
|
|
48
|
+
"oxlint": "^1.82.0",
|
|
49
|
+
"typedoc": "^0.28.20",
|
|
50
|
+
"typescript": "^5"
|
|
51
|
+
},
|
|
52
|
+
"engines": {
|
|
53
|
+
"node": ">=22"
|
|
54
|
+
}
|
|
55
|
+
}
|