@house-rules/domain-postgres 0.1.0 → 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 +2 -0
- package/README.md +21 -1
- package/dist/adapters/postgres-platform.d.ts +1 -0
- package/dist/adapters/postgres-platform.js +2 -2
- package/dist/adapters/tables.d.ts +12 -0
- package/dist/adapters/tables.js +36 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -12,6 +12,8 @@ const Hosted = App.layer(AppHandlers).pipe(Layer.provide(fromEnv({ schema: "app"
|
|
|
12
12
|
|
|
13
13
|
- `fromEnv({ schema })` reads `DOMAIN_STORE` (`postgres` by default, or `memory`) and `DATABASE_URL`. Declare both in the app's `.env.schema`.
|
|
14
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.
|
|
15
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`.
|
|
16
18
|
|
|
17
19
|
## Fixed rules
|
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@ const Hosted = App.layer(AppHandlers).pipe(
|
|
|
16
16
|
|
|
17
17
|
## What it stores
|
|
18
18
|
|
|
19
|
-
`layer` creates its tables in `schema` on start (`CREATE ... IF NOT EXISTS`):
|
|
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
20
|
|
|
21
21
|
| Table | Holds |
|
|
22
22
|
| --- | --- |
|
|
@@ -29,6 +29,26 @@ const Hosted = App.layer(AppHandlers).pipe(
|
|
|
29
29
|
|
|
30
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
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
|
+
|
|
32
52
|
## Limits
|
|
33
53
|
|
|
34
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.
|
|
@@ -8,7 +8,7 @@ import * as Queue from "effect/Queue";
|
|
|
8
8
|
import { makeAggregateStore } from "./aggregate-store.js";
|
|
9
9
|
import { notifier } from "./host.js";
|
|
10
10
|
import { makeOutbox } from "./outbox.js";
|
|
11
|
-
import { migrate } from "./tables.js";
|
|
11
|
+
import { checkTables, migrate } from "./tables.js";
|
|
12
12
|
import { makeViewStore } from "./view-store.js";
|
|
13
13
|
const unsupported = (kind) => Effect.die(new Error(`@house-rules/domain-postgres does not host ${kind} yet; use Domain.InMemory`));
|
|
14
14
|
const makeHost = (sql, schema, model, scope, options) => Effect.gen(function* () {
|
|
@@ -60,7 +60,7 @@ const makeHost = (sql, schema, model, scope, options) => Effect.gen(function* ()
|
|
|
60
60
|
export const layer = (options = {}) => Layer.effect(Platform, Effect.gen(function* () {
|
|
61
61
|
const sql = yield* PgClient;
|
|
62
62
|
const schema = options.schema ?? "domain";
|
|
63
|
-
yield* migrate(schema).pipe(Effect.orDie);
|
|
63
|
+
yield* (options.createTables === false ? checkTables(schema) : migrate(schema)).pipe(Effect.orDie);
|
|
64
64
|
return Platform.of({
|
|
65
65
|
model: ({ modelName }) => Effect.flatMap(Effect.scope, (scope) => makeHost(sql, schema, modelName, scope, options)),
|
|
66
66
|
});
|
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
import { PgClient } from "@effect/sql-pg/PgClient";
|
|
2
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
|
+
}
|
|
3
13
|
export declare const createTables: (schema: string) => Effect.Effect<void, import("effect/sql/SqlError").SqlError, PgClient>;
|
|
4
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 {};
|
package/dist/adapters/tables.js
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
import { PgClient } from "@effect/sql-pg/PgClient";
|
|
2
|
+
import * as Data from "effect/Data";
|
|
2
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
|
+
}
|
|
3
17
|
export const createTables = (schema) => Effect.gen(function* () {
|
|
4
18
|
const sql = yield* PgClient;
|
|
5
19
|
const t = (name) => sql(`${schema}.${name}`);
|
|
@@ -94,3 +108,25 @@ export const migrate = (schema) => Effect.gen(function* () {
|
|
|
94
108
|
const sql = yield* PgClient;
|
|
95
109
|
yield* sql.withTransaction(createTables(schema));
|
|
96
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
|
+
});
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export { layer, type Options } from "./adapters/postgres-platform.ts";
|
|
2
|
-
export { migrate } from "./adapters/tables.ts";
|
|
2
|
+
export { migrate, TablesMissing } from "./adapters/tables.ts";
|
|
3
3
|
export { domainStore, fromEnv, LOCAL_DATABASE_URL, PgForTests, platformForTests, testSchema, } from "./adapters/from-env.ts";
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export { layer } from "./adapters/postgres-platform.js";
|
|
2
|
-
export { migrate } from "./adapters/tables.js";
|
|
2
|
+
export { migrate, TablesMissing } from "./adapters/tables.js";
|
|
3
3
|
export { domainStore, fromEnv, LOCAL_DATABASE_URL, PgForTests, platformForTests, testSchema, } from "./adapters/from-env.js";
|
package/package.json
CHANGED