mirror of
https://github.com/lancedb/lancedb.git
synced 2026-10-13 15:22:30 +00:00
Merge branch 'main' into refactor/wal-naming
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1 +0,0 @@
|
||||
AGENTS.md
|
||||
+4
-3
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "lancedb-nodejs"
|
||||
edition.workspace = true
|
||||
version = "0.38.0-beta.11"
|
||||
version = "0.41.0-beta.6"
|
||||
publish = false
|
||||
license.workspace = true
|
||||
description.workspace = true
|
||||
@@ -37,8 +37,9 @@ lzma-sys = { version = "0.1", features = ["static"] }
|
||||
log.workspace = true
|
||||
|
||||
# Pin to resolve build failures; update periodically for security patches.
|
||||
aws-lc-sys = "=0.40.0"
|
||||
aws-lc-rs = "=1.16.3"
|
||||
# rustls >= 0.23.45 (RUSTSEC-2026-0285) needs aws-lc-rs >= 1.18.
|
||||
aws-lc-sys = "=0.45.0"
|
||||
aws-lc-rs = "=1.18.1"
|
||||
|
||||
[build-dependencies]
|
||||
napi-build = "2.3.1"
|
||||
|
||||
@@ -275,6 +275,7 @@
|
||||
[@types/node@20.16.10](https://github.com/DefinitelyTyped/DefinitelyTyped) - MIT
|
||||
[@types/node@20.17.9](https://github.com/DefinitelyTyped/DefinitelyTyped) - MIT
|
||||
[@types/node@22.7.4](https://github.com/DefinitelyTyped/DefinitelyTyped) - MIT
|
||||
[@types/node@25.9.8](https://github.com/DefinitelyTyped/DefinitelyTyped) - MIT
|
||||
[@types/semver@7.5.6](https://github.com/DefinitelyTyped/DefinitelyTyped) - MIT
|
||||
[@types/stack-utils@2.0.3](https://github.com/DefinitelyTyped/DefinitelyTyped) - MIT
|
||||
[@types/tmp@0.2.6](https://github.com/DefinitelyTyped/DefinitelyTyped) - MIT
|
||||
@@ -306,6 +307,9 @@
|
||||
[apache-arrow@16.0.0](https://github.com/apache/arrow) - Apache-2.0
|
||||
[apache-arrow@17.0.0](https://github.com/apache/arrow) - Apache-2.0
|
||||
[apache-arrow@18.0.0](https://github.com/apache/arrow) - Apache-2.0
|
||||
[apache-arrow@19.0.1](https://github.com/apache/arrow) - Apache-2.0
|
||||
[apache-arrow@20.0.0](https://github.com/apache/arrow) - Apache-2.0
|
||||
[apache-arrow@21.2.0](https://github.com/apache/arrow-js) - Apache-2.0
|
||||
[argparse@1.0.10](https://github.com/nodeca/argparse) - MIT
|
||||
[argparse@2.0.1](https://github.com/nodeca/argparse) - Python-2.0
|
||||
[array-back@3.1.0](https://github.com/75lb/array-back) - MIT
|
||||
@@ -348,6 +352,7 @@
|
||||
[color@4.2.3](https://github.com/Qix-/color) - MIT
|
||||
[combined-stream@1.0.8](https://github.com/felixge/node-combined-stream) - MIT
|
||||
[command-line-args@5.2.1](https://github.com/75lb/command-line-args) - MIT
|
||||
[command-line-args@6.0.2](https://github.com/75lb/command-line-args) - MIT
|
||||
[command-line-usage@7.0.1](https://github.com/75lb/command-line-usage) - MIT
|
||||
[concat-map@0.0.1](https://github.com/substack/node-concat-map) - MIT
|
||||
[convert-source-map@2.0.0](https://github.com/thlorenz/convert-source-map) - MIT
|
||||
@@ -398,12 +403,14 @@
|
||||
[file-entry-cache@6.0.1](https://github.com/royriojas/file-entry-cache) - MIT
|
||||
[fill-range@7.1.1](https://github.com/jonschlinkert/fill-range) - MIT
|
||||
[find-replace@3.0.0](https://github.com/75lb/find-replace) - MIT
|
||||
[find-replace@5.0.2](https://github.com/75lb/find-replace) - MIT
|
||||
[find-up@4.1.0](https://github.com/sindresorhus/find-up) - MIT
|
||||
[find-up@5.0.0](https://github.com/sindresorhus/find-up) - MIT
|
||||
[flat-cache@3.2.0](https://github.com/jaredwray/flat-cache) - MIT
|
||||
[flatbuffers@1.12.0](https://github.com/google/flatbuffers) - Apache*
|
||||
[flatbuffers@23.5.26](https://github.com/google/flatbuffers) - Apache*
|
||||
[flatbuffers@24.3.25](https://github.com/google/flatbuffers) - Apache-2.0
|
||||
[flatbuffers@25.9.23](https://github.com/google/flatbuffers) - Apache-2.0
|
||||
[flatted@3.2.9](https://github.com/WebReflection/flatted) - ISC
|
||||
[follow-redirects@1.15.6](https://github.com/follow-redirects/follow-redirects) - MIT
|
||||
[foreground-child@3.3.0](https://github.com/tapjs/foreground-child) - ISC
|
||||
@@ -644,6 +651,7 @@
|
||||
[uc.micro@2.1.0](https://github.com/markdown-it/uc.micro) - MIT
|
||||
[undici-types@5.26.5](https://github.com/nodejs/undici) - MIT
|
||||
[undici-types@6.19.8](https://github.com/nodejs/undici) - MIT
|
||||
[undici-types@7.24.6](https://github.com/nodejs/undici) - MIT
|
||||
[update-browserslist-db@1.0.13](https://github.com/browserslist/update-db) - MIT
|
||||
[uri-js@4.4.1](https://github.com/garycourt/uri-js) - BSD-2-Clause
|
||||
[uuid@9.0.1](https://github.com/uuidjs/uuid) - MIT
|
||||
|
||||
+1487
-1378
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
// Every Apache Arrow release the package accepts as a peer dependency, as
|
||||
// separately installed copies so the tests can feed objects built by one
|
||||
// Arrow version into a LanceDB running on another. When a new Arrow major
|
||||
// is released, add the alias to `package.json` and extend this list; the
|
||||
// test files pick the change up from here.
|
||||
import * as arrow15 from "apache-arrow-15";
|
||||
import * as arrow16 from "apache-arrow-16";
|
||||
import * as arrow17 from "apache-arrow-17";
|
||||
import * as arrow18 from "apache-arrow-18";
|
||||
import * as arrow19 from "apache-arrow-19";
|
||||
import * as arrow20 from "apache-arrow-20";
|
||||
import * as arrow21 from "apache-arrow-21";
|
||||
|
||||
export { arrow15, arrow16, arrow17, arrow18, arrow19, arrow20, arrow21 };
|
||||
|
||||
export type ApacheArrow =
|
||||
| typeof arrow15
|
||||
| typeof arrow16
|
||||
| typeof arrow17
|
||||
| typeof arrow18
|
||||
| typeof arrow19
|
||||
| typeof arrow20
|
||||
| typeof arrow21;
|
||||
|
||||
export const arrowVersions: ApacheArrow[] = [
|
||||
arrow15,
|
||||
arrow16,
|
||||
arrow17,
|
||||
arrow18,
|
||||
arrow19,
|
||||
arrow20,
|
||||
arrow21,
|
||||
];
|
||||
|
||||
/** The newest supported Arrow release, for tests that only need one. */
|
||||
export const latestArrow = arrow21;
|
||||
@@ -0,0 +1,509 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
import {
|
||||
Field,
|
||||
Float,
|
||||
Float32,
|
||||
Int32,
|
||||
Int64,
|
||||
List,
|
||||
Schema,
|
||||
Struct,
|
||||
Utf8,
|
||||
} from "apache-arrow";
|
||||
import {
|
||||
convertToTable,
|
||||
makeArrowTable,
|
||||
resolveBlobInputs,
|
||||
} from "../lancedb/arrow";
|
||||
import { BlobFile, blob, coerceBlobValue, isBlobField } from "../lancedb/blob";
|
||||
import { EmbeddingFunction, LanceSchema } from "../lancedb/embedding";
|
||||
import { getRegistry } from "../lancedb/embedding/registry";
|
||||
|
||||
describe("blob()", () => {
|
||||
it("marks the field as lance.blob.v2", () => {
|
||||
const field = blob("image", { nullable: false });
|
||||
expect(field.nullable).toBe(false);
|
||||
expect(isBlobField(field)).toBe(true);
|
||||
expect(field.metadata.get("ARROW:extension:name")).toBe("lance.blob.v2");
|
||||
});
|
||||
|
||||
it("writes encoding thresholds as field metadata", () => {
|
||||
const field = blob("video", {
|
||||
inlineSizeThreshold: 1024,
|
||||
dedicatedSizeThreshold: 2 * 1024 * 1024,
|
||||
packFileSizeThreshold: 64 * 1024 * 1024,
|
||||
});
|
||||
expect(
|
||||
field.metadata.get("lance-encoding:blob-inline-size-threshold"),
|
||||
).toBe("1024");
|
||||
expect(
|
||||
field.metadata.get("lance-encoding:blob-dedicated-size-threshold"),
|
||||
).toBe(String(2 * 1024 * 1024));
|
||||
expect(
|
||||
field.metadata.get("lance-encoding:blob-pack-file-size-threshold"),
|
||||
).toBe(String(64 * 1024 * 1024));
|
||||
});
|
||||
|
||||
it("rejects invalid thresholds", () => {
|
||||
expect(() => blob("image", { inlineSizeThreshold: -1 })).toThrow(
|
||||
/inlineSizeThreshold must be non-negative/,
|
||||
);
|
||||
expect(() => blob("image", { dedicatedSizeThreshold: 0 })).toThrow(
|
||||
/dedicatedSizeThreshold must be positive/,
|
||||
);
|
||||
expect(() => blob("image", { packFileSizeThreshold: 1.5 })).toThrow(
|
||||
/packFileSizeThreshold must be a safe integer/,
|
||||
);
|
||||
expect(() =>
|
||||
blob("image", { dedicatedSizeThreshold: Number.MAX_SAFE_INTEGER + 1 }),
|
||||
).toThrow(/dedicatedSizeThreshold must be a safe integer/);
|
||||
});
|
||||
});
|
||||
|
||||
class BlobStruct {
|
||||
constructor(
|
||||
public data?: unknown,
|
||||
public uri?: unknown,
|
||||
) {}
|
||||
}
|
||||
|
||||
describe("coerceBlobValue", () => {
|
||||
it.each([
|
||||
["Buffer", Buffer.from("x"), { data: Buffer.from("x"), uri: null }],
|
||||
[
|
||||
"Uint8Array",
|
||||
new Uint8Array([120]),
|
||||
{ data: new Uint8Array([120]), uri: null },
|
||||
],
|
||||
["URI string", "s3://bucket/key", { data: null, uri: "s3://bucket/key" }],
|
||||
[
|
||||
"data struct",
|
||||
{ data: Buffer.from("y") },
|
||||
{ data: Buffer.from("y"), uri: null },
|
||||
],
|
||||
[
|
||||
"uri struct",
|
||||
{ uri: "s3://bucket/key" },
|
||||
{ data: null, uri: "s3://bucket/key" },
|
||||
],
|
||||
[
|
||||
"ArrayBuffer",
|
||||
new Uint8Array([120]).buffer,
|
||||
{ data: new Uint8Array([120]), uri: null },
|
||||
],
|
||||
[
|
||||
"URL",
|
||||
new URL("https://example.com/blob"),
|
||||
{ data: null, uri: "https://example.com/blob" },
|
||||
],
|
||||
[
|
||||
"data struct with ArrayBuffer",
|
||||
{ data: new Uint8Array([121]).buffer },
|
||||
{ data: new Uint8Array([121]), uri: null },
|
||||
],
|
||||
[
|
||||
"uri struct with URL",
|
||||
{ uri: new URL("file:///tmp/a.png") },
|
||||
{ data: null, uri: "file:///tmp/a.png" },
|
||||
],
|
||||
[
|
||||
"class instance with Uint8Array data",
|
||||
new BlobStruct(new Uint8Array([122])),
|
||||
{ data: new Uint8Array([122]), uri: null },
|
||||
],
|
||||
[
|
||||
"class instance with ArrayBuffer data",
|
||||
new BlobStruct(new Uint8Array([122]).buffer),
|
||||
{ data: new Uint8Array([122]), uri: null },
|
||||
],
|
||||
[
|
||||
"class instance with URL uri",
|
||||
new BlobStruct(undefined, new URL("s3://bucket/key")),
|
||||
{ data: null, uri: "s3://bucket/key" },
|
||||
],
|
||||
["null", null, null],
|
||||
])("accepts %s", (_name, input, expected) => {
|
||||
expect(coerceBlobValue(input)).toEqual(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["empty URI", "", /uri cannot be empty/],
|
||||
["object without data or uri", { position: 0 }, /data' or 'uri/],
|
||||
[
|
||||
"Int16Array",
|
||||
new Int16Array([1]),
|
||||
/Blob data must be Buffer, Uint8Array, ArrayBuffer, or Blob/,
|
||||
],
|
||||
[
|
||||
"both data and uri",
|
||||
{ data: Buffer.from("y"), uri: "s3://bucket/key" },
|
||||
/exactly one of 'data' or 'uri'/,
|
||||
],
|
||||
[
|
||||
"neither data nor uri",
|
||||
{ data: null, uri: null },
|
||||
/exactly one of 'data' or 'uri'/,
|
||||
],
|
||||
["Blob", new Blob(["x"]), /must be read asynchronously/],
|
||||
["data struct with Blob", { data: new Blob(["x"]) }, /asynchronously/],
|
||||
[
|
||||
"class instance with Blob data",
|
||||
new BlobStruct(new Blob(["x"])),
|
||||
/asynchronously/,
|
||||
],
|
||||
[
|
||||
"unknown struct field",
|
||||
{ data: Buffer.from("y"), mime: "image/png" },
|
||||
/Blob struct values only support 'data' and 'uri', got 'mime'/,
|
||||
],
|
||||
[
|
||||
"misspelled struct field",
|
||||
{ uri: "s3://bucket/key", dat: Buffer.from("y") },
|
||||
/got 'dat'/,
|
||||
],
|
||||
[
|
||||
"SharedArrayBuffer",
|
||||
new SharedArrayBuffer(2),
|
||||
/Blob column values must be Buffer, Uint8Array, ArrayBuffer, Blob/,
|
||||
],
|
||||
["Date", new Date(0), /Blob column values must be/],
|
||||
])("rejects %s", (_name, input, message) => {
|
||||
expect(() => coerceBlobValue(input)).toThrow(message);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveBlobInputs", () => {
|
||||
it("reads Blob and File values in blob columns only", async () => {
|
||||
const schema = new Schema([
|
||||
new Field("name", new Utf8(), true),
|
||||
blob("image"),
|
||||
new Field("images", new List(blob("image")), true),
|
||||
]);
|
||||
const shared = new Blob(["same"]);
|
||||
const notABlobColumn = new Blob(["other"]);
|
||||
const data = [
|
||||
{
|
||||
name: notABlobColumn,
|
||||
image: new File(["file"], "a.txt"),
|
||||
images: [shared, { data: shared }, null],
|
||||
},
|
||||
{ name: "x", image: Buffer.from("buf"), images: null },
|
||||
];
|
||||
const resolved = await resolveBlobInputs(data, schema);
|
||||
|
||||
expect(resolved[0].name).toBe(notABlobColumn);
|
||||
expect(Buffer.from(resolved[0].image as Uint8Array).toString()).toBe(
|
||||
"file",
|
||||
);
|
||||
const images = resolved[0].images as [Uint8Array, { data: Uint8Array }];
|
||||
expect(Buffer.from(images[0]).toString()).toBe("same");
|
||||
expect(Buffer.from(images[1].data).toString()).toBe("same");
|
||||
// Rows without Blob values and the caller's records are left alone.
|
||||
expect(resolved[1]).toBe(data[1]);
|
||||
expect(data[0].images?.[0]).toBe(shared);
|
||||
});
|
||||
|
||||
it("reads each distinct Blob once, bare or inside data structs", async () => {
|
||||
class CountingBlob extends Blob {
|
||||
calls = 0;
|
||||
async arrayBuffer(): Promise<ArrayBuffer> {
|
||||
this.calls++;
|
||||
return super.arrayBuffer();
|
||||
}
|
||||
}
|
||||
const shared = new CountingBlob(["same"]);
|
||||
const schema = new Schema([blob("image")]);
|
||||
const resolved = await resolveBlobInputs(
|
||||
[
|
||||
{ image: { data: shared } },
|
||||
{ image: { data: shared } },
|
||||
{ image: shared },
|
||||
],
|
||||
schema,
|
||||
);
|
||||
expect(shared.calls).toBe(1);
|
||||
const first = (resolved[0].image as { data: Uint8Array }).data;
|
||||
expect(Buffer.from(first).toString()).toBe("same");
|
||||
expect((resolved[1].image as { data: Uint8Array }).data).toBe(first);
|
||||
expect(resolved[2].image).toBe(first);
|
||||
});
|
||||
|
||||
it("reads a Blob held by a class instance", async () => {
|
||||
const schema = new Schema([blob("image")]);
|
||||
const [row] = await resolveBlobInputs(
|
||||
[{ image: new BlobStruct(new Blob(["hi"])) }],
|
||||
schema,
|
||||
);
|
||||
expect(
|
||||
Buffer.from((row.image as { data: Uint8Array }).data).toString(),
|
||||
).toBe("hi");
|
||||
});
|
||||
|
||||
it("returns the input when there is nothing to read", async () => {
|
||||
const data = [{ image: Buffer.from("x") }];
|
||||
const schema = new Schema([blob("image")]);
|
||||
await expect(resolveBlobInputs(data, schema)).resolves.toBe(data);
|
||||
await expect(resolveBlobInputs(data)).resolves.toBe(data);
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertToTable blob columns with embedding functions", () => {
|
||||
// Embedding metadata routes rows through ensureNestedFieldsExist, which must
|
||||
// leave blob values for the blob coercion instead of treating them as structs.
|
||||
class LengthEmbedding extends EmbeddingFunction<string> {
|
||||
ndims() {
|
||||
return 2;
|
||||
}
|
||||
embeddingDataType(): Float {
|
||||
return new Float32();
|
||||
}
|
||||
async computeSourceEmbeddings(data: string[]) {
|
||||
return data.map((text) => [text.length, 1]);
|
||||
}
|
||||
}
|
||||
getRegistry().register("blob-test-length")(LengthEmbedding);
|
||||
const func = getRegistry().get<LengthEmbedding>("blob-test-length")!.create();
|
||||
const embeddingSchema = LanceSchema({
|
||||
text: func.sourceField(new Utf8()),
|
||||
vector: func.vectorField(),
|
||||
});
|
||||
const schema = new Schema(
|
||||
[...embeddingSchema.fields, blob("image")],
|
||||
embeddingSchema.metadata,
|
||||
);
|
||||
|
||||
it.each([
|
||||
["Uint8Array", new Uint8Array([104, 105]), "hi", null],
|
||||
["ArrayBuffer", new Uint8Array([104, 105]).buffer, "hi", null],
|
||||
["Blob", new Blob(["hi"]), "hi", null],
|
||||
["URI string", "s3://b/k", null, "s3://b/k"],
|
||||
["URL", new URL("s3://b/k"), null, "s3://b/k"],
|
||||
["data struct", { data: new Uint8Array([104, 105]) }, "hi", null],
|
||||
])("accepts %s input", async (_name, image, data, uri) => {
|
||||
const table = await convertToTable([{ text: "a", image }], undefined, {
|
||||
schema,
|
||||
});
|
||||
const column = table.getChild("image")!;
|
||||
expect(column.nullCount).toBe(0);
|
||||
const bytes = column.getChild("data")!.get(0);
|
||||
expect(bytes == null ? null : Buffer.from(bytes).toString()).toBe(data);
|
||||
expect(column.getChild("uri")!.get(0)).toBe(uri);
|
||||
});
|
||||
|
||||
it("writes a missing blob as null", async () => {
|
||||
const table = await convertToTable([{ text: "a" }], undefined, { schema });
|
||||
const column = table.getChild("image")!;
|
||||
expect(column.nullCount).toBe(1);
|
||||
expect(column.get(0)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("BlobFile", () => {
|
||||
it("rejects constructing BlobFile without a native handle", () => {
|
||||
expect(() => new (BlobFile as unknown as { new (): BlobFile })()).toThrow(
|
||||
/fetchBlobFiles/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("makeArrowTable blob columns", () => {
|
||||
it.each([
|
||||
["ReadableStream", new ReadableStream()],
|
||||
["Int16Array", new Int16Array([1])],
|
||||
])("rejects %s input rather than writing a null blob", (_name, image) => {
|
||||
const schema = new Schema([blob("image")]);
|
||||
expect(() => makeArrowTable([{ image }], { schema })).toThrow(/image/);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Blob", new Blob(["hello"])],
|
||||
["File", new File(["hello"], "hello.txt")],
|
||||
["data struct with Blob", { data: new Blob(["hello"]) }],
|
||||
[
|
||||
"Blob with enumerable fields",
|
||||
Object.assign(new Blob(["hello"]), { position: 0 }),
|
||||
],
|
||||
])("points %s input at the async APIs", (_name, image) => {
|
||||
const schema = new Schema([blob("image")]);
|
||||
expect(() => makeArrowTable([{ id: 1, image }], { schema })).toThrow(
|
||||
/blob field image at row 0: Blob and File values must be read asynchronously/,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["ArrayBuffer", new Uint8Array([104, 105]).buffer, "hi", null],
|
||||
[
|
||||
"data struct with ArrayBuffer",
|
||||
{ data: new Uint8Array([104, 105]).buffer },
|
||||
"hi",
|
||||
null,
|
||||
],
|
||||
["URL", new URL("https://example.com/a"), null, "https://example.com/a"],
|
||||
[
|
||||
"uri struct with URL",
|
||||
{ uri: new URL("file:///tmp/a.png") },
|
||||
null,
|
||||
"file:///tmp/a.png",
|
||||
],
|
||||
])("accepts %s input", (_name, image, data, uri) => {
|
||||
const schema = new Schema([blob("image")]);
|
||||
const input = { image };
|
||||
const table = makeArrowTable([input], { schema });
|
||||
const column = table.getChild("image")!;
|
||||
expect(column.nullCount).toBe(0);
|
||||
const bytes = column.getChild("data")!.get(0);
|
||||
expect(bytes == null ? null : Buffer.from(bytes).toString()).toBe(data);
|
||||
expect(column.getChild("uri")!.get(0)).toBe(uri);
|
||||
// The caller's record is left untouched.
|
||||
expect(input.image).toBe(image);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Uint8Array", new Uint8Array([104, 105]), "hi", null],
|
||||
["URI string", "s3://b/k", null, "s3://b/k"],
|
||||
["data struct", { data: new Uint8Array([104, 105]) }, "hi", null],
|
||||
])("accepts %s input after a null row", (_name, image, data, uri) => {
|
||||
const schema = new Schema([blob("image")]);
|
||||
const table = makeArrowTable([{ image: null }, { image }], { schema });
|
||||
const column = table.getChild("image")!;
|
||||
expect(column.nullCount).toBe(1);
|
||||
expect(column.get(0)).toBeNull();
|
||||
const bytes = column.getChild("data")!.get(1);
|
||||
expect(bytes == null ? null : Buffer.from(bytes).toString()).toBe(data);
|
||||
expect(column.getChild("uri")!.get(1)).toBe(uri);
|
||||
});
|
||||
|
||||
it("accepts a null struct before one holding a blob", () => {
|
||||
const schema = new Schema([
|
||||
new Field("info", new Struct([blob("image")]), true),
|
||||
]);
|
||||
const table = makeArrowTable(
|
||||
[{ info: null }, { info: { image: new Uint8Array([97]) } }],
|
||||
{ schema },
|
||||
);
|
||||
const info = table.getChild("info")!;
|
||||
expect(info.get(0)).toBeNull();
|
||||
const image = (info.get(1) as { image: { data: Uint8Array } }).image;
|
||||
expect(Buffer.from(image.data).toString()).toBe("a");
|
||||
});
|
||||
|
||||
it("skips a missing blob column named like an Object.prototype member", () => {
|
||||
const schema = new Schema([
|
||||
new Field("id", new Int32(), true),
|
||||
blob("constructor"),
|
||||
]);
|
||||
const table = makeArrowTable([{ id: 1 }], { schema });
|
||||
expect(table.schema.fields.map((f) => f.name)).toEqual(["id"]);
|
||||
});
|
||||
|
||||
it("accepts ArrayBuffer and URL inside lists and structs", () => {
|
||||
const schema = new Schema([
|
||||
new Field("images", new List(blob("image")), true),
|
||||
new Field("info", new Struct([blob("image")]), true),
|
||||
]);
|
||||
const table = makeArrowTable(
|
||||
[
|
||||
{
|
||||
images: [new Uint8Array([97]).buffer, new URL("s3://b/k")],
|
||||
info: { image: new Uint8Array([98]).buffer },
|
||||
},
|
||||
],
|
||||
{ schema },
|
||||
);
|
||||
const images = Array.from(
|
||||
table.getChild("images")!.get(0) as Iterable<{
|
||||
data: Uint8Array | null;
|
||||
uri: string | null;
|
||||
}>,
|
||||
);
|
||||
expect(Buffer.from(images[0].data!).toString()).toBe("a");
|
||||
expect(images[1].uri).toBe("s3://b/k");
|
||||
const info = table.getChild("info")!.get(0) as {
|
||||
image: { data: Uint8Array };
|
||||
};
|
||||
expect(Buffer.from(info.image.data).toString()).toBe("b");
|
||||
});
|
||||
|
||||
it("coerces Buffer input onto a blob field", () => {
|
||||
const schema = new Schema([
|
||||
new Field("id", new Int64(), true),
|
||||
blob("image"),
|
||||
]);
|
||||
const table = makeArrowTable([{ id: 1n, image: Buffer.from("hello") }], {
|
||||
schema,
|
||||
});
|
||||
expect(isBlobField(table.schema.fields[1])).toBe(true);
|
||||
const image = table.getChild("image")!;
|
||||
expect(image.nullCount).toBe(0);
|
||||
expect(image.getChild("uri")!.get(0)).toBeNull();
|
||||
expect(image.getChild("data")!.nullCount).toBe(0);
|
||||
expect(Buffer.from(image.getChild("data")!.get(0)!).toString()).toBe(
|
||||
"hello",
|
||||
);
|
||||
});
|
||||
|
||||
it("coerces Buffer elements inside a list and keeps null slots", () => {
|
||||
const schema = new Schema([
|
||||
new Field("id", new Int64(), true),
|
||||
new Field("images", new List(blob("image")), true),
|
||||
]);
|
||||
const table = makeArrowTable(
|
||||
[
|
||||
{ id: 1n, images: [Buffer.from("a"), Buffer.from("bb")] },
|
||||
{ id: 2n, images: null },
|
||||
{ id: 3n, images: [Buffer.from("c"), null] },
|
||||
{ id: 4n, images: [] },
|
||||
],
|
||||
{ schema },
|
||||
);
|
||||
const images = table.getChild("images")!;
|
||||
expect(images.nullCount).toBe(1);
|
||||
const rows = images.toArray();
|
||||
expect(rows[1]).toBeNull();
|
||||
expect(Array.from(rows[3] as Iterable<unknown>)).toHaveLength(0);
|
||||
const first = Array.from(rows[0] as Iterable<{ data: Uint8Array | null }>);
|
||||
expect(Buffer.from(first[0].data!).toString()).toBe("a");
|
||||
expect(Buffer.from(first[1].data!).toString()).toBe("bb");
|
||||
const third = Array.from(
|
||||
rows[2] as Iterable<{ data: Uint8Array | null } | null>,
|
||||
);
|
||||
expect(Buffer.from(third[0]!.data!).toString()).toBe("c");
|
||||
expect(third[1]).toBeNull();
|
||||
});
|
||||
|
||||
it("coerces Buffer fields inside list structs", () => {
|
||||
const schema = new Schema([
|
||||
new Field("id", new Int64(), true),
|
||||
new Field(
|
||||
"items",
|
||||
new List(
|
||||
new Field(
|
||||
"item",
|
||||
new Struct([new Field("name", new Utf8(), true), blob("image")]),
|
||||
true,
|
||||
),
|
||||
),
|
||||
true,
|
||||
),
|
||||
]);
|
||||
const table = makeArrowTable(
|
||||
[
|
||||
{
|
||||
id: 1n,
|
||||
items: [{ name: "one", image: Buffer.from("alpha") }],
|
||||
},
|
||||
],
|
||||
{ schema },
|
||||
);
|
||||
const items = Array.from(
|
||||
table.getChild("items")!.toArray()[0] as Iterable<{
|
||||
name: string;
|
||||
image: { data: Uint8Array | null };
|
||||
}>,
|
||||
);
|
||||
expect(items[0].name).toBe("one");
|
||||
expect(Buffer.from(items[0].image.data!).toString()).toBe("alpha");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
import * as http from "http";
|
||||
import { Catalog, connectCatalog } from "../lancedb";
|
||||
|
||||
type RecordedRequest = {
|
||||
url: string;
|
||||
headers: http.IncomingHttpHeaders;
|
||||
body: Record<string, unknown>;
|
||||
};
|
||||
|
||||
async function withCatalog(
|
||||
responses: [number, unknown][],
|
||||
callback: (catalog: Catalog, requests: RecordedRequest[]) => Promise<void>,
|
||||
) {
|
||||
const requests: RecordedRequest[] = [];
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of req) chunks.push(Buffer.from(chunk));
|
||||
const body = Buffer.concat(chunks).toString();
|
||||
requests.push({
|
||||
url: req.url ?? "",
|
||||
headers: req.headers,
|
||||
body: body ? JSON.parse(body) : {},
|
||||
});
|
||||
const [status, response] = responses.shift() ?? [
|
||||
500,
|
||||
{ error: "Unexpected request" },
|
||||
];
|
||||
res.writeHead(status, { "content-type": "application/json" });
|
||||
res.end(status === 204 ? undefined : JSON.stringify(response));
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string")
|
||||
throw new Error("Missing server address");
|
||||
try {
|
||||
const catalog = await connectCatalog(`http://127.0.0.1:${address.port}`, {
|
||||
apiKey: "secret",
|
||||
sqlHostOverride: "grpc+tls://sql.example.com:10026",
|
||||
clientConfig: {
|
||||
extraHeaders: {
|
||||
"X-LanceDB-Database": "wrong-static",
|
||||
"X-LanceDB-Database-Prefix": "wrong",
|
||||
},
|
||||
},
|
||||
headerProvider: () => ({
|
||||
"X-LanceDB-Database": "wrong-dynamic",
|
||||
"X-LanceDB-Database-Prefix": "wrong",
|
||||
authorization: "Bearer refreshed",
|
||||
}),
|
||||
});
|
||||
await callback(catalog, requests);
|
||||
expect(responses).toHaveLength(0);
|
||||
} finally {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
}
|
||||
|
||||
describe("remote catalog", () => {
|
||||
it("uses root namespace routes and preserves independent database scope", async () => {
|
||||
await withCatalog(
|
||||
[
|
||||
[204, null],
|
||||
[200, { tables: [] }],
|
||||
[200, {}],
|
||||
[200, { tables: [] }],
|
||||
[200, { tables: [] }],
|
||||
// biome-ignore lint/style/useNamingConvention: server wire format
|
||||
[200, { namespaces: ["team/search"], page_token: "next" }],
|
||||
[204, null],
|
||||
],
|
||||
async (catalog, requests) => {
|
||||
const first = await catalog.createDatabase("team/search", {
|
||||
existOk: true,
|
||||
});
|
||||
expect(await first.tableNames()).toEqual([]);
|
||||
const second = await catalog.connectDatabase("other");
|
||||
expect(await second.tableNames()).toEqual([]);
|
||||
expect(await first.tableNames()).toEqual([]);
|
||||
const names = catalog.listDatabases({ pageLimit: 1, pageToken: "a/b" });
|
||||
expect(await names.next()).toEqual({
|
||||
done: false,
|
||||
value: "team/search",
|
||||
});
|
||||
expect(names.pageToken()).toBe("next");
|
||||
await catalog.dropDatabase("team/search", { ignoreMissing: true });
|
||||
expect(requests[0].url).toBe("/v1/namespace/team%2Fsearch/create");
|
||||
expect(requests[0].body).toEqual({ mode: "ExistOk" });
|
||||
expect(requests[5].url).toBe(
|
||||
"/v1/namespace/$/list?limit=1&page_token=a%2Fb",
|
||||
);
|
||||
expect(requests[6].body).toEqual({
|
||||
mode: "Skip",
|
||||
behavior: "Restrict",
|
||||
});
|
||||
for (const [i, request] of requests.entries()) {
|
||||
expect(request.headers["x-lancedb-database"]).toBe(
|
||||
i === 1 || i === 4 ? "team/search" : i === 3 ? "other" : undefined,
|
||||
);
|
||||
expect(request.headers["x-lancedb-database-prefix"]).toBeUndefined();
|
||||
expect(request.headers.authorization).toBe("Bearer refreshed");
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("propagates lifecycle errors and sends restricted drops", async () => {
|
||||
await withCatalog(
|
||||
[
|
||||
[404, {}],
|
||||
[409, {}],
|
||||
[400, {}],
|
||||
[404, {}],
|
||||
],
|
||||
async (catalog, requests) => {
|
||||
await expect(catalog.connectDatabase("missing")).rejects.toThrow(
|
||||
"missing",
|
||||
);
|
||||
await expect(catalog.createDatabase("exists")).rejects.toThrow(
|
||||
"exists",
|
||||
);
|
||||
await expect(catalog.dropDatabase("full")).rejects.toThrow();
|
||||
await catalog.dropDatabase("missing", { ignoreMissing: true });
|
||||
expect(requests[2].body).toEqual({
|
||||
mode: "Fail",
|
||||
behavior: "Restrict",
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("lazily traverses pages, including empty pages, and exposes cached state", async () => {
|
||||
await withCatalog(
|
||||
[
|
||||
// biome-ignore lint/style/useNamingConvention: server wire format
|
||||
[200, { namespaces: ["a", "b"], page_token: "empty" }],
|
||||
// biome-ignore lint/style/useNamingConvention: server wire format
|
||||
[200, { namespaces: [], page_token: "last" }],
|
||||
// biome-ignore lint/style/useNamingConvention: server wire format
|
||||
[200, { namespaces: ["c", "d"], page_token: "" }],
|
||||
],
|
||||
async (catalog, requests) => {
|
||||
const names = catalog.listDatabases({ pageLimit: 2 });
|
||||
expect(names[Symbol.asyncIterator]()).toBe(names);
|
||||
expect(requests).toHaveLength(0);
|
||||
expect(names.numPageResults()).toBe(0);
|
||||
expect(names.pageToken()).toBeUndefined();
|
||||
expect(await names.next()).toEqual({ done: false, value: "a" });
|
||||
expect(names.numPageResults()).toBe(1);
|
||||
expect(names.pageToken()).toBe("empty");
|
||||
expect(await names.next()).toEqual({ done: false, value: "b" });
|
||||
expect(requests).toHaveLength(1);
|
||||
expect(await names.next()).toEqual({ done: false, value: "c" });
|
||||
expect(requests).toHaveLength(3);
|
||||
expect(names.pageToken()).toBeUndefined();
|
||||
expect(names.numPageResults()).toBe(1);
|
||||
const remaining = [];
|
||||
for await (const name of names) remaining.push(name);
|
||||
expect(remaining).toEqual(["d"]);
|
||||
expect(names.numPageResults()).toBe(0);
|
||||
expect(await names.next()).toEqual({ done: true, value: undefined });
|
||||
expect(requests.map((request) => request.url)).toEqual([
|
||||
"/v1/namespace/$/list?limit=2",
|
||||
"/v1/namespace/$/list?limit=2&page_token=empty",
|
||||
"/v1/namespace/$/list?limit=2&page_token=last",
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("resumes from a saved token after draining the cache", async () => {
|
||||
await withCatalog(
|
||||
[
|
||||
// biome-ignore lint/style/useNamingConvention: server wire format
|
||||
[200, { namespaces: ["a", "b"], page_token: "next/token" }],
|
||||
[200, { namespaces: ["c"] }],
|
||||
],
|
||||
async (catalog, requests) => {
|
||||
const names = catalog.listDatabases({
|
||||
pageToken: "start/token",
|
||||
pageLimit: 2,
|
||||
});
|
||||
expect(names.pageToken()).toBe("start/token");
|
||||
expect(await names.next()).toEqual({ done: false, value: "a" });
|
||||
while (names.numPageResults() > 0) await names.next();
|
||||
const resumed = catalog.listDatabases({
|
||||
pageToken: names.pageToken(),
|
||||
pageLimit: 2,
|
||||
});
|
||||
const results = [];
|
||||
for await (const name of resumed) results.push(name);
|
||||
expect(results).toEqual(["c"]);
|
||||
expect(requests.map((request) => request.url)).toEqual([
|
||||
"/v1/namespace/$/list?limit=2&page_token=start%2Ftoken",
|
||||
"/v1/namespace/$/list?limit=2&page_token=next%2Ftoken",
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("terminates on errors and retains the failed request token", async () => {
|
||||
await withCatalog(
|
||||
[
|
||||
// biome-ignore lint/style/useNamingConvention: server wire format
|
||||
[200, { namespaces: ["a"], page_token: "retry" }],
|
||||
[400, {}],
|
||||
[200, { namespaces: ["b"] }],
|
||||
],
|
||||
async (catalog, requests) => {
|
||||
const names = catalog.listDatabases();
|
||||
await names.next();
|
||||
await expect(names.next()).rejects.toThrow();
|
||||
expect(names.pageToken()).toBe("retry");
|
||||
expect(names.numPageResults()).toBe(0);
|
||||
expect(await names.next()).toEqual({ done: true, value: undefined });
|
||||
expect(requests).toHaveLength(2);
|
||||
const resumed = catalog.listDatabases({ pageToken: names.pageToken() });
|
||||
expect(await resumed.next()).toEqual({ done: false, value: "b" });
|
||||
expect(await resumed.next()).toEqual({ done: true, value: undefined });
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("handles an empty listing and concurrent advances", async () => {
|
||||
await withCatalog(
|
||||
[
|
||||
[200, { namespaces: [] }],
|
||||
[200, { namespaces: ["a", "b"] }],
|
||||
],
|
||||
async (catalog, requests) => {
|
||||
const empty = catalog.listDatabases();
|
||||
expect(await empty.next()).toEqual({ done: true, value: undefined });
|
||||
expect(await empty.next()).toEqual({ done: true, value: undefined });
|
||||
const names = catalog.listDatabases();
|
||||
const pending = [names.next(), names.next(), names.next()];
|
||||
expect(names.numPageResults()).toBe(0);
|
||||
expect(names.pageToken()).toBeUndefined();
|
||||
const results = await Promise.all(pending);
|
||||
expect(
|
||||
results
|
||||
.filter((result) => !result.done)
|
||||
.map((result) => result.value)
|
||||
.sort(),
|
||||
).toEqual(["a", "b"]);
|
||||
expect(results.filter((result) => result.done)).toHaveLength(1);
|
||||
expect(requests).toHaveLength(2);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("validates endpoints and pagination", async () => {
|
||||
await expect(connectCatalog("/tmp/catalog")).rejects.toThrow();
|
||||
const catalog = await connectCatalog("http://127.0.0.1:1");
|
||||
for (const limit of [
|
||||
0,
|
||||
-1,
|
||||
1.5,
|
||||
2147483648,
|
||||
Number.NaN,
|
||||
Number.POSITIVE_INFINITY,
|
||||
]) {
|
||||
expect(() => catalog.listDatabases({ pageLimit: limit })).toThrow(
|
||||
"limit",
|
||||
);
|
||||
}
|
||||
await expect(catalog.connectDatabase("a$b")).rejects.toThrow(
|
||||
"Invalid database name",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
import { readdirSync } from "fs";
|
||||
import { mkdirSync, readdirSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { Field, Float64, Schema } from "apache-arrow";
|
||||
import * as tmp from "tmp";
|
||||
import {
|
||||
@@ -180,6 +181,32 @@ describe("given a connection", () => {
|
||||
expect(seen).toEqual(created);
|
||||
});
|
||||
|
||||
it("should list all names and use a default page size of 100", async () => {
|
||||
const names = Array.from(
|
||||
{ length: 130 },
|
||||
(_, i) => `t${i.toString().padStart(3, "0")}`,
|
||||
);
|
||||
for (const name of names) {
|
||||
mkdirSync(join(tmpDir.name, `${name}.lance`));
|
||||
}
|
||||
await expect(db.tableNames()).resolves.toEqual(names);
|
||||
await expect(db.tableNames({ limit: 0 })).resolves.toEqual([]);
|
||||
const first = await db.listTables();
|
||||
expect(first.tables).toEqual(names.slice(0, 100));
|
||||
expect(first.pageToken).toBeDefined();
|
||||
const second = await db.listTables({ pageToken: first.pageToken });
|
||||
expect(second.tables).toEqual(names.slice(100));
|
||||
expect(second.pageToken).toBeUndefined();
|
||||
await expect(db.listTables({ limit: 200 })).resolves.toEqual({
|
||||
tables: names,
|
||||
pageToken: undefined,
|
||||
});
|
||||
await expect(db.listTables({ limit: 0 })).resolves.toEqual({
|
||||
tables: [],
|
||||
pageToken: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("should list tables in a namespace", async () => {
|
||||
const db = await connect(tmpDir.name, {
|
||||
// biome-ignore lint/style/useNamingConvention: opaque backend property key, must match Rust
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
@exit /b 0
|
||||
@@ -16,18 +16,47 @@ describe("materialized views", () => {
|
||||
beforeEach(async () => {
|
||||
tmpDir = tmp.dirSync({ unsafeCleanup: true });
|
||||
db = await connect(tmpDir.name);
|
||||
await db.createTable(
|
||||
"people",
|
||||
[
|
||||
{ name: "ada", age: 36 },
|
||||
{ name: "kid", age: 7 },
|
||||
{ name: "grace", age: 85 },
|
||||
],
|
||||
{ storageOptions: { newTableEnableStableRowIds: "true" } },
|
||||
);
|
||||
await db.createTable("people", [
|
||||
{ name: "ada", age: 36 },
|
||||
{ name: "kid", age: 7 },
|
||||
{ name: "grace", age: 85 },
|
||||
]);
|
||||
});
|
||||
afterEach(() => tmpDir.removeCallback());
|
||||
|
||||
it("reads stored queries and legacy layouts", () => {
|
||||
const read = (stored: string) =>
|
||||
definitionFromMetadata(new Map([[DEFINITION_META_KEY, stored]]), "v");
|
||||
const query =
|
||||
"SELECT id, c.chunk FROM ns.docs, UNNEST(chunks) AS c WHERE id > 1";
|
||||
expect(read(`{"format":1,"query":${JSON.stringify(query)}}`).query).toBe(
|
||||
query,
|
||||
);
|
||||
|
||||
// The structured layout written before the format number reads as the
|
||||
// query it described, under either of its kind tags.
|
||||
expect(
|
||||
read(
|
||||
'{"kind":"namespaced_select","source_table":"people","source_namespace":["ns"],' +
|
||||
'"projections":[{"output":"name","expression":"`name`"},' +
|
||||
'{"output":"Shout","expression":"upper(name)"}],"filter":"age >= 18","limit":42}',
|
||||
).query,
|
||||
).toBe(
|
||||
"SELECT `name`, upper(name) AS `Shout` FROM ns.people WHERE age >= 18 LIMIT 42",
|
||||
);
|
||||
expect(read('{"kind":"select","source_table":"people"}').query).toBe(
|
||||
"SELECT * FROM people",
|
||||
);
|
||||
|
||||
// A newer writer's layout is reported, never guessed at.
|
||||
for (const newer of [
|
||||
`{"format":3,"query":${JSON.stringify(query)}}`,
|
||||
'{"kind":"select_v3","source_table":"people"}',
|
||||
]) {
|
||||
expect(() => read(newer)).toThrow(/cannot refresh/);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a stored limit a number cannot carry", () => {
|
||||
const big = new Map([
|
||||
[
|
||||
@@ -38,14 +67,6 @@ describe("materialized views", () => {
|
||||
expect(() => definitionFromMetadata(big, "v")).toThrow(
|
||||
/too large to represent exactly/,
|
||||
);
|
||||
|
||||
const safe = new Map([
|
||||
[
|
||||
DEFINITION_META_KEY,
|
||||
'{"kind":"select","source_table":"people","limit":42}',
|
||||
],
|
||||
]);
|
||||
expect(definitionFromMetadata(safe, "v").limit).toBe(42);
|
||||
});
|
||||
|
||||
it("creates, refreshes and queries a view", async () => {
|
||||
@@ -54,11 +75,7 @@ describe("materialized views", () => {
|
||||
where: "age >= 18",
|
||||
});
|
||||
expect(view.name).toBe("adults");
|
||||
expect(await view.table().countRows()).toBe(0);
|
||||
|
||||
const result = await view.refresh();
|
||||
expect(result.mode).toBe("rebuild");
|
||||
expect(Number(result.rowsWritten)).toBe(2);
|
||||
expect(await view.table().countRows()).toBe(2);
|
||||
|
||||
const rows = await view.table().query().toArray();
|
||||
expect(rows.map((r) => r.shout).sort()).toEqual(["ADA", "GRACE"]);
|
||||
@@ -70,37 +87,50 @@ describe("materialized views", () => {
|
||||
});
|
||||
const view = await db.openMaterializedView("adults");
|
||||
const definition = await view.definition();
|
||||
expect(definition.sourceTable).toBe("people");
|
||||
expect(definition.filter).toBe("age >= 18");
|
||||
expect(definition.projections).toEqual([
|
||||
["name", "`name`"],
|
||||
["age", "`age`"],
|
||||
]);
|
||||
expect(definition.inputs).toEqual(["age", "name"]);
|
||||
expect(definition.query).toBe(
|
||||
"SELECT name, age FROM people WHERE age >= 18",
|
||||
);
|
||||
});
|
||||
|
||||
it("refreshes incrementally after an append", async () => {
|
||||
const view = await db.createMaterializedView("copy", "people");
|
||||
it("fully refreshes after an append", async () => {
|
||||
const view = await db.createMaterializedView("copy", "people", {
|
||||
withNoData: true,
|
||||
});
|
||||
await view.refresh();
|
||||
|
||||
const people = await db.openTable("people");
|
||||
await people.add([{ name: "alan", age: 41 }]);
|
||||
const result = await view.refresh();
|
||||
expect(result.mode).toBe("incremental");
|
||||
expect(Number(result.rowsWritten)).toBe(1);
|
||||
expect(result.mode).toBe("rebuild");
|
||||
expect(Number(result.rowsWritten)).toBe(4);
|
||||
expect(await view.table().countRows()).toBe(4);
|
||||
|
||||
expect((await view.refresh()).mode).toBe("no_op");
|
||||
expect((await view.refresh()).mode).toBe("rebuild");
|
||||
});
|
||||
|
||||
it("lists views and rejects non-views", async () => {
|
||||
await db.createMaterializedView("adults", "people", {
|
||||
where: "age >= 18",
|
||||
});
|
||||
expect(await db.listMaterializedViews()).toEqual(["adults"]);
|
||||
expect(await collect(db.listMaterializedViews())).toEqual(["adults"]);
|
||||
await expect(db.openMaterializedView("people")).rejects.toThrow(
|
||||
"not a materialized view",
|
||||
);
|
||||
await expect(db.dropMaterializedView("people")).rejects.toThrow(
|
||||
"not a materialized view",
|
||||
);
|
||||
|
||||
await db.dropMaterializedView("adults");
|
||||
expect(await collect(db.listMaterializedViews())).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns a job when dropping a view asynchronously", async () => {
|
||||
await db.createMaterializedView("adults", "people");
|
||||
|
||||
const job = await db.dropMaterializedViewAsync("adults");
|
||||
expect(job.id).toBeNull();
|
||||
await job.wait();
|
||||
expect(await collect(db.listMaterializedViews())).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects an invalid expression at create time", async () => {
|
||||
@@ -117,7 +147,7 @@ describe("materialized views", () => {
|
||||
db.createMaterializedView("bad", "people", { limit }),
|
||||
).rejects.toThrow("non-negative integer");
|
||||
}
|
||||
expect(await db.listMaterializedViews()).toEqual([]);
|
||||
expect(await collect(db.listMaterializedViews())).toEqual([]);
|
||||
|
||||
const view = await db.createMaterializedView("copy", "people");
|
||||
for (const sourceVersion of [-1, 1.5, Infinity, NaN]) {
|
||||
@@ -128,20 +158,24 @@ describe("materialized views", () => {
|
||||
});
|
||||
|
||||
it("quotes bare select names", async () => {
|
||||
await db.createTable("odd_names", [{ "order item": "widget" }], {
|
||||
storageOptions: { newTableEnableStableRowIds: "true" },
|
||||
});
|
||||
await db.createTable("odd_names", [{ "order item": "widget" }]);
|
||||
const view = await db.createMaterializedView("quoted", "odd_names", {
|
||||
select: ["order item"],
|
||||
withNoData: true,
|
||||
});
|
||||
const result = await view.refresh();
|
||||
expect(Number(result.rowsWritten)).toBe(1);
|
||||
});
|
||||
|
||||
it("requires stable row ids on the source", async () => {
|
||||
it("does not require stable row ids on the source", async () => {
|
||||
await db.createTable("plain", [{ x: 1 }]);
|
||||
await expect(db.createMaterializedView("v", "plain")).rejects.toThrow(
|
||||
"stable row ids",
|
||||
);
|
||||
const view = await db.createMaterializedView("v", "plain");
|
||||
expect(await view.table().countRows()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
async function collect<T>(items: AsyncIterable<T>): Promise<T[]> {
|
||||
const result: T[] = [];
|
||||
for await (const item of items) result.push(item);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
import * as fs from "fs";
|
||||
import * as http from "http";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import * as os from "os";
|
||||
import * as path from "path";
|
||||
import { OAuthConfig, OAuthFlowType, OAuthSession } from "../lancedb/oauth";
|
||||
|
||||
function tempCacheDir(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), "lancedb-oauth-cache-"));
|
||||
}
|
||||
|
||||
function deviceConfig(issuerUrl: string, cacheDir: string): OAuthConfig {
|
||||
return {
|
||||
issuerUrl,
|
||||
clientId: "client-id",
|
||||
scopes: ["openid"],
|
||||
flow: OAuthFlowType.DeviceCode,
|
||||
tokenCache: { cacheDir },
|
||||
};
|
||||
}
|
||||
|
||||
describe("OAuthSession", () => {
|
||||
beforeAll(() => {
|
||||
// Child processes inherit the real environment, just as Rust reads it.
|
||||
// Fail before login if the browser override only exists in Jest's sandbox.
|
||||
const browser = execFileSync(
|
||||
process.execPath,
|
||||
["-p", "process.env.LANCEDB_OAUTH_BROWSER ?? ''"],
|
||||
{ encoding: "utf8" },
|
||||
).trim();
|
||||
expect(browser).not.toBe("");
|
||||
expect(browser).toBe(process.env.LANCEDB_OAUTH_BROWSER);
|
||||
});
|
||||
|
||||
it("reports an absent session and logout is idempotent", async () => {
|
||||
const cacheDir = tempCacheDir();
|
||||
const session = new OAuthSession(
|
||||
deviceConfig("https://issuer.example.com", cacheDir),
|
||||
);
|
||||
|
||||
const status = await session.status();
|
||||
expect(status.refreshable).toBe(false);
|
||||
expect(status.issuerUrl).toBe("https://issuer.example.com");
|
||||
expect(status.clientId).toBe("client-id");
|
||||
expect(status.scopes).toEqual(["openid"]);
|
||||
expect(status.flow).toBe("device_code");
|
||||
expect(status.obtainedAt).toBeUndefined();
|
||||
|
||||
const logout = await session.logout();
|
||||
expect(logout.removed).toBe(false);
|
||||
});
|
||||
|
||||
it("requires token cache options", () => {
|
||||
const config: OAuthConfig = {
|
||||
issuerUrl: "https://issuer.example.com",
|
||||
clientId: "client-id",
|
||||
scopes: ["openid"],
|
||||
flow: OAuthFlowType.DeviceCode,
|
||||
};
|
||||
expect(() => new OAuthSession(config)).toThrow(/token/);
|
||||
});
|
||||
|
||||
it("rejects azure managed identity persistence", () => {
|
||||
const config: OAuthConfig = {
|
||||
issuerUrl: "https://login.microsoftonline.com/tenant/v2.0",
|
||||
clientId: "app-id",
|
||||
scopes: ["api://app/.default"],
|
||||
flow: OAuthFlowType.AzureManagedIdentity,
|
||||
tokenCache: { cacheDir: tempCacheDir() },
|
||||
};
|
||||
expect(() => new OAuthSession(config)).toThrow(/AzureManagedIdentity/);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{},
|
||||
{
|
||||
resource: "https://api.example.com/a?x=1&y=two",
|
||||
audience: "audience + & / ü",
|
||||
},
|
||||
])(
|
||||
"logs in via device flow with target %j, caches, and logs out",
|
||||
async (target) => {
|
||||
const server = new MockIdp();
|
||||
await server.start();
|
||||
try {
|
||||
const cacheDir = tempCacheDir();
|
||||
const issuerUrl = server.issuerUrl();
|
||||
|
||||
const config = { ...deviceConfig(issuerUrl, cacheDir), ...target };
|
||||
const session = new OAuthSession(config);
|
||||
const status = await session.login();
|
||||
expect(status.refreshable).toBe(true);
|
||||
expect(status.resource).toBe(config.resource);
|
||||
expect(status.audience).toBe(config.audience);
|
||||
expect(status.obtainedAt).toBeGreaterThan(0);
|
||||
expect(server.state.deviceAuthorizations).toBe(1);
|
||||
|
||||
// An independent session (a fresh "process") sees the cached login.
|
||||
const other = new OAuthSession(config);
|
||||
const cached = await other.status();
|
||||
expect(cached.refreshable).toBe(true);
|
||||
|
||||
const logout = await other.logout();
|
||||
expect(logout.removed).toBe(true);
|
||||
const again = await session.logout();
|
||||
expect(again.removed).toBe(false);
|
||||
expect((await session.status()).refreshable).toBe(false);
|
||||
|
||||
// Only the initial login used the interactive device flow.
|
||||
expect(server.state.deviceAuthorizations).toBe(1);
|
||||
expect(server.state.refreshGrants).toBe(0);
|
||||
expect(server.requests).toHaveLength(2);
|
||||
for (const params of server.requests) {
|
||||
expect(params.getAll("resource")).toEqual(
|
||||
config.resource === undefined ? [] : [config.resource],
|
||||
);
|
||||
expect(params.getAll("audience")).toEqual(
|
||||
config.audience === undefined ? [] : [config.audience],
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
},
|
||||
15000,
|
||||
);
|
||||
});
|
||||
|
||||
/** Mock IdP with discovery, device authorization, and rotating refresh. */
|
||||
class MockIdp {
|
||||
readonly requests: URLSearchParams[] = [];
|
||||
readonly state = {
|
||||
deviceAuthorizations: 0,
|
||||
refreshGrants: 0,
|
||||
accessTokensIssued: 0,
|
||||
currentRefresh: null as string | null,
|
||||
};
|
||||
private server?: http.Server;
|
||||
private port = 0;
|
||||
|
||||
issuerUrl(): string {
|
||||
return `http://127.0.0.1:${this.port}`;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
const server = http.createServer((req, res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (chunk) => chunks.push(chunk));
|
||||
req.on("end", () => {
|
||||
const body = Buffer.concat(chunks).toString();
|
||||
const params = new URLSearchParams(body);
|
||||
this.handle(req.url ?? "", params, res);
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => resolve());
|
||||
});
|
||||
const address = server.address();
|
||||
if (address && typeof address === "object") {
|
||||
this.port = address.port;
|
||||
}
|
||||
this.server = server;
|
||||
}
|
||||
|
||||
private handle(
|
||||
url: string,
|
||||
params: URLSearchParams,
|
||||
res: http.ServerResponse,
|
||||
): void {
|
||||
const respond = (status: number, payload: unknown): void => {
|
||||
const body = JSON.stringify(payload);
|
||||
res.writeHead(status, {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Length": Buffer.byteLength(body),
|
||||
});
|
||||
res.end(body);
|
||||
};
|
||||
|
||||
if (url === "/.well-known/openid-configuration") {
|
||||
respond(200, {
|
||||
// biome-ignore lint/style/useNamingConvention: OAuth wire format
|
||||
token_endpoint: `${this.issuerUrl()}/token`,
|
||||
// biome-ignore lint/style/useNamingConvention: OAuth wire format
|
||||
device_authorization_endpoint: `${this.issuerUrl()}/device`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (url === "/device" || url === "/token") {
|
||||
this.requests.push(params);
|
||||
}
|
||||
|
||||
if (url === "/device") {
|
||||
this.state.deviceAuthorizations += 1;
|
||||
respond(200, {
|
||||
// biome-ignore lint/style/useNamingConvention: OAuth wire format
|
||||
device_code: "device-code",
|
||||
// biome-ignore lint/style/useNamingConvention: OAuth wire format
|
||||
user_code: "ABCD-EFGH",
|
||||
// biome-ignore lint/style/useNamingConvention: OAuth wire format
|
||||
verification_uri: `${this.issuerUrl()}/verify`,
|
||||
// biome-ignore lint/style/useNamingConvention: OAuth wire format
|
||||
expires_in: 60,
|
||||
interval: 1,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (url === "/token") {
|
||||
const grantType = params.get("grant_type") ?? "";
|
||||
if (grantType === "refresh_token") {
|
||||
this.state.refreshGrants += 1;
|
||||
if (params.get("refresh_token") !== this.state.currentRefresh) {
|
||||
respond(400, { error: "invalid_grant" });
|
||||
return;
|
||||
}
|
||||
} else if (!grantType.includes("device_code")) {
|
||||
respond(400, { error: "unsupported_grant_type" });
|
||||
return;
|
||||
}
|
||||
this.state.accessTokensIssued += 1;
|
||||
const number = this.state.accessTokensIssued;
|
||||
const refresh = `refresh-${number}`;
|
||||
this.state.currentRefresh = refresh;
|
||||
respond(200, {
|
||||
// biome-ignore lint/style/useNamingConvention: OAuth wire format
|
||||
access_token: `access-${number}`,
|
||||
// biome-ignore lint/style/useNamingConvention: OAuth wire format
|
||||
refresh_token: refresh,
|
||||
// biome-ignore lint/style/useNamingConvention: OAuth wire format
|
||||
expires_in: 3600,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
respond(404, {});
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.server?.close();
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,8 @@ import packageJson = require("../package.json");
|
||||
|
||||
describe("package metadata", () => {
|
||||
it("requires Node.js type declarations compatible with the runtime", () => {
|
||||
expect(packageJson.engines.node).toBe(">= 18");
|
||||
expect(packageJson.peerDependencies["@types/node"]).toBe(">=18");
|
||||
expect(packageJson.engines.node).toBe(">= 22");
|
||||
expect(packageJson.peerDependencies["@types/node"]).toBe(">=22");
|
||||
expect(packageJson.peerDependenciesMeta["@types/node"]).toEqual({
|
||||
optional: true,
|
||||
});
|
||||
|
||||
@@ -110,6 +110,129 @@ describe("Query outputSchema", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Query empty results", () => {
|
||||
let tmpDir: tmp.DirResult;
|
||||
let table: Table;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = tmp.dirSync({ unsafeCleanup: true });
|
||||
const db = await connect(tmpDir.name);
|
||||
const schema = new Schema(
|
||||
[
|
||||
new Field("id", new Int64(), false),
|
||||
new Field(
|
||||
"text",
|
||||
new Utf8(),
|
||||
true,
|
||||
new Map([["description", "searchable text"]]),
|
||||
),
|
||||
new Field(
|
||||
"vector",
|
||||
new FixedSizeList(2, new Field("item", new Float32())),
|
||||
true,
|
||||
),
|
||||
],
|
||||
new Map([["source", "empty-query-regression"]]),
|
||||
);
|
||||
table = await db.createTable(
|
||||
"test",
|
||||
makeArrowTable([{ id: 1n, text: "hello", vector: [1, 2] }], { schema }),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
tmpDir.removeCallback();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "filtered scan",
|
||||
query: (table: Table) => table.query().where("id < 0"),
|
||||
fields: ["id", "text", "vector"],
|
||||
},
|
||||
{
|
||||
name: "zero limit",
|
||||
query: (table: Table) => table.query().limit(0),
|
||||
fields: ["id", "text", "vector"],
|
||||
},
|
||||
{
|
||||
name: "column projection",
|
||||
query: (table: Table) =>
|
||||
table.query().where("id < 0").select(["text", "id"]),
|
||||
fields: ["text", "id"],
|
||||
},
|
||||
{
|
||||
name: "dynamic projection",
|
||||
query: (table: Table) =>
|
||||
table.query().where("id < 0").select({ doubled: "id * 2" }),
|
||||
fields: ["doubled"],
|
||||
},
|
||||
{
|
||||
name: "filtered vector search",
|
||||
query: (table: Table) =>
|
||||
table.search([0.5, 0.5]).where("id < 0").limit(3),
|
||||
fields: ["id", "text", "vector", "_distance"],
|
||||
},
|
||||
{
|
||||
name: "vector projection with row id",
|
||||
query: (table: Table) =>
|
||||
table
|
||||
.vectorSearch([0.5, 0.5])
|
||||
.where("id < 0")
|
||||
.select(["id"])
|
||||
.withRowId(),
|
||||
fields: ["id", "_distance", "_rowid"],
|
||||
},
|
||||
{
|
||||
name: "fast search without an index",
|
||||
query: (table: Table) => table.search([0.5, 0.5]).fastSearch(),
|
||||
fields: ["id", "text", "vector", "_distance"],
|
||||
},
|
||||
{
|
||||
name: "take with no offsets",
|
||||
query: (table: Table) => table.takeOffsets([]).select(["text"]),
|
||||
fields: ["text"],
|
||||
},
|
||||
])("preserves the output schema for $name", async ({ query, fields }) => {
|
||||
const result = await query(table).toArrow({ maxBatchLength: 1 });
|
||||
|
||||
expect(result.numRows).toBe(0);
|
||||
expect(result.schema.fields.map((field) => field.name)).toEqual(fields);
|
||||
for (const name of fields) {
|
||||
expect(result.getChild(name)).not.toBeNull();
|
||||
expect(result.getChild(name)?.length).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves types, nullability, and metadata", async () => {
|
||||
const result = await table.query().where("id < 0").toArrow();
|
||||
const schema = await table.schema();
|
||||
|
||||
expect(result.schema).toEqual(schema);
|
||||
expect(result.getChild("id")?.type.toString()).toBe("Int64");
|
||||
expect(result.schema.fields[0].nullable).toBe(false);
|
||||
expect(result.schema.fields[1].metadata.get("description")).toBe(
|
||||
"searchable text",
|
||||
);
|
||||
expect(result.schema.metadata.get("source")).toBe("empty-query-regression");
|
||||
});
|
||||
|
||||
it("preserves the score column for full-text search with no matches", async () => {
|
||||
await table.createIndex("text", { config: Index.fts() });
|
||||
const result = await table
|
||||
.search("missing", "fts")
|
||||
.select(["id"])
|
||||
.toArrow();
|
||||
|
||||
expect(result.numRows).toBe(0);
|
||||
expect(result.schema.fields.map((field) => field.name)).toEqual([
|
||||
"id",
|
||||
"_score",
|
||||
]);
|
||||
expect(result.getChild("_score")?.type.toString()).toBe("Float32");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Search pagination", () => {
|
||||
let tmpDir: tmp.DirResult;
|
||||
let table: Table;
|
||||
@@ -404,3 +527,106 @@ describe("Query orderBy", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("IVF search effort", () => {
|
||||
let directory: tmp.DirResult;
|
||||
let table: Table;
|
||||
const vector = Array.from({ length: 16 }, (_, i) => (i === 0 ? 1 : 0));
|
||||
|
||||
beforeEach(async () => {
|
||||
directory = tmp.dirSync({ unsafeCleanup: true });
|
||||
const db = await connect(directory.name);
|
||||
const rows = Array.from({ length: 512 }, (_, id) => ({
|
||||
id,
|
||||
vector: Array.from(
|
||||
{ length: 16 },
|
||||
(_, j) =>
|
||||
(Math.floor(id / 32) === j ? 1 : 0) + Math.sin(id * 17 + j) * 0.001,
|
||||
),
|
||||
}));
|
||||
table = await db.createTable("effort", rows.slice(0, 256));
|
||||
await table.add(rows.slice(256));
|
||||
await table.createIndex("vector", {
|
||||
config: Index.ivfFlat({ numPartitions: 16 }),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
directory.removeCallback();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[null, 6],
|
||||
[0, 1],
|
||||
[0.25, 3],
|
||||
[0.5, 6],
|
||||
[0.75, 10],
|
||||
[1, 16],
|
||||
])(
|
||||
"preserves effort %s through execution and probe precedence",
|
||||
async (effort, expected) => {
|
||||
const partitions = async (query: ReturnType<Table["vectorSearch"]>) => {
|
||||
const plan = await query.limit(8).analyzePlan();
|
||||
const matches = [...plan.matchAll(/partitions_searched=(\d+)/g)];
|
||||
expect(matches.length).toBeGreaterThan(0);
|
||||
return matches.reduce((total, match) => total + Number(match[1]), 0);
|
||||
};
|
||||
expect(
|
||||
await partitions(table.vectorSearch(vector).searchEffort(effort)),
|
||||
).toBe(expected);
|
||||
expect(
|
||||
await partitions(
|
||||
table.vectorSearch(vector).nprobes(1).searchEffort(effort),
|
||||
),
|
||||
).toBe(effort === null ? 1 : expected);
|
||||
expect(
|
||||
await partitions(
|
||||
table.vectorSearch(vector).searchEffort(effort).nprobes(1),
|
||||
),
|
||||
).toBe(effort === null ? 1 : expected);
|
||||
expect(
|
||||
await partitions(
|
||||
table
|
||||
.vectorSearch(vector)
|
||||
.searchEffort(effort)
|
||||
.nprobes(1)
|
||||
.searchEffort(null),
|
||||
),
|
||||
).toBe(1);
|
||||
expect(
|
||||
await partitions(
|
||||
table.vectorSearch(vector).searchEffort(effort).maximumNprobes(2),
|
||||
),
|
||||
).toBeLessThanOrEqual(2);
|
||||
const actual = await table
|
||||
.vectorSearch(vector)
|
||||
.searchEffort(effort)
|
||||
.limit(8)
|
||||
.toArray();
|
||||
const exact = await table
|
||||
.vectorSearch(vector)
|
||||
.bypassVectorIndex()
|
||||
.limit(8)
|
||||
.toArray();
|
||||
const ids = new Set(exact.map((row) => row.id));
|
||||
expect(
|
||||
actual.filter((row) => ids.has(row.id)).length / 8,
|
||||
).toBeGreaterThanOrEqual(0.5);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
-0.1,
|
||||
1.1,
|
||||
Number.NaN,
|
||||
Number.POSITIVE_INFINITY,
|
||||
Number.NEGATIVE_INFINITY,
|
||||
])("rejects invalid effort %s before execution", (effort) => {
|
||||
expect(() => table.vectorSearch(vector).searchEffort(effort)).toThrow(
|
||||
"search_effort must be finite and in [0, 1]",
|
||||
);
|
||||
expect(() =>
|
||||
table.vectorSearch(vector).fullTextSearch("dog").searchEffort(effort),
|
||||
).toThrow("search_effort must be finite and in [0, 1]");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,13 +3,10 @@
|
||||
|
||||
import * as apiArrow from "apache-arrow";
|
||||
|
||||
import * as arrow15 from "apache-arrow-15";
|
||||
import * as arrow16 from "apache-arrow-16";
|
||||
import * as arrow17 from "apache-arrow-17";
|
||||
import * as arrow18 from "apache-arrow-18";
|
||||
|
||||
import * as tmp from "tmp";
|
||||
|
||||
import { arrowVersions } from "./arrow_versions";
|
||||
|
||||
import { connect } from "../lancedb";
|
||||
import {
|
||||
EmbeddingFunction,
|
||||
@@ -18,7 +15,7 @@ import {
|
||||
} from "../lancedb/embedding";
|
||||
import { getRegistry, register } from "../lancedb/embedding/registry";
|
||||
|
||||
describe.each([arrow15, arrow16, arrow17, arrow18])("LanceSchema", (arrow) => {
|
||||
describe.each(arrowVersions)("LanceSchema", (arrow) => {
|
||||
test("should preserve input order", async () => {
|
||||
const schema = LanceSchema({
|
||||
id: new arrow.Int32(),
|
||||
@@ -29,7 +26,7 @@ describe.each([arrow15, arrow16, arrow17, arrow18])("LanceSchema", (arrow) => {
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([arrow15, arrow16, arrow17, arrow18])("Registry", (arrow) => {
|
||||
describe.each(arrowVersions)("Registry", (arrow) => {
|
||||
let tmpDir: tmp.DirResult;
|
||||
beforeEach(() => {
|
||||
tmpDir = tmp.dirSync({ unsafeCleanup: true });
|
||||
@@ -240,7 +237,7 @@ describe("Registry.setVar", () => {
|
||||
return data.map(() => [1, 2, 3]);
|
||||
}
|
||||
embeddingDataType() {
|
||||
return new arrow18.Float32() as apiArrow.Float;
|
||||
return new apiArrow.Float32();
|
||||
}
|
||||
protected getSensitiveKeys() {
|
||||
return ["secretKey"];
|
||||
|
||||
+676
-23
@@ -3,19 +3,32 @@
|
||||
|
||||
import * as http from "http";
|
||||
import { RequestListener } from "http";
|
||||
import packageJson = require("../package.json");
|
||||
import {
|
||||
ClientAuthMethod,
|
||||
ClientConfig,
|
||||
Connection,
|
||||
ConnectionOptions,
|
||||
OAuthConfig,
|
||||
OAuthFlowType,
|
||||
TlsConfig,
|
||||
connect,
|
||||
} from "../lancedb";
|
||||
import {
|
||||
Table as ArrowTable,
|
||||
Field,
|
||||
Int64,
|
||||
RecordBatch,
|
||||
Schema,
|
||||
tableToIPC,
|
||||
} from "../lancedb/arrow";
|
||||
import {
|
||||
HeaderProvider,
|
||||
OAuthHeaderProvider,
|
||||
StaticHeaderProvider,
|
||||
} from "../lancedb/header";
|
||||
import { Index } from "../lancedb/indices";
|
||||
import { LocalTable } from "../lancedb/table";
|
||||
|
||||
// Test-only header providers
|
||||
class CustomProvider extends HeaderProvider {
|
||||
@@ -70,26 +83,329 @@ async function withMockDatabase(
|
||||
try {
|
||||
await callback(db);
|
||||
} finally {
|
||||
server.close();
|
||||
// `close()` alone leaves the port bound until keep-alive sockets drain, so
|
||||
// a single failing test would cascade into EADDRINUSE for every test after
|
||||
// it. Destroy the connections and wait for the port to actually be free.
|
||||
await new Promise<void>((resolve) => {
|
||||
server.closeAllConnections();
|
||||
server.close(() => resolve());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
describe("remote connection", () => {
|
||||
it("refuses materialized views before issuing any request", async () => {
|
||||
const paths: string[] = [];
|
||||
it("reports unsupported operations with backend-neutral errors", async () => {
|
||||
await withMockDatabase(
|
||||
(req, res) => {
|
||||
paths.push(req.url ?? "");
|
||||
expect(req.url).toBe("/v1/table/test/describe/");
|
||||
res.writeHead(200, { "Content-Type": "application/json" }).end(
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
schema: {
|
||||
fields: [
|
||||
{ name: "id", type: { type: "int64" }, nullable: false },
|
||||
{
|
||||
name: "vector",
|
||||
type: {
|
||||
type: "fixed_size_list",
|
||||
fields: [
|
||||
{ name: "item", type: { type: "float" }, nullable: true },
|
||||
],
|
||||
length: 2,
|
||||
},
|
||||
nullable: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
async (db) => {
|
||||
const table = (await db.openTable("test")) as LocalTable;
|
||||
const cases: [() => Promise<unknown>, string][] = [
|
||||
[() => table.usesV2ManifestPaths(), "uses_v2_manifest_paths"],
|
||||
[() => table.migrateManifestPathsV2(), "migrate_manifest_paths_v2"],
|
||||
[
|
||||
() => table.setUnenforcedPrimaryKey("id"),
|
||||
"set_unenforced_primary_key",
|
||||
],
|
||||
[() => table.optimize(), "optimize"],
|
||||
[
|
||||
() => table.createIndex("vector", { config: Index.hnswPq() }),
|
||||
"IVF_HNSW_PQ",
|
||||
],
|
||||
];
|
||||
for (const [run, operation] of cases) {
|
||||
let message = `LanceDBError: not supported: ${operation} is not supported for remote tables.`;
|
||||
if (operation === "IVF_HNSW_PQ") {
|
||||
message += " Please use IVF_HNSW_SQ instead.";
|
||||
}
|
||||
await expect(run()).rejects.toThrow(new Error(message));
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects nprobes(0) before sending a query", async () => {
|
||||
const requests: string[] = [];
|
||||
await withMockDatabase(
|
||||
(req, res) => {
|
||||
requests.push(req.url ?? "");
|
||||
if (req.url === "/v1/table/test/describe/") {
|
||||
res
|
||||
.writeHead(200, { "Content-Type": "application/json" })
|
||||
.end(JSON.stringify({ version: 1, schema: { fields: [] } }));
|
||||
} else {
|
||||
res.writeHead(404).end();
|
||||
}
|
||||
},
|
||||
async (db) => {
|
||||
const table = await db.openTable("test");
|
||||
expect(() => table.vectorSearch([0, 0]).nprobes(0)).toThrow(
|
||||
"Invalid input, nprobes must be greater than 0",
|
||||
);
|
||||
expect(() =>
|
||||
table.query().nearestTo([0, 0]).fullTextSearch("dog").nprobes(0),
|
||||
).toThrow("Invalid input, nprobes must be greater than 0");
|
||||
await expect(
|
||||
table
|
||||
.vectorSearch(Promise.resolve([0, 0]))
|
||||
.nprobes(0)
|
||||
.toArrow(),
|
||||
).rejects.toThrow("Invalid input, nprobes must be greater than 0");
|
||||
},
|
||||
);
|
||||
expect(requests).toEqual(["/v1/table/test/describe/"]);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"preserves an empty query's schema with an empty batch: %s",
|
||||
async (withEmptyBatch) => {
|
||||
const schema = new Schema(
|
||||
[new Field("doubled", new Int64(), false)],
|
||||
new Map([["source", "query-output"]]),
|
||||
);
|
||||
const result = new ArrowTable(
|
||||
schema,
|
||||
withEmptyBatch ? [new RecordBatch(schema, undefined)] : [],
|
||||
);
|
||||
const response = Buffer.from(tableToIPC(result, "stream"));
|
||||
let queryRequests = 0;
|
||||
|
||||
await withMockDatabase(
|
||||
(req, res) => {
|
||||
if (req.url?.endsWith("/describe/")) {
|
||||
res.writeHead(200, { "Content-Type": "application/json" }).end(
|
||||
JSON.stringify({
|
||||
name: "items",
|
||||
version: 1,
|
||||
schema: {
|
||||
fields: [
|
||||
{ name: "id", type: { type: "int64" }, nullable: false },
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
} else if (req.url?.endsWith("/query/")) {
|
||||
queryRequests++;
|
||||
req.resume();
|
||||
req.on("end", () => {
|
||||
res
|
||||
.writeHead(200, {
|
||||
"Content-Type": "application/vnd.apache.arrow.stream",
|
||||
})
|
||||
.end(response);
|
||||
});
|
||||
} else {
|
||||
res.writeHead(404).end();
|
||||
}
|
||||
},
|
||||
async (db) => {
|
||||
const table = await db.openTable("items");
|
||||
const result = await table
|
||||
.query()
|
||||
.where("id < 0")
|
||||
.select({ doubled: "id * 2" })
|
||||
.toArrow();
|
||||
|
||||
expect(result.numRows).toBe(0);
|
||||
expect(result.schema).toEqual(schema);
|
||||
expect(result.getChild("doubled")?.length).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
expect(queryRequests).toBe(1);
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects the external blob opt-in without blocking regular adds", async () => {
|
||||
let insertRequests = 0;
|
||||
const describeRequests: string[] = [];
|
||||
|
||||
await withMockDatabase(
|
||||
(req, res) => {
|
||||
const requestPath = req.url ?? "";
|
||||
if (requestPath.endsWith("/describe/")) {
|
||||
describeRequests.push(requestPath);
|
||||
res.writeHead(200, { "Content-Type": "application/json" }).end(
|
||||
JSON.stringify({
|
||||
name: "items",
|
||||
version: 1,
|
||||
schema: {
|
||||
fields: [
|
||||
{
|
||||
name: "id",
|
||||
type: { type: "int64" },
|
||||
nullable: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (requestPath.endsWith("/insert/")) {
|
||||
insertRequests++;
|
||||
req.resume();
|
||||
req.on("end", () => {
|
||||
res
|
||||
.writeHead(200, { "Content-Type": "application/json" })
|
||||
.end(JSON.stringify({ version: insertRequests + 1 }));
|
||||
});
|
||||
return;
|
||||
}
|
||||
res.writeHead(404).end();
|
||||
},
|
||||
async (db) => {
|
||||
await expect(db.openMaterializedView("secret_table")).rejects.toThrow(
|
||||
/only on local databases/,
|
||||
const table = await db.openTable("items");
|
||||
|
||||
await expect(
|
||||
table.add([{ id: 1n }], {
|
||||
allowExternalBlobOutsideBases: true,
|
||||
}),
|
||||
).rejects.toThrow("only supported on local tables");
|
||||
expect(insertRequests).toBe(0);
|
||||
|
||||
await expect(table.add([{ id: 2n }])).resolves.toMatchObject({
|
||||
version: 2,
|
||||
});
|
||||
await expect(
|
||||
table.add([{ id: 3n }], {
|
||||
allowExternalBlobOutsideBases: false,
|
||||
}),
|
||||
).resolves.toMatchObject({ version: 3 });
|
||||
},
|
||||
);
|
||||
|
||||
expect(describeRequests.length).toBeGreaterThan(0);
|
||||
expect(insertRequests).toBe(2);
|
||||
});
|
||||
|
||||
it("lists materialized views through the namespace route", async () => {
|
||||
await withMockDatabase(
|
||||
(req, res) => {
|
||||
expect(req.method).toBe("GET");
|
||||
expect(req.url).toBe("/v1/namespace/$/materialized_view/list");
|
||||
res
|
||||
.writeHead(200, { "content-type": "application/json" })
|
||||
.end(JSON.stringify({ views: ["daily_sales"] }));
|
||||
},
|
||||
async (db) => {
|
||||
expect(await collect(db.listMaterializedViews())).toEqual([
|
||||
"daily_sales",
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("creates a view and decodes the schema it resolved to", async () => {
|
||||
await withMockDatabase(
|
||||
(req, res) => {
|
||||
expect(req.method).toBe("POST");
|
||||
expect(req.url).toBe("/v1/view/analytics$adults/create");
|
||||
res.writeHead(200, { "content-type": "application/json" }).end(
|
||||
JSON.stringify({
|
||||
name: "adults",
|
||||
namespace: ["analytics"],
|
||||
query: "SELECT name FROM people",
|
||||
// biome-ignore lint/style/useNamingConvention: the wire field is snake_case
|
||||
default_database: "db",
|
||||
// biome-ignore lint/style/useNamingConvention: the wire field is snake_case
|
||||
default_namespace: ["analytics"],
|
||||
schema: {
|
||||
fields: [
|
||||
{ name: "name", nullable: true, type: { type: "utf8" } },
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
await expect(db.listMaterializedViews()).rejects.toThrow(
|
||||
/only on local databases/,
|
||||
);
|
||||
expect(paths).toEqual([]);
|
||||
},
|
||||
async (db) => {
|
||||
const view = await db.createView("adults", "SELECT name FROM people", [
|
||||
"analytics",
|
||||
]);
|
||||
expect(view.name).toBe("adults");
|
||||
expect(view.namespacePath).toEqual(["analytics"]);
|
||||
expect(view.query).toBe("SELECT name FROM people");
|
||||
expect(view.defaultDatabase).toBe("db");
|
||||
expect(view.defaultNamespacePath).toEqual(["analytics"]);
|
||||
expect(view.schema.fields.map((f) => f.name)).toEqual(["name"]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("lists and drops views through their own routes", async () => {
|
||||
await withMockDatabase(
|
||||
(req, res) => {
|
||||
expect(req.url).toBe("/v1/namespace/$/view/list");
|
||||
res
|
||||
.writeHead(200, { "content-type": "application/json" })
|
||||
.end(JSON.stringify({ views: ["adults"] }));
|
||||
},
|
||||
async (db) => {
|
||||
expect(await collect(db.listViews())).toEqual(["adults"]);
|
||||
},
|
||||
);
|
||||
|
||||
await withMockDatabase(
|
||||
(req, res) => {
|
||||
expect(req.method).toBe("POST");
|
||||
expect(req.url).toBe("/v1/view/adults/drop");
|
||||
res.writeHead(200, { "content-type": "application/json" }).end("{}");
|
||||
},
|
||||
async (db) => {
|
||||
await db.dropView("adults");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("reports the cleanup job when a view drop is accepted", async () => {
|
||||
await withMockDatabase(
|
||||
(req, res) => {
|
||||
expect(req.method).toBe("POST");
|
||||
expect(req.url).toBe("/v1/view/adults/drop");
|
||||
res
|
||||
.writeHead(202, { "content-type": "application/json" })
|
||||
.end('{"job_id": "j1-do-abc"}');
|
||||
},
|
||||
async (db) => {
|
||||
const job = await db.dropViewAsync("adults");
|
||||
expect(job.id).toBe("j1-do-abc");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a finished job when a view drop had nothing to delete", async () => {
|
||||
await withMockDatabase(
|
||||
(req, res) => {
|
||||
expect(req.url).toBe("/v1/view/adults/drop");
|
||||
res.writeHead(200, { "content-type": "application/json" }).end("{}");
|
||||
},
|
||||
async (db) => {
|
||||
// A 200 means the name was not bound, so there is no cleanup to wait on.
|
||||
const job = await db.dropViewAsync("adults");
|
||||
expect(job.id).toBeNull();
|
||||
await job.wait();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -131,7 +447,7 @@ describe("remote connection", () => {
|
||||
(req, res) => {
|
||||
expect(req.headers["x-api-key"]).toEqual("fake");
|
||||
expect(req.headers["user-agent"]).toEqual(
|
||||
`LanceDB-Node-Client/${process.env.npm_package_version}`,
|
||||
`LanceDB-Node-Client/${packageJson.version}`,
|
||||
);
|
||||
|
||||
const body = JSON.stringify({ tables: [] });
|
||||
@@ -189,6 +505,66 @@ describe("remote connection", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("lists the rows a Function refresh skipped", async () => {
|
||||
const bodies: unknown[] = [];
|
||||
await withMockDatabase(
|
||||
(req, res) => {
|
||||
const path = req.url ?? "";
|
||||
if (path.endsWith("/describe/")) {
|
||||
res.writeHead(200, { "Content-Type": "application/json" }).end(
|
||||
JSON.stringify({
|
||||
name: "docs",
|
||||
version: 1,
|
||||
schema: { fields: [] },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (path === "/v1/table/docs/errors") {
|
||||
let body = "";
|
||||
req.on("data", (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
req.on("end", () => {
|
||||
bodies.push(JSON.parse(body));
|
||||
res.writeHead(200, { "Content-Type": "application/json" }).end(
|
||||
`{"records": [{"job_id": "j-7", "fragment_id": 3, "row_offset": 9,
|
||||
"column": "embedding", "function": "embed", "function_version": "2",
|
||||
"table_version": 11, "error_type": "ValueError",
|
||||
"error_message": "bad input 'x'", "created_at_millis": 1700000000000}],
|
||||
"fragments": [{"job_id": "j-7", "fragment_id": 4, "rows_skipped": 500,
|
||||
"rows_recorded": 100}], "truncated": true}`,
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
res.writeHead(404).end();
|
||||
},
|
||||
async (db) => {
|
||||
const table = await db.openTable("docs");
|
||||
const errors = await table.functionErrors({
|
||||
jobId: "j-7",
|
||||
column: "embedding",
|
||||
limit: 2,
|
||||
});
|
||||
expect(errors.truncated).toBe(true);
|
||||
expect(errors.records.map((r) => r.errorMessage)).toEqual([
|
||||
"bad input 'x'",
|
||||
]);
|
||||
expect(errors.records[0].rowOffset).toBe(9);
|
||||
expect(errors.fragments[0].rowsSkipped).toBe(500);
|
||||
await table.functionErrors();
|
||||
await expect(table.functionErrors({ limit: -1 })).rejects.toThrow(
|
||||
"limit must be a non-negative integer",
|
||||
);
|
||||
},
|
||||
);
|
||||
expect(bodies).toEqual([
|
||||
JSON.parse('{"job_id": "j-7", "column": "embedding", "limit": 2}'),
|
||||
{},
|
||||
]);
|
||||
});
|
||||
|
||||
it("surfaces JSON server errors from remote table operations", async () => {
|
||||
await withMockDatabase(
|
||||
(req, res) => {
|
||||
@@ -435,6 +811,40 @@ describe("remote connection", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
describe("OAuthConfig", () => {
|
||||
it("should expose client auth method values", () => {
|
||||
expect(ClientAuthMethod.None).toBe("none");
|
||||
expect(ClientAuthMethod.ClientSecretBasic).toBe("client_secret_basic");
|
||||
expect(ClientAuthMethod.ClientSecretPost).toBe("client_secret_post");
|
||||
});
|
||||
|
||||
it("should accept a confidential client with basic auth", () => {
|
||||
const config: OAuthConfig = {
|
||||
issuerUrl: "https://issuer.example.com",
|
||||
clientId: "client-id",
|
||||
clientSecret: "secret",
|
||||
scopes: ["openid"],
|
||||
flow: OAuthFlowType.AuthorizationCode,
|
||||
clientAuthMethod: ClientAuthMethod.ClientSecretBasic,
|
||||
};
|
||||
|
||||
expect(config.clientAuthMethod).toBe(ClientAuthMethod.ClientSecretBasic);
|
||||
});
|
||||
|
||||
it("should accept a public PKCE client without auth method or secret", () => {
|
||||
const config: OAuthConfig = {
|
||||
issuerUrl: "https://issuer.example.com",
|
||||
clientId: "client-id",
|
||||
scopes: ["openid"],
|
||||
flow: OAuthFlowType.AuthorizationCode,
|
||||
usePkce: true,
|
||||
};
|
||||
|
||||
expect(config.clientSecret).toBeUndefined();
|
||||
expect(config.clientAuthMethod).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TlsConfig", () => {
|
||||
it("should create TlsConfig with all fields", () => {
|
||||
const tlsConfig: TlsConfig = {
|
||||
@@ -932,6 +1342,7 @@ describe("remote connection jobs surface", () => {
|
||||
const { tableFromArrays, tableToIPC } = await import("apache-arrow");
|
||||
const eventsTable = tableFromArrays({ state: ["created", "succeeded"] });
|
||||
const eventsBody = Buffer.from(tableToIPC(eventsTable, "stream"));
|
||||
const queryEventsPayloads: Record<string, unknown>[] = [];
|
||||
|
||||
await withMockDatabase(
|
||||
(req, res) => {
|
||||
@@ -960,6 +1371,16 @@ describe("remote connection jobs surface", () => {
|
||||
);
|
||||
}
|
||||
} else if (req.url === "/v1/jobs/describe") {
|
||||
if (payload["job_id"] === "job-2") {
|
||||
res
|
||||
.writeHead(200, { "Content-Type": "application/json" })
|
||||
.end(
|
||||
'{"job_id": "job-2", "job_type": "refresh_column", ' +
|
||||
'"job_state": "DONE", "creation_ms": 2000, ' +
|
||||
'"result": {"rows_assigned": 1000000}}',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (payload["job_id"] !== "job-1") {
|
||||
res.writeHead(404).end("no such job");
|
||||
return;
|
||||
@@ -980,7 +1401,26 @@ describe("remote connection jobs surface", () => {
|
||||
res
|
||||
.writeHead(200, { "Content-Type": "application/json" })
|
||||
.end('{"job_id": "job-1"}');
|
||||
} else if (req.url === "/v1/jobs/pause") {
|
||||
if (payload["job_id"] !== "job-1") {
|
||||
res.writeHead(404).end("no such job");
|
||||
return;
|
||||
}
|
||||
res
|
||||
.writeHead(200, { "Content-Type": "application/json" })
|
||||
.end('{"job_id": "job-1", "paused": true}');
|
||||
} else if (req.url === "/v1/jobs/resume") {
|
||||
if (payload["job_id"] !== "job-1") {
|
||||
res.writeHead(404).end("no such job");
|
||||
return;
|
||||
}
|
||||
res
|
||||
.writeHead(200, { "Content-Type": "application/json" })
|
||||
.end(
|
||||
'{"job_id": "job-1", "resumed": false, "still_pausing": true}',
|
||||
);
|
||||
} else if (req.url === "/v1/jobs/query_events") {
|
||||
queryEventsPayloads.push(payload);
|
||||
res
|
||||
.writeHead(200, {
|
||||
"Content-Type": "application/vnd.apache.arrow.stream",
|
||||
@@ -992,30 +1432,243 @@ describe("remote connection jobs surface", () => {
|
||||
});
|
||||
},
|
||||
async (db) => {
|
||||
const jobs = await db.listJobs();
|
||||
const jobs = await collect(db.listJobs());
|
||||
expect(jobs.map((job) => job.jobId)).toEqual(["job-1", "job-2"]);
|
||||
expect(jobs[0].state).toEqual("running");
|
||||
expect(jobs[1].state).toEqual("finished");
|
||||
|
||||
const description = await db.getJob("job-1");
|
||||
expect(description?.state).toEqual("failed");
|
||||
expect(JSON.parse(description?.specJson ?? "")).toEqual({
|
||||
column: "vec",
|
||||
});
|
||||
expect(description?.failure?.message).toEqual("worker died");
|
||||
expect(await db.getJob("missing")).toBeNull();
|
||||
|
||||
expect(await db.cancelJob("job-1")).toBe(true);
|
||||
expect(await db.cancelJob("missing")).toBe(false);
|
||||
|
||||
const history = await db.jobHistory("job-1");
|
||||
expect(history.numRows).toEqual(2);
|
||||
expect(await db.pauseJob("job-1")).toEqual("pausing");
|
||||
expect(await db.resumeJob("job-1")).toEqual("still_pausing");
|
||||
|
||||
const job = db.job("job-1");
|
||||
// Opening a job hands back a populated handle; a missing one rejects.
|
||||
await expect(db.openJob("missing")).rejects.toThrow("not found");
|
||||
const finished = await db.openJob("job-2");
|
||||
expect(finished.state).toEqual("finished");
|
||||
expect(finished.result).toEqual({
|
||||
// biome-ignore lint/style/useNamingConvention: snake_case mandated by the server wire format
|
||||
rows_assigned: 1000000,
|
||||
});
|
||||
|
||||
const job = await db.openJob("job-1");
|
||||
expect(job.id).toEqual("job-1");
|
||||
|
||||
// openJob already populated the handle; refresh() re-reads it.
|
||||
expect(job.state).toEqual("failed");
|
||||
await job.refresh();
|
||||
expect(job.state).toEqual("failed");
|
||||
expect(job.jobType).toEqual("create_index");
|
||||
expect(job.creationMs).toEqual(1000);
|
||||
expect(job.spec).toEqual({ column: "vec" });
|
||||
expect(job.result).toBeNull();
|
||||
expect(job.failure?.message).toEqual("worker died");
|
||||
|
||||
// The handle reaches its own events, supplying its job id.
|
||||
const jobEvents = await job.events({
|
||||
limit: 500,
|
||||
filter: "state = 'claim_complete'",
|
||||
});
|
||||
expect(jobEvents.numRows).toEqual(2);
|
||||
expect(queryEventsPayloads.pop()).toEqual({
|
||||
// biome-ignore lint/style/useNamingConvention: snake_case mandated by the server wire format
|
||||
job_id: "job-1",
|
||||
limit: 500,
|
||||
filter: "state = 'claim_complete'",
|
||||
});
|
||||
|
||||
// Printing lays every known field out on its own line, with the JSON
|
||||
// payloads indented rather than crammed onto one line.
|
||||
expect(`${job}`).toEqual(
|
||||
[
|
||||
"Job(",
|
||||
' id="job-1",',
|
||||
' state="failed",',
|
||||
' jobType="create_index",',
|
||||
" creationMs=1000,",
|
||||
" spec={",
|
||||
' "column": "vec"',
|
||||
" },",
|
||||
" failure={",
|
||||
' "phase": "execute",',
|
||||
' "message": "worker died",',
|
||||
' "retryable": true',
|
||||
" },",
|
||||
")",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
expect(await job.status()).toEqual("failed");
|
||||
await expect(job.wait()).rejects.toThrow("worker died");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves numDeletedRows absent when the server omits it", async () => {
|
||||
// A server that predates num_deleted_rows says nothing about deleted rows
|
||||
const statsReply =
|
||||
'{"total_bytes":1,"num_rows":3,"num_indices":0,"fragment_stats":' +
|
||||
'{"num_fragments":1,"num_small_fragments":0,' +
|
||||
'"lengths":{"min":3,"max":3,"mean":3,"p25":3,"p50":3,"p75":3,"p99":3}}}';
|
||||
|
||||
await withMockDatabase(
|
||||
(req, res) => {
|
||||
const path = req.url ?? "";
|
||||
const body = path.endsWith("/describe/")
|
||||
? JSON.stringify({ name: "t", version: 1, schema: { fields: [] } })
|
||||
: statsReply;
|
||||
res.writeHead(200, { "Content-Type": "application/json" }).end(body);
|
||||
},
|
||||
async (db) => {
|
||||
const stats = await (await db.openTable("t")).stats();
|
||||
expect("numDeletedRows" in stats).toBe(false);
|
||||
expect(stats.numDeletedRows).toBeUndefined();
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
async function collect<T>(items: AsyncIterable<T>): Promise<T[]> {
|
||||
const result: T[] = [];
|
||||
for await (const item of items) result.push(item);
|
||||
return result;
|
||||
}
|
||||
|
||||
describe("lazy resource listings", () => {
|
||||
for (const kind of ["views", "materializedViews", "jobs"] as const) {
|
||||
const listing = (
|
||||
db: Connection,
|
||||
options: { pageToken?: string; pageLimit?: number } = {},
|
||||
) => {
|
||||
if (kind === "jobs") return db.listJobs(options);
|
||||
if (kind === "views") return db.listViews(["team"], options);
|
||||
return db.listMaterializedViews(options);
|
||||
};
|
||||
const page = (names: string[], token?: string) => {
|
||||
const items =
|
||||
kind === "jobs"
|
||||
? names.map((name) => ({
|
||||
// biome-ignore lint/style/useNamingConvention: server wire format
|
||||
job_id: name,
|
||||
table: "t",
|
||||
// biome-ignore lint/style/useNamingConvention: server wire format
|
||||
job_type: "create_index",
|
||||
state: "in_progress",
|
||||
// biome-ignore lint/style/useNamingConvention: server wire format
|
||||
created_at_millis: 1,
|
||||
}))
|
||||
: names;
|
||||
return {
|
||||
[kind === "jobs" ? "jobs" : "views"]: items,
|
||||
// biome-ignore lint/style/useNamingConvention: server wire format
|
||||
page_token: token,
|
||||
};
|
||||
};
|
||||
|
||||
it(`${kind} fetches lazily, skips empty pages, and resumes`, async () => {
|
||||
const requests: { url: string; body: Record<string, unknown> }[] = [];
|
||||
const responses = [
|
||||
page(["a", "b"], "empty"),
|
||||
page([], "last"),
|
||||
page(["c", "d"], ""),
|
||||
];
|
||||
await withMockDatabase(
|
||||
async (req, res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of req) chunks.push(Buffer.from(chunk));
|
||||
const body = Buffer.concat(chunks).toString();
|
||||
requests.push({
|
||||
url: req.url ?? "",
|
||||
body: body ? JSON.parse(body) : {},
|
||||
});
|
||||
res
|
||||
.writeHead(200, { "content-type": "application/json" })
|
||||
.end(JSON.stringify(responses.shift()));
|
||||
},
|
||||
async (db) => {
|
||||
const items = listing(db, { pageToken: "start/token", pageLimit: 2 });
|
||||
expect(requests).toHaveLength(0);
|
||||
expect(items.pageToken()).toBe("start/token");
|
||||
expect(items.numPageResults()).toBe(0);
|
||||
expect((await items.next()).done).toBe(false);
|
||||
expect(items.numPageResults()).toBe(1);
|
||||
expect(items.pageToken()).toBe("empty");
|
||||
await items.next();
|
||||
expect(requests).toHaveLength(1);
|
||||
const resumed = listing(db, {
|
||||
pageToken: items.pageToken(),
|
||||
pageLimit: 2,
|
||||
});
|
||||
await resumed.next();
|
||||
expect(resumed.numPageResults()).toBe(1);
|
||||
expect(resumed.pageToken()).toBeUndefined();
|
||||
expect(requests).toHaveLength(3);
|
||||
await resumed.next();
|
||||
expect((await resumed.next()).done).toBe(true);
|
||||
expect((await resumed.next()).done).toBe(true);
|
||||
for (const [i, request] of requests.entries()) {
|
||||
const token = ["start/token", "empty", "last"][i];
|
||||
const url = new URL(request.url, "http://localhost");
|
||||
if (kind === "jobs") {
|
||||
expect(url.pathname).toBe("/v1/jobs/list");
|
||||
expect(request.body).toEqual({
|
||||
// biome-ignore lint/style/useNamingConvention: server wire format
|
||||
page_token: token,
|
||||
limit: 2,
|
||||
});
|
||||
} else {
|
||||
expect(url.pathname).toBe(
|
||||
kind === "views"
|
||||
? "/v1/namespace/team/view/list"
|
||||
: "/v1/namespace/$/materialized_view/list",
|
||||
);
|
||||
expect(url.searchParams.get("page_token")).toBe(token);
|
||||
expect(url.searchParams.get("limit")).toBe("2");
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it(`${kind} retains failed tokens and serializes concurrent advances`, async () => {
|
||||
let requests = 0;
|
||||
await withMockDatabase(
|
||||
(_req, res) => {
|
||||
requests += 1;
|
||||
res
|
||||
.writeHead(requests === 1 ? 401 : 200, {
|
||||
"content-type": "application/json",
|
||||
})
|
||||
.end(JSON.stringify(requests === 1 ? {} : page(["a", "b"])));
|
||||
},
|
||||
async (db) => {
|
||||
const failed = listing(db, { pageToken: "retry" });
|
||||
await expect(failed.next()).rejects.toThrow();
|
||||
expect(failed.pageToken()).toBe("retry");
|
||||
expect((await failed.next()).done).toBe(true);
|
||||
expect(requests).toBe(1);
|
||||
const resumed = listing(db, { pageToken: failed.pageToken() });
|
||||
const results = await Promise.all([
|
||||
resumed.next(),
|
||||
resumed.next(),
|
||||
resumed.next(),
|
||||
]);
|
||||
expect(results.filter((result) => result.done)).toHaveLength(1);
|
||||
expect(requests).toBe(2);
|
||||
for (const limit of [
|
||||
0,
|
||||
-1,
|
||||
1.5,
|
||||
Number.NaN,
|
||||
Number.POSITIVE_INFINITY,
|
||||
2147483648,
|
||||
]) {
|
||||
expect(() => listing(db, { pageLimit: limit })).toThrow("limit");
|
||||
}
|
||||
expect(requests).toBe(2);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
import * as arrow from "../lancedb/arrow";
|
||||
import { sanitizeField, sanitizeMap, sanitizeType } from "../lancedb/sanitize";
|
||||
import {
|
||||
sanitizeField,
|
||||
sanitizeMap,
|
||||
sanitizeSchema,
|
||||
sanitizeType,
|
||||
} from "../lancedb/sanitize";
|
||||
import { arrow21 } from "./arrow_versions";
|
||||
|
||||
describe("sanitize", function () {
|
||||
describe("sanitizeType function", function () {
|
||||
@@ -59,6 +65,8 @@ describe("sanitize", function () {
|
||||
["durationmicrosecond", new arrow.DurationMicrosecond()],
|
||||
["durationmillisecond", new arrow.DurationMillisecond()],
|
||||
["durationsecond", new arrow.DurationSecond()],
|
||||
["largebinary", new arrow.LargeBinary()],
|
||||
["largeutf8", new arrow.LargeUtf8()],
|
||||
] as const;
|
||||
|
||||
it.each(allTypeNameTestCases)(
|
||||
@@ -89,6 +97,110 @@ describe("sanitize", function () {
|
||||
"Unrecognized type name in schema: invalid_type",
|
||||
);
|
||||
});
|
||||
|
||||
// These types only exist in Arrow 20 (IntervalMonthDayNano) and 21 (the
|
||||
// rest), so the objects come from a second copy of the newest release. The
|
||||
// library itself may be running on an older Arrow (see the CI matrix), in
|
||||
// which case sanitizing them has to fail with a clear message instead.
|
||||
const hostArrow = arrow as unknown as Record<string, unknown>;
|
||||
const hostSupportsNewerTypes = typeof hostArrow.Utf8View === "function";
|
||||
const whenSupported = hostSupportsNewerTypes ? describe : describe.skip;
|
||||
const whenUnsupported = hostSupportsNewerTypes ? describe.skip : describe;
|
||||
|
||||
const newerTypeTestCases = [
|
||||
["Utf8View", () => new arrow21.Utf8View()],
|
||||
["BinaryView", () => new arrow21.BinaryView()],
|
||||
["IntervalMonthDayNano", () => new arrow21.IntervalMonthDayNano()],
|
||||
[
|
||||
"LargeList",
|
||||
() =>
|
||||
new arrow21.LargeList(new arrow21.Field("item", new arrow21.Utf8())),
|
||||
],
|
||||
] as const;
|
||||
|
||||
whenSupported("types added in Arrow 20 and 21", function () {
|
||||
it.each(newerTypeTestCases)(
|
||||
"should sanitize a %s type from another copy of Arrow",
|
||||
function (name, makeForeign) {
|
||||
const foreign = makeForeign();
|
||||
const result = sanitizeType(foreign);
|
||||
// Interval subclasses share `Type.Interval`, so the sanitized type
|
||||
// is the generic `Interval` carrying the same unit, as for the
|
||||
// other units.
|
||||
const expected = name === "IntervalMonthDayNano" ? "Interval" : name;
|
||||
expect(result).toBeInstanceOf(hostArrow[expected] as () => unknown);
|
||||
expect(result.typeId).toBe(foreign.typeId);
|
||||
expect(result.toString()).toBe(foreign.toString());
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["binaryview", "BinaryView"],
|
||||
["utf8view", "Utf8View"],
|
||||
["intervalmonthdaynano", "Interval"],
|
||||
])('should map type name "%s" to %s', function (name, expected) {
|
||||
expect(sanitizeType(name)).toBeInstanceOf(
|
||||
hostArrow[expected] as () => unknown,
|
||||
);
|
||||
});
|
||||
|
||||
it("should sanitize the child field of a LargeList", function () {
|
||||
const foreign = new arrow21.LargeList(
|
||||
new arrow21.Field("item", new arrow21.Int32(), false),
|
||||
);
|
||||
const result = sanitizeType(foreign) as arrow.List;
|
||||
expect(result.children[0]).toBeInstanceOf(arrow.Field);
|
||||
expect(result.children[0].name).toBe("item");
|
||||
expect(result.children[0].nullable).toBe(false);
|
||||
const child = result.children[0].type;
|
||||
expect(arrow.DataType.isInt(child)).toBe(true);
|
||||
expect((child as arrow.Int).bitWidth).toBe(32);
|
||||
expect((child as arrow.Int).isSigned).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
whenUnsupported("types newer than the installed Arrow", function () {
|
||||
it.each(
|
||||
newerTypeTestCases.filter(([name]) => name !== "IntervalMonthDayNano"),
|
||||
)(
|
||||
"should explain that %s needs a newer apache-arrow",
|
||||
function (name, makeForeign) {
|
||||
expect(() => sanitizeType(makeForeign())).toThrow(
|
||||
`The Arrow type ${name} requires apache-arrow`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("should sanitize IntervalMonthDayNano to a generic Interval", () => {
|
||||
// The subclass shares `Type.Interval`, which every supported Arrow
|
||||
// release has; only the unit is new.
|
||||
const result = sanitizeType(new arrow21.IntervalMonthDayNano());
|
||||
expect(result).toBeInstanceOf(arrow.Interval);
|
||||
expect((result as arrow.Interval).unit).toBe(
|
||||
arrow21.IntervalUnit.MONTH_DAY_NANO,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should rebuild a schema from a second copy of the same Arrow version", function () {
|
||||
// Arrow 21 makes `instanceof Schema` succeed across copies of the library
|
||||
// but not `instanceof <DataType>`; the schema must still be rebuilt.
|
||||
const foreign = new arrow21.Schema([
|
||||
new arrow21.Field("x", new arrow21.Float32(), true),
|
||||
]);
|
||||
// Only Arrow 21+ overrides `Symbol.hasInstance` this way.
|
||||
const crossCopyInstanceof = Object.getOwnPropertySymbols(
|
||||
arrow.Schema,
|
||||
).includes(Symbol.hasInstance);
|
||||
expect(foreign instanceof arrow.Schema).toBe(crossCopyInstanceof);
|
||||
const result = sanitizeSchema(foreign);
|
||||
expect(result).not.toBe(foreign);
|
||||
const type = result.fields[0].type;
|
||||
expect(type).toBeInstanceOf(arrow.DataType);
|
||||
expect(arrow.DataType.isFloat(type)).toBe(true);
|
||||
expect((type as arrow.Float).precision).toBe(arrow.Precision.SINGLE);
|
||||
expect(() => arrow.makeBuilder({ type })).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeField function", function () {
|
||||
|
||||
+944
-20
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,9 @@ test.each([
|
||||
[new ArrayBuffer(8), "X'0000000000000000'"],
|
||||
[Buffer.from("hello"), "X'68656c6c6f'"],
|
||||
["Hello 'world'", "'Hello ''world'''"],
|
||||
[NaN, "CAST('NaN' AS DOUBLE)"],
|
||||
[Infinity, "CAST('Infinity' AS DOUBLE)"],
|
||||
[-Infinity, "CAST('-Infinity' AS DOUBLE)"],
|
||||
])("toSQL(%p) === %p", (value, expected) => {
|
||||
expect(toSQL(value)).toBe(expected);
|
||||
});
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
"//1": "--experimental-vm-modules is needed to run jest with sentence-transformers",
|
||||
"//2": "--testEnvironment is needed to run jest with sentence-transformers",
|
||||
"//3": "See: https://github.com/huggingface/transformers.js/issues/57",
|
||||
"test": "node --experimental-vm-modules node_modules/.bin/jest --testEnvironment jest-environment-node-single-context --verbose",
|
||||
"//4": "jest is invoked by its JS entry, not node_modules/.bin/jest: under pnpm that path is a shell shim, which `node` cannot execute",
|
||||
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --testEnvironment jest-environment-node-single-context --verbose",
|
||||
"lint": "biome check *.ts && biome format *.ts",
|
||||
"lint-ci": "biome ci .",
|
||||
"lint-fix": "biome check --write *.ts && pnpm format",
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
const path = require("node:path");
|
||||
|
||||
// Set the real process environment before Jest creates its sandbox and workers.
|
||||
// Assigning process.env inside a test does not reach Rust's std::env.
|
||||
process.env.LANCEDB_OAUTH_BROWSER =
|
||||
process.platform === "win32"
|
||||
? path.join(__dirname, "__test__/fixtures/oauth_browser.cmd")
|
||||
: "/usr/bin/true";
|
||||
|
||||
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
||||
module.exports = {
|
||||
preset: "ts-jest",
|
||||
|
||||
+383
-17
@@ -40,6 +40,12 @@ import {
|
||||
} from "apache-arrow";
|
||||
import { Buffers } from "apache-arrow/data";
|
||||
import { typedArrayToArrowType } from "./arrow_type";
|
||||
import {
|
||||
blobToRead,
|
||||
coerceBlobValue,
|
||||
isBlobField,
|
||||
withBlobBytes,
|
||||
} from "./blob";
|
||||
import { type EmbeddingFunction } from "./embedding/embedding_function";
|
||||
import {
|
||||
EmbeddingFunctionConfig,
|
||||
@@ -65,11 +71,44 @@ export type SchemaLike =
|
||||
export type FieldLike =
|
||||
| Field
|
||||
| {
|
||||
type: string;
|
||||
type: string | DataTypeLike;
|
||||
name: string;
|
||||
nullable: boolean;
|
||||
metadata?: Map<string, string>;
|
||||
};
|
||||
/**
|
||||
* A `DataType` from any copy or version of apache-arrow.
|
||||
*
|
||||
* Arrow 21 brands its classes with `unique symbol` properties, so a type
|
||||
* object from a second copy of the library no longer satisfies the `DataType`
|
||||
* type of this one even though it is structurally identical. Inputs that only
|
||||
* need to be sanitized accept this looser shape instead.
|
||||
*/
|
||||
export type DataTypeLike = DataType | { readonly typeId: number };
|
||||
|
||||
/**
|
||||
* Create an Arrow field backed by LanceDB's JSON extension type.
|
||||
*
|
||||
* @param name - The field name.
|
||||
* @param nullable - Whether the field accepts null values.
|
||||
* @example
|
||||
* ```ts
|
||||
* import { connect, makeJsonField } from "@lancedb/lancedb";
|
||||
* import { Schema } from "apache-arrow";
|
||||
*
|
||||
* const schema = new Schema([makeJsonField("metadata")]);
|
||||
* const db = await connect("/path/to/database");
|
||||
* await db.createTable("items", [{ metadata: '{"source":"api"}' }], { schema });
|
||||
* ```
|
||||
*/
|
||||
export function makeJsonField(name: string, nullable = true): Field {
|
||||
return new Field(
|
||||
name,
|
||||
new Utf8(),
|
||||
nullable,
|
||||
new Map([["ARROW:extension:name", "arrow.json"]]),
|
||||
);
|
||||
}
|
||||
|
||||
export type DataLike =
|
||||
| import("apache-arrow").Data
|
||||
@@ -415,6 +454,24 @@ export function makeArrowTable(
|
||||
);
|
||||
}
|
||||
|
||||
if (schema !== undefined) {
|
||||
// Validate blob values up front and give every one the full
|
||||
// `{ data, uri }` shape, so inference sees the same struct whether a row
|
||||
// passed bytes, a URI, or a partial struct.
|
||||
data = mapBlobInputs(data, schema, (value, field, row) => {
|
||||
if (value === undefined) {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
return coerceBlobValue(value);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`Invalid value for blob field ${field} at row ${row}: ${(e as Error).message}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let schemaMetadata = schema?.metadata || new Map<string, string>();
|
||||
if (metadata !== undefined) {
|
||||
schemaMetadata = new Map([...schemaMetadata, ...metadata]);
|
||||
@@ -430,12 +487,14 @@ export function makeArrowTable(
|
||||
throw new Error("A schema must be provided if data is empty");
|
||||
} else {
|
||||
schema = new Schema(schema.fields, schemaMetadata);
|
||||
validateBlobSchema(schema);
|
||||
return new ArrowTable(schema);
|
||||
}
|
||||
}
|
||||
|
||||
let inferredSchema = inferSchema(data, schema, opt);
|
||||
inferredSchema = new Schema(inferredSchema.fields, schemaMetadata);
|
||||
validateBlobSchema(inferredSchema);
|
||||
|
||||
const finalColumns: Record<string, Vector> = {};
|
||||
for (const field of inferredSchema.fields) {
|
||||
@@ -445,6 +504,162 @@ export function makeArrowTable(
|
||||
return new ArrowTable(inferredSchema, finalColumns);
|
||||
}
|
||||
|
||||
function validateBlobSchema(schema: Schema): void {
|
||||
for (const field of schema.fields) {
|
||||
validateBlobField(field);
|
||||
}
|
||||
}
|
||||
|
||||
function validateBlobField(field: Field): void {
|
||||
if (
|
||||
isFixedSizeList(field.type) &&
|
||||
containsBlobField(field.type.children[0])
|
||||
) {
|
||||
throw new Error(
|
||||
"Blob fields inside FixedSizeList are not supported. Use List instead.",
|
||||
);
|
||||
}
|
||||
for (const child of field.type.children ?? []) {
|
||||
validateBlobField(child);
|
||||
}
|
||||
}
|
||||
|
||||
function containsBlobField(field: Field): boolean {
|
||||
if (isBlobField(field)) {
|
||||
return true;
|
||||
}
|
||||
return (field.type.children ?? []).some((child: Field) =>
|
||||
containsBlobField(child),
|
||||
);
|
||||
}
|
||||
|
||||
type BlobInputVisitor = (value: unknown, field: string, row: number) => unknown;
|
||||
|
||||
/**
|
||||
* Calls `visit` on every value that lands in a blob column of `schema`,
|
||||
* including blobs nested in structs and lists, and replaces it with the
|
||||
* result. Records and arrays are copied only where a value changed, so the
|
||||
* caller's data is never mutated.
|
||||
*/
|
||||
function mapBlobInputs(
|
||||
data: Array<Record<string, unknown>>,
|
||||
schema: Schema,
|
||||
visit: BlobInputVisitor,
|
||||
): Array<Record<string, unknown>> {
|
||||
if (!schema.fields.some(containsBlobField)) {
|
||||
return data;
|
||||
}
|
||||
return data.map((record, row) =>
|
||||
isObject(record)
|
||||
? mapBlobFields(record, schema.fields, "", row, visit)
|
||||
: record,
|
||||
);
|
||||
}
|
||||
|
||||
function mapBlobFields(
|
||||
record: Record<string, unknown>,
|
||||
fields: Field[],
|
||||
prefix: string,
|
||||
row: number,
|
||||
visit: BlobInputVisitor,
|
||||
): Record<string, unknown> {
|
||||
let out: Record<string, unknown> | undefined;
|
||||
for (const field of fields) {
|
||||
if (!containsBlobField(field) || !Object.hasOwn(record, field.name)) {
|
||||
continue;
|
||||
}
|
||||
const value = record[field.name];
|
||||
const mapped = mapBlobField(
|
||||
value,
|
||||
field,
|
||||
`${prefix}${field.name}`,
|
||||
row,
|
||||
visit,
|
||||
);
|
||||
if (mapped !== value) {
|
||||
out ??= { ...record };
|
||||
out[field.name] = mapped;
|
||||
}
|
||||
}
|
||||
return out ?? record;
|
||||
}
|
||||
|
||||
function mapBlobField(
|
||||
value: unknown,
|
||||
field: Field,
|
||||
label: string,
|
||||
row: number,
|
||||
visit: BlobInputVisitor,
|
||||
): unknown {
|
||||
if (isBlobField(field)) {
|
||||
return visit(value, label, row);
|
||||
}
|
||||
if (field.type instanceof Struct && isObject(value)) {
|
||||
return mapBlobFields(value, field.type.children, `${label}.`, row, visit);
|
||||
}
|
||||
if (isList(field.type) && Array.isArray(value)) {
|
||||
const child = field.type.children[0];
|
||||
let out: unknown[] | undefined;
|
||||
for (const [index, element] of value.entries()) {
|
||||
const mapped = mapBlobField(
|
||||
element,
|
||||
child,
|
||||
`${label}[${index}]`,
|
||||
row,
|
||||
visit,
|
||||
);
|
||||
if (mapped !== element) {
|
||||
out ??= [...value];
|
||||
out[index] = mapped;
|
||||
}
|
||||
}
|
||||
return out ?? value;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads `Blob` / `File` values in the blob columns of `schema` into bytes so
|
||||
* the synchronous conversion in {@link makeArrowTable} can accept them.
|
||||
* Returns `data` itself when there is nothing to read.
|
||||
*/
|
||||
export async function resolveBlobInputs(
|
||||
data: Array<Record<string, unknown>>,
|
||||
schema?: SchemaLike,
|
||||
): Promise<Array<Record<string, unknown>>> {
|
||||
if (schema === undefined || schema === null) {
|
||||
return data;
|
||||
}
|
||||
const sanitized = sanitizeSchema(schema);
|
||||
// Keyed by the Blob itself, so rows sharing one Blob (bare or as
|
||||
// `{ data }`) read it once and share the bytes.
|
||||
const reads = new Map<Blob, Promise<Uint8Array>>();
|
||||
mapBlobInputs(data, sanitized, (value) => {
|
||||
const source = blobToRead(value);
|
||||
if (source !== undefined && !reads.has(source)) {
|
||||
reads.set(
|
||||
source,
|
||||
source.arrayBuffer().then((buffer) => new Uint8Array(buffer)),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
});
|
||||
if (reads.size === 0) {
|
||||
return data;
|
||||
}
|
||||
const bytes = new Map(
|
||||
await Promise.all(
|
||||
[...reads].map(async ([source, read]) => [source, await read] as const),
|
||||
),
|
||||
);
|
||||
return mapBlobInputs(data, sanitized, (value) => {
|
||||
const source = blobToRead(value);
|
||||
return source === undefined
|
||||
? value
|
||||
: withBlobBytes(value, bytes.get(source) as Uint8Array);
|
||||
});
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
@@ -480,6 +695,32 @@ function transposeData(
|
||||
path: string[] = [],
|
||||
): Vector {
|
||||
const valuesPath = [...path, field.name];
|
||||
if (isBlobField(field) && field.type instanceof Struct) {
|
||||
const blobRows = data.map((datum) =>
|
||||
coerceBlobValue(valueAtPath(datum, valuesPath)),
|
||||
);
|
||||
const childVectors = field.type.children.map((child) => {
|
||||
const values = blobRows.map((row) =>
|
||||
row == null ? null : (row[child.name as "data" | "uri"] ?? null),
|
||||
);
|
||||
return makeVector(values, child.type, undefined, child.nullable);
|
||||
});
|
||||
const nullCount = blobRows.filter((row) => row === null).length;
|
||||
const structData = makeData({
|
||||
type: field.type,
|
||||
length: blobRows.length,
|
||||
nullCount,
|
||||
nullBitmap:
|
||||
nullCount > 0
|
||||
? arrowUtil.packBools(blobRows.map((row) => row !== null))
|
||||
: undefined,
|
||||
children: childVectors.map((v) => v.data[0]),
|
||||
});
|
||||
return arrowMakeVector(structData);
|
||||
}
|
||||
if (isList(field.type) && containsBlobField(field.type.children[0])) {
|
||||
return transposeListData(data, field, valuesPath);
|
||||
}
|
||||
const values = data.map((datum) => valueAtPath(datum, valuesPath));
|
||||
if (field.type instanceof Struct) {
|
||||
const childFields = field.type.children;
|
||||
@@ -495,7 +736,7 @@ function transposeData(
|
||||
nullCount > 0
|
||||
? arrowUtil.packBools(values.map((value) => value !== null))
|
||||
: undefined,
|
||||
children: childVectors as unknown as ArrowData<DataType>[],
|
||||
children: childVectors.map((v) => v.data[0]),
|
||||
});
|
||||
return arrowMakeVector(structData);
|
||||
} else {
|
||||
@@ -503,6 +744,48 @@ function transposeData(
|
||||
}
|
||||
}
|
||||
|
||||
function transposeListData(
|
||||
data: Record<string, unknown>[],
|
||||
field: Field,
|
||||
valuesPath: string[],
|
||||
): Vector {
|
||||
const listType = field.type as List;
|
||||
const childField = listType.children[0];
|
||||
const lists = data.map((datum) => valueAtPath(datum, valuesPath));
|
||||
const flattened: Record<string, unknown>[] = [];
|
||||
const validity: boolean[] = [];
|
||||
const offsets: number[] = [0];
|
||||
|
||||
for (const list of lists) {
|
||||
if (list == null) {
|
||||
validity.push(false);
|
||||
offsets.push(flattened.length);
|
||||
continue;
|
||||
}
|
||||
if (!Array.isArray(list)) {
|
||||
throw new Error(`expected an array for list field '${field.name}'`);
|
||||
}
|
||||
validity.push(true);
|
||||
for (const element of list) {
|
||||
flattened.push({ [childField.name]: element });
|
||||
}
|
||||
offsets.push(flattened.length);
|
||||
}
|
||||
|
||||
const childVector = transposeData(flattened, childField, []);
|
||||
const nullCount = validity.filter((valid) => !valid).length;
|
||||
return arrowMakeVector(
|
||||
makeData({
|
||||
type: listType,
|
||||
length: lists.length,
|
||||
nullCount,
|
||||
nullBitmap: nullCount > 0 ? arrowUtil.packBools(validity) : undefined,
|
||||
valueOffsets: Int32Array.from(offsets),
|
||||
child: childVector.data[0],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an empty Arrow table with the provided schema
|
||||
*/
|
||||
@@ -600,7 +883,7 @@ function makeVector(
|
||||
}
|
||||
if (values.length === 0) {
|
||||
throw Error(
|
||||
"makeVector requires at least one value or the type must be specfied",
|
||||
"makeVector requires at least one value or the type must be specified",
|
||||
);
|
||||
}
|
||||
const sampleValue = values.find((val) => val !== null && val !== undefined);
|
||||
@@ -858,7 +1141,7 @@ async function applyEmbeddings<T>(
|
||||
* customized by the `embeddingDataType` property of the embedding function.
|
||||
*
|
||||
* If a schema is provided in `makeTableOptions` then it should include the
|
||||
* embedding columns. If no schema is provded then embedding columns will
|
||||
* embedding columns. If no schema is provided then embedding columns will
|
||||
* be placed at the end of the table, after all of the input columns.
|
||||
*/
|
||||
export async function convertToTable(
|
||||
@@ -880,6 +1163,11 @@ export async function convertToTable(
|
||||
);
|
||||
}
|
||||
|
||||
processedData = await resolveBlobInputs(
|
||||
processedData,
|
||||
makeTableOptions?.schema,
|
||||
);
|
||||
|
||||
const table = makeArrowTable(processedData, makeTableOptions);
|
||||
return await applyEmbeddings(table, embeddings, makeTableOptions?.schema);
|
||||
}
|
||||
@@ -935,6 +1223,71 @@ export async function fromRecordsToStreamBuffer(
|
||||
return Buffer.from(await writer.toUint8Array());
|
||||
}
|
||||
|
||||
// `Type.Utf8View` / `Type.BinaryView` as numbers: the enum members only exist
|
||||
// in Arrow 21+, and this module compiles against every supported release.
|
||||
const UTF8_VIEW_TYPE_ID = 24;
|
||||
const BINARY_VIEW_TYPE_ID = 23;
|
||||
|
||||
/**
|
||||
* Copy a Utf8View / BinaryView `Data` into a single `Data` of `type`.
|
||||
*/
|
||||
function materializeViewData(child: ArrowData, type: DataType): ArrowData {
|
||||
const builder = makeBuilder({ type, nullValues: [null] });
|
||||
for (const value of new Vector([child])) {
|
||||
builder.append(value);
|
||||
}
|
||||
return builder.finish().flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild any top-level Utf8View / BinaryView column as Utf8 / Binary.
|
||||
*
|
||||
* Lance stores the view types as their offset-based equivalents anyway, so
|
||||
* nothing is lost. Doing it here also sidesteps an Arrow JS 21 bug: its IPC
|
||||
* writer emits a truncated views buffer for a *sliced* view array, which the
|
||||
* Rust reader rejects with "Need at least N bytes in buffers[0]".
|
||||
*
|
||||
* The record batches are rebuilt positionally rather than through a
|
||||
* `Record<string, Vector>`: JavaScript enumerates integer-like keys first, so
|
||||
* a field named e.g. `"1"` would otherwise be paired with the wrong column.
|
||||
*
|
||||
* Tables without view columns are returned as-is.
|
||||
*/
|
||||
function materializeViewColumns(table: ArrowTable): ArrowTable {
|
||||
const replacements = new Map<number, DataType>();
|
||||
table.schema.fields.forEach((field, i) => {
|
||||
if (field.type.typeId === UTF8_VIEW_TYPE_ID) {
|
||||
replacements.set(i, new Utf8());
|
||||
} else if (field.type.typeId === BINARY_VIEW_TYPE_ID) {
|
||||
replacements.set(i, new Binary());
|
||||
}
|
||||
});
|
||||
if (replacements.size === 0) {
|
||||
return table;
|
||||
}
|
||||
const fields = table.schema.fields.map((field, i) => {
|
||||
const type = replacements.get(i);
|
||||
return type === undefined
|
||||
? field
|
||||
: new Field(field.name, type, field.nullable, field.metadata);
|
||||
});
|
||||
const schema = new Schema(fields, table.schema.metadata);
|
||||
const batches = table.batches.map((batch) => {
|
||||
const children = batch.data.children.map((child, i) => {
|
||||
const type = replacements.get(i);
|
||||
return type === undefined ? child : materializeViewData(child, type);
|
||||
});
|
||||
const data = makeData({
|
||||
type: new Struct(fields),
|
||||
length: batch.numRows,
|
||||
nullCount: 0,
|
||||
children,
|
||||
});
|
||||
return new RecordBatch(schema, data);
|
||||
});
|
||||
return new ArrowTable(schema, batches);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize an Arrow Table into a buffer using the Arrow IPC File serialization
|
||||
*
|
||||
@@ -951,7 +1304,10 @@ export async function fromTableToBuffer(
|
||||
if (schema !== undefined && schema !== null) {
|
||||
schema = sanitizeSchema(schema);
|
||||
}
|
||||
const tableWithEmbeddings = await applyEmbeddings(table, embeddings, schema);
|
||||
const tableWithEmbeddings = materializeViewColumns(
|
||||
await applyEmbeddings(table, embeddings, schema),
|
||||
);
|
||||
validateBlobSchema(tableWithEmbeddings.schema);
|
||||
const writer = RecordBatchFileWriter.writeAll(tableWithEmbeddings);
|
||||
return Buffer.from(await writer.toUint8Array());
|
||||
}
|
||||
@@ -1021,7 +1377,8 @@ export async function fromRecordBatchToBuffer(
|
||||
export async function fromRecordBatchToStreamBuffer(
|
||||
batch: RecordBatch,
|
||||
): Promise<Buffer> {
|
||||
const writer = RecordBatchStreamWriter.writeAll([batch]);
|
||||
const table = materializeViewColumns(new ArrowTable([batch]));
|
||||
const writer = RecordBatchStreamWriter.writeAll(table);
|
||||
return Buffer.from(await writer.toUint8Array());
|
||||
}
|
||||
|
||||
@@ -1038,7 +1395,9 @@ export async function fromTableToStreamBuffer(
|
||||
embeddings?: EmbeddingFunctionConfig,
|
||||
schema?: SchemaLike,
|
||||
): Promise<Buffer> {
|
||||
const tableWithEmbeddings = await applyEmbeddings(table, embeddings, schema);
|
||||
const tableWithEmbeddings = materializeViewColumns(
|
||||
await applyEmbeddings(table, embeddings, schema),
|
||||
);
|
||||
const writer = RecordBatchStreamWriter.writeAll(tableWithEmbeddings);
|
||||
return Buffer.from(await writer.toUint8Array());
|
||||
}
|
||||
@@ -1165,7 +1524,7 @@ export function ensureNestedFieldsExist(
|
||||
for (const field of schema.fields) {
|
||||
if (field.name in row) {
|
||||
if (
|
||||
field.type.constructor.name === "Struct" &&
|
||||
isPlainStructField(field) &&
|
||||
row[field.name] !== null &&
|
||||
row[field.name] !== undefined
|
||||
) {
|
||||
@@ -1182,10 +1541,9 @@ export function ensureNestedFieldsExist(
|
||||
} else {
|
||||
// Keep a missing struct valid while filling each of its children with
|
||||
// null. This is distinct from an explicitly null struct value.
|
||||
completeRow[field.name] =
|
||||
field.type.constructor.name === "Struct"
|
||||
? ensureStructFieldsExist({}, field.type as Struct)
|
||||
: null;
|
||||
completeRow[field.name] = isPlainStructField(field)
|
||||
? ensureStructFieldsExist({}, field.type as Struct)
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1193,6 +1551,15 @@ export function ensureNestedFieldsExist(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Blob fields are Arrow structs, but their values are bytes, URIs, or
|
||||
* `{ data } | { uri }` inputs that the blob coercion in `makeArrowTable`
|
||||
* handles, so they must not be filled in like ordinary structs.
|
||||
*/
|
||||
function isPlainStructField(field: Field): boolean {
|
||||
return field.type.constructor.name === "Struct" && !isBlobField(field);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively ensures that all fields in a struct type exist in the data,
|
||||
* filling missing fields with null values.
|
||||
@@ -1206,7 +1573,7 @@ function ensureStructFieldsExist(
|
||||
for (const childField of structType.children) {
|
||||
if (childField.name in data) {
|
||||
if (
|
||||
childField.type.constructor.name === "Struct" &&
|
||||
isPlainStructField(childField) &&
|
||||
data[childField.name] !== null &&
|
||||
data[childField.name] !== undefined
|
||||
) {
|
||||
@@ -1222,10 +1589,9 @@ function ensureStructFieldsExist(
|
||||
} else {
|
||||
// Keep a missing struct valid while filling each of its children with
|
||||
// null. This is distinct from an explicitly null struct value.
|
||||
completeStruct[childField.name] =
|
||||
childField.type.constructor.name === "Struct"
|
||||
? ensureStructFieldsExist({}, childField.type as Struct)
|
||||
: null;
|
||||
completeStruct[childField.name] = isPlainStructField(childField)
|
||||
? ensureStructFieldsExist({}, childField.type as Struct)
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
import { Field, LargeBinary, Struct, Utf8 } from "apache-arrow";
|
||||
import { BlobFile as NativeBlobFile } from "./native";
|
||||
|
||||
const BLOB_V2_EXTENSION_NAME = "lance.blob.v2";
|
||||
|
||||
const INLINE_SIZE_THRESHOLD_KEY = "lance-encoding:blob-inline-size-threshold";
|
||||
const DEDICATED_SIZE_THRESHOLD_KEY =
|
||||
"lance-encoding:blob-dedicated-size-threshold";
|
||||
const PACK_FILE_SIZE_THRESHOLD_KEY =
|
||||
"lance-encoding:blob-pack-file-size-threshold";
|
||||
|
||||
/**
|
||||
* Bytes accepted for a blob value. `ArrayBuffer` is wrapped without copying.
|
||||
* `Blob` and `File` are read with `arrayBuffer()`, which only the async write
|
||||
* paths ({@link Connection.createTable}, {@link Table.add},
|
||||
* {@link Table.mergeInsert}) can do.
|
||||
*/
|
||||
export type BlobData = Buffer | Uint8Array | ArrayBuffer | Blob;
|
||||
|
||||
/** A URI accepted for a blob value. A `URL` is stored as its `href`. */
|
||||
export type BlobUri = string | URL;
|
||||
|
||||
/**
|
||||
* A value accepted for a `lance.blob.v2` column (see {@link blob}).
|
||||
*
|
||||
* Either inline bytes, a URI pointing at external bytes, or a struct that sets
|
||||
* exactly one of `data` or `uri`. `null` and `undefined` write a null blob.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { pathToFileURL } from "node:url";
|
||||
* import type { BlobInput } from "@lancedb/lancedb";
|
||||
*
|
||||
* const rows: { id: bigint; image: BlobInput }[] = [
|
||||
* { id: 1n, image: await (await fetch(url)).arrayBuffer() },
|
||||
* { id: 2n, image: pathToFileURL("/data/cat.png") },
|
||||
* { id: 3n, image: { data: new Blob(["hello"]) } },
|
||||
* ];
|
||||
* await table.add(rows);
|
||||
* ```
|
||||
*/
|
||||
export type BlobInput =
|
||||
| BlobData
|
||||
| BlobUri
|
||||
| { data: BlobData; uri?: null }
|
||||
| { data?: null; uri: BlobUri }
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
/** A blob value normalized to the columns of the blob struct. */
|
||||
export type BlobValue = {
|
||||
data: Buffer | Uint8Array | null;
|
||||
uri: string | null;
|
||||
};
|
||||
|
||||
export type BlobOptions = {
|
||||
/** Defaults to true. */
|
||||
nullable?: boolean;
|
||||
/**
|
||||
* Max payload bytes kept inline in the data file. Zero is allowed. Must be a
|
||||
* safe integer.
|
||||
*/
|
||||
inlineSizeThreshold?: number;
|
||||
/**
|
||||
* Max payload bytes stored in a packed sidecar before a dedicated file. Must
|
||||
* be a positive safe integer.
|
||||
*/
|
||||
dedicatedSizeThreshold?: number;
|
||||
/**
|
||||
* Max bytes in one packed sidecar before starting another. Must be a positive
|
||||
* safe integer.
|
||||
*/
|
||||
packFileSizeThreshold?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Declares a `lance.blob.v2` column.
|
||||
*
|
||||
* Query results are descriptors, not payload bytes. Use {@link Table.fetchBlobs}
|
||||
* or {@link Table.fetchBlobFiles} to read bytes.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { readFile } from "node:fs/promises";
|
||||
* import { Field, Int64, Schema } from "apache-arrow";
|
||||
* import { blob, connect } from "@lancedb/lancedb";
|
||||
*
|
||||
* const db = await connect("./data");
|
||||
* const video = await readFile("clip.mp4");
|
||||
* const table = await db.createTable(
|
||||
* "videos",
|
||||
* [{ id: 1n, video }],
|
||||
* {
|
||||
* schema: new Schema([
|
||||
* new Field("id", new Int64()),
|
||||
* blob("video"),
|
||||
* ]),
|
||||
* },
|
||||
* );
|
||||
*
|
||||
* const rows = await table.query().select(["id"]).withRowId().toArray();
|
||||
* const rowIds = rows.map((row) => row._rowid as bigint);
|
||||
* const bytes = await table.fetchBlobs("video", rowIds);
|
||||
*
|
||||
* const [handle] = await table.fetchBlobFiles("video", rowIds);
|
||||
* const size = handle!.size();
|
||||
* const header = await handle!.readRange(0n, size < 65536n ? size : 65536n);
|
||||
* ```
|
||||
*/
|
||||
export function blob(name: string, options: BlobOptions = {}): Field {
|
||||
const metadata = new Map<string, string>([
|
||||
["ARROW:extension:name", BLOB_V2_EXTENSION_NAME],
|
||||
]);
|
||||
setThreshold(
|
||||
metadata,
|
||||
INLINE_SIZE_THRESHOLD_KEY,
|
||||
"inlineSizeThreshold",
|
||||
options.inlineSizeThreshold,
|
||||
0,
|
||||
);
|
||||
setThreshold(
|
||||
metadata,
|
||||
DEDICATED_SIZE_THRESHOLD_KEY,
|
||||
"dedicatedSizeThreshold",
|
||||
options.dedicatedSizeThreshold,
|
||||
1,
|
||||
);
|
||||
setThreshold(
|
||||
metadata,
|
||||
PACK_FILE_SIZE_THRESHOLD_KEY,
|
||||
"packFileSizeThreshold",
|
||||
options.packFileSizeThreshold,
|
||||
1,
|
||||
);
|
||||
return new Field(
|
||||
name,
|
||||
new Struct([
|
||||
new Field("data", new LargeBinary(), true),
|
||||
new Field("uri", new Utf8(), true),
|
||||
]),
|
||||
options.nullable ?? true,
|
||||
metadata,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks for the `lance.blob.v2` extension marker. Does not validate the
|
||||
* field's storage type.
|
||||
*/
|
||||
export function isBlobField(field: Field): boolean {
|
||||
return field.metadata?.get("ARROW:extension:name") === BLOB_V2_EXTENSION_NAME;
|
||||
}
|
||||
|
||||
/**
|
||||
* A lazy handle to blob bytes. Create one with {@link Table.fetchBlobFiles}.
|
||||
*
|
||||
* @hideconstructor
|
||||
*/
|
||||
export class BlobFile {
|
||||
private readonly inner: NativeBlobFile;
|
||||
|
||||
private constructor(inner: NativeBlobFile) {
|
||||
if (!(inner instanceof NativeBlobFile)) {
|
||||
throw new Error("BlobFile handles come from Table.fetchBlobFiles");
|
||||
}
|
||||
this.inner = inner;
|
||||
}
|
||||
|
||||
/** @ignore */
|
||||
static fromNative(inner: NativeBlobFile): BlobFile {
|
||||
return new BlobFile(inner);
|
||||
}
|
||||
|
||||
/** Returns the blob size in bytes. */
|
||||
size(): bigint {
|
||||
return this.inner.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads from the cursor and advances the cursor.
|
||||
*
|
||||
* Reads to the end when `maxBytes` is omitted, or at most `maxBytes` bytes
|
||||
* otherwise. Returns an empty buffer at the end of the blob.
|
||||
* {@link BlobFile.readRange} does not move the cursor.
|
||||
*/
|
||||
read(maxBytes?: bigint, options?: BlobReadOptions): Promise<Buffer> {
|
||||
return runWithSignal(options?.signal, (signal) =>
|
||||
this.inner.read(maxBytes, signal),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the half-open byte range `[start, end)`.
|
||||
*
|
||||
* Fails when `end` is past the blob size. Does not move the cursor.
|
||||
*/
|
||||
readRange(
|
||||
start: bigint,
|
||||
end: bigint,
|
||||
options?: BlobReadOptions,
|
||||
): Promise<Buffer> {
|
||||
return runWithSignal(options?.signal, (signal) =>
|
||||
this.inner.readRange(start, end, signal),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads several half-open byte ranges. Returns one buffer per range, in
|
||||
* the order given.
|
||||
*
|
||||
* Fails when any `end` is past the blob size. Does not move the cursor.
|
||||
*/
|
||||
readRanges(
|
||||
ranges: BlobRange[],
|
||||
options?: BlobReadOptions,
|
||||
): Promise<Buffer[]> {
|
||||
return runWithSignal(options?.signal, (signal) =>
|
||||
this.inner.readRanges(ranges, signal),
|
||||
);
|
||||
}
|
||||
|
||||
/** Moves the cursor to `position`, in bytes from the start of the blob. */
|
||||
seek(position: bigint): Promise<void> {
|
||||
return this.inner.seek(position);
|
||||
}
|
||||
|
||||
/** Returns the cursor position in bytes. */
|
||||
tell(): Promise<bigint> {
|
||||
return this.inner.tell();
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases the handle. Reads after `close()` fail. Calling it again does
|
||||
* nothing.
|
||||
*/
|
||||
close(): Promise<void> {
|
||||
return this.inner.close();
|
||||
}
|
||||
|
||||
/** Returns true after {@link BlobFile.close}. */
|
||||
isClosed(): boolean {
|
||||
return this.inner.isClosed();
|
||||
}
|
||||
}
|
||||
|
||||
/** Options for blob reads. */
|
||||
export type BlobReadOptions = {
|
||||
/**
|
||||
* Cancels the read. The call rejects with `signal.reason`, and the native
|
||||
* read stops, including in-flight requests to a remote table.
|
||||
*/
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
/** @ignore */
|
||||
export async function runWithSignal<T>(
|
||||
signal: AbortSignal | undefined,
|
||||
run: (signal: AbortSignal | undefined) => Promise<T>,
|
||||
): Promise<T> {
|
||||
if (signal === undefined) {
|
||||
return run(undefined);
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
// napi-rs replaces `onabort` on the signal it receives, so pass a private
|
||||
// signal and forward the caller's abort to it.
|
||||
const controller = new AbortController();
|
||||
const onAbort = () => controller.abort();
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
try {
|
||||
return await run(controller.signal);
|
||||
} catch (err) {
|
||||
if (signal.aborted) {
|
||||
throw signal.reason;
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
}
|
||||
|
||||
/** A half-open byte range `[start, end)` for {@link BlobFile.readRanges}. */
|
||||
export type BlobRange = {
|
||||
start: bigint;
|
||||
end: bigint;
|
||||
};
|
||||
|
||||
/**
|
||||
* Rewrites the synchronous-only widenings of {@link BlobInput} (`ArrayBuffer`,
|
||||
* `URL`) into `Uint8Array` and URI strings, keeping the value's shape. Other
|
||||
* values are returned unchanged and validated later by {@link coerceBlobValue}.
|
||||
*
|
||||
* Throws on `Blob` / `File`, which must be read into bytes first.
|
||||
*/
|
||||
export function normalizeBlobInput(value: unknown): unknown {
|
||||
if (blobToRead(value) !== undefined) {
|
||||
throw new Error(
|
||||
"Blob and File values must be read asynchronously. Pass them to " +
|
||||
"createTable, add, or mergeInsert, or convert them with " +
|
||||
"`new Uint8Array(await value.arrayBuffer())`",
|
||||
);
|
||||
}
|
||||
if (value instanceof ArrayBuffer) {
|
||||
return new Uint8Array(value);
|
||||
}
|
||||
if (value instanceof URL) {
|
||||
return value.href;
|
||||
}
|
||||
if (isBlobStruct(value)) {
|
||||
// Read `data` and `uri` explicitly: spreading a class instance would drop
|
||||
// fields defined as getters on its prototype.
|
||||
const out: Record<string, unknown> = {
|
||||
...value,
|
||||
data: value.data,
|
||||
uri: value.uri,
|
||||
};
|
||||
let changed = false;
|
||||
if (value.data instanceof ArrayBuffer) {
|
||||
out.data = new Uint8Array(value.data);
|
||||
changed = true;
|
||||
}
|
||||
if (value.uri instanceof URL) {
|
||||
out.uri = value.uri.href;
|
||||
changed = true;
|
||||
}
|
||||
return changed ? out : value;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the `Blob` / `File` a blob value needs read, either the value itself
|
||||
* or its `data` field, or `undefined` when there is nothing to read.
|
||||
*/
|
||||
export function blobToRead(value: unknown): Blob | undefined {
|
||||
if (isBlobLike(value)) {
|
||||
return value;
|
||||
}
|
||||
if (isBlobStruct(value) && isBlobLike(value.data)) {
|
||||
return value.data;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the `Blob` that {@link blobToRead} found in `value` with `bytes`,
|
||||
* keeping the value's shape.
|
||||
*/
|
||||
export function withBlobBytes(value: unknown, bytes: Uint8Array): unknown {
|
||||
if (isBlobLike(value)) {
|
||||
return bytes;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
return { ...record, uri: record.uri, data: bytes };
|
||||
}
|
||||
|
||||
export function coerceBlobValue(input: unknown): BlobValue | null {
|
||||
const value = normalizeBlobInput(input);
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (isBlobBytes(value)) {
|
||||
return { data: value, uri: null };
|
||||
}
|
||||
if (ArrayBuffer.isView(value)) {
|
||||
throw new Error(
|
||||
"Blob data must be Buffer, Uint8Array, ArrayBuffer, or Blob",
|
||||
);
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
if (value === "") {
|
||||
throw new Error("Blob uri cannot be empty");
|
||||
}
|
||||
return { data: null, uri: value };
|
||||
}
|
||||
if (isBlobStruct(value)) {
|
||||
const record = value;
|
||||
if (!("data" in record) && !("uri" in record)) {
|
||||
throw new Error(
|
||||
"Blob struct values must include a 'data' or 'uri' field",
|
||||
);
|
||||
}
|
||||
const unknownKeys = Object.keys(record).filter(
|
||||
(key) => key !== "data" && key !== "uri",
|
||||
);
|
||||
if (unknownKeys.length > 0) {
|
||||
throw new Error(
|
||||
`Blob struct values only support 'data' and 'uri', got ${unknownKeys
|
||||
.map((key) => `'${key}'`)
|
||||
.join(", ")}`,
|
||||
);
|
||||
}
|
||||
const uri = record.uri;
|
||||
if (uri === "") {
|
||||
throw new Error("Blob uri cannot be empty");
|
||||
}
|
||||
if (uri != null && typeof uri !== "string") {
|
||||
throw new Error(
|
||||
`Blob uri must be a string, URL, or null, got ${typeof uri}`,
|
||||
);
|
||||
}
|
||||
const data = record.data;
|
||||
if (data != null && !isBlobBytes(data)) {
|
||||
throw new Error(
|
||||
"Blob data must be Buffer, Uint8Array, ArrayBuffer, Blob, or null",
|
||||
);
|
||||
}
|
||||
const bytes = (data as Buffer | Uint8Array | null | undefined) ?? null;
|
||||
const uriValue = uri ?? null;
|
||||
if ((bytes == null) === (uriValue == null)) {
|
||||
throw new Error(
|
||||
"Blob struct values must set exactly one of 'data' or 'uri'",
|
||||
);
|
||||
}
|
||||
return { data: bytes, uri: uriValue };
|
||||
}
|
||||
throw new Error(
|
||||
"Blob column values must be Buffer, Uint8Array, ArrayBuffer, Blob, a URI string or URL, null, or { data?, uri? }",
|
||||
);
|
||||
}
|
||||
|
||||
function isBlobLike(value: unknown): value is Blob {
|
||||
return typeof Blob !== "undefined" && value instanceof Blob;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `value` is a `{ data?, uri? }` struct: any plain object, or any
|
||||
* other object with a `data` or `uri` field (such as a class instance), apart
|
||||
* from the binary, `Blob`, and `URL` values that are blob inputs themselves.
|
||||
*/
|
||||
function isBlobStruct(value: unknown): value is Record<string, unknown> {
|
||||
if (
|
||||
typeof value !== "object" ||
|
||||
value === null ||
|
||||
Array.isArray(value) ||
|
||||
ArrayBuffer.isView(value) ||
|
||||
value instanceof ArrayBuffer ||
|
||||
(typeof SharedArrayBuffer !== "undefined" &&
|
||||
value instanceof SharedArrayBuffer) ||
|
||||
isBlobLike(value) ||
|
||||
value instanceof URL
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return (
|
||||
prototype === Object.prototype ||
|
||||
prototype === null ||
|
||||
"data" in value ||
|
||||
"uri" in value
|
||||
);
|
||||
}
|
||||
|
||||
function isBlobBytes(value: unknown): value is Buffer | Uint8Array {
|
||||
return Buffer.isBuffer(value) || value instanceof Uint8Array;
|
||||
}
|
||||
|
||||
function setThreshold(
|
||||
metadata: Map<string, string>,
|
||||
key: string,
|
||||
optionName: string,
|
||||
value: number | undefined,
|
||||
minimum: number,
|
||||
): void {
|
||||
if (value === undefined) {
|
||||
return;
|
||||
}
|
||||
if (!Number.isSafeInteger(value)) {
|
||||
throw new Error(`${optionName} must be a safe integer`);
|
||||
}
|
||||
if (value < minimum) {
|
||||
throw new Error(
|
||||
minimum <= 0
|
||||
? `${optionName} must be non-negative`
|
||||
: `${optionName} must be positive`,
|
||||
);
|
||||
}
|
||||
metadata.set(key, String(value));
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
import { Connection, LocalConnection } from "./connection";
|
||||
import { HeaderProvider } from "./header";
|
||||
import {
|
||||
JsHeaderProvider,
|
||||
Catalog as NativeCatalog,
|
||||
CatalogOptions as NativeCatalogOptions,
|
||||
DatabaseNames as NativeDatabaseNames,
|
||||
} from "./native.js";
|
||||
import { OAuthConfig } from "./oauth";
|
||||
|
||||
/** Options shared by a catalog and the database connections it returns. */
|
||||
export interface CatalogOptions
|
||||
extends Omit<NativeCatalogOptions, "oauthConfig"> {
|
||||
oauthConfig?: OAuthConfig;
|
||||
/** Called for each request to supply authentication headers. */
|
||||
headerProvider?:
|
||||
| HeaderProvider
|
||||
| (() => Record<string, string> | Promise<Record<string, string>>);
|
||||
}
|
||||
|
||||
/**
|
||||
* A lazy async iterator of database names with pagination state.
|
||||
* Returned by {@link Catalog.listDatabases}.
|
||||
*/
|
||||
export class DatabaseNames implements AsyncIterableIterator<string> {
|
||||
/** @hidden */
|
||||
constructor(private readonly inner: NativeDatabaseNames) {}
|
||||
|
||||
[Symbol.asyncIterator](): AsyncIterableIterator<string> {
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Fetch the next name, requesting another page only when needed. */
|
||||
async next(): Promise<IteratorResult<string>> {
|
||||
const value = await this.inner.next();
|
||||
return value == null
|
||||
? { done: true, value: undefined }
|
||||
: { done: false, value };
|
||||
}
|
||||
|
||||
/** Number of names available without another REST request. */
|
||||
numPageResults(): number {
|
||||
return this.inner.numPageResults();
|
||||
}
|
||||
|
||||
/**
|
||||
* Token for the next REST request. Initially this is the supplied starting token.
|
||||
* Returns undefined after the final page is fetched, even if names remain cached.
|
||||
* Drain the cache before saving a token to avoid skipping names when resuming.
|
||||
* A failed request terminates iteration and retains its token for a new iterator.
|
||||
*/
|
||||
pageToken(): string | undefined {
|
||||
return this.inner.pageToken() ?? undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** A remote catalog manages databases through the server's root namespace. */
|
||||
export class Catalog {
|
||||
/** @hidden */
|
||||
constructor(private readonly inner: NativeCatalog) {}
|
||||
|
||||
/** The root namespace endpoint. */
|
||||
get uri(): string {
|
||||
return this.inner.uri;
|
||||
}
|
||||
|
||||
/** Create a database, or open an existing database when existOk is true. */
|
||||
async createDatabase(
|
||||
name: string,
|
||||
options: { existOk?: boolean } = {},
|
||||
): Promise<Connection> {
|
||||
return new LocalConnection(
|
||||
await this.inner.createDatabase(name, options.existOk),
|
||||
);
|
||||
}
|
||||
|
||||
/** Connect to an existing database by its logical name. */
|
||||
async connectDatabase(name: string): Promise<Connection> {
|
||||
return new LocalConnection(await this.inner.connectDatabase(name));
|
||||
}
|
||||
|
||||
/** Drop an empty database. The server rejects nonempty databases. */
|
||||
async dropDatabase(
|
||||
name: string,
|
||||
options: { ignoreMissing?: boolean } = {},
|
||||
): Promise<void> {
|
||||
await this.inner.dropDatabase(name, options.ignoreMissing);
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterate lazily over all database names using `for await...of`.
|
||||
* pageToken resumes from a saved token; omitted starts at the beginning.
|
||||
* pageLimit limits each REST response, not the total; omitted uses the server default.
|
||||
* Request errors are raised during iteration and terminate the iterator.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* for await (const name of catalog.listDatabases({ pageLimit: 20 })) {
|
||||
* console.log(name);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
listDatabases(
|
||||
options: { pageLimit?: number; pageToken?: string } = {},
|
||||
): DatabaseNames {
|
||||
if (
|
||||
options.pageLimit !== undefined &&
|
||||
(!Number.isInteger(options.pageLimit) ||
|
||||
options.pageLimit <= 0 ||
|
||||
options.pageLimit > 2147483647)
|
||||
) {
|
||||
throw new Error(
|
||||
"Database list limit must be an integer between 1 and 2147483647",
|
||||
);
|
||||
}
|
||||
return new DatabaseNames(
|
||||
this.inner.listDatabases(options.pageToken, options.pageLimit),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to an HTTP(S) catalog endpoint. Catalog requests omit database-selection
|
||||
* headers; opened database connections inherit authentication and client options.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const catalog = await connectCatalog("https://my-server.example", { apiKey: "secret" });
|
||||
* const db = await catalog.createDatabase("analytics", { existOk: true });
|
||||
* for await (const name of catalog.listDatabases({ pageLimit: 20 })) {
|
||||
* console.log(name);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export async function connectCatalog(
|
||||
endpoint: string,
|
||||
options: CatalogOptions = {},
|
||||
): Promise<Catalog> {
|
||||
const { headerProvider, ...nativeOptions } = options;
|
||||
const provider = headerProvider
|
||||
? new JsHeaderProvider(async () =>
|
||||
typeof headerProvider === "function"
|
||||
? headerProvider()
|
||||
: headerProvider.getHeaders(),
|
||||
)
|
||||
: undefined;
|
||||
return new Catalog(
|
||||
await NativeCatalog.new(endpoint, nativeOptions, provider),
|
||||
);
|
||||
}
|
||||
+200
-46
@@ -1,7 +1,6 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
import { tableFromIPC } from "apache-arrow";
|
||||
import {
|
||||
Data,
|
||||
SchemaLike,
|
||||
@@ -9,6 +8,7 @@ import {
|
||||
fromTableToStreamBuffer,
|
||||
isArrowTable,
|
||||
makeArrowTable,
|
||||
resolveBlobInputs,
|
||||
} from "./arrow";
|
||||
import {
|
||||
Table as ArrowTable,
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
makeEmptyTable,
|
||||
} from "./arrow";
|
||||
import { EmbeddingFunctionConfig, getRegistry } from "./embedding/registry";
|
||||
import { Job } from "./job";
|
||||
import { Listing, ListingOptions, validateListingOptions } from "./listing";
|
||||
import {
|
||||
MaterializedView,
|
||||
MaterializedViewSelect,
|
||||
@@ -27,12 +29,11 @@ import type {
|
||||
CreateNamespaceResponse,
|
||||
DescribeNamespaceResponse,
|
||||
DropNamespaceResponse,
|
||||
Job,
|
||||
JobDescription,
|
||||
JobInfo,
|
||||
ListNamespacesResponse,
|
||||
ListTablesResponse,
|
||||
} from "./native";
|
||||
import { ViewDescription, viewDescriptionFromNative } from "./view";
|
||||
export type {
|
||||
CreateNamespaceResponse,
|
||||
DescribeNamespaceResponse,
|
||||
@@ -149,7 +150,7 @@ export interface TableNamesOptions {
|
||||
* the last table name from the previous page.
|
||||
*/
|
||||
startAfter?: string;
|
||||
/** An optional limit to the number of results to return. */
|
||||
/** The maximum number of names to return. Omitted returns all names; zero returns none. */
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
@@ -163,6 +164,7 @@ export interface ListTablesOptions {
|
||||
pageToken?: string;
|
||||
/**
|
||||
* An upper bound on how many tables to return.
|
||||
* Defaults to 100. Zero returns an empty page without a continuation token.
|
||||
*
|
||||
* A page may hold fewer than this and still not be the last one, so keep
|
||||
* going while the response carries a page token rather than while pages are
|
||||
@@ -322,13 +324,9 @@ export abstract class Connection {
|
||||
/**
|
||||
* Define a materialized view named `name` over the table `source`.
|
||||
*
|
||||
* The view is created empty, with the query recorded in its schema
|
||||
* metadata; `view.refresh()` computes the rows. The view is a normal
|
||||
* table: it can be queried, indexed and searched, and it appears in
|
||||
* `tableNames`. The source table must have stable row ids (create it with
|
||||
* the `newTableEnableStableRowIds` storage option); they keep the view's
|
||||
* provenance valid across source compactions and cannot be enabled after
|
||||
* a table exists. Local databases only.
|
||||
* The view is populated before creation returns. Set `withNoData` to create
|
||||
* only its definition and empty backing table. The view is a normal table:
|
||||
* it can be queried, indexed and searched, and it appears in `tableNames`.
|
||||
*/
|
||||
abstract createMaterializedView(
|
||||
name: string,
|
||||
@@ -337,6 +335,7 @@ export abstract class Connection {
|
||||
select?: MaterializedViewSelect;
|
||||
where?: string;
|
||||
limit?: number;
|
||||
withNoData?: boolean;
|
||||
},
|
||||
): Promise<MaterializedView>;
|
||||
|
||||
@@ -350,9 +349,89 @@ export abstract class Connection {
|
||||
/**
|
||||
* The names of the materialized views in this database.
|
||||
*
|
||||
* Found by reading every table's schema, so this costs an open per table.
|
||||
* Iterate lazily with `for await...of`. Local listings inspect table schemas
|
||||
* one page at a time. Options control page size and the starting token.
|
||||
* See {@link Listing} for pagination state and error behavior.
|
||||
*/
|
||||
abstract listMaterializedViews(): Promise<string[]>;
|
||||
abstract listMaterializedViews(options?: ListingOptions): Listing<string>;
|
||||
|
||||
/**
|
||||
* Drop the materialized view named `name`.
|
||||
*
|
||||
* The view may become unavailable before physical cleanup finishes. Use
|
||||
* {@link dropMaterializedViewAsync} to retain and wait for the cleanup job.
|
||||
*
|
||||
* Rejects a table that exists but is not a materialized view.
|
||||
*/
|
||||
abstract dropMaterializedView(
|
||||
name: string,
|
||||
namespacePath?: string[],
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* Start dropping the materialized view named `name` and return its cleanup
|
||||
* job without waiting for completion.
|
||||
*
|
||||
* Rejects a table that exists but is not a materialized view.
|
||||
*/
|
||||
abstract dropMaterializedViewAsync(
|
||||
name: string,
|
||||
namespacePath?: string[],
|
||||
): Promise<Job>;
|
||||
|
||||
/**
|
||||
* Create a view: a named query the database plans on every read.
|
||||
*
|
||||
* The query is planned once, at creation, so one that cannot be planned is
|
||||
* rejected now rather than at the first read. A view holds no rows, and its
|
||||
* readers see its sources as they are at read time.
|
||||
*
|
||||
* There is no replace: a name already taken is an error, and changing a
|
||||
* view is a drop followed by a create.
|
||||
*/
|
||||
abstract createView(
|
||||
name: string,
|
||||
query: string,
|
||||
namespacePath?: string[],
|
||||
): Promise<ViewDescription>;
|
||||
|
||||
/**
|
||||
* What this database records about the view named `name`: its defining
|
||||
* query and the schema that query resolved to.
|
||||
*/
|
||||
abstract describeView(
|
||||
name: string,
|
||||
namespacePath?: string[],
|
||||
): Promise<ViewDescription>;
|
||||
|
||||
/**
|
||||
* Drop the view named `name` and wait for its definition to be deleted.
|
||||
*
|
||||
* The tables it reads are untouched: a view holds no rows of its own. Use
|
||||
* {@link dropViewAsync} to retain the cleanup job instead of waiting on it.
|
||||
*/
|
||||
abstract dropView(name: string, namespacePath?: string[]): Promise<void>;
|
||||
|
||||
/**
|
||||
* Start dropping the view named `name` and return the job deleting its
|
||||
* definition, without waiting for completion.
|
||||
*
|
||||
* The name is free before this resolves. When nothing was bound to it, the
|
||||
* returned job is already finished and has no id.
|
||||
*/
|
||||
abstract dropViewAsync(name: string, namespacePath?: string[]): Promise<Job>;
|
||||
|
||||
/**
|
||||
* The names of the views in one namespace.
|
||||
*
|
||||
* Names only; a definition comes from {@link describeView}.
|
||||
* Iterate lazily with `for await...of`. See {@link Listing} for pagination
|
||||
* state and error behavior; options control page size and the starting token.
|
||||
*/
|
||||
abstract listViews(
|
||||
namespacePath?: string[],
|
||||
options?: ListingOptions,
|
||||
): Listing<string>;
|
||||
|
||||
abstract openTable(
|
||||
name: string,
|
||||
@@ -557,23 +636,22 @@ export abstract class Connection {
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* A {@link Job} handle for a server-side job by id.
|
||||
* Open a server-side job by id, returning a handle with its record already
|
||||
* populated. Rejects when the server has no such job, the way
|
||||
* {@link Connection.openTable} does for a missing table.
|
||||
*
|
||||
* The handle is constructed without a server round trip; an unknown id
|
||||
* surfaces when the handle is used. Dropping the handle has no effect on
|
||||
* the job itself.
|
||||
* The returned {@link Job} answers for its own state, specification,
|
||||
* result, failure and event history, so there is no separate
|
||||
* connection-level call for any of them.
|
||||
*/
|
||||
abstract job(jobId: string): Job;
|
||||
|
||||
/** List server-side jobs across the database's tables. */
|
||||
abstract listJobs(): Promise<JobInfo[]>;
|
||||
abstract openJob(jobId: string): Promise<Job>;
|
||||
|
||||
/**
|
||||
* Describe a single server-side job by id.
|
||||
*
|
||||
* Resolves to `null` when the server has no such job.
|
||||
* Iterate lazily over server-side jobs across the database's tables.
|
||||
* Use `for await...of`. Options control page size and the starting token.
|
||||
* See {@link Listing} for pagination state and error behavior.
|
||||
*/
|
||||
abstract getJob(jobId: string): Promise<JobDescription | null>;
|
||||
abstract listJobs(options?: ListingOptions): Listing<JobInfo>;
|
||||
|
||||
/**
|
||||
* Request cancellation of a server-side job by id.
|
||||
@@ -584,11 +662,22 @@ export abstract class Connection {
|
||||
abstract cancelJob(jobId: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* The lifecycle event history of a server-side job, as an Arrow table.
|
||||
* Pause a server-side job by id.
|
||||
*
|
||||
* Lists history across all jobs when `jobId` is omitted.
|
||||
* The job's workers drain and it stays parked until resumed. Resolves to
|
||||
* "pausing", "already_paused", or "committing" -- a job finalizing its
|
||||
* results cannot be parked; retry shortly.
|
||||
*/
|
||||
abstract jobHistory(jobId?: string): Promise<ArrowTable>;
|
||||
abstract pauseJob(jobId: string): Promise<string>;
|
||||
|
||||
/**
|
||||
* Resume a paused server-side job by id.
|
||||
*
|
||||
* Its workers pick their work back up from checkpoints. Resolves to
|
||||
* "resumed", "still_pausing" -- the pause's worker drain is not confirmed
|
||||
* yet; retry shortly -- or "not_paused".
|
||||
*/
|
||||
abstract resumeJob(jobId: string): Promise<string>;
|
||||
}
|
||||
|
||||
/** @hideconstructor */
|
||||
@@ -645,6 +734,7 @@ export class LocalConnection extends Connection {
|
||||
select?: MaterializedViewSelect;
|
||||
where?: string;
|
||||
limit?: number;
|
||||
withNoData?: boolean;
|
||||
},
|
||||
): Promise<MaterializedView> {
|
||||
validateNonNegativeInteger(options?.limit, "limit");
|
||||
@@ -654,6 +744,7 @@ export class LocalConnection extends Connection {
|
||||
normalizeSelect(options?.select),
|
||||
options?.where,
|
||||
options?.limit,
|
||||
options?.withNoData ?? false,
|
||||
);
|
||||
return new MaterializedView(new LocalTable(innerTable));
|
||||
}
|
||||
@@ -663,8 +754,68 @@ export class LocalConnection extends Connection {
|
||||
return new MaterializedView(new LocalTable(innerTable));
|
||||
}
|
||||
|
||||
async listMaterializedViews(): Promise<string[]> {
|
||||
return await this.inner.listMaterializedViews();
|
||||
listMaterializedViews(options: ListingOptions = {}): Listing<string> {
|
||||
validateListingOptions(options);
|
||||
return new Listing(
|
||||
this.inner.listMaterializedViews(options.pageToken, options.pageLimit),
|
||||
);
|
||||
}
|
||||
|
||||
async dropMaterializedView(
|
||||
name: string,
|
||||
namespacePath?: string[],
|
||||
): Promise<void> {
|
||||
return this.inner.dropMaterializedView(name, namespacePath ?? []);
|
||||
}
|
||||
|
||||
async dropMaterializedViewAsync(
|
||||
name: string,
|
||||
namespacePath?: string[],
|
||||
): Promise<Job> {
|
||||
return new Job(
|
||||
await this.inner.dropMaterializedViewAsync(name, namespacePath ?? []),
|
||||
);
|
||||
}
|
||||
|
||||
async createView(
|
||||
name: string,
|
||||
query: string,
|
||||
namespacePath?: string[],
|
||||
): Promise<ViewDescription> {
|
||||
return viewDescriptionFromNative(
|
||||
await this.inner.createView(name, query, namespacePath ?? []),
|
||||
);
|
||||
}
|
||||
|
||||
async describeView(
|
||||
name: string,
|
||||
namespacePath?: string[],
|
||||
): Promise<ViewDescription> {
|
||||
return viewDescriptionFromNative(
|
||||
await this.inner.describeView(name, namespacePath ?? []),
|
||||
);
|
||||
}
|
||||
|
||||
async dropView(name: string, namespacePath?: string[]): Promise<void> {
|
||||
return this.inner.dropView(name, namespacePath ?? []);
|
||||
}
|
||||
|
||||
async dropViewAsync(name: string, namespacePath?: string[]): Promise<Job> {
|
||||
return new Job(await this.inner.dropViewAsync(name, namespacePath ?? []));
|
||||
}
|
||||
|
||||
listViews(
|
||||
namespacePath?: string[],
|
||||
options: ListingOptions = {},
|
||||
): Listing<string> {
|
||||
validateListingOptions(options);
|
||||
return new Listing(
|
||||
this.inner.listViews(
|
||||
namespacePath ?? [],
|
||||
options.pageToken,
|
||||
options.pageLimit,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async listTables(
|
||||
@@ -869,7 +1020,7 @@ export class LocalConnection extends Connection {
|
||||
}
|
||||
|
||||
async dropTableAsync(name: string, namespacePath?: string[]): Promise<Job> {
|
||||
return this.inner.dropTableAsync(name, namespacePath ?? []);
|
||||
return new Job(await this.inner.dropTableAsync(name, namespacePath ?? []));
|
||||
}
|
||||
|
||||
async dropAllTables(namespacePath?: string[]): Promise<void> {
|
||||
@@ -928,28 +1079,27 @@ export class LocalConnection extends Connection {
|
||||
);
|
||||
}
|
||||
|
||||
job(jobId: string): Job {
|
||||
return this.inner.job(jobId);
|
||||
async openJob(jobId: string): Promise<Job> {
|
||||
return new Job(await this.inner.openJob(jobId));
|
||||
}
|
||||
|
||||
async listJobs(): Promise<JobInfo[]> {
|
||||
return this.inner.listJobs();
|
||||
}
|
||||
|
||||
async getJob(jobId: string): Promise<JobDescription | null> {
|
||||
return this.inner.getJob(jobId);
|
||||
listJobs(options: ListingOptions = {}): Listing<JobInfo> {
|
||||
validateListingOptions(options);
|
||||
return new Listing(
|
||||
this.inner.listJobs(options.pageToken, options.pageLimit),
|
||||
);
|
||||
}
|
||||
|
||||
async cancelJob(jobId: string): Promise<boolean> {
|
||||
return this.inner.cancelJob(jobId);
|
||||
}
|
||||
|
||||
async jobHistory(jobId?: string): Promise<ArrowTable> {
|
||||
const buf = await this.inner.jobHistory(jobId);
|
||||
if (buf.length === 0) {
|
||||
return new ArrowTable();
|
||||
}
|
||||
return tableFromIPC(buf);
|
||||
async pauseJob(jobId: string): Promise<string> {
|
||||
return this.inner.pauseJob(jobId);
|
||||
}
|
||||
|
||||
async resumeJob(jobId: string): Promise<string> {
|
||||
return this.inner.resumeJob(jobId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1009,7 +1159,11 @@ async function parseTableData(
|
||||
if (isArrowTable(data)) {
|
||||
table = sanitizeTable(data);
|
||||
} else {
|
||||
table = makeArrowTable(data as Record<string, unknown>[], options);
|
||||
const records = await resolveBlobInputs(
|
||||
data as Record<string, unknown>[],
|
||||
options?.schema,
|
||||
);
|
||||
table = makeArrowTable(records, options);
|
||||
}
|
||||
if (streaming) {
|
||||
const buf = await fromTableToStreamBuffer(
|
||||
|
||||
+38
-8
@@ -26,6 +26,7 @@ export {
|
||||
MaterializedViewDefinition,
|
||||
MaterializedViewSelect,
|
||||
} from "./materialized_view";
|
||||
export { ViewDescription } from "./view";
|
||||
export { JsHeaderProvider as NativeJsHeaderProvider } from "./native.js";
|
||||
|
||||
// OpenTelemetry metrics bridge. Only the high-level entry point is public; the
|
||||
@@ -56,6 +57,10 @@ export {
|
||||
AddResult,
|
||||
AddColumnsResult,
|
||||
RefreshColumnResult,
|
||||
FunctionErrors,
|
||||
FunctionErrorsOptions,
|
||||
FunctionErrorRecord,
|
||||
FunctionErrorFragment,
|
||||
RefreshMaterializedViewResult,
|
||||
AlterColumnsResult,
|
||||
UpdateFieldMetadataResult,
|
||||
@@ -72,11 +77,22 @@ export {
|
||||
|
||||
export {
|
||||
makeArrowTable,
|
||||
makeJsonField,
|
||||
MakeArrowTableOptions,
|
||||
Data,
|
||||
VectorColumnOptions,
|
||||
} from "./arrow";
|
||||
|
||||
export { blob, isBlobField, BlobFile } from "./blob";
|
||||
export type {
|
||||
BlobData,
|
||||
BlobInput,
|
||||
BlobOptions,
|
||||
BlobRange,
|
||||
BlobReadOptions,
|
||||
BlobUri,
|
||||
} from "./blob";
|
||||
|
||||
export {
|
||||
Connection,
|
||||
CreateTableOptions,
|
||||
@@ -94,13 +110,9 @@ export {
|
||||
RenameTableOptions,
|
||||
} from "./connection";
|
||||
|
||||
export {
|
||||
Job,
|
||||
JobDescription,
|
||||
JobFailureInfo,
|
||||
JobInfo,
|
||||
Session,
|
||||
} from "./native.js";
|
||||
export { JobFailureInfo, JobInfo, Session } from "./native.js";
|
||||
|
||||
export { Job, JobEventsOptions } from "./job";
|
||||
|
||||
export {
|
||||
AutoQuery,
|
||||
@@ -171,7 +183,15 @@ export {
|
||||
TokenResponse,
|
||||
} from "./header";
|
||||
|
||||
export { OAuthConfig, OAuthFlowType } from "./oauth";
|
||||
export {
|
||||
ClientAuthMethod,
|
||||
OAuthConfig,
|
||||
OAuthFlowType,
|
||||
OAuthSession,
|
||||
SessionLogout,
|
||||
SessionStatus,
|
||||
TokenCacheOptions,
|
||||
} from "./oauth";
|
||||
|
||||
export { MergeInsertBuilder, WriteExecutionOptions } from "./merge";
|
||||
|
||||
@@ -182,6 +202,7 @@ export * as rerankers from "./rerankers";
|
||||
export {
|
||||
SchemaLike,
|
||||
TableLike,
|
||||
DataTypeLike,
|
||||
FieldLike,
|
||||
RecordBatchLike,
|
||||
DataLike,
|
||||
@@ -621,3 +642,12 @@ export async function connectNamespace(
|
||||
);
|
||||
return new LocalConnection(nativeConn);
|
||||
}
|
||||
|
||||
export {
|
||||
Catalog,
|
||||
CatalogOptions,
|
||||
DatabaseNames,
|
||||
connectCatalog,
|
||||
} from "./catalog";
|
||||
|
||||
export { Listing, ListingOptions } from "./listing";
|
||||
|
||||
@@ -26,7 +26,7 @@ export interface IvfPqOptions {
|
||||
* This value controls how much the vector is compressed during the quantization step.
|
||||
* The more sub vectors there are the less the vector is compressed. The default is
|
||||
* the dimension of the vector divided by 16. If the dimension is not evenly divisible
|
||||
* by 16 we use the dimension divded by 8.
|
||||
* by 16 we use the dimension divided by 8.
|
||||
*
|
||||
* The above two cases are highly preferred. Having 8 or 16 values per subvector allows
|
||||
* us to use efficient SIMD instructions.
|
||||
@@ -228,7 +228,7 @@ export interface HnswPqOptions {
|
||||
* This value controls how much the vector is compressed during the quantization step.
|
||||
* The more sub vectors there are the less the vector is compressed. The default is
|
||||
* the dimension of the vector divided by 16. If the dimension is not evenly divisible
|
||||
* by 16 we use the dimension divded by 8.
|
||||
* by 16 we use the dimension divided by 8.
|
||||
*
|
||||
* The above two cases are highly preferred. Having 8 or 16 values per subvector allows
|
||||
* us to use efficient SIMD instructions.
|
||||
@@ -491,6 +491,7 @@ export type BaseTokenizer =
|
||||
| "whitespace"
|
||||
| "raw"
|
||||
| "ngram"
|
||||
| "code"
|
||||
| "icu"
|
||||
| "icu/split"
|
||||
| `jieba/${string}`
|
||||
@@ -520,6 +521,8 @@ export interface FtsOptions {
|
||||
*
|
||||
* "raw" - Raw tokenizer. This tokenizer does not split the text into tokens and indexes the entire text as a single token.
|
||||
*
|
||||
* "code" - Code tokenizer. This tokenizer keeps snake_case and camelCase identifiers as single tokens.
|
||||
*
|
||||
* "icu" - ICU dictionary-based word segmentation.
|
||||
*
|
||||
* "icu/split" - ICU segmentation with simple-style delimiter splitting.
|
||||
@@ -825,7 +828,7 @@ export interface IndexOptions {
|
||||
/**
|
||||
* Advanced index configuration
|
||||
*
|
||||
* This option allows you to specify a specfic index to create and also
|
||||
* This option allows you to specify a specific index to create and also
|
||||
* allows you to pass in configuration for training the index.
|
||||
*
|
||||
* See the static methods on Index for details on the various index types.
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
import { Table as ArrowTable, tableFromIPC } from "apache-arrow";
|
||||
import { JobFailureInfo, Job as NativeJob } from "./native";
|
||||
|
||||
/** Which of a job's events {@link Job.events} returns. */
|
||||
export interface JobEventsOptions {
|
||||
/** Maximum event rows to return, up to the server maximum of 10,000. */
|
||||
limit?: number;
|
||||
/** SQL-like filter over the event columns. */
|
||||
filter?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A handle to an operation that may still be running.
|
||||
*
|
||||
* The operation may already be complete when the handle is created.
|
||||
*
|
||||
* The detail getters read what the handle last observed. Submitting an
|
||||
* operation returns only a job id, so populating them eagerly would cost an
|
||||
* extra round trip on every call:
|
||||
*
|
||||
* - {@link Job.refresh} and {@link Job.status} fetch the whole record.
|
||||
* - {@link Job.wait} records the terminal state it establishes, but not the
|
||||
* rest of the record.
|
||||
* - Everything is null until one of those runs.
|
||||
*
|
||||
* @hideconstructor
|
||||
*/
|
||||
export class Job {
|
||||
private readonly inner: NativeJob;
|
||||
|
||||
constructor(inner: NativeJob) {
|
||||
this.inner = inner;
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifies the operation on the server that is running it.
|
||||
*
|
||||
* Operations that run in this process have no server id. The value is
|
||||
* opaque: parsing it or storing it to resume the job later is not supported.
|
||||
*/
|
||||
get id(): string | null {
|
||||
return this.inner.id ?? null;
|
||||
}
|
||||
|
||||
/** The last observed lifecycle state, without contacting the backend. */
|
||||
get state(): string | null {
|
||||
return this.inner.state ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The job's type, as the server names it. Null for an in-process job, which
|
||||
* has no server-side record.
|
||||
*/
|
||||
get jobType(): string | null {
|
||||
return this.inner.jobType ?? null;
|
||||
}
|
||||
|
||||
/** When the job was created, in milliseconds since the epoch. */
|
||||
get creationMs(): number | null {
|
||||
return this.inner.creationMs ?? null;
|
||||
}
|
||||
|
||||
/** The job-type-specific specification it was submitted with. */
|
||||
// biome-ignore lint/suspicious/noExplicitAny: shape varies by job type
|
||||
get spec(): any | null {
|
||||
return parseJson(this.inner.specJson);
|
||||
}
|
||||
|
||||
/**
|
||||
* The job-type-specific terminal result. Null until the job succeeds, so a
|
||||
* job that never terminates reports its progress through {@link Job.events}
|
||||
* instead.
|
||||
*/
|
||||
// biome-ignore lint/suspicious/noExplicitAny: shape varies by job type
|
||||
get result(): any | null {
|
||||
return parseJson(this.inner.resultJson);
|
||||
}
|
||||
|
||||
/** Why the job failed, when it failed and the server reports a reason. */
|
||||
get failure(): JobFailureInfo | null {
|
||||
return this.inner.failure ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The operation's current lifecycle state: "running", "finished", "failed",
|
||||
* or "cancelled".
|
||||
*
|
||||
* A point snapshot; unlike {@link Job.wait} it does not block or reject on a
|
||||
* terminal failure state. Also refreshes the getters above.
|
||||
*/
|
||||
async status(): Promise<string> {
|
||||
return this.inner.status();
|
||||
}
|
||||
|
||||
/** Wait until the operation reaches a terminal state. */
|
||||
async wait(): Promise<void> {
|
||||
return this.inner.wait();
|
||||
}
|
||||
|
||||
/** Request cancellation. Cancelling a finished operation is a no-op. */
|
||||
async cancel(): Promise<void> {
|
||||
return this.inner.cancel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the backend for this job's current state, and for a server-side job
|
||||
* its full record, then cache it for the getters above.
|
||||
*/
|
||||
async refresh(): Promise<void> {
|
||||
return this.inner.refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* This job's recorded lifecycle events.
|
||||
*
|
||||
* Where the getters above report a terminal result only once the job reaches
|
||||
* one, events are written as the job runs and outlive the workers that
|
||||
* produced them. A distributed job records a `claim`/`claim_complete` pair
|
||||
* per unit of work, each carrying `rows_processed`, so a job that never
|
||||
* finishes still accounts for what it did.
|
||||
*
|
||||
* The server caps results at 1000 rows by default and 10,000 at most, and
|
||||
* truncates without saying so, so pass `limit` for a job that emits an event
|
||||
* per fragment. `filter` is a SQL-like expression over the `state`,
|
||||
* `updated_by`, `emitted_from`, `emitted_by`, and `claim_entity` columns.
|
||||
*/
|
||||
async events(options?: JobEventsOptions): Promise<ArrowTable> {
|
||||
const buf = await this.inner.events(options?.limit, options?.filter);
|
||||
if (buf.length === 0) {
|
||||
return new ArrowTable();
|
||||
}
|
||||
return tableFromIPC(buf);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every field the handle currently knows, one per line, with the JSON
|
||||
* payloads indented -- a refresh job's spec and result are the point of
|
||||
* printing it.
|
||||
*/
|
||||
toString(): string {
|
||||
if (this.state === null) {
|
||||
const known = this.id === null ? "" : `id=${JSON.stringify(this.id)}, `;
|
||||
return `Job(${known}not refreshed)`;
|
||||
}
|
||||
const fields: string[] = [];
|
||||
if (this.id !== null) {
|
||||
fields.push(`id=${JSON.stringify(this.id)}`);
|
||||
}
|
||||
fields.push(`state=${JSON.stringify(this.state)}`);
|
||||
if (this.jobType !== null) {
|
||||
fields.push(`jobType=${JSON.stringify(this.jobType)}`);
|
||||
}
|
||||
if (this.creationMs !== null) {
|
||||
fields.push(`creationMs=${this.creationMs}`);
|
||||
}
|
||||
for (const [name, value] of [
|
||||
["spec", this.spec],
|
||||
["result", this.result],
|
||||
] as const) {
|
||||
if (value !== null) {
|
||||
fields.push(`${name}=${indentJson(value)}`);
|
||||
}
|
||||
}
|
||||
if (this.failure !== null) {
|
||||
fields.push(`failure=${indentJson(this.failure)}`);
|
||||
}
|
||||
return `Job(${fields.map((field) => `\n${REPR_INDENT}${field},`).join("")}\n)`;
|
||||
}
|
||||
|
||||
[Symbol.for("nodejs.util.inspect.custom")](): string {
|
||||
return this.toString();
|
||||
}
|
||||
}
|
||||
|
||||
const REPR_INDENT = " ";
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: shape varies by job type
|
||||
function indentJson(value: any): string {
|
||||
return JSON.stringify(value, null, 4).replace(/\n/g, `\n${REPR_INDENT}`);
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: shape varies by job type
|
||||
function parseJson(raw: string | null | undefined): any | null {
|
||||
return raw === null || raw === undefined ? null : JSON.parse(raw);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
/** Pagination controls for lazy resource listings. */
|
||||
export interface ListingOptions {
|
||||
/** Starting continuation token; omitted starts at the beginning. */
|
||||
pageToken?: string;
|
||||
/** Maximum results per request, not a limit on the whole listing. */
|
||||
pageLimit?: number;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function validateListingOptions(options: ListingOptions): void {
|
||||
if (
|
||||
options.pageLimit !== undefined &&
|
||||
(!Number.isInteger(options.pageLimit) ||
|
||||
options.pageLimit <= 0 ||
|
||||
options.pageLimit > 2147483647)
|
||||
) {
|
||||
throw new Error(
|
||||
"Listing page limit must be an integer between 1 and 2147483647",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A lazy async iterator over database resources with pagination state.
|
||||
* Requests occur during iteration; request errors terminate the iterator.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* for await (const name of connection.listViews([], { pageLimit: 20 })) {
|
||||
* console.log(name);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class Listing<T> implements AsyncIterableIterator<T> {
|
||||
/** @hidden */
|
||||
constructor(
|
||||
private readonly inner: {
|
||||
next(): Promise<T | null | undefined>;
|
||||
numPageResults(): number;
|
||||
pageToken(): string | null | undefined;
|
||||
},
|
||||
) {}
|
||||
|
||||
[Symbol.asyncIterator](): AsyncIterableIterator<T> {
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Fetch the next item, requesting another page only when needed. */
|
||||
async next(): Promise<IteratorResult<T>> {
|
||||
const value = await this.inner.next();
|
||||
return value == null
|
||||
? { done: true, value: undefined }
|
||||
: { done: false, value };
|
||||
}
|
||||
|
||||
/** Number of cached items available without another request. */
|
||||
numPageResults(): number {
|
||||
return this.inner.numPageResults();
|
||||
}
|
||||
|
||||
/**
|
||||
* Token for the next request. Initially this is the supplied starting token.
|
||||
* Returns undefined after fetching the final page, even with cached items.
|
||||
* Drain the cache before saving a token to avoid skipping items on resumption.
|
||||
* Failed requests terminate iteration and retain their token for a new iterator.
|
||||
*/
|
||||
pageToken(): string | undefined {
|
||||
return this.inner.pageToken() ?? undefined;
|
||||
}
|
||||
}
|
||||
@@ -7,18 +7,21 @@ import { Table } from "./table";
|
||||
/** Schema metadata key holding a materialized view's definition. */
|
||||
export const DEFINITION_META_KEY = "mv.definition";
|
||||
|
||||
/** The query that defines a materialized view. */
|
||||
/**
|
||||
* The newest stored layout this version reads: `{"format": N, "query": "<SQL>"}`,
|
||||
* format 2 being a query with `GROUP BY`.
|
||||
*/
|
||||
export const DEFINITION_FORMAT = 2;
|
||||
|
||||
/**
|
||||
* The query that defines a materialized view, as stored:
|
||||
* `SELECT columns FROM [ns.]table [, function(args) AS alias | , UNNEST(column) AS alias]
|
||||
* [WHERE predicate] [GROUP BY expr, ...] [LIMIT n]`. A Function in `FROM`
|
||||
* position yields one row per element it returns.
|
||||
*/
|
||||
export interface MaterializedViewDefinition {
|
||||
/** Name of the source table, in the same database as the view. */
|
||||
sourceTable: string;
|
||||
/** `[output column, SQL expression]` pairs, in view schema order. */
|
||||
projections: [string, string][];
|
||||
/** SQL predicate selecting the source rows the view holds. */
|
||||
filter?: string;
|
||||
/** Cap on the number of rows the view holds. */
|
||||
limit?: number;
|
||||
/** Source columns the projections and filter read. */
|
||||
inputs: string[];
|
||||
/** The defining query, in the canonical spelling the server stores. */
|
||||
query: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,12 +79,31 @@ export function definitionFromMetadata(
|
||||
if (raw === undefined) {
|
||||
throw new Error(`Table '${name}' is not a materialized view`);
|
||||
}
|
||||
return definitionFromJson(raw, name);
|
||||
}
|
||||
|
||||
/** @internal Parse the backend-independent definition returned by native code. */
|
||||
export function definitionFromJson(
|
||||
raw: string,
|
||||
name: string,
|
||||
): MaterializedViewDefinition {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: raw JSON
|
||||
const value: any = JSON.parse(raw);
|
||||
if (value.kind !== "select") {
|
||||
if (value.format !== undefined) {
|
||||
// A newer writer's layout is reported, never guessed at.
|
||||
if (!Number.isInteger(value.format) || value.format > DEFINITION_FORMAT) {
|
||||
throw new Error(
|
||||
`materialized view '${name}' is stored in format ${value.format}, ` +
|
||||
"which this version of lancedb cannot refresh",
|
||||
);
|
||||
}
|
||||
return { query: value.query };
|
||||
}
|
||||
// The structured layout written before the format number.
|
||||
if (value.kind !== "select" && value.kind !== "namespaced_select") {
|
||||
throw new Error(
|
||||
`materialized view '${name}' is defined by '${value.kind}', which this ` +
|
||||
"version of lancedb cannot refresh",
|
||||
`materialized view '${name}' is stored in format kind '${value.kind}', ` +
|
||||
"which this version of lancedb cannot refresh",
|
||||
);
|
||||
}
|
||||
const limit = value.limit ?? undefined;
|
||||
@@ -93,17 +115,41 @@ export function definitionFromMetadata(
|
||||
`materialized view '${name}' has a stored limit too large to represent exactly`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
sourceTable: value.source_table,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: raw JSON
|
||||
projections: (value.projections ?? []).map((p: any) => [
|
||||
p.output,
|
||||
p.expression,
|
||||
]),
|
||||
filter: value.filter ?? undefined,
|
||||
limit,
|
||||
inputs: value.inputs ?? [],
|
||||
};
|
||||
return { query: legacyQuery(value, limit) };
|
||||
}
|
||||
|
||||
function legacyIdent(name: string): string {
|
||||
return /^[a-z_][a-z0-9_]*$/.test(name)
|
||||
? name
|
||||
: `\`${name.replace(/`/g, "``")}\``;
|
||||
}
|
||||
|
||||
/** Render the pre-format structured layout as the query it described. */
|
||||
// biome-ignore lint/suspicious/noExplicitAny: raw JSON
|
||||
function legacyQuery(value: any, limit: number | undefined): string {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: raw JSON
|
||||
const projections: any[] = value.projections ?? [];
|
||||
const columns =
|
||||
projections.length === 0
|
||||
? "*"
|
||||
: projections
|
||||
.map((p) =>
|
||||
p.expression === p.output || p.expression === `\`${p.output}\``
|
||||
? p.expression
|
||||
: `${p.expression} AS ${legacyIdent(p.output)}`,
|
||||
)
|
||||
.join(", ");
|
||||
const table = [...(value.source_namespace ?? []), value.source_table]
|
||||
.map(legacyIdent)
|
||||
.join(".");
|
||||
let query = `SELECT ${columns} FROM ${table}`;
|
||||
if (value.filter !== undefined && value.filter !== null) {
|
||||
query += ` WHERE ${value.filter}`;
|
||||
}
|
||||
if (limit !== undefined) {
|
||||
query += ` LIMIT ${limit}`;
|
||||
}
|
||||
return query;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -130,32 +176,25 @@ export class MaterializedView {
|
||||
return this.inner;
|
||||
}
|
||||
|
||||
/** The query that defines the view, read from its stored schema. */
|
||||
/** The query that defines the view. */
|
||||
async definition(): Promise<MaterializedViewDefinition> {
|
||||
const schema = await this.inner.schema();
|
||||
return definitionFromMetadata(schema.metadata, this.name);
|
||||
return definitionFromJson(
|
||||
await this.inner.materializedViewDefinition(),
|
||||
this.name,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute the view from its source.
|
||||
*
|
||||
* The refresh is incremental when the source's changes can be reconciled
|
||||
* into the view -- rows added, changed or removed since the last one --
|
||||
* and otherwise rebuilds. `full` forces a rebuild; `sourceVersion`
|
||||
* refreshes to that source version instead of the latest.
|
||||
*
|
||||
* Concurrent refreshes of one view do not duplicate its rows. Two that
|
||||
* plan the same source rows conflict on commit, and the loser throws
|
||||
* rather than writing them a second time.
|
||||
* Every refresh rebuilds the complete view. For local views,
|
||||
* `sourceVersion` refreshes to that source version instead of the latest;
|
||||
* remote SQL refreshes do not support source-version pinning.
|
||||
*/
|
||||
async refresh(options?: {
|
||||
full?: boolean;
|
||||
sourceVersion?: number;
|
||||
}): Promise<RefreshMaterializedViewResult> {
|
||||
validateNonNegativeInteger(options?.sourceVersion, "sourceVersion");
|
||||
return await this.inner.refreshMaterializedView(
|
||||
options?.full,
|
||||
options?.sourceVersion,
|
||||
);
|
||||
return await this.inner.refreshMaterializedView(options?.sourceVersion);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ export class MergeInsertBuilder {
|
||||
* but that behavior is subject to change.
|
||||
*
|
||||
* An optional condition may be specified. If it is, then only
|
||||
* matched rows that satisfy the condtion will be updated. Any
|
||||
* matched rows that satisfy the condition will be updated. Any
|
||||
* rows that do not satisfy the condition will be left as they
|
||||
* are. Failing to satisfy the condition does not cause a
|
||||
* "matched row" to become a "not matched" row.
|
||||
|
||||
@@ -1,16 +1,79 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
import {
|
||||
OAuthConfig as NativeOAuthConfig,
|
||||
OAuthSession as NativeOAuthSession,
|
||||
} from "./native";
|
||||
|
||||
/**
|
||||
* OAuth authentication flow types.
|
||||
*/
|
||||
export enum OAuthFlowType {
|
||||
/** Client Credentials grant (service-to-service / M2M). */
|
||||
ClientCredentials = "client_credentials",
|
||||
/** Interactive Authorization Code grant, using PKCE by default. */
|
||||
AuthorizationCode = "authorization_code",
|
||||
/** Device Authorization grant for CLI and headless environments. */
|
||||
DeviceCode = "device_code",
|
||||
/** Azure Managed Identity via IMDS. */
|
||||
AzureManagedIdentity = "azure_managed_identity",
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for the persistent OAuth token cache.
|
||||
*
|
||||
* The cache is opt-in: it is only used when set as `tokenCache` on
|
||||
* {@link OAuthConfig}. Only refresh tokens are persisted, in a private
|
||||
* directory with owner-only permissions, so short-lived processes can reuse
|
||||
* an authenticated session instead of re-prompting on every start.
|
||||
*
|
||||
* Multiple identities (issuer, client, scopes, resource, audience, flow, client authentication)
|
||||
* get separate cache entries. Within one identity the most recent login wins.
|
||||
*/
|
||||
export interface TokenCacheOptions {
|
||||
/**
|
||||
* Directory that holds cached credentials. Defaults to
|
||||
* `$XDG_CACHE_HOME/lancedb/oauth`, `$HOME/.cache/lancedb/oauth` on Unix,
|
||||
* or `%LOCALAPPDATA%\\lancedb\\oauth` on Windows. The directory is created
|
||||
* with owner-only permissions (`0700`) when missing.
|
||||
*/
|
||||
cacheDir?: string;
|
||||
|
||||
/**
|
||||
* How long to wait for the cross-process refresh lock before failing, in
|
||||
* seconds (default: 30).
|
||||
*/
|
||||
lockTimeoutSecs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* How the client authenticates to the OAuth token endpoint.
|
||||
*
|
||||
* The method applies to every OAuth request that carries client
|
||||
* authentication: client-credentials, authorization-code exchange,
|
||||
* refresh-token, and device-authorization requests. The Azure managed
|
||||
* identity flow ignores this option.
|
||||
*/
|
||||
export enum ClientAuthMethod {
|
||||
/**
|
||||
* No client authentication, for public clients using PKCE or the device
|
||||
* flow. Cannot be combined with `clientSecret`.
|
||||
*/
|
||||
None = "none",
|
||||
/**
|
||||
* HTTP Basic authentication. This is the RFC 6749 recommended method and
|
||||
* the normal default for confidential clients, including default Okta
|
||||
* applications. Requires `clientSecret`.
|
||||
*/
|
||||
ClientSecretBasic = "client_secret_basic",
|
||||
/**
|
||||
* Credentials in the request body, for providers configured to require it.
|
||||
* Requires `clientSecret`.
|
||||
*/
|
||||
ClientSecretPost = "client_secret_post",
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth configuration for LanceDB authentication.
|
||||
*
|
||||
@@ -31,6 +94,18 @@ export enum OAuthFlowType {
|
||||
* };
|
||||
* ```
|
||||
*
|
||||
* Providers requiring an explicit target can set `resource` and/or `audience`:
|
||||
* ```typescript
|
||||
* const targeted: OAuthConfig = {
|
||||
* issuerUrl: "https://issuer.example.com",
|
||||
* clientId: "app-id",
|
||||
* clientSecret: "secret",
|
||||
* scopes: ["read"],
|
||||
* resource: "https://api.example.com",
|
||||
* audience: "lancedb-api",
|
||||
* };
|
||||
* ```
|
||||
*
|
||||
* @example Azure Managed Identity:
|
||||
* ```typescript
|
||||
* const config: OAuthConfig = {
|
||||
@@ -40,6 +115,21 @@ export enum OAuthFlowType {
|
||||
* flow: OAuthFlowType.AzureManagedIdentity,
|
||||
* };
|
||||
* ```
|
||||
*
|
||||
* @example Authorization Code with PKCE:
|
||||
* The authorization URL is written to stderr before LanceDB tries to open a
|
||||
* browser, so it can be copied in headless environments.
|
||||
* ```typescript
|
||||
* const config: OAuthConfig = {
|
||||
* issuerUrl: "https://login.microsoftonline.com/{tenant}/v2.0",
|
||||
* clientId: "app-id",
|
||||
* scopes: ["openid", "api://lancedb-api/access"],
|
||||
* flow: OAuthFlowType.AuthorizationCode,
|
||||
* };
|
||||
* ```
|
||||
*
|
||||
* Device Authorization writes the verification URL and user code to stderr
|
||||
* before polling begins.
|
||||
*/
|
||||
export interface OAuthConfig {
|
||||
/**
|
||||
@@ -58,12 +148,43 @@ export interface OAuthConfig {
|
||||
*/
|
||||
scopes: string[];
|
||||
|
||||
/**
|
||||
* Resource indicator (RFC 8707), forwarded verbatim to authorization and token
|
||||
* endpoints, including refresh requests. Must be an absolute URI without a
|
||||
* fragment. Not supported for Azure managed identity.
|
||||
*/
|
||||
resource?: string;
|
||||
|
||||
/**
|
||||
* Provider-specific audience, forwarded to authorization and token endpoints,
|
||||
* including refresh requests. Not supported for Azure managed identity.
|
||||
*/
|
||||
audience?: string;
|
||||
|
||||
/** Authentication flow (default: ClientCredentials). */
|
||||
flow?: OAuthFlowType;
|
||||
|
||||
/** Client secret (required for ClientCredentials). */
|
||||
clientSecret?: string;
|
||||
|
||||
/**
|
||||
* How the client authenticates to the token endpoint (default: auto).
|
||||
* With a `clientSecret` the default is `ClientAuthMethod.ClientSecretBasic`,
|
||||
* which matches the RFC 6749 recommendation and the default configuration
|
||||
* of Okta confidential applications; without a secret the client is public
|
||||
* and no client authentication is sent.
|
||||
*/
|
||||
clientAuthMethod?: ClientAuthMethod;
|
||||
|
||||
/** Loopback redirect URI for AuthorizationCode. */
|
||||
redirectUri?: string;
|
||||
|
||||
/** Port for the AuthorizationCode loopback callback server (default: 8400). */
|
||||
callbackPort?: number;
|
||||
|
||||
/** Protect AuthorizationCode with S256 PKCE (default: true). */
|
||||
usePkce?: boolean;
|
||||
|
||||
/** Client ID for user-assigned managed identity (AzureManagedIdentity). */
|
||||
managedIdentityClientId?: string;
|
||||
|
||||
@@ -73,4 +194,125 @@ export interface OAuthConfig {
|
||||
* the TTL, each request refreshes the token.
|
||||
*/
|
||||
refreshBufferSecs?: number;
|
||||
|
||||
/**
|
||||
* Opt in to the persistent token cache so short-lived processes reuse one
|
||||
* session. Only refresh tokens are persisted. Only supported by
|
||||
* AuthorizationCode and DeviceCode; Azure managed identity is rejected.
|
||||
* Default: unset (memory only).
|
||||
*/
|
||||
tokenCache?: TokenCacheOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe, non-secret view of a cached OAuth session, returned by
|
||||
* {@link OAuthSession.status} and {@link OAuthSession.login}.
|
||||
*/
|
||||
export interface SessionStatus {
|
||||
/**
|
||||
* Whether a cached session exists that can obtain tokens without
|
||||
* interactive authentication. Because access tokens are not persisted,
|
||||
* this is `true` exactly when a refresh token is cached; the next
|
||||
* connection refreshes with it rather than opening a browser or device
|
||||
* prompt.
|
||||
*/
|
||||
refreshable: boolean;
|
||||
|
||||
/** Canonical issuer URL of the cached session. */
|
||||
issuerUrl: string;
|
||||
|
||||
/** Client ID of the cached session. */
|
||||
clientId: string;
|
||||
|
||||
/** Canonical (sorted, de-duplicated) scope set of the cached session. */
|
||||
scopes: string[];
|
||||
|
||||
/** Resource indicator used to obtain the cached session, if configured. */
|
||||
resource?: string;
|
||||
|
||||
/** Provider-specific audience used to obtain the cached session, if configured. */
|
||||
audience?: string;
|
||||
|
||||
/** Flow that produced the cached session. */
|
||||
flow: string;
|
||||
|
||||
/** When the cached session was obtained, as Unix seconds. */
|
||||
obtainedAt?: number;
|
||||
}
|
||||
|
||||
/** Result of {@link OAuthSession.logout}. */
|
||||
export interface SessionLogout {
|
||||
/**
|
||||
* Whether a cached credential was removed. `false` means no matching
|
||||
* session was cached; logout is idempotent.
|
||||
*/
|
||||
removed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicit OAuth session lifecycle for the persistent token cache: eager
|
||||
* `login`, non-secret `status`, and local `logout`.
|
||||
*
|
||||
* A session is built from the same {@link OAuthConfig} used to connect
|
||||
* (including its `tokenCache` options). A connection created with the same
|
||||
* configuration shares the cache, so logging in here prepares tokens for
|
||||
* later processes without any database request.
|
||||
*
|
||||
* `login` always runs the configured interactive flow and replaces the cached
|
||||
* session (the most recent login wins). `logout` removes only the local
|
||||
* credential; it does not revoke anything with the provider and does not sign
|
||||
* out of a browser SSO session.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const config: OAuthConfig = {
|
||||
* issuerUrl: "https://issuer.example.com",
|
||||
* clientId: "my-app",
|
||||
* scopes: ["openid", "offline_access"],
|
||||
* flow: OAuthFlowType.DeviceCode,
|
||||
* tokenCache: { cacheDir: "/tmp/my-app/oauth-cache" },
|
||||
* };
|
||||
* const session = new OAuthSession(config);
|
||||
* const status = await session.login();
|
||||
* ```
|
||||
*/
|
||||
export class OAuthSession {
|
||||
private readonly inner: NativeOAuthSession;
|
||||
|
||||
/** Create a session manager for the given OAuth configuration. */
|
||||
constructor(config: OAuthConfig) {
|
||||
this.inner = new NativeOAuthSession(config as unknown as NativeOAuthConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* Eagerly run the configured authentication flow and store the session.
|
||||
*
|
||||
* A successful login always replaces any prior cached session for this
|
||||
* identity; if the provider does not issue a refresh token (for example
|
||||
* without `offline_access`), the previous record is removed and the status
|
||||
* reports `refreshable == false`.
|
||||
*/
|
||||
async login(): Promise<SessionStatus> {
|
||||
return this.inner.login();
|
||||
}
|
||||
|
||||
/**
|
||||
* Report whether a matching cached session exists, with safe metadata.
|
||||
*
|
||||
* This never contacts the identity provider and never exposes token values.
|
||||
*/
|
||||
async status(): Promise<SessionStatus> {
|
||||
return this.inner.status();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the matching local cached credential.
|
||||
*
|
||||
* This only deletes the local cache entry. It does not revoke the refresh
|
||||
* token with the provider and does not sign out of a browser SSO session.
|
||||
* Repeated calls succeed; `removed` reports whether a credential existed.
|
||||
*/
|
||||
async logout(): Promise<SessionLogout> {
|
||||
return this.inner.logout();
|
||||
}
|
||||
}
|
||||
|
||||
+38
-29
@@ -41,26 +41,6 @@ export async function* RecordBatchIterator(
|
||||
}
|
||||
}
|
||||
|
||||
class RecordBatchIterable<
|
||||
NativeQueryType extends NativeQuery | NativeVectorQuery | NativeTakeQuery,
|
||||
> implements AsyncIterable<RecordBatch>
|
||||
{
|
||||
private inner: NativeQueryType;
|
||||
private options?: QueryExecutionOptions;
|
||||
|
||||
constructor(inner: NativeQueryType, options?: QueryExecutionOptions) {
|
||||
this.inner = inner;
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: skip
|
||||
[Symbol.asyncIterator](): AsyncIterator<RecordBatch<any>, any, undefined> {
|
||||
return RecordBatchIterator(
|
||||
this.inner.execute(this.options?.maxBatchLength, this.options?.timeoutMs),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Options that control the behavior of a particular query execution
|
||||
*/
|
||||
@@ -271,13 +251,20 @@ export class QueryBase<
|
||||
return RecordBatchIterator(this.nativeExecute());
|
||||
}
|
||||
|
||||
/** Collect the results as an Arrow @see {@link ArrowTable}. */
|
||||
/**
|
||||
* Collect the results as an Arrow @see {@link ArrowTable}.
|
||||
*
|
||||
* Empty results retain the query's output schema.
|
||||
*/
|
||||
async toArrow(options?: Partial<QueryExecutionOptions>): Promise<ArrowTable> {
|
||||
const batches = [];
|
||||
const inner = await this.getInner();
|
||||
for await (const batch of new RecordBatchIterable(inner, options)) {
|
||||
const iterator = this.nativeExecute(options);
|
||||
for await (const batch of RecordBatchIterator(iterator)) {
|
||||
batches.push(batch);
|
||||
}
|
||||
if (batches.length === 0) {
|
||||
return tableFromIPC((await iterator).schema());
|
||||
}
|
||||
return new ArrowTable(batches);
|
||||
}
|
||||
|
||||
@@ -532,6 +519,8 @@ export class VectorQuery extends StandardQueryBase<NativeVectorQuery> {
|
||||
/**
|
||||
* Set the number of partitions to search (probe)
|
||||
*
|
||||
* The number of probes must be greater than 0.
|
||||
*
|
||||
* This argument is only used when the vector column has an IVF PQ index.
|
||||
* If there is no index then this value is ignored.
|
||||
*
|
||||
@@ -543,17 +532,16 @@ export class VectorQuery extends StandardQueryBase<NativeVectorQuery> {
|
||||
* partitions should be searched.
|
||||
*
|
||||
* Increasing this value will increase the recall of your query but will
|
||||
* also increase the latency of your query. The default value is 20. This
|
||||
* default is good for many cases but the best value to use will depend on
|
||||
* your data and the recall that you need to achieve.
|
||||
* also increase the latency of your query. If this method is not called,
|
||||
* Lance's adaptive probe defaults are used.
|
||||
*
|
||||
* For best results we recommend tuning this parameter with a benchmark against
|
||||
* your actual data to find the smallest possible value that will still give
|
||||
* you the desired recall.
|
||||
*
|
||||
* For more fine grained control over behavior when you have a very narrow filter
|
||||
* you can use `minimumNprobes` and `maximumNprobes`. This method sets both
|
||||
* the minimum and maximum to the same value.
|
||||
* The value is retained as `nprobes` through client and server request
|
||||
* construction. Lance sets both probe bounds to this value. Explicit minimum
|
||||
* or maximum settings can override their respective bounds.
|
||||
*/
|
||||
nprobes(nprobes: number): VectorQuery {
|
||||
this.doVectorCall((inner) => inner.nprobes(nprobes));
|
||||
@@ -561,6 +549,27 @@ export class VectorQuery extends StandardQueryBase<NativeVectorQuery> {
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the initial IVF search effort to a finite value in [0, 1].
|
||||
*
|
||||
* The default, null, uses Auto (internally 0.5). Zero starts at the caller
|
||||
* minimum; one searches all partitions within the caller maximum. Later
|
||||
* probing may continue when too few candidates are found.
|
||||
* Any explicit effort, including 0.5, overrides nprobes in either call order.
|
||||
* Explicit minimum and maximum bounds still apply. This is not a recall
|
||||
* guarantee and does not change refinement or HNSW ef. The MemWAL LSM
|
||||
* scanner does not yet support this option.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* table.vectorSearch([0.1, 0.2]).searchEffort(0.75).limit(10);
|
||||
* ```
|
||||
*/
|
||||
searchEffort(searchEffort: number | null): VectorQuery {
|
||||
this.doVectorCall((inner) => inner.searchEffort(searchEffort));
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the minimum number of probes used.
|
||||
*
|
||||
|
||||
+138
-16
@@ -3,12 +3,13 @@
|
||||
|
||||
// The utilities in this file help sanitize data from the user's arrow
|
||||
// library into the types expected by vectordb's arrow library. Node
|
||||
// generally allows for mulitple versions of the same library (and sometimes
|
||||
// generally allows for multiple versions of the same library (and sometimes
|
||||
// even multiple copies of the same version) to be installed at the same
|
||||
// time. However, arrow-js uses instanceof which expected that the input
|
||||
// comes from the exact same library instance. This is not always the case
|
||||
// and so we must sanitize the input to ensure that it is compatible.
|
||||
|
||||
import * as arrowModule from "apache-arrow";
|
||||
import { BufferType, Data, Vector } from "apache-arrow";
|
||||
import type { IntBitWidth, TKeys, TimeBitWidth } from "apache-arrow/type";
|
||||
import {
|
||||
@@ -43,6 +44,8 @@ import {
|
||||
Interval,
|
||||
IntervalDayTime,
|
||||
IntervalYearMonth,
|
||||
LargeBinary,
|
||||
LargeUtf8,
|
||||
List,
|
||||
Map_,
|
||||
Null,
|
||||
@@ -74,6 +77,66 @@ import {
|
||||
Utf8,
|
||||
} from "./arrow";
|
||||
|
||||
/**
|
||||
* Whether `value` was created by *this* copy of apache-arrow's `ctor`.
|
||||
*
|
||||
* Arrow 21 overrides `Symbol.hasInstance` on `Schema`, `Field`, `Table`,
|
||||
* `RecordBatch`, `Vector` and `Data` so that `instanceof` also matches objects
|
||||
* built by a different copy of the library. Those are exactly the objects
|
||||
* this module has to rebuild: the `DataType` instances nested inside them are
|
||||
* deliberately *not* given that treatment upstream, so Arrow's own builders and
|
||||
* writers reject them. Walk the real prototype chain instead of trusting
|
||||
* `instanceof`.
|
||||
*/
|
||||
function isOwnInstance<T>(
|
||||
value: unknown,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: matches any class constructor
|
||||
ctor: abstract new (...args: any[]) => T,
|
||||
): value is T {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
Object.prototype.isPrototypeOf.call(ctor.prototype, value)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Types added to Arrow JS after release 18, keyed by the `Type` enum value
|
||||
* they use. The library compiles and runs against every Arrow release in the
|
||||
* peer range, so these are looked up by name at runtime instead of imported:
|
||||
* an older Arrow simply does not have the class.
|
||||
*/
|
||||
type NewerTypeName =
|
||||
| "IntervalMonthDayNano"
|
||||
| "LargeList"
|
||||
| "BinaryView"
|
||||
| "Utf8View";
|
||||
const NEWER_TYPES: ReadonlyArray<{
|
||||
name: NewerTypeName;
|
||||
typeId: number;
|
||||
since: number;
|
||||
}> = [
|
||||
{ name: "IntervalMonthDayNano", typeId: -31, since: 20 },
|
||||
{ name: "LargeList", typeId: 21, since: 21 },
|
||||
{ name: "BinaryView", typeId: 23, since: 21 },
|
||||
{ name: "Utf8View", typeId: 24, since: 21 },
|
||||
];
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: constructor arity varies by type
|
||||
type DataTypeConstructor = new (...args: any[]) => DataType;
|
||||
|
||||
function newerTypeConstructor(name: NewerTypeName): DataTypeConstructor {
|
||||
const ctor = (arrowModule as unknown as Record<string, unknown>)[name];
|
||||
if (typeof ctor !== "function") {
|
||||
const since = NEWER_TYPES.find((t) => t.name === name)?.since;
|
||||
throw new Error(
|
||||
`The Arrow type ${name} requires apache-arrow ${since} or newer, ` +
|
||||
"but the installed apache-arrow does not provide it",
|
||||
);
|
||||
}
|
||||
return ctor as DataTypeConstructor;
|
||||
}
|
||||
|
||||
type SanitizationContext = {
|
||||
types: WeakMap<object, DataType>;
|
||||
vectors: WeakMap<object, Vector>;
|
||||
@@ -210,19 +273,37 @@ export function sanitizeList(typeLike: object) {
|
||||
return sanitizeListWithContext(typeLike, createSanitizationContext());
|
||||
}
|
||||
|
||||
function sanitizeListChildWithContext(
|
||||
typeLike: object,
|
||||
context: SanitizationContext,
|
||||
typeName: string,
|
||||
): Field {
|
||||
if (!("children" in typeLike) || !Array.isArray(typeLike.children)) {
|
||||
throw Error(
|
||||
`Expected a ${typeName} type to have an array-like \`children\` property`,
|
||||
);
|
||||
}
|
||||
if (typeLike.children.length !== 1) {
|
||||
throw Error(`Expected a ${typeName} type to have exactly one child`);
|
||||
}
|
||||
return sanitizeFieldWithContext(typeLike.children[0], context);
|
||||
}
|
||||
|
||||
function sanitizeListWithContext(
|
||||
typeLike: object,
|
||||
context: SanitizationContext,
|
||||
) {
|
||||
if (!("children" in typeLike) || !Array.isArray(typeLike.children)) {
|
||||
throw Error(
|
||||
"Expected a List type to have an array-like `children` property",
|
||||
);
|
||||
}
|
||||
if (typeLike.children.length !== 1) {
|
||||
throw Error("Expected a List type to have exactly one child");
|
||||
}
|
||||
return new List(sanitizeFieldWithContext(typeLike.children[0], context));
|
||||
return new List(sanitizeListChildWithContext(typeLike, context, "List"));
|
||||
}
|
||||
|
||||
function sanitizeLargeListWithContext(
|
||||
typeLike: object,
|
||||
context: SanitizationContext,
|
||||
) {
|
||||
const LargeList = newerTypeConstructor("LargeList");
|
||||
return new LargeList(
|
||||
sanitizeListChildWithContext(typeLike, context, "LargeList"),
|
||||
);
|
||||
}
|
||||
|
||||
export function sanitizeStruct(typeLike: object) {
|
||||
@@ -449,11 +530,30 @@ function sanitizeTypeWithContext(
|
||||
return type;
|
||||
}
|
||||
|
||||
function sanitizeNewerTypeById(
|
||||
typeLike: object,
|
||||
typeId: number,
|
||||
context: SanitizationContext,
|
||||
): DataType | undefined {
|
||||
const entry = NEWER_TYPES.find((t) => t.typeId === typeId);
|
||||
if (entry === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (entry.name === "LargeList") {
|
||||
return sanitizeLargeListWithContext(typeLike, context);
|
||||
}
|
||||
return new (newerTypeConstructor(entry.name))();
|
||||
}
|
||||
|
||||
function sanitizeTypeById(
|
||||
typeLike: object,
|
||||
typeId: Type,
|
||||
context: SanitizationContext,
|
||||
): DataType {
|
||||
const newer = sanitizeNewerTypeById(typeLike, typeId, context);
|
||||
if (newer !== undefined) {
|
||||
return newer;
|
||||
}
|
||||
switch (typeId) {
|
||||
case Type.NONE:
|
||||
throw Error("Received a Type with a typeId of NONE");
|
||||
@@ -465,8 +565,12 @@ function sanitizeTypeById(
|
||||
return sanitizeFloat(typeLike);
|
||||
case Type.Binary:
|
||||
return new Binary();
|
||||
case Type.LargeBinary:
|
||||
return new LargeBinary();
|
||||
case Type.Utf8:
|
||||
return new Utf8();
|
||||
case Type.LargeUtf8:
|
||||
return new LargeUtf8();
|
||||
case Type.Bool:
|
||||
return new Bool();
|
||||
case Type.Decimal:
|
||||
@@ -566,7 +670,7 @@ function sanitizeFieldWithContext(
|
||||
fieldLike: unknown,
|
||||
context: SanitizationContext,
|
||||
): Field {
|
||||
if (fieldLike instanceof Field) {
|
||||
if (isOwnInstance(fieldLike, Field)) {
|
||||
return fieldLike;
|
||||
}
|
||||
if (typeof fieldLike !== "object" || fieldLike === null) {
|
||||
@@ -620,7 +724,7 @@ function sanitizeSchemaWithContext(
|
||||
schemaLike: SchemaLike,
|
||||
context: SanitizationContext,
|
||||
): Schema {
|
||||
if (schemaLike instanceof Schema) {
|
||||
if (isOwnInstance(schemaLike, Schema)) {
|
||||
return schemaLike;
|
||||
}
|
||||
if (typeof schemaLike !== "object" || schemaLike === null) {
|
||||
@@ -647,7 +751,7 @@ function sanitizeSchemaWithContext(
|
||||
}
|
||||
|
||||
export function sanitizeTable(tableLike: TableLike): Table {
|
||||
if (tableLike instanceof Table) {
|
||||
if (isOwnInstance(tableLike, Table)) {
|
||||
return tableLike;
|
||||
}
|
||||
if (typeof tableLike !== "object" || tableLike === null) {
|
||||
@@ -675,7 +779,7 @@ function sanitizeRecordBatch(
|
||||
batchLike: RecordBatchLike,
|
||||
context: SanitizationContext,
|
||||
): RecordBatch {
|
||||
if (batchLike instanceof RecordBatch) {
|
||||
if (isOwnInstance(batchLike, RecordBatch)) {
|
||||
return batchLike;
|
||||
}
|
||||
if (typeof batchLike !== "object" || batchLike === null) {
|
||||
@@ -704,11 +808,17 @@ type DictionaryDataLike = DataLike & {
|
||||
dictionary?: DictionaryVectorLike;
|
||||
};
|
||||
|
||||
// Arrow 21 added `variadicBuffers` to `Data` for the Utf8View / BinaryView
|
||||
// types. Older Arrow versions do not have the property, so it is optional.
|
||||
type VariadicDataLike = DataLike & {
|
||||
variadicBuffers?: ReadonlyArray<Uint8Array>;
|
||||
};
|
||||
|
||||
function sanitizeData(
|
||||
dataLike: DataLike,
|
||||
context: SanitizationContext,
|
||||
): Data<DataType> {
|
||||
if (dataLike instanceof Data) {
|
||||
if (isOwnInstance(dataLike, Data)) {
|
||||
return dataLike;
|
||||
}
|
||||
const cachedData = context.data.get(dataLike);
|
||||
@@ -726,7 +836,12 @@ function sanitizeData(
|
||||
context.vectors.set(dictionaryLike, dictionary);
|
||||
}
|
||||
}
|
||||
const data = new Data(
|
||||
// Arrow 21 added an eighth constructor parameter (`variadicBuffers`); older
|
||||
// releases ignore the extra argument but their typings do not declare it.
|
||||
const DataCtor = Data as unknown as new (
|
||||
...args: unknown[]
|
||||
) => Data<DataType>;
|
||||
const data = new DataCtor(
|
||||
sanitizeTypeWithContext(dataLike.type, context),
|
||||
dataLike.offset,
|
||||
dataLike.length,
|
||||
@@ -739,6 +854,7 @@ function sanitizeData(
|
||||
},
|
||||
dataLike.children.map((child) => sanitizeData(child, context)),
|
||||
dictionary,
|
||||
(dataLike as VariadicDataLike).variadicBuffers,
|
||||
);
|
||||
context.data.set(dataLike, data);
|
||||
return data;
|
||||
@@ -747,7 +863,11 @@ function sanitizeData(
|
||||
const constructorsByTypeName = {
|
||||
null: () => new Null(),
|
||||
binary: () => new Binary(),
|
||||
largebinary: () => new LargeBinary(),
|
||||
binaryview: () => new (newerTypeConstructor("BinaryView"))(),
|
||||
utf8: () => new Utf8(),
|
||||
largeutf8: () => new LargeUtf8(),
|
||||
utf8view: () => new (newerTypeConstructor("Utf8View"))(),
|
||||
bool: () => new Bool(),
|
||||
int8: () => new Int8(),
|
||||
int16: () => new Int16(),
|
||||
@@ -768,6 +888,8 @@ const constructorsByTypeName = {
|
||||
timesecond: () => new TimeSecond(),
|
||||
intervaldaytime: () => new IntervalDayTime(),
|
||||
intervalyearmonth: () => new IntervalYearMonth(),
|
||||
intervalmonthdaynano: () =>
|
||||
new (newerTypeConstructor("IntervalMonthDayNano"))(),
|
||||
durationnanosecond: () => new DurationNanosecond(),
|
||||
durationmicrosecond: () => new DurationMicrosecond(),
|
||||
durationmillisecond: () => new DurationMillisecond(),
|
||||
|
||||
+94
-12
@@ -62,17 +62,25 @@ class SchemaInferrer {
|
||||
}
|
||||
|
||||
private observe(path: string[], value: unknown, row: number): void {
|
||||
assertSupportedValue(value, path.join("."), row);
|
||||
|
||||
const current = this.fields.get(path);
|
||||
if (current === undefined) {
|
||||
this.addField(path, value, row);
|
||||
} else if (this.providedSchema === undefined) {
|
||||
this.updateInferredField(path, value, row, current);
|
||||
} else if (current instanceof DeferredTypeEvidence) {
|
||||
if (value != null) {
|
||||
this.addSchemaField(this.providedSchema, path, value, row);
|
||||
}
|
||||
} else if (current instanceof FieldTree || DataType.isStruct(current)) {
|
||||
assertStructValue(path, value, row);
|
||||
}
|
||||
}
|
||||
|
||||
private addField(path: string[], value: unknown, row: number): void {
|
||||
if (this.providedSchema !== undefined) {
|
||||
this.addSchemaField(this.providedSchema, path, row);
|
||||
this.addSchemaField(this.providedSchema, path, value, row);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -93,15 +101,31 @@ class SchemaInferrer {
|
||||
}
|
||||
}
|
||||
|
||||
private addSchemaField(schema: Schema, path: string[], row: number): void {
|
||||
private addSchemaField(
|
||||
schema: Schema,
|
||||
path: string[],
|
||||
value: unknown,
|
||||
row: number,
|
||||
): void {
|
||||
const field = fieldAtPath(schema, path);
|
||||
if (field === undefined) {
|
||||
throw new Error(
|
||||
`Found field not in schema: ${path.join(".")} at row ${row}`,
|
||||
);
|
||||
}
|
||||
if (DataType.isStruct(field.type)) {
|
||||
assertStructValue(path, value, row);
|
||||
}
|
||||
|
||||
const conflict = this.fields.set(path, field.type);
|
||||
// Nulls are kept as deferred evidence so that a struct field seen first as
|
||||
// null can be replaced by a record in a later row, just as a record seen
|
||||
// first already ignores later nulls.
|
||||
const conflict = this.fields.set(
|
||||
path,
|
||||
DeferredTypeEvidence.from(value, row) ?? field.type,
|
||||
(existing) =>
|
||||
existing instanceof DeferredTypeEvidence && existing.isOnlyNulls(),
|
||||
);
|
||||
if (conflict !== undefined) {
|
||||
throw branchConflictError(conflict, row, "Struct");
|
||||
}
|
||||
@@ -430,16 +454,74 @@ function* recordPathsAndValues(
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return false;
|
||||
}
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
!(value instanceof RegExp) &&
|
||||
!(value instanceof Date) &&
|
||||
!(value instanceof Set) &&
|
||||
!(value instanceof Map) &&
|
||||
!(value instanceof Buffer) &&
|
||||
!ArrayBuffer.isView(value)
|
||||
(prototype === Object.prototype || prototype === null) &&
|
||||
Object.keys(value).length > 0
|
||||
);
|
||||
}
|
||||
|
||||
function isUnsupportedObject(value: unknown): boolean {
|
||||
if (
|
||||
typeof value !== "object" ||
|
||||
value === null ||
|
||||
Array.isArray(value) ||
|
||||
isRecord(value)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// Preserve the object types already passed through to Arrow. Other objects
|
||||
// must not disappear while walking struct fields.
|
||||
return !(
|
||||
value instanceof RegExp ||
|
||||
value instanceof Date ||
|
||||
value instanceof Set ||
|
||||
value instanceof Map ||
|
||||
Buffer.isBuffer(value) ||
|
||||
ArrayBuffer.isView(value)
|
||||
);
|
||||
}
|
||||
|
||||
function assertSupportedValue(
|
||||
value: unknown,
|
||||
field: string,
|
||||
row: number,
|
||||
): void {
|
||||
if (Array.isArray(value)) {
|
||||
for (const [index, item] of value.entries()) {
|
||||
assertSupportedValue(item, `${field}[${index}]`, row);
|
||||
}
|
||||
} else if (isRecord(value)) {
|
||||
for (const [name, child] of Object.entries(value)) {
|
||||
assertSupportedValue(child, `${field}.${name}`, row);
|
||||
}
|
||||
} else if (isUnsupportedObject(value)) {
|
||||
throw new Error(
|
||||
`Unsupported object value for field ${field} at row ${row}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejects primitives, arrays, and typed arrays given for a struct field, which
|
||||
* would otherwise be written as a struct with every child null.
|
||||
*/
|
||||
function assertStructValue(path: string[], value: unknown, row: number): void {
|
||||
if (
|
||||
value == null ||
|
||||
(typeof value === "object" &&
|
||||
!Array.isArray(value) &&
|
||||
!ArrayBuffer.isView(value))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const kind =
|
||||
typeof value === "object" ? value.constructor.name : typeof value;
|
||||
throw new Error(
|
||||
`Expected a struct value for field ${path.join(".")} at row ${row}, got ${kind}.`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+181
-58
@@ -17,8 +17,11 @@ import {
|
||||
tableFromIPC,
|
||||
} from "./arrow";
|
||||
|
||||
import { BlobFile, BlobReadOptions, runWithSignal } from "./blob";
|
||||
import { EmbeddingFunctionConfig, getRegistry } from "./embedding/registry";
|
||||
import { IndexOptions } from "./indices";
|
||||
import { Job } from "./job";
|
||||
import { validateNonNegativeInteger } from "./materialized_view";
|
||||
import { MergeInsertBuilder } from "./merge";
|
||||
import {
|
||||
AddColumnsResult,
|
||||
@@ -28,9 +31,10 @@ import {
|
||||
BranchContents,
|
||||
DeleteResult,
|
||||
DropColumnsResult,
|
||||
FunctionErrors,
|
||||
FunctionErrorsOptions,
|
||||
IndexConfig,
|
||||
IndexStatistics,
|
||||
Job,
|
||||
LsmStats,
|
||||
Branches as NativeBranches,
|
||||
OptimizeStats,
|
||||
@@ -120,6 +124,17 @@ export interface AddDataOptions {
|
||||
* ```
|
||||
*/
|
||||
progress: (progress: WriteProgress) => void;
|
||||
|
||||
/**
|
||||
* Whether blob URIs outside registered external bases may be written.
|
||||
*
|
||||
* Defaults to `false`. This option is supported only for local/native
|
||||
* tables; remote tables return an error when it is enabled. An enabled write
|
||||
* stores the absolute URI reference without registering a base or copying
|
||||
* the external object into the database. The object must remain accessible
|
||||
* when the blob is read later.
|
||||
*/
|
||||
allowExternalBlobOutsideBases?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateOptions {
|
||||
@@ -147,7 +162,8 @@ export interface OptimizeOptions {
|
||||
* olderThan.setDate(olderThan.getDate() - 1));
|
||||
* tbl.optimize({cleanupOlderThan: olderThan});
|
||||
*
|
||||
* // Delete all versions except the current version
|
||||
* // Delete versions committed before this point. Versions created by the
|
||||
* // optimize call itself are newer than the cutoff and will be retained.
|
||||
* tbl.optimize({cleanupOlderThan: new Date()});
|
||||
*/
|
||||
cleanupOlderThan: Date;
|
||||
@@ -209,9 +225,8 @@ export interface LsmWriteSpec {
|
||||
/** Bucket variant: the number of buckets, in `[1, 1024]`. */
|
||||
numBuckets?: number;
|
||||
/**
|
||||
* Indexes the MemWAL keeps up to date. Omit to maintain every supported
|
||||
* index, resolved on install — a snapshot, so indexes created later are not
|
||||
* maintained. Pass `[]` for none.
|
||||
* Indexes the MemWAL keeps up to date. Omit for every index the table has,
|
||||
* including ones created later. Pass `[]` for none.
|
||||
*/
|
||||
maintainedIndexes?: string[];
|
||||
/** Default `ShardWriter` configuration recorded in the MemWAL index. */
|
||||
@@ -313,7 +328,7 @@ export abstract class Table {
|
||||
* Note: if your condition is something like "some_id_column == 7" and
|
||||
* you are updating many rows (with different ids) then you will get
|
||||
* better performance with a single [`merge_insert`] call instead of
|
||||
* repeatedly calilng this method.
|
||||
* repeatedly calling this method.
|
||||
* @param {Map<string, string> | Record<string, string>} updates - the
|
||||
* columns to update
|
||||
* @returns {Promise<UpdateResult>} A promise that resolves to an object
|
||||
@@ -510,6 +525,41 @@ export abstract class Table {
|
||||
*/
|
||||
abstract takeRowIds(rowIds: readonly (bigint | number)[]): TakeQuery;
|
||||
|
||||
/**
|
||||
* Blob v2 columns, including nested dotted paths.
|
||||
*/
|
||||
abstract blobColumns(): Promise<string[]>;
|
||||
|
||||
/**
|
||||
* Bytes for `column` at row IDs from {@link Query.withRowId}.
|
||||
*
|
||||
* Reads the table's current checkout. IDs from another version can fail after
|
||||
* compaction unless stable row ids are enabled. Results keep input order and
|
||||
* duplicates. Null blobs are `null`. Empty blobs are empty buffers.
|
||||
* Remote servers limit each request to 1024 row IDs and 64 MiB of blob bytes.
|
||||
* The client splits requests automatically and reads an individual larger
|
||||
* blob through the Range route. This method still materializes all bytes in
|
||||
* memory; use {@link Table.fetchBlobFiles} for large values.
|
||||
*/
|
||||
abstract fetchBlobs(
|
||||
column: string,
|
||||
rowIds: readonly (bigint | number)[],
|
||||
options?: BlobReadOptions,
|
||||
): Promise<(Buffer | null)[]>;
|
||||
|
||||
/**
|
||||
* Opens lazy blob handles for `column` at the given row IDs using the
|
||||
* table's current checkout.
|
||||
*
|
||||
* Preserves input order, duplicates, and nulls. Use this for large payloads.
|
||||
* See {@link Table.fetchBlobs} for row-ID validity across versions.
|
||||
*/
|
||||
abstract fetchBlobFiles(
|
||||
column: string,
|
||||
rowIds: readonly (bigint | number)[],
|
||||
options?: BlobReadOptions,
|
||||
): Promise<(BlobFile | null)[]>;
|
||||
|
||||
/**
|
||||
* Create a search query to find the nearest neighbors
|
||||
* of the given query
|
||||
@@ -542,10 +592,10 @@ export abstract class Table {
|
||||
* {@link Table#refreshColumn}. Declaring one therefore costs the same on a
|
||||
* large table as on an empty one.
|
||||
*
|
||||
* A refresh does not revisit rows it has already filled, so mutating an
|
||||
* input leaves the value computed at fill time; recomputing means dropping
|
||||
* the column and declaring it again. While a declaration reads a column,
|
||||
* that column cannot be renamed, retyped or dropped.
|
||||
* A refresh also recomputes the rows whose inputs changed since they were
|
||||
* computed, so a mutated input is reflected by the next refresh. While a
|
||||
* declaration reads a column, that column cannot be renamed, retyped or
|
||||
* dropped.
|
||||
*
|
||||
* On LanceDB Cloud and Enterprise the expression is planned by the
|
||||
* server, and the refresh runs as a server job -- see
|
||||
@@ -576,10 +626,10 @@ export abstract class Table {
|
||||
/**
|
||||
* Fill the rows of a computed column that hold no value yet.
|
||||
*
|
||||
* Rows appended since the last refresh are filled by the next one; rows
|
||||
* already filled are left as they are, so the call is idempotent and does
|
||||
* not observe a mutated input. Local tables only: a remote refresh runs
|
||||
* as a server job, through {@link Table#refreshColumnAsync}.
|
||||
* Rows appended since the last refresh are filled by the next one, and
|
||||
* rows whose inputs changed since they were computed are recomputed;
|
||||
* everything else is left as it is. Local tables only: a remote refresh
|
||||
* runs as a server job, through {@link Table#refreshColumnAsync}.
|
||||
* @param {string} column The name of the computed column to fill.
|
||||
* @returns {Promise<RefreshColumnResult>} A promise that resolves to the
|
||||
* number of rows filled and the new version number of the table.
|
||||
@@ -605,18 +655,41 @@ export abstract class Table {
|
||||
*/
|
||||
abstract refreshColumnAsync(column: string): Promise<Job>;
|
||||
|
||||
/**
|
||||
* The per-row errors Function refreshes recorded on this table.
|
||||
*
|
||||
* A refresh running under a skip policy records each row it skipped with
|
||||
* the input that failed and the error. This lists those records, newest
|
||||
* job first, plus a summary for any fragment whose per-row detail was
|
||||
* capped. LanceDB Cloud and Enterprise only; reading errors needs read
|
||||
* access to the table, since a message carries the value that failed.
|
||||
* @param {FunctionErrorsOptions} options Optional filters: `jobId`,
|
||||
* `column`, and `limit` (server default 10000, cap 100000).
|
||||
* @returns {Promise<FunctionErrors>} The records, the capped fragments,
|
||||
* and whether the listing stopped at its limit.
|
||||
* @example
|
||||
* ```ts
|
||||
* const { records, truncated } = await table.functionErrors({ column: "embedding" });
|
||||
* ```
|
||||
*/
|
||||
abstract functionErrors(
|
||||
options?: FunctionErrorsOptions,
|
||||
): Promise<FunctionErrors>;
|
||||
|
||||
/**
|
||||
* Recompute this table's contents from its materialized-view definition.
|
||||
*
|
||||
* Plumbing for {@link MaterializedView.refresh}, which is the way to call
|
||||
* it: rejects tables that carry no view definition. Local tables only.
|
||||
* it: rejects tables that carry no view definition.
|
||||
* @ignore
|
||||
*/
|
||||
abstract refreshMaterializedView(
|
||||
full?: boolean,
|
||||
sourceVersion?: number,
|
||||
): Promise<RefreshMaterializedViewResult>;
|
||||
|
||||
/** @ignore */
|
||||
abstract materializedViewDefinition(): Promise<string>;
|
||||
|
||||
/**
|
||||
* Alter the name or nullability of columns.
|
||||
* @param {ColumnAlteration[]} columnAlterations One or more alterations to
|
||||
@@ -692,8 +765,8 @@ export abstract class Table {
|
||||
* ({@link Table#setUnenforcedPrimaryKey}); bucket sharding additionally
|
||||
* requires it to be the single column being bucketed.
|
||||
*
|
||||
* Omitting `maintainedIndexes` maintains every index on the table, resolved
|
||||
* here, failing if one cannot be maintained — name them to install anyway.
|
||||
* Omitting `maintainedIndexes` maintains every index the table has,
|
||||
* including ones created later, and skips a kind the MemWAL cannot maintain.
|
||||
* Naming them pins an exact set, and a still-building index is rejected
|
||||
* rather than quietly omitted.
|
||||
* @param {LsmWriteSpec} spec The sharding spec to install.
|
||||
@@ -723,10 +796,9 @@ export abstract class Table {
|
||||
*
|
||||
* Resolves to `undefined` when the MemWAL LSM write path is not enabled (no
|
||||
* spec has been set, or it was removed with {@link Table#unsetLsmWriteSpec}).
|
||||
* The returned spec mirrors what was passed to
|
||||
* {@link Table#setLsmWriteSpec}, except that `maintainedIndexes` always
|
||||
* reports the concrete list resolved when the spec was set — `undefined`
|
||||
* never round-trips.
|
||||
* The spec is the one installed, including its maintained-index selection:
|
||||
* an absent `maintainedIndexes` for every index the table has, an empty
|
||||
* array for none.
|
||||
* @returns {Promise<LsmWriteSpec | undefined>}
|
||||
*/
|
||||
abstract getLsmWriteSpec(): Promise<LsmWriteSpec | undefined>;
|
||||
@@ -919,6 +991,16 @@ export abstract class Table {
|
||||
/** Return the table as an arrow table */
|
||||
abstract toArrow(): Promise<ArrowTable>;
|
||||
|
||||
/**
|
||||
* Create a {@link MergeInsertBuilder}, which combines new data with the
|
||||
* existing table in a single transaction — inserting, updating and deleting
|
||||
* rows depending on how they match.
|
||||
*
|
||||
* @param on - The column, or columns, to match source rows against target
|
||||
* rows on. Typically a key or id column. Several columns match on the
|
||||
* composite key: a source row updates a target row only when it agrees on
|
||||
* every one of them.
|
||||
*/
|
||||
abstract mergeInsert(on: string | string[]): MergeInsertBuilder;
|
||||
|
||||
/** List all the stats of a specified index
|
||||
@@ -1023,7 +1105,12 @@ export class LocalTable extends Table {
|
||||
}
|
||||
}
|
||||
: undefined;
|
||||
return await this.inner.add(buffer, mode, progress);
|
||||
return await this.inner.add(
|
||||
buffer,
|
||||
mode,
|
||||
progress,
|
||||
options?.allowExternalBlobOutsideBases,
|
||||
);
|
||||
}
|
||||
|
||||
async update(
|
||||
@@ -1114,13 +1201,15 @@ export class LocalTable extends Table {
|
||||
): Promise<Job> {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: skip
|
||||
const nativeIndex = (options?.config as any)?.inner;
|
||||
return await this.inner.createIndexAsync(
|
||||
nativeIndex,
|
||||
column,
|
||||
options?.replace,
|
||||
options?.waitTimeoutSeconds,
|
||||
options?.name,
|
||||
options?.train,
|
||||
return new Job(
|
||||
await this.inner.createIndexAsync(
|
||||
nativeIndex,
|
||||
column,
|
||||
options?.replace,
|
||||
options?.waitTimeoutSeconds,
|
||||
options?.name,
|
||||
options?.train,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1148,23 +1237,37 @@ export class LocalTable extends Table {
|
||||
}
|
||||
|
||||
takeRowIds(rowIds: readonly (bigint | number)[]): TakeQuery {
|
||||
const ids = rowIds.map((id) => {
|
||||
if (typeof id === "bigint") {
|
||||
return id;
|
||||
}
|
||||
if (!Number.isInteger(id)) {
|
||||
throw new Error("Row id must be an integer (or bigint)");
|
||||
}
|
||||
if (id < 0) {
|
||||
throw new Error("Row id cannot be negative");
|
||||
}
|
||||
if (!Number.isSafeInteger(id)) {
|
||||
throw new Error("Row id is too large for number; use bigint instead");
|
||||
}
|
||||
return BigInt(id);
|
||||
});
|
||||
return new TakeQuery(this.inner.takeRowIds(rowIdsToBigInts(rowIds)));
|
||||
}
|
||||
|
||||
return new TakeQuery(this.inner.takeRowIds(ids));
|
||||
blobColumns(): Promise<string[]> {
|
||||
return this.inner.blobColumns();
|
||||
}
|
||||
|
||||
async fetchBlobs(
|
||||
column: string,
|
||||
rowIds: readonly (bigint | number)[],
|
||||
options?: BlobReadOptions,
|
||||
): Promise<(Buffer | null)[]> {
|
||||
const values = await runWithSignal(options?.signal, (signal) =>
|
||||
this.inner.fetchBlobs(column, rowIdsToBigInts(rowIds), signal),
|
||||
);
|
||||
// N-API Option maps missing values to undefined. Collapse those to null.
|
||||
return values.map((value) => value ?? null);
|
||||
}
|
||||
|
||||
async fetchBlobFiles(
|
||||
column: string,
|
||||
rowIds: readonly (bigint | number)[],
|
||||
options?: BlobReadOptions,
|
||||
): Promise<(BlobFile | null)[]> {
|
||||
const files = await runWithSignal(options?.signal, (signal) =>
|
||||
this.inner.fetchBlobFiles(column, rowIdsToBigInts(rowIds), signal),
|
||||
);
|
||||
// N-API Option maps missing values to undefined. Collapse those to null.
|
||||
return files.map((file) =>
|
||||
file == null ? null : BlobFile.fromNative(file),
|
||||
);
|
||||
}
|
||||
|
||||
query(): Query {
|
||||
@@ -1303,14 +1406,24 @@ export class LocalTable extends Table {
|
||||
}
|
||||
|
||||
async refreshColumnAsync(column: string): Promise<Job> {
|
||||
return await this.inner.refreshColumnAsync(column);
|
||||
return new Job(await this.inner.refreshColumnAsync(column));
|
||||
}
|
||||
|
||||
async functionErrors(
|
||||
options?: FunctionErrorsOptions,
|
||||
): Promise<FunctionErrors> {
|
||||
validateNonNegativeInteger(options?.limit, "limit");
|
||||
return await this.inner.functionErrors(options);
|
||||
}
|
||||
|
||||
async refreshMaterializedView(
|
||||
full?: boolean,
|
||||
sourceVersion?: number,
|
||||
): Promise<RefreshMaterializedViewResult> {
|
||||
return await this.inner.refreshMaterializedView(full, sourceVersion);
|
||||
return await this.inner.refreshMaterializedView(sourceVersion);
|
||||
}
|
||||
|
||||
async materializedViewDefinition(): Promise<string> {
|
||||
return await this.inner.materializedViewDefinition();
|
||||
}
|
||||
|
||||
async alterColumns(
|
||||
@@ -1433,16 +1546,8 @@ export class LocalTable extends Table {
|
||||
}
|
||||
|
||||
async optimize(options?: Partial<OptimizeOptions>): Promise<OptimizeStats> {
|
||||
let cleanupOlderThanMs;
|
||||
if (
|
||||
options?.cleanupOlderThan !== undefined &&
|
||||
options?.cleanupOlderThan !== null
|
||||
) {
|
||||
cleanupOlderThanMs =
|
||||
new Date().getTime() - options.cleanupOlderThan.getTime();
|
||||
}
|
||||
return await this.inner.optimize(
|
||||
cleanupOlderThanMs,
|
||||
options?.cleanupOlderThan?.getTime(),
|
||||
options?.deleteUnverified,
|
||||
);
|
||||
}
|
||||
@@ -1721,3 +1826,21 @@ export class Branches {
|
||||
)) as unknown as CherryPickResult;
|
||||
}
|
||||
}
|
||||
|
||||
function rowIdsToBigInts(rowIds: readonly (bigint | number)[]): bigint[] {
|
||||
return rowIds.map((id) => {
|
||||
if (typeof id === "bigint") {
|
||||
return id;
|
||||
}
|
||||
if (!Number.isInteger(id)) {
|
||||
throw new Error("Row id must be an integer (or bigint)");
|
||||
}
|
||||
if (id < 0) {
|
||||
throw new Error("Row id cannot be negative");
|
||||
}
|
||||
if (!Number.isSafeInteger(id)) {
|
||||
throw new Error("Row id is too large for number; use bigint instead");
|
||||
}
|
||||
return BigInt(id);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,6 +15,14 @@ export function toSQL(value: IntoSql): string {
|
||||
if (typeof value === "string") {
|
||||
return `'${value.replace(/'/g, "''")}'`;
|
||||
} else if (typeof value === "number") {
|
||||
// toString() gives "NaN" / "Infinity", which SQL reads as column names.
|
||||
if (Number.isNaN(value)) {
|
||||
return "CAST('NaN' AS DOUBLE)";
|
||||
} else if (!Number.isFinite(value)) {
|
||||
return value > 0
|
||||
? "CAST('Infinity' AS DOUBLE)"
|
||||
: "CAST('-Infinity' AS DOUBLE)";
|
||||
}
|
||||
return value.toString();
|
||||
} else if (typeof value === "boolean") {
|
||||
return value ? "TRUE" : "FALSE";
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
import { Schema, tableFromIPC } from "apache-arrow";
|
||||
import { ViewDescription as NativeViewDescription } from "./native";
|
||||
|
||||
/**
|
||||
* What a database records about one view.
|
||||
*
|
||||
* A view holds no rows: it stores the statement that defines it and the
|
||||
* schema that statement resolved to, so a reader sees its sources as they
|
||||
* are at read time.
|
||||
*/
|
||||
export interface ViewDescription {
|
||||
/** The view's name within its namespace. */
|
||||
name: string;
|
||||
/** The namespace holding the view; empty is the root namespace. */
|
||||
namespacePath: string[];
|
||||
/** The defining query, as the database stores it. */
|
||||
query: string;
|
||||
/** The database that unqualified table names in {@link query} resolve against. */
|
||||
defaultDatabase: string;
|
||||
/**
|
||||
* The namespace path those unqualified names resolve against; empty is the
|
||||
* root namespace.
|
||||
*
|
||||
* Recorded with the view because it outlives the session that declared it:
|
||||
* a reader resolving the query against its own default namespace could read
|
||||
* a different table than the view was defined over.
|
||||
*/
|
||||
defaultNamespacePath: string[];
|
||||
/** The schema the defining query resolved to when the view was created. */
|
||||
schema: Schema;
|
||||
}
|
||||
|
||||
/** Decode the schema the binding hands over as an Arrow IPC file. */
|
||||
export function viewDescriptionFromNative(
|
||||
view: NativeViewDescription,
|
||||
): ViewDescription {
|
||||
return {
|
||||
name: view.name,
|
||||
namespacePath: view.namespacePath,
|
||||
query: view.query,
|
||||
defaultDatabase: view.defaultDatabase,
|
||||
defaultNamespacePath: view.defaultNamespacePath,
|
||||
schema: tableFromIPC(view.schema).schema,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@lancedb/lancedb-darwin-arm64",
|
||||
"version": "0.38.0-beta.11",
|
||||
"version": "0.41.0-beta.6",
|
||||
"os": ["darwin"],
|
||||
"cpu": ["arm64"],
|
||||
"main": "lancedb.darwin-arm64.node",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@lancedb/lancedb-linux-arm64-gnu",
|
||||
"version": "0.38.0-beta.11",
|
||||
"version": "0.41.0-beta.6",
|
||||
"os": ["linux"],
|
||||
"cpu": ["arm64"],
|
||||
"main": "lancedb.linux-arm64-gnu.node",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@lancedb/lancedb-linux-arm64-musl",
|
||||
"version": "0.38.0-beta.11",
|
||||
"version": "0.41.0-beta.6",
|
||||
"os": ["linux"],
|
||||
"cpu": ["arm64"],
|
||||
"main": "lancedb.linux-arm64-musl.node",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@lancedb/lancedb-linux-x64-gnu",
|
||||
"version": "0.38.0-beta.11",
|
||||
"version": "0.41.0-beta.6",
|
||||
"os": ["linux"],
|
||||
"cpu": ["x64"],
|
||||
"main": "lancedb.linux-x64-gnu.node",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@lancedb/lancedb-linux-x64-musl",
|
||||
"version": "0.38.0-beta.11",
|
||||
"version": "0.41.0-beta.6",
|
||||
"os": ["linux"],
|
||||
"cpu": ["x64"],
|
||||
"main": "lancedb.linux-x64-musl.node",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@lancedb/lancedb-win32-arm64-msvc",
|
||||
"version": "0.38.0-beta.11",
|
||||
"version": "0.41.0-beta.6",
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@lancedb/lancedb-win32-x64-msvc",
|
||||
"version": "0.38.0-beta.11",
|
||||
"version": "0.41.0-beta.6",
|
||||
"os": ["win32"],
|
||||
"cpu": ["x64"],
|
||||
"main": "lancedb.win32-x64-msvc.node",
|
||||
|
||||
Generated
-11106
File diff suppressed because it is too large
Load Diff
+10
-6
@@ -11,7 +11,7 @@
|
||||
"ann"
|
||||
],
|
||||
"private": false,
|
||||
"version": "0.38.0-beta.11",
|
||||
"version": "0.41.0-beta.6",
|
||||
"main": "dist/index.js",
|
||||
"exports": {
|
||||
".": "./dist/index.js",
|
||||
@@ -44,19 +44,23 @@
|
||||
"@biomejs/biome": "^1.7.3",
|
||||
"@jest/globals": "^29.7.0",
|
||||
"@napi-rs/cli": "3.7.0",
|
||||
"@opentelemetry/sdk-metrics": "^1.30.0",
|
||||
"@opentelemetry/sdk-metrics": "^2.10.0",
|
||||
"@types/axios": "^0.14.0",
|
||||
"@types/jest": "^29.1.2",
|
||||
"@types/node": "22.7.4",
|
||||
"@types/tmp": "^0.2.6",
|
||||
"apache-arrow": "21.2.0",
|
||||
"apache-arrow-15": "npm:apache-arrow@15.0.0",
|
||||
"apache-arrow-16": "npm:apache-arrow@16.0.0",
|
||||
"apache-arrow-17": "npm:apache-arrow@17.0.0",
|
||||
"apache-arrow-18": "npm:apache-arrow@18.0.0",
|
||||
"apache-arrow-19": "npm:apache-arrow@19.0.1",
|
||||
"apache-arrow-20": "npm:apache-arrow@20.0.0",
|
||||
"apache-arrow-21": "npm:apache-arrow@21.2.0",
|
||||
"eslint": "^8.57.0",
|
||||
"jest": "^29.7.0",
|
||||
"shx": "^0.3.4",
|
||||
"tmp": "^0.2.3",
|
||||
"tmp": "^0.2.7",
|
||||
"ts-jest": "^29.1.2",
|
||||
"typedoc": "0.26.4",
|
||||
"typedoc-plugin-markdown": "4.2.1",
|
||||
@@ -67,7 +71,7 @@
|
||||
"timeout": "3m"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
"node": ">= 22"
|
||||
},
|
||||
"packageManager": "pnpm@11.1.1",
|
||||
"cpu": ["x64", "arm64"],
|
||||
@@ -101,8 +105,8 @@
|
||||
"openai": "4.29.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": ">=18",
|
||||
"apache-arrow": ">=15.0.0 <=18.1.0"
|
||||
"@types/node": ">=22",
|
||||
"apache-arrow": ">=15.0.0 <22.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
|
||||
Generated
+691
-451
File diff suppressed because it is too large
Load Diff
@@ -16,3 +16,41 @@ allowBuilds:
|
||||
onnxruntime-node: true
|
||||
protobufjs: true
|
||||
sharp: true
|
||||
|
||||
minimumReleaseAgeExclude:
|
||||
- protobufjs@7.5.8
|
||||
- tmp@0.2.6
|
||||
- form-data@4.0.6
|
||||
- tar@7.5.16
|
||||
- markdown-it@14.1.2
|
||||
- linkify-it@5.0.1
|
||||
- js-yaml@3.15.0
|
||||
- js-yaml@4.1.2
|
||||
- protobufjs@7.6.1
|
||||
- protobufjs@7.6.3
|
||||
- '@babel/core@7.29.1'
|
||||
- axios@1.18.0
|
||||
- brace-expansion@2.1.2
|
||||
- brace-expansion@1.1.16
|
||||
- js-yaml@4.3.0
|
||||
- tar@7.5.18
|
||||
- tar@7.5.19
|
||||
- tar@7.5.17
|
||||
- protobufjs@7.6.5
|
||||
- linkify-it@5.0.2
|
||||
- sharp@0.35.0
|
||||
- brace-expansion@1.1.17
|
||||
- brace-expansion@2.1.3
|
||||
- brace-expansion@2.1.4
|
||||
- brace-expansion@1.1.18
|
||||
- js-yaml@3.15.1
|
||||
- js-yaml@4.3.1
|
||||
- tar@7.5.21
|
||||
- '@opentelemetry/core@2.8.0'
|
||||
|
||||
# @huggingface/transformers pins sharp ^0.33.5 and no released version has moved
|
||||
# past ^0.34.5, all of which inherit the libvips CVEs in GHSA-f88m-g3jw-g9cj.
|
||||
# Force the patched line. sharp is only reached by transformers' image pipeline,
|
||||
# which LanceDB's text embedding function never uses.
|
||||
overrides:
|
||||
sharp: ^0.35.4
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
use std::future::Future;
|
||||
use std::ops::Range;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use arrow_array::{Array, LargeBinaryArray};
|
||||
use futures::future::abortable;
|
||||
use lancedb::blob::BlobFile as LanceBlobFile;
|
||||
use napi::Env;
|
||||
use napi::bindgen_prelude::*;
|
||||
use napi_derive::napi;
|
||||
|
||||
use crate::error::convert_error;
|
||||
|
||||
#[napi(object)]
|
||||
pub struct BlobRange {
|
||||
pub start: BigInt,
|
||||
pub end: BigInt,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub struct BlobFile {
|
||||
inner: Arc<LanceBlobFile>,
|
||||
closed: AtomicBool,
|
||||
}
|
||||
|
||||
impl BlobFile {
|
||||
pub(crate) fn new(inner: LanceBlobFile) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(inner),
|
||||
closed: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_open(&self) -> napi::Result<()> {
|
||||
if self.closed.load(Ordering::Acquire) {
|
||||
Err(napi::Error::from_reason("blob file is already closed"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl BlobFile {
|
||||
#[napi]
|
||||
pub fn size(&self) -> BigInt {
|
||||
BigInt::from(self.inner.size())
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn read<'env>(
|
||||
&self,
|
||||
env: &'env Env,
|
||||
max_bytes: Option<BigInt>,
|
||||
signal: Option<AbortSignal>,
|
||||
) -> napi::Result<PromiseRaw<'env, Buffer>> {
|
||||
self.ensure_open()?;
|
||||
let max_bytes = max_bytes
|
||||
.map(|max_bytes| {
|
||||
usize::try_from(parse_u64(max_bytes, "maxBytes")?)
|
||||
.map_err(|_| napi::Error::from_reason("maxBytes is too large"))
|
||||
})
|
||||
.transpose()?;
|
||||
let inner = self.inner.clone();
|
||||
spawn_abortable(env, signal, async move {
|
||||
let bytes = match max_bytes {
|
||||
None => inner.read().await,
|
||||
Some(max_bytes) => inner.read_up_to(max_bytes).await,
|
||||
}
|
||||
.map_err(|err| convert_error(&err))?;
|
||||
Ok(Buffer::from(bytes.as_ref()))
|
||||
})
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn read_range<'env>(
|
||||
&self,
|
||||
env: &'env Env,
|
||||
start: BigInt,
|
||||
end: BigInt,
|
||||
signal: Option<AbortSignal>,
|
||||
) -> napi::Result<PromiseRaw<'env, Buffer>> {
|
||||
self.ensure_open()?;
|
||||
let range = bigint_range(start, end)?;
|
||||
let inner = self.inner.clone();
|
||||
spawn_abortable(env, signal, async move {
|
||||
let bytes = inner
|
||||
.read_range(range)
|
||||
.await
|
||||
.map_err(|err| convert_error(&err))?;
|
||||
Ok(Buffer::from(bytes.as_ref()))
|
||||
})
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn read_ranges<'env>(
|
||||
&self,
|
||||
env: &'env Env,
|
||||
ranges: Vec<BlobRange>,
|
||||
signal: Option<AbortSignal>,
|
||||
) -> napi::Result<PromiseRaw<'env, Vec<Buffer>>> {
|
||||
self.ensure_open()?;
|
||||
let ranges = ranges
|
||||
.into_iter()
|
||||
.map(|range| bigint_range(range.start, range.end))
|
||||
.collect::<napi::Result<Vec<_>>>()?;
|
||||
let inner = self.inner.clone();
|
||||
spawn_abortable(env, signal, async move {
|
||||
let buffers = inner
|
||||
.read_ranges(&ranges)
|
||||
.await
|
||||
.map_err(|err| convert_error(&err))?;
|
||||
Ok(buffers
|
||||
.iter()
|
||||
.map(|bytes| Buffer::from(bytes.as_ref()))
|
||||
.collect())
|
||||
})
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn seek(&self, position: BigInt) -> napi::Result<()> {
|
||||
self.ensure_open()?;
|
||||
let position = parse_u64(position, "position")?;
|
||||
self.inner
|
||||
.seek(position)
|
||||
.await
|
||||
.map_err(|err| convert_error(&err))
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn tell(&self) -> napi::Result<BigInt> {
|
||||
self.ensure_open()?;
|
||||
let position = self.inner.tell().await.map_err(|err| convert_error(&err))?;
|
||||
Ok(BigInt::from(position))
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn close(&self) -> napi::Result<()> {
|
||||
if self.closed.swap(true, Ordering::AcqRel) {
|
||||
return Ok(());
|
||||
}
|
||||
self.inner.close().await.map_err(|err| convert_error(&err))
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn is_closed(&self) -> bool {
|
||||
self.closed.load(Ordering::Acquire)
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `fut` on the napi runtime. Aborting `signal` drops the future, which
|
||||
/// cancels in-flight reads.
|
||||
pub fn spawn_abortable<'env, T>(
|
||||
env: &'env Env,
|
||||
signal: Option<AbortSignal>,
|
||||
fut: impl Future<Output = napi::Result<T>> + Send + 'static,
|
||||
) -> napi::Result<PromiseRaw<'env, T>>
|
||||
where
|
||||
T: ToNapiValue + Send + 'static,
|
||||
{
|
||||
let (fut, handle) = abortable(fut);
|
||||
if let Some(signal) = signal {
|
||||
signal.on_abort(move || handle.abort());
|
||||
}
|
||||
env.spawn_future(async move {
|
||||
fut.await
|
||||
.unwrap_or_else(|_| Err(napi::Error::from_reason("blob read aborted")))
|
||||
})
|
||||
}
|
||||
|
||||
fn bigint_range(start: BigInt, end: BigInt) -> napi::Result<Range<u64>> {
|
||||
let start = parse_u64(start, "start")?;
|
||||
let end = parse_u64(end, "end")?;
|
||||
if start > end {
|
||||
return Err(napi::Error::from_reason(format!(
|
||||
"invalid blob range: start ({start}) > end ({end})"
|
||||
)));
|
||||
}
|
||||
Ok(start..end)
|
||||
}
|
||||
|
||||
fn parse_u64(value: BigInt, name: &str) -> napi::Result<u64> {
|
||||
let (negative, value, lossless) = value.get_u64();
|
||||
if negative {
|
||||
return Err(napi::Error::from_reason(format!(
|
||||
"{name} cannot be negative"
|
||||
)));
|
||||
}
|
||||
if !lossless {
|
||||
return Err(napi::Error::from_reason(format!(
|
||||
"{name} is too large to fit in u64"
|
||||
)));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
pub fn parse_row_ids(row_ids: Vec<BigInt>) -> napi::Result<Vec<u64>> {
|
||||
row_ids
|
||||
.into_iter()
|
||||
.map(|id| parse_u64(id, "row id"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn copy_blob_buffers(array: LargeBinaryArray) -> Vec<Option<Buffer>> {
|
||||
(0..array.len())
|
||||
.map(|i| {
|
||||
if array.is_null(i) {
|
||||
None
|
||||
} else {
|
||||
Some(Buffer::from(array.value(i).to_vec()))
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use futures::{StreamExt, future::poll_fn};
|
||||
|
||||
use lancedb::catalog::{CatalogConnection, CreateDatabaseRequest, DropDatabaseRequest};
|
||||
use napi::bindgen_prelude::*;
|
||||
use napi_derive::napi;
|
||||
|
||||
use crate::connection::Connection;
|
||||
use crate::error::NapiErrorExt;
|
||||
use crate::header::JsHeaderProvider;
|
||||
use crate::remote::{ClientConfig, OAuthConfig};
|
||||
|
||||
#[napi(object)]
|
||||
pub struct CatalogOptions {
|
||||
pub api_key: Option<String>,
|
||||
pub client_config: Option<ClientConfig>,
|
||||
/// SQL service endpoint inherited by database connections.
|
||||
pub sql_host_override: Option<String>,
|
||||
pub read_consistency_interval: Option<f64>,
|
||||
pub oauth_config: Option<OAuthConfig>,
|
||||
}
|
||||
|
||||
/// A lazy iterator over database names with inspectable pagination state.
|
||||
#[napi]
|
||||
pub struct DatabaseNames {
|
||||
inner: Mutex<lancedb::catalog::DatabaseNames>,
|
||||
next_lock: futures::lock::Mutex<()>,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl DatabaseNames {
|
||||
/// Number of names cached without another REST request.
|
||||
#[napi]
|
||||
pub fn num_page_results(&self) -> u32 {
|
||||
self.inner.lock().unwrap().num_page_results() as u32
|
||||
}
|
||||
|
||||
/// Continuation token for the next REST request.
|
||||
#[napi]
|
||||
pub fn page_token(&self) -> Option<String> {
|
||||
self.inner.lock().unwrap().page_token().map(str::to_owned)
|
||||
}
|
||||
|
||||
/// Fetch the next name, returning None when exhausted.
|
||||
#[napi]
|
||||
pub async fn next(&self) -> Result<Option<String>> {
|
||||
// Serialize advances but allow synchronous inspection between polls.
|
||||
let _guard = self.next_lock.lock().await;
|
||||
poll_fn(|cx| self.inner.lock().unwrap().poll_next_unpin(cx))
|
||||
.await
|
||||
.transpose()
|
||||
.default_error()
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub struct Catalog {
|
||||
inner: CatalogConnection,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl Catalog {
|
||||
#[napi(factory)]
|
||||
pub async fn new(
|
||||
endpoint: String,
|
||||
options: CatalogOptions,
|
||||
header_provider: Option<&JsHeaderProvider>,
|
||||
) -> Result<Self> {
|
||||
let mut builder = lancedb::connect_catalog(endpoint);
|
||||
if let Some(key) = options.api_key {
|
||||
builder = builder.api_key(key);
|
||||
}
|
||||
let mut config: lancedb::remote::ClientConfig =
|
||||
options.client_config.unwrap_or_default().into();
|
||||
if let Some(provider) = header_provider {
|
||||
config.header_provider = Some(Arc::new(provider.clone()));
|
||||
}
|
||||
builder = builder.client_config(config);
|
||||
if let Some(endpoint) = options.sql_host_override {
|
||||
builder = builder.sql_host_override(endpoint);
|
||||
}
|
||||
if let Some(interval) = options.read_consistency_interval {
|
||||
let interval = Duration::try_from_secs_f64(interval).map_err(|err| {
|
||||
Error::from_reason(format!("Invalid read consistency interval: {err}"))
|
||||
})?;
|
||||
builder = builder.read_consistency_interval(interval);
|
||||
}
|
||||
if let Some(oauth) = options.oauth_config {
|
||||
builder = builder.oauth_config(oauth.try_into().default_error()?);
|
||||
}
|
||||
Ok(Self {
|
||||
inner: builder.execute().await.default_error()?,
|
||||
})
|
||||
}
|
||||
|
||||
#[napi(getter)]
|
||||
pub fn uri(&self) -> String {
|
||||
self.inner.uri().to_string()
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn create_database(
|
||||
&self,
|
||||
name: String,
|
||||
exist_ok: Option<bool>,
|
||||
) -> Result<Connection> {
|
||||
self.inner
|
||||
.create_database(CreateDatabaseRequest::new(name).exist_ok(exist_ok.unwrap_or(false)))
|
||||
.await
|
||||
.map(Connection::inner_new)
|
||||
.default_error()
|
||||
}
|
||||
#[napi]
|
||||
pub async fn connect_database(&self, name: String) -> Result<Connection> {
|
||||
self.inner
|
||||
.connect_database(name)
|
||||
.await
|
||||
.map(Connection::inner_new)
|
||||
.default_error()
|
||||
}
|
||||
#[napi]
|
||||
pub async fn drop_database(&self, name: String, ignore_missing: Option<bool>) -> Result<()> {
|
||||
self.inner
|
||||
.drop_database(
|
||||
DropDatabaseRequest::new(name).ignore_missing(ignore_missing.unwrap_or(false)),
|
||||
)
|
||||
.await
|
||||
.default_error()
|
||||
}
|
||||
#[napi]
|
||||
pub fn list_databases(
|
||||
&self,
|
||||
page_token: Option<String>,
|
||||
page_limit: Option<u32>,
|
||||
) -> DatabaseNames {
|
||||
DatabaseNames {
|
||||
inner: Mutex::new(self.inner.list_databases(page_token, page_limit)),
|
||||
next_lock: futures::lock::Mutex::new(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
+182
-45
@@ -55,6 +55,32 @@ pub struct DropNamespaceResponse {
|
||||
pub transaction_id: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// What a database records about one view.
|
||||
#[napi(object)]
|
||||
pub struct ViewDescription {
|
||||
pub name: String,
|
||||
pub namespace_path: Vec<String>,
|
||||
pub query: String,
|
||||
pub default_database: String,
|
||||
pub default_namespace_path: Vec<String>,
|
||||
/// The view's schema as an empty Arrow IPC file, the way a table reports
|
||||
/// its own.
|
||||
pub schema: Buffer,
|
||||
}
|
||||
|
||||
impl ViewDescription {
|
||||
fn from_inner(view: lancedb::view::ViewDescription) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
name: view.name,
|
||||
namespace_path: view.namespace_path,
|
||||
query: view.query,
|
||||
default_database: view.default_database,
|
||||
default_namespace_path: view.default_namespace_path,
|
||||
schema: crate::util::schema_to_buffer(&view.schema)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Connection {
|
||||
pub(crate) fn inner_new(inner: LanceDBConnection) -> Self {
|
||||
Self { inner: Some(inner) }
|
||||
@@ -308,6 +334,7 @@ impl Connection {
|
||||
projections: Option<Vec<Vec<String>>>,
|
||||
filter: Option<String>,
|
||||
limit: Option<i64>,
|
||||
with_no_data: bool,
|
||||
) -> napi::Result<Table> {
|
||||
let mut builder = self.get_inner()?.create_materialized_view(name, source);
|
||||
if let Some(projections) = projections {
|
||||
@@ -328,6 +355,7 @@ impl Connection {
|
||||
.map_err(|_| napi::Error::from_reason("limit must be a non-negative integer"))?;
|
||||
builder = builder.limit(limit);
|
||||
}
|
||||
builder = builder.with_no_data(with_no_data);
|
||||
let view = builder.execute().await.default_error()?;
|
||||
Ok(Table::new(view.table().clone()))
|
||||
}
|
||||
@@ -343,13 +371,125 @@ impl Connection {
|
||||
}
|
||||
|
||||
#[napi(catch_unwind)]
|
||||
pub async fn list_materialized_views(&self) -> napi::Result<Vec<String>> {
|
||||
let views = self
|
||||
pub fn list_materialized_views(
|
||||
&self,
|
||||
page_token: Option<String>,
|
||||
page_limit: Option<u32>,
|
||||
) -> napi::Result<crate::listing::NameListing> {
|
||||
Ok(crate::listing::NameListing::new(
|
||||
self.get_inner()?
|
||||
.list_materialized_views(crate::listing::options(page_token, page_limit)),
|
||||
))
|
||||
}
|
||||
|
||||
/// Drop a materialized view.
|
||||
#[napi(catch_unwind)]
|
||||
pub async fn drop_materialized_view(
|
||||
&self,
|
||||
name: String,
|
||||
namespace_path: Option<Vec<String>>,
|
||||
) -> napi::Result<()> {
|
||||
let ns = namespace_path.unwrap_or_default();
|
||||
self.get_inner()?
|
||||
.drop_materialized_view(&name, &ns)
|
||||
.await
|
||||
.default_error()
|
||||
}
|
||||
|
||||
/// Create a view: a named query planned on every read.
|
||||
#[napi(catch_unwind)]
|
||||
pub async fn create_view(
|
||||
&self,
|
||||
name: String,
|
||||
query: String,
|
||||
namespace_path: Option<Vec<String>>,
|
||||
) -> napi::Result<ViewDescription> {
|
||||
let ns = namespace_path.unwrap_or_default();
|
||||
let view = self
|
||||
.get_inner()?
|
||||
.list_materialized_views()
|
||||
.create_view(&name, &query, &ns)
|
||||
.await
|
||||
.default_error()?;
|
||||
Ok(views.into_iter().map(|v| v.name).collect())
|
||||
ViewDescription::from_inner(view)
|
||||
}
|
||||
|
||||
/// What the database records about one view.
|
||||
#[napi(catch_unwind)]
|
||||
pub async fn describe_view(
|
||||
&self,
|
||||
name: String,
|
||||
namespace_path: Option<Vec<String>>,
|
||||
) -> napi::Result<ViewDescription> {
|
||||
let ns = namespace_path.unwrap_or_default();
|
||||
let view = self
|
||||
.get_inner()?
|
||||
.describe_view(&name, &ns)
|
||||
.await
|
||||
.default_error()?;
|
||||
ViewDescription::from_inner(view)
|
||||
}
|
||||
|
||||
/// Drop a view and wait for its definition to be deleted. The tables it
|
||||
/// reads are untouched.
|
||||
#[napi(catch_unwind)]
|
||||
pub async fn drop_view(
|
||||
&self,
|
||||
name: String,
|
||||
namespace_path: Option<Vec<String>>,
|
||||
) -> napi::Result<()> {
|
||||
let ns = namespace_path.unwrap_or_default();
|
||||
self.get_inner()?
|
||||
.drop_view(&name, &ns)
|
||||
.await
|
||||
.default_error()
|
||||
}
|
||||
|
||||
/// Start dropping a view and return the job deleting its definition.
|
||||
#[napi(catch_unwind)]
|
||||
pub async fn drop_view_async(
|
||||
&self,
|
||||
name: String,
|
||||
namespace_path: Option<Vec<String>>,
|
||||
) -> napi::Result<crate::job::Job> {
|
||||
let ns = namespace_path.unwrap_or_default();
|
||||
let job = self
|
||||
.get_inner()?
|
||||
.drop_view_async(&name, &ns)
|
||||
.await
|
||||
.default_error()?;
|
||||
Ok(crate::job::Job::new(job))
|
||||
}
|
||||
|
||||
/// The names of the views in one namespace.
|
||||
#[napi(catch_unwind)]
|
||||
pub fn list_views(
|
||||
&self,
|
||||
namespace_path: Option<Vec<String>>,
|
||||
page_token: Option<String>,
|
||||
page_limit: Option<u32>,
|
||||
) -> napi::Result<crate::listing::NameListing> {
|
||||
Ok(crate::listing::NameListing::new(
|
||||
self.get_inner()?.list_views(
|
||||
&namespace_path.unwrap_or_default(),
|
||||
crate::listing::options(page_token, page_limit),
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
/// Start dropping a materialized view and return its cleanup job.
|
||||
#[napi(catch_unwind)]
|
||||
pub async fn drop_materialized_view_async(
|
||||
&self,
|
||||
name: String,
|
||||
namespace_path: Option<Vec<String>>,
|
||||
) -> napi::Result<crate::job::Job> {
|
||||
let ns = namespace_path.unwrap_or_default();
|
||||
let job = self
|
||||
.get_inner()?
|
||||
.drop_materialized_view_async(&name, &ns)
|
||||
.await
|
||||
.default_error()?;
|
||||
Ok(crate::job::Job::new(job))
|
||||
}
|
||||
|
||||
#[napi(catch_unwind)]
|
||||
@@ -442,32 +582,29 @@ impl Connection {
|
||||
self.get_inner()?.drop_all_tables(&ns).await.default_error()
|
||||
}
|
||||
|
||||
/// A `Job` handle for a server-side job by id.
|
||||
/// Open a server-side job by id, returning a handle with its record
|
||||
/// already populated. Rejects when the server has no such job.
|
||||
///
|
||||
/// The handle is constructed without a server round trip; an unknown id
|
||||
/// surfaces when the handle is used.
|
||||
#[napi]
|
||||
pub fn job(&self, job_id: String) -> napi::Result<crate::job::Job> {
|
||||
let job = self.get_inner()?.job(job_id).default_error()?;
|
||||
/// The returned handle answers for its own state, specification, result,
|
||||
/// failure and event history, so there is no separate connection-level
|
||||
/// call for any of them.
|
||||
#[napi(catch_unwind)]
|
||||
pub async fn open_job(&self, job_id: String) -> napi::Result<crate::job::Job> {
|
||||
let job = self.get_inner()?.open_job(&job_id).await.default_error()?;
|
||||
Ok(crate::job::Job::new(job))
|
||||
}
|
||||
|
||||
/// List server-side jobs across the database's tables.
|
||||
#[napi(catch_unwind)]
|
||||
pub async fn list_jobs(&self) -> napi::Result<Vec<crate::job::JobInfo>> {
|
||||
let jobs = self.get_inner()?.list_jobs().await.default_error()?;
|
||||
Ok(jobs.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
/// Describe a single server-side job by id. `null` when the server has
|
||||
/// no such job.
|
||||
#[napi(catch_unwind)]
|
||||
pub async fn get_job(
|
||||
pub fn list_jobs(
|
||||
&self,
|
||||
job_id: String,
|
||||
) -> napi::Result<Option<crate::job::JobDescription>> {
|
||||
let description = self.get_inner()?.get_job(&job_id).await.default_error()?;
|
||||
Ok(description.map(Into::into))
|
||||
page_token: Option<String>,
|
||||
page_limit: Option<u32>,
|
||||
) -> napi::Result<crate::listing::JobListing> {
|
||||
Ok(crate::listing::JobListing::new(
|
||||
self.get_inner()?
|
||||
.list_jobs(crate::listing::options(page_token, page_limit)),
|
||||
))
|
||||
}
|
||||
|
||||
/// Request cancellation of a server-side job by id. Returns true if the
|
||||
@@ -477,32 +614,32 @@ impl Connection {
|
||||
self.get_inner()?.cancel_job(&job_id).await.default_error()
|
||||
}
|
||||
|
||||
/// The lifecycle event history of a server-side job (all jobs when
|
||||
/// `job_id` is null), as an Arrow IPC stream buffer. Empty when there is
|
||||
/// no history.
|
||||
/// Pause a server-side job by id: its workers drain and it stays parked
|
||||
/// until resumed. Returns "pausing", "already_paused", or "committing".
|
||||
#[napi(catch_unwind)]
|
||||
pub async fn job_history(&self, job_id: Option<String>) -> napi::Result<Buffer> {
|
||||
let batches = self
|
||||
pub async fn pause_job(&self, job_id: String) -> napi::Result<String> {
|
||||
let status = self.get_inner()?.pause_job(&job_id).await.default_error()?;
|
||||
Ok(match status {
|
||||
lancedb::database::PauseJobStatus::Pausing => "pausing".to_string(),
|
||||
lancedb::database::PauseJobStatus::AlreadyPaused => "already_paused".to_string(),
|
||||
lancedb::database::PauseJobStatus::Committing => "committing".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Resume a paused server-side job by id. Returns "resumed",
|
||||
/// "still_pausing", or "not_paused".
|
||||
#[napi(catch_unwind)]
|
||||
pub async fn resume_job(&self, job_id: String) -> napi::Result<String> {
|
||||
let status = self
|
||||
.get_inner()?
|
||||
.job_history(job_id.as_deref())
|
||||
.resume_job(&job_id)
|
||||
.await
|
||||
.default_error()?;
|
||||
let Some(first) = batches.first() else {
|
||||
return Ok(Buffer::from(Vec::<u8>::new()));
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
let mut writer = arrow_ipc::writer::StreamWriter::try_new(&mut out, &first.schema())
|
||||
.map_err(|e| napi::Error::from_reason(e.to_string()))?;
|
||||
for batch in &batches {
|
||||
writer
|
||||
.write(batch)
|
||||
.map_err(|e| napi::Error::from_reason(e.to_string()))?;
|
||||
}
|
||||
writer
|
||||
.finish()
|
||||
.map_err(|e| napi::Error::from_reason(e.to_string()))?;
|
||||
drop(writer);
|
||||
Ok(Buffer::from(out))
|
||||
Ok(match status {
|
||||
lancedb::database::ResumeJobStatus::Resumed => "resumed".to_string(),
|
||||
lancedb::database::ResumeJobStatus::StillPausing => "still_pausing".to_string(),
|
||||
lancedb::database::ResumeJobStatus::NotPaused => "not_paused".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
#[napi(catch_unwind)]
|
||||
|
||||
+6
-1
@@ -239,7 +239,12 @@ impl Index {
|
||||
opts = opts.base_tokenizer(base_tokenizer);
|
||||
}
|
||||
if let Some(language) = language {
|
||||
opts = opts.language(&language).unwrap();
|
||||
opts = opts.language(&language).map_err(|_| {
|
||||
napi::Error::from_reason(format!(
|
||||
"LanceDB does not support the requested language: '{}'",
|
||||
language
|
||||
))
|
||||
})?;
|
||||
}
|
||||
if let Some(max_token_length) = max_token_length {
|
||||
opts = opts.max_token_length(Some(max_token_length as usize));
|
||||
|
||||
@@ -7,6 +7,8 @@ use lancedb::ipc::batches_to_ipc_file;
|
||||
use napi::bindgen_prelude::*;
|
||||
use napi_derive::napi;
|
||||
|
||||
use crate::util::schema_to_buffer;
|
||||
|
||||
/** Typescript-style Async Iterator over RecordBatches */
|
||||
#[napi]
|
||||
pub struct RecordBatchIterator {
|
||||
@@ -19,6 +21,12 @@ impl RecordBatchIterator {
|
||||
Self { inner }
|
||||
}
|
||||
|
||||
/// Return the executed stream's output schema as an Arrow IPC file.
|
||||
#[napi]
|
||||
pub fn schema(&self) -> napi::Result<Buffer> {
|
||||
schema_to_buffer(&self.inner.schema())
|
||||
}
|
||||
|
||||
#[napi(catch_unwind)]
|
||||
pub async unsafe fn next(&mut self) -> napi::Result<Option<Buffer>> {
|
||||
if let Some(rst) = self.inner.next().await {
|
||||
|
||||
+90
-34
@@ -3,6 +3,9 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use arrow_array::RecordBatch;
|
||||
use lancedb::job::JobEventsRequest;
|
||||
use napi::bindgen_prelude::Buffer;
|
||||
use napi_derive::napi;
|
||||
|
||||
use crate::error::NapiErrorExt;
|
||||
@@ -55,12 +58,98 @@ impl Job {
|
||||
pub async fn cancel(&self) -> napi::Result<()> {
|
||||
self.inner.cancel().await.default_error()
|
||||
}
|
||||
|
||||
/// Ask the backend for this job's current state, and for a server-side job
|
||||
/// its full record, then cache it for the getters below.
|
||||
///
|
||||
/// They are all null until this runs, because submitting an operation
|
||||
/// returns only a job id. {@link Job.status} fetches the whole record too;
|
||||
/// {@link Job.wait} records only the terminal state it establishes.
|
||||
#[napi(catch_unwind)]
|
||||
pub async fn refresh(&self) -> napi::Result<()> {
|
||||
self.inner.refresh().await.default_error()
|
||||
}
|
||||
|
||||
/// The last observed lifecycle state, without contacting the backend.
|
||||
#[napi(getter)]
|
||||
pub fn state(&self) -> Option<String> {
|
||||
self.inner.state()
|
||||
}
|
||||
|
||||
/// The job's type, as the server names it. Null for an in-process job,
|
||||
/// which has no server-side record.
|
||||
#[napi(getter)]
|
||||
pub fn job_type(&self) -> Option<String> {
|
||||
self.inner.job_type()
|
||||
}
|
||||
|
||||
/// When the job was created, in milliseconds since the epoch.
|
||||
#[napi(getter)]
|
||||
pub fn creation_ms(&self) -> Option<i64> {
|
||||
self.inner.creation_ms()
|
||||
}
|
||||
|
||||
/// The job-type-specific specification as a JSON string, when present.
|
||||
#[napi(getter)]
|
||||
pub fn spec_json(&self) -> Option<String> {
|
||||
self.inner.spec().map(|spec| spec.to_string())
|
||||
}
|
||||
|
||||
/// The job-type-specific terminal result as a JSON string. Null until the
|
||||
/// job succeeds, so a job that never terminates reports its progress
|
||||
/// through {@link Job.events} instead.
|
||||
#[napi(getter)]
|
||||
pub fn result_json(&self) -> Option<String> {
|
||||
self.inner.result().map(|result| result.to_string())
|
||||
}
|
||||
|
||||
/// Why the job failed, when it failed and the server reports a reason.
|
||||
#[napi(getter)]
|
||||
pub fn failure(&self) -> Option<JobFailureInfo> {
|
||||
self.inner.failure().map(|failure| JobFailureInfo {
|
||||
phase: failure.phase,
|
||||
message: failure.message,
|
||||
retryable: failure.retryable,
|
||||
})
|
||||
}
|
||||
|
||||
/// This job's recorded lifecycle events, as an Arrow IPC stream buffer.
|
||||
/// The TypeScript wrapper turns it into an Arrow table.
|
||||
#[napi(catch_unwind)]
|
||||
pub async fn events(&self, limit: Option<u32>, filter: Option<String>) -> napi::Result<Buffer> {
|
||||
let batches = self
|
||||
.inner
|
||||
.events(JobEventsRequest { limit, filter })
|
||||
.await
|
||||
.default_error()?;
|
||||
batches_to_ipc_buffer(&batches)
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialise Arrow batches as a single IPC stream for the TypeScript layer.
|
||||
fn batches_to_ipc_buffer(batches: &[RecordBatch]) -> napi::Result<Buffer> {
|
||||
let Some(first) = batches.first() else {
|
||||
return Ok(Buffer::from(Vec::<u8>::new()));
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
let mut writer = arrow_ipc::writer::StreamWriter::try_new(&mut out, &first.schema())
|
||||
.map_err(|e| napi::Error::from_reason(e.to_string()))?;
|
||||
for batch in batches {
|
||||
writer
|
||||
.write(batch)
|
||||
.map_err(|e| napi::Error::from_reason(e.to_string()))?;
|
||||
}
|
||||
writer
|
||||
.finish()
|
||||
.map_err(|e| napi::Error::from_reason(e.to_string()))?;
|
||||
drop(writer);
|
||||
Ok(Buffer::from(out))
|
||||
}
|
||||
|
||||
/// A row from `Connection.listJobs`: one server-side job.
|
||||
#[napi(object)]
|
||||
pub struct JobInfo {
|
||||
/// The job id -- what `Connection.getJob` and `Connection.cancelJob`
|
||||
/// The job id -- what `Connection.openJob` and `Connection.cancelJob`
|
||||
/// accept.
|
||||
pub job_id: String,
|
||||
/// The table the job runs against, without URI or namespace.
|
||||
@@ -91,36 +180,3 @@ pub struct JobFailureInfo {
|
||||
pub message: Option<String>,
|
||||
pub retryable: Option<bool>,
|
||||
}
|
||||
|
||||
/// A described job from `Connection.getJob`.
|
||||
#[napi(object)]
|
||||
pub struct JobDescription {
|
||||
pub job_id: String,
|
||||
pub job_type: String,
|
||||
/// Lifecycle state: "running", "finished", "failed", or "cancelled".
|
||||
pub state: String,
|
||||
/// When the job was created, in milliseconds since the epoch.
|
||||
pub creation_ms: i64,
|
||||
/// The job-type-specific specification as a JSON string, when present.
|
||||
pub spec_json: Option<String>,
|
||||
/// Why the job failed, when the job is failed and the server reports a
|
||||
/// reason.
|
||||
pub failure: Option<JobFailureInfo>,
|
||||
}
|
||||
|
||||
impl From<lancedb::database::JobDescription> for JobDescription {
|
||||
fn from(description: lancedb::database::JobDescription) -> Self {
|
||||
Self {
|
||||
job_id: description.job_id,
|
||||
job_type: description.job_type,
|
||||
state: description.state,
|
||||
creation_ms: description.creation_ms,
|
||||
spec_json: (!description.spec.is_null()).then(|| description.spec.to_string()),
|
||||
failure: description.failure.map(|failure| JobFailureInfo {
|
||||
phase: failure.phase,
|
||||
message: failure.message,
|
||||
retryable: failure.retryable,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,12 +10,15 @@ use std::collections::HashMap;
|
||||
use env_logger::Env;
|
||||
use napi_derive::*;
|
||||
|
||||
mod blob;
|
||||
mod catalog;
|
||||
mod connection;
|
||||
mod error;
|
||||
mod header;
|
||||
mod index;
|
||||
mod iterator;
|
||||
mod job;
|
||||
mod listing;
|
||||
pub mod merge;
|
||||
pub mod otel;
|
||||
pub mod permutation;
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
use crate::error::NapiErrorExt;
|
||||
use futures::{StreamExt, future::poll_fn};
|
||||
use lancedb::listing::{Listing, ListingOptions};
|
||||
use napi_derive::napi;
|
||||
use std::sync::Mutex;
|
||||
|
||||
pub fn options(page_token: Option<String>, page_limit: Option<u32>) -> ListingOptions {
|
||||
let mut options = ListingOptions::default();
|
||||
options.page_token = page_token;
|
||||
options.page_limit = page_limit;
|
||||
options
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub struct NameListing {
|
||||
inner: Mutex<Listing<String>>,
|
||||
next_lock: futures::lock::Mutex<()>,
|
||||
}
|
||||
impl NameListing {
|
||||
pub fn new(inner: Listing<String>) -> Self {
|
||||
Self {
|
||||
inner: Mutex::new(inner),
|
||||
next_lock: futures::lock::Mutex::new(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
#[napi]
|
||||
impl NameListing {
|
||||
#[napi]
|
||||
pub fn num_page_results(&self) -> u32 {
|
||||
self.inner.lock().unwrap().num_page_results() as u32
|
||||
}
|
||||
#[napi]
|
||||
pub fn page_token(&self) -> Option<String> {
|
||||
self.inner.lock().unwrap().page_token().map(str::to_owned)
|
||||
}
|
||||
#[napi]
|
||||
pub async fn next(&self) -> napi::Result<Option<String>> {
|
||||
let _guard = self.next_lock.lock().await;
|
||||
let value = poll_fn(|cx| self.inner.lock().unwrap().poll_next_unpin(cx))
|
||||
.await
|
||||
.transpose()
|
||||
.default_error()?;
|
||||
Ok(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub struct JobListing {
|
||||
inner: Mutex<Listing<lancedb::database::JobInfo>>,
|
||||
next_lock: futures::lock::Mutex<()>,
|
||||
}
|
||||
impl JobListing {
|
||||
pub fn new(inner: Listing<lancedb::database::JobInfo>) -> Self {
|
||||
Self {
|
||||
inner: Mutex::new(inner),
|
||||
next_lock: futures::lock::Mutex::new(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
#[napi]
|
||||
impl JobListing {
|
||||
#[napi]
|
||||
pub fn num_page_results(&self) -> u32 {
|
||||
self.inner.lock().unwrap().num_page_results() as u32
|
||||
}
|
||||
#[napi]
|
||||
pub fn page_token(&self) -> Option<String> {
|
||||
self.inner.lock().unwrap().page_token().map(str::to_owned)
|
||||
}
|
||||
#[napi]
|
||||
pub async fn next(&self) -> napi::Result<Option<crate::job::JobInfo>> {
|
||||
let _guard = self.next_lock.lock().await;
|
||||
let value = poll_fn(|cx| self.inner.lock().unwrap().poll_next_unpin(cx))
|
||||
.await
|
||||
.transpose()
|
||||
.default_error()?;
|
||||
Ok(value.map(|value| value.into()))
|
||||
}
|
||||
}
|
||||
+23
-3
@@ -289,8 +289,23 @@ impl VectorQuery {
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn nprobes(&mut self, nprobe: u32) {
|
||||
self.inner = self.inner.clone().nprobes(nprobe as usize);
|
||||
pub fn nprobes(&mut self, nprobe: u32) -> napi::Result<()> {
|
||||
self.inner = self
|
||||
.inner
|
||||
.clone()
|
||||
.nprobes(nprobe as usize)
|
||||
.default_error()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn search_effort(&mut self, search_effort: Option<f64>) -> napi::Result<()> {
|
||||
self.inner = self
|
||||
.inner
|
||||
.clone()
|
||||
.search_effort(search_effort)
|
||||
.default_error()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[napi]
|
||||
@@ -658,13 +673,18 @@ impl JsFullTextQuery {
|
||||
FtsQuery::Phrase(_) => "phrase".to_string(),
|
||||
FtsQuery::Boost(_) => "boost".to_string(),
|
||||
FtsQuery::MultiMatch(_) => "multi_match".to_string(),
|
||||
FtsQuery::CombinedFields(_) => "combined_fields".to_string(),
|
||||
FtsQuery::Boolean(_) => "boolean".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_fts_query(query: Object) -> napi::Result<FullTextSearchQuery> {
|
||||
if let Ok(Some(query)) = query.get::<&JsFullTextQuery>("query") {
|
||||
// `&JsFullTextQuery` recovers a native class reference through napi's borrow-tracked
|
||||
// path, which is only usable from generated `#[napi]` argument conversion. This is a
|
||||
// manual lookup on a nested `Object` property instead, so use `ClassInstance`, which
|
||||
// unwraps the class without requiring a borrow scope.
|
||||
if let Ok(Some(query)) = query.get::<ClassInstance<JsFullTextQuery>>("query") {
|
||||
Ok(FullTextSearchQuery::new_query(query.inner.clone()))
|
||||
} else if let Ok(Some(query_text)) = query.get::<String>("query") {
|
||||
let mut query_text = query_text;
|
||||
|
||||
+334
-2
@@ -6,6 +6,8 @@ use std::collections::HashMap;
|
||||
use lancedb::error::Error;
|
||||
use napi_derive::*;
|
||||
|
||||
use crate::error::NapiErrorExt;
|
||||
|
||||
/// Timeout configuration for remote HTTP client.
|
||||
#[napi(object)]
|
||||
#[derive(Debug)]
|
||||
@@ -91,6 +93,9 @@ pub struct ClientConfig {
|
||||
pub retry_config: Option<RetryConfig>,
|
||||
pub timeout_config: Option<TimeoutConfig>,
|
||||
pub extra_headers: Option<HashMap<String, String>>,
|
||||
/// The delimiter joining a namespace path and a name into one object
|
||||
/// identifier. `"$"` is the only supported value, and leaving this unset is
|
||||
/// how to get it; anything else is rejected when the connection is created.
|
||||
pub id_delimiter: Option<String>,
|
||||
pub tls_config: Option<TlsConfig>,
|
||||
/// User identifier for tracking purposes.
|
||||
@@ -141,6 +146,34 @@ impl From<TlsConfig> for lancedb::remote::TlsConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Options for the persistent OAuth token cache.
|
||||
///
|
||||
/// The cache is opt-in: it is only used when set as `tokenCache` on
|
||||
/// `OAuthConfig`. Only refresh tokens are persisted, in a private directory
|
||||
/// with owner-only permissions, so short-lived processes can reuse an
|
||||
/// authenticated session instead of re-prompting on every start.
|
||||
#[napi(object)]
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct TokenCacheOptions {
|
||||
/// Directory that holds cached credentials. Defaults to
|
||||
/// `$XDG_CACHE_HOME/lancedb/oauth`, `$HOME/.cache/lancedb/oauth` on Unix,
|
||||
/// or `%LOCALAPPDATA%\lancedb\oauth` on Windows. The directory is created
|
||||
/// with owner-only permissions (`0700`) when missing.
|
||||
pub cache_dir: Option<String>,
|
||||
/// How long to wait for the cross-process refresh lock before failing,
|
||||
/// in seconds (default: 30).
|
||||
pub lock_timeout_secs: Option<u32>,
|
||||
}
|
||||
|
||||
impl From<TokenCacheOptions> for lancedb::remote::TokenCacheOptions {
|
||||
fn from(options: TokenCacheOptions) -> Self {
|
||||
Self {
|
||||
cache_dir: options.cache_dir.map(std::path::PathBuf::from),
|
||||
lock_timeout_secs: options.lock_timeout_secs.map(|secs| secs as u64),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// OAuth configuration for LanceDB authentication.
|
||||
///
|
||||
/// This is the generated napi-rs binding shape. TypeScript users should prefer
|
||||
@@ -158,16 +191,35 @@ pub struct OAuthConfig {
|
||||
/// OAuth scopes to request. For Azure managed identity, exactly one scope
|
||||
/// or resource is required. For example: `["api://{app_id}/.default"]`
|
||||
pub scopes: Vec<String>,
|
||||
/// Authentication flow: "client_credentials" or "azure_managed_identity"
|
||||
/// Optional resource indicator for authorization and token requests.
|
||||
pub resource: Option<String>,
|
||||
/// Optional provider-specific audience for authorization and token requests.
|
||||
pub audience: Option<String>,
|
||||
/// Authentication flow: "client_credentials", "authorization_code",
|
||||
/// "device_code", or "azure_managed_identity"
|
||||
pub flow: Option<String>,
|
||||
/// Client secret (required for client_credentials).
|
||||
pub client_secret: Option<String>,
|
||||
/// How the client authenticates to the token endpoint: "none",
|
||||
/// "client_secret_basic", or "client_secret_post". Defaults to
|
||||
/// "client_secret_basic" when a client secret is set, and "none" for
|
||||
/// public clients.
|
||||
pub client_auth_method: Option<String>,
|
||||
/// Loopback redirect URI for authorization_code.
|
||||
pub redirect_uri: Option<String>,
|
||||
/// Port for the authorization_code loopback callback server.
|
||||
pub callback_port: Option<u16>,
|
||||
/// Whether authorization_code uses S256 PKCE (default: true).
|
||||
pub use_pkce: Option<bool>,
|
||||
/// Client ID for user-assigned managed identity (azure_managed_identity).
|
||||
pub managed_identity_client_id: Option<String>,
|
||||
/// Seconds before expiry to trigger proactive refresh (default: 300).
|
||||
/// Keep this well below the token TTL; if it is greater than or equal to
|
||||
/// the TTL, each request refreshes the token.
|
||||
pub refresh_buffer_secs: Option<u32>,
|
||||
/// Opt in to the persistent token cache so short-lived processes reuse
|
||||
/// one session. Only refresh tokens are persisted.
|
||||
pub token_cache: Option<TokenCacheOptions>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for OAuthConfig {
|
||||
@@ -176,16 +228,23 @@ impl std::fmt::Debug for OAuthConfig {
|
||||
.field("issuer_url", &self.issuer_url)
|
||||
.field("client_id", &self.client_id)
|
||||
.field("scopes", &self.scopes)
|
||||
.field("resource", &self.resource)
|
||||
.field("audience", &self.audience)
|
||||
.field("flow", &self.flow)
|
||||
.field(
|
||||
"client_secret",
|
||||
&self.client_secret.as_deref().map(|_| "<redacted>"),
|
||||
)
|
||||
.field("client_auth_method", &self.client_auth_method)
|
||||
.field("redirect_uri", &self.redirect_uri)
|
||||
.field("callback_port", &self.callback_port)
|
||||
.field("use_pkce", &self.use_pkce)
|
||||
.field(
|
||||
"managed_identity_client_id",
|
||||
&self.managed_identity_client_id,
|
||||
)
|
||||
.field("refresh_buffer_secs", &self.refresh_buffer_secs)
|
||||
.field("token_cache", &self.token_cache)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -194,10 +253,22 @@ impl TryFrom<OAuthConfig> for lancedb::remote::oauth::OAuthConfig {
|
||||
type Error = Error;
|
||||
|
||||
fn try_from(config: OAuthConfig) -> Result<Self, Self::Error> {
|
||||
use lancedb::remote::oauth::OAuthFlow;
|
||||
use lancedb::remote::oauth::{AuthorizationCodeOptions, OAuthFlow};
|
||||
|
||||
let flow = match config.flow.as_deref().unwrap_or("client_credentials") {
|
||||
"client_credentials" => OAuthFlow::ClientCredentials,
|
||||
"authorization_code" => {
|
||||
let mut options =
|
||||
AuthorizationCodeOptions::new().use_pkce(config.use_pkce.unwrap_or(true));
|
||||
if let Some(redirect_uri) = config.redirect_uri {
|
||||
options = options.redirect_uri(redirect_uri);
|
||||
}
|
||||
if let Some(callback_port) = config.callback_port {
|
||||
options = options.callback_port(callback_port);
|
||||
}
|
||||
OAuthFlow::AuthorizationCode(options)
|
||||
}
|
||||
"device_code" => OAuthFlow::DeviceCode,
|
||||
"azure_managed_identity" => OAuthFlow::AzureManagedIdentity {
|
||||
client_id: config.managed_identity_client_id,
|
||||
},
|
||||
@@ -208,17 +279,147 @@ impl TryFrom<OAuthConfig> for lancedb::remote::oauth::OAuthConfig {
|
||||
}
|
||||
};
|
||||
|
||||
let client_auth_method = match config.client_auth_method.as_deref() {
|
||||
Some("none") => Some(lancedb::remote::oauth::ClientAuthMethod::None),
|
||||
Some("client_secret_basic") => {
|
||||
Some(lancedb::remote::oauth::ClientAuthMethod::ClientSecretBasic)
|
||||
}
|
||||
Some("client_secret_post") => {
|
||||
Some(lancedb::remote::oauth::ClientAuthMethod::ClientSecretPost)
|
||||
}
|
||||
None => None,
|
||||
Some(other) => {
|
||||
return Err(Error::InvalidInput {
|
||||
message: format!("Unknown OAuth client auth method: {other}"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
issuer_url: config.issuer_url,
|
||||
client_id: config.client_id,
|
||||
client_secret: config.client_secret,
|
||||
client_auth_method,
|
||||
scopes: config.scopes,
|
||||
resource: config.resource,
|
||||
audience: config.audience,
|
||||
flow,
|
||||
refresh_buffer_secs: config.refresh_buffer_secs.map(|v| v as u64),
|
||||
token_cache: config.token_cache.map(Into::into),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Safe, non-secret view of a cached OAuth session, returned by
|
||||
/// `OAuthSession.status()` and `OAuthSession.login()`.
|
||||
#[napi(object)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SessionStatus {
|
||||
/// Whether a cached session exists that can obtain tokens without
|
||||
/// interactive authentication.
|
||||
pub refreshable: bool,
|
||||
/// Canonical issuer URL of the cached session.
|
||||
pub issuer_url: String,
|
||||
/// Client ID of the cached session.
|
||||
pub client_id: String,
|
||||
/// Canonical (sorted, de-duplicated) scopes of the cached session.
|
||||
pub scopes: Vec<String>,
|
||||
/// Optional resource indicator for authorization and token requests.
|
||||
pub resource: Option<String>,
|
||||
/// Optional provider-specific audience for authorization and token requests.
|
||||
pub audience: Option<String>,
|
||||
/// Flow that produced the cached session.
|
||||
pub flow: String,
|
||||
/// When the cached session was obtained, as Unix seconds.
|
||||
pub obtained_at: Option<f64>,
|
||||
}
|
||||
|
||||
/// Result of `OAuthSession.logout()`.
|
||||
#[napi(object)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SessionLogout {
|
||||
/// Whether a cached credential was removed. `false` means no matching
|
||||
/// session was cached; logout is idempotent.
|
||||
pub removed: bool,
|
||||
}
|
||||
|
||||
/// Explicit OAuth session lifecycle for the persistent token cache: eager
|
||||
/// `login`, non-secret `status`, and local `logout`.
|
||||
///
|
||||
/// A session is built from the same `OAuthConfig` used to connect (including
|
||||
/// its `tokenCache` options). A connection created with the same
|
||||
/// configuration shares the cache, so logging in here prepares tokens for
|
||||
/// later processes without any database request.
|
||||
#[napi]
|
||||
pub struct OAuthSession {
|
||||
inner: lancedb::remote::OAuthSession,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl OAuthSession {
|
||||
/// Create a session manager for the given OAuth configuration.
|
||||
///
|
||||
/// The configuration must enable `tokenCache` options and use a flow that
|
||||
/// supports persistent sessions (authorization code or device code).
|
||||
#[napi(constructor)]
|
||||
pub fn new(config: OAuthConfig) -> napi::Result<Self> {
|
||||
let config: lancedb::remote::oauth::OAuthConfig = config.try_into().default_error()?;
|
||||
let inner = lancedb::remote::OAuthSession::new(config).default_error()?;
|
||||
Ok(Self { inner })
|
||||
}
|
||||
|
||||
/// Eagerly run the configured authentication flow and store the session.
|
||||
///
|
||||
/// A successful login always replaces any prior cached session for this
|
||||
/// identity; if the provider does not issue a refresh token (for example
|
||||
/// without `offline_access`), the previous record is removed and the
|
||||
/// status reports `refreshable == false`.
|
||||
#[napi(catch_unwind)]
|
||||
pub async fn login(&self) -> napi::Result<SessionStatus> {
|
||||
let status = self.inner.login().await.default_error()?;
|
||||
Ok(SessionStatus::from(status))
|
||||
}
|
||||
|
||||
/// Report whether a matching cached session exists, with safe metadata.
|
||||
///
|
||||
/// This never contacts the identity provider and never exposes token
|
||||
/// values.
|
||||
#[napi(catch_unwind)]
|
||||
pub async fn status(&self) -> napi::Result<SessionStatus> {
|
||||
let status = self.inner.status().await.default_error()?;
|
||||
Ok(SessionStatus::from(status))
|
||||
}
|
||||
|
||||
/// Remove the matching local cached credential.
|
||||
///
|
||||
/// This only deletes the local cache entry. It does not revoke the
|
||||
/// refresh token with the provider and does not sign out of a browser
|
||||
/// SSO session. Repeated calls succeed; `removed` reports whether a
|
||||
/// credential existed.
|
||||
#[napi(catch_unwind)]
|
||||
pub async fn logout(&self) -> napi::Result<SessionLogout> {
|
||||
let logout = self.inner.logout().await.default_error()?;
|
||||
Ok(SessionLogout {
|
||||
removed: logout.removed,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl From<lancedb::remote::SessionStatus> for SessionStatus {
|
||||
fn from(status: lancedb::remote::SessionStatus) -> Self {
|
||||
Self {
|
||||
refreshable: status.refreshable,
|
||||
issuer_url: status.issuer_url,
|
||||
client_id: status.client_id,
|
||||
scopes: status.scopes,
|
||||
resource: status.resource,
|
||||
audience: status.audience,
|
||||
flow: status.flow,
|
||||
obtained_at: status.obtained_at.map(|secs| secs as f64),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ClientConfig> for lancedb::remote::ClientConfig {
|
||||
fn from(config: ClientConfig) -> Self {
|
||||
Self {
|
||||
@@ -252,8 +453,15 @@ mod tests {
|
||||
scopes: vec!["scope".to_string()],
|
||||
flow: Some("typo".to_string()),
|
||||
client_secret: None,
|
||||
client_auth_method: None,
|
||||
redirect_uri: None,
|
||||
callback_port: None,
|
||||
use_pkce: None,
|
||||
managed_identity_client_id: None,
|
||||
refresh_buffer_secs: None,
|
||||
resource: None,
|
||||
audience: None,
|
||||
token_cache: None,
|
||||
};
|
||||
|
||||
let err = lancedb::remote::oauth::OAuthConfig::try_from(config).unwrap_err();
|
||||
@@ -272,12 +480,136 @@ mod tests {
|
||||
scopes: vec!["scope".to_string()],
|
||||
flow: Some("client_credentials".to_string()),
|
||||
client_secret: Some("super-secret".to_string()),
|
||||
client_auth_method: None,
|
||||
redirect_uri: None,
|
||||
callback_port: None,
|
||||
use_pkce: None,
|
||||
managed_identity_client_id: None,
|
||||
refresh_buffer_secs: None,
|
||||
resource: None,
|
||||
audience: None,
|
||||
token_cache: None,
|
||||
};
|
||||
|
||||
let debug = format!("{config:?}");
|
||||
assert!(!debug.contains("super-secret"));
|
||||
assert!(debug.contains("client_secret: Some(\"<redacted>\")"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_authorization_code_conversion_preserves_options() {
|
||||
let config = OAuthConfig {
|
||||
issuer_url: "https://issuer.example.com".to_string(),
|
||||
client_id: "client-id".to_string(),
|
||||
scopes: vec!["openid".to_string()],
|
||||
flow: Some("authorization_code".to_string()),
|
||||
client_secret: Some("secret".to_string()),
|
||||
client_auth_method: None,
|
||||
redirect_uri: Some("http://127.0.0.1:9000/callback".to_string()),
|
||||
callback_port: Some(9000),
|
||||
use_pkce: Some(false),
|
||||
managed_identity_client_id: None,
|
||||
refresh_buffer_secs: None,
|
||||
resource: Some("urn:resource".into()),
|
||||
audience: Some("audience".into()),
|
||||
token_cache: None,
|
||||
};
|
||||
|
||||
let converted = lancedb::remote::oauth::OAuthConfig::try_from(config).unwrap();
|
||||
let lancedb::remote::oauth::OAuthFlow::AuthorizationCode(options) = converted.flow else {
|
||||
panic!("expected authorization code flow");
|
||||
};
|
||||
assert_eq!(
|
||||
options.redirect_uri.as_deref(),
|
||||
Some("http://127.0.0.1:9000/callback")
|
||||
);
|
||||
assert_eq!(options.callback_port, Some(9000));
|
||||
assert!(!options.use_pkce);
|
||||
assert_eq!(converted.resource.as_deref(), Some("urn:resource"));
|
||||
assert_eq!(converted.audience.as_deref(), Some("audience"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_device_code_conversion() {
|
||||
let config = OAuthConfig {
|
||||
issuer_url: "https://issuer.example.com".to_string(),
|
||||
client_id: "client-id".to_string(),
|
||||
scopes: vec!["openid".to_string()],
|
||||
flow: Some("device_code".to_string()),
|
||||
client_secret: None,
|
||||
client_auth_method: None,
|
||||
redirect_uri: None,
|
||||
callback_port: None,
|
||||
use_pkce: None,
|
||||
managed_identity_client_id: None,
|
||||
refresh_buffer_secs: None,
|
||||
resource: None,
|
||||
audience: None,
|
||||
token_cache: None,
|
||||
};
|
||||
|
||||
let converted = lancedb::remote::oauth::OAuthConfig::try_from(config).unwrap();
|
||||
assert!(matches!(
|
||||
converted.flow,
|
||||
lancedb::remote::oauth::OAuthFlow::DeviceCode
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_auth_method_conversion() {
|
||||
use lancedb::remote::oauth::ClientAuthMethod;
|
||||
|
||||
for (value, expected) in [
|
||||
("none", ClientAuthMethod::None),
|
||||
("client_secret_basic", ClientAuthMethod::ClientSecretBasic),
|
||||
("client_secret_post", ClientAuthMethod::ClientSecretPost),
|
||||
] {
|
||||
let config = OAuthConfig {
|
||||
issuer_url: "https://issuer.example.com".to_string(),
|
||||
client_id: "client-id".to_string(),
|
||||
scopes: vec!["openid".to_string()],
|
||||
flow: Some("device_code".to_string()),
|
||||
client_secret: None,
|
||||
client_auth_method: Some(value.to_string()),
|
||||
resource: None,
|
||||
audience: None,
|
||||
redirect_uri: None,
|
||||
callback_port: None,
|
||||
use_pkce: None,
|
||||
managed_identity_client_id: None,
|
||||
refresh_buffer_secs: None,
|
||||
token_cache: None,
|
||||
};
|
||||
|
||||
let converted = lancedb::remote::oauth::OAuthConfig::try_from(config).unwrap();
|
||||
assert_eq!(converted.client_auth_method, Some(expected));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unknown_client_auth_method_returns_invalid_input() {
|
||||
let config = OAuthConfig {
|
||||
issuer_url: "https://issuer.example.com".to_string(),
|
||||
client_id: "client-id".to_string(),
|
||||
scopes: vec!["openid".to_string()],
|
||||
flow: Some("device_code".to_string()),
|
||||
client_secret: None,
|
||||
client_auth_method: Some("typo".to_string()),
|
||||
resource: None,
|
||||
audience: None,
|
||||
redirect_uri: None,
|
||||
callback_port: None,
|
||||
use_pkce: None,
|
||||
managed_identity_client_id: None,
|
||||
refresh_buffer_secs: None,
|
||||
token_cache: None,
|
||||
};
|
||||
|
||||
let err = lancedb::remote::oauth::OAuthConfig::try_from(config).unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
Error::InvalidInput { message }
|
||||
if message == "Unknown OAuth client auth method: typo"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
+229
-31
@@ -7,14 +7,16 @@ use chrono::{DateTime, Utc};
|
||||
|
||||
use lancedb::ipc::{ipc_file_to_batches, ipc_file_to_schema};
|
||||
use lancedb::table::{
|
||||
AddDataMode, ColumnAlteration as LanceColumnAlteration, Duration,
|
||||
AddDataMode, ColumnAlteration as LanceColumnAlteration,
|
||||
FieldMetadataUpdate as LanceFieldMetadataUpdate, FtsToken as LanceDbFtsToken,
|
||||
NewColumnTransform, OptimizeAction, OptimizeOptions, Ref, Table as LanceDbTable,
|
||||
};
|
||||
use napi::Env;
|
||||
use napi::bindgen_prelude::*;
|
||||
use napi::threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode};
|
||||
use napi_derive::napi;
|
||||
|
||||
use crate::blob::{BlobFile, copy_blob_buffers, parse_row_ids, spawn_abortable};
|
||||
use crate::error::NapiErrorExt;
|
||||
use crate::index::Index;
|
||||
use crate::merge::NativeMergeInsertBuilder;
|
||||
@@ -73,13 +75,14 @@ impl Table {
|
||||
|
||||
#[napi(
|
||||
catch_unwind,
|
||||
ts_args_type = "buf: Buffer, mode: string, progressCallback?: (progress: WriteProgressInfo) => void"
|
||||
ts_args_type = "buf: Buffer, mode: string, progressCallback?: (progress: WriteProgressInfo) => void, allowExternalBlobOutsideBases?: boolean"
|
||||
)]
|
||||
pub async fn add(
|
||||
&self,
|
||||
buf: Buffer,
|
||||
mode: String,
|
||||
progress_callback: Option<ProgressFn>,
|
||||
allow_external_blob_outside_bases: Option<bool>,
|
||||
) -> napi::Result<AddResult> {
|
||||
let batches = ipc_file_to_batches(buf.to_vec())
|
||||
.map_err(|e| napi::Error::from_reason(format!("Failed to read IPC file: {}", e)))?;
|
||||
@@ -104,6 +107,9 @@ impl Table {
|
||||
return Err(napi::Error::from_reason(format!("Invalid mode: {}", mode)));
|
||||
};
|
||||
|
||||
op = op
|
||||
.allow_external_blob_outside_bases(allow_external_blob_outside_bases.unwrap_or(false));
|
||||
|
||||
if let Some(tsfn) = progress_callback {
|
||||
op = op.progress(move |p| {
|
||||
// NonBlocking: dispatch onto the JS event loop without
|
||||
@@ -329,6 +335,52 @@ impl Table {
|
||||
))
|
||||
}
|
||||
|
||||
#[napi(catch_unwind)]
|
||||
pub async fn blob_columns(&self) -> napi::Result<Vec<String>> {
|
||||
self.inner_ref()?.blob_columns().await.default_error()
|
||||
}
|
||||
|
||||
#[napi(catch_unwind)]
|
||||
pub fn fetch_blobs<'env>(
|
||||
&self,
|
||||
env: &'env Env,
|
||||
column: String,
|
||||
row_ids: Vec<BigInt>,
|
||||
signal: Option<AbortSignal>,
|
||||
) -> napi::Result<PromiseRaw<'env, Vec<Option<Buffer>>>> {
|
||||
let row_ids = parse_row_ids(row_ids)?;
|
||||
let table = self.inner_ref()?.clone();
|
||||
spawn_abortable(env, signal, async move {
|
||||
let array = table
|
||||
.fetch_blobs(column.as_str(), &row_ids)
|
||||
.await
|
||||
.default_error()?;
|
||||
Ok(copy_blob_buffers(array))
|
||||
})
|
||||
}
|
||||
|
||||
#[napi(catch_unwind)]
|
||||
pub fn fetch_blob_files<'env>(
|
||||
&self,
|
||||
env: &'env Env,
|
||||
column: String,
|
||||
row_ids: Vec<BigInt>,
|
||||
signal: Option<AbortSignal>,
|
||||
) -> napi::Result<PromiseRaw<'env, Vec<Option<BlobFile>>>> {
|
||||
let row_ids = parse_row_ids(row_ids)?;
|
||||
let table = self.inner_ref()?.clone();
|
||||
spawn_abortable(env, signal, async move {
|
||||
let files = table
|
||||
.fetch_blob_files(column.as_str(), &row_ids)
|
||||
.await
|
||||
.default_error()?;
|
||||
Ok(files
|
||||
.into_iter()
|
||||
.map(|file| file.map(BlobFile::new))
|
||||
.collect())
|
||||
})
|
||||
}
|
||||
|
||||
#[napi(catch_unwind)]
|
||||
pub fn vector_search(&self, vector: Float32Array) -> napi::Result<VectorQuery> {
|
||||
self.query()?.nearest_to(vector)
|
||||
@@ -388,16 +440,41 @@ impl Table {
|
||||
Ok(crate::job::Job::new(job))
|
||||
}
|
||||
|
||||
#[napi(catch_unwind)]
|
||||
pub async fn function_errors(
|
||||
&self,
|
||||
options: Option<FunctionErrorsOptions>,
|
||||
) -> napi::Result<FunctionErrors> {
|
||||
let options = options.unwrap_or_default();
|
||||
let limit = options
|
||||
.limit
|
||||
.map(|limit| {
|
||||
usize::try_from(limit)
|
||||
.map_err(|_| napi::Error::from_reason("limit must be a non-negative integer"))
|
||||
})
|
||||
.transpose()?;
|
||||
let request = lancedb::function::FunctionErrorsRequest {
|
||||
job_id: options.job_id,
|
||||
column: options.column,
|
||||
limit,
|
||||
};
|
||||
let errors = self
|
||||
.inner_ref()?
|
||||
.function_errors(request)
|
||||
.await
|
||||
.default_error()?;
|
||||
Ok(errors.into())
|
||||
}
|
||||
|
||||
#[napi(catch_unwind)]
|
||||
pub async fn refresh_materialized_view(
|
||||
&self,
|
||||
full: Option<bool>,
|
||||
source_version: Option<i64>,
|
||||
) -> napi::Result<RefreshMaterializedViewResult> {
|
||||
let view = lancedb::MaterializedView::from_table(self.inner_ref()?.clone())
|
||||
.await
|
||||
.default_error()?;
|
||||
let mut builder = view.refresh().full(full.unwrap_or(false));
|
||||
let mut builder = view.refresh();
|
||||
if let Some(version) = source_version {
|
||||
let version = u64::try_from(version).map_err(|_| {
|
||||
napi::Error::from_reason("sourceVersion must be a non-negative integer")
|
||||
@@ -408,6 +485,17 @@ impl Table {
|
||||
Ok(result.into())
|
||||
}
|
||||
|
||||
#[napi(catch_unwind)]
|
||||
pub async fn materialized_view_definition(&self) -> napi::Result<String> {
|
||||
let inner = self.inner_ref()?.clone();
|
||||
let view = lancedb::MaterializedView::from_table(inner)
|
||||
.await
|
||||
.default_error()?;
|
||||
Ok(lancedb::materialized_view::definition_metadata_from_sql(
|
||||
view.definition_sql(),
|
||||
))
|
||||
}
|
||||
|
||||
#[napi(catch_unwind)]
|
||||
pub async fn add_columns_with_schema(
|
||||
&self,
|
||||
@@ -638,22 +726,20 @@ impl Table {
|
||||
#[napi(catch_unwind)]
|
||||
pub async fn optimize(
|
||||
&self,
|
||||
older_than_ms: Option<i64>,
|
||||
before_timestamp_ms: Option<i64>,
|
||||
delete_unverified: Option<bool>,
|
||||
) -> napi::Result<OptimizeStats> {
|
||||
let inner = self.inner_ref()?;
|
||||
|
||||
let older_than = if let Some(ms) = older_than_ms {
|
||||
if ms == i64::MIN {
|
||||
return Err(napi::Error::from_reason(format!(
|
||||
"older_than_ms can not be {}",
|
||||
i32::MIN,
|
||||
)));
|
||||
}
|
||||
Duration::try_milliseconds(ms)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let before_timestamp = before_timestamp_ms
|
||||
.map(|ms| {
|
||||
DateTime::from_timestamp_millis(ms).ok_or_else(|| {
|
||||
napi::Error::from_reason(format!(
|
||||
"cleanupOlderThan timestamp is out of range: {ms}"
|
||||
))
|
||||
})
|
||||
})
|
||||
.transpose()?;
|
||||
|
||||
let compaction_stats = inner
|
||||
.optimize(OptimizeAction::Compact {
|
||||
@@ -664,16 +750,22 @@ impl Table {
|
||||
.default_error()?
|
||||
.compaction
|
||||
.unwrap();
|
||||
let prune_stats = inner
|
||||
.optimize(OptimizeAction::Prune {
|
||||
older_than,
|
||||
delete_unverified,
|
||||
error_if_tagged_old_versions: None,
|
||||
})
|
||||
.await
|
||||
.default_error()?
|
||||
.prune
|
||||
.unwrap();
|
||||
let prune_stats = if let Some(before_timestamp) = before_timestamp {
|
||||
inner
|
||||
.optimize_prune_before(before_timestamp, delete_unverified, None)
|
||||
.await
|
||||
} else {
|
||||
inner
|
||||
.optimize(OptimizeAction::Prune {
|
||||
older_than: None,
|
||||
delete_unverified,
|
||||
error_if_tagged_old_versions: None,
|
||||
})
|
||||
.await
|
||||
}
|
||||
.default_error()?
|
||||
.prune
|
||||
.unwrap();
|
||||
inner
|
||||
.optimize(lancedb::table::OptimizeAction::Index(
|
||||
OptimizeOptions::default(),
|
||||
@@ -744,7 +836,10 @@ impl Table {
|
||||
pub async fn uses_v2_manifest_paths(&self) -> napi::Result<bool> {
|
||||
self.inner_ref()?
|
||||
.as_native()
|
||||
.ok_or_else(|| napi::Error::from_reason("This cannot be run on a remote table"))?
|
||||
.ok_or_else(|| lancedb::Error::NotSupported {
|
||||
message: "uses_v2_manifest_paths is not supported for remote tables.".into(),
|
||||
})
|
||||
.default_error()?
|
||||
.uses_v2_manifest_paths()
|
||||
.await
|
||||
.default_error()
|
||||
@@ -754,7 +849,10 @@ impl Table {
|
||||
pub async fn migrate_manifest_paths_v2(&self) -> napi::Result<()> {
|
||||
self.inner_ref()?
|
||||
.as_native()
|
||||
.ok_or_else(|| napi::Error::from_reason("This cannot be run on a remote table"))?
|
||||
.ok_or_else(|| lancedb::Error::NotSupported {
|
||||
message: "migrate_manifest_paths_v2 is not supported for remote tables.".into(),
|
||||
})
|
||||
.default_error()?
|
||||
.migrate_manifest_paths_v2()
|
||||
.await
|
||||
.default_error()
|
||||
@@ -1270,6 +1368,16 @@ pub struct TableStatistics {
|
||||
/// The number of rows in the table
|
||||
pub num_rows: i64,
|
||||
|
||||
/// The number of rows marked as deleted across all fragments of the table
|
||||
///
|
||||
/// These rows are not included in `numRows`, but still occupy space on disk
|
||||
/// until the table is compacted, so a large value here indicates that the
|
||||
/// table should be optimized. Fragments in which every row was deleted are
|
||||
/// dropped outright, so their rows are not counted here.
|
||||
///
|
||||
/// Absent (`undefined`) when the backend does not report deletion counts.
|
||||
pub num_deleted_rows: Option<i64>,
|
||||
|
||||
/// The number of indices in the table
|
||||
pub num_indices: i64,
|
||||
|
||||
@@ -1318,6 +1426,7 @@ impl From<lancedb::table::TableStatistics> for TableStatistics {
|
||||
Self {
|
||||
total_bytes: v.total_bytes as i64,
|
||||
num_rows: v.num_rows as i64,
|
||||
num_deleted_rows: v.num_deleted_rows.map(|n| n as i64),
|
||||
num_indices: v.num_indices as i64,
|
||||
fragment_stats: FragmentStatistics {
|
||||
num_fragments: v.fragment_stats.num_fragments as i64,
|
||||
@@ -1420,9 +1529,100 @@ pub struct RefreshColumnResult {
|
||||
pub version: i64,
|
||||
}
|
||||
|
||||
/// Which per-row Function errors to list; every filter is optional.
|
||||
#[napi(object)]
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct FunctionErrorsOptions {
|
||||
/// Only errors recorded by this job.
|
||||
pub job_id: Option<String>,
|
||||
/// Only errors on this column.
|
||||
pub column: Option<String>,
|
||||
/// At most this many records (server default 10000, cap 100000).
|
||||
pub limit: Option<i64>,
|
||||
}
|
||||
|
||||
/// One row a Function refresh skipped, as the server recorded it.
|
||||
#[napi(object)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FunctionErrorRecord {
|
||||
pub job_id: String,
|
||||
pub fragment_id: i64,
|
||||
/// The row's offset within the fragment; absent when the fragment's
|
||||
/// detail was capped.
|
||||
pub row_offset: Option<i64>,
|
||||
pub column: String,
|
||||
pub function: String,
|
||||
pub function_version: String,
|
||||
pub table_version: i64,
|
||||
pub error_type: String,
|
||||
pub error_message: String,
|
||||
pub created_at_millis: i64,
|
||||
}
|
||||
|
||||
impl From<lancedb::function::FunctionErrorRecord> for FunctionErrorRecord {
|
||||
fn from(record: lancedb::function::FunctionErrorRecord) -> Self {
|
||||
Self {
|
||||
job_id: record.job_id,
|
||||
fragment_id: record.fragment_id as i64,
|
||||
row_offset: record.row_offset.map(i64::from),
|
||||
column: record.column,
|
||||
function: record.function,
|
||||
function_version: record.function_version,
|
||||
table_version: record.table_version as i64,
|
||||
error_type: record.error_type,
|
||||
error_message: record.error_message,
|
||||
created_at_millis: record.created_at_millis,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A fragment whose per-row error detail was capped: `rowsSkipped` rows
|
||||
/// failed, of which only `rowsRecorded` have a record of their own.
|
||||
#[napi(object)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FunctionErrorFragment {
|
||||
pub job_id: String,
|
||||
pub fragment_id: i64,
|
||||
pub rows_skipped: i64,
|
||||
pub rows_recorded: i64,
|
||||
}
|
||||
|
||||
impl From<lancedb::function::FunctionErrorFragment> for FunctionErrorFragment {
|
||||
fn from(fragment: lancedb::function::FunctionErrorFragment) -> Self {
|
||||
Self {
|
||||
job_id: fragment.job_id,
|
||||
fragment_id: fragment.fragment_id as i64,
|
||||
rows_skipped: fragment.rows_skipped as i64,
|
||||
rows_recorded: fragment.rows_recorded as i64,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A table's per-row Function errors.
|
||||
#[napi(object)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FunctionErrors {
|
||||
/// The recorded rows, newest job first.
|
||||
pub records: Vec<FunctionErrorRecord>,
|
||||
/// Fragments whose detail was capped.
|
||||
pub fragments: Vec<FunctionErrorFragment>,
|
||||
/// Whether the listing stopped at its limit.
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
impl From<lancedb::function::FunctionErrors> for FunctionErrors {
|
||||
fn from(errors: lancedb::function::FunctionErrors) -> Self {
|
||||
Self {
|
||||
records: errors.records.into_iter().map(Into::into).collect(),
|
||||
fragments: errors.fragments.into_iter().map(Into::into).collect(),
|
||||
truncated: errors.truncated,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct RefreshMaterializedViewResult {
|
||||
/// How the view was brought up to date: "rebuild", "incremental" or "no_op".
|
||||
/// How the view was brought up to date: "rebuild".
|
||||
pub mode: String,
|
||||
pub rows_written: i64,
|
||||
pub source_version: i64,
|
||||
@@ -1433,8 +1633,6 @@ impl From<lancedb::RefreshMaterializedViewResult> for RefreshMaterializedViewRes
|
||||
fn from(value: lancedb::RefreshMaterializedViewResult) -> Self {
|
||||
let mode = match value.mode {
|
||||
lancedb::RefreshMode::Rebuild => "rebuild",
|
||||
lancedb::RefreshMode::Incremental => "incremental",
|
||||
lancedb::RefreshMode::NoOp => "no_op",
|
||||
};
|
||||
Self {
|
||||
mode: mode.to_string(),
|
||||
|
||||
@@ -10,7 +10,10 @@
|
||||
"resolveJsonModule": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"moduleResolution": "Node"
|
||||
"moduleResolution": "Node",
|
||||
// flatbuffers 25 (via apache-arrow 21) ships declarations that need
|
||||
// TypeScript 5.7+ generic typed arrays; skip checking third-party .d.ts.
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"exclude": ["./dist/*", "./examples/*"],
|
||||
"typedocOptions": {
|
||||
|
||||
+2
-1
@@ -4,7 +4,8 @@
|
||||
"lancedb/native.d.ts:VectorQuery",
|
||||
"lancedb/native.d.ts:TakeQuery",
|
||||
"lancedb/native.d.ts:RecordBatchIterator",
|
||||
"lancedb/native.d.ts:NativeMergeInsertBuilder"
|
||||
"lancedb/native.d.ts:NativeMergeInsertBuilder",
|
||||
"lancedb/native.d.ts:TokenCacheOptions"
|
||||
],
|
||||
"useHTMLEncodedBrackets": true,
|
||||
"useCodeBlocks": true,
|
||||
|
||||
Reference in New Issue
Block a user