@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 +83 -175
- package/Journal.js +46 -466
- package/JournalWatcher.js +25 -0
- package/JsonlError.js +14 -9
- package/JsonlEvent.js +1 -8
- package/Line.js +37 -135
- package/LineSlice.js +12 -10
- package/NodeJournalWatcher.js +104 -0
- package/README.md +3 -2
- package/Slice.js +3 -9
- package/index.d.ts +387 -492
- package/index.js +5 -4
- package/internal/engine.js +400 -0
- package/internal/tail.js +173 -90
- package/node.d.ts +37 -0
- package/node.js +3 -0
- package/package.json +6 -1
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 {
|
|
3
|
+
import { Result } from "effect";
|
|
5
4
|
|
|
6
5
|
//#region src/Line.ts
|
|
7
6
|
/**
|
|
8
|
-
*
|
|
7
|
+
* Whether a line carries nothing but whitespace.
|
|
9
8
|
*
|
|
10
|
-
*
|
|
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
|
|
13
|
+
* Splitting and parsing JSONL text.
|
|
27
14
|
*
|
|
28
|
-
* Every operation is total and synchronous:
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
|
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
|
-
*
|
|
42
|
-
*
|
|
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
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
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
|
-
* @
|
|
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 =
|
|
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
|
|
85
|
-
const
|
|
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 + (
|
|
89
|
-
lines.push(
|
|
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(
|
|
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.
|
|
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
|
|
136
|
-
*
|
|
137
|
-
*
|
|
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
|
-
|
|
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,
|
|
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
|
|
9
|
-
*
|
|
10
|
-
* `
|
|
11
|
-
*
|
|
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
|
-
|
|
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 `
|
|
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.
|
|
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
|
|
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
|
-
|
|
19
|
-
return true;
|
|
13
|
+
return slice.to === void 0 || millis < slice.to.epochMilliseconds;
|
|
20
14
|
};
|
|
21
15
|
|
|
22
16
|
//#endregion
|