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