Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions src/lib/api/object-route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -673,9 +673,9 @@ function boundText(part: ObjectSourcePart, limit: number): ObjectSourcePart {
* was the only engine that had landed; the day-one set is now three and the count was re-measured
* rather than the digit bumped, because what it counts is what the paragraph is for.
*
* There are THREE producers of `edit`: `providers/sql/postgres.ts:3246`, gated on
* There are THREE producers of `edit`: `providers/sql/postgres.ts:3269`, gated on
* `kindAcceptsSourceEdits(capabilities, kind)`; `providers/sql/trino/index.ts:1279` and
* `providers/keyvalue/redis.ts:1948`, both gated on `spec.acceptsSourceEdits === true`, which is the
* `providers/keyvalue/redis.ts:1957`, both gated on `spec.acceptsSourceEdits === true`, which is the
* same fact read through the same declaration. All three sit on the READABLE arm, verified rather
* than assumed: no producer attaches `edit` to a part carrying `unavailable`.
*
Expand All @@ -687,7 +687,7 @@ function boundText(part: ObjectSourcePart, limit: number): ObjectSourcePart {
* Rule 2's producer set GREW and its character changed, which is the part a bumped digit would have
* hidden. On PostgreSQL it is a by-product: that site spreads `truncated` and `edit` from a single
* read, so a routine over `SOURCE_CHARACTER_LIMIT` reaches it. On Redis it is a DECIDED POSITION,
* stated at `redis.ts:1941-1947`: the affordance is offered on a truncated part deliberately, because
* stated at `redis.ts:1950-1956`: the affordance is offered on a truncated part deliberately, because
* the bound is the CALLER's and the same object read without one is whole, so a provider that withheld
* it there would be answering a property of the REQUEST as a property of the object. Rule 2 is what
* makes that position safe on the standalone path, and the pane's predicate and `buildObjectEdit`'s
Expand All @@ -707,7 +707,7 @@ function boundText(part: ObjectSourcePart, limit: number): ObjectSourcePart {
*
* THE BOUND, on both sides of the same constant. `edit-plan/route.ts:74` refuses a SUBMITTED text
* longer than `EDIT_CHARACTER_LIMIT`, and all three day-one providers refuse a READ definition longer
* than it inside `buildObjectEdit`: `providers/sql/postgres.ts:3403`, `providers/keyvalue/redis.ts:2038`
* than it inside `buildObjectEdit`: `providers/sql/postgres.ts:3426`, `providers/keyvalue/redis.ts:2047`
* and `providers/sql/trino/index.ts:1473`. The second is what closes the class rather than narrowing
* it: a plan is minted only from the build's own read, so a definition the pane could only have shown
* truncated never reaches a plan at all, whatever the client POSTs.
Expand Down
86 changes: 86 additions & 0 deletions src/lib/db/object-kinds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,92 @@ export function assertObjectPathShape(
);
}

/**
* The engine half of `assertContainerPathShape`: the identity its error carries, and the
* three things the fifteen hoisted copies disagreed on.
*/
export type ContainerPathShapeEngine = {
/** The engine code the thrown `QueryError` is stamped with. */
code: DatabaseType;
/** The message's opening subject, article included: "A MySQL", "An Oracle". */
label: string;
/**
* Which path depths this engine accepts. `exact` takes the declared depth and nothing
* else; `prefixes` takes every depth up to it, because a container that names only the
* outer levels is a real address on those engines (a bucket with no scope, a catalog
* with no schema). Both refuse a path longer than the declaration.
*/
shapes: "exact" | "prefixes";
/**
* What the message prints in place of a shape list when the declaration carries no
* container level at all. The empty join would read as a formatting bug rather than as
* the fact it is, so each engine spells it in its own words.
*/
emptyShapes: string;
/**
* Which field of a declared level spells the shape. `label` is the engine's own word for
* a person reading a refusal, which is what most engines print; `id` is what Trino and
* PostgreSQL print, because a declaration whose label is prose would otherwise describe
* a shape no read accepts, since every read binds its segment by `id`. On both engines'
* own declarations the two are the same word, so only a varied declaration shows it.
*/
shapeNames: "id" | "label";
};

/**
* The path shapes a container is addressed by, spelled for a message: `[database]`,
* `[bucket] or [bucket, scope]`, or nothing at all for an engine that declares no level.
*
* An engine with no declared level accepts exactly one shape, the empty path, and prints
* `emptyShapes` rather than `[]` when a caller sends anything else.
*/
function containerShapeNames(
capabilities: ProviderCapabilities,
engine: ContainerPathShapeEngine,
): readonly string[][] {
const names = declaredLevels(capabilities).map((level) =>
engine.shapeNames === "id" ? level.id : level.label.toLowerCase(),
);
// The two families differ on exactly one case, the declaration that names no level. An
// `exact` engine accepts the empty path there, because the depth it asks for is zero; a
// `prefixes` engine accepts nothing at all, since every prefix of an empty list is a
// shape it never declared, and the message says so in the engine's own words.
if (engine.shapes === "exact") return names.length === 0 ? [[]] : [names];
return names.map((_, index) => names.slice(0, index + 1));
}

function renderContainerShapes(shapes: readonly string[][], engine: ContainerPathShapeEngine): string {
if (shapes.length === 0 || shapes.every((shape) => shape.length === 0)) return engine.emptyShapes;
return shapes.map((shape) => `[${shape.join(", ")}]`).join(" or ");
}

/**
* Refuses a container path that is not one of the shapes the DECLARATION describes.
*
* Hoisted from fifteen provider-local copies (#1065): eleven threw on a depth mismatch and
* four carried their own `shapeList()`, and two of those four had already drifted. The
* depth comes from `containerDepth()` through `declaredLevels` and the segment names from
* the declared labels, so the check and its message are the same array and nothing here
* can inherit a hardcoded 1. An engine's own opening words, its accepted depths and its
* empty-declaration wording travel through the descriptor, because those differ by design.
*
* It raises rather than reading a segment and carrying on: `undefined` bound to a
* parameter answers an empty folder that looks exactly like a container holding nothing,
* and a path one segment too long would bind the object's own name as the missing level.
*/
export function assertContainerPathShape(
capabilities: ProviderCapabilities,
container: readonly string[],
engine: ContainerPathShapeEngine,
): void {
const shapes = containerShapeNames(capabilities, engine);
if (shapes.some((shape) => shape.length === container.length)) return;
throw new QueryError(
`${engine.label} container path is ${renderContainerShapes(shapes, engine)}, received ${JSON.stringify(container)}`,
engine.code,
);
}

/**
* Whether THIS KIND accepts a row write. Absent and undeclared both read as false.
*
Expand Down
48 changes: 22 additions & 26 deletions src/lib/db/providers/document/couchbase/objects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,13 @@
*/

import { QueryError } from "@/lib/db/errors";
import { assertObjectPathShape, containerDepth, type ObjectPathShapeEngine } from "@/lib/db/object-kinds";
import {
assertContainerPathShape,
assertObjectPathShape,
containerDepth,
type ContainerPathShapeEngine,
type ObjectPathShapeEngine,
} from "@/lib/db/object-kinds";
import { comparePaths } from "@/lib/db/object-path";
import type {
ColumnSchema,
Expand All @@ -123,6 +129,20 @@ import { DOCUMENT_KEY_EXPRESSION, unquoteIndexKey } from "./introspect";
import { COUCHBASE_DEFAULT_SCOPE } from "./keyspace";
import type { CouchbaseRow, Keyspace } from "./transport";

/**
* Couchbase's identity for the shared container-path renderer.
*
* `shapes: "prefixes"`: every depth up to the declaration is a real address here, because a
* caller may name only the outer levels. A path longer than the declaration is still refused.
*/
const COUCHBASE_CONTAINER_PATH_ENGINE: ContainerPathShapeEngine = {
code: "couchbase",
label: "A Couchbase",
shapeNames: "label",
shapes: "prefixes",
emptyShapes: "nothing: this declaration carries no container level",
};

// ============================================================================
// The declaration
// ============================================================================
Expand Down Expand Up @@ -393,24 +413,6 @@ function requiredSegment(
return segment;
}

/** Every prefix of the declared levels: a bucket alone, or a bucket and a scope. */
function containerShapes(capabilities: ProviderCapabilities): readonly string[][] {
const names = declaredLevels(capabilities).map((level) => level.label.toLowerCase());
return names.map((_, index) => names.slice(0, index + 1));
}

/**
* The shapes above, spelled for a message: `[bucket] or [bucket, scope]`.
*
* A declaration carrying no container level has no shape at all, and the empty join would
* print "a Couchbase container path is , received []", which reads as a formatting bug
* rather than as the fact it is.
*/
function shapeList(shapes: readonly string[][]): string {
if (shapes.length === 0) return "nothing: this declaration carries no container level";
return shapes.map((shape) => `[${shape.join(", ")}]`).join(" or ");
}

/**
* What one container path addresses: the bucket to bind, and the scope to filter to.
*
Expand All @@ -424,13 +426,7 @@ export interface ContainerRead {
}

export function containerRead(capabilities: ProviderCapabilities, container: readonly string[]): ContainerRead {
const shapes = containerShapes(capabilities);
if (!shapes.some((shape) => shape.length === container.length)) {
throw new QueryError(
`A Couchbase container path is ${shapeList(shapes)}, received ${JSON.stringify(container)}`,
"couchbase",
);
}
assertContainerPathShape(capabilities, container, COUCHBASE_CONTAINER_PATH_ENGINE);
const segments = containerSegments(capabilities, container);
return { bucket: requiredSegment(segments, "catalog"), scope: segments.schema };
}
Expand Down
27 changes: 18 additions & 9 deletions src/lib/db/providers/document/mongodb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,19 +47,35 @@ import {
} from "../../types";
import {
applySourceBound,
assertContainerPathShape,
assertObjectPathShape,
type ObjectPathShapeEngine,
callerBoundTruncationReason,
containerDepth,
declaredKinds,
findKind,
isCountUnavailable,
type ContainerPathShapeEngine,
type ObjectPathShapeEngine,
} from "@/lib/db/object-kinds";
import { comparePaths } from "@/lib/db/object-path";
import { DatabaseConfigError, ConnectionError, QueryError, mapDatabaseError } from "../../errors";
import { formatBytes } from "../../utils/pool-manager";
import { CACHE_HIT_RATIO_UNAVAILABLE, formatCacheHitRatio, measuredNumber } from "@/lib/monitoring-cache-ratio";

/**
* MongoDB's identity for the shared container-path renderer.
*
* `shapes: "exact"`: the caller either names every declared level or is refused,
* because a partial path would leave a level unbound and answer an empty folder.
*/
const MONGODB_CONTAINER_PATH_ENGINE: ContainerPathShapeEngine = {
code: "mongodb",
label: "A MongoDB",
shapeNames: "label",
shapes: "exact",
emptyShapes: "empty",
};

// ============================================================================
// Types
// ============================================================================
Expand Down Expand Up @@ -431,14 +447,7 @@ function containerSegment(
* way to report a caller mistake.
*/
function containerDatabase(capabilities: ProviderCapabilities, container: readonly string[]): string {
const levels = declaredLevels(capabilities);
if (container.length !== levels.length) {
throw new QueryError(
`A MongoDB container path is [${levels.map((level) => level.label.toLowerCase()).join(", ")}], ` +
`received ${JSON.stringify(container)}`,
"mongodb",
);
}
assertContainerPathShape(capabilities, container, MONGODB_CONTAINER_PATH_ENGINE);
return containerSegment(capabilities, container, "schema");
}

Expand Down
21 changes: 17 additions & 4 deletions src/lib/db/providers/embedded/libredb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,11 @@ import {
type ObjectKindSpec,
} from "../../types";
import {
assertContainerPathShape,
assertObjectPathShape,
callerBoundTruncationReason,
containerDepth,
type ContainerPathShapeEngine,
declaredKinds,
findKind,
type ObjectPathShapeEngine,
Expand All @@ -62,6 +64,20 @@ import { CACHE_HIT_RATIO_UNAVAILABLE } from "@/lib/monitoring-cache-ratio";
import * as fs from "fs";
import * as path from "path";

/**
* LibreDB's identity for the shared container-path renderer.
*
* `shapes: "exact"`: the only path this engine accepts is the declared depth, which here
* is the empty one, so any segment at all is a caller holding another engine's model.
*/
const LIBREDB_CONTAINER_PATH_ENGINE: ContainerPathShapeEngine = {
code: "libredb",
label: "A LibreDB",
shapeNames: "label",
shapes: "exact",
emptyShapes: "empty",
};

// ============================================================================
// Lazy package loader (mirrors sqlite.ts loading bun:sqlite)
// ============================================================================
Expand Down Expand Up @@ -309,10 +325,7 @@ function declaredLevels(capabilities: ProviderCapabilities): readonly ContainerL
* database holding nothing.
*/
function assertContainerPath(capabilities: ProviderCapabilities, container: readonly string[]): void {
const levels = declaredLevels(capabilities);
if (container.length === levels.length) return;
const shape = levels.length === 0 ? "empty" : `[${levels.map((level) => level.label.toLowerCase()).join(", ")}]`;
throw new QueryError(`A LibreDB container path is ${shape}, received ${JSON.stringify(container)}`, "libredb");
assertContainerPathShape(capabilities, container, LIBREDB_CONTAINER_PATH_ENGINE);
}

/** One enumerated object, with the two things `describeObject` needs to describe it. */
Expand Down
25 changes: 17 additions & 8 deletions src/lib/db/providers/keyvalue/redis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,12 @@ import Redis, { type RedisOptions } from "ioredis";
import { BaseDatabaseProvider } from "../../base-provider";
import {
applySourceBound,
assertContainerPathShape,
assertObjectPathShape,
type ObjectPathShapeEngine,
callerBoundTruncationReason,
containerDepth,
type ContainerPathShapeEngine,
declaredKinds,
findKind,
requireEditableKind,
Expand Down Expand Up @@ -72,6 +74,20 @@ import {
} from "../../types";
import { DatabaseConfigError, QueryError, ConnectionError } from "../../errors";

/**
* Redis's identity for the shared container-path renderer.
*
* `shapes: "exact"`: the caller either names every declared level or is refused,
* because a partial path would leave a level unbound and answer an empty folder.
*/
const REDIS_CONTAINER_PATH_ENGINE: ContainerPathShapeEngine = {
code: "redis",
label: "A Redis",
shapeNames: "label",
shapes: "exact",
emptyShapes: "empty",
};

/**
* The server's own words for "you asked me to discard and there is nothing queued".
*
Expand Down Expand Up @@ -317,14 +333,7 @@ function containerSegment(
* limit of the deployment, which this function does not know without a second round trip.
*/
function containerDatabase(capabilities: ProviderCapabilities, container: readonly string[]): number {
const levels = declaredLevels(capabilities);
if (container.length !== levels.length) {
throw new QueryError(
`A Redis container path is [${levels.map((level) => level.label.toLowerCase()).join(", ")}], ` +
`received ${JSON.stringify(container)}`,
"redis",
);
}
assertContainerPathShape(capabilities, container, REDIS_CONTAINER_PATH_ENGINE);
const segment = containerSegment(capabilities, container, "schema");
if (!/^\d+$/.test(segment)) {
throw new QueryError(`A Redis database is a number, received ${JSON.stringify(segment)}`, "redis");
Expand Down
Loading
Loading