@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/Line.js CHANGED
@@ -1,50 +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
- * A line that parsed as JSON, paired with the slice it came from.
7
+ * Whether a line carries nothing but whitespace.
9
8
  *
10
- * `value` is deliberately `unknown`: this layer knows JSON, not envelopes.
11
- * Validating `event`, `at`, `scope` and the registered payload schema is the
12
- * envelope layer's job, and keeping the split means a malformed *envelope* and
13
- * a malformed *line* stay distinguishable failures.
14
- *
15
- * @public
9
+ * @internal
16
10
  */
17
- var ParsedLine = class extends Schema.Class("ParsedLine")({
18
- /** Where this line lives in the source. */
19
- line: LineSlice,
20
- /** The parsed JSON value — any JSON value, not necessarily an object. */
21
- value: Schema.Unknown
22
- }) {};
23
- /** Whether a line carries nothing but whitespace. */
24
11
  const isBlank = (line) => line.text.trim() === "";
25
12
  /**
26
- * Splitting, parsing and corrupt-tail walk-back over JSONL text.
13
+ * Splitting and parsing JSONL text.
27
14
  *
28
- * Every operation is total and synchronous: it returns a value for every
29
- * input, including empty text, torn tails, and text that is not JSONL at all.
30
- * Nothing here throws — a `JSON.parse` failure becomes a {@link MalformedLine}
31
- * in the returned `Result`, because a journal is untrusted input and untrusted
32
- * 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.
33
18
  *
34
19
  * @public
35
20
  */
36
- var Line = class Line {
21
+ var Line = class {
22
+ constructor() {}
37
23
  /**
38
24
  * The UTF-8 byte length of a string.
39
25
  *
40
- * Exposed because callers doing their own offset arithmetic must measure
41
- * the same way this module does. `String.length` is a different number for
42
- * any non-ASCII line.
43
- *
44
- * An unpaired surrogate counts as the 3 bytes of U+FFFD, matching what a
45
- * UTF-8 write actually emits. The offsets therefore stay true to the file
46
- * even though the character itself cannot survive the round trip — text
47
- * 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.
48
30
  *
49
31
  * @example
50
32
  * ```ts
@@ -60,150 +42,70 @@ var Line = class Line {
60
42
  /**
61
43
  * Split text into candidate lines with byte-exact offsets.
62
44
  *
63
- * Lines are separated by `\n`; a `\r` immediately preceding it is treated as
64
- * part of the terminator, so CRLF journals split identically to LF ones. A
65
- * bare `\r` is ordinary content, because only `\n` terminates.
66
- *
67
- * A trailing terminator does **not** produce a phantom empty final line, so
68
- * a well-formed journal yields exactly as many slices as it has entries.
69
- * Interior blank lines *are* returned — they are real bytes at real offsets,
70
- * and hiding them would make the offsets lie.
71
- *
72
- * 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.
73
50
  *
74
51
  * @param text - JSONL source text.
75
- * @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.
76
56
  */
77
- static split(text) {
57
+ static split(text, base = 0) {
78
58
  const lines = [];
79
- let offset = 0;
59
+ let offset = base;
80
60
  let start = 0;
81
61
  for (;;) {
82
62
  const newline = text.indexOf("\n", start);
83
63
  if (newline === -1) break;
84
- const raw = text.slice(start, newline);
85
- const hadCarriageReturn = raw.endsWith("\r");
86
- 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);
87
66
  const length = utf8Length(content);
88
- const end = offset + length + (hadCarriageReturn ? 2 : 1);
89
- lines.push(new LineSlice({
67
+ const end = offset + length + (carriageReturn ? 2 : 1);
68
+ lines.push({
90
69
  offset,
91
70
  end,
92
71
  length,
93
72
  text: content,
94
73
  terminated: true
95
- }));
74
+ });
96
75
  offset = end;
97
76
  start = newline + 1;
98
77
  }
99
78
  if (start < text.length) {
100
79
  const content = text.slice(start);
101
80
  const length = utf8Length(content);
102
- lines.push(new LineSlice({
81
+ lines.push({
103
82
  offset,
104
83
  end: offset + length,
105
84
  length,
106
85
  text: content,
107
86
  terminated: false
108
- }));
87
+ });
109
88
  }
110
89
  return lines;
111
90
  }
112
91
  /**
113
- * The byte offset up to which this text has been fully consumed.
114
- *
115
- * This is the offset past the last **terminated** line. An unterminated
116
- * final line is left unconsumed on purpose: it may be a writer caught
117
- * mid-append, and re-reading from this offset once the file grows sees the
118
- * completed line rather than gluing a stale fragment to fresh bytes.
119
- *
120
- * @param text - JSONL source text.
121
- * @returns The resume cursor, in UTF-8 bytes.
122
- */
123
- static consumedOffset(text) {
124
- const last = Line.split(text).at(-1);
125
- if (last === void 0) return 0;
126
- return last.terminated ? last.end : last.offset;
127
- }
128
- /**
129
92
  * Parse one candidate line's JSON.
130
93
  *
131
- * Any JSON value succeeds — objects, arrays and scalars alike. This layer
132
- * does not know what an envelope is.
94
+ * Any JSON value succeeds — objects, arrays and scalars alike.
133
95
  *
134
96
  * @param line - A slice from {@link Line.split}.
135
- * @returns The {@link ParsedLine}, or a {@link MalformedLine} carrying the
136
- * slice so the caller can locate the damage and decide whether an
137
- * 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.
138
100
  */
139
101
  static parseResult(line) {
140
- let value;
141
102
  try {
142
- value = JSON.parse(line.text);
103
+ return Result.succeed(JSON.parse(line.text));
143
104
  } catch {
144
105
  return Result.fail(new MalformedLine({ line }));
145
106
  }
146
- return Result.succeed(new ParsedLine({
147
- line,
148
- value
149
- }));
150
- }
151
- /**
152
- * Parse every non-blank line, reporting failures rather than dropping them.
153
- *
154
- * There is deliberately no "valid lines only" variant. A hole in the middle
155
- * of a journal is information — it means a line was written that no reader
156
- * can interpret — and whether that is tolerable is the caller's decision,
157
- * not this module's. Filter the `Result`s yourself and the choice stays
158
- * visible at the call site.
159
- *
160
- * Whitespace-only lines are skipped rather than reported: they carry no
161
- * information to lose, and reporting them would make a hand-edited journal
162
- * look corrupt.
163
- *
164
- * @param text - JSONL source text.
165
- * @returns One `Result` per non-blank line, in source order.
166
- */
167
- static parseAll(text) {
168
- const results = [];
169
- for (const line of Line.split(text)) if (!isBlank(line)) results.push(Line.parseResult(line));
170
- return results;
171
- }
172
- /**
173
- * Walk back from the end to the last line that parses.
174
- *
175
- * This is the whole read path for a snapshot-style journal, where the
176
- * current state *is* the last valid line: a session killed mid-append leaves
177
- * a partial final line, and the walk-back steps over it — and over any
178
- * number of malformed or blank trailing lines — to the last line that means
179
- * something.
180
- *
181
- * Parsing stops at the first success, so the cost is proportional to the
182
- * damage at the tail rather than to the age of the file.
183
- *
184
- * **A torn tail is only detectable when its fragment fails to parse.**
185
- * Every strict prefix of a JSON *object* is invalid JSON, so a truncated
186
- * `{"event":"unlinked"}` is always caught. A truncated *scalar* is not:
187
- * `42` cut mid-write leaves `4`, which parses cleanly as a different value.
188
- * The JSON layer cannot close that hole — the envelope layer can, because
189
- * `4` is not an envelope, which is one more reason the envelope is the
190
- * package's contract rather than an option. Check `LineSlice.terminated` on
191
- * the result when it matters.
192
- *
193
- * @param text - JSONL source text.
194
- * @returns The last parseable line, or `Option.none()` if none parses.
195
- */
196
- static lastValid(text) {
197
- const lines = Line.split(text);
198
- for (let index = lines.length - 1; index >= 0; index--) {
199
- const line = lines[index];
200
- if (line === void 0 || isBlank(line)) continue;
201
- const parsed = Line.parseResult(line);
202
- if (Result.isSuccess(parsed)) return Option.some(parsed.success);
203
- }
204
- return Option.none();
205
107
  }
206
108
  };
207
109
 
208
110
  //#endregion
209
- export { Line, ParsedLine };
111
+ export { Line, isBlank };
package/LineSlice.js CHANGED
@@ -5,16 +5,19 @@ import { Schema } from "effect";
5
5
  * A single candidate line: its text, and where it lives in the source **in
6
6
  * bytes**.
7
7
  *
8
- * Every offset on this class is a UTF-8 byte offset, never a UTF-16 code-unit
9
- * index, because these values are cursors into a file: they are handed to
10
- * `FileSystem.stream`'s `offset` option and persisted across process restarts.
11
- * A `String.length`-derived offset is correct only for ASCII journals and is
12
- * 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.
13
12
  *
14
13
  * The terminator is **not** part of the content: `text` and `length` exclude
15
14
  * the trailing `\n`, and exclude the `\r` of a `\r\n` pair. `end` includes it,
16
15
  * which is why `end - offset` is not always `length`.
17
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
+ *
18
21
  * @example
19
22
  * ```ts
20
23
  * import { Line } from "@effected/jsonl";
@@ -29,7 +32,7 @@ import { Schema } from "effect";
29
32
  *
30
33
  * @public
31
34
  */
32
- var LineSlice = class extends Schema.Class("LineSlice")({
35
+ const LineSlice = Schema.Struct({
33
36
  /** UTF-8 byte offset of this line's first content byte. */
34
37
  offset: Schema.Number,
35
38
  /**
@@ -39,7 +42,7 @@ var LineSlice = class extends Schema.Class("LineSlice")({
39
42
  * Equal to `offset + length` when the line is unterminated.
40
43
  */
41
44
  end: Schema.Number,
42
- /** UTF-8 byte length of `LineSlice.text`, excluding any terminator. */
45
+ /** UTF-8 byte length of `text`, excluding any terminator. */
43
46
  length: Schema.Number,
44
47
  /** The line's content, with its terminator and any paired `\r` removed. */
45
48
  text: Schema.String,
@@ -47,11 +50,10 @@ var LineSlice = class extends Schema.Class("LineSlice")({
47
50
  * Whether a `\n` terminated this line in the source.
48
51
  *
49
52
  * `false` can only occur on the final line, and means the line **may be a
50
- * torn tail** — a writer caught mid-append. A reader walks back over it and
51
- * leaves its bytes unconsumed so the next read sees the completed line.
53
+ * torn tail** — a writer caught mid-append.
52
54
  */
53
55
  terminated: Schema.Boolean
54
- }) {};
56
+ });
55
57
 
56
58
  //#endregion
57
59
  export { LineSlice };
@@ -0,0 +1,104 @@
1
+ import { JournalWatcher } from "./JournalWatcher.js";
2
+ import { Cause, Effect, Layer, PlatformError, Queue, Stream } from "effect";
3
+ import * as NFS from "node:fs";
4
+
5
+ //#region src/NodeJournalWatcher.ts
6
+ /** The `SystemErrorTag` an errno code maps to, after core's Node filesystem mapping. */
7
+ const reasonOf = (code) => {
8
+ switch (code) {
9
+ case "ENOENT": return "NotFound";
10
+ case "EACCES":
11
+ case "EPERM": return "PermissionDenied";
12
+ case "ENOTDIR":
13
+ case "ELOOP": return "BadResource";
14
+ default: return "Unknown";
15
+ }
16
+ };
17
+ const watchError = (path, cause) => PlatformError.systemError({
18
+ _tag: reasonOf(cause?.code),
19
+ module: "FileSystem",
20
+ method: "watch",
21
+ pathOrDescriptor: path,
22
+ syscall: "watch",
23
+ cause
24
+ });
25
+ /** The identity a watched file is armed on, or `undefined` once the path names nothing. */
26
+ const identityAt = (path) => {
27
+ try {
28
+ const info = NFS.statSync(path);
29
+ return `${info.dev}:${info.ino}`;
30
+ } catch {
31
+ return;
32
+ }
33
+ };
34
+ /**
35
+ * Register `fs.watch` synchronously, inside the acquire — so when the effect
36
+ * succeeds the platform watch is live, which is the contract
37
+ * {@link Jsonl.JournalWatcherShape.watch} states and core's
38
+ * `FileSystem.watch` cannot.
39
+ *
40
+ * A file watch follows the inode it was armed on, and Node emits neither
41
+ * `close` nor `error` when that inode is unlinked or renamed over: left
42
+ * alone, the stream would outlive the file and the journal would follow a
43
+ * dead inode forever. So every event on a file re-checks the path's identity,
44
+ * and the stream ends — after delivering the event — once the path is gone or
45
+ * names a different file. A directory watch never ends that way: a sibling
46
+ * appearing or vanishing is routine there.
47
+ */
48
+ const watch = (path) => Effect.gen(function* () {
49
+ const queue = yield* Queue.unbounded();
50
+ yield* Effect.acquireRelease(Effect.try({
51
+ try: () => {
52
+ const armed = NFS.statSync(path);
53
+ const identity = armed.isDirectory() ? void 0 : `${armed.dev}:${armed.ino}`;
54
+ const watcher = NFS.watch(path, (_event, name) => {
55
+ Queue.offerUnsafe(queue, name ?? void 0);
56
+ if (identity !== void 0 && identityAt(path) !== identity) Queue.endUnsafe(queue);
57
+ });
58
+ watcher.on("error", (cause) => {
59
+ Queue.failCauseUnsafe(queue, Cause.fail(watchError(path, cause)));
60
+ });
61
+ watcher.on("close", () => {
62
+ Queue.endUnsafe(queue);
63
+ });
64
+ return watcher;
65
+ },
66
+ catch: (cause) => watchError(path, cause)
67
+ }), (watcher) => Effect.sync(() => watcher.close()));
68
+ return Stream.fromQueue(queue);
69
+ });
70
+ /**
71
+ * The Node implementation of {@link Jsonl.JournalWatcher}, over `node:fs`'s
72
+ * `watch`.
73
+ *
74
+ * Registration happens synchronously while the watch effect runs, so a
75
+ * journal's catch-up read after arming can never miss an append that the
76
+ * watch also fails to report.
77
+ *
78
+ * @example
79
+ * ```ts
80
+ * import { Journal, JsonlEvent } from "@effected/jsonl";
81
+ * import { NodeJournalWatcher } from "@effected/jsonl/node";
82
+ * import { NodeFileSystem } from "@effect/platform-node";
83
+ * import { Effect, Schema } from "effect";
84
+ *
85
+ * const events = [JsonlEvent.make("mail", { data: Schema.Struct({ round: Schema.Number }) })] as const;
86
+ *
87
+ * class Mail extends Journal.Service<Mail>()("app/Mail", { events, config: { path: ".app/mail.jsonl" } }) {}
88
+ *
89
+ * const program = Effect.gen(function* () {
90
+ * const mail = yield* Mail;
91
+ * yield* mail.append("mail", { round: 1 });
92
+ * }).pipe(Effect.provide(Mail.layer), Effect.provide([NodeFileSystem.layer, NodeJournalWatcher.layer]));
93
+ * ```
94
+ *
95
+ * @public
96
+ */
97
+ var NodeJournalWatcher = class {
98
+ constructor() {}
99
+ /** Provides {@link Jsonl.JournalWatcher} over `node:fs`. */
100
+ static layer = Layer.succeed(JournalWatcher, { watch });
101
+ };
102
+
103
+ //#endregion
104
+ export { NodeJournalWatcher };
package/README.md CHANGED
@@ -41,7 +41,7 @@ Requires Node.js >=24.11.0. `effect` v4 is the only peer dependency, and the onl
41
41
 
42
42
  All `@effected/*` packages are ESM-only: the exports maps publish only `import` conditions, so `require()` — including tools that resolve in CJS mode — fails with Node's `ERR_PACKAGE_PATH_NOT_EXPORTED` rather than loading a CJS build that does not exist. Import from an ES module.
43
43
 
44
- `FileSystem` comes from `effect` core, not from a platform package, so a consumer provides it once at the edge (`NodeFileSystem.layer` from `@effect/platform-node` on Node). `Path` is deliberately not required — every path is an opaque string handed straight to `FileSystem`, and this package never joins, resolves or splits one.
44
+ `FileSystem` comes from `effect` core, not from a platform package, so a consumer provides it once at the edge (`NodeFileSystem.layer` from `@effect/platform-node` on Node). The journal also needs a `JournalWatcher` — a file watch that reports when it is armed, so an append cannot land unseen while the watch is being set up; on Node that is `NodeJournalWatcher.layer` from the `@effected/jsonl/node` subpath, the only part of the package that touches `node:*`. `Path` is deliberately not required — every path is an opaque string handed straight to `FileSystem`, and this package never joins, resolves or splits one.
45
45
 
46
46
  ## Quick start
47
47
 
@@ -49,6 +49,7 @@ Declare the events a journal may contain, mint a service class for that registry
49
49
 
50
50
  ```ts
51
51
  import { Journal, JsonlEvent } from "@effected/jsonl";
52
+ import { NodeJournalWatcher } from "@effected/jsonl/node";
52
53
  import { NodeFileSystem } from "@effect/platform-node";
53
54
  import { Effect, Option, Schema, Stream, SubscriptionRef } from "effect";
54
55
 
@@ -90,7 +91,7 @@ const program = Effect.gen(function* () {
90
91
  );
91
92
  });
92
93
 
93
- Effect.runPromise(program.pipe(Effect.provide(MailJournalLive), Effect.provide(NodeFileSystem.layer)));
94
+ Effect.runPromise(program.pipe(Effect.provide(MailJournalLive), Effect.provide([NodeFileSystem.layer, NodeJournalWatcher.layer])));
94
95
  ```
95
96
 
96
97
  `events: ["mail-received"]` narrows the stream's element type to that variant, so a projection over a slice is exhaustively checkable rather than a cast. An event marked `terminal` makes the journal quiescent once it reaches the tail: further appends fail typed unless the event is marked `reopen`, and every `changes` stream ends rather than hanging — including one whose slice excludes the terminal event, and including a subscriber that attaches after the fact.
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