mirror of
https://github.com/lancedb/lancedb.git
synced 2026-08-18 12:08:35 +00:00
feat: add remote branch diff and merge client APIs (#3686)
This PR adds some support for `diff` / `merge` in the remote client as for local tables we stay `NotSupported` until https://github.com/lance-format/lance/issues/7263. This wires the two review-and-land calls against the remote REST API: - `POST /v1/table/{id}/branches/diff` - `POST /v1/table/{id}/branches/merge` Rust gets typed results (`BranchDiff`, `MergeBranchResult`). Python returns the wire JSON, same shape as the REST response. Merge here means promoting a branch's added columns onto `main`. ### Behavior - Remote only. Local raises `NotSupported`. - A rejected merge is not an exception. HTTP 409 still returns `Ok` / a dict with `status="rejected"` and blockers in `diff.mergeBlockers`. - Unknown blocker / status codes parse as `Unknown` so a newer server does not break older clients. - `MergePreview` tolerates missing fields for the same reason. - Merge requests are not retried. 409 is final and carries the body you need. ### Example ```python table = db.open_table("images") table.branches.create("exp") exp = table.branches.checkout("exp") exp.add_columns({"tag": "cast('draft' as string)"}) diff = table.branches.diff("exp") preview = table.branches.merge("exp", dry_run=True) result = table.branches.merge("exp", dry_run=False) if result["status"] == "merged": print("landed at", result["mainVersionAfter"]) elif result["status"] == "rejected": print(result["diff"]["mergeBlockers"]) ``` ### Testing cargo test -p lancedb --features remote diff_branch cargo test -p lancedb --features remote merge_branch --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -225,6 +225,113 @@ describe("remote connection", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("diffs and merges remote branches", async () => {
|
||||
const sampleDiff = {
|
||||
fromBranch: "exp",
|
||||
parentVersion: 1,
|
||||
mainVersion: 2,
|
||||
branchVersion: 3,
|
||||
baseMoved: false,
|
||||
rowCountMain: 3,
|
||||
rowCountBranch: 3,
|
||||
rowSummary: {
|
||||
unchanged: 3,
|
||||
newOnBase: 0,
|
||||
newOnBranch: 0,
|
||||
staleRecompute: 0,
|
||||
inputsChanged: 0,
|
||||
deltaAvailable: false,
|
||||
},
|
||||
addedColumns: [{ name: "tag", dataType: "utf8", nullable: true }],
|
||||
removedColumns: [],
|
||||
changedColumns: [],
|
||||
addedIndexes: [],
|
||||
removedIndexes: [],
|
||||
mergeable: true,
|
||||
mergeBlockers: [],
|
||||
};
|
||||
const mergeBodies: Record<string, unknown>[] = [];
|
||||
|
||||
await withMockDatabase(
|
||||
(req, res) => {
|
||||
const path = req.url ?? "";
|
||||
if (path.endsWith("/describe/")) {
|
||||
res.writeHead(200, { "Content-Type": "application/json" }).end(
|
||||
JSON.stringify({
|
||||
name: "t",
|
||||
version: 2,
|
||||
schema: { fields: [] },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let raw = "";
|
||||
req.on("data", (chunk) => {
|
||||
raw += chunk;
|
||||
});
|
||||
req.on("end", () => {
|
||||
const body = raw ? JSON.parse(raw) : {};
|
||||
if (path.endsWith("/branches/diff/")) {
|
||||
// biome-ignore lint/style/useNamingConvention: snake_case mandated by the server wire format
|
||||
expect(body).toEqual({ from_branch: "exp" });
|
||||
res
|
||||
.writeHead(200, { "Content-Type": "application/json" })
|
||||
.end(JSON.stringify(sampleDiff));
|
||||
return;
|
||||
}
|
||||
if (path.endsWith("/branches/merge/")) {
|
||||
mergeBodies.push(body);
|
||||
const dryRun = body["dry_run"] === true;
|
||||
const response = {
|
||||
status: dryRun ? "ready" : "rejected",
|
||||
diff: dryRun
|
||||
? sampleDiff
|
||||
: {
|
||||
...sampleDiff,
|
||||
mergeable: false,
|
||||
mergeBlockers: [
|
||||
{ code: "baseMoved", message: "main has advanced" },
|
||||
],
|
||||
},
|
||||
preview: { promotedColumns: dryRun ? ["tag"] : [] },
|
||||
};
|
||||
res
|
||||
.writeHead(dryRun ? 200 : 409, {
|
||||
"Content-Type": "application/json",
|
||||
})
|
||||
.end(JSON.stringify(response));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404).end();
|
||||
});
|
||||
},
|
||||
async (db) => {
|
||||
const table = await db.openTable("t");
|
||||
const branches = await table.branches();
|
||||
|
||||
await expect(branches.diff("exp")).resolves.toEqual(sampleDiff);
|
||||
|
||||
const rejected = await branches.merge("exp");
|
||||
expect(rejected.status).toBe("rejected");
|
||||
expect(rejected.diff.mergeBlockers).toEqual([
|
||||
{ code: "baseMoved", message: "main has advanced" },
|
||||
]);
|
||||
|
||||
const preview = await branches.merge("exp", true);
|
||||
expect(preview.status).toBe("ready");
|
||||
expect(preview.preview.promotedColumns).toEqual(["tag"]);
|
||||
},
|
||||
);
|
||||
|
||||
expect(mergeBodies).toEqual([
|
||||
// biome-ignore lint/style/useNamingConvention: snake_case mandated by the server wire format
|
||||
{ from_branch: "exp", dry_run: false },
|
||||
// biome-ignore lint/style/useNamingConvention: snake_case mandated by the server wire format
|
||||
{ from_branch: "exp", dry_run: true },
|
||||
]);
|
||||
});
|
||||
|
||||
describe("TlsConfig", () => {
|
||||
it("should create TlsConfig with all fields", () => {
|
||||
const tlsConfig: TlsConfig = {
|
||||
|
||||
@@ -124,6 +124,14 @@ export {
|
||||
export {
|
||||
Table,
|
||||
Branches,
|
||||
BranchColumnSummary,
|
||||
BranchColumnChange,
|
||||
BranchIndexSummary,
|
||||
BranchRowCountSummary,
|
||||
MergeBlocker,
|
||||
BranchDiff,
|
||||
MergePreview,
|
||||
MergeBranchResult,
|
||||
AddDataOptions,
|
||||
UpdateOptions,
|
||||
OptimizeOptions,
|
||||
|
||||
@@ -1329,6 +1329,76 @@ export interface FieldMetadataUpdate {
|
||||
replace?: boolean;
|
||||
}
|
||||
|
||||
/** Summary of a column in a branch diff. */
|
||||
export interface BranchColumnSummary {
|
||||
name: string;
|
||||
dataType: string;
|
||||
nullable: boolean;
|
||||
}
|
||||
|
||||
/** A column whose definition differs between main and the branch. */
|
||||
export interface BranchColumnChange {
|
||||
name: string;
|
||||
main: BranchColumnSummary;
|
||||
branch: BranchColumnSummary;
|
||||
}
|
||||
|
||||
/** Summary of an index in a branch diff. */
|
||||
export interface BranchIndexSummary {
|
||||
indexName: string;
|
||||
columns: string[];
|
||||
indexType?: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
/** Row-level comparison between main and the branch. */
|
||||
export interface BranchRowCountSummary {
|
||||
unchanged: number;
|
||||
newOnBase: number;
|
||||
newOnBranch: number;
|
||||
staleRecompute: number;
|
||||
inputsChanged: number;
|
||||
deltaAvailable: boolean;
|
||||
}
|
||||
|
||||
/** A reason why a branch cannot currently be merged. */
|
||||
export interface MergeBlocker {
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** Read-only comparison of a branch against main. */
|
||||
export interface BranchDiff {
|
||||
fromBranch: string;
|
||||
parentVersion: number;
|
||||
mainVersion: number;
|
||||
branchVersion: number;
|
||||
baseMoved: boolean;
|
||||
rowCountMain: number;
|
||||
rowCountBranch: number;
|
||||
rowSummary: BranchRowCountSummary;
|
||||
addedColumns: BranchColumnSummary[];
|
||||
removedColumns: BranchColumnSummary[];
|
||||
changedColumns: BranchColumnChange[];
|
||||
addedIndexes: BranchIndexSummary[];
|
||||
removedIndexes: BranchIndexSummary[];
|
||||
mergeable: boolean;
|
||||
mergeBlockers: MergeBlocker[];
|
||||
}
|
||||
|
||||
/** Changes that would be, or were, promoted by a branch merge. */
|
||||
export interface MergePreview {
|
||||
promotedColumns: string[];
|
||||
}
|
||||
|
||||
/** Result of previewing or attempting a branch merge. */
|
||||
export interface MergeBranchResult {
|
||||
status: "ready" | "rejected" | "notImplemented" | "merged" | "unknown";
|
||||
diff: BranchDiff;
|
||||
preview: MergePreview;
|
||||
mainVersionAfter?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Branch manager for a {@link Table}.
|
||||
*
|
||||
@@ -1381,4 +1451,28 @@ export class Branches {
|
||||
async delete(name: string): Promise<void> {
|
||||
return await this.#inner.delete(name);
|
||||
}
|
||||
|
||||
/** Compare a branch against main without modifying either branch. */
|
||||
async diff(fromBranch: string): Promise<BranchDiff> {
|
||||
return (await this.#inner.diff(fromBranch)) as unknown as BranchDiff;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a branch into main.
|
||||
*
|
||||
* Set `dryRun` to `true` to preview the merge. A rejected merge resolves
|
||||
* with `status: "rejected"` instead of throwing.
|
||||
*
|
||||
* @param fromBranch Branch to merge from.
|
||||
* @param dryRun When true, only preview the merge. Defaults to false.
|
||||
*/
|
||||
async merge(
|
||||
fromBranch: string,
|
||||
dryRun: boolean = false,
|
||||
): Promise<MergeBranchResult> {
|
||||
return (await this.#inner.merge(
|
||||
fromBranch,
|
||||
dryRun,
|
||||
)) as unknown as MergeBranchResult;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1355,4 +1355,28 @@ impl Branches {
|
||||
pub async fn delete(&self, name: String) -> napi::Result<()> {
|
||||
self.inner.delete_branch(&name).await.default_error()
|
||||
}
|
||||
|
||||
#[napi(ts_return_type = "Promise<Record<string, unknown>>")]
|
||||
pub async fn diff(&self, from_branch: String) -> napi::Result<serde_json::Value> {
|
||||
let diff = self.inner.diff_branch(&from_branch).await.default_error()?;
|
||||
serde_json::to_value(diff).map_err(|err| {
|
||||
napi::Error::from_reason(format!("failed to serialize branch diff: {err}"))
|
||||
})
|
||||
}
|
||||
|
||||
#[napi(ts_return_type = "Promise<Record<string, unknown>>")]
|
||||
pub async fn merge(
|
||||
&self,
|
||||
from_branch: String,
|
||||
dry_run: Option<bool>,
|
||||
) -> napi::Result<serde_json::Value> {
|
||||
let result = self
|
||||
.inner
|
||||
.merge_branch(&from_branch, dry_run.unwrap_or(false))
|
||||
.await
|
||||
.default_error()?;
|
||||
serde_json::to_value(result).map_err(|err| {
|
||||
napi::Error::from_reason(format!("failed to serialize branch merge result: {err}"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user