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

multi parent relationship for changes #3180

Merged
Show file tree
Hide file tree
Changes from 8 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
50 changes: 23 additions & 27 deletions lix/packages/sdk/src/change-queue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ test("should use queue and settled correctly", async () => {
const enc = new TextEncoder();
await lix.db
.insertInto("file")
.values({ id: "test", path: "test.txt", data: enc.encode("test") })
.values({ id: "test", path: "test.txt", data: enc.encode("insert text") })
.execute();

const internalFiles = await lix.db
Expand Down Expand Up @@ -85,19 +85,15 @@ test("should use queue and settled correctly", async () => {
.execute();

expect(changes).toEqual([
{
id: changes[0]?.id,
created_at: changes[0]?.created_at,
snapshot_id: changes[0]?.snapshot_id,
parent_id: null,
expect.objectContaining({
entity_id: "test",
type: "text",
file_id: "test",
plugin_key: "mock-plugin",
content: {
text: "test",
text: "insert text",
},
},
}),
]);

await lix.db
Expand Down Expand Up @@ -150,46 +146,46 @@ test("should use queue and settled correctly", async () => {
.select("snapshot.content")
.execute();

const updatedEdges = await lix.db
.selectFrom("change_edge")
.selectAll()
.execute();

expect(updatedChanges).toEqual([
{
id: updatedChanges[0]?.id,
created_at: updatedChanges[0]?.created_at,
snapshot_id: updatedChanges[0]?.snapshot_id,
parent_id: null,
expect.objectContaining({
entity_id: "test",
type: "text",
file_id: "test",
plugin_key: "mock-plugin",
content: {
text: "test",
text: "insert text",
},
},
{
}),
expect.objectContaining({
Copy link
Member Author

Choose a reason for hiding this comment

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

I updated the tests to expect.objectContaining to reduce the refactor effort in the future.

entity_id: "test",
created_at: updatedChanges[1]?.created_at,
snapshot_id: updatedChanges[1]?.snapshot_id,
file_id: "test",
id: updatedChanges[1]?.id,
parent_id: updatedChanges[0]?.id,
plugin_key: "mock-plugin",
type: "text",
content: {
text: "test updated text",
},
},
{
created_at: updatedChanges[2]?.created_at,
snapshot_id: updatedChanges[2]?.snapshot_id,
}),
expect.objectContaining({
file_id: "test",
id: updatedChanges[2]?.id,
parent_id: updatedChanges[1]?.id,
entity_id: "test",
plugin_key: "mock-plugin",
type: "text",
content: {
text: "test updated text second update",
},
},
}),
]);

expect(updatedEdges).toEqual([
// 0 is the parent of 1
// 1 is the parent of 2
{ parent_id: updatedChanges[0]?.id, child_id: updatedChanges[1]?.id },
{ parent_id: updatedChanges[1]?.id, child_id: updatedChanges[2]?.id },
]);
});

Expand Down
11 changes: 9 additions & 2 deletions lix/packages/sdk/src/database/applySchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ export async function applySchema(args: { sqlite: SqliteDatabase }) {

CREATE TABLE IF NOT EXISTS change (
id TEXT PRIMARY KEY DEFAULT (uuid_v4()),
parent_id TEXT,
entity_id TEXT NOT NULL,
type TEXT NOT NULL,
file_id TEXT NOT NULL,
Expand All @@ -37,7 +36,15 @@ export async function applySchema(args: { sqlite: SqliteDatabase }) {
created_at TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL
) strict;

CREATE INDEX IF NOT EXISTS idx_change_parent_id ON change (parent_id);
CREATE TABLE IF NOT EXISTS change_edge (
parent_id TEXT NOT NULL,
child_id TEXT NOT NULL,
PRIMARY KEY (parent_id, child_id),
FOREIGN KEY(parent_id) REFERENCES change(id),
FOREIGN KEY(child_id) REFERENCES change(id),
-- Prevent self referencing edges
CHECK (parent_id != child_id)
Copy link
Member Author

Choose a reason for hiding this comment

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

I didn't know CHECK before. Very cool. SQLite now verifies that edges are not self-referencing. A unit test has been written as well.

Copy link
Contributor

Choose a reason for hiding this comment

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

I recall we used this to validate the id of the entity type. So if you try to insert an change id (here postfixed with the c) "415f1f62-2017-4761-9ece-519c1e5e45e3-c" into a snapshot you can fail early

) strict;
samuelstroschein marked this conversation as resolved.
Show resolved Hide resolved

CREATE TABLE IF NOT EXISTS snapshot (
id TEXT GENERATED ALWAYS AS (sha256(content)) STORED UNIQUE,
Expand Down
23 changes: 23 additions & 0 deletions lix/packages/sdk/src/database/initDb.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,26 @@ test("files should be able to have metadata", async () => {

expect(updatedFile.metadata?.primary_key).toBe("something-else");
});

test("change edges can't reference themselves", async () => {
const sqlite = await createInMemoryDatabase({
readOnly: false,
});
const db = initDb({ sqlite });

try {
await db
.insertInto("change_edge")
.values({
parent_id: "change1",
child_id: "change1",
})
.returningAll()
.execute();
} catch (error) {
// the sqite3 error is not exposed
expect((error as any).name).toBe("SQLite3Error");
samuelstroschein marked this conversation as resolved.
Show resolved Hide resolved
// error code 275 = SQLITE_CONSTRAINT_CHECK
expect((error as any).resultCode).toBe(275);
}
});
9 changes: 8 additions & 1 deletion lix/packages/sdk/src/database/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export type LixDatabaseSchema = {
change: ChangeTable;
file_internal: LixFileTable;
change_queue: ChangeQueueTable;
change_edge: ChangeEdgeTable;
conflict: ConflictTable;
snapshot: SnapshotTable;

Expand Down Expand Up @@ -42,7 +43,6 @@ export type Change = Selectable<ChangeTable>;
export type NewChange = Insertable<ChangeTable>;
type ChangeTable = {
id: Generated<string>;
parent_id: Generated<string> | null;
/**
* The entity the change refers to.
*/
Expand Down Expand Up @@ -71,6 +71,13 @@ type ChangeTable = {
created_at: Generated<string>;
};

export type ChangeEdge = Selectable<ChangeEdgeTable>;
export type NewChangeEdge = Insertable<ChangeEdgeTable>;
type ChangeEdgeTable = {
parent_id: ChangeTable["id"];
child_id: ChangeTable["id"];
};

export type Snapshot = Selectable<SnapshotTable>;
export type NewSnapshot = Insertable<SnapshotTable>;
type SnapshotTable = {
Expand Down
27 changes: 20 additions & 7 deletions lix/packages/sdk/src/file-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,9 +120,11 @@ export async function handleFileChange(args: {
// heuristic to find the previous change
// there is no guarantee that the previous change is the leaf change
// because sorting by time is unreliable in a distributed system
const previousChange = await trx
const maybeParentChange = await trx
.selectFrom("change")
.selectAll()
.innerJoin("snapshot", "snapshot.id", "change.snapshot_id")
.selectAll("change")
.select("snapshot.content")
.where("file_id", "=", fileId)
.where("type", "=", detectedChange.type)
.where("entity_id", "=", detectedChange.entity_id)
Expand All @@ -132,11 +134,11 @@ export async function handleFileChange(args: {
.executeTakeFirst();

// get the leaf change of the assumed previous change
const leafChange = !previousChange
const parentChange = !maybeParentChange
? undefined
: await getLeafChange({
lix: { db: trx },
change: previousChange,
change: maybeParentChange,
});

const snapshot = await trx
Expand All @@ -155,17 +157,28 @@ export async function handleFileChange(args: {
.returning("id")
.executeTakeFirstOrThrow();

await trx
const insertedChange = await trx
.insertInto("change")
.values({
type: detectedChange.type,
file_id: fileId,
plugin_key: detectedChange.pluginKey,
entity_id: detectedChange.entity_id,
parent_id: leafChange?.id,
snapshot_id: snapshot.id,
})
.execute();
.returning("id")
.executeTakeFirstOrThrow();

// If a parent exists, the change is a child of the parent
if (parentChange) {
await trx
.insertInto("change_edge")
.values({
parent_id: parentChange.id,
child_id: insertedChange.id,
})
.execute();
}
}

await trx
Expand Down
19 changes: 17 additions & 2 deletions lix/packages/sdk/src/merge/merge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { openLixInMemory } from "../open/openLixInMemory.js";
import { newLixFile } from "../newLix.js";
import { merge } from "./merge.js";
import type {
ChangeEdge,
NewChange,
NewConflict,
NewSnapshot,
Expand Down Expand Up @@ -45,6 +46,8 @@ test("it should copy changes from the sourceLix into the targetLix that do not e
},
];

const mockEdges: ChangeEdge[] = [{ parent_id: "2", child_id: "3" }];

const mockPlugin: LixPlugin = {
key: "mock-plugin",
detectConflicts: vi.fn().mockResolvedValue([]),
Expand Down Expand Up @@ -76,6 +79,8 @@ test("it should copy changes from the sourceLix into the targetLix that do not e
.values([mockChanges[0]!, mockChanges[1]!, mockChanges[2]!])
.execute();

await sourceLix.db.insertInto("change_edge").values([mockEdges[0]]).execute();

await targetLix.db
.insertInto("snapshot")
.values(
Expand Down Expand Up @@ -119,6 +124,13 @@ test("it should copy changes from the sourceLix into the targetLix that do not e
mockSnapshots[2]?.id,
]);

const edges = await targetLix.db
.selectFrom("change_edge")
.selectAll()
.execute();

expect(edges).toEqual(mockEdges);

expect(mockPlugin.applyChanges).toHaveBeenCalledTimes(1);
expect(mockPlugin.detectConflicts).toHaveBeenCalledTimes(1);
});
Expand Down Expand Up @@ -345,7 +357,6 @@ test("it should apply changes that are not conflicting", async () => {
},
{
id: "2",
parent_id: "1",
entity_id: "value1",
type: "mock",
snapshot_id: mockSnapshots[1]!.id,
Expand All @@ -354,6 +365,8 @@ test("it should apply changes that are not conflicting", async () => {
},
];

const edges: ChangeEdge[] = [{ parent_id: "1", child_id: "2" }];

const mockPlugin: LixPlugin = {
key: "mock-plugin",
applyChanges: async ({ changes }) => {
Expand Down Expand Up @@ -389,6 +402,8 @@ test("it should apply changes that are not conflicting", async () => {
.values([mockChanges[0]!, mockChanges[1]!])
.execute();

await sourceLix.db.insertInto("change_edge").values([edges[0]]).execute();

await targetLix.db
.insertInto("snapshot")
.values(
Expand All @@ -397,6 +412,7 @@ test("it should apply changes that are not conflicting", async () => {
}),
)
.execute();

await targetLix.db.insertInto("change").values([mockChanges[0]!]).execute();

await targetLix.db
Expand Down Expand Up @@ -657,7 +673,6 @@ test("it should copy discussion and related comments and mappings", async () =>
id: changes[0]?.id,
created_at: changes[0]?.created_at,
snapshot_id: changes[0]?.snapshot_id,
parent_id: null,
type: "text",
file_id: "test",
entity_id: "test",
Expand Down
23 changes: 19 additions & 4 deletions lix/packages/sdk/src/merge/merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,9 +117,14 @@ export async function merge(args: {
.selectAll()
.execute();

const sourceEdges = await args.sourceLix.db
.selectFrom("change_edge")
.selectAll()
.execute();

await args.targetLix.db.transaction().execute(async (trx) => {
if (sourceChangesWithSnapshot.length > 0) {
// 1. copy the snapshots from source
// copy the snapshots from source
await trx
.insertInto("snapshot")
.values(
Expand All @@ -146,7 +151,7 @@ export async function merge(args: {
.execute();
}

// 2. insert the conflicts of those changes
// insert the conflicts of those changes
if (conflicts.length > 0) {
await trx
.insertInto("conflict")
Expand All @@ -157,15 +162,25 @@ export async function merge(args: {
}

for (const [fileId, fileData] of Object.entries(changesPerFile)) {
// 3. update the file data with the applied changes
// update the file data with the applied changes
await trx
.updateTable("file_internal")
.set("data", fileData)
.where("id", "=", fileId)
.execute();
}

// 4. add discussions, comments and discsussion_change_mappings
// copy edges
if (sourceEdges.length > 0) {
await trx
.insertInto("change_edge")
.values(sourceEdges)
// ignore if already exists
.onConflict((oc) => oc.doNothing())
.execute();
}

// add discussions, comments and discsussion_change_mappings

if (sourceDiscussions.length > 0) {
await trx
Expand Down
Loading
Loading