@shivaedev/effect-changes 0.0.0 → 0.1.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/CHANGELOG.md ADDED
@@ -0,0 +1,20 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 - 2026-09-26
4
+
5
+ ### Added
6
+
7
+ - Add `makeChannel`: record changes inside a transaction and publish them once,
8
+ deduplicated by key, only after the outermost frame for their owner commits.
9
+ Nested frames merge on commit and are discarded on rollback; frames are kept
10
+ per transaction owner.
11
+ - Wrap exit-reporting native transactions with `within`, Promise-committing
12
+ drivers with `open` and `settle`, and non-transactional work with `batch`.
13
+ - Publish exactly when the database committed, including when the caller is
14
+ interrupted while `COMMIT` is in flight.
15
+ - Log a sink failure after commit, whether a failed Effect, a defect or a
16
+ synchronous throw, and keep the committed result by default;
17
+ `onPublishFailure: "die"` raises a defect instead.
18
+ - Expose `channel.Sink` and `channel.Observer` as `Context.Reference`s: tests
19
+ can swap the sink for a scope and observe every recorded, published and
20
+ discarded change.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ShivaeDev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,127 @@
1
1
  # @shivaedev/effect-changes
2
2
 
3
- This version only reserves the name. Releases are published from https://github.com/ShivaeDev/platform.
3
+ Commit-bound change channels for Effect. Code that writes rows records the changes it made; the channel publishes them only after the transaction that wrote them commits, once, deduplicated. A rollback, a failed `COMMIT` or an interruption before `COMMIT` publishes nothing.
4
+
5
+ The package imports only `effect` and runs in browsers and servers. It knows nothing about SQL or any ORM: a channel binds to a database through two things you supply, the transaction owner and the native transaction.
6
+
7
+ ```ts
8
+ import { makeChannel } from "@shivaedev/effect-changes";
9
+ import { Effect } from "effect";
10
+ import { SqlClient } from "effect/unstable/sql";
11
+
12
+ interface ChangeEvent {
13
+ readonly userId: string;
14
+ readonly domain: string;
15
+ }
16
+ declare const bus: { readonly emit: (event: ChangeEvent) => void };
17
+
18
+ const liveChanges = makeChannel<ChangeEvent, SqlClient.SqlClient>({
19
+ name: "LiveChanges",
20
+ owner: Effect.map(SqlClient.SqlClient, (sql) => sql.transactionService),
21
+ key: (event) => `${event.userId}:${event.domain}`,
22
+ publish: (events) => Effect.sync(() => events.forEach((event) => bus.emit(event))),
23
+ });
24
+
25
+ const addMember = (owner: string, member: string) =>
26
+ Effect.gen(function* () {
27
+ const sql = yield* SqlClient.SqlClient;
28
+ yield* sql`insert into membership (owner, member) values (${owner}, ${member})`;
29
+ yield* liveChanges.record([
30
+ { userId: owner, domain: "memberships" },
31
+ { userId: member, domain: "memberships" },
32
+ ]);
33
+ });
34
+
35
+ const program = Effect.flatMap(SqlClient.SqlClient, (sql) => liveChanges.within(sql.withTransaction)(addMember("owner", "member")));
36
+ ```
37
+
38
+ ## API
39
+
40
+ ```ts
41
+ makeChannel<A, R = never>(options: ChannelOptions<A, R>): Channel<A, R>
42
+
43
+ type Publish<A, R = never> = (changes: ReadonlyArray<A>) => Effect<void, unknown, R>;
44
+ type PublishFailure = "log" | "die";
45
+ type Outcome = "committed" | "rolledBack";
46
+
47
+ interface ChannelOptions<A, R> {
48
+ readonly name: string;
49
+ readonly owner: Effect<unknown, never, R>;
50
+ readonly key?: (change: A) => unknown;
51
+ readonly publish: Publish<A, R>;
52
+ readonly onPublishFailure?: PublishFailure;
53
+ readonly unowned?: Effect<void, never, R>;
54
+ }
55
+
56
+ interface Channel<A, R> {
57
+ readonly record: (changes: Iterable<A>) => Effect<void, never, R>;
58
+ readonly within: <X, E, R2, E2, R3>(
59
+ native: (body: Effect<X, E, R2>) => Effect<X, E2, R3>,
60
+ ) => (body: Effect<X, E, R2>) => Effect<X, E2, R | R3>;
61
+ readonly open: Effect<Frame, never, R>;
62
+ readonly batch: <X, E, R2>(body: Effect<X, E, R2>) => Effect<X, E, R | R2>;
63
+ readonly Sink: Context.Reference<Publish<A, R>>;
64
+ readonly Observer: Context.Reference<Observer<A>>;
65
+ }
66
+
67
+ interface Frame {
68
+ readonly provide: <X, E, R>(body: Effect<X, E, R>) => Effect<X, E, R>;
69
+ readonly settle: (outcome: Outcome) => Effect<void>;
70
+ }
71
+
72
+ type Observation<A> =
73
+ | { readonly _tag: "Recorded"; readonly changes: ReadonlyArray<A> }
74
+ | { readonly _tag: "Published"; readonly changes: ReadonlyArray<A> }
75
+ | { readonly _tag: "Discarded"; readonly changes: ReadonlyArray<A> };
76
+ type Observer<A> = (observation: Observation<A>) => Effect<void>;
77
+ ```
78
+
79
+ - `owner` identifies the transaction a change belongs to, usually one value per database or connection pool. Frames are kept per owner.
80
+ - `key` deduplicates changes. It defaults to the change itself (`Set` semantics). The first occurrence of a key keeps its place.
81
+ - `record` accepts any number of changes; one write may notify several subjects.
82
+ - `within(native)` wraps a native transaction combinator whose success means `COMMIT` (or savepoint release) happened, such as `sql.withTransaction`.
83
+ - `open` and `settle` are for drivers that report the commit through a Promise, such as Prisma's interactive `$transaction`. Open a frame, run the body with `frame.provide`, and settle it with the driver's outcome.
84
+ - `batch` defers publishing for work outside a transaction, for example one request. Its writes were autocommitted, so it publishes on any exit.
85
+ - `unowned` is an optional guard that runs when a change is recorded, or a root frame opened, with no transaction frame for the owner. Use it to refuse native transactions the channel cannot observe.
86
+
87
+ ## Semantics
88
+
89
+ | Situation | Root frame | Nested frame (savepoint) |
90
+ | --- | --- | --- |
91
+ | Body succeeds and the native transaction commits | Publish the distinct changes once, before `within` returns | Merge into the parent frame |
92
+ | Typed failure, defect, interruption before `COMMIT`, or failed `COMMIT` | Discard | Discard; the parent keeps its own changes |
93
+ | `record` after the frame settled | Die | Die |
94
+ | `record` with no frame for the owner | Run `unowned`, then publish immediately | n/a |
95
+
96
+ - A frame for owner B opened inside a frame for owner A is a root for B: it publishes when B commits, even if A rolls back later. Re-entering A inside B joins A's frame.
97
+ - The publish decision follows the commit, not the fiber. `within` runs the native combinator uninterruptibly apart from the body, so an interruption that arrives while `COMMIT` is in flight takes effect after it; the changes publish if the database committed, and the caller still sees the interruption. The native combinator must run the body once and succeed only when it committed.
98
+ - `settle` is idempotent and uninterruptible; the first outcome wins. Publishing is uninterruptible.
99
+ - A `batch` inside a transaction merges into it on any exit and follows the transaction's outcome. Transactions inside a batch merge into it when they commit.
100
+
101
+ ## Publish failures
102
+
103
+ The data is committed by the time a sink runs, so a failing sink cannot undo it. With the default `onPublishFailure: "log"`, a failed Effect, a defect, or a sink function that throws synchronously is logged once as an error with its full cause, the channel name and the change count; the span is annotated, and the caller's committed result stands. `"die"` turns the failure into a defect instead. Retries belong in the sink.
104
+
105
+ ## Testing
106
+
107
+ `channel.Sink` and `channel.Observer` are `Context.Reference`s. Provide them to a test's scope; production code that never provides them uses the configured sink and no observer.
108
+
109
+ ```ts
110
+ const published: Array<ChangeEvent> = [];
111
+ const captureSink = Layer.succeed(liveChanges.Sink, (events: ReadonlyArray<ChangeEvent>) => Effect.sync(() => published.push(...events)));
112
+
113
+ const observations: Array<Observation<ChangeEvent>> = [];
114
+ const observe = Layer.succeed(liveChanges.Observer, (observation: Observation<ChangeEvent>) => Effect.sync(() => observations.push(observation)));
115
+ ```
116
+
117
+ - The sink override replaces `publish` for everything that runs in its scope, including frames settled later from that scope. The failure policy still applies.
118
+ - The observer sees every accepted `record` call as `Recorded` (its distinct changes), every frame dropped by a rollback or failed commit as `Discarded` (including changes merged into it from committed savepoints), and every publish as `Published` (what the sink receives). A change recorded in a rolled-back savepoint therefore appears as `Recorded` and `Discarded`.
119
+
120
+ ## Limits
121
+
122
+ - Delivery is in-process. A crash between `COMMIT` and publishing loses the changes, together with the in-process subscribers that would have received them.
123
+ - There is no cross-process delivery. PostgreSQL `NOTIFY` is itself commit-bound and would be the natural transport; it is not implemented.
124
+ - Changes with the same key are not merged; the first one wins.
125
+ - Only changes that code records are published. Writes made by triggers, cascades or raw SQL that does not call `record` are invisible to the channel.
126
+
127
+ This release targets Effect `4.0.0-rc.112`.
@@ -0,0 +1,22 @@
1
+ import { Context, Effect } from "effect";
2
+ import { type Frame } from "./frame.ts";
3
+ import { type Observer } from "./observe.ts";
4
+ import { type Publish, type PublishFailure } from "./publish.ts";
5
+ export interface ChannelOptions<A, R> {
6
+ readonly name: string;
7
+ readonly owner: Effect.Effect<unknown, never, R>;
8
+ readonly key?: (change: A) => unknown;
9
+ readonly publish: Publish<A, R>;
10
+ readonly onPublishFailure?: PublishFailure;
11
+ readonly unowned?: Effect.Effect<void, never, R>;
12
+ }
13
+ export interface Channel<A, R> {
14
+ readonly record: (changes: Iterable<A>) => Effect.Effect<void, never, R>;
15
+ readonly within: <X, E, R2, E2, R3>(native: (body: Effect.Effect<X, E, R2>) => Effect.Effect<X, E2, R3>) => (body: Effect.Effect<X, E, R2>) => Effect.Effect<X, E2, R | R3>;
16
+ readonly open: Effect.Effect<Frame, never, R>;
17
+ readonly batch: <X, E, R2>(body: Effect.Effect<X, E, R2>) => Effect.Effect<X, E, R | R2>;
18
+ readonly Sink: Context.Reference<Publish<A, R>>;
19
+ readonly Observer: Context.Reference<Observer<A>>;
20
+ }
21
+ export declare const makeChannel: <A, R = never>(options: ChannelOptions<A, R>) => Channel<A, R>;
22
+ //# sourceMappingURL=channel.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"channel.d.ts","sourceRoot":"","sources":["../src/channel.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,EAAQ,MAAM,QAAQ,CAAC;AAC/C,OAAO,EAAoB,KAAK,KAAK,EAAyC,MAAM,YAAY,CAAC;AACjG,OAAO,EAAoB,KAAK,QAAQ,EAAc,MAAM,cAAc,CAAC;AAC3E,OAAO,EAAE,KAAK,OAAO,EAAE,KAAK,cAAc,EAAa,MAAM,cAAc,CAAC;AAE5E,MAAM,WAAW,cAAc,CAAC,CAAC,EAAE,CAAC;IACnC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC;IACjD,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,KAAK,OAAO,CAAC;IACtC,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;IAChC,QAAQ,CAAC,gBAAgB,CAAC,EAAE,cAAc,CAAC;IAC3C,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC;CACjD;AAED,MAAM,WAAW,OAAO,CAAC,CAAC,EAAE,CAAC;IAC5B,QAAQ,CAAC,MAAM,EAAE,CAAC,OAAO,EAAE,QAAQ,CAAC,CAAC,CAAC,KAAK,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC;IACzE,QAAQ,CAAC,MAAM,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,EACjC,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,KAAK,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,EAAE,EAAE,EAAE,CAAC,KAC/D,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,KAAK,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,GAAG,EAAE,CAAC,CAAC;IACrE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,CAAC,KAAK,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC;IAC9C,QAAQ,CAAC,KAAK,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,KAAK,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,GAAG,EAAE,CAAC,CAAC;IACzF,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;IAChD,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC,SAAS,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC;CAClD;AAID,eAAO,MAAM,WAAW,GAAI,CAAC,EAAE,CAAC,GAAG,KAAK,EAAE,SAAS,cAAc,CAAC,CAAC,EAAE,CAAC,CAAC,KAAG,OAAO,CAAC,CAAC,EAAE,CAAC,CAkErF,CAAC"}
@@ -0,0 +1,59 @@
1
+ import { Context, Effect, Exit } from "effect";
2
+ import { add, keyed, makeBuffer, makeFrame, settled } from "./frame.js";
3
+ import { unobserved } from "./observe.js";
4
+ import { publisher } from "./publish.js";
5
+ let channels = 0;
6
+ export const makeChannel = (options) => {
7
+ const { name } = options;
8
+ const prefix = `@shivaedev/effect-changes/${name}/${channels++}`;
9
+ const Frames = Context.Reference(`${prefix}/Frames`, { defaultValue: () => new Map() });
10
+ const CurrentSink = Context.Reference(`${prefix}/Sink`, { defaultValue: () => options.publish });
11
+ const CurrentObserver = Context.Reference(`${prefix}/Observer`, { defaultValue: () => unobserved });
12
+ const keyOf = options.key ?? ((change) => change);
13
+ const publish = publisher(name, options.onPublishFailure ?? "log");
14
+ const guard = options.unowned ?? Effect.void;
15
+ const unguarded = (frame) => frame === undefined || frame.kind === "batch";
16
+ const observe = (observation) => Effect.flatMap(Effect.service(CurrentObserver), (observer) => observer(observation));
17
+ const deliver = (changes) => Effect.andThen(observe({ _tag: "Published", changes }), Effect.flatMap(Effect.service(CurrentSink), (sink) => publish(sink, changes)));
18
+ const locate = Effect.gen(function* () {
19
+ const owner = yield* options.owner;
20
+ const frames = yield* Frames;
21
+ return { owner, frames, frame: frames.get(owner) };
22
+ });
23
+ const record = Effect.fn("Changes.record")(function* (changes) {
24
+ const { frame } = yield* locate;
25
+ if (unguarded(frame))
26
+ yield* guard;
27
+ const entries = keyed(changes, keyOf);
28
+ if (frame !== undefined)
29
+ yield* add(name, frame, entries);
30
+ if (entries.size === 0)
31
+ return;
32
+ const distinct = [...entries.values()];
33
+ yield* observe({ _tag: "Recorded", changes: distinct });
34
+ if (frame === undefined)
35
+ yield* deliver(distinct);
36
+ });
37
+ const openAs = (kind) => Effect.gen(function* () {
38
+ const { owner, frames, frame: parent } = yield* locate;
39
+ if (parent !== undefined && !parent.open)
40
+ return yield* settled(name);
41
+ if (kind === "transaction" && unguarded(parent))
42
+ yield* guard;
43
+ const buffer = makeBuffer(kind, parent);
44
+ const inner = new Map(frames).set(owner, buffer);
45
+ const context = yield* Effect.context();
46
+ return makeFrame({
47
+ name,
48
+ buffer,
49
+ provide: (body) => Effect.provideService(body, Frames, inner),
50
+ publish: (changes) => Effect.provideContext(deliver(changes), context),
51
+ discard: (changes) => Effect.provideContext(observe({ _tag: "Discarded", changes }), context),
52
+ });
53
+ });
54
+ const open = openAs("transaction");
55
+ const within = (native) => (body) => Effect.uninterruptibleMask((restore) => Effect.flatMap(open, (frame) => Effect.onExit(native(restore(frame.provide(body))), (exit) => frame.settle(Exit.isSuccess(exit) ? "committed" : "rolledBack"))));
56
+ const batch = (body) => Effect.uninterruptibleMask((restore) => Effect.flatMap(openAs("batch"), (frame) => Effect.onExit(restore(frame.provide(body)), () => frame.settle("committed"))));
57
+ return { record, within, open, batch, Sink: CurrentSink, Observer: CurrentObserver };
58
+ };
59
+ //# sourceMappingURL=channel.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"channel.js","sourceRoot":"","sources":["../src/channel.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,24 @@
1
+ import { Effect } from "effect";
2
+ export type Outcome = "committed" | "rolledBack";
3
+ export interface Frame {
4
+ readonly provide: <X, E, R>(body: Effect.Effect<X, E, R>) => Effect.Effect<X, E, R>;
5
+ readonly settle: (outcome: Outcome) => Effect.Effect<void>;
6
+ }
7
+ export interface Buffer<A> {
8
+ readonly kind: "transaction" | "batch";
9
+ readonly changes: Map<unknown, A>;
10
+ readonly parent: Buffer<A> | undefined;
11
+ open: boolean;
12
+ }
13
+ export declare const makeBuffer: <A>(kind: Buffer<A>["kind"], parent: Buffer<A> | undefined) => Buffer<A>;
14
+ export declare const settled: (name: string) => Effect.Effect<never>;
15
+ export declare const add: <A>(name: string, buffer: Buffer<A>, entries: Iterable<readonly [unknown, A]>) => Effect.Effect<void>;
16
+ export declare const keyed: <A>(changes: Iterable<A>, keyOf: (change: A) => unknown) => Map<unknown, A>;
17
+ export declare const makeFrame: <A>(options: {
18
+ readonly name: string;
19
+ readonly buffer: Buffer<A>;
20
+ readonly provide: <X, E, R>(body: Effect.Effect<X, E, R>) => Effect.Effect<X, E, R>;
21
+ readonly publish: (changes: ReadonlyArray<A>) => Effect.Effect<void>;
22
+ readonly discard: (changes: ReadonlyArray<A>) => Effect.Effect<void>;
23
+ }) => Frame;
24
+ //# sourceMappingURL=frame.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"frame.d.ts","sourceRoot":"","sources":["../src/frame.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEhC,MAAM,MAAM,OAAO,GAAG,WAAW,GAAG,YAAY,CAAC;AAEjD,MAAM,WAAW,KAAK;IACrB,QAAQ,CAAC,OAAO,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,KAAK,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC;IACpF,QAAQ,CAAC,MAAM,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;CAC3D;AAED,MAAM,WAAW,MAAM,CAAC,CAAC;IACxB,QAAQ,CAAC,IAAI,EAAE,aAAa,GAAG,OAAO,CAAC;IACvC,QAAQ,CAAC,OAAO,EAAE,GAAG,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC;IAClC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,GAAG,SAAS,CAAC;IACvC,IAAI,EAAE,OAAO,CAAC;CACd;AAED,eAAO,MAAM,UAAU,GAAI,CAAC,EAAE,MAAM,MAAM,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,EAAE,QAAQ,MAAM,CAAC,CAAC,CAAC,GAAG,SAAS,KAAG,MAAM,CAAC,CAAC,CAK7F,CAAC;AAEH,eAAO,MAAM,OAAO,GAAI,MAAM,MAAM,KAAG,MAAM,CAAC,MAAM,CAAC,KAAK,CACyE,CAAC;AAEpI,eAAO,MAAM,GAAG,GAAI,CAAC,EAAE,MAAM,MAAM,EAAE,QAAQ,MAAM,CAAC,CAAC,CAAC,EAAE,SAAS,QAAQ,CAAC,SAAS,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,KAAG,MAAM,CAAC,MAAM,CAAC,IAAI,CAKpG,CAAC;AAElB,eAAO,MAAM,KAAK,GAAI,CAAC,EAAE,SAAS,QAAQ,CAAC,CAAC,CAAC,EAAE,OAAO,CAAC,MAAM,EAAE,CAAC,KAAK,OAAO,KAAG,GAAG,CAAC,OAAO,EAAE,CAAC,CAO5F,CAAC;AAEF,eAAO,MAAM,SAAS,GAAI,CAAC,EAAE,SAAS;IACrC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC;IAC3B,QAAQ,CAAC,OAAO,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,KAAK,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC;IACpF,QAAQ,CAAC,OAAO,EAAE,CAAC,OAAO,EAAE,aAAa,CAAC,CAAC,CAAC,KAAK,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;IACrE,QAAQ,CAAC,OAAO,EAAE,CAAC,OAAO,EAAE,aAAa,CAAC,CAAC,CAAC,KAAK,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;CACrE,KAAG,KAWH,CAAC"}
package/dist/frame.js ADDED
@@ -0,0 +1,40 @@
1
+ import { Effect } from "effect";
2
+ export const makeBuffer = (kind, parent) => ({
3
+ kind,
4
+ changes: new Map(),
5
+ parent,
6
+ open: true,
7
+ });
8
+ export const settled = (name) => Effect.die(new Error(`${name}: changes arrived after their transaction settled; record them before the transaction body returns`));
9
+ export const add = (name, buffer, entries) => buffer.open
10
+ ? Effect.sync(() => {
11
+ for (const [key, change] of entries)
12
+ if (!buffer.changes.has(key))
13
+ buffer.changes.set(key, change);
14
+ })
15
+ : settled(name);
16
+ export const keyed = (changes, keyOf) => {
17
+ const entries = new Map();
18
+ for (const change of changes) {
19
+ const key = keyOf(change);
20
+ if (!entries.has(key))
21
+ entries.set(key, change);
22
+ }
23
+ return entries;
24
+ };
25
+ export const makeFrame = (options) => {
26
+ const { name, buffer } = options;
27
+ const settle = Effect.fn("Changes.settle")(function* (outcome) {
28
+ if (!buffer.open)
29
+ return;
30
+ buffer.open = false;
31
+ if (outcome === "committed" && buffer.parent !== undefined)
32
+ return yield* add(name, buffer.parent, buffer.changes);
33
+ const changes = [...buffer.changes.values()];
34
+ if (changes.length === 0)
35
+ return;
36
+ yield* outcome === "committed" ? options.publish(changes) : options.discard(changes);
37
+ }, Effect.uninterruptible);
38
+ return { provide: options.provide, settle };
39
+ };
40
+ //# sourceMappingURL=frame.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"frame.js","sourceRoot":"","sources":["../src/frame.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAgBhC,MAAM,CAAC,MAAM,UAAU,GAAG,CAAI,IAAuB,EAAE,MAA6B,EAAa,EAAE,CAAC,CAAC;IACpG,IAAI;IACJ,OAAO,EAAE,IAAI,GAAG,EAAE;IAClB,MAAM;IACN,IAAI,EAAE,IAAI;CACV,CAAC,CAAC;AAEH,MAAM,CAAC,MAAM,OAAO,GAAG,CAAC,IAAY,EAAwB,EAAE,CAC7D,MAAM,CAAC,GAAG,CAAC,IAAI,KAAK,CAAC,GAAG,IAAI,oGAAoG,CAAC,CAAC,CAAC;AAEpI,MAAM,CAAC,MAAM,GAAG,GAAG,CAAI,IAAY,EAAE,MAAiB,EAAE,OAAwC,EAAuB,EAAE,CACxH,MAAM,CAAC,IAAI;IACV,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,EAAE;QACjB,KAAK,MAAM,CAAC,GAAG,EAAE,MAAM,CAAC,IAAI,OAAO;YAAE,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC;gBAAE,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC;IACpG,CAAC,CAAC;IACH,CAAC,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;AAElB,MAAM,CAAC,MAAM,KAAK,GAAG,CAAI,OAAoB,EAAE,KAA6B,EAAmB,EAAE;IAChG,MAAM,OAAO,GAAG,IAAI,GAAG,EAAc,CAAC;IACtC,KAAK,MAAM,MAAM,IAAI,OAAO,EAAE,CAAC;QAC9B,MAAM,GAAG,GAAG,KAAK,CAAC,MAAM,CAAC,CAAC;QAC1B,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC;YAAE,OAAO,CAAC,GAAG,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC;IACjD,CAAC;IACD,OAAO,OAAO,CAAC;AAChB,CAAC,CAAC;AAEF,MAAM,CAAC,MAAM,SAAS,GAAG,CAAI,OAM5B,EAAS,EAAE;IACX,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC;IACjC,MAAM,MAAM,GAAG,MAAM,CAAC,EAAE,CAAC,gBAAgB,CAAC,CAAC,QAAQ,CAAC,EAAE,OAAgB;QACrE,IAAI,CAAC,MAAM,CAAC,IAAI;YAAE,OAAO;QACzB,MAAM,CAAC,IAAI,GAAG,KAAK,CAAC;QACpB,IAAI,OAAO,KAAK,WAAW,IAAI,MAAM,CAAC,MAAM,KAAK,SAAS;YAAE,OAAO,KAAK,CAAC,CAAC,GAAG,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,OAAO,CAAC,CAAC;QACnH,MAAM,OAAO,GAAG,CAAC,GAAG,MAAM,CAAC,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;QAC7C,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO;QACjC,KAAK,CAAC,CAAC,OAAO,KAAK,WAAW,CAAC,CAAC,CAAC,OAAO,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;IACtF,CAAC,EAAE,MAAM,CAAC,eAAe,CAAC,CAAC;IAC3B,OAAO,EAAE,OAAO,EAAE,OAAO,CAAC,OAAO,EAAE,MAAM,EAAE,CAAC;AAC7C,CAAC,CAAC"}
@@ -0,0 +1,5 @@
1
+ export { type Channel, type ChannelOptions, makeChannel } from "./channel.ts";
2
+ export type { Frame, Outcome } from "./frame.ts";
3
+ export type { Observation, Observer } from "./observe.ts";
4
+ export type { Publish, PublishFailure } from "./publish.ts";
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,OAAO,EAAE,KAAK,cAAc,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAC9E,YAAY,EAAE,KAAK,EAAE,OAAO,EAAE,MAAM,YAAY,CAAC;AACjD,YAAY,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAC;AAC1D,YAAY,EAAE,OAAO,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { makeChannel } from "./channel.js";
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAqC,WAAW,EAAE,MAAM,cAAc,CAAC"}
@@ -0,0 +1,14 @@
1
+ import { Effect } from "effect";
2
+ export type Observation<A> = {
3
+ readonly _tag: "Recorded";
4
+ readonly changes: ReadonlyArray<A>;
5
+ } | {
6
+ readonly _tag: "Published";
7
+ readonly changes: ReadonlyArray<A>;
8
+ } | {
9
+ readonly _tag: "Discarded";
10
+ readonly changes: ReadonlyArray<A>;
11
+ };
12
+ export type Observer<A> = (observation: Observation<A>) => Effect.Effect<void>;
13
+ export declare const unobserved: (_observation: unknown) => Effect.Effect<void>;
14
+ //# sourceMappingURL=observe.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"observe.d.ts","sourceRoot":"","sources":["../src/observe.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEhC,MAAM,MAAM,WAAW,CAAC,CAAC,IACtB;IAAE,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,aAAa,CAAC,CAAC,CAAC,CAAA;CAAE,GACjE;IAAE,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,aAAa,CAAC,CAAC,CAAC,CAAA;CAAE,GAClE;IAAE,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,aAAa,CAAC,CAAC,CAAC,CAAA;CAAE,CAAC;AAEtE,MAAM,MAAM,QAAQ,CAAC,CAAC,IAAI,CAAC,WAAW,EAAE,WAAW,CAAC,CAAC,CAAC,KAAK,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;AAE/E,eAAO,MAAM,UAAU,GAAI,cAAc,OAAO,KAAG,MAAM,CAAC,MAAM,CAAC,IAAI,CAAgB,CAAC"}
@@ -0,0 +1,3 @@
1
+ import { Effect } from "effect";
2
+ export const unobserved = (_observation) => Effect.void;
3
+ //# sourceMappingURL=observe.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"observe.js","sourceRoot":"","sources":["../src/observe.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAShC,MAAM,CAAC,MAAM,UAAU,GAAG,CAAC,YAAqB,EAAuB,EAAE,CAAC,MAAM,CAAC,IAAI,CAAC"}
@@ -0,0 +1,5 @@
1
+ import { Effect } from "effect";
2
+ export type PublishFailure = "log" | "die";
3
+ export type Publish<A, R = never> = (changes: ReadonlyArray<A>) => Effect.Effect<void, unknown, R>;
4
+ export declare const publisher: <A, R>(name: string, policy: PublishFailure) => ((sink: Publish<A, R>, changes: ReadonlyArray<A>) => Effect.Effect<void, never, R>);
5
+ //# sourceMappingURL=publish.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"publish.d.ts","sourceRoot":"","sources":["../src/publish.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEhC,MAAM,MAAM,cAAc,GAAG,KAAK,GAAG,KAAK,CAAC;AAE3C,MAAM,MAAM,OAAO,CAAC,CAAC,EAAE,CAAC,GAAG,KAAK,IAAI,CAAC,OAAO,EAAE,aAAa,CAAC,CAAC,CAAC,KAAK,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,OAAO,EAAE,CAAC,CAAC,CAAC;AAEnG,eAAO,MAAM,SAAS,GAAI,CAAC,EAAE,CAAC,EAC7B,MAAM,MAAM,EACZ,QAAQ,cAAc,KACpB,CAAC,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,OAAO,EAAE,aAAa,CAAC,CAAC,CAAC,KAAK,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC,CAW1D,CAAC"}
@@ -0,0 +1,9 @@
1
+ import { Effect } from "effect";
2
+ export const publisher = (name, policy) => Effect.fn("Changes.publish")(function* (sink, changes) {
3
+ yield* Effect.annotateCurrentSpan({ "changes.channel": name, "changes.count": changes.length });
4
+ const sent = Effect.suspend(() => sink(changes));
5
+ if (policy === "die")
6
+ return yield* Effect.orDie(sent);
7
+ yield* Effect.catchCause(sent, (cause) => Effect.andThen(Effect.annotateCurrentSpan("changes.published", false), Effect.logError("Changes.publish failed after the changes were committed; the committed result stands", cause)).pipe(Effect.annotateLogs({ channel: name, changes: changes.length })));
8
+ }, Effect.uninterruptible);
9
+ //# sourceMappingURL=publish.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"publish.js","sourceRoot":"","sources":["../src/publish.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAMhC,MAAM,CAAC,MAAM,SAAS,GAAG,CACxB,IAAY,EACZ,MAAsB,EACgE,EAAE,CACxF,MAAM,CAAC,EAAE,CAAC,iBAAiB,CAAC,CAAC,QAAQ,CAAC,EAAE,IAAmB,EAAE,OAAyB;IACrF,KAAK,CAAC,CAAC,MAAM,CAAC,mBAAmB,CAAC,EAAE,iBAAiB,EAAE,IAAI,EAAE,eAAe,EAAE,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IAChG,MAAM,IAAI,GAAG,MAAM,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC;IACjD,IAAI,MAAM,KAAK,KAAK;QAAE,OAAO,KAAK,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;IACvD,KAAK,CAAC,CAAC,MAAM,CAAC,UAAU,CAAC,IAAI,EAAE,CAAC,KAAK,EAAE,EAAE,CACxC,MAAM,CAAC,OAAO,CACb,MAAM,CAAC,mBAAmB,CAAC,mBAAmB,EAAE,KAAK,CAAC,EACtD,MAAM,CAAC,QAAQ,CAAC,sFAAsF,EAAE,KAAK,CAAC,CAC9G,CAAC,IAAI,CAAC,MAAM,CAAC,YAAY,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC,CACvE,CAAC;AACH,CAAC,EAAE,MAAM,CAAC,eAAe,CAAC,CAAC"}
package/package.json CHANGED
@@ -1,14 +1,58 @@
1
1
  {
2
2
  "name": "@shivaedev/effect-changes",
3
- "version": "0.0.0",
4
- "description": "Name reservation. The first release is published from https://github.com/ShivaeDev/platform.",
3
+ "version": "0.1.0",
4
+ "description": "Commit-bound change channels for Effect: publish recorded changes only after the transaction commits",
5
+ "type": "module",
5
6
  "license": "MIT",
6
7
  "repository": {
7
8
  "type": "git",
8
9
  "url": "git+https://github.com/ShivaeDev/platform.git",
9
10
  "directory": "packages/effect-changes"
10
11
  },
12
+ "homepage": "https://github.com/ShivaeDev/platform/tree/main/packages/effect-changes#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/ShivaeDev/platform/issues"
15
+ },
16
+ "engines": {
17
+ "node": ">=24"
18
+ },
19
+ "sideEffects": false,
20
+ "files": [
21
+ "dist",
22
+ "src",
23
+ "CHANGELOG.md",
24
+ "README.md"
25
+ ],
26
+ "exports": {
27
+ ".": {
28
+ "types": "./dist/index.d.ts",
29
+ "source": "./src/index.ts",
30
+ "import": "./dist/index.js",
31
+ "default": "./dist/index.js"
32
+ },
33
+ "./package.json": "./package.json"
34
+ },
11
35
  "publishConfig": {
12
- "access": "public"
36
+ "access": "public",
37
+ "provenance": true
38
+ },
39
+ "peerDependencies": {
40
+ "effect": "4.0.0-rc.112"
41
+ },
42
+ "devDependencies": {
43
+ "@types/node": "24.10.1",
44
+ "@typescript/native": "npm:typescript@7.0.2",
45
+ "effect": "4.0.0-rc.112",
46
+ "typescript": "npm:@typescript/typescript6@6.0.2",
47
+ "vitest": "4.1.9"
48
+ },
49
+ "scripts": {
50
+ "build": "node --eval \"import('node:fs').then(({ rmSync }) => rmSync('dist', { force: true, recursive: true }))\" && tsc6 --project tsconfig.build.json",
51
+ "check": "biome check .",
52
+ "test": "vitest run",
53
+ "test:package": "node scripts/test-package.mjs",
54
+ "typecheck": "tsc --noEmit",
55
+ "typecheck:compat": "tsc6 --noEmit",
56
+ "ready": "pnpm check && pnpm typecheck && pnpm typecheck:compat && pnpm test && pnpm build && pnpm test:package"
13
57
  }
14
- }
58
+ }
package/src/channel.ts ADDED
@@ -0,0 +1,94 @@
1
+ import { Context, Effect, Exit } from "effect";
2
+ import { add, type Buffer, type Frame, keyed, makeBuffer, makeFrame, settled } from "./frame.ts";
3
+ import { type Observation, type Observer, unobserved } from "./observe.ts";
4
+ import { type Publish, type PublishFailure, publisher } from "./publish.ts";
5
+
6
+ export interface ChannelOptions<A, R> {
7
+ readonly name: string;
8
+ readonly owner: Effect.Effect<unknown, never, R>;
9
+ readonly key?: (change: A) => unknown;
10
+ readonly publish: Publish<A, R>;
11
+ readonly onPublishFailure?: PublishFailure;
12
+ readonly unowned?: Effect.Effect<void, never, R>;
13
+ }
14
+
15
+ export interface Channel<A, R> {
16
+ readonly record: (changes: Iterable<A>) => Effect.Effect<void, never, R>;
17
+ readonly within: <X, E, R2, E2, R3>(
18
+ native: (body: Effect.Effect<X, E, R2>) => Effect.Effect<X, E2, R3>,
19
+ ) => (body: Effect.Effect<X, E, R2>) => Effect.Effect<X, E2, R | R3>;
20
+ readonly open: Effect.Effect<Frame, never, R>;
21
+ readonly batch: <X, E, R2>(body: Effect.Effect<X, E, R2>) => Effect.Effect<X, E, R | R2>;
22
+ readonly Sink: Context.Reference<Publish<A, R>>;
23
+ readonly Observer: Context.Reference<Observer<A>>;
24
+ }
25
+
26
+ let channels = 0;
27
+
28
+ export const makeChannel = <A, R = never>(options: ChannelOptions<A, R>): Channel<A, R> => {
29
+ const { name } = options;
30
+ const prefix = `@shivaedev/effect-changes/${name}/${channels++}`;
31
+ const Frames = Context.Reference<ReadonlyMap<unknown, Buffer<A>>>(`${prefix}/Frames`, { defaultValue: () => new Map() });
32
+ const CurrentSink = Context.Reference<Publish<A, R>>(`${prefix}/Sink`, { defaultValue: () => options.publish });
33
+ const CurrentObserver = Context.Reference<Observer<A>>(`${prefix}/Observer`, { defaultValue: () => unobserved });
34
+ const keyOf = options.key ?? ((change: A): unknown => change);
35
+ const publish = publisher<A, R>(name, options.onPublishFailure ?? "log");
36
+ const guard = options.unowned ?? Effect.void;
37
+ const unguarded = (frame: Buffer<A> | undefined) => frame === undefined || frame.kind === "batch";
38
+ const observe = (observation: Observation<A>) => Effect.flatMap(Effect.service(CurrentObserver), (observer) => observer(observation));
39
+ const deliver = (changes: ReadonlyArray<A>) =>
40
+ Effect.andThen(
41
+ observe({ _tag: "Published", changes }),
42
+ Effect.flatMap(Effect.service(CurrentSink), (sink) => publish(sink, changes)),
43
+ );
44
+
45
+ const locate = Effect.gen(function* () {
46
+ const owner = yield* options.owner;
47
+ const frames = yield* Frames;
48
+ return { owner, frames, frame: frames.get(owner) };
49
+ });
50
+
51
+ const record = Effect.fn("Changes.record")(function* (changes: Iterable<A>) {
52
+ const { frame } = yield* locate;
53
+ if (unguarded(frame)) yield* guard;
54
+ const entries = keyed(changes, keyOf);
55
+ if (frame !== undefined) yield* add(name, frame, entries);
56
+ if (entries.size === 0) return;
57
+ const distinct = [...entries.values()];
58
+ yield* observe({ _tag: "Recorded", changes: distinct });
59
+ if (frame === undefined) yield* deliver(distinct);
60
+ });
61
+
62
+ const openAs = (kind: Buffer<A>["kind"]) =>
63
+ Effect.gen(function* () {
64
+ const { owner, frames, frame: parent } = yield* locate;
65
+ if (parent !== undefined && !parent.open) return yield* settled(name);
66
+ if (kind === "transaction" && unguarded(parent)) yield* guard;
67
+ const buffer = makeBuffer(kind, parent);
68
+ const inner = new Map(frames).set(owner, buffer);
69
+ const context = yield* Effect.context<R>();
70
+ return makeFrame({
71
+ name,
72
+ buffer,
73
+ provide: (body) => Effect.provideService(body, Frames, inner),
74
+ publish: (changes) => Effect.provideContext(deliver(changes), context),
75
+ discard: (changes) => Effect.provideContext(observe({ _tag: "Discarded", changes }), context),
76
+ });
77
+ });
78
+
79
+ const open = openAs("transaction");
80
+
81
+ const within: Channel<A, R>["within"] = (native) => (body) =>
82
+ Effect.uninterruptibleMask((restore) =>
83
+ Effect.flatMap(open, (frame) =>
84
+ Effect.onExit(native(restore(frame.provide(body))), (exit) => frame.settle(Exit.isSuccess(exit) ? "committed" : "rolledBack")),
85
+ ),
86
+ );
87
+
88
+ const batch: Channel<A, R>["batch"] = (body) =>
89
+ Effect.uninterruptibleMask((restore) =>
90
+ Effect.flatMap(openAs("batch"), (frame) => Effect.onExit(restore(frame.provide(body)), () => frame.settle("committed"))),
91
+ );
92
+
93
+ return { record, within, open, batch, Sink: CurrentSink, Observer: CurrentObserver };
94
+ };
package/src/frame.ts ADDED
@@ -0,0 +1,60 @@
1
+ import { Effect } from "effect";
2
+
3
+ export type Outcome = "committed" | "rolledBack";
4
+
5
+ export interface Frame {
6
+ readonly provide: <X, E, R>(body: Effect.Effect<X, E, R>) => Effect.Effect<X, E, R>;
7
+ readonly settle: (outcome: Outcome) => Effect.Effect<void>;
8
+ }
9
+
10
+ export interface Buffer<A> {
11
+ readonly kind: "transaction" | "batch";
12
+ readonly changes: Map<unknown, A>;
13
+ readonly parent: Buffer<A> | undefined;
14
+ open: boolean;
15
+ }
16
+
17
+ export const makeBuffer = <A>(kind: Buffer<A>["kind"], parent: Buffer<A> | undefined): Buffer<A> => ({
18
+ kind,
19
+ changes: new Map(),
20
+ parent,
21
+ open: true,
22
+ });
23
+
24
+ export const settled = (name: string): Effect.Effect<never> =>
25
+ Effect.die(new Error(`${name}: changes arrived after their transaction settled; record them before the transaction body returns`));
26
+
27
+ export const add = <A>(name: string, buffer: Buffer<A>, entries: Iterable<readonly [unknown, A]>): Effect.Effect<void> =>
28
+ buffer.open
29
+ ? Effect.sync(() => {
30
+ for (const [key, change] of entries) if (!buffer.changes.has(key)) buffer.changes.set(key, change);
31
+ })
32
+ : settled(name);
33
+
34
+ export const keyed = <A>(changes: Iterable<A>, keyOf: (change: A) => unknown): Map<unknown, A> => {
35
+ const entries = new Map<unknown, A>();
36
+ for (const change of changes) {
37
+ const key = keyOf(change);
38
+ if (!entries.has(key)) entries.set(key, change);
39
+ }
40
+ return entries;
41
+ };
42
+
43
+ export const makeFrame = <A>(options: {
44
+ readonly name: string;
45
+ readonly buffer: Buffer<A>;
46
+ readonly provide: <X, E, R>(body: Effect.Effect<X, E, R>) => Effect.Effect<X, E, R>;
47
+ readonly publish: (changes: ReadonlyArray<A>) => Effect.Effect<void>;
48
+ readonly discard: (changes: ReadonlyArray<A>) => Effect.Effect<void>;
49
+ }): Frame => {
50
+ const { name, buffer } = options;
51
+ const settle = Effect.fn("Changes.settle")(function* (outcome: Outcome) {
52
+ if (!buffer.open) return;
53
+ buffer.open = false;
54
+ if (outcome === "committed" && buffer.parent !== undefined) return yield* add(name, buffer.parent, buffer.changes);
55
+ const changes = [...buffer.changes.values()];
56
+ if (changes.length === 0) return;
57
+ yield* outcome === "committed" ? options.publish(changes) : options.discard(changes);
58
+ }, Effect.uninterruptible);
59
+ return { provide: options.provide, settle };
60
+ };
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export { type Channel, type ChannelOptions, makeChannel } from "./channel.ts";
2
+ export type { Frame, Outcome } from "./frame.ts";
3
+ export type { Observation, Observer } from "./observe.ts";
4
+ export type { Publish, PublishFailure } from "./publish.ts";
package/src/observe.ts ADDED
@@ -0,0 +1,10 @@
1
+ import { Effect } from "effect";
2
+
3
+ export type Observation<A> =
4
+ | { readonly _tag: "Recorded"; readonly changes: ReadonlyArray<A> }
5
+ | { readonly _tag: "Published"; readonly changes: ReadonlyArray<A> }
6
+ | { readonly _tag: "Discarded"; readonly changes: ReadonlyArray<A> };
7
+
8
+ export type Observer<A> = (observation: Observation<A>) => Effect.Effect<void>;
9
+
10
+ export const unobserved = (_observation: unknown): Effect.Effect<void> => Effect.void;
package/src/publish.ts ADDED
@@ -0,0 +1,21 @@
1
+ import { Effect } from "effect";
2
+
3
+ export type PublishFailure = "log" | "die";
4
+
5
+ export type Publish<A, R = never> = (changes: ReadonlyArray<A>) => Effect.Effect<void, unknown, R>;
6
+
7
+ export const publisher = <A, R>(
8
+ name: string,
9
+ policy: PublishFailure,
10
+ ): ((sink: Publish<A, R>, changes: ReadonlyArray<A>) => Effect.Effect<void, never, R>) =>
11
+ Effect.fn("Changes.publish")(function* (sink: Publish<A, R>, changes: ReadonlyArray<A>) {
12
+ yield* Effect.annotateCurrentSpan({ "changes.channel": name, "changes.count": changes.length });
13
+ const sent = Effect.suspend(() => sink(changes));
14
+ if (policy === "die") return yield* Effect.orDie(sent);
15
+ yield* Effect.catchCause(sent, (cause) =>
16
+ Effect.andThen(
17
+ Effect.annotateCurrentSpan("changes.published", false),
18
+ Effect.logError("Changes.publish failed after the changes were committed; the committed result stands", cause),
19
+ ).pipe(Effect.annotateLogs({ channel: name, changes: changes.length })),
20
+ );
21
+ }, Effect.uninterruptible);