@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/Line.js
CHANGED
|
@@ -1,60 +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
|
-
* a `PreToolUse` hook script reads the current state of a journal with one
|
|
12
|
-
* call. That is a contract, not a convenience: a hook that had to construct an
|
|
13
|
-
* Effect runtime to read one line would not adopt this package.
|
|
14
|
-
*
|
|
15
|
-
* @since 0.1.0
|
|
16
|
-
*/
|
|
17
|
-
/**
|
|
18
|
-
* A line that parsed as JSON, paired with the slice it came from.
|
|
19
|
-
*
|
|
20
|
-
* `value` is deliberately `unknown`: this layer knows JSON, not envelopes.
|
|
21
|
-
* Validating `event`, `at`, `scope` and the registered payload schema is the
|
|
22
|
-
* envelope layer's job, and keeping the split means a malformed *envelope* and
|
|
23
|
-
* a malformed *line* stay distinguishable failures.
|
|
24
|
-
*
|
|
25
|
-
* @public
|
|
9
|
+
* @internal
|
|
26
10
|
*/
|
|
27
|
-
var ParsedLine = class extends Schema.Class("ParsedLine")({
|
|
28
|
-
/** Where this line lives in the source. */
|
|
29
|
-
line: LineSlice,
|
|
30
|
-
/** The parsed JSON value — any JSON value, not necessarily an object. */
|
|
31
|
-
value: Schema.Unknown
|
|
32
|
-
}) {};
|
|
33
|
-
/** Whether a line carries nothing but whitespace. */
|
|
34
11
|
const isBlank = (line) => line.text.trim() === "";
|
|
35
12
|
/**
|
|
36
|
-
* Splitting
|
|
13
|
+
* Splitting and parsing JSONL text.
|
|
37
14
|
*
|
|
38
|
-
* Every operation is total and synchronous:
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* in the returned `Result`, because a journal is untrusted input and untrusted
|
|
42
|
-
* 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.
|
|
43
18
|
*
|
|
44
19
|
* @public
|
|
45
20
|
*/
|
|
46
|
-
var Line = class
|
|
21
|
+
var Line = class {
|
|
22
|
+
constructor() {}
|
|
47
23
|
/**
|
|
48
24
|
* The UTF-8 byte length of a string.
|
|
49
25
|
*
|
|
50
|
-
* Exposed because callers doing their own offset arithmetic must measure
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
* An unpaired surrogate counts as the 3 bytes of U+FFFD, matching what a
|
|
55
|
-
* UTF-8 write actually emits. The offsets therefore stay true to the file
|
|
56
|
-
* even though the character itself cannot survive the round trip — text
|
|
57
|
-
* 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.
|
|
58
30
|
*
|
|
59
31
|
* @example
|
|
60
32
|
* ```ts
|
|
@@ -70,150 +42,70 @@ var Line = class Line {
|
|
|
70
42
|
/**
|
|
71
43
|
* Split text into candidate lines with byte-exact offsets.
|
|
72
44
|
*
|
|
73
|
-
* Lines are separated by `\n`; a `\r` immediately preceding it is
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
* a well-formed journal yields exactly as many slices as it has entries.
|
|
79
|
-
* Interior blank lines *are* returned — they are real bytes at real offsets,
|
|
80
|
-
* and hiding them would make the offsets lie.
|
|
81
|
-
*
|
|
82
|
-
* 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.
|
|
83
50
|
*
|
|
84
51
|
* @param text - JSONL source text.
|
|
85
|
-
* @
|
|
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.
|
|
86
56
|
*/
|
|
87
|
-
static split(text) {
|
|
57
|
+
static split(text, base = 0) {
|
|
88
58
|
const lines = [];
|
|
89
|
-
let offset =
|
|
59
|
+
let offset = base;
|
|
90
60
|
let start = 0;
|
|
91
61
|
for (;;) {
|
|
92
62
|
const newline = text.indexOf("\n", start);
|
|
93
63
|
if (newline === -1) break;
|
|
94
|
-
const
|
|
95
|
-
const
|
|
96
|
-
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);
|
|
97
66
|
const length = utf8Length(content);
|
|
98
|
-
const end = offset + length + (
|
|
99
|
-
lines.push(
|
|
67
|
+
const end = offset + length + (carriageReturn ? 2 : 1);
|
|
68
|
+
lines.push({
|
|
100
69
|
offset,
|
|
101
70
|
end,
|
|
102
71
|
length,
|
|
103
72
|
text: content,
|
|
104
73
|
terminated: true
|
|
105
|
-
})
|
|
74
|
+
});
|
|
106
75
|
offset = end;
|
|
107
76
|
start = newline + 1;
|
|
108
77
|
}
|
|
109
78
|
if (start < text.length) {
|
|
110
79
|
const content = text.slice(start);
|
|
111
80
|
const length = utf8Length(content);
|
|
112
|
-
lines.push(
|
|
81
|
+
lines.push({
|
|
113
82
|
offset,
|
|
114
83
|
end: offset + length,
|
|
115
84
|
length,
|
|
116
85
|
text: content,
|
|
117
86
|
terminated: false
|
|
118
|
-
})
|
|
87
|
+
});
|
|
119
88
|
}
|
|
120
89
|
return lines;
|
|
121
90
|
}
|
|
122
91
|
/**
|
|
123
|
-
* The byte offset up to which this text has been fully consumed.
|
|
124
|
-
*
|
|
125
|
-
* This is the offset past the last **terminated** line. An unterminated
|
|
126
|
-
* final line is left unconsumed on purpose: it may be a writer caught
|
|
127
|
-
* mid-append, and re-reading from this offset once the file grows sees the
|
|
128
|
-
* completed line rather than gluing a stale fragment to fresh bytes.
|
|
129
|
-
*
|
|
130
|
-
* @param text - JSONL source text.
|
|
131
|
-
* @returns The resume cursor, in UTF-8 bytes.
|
|
132
|
-
*/
|
|
133
|
-
static consumedOffset(text) {
|
|
134
|
-
const last = Line.split(text).at(-1);
|
|
135
|
-
if (last === void 0) return 0;
|
|
136
|
-
return last.terminated ? last.end : last.offset;
|
|
137
|
-
}
|
|
138
|
-
/**
|
|
139
92
|
* Parse one candidate line's JSON.
|
|
140
93
|
*
|
|
141
|
-
* Any JSON value succeeds — objects, arrays and scalars alike.
|
|
142
|
-
* does not know what an envelope is.
|
|
94
|
+
* Any JSON value succeeds — objects, arrays and scalars alike.
|
|
143
95
|
*
|
|
144
96
|
* @param line - A slice from {@link Line.split}.
|
|
145
|
-
* @returns The
|
|
146
|
-
*
|
|
147
|
-
*
|
|
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.
|
|
148
100
|
*/
|
|
149
101
|
static parseResult(line) {
|
|
150
|
-
let value;
|
|
151
102
|
try {
|
|
152
|
-
|
|
103
|
+
return Result.succeed(JSON.parse(line.text));
|
|
153
104
|
} catch {
|
|
154
105
|
return Result.fail(new MalformedLine({ line }));
|
|
155
106
|
}
|
|
156
|
-
return Result.succeed(new ParsedLine({
|
|
157
|
-
line,
|
|
158
|
-
value
|
|
159
|
-
}));
|
|
160
|
-
}
|
|
161
|
-
/**
|
|
162
|
-
* Parse every non-blank line, reporting failures rather than dropping them.
|
|
163
|
-
*
|
|
164
|
-
* There is deliberately no "valid lines only" variant. A hole in the middle
|
|
165
|
-
* of a journal is information — it means a line was written that no reader
|
|
166
|
-
* can interpret — and whether that is tolerable is the caller's decision,
|
|
167
|
-
* not this module's. Filter the `Result`s yourself and the choice stays
|
|
168
|
-
* visible at the call site.
|
|
169
|
-
*
|
|
170
|
-
* Whitespace-only lines are skipped rather than reported: they carry no
|
|
171
|
-
* information to lose, and reporting them would make a hand-edited journal
|
|
172
|
-
* look corrupt.
|
|
173
|
-
*
|
|
174
|
-
* @param text - JSONL source text.
|
|
175
|
-
* @returns One `Result` per non-blank line, in source order.
|
|
176
|
-
*/
|
|
177
|
-
static parseAll(text) {
|
|
178
|
-
const results = [];
|
|
179
|
-
for (const line of Line.split(text)) if (!isBlank(line)) results.push(Line.parseResult(line));
|
|
180
|
-
return results;
|
|
181
|
-
}
|
|
182
|
-
/**
|
|
183
|
-
* Walk back from the end to the last line that parses.
|
|
184
|
-
*
|
|
185
|
-
* This is the whole read path for a snapshot-style journal, where the
|
|
186
|
-
* current state *is* the last valid line: a session killed mid-append leaves
|
|
187
|
-
* a partial final line, and the walk-back steps over it — and over any
|
|
188
|
-
* number of malformed or blank trailing lines — to the last line that means
|
|
189
|
-
* something.
|
|
190
|
-
*
|
|
191
|
-
* Parsing stops at the first success, so the cost is proportional to the
|
|
192
|
-
* damage at the tail rather than to the age of the file.
|
|
193
|
-
*
|
|
194
|
-
* **A torn tail is only detectable when its fragment fails to parse.**
|
|
195
|
-
* Every strict prefix of a JSON *object* is invalid JSON, so a truncated
|
|
196
|
-
* `{"event":"unlinked"}` is always caught. A truncated *scalar* is not:
|
|
197
|
-
* `42` cut mid-write leaves `4`, which parses cleanly as a different value.
|
|
198
|
-
* The JSON layer cannot close that hole — the envelope layer can, because
|
|
199
|
-
* `4` is not an envelope, which is one more reason the envelope is the
|
|
200
|
-
* package's contract rather than an option. Check `LineSlice.terminated` on
|
|
201
|
-
* the result when it matters.
|
|
202
|
-
*
|
|
203
|
-
* @param text - JSONL source text.
|
|
204
|
-
* @returns The last parseable line, or `Option.none()` if none parses.
|
|
205
|
-
*/
|
|
206
|
-
static lastValid(text) {
|
|
207
|
-
const lines = Line.split(text);
|
|
208
|
-
for (let index = lines.length - 1; index >= 0; index--) {
|
|
209
|
-
const line = lines[index];
|
|
210
|
-
if (line === void 0 || isBlank(line)) continue;
|
|
211
|
-
const parsed = Line.parseResult(line);
|
|
212
|
-
if (Result.isSuccess(parsed)) return Option.some(parsed.success);
|
|
213
|
-
}
|
|
214
|
-
return Option.none();
|
|
215
107
|
}
|
|
216
108
|
};
|
|
217
109
|
|
|
218
110
|
//#endregion
|
|
219
|
-
export { Line,
|
|
111
|
+
export { Line, isBlank };
|
package/LineSlice.js
CHANGED
|
@@ -2,24 +2,22 @@ import { Schema } from "effect";
|
|
|
2
2
|
|
|
3
3
|
//#region src/LineSlice.ts
|
|
4
4
|
/**
|
|
5
|
-
* One candidate line of a JSONL journal, located in the source by byte.
|
|
6
|
-
*
|
|
7
|
-
* @since 0.1.0
|
|
8
|
-
*/
|
|
9
|
-
/**
|
|
10
5
|
* A single candidate line: its text, and where it lives in the source **in
|
|
11
6
|
* bytes**.
|
|
12
7
|
*
|
|
13
|
-
* Every offset
|
|
14
|
-
*
|
|
15
|
-
* `
|
|
16
|
-
*
|
|
17
|
-
* 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.
|
|
18
12
|
*
|
|
19
13
|
* The terminator is **not** part of the content: `text` and `length` exclude
|
|
20
14
|
* the trailing `\n`, and exclude the `\r` of a `\r\n` pair. `end` includes it,
|
|
21
15
|
* which is why `end - offset` is not always `length`.
|
|
22
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
|
+
*
|
|
23
21
|
* @example
|
|
24
22
|
* ```ts
|
|
25
23
|
* import { Line } from "@effected/jsonl";
|
|
@@ -34,7 +32,7 @@ import { Schema } from "effect";
|
|
|
34
32
|
*
|
|
35
33
|
* @public
|
|
36
34
|
*/
|
|
37
|
-
|
|
35
|
+
const LineSlice = Schema.Struct({
|
|
38
36
|
/** UTF-8 byte offset of this line's first content byte. */
|
|
39
37
|
offset: Schema.Number,
|
|
40
38
|
/**
|
|
@@ -44,7 +42,7 @@ var LineSlice = class extends Schema.Class("LineSlice")({
|
|
|
44
42
|
* Equal to `offset + length` when the line is unterminated.
|
|
45
43
|
*/
|
|
46
44
|
end: Schema.Number,
|
|
47
|
-
/** UTF-8 byte length of `
|
|
45
|
+
/** UTF-8 byte length of `text`, excluding any terminator. */
|
|
48
46
|
length: Schema.Number,
|
|
49
47
|
/** The line's content, with its terminator and any paired `\r` removed. */
|
|
50
48
|
text: Schema.String,
|
|
@@ -52,11 +50,10 @@ var LineSlice = class extends Schema.Class("LineSlice")({
|
|
|
52
50
|
* Whether a `\n` terminated this line in the source.
|
|
53
51
|
*
|
|
54
52
|
* `false` can only occur on the final line, and means the line **may be a
|
|
55
|
-
* torn tail** — a writer caught mid-append.
|
|
56
|
-
* leaves its bytes unconsumed so the next read sees the completed line.
|
|
53
|
+
* torn tail** — a writer caught mid-append.
|
|
57
54
|
*/
|
|
58
55
|
terminated: Schema.Boolean
|
|
59
|
-
})
|
|
56
|
+
});
|
|
60
57
|
|
|
61
58
|
//#endregion
|
|
62
59
|
export { LineSlice };
|
package/README.md
CHANGED
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
|
|
8
8
|
Append-only, schema-validated JSONL journals as a definable Effect service. The subject is not the format — one JSON value per line is a two-sentence specification and needs no library — but the file as a live object: a journal that only ever grows, whose current state is its last valid line, whose tail may be torn mid-append, and which several processes read while one writes. Every line is an envelope (`at`, `event`, an optional `scope`, and a `data` payload validated by the schema you registered for that event), so a journal stays greppable, `jq`-able and diffable in a pull request while the reads that matter stay typed.
|
|
9
9
|
|
|
10
|
-
> **Pre
|
|
11
|
-
>
|
|
12
|
-
> `1.0.0`
|
|
13
|
-
>
|
|
10
|
+
> **Pre-`1.0.0`.** This package is part of the `@effected/*` kit, built on stable
|
|
11
|
+
> Effect v4 (`effect` `^4.0.0`) and still in `0.x` development. Stable Effect
|
|
12
|
+
> makes a kit `1.0.0` possible, not automatic. To keep your `effect` and
|
|
13
|
+
> `@effect/*` versions on the line the kit is built and tested against, install
|
|
14
14
|
> [`@effected/pnpm-plugin-effect`](https://www.npmjs.com/package/@effected/pnpm-plugin-effect).
|
|
15
15
|
>
|
|
16
16
|
> **Stability: unstable.** This package's API surface is not yet considered
|
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
|