From 08711791d61ad673f3ec89bd6ee7caa475d7a970 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Thu, 23 Apr 2026 16:05:45 +0100 Subject: [PATCH] feat(base): add baseSchemaName helper for duckdb schema naming --- .../core/base/query-cache/schema-name.spec.ts | 34 +++++++++++++++++++ .../src/core/base/query-cache/schema-name.ts | 31 +++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 apps/server/src/core/base/query-cache/schema-name.spec.ts create mode 100644 apps/server/src/core/base/query-cache/schema-name.ts diff --git a/apps/server/src/core/base/query-cache/schema-name.spec.ts b/apps/server/src/core/base/query-cache/schema-name.spec.ts new file mode 100644 index 00000000..2576f991 --- /dev/null +++ b/apps/server/src/core/base/query-cache/schema-name.spec.ts @@ -0,0 +1,34 @@ +import { baseSchemaName } from './schema-name'; + +describe('baseSchemaName', () => { + it('converts a uuid to a DuckDB-safe identifier with a b_ prefix', () => { + expect(baseSchemaName('019c69a5-1d84-7985-a7f6-8ee2871d8669')).toBe( + 'b_019c69a51d847985a7f68ee2871d8669', + ); + }); + + it('rejects a non-uuid string (preserves the quoting contract)', () => { + expect(() => baseSchemaName('not-a-uuid')).toThrow(/invalid base id/i); + expect(() => baseSchemaName('')).toThrow(/invalid base id/i); + expect(() => baseSchemaName('b_019c69a5; DROP TABLE rows; --')).toThrow( + /invalid base id/i, + ); + }); + + it('is deterministic', () => { + const id = '019c70b3-dd47-7014-8b87-ec8f167577ee'; + expect(baseSchemaName(id)).toBe(baseSchemaName(id)); + }); + + it('accepts mixed-case hex and normalises to lowercase', () => { + expect(baseSchemaName('019C69A5-1D84-7985-A7F6-8EE2871D8669')).toBe( + 'b_019c69a51d847985a7f68ee2871d8669', + ); + }); + + it('produces names that parse as SQL identifiers without quoting', () => { + const name = baseSchemaName('019c69a5-1d84-7985-a7f6-8ee2871d8669'); + // Must match DuckDB's unquoted-identifier grammar: [a-zA-Z_][a-zA-Z0-9_]* + expect(name).toMatch(/^[a-zA-Z_][a-zA-Z0-9_]*$/); + }); +}); diff --git a/apps/server/src/core/base/query-cache/schema-name.ts b/apps/server/src/core/base/query-cache/schema-name.ts new file mode 100644 index 00000000..434f36f8 --- /dev/null +++ b/apps/server/src/core/base/query-cache/schema-name.ts @@ -0,0 +1,31 @@ +// Matches the UUID regex pattern in `loader-sql.ts`. We use a handwritten +// regex rather than importing `validate` from the `uuid` package because +// that package is ESM-only and Jest's ts-jest config cannot transform it +// in this repo. +const UUID = + /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; + +const UUID_DASHES = /-/g; + +/* + * Turns a base UUID into a DuckDB-safe schema name. + * + * '019c69a5-1d84-7985-a7f6-8ee2871d8669' + * -> 'b_019c69a51d847985a7f68ee2871d8669' + * + * The `b_` prefix is required because DuckDB unquoted identifiers must start + * with a letter or underscore — a bare hex UUID starts with a digit and would + * have to be double-quoted everywhere. The strip-dashes step makes the rest + * of the identifier hex-only, which is always safe. + * + * All attached database names, `DETACH DATABASE` targets, and schema-qualified + * references (`.rows`) run through this function. Validation is + * strict: if the input isn't a real UUID, we throw rather than produce a + * "safe-looking" identifier that might leak through to user-facing SQL. + */ +export function baseSchemaName(baseId: string): string { + if (!UUID.test(baseId)) { + throw new Error(`Invalid base id "${baseId}"`); + } + return `b_${baseId.toLowerCase().replace(UUID_DASHES, '')}`; +}