@effected/jsonl 0.10.0 → 0.11.1

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
@@ -20,6 +20,7 @@ var Journal = class {
20
20
  * @example
21
21
  * ```ts
22
22
  * import { Journal, JsonlEvent } from "@effected/jsonl";
23
+ * import { NodeJournalWatcher } from "@effected/jsonl/node";
23
24
  * import { NodeFileSystem } from "@effect/platform-node";
24
25
  * import { Effect, Schema } from "effect";
25
26
  *
@@ -33,7 +34,10 @@ var Journal = class {
33
34
  * const program = Effect.gen(function* () {
34
35
  * const mail = yield* Mail;
35
36
  * yield* mail.append("mail", { round: 1 });
36
- * }).pipe(Effect.provide(Mail.layer), Effect.provide(NodeFileSystem.layer));
37
+ * }).pipe(
38
+ * Effect.provide(Mail.layer),
39
+ * Effect.provide([NodeFileSystem.layer, NodeJournalWatcher.layer]),
40
+ * );
37
41
  * ```
38
42
  */
39
43
  static Service() {
@@ -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 };
@@ -0,0 +1,104 @@
1
+ import { JournalWatcher } from "./JournalWatcher.js";
2
+ import { Cause, Effect, Layer, PlatformError, Queue, Stream } from "effect";
3
+ import * as NFS from "node:fs";
4
+
5
+ //#region src/NodeJournalWatcher.ts
6
+ /** The `SystemErrorTag` an errno code maps to, after core's Node filesystem mapping. */
7
+ const reasonOf = (code) => {
8
+ switch (code) {
9
+ case "ENOENT": return "NotFound";
10
+ case "EACCES":
11
+ case "EPERM": return "PermissionDenied";
12
+ case "ENOTDIR":
13
+ case "ELOOP": return "BadResource";
14
+ default: return "Unknown";
15
+ }
16
+ };
17
+ const watchError = (path, cause) => PlatformError.systemError({
18
+ _tag: reasonOf(cause?.code),
19
+ module: "FileSystem",
20
+ method: "watch",
21
+ pathOrDescriptor: path,
22
+ syscall: "watch",
23
+ cause
24
+ });
25
+ /** The identity a watched file is armed on, or `undefined` once the path names nothing. */
26
+ const identityAt = (path) => {
27
+ try {
28
+ const info = NFS.statSync(path);
29
+ return `${info.dev}:${info.ino}`;
30
+ } catch {
31
+ return;
32
+ }
33
+ };
34
+ /**
35
+ * Register `fs.watch` synchronously, inside the acquire — so when the effect
36
+ * succeeds the platform watch is live, which is the contract
37
+ * {@link Jsonl.JournalWatcherShape.watch} states and core's
38
+ * `FileSystem.watch` cannot.
39
+ *
40
+ * A file watch follows the inode it was armed on, and Node emits neither
41
+ * `close` nor `error` when that inode is unlinked or renamed over: left
42
+ * alone, the stream would outlive the file and the journal would follow a
43
+ * dead inode forever. So every event on a file re-checks the path's identity,
44
+ * and the stream ends — after delivering the event — once the path is gone or
45
+ * names a different file. A directory watch never ends that way: a sibling
46
+ * appearing or vanishing is routine there.
47
+ */
48
+ const watch = (path) => Effect.gen(function* () {
49
+ const queue = yield* Queue.unbounded();
50
+ yield* Effect.acquireRelease(Effect.try({
51
+ try: () => {
52
+ const armed = NFS.statSync(path);
53
+ const identity = armed.isDirectory() ? void 0 : `${armed.dev}:${armed.ino}`;
54
+ const watcher = NFS.watch(path, (_event, name) => {
55
+ Queue.offerUnsafe(queue, name ?? void 0);
56
+ if (identity !== void 0 && identityAt(path) !== identity) Queue.endUnsafe(queue);
57
+ });
58
+ watcher.on("error", (cause) => {
59
+ Queue.failCauseUnsafe(queue, Cause.fail(watchError(path, cause)));
60
+ });
61
+ watcher.on("close", () => {
62
+ Queue.endUnsafe(queue);
63
+ });
64
+ return watcher;
65
+ },
66
+ catch: (cause) => watchError(path, cause)
67
+ }), (watcher) => Effect.sync(() => watcher.close()));
68
+ return Stream.fromQueue(queue);
69
+ });
70
+ /**
71
+ * The Node implementation of {@link Jsonl.JournalWatcher}, over `node:fs`'s
72
+ * `watch`.
73
+ *
74
+ * Registration happens synchronously while the watch effect runs, so a
75
+ * journal's catch-up read after arming can never miss an append that the
76
+ * watch also fails to report.
77
+ *
78
+ * @example
79
+ * ```ts
80
+ * import { Journal, JsonlEvent } from "@effected/jsonl";
81
+ * import { NodeJournalWatcher } from "@effected/jsonl/node";
82
+ * import { NodeFileSystem } from "@effect/platform-node";
83
+ * import { Effect, Schema } from "effect";
84
+ *
85
+ * const events = [JsonlEvent.make("mail", { data: Schema.Struct({ round: Schema.Number }) })] as const;
86
+ *
87
+ * class Mail extends Journal.Service<Mail>()("app/Mail", { events, config: { path: ".app/mail.jsonl" } }) {}
88
+ *
89
+ * const program = Effect.gen(function* () {
90
+ * const mail = yield* Mail;
91
+ * yield* mail.append("mail", { round: 1 });
92
+ * }).pipe(Effect.provide(Mail.layer), Effect.provide([NodeFileSystem.layer, NodeJournalWatcher.layer]));
93
+ * ```
94
+ *
95
+ * @public
96
+ */
97
+ var NodeJournalWatcher = class {
98
+ constructor() {}
99
+ /** Provides {@link Jsonl.JournalWatcher} over `node:fs`. */
100
+ static layer = Layer.succeed(JournalWatcher, { watch });
101
+ };
102
+
103
+ //#endregion
104
+ export { NodeJournalWatcher };
package/README.md CHANGED
@@ -41,7 +41,7 @@ Requires Node.js >=24.11.0. `effect` v4 is the only peer dependency, and the onl
41
41
 
42
42
  All `@effected/*` packages are ESM-only: the exports maps publish only `import` conditions, so `require()` — including tools that resolve in CJS mode — fails with Node's `ERR_PACKAGE_PATH_NOT_EXPORTED` rather than loading a CJS build that does not exist. Import from an ES module.
43
43
 
44
- `FileSystem` comes from `effect` core, not from a platform package, so a consumer provides it once at the edge (`NodeFileSystem.layer` from `@effect/platform-node` on Node). `Path` is deliberately not required — every path is an opaque string handed straight to `FileSystem`, and this package never joins, resolves or splits one.
44
+ `FileSystem` comes from `effect` core, not from a platform package, so a consumer provides it once at the edge (`NodeFileSystem.layer` from `@effect/platform-node` on Node). The journal also needs a `JournalWatcher` — a file watch that reports when it is armed, so an append cannot land unseen while the watch is being set up; on Node that is `NodeJournalWatcher.layer` from the `@effected/jsonl/node` subpath, the only part of the package that touches `node:*`. `Path` is deliberately not required — every path is an opaque string handed straight to `FileSystem`, and this package never joins, resolves or splits one.
45
45
 
46
46
  ## Quick start
47
47
 
@@ -49,6 +49,7 @@ Declare the events a journal may contain, mint a service class for that registry
49
49
 
50
50
  ```ts
51
51
  import { Journal, JsonlEvent } from "@effected/jsonl";
52
+ import { NodeJournalWatcher } from "@effected/jsonl/node";
52
53
  import { NodeFileSystem } from "@effect/platform-node";
53
54
  import { Effect, Option, Schema, Stream, SubscriptionRef } from "effect";
54
55
 
@@ -90,7 +91,7 @@ const program = Effect.gen(function* () {
90
91
  );
91
92
  });
92
93
 
93
- Effect.runPromise(program.pipe(Effect.provide(MailJournalLive), Effect.provide(NodeFileSystem.layer)));
94
+ Effect.runPromise(program.pipe(Effect.provide(MailJournalLive), Effect.provide([NodeFileSystem.layer, NodeJournalWatcher.layer])));
94
95
  ```
95
96
 
96
97
  `events: ["mail-received"]` narrows the stream's element type to that variant, so a projection over a slice is exhaustively checkable rather than a cast. An event marked `terminal` makes the journal quiescent once it reaches the tail: further appends fail typed unless the event is marked `reopen`, and every `changes` stream ends rather than hanging — including one whose slice excludes the terminal event, and including a subscriber that attaches after the fact.
package/index.d.ts CHANGED
@@ -582,6 +582,58 @@ export declare const Envelope: {
582
582
  }) => Result.Result<string, UnknownEvent | InvalidData | UnserializableData>;
583
583
  };
584
584
  //#endregion
585
+ //#region src/JournalWatcher.d.ts
586
+ /**
587
+ * The service shape behind {@link JournalWatcher}.
588
+ *
589
+ * @public
590
+ */
591
+ interface JournalWatcherShape {
592
+ /**
593
+ * Watch `path`, a journal file or the directory it will be created in.
594
+ *
595
+ * The effect succeeds only once the watch is **registered**: every change
596
+ * to `path` after it succeeds is delivered on the returned stream. That
597
+ * ordering is the whole contract — the journal arms a watch, then reads
598
+ * what it missed, then follows the stream, and a change landing between a
599
+ * watch that was merely requested and one that was registered would be
600
+ * lost. The watch lives until the enclosing scope closes. Watching a file,
601
+ * the stream ends once the path is removed or names a different file (a
602
+ * rename over it), because a platform watch follows the inode it was armed
603
+ * on and would otherwise go blind; the journal then re-arms on whatever the
604
+ * path names now. Watching a directory, it does not end for a child
605
+ * appearing or vanishing.
606
+ *
607
+ * Each element is the name the platform reports for the change, which may
608
+ * be a bare basename and may be absent. The journal treats every element as
609
+ * an untyped poke — "re-stat the file" — and never opens what it names.
610
+ *
611
+ * A path that does not exist fails with a `PlatformError` whose reason is
612
+ * `NotFound`.
613
+ */
614
+ readonly watch: (path: string) => Effect.Effect<Stream.Stream<string | undefined, PlatformError.PlatformError>, PlatformError.PlatformError, Scope.Scope>;
615
+ }
616
+ declare const JournalWatcher_base: Context.ServiceClass<JournalWatcher, "@effected/jsonl/JournalWatcher", JournalWatcherShape>;
617
+ /**
618
+ * How a journal is told its file changed underneath it: a watch that reports
619
+ * when it is armed.
620
+ *
621
+ * Core's `FileSystem.watch` cannot serve here. Its stream registers the
622
+ * platform watch in a fiber it forks, after an asynchronous `stat`, and gives
623
+ * no signal when that has happened — so a journal reading what it missed
624
+ * before the watch was live cannot know whether a concurrent append landed
625
+ * before registration (and is lost for good) or after (and will be reported).
626
+ * Under load that window is real. This service closes it by construction:
627
+ * {@link JournalWatcherShape.watch} succeeds only once registration is done.
628
+ *
629
+ * The library owns no backend. On Node, provide `NodeJournalWatcher.layer`
630
+ * from `@effected/jsonl/node`; elsewhere, implement the shape over the
631
+ * platform's own watch primitive, honouring the arm-before-success contract.
632
+ *
633
+ * @public
634
+ */
635
+ export declare class JournalWatcher extends JournalWatcher_base {}
636
+ //#endregion
585
637
  //#region src/Slice.d.ts
586
638
  /**
587
639
  * A filter over envelope fields, plus where to resume and what to do with a
@@ -796,13 +848,13 @@ interface JournalClass<Self, Id extends string, R extends JsonlEvent.Registry, E
796
848
  * A journal file that does not exist yet is legal and constructs cleanly; one
797
849
  * that exists and cannot be read fails with `PlatformError`.
798
850
  */
799
- readonly layer: Layer.Layer<Self, PlatformError.PlatformError | E, FileSystem.FileSystem | RC>;
851
+ readonly layer: Layer.Layer<Self, PlatformError.PlatformError | E, FileSystem.FileSystem | JournalWatcher | RC>;
800
852
  /**
801
853
  * Build a journal over an explicit config, for a path only known at run
802
854
  * time. Wrap it in `Layer.effect(Class, Class.make(config))` and bind that
803
855
  * layer once: every build is an independent journal.
804
856
  */
805
- readonly make: (config: JournalConfig) => Effect.Effect<JournalShape<R>, PlatformError.PlatformError, FileSystem.FileSystem | Scope.Scope>;
857
+ readonly make: (config: JournalConfig) => Effect.Effect<JournalShape<R>, PlatformError.PlatformError, FileSystem.FileSystem | JournalWatcher | Scope.Scope>;
806
858
  }
807
859
  /**
808
860
  * Defines journal services.
@@ -822,6 +874,7 @@ export declare class Journal {
822
874
  * @example
823
875
  * ```ts
824
876
  * import { Journal, JsonlEvent } from "@effected/jsonl";
877
+ * import { NodeJournalWatcher } from "@effected/jsonl/node";
825
878
  * import { NodeFileSystem } from "@effect/platform-node";
826
879
  * import { Effect, Schema } from "effect";
827
880
  *
@@ -835,7 +888,10 @@ export declare class Journal {
835
888
  * const program = Effect.gen(function* () {
836
889
  * const mail = yield* Mail;
837
890
  * yield* mail.append("mail", { round: 1 });
838
- * }).pipe(Effect.provide(Mail.layer), Effect.provide(NodeFileSystem.layer));
891
+ * }).pipe(
892
+ * Effect.provide(Mail.layer),
893
+ * Effect.provide([NodeFileSystem.layer, NodeJournalWatcher.layer]),
894
+ * );
839
895
  * ```
840
896
  */
841
897
  static Service<Self>(): <const Id extends string, const R extends JsonlEvent.Registry, E = never, RC = never>(id: Id, options: {
@@ -902,5 +958,5 @@ export declare class Line {
902
958
  static parseResult(line: LineSlice): Result.Result<unknown, MalformedLine>;
903
959
  }
904
960
  //#endregion
905
- export type { AppendError, AppendOptions, ChangesError, DataSchema, DecodeError, EnvelopeOf, EnvelopeUnion, EnvelopeWithTag, JournalClass, JournalConfig, JournalShape, JsonlError, LinePosition, QueryError, Slice };
961
+ export type { AppendError, AppendOptions, ChangesError, DataSchema, DecodeError, EnvelopeOf, EnvelopeUnion, EnvelopeWithTag, JournalClass, JournalConfig, JournalShape, JournalWatcherShape, JsonlError, LinePosition, QueryError, Slice };
906
962
  //# sourceMappingURL=index.d.ts.map
package/index.js CHANGED
@@ -2,7 +2,8 @@ import { LineSlice } from "./LineSlice.js";
2
2
  import { InvalidData, JournalClosed, JournalNotFound, JournalResync, MalformedLine, TerminalViolation, UnknownEvent, UnserializableData } from "./JsonlError.js";
3
3
  import { Line } from "./Line.js";
4
4
  import { Envelope } from "./Envelope.js";
5
+ import { JournalWatcher } from "./JournalWatcher.js";
5
6
  import { Journal } from "./Journal.js";
6
7
  import { JsonlEvent } from "./JsonlEvent.js";
7
8
 
8
- export { Envelope, InvalidData, Journal, JournalClosed, JournalNotFound, JournalResync, JsonlEvent, Line, LineSlice, MalformedLine, TerminalViolation, UnknownEvent, UnserializableData };
9
+ export { Envelope, InvalidData, Journal, JournalClosed, JournalNotFound, JournalResync, JournalWatcher, JsonlEvent, Line, LineSlice, MalformedLine, TerminalViolation, UnknownEvent, UnserializableData };
@@ -1,10 +1,11 @@
1
1
  import { JournalClosed, JournalNotFound, JournalResync, TerminalViolation } from "../JsonlError.js";
2
2
  import { Line, isBlank } from "../Line.js";
3
3
  import { Envelope, completeResult, frameResult } from "../Envelope.js";
4
+ import { JournalWatcher } from "../JournalWatcher.js";
4
5
  import { matchesFrame } from "../Slice.js";
5
6
  import { canMerge, shallowMerge } from "./merge.js";
6
7
  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
+ import { ByteSize, Channel, DateTime, Deferred, Duration, Effect, Exit, FileSystem, Option, PubSub, Result, Semaphore, Stream, SubscriptionRef } from "effect";
8
9
 
9
10
  //#region src/internal/engine.ts
10
11
  const positionOf = (item) => Result.isSuccess(item) ? item.success.position : item.failure.position;
@@ -16,14 +17,6 @@ const SHUTDOWN_PUBLISH_TIMEOUT = Duration.seconds(5);
16
17
  * keep working, live observation does not.
17
18
  */
18
19
  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
20
  const encoder = new TextEncoder();
28
21
  /**
29
22
  * Index of the last path separator, on either convention — a Windows path
@@ -64,6 +57,7 @@ const admit = (items, slice) => {
64
57
  */
65
58
  const makeEngine = (id, events, config) => Effect.gen(function* () {
66
59
  const fs = yield* FileSystem.FileSystem;
60
+ const watcher = yield* JournalWatcher;
67
61
  const { path } = config;
68
62
  const terminalTags = new Set(events.filter((event) => event.terminal).map((event) => event.tag));
69
63
  const reopenTags = new Set(events.filter((event) => event.reopen).map((event) => event.tag));
@@ -366,19 +360,26 @@ const makeEngine = (id, events, config) => Effect.gen(function* () {
366
360
  if (Option.isSome(initial)) yield* seed(initial.value).pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.void));
367
361
  const basename = basenameOf(path);
368
362
  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);
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
+ }));
371
375
  const supervise = Effect.gen(function* () {
372
376
  let immediateCompletions = 0;
373
377
  for (;;) {
374
378
  const startedAt = consumed;
375
379
  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* Effect.ignore(followJournal);
380
381
  yield* ingest;
381
- } else yield* watchForCreation;
382
+ } else yield* Effect.ignore(awaitCreation);
382
383
  immediateCompletions = consumed === startedAt ? immediateCompletions + 1 : 0;
383
384
  if (immediateCompletions > MAX_IMMEDIATE_REARMS) return;
384
385
  }
package/node.d.ts ADDED
@@ -0,0 +1,37 @@
1
+ import * as Jsonl from "@effected/jsonl";
2
+ import { Layer } from "effect";
3
+ //#region src/NodeJournalWatcher.d.ts
4
+ /**
5
+ * The Node implementation of {@link Jsonl.JournalWatcher}, over `node:fs`'s
6
+ * `watch`.
7
+ *
8
+ * Registration happens synchronously while the watch effect runs, so a
9
+ * journal's catch-up read after arming can never miss an append that the
10
+ * watch also fails to report.
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * import { Journal, JsonlEvent } from "@effected/jsonl";
15
+ * import { NodeJournalWatcher } from "@effected/jsonl/node";
16
+ * import { NodeFileSystem } from "@effect/platform-node";
17
+ * import { Effect, Schema } from "effect";
18
+ *
19
+ * const events = [JsonlEvent.make("mail", { data: Schema.Struct({ round: Schema.Number }) })] as const;
20
+ *
21
+ * class Mail extends Journal.Service<Mail>()("app/Mail", { events, config: { path: ".app/mail.jsonl" } }) {}
22
+ *
23
+ * const program = Effect.gen(function* () {
24
+ * const mail = yield* Mail;
25
+ * yield* mail.append("mail", { round: 1 });
26
+ * }).pipe(Effect.provide(Mail.layer), Effect.provide([NodeFileSystem.layer, NodeJournalWatcher.layer]));
27
+ * ```
28
+ *
29
+ * @public
30
+ */
31
+ export declare class NodeJournalWatcher {
32
+ private constructor();
33
+ /** Provides {@link Jsonl.JournalWatcher} over `node:fs`. */
34
+ static readonly layer: Layer.Layer<Jsonl.JournalWatcher>;
35
+ }
36
+ //#endregion
37
+ //# sourceMappingURL=node.d.ts.map
package/node.js ADDED
@@ -0,0 +1,3 @@
1
+ import { NodeJournalWatcher } from "./NodeJournalWatcher.js";
2
+
3
+ export { NodeJournalWatcher };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effected/jsonl",
3
- "version": "0.10.0",
3
+ "version": "0.11.1",
4
4
  "private": false,
5
5
  "description": "Append-only, schema-validated JSONL journals as a definable Effect service.",
6
6
  "keywords": [
@@ -34,10 +34,15 @@
34
34
  "import": "./index.js",
35
35
  "default": "./index.js"
36
36
  },
37
+ "./node": {
38
+ "types": "./node.d.ts",
39
+ "import": "./node.js",
40
+ "default": "./node.js"
41
+ },
37
42
  "./package.json": "./package.json"
38
43
  },
39
44
  "peerDependencies": {
40
- "effect": "^4.0.0"
45
+ "effect": "4.0.2"
41
46
  },
42
47
  "engines": {
43
48
  "node": ">=24.11.0"