mirror of
https://github.com/lancedb/lancedb.git
synced 2026-10-13 15:22:30 +00:00
Remote SDKs report Cloud-specific errors against self-hosted servers, and some Python wrappers raise different exception types for the same unsupported operation. The sync index wrapper also warns that `replace` is ignored even though Rust forwards it to the server. Use backend-neutral table/server diagnostics and the existing `NotSupported` error conversions for unsupported manifest operations, blob ranges, primary keys, optimization, and HNSW-PQ indexes. Sync Python delegates to the async paths, so manifest operations and `optimize()` consistently raise `NotImplementedError`. Legacy and config-based HNSW-PQ creation share the same error and alternative-index advice. Remove the false replacement warning and simplify the ignored GPU-accelerator warning. Keep the deprecated cleanup/compaction no-ops, with backend-neutral warnings that do not assume automatic server maintenance. Embedding configuration forwarding and stale warning removal are now supplied by #4408 in `main`. Retain the regression that verifies full embedding metadata and warning-free remote creation alongside the diagnostics tests. Validation against the merged `main` revision `bbc4aebe` and Lance 14.0.0-beta.1: - Rebuilt the Python extension with `uv run --extra tests --extra dev maturin develop --extras tests,dev`; all 122 remote Python tests passed. Root Ruff checks and formatting passed using the uv-managed environment. - `pnpm build`, `pnpm lint`, formatting, and `pnpm run docs` passed; all 47 remote Node tests and the focused local vector-index regression passed. - All 583 Rust remote tests and 54 query unit tests passed, along with `cargo check --quiet --features remote --tests --examples`, `cargo clippy --quiet --features remote --tests --examples`, and `cargo fmt --all`. Fixes #4393 <!-- lance-gatekeeper-fix:v1 agent=10bafd337999f4910855c3a9b71e7dcf generation=1 --> --------- Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
1675 lines
54 KiB
TypeScript
1675 lines
54 KiB
TypeScript
// SPDX-License-Identifier: Apache-2.0
|
|
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
|
|
|
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 {
|
|
getHeaders(): Record<string, string> {
|
|
return { "X-Custom": "custom-value" };
|
|
}
|
|
}
|
|
|
|
class ErrorProvider extends HeaderProvider {
|
|
private errorMessage: string;
|
|
public callCount: number = 0;
|
|
|
|
constructor(errorMessage: string = "Test error") {
|
|
super();
|
|
this.errorMessage = errorMessage;
|
|
}
|
|
|
|
getHeaders(): Record<string, string> {
|
|
this.callCount++;
|
|
throw new Error(this.errorMessage);
|
|
}
|
|
}
|
|
|
|
class ConcurrentProvider extends HeaderProvider {
|
|
private counter: number = 0;
|
|
|
|
getHeaders(): Record<string, string> {
|
|
this.counter++;
|
|
return { "X-Request-Id": String(this.counter) };
|
|
}
|
|
}
|
|
|
|
async function withMockDatabase(
|
|
listener: RequestListener,
|
|
callback: (db: Connection) => void,
|
|
connectionOptions?: ConnectionOptions,
|
|
) {
|
|
const server = http.createServer(listener);
|
|
server.listen(8000);
|
|
|
|
const db = await connect(
|
|
"db://dev",
|
|
Object.assign(
|
|
{
|
|
apiKey: "fake",
|
|
hostOverride: "http://localhost:8000",
|
|
},
|
|
connectionOptions,
|
|
),
|
|
);
|
|
|
|
try {
|
|
await callback(db);
|
|
} finally {
|
|
// `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("reports unsupported operations with backend-neutral errors", async () => {
|
|
await withMockDatabase(
|
|
(req, res) => {
|
|
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) => {
|
|
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" } },
|
|
],
|
|
},
|
|
}),
|
|
);
|
|
},
|
|
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();
|
|
},
|
|
);
|
|
});
|
|
|
|
it("should accept partial connection options", async () => {
|
|
await connect("db://test", {
|
|
apiKey: "fake",
|
|
clientConfig: {
|
|
timeoutConfig: { readTimeout: 5 },
|
|
retryConfig: { retries: 2 },
|
|
},
|
|
});
|
|
});
|
|
|
|
it("should accept overall timeout configuration", async () => {
|
|
await connect("db://test", {
|
|
apiKey: "fake",
|
|
clientConfig: {
|
|
timeoutConfig: { timeout: 30 },
|
|
},
|
|
});
|
|
|
|
// Test with all timeout parameters
|
|
await connect("db://test", {
|
|
apiKey: "fake",
|
|
clientConfig: {
|
|
timeoutConfig: {
|
|
timeout: 60,
|
|
connectTimeout: 10,
|
|
readTimeout: 20,
|
|
poolIdleTimeout: 300,
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it("should pass down apiKey and userAgent", async () => {
|
|
await withMockDatabase(
|
|
(req, res) => {
|
|
expect(req.headers["x-api-key"]).toEqual("fake");
|
|
expect(req.headers["user-agent"]).toEqual(
|
|
`LanceDB-Node-Client/${packageJson.version}`,
|
|
);
|
|
|
|
const body = JSON.stringify({ tables: [] });
|
|
res.writeHead(200, { "Content-Type": "application/json" }).end(body);
|
|
},
|
|
async (db) => {
|
|
const tableNames = await db.tableNames();
|
|
expect(tableNames).toEqual([]);
|
|
},
|
|
);
|
|
});
|
|
|
|
it("allows customizing user agent", async () => {
|
|
await withMockDatabase(
|
|
(req, res) => {
|
|
expect(req.headers["user-agent"]).toEqual("MyApp/1.0");
|
|
|
|
const body = JSON.stringify({ tables: [] });
|
|
res.writeHead(200, { "Content-Type": "application/json" }).end(body);
|
|
},
|
|
async (db) => {
|
|
const tableNames = await db.tableNames();
|
|
expect(tableNames).toEqual([]);
|
|
},
|
|
{
|
|
clientConfig: {
|
|
userAgent: "MyApp/1.0",
|
|
},
|
|
},
|
|
);
|
|
});
|
|
|
|
it("shows the full error messages on retry errors", async () => {
|
|
await withMockDatabase(
|
|
(_req, res) => {
|
|
// We retry on 500 errors, so we return 500s until the client gives up.
|
|
res.writeHead(500).end("Internal Server Error");
|
|
},
|
|
async (db) => {
|
|
try {
|
|
await db.tableNames();
|
|
fail("expected an error");
|
|
// biome-ignore lint/suspicious/noExplicitAny: skip
|
|
} catch (e: any) {
|
|
expect(e.message).toContain("Hit retry limit for request_id=");
|
|
expect(e.message).toContain("Caused by: Http error");
|
|
expect(e.message).toContain("500 Internal Server Error");
|
|
}
|
|
},
|
|
{
|
|
clientConfig: {
|
|
retryConfig: { retries: 2 },
|
|
},
|
|
},
|
|
);
|
|
});
|
|
|
|
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) => {
|
|
const path = req.url ?? "";
|
|
if (path.endsWith("/describe/")) {
|
|
res.writeHead(200, { "Content-Type": "application/json" }).end(
|
|
JSON.stringify({
|
|
name: "broken_table",
|
|
version: 1,
|
|
schema: { fields: [] },
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (path.endsWith("/count_rows/")) {
|
|
res
|
|
.writeHead(400, { "Content-Type": "application/json" })
|
|
.end(JSON.stringify({ error: "count rows failed" }));
|
|
return;
|
|
}
|
|
|
|
res.writeHead(404).end();
|
|
},
|
|
async (db) => {
|
|
const table = await db.openTable("broken_table");
|
|
|
|
await expect(table.countRows()).rejects.toThrow("count rows failed");
|
|
},
|
|
);
|
|
});
|
|
|
|
it("should pass on requested extra headers", async () => {
|
|
await withMockDatabase(
|
|
(req, res) => {
|
|
expect(req.headers["x-my-header"]).toEqual("my-value");
|
|
|
|
const body = JSON.stringify({ tables: [] });
|
|
res.writeHead(200, { "Content-Type": "application/json" }).end(body);
|
|
},
|
|
async (db) => {
|
|
const tableNames = await db.tableNames();
|
|
expect(tableNames).toEqual([]);
|
|
},
|
|
{
|
|
clientConfig: {
|
|
extraHeaders: {
|
|
"x-my-header": "my-value",
|
|
},
|
|
},
|
|
},
|
|
);
|
|
});
|
|
|
|
it("supports version time-travel and branches on remote", async () => {
|
|
await withMockDatabase(
|
|
(req, res) => {
|
|
const body = req.url?.includes("/branches/list")
|
|
? JSON.stringify({
|
|
branches: {
|
|
exp: { parentVersion: 1, createAt: 1, manifestSize: 1 },
|
|
},
|
|
})
|
|
: JSON.stringify({ name: "t", version: 2, schema: { fields: [] } });
|
|
res.writeHead(200, { "Content-Type": "application/json" }).end(body);
|
|
},
|
|
async (db) => {
|
|
// version-only (and "main" + version) time-travel the main chain
|
|
const v2 = await db.openTable("t", undefined, { version: 2 });
|
|
expect(v2.currentBranch()).toBeNull();
|
|
const mainV2 = await db.openTable("t", undefined, {
|
|
branch: "main",
|
|
version: 2,
|
|
});
|
|
expect(mainV2.currentBranch()).toBeNull();
|
|
|
|
// a non-main branch opens a handle scoped to that branch
|
|
const exp = await db.openTable("t", undefined, { branch: "exp" });
|
|
expect(exp.currentBranch()).toBe("exp");
|
|
const expV2 = await db.openTable("t", undefined, {
|
|
branch: "exp",
|
|
version: 2,
|
|
});
|
|
expect(expV2.currentBranch()).toBe("exp");
|
|
},
|
|
);
|
|
});
|
|
|
|
it("sends FTS options to remote tables", async () => {
|
|
let createIndexBody: Record<string, unknown> | undefined;
|
|
|
|
await withMockDatabase(
|
|
(req, res) => {
|
|
const path = req.url ?? "";
|
|
if (path.endsWith("/describe/")) {
|
|
res.writeHead(200, { "Content-Type": "application/json" }).end(
|
|
JSON.stringify({
|
|
name: "t",
|
|
version: 1,
|
|
schema: {
|
|
fields: [
|
|
{ name: "text", type: { type: "string" }, nullable: false },
|
|
],
|
|
},
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (path.endsWith("/create_index/")) {
|
|
let raw = "";
|
|
req.on("data", (chunk) => {
|
|
raw += chunk;
|
|
});
|
|
req.on("end", () => {
|
|
createIndexBody = JSON.parse(raw);
|
|
res.writeHead(200).end();
|
|
});
|
|
return;
|
|
}
|
|
|
|
res.writeHead(404).end();
|
|
},
|
|
async (db) => {
|
|
const table = await db.openTable("t");
|
|
await table.createIndex("text", {
|
|
config: Index.fts({
|
|
blockSize: 256,
|
|
removeStopWords: true,
|
|
customStopWords: ["the"],
|
|
}),
|
|
});
|
|
},
|
|
);
|
|
|
|
expect(createIndexBody?.["column"]).toBe("text");
|
|
expect(createIndexBody?.["index_type"]).toBe("FTS");
|
|
expect(createIndexBody?.["block_size"]).toBe(256);
|
|
expect(createIndexBody?.["custom_stop_words"]).toEqual(["the"]);
|
|
});
|
|
|
|
it("diffs and cherry-picks remote branches", async () => {
|
|
const sampleDiff = {
|
|
fromBranch: "exp",
|
|
parentVersion: 1,
|
|
mainVersion: 2,
|
|
branchVersion: 3,
|
|
baseMoved: false,
|
|
rowCountMain: 3,
|
|
rowCountBranch: 3,
|
|
rowSummary: {
|
|
unchanged: 3,
|
|
newOnBase: 0,
|
|
newOnBranch: 0,
|
|
staleRecompute: 0,
|
|
inputsChanged: 0,
|
|
deltaAvailable: false,
|
|
},
|
|
addedColumns: [{ name: "tag", dataType: "utf8", nullable: true }],
|
|
removedColumns: [],
|
|
changedColumns: [],
|
|
addedIndexes: [],
|
|
removedIndexes: [],
|
|
errors: [],
|
|
};
|
|
const cherryPickBodies: Record<string, unknown>[] = [];
|
|
|
|
await withMockDatabase(
|
|
(req, res) => {
|
|
const path = req.url ?? "";
|
|
if (path.endsWith("/describe/")) {
|
|
res.writeHead(200, { "Content-Type": "application/json" }).end(
|
|
JSON.stringify({
|
|
name: "t",
|
|
version: 2,
|
|
schema: { fields: [] },
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
|
|
let raw = "";
|
|
req.on("data", (chunk) => {
|
|
raw += chunk;
|
|
});
|
|
req.on("end", () => {
|
|
const body = raw ? JSON.parse(raw) : {};
|
|
if (path.endsWith("/branches/diff/")) {
|
|
// biome-ignore lint/style/useNamingConvention: snake_case mandated by the server wire format
|
|
expect(body).toEqual({ from_branch: "exp" });
|
|
res
|
|
.writeHead(200, { "Content-Type": "application/json" })
|
|
.end(JSON.stringify(sampleDiff));
|
|
return;
|
|
}
|
|
if (path.endsWith("/branches/cherry_pick/")) {
|
|
cherryPickBodies.push(body);
|
|
const dryRun = body["dry_run"] === true;
|
|
const response = {
|
|
status: dryRun ? "ready" : "failed",
|
|
diff: dryRun
|
|
? sampleDiff
|
|
: {
|
|
...sampleDiff,
|
|
errors: [
|
|
{ code: "baseMoved", message: "main has advanced" },
|
|
],
|
|
},
|
|
preview: { promotedColumns: dryRun ? ["tag"] : [] },
|
|
};
|
|
res
|
|
.writeHead(dryRun ? 200 : 409, {
|
|
"Content-Type": "application/json",
|
|
})
|
|
.end(JSON.stringify(response));
|
|
return;
|
|
}
|
|
res.writeHead(404).end();
|
|
});
|
|
},
|
|
async (db) => {
|
|
const table = await db.openTable("t");
|
|
const branches = await table.branches();
|
|
|
|
await expect(branches.diff("exp")).resolves.toEqual(sampleDiff);
|
|
|
|
const failed = await branches.cherryPick("exp");
|
|
expect(failed.status).toBe("failed");
|
|
expect(failed.diff.errors).toEqual([
|
|
{ code: "baseMoved", message: "main has advanced" },
|
|
]);
|
|
|
|
const preview = await branches.cherryPick("exp", true);
|
|
expect(preview.status).toBe("ready");
|
|
expect(preview.preview.promotedColumns).toEqual(["tag"]);
|
|
},
|
|
);
|
|
|
|
expect(cherryPickBodies).toEqual([
|
|
// biome-ignore lint/style/useNamingConvention: snake_case mandated by the server wire format
|
|
{ from_branch: "exp", dry_run: false },
|
|
// biome-ignore lint/style/useNamingConvention: snake_case mandated by the server wire format
|
|
{ from_branch: "exp", dry_run: true },
|
|
]);
|
|
});
|
|
|
|
describe("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 = {
|
|
certFile: "/path/to/cert.pem",
|
|
keyFile: "/path/to/key.pem",
|
|
sslCaCert: "/path/to/ca.pem",
|
|
assertHostname: false,
|
|
};
|
|
|
|
expect(tlsConfig.certFile).toBe("/path/to/cert.pem");
|
|
expect(tlsConfig.keyFile).toBe("/path/to/key.pem");
|
|
expect(tlsConfig.sslCaCert).toBe("/path/to/ca.pem");
|
|
expect(tlsConfig.assertHostname).toBe(false);
|
|
});
|
|
|
|
it("should create TlsConfig with partial fields", () => {
|
|
const tlsConfig: TlsConfig = {
|
|
certFile: "/path/to/cert.pem",
|
|
keyFile: "/path/to/key.pem",
|
|
};
|
|
|
|
expect(tlsConfig.certFile).toBe("/path/to/cert.pem");
|
|
expect(tlsConfig.keyFile).toBe("/path/to/key.pem");
|
|
expect(tlsConfig.sslCaCert).toBeUndefined();
|
|
expect(tlsConfig.assertHostname).toBeUndefined();
|
|
});
|
|
|
|
it("should create ClientConfig with TlsConfig", () => {
|
|
const tlsConfig: TlsConfig = {
|
|
certFile: "/path/to/cert.pem",
|
|
keyFile: "/path/to/key.pem",
|
|
sslCaCert: "/path/to/ca.pem",
|
|
assertHostname: true,
|
|
};
|
|
|
|
const clientConfig: ClientConfig = {
|
|
userAgent: "test-agent",
|
|
tlsConfig: tlsConfig,
|
|
};
|
|
|
|
expect(clientConfig.userAgent).toBe("test-agent");
|
|
expect(clientConfig.tlsConfig).toBeDefined();
|
|
expect(clientConfig.tlsConfig?.certFile).toBe("/path/to/cert.pem");
|
|
expect(clientConfig.tlsConfig?.keyFile).toBe("/path/to/key.pem");
|
|
expect(clientConfig.tlsConfig?.sslCaCert).toBe("/path/to/ca.pem");
|
|
expect(clientConfig.tlsConfig?.assertHostname).toBe(true);
|
|
});
|
|
|
|
it("should handle empty TlsConfig", () => {
|
|
const tlsConfig: TlsConfig = {};
|
|
|
|
expect(tlsConfig.certFile).toBeUndefined();
|
|
expect(tlsConfig.keyFile).toBeUndefined();
|
|
expect(tlsConfig.sslCaCert).toBeUndefined();
|
|
expect(tlsConfig.assertHostname).toBeUndefined();
|
|
});
|
|
|
|
it("should accept TlsConfig in connection options", () => {
|
|
const tlsConfig: TlsConfig = {
|
|
certFile: "/path/to/cert.pem",
|
|
keyFile: "/path/to/key.pem",
|
|
sslCaCert: "/path/to/ca.pem",
|
|
assertHostname: false,
|
|
};
|
|
|
|
// Just verify that the ClientConfig accepts the TlsConfig
|
|
const clientConfig: ClientConfig = {
|
|
tlsConfig: tlsConfig,
|
|
};
|
|
|
|
const connectionOptions: ConnectionOptions = {
|
|
apiKey: "fake",
|
|
clientConfig: clientConfig,
|
|
};
|
|
|
|
// Verify the configuration structure is correct
|
|
expect(connectionOptions.clientConfig).toBeDefined();
|
|
expect(connectionOptions.clientConfig?.tlsConfig).toBeDefined();
|
|
expect(connectionOptions.clientConfig?.tlsConfig?.certFile).toBe(
|
|
"/path/to/cert.pem",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("header providers", () => {
|
|
it("should work with StaticHeaderProvider", async () => {
|
|
const provider = new StaticHeaderProvider({
|
|
authorization: "Bearer test-token",
|
|
"X-Custom": "value",
|
|
});
|
|
|
|
const headers = provider.getHeaders();
|
|
expect(headers).toEqual({
|
|
authorization: "Bearer test-token",
|
|
"X-Custom": "value",
|
|
});
|
|
|
|
// Test that it returns a copy
|
|
headers["X-Modified"] = "modified";
|
|
const headers2 = provider.getHeaders();
|
|
expect(headers2).not.toHaveProperty("X-Modified");
|
|
});
|
|
|
|
it("should pass headers from StaticHeaderProvider to requests", async () => {
|
|
const provider = new StaticHeaderProvider({
|
|
"X-Custom-Auth": "secret-token",
|
|
"X-Request-Source": "test-suite",
|
|
});
|
|
|
|
await withMockDatabase(
|
|
(req, res) => {
|
|
expect(req.headers["x-custom-auth"]).toEqual("secret-token");
|
|
expect(req.headers["x-request-source"]).toEqual("test-suite");
|
|
|
|
const body = JSON.stringify({ tables: [] });
|
|
res.writeHead(200, { "Content-Type": "application/json" }).end(body);
|
|
},
|
|
async () => {
|
|
// Use actual header provider mechanism instead of extraHeaders
|
|
const conn = await connect(
|
|
"db://dev",
|
|
{
|
|
apiKey: "fake",
|
|
hostOverride: "http://localhost:8000",
|
|
},
|
|
undefined, // session
|
|
provider, // headerProvider
|
|
);
|
|
|
|
const tableNames = await conn.tableNames();
|
|
expect(tableNames).toEqual([]);
|
|
},
|
|
);
|
|
});
|
|
|
|
it("should work with CustomProvider", () => {
|
|
const provider = new CustomProvider();
|
|
const headers = provider.getHeaders();
|
|
expect(headers).toEqual({ "X-Custom": "custom-value" });
|
|
});
|
|
|
|
it("should handle ErrorProvider errors", () => {
|
|
const provider = new ErrorProvider("Authentication failed");
|
|
|
|
expect(() => provider.getHeaders()).toThrow("Authentication failed");
|
|
expect(provider.callCount).toBe(1);
|
|
|
|
// Test that error is thrown each time
|
|
expect(() => provider.getHeaders()).toThrow("Authentication failed");
|
|
expect(provider.callCount).toBe(2);
|
|
});
|
|
|
|
it("should work with ConcurrentProvider", () => {
|
|
const provider = new ConcurrentProvider();
|
|
|
|
const headers1 = provider.getHeaders();
|
|
const headers2 = provider.getHeaders();
|
|
const headers3 = provider.getHeaders();
|
|
|
|
expect(headers1).toEqual({ "X-Request-Id": "1" });
|
|
expect(headers2).toEqual({ "X-Request-Id": "2" });
|
|
expect(headers3).toEqual({ "X-Request-Id": "3" });
|
|
});
|
|
|
|
describe("OAuthHeaderProvider", () => {
|
|
it("should initialize correctly", () => {
|
|
const fetcher = () => ({
|
|
accessToken: "token123",
|
|
expiresIn: 3600,
|
|
});
|
|
|
|
const provider = new OAuthHeaderProvider(fetcher);
|
|
expect(provider).toBeInstanceOf(HeaderProvider);
|
|
});
|
|
|
|
it("should fetch token on first use", async () => {
|
|
let callCount = 0;
|
|
const fetcher = () => {
|
|
callCount++;
|
|
return {
|
|
accessToken: "token123",
|
|
expiresIn: 3600,
|
|
};
|
|
};
|
|
|
|
const provider = new OAuthHeaderProvider(fetcher);
|
|
|
|
// Need to manually refresh first due to sync limitation
|
|
await provider.refreshToken();
|
|
|
|
const headers = provider.getHeaders();
|
|
expect(headers).toEqual({ authorization: "Bearer token123" });
|
|
expect(callCount).toBe(1);
|
|
|
|
// Second call should not fetch again
|
|
const headers2 = provider.getHeaders();
|
|
expect(headers2).toEqual({ authorization: "Bearer token123" });
|
|
expect(callCount).toBe(1);
|
|
});
|
|
|
|
it("should handle tokens without expiry", async () => {
|
|
const fetcher = () => ({
|
|
accessToken: "permanent_token",
|
|
});
|
|
|
|
const provider = new OAuthHeaderProvider(fetcher);
|
|
await provider.refreshToken();
|
|
|
|
const headers = provider.getHeaders();
|
|
expect(headers).toEqual({ authorization: "Bearer permanent_token" });
|
|
});
|
|
|
|
it("should throw error when access_token is missing", async () => {
|
|
const fetcher = () =>
|
|
({
|
|
expiresIn: 3600,
|
|
}) as { accessToken?: string; expiresIn?: number };
|
|
|
|
const provider = new OAuthHeaderProvider(
|
|
fetcher as () => {
|
|
accessToken: string;
|
|
expiresIn?: number;
|
|
},
|
|
);
|
|
|
|
await expect(provider.refreshToken()).rejects.toThrow(
|
|
"Token fetcher did not return 'accessToken'",
|
|
);
|
|
});
|
|
|
|
it("should handle async token fetchers", async () => {
|
|
const fetcher = async () => {
|
|
// Simulate async operation
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
return {
|
|
accessToken: "async_token",
|
|
expiresIn: 3600,
|
|
};
|
|
};
|
|
|
|
const provider = new OAuthHeaderProvider(fetcher);
|
|
await provider.refreshToken();
|
|
|
|
const headers = provider.getHeaders();
|
|
expect(headers).toEqual({ authorization: "Bearer async_token" });
|
|
});
|
|
});
|
|
|
|
it("should merge header provider headers with extra headers", async () => {
|
|
const provider = new StaticHeaderProvider({
|
|
"X-From-Provider": "provider-value",
|
|
});
|
|
|
|
await withMockDatabase(
|
|
(req, res) => {
|
|
expect(req.headers["x-from-provider"]).toEqual("provider-value");
|
|
expect(req.headers["x-extra-header"]).toEqual("extra-value");
|
|
|
|
const body = JSON.stringify({ tables: [] });
|
|
res.writeHead(200, { "Content-Type": "application/json" }).end(body);
|
|
},
|
|
async () => {
|
|
// Use header provider with additional extraHeaders
|
|
const conn = await connect(
|
|
"db://dev",
|
|
{
|
|
apiKey: "fake",
|
|
hostOverride: "http://localhost:8000",
|
|
clientConfig: {
|
|
extraHeaders: {
|
|
"X-Extra-Header": "extra-value",
|
|
},
|
|
},
|
|
},
|
|
undefined, // session
|
|
provider, // headerProvider
|
|
);
|
|
|
|
const tableNames = await conn.tableNames();
|
|
expect(tableNames).toEqual([]);
|
|
},
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("header provider integration", () => {
|
|
it("should work with TypeScript StaticHeaderProvider", async () => {
|
|
let requestCount = 0;
|
|
|
|
await withMockDatabase(
|
|
(req, res) => {
|
|
requestCount++;
|
|
|
|
// Check headers are present on each request
|
|
expect(req.headers["authorization"]).toEqual("Bearer test-token-123");
|
|
expect(req.headers["x-custom"]).toEqual("custom-value");
|
|
|
|
// Return different responses based on the endpoint
|
|
if (req.url === "/v1/table/test_table/describe/") {
|
|
const body = JSON.stringify({
|
|
name: "test_table",
|
|
schema: { fields: [] },
|
|
});
|
|
res
|
|
.writeHead(200, { "Content-Type": "application/json" })
|
|
.end(body);
|
|
} else {
|
|
const body = JSON.stringify({ tables: ["test_table"] });
|
|
res
|
|
.writeHead(200, { "Content-Type": "application/json" })
|
|
.end(body);
|
|
}
|
|
},
|
|
async () => {
|
|
// Create provider with static headers
|
|
const provider = new StaticHeaderProvider({
|
|
authorization: "Bearer test-token-123",
|
|
"X-Custom": "custom-value",
|
|
});
|
|
|
|
// Connect with the provider
|
|
const conn = await connect(
|
|
"db://dev",
|
|
{
|
|
apiKey: "fake",
|
|
hostOverride: "http://localhost:8000",
|
|
},
|
|
undefined, // session
|
|
provider, // headerProvider
|
|
);
|
|
|
|
// Make multiple requests to verify headers are sent each time
|
|
const tables1 = await conn.tableNames();
|
|
expect(tables1).toEqual(["test_table"]);
|
|
|
|
const tables2 = await conn.tableNames();
|
|
expect(tables2).toEqual(["test_table"]);
|
|
|
|
// Verify headers were sent with each request
|
|
expect(requestCount).toBeGreaterThanOrEqual(2);
|
|
},
|
|
);
|
|
});
|
|
|
|
it("should work with JavaScript function provider", async () => {
|
|
let requestId = 0;
|
|
|
|
await withMockDatabase(
|
|
(req, res) => {
|
|
// Check dynamic header is present
|
|
expect(req.headers["x-request-id"]).toBeDefined();
|
|
expect(req.headers["x-request-id"]).toMatch(/^req-\d+$/);
|
|
|
|
const body = JSON.stringify({ tables: [] });
|
|
res.writeHead(200, { "Content-Type": "application/json" }).end(body);
|
|
},
|
|
async () => {
|
|
// Create a JavaScript function that returns dynamic headers
|
|
const getHeaders = async () => {
|
|
requestId++;
|
|
return {
|
|
"X-Request-Id": `req-${requestId}`,
|
|
"X-Timestamp": new Date().toISOString(),
|
|
};
|
|
};
|
|
|
|
// Connect with the function directly
|
|
const conn = await connect(
|
|
"db://dev",
|
|
{
|
|
apiKey: "fake",
|
|
hostOverride: "http://localhost:8000",
|
|
},
|
|
undefined, // session
|
|
getHeaders, // headerProvider
|
|
);
|
|
|
|
// Make requests - each should have different headers
|
|
const tables = await conn.tableNames();
|
|
expect(tables).toEqual([]);
|
|
},
|
|
);
|
|
});
|
|
|
|
it("should support OAuth-like token refresh pattern", async () => {
|
|
let tokenVersion = 0;
|
|
|
|
await withMockDatabase(
|
|
(req, res) => {
|
|
// Verify authorization header
|
|
const authHeader = req.headers["authorization"];
|
|
expect(authHeader).toBeDefined();
|
|
expect(authHeader).toMatch(/^Bearer token-v\d+$/);
|
|
|
|
const body = JSON.stringify({ tables: [] });
|
|
res.writeHead(200, { "Content-Type": "application/json" }).end(body);
|
|
},
|
|
async () => {
|
|
// Simulate OAuth token fetcher
|
|
const fetchToken = async () => {
|
|
tokenVersion++;
|
|
return {
|
|
authorization: `Bearer token-v${tokenVersion}`,
|
|
};
|
|
};
|
|
|
|
// Connect with the function directly
|
|
const conn = await connect(
|
|
"db://dev",
|
|
{
|
|
apiKey: "fake",
|
|
hostOverride: "http://localhost:8000",
|
|
},
|
|
undefined, // session
|
|
fetchToken, // headerProvider
|
|
);
|
|
|
|
// Each request will fetch a new token
|
|
await conn.tableNames();
|
|
|
|
// Token should be different on next request
|
|
await conn.tableNames();
|
|
},
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("renameTable", () => {
|
|
async function captureRenameRequest(
|
|
call: (db: Connection) => Promise<void>,
|
|
): Promise<{ url: string; body: Record<string, unknown> }> {
|
|
let captured: { url: string; body: Record<string, unknown> } | undefined;
|
|
await withMockDatabase((req, res) => {
|
|
let raw = "";
|
|
req.on("data", (chunk) => {
|
|
raw += chunk;
|
|
});
|
|
req.on("end", () => {
|
|
captured = {
|
|
url: req.url ?? "",
|
|
body: raw ? JSON.parse(raw) : {},
|
|
};
|
|
res.writeHead(200, { "Content-Type": "application/json" }).end("");
|
|
});
|
|
}, call);
|
|
if (!captured) {
|
|
throw new Error("mock server never saw a request");
|
|
}
|
|
return captured;
|
|
}
|
|
|
|
it("sends rename request for a table in the root namespace", async () => {
|
|
const { url, body } = await captureRenameRequest(async (db) => {
|
|
await db.renameTable("table1", "table2");
|
|
});
|
|
expect(url).toBe("/v1/table/table1/rename/");
|
|
// biome-ignore lint/style/useNamingConvention: snake_case mandated by the server wire format
|
|
expect(body).toEqual({ new_table_name: "table2" });
|
|
});
|
|
|
|
it("omits new_namespace when only the current namespace is supplied", async () => {
|
|
// Safe-default check: passing namespacePath alone must not send
|
|
// `new_namespace`, so the server keeps the table in its current
|
|
// namespace instead of silently moving it to root.
|
|
const { url, body } = await captureRenameRequest(async (db) => {
|
|
await db.renameTable("table1", "table2", {
|
|
namespacePath: ["ns1"],
|
|
});
|
|
});
|
|
expect(url).toBe("/v1/table/ns1$table1/rename/");
|
|
// biome-ignore lint/style/useNamingConvention: snake_case mandated by the server wire format
|
|
expect(body).toEqual({ new_table_name: "table2" });
|
|
});
|
|
|
|
it("includes new_namespace in the body for a cross-namespace rename", async () => {
|
|
const { url, body } = await captureRenameRequest(async (db) => {
|
|
await db.renameTable("table1", "table2", {
|
|
namespacePath: ["ns1"],
|
|
newNamespacePath: ["ns2"],
|
|
});
|
|
});
|
|
expect(url).toBe("/v1/table/ns1$table1/rename/");
|
|
expect(body).toEqual({
|
|
// biome-ignore lint/style/useNamingConvention: snake_case mandated by the server wire format
|
|
new_table_name: "table2",
|
|
// biome-ignore lint/style/useNamingConvention: snake_case mandated by the server wire format
|
|
new_namespace: ["ns2"],
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("remote connection jobs surface", () => {
|
|
it("lists, describes, cancels, and reads history", async () => {
|
|
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) => {
|
|
let body = "";
|
|
req.on("data", (chunk) => {
|
|
body += chunk;
|
|
});
|
|
req.on("end", () => {
|
|
const payload = body.length > 0 ? JSON.parse(body) : {};
|
|
if (req.url === "/v1/jobs/list") {
|
|
if (payload["page_token"] === undefined) {
|
|
res
|
|
.writeHead(200, { "Content-Type": "application/json" })
|
|
.end(
|
|
'{"jobs": [{"job_id": "job-1", "table": "t1", ' +
|
|
'"job_type": "create_index", "state": "in_progress", ' +
|
|
'"created_at_millis": 1000}], "page_token": "next"}',
|
|
);
|
|
} else {
|
|
res
|
|
.writeHead(200, { "Content-Type": "application/json" })
|
|
.end(
|
|
'{"jobs": [{"job_id": "job-2", "table": "t2", ' +
|
|
'"job_type": "create_index", "state": "succeeded", ' +
|
|
'"created_at_millis": 2000}]}',
|
|
);
|
|
}
|
|
} 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;
|
|
}
|
|
res
|
|
.writeHead(200, { "Content-Type": "application/json" })
|
|
.end(
|
|
'{"job_id": "job-1", "job_type": "create_index", ' +
|
|
'"job_state": "FAILED", "creation_ms": 1000, ' +
|
|
'"spec": {"column": "vec"}, "failure": {"phase": "execute", ' +
|
|
'"message": "worker died", "retryable": true}}',
|
|
);
|
|
} else if (req.url === "/v1/jobs/cancel") {
|
|
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"}');
|
|
} 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",
|
|
})
|
|
.end(eventsBody);
|
|
} else {
|
|
res.writeHead(404).end();
|
|
}
|
|
});
|
|
},
|
|
async (db) => {
|
|
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");
|
|
|
|
expect(await db.cancelJob("job-1")).toBe(true);
|
|
expect(await db.cancelJob("missing")).toBe(false);
|
|
|
|
expect(await db.pauseJob("job-1")).toEqual("pausing");
|
|
expect(await db.resumeJob("job-1")).toEqual("still_pausing");
|
|
|
|
// 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);
|
|
},
|
|
);
|
|
});
|
|
}
|
|
});
|