tursodatabase / tursodatabase/libsql-client-ts
SqlCache measures SQLD's 5 KB stored-SQL cap in UTF-16 code units, but the server enforces it in UTF-8 bytes
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 576
- Forks
- 69
- PR merge metrics
- No merged PRs in 30d
Description
Summary
SqlCache.apply guards SQLD's 5 KB stored-SQL cap with sqlText.length >= 5000, which counts UTF-16 code units. SQLD enforces that cap in UTF-8 bytes. Any statement that falls between the two measures is cached and stored by the client, refused by the server, and every later batch that references its sql_id fails with SQL text <id> not found.
Pure-ASCII SQL is unaffected because the two measures coincide. The bug only appears once statements carry multibyte text — CJK, accented Latin, emoji — which makes it look like a mysterious, data-dependent failure rather than a size limit.
Where the two measures disagree
Client — packages/libsql-client/src/sql_cache.ts (unchanged since 06debb5, Apr 2024, and still present in the published 0.17.4):
// Stored SQL cannot exceed 5kb.
if (sqlText.length >= 5000) { // UTF-16 code units
continue;
}
Server — libsql-server/src/hrana/http/request.rs:
const MAX_STORED_SQL_SIZE: ByteSize = ByteSize::kb(5);
...
} else if req.sql.len() > MAX_STORED_SQL_SIZE.as_u64() as usize { // UTF-8 bytes
bail!(StreamResponseError::SqlTooLarge { size: ByteSize::b(req.sql.len() as _) })
}
String::len() in Rust is the byte length, so the server's window is bytes <= 5000 while the client's is codeUnits < 5000. A statement of 3,024 characters / 9,024 bytes satisfies the client's check and violates the server's.
Why the reported error points at the wrong thing
The store_sql request that SQLD refuses is pipelined alongside the work that uses it, and HttpStream.storeSql routes its rejection into _setClosed rather than to the caller:
storeSql(sql) {
const sqlId = this.#sqlIdAlloc.alloc();
this.#sendStreamRequest({ type: "store_sql", sqlId, sql })
.then(() => undefined, (error) => this._setClosed(error));
return new Sql(this, sqlId);
}
So the first error the application actually sees is the downstream one — an HTTP 400 naming an id the server never registered. Because the 30-entry LRU recycles slots, that id is frequently a small number like 0, which reads like a protocol bug rather than an oversized statement. Nothing in the message mentions size, encoding, or which statement was at fault.
Reproduction
Self-contained and synthetic — no sqld instance required. The fake server implements only the two relevant SQLD behaviours: refuse store_sql above 5,000 UTF-8 bytes, and answer 400 SQL text <id> not found when a batch names an unknown id.
$ npm install @libsql/client@0.17.4
$ node repro.mjs
statement length (UTF-16 code units): 3024
statement length (UTF-8 bytes): 9024
client caches it (length < 5000): true
server stores it (bytes <= 5000): false
RESULT: failed as reported
refused store_sql requests: 1
error: SERVER_ERROR: Server returned HTTP status 400: SQL text 0 not found
repro.mjs
import { createClient } from "@libsql/client";
const SQLD_STORED_SQL_BYTE_CAP = 5000;
// Each character is one UTF-16 code unit but three UTF-8 bytes, so the statement
// sits under the cap by characters and over it by bytes.
const TEXT = "借".repeat(3000);
const SQL = `INSERT INTO t VALUES('${TEXT}')`;
console.log("statement length (UTF-16 code units):", SQL.length);
console.log("statement length (UTF-8 bytes): ", Buffer.byteLength(SQL, "utf8"));
console.log("client caches it (length < 5000): ", SQL.length < 5000);
console.log("server stores it (bytes <= 5000): ", Buffer.byteLength(SQL, "utf8") <= SQLD_STORED_SQL_BYTE_CAP);
const stored = new Map();
let refusedStores = 0;
class ProtocolFailure extends Error {}
const resolveSql = (stmt) => {
if (typeof stmt.sql === "string") return stmt.sql;
const text = stored.get(stmt.sql_id);
if (text === undefined) throw new ProtocolFailure(`SQL text ${stmt.sql_id} not found`);
return text;
};
const emptyResult = () => ({
cols: [], rows: [], affected_row_count: 0, last_insert_rowid: null,
replication_index: null, rows_read: 0, rows_written: 0, query_duration_ms: 0,
});
const fetchImpl = async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init);
const path = new URL(request.url).pathname;
// Decline the protobuf endpoint so the client negotiates JSON Hrana v2.
if (!path.endsWith("/v2/pipeline")) return new Response("not found", { status: 404 });
const body = await request.json();
const results = [];
try {
for (const entry of body.requests) {
if (entry.type === "store_sql") {
if (Buffer.byteLength(String(entry.sql), "utf8") > SQLD_STORED_SQL_BYTE_CAP) {
refusedStores += 1; // sqld bails here; the id is never registered
} else {
stored.set(entry.sql_id, entry.sql);
}
results.push({ type: "ok", response: { type: "store_sql" } });
} else if (entry.type === "close_sql") {
stored.delete(entry.sql_id);
results.push({ type: "ok", response: { type: "close_sql" } });
} else if (entry.type === "execute") {
resolveSql(entry.stmt);
results.push({ type: "ok", response: { type: "execute", result: emptyResult() } });
} else if (entry.type === "batch") {
const steps = (entry.batch.steps ?? []).map((step) => { resolveSql(step.stmt); return emptyResult(); });
results.push({
type: "ok",
response: { type: "batch", result: { step_results: steps, step_errors: steps.map(() => null) } },
});
} else {
results.push({ type: "ok", response: { type: entry.type } });
}
}
} catch (error) {
if (error instanceof ProtocolFailure) {
return new Response(error.message, { status: 400, headers: { "content-type": "text/plain" } });
}
throw error;
}
return Response.json({ baton: "baton", base_url: null, results });
};
const client = createClient({
url: "https://namespace.example.invalid",
authToken: "token",
fetch: fetchImpl,
});
const tx = await client.transaction("write");
try {
// The first batch stores the oversized SQL (server refuses); the second
// reuses the cached id (server has nothing under that id).
await tx.batch([{ sql: SQL, args: [] }]);
await tx.batch([{ sql: SQL, args: [] }]);
await tx.commit();
console.log("\nRESULT: no error — the defect did not reproduce");
} catch (error) {
console.log("\nRESULT: failed as reported");
console.log(" refused store_sql requests:", refusedStores);
console.log(" error:", String(error.message ?? error));
} finally {
client.close();
}
How we hit it
Restoring a logical SQL dump into a namespace holding translated (Japanese) prose failed deterministically, always at the same statement, with SERVER_ERROR: Server returned HTTP status 400: SQL text 0 not found. The offending statement measured 4,218 characters / 8,618 bytes — comfortably under the client's guard, well over the server's.
Two things made this expensive to diagnose, and both are worth knowing if you try to reproduce it:
- Statement size alone does not reproduce it. A synthetic 9,007-character ASCII statement replays cleanly, because past 5,000 characters the client's guard correctly leaves the text inline. You need multibyte text sized between the two measures.
- The reported
sql_idis misleading. It reflects whichever LRU slot was recycled, not the failing statement.
Suggested fix
Measure the guard in the same units the server does:
if (new TextEncoder().encode(sqlText).length >= 5000) {
continue;
}
Verified: patching this single line in @libsql/client@0.17.4 makes the repro above pass — the oversized statement is left inline and the batch succeeds.
TextEncoder is available on every runtime this package targets. If allocating a byte array per statement on the hot path is a concern, a cheap pre-filter preserves the current fast path for ASCII, since UTF-8 never expands a JS string beyond 3 bytes per code unit:
// Fast path: cannot possibly exceed the cap.
if (sqlText.length * 3 >= 5000 && new TextEncoder().encode(sqlText).length >= 5000) {
continue;
}
(Incidentally, the client's >= 5000 is one byte stricter than the server's > 5000. That direction is safe, so it is only worth aligning if you touch the line anyway.)
Two optional follow-ups, both independent of the units fix:
- Surface the refused
store_sqlerror to the caller instead of only into_setClosed, so the application seesThe statement is too large to be storedrather than a downstream missing-id 400. - Include the byte size in the client-side skip decision's reasoning, or document the cap's units in the comment above the guard — the current comment says "5kb" without saying which measure.
Environment
@libsql/client0.17.4(also reproduced on0.15.9); guard unchanged onmain- Node.js v22.22.3, macOS (arm64)
- Server: sqld with
--enable-namespaces, HTTP transport, JSON Hrana v2 pipeline
By Merlin (rbeckner.com) and his AI agent (Claude Code, Claude Fable 5, thinking on: high).
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in packages/libsql-client/src/sql_cache.ts at the stored-SQL size guard and compare its measurement with the server behavior described in the issue. Run the self-contained repro.mjs to confirm the multibyte-text failure. Done means oversized SQL is left inline rather than cached, and the repro succeeds without the missing-id error.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- databases
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 84/100