@effected/jsonl 0.9.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 +83 -175
- package/Journal.js +42 -466
- package/JsonlError.js +14 -9
- package/JsonlEvent.js +1 -8
- package/Line.js +37 -135
- package/LineSlice.js +12 -10
- package/Slice.js +3 -9
- package/index.d.ts +333 -494
- package/index.js +4 -4
- package/internal/engine.js +399 -0
- package/internal/tail.js +173 -90
- package/package.json +1 -1
package/Line.js
CHANGED
|
@@ -1,50 +1,32 @@
|
|
|
1
|
-
import { LineSlice } from "./LineSlice.js";
|
|
2
1
|
import { MalformedLine } from "./JsonlError.js";
|
|
3
2
|
import { utf8Length } from "./internal/utf8.js";
|
|
4
|
-
import {
|
|
3
|
+
import { Result } from "effect";
|
|
5
4
|
|
|
6
5
|
//#region src/Line.ts
|
|
7
6
|
/**
|
|
8
|
-
*
|
|
7
|
+
* Whether a line carries nothing but whitespace.
|
|
9
8
|
*
|
|
10
|
-
*
|
|
11
|
-
* Validating `event`, `at`, `scope` and the registered payload schema is the
|
|
12
|
-
* envelope layer's job, and keeping the split means a malformed *envelope* and
|
|
13
|
-
* a malformed *line* stay distinguishable failures.
|
|
14
|
-
*
|
|
15
|
-
* @public
|
|
9
|
+
* @internal
|
|
16
10
|
*/
|
|
17
|
-
var ParsedLine = class extends Schema.Class("ParsedLine")({
|
|
18
|
-
/** Where this line lives in the source. */
|
|
19
|
-
line: LineSlice,
|
|
20
|
-
/** The parsed JSON value — any JSON value, not necessarily an object. */
|
|
21
|
-
value: Schema.Unknown
|
|
22
|
-
}) {};
|
|
23
|
-
/** Whether a line carries nothing but whitespace. */
|
|
24
11
|
const isBlank = (line) => line.text.trim() === "";
|
|
25
12
|
/**
|
|
26
|
-
* Splitting
|
|
13
|
+
* Splitting and parsing JSONL text.
|
|
27
14
|
*
|
|
28
|
-
* Every operation is total and synchronous:
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* in the returned `Result`, because a journal is untrusted input and untrusted
|
|
32
|
-
* input fails typed.
|
|
15
|
+
* Every operation is total and synchronous: nothing here throws. A `JSON.parse`
|
|
16
|
+
* failure becomes a {@link MalformedLine}, because a journal is untrusted input
|
|
17
|
+
* and untrusted input fails typed.
|
|
33
18
|
*
|
|
34
19
|
* @public
|
|
35
20
|
*/
|
|
36
|
-
var Line = class
|
|
21
|
+
var Line = class {
|
|
22
|
+
constructor() {}
|
|
37
23
|
/**
|
|
38
24
|
* The UTF-8 byte length of a string.
|
|
39
25
|
*
|
|
40
|
-
* Exposed because callers doing their own offset arithmetic must measure
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* An unpaired surrogate counts as the 3 bytes of U+FFFD, matching what a
|
|
45
|
-
* UTF-8 write actually emits. The offsets therefore stay true to the file
|
|
46
|
-
* even though the character itself cannot survive the round trip — text
|
|
47
|
-
* read back from a UTF-8 journal never contains one.
|
|
26
|
+
* Exposed because callers doing their own offset arithmetic must measure the
|
|
27
|
+
* same way this module does. `String.length` is a different number for any
|
|
28
|
+
* non-ASCII line. An unpaired surrogate counts as the 3 bytes of U+FFFD,
|
|
29
|
+
* matching what a UTF-8 write emits.
|
|
48
30
|
*
|
|
49
31
|
* @example
|
|
50
32
|
* ```ts
|
|
@@ -60,150 +42,70 @@ var Line = class Line {
|
|
|
60
42
|
/**
|
|
61
43
|
* Split text into candidate lines with byte-exact offsets.
|
|
62
44
|
*
|
|
63
|
-
* Lines are separated by `\n`; a `\r` immediately preceding it is
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
* a well-formed journal yields exactly as many slices as it has entries.
|
|
69
|
-
* Interior blank lines *are* returned — they are real bytes at real offsets,
|
|
70
|
-
* and hiding them would make the offsets lie.
|
|
71
|
-
*
|
|
72
|
-
* Only the final slice can have `terminated: false`.
|
|
45
|
+
* Lines are separated by `\n`; a `\r` immediately preceding it is part of the
|
|
46
|
+
* terminator, so CRLF journals split identically to LF ones. A bare `\r` is
|
|
47
|
+
* ordinary content. A trailing terminator does **not** produce a phantom
|
|
48
|
+
* empty final line; interior blank lines *are* returned, because they are
|
|
49
|
+
* real bytes at real offsets. Only the final slice can be unterminated.
|
|
73
50
|
*
|
|
74
51
|
* @param text - JSONL source text.
|
|
75
|
-
* @
|
|
52
|
+
* @param base - The byte offset `text` starts at in its file. Every offset
|
|
53
|
+
* returned is shifted by it, so text read from the middle of a journal
|
|
54
|
+
* yields offsets into the journal rather than into the text.
|
|
55
|
+
* @returns One {@link (LineSlice:type)} per candidate line, in source order.
|
|
76
56
|
*/
|
|
77
|
-
static split(text) {
|
|
57
|
+
static split(text, base = 0) {
|
|
78
58
|
const lines = [];
|
|
79
|
-
let offset =
|
|
59
|
+
let offset = base;
|
|
80
60
|
let start = 0;
|
|
81
61
|
for (;;) {
|
|
82
62
|
const newline = text.indexOf("\n", start);
|
|
83
63
|
if (newline === -1) break;
|
|
84
|
-
const
|
|
85
|
-
const
|
|
86
|
-
const content = hadCarriageReturn ? raw.slice(0, -1) : raw;
|
|
64
|
+
const carriageReturn = newline > start && text.charCodeAt(newline - 1) === 13;
|
|
65
|
+
const content = text.slice(start, carriageReturn ? newline - 1 : newline);
|
|
87
66
|
const length = utf8Length(content);
|
|
88
|
-
const end = offset + length + (
|
|
89
|
-
lines.push(
|
|
67
|
+
const end = offset + length + (carriageReturn ? 2 : 1);
|
|
68
|
+
lines.push({
|
|
90
69
|
offset,
|
|
91
70
|
end,
|
|
92
71
|
length,
|
|
93
72
|
text: content,
|
|
94
73
|
terminated: true
|
|
95
|
-
})
|
|
74
|
+
});
|
|
96
75
|
offset = end;
|
|
97
76
|
start = newline + 1;
|
|
98
77
|
}
|
|
99
78
|
if (start < text.length) {
|
|
100
79
|
const content = text.slice(start);
|
|
101
80
|
const length = utf8Length(content);
|
|
102
|
-
lines.push(
|
|
81
|
+
lines.push({
|
|
103
82
|
offset,
|
|
104
83
|
end: offset + length,
|
|
105
84
|
length,
|
|
106
85
|
text: content,
|
|
107
86
|
terminated: false
|
|
108
|
-
})
|
|
87
|
+
});
|
|
109
88
|
}
|
|
110
89
|
return lines;
|
|
111
90
|
}
|
|
112
91
|
/**
|
|
113
|
-
* The byte offset up to which this text has been fully consumed.
|
|
114
|
-
*
|
|
115
|
-
* This is the offset past the last **terminated** line. An unterminated
|
|
116
|
-
* final line is left unconsumed on purpose: it may be a writer caught
|
|
117
|
-
* mid-append, and re-reading from this offset once the file grows sees the
|
|
118
|
-
* completed line rather than gluing a stale fragment to fresh bytes.
|
|
119
|
-
*
|
|
120
|
-
* @param text - JSONL source text.
|
|
121
|
-
* @returns The resume cursor, in UTF-8 bytes.
|
|
122
|
-
*/
|
|
123
|
-
static consumedOffset(text) {
|
|
124
|
-
const last = Line.split(text).at(-1);
|
|
125
|
-
if (last === void 0) return 0;
|
|
126
|
-
return last.terminated ? last.end : last.offset;
|
|
127
|
-
}
|
|
128
|
-
/**
|
|
129
92
|
* Parse one candidate line's JSON.
|
|
130
93
|
*
|
|
131
|
-
* Any JSON value succeeds — objects, arrays and scalars alike.
|
|
132
|
-
* does not know what an envelope is.
|
|
94
|
+
* Any JSON value succeeds — objects, arrays and scalars alike.
|
|
133
95
|
*
|
|
134
96
|
* @param line - A slice from {@link Line.split}.
|
|
135
|
-
* @returns The
|
|
136
|
-
*
|
|
137
|
-
*
|
|
97
|
+
* @returns The parsed value, or a {@link MalformedLine} carrying the slice so
|
|
98
|
+
* the caller can locate the damage. An unterminated malformed line is a
|
|
99
|
+
* torn tail; a terminated one is a hole that will never heal.
|
|
138
100
|
*/
|
|
139
101
|
static parseResult(line) {
|
|
140
|
-
let value;
|
|
141
102
|
try {
|
|
142
|
-
|
|
103
|
+
return Result.succeed(JSON.parse(line.text));
|
|
143
104
|
} catch {
|
|
144
105
|
return Result.fail(new MalformedLine({ line }));
|
|
145
106
|
}
|
|
146
|
-
return Result.succeed(new ParsedLine({
|
|
147
|
-
line,
|
|
148
|
-
value
|
|
149
|
-
}));
|
|
150
|
-
}
|
|
151
|
-
/**
|
|
152
|
-
* Parse every non-blank line, reporting failures rather than dropping them.
|
|
153
|
-
*
|
|
154
|
-
* There is deliberately no "valid lines only" variant. A hole in the middle
|
|
155
|
-
* of a journal is information — it means a line was written that no reader
|
|
156
|
-
* can interpret — and whether that is tolerable is the caller's decision,
|
|
157
|
-
* not this module's. Filter the `Result`s yourself and the choice stays
|
|
158
|
-
* visible at the call site.
|
|
159
|
-
*
|
|
160
|
-
* Whitespace-only lines are skipped rather than reported: they carry no
|
|
161
|
-
* information to lose, and reporting them would make a hand-edited journal
|
|
162
|
-
* look corrupt.
|
|
163
|
-
*
|
|
164
|
-
* @param text - JSONL source text.
|
|
165
|
-
* @returns One `Result` per non-blank line, in source order.
|
|
166
|
-
*/
|
|
167
|
-
static parseAll(text) {
|
|
168
|
-
const results = [];
|
|
169
|
-
for (const line of Line.split(text)) if (!isBlank(line)) results.push(Line.parseResult(line));
|
|
170
|
-
return results;
|
|
171
|
-
}
|
|
172
|
-
/**
|
|
173
|
-
* Walk back from the end to the last line that parses.
|
|
174
|
-
*
|
|
175
|
-
* This is the whole read path for a snapshot-style journal, where the
|
|
176
|
-
* current state *is* the last valid line: a session killed mid-append leaves
|
|
177
|
-
* a partial final line, and the walk-back steps over it — and over any
|
|
178
|
-
* number of malformed or blank trailing lines — to the last line that means
|
|
179
|
-
* something.
|
|
180
|
-
*
|
|
181
|
-
* Parsing stops at the first success, so the cost is proportional to the
|
|
182
|
-
* damage at the tail rather than to the age of the file.
|
|
183
|
-
*
|
|
184
|
-
* **A torn tail is only detectable when its fragment fails to parse.**
|
|
185
|
-
* Every strict prefix of a JSON *object* is invalid JSON, so a truncated
|
|
186
|
-
* `{"event":"unlinked"}` is always caught. A truncated *scalar* is not:
|
|
187
|
-
* `42` cut mid-write leaves `4`, which parses cleanly as a different value.
|
|
188
|
-
* The JSON layer cannot close that hole — the envelope layer can, because
|
|
189
|
-
* `4` is not an envelope, which is one more reason the envelope is the
|
|
190
|
-
* package's contract rather than an option. Check `LineSlice.terminated` on
|
|
191
|
-
* the result when it matters.
|
|
192
|
-
*
|
|
193
|
-
* @param text - JSONL source text.
|
|
194
|
-
* @returns The last parseable line, or `Option.none()` if none parses.
|
|
195
|
-
*/
|
|
196
|
-
static lastValid(text) {
|
|
197
|
-
const lines = Line.split(text);
|
|
198
|
-
for (let index = lines.length - 1; index >= 0; index--) {
|
|
199
|
-
const line = lines[index];
|
|
200
|
-
if (line === void 0 || isBlank(line)) continue;
|
|
201
|
-
const parsed = Line.parseResult(line);
|
|
202
|
-
if (Result.isSuccess(parsed)) return Option.some(parsed.success);
|
|
203
|
-
}
|
|
204
|
-
return Option.none();
|
|
205
107
|
}
|
|
206
108
|
};
|
|
207
109
|
|
|
208
110
|
//#endregion
|
|
209
|
-
export { Line,
|
|
111
|
+
export { Line, isBlank };
|
package/LineSlice.js
CHANGED
|
@@ -5,16 +5,19 @@ import { Schema } from "effect";
|
|
|
5
5
|
* A single candidate line: its text, and where it lives in the source **in
|
|
6
6
|
* bytes**.
|
|
7
7
|
*
|
|
8
|
-
* Every offset
|
|
9
|
-
*
|
|
10
|
-
* `
|
|
11
|
-
*
|
|
12
|
-
* the single most likely bug in this module.
|
|
8
|
+
* Every offset here is a UTF-8 byte offset, never a UTF-16 code-unit index,
|
|
9
|
+
* because these values are cursors into a file that persist across process
|
|
10
|
+
* restarts. A `String.length`-derived offset is correct only for ASCII journals
|
|
11
|
+
* and is the single most likely bug in this module.
|
|
13
12
|
*
|
|
14
13
|
* The terminator is **not** part of the content: `text` and `length` exclude
|
|
15
14
|
* the trailing `\n`, and exclude the `\r` of a `\r\n` pair. `end` includes it,
|
|
16
15
|
* which is why `end - offset` is not always `length`.
|
|
17
16
|
*
|
|
17
|
+
* A plain record rather than a class: `Line.split` produces one per line of
|
|
18
|
+
* every read, and a class instance costs some forty times a plain object on
|
|
19
|
+
* that path. The schema exists so errors can carry the line structurally.
|
|
20
|
+
*
|
|
18
21
|
* @example
|
|
19
22
|
* ```ts
|
|
20
23
|
* import { Line } from "@effected/jsonl";
|
|
@@ -29,7 +32,7 @@ import { Schema } from "effect";
|
|
|
29
32
|
*
|
|
30
33
|
* @public
|
|
31
34
|
*/
|
|
32
|
-
|
|
35
|
+
const LineSlice = Schema.Struct({
|
|
33
36
|
/** UTF-8 byte offset of this line's first content byte. */
|
|
34
37
|
offset: Schema.Number,
|
|
35
38
|
/**
|
|
@@ -39,7 +42,7 @@ var LineSlice = class extends Schema.Class("LineSlice")({
|
|
|
39
42
|
* Equal to `offset + length` when the line is unterminated.
|
|
40
43
|
*/
|
|
41
44
|
end: Schema.Number,
|
|
42
|
-
/** UTF-8 byte length of `
|
|
45
|
+
/** UTF-8 byte length of `text`, excluding any terminator. */
|
|
43
46
|
length: Schema.Number,
|
|
44
47
|
/** The line's content, with its terminator and any paired `\r` removed. */
|
|
45
48
|
text: Schema.String,
|
|
@@ -47,11 +50,10 @@ var LineSlice = class extends Schema.Class("LineSlice")({
|
|
|
47
50
|
* Whether a `\n` terminated this line in the source.
|
|
48
51
|
*
|
|
49
52
|
* `false` can only occur on the final line, and means the line **may be a
|
|
50
|
-
* torn tail** — a writer caught mid-append.
|
|
51
|
-
* leaves its bytes unconsumed so the next read sees the completed line.
|
|
53
|
+
* torn tail** — a writer caught mid-append.
|
|
52
54
|
*/
|
|
53
55
|
terminated: Schema.Boolean
|
|
54
|
-
})
|
|
56
|
+
});
|
|
55
57
|
|
|
56
58
|
//#endregion
|
|
57
59
|
export { LineSlice };
|
package/Slice.js
CHANGED
|
@@ -1,22 +1,16 @@
|
|
|
1
1
|
//#region src/Slice.ts
|
|
2
2
|
/**
|
|
3
|
-
* Whether
|
|
4
|
-
*
|
|
5
|
-
* Takes the **frame**, never a decoded envelope — that is what keeps matching
|
|
6
|
-
* ahead of the payload schema on the read path.
|
|
3
|
+
* Whether frame fields satisfy a slice.
|
|
7
4
|
*
|
|
8
5
|
* @internal
|
|
9
6
|
*/
|
|
10
7
|
const matchesFrame = (frame, slice) => {
|
|
11
8
|
if (slice === void 0) return true;
|
|
12
9
|
if (slice.events !== void 0 && !slice.events.includes(frame.event)) return false;
|
|
13
|
-
if (slice.scopes !== void 0)
|
|
14
|
-
if (frame.scope === void 0 || !slice.scopes.includes(frame.scope)) return false;
|
|
15
|
-
}
|
|
10
|
+
if (slice.scopes !== void 0 && (frame.scope === void 0 || !slice.scopes.includes(frame.scope))) return false;
|
|
16
11
|
const millis = frame.at.epochMilliseconds;
|
|
17
12
|
if (slice.from !== void 0 && millis < slice.from.epochMilliseconds) return false;
|
|
18
|
-
|
|
19
|
-
return true;
|
|
13
|
+
return slice.to === void 0 || millis < slice.to.epochMilliseconds;
|
|
20
14
|
};
|
|
21
15
|
|
|
22
16
|
//#endregion
|