@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 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 };