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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/cloud-3485-turso-remote-balanced-group.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@objectstack/driver-turso': patch
'@objectstack/driver-sql': patch
---

fix(driver-turso): the remote face joins a large `$and` / `$or` as balanced halves, so comment and attachment reads fit a hosted libSQL server's depth limit

Clause-②: no
Metadata-protocol: none

On a remote Turso / libSQL database (`TursoDriver` with a `libsql://`, `https://` or `wss://` url and no `syncUrl`, as every hosted environment runs), every read of `sys_comment` and `sys_attachment` answered `500 DATABASE_ERROR` once the deployment registered roughly a hundred objects, as an installed app does. The driver's log named the cause: `Expression tree is too large (maximum depth 100)`. The comment and attachment read gates OR one `ReadableIds` branch per registered parent object, and the remote face's compiler joined a group's members into one flat chain, one expression level per member, while the hosted server refuses a tree deeper than 100.

The local face (`SqlDriver`) has emitted such groups as balanced halves since #29888. The remote face compiles its SQL text itself, so that change never reached it. It now regroups by the same rule, shared from `@objectstack/driver-sql` as `joinBalancedGroup` (with `FLAT_GROUP_MAX_MEMBERS`): a group of at most 8 members compiles byte-identical to before, and a larger one as two parenthesised halves, recursively, so the depth it adds grows with the logarithm of its size. Measured on the gate's own shape with 134 branches and a three-level master chain: 162 levels before, 38 after.

The regrouped expression selects exactly the rows the flat one did, on every dialect: `AND` and `OR` are associative over SQL's three-valued logic, the members keep their order, and so do their bind values. No gate, scope or tenant wall changes.
5 changes: 5 additions & 0 deletions packages/drivers/driver-sql/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ export { withheldFilterDiagnosticOf } from './sql-driver.js';
// rewrites. Exported because the Turso remote backfill applies the same rule to
// its own residue, and a second copy there is how the two faces would drift.
export { recoverUnencodedJsonText } from './unencoded-json-text.js';
// [cloud#3485] How a large `$and` / `$or` group is emitted so the expression
// tree it parses to stays shallow (#29888). Exported because the Turso remote
// face compiles SQL text with its own compiler, and must regroup by the same
// rule the knex path does.
export { FLAT_GROUP_MAX_MEMBERS, joinBalancedGroup } from './sql-driver-balanced-group.js';
// [#11991] The #11756 emission-scope refusal. Exported because a host that
// renders its own diagnostics (Studio, `os migrate plan`, an installer) needs
// the structured `client` / `supportedClients` and the stable `code` — the
Expand Down
27 changes: 27 additions & 0 deletions packages/drivers/driver-sql/src/sql-driver-balanced-group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,30 @@ export function emitBalancedGroup<B, M>(
into[attach]((subQb) => emitBalancedGroup(subQb, members.slice(0, half), attach, emit));
into[attach]((subQb) => emitBalancedGroup(subQb, members.slice(half), attach, emit));
}

/**
* [cloud#3485] The same regrouping, for a compiler that writes SQL TEXT rather
* than driving a query builder: `TursoDriver`'s remote face
* (`RemoteTransport`), which compiles every hosted environment's reads itself
* and so never passed through {@link emitBalancedGroup}. One rule, both faces:
* a second copy of it in that package is how the two would drift.
*
* `members` are the group's compiled members, each a self-contained boolean
* fragment the caller has already parenthesised; `joiner` is the operator the
* group's members are joined with. At most {@link FLAT_GROUP_MAX_MEMBERS}
* members are joined flat, byte-identical to `members.join(joiner)`; above that
* the two halves are joined once, each in its own parentheses, recursively.
*
* The members keep their order, so the bind values a caller collected in
* member order still line up with the placeholders the text holds. An empty
* list joins to `''`: the caller decides what an empty group means.
*/
export function joinBalancedGroup(members: readonly string[], joiner: ' AND ' | ' OR '): string {
if (members.length <= FLAT_GROUP_MAX_MEMBERS) return members.join(joiner);
const half = Math.ceil(members.length / 2);
return (
`(${joinBalancedGroup(members.slice(0, half), joiner)})` +
joiner +
`(${joinBalancedGroup(members.slice(half), joiner)})`
);
}
20 changes: 18 additions & 2 deletions packages/drivers/driver-turso/src/remote-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,12 @@ import {
type DeclaredIndexInput,
type ExpectedIndex,
} from '@objectstack/driver-sql';
// [cloud#3485] A large `$and` / `$or` group is joined as balanced halves, so the
// expression tree SQLite parses stays shallow: the rule the local face's knex
// path applies (#29888), shared, never copied. This face compiles every hosted
// environment's reads, and a flat chain of one parent-record gate branch per
// registered object passed the hosted libSQL server's depth limit of 100.
import { joinBalancedGroup } from '@objectstack/driver-sql';
import { nanoid } from 'nanoid';

/**
Expand Down Expand Up @@ -3251,7 +3257,10 @@ export class RemoteTransport {
subArgs.push(...sa);
}
if (subClauses.length > 0) {
clauses.push(`(${subClauses.join(' AND ')})`);
// [cloud#3485] Balanced halves above FLAT_GROUP_MAX_MEMBERS members,
// byte-identical flat at or below it; members keep their order, so
// `subArgs` still line up with the placeholders.
clauses.push(`(${joinBalancedGroup(subClauses, ' AND ')})`);
args.push(...subArgs);
}
// Every conjunct TRUE (`$and: []`, `$and: [{}]`) → the conjunction is
Expand Down Expand Up @@ -3286,7 +3295,14 @@ export class RemoteTransport {
// whole-table write.
clauses.push(SQL_FALSE);
} else {
clauses.push(`(${subClauses.join(' OR ')})`);
// [cloud#3485] Balanced halves above FLAT_GROUP_MAX_MEMBERS members,
// byte-identical flat at or below it. The parent-record read gates
// (sys_comment, sys_attachment) OR one `ReadableIds` branch per
// registered parent object: joined flat, that chain was one level
// per parent, and a hosted environment with an app installed passed
// the server's limit of 100 on every read of those objects. Members
// keep their order, so `subArgs` still line up with the placeholders.
clauses.push(`(${joinBalancedGroup(subClauses, ' OR ')})`);
args.push(...subArgs);
}
} else if (key === '$not') {
Expand Down
Loading
Loading