@house-rules/relations 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/AGENTS.md ADDED
@@ -0,0 +1,40 @@
1
+ # @house-rules/relations: notes for agents
2
+
3
+ Relations stores typed edges between opaque `(kind, id)` nodes. It owns structural validation and graph traversal, not authority. The package is publish-ready but is not part of the release workflow.
4
+
5
+ ## Boundary
6
+
7
+ Relations takes NO host port and performs NO access checks and NO node-existence checks. It validates only STRUCTURE from its config. The consuming app verifies endpoint existence and access in its feature BEFORE calling relate/unrelate. Authority-bearing relationships, including memberships and roles, belong to `@house-rules/party`, never here.
8
+
9
+ if removing an edge changes what someone may DO, it belongs in party; if it only changes what is shown or computed, it belongs in relations.
10
+
11
+ Do not add a target-access port, caller parameter, role lookup or endpoint-existence lookup. Reads also return structural facts without filtering. Apps must guard graph reads and avoid exposing edges or node ids the caller cannot see.
12
+
13
+ ## Mounting
14
+
15
+ Import `Relations` and `RelationsMount` from the main door. Composition selects `Relations.layerMemory(options)` or `Relations.layerPostgres(options)`, and pairs the mount with that layer. Provide one shared unit adapter. Postgres also needs `SqlClient` and the module's migrations, registered as `{ "workspace": "packages/relations" }` in this workspace or by package in an installed app.
16
+
17
+ There is no host port. `RelationStore` is the module-owned storage port; adapters answer facts, while domain code checks structural limits. `bindRelations` captures the service once and supports person and system execution equally. The browser-safe contract door exports schemas, errors, permissions and `RelationsOperations`, never layers or bindings.
18
+
19
+ ## Options and operations
20
+
21
+ Pass `nodeKinds`, `relationTypes`, `maxGraphSize` and `maxPageSize`. Each relation type declares `name`, allowed `source`/`target` kinds, `directed`, `acyclic`, `unique` and optional cardinality caps. Kind sets must be nonempty subsets of `nodeKinds`; type names and node kinds must be unique. Caps are positive integers. Directed types accept `maxOutPerSource` and `maxInPerTarget`; symmetric types accept `maxDegree` and cannot be acyclic.
22
+
23
+ `Relations.presets.default` supplies only graph/page limits. Add app-specific kinds and types in code. See [README.md](README.md) for an example and traversal semantics.
24
+
25
+ - `Relations.relate` and `Relations.unrelate` declare `relations:write` and are transactional. Unrelate is destructive.
26
+ - `Relations.listRelations` and `Relations.subgraph` declare `relations:read` and are read-only.
27
+ - `relate` returns `{ relation, events: [Related] }`; `unrelate` returns `{ events: [Unrelated] }`. `RelationEvents` in `./contract` declares facts with `kind: "Related"` or `kind: "Unrelated"`, `type`, `source: { kind, id }` and `target: { kind, id }`. Facts use stored endpoint order, including symmetric canonicalization. Read results are unchanged.
28
+ - `removeNode(ref)` is an idempotent facade hook, not an operation. Call it inside the node-deletion feature's unit. It accepts any nonempty kind and id, even after the kind is removed from config. It returns void and no events because node deletion is a fact owned by the node's module.
29
+
30
+ ## Fixed rules
31
+
32
+ The module returns event facts as plain data. It never emits, depends on event-log or reads `CurrentActor`. Features decide whether to record facts through explicit event-log edges; event-log supplies the envelope.
33
+
34
+ Writes require `UnitOfWork.required`. The module never opens a unit. Memory registers undo before mutation and uses the shared `memoryUnitOfWork` to serialize units. Postgres joins the open transaction. Before insertion checks, it takes a transaction-scoped advisory lock with a fixed Relations namespace and a hash of the type name. Writes of one type are serialized; different types run in parallel. Features relating several types in one unit must relate them in a fixed, sorted type-name order across all callers to avoid deadlocks between per-type advisory locks. Deletions need no advisory lock because they cannot create a cycle or exceed a cap. The checks rely on READ COMMITTED; the default isolation level is assumed. Use the same module config for every process sharing a database.
35
+
36
+ Node references are plain kind/id columns with no cross-module foreign keys. Symmetric edges use deterministic endpoint order, including when the allowed endpoint kinds differ. A self-edge counts once toward `maxDegree`; directed acyclic types reject it.
37
+
38
+ Cycle traversal uses a bounded memory walk or a recursive Postgres CTE. `maxGraphSize` bounds visited nodes during cycle checks, and bounds both nodes and edges returned by subgraph BFS. Crossing a bound fails `GraphTooLarge`, never returns a partial graph. Parallel edges count separately. Depth zero returns only the root. Direction follows stored order; use `both` for symmetric traversal.
39
+
40
+ Unavailable errors use fixed public text and retain causes internally. Test changes against both memory and real Postgres with `MigrationsTesting.database`. Run `pnpm check` and `pnpm test` before calling work ready.
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,79 @@
1
- # Temporary Holding Version
1
+ # @house-rules/relations
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
+ Typed structural edges between opaque nodes, with cardinality and cycle checks. The package is publish-ready but is not part of the release workflow. Nothing has been published.
4
+
5
+ Relations does not check access or node existence. The app must verify both endpoints in its feature before it calls `relate` or `unrelate`. Memberships, roles and other authority-bearing relationships belong in Party.
6
+
7
+ > if removing an edge changes what someone may DO, it belongs in party; if it only changes what is shown or computed, it belongs in relations.
8
+
9
+ ## Configure and mount
10
+
11
+ ```ts
12
+ import { Relations, RelationsMount } from "@house-rules/relations";
13
+
14
+ const relations = Relations.layerMemory({
15
+ ...Relations.presets.default,
16
+ nodeKinds: ["document", "folder"],
17
+ relationTypes: [
18
+ {
19
+ name: "contains",
20
+ source: ["folder"],
21
+ target: ["folder", "document"],
22
+ directed: true,
23
+ acyclic: true,
24
+ unique: true,
25
+ maxInPerTarget: 1,
26
+ },
27
+ ],
28
+ });
29
+ ```
30
+
31
+ Composition pairs `RelationsMount` with this layer and a shared `memoryUnitOfWork`. For Postgres, select `Relations.layerPostgres` instead, provide `SqlClient` and `sqlUnitOfWork`, and run the owning module's `migrations/` through `@house-rules/migrations`. This workspace registers it in root `migrations.json`. There are no cross-module foreign keys and no host-access port.
32
+
33
+ All limits are positive integers. Endpoint kind lists must be nonempty subsets of `nodeKinds`. Duplicate node kinds or type names fail layer acquisition with `RelationsInputInvalid`. Symmetric types cannot be acyclic or use directed cardinality caps. Directed types cannot use `maxDegree`.
34
+
35
+ ## Operations
36
+
37
+ The browser-safe `@house-rules/relations/contract` door exports `RelationsOperations`, `RelationEvents`, schemas, errors and `RelationsPermissions`. Features declare these descriptors as edges; composition captures the service with `bindRelations` through `RelationsMount`.
38
+
39
+ | Operation | Input | Result | Permission and flags |
40
+ | --- | --- | --- | --- |
41
+ | `relate` | `{ type, source: { kind, id }, target: { kind, id } }` | `{ relation, events: [Related] }`; relation has `id` and epoch-millisecond `createdAt` | `relations:write`, transactional |
42
+ | `unrelate` | `{ id }` | `{ events: [Unrelated] }` | `relations:write`, transactional, destructive |
43
+ | `listRelations` | `{ node, type?, direction, page?: { size?, cursor? } }` | `{ edges, next? }` | `relations:read`, readOnly |
44
+ | `subgraph` | `{ root, type, direction, depth? }` | `{ nodes, edges }` | `relations:read`, readOnly |
45
+
46
+ Facade methods use the same input objects, except `unrelate(id)` takes the id directly. They take no Actor. Person and system bindings behave the same after the app's permission gates.
47
+
48
+ `RelationEvents.Related` and `RelationEvents.Unrelated` declare `{ kind: "Related" | "Unrelated", type, source: { kind, id }, target: { kind, id } }`. Each successful write returns its fact in stored endpoint order. Unrelate returns the endpoints of the edge it actually deleted. Read results are unchanged. The module returns plain data, never emits, never depends on event-log and never reads `CurrentActor`. Features choose whether to record facts through explicit event-log edges; event-log supplies the envelope.
49
+
50
+ `removeNode(ref)` is a facade-only cascade hook. Call it inside the same feature unit that deletes a node. It removes every touching edge, across all types, and succeeds when none exist. It accepts any nonempty kind and id, including a kind removed from config. It does not delete or verify the node. It returns void and no events because node deletion is a fact owned by the node's module.
51
+
52
+ ## Graph semantics
53
+
54
+ Symmetric types store endpoints in deterministic kind-then-id order. Either input orientation is valid if it matches the declared endpoint kind pairing. Reversing the arguments finds the same duplicate when `unique` is true. Set `unique: false` to allow parallel edges with separate ids.
55
+
56
+ Directions `out` and `in` follow stored source and target columns. Use `both` for symmetric edges. Lists order by `(createdAt, id)` and use an exclusive cursor; requested page size is capped at `maxPageSize`.
57
+
58
+ Directed caps count edges leaving a source or entering a target, per type. Symmetric `maxDegree` counts touching edges, including a self-edge once. Each parallel edge counts. Checks run inside the write unit. Postgres serializes insertions of one relation type with a transaction-scoped advisory lock; different types can run in parallel. Deletions need no advisory lock. The checks rely on the assumed default READ COMMITTED isolation level. Memory uses the shared unit adapter's serialization.
59
+
60
+ For an acyclic directed type, `relate(source, target)` searches from target for source before insertion. Self-edges fail `RelationCycle`. Postgres uses a recursive CTE with node deduplication and a demand limit; memory walks the same directed graph. If visited nodes exceed `maxGraphSize`, the write fails `GraphTooLarge` rather than assuming the graph is acyclic.
61
+
62
+ Subgraph uses BFS. Omitted depth traverses until exhaustion or the bound; depth zero returns the opaque root alone even if it has no edges. Nodes and edges are deduplicated by reference and edge id. Both counts must fit `maxGraphSize`, so parallel edges cannot produce an unbounded result. Exceeding either bound fails `GraphTooLarge` without a partial result.
63
+
64
+ ## Failures and units
65
+
66
+ Unknown types and invalid endpoints use `RelationTypeUnknown` and `RelationEndpointInvalid`. Duplicate, cardinality and cycle failures are conflicts. Removing an absent id returns `RelationNotFound`. Invalid page/depth/options use `RelationsInputInvalid`; oversized traversal uses `GraphTooLarge`. Store failures use `RelationsStoreUnavailable` with fixed public text and an internal cause.
67
+
68
+ Every write, including the cascade hook and adapter writes, fails `NoOpenUnit` outside a live unit. The module never opens one. Memory undo runs on rollback; Postgres joins the existing transaction.
69
+
70
+ ## Tests
71
+
72
+ ```sh
73
+ docker compose up -d
74
+ pnpm --filter @house-rules/relations typecheck
75
+ pnpm --filter @house-rules/relations build
76
+ pnpm --filter @house-rules/relations test
77
+ ```
78
+
79
+ Shared storage and structural suites run on memory and a fresh real Postgres database created through `MigrationsTesting.database`. Runtime tests exercise the mount, bindings and transactional feature execution.
@@ -0,0 +1,3 @@
1
+ import { Layer } from "effect";
2
+ import { RelationStore } from "../../storage.js";
3
+ export declare const memoryRelationStore: () => Layer.Layer<RelationStore>;
@@ -0,0 +1,67 @@
1
+ import { UnitOfWork } from "@house-rules/domain";
2
+ import { Effect, Layer } from "effect";
3
+ import { compareRelations, copyRelation, nodeKey, sameNode, touches, } from "../../relation/graph.js";
4
+ import { RelationsStoreUnavailable } from "../../relation/relation.js";
5
+ import { RelationStore } from "../../storage.js";
6
+ export const memoryRelationStore = () => Layer.sync(RelationStore, () => {
7
+ const rows = new Map();
8
+ const change = Effect.fn("RelationStore.memory.change")(function* (run) {
9
+ yield* UnitOfWork.required;
10
+ const before = new Map([...rows].map(([id, edge]) => [id, copyRelation(edge)]));
11
+ yield* UnitOfWork.onRollback(Effect.sync(() => {
12
+ rows.clear();
13
+ for (const [id, edge] of before)
14
+ rows.set(id, edge);
15
+ }));
16
+ run();
17
+ });
18
+ return RelationStore.of({
19
+ lock: () => UnitOfWork.required,
20
+ list: (node, type, direction, size, after) => Effect.sync(() => [...rows.values()]
21
+ .filter((edge) => (type === undefined || edge.type === type) &&
22
+ touches(edge, node, direction) &&
23
+ (after === undefined || compareRelations(edge, { ...edge, ...after }) > 0))
24
+ .sort(compareRelations)
25
+ .slice(0, size)
26
+ .map(copyRelation)),
27
+ count: (node, type, direction) => Effect.sync(() => [...rows.values()].filter((edge) => edge.type === type && touches(edge, node, direction)).length),
28
+ exists: (source, target, type) => Effect.sync(() => [...rows.values()].some((edge) => edge.type === type && sameNode(edge.source, source) && sameNode(edge.target, target))),
29
+ reachable: (root, type, limit) => Effect.sync(() => {
30
+ const nodes = [{ ...root }];
31
+ const seen = new Set([nodeKey(root)]);
32
+ for (let i = 0; i < nodes.length && nodes.length < limit; i++) {
33
+ const node = nodes[i];
34
+ if (node === undefined)
35
+ break;
36
+ for (const edge of rows.values()) {
37
+ if (edge.type !== type ||
38
+ !sameNode(edge.source, node) ||
39
+ seen.has(nodeKey(edge.target)))
40
+ continue;
41
+ seen.add(nodeKey(edge.target));
42
+ nodes.push({ ...edge.target });
43
+ if (nodes.length >= limit)
44
+ break;
45
+ }
46
+ }
47
+ return nodes;
48
+ }),
49
+ insert: Effect.fn("RelationStore.memory.insert")(function* (edge) {
50
+ yield* UnitOfWork.required;
51
+ if (rows.has(edge.id))
52
+ return yield* new RelationsStoreUnavailable({ cause: "Duplicate relation id." });
53
+ yield* change(() => void rows.set(edge.id, copyRelation(edge)));
54
+ }),
55
+ remove: Effect.fn("RelationStore.memory.remove")(function* (id) {
56
+ yield* UnitOfWork.required;
57
+ const found = rows.get(id);
58
+ yield* change(() => void rows.delete(id));
59
+ return found === undefined ? undefined : copyRelation(found);
60
+ }),
61
+ removeNode: (node) => change(() => {
62
+ for (const [id, edge] of rows)
63
+ if (touches(edge, node, "both"))
64
+ rows.delete(id);
65
+ }),
66
+ });
67
+ });
@@ -0,0 +1,4 @@
1
+ import { Layer } from "effect";
2
+ import { SqlClient } from "effect/sql/SqlClient";
3
+ import { RelationStore } from "../../storage.js";
4
+ export declare const postgresRelationStore: Layer.Layer<RelationStore, never, SqlClient>;
@@ -0,0 +1,49 @@
1
+ import { UnitOfWork } from "@house-rules/domain";
2
+ import { Effect, Layer, Schema } from "effect";
3
+ import { SqlClient } from "effect/sql/SqlClient";
4
+ import { NodeRef, Relation, RelationsStoreUnavailable } from "../../relation/relation.js";
5
+ import { RelationStore } from "../../storage.js";
6
+ const unavailable = (cause) => new RelationsStoreUnavailable({ cause });
7
+ export const postgresRelationStore = Layer.effect(RelationStore, Effect.gen(function* postgresRelationStore() {
8
+ const sql = yield* SqlClient;
9
+ const columns = sql.literal(`id, type, json_build_object('kind', source_kind, 'id', source_id) as source, json_build_object('kind', target_kind, 'id', target_id) as target, created_at::float8 as "createdAt"`);
10
+ const read = (query) => query.pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.Array(Relation))), Effect.mapError(unavailable));
11
+ const write = Effect.fn("RelationStore.postgres.write")(function* (query) {
12
+ yield* UnitOfWork.required;
13
+ yield* query.pipe(Effect.mapError(unavailable));
14
+ });
15
+ const at = (node, direction) => sql.or([
16
+ ...(direction !== "in"
17
+ ? [sql `(source_kind = ${node.kind} and source_id = ${node.id})`]
18
+ : []),
19
+ ...(direction !== "out"
20
+ ? [sql `(target_kind = ${node.kind} and target_id = ${node.id})`]
21
+ : []),
22
+ ]);
23
+ return RelationStore.of({
24
+ // Serialize same-type checks through commit. READ COMMITTED sees the preceding writer.
25
+ lock: (type) => write(sql `select pg_advisory_xact_lock(1919249505, hashtext(${type}))`),
26
+ list: (node, type, direction, size, after) => read(sql `
27
+ select ${columns} from relation_edges where ${at(node, direction)}
28
+ and ${type === undefined ? sql `true` : sql `type = ${type}`}
29
+ and ${after === undefined ? sql `true` : sql `(created_at, id collate "C") > (${after.createdAt}, ${after.id})`}
30
+ order by created_at, id collate "C" limit ${size}`),
31
+ count: (node, type, direction) => sql `select count(*)::float8 as count from relation_edges where type = ${type} and ${at(node, direction)}`.pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.Array(Schema.Struct({ count: Schema.Int })))), Effect.map((rows) => rows[0]?.count ?? 0), Effect.mapError(unavailable)),
32
+ exists: (source, target, type) => sql `select id from relation_edges where type = ${type} and source_kind = ${source.kind} and source_id = ${source.id} and target_kind = ${target.kind} and target_id = ${target.id} limit 1`.pipe(Effect.map((rows) => rows.length > 0), Effect.mapError(unavailable)),
33
+ // UNION deduplicates nodes. The outer LIMIT stops recursive demand at the bound.
34
+ reachable: (root, type, limit) => sql `
35
+ with recursive reached(kind, id) as (
36
+ select ${root.kind}::text, ${root.id}::text
37
+ union
38
+ select e.target_kind, e.target_id from relation_edges e join reached r
39
+ on e.source_kind = r.kind and e.source_id = r.id where e.type = ${type}
40
+ ) select kind, id from reached limit ${limit}`.pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.Array(NodeRef))), Effect.mapError(unavailable)),
41
+ insert: (edge) => write(sql `insert into relation_edges (id, type, source_kind, source_id, target_kind, target_id, created_at) values (${edge.id}, ${edge.type}, ${edge.source.kind}, ${edge.source.id}, ${edge.target.kind}, ${edge.target.id}, ${edge.createdAt})`),
42
+ remove: Effect.fn("RelationStore.postgres.remove")(function* (id) {
43
+ yield* UnitOfWork.required;
44
+ const rows = yield* read(sql `delete from relation_edges where id = ${id} returning ${columns}`);
45
+ return rows[0];
46
+ }),
47
+ removeNode: (node) => write(sql `delete from relation_edges where ${at(node, "both")}`),
48
+ });
49
+ }));
@@ -0,0 +1,4 @@
1
+ export { RelateResult, RelationEvents, UnrelateResult } from "./relation/events.js";
2
+ export { DuplicateRelation, GraphTooLarge, ListRelationsInput, NodeRef, RelateInput, Relation, RelationCardinalityExceeded, RelationCursor, RelationCycle, RelationDirection, RelationEndpointInvalid, RelationGraph, RelationList, RelationNotFound, RelationPage, RelationsInputInvalid, RelationsStoreUnavailable, RelationTypeUnknown, SubgraphInput, } from "./relation/relation.js";
3
+ export { RelationsOperations } from "./relation/operations.js";
4
+ export { RelationsPermissions, type RelationsPermission } from "./relation/permissions.js";
@@ -0,0 +1,4 @@
1
+ export { RelateResult, RelationEvents, UnrelateResult } from "./relation/events.js";
2
+ export { DuplicateRelation, GraphTooLarge, ListRelationsInput, NodeRef, RelateInput, Relation, RelationCardinalityExceeded, RelationCursor, RelationCycle, RelationDirection, RelationEndpointInvalid, RelationGraph, RelationList, RelationNotFound, RelationPage, RelationsInputInvalid, RelationsStoreUnavailable, RelationTypeUnknown, SubgraphInput, } from "./relation/relation.js";
3
+ export { RelationsOperations } from "./relation/operations.js";
4
+ export { RelationsPermissions } from "./relation/permissions.js";
@@ -0,0 +1,313 @@
1
+ import { Effect, Layer } from "effect";
2
+ import { RelationsOptions } from "./relation/options.js";
3
+ import { RelationsInputInvalid } from "./relation/relation.js";
4
+ import { RelationStore } from "./storage.js";
5
+ declare const Relations_base: import("effect/Context").ServiceClass<Relations, "relations", {
6
+ relate: (input: {
7
+ readonly type: string;
8
+ readonly source: {
9
+ readonly kind: string;
10
+ readonly id: string;
11
+ };
12
+ readonly target: {
13
+ readonly kind: string;
14
+ readonly id: string;
15
+ };
16
+ }) => Effect.Effect<{
17
+ relation: {
18
+ readonly id: string;
19
+ readonly type: string;
20
+ readonly source: {
21
+ readonly kind: string;
22
+ readonly id: string;
23
+ };
24
+ readonly target: {
25
+ readonly kind: string;
26
+ readonly id: string;
27
+ };
28
+ readonly createdAt: number;
29
+ };
30
+ events: {
31
+ kind: "Related";
32
+ type: string;
33
+ source: {
34
+ kind: string;
35
+ id: string;
36
+ };
37
+ target: {
38
+ kind: string;
39
+ id: string;
40
+ };
41
+ }[];
42
+ }, import("./relation/relation.js").DuplicateRelation | import("./relation/relation.js").GraphTooLarge | import("@house-rules/domain").NoOpenUnit | import("./relation/relation.js").RelationCardinalityExceeded | import("./relation/relation.js").RelationCycle | import("./relation/relation.js").RelationEndpointInvalid | import("./relation/relation.js").RelationTypeUnknown | import("./relation/relation.js").RelationsStoreUnavailable, never>;
43
+ unrelate: (id: string) => Effect.Effect<{
44
+ events: {
45
+ kind: "Unrelated";
46
+ type: string;
47
+ source: {
48
+ kind: string;
49
+ id: string;
50
+ };
51
+ target: {
52
+ kind: string;
53
+ id: string;
54
+ };
55
+ }[];
56
+ }, import("@house-rules/domain").NoOpenUnit | import("./relation/relation.js").RelationNotFound | import("./relation/relation.js").RelationsStoreUnavailable, never>;
57
+ listRelations: (input: {
58
+ readonly node: {
59
+ readonly kind: string;
60
+ readonly id: string;
61
+ };
62
+ readonly type?: string;
63
+ readonly direction: "both" | "in" | "out";
64
+ readonly page?: {
65
+ readonly size?: number;
66
+ readonly cursor?: {
67
+ readonly createdAt: number;
68
+ readonly id: string;
69
+ };
70
+ };
71
+ }) => Effect.Effect<{
72
+ edges: {
73
+ readonly id: string;
74
+ readonly type: string;
75
+ readonly source: {
76
+ readonly kind: string;
77
+ readonly id: string;
78
+ };
79
+ readonly target: {
80
+ readonly kind: string;
81
+ readonly id: string;
82
+ };
83
+ readonly createdAt: number;
84
+ }[];
85
+ next: {
86
+ createdAt: number;
87
+ id: string;
88
+ };
89
+ } | {
90
+ next?: never;
91
+ edges: {
92
+ readonly id: string;
93
+ readonly type: string;
94
+ readonly source: {
95
+ readonly kind: string;
96
+ readonly id: string;
97
+ };
98
+ readonly target: {
99
+ readonly kind: string;
100
+ readonly id: string;
101
+ };
102
+ readonly createdAt: number;
103
+ }[];
104
+ }, import("./relation/relation.js").RelationEndpointInvalid | import("./relation/relation.js").RelationTypeUnknown | RelationsInputInvalid | import("./relation/relation.js").RelationsStoreUnavailable, never>;
105
+ subgraph: (input: {
106
+ readonly root: {
107
+ readonly kind: string;
108
+ readonly id: string;
109
+ };
110
+ readonly type: string;
111
+ readonly direction: "both" | "in" | "out";
112
+ readonly depth?: number;
113
+ }) => Effect.Effect<{
114
+ nodes: {
115
+ kind: string;
116
+ id: string;
117
+ }[];
118
+ edges: {
119
+ readonly id: string;
120
+ readonly type: string;
121
+ readonly source: {
122
+ readonly kind: string;
123
+ readonly id: string;
124
+ };
125
+ readonly target: {
126
+ readonly kind: string;
127
+ readonly id: string;
128
+ };
129
+ readonly createdAt: number;
130
+ }[];
131
+ }, import("./relation/relation.js").GraphTooLarge | import("./relation/relation.js").RelationEndpointInvalid | import("./relation/relation.js").RelationTypeUnknown | RelationsInputInvalid | import("./relation/relation.js").RelationsStoreUnavailable, never>;
132
+ removeNode: (ref: {
133
+ readonly kind: string;
134
+ readonly id: string;
135
+ }) => Effect.Effect<void, import("@house-rules/domain").NoOpenUnit | import("./relation/relation.js").RelationEndpointInvalid | import("./relation/relation.js").RelationsStoreUnavailable, never>;
136
+ }> & {
137
+ readonly id: "relations";
138
+ } & {
139
+ readonly make: (options: {
140
+ readonly nodeKinds: readonly string[];
141
+ readonly relationTypes: readonly {
142
+ readonly name: string;
143
+ readonly source: readonly string[];
144
+ readonly target: readonly string[];
145
+ readonly directed: boolean;
146
+ readonly acyclic: boolean;
147
+ readonly unique: boolean;
148
+ readonly maxOutPerSource?: number;
149
+ readonly maxInPerTarget?: number;
150
+ readonly maxDegree?: number;
151
+ }[];
152
+ readonly maxGraphSize: number;
153
+ readonly maxPageSize: number;
154
+ }) => Effect.Effect<{
155
+ relate: (input: {
156
+ readonly type: string;
157
+ readonly source: {
158
+ readonly kind: string;
159
+ readonly id: string;
160
+ };
161
+ readonly target: {
162
+ readonly kind: string;
163
+ readonly id: string;
164
+ };
165
+ }) => Effect.Effect<{
166
+ relation: {
167
+ readonly id: string;
168
+ readonly type: string;
169
+ readonly source: {
170
+ readonly kind: string;
171
+ readonly id: string;
172
+ };
173
+ readonly target: {
174
+ readonly kind: string;
175
+ readonly id: string;
176
+ };
177
+ readonly createdAt: number;
178
+ };
179
+ events: {
180
+ kind: "Related";
181
+ type: string;
182
+ source: {
183
+ kind: string;
184
+ id: string;
185
+ };
186
+ target: {
187
+ kind: string;
188
+ id: string;
189
+ };
190
+ }[];
191
+ }, import("./relation/relation.js").DuplicateRelation | import("./relation/relation.js").GraphTooLarge | import("@house-rules/domain").NoOpenUnit | import("./relation/relation.js").RelationCardinalityExceeded | import("./relation/relation.js").RelationCycle | import("./relation/relation.js").RelationEndpointInvalid | import("./relation/relation.js").RelationTypeUnknown | import("./relation/relation.js").RelationsStoreUnavailable, never>;
192
+ unrelate: (id: string) => Effect.Effect<{
193
+ events: {
194
+ kind: "Unrelated";
195
+ type: string;
196
+ source: {
197
+ kind: string;
198
+ id: string;
199
+ };
200
+ target: {
201
+ kind: string;
202
+ id: string;
203
+ };
204
+ }[];
205
+ }, import("@house-rules/domain").NoOpenUnit | import("./relation/relation.js").RelationNotFound | import("./relation/relation.js").RelationsStoreUnavailable, never>;
206
+ listRelations: (input: {
207
+ readonly node: {
208
+ readonly kind: string;
209
+ readonly id: string;
210
+ };
211
+ readonly type?: string;
212
+ readonly direction: "both" | "in" | "out";
213
+ readonly page?: {
214
+ readonly size?: number;
215
+ readonly cursor?: {
216
+ readonly createdAt: number;
217
+ readonly id: string;
218
+ };
219
+ };
220
+ }) => Effect.Effect<{
221
+ edges: {
222
+ readonly id: string;
223
+ readonly type: string;
224
+ readonly source: {
225
+ readonly kind: string;
226
+ readonly id: string;
227
+ };
228
+ readonly target: {
229
+ readonly kind: string;
230
+ readonly id: string;
231
+ };
232
+ readonly createdAt: number;
233
+ }[];
234
+ next: {
235
+ createdAt: number;
236
+ id: string;
237
+ };
238
+ } | {
239
+ next?: never;
240
+ edges: {
241
+ readonly id: string;
242
+ readonly type: string;
243
+ readonly source: {
244
+ readonly kind: string;
245
+ readonly id: string;
246
+ };
247
+ readonly target: {
248
+ readonly kind: string;
249
+ readonly id: string;
250
+ };
251
+ readonly createdAt: number;
252
+ }[];
253
+ }, import("./relation/relation.js").RelationEndpointInvalid | import("./relation/relation.js").RelationTypeUnknown | RelationsInputInvalid | import("./relation/relation.js").RelationsStoreUnavailable, never>;
254
+ subgraph: (input: {
255
+ readonly root: {
256
+ readonly kind: string;
257
+ readonly id: string;
258
+ };
259
+ readonly type: string;
260
+ readonly direction: "both" | "in" | "out";
261
+ readonly depth?: number;
262
+ }) => Effect.Effect<{
263
+ nodes: {
264
+ kind: string;
265
+ id: string;
266
+ }[];
267
+ edges: {
268
+ readonly id: string;
269
+ readonly type: string;
270
+ readonly source: {
271
+ readonly kind: string;
272
+ readonly id: string;
273
+ };
274
+ readonly target: {
275
+ readonly kind: string;
276
+ readonly id: string;
277
+ };
278
+ readonly createdAt: number;
279
+ }[];
280
+ }, import("./relation/relation.js").GraphTooLarge | import("./relation/relation.js").RelationEndpointInvalid | import("./relation/relation.js").RelationTypeUnknown | RelationsInputInvalid | import("./relation/relation.js").RelationsStoreUnavailable, never>;
281
+ removeNode: (ref: {
282
+ readonly kind: string;
283
+ readonly id: string;
284
+ }) => Effect.Effect<void, import("@house-rules/domain").NoOpenUnit | import("./relation/relation.js").RelationEndpointInvalid | import("./relation/relation.js").RelationsStoreUnavailable, never>;
285
+ }, RelationsInputInvalid, RelationStore>;
286
+ readonly layer: (options: {
287
+ readonly nodeKinds: readonly string[];
288
+ readonly relationTypes: readonly {
289
+ readonly name: string;
290
+ readonly source: readonly string[];
291
+ readonly target: readonly string[];
292
+ readonly directed: boolean;
293
+ readonly acyclic: boolean;
294
+ readonly unique: boolean;
295
+ readonly maxOutPerSource?: number;
296
+ readonly maxInPerTarget?: number;
297
+ readonly maxDegree?: number;
298
+ }[];
299
+ readonly maxGraphSize: number;
300
+ readonly maxPageSize: number;
301
+ }) => import("@house-rules/domain").ModuleLayer<Relations, RelationsInputInvalid, RelationStore>;
302
+ };
303
+ export declare class Relations extends Relations_base {
304
+ static readonly presets: {
305
+ readonly default: {
306
+ readonly maxGraphSize: 1000;
307
+ readonly maxPageSize: 100;
308
+ };
309
+ };
310
+ static readonly layerMemory: (options: RelationsOptions) => Layer.Layer<Relations, RelationsInputInvalid, never>;
311
+ static readonly layerPostgres: (options: RelationsOptions) => Layer.Layer<Relations, RelationsInputInvalid, import("effect/sql/SqlClient").SqlClient>;
312
+ }
313
+ export {};
package/dist/facade.js ADDED
@@ -0,0 +1,19 @@
1
+ import { defineModule } from "@house-rules/domain";
2
+ import { Effect, Layer, Schema } from "effect";
3
+ import { memoryRelationStore } from "./adapters/memory/relation.js";
4
+ import { postgresRelationStore } from "./adapters/postgres/relation.js";
5
+ import { RelationsOptions } from "./relation/options.js";
6
+ import { RelationsInputInvalid } from "./relation/relation.js";
7
+ import { makeRelations } from "./relation/relations.js";
8
+ import { RelationStore } from "./storage.js";
9
+ export class Relations extends defineModule()("relations", {
10
+ make: (options) => Effect.gen(function* relationsLayer() {
11
+ const settings = yield* Schema.decodeEffect(RelationsOptions)(options).pipe(Effect.mapError(() => new RelationsInputInvalid()));
12
+ const store = yield* RelationStore;
13
+ return makeRelations(store, settings);
14
+ }),
15
+ }) {
16
+ static presets = { default: { maxGraphSize: 1000, maxPageSize: 100 } };
17
+ static layerMemory = (options) => this.layer(options).pipe(Layer.provide(memoryRelationStore()));
18
+ static layerPostgres = (options) => this.layer(options).pipe(Layer.provide(postgresRelationStore));
19
+ }