@house-rules/domain-postgres 0.0.0-stage → 0.1.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/AGENTS.md +25 -0
- package/LICENSE +21 -0
- package/README.md +60 -2
- package/dist/adapters/aggregate-store.d.ts +3 -0
- package/dist/adapters/aggregate-store.js +139 -0
- package/dist/adapters/from-env.d.ts +17 -0
- package/dist/adapters/from-env.js +35 -0
- package/dist/adapters/host.d.ts +21 -0
- package/dist/adapters/host.js +19 -0
- package/dist/adapters/outbox.d.ts +14 -0
- package/dist/adapters/outbox.js +44 -0
- package/dist/adapters/postgres-platform.d.ts +11 -0
- package/dist/adapters/postgres-platform.js +67 -0
- package/dist/adapters/tables.d.ts +16 -0
- package/dist/adapters/tables.js +132 -0
- package/dist/adapters/view-store.d.ts +4 -0
- package/dist/adapters/view-store.js +59 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/package.json +48 -3
package/AGENTS.md
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Agent guide: @house-rules/domain-postgres
|
|
2
|
+
|
|
3
|
+
A Postgres `Platform` for Alchemy Domain (`@alchemy.run/domain` 2.0.0-beta.85, Effect 4.1.2). Swap it in where an app would use `Domain.InMemory`; the model does not change.
|
|
4
|
+
|
|
5
|
+
## Wiring
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { fromEnv } from "@house-rules/domain-postgres";
|
|
9
|
+
|
|
10
|
+
const Hosted = App.layer(AppHandlers).pipe(Layer.provide(fromEnv({ schema: "app" })));
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
- `fromEnv({ schema })` reads `DOMAIN_STORE` (`postgres` by default, or `memory`) and `DATABASE_URL`. Declare both in the app's `.env.schema`.
|
|
14
|
+
- `layer({ schema })` is the same store without the env switch; provide a `PgClient` yourself.
|
|
15
|
+
- By default `layer` and `fromEnv` create the schema and tables on start. That needs CREATE on the database or schema. When the runtime role may only read and write rows, the schema owner runs `migrate(schema)` in the migration step, and the app passes `createTables: false`. Start then only checks the tables and dies with `TablesMissing`, naming the schema and missing tables, if any are absent.
|
|
16
|
+
- The runtime role needs `USAGE` on the schema, `SELECT, INSERT, UPDATE, DELETE` on its tables, `USAGE` on its sequences (`outbox.position` is a `bigserial`), and `ALTER DEFAULT PRIVILEGES FOR ROLE <owner> IN SCHEMA <schema>` granting the same on tables and sequences the owner adds later. The README has the SQL.
|
|
17
|
+
- Tests provide `platformForTests`: a fresh `test_<uuid>` schema per test, dropped after. Run the suite again with `DOMAIN_STORE=memory` to cover `Domain.InMemory`.
|
|
18
|
+
|
|
19
|
+
## Fixed rules
|
|
20
|
+
|
|
21
|
+
- Run one replica per schema. Two processes would both run the outbox loop and the schedules, so a policy could fire twice.
|
|
22
|
+
- No projections, streams or sagas. Building a model with them fails.
|
|
23
|
+
- No backfill. A new view, or a view whose `version` changes, fills from new events only.
|
|
24
|
+
- Pin `@alchemy.run/domain`, `alchemy` and `effect` to this package's peer versions. The `Platform` contract is beta; rerun the tests on every Alchemy bump.
|
|
25
|
+
- A policy command id comes from its event, so a redelivered event is deduplicated by its receipt. A call to an outside provider inside a policy is not; make it idempotent.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jakub Szwajka
|
|
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,61 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @house-rules/domain-postgres
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A Postgres `Platform` for Alchemy Domain (`@alchemy.run/domain` 2.0.0-beta.85). It keeps a model's events, state, receipts, views, schedules and outbox in your own Postgres, so a model runs on Node in a container and survives a restart. It is its own package so it can be deleted when Alchemy ships its own Postgres host. [AGENTS.md](AGENTS.md) holds the wiring and fixed rules for agents.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { layer } from "@house-rules/domain-postgres";
|
|
7
|
+
import * as PgClient from "@effect/sql-pg/PgClient";
|
|
8
|
+
|
|
9
|
+
const Hosted = App.layer(AppHandlers).pipe(
|
|
10
|
+
Layer.provide(layer({ schema: "app" })),
|
|
11
|
+
Layer.provide(PgClient.layerConfig({ url: Config.Redacted("DATABASE_URL") })),
|
|
12
|
+
);
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`fromEnv({ schema })` picks the store from `DOMAIN_STORE`: `postgres` (default, needs `DATABASE_URL`) or `memory` (`Domain.InMemory`). Swapping the Layer is the whole move; the model does not change.
|
|
16
|
+
|
|
17
|
+
## What it stores
|
|
18
|
+
|
|
19
|
+
`layer` creates its tables in `schema` on start (`CREATE ... IF NOT EXISTS`), unless you pass `createTables: false` (see [A runtime role that cannot create](#a-runtime-role-that-cannot-create)):
|
|
20
|
+
|
|
21
|
+
| Table | Holds |
|
|
22
|
+
| --- | --- |
|
|
23
|
+
| `instances` | each aggregate instance's state and version; its row lock is the single writer |
|
|
24
|
+
| `events` | every committed event, keyed `(model, aggregate, id, seq)` so two writers can never commit the same seq |
|
|
25
|
+
| `receipts` | the reply to each command id, so a retried command returns the first receipt |
|
|
26
|
+
| `schedules`, `cancellations` | scheduled commands and the ones cancelled |
|
|
27
|
+
| `views`, `view_keys`, `view_entries` | each view key's snapshot, with per-source checkpoints, and what it emitted |
|
|
28
|
+
| `outbox` | deliveries to views and policies, written in the same transaction as the commit |
|
|
29
|
+
|
|
30
|
+
A command commits its events, state, receipt and outbox rows in one transaction. One loop per model delivers the outbox in order, deletes each row after its view or policy succeeds, retries a failure, and parks a row after `maxAttempts` (5) by setting `failed_at`. Views drop duplicates by checkpoint, so delivery is at least once.
|
|
31
|
+
|
|
32
|
+
## A runtime role that cannot create
|
|
33
|
+
|
|
34
|
+
Postgres checks the CREATE privilege before it checks whether a schema or table exists. So the default start, which runs `CREATE ... IF NOT EXISTS`, fails with `permission denied` for a role that may only read and write rows, even when every table is there.
|
|
35
|
+
|
|
36
|
+
Split the two jobs. The schema owner runs `migrate(schema)` in the deploy's migration step, with a `PgClient` that connects as the owner. `migrate` is idempotent. The app runs `layer({ schema, createTables: false })` or `fromEnv({ schema, createTables: false })` as the runtime role. With `createTables: false`, start reads `pg_catalog` instead of creating anything. If the schema or any table is missing, the layer dies with `TablesMissing`, which names the schema and the missing tables. It never creates them.
|
|
37
|
+
|
|
38
|
+
The runtime role needs these grants, run once by the owner:
|
|
39
|
+
|
|
40
|
+
```sql
|
|
41
|
+
GRANT USAGE ON SCHEMA app TO app_runtime;
|
|
42
|
+
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA app TO app_runtime;
|
|
43
|
+
GRANT USAGE ON ALL SEQUENCES IN SCHEMA app TO app_runtime;
|
|
44
|
+
ALTER DEFAULT PRIVILEGES FOR ROLE app_owner IN SCHEMA app
|
|
45
|
+
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_runtime;
|
|
46
|
+
ALTER DEFAULT PRIVILEGES FOR ROLE app_owner IN SCHEMA app
|
|
47
|
+
GRANT USAGE ON SEQUENCES TO app_runtime;
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The sequence grant covers `outbox.position`, a `bigserial`. The default privileges cover tables a later version of this package adds when the owner runs `migrate` again. The runtime role needs no CREATE on the database or the schema.
|
|
51
|
+
|
|
52
|
+
## Limits
|
|
53
|
+
|
|
54
|
+
- One replica per database schema. Two processes would both run the outbox loop and each other's schedules, and live reads (`watch`) only see commits made in their own process.
|
|
55
|
+
- No projections, streams or sagas: building a model with them fails.
|
|
56
|
+
- No backfill. A view added to a database that already has events starts empty and fills from new events only; bumping a view's `version` empties it the same way.
|
|
57
|
+
- The `Platform` contract is beta. `Platform.ts` changed 308 lines between beta.84 and beta.85, so pin Alchemy exactly and rerun these tests on every bump.
|
|
58
|
+
|
|
59
|
+
## Tests
|
|
60
|
+
|
|
61
|
+
Tests run against real Postgres. Start it with `pnpm db:up` (docker compose, `127.0.0.1:55432`); CI runs the same image as a service. `platformForTests` gives each test its own schema and drops it after. Set `DOMAIN_STORE=memory` to run the same tests on `Domain.InMemory`; this package's `test` script runs both, and the restart test runs on Postgres only.
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { AlreadyCancelled, AlreadyExecuted, NotScheduled, } from "@alchemy.run/domain/Aggregate";
|
|
2
|
+
import * as Clock from "effect/Clock";
|
|
3
|
+
import * as Effect from "effect/Effect";
|
|
4
|
+
import * as Stream from "effect/Stream";
|
|
5
|
+
export const makeAggregateStore = (host, kit) => {
|
|
6
|
+
const { sql, t, model, notify, kick, stage, firing, arm } = host;
|
|
7
|
+
const name = kit.name;
|
|
8
|
+
const where = (id) => sql `model = ${model} AND aggregate = ${name} AND id = ${id}`;
|
|
9
|
+
const lock = (id) => Effect.gen(function* () {
|
|
10
|
+
yield* sql `
|
|
11
|
+
INSERT INTO ${t("instances")} (model, aggregate, id, state, version)
|
|
12
|
+
VALUES (${model}, ${name}, ${id}, NULL, 0) ON CONFLICT DO NOTHING`;
|
|
13
|
+
const rows = yield* sql `
|
|
14
|
+
SELECT state, version FROM ${t("instances")} WHERE ${where(id)} FOR UPDATE`;
|
|
15
|
+
return rows[0] ?? { state: null, version: 0 };
|
|
16
|
+
});
|
|
17
|
+
const receipt = (id, commandId) => Effect.map(sql `
|
|
18
|
+
SELECT result FROM ${t("receipts")} WHERE ${where(id)} AND command_id = ${commandId}`, (rows) => rows[0]?.result);
|
|
19
|
+
const commit = (id, command, meta) => Effect.gen(function* () {
|
|
20
|
+
const current = yield* lock(id);
|
|
21
|
+
const duplicate = yield* receipt(id, meta.commandId);
|
|
22
|
+
if (duplicate)
|
|
23
|
+
return duplicate;
|
|
24
|
+
const result = yield* kit.handle({
|
|
25
|
+
id,
|
|
26
|
+
state: current.state,
|
|
27
|
+
version: current.version,
|
|
28
|
+
command,
|
|
29
|
+
commandId: meta.commandId,
|
|
30
|
+
context: meta.context,
|
|
31
|
+
now: yield* Clock.currentTimeMillis,
|
|
32
|
+
correlationId: meta.correlationId,
|
|
33
|
+
trace: meta.trace,
|
|
34
|
+
link: meta.link,
|
|
35
|
+
});
|
|
36
|
+
if (result._tag === "Rejected")
|
|
37
|
+
return result;
|
|
38
|
+
yield* Effect.forEach(result.events, (stored) => sql `
|
|
39
|
+
INSERT INTO ${t("events")} (model, aggregate, id, seq, event, envelope)
|
|
40
|
+
VALUES (${model}, ${name}, ${id}, ${stored.envelope.seq},
|
|
41
|
+
${sql.json(stored.event)}, ${sql.json(stored.envelope)})`, { discard: true });
|
|
42
|
+
yield* sql `
|
|
43
|
+
UPDATE ${t("instances")} SET state = ${sql.json(result.state)}, version = ${result.version}
|
|
44
|
+
WHERE ${where(id)}`;
|
|
45
|
+
const sent = {
|
|
46
|
+
_tag: "Accepted",
|
|
47
|
+
receipt: {
|
|
48
|
+
stream: `${name}/${id}`,
|
|
49
|
+
version: result.version,
|
|
50
|
+
events: result.events.map((stored) => stored.event),
|
|
51
|
+
reply: result.reply,
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
yield* sql `
|
|
55
|
+
INSERT INTO ${t("receipts")} (model, aggregate, id, command_id, result)
|
|
56
|
+
VALUES (${model}, ${name}, ${id}, ${meta.commandId}, ${sql.json(sent)})`;
|
|
57
|
+
yield* stage(result.deliveries);
|
|
58
|
+
return sent;
|
|
59
|
+
});
|
|
60
|
+
const committed = (id) => Effect.andThen(notify.bump(`a:${name}/${id}`), kick);
|
|
61
|
+
const send = (id, command, meta) => sql.withTransaction(commit(id, command, meta)).pipe(Effect.orDie, Effect.tap(() => committed(id)));
|
|
62
|
+
firing.set(name, (id, commandId, token) => sql
|
|
63
|
+
.withTransaction(Effect.gen(function* () {
|
|
64
|
+
const rows = yield* sql `
|
|
65
|
+
DELETE FROM ${t("schedules")}
|
|
66
|
+
WHERE ${where(id)} AND command_id = ${commandId} AND token = ${token}
|
|
67
|
+
RETURNING command, meta`;
|
|
68
|
+
const due = rows[0];
|
|
69
|
+
if (due)
|
|
70
|
+
yield* commit(id, due.command, due.meta);
|
|
71
|
+
}))
|
|
72
|
+
.pipe(Effect.orDie, Effect.andThen(committed(id))));
|
|
73
|
+
const state = (id) => Effect.map(sql `SELECT state, version FROM ${t("instances")} WHERE ${where(id)}`, (rows) => rows[0] ?? { state: null, version: 0 }).pipe(Effect.orDie);
|
|
74
|
+
const eventsAfter = (id, after) => sql `
|
|
75
|
+
SELECT event, envelope FROM ${t("events")} WHERE ${where(id)} AND seq > ${after}
|
|
76
|
+
ORDER BY seq`.pipe(Effect.orDie);
|
|
77
|
+
return {
|
|
78
|
+
send,
|
|
79
|
+
schedule: (id, command, meta, due) => Effect.gen(function* () {
|
|
80
|
+
const at = "at" in due ? due.at : (yield* Clock.currentTimeMillis) + due.afterMillis;
|
|
81
|
+
const token = yield* sql
|
|
82
|
+
.withTransaction(Effect.gen(function* () {
|
|
83
|
+
yield* lock(id);
|
|
84
|
+
if (yield* receipt(id, meta.commandId))
|
|
85
|
+
return yield* new AlreadyExecuted({
|
|
86
|
+
aggregate: name,
|
|
87
|
+
id,
|
|
88
|
+
commandId: meta.commandId,
|
|
89
|
+
});
|
|
90
|
+
yield* sql `DELETE FROM ${t("cancellations")} WHERE ${where(id)} AND command_id = ${meta.commandId}`;
|
|
91
|
+
const rows = yield* sql `
|
|
92
|
+
INSERT INTO ${t("schedules")} (model, aggregate, id, command_id, command, meta, due_at, token)
|
|
93
|
+
VALUES (${model}, ${name}, ${id}, ${meta.commandId}, ${sql.json(command)},
|
|
94
|
+
${sql.json(meta)}, ${at}, gen_random_uuid()::text)
|
|
95
|
+
ON CONFLICT (model, aggregate, id, command_id)
|
|
96
|
+
DO UPDATE SET command = EXCLUDED.command, meta = EXCLUDED.meta,
|
|
97
|
+
due_at = EXCLUDED.due_at, token = EXCLUDED.token
|
|
98
|
+
RETURNING token`;
|
|
99
|
+
const token = rows[0]?.token;
|
|
100
|
+
return token ?? (yield* Effect.die(new Error("schedule row not written")));
|
|
101
|
+
}))
|
|
102
|
+
.pipe(Effect.catchTag("SqlError", Effect.die));
|
|
103
|
+
yield* arm(name, id, meta.commandId, token, at);
|
|
104
|
+
return at;
|
|
105
|
+
}),
|
|
106
|
+
unschedule: (id, commandId) => sql
|
|
107
|
+
.withTransaction(Effect.gen(function* () {
|
|
108
|
+
const fields = { aggregate: name, id, commandId };
|
|
109
|
+
yield* lock(id);
|
|
110
|
+
if (yield* receipt(id, commandId))
|
|
111
|
+
return yield* new AlreadyExecuted(fields);
|
|
112
|
+
const cancelled = yield* sql `
|
|
113
|
+
SELECT 1 FROM ${t("cancellations")} WHERE ${where(id)} AND command_id = ${commandId}`;
|
|
114
|
+
if (cancelled.length > 0)
|
|
115
|
+
return yield* new AlreadyCancelled(fields);
|
|
116
|
+
const removed = yield* sql `
|
|
117
|
+
DELETE FROM ${t("schedules")} WHERE ${where(id)} AND command_id = ${commandId}
|
|
118
|
+
RETURNING 1`;
|
|
119
|
+
if (removed.length === 0)
|
|
120
|
+
return yield* new NotScheduled(fields);
|
|
121
|
+
yield* sql `
|
|
122
|
+
INSERT INTO ${t("cancellations")} (model, aggregate, id, command_id)
|
|
123
|
+
VALUES (${model}, ${name}, ${id}, ${commandId})`;
|
|
124
|
+
}))
|
|
125
|
+
.pipe(Effect.catchTag("SqlError", Effect.die)),
|
|
126
|
+
state,
|
|
127
|
+
events: (id) => eventsAfter(id, 0),
|
|
128
|
+
changes: (id) => notify.changes(`a:${name}/${id}`, state(id)),
|
|
129
|
+
tail: (id, after) => {
|
|
130
|
+
let last = after;
|
|
131
|
+
return notify.changes(`a:${name}/${id}`, Effect.void).pipe(Stream.flatMap(() => Stream.unwrap(Effect.map(eventsAfter(id, last), (fresh) => {
|
|
132
|
+
const newest = fresh.at(-1);
|
|
133
|
+
if (newest)
|
|
134
|
+
last = newest.envelope.seq;
|
|
135
|
+
return Stream.fromIterable(fresh);
|
|
136
|
+
}))));
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import * as Domain from "@alchemy.run/domain";
|
|
2
|
+
import * as Pg from "@effect/sql-pg/PgClient";
|
|
3
|
+
import * as Config from "effect/Config";
|
|
4
|
+
import * as Effect from "effect/Effect";
|
|
5
|
+
import * as Layer from "effect/Layer";
|
|
6
|
+
import * as Redacted from "effect/Redacted";
|
|
7
|
+
import { type Options } from "./postgres-platform.ts";
|
|
8
|
+
export declare const LOCAL_DATABASE_URL = "postgres://house_rules:house_rules@127.0.0.1:55432/house_rules";
|
|
9
|
+
export declare const databaseUrl: Config.Config<Redacted.Redacted<string>>;
|
|
10
|
+
export declare const domainStore: Config.Config<"memory" | "postgres">;
|
|
11
|
+
export declare const PgForTests: Layer.Layer<Pg.PgClient | import("effect/sql/SqlClient").SqlClient, Config.ConfigError | import("effect/sql/SqlError").SqlError, never>;
|
|
12
|
+
export declare const testSchema: Effect.Effect<string, never, Pg.PgClient | import("effect/Scope").Scope>;
|
|
13
|
+
export declare const postgresInFreshSchema: Layer.Layer<Domain.Platform, never, Pg.PgClient>;
|
|
14
|
+
export declare const fromEnv: (options: Omit<Options, "schema"> & {
|
|
15
|
+
readonly schema: string;
|
|
16
|
+
}) => Layer.Layer<Domain.Platform, Config.ConfigError | import("effect/sql/SqlError").SqlError, never>;
|
|
17
|
+
export declare const platformForTests: Layer.Layer<Domain.Platform, never, never>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import * as Domain from "@alchemy.run/domain";
|
|
2
|
+
import * as Pg from "@effect/sql-pg/PgClient";
|
|
3
|
+
import * as Config from "effect/Config";
|
|
4
|
+
import * as Effect from "effect/Effect";
|
|
5
|
+
import * as Layer from "effect/Layer";
|
|
6
|
+
import * as Redacted from "effect/Redacted";
|
|
7
|
+
import { layer } from "./postgres-platform.js";
|
|
8
|
+
export const LOCAL_DATABASE_URL = "postgres://house_rules:house_rules@127.0.0.1:55432/house_rules";
|
|
9
|
+
export const databaseUrl = Config.Redacted("DATABASE_URL").pipe(Config.withDefault(Redacted.make(LOCAL_DATABASE_URL)));
|
|
10
|
+
export const domainStore = Config.Literals(["postgres", "memory"], "DOMAIN_STORE").pipe(Config.withDefault("postgres"));
|
|
11
|
+
export const PgForTests = Pg.layerConfig({ url: databaseUrl });
|
|
12
|
+
export const testSchema = Effect.acquireRelease(Effect.gen(function* () {
|
|
13
|
+
const sql = yield* Pg.PgClient;
|
|
14
|
+
const rows = yield* sql `
|
|
15
|
+
SELECT 'test_' || replace(gen_random_uuid()::text, '-', '') AS name`;
|
|
16
|
+
const name = rows[0]?.name;
|
|
17
|
+
return name ?? (yield* Effect.die(new Error("no schema name")));
|
|
18
|
+
}), (schema) => Effect.gen(function* () {
|
|
19
|
+
const sql = yield* Pg.PgClient;
|
|
20
|
+
yield* sql `DROP SCHEMA IF EXISTS ${sql(schema)} CASCADE`;
|
|
21
|
+
}).pipe(Effect.ignore)).pipe(Effect.orDie);
|
|
22
|
+
export const postgresInFreshSchema = Layer.unwrap(Effect.map(testSchema, (schema) => layer({ schema, pollInterval: "200 millis" })));
|
|
23
|
+
export const fromEnv = (options) => Layer.unwrap(Effect.gen(function* () {
|
|
24
|
+
const store = yield* domainStore;
|
|
25
|
+
if (store === "memory")
|
|
26
|
+
return Domain.InMemory;
|
|
27
|
+
const url = yield* Config.Redacted("DATABASE_URL");
|
|
28
|
+
return layer(options).pipe(Layer.provide(Pg.layer({ url })));
|
|
29
|
+
}));
|
|
30
|
+
export const platformForTests = Layer.unwrap(Effect.gen(function* () {
|
|
31
|
+
const store = yield* domainStore;
|
|
32
|
+
return store === "memory"
|
|
33
|
+
? Domain.InMemory
|
|
34
|
+
: postgresInFreshSchema.pipe(Layer.provide(PgForTests), Layer.orDie);
|
|
35
|
+
})).pipe(Layer.orDie);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Delivery } from "@alchemy.run/domain/Platform";
|
|
2
|
+
import type { PgClient } from "@effect/sql-pg/PgClient";
|
|
3
|
+
import * as Effect from "effect/Effect";
|
|
4
|
+
import type { SqlError } from "effect/sql/SqlError";
|
|
5
|
+
import * as Stream from "effect/Stream";
|
|
6
|
+
export type Fire = (id: string, commandId: string, token: string) => Effect.Effect<void>;
|
|
7
|
+
export interface Notifier {
|
|
8
|
+
readonly bump: (key: string) => Effect.Effect<void>;
|
|
9
|
+
readonly changes: <A>(key: string, read: Effect.Effect<A>) => Stream.Stream<A>;
|
|
10
|
+
}
|
|
11
|
+
export interface Host {
|
|
12
|
+
readonly sql: PgClient;
|
|
13
|
+
readonly t: (table: string) => ReturnType<PgClient>;
|
|
14
|
+
readonly model: string;
|
|
15
|
+
readonly notify: Notifier;
|
|
16
|
+
readonly kick: Effect.Effect<void>;
|
|
17
|
+
readonly stage: (deliveries: ReadonlyArray<Delivery>) => Effect.Effect<void, SqlError>;
|
|
18
|
+
readonly firing: Map<string, Fire>;
|
|
19
|
+
readonly arm: (aggregate: string, id: string, commandId: string, token: string, at: number) => Effect.Effect<void>;
|
|
20
|
+
}
|
|
21
|
+
export declare const notifier: () => Notifier;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import * as Stream from "effect/Stream";
|
|
3
|
+
import * as SubscriptionRef from "effect/SubscriptionRef";
|
|
4
|
+
export const notifier = () => {
|
|
5
|
+
const refs = new Map();
|
|
6
|
+
const ref = (key) => Effect.suspend(() => {
|
|
7
|
+
const found = refs.get(key);
|
|
8
|
+
if (found)
|
|
9
|
+
return Effect.succeed(found);
|
|
10
|
+
return Effect.tap(SubscriptionRef.make(0), (made) => Effect.sync(() => refs.set(key, made)));
|
|
11
|
+
});
|
|
12
|
+
return {
|
|
13
|
+
bump: (key) => Effect.suspend(() => {
|
|
14
|
+
const found = refs.get(key);
|
|
15
|
+
return found ? SubscriptionRef.update(found, (n) => n + 1) : Effect.void;
|
|
16
|
+
}),
|
|
17
|
+
changes: (key, read) => Stream.unwrap(Effect.map(ref(key), (found) => SubscriptionRef.changes(found).pipe(Stream.mapEffect(() => read)))),
|
|
18
|
+
};
|
|
19
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Delivery } from "@alchemy.run/domain/Platform";
|
|
2
|
+
import type { PgClient } from "@effect/sql-pg/PgClient";
|
|
3
|
+
import * as Duration from "effect/Duration";
|
|
4
|
+
import * as Effect from "effect/Effect";
|
|
5
|
+
import * as Queue from "effect/Queue";
|
|
6
|
+
export declare const makeOutbox: (options: {
|
|
7
|
+
readonly sql: PgClient;
|
|
8
|
+
readonly table: string;
|
|
9
|
+
readonly model: string;
|
|
10
|
+
readonly wake: Queue.Queue<void>;
|
|
11
|
+
readonly deliver: (delivery: Delivery) => Effect.Effect<void>;
|
|
12
|
+
readonly pollInterval: Duration.Input;
|
|
13
|
+
readonly maxAttempts: number;
|
|
14
|
+
}) => Effect.Effect<never, never, never>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import * as Clock from "effect/Clock";
|
|
2
|
+
import * as Duration from "effect/Duration";
|
|
3
|
+
import * as Effect from "effect/Effect";
|
|
4
|
+
import * as Exit from "effect/Exit";
|
|
5
|
+
import * as Queue from "effect/Queue";
|
|
6
|
+
const BATCH = 100;
|
|
7
|
+
export const makeOutbox = (options) => {
|
|
8
|
+
const { sql, model } = options;
|
|
9
|
+
const table = sql(options.table);
|
|
10
|
+
const pass = Effect.gen(function* () {
|
|
11
|
+
const rows = yield* sql `
|
|
12
|
+
SELECT position, delivery, attempts FROM ${table}
|
|
13
|
+
WHERE model = ${model} AND failed_at IS NULL
|
|
14
|
+
ORDER BY position LIMIT ${BATCH}`;
|
|
15
|
+
for (const row of rows) {
|
|
16
|
+
const exit = yield* Effect.exit(options.deliver(row.delivery));
|
|
17
|
+
if (Exit.isSuccess(exit)) {
|
|
18
|
+
yield* sql `DELETE FROM ${table} WHERE position = ${row.position}`;
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
const attempts = row.attempts + 1;
|
|
22
|
+
if (attempts >= options.maxAttempts) {
|
|
23
|
+
const now = yield* Clock.currentTimeMillis;
|
|
24
|
+
yield* sql `UPDATE ${table} SET attempts = ${attempts}, failed_at = ${now} WHERE position = ${row.position}`;
|
|
25
|
+
yield* Effect.logError(`delivery ${row.position} to ${row.delivery.target.kind} ${row.delivery.target.name} failed ${attempts} times; parked`, exit.cause);
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
yield* sql `UPDATE ${table} SET attempts = ${attempts} WHERE position = ${row.position}`;
|
|
29
|
+
yield* Effect.logWarning(`delivery ${row.position} to ${row.delivery.target.kind} ${row.delivery.target.name} failed; retrying`, exit.cause);
|
|
30
|
+
// Stop here so later deliveries from the same source never overtake this one.
|
|
31
|
+
return "retry";
|
|
32
|
+
}
|
|
33
|
+
return rows.length === BATCH ? "more" : "drained";
|
|
34
|
+
}).pipe(Effect.orDie);
|
|
35
|
+
const idle = Effect.raceFirst(Queue.take(options.wake), Effect.sleep(Duration.fromInputUnsafe(options.pollInterval)));
|
|
36
|
+
return Effect.gen(function* () {
|
|
37
|
+
while (true) {
|
|
38
|
+
const result = yield* pass;
|
|
39
|
+
if (result === "more")
|
|
40
|
+
continue;
|
|
41
|
+
yield* idle;
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Platform } from "@alchemy.run/domain/Platform";
|
|
2
|
+
import { PgClient } from "@effect/sql-pg/PgClient";
|
|
3
|
+
import * as Duration from "effect/Duration";
|
|
4
|
+
import * as Layer from "effect/Layer";
|
|
5
|
+
export interface Options {
|
|
6
|
+
readonly schema?: string;
|
|
7
|
+
readonly createTables?: boolean;
|
|
8
|
+
readonly pollInterval?: Duration.Input;
|
|
9
|
+
readonly maxAttempts?: number;
|
|
10
|
+
}
|
|
11
|
+
export declare const layer: (options?: Options) => Layer.Layer<Platform, never, PgClient>;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { Platform } from "@alchemy.run/domain/Platform";
|
|
2
|
+
import { PgClient } from "@effect/sql-pg/PgClient";
|
|
3
|
+
import * as Clock from "effect/Clock";
|
|
4
|
+
import * as Duration from "effect/Duration";
|
|
5
|
+
import * as Effect from "effect/Effect";
|
|
6
|
+
import * as Layer from "effect/Layer";
|
|
7
|
+
import * as Queue from "effect/Queue";
|
|
8
|
+
import { makeAggregateStore } from "./aggregate-store.js";
|
|
9
|
+
import { notifier } from "./host.js";
|
|
10
|
+
import { makeOutbox } from "./outbox.js";
|
|
11
|
+
import { checkTables, migrate } from "./tables.js";
|
|
12
|
+
import { makeViewStore } from "./view-store.js";
|
|
13
|
+
const unsupported = (kind) => Effect.die(new Error(`@house-rules/domain-postgres does not host ${kind} yet; use Domain.InMemory`));
|
|
14
|
+
const makeHost = (sql, schema, model, scope, options) => Effect.gen(function* () {
|
|
15
|
+
const t = (name) => sql(`${schema}.${name}`);
|
|
16
|
+
const notify = notifier();
|
|
17
|
+
let deliver;
|
|
18
|
+
const wake = yield* Queue.sliding(1);
|
|
19
|
+
const kick = Queue.offer(wake, undefined).pipe(Effect.asVoid);
|
|
20
|
+
const stage = (deliveries) => Effect.forEach(deliveries, (delivery) => sql `INSERT INTO ${t("outbox")} (model, delivery) VALUES (${model}, ${sql.json(delivery)})`, { discard: true });
|
|
21
|
+
const firing = new Map();
|
|
22
|
+
const arm = (aggregate, id, commandId, token, at) => Effect.gen(function* () {
|
|
23
|
+
const now = yield* Clock.currentTimeMillis;
|
|
24
|
+
const fire = firing.get(aggregate);
|
|
25
|
+
if (!fire)
|
|
26
|
+
return;
|
|
27
|
+
yield* Effect.sleep(Duration.millis(Math.max(0, at - now))).pipe(Effect.andThen(fire(id, commandId, token)), Effect.catchCause((cause) => Effect.logError(`scheduled ${aggregate}/${id}/${commandId} failed`, cause)), Effect.forkIn(scope));
|
|
28
|
+
});
|
|
29
|
+
const host = { sql, t, model, notify, kick, stage, firing, arm };
|
|
30
|
+
const remember = (kit) => {
|
|
31
|
+
deliver ??= kit.deliver;
|
|
32
|
+
};
|
|
33
|
+
const outbox = makeOutbox({
|
|
34
|
+
sql,
|
|
35
|
+
table: `${schema}.outbox`,
|
|
36
|
+
model,
|
|
37
|
+
wake,
|
|
38
|
+
deliver: (delivery) => deliver ? deliver(delivery) : Effect.die(new Error("no store is hosted yet")),
|
|
39
|
+
pollInterval: options.pollInterval ?? "1 second",
|
|
40
|
+
maxAttempts: options.maxAttempts ?? 5,
|
|
41
|
+
});
|
|
42
|
+
const modelHost = {
|
|
43
|
+
aggregate: (_aggregate, kit) => Effect.sync(() => {
|
|
44
|
+
remember(kit);
|
|
45
|
+
return makeAggregateStore(host, kit);
|
|
46
|
+
}),
|
|
47
|
+
view: (_view, kit) => Effect.andThen(Effect.sync(() => remember(kit)), makeViewStore(host, kit)),
|
|
48
|
+
policy: (_policy, kit) => Effect.succeed({ receive: (delivery) => kit.run(delivery) }),
|
|
49
|
+
projection: () => unsupported("projections"),
|
|
50
|
+
stream: () => unsupported("streams"),
|
|
51
|
+
start: () => Effect.gen(function* () {
|
|
52
|
+
const due = yield* sql `
|
|
53
|
+
SELECT aggregate, id, command_id, token, due_at FROM ${t("schedules")} WHERE model = ${model}`.pipe(Effect.orDie);
|
|
54
|
+
yield* Effect.forEach(due, (row) => arm(row.aggregate, row.id, row.command_id, row.token, Number(row.due_at)), { discard: true });
|
|
55
|
+
yield* Effect.forkIn(outbox, scope);
|
|
56
|
+
}),
|
|
57
|
+
};
|
|
58
|
+
return modelHost;
|
|
59
|
+
});
|
|
60
|
+
export const layer = (options = {}) => Layer.effect(Platform, Effect.gen(function* () {
|
|
61
|
+
const sql = yield* PgClient;
|
|
62
|
+
const schema = options.schema ?? "domain";
|
|
63
|
+
yield* (options.createTables === false ? checkTables(schema) : migrate(schema)).pipe(Effect.orDie);
|
|
64
|
+
return Platform.of({
|
|
65
|
+
model: ({ modelName }) => Effect.flatMap(Effect.scope, (scope) => makeHost(sql, schema, modelName, scope, options)),
|
|
66
|
+
});
|
|
67
|
+
}));
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { PgClient } from "@effect/sql-pg/PgClient";
|
|
2
|
+
import * as Effect from "effect/Effect";
|
|
3
|
+
export declare const tableNames: readonly ["instances", "events", "receipts", "schedules", "cancellations", "views", "view_keys", "view_entries", "outbox"];
|
|
4
|
+
declare const TablesMissing_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 & {
|
|
5
|
+
readonly _tag: "TablesMissing";
|
|
6
|
+
} & Readonly<A>;
|
|
7
|
+
export declare class TablesMissing extends TablesMissing_base<{
|
|
8
|
+
readonly schema: string;
|
|
9
|
+
readonly missing: ReadonlyArray<string>;
|
|
10
|
+
readonly message: string;
|
|
11
|
+
}> {
|
|
12
|
+
}
|
|
13
|
+
export declare const createTables: (schema: string) => Effect.Effect<void, import("effect/sql/SqlError").SqlError, PgClient>;
|
|
14
|
+
export declare const migrate: (schema: string) => Effect.Effect<void, import("effect/sql/SqlError").SqlError, PgClient>;
|
|
15
|
+
export declare const checkTables: (schema: string) => Effect.Effect<undefined, import("effect/sql/SqlError").SqlError | TablesMissing, PgClient>;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { PgClient } from "@effect/sql-pg/PgClient";
|
|
2
|
+
import * as Data from "effect/Data";
|
|
3
|
+
import * as Effect from "effect/Effect";
|
|
4
|
+
export const tableNames = [
|
|
5
|
+
"instances",
|
|
6
|
+
"events",
|
|
7
|
+
"receipts",
|
|
8
|
+
"schedules",
|
|
9
|
+
"cancellations",
|
|
10
|
+
"views",
|
|
11
|
+
"view_keys",
|
|
12
|
+
"view_entries",
|
|
13
|
+
"outbox",
|
|
14
|
+
];
|
|
15
|
+
export class TablesMissing extends Data.TaggedError("TablesMissing") {
|
|
16
|
+
}
|
|
17
|
+
export const createTables = (schema) => Effect.gen(function* () {
|
|
18
|
+
const sql = yield* PgClient;
|
|
19
|
+
const t = (name) => sql(`${schema}.${name}`);
|
|
20
|
+
// Parallel hosts on one database would race CREATE IF NOT EXISTS without it.
|
|
21
|
+
yield* sql `SELECT pg_advisory_xact_lock(hashtext(${`house-rules/domain-postgres/${schema}`}))`;
|
|
22
|
+
yield* sql `CREATE SCHEMA IF NOT EXISTS ${sql(schema)}`;
|
|
23
|
+
yield* sql `
|
|
24
|
+
CREATE TABLE IF NOT EXISTS ${t("instances")} (
|
|
25
|
+
model text NOT NULL,
|
|
26
|
+
aggregate text NOT NULL,
|
|
27
|
+
id text NOT NULL,
|
|
28
|
+
state jsonb,
|
|
29
|
+
version integer NOT NULL DEFAULT 0 CHECK (version >= 0),
|
|
30
|
+
PRIMARY KEY (model, aggregate, id)
|
|
31
|
+
)`;
|
|
32
|
+
yield* sql `
|
|
33
|
+
CREATE TABLE IF NOT EXISTS ${t("events")} (
|
|
34
|
+
model text NOT NULL,
|
|
35
|
+
aggregate text NOT NULL,
|
|
36
|
+
id text NOT NULL,
|
|
37
|
+
seq integer NOT NULL CHECK (seq > 0),
|
|
38
|
+
event jsonb NOT NULL,
|
|
39
|
+
envelope jsonb NOT NULL,
|
|
40
|
+
PRIMARY KEY (model, aggregate, id, seq)
|
|
41
|
+
)`;
|
|
42
|
+
yield* sql `
|
|
43
|
+
CREATE TABLE IF NOT EXISTS ${t("receipts")} (
|
|
44
|
+
model text NOT NULL,
|
|
45
|
+
aggregate text NOT NULL,
|
|
46
|
+
id text NOT NULL,
|
|
47
|
+
command_id text NOT NULL,
|
|
48
|
+
result jsonb NOT NULL,
|
|
49
|
+
PRIMARY KEY (model, aggregate, id, command_id)
|
|
50
|
+
)`;
|
|
51
|
+
yield* sql `
|
|
52
|
+
CREATE TABLE IF NOT EXISTS ${t("schedules")} (
|
|
53
|
+
model text NOT NULL,
|
|
54
|
+
aggregate text NOT NULL,
|
|
55
|
+
id text NOT NULL,
|
|
56
|
+
command_id text NOT NULL,
|
|
57
|
+
command jsonb NOT NULL,
|
|
58
|
+
meta jsonb NOT NULL,
|
|
59
|
+
due_at bigint NOT NULL,
|
|
60
|
+
token text NOT NULL,
|
|
61
|
+
PRIMARY KEY (model, aggregate, id, command_id)
|
|
62
|
+
)`;
|
|
63
|
+
yield* sql `
|
|
64
|
+
CREATE TABLE IF NOT EXISTS ${t("cancellations")} (
|
|
65
|
+
model text NOT NULL,
|
|
66
|
+
aggregate text NOT NULL,
|
|
67
|
+
id text NOT NULL,
|
|
68
|
+
command_id text NOT NULL,
|
|
69
|
+
PRIMARY KEY (model, aggregate, id, command_id)
|
|
70
|
+
)`;
|
|
71
|
+
yield* sql `
|
|
72
|
+
CREATE TABLE IF NOT EXISTS ${t("views")} (
|
|
73
|
+
model text NOT NULL,
|
|
74
|
+
view text NOT NULL,
|
|
75
|
+
generation integer NOT NULL,
|
|
76
|
+
PRIMARY KEY (model, view)
|
|
77
|
+
)`;
|
|
78
|
+
yield* sql `
|
|
79
|
+
CREATE TABLE IF NOT EXISTS ${t("view_keys")} (
|
|
80
|
+
model text NOT NULL,
|
|
81
|
+
view text NOT NULL,
|
|
82
|
+
key text NOT NULL,
|
|
83
|
+
snapshot jsonb NOT NULL,
|
|
84
|
+
PRIMARY KEY (model, view, key)
|
|
85
|
+
)`;
|
|
86
|
+
yield* sql `
|
|
87
|
+
CREATE TABLE IF NOT EXISTS ${t("view_entries")} (
|
|
88
|
+
model text NOT NULL,
|
|
89
|
+
view text NOT NULL,
|
|
90
|
+
key text NOT NULL,
|
|
91
|
+
seq integer NOT NULL CHECK (seq > 0),
|
|
92
|
+
entry jsonb NOT NULL,
|
|
93
|
+
PRIMARY KEY (model, view, key, seq)
|
|
94
|
+
)`;
|
|
95
|
+
yield* sql `
|
|
96
|
+
CREATE TABLE IF NOT EXISTS ${t("outbox")} (
|
|
97
|
+
position bigserial PRIMARY KEY,
|
|
98
|
+
model text NOT NULL,
|
|
99
|
+
delivery jsonb NOT NULL,
|
|
100
|
+
attempts integer NOT NULL DEFAULT 0,
|
|
101
|
+
failed_at bigint
|
|
102
|
+
)`;
|
|
103
|
+
yield* sql `
|
|
104
|
+
CREATE INDEX IF NOT EXISTS ${sql("outbox_pending")} ON ${t("outbox")} (model, position)
|
|
105
|
+
WHERE failed_at IS NULL`;
|
|
106
|
+
});
|
|
107
|
+
export const migrate = (schema) => Effect.gen(function* () {
|
|
108
|
+
const sql = yield* PgClient;
|
|
109
|
+
yield* sql.withTransaction(createTables(schema));
|
|
110
|
+
});
|
|
111
|
+
export const checkTables = (schema) => Effect.gen(function* () {
|
|
112
|
+
const sql = yield* PgClient;
|
|
113
|
+
const schemas = yield* sql `
|
|
114
|
+
SELECT EXISTS (SELECT 1 FROM pg_catalog.pg_namespace WHERE nspname = ${schema}) AS found`;
|
|
115
|
+
const rows = yield* sql `
|
|
116
|
+
SELECT c.relname AS name
|
|
117
|
+
FROM pg_catalog.pg_class c
|
|
118
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
119
|
+
WHERE n.nspname = ${schema} AND c.relkind IN ('r', 'p')`;
|
|
120
|
+
const found = new Set(rows.map((row) => row.name));
|
|
121
|
+
const missing = tableNames.filter((name) => !found.has(name));
|
|
122
|
+
if (missing.length === 0)
|
|
123
|
+
return;
|
|
124
|
+
const fix = `run migrate("${schema}") as the schema owner before starting with createTables: false`;
|
|
125
|
+
return yield* new TablesMissing({
|
|
126
|
+
schema,
|
|
127
|
+
missing,
|
|
128
|
+
message: schemas[0]?.found === true
|
|
129
|
+
? `schema "${schema}" lacks tables ${missing.join(", ")}; ${fix}`
|
|
130
|
+
: `schema "${schema}" does not exist; ${fix}`,
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { emptySnapshot, } from "@alchemy.run/domain/Platform";
|
|
2
|
+
import * as Effect from "effect/Effect";
|
|
3
|
+
import * as Stream from "effect/Stream";
|
|
4
|
+
export const makeViewStore = (host, kit) => Effect.gen(function* () {
|
|
5
|
+
const { sql, t, model, notify, kick, stage } = host;
|
|
6
|
+
const name = kit.name;
|
|
7
|
+
const where = (key) => sql `model = ${model} AND view = ${name} AND key = ${key}`;
|
|
8
|
+
yield* sql
|
|
9
|
+
.withTransaction(Effect.gen(function* () {
|
|
10
|
+
const rows = yield* sql `
|
|
11
|
+
SELECT generation FROM ${t("views")} WHERE model = ${model} AND view = ${name} FOR UPDATE`;
|
|
12
|
+
const stored = rows[0]?.generation;
|
|
13
|
+
if (stored !== undefined && stored >= kit.generation)
|
|
14
|
+
return;
|
|
15
|
+
// Rebuilt: start every key over. Without a backfill, keys refill from new events only.
|
|
16
|
+
yield* sql `DELETE FROM ${t("view_keys")} WHERE model = ${model} AND view = ${name}`;
|
|
17
|
+
yield* sql `DELETE FROM ${t("view_entries")} WHERE model = ${model} AND view = ${name}`;
|
|
18
|
+
yield* sql `
|
|
19
|
+
INSERT INTO ${t("views")} (model, view, generation) VALUES (${model}, ${name}, ${kit.generation})
|
|
20
|
+
ON CONFLICT (model, view) DO UPDATE SET generation = EXCLUDED.generation`;
|
|
21
|
+
}))
|
|
22
|
+
.pipe(Effect.orDie);
|
|
23
|
+
const read = (key) => Effect.map(sql `SELECT snapshot FROM ${t("view_keys")} WHERE ${where(key)}`, (rows) => rows[0]?.snapshot ?? emptySnapshot).pipe(Effect.orDie);
|
|
24
|
+
const entriesAfter = (key, after) => Effect.map(sql `
|
|
25
|
+
SELECT entry FROM ${t("view_entries")} WHERE ${where(key)} AND seq > ${after} ORDER BY seq`, (rows) => rows.map((row) => row.entry)).pipe(Effect.orDie);
|
|
26
|
+
const apply = (key, deliveries) => Effect.gen(function* () {
|
|
27
|
+
yield* sql `
|
|
28
|
+
INSERT INTO ${t("view_keys")} (model, view, key, snapshot)
|
|
29
|
+
VALUES (${model}, ${name}, ${key}, ${sql.json(emptySnapshot)}) ON CONFLICT DO NOTHING`;
|
|
30
|
+
const rows = yield* sql `
|
|
31
|
+
SELECT snapshot FROM ${t("view_keys")} WHERE ${where(key)} FOR UPDATE`;
|
|
32
|
+
const before = rows[0]?.snapshot ?? emptySnapshot;
|
|
33
|
+
const result = yield* kit.apply(key, before, deliveries);
|
|
34
|
+
if (!result.changed)
|
|
35
|
+
return false;
|
|
36
|
+
yield* sql `UPDATE ${t("view_keys")} SET snapshot = ${sql.json(result.snapshot)} WHERE ${where(key)}`;
|
|
37
|
+
yield* Effect.forEach(result.entries, (entry) => sql `
|
|
38
|
+
INSERT INTO ${t("view_entries")} (model, view, key, seq, entry)
|
|
39
|
+
VALUES (${model}, ${name}, ${key}, ${entry.seq}, ${sql.json(entry)})`, { discard: true });
|
|
40
|
+
yield* stage(result.downstream);
|
|
41
|
+
return true;
|
|
42
|
+
});
|
|
43
|
+
const store = {
|
|
44
|
+
receive: (key, deliveries) => sql.withTransaction(apply(key, deliveries)).pipe(Effect.orDie, Effect.tap((changed) => changed ? Effect.andThen(notify.bump(`v:${name}/${key}`), kick) : Effect.void), Effect.asVoid, kit.observe(key, deliveries)),
|
|
45
|
+
read,
|
|
46
|
+
changes: (key) => notify.changes(`v:${name}/${key}`, read(key)),
|
|
47
|
+
list: (key) => entriesAfter(key, 0),
|
|
48
|
+
tail: (key, after) => {
|
|
49
|
+
let last = after;
|
|
50
|
+
return notify.changes(`v:${name}/${key}`, Effect.void).pipe(Stream.flatMap(() => Stream.unwrap(Effect.map(entriesAfter(key, last), (fresh) => {
|
|
51
|
+
const newest = fresh.at(-1);
|
|
52
|
+
if (newest)
|
|
53
|
+
last = newest.seq;
|
|
54
|
+
return Stream.fromIterable(fresh);
|
|
55
|
+
}))));
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
return store;
|
|
59
|
+
});
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,51 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@house-rules/domain-postgres",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "A Postgres Platform for Alchemy Domain: events, state, views and an outbox in your own Postgres.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"files": [
|
|
8
|
+
"dist",
|
|
9
|
+
"AGENTS.md",
|
|
10
|
+
"README.md"
|
|
11
|
+
],
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"default": "./dist/index.js"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/JakubSzwajka/house-rules.git",
|
|
24
|
+
"directory": "packages/domain-postgres"
|
|
25
|
+
},
|
|
26
|
+
"bugs": {
|
|
27
|
+
"url": "https://github.com/JakubSzwajka/house-rules/issues"
|
|
28
|
+
},
|
|
29
|
+
"homepage": "https://github.com/JakubSzwajka/house-rules",
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@effect/sql-pg": "4.1.2"
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"@alchemy.run/domain": "2.0.0-beta.85",
|
|
35
|
+
"alchemy": "2.0.0-beta.85",
|
|
36
|
+
"effect": "4.1.2"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@alchemy.run/domain": "2.0.0-beta.85",
|
|
40
|
+
"@effect/vitest": "4.1.2",
|
|
41
|
+
"alchemy": "2.0.0-beta.85",
|
|
42
|
+
"effect": "4.1.2",
|
|
43
|
+
"typescript": "7.0.2",
|
|
44
|
+
"vitest": "5.0.1"
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"build": "tsc --project tsconfig.build.json",
|
|
48
|
+
"typecheck": "tsc --project tsconfig.json",
|
|
49
|
+
"test": "vitest run && DOMAIN_STORE=memory vitest run"
|
|
50
|
+
}
|
|
6
51
|
}
|