@effected/jsonl 0.8.0 → 0.10.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/Envelope.js +95 -155
- package/Journal.js +42 -463
- package/JsonlError.js +14 -18
- package/JsonlEvent.js +1 -8
- package/Line.js +37 -145
- package/LineSlice.js +12 -15
- package/README.md +4 -4
- package/Slice.js +3 -9
- package/index.d.ts +345 -471
- package/index.js +4 -4
- package/internal/engine.js +399 -0
- package/internal/merge.js +0 -9
- package/internal/tail.js +173 -90
- package/internal/utf8.js +0 -9
- package/package.json +2 -2
package/internal/tail.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ByteSize, Effect, Option } from "effect";
|
|
1
|
+
import { ByteSize, Effect, Option, Stream } from "effect";
|
|
2
2
|
|
|
3
3
|
//#region src/internal/tail.ts
|
|
4
4
|
/** UTF-8 BOM, as bytes. `U+FEFF` encodes to these three. */
|
|
@@ -10,134 +10,217 @@ const BOM = [
|
|
|
10
10
|
/** Byte value of `\n`. Cannot occur inside a UTF-8 multi-byte sequence. */
|
|
11
11
|
const LF = 10;
|
|
12
12
|
/**
|
|
13
|
-
* The
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* on the first read, small enough that reading it is cheap against a journal of
|
|
17
|
-
* any age. When it misses, {@link readTail} widens rather than failing.
|
|
13
|
+
* The first tail window. Large enough that a snapshot journal's last line is
|
|
14
|
+
* almost always inside it, small enough to be cheap against a journal of any
|
|
15
|
+
* age.
|
|
18
16
|
*
|
|
19
17
|
* @internal
|
|
20
18
|
*/
|
|
21
19
|
const DEFAULT_WINDOW = 8192;
|
|
22
|
-
/** Does the buffer begin with a UTF-8 BOM? */
|
|
23
|
-
const hasBom = (bytes) => bytes.length >= 3 && bytes[0] === BOM[0] && bytes[1] === BOM[1] && bytes[2] === BOM[2];
|
|
24
20
|
/**
|
|
25
|
-
*
|
|
21
|
+
* The largest window a single tail read allocates.
|
|
26
22
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* position was a real defect: a BOM'd journal larger than the window never has
|
|
30
|
-
* a window at offset 0, so every offset it emitted was physical — silently off
|
|
31
|
-
* by three against a file the package itself had described as post-BOM.
|
|
23
|
+
* A regression fence: the historical read once sized a "tail" window to its
|
|
24
|
+
* whole region. A caller needing more than this pages for it.
|
|
32
25
|
*
|
|
33
26
|
* @internal
|
|
34
27
|
*/
|
|
35
|
-
const
|
|
36
|
-
const head = yield* (yield* fs.open(path, { flag: "r" })).readAlloc(BOM.length);
|
|
37
|
-
const bytes = Option.getOrElse(head, () => /* @__PURE__ */ new Uint8Array(0));
|
|
38
|
-
return hasBom(bytes) ? BOM.length : 0;
|
|
39
|
-
}).pipe(Effect.scoped);
|
|
28
|
+
const MAX_WINDOW = 1048576;
|
|
40
29
|
/**
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* Three disciplines, each load-bearing:
|
|
30
|
+
* The forward page size of {@link readLinePages}.
|
|
44
31
|
*
|
|
45
|
-
*
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
32
|
+
* @internal
|
|
33
|
+
*/
|
|
34
|
+
const PAGE_SIZE = 65536;
|
|
35
|
+
const EMPTY = /* @__PURE__ */ new Uint8Array(0);
|
|
36
|
+
const decoder = new TextDecoder();
|
|
37
|
+
/** Join byte chunks with one allocation. */
|
|
38
|
+
const join = (chunks) => {
|
|
39
|
+
if (chunks.length === 1) return chunks[0];
|
|
40
|
+
let length = 0;
|
|
41
|
+
for (const chunk of chunks) length += chunk.length;
|
|
42
|
+
const joined = new Uint8Array(length);
|
|
43
|
+
let at = 0;
|
|
44
|
+
for (const chunk of chunks) {
|
|
45
|
+
joined.set(chunk, at);
|
|
46
|
+
at += chunk.length;
|
|
47
|
+
}
|
|
48
|
+
return joined;
|
|
49
|
+
};
|
|
50
|
+
/** Read `[from, to)` (physical) from an open handle. */
|
|
51
|
+
const readAt = (file, from, to) => Effect.gen(function* () {
|
|
52
|
+
yield* file.seek(BigInt(from), "start");
|
|
53
|
+
return Option.getOrElse(yield* file.readAlloc(to - from), () => EMPTY);
|
|
54
|
+
});
|
|
55
|
+
/**
|
|
56
|
+
* Probe the first three bytes of a file for a BOM.
|
|
57
57
|
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
* rewrite of that read — one that emits per window and can therefore honour a
|
|
62
|
-
* maximum — and is carried on spencerbeggs/effected#233, not added underneath
|
|
63
|
-
* it.
|
|
58
|
+
* A property of the FILE, read once from the start — never inferred from a
|
|
59
|
+
* window's position, which for a journal larger than the window never reaches
|
|
60
|
+
* offset 0.
|
|
64
61
|
*
|
|
65
62
|
* @internal
|
|
66
63
|
*/
|
|
67
|
-
const
|
|
68
|
-
const info = yield* fs.stat(path);
|
|
69
|
-
const physicalSize = ByteSize.toNumberUnsafe(info.size);
|
|
70
|
-
const logicalSize = physicalSize - bomBytes;
|
|
71
|
-
const from = Math.max(bomBytes, physicalSize - window);
|
|
64
|
+
const probeBomBytes = (fs, path) => Effect.gen(function* () {
|
|
72
65
|
const file = yield* fs.open(path, { flag: "r" });
|
|
73
|
-
yield* file
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
66
|
+
const head = yield* readAt(file, 0, BOM.length);
|
|
67
|
+
return head.length >= 3 && head[0] === BOM[0] && head[1] === BOM[1] && head[2] === BOM[2] ? BOM.length : 0;
|
|
68
|
+
}).pipe(Effect.scoped);
|
|
69
|
+
/**
|
|
70
|
+
* Read the window of at most {@link MAX_WINDOW} bytes ending at physical `end`,
|
|
71
|
+
* decoded from a line boundary.
|
|
72
|
+
*
|
|
73
|
+
* Unless the window reaches the start of the content, its leading partial line
|
|
74
|
+
* is discarded — through the first `\n`, which cannot occur inside a UTF-8
|
|
75
|
+
* multi-byte sequence, so the decode starts on a character boundary too. A BOM
|
|
76
|
+
* is excluded by starting the read past it, never by an implicit strip.
|
|
77
|
+
*/
|
|
78
|
+
const readWindow = (file, end, window, bomBytes) => Effect.gen(function* () {
|
|
79
|
+
const from = Math.max(bomBytes, end - Math.min(window, MAX_WINDOW));
|
|
80
|
+
const bytes = yield* readAt(file, from, end);
|
|
81
|
+
const atFileStart = from === bomBytes;
|
|
77
82
|
let cursor = 0;
|
|
78
|
-
if (!
|
|
83
|
+
if (!atFileStart) {
|
|
79
84
|
const newline = bytes.indexOf(LF);
|
|
80
85
|
cursor = newline === -1 ? bytes.length : newline + 1;
|
|
81
86
|
}
|
|
82
87
|
return {
|
|
83
|
-
text:
|
|
88
|
+
text: decoder.decode(bytes.subarray(cursor)),
|
|
84
89
|
start: from + cursor - bomBytes,
|
|
85
|
-
atFileStart
|
|
86
|
-
size: logicalSize
|
|
90
|
+
atFileStart
|
|
87
91
|
};
|
|
88
|
-
})
|
|
92
|
+
});
|
|
93
|
+
/**
|
|
94
|
+
* Read the one line that ends at physical `end`, however long it is, in
|
|
95
|
+
* clamped steps backward to the newline before it.
|
|
96
|
+
*
|
|
97
|
+
* The escape hatch for a window with no line boundary inside it: the line is
|
|
98
|
+
* longer than the window, and the clamp forbids asking for a bigger one.
|
|
99
|
+
*/
|
|
100
|
+
const readLineEnding = (file, end, bomBytes) => Effect.gen(function* () {
|
|
101
|
+
const chunks = [];
|
|
102
|
+
let position = end;
|
|
103
|
+
for (;;) {
|
|
104
|
+
const from = Math.max(bomBytes, position - MAX_WINDOW);
|
|
105
|
+
const bytes = yield* readAt(file, from, position);
|
|
106
|
+
const searchFrom = position === end ? bytes.length - 2 : bytes.length - 1;
|
|
107
|
+
const newline = searchFrom < 0 ? -1 : bytes.lastIndexOf(LF, searchFrom);
|
|
108
|
+
if (newline !== -1) {
|
|
109
|
+
chunks.unshift(bytes.subarray(newline + 1));
|
|
110
|
+
return {
|
|
111
|
+
text: decoder.decode(join(chunks)),
|
|
112
|
+
start: from + newline + 1 - bomBytes,
|
|
113
|
+
atFileStart: false
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
chunks.unshift(bytes);
|
|
117
|
+
if (from === bomBytes) return {
|
|
118
|
+
text: decoder.decode(join(chunks)),
|
|
119
|
+
start: 0,
|
|
120
|
+
atFileStart: true
|
|
121
|
+
};
|
|
122
|
+
position = from;
|
|
123
|
+
}
|
|
124
|
+
});
|
|
89
125
|
/**
|
|
90
|
-
*
|
|
91
|
-
* been seen.
|
|
126
|
+
* Search backward from the end of the journal, one window at a time, until
|
|
127
|
+
* `decode` finds something or the whole file has been seen.
|
|
92
128
|
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
129
|
+
* Each window covers only bytes no earlier one did — it ends at the line
|
|
130
|
+
* boundary where the previous one began — and the windows grow from
|
|
131
|
+
* `initialWindow` by fours up to {@link MAX_WINDOW}. `decode` sees each window
|
|
132
|
+
* on its own: "the last match in this window" is the last match in the file
|
|
133
|
+
* only because every later window has already answered none.
|
|
96
134
|
*
|
|
97
135
|
* @internal
|
|
98
136
|
*/
|
|
99
137
|
const readTailUntil = (fs, path, bomBytes, decode, initialWindow = DEFAULT_WINDOW) => Effect.gen(function* () {
|
|
138
|
+
const file = yield* fs.open(path, { flag: "r" });
|
|
139
|
+
let end = ByteSize.toNumberUnsafe((yield* file.stat).size);
|
|
100
140
|
let window = initialWindow;
|
|
101
141
|
for (;;) {
|
|
102
|
-
|
|
142
|
+
let tail = yield* readWindow(file, end, window, bomBytes);
|
|
143
|
+
if (tail.text === "" && !tail.atFileStart && tail.start + bomBytes === end) tail = yield* readLineEnding(file, end, bomBytes);
|
|
103
144
|
const found = decode(tail);
|
|
104
|
-
if (Option.isSome(found)) return found;
|
|
105
|
-
|
|
106
|
-
window
|
|
145
|
+
if (Option.isSome(found) || tail.atFileStart) return found;
|
|
146
|
+
end = tail.start + bomBytes;
|
|
147
|
+
window = Math.min(window * 4, MAX_WINDOW);
|
|
107
148
|
}
|
|
108
|
-
});
|
|
149
|
+
}).pipe(Effect.scoped);
|
|
109
150
|
/**
|
|
110
|
-
* Read
|
|
151
|
+
* Read the logical region `[from, end)` forward, in pages, as complete lines.
|
|
111
152
|
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
153
|
+
* Each pull reads at most `pageSize` bytes, emits every line that page
|
|
154
|
+
* completes, and carries the trailing unterminated fragment into the next pull
|
|
155
|
+
* — so a consumer that stops early never pays for the rest, and memory is a
|
|
156
|
+
* page plus the longest line. Pages are cut at `\n`, so each decodes on its
|
|
157
|
+
* own: a character a raw read split in two is still whole in the carry by the
|
|
158
|
+
* time its line is emitted.
|
|
114
159
|
*
|
|
115
|
-
* `
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
160
|
+
* `from` is a line cursor: at a line boundary it is exactly where reading
|
|
161
|
+
* starts; pointing INTO a line, the straddled line is skipped whole. That is why
|
|
162
|
+
* reading starts one byte early — a `from` at a line start hands the skip rule
|
|
163
|
+
* the previous line's terminator and consumes exactly that byte. Whatever
|
|
164
|
+
* remains at `end` without a terminator is emitted last.
|
|
165
|
+
*
|
|
166
|
+
* `end` is the caller's bound, sampled once: bytes appended past it are never
|
|
167
|
+
* read. A file that shrinks underneath ends the stream at what was readable.
|
|
121
168
|
*
|
|
122
169
|
* @internal
|
|
123
170
|
*/
|
|
124
|
-
const
|
|
125
|
-
if (
|
|
171
|
+
const readLinePages = (fs, path, from, end, bomBytes, pageSize = PAGE_SIZE) => Stream.unwrap(Effect.gen(function* () {
|
|
172
|
+
if (from >= end) return Stream.empty;
|
|
126
173
|
const file = yield* fs.open(path, { flag: "r" });
|
|
127
|
-
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
const
|
|
174
|
+
const stop = end + bomBytes;
|
|
175
|
+
const flush = (state) => state.skipping || state.carry.length === 0 ? [] : [{
|
|
176
|
+
text: decoder.decode(join(state.carry)),
|
|
177
|
+
start: state.carryStart
|
|
178
|
+
}];
|
|
179
|
+
return Stream.paginate({
|
|
180
|
+
position: from === 0 ? bomBytes : from + bomBytes - 1,
|
|
181
|
+
carry: [],
|
|
182
|
+
carryStart: from,
|
|
183
|
+
skipping: from > 0
|
|
184
|
+
}, (state) => Effect.gen(function* () {
|
|
185
|
+
const bytes = state.position >= stop ? EMPTY : yield* readAt(file, state.position, Math.min(state.position + pageSize, stop));
|
|
186
|
+
if (bytes.length === 0) return [flush(state), Option.none()];
|
|
187
|
+
const position = state.position + bytes.length;
|
|
188
|
+
let fresh = bytes;
|
|
189
|
+
let carryStart = state.carryStart;
|
|
190
|
+
if (state.skipping) {
|
|
191
|
+
const newline = bytes.indexOf(LF);
|
|
192
|
+
if (newline === -1) return [[], Option.some({
|
|
193
|
+
...state,
|
|
194
|
+
position
|
|
195
|
+
})];
|
|
196
|
+
fresh = bytes.subarray(newline + 1);
|
|
197
|
+
carryStart = state.position + newline + 1 - bomBytes;
|
|
198
|
+
}
|
|
199
|
+
const last = fresh.lastIndexOf(LF);
|
|
200
|
+
if (last === -1) {
|
|
201
|
+
const carry = fresh.length === 0 ? state.carry : [...state.carry, fresh];
|
|
202
|
+
return [[], Option.some({
|
|
203
|
+
position,
|
|
204
|
+
carry,
|
|
205
|
+
carryStart,
|
|
206
|
+
skipping: false
|
|
207
|
+
})];
|
|
208
|
+
}
|
|
209
|
+
const page = {
|
|
210
|
+
text: decoder.decode(join([...state.carry, fresh.subarray(0, last + 1)])),
|
|
211
|
+
start: carryStart
|
|
212
|
+
};
|
|
213
|
+
let carryLength = 0;
|
|
214
|
+
for (const chunk of state.carry) carryLength += chunk.length;
|
|
215
|
+
const rest = fresh.slice(last + 1);
|
|
216
|
+
return [[page], Option.some({
|
|
217
|
+
position,
|
|
218
|
+
carry: rest.length === 0 ? [] : [rest],
|
|
219
|
+
carryStart: carryStart + carryLength + last + 1,
|
|
220
|
+
skipping: false
|
|
221
|
+
})];
|
|
222
|
+
}));
|
|
223
|
+
}));
|
|
141
224
|
|
|
142
225
|
//#endregion
|
|
143
|
-
export { DEFAULT_WINDOW,
|
|
226
|
+
export { DEFAULT_WINDOW, MAX_WINDOW, PAGE_SIZE, probeBomBytes, readLinePages, readTailUntil };
|
package/internal/utf8.js
CHANGED
|
@@ -1,14 +1,5 @@
|
|
|
1
1
|
//#region src/internal/utf8.ts
|
|
2
2
|
/**
|
|
3
|
-
* UTF-8 byte accounting.
|
|
4
|
-
*
|
|
5
|
-
* A zero-dependency leaf so that both the line splitter and the error taxonomy
|
|
6
|
-
* can measure bytes without either importing the other — the same cycle
|
|
7
|
-
* firewall the other pure-tier packages use.
|
|
8
|
-
*
|
|
9
|
-
* @internal
|
|
10
|
-
*/
|
|
11
|
-
/**
|
|
12
3
|
* The UTF-8 byte length of a JavaScript string.
|
|
13
4
|
*
|
|
14
5
|
* Journal offsets are **bytes**, because that is what `FileSystem.stream`'s
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effected/jsonl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Append-only, schema-validated JSONL journals as a definable Effect service.",
|
|
6
6
|
"keywords": [
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"./package.json": "./package.json"
|
|
38
38
|
},
|
|
39
39
|
"peerDependencies": {
|
|
40
|
-
"effect": "4.0.0
|
|
40
|
+
"effect": "^4.0.0"
|
|
41
41
|
},
|
|
42
42
|
"engines": {
|
|
43
43
|
"node": ">=24.11.0"
|