@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/Line.js CHANGED
@@ -1,60 +1,32 @@
1
- import { LineSlice } from "./LineSlice.js";
2
1
  import { MalformedLine } from "./JsonlError.js";
3
2
  import { utf8Length } from "./internal/utf8.js";
4
- import { Option, Result, Schema } from "effect";
3
+ import { Result } from "effect";
5
4
 
6
5
  //#region src/Line.ts
7
6
  /**
8
- * The pure, synchronous line layer of a JSONL journal.
7
+ * Whether a line carries nothing but whitespace.
9
8
  *
10
- * Nothing here touches `FileSystem`, builds an `Effect`, or needs a runtime —
11
- * a `PreToolUse` hook script reads the current state of a journal with one
12
- * call. That is a contract, not a convenience: a hook that had to construct an
13
- * Effect runtime to read one line would not adopt this package.
14
- *
15
- * @since 0.1.0
16
- */
17
- /**
18
- * A line that parsed as JSON, paired with the slice it came from.
19
- *
20
- * `value` is deliberately `unknown`: this layer knows JSON, not envelopes.
21
- * Validating `event`, `at`, `scope` and the registered payload schema is the
22
- * envelope layer's job, and keeping the split means a malformed *envelope* and
23
- * a malformed *line* stay distinguishable failures.
24
- *
25
- * @public
9
+ * @internal
26
10
  */
27
- var ParsedLine = class extends Schema.Class("ParsedLine")({
28
- /** Where this line lives in the source. */
29
- line: LineSlice,
30
- /** The parsed JSON value — any JSON value, not necessarily an object. */
31
- value: Schema.Unknown
32
- }) {};
33
- /** Whether a line carries nothing but whitespace. */
34
11
  const isBlank = (line) => line.text.trim() === "";
35
12
  /**
36
- * Splitting, parsing and corrupt-tail walk-back over JSONL text.
13
+ * Splitting and parsing JSONL text.
37
14
  *
38
- * Every operation is total and synchronous: it returns a value for every
39
- * input, including empty text, torn tails, and text that is not JSONL at all.
40
- * Nothing here throws — a `JSON.parse` failure becomes a {@link MalformedLine}
41
- * in the returned `Result`, because a journal is untrusted input and untrusted
42
- * input fails typed.
15
+ * Every operation is total and synchronous: nothing here throws. A `JSON.parse`
16
+ * failure becomes a {@link MalformedLine}, because a journal is untrusted input
17
+ * and untrusted input fails typed.
43
18
  *
44
19
  * @public
45
20
  */
46
- var Line = class Line {
21
+ var Line = class {
22
+ constructor() {}
47
23
  /**
48
24
  * The UTF-8 byte length of a string.
49
25
  *
50
- * Exposed because callers doing their own offset arithmetic must measure
51
- * the same way this module does. `String.length` is a different number for
52
- * any non-ASCII line.
53
- *
54
- * An unpaired surrogate counts as the 3 bytes of U+FFFD, matching what a
55
- * UTF-8 write actually emits. The offsets therefore stay true to the file
56
- * even though the character itself cannot survive the round trip — text
57
- * read back from a UTF-8 journal never contains one.
26
+ * Exposed because callers doing their own offset arithmetic must measure the
27
+ * same way this module does. `String.length` is a different number for any
28
+ * non-ASCII line. An unpaired surrogate counts as the 3 bytes of U+FFFD,
29
+ * matching what a UTF-8 write emits.
58
30
  *
59
31
  * @example
60
32
  * ```ts
@@ -70,150 +42,70 @@ var Line = class Line {
70
42
  /**
71
43
  * Split text into candidate lines with byte-exact offsets.
72
44
  *
73
- * Lines are separated by `\n`; a `\r` immediately preceding it is treated as
74
- * part of the terminator, so CRLF journals split identically to LF ones. A
75
- * bare `\r` is ordinary content, because only `\n` terminates.
76
- *
77
- * A trailing terminator does **not** produce a phantom empty final line, so
78
- * a well-formed journal yields exactly as many slices as it has entries.
79
- * Interior blank lines *are* returned — they are real bytes at real offsets,
80
- * and hiding them would make the offsets lie.
81
- *
82
- * Only the final slice can have `terminated: false`.
45
+ * Lines are separated by `\n`; a `\r` immediately preceding it is part of the
46
+ * terminator, so CRLF journals split identically to LF ones. A bare `\r` is
47
+ * ordinary content. A trailing terminator does **not** produce a phantom
48
+ * empty final line; interior blank lines *are* returned, because they are
49
+ * real bytes at real offsets. Only the final slice can be unterminated.
83
50
  *
84
51
  * @param text - JSONL source text.
85
- * @returns One {@link LineSlice} per candidate line, in source order.
52
+ * @param base - The byte offset `text` starts at in its file. Every offset
53
+ * returned is shifted by it, so text read from the middle of a journal
54
+ * yields offsets into the journal rather than into the text.
55
+ * @returns One {@link (LineSlice:type)} per candidate line, in source order.
86
56
  */
87
- static split(text) {
57
+ static split(text, base = 0) {
88
58
  const lines = [];
89
- let offset = 0;
59
+ let offset = base;
90
60
  let start = 0;
91
61
  for (;;) {
92
62
  const newline = text.indexOf("\n", start);
93
63
  if (newline === -1) break;
94
- const raw = text.slice(start, newline);
95
- const hadCarriageReturn = raw.endsWith("\r");
96
- const content = hadCarriageReturn ? raw.slice(0, -1) : raw;
64
+ const carriageReturn = newline > start && text.charCodeAt(newline - 1) === 13;
65
+ const content = text.slice(start, carriageReturn ? newline - 1 : newline);
97
66
  const length = utf8Length(content);
98
- const end = offset + length + (hadCarriageReturn ? 2 : 1);
99
- lines.push(new LineSlice({
67
+ const end = offset + length + (carriageReturn ? 2 : 1);
68
+ lines.push({
100
69
  offset,
101
70
  end,
102
71
  length,
103
72
  text: content,
104
73
  terminated: true
105
- }));
74
+ });
106
75
  offset = end;
107
76
  start = newline + 1;
108
77
  }
109
78
  if (start < text.length) {
110
79
  const content = text.slice(start);
111
80
  const length = utf8Length(content);
112
- lines.push(new LineSlice({
81
+ lines.push({
113
82
  offset,
114
83
  end: offset + length,
115
84
  length,
116
85
  text: content,
117
86
  terminated: false
118
- }));
87
+ });
119
88
  }
120
89
  return lines;
121
90
  }
122
91
  /**
123
- * The byte offset up to which this text has been fully consumed.
124
- *
125
- * This is the offset past the last **terminated** line. An unterminated
126
- * final line is left unconsumed on purpose: it may be a writer caught
127
- * mid-append, and re-reading from this offset once the file grows sees the
128
- * completed line rather than gluing a stale fragment to fresh bytes.
129
- *
130
- * @param text - JSONL source text.
131
- * @returns The resume cursor, in UTF-8 bytes.
132
- */
133
- static consumedOffset(text) {
134
- const last = Line.split(text).at(-1);
135
- if (last === void 0) return 0;
136
- return last.terminated ? last.end : last.offset;
137
- }
138
- /**
139
92
  * Parse one candidate line's JSON.
140
93
  *
141
- * Any JSON value succeeds — objects, arrays and scalars alike. This layer
142
- * does not know what an envelope is.
94
+ * Any JSON value succeeds — objects, arrays and scalars alike.
143
95
  *
144
96
  * @param line - A slice from {@link Line.split}.
145
- * @returns The {@link ParsedLine}, or a {@link MalformedLine} carrying the
146
- * slice so the caller can locate the damage and decide whether an
147
- * unterminated line is a torn tail worth waiting for.
97
+ * @returns The parsed value, or a {@link MalformedLine} carrying the slice so
98
+ * the caller can locate the damage. An unterminated malformed line is a
99
+ * torn tail; a terminated one is a hole that will never heal.
148
100
  */
149
101
  static parseResult(line) {
150
- let value;
151
102
  try {
152
- value = JSON.parse(line.text);
103
+ return Result.succeed(JSON.parse(line.text));
153
104
  } catch {
154
105
  return Result.fail(new MalformedLine({ line }));
155
106
  }
156
- return Result.succeed(new ParsedLine({
157
- line,
158
- value
159
- }));
160
- }
161
- /**
162
- * Parse every non-blank line, reporting failures rather than dropping them.
163
- *
164
- * There is deliberately no "valid lines only" variant. A hole in the middle
165
- * of a journal is information — it means a line was written that no reader
166
- * can interpret — and whether that is tolerable is the caller's decision,
167
- * not this module's. Filter the `Result`s yourself and the choice stays
168
- * visible at the call site.
169
- *
170
- * Whitespace-only lines are skipped rather than reported: they carry no
171
- * information to lose, and reporting them would make a hand-edited journal
172
- * look corrupt.
173
- *
174
- * @param text - JSONL source text.
175
- * @returns One `Result` per non-blank line, in source order.
176
- */
177
- static parseAll(text) {
178
- const results = [];
179
- for (const line of Line.split(text)) if (!isBlank(line)) results.push(Line.parseResult(line));
180
- return results;
181
- }
182
- /**
183
- * Walk back from the end to the last line that parses.
184
- *
185
- * This is the whole read path for a snapshot-style journal, where the
186
- * current state *is* the last valid line: a session killed mid-append leaves
187
- * a partial final line, and the walk-back steps over it — and over any
188
- * number of malformed or blank trailing lines — to the last line that means
189
- * something.
190
- *
191
- * Parsing stops at the first success, so the cost is proportional to the
192
- * damage at the tail rather than to the age of the file.
193
- *
194
- * **A torn tail is only detectable when its fragment fails to parse.**
195
- * Every strict prefix of a JSON *object* is invalid JSON, so a truncated
196
- * `{"event":"unlinked"}` is always caught. A truncated *scalar* is not:
197
- * `42` cut mid-write leaves `4`, which parses cleanly as a different value.
198
- * The JSON layer cannot close that hole — the envelope layer can, because
199
- * `4` is not an envelope, which is one more reason the envelope is the
200
- * package's contract rather than an option. Check `LineSlice.terminated` on
201
- * the result when it matters.
202
- *
203
- * @param text - JSONL source text.
204
- * @returns The last parseable line, or `Option.none()` if none parses.
205
- */
206
- static lastValid(text) {
207
- const lines = Line.split(text);
208
- for (let index = lines.length - 1; index >= 0; index--) {
209
- const line = lines[index];
210
- if (line === void 0 || isBlank(line)) continue;
211
- const parsed = Line.parseResult(line);
212
- if (Result.isSuccess(parsed)) return Option.some(parsed.success);
213
- }
214
- return Option.none();
215
107
  }
216
108
  };
217
109
 
218
110
  //#endregion
219
- export { Line, ParsedLine };
111
+ export { Line, isBlank };
package/LineSlice.js CHANGED
@@ -2,24 +2,22 @@ import { Schema } from "effect";
2
2
 
3
3
  //#region src/LineSlice.ts
4
4
  /**
5
- * One candidate line of a JSONL journal, located in the source by byte.
6
- *
7
- * @since 0.1.0
8
- */
9
- /**
10
5
  * A single candidate line: its text, and where it lives in the source **in
11
6
  * bytes**.
12
7
  *
13
- * Every offset on this class is a UTF-8 byte offset, never a UTF-16 code-unit
14
- * index, because these values are cursors into a file: they are handed to
15
- * `FileSystem.stream`'s `offset` option and persisted across process restarts.
16
- * A `String.length`-derived offset is correct only for ASCII journals and is
17
- * the single most likely bug in this module.
8
+ * Every offset here is a UTF-8 byte offset, never a UTF-16 code-unit index,
9
+ * because these values are cursors into a file that persist across process
10
+ * restarts. A `String.length`-derived offset is correct only for ASCII journals
11
+ * and is the single most likely bug in this module.
18
12
  *
19
13
  * The terminator is **not** part of the content: `text` and `length` exclude
20
14
  * the trailing `\n`, and exclude the `\r` of a `\r\n` pair. `end` includes it,
21
15
  * which is why `end - offset` is not always `length`.
22
16
  *
17
+ * A plain record rather than a class: `Line.split` produces one per line of
18
+ * every read, and a class instance costs some forty times a plain object on
19
+ * that path. The schema exists so errors can carry the line structurally.
20
+ *
23
21
  * @example
24
22
  * ```ts
25
23
  * import { Line } from "@effected/jsonl";
@@ -34,7 +32,7 @@ import { Schema } from "effect";
34
32
  *
35
33
  * @public
36
34
  */
37
- var LineSlice = class extends Schema.Class("LineSlice")({
35
+ const LineSlice = Schema.Struct({
38
36
  /** UTF-8 byte offset of this line's first content byte. */
39
37
  offset: Schema.Number,
40
38
  /**
@@ -44,7 +42,7 @@ var LineSlice = class extends Schema.Class("LineSlice")({
44
42
  * Equal to `offset + length` when the line is unterminated.
45
43
  */
46
44
  end: Schema.Number,
47
- /** UTF-8 byte length of `LineSlice.text`, excluding any terminator. */
45
+ /** UTF-8 byte length of `text`, excluding any terminator. */
48
46
  length: Schema.Number,
49
47
  /** The line's content, with its terminator and any paired `\r` removed. */
50
48
  text: Schema.String,
@@ -52,11 +50,10 @@ var LineSlice = class extends Schema.Class("LineSlice")({
52
50
  * Whether a `\n` terminated this line in the source.
53
51
  *
54
52
  * `false` can only occur on the final line, and means the line **may be a
55
- * torn tail** — a writer caught mid-append. A reader walks back over it and
56
- * leaves its bytes unconsumed so the next read sees the completed line.
53
+ * torn tail** — a writer caught mid-append.
57
54
  */
58
55
  terminated: Schema.Boolean
59
- }) {};
56
+ });
60
57
 
61
58
  //#endregion
62
59
  export { LineSlice };
package/README.md CHANGED
@@ -7,10 +7,10 @@
7
7
 
8
8
  Append-only, schema-validated JSONL journals as a definable Effect service. The subject is not the format — one JSON value per line is a two-sentence specification and needs no library — but the file as a live object: a journal that only ever grows, whose current state is its last valid line, whose tail may be torn mid-append, and which several processes read while one writes. Every line is an envelope (`at`, `event`, an optional `scope`, and a `data` payload validated by the schema you registered for that event), so a journal stays greppable, `jq`-able and diffable in a pull request while the reads that matter stay typed.
9
9
 
10
- > **Pre-release.** This package is part of the `@effected/*` kit, in pre-`1.0.0`
11
- > development against a single pinned Effect v4 prerelease. Packages graduate to
12
- > `1.0.0` once Effect `4.0.0` ships. To hold your own `effect` versions at
13
- > exactly the ones the kit is built and tested against, install
10
+ > **Pre-`1.0.0`.** This package is part of the `@effected/*` kit, built on stable
11
+ > Effect v4 (`effect` `^4.0.0`) and still in `0.x` development. Stable Effect
12
+ > makes a kit `1.0.0` possible, not automatic. To keep your `effect` and
13
+ > `@effect/*` versions on the line the kit is built and tested against, install
14
14
  > [`@effected/pnpm-plugin-effect`](https://www.npmjs.com/package/@effected/pnpm-plugin-effect).
15
15
  >
16
16
  > **Stability: unstable.** This package's API surface is not yet considered
package/Slice.js CHANGED
@@ -1,22 +1,16 @@
1
1
  //#region src/Slice.ts
2
2
  /**
3
- * Whether a frame satisfies a slice.
4
- *
5
- * Takes the **frame**, never a decoded envelope — that is what keeps matching
6
- * ahead of the payload schema on the read path.
3
+ * Whether frame fields satisfy a slice.
7
4
  *
8
5
  * @internal
9
6
  */
10
7
  const matchesFrame = (frame, slice) => {
11
8
  if (slice === void 0) return true;
12
9
  if (slice.events !== void 0 && !slice.events.includes(frame.event)) return false;
13
- if (slice.scopes !== void 0) {
14
- if (frame.scope === void 0 || !slice.scopes.includes(frame.scope)) return false;
15
- }
10
+ if (slice.scopes !== void 0 && (frame.scope === void 0 || !slice.scopes.includes(frame.scope))) return false;
16
11
  const millis = frame.at.epochMilliseconds;
17
12
  if (slice.from !== void 0 && millis < slice.from.epochMilliseconds) return false;
18
- if (slice.to !== void 0 && millis >= slice.to.epochMilliseconds) return false;
19
- return true;
13
+ return slice.to === void 0 || millis < slice.to.epochMilliseconds;
20
14
  };
21
15
 
22
16
  //#endregion