@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/Journal.js
CHANGED
|
@@ -1,478 +1,58 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { Line } from "./Line.js";
|
|
4
|
-
import { Envelope } from "./Envelope.js";
|
|
5
|
-
import { canMerge, shallowMerge } from "./internal/merge.js";
|
|
6
|
-
import { DEFAULT_WINDOW, probeBomBytes, readRangeText, readTail, readTailUntil } from "./internal/tail.js";
|
|
7
|
-
import { matchesFrame } from "./Slice.js";
|
|
8
|
-
import { Channel, Context, DateTime, Deferred, Duration, Effect, Exit, Fiber, FileSystem, Layer, Option, PubSub, Result, Semaphore, Stream, SubscriptionRef } from "effect";
|
|
1
|
+
import { makeEngine } from "./internal/engine.js";
|
|
2
|
+
import { Context, Effect, Layer } from "effect";
|
|
9
3
|
|
|
10
4
|
//#region src/Journal.ts
|
|
11
5
|
/**
|
|
12
|
-
*
|
|
6
|
+
* Defines journal services.
|
|
13
7
|
*
|
|
14
|
-
*
|
|
15
|
-
* observe completion, so waiting on it forever only converts "one stuck
|
|
16
|
-
* subscriber" into "the process cannot shut down".
|
|
17
|
-
*/
|
|
18
|
-
const SHUTDOWN_PUBLISH_TIMEOUT = Duration.seconds(5);
|
|
19
|
-
/**
|
|
20
|
-
* How many times the watcher re-arms a watch that ends without observing
|
|
21
|
-
* anything before it gives up.
|
|
22
|
-
*
|
|
23
|
-
* A watch that completes instantly — an unsupported backend, a path that keeps
|
|
24
|
-
* vanishing — would otherwise spin the supervisor at full speed, and no timer
|
|
25
|
-
* is permitted here to back it off. Giving up is the honest failure: local
|
|
26
|
-
* appends keep working, live observation does not.
|
|
27
|
-
*/
|
|
28
|
-
const MAX_IMMEDIATE_REARMS = 8;
|
|
29
|
-
/**
|
|
30
|
-
* Scheduler turns yielded to a freshly-forked watch consumer before the
|
|
31
|
-
* catch-up read runs.
|
|
32
|
-
*
|
|
33
|
-
* See the arming comment in `supervise`: this orders arming ahead of catch-up
|
|
34
|
-
* by scheduling rather than by synchronisation, because `fs.watch` offers no
|
|
35
|
-
* registration signal.
|
|
36
|
-
*/
|
|
37
|
-
const ARM_YIELDS = 3;
|
|
38
|
-
/**
|
|
39
|
-
* Index of the last path separator, on either convention.
|
|
40
|
-
*
|
|
41
|
-
* Both are checked because a Windows path contains no `/` at all: matching only
|
|
42
|
-
* on `/` returns `-1` there, which makes the whole path its own basename and
|
|
43
|
-
* points the activation watch at the process working directory — a journal
|
|
44
|
-
* created after its layer was built is then never observed.
|
|
45
|
-
*/
|
|
46
|
-
const lastSeparator = (path) => Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
|
|
47
|
-
/**
|
|
48
|
-
* The last segment of a path.
|
|
49
|
-
*
|
|
50
|
-
* Used for **comparison only** — matching a watch event's basename against the
|
|
51
|
-
* journal's filename. `event.path` is never opened or read, on any watch.
|
|
52
|
-
*/
|
|
53
|
-
const basenameOf = (path) => path.slice(lastSeparator(path) + 1);
|
|
54
|
-
/**
|
|
55
|
-
* The directory a path sits in — the single derived path this package ever
|
|
56
|
-
* hands to the filesystem, and only as {@link JournalConfig.directory}'s
|
|
57
|
-
* default.
|
|
58
|
-
*
|
|
59
|
-
* Path arithmetic is otherwise forbidden here: paths are opaque strings handed
|
|
60
|
-
* straight to `FileSystem`. This one derivation exists because the activation
|
|
61
|
-
* watch has to name a directory and the layer takes only a file path; a caller
|
|
62
|
-
* whose path convention this does not fit (a drive-relative Windows path, say)
|
|
63
|
-
* names the directory explicitly instead.
|
|
64
|
-
*/
|
|
65
|
-
const parentOf = (path) => {
|
|
66
|
-
const separator = lastSeparator(path);
|
|
67
|
-
if (separator < 0) return ".";
|
|
68
|
-
return separator === 0 ? path.slice(0, 1) : path.slice(0, separator);
|
|
69
|
-
};
|
|
70
|
-
/**
|
|
71
|
-
* The last valid envelope in a tail window, with its offsets rebased onto the
|
|
72
|
-
* journal.
|
|
73
|
-
*
|
|
74
|
-
* The walk-back itself is **not** reimplemented here: `Envelope.lastValidResult`
|
|
75
|
-
* is the binding definition of "the journal's current state" — which lines
|
|
76
|
-
* count as blank, which failures are walked over, where it stops — and a second
|
|
77
|
-
* copy of those rules would only be a place for them to drift apart. All this
|
|
78
|
-
* adds is the one thing the pure core cannot know: the window's text begins at
|
|
79
|
-
* `window.start`, so every offset inside it is relative to that.
|
|
8
|
+
* @public
|
|
80
9
|
*/
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
line: new LineSlice({
|
|
84
|
-
offset: envelope.line.offset + window.start,
|
|
85
|
-
end: envelope.line.end + window.start,
|
|
86
|
-
length: envelope.line.length,
|
|
87
|
-
text: envelope.line.text,
|
|
88
|
-
terminated: envelope.line.terminated
|
|
89
|
-
})
|
|
90
|
-
}));
|
|
91
|
-
const makeEngine = (events, config, fs) => Effect.gen(function* () {
|
|
92
|
-
const terminalTags = new Set(events.filter((event) => event.terminal).map((event) => event.tag));
|
|
93
|
-
const reopenTags = new Set(events.filter((event) => event.reopen).map((event) => event.tag));
|
|
94
|
-
/** Guards the WRITE critical section only: file write plus ref updates. */
|
|
95
|
-
const writePermit = Semaphore.makeUnsafe(1);
|
|
96
|
-
/**
|
|
97
|
-
* The publish baton.
|
|
98
|
-
*
|
|
99
|
-
* Each append links a fresh Deferred onto this chain **while holding the
|
|
100
|
-
* write permit**, so publish order is fixed to write order; it then awaits
|
|
101
|
-
* its predecessor and publishes OUTSIDE the write permit. That is what lets
|
|
102
|
-
* a stalled subscriber block publishers without wedging writers or
|
|
103
|
-
* deadlocking scope close, while still making an append complete only once
|
|
104
|
-
* the hub has accepted its envelope.
|
|
105
|
-
*/
|
|
106
|
-
let publishBaton = Deferred.makeUnsafe();
|
|
107
|
-
Deferred.doneUnsafe(publishBaton, Exit.void);
|
|
108
|
-
/** Bytes of leading BOM, probed from the file — never inferred from a window. */
|
|
109
|
-
let bomBytes = 0;
|
|
10
|
+
var Journal = class {
|
|
11
|
+
constructor() {}
|
|
110
12
|
/**
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
13
|
+
* Define a `Journal` service class over a registry.
|
|
14
|
+
*
|
|
15
|
+
* Each registry gets its own uniquely-keyed class, so several journals
|
|
16
|
+
* coexist in one layer graph, each typed by its own registry. `config` is a
|
|
17
|
+
* plain record or an `Effect` producing one — so a path can come from
|
|
18
|
+
* `Config` or another service, resolved when the layer builds.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```ts
|
|
22
|
+
* import { Journal, JsonlEvent } from "@effected/jsonl";
|
|
23
|
+
* import { NodeJournalWatcher } from "@effected/jsonl/node";
|
|
24
|
+
* import { NodeFileSystem } from "@effect/platform-node";
|
|
25
|
+
* import { Effect, Schema } from "effect";
|
|
26
|
+
*
|
|
27
|
+
* const events = [JsonlEvent.make("mail", { data: Schema.Struct({ round: Schema.Number }) })] as const;
|
|
28
|
+
*
|
|
29
|
+
* class Mail extends Journal.Service<Mail>()("app/Mail", {
|
|
30
|
+
* events,
|
|
31
|
+
* config: { path: ".app/mail.jsonl" },
|
|
32
|
+
* }) {}
|
|
33
|
+
*
|
|
34
|
+
* const program = Effect.gen(function* () {
|
|
35
|
+
* const mail = yield* Mail;
|
|
36
|
+
* yield* mail.append("mail", { round: 1 });
|
|
37
|
+
* }).pipe(
|
|
38
|
+
* Effect.provide(Mail.layer),
|
|
39
|
+
* Effect.provide([NodeFileSystem.layer, NodeJournalWatcher.layer]),
|
|
40
|
+
* );
|
|
41
|
+
* ```
|
|
117
42
|
*/
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
});
|
|
129
|
-
const readLatest = Effect.gen(function* () {
|
|
130
|
-
yield* requireFile;
|
|
131
|
-
return yield* readTailUntil(fs, config.path, bomBytes, (window) => decodeWindow(events, window), DEFAULT_WINDOW);
|
|
132
|
-
});
|
|
133
|
-
const refresh = Effect.gen(function* () {
|
|
134
|
-
const found = yield* readLatest;
|
|
135
|
-
yield* SubscriptionRef.set(latest, found);
|
|
136
|
-
return found;
|
|
137
|
-
});
|
|
138
|
-
const isTerminalTail = Effect.gen(function* () {
|
|
139
|
-
const current = yield* SubscriptionRef.get(latest);
|
|
140
|
-
return Option.isSome(current) && terminalTags.has(current.value.event);
|
|
141
|
-
});
|
|
142
|
-
/**
|
|
143
|
-
* Read `[from, to)` and decode the complete lines in it, in file order.
|
|
144
|
-
*
|
|
145
|
-
* The one decode loop the external-growth paths share. Both the watcher's
|
|
146
|
-
* catch-up ingest and an append whose bytes landed past another writer's
|
|
147
|
-
* need the same thing — "decode this gap" — and two copies of a loop that
|
|
148
|
-
* rebases offsets and tolerates a torn tail is exactly the drift that
|
|
149
|
-
* produces a cursor bug on one path only.
|
|
150
|
-
*
|
|
151
|
-
* `advanced` is where the last COMPLETE line ended: an unterminated final
|
|
152
|
-
* line is not consumed, so a torn tail holds the offset until its writer
|
|
153
|
-
* finishes it. A line that fails to decode is skipped rather than fatal —
|
|
154
|
-
* a foreign writer's unknown tag must not blind this reader to the lines
|
|
155
|
-
* after it.
|
|
156
|
-
*/
|
|
157
|
-
const decodeRange = (from, to) => Effect.gen(function* () {
|
|
158
|
-
const text = yield* readRangeText(fs, config.path, from + bomBytes, to - from);
|
|
159
|
-
const lines = Line.split(text);
|
|
160
|
-
const decoded = [];
|
|
161
|
-
let advanced = from;
|
|
162
|
-
for (const line of lines) {
|
|
163
|
-
if (!line.terminated) break;
|
|
164
|
-
advanced = line.end + from;
|
|
165
|
-
if (line.text.trim() === "") continue;
|
|
166
|
-
const rebased = new LineSlice({
|
|
167
|
-
offset: line.offset + from,
|
|
168
|
-
end: line.end + from,
|
|
169
|
-
length: line.length,
|
|
170
|
-
text: line.text,
|
|
171
|
-
terminated: true
|
|
172
|
-
});
|
|
173
|
-
const envelope = Envelope.decodeResult(events, rebased);
|
|
174
|
-
if (Result.isSuccess(envelope)) decoded.push(envelope.success);
|
|
175
|
-
}
|
|
176
|
-
return {
|
|
177
|
-
decoded,
|
|
178
|
-
advanced
|
|
43
|
+
static Service() {
|
|
44
|
+
return (id, options) => {
|
|
45
|
+
const make = (config) => Effect.map(makeEngine(id, options.events, config), (engine) => engine.journal);
|
|
46
|
+
const config = Effect.isEffect(options.config) ? options.config : Effect.succeed(options.config);
|
|
47
|
+
class JournalService extends Context.Service()(id) {
|
|
48
|
+
static events = options.events;
|
|
49
|
+
static make = make;
|
|
50
|
+
static layer = Layer.effect(JournalService, Effect.flatMap(config, make));
|
|
51
|
+
}
|
|
52
|
+
return JournalService;
|
|
179
53
|
};
|
|
180
|
-
});
|
|
181
|
-
/**
|
|
182
|
-
* The one write path.
|
|
183
|
-
*
|
|
184
|
-
* `build` runs **inside the write permit**, so an inherit-and-patch reads
|
|
185
|
-
* the current state under the same lock that serializes the write. Reading
|
|
186
|
-
* outside it was a lost-update race: two concurrent patches to different
|
|
187
|
-
* fields both read the same base and the second silently reverted the
|
|
188
|
-
* first.
|
|
189
|
-
*/
|
|
190
|
-
const appendWith = (event, build, scope) => Effect.gen(function* () {
|
|
191
|
-
if (closed) return yield* new JournalClosed({ event });
|
|
192
|
-
const { envelope, external, predecessor, baton } = yield* writePermit.withPermits(1)(Effect.gen(function* () {
|
|
193
|
-
if (closed) return yield* new JournalClosed({ event });
|
|
194
|
-
yield* requireFile;
|
|
195
|
-
const current = yield* SubscriptionRef.get(latest);
|
|
196
|
-
if (Option.isSome(current) && terminalTags.has(current.value.event) && !reopenTags.has(event)) return yield* new TerminalViolation({
|
|
197
|
-
event,
|
|
198
|
-
terminal: current.value.event
|
|
199
|
-
});
|
|
200
|
-
const data = build(current);
|
|
201
|
-
const at = yield* DateTime.now;
|
|
202
|
-
const encoded = Envelope.encodeResult(events, {
|
|
203
|
-
event,
|
|
204
|
-
data,
|
|
205
|
-
at,
|
|
206
|
-
...scope === void 0 ? {} : { scope }
|
|
207
|
-
});
|
|
208
|
-
if (Result.isFailure(encoded)) return yield* encoded.failure;
|
|
209
|
-
const bytes = new TextEncoder().encode(encoded.success);
|
|
210
|
-
const end = yield* Effect.scoped(Effect.gen(function* () {
|
|
211
|
-
const file = yield* fs.open(config.path, { flag: "a" });
|
|
212
|
-
yield* file.writeAll(bytes);
|
|
213
|
-
const info = yield* file.stat;
|
|
214
|
-
return Number(info.size) - bomBytes;
|
|
215
|
-
}));
|
|
216
|
-
const offset = end - bytes.length;
|
|
217
|
-
const external = offset > consumed ? (yield* decodeRange(consumed, offset)).decoded : [];
|
|
218
|
-
consumed = end;
|
|
219
|
-
const line = new LineSlice({
|
|
220
|
-
offset,
|
|
221
|
-
end,
|
|
222
|
-
length: bytes.length - 1,
|
|
223
|
-
text: encoded.success.slice(0, -1),
|
|
224
|
-
terminated: true
|
|
225
|
-
});
|
|
226
|
-
const decoded = Envelope.decodeResult(events, line);
|
|
227
|
-
if (Result.isFailure(decoded)) return yield* decoded.failure;
|
|
228
|
-
yield* SubscriptionRef.set(latest, Option.some(decoded.success));
|
|
229
|
-
const previous = publishBaton;
|
|
230
|
-
const mine = Deferred.makeUnsafe();
|
|
231
|
-
publishBaton = mine;
|
|
232
|
-
return {
|
|
233
|
-
envelope: decoded.success,
|
|
234
|
-
external,
|
|
235
|
-
predecessor: previous,
|
|
236
|
-
baton: mine
|
|
237
|
-
};
|
|
238
|
-
}));
|
|
239
|
-
yield* Effect.uninterruptibleMask((restore) => restore(Effect.gen(function* () {
|
|
240
|
-
yield* Deferred.await(predecessor);
|
|
241
|
-
for (const preceding of external) yield* PubSub.publish(hub, [preceding]);
|
|
242
|
-
yield* PubSub.publish(hub, [envelope]);
|
|
243
|
-
})).pipe(Effect.ensuring(Deferred.done(baton, Exit.void))));
|
|
244
|
-
return envelope;
|
|
245
|
-
});
|
|
246
|
-
const appendEncoded = (event, data, scope) => appendWith(event, () => data, scope);
|
|
247
|
-
/**
|
|
248
|
-
* Read the file from `cursor`, frame-filtered before payload decode.
|
|
249
|
-
*
|
|
250
|
-
* **What this actually does, as built**: ONE read of the requested region
|
|
251
|
-
* — `cursor` to the end of the file, bounded by the file's size — whose
|
|
252
|
-
* matching envelopes are buffered and then emitted. It is bounded by the
|
|
253
|
-
* cursor, not by a window, so an unsliced read over a large journal holds
|
|
254
|
-
* that journal in memory. The frame filter still runs per line before any
|
|
255
|
-
* payload schema, so the filter-before-decode guarantee is untouched;
|
|
256
|
-
* what is not true here is the "never held in memory" half.
|
|
257
|
-
*
|
|
258
|
-
* Paging this — read one window, emit its envelopes, carry the
|
|
259
|
-
* unterminated tail into the next read — is spencerbeggs/effected#233,
|
|
260
|
-
* rather than something attempted alongside the correctness fixes around
|
|
261
|
-
* it.
|
|
262
|
-
*/
|
|
263
|
-
const readFrom = (slice) => Stream.unwrap(Effect.gen(function* () {
|
|
264
|
-
yield* requireFile;
|
|
265
|
-
const from = slice?.cursor ?? 0;
|
|
266
|
-
const info = yield* fs.stat(config.path);
|
|
267
|
-
const logicalSize = Number(info.size) - bomBytes;
|
|
268
|
-
if (from >= logicalSize) return Stream.empty;
|
|
269
|
-
const window = yield* readTail(fs, config.path, logicalSize - from + 1, bomBytes);
|
|
270
|
-
const lines = Line.split(window.text);
|
|
271
|
-
const selected = [];
|
|
272
|
-
for (const line of lines) {
|
|
273
|
-
if (line.text.trim() === "") continue;
|
|
274
|
-
const rebased = new LineSlice({
|
|
275
|
-
offset: line.offset + window.start,
|
|
276
|
-
end: line.end + window.start,
|
|
277
|
-
length: line.length,
|
|
278
|
-
text: line.text,
|
|
279
|
-
terminated: line.terminated
|
|
280
|
-
});
|
|
281
|
-
if (rebased.offset < from) continue;
|
|
282
|
-
const decoded = Envelope.decodeSelectedResult(events, rebased, (frame) => matchesFrame(frame, slice));
|
|
283
|
-
if (Option.isNone(decoded)) continue;
|
|
284
|
-
if (Result.isFailure(decoded.value)) return Stream.concat(Stream.fromIterable(selected), Stream.fail(decoded.value.failure));
|
|
285
|
-
selected.push(decoded.value.success);
|
|
286
|
-
}
|
|
287
|
-
return Stream.fromIterable(selected);
|
|
288
|
-
}));
|
|
289
|
-
/**
|
|
290
|
-
* Envelope-level matching, for the live path.
|
|
291
|
-
*
|
|
292
|
-
* The frame-level {@link matchesFrame} is the one that carries the
|
|
293
|
-
* filter-before-decode guarantee; this is its counterpart for envelopes
|
|
294
|
-
* that arrived already decoded from the hub, where there is no decode left
|
|
295
|
-
* to avoid.
|
|
296
|
-
*/
|
|
297
|
-
const matchesEnvelope = (envelope, slice) => matchesFrame({
|
|
298
|
-
at: envelope.at,
|
|
299
|
-
event: envelope.event,
|
|
300
|
-
...envelope.scope === void 0 ? {} : { scope: envelope.scope },
|
|
301
|
-
data: envelope.data
|
|
302
|
-
}, slice);
|
|
303
|
-
const isTerminalEnvelope = (envelope) => terminalTags.has(envelope.event);
|
|
304
|
-
const changesStream = (slice) => Stream.unwrap(Effect.gen(function* () {
|
|
305
|
-
const alreadyQuiescent = yield* isTerminalTail;
|
|
306
|
-
const replay = slice?.cursor === void 0 ? Stream.empty : readFrom(slice);
|
|
307
|
-
if (alreadyQuiescent || closed) return replay;
|
|
308
|
-
const subscription = yield* PubSub.subscribe(hub);
|
|
309
|
-
let replayedThrough = slice?.cursor ?? 0;
|
|
310
|
-
const history = replay.pipe(Stream.tap((envelope) => Effect.sync(() => {
|
|
311
|
-
replayedThrough = Math.max(replayedThrough, envelope.line.end);
|
|
312
|
-
})));
|
|
313
|
-
const live = Stream.fromChannel(Channel.fromEffectTake(PubSub.take(subscription))).pipe(Stream.takeUntil(isTerminalEnvelope), Stream.filter((envelope) => envelope.line.offset >= replayedThrough));
|
|
314
|
-
return Stream.concat(history, live);
|
|
315
|
-
}));
|
|
316
|
-
const identityOf = (info) => Option.map(info.ino, (ino) => `${info.dev}:${ino}`);
|
|
317
|
-
const ingest = Semaphore.makeUnsafe(1).withPermits(1)(Effect.gen(function* () {
|
|
318
|
-
if (!(yield* exists())) return;
|
|
319
|
-
const info = yield* fs.stat(config.path);
|
|
320
|
-
const currentIdentity = identityOf(info);
|
|
321
|
-
const logicalSize = Number(info.size) - bomBytes;
|
|
322
|
-
const replaced = Option.isSome(identity) && Option.isSome(currentIdentity) && identity.value !== currentIdentity.value;
|
|
323
|
-
if (replaced || logicalSize < consumed) {
|
|
324
|
-
const failure = new JournalResync({
|
|
325
|
-
path: config.path,
|
|
326
|
-
reason: replaced ? "replaced" : "truncated",
|
|
327
|
-
expected: consumed,
|
|
328
|
-
actual: logicalSize
|
|
329
|
-
});
|
|
330
|
-
identity = currentIdentity;
|
|
331
|
-
bomBytes = yield* probeBomBytes(fs, config.path);
|
|
332
|
-
consumed = 0;
|
|
333
|
-
yield* SubscriptionRef.set(latest, Option.none());
|
|
334
|
-
yield* PubSub.publish(hub, Exit.fail(failure));
|
|
335
|
-
return;
|
|
336
|
-
}
|
|
337
|
-
identity = currentIdentity;
|
|
338
|
-
if (logicalSize <= consumed) return;
|
|
339
|
-
const { envelopes, predecessor, baton } = yield* writePermit.withPermits(1)(Effect.gen(function* () {
|
|
340
|
-
const { decoded, advanced } = yield* decodeRange(consumed, logicalSize);
|
|
341
|
-
consumed = advanced;
|
|
342
|
-
if (decoded.length > 0) yield* SubscriptionRef.set(latest, Option.some(decoded[decoded.length - 1]));
|
|
343
|
-
const previous = publishBaton;
|
|
344
|
-
const mine = Deferred.makeUnsafe();
|
|
345
|
-
publishBaton = mine;
|
|
346
|
-
return {
|
|
347
|
-
envelopes: decoded,
|
|
348
|
-
predecessor: previous,
|
|
349
|
-
baton: mine
|
|
350
|
-
};
|
|
351
|
-
}));
|
|
352
|
-
yield* Effect.uninterruptibleMask((restore) => restore(Effect.gen(function* () {
|
|
353
|
-
yield* Deferred.await(predecessor);
|
|
354
|
-
for (const envelope of envelopes) yield* PubSub.publish(hub, [envelope]);
|
|
355
|
-
})).pipe(Effect.ensuring(Deferred.done(baton, Exit.void))));
|
|
356
|
-
}));
|
|
357
|
-
const shape = {
|
|
358
|
-
append: (event, data, options) => appendEncoded(event, data, options?.scope),
|
|
359
|
-
appendPatch: (event, patch, options) => appendWith(event, (current) => {
|
|
360
|
-
const base = Option.isSome(current) ? current.value.data : void 0;
|
|
361
|
-
if (!canMerge(base, patch)) return patch;
|
|
362
|
-
return shallowMerge(base, patch);
|
|
363
|
-
}, options?.scope),
|
|
364
|
-
latest,
|
|
365
|
-
quiescent: isTerminalTail,
|
|
366
|
-
query: ((slice) => readFrom(slice).pipe(Stream.filter((envelope) => matchesEnvelope(envelope, slice)))),
|
|
367
|
-
changes: ((slice) => changesStream(slice).pipe(Stream.filter((envelope) => matchesEnvelope(envelope, slice)))),
|
|
368
|
-
projection: ((initial, fold, slice) => changesStream(slice).pipe(Stream.filter((envelope) => matchesEnvelope(envelope, slice)), Stream.scan(() => initial, fold))),
|
|
369
|
-
create: Effect.gen(function* () {
|
|
370
|
-
if (!(yield* exists())) yield* Effect.scoped(fs.open(config.path, { flag: "a" }));
|
|
371
|
-
}),
|
|
372
|
-
remove: fs.remove(config.path, { force: true }),
|
|
373
|
-
hub
|
|
374
|
-
};
|
|
375
|
-
if (yield* exists()) {
|
|
376
|
-
bomBytes = yield* probeBomBytes(fs, config.path);
|
|
377
|
-
yield* refresh.pipe(Effect.catchTag("JournalNotFound", () => Effect.succeed(Option.none())));
|
|
378
|
-
const info = yield* fs.stat(config.path);
|
|
379
|
-
consumed = Number(info.size) - bomBytes;
|
|
380
|
-
}
|
|
381
|
-
/**
|
|
382
|
-
* Watch for the life of the layer scope.
|
|
383
|
-
*
|
|
384
|
-
* Two watches, and the distinction matters:
|
|
385
|
-
*
|
|
386
|
-
* - **The journal itself**, once it exists. Steady state, one `Update` per
|
|
387
|
-
* append, and the tags are trustworthy here.
|
|
388
|
-
* - **The parent directory**, while the journal does not exist yet. The
|
|
389
|
-
* tags are NOT trustworthy at this edge — on the installed node backend a
|
|
390
|
-
* creation and an append both arrive as `Remove`, with a bare relative
|
|
391
|
-
* `path` — so this watch **never branches on `WatchEvent._tag`**. Any
|
|
392
|
-
* event whose path BASENAME matches the journal filename is an untyped
|
|
393
|
-
* poke meaning "re-stat the journal yourself".
|
|
394
|
-
*
|
|
395
|
-
* The directory watch is an **activation** watch and nothing more: it ends
|
|
396
|
-
* as soon as the journal exists, and the supervisor then arms the file
|
|
397
|
-
* watch. Staying on it would be silently wrong rather than merely
|
|
398
|
-
* redundant — a non-recursive directory watch reports creation, not a
|
|
399
|
-
* child's later content appends, so a journal that never handed off would
|
|
400
|
-
* observe its own appends and no external ones.
|
|
401
|
-
*
|
|
402
|
-
* `event.path` is **never** used to open or read anything, on either watch.
|
|
403
|
-
* It can be a bare basename that resolves against the process CWD; the
|
|
404
|
-
* journal's own configured path is the only path this service touches. The
|
|
405
|
-
* symptom of violating that is a phantom `JournalNotFound` on a healthy
|
|
406
|
-
* journal.
|
|
407
|
-
*
|
|
408
|
-
* No timer anywhere. Activation is event-driven.
|
|
409
|
-
*/
|
|
410
|
-
const basename = basenameOf(config.path);
|
|
411
|
-
const directory = config.directory ?? parentOf(config.path);
|
|
412
|
-
const watchJournal = fs.watch(config.path).pipe(Stream.runForEach(() => ingest), Effect.ignore);
|
|
413
|
-
const watchForCreation = fs.watch(directory).pipe(Stream.filter((event) => basenameOf(event.path) === basename), Stream.takeUntilEffect(() => exists()), Stream.runForEach(() => ingest), Effect.ignore);
|
|
414
|
-
const supervise = Effect.gen(function* () {
|
|
415
|
-
let immediateCompletions = 0;
|
|
416
|
-
for (;;) {
|
|
417
|
-
const startedAt = consumed;
|
|
418
|
-
if (yield* exists()) {
|
|
419
|
-
const armed = yield* Effect.forkChild(watchJournal);
|
|
420
|
-
for (let turn = 0; turn < ARM_YIELDS; turn++) yield* Effect.yieldNow;
|
|
421
|
-
yield* ingest;
|
|
422
|
-
yield* Fiber.join(armed);
|
|
423
|
-
yield* ingest;
|
|
424
|
-
} else yield* watchForCreation;
|
|
425
|
-
immediateCompletions = consumed === startedAt ? immediateCompletions + 1 : 0;
|
|
426
|
-
if (immediateCompletions > MAX_IMMEDIATE_REARMS) return;
|
|
427
|
-
}
|
|
428
|
-
});
|
|
429
|
-
yield* Effect.forkScoped(supervise.pipe(Effect.ignore));
|
|
430
|
-
yield* Effect.addFinalizer(() => Effect.gen(function* () {
|
|
431
|
-
closed = true;
|
|
432
|
-
const pending = yield* writePermit.withPermits(1)(Effect.sync(() => publishBaton));
|
|
433
|
-
yield* Effect.interruptible(Effect.gen(function* () {
|
|
434
|
-
yield* Deferred.await(pending);
|
|
435
|
-
yield* PubSub.publish(hub, Exit.void);
|
|
436
|
-
})).pipe(Effect.timeout(config.shutdownPublishTimeout ?? SHUTDOWN_PUBLISH_TIMEOUT), Effect.ignore);
|
|
437
|
-
}));
|
|
438
|
-
return shape;
|
|
439
|
-
});
|
|
440
|
-
/**
|
|
441
|
-
* Define a `Journal` service class over a registry.
|
|
442
|
-
*
|
|
443
|
-
* A `Context.Service` cannot itself be generic over the registry — the shape
|
|
444
|
-
* binds at declaration and a Key cannot be parameterized at retrieval — so each
|
|
445
|
-
* registry gets its own uniquely-keyed class, and several journals coexist in
|
|
446
|
-
* one layer graph with each one's operations typed by *its* registry. This
|
|
447
|
-
* mirrors `ConfigFile.Service<Self, A>()(id)`.
|
|
448
|
-
*
|
|
449
|
-
* Pass the registry as `options.events`; the returned class exposes it as
|
|
450
|
-
* `events` and builds the scoped layer with `.layer(config)`.
|
|
451
|
-
*
|
|
452
|
-
* @example
|
|
453
|
-
* ```ts
|
|
454
|
-
* import { Journal, JsonlEvent } from "@effected/jsonl";
|
|
455
|
-
* import { Schema } from "effect";
|
|
456
|
-
*
|
|
457
|
-
* const events = [JsonlEvent.make("started", { data: Schema.Void })] as const;
|
|
458
|
-
*
|
|
459
|
-
* class MailJournal extends Journal.Service<MailJournal>()("app/MailJournal", { events }) {}
|
|
460
|
-
*
|
|
461
|
-
* export const layer = MailJournal.layer({ path: ".app/mail.jsonl" });
|
|
462
|
-
* ```
|
|
463
|
-
*
|
|
464
|
-
* @public
|
|
465
|
-
*/
|
|
466
|
-
const Journal = { Service: () => (id, options) => {
|
|
467
|
-
class JournalService extends Context.Service()(id) {
|
|
468
|
-
static events = options.events;
|
|
469
|
-
static layer = (config) => Layer.effect(JournalService, Effect.gen(function* () {
|
|
470
|
-
const fs = yield* FileSystem.FileSystem;
|
|
471
|
-
return yield* makeEngine(options.events, config, fs);
|
|
472
|
-
}));
|
|
473
54
|
}
|
|
474
|
-
|
|
475
|
-
} };
|
|
55
|
+
};
|
|
476
56
|
|
|
477
57
|
//#endregion
|
|
478
58
|
export { Journal };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Context } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/JournalWatcher.ts
|
|
4
|
+
/**
|
|
5
|
+
* How a journal is told its file changed underneath it: a watch that reports
|
|
6
|
+
* when it is armed.
|
|
7
|
+
*
|
|
8
|
+
* Core's `FileSystem.watch` cannot serve here. Its stream registers the
|
|
9
|
+
* platform watch in a fiber it forks, after an asynchronous `stat`, and gives
|
|
10
|
+
* no signal when that has happened — so a journal reading what it missed
|
|
11
|
+
* before the watch was live cannot know whether a concurrent append landed
|
|
12
|
+
* before registration (and is lost for good) or after (and will be reported).
|
|
13
|
+
* Under load that window is real. This service closes it by construction:
|
|
14
|
+
* {@link JournalWatcherShape.watch} succeeds only once registration is done.
|
|
15
|
+
*
|
|
16
|
+
* The library owns no backend. On Node, provide `NodeJournalWatcher.layer`
|
|
17
|
+
* from `@effected/jsonl/node`; elsewhere, implement the shape over the
|
|
18
|
+
* platform's own watch primitive, honouring the arm-before-success contract.
|
|
19
|
+
*
|
|
20
|
+
* @public
|
|
21
|
+
*/
|
|
22
|
+
var JournalWatcher = class extends Context.Service()("@effected/jsonl/JournalWatcher") {};
|
|
23
|
+
|
|
24
|
+
//#endregion
|
|
25
|
+
export { JournalWatcher };
|
package/JsonlError.js
CHANGED
|
@@ -12,6 +12,8 @@ import { Option, Schema } from "effect";
|
|
|
12
12
|
* and expected types — instead of flattening it to a string at the boundary.
|
|
13
13
|
*/
|
|
14
14
|
const SchemaErrorFromSelf = Schema.declare(Schema.isSchemaError);
|
|
15
|
+
/** The ` at byte offset N` suffix, when there is a line to locate. */
|
|
16
|
+
const at = (line) => line === void 0 ? "" : ` at byte offset ${line.offset}`;
|
|
15
17
|
/**
|
|
16
18
|
* A journal line that is not valid JSON.
|
|
17
19
|
*
|
|
@@ -57,15 +59,15 @@ line: LineSlice }) {
|
|
|
57
59
|
* @public
|
|
58
60
|
*/
|
|
59
61
|
var UnknownEvent = class extends Schema.TaggedError()("UnknownEvent", {
|
|
60
|
-
/** The offending line, with its byte offsets into the source. */
|
|
61
|
-
line: LineSlice,
|
|
62
|
+
/** The offending line, with its byte offsets into the source. Absent on the encode path. */
|
|
63
|
+
line: Schema.optionalKey(LineSlice),
|
|
62
64
|
/** The unrecognized tag as it appeared on the envelope. */
|
|
63
65
|
event: Schema.String,
|
|
64
66
|
/** The tags this journal's registry does define. */
|
|
65
67
|
known: Schema.Array(Schema.String)
|
|
66
68
|
}) {
|
|
67
69
|
get message() {
|
|
68
|
-
return `unknown JSONL event ${JSON.stringify(this.event)}
|
|
70
|
+
return `unknown JSONL event ${JSON.stringify(this.event)}${at(this.line)}`;
|
|
69
71
|
}
|
|
70
72
|
};
|
|
71
73
|
/**
|
|
@@ -83,8 +85,11 @@ var UnknownEvent = class extends Schema.TaggedError()("UnknownEvent", {
|
|
|
83
85
|
* @public
|
|
84
86
|
*/
|
|
85
87
|
var InvalidData = class extends Schema.TaggedError()("InvalidData", {
|
|
86
|
-
/**
|
|
87
|
-
line
|
|
88
|
+
/**
|
|
89
|
+
* The offending line, with its byte offsets into the source. Absent when the
|
|
90
|
+
* failure came from encoding a payload for append, which has no line yet.
|
|
91
|
+
*/
|
|
92
|
+
line: Schema.optionalKey(LineSlice),
|
|
88
93
|
/**
|
|
89
94
|
* The event tag whose payload schema rejected the data, or `none` when it
|
|
90
95
|
* was the envelope frame itself that failed.
|
|
@@ -94,7 +99,7 @@ var InvalidData = class extends Schema.TaggedError()("InvalidData", {
|
|
|
94
99
|
error: SchemaErrorFromSelf
|
|
95
100
|
}) {
|
|
96
101
|
get message() {
|
|
97
|
-
return `invalid JSONL ${Option.isSome(this.event) ? `payload for event ${JSON.stringify(this.event.value)}` : "envelope"}
|
|
102
|
+
return `invalid JSONL ${Option.isSome(this.event) ? `payload for event ${JSON.stringify(this.event.value)}` : "envelope"}${at(this.line)}: ${this.error.message}`;
|
|
98
103
|
}
|
|
99
104
|
};
|
|
100
105
|
/**
|
|
@@ -121,8 +126,8 @@ var TerminalViolation = class extends Schema.TaggedError()("TerminalViolation",
|
|
|
121
126
|
*
|
|
122
127
|
* A missing journal is a **legal state** — building the layer over a path that
|
|
123
128
|
* does not exist yet succeeds, and the watcher activates once the file appears.
|
|
124
|
-
* What is not legal is materializing it implicitly: `append
|
|
125
|
-
* `latest`
|
|
129
|
+
* What is not legal is materializing it implicitly: `append` and `query` fail
|
|
130
|
+
* with this (and `latest` reads as empty) rather than creating the file, so a typo in a path
|
|
126
131
|
* cannot quietly produce a second, empty journal that looks like a working
|
|
127
132
|
* system with no history. Creation is always explicit, via `create`.
|
|
128
133
|
*
|
|
@@ -161,7 +166,7 @@ var UnserializableData = class extends Schema.TaggedError()("UnserializableData"
|
|
|
161
166
|
/** The event tag whose payload could not be serialized. */
|
|
162
167
|
event: Schema.String,
|
|
163
168
|
/** The value `JSON.stringify` threw, carried structurally. */
|
|
164
|
-
cause: Schema.
|
|
169
|
+
cause: Schema.Defect()
|
|
165
170
|
}) {
|
|
166
171
|
get message() {
|
|
167
172
|
const detail = this.cause instanceof Error ? this.cause.message : "value is not JSON-serializable";
|
package/JsonlEvent.js
CHANGED
|
@@ -1,11 +1,5 @@
|
|
|
1
1
|
//#region src/JsonlEvent.ts
|
|
2
2
|
/**
|
|
3
|
-
* Runtime type identifier marking a JSONL event definition.
|
|
4
|
-
*
|
|
5
|
-
* @public
|
|
6
|
-
*/
|
|
7
|
-
const JsonlEventTypeId = "~effected/jsonl/JsonlEvent";
|
|
8
|
-
/**
|
|
9
3
|
* Defines an event.
|
|
10
4
|
*
|
|
11
5
|
* The `const` type parameters are load-bearing: they keep `"unlinked"` a
|
|
@@ -34,7 +28,6 @@ const JsonlEventTypeId = "~effected/jsonl/JsonlEvent";
|
|
|
34
28
|
* @public
|
|
35
29
|
*/
|
|
36
30
|
const JsonlEvent = { make: (tag, options) => ({
|
|
37
|
-
[JsonlEventTypeId]: JsonlEventTypeId,
|
|
38
31
|
tag,
|
|
39
32
|
data: options.data,
|
|
40
33
|
terminal: options.terminal ?? false,
|
|
@@ -42,4 +35,4 @@ const JsonlEvent = { make: (tag, options) => ({
|
|
|
42
35
|
}) };
|
|
43
36
|
|
|
44
37
|
//#endregion
|
|
45
|
-
export { JsonlEvent
|
|
38
|
+
export { JsonlEvent };
|