InfiniteOceanv2

Query and SQL

POST /v2/query and POST /v2/sql read many drops at once — filters, sorting, limits, aggregates and joins, plus what the scanned/matched/truncated/complete counters actually mean and where they cannot be trusted.

What these two endpoints do

POST /v2/query and POST /v2/sql both read many drops at once. They scan the current version of every entity under one route (or, for /v2/query, under a tree of routes), keep the rows that match your condition, and return them. Neither endpoint writes anything.

Pick /v2/query when you want a JSON body you can build programmatically, nested field access, aggregates, or a join. Pick /v2/sql when you want to type a SELECT by hand and get back column/row arrays instead of objects.

Both require X-Ocean-Key, and both check read access on the route named in the request. A route your key may not read returns 403 FORBIDDEN — not an empty result.

Neither door takes URL query parameters. Everything goes in the JSON body. Appending any parameter to the URL is a hard 400, so /v2/query?limit=5 will not quietly ignore the parameter — it fails the whole request:

curl -s -X POST "https://api2.infiniteocean.io/v2/query?limit=5" \
  -H "Content-Type: application/json" -H "X-Ocean-Key: $OCEAN_KEY" \
  -d '{"prefix":"_docs_probe/query/orders","limit":2}'

400  {"ok":false,"error":"unknown query parameter \"limit\" — nothing was applied",
      "code":"UNKNOWN_PARAM"}

Both are POST only. GET /v2/query returns 404 {"ok":false,"error":"no /v2 endpoint matches this method and path","code":"NOT_FOUND"}.

The sample route used on this page

Every request and response below was run against a live route. To reproduce them, write the same drops under a prefix your key owns, then substitute that prefix. The first write to a route creates its access rule — the drop response says "rule_created":true on that first write and false on every later one.

curl -s -X POST https://api2.infiniteocean.io/v2/drop \
  -H "Content-Type: application/json" -H "X-Ocean-Key: $OCEAN_KEY" \
  -d '{"route":"_docs_probe/query/orders","key":"o1",
       "payload":{"customer":"alice","amount":120,"status":"paid","shipped":true,
                  "meta":{"region":"eu","tier":2}}}'

The five rows used throughout, with the ts each one carried at the time these responses were captured (o1 had been rewritten, so its ts is the newest — that matters for the from and last examples):

keytscustomeramountstatusshippedmeta
o11787593824762alice120paidtrue{region: eu, tier: 2}
o21787591981613bob45openfalse{region: us, tier: 1}
o31787591981916alice300paidtrue{region: eu, tier: 3}
o41787591983650carol80openfalse{region: us, tier: 2}
o51787591985670bob210paidtrue{region: apac, tier: 1}

POST /v2/query

Smallest working request

curl -s -X POST https://api2.infiniteocean.io/v2/query \
  -H "Content-Type: application/json" -H "X-Ocean-Key: $OCEAN_KEY" \
  -d '{"prefix":"_docs_probe/query/orders","filter":{"status":"paid"},
       "limit":10,"select":["key","status"]}'

Real response, 200, in full:

{"ok":true,"scope":"squery.join_s1",
 "results":[{"key":"o1","status":"paid"},{"key":"o3","status":"paid"},{"key":"o5","status":"paid"}],
 "drops":[{"key":"o1","status":"paid"},{"key":"o3","status":"paid"},{"key":"o5","status":"paid"}],
 "scanned":5,"matched":3,"returned":3,"truncated":false,"complete":true,
 "catalog_complete":true,"time_ms":1097}

drops is a byte-identical copy of results, kept for older clients. Read results. Later examples on this page elide the drops copy; that is the only edit made to any response shown here.

Request body

FieldTypeRequiredMeaning
prefixstringyesThe route to scan. route is accepted as an alias. Ends in /* to scan all descendant routes.
filterobjectnoField conditions, ANDed. No filter means every row matches.
sortobjectnoExactly one {"field": 1} (ascending) or {"field": -1} (descending).
limitintegernoRows to return. Default 100.
selectstring[]noTop-level payload fields to keep, plus "key".
fromnumber or stringnoKeep rows whose ts is strictly greater. Epoch milliseconds, or "YYYY-MM-DD".
lastintegernoKeep the N newest matching rows by ts.
aggregateobjectno{group_by?, compute}. Switches the response to aggregate mode.
group_by / computestring / objectnoThe same aggregate, spelled at the top level instead of nested.
joinobjectnoLeft join onto a second route by entity key.

Unknown top-level body fields are ignored rather than rejected. {"prefix":"_docs_probe/query/orders","bogus":1,"limit":2,"select":["key"]} returned 200 and a normal result set. Do not read that as support for a field — check that the effect you wanted actually happened. (URL query parameters behave the opposite way and 400; see above.)

filter

A filter entry is either a bare literal (meaning $eq) or a single-operator object. Multiple entries are ANDed. One operator per field.

"filter": {"status": "paid"}                           equality (shorthand)
"filter": {"amount": {"$eq": 300}}                     equality (explicit)
"filter": {"amount": {"$gt": 100}}                     one operator
"filter": {"status": "paid", "amount": {"$gt": 150}}   AND

Operators: $eq, $ne, $gt, $gte, $lt, $lte. There is no $or, no $in, and no $contains — each returns 400 UNSUPPORTED_QUERY.

The entity key is filterable under the reserved name key:

curl -s -X POST https://api2.infiniteocean.io/v2/query \
  -H "Content-Type: application/json" -H "X-Ocean-Key: $OCEAN_KEY" \
  -d '{"prefix":"_docs_probe/query/orders","filter":{"key":{"$gte":"o3"}},
       "limit":10,"select":["key"]}'
{"ok":true,"scope":"squery.join_s1",
 "results":[{"key":"o3"},{"key":"o4"},{"key":"o5"}],
 "scanned":5,"matched":3,"returned":3,"truncated":false,"complete":true,
 "catalog_complete":true,"time_ms":941}

A payload. prefix on a field name is stripped before matching. Verified back to back: {"amount":120} and {"payload.amount":120} both returned [{"key":"o1"}] with "matched":1.

Nested payload fields

A filter field may contain one dot, which reaches into a nested object one level down. This was measured, not assumed:

curl -s -X POST https://api2.infiniteocean.io/v2/query \
  -H "Content-Type: application/json" -H "X-Ocean-Key: $OCEAN_KEY" \
  -d '{"prefix":"_docs_probe/query/orders","filter":{"meta.region":"eu"},
       "limit":10,"select":["key","meta"]}'
{"ok":true,"scope":"squery.join_s1",
 "results":[{"key":"o1","meta":{"region":"eu","tier":2}},
            {"key":"o3","meta":{"region":"eu","tier":3}}],
 "scanned":5,"matched":2,"returned":2,"truncated":false,"complete":true,
 "catalog_complete":true,"time_ms":1027}

Two dots do not work. A drop with payload {"name":"deep","a":{"b":{"c":1}},"tags":["x","y"]} was written to _docs_probe/query/deep, and {"filter":{"a.b.c":1}} returned 200 with "matched":0 — no error, no rows, while an unfiltered query on the same route returned the row. Same for {"a.b":"x"}, because the value at a.b is an object and only strings, numbers, booleans and null are comparable. A too-deep path fails silently as an empty result. If you need to filter on something three levels down, flatten it into a top-level or one-level-nested field when you write the drop.

Type matching is strict

A number in the payload only matches a JSON number in the filter, and a string only matches a string. There is no coercion and no error — the mismatch returns zero rows. Both of these were run back to back:

# string literal against a numeric field
-d '{"prefix":"_docs_probe/query/orders","filter":{"amount":"120"},"limit":10,"select":["key"]}'
200  {"results":[], "scanned":5,"matched":0,"returned":0, …}

# number literal against the same field
-d '{"prefix":"_docs_probe/query/orders","filter":{"amount":120},"limit":10,"select":["key"]}'
200  {"results":[{"key":"o1"}], "scanned":5,"matched":1,"returned":1, …}

Booleans compare against true / false and support only $eq and $ne; a null field compares against null, also equality only. Both verified against a route holding {"note":null,"flag":true} (key n1) and {"note":"set","flag":false} (key n2):

-d '{"prefix":"_docs_probe/query/nulls","filter":{"note":null},"limit":10}'
200  "results":[{"key":"n1","ts":1787592843198,"payload":{"note":null,"flag":true}}],"matched":1

-d '{"prefix":"_docs_probe/query/nulls","filter":{"flag":{"$ne":true}},"limit":10}'
200  "results":[{"key":"n2","ts":1787592843537,"payload":{"note":"set","flag":false}}],"matched":1

Arrays and objects stored in a field are not comparable to anything and never match. A field that is absent from a row never matches either, including with $ne — so a $ne filter will not surface rows that lack the field entirely:

-d '{… "filter":{"nonexistent":{"$ne":"x"}}, "select":["key"]}'
200  "results":[],"scanned":5,"matched":0

-d '{… "filter":{"status":{"$ne":"x"}}, "select":["key"]}'          # control
200  "results":[{"key":"o1"},…,{"key":"o5"}],"scanned":5,"matched":5

sort

One field only. 1 ascending, -1 descending. Two fields return 400 UNSUPPORTED_QUERY. Sorting happens over the whole matched set, before limit cuts it, so sort + limit gives you a genuine top-N.

curl -s -X POST https://api2.infiniteocean.io/v2/query \
  -H "Content-Type: application/json" -H "X-Ocean-Key: $OCEAN_KEY" \
  -d '{"prefix":"_docs_probe/query/orders","sort":{"amount":-1},
       "limit":2,"select":["key","amount"]}'
{"ok":true,"scope":"squery.join_s1",
 "results":[{"key":"o3","amount":300},{"key":"o5","amount":210}],
 "scanned":5,"matched":5,"returned":2,"truncated":true,"complete":true,
 "catalog_complete":true,"time_ms":1037}

Confirmed on a larger route too: {"sort":{"n":-1},"limit":5} over a 230-row route returned b329…b325 with "scanned":230,"matched":230,"returned":5 — the top 5 of all 230, not the top 5 of the first page read.

Without sort, rows arrive in storage enumeration order. Do not depend on it; add a sort if order matters to you.

limit

Default 100, measured on a 230-row route: omitting limit returned "returned":100,"matched":230,"truncated":true. "limit":0 is legal and returns "results":[] with matched still counted — a cheap way to count matches without transferring rows. Values larger than the match count return everything and do not error: on the same 230-row route both "limit":250 and "limit":99999 returned all 230 rows with "truncated":false.

select

select keeps the named top-level payload fields plus the reserved key. Anything else you name comes back as null, including dotted paths and ts:

-d '{"prefix":"_docs_probe/query/orders","select":["key","meta.region"],"limit":3}'
200  "results":[{"key":"o1","meta.region":null},
                 {"key":"o2","meta.region":null},
                 {"key":"o3","meta.region":null}]

-d '{"prefix":"_docs_probe/query/orders","select":["key","ts"],"limit":2}'
200  "results":[{"key":"o1","ts":null},{"key":"o2","ts":null}]

To get a nested value, select its parent object ("select":["key","meta"]) and read into it client-side. When select is omitted, each row carries key, ts (epoch milliseconds) and the whole payload.

from and last

from keeps rows whose ts is strictly greater than the cutoff. It takes epoch milliseconds or a YYYY-MM-DD date. Verified in both directions on the five rows and timestamps in the table above:

-d '{"prefix":"_docs_probe/query/orders","from":"2026-08-23","select":["key"],"limit":10}'
200  "matched":5   (all five rows are newer)

-d '{"prefix":"_docs_probe/query/orders","from":"2026-08-25","select":["key"],"limit":10}'
200  "matched":0   (none are)

-d '{"prefix":"_docs_probe/query/orders","from":1787591983000,"select":["key"],"limit":10}'
200  "results":[{"key":"o1"},{"key":"o4"},{"key":"o5"}],"matched":3

The epoch example returns o1 because o1 was rewritten and now carries the newest ts of the five — a live demonstration of the next paragraph.

last: N keeps the N newest matching rows by ts. {"last":2,"select":["key"]} returned [{"key":"o1"},{"key":"o5"}] — the two newest timestamps, not the two highest keys.

Neither is a history log. Both operate on the current version of each entity, so a drop written five times still contributes one row, carrying the timestamp of its latest write.

Wildcard prefixes

A prefix ending in /* scans every descendant route under that parent, at any depth. Each row then also carries a route field naming where it came from:

curl -s -X POST https://api2.infiniteocean.io/v2/query \
  -H "Content-Type: application/json" -H "X-Ocean-Key: $OCEAN_KEY" \
  -d '{"prefix":"_docs_probe/query/*","filter":{"amount":{"$gte":300}},"limit":5}'
{"ok":true,"scope":"squery.join_s1",
 "results":[{"key":"o3","route":"_docs_probe/query/orders","ts":1787591981916,
             "payload":{"customer":"alice","amount":300,"status":"paid","shipped":true,
                        "meta":{"region":"eu","tier":3}}}],
 "scanned":248,"matched":1,"returned":1,"truncated":false,"complete":true,
 "catalog_complete":false,"time_ms":13932}

scanned is the total across every descendant route, so it grows with everything under the parent — 248 here, against 5 for the same filter on the single orders route. A bare "prefix":"*" is refused with 403 FORBIDDEN.

Wildcards are much slower. Two runs of the query above took 13.9 s and 16.6 s, against roughly 1 s for the same filter on one exact route. Prefer exact routes.

Aggregates

Adding aggregate (or a top-level compute) folds the matched rows instead of listing them. compute maps an output name to one operator: {"$count":true}, or {"$sum"|"$avg"|"$min"|"$max": "field"}. group_by is optional; without it you get one row for everything.

curl -s -X POST https://api2.infiniteocean.io/v2/query \
  -H "Content-Type: application/json" -H "X-Ocean-Key: $OCEAN_KEY" \
  -d '{"prefix":"_docs_probe/query/orders",
       "aggregate":{"group_by":"customer",
                    "compute":{"total":{"$sum":"amount"},
                               "n":{"$count":true},
                               "avg_amount":{"$avg":"amount"}}}}'
{"ok":true,"mode":"aggregate","scope":"squery.join_s1","partial":false,
 "results":[{"customer":"alice","total":420,"n":2,"avg_amount":210},
            {"customer":"bob","total":255,"n":2,"avg_amount":127.5},
            {"customer":"carol","total":80,"n":1,"avg_amount":80}],
 "groups":[…identical copy of results…],
 "scanned":5,"matched":5,"returned":3,"truncated":false,"complete":true,
 "catalog_complete":true,"time_ms":2904}

In aggregate mode the response gains "mode":"aggregate" and partial, and groups mirrors results. returned is the number of groups.

Do not read scanned and matched as row counts in aggregate mode. The same aggregate request, repeated six times with no writes in between, returned identical results every time but reported "scanned":5,"matched":5 on the first run (2904 ms) and "scanned":0,"matched":1 on all five that followed (670–1377 ms). Those two counters describe how the answer was produced on that request, not how many rows exist. If you need a row count, ask for it as {"$count":true} in compute — that value was correct on every run.

filter applies before the fold — {"filter":{"status":"paid"},"aggregate":{"compute":{"n":{"$count":true},"total":{"$sum":"amount"}}}} returned [{"n":3,"total":630}] with "scanned":5,"matched":3. The flat spelling works too: {"group_by":"status","compute":{"n":{"$count":true}}} returned [{"status":"open","n":2},{"status":"paid","n":3}].

group_by without compute is refused with 400 UNSUPPORTED_QUERY rather than silently returning a plain list.

Aggregates work across more than one internal read: an aggregate over a 230-row route returned {"n":230,"max_n":329,"min_n":100} with "scanned":230,"matched":230,"complete":true, matching the data written.

Joins

A join left-joins each returned row onto a second route by that route's entity key. on is [localField, "key"]: the local field holds the remote key. Optional select picks fields out of the remote payload; omit it to get the whole remote payload.

curl -s -X POST https://api2.infiniteocean.io/v2/query \
  -H "Content-Type: application/json" -H "X-Ocean-Key: $OCEAN_KEY" \
  -d '{"prefix":"_docs_probe/query/invoices",
       "join":{"prefix":"_docs_probe/query/users",
               "on":["payload.user_id","key"],
               "select":["payload.name","payload.plan"]},
       "limit":10}'
{"ok":true,"scope":"squery.join_s1",
 "results":[{"_joined":{"name":"Alice Ross","plan":"pro"},"key":"i1","ts":1787592186446,
             "payload":{"user_id":"u_alice","amount":10}},
            {"_joined":{"name":"Bob Kane","plan":"free"},"key":"i2","ts":1787592187942,
             "payload":{"user_id":"u_bob","amount":20}},
            {"_joined":{"name":"Alice Ross","plan":"pro"},"key":"i3","ts":1787592188194,
             "payload":{"user_id":"u_alice","amount":30}}],
 "scanned":3,"matched":3,"returned":3,"truncated":false,"complete":true,
 "catalog_complete":true,"time_ms":1846,"joins_resolved":2}

The joined data lands under _joined. joins_resolved counts distinct remote lookups — 2, not 3, because two invoices share a user.

The remote side must be "key". Joining on a remote payload field returns 400 JOIN_REMOTE_NOT_KEY:

{"ok":false,
 "error":"join.on[1] must be \"key\" — the remote side of a join is the entity key. Matching an
          arbitrary remote field would scan the whole remote route on every query. Key your remote
          route by the value you join on (users @<user_id>), then join on
          [\"payload.user_id\", \"key\"].",
 "code":"JOIN_REMOTE_NOT_KEY","scope":"squery.join_s1"}

So design the remote route with the join value as its key. A join combined with an aggregate is refused with 400 UNSUPPORTED_QUERY.

Response fields

FieldMeaning
oktrue on success, false on every error body.
scopeAlways "squery.join_s1" on success, and on the 400s this engine raises itself (UNSUPPORTED_QUERY, JOIN_REMOTE_NOT_KEY). It is absent from 401 and 403 bodies, which are produced before the engine runs.
resultsThe rows, or the groups in aggregate mode.
drops / groupsIdentical copies of results, for older clients. Ignore them.
scannedRows read from storage before filtering. Not meaningful in aggregate mode — see above.
matchedRows that passed the filter, before limit. Not meaningful in aggregate mode — see above.
returnedRows (or groups) actually in results.
truncatedtrue if returned < matched or the scan did not certify it reached the end. See below.
completetrue only when the scan certifiably reached the end of the route.
catalog_completePresent on every /v2/query response, and always true for an exact route. On a wildcard it reports whether the route directory could enumerate every descendant route; false means more routes may exist, not that this scan was short.
time_msServer-side time for the query.
joins_resolvedPresent only with a join: distinct remote key lookups performed.
mode, partialPresent only in aggregate mode.

Reading truncated and complete

This is the part that will bite you if you skim it.

truncated is the single field that means "your answer is short". It is the OR of two separate conditions: returned < matched, and the scan not certifying that it reached the end. Only truncated covers both — if you page by comparing returned to matched, a short scan looks consistent and complete while being wrong.

complete: false does not mean rows are missing. It appears reliably on a route whose index has not been folded yet. Measured on a route created moments earlier, through both doors:

# two drops written to a brand-new route, queried ~4 s later
SELECT * FROM `_docs_probe/query/disjoint` WHERE key >= ''
200  {"row_count":2,"scanned":2,"matched":2,"returned":2,
      "truncated":true,"complete":false, …}    ← both rows present anyway

The same five-row orders route, long settled, returned "truncated":false,"complete":true on twelve consecutive identical runs, and GET /v2/list on it reported "complete":true,"index":true at the same moment. Rewriting a row on an already-folded route did not flip it back: complete stayed true on ten polls over 20 seconds after an overwrite.

Practical handling: treat complete: false on a route you have only just started writing to as "not yet certified", and retry if certainty matters. Treat complete: false together with returned == limit as a genuinely short scan.

Errors

StatuscodeWhen
400UNKNOWN_PARAMAny URL query parameter. This door takes none.
400MISSING_FIELDNo prefix or route. A body that is not valid JSON also lands here: "body must include prefix (or route) — nothing was applied".
400UNSUPPORTED_QUERY$or, $in, $contains, offset, multi-field sort, group_by without compute, join plus aggregate. All six return the identical body; the error text lists the whole supported scope.
400JOIN_REMOTE_NOT_KEYon[1] is not "key".
400INVALID_INPUTA field has the wrong JSON type. All three of "limit":"5", "select":"key" and "limit":-1 returned {"ok":false,"error":"invalid query body — nothing was applied","code":"INVALID_INPUT"}.
401KEY_REQUIREDNo X-Ocean-Key header.
401UNKNOWN_KEYHeader present, key not recognised.
403FORBIDDENYour key may not read that route. A route that does not exist also returns 403, not 404 — there is no rule granting you access to it. A bare "prefix":"*" is the same.
404NOT_FOUNDWrong method — this path exists for POST only.
409AGGREGATE_NEEDS_INDEXDeclared, not observed in these probes. The route is larger than a single scan can cover, so any total would be wrong rather than partial; the query is valid and answers once the route is folded.
503NOT_READYDeclared, not observed in these probes. Storage backend not wired on the node that answered.
# unsupported operator, real response
-d '{"prefix":"_docs_probe/query/orders","filter":{"status":{"$in":["paid"]}},"limit":5}'
400
{"ok":false,
 "error":"unsupported structured query — v2 scope is prefix(exact or parent/* via route
          directory)+filter($eq|$ne|$gt|$gte|$lt|$lte)+single sort+limit+select+from+last+
          aggregate(group_by+compute $count|$sum|$avg|$min|$max)+left join on the remote entity
          key; no offset/$or/$in, and no join together with aggregate (scope=squery.join_s1)",
 "code":"UNSUPPORTED_QUERY","scope":"squery.join_s1"}

POST /v2/sql

Smallest working request

The body is exactly {"query": "<sql>"} and nothing else.

curl -s -X POST https://api2.infiniteocean.io/v2/sql \
  -H "Content-Type: application/json" -H "X-Ocean-Key: $OCEAN_KEY" \
  -d '{"query":"SELECT * FROM \"_docs_probe/query/orders\" WHERE status = '"'"'paid'"'"'"}'
{"ok":true,"command":"SELECT","scope":"select_cols_where",
 "columns":["key","customer","amount","status","shipped","meta"],
 "rows":[["o1","alice",120,"paid",true,{"region":"eu","tier":2}],
         ["o3","alice",300,"paid",true,{"region":"eu","tier":3}],
         ["o5","bob",210,"paid",true,{"region":"apac","tier":1}]],
 "row_count":3,"scanned":5,"matched":3,"returned":3,
 "truncated":false,"complete":true,"time_ms":1040}

Results are column-oriented: columns names the fields, rows holds arrays in that order. With SELECT *, columns is the union of key and every payload field seen across the matched rows; a row missing one of them gets null in that position. Verified on two rows with no fields in common — {"alpha":1,"beta":2} and {"gamma":3,"delta":4}:

{"columns":["key","alpha","beta","gamma","delta"],
 "rows":[["d1",1,2,null,null],["d2",null,null,3,4]],"row_count":2}

The grammar

SELECT * | col [, col ...]
FROM route
WHERE or_term [OR or_term ...]
[ORDER BY col [ASC | DESC]]
[LIMIT n]

or_term  ::= pred [AND pred ...]
pred     ::= col OP literal
OP       ::= =  !=  <>  <  >  <=  >=

Three constraints that are easy to trip over, all confirmed against the live door:

Not supported at all, each returning 400 UNSUPPORTED_SQL — all tested: JOIN, GROUP BY, aggregate functions such as COUNT(*), INSERT, parentheses, and dotted column names such as meta.region. For nested fields, aggregates or joins, use /v2/query.

There is no default row limit on this door. A 230-row route queried with WHERE key >= '' and no LIMIT returned all 230 rows ("row_count":230,"truncated":false). /v2/query would have capped the same request at 100. Bound your SELECT yourself.

Quote any route that contains a slash

An unquoted FROM identifier stops at the first character that is not a letter, digit, underscore or hyphen, so a slash ends the route name and the rest of the path is left over as text the parser cannot place. The result is a grammar error, not a route error:

# unquoted, five identical runs
-d '{"query":"SELECT * FROM _docs_probe/query/orders WHERE status = '"'"'paid'"'"'"}'
400  {"ok":false,"error":"unsupported SQL — v2 scope is SELECT *|col[,col...] FROM route
      WHERE or_term …","code":"UNSUPPORTED_SQL","scope":"select_cols_where"}

Wrap it in double quotes or backticks and it works. Both were verified:

SELECT * FROM "_docs_probe/query/orders" WHERE status = 'paid'
SELECT key, customer, amount FROM `_docs_probe/query/orders`
  WHERE amount > 50 ORDER BY amount DESC LIMIT 2
{"ok":true,"command":"SELECT","scope":"select_cols_where",
 "columns":["key","customer","amount"],
 "rows":[["o3","alice",300],["o5","bob",210]],
 "row_count":2,"scanned":5,"matched":4,"returned":2,
 "truncated":true,"complete":true,"time_ms":931}

A single-segment route works either way: SELECT key, n FROM _docs_probe WHERE n = 1 and the same query with backticks both returned 200 with "rows":[["p1",1]]. Quoting always works, so quote always.

A route name that parses cleanly but that your key may not read is where you get 403 FORBIDDEN — for example FROM nosuchroute, or a wildcard, which this door does not support: FROM `_docs_probe/query/*` returned 403 {"ok":false,"error":"forbidden","code":"FORBIDDEN"}.

Literals and types

String literals are single-quoted. Numbers are bare. The distinction is load-bearing, exactly as in /v2/query:

WHERE amount = '120'   200, "matched":0    (quoted literal vs numeric field)
WHERE amount = 120     200, "matched":1, rows [["o1"]]
WHERE status = paid    400 UNSUPPORTED_SQL (bare word is not a literal)
WHERE shipped = true   400 UNSUPPORTED_SQL
WHERE shipped = 'true' 200, rows [["o1",true],["o3",true],["o5",true]]

Booleans compare against the quoted literals 'true' and 'false', with = and != only. The entity key is available as the column key: WHERE key >= 'o3' returned o3, o4, o5.

AND/OR precedence, verified: WHERE status = 'open' AND amount > 50 OR amount >= 300 returned [["o3","paid",300],["o4","open",80]] — that is (status='open' AND amount>50) OR amount>=300.

Response fields

FieldMeaning
commandAlways "SELECT".
scope"select_cols_where" on success and on UNSUPPORTED_SQL. Absent from 401 and 403 bodies.
columnsColumn names, in the order the row arrays use.
rowsArrays of values, one per matched row.
row_countLength of rows. Equal to returned.
scanned, matched, returnedSame meanings as on /v2/query.
truncated, completeSame meanings, including the unfolded-route complete:false behaviour.
time_msServer-side time.

There is no catalog_complete and no drops alias on this door.

Errors

StatuscodeWhen
400UNKNOWN_PARAMAny URL query parameter. This door takes none.
400INVALID_JSONBody is not valid JSON ("body is not valid JSON — nothing was applied") or not an object ("body must be an object — nothing was applied").
400MISSING_FIELDNo query field. {} returns "body must include query — nothing was applied".
400UNKNOWN_FIELDAny body field other than query. {"query":"…","limit":5} is rejected — the limit belongs inside the SQL.
400INVALID_INPUTquery is not a string. {"query":5} returned "query must be a string — nothing was applied".
400UNSUPPORTED_SQLAnything outside the grammar, including an unquoted route containing a slash. The error text prints the whole grammar.
401KEY_REQUIREDNo X-Ocean-Key header.
403FORBIDDENThe FROM route parsed, but your key may not read it. A route that does not exist lands here too.
503NOT_READYDeclared, not observed in these probes. Storage backend not wired on the node that answered.
-d '{"query":"SELECT key FROM `_docs_probe/query/orders` LIMIT 2"}'
400
{"ok":false,
 "error":"unsupported SQL — v2 scope is SELECT *|col[,col...] FROM route WHERE or_term
          (OR or_term)* [ORDER BY col [ASC|DESC]] [LIMIT n]; or_term is pred (AND pred)*;
          pred is col OP lit; OP is = != <> < > <= >=; AND binds tighter than OR;
          LIMIT requires ORDER BY; no parens/JOIN (scope=select_cols_where)",
 "code":"UNSUPPORTED_SQL","scope":"select_cols_where"}

Payload shape: what is and is not queryable

Both endpoints see a drop's payload only if it is a JSON object. This was tested by writing four payload shapes to one route and reading them back through three doors.

What you POST as payloadStored asGET /v2/entity/…/v2/queryFilterable?
{"customer":"alice","amount":120}object{"customer":"alice","amount":120}payload objectyes
"{\"customer\":\"alice\",\"amount\":120}"object — parsed at write{"customer":"alice","amount":120}payload objectyes
"hello world"string"hello world"payload: {}no
"[1,2,3]" or [1,2,3]array[1,2,3]payload: {}no

A payload written as a JSON string is parsed into an object at write time and queries normally. Row s1 was written with "payload":"{\"customer\":\"alice\",\"amount\":120}" and row s2 with the same content as a real object. Both came back identically, and {"filter":{"customer":"alice"}} matched both and nothing else:

{"ok":true,"scope":"squery.join_s1",
 "results":[{"key":"s1","ts":1787592072390,"payload":{"customer":"alice","amount":120}},
            {"key":"s2","ts":1787592072671,"payload":{"customer":"alice","amount":120}},
            {"key":"s3","ts":1787592101730,"payload":{}},
            {"key":"s4","ts":1787592102019,"payload":{}},
            {"key":"s5","ts":1787592102315,"payload":{}}],
 "scanned":5,"matched":5,"returned":5, …}

You do not need to stringify, and you gain nothing by it — send objects.

A payload that is not an object is stored and readable, but invisible to queries. Rows s3 ("hello world"), s4 ("[1,2,3]") and s5 ([1,2,3]) all read back correctly through the single-entity door:

GET /v2/entity/_docs_probe/query/strpay/s3  200  "hello world"
GET /v2/entity/_docs_probe/query/strpay/s5  200  [1,2,3]
GET /v2/list/_docs_probe/query/strpay       200  {"items":[…,{"key":"s3","value":"hello world"},
                                                              {"key":"s5","value":[1,2,3]}], …}

Through /v2/query, the same rows render as "payload":{}. They are still counted in scanned and, with no filter, in matched — so a count can include rows whose content you cannot see. Once a filter is present they never match: the same {"filter":{"customer":"alice"}} over all five rows returned "scanned":5,"matched":2. Through /v2/sql they appear with null in every payload column:

SELECT * FROM `_docs_probe/query/strpay` WHERE key >= 's1'
200
{"columns":["key","customer","amount"],
 "rows":[["s1","alice",120],["s2","alice",120],
         ["s3",null,null],["s4",null,null],["s5",null,null]],
 "row_count":5,"scanned":5,"matched":5,"returned":5, …}

The rule to build on: if you want a drop to be queryable, its payload must be a JSON object. Wrap scalars and arrays in one: {"value":"hello world"}, {"items":[1,2,3]}.

Choosing between the two

/v2/query/v2/sql
Route scopeOne route, or parent/*One route
Condition requiredNo — omit filter to listYes — WHERE is mandatory
ORNot supportedSupported
Nested fieldsOne level (meta.region)Not supported
SortOne fieldOne column
LimitAny time, default 100Only with ORDER BY; no default cap
Aggregates$count $sum $avg $min $max, with group_byNot supported
JoinLeft join on remote entity keyNot supported
Time windowfrom, lastNot supported
Result shapeArray of objectscolumns + rows arrays

Things worth knowing before you build on this

Base URL https://api2.infiniteocean.io · authenticate with the X-Ocean-Key header.