Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Branching #3201

Merged
merged 17 commits into from
Nov 4, 2024
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
63 changes: 63 additions & 0 deletions packages/lix-sdk/src/branch/create-branch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { test, expect } from "vitest";
import { updateBranchPointers } from "../branch/update-branch-pointers.js";
import { openLixInMemory } from "../open/openLixInMemory.js";
import { createBranch } from "./create-branch.js";

test("it should copy the change pointers from the parent branch", async () => {
const lix = await openLixInMemory({});
samuelstroschein marked this conversation as resolved.
Show resolved Hide resolved

const mainBranch = await lix.db
.selectFrom("branch")
.selectAll()
.where("name", "=", "main")
.executeTakeFirstOrThrow();

await lix.db.transaction().execute(async (trx) => {
const changes = await trx
.insertInto("change")
.values([
{
type: "file",
entity_id: "value1",
file_id: "mock",
plugin_key: "mock-plugin",
snapshot_id: "sn1",
},
{
type: "file",
entity_id: "value2",
file_id: "mock",
plugin_key: "mock-plugin",
snapshot_id: "sn2",
},
])
.returningAll()
.execute();

await updateBranchPointers({
lix: { db: trx },
branch: mainBranch,
changes,
});
});

const branch = await createBranch({
lix,
from: mainBranch,
name: "feature-branch",
});

const branchChangePointers = await lix.db
.selectFrom("branch_change_pointer")
.selectAll()
.execute();

// main and feature branch should have the same change pointers
expect(branchChangePointers.length).toBe(4);
expect(branchChangePointers.map((pointer) => pointer.branch_id)).toEqual([
mainBranch.id,
mainBranch.id,
branch.id,
branch.id,
]);
});
64 changes: 64 additions & 0 deletions packages/lix-sdk/src/branch/create-branch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import type { Branch } from "../database/schema.js";
import type { Lix } from "../open/openLix.js";

/**
* Creates a new branch.
*
* @example
* ```ts
* const branch = await createBranch({ lix, from: otherBranch });
* ```
*/
export async function createBranch(args: {
lix: Pick<Lix, "db">;
from: Pick<Branch, "id">;
name?: Branch["name"];
}): Promise<Branch> {
const executeInTransaction = async (trx: Lix["db"]) => {
const branch = await trx
.insertInto("branch")
.defaultValues()
.returningAll()
.executeTakeFirstOrThrow();

if (args.name) {
await trx
.updateTable("branch")
.set({ name: args.name })
.where("id", "=", branch.id)
.execute();
}

// copy the change pointers from the parent branch
await trx
.insertInto("branch_change_pointer")
.columns([
"branch_id",
"change_id",
"change_file_id",
"change_entity_id",
"change_type",
])
.expression((eb) =>
trx
.selectFrom("branch_change_pointer")
.select([
eb.val(branch.id).as("branch_id"),
"change_id",
"change_file_id",
"change_entity_id",
"change_type",
])
.where("branch_id", "=", args.from.id),
)
.execute();

return branch;
};

if (args.lix.db.isTransaction) {
return executeInTransaction(args.lix.db);
} else {
return args.lix.db.transaction().execute(executeInTransaction);
}
}
7 changes: 7 additions & 0 deletions packages/lix-sdk/src/branch/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* Public API for the branches.
*/

export { createBranch } from "./create-branch.js";
export { switchBranch } from "./switch-branch.js";
export { updateBranchPointers } from "./update-branch-pointers.js";
254 changes: 254 additions & 0 deletions packages/lix-sdk/src/branch/merge-branch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
import { test, expect, vi } from "vitest";
import { openLixInMemory } from "../open/openLixInMemory.js";
import type { LixPlugin } from "../plugin/lix-plugin.js";
import { mergeBranch } from "./merge-branch.js";

test("it should update the branch pointers for non-conflicting changes and insert detected conflicts", async () => {
const lix = await openLixInMemory({});

// Initialize source and target branches
const sourceBranch = await lix.db
.insertInto("branch")
.values({ name: "source-branch" })
.returningAll()
.executeTakeFirstOrThrow();

const targetBranch = await lix.db
.insertInto("branch")
.values({ name: "target-branch" })
.returningAll()
.executeTakeFirstOrThrow();

// Insert changes into `change` table and `branch_change_pointer` for source branch
const [change1, change2, change3] = await lix.db
.insertInto("change")
.values([
{
id: "change1",
type: "file",
entity_id: "entity1",
file_id: "file1",
plugin_key: "mock-plugin",
snapshot_id: "no-content",
},
{
id: "change2",
type: "file",
entity_id: "entity2",
file_id: "file2",
plugin_key: "mock-plugin",
snapshot_id: "no-content",
},
{
id: "change3",
type: "file",
entity_id: "entity3",
file_id: "file3",
plugin_key: "mock-plugin",
snapshot_id: "no-content",
},
])
.returningAll()
.execute();

await lix.db
.insertInto("branch_change_pointer")
.values([
{
branch_id: sourceBranch.id,
change_id: change1!.id,
change_entity_id: change1!.entity_id,
change_file_id: change1!.file_id,
change_type: change1!.type,
},
{
branch_id: sourceBranch.id,
change_id: change2!.id,
change_entity_id: change2!.entity_id,
change_file_id: change2!.file_id,
change_type: change2!.type,
},
{
branch_id: sourceBranch.id,
change_id: change3!.id,
change_entity_id: change3!.entity_id,
change_file_id: change3!.file_id,
change_type: change3!.type,
},
])
.execute();

const mockPlugin: LixPlugin = {
key: "mock",
detectConflictsV2: async () => {
// simulating a conflict between change2 and change3
return [
{
change_id: change2!.id,
conflicting_change_id: change3!.id,
},
];
},
};

lix.plugin.getAll = vi.fn().mockResolvedValue([mockPlugin]);

// Execute the mergeBranch function
await mergeBranch({ lix, sourceBranch, targetBranch });

// Validate results in `branch_change_pointer` and `conflict` tables
const targetPointers = await lix.db
.selectFrom("branch_change_pointer")
.selectAll()
.where("branch_id", "=", targetBranch.id)
.execute();

const conflicts = await lix.db.selectFrom("conflict").selectAll().execute();

// Ensure that non-conflicting pointers (change1 and change2) are in target branch
expect(targetPointers.map((pointer) => pointer.change_id)).toContain(
change1?.id,
);
expect(targetPointers.map((pointer) => pointer.change_id)).toContain(
change2?.id,
);

// Ensure that conflicting pointer (change3) is not in target branch
expect(targetPointers.map((pointer) => pointer.change_id)).not.toContain(
change3?.id,
);

// Verify that a conflict for change2 was added to the `conflict` table
expect(conflicts.map((conflict) => conflict.change_id)).toContain(
change2?.id,
);
});

// it is reasonable to assume that a conflict exists if the same (entity, file, type) change is updated in both branches.
// in case a plugin does not detect a conflict, the system should automatically detect it.
test("it should automatically detect a conflict if a change exists that differs updates in both branches despite having a common ancestor", async () => {
Copy link
Member Author

@samuelstroschein samuelstroschein Nov 4, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a beautiful change.

plugins do not need to detect diverging entity graphs as conflict anymore. lix handles that as it's universal conflict. an analogy with git: git defines entities in text files as rows. if the same row (entity) diverges in two repos, git raises a conflict. same now applies to lix. if two entities diverge, it's a conflict. but in contrast to git, entities can be defined more fine grained e.g. "character", automatically be resolved, and semantic conflicts between entities can be raised. if row 1 and row 2 are diverging, it's not an 'entity conflict' but maybe a semantic one that a plugin can detect!

const lix = await openLixInMemory({});

// Initialize source and target branches
const sourceBranch = await lix.db
.insertInto("branch")
.values({ name: "source-branch" })
.returningAll()
.executeTakeFirstOrThrow();

const targetBranch = await lix.db
.insertInto("branch")
.values({ name: "target-branch" })
.returningAll()
.executeTakeFirstOrThrow();

const ancestorChange = await lix.db
.insertInto("change")
.values({
id: "ancestor-change",
type: "type1",
entity_id: "entity1",
file_id: "file1",
plugin_key: "mock",
snapshot_id: "no-content",
})
.returningAll()
.executeTakeFirstOrThrow();

// Simulate updates to the entity in both branches
const sourceChange = await lix.db
.insertInto("change")
.values({
id: "source-change",
type: "type1",
entity_id: "entity1",
file_id: "file1",
plugin_key: "mock",
snapshot_id: "no-content",
})
.returningAll()
.executeTakeFirstOrThrow();

const targetChange = await lix.db
.insertInto("change")
.values({
id: "target-change",
type: "type1",
entity_id: "entity1",
file_id: "file1",
plugin_key: "mock",
snapshot_id: "no-content",
})
.returningAll()
.executeTakeFirstOrThrow();

// insert edges to ancestor change
await lix.db
.insertInto("change_graph_edge")
.values([
{ parent_id: ancestorChange.id, child_id: sourceChange.id },
{ parent_id: ancestorChange.id, child_id: targetChange.id },
])
.execute();

// Insert head pointers for source and target branches
await lix.db
.insertInto("branch_change_pointer")
.values([
{
branch_id: sourceBranch.id,
change_id: sourceChange.id,
change_entity_id: "entity1",
change_file_id: "file1",
change_type: "type1",
},
{
branch_id: targetBranch.id,
change_id: targetChange.id,
change_entity_id: "entity1",
change_file_id: "file1",
change_type: "type1",
},
])
.execute();

const mockPlugin: LixPlugin = {
key: "mock-plugin",
detectConflictsV2: async () => {
// Simulate no manual conflicts; system should detect automatically
return [];
},
};
lix.plugin.getAll = vi.fn().mockResolvedValue([mockPlugin]);

await mergeBranch({ lix, sourceBranch, targetBranch });

// Validate results in `conflict` table
const conflicts = await lix.db
.selectFrom("conflict")
.selectAll()
.where("change_id", "=", sourceChange.id)
.execute();

// Ensure that the change from `sourceBranch` is detected as a conflict
expect(conflicts).toEqual([
expect.objectContaining({
change_id: sourceChange.id,
conflicting_change_id: targetChange.id,
}),
]);

// ensure that the branch change pointer hasn't been updated
const targetPointers = await lix.db
.selectFrom("branch_change_pointer")
.selectAll()
.where("branch_id", "=", targetBranch.id)
.execute();

expect(targetPointers.map((pointer) => pointer.change_id)).not.toContain(
sourceChange.id,
);
expect(targetPointers.map((pointer) => pointer.change_id)).toContain(
targetChange.id,
);
});
Loading
Loading