@effected/jsonl 0.9.0 → 0.11.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 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 { Effect, Option, Result, Schema } from "effect";
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 of the read path: the envelope with its payload left undecoded.
7
+ * Stage one: the envelope with its payload left undecoded.
9
8
  *
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
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 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.
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
- 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);
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 encodeAt = Schema.encodeUnknownResult(Schema.DateTimeUtcFromString);
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
- * 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.
60
+ * The one payload-decode path; every decode, filtered or not, goes through it.
57
61
  *
58
- * Both `decodeResult` and `decodeSelectedResult` call this, so there is exactly
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 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
+ * 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, Line } from "@effected/jsonl";
93
- * import { Result, Schema } from "effect";
95
+ * import { Envelope, JsonlEvent } from "@effected/jsonl";
96
+ * import { Option, Schema } from "effect";
94
97
  *
95
- * const events = [JsonlEvent.make("started", { data: Schema.Struct({ id: Schema.String }) })] as const;
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 (const line of Line.split(sourceText)) {
100
- * const decoded = Envelope.decodeResult(events, line);
101
- * if (Result.isSuccess(decoded) && decoded.success.event === "started") {
102
- * decoded.success.data.id; // typed from the registered payload schema
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
- * Stage one only: decode the frame, leaving `data` untouched.
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 typed reason it is not one: not JSON
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 = Envelope.frameResult(line);
148
- if (Result.isFailure(frame)) return Result.fail(frame.failure);
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
- * Same contract as `Line.parseAll` one layer down: a line that is not a
179
- * legal envelope is a `Result` in the array, never a gap in it.
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
- * Decoding stops at the first success, so the cost tracks the damage at the
203
- * tail rather than the age of the file.
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
- lastValidResult: (events, text) => {
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 === void 0 || line.text.trim() === "") continue;
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 `writeAll` of a *complete line*, and an
224
- * API that returned the text without its terminator would invite a caller to
225
- * write the two separately — which is exactly the interleaving the contract
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 service-assigned
233
- * timestamp, and an optional scope.
234
- * @returns The encoded line, or `UnknownEvent` for a tag the registry does
235
- * not define, `InvalidData` when the payload does not satisfy its schema,
236
- * or `UnserializableData` when the encoded payload cannot be turned into
237
- * JSON (a bigint or a reference cycle).
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.success,
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
- return Result.succeed(`${text}\n`);
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, EnvelopeFrame };
207
+ export { Envelope, completeResult, frameResult };