diff --git a/CHANGELOG.md b/CHANGELOG.md index ec4f60f4..134163cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/reference/commands/relations.md b/docs/reference/commands/relations.md index 56b419c6..85132f34 100644 --- a/docs/reference/commands/relations.md +++ b/docs/reference/commands/relations.md @@ -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 +jumbo relation add --from-type goal --from-id --to-type invariant --to-id --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 --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 diff --git a/src/application/context/relations/add/IRelationAddedReader.ts b/src/application/context/relations/add/IRelationAddedReader.ts index 3f9ea725..8be37249 100644 --- a/src/application/context/relations/add/IRelationAddedReader.ts +++ b/src/application/context/relations/add/IRelationAddedReader.ts @@ -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( diff --git a/src/infrastructure/context/relations/add/SqliteRelationAddedProjector.ts b/src/infrastructure/context/relations/add/SqliteRelationAddedProjector.ts index b3351138..3f1cbc7b 100644 --- a/src/infrastructure/context/relations/add/SqliteRelationAddedProjector.ts +++ b/src/infrastructure/context/relations/add/SqliteRelationAddedProjector.ts @@ -17,10 +17,11 @@ export class SqliteRelationAddedProjector implements IRelationAddedProjector, IR async applyRelationAdded(event: RelationAddedEvent): Promise { 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( @@ -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 | undefined; return row ? this.mapRowToView(row) : null; diff --git a/src/infrastructure/context/relations/migrations/002-unique-non-removed-relations.sql b/src/infrastructure/context/relations/migrations/002-unique-non-removed-relations.sql new file mode 100644 index 00000000..6df54195 --- /dev/null +++ b/src/infrastructure/context/relations/migrations/002-unique-non-removed-relations.sql @@ -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'; diff --git a/tests/infrastructure/context/relations/add/SqliteRelationAddedProjector.test.ts b/tests/infrastructure/context/relations/add/SqliteRelationAddedProjector.test.ts new file mode 100644 index 00000000..fe8e3e2f --- /dev/null +++ b/tests/infrastructure/context/relations/add/SqliteRelationAddedProjector.test.ts @@ -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); + }); +}); diff --git a/tests/integration/relation-add-after-removal.integration.test.ts b/tests/integration/relation-add-after-removal.integration.test.ts new file mode 100644 index 00000000..75452920 --- /dev/null +++ b/tests/integration/relation-add-after-removal.integration.test.ts @@ -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 = (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(); + 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); + } + }); +});