@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/Journal.js CHANGED
@@ -1,478 +1,58 @@
1
- import { LineSlice } from "./LineSlice.js";
2
- import { JournalClosed, JournalNotFound, JournalResync, TerminalViolation } from "./JsonlError.js";
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
- * How long scope close waits for the terminal `Exit` to be accepted by the hub.
6
+ * Defines journal services.
13
7
  *
14
- * Bounded rather than indefinite: a subscriber that never consumes cannot
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
- const decodeWindow = (events, window) => Option.map(Envelope.lastValidResult(events, window.text), (envelope) => ({
82
- ...envelope,
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
- * The watched file's identity, for detecting replacement.
112
- *
113
- * Size alone cannot see a same-size replace; inode identity can. `ino` is
114
- * an `Option` in core's `File.Info`, so on a platform that does not report
115
- * it this degrades to truncation-only detection — stated rather than
116
- * pretended away.
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
- let identity = Option.none();
119
- const hub = yield* PubSub.bounded(config.capacity ?? 64);
120
- const latest = yield* SubscriptionRef.make(Option.none());
121
- /** Refusal state. Read before the permit so a late append fails fast. */
122
- let closed = false;
123
- /** Logical bytes decoded so far — the resume cursor the watcher advances. */
124
- let consumed = 0;
125
- const exists = () => fs.exists(config.path);
126
- const requireFile = Effect.gen(function* () {
127
- if (!(yield* exists())) return yield* new JournalNotFound({ path: config.path });
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
- return JournalService;
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)} at byte offset ${this.line.offset}`;
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
- /** The offending line, with its byte offsets into the source. */
87
- line: LineSlice,
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"} at byte offset ${this.line.offset}: ${this.error.message}`;
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`, `query` and
125
- * `latest` fail with this rather than creating the file, so a typo in a path
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.Unknown
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, JsonlEventTypeId };
38
+ export { JsonlEvent };