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
36 changes: 36 additions & 0 deletions .changeset/turso-remote-aggregation-alias-escaped.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
---
"@objectstack/driver-turso": patch
---

fix(driver-turso): escape the aggregation alias instead of gating it, so remote-mode analytics cube queries stop 500ing (#14113)

`RemoteTransport.aggregate` (Turso **remote** mode) held the aggregation
`alias` to `SAFE_IDENTIFIER` (`/^[a-zA-Z_][a-zA-Z0-9_]*$/`). A dot fails that
regex. Every analytics measure is named `<cube>.<measure>` on the wire and
`ObjectQLStrategy` uses that name verbatim as the aggregation `alias`, so
**every** cube query that reached this face threw
`RemoteTransport: unsafe identifier rejected: "showcase_delivery.count"` — a
bare `Error` with no `code` and no `status`, which `mapDataError` then served
as an opaque 500 for a query that is spelled correctly.

The alias is now **escaped rather than gated**: it may be any string, and the
quote character is doubled (`"` → `""`), the standard escape inside a quoted
SQL identifier. This is the ALIAS half of the distinction `driver-sql` drew at
**#13714**, where the same position routes through knex's `wrapIdentifier`
(`SqlDriver.aliasIdentifierSql`) — a qualified **reference** must be
validated, a single output **name** must be quoted and escaped.
`AggregationNodeSchema` declares `alias: z.string()`, an output-column key,
and the in-memory, MongoDB and (post-#13714) SQL faces all project it
verbatim; this face was the outlier.

⛔ **Not** "drop the check". The alias reaches the statement raw inside
`AS "…"`, so an alias containing a `"` would close the quoting and continue as
grammar. `bucket"; DROP TABLE deal; --` now compiles to the single inert
column name `"bucket""; DROP TABLE deal; --"` and is returned as a column
name, executed against a real SQLite-backed client rather than asserted as a
string — the only instrument that tells "escaped" apart from "broke out".

The `field` and `object` positions keep `assertSafeIdentifier` unchanged: those
become column and table **references**, which are grammar. Default aliases are
byte-identical (`count_all` still spells itself the same way), and the
`groupBy` alias position is untouched by this change.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#14113] The aggregation ALIAS is escaped, not gated — TursoDriver's REMOTE
* transport.
*
* ## The defect
*
* `RemoteTransport.aggregate` held the aggregation `alias` to
* `SAFE_IDENTIFIER` (`/^[a-zA-Z_][a-zA-Z0-9_]*$/`). A dot fails that regex.
* Every analytics measure is named `<cube>.<measure>` on the wire and
* `ObjectQLStrategy` uses that name verbatim as the aggregation `alias`
* (`objectql-strategy.ts`, `{ field, method, alias: measure }`), so EVERY cube
* query that reached this face threw:
*
* ```
* RemoteTransport: unsafe identifier rejected: "showcase_delivery.count"
* ```
*
* a bare `Error` with no `code` and no `status`, which `mapDataError` then
* serves as an opaque 500 — the #11455 / #8931 shape.
*
* ## Why the fix is escaping and NOT dropping the check
*
* The alias is interpolated RAW into `AS "${alias}"`, so an alias containing a
* `"` would close the quoting and continue as grammar. The repair is the
* standard doubled-quote escape inside a quoted SQL identifier (`"` → `""`) —
* the ALIAS half of the distinction #13714 drew one face over, where
* `SqlDriver.aliasIdentifierSql` routes the same position through knex's
* `wrapIdentifier`. A qualified REFERENCE must be validated; a single output
* NAME must be quoted and escaped. `AggregationNodeSchema` declares
* `alias: z.string()` — an output-column key — and the in-memory, MongoDB and
* (post-#13714) SQL faces all project it verbatim. This face was the outlier.
*
* ## Why a SQLite-backed client stub rather than a mocked `execute`
*
* Only EXECUTING the statement tells "escaped" apart from "broke out". A
* string assertion alone would pass on an alias that terminates the quoting,
* because the text still *looks* like a select list. libsql IS SQLite, so
* `makeLibsqlSqliteStub` runs what this transport emits: an alias that escaped
* its quoting is a syntax error (or a second statement better-sqlite3 refuses
* to prepare), and a green read of the value back under the literal alias is
* the proof. The emitted SQL is pinned too, so a future reader can see the
* doubled quote rather than infer it.
*
* ## Reverse verification — direction predicted BEFORE it was run
*
* Restore `this.assertSafeIdentifier(alias)` above the `selectParts.push` and
* emit `AS "${alias}"` again (the pre-#14113 two lines):
*
* - the dotted-alias cases (repro, emitted SQL, the un-bucketed cube shape)
* go RED by THROWING inside the call — `unsafe identifier rejected:
* "showcase_delivery.count"` — not on a comparison.
* - the quote-escape cases go RED by throwing the same sentence, naming
* `won"count` / the `DROP TABLE` text. They cannot go red on a broken-out
* statement, because the restored guard refuses that input before any SQL is
* built — which is exactly why the guard could not simply be deleted.
* - the `field`-position control and the groupBy-alias control stay GREEN:
* neither position is touched by this card, and that is what they are here
* to hold.
* - the default-alias case stays GREEN: `count_all` passes `SAFE_IDENTIFIER`
* either way, so it pins the byte-identical emission across the change.
*
* Measured after writing the above — see the PR body for the run.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { TursoDriver } from './turso-driver.js';
import { makeLibsqlSqliteStub, asLibsqlClient, type LibsqlSqliteStub } from './libsql-sqlite-stub.testkit.js';

/**
* Named for the cube in the field report the card was filed from, so the alias
* under test (`showcase_delivery.count`) is the real wire spelling rather than
* a stand-in.
*/
const DELIVERY_OBJECT = {
name: 'showcase_delivery',
fields: {
id: { type: 'string' },
region: { type: 'string' },
amount: { type: 'number' },
},
};

const ROWS = [
{ id: '1', region: 'west', amount: 10 },
{ id: '2', region: 'west', amount: 20 },
{ id: '3', region: 'east', amount: 30 },
];

describe('[#14113] RemoteTransport — the aggregation alias is escaped, not gated', () => {
let driver: TursoDriver;
let stub: LibsqlSqliteStub;

beforeAll(async () => {
stub = makeLibsqlSqliteStub();
driver = new TursoDriver({ url: 'libsql://alias-quoting.turso.io', client: asLibsqlClient(stub) });
await driver.connect();
// The mode this suite is about — the one with its own hand-written SQL.
expect(driver.transportMode).toBe('remote');
await driver.syncSchema(DELIVERY_OBJECT.name, DELIVERY_OBJECT);
for (const row of ROWS) await driver.create(DELIVERY_OBJECT.name, { ...row });
});

afterAll(async () => {
await driver.disconnect();
stub.close();
});

/**
* A transport backed by the same database, capturing the statements it sends
* AND executing them — the capture alone would not prove the statement runs.
*/
const capturing = async () => {
const seen: string[] = [];
const spy = {
...stub,
execute: async (stmt: unknown) => {
seen.push((stmt as { sql: string }).sql);
return stub.execute(stmt);
},
};
const t = new TursoDriver({ url: 'libsql://alias-quoting.turso.io', client: asLibsqlClient(spy) });
await t.connect();
return { t, seen };
};

describe('direction 1 — a dotted `CUBE.MEASURE` alias now reaches the database', () => {
it('the exact alias the card measured is served, on rows', async () => {
const rows = await driver.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
aggregations: [{ function: 'count', alias: 'showcase_delivery.count' }],
} as never);
// The value comes back under the caller's own key — a dot is inert
// inside a quoted identifier, which is the whole claim of this card.
expect(rows).toHaveLength(1);
expect((rows as Array<Record<string, unknown>>)[0]['showcase_delivery.count']).toBe(3);
});

it('compiles to ONE quoted identifier, dot and all', async () => {
const { t, seen } = await capturing();
await t.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
aggregations: [{ function: 'count', alias: 'showcase_delivery.count' }],
} as never);
expect(seen).toEqual([
'SELECT count(*) AS "showcase_delivery.count" FROM "showcase_delivery"',
]);
// ⛔ Not two segments. The failure this replaces is a face that treats an
// alias as a qualified reference; the dot must stay INSIDE the quotes.
expect(seen[0]).not.toContain('"showcase_delivery"."count"');
});

it('the un-bucketed cube shape — a grouped measure — is served end to end', async () => {
// The path that actually reaches `driver.aggregate`: remote mode
// publishes `queryDateGranularity: {}` (see `TursoDriver.supports`), so a
// BUCKETED query falls back to `find()` + in-memory bucketing and never
// arrives here. The UN-bucketed cube query is the one that ate the
// refusal, and it carries a dimension in `groupBy` beside the measure.
//
// ⭐ The groupBy FIELD is a bare column name here, not a dotted one, and
// that is measured rather than assumed: `ObjectQLStrategy.resolveFieldName`
// resolves a dimension to `member.sql` or `member.split('.')[1]`, so only
// the MEASURE arrives dotted. That asymmetry is why this card is confined
// to the alias position of `aggregations`.
const rows = await driver.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
groupBy: ['region'],
aggregations: [
{ function: 'count', alias: 'showcase_delivery.count' },
{ function: 'sum', field: 'amount', alias: 'showcase_delivery.total_amount' },
],
} as never);
const byRegion = Object.fromEntries(
(rows as Array<Record<string, unknown>>).map((r) => [
r.region,
[r['showcase_delivery.count'], r['showcase_delivery.total_amount']],
]),
);
expect(byRegion).toEqual({ west: [2, 30], east: [1, 30] });
});
});

describe('direction 2 — an alias carrying a `"` is ESCAPED, not let through', () => {
it('doubles the quote and still runs, returning the value under the literal alias', async () => {
const alias = 'won"count';
const { t, seen } = await capturing();
const rows = await t.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
aggregations: [{ function: 'count', alias }],
} as never);
expect(seen).toEqual(['SELECT count(*) AS "won""count" FROM "showcase_delivery"']);
// Executed, not merely emitted: an alias that broke out of its quoting
// would be a syntax error here rather than a row.
expect((rows as Array<Record<string, unknown>>)[0][alias]).toBe(3);
});

it('an alias that tries to close the quoting and append a statement stays one name', async () => {
const alias = 'bucket"; DROP TABLE showcase_delivery; --';
const { t, seen } = await capturing();
const rows = await t.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
aggregations: [{ function: 'count', alias }],
} as never);
expect(seen).toEqual([
'SELECT count(*) AS "bucket""; DROP TABLE showcase_delivery; --" FROM "showcase_delivery"',
]);
// The whole payload came back as a COLUMN NAME — it was data, never
// grammar.
expect((rows as Array<Record<string, unknown>>)[0][alias]).toBe(3);
// And the table it named is still there, with every row.
expect(
stub.raw.prepare('select count(*) as c from showcase_delivery').all(),
).toEqual([{ c: 3 }]);
});
});

describe('regression controls — the positions this card did NOT touch', () => {
it('the `field` position still refuses an unsafe identifier, and sends nothing', async () => {
// `SAFE_IDENTIFIER` is doing real work here: `field` becomes a column
// REFERENCE, which is grammar. Escaping is the answer for a NAME only.
const { t, seen } = await capturing();
const err = await t
.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
aggregations: [{ function: 'sum', field: 'amount"; DROP TABLE showcase_delivery; --', alias: 'n' }],
} as never)
.then(
() => { throw new Error('expected the transport to refuse an unsafe field'); },
(e) => e as Error,
);
// The OFFENDING TEXT, not just the sentence (#6144) — an alias that is
// itself safe is what makes this case reach the `field` check at all.
expect(err.message).toContain('unsafe identifier rejected');
expect(err.message).toContain('amount"; DROP TABLE showcase_delivery; --');
expect(seen).toEqual([]);
});

it('the `object` position still refuses an unsafe identifier', async () => {
const { t, seen } = await capturing();
const err = await t
.aggregate('showcase_delivery"; DROP TABLE showcase_delivery; --', {
object: 'showcase_delivery"; DROP TABLE showcase_delivery; --',
aggregations: [{ function: 'count', alias: 'n' }],
} as never)
.then(
() => { throw new Error('expected the transport to refuse an unsafe object') },
(e) => e as Error,
);
expect(err.message).toContain('unsafe identifier rejected');
expect(seen).toEqual([]);
});

it('the groupBy alias position is UNCHANGED — still refused, and that is a separate card', async () => {
// ⚠️ Deliberate scope line, pinned so it cannot drift silently. The
// groupBy `alias` is the same class of thing (an output NAME) and
// `driver-sql` escapes it post-#13714 (`aliasIdentifierSql` at its
// groupBy select site), so this face diverges there too — but that
// position carries a LANDED pin (#6401, `remote-transport-groupby-node`)
// asserting the refusal, so reversing it is a judgement this card was not
// dispatched to make. Filed separately rather than patched inline; this
// control records the state it was left in.
//
// It is also NOT on the reproducing path: `ObjectQLStrategy` resolves a
// dimension to a bare column name, so no analytics query sends a dotted
// groupBy alias.
const { t, seen } = await capturing();
const err = await t
.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
groupBy: [{ field: 'region', alias: 'showcase_delivery.region' }],
aggregations: [{ function: 'count', alias: 'showcase_delivery.count' }],
} as never)
.then(
() => { throw new Error('expected the groupBy alias to still be refused') },
(e) => e as Error,
);
expect(err.message).toContain('unsafe identifier rejected');
expect(err.message).toContain('showcase_delivery.region');
expect(seen).toEqual([]);
});

it('the default alias is byte-identical to what it was before', async () => {
// A caller who omits `alias` reads the result under `count_all` exactly
// as they did pre-#14113 — this change moves no default.
const { t, seen } = await capturing();
const rows = await t.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
aggregations: [{ function: 'count' }],
} as never);
expect(seen).toEqual(['SELECT count(*) AS "count_all" FROM "showcase_delivery"']);
expect((rows as Array<Record<string, unknown>>)[0].count_all).toBe(3);
});
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
36 changes: 36 additions & 0 deletions .changeset/turso-remote-aggregation-alias-escaped.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
---
"@objectstack/driver-turso": patch
---

fix(driver-turso): escape the aggregation alias instead of gating it, so remote-mode analytics cube queries stop 500ing (#14113)

`RemoteTransport.aggregate` (Turso **remote** mode) held the aggregation
`alias` to `SAFE_IDENTIFIER` (`/^[a-zA-Z_][a-zA-Z0-9_]*$/`). A dot fails that
regex. Every analytics measure is named `<cube>.<measure>` on the wire and
`ObjectQLStrategy` uses that name verbatim as the aggregation `alias`, so
**every** cube query that reached this face threw
`RemoteTransport: unsafe identifier rejected: "showcase_delivery.count"` — a
bare `Error` with no `code` and no `status`, which `mapDataError` then served
as an opaque 500 for a query that is spelled correctly.

The alias is now **escaped rather than gated**: it may be any string, and the
quote character is doubled (`"` → `""`), the standard escape inside a quoted
SQL identifier. This is the ALIAS half of the distinction `driver-sql` drew at
**#13714**, where the same position routes through knex's `wrapIdentifier`
(`SqlDriver.aliasIdentifierSql`) — a qualified **reference** must be
validated, a single output **name** must be quoted and escaped.
`AggregationNodeSchema` declares `alias: z.string()`, an output-column key,
and the in-memory, MongoDB and (post-#13714) SQL faces all project it
verbatim; this face was the outlier.

⛔ **Not** "drop the check". The alias reaches the statement raw inside
`AS "…"`, so an alias containing a `"` would close the quoting and continue as
grammar. `bucket"; DROP TABLE deal; --` now compiles to the single inert
column name `"bucket""; DROP TABLE deal; --"` and is returned as a column
name, executed against a real SQLite-backed client rather than asserted as a
string — the only instrument that tells "escaped" apart from "broke out".

The `field` and `object` positions keep `assertSafeIdentifier` unchanged: those
become column and table **references**, which are grammar. Default aliases are
byte-identical (`count_all` still spells itself the same way), and the
`groupBy` alias position is untouched by this change.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#14113] The aggregation ALIAS is escaped, not gated — TursoDriver's REMOTE
* transport.
*
* ## The defect
*
* `RemoteTransport.aggregate` held the aggregation `alias` to
* `SAFE_IDENTIFIER` (`/^[a-zA-Z_][a-zA-Z0-9_]*$/`). A dot fails that regex.
* Every analytics measure is named `<cube>.<measure>` on the wire and
* `ObjectQLStrategy` uses that name verbatim as the aggregation `alias`
* (`objectql-strategy.ts`, `{ field, method, alias: measure }`), so EVERY cube
* query that reached this face threw:
*
* ```
* RemoteTransport: unsafe identifier rejected: "showcase_delivery.count"
* ```
*
* a bare `Error` with no `code` and no `status`, which `mapDataError` then
* serves as an opaque 500 — the #11455 / #8931 shape.
*
* ## Why the fix is escaping and NOT dropping the check
*
* The alias is interpolated RAW into `AS "${alias}"`, so an alias containing a
* `"` would close the quoting and continue as grammar. The repair is the
* standard doubled-quote escape inside a quoted SQL identifier (`"` → `""`) —
* the ALIAS half of the distinction #13714 drew one face over, where
* `SqlDriver.aliasIdentifierSql` routes the same position through knex's
* `wrapIdentifier`. A qualified REFERENCE must be validated; a single output
* NAME must be quoted and escaped. `AggregationNodeSchema` declares
* `alias: z.string()` — an output-column key — and the in-memory, MongoDB and
* (post-#13714) SQL faces all project it verbatim. This face was the outlier.
*
* ## Why a SQLite-backed client stub rather than a mocked `execute`
*
* Only EXECUTING the statement tells "escaped" apart from "broke out". A
* string assertion alone would pass on an alias that terminates the quoting,
* because the text still *looks* like a select list. libsql IS SQLite, so
* `makeLibsqlSqliteStub` runs what this transport emits: an alias that escaped
* its quoting is a syntax error (or a second statement better-sqlite3 refuses
* to prepare), and a green read of the value back under the literal alias is
* the proof. The emitted SQL is pinned too, so a future reader can see the
* doubled quote rather than infer it.
*
* ## Reverse verification — direction predicted BEFORE it was run
*
* Restore `this.assertSafeIdentifier(alias)` above the `selectParts.push` and
* emit `AS "${alias}"` again (the pre-#14113 two lines):
*
* - the dotted-alias cases (repro, emitted SQL, the un-bucketed cube shape)
* go RED by THROWING inside the call — `unsafe identifier rejected:
* "showcase_delivery.count"` — not on a comparison.
* - the quote-escape cases go RED by throwing the same sentence, naming
* `won"count` / the `DROP TABLE` text. They cannot go red on a broken-out
* statement, because the restored guard refuses that input before any SQL is
* built — which is exactly why the guard could not simply be deleted.
* - the `field`-position control and the groupBy-alias control stay GREEN:
* neither position is touched by this card, and that is what they are here
* to hold.
* - the default-alias case stays GREEN: `count_all` passes `SAFE_IDENTIFIER`
* either way, so it pins the byte-identical emission across the change.
*
* Measured after writing the above — see the PR body for the run.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { TursoDriver } from './turso-driver.js';
import { makeLibsqlSqliteStub, asLibsqlClient, type LibsqlSqliteStub } from './libsql-sqlite-stub.testkit.js';

/**
* Named for the cube in the field report the card was filed from, so the alias
* under test (`showcase_delivery.count`) is the real wire spelling rather than
* a stand-in.
*/
const DELIVERY_OBJECT = {
name: 'showcase_delivery',
fields: {
id: { type: 'string' },
region: { type: 'string' },
amount: { type: 'number' },
},
};

const ROWS = [
{ id: '1', region: 'west', amount: 10 },
{ id: '2', region: 'west', amount: 20 },
{ id: '3', region: 'east', amount: 30 },
];

describe('[#14113] RemoteTransport — the aggregation alias is escaped, not gated', () => {
let driver: TursoDriver;
let stub: LibsqlSqliteStub;

beforeAll(async () => {
stub = makeLibsqlSqliteStub();
driver = new TursoDriver({ url: 'libsql://alias-quoting.turso.io', client: asLibsqlClient(stub) });
await driver.connect();
// The mode this suite is about — the one with its own hand-written SQL.
expect(driver.transportMode).toBe('remote');
await driver.syncSchema(DELIVERY_OBJECT.name, DELIVERY_OBJECT);
for (const row of ROWS) await driver.create(DELIVERY_OBJECT.name, { ...row });
});

afterAll(async () => {
await driver.disconnect();
stub.close();
});

/**
* A transport backed by the same database, capturing the statements it sends
* AND executing them — the capture alone would not prove the statement runs.
*/
const capturing = async () => {
const seen: string[] = [];
const spy = {
...stub,
execute: async (stmt: unknown) => {
seen.push((stmt as { sql: string }).sql);
return stub.execute(stmt);
},
};
const t = new TursoDriver({ url: 'libsql://alias-quoting.turso.io', client: asLibsqlClient(spy) });
await t.connect();
return { t, seen };
};

describe('direction 1 — a dotted `CUBE.MEASURE` alias now reaches the database', () => {
it('the exact alias the card measured is served, on rows', async () => {
const rows = await driver.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
aggregations: [{ function: 'count', alias: 'showcase_delivery.count' }],
} as never);
// The value comes back under the caller's own key — a dot is inert
// inside a quoted identifier, which is the whole claim of this card.
expect(rows).toHaveLength(1);
expect((rows as Array<Record<string, unknown>>)[0]['showcase_delivery.count']).toBe(3);
});

it('compiles to ONE quoted identifier, dot and all', async () => {
const { t, seen } = await capturing();
await t.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
aggregations: [{ function: 'count', alias: 'showcase_delivery.count' }],
} as never);
expect(seen).toEqual([
'SELECT count(*) AS "showcase_delivery.count" FROM "showcase_delivery"',
]);
// ⛔ Not two segments. The failure this replaces is a face that treats an
// alias as a qualified reference; the dot must stay INSIDE the quotes.
expect(seen[0]).not.toContain('"showcase_delivery"."count"');
});

it('the un-bucketed cube shape — a grouped measure — is served end to end', async () => {
// The path that actually reaches `driver.aggregate`: remote mode
// publishes `queryDateGranularity: {}` (see `TursoDriver.supports`), so a
// BUCKETED query falls back to `find()` + in-memory bucketing and never
// arrives here. The UN-bucketed cube query is the one that ate the
// refusal, and it carries a dimension in `groupBy` beside the measure.
//
// ⭐ The groupBy FIELD is a bare column name here, not a dotted one, and
// that is measured rather than assumed: `ObjectQLStrategy.resolveFieldName`
// resolves a dimension to `member.sql` or `member.split('.')[1]`, so only
// the MEASURE arrives dotted. That asymmetry is why this card is confined
// to the alias position of `aggregations`.
const rows = await driver.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
groupBy: ['region'],
aggregations: [
{ function: 'count', alias: 'showcase_delivery.count' },
{ function: 'sum', field: 'amount', alias: 'showcase_delivery.total_amount' },
],
} as never);
const byRegion = Object.fromEntries(
(rows as Array<Record<string, unknown>>).map((r) => [
r.region,
[r['showcase_delivery.count'], r['showcase_delivery.total_amount']],
]),
);
expect(byRegion).toEqual({ west: [2, 30], east: [1, 30] });
});
});

describe('direction 2 — an alias carrying a `"` is ESCAPED, not let through', () => {
it('doubles the quote and still runs, returning the value under the literal alias', async () => {
const alias = 'won"count';
const { t, seen } = await capturing();
const rows = await t.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
aggregations: [{ function: 'count', alias }],
} as never);
expect(seen).toEqual(['SELECT count(*) AS "won""count" FROM "showcase_delivery"']);
// Executed, not merely emitted: an alias that broke out of its quoting
// would be a syntax error here rather than a row.
expect((rows as Array<Record<string, unknown>>)[0][alias]).toBe(3);
});

it('an alias that tries to close the quoting and append a statement stays one name', async () => {
const alias = 'bucket"; DROP TABLE showcase_delivery; --';
const { t, seen } = await capturing();
const rows = await t.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
aggregations: [{ function: 'count', alias }],
} as never);
expect(seen).toEqual([
'SELECT count(*) AS "bucket""; DROP TABLE showcase_delivery; --" FROM "showcase_delivery"',
]);
// The whole payload came back as a COLUMN NAME — it was data, never
// grammar.
expect((rows as Array<Record<string, unknown>>)[0][alias]).toBe(3);
// And the table it named is still there, with every row.
expect(
stub.raw.prepare('select count(*) as c from showcase_delivery').all(),
).toEqual([{ c: 3 }]);
});
});

describe('regression controls — the positions this card did NOT touch', () => {
it('the `field` position still refuses an unsafe identifier, and sends nothing', async () => {
// `SAFE_IDENTIFIER` is doing real work here: `field` becomes a column
// REFERENCE, which is grammar. Escaping is the answer for a NAME only.
const { t, seen } = await capturing();
const err = await t
.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
aggregations: [{ function: 'sum', field: 'amount"; DROP TABLE showcase_delivery; --', alias: 'n' }],
} as never)
.then(
() => { throw new Error('expected the transport to refuse an unsafe field'); },
(e) => e as Error,
);
// The OFFENDING TEXT, not just the sentence (#6144) — an alias that is
// itself safe is what makes this case reach the `field` check at all.
expect(err.message).toContain('unsafe identifier rejected');
expect(err.message).toContain('amount"; DROP TABLE showcase_delivery; --');
expect(seen).toEqual([]);
});

it('the `object` position still refuses an unsafe identifier', async () => {
const { t, seen } = await capturing();
const err = await t
.aggregate('showcase_delivery"; DROP TABLE showcase_delivery; --', {
object: 'showcase_delivery"; DROP TABLE showcase_delivery; --',
aggregations: [{ function: 'count', alias: 'n' }],
} as never)
.then(
() => { throw new Error('expected the transport to refuse an unsafe object') },
(e) => e as Error,
);
expect(err.message).toContain('unsafe identifier rejected');
expect(seen).toEqual([]);
});

it('the groupBy alias position is UNCHANGED — still refused, and that is a separate card', async () => {
// ⚠️ Deliberate scope line, pinned so it cannot drift silently. The
// groupBy `alias` is the same class of thing (an output NAME) and
// `driver-sql` escapes it post-#13714 (`aliasIdentifierSql` at its
// groupBy select site), so this face diverges there too — but that
// position carries a LANDED pin (#6401, `remote-transport-groupby-node`)
// asserting the refusal, so reversing it is a judgement this card was not
// dispatched to make. Filed separately rather than patched inline; this
// control records the state it was left in.
//
// It is also NOT on the reproducing path: `ObjectQLStrategy` resolves a
// dimension to a bare column name, so no analytics query sends a dotted
// groupBy alias.
const { t, seen } = await capturing();
const err = await t
.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
groupBy: [{ field: 'region', alias: 'showcase_delivery.region' }],
aggregations: [{ function: 'count', alias: 'showcase_delivery.count' }],
} as never)
.then(
() => { throw new Error('expected the groupBy alias to still be refused') },
(e) => e as Error,
);
expect(err.message).toContain('unsafe identifier rejected');
expect(err.message).toContain('showcase_delivery.region');
expect(seen).toEqual([]);
});

it('the default alias is byte-identical to what it was before', async () => {
// A caller who omits `alias` reads the result under `count_all` exactly
// as they did pre-#14113 — this change moves no default.
const { t, seen } = await capturing();
const rows = await t.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
aggregations: [{ function: 'count' }],
} as never);
expect(seen).toEqual(['SELECT count(*) AS "count_all" FROM "showcase_delivery"']);
expect((rows as Array<Record<string, unknown>>)[0].count_all).toBe(3);
});
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
36 changes: 36 additions & 0 deletions .changeset/turso-remote-aggregation-alias-escaped.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
---
"@objectstack/driver-turso": patch
---

fix(driver-turso): escape the aggregation alias instead of gating it, so remote-mode analytics cube queries stop 500ing (#14113)

`RemoteTransport.aggregate` (Turso **remote** mode) held the aggregation
`alias` to `SAFE_IDENTIFIER` (`/^[a-zA-Z_][a-zA-Z0-9_]*$/`). A dot fails that
regex. Every analytics measure is named `<cube>.<measure>` on the wire and
`ObjectQLStrategy` uses that name verbatim as the aggregation `alias`, so
**every** cube query that reached this face threw
`RemoteTransport: unsafe identifier rejected: "showcase_delivery.count"` — a
bare `Error` with no `code` and no `status`, which `mapDataError` then served
as an opaque 500 for a query that is spelled correctly.

The alias is now **escaped rather than gated**: it may be any string, and the
quote character is doubled (`"` → `""`), the standard escape inside a quoted
SQL identifier. This is the ALIAS half of the distinction `driver-sql` drew at
**#13714**, where the same position routes through knex's `wrapIdentifier`
(`SqlDriver.aliasIdentifierSql`) — a qualified **reference** must be
validated, a single output **name** must be quoted and escaped.
`AggregationNodeSchema` declares `alias: z.string()`, an output-column key,
and the in-memory, MongoDB and (post-#13714) SQL faces all project it
verbatim; this face was the outlier.

⛔ **Not** "drop the check". The alias reaches the statement raw inside
`AS "…"`, so an alias containing a `"` would close the quoting and continue as
grammar. `bucket"; DROP TABLE deal; --` now compiles to the single inert
column name `"bucket""; DROP TABLE deal; --"` and is returned as a column
name, executed against a real SQLite-backed client rather than asserted as a
string — the only instrument that tells "escaped" apart from "broke out".

The `field` and `object` positions keep `assertSafeIdentifier` unchanged: those
become column and table **references**, which are grammar. Default aliases are
byte-identical (`count_all` still spells itself the same way), and the
`groupBy` alias position is untouched by this change.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#14113] The aggregation ALIAS is escaped, not gated — TursoDriver's REMOTE
* transport.
*
* ## The defect
*
* `RemoteTransport.aggregate` held the aggregation `alias` to
* `SAFE_IDENTIFIER` (`/^[a-zA-Z_][a-zA-Z0-9_]*$/`). A dot fails that regex.
* Every analytics measure is named `<cube>.<measure>` on the wire and
* `ObjectQLStrategy` uses that name verbatim as the aggregation `alias`
* (`objectql-strategy.ts`, `{ field, method, alias: measure }`), so EVERY cube
* query that reached this face threw:
*
* ```
* RemoteTransport: unsafe identifier rejected: "showcase_delivery.count"
* ```
*
* a bare `Error` with no `code` and no `status`, which `mapDataError` then
* serves as an opaque 500 — the #11455 / #8931 shape.
*
* ## Why the fix is escaping and NOT dropping the check
*
* The alias is interpolated RAW into `AS "${alias}"`, so an alias containing a
* `"` would close the quoting and continue as grammar. The repair is the
* standard doubled-quote escape inside a quoted SQL identifier (`"` → `""`) —
* the ALIAS half of the distinction #13714 drew one face over, where
* `SqlDriver.aliasIdentifierSql` routes the same position through knex's
* `wrapIdentifier`. A qualified REFERENCE must be validated; a single output
* NAME must be quoted and escaped. `AggregationNodeSchema` declares
* `alias: z.string()` — an output-column key — and the in-memory, MongoDB and
* (post-#13714) SQL faces all project it verbatim. This face was the outlier.
*
* ## Why a SQLite-backed client stub rather than a mocked `execute`
*
* Only EXECUTING the statement tells "escaped" apart from "broke out". A
* string assertion alone would pass on an alias that terminates the quoting,
* because the text still *looks* like a select list. libsql IS SQLite, so
* `makeLibsqlSqliteStub` runs what this transport emits: an alias that escaped
* its quoting is a syntax error (or a second statement better-sqlite3 refuses
* to prepare), and a green read of the value back under the literal alias is
* the proof. The emitted SQL is pinned too, so a future reader can see the
* doubled quote rather than infer it.
*
* ## Reverse verification — direction predicted BEFORE it was run
*
* Restore `this.assertSafeIdentifier(alias)` above the `selectParts.push` and
* emit `AS "${alias}"` again (the pre-#14113 two lines):
*
* - the dotted-alias cases (repro, emitted SQL, the un-bucketed cube shape)
* go RED by THROWING inside the call — `unsafe identifier rejected:
* "showcase_delivery.count"` — not on a comparison.
* - the quote-escape cases go RED by throwing the same sentence, naming
* `won"count` / the `DROP TABLE` text. They cannot go red on a broken-out
* statement, because the restored guard refuses that input before any SQL is
* built — which is exactly why the guard could not simply be deleted.
* - the `field`-position control and the groupBy-alias control stay GREEN:
* neither position is touched by this card, and that is what they are here
* to hold.
* - the default-alias case stays GREEN: `count_all` passes `SAFE_IDENTIFIER`
* either way, so it pins the byte-identical emission across the change.
*
* Measured after writing the above — see the PR body for the run.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { TursoDriver } from './turso-driver.js';
import { makeLibsqlSqliteStub, asLibsqlClient, type LibsqlSqliteStub } from './libsql-sqlite-stub.testkit.js';

/**
* Named for the cube in the field report the card was filed from, so the alias
* under test (`showcase_delivery.count`) is the real wire spelling rather than
* a stand-in.
*/
const DELIVERY_OBJECT = {
name: 'showcase_delivery',
fields: {
id: { type: 'string' },
region: { type: 'string' },
amount: { type: 'number' },
},
};

const ROWS = [
{ id: '1', region: 'west', amount: 10 },
{ id: '2', region: 'west', amount: 20 },
{ id: '3', region: 'east', amount: 30 },
];

describe('[#14113] RemoteTransport — the aggregation alias is escaped, not gated', () => {
let driver: TursoDriver;
let stub: LibsqlSqliteStub;

beforeAll(async () => {
stub = makeLibsqlSqliteStub();
driver = new TursoDriver({ url: 'libsql://alias-quoting.turso.io', client: asLibsqlClient(stub) });
await driver.connect();
// The mode this suite is about — the one with its own hand-written SQL.
expect(driver.transportMode).toBe('remote');
await driver.syncSchema(DELIVERY_OBJECT.name, DELIVERY_OBJECT);
for (const row of ROWS) await driver.create(DELIVERY_OBJECT.name, { ...row });
});

afterAll(async () => {
await driver.disconnect();
stub.close();
});

/**
* A transport backed by the same database, capturing the statements it sends
* AND executing them — the capture alone would not prove the statement runs.
*/
const capturing = async () => {
const seen: string[] = [];
const spy = {
...stub,
execute: async (stmt: unknown) => {
seen.push((stmt as { sql: string }).sql);
return stub.execute(stmt);
},
};
const t = new TursoDriver({ url: 'libsql://alias-quoting.turso.io', client: asLibsqlClient(spy) });
await t.connect();
return { t, seen };
};

describe('direction 1 — a dotted `CUBE.MEASURE` alias now reaches the database', () => {
it('the exact alias the card measured is served, on rows', async () => {
const rows = await driver.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
aggregations: [{ function: 'count', alias: 'showcase_delivery.count' }],
} as never);
// The value comes back under the caller's own key — a dot is inert
// inside a quoted identifier, which is the whole claim of this card.
expect(rows).toHaveLength(1);
expect((rows as Array<Record<string, unknown>>)[0]['showcase_delivery.count']).toBe(3);
});

it('compiles to ONE quoted identifier, dot and all', async () => {
const { t, seen } = await capturing();
await t.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
aggregations: [{ function: 'count', alias: 'showcase_delivery.count' }],
} as never);
expect(seen).toEqual([
'SELECT count(*) AS "showcase_delivery.count" FROM "showcase_delivery"',
]);
// ⛔ Not two segments. The failure this replaces is a face that treats an
// alias as a qualified reference; the dot must stay INSIDE the quotes.
expect(seen[0]).not.toContain('"showcase_delivery"."count"');
});

it('the un-bucketed cube shape — a grouped measure — is served end to end', async () => {
// The path that actually reaches `driver.aggregate`: remote mode
// publishes `queryDateGranularity: {}` (see `TursoDriver.supports`), so a
// BUCKETED query falls back to `find()` + in-memory bucketing and never
// arrives here. The UN-bucketed cube query is the one that ate the
// refusal, and it carries a dimension in `groupBy` beside the measure.
//
// ⭐ The groupBy FIELD is a bare column name here, not a dotted one, and
// that is measured rather than assumed: `ObjectQLStrategy.resolveFieldName`
// resolves a dimension to `member.sql` or `member.split('.')[1]`, so only
// the MEASURE arrives dotted. That asymmetry is why this card is confined
// to the alias position of `aggregations`.
const rows = await driver.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
groupBy: ['region'],
aggregations: [
{ function: 'count', alias: 'showcase_delivery.count' },
{ function: 'sum', field: 'amount', alias: 'showcase_delivery.total_amount' },
],
} as never);
const byRegion = Object.fromEntries(
(rows as Array<Record<string, unknown>>).map((r) => [
r.region,
[r['showcase_delivery.count'], r['showcase_delivery.total_amount']],
]),
);
expect(byRegion).toEqual({ west: [2, 30], east: [1, 30] });
});
});

describe('direction 2 — an alias carrying a `"` is ESCAPED, not let through', () => {
it('doubles the quote and still runs, returning the value under the literal alias', async () => {
const alias = 'won"count';
const { t, seen } = await capturing();
const rows = await t.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
aggregations: [{ function: 'count', alias }],
} as never);
expect(seen).toEqual(['SELECT count(*) AS "won""count" FROM "showcase_delivery"']);
// Executed, not merely emitted: an alias that broke out of its quoting
// would be a syntax error here rather than a row.
expect((rows as Array<Record<string, unknown>>)[0][alias]).toBe(3);
});

it('an alias that tries to close the quoting and append a statement stays one name', async () => {
const alias = 'bucket"; DROP TABLE showcase_delivery; --';
const { t, seen } = await capturing();
const rows = await t.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
aggregations: [{ function: 'count', alias }],
} as never);
expect(seen).toEqual([
'SELECT count(*) AS "bucket""; DROP TABLE showcase_delivery; --" FROM "showcase_delivery"',
]);
// The whole payload came back as a COLUMN NAME — it was data, never
// grammar.
expect((rows as Array<Record<string, unknown>>)[0][alias]).toBe(3);
// And the table it named is still there, with every row.
expect(
stub.raw.prepare('select count(*) as c from showcase_delivery').all(),
).toEqual([{ c: 3 }]);
});
});

describe('regression controls — the positions this card did NOT touch', () => {
it('the `field` position still refuses an unsafe identifier, and sends nothing', async () => {
// `SAFE_IDENTIFIER` is doing real work here: `field` becomes a column
// REFERENCE, which is grammar. Escaping is the answer for a NAME only.
const { t, seen } = await capturing();
const err = await t
.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
aggregations: [{ function: 'sum', field: 'amount"; DROP TABLE showcase_delivery; --', alias: 'n' }],
} as never)
.then(
() => { throw new Error('expected the transport to refuse an unsafe field'); },
(e) => e as Error,
);
// The OFFENDING TEXT, not just the sentence (#6144) — an alias that is
// itself safe is what makes this case reach the `field` check at all.
expect(err.message).toContain('unsafe identifier rejected');
expect(err.message).toContain('amount"; DROP TABLE showcase_delivery; --');
expect(seen).toEqual([]);
});

it('the `object` position still refuses an unsafe identifier', async () => {
const { t, seen } = await capturing();
const err = await t
.aggregate('showcase_delivery"; DROP TABLE showcase_delivery; --', {
object: 'showcase_delivery"; DROP TABLE showcase_delivery; --',
aggregations: [{ function: 'count', alias: 'n' }],
} as never)
.then(
() => { throw new Error('expected the transport to refuse an unsafe object') },
(e) => e as Error,
);
expect(err.message).toContain('unsafe identifier rejected');
expect(seen).toEqual([]);
});

it('the groupBy alias position is UNCHANGED — still refused, and that is a separate card', async () => {
// ⚠️ Deliberate scope line, pinned so it cannot drift silently. The
// groupBy `alias` is the same class of thing (an output NAME) and
// `driver-sql` escapes it post-#13714 (`aliasIdentifierSql` at its
// groupBy select site), so this face diverges there too — but that
// position carries a LANDED pin (#6401, `remote-transport-groupby-node`)
// asserting the refusal, so reversing it is a judgement this card was not
// dispatched to make. Filed separately rather than patched inline; this
// control records the state it was left in.
//
// It is also NOT on the reproducing path: `ObjectQLStrategy` resolves a
// dimension to a bare column name, so no analytics query sends a dotted
// groupBy alias.
const { t, seen } = await capturing();
const err = await t
.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
groupBy: [{ field: 'region', alias: 'showcase_delivery.region' }],
aggregations: [{ function: 'count', alias: 'showcase_delivery.count' }],
} as never)
.then(
() => { throw new Error('expected the groupBy alias to still be refused') },
(e) => e as Error,
);
expect(err.message).toContain('unsafe identifier rejected');
expect(err.message).toContain('showcase_delivery.region');
expect(seen).toEqual([]);
});

it('the default alias is byte-identical to what it was before', async () => {
// A caller who omits `alias` reads the result under `count_all` exactly
// as they did pre-#14113 — this change moves no default.
const { t, seen } = await capturing();
const rows = await t.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
aggregations: [{ function: 'count' }],
} as never);
expect(seen).toEqual(['SELECT count(*) AS "count_all" FROM "showcase_delivery"']);
expect((rows as Array<Record<string, unknown>>)[0].count_all).toBe(3);
});
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
36 changes: 36 additions & 0 deletions .changeset/turso-remote-aggregation-alias-escaped.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
---
"@objectstack/driver-turso": patch
---

fix(driver-turso): escape the aggregation alias instead of gating it, so remote-mode analytics cube queries stop 500ing (#14113)

`RemoteTransport.aggregate` (Turso **remote** mode) held the aggregation
`alias` to `SAFE_IDENTIFIER` (`/^[a-zA-Z_][a-zA-Z0-9_]*$/`). A dot fails that
regex. Every analytics measure is named `<cube>.<measure>` on the wire and
`ObjectQLStrategy` uses that name verbatim as the aggregation `alias`, so
**every** cube query that reached this face threw
`RemoteTransport: unsafe identifier rejected: "showcase_delivery.count"` — a
bare `Error` with no `code` and no `status`, which `mapDataError` then served
as an opaque 500 for a query that is spelled correctly.

The alias is now **escaped rather than gated**: it may be any string, and the
quote character is doubled (`"` → `""`), the standard escape inside a quoted
SQL identifier. This is the ALIAS half of the distinction `driver-sql` drew at
**#13714**, where the same position routes through knex's `wrapIdentifier`
(`SqlDriver.aliasIdentifierSql`) — a qualified **reference** must be
validated, a single output **name** must be quoted and escaped.
`AggregationNodeSchema` declares `alias: z.string()`, an output-column key,
and the in-memory, MongoDB and (post-#13714) SQL faces all project it
verbatim; this face was the outlier.

⛔ **Not** "drop the check". The alias reaches the statement raw inside
`AS "…"`, so an alias containing a `"` would close the quoting and continue as
grammar. `bucket"; DROP TABLE deal; --` now compiles to the single inert
column name `"bucket""; DROP TABLE deal; --"` and is returned as a column
name, executed against a real SQLite-backed client rather than asserted as a
string — the only instrument that tells "escaped" apart from "broke out".

The `field` and `object` positions keep `assertSafeIdentifier` unchanged: those
become column and table **references**, which are grammar. Default aliases are
byte-identical (`count_all` still spells itself the same way), and the
`groupBy` alias position is untouched by this change.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#14113] The aggregation ALIAS is escaped, not gated — TursoDriver's REMOTE
* transport.
*
* ## The defect
*
* `RemoteTransport.aggregate` held the aggregation `alias` to
* `SAFE_IDENTIFIER` (`/^[a-zA-Z_][a-zA-Z0-9_]*$/`). A dot fails that regex.
* Every analytics measure is named `<cube>.<measure>` on the wire and
* `ObjectQLStrategy` uses that name verbatim as the aggregation `alias`
* (`objectql-strategy.ts`, `{ field, method, alias: measure }`), so EVERY cube
* query that reached this face threw:
*
* ```
* RemoteTransport: unsafe identifier rejected: "showcase_delivery.count"
* ```
*
* a bare `Error` with no `code` and no `status`, which `mapDataError` then
* serves as an opaque 500 — the #11455 / #8931 shape.
*
* ## Why the fix is escaping and NOT dropping the check
*
* The alias is interpolated RAW into `AS "${alias}"`, so an alias containing a
* `"` would close the quoting and continue as grammar. The repair is the
* standard doubled-quote escape inside a quoted SQL identifier (`"` → `""`) —
* the ALIAS half of the distinction #13714 drew one face over, where
* `SqlDriver.aliasIdentifierSql` routes the same position through knex's
* `wrapIdentifier`. A qualified REFERENCE must be validated; a single output
* NAME must be quoted and escaped. `AggregationNodeSchema` declares
* `alias: z.string()` — an output-column key — and the in-memory, MongoDB and
* (post-#13714) SQL faces all project it verbatim. This face was the outlier.
*
* ## Why a SQLite-backed client stub rather than a mocked `execute`
*
* Only EXECUTING the statement tells "escaped" apart from "broke out". A
* string assertion alone would pass on an alias that terminates the quoting,
* because the text still *looks* like a select list. libsql IS SQLite, so
* `makeLibsqlSqliteStub` runs what this transport emits: an alias that escaped
* its quoting is a syntax error (or a second statement better-sqlite3 refuses
* to prepare), and a green read of the value back under the literal alias is
* the proof. The emitted SQL is pinned too, so a future reader can see the
* doubled quote rather than infer it.
*
* ## Reverse verification — direction predicted BEFORE it was run
*
* Restore `this.assertSafeIdentifier(alias)` above the `selectParts.push` and
* emit `AS "${alias}"` again (the pre-#14113 two lines):
*
* - the dotted-alias cases (repro, emitted SQL, the un-bucketed cube shape)
* go RED by THROWING inside the call — `unsafe identifier rejected:
* "showcase_delivery.count"` — not on a comparison.
* - the quote-escape cases go RED by throwing the same sentence, naming
* `won"count` / the `DROP TABLE` text. They cannot go red on a broken-out
* statement, because the restored guard refuses that input before any SQL is
* built — which is exactly why the guard could not simply be deleted.
* - the `field`-position control and the groupBy-alias control stay GREEN:
* neither position is touched by this card, and that is what they are here
* to hold.
* - the default-alias case stays GREEN: `count_all` passes `SAFE_IDENTIFIER`
* either way, so it pins the byte-identical emission across the change.
*
* Measured after writing the above — see the PR body for the run.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { TursoDriver } from './turso-driver.js';
import { makeLibsqlSqliteStub, asLibsqlClient, type LibsqlSqliteStub } from './libsql-sqlite-stub.testkit.js';

/**
* Named for the cube in the field report the card was filed from, so the alias
* under test (`showcase_delivery.count`) is the real wire spelling rather than
* a stand-in.
*/
const DELIVERY_OBJECT = {
name: 'showcase_delivery',
fields: {
id: { type: 'string' },
region: { type: 'string' },
amount: { type: 'number' },
},
};

const ROWS = [
{ id: '1', region: 'west', amount: 10 },
{ id: '2', region: 'west', amount: 20 },
{ id: '3', region: 'east', amount: 30 },
];

describe('[#14113] RemoteTransport — the aggregation alias is escaped, not gated', () => {
let driver: TursoDriver;
let stub: LibsqlSqliteStub;

beforeAll(async () => {
stub = makeLibsqlSqliteStub();
driver = new TursoDriver({ url: 'libsql://alias-quoting.turso.io', client: asLibsqlClient(stub) });
await driver.connect();
// The mode this suite is about — the one with its own hand-written SQL.
expect(driver.transportMode).toBe('remote');
await driver.syncSchema(DELIVERY_OBJECT.name, DELIVERY_OBJECT);
for (const row of ROWS) await driver.create(DELIVERY_OBJECT.name, { ...row });
});

afterAll(async () => {
await driver.disconnect();
stub.close();
});

/**
* A transport backed by the same database, capturing the statements it sends
* AND executing them — the capture alone would not prove the statement runs.
*/
const capturing = async () => {
const seen: string[] = [];
const spy = {
...stub,
execute: async (stmt: unknown) => {
seen.push((stmt as { sql: string }).sql);
return stub.execute(stmt);
},
};
const t = new TursoDriver({ url: 'libsql://alias-quoting.turso.io', client: asLibsqlClient(spy) });
await t.connect();
return { t, seen };
};

describe('direction 1 — a dotted `CUBE.MEASURE` alias now reaches the database', () => {
it('the exact alias the card measured is served, on rows', async () => {
const rows = await driver.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
aggregations: [{ function: 'count', alias: 'showcase_delivery.count' }],
} as never);
// The value comes back under the caller's own key — a dot is inert
// inside a quoted identifier, which is the whole claim of this card.
expect(rows).toHaveLength(1);
expect((rows as Array<Record<string, unknown>>)[0]['showcase_delivery.count']).toBe(3);
});

it('compiles to ONE quoted identifier, dot and all', async () => {
const { t, seen } = await capturing();
await t.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
aggregations: [{ function: 'count', alias: 'showcase_delivery.count' }],
} as never);
expect(seen).toEqual([
'SELECT count(*) AS "showcase_delivery.count" FROM "showcase_delivery"',
]);
// ⛔ Not two segments. The failure this replaces is a face that treats an
// alias as a qualified reference; the dot must stay INSIDE the quotes.
expect(seen[0]).not.toContain('"showcase_delivery"."count"');
});

it('the un-bucketed cube shape — a grouped measure — is served end to end', async () => {
// The path that actually reaches `driver.aggregate`: remote mode
// publishes `queryDateGranularity: {}` (see `TursoDriver.supports`), so a
// BUCKETED query falls back to `find()` + in-memory bucketing and never
// arrives here. The UN-bucketed cube query is the one that ate the
// refusal, and it carries a dimension in `groupBy` beside the measure.
//
// ⭐ The groupBy FIELD is a bare column name here, not a dotted one, and
// that is measured rather than assumed: `ObjectQLStrategy.resolveFieldName`
// resolves a dimension to `member.sql` or `member.split('.')[1]`, so only
// the MEASURE arrives dotted. That asymmetry is why this card is confined
// to the alias position of `aggregations`.
const rows = await driver.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
groupBy: ['region'],
aggregations: [
{ function: 'count', alias: 'showcase_delivery.count' },
{ function: 'sum', field: 'amount', alias: 'showcase_delivery.total_amount' },
],
} as never);
const byRegion = Object.fromEntries(
(rows as Array<Record<string, unknown>>).map((r) => [
r.region,
[r['showcase_delivery.count'], r['showcase_delivery.total_amount']],
]),
);
expect(byRegion).toEqual({ west: [2, 30], east: [1, 30] });
});
});

describe('direction 2 — an alias carrying a `"` is ESCAPED, not let through', () => {
it('doubles the quote and still runs, returning the value under the literal alias', async () => {
const alias = 'won"count';
const { t, seen } = await capturing();
const rows = await t.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
aggregations: [{ function: 'count', alias }],
} as never);
expect(seen).toEqual(['SELECT count(*) AS "won""count" FROM "showcase_delivery"']);
// Executed, not merely emitted: an alias that broke out of its quoting
// would be a syntax error here rather than a row.
expect((rows as Array<Record<string, unknown>>)[0][alias]).toBe(3);
});

it('an alias that tries to close the quoting and append a statement stays one name', async () => {
const alias = 'bucket"; DROP TABLE showcase_delivery; --';
const { t, seen } = await capturing();
const rows = await t.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
aggregations: [{ function: 'count', alias }],
} as never);
expect(seen).toEqual([
'SELECT count(*) AS "bucket""; DROP TABLE showcase_delivery; --" FROM "showcase_delivery"',
]);
// The whole payload came back as a COLUMN NAME — it was data, never
// grammar.
expect((rows as Array<Record<string, unknown>>)[0][alias]).toBe(3);
// And the table it named is still there, with every row.
expect(
stub.raw.prepare('select count(*) as c from showcase_delivery').all(),
).toEqual([{ c: 3 }]);
});
});

describe('regression controls — the positions this card did NOT touch', () => {
it('the `field` position still refuses an unsafe identifier, and sends nothing', async () => {
// `SAFE_IDENTIFIER` is doing real work here: `field` becomes a column
// REFERENCE, which is grammar. Escaping is the answer for a NAME only.
const { t, seen } = await capturing();
const err = await t
.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
aggregations: [{ function: 'sum', field: 'amount"; DROP TABLE showcase_delivery; --', alias: 'n' }],
} as never)
.then(
() => { throw new Error('expected the transport to refuse an unsafe field'); },
(e) => e as Error,
);
// The OFFENDING TEXT, not just the sentence (#6144) — an alias that is
// itself safe is what makes this case reach the `field` check at all.
expect(err.message).toContain('unsafe identifier rejected');
expect(err.message).toContain('amount"; DROP TABLE showcase_delivery; --');
expect(seen).toEqual([]);
});

it('the `object` position still refuses an unsafe identifier', async () => {
const { t, seen } = await capturing();
const err = await t
.aggregate('showcase_delivery"; DROP TABLE showcase_delivery; --', {
object: 'showcase_delivery"; DROP TABLE showcase_delivery; --',
aggregations: [{ function: 'count', alias: 'n' }],
} as never)
.then(
() => { throw new Error('expected the transport to refuse an unsafe object') },
(e) => e as Error,
);
expect(err.message).toContain('unsafe identifier rejected');
expect(seen).toEqual([]);
});

it('the groupBy alias position is UNCHANGED — still refused, and that is a separate card', async () => {
// ⚠️ Deliberate scope line, pinned so it cannot drift silently. The
// groupBy `alias` is the same class of thing (an output NAME) and
// `driver-sql` escapes it post-#13714 (`aliasIdentifierSql` at its
// groupBy select site), so this face diverges there too — but that
// position carries a LANDED pin (#6401, `remote-transport-groupby-node`)
// asserting the refusal, so reversing it is a judgement this card was not
// dispatched to make. Filed separately rather than patched inline; this
// control records the state it was left in.
//
// It is also NOT on the reproducing path: `ObjectQLStrategy` resolves a
// dimension to a bare column name, so no analytics query sends a dotted
// groupBy alias.
const { t, seen } = await capturing();
const err = await t
.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
groupBy: [{ field: 'region', alias: 'showcase_delivery.region' }],
aggregations: [{ function: 'count', alias: 'showcase_delivery.count' }],
} as never)
.then(
() => { throw new Error('expected the groupBy alias to still be refused') },
(e) => e as Error,
);
expect(err.message).toContain('unsafe identifier rejected');
expect(err.message).toContain('showcase_delivery.region');
expect(seen).toEqual([]);
});

it('the default alias is byte-identical to what it was before', async () => {
// A caller who omits `alias` reads the result under `count_all` exactly
// as they did pre-#14113 — this change moves no default.
const { t, seen } = await capturing();
const rows = await t.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
aggregations: [{ function: 'count' }],
} as never);
expect(seen).toEqual(['SELECT count(*) AS "count_all" FROM "showcase_delivery"']);
expect((rows as Array<Record<string, unknown>>)[0].count_all).toBe(3);
});
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
36 changes: 36 additions & 0 deletions .changeset/turso-remote-aggregation-alias-escaped.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
---
"@objectstack/driver-turso": patch
---

fix(driver-turso): escape the aggregation alias instead of gating it, so remote-mode analytics cube queries stop 500ing (#14113)

`RemoteTransport.aggregate` (Turso **remote** mode) held the aggregation
`alias` to `SAFE_IDENTIFIER` (`/^[a-zA-Z_][a-zA-Z0-9_]*$/`). A dot fails that
regex. Every analytics measure is named `<cube>.<measure>` on the wire and
`ObjectQLStrategy` uses that name verbatim as the aggregation `alias`, so
**every** cube query that reached this face threw
`RemoteTransport: unsafe identifier rejected: "showcase_delivery.count"` — a
bare `Error` with no `code` and no `status`, which `mapDataError` then served
as an opaque 500 for a query that is spelled correctly.

The alias is now **escaped rather than gated**: it may be any string, and the
quote character is doubled (`"` → `""`), the standard escape inside a quoted
SQL identifier. This is the ALIAS half of the distinction `driver-sql` drew at
**#13714**, where the same position routes through knex's `wrapIdentifier`
(`SqlDriver.aliasIdentifierSql`) — a qualified **reference** must be
validated, a single output **name** must be quoted and escaped.
`AggregationNodeSchema` declares `alias: z.string()`, an output-column key,
and the in-memory, MongoDB and (post-#13714) SQL faces all project it
verbatim; this face was the outlier.

⛔ **Not** "drop the check". The alias reaches the statement raw inside
`AS "…"`, so an alias containing a `"` would close the quoting and continue as
grammar. `bucket"; DROP TABLE deal; --` now compiles to the single inert
column name `"bucket""; DROP TABLE deal; --"` and is returned as a column
name, executed against a real SQLite-backed client rather than asserted as a
string — the only instrument that tells "escaped" apart from "broke out".

The `field` and `object` positions keep `assertSafeIdentifier` unchanged: those
become column and table **references**, which are grammar. Default aliases are
byte-identical (`count_all` still spells itself the same way), and the
`groupBy` alias position is untouched by this change.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#14113] The aggregation ALIAS is escaped, not gated — TursoDriver's REMOTE
* transport.
*
* ## The defect
*
* `RemoteTransport.aggregate` held the aggregation `alias` to
* `SAFE_IDENTIFIER` (`/^[a-zA-Z_][a-zA-Z0-9_]*$/`). A dot fails that regex.
* Every analytics measure is named `<cube>.<measure>` on the wire and
* `ObjectQLStrategy` uses that name verbatim as the aggregation `alias`
* (`objectql-strategy.ts`, `{ field, method, alias: measure }`), so EVERY cube
* query that reached this face threw:
*
* ```
* RemoteTransport: unsafe identifier rejected: "showcase_delivery.count"
* ```
*
* a bare `Error` with no `code` and no `status`, which `mapDataError` then
* serves as an opaque 500 — the #11455 / #8931 shape.
*
* ## Why the fix is escaping and NOT dropping the check
*
* The alias is interpolated RAW into `AS "${alias}"`, so an alias containing a
* `"` would close the quoting and continue as grammar. The repair is the
* standard doubled-quote escape inside a quoted SQL identifier (`"` → `""`) —
* the ALIAS half of the distinction #13714 drew one face over, where
* `SqlDriver.aliasIdentifierSql` routes the same position through knex's
* `wrapIdentifier`. A qualified REFERENCE must be validated; a single output
* NAME must be quoted and escaped. `AggregationNodeSchema` declares
* `alias: z.string()` — an output-column key — and the in-memory, MongoDB and
* (post-#13714) SQL faces all project it verbatim. This face was the outlier.
*
* ## Why a SQLite-backed client stub rather than a mocked `execute`
*
* Only EXECUTING the statement tells "escaped" apart from "broke out". A
* string assertion alone would pass on an alias that terminates the quoting,
* because the text still *looks* like a select list. libsql IS SQLite, so
* `makeLibsqlSqliteStub` runs what this transport emits: an alias that escaped
* its quoting is a syntax error (or a second statement better-sqlite3 refuses
* to prepare), and a green read of the value back under the literal alias is
* the proof. The emitted SQL is pinned too, so a future reader can see the
* doubled quote rather than infer it.
*
* ## Reverse verification — direction predicted BEFORE it was run
*
* Restore `this.assertSafeIdentifier(alias)` above the `selectParts.push` and
* emit `AS "${alias}"` again (the pre-#14113 two lines):
*
* - the dotted-alias cases (repro, emitted SQL, the un-bucketed cube shape)
* go RED by THROWING inside the call — `unsafe identifier rejected:
* "showcase_delivery.count"` — not on a comparison.
* - the quote-escape cases go RED by throwing the same sentence, naming
* `won"count` / the `DROP TABLE` text. They cannot go red on a broken-out
* statement, because the restored guard refuses that input before any SQL is
* built — which is exactly why the guard could not simply be deleted.
* - the `field`-position control and the groupBy-alias control stay GREEN:
* neither position is touched by this card, and that is what they are here
* to hold.
* - the default-alias case stays GREEN: `count_all` passes `SAFE_IDENTIFIER`
* either way, so it pins the byte-identical emission across the change.
*
* Measured after writing the above — see the PR body for the run.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { TursoDriver } from './turso-driver.js';
import { makeLibsqlSqliteStub, asLibsqlClient, type LibsqlSqliteStub } from './libsql-sqlite-stub.testkit.js';

/**
* Named for the cube in the field report the card was filed from, so the alias
* under test (`showcase_delivery.count`) is the real wire spelling rather than
* a stand-in.
*/
const DELIVERY_OBJECT = {
name: 'showcase_delivery',
fields: {
id: { type: 'string' },
region: { type: 'string' },
amount: { type: 'number' },
},
};

const ROWS = [
{ id: '1', region: 'west', amount: 10 },
{ id: '2', region: 'west', amount: 20 },
{ id: '3', region: 'east', amount: 30 },
];

describe('[#14113] RemoteTransport — the aggregation alias is escaped, not gated', () => {
let driver: TursoDriver;
let stub: LibsqlSqliteStub;

beforeAll(async () => {
stub = makeLibsqlSqliteStub();
driver = new TursoDriver({ url: 'libsql://alias-quoting.turso.io', client: asLibsqlClient(stub) });
await driver.connect();
// The mode this suite is about — the one with its own hand-written SQL.
expect(driver.transportMode).toBe('remote');
await driver.syncSchema(DELIVERY_OBJECT.name, DELIVERY_OBJECT);
for (const row of ROWS) await driver.create(DELIVERY_OBJECT.name, { ...row });
});

afterAll(async () => {
await driver.disconnect();
stub.close();
});

/**
* A transport backed by the same database, capturing the statements it sends
* AND executing them — the capture alone would not prove the statement runs.
*/
const capturing = async () => {
const seen: string[] = [];
const spy = {
...stub,
execute: async (stmt: unknown) => {
seen.push((stmt as { sql: string }).sql);
return stub.execute(stmt);
},
};
const t = new TursoDriver({ url: 'libsql://alias-quoting.turso.io', client: asLibsqlClient(spy) });
await t.connect();
return { t, seen };
};

describe('direction 1 — a dotted `CUBE.MEASURE` alias now reaches the database', () => {
it('the exact alias the card measured is served, on rows', async () => {
const rows = await driver.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
aggregations: [{ function: 'count', alias: 'showcase_delivery.count' }],
} as never);
// The value comes back under the caller's own key — a dot is inert
// inside a quoted identifier, which is the whole claim of this card.
expect(rows).toHaveLength(1);
expect((rows as Array<Record<string, unknown>>)[0]['showcase_delivery.count']).toBe(3);
});

it('compiles to ONE quoted identifier, dot and all', async () => {
const { t, seen } = await capturing();
await t.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
aggregations: [{ function: 'count', alias: 'showcase_delivery.count' }],
} as never);
expect(seen).toEqual([
'SELECT count(*) AS "showcase_delivery.count" FROM "showcase_delivery"',
]);
// ⛔ Not two segments. The failure this replaces is a face that treats an
// alias as a qualified reference; the dot must stay INSIDE the quotes.
expect(seen[0]).not.toContain('"showcase_delivery"."count"');
});

it('the un-bucketed cube shape — a grouped measure — is served end to end', async () => {
// The path that actually reaches `driver.aggregate`: remote mode
// publishes `queryDateGranularity: {}` (see `TursoDriver.supports`), so a
// BUCKETED query falls back to `find()` + in-memory bucketing and never
// arrives here. The UN-bucketed cube query is the one that ate the
// refusal, and it carries a dimension in `groupBy` beside the measure.
//
// ⭐ The groupBy FIELD is a bare column name here, not a dotted one, and
// that is measured rather than assumed: `ObjectQLStrategy.resolveFieldName`
// resolves a dimension to `member.sql` or `member.split('.')[1]`, so only
// the MEASURE arrives dotted. That asymmetry is why this card is confined
// to the alias position of `aggregations`.
const rows = await driver.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
groupBy: ['region'],
aggregations: [
{ function: 'count', alias: 'showcase_delivery.count' },
{ function: 'sum', field: 'amount', alias: 'showcase_delivery.total_amount' },
],
} as never);
const byRegion = Object.fromEntries(
(rows as Array<Record<string, unknown>>).map((r) => [
r.region,
[r['showcase_delivery.count'], r['showcase_delivery.total_amount']],
]),
);
expect(byRegion).toEqual({ west: [2, 30], east: [1, 30] });
});
});

describe('direction 2 — an alias carrying a `"` is ESCAPED, not let through', () => {
it('doubles the quote and still runs, returning the value under the literal alias', async () => {
const alias = 'won"count';
const { t, seen } = await capturing();
const rows = await t.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
aggregations: [{ function: 'count', alias }],
} as never);
expect(seen).toEqual(['SELECT count(*) AS "won""count" FROM "showcase_delivery"']);
// Executed, not merely emitted: an alias that broke out of its quoting
// would be a syntax error here rather than a row.
expect((rows as Array<Record<string, unknown>>)[0][alias]).toBe(3);
});

it('an alias that tries to close the quoting and append a statement stays one name', async () => {
const alias = 'bucket"; DROP TABLE showcase_delivery; --';
const { t, seen } = await capturing();
const rows = await t.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
aggregations: [{ function: 'count', alias }],
} as never);
expect(seen).toEqual([
'SELECT count(*) AS "bucket""; DROP TABLE showcase_delivery; --" FROM "showcase_delivery"',
]);
// The whole payload came back as a COLUMN NAME — it was data, never
// grammar.
expect((rows as Array<Record<string, unknown>>)[0][alias]).toBe(3);
// And the table it named is still there, with every row.
expect(
stub.raw.prepare('select count(*) as c from showcase_delivery').all(),
).toEqual([{ c: 3 }]);
});
});

describe('regression controls — the positions this card did NOT touch', () => {
it('the `field` position still refuses an unsafe identifier, and sends nothing', async () => {
// `SAFE_IDENTIFIER` is doing real work here: `field` becomes a column
// REFERENCE, which is grammar. Escaping is the answer for a NAME only.
const { t, seen } = await capturing();
const err = await t
.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
aggregations: [{ function: 'sum', field: 'amount"; DROP TABLE showcase_delivery; --', alias: 'n' }],
} as never)
.then(
() => { throw new Error('expected the transport to refuse an unsafe field'); },
(e) => e as Error,
);
// The OFFENDING TEXT, not just the sentence (#6144) — an alias that is
// itself safe is what makes this case reach the `field` check at all.
expect(err.message).toContain('unsafe identifier rejected');
expect(err.message).toContain('amount"; DROP TABLE showcase_delivery; --');
expect(seen).toEqual([]);
});

it('the `object` position still refuses an unsafe identifier', async () => {
const { t, seen } = await capturing();
const err = await t
.aggregate('showcase_delivery"; DROP TABLE showcase_delivery; --', {
object: 'showcase_delivery"; DROP TABLE showcase_delivery; --',
aggregations: [{ function: 'count', alias: 'n' }],
} as never)
.then(
() => { throw new Error('expected the transport to refuse an unsafe object') },
(e) => e as Error,
);
expect(err.message).toContain('unsafe identifier rejected');
expect(seen).toEqual([]);
});

it('the groupBy alias position is UNCHANGED — still refused, and that is a separate card', async () => {
// ⚠️ Deliberate scope line, pinned so it cannot drift silently. The
// groupBy `alias` is the same class of thing (an output NAME) and
// `driver-sql` escapes it post-#13714 (`aliasIdentifierSql` at its
// groupBy select site), so this face diverges there too — but that
// position carries a LANDED pin (#6401, `remote-transport-groupby-node`)
// asserting the refusal, so reversing it is a judgement this card was not
// dispatched to make. Filed separately rather than patched inline; this
// control records the state it was left in.
//
// It is also NOT on the reproducing path: `ObjectQLStrategy` resolves a
// dimension to a bare column name, so no analytics query sends a dotted
// groupBy alias.
const { t, seen } = await capturing();
const err = await t
.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
groupBy: [{ field: 'region', alias: 'showcase_delivery.region' }],
aggregations: [{ function: 'count', alias: 'showcase_delivery.count' }],
} as never)
.then(
() => { throw new Error('expected the groupBy alias to still be refused') },
(e) => e as Error,
);
expect(err.message).toContain('unsafe identifier rejected');
expect(err.message).toContain('showcase_delivery.region');
expect(seen).toEqual([]);
});

it('the default alias is byte-identical to what it was before', async () => {
// A caller who omits `alias` reads the result under `count_all` exactly
// as they did pre-#14113 — this change moves no default.
const { t, seen } = await capturing();
const rows = await t.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
aggregations: [{ function: 'count' }],
} as never);
expect(seen).toEqual(['SELECT count(*) AS "count_all" FROM "showcase_delivery"']);
expect((rows as Array<Record<string, unknown>>)[0].count_all).toBe(3);
});
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
36 changes: 36 additions & 0 deletions .changeset/turso-remote-aggregation-alias-escaped.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
---
"@objectstack/driver-turso": patch
---

fix(driver-turso): escape the aggregation alias instead of gating it, so remote-mode analytics cube queries stop 500ing (#14113)

`RemoteTransport.aggregate` (Turso **remote** mode) held the aggregation
`alias` to `SAFE_IDENTIFIER` (`/^[a-zA-Z_][a-zA-Z0-9_]*$/`). A dot fails that
regex. Every analytics measure is named `<cube>.<measure>` on the wire and
`ObjectQLStrategy` uses that name verbatim as the aggregation `alias`, so
**every** cube query that reached this face threw
`RemoteTransport: unsafe identifier rejected: "showcase_delivery.count"` — a
bare `Error` with no `code` and no `status`, which `mapDataError` then served
as an opaque 500 for a query that is spelled correctly.

The alias is now **escaped rather than gated**: it may be any string, and the
quote character is doubled (`"` → `""`), the standard escape inside a quoted
SQL identifier. This is the ALIAS half of the distinction `driver-sql` drew at
**#13714**, where the same position routes through knex's `wrapIdentifier`
(`SqlDriver.aliasIdentifierSql`) — a qualified **reference** must be
validated, a single output **name** must be quoted and escaped.
`AggregationNodeSchema` declares `alias: z.string()`, an output-column key,
and the in-memory, MongoDB and (post-#13714) SQL faces all project it
verbatim; this face was the outlier.

⛔ **Not** "drop the check". The alias reaches the statement raw inside
`AS "…"`, so an alias containing a `"` would close the quoting and continue as
grammar. `bucket"; DROP TABLE deal; --` now compiles to the single inert
column name `"bucket""; DROP TABLE deal; --"` and is returned as a column
name, executed against a real SQLite-backed client rather than asserted as a
string — the only instrument that tells "escaped" apart from "broke out".

The `field` and `object` positions keep `assertSafeIdentifier` unchanged: those
become column and table **references**, which are grammar. Default aliases are
byte-identical (`count_all` still spells itself the same way), and the
`groupBy` alias position is untouched by this change.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#14113] The aggregation ALIAS is escaped, not gated — TursoDriver's REMOTE
* transport.
*
* ## The defect
*
* `RemoteTransport.aggregate` held the aggregation `alias` to
* `SAFE_IDENTIFIER` (`/^[a-zA-Z_][a-zA-Z0-9_]*$/`). A dot fails that regex.
* Every analytics measure is named `<cube>.<measure>` on the wire and
* `ObjectQLStrategy` uses that name verbatim as the aggregation `alias`
* (`objectql-strategy.ts`, `{ field, method, alias: measure }`), so EVERY cube
* query that reached this face threw:
*
* ```
* RemoteTransport: unsafe identifier rejected: "showcase_delivery.count"
* ```
*
* a bare `Error` with no `code` and no `status`, which `mapDataError` then
* serves as an opaque 500 — the #11455 / #8931 shape.
*
* ## Why the fix is escaping and NOT dropping the check
*
* The alias is interpolated RAW into `AS "${alias}"`, so an alias containing a
* `"` would close the quoting and continue as grammar. The repair is the
* standard doubled-quote escape inside a quoted SQL identifier (`"` → `""`) —
* the ALIAS half of the distinction #13714 drew one face over, where
* `SqlDriver.aliasIdentifierSql` routes the same position through knex's
* `wrapIdentifier`. A qualified REFERENCE must be validated; a single output
* NAME must be quoted and escaped. `AggregationNodeSchema` declares
* `alias: z.string()` — an output-column key — and the in-memory, MongoDB and
* (post-#13714) SQL faces all project it verbatim. This face was the outlier.
*
* ## Why a SQLite-backed client stub rather than a mocked `execute`
*
* Only EXECUTING the statement tells "escaped" apart from "broke out". A
* string assertion alone would pass on an alias that terminates the quoting,
* because the text still *looks* like a select list. libsql IS SQLite, so
* `makeLibsqlSqliteStub` runs what this transport emits: an alias that escaped
* its quoting is a syntax error (or a second statement better-sqlite3 refuses
* to prepare), and a green read of the value back under the literal alias is
* the proof. The emitted SQL is pinned too, so a future reader can see the
* doubled quote rather than infer it.
*
* ## Reverse verification — direction predicted BEFORE it was run
*
* Restore `this.assertSafeIdentifier(alias)` above the `selectParts.push` and
* emit `AS "${alias}"` again (the pre-#14113 two lines):
*
* - the dotted-alias cases (repro, emitted SQL, the un-bucketed cube shape)
* go RED by THROWING inside the call — `unsafe identifier rejected:
* "showcase_delivery.count"` — not on a comparison.
* - the quote-escape cases go RED by throwing the same sentence, naming
* `won"count` / the `DROP TABLE` text. They cannot go red on a broken-out
* statement, because the restored guard refuses that input before any SQL is
* built — which is exactly why the guard could not simply be deleted.
* - the `field`-position control and the groupBy-alias control stay GREEN:
* neither position is touched by this card, and that is what they are here
* to hold.
* - the default-alias case stays GREEN: `count_all` passes `SAFE_IDENTIFIER`
* either way, so it pins the byte-identical emission across the change.
*
* Measured after writing the above — see the PR body for the run.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { TursoDriver } from './turso-driver.js';
import { makeLibsqlSqliteStub, asLibsqlClient, type LibsqlSqliteStub } from './libsql-sqlite-stub.testkit.js';

/**
* Named for the cube in the field report the card was filed from, so the alias
* under test (`showcase_delivery.count`) is the real wire spelling rather than
* a stand-in.
*/
const DELIVERY_OBJECT = {
name: 'showcase_delivery',
fields: {
id: { type: 'string' },
region: { type: 'string' },
amount: { type: 'number' },
},
};

const ROWS = [
{ id: '1', region: 'west', amount: 10 },
{ id: '2', region: 'west', amount: 20 },
{ id: '3', region: 'east', amount: 30 },
];

describe('[#14113] RemoteTransport — the aggregation alias is escaped, not gated', () => {
let driver: TursoDriver;
let stub: LibsqlSqliteStub;

beforeAll(async () => {
stub = makeLibsqlSqliteStub();
driver = new TursoDriver({ url: 'libsql://alias-quoting.turso.io', client: asLibsqlClient(stub) });
await driver.connect();
// The mode this suite is about — the one with its own hand-written SQL.
expect(driver.transportMode).toBe('remote');
await driver.syncSchema(DELIVERY_OBJECT.name, DELIVERY_OBJECT);
for (const row of ROWS) await driver.create(DELIVERY_OBJECT.name, { ...row });
});

afterAll(async () => {
await driver.disconnect();
stub.close();
});

/**
* A transport backed by the same database, capturing the statements it sends
* AND executing them — the capture alone would not prove the statement runs.
*/
const capturing = async () => {
const seen: string[] = [];
const spy = {
...stub,
execute: async (stmt: unknown) => {
seen.push((stmt as { sql: string }).sql);
return stub.execute(stmt);
},
};
const t = new TursoDriver({ url: 'libsql://alias-quoting.turso.io', client: asLibsqlClient(spy) });
await t.connect();
return { t, seen };
};

describe('direction 1 — a dotted `CUBE.MEASURE` alias now reaches the database', () => {
it('the exact alias the card measured is served, on rows', async () => {
const rows = await driver.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
aggregations: [{ function: 'count', alias: 'showcase_delivery.count' }],
} as never);
// The value comes back under the caller's own key — a dot is inert
// inside a quoted identifier, which is the whole claim of this card.
expect(rows).toHaveLength(1);
expect((rows as Array<Record<string, unknown>>)[0]['showcase_delivery.count']).toBe(3);
});

it('compiles to ONE quoted identifier, dot and all', async () => {
const { t, seen } = await capturing();
await t.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
aggregations: [{ function: 'count', alias: 'showcase_delivery.count' }],
} as never);
expect(seen).toEqual([
'SELECT count(*) AS "showcase_delivery.count" FROM "showcase_delivery"',
]);
// ⛔ Not two segments. The failure this replaces is a face that treats an
// alias as a qualified reference; the dot must stay INSIDE the quotes.
expect(seen[0]).not.toContain('"showcase_delivery"."count"');
});

it('the un-bucketed cube shape — a grouped measure — is served end to end', async () => {
// The path that actually reaches `driver.aggregate`: remote mode
// publishes `queryDateGranularity: {}` (see `TursoDriver.supports`), so a
// BUCKETED query falls back to `find()` + in-memory bucketing and never
// arrives here. The UN-bucketed cube query is the one that ate the
// refusal, and it carries a dimension in `groupBy` beside the measure.
//
// ⭐ The groupBy FIELD is a bare column name here, not a dotted one, and
// that is measured rather than assumed: `ObjectQLStrategy.resolveFieldName`
// resolves a dimension to `member.sql` or `member.split('.')[1]`, so only
// the MEASURE arrives dotted. That asymmetry is why this card is confined
// to the alias position of `aggregations`.
const rows = await driver.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
groupBy: ['region'],
aggregations: [
{ function: 'count', alias: 'showcase_delivery.count' },
{ function: 'sum', field: 'amount', alias: 'showcase_delivery.total_amount' },
],
} as never);
const byRegion = Object.fromEntries(
(rows as Array<Record<string, unknown>>).map((r) => [
r.region,
[r['showcase_delivery.count'], r['showcase_delivery.total_amount']],
]),
);
expect(byRegion).toEqual({ west: [2, 30], east: [1, 30] });
});
});

describe('direction 2 — an alias carrying a `"` is ESCAPED, not let through', () => {
it('doubles the quote and still runs, returning the value under the literal alias', async () => {
const alias = 'won"count';
const { t, seen } = await capturing();
const rows = await t.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
aggregations: [{ function: 'count', alias }],
} as never);
expect(seen).toEqual(['SELECT count(*) AS "won""count" FROM "showcase_delivery"']);
// Executed, not merely emitted: an alias that broke out of its quoting
// would be a syntax error here rather than a row.
expect((rows as Array<Record<string, unknown>>)[0][alias]).toBe(3);
});

it('an alias that tries to close the quoting and append a statement stays one name', async () => {
const alias = 'bucket"; DROP TABLE showcase_delivery; --';
const { t, seen } = await capturing();
const rows = await t.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
aggregations: [{ function: 'count', alias }],
} as never);
expect(seen).toEqual([
'SELECT count(*) AS "bucket""; DROP TABLE showcase_delivery; --" FROM "showcase_delivery"',
]);
// The whole payload came back as a COLUMN NAME — it was data, never
// grammar.
expect((rows as Array<Record<string, unknown>>)[0][alias]).toBe(3);
// And the table it named is still there, with every row.
expect(
stub.raw.prepare('select count(*) as c from showcase_delivery').all(),
).toEqual([{ c: 3 }]);
});
});

describe('regression controls — the positions this card did NOT touch', () => {
it('the `field` position still refuses an unsafe identifier, and sends nothing', async () => {
// `SAFE_IDENTIFIER` is doing real work here: `field` becomes a column
// REFERENCE, which is grammar. Escaping is the answer for a NAME only.
const { t, seen } = await capturing();
const err = await t
.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
aggregations: [{ function: 'sum', field: 'amount"; DROP TABLE showcase_delivery; --', alias: 'n' }],
} as never)
.then(
() => { throw new Error('expected the transport to refuse an unsafe field'); },
(e) => e as Error,
);
// The OFFENDING TEXT, not just the sentence (#6144) — an alias that is
// itself safe is what makes this case reach the `field` check at all.
expect(err.message).toContain('unsafe identifier rejected');
expect(err.message).toContain('amount"; DROP TABLE showcase_delivery; --');
expect(seen).toEqual([]);
});

it('the `object` position still refuses an unsafe identifier', async () => {
const { t, seen } = await capturing();
const err = await t
.aggregate('showcase_delivery"; DROP TABLE showcase_delivery; --', {
object: 'showcase_delivery"; DROP TABLE showcase_delivery; --',
aggregations: [{ function: 'count', alias: 'n' }],
} as never)
.then(
() => { throw new Error('expected the transport to refuse an unsafe object') },
(e) => e as Error,
);
expect(err.message).toContain('unsafe identifier rejected');
expect(seen).toEqual([]);
});

it('the groupBy alias position is UNCHANGED — still refused, and that is a separate card', async () => {
// ⚠️ Deliberate scope line, pinned so it cannot drift silently. The
// groupBy `alias` is the same class of thing (an output NAME) and
// `driver-sql` escapes it post-#13714 (`aliasIdentifierSql` at its
// groupBy select site), so this face diverges there too — but that
// position carries a LANDED pin (#6401, `remote-transport-groupby-node`)
// asserting the refusal, so reversing it is a judgement this card was not
// dispatched to make. Filed separately rather than patched inline; this
// control records the state it was left in.
//
// It is also NOT on the reproducing path: `ObjectQLStrategy` resolves a
// dimension to a bare column name, so no analytics query sends a dotted
// groupBy alias.
const { t, seen } = await capturing();
const err = await t
.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
groupBy: [{ field: 'region', alias: 'showcase_delivery.region' }],
aggregations: [{ function: 'count', alias: 'showcase_delivery.count' }],
} as never)
.then(
() => { throw new Error('expected the groupBy alias to still be refused') },
(e) => e as Error,
);
expect(err.message).toContain('unsafe identifier rejected');
expect(err.message).toContain('showcase_delivery.region');
expect(seen).toEqual([]);
});

it('the default alias is byte-identical to what it was before', async () => {
// A caller who omits `alias` reads the result under `count_all` exactly
// as they did pre-#14113 — this change moves no default.
const { t, seen } = await capturing();
const rows = await t.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
aggregations: [{ function: 'count' }],
} as never);
expect(seen).toEqual(['SELECT count(*) AS "count_all" FROM "showcase_delivery"']);
expect((rows as Array<Record<string, unknown>>)[0].count_all).toBe(3);
});
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
36 changes: 36 additions & 0 deletions .changeset/turso-remote-aggregation-alias-escaped.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
---
"@objectstack/driver-turso": patch
---

fix(driver-turso): escape the aggregation alias instead of gating it, so remote-mode analytics cube queries stop 500ing (#14113)

`RemoteTransport.aggregate` (Turso **remote** mode) held the aggregation
`alias` to `SAFE_IDENTIFIER` (`/^[a-zA-Z_][a-zA-Z0-9_]*$/`). A dot fails that
regex. Every analytics measure is named `<cube>.<measure>` on the wire and
`ObjectQLStrategy` uses that name verbatim as the aggregation `alias`, so
**every** cube query that reached this face threw
`RemoteTransport: unsafe identifier rejected: "showcase_delivery.count"` — a
bare `Error` with no `code` and no `status`, which `mapDataError` then served
as an opaque 500 for a query that is spelled correctly.

The alias is now **escaped rather than gated**: it may be any string, and the
quote character is doubled (`"` → `""`), the standard escape inside a quoted
SQL identifier. This is the ALIAS half of the distinction `driver-sql` drew at
**#13714**, where the same position routes through knex's `wrapIdentifier`
(`SqlDriver.aliasIdentifierSql`) — a qualified **reference** must be
validated, a single output **name** must be quoted and escaped.
`AggregationNodeSchema` declares `alias: z.string()`, an output-column key,
and the in-memory, MongoDB and (post-#13714) SQL faces all project it
verbatim; this face was the outlier.

⛔ **Not** "drop the check". The alias reaches the statement raw inside
`AS "…"`, so an alias containing a `"` would close the quoting and continue as
grammar. `bucket"; DROP TABLE deal; --` now compiles to the single inert
column name `"bucket""; DROP TABLE deal; --"` and is returned as a column
name, executed against a real SQLite-backed client rather than asserted as a
string — the only instrument that tells "escaped" apart from "broke out".

The `field` and `object` positions keep `assertSafeIdentifier` unchanged: those
become column and table **references**, which are grammar. Default aliases are
byte-identical (`count_all` still spells itself the same way), and the
`groupBy` alias position is untouched by this change.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#14113] The aggregation ALIAS is escaped, not gated — TursoDriver's REMOTE
* transport.
*
* ## The defect
*
* `RemoteTransport.aggregate` held the aggregation `alias` to
* `SAFE_IDENTIFIER` (`/^[a-zA-Z_][a-zA-Z0-9_]*$/`). A dot fails that regex.
* Every analytics measure is named `<cube>.<measure>` on the wire and
* `ObjectQLStrategy` uses that name verbatim as the aggregation `alias`
* (`objectql-strategy.ts`, `{ field, method, alias: measure }`), so EVERY cube
* query that reached this face threw:
*
* ```
* RemoteTransport: unsafe identifier rejected: "showcase_delivery.count"
* ```
*
* a bare `Error` with no `code` and no `status`, which `mapDataError` then
* serves as an opaque 500 — the #11455 / #8931 shape.
*
* ## Why the fix is escaping and NOT dropping the check
*
* The alias is interpolated RAW into `AS "${alias}"`, so an alias containing a
* `"` would close the quoting and continue as grammar. The repair is the
* standard doubled-quote escape inside a quoted SQL identifier (`"` → `""`) —
* the ALIAS half of the distinction #13714 drew one face over, where
* `SqlDriver.aliasIdentifierSql` routes the same position through knex's
* `wrapIdentifier`. A qualified REFERENCE must be validated; a single output
* NAME must be quoted and escaped. `AggregationNodeSchema` declares
* `alias: z.string()` — an output-column key — and the in-memory, MongoDB and
* (post-#13714) SQL faces all project it verbatim. This face was the outlier.
*
* ## Why a SQLite-backed client stub rather than a mocked `execute`
*
* Only EXECUTING the statement tells "escaped" apart from "broke out". A
* string assertion alone would pass on an alias that terminates the quoting,
* because the text still *looks* like a select list. libsql IS SQLite, so
* `makeLibsqlSqliteStub` runs what this transport emits: an alias that escaped
* its quoting is a syntax error (or a second statement better-sqlite3 refuses
* to prepare), and a green read of the value back under the literal alias is
* the proof. The emitted SQL is pinned too, so a future reader can see the
* doubled quote rather than infer it.
*
* ## Reverse verification — direction predicted BEFORE it was run
*
* Restore `this.assertSafeIdentifier(alias)` above the `selectParts.push` and
* emit `AS "${alias}"` again (the pre-#14113 two lines):
*
* - the dotted-alias cases (repro, emitted SQL, the un-bucketed cube shape)
* go RED by THROWING inside the call — `unsafe identifier rejected:
* "showcase_delivery.count"` — not on a comparison.
* - the quote-escape cases go RED by throwing the same sentence, naming
* `won"count` / the `DROP TABLE` text. They cannot go red on a broken-out
* statement, because the restored guard refuses that input before any SQL is
* built — which is exactly why the guard could not simply be deleted.
* - the `field`-position control and the groupBy-alias control stay GREEN:
* neither position is touched by this card, and that is what they are here
* to hold.
* - the default-alias case stays GREEN: `count_all` passes `SAFE_IDENTIFIER`
* either way, so it pins the byte-identical emission across the change.
*
* Measured after writing the above — see the PR body for the run.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { TursoDriver } from './turso-driver.js';
import { makeLibsqlSqliteStub, asLibsqlClient, type LibsqlSqliteStub } from './libsql-sqlite-stub.testkit.js';

/**
* Named for the cube in the field report the card was filed from, so the alias
* under test (`showcase_delivery.count`) is the real wire spelling rather than
* a stand-in.
*/
const DELIVERY_OBJECT = {
name: 'showcase_delivery',
fields: {
id: { type: 'string' },
region: { type: 'string' },
amount: { type: 'number' },
},
};

const ROWS = [
{ id: '1', region: 'west', amount: 10 },
{ id: '2', region: 'west', amount: 20 },
{ id: '3', region: 'east', amount: 30 },
];

describe('[#14113] RemoteTransport — the aggregation alias is escaped, not gated', () => {
let driver: TursoDriver;
let stub: LibsqlSqliteStub;

beforeAll(async () => {
stub = makeLibsqlSqliteStub();
driver = new TursoDriver({ url: 'libsql://alias-quoting.turso.io', client: asLibsqlClient(stub) });
await driver.connect();
// The mode this suite is about — the one with its own hand-written SQL.
expect(driver.transportMode).toBe('remote');
await driver.syncSchema(DELIVERY_OBJECT.name, DELIVERY_OBJECT);
for (const row of ROWS) await driver.create(DELIVERY_OBJECT.name, { ...row });
});

afterAll(async () => {
await driver.disconnect();
stub.close();
});

/**
* A transport backed by the same database, capturing the statements it sends
* AND executing them — the capture alone would not prove the statement runs.
*/
const capturing = async () => {
const seen: string[] = [];
const spy = {
...stub,
execute: async (stmt: unknown) => {
seen.push((stmt as { sql: string }).sql);
return stub.execute(stmt);
},
};
const t = new TursoDriver({ url: 'libsql://alias-quoting.turso.io', client: asLibsqlClient(spy) });
await t.connect();
return { t, seen };
};

describe('direction 1 — a dotted `CUBE.MEASURE` alias now reaches the database', () => {
it('the exact alias the card measured is served, on rows', async () => {
const rows = await driver.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
aggregations: [{ function: 'count', alias: 'showcase_delivery.count' }],
} as never);
// The value comes back under the caller's own key — a dot is inert
// inside a quoted identifier, which is the whole claim of this card.
expect(rows).toHaveLength(1);
expect((rows as Array<Record<string, unknown>>)[0]['showcase_delivery.count']).toBe(3);
});

it('compiles to ONE quoted identifier, dot and all', async () => {
const { t, seen } = await capturing();
await t.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
aggregations: [{ function: 'count', alias: 'showcase_delivery.count' }],
} as never);
expect(seen).toEqual([
'SELECT count(*) AS "showcase_delivery.count" FROM "showcase_delivery"',
]);
// ⛔ Not two segments. The failure this replaces is a face that treats an
// alias as a qualified reference; the dot must stay INSIDE the quotes.
expect(seen[0]).not.toContain('"showcase_delivery"."count"');
});

it('the un-bucketed cube shape — a grouped measure — is served end to end', async () => {
// The path that actually reaches `driver.aggregate`: remote mode
// publishes `queryDateGranularity: {}` (see `TursoDriver.supports`), so a
// BUCKETED query falls back to `find()` + in-memory bucketing and never
// arrives here. The UN-bucketed cube query is the one that ate the
// refusal, and it carries a dimension in `groupBy` beside the measure.
//
// ⭐ The groupBy FIELD is a bare column name here, not a dotted one, and
// that is measured rather than assumed: `ObjectQLStrategy.resolveFieldName`
// resolves a dimension to `member.sql` or `member.split('.')[1]`, so only
// the MEASURE arrives dotted. That asymmetry is why this card is confined
// to the alias position of `aggregations`.
const rows = await driver.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
groupBy: ['region'],
aggregations: [
{ function: 'count', alias: 'showcase_delivery.count' },
{ function: 'sum', field: 'amount', alias: 'showcase_delivery.total_amount' },
],
} as never);
const byRegion = Object.fromEntries(
(rows as Array<Record<string, unknown>>).map((r) => [
r.region,
[r['showcase_delivery.count'], r['showcase_delivery.total_amount']],
]),
);
expect(byRegion).toEqual({ west: [2, 30], east: [1, 30] });
});
});

describe('direction 2 — an alias carrying a `"` is ESCAPED, not let through', () => {
it('doubles the quote and still runs, returning the value under the literal alias', async () => {
const alias = 'won"count';
const { t, seen } = await capturing();
const rows = await t.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
aggregations: [{ function: 'count', alias }],
} as never);
expect(seen).toEqual(['SELECT count(*) AS "won""count" FROM "showcase_delivery"']);
// Executed, not merely emitted: an alias that broke out of its quoting
// would be a syntax error here rather than a row.
expect((rows as Array<Record<string, unknown>>)[0][alias]).toBe(3);
});

it('an alias that tries to close the quoting and append a statement stays one name', async () => {
const alias = 'bucket"; DROP TABLE showcase_delivery; --';
const { t, seen } = await capturing();
const rows = await t.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
aggregations: [{ function: 'count', alias }],
} as never);
expect(seen).toEqual([
'SELECT count(*) AS "bucket""; DROP TABLE showcase_delivery; --" FROM "showcase_delivery"',
]);
// The whole payload came back as a COLUMN NAME — it was data, never
// grammar.
expect((rows as Array<Record<string, unknown>>)[0][alias]).toBe(3);
// And the table it named is still there, with every row.
expect(
stub.raw.prepare('select count(*) as c from showcase_delivery').all(),
).toEqual([{ c: 3 }]);
});
});

describe('regression controls — the positions this card did NOT touch', () => {
it('the `field` position still refuses an unsafe identifier, and sends nothing', async () => {
// `SAFE_IDENTIFIER` is doing real work here: `field` becomes a column
// REFERENCE, which is grammar. Escaping is the answer for a NAME only.
const { t, seen } = await capturing();
const err = await t
.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
aggregations: [{ function: 'sum', field: 'amount"; DROP TABLE showcase_delivery; --', alias: 'n' }],
} as never)
.then(
() => { throw new Error('expected the transport to refuse an unsafe field'); },
(e) => e as Error,
);
// The OFFENDING TEXT, not just the sentence (#6144) — an alias that is
// itself safe is what makes this case reach the `field` check at all.
expect(err.message).toContain('unsafe identifier rejected');
expect(err.message).toContain('amount"; DROP TABLE showcase_delivery; --');
expect(seen).toEqual([]);
});

it('the `object` position still refuses an unsafe identifier', async () => {
const { t, seen } = await capturing();
const err = await t
.aggregate('showcase_delivery"; DROP TABLE showcase_delivery; --', {
object: 'showcase_delivery"; DROP TABLE showcase_delivery; --',
aggregations: [{ function: 'count', alias: 'n' }],
} as never)
.then(
() => { throw new Error('expected the transport to refuse an unsafe object') },
(e) => e as Error,
);
expect(err.message).toContain('unsafe identifier rejected');
expect(seen).toEqual([]);
});

it('the groupBy alias position is UNCHANGED — still refused, and that is a separate card', async () => {
// ⚠️ Deliberate scope line, pinned so it cannot drift silently. The
// groupBy `alias` is the same class of thing (an output NAME) and
// `driver-sql` escapes it post-#13714 (`aliasIdentifierSql` at its
// groupBy select site), so this face diverges there too — but that
// position carries a LANDED pin (#6401, `remote-transport-groupby-node`)
// asserting the refusal, so reversing it is a judgement this card was not
// dispatched to make. Filed separately rather than patched inline; this
// control records the state it was left in.
//
// It is also NOT on the reproducing path: `ObjectQLStrategy` resolves a
// dimension to a bare column name, so no analytics query sends a dotted
// groupBy alias.
const { t, seen } = await capturing();
const err = await t
.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
groupBy: [{ field: 'region', alias: 'showcase_delivery.region' }],
aggregations: [{ function: 'count', alias: 'showcase_delivery.count' }],
} as never)
.then(
() => { throw new Error('expected the groupBy alias to still be refused') },
(e) => e as Error,
);
expect(err.message).toContain('unsafe identifier rejected');
expect(err.message).toContain('showcase_delivery.region');
expect(seen).toEqual([]);
});

it('the default alias is byte-identical to what it was before', async () => {
// A caller who omits `alias` reads the result under `count_all` exactly
// as they did pre-#14113 — this change moves no default.
const { t, seen } = await capturing();
const rows = await t.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
aggregations: [{ function: 'count' }],
} as never);
expect(seen).toEqual(['SELECT count(*) AS "count_all" FROM "showcase_delivery"']);
expect((rows as Array<Record<string, unknown>>)[0].count_all).toBe(3);
});
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
36 changes: 36 additions & 0 deletions .changeset/turso-remote-aggregation-alias-escaped.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
---
"@objectstack/driver-turso": patch
---

fix(driver-turso): escape the aggregation alias instead of gating it, so remote-mode analytics cube queries stop 500ing (#14113)

`RemoteTransport.aggregate` (Turso **remote** mode) held the aggregation
`alias` to `SAFE_IDENTIFIER` (`/^[a-zA-Z_][a-zA-Z0-9_]*$/`). A dot fails that
regex. Every analytics measure is named `<cube>.<measure>` on the wire and
`ObjectQLStrategy` uses that name verbatim as the aggregation `alias`, so
**every** cube query that reached this face threw
`RemoteTransport: unsafe identifier rejected: "showcase_delivery.count"` — a
bare `Error` with no `code` and no `status`, which `mapDataError` then served
as an opaque 500 for a query that is spelled correctly.

The alias is now **escaped rather than gated**: it may be any string, and the
quote character is doubled (`"` → `""`), the standard escape inside a quoted
SQL identifier. This is the ALIAS half of the distinction `driver-sql` drew at
**#13714**, where the same position routes through knex's `wrapIdentifier`
(`SqlDriver.aliasIdentifierSql`) — a qualified **reference** must be
validated, a single output **name** must be quoted and escaped.
`AggregationNodeSchema` declares `alias: z.string()`, an output-column key,
and the in-memory, MongoDB and (post-#13714) SQL faces all project it
verbatim; this face was the outlier.

⛔ **Not** "drop the check". The alias reaches the statement raw inside
`AS "…"`, so an alias containing a `"` would close the quoting and continue as
grammar. `bucket"; DROP TABLE deal; --` now compiles to the single inert
column name `"bucket""; DROP TABLE deal; --"` and is returned as a column
name, executed against a real SQLite-backed client rather than asserted as a
string — the only instrument that tells "escaped" apart from "broke out".

The `field` and `object` positions keep `assertSafeIdentifier` unchanged: those
become column and table **references**, which are grammar. Default aliases are
byte-identical (`count_all` still spells itself the same way), and the
`groupBy` alias position is untouched by this change.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#14113] The aggregation ALIAS is escaped, not gated — TursoDriver's REMOTE
* transport.
*
* ## The defect
*
* `RemoteTransport.aggregate` held the aggregation `alias` to
* `SAFE_IDENTIFIER` (`/^[a-zA-Z_][a-zA-Z0-9_]*$/`). A dot fails that regex.
* Every analytics measure is named `<cube>.<measure>` on the wire and
* `ObjectQLStrategy` uses that name verbatim as the aggregation `alias`
* (`objectql-strategy.ts`, `{ field, method, alias: measure }`), so EVERY cube
* query that reached this face threw:
*
* ```
* RemoteTransport: unsafe identifier rejected: "showcase_delivery.count"
* ```
*
* a bare `Error` with no `code` and no `status`, which `mapDataError` then
* serves as an opaque 500 — the #11455 / #8931 shape.
*
* ## Why the fix is escaping and NOT dropping the check
*
* The alias is interpolated RAW into `AS "${alias}"`, so an alias containing a
* `"` would close the quoting and continue as grammar. The repair is the
* standard doubled-quote escape inside a quoted SQL identifier (`"` → `""`) —
* the ALIAS half of the distinction #13714 drew one face over, where
* `SqlDriver.aliasIdentifierSql` routes the same position through knex's
* `wrapIdentifier`. A qualified REFERENCE must be validated; a single output
* NAME must be quoted and escaped. `AggregationNodeSchema` declares
* `alias: z.string()` — an output-column key — and the in-memory, MongoDB and
* (post-#13714) SQL faces all project it verbatim. This face was the outlier.
*
* ## Why a SQLite-backed client stub rather than a mocked `execute`
*
* Only EXECUTING the statement tells "escaped" apart from "broke out". A
* string assertion alone would pass on an alias that terminates the quoting,
* because the text still *looks* like a select list. libsql IS SQLite, so
* `makeLibsqlSqliteStub` runs what this transport emits: an alias that escaped
* its quoting is a syntax error (or a second statement better-sqlite3 refuses
* to prepare), and a green read of the value back under the literal alias is
* the proof. The emitted SQL is pinned too, so a future reader can see the
* doubled quote rather than infer it.
*
* ## Reverse verification — direction predicted BEFORE it was run
*
* Restore `this.assertSafeIdentifier(alias)` above the `selectParts.push` and
* emit `AS "${alias}"` again (the pre-#14113 two lines):
*
* - the dotted-alias cases (repro, emitted SQL, the un-bucketed cube shape)
* go RED by THROWING inside the call — `unsafe identifier rejected:
* "showcase_delivery.count"` — not on a comparison.
* - the quote-escape cases go RED by throwing the same sentence, naming
* `won"count` / the `DROP TABLE` text. They cannot go red on a broken-out
* statement, because the restored guard refuses that input before any SQL is
* built — which is exactly why the guard could not simply be deleted.
* - the `field`-position control and the groupBy-alias control stay GREEN:
* neither position is touched by this card, and that is what they are here
* to hold.
* - the default-alias case stays GREEN: `count_all` passes `SAFE_IDENTIFIER`
* either way, so it pins the byte-identical emission across the change.
*
* Measured after writing the above — see the PR body for the run.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { TursoDriver } from './turso-driver.js';
import { makeLibsqlSqliteStub, asLibsqlClient, type LibsqlSqliteStub } from './libsql-sqlite-stub.testkit.js';

/**
* Named for the cube in the field report the card was filed from, so the alias
* under test (`showcase_delivery.count`) is the real wire spelling rather than
* a stand-in.
*/
const DELIVERY_OBJECT = {
name: 'showcase_delivery',
fields: {
id: { type: 'string' },
region: { type: 'string' },
amount: { type: 'number' },
},
};

const ROWS = [
{ id: '1', region: 'west', amount: 10 },
{ id: '2', region: 'west', amount: 20 },
{ id: '3', region: 'east', amount: 30 },
];

describe('[#14113] RemoteTransport — the aggregation alias is escaped, not gated', () => {
let driver: TursoDriver;
let stub: LibsqlSqliteStub;

beforeAll(async () => {
stub = makeLibsqlSqliteStub();
driver = new TursoDriver({ url: 'libsql://alias-quoting.turso.io', client: asLibsqlClient(stub) });
await driver.connect();
// The mode this suite is about — the one with its own hand-written SQL.
expect(driver.transportMode).toBe('remote');
await driver.syncSchema(DELIVERY_OBJECT.name, DELIVERY_OBJECT);
for (const row of ROWS) await driver.create(DELIVERY_OBJECT.name, { ...row });
});

afterAll(async () => {
await driver.disconnect();
stub.close();
});

/**
* A transport backed by the same database, capturing the statements it sends
* AND executing them — the capture alone would not prove the statement runs.
*/
const capturing = async () => {
const seen: string[] = [];
const spy = {
...stub,
execute: async (stmt: unknown) => {
seen.push((stmt as { sql: string }).sql);
return stub.execute(stmt);
},
};
const t = new TursoDriver({ url: 'libsql://alias-quoting.turso.io', client: asLibsqlClient(spy) });
await t.connect();
return { t, seen };
};

describe('direction 1 — a dotted `CUBE.MEASURE` alias now reaches the database', () => {
it('the exact alias the card measured is served, on rows', async () => {
const rows = await driver.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
aggregations: [{ function: 'count', alias: 'showcase_delivery.count' }],
} as never);
// The value comes back under the caller's own key — a dot is inert
// inside a quoted identifier, which is the whole claim of this card.
expect(rows).toHaveLength(1);
expect((rows as Array<Record<string, unknown>>)[0]['showcase_delivery.count']).toBe(3);
});

it('compiles to ONE quoted identifier, dot and all', async () => {
const { t, seen } = await capturing();
await t.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
aggregations: [{ function: 'count', alias: 'showcase_delivery.count' }],
} as never);
expect(seen).toEqual([
'SELECT count(*) AS "showcase_delivery.count" FROM "showcase_delivery"',
]);
// ⛔ Not two segments. The failure this replaces is a face that treats an
// alias as a qualified reference; the dot must stay INSIDE the quotes.
expect(seen[0]).not.toContain('"showcase_delivery"."count"');
});

it('the un-bucketed cube shape — a grouped measure — is served end to end', async () => {
// The path that actually reaches `driver.aggregate`: remote mode
// publishes `queryDateGranularity: {}` (see `TursoDriver.supports`), so a
// BUCKETED query falls back to `find()` + in-memory bucketing and never
// arrives here. The UN-bucketed cube query is the one that ate the
// refusal, and it carries a dimension in `groupBy` beside the measure.
//
// ⭐ The groupBy FIELD is a bare column name here, not a dotted one, and
// that is measured rather than assumed: `ObjectQLStrategy.resolveFieldName`
// resolves a dimension to `member.sql` or `member.split('.')[1]`, so only
// the MEASURE arrives dotted. That asymmetry is why this card is confined
// to the alias position of `aggregations`.
const rows = await driver.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
groupBy: ['region'],
aggregations: [
{ function: 'count', alias: 'showcase_delivery.count' },
{ function: 'sum', field: 'amount', alias: 'showcase_delivery.total_amount' },
],
} as never);
const byRegion = Object.fromEntries(
(rows as Array<Record<string, unknown>>).map((r) => [
r.region,
[r['showcase_delivery.count'], r['showcase_delivery.total_amount']],
]),
);
expect(byRegion).toEqual({ west: [2, 30], east: [1, 30] });
});
});

describe('direction 2 — an alias carrying a `"` is ESCAPED, not let through', () => {
it('doubles the quote and still runs, returning the value under the literal alias', async () => {
const alias = 'won"count';
const { t, seen } = await capturing();
const rows = await t.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
aggregations: [{ function: 'count', alias }],
} as never);
expect(seen).toEqual(['SELECT count(*) AS "won""count" FROM "showcase_delivery"']);
// Executed, not merely emitted: an alias that broke out of its quoting
// would be a syntax error here rather than a row.
expect((rows as Array<Record<string, unknown>>)[0][alias]).toBe(3);
});

it('an alias that tries to close the quoting and append a statement stays one name', async () => {
const alias = 'bucket"; DROP TABLE showcase_delivery; --';
const { t, seen } = await capturing();
const rows = await t.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
aggregations: [{ function: 'count', alias }],
} as never);
expect(seen).toEqual([
'SELECT count(*) AS "bucket""; DROP TABLE showcase_delivery; --" FROM "showcase_delivery"',
]);
// The whole payload came back as a COLUMN NAME — it was data, never
// grammar.
expect((rows as Array<Record<string, unknown>>)[0][alias]).toBe(3);
// And the table it named is still there, with every row.
expect(
stub.raw.prepare('select count(*) as c from showcase_delivery').all(),
).toEqual([{ c: 3 }]);
});
});

describe('regression controls — the positions this card did NOT touch', () => {
it('the `field` position still refuses an unsafe identifier, and sends nothing', async () => {
// `SAFE_IDENTIFIER` is doing real work here: `field` becomes a column
// REFERENCE, which is grammar. Escaping is the answer for a NAME only.
const { t, seen } = await capturing();
const err = await t
.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
aggregations: [{ function: 'sum', field: 'amount"; DROP TABLE showcase_delivery; --', alias: 'n' }],
} as never)
.then(
() => { throw new Error('expected the transport to refuse an unsafe field'); },
(e) => e as Error,
);
// The OFFENDING TEXT, not just the sentence (#6144) — an alias that is
// itself safe is what makes this case reach the `field` check at all.
expect(err.message).toContain('unsafe identifier rejected');
expect(err.message).toContain('amount"; DROP TABLE showcase_delivery; --');
expect(seen).toEqual([]);
});

it('the `object` position still refuses an unsafe identifier', async () => {
const { t, seen } = await capturing();
const err = await t
.aggregate('showcase_delivery"; DROP TABLE showcase_delivery; --', {
object: 'showcase_delivery"; DROP TABLE showcase_delivery; --',
aggregations: [{ function: 'count', alias: 'n' }],
} as never)
.then(
() => { throw new Error('expected the transport to refuse an unsafe object') },
(e) => e as Error,
);
expect(err.message).toContain('unsafe identifier rejected');
expect(seen).toEqual([]);
});

it('the groupBy alias position is UNCHANGED — still refused, and that is a separate card', async () => {
// ⚠️ Deliberate scope line, pinned so it cannot drift silently. The
// groupBy `alias` is the same class of thing (an output NAME) and
// `driver-sql` escapes it post-#13714 (`aliasIdentifierSql` at its
// groupBy select site), so this face diverges there too — but that
// position carries a LANDED pin (#6401, `remote-transport-groupby-node`)
// asserting the refusal, so reversing it is a judgement this card was not
// dispatched to make. Filed separately rather than patched inline; this
// control records the state it was left in.
//
// It is also NOT on the reproducing path: `ObjectQLStrategy` resolves a
// dimension to a bare column name, so no analytics query sends a dotted
// groupBy alias.
const { t, seen } = await capturing();
const err = await t
.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
groupBy: [{ field: 'region', alias: 'showcase_delivery.region' }],
aggregations: [{ function: 'count', alias: 'showcase_delivery.count' }],
} as never)
.then(
() => { throw new Error('expected the groupBy alias to still be refused') },
(e) => e as Error,
);
expect(err.message).toContain('unsafe identifier rejected');
expect(err.message).toContain('showcase_delivery.region');
expect(seen).toEqual([]);
});

it('the default alias is byte-identical to what it was before', async () => {
// A caller who omits `alias` reads the result under `count_all` exactly
// as they did pre-#14113 — this change moves no default.
const { t, seen } = await capturing();
const rows = await t.aggregate(DELIVERY_OBJECT.name, {
object: DELIVERY_OBJECT.name,
aggregations: [{ function: 'count' }],
} as never);
expect(seen).toEqual(['SELECT count(*) AS "count_all" FROM "showcase_delivery"']);
expect((rows as Array<Record<string, unknown>>)[0].count_all).toBe(3);
});
});
});
Loading
Loading