@effected/jsonl 0.9.0 → 0.10.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/index.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { LineSlice } from "./LineSlice.js";
2
2
  import { InvalidData, JournalClosed, JournalNotFound, JournalResync, MalformedLine, TerminalViolation, UnknownEvent, UnserializableData } from "./JsonlError.js";
3
- import { Line, ParsedLine } from "./Line.js";
4
- import { Envelope, EnvelopeFrame } from "./Envelope.js";
3
+ import { Line } from "./Line.js";
4
+ import { Envelope } from "./Envelope.js";
5
5
  import { Journal } from "./Journal.js";
6
- import { JsonlEvent, JsonlEventTypeId } from "./JsonlEvent.js";
6
+ import { JsonlEvent } from "./JsonlEvent.js";
7
7
 
8
- export { Envelope, EnvelopeFrame, InvalidData, Journal, JournalClosed, JournalNotFound, JournalResync, JsonlEvent, JsonlEventTypeId, Line, LineSlice, MalformedLine, ParsedLine, TerminalViolation, UnknownEvent, UnserializableData };
8
+ export { Envelope, InvalidData, Journal, JournalClosed, JournalNotFound, JournalResync, JsonlEvent, Line, LineSlice, MalformedLine, TerminalViolation, UnknownEvent, UnserializableData };
@@ -0,0 +1,399 @@
1
+ import { JournalClosed, JournalNotFound, JournalResync, TerminalViolation } from "../JsonlError.js";
2
+ import { Line, isBlank } from "../Line.js";
3
+ import { Envelope, completeResult, frameResult } from "../Envelope.js";
4
+ import { matchesFrame } from "../Slice.js";
5
+ import { canMerge, shallowMerge } from "./merge.js";
6
+ import { probeBomBytes, readLinePages, readTailUntil } from "./tail.js";
7
+ import { ByteSize, Channel, DateTime, Deferred, Duration, Effect, Exit, Fiber, FileSystem, Option, PubSub, Result, Semaphore, Stream, SubscriptionRef } from "effect";
8
+
9
+ //#region src/internal/engine.ts
10
+ const positionOf = (item) => Result.isSuccess(item) ? item.success.position : item.failure.position;
11
+ const SHUTDOWN_PUBLISH_TIMEOUT = Duration.seconds(5);
12
+ /**
13
+ * How many times the watcher re-arms a watch that ends without observing
14
+ * anything before it gives up. No timer is permitted here to back off a watch
15
+ * that completes instantly, so giving up is the honest failure: local appends
16
+ * keep working, live observation does not.
17
+ */
18
+ const MAX_IMMEDIATE_REARMS = 8;
19
+ /**
20
+ * Scheduler turns yielded to a freshly-forked watch consumer before the
21
+ * catch-up read. `fs.watch` exposes no "registered" signal, so arming is
22
+ * ordered ahead of catch-up by scheduling, not synchronisation — empirically
23
+ * sufficient, not a proof. The airtight primitive would be
24
+ * `FileSystem.WatchBackend.register`, at the cost of `WatchBackend` in `R`.
25
+ */
26
+ const ARM_YIELDS = 3;
27
+ const encoder = new TextEncoder();
28
+ /**
29
+ * Index of the last path separator, on either convention — a Windows path
30
+ * contains no `/` at all.
31
+ */
32
+ const lastSeparator = (path) => Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
33
+ /** The last segment of a path, for comparison only — never opened. */
34
+ const basenameOf = (path) => path.slice(lastSeparator(path) + 1);
35
+ /** {@link JournalConfig.directory}'s default. */
36
+ const parentOf = (path) => {
37
+ const separator = lastSeparator(path);
38
+ if (separator < 0) return ".";
39
+ return separator === 0 ? path.slice(0, 1) : path.slice(0, separator);
40
+ };
41
+ const identityOf = (info) => Option.map(info.ino, (ino) => `${info.dev}:${ino}`);
42
+ /**
43
+ * Admit decoded items through a slice: matching envelopes pass, rejections end
44
+ * the stream only when the slice asks (`onInvalid: "fail"`) and the line could
45
+ * have mattered to it — its frame matched, or it had no frame to match.
46
+ */
47
+ const admit = (items, slice) => {
48
+ const admitted = [];
49
+ for (const item of items) {
50
+ if (Result.isSuccess(item)) {
51
+ if (matchesFrame(item.success, slice)) admitted.push(item.success);
52
+ continue;
53
+ }
54
+ const rejected = item.failure;
55
+ const relevant = Option.isNone(rejected.frame) || matchesFrame(rejected.frame.value, slice);
56
+ if (slice?.onInvalid === "fail" && relevant) return Stream.concat(Stream.fromIterable(admitted), Stream.fail(rejected.error));
57
+ }
58
+ return Stream.fromIterable(admitted);
59
+ };
60
+ /**
61
+ * Build the engine over one journal file.
62
+ *
63
+ * @internal
64
+ */
65
+ const makeEngine = (id, events, config) => Effect.gen(function* () {
66
+ const fs = yield* FileSystem.FileSystem;
67
+ const { path } = config;
68
+ const terminalTags = new Set(events.filter((event) => event.terminal).map((event) => event.tag));
69
+ const reopenTags = new Set(events.filter((event) => event.reopen).map((event) => event.tag));
70
+ const isTerminal = (item) => Result.isSuccess(item) && terminalTags.has(item.success.event);
71
+ /** Guards the WRITE critical section: file write plus state updates. */
72
+ const writePermit = Semaphore.makeUnsafe(1);
73
+ /**
74
+ * Serializes ingests against each other — two overlapping runs would read
75
+ * the same `consumed` and publish the same lines twice. Its own lock so an
76
+ * append is not blocked behind a slow catch-up read.
77
+ */
78
+ const ingestPermit = Semaphore.makeUnsafe(1);
79
+ /**
80
+ * The publish baton. Each publisher links a fresh Deferred onto this chain
81
+ * **under the write permit**, fixing publish order to write order, then
82
+ * awaits its predecessor and publishes OUTSIDE the permit — so a stalled
83
+ * subscriber blocks publishers without wedging writers or scope close.
84
+ */
85
+ let publishBaton = Deferred.makeUnsafe();
86
+ Deferred.doneUnsafe(publishBaton, Exit.void);
87
+ /** Bytes of leading BOM, probed from the file — never inferred from a window. */
88
+ let bomBytes = 0;
89
+ /**
90
+ * The file's identity, for detecting replacement. `ino` is an `Option` in
91
+ * core's `File.Info`; where it is missing this degrades to truncation-only
92
+ * detection.
93
+ */
94
+ let identity = Option.none();
95
+ /** Refusal state. Read before the permit so a late append fails fast. */
96
+ let closed = false;
97
+ /** Logical bytes consumed so far — where the next ingest resumes. */
98
+ let consumed = 0;
99
+ const hub = yield* PubSub.bounded(config.capacity ?? 64);
100
+ const latest = yield* SubscriptionRef.make(Option.none());
101
+ const logicalSize = (info) => ByteSize.toNumberUnsafe(info.size) - bomBytes;
102
+ /** `stat`, with a missing file as `none` rather than a failure. */
103
+ const statOrMissing = fs.stat(path).pipe(Effect.map(Option.some), Effect.catchReason("PlatformError", "NotFound", () => Effect.succeedNone));
104
+ const requireStat = Effect.flatMap(statOrMissing, (info) => Option.match(info, {
105
+ onNone: () => Effect.fail(new JournalNotFound({ path })),
106
+ onSome: Effect.succeed
107
+ }));
108
+ const isTerminalTail = Effect.map(SubscriptionRef.get(latest), (current) => Option.isSome(current) && terminalTags.has(current.value.event));
109
+ /**
110
+ * Decode one line into an item, filter before decode: a line whose frame
111
+ * does not match `slice` returns `undefined` and its payload schema never
112
+ * runs. With no slice, every line becomes an item.
113
+ */
114
+ const itemOf = (line, slice) => {
115
+ const position = {
116
+ offset: line.offset,
117
+ end: line.end
118
+ };
119
+ const frame = frameResult(line);
120
+ if (Result.isFailure(frame)) return Result.fail({
121
+ error: frame.failure,
122
+ frame: Option.none(),
123
+ position
124
+ });
125
+ if (!matchesFrame(frame.success, slice)) return;
126
+ const envelope = completeResult(events, line, frame.success);
127
+ return Result.isSuccess(envelope) ? Result.succeed(envelope.success) : Result.fail({
128
+ error: envelope.failure,
129
+ frame: Option.some(frame.success),
130
+ position
131
+ });
132
+ };
133
+ const itemsOf = (page, slice) => {
134
+ const items = [];
135
+ for (const line of Line.split(page.text, page.start)) {
136
+ if (!line.terminated || isBlank(line)) continue;
137
+ const item = itemOf(line, slice);
138
+ if (item !== void 0) items.push(item);
139
+ }
140
+ return items;
141
+ };
142
+ /**
143
+ * The complete, non-blank lines in `[from, to)`, in file order. `advanced`
144
+ * is where the last COMPLETE line ended: a torn tail holds the offset
145
+ * until its writer finishes it.
146
+ */
147
+ const scanRange = (from, to) => Effect.gen(function* () {
148
+ const lines = [];
149
+ let advanced = from;
150
+ yield* Stream.runForEach(readLinePages(fs, path, from, to, bomBytes), (page) => Effect.sync(() => {
151
+ for (const line of Line.split(page.text, page.start)) {
152
+ if (!line.terminated) return;
153
+ advanced = line.end;
154
+ if (!isBlank(line)) lines.push(line);
155
+ }
156
+ }));
157
+ return {
158
+ lines,
159
+ advanced
160
+ };
161
+ });
162
+ const toItems = (lines) => lines.map((line) => itemOf(line, void 0));
163
+ /** Decode the gap `[from, to)` a watcher catch-up has to publish. */
164
+ const decodeRange = (from, to) => Effect.map(scanRange(from, to), ({ lines, advanced }) => ({
165
+ items: toItems(lines),
166
+ advanced
167
+ }));
168
+ const lastEnvelope = (items) => {
169
+ for (let index = items.length - 1; index >= 0; index--) {
170
+ const item = items[index];
171
+ if (Result.isSuccess(item)) return Option.some(item.success);
172
+ }
173
+ return Option.none();
174
+ };
175
+ /** Link onto the publish chain. Call ONLY under the write permit. */
176
+ const linkBaton = () => {
177
+ const predecessor = publishBaton;
178
+ const baton = Deferred.makeUnsafe();
179
+ publishBaton = baton;
180
+ return {
181
+ predecessor,
182
+ baton
183
+ };
184
+ };
185
+ /**
186
+ * Publish in chain order, outside the write permit. `uninterruptibleMask`
187
+ * installs the baton hand-off before any interrupt can land, while the
188
+ * await-and-publish itself stays interruptible — otherwise an interrupt in
189
+ * between would leave every later publisher waiting on a baton nobody
190
+ * passes.
191
+ */
192
+ const publishAfter = (link, items) => Effect.uninterruptibleMask((restore) => restore(Effect.gen(function* () {
193
+ yield* Deferred.await(link.predecessor);
194
+ for (const item of items) yield* PubSub.publish(hub, [item]);
195
+ })).pipe(Effect.ensuring(Deferred.done(link.baton, Exit.void))));
196
+ /**
197
+ * Adopt the file as it is now: its BOM, its identity, its current state,
198
+ * and the end of its last complete line as the resume point. Construction
199
+ * and recovery from a resync both start here, so there is one definition
200
+ * of "caught up".
201
+ */
202
+ const seed = (info) => Effect.gen(function* () {
203
+ bomBytes = yield* probeBomBytes(fs, path);
204
+ identity = identityOf(info);
205
+ const resume = yield* readTailUntil(fs, path, bomBytes, (window) => {
206
+ const lines = Line.split(window.text, window.start);
207
+ for (let index = lines.length - 1; index >= 0; index--) {
208
+ const line = lines[index];
209
+ if (line.terminated) return Option.some(line.end);
210
+ }
211
+ return Option.none();
212
+ });
213
+ consumed = Option.getOrElse(resume, () => 0);
214
+ const found = yield* readTailUntil(fs, path, bomBytes, (window) => Envelope.lastValid(events, window.text, window.start));
215
+ yield* SubscriptionRef.set(latest, found);
216
+ });
217
+ /**
218
+ * The one write path. `build` runs **inside the write permit**, so an
219
+ * inherit-and-patch reads the state it patches under the same lock that
220
+ * serializes the write — reading it outside was a lost-update race.
221
+ */
222
+ const appendWith = (event, build, scope) => Effect.gen(function* () {
223
+ if (closed) return yield* new JournalClosed({ event });
224
+ const { envelope, before, after, link } = yield* writePermit.withPermits(1)(Effect.gen(function* () {
225
+ if (closed) return yield* new JournalClosed({ event });
226
+ yield* requireStat;
227
+ const current = yield* SubscriptionRef.get(latest);
228
+ if (Option.isSome(current) && terminalTags.has(current.value.event) && !reopenTags.has(event)) return yield* new TerminalViolation({
229
+ event,
230
+ terminal: current.value.event
231
+ });
232
+ const encoded = Envelope.encodeResult(events, {
233
+ event,
234
+ data: build(current),
235
+ at: yield* DateTime.now,
236
+ ...scope === void 0 ? {} : { scope }
237
+ });
238
+ if (Result.isFailure(encoded)) {
239
+ if (encoded.failure._tag === "UnknownEvent") return yield* Effect.die(encoded.failure);
240
+ return yield* encoded.failure;
241
+ }
242
+ const bytes = encoder.encode(encoded.success);
243
+ let end = yield* Effect.scoped(Effect.gen(function* () {
244
+ const file = yield* fs.open(path, { flag: "a" });
245
+ yield* file.writeAll(bytes);
246
+ return logicalSize(yield* file.stat);
247
+ }));
248
+ const text = encoded.success.slice(0, -1);
249
+ let own = {
250
+ offset: end - bytes.length,
251
+ end,
252
+ length: bytes.length - 1,
253
+ text,
254
+ terminated: true
255
+ };
256
+ let before = [];
257
+ let after = [];
258
+ if (own.offset !== consumed) {
259
+ const scan = yield* scanRange(consumed, end);
260
+ const index = scan.lines.findLastIndex((line) => line.text === text && line.offset <= own.offset);
261
+ if (index !== -1) {
262
+ own = scan.lines[index];
263
+ before = toItems(scan.lines.slice(0, index));
264
+ after = toItems(scan.lines.slice(index + 1));
265
+ end = scan.advanced;
266
+ } else before = toItems(scan.lines);
267
+ }
268
+ consumed = Math.max(consumed, end);
269
+ const decoded = Envelope.decodeResult(events, own);
270
+ if (Result.isFailure(decoded)) {
271
+ if (decoded.failure._tag === "InvalidData") return yield* decoded.failure;
272
+ return yield* Effect.die(decoded.failure);
273
+ }
274
+ yield* SubscriptionRef.set(latest, Option.orElse(lastEnvelope(after), () => Option.some(decoded.success)));
275
+ return {
276
+ envelope: decoded.success,
277
+ before,
278
+ after,
279
+ link: linkBaton()
280
+ };
281
+ }));
282
+ yield* publishAfter(link, [
283
+ ...before,
284
+ Result.succeed(envelope),
285
+ ...after
286
+ ]);
287
+ return envelope;
288
+ }).pipe(Effect.withSpan(`${id}.append`, { attributes: { event } }));
289
+ /**
290
+ * The historical read, one array of items per page. The region is fixed
291
+ * by ONE stat, so an append landing while the pages are read stays out.
292
+ */
293
+ const history = (slice) => Stream.unwrap(Effect.map(requireStat, (info) => readLinePages(fs, path, slice?.cursor ?? 0, logicalSize(info), bomBytes).pipe(Stream.map((page) => itemsOf(page, slice)))));
294
+ const changes = (slice) => Stream.unwrap(Effect.gen(function* () {
295
+ const replay = slice?.cursor === void 0 ? Stream.empty : history(slice);
296
+ if ((yield* isTerminalTail) || closed) return replay.pipe(Stream.flatMap((items) => admit(items, slice)));
297
+ const subscription = yield* PubSub.subscribe(hub);
298
+ let replayedThrough = slice?.cursor ?? 0;
299
+ const replayed = replay.pipe(Stream.tap((items) => Effect.sync(() => {
300
+ const last = items.at(-1);
301
+ if (last !== void 0) replayedThrough = Math.max(replayedThrough, positionOf(last).end);
302
+ })));
303
+ const live = Stream.fromChannel(Channel.fromEffectTake(PubSub.take(subscription))).pipe(Stream.takeUntil(isTerminal), Stream.filter((item) => positionOf(item).offset >= replayedThrough), Stream.map((item) => [item]));
304
+ return Stream.concat(replayed, live).pipe(Stream.flatMap((items) => admit(items, slice)));
305
+ }));
306
+ /**
307
+ * Ingest whatever has been appended since `consumed`, into the same hub
308
+ * and `latest` as a local append, in file order.
309
+ */
310
+ const ingest = ingestPermit.withPermits(1)(Effect.gen(function* () {
311
+ const floor = consumed;
312
+ const stat = yield* statOrMissing;
313
+ if (Option.isNone(stat)) return;
314
+ const info = stat.value;
315
+ const currentIdentity = identityOf(info);
316
+ const size = logicalSize(info);
317
+ const replaced = Option.isSome(identity) && Option.isSome(currentIdentity) && identity.value !== currentIdentity.value;
318
+ if (replaced || size < floor) {
319
+ const failure = new JournalResync({
320
+ path,
321
+ reason: replaced ? "replaced" : "truncated",
322
+ expected: floor,
323
+ actual: size
324
+ });
325
+ yield* writePermit.withPermits(1)(Effect.flatMap(statOrMissing, (current) => Option.match(current, {
326
+ onSome: seed,
327
+ onNone: () => Effect.andThen(Effect.sync(() => {
328
+ identity = Option.none();
329
+ bomBytes = 0;
330
+ consumed = 0;
331
+ }), SubscriptionRef.set(latest, Option.none()))
332
+ })));
333
+ yield* PubSub.publish(hub, Exit.fail(failure));
334
+ return;
335
+ }
336
+ identity = currentIdentity;
337
+ if (size <= consumed) return;
338
+ const { items, link } = yield* writePermit.withPermits(1)(Effect.gen(function* () {
339
+ const { items, advanced } = yield* decodeRange(consumed, size);
340
+ consumed = advanced;
341
+ const last = lastEnvelope(items);
342
+ if (Option.isSome(last)) yield* SubscriptionRef.set(latest, last);
343
+ return {
344
+ items,
345
+ link: linkBaton()
346
+ };
347
+ }));
348
+ yield* publishAfter(link, items);
349
+ }));
350
+ const journal = {
351
+ append: (event, data, options) => appendWith(event, () => data, options?.scope),
352
+ appendPatch: (event, patch, options) => appendWith(event, (current) => {
353
+ const base = Option.isSome(current) ? current.value.data : void 0;
354
+ return canMerge(base, patch) ? shallowMerge(base, patch) : patch;
355
+ }, options?.scope),
356
+ latest: SubscriptionRef.get(latest),
357
+ latestChanges: SubscriptionRef.changes(latest),
358
+ quiescent: isTerminalTail,
359
+ query: (slice) => history(slice).pipe(Stream.flatMap((items) => admit(items, slice))),
360
+ changes,
361
+ projection: (initial, fold, slice) => changes(slice).pipe(Stream.scan(() => initial, fold)),
362
+ create: Effect.scoped(fs.open(path, { flag: "a" })).pipe(Effect.asVoid, Effect.withSpan(`${id}.create`)),
363
+ remove: fs.remove(path, { force: true }).pipe(Effect.withSpan(`${id}.remove`))
364
+ };
365
+ const initial = yield* statOrMissing;
366
+ if (Option.isSome(initial)) yield* seed(initial.value).pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.void));
367
+ const basename = basenameOf(path);
368
+ const directory = config.directory ?? parentOf(path);
369
+ const watchJournal = fs.watch(path).pipe(Stream.runForEach(() => ingest), Effect.ignore);
370
+ const watchForCreation = fs.watch(directory).pipe(Stream.filter((event) => basenameOf(event.path) === basename), Stream.takeUntilEffect(() => fs.exists(path)), Stream.runForEach(() => ingest), Effect.ignore);
371
+ const supervise = Effect.gen(function* () {
372
+ let immediateCompletions = 0;
373
+ for (;;) {
374
+ const startedAt = consumed;
375
+ if (yield* fs.exists(path)) {
376
+ const armed = yield* Effect.forkChild(watchJournal);
377
+ for (let turn = 0; turn < ARM_YIELDS; turn++) yield* Effect.yieldNow;
378
+ yield* ingest;
379
+ yield* Fiber.join(armed);
380
+ yield* ingest;
381
+ } else yield* watchForCreation;
382
+ immediateCompletions = consumed === startedAt ? immediateCompletions + 1 : 0;
383
+ if (immediateCompletions > MAX_IMMEDIATE_REARMS) return;
384
+ }
385
+ });
386
+ yield* Effect.forkScoped(supervise.pipe(Effect.ignore));
387
+ yield* Effect.addFinalizer(() => Effect.gen(function* () {
388
+ closed = true;
389
+ const pending = yield* writePermit.withPermits(1)(Effect.sync(() => publishBaton));
390
+ yield* Effect.interruptible(Effect.andThen(Deferred.await(pending), PubSub.publish(hub, Exit.void))).pipe(Effect.timeout(config.shutdownPublishTimeout ?? SHUTDOWN_PUBLISH_TIMEOUT), Effect.ignore);
391
+ }));
392
+ return {
393
+ journal,
394
+ hub
395
+ };
396
+ });
397
+
398
+ //#endregion
399
+ export { makeEngine };