@effected/jsonl 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/Envelope.js +267 -0
- package/Journal.js +475 -0
- package/JsonlError.js +246 -0
- package/JsonlEvent.js +45 -0
- package/LICENSE +21 -0
- package/Line.js +219 -0
- package/LineSlice.js +62 -0
- package/README.md +181 -0
- package/Slice.js +23 -0
- package/index.d.ts +1032 -0
- package/index.js +8 -0
- package/internal/merge.js +112 -0
- package/internal/tail.js +143 -0
- package/internal/utf8.js +43 -0
- package/package.json +45 -0
- package/tsdoc-metadata.json +11 -0
package/JsonlError.js
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { LineSlice } from "./LineSlice.js";
|
|
2
|
+
import { Option, Schema, SchemaError } from "effect";
|
|
3
|
+
|
|
4
|
+
//#region src/JsonlError.ts
|
|
5
|
+
/**
|
|
6
|
+
* The `@effected/jsonl` error taxonomy.
|
|
7
|
+
*
|
|
8
|
+
* Every tag names a distinct recovery a caller would actually make, and every
|
|
9
|
+
* cause is carried structurally rather than stringified. Core's `PlatformError`
|
|
10
|
+
* passes through untranslated rather than being wrapped.
|
|
11
|
+
*
|
|
12
|
+
* @since 0.1.0
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* `Schema.SchemaError` as a schema of itself.
|
|
16
|
+
*
|
|
17
|
+
* A schema issue tree is a live object graph, not something with a wire form,
|
|
18
|
+
* so it is declared by its type guard rather than given an encoding. That is
|
|
19
|
+
* what lets {@link InvalidData} be an ordinary schema-backed tagged error while
|
|
20
|
+
* still carrying the failure **structurally** — `error.issue` keeps its paths
|
|
21
|
+
* and expected types — instead of flattening it to a string at the boundary.
|
|
22
|
+
*/
|
|
23
|
+
const SchemaErrorFromSelf = Schema.declare(SchemaError.isSchemaError);
|
|
24
|
+
/**
|
|
25
|
+
* A journal line that is not valid JSON.
|
|
26
|
+
*
|
|
27
|
+
* **This is the expected steady state at the tail of a live journal**, not
|
|
28
|
+
* necessarily corruption: a writer caught mid-`write` leaves a partial final
|
|
29
|
+
* line, and `LineSlice.terminated` is what distinguishes the two cases. An
|
|
30
|
+
* unterminated malformed line is a torn tail that the next append completes; a
|
|
31
|
+
* *terminated* malformed line is a hole in the history that will never heal.
|
|
32
|
+
*
|
|
33
|
+
* Malformed input always fails through this typed channel — never as a defect,
|
|
34
|
+
* and never by being silently dropped from a read.
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* ```ts
|
|
38
|
+
* import { Line } from "@effected/jsonl";
|
|
39
|
+
* import { Result } from "effect";
|
|
40
|
+
*
|
|
41
|
+
* const [line] = Line.split('{"a":');
|
|
42
|
+
* const parsed = Line.parseResult(line!);
|
|
43
|
+
* if (Result.isFailure(parsed)) {
|
|
44
|
+
* parsed.failure.line.terminated; // false — a torn tail, not a hole
|
|
45
|
+
* }
|
|
46
|
+
* ```
|
|
47
|
+
*
|
|
48
|
+
* @public
|
|
49
|
+
*/
|
|
50
|
+
var MalformedLine = class extends Schema.TaggedErrorClass()("MalformedLine", {
|
|
51
|
+
/** The offending line, with its byte offsets into the source. */
|
|
52
|
+
line: LineSlice }) {
|
|
53
|
+
get message() {
|
|
54
|
+
return `JSONL ${this.line.terminated ? "malformed line" : "unterminated final line"} at byte offset ${this.line.offset}`;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* A line whose `event` tag is not in the registry.
|
|
59
|
+
*
|
|
60
|
+
* Typed rather than a defect on purpose: a journal written by an older or newer
|
|
61
|
+
* version of the same application is hostile input in the technical sense, and
|
|
62
|
+
* a reader that crashed on an unrecognized tag would be unable to skip forward
|
|
63
|
+
* past one. The known tags travel with the error so a caller can report the
|
|
64
|
+
* mismatch without reaching back for the registry.
|
|
65
|
+
*
|
|
66
|
+
* @public
|
|
67
|
+
*/
|
|
68
|
+
var UnknownEvent = class extends Schema.TaggedErrorClass()("UnknownEvent", {
|
|
69
|
+
/** The offending line, with its byte offsets into the source. */
|
|
70
|
+
line: LineSlice,
|
|
71
|
+
/** The unrecognized tag as it appeared on the envelope. */
|
|
72
|
+
event: Schema.String,
|
|
73
|
+
/** The tags this journal's registry does define. */
|
|
74
|
+
known: Schema.Array(Schema.String)
|
|
75
|
+
}) {
|
|
76
|
+
get message() {
|
|
77
|
+
return `unknown JSONL event ${JSON.stringify(this.event)} at byte offset ${this.line.offset}`;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* A line whose envelope or payload failed schema validation.
|
|
82
|
+
*
|
|
83
|
+
* Covers both stages of the two-stage decode, distinguished by `event`: the
|
|
84
|
+
* frame itself (`Option.none()` — the line is JSON but not an envelope) and a
|
|
85
|
+
* registered payload (`Option.some(tag)` — the envelope is well-formed but its
|
|
86
|
+
* `data` does not match the schema registered for that tag).
|
|
87
|
+
*
|
|
88
|
+
* The `SchemaError` is carried **whole**, so `error.issue` is the full issue
|
|
89
|
+
* tree with its paths and expected types intact. Nothing here is stringified;
|
|
90
|
+
* `message` renders lazily and only when something asks for it.
|
|
91
|
+
*
|
|
92
|
+
* @public
|
|
93
|
+
*/
|
|
94
|
+
var InvalidData = class extends Schema.TaggedErrorClass()("InvalidData", {
|
|
95
|
+
/** The offending line, with its byte offsets into the source. */
|
|
96
|
+
line: LineSlice,
|
|
97
|
+
/**
|
|
98
|
+
* The event tag whose payload schema rejected the data, or `none` when it
|
|
99
|
+
* was the envelope frame itself that failed.
|
|
100
|
+
*/
|
|
101
|
+
event: Schema.Option(Schema.String),
|
|
102
|
+
/** The schema failure, carried structurally — `issue` is the full tree. */
|
|
103
|
+
error: SchemaErrorFromSelf
|
|
104
|
+
}) {
|
|
105
|
+
get message() {
|
|
106
|
+
return `invalid JSONL ${Option.isSome(this.event) ? `payload for event ${JSON.stringify(this.event.value)}` : "envelope"} at byte offset ${this.line.offset}: ${this.error.message}`;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
/**
|
|
110
|
+
* An append attempted after a terminal event, by an event not marked `reopen`.
|
|
111
|
+
*
|
|
112
|
+
* A journal whose tail is terminal is quiescent: it is finished, and appending
|
|
113
|
+
* to it would silently resurrect a closed loop. Reopening is legal but must be
|
|
114
|
+
* declared, which is what `reopen` marks.
|
|
115
|
+
*
|
|
116
|
+
* @public
|
|
117
|
+
*/
|
|
118
|
+
var TerminalViolation = class extends Schema.TaggedErrorClass()("TerminalViolation", {
|
|
119
|
+
/** The tag of the event whose append was refused. */
|
|
120
|
+
event: Schema.String,
|
|
121
|
+
/** The terminal event currently at the tail of the journal. */
|
|
122
|
+
terminal: Schema.String
|
|
123
|
+
}) {
|
|
124
|
+
get message() {
|
|
125
|
+
return `cannot append ${JSON.stringify(this.event)}: the journal is terminal at ${JSON.stringify(this.terminal)}`;
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
/**
|
|
129
|
+
* An operation against a journal file that does not exist.
|
|
130
|
+
*
|
|
131
|
+
* A missing journal is a **legal state** — building the layer over a path that
|
|
132
|
+
* does not exist yet succeeds, and the watcher activates once the file appears.
|
|
133
|
+
* What is not legal is materializing it implicitly: `append`, `query` and
|
|
134
|
+
* `latest` fail with this rather than creating the file, so a typo in a path
|
|
135
|
+
* cannot quietly produce a second, empty journal that looks like a working
|
|
136
|
+
* system with no history. Creation is always explicit, via `create`.
|
|
137
|
+
*
|
|
138
|
+
* @public
|
|
139
|
+
*/
|
|
140
|
+
var JournalNotFound = class extends Schema.TaggedErrorClass()("JournalNotFound", {
|
|
141
|
+
/** The path that does not exist. */
|
|
142
|
+
path: Schema.String }) {
|
|
143
|
+
get message() {
|
|
144
|
+
return `journal not found: ${this.path}`;
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
/**
|
|
148
|
+
* A payload that validated against its schema but cannot be serialized to JSON.
|
|
149
|
+
*
|
|
150
|
+
* The two reachable causes are a `bigint` anywhere in the encoded value and a
|
|
151
|
+
* reference cycle; `JSON.stringify` throws a `TypeError` for both. A payload
|
|
152
|
+
* schema is free to permit either — `Schema.Unknown` permits everything — so
|
|
153
|
+
* schema validity does not imply serializability and the encode path has to
|
|
154
|
+
* treat them as separate questions.
|
|
155
|
+
*
|
|
156
|
+
* This is its own tag rather than a flavour of {@link InvalidData} because the
|
|
157
|
+
* recovery differs: `InvalidData` means the value is wrong and the caller
|
|
158
|
+
* should fix the value, while this means the value is fine but unrepresentable
|
|
159
|
+
* in this format and the caller must change the *shape* they are journaling.
|
|
160
|
+
*
|
|
161
|
+
* The thrown value is carried as-is under `cause` — never stringified, and
|
|
162
|
+
* never re-thrown. A raw `TypeError` escaping the encode path would be
|
|
163
|
+
* especially bad here: `Effect.fromResult` evaluates its argument eagerly, so
|
|
164
|
+
* an unguarded throw happens at **construction** of the `Effect`, before any
|
|
165
|
+
* fiber exists, where not even `Effect.runSyncExit` can catch it.
|
|
166
|
+
*
|
|
167
|
+
* @public
|
|
168
|
+
*/
|
|
169
|
+
var UnserializableData = class extends Schema.TaggedErrorClass()("UnserializableData", {
|
|
170
|
+
/** The event tag whose payload could not be serialized. */
|
|
171
|
+
event: Schema.String,
|
|
172
|
+
/** The value `JSON.stringify` threw, carried structurally. */
|
|
173
|
+
cause: Schema.Unknown
|
|
174
|
+
}) {
|
|
175
|
+
get message() {
|
|
176
|
+
const detail = this.cause instanceof Error ? this.cause.message : "value is not JSON-serializable";
|
|
177
|
+
return `cannot serialize payload for event ${JSON.stringify(this.event)}: ${detail}`;
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
/**
|
|
181
|
+
* An append refused because the journal's scope has closed.
|
|
182
|
+
*
|
|
183
|
+
* Its own tag rather than a flavour of {@link TerminalViolation}, because the
|
|
184
|
+
* recoveries have nothing in common: a terminal journal is a *state* the
|
|
185
|
+
* consumer can reason about and reopen from with a `reopen` event, while a
|
|
186
|
+
* closed one is a *lifecycle* fact — this service is gone, and the only moves
|
|
187
|
+
* are to build a new layer or stop.
|
|
188
|
+
*
|
|
189
|
+
* Refusal is deliberately a typed failure rather than a wait. A `Latch` would
|
|
190
|
+
* suspend the late append with no failure channel, turning "the journal is
|
|
191
|
+
* closing" into a hang; failing fast is what lets a caller react.
|
|
192
|
+
*
|
|
193
|
+
* @public
|
|
194
|
+
*/
|
|
195
|
+
var JournalClosed = class extends Schema.TaggedErrorClass()("JournalClosed", {
|
|
196
|
+
/** The tag of the event whose append was refused. */
|
|
197
|
+
event: Schema.String }) {
|
|
198
|
+
get message() {
|
|
199
|
+
return `cannot append ${JSON.stringify(this.event)}: the journal is closed`;
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
/**
|
|
203
|
+
* The journal file was truncated or replaced beneath a reader.
|
|
204
|
+
*
|
|
205
|
+
* The cooperative-writer contract is append-only: a journal only ever grows,
|
|
206
|
+
* and every cursor this package hands out depends on that. When the file shrinks
|
|
207
|
+
* below a tracked offset, or the path comes to name a different file entirely,
|
|
208
|
+
* the contract has been broken by something outside the package and every
|
|
209
|
+
* offset-derived belief is now meaningless.
|
|
210
|
+
*
|
|
211
|
+
* Surfaced rather than repaired, deliberately. Silently re-reading from zero
|
|
212
|
+
* would paper over a real operational fault — a rotating log shipper, a
|
|
213
|
+
* `>` where `>>` was meant — and leave projections quietly inconsistent with
|
|
214
|
+
* the file. The recovery is the consumer's: discard cursor-derived state,
|
|
215
|
+
* re-read, and tell somebody.
|
|
216
|
+
*
|
|
217
|
+
* One tag rather than two, though `reason` distinguishes the causes: truncation
|
|
218
|
+
* and replacement have the **same** recovery, and a tag per cause would split
|
|
219
|
+
* one recovery across two tags.
|
|
220
|
+
*
|
|
221
|
+
* @remarks
|
|
222
|
+
* Detection is as complete as the platform allows and no more. Truncation is
|
|
223
|
+
* caught by size; replacement is caught by inode identity, which
|
|
224
|
+
* `FileSystem.File.Info` exposes as an **`Option`** — on a platform that does
|
|
225
|
+
* not report it, a replacement at equal or greater size is undetectable and
|
|
226
|
+
* only truncation is caught.
|
|
227
|
+
*
|
|
228
|
+
* @public
|
|
229
|
+
*/
|
|
230
|
+
var JournalResync = class extends Schema.TaggedErrorClass()("JournalResync", {
|
|
231
|
+
/** The journal path whose file changed identity or shrank. */
|
|
232
|
+
path: Schema.String,
|
|
233
|
+
/** Which contract breach was detected. Diagnostic; the recovery is the same. */
|
|
234
|
+
reason: Schema.Literals(["truncated", "replaced"]),
|
|
235
|
+
/** The logical offset the reader had consumed to. */
|
|
236
|
+
expected: Schema.Number,
|
|
237
|
+
/** The file's logical size when the breach was noticed. */
|
|
238
|
+
actual: Schema.Number
|
|
239
|
+
}) {
|
|
240
|
+
get message() {
|
|
241
|
+
return `journal ${this.reason} beneath the reader at ${this.path}: consumed ${this.expected}, file is now ${this.actual}`;
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
//#endregion
|
|
246
|
+
export { InvalidData, JournalClosed, JournalNotFound, JournalResync, MalformedLine, TerminalViolation, UnknownEvent, UnserializableData };
|
package/JsonlEvent.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
//#region src/JsonlEvent.ts
|
|
2
|
+
/**
|
|
3
|
+
* Runtime type identifier marking a JSONL event definition.
|
|
4
|
+
*
|
|
5
|
+
* @public
|
|
6
|
+
*/
|
|
7
|
+
const JsonlEventTypeId = "~effected/jsonl/JsonlEvent";
|
|
8
|
+
/**
|
|
9
|
+
* Defines an event.
|
|
10
|
+
*
|
|
11
|
+
* The `const` type parameters are load-bearing: they keep `"unlinked"` a
|
|
12
|
+
* literal rather than widening it to `string`, and keep `terminal: true` a
|
|
13
|
+
* literal `true`, so both survive into the derived envelope union where the
|
|
14
|
+
* narrowing and the terminal/reopen state machine depend on them.
|
|
15
|
+
*
|
|
16
|
+
* `data` is required rather than defaulted. A payload-less event says so
|
|
17
|
+
* explicitly with `Schema.Void`, because a silent default would make a typo in
|
|
18
|
+
* the options object look like a deliberate void payload.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```ts
|
|
22
|
+
* import { JsonlEvent } from "@effected/jsonl";
|
|
23
|
+
* import { Schema } from "effect";
|
|
24
|
+
*
|
|
25
|
+
* const MailReceived = JsonlEvent.make("mail-received", {
|
|
26
|
+
* data: Schema.Struct({ round: Schema.Number }),
|
|
27
|
+
* });
|
|
28
|
+
* const Unlinked = JsonlEvent.make("unlinked", { data: Schema.Void, terminal: true });
|
|
29
|
+
* const Relinked = JsonlEvent.make("relinked", { data: Schema.Void, reopen: true });
|
|
30
|
+
*
|
|
31
|
+
* const registry = [MailReceived, Unlinked, Relinked] as const;
|
|
32
|
+
* ```
|
|
33
|
+
*
|
|
34
|
+
* @public
|
|
35
|
+
*/
|
|
36
|
+
const JsonlEvent = { make: (tag, options) => ({
|
|
37
|
+
[JsonlEventTypeId]: JsonlEventTypeId,
|
|
38
|
+
tag,
|
|
39
|
+
data: options.data,
|
|
40
|
+
terminal: options.terminal ?? false,
|
|
41
|
+
reopen: options.reopen ?? false
|
|
42
|
+
}) };
|
|
43
|
+
|
|
44
|
+
//#endregion
|
|
45
|
+
export { JsonlEvent, JsonlEventTypeId };
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 C. Spencer Beggs
|
|
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/Line.js
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { LineSlice } from "./LineSlice.js";
|
|
2
|
+
import { MalformedLine } from "./JsonlError.js";
|
|
3
|
+
import { utf8Length } from "./internal/utf8.js";
|
|
4
|
+
import { Option, Result, Schema } from "effect";
|
|
5
|
+
|
|
6
|
+
//#region src/Line.ts
|
|
7
|
+
/**
|
|
8
|
+
* The pure, synchronous line layer of a JSONL journal.
|
|
9
|
+
*
|
|
10
|
+
* Nothing here touches `FileSystem`, builds an `Effect`, or needs a runtime —
|
|
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
|
|
26
|
+
*/
|
|
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
|
+
const isBlank = (line) => line.text.trim() === "";
|
|
35
|
+
/**
|
|
36
|
+
* Splitting, parsing and corrupt-tail walk-back over JSONL text.
|
|
37
|
+
*
|
|
38
|
+
* Every operation is total and synchronous: it returns a value for every
|
|
39
|
+
* input, including empty text, torn tails, and text that is not JSONL at all.
|
|
40
|
+
* Nothing here throws — a `JSON.parse` failure becomes a {@link MalformedLine}
|
|
41
|
+
* in the returned `Result`, because a journal is untrusted input and untrusted
|
|
42
|
+
* input fails typed.
|
|
43
|
+
*
|
|
44
|
+
* @public
|
|
45
|
+
*/
|
|
46
|
+
var Line = class Line {
|
|
47
|
+
/**
|
|
48
|
+
* The UTF-8 byte length of a string.
|
|
49
|
+
*
|
|
50
|
+
* Exposed because callers doing their own offset arithmetic must measure
|
|
51
|
+
* the same way this module does. `String.length` is a different number for
|
|
52
|
+
* any non-ASCII line.
|
|
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.
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* ```ts
|
|
61
|
+
* import { Line } from "@effected/jsonl";
|
|
62
|
+
*
|
|
63
|
+
* Line.byteLength("\u{1F600}"); // 4
|
|
64
|
+
* "\u{1F600}".length; // 2 — the trap
|
|
65
|
+
* ```
|
|
66
|
+
*/
|
|
67
|
+
static byteLength(text) {
|
|
68
|
+
return utf8Length(text);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Split text into candidate lines with byte-exact offsets.
|
|
72
|
+
*
|
|
73
|
+
* Lines are separated by `\n`; a `\r` immediately preceding it is treated as
|
|
74
|
+
* part of the terminator, so CRLF journals split identically to LF ones. A
|
|
75
|
+
* bare `\r` is ordinary content, because only `\n` terminates.
|
|
76
|
+
*
|
|
77
|
+
* A trailing terminator does **not** produce a phantom empty final line, so
|
|
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`.
|
|
83
|
+
*
|
|
84
|
+
* @param text - JSONL source text.
|
|
85
|
+
* @returns One {@link LineSlice} per candidate line, in source order.
|
|
86
|
+
*/
|
|
87
|
+
static split(text) {
|
|
88
|
+
const lines = [];
|
|
89
|
+
let offset = 0;
|
|
90
|
+
let start = 0;
|
|
91
|
+
for (;;) {
|
|
92
|
+
const newline = text.indexOf("\n", start);
|
|
93
|
+
if (newline === -1) break;
|
|
94
|
+
const raw = text.slice(start, newline);
|
|
95
|
+
const hadCarriageReturn = raw.endsWith("\r");
|
|
96
|
+
const content = hadCarriageReturn ? raw.slice(0, -1) : raw;
|
|
97
|
+
const length = utf8Length(content);
|
|
98
|
+
const end = offset + length + (hadCarriageReturn ? 2 : 1);
|
|
99
|
+
lines.push(new LineSlice({
|
|
100
|
+
offset,
|
|
101
|
+
end,
|
|
102
|
+
length,
|
|
103
|
+
text: content,
|
|
104
|
+
terminated: true
|
|
105
|
+
}));
|
|
106
|
+
offset = end;
|
|
107
|
+
start = newline + 1;
|
|
108
|
+
}
|
|
109
|
+
if (start < text.length) {
|
|
110
|
+
const content = text.slice(start);
|
|
111
|
+
const length = utf8Length(content);
|
|
112
|
+
lines.push(new LineSlice({
|
|
113
|
+
offset,
|
|
114
|
+
end: offset + length,
|
|
115
|
+
length,
|
|
116
|
+
text: content,
|
|
117
|
+
terminated: false
|
|
118
|
+
}));
|
|
119
|
+
}
|
|
120
|
+
return lines;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
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
|
+
* Parse one candidate line's JSON.
|
|
140
|
+
*
|
|
141
|
+
* Any JSON value succeeds — objects, arrays and scalars alike. This layer
|
|
142
|
+
* does not know what an envelope is.
|
|
143
|
+
*
|
|
144
|
+
* @param line - A slice from {@link Line.split}.
|
|
145
|
+
* @returns The {@link ParsedLine}, or a {@link MalformedLine} carrying the
|
|
146
|
+
* slice so the caller can locate the damage and decide whether an
|
|
147
|
+
* unterminated line is a torn tail worth waiting for.
|
|
148
|
+
*/
|
|
149
|
+
static parseResult(line) {
|
|
150
|
+
let value;
|
|
151
|
+
try {
|
|
152
|
+
value = JSON.parse(line.text);
|
|
153
|
+
} catch {
|
|
154
|
+
return Result.fail(new MalformedLine({ line }));
|
|
155
|
+
}
|
|
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
|
+
}
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
//#endregion
|
|
219
|
+
export { Line, ParsedLine };
|
package/LineSlice.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/LineSlice.ts
|
|
4
|
+
/**
|
|
5
|
+
* One candidate line of a JSONL journal, located in the source by byte.
|
|
6
|
+
*
|
|
7
|
+
* @since 0.1.0
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* A single candidate line: its text, and where it lives in the source **in
|
|
11
|
+
* bytes**.
|
|
12
|
+
*
|
|
13
|
+
* Every offset on this class is a UTF-8 byte offset, never a UTF-16 code-unit
|
|
14
|
+
* index, because these values are cursors into a file: they are handed to
|
|
15
|
+
* `FileSystem.stream`'s `offset` option and persisted across process restarts.
|
|
16
|
+
* A `String.length`-derived offset is correct only for ASCII journals and is
|
|
17
|
+
* the single most likely bug in this module.
|
|
18
|
+
*
|
|
19
|
+
* The terminator is **not** part of the content: `text` and `length` exclude
|
|
20
|
+
* the trailing `\n`, and exclude the `\r` of a `\r\n` pair. `end` includes it,
|
|
21
|
+
* which is why `end - offset` is not always `length`.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```ts
|
|
25
|
+
* import { Line } from "@effected/jsonl";
|
|
26
|
+
*
|
|
27
|
+
* const [first] = Line.split('{"a":1}\r\n{"b":2}\n');
|
|
28
|
+
* first?.text; // '{"a":1}'
|
|
29
|
+
* first?.offset; // 0
|
|
30
|
+
* first?.length; // 7 — content bytes, no terminator
|
|
31
|
+
* first?.end; // 9 — past the CR and the LF
|
|
32
|
+
* first?.terminated; // true
|
|
33
|
+
* ```
|
|
34
|
+
*
|
|
35
|
+
* @public
|
|
36
|
+
*/
|
|
37
|
+
var LineSlice = class extends Schema.Class("LineSlice")({
|
|
38
|
+
/** UTF-8 byte offset of this line's first content byte. */
|
|
39
|
+
offset: Schema.Number,
|
|
40
|
+
/**
|
|
41
|
+
* UTF-8 byte offset just past this line's terminator — the offset at which
|
|
42
|
+
* the next line begins, and the resume cursor for an incremental read.
|
|
43
|
+
*
|
|
44
|
+
* Equal to `offset + length` when the line is unterminated.
|
|
45
|
+
*/
|
|
46
|
+
end: Schema.Number,
|
|
47
|
+
/** UTF-8 byte length of `LineSlice.text`, excluding any terminator. */
|
|
48
|
+
length: Schema.Number,
|
|
49
|
+
/** The line's content, with its terminator and any paired `\r` removed. */
|
|
50
|
+
text: Schema.String,
|
|
51
|
+
/**
|
|
52
|
+
* Whether a `\n` terminated this line in the source.
|
|
53
|
+
*
|
|
54
|
+
* `false` can only occur on the final line, and means the line **may be a
|
|
55
|
+
* torn tail** — a writer caught mid-append. A reader walks back over it and
|
|
56
|
+
* leaves its bytes unconsumed so the next read sees the completed line.
|
|
57
|
+
*/
|
|
58
|
+
terminated: Schema.Boolean
|
|
59
|
+
}) {};
|
|
60
|
+
|
|
61
|
+
//#endregion
|
|
62
|
+
export { LineSlice };
|