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) |
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:
- 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.
- 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).
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 |
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.ts
└── 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.
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.
CouchbaseProvider reuses these inherited members rather than reimplementing them:
- State machine —
setConnected(),setError(),isConnected(),ensureConnected(). - Instrumentation —
trackQuery()(active-query counter) andmeasureExecution()(wall clock). - Helpers —
formatDuration(),getSafeConfig()(password-stripped logging),mapError(). - Default
getMonitoringData()— orchestratesgetOverview+getPerformanceMetrics+getSlowQueries+getActiveSessions(+ tables/indexes/storage) concurrently.
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.
These are the non-obvious choices. Read this section before changing the provider.
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 TRANSACTIONreturns atxidcarried 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.
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 inhttp-transport.ts. A strayif (response.status === "errors")in provider logic quietly erodes the boundary and makes a future SDK adapter expensive.
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).
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).
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".
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 (query-generators.ts):
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).
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.
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).
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).
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.
ExplainFormat gains "couchbase-json"
(src/lib/db/types.ts), with the strategy in
src/lib/explain/couchbase-json.ts:
-
buildSql()returnsEXPLAIN ${sql}forSELECTstatements, in both modes — it produces the plan without executing anything. SQL++ has noEXPLAIN ANALYZE; real timings come only from the request-levelprofile: "timings"parameter, whichExplainStrategycannot set by design (it emits SQL only), so the estimate is the best available answer for either mode. This mirrorssqlite-queryplan.ts, which ignores the mode for the same reason.Returning
nullfor analyze would not narrow the feature, it would disable it: the direct Explain action always builds with modeanalyze(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-SELECTis still declined in both modes. -
toRenderModel()walks#operatorinto 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.
| 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" |
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:
ConnectionModal renders the label "Bucket" for type === 'couchbase' (databaseFieldLabel,
ConnectionModal.tsx), 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(),
};supportsConnectionString is true, and the UI parser, parseConnectionString()
(connection-string-parser.ts), decomposes the URL
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
httpsvshttpfromconfig.sslalone and never re-reads the pasted string (§4.3); without it acouchbases://paste posted plain HTTP to 18091.requireand 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%2Dsampleselectstravel-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%selects100%. 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 acouchbase://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 tocouchbase.
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.
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).
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.
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 Couchbase is one of the dialects
deliberately left at the compatibility default: no authoritative source was established for how
SQL++ reads #, and guessing it from a neighbouring dialect is what that channel exists to stop. So
the reading here is unchanged — # opens a line comment unless the next character makes a PostgreSQL
operator — and a statement ending in a # run is returned unbounded rather than bounded on a guess
(which dialect the readers are reading).
| 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.
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).
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.
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 WORKsucceeds and answers atxid, withtransactionRemainingTime: 15sin the metrics. A statement a user runs throughquery()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_consistencyand at mostquery_context,args,readonlyandprofile(http-transport.ts), and never atxid. Measured:SELECT 1straight afterBEGIN WORKansweredsuccesswith notransactionRemainingTimeat all, so it was not inside the transaction.ROLLBACK WORKwith notxidis 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
txidafter 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.
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:scopesdoes not list_defaulton 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 = bucketand nobucket/scopefields 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.
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.
| 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.
| 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.
system:keyspaces and system:indexes each answer two shapes:
- a scoped row, carrying
bucket/bucket_idandscope/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.
- 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_nameis created on bothinventory.airlineandinventory.hoteland both succeed, while a secondix_nameon 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 declaresattachedTo: "collection". - A collection name is unique per scope, not per bucket: the fixture holds
airlinein both_defaultandinventory, each with its ownix_nameover a different key. A read that filtered a collection's indexes on the collection name alone would hand one of them the other's.
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.
- No
viewkind. SQL++ has noCREATE VIEWstatement at all:CREATE VIEW v AS SELECT 1is 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_legacyholding aby_cityview over the CAPI port — and they are invisible to the query service: aLIKEoverENCODE_JSONofsystem:all_keyspaces,system:all_indexes,system:functions,system:bucketsandsystem:all_scopesfinds zero rows mentioning it, and it is not a document in_default._defaulteither. 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
_systemscope's contents._systemis 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.
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.
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:
INFER a, bis error 3000, a syntax error at the comma.INFERtakes one keyspace.INFERagainst a scope is refused:Keyspace resolves to default:travel.inventory - only 2 or 4 parts are valid. There is no folder-level form.INFERis subquery-able — bothSELECT * FROM (INFER ...) AS xandWITH x AS (INFER ...) SELECT xparse — so aUNION ALLover 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 keepshotelandbookingsempty 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.
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-initTo 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.shPort 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:
discountininventoryanddiscountin_default. The globalcelsiusis deliberately outside the tree (§6a.5) and is not counted. The two bodies differ on purpose,price - (price * pct / 100)againstx / 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_nameon each ofinventory.airline,inventory.hoteland_default.airline, the primary index oninventory.airline, and the bucket-level primary index, which thecouchbase-initsidecar 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.
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.
| 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.
collectionis schemaless. A Couchbase collection has whatever fields its documents carry. There is no definition anybody authored, which is the same factdescribeObjectstates by INFERring columns from a document sample rather than reading a schema.indexpublishes no statement.system:indexescarries an index's name, itsindex_key, itsis_primary, itsusingand its state, and no field holding theCREATE INDEXtext. 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 indescribeObject(§6a.7), which is where they belong.
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.
| 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.
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.
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.
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.
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.
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).
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:79-80 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.
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 Tables row this provider has, getTableStats()) means the
bucket's default collection, so the 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.
| 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.
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.
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.
getCapabilities() (index.ts)
| Capability | Value |
|---|---|
queryLanguage |
sql |
supportsExplain |
true |
explainFormat |
couchbase-json |
supportsExternalQueryLimiting |
true |
supportsCreateTable |
false |
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 |
supportsCreateTable: false is deliberate: CreateTableModal builds CREATE TABLE from a column
list, while Couchbase collections are schemaless and CREATE COLLECTION takes no columns. Leaving
the flag on 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.
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.
There is no mock.module() anywhere in the Couchbase suite, which is why these files carry no
process-wide contamination risk:
tests/integration/db/couchbase-provider.test.tsreplacesglobalThis.fetchper test and restores it inafterEach. Every payload in it was captured from a live Couchbase Server 8.0.2 Community node, so the fake speaks exactly what the cluster speaks.tests/unit/db/couchbase/http-transport.test.tsdrives the transport through its injectedrequestJson/resolveSrvdependencies, and exercises the realnodeRequestJsonagainst a local server rather than mocking it away.tests/unit/db/couchbase/introspect.test.tsandkeyspace.test.tstest pure functions and a hand-written fake transport — the payoff of the seam in §3.2.tests/unit/lib/explain/couchbase-json.test.tscovers the plan walker, including the~child/~childrenpair.
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).
# 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 testThe 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 0Then 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`;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();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.
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 ... SETrewrites through the query service instead. - No inline row editing in the results grid, declared as
supportsInlineRowEdit: false(#269). The obstacle is notUPDATE— SQL++ has it — but the document key: the collection-open query projects it asMETA(d).id AS __id, and the shared editor's key heuristic picks that alias up and emitsWHERE __id = '<key>', a predicate no document satisfies, so the edit would match zero documents and still report success. Addressing a document needsMETA(d).idorUSE 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 PostgreSQLALTER TABLE ... ALTER COLUMNDDL 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 TRANSACTIONreturns atxid), but the provider exposes no begin/commit/rollback API, and thetxidit never sends is whyendOpenQueryTransaction()is absent (§5.5). - No
cancelQuery. A running statement is terminated through maintenancekillwith its request id, which needs the Query System Catalog role. UPDATE STATISTICSis 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.
INFERreads 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 isN/Awith no byte count, whilescansreports0only 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 read0, and the performance metrics whose sources are denied are omitted rather than reported as zeroes — by design (§3.9, §7.1).
- Tracking issue: #262 — Add Couchbase provider
- Source:
src/lib/db/providers/document/couchbase/ - Explain strategy:
src/lib/explain/couchbase-json.ts - Base class:
src/lib/db/base-provider.ts - Interface & DTOs:
src/lib/db/types.ts - Errors:
src/lib/db/errors.ts - Tests:
tests/integration/db/couchbase-provider.test.ts·tests/unit/db/couchbase/ - API contract:
docs/API_DOCS.md - Query Service REST API: https://docs.couchbase.com/server/current/n1ql/n1ql-rest-api/index.html
- Listing Node Services: https://docs.couchbase.com/server/current/rest-api/rest-list-node-services.html
- System namespace catalogs: https://docs.couchbase.com/server/current/n1ql/n1ql-intro/sysinfo.html
- USE clause (
USE KEYS): https://docs.couchbase.com/server/current/n1ql/n1ql-language-reference/hints.html - INFER: https://docs.couchbase.com/server/current/n1ql/n1ql-language-reference/infer.html
- EXPLAIN: https://docs.couchbase.com/server/current/n1ql/n1ql-language-reference/explain.html
- Sibling provider docs: PostgreSQL · MySQL · Oracle · SQL Server · SQLite · MongoDB · Trino · Redis · LibreDB