@effected/jsonl 0.8.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 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,116 +80,80 @@ 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.
92
+ *
93
+ * @example
94
+ * ```ts
95
+ * import { Envelope, JsonlEvent } from "@effected/jsonl";
96
+ * import { Option, Schema } from "effect";
97
+ *
98
+ * const events = [JsonlEvent.make("state", { data: Schema.Struct({ round: Schema.Number }) })] as const;
99
+ *
100
+ * declare const sourceText: string;
101
+ *
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
106
+ * }
107
+ * ```
89
108
  *
90
109
  * @public
91
110
  */
92
111
  const Envelope = {
93
112
  /**
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.
113
+ * Decode one line into a fully validated envelope.
118
114
  *
119
115
  * @param events - The registry defining which tags are legal and what their
120
116
  * payloads must look like.
121
117
  * @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`).
118
+ * @returns The envelope, or why the line is not one.
125
119
  */
126
120
  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);
121
+ const frame = frameResult(line);
122
+ return Result.isFailure(frame) ? Result.fail(frame.failure) : completeResult(events, line, frame.success);
130
123
  },
131
124
  /**
132
- * Decode a line only if its **frame** is selected, running the payload schema
133
- * for selected lines only.
125
+ * Decode every non-blank line, reporting failures rather than dropping them.
134
126
  *
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.
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.
141
129
  *
142
130
  * @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.
131
+ * @param text - JSONL source text.
132
+ * @param base - The byte offset `text` starts at in its file.
133
+ * @returns One `Result` per non-blank line, in file order.
160
134
  */
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
- },
135
+ decodeAllResult: (events, text, base = 0) => Line.split(text, base).filter((line) => !isBlank(line)).map((line) => Envelope.decodeResult(events, line)),
166
136
  /**
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.
137
+ * Walk back from the end to the last valid **envelope** — the journal's
138
+ * current state.
177
139
  *
178
- * Decoding stops at the first success, so the cost tracks the damage at the
179
- * 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.
180
145
  *
181
146
  * @param events - The registry to validate against.
182
- * @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.
183
150
  * @returns The last valid envelope, or `Option.none()` if there is none.
184
151
  */
185
- lastValidResult: (events, text) => {
186
- const lines = Line.split(text);
152
+ lastValid: (events, text, base = 0) => {
153
+ const lines = Line.split(text, base);
187
154
  for (let index = lines.length - 1; index >= 0; index--) {
188
155
  const line = lines[index];
189
- if (line === void 0 || line.text.trim() === "") continue;
156
+ if (isBlank(line)) continue;
190
157
  const decoded = Envelope.decodeResult(events, line);
191
158
  if (Result.isSuccess(decoded)) return Option.some(decoded.success);
192
159
  }
@@ -196,72 +163,45 @@ const Envelope = {
196
163
  * Encode one envelope into a complete JSONL line, terminator included.
197
164
  *
198
165
  * 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.
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.
206
169
  *
207
170
  * @param events - The registry to validate against.
208
- * @param envelope - The event tag, its payload, the service-assigned
209
- * timestamp, and an optional scope.
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.
210
177
  */
211
178
  encodeResult: (events, envelope) => {
212
- const empty = new LineSlice({
213
- offset: 0,
214
- end: 0,
215
- length: 0,
216
- text: "",
217
- terminated: false
218
- });
219
179
  const definition = indexRegistry(events).get(envelope.event);
220
180
  if (definition === void 0) return Result.fail(new UnknownEvent({
221
- line: empty,
222
181
  event: envelope.event,
223
182
  known: events.map((e) => e.tag)
224
183
  }));
225
184
  const data = Schema.encodeUnknownResult(definition.data)(envelope.data);
226
185
  if (Result.isFailure(data)) return Result.fail(new InvalidData({
227
- line: empty,
228
186
  event: Option.some(envelope.event),
229
187
  error: data.failure
230
188
  }));
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
189
  try {
239
- text = JSON.stringify({
240
- at: at.success,
190
+ const text = JSON.stringify({
191
+ at: DateTime.formatIso(envelope.at),
241
192
  event: envelope.event,
242
193
  ...envelope.scope === void 0 ? {} : { scope: envelope.scope },
243
194
  data: data.success === void 0 ? null : data.success
244
195
  });
196
+ return Result.succeed(`${text}\n`);
245
197
  } catch (cause) {
246
198
  return Result.fail(new UnserializableData({
247
199
  event: envelope.event,
248
200
  cause
249
201
  }));
250
202
  }
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))
203
+ }
264
204
  };
265
205
 
266
206
  //#endregion
267
- export { Envelope, EnvelopeFrame };
207
+ export { Envelope, completeResult, frameResult };