ink-cartridge 3.6.1
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 +249 -0
- package/dist/binary-storage/BinaryStorage.d.ts +308 -0
- package/dist/binary-storage/BinaryStorage.js +548 -0
- package/dist/binary-storage/StreamingReader.d.ts +53 -0
- package/dist/binary-storage/StreamingReader.js +530 -0
- package/dist/binary-storage/index.d.ts +91 -0
- package/dist/binary-storage/index.js +91 -0
- package/dist/binary-storage/types.d.ts +400 -0
- package/dist/binary-storage/types.js +15 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +214 -0
- package/dist/cli/initTheme.d.ts +14 -0
- package/dist/cli/initTheme.js +124 -0
- package/dist/cli/makeLanguageType.d.ts +46 -0
- package/dist/cli/makeLanguageType.js +264 -0
- package/dist/cli/makeThemeType.d.ts +43 -0
- package/dist/cli/makeThemeType.js +222 -0
- package/dist/components/badge/Badge.d.ts +7 -0
- package/dist/components/badge/Badge.js +9 -0
- package/dist/components/dialog/ConfirmDialog.d.ts +23 -0
- package/dist/components/dialog/ConfirmDialog.js +57 -0
- package/dist/components/dialog/types.d.ts +20 -0
- package/dist/components/dialog/types.js +1 -0
- package/dist/components/divider/Divider.d.ts +8 -0
- package/dist/components/divider/Divider.js +10 -0
- package/dist/components/fold/Fold.d.ts +3 -0
- package/dist/components/fold/Fold.js +44 -0
- package/dist/components/fold/types.d.ts +28 -0
- package/dist/components/fold/types.js +1 -0
- package/dist/components/form/Field.d.ts +30 -0
- package/dist/components/form/Field.js +59 -0
- package/dist/components/form/Form.d.ts +32 -0
- package/dist/components/form/Form.js +163 -0
- package/dist/components/form/context.d.ts +14 -0
- package/dist/components/form/context.js +20 -0
- package/dist/components/form/types.d.ts +115 -0
- package/dist/components/form/types.js +1 -0
- package/dist/components/key-hint/KeyHint.d.ts +10 -0
- package/dist/components/key-hint/KeyHint.js +12 -0
- package/dist/components/multi-select/MultiSelectInput.d.ts +125 -0
- package/dist/components/multi-select/MultiSelectInput.js +287 -0
- package/dist/components/multi-select/types.d.ts +100 -0
- package/dist/components/multi-select/types.js +1 -0
- package/dist/components/number-input/NumberInput.d.ts +11 -0
- package/dist/components/number-input/NumberInput.js +44 -0
- package/dist/components/progress-bar/ProgressBar.d.ts +11 -0
- package/dist/components/progress-bar/ProgressBar.js +9 -0
- package/dist/components/search-input/SearchInput.d.ts +10 -0
- package/dist/components/search-input/SearchInput.js +15 -0
- package/dist/components/select/SelectInput.d.ts +100 -0
- package/dist/components/select/SelectInput.js +147 -0
- package/dist/components/select/types.d.ts +63 -0
- package/dist/components/select/types.js +1 -0
- package/dist/components/spinner/Spinner.d.ts +12 -0
- package/dist/components/spinner/Spinner.js +24 -0
- package/dist/components/tabs/Tabs.d.ts +3 -0
- package/dist/components/tabs/Tabs.js +57 -0
- package/dist/components/tabs/types.d.ts +32 -0
- package/dist/components/tabs/types.js +1 -0
- package/dist/components/text/TextInput.d.ts +39 -0
- package/dist/components/text/TextInput.js +233 -0
- package/dist/components/text/types.d.ts +62 -0
- package/dist/components/text/types.js +1 -0
- package/dist/dev/dev-screen.d.ts +36 -0
- package/dist/dev/dev-screen.js +142 -0
- package/dist/dev/entrance.d.ts +51 -0
- package/dist/dev/entrance.js +67 -0
- package/dist/dev/globalKey-display.d.ts +2 -0
- package/dist/dev/globalKey-display.js +8 -0
- package/dist/dev/types.d.ts +9 -0
- package/dist/dev/types.js +1 -0
- package/dist/index.d.ts +46 -0
- package/dist/index.js +50 -0
- package/dist/keyboard/check-global-key.d.ts +18 -0
- package/dist/keyboard/check-global-key.js +52 -0
- package/dist/keyboard/context.d.ts +398 -0
- package/dist/keyboard/context.js +9 -0
- package/dist/keyboard/global-key-processor/index.d.ts +16 -0
- package/dist/keyboard/global-key-processor/index.js +75 -0
- package/dist/keyboard/global-sequence-processor/index.d.ts +14 -0
- package/dist/keyboard/global-sequence-processor/index.js +165 -0
- package/dist/keyboard/hook.d.ts +51 -0
- package/dist/keyboard/hook.js +105 -0
- package/dist/keyboard/index.d.ts +5 -0
- package/dist/keyboard/index.js +3 -0
- package/dist/keyboard/keyNormalizer.d.ts +37 -0
- package/dist/keyboard/keyNormalizer.js +119 -0
- package/dist/keyboard/layer-handler.d.ts +57 -0
- package/dist/keyboard/layer-handler.js +274 -0
- package/dist/keyboard/modal-processor/index.d.ts +16 -0
- package/dist/keyboard/modal-processor/index.js +97 -0
- package/dist/keyboard/overlay-processor/index.d.ts +16 -0
- package/dist/keyboard/overlay-processor/index.js +30 -0
- package/dist/keyboard/pipeline/chain.d.ts +10 -0
- package/dist/keyboard/pipeline/chain.js +45 -0
- package/dist/keyboard/pipeline/context.d.ts +34 -0
- package/dist/keyboard/pipeline/context.js +42 -0
- package/dist/keyboard/pipeline/index.d.ts +3 -0
- package/dist/keyboard/pipeline/index.js +2 -0
- package/dist/keyboard/provider.d.ts +32 -0
- package/dist/keyboard/provider.js +985 -0
- package/dist/keyboard/screen-stack-processor/index.d.ts +14 -0
- package/dist/keyboard/screen-stack-processor/index.js +32 -0
- package/dist/keyboard/types.d.ts +632 -0
- package/dist/keyboard/types.js +1 -0
- package/dist/language/context.d.ts +7 -0
- package/dist/language/context.js +7 -0
- package/dist/language/hook.d.ts +19 -0
- package/dist/language/hook.js +26 -0
- package/dist/language/index.d.ts +3 -0
- package/dist/language/index.js +2 -0
- package/dist/language/provider.d.ts +30 -0
- package/dist/language/provider.js +155 -0
- package/dist/language/types.d.ts +124 -0
- package/dist/language/types.js +1 -0
- package/dist/screen/ModalContext.d.ts +11 -0
- package/dist/screen/ModalContext.js +12 -0
- package/dist/screen/OverlayContext.d.ts +9 -0
- package/dist/screen/OverlayContext.js +10 -0
- package/dist/screen/context.d.ts +61 -0
- package/dist/screen/context.js +8 -0
- package/dist/screen/current-screen.d.ts +16 -0
- package/dist/screen/current-screen.js +38 -0
- package/dist/screen/hook.d.ts +13 -0
- package/dist/screen/hook.js +20 -0
- package/dist/screen/index.d.ts +7 -0
- package/dist/screen/index.js +5 -0
- package/dist/screen/provider.d.ts +112 -0
- package/dist/screen/provider.js +577 -0
- package/dist/screen/registry.d.ts +27 -0
- package/dist/screen/registry.js +71 -0
- package/dist/screen/types.d.ts +227 -0
- package/dist/screen/types.js +1 -0
- package/dist/storage/index.d.ts +45 -0
- package/dist/storage/index.js +46 -0
- package/dist/storage/storage.d.ts +139 -0
- package/dist/storage/storage.js +236 -0
- package/dist/storage/types.d.ts +145 -0
- package/dist/storage/types.js +1 -0
- package/dist/theme/context.d.ts +7 -0
- package/dist/theme/context.js +7 -0
- package/dist/theme/hook.d.ts +11 -0
- package/dist/theme/hook.js +18 -0
- package/dist/theme/index.d.ts +4 -0
- package/dist/theme/index.js +2 -0
- package/dist/theme/provider.d.ts +18 -0
- package/dist/theme/provider.js +308 -0
- package/dist/theme/types.d.ts +53 -0
- package/dist/theme/types.js +1 -0
- package/package.json +71 -0
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { TAG_NAMES } from './types.js';
|
|
4
|
+
/**
|
|
5
|
+
* Size constants for the binary encoding format.
|
|
6
|
+
*
|
|
7
|
+
* Every value on disk has this layout:
|
|
8
|
+
*
|
|
9
|
+
* ```
|
|
10
|
+
* [1 byte: TypeTag] [N bytes: payload]
|
|
11
|
+
* ```
|
|
12
|
+
*
|
|
13
|
+
* The payload size depends on the type:
|
|
14
|
+
* - Number: 8 bytes (IEEE 754 float64, little-endian)
|
|
15
|
+
* - Boolean: 1 byte (0x00 or 0x01)
|
|
16
|
+
* - String: 4 bytes (uint32 LE length) + UTF-8 content
|
|
17
|
+
* - Object: same as String (JSON-serialised)
|
|
18
|
+
* - Array: same as String (JSON-serialised)
|
|
19
|
+
* - Null: 0 bytes (just the tag)
|
|
20
|
+
*/
|
|
21
|
+
const TAG_SIZE = 1;
|
|
22
|
+
const NUM_SIZE = 8;
|
|
23
|
+
const BOOL_SIZE = 1;
|
|
24
|
+
const LEN_SIZE = 4;
|
|
25
|
+
/**
|
|
26
|
+
* Default allocation increment for the internal Buffer.
|
|
27
|
+
*
|
|
28
|
+
* When the buffer needs to grow, we allocate at least 4 KiB
|
|
29
|
+
* extra to amortise the cost of `Buffer.alloc()` + `copy()`
|
|
30
|
+
* across many small writes. A single `write.num()` call needs
|
|
31
|
+
* only 9 bytes, but allocating 9 bytes at a time would be slow.
|
|
32
|
+
*/
|
|
33
|
+
const GROW_STEP = 4096;
|
|
34
|
+
/**
|
|
35
|
+
* Sequential binary key-value store backed by a single file.
|
|
36
|
+
*
|
|
37
|
+
* ## Architecture
|
|
38
|
+
*
|
|
39
|
+
* The entire file is loaded into a single in-memory `Buffer` on
|
|
40
|
+
* construction (lazy — only if the file already exists). Two
|
|
41
|
+
* cursors track the current position:
|
|
42
|
+
*
|
|
43
|
+
* - `readPos` — where the next `read.*()` will start reading.
|
|
44
|
+
* Starts at 0. Advanced each time a value is consumed.
|
|
45
|
+
*
|
|
46
|
+
* - `writePos` — where the next `write.*()` will append data.
|
|
47
|
+
* Starts at the file size (or 0 for a new file). Advanced
|
|
48
|
+
* each time a value is written.
|
|
49
|
+
*
|
|
50
|
+
* Because both cursors point into the same Buffer, you can write
|
|
51
|
+
* values, then immediately read them back by resetting the read
|
|
52
|
+
* cursor — no disk round-trip needed.
|
|
53
|
+
*
|
|
54
|
+
* ## Write path
|
|
55
|
+
*
|
|
56
|
+
* 1. `write.num(v)` calls `enqueue(() => appendNum(v))`.
|
|
57
|
+
* 2. `enqueue` chains the task onto the internal `pending` promise
|
|
58
|
+
* so writes are strictly ordered.
|
|
59
|
+
* 3. `appendNum` calls `growBuf()` to ensure capacity, writes
|
|
60
|
+
* `[TypeTag.Number][8-byte float64 LE]` at `writePos`, then
|
|
61
|
+
* advances `writePos`.
|
|
62
|
+
* 4. If `flush: true` (default), `forceFlush()` writes the buffer
|
|
63
|
+
* to disk atomically (temp file → rename).
|
|
64
|
+
*
|
|
65
|
+
* ## Read path
|
|
66
|
+
*
|
|
67
|
+
* 1. `read.num()` calls `readTyped(TypeTag.Number, ...)`.
|
|
68
|
+
* 2. `readTyped` calls `readTag()` which checks `readPos < writePos`
|
|
69
|
+
* (end-of-stream guard) and returns the byte at `readPos`.
|
|
70
|
+
* 3. If the tag matches `TypeTag.Number`, `readPos` advances past
|
|
71
|
+
* the tag and the payload reader (`readNumPayload`) is invoked.
|
|
72
|
+
* 4. `readNumPayload` reads 8 bytes at `readPos` as float64 LE,
|
|
73
|
+
* advances `readPos`, and returns the number.
|
|
74
|
+
*
|
|
75
|
+
* ## Concurrency model
|
|
76
|
+
*
|
|
77
|
+
* Writes are serialised through a promise chain (`this.pending`).
|
|
78
|
+
* The chain uses `.then(task, task)` — the second argument means
|
|
79
|
+
* that even if one write task rejects, subsequent writes still run.
|
|
80
|
+
* Reads do NOT go through the queue because they are synchronous
|
|
81
|
+
* operations on the in-memory buffer (the `async` wrapper is for
|
|
82
|
+
* API consistency only).
|
|
83
|
+
*
|
|
84
|
+
* ## Atomicity guarantee
|
|
85
|
+
*
|
|
86
|
+
* Flush uses the same temp-file-then-rename pattern as the JSON
|
|
87
|
+
* {@link Storage}: write to `file.bin.tmp`, then `fs.renameSync`.
|
|
88
|
+
* On POSIX systems, `rename` is atomic — the on-disk file is
|
|
89
|
+
* always either the complete old version or the complete new
|
|
90
|
+
* version. A crash mid-write leaves the original file intact.
|
|
91
|
+
*
|
|
92
|
+
* ## Buffer growth strategy
|
|
93
|
+
*
|
|
94
|
+
* {@link growBuf} doubles the buffer capacity when it runs out
|
|
95
|
+
* of space, with a minimum increment of {@link GROW_STEP} (4 KiB).
|
|
96
|
+
* This means writing 10,000 small values allocates ~20 times
|
|
97
|
+
* instead of 10,000 times — a classic amortised-O(1) approach.
|
|
98
|
+
*/
|
|
99
|
+
export class BinaryStorage {
|
|
100
|
+
/** Resolved configuration with defaults applied. */
|
|
101
|
+
options;
|
|
102
|
+
/** Absolute path to the backing file. */
|
|
103
|
+
filePath;
|
|
104
|
+
/**
|
|
105
|
+
* In-memory copy of the file. Growable — always
|
|
106
|
+
* `buffer.length >= writePos`. Only the first `writePos`
|
|
107
|
+
* bytes are meaningful; the rest is pre-allocated capacity.
|
|
108
|
+
*/
|
|
109
|
+
buffer;
|
|
110
|
+
/** Next byte to read from. Always `0 <= readPos <= writePos`. */
|
|
111
|
+
readPos = 0;
|
|
112
|
+
/** Next byte to write to. Equal to total valid data size. */
|
|
113
|
+
writePos;
|
|
114
|
+
/**
|
|
115
|
+
* Promise chain for serialising writes. Each write task
|
|
116
|
+
* is attached via `.then(task, task)` so the chain never
|
|
117
|
+
* breaks even if a task rejects.
|
|
118
|
+
*/
|
|
119
|
+
pending = Promise.resolve();
|
|
120
|
+
/**
|
|
121
|
+
* @param options Optional configuration. Instances should be
|
|
122
|
+
* created via {@link createBinaryStorage}.
|
|
123
|
+
*/
|
|
124
|
+
constructor(options) {
|
|
125
|
+
this.options = {
|
|
126
|
+
dir: options?.dir ?? './data',
|
|
127
|
+
file: options?.file ?? 'storage.bin',
|
|
128
|
+
flush: options?.flush ?? true,
|
|
129
|
+
};
|
|
130
|
+
this.filePath = path.resolve(this.options.dir, this.options.file);
|
|
131
|
+
this.ensureDir();
|
|
132
|
+
// Load existing file into memory, or start with an empty buffer.
|
|
133
|
+
this.buffer = this.loadFile();
|
|
134
|
+
this.writePos = this.buffer.length;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Typed write methods.
|
|
138
|
+
*
|
|
139
|
+
* Each method enqueues the write task so that concurrent
|
|
140
|
+
* callers never interleave. The task:
|
|
141
|
+
* 1. Ensures buffer capacity via `growBuf`.
|
|
142
|
+
* 2. Writes the type tag + payload at `writePos`.
|
|
143
|
+
* 3. Advances `writePos`.
|
|
144
|
+
* 4. Flushes if `options.flush` is true.
|
|
145
|
+
*/
|
|
146
|
+
write = {
|
|
147
|
+
num: (value) => this.enqueue(() => this.appendNum(value)),
|
|
148
|
+
str: (value) => this.enqueue(() => this.appendStr(value)),
|
|
149
|
+
b: (value) => this.enqueue(() => this.appendBool(value)),
|
|
150
|
+
obj: (v) => this.enqueue(() => this.appendObj(v)),
|
|
151
|
+
arr: (v) => this.enqueue(() => this.appendArr(v)),
|
|
152
|
+
any: (value) => this.enqueue(() => this.appendAny(value)),
|
|
153
|
+
null: () => this.enqueue(() => this.appendNull()),
|
|
154
|
+
/** Force-persist regardless of the `flush` option. */
|
|
155
|
+
flush: () => this.enqueue(async () => { await this.forceFlush(); }),
|
|
156
|
+
};
|
|
157
|
+
/**
|
|
158
|
+
* Typed read methods.
|
|
159
|
+
*
|
|
160
|
+
* Each method delegates to `readTyped()` which validates the
|
|
161
|
+
* type tag, then calls the type-specific payload reader.
|
|
162
|
+
* Wrapped in `async` so that errors thrown synchronously
|
|
163
|
+
* (e.g. type mismatch) become Promise rejections.
|
|
164
|
+
*/
|
|
165
|
+
read = {
|
|
166
|
+
num: async () => this.readTyped(1 /* TypeTag.Number */, () => this.readNumPayload()),
|
|
167
|
+
str: async () => this.readTyped(2 /* TypeTag.String */, () => this.readStrPayload()),
|
|
168
|
+
b: async () => this.readTyped(3 /* TypeTag.Boolean */, () => this.readBoolPayload()),
|
|
169
|
+
obj: async () => this.readTyped(4 /* TypeTag.Object */, () => this.readJSONPayload()),
|
|
170
|
+
arr: async () => this.readTyped(5 /* TypeTag.Array */, () => this.readJSONPayload()),
|
|
171
|
+
any: async () => this.readAnyPayload(),
|
|
172
|
+
};
|
|
173
|
+
tellRead() { return this.readPos; }
|
|
174
|
+
tellWrite() { return this.writePos; }
|
|
175
|
+
seekRead(pos) {
|
|
176
|
+
if (pos < 0 || pos > this.writePos) {
|
|
177
|
+
throw new Error(`[Ink-Cartridge] BinaryStorage: seekRead(${pos}) out of range ` +
|
|
178
|
+
`(0..${this.writePos}).`);
|
|
179
|
+
}
|
|
180
|
+
this.readPos = pos;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Truncate the stream at `pos` and persist.
|
|
184
|
+
*
|
|
185
|
+
* Because this mutates the buffer, it goes through the write
|
|
186
|
+
* queue to avoid racing with in-flight writes. If the read
|
|
187
|
+
* cursor would end up past the new write position, it is
|
|
188
|
+
* clamped back.
|
|
189
|
+
*/
|
|
190
|
+
async seekWrite(pos) {
|
|
191
|
+
if (pos < 0) {
|
|
192
|
+
throw new Error(`[Ink-Cartridge] BinaryStorage: seekWrite(${pos}) must be >= 0.`);
|
|
193
|
+
}
|
|
194
|
+
await this.enqueue(async () => {
|
|
195
|
+
// If the truncation point is within the current buffer,
|
|
196
|
+
// slice away everything beyond it. Using Buffer.from()
|
|
197
|
+
// creates an independent copy so the old buffer can be GC'd.
|
|
198
|
+
if (pos < this.buffer.length) {
|
|
199
|
+
this.buffer = Buffer.from(this.buffer.subarray(0, pos));
|
|
200
|
+
}
|
|
201
|
+
this.writePos = pos;
|
|
202
|
+
if (this.readPos > this.writePos) {
|
|
203
|
+
this.readPos = this.writePos;
|
|
204
|
+
}
|
|
205
|
+
await this.flush();
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
resetRead() { this.seekRead(0); }
|
|
209
|
+
/** Convenience: discard everything after the read cursor. */
|
|
210
|
+
async truncate() {
|
|
211
|
+
await this.seekWrite(this.readPos);
|
|
212
|
+
}
|
|
213
|
+
// ═══════════════════════════════════════════════════════════
|
|
214
|
+
// Internal helpers — not part of the public API
|
|
215
|
+
// ═══════════════════════════════════════════════════════════
|
|
216
|
+
ensureDir() {
|
|
217
|
+
fs.mkdirSync(this.options.dir, { recursive: true });
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Read the file into a Buffer. Returns an empty buffer if the
|
|
221
|
+
* file does not exist (first use) or can't be read.
|
|
222
|
+
*/
|
|
223
|
+
loadFile() {
|
|
224
|
+
try {
|
|
225
|
+
return fs.readFileSync(this.filePath);
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
return Buffer.alloc(0);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Serialise access so concurrent callers never interleave.
|
|
233
|
+
*
|
|
234
|
+
* Each task is chained onto `this.pending` using
|
|
235
|
+
* `.then(task, task)`. The second `task` argument is critical:
|
|
236
|
+
* it means that if a previous task rejects, the next task
|
|
237
|
+
* still executes. Without it, a single failed write would
|
|
238
|
+
* permanently break the chain and all subsequent writes
|
|
239
|
+
* would hang.
|
|
240
|
+
*/
|
|
241
|
+
enqueue(task) {
|
|
242
|
+
this.pending = this.pending.then(task, task);
|
|
243
|
+
return this.pending;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Conditional flush — only persists if `options.flush` is true.
|
|
247
|
+
*
|
|
248
|
+
* Called automatically after every write when `flush: true`.
|
|
249
|
+
* When `flush: false`, writes are buffered in memory until
|
|
250
|
+
* {@link forceFlush} is called (via `write.flush()`).
|
|
251
|
+
*/
|
|
252
|
+
async flush() {
|
|
253
|
+
if (!this.options.flush)
|
|
254
|
+
return;
|
|
255
|
+
await this.forceFlush();
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Unconditional flush — always writes to disk.
|
|
259
|
+
*
|
|
260
|
+
* Only the first `writePos` bytes are written; the rest of the
|
|
261
|
+
* buffer is pre-allocated capacity. Uses atomic rename to
|
|
262
|
+
* prevent corruption from partial writes or crashes.
|
|
263
|
+
*/
|
|
264
|
+
async forceFlush() {
|
|
265
|
+
const tmpPath = this.filePath + '.tmp';
|
|
266
|
+
fs.writeFileSync(tmpPath, this.buffer.subarray(0, this.writePos));
|
|
267
|
+
fs.renameSync(tmpPath, this.filePath);
|
|
268
|
+
}
|
|
269
|
+
// ── write payload helpers ──────────────────────────────────
|
|
270
|
+
/**
|
|
271
|
+
* Ensure the internal Buffer has at least `needed` free bytes
|
|
272
|
+
* after `writePos`.
|
|
273
|
+
*
|
|
274
|
+
* Growth strategy: allocate `max(needed, GROW_STEP)` extra bytes,
|
|
275
|
+
* copy the old buffer into the new one. This is amortised O(1)
|
|
276
|
+
* per byte written — the classic dynamic-array approach.
|
|
277
|
+
*/
|
|
278
|
+
growBuf(needed) {
|
|
279
|
+
if (this.writePos + needed <= this.buffer.length)
|
|
280
|
+
return;
|
|
281
|
+
const extra = Math.max(needed, GROW_STEP);
|
|
282
|
+
const grown = Buffer.alloc(this.buffer.length + extra);
|
|
283
|
+
this.buffer.copy(grown);
|
|
284
|
+
this.buffer = grown;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Encode a number: `[0x01][8 bytes float64 LE]`.
|
|
288
|
+
*
|
|
289
|
+
* Uses `writeDoubleLE` which writes IEEE 754 binary64 format.
|
|
290
|
+
* This preserves all JS numbers faithfully, including special
|
|
291
|
+
* values like `Infinity`, `-Infinity`, and `NaN`.
|
|
292
|
+
*/
|
|
293
|
+
async appendNum(value) {
|
|
294
|
+
this.growBuf(TAG_SIZE + NUM_SIZE);
|
|
295
|
+
this.buffer.writeUInt8(1 /* TypeTag.Number */, this.writePos);
|
|
296
|
+
this.buffer.writeDoubleLE(value, this.writePos + TAG_SIZE);
|
|
297
|
+
this.writePos += TAG_SIZE + NUM_SIZE;
|
|
298
|
+
await this.flush();
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Encode a string: `[0x02][4 bytes uint32 LE length][UTF-8 bytes]`.
|
|
302
|
+
*
|
|
303
|
+
* The length is the byte count of the UTF-8 encoding, not the
|
|
304
|
+
* character count. This ensures multi-byte characters (emoji,
|
|
305
|
+
* CJK) are handled correctly.
|
|
306
|
+
*/
|
|
307
|
+
async appendStr(value) {
|
|
308
|
+
const encoded = Buffer.from(value, 'utf-8');
|
|
309
|
+
this.growBuf(TAG_SIZE + LEN_SIZE + encoded.length);
|
|
310
|
+
let pos = this.writePos;
|
|
311
|
+
this.buffer.writeUInt8(2 /* TypeTag.String */, pos);
|
|
312
|
+
pos += TAG_SIZE;
|
|
313
|
+
this.buffer.writeUInt32LE(encoded.length, pos);
|
|
314
|
+
pos += LEN_SIZE;
|
|
315
|
+
encoded.copy(this.buffer, pos);
|
|
316
|
+
this.writePos = pos + encoded.length;
|
|
317
|
+
await this.flush();
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Encode a boolean: `[0x03][1 byte: 0x00 or 0x01]`.
|
|
321
|
+
*/
|
|
322
|
+
async appendBool(value) {
|
|
323
|
+
this.growBuf(TAG_SIZE + BOOL_SIZE);
|
|
324
|
+
this.buffer.writeUInt8(3 /* TypeTag.Boolean */, this.writePos);
|
|
325
|
+
this.buffer.writeUInt8(value ? 1 : 0, this.writePos + TAG_SIZE);
|
|
326
|
+
this.writePos += TAG_SIZE + BOOL_SIZE;
|
|
327
|
+
await this.flush();
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Encode an object as JSON: `[0x04][4 bytes length][JSON UTF-8]`.
|
|
331
|
+
*
|
|
332
|
+
* Objects and arrays use the same wire format (length-prefixed
|
|
333
|
+
* UTF-8 string) but different type tags so that `read.obj()`
|
|
334
|
+
* and `read.arr()` can validate the expected shape.
|
|
335
|
+
*/
|
|
336
|
+
async appendObj(value) {
|
|
337
|
+
const json = JSON.stringify(value);
|
|
338
|
+
const encoded = Buffer.from(json, 'utf-8');
|
|
339
|
+
this.growBuf(TAG_SIZE + LEN_SIZE + encoded.length);
|
|
340
|
+
let pos = this.writePos;
|
|
341
|
+
this.buffer.writeUInt8(4 /* TypeTag.Object */, pos);
|
|
342
|
+
pos += TAG_SIZE;
|
|
343
|
+
this.buffer.writeUInt32LE(encoded.length, pos);
|
|
344
|
+
pos += LEN_SIZE;
|
|
345
|
+
encoded.copy(this.buffer, pos);
|
|
346
|
+
this.writePos = pos + encoded.length;
|
|
347
|
+
await this.flush();
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Encode an array as JSON: `[0x05][4 bytes length][JSON UTF-8]`.
|
|
351
|
+
*/
|
|
352
|
+
async appendArr(value) {
|
|
353
|
+
const json = JSON.stringify(value);
|
|
354
|
+
const encoded = Buffer.from(json, 'utf-8');
|
|
355
|
+
this.growBuf(TAG_SIZE + LEN_SIZE + encoded.length);
|
|
356
|
+
let pos = this.writePos;
|
|
357
|
+
this.buffer.writeUInt8(5 /* TypeTag.Array */, pos);
|
|
358
|
+
pos += TAG_SIZE;
|
|
359
|
+
this.buffer.writeUInt32LE(encoded.length, pos);
|
|
360
|
+
pos += LEN_SIZE;
|
|
361
|
+
encoded.copy(this.buffer, pos);
|
|
362
|
+
this.writePos = pos + encoded.length;
|
|
363
|
+
await this.flush();
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Auto-detect the type of `value` and delegate to the
|
|
367
|
+
* appropriate typed append method.
|
|
368
|
+
*
|
|
369
|
+
* Detection order:
|
|
370
|
+
* 1. `typeof` for number/string/boolean → typed tags
|
|
371
|
+
* 2. `null` / `undefined` → TypeTag.Null
|
|
372
|
+
* 3. `Array.isArray` → TypeTag.Array
|
|
373
|
+
* 4. `typeof === 'object'` → TypeTag.Object
|
|
374
|
+
* 5. Fallback → TypeTag.Null (shouldn't happen, but safe)
|
|
375
|
+
*/
|
|
376
|
+
async appendAny(value) {
|
|
377
|
+
if (typeof value === 'number') {
|
|
378
|
+
await this.appendNum(value);
|
|
379
|
+
}
|
|
380
|
+
else if (typeof value === 'string') {
|
|
381
|
+
await this.appendStr(value);
|
|
382
|
+
}
|
|
383
|
+
else if (typeof value === 'boolean') {
|
|
384
|
+
await this.appendBool(value);
|
|
385
|
+
}
|
|
386
|
+
else if (value === null || value === undefined) {
|
|
387
|
+
await this.appendNull();
|
|
388
|
+
}
|
|
389
|
+
else if (Array.isArray(value)) {
|
|
390
|
+
await this.appendArr(value);
|
|
391
|
+
}
|
|
392
|
+
else if (typeof value === 'object') {
|
|
393
|
+
await this.appendObj(value);
|
|
394
|
+
}
|
|
395
|
+
else {
|
|
396
|
+
await this.appendNull();
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Encode null: `[0x06]` — just the tag, no payload.
|
|
401
|
+
*/
|
|
402
|
+
async appendNull() {
|
|
403
|
+
this.growBuf(TAG_SIZE);
|
|
404
|
+
this.buffer.writeUInt8(6 /* TypeTag.Null */, this.writePos);
|
|
405
|
+
this.writePos += TAG_SIZE;
|
|
406
|
+
await this.flush();
|
|
407
|
+
}
|
|
408
|
+
// ── read helpers ───────────────────────────────────────────
|
|
409
|
+
/**
|
|
410
|
+
* Peek at the type tag byte at `readPos`.
|
|
411
|
+
*
|
|
412
|
+
* This is the end-of-stream guard for all typed read methods.
|
|
413
|
+
* It checks against `writePos` (not `buffer.length`) because
|
|
414
|
+
* the buffer may have pre-allocated capacity beyond the valid
|
|
415
|
+
* data region.
|
|
416
|
+
*
|
|
417
|
+
* @returns The tag byte (0x01–0x06).
|
|
418
|
+
* @throws If `readPos >= writePos` (no more values to read).
|
|
419
|
+
*/
|
|
420
|
+
readTag() {
|
|
421
|
+
if (this.readPos >= this.writePos) {
|
|
422
|
+
throw new Error(`[Ink-Cartridge] BinaryStorage: end of stream at byte ${this.readPos} — ` +
|
|
423
|
+
`no more values to read.`);
|
|
424
|
+
}
|
|
425
|
+
return this.buffer.readUInt8(this.readPos);
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Read a uint32 LE length prefix from `readPos` and advance.
|
|
429
|
+
*
|
|
430
|
+
* @returns The length value.
|
|
431
|
+
* @throws If there aren't enough bytes for the length prefix.
|
|
432
|
+
*/
|
|
433
|
+
readUInt32LE() {
|
|
434
|
+
if (this.readPos + LEN_SIZE > this.writePos) {
|
|
435
|
+
throw new Error(`[Ink-Cartridge] BinaryStorage: unexpected end of stream at byte ${this.readPos} ` +
|
|
436
|
+
`while reading length prefix.`);
|
|
437
|
+
}
|
|
438
|
+
const v = this.buffer.readUInt32LE(this.readPos);
|
|
439
|
+
this.readPos += LEN_SIZE;
|
|
440
|
+
return v;
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Core typed-read routine.
|
|
444
|
+
*
|
|
445
|
+
* 1. Peek at the tag byte.
|
|
446
|
+
* 2. If it doesn't match `expected`, throw a descriptive error
|
|
447
|
+
* with the byte position and human-readable type names.
|
|
448
|
+
* 3. Otherwise advance past the tag and invoke the payload
|
|
449
|
+
* reader callback.
|
|
450
|
+
*
|
|
451
|
+
* @typeParam T — The return type of the payload reader.
|
|
452
|
+
* @param expected — The TypeTag we expect to find.
|
|
453
|
+
* @param readPayload — Callback that reads the payload bytes
|
|
454
|
+
* and returns the decoded value.
|
|
455
|
+
*/
|
|
456
|
+
readTyped(expected, readPayload) {
|
|
457
|
+
const tag = this.readTag();
|
|
458
|
+
if (tag !== expected) {
|
|
459
|
+
const expectedName = TAG_NAMES[expected];
|
|
460
|
+
const actualName = TAG_NAMES[tag] ?? `unknown(0x${tag.toString(16)})`;
|
|
461
|
+
throw new Error(`[Ink-Cartridge] BinaryStorage: type mismatch at byte ${this.readPos}. ` +
|
|
462
|
+
`Expected ${expectedName}, got ${actualName}.`);
|
|
463
|
+
}
|
|
464
|
+
this.readPos += TAG_SIZE;
|
|
465
|
+
return readPayload();
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Read 8 bytes as a float64 LE number.
|
|
469
|
+
*
|
|
470
|
+
* Uses `readDoubleLE` which decodes IEEE 754 binary64.
|
|
471
|
+
* `Infinity`, `-Infinity`, and `NaN` round-trip correctly.
|
|
472
|
+
*/
|
|
473
|
+
readNumPayload() {
|
|
474
|
+
if (this.readPos + NUM_SIZE > this.writePos) {
|
|
475
|
+
throw new Error(`[Ink-Cartridge] BinaryStorage: unexpected end of stream at byte ${this.readPos} ` +
|
|
476
|
+
`while reading number (need ${NUM_SIZE} bytes).`);
|
|
477
|
+
}
|
|
478
|
+
const val = this.buffer.readDoubleLE(this.readPos);
|
|
479
|
+
this.readPos += NUM_SIZE;
|
|
480
|
+
return val;
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Read a length-prefixed UTF-8 string.
|
|
484
|
+
*
|
|
485
|
+
* 1. Read 4-byte uint32 LE → byte length.
|
|
486
|
+
* 2. Read that many bytes as UTF-8.
|
|
487
|
+
*/
|
|
488
|
+
readStrPayload() {
|
|
489
|
+
const len = this.readUInt32LE();
|
|
490
|
+
if (this.readPos + len > this.writePos) {
|
|
491
|
+
throw new Error(`[Ink-Cartridge] BinaryStorage: unexpected end of stream at byte ${this.readPos}. ` +
|
|
492
|
+
`Expected ${len} bytes of string data, only ${this.writePos - this.readPos} available.`);
|
|
493
|
+
}
|
|
494
|
+
const val = this.buffer.toString('utf-8', this.readPos, this.readPos + len);
|
|
495
|
+
this.readPos += len;
|
|
496
|
+
return val;
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* Read a single byte as a boolean.
|
|
500
|
+
*
|
|
501
|
+
* Any non-zero byte is treated as `true`.
|
|
502
|
+
*/
|
|
503
|
+
readBoolPayload() {
|
|
504
|
+
if (this.readPos + BOOL_SIZE > this.writePos) {
|
|
505
|
+
throw new Error(`[Ink-Cartridge] BinaryStorage: unexpected end of stream at byte ${this.readPos} ` +
|
|
506
|
+
`while reading boolean.`);
|
|
507
|
+
}
|
|
508
|
+
const val = this.buffer.readUInt8(this.readPos) !== 0;
|
|
509
|
+
this.readPos += BOOL_SIZE;
|
|
510
|
+
return val;
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Read a string payload and parse it as JSON.
|
|
514
|
+
*
|
|
515
|
+
* Shared by `read.obj()` and `read.arr()` — the type tag
|
|
516
|
+
* distinguishes the two, but the on-wire format is identical
|
|
517
|
+
* (JSON string).
|
|
518
|
+
*/
|
|
519
|
+
readJSONPayload() {
|
|
520
|
+
const raw = this.readStrPayload();
|
|
521
|
+
return JSON.parse(raw);
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Read the next value regardless of type.
|
|
525
|
+
*
|
|
526
|
+
* Reads the tag byte, then dispatches to the appropriate
|
|
527
|
+
* payload reader. This is the universal read method — it
|
|
528
|
+
* accepts any type and returns the decoded value directly.
|
|
529
|
+
*
|
|
530
|
+
* @throws If the tag byte is unknown (file corruption or
|
|
531
|
+
* newer format version).
|
|
532
|
+
*/
|
|
533
|
+
readAnyPayload() {
|
|
534
|
+
const tag = this.readTag();
|
|
535
|
+
this.readPos += TAG_SIZE;
|
|
536
|
+
switch (tag) {
|
|
537
|
+
case 1 /* TypeTag.Number */: return this.readNumPayload();
|
|
538
|
+
case 2 /* TypeTag.String */: return this.readStrPayload();
|
|
539
|
+
case 3 /* TypeTag.Boolean */: return this.readBoolPayload();
|
|
540
|
+
case 4 /* TypeTag.Object */: return this.readJSONPayload();
|
|
541
|
+
case 5 /* TypeTag.Array */: return this.readJSONPayload();
|
|
542
|
+
case 6 /* TypeTag.Null */: return null;
|
|
543
|
+
default:
|
|
544
|
+
throw new Error(`[Ink-Cartridge] BinaryStorage: unknown type tag 0x${tag.toString(16)} ` +
|
|
545
|
+
`at byte ${this.readPos - TAG_SIZE}. File may be corrupt.`);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { StreamingReaderOptions, StreamingReaderAPI } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Error thrown when the binary file is structurally invalid.
|
|
4
|
+
*
|
|
5
|
+
* Three conditions trigger this:
|
|
6
|
+
* 1. An unknown type tag byte (value outside 0x01–0x06).
|
|
7
|
+
* 2. The file ends mid-value (truncated — fewer bytes remaining than
|
|
8
|
+
* the length prefix declares).
|
|
9
|
+
* 3. Invalid JSON inside an Object or Array payload.
|
|
10
|
+
*
|
|
11
|
+
* The `offset` property gives the byte position in the file where
|
|
12
|
+
* corruption was first detected, aiding debugging.
|
|
13
|
+
*/
|
|
14
|
+
export declare class StreamCorruptError extends Error {
|
|
15
|
+
readonly offset: number;
|
|
16
|
+
constructor(message: string, offset: number);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Create a streaming binary reader for a file.
|
|
20
|
+
*
|
|
21
|
+
* Unlike {@link createBinaryStorage} (which loads the entire file into
|
|
22
|
+
* memory), this reads values sequentially from disk via a Node.js
|
|
23
|
+
* `ReadStream`. Values are parsed one at a time and returned in
|
|
24
|
+
* complete batches — the user never sees partial / truncated data.
|
|
25
|
+
*
|
|
26
|
+
* @param filePath — Absolute or relative path to the binary file.
|
|
27
|
+
* @param options — Optional tuning knobs (queue size, chunk size).
|
|
28
|
+
* @returns A reader with `readBatch()`, async iteration, and `destroy()`.
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```ts
|
|
32
|
+
* import { createStreamingReader } from 'ink-cartridge';
|
|
33
|
+
*
|
|
34
|
+
* const reader = createStreamingReader('large.bin');
|
|
35
|
+
*
|
|
36
|
+
* let batch;
|
|
37
|
+
* while ((batch = await reader.readBatch(1000)).length > 0) {
|
|
38
|
+
* for (const value of batch) {
|
|
39
|
+
* console.log(value);
|
|
40
|
+
* }
|
|
41
|
+
* }
|
|
42
|
+
*
|
|
43
|
+
* // Or: async iteration
|
|
44
|
+
* for await (const value of reader) {
|
|
45
|
+
* console.log(value);
|
|
46
|
+
* }
|
|
47
|
+
* ```
|
|
48
|
+
*
|
|
49
|
+
* @throws `ENOENT` if the file does not exist.
|
|
50
|
+
* @throws `StreamCorruptError` if the file is corrupt or truncated.
|
|
51
|
+
*/
|
|
52
|
+
export declare function createStreamingReader(filePath: string, options?: StreamingReaderOptions): StreamingReaderAPI;
|
|
53
|
+
export type { StreamingReaderOptions, StreamingReaderAPI };
|