diff --git a/nodejs/__test__/table.test.ts b/nodejs/__test__/table.test.ts index 12fee4733..2361d68c4 100644 --- a/nodejs/__test__/table.test.ts +++ b/nodejs/__test__/table.test.ts @@ -85,6 +85,39 @@ describe.each([arrow15, arrow16, arrow17, arrow18])( await expect(table.countRows()).resolves.toBe(3); }); + it("should support branches", async () => { + await table.add([{ id: 1 }]); + expect(await table.countRows()).toBe(1); + + // fork an isolated, writable branch from main + const branch = await (await table.branches()).create("exp"); + expect(await branch.countRows()).toBe(1); + await branch.add([{ id: 2 }]); + expect(await branch.countRows()).toBe(2); + // main is untouched by branch writes + expect(await table.countRows()).toBe(1); + + // listed, with main (null) as the parent + const list = await (await table.branches()).list(); + expect(Object.keys(list)).toContain("exp"); + expect(list["exp"].parentBranch).toBeNull(); + + // fromRef="main" is equivalent to the default + await (await table.branches()).create("exp2", "main"); + const list2 = await (await table.branches()).list(); + expect(list2["exp2"].parentBranch).toBeNull(); + + // checkout returns a handle scoped to the branch's latest + const checkedOut = await (await table.branches()).checkout("exp"); + expect(await checkedOut.countRows()).toBe(2); + + // delete removes it + await (await table.branches()).delete("exp"); + await (await table.branches()).delete("exp2"); + const after = await (await table.branches()).list(); + expect(Object.keys(after)).not.toContain("exp"); + }); + it("should show table stats", async () => { await table.add([{ id: 1 }, { id: 2 }]); await table.add([{ id: 1 }]); diff --git a/nodejs/lancedb/table.ts b/nodejs/lancedb/table.ts index e3821bd81..10b486bf9 100644 --- a/nodejs/lancedb/table.ts +++ b/nodejs/lancedb/table.ts @@ -25,10 +25,12 @@ import { AddColumnsSql, AddResult, AlterColumnsResult, + BranchContents, DeleteResult, DropColumnsResult, IndexConfig, IndexStatistics, + Branches as NativeBranches, OptimizeStats, TableStatistics, Tags, @@ -653,6 +655,14 @@ export abstract class Table { */ abstract tags(): Promise; + /** + * Get the branch manager for this table. + * + * Branches are isolated, writable lines of history forked from another + * branch (or version). Writes on a branch do not affect `main`. + */ + abstract branches(): Promise; + /** * Restore the table to the currently checked out version * @@ -1108,6 +1118,10 @@ export class LocalTable extends Table { return await this.inner.tags(); } + async branches(): Promise { + return new Branches(await this.inner.branches()); + } + async optimize(options?: Partial): Promise { let cleanupOlderThanMs; if ( @@ -1238,3 +1252,47 @@ export interface FieldMetadataUpdate { /** If true, replace the field's entire metadata map instead of merging. */ replace?: boolean; } + +/** + * Branch manager for a {@link Table}. + * + * Unlike tags, `create` and `checkout` return a new {@link Table} handle scoped + * to the branch; writes on it do not affect `main`. + */ +export class Branches { + private readonly inner: NativeBranches; + + constructor(inner: NativeBranches) { + this.inner = inner; + } + + /** List all branches, mapping name to branch metadata. */ + async list(): Promise> { + return await this.inner.list(); + } + + /** + * Create a branch and return a handle scoped to it. + * + * @param name Name of the new branch. + * @param fromRef Source branch to fork from. Defaults to `main`. + * @param fromVersion A specific version on `fromRef`. Defaults to latest. + */ + async create( + name: string, + fromRef?: string, + fromVersion?: number, + ): Promise { + return new LocalTable(await this.inner.create(name, fromRef, fromVersion)); + } + + /** Check out an existing branch and return a handle scoped to it. */ + async checkout(name: string): Promise
{ + return new LocalTable(await this.inner.checkout(name)); + } + + /** Delete a branch. */ + async delete(name: string): Promise { + return await this.inner.delete(name); + } +} diff --git a/nodejs/src/table.rs b/nodejs/src/table.rs index 2add2f3f3..dc8f73e22 100644 --- a/nodejs/src/table.rs +++ b/nodejs/src/table.rs @@ -7,7 +7,7 @@ use lancedb::ipc::{ipc_file_to_batches, ipc_file_to_schema}; use lancedb::table::{ AddDataMode, ColumnAlteration as LanceColumnAlteration, Duration, FieldMetadataUpdate as LanceFieldMetadataUpdate, NewColumnTransform, OptimizeAction, - OptimizeOptions, Table as LanceDbTable, + OptimizeOptions, Ref, Table as LanceDbTable, }; use napi::bindgen_prelude::*; use napi::threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode}; @@ -478,6 +478,13 @@ impl Table { }) } + #[napi(catch_unwind)] + pub async fn branches(&self) -> napi::Result { + Ok(Branches { + inner: self.inner_ref()?.clone(), + }) + } + #[napi(catch_unwind)] pub async fn optimize( &self, @@ -1060,6 +1067,13 @@ pub struct TagContents { pub manifest_size: i64, } +#[napi] +pub struct BranchContents { + pub parent_branch: Option, + pub parent_version: i64, + pub manifest_size: i64, +} + #[napi] pub struct Tags { inner: LanceDbTable, @@ -1128,3 +1142,60 @@ impl Tags { .default_error() } } + +#[napi] +pub struct Branches { + inner: LanceDbTable, +} + +#[napi] +impl Branches { + #[napi] + pub async fn list(&self) -> napi::Result> { + let branches = self.inner.list_branches().await.default_error()?; + let result = branches + .into_iter() + .map(|(k, v)| { + ( + k, + BranchContents { + parent_branch: v.parent_branch, + parent_version: v.parent_version as i64, + manifest_size: v.manifest_size as i64, + }, + ) + }) + .collect(); + Ok(result) + } + + #[napi] + pub async fn create( + &self, + name: String, + from_ref: Option, + from_version: Option, + ) -> napi::Result
{ + // "main" and None are two spellings of the root branch; normalize so + // from_ref = "main" behaves identically to the default. + let from_ref = from_ref.filter(|b| b != "main"); + let from = Ref::Version(from_ref, from_version.map(|v| v as u64)); + let table = self + .inner + .create_branch(&name, from) + .await + .default_error()?; + Ok(Table::new(table)) + } + + #[napi] + pub async fn checkout(&self, name: String) -> napi::Result
{ + let table = self.inner.checkout_branch(&name).await.default_error()?; + Ok(Table::new(table)) + } + + #[napi] + pub async fn delete(&self, name: String) -> napi::Result<()> { + self.inner.delete_branch(&name).await.default_error() + } +}