Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- **Adding relations after removal**: Adding the same connection again creates a new relation with the supplied guidance while preserving the removed relation and its event history.

## [3.23.0] - 2026-09-06

### Added
Expand Down
11 changes: 10 additions & 1 deletion docs/reference/commands/relations.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,16 @@ Add, list, traverse, find paths through, audit, and remove relationships between

## jumbo relation add

Add a relationship between two entities.
Add a relationship between two entities. If an earlier relation with the same source type and ID, target type and ID, and relationship type was removed, adding again creates a new relation with a new ID and the supplied description and strength. The removed relation keeps its original metadata and event history.

Existing non-removed relations retain the usual duplicate behavior: add returns the existing ID without changing metadata or lifecycle status. To replace guidance on an active relation, remove it first, then add the new connection:

```bash
jumbo relation remove --id <oldRelationId>
jumbo relation add --from-type goal --from-id <goalId> --to-type invariant --to-id <invariantId> --type must-respect --description "Respect this constraint within the goal scope" --format json
```

The response contains the new relation ID. Omitted strength becomes `null`, as for any new relation. Use `jumbo relations list --entity-type goal --entity-id <goalId> --status all` to inspect current and removed links. Remove superseded links, such as obsolete `constrained-by` links, explicitly so only the intended guidance contributes to live context.

### Synopsis

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { EntityTypeValue } from "../../../../domain/relations/Constants.js";

/**
* Port interface for reading relation data needed by AddRelationCommandHandler.
* Used to check for existing relations (idempotency check).
* Used to check for non-removed relations (idempotency check).
* Removed relations are historical connections and must not prevent a new add.
*/
export interface IRelationAddedReader {
findByEntities(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,11 @@ export class SqliteRelationAddedProjector implements IRelationAddedProjector, IR

async applyRelationAdded(event: RelationAddedEvent): Promise<void> {
const stmt = this.db.prepare(`
INSERT OR REPLACE INTO relation_views (
INSERT INTO relation_views (
relationId, fromEntityType, fromEntityId, toEntityType, toEntityId,
relationType, strength, description, status, version, createdAt, updatedAt
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(relationId) DO NOTHING
`);

stmt.run(
Expand Down Expand Up @@ -53,6 +54,7 @@ export class SqliteRelationAddedProjector implements IRelationAddedProjector, IR
AND toEntityType = ?
AND toEntityId = ?
AND relationType = ?
AND status != 'removed'
`).get(fromEntityType, fromEntityId, toEntityType, toEntityId, relationType) as Record<string, unknown> | undefined;

return row ? this.mapRowToView(row) : null;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-- Removed connections retain their rows; a subsequent add has a new relation ID.
DROP INDEX IF EXISTS idx_relation_unique;

CREATE UNIQUE INDEX idx_relation_unique ON relation_views(
fromEntityType, fromEntityId, toEntityType, toEntityId, relationType
) WHERE status != 'removed';
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import Database from "better-sqlite3";
import { readFileSync } from "node:fs";
import { Relation } from "../../../../../src/domain/relations/Relation.js";
import { SqliteRelationAddedProjector } from "../../../../../src/infrastructure/context/relations/add/SqliteRelationAddedProjector.js";
import { SqliteRelationRemovedProjector } from "../../../../../src/infrastructure/context/relations/remove/SqliteRelationRemovedProjector.js";
import { SqliteRelationDeactivatedProjector } from "../../../../../src/infrastructure/context/relations/deactivate/SqliteRelationDeactivatedProjector.js";

describe("SqliteRelationAddedProjector", () => {
let db: Database.Database;
let projector: SqliteRelationAddedProjector;
const migrate = () => db.exec(readFileSync("src/infrastructure/context/relations/migrations/002-unique-non-removed-relations.sql", "utf8"));
const create = () => {
const relation = Relation.create();
const event = relation.add("goal", "same-id", "component", "same-id", "involves", "Guidance", "strong");
return { relation, event };
};
const find = () => projector.findByEntities("goal", "same-id", "component", "same-id", "involves");
beforeEach(() => {
db = new Database(":memory:");
db.exec(readFileSync("src/infrastructure/context/relations/migrations/001-create-relation-views.sql", "utf8"));
projector = new SqliteRelationAddedProjector(db);
});
afterEach(() => db.close());

it("excludes removed relations while retaining typed identity and deactivated matches", async () => {
migrate();
const { relation, event } = create();
await projector.applyRelationAdded(event);
expect(await find()).toMatchObject({ relationId: event.aggregateId, status: "active" });
expect(await projector.findByEntities("component", "same-id", "goal", "same-id", "involves")).toBeNull();
expect(await projector.findByEntities("goal", "same-id", "component", "same-id", "uses")).toBeNull();
await new SqliteRelationDeactivatedProjector(db).applyRelationDeactivated(relation.deactivate("Endpoint inactive"));
expect(await find()).toMatchObject({ relationId: event.aggregateId, status: "deactivated" });
await new SqliteRelationRemovedProjector(db).applyRelationRemoved(relation.remove());
expect(await find()).toBeNull();
});

it.each(["active", "deactivated", "removed"] as const)("migrates existing %s rows without modifying them", async status => {
const { relation, event } = create();
await projector.applyRelationAdded(event);
if (status === "deactivated") await new SqliteRelationDeactivatedProjector(db).applyRelationDeactivated(relation.deactivate("Paused"));
if (status === "removed") await new SqliteRelationRemovedProjector(db).applyRelationRemoved(relation.remove());
const before = db.prepare("SELECT * FROM relation_views").all();
migrate();
expect(db.prepare("SELECT * FROM relation_views").all()).toEqual(before);
const next = create();
if (status === "removed") {
await projector.applyRelationAdded(next.event);
expect(await find()).toMatchObject({ relationId: next.event.aggregateId });
expect(db.prepare("SELECT * FROM relation_views WHERE relationId = ?").get(event.aggregateId)).toEqual(before[0]);
} else {
await expect(projector.applyRelationAdded(next.event)).rejects.toMatchObject({ code: "SQLITE_CONSTRAINT_UNIQUE" });
expect(db.prepare("SELECT * FROM relation_views").all()).toEqual(before);
}
});

it("preserves multiple removed connections alongside one current connection", async () => {
migrate();
for (let index = 0; index < 3; index++) {
const { relation, event } = create();
await projector.applyRelationAdded(event);
await new SqliteRelationRemovedProjector(db).applyRelationRemoved(relation.remove());
}
const current = create();
await projector.applyRelationAdded(current.event);
expect(await find()).toMatchObject({ relationId: current.event.aggregateId });
await expect(projector.applyRelationAdded(create().event)).rejects.toMatchObject({ code: "SQLITE_CONSTRAINT_UNIQUE" });
expect(db.prepare("SELECT COUNT(*) AS count FROM relation_views").get()).toEqual({ count: 4 });
});

it("replaying an add cannot overwrite its removed row or replace a newer relation", async () => {
migrate();
const old = create();
await projector.applyRelationAdded(old.event);
await new SqliteRelationRemovedProjector(db).applyRelationRemoved(old.relation.remove());
const current = create();
await projector.applyRelationAdded(current.event);
const before = db.prepare("SELECT * FROM relation_views ORDER BY relationId").all();
await projector.applyRelationAdded(old.event);
await projector.applyRelationAdded(current.event);
expect(db.prepare("SELECT * FROM relation_views ORDER BY relationId").all()).toEqual(before);
});
});
105 changes: 105 additions & 0 deletions tests/integration/relation-add-after-removal.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import fs from "fs-extra";
import { join, resolve } from "node:path";
import { tmpdir } from "node:os";
import { spawnSync } from "node:child_process";
import Database from "better-sqlite3";
import { Host } from "../../src/infrastructure/host/Host.js";
import { IApplicationContainer } from "../../src/application/host/IApplicationContainer.js";
import { ProjectionBusFactory } from "../../src/infrastructure/messaging/ProjectionBusFactory.js";
import { SqliteRelationViewReader } from "../../src/infrastructure/context/relations/get/SqliteRelationViewReader.js";
import { RelationEventType } from "../../src/domain/relations/Constants.js";
import { jest } from "@jest/globals";

jest.setTimeout(30_000);

describe("Adding a relation after removal", () => {
let directory: string;
let host: Host;
let container: IApplicationContainer;
beforeEach(async () => {
directory = await fs.mkdtemp(join(tmpdir(), "relation-add-after-removal-"));
host = new Host(join(directory, ".jumbo"));
container = await host.createBuilder().build();
});
afterEach(async () => {
host.dispose();
await fs.remove(directory);
});
async function endpoints() {
const { goalId } = await container.addGoalController.handle({ title: "Repair context", objective: "Use current guidance", successCriteria: ["Current guidance connected"] });
const { invariantId } = await container.addInvariantController.handle({ title: "Boundary", description: "Respect the application boundary" });
return { fromEntityType: "goal" as const, fromEntityId: goalId, toEntityType: "invariant" as const, toEntityId: invariantId, relationType: "must-respect", description: "Original guidance", strength: "strong" as const };
}

it("creates a new identity and scoped guidance while preserving history through replay", async () => {
const request = await endpoints();
const original = await container.addRelationController.handle(request);
const obsolete = await container.addRelationController.handle({ ...request, relationType: "constrained-by", description: "Superseded broad constraint" });
await container.removeRelationController.handle(obsolete);
await container.removeRelationController.handle(original);
const originalHistory = await container.relationRemovedEventStore.readStream(original.relationId);
const originalView = await container.relationRemovedProjector.findById(original.relationId);
const revised = { ...request, description: "Respect this boundary only for relation add", strength: undefined };
const current = await container.addRelationController.handle(revised);
expect(current.relationId).not.toBe(original.relationId);
expect(current).toEqual({ relationId: expect.any(String) });
expect(await container.relationAddedProjector.findByEntities(request.fromEntityType, request.fromEntityId, request.toEntityType, request.toEntityId, request.relationType)).toMatchObject({ relationId: current.relationId, description: revised.description, status: "active", version: 1, strength: null });
expect(await container.addRelationController.handle(revised)).toEqual(current);
expect(await container.relationRemovedProjector.findById(original.relationId)).toEqual(originalView);
expect(await container.relationRemovedEventStore.readStream(original.relationId)).toEqual(originalHistory);
const currentHistory = await container.relationRemovedEventStore.readStream(current.relationId);
expect(originalHistory.map(event => event.type)).toEqual([RelationEventType.ADDED, RelationEventType.REMOVED]);
expect(currentHistory.map(event => event.type)).toEqual([RelationEventType.ADDED]);
const context = await container.goalContextAssembler.assembleContextForGoal(request.fromEntityId);
expect(context?.context.invariants).toEqual([
expect.objectContaining({ relationType: "must-respect", relationDescription: revised.description, entity: expect.objectContaining({ invariantId: request.toEntityId }) }),
]);
const rebuilt = new Database(":memory:");
try {
for (const migration of ["001-create-relation-views.sql", "002-unique-non-removed-relations.sql"]) {
rebuilt.exec(await fs.readFile(join("src/infrastructure/context/relations/migrations", migration), "utf8"));
}
const bus = new ProjectionBusFactory().create(rebuilt);
const obsoleteHistory = await container.relationRemovedEventStore.readStream(obsolete.relationId);
for (const event of [...originalHistory, ...obsoleteHistory, ...currentHistory]) await bus.publish(event);
const order = <T extends { relationId: string }>(rows: T[]) => rows.sort((left, right) => left.relationId.localeCompare(right.relationId));
expect(order(await new SqliteRelationViewReader(rebuilt).findAll({ status: "all" }))).toEqual(order(await container.relationViewReader.findAll({ status: "all" })));
expect(await container.relationRemovedEventStore.readStream(original.relationId)).toEqual(originalHistory);
expect(await container.relationRemovedEventStore.readStream(current.relationId)).toEqual(currentHistory);
} finally { rebuilt.close(); }
});

it("preserves duplicate behavior and creates new identities on repeated removal", async () => {
const request = await endpoints();
const ids = new Set<string>();
for (let index = 0; index < 3; index++) {
const added = await container.addRelationController.handle(request);
expect(ids.has(added.relationId)).toBe(false);
ids.add(added.relationId);
expect(await container.addRelationController.handle({ ...request, description: "Existing duplicate behavior" })).toEqual(added);
await container.removeRelationController.handle(added);
}
expect(await container.relationViewReader.findAll({ status: "all" })).toHaveLength(3);
expect(await container.relationViewReader.findAll({ status: "active" })).toHaveLength(0);
});

it.each(["text", "json"] as const)("compiled CLI creates a new connection with unchanged %s output", async format => {
const request = await endpoints();
const original = await container.addRelationController.handle(request);
await container.removeRelationController.handle(original);
const result = spawnSync(process.execPath, [resolve("dist/cli.js"), "relation", "add", "--from-type", "goal", "--from-id", request.fromEntityId, "--to-type", "invariant", "--to-id", request.toEntityId, "--type", request.relationType, "--description", "New guidance", "--format", format], {
cwd: directory, encoding: "utf8", env: { ...process.env, JUMBO_TELEMETRY_DISABLED: "1" },
});
expect(result.error).toBeUndefined();
expect(result.status).toBe(0);
const current = await container.relationAddedProjector.findByEntities("goal", request.fromEntityId, "invariant", request.toEntityId, request.relationType);
expect(current).toMatchObject({ status: "active", description: "New guidance", strength: null, version: 1 });
expect(current!.relationId).not.toBe(original.relationId);
if (format === "json") {
expect(JSON.parse(result.stdout)).toEqual({ relationId: current!.relationId, from: `goal:${request.fromEntityId}`, to: `invariant:${request.toEntityId}`, relationType: request.relationType });
} else {
expect(result.stdout).toContain("Relation added successfully");
expect(result.stdout).toContain(current!.relationId);
}
});
});
Loading