@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/Envelope.js
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { LineSlice } from "./LineSlice.js";
|
|
2
|
+
import { InvalidData, UnknownEvent, UnserializableData } from "./JsonlError.js";
|
|
3
|
+
import { Line } from "./Line.js";
|
|
4
|
+
import { Effect, Option, Result, Schema } from "effect";
|
|
5
|
+
|
|
6
|
+
//#region src/Envelope.ts
|
|
7
|
+
/**
|
|
8
|
+
* Stage one of the read path: the envelope with its payload left undecoded.
|
|
9
|
+
*
|
|
10
|
+
* A `Schema.Struct` rather than a `Schema.Class` deliberately. This is the hot
|
|
11
|
+
* path — it runs for every line of every read, including the lines a `Slice` is
|
|
12
|
+
* about to discard — and it is a transient the caller never holds, so there is
|
|
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
|
|
20
|
+
*/
|
|
21
|
+
const EnvelopeFrame = Schema.Struct({
|
|
22
|
+
at: Schema.DateTimeUtcFromString,
|
|
23
|
+
event: Schema.String,
|
|
24
|
+
scope: Schema.optionalKey(Schema.String),
|
|
25
|
+
data: Schema.Unknown
|
|
26
|
+
});
|
|
27
|
+
/**
|
|
28
|
+
* The tag→definition lookup, built once per registry rather than once per line.
|
|
29
|
+
*
|
|
30
|
+
* A registry is a frozen array declared at module scope and handed to every
|
|
31
|
+
* call, so it is a legitimate cache key: the same array always indexes to the
|
|
32
|
+
* same map. Building the map per call was measured at 1.23x the cost of this on
|
|
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.
|
|
37
|
+
*/
|
|
38
|
+
const registryIndex = /* @__PURE__ */ new WeakMap();
|
|
39
|
+
const indexRegistry = (events) => {
|
|
40
|
+
const cached = registryIndex.get(events);
|
|
41
|
+
if (cached !== void 0) return cached;
|
|
42
|
+
Object.freeze(events);
|
|
43
|
+
const index = new Map(events.map((event) => [event.tag, event]));
|
|
44
|
+
registryIndex.set(events, index);
|
|
45
|
+
return index;
|
|
46
|
+
};
|
|
47
|
+
const decodeFrame = Schema.decodeUnknownResult(EnvelopeFrame);
|
|
48
|
+
const encodeAt = Schema.encodeUnknownResult(Schema.DateTimeUtcFromString);
|
|
49
|
+
/**
|
|
50
|
+
* Stage two: apply the registered payload schema to an already-decoded frame.
|
|
51
|
+
*
|
|
52
|
+
* A module-level const rather than a member of the exported `Envelope` object,
|
|
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.
|
|
57
|
+
*
|
|
58
|
+
* Both `decodeResult` and `decodeSelectedResult` call this, so there is exactly
|
|
59
|
+
* one payload-decode path and the filtered and unfiltered forms cannot drift.
|
|
60
|
+
*/
|
|
61
|
+
const completeResult = (events, line, frame) => {
|
|
62
|
+
const { at, event, scope, data } = frame;
|
|
63
|
+
const definition = indexRegistry(events).get(event);
|
|
64
|
+
if (definition === void 0) return Result.fail(new UnknownEvent({
|
|
65
|
+
line,
|
|
66
|
+
event,
|
|
67
|
+
known: events.map((e) => e.tag)
|
|
68
|
+
}));
|
|
69
|
+
const decoded = Schema.decodeUnknownResult(definition.data)(data);
|
|
70
|
+
if (Result.isFailure(decoded)) return Result.fail(new InvalidData({
|
|
71
|
+
line,
|
|
72
|
+
event: Option.some(event),
|
|
73
|
+
error: decoded.failure
|
|
74
|
+
}));
|
|
75
|
+
return Result.succeed({
|
|
76
|
+
at,
|
|
77
|
+
event,
|
|
78
|
+
...scope === void 0 ? {} : { scope },
|
|
79
|
+
data: decoded.success,
|
|
80
|
+
line
|
|
81
|
+
});
|
|
82
|
+
};
|
|
83
|
+
/**
|
|
84
|
+
* Decoding, encoding and the envelope-level walk-back.
|
|
85
|
+
*
|
|
86
|
+
* Every operation here is synchronous and `Result`-based; the `Effect` forms are
|
|
87
|
+
* defined **in terms of** the sync ones via `Effect.fromResult`, so the two can
|
|
88
|
+
* never drift into two implementations of the same rule.
|
|
89
|
+
*
|
|
90
|
+
* @public
|
|
91
|
+
*/
|
|
92
|
+
const Envelope = {
|
|
93
|
+
/**
|
|
94
|
+
* Stage one only: decode the frame, leaving `data` untouched.
|
|
95
|
+
*
|
|
96
|
+
* This is what filtering reads. It never runs a payload schema, so its cost
|
|
97
|
+
* is independent of payload size and depth, and a line destined to be
|
|
98
|
+
* filtered out never pays for its payload at all.
|
|
99
|
+
*
|
|
100
|
+
* @param line - A slice from `Line.split`.
|
|
101
|
+
*/
|
|
102
|
+
frameResult: (line) => {
|
|
103
|
+
const parsed = Line.parseResult(line);
|
|
104
|
+
if (Result.isFailure(parsed)) return Result.fail(parsed.failure);
|
|
105
|
+
const frame = decodeFrame(parsed.success.value);
|
|
106
|
+
if (Result.isFailure(frame)) return Result.fail(new InvalidData({
|
|
107
|
+
line,
|
|
108
|
+
event: Option.none(),
|
|
109
|
+
error: frame.failure
|
|
110
|
+
}));
|
|
111
|
+
return Result.succeed({
|
|
112
|
+
...frame.success,
|
|
113
|
+
line
|
|
114
|
+
});
|
|
115
|
+
},
|
|
116
|
+
/**
|
|
117
|
+
* Decode one line into a fully validated envelope, running both stages.
|
|
118
|
+
*
|
|
119
|
+
* @param events - The registry defining which tags are legal and what their
|
|
120
|
+
* payloads must look like.
|
|
121
|
+
* @param line - A slice from `Line.split`.
|
|
122
|
+
* @returns The envelope, or the typed reason it is not one: not JSON
|
|
123
|
+
* (`MalformedLine`), not an envelope or a bad payload (`InvalidData`), or
|
|
124
|
+
* a tag this registry does not define (`UnknownEvent`).
|
|
125
|
+
*/
|
|
126
|
+
decodeResult: (events, line) => {
|
|
127
|
+
const frame = Envelope.frameResult(line);
|
|
128
|
+
if (Result.isFailure(frame)) return Result.fail(frame.failure);
|
|
129
|
+
return completeResult(events, line, frame.success);
|
|
130
|
+
},
|
|
131
|
+
/**
|
|
132
|
+
* Decode a line only if its **frame** is selected, running the payload schema
|
|
133
|
+
* for selected lines only.
|
|
134
|
+
*
|
|
135
|
+
* This is the structural form of the filter-before-decode guarantee: when
|
|
136
|
+
* `select` rejects the frame, the registered `data` schema is never reached,
|
|
137
|
+
* so a consumer reading a journal it shares with a noisy neighbour pays
|
|
138
|
+
* nothing for the neighbour's payloads. Writing the filter *after* a full
|
|
139
|
+
* decode would produce identical output while charging for every line — which
|
|
140
|
+
* is why this exists as its own operation rather than as advice.
|
|
141
|
+
*
|
|
142
|
+
* @param events - The registry to validate against.
|
|
143
|
+
* @param line - A slice from `Line.split`.
|
|
144
|
+
* @param select - Predicate over the decoded frame — `at`, `event`, `scope`
|
|
145
|
+
* and the raw undecoded `data`.
|
|
146
|
+
* @returns `Option.none()` when the frame is not selected; otherwise the
|
|
147
|
+
* fully decoded envelope, or the typed reason it is not one.
|
|
148
|
+
*/
|
|
149
|
+
decodeSelectedResult: (events, line, select) => {
|
|
150
|
+
const frame = Envelope.frameResult(line);
|
|
151
|
+
if (Result.isFailure(frame)) return Option.some(Result.fail(frame.failure));
|
|
152
|
+
if (!select(frame.success)) return Option.none();
|
|
153
|
+
return Option.some(completeResult(events, line, frame.success));
|
|
154
|
+
},
|
|
155
|
+
/**
|
|
156
|
+
* Decode every non-blank line, reporting failures rather than dropping them.
|
|
157
|
+
*
|
|
158
|
+
* Same contract as `Line.parseAll` one layer down: a line that is not a
|
|
159
|
+
* legal envelope is a `Result` in the array, never a gap in it.
|
|
160
|
+
*/
|
|
161
|
+
decodeAllResult: (events, text) => {
|
|
162
|
+
const results = [];
|
|
163
|
+
for (const line of Line.split(text)) if (line.text.trim() !== "") results.push(Envelope.decodeResult(events, line));
|
|
164
|
+
return results;
|
|
165
|
+
},
|
|
166
|
+
/**
|
|
167
|
+
* Walk back from the end to the last valid **envelope**.
|
|
168
|
+
*
|
|
169
|
+
* This is the binding definition of "the last valid line", and the envelope
|
|
170
|
+
* layer is where it has to live. `Line.lastValid` stops at JSON validity,
|
|
171
|
+
* which is not enough: a torn scalar tail (`42` cut mid-write leaves `4`)
|
|
172
|
+
* parses cleanly as a different value, so a JSON-level walk-back would hand
|
|
173
|
+
* back corruption as the current state. An envelope is always an object,
|
|
174
|
+
* every strict prefix of an object text is invalid JSON, and a bare `4` is
|
|
175
|
+
* not an envelope — so this walk-back detects the torn tail that the one
|
|
176
|
+
* below it structurally cannot.
|
|
177
|
+
*
|
|
178
|
+
* Decoding stops at the first success, so the cost tracks the damage at the
|
|
179
|
+
* tail rather than the age of the file.
|
|
180
|
+
*
|
|
181
|
+
* @param events - The registry to validate against.
|
|
182
|
+
* @param text - JSONL source text.
|
|
183
|
+
* @returns The last valid envelope, or `Option.none()` if there is none.
|
|
184
|
+
*/
|
|
185
|
+
lastValidResult: (events, text) => {
|
|
186
|
+
const lines = Line.split(text);
|
|
187
|
+
for (let index = lines.length - 1; index >= 0; index--) {
|
|
188
|
+
const line = lines[index];
|
|
189
|
+
if (line === void 0 || line.text.trim() === "") continue;
|
|
190
|
+
const decoded = Envelope.decodeResult(events, line);
|
|
191
|
+
if (Result.isSuccess(decoded)) return Option.some(decoded.success);
|
|
192
|
+
}
|
|
193
|
+
return Option.none();
|
|
194
|
+
},
|
|
195
|
+
/**
|
|
196
|
+
* Encode one envelope into a complete JSONL line, terminator included.
|
|
197
|
+
*
|
|
198
|
+
* The trailing `\n` is part of the returned string on purpose: the
|
|
199
|
+
* cooperative-writer contract is one `writeAll` of a *complete line*, and an
|
|
200
|
+
* API that returned the text without its terminator would invite a caller to
|
|
201
|
+
* write the two separately — which is exactly the interleaving the contract
|
|
202
|
+
* exists to prevent.
|
|
203
|
+
*
|
|
204
|
+
* No `Schema.Union` is involved. The tag is known, so the payload schema is
|
|
205
|
+
* a direct registry lookup; a union would buy nothing and decode eagerly.
|
|
206
|
+
*
|
|
207
|
+
* @param events - The registry to validate against.
|
|
208
|
+
* @param envelope - The event tag, its payload, the service-assigned
|
|
209
|
+
* timestamp, and an optional scope.
|
|
210
|
+
*/
|
|
211
|
+
encodeResult: (events, envelope) => {
|
|
212
|
+
const empty = new LineSlice({
|
|
213
|
+
offset: 0,
|
|
214
|
+
end: 0,
|
|
215
|
+
length: 0,
|
|
216
|
+
text: "",
|
|
217
|
+
terminated: false
|
|
218
|
+
});
|
|
219
|
+
const definition = indexRegistry(events).get(envelope.event);
|
|
220
|
+
if (definition === void 0) return Result.fail(new UnknownEvent({
|
|
221
|
+
line: empty,
|
|
222
|
+
event: envelope.event,
|
|
223
|
+
known: events.map((e) => e.tag)
|
|
224
|
+
}));
|
|
225
|
+
const data = Schema.encodeUnknownResult(definition.data)(envelope.data);
|
|
226
|
+
if (Result.isFailure(data)) return Result.fail(new InvalidData({
|
|
227
|
+
line: empty,
|
|
228
|
+
event: Option.some(envelope.event),
|
|
229
|
+
error: data.failure
|
|
230
|
+
}));
|
|
231
|
+
const at = encodeAt(envelope.at);
|
|
232
|
+
if (Result.isFailure(at)) return Result.fail(new InvalidData({
|
|
233
|
+
line: empty,
|
|
234
|
+
event: Option.some(envelope.event),
|
|
235
|
+
error: at.failure
|
|
236
|
+
}));
|
|
237
|
+
let text;
|
|
238
|
+
try {
|
|
239
|
+
text = JSON.stringify({
|
|
240
|
+
at: at.success,
|
|
241
|
+
event: envelope.event,
|
|
242
|
+
...envelope.scope === void 0 ? {} : { scope: envelope.scope },
|
|
243
|
+
data: data.success === void 0 ? null : data.success
|
|
244
|
+
});
|
|
245
|
+
} catch (cause) {
|
|
246
|
+
return Result.fail(new UnserializableData({
|
|
247
|
+
event: envelope.event,
|
|
248
|
+
cause
|
|
249
|
+
}));
|
|
250
|
+
}
|
|
251
|
+
return Result.succeed(`${text}\n`);
|
|
252
|
+
},
|
|
253
|
+
/**
|
|
254
|
+
* {@link (Envelope:variable).decodeResult} in the `Effect` channel.
|
|
255
|
+
*
|
|
256
|
+
* Prefer the sync form in a hook or any caller that has no runtime; this
|
|
257
|
+
* exists for programs that already are one.
|
|
258
|
+
*/
|
|
259
|
+
decode: (events, line) => Effect.fromResult(Envelope.decodeResult(events, line)),
|
|
260
|
+
/**
|
|
261
|
+
* {@link (Envelope:variable).encodeResult} in the `Effect` channel.
|
|
262
|
+
*/
|
|
263
|
+
encode: (events, envelope) => Effect.fromResult(Envelope.encodeResult(events, envelope))
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
//#endregion
|
|
267
|
+
export { Envelope, EnvelopeFrame };
|