Skip to content

Latest commit

 

History

History
1688 lines (1348 loc) · 119 KB

couchbase.md

File metadata and controls

1688 lines (1348 loc) · 119 KB

Couchbase Provider

Couchbase Server support for LibreDB Studio, built on the documented REST surfaces — the Query Service (/query/service) and the management API (/pools/default...) — with no driver dependency of any kind. This document is the single reference point for the Couchbase provider: design, architecture, usage, and tests. If you are reading the code, extending Couchbase support, or authoring a new provider, start here.

DB_HTTP_BLOCK_PRIVATE_HOSTS=true blocks loopback, private, link-local and other non-public HTTP destinations; it is off by default so local connections work.

Status Implemented & shipped
Database type id couchbase
Family Document (src/lib/db/providers/document/couchbase/)
Driver None — HTTP only (fetch for plaintext, node:https for TLS; both runtime built-ins)
Query language sql (SQL++, formerly N1QL)
Default port 8091 management (18091 with TLS). Query ports are discovered, never configured
Connection pooling None — each statement is one stateless HTTP request
Connection string Supported (couchbase://, couchbases://, Capella SRV endpoints)
EXPLAIN couchbase-json — estimate only, no EXPLAIN ANALYZE
Transactions Not exposed (no begin/commit/rollback API on this provider)
Query cancellation No cancelQuery; a running request is killed via maintenance kill
Source src/lib/db/providers/document/couchbase/
Tests tests/integration/db/couchbase-provider.test.ts + tests/unit/db/couchbase/
Tracking issue #262 — Add Couchbase provider (SQL++ over Query REST, no native dependency)
Write reach open: the editor, Import and maintenance (analyze, reindex, kill) send writes

1. Overview

Couchbase is a distributed document database whose query language, SQL++, is a real SQL dialect. That makes it an easier fit for this codebase than MongoDB was: the provider declares queryLanguage: "sql" and inherits Monaco SQL highlighting, the shared query limiter, the "sql" tab type and saved queries with no additional code.

The two things that are Couchbase-shaped, and which every design decision below flows from:

  1. The hierarchy has four levels — cluster > bucket > scope > collection — while the schema explorer renders a flat list. The bucket is pinned by the connection; scope and collection are flattened exactly the way PostgreSQL flattens schema and table.
  2. Indexes govern whether, and how fast, a keyspace can be read. From Server 7.6 an un-indexed collection is still readable through a sequential scan, but slowly; on 7.0-7.2 it fails outright with error 4000. The provider handles both and tells the user what to do (§3.8).

Concept mapping

DatabaseProvider slot Couchbase realisation Mechanism
"Table" (the relation kind) A collection, displayed as collection or scope.collection system:keyspaces LEFT JOIN system:scopes
"Row" A document SQL++ result row
Columns Inferred field types from a 100-document sample INFER <keyspace> WITH {"sample_size": 100}
Primary key The document key, projected as __id META(d).id
query(sql) A SQL++ statement POST /query/service
Indexes Global secondary indexes (GSI) system:indexes
Foreign keys none (Couchbase has none) always []
getOverview() / storage Cluster and bucket runtime statistics /pools/default, /pools/default/buckets/<bucket>
getSlowQueries() / getActiveSessions() Query-service request catalogs system:completed_requests, system:active_requests
Maintenance analyze / reindex / kill UPDATE STATISTICS, BUILD INDEX, DELETE FROM system:active_requests

2. Architecture

2.1 Where it sits

The database layer uses the Strategy Pattern. Every provider implements the DatabaseProvider interface, and most shared mechanics live in the abstract BaseDatabaseProvider. Couchbase is the first provider that is a directory rather than a single file, because the transport is a seam (§3.2):

src/lib/db/providers/document/
├── mongodb/
└── couchbase/
    ├── index.ts             # CouchbaseProvider - the DatabaseProvider implementation
    ├── transport.ts         # CouchbaseTransport interface + neutral result types (no I/O)
    ├── http-transport.ts    # the one implementation: Query REST + management REST
    ├── keyspace.ts          # display name <-> backtick-quoted keyspace path (pure)
    └── introspect.ts        # system:* catalog reads + INFER

The explain strategy lives with the other strategies, not with the provider: src/lib/explain/couchbase-json.ts.

2.2 Class hierarchy

DatabaseProvider (interface, types.ts)
        ^
        | implements
BaseDatabaseProvider (abstract, base-provider.ts)
        ^
        | extends
CouchbaseProvider (couchbase/index.ts)

CouchbaseProvider extends BaseDatabaseProvider directly — the same pattern as MongoDBProvider and RedisProvider. It is not a SQLBaseProvider, and the reason is the dialect rather than the transport: that base is pure SQL text helpers keyed off this.type (sql-base.ts) with nothing driver- or pool-bound in it, so an HTTP transport alone would be no reason to skip it. What does not fit is the quoting — escapeIdentifier() emits double quotes for every type except MySQL and SQL Server, while SQL++ needs doubled backticks, which this provider owns in keyspace.ts.

The cost of that choice is one duplication: prepareQuery() is re-implemented here to apply the shared query limiter that SQLBaseProvider.prepareQuery() would otherwise have supplied. SQL++ being a SQL dialect is expressed through queryLanguage: "sql", not through the class hierarchy.

2.3 What the base class gives you for free

CouchbaseProvider reuses these inherited members rather than reimplementing them:

  • State machine — setConnected(), setError(), isConnected(), ensureConnected().
  • Instrumentation — trackQuery() (active-query counter) and measureExecution() (wall clock).
  • Helpers — formatDuration(), getSafeConfig() (password-stripped logging), mapError().
  • Default getMonitoringData() — orchestrates getOverview + getPerformanceMetrics + getSlowQueries + getActiveSessions (+ tables/indexes/storage) concurrently.

2.4 Registration & lifecycle

The factory wires Couchbase in via a dynamic import inside createDatabaseProvider() (factory.ts):

case 'couchbase': {
  // The explicit /index specifier keeps this dynamic import statically analysable:
  // a bare directory resolves only at runtime, which the bundler cannot trace into a chunk.
  const { CouchbaseProvider } = await import('./providers/document/couchbase/index');
  return new CouchbaseProvider(connection, options);
}

connect() (index.ts) proves reachability and credentials with one GET /pools/default — the cheapest call that needs no RBAC role beyond cluster read — then assertBucketExists() asks the bucket's own path (GET /pools/default/buckets/<bucket>): a 404 there is the cluster itself saying the configured bucket is not on it, and the connect is refused with the bucket named rather than saved to a connection that shows the cluster's real buckets in the tree and never says the configured one is wrong. Only a 404 refuses; a read that fails for any other reason — a role that may read the cluster but not its bucket settings — leaves the connect the pools read already proved. disconnect() clears the transport's cached endpoint discovery; there are no sockets to close. API routes use getOrCreateProvider(), which caches the connected provider per connection.id and evicts it after 30 minutes idle.


3. Design decisions

These are the non-obvious choices. Read this section before changing the provider.

3.1 HTTP transport only — no native dependency, mandatory or optional

The official couchbase SDK is Apache-2.0 but weighs 64.6 MB unpacked across 3765 files, depends on cmake-js and node-addon-api, and runs a postinstall step that downloads a prebuilt binary or compiles from source. Studio ships as a Docker image, Snap, AppImage, Flatpak, deb/rpm and the @libredb/studio npm package that libredb-platform consumes — every one of those would inherit the native module.

HTTP is also the more deployable choice, not the weaker one: air-gapped installs have no postinstall download to fail; the binary KV protocol on 11210 does not traverse corporate HTTP proxies while REST does; site firewall policy commonly opens 8091/18093 and not 11210; and container images need no glibc/musl matching. Couchbase's own Web Console and the Capella UI are browser applications, so they reach the cluster over exactly these endpoints.

Two capabilities that look SDK-only are not:

  • Direct document lookup by key works over the Query Service via USE KEYS (§5.3).
  • Distributed ACID transactions work over REST (BEGIN TRANSACTION returns a txid carried as a request parameter). This provider does not expose them yet — see §13.

What remains genuinely SDK-only is three things, all named honestly in §13: KV range scan, non-JSON documents, and subdocument operations.

3.2 The transport seam: one interface, one implementation

Provider logic never calls fetch. It goes through CouchbaseTransport (transport.ts), so adopting the SDK later would be one new file implementing the same contract rather than a rewrite:

interface CouchbaseTransport {
  readonly kind: "http";
  query(stmt: string, o?: QueryOpts): Promise<CouchbaseQueryResult>;
  manage<T>(path: string): Promise<T>;
  /** SDK extension point: KV range scan. Not available over HTTP. */
  scanDocuments?(ks: Keyspace, limit: number, skip: number): Promise<CouchbaseRow[]>;
  close(): Promise<void>;
}

The result type, CouchbaseQueryResult, is deliberately neutral rather than the REST envelope (transport.ts):

interface CouchbaseQueryResult {
  rows: CouchbaseRow[];
  fieldNames: string[] | null;   // null when the source cannot tell (SELECT *)
  executionTimeMs: number;
  mutationCount: number;
  warnings: CouchbaseWarning[];   // { message, code? } - no code is left out, never zeroed
}

An interface shaped like { results, signature, status, metrics, errors } would force any future SDK adapter to fabricate fields only the REST API produces. Both sources produce the shape above without inventing anything. Errors follow the same rule: the transport throws a normalized CouchbaseError { code, message, retriable } (transport.ts) whose code is a single numeric space — SQL++ codes (3000, 4000, 13014, …) and HTTP codes (401, 403, 503) both land there, so provider-level mapping is one switch.

manage() stays HTTP permanently: the SDK's management APIs cover bucket/index/user settings, not cluster and bucket runtime statistics, so /pools/default and /pools/default/buckets/<bucket> are required for overview, performance and storage metrics under any transport.

Seam rule. The REST envelope identifiers (results, signature, requestID, status) must appear only in http-transport.ts. A stray if (response.status === "errors") in provider logic quietly erodes the boundary and makes a future SDK adapter expensive.

3.3 Ports are discovered, not configured

DatabaseConnection carries one port, but Couchbase needs both a management endpoint and a query endpoint. Only the management port is stored (8091, or 18091 with TLS); the query endpoint comes from GET /pools/default/nodeServices, reading nodesExt[].services.n1ql (or n1qlSSL under TLS) and preferring alternateAddresses.external when present — which is what makes NAT, Docker port mapping and Capella work (pickQueryEndpoint(), http-transport.ts). With no n1ql entry anywhere the transport falls back to 8093 / 18093.

Discovery is cached as a promise, so concurrent first queries share one round trip — but a failed discovery is not cached, or one unreachable moment would poison every later query on the connection (getQueryEndpoint(), http-transport.ts).

Capella endpoints (couchbases://cb.<id>.cloud.couchbase.com) are SRV records, so a host given without an explicit port is resolved through _couchbases._tcp.<host> first; a DNS failure or an empty answer falls back to treating the host as a plain A record, which is what every self-hosted cluster needs anyway (resolveHost(), http-transport.ts).

3.4 Keyspace flattening follows the PostgreSQL rule

keyspace.ts is pure, with no I/O. The default scope is implicit and everything else is qualified, exactly as postgres.ts does for schema/table:

keyspaceDisplayName('_default', 'hotel')     // -> "hotel"
keyspaceDisplayName('inventory', 'hotel')    // -> "inventory.hotel"
keyspaceFromDisplayName('travel', 'inventory.hotel')
// -> { bucket: 'travel', scope: 'inventory', collection: 'hotel' }
keyspacePath({ bucket: 'travel', scope: 'inventory', collection: 'hotel' })
// -> `travel`.`inventory`.`hotel`

Quoting is a security boundary. SQL++ has no bind parameter for identifiers, so keyspace paths are assembled by concatenation; quoteIdentifier() (keyspace.ts) doubles embedded backticks so a hostile identifier cannot terminate its own quoting and have the remainder parsed as SQL++. Backticks are also required for a second, mundane reason: bucket and scope are reserved words in SQL++, and an unquoted projection over system:keyspaces fails with error 3000 (verified on Server 8.0.2).

3.5 HTTP 200 does not mean success

The Query Service returns syntax and semantic errors inside a 200 response with status: "errors". The transport therefore inspects the payload before the HTTP code (throwIfFailed(), http-transport.ts); skipping that check reports a failed statement as "0 rows".

3.6 SELECT * nests documents, so generated queries project the key explicitly

SELECT * FROM hotel yields [{ "hotel": { ... } }] — the document is nested under the keyspace name, and the key is not part of the result at all. Generated queries therefore alias the keyspace and project the key (generators.ts, its row of SQL_PORT_GENERATORS):

SELECT META(d).id AS __id, d.* FROM `travel`.`inventory`.`hotel` AS d;

It carries no row bound. Since #816 the preview cap travels as the limit execution option rather than as text in the statement, so the limiter can recognise the bound as its own and advance it for page two; a bound in the statement is indistinguishable from one the user typed, and a self-bounded statement comes back unrewritten with the requested offset discarded. This provider declares supportsResultPagination: true, so the results grid offers Load More over the preview.

The alias __id matches COUCHBASE_DOCUMENT_KEY_COLUMN in the introspection module (introspect.ts), so the schema tree and the result grid name the key identically. A hand-written SELECT * still works; its columns are then derived from the rows, because a wildcard signature tells the transport nothing (fieldNamesFromSignature(), http-transport.ts).

3.7 Read-your-writes: scan_consistency defaults to request_plus

The query service defaults to not_bounded, reading the index in whatever state it happens to be in. That is unacceptable for an interactive editor. Verified against Couchbase Server 8.0.2: immediately after an INSERT, a SELECT returned zero rows while COUNT(*) already returned three, and the same SELECT returned three rows seconds later — a user inserts a row, selects, and sees nothing.

The transport therefore sends scan_consistency: "request_plus" on every statement (DEFAULT_SCAN_CONSISTENCY, http-transport.ts), so a user always sees their own writes. Callers that prefer latency over freshness opt out per statement:

await transport.query('SELECT ...', { scanConsistency: 'not_bounded' });

The trade-off is explicit: request_plus makes the query wait for the index to catch up with the mutations issued before it, which costs latency on a write-heavy cluster. Correctness in an editor is worth more than milliseconds; speed remains one option away.

3.8 Un-indexed keyspaces: sequential scan, and error 4000

What happens when a collection has no index depends on the server version, and the difference matters enough to state plainly.

Server 7.6 and later — it works, slowly. The Query Service falls back to a sequential scan, which uses a KV range scan underneath to enumerate keys, so CRUD and JOIN all succeed with no primary or secondary index present. Verified on Community Edition 8.0.2: selecting from a collection with no index at all returns rows, and EXPLAIN shows the fallback explicitly:

{ "#operator": "PrimaryScan3", "index": "#sequentialscan", "using": "sequentialscan" }

Clicking an un-indexed collection in the schema explorer therefore just works. The caveat is performance, not capability: a sequential scan is not optimised for throughput and degrades sharply on large collections, to the point of query timeouts. Creating an index remains the right thing to do for anything beyond a small or throwaway collection — it is a recommendation now, not a prerequisite.

Server 7.0 to 7.2 — it fails with error 4000. Sequential scan does not exist there, so the same statement returns "No index available on keyspace". The provider re-raises it as a QueryError carrying the runnable remedy, quoted for the exact keyspace the statement read from (primaryIndexRemedy(), index.ts):

No index available on keyspace `travel`.`inventory`.`hotel` that matches your query.
Create one first: CREATE PRIMARY INDEX ON `travel`.`inventory`.`hotel`

In both cases the deleted flat relations read reads system:indexes, so an un-indexed collection shows an empty index list in the explorer before anything is run. Documents whose key is known are reachable without any index on every supported version (§5.3).

3.9 Monitoring degrades to empty, never throws

system:completed_requests, system:active_requests and the index-service statistics require the Query System Catalog RBAC role, so a denial is the normal case for a restricted user. Every monitoring source funnels through one helper, degradeTo() (index.ts):

async function degradeTo<T>(operation: () => Promise<T>, fallback: T): Promise<T> {
  try { return await operation(); } catch { return fallback; }
}

A source the connected user cannot read yields the fallback instead of breaking an otherwise working connection. the deleted flat relations read is the deliberate exception: an empty index list is the un-indexed signal of §3.8, so degrading a failed catalog read to empty would fabricate that signal for the whole bucket (introspect.ts).

3.10 INFER output is nested, and every flavour is unioned

INFER returns one row that is itself an array of flavours — one entry per document shape it found. Each flavour carries a properties map whose values have type, %docs and samples, plus a ~meta pseudo-property whose nested id describes the document key. Verified shape:

{ "#docs": 3, "Flavor": "", "properties": {
  "city": { "type": "string", "%docs": 100, "samples": ["Bursa", "Istanbul"] },
  "~meta": { "properties": { "id": { "type": "string", "samples": ["hotel::1"] } } } } }

columnsFromFlavours() (introspect.ts) unions all flavours — taking only the first would drop every field the other shapes carry. A field is nullable when %docs < 100, when null is among its observed types, or when it is missing from some flavour. Multiple observed types render as mixed(a|b), the same convention the MongoDB provider uses. ~meta becomes the leading __id column, marked primary.

3.11 EXPLAIN reuses the shared tree model

ExplainFormat gains "couchbase-json" (src/lib/db/types.ts), with the strategy in src/lib/explain/couchbase-json.ts:

  • buildSql() returns EXPLAIN ${sql} for SELECT statements, in both modes — it produces the plan without executing anything. SQL++ has no EXPLAIN ANALYZE; real timings come only from the request-level profile: "timings" parameter, which ExplainStrategy cannot set by design (it emits SQL only), so the estimate is the best available answer for either mode. This mirrors sqlite-queryplan.ts, which ignores the mode for the same reason.

    Returning null for analyze would not narrow the feature, it would disable it: the direct Explain action always builds with mode analyze (explainAccepted, use-query-execution.ts) and refuses the run when the strategy declines, so the button would be dead while only the background pre-warm worked. A non-SELECT is still declined in both modes.

  • toRenderModel() walks #operator into the existing { kind: "tree" } model.

Both child shapes are walked. Couchbase plans use ~children (an array, on Sequence and friends) and ~child (a single operator, observed on Parallel); a walker handling only ~children silently truncates the tree. The strategy collects every tilde-prefixed key of either shape. Cost and cardinality are read flat on the operator or nested under optimizer_estimates, and -1 is treated as "no estimate" rather than a metric. Couchbase 8.0.2 advertises clusterCapabilities.n1ql including costBasedOptimizer even on Community Edition, so cost and cardinality can appear on CE plans.


4. Connection

4.1 Configuration fields

Field Required Notes
host Yes (or connectionString) Cluster node hostname. validate() throws DatabaseConfigError when both are missing
port No Management port only. Defaults to 8091 (18091 when SSL is on). Query ports are discovered (§3.3)
user / password No Sent as HTTP Basic on every request
database Yes, unless supplied in the URI path Carries the bucket name and overrides the URI path. One bucket per connection; the ConnectionModal labels this field "Bucket Name"
connectionString No couchbase:// / couchbases://; see §4.2
ssl No See §4.3

database being the bucket is the one field that surprises people, so the form says so: the couchbase entry of CONNECTION_FORM declares the label "Bucket Name" for it (fieldLabels, connection-form.ts), which ConnectionModal draws, and validate() rejects a connection without one (index.ts):

Couchbase requires a bucket (use the URL path or the "database" field)

Multi-bucket browsing from a single connection is out of scope; create one connection per bucket.

const connection = {
  id: 'cb-1',
  name: 'Travel',
  type: 'couchbase',
  host: '127.0.0.1',
  port: 8091,            // management port; 8093 is found from the cluster
  user: 'Administrator',
  password: 'password123',
  database: 'travel',    // the BUCKET
  createdAt: new Date(),
};

4.2 Connection strings

supportsConnectionString is true, and the UI parser, parseConnectionString() (connection-string-parser.ts), hands the URL to Couchbase's connection string row (connection-string.ts), which decomposes it into discrete fields before the provider sees it:

Input host port database (bucket)
couchbase://localhost:8091/travel localhost 8091 travel
couchbase://user:pw@node1,node2/travel node1 (first host wins) 8091 travel
couchbases://cb.abc123.cloud.couchbase.com the host 18091 (none — not invented)

Only the management port is ever stored: 8091 for couchbase://, 18091 for couchbases://. The scheme also arrives as an SSL mode - require for couchbases://, disable for couchbase://

  • because the transport picks https vs http from config.ssl alone and never re-reads the pasted string (§4.3); without it a couchbases:// paste posted plain HTTP to 18091. require and not a verifying mode for the reason §4.3 gives: a self-hosted cluster's certificate is self-signed, so only the SSL panel turns verification on. A connection in Connection String mode has its hostname lifted out for the transport. When the separate Bucket (database) field is empty, the provider also reads and URL-decodes the first path segment as the bucket: couchbase://localhost/travel%2Dsample selects travel-sample. A % that does not start a valid escape is kept verbatim, as Paste URL keeps it, because a bucket name may contain one: couchbase://localhost/100% selects 100%. An explicit Bucket field wins. The resolved bucket is used for management requests and the SQL++ query context alike; a URL without a bucket path still requires the field to be filled in. The URL's port is deliberately not used, because a couchbase:// URL from an application config carries the KV port, not the management port, and discovery handles the rest (hostFromConnectionString(), index.ts). detectConnectionStringType() maps both schemes to couchbase.

A Capella endpoint carries neither a port nor a bucket path, so the bucket must be filled in by hand; nothing is invented for it.

4.3 TLS

config.ssl drives two things. When mode is anything but disable, requests switch from fetch to node:https and the scheme becomes https. Node's fetch cannot carry a custom CA or relax verification without an undici Agent passed as dispatcher, and undici is not a dependency of this project (and must not become one); node:https is a built-in that takes ca/cert/key/ rejectUnauthorized directly, so self-signed self-hosted clusters work on the Node runtime that ships in the Docker image.

rejectUnauthorized follows the same rule PostgreSQL and MySQL use: require is the only mode that does not verify the chain, because a self-hosted Couchbase node ships a self-signed certificate. verify-system (D26) verifies against the trust store the runtime already has — the mode a Capella endpoint can satisfy exactly as pasted, since its certificate is signed by a public root and there is no PEM to go looking for — while verify-ca/verify-full verify against a pasted caCert. An explicit ssl.rejectUnauthorized always wins (buildTlsMaterial(), http-transport.ts).

4.4 Endpoint validation and redirects

host and port are validated when the transport is constructed, which happens in connect(), so a bad value fails Test Connection and never a capability read. A host must be a hostname, an IPv4 address or an IPv6 address (bracketed or not), and a port must be an integer from 1 to 65535. Anything else is a DatabaseConfigError that names the field and does not repeat the value. Every request URL is built by the shared endpoint.ts with URL and URLSearchParams and checked against the intended hostname, port and path before it is sent, so no value can move a request to another path or another server. A scheme's default port (80 for http, 443 for https) is left out of the URL the way URL serializes it.

Redirects are not followed. Every request sets redirect: "manual", and a 3xx answer becomes a ConnectionError naming the status and only the origin of its Location, since a followed redirect would take the Basic credential and the statement to wherever the server pointed.

The host a request finally uses can come from somewhere other than the form: an SRV record for a host with no port, or the node addresses /pools/default/nodeServices reports (§3.3). Both go through the same builder, so an address the cluster reports that is not a valid host or port fails the query with a DatabaseConfigError before it is sent.

The TLS path uses node:https, which does not follow a redirect on its own; a test pins that. A 3xx there is reported as an HTTP failure, Couchbase request failed with HTTP 302, rather than as the ConnectionError above.

Connection string

The scheme shown for Couchbase is couchbase://, and the parser also accepts couchbases://. Its row in CONNECTION_STRING is declared in connection-string.ts. Its paste claims couchbase:// and couchbases://, and stores only the management port, 8091 or 18091, since the query ports are discovered from the cluster. It has no readTls: the scheme itself is the TLS, so couchbases:// sets require and couchbase:// sets disable. It runs no seed load check.

Execution UI

The rolled-back notice names COMMIT as the keyword that keeps an open transaction, and couchbase has no consent deadline, so the agent's consent card shows no deadline chip or sentence for it. Its row in EXECUTION_UI is declared in execution-ui.ts. What a tree click and Generate Query write is COUCHBASE_SQL_GENERATORS, its row of SQL_PORT_GENERATORS keyed by its default port, declared in generators.ts and read by the engine-agnostic dispatcher in src/lib/query-generators.ts.


5. Query interface

5.1 Execution

query(sql, params?) (index.ts) sends one SQL++ statement. Positional parameters map to $1-style placeholders:

await provider.query('SELECT h.name FROM `travel`.`inventory`.`hotel` AS h WHERE h.city = $1',
                     ['Istanbul']);

The request carries metrics: true, the effective statement timeout, and scan_consistency: request_plus (§3.7). prepareQuery() injects LIMIT/OFFSET through the shared limiter (supportsExternalQueryLimiting: true), so paging behaves as it does for every SQL provider — including where the clause is placed: at the end of the statement, before a trailing comment or ; (query-optimization.md).

The limiter is told which dialect it is reading (#292), and reads Couchbase under its own SQL++ grammar (§5.6): a subscript key holding a ] and a literal holding a backslash-escaped quote end where Couchbase ends them, so the bound is written after them, and an existing LIMIT, written before or after its OFFSET, is kept (which dialect the readers are reading).

5.2 Result shaping

Source field QueryResult field Notes
result rows rows JSON objects exactly as the cluster returned them, except that an integer past 2^53 arrives as its exact digits (see below)
signature fields null for a wildcard signature, in which case columns are the union of the keys the rows carry, first seen first; an empty key, from either source, is named (No column name) and the rows carrying it are keyed under that name (see below)
— rowCount rows.length, or the mutation count when a statement returned no rows
metrics executionTime executionTime The cluster's own time (excludes network latency); falls back to the measured wall clock when the cluster reported none
warnings warnings The notices the cluster attached to a statement it completed, each carrying its message and the cluster's own code when it reported one — an entry with no code arrives without one rather than with a substituted 0, which is itself a legal code. Absent when the cluster reported no warnings at all — never an empty array, so the result UI decides from the field's presence alone (issue #273)

An empty key is a column named (No column name). JSON carries an empty key, and the transport's JSON.parse keeps it, so a document {"": 1} or a signature {"": "number"} would otherwise answer a column "", which the grid cannot take. uniquelyKeyedRows names it through uniqueFieldNames, numbered past any key the rows already use, and keys every row carrying it under that name. Not measured on a live cluster; the rule is pinned by the provider's integration tests.

An integer past 2^53 is handed over as its digits. The query service sends a document's number as the unquoted literal it was stored as, and JSON.parse rounds one past 2^53 with no error: measured on 8.0.2 CE on 2026-10-04, a document {"big":9007199254740993} read through SELECT d.big ... WHERE META(d).id = "h1" was shown as 9007199254740992 in the grid, the API and every export. Every body the transport reads therefore goes through quoteUnsafeIntegers before it is parsed (parseJsonBody in http-transport.ts), so such a value reaches the grid as a string holding its exact digits, at any depth of the document, the way Druid's transport hands one over. An integer inside the safe range, and every float, stays a number, and the pass is string-aware, so digits inside a string value are never touched. The counts and sizes this provider reads from the REST API all sit far inside the safe range, so none of them changed type.

5.3 USE KEYS reads a document with no index at all

This is the second thing that surprises people, and it is worth knowing before creating an index just to look at one document. USE KEYS bypasses index lookup and reads straight from KV:

SELECT META(d).id AS __id, d.* FROM `travel`.`inventory`.`hotel` AS d USE KEYS ["hotel::1"];

That statement succeeds on a keyspace that has no index whatsoever — error 4000 never fires. What still needs an index is discovering keys you do not already know (§13).

5.4 EXPLAIN

The EXPLAIN button is available (supportsExplain: true) and renders the plan tree described in §3.11. Couchbase has no analyze mode, so both the direct action and the background pre-warm show the estimated plan.

5.5 endOpenQueryTransaction() is absent, and which absence it is (D75)

The optional provider surface that ends a transaction a statement left open on the session query() runs on (types.ts, implemented on postgres, sqlite, duckdb and redis) is not implemented here: the driver cannot be asked.

This engine DOES leave a transaction open, and that is why the absence is this one and not "the engine has no transaction to leave open". SQL++ transactions work over REST, so this was measured rather than assumed. On Couchbase Community Edition 8.0.2, 2026-09-15, against POST /query/service:

  • BEGIN WORK succeeds and answers a txid, with transactionRemainingTime: 15s in the metrics. A statement a user runs through query() therefore can and does open a transaction on the cluster. It is named by that id, not by a connection.
  • Nothing this provider sends can name it. The transport sends statement, timeout, metrics, scan_consistency and at most query_context, args, readonly and profile (http-transport.ts), and never a txid. Measured: SELECT 1 straight after BEGIN WORK answered success with no transactionRemainingTime at all, so it was not inside the transaction. ROLLBACK WORK with no txid is refused with code 17002, "ROLLBACK statement is not supported outside the transaction", so the provider cannot end one either, and the surface's contract is to end it and say so.
  • The cluster ends it itself when nobody does: reusing the txid after the window answered code 17010, "Transaction timeout". That bounds the damage to 15 seconds; it does not make the transaction absent.

It is not intrinsic, and the next reader should not stop looking. toQueryResult hands record.results straight back as rows, so the txid from BEGIN WORK is already parsed out of the reply this provider read. An implementation that remembered it and sent ROLLBACK WORK with that txid in the request body could answer the surface truthfully. That is unbuilt work, not a missing engine feature. Until it exists the absence is a declared boundary rather than a fallback: the caller shape-checks for the method and this provider does not answer it.


5.6 SQL grammar

How this engine's statement text is read is declared once, in src/lib/db/providers/document/couchbase/sql-grammar.ts, the couchbase row of the SQL grammar registry in src/lib/db/sql-grammar/. Its grammar is COUCHBASE_GRAMMAR. Every fact was read off the query service's own lexer (parser/n1ql/n1ql.nex in couchbase/query, the 8.0 branch) and the SQL++ language reference (Identifiers, Literals, the LIMIT and OFFSET clauses and the n1ql.ebnf and dql.ebnf grammar they include), and each was probed on 2026-10-28 on couchbase:community-8.0.2 over POST /query/service, the endpoint the provider sends on:

Fact Reading Probe
# code, never a comment SELECT 1 AS a # note is 3000 "syntax error ... at: #"
[…] an array literal or subscript: it nests and a literal inside it is a literal SELECT [1,[2,3]][1][0] AS a answers 2, SELECT {"a]b": 7}.["a]b"] AS a answers 7, SELECT 1 AS [a] is a syntax error
/* … */ flat: the first */ closes it, and a /*+ hint has the same extent; a run holding a *, one character and a / (/* a*b/c */) is no comment to the engine but a syntax error, so the flat reading is the fail-safe one SELECT /* a /* b */ 1 AS a answers 1, SELECT /* a /* b */ c */ 1 AS a is a syntax error, SELECT /*+ 'x */ 1 AS a answers 1
q'…' not a form SELECT q'[x]' AS a is a syntax error
// code, never a comment SELECT 4 // 2 AS a is a syntax error
\ in '…' and "…" always an escape, and a doubled ' still is one SELECT 'it\'s' AS a and SELECT 'it''s' AS a answer it's, SELECT "it\"s" AS a answers it"s
\ in a backtick name an escape, and a doubled backtick still is one SELECT "a" AS `x\`y` and SELECT "a" AS `xy `` both answer a field named ``xy``
a carriage return in a -- comment ends it, and a backslash continues nothing SELECT 1 AS a -- note<CR>, 2 AS b answers both columns, and so does SELECT 1 AS a -- note \<LF>, 2 AS b
script one statement per request, no procedural body SELECT 1 AS a; SELECT 2 AS b is a syntax error
clauses after the row bound none: a SELECT ends at its LIMIT and OFFSET SELECT 1 AS a OFFSET 1 LIMIT 500 succeeds, and LIMIT NULL and LIMIT MISSING are 5030 "Invalid LIMIT"

A "…" run is a string literal in SQL++, not a name, but the span reader's quoted-name reading of it ends in the same place under the escape rule above, which is all the readers ask. Three readings stay the shared reader's, each in the fail-safe direction. A $$ or $tag$ run is a dollar string to the span reader, while SQL++ has no dollar string and refuses a $ that no parameter name or number follows (SELECT $$x$$ AS a is a syntax error), so such text stays one statement, which the engine refuses. A backslash before a line feed leaves a literal unterminated to the span reader, and SQL++ refuses that escape too ("invalid escape sequence"). A LIMIT whose count is an expression or a parameter (LIMIT $n, LIMIT 2 * 3) is not read as a bound, so the limiter appends one and the engine refuses the second LIMIT ("syntax error ... at: LIMIT (reserved word)") rather than running the read unbounded. readsSqlText is true: the editor text is SQL++, which the SQL readers read under that grammar. Redaction is none: a redacted statement is stored as ?, because no SQL++ redaction reader exists, and a reader guessing where a literal ends can store the literal.

6. Schema introspection

Every reading of this engine's objects goes through the object surface (objects.ts), over the same catalogs the deleted flat reading used:

Data Source
Collections system:keyspaces LEFT JOIN system:scopes, filtered to the pinned bucket
Columns INFER <keyspace> WITH {"sample_size": 100} per collection, 4 at a time, 5 s server-side timeout each
Indexes system:indexes
Foreign keys always [] — Couchbase has none and none are invented

Three details are load-bearing:

  • The join is a LEFT JOIN. system:scopes does not list _default on Server 8.0.2, so an inner join silently drops every collection in the default scope.
  • The bucket-level catalog row is kept. A row with name = bucket and no bucket/scope fields is the pre-collections default collection; dropping it would hide every document written before scopes existed.
  • A failed INFER yields empty columns, never an error. The two common causes — the user lacks SELECT on the collection, and the collection is empty (error 7014, "No documents found, unable to infer schema") — are both states the explorer should render, not fail on. Coverage is not truncated to a fixed number of collections; the concurrency bound of 4 is what keeps the cost of schema loading in hand (mapWithConcurrency(), introspect.ts).

the object surface merges both halves. A primary index carries no index_key, so it is reported with the synthetic column META().id; unique is true only for primary indexes, because no secondary GSI enforces uniqueness.


6a. The object surface (#789)

the object surface above flattens the bucket into one list of collections. The object browser is the other surface: it walks the engine's own hierarchy, so a bucket holds scopes and a scope holds collections, functions and indexes. Both surfaces are live through Phase 1 and they read the same catalogs.

Everything in this section was measured against Couchbase Server 8.0.2 Community running docker/couchbase-init/01-object-fixture.sh, on 2026-09-11. The statements and the derivations live in objects.ts; the four methods are on the provider in index.ts.

6a.1 What is declared

Level id Label Source
0 catalog Bucket system:buckets
1 schema Scope system:all_scopes, _system excluded by exact name
Kind Role Path Source
collection relation [bucket, scope, collection] system:keyspaces
function routine [bucket, scope, function] system:functions, and the only kind here declaring hasSource (§6b)
index config, attachedTo: collection [bucket, scope, collection, index] system:indexes

A bucket alone is a real address as well as a bucket and a scope, and the declaration states it as containerPathShapes: "prefixes" (§9). A bucket-level container carries no scope, and that is absent rather than _default, because _default is a real scope holding real collections.

A collection declares acceptsRowWrites: true. That is the per-kind fact and it is deliberately separate from the engine-wide supportsInlineRowEdit: false this provider also declares: the results grid's UPDATE ... SET cannot address a document through the __id projection (§9), while an import into a collection is an ordinary UPSERT.

isSessionDefault is marked at both levels. The bucket is the one the connection pinned; the scope is _default, in that bucket only, because an unqualified SQL++ keyspace resolves into _default and §3.4 already treats it as the implicit scope for the flat explorer. Marking only the bucket would leave the tree's first paint opening a bucket and stopping, with no counts read at all.

The connect-time inventory read (autocomplete, the ERD) is scoped the same way (#1402): it lists the pinned bucket's scopes without listing every other bucket's, while the tree still lists every bucket lazily, one container at a time.

6a.2 Which catalog, and why not the obvious one

Read Catalog Why not the alternative
Scopes system:all_scopes system:scopes lists neither _default nor _system. Measured on a bucket holding four scopes: SELECT s.name FROM system:scopes answers 2 rows while SELECT COUNT(*) FROM system:scopes answers 4. _default is where most of a bucket's collections live, so reading system:scopes would hide them.
Collections system:keyspaces system:all_keyspaces answers every system:keyspaces row for the bucket plus three: the _system scope's _mobile and _query, and a scoped _default._default row alongside the pre-scopes bucket-level row that already is that collection. One collection would get two paths. The excess is three whatever the bucket holds; the totals move with the fixture (§6a.8), so they are not quoted here.
Indexes system:indexes system:all_indexes carries a #sequentialscan pseudo-index for every keyspace it can see, in every namespace, plus the query service's own #system namespace indexes. What it adds is not a constant: it grows with the keyspace count, while system:indexes answers only the indexes that were created.
Functions system:functions There is no system:all_functions ("Keyspace not found all_functions").

Nothing here counts with COUNT(*), and that is measured rather than stylistic. A system: keyspace can count rows its own projection never returns — the system:scopes measurement above is the reproducible case — so a badge taken that way would be a number the folder can never show. countObjects() is the length of what listObjects() returns, kind by kind. That costs one round trip per declared kind on a count, three in total, and it is what discharges the "the listing contains exactly what the count counted" rule structurally: there is no second statement for the two answers to drift apart in.

6a.3 The two row shapes, and the bucket-level one

system:keyspaces and system:indexes each answer two shapes:

  • a scoped row, carrying bucket/bucket_id and scope/scope_id;
  • the pre-scopes bucket-level row, carrying neither, whose name is the bucket's name.

The second is _default._default and its indexes. Dropping it would hide every document written before scopes existed, and naming the collection from the row's own name would address it as a collection called travel, which is a keyspace path no statement can reach. One placement function (resolveKeyspaceOf()) serves both catalogs, because their projections are aliased onto the same field names.

That function is total: it always returns a placement. A scoped row that names no scope, and an index row that names no keyspace, are both placed in _default, which is where an unqualified SQL++ keyspace resolves. Neither shape was observed on 8.0.2, and neither is claimed to be unreachable: dropping a row instead would take it out of the count and the listing together, which is an object that is invisible in the tree while the count still agrees with the listing, so no gate would see it. The only row dropped is one carrying no name, which addresses nothing and cannot be a tree row.

6a.4 Identity

  • A collection is addressed by its name within its scope.
  • A function is addressed by its bare name, with no argument list. SQL++ has no overloading: measured, with discount(price, pct) in place, CREATE FUNCTION discount(price) is refused with "Function 'discount' already exists" — refused on the name, arity and all. The same name in another scope succeeds. PostgreSQL needs an argument-type list here; Couchbase does not.
  • An index carries its collection in the path. Measured: ix_name is created on both inventory.airline and inventory.hotel and both succeed, while a second ix_name on one of them is refused with "The index ix_name already exists." So the name alone is not unique within a scope, and the kind declares attachedTo: "collection".
  • A collection name is unique per scope, not per bucket: the fixture holds airline in both _default and inventory, each with its own ix_name over a different key. A read that filtered a collection's indexes on the collection name alone would hand one of them the other's.

6a.5 A GLOBAL function is excluded

system:functions answers namespace-level functions as well as scope-level ones. A global function belongs to default:, above every bucket, so it has no container in a bucket/scope tree; listing it under a bucket would claim it lives somewhere it does not. The fixture creates celsius so the exclusion is pinned by a test that names it.

The exclusion is structural: a row is placed by whether its identity carries a bucket and a scope, never by matching identity.type (measured values: "global", "scope"). A future third identity type is therefore placed by where it says it lives rather than dropped for being an unrecognised spelling. There is no other classifier vocabulary in this provider at all: a row's KIND is decided by which catalog it came from, so there is no CASE an unmodelled value can fall out of.

definition["#language"] is likewise never read. Its measured values are inline and, on Enterprise Edition, javascript; Community Edition refuses the latter outright ("Functions of type javascript are only supported in Enterprise Edition"). Since one kind covers both flavours, the language is a detail for a Phase 2 Source tab rather than a classifier.

6a.6 What is NOT declared, and what was measured to decide it

  • No view kind. SQL++ has no CREATE VIEW statement at all: CREATE VIEW v AS SELECT 1 is error 3000, "syntax error - line 1, column 8, near 'CREATE ', at: VIEW (reserved word)". The legacy map-reduce Views, deprecated since 7.0 and still not removed, do exist — the fixture creates _design/dev_legacy holding a by_city view over the CAPI port — and they are invisible to the query service: a LIKE over ENCODE_JSON of system:all_keyspaces, system:all_indexes, system:functions, system:buckets and system:all_scopes finds zero rows mentioning it, and it is not a document in _default._default either. They are reachable only over ports 8091 and 8092, which this provider's transport does not speak for objects. Phase 2 could add them through the management REST surface; Phase 1 does not guess.
  • No Eventing Function kind. Eventing is a separate service with its own REST API on port 8096. Measured on the fixture node, which runs data,index,query: nothing listens on 8096 at all, and Eventing is an Enterprise Edition service in any case. It is not reachable through the query service this provider speaks.
  • No kind for the _system scope's contents. _system is the server's own scope (_mobile, _query) and is excluded by exact name. A prefix rule would be wrong twice: it would sweep up _default, and the engine already refuses any user scope starting with _ or % ("First character must not be _ or %", measured on _systemx), so nothing a person creates can hide behind the exclusion.

6a.7 describeObject

The kind decides, and nothing reads the name to work out what it is holding.

Kind Columns Indexes Foreign keys
collection INFER, 100-document sample, the same bound the deleted flat reading used that collection's system:indexes rows, scope and collection both matched always []
function [] [] []
index [] [] []

A rejected INFER yields no columns rather than an error: the collection being empty (error 7014) and the user lacking SELECT on it are both ordinary states. The fixture leaves hotel and bookings empty so that stays measured. foreignKeys is always [] for the same reason declaresForeignKeys: false is declared: SQL++ has no referential constraint.

collection is the only kind declaring hasColumns, so it is the only object row the desktop object tree gives a twisty to; function and index declare nothing, are leaves there, and the tree derives no read for them, which is the same fact the table above states by answering []. An empty collection and an INFER the caller has no SELECT grant for both reach the tree as an open row reporting No columns reported, because neither is an error on this engine.

A function's BODY is read by readObjectSource instead (§6b), not by describeObject. A function's parameter list and an index's keys as a first-class detail remain unmodelled.

6a.7b describeObjects, the bulk column read

describeObjects(container, kind, limit?) answers columns and indexes for every object of one kind in one container. The two halves of an ObjectDetail do not have the same answer here, and both answers are measurements on Couchbase Server 8.0.2 Community, on a node this task created and removed.

The indexes bulk-read, and already did. INDEXES_SQL answers one bucket's whole index catalog in one statement and describeObject filters it down to one collection, so the batch reads it once for the folder where a loop over describeObject reads the same statement once per object.

The columns cannot. Couchbase stores no schema: a collection has whatever fields its documents carry, and INFER is the engine's own sampler. Three measurements close every combined form:

  1. INFER a, b is error 3000, a syntax error at the comma. INFER takes one keyspace.
  2. INFER against a scope is refused: Keyspace resolves to default:travel.inventory - only 2 or 4 parts are valid. There is no folder-level form.
  3. INFER is subquery-able — both SELECT * FROM (INFER ...) AS x and WITH x AS (INFER ...) SELECT x parse — so a UNION ALL over several of them is a real statement. It is still wrong here: measured, such a statement fails entirely with error 7014, No documents found, unable to infer schema, as soon as one of its keyspaces is empty. An empty collection is an ordinary state, and the fixture keeps hotel and bookings empty on purpose, so a combined statement would cost a whole folder its columns because one collection held no documents.

So the INFERs are one per described object, at most INFER_CONCURRENCY (4) in flight, and cut by the caller's limit before any of them is issued. A folder of N collections costs two catalog statements plus N INFERs, against 2N statements for the same objects one at a time.

Shape Statements Time
One describeObjects over a 40-collection scope 42 293 ms
The 40 describeObject calls it replaces 80 462 ms

The gain is real but modest, and that is the honest shape of it: the INFERs dominate and they do not go away, so what the batch removes is 39 whole-bucket index reads. The bench scope is re-runnable — 40 collections in a bench scope, two documents each — and is not part of the committed fixture.

One mapper, shared with the single read. relationDetail() in objects.ts builds both, so a batch cannot spell an index differently from describeObject on the same collection. Verified live: for every collection of travel, travel._default and travel.inventory, the batch's detail is identical to describeObject() for the same path.

function and index answer { details: [] } with no round trip, which is the same fact the single read states by answering three empty arrays: a function's parameters and an index's keys are not columns.

The bound is the caller's, and there is no limit + 1. The catalog statements answer a whole bucket in one round trip each, so the target set is complete before anything is cut and the comparison is exact. The cut is applied in code, after comparePaths, which makes a bounded read's membership ours rather than the server's — the server never orders this read. INFER's own sample_size is deliberately not reported as truncation: it bounds the documents a column list is inferred from, exactly as in the single read and in the object surface, and no object is dropped by it, so reporting it would claim the batch left objects out when it left none out.

Collation, for the record. SQL++ ORDER BY over ["\ue000", "😀"] answers U+E000 first, which is UTF-8 byte order, while comparePaths compares UTF-16 code units and puts 😀 first (0xD83D is below 0xE000). Task 26a-2 measured the same divergence on five SQL engines. It cannot bite here, because no statement in the object surface carries an ORDER BY at all: every listing, and the cut a bounded read applies, is sorted in TypeScript.

6a.8 The fixture

docker/couchbase-init/01-object-fixture.sh is part of the deliverable, not scaffolding: every claim above is re-measurable against it. Couchbase has no /docker-entrypoint-initdb.d — a fresh node has no cluster, no bucket and no index — so it is applied by the couchbase-init sidecar in database-compose.yml, which mounts this directory at /fixture and runs the script after cluster-init, bucket-create and the bucket-level CREATE PRIMARY INDEX. The script is re-runnable, so an edit is re-applied with:

docker compose -f database-compose.yml up -d couchbase couchbase-init
docker logs libredb-couchbase-init

To apply it to a node you brought up by hand, mount the directory and run the script inside the container, which is how the measurements above were taken:

docker run -d --name cb -p 8091:8091 -p 8092:8092 -p 8093:8093 \
  --ulimit nofile=200000:200000 \
  -v "$PWD/docker/couchbase-init:/fixture:ro" couchbase:community-8.0.2
docker exec cb couchbase-cli cluster-init -c localhost \
  --cluster-username Administrator --cluster-password password123 \
  --cluster-name libredb --services data,index,query \
  --cluster-ramsize 1024 --cluster-index-ramsize 512 --index-storage-setting default
docker exec cb couchbase-cli bucket-create -c localhost -u Administrator -p password123 \
  --bucket travel --bucket-type couchbase --storage-backend couchstore \
  --bucket-ramsize 256 --bucket-replica 0 --wait
docker exec -e COUCHBASE_HOST=localhost cb bash /fixture/01-object-fixture.sh

Port 8092 is the CAPI port and is needed only for the map-reduce view; the provider itself uses 8091 and 8093. What the fixture holds, and the counts it produces:

What it holds, so the counts below can be derived rather than remembered:

  • Collections: inventory.airline, inventory.hotel, _default.airline, _default.bookings, plus _default._default, which every bucket has and which the query service reports as the pre-scopes bucket-level row (§6a.3).
  • Functions: discount in inventory and discount in _default. The global celsius is deliberately outside the tree (§6a.5) and is not counted. The two bodies differ on purpose, price - (price * pct / 100) against x / 2, so a source read matching on the name alone answers the wrong one rather than the same one by luck, and neither is a single character, because the object-surface conformance helper refuses a definition under two characters as one a bound cannot be told from no bound.
  • Indexes: ix_name on each of inventory.airline, inventory.hotel and _default.airline, the primary index on inventory.airline, and the bucket-level primary index, which the couchbase-init sidecar creates before running the script.
Container collection function index
[travel] 5 2 5
[travel, _default] 3 1 2
[travel, inventory] 2 1 3

The [travel] row is the length of each list above; the two scope rows are the same objects split by scope, with _default._default and the bucket-level index falling into _default. Those are the numbers tests/integration/db/couchbase-provider.test.ts asserts and the numbers a live provider answered against the fixture on 2026-09-11. They move whenever the script gains an object, so anything stated as a total elsewhere in this document is stated against this list.


6b. Object source (#789)

readObjectSource(path, kind, limit?) answers one object's definition text. Everything below was measured against Couchbase Server 8.0.2 Community running docker/couchbase-init/01-object-fixture.sh, on 2026-09-13, including one end-to-end run of the provider itself against that node.

6b.1 Per kind

Kind hasSource Statement What the text IS Monaco id
function Yes SELECT f.identity AS identity, f.definition AS definition FROM system:functions AS f definition.text, the body as authored: form: "partial", origin: "stored" sql
collection No none the engine publishes no such text at all not applicable
index No none the engine publishes no such text at all not applicable

form: "partial", and it is a fact rather than a hedge. definition.text is a BODY. The parameter list is a separate field (definition.parameters) and there is no CREATE FUNCTION header anywhere in the row, so what the pane shows is less than the statement that created the object. Composing the three into one statement would show a reader something this product wrote rather than something the engine published.

origin: "stored", and the measurement that decides it. The row carries the author's bytes AND the engine's normalisation of them, side by side. The fixture's CREATE OR REPLACE FUNCTION travel.inventory.discount(price, pct) { price - (price * pct / 100) } answers:

{"#language":"inline",
 "expression":"(`price` - ((`price` * `pct`) / 100))",
 "parameters":["price","pct"],
 "text":"price - (price * pct / 100)"}

text is what was typed; expression is a reconstruction. The read takes text, so the origin is stored. Taking expression would have made it regenerated and would have shown a reader backticks and parentheses they never wrote.

6b.2 Why collection and index declare nothing

  • collection is schemaless. A Couchbase collection has whatever fields its documents carry. There is no definition anybody authored, which is the same fact describeObject states by INFERring columns from a document sample rather than reading a schema.
  • index publishes no statement. system:indexes carries an index's name, its index_key, its is_primary, its using and its state, and no field holding the CREATE INDEX text. A Source tab there could therefore only show a statement composed out of the keys, which is a statement this product invented rather than one the engine published. The index KEYS are a fact the catalog does publish, and they are in describeObject (§6a.7), which is where they belong.

6b.3 The statement, the escaper, and what reaches the wire

FUNCTIONS_SQL gained f.definition beside the f.identity it always read. It is the SAME statement the listing sends, so readObjectSource, listObjects and countObjects cannot come to look at different sets of functions.

No identifier and no bind reaches this statement. The function's identity is matched IN CODE against the rows the read returns, exactly as kindObjects places them (§6a.5 is the same rule, applied to the global function). So there is no escaper in this engine's source read to get wrong. That is the whole answer to the identifier-quoting question every other provider in #789 has to settle: the catalog is namespace-wide and small, one row per user-defined function on the cluster, so reading all of it and matching in code costs nothing and keeps one visible placement rule.

6b.4 The refusals, and the one that CANNOT be verified on this image

Cause What happens Whose sentence Verified live
The row carries no readable definition.text a refusal part carrying unavailable, no text ours, see below No, and it cannot be. See below
No row matches the path RAISES QueryError naming the object, No Couchbase function named <name> in <bucket>.<scope> ours Yes
The statement itself is refused RAISES, through the ordinary error map the cluster's Yes, in the suite

The unverifiable branch, stated in advance rather than discovered. An EXTERNAL JavaScript function's body does not live in system:functions at all: Couchbase keeps it in a library on the evaluator endpoint (<query-node>:8093/evaluator/v1/libraries/<library>), which the query service does not expose and which this provider's transport does not speak. Its catalog row therefore carries no definition.text, and the read answers the refusal part above. That branch cannot be driven against the image this repository runs. Community Edition refuses to create such a function at all:

CREATE OR REPLACE FUNCTION `travel`.`inventory`.`extfn`(a) LANGUAGE JAVASCRIPT AS "add" AT "mylib"
-> error 3000: Functions of type javascript are only supported in Enterprise Edition

measured verbatim on Server 8.0.2 Community on 2026-09-13, with /pools reporting "isEnterprise": false. So the branch is written, and it is driven by tests/integration/db/couchbase-provider.test.ts against an authored row rather than by a live cluster. A whitespace-only body takes the same branch and is equally unproducible here: CREATE FUNCTION f() { } is error 3000, a syntax error at the closing brace.

The refusal sentence is ours and not the cluster's, declared rather than smuggled: the read SUCCEEDS and the row it answers carries no body, so Couchbase said nothing there is anything to carry. It names the object and says where the body actually lives.

A refused STATEMENT raises rather than becoming a refusal part, and that is a decision. The read is namespace-wide, so its failure says nothing about this object in particular, and countObjects already carries the cluster's own sentence for the same read as { unavailable } per kind (§6a), which is where a whole-surface refusal belongs.

6b.5 system:functions FILTERS BY PERMISSION, so a privilege problem arrives as absence

Measured on 8.0.2 Community on 2026-09-13, with Community Edition's three roles (admin, ro_admin, bucket_full_access):

Caller SELECT f.identity, f.definition FROM system:functions
admin all three rows, including the global celsius
bucket_full_access[travel] the two travel rows, and NOT celsius
ro_admin zero rows, "status": "success"

None of the three is an error. So a caller who may not see a function meets absence, indistinguishable from a function that was never created, and the read raises for both. That is the honest answer to both and it is why Couchbase is not on the phase's live-refusal list: this engine has no privilege-driven refusal for object source to carry.

6b.6 The derivations

The bucket and the scope come from the segments the DECLARATION assigns to the catalog and schema levels; the function's name is path[path.length - 1]. Never path[0], path[1] or path[2]. Couchbase's declaration is already two-level, so a test swapping in a two-level containerLevels would match the real one and pass for a hardcoded implementation. The suite therefore drives two varied declarations instead: one that SWAPS the two levels over, fed a path in the swapped order, where the same row must still be found; and one giving function an attachedTo so it is addressed at four segments, where path[2] names the base object while the last segment names the function.


6c. Object edit (#789)

This engine is a REFUSAL because it was NOT PROBED, and this section says so rather than implying a measurement. Neither wave of #789 ran a replace against a UDF on this image, so nothing is known here about what a failed replace, or a byte-identical one, does to the stored function. This phase declares a kind editable only where a failure cannot lose the object and a success destroys nothing the user was not shown, and an unprobed engine cannot be shown to satisfy either half, so the declaration is withheld rather than guessed. No kind here declares acceptsSourceEdits, and tests/isolated/object-edit-declarations.test.ts is what holds that absence and this section together.

7. Monitoring & health

Every method below degrades to empty on a permission error (§3.9) — empty, not zero: a denied read measures nothing, and a metric nobody measured is omitted rather than reported as 0 (§7.1).

Method Source Notes
getOverview() /pools/default, /pools/default/buckets/<bucket>, system:keyspaces, system:indexes version and uptime from the first node; activeConnections from the curr_connections series
getPerformanceMetrics() bucket stats cache hit ratio is 100 - ep_cache_miss_rate (clamped 0..100); queriesPerSecond is cmd_get + cmd_set; buffer-pool usage is quotaPercentUsed — each is omitted when its source published nothing (§7.1)
getSlowQueries() system:completed_requests ordered by elapsedTime one row per recorded request, so calls is always 1 — these are individual requests, not aggregates
getActiveSessions() system:active_requests request id, statement, user, remote address, state, elapsed
getTableStats() /pools/default/buckets/<bucket> bucket level only — per-collection item counts need a COUNT(*) per collection, too expensive for a monitoring poll. The row's tableSize and totalSize are both basicStats.diskUsed, the on-disk measure getOverview() publishes and the Storage tab divides by (§7.2)
getIndexStats() system:indexes + /pools/default/buckets/@index-<bucket>/stats index name, scope, collection, keys, type. Modern servers no longer publish per-index statistics there, so an unpublished size shows as indexSize: "N/A" with indexSizeBytes omitted (a 0 B read as an empty index, and the Storage tab summed it); scans still falls back to 0, because IndexStats.scans is a required field
getStorageStats() /pools/default/buckets/<bucket> Data (basicStats.diskUsed) and RAM Quota (quota.ram with quotaPercentUsed)
getHealth() the four above, in parallel connections, size, cache hit ratio (the string N/A when there is none — formatCacheHitRatio from src/lib/monitoring-cache-ratio.ts), top 5 slow queries, top 10 sessions

maxConnections in the overview is the documented KV default (65536): Couchbase advertises no connection ceiling over REST, so the denominator is a constant while the numerator stays measured.

7.1 An unread metric is absent, not zero

PerformanceMetrics.cacheHitRatio, queriesPerSecond and bufferPoolUsage are all optional, and this provider now omits each one whose source published nothing:

Field Reported when Omitted when
cacheHitRatio the ep_cache_miss_rate series has a numeric last sample the series is absent — the stats endpoint was denied, or the bucket is not a Couchbase bucket and publishes no ep_* series at all
queriesPerSecond at least one of cmd_get / cmd_set was published (the other counts as 0) neither was published
bufferPoolUsage basicStats.quotaPercentUsed is a number basicStats is missing, i.e. the bucket endpoint was unreadable

A measured 0 is kept in every case: a bucket with no misses really is at a 100% hit ratio, an idle bucket really is doing 0 operations, and an empty bucket really is using none of its quota.

The cache one is why this matters. DEFAULT_THRESHOLDS rates the ratio direction: "below" with critical: 80, so the previous missRate === null ? 0 — a stand-in chosen to avoid "a flattering 100" — made every bucket whose statistics the connected user may not read show a red critical cache fault the cluster never reported. Reading the KV stats needs a role many application users lack (§3.9), so that was the ordinary case, not an edge one. Omitted, the same panels render N/A / "Not measured" and score the card as healthy (OverviewTab.tsx, PerformanceTab.tsx).

7.2 One size measure per bucket

basicStats carries two sizes of the same bucket, dataUsed and diskUsed, and this provider used to publish one of them per monitoring tab: getTableStats() put dataUsed in the row's tableSizeBytes, the row's totalSizeBytes was diskUsed, and getOverview() published diskUsed as the database size the Storage tab divides every share by. One bucket therefore read two sizes at once: 1.57 MB on the Tables tab against 16.86 MB, "100% of DB", on the Storage tab, measured on Couchbase 8.0.2 CE (#1455).

Both fields of the row now carry diskUsed, the measure getOverview()'s databaseSizeBytes and getStorageStats()'s Data row already publish. The bucket reads one size across the two tabs, and the Storage tab's share divides the bucket's own bytes by themselves. The Tables tab's Size column is therefore the bucket's on-disk size; dataUsed is deliberately not published as a table size, because nothing in the monitoring surface reports that measure as a total, and a share whose numerator and denominator measure different things is not a share.

The Indexes card reads N/A beside a bucket whose disk usage the cluster does publish, and the field it is missing belongs to the TableStats row rather than to the index listing: getTableStats() returns the one bucket row and that row carries no indexSizeBytes, while StorageTab.tsx shows the card only when every table row carries one (tables.every((t) => t.indexSizeBytes !== undefined)). getIndexStats() omits IndexStats.indexSizeBytes for its own reason (§7.1), and that listing is not the one the card reads. A bucket is one keyspace with no separate index size to report, so the absent figure is real rather than a 0.


8. Maintenance

runMaintenance(type, target?, container?) (index.ts). All three operations require a target.

A container is the row's schemaName (#772), and the keyspace it addresses is decided from it: the bucket's own name (the only statistics row getTableStats() returns) means the bucket's default collection, so that row's Analyze button addresses `bucket`.`_default`.`_default` rather than a scope that does not exist; any other container is the SCOPE the collection sits in, used as one instead of being parsed back out of the display name. Without a container the display-name rule stands: scope.collection, or the default scope for a bare name.

The collection menu does not click that bucket row. It opens the admin Operations page on the collection's own path (?path=<bucket>&path=<scope>&path=<collection>). The page's list is still getTableStats(), which does not name the collection, so where the provider declares the operation per object the page offers a row for the path itself (#1412). That row's schema is the scope segment and its button sends (collection, scope), the "any other container" arm above. The row is not a measurement: item counts stay on the bucket row.

Type Couchbase action Notes
analyze UPDATE STATISTICS FOR <keyspace> INDEX ALL Enterprise Edition only. A Community cluster answers "'Update Statistics' is an enterprise level feature.", returned verbatim as a failed result, not swallowed or reworded. The success reply names the same keyspace the statement addressed (Updated statistics for `travel`.`inventory`.`hotel` ), so a row whose target is the bucket cannot report as if the bucket itself had been touched (#1091 review)
reindex BUILD INDEX ON <keyspace>(...) over the keyspace's deferred indexes Reports "No deferred indexes on X" when there are none
kill DELETE FROM system:active_requests WHERE requestId = $1 Target is the request id shown in active sessions

vacuum, optimize and check have no Couchbase equivalent, so they are absent from maintenanceOperations and neither tab that offers maintenance renders them — the monitoring Tables tab since #272, the admin Operations tab since #282. Calling runMaintenance with one directly throws a QueryError naming the three supported operations.

Where each operation may be offered (maintenanceOperationSpecs)

Declaring that an operation EXISTS is not enough to put a button on it: two engines that declare the same MaintenanceType take different kinds of target, so each provider also declares what its own operations may be pointed at. The monitoring Tables tab renders a per-row control only where perEntity is true, the admin Operations tab a whole-database card only where global is true, and both take the wording from label (#496).

POST /api/db/maintenance reads the same declaration since #U20, and it is the one reader that REFUSES rather than hides: it takes the placement from whether the request carries a target (absent or empty means whole-database) and answers 400 when this provider marks that placement unavailable while the other one is available - a targetless {type:"analyze"} or {type:"reindex"} is that request here, the API-side half of the two withheld global cards.

Operation Control label Per-row Global Why
analyze Update Statistics yes no UPDATE STATISTICS FOR <keyspace> names one collection, and dispatchMaintenance requires the target
reindex Build Deferred Indexes yes no BUILD INDEX ON <keyspace> names one collection; there is no whole-bucket form
kill Cancel Request no no the target is a request id from the Sessions panel

Both global cards are withheld rather than synthesised from a keyspace list this provider does not enumerate for maintenance. Before #U9 the global Reindex card rendered for every provider that declared reindex, so on Couchbase every click answered "The reindex operation requires a target"; the per-collection control carries the keyspace and runs, on the row the Operations page offers for that path. vacuumAction ("Compact") names nothing this provider can run - its own description says the server compacts automatically - so vacuum stays undeclared, vacuumActionOperation stays absent, and that card never renders either.


9. Capabilities & labels

getCapabilities() (index.ts)

Capability Value
queryLanguage sql
supportsExplain true
explainFormat couchbase-json
supportsExternalQueryLimiting true
createTable absent
supportsInlineRowEdit false — SQL++ has UPDATE <keyspace> SET ... WHERE ..., but the shared editor's WHERE <pk> = <value> would filter on __id, the key projection alias, which is not a document field (§13)
supportsTestDataGeneration false - a collection takes a document write, but the Generate Test Data dialog's INSERT INTO ... VALUES was never measured against SQL++, so the row menus do not offer it (#1468)
supportsResultPagination true — SQL++ takes LIMIT n OFFSET m, and this provider's prepareQuery routes through the shared limiter to emit it (#816)
supportsTransactions false — the query service is reached over stateless HTTP and no session spans two requests, so the transaction trio and SANDBOX are not offered (#464)
declaresForeignKeys false — SQL++ has no referential constraint; collections are schemaless and the columns reported here are inferred from a document sample
supportsMaintenance true
maintenanceOperations ['analyze', 'reindex', 'kill']
supportsConnectionString true
defaultPort 8091
containerLevels two levels, catalog labelled Bucket then schema labelled Scope (§6a.1)
containerPathShapes prefixes: [bucket] and [bucket, scope] both address a container, because a bucket alone is a real address (§6a.1); the empty path and a longer path are both refused, by the object routes over HTTP and by this provider for a caller that reaches it directly (#1147)
objectKinds collection, function, index (§6a)
schemaRefreshPattern \b(CREATE|DROP|ALTER)\s+(COLLECTION|SCOPE|INDEX)\b

Declaring no createTable is deliberate: CreateTableModal builds CREATE TABLE from a column list, while Couchbase collections are schemaless and CREATE COLLECTION takes no columns. Declaring one would render a control that can only emit invalid SQL++.

getLabels() (index.ts)

Document vocabulary: entity -> Collection, row -> document, select -> Select Documents, analyze -> Update Statistics (the card text names the Enterprise-only restriction), vacuum -> Compact (the card says Couchbase compacts automatically and there is nothing to run), search placeholder -> Search collections or fields....

One label is about the monitoring tab instead: slowQueriesEmptyState -> "Query stats come from system:completed_requests, which keeps only requests over the query service's threshold." The Queries panel's empty state was hardcoded to PostgreSQL's pg_stat_statements advice on every engine (#463), and the completed-requests catalog (§7) is what this cluster actually keeps.

The global Reindex card gets its own triad (#464). It was hardcoded to PostgreSQL's "Run Reindex" / "Rebuild Indexes" / "Reconstructs all indexes in the database.", and this provider's reindex is none of those things: it is BUILD INDEX over the deferred GSI indexes of one keyspace (§8).

Field Value
reindexGlobalLabel Build Indexes
reindexGlobalTitle Build Deferred GSI Indexes
reindexGlobalDesc Runs BUILD INDEX for the deferred global secondary indexes of one collection; it needs a collection, so run it from the collection rather than here.

The last clause was a fact about a control that no longer renders. dispatchMaintenance() sends reindex through requireTarget() while the Operations tab's global card sends no target, so the card used to answer "The reindex operation requires a target" here. #496 settled that by withdrawing the card rather than by rewording it — maintenanceOperationSpecs.reindex declares global: false, and maintenanceControl(..., "global") therefore offers nothing. The per-collection control is the only one on this provider, which is what the operation itself requires. The three reindexGlobal* labels above are consequently read by nobody today; they are the wording the card would carry if it ever returns.

SQL writing

couchbase/sql-writing.ts declares how a generated statement spells a literal, a name and a bind for this engine, and it is this engine's row of SQL_WRITING (src/lib/db/sql-writing/index.ts), which the readers in src/lib/sql/values.ts and src/lib/sql/identifier.ts look up. A literal is written by literalBackslash and read back by unquoteBackslash: every escape, the quote's included, is spelled with a backslash, and doubling is not part of the grammar. A name is quoted by identifierDoubleQuoted, the SQL standard form: a double-quoted name with an inner double quote doubled. A bind is spelled by placeholderDollar, as $1, $2. SQL++ spells its literals the way JSON does: char ::= unicode-character | '\' ( '\' | '"' | "'" | 'b' | 'f' | 'n' | 'r' | 't' | 'u' hex hex hex hex ). Doubling is not in that grammar, so a doubled quote is not one literal there. The name gets the SQL standard form, which this engine's generators fall back to. SQL++ takes its values in args, which the statement reads as $1, $2. Inline row editing reads a key column's kind through the shared INSTANT_TYPE_NAMES, FLOAT64_TYPE_NAMES and EXACT_DECIMAL_TYPE_NAMES of the module's inlineEdit, and binds every edit as typed.

The module's export field writes a result export's CREATE TABLE and INSERT from the shared building blocks alone: STANDARD_DDL_TYPES for the column types, NOTHING_STANDS_ALONE and NO_BARE_SPELLING, so every bare declared name is re-spelled from its family, NULL_NON_FINITE for NaN and the infinities, and binaryTextBackslash for a binary cell, the \x… text every other surface shows, quoted with literalBackslash. SQL++ parses no CREATE TABLE (a collection is schemaless and CREATE COLLECTION takes no columns, which is why the provider declares no createTable), so a file written from it is meant to run somewhere else, and every bare name in it is re-spelled from its family, portably.


10. Error handling

The transport normalizes every failure into CouchbaseError { code, message, retriable }; the provider maps that one numeric space onto the shared classes from src/lib/db/errors.ts (mapCouchbaseError(), index.ts):

Code Meaning Error raised
4000 No index available on keyspace QueryError + the runnable CREATE PRIMARY INDEX remedy (§3.8)
1080 Request timeout TimeoutError
13014 Missing or invalid credentials AuthenticationError
401 / 403 HTTP unauthorized / forbidden AuthenticationError
503 Query service unavailable (node warming up) ConnectionError
0 with retriable Network fault (DNS, refused, reset) ConnectionError
anything else (e.g. 3000 syntax) Statement the cluster rejected QueryError carrying the cluster's message
Situation Error
Missing host and connectionString DatabaseConfigError
Missing database (bucket) DatabaseConfigError
Operation before connect() DatabaseConfigError (via ensureConnected())
connect() fails on credentials AuthenticationError
connect() fails otherwise ConnectionError (carries host/port)

retriable marks failures worth repeating (request timeout 1080, bulk KV fetch 12008, CAS mismatch 12009, network faults) as opposed to ones that need a user fix.


11. Testing

11.1 How the tests work

There is no mock.module() anywhere in the Couchbase suite, which is why these files carry no process-wide contamination risk:

11.2 Coverage

Validation, connect/disconnect, capabilities, labels, prepareQuery, query execution and result shaping, the full error map, endpoint discovery (including alternateAddresses and the fallback port), SRV resolution and its fallback, TLS material, request_plus and the not_bounded override, collection listing, INFER flavour union, index mapping, every monitoring method and its degraded path, all three maintenance operations, the explain strategy, the whole object surface, and the source read with both of its refusal branches and both of its derivation pins (§6b).

11.3 Run it

# Just this provider
bun tests/run-tests.ts tests/integration/db/couchbase-provider.test.ts
bun tests/run-tests.ts tests/unit/db/couchbase

# Full isolated suite (CI-equivalent)
bun run test

11.4 Optional: verifying against a live cluster

The committed tests are mock-based by design. To smoke-test against a real server:

docker run --rm -d --name cb -p 8091-8096:8091-8096 couchbase:community

# Community Edition REJECTS the Magma storage backend, which couchbase-cli defaults to,
# and a single node cannot satisfy a replica - both flags are required.
docker exec cb couchbase-cli cluster-init -c 127.0.0.1 \
  --cluster-username Administrator --cluster-password password123 \
  --services data,index,query --cluster-ramsize 512 --cluster-index-ramsize 256
docker exec cb couchbase-cli bucket-create -c 127.0.0.1 \
  -u Administrator -p password123 --bucket travel \
  --bucket-type couchbase --bucket-ramsize 256 \
  --storage-backend couchstore --bucket-replica 0

Then point a Studio connection at 127.0.0.1:8091 with bucket travel, and create a primary index so the collection can be browsed:

CREATE PRIMARY INDEX ON `travel`;

12. Usage examples

12.1 Programmatic (via the factory)

import { createDatabaseProvider } from '@/lib/db/factory';

const provider = await createDatabaseProvider({
  id: 'cb1', name: 'Travel', type: 'couchbase',
  host: '127.0.0.1', port: 8091,
  user: 'Administrator', password: 'password123',
  database: 'travel',                    // the bucket
  createdAt: new Date(),
});

await provider.connect();

const rows = await provider.query(
  'SELECT META(d).id AS __id, d.* FROM `travel`.`inventory`.`hotel` AS d LIMIT 50',
);
const byKey = await provider.query(
  'SELECT d.* FROM `travel`.`inventory`.`hotel` AS d USE KEYS ["hotel::1"]',
);
const objects = await provider.listObjects(container, 'table');
const { details } = await provider.describeObjects(container, 'table');

await provider.disconnect();

12.2 Over the API

POST /api/db/query with the SQL++ statement in the sql field — the same contract every SQL provider uses. POST /api/db/maintenance (admin) accepts analyze / reindex / kill, each with a target. Transaction and cancel routes do not apply.


13. Known limitations & future work

What the HTTP transport does not provide. These are real, they are the whole cost of having no native dependency, and they are listed first so nobody discovers them by accident:

  • Direct KV range scan, on Server 7.0-7.2 only. The SDK can stream a collection's documents straight from the data service without touching an index. On 7.6 and later this gap has closed on its own: the Query Service performs a sequential scan backed by a KV range scan, so an un-indexed collection lists fine over plain SQL++ and no SDK is required (§3.8). The gap is therefore confined to clusters older than 7.6, where an un-indexed collection raises error 4000 and the remedy is to create the index. The seam still carries the scanDocuments?() extension point (§3.2).
  • Non-JSON documents. The Query Service only sees JSON. Binary and raw-string documents stored through the KV API are invisible to every SQL++ statement, so they appear in no result set and contribute nothing to INFER.
  • Subdocument operations. The KV subdoc API (mutate/lookup a single path inside a document without fetching it) has no REST equivalent. SQL++ projection covers reading a path; targeted in-place path mutation does not exist here — an UPDATE ... SET rewrites through the query service instead.
  • No inline row editing in the results grid, declared as supportsInlineRowEdit: false (#269). The obstacle is not UPDATE — SQL++ has it — but the document key: the collection-open query projects it as META(d).id AS __id, and the shared editor's key heuristic picks that alias up and emits WHERE __id = '<key>', a predicate no document satisfies, so the edit would match zero documents and still report success. Addressing a document needs META(d).id or USE KEYS, i.e. per-dialect statement building (#279). Until then the control is not offered here and a document is edited with a hand-written SQL++ statement.
  • No column modification in a generated migration. #269 also gave the schema-diff migration generator a per-dialect answer for a modified column; a collection has no column definition to change, so it emits -- Couchbase: Cannot alter column "<name>". ... where it previously emitted PostgreSQL ALTER TABLE ... ALTER COLUMN DDL the query service would reject.

A follow-up issue gets opened if a real user reports one of: browsing collections that have no index, viewing non-JSON documents, or a policy requiring the official SDK. At that point the work is one new file implementing CouchbaseTransport; provider logic, introspection, the explain strategy and all UI registration are untouched.

Everything else:

  • No transactions. SQL++ transactions work over REST (BEGIN TRANSACTION returns a txid), but the provider exposes no begin/commit/rollback API, and the txid it never sends is why endOpenQueryTransaction() is absent (§5.5).
  • No cancelQuery. A running statement is terminated through maintenance kill with its request id, which needs the Query System Catalog role.
  • UPDATE STATISTICS is Enterprise-only. On Community Edition the maintenance action returns the cluster's refusal verbatim rather than pretending to succeed.
  • No analyze-mode EXPLAIN. SQL++ has none; see §3.11.
  • Schema columns are a sample, not a declaration. INFER reads 100 documents per collection, so a field that appears only in unsampled documents will not show. Inference is flat: a nested object is reported as its own type, not expanded into dotted sub-fields.
  • Table stats are bucket level. Per-collection item counts would need a COUNT(*) per collection on every monitoring poll.
  • Index size is usually unknown and scan counts are usually 0. Modern servers no longer publish per-index series under @index-<bucket>, and the provider does not guess: an unpublished size is N/A with no byte count, while scans reports 0 only because the field is required.
  • One bucket per connection. Multi-bucket browsing from a single connection is out of scope; the deleted flat list read and every monitoring read are scoped to config.database.
  • Analytics/Columnar, Full-Text Search, Eventing and Capella management APIs (allowed-IP administration, cluster provisioning) are not covered.
  • Monitoring needs privileges. Without the Query System Catalog role, slow queries and active sessions are [], index scan counts read 0, and the performance metrics whose sources are denied are omitted rather than reported as zeroes — by design (§3.9, §7.1).

Migration DDL

COUCHBASE_MIGRATION_DDL is what the schema-diff migration generator writes for couchbase, read through migrationDdlFor in src/lib/db/migration-ddl/. It declines the whole diff: refusal is the one line the migration prints after its header, in place of any DDL, and transactionWrapper is null. Couchbase has index and collection DDL, but no CREATE or ALTER TABLE column grammar. Couchbase does have distributed ACID transactions, but spells one BEGIN TRANSACTION and answers with a txid every following statement has to carry as a request parameter, a shape a flat migration file cannot express (#284).

The refusal reason it prints, verbatim:

  • "Collections hold schemaless JSON documents, so there is no column definition to change."

14. References


15. Agent mode: a user holding only query_select, checked before every statement

Agent mode executes statements on Couchbase through queryReadOnly in index.ts, with the check and the statement fence in agent-boundary.ts. Every fact below was measured on couchbase/server:enterprise-8.0.2 and community-8.0.2, digest-pinned, and is recorded in tests/fixtures/couchbase/8.0.2/agent-boundary/, whose run.sh re-records every capture.

15.1 The boundary is the user

The query service has no read-only session, and its readonly request flag is no boundary: it refuses INSERT, UPDATE, DELETE, CREATE INDEX, BEGIN WORK, EXECUTE FUNCTION and the EXECUTE of a prepared DELETE with code 1000, but runs UPDATE STATISTICS (capture 04). So the boundary is credential-enforced: a user whose only roles are query_select on named scopes of the connection's bucket, never on every scope and never on its _system scope. A role on every scope reaches the bucket's _system scope, whose _query collection holds the query optimizer's statistics and whose _mobile collection holds Sync Gateway's own documents, the latter readable by key with no index; a role on named scopes reaches neither (capture 09). Such a user is refused every INSERT, UPSERT, UPDATE, DELETE, MERGE, index, scope, collection, sequence and GRANT statement with code 13014, a call of any user-defined function, inline or JavaScript, scoped or global, CURL(), NEXTVAL, a read of another bucket and of the system keyspaces that hold other users' requests, prepared statements and roles (system:completed_requests, system:active_requests, system:prepareds, system:user_info, system:applicable_roles), each with 13014 or, for a function call inside a projection, 5010 naming the missing role (capture 02). The positive controls show each refused role really reaches further: query_update, query_insert and query_delete write, data_writer writes through the document API, a group's data_writer does too, query_select on every bucket or on a second bucket reads it, query_system_catalog reads the administrator's statement text and prepared statement, and the function and sequence roles run their code (capture 03). Community Edition offers only admin, ro_admin and bucket_full_access, refuses to grant query_select at all, and its bucket_full_access user writes (capture 06), so no Community Edition user is admitted. An agent credential beside a connection string is refused before the provider is built, as on every engine.

15.2 The check, before every statement

verifyCouchbaseAgentProfile runs at the open of a handle under the agent read-only profile and again before every agent statement, keeping no verdict between calls, so a role added after one call refuses the next and a role taken away admits the one after. It sends two requests as the agent user:

  1. GET /whoami, which lists the user's roles, those it holds through a group included (capture 01). Every role must be query_select whose bucket_name is the connection's bucket exactly and whose scope_name names one scope, never * and never _system, on any of that scope's collections. Any other role is refused by name, as Couchbase spells it (data_writer[probe:*:*], query_select[probe:*:*]), a role the check has never seen included, so a new role is refused until it is measured; a user holding none is refused as too narrow. An answer it cannot read, or one for another identity, refuses the profile.
  2. DELETE FROM `_default` USE KEYS "libredb-agent-probe-<24 hex>", with the readonly flag off so the refusal is the role's, which an enforcing server refuses with 13014 for the missing query_delete; any other answer refuses the profile, a bucket whose _default collection was dropped included.

The operations profile sends no statement, so it opens without the check and plan mode grounds on a privileged connection.

15.3 The statement path

Only one statement whose first keyword is SELECT or WITH is sent, refused before anything is sent otherwise (couchbaseAgentStatementRefusal), because a user holding only query_select may still run UPDATE STATISTICS, which writes the optimizer's statistics, BEGIN WORK, PREPARE, ADVISE, INFER and the EXECUTE of a statement another user prepared, which runs under that statement's own privilege list (captures 02 and 04). A WITH leads only a SELECT: WITH ... INSERT, DELETE, UPSERT, MERGE, UPDATE and EXECUTE FUNCTION are syntax errors (captures 02 and 05). The text is read with the SQL++ grammar row (sql-grammar.ts): comments and whitespace skipped, string literals and backtick names opaque, a code semicolon ending the statement with only comments and whitespace after it, since the server runs SELECT 1 AS a;;; text ending inside a comment, string or name, and a $$ run, are refused. The server ends a -- comment at a lone carriage return (capture 05), so a carriage return inside a -- comment with more text after it is refused, whichever reading a later reader takes; U+2028, U+0085 and a form feed end no comment there.

Each statement goes out on a request of its own through the shared node transport (src/lib/db/http/node-transport.ts), whose body carries the statement, its timeout (the budget's), readonly: true as defence in depth only, scan_consistency: "request_plus" and the bucket's query context, and nothing else: no arguments, transaction id or prepared name, so nothing one request set reaches the next. One deadline, the budget's, bounds the check and the statement together; the server stops the statement at its own timeout, and the client deadline ends the wait, not the work. Budgets refuse rather than truncate: the answer is read up to twice the byte budget plus 64 KiB and refused past it, and a result past the row or the byte budget is refused. A statement the user's role does not allow is answered as the statement's failure, so the agent can repair it; a check that cannot be read is the environment's.

15.4 Residual: a role taken away

GET /whoami lists a role change the moment the management request answers, while the query service honours a role taken away for up to some tens of milliseconds more: in capture 08, the call made at once after the change still ran in 1 of 10 rounds and was refused 54 ms after it. So a user narrowed to query_select may, for that window, still reach what the removed role allowed through a SELECT, the statement fence still in front of it. A fresh user holding only query_select on named scopes, rather than narrowing one, is the remedy.

15.5 Agent language pack

Statement-language family: sql. Overlay: src/lib/agent/languages/sql/engines/couchbase.ts, with its client row in src/lib/agent/languages/sql/engines/client.ts. The agent reads it through runServerPack and agentEnginePack, and the dialect through engineServerDialect.

Member Kind Value
catalogRead Dialect-bound Supplied
planEstimate Dialect-bound "unsupported"
profileComposition Dialect-bound "unsupported"
statistics Dialect-bound "unsupported"
catalogCapture Dialect-bound "unsupported"
grounding Shared Overridden

The overlay overrides the sql family's grounding: it keeps the family's own and adds two planning rules a plan run on Couchbase is told (each collection named as scope.collection in backticks, since the query context is the bucket; nested fields and the document key), and sets relations false, since SQL++ has no foreign key. The fence's one SELECT is not a planning rule: a plan run drafts writes too, for the owner to run in the editor. A collection declares no columns, so the column kind samples at most 100 documents of one collection, named by its scope as the schema and its own name as the table, and answers each top-level field's name and JSON type once as table_schema, table_name, column_name and data_type (capture 07); it describes the sample, as the schema explorer's INFER does, and a selector without both names is refused. The relation, index and statistics kinds are refused with SELECTOR_UNSUPPORTED_BY_DIALECT: SQL++ has no foreign key, system:indexes shows a user holding only query_select no row, and the server keeps no row count it can vouch for. So investigation, database assessment and data analysis open here and send their statements through queryReadOnly, where inspect_plan and profile_table answer UNSUPPORTED_DIALECT; database assessment cannot pass with no table profile (B146), and the sql-composers requirement holds query optimization out, since the agent path sends no EXPLAIN (B150); plan mode drafts here as on every engine. A pack does not open agent mode: execution is gated by AGENT_EXECUTION_ENGINES and the execution-profile check. MCP's run_read_query runs statements here under the same boundary (MCP_STATEMENT_READ.couchbase is true), as 15.7 records.

15.6 Boundary record

bun tests/live/couchbase-agent-boundary.ts, run on 2026-10-30 against the enterprise-8.0.2 node and the community-8.0.2 node run.sh sets up, passes 90 checks through the production path (createDatabaseProvider with the profile's execution context, connect, queryReadOnly) and with no admission of Studio's in front: the open admits query_select on a named scope and on one collection, and refuses by name query_select on every scope of the bucket and every other role listed in 15.1, data_writer held through a group, and Community Edition's bucket_full_access and ro_admin users, while a user holding no role is refused as unauthenticated before the check, since it cannot read /pools/default; the operations profile opens the administrator unchecked; each write, DDL, transaction, PREPARE, EXECUTE, UPDATE STATISTICS, ADVISE, GRANT, second statement and carriage-return text is refused before it is sent; behind the fence, the same statements sent as the user on a request of its own are refused for a missing role (13014), never as a syntax error, except UPDATE STATISTICS, BEGIN WORK, PREPARE, ADVISE and the EXECUTE of the administrator's prepared statement, which the server runs for such a user; each function call, CURL(), NEXTVAL, other-bucket, system-keyspace and _system scope read passes the fence and is refused by the server naming the missing role; the bucket's documents read the same after; the administrator's INSERT, JavaScript function and carriage-return DELETE each write, the positive controls; a role or a group added after one call refuses the next and its removal admits the one after; the three budgets refuse; and the composed column read answers the sample's fields.

15.7 MCP read

MCP_STATEMENT_READ.couchbase is true, so MCP's run_read_query runs statements on a Couchbase seed under the boundary above: it acquires the connection under the agent read-only profile, whose open runs the check of 15.2, and sends each statement through queryReadOnly, which runs the check again before the statement and passes the statement through the fence of 15.3. It runs a SELECT (a WITH is fine): run_read_query's own fence refuses every other statement before anything is acquired, but for an EXPLAIN, a VALUES and a TABLE text, which pass it and which the provider's fence refuses after the open and before anything is sent. It pages with LIMIT n OFFSET m, which the provider appends to a SELECT without a LIMIT of its own; an offset on a statement with its own LIMIT is refused before the statement is sent. A SELECT without its own LIMIT is paged at max_rows (100 by default, at most 500), and pagination.hasMore and pagination.nextOffset say where the next page starts; a query whose own LIMIT is past 1000 rows, or a result past 1 MiB, is refused, never cut. A SELECT with an OFFSET of its own and no LIMIT is given a nextOffset too, but the call at that offset fails with a syntax error. It takes no database: the seed reads the bucket it names.

MCP signs in as the seed's agent credential when it sets one, and as the seed's own user otherwise, and that user must be one the check admits: a user whose only roles are query_select[<bucket>:<scope>] on named scopes of the seed's bucket, counting the roles of its groups, on Enterprise Edition. Any other is refused at open in run_read_query's refusal sentence, naming the role that reaches past those: the administrator, ro_admin, query_system_catalog, data_reader, data_writer held directly or through a group, and query_select on every scope, on the _system scope, on every bucket or on a second bucket among them. inspect_schema takes the operations profile, which sends no statement, so it opens a Couchbase seed unchecked, as the editor does, with the administrator too.

What such a user reads, measured on enterprise-8.0.2 on 2026-11-02 as the user and as the administrator, beside the documents of the scopes it holds the role on: its own entry in system:my_user_info (its id, roles, groups, uuid and the date its password last changed, no password hash), its own bucket and the keyspaces it holds a role on in system:buckets, system:scopes, system:all_scopes, system:keyspaces, system:all_keyspaces, system:keyspaces_info and system:all_keyspaces_info, the namespace in system:namespaces, a sequence in a scope it holds the role on, its value block included, in system:sequences and system:all_sequences, the cluster's address in system:datastores, and the server's version and node name through DS_VERSION() and NODE_NAME(). system:indexes shows it no index, and system:all_indexes only the indexes of the system keyspaces. None of these answers a row of a scope it holds no role on. The server refuses it everything else it was asked for, for a missing role (13014): another bucket and a scope of its own bucket it holds no role on, the _system scope included, whether named with or without backticks or the default: namespace, relative to the query context, in a subquery, a join or with USE KEYS; system:user_info, system:group_info, system:applicable_roles and system:nodes, which need admin; system:active_requests, system:completed_requests, system:completed_requests_history, system:prepareds, system:functions_cache, system:dictionary, system:dictionary_cache, system:tasks_cache, system:transactions, system:vitals, system:bucket_info, system:database_info, system:aus, system:aus_settings and system:awr, which need query_system_catalog; CURL() and NEXTVAL; ADVISOR()'s session forms (start, get, list and the rest), refused for query_system_catalog; and a call of a user-defined function, inline or JavaScript, scoped or global, refused with 5010, whose cause in the server's answer names the missing role, while Studio passes on only the message Error evaluating projection, so neither the agent nor an MCP client sees the role. ADVISOR() given one statement runs for the user and recommends indexes on a keyspace it holds the role on, while for any other keyspace its answer row holds the server's missing-role message and nothing more. A system keyspace's name in another case names no keyspace (SYSTEM:USER_INFO answers "Keyspace not found"), and a bare name is read inside the query context's bucket. The server answers a bucket, scope or collection that does not exist as not found (12003, 12021) and one that exists as a missing role (13014), so the user can learn whether a name exists, but not what it holds. So what a statement can read is the user's own roles, which the server enforces on every keyspace a statement names, and which the check admits only when they are this narrow; the provider adds no check of keyspace names in statement text.

bun tests/live/couchbase-mcp-boundary.ts, run on 2026-11-02 against the enterprise-8.0.2 node run.sh sets up, with a scope s1 holding a collection c1 in the bucket, passes 122 checks through run_read_query's own handler, with the factory's profiled acquisition and its statement fence removed where a check says so: the open serves a user holding query_select on the _default scope under agent-read-only, and refuses through MCP, in the tool's sentence, the administrator, ro_admin, query_system_catalog, data_writer held directly and through a group, and query_select on every scope, the _system scope, every bucket and a second bucket, while a user holding no role is refused as unauthenticated before the check; with the MCP fence in place each write, DDL, transaction, PREPARE, EXECUTE, UPDATE STATISTICS, ADVISE and GRANT is refused before anything is acquired, and with it removed the provider's fence refuses each, and INFER, EXPLAIN, a second statement and the carriage-return text, before it is sent, while the same text sent raw as the user is refused for a missing role (13014) but for the five the server runs for such a user; each read past the role listed above passes the MCP fence and is refused by the server in its own words, ADVISOR() given one statement recommends indexes on the user's own keyspace and answers another bucket's with the missing-role message, and an absent bucket, scope and collection are answered as not found; the documents of both buckets and of s1.c1, the keyspaces, indexes, functions and users read the same after; the administrator's INSERT and carriage-return DELETE write, the positive controls; a WITH answers, an EXPLAIN, a VALUES and a TABLE text pass the MCP fence and are refused by the provider's before they are sent, a read past max_rows names the next offset and that offset answers the next rows, an offset on a statement with its own LIMIT is refused, and a read with its own LIMIT past 1000 rows and one past 1 MiB are refused, never cut; a read past timeout_ms is answered as the timeout and the query service holds the statement no longer, and a read whose client closes the call is answered as the cancel at once while the statement runs on until the time left of timeout_ms and then is held no longer. The agent live check (15.6) passes unchanged on the same node.