@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/Envelope.js
CHANGED
|
@@ -1,22 +1,14 @@
|
|
|
1
|
-
import { LineSlice } from "./LineSlice.js";
|
|
2
1
|
import { InvalidData, UnknownEvent, UnserializableData } from "./JsonlError.js";
|
|
3
|
-
import { Line } from "./Line.js";
|
|
4
|
-
import {
|
|
2
|
+
import { Line, isBlank } from "./Line.js";
|
|
3
|
+
import { DateTime, Option, Result, Schema } from "effect";
|
|
5
4
|
|
|
6
5
|
//#region src/Envelope.ts
|
|
7
6
|
/**
|
|
8
|
-
* Stage one
|
|
7
|
+
* Stage one: the envelope with its payload left undecoded.
|
|
9
8
|
*
|
|
10
|
-
* A `Schema.Struct
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* nothing to gain from wrapping each one in a class instance.
|
|
14
|
-
*
|
|
15
|
-
* `data` is `Schema.Unknown`, which passes the payload through without
|
|
16
|
-
* traversing it. That is what keeps frame decoding independent of payload
|
|
17
|
-
* depth, and what lets filtering happen before any payload schema runs.
|
|
18
|
-
*
|
|
19
|
-
* @public
|
|
9
|
+
* A `Schema.Struct`, not a class: it runs for every line of every read,
|
|
10
|
+
* including the lines a slice is about to discard. `data` is `Schema.Unknown`,
|
|
11
|
+
* which passes the payload through without traversing it.
|
|
20
12
|
*/
|
|
21
13
|
const EnvelopeFrame = Schema.Struct({
|
|
22
14
|
at: Schema.DateTimeUtcFromString,
|
|
@@ -27,36 +19,47 @@ const EnvelopeFrame = Schema.Struct({
|
|
|
27
19
|
/**
|
|
28
20
|
* The tag→definition lookup, built once per registry rather than once per line.
|
|
29
21
|
*
|
|
30
|
-
* A registry is
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* the per-line decode path — small, but paid once per line of every read, which
|
|
34
|
-
* is exactly the shape of cost this package exists to keep down.
|
|
35
|
-
*
|
|
36
|
-
* `WeakMap` so a registry that goes out of scope takes its index with it.
|
|
22
|
+
* A registry is an array declared at module scope and handed to every call, so
|
|
23
|
+
* it is a legitimate cache key; `WeakMap` so a registry that goes out of scope
|
|
24
|
+
* takes its index with it.
|
|
37
25
|
*/
|
|
38
26
|
const registryIndex = /* @__PURE__ */ new WeakMap();
|
|
39
27
|
const indexRegistry = (events) => {
|
|
40
|
-
|
|
41
|
-
if (
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
28
|
+
let index = registryIndex.get(events);
|
|
29
|
+
if (index === void 0) {
|
|
30
|
+
Object.freeze(events);
|
|
31
|
+
index = new Map(events.map((event) => [event.tag, event]));
|
|
32
|
+
registryIndex.set(events, index);
|
|
33
|
+
}
|
|
45
34
|
return index;
|
|
46
35
|
};
|
|
47
36
|
const decodeFrame = Schema.decodeUnknownResult(EnvelopeFrame);
|
|
48
|
-
const
|
|
37
|
+
const positionOf = (line) => ({
|
|
38
|
+
offset: line.offset,
|
|
39
|
+
end: line.end
|
|
40
|
+
});
|
|
41
|
+
/**
|
|
42
|
+
* Stage one only: parse a line and decode its frame, leaving `data` untouched.
|
|
43
|
+
*
|
|
44
|
+
* @internal
|
|
45
|
+
*/
|
|
46
|
+
const frameResult = (line) => {
|
|
47
|
+
const parsed = Line.parseResult(line);
|
|
48
|
+
if (Result.isFailure(parsed)) return Result.fail(parsed.failure);
|
|
49
|
+
const frame = decodeFrame(parsed.success);
|
|
50
|
+
if (Result.isFailure(frame)) return Result.fail(new InvalidData({
|
|
51
|
+
line,
|
|
52
|
+
event: Option.none(),
|
|
53
|
+
error: frame.failure
|
|
54
|
+
}));
|
|
55
|
+
return Result.succeed(frame.success);
|
|
56
|
+
};
|
|
49
57
|
/**
|
|
50
58
|
* Stage two: apply the registered payload schema to an already-decoded frame.
|
|
51
59
|
*
|
|
52
|
-
*
|
|
53
|
-
* and deliberately not exported. API Extractor honours release tags on
|
|
54
|
-
* top-level declarations, not on members of an exported object literal — so an
|
|
55
|
-
* `@internal` tag down there is inert text and the member ships as supported
|
|
56
|
-
* public API. Hoisting is what actually keeps it off the surface.
|
|
60
|
+
* The one payload-decode path; every decode, filtered or not, goes through it.
|
|
57
61
|
*
|
|
58
|
-
*
|
|
59
|
-
* one payload-decode path and the filtered and unfiltered forms cannot drift.
|
|
62
|
+
* @internal
|
|
60
63
|
*/
|
|
61
64
|
const completeResult = (events, line, frame) => {
|
|
62
65
|
const { at, event, scope, data } = frame;
|
|
@@ -77,30 +80,29 @@ const completeResult = (events, line, frame) => {
|
|
|
77
80
|
event,
|
|
78
81
|
...scope === void 0 ? {} : { scope },
|
|
79
82
|
data: decoded.success,
|
|
80
|
-
line
|
|
83
|
+
position: positionOf(line)
|
|
81
84
|
});
|
|
82
85
|
};
|
|
83
86
|
/**
|
|
84
87
|
* Decoding, encoding and the envelope-level walk-back.
|
|
85
88
|
*
|
|
86
|
-
* Every operation
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
+
* Every operation is synchronous and `Result`-based, so a hook script can read
|
|
90
|
+
* a journal with no Effect runtime. Lift one into an `Effect` with
|
|
91
|
+
* `Effect.fromResult` where a program needs it.
|
|
89
92
|
*
|
|
90
93
|
* @example
|
|
91
94
|
* ```ts
|
|
92
|
-
* import { Envelope, JsonlEvent
|
|
93
|
-
* import {
|
|
95
|
+
* import { Envelope, JsonlEvent } from "@effected/jsonl";
|
|
96
|
+
* import { Option, Schema } from "effect";
|
|
94
97
|
*
|
|
95
|
-
* const events = [JsonlEvent.make("
|
|
98
|
+
* const events = [JsonlEvent.make("state", { data: Schema.Struct({ round: Schema.Number }) })] as const;
|
|
96
99
|
*
|
|
97
100
|
* declare const sourceText: string;
|
|
98
101
|
*
|
|
99
|
-
* for
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
* }
|
|
102
|
+
* // The whole read path for a snapshot journal, with no runtime.
|
|
103
|
+
* const current = Envelope.lastValid(events, sourceText);
|
|
104
|
+
* if (Option.isSome(current)) {
|
|
105
|
+
* current.value.data.round; // typed from the registered payload schema
|
|
104
106
|
* }
|
|
105
107
|
* ```
|
|
106
108
|
*
|
|
@@ -108,109 +110,50 @@ const completeResult = (events, line, frame) => {
|
|
|
108
110
|
*/
|
|
109
111
|
const Envelope = {
|
|
110
112
|
/**
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
* This is what filtering reads. It never runs a payload schema, so its cost
|
|
114
|
-
* is independent of payload size and depth, and a line destined to be
|
|
115
|
-
* filtered out never pays for its payload at all.
|
|
116
|
-
*
|
|
117
|
-
* @param line - A slice from `Line.split`.
|
|
118
|
-
* @returns The frame with its source `line` attached, or `MalformedLine`
|
|
119
|
-
* when the line is not JSON, or `InvalidData` when it is JSON that is not
|
|
120
|
-
* an envelope frame.
|
|
121
|
-
*/
|
|
122
|
-
frameResult: (line) => {
|
|
123
|
-
const parsed = Line.parseResult(line);
|
|
124
|
-
if (Result.isFailure(parsed)) return Result.fail(parsed.failure);
|
|
125
|
-
const frame = decodeFrame(parsed.success.value);
|
|
126
|
-
if (Result.isFailure(frame)) return Result.fail(new InvalidData({
|
|
127
|
-
line,
|
|
128
|
-
event: Option.none(),
|
|
129
|
-
error: frame.failure
|
|
130
|
-
}));
|
|
131
|
-
return Result.succeed({
|
|
132
|
-
...frame.success,
|
|
133
|
-
line
|
|
134
|
-
});
|
|
135
|
-
},
|
|
136
|
-
/**
|
|
137
|
-
* Decode one line into a fully validated envelope, running both stages.
|
|
113
|
+
* Decode one line into a fully validated envelope.
|
|
138
114
|
*
|
|
139
115
|
* @param events - The registry defining which tags are legal and what their
|
|
140
116
|
* payloads must look like.
|
|
141
117
|
* @param line - A slice from `Line.split`.
|
|
142
|
-
* @returns The envelope, or the
|
|
143
|
-
* (`MalformedLine`), not an envelope or a bad payload (`InvalidData`), or
|
|
144
|
-
* a tag this registry does not define (`UnknownEvent`).
|
|
118
|
+
* @returns The envelope, or why the line is not one.
|
|
145
119
|
*/
|
|
146
120
|
decodeResult: (events, line) => {
|
|
147
|
-
const frame =
|
|
148
|
-
|
|
149
|
-
return completeResult(events, line, frame.success);
|
|
150
|
-
},
|
|
151
|
-
/**
|
|
152
|
-
* Decode a line only if its **frame** is selected, running the payload schema
|
|
153
|
-
* for selected lines only.
|
|
154
|
-
*
|
|
155
|
-
* This is the structural form of the filter-before-decode guarantee: when
|
|
156
|
-
* `select` rejects the frame, the registered `data` schema is never reached,
|
|
157
|
-
* so a consumer reading a journal it shares with a noisy neighbour pays
|
|
158
|
-
* nothing for the neighbour's payloads. Writing the filter *after* a full
|
|
159
|
-
* decode would produce identical output while charging for every line — which
|
|
160
|
-
* is why this exists as its own operation rather than as advice.
|
|
161
|
-
*
|
|
162
|
-
* @param events - The registry to validate against.
|
|
163
|
-
* @param line - A slice from `Line.split`.
|
|
164
|
-
* @param select - Predicate over the decoded frame — `at`, `event`, `scope`
|
|
165
|
-
* and the raw undecoded `data`.
|
|
166
|
-
* @returns `Option.none()` when the frame is not selected; otherwise the
|
|
167
|
-
* fully decoded envelope, or the typed reason it is not one.
|
|
168
|
-
*/
|
|
169
|
-
decodeSelectedResult: (events, line, select) => {
|
|
170
|
-
const frame = Envelope.frameResult(line);
|
|
171
|
-
if (Result.isFailure(frame)) return Option.some(Result.fail(frame.failure));
|
|
172
|
-
if (!select(frame.success)) return Option.none();
|
|
173
|
-
return Option.some(completeResult(events, line, frame.success));
|
|
121
|
+
const frame = frameResult(line);
|
|
122
|
+
return Result.isFailure(frame) ? Result.fail(frame.failure) : completeResult(events, line, frame.success);
|
|
174
123
|
},
|
|
175
124
|
/**
|
|
176
125
|
* Decode every non-blank line, reporting failures rather than dropping them.
|
|
177
126
|
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
127
|
+
* A line that is not a legal envelope is a `Result` in the array, never a gap
|
|
128
|
+
* in it: whether a hole is tolerable is the caller's decision.
|
|
180
129
|
*
|
|
181
130
|
* @param events - The registry to validate against.
|
|
182
131
|
* @param text - JSONL source text.
|
|
132
|
+
* @param base - The byte offset `text` starts at in its file.
|
|
183
133
|
* @returns One `Result` per non-blank line, in file order.
|
|
184
134
|
*/
|
|
185
|
-
decodeAllResult: (events, text) =>
|
|
186
|
-
const results = [];
|
|
187
|
-
for (const line of Line.split(text)) if (line.text.trim() !== "") results.push(Envelope.decodeResult(events, line));
|
|
188
|
-
return results;
|
|
189
|
-
},
|
|
135
|
+
decodeAllResult: (events, text, base = 0) => Line.split(text, base).filter((line) => !isBlank(line)).map((line) => Envelope.decodeResult(events, line)),
|
|
190
136
|
/**
|
|
191
|
-
* Walk back from the end to the last valid **envelope
|
|
192
|
-
*
|
|
193
|
-
* This is the binding definition of "the last valid line", and the envelope
|
|
194
|
-
* layer is where it has to live. `Line.lastValid` stops at JSON validity,
|
|
195
|
-
* which is not enough: a torn scalar tail (`42` cut mid-write leaves `4`)
|
|
196
|
-
* parses cleanly as a different value, so a JSON-level walk-back would hand
|
|
197
|
-
* back corruption as the current state. An envelope is always an object,
|
|
198
|
-
* every strict prefix of an object text is invalid JSON, and a bare `4` is
|
|
199
|
-
* not an envelope — so this walk-back detects the torn tail that the one
|
|
200
|
-
* below it structurally cannot.
|
|
137
|
+
* Walk back from the end to the last valid **envelope** — the journal's
|
|
138
|
+
* current state.
|
|
201
139
|
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
140
|
+
* A torn tail is stepped over, and so is any number of malformed, foreign or
|
|
141
|
+
* blank trailing lines. Validity is judged at the envelope, not merely as
|
|
142
|
+
* JSON: a torn *scalar* (`42` cut mid-write leaves `4`) parses cleanly as a
|
|
143
|
+
* different value, but is not an envelope. Decoding stops at the first
|
|
144
|
+
* success, so the cost tracks the damage at the tail, not the file's age.
|
|
204
145
|
*
|
|
205
146
|
* @param events - The registry to validate against.
|
|
206
|
-
* @param text - JSONL source text
|
|
147
|
+
* @param text - JSONL source text — the whole file, or a tail of it that
|
|
148
|
+
* begins at a line boundary.
|
|
149
|
+
* @param base - The byte offset `text` starts at in its file.
|
|
207
150
|
* @returns The last valid envelope, or `Option.none()` if there is none.
|
|
208
151
|
*/
|
|
209
|
-
|
|
210
|
-
const lines = Line.split(text);
|
|
152
|
+
lastValid: (events, text, base = 0) => {
|
|
153
|
+
const lines = Line.split(text, base);
|
|
211
154
|
for (let index = lines.length - 1; index >= 0; index--) {
|
|
212
155
|
const line = lines[index];
|
|
213
|
-
if (line
|
|
156
|
+
if (isBlank(line)) continue;
|
|
214
157
|
const decoded = Envelope.decodeResult(events, line);
|
|
215
158
|
if (Result.isSuccess(decoded)) return Option.some(decoded.success);
|
|
216
159
|
}
|
|
@@ -220,80 +163,45 @@ const Envelope = {
|
|
|
220
163
|
* Encode one envelope into a complete JSONL line, terminator included.
|
|
221
164
|
*
|
|
222
165
|
* The trailing `\n` is part of the returned string on purpose: the
|
|
223
|
-
* cooperative-writer contract is one
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
* exists to prevent.
|
|
227
|
-
*
|
|
228
|
-
* No `Schema.Union` is involved. The tag is known, so the payload schema is
|
|
229
|
-
* a direct registry lookup; a union would buy nothing and decode eagerly.
|
|
166
|
+
* cooperative-writer contract is one write of a *complete line*, and an API
|
|
167
|
+
* returning the text without its terminator would invite writing the two
|
|
168
|
+
* separately.
|
|
230
169
|
*
|
|
231
170
|
* @param events - The registry to validate against.
|
|
232
|
-
* @param envelope - The event tag, its payload, the
|
|
233
|
-
*
|
|
234
|
-
* @returns The encoded line, or `
|
|
235
|
-
*
|
|
236
|
-
* or
|
|
237
|
-
*
|
|
171
|
+
* @param envelope - The event tag, its payload, the timestamp, and an
|
|
172
|
+
* optional scope.
|
|
173
|
+
* @returns The encoded line, or `InvalidData` when the payload does not
|
|
174
|
+
* satisfy its schema, `UnserializableData` when the encoded payload is not
|
|
175
|
+
* JSON (a bigint or a reference cycle), or `UnknownEvent` for a tag the
|
|
176
|
+
* registry does not define.
|
|
238
177
|
*/
|
|
239
178
|
encodeResult: (events, envelope) => {
|
|
240
|
-
const empty = new LineSlice({
|
|
241
|
-
offset: 0,
|
|
242
|
-
end: 0,
|
|
243
|
-
length: 0,
|
|
244
|
-
text: "",
|
|
245
|
-
terminated: false
|
|
246
|
-
});
|
|
247
179
|
const definition = indexRegistry(events).get(envelope.event);
|
|
248
180
|
if (definition === void 0) return Result.fail(new UnknownEvent({
|
|
249
|
-
line: empty,
|
|
250
181
|
event: envelope.event,
|
|
251
182
|
known: events.map((e) => e.tag)
|
|
252
183
|
}));
|
|
253
184
|
const data = Schema.encodeUnknownResult(definition.data)(envelope.data);
|
|
254
185
|
if (Result.isFailure(data)) return Result.fail(new InvalidData({
|
|
255
|
-
line: empty,
|
|
256
186
|
event: Option.some(envelope.event),
|
|
257
187
|
error: data.failure
|
|
258
188
|
}));
|
|
259
|
-
const at = encodeAt(envelope.at);
|
|
260
|
-
if (Result.isFailure(at)) return Result.fail(new InvalidData({
|
|
261
|
-
line: empty,
|
|
262
|
-
event: Option.some(envelope.event),
|
|
263
|
-
error: at.failure
|
|
264
|
-
}));
|
|
265
|
-
let text;
|
|
266
189
|
try {
|
|
267
|
-
text = JSON.stringify({
|
|
268
|
-
at: at
|
|
190
|
+
const text = JSON.stringify({
|
|
191
|
+
at: DateTime.formatIso(envelope.at),
|
|
269
192
|
event: envelope.event,
|
|
270
193
|
...envelope.scope === void 0 ? {} : { scope: envelope.scope },
|
|
271
194
|
data: data.success === void 0 ? null : data.success
|
|
272
195
|
});
|
|
196
|
+
return Result.succeed(`${text}\n`);
|
|
273
197
|
} catch (cause) {
|
|
274
198
|
return Result.fail(new UnserializableData({
|
|
275
199
|
event: envelope.event,
|
|
276
200
|
cause
|
|
277
201
|
}));
|
|
278
202
|
}
|
|
279
|
-
|
|
280
|
-
},
|
|
281
|
-
/**
|
|
282
|
-
* `Envelope.decodeResult` in the `Effect` channel.
|
|
283
|
-
*
|
|
284
|
-
* Prefer the sync form in a hook or any caller that has no runtime; this
|
|
285
|
-
* exists for programs that already are one. Fails with `MalformedLine`,
|
|
286
|
-
* `InvalidData` or `UnknownEvent`; requires nothing.
|
|
287
|
-
*/
|
|
288
|
-
decode: (events, line) => Effect.fromResult(Envelope.decodeResult(events, line)),
|
|
289
|
-
/**
|
|
290
|
-
* `Envelope.encodeResult` in the `Effect` channel.
|
|
291
|
-
*
|
|
292
|
-
* Fails with `UnknownEvent`, `InvalidData` or `UnserializableData`; requires
|
|
293
|
-
* nothing.
|
|
294
|
-
*/
|
|
295
|
-
encode: (events, envelope) => Effect.fromResult(Envelope.encodeResult(events, envelope))
|
|
203
|
+
}
|
|
296
204
|
};
|
|
297
205
|
|
|
298
206
|
//#endregion
|
|
299
|
-
export { Envelope,
|
|
207
|
+
export { Envelope, completeResult, frameResult };
|