@shivaedev/events 0.0.0-stage → 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,7 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 - 2026-11-03
4
+
5
+ ### Added
6
+
7
+ - Keep optional durable feature history with versioned Effect Schema events, stable request ids and expected stream versions on PostgreSQL. Project through SQL repositories in the append transaction, rebuild read models with database-only projections and upcast historical payloads. Per-stream projection progress keeps late commits eligible for catch-up.
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,104 @@
1
- # Temporary Holding Version
1
+ # @shivaedev/events
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Keep durable history for the features that need to explain how they got here. Commands record versioned Effect Schema values and update ordinary SQL repositories together; stored events can rebuild those rows later.
4
+
5
+ ## History with ordinary live queries
6
+
7
+ A rename can leave both its history and its read model in one commit:
8
+
9
+ ```ts
10
+ yield* notes.append({
11
+ stream: "note-123",
12
+ requestId: "rename-456",
13
+ expectedVersion: 0,
14
+ events: [{ name: "Published" }],
15
+ });
16
+ ```
17
+
18
+ The projection writes through `@shivaedev/sql`, so existing queries refresh through their usual change keys. Ordinary features can keep using SQL directly.
19
+
20
+ ## Using it
21
+
22
+ A store groups streams with one event schema and a set of named projections. A stream starts at version zero and advances once per event. The schema version describes the payload format; it is independent of the stream version.
23
+
24
+ ### Set up PostgreSQL
25
+
26
+ Install the optional package alongside your SQL stack:
27
+
28
+ ```sh
29
+ pnpm add @shivaedev/events @shivaedev/sql effect @effect/sql-pg
30
+ ```
31
+
32
+ Add `postgresMigration` from `@shivaedev/events/postgresMigration.ts` as one entry in your application's native migration loader. Run that loader with `migratePostgres` as usual. This creates the event, request, stream and projection-progress tables. The application owns its read-model migrations and PostgreSQL Layer.
33
+
34
+ ### Define a feature
35
+
36
+ Describe the current event with Effect Schema, create ordinary repositories, then construct the store in the application's SQL environment:
37
+
38
+ ```ts
39
+ import { Effect, Schema } from "effect";
40
+ import { Model } from "effect/schema";
41
+ import { makeEventStore } from "@shivaedev/events/makeEventStore.ts";
42
+ import { makeRepository } from "@shivaedev/sql/repository.ts";
43
+
44
+ class Note extends Model.Class<Note>("Note")({
45
+ id: Schema.String,
46
+ name: Schema.String,
47
+ }) {}
48
+
49
+ const Renamed = Schema.Struct({ name: Schema.String });
50
+
51
+ const makeNotes = Effect.gen(function* () {
52
+ const rows = yield* makeRepository(Note, {
53
+ conflictColumns: ["id"],
54
+ idColumn: "id",
55
+ spanPrefix: "Note",
56
+ tableName: "notes",
57
+ });
58
+ return yield* makeEventStore({
59
+ name: "notes",
60
+ schema: Renamed,
61
+ version: 1,
62
+ projections: [
63
+ {
64
+ name: "names",
65
+ apply: ({ stream, value }) => rows.upsert({ id: stream, name: value.name }).pipe(Effect.asVoid),
66
+ reset: rows.delete({}),
67
+ },
68
+ ],
69
+ });
70
+ });
71
+ ```
72
+
73
+ The application creates the `notes` table before acquiring this store. Keep store and projection names stable: they identify durable history and progress. Use a tagged schema union when a feature emits several event types.
74
+
75
+ Projection `apply` receives `{ stream, streamVersion, requestId, value }`. `apply` and `reset` are database-only Effects: their service requirements allow SQL and Reactivity, with application failures retained in the error channel. Keep HTTP calls, jobs, email and other external effects outside projections, including services captured in closures. Rebuild invokes only `reset` and `apply`; it never runs command handlers. Replay order is stream name followed by stream version, so projections must not depend on a total order across streams.
76
+
77
+ ### Append from a command
78
+
79
+ Call `append({ stream, requestId, expectedVersion, events })` with a nonempty batch. It returns the resulting stream version. Events, request recognition, projection progress and repository writes share `sql.transact`. A projection failure rolls them all back, including earlier events in the batch. An enclosing transaction must also use `transact`.
80
+
81
+ Choose a stable request id before retrying a command. It is unique across the store. `AlreadyDone` from `errors.ts` carries the original `stream`, `streamVersion` and `requestId`; handle it as a completed request. It takes precedence over a stale expected version. `StreamConflict` carries `stream`, `expectedVersion` and `actualVersion`; reload the stream and decide the command again. Concurrent PostgreSQL writers use the same checks.
82
+
83
+ ### Read, catch up and rebuild
84
+
85
+ `read(stream)` returns decoded events in stream-version order. `catchUp` applies events missing from each named projection, tracking progress separately for each stream. Call it when adding a projection or consuming history written by a store configuration without that projection. Calling it again does not reapply events. An event that was uncommitted during a pass remains eligible for the next pass; there is no global sequence high-water mark.
86
+
87
+ `rebuild` resets the configured read models, clears their progress and replays stored events in one transaction. Its PostgreSQL table lock excludes appends and catch-up writes while rebuilding, across stores sharing these tables. This is an offline rebuild, so schedule it when blocking writes is acceptable. Each projection's reset must clear only the rows it owns.
88
+
89
+ ### Evolve event payloads
90
+
91
+ Increase the schema version for new writes and provide `upcast` to turn an older encoded payload into the current encoded shape:
92
+
93
+ ```ts
94
+ const options = {
95
+ name: "notes",
96
+ schema: Schema.Struct({ name: Schema.String }),
97
+ version: 2,
98
+ upcast: ({ payload }: { readonly version: number; readonly payload: unknown }) =>
99
+ Schema.decodeUnknownEffect(Schema.Struct({ title: Schema.String }))(payload).pipe(Effect.map(({ title }) => ({ name: title }))),
100
+ projections: [],
101
+ };
102
+ ```
103
+
104
+ The store calls the upcaster only for a different stored version, then decodes its result with the current schema's JSON codec. Without an upcaster it fails with `UnsupportedEventVersion`. For several historical versions, branch on the supplied version and compose the necessary transformations. Upcasters require no services and must perform no external effects. Stored payloads remain unchanged.
@@ -0,0 +1,5 @@
1
+ import { Effect, Schema } from "effect";
2
+ import { AlreadyDone, StreamConflict } from "#errors.ts";
3
+ import type { Append, EventStoreOptions } from "#types.ts";
4
+ export declare const append: <A, E, TUpcastError>(options: EventStoreOptions<A, E, TUpcastError>, command: Append<A>) => Effect.Effect<number, AlreadyDone | Schema.SchemaError | import("effect/sql/SqlError").SqlError | StreamConflict | import("#errors.ts").UnsupportedEventVersion | Exclude<E, import("effect/sql/SqlError").SqlError> | Exclude<TUpcastError, import("effect/sql/SqlError").SqlError>, import("#types.ts").ProjectionServices>;
5
+ //# sourceMappingURL=append.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"append.d.ts","sourceRoot":"","sources":["../src/append.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAGxC,OAAO,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAEzD,OAAO,KAAK,EAAE,MAAM,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAC;AAI3D,eAAO,MAAM,MAAM,GAAyC,CAAC,EAAE,CAAC,EAAE,YAAY,sYAoC5E,CAAC"}
package/dist/append.js ADDED
@@ -0,0 +1,35 @@
1
+ import { Effect, Schema } from "effect";
2
+ import { SqlClient } from "effect/sql";
3
+ import { transact } from "@shivaedev/sql/transact.ts";
4
+ import { AlreadyDone, StreamConflict } from "#errors.ts";
5
+ import { projectStream } from "#projectStream.ts";
6
+ const Version = Schema.Struct({ version: Schema.Int });
7
+ export const append = Effect.fn("Events.append")(function* (options, command) {
8
+ return yield* transact(Effect.gen(function* () {
9
+ const sql = yield* SqlClient.SqlClient;
10
+ const version = command.expectedVersion + command.events.length;
11
+ const claimed = yield* sql `insert into events_requests (store, request_id, stream, version)
12
+ values (${options.name}, ${command.requestId}, ${command.stream}, ${version})
13
+ on conflict do nothing returning request_id`;
14
+ if (claimed.length === 0) {
15
+ const [done] = yield* sql `select stream, version from events_requests
16
+ where store = ${options.name} and request_id = ${command.requestId}`.pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.NonEmptyArray(Schema.Struct({ stream: Schema.String, version: Schema.Int })))));
17
+ return yield* Effect.fail(new AlreadyDone({ requestId: command.requestId, stream: done.stream, streamVersion: done.version }));
18
+ }
19
+ yield* sql `insert into events_streams (store, stream, version) values (${options.name}, ${command.stream}, 0) on conflict do nothing`;
20
+ const changed = yield* sql `update events_streams set version = ${version}
21
+ where store = ${options.name} and stream = ${command.stream} and version = ${command.expectedVersion} returning version`;
22
+ if (changed.length === 0) {
23
+ const [current] = yield* sql `select version from events_streams where store = ${options.name} and stream = ${command.stream}`.pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.NonEmptyArray(Version))));
24
+ return yield* Effect.fail(new StreamConflict({ actualVersion: current.version, expectedVersion: command.expectedVersion, stream: command.stream }));
25
+ }
26
+ for (const [index, event] of command.events.entries()) {
27
+ const payload = yield* Schema.encodeEffect(Schema.toCodecJson(options.schema))(event);
28
+ yield* sql `insert into events_log (store, stream, stream_version, request_id, event_version, payload)
29
+ values (${options.name}, ${command.stream}, ${command.expectedVersion + index + 1}, ${command.requestId}, ${options.version}, ${JSON.stringify(payload)}::jsonb)`;
30
+ }
31
+ yield* projectStream(options, command.stream);
32
+ return version;
33
+ }), { onSqlError: (error) => error });
34
+ });
35
+ //# sourceMappingURL=append.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"append.js","sourceRoot":"","sources":["../src/append.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,28 @@
1
+ declare const AlreadyDone_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
2
+ readonly _tag: "AlreadyDone";
3
+ } & Readonly<A>;
4
+ export declare class AlreadyDone extends AlreadyDone_base<{
5
+ readonly requestId: string;
6
+ readonly stream: string;
7
+ readonly streamVersion: number;
8
+ }> {
9
+ }
10
+ declare const StreamConflict_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
11
+ readonly _tag: "StreamConflict";
12
+ } & Readonly<A>;
13
+ export declare class StreamConflict extends StreamConflict_base<{
14
+ readonly stream: string;
15
+ readonly expectedVersion: number;
16
+ readonly actualVersion: number;
17
+ }> {
18
+ }
19
+ declare const UnsupportedEventVersion_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
20
+ readonly _tag: "UnsupportedEventVersion";
21
+ } & Readonly<A>;
22
+ export declare class UnsupportedEventVersion extends UnsupportedEventVersion_base<{
23
+ readonly storedVersion: number;
24
+ readonly currentVersion: number;
25
+ }> {
26
+ }
27
+ export {};
28
+ //# sourceMappingURL=errors.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":";;;AAEA,qBAAa,WAAY,SAAQ,iBAAgC;IAChE,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;CAC/B,CAAC;CAAG;;;;AAEL,qBAAa,cAAe,SAAQ,oBAAmC;IACtE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;CAC/B,CAAC;CAAG;;;;AAEL,qBAAa,uBAAwB,SAAQ,6BAA4C;IACxF,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;CAChC,CAAC;CAAG"}
package/dist/errors.js ADDED
@@ -0,0 +1,8 @@
1
+ import { Data } from "effect";
2
+ export class AlreadyDone extends Data.TaggedError("AlreadyDone") {
3
+ }
4
+ export class StreamConflict extends Data.TaggedError("StreamConflict") {
5
+ }
6
+ export class UnsupportedEventVersion extends Data.TaggedError("UnsupportedEventVersion") {
7
+ }
8
+ //# sourceMappingURL=errors.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.js","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAC;AAE9B,MAAM,OAAO,WAAY,SAAQ,IAAI,CAAC,WAAW,CAAC,aAAa,CAI7D;CAAG;AAEL,MAAM,OAAO,cAAe,SAAQ,IAAI,CAAC,WAAW,CAAC,gBAAgB,CAInE;CAAG;AAEL,MAAM,OAAO,uBAAwB,SAAQ,IAAI,CAAC,WAAW,CAAC,yBAAyB,CAGrF;CAAG"}
@@ -0,0 +1,15 @@
1
+ import { Effect, Schema } from "effect";
2
+ import { SqlClient } from "effect/sql";
3
+ import type { Append, EventStoreOptions } from "#types.ts";
4
+ export declare const makeEventStore: <A, E = never, TUpcastError = never>(options: EventStoreOptions<A, E, TUpcastError>) => Effect.Effect<{
5
+ append: (command: Append<A>) => Effect.Effect<number, import("./errors.ts").AlreadyDone | Schema.SchemaError | import("effect/sql/SqlError").SqlError | import("./errors.ts").StreamConflict | import("./errors.ts").UnsupportedEventVersion | Exclude<E, import("effect/sql/SqlError").SqlError> | Exclude<TUpcastError, import("effect/sql/SqlError").SqlError>, import("#types.ts").ProjectionServices>;
6
+ catchUp: Effect.Effect<void, Schema.SchemaError | import("effect/sql/SqlError").SqlError | import("./errors.ts").UnsupportedEventVersion | Exclude<E, import("effect/sql/SqlError").SqlError> | Exclude<TUpcastError, import("effect/sql/SqlError").SqlError>, import("#types.ts").ProjectionServices>;
7
+ read: (stream: string) => Effect.Effect<{
8
+ requestId: string;
9
+ stream: string;
10
+ streamVersion: number;
11
+ value: A;
12
+ }[], TUpcastError | Schema.SchemaError | import("effect/sql/SqlError").SqlError | import("./errors.ts").UnsupportedEventVersion, SqlClient.SqlClient>;
13
+ rebuild: Effect.Effect<void, Schema.SchemaError | import("effect/sql/SqlError").SqlError | import("./errors.ts").UnsupportedEventVersion | Exclude<E, import("effect/sql/SqlError").SqlError> | Exclude<TUpcastError, import("effect/sql/SqlError").SqlError>, import("#types.ts").ProjectionServices>;
14
+ }, never, SqlClient.SqlClient>;
15
+ //# sourceMappingURL=makeEventStore.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"makeEventStore.d.ts","sourceRoot":"","sources":["../src/makeEventStore.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AACxC,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAKvC,OAAO,KAAK,EAAE,MAAM,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAC;AAS3D,eAAO,MAAM,cAAc,GAAiD,CAAC,EAAE,CAAC,UAAU,YAAY;sBA6BlF,MAAM,CAAC,CAAC,CAAC;;mBAEZ,MAAM;;;;;;;8BAGrB,CAAC"}
@@ -0,0 +1,38 @@
1
+ import { Effect, Schema } from "effect";
2
+ import { SqlClient } from "effect/sql";
3
+ import { transact } from "@shivaedev/sql/transact.ts";
4
+ import { append } from "#append.ts";
5
+ import { projectStream } from "#projectStream.ts";
6
+ import { readEvents } from "#readEvents.ts";
7
+ const streams = Effect.fn("Events.streams")(function* (name) {
8
+ const sql = yield* SqlClient.SqlClient;
9
+ return yield* sql `select stream from events_streams where store = ${name} order by stream`.pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.Array(Schema.Struct({ stream: Schema.String })))));
10
+ });
11
+ export const makeEventStore = Effect.fn("Events.makeEventStore")(function* (options) {
12
+ const sql = yield* SqlClient.SqlClient;
13
+ const catchUp = Effect.gen(function* () {
14
+ for (const { stream } of yield* streams(options.name)) {
15
+ yield* transact(Effect.gen(function* () {
16
+ yield* sql `select version from events_streams where store = ${options.name} and stream = ${stream} for update`;
17
+ yield* projectStream(options, stream);
18
+ }), { onSqlError: (error) => error });
19
+ }
20
+ });
21
+ const rebuild = transact(Effect.gen(function* () {
22
+ yield* sql `lock table events_streams in exclusive mode`;
23
+ for (const projection of options.projections) {
24
+ yield* projection.reset;
25
+ yield* sql `delete from events_progress where store = ${options.name} and projection = ${projection.name}`;
26
+ }
27
+ for (const { stream } of yield* streams(options.name)) {
28
+ yield* projectStream(options, stream);
29
+ }
30
+ }), { onSqlError: (error) => error });
31
+ return {
32
+ append: (command) => append(options, command),
33
+ catchUp,
34
+ read: (stream) => readEvents(options, stream),
35
+ rebuild,
36
+ };
37
+ });
38
+ //# sourceMappingURL=makeEventStore.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"makeEventStore.js","sourceRoot":"","sources":["../src/makeEventStore.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AACxC,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AACvC,OAAO,EAAE,QAAQ,EAAE,MAAM,4BAA4B,CAAC;AACtD,OAAO,EAAE,MAAM,EAAE,MAAM,YAAY,CAAC;AACpC,OAAO,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAClD,OAAO,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAG5C,MAAM,OAAO,GAAG,MAAM,CAAC,EAAE,CAAC,gBAAgB,CAAC,CAAC,QAAQ,CAAC,EAAE,IAAY;IAClE,MAAM,GAAG,GAAG,KAAK,CAAC,CAAC,SAAS,CAAC,SAAS,CAAC;IACvC,OAAO,KAAK,CAAC,CAAC,GAAG,CAAA,mDAAmD,IAAI,kBAAkB,CAAC,IAAI,CAC9F,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,mBAAmB,CAAC,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,CAAC,CAClG,CAAC;AACH,CAAC,CAAC,CAAC;AAEH,MAAM,CAAC,MAAM,cAAc,GAAG,MAAM,CAAC,EAAE,CAAC,uBAAuB,CAAC,CAAC,QAAQ,CAAC,EACzE,OAA8C;IAE9C,MAAM,GAAG,GAAG,KAAK,CAAC,CAAC,SAAS,CAAC,SAAS,CAAC;IACvC,MAAM,OAAO,GAAG,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC;QACnC,KAAK,MAAM,EAAE,MAAM,EAAE,IAAI,KAAK,CAAC,CAAC,OAAO,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC;YACvD,KAAK,CAAC,CAAC,QAAQ,CACd,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC;gBACnB,KAAK,CAAC,CAAC,GAAG,CAAA,oDAAoD,OAAO,CAAC,IAAI,iBAAiB,MAAM,aAAa,CAAC;gBAC/G,KAAK,CAAC,CAAC,aAAa,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;YACvC,CAAC,CAAC,EACF,EAAE,UAAU,EAAE,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,EAAE,CAChC,CAAC;QACH,CAAC;IACF,CAAC,CAAC,CAAC;IACH,MAAM,OAAO,GAAG,QAAQ,CACvB,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC;QACnB,KAAK,CAAC,CAAC,GAAG,CAAA,6CAA6C,CAAC;QACxD,KAAK,MAAM,UAAU,IAAI,OAAO,CAAC,WAAW,EAAE,CAAC;YAC9C,KAAK,CAAC,CAAC,UAAU,CAAC,KAAK,CAAC;YACxB,KAAK,CAAC,CAAC,GAAG,CAAA,6CAA6C,OAAO,CAAC,IAAI,qBAAqB,UAAU,CAAC,IAAI,EAAE,CAAC;QAC3G,CAAC;QACD,KAAK,MAAM,EAAE,MAAM,EAAE,IAAI,KAAK,CAAC,CAAC,OAAO,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC;YACvD,KAAK,CAAC,CAAC,aAAa,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;QACvC,CAAC;IACF,CAAC,CAAC,EACF,EAAE,UAAU,EAAE,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,EAAE,CAChC,CAAC;IACF,OAAO;QACN,MAAM,EAAE,CAAC,OAAkB,EAAE,EAAE,CAAC,MAAM,CAAC,OAAO,EAAE,OAAO,CAAC;QACxD,OAAO;QACP,IAAI,EAAE,CAAC,MAAc,EAAE,EAAE,CAAC,UAAU,CAAC,OAAO,EAAE,MAAM,CAAC;QACrD,OAAO;KACP,CAAC;AACH,CAAC,CAAC,CAAC"}
@@ -0,0 +1,4 @@
1
+ import { Effect } from "effect";
2
+ import { SqlClient } from "effect/sql";
3
+ export declare const postgresMigration: Effect.Effect<void, import("effect/sql/SqlError").SqlError, SqlClient.SqlClient>;
4
+ //# sourceMappingURL=postgresMigration.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"postgresMigration.d.ts","sourceRoot":"","sources":["../src/postgresMigration.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAEvC,eAAO,MAAM,iBAAiB,kFAqB5B,CAAC"}
@@ -0,0 +1,25 @@
1
+ import { Effect } from "effect";
2
+ import { SqlClient } from "effect/sql";
3
+ export const postgresMigration = Effect.gen(function* () {
4
+ const sql = yield* SqlClient.SqlClient;
5
+ yield* sql `create table events_streams (
6
+ store text not null, stream text not null, version integer not null,
7
+ primary key (store, stream)
8
+ )`;
9
+ yield* sql `create table events_requests (
10
+ store text not null, request_id text not null, stream text not null, version integer not null,
11
+ primary key (store, request_id)
12
+ )`;
13
+ yield* sql `create table events_log (
14
+ store text not null, stream text not null, stream_version integer not null,
15
+ request_id text not null, event_version integer not null, payload jsonb not null,
16
+ primary key (store, stream, stream_version),
17
+ foreign key (store, stream) references events_streams (store, stream)
18
+ )`;
19
+ yield* sql `create table events_progress (
20
+ store text not null, projection text not null, stream text not null, version integer not null,
21
+ primary key (store, projection, stream),
22
+ foreign key (store, stream) references events_streams (store, stream)
23
+ )`;
24
+ });
25
+ //# sourceMappingURL=postgresMigration.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"postgresMigration.js","sourceRoot":"","sources":["../src/postgresMigration.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAEvC,MAAM,CAAC,MAAM,iBAAiB,GAAG,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC;IACpD,MAAM,GAAG,GAAG,KAAK,CAAC,CAAC,SAAS,CAAC,SAAS,CAAC;IACvC,KAAK,CAAC,CAAC,GAAG,CAAA;;;GAGR,CAAC;IACH,KAAK,CAAC,CAAC,GAAG,CAAA;;;GAGR,CAAC;IACH,KAAK,CAAC,CAAC,GAAG,CAAA;;;;;GAKR,CAAC;IACH,KAAK,CAAC,CAAC,GAAG,CAAA;;;;GAIR,CAAC;AACJ,CAAC,CAAC,CAAC"}
@@ -0,0 +1,4 @@
1
+ import { Effect, Schema } from "effect";
2
+ import type { EventStoreOptions } from "#types.ts";
3
+ export declare const projectStream: <A, E, TUpcastError>(options: EventStoreOptions<A, E, TUpcastError>, stream: string) => Effect.Effect<void, E | TUpcastError | Schema.SchemaError | import("effect/sql/SqlError").SqlError | import("./errors.ts").UnsupportedEventVersion, import("#types.ts").ProjectionServices>;
4
+ //# sourceMappingURL=projectStream.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"projectStream.d.ts","sourceRoot":"","sources":["../src/projectStream.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAGxC,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAC;AAEnD,eAAO,MAAM,aAAa,GAAgD,CAAC,EAAE,CAAC,EAAE,YAAY,gQA6B1F,CAAC"}
@@ -0,0 +1,29 @@
1
+ import { Effect, Schema } from "effect";
2
+ import { SqlClient } from "effect/sql";
3
+ import { readEvents } from "#readEvents.ts";
4
+ export const projectStream = Effect.fn("Events.projectStream")(function* (options, stream) {
5
+ const sql = yield* SqlClient.SqlClient;
6
+ const checkpoints = [];
7
+ for (const projection of options.projections) {
8
+ yield* sql `insert into events_progress (store, projection, stream, version)
9
+ values (${options.name}, ${projection.name}, ${stream}, 0) on conflict do nothing`;
10
+ const progress = yield* sql `select version from events_progress
11
+ where store = ${options.name} and projection = ${projection.name} and stream = ${stream} for update`.pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.NonEmptyArray(Schema.Struct({ version: Schema.Int })))));
12
+ checkpoints.push({ projection, version: progress[0].version });
13
+ }
14
+ if (checkpoints.length === 0) {
15
+ return;
16
+ }
17
+ const events = yield* readEvents(options, stream, Math.min(...checkpoints.map(({ version }) => version)));
18
+ for (const event of events) {
19
+ for (const { projection, version } of checkpoints) {
20
+ if (event.streamVersion <= version) {
21
+ continue;
22
+ }
23
+ yield* projection.apply(event);
24
+ yield* sql `update events_progress set version = ${event.streamVersion}
25
+ where store = ${options.name} and projection = ${projection.name} and stream = ${stream}`;
26
+ }
27
+ }
28
+ });
29
+ //# sourceMappingURL=projectStream.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"projectStream.js","sourceRoot":"","sources":["../src/projectStream.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AACxC,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AACvC,OAAO,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAG5C,MAAM,CAAC,MAAM,aAAa,GAAG,MAAM,CAAC,EAAE,CAAC,sBAAsB,CAAC,CAAC,QAAQ,CAAC,EACvE,OAA8C,EAC9C,MAAc;IAEd,MAAM,GAAG,GAAG,KAAK,CAAC,CAAC,SAAS,CAAC,SAAS,CAAC;IACvC,MAAM,WAAW,GAA8F,EAAE,CAAC;IAClH,KAAK,MAAM,UAAU,IAAI,OAAO,CAAC,WAAW,EAAE,CAAC;QAC9C,KAAK,CAAC,CAAC,GAAG,CAAA;aACC,OAAO,CAAC,IAAI,KAAK,UAAU,CAAC,IAAI,KAAK,MAAM,6BAA6B,CAAC;QACpF,MAAM,QAAQ,GAAG,KAAK,CAAC,CAAC,GAAG,CAAA;mBACV,OAAO,CAAC,IAAI,qBAAqB,UAAU,CAAC,IAAI,iBAAiB,MAAM,aAAa,CAAC,IAAI,CACzG,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,mBAAmB,CAAC,MAAM,CAAC,aAAa,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,OAAO,EAAE,MAAM,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC,CAAC,CACxG,CAAC;QACF,WAAW,CAAC,IAAI,CAAC,EAAE,UAAU,EAAE,OAAO,EAAE,QAAQ,CAAC,CAAC,CAAC,CAAC,OAAO,EAAE,CAAC,CAAC;IAChE,CAAC;IACD,IAAI,WAAW,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC9B,OAAO;IACR,CAAC;IACD,MAAM,MAAM,GAAG,KAAK,CAAC,CAAC,UAAU,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,GAAG,CAAC,GAAG,WAAW,CAAC,GAAG,CAAC,CAAC,EAAE,OAAO,EAAE,EAAE,EAAE,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC;IAC1G,KAAK,MAAM,KAAK,IAAI,MAAM,EAAE,CAAC;QAC5B,KAAK,MAAM,EAAE,UAAU,EAAE,OAAO,EAAE,IAAI,WAAW,EAAE,CAAC;YACnD,IAAI,KAAK,CAAC,aAAa,IAAI,OAAO,EAAE,CAAC;gBACpC,SAAS;YACV,CAAC;YACD,KAAK,CAAC,CAAC,UAAU,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;YAC/B,KAAK,CAAC,CAAC,GAAG,CAAA,wCAAwC,KAAK,CAAC,aAAa;oBACpD,OAAO,CAAC,IAAI,qBAAqB,UAAU,CAAC,IAAI,iBAAiB,MAAM,EAAE,CAAC;QAC5F,CAAC;IACF,CAAC;AACF,CAAC,CAAC,CAAC"}
@@ -0,0 +1,11 @@
1
+ import { Effect, Schema } from "effect";
2
+ import { SqlClient } from "effect/sql";
3
+ import { UnsupportedEventVersion } from "#errors.ts";
4
+ import type { EventStoreOptions } from "#types.ts";
5
+ export declare const readEvents: <A, E, TUpcastError>(options: EventStoreOptions<A, E, TUpcastError>, stream: string, after?: number | undefined) => Effect.Effect<{
6
+ requestId: string;
7
+ stream: string;
8
+ streamVersion: number;
9
+ value: A;
10
+ }[], TUpcastError | Schema.SchemaError | import("effect/sql/SqlError").SqlError | UnsupportedEventVersion, SqlClient.SqlClient>;
11
+ //# sourceMappingURL=readEvents.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"readEvents.d.ts","sourceRoot":"","sources":["../src/readEvents.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AACxC,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AACvC,OAAO,EAAE,uBAAuB,EAAE,MAAM,YAAY,CAAC;AACrD,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAC;AASnD,eAAO,MAAM,UAAU,GAA6C,CAAC,EAAE,CAAC,EAAE,YAAY;;;;;+HA0BpF,CAAC"}
@@ -0,0 +1,31 @@
1
+ import { Effect, Schema } from "effect";
2
+ import { SqlClient } from "effect/sql";
3
+ import { UnsupportedEventVersion } from "#errors.ts";
4
+ const Entry = Schema.Struct({
5
+ eventVersion: Schema.Int,
6
+ payload: Schema.Unknown,
7
+ requestId: Schema.String,
8
+ streamVersion: Schema.Int,
9
+ });
10
+ export const readEvents = Effect.fn("Events.readEvents")(function* (options, stream, after = 0) {
11
+ const sql = yield* SqlClient.SqlClient;
12
+ const entries = yield* sql `select event_version as "eventVersion", payload, request_id as "requestId", stream_version as "streamVersion"
13
+ from events_log where store = ${options.name} and stream = ${stream} and stream_version > ${after}
14
+ order by stream_version`.withoutTransform.pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.Array(Entry))));
15
+ return yield* Effect.forEach(entries, (entry) => Effect.gen(function* () {
16
+ let payload = entry.payload;
17
+ if (entry.eventVersion !== options.version) {
18
+ if (options.upcast === undefined) {
19
+ return yield* Effect.fail(new UnsupportedEventVersion({ currentVersion: options.version, storedVersion: entry.eventVersion }));
20
+ }
21
+ payload = yield* options.upcast({ payload: entry.payload, version: entry.eventVersion });
22
+ }
23
+ return {
24
+ requestId: entry.requestId,
25
+ stream,
26
+ streamVersion: entry.streamVersion,
27
+ value: yield* Schema.decodeUnknownEffect(Schema.toCodecJson(options.schema))(payload),
28
+ };
29
+ }));
30
+ });
31
+ //# sourceMappingURL=readEvents.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"readEvents.js","sourceRoot":"","sources":["../src/readEvents.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AACxC,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AACvC,OAAO,EAAE,uBAAuB,EAAE,MAAM,YAAY,CAAC;AAGrD,MAAM,KAAK,GAAG,MAAM,CAAC,MAAM,CAAC;IAC3B,YAAY,EAAE,MAAM,CAAC,GAAG;IACxB,OAAO,EAAE,MAAM,CAAC,OAAO;IACvB,SAAS,EAAE,MAAM,CAAC,MAAM;IACxB,aAAa,EAAE,MAAM,CAAC,GAAG;CACzB,CAAC,CAAC;AAEH,MAAM,CAAC,MAAM,UAAU,GAAG,MAAM,CAAC,EAAE,CAAC,mBAAmB,CAAC,CAAC,QAAQ,CAAC,EACjE,OAA8C,EAC9C,MAAc,EACd,KAAK,GAAG,CAAC;IAET,MAAM,GAAG,GAAG,KAAK,CAAC,CAAC,SAAS,CAAC,SAAS,CAAC;IACvC,MAAM,OAAO,GAAG,KAAK,CAAC,CAAC,GAAG,CAAA;kCACO,OAAO,CAAC,IAAI,iBAAiB,MAAM,yBAAyB,KAAK;0BACzE,CAAC,gBAAgB,CAAC,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,mBAAmB,CAAC,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;IACjH,OAAO,KAAK,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,OAAO,EAAE,CAAC,KAAK,EAAE,EAAE,CAC/C,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC;QACnB,IAAI,OAAO,GAAG,KAAK,CAAC,OAAO,CAAC;QAC5B,IAAI,KAAK,CAAC,YAAY,KAAK,OAAO,CAAC,OAAO,EAAE,CAAC;YAC5C,IAAI,OAAO,CAAC,MAAM,KAAK,SAAS,EAAE,CAAC;gBAClC,OAAO,KAAK,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,uBAAuB,CAAC,EAAE,cAAc,EAAE,OAAO,CAAC,OAAO,EAAE,aAAa,EAAE,KAAK,CAAC,YAAY,EAAE,CAAC,CAAC,CAAC;YAChI,CAAC;YACD,OAAO,GAAG,KAAK,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,OAAO,EAAE,KAAK,CAAC,OAAO,EAAE,OAAO,EAAE,KAAK,CAAC,YAAY,EAAE,CAAC,CAAC;QAC1F,CAAC;QACD,OAAO;YACN,SAAS,EAAE,KAAK,CAAC,SAAS;YAC1B,MAAM;YACN,aAAa,EAAE,KAAK,CAAC,aAAa;YAClC,KAAK,EAAE,KAAK,CAAC,CAAC,MAAM,CAAC,mBAAmB,CAAC,MAAM,CAAC,WAAW,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,OAAO,CAAC;SACrF,CAAC;IACH,CAAC,CAAC,CACF,CAAC;AACH,CAAC,CAAC,CAAC"}
@@ -0,0 +1,33 @@
1
+ import type { Effect, Schema } from "effect";
2
+ import type { Reactivity } from "effect/reactivity/Reactivity";
3
+ import type { SqlClient } from "effect/sql";
4
+ export type ProjectionServices = SqlClient.SqlClient | Reactivity;
5
+ export interface StoredEvent {
6
+ readonly payload: unknown;
7
+ readonly version: number;
8
+ }
9
+ export interface Event<A> {
10
+ readonly requestId: string;
11
+ readonly stream: string;
12
+ readonly streamVersion: number;
13
+ readonly value: A;
14
+ }
15
+ export interface Projection<A, E> {
16
+ readonly apply: (event: Event<A>) => Effect.Effect<void, E, ProjectionServices>;
17
+ readonly name: string;
18
+ readonly reset: Effect.Effect<void, E, ProjectionServices>;
19
+ }
20
+ export interface EventStoreOptions<A, E, TUpcastError> {
21
+ readonly name: string;
22
+ readonly projections: readonly Projection<A, E>[];
23
+ readonly schema: Schema.Codec<A, unknown>;
24
+ readonly upcast?: (event: StoredEvent) => Effect.Effect<unknown, TUpcastError>;
25
+ readonly version: number;
26
+ }
27
+ export interface Append<A> {
28
+ readonly events: readonly [A, ...A[]];
29
+ readonly expectedVersion: number;
30
+ readonly requestId: string;
31
+ readonly stream: string;
32
+ }
33
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAC7C,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,8BAA8B,CAAC;AAC/D,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAE5C,MAAM,MAAM,kBAAkB,GAAG,SAAS,CAAC,SAAS,GAAG,UAAU,CAAC;AAElE,MAAM,WAAW,WAAW;IAC3B,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,KAAK,CAAC,CAAC;IACvB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,KAAK,EAAE,CAAC,CAAC;CAClB;AAED,MAAM,WAAW,UAAU,CAAC,CAAC,EAAE,CAAC;IAC/B,QAAQ,CAAC,KAAK,EAAE,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC,CAAC,KAAK,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC,EAAE,kBAAkB,CAAC,CAAC;IAChF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC,EAAE,kBAAkB,CAAC,CAAC;CAC3D;AAED,MAAM,WAAW,iBAAiB,CAAC,CAAC,EAAE,CAAC,EAAE,YAAY;IACpD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,WAAW,EAAE,SAAS,UAAU,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC;IAClD,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC,EAAE,OAAO,CAAC,CAAC;IAC1C,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,WAAW,KAAK,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,YAAY,CAAC,CAAC;IAC/E,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,MAAM,CAAC,CAAC;IACxB,QAAQ,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC,EAAE,GAAG,CAAC,EAAE,CAAC,CAAC;IACtC,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACxB"}
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
package/package.json CHANGED
@@ -1,6 +1,75 @@
1
1
  {
2
2
  "name": "@shivaedev/events",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "Durable event history and rebuildable SQL projections for Effect applications",
5
+ "keywords": [
6
+ "effect"
7
+ ],
8
+ "homepage": "https://github.com/ShivaeDev/platform/tree/main/packages/events#readme",
9
+ "bugs": {
10
+ "url": "https://github.com/ShivaeDev/platform/issues"
11
+ },
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/ShivaeDev/platform.git",
15
+ "directory": "packages/events"
16
+ },
17
+ "license": "MIT",
18
+ "sideEffects": false,
19
+ "type": "module",
20
+ "imports": {
21
+ "#test/*": "./src/test-support/*",
22
+ "#*.ts": {
23
+ "source": "./src/*.ts",
24
+ "types": "./dist/*.d.ts",
25
+ "default": "./dist/*.js"
26
+ }
27
+ },
28
+ "exports": {
29
+ "./*.ts": {
30
+ "source": "./src/*.ts",
31
+ "types": "./dist/*.d.ts",
32
+ "default": "./dist/*.js"
33
+ },
34
+ "./package.json": "./package.json"
35
+ },
36
+ "files": [
37
+ "dist",
38
+ "src",
39
+ "!src/**/*.test.ts",
40
+ "!src/**/*.spec.ts",
41
+ "!src/**/test-support",
42
+ "CHANGELOG.md",
43
+ "README.md"
44
+ ],
45
+ "dependencies": {
46
+ "@shivaedev/sql": "0.35.0"
47
+ },
48
+ "devDependencies": {
49
+ "@effect/sql-pg": "4.0.6",
50
+ "@shivaedev/changes": "0.9.0",
51
+ "@shivaedev/quality": "0.57.1",
52
+ "@shivaedev/test-sql": "0.18.0",
53
+ "@shivaedev/test-story": "0.36.0",
54
+ "@types/node": "24.19.2",
55
+ "effect": "4.0.6",
56
+ "typescript": "7.0.2",
57
+ "vitest": "5.0.5"
58
+ },
59
+ "peerDependencies": {
60
+ "effect": "4.0.6"
61
+ },
62
+ "engines": {
63
+ "node": ">=24"
64
+ },
65
+ "publishConfig": {
66
+ "access": "public",
67
+ "provenance": true
68
+ },
69
+ "scripts": {
70
+ "build": "node --eval \"import('node:fs').then(({ rmSync }) => rmSync('dist', { force: true, recursive: true }))\" && tsc --project tsconfig.build.json",
71
+ "ready": "pnpm typecheck && pnpm test && pnpm build",
72
+ "test": "vitest run",
73
+ "typecheck": "tsc --noEmit"
74
+ }
6
75
  }
package/src/append.ts ADDED
@@ -0,0 +1,46 @@
1
+ import { Effect, Schema } from "effect";
2
+ import { SqlClient } from "effect/sql";
3
+ import { transact } from "@shivaedev/sql/transact.ts";
4
+ import { AlreadyDone, StreamConflict } from "#errors.ts";
5
+ import { projectStream } from "#projectStream.ts";
6
+ import type { Append, EventStoreOptions } from "#types.ts";
7
+
8
+ const Version = Schema.Struct({ version: Schema.Int });
9
+
10
+ export const append = Effect.fn("Events.append")(function* <A, E, TUpcastError>(options: EventStoreOptions<A, E, TUpcastError>, command: Append<A>) {
11
+ return yield* transact(
12
+ Effect.gen(function* () {
13
+ const sql = yield* SqlClient.SqlClient;
14
+ const version = command.expectedVersion + command.events.length;
15
+ const claimed = yield* sql`insert into events_requests (store, request_id, stream, version)
16
+ values (${options.name}, ${command.requestId}, ${command.stream}, ${version})
17
+ on conflict do nothing returning request_id`;
18
+ if (claimed.length === 0) {
19
+ const [done] = yield* sql`select stream, version from events_requests
20
+ where store = ${options.name} and request_id = ${command.requestId}`.pipe(
21
+ Effect.flatMap(Schema.decodeUnknownEffect(Schema.NonEmptyArray(Schema.Struct({ stream: Schema.String, version: Schema.Int })))),
22
+ );
23
+ return yield* Effect.fail(new AlreadyDone({ requestId: command.requestId, stream: done.stream, streamVersion: done.version }));
24
+ }
25
+ yield* sql`insert into events_streams (store, stream, version) values (${options.name}, ${command.stream}, 0) on conflict do nothing`;
26
+ const changed = yield* sql`update events_streams set version = ${version}
27
+ where store = ${options.name} and stream = ${command.stream} and version = ${command.expectedVersion} returning version`;
28
+ if (changed.length === 0) {
29
+ const [current] = yield* sql`select version from events_streams where store = ${options.name} and stream = ${command.stream}`.pipe(
30
+ Effect.flatMap(Schema.decodeUnknownEffect(Schema.NonEmptyArray(Version))),
31
+ );
32
+ return yield* Effect.fail(
33
+ new StreamConflict({ actualVersion: current.version, expectedVersion: command.expectedVersion, stream: command.stream }),
34
+ );
35
+ }
36
+ for (const [index, event] of command.events.entries()) {
37
+ const payload = yield* Schema.encodeEffect(Schema.toCodecJson(options.schema))(event);
38
+ yield* sql`insert into events_log (store, stream, stream_version, request_id, event_version, payload)
39
+ values (${options.name}, ${command.stream}, ${command.expectedVersion + index + 1}, ${command.requestId}, ${options.version}, ${JSON.stringify(payload)}::jsonb)`;
40
+ }
41
+ yield* projectStream(options, command.stream);
42
+ return version;
43
+ }),
44
+ { onSqlError: (error) => error },
45
+ );
46
+ });
package/src/errors.ts ADDED
@@ -0,0 +1,18 @@
1
+ import { Data } from "effect";
2
+
3
+ export class AlreadyDone extends Data.TaggedError("AlreadyDone")<{
4
+ readonly requestId: string;
5
+ readonly stream: string;
6
+ readonly streamVersion: number;
7
+ }> {}
8
+
9
+ export class StreamConflict extends Data.TaggedError("StreamConflict")<{
10
+ readonly stream: string;
11
+ readonly expectedVersion: number;
12
+ readonly actualVersion: number;
13
+ }> {}
14
+
15
+ export class UnsupportedEventVersion extends Data.TaggedError("UnsupportedEventVersion")<{
16
+ readonly storedVersion: number;
17
+ readonly currentVersion: number;
18
+ }> {}
@@ -0,0 +1,50 @@
1
+ import { Effect, Schema } from "effect";
2
+ import { SqlClient } from "effect/sql";
3
+ import { transact } from "@shivaedev/sql/transact.ts";
4
+ import { append } from "#append.ts";
5
+ import { projectStream } from "#projectStream.ts";
6
+ import { readEvents } from "#readEvents.ts";
7
+ import type { Append, EventStoreOptions } from "#types.ts";
8
+
9
+ const streams = Effect.fn("Events.streams")(function* (name: string) {
10
+ const sql = yield* SqlClient.SqlClient;
11
+ return yield* sql`select stream from events_streams where store = ${name} order by stream`.pipe(
12
+ Effect.flatMap(Schema.decodeUnknownEffect(Schema.Array(Schema.Struct({ stream: Schema.String })))),
13
+ );
14
+ });
15
+
16
+ export const makeEventStore = Effect.fn("Events.makeEventStore")(function* <A, E = never, TUpcastError = never>(
17
+ options: EventStoreOptions<A, E, TUpcastError>,
18
+ ) {
19
+ const sql = yield* SqlClient.SqlClient;
20
+ const catchUp = Effect.gen(function* () {
21
+ for (const { stream } of yield* streams(options.name)) {
22
+ yield* transact(
23
+ Effect.gen(function* () {
24
+ yield* sql`select version from events_streams where store = ${options.name} and stream = ${stream} for update`;
25
+ yield* projectStream(options, stream);
26
+ }),
27
+ { onSqlError: (error) => error },
28
+ );
29
+ }
30
+ });
31
+ const rebuild = transact(
32
+ Effect.gen(function* () {
33
+ yield* sql`lock table events_streams in exclusive mode`;
34
+ for (const projection of options.projections) {
35
+ yield* projection.reset;
36
+ yield* sql`delete from events_progress where store = ${options.name} and projection = ${projection.name}`;
37
+ }
38
+ for (const { stream } of yield* streams(options.name)) {
39
+ yield* projectStream(options, stream);
40
+ }
41
+ }),
42
+ { onSqlError: (error) => error },
43
+ );
44
+ return {
45
+ append: (command: Append<A>) => append(options, command),
46
+ catchUp,
47
+ read: (stream: string) => readEvents(options, stream),
48
+ rebuild,
49
+ };
50
+ });
@@ -0,0 +1,25 @@
1
+ import { Effect } from "effect";
2
+ import { SqlClient } from "effect/sql";
3
+
4
+ export const postgresMigration = Effect.gen(function* () {
5
+ const sql = yield* SqlClient.SqlClient;
6
+ yield* sql`create table events_streams (
7
+ store text not null, stream text not null, version integer not null,
8
+ primary key (store, stream)
9
+ )`;
10
+ yield* sql`create table events_requests (
11
+ store text not null, request_id text not null, stream text not null, version integer not null,
12
+ primary key (store, request_id)
13
+ )`;
14
+ yield* sql`create table events_log (
15
+ store text not null, stream text not null, stream_version integer not null,
16
+ request_id text not null, event_version integer not null, payload jsonb not null,
17
+ primary key (store, stream, stream_version),
18
+ foreign key (store, stream) references events_streams (store, stream)
19
+ )`;
20
+ yield* sql`create table events_progress (
21
+ store text not null, projection text not null, stream text not null, version integer not null,
22
+ primary key (store, projection, stream),
23
+ foreign key (store, stream) references events_streams (store, stream)
24
+ )`;
25
+ });
@@ -0,0 +1,35 @@
1
+ import { Effect, Schema } from "effect";
2
+ import { SqlClient } from "effect/sql";
3
+ import { readEvents } from "#readEvents.ts";
4
+ import type { EventStoreOptions } from "#types.ts";
5
+
6
+ export const projectStream = Effect.fn("Events.projectStream")(function* <A, E, TUpcastError>(
7
+ options: EventStoreOptions<A, E, TUpcastError>,
8
+ stream: string,
9
+ ) {
10
+ const sql = yield* SqlClient.SqlClient;
11
+ const checkpoints: { readonly projection: (typeof options.projections)[number]; readonly version: number }[] = [];
12
+ for (const projection of options.projections) {
13
+ yield* sql`insert into events_progress (store, projection, stream, version)
14
+ values (${options.name}, ${projection.name}, ${stream}, 0) on conflict do nothing`;
15
+ const progress = yield* sql`select version from events_progress
16
+ where store = ${options.name} and projection = ${projection.name} and stream = ${stream} for update`.pipe(
17
+ Effect.flatMap(Schema.decodeUnknownEffect(Schema.NonEmptyArray(Schema.Struct({ version: Schema.Int })))),
18
+ );
19
+ checkpoints.push({ projection, version: progress[0].version });
20
+ }
21
+ if (checkpoints.length === 0) {
22
+ return;
23
+ }
24
+ const events = yield* readEvents(options, stream, Math.min(...checkpoints.map(({ version }) => version)));
25
+ for (const event of events) {
26
+ for (const { projection, version } of checkpoints) {
27
+ if (event.streamVersion <= version) {
28
+ continue;
29
+ }
30
+ yield* projection.apply(event);
31
+ yield* sql`update events_progress set version = ${event.streamVersion}
32
+ where store = ${options.name} and projection = ${projection.name} and stream = ${stream}`;
33
+ }
34
+ }
35
+ });
@@ -0,0 +1,39 @@
1
+ import { Effect, Schema } from "effect";
2
+ import { SqlClient } from "effect/sql";
3
+ import { UnsupportedEventVersion } from "#errors.ts";
4
+ import type { EventStoreOptions } from "#types.ts";
5
+
6
+ const Entry = Schema.Struct({
7
+ eventVersion: Schema.Int,
8
+ payload: Schema.Unknown,
9
+ requestId: Schema.String,
10
+ streamVersion: Schema.Int,
11
+ });
12
+
13
+ export const readEvents = Effect.fn("Events.readEvents")(function* <A, E, TUpcastError>(
14
+ options: EventStoreOptions<A, E, TUpcastError>,
15
+ stream: string,
16
+ after = 0,
17
+ ) {
18
+ const sql = yield* SqlClient.SqlClient;
19
+ const entries = yield* sql`select event_version as "eventVersion", payload, request_id as "requestId", stream_version as "streamVersion"
20
+ from events_log where store = ${options.name} and stream = ${stream} and stream_version > ${after}
21
+ order by stream_version`.withoutTransform.pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.Array(Entry))));
22
+ return yield* Effect.forEach(entries, (entry) =>
23
+ Effect.gen(function* () {
24
+ let payload = entry.payload;
25
+ if (entry.eventVersion !== options.version) {
26
+ if (options.upcast === undefined) {
27
+ return yield* Effect.fail(new UnsupportedEventVersion({ currentVersion: options.version, storedVersion: entry.eventVersion }));
28
+ }
29
+ payload = yield* options.upcast({ payload: entry.payload, version: entry.eventVersion });
30
+ }
31
+ return {
32
+ requestId: entry.requestId,
33
+ stream,
34
+ streamVersion: entry.streamVersion,
35
+ value: yield* Schema.decodeUnknownEffect(Schema.toCodecJson(options.schema))(payload),
36
+ };
37
+ }),
38
+ );
39
+ });
package/src/types.ts ADDED
@@ -0,0 +1,38 @@
1
+ import type { Effect, Schema } from "effect";
2
+ import type { Reactivity } from "effect/reactivity/Reactivity";
3
+ import type { SqlClient } from "effect/sql";
4
+
5
+ export type ProjectionServices = SqlClient.SqlClient | Reactivity;
6
+
7
+ export interface StoredEvent {
8
+ readonly payload: unknown;
9
+ readonly version: number;
10
+ }
11
+
12
+ export interface Event<A> {
13
+ readonly requestId: string;
14
+ readonly stream: string;
15
+ readonly streamVersion: number;
16
+ readonly value: A;
17
+ }
18
+
19
+ export interface Projection<A, E> {
20
+ readonly apply: (event: Event<A>) => Effect.Effect<void, E, ProjectionServices>;
21
+ readonly name: string;
22
+ readonly reset: Effect.Effect<void, E, ProjectionServices>;
23
+ }
24
+
25
+ export interface EventStoreOptions<A, E, TUpcastError> {
26
+ readonly name: string;
27
+ readonly projections: readonly Projection<A, E>[];
28
+ readonly schema: Schema.Codec<A, unknown>;
29
+ readonly upcast?: (event: StoredEvent) => Effect.Effect<unknown, TUpcastError>;
30
+ readonly version: number;
31
+ }
32
+
33
+ export interface Append<A> {
34
+ readonly events: readonly [A, ...A[]];
35
+ readonly expectedVersion: number;
36
+ readonly requestId: string;
37
+ readonly stream: string;
38
+ }