@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/internal/tail.js CHANGED
@@ -1,4 +1,4 @@
1
- import { ByteSize, Effect, Option } from "effect";
1
+ import { ByteSize, Effect, Option, Stream } from "effect";
2
2
 
3
3
  //#region src/internal/tail.ts
4
4
  /** UTF-8 BOM, as bytes. `U+FEFF` encodes to these three. */
@@ -10,134 +10,217 @@ const BOM = [
10
10
  /** Byte value of `\n`. Cannot occur inside a UTF-8 multi-byte sequence. */
11
11
  const LF = 10;
12
12
  /**
13
- * The default tail window.
14
- *
15
- * Large enough that a snapshot journal's last line is almost always inside it
16
- * on the first read, small enough that reading it is cheap against a journal of
17
- * any age. When it misses, {@link readTail} widens rather than failing.
13
+ * The first tail window. Large enough that a snapshot journal's last line is
14
+ * almost always inside it, small enough to be cheap against a journal of any
15
+ * age.
18
16
  *
19
17
  * @internal
20
18
  */
21
19
  const DEFAULT_WINDOW = 8192;
22
- /** Does the buffer begin with a UTF-8 BOM? */
23
- const hasBom = (bytes) => bytes.length >= 3 && bytes[0] === BOM[0] && bytes[1] === BOM[1] && bytes[2] === BOM[2];
24
20
  /**
25
- * Probe the first three bytes of a file for a BOM.
21
+ * The largest window a single tail read allocates.
26
22
  *
27
- * This is a property of the FILE, so it is read once from the start rather than
28
- * inferred from whatever window happens to be in hand. Inferring it from window
29
- * position was a real defect: a BOM'd journal larger than the window never has
30
- * a window at offset 0, so every offset it emitted was physical — silently off
31
- * by three against a file the package itself had described as post-BOM.
23
+ * A regression fence: the historical read once sized a "tail" window to its
24
+ * whole region. A caller needing more than this pages for it.
32
25
  *
33
26
  * @internal
34
27
  */
35
- const probeBomBytes = (fs, path) => Effect.gen(function* () {
36
- const head = yield* (yield* fs.open(path, { flag: "r" })).readAlloc(BOM.length);
37
- const bytes = Option.getOrElse(head, () => /* @__PURE__ */ new Uint8Array(0));
38
- return hasBom(bytes) ? BOM.length : 0;
39
- }).pipe(Effect.scoped);
28
+ const MAX_WINDOW = 1048576;
40
29
  /**
41
- * Read the last `window` bytes of a journal, decoded from a line boundary.
42
- *
43
- * Three disciplines, each load-bearing:
30
+ * The forward page size of {@link readLinePages}.
44
31
  *
45
- * 1. **Start at a line boundary.** Unless the window reaches offset 0, the
46
- * leading partial line is discarded — everything up to and including the
47
- * first `\n`. This is also what makes the decode safe across a chunk
48
- * boundary: `\n` is `0x0A` and **cannot** appear inside a UTF-8 multi-byte
49
- * sequence, so a window that starts just past one starts on a character
50
- * boundary too. No separate mid-character guard is needed.
51
- * 2. **Strip exactly one leading BOM, at the byte level**, and only when the
52
- * window genuinely starts at file offset 0. Reads here are offset-based, so
53
- * an implicit strip (as `readFileString` performs) would desynchronize every
54
- * offset the package emits; doing it explicitly keeps the convention stated.
55
- * A `U+FEFF` anywhere else is content and is left alone.
56
- * 3. **Never read the whole file** unless the file is smaller than the window.
32
+ * @internal
33
+ */
34
+ const PAGE_SIZE = 65536;
35
+ const EMPTY = /* @__PURE__ */ new Uint8Array(0);
36
+ const decoder = new TextDecoder();
37
+ /** Join byte chunks with one allocation. */
38
+ const join = (chunks) => {
39
+ if (chunks.length === 1) return chunks[0];
40
+ let length = 0;
41
+ for (const chunk of chunks) length += chunk.length;
42
+ const joined = new Uint8Array(length);
43
+ let at = 0;
44
+ for (const chunk of chunks) {
45
+ joined.set(chunk, at);
46
+ at += chunk.length;
47
+ }
48
+ return joined;
49
+ };
50
+ /** Read `[from, to)` (physical) from an open handle. */
51
+ const readAt = (file, from, to) => Effect.gen(function* () {
52
+ yield* file.seek(BigInt(from), "start");
53
+ return Option.getOrElse(yield* file.readAlloc(to - from), () => EMPTY);
54
+ });
55
+ /**
56
+ * Probe the first three bytes of a file for a BOM.
57
57
  *
58
- * **`window` is not clamped**, deliberately. `Journal`'s historical read sizes
59
- * its window to the region it has to return, so a clamp here would silently
60
- * truncate that read rather than bound it. The clamp belongs with the paged
61
- * rewrite of that read — one that emits per window and can therefore honour a
62
- * maximum — and is carried on spencerbeggs/effected#233, not added underneath
63
- * it.
58
+ * A property of the FILE, read once from the start — never inferred from a
59
+ * window's position, which for a journal larger than the window never reaches
60
+ * offset 0.
64
61
  *
65
62
  * @internal
66
63
  */
67
- const readTail = (fs, path, window, bomBytes) => Effect.gen(function* () {
68
- const info = yield* fs.stat(path);
69
- const physicalSize = ByteSize.toNumberUnsafe(info.size);
70
- const logicalSize = physicalSize - bomBytes;
71
- const from = Math.max(bomBytes, physicalSize - window);
64
+ const probeBomBytes = (fs, path) => Effect.gen(function* () {
72
65
  const file = yield* fs.open(path, { flag: "r" });
73
- yield* file.seek(BigInt(from), "start");
74
- const read = yield* file.readAlloc(physicalSize - from);
75
- const bytes = Option.getOrElse(read, () => /* @__PURE__ */ new Uint8Array(0));
76
- const windowAtFileStart = from === bomBytes;
66
+ const head = yield* readAt(file, 0, BOM.length);
67
+ return head.length >= 3 && head[0] === BOM[0] && head[1] === BOM[1] && head[2] === BOM[2] ? BOM.length : 0;
68
+ }).pipe(Effect.scoped);
69
+ /**
70
+ * Read the window of at most {@link MAX_WINDOW} bytes ending at physical `end`,
71
+ * decoded from a line boundary.
72
+ *
73
+ * Unless the window reaches the start of the content, its leading partial line
74
+ * is discarded — through the first `\n`, which cannot occur inside a UTF-8
75
+ * multi-byte sequence, so the decode starts on a character boundary too. A BOM
76
+ * is excluded by starting the read past it, never by an implicit strip.
77
+ */
78
+ const readWindow = (file, end, window, bomBytes) => Effect.gen(function* () {
79
+ const from = Math.max(bomBytes, end - Math.min(window, MAX_WINDOW));
80
+ const bytes = yield* readAt(file, from, end);
81
+ const atFileStart = from === bomBytes;
77
82
  let cursor = 0;
78
- if (!windowAtFileStart) {
83
+ if (!atFileStart) {
79
84
  const newline = bytes.indexOf(LF);
80
85
  cursor = newline === -1 ? bytes.length : newline + 1;
81
86
  }
82
87
  return {
83
- text: new TextDecoder().decode(bytes.subarray(cursor)),
88
+ text: decoder.decode(bytes.subarray(cursor)),
84
89
  start: from + cursor - bomBytes,
85
- atFileStart: windowAtFileStart,
86
- size: logicalSize
90
+ atFileStart
87
91
  };
88
- }).pipe(Effect.scoped);
92
+ });
93
+ /**
94
+ * Read the one line that ends at physical `end`, however long it is, in
95
+ * clamped steps backward to the newline before it.
96
+ *
97
+ * The escape hatch for a window with no line boundary inside it: the line is
98
+ * longer than the window, and the clamp forbids asking for a bigger one.
99
+ */
100
+ const readLineEnding = (file, end, bomBytes) => Effect.gen(function* () {
101
+ const chunks = [];
102
+ let position = end;
103
+ for (;;) {
104
+ const from = Math.max(bomBytes, position - MAX_WINDOW);
105
+ const bytes = yield* readAt(file, from, position);
106
+ const searchFrom = position === end ? bytes.length - 2 : bytes.length - 1;
107
+ const newline = searchFrom < 0 ? -1 : bytes.lastIndexOf(LF, searchFrom);
108
+ if (newline !== -1) {
109
+ chunks.unshift(bytes.subarray(newline + 1));
110
+ return {
111
+ text: decoder.decode(join(chunks)),
112
+ start: from + newline + 1 - bomBytes,
113
+ atFileStart: false
114
+ };
115
+ }
116
+ chunks.unshift(bytes);
117
+ if (from === bomBytes) return {
118
+ text: decoder.decode(join(chunks)),
119
+ start: 0,
120
+ atFileStart: true
121
+ };
122
+ position = from;
123
+ }
124
+ });
89
125
  /**
90
- * Read widening windows until `decode` finds something, or the whole file has
91
- * been seen.
126
+ * Search backward from the end of the journal, one window at a time, until
127
+ * `decode` finds something or the whole file has been seen.
92
128
  *
93
- * The widening is what makes the bounded read *correct* rather than merely
94
- * cheap: a journal whose last line is longer than the initial window would
95
- * otherwise report "no valid envelope" for a perfectly healthy file.
129
+ * Each window covers only bytes no earlier one did — it ends at the line
130
+ * boundary where the previous one began — and the windows grow from
131
+ * `initialWindow` by fours up to {@link MAX_WINDOW}. `decode` sees each window
132
+ * on its own: "the last match in this window" is the last match in the file
133
+ * only because every later window has already answered none.
96
134
  *
97
135
  * @internal
98
136
  */
99
137
  const readTailUntil = (fs, path, bomBytes, decode, initialWindow = DEFAULT_WINDOW) => Effect.gen(function* () {
138
+ const file = yield* fs.open(path, { flag: "r" });
139
+ let end = ByteSize.toNumberUnsafe((yield* file.stat).size);
100
140
  let window = initialWindow;
101
141
  for (;;) {
102
- const tail = yield* readTail(fs, path, window, bomBytes);
142
+ let tail = yield* readWindow(file, end, window, bomBytes);
143
+ if (tail.text === "" && !tail.atFileStart && tail.start + bomBytes === end) tail = yield* readLineEnding(file, end, bomBytes);
103
144
  const found = decode(tail);
104
- if (Option.isSome(found)) return found;
105
- if (tail.atFileStart) return Option.none();
106
- window *= 4;
145
+ if (Option.isSome(found) || tail.atFileStart) return found;
146
+ end = tail.start + bomBytes;
147
+ window = Math.min(window * 4, MAX_WINDOW);
107
148
  }
108
- });
149
+ }).pipe(Effect.scoped);
109
150
  /**
110
- * Read a byte range and decode it as text, safely across chunk boundaries.
151
+ * Read the logical region `[from, end)` forward, in pages, as complete lines.
111
152
  *
112
- * The byte→string seam lives here, in the service layer, because `Line.split`
113
- * is string-in by design and the pure core must never learn about buffers.
153
+ * Each pull reads at most `pageSize` bytes, emits every line that page
154
+ * completes, and carries the trailing unterminated fragment into the next pull
155
+ * — so a consumer that stops early never pays for the rest, and memory is a
156
+ * page plus the longest line. Pages are cut at `\n`, so each decodes on its
157
+ * own: a character a raw read split in two is still whole in the carry by the
158
+ * time its line is emitted.
114
159
  *
115
- * `TextDecoder` is used in **streaming mode** (`{ stream: true }`) so a
116
- * multi-byte character split across two reads is reassembled rather than
117
- * mangled. A naive per-chunk `decode` corrupts any such character — a bug that
118
- * appears only with non-ASCII payloads at specific sizes, which is exactly the
119
- * kind that reaches production. The final `decode()` with no argument flushes
120
- * any trailing partial sequence.
160
+ * `from` is a line cursor: at a line boundary it is exactly where reading
161
+ * starts; pointing INTO a line, the straddled line is skipped whole. That is why
162
+ * reading starts one byte early — a `from` at a line start hands the skip rule
163
+ * the previous line's terminator and consumes exactly that byte. Whatever
164
+ * remains at `end` without a terminator is emitted last.
165
+ *
166
+ * `end` is the caller's bound, sampled once: bytes appended past it are never
167
+ * read. A file that shrinks underneath ends the stream at what was readable.
121
168
  *
122
169
  * @internal
123
170
  */
124
- const readRangeText = (fs, path, from, length) => Effect.gen(function* () {
125
- if (length <= 0) return "";
171
+ const readLinePages = (fs, path, from, end, bomBytes, pageSize = PAGE_SIZE) => Stream.unwrap(Effect.gen(function* () {
172
+ if (from >= end) return Stream.empty;
126
173
  const file = yield* fs.open(path, { flag: "r" });
127
- yield* file.seek(BigInt(from), "start");
128
- const decoder = new TextDecoder();
129
- let text = "";
130
- let remaining = length;
131
- while (remaining > 0) {
132
- const chunk = yield* file.readAlloc(Math.min(remaining, CHUNK));
133
- if (Option.isNone(chunk) || chunk.value.length === 0) break;
134
- text += decoder.decode(chunk.value, { stream: true });
135
- remaining -= chunk.value.length;
136
- }
137
- return text + decoder.decode();
138
- }).pipe(Effect.scoped);
139
- /** Read granularity for incremental tail reads. */
140
- const CHUNK = 65536;
174
+ const stop = end + bomBytes;
175
+ const flush = (state) => state.skipping || state.carry.length === 0 ? [] : [{
176
+ text: decoder.decode(join(state.carry)),
177
+ start: state.carryStart
178
+ }];
179
+ return Stream.paginate({
180
+ position: from === 0 ? bomBytes : from + bomBytes - 1,
181
+ carry: [],
182
+ carryStart: from,
183
+ skipping: from > 0
184
+ }, (state) => Effect.gen(function* () {
185
+ const bytes = state.position >= stop ? EMPTY : yield* readAt(file, state.position, Math.min(state.position + pageSize, stop));
186
+ if (bytes.length === 0) return [flush(state), Option.none()];
187
+ const position = state.position + bytes.length;
188
+ let fresh = bytes;
189
+ let carryStart = state.carryStart;
190
+ if (state.skipping) {
191
+ const newline = bytes.indexOf(LF);
192
+ if (newline === -1) return [[], Option.some({
193
+ ...state,
194
+ position
195
+ })];
196
+ fresh = bytes.subarray(newline + 1);
197
+ carryStart = state.position + newline + 1 - bomBytes;
198
+ }
199
+ const last = fresh.lastIndexOf(LF);
200
+ if (last === -1) {
201
+ const carry = fresh.length === 0 ? state.carry : [...state.carry, fresh];
202
+ return [[], Option.some({
203
+ position,
204
+ carry,
205
+ carryStart,
206
+ skipping: false
207
+ })];
208
+ }
209
+ const page = {
210
+ text: decoder.decode(join([...state.carry, fresh.subarray(0, last + 1)])),
211
+ start: carryStart
212
+ };
213
+ let carryLength = 0;
214
+ for (const chunk of state.carry) carryLength += chunk.length;
215
+ const rest = fresh.slice(last + 1);
216
+ return [[page], Option.some({
217
+ position,
218
+ carry: rest.length === 0 ? [] : [rest],
219
+ carryStart: carryStart + carryLength + last + 1,
220
+ skipping: false
221
+ })];
222
+ }));
223
+ }));
141
224
 
142
225
  //#endregion
143
- export { DEFAULT_WINDOW, probeBomBytes, readRangeText, readTail, readTailUntil };
226
+ export { DEFAULT_WINDOW, MAX_WINDOW, PAGE_SIZE, probeBomBytes, readLinePages, readTailUntil };
package/node.d.ts ADDED
@@ -0,0 +1,37 @@
1
+ import * as Jsonl from "@effected/jsonl";
2
+ import { Layer } from "effect";
3
+ //#region src/NodeJournalWatcher.d.ts
4
+ /**
5
+ * The Node implementation of {@link Jsonl.JournalWatcher}, over `node:fs`'s
6
+ * `watch`.
7
+ *
8
+ * Registration happens synchronously while the watch effect runs, so a
9
+ * journal's catch-up read after arming can never miss an append that the
10
+ * watch also fails to report.
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * import { Journal, JsonlEvent } from "@effected/jsonl";
15
+ * import { NodeJournalWatcher } from "@effected/jsonl/node";
16
+ * import { NodeFileSystem } from "@effect/platform-node";
17
+ * import { Effect, Schema } from "effect";
18
+ *
19
+ * const events = [JsonlEvent.make("mail", { data: Schema.Struct({ round: Schema.Number }) })] as const;
20
+ *
21
+ * class Mail extends Journal.Service<Mail>()("app/Mail", { events, config: { path: ".app/mail.jsonl" } }) {}
22
+ *
23
+ * const program = Effect.gen(function* () {
24
+ * const mail = yield* Mail;
25
+ * yield* mail.append("mail", { round: 1 });
26
+ * }).pipe(Effect.provide(Mail.layer), Effect.provide([NodeFileSystem.layer, NodeJournalWatcher.layer]));
27
+ * ```
28
+ *
29
+ * @public
30
+ */
31
+ export declare class NodeJournalWatcher {
32
+ private constructor();
33
+ /** Provides {@link Jsonl.JournalWatcher} over `node:fs`. */
34
+ static readonly layer: Layer.Layer<Jsonl.JournalWatcher>;
35
+ }
36
+ //#endregion
37
+ //# sourceMappingURL=node.d.ts.map
package/node.js ADDED
@@ -0,0 +1,3 @@
1
+ import { NodeJournalWatcher } from "./NodeJournalWatcher.js";
2
+
3
+ export { NodeJournalWatcher };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effected/jsonl",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "private": false,
5
5
  "description": "Append-only, schema-validated JSONL journals as a definable Effect service.",
6
6
  "keywords": [
@@ -34,6 +34,11 @@
34
34
  "import": "./index.js",
35
35
  "default": "./index.js"
36
36
  },
37
+ "./node": {
38
+ "types": "./node.d.ts",
39
+ "import": "./node.js",
40
+ "default": "./node.js"
41
+ },
37
42
  "./package.json": "./package.json"
38
43
  },
39
44
  "peerDependencies": {